Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

528 lines
19 KiB
Python

"""Workspace → ``Entity`` adapters.
One pure read-only function per surface. Adapters never mutate
workspace state. They read whatever lives under
``data/user/workspace/`` (or, for chat/quiz, the chat history SQLite
DB; for kb-list, the kb config JSON; for the ``partner`` surface, the
per-partner conversation JSONL under ``data/partners/``).
Each adapter returns a ``list[Entity]`` with stable ``id`` and a
deterministic ``fingerprint`` so the diff engine can detect changes
across refreshes.
"""
from __future__ import annotations
from datetime import datetime, timezone
import hashlib
import json
import logging
import sqlite3
from deeptutor.services.memory.paths import Surface
from deeptutor.services.memory.snapshot.entity import Entity
from deeptutor.services.path_service import get_path_service
logger = logging.getLogger(__name__)
# ── Helpers ──────────────────────────────────────────────────────────
def _sha1(*parts: object) -> str:
h = hashlib.sha1(usedforsecurity=False)
for part in parts:
if part is None:
continue
if isinstance(part, (dict, list)):
blob = json.dumps(part, sort_keys=True, ensure_ascii=False)
else:
blob = str(part)
h.update(blob.encode("utf-8"))
h.update(b"\0")
return h.hexdigest()[:16]
def _iso(ts: float | int | str | None) -> str:
if isinstance(ts, str):
try:
datetime.fromisoformat(ts.replace("Z", "+00:00"))
return ts
except Exception:
pass
if isinstance(ts, (int, float)):
try:
return datetime.fromtimestamp(float(ts), tz=timezone.utc).isoformat()
except Exception:
pass
return ""
# ── Adapters ─────────────────────────────────────────────────────────
def read_notebook_entities() -> list[Entity]:
ps = get_path_service()
index_file = ps.get_notebook_index_file()
if not index_file.exists():
return []
try:
index = json.loads(index_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
notebooks = index.get("notebooks") or []
out: list[Entity] = []
for nb in notebooks:
if not isinstance(nb, dict):
continue
nb_id = nb.get("id")
nb_name = nb.get("name") or nb_id
if not nb_id:
continue
nb_file = ps.get_notebook_file(nb_id)
if not nb_file.exists():
continue
try:
nb_data = json.loads(nb_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
for r in nb_data.get("records") or []:
if not isinstance(r, dict):
continue
rid = r.get("id")
if not rid:
continue
title = r.get("title", "") or ""
user_query = r.get("user_query", "") or ""
output = r.get("output", "") or ""
summary = r.get("summary", "") or ""
content = "\n\n".join(
part
for part in (
f"# {title}" if title else "",
f"**User query**: {user_query}" if user_query else "",
f"**Summary**: {summary}" if summary else "",
output,
)
if part
)
out.append(
Entity(
id=rid,
label=f"{title} · {nb_name}" if title else f"({rid}) · {nb_name}",
ts=_iso(r.get("created_at")),
content=content,
metadata={
"notebook_id": nb_id,
"notebook_name": nb_name,
"record_type": r.get("record_type", ""),
"kb_name": r.get("kb_name"),
},
fingerprint=_sha1(title, user_query, output, summary),
)
)
return out
def read_cowriter_entities() -> list[Entity]:
docs_dir = get_path_service().get_co_writer_docs_dir()
if not docs_dir.exists():
return []
out: list[Entity] = []
for entry in sorted(docs_dir.iterdir()):
manifest = entry / "manifest.json"
if not manifest.exists():
continue
try:
m = json.loads(manifest.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
doc_id = m.get("id")
if not doc_id:
continue
title = m.get("title", "") or ""
content = m.get("content", "") or ""
out.append(
Entity(
id=doc_id,
label=title or doc_id,
ts=_iso(m.get("updated_at") or m.get("created_at")),
content=content,
metadata={"doc_id": doc_id, "title": title},
fingerprint=_sha1(title, content),
)
)
return out
def read_book_entities() -> list[Entity]:
books_dir = get_path_service().get_book_dir()
if not books_dir.exists():
return []
out: list[Entity] = []
for entry in sorted(books_dir.iterdir()):
if not entry.is_dir():
continue
manifest_path = entry / "manifest.json"
if not manifest_path.exists():
continue
try:
m = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
book_id = m.get("id")
if not book_id:
continue
title = m.get("title", "") or ""
description = m.get("description", "") or ""
# Pull page titles + chapter outline from spine for L2 to chew on.
spine_path = entry / "spine.json"
page_titles: list[str] = []
if spine_path.exists():
try:
spine = json.loads(spine_path.read_text(encoding="utf-8"))
page_titles = [
str(p.get("title") or p.get("id") or "")
for p in (spine.get("pages") or [])
if isinstance(p, dict)
]
except (OSError, json.JSONDecodeError):
page_titles = []
content = "\n\n".join(
part
for part in (
f"# {title}",
description,
"## Pages\n" + "\n".join(f"- {p}" for p in page_titles) if page_titles else "",
)
if part
)
out.append(
Entity(
id=book_id,
label=title or book_id,
ts=_iso(m.get("updated_at") or m.get("created_at")),
content=content,
metadata={
"book_id": book_id,
"page_count": m.get("page_count", 0),
"chapter_count": m.get("chapter_count", 0),
"knowledge_bases": m.get("knowledge_bases", []),
"status": m.get("status", ""),
},
fingerprint=_sha1(title, description, m.get("updated_at"), len(page_titles)),
)
)
return out
def _partner_display_name(partner_dir, partner_id: str) -> str:
"""Read ``name`` out of a partner's ``config.yaml`` for tagging.
Falls back to the directory id when the config is missing or unreadable.
"""
cfg = partner_dir / "config.yaml"
if not cfg.exists():
return partner_id
try:
import yaml
data = yaml.safe_load(cfg.read_text(encoding="utf-8")) or {}
except Exception:
return partner_id
if isinstance(data, dict):
name = data.get("name")
if isinstance(name, str) and name.strip():
return name.strip()
return partner_id
def _partner_session_entity(path, partner_id: str, partner_name: str) -> Entity | None:
"""Build one Entity from a partner session JSONL file.
The conversation is inlined as ``user / assistant`` blocks (mirroring
:func:`read_chat_entities`) so L2 sees the actual dialogue, and the
originating partner is tagged into both the label and the metadata so
the consolidator carries provenance into L2/L3.
"""
records: list[dict] = []
try:
with path.open("r", encoding="utf-8") as fh:
for raw in fh:
raw = raw.strip()
if not raw:
continue
try:
obj = json.loads(raw)
except json.JSONDecodeError:
continue
if isinstance(obj, dict):
records.append(obj)
except OSError:
return None
blocks: list[str] = []
for r in records:
role = r.get("role") or ""
body = (r.get("content") or "").strip()
if not body:
continue
blocks.append(f"### {role}\n{body}")
if not blocks:
return None
session_key = path.stem
archived = session_key.startswith("_archived_")
last = records[-1]
last_ts = last.get("timestamp", "")
last_content = last.get("content", "")
return Entity(
id=f"{partner_id}:{session_key}",
label=f"{session_key} · {partner_name}",
ts=_iso(last_ts),
content="\n\n".join(blocks),
metadata={
"partner_id": partner_id,
"partner_name": partner_name,
"session_key": session_key,
"archived": archived,
"message_count": len(blocks),
},
fingerprint=_sha1(len(blocks), last_ts, last_content),
)
def read_partner_entities() -> list[Entity]:
"""One Entity per partner conversation *session* (archived + active),
tagged with the originating partner. Surfaced under the ``partner``
surface (UI label "伙伴").
Partner runtimes persist conversations as JSONL under
``data/partners/<id>/sessions/*.jsonl`` — a store entirely separate from
the chat-history SQLite DB the ``chat`` adapter reads. This adapter
bridges that store into the memory pipeline so partner conversations
consolidate into L2/L3 like every other surface.
Partners are anchored to the admin workspace, so we only surface them
when the active scope IS the admin's own memory; a regular user's memory
view must not see the admin's partner conversations.
"""
from deeptutor.multi_user.paths import get_admin_path_service
admin_root = get_admin_path_service().workspace_root.resolve()
if get_path_service().workspace_root.resolve() != admin_root:
return []
partners_root = admin_root / "partners"
if not partners_root.exists():
return []
out: list[Entity] = []
for partner_dir in sorted(partners_root.iterdir()):
if not partner_dir.is_dir():
continue
sessions_dir = partner_dir / "sessions"
if not sessions_dir.is_dir():
continue
partner_id = partner_dir.name
partner_name = _partner_display_name(partner_dir, partner_id)
for sess_file in sorted(sessions_dir.glob("*.jsonl")):
entity = _partner_session_entity(sess_file, partner_id, partner_name)
if entity is not None:
out.append(entity)
return out
def read_kb_entities() -> list[Entity]:
"""KB *list* snapshot — one Entity per registered knowledge base.
KB queries stay event-driven and live in the trace store; the L2
consolidator combines both signals.
"""
kb_root = get_path_service().get_knowledge_bases_root()
cfg_file = kb_root / "kb_config.json"
if not cfg_file.exists():
return []
try:
cfg = json.loads(cfg_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
kbs = cfg.get("knowledge_bases") or {}
if not isinstance(kbs, dict):
return []
out: list[Entity] = []
for kb_name, kb_data in sorted(kbs.items()):
if not isinstance(kb_name, str):
continue
if not isinstance(kb_data, dict):
kb_data = {}
description = kb_data.get("description", "") or ""
versions = kb_data.get("index_versions") or []
sigs = sorted(v.get("signature", "") for v in versions if isinstance(v, dict))
earliest_created = min(
(
v.get("created_at", "")
for v in versions
if isinstance(v, dict) and v.get("created_at")
),
default="",
)
content = "\n\n".join(
part
for part in (
f"# {kb_name}",
description,
f"Index versions: {len(versions)}",
)
if part
)
out.append(
Entity(
id=kb_name,
label=kb_name,
ts=earliest_created,
content=content,
metadata={
"kb_name": kb_name,
"version_count": len(versions),
"rag_provider": kb_data.get("rag_provider", ""),
},
fingerprint=_sha1(description, sigs),
)
)
return out
def read_chat_entities() -> list[Entity]:
"""One Entity per chat session. ``content`` inlines all turns as
``user / assistant`` blocks so L2 sees the actual conversation."""
db_path = get_path_service().get_chat_history_db()
if not db_path.exists():
return []
out: list[Entity] = []
try:
with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) as conn:
conn.row_factory = sqlite3.Row
sessions = conn.execute(
"SELECT id, title, created_at, updated_at FROM sessions ORDER BY updated_at DESC"
).fetchall()
for sess in sessions:
sid = sess["id"]
msgs = conn.execute(
"SELECT id, role, content, capability, created_at "
"FROM messages WHERE session_id = ? "
"ORDER BY created_at ASC, id ASC",
(sid,),
).fetchall()
blocks: list[str] = []
for m in msgs:
role = m["role"]
body = (m["content"] or "").strip()
if not body:
continue
blocks.append(f"### {role}\n{body}")
content = "\n\n".join(blocks)
last_msg_id = msgs[-1]["id"] if msgs else 0
out.append(
Entity(
id=sid,
label=sess["title"] or sid,
ts=_iso(sess["updated_at"]),
content=content,
metadata={
"session_id": sid,
"message_count": len(msgs),
},
fingerprint=_sha1(last_msg_id, sess["updated_at"]),
)
)
except sqlite3.Error as exc:
logger.warning("chat snapshot scan failed: %s", exc)
return []
return out
def read_quiz_entities() -> list[Entity]:
"""One Entity per recorded quiz attempt (notebook_entries row)."""
db_path = get_path_service().get_chat_history_db()
if not db_path.exists():
return []
out: list[Entity] = []
try:
with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT id, session_id, turn_id, question_id, question, "
"question_type, options_json, correct_answer, explanation, "
"difficulty, user_answer, is_correct, bookmarked, "
"created_at FROM notebook_entries "
"ORDER BY created_at DESC"
).fetchall()
for r in rows:
qid = r["question_id"] or f"row_{r['id']}"
entity_id = f"{r['session_id']}:{qid}"
question = (r["question"] or "").strip()
user_answer = (r["user_answer"] or "").strip()
correct = (r["correct_answer"] or "").strip()
explanation = (r["explanation"] or "").strip()
is_correct = bool(int(r["is_correct"] or 0))
content = "\n\n".join(
part
for part in (
f"**Question**: {question}" if question else "",
f"**User answer**: {user_answer}" if user_answer else "",
f"**Correct answer**: {correct}" if correct else "",
f"**Explanation**: {explanation}" if explanation else "",
)
if part
)
out.append(
Entity(
id=entity_id,
label=question[:80] or qid,
ts=_iso(r["created_at"]),
content=content,
metadata={
"session_id": r["session_id"],
"turn_id": r["turn_id"],
"question_id": qid,
"question_type": r["question_type"],
"difficulty": r["difficulty"],
"is_correct": is_correct,
"bookmarked": bool(int(r["bookmarked"] or 0)),
},
fingerprint=_sha1(question, user_answer, correct, is_correct),
)
)
except sqlite3.Error as exc:
logger.warning("quiz snapshot scan failed: %s", exc)
return []
return out
# ── Dispatch ─────────────────────────────────────────────────────────
_READERS = {
"notebook": read_notebook_entities,
"cowriter": read_cowriter_entities,
"book": read_book_entities,
"partner": read_partner_entities,
"kb": read_kb_entities,
"chat": read_chat_entities,
"quiz": read_quiz_entities,
}
def read_entities(surface: Surface) -> list[Entity]:
reader = _READERS.get(surface)
if reader is None:
return []
try:
return reader()
except Exception as exc:
logger.warning("snapshot adapter failed surface=%s: %s", surface, exc)
return []
SUPPORTED_SURFACES: tuple[Surface, ...] = tuple(_READERS.keys()) # type: ignore[arg-type,assignment]