mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:01:57 +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);关键行为做了变异验证、全部抓到。
191 lines
8.7 KiB
Python
191 lines
8.7 KiB
Python
"""文本列按文本渲染:查询快照带着 column_types(查询时从驱动的原始值记下的)。
|
||
|
||
以前快照里没有列类型,VARCHAR 的 "2026" 和小数位为 0 的 DECIMAL "45678" 长得一模一样,只能
|
||
「像数字就当数字」:报告里 [[v:Q1.r0.week]] 渲染成「2,026」。现在 text 列一律按文本:
|
||
render_cell、[[v:]] 的渲染和取值、写作目录、cite_fields 没声明类型的字段。拿不到 column_types
|
||
的老快照保持原来的启发式。
|
||
|
||
口径卡表达式里的 cell() 照旧按值猜:那是在算数,数字写成文本的列(CAST 成文本、VARCHAR 存金额)
|
||
多半就是要拿来算的;要文本写 str(cell(...))。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import sqlite3
|
||
|
||
import pytest
|
||
from langchain_core.messages import AIMessage
|
||
from sqlalchemy import select
|
||
|
||
from app.core import artifact_store
|
||
from app.data.engine import engines
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import DataSource, Run
|
||
from app.engine.evidence import (
|
||
RenderError,
|
||
build_catalog,
|
||
catalog_prompt,
|
||
compose_doc,
|
||
iter_segments,
|
||
render_cell,
|
||
verify_doc,
|
||
)
|
||
from app.engine.expressions import cell_value, column_kind, eval_expression
|
||
from app.engine.runner import run_manager
|
||
|
||
BASE = {"columns": ["week", "gmv", "code"], "rows": [["2026", "45678", "10001"], ["2027", "12.50", "10002"]],
|
||
"row_count": 2, "truncated": False, "elapsed_ms": 1, "sql": "SELECT week, gmv, code FROM orders",
|
||
"source": "shop"}
|
||
TYPED = {**BASE, "column_types": {"week": "text", "gmv": "number", "code": "text"}}
|
||
|
||
|
||
def _store(snap: dict) -> str:
|
||
text = artifact_store.canonical_json(snap)
|
||
artifact = artifact_store.content_hash(text)
|
||
path = artifact_store._path_of(artifact)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(text, encoding="utf-8")
|
||
return artifact
|
||
|
||
|
||
def catalog_of(snap: dict) -> dict:
|
||
artifact = _store(snap)
|
||
return build_catalog(nodes={}, ledger=[{
|
||
"kind": "query", "node_id": "fetch", "exec": 1, "artifact": artifact, "via": "b" * 64, "tool": "db_query__shop",
|
||
"source": "shop", "columns": snap["columns"], "rows": len(snap["rows"]), "truncated": False}], inputs={})
|
||
|
||
|
||
def test_cell_value_keeps_text_columns_as_text():
|
||
assert cell_value("2026", "text") == "2026"
|
||
assert cell_value("45678", "number") == 45678
|
||
assert cell_value("2026") == 2026 # 老快照:照旧按值猜
|
||
assert cell_value("2026", None) == 2026
|
||
assert column_kind(TYPED, "week") == "text" and column_kind(TYPED, "gmv") == "number"
|
||
assert column_kind(BASE, "week") is None and column_kind(TYPED, "nope") is None
|
||
assert column_kind({"column_types": "bad"}, "week") is None
|
||
|
||
|
||
def test_render_cell_reads_the_column_type():
|
||
assert render_cell("2026", kind="text") == "2026"
|
||
assert render_cell("10001", kind="text") == "10001"
|
||
assert render_cell("45678", kind="number") == "45,678"
|
||
assert render_cell("2026") == "2,026"
|
||
with pytest.raises(RenderError):
|
||
render_cell("2026", "万", kind="text")
|
||
|
||
|
||
def test_cell_refs_render_text_columns_as_text():
|
||
doc = compose_doc("[[v:Q1.r0.week]] 周销售额 [[v:Q1.r0.gmv]],编号 [[v:Q1.r1.code]]。", catalog_of(TYPED))
|
||
assert doc["markdown"] == "2026 周销售额 45,678,编号 10002。"
|
||
week, gmv, code = [s for s in iter_segments(doc) if s.get("ref")]
|
||
assert week["kind"] == "value" and week["cite"]["value"] == "2026"
|
||
assert gmv["kind"] == "number" and gmv["cite"]["value"] == 45678
|
||
assert code["kind"] == "value" and code["cite"]["value"] == "10002"
|
||
assert doc["violations"] == []
|
||
assert verify_doc(doc, catalog_of(TYPED))["violations"] == []
|
||
|
||
|
||
def test_old_snapshots_keep_the_heuristic():
|
||
doc = compose_doc("[[v:Q1.r0.week]] 周", catalog_of(BASE))
|
||
assert doc["markdown"] == "2,026 周"
|
||
|
||
|
||
def test_a_text_column_cannot_be_converted():
|
||
doc = compose_doc("[[v:Q1.r0.week|万]]", catalog_of(TYPED))
|
||
[bad] = [v for v in doc["violations"] if v["code"] == "unresolved_ref"]
|
||
assert "不是数" in bad["message"]
|
||
|
||
|
||
def test_tables_render_text_columns_as_text():
|
||
doc = compose_doc("[[table:Q1 cols=week,gmv]]", catalog_of(TYPED))
|
||
assert "| 2026 | 45,678 |" in doc["markdown"] and "| 2027 | 12.5 |" in doc["markdown"]
|
||
|
||
|
||
def test_the_writing_catalog_shows_what_the_report_will_show():
|
||
prompt = catalog_prompt(catalog_of(TYPED))
|
||
assert "week=2026,gmv=45,678,code=10001" in prompt
|
||
# 示例挑第一个数:文本列不算
|
||
assert "例:[[v:Q1.r0.gmv]]" in prompt
|
||
assert "例:[[v:Q1.r0.week]]" in catalog_prompt(catalog_of(BASE))
|
||
|
||
|
||
def test_cite_fields_without_a_declared_type_follow_the_column():
|
||
from app.engine.nodes.llm import cited_fields, verify_cited_fields
|
||
|
||
queries = [{"alias": "Q1", "artifact": "a" * 64, "via": "b" * 64}]
|
||
parsed = {"week": {"value": "2026", "from": {"call": "Q1", "row": 0, "column": "week"}},
|
||
"gmv": {"value": 45678, "from": {"call": "Q1", "row": 0, "column": "gmv"}},
|
||
"weeks": {"value": ["2026", "2027"], "from": {"call": "Q1", "rows": "0-1", "column": "week"}}}
|
||
schema = {"type": "object", "properties": {"week": {}, "gmv": {}, "weeks": {"type": "array", "items": {}}}}
|
||
data, ev = verify_cited_fields({"parsed": parsed}, cited_fields(schema), queries, loader=lambda a: TYPED)
|
||
assert data == {"week": "2026", "gmv": 45678, "weeks": ["2026", "2027"]}
|
||
assert {e["status"] for e in ev.values()} == {"verified"}
|
||
# 声明了类型的照声明:写 schema 的人说了它是数
|
||
declared = {"type": "object", "properties": {"week": {"type": "integer"}, "gmv": {}, "weeks": {
|
||
"type": "array", "items": {"type": "number"}}}}
|
||
data, _ = verify_cited_fields({"parsed": parsed}, cited_fields(declared), queries, loader=lambda a: TYPED)
|
||
assert data == {"week": 2026, "gmv": 45678, "weeks": [2026, 2027]}
|
||
# 老快照照旧按值猜
|
||
data, _ = verify_cited_fields({"parsed": parsed}, cited_fields(schema), queries, loader=lambda a: BASE)
|
||
assert data["week"] == 2026
|
||
|
||
|
||
def test_card_expressions_keep_computing_on_number_like_text():
|
||
assert eval_expression("cell(vars.q, 0, 'week') + 1", {"vars": {"q": TYPED}}) == 2027
|
||
assert eval_expression("str(cell(vars.q, 0, 'week'))", {"vars": {"q": TYPED}}) == "2026"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 端到端:列类型从驱动一路带到报告
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
async def shop(tmp_path):
|
||
await run_manager.setup()
|
||
path = tmp_path / "shop.db"
|
||
db = sqlite3.connect(path)
|
||
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, week TEXT, amount REAL);")
|
||
db.executemany("INSERT INTO orders VALUES (?, ?, ?)", [(1, "2026", 100.5), (2, "2026", 200.0)])
|
||
db.commit()
|
||
db.close()
|
||
async with SessionLocal() as session:
|
||
row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none()
|
||
if row is None:
|
||
row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True)
|
||
session.add(row)
|
||
else:
|
||
row.database, row.kind, row.enabled, row.readonly, row.options = str(path), "sqlite", True, True, {}
|
||
await session.commit()
|
||
source_id = row.id
|
||
await engines.invalidate(source_id)
|
||
yield
|
||
await engines.invalidate(source_id)
|
||
await run_manager.shutdown()
|
||
|
||
|
||
async def test_a_report_quotes_a_text_year_as_written(shop, monkeypatch):
|
||
from app.providers import mock_model
|
||
|
||
monkeypatch.setattr(mock_model.MockChatModel, "_decide",
|
||
lambda model, messages: AIMessage(content="[[v:Q1.r0.week]] 年销售额 [[v:Q1.r0.gmv]]。"))
|
||
|
||
def node(nid, ntype, **config):
|
||
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
|
||
|
||
nodes = [node("start", "input"),
|
||
node("fetch", "tool", tool="db_query__shop",
|
||
args={"sql": "SELECT week, SUM(amount) AS gmv FROM orders GROUP BY week"}),
|
||
node("write", "report", instructions="写一句话", on_violation="flag"),
|
||
node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}])]
|
||
graph = {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
|
||
run = await run_manager.start(graph=graph, input_payload={})
|
||
for _ in range(400):
|
||
await asyncio.sleep(0.05)
|
||
async with SessionLocal() as session:
|
||
row = await session.get(Run, run.id)
|
||
if row.status in ("succeeded", "failed"):
|
||
break
|
||
assert row.status == "succeeded", row.error
|
||
assert row.output["答案"] == "2026 年销售额 300.5。"
|