# ============================================================ # 会话引用 v1(moduleId: core-session-ref, 可重生 ✅) # plan.md §4.7 / 矩阵 63 行:@会话/@方案/@版本/@报告/@知识 结构化引用。 # 硬规则:默认按时点快照取值(被引对象后续变化不影响已得结论), # 来源删除/无权访问时显式失败,每次解析结果进入证据链(evidenceRefs)。 # @知识:经注入的 knowledge_lookup 回调从知识库强制命中(带出处), # 未命中/未接线时显式 RefResolutionError(中文说明),绝不编造。 # ============================================================ from __future__ import annotations # 前向类型引用 import copy # 时点快照:深拷贝隔离后续变更 import re # 引用语法解析 from dataclasses import dataclass # 结构化解析结果 from datetime import UTC, datetime # 快照时点 from typing import Any, Callable # 类型标注(knowledge_lookup 回调) from server.timeutil import fmt_dt # 统一时间戳格式 # 引用种类:中文标签 -> 稳定 kind(与 plan.md §4.7 表格一致) REF_KIND_LABELS = { "会话": "session", "方案": "scenario", "版本": "version", "报告": "report", "知识": "knowledge", } REF_KINDS = frozenset(REF_KIND_LABELS.values()) # 引用语法:@会话: / @方案: / @版本: / @报告: / @知识: # id 取到空白/中英文标点/括号/引号/下一个 @ 为止(支持中文 ID 与 -_. 版本号) _REF_RE = re.compile( r"@(会话|方案|版本|报告|知识)\s*[::]\s*" r"([^\s,。!?、;;:,:“”\"'\u2018\u2019\u201c\u201d「」『』()()\[\]{}@#]+)" ) @dataclass(frozen=True) class ParsedRef: """一次解析出的结构化引用(稳定:相同文本 -> 相同引用)。""" kind: str # session / scenario / version / report target_id: str # 目标对象稳定 ID raw: str # 原始匹配串(含 @ 前缀) @property def ref_id(self) -> str: """稳定引用 ID::(与证据引用语法同构)。""" return f"{self.kind}:{self.target_id}" def to_evidence_ref(self) -> str: """证据链引用串(供 write_audit evidence_refs)。""" return self.ref_id @dataclass class ResolvedRef: """按时点解析出的引用值:快照 + 出处 + 证据引用。""" kind: str # session / scenario / version / report target_id: str # 目标对象稳定 ID snapshot_at: str # 取值时点(引用快照时间) value: Any # 时点快照(深拷贝,后续变更不影响) source: dict[str, Any] # 出处:来源表/消息数/来源会话等 @property def ref(self) -> str: """证据链引用串::。""" return f"{self.kind}:{self.target_id}" class RefResolutionError(Exception): """引用解析显式失败:来源删除、无权访问或目标不存在。 携带 kind/target_id/reason,调用方可显式报告而非泛化掩盖。 """ def __init__(self, kind: str, target_id: str, reason: str) -> None: super().__init__(f"ref {kind}:{target_id} unresolved: {reason}") self.kind = kind self.target_id = target_id self.reason = reason def parse_refs(text: str) -> list[ParsedRef]: """解析文本中的 @会话/@方案/@版本/@报告 结构化引用为稳定 ID 列表。 - 相同输入 -> 相同输出(确定性、顺序稳定、按 (kind, target_id) 去重) - 返回空列表当无引用;非法/缺 ID 的 @ 不产生引用 """ out: list[ParsedRef] = [] seen: set[tuple[str, str]] = set() for match in _REF_RE.finditer(text or ""): label, target_id = match.group(1), match.group(2).strip() kind = REF_KIND_LABELS[label] key = (kind, target_id) if key in seen: continue seen.add(key) out.append(ParsedRef(kind=kind, target_id=target_id, raw=match.group(0))) return out def _session_messages(store_data: dict[str, Any], session_id: str) -> list[dict[str, Any]]: """取会话消息(快照:messages[session_id] -> list[dict])。""" return list((store_data.get("messages") or {}).get(session_id) or []) def _lookup_list(store_data: dict[str, Any], *keys: str) -> list[Any]: """按候选键取首个非空表(兼容世界/工作区不同命名)。""" for key in keys: value = store_data.get(key) if value: return list(value) return [] def _now_local() -> datetime: """本地无时区时间(与仓库 fmt_dt 语义一致)。""" return datetime.now(UTC).astimezone().replace(tzinfo=None) def _now_snapshot(now: datetime | None) -> str: return fmt_dt(now or _now_local()) def _check_project_access(store_data: dict[str, Any], session: dict[str, Any], reason: str) -> None: """会话归属项目校验:项目不在可见列表或已归档 -> 无权访问,显式失败。""" project_id = session.get("projectId") or "__personal__" if project_id == "__personal__": return projects = _lookup_list(store_data, "projects") if not projects: return for project in projects: if project.get("id") == project_id and not project.get("archived"): return raise RefResolutionError("session", session.get("id") or "", reason) def _find_session(store_data: dict[str, Any], ref_id: str) -> dict[str, Any]: """在可见会话列表中定位目标;来源删除(不在可见列表)显式失败。""" sessions = _lookup_list(store_data, "sessions") for session in sessions: if session.get("id") == ref_id: _check_project_access(store_data, session, "no-access: project archived or not in scope") return session raise RefResolutionError("session", ref_id, "deleted-or-inaccessible") def _resolve_session(store_data: dict[str, Any], ref_id: str, snapshot_at: str) -> ResolvedRef: session = _find_session(store_data, ref_id) messages = _session_messages(store_data, ref_id) value = {"session": copy.deepcopy(session), "messages": copy.deepcopy(messages)} source = { "table": "sessions", "sessionId": ref_id, "messageCount": len(messages), "projectId": session.get("projectId"), } return ResolvedRef(kind="session", target_id=ref_id, snapshot_at=snapshot_at, value=value, source=source) def _resolve_scenario(store_data: dict[str, Any], ref_id: str, snapshot_at: str) -> ResolvedRef: cards = _lookup_list(store_data, "scenarios", "scenarioCards") for card in cards: if card.get("scenarioId") == ref_id: return ResolvedRef(kind="scenario", target_id=ref_id, snapshot_at=snapshot_at, value=copy.deepcopy(card), source={"table": "scenarios"}) raise RefResolutionError("scenario", ref_id, "deleted-or-inaccessible") def _resolve_version(store_data: dict[str, Any], ref_id: str, snapshot_at: str) -> ResolvedRef: versions = _lookup_list(store_data, "versions", "scheduleVersions", "flexScheduleVersions") for version in versions: if version.get("id") == ref_id or version.get("versionId") == ref_id \ or version.get("version") == ref_id: return ResolvedRef(kind="version", target_id=ref_id, snapshot_at=snapshot_at, value=copy.deepcopy(version), source={"table": "scheduleVersions"}) raise RefResolutionError("version", ref_id, "deleted-or-inaccessible") def _resolve_report(store_data: dict[str, Any], ref_id: str, snapshot_at: str) -> ResolvedRef: reports = _lookup_list(store_data, "reports", "reportAssets") for report in reports: if report.get("reportId") == ref_id or report.get("id") == ref_id: return ResolvedRef(kind="report", target_id=ref_id, snapshot_at=snapshot_at, value=copy.deepcopy(report), source={"table": "reports"}) raise RefResolutionError("report", ref_id, "deleted-or-inaccessible") def _resolve_knowledge(ref_id: str, snapshot_at: str, knowledge_lookup: Callable[[str], dict[str, Any] | None] | None) -> ResolvedRef: """从知识库强制命中 @知识 引用(RAG 命中轮,矩阵 63 行剩余项)。 - 命中:返回资产时点快照(正文 + 出处:assetId/title/kind/version) - 未命中:显式 RefResolutionError(中文说明),绝不编造 - knowledge_lookup 未注入(默认 None):显式报错说明 @知识 未接线,不静默 """ if knowledge_lookup is None: raise RefResolutionError( "knowledge", ref_id, "not-wired: 知识库检索未接线(resolve 未注入 knowledge_lookup 回调)") asset = knowledge_lookup(ref_id) if not asset: raise RefResolutionError( "knowledge", ref_id, f"knowledge-miss: 知识库未命中《{ref_id}》(未编造)") asset_id = str(asset.get("assetId") or ref_id) # 稳定目标 ID = 资产 ID value = copy.deepcopy(asset) # 时点快照:深拷贝隔离后续变更 source = { "table": "knowledge", "assetId": asset.get("assetId"), "title": asset.get("title"), "kind": asset.get("kind"), "version": asset.get("version"), "query": ref_id, # 原始引用串(可回查请求) } return ResolvedRef(kind="knowledge", target_id=asset_id, snapshot_at=snapshot_at, value=value, source=source) _RESOLVERS = { "session": _resolve_session, "scenario": _resolve_scenario, "version": _resolve_version, "report": _resolve_report, } def resolve_ref(store_data: dict[str, Any], ref_kind: str, ref_id: str, *, now: datetime | None = None, knowledge_lookup: Callable[[str], dict[str, Any] | None] | None = None) -> ResolvedRef: """按时点解析一条引用:取时点快照;来源删除/无权访问显式失败。 Args: store_data: 工作区快照(projects/sessions/messages/scenarios/versions/reports) ref_kind: session / scenario / version / report / knowledge ref_id: 目标对象稳定 ID(@知识 时取 assetId 或标题关键词) now: 取值时点(测试可注入;默认当前时间) knowledge_lookup: @知识 专用:知识库查找回调(入参目标串,返回资产 dict 或 None)。 默认 None 时解析 @知识 显式抛 RefResolutionError 说明未接线(不静默)。 Returns: ResolvedRef:value 为深拷贝快照(被引对象后续变化不影响已得结论) Raises: RefResolutionError: 目标删除/不可见/无权访问/@知识 未命中或未接线 ValueError: 未知 ref_kind """ if ref_kind not in REF_KINDS: raise ValueError(f"unknown ref kind: {ref_kind!r}") snapshot_at = _now_snapshot(now) if ref_kind == "knowledge": return _resolve_knowledge(ref_id, snapshot_at, knowledge_lookup) return _RESOLVERS[ref_kind](store_data, ref_id, snapshot_at) def resolve_all(store_data: dict[str, Any], refs: list[ParsedRef], *, now: datetime | None = None, knowledge_lookup: Callable[[str], dict[str, Any] | None] | None = None, ) -> tuple[list[ResolvedRef], list[str]]: """批量解析引用,返回 (解析结果, 证据链引用串列表)。 证据链引用串形如 session: / scenario: / version: / report: / knowledge:,可直接传入 write_audit(..., evidence_refs=...) 进入审计证据链。 knowledge_lookup 透传给 @知识 解析(见 resolve_ref);不含 @知识 引用时保持 既有行为完全兼容(不传亦可)。 """ resolved: list[ResolvedRef] = [] for ref in refs: resolved.append(resolve_ref(store_data, ref.kind, ref.target_id, now=now, knowledge_lookup=knowledge_lookup)) evidence_refs = [item.ref for item in resolved] return resolved, evidence_refs