Files
AgentLab/backend/app/engine/state.py
T
ThearchyHelios e4f2693918 可点击证据第一期:报告里的每个数字都点得开出处
「引用即渲染」:写报告的模型只写引用标记([[m:指标id]]、[[i:输入字段]]、[[see:…]]),数值由系统按口径卡
填进去,每个数字都挂着一条能复算的证据链。本期是数字层闭环;实体、结论句的概率性证据放在后续几期。

内核(engine/evidence.py,口径卡、出具契约、表达式等)
- 口径卡记录每个指标的来源:原式、代入式(每个输入写成「值 ← 路径」)、复算结果、口径与版本
- 出具契约的数值匹配改为按位置标注;同一个值有几个出处时记为「出处不唯一」并列出候选,不再随便挑一个
- 证据 id 由内容派生,事后对不上就是被改过;零值、结构性数字(列表序号、代码块标签)单独处理
- 口径卡新增 on_missing:缺输入时默认仍是整个节点失败,可以改成记为空值、交给契约判档

报告撰写节点(report)与引用模式的出具契约
- 流式生成时 token 里就是真实数字,不会漏出原始标记;裸数字、不存在的引用当场判违规,交回去重写
  (max_repairs,默认 1 次);仍不过时按 on_violation 失败(正式运行默认)或把违规标在报告里(探索运行默认)
- report.checked 事件记下核对结果,落在封存范围内;fail 模式下仍不过时带 failed,运行记录里画成失败
- 出具契约写了 report_from 就按引用核对:取回文档时复验哈希,独立重算一遍,全部带引用才判完整出具
- 成果字段逐字等于报告正文时标注 _evidence;复核只能加说明,不能改写带证据的答案(前后端各拦一道)

证据接口
- GET /api/runs/{id}/evidence 返回证据图;GET …/evidence/segments/{id} 返回一个片段的证据链
- 只认封存范围内的事件能追到的工件,不读 artifacts 表;封存被改过时不把任何一项标成已封存
- 没有报告的旧运行降级为 none 或 legacy_contract

界面
- EvidenceDoc 按块、句、片段渲染报告,片段是和正文一样的按钮;整份报告只占一个 Tab 位,
  ←/→ 在片段间移动、↑/↓ 按句、n/N 跳到下一处「无证据」、Enter 打开面板、Esc 关闭并把焦点还回去
- EvidencePanel:宽屏侧边、窄屏底部抽屉、360px 画布右栏里栏内展开;显示指标步骤、代入式、复算结果、
  封存状态。正文不是封存的那份报告时明确说出来,不拿别的报告的封存画绿
- 出具横幅多一行「N/N 数字有出处 · 无证据 M」和「定位下一处」;没有 _evidence 的旧回答渲染一字不差
- 画布认得报告撰写节点;Copilot 手册补上口径卡、报告节点、出具契约的写法;新增模板「⑨ 可追溯周报」

验证(沙箱):tsc 干净,生产构建通过,pytest 1601 passed,check-all 16 项检查全绿(新增 check-evidence)。
2026-09-28 11:47:23 +08:00

119 lines
4.5 KiB
Python
Raw 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.
from __future__ import annotations
import operator
from typing import Annotated, Any, TypedDict
from langchain_core.messages import AnyMessage
from langgraph.graph import add_messages
def merge_dict(left: dict[str, Any] | None, right: dict[str, Any] | None) -> dict[str, Any]:
"""浅合并。并行分支各自写不同的 key 时不会互相覆盖。"""
out = dict(left or {})
out.update(right or {})
return out
def merge_usage(left: dict[str, Any] | None, right: dict[str, Any] | None) -> dict[str, Any]:
"""累加 token / 成本,其余字段取新值。"""
out = dict(left or {})
for key, value in (right or {}).items():
if isinstance(value, (int, float)) and isinstance(out.get(key), (int, float)):
out[key] = out[key] + value
else:
out[key] = value
return out
class GraphState(TypedDict, total=False):
"""所有节点共享的状态。
- messages:对话历史,agent 节点之间靠它传递上下文
- nodes:node_id -> 该节点的结构化输出,模板里用 {{ nodes.xxx.text }} 引用
- vars:用户显式写入的变量池
- output:output 节点收集的最终成果
- evidence:证据台账。能被报告引用的工件(口径卡的指标集、查询快照……)按产生
顺序记在这里,报告节点建目录时给它们统一编号。只追加不改写;旧 checkpoint 里
没有这个通道,续跑时 LangGraph 按空列表起步
"""
messages: Annotated[list[AnyMessage], add_messages]
input: dict[str, Any]
vars: Annotated[dict[str, Any], merge_dict]
nodes: Annotated[dict[str, Any], merge_dict]
output: Annotated[dict[str, Any], merge_dict]
usage: Annotated[dict[str, Any], merge_usage]
trail: Annotated[list[dict[str, Any]], operator.add]
# 循环计数器:node_id -> 已迭代次数,防止无限循环
loops: Annotated[dict[str, Any], merge_dict]
evidence: Annotated[list[dict[str, Any]], operator.add]
def template_context(state: GraphState) -> dict[str, Any]:
"""把状态摊平成模板/表达式可以引用的命名空间。"""
return {
"input": state.get("input") or {},
"nodes": state.get("nodes") or {},
"vars": state.get("vars") or {},
"output": state.get("output") or {},
"loops": state.get("loops") or {},
"usage": state.get("usage") or {},
"messages": [
{"role": getattr(m, "type", "?"), "content": _text_of(m)}
for m in (state.get("messages") or [])
],
"last_message": _text_of((state.get("messages") or [None])[-1]),
}
def _text_of(message: Any) -> str:
"""把 LangChain 的多模态 content 压成纯文本。"""
if message is None:
return ""
content = getattr(message, "content", message)
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict):
if block.get("type") == "text":
parts.append(block.get("text", ""))
elif "text" in block:
parts.append(str(block["text"]))
return "".join(parts)
return str(content)
def message_text(message: Any) -> str:
return _text_of(message)
def thinking_text(message: Any) -> str:
"""抽出思考内容。
两种形态都要认,因为两边的模型都会产出思考,而界面上"它大致在想什么"
只有这一个来源:
- Anthropic 系:content 是块列表,思考混在里面(type == "thinking")。
正文提取必须跳过它们,但它本身很有价值,所以单独取出来。
- OpenAI 兼容系(DeepSeek / Qianfan / vLLM 等):思考走 delta 里的
reasoning_content。langchain 的 ChatOpenAI 明确不保留这个字段,
我们在 providers/factory.py 里子类化捞回了 additional_kwargs。
"""
extra = getattr(message, "additional_kwargs", None) or {}
reasoning = extra.get("reasoning_content") or extra.get("reasoning")
if isinstance(reasoning, str) and reasoning:
return reasoning
content = getattr(message, "content", message)
if not isinstance(content, list):
return ""
parts: list[str] = []
for block in content:
if isinstance(block, dict) and block.get("type") == "thinking":
parts.append(str(block.get("thinking") or ""))
return "".join(parts)