mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 21:01:59 +00:00
fcdcc30d4e
一、可点击证据第二期
取数链路
- 智能体、工具节点、代码节点、知识检索取数时都记进证据台账;报告目录按台账编出 Q1… / K1…
- 报告可以引用快照里的一格([[v:Q1.r0.gmv]]),也可以由系统从快照生成整张表([[table:Q1 cols=… rows=0-4]]),
表里每一格都是可点的片段;取值一律以快照为准,取回快照时复验哈希
- 口径卡表达式新增 cell(x, row, column),lineage 精确到单元格
- 智能体开 cite_fields(配合 output_schema)后,循环结束做一次结构化抽取,每个字段标出取自哪次查询的哪一格,
再到快照里核对:一致记 verified;不一致以快照为准并告警;查不到的记为空值,永远不兜底成 0
- 代码节点新增 evidence_role(source / compute)和 code_sha;沙箱算出来的数不能被报告直接引用,要进口径卡
- 查询快照记下列类型,文本列按文本渲染(年份 "2026" 不再显示成「2,026」)
- 升级前发起的运行(停在审批上的)一律走旧逻辑:新的 task 只追加在循环之后,恢复时不会错号
证据接口与面板
- 片段链接上查询步骤:SQL、被引用的行加前后各 2 行、高亮到格;数据源可设 mask_columns 遮罩列,
遮罩在查询时记进快照,数据源事后改名或删除也照样遮罩(在有身份体系之前,遮罩只减少暴露,不是安全边界)
- 输入来源写明核对结果(与快照一致 / 模型报的值和快照不一致 / 没查到记为空);口径卡缺输入时也给出完整的链
- 受管出具要在契约里声明 cells 才能引用单元格;受管与否记在所钉版本上,改画布不再让受管版本的正式运行降级
- 口径卡可以用 caliber_from 钉住另一个已发布工作流里的口径卡,上游有新版本时按 upgrade_policy 处置,
和子工作流同一套规则;子工作流节点也补上了升版处置的配置项
Copilot、校验与报错
- 问数据的答案里有数字时,搭 input → 智能体 → 报告 → 出口,报告直接引用查过的单元格
- 写 SQL 前核对数据源的表结构,SQL 里出现表结构里没有的列会被打回
- 不再搭「智能体文字 → 转换节点解析 JSON」这种图;转换节点的 JSON 报错分清是上游模型写坏了、
模板里裸插了模型文字(给出 | json 的改法),还是模板本身写错了
- 工具节点遇到「查询失败」「SQL 被拒绝」判为失败;发起运行前检查绑定的工具是否存在,不存在时直接拒绝并给出入口
二、发布前自动修复
- 问题带上机器编号(code)和修复 id;新增只读接口 POST /api/workflows/{id}/publish-check 和 /autofix
- 修复分三类:答案唯一、不降低要求的一键修复;要人拿主意的给选项(不替人选);结构性问题交给 Copilot
- 一律先预览,不自动保存、不自动发布;删节点、删契约、放宽审批、关掉 strict、改级别等降低要求的改动一律拒绝,
Copilot 的方案也要过这一关;应用后重跑检查,错误必须变少且不能冒出新的
- 发布弹窗打开就先检查;问题面板新增「发布前检查」,平时就能看到、就地修掉
- 门禁修正:契约有 report_from 时不再要求 narrative(模板⑨可以发成受管);全图默认审批为「全部放行」时同样拦下
- 修掉画布保存会抹掉全图默认(defaults)的老问题:以前任何一次保存都会把它清空,审批 always 因此被悄悄放宽
验证(沙箱):tsc 干净,生产构建通过,pytest 2268 passed(两遍),check-all 17 项检查、3915 项全过
(新增 check-publish);关键行为做了变异验证、全部抓到。
187 lines
7.9 KiB
Python
187 lines
7.9 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Any
|
||
|
||
from app.core.events import EventType
|
||
from app.db.base import SessionLocal
|
||
from app.engine.context import NodeContext, NodeError
|
||
from app.engine.evidence import ledger_enabled, next_exec
|
||
from app.engine.state import GraphState, template_context
|
||
from app.memory import kb, store
|
||
|
||
|
||
async def run_memory(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||
"""长期记忆读写节点。
|
||
|
||
把记忆做成画布上的一个节点,而不是藏在 agent 内部,是为了让"什么时候记、
|
||
记什么、记到哪个 scope"变成可见、可调的编排决策。
|
||
"""
|
||
action = ctx.cfg("action", "recall") # recall | write | clear
|
||
scope = ctx.render_str(ctx.cfg("scope", "") or ctx.run.memory_scope, state)
|
||
|
||
async with SessionLocal() as session:
|
||
if action == "recall":
|
||
query = ctx.render_str(ctx.cfg("query", "{{ last_message }}"), state)
|
||
limit = int(ctx.cfg("limit", 5) or 5)
|
||
hits = await store.recall(
|
||
session, scope=scope, query=query, limit=limit, kind=ctx.cfg("kind") or None
|
||
)
|
||
text = "\n".join(f"- {h['content']}" for h in hits)
|
||
result: dict[str, Any] = {
|
||
"memories": hits,
|
||
"count": len(hits),
|
||
"text": text,
|
||
"scope": scope,
|
||
}
|
||
elif action == "write":
|
||
content = ctx.render_str(ctx.cfg("content", ""), state)
|
||
if not content.strip():
|
||
raise NodeError(ctx.node.id, "记忆写入节点没有内容")
|
||
item = await store.remember(
|
||
session,
|
||
scope=scope,
|
||
content=content,
|
||
kind=ctx.cfg("kind", "fact") or "fact",
|
||
importance=float(ctx.cfg("importance", 0.5) or 0.5),
|
||
meta={"run_id": ctx.run.run_id, "node_id": ctx.node.id},
|
||
)
|
||
result = {"id": item.id, "saved": True, "scope": scope, "content": item.content}
|
||
elif action == "clear":
|
||
removed = await store.clear_scope(session, scope)
|
||
result = {"cleared": removed, "scope": scope}
|
||
else:
|
||
raise NodeError(ctx.node.id, f"未知的记忆操作:{action}")
|
||
|
||
# 写入必须看得见:这是往长期记忆里存东西,会影响以后每一次对话。
|
||
# 以前发的是 info 日志,而解码层丢弃所有 info,等于系统背着用户记了东西
|
||
ctx.emit(
|
||
EventType.MEMORY_END,
|
||
action=action,
|
||
scope=scope,
|
||
count=result.get("count", result.get("cleared", 1 if action == "write" else 0)),
|
||
# 记了什么要原样说出来,只报"写了 1 条"等于没说
|
||
content=result.get("content", "")[:300],
|
||
)
|
||
|
||
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
|
||
var_name = ctx.cfg("assign_to", "")
|
||
if var_name:
|
||
updates["vars"] = {var_name: result.get("text", result)}
|
||
return updates
|
||
|
||
|
||
async def run_retrieve(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||
"""知识库检索节点。输出既有结构化命中列表,也有拼好的上下文文本。"""
|
||
query = ctx.render_str(ctx.cfg("query", "{{ last_message }}"), state)
|
||
if not query.strip():
|
||
raise NodeError(ctx.node.id, "检索节点没有查询内容")
|
||
|
||
collection = ctx.render_str(ctx.cfg("collection", "") or ctx.run.collection, state)
|
||
limit = int(ctx.cfg("limit", 5) or 5)
|
||
# 不写死 0.5:没配置就跟着 embedder 的能力走(见 embeddings.default_alpha)
|
||
raw_alpha = ctx.cfg("alpha")
|
||
alpha = float(raw_alpha) if raw_alpha not in (None, "") else None
|
||
|
||
# 要重排就先多捞一些:重排只能在初筛给出的候选里挑,初筛只给 5 条的话
|
||
# 它最多把这 5 条换个顺序,第 6 条是对的也救不回来
|
||
mode = ctx.cfg("rerank", "off") or "off"
|
||
fetch = max(limit, 20) if mode == "model" else limit
|
||
|
||
degraded: list[str] = []
|
||
async with SessionLocal() as session:
|
||
hits = await kb.search(
|
||
session, collection=collection, query=query, limit=fetch, alpha=alpha,
|
||
on_degrade=degraded.append,
|
||
)
|
||
|
||
if mode == "model" and hits:
|
||
from app.engine.nodes.llm import _model_spec
|
||
from app.memory.rerank import rerank
|
||
from app.providers.factory import ProviderNotConfigured, get_chat_model
|
||
|
||
# 重排有自己的预算,不跟节点上那个走:
|
||
#
|
||
# 它的输出只有一行几十字的 JSON,但模型为了读完 20 条候选会花掉上千个
|
||
# 输出 token——实测 deepseek-v4-pro 单次用 800~1600 个,一旦顶到上限,
|
||
# 正文就什么都不剩(失败那次 out_tokens 正好等于 max_tokens)。
|
||
#
|
||
# thinking=off 对 Anthropic 有效;openai_compatible 那条路径不读这个字段
|
||
# (factory.py 只在 anthropic 分支里设 thinking),所以真正兜住的是
|
||
# 上面那个预算。打分任务本来也不需要思考模式。
|
||
spec = _model_spec(ctx).model_copy(update={
|
||
"max_tokens": max(int(ctx.cfg("max_tokens") or 0), 4096),
|
||
"thinking": "off",
|
||
})
|
||
try:
|
||
async with SessionLocal() as session:
|
||
model, _ = await get_chat_model(session, spec)
|
||
except ProviderNotConfigured as e:
|
||
degraded.append(f"重排用不了:{e}")
|
||
model = None
|
||
hits = await rerank(model, query, hits, top_n=limit, on_note=degraded.append)
|
||
else:
|
||
hits = hits[:limit]
|
||
for note in degraded:
|
||
# 检索悄悄少了一半能力,不说的话用户只会觉得"最近搜得不准"
|
||
ctx.emit(EventType.LOG, level="warn", message=note, code="retrieve_degraded")
|
||
|
||
min_score = float(ctx.cfg("min_score", 0.0) or 0.0)
|
||
hits = [h for h in hits if h["score"] >= min_score]
|
||
|
||
# 拼成可以直接塞进 prompt 的上下文,带编号方便模型引用出处
|
||
context_text = "\n\n".join(
|
||
f"[{i + 1}] {h['title']}(片段 {h['ordinal']})\n{h['content']}"
|
||
for i, h in enumerate(hits)
|
||
)
|
||
# 命中落工件库。SQL 取数、工具调用、agent 工具调用都有快照,检索一直没有,
|
||
# 于是结论只要来自知识库,"这个数是哪来的"这条链就断在这儿
|
||
from app.core.artifact_store import put_json
|
||
from app.memory.embeddings import default_alpha, embedder_id
|
||
|
||
snapshot: str | None = None
|
||
try:
|
||
snapshot = await put_json(
|
||
{
|
||
"query": query, "collection": collection,
|
||
"alpha": alpha if alpha is not None else default_alpha(),
|
||
"min_score": min_score,
|
||
"embedder": embedder_id(), "rerank": mode, "degraded": degraded,
|
||
"hits": hits,
|
||
},
|
||
kind="retrieval_snapshot",
|
||
run_id=ctx.run.run_id, node_id=ctx.node.id,
|
||
)
|
||
except Exception: # noqa: BLE001 - 存不下不影响这次检索本身
|
||
snapshot = None
|
||
|
||
ctx.emit(
|
||
EventType.RETRIEVE_END,
|
||
collection=collection,
|
||
query=query[:200],
|
||
count=len(hits),
|
||
top_score=hits[0]["score"] if hits else 0,
|
||
reranked=mode == "model",
|
||
degraded=bool(degraded),
|
||
artifact=snapshot,
|
||
)
|
||
|
||
result = {
|
||
"hits": hits,
|
||
"count": len(hits),
|
||
"text": context_text,
|
||
"query": query,
|
||
"collection": collection,
|
||
"artifact": snapshot,
|
||
}
|
||
|
||
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
|
||
if snapshot and ledger_enabled(ctx.run):
|
||
# 证据台账:报告目录按它编 K1…,[[see:K1]] 点开追得到这次检索命中了哪几段
|
||
updates["evidence"] = [{"kind": "retrieval", "node_id": ctx.node.id, "exec": next_exec(state, ctx.node.id),
|
||
"artifact": snapshot, "source": collection, "rows": len(hits)}]
|
||
var_name = ctx.cfg("assign_to", "")
|
||
if var_name:
|
||
updates["vars"] = {var_name: context_text}
|
||
return updates
|