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

318 lines
12 KiB
Python

"""Lightweight JSONL conversation store for partner sessions.
One JSONL file per session key (``telegram:12345``, ``web:<session_id>``,
``partner:<id>`` …) under ``data/partners/<id>/sessions/``. This is the
partner-side replacement for the deleted engine's SessionManager: it only
has to hand the chat agent loop an OpenAI-format ``conversation_history``
and back the history API, so a flat append-only file per session is enough.
"""
from __future__ import annotations
from datetime import datetime
import json
import logging
from pathlib import Path
import threading
from typing import Any
from deeptutor.partners.helpers import safe_filename
logger = logging.getLogger(__name__)
_HISTORY_MAX_MESSAGES = 40
_HISTORY_MAX_CHARS = 24_000
_ARCHIVE_PREFIX = "_archived_"
class PartnerSessionStore:
"""Append-only JSONL persistence for one partner's conversations."""
def __init__(self, sessions_dir: Path) -> None:
self._dir = sessions_dir
self._dir.mkdir(parents=True, exist_ok=True)
self._write_lock = threading.Lock()
def _path(self, session_key: str) -> Path:
return self._dir / f"{self._stem(session_key)}.jsonl"
@staticmethod
def is_archived_key(session_key: str) -> bool:
return safe_filename(session_key).startswith(_ARCHIVE_PREFIX)
# ── session metadata (archived flag) ──────────────────────────
#
# The web app drives session lifecycle by key (resume / delete / branch),
# so a web session is archived with a soft flag in this sidecar rather than
# renamed — the file keeps its key and stays resumable. IM still uses the
# rename-based ``archive()`` to free a chat's fixed key for a fresh start.
@property
def _index_path(self) -> Path:
return self._dir / "_index.json"
def _load_index(self) -> dict[str, Any]:
path = self._index_path
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def _save_index(self, index: dict[str, Any]) -> None:
with self._write_lock:
self._index_path.write_text(
json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8"
)
def _stem(self, session_key: str) -> str:
# Single source of truth for key → on-disk stem. ``safe_filename``
# neutralizes path separators (so a key can't escape the sessions dir);
# we additionally strip leading/trailing dots and fall back to
# ``default`` so a degenerate key (empty, ``.`` / ``..``) can't produce a
# hidden or dots-only file. NOTE: safe_filename is intentionally lossy —
# ``:`` and ``/`` both fold to ``_`` — so keys differing only in unsafe
# characters share a file. Callers build keys from safe components (uuid
# hex, slugged ascii ids), so that collision is unreachable in practice;
# making the mapping injective would rename every existing session file
# (``web:<id>`` → ``web_<id>.jsonl``) and orphan history, so it is left
# as-is deliberately.
return safe_filename(session_key).strip(".") or "default"
def is_archived(self, session_key: str) -> bool:
"""Archived = soft flag in the index OR a legacy ``_archived_`` file."""
stem = self._stem(session_key)
if bool(self._load_index().get(stem, {}).get("archived")):
return True
return self.is_archived_key(session_key)
def set_archived(self, session_key: str, archived: bool) -> None:
stem = self._stem(session_key)
index = self._load_index()
entry = index.get(stem, {})
if archived:
entry["archived"] = True
else:
entry.pop("archived", None)
if entry:
index[stem] = entry
else:
index.pop(stem, None)
self._save_index(index)
def delete_session(self, session_key: str) -> bool:
"""Remove a session's file and its index entry. Returns whether it existed."""
path = self._path(session_key)
existed = path.exists()
with self._write_lock:
path.unlink(missing_ok=True)
index = self._load_index()
if index.pop(self._stem(session_key), None) is not None:
self._save_index(index)
return existed
def branch(self, source_key: str, new_key: str) -> dict[str, Any] | None:
"""Copy *source_key*'s full history into *new_key* and archive the source.
Returns the new session's summary, or ``None`` if the source is empty.
The new key keeps the conversation going with the same context while the
original is preserved (archived) and still resumable.
"""
records = self._read_records(source_key)
if not records:
return None
dest = self._path(new_key)
with self._write_lock:
with open(dest, "a", encoding="utf-8") as f:
for record in records:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
self.set_archived(source_key, True)
return self._session_summary(dest)
# ── write ─────────────────────────────────────────────────────
def append(
self,
session_key: str,
role: str,
content: str,
*,
channel: str = "",
sender_id: str = "",
metadata: dict[str, Any] | None = None,
attachments: list[dict[str, Any]] | None = None,
events: list[dict[str, Any]] | None = None,
) -> None:
record: dict[str, Any] = {
"role": role,
"content": content,
"timestamp": datetime.now().isoformat(),
}
if channel:
record["channel"] = channel
if sender_id:
record["sender_id"] = sender_id
if metadata:
record["metadata"] = metadata
if attachments:
record["attachments"] = attachments
# The turn's trace (thinking / tool calls / narration) so the web chat
# can rehydrate the collapsible "Done" activity after a refresh — the
# IM channels never read it back, but it costs only storage there.
if events:
record["events"] = events
line = json.dumps(record, ensure_ascii=False)
with self._write_lock:
with open(self._path(session_key), "a", encoding="utf-8") as f:
f.write(line + "\n")
def clear(self, session_key: str) -> bool:
path = self._path(session_key)
if not path.exists():
return False
path.unlink()
return True
def archive(self, session_key: str) -> dict[str, Any] | None:
"""Move the current session file aside and return its archive summary.
The active key remains reusable: after archiving ``telegram:42``, new
messages write to ``telegram_42.jsonl`` while the old file is kept as a
separate archived session that can still be inspected through the
history API by using the returned ``session_key``.
"""
path = self._path(session_key)
if not path.exists():
return None
records = self._read_records(session_key)
if not records:
return None
safe_key = self._stem(session_key)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
archive_key = f"{_ARCHIVE_PREFIX}{stamp}_{safe_key}"
archive_path = self._path(archive_key)
suffix = 1
while archive_path.exists():
archive_key = f"{_ARCHIVE_PREFIX}{stamp}_{suffix}_{safe_key}"
archive_path = self._path(archive_key)
suffix += 1
with self._write_lock:
if not path.exists():
return None
path.replace(archive_path)
return self._session_summary(archive_path)
# ── read ──────────────────────────────────────────────────────
def _read_records(self, session_key: str) -> list[dict[str, Any]]:
path = self._path(session_key)
if not path.exists():
return []
records: list[dict[str, Any]] = []
try:
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(data, dict) and data.get("role") and data.get("content"):
records.append(data)
except OSError:
logger.exception("Failed to read partner session %s", session_key)
return records
def conversation_history(
self,
session_key: str,
*,
max_messages: int = _HISTORY_MAX_MESSAGES,
max_chars: int = _HISTORY_MAX_CHARS,
) -> list[dict[str, str]]:
"""Return trailing history in OpenAI message format, budget-capped."""
records = self._read_records(session_key)
window = [
{"role": str(r["role"]), "content": str(r["content"])}
for r in records[-max_messages:]
if r.get("role") in {"user", "assistant"}
]
total = 0
kept: list[dict[str, str]] = []
for message in reversed(window):
total += len(message["content"])
if kept and total > max_chars:
break
kept.append(message)
kept.reverse()
return kept
def messages(self, session_key: str, *, limit: int = 100) -> list[dict[str, Any]]:
"""Raw records (role/content/timestamp/...) for the history API."""
return self._read_records(session_key)[-limit:]
def merged_messages(
self, *, limit: int = 100, include_archived: bool = False
) -> list[dict[str, Any]]:
"""All sessions' records merged chronologically (recent-activity view)."""
merged: list[tuple[str, int, dict[str, Any]]] = []
sequence = 0
for path in sorted(self._dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime):
if not include_archived and self.is_archived(path.stem):
continue
key = path.stem
for record in self._read_records(key):
merged.append((str(record.get("timestamp", "")), sequence, record))
sequence += 1
merged.sort(key=lambda item: (item[0], item[1]))
return [item[2] for item in merged[-limit:]]
def _session_summary(self, path: Path) -> dict[str, Any]:
records = self._read_records(path.stem)
last = records[-1] if records else {}
archived = self.is_archived(path.stem)
return {
"session_key": path.stem,
"title": self._derive_title(records),
"message_count": len(records),
"updated_at": datetime.fromtimestamp(path.stat().st_mtime).isoformat(),
"last_message": str(last.get("content", ""))[:200],
"archived": archived,
}
@staticmethod
def _derive_title(records: list[dict[str, Any]]) -> str:
"""A short, human label for a session — its opening user message.
The first user turn is what a conversation is "about"; fall back to the
first record of any role. First line only, trimmed to a chip-sized
length. Empty for an empty session (the UI shows a generic label).
"""
opener = next(
(r for r in records if r.get("role") == "user"),
records[0] if records else None,
)
if not opener:
return ""
text = str(opener.get("content", "")).strip()
first_line = text.splitlines()[0] if text else ""
return first_line[:60].strip()
def list_sessions(self) -> list[dict[str, Any]]:
return [
self._session_summary(path)
for path in sorted(
self._dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True
)
]
__all__ = ["PartnerSessionStore"]