e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
831 lines
30 KiB
Python
831 lines
30 KiB
Python
"""Branch-isolated, cumulative source inventory for the chat capability.
|
|
|
|
The chat pipeline shows the LLM an "Attached Sources" manifest each turn so
|
|
it can decide whether to call ``read_source(id)`` for full text. Historically
|
|
this manifest only listed sources the user attached in the *current* turn —
|
|
so the model forgot anything uploaded in earlier turns unless re-attached.
|
|
|
|
This module materialises the manifest as a **session-cumulative inventory**:
|
|
|
|
* Sources attached in the current turn (the "fresh" set) get a full preview
|
|
in the manifest, just like before.
|
|
* Sources attached in *prior* turns on the active branch's ancestor chain
|
|
(the "historical" set) get a compact one-line row: id, name, kind, size,
|
|
and the turn ordinal where they first appeared. The LLM can call
|
|
``read_source(id)`` to load full text when the question warrants it.
|
|
|
|
Both sets dedupe by source id; fresh always wins on collision. Branch
|
|
isolation is enforced by walking ``parent_message_id`` from the active
|
|
branch's leaf, so sibling branches never leak sources into each other.
|
|
|
|
The output is decoupled from the rest of ``turn_runtime``:
|
|
|
|
inventory = await build_inventory(store, ..., fresh_*=...)
|
|
manifest_text, source_index = render_manifest(inventory)
|
|
|
|
``source_index`` is the per-turn ``{source_id: full_text}`` map handed to
|
|
``ReadSourceTool`` via tool-call kwargs injection.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
import logging
|
|
from typing import Any, Sequence
|
|
|
|
from deeptutor.services.session.protocol import SessionStoreProtocol
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Per-source text-preview caps. Fresh sources get a meaningful preview so
|
|
# the model can answer simple "is this the right one?" questions without
|
|
# read_source. Historical sources surface only their identity — the model
|
|
# pays the read_source cost only when it actually needs them.
|
|
MANIFEST_PREVIEW_CHARS_FRESH = 2000
|
|
# Image attachments flow through the multimodal block path; never list them.
|
|
_IMAGE_MIME_PREFIX = "image/"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SourceEntry:
|
|
"""One row in the per-turn Attached Sources manifest."""
|
|
|
|
sid: str
|
|
kind: str # "notebook" | "book" | "history" | "question" | "attachment"
|
|
name: str
|
|
full_text: str
|
|
fresh: bool
|
|
# 1-indexed ordinal of the user turn this source first appeared in,
|
|
# within the active branch's lineage. Fresh sources use the **current**
|
|
# turn's ordinal so the manifest can label them consistently.
|
|
first_seen_turn: int
|
|
|
|
@property
|
|
def char_count(self) -> int:
|
|
return len(self.full_text)
|
|
|
|
|
|
@dataclass
|
|
class SourceInventory:
|
|
"""Ordered set of ``SourceEntry`` keyed by ``sid``.
|
|
|
|
``add`` is the only mutator. On duplicate ``sid`` the existing entry is
|
|
upgraded to ``fresh=True`` if the incoming entry is fresh — so a source
|
|
attached in the current turn always renders with a preview even if it
|
|
was also attached in a prior turn.
|
|
"""
|
|
|
|
entries: list[SourceEntry] = field(default_factory=list)
|
|
_index: dict[str, int] = field(default_factory=dict, repr=False)
|
|
|
|
def add(self, entry: SourceEntry) -> None:
|
|
if not entry.sid:
|
|
return
|
|
if not entry.full_text.strip():
|
|
return
|
|
existing_pos = self._index.get(entry.sid)
|
|
if existing_pos is None:
|
|
self._index[entry.sid] = len(self.entries)
|
|
self.entries.append(entry)
|
|
return
|
|
existing = self.entries[existing_pos]
|
|
# Fresh always wins; otherwise keep the earlier registration.
|
|
if entry.fresh and not existing.fresh:
|
|
self.entries[existing_pos] = entry
|
|
|
|
def is_empty(self) -> bool:
|
|
return not self.entries
|
|
|
|
def __contains__(self, sid: str) -> bool:
|
|
return sid in self._index
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API: build + render
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def build_inventory(
|
|
store: SessionStoreProtocol,
|
|
*,
|
|
session_id: str,
|
|
leaf_message_id: int | None,
|
|
current_turn_ordinal: int,
|
|
fresh_attachment_records: Sequence[dict[str, Any]],
|
|
fresh_notebook_records: Sequence[dict[str, Any]],
|
|
fresh_book_context_text: str,
|
|
fresh_book_references: Sequence[dict[str, Any]],
|
|
fresh_history_session_ids: Sequence[Any],
|
|
fresh_question_entry_ids: Sequence[Any],
|
|
language: str = "en",
|
|
) -> SourceInventory:
|
|
"""Compose the session-cumulative inventory for one chat turn.
|
|
|
|
Fresh refs are added first (so they shadow historical entries on the
|
|
same sid); historical refs are then collected from the active branch's
|
|
ancestor messages. The caller passes already-resolved notebook records
|
|
and the already-rendered book context — keeping side-effects (LLM
|
|
summarisation, file I/O) under the caller's control rather than buried
|
|
inside this module.
|
|
"""
|
|
inv = SourceInventory()
|
|
_add_fresh(
|
|
inv,
|
|
current_turn_ordinal=current_turn_ordinal,
|
|
attachment_records=fresh_attachment_records,
|
|
notebook_records=fresh_notebook_records,
|
|
book_context_text=fresh_book_context_text,
|
|
book_references=fresh_book_references,
|
|
)
|
|
# History + question entries are async (per-id store fetches), keep them
|
|
# in a separate phase so the sync fresh additions don't block.
|
|
await _add_fresh_history(
|
|
inv,
|
|
store=store,
|
|
history_session_ids=fresh_history_session_ids,
|
|
current_turn_ordinal=current_turn_ordinal,
|
|
language=language,
|
|
)
|
|
await _add_fresh_questions(
|
|
inv,
|
|
store=store,
|
|
question_entry_ids=fresh_question_entry_ids,
|
|
current_turn_ordinal=current_turn_ordinal,
|
|
)
|
|
await _add_historical(
|
|
inv,
|
|
store=store,
|
|
session_id=session_id,
|
|
leaf_message_id=leaf_message_id,
|
|
language=language,
|
|
)
|
|
return inv
|
|
|
|
|
|
def render_manifest(inv: SourceInventory) -> tuple[str, dict[str, str]]:
|
|
"""Render the inventory into (manifest_text, source_index).
|
|
|
|
``manifest_text`` is the human/LLM-readable block injected at the tail
|
|
of the chat system prompt. ``source_index`` maps each source id to its
|
|
full extracted text and is handed to ``ReadSourceTool`` so the LLM can
|
|
read on demand.
|
|
"""
|
|
if inv.is_empty():
|
|
return "", {}
|
|
|
|
source_index: dict[str, str] = {sid: e.full_text for sid, e in _iter_sid_entries(inv)}
|
|
rendered_rows: list[str] = []
|
|
for entry in inv.entries:
|
|
rendered_rows.append(_render_row(entry))
|
|
|
|
header = (
|
|
"[Attached Sources]\n"
|
|
"An index of the sources the user has attached in this conversation. "
|
|
"Rows with a `preview` field were attached **this turn**; rows marked "
|
|
"`previously attached (turn N)` were uploaded in earlier turns and show "
|
|
"only their identity. Their full text can be loaded on demand when a "
|
|
"source is relevant. Refer to sources by name; never invent source ids."
|
|
)
|
|
return header + "\n\n" + "\n\n".join(rendered_rows), source_index
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _iter_sid_entries(inv: SourceInventory):
|
|
seen: set[str] = set()
|
|
for e in inv.entries:
|
|
if e.sid in seen:
|
|
continue
|
|
seen.add(e.sid)
|
|
yield e.sid, e
|
|
|
|
|
|
def _clip_preview(text: str, limit: int = MANIFEST_PREVIEW_CHARS_FRESH) -> str:
|
|
cleaned = (text or "").strip()
|
|
if len(cleaned) <= limit:
|
|
return cleaned
|
|
return cleaned[:limit].rstrip() + "…"
|
|
|
|
|
|
def _format_size(char_count: int) -> str:
|
|
"""Compact size hint for historical rows ('~3 KB', '~120 chars')."""
|
|
if char_count >= 1024:
|
|
return f"~{round(char_count / 1024)} KB"
|
|
return f"~{char_count} chars"
|
|
|
|
|
|
def _render_row(entry: SourceEntry) -> str:
|
|
if entry.fresh:
|
|
preview = _clip_preview(entry.full_text)
|
|
return f"- id={entry.sid} type={entry.kind} name={entry.name!r}\n preview: {preview!r}"
|
|
return (
|
|
f"- id={entry.sid} type={entry.kind} name={entry.name!r}"
|
|
f" size={_format_size(entry.char_count)} "
|
|
f"source: previously attached (turn {entry.first_seen_turn})"
|
|
)
|
|
|
|
|
|
# ----- Fresh source addition (current-turn payload) -----------------------
|
|
|
|
|
|
def _add_fresh(
|
|
inv: SourceInventory,
|
|
*,
|
|
current_turn_ordinal: int,
|
|
attachment_records: Sequence[dict[str, Any]],
|
|
notebook_records: Sequence[dict[str, Any]],
|
|
book_context_text: str,
|
|
book_references: Sequence[dict[str, Any]],
|
|
) -> None:
|
|
"""Add the synchronously-available fresh sources (notebook records,
|
|
book pages, attachments)."""
|
|
for rec in notebook_records:
|
|
rid = str(rec.get("id", "") or "").strip()
|
|
full = str(rec.get("output", "") or "")
|
|
if not rid or not full.strip():
|
|
continue
|
|
inv.add(
|
|
SourceEntry(
|
|
sid=f"nb-{rid}",
|
|
kind="notebook",
|
|
name=str(rec.get("title") or rec.get("name") or "Untitled record"),
|
|
full_text=full,
|
|
fresh=True,
|
|
first_seen_turn=current_turn_ordinal,
|
|
)
|
|
)
|
|
|
|
# Books: split the cumulative ``build_book_context`` output by the
|
|
# ``---`` section separator so each book gets its own ``bk-{book_id}``
|
|
# sid. The order in ``book_references`` matches the order
|
|
# ``build_book_context`` produces sections in, so we can zip them.
|
|
book_sections = _split_book_sections(book_context_text)
|
|
for ref, section in zip(book_references, book_sections, strict=False):
|
|
book_id = str(ref.get("book_id") or "").strip()
|
|
if not book_id or not section.strip():
|
|
continue
|
|
inv.add(
|
|
SourceEntry(
|
|
sid=f"bk-{book_id}",
|
|
kind="book",
|
|
name=_extract_book_title(section, fallback=f"Book {book_id}"),
|
|
full_text=section,
|
|
fresh=True,
|
|
first_seen_turn=current_turn_ordinal,
|
|
)
|
|
)
|
|
|
|
for record in attachment_records:
|
|
if str(record.get("type", "")).lower() == "image":
|
|
continue
|
|
mime = str(record.get("mime_type", "")).lower()
|
|
if mime.startswith(_IMAGE_MIME_PREFIX):
|
|
continue
|
|
text = str(record.get("extracted_text", "") or "")
|
|
att_id = str(record.get("id", "") or "").strip()
|
|
if not text.strip() or not att_id:
|
|
continue
|
|
inv.add(
|
|
SourceEntry(
|
|
sid=f"at-{att_id}",
|
|
kind="attachment",
|
|
name=str(record.get("filename") or "Untitled file"),
|
|
full_text=text,
|
|
fresh=True,
|
|
first_seen_turn=current_turn_ordinal,
|
|
)
|
|
)
|
|
|
|
|
|
async def _add_fresh_history(
|
|
inv: SourceInventory,
|
|
*,
|
|
store: SessionStoreProtocol,
|
|
history_session_ids: Sequence[Any],
|
|
current_turn_ordinal: int,
|
|
language: str = "en",
|
|
) -> None:
|
|
for raw in history_session_ids:
|
|
hs_id = str(raw or "").strip()
|
|
if not hs_id:
|
|
continue
|
|
text, name = await _load_history_session(store, hs_id, language=language)
|
|
if not text:
|
|
continue
|
|
inv.add(
|
|
SourceEntry(
|
|
sid=f"hs-{hs_id}",
|
|
kind="history",
|
|
name=name,
|
|
full_text=text,
|
|
fresh=True,
|
|
first_seen_turn=current_turn_ordinal,
|
|
)
|
|
)
|
|
|
|
|
|
async def _add_fresh_questions(
|
|
inv: SourceInventory,
|
|
*,
|
|
store: SessionStoreProtocol,
|
|
question_entry_ids: Sequence[Any],
|
|
current_turn_ordinal: int,
|
|
) -> None:
|
|
get_entry = getattr(store, "get_notebook_entry", None)
|
|
if not callable(get_entry):
|
|
return
|
|
for raw in question_entry_ids:
|
|
try:
|
|
eid = int(raw)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
block, stem = await _load_question_entry(store, eid)
|
|
if not block:
|
|
continue
|
|
inv.add(
|
|
SourceEntry(
|
|
sid=f"qb-{eid}",
|
|
kind="question",
|
|
name=stem,
|
|
full_text=block,
|
|
fresh=True,
|
|
first_seen_turn=current_turn_ordinal,
|
|
)
|
|
)
|
|
|
|
|
|
# ----- Historical source collection ---------------------------------------
|
|
|
|
|
|
async def _add_historical(
|
|
inv: SourceInventory,
|
|
*,
|
|
store: SessionStoreProtocol,
|
|
session_id: str,
|
|
leaf_message_id: int | None,
|
|
language: str = "en",
|
|
) -> None:
|
|
"""Walk the active branch's ancestor user messages and pull in
|
|
references they carried. Sources already in ``inv`` (i.e. fresh
|
|
duplicates) are skipped — fresh entries always win."""
|
|
lineage = await _load_lineage(store, session_id, leaf_message_id)
|
|
user_turn_ordinal = 0
|
|
for msg in lineage:
|
|
if msg.get("role") != "user":
|
|
continue
|
|
user_turn_ordinal += 1
|
|
await _collect_from_user_message(
|
|
inv, store=store, msg=msg, turn_ordinal=user_turn_ordinal, language=language
|
|
)
|
|
|
|
|
|
async def _collect_from_user_message(
|
|
inv: SourceInventory,
|
|
*,
|
|
store: SessionStoreProtocol,
|
|
msg: dict[str, Any],
|
|
turn_ordinal: int,
|
|
language: str = "en",
|
|
) -> None:
|
|
"""Drain one prior user message into the inventory as historical
|
|
entries. Attachments are pulled from the persisted ``attachments``
|
|
JSON; space refs are pulled from ``metadata.request_snapshot`` and
|
|
re-resolved through their respective services so the historical
|
|
full text always reflects the current state of the referenced object.
|
|
"""
|
|
# Attachments — extracted_text was persisted at upload time, no
|
|
# external lookup needed.
|
|
for att in msg.get("attachments") or []:
|
|
att_id = str(att.get("id", "") or "").strip()
|
|
if not att_id:
|
|
continue
|
|
sid = f"at-{att_id}"
|
|
if sid in inv:
|
|
continue
|
|
mime = str(att.get("mime_type", "")).lower()
|
|
if mime.startswith(_IMAGE_MIME_PREFIX):
|
|
continue
|
|
text = str(att.get("extracted_text") or "")
|
|
if not text.strip():
|
|
continue
|
|
inv.add(
|
|
SourceEntry(
|
|
sid=sid,
|
|
kind="attachment",
|
|
name=str(att.get("filename") or "Untitled file"),
|
|
full_text=text,
|
|
fresh=False,
|
|
first_seen_turn=turn_ordinal,
|
|
)
|
|
)
|
|
|
|
snap = (msg.get("metadata") or {}).get("request_snapshot") or {}
|
|
if not isinstance(snap, dict):
|
|
return
|
|
|
|
# Notebook records — re-resolve through the notebook service.
|
|
notebook_refs = snap.get("notebookReferences") or []
|
|
if notebook_refs:
|
|
from deeptutor.services.notebook import get_notebook_manager
|
|
|
|
try:
|
|
records = get_notebook_manager().get_records_by_references(list(notebook_refs))
|
|
except Exception:
|
|
records = []
|
|
for rec in records:
|
|
rid = str(rec.get("id", "") or "").strip()
|
|
if not rid:
|
|
continue
|
|
sid = f"nb-{rid}"
|
|
if sid in inv:
|
|
continue
|
|
full = str(rec.get("output", "") or "")
|
|
if not full.strip():
|
|
continue
|
|
inv.add(
|
|
SourceEntry(
|
|
sid=sid,
|
|
kind="notebook",
|
|
name=str(rec.get("title") or rec.get("name") or "Untitled record"),
|
|
full_text=full,
|
|
fresh=False,
|
|
first_seen_turn=turn_ordinal,
|
|
)
|
|
)
|
|
|
|
# Books — one source per book_id (union of page ranges across all
|
|
# turns is implicit because we always pull the *current* book reference
|
|
# to render).
|
|
for ref in snap.get("bookReferences") or []:
|
|
book_id = str((ref or {}).get("book_id") or "").strip()
|
|
if not book_id:
|
|
continue
|
|
sid = f"bk-{book_id}"
|
|
if sid in inv:
|
|
continue
|
|
section_text, name = _resolve_book_section(ref)
|
|
if not section_text.strip():
|
|
continue
|
|
inv.add(
|
|
SourceEntry(
|
|
sid=sid,
|
|
kind="book",
|
|
name=name,
|
|
full_text=section_text,
|
|
fresh=False,
|
|
first_seen_turn=turn_ordinal,
|
|
)
|
|
)
|
|
|
|
# History sessions — async, one store fetch per id.
|
|
for raw in snap.get("historyReferences") or []:
|
|
hs_id = str(raw or "").strip()
|
|
if not hs_id:
|
|
continue
|
|
sid = f"hs-{hs_id}"
|
|
if sid in inv:
|
|
continue
|
|
text, name = await _load_history_session(store, hs_id, language=language)
|
|
if not text:
|
|
continue
|
|
inv.add(
|
|
SourceEntry(
|
|
sid=sid,
|
|
kind="history",
|
|
name=name,
|
|
full_text=text,
|
|
fresh=False,
|
|
first_seen_turn=turn_ordinal,
|
|
)
|
|
)
|
|
|
|
# Question-bank entries.
|
|
for raw in snap.get("questionNotebookReferences") or []:
|
|
try:
|
|
eid = int(raw)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
sid = f"qb-{eid}"
|
|
if sid in inv:
|
|
continue
|
|
block, stem = await _load_question_entry(store, eid)
|
|
if not block:
|
|
continue
|
|
inv.add(
|
|
SourceEntry(
|
|
sid=sid,
|
|
kind="question",
|
|
name=stem,
|
|
full_text=block,
|
|
fresh=False,
|
|
first_seen_turn=turn_ordinal,
|
|
)
|
|
)
|
|
|
|
|
|
# ----- Lineage walker (branch-safe, store-protocol-compatible) ------------
|
|
|
|
|
|
async def _load_lineage(
|
|
store: SessionStoreProtocol,
|
|
session_id: str,
|
|
leaf_message_id: int | None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Return the active branch's ancestor user/assistant messages in
|
|
chronological order. When ``leaf_message_id`` is ``None`` (legacy
|
|
linear append), returns every message in the session. When it's set
|
|
(branched edit), walks the ``parent_message_id`` chain up from that
|
|
leaf so sibling branches are excluded. Uses ``get_messages`` (which
|
|
every store implements) plus a Python-side parent walk, avoiding a
|
|
sqlite-only ``get_message_path``.
|
|
"""
|
|
all_msgs = await store.get_messages(session_id)
|
|
if leaf_message_id is None:
|
|
return all_msgs
|
|
by_id: dict[int, dict[str, Any]] = {}
|
|
for m in all_msgs:
|
|
mid = m.get("id")
|
|
if mid is not None:
|
|
by_id[int(mid)] = m
|
|
chain: list[dict[str, Any]] = []
|
|
current: int | None = int(leaf_message_id)
|
|
safety = 10_000
|
|
while current is not None and safety > 0:
|
|
m = by_id.get(int(current))
|
|
if m is None:
|
|
break
|
|
chain.append(m)
|
|
parent = m.get("parent_message_id")
|
|
current = int(parent) if parent is not None else None
|
|
safety -= 1
|
|
chain.reverse()
|
|
return chain
|
|
|
|
|
|
# ----- Per-type resolvers shared by fresh + historical paths --------------
|
|
|
|
|
|
def _split_book_sections(book_context_text: str) -> list[str]:
|
|
"""Split the output of ``build_book_context`` back into per-book
|
|
sections. ``build_book_context`` joins sections with ``"\\n\\n---\\n\\n"``;
|
|
we split on the same separator. Returns an empty list when input is
|
|
empty."""
|
|
if not book_context_text.strip():
|
|
return []
|
|
return [seg for seg in book_context_text.split("\n\n---\n\n") if seg.strip()]
|
|
|
|
|
|
def _extract_book_title(section: str, *, fallback: str) -> str:
|
|
"""``_serialize_book_header`` prefixes every section with
|
|
``# Book: <title>`` — extract that here for the manifest's name field."""
|
|
first_line = section.lstrip().split("\n", 1)[0]
|
|
prefix = "# Book: "
|
|
if first_line.startswith(prefix):
|
|
return first_line[len(prefix) :].strip() or fallback
|
|
return fallback
|
|
|
|
|
|
def _resolve_book_section(book_reference: dict[str, Any]) -> tuple[str, str]:
|
|
"""Resolve a single book reference into its serialized section + title.
|
|
|
|
Used by the historical-collection path where each past turn's book
|
|
reference is rendered independently (so the per-book ``bk-{book_id}``
|
|
source id stays stable). Returns ``("", "")`` on failure.
|
|
"""
|
|
from deeptutor.book.context import build_book_context
|
|
|
|
try:
|
|
result = build_book_context([book_reference])
|
|
except Exception:
|
|
logger.debug("Failed to resolve historical book reference", exc_info=True)
|
|
return "", ""
|
|
text = (result.text or "").strip()
|
|
if not text:
|
|
return "", ""
|
|
name = _extract_book_title(text, fallback=f"Book {book_reference.get('book_id', '?')}")
|
|
return text, name
|
|
|
|
|
|
# Human labels for the external agents a session can be imported from. The
|
|
# import source is recorded at import time in ``preferences["import"]["source"]``
|
|
# (see ``deeptutor/api/routers/imports.py``).
|
|
_EXTERNAL_AGENT_LABELS: dict[str, str] = {
|
|
"claude_code": "Claude Code",
|
|
"codex": "Codex",
|
|
}
|
|
|
|
|
|
def _imported_agent_label(meta: dict[str, Any], lang: str) -> str | None:
|
|
"""Return a human label for the external agent a session was imported from,
|
|
or ``None`` when the session is *not* an imported external-agent transcript.
|
|
|
|
A referenced session is "imported" when its id carries the ``imported_``
|
|
prefix or its preferences hold the ``import`` block written at import time.
|
|
A referenced *partner* session (resolved by :func:`_load_partner_session`)
|
|
carries ``source == "partner"`` and is framed with the partner's own name.
|
|
"""
|
|
prefs = meta.get("preferences") if isinstance(meta, dict) else None
|
|
import_meta = prefs.get("import") if isinstance(prefs, dict) else None
|
|
source = str((import_meta or {}).get("source") or "").strip().lower()
|
|
sid = str(meta.get("session_id") or meta.get("id") or "")
|
|
if source == "partner":
|
|
name = str(meta.get("partner_name") or "").strip()
|
|
return name or ("伙伴" if lang == "zh" else "a partner")
|
|
if not source and not sid.startswith("imported_"):
|
|
return None
|
|
if source in _EXTERNAL_AGENT_LABELS:
|
|
return _EXTERNAL_AGENT_LABELS[source]
|
|
return "外部 AI 助手" if lang == "zh" else "an external AI assistant"
|
|
|
|
|
|
def serialize_referenced_transcript(
|
|
meta: dict[str, Any],
|
|
messages: Sequence[dict[str, Any]],
|
|
*,
|
|
language: str = "en",
|
|
) -> str:
|
|
"""Serialize a *referenced* conversation into a clearly-framed transcript.
|
|
|
|
A referenced session is material the user attached for the assistant to
|
|
read and discuss — it is **not** the current conversation. Two failure
|
|
modes motivated this framing:
|
|
|
|
* An imported session is a transcript of the user talking to a *different*
|
|
AI agent. Rendered as bare ``## Assistant`` turns it reads exactly like
|
|
the model's own past replies, so the model adopts that agent's first
|
|
person voice and even claims its actions as its own.
|
|
* Even a referenced *native* session is a separate conversation, not the
|
|
current one.
|
|
|
|
The fix is structural: prepend an explicit boundary header and name the
|
|
other party (the external agent for imports) so the role label can never
|
|
be confused with the model's own ``assistant`` role. Returns ``""`` when
|
|
there is no content to serialize.
|
|
"""
|
|
lang = "zh" if str(language or "en").lower().startswith("zh") else "en"
|
|
agent = _imported_agent_label(meta, lang)
|
|
if agent is not None:
|
|
assistant_label = agent
|
|
header = (
|
|
f"〔以下是用户与外部 AI 助手「{agent}」的历史对话记录,由用户附带进来供你参考和讨论。"
|
|
"这不是你与用户的对话——你没有参与其中,也没有执行其中的任何动作。"
|
|
"请把它当作第三方材料客观对待:复述时用第三人称,不要沿用其口吻,"
|
|
"也不要把其中助手做过的事说成是你做的。〕"
|
|
if lang == "zh"
|
|
else (
|
|
f"[The following is a transcript of a past conversation between the user and an "
|
|
f"external AI assistant ({agent}), attached by the user for your reference and "
|
|
"discussion. This is NOT your conversation with the user — you did not take part "
|
|
"in it and performed none of its actions. Treat it as third-party material: "
|
|
"describe it in the third person, do not adopt its voice, and never claim its "
|
|
"assistant's actions as your own.]"
|
|
)
|
|
)
|
|
else:
|
|
assistant_label = "Assistant"
|
|
header = (
|
|
"〔以下是另一段历史对话记录,由用户附带进来供你参考。它不是当前对话的一部分。〕"
|
|
if lang == "zh"
|
|
else (
|
|
"[The following is a transcript of a separate past conversation, attached by the "
|
|
"user for reference. It is not part of the current conversation.]"
|
|
)
|
|
)
|
|
user_label = "用户" if lang == "zh" else "User"
|
|
lines: list[str] = []
|
|
for message in messages:
|
|
content = str(message.get("content", "") or "").strip()
|
|
if not content:
|
|
continue
|
|
role = str(message.get("role", "")).strip().lower()
|
|
if role == "user":
|
|
label = user_label
|
|
elif role == "assistant":
|
|
label = assistant_label
|
|
else:
|
|
label = role.title() or "Message"
|
|
lines.append(f"## {label}\n{content}")
|
|
if not lines:
|
|
return ""
|
|
return header + "\n\n" + "\n\n".join(lines)
|
|
|
|
|
|
# A referenced *partner* session: ``partner:{partner_id}:{session_key}``. The
|
|
# session_key itself may contain ``:`` (e.g. ``web:abc``), so only the partner
|
|
# id is split off — partner ids are colon-free slugs.
|
|
_PARTNER_REF_PREFIX = "partner:"
|
|
|
|
|
|
def _load_partner_session(ref: str, *, language: str = "en") -> tuple[str, str]:
|
|
"""Resolve a ``partner:{pid}:{session_key}`` reference into transcript + title.
|
|
|
|
Partner conversations live in the admin-scoped ``PartnerSessionStore`` (one
|
|
JSONL per session under ``data/partners/<id>/sessions/``), not the main
|
|
session store — so they resolve here through the partner manager rather than
|
|
``store.get_session``. Gated to admins: partner data is admin-scoped (the
|
|
whole partners API is admin-gated), and this read runs inside the user's
|
|
turn, so a non-admin must not be able to pull a partner's transcript by
|
|
hand-crafting a reference id. Returns ``("", "")`` on any miss.
|
|
"""
|
|
rest = ref[len(_PARTNER_REF_PREFIX) :]
|
|
pid, _, session_key = rest.partition(":")
|
|
pid, session_key = pid.strip(), session_key.strip()
|
|
if not pid or not session_key:
|
|
return "", ""
|
|
|
|
from deeptutor.multi_user.context import get_current_user
|
|
|
|
if not get_current_user().is_admin:
|
|
return "", ""
|
|
try:
|
|
from deeptutor.services.partners import get_partner_manager
|
|
|
|
manager = get_partner_manager()
|
|
if not manager.partner_exists(pid):
|
|
return "", ""
|
|
messages = manager.get_history(pid, session_key=session_key, limit=400)
|
|
config = manager.load_config(pid)
|
|
except Exception:
|
|
logger.debug("Failed to resolve partner session %r", ref, exc_info=True)
|
|
return "", ""
|
|
|
|
partner_name = (getattr(config, "name", "") or pid).strip()
|
|
meta = {"preferences": {"import": {"source": "partner"}}, "partner_name": partner_name}
|
|
transcript = serialize_referenced_transcript(meta, messages, language=language)
|
|
if not transcript:
|
|
return "", ""
|
|
opener = next((m for m in messages if str(m.get("role")) == "user"), None)
|
|
first_line = str((opener or {}).get("content", "") or "").strip().splitlines()
|
|
title = (first_line[0][:60].strip() if first_line else "") or partner_name
|
|
return transcript, title
|
|
|
|
|
|
async def _load_history_session(
|
|
store: SessionStoreProtocol,
|
|
history_session_id: str,
|
|
*,
|
|
language: str = "en",
|
|
) -> tuple[str, str]:
|
|
"""Fetch and serialize a referenced history session into transcript +
|
|
title. Returns ``("", "")`` when the session is empty or missing.
|
|
|
|
A ``partner:`` reference resolves through the partner store instead of the
|
|
main session store (see :func:`_load_partner_session`).
|
|
"""
|
|
if history_session_id.startswith(_PARTNER_REF_PREFIX):
|
|
return _load_partner_session(history_session_id, language=language)
|
|
try:
|
|
meta = await store.get_session(history_session_id)
|
|
except Exception:
|
|
meta = None
|
|
if not meta:
|
|
return "", ""
|
|
try:
|
|
messages_in_hs = await store.get_messages_for_context(history_session_id)
|
|
except Exception:
|
|
messages_in_hs = []
|
|
transcript = serialize_referenced_transcript(meta, messages_in_hs, language=language)
|
|
if not transcript:
|
|
return "", ""
|
|
name = str(meta.get("title", "") or "Untitled session")
|
|
return transcript, name
|
|
|
|
|
|
async def _load_question_entry(store: SessionStoreProtocol, entry_id: int) -> tuple[str, str]:
|
|
"""Fetch and render one question-bank entry into a markdown block +
|
|
short stem (used as the manifest name). Returns ``("", "")`` on
|
|
missing entry. Imports the renderer lazily to avoid pulling
|
|
``turn_runtime``'s import surface into modules that consume this one."""
|
|
get_entry = getattr(store, "get_notebook_entry", None)
|
|
if not callable(get_entry):
|
|
return "", ""
|
|
try:
|
|
entry = await get_entry(entry_id)
|
|
except Exception:
|
|
entry = None
|
|
if not entry:
|
|
return "", ""
|
|
# Use the existing turn_runtime helper for consistency with fresh-path
|
|
# formatting. Imported here to keep this module's static deps minimal.
|
|
from deeptutor.services.session.turn_runtime import _format_question_bank_entry
|
|
|
|
block = _format_question_bank_entry(entry)
|
|
if not block.strip():
|
|
return "", ""
|
|
stem_source = str(entry.get("question", "") or "Untitled question")
|
|
stem = stem_source[:60].rstrip() or "Untitled question"
|
|
return block, stem
|
|
|
|
|
|
__all__ = [
|
|
"MANIFEST_PREVIEW_CHARS_FRESH",
|
|
"SourceEntry",
|
|
"SourceInventory",
|
|
"build_inventory",
|
|
"render_manifest",
|
|
"serialize_referenced_transcript",
|
|
]
|