274 lines
12 KiB
Python
274 lines
12 KiB
Python
# ============================================================
|
||
# 会话引用 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> / @版本:<id> / @报告:<id> / @知识:<assetId 或标题关键词>
|
||
# 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:<kind>:<target_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:
|
||
"""证据链引用串:<kind>:<target_id>。"""
|
||
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:<id> / scenario:<id> / version:<id> / report:<id> /
|
||
knowledge:<assetId>,可直接传入 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
|