mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 19:12:00 +00:00
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)。
119 lines
4.5 KiB
Python
119 lines
4.5 KiB
Python
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)
|