aps-agent/server/agent_core/session_ref.py

274 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 会话引用 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