diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 778153e9..85870718 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -209,11 +209,13 @@ NODE_REFERENCE = """\ metrics_from 不写就取所有上游口径卡;on_violation 不写时探索运行是 flag(标出来、照常产出),正式运行是 fail; numbers 默认 strict(裸数字算违规)。claims 是结论句策略:off(默认,不管)/ require_citation(每句结论都要 挂引用,没挂的计入缺口、出具降档)/ judge(在 require_citation 之外,再请另一个模型按每句挂的依据判断支不支持: - 正式运行在节点里判,证据不支持的按 judge.on_unsupported 处置——degrade 降档(默认)或 withhold 不予出具; + 正式运行在节点里判,证据相矛盾的按 judge.on_unsupported 处置——degrade 降档(默认)或 withhold 不予出具, + 证据不足的(摘录里没有判断所需的信息)只降档; 探索运行不在节点里花钱,读的人点开哪句再按需判哪句,只标注)。judge 的子配置写在节点的 judge 里:judge.model、 judge.provider 指定裁判模型(建议和写作模型不同,同一个模型等于自己审自己;不写就用设置里的「证据裁判模型」); judge.max_cost_usd、judge.max_claims、judge.timeout_s 是每份报告的裁判金额、句数、时长上限,写 null 表示不限, - 不写取设置里的默认;judge.rewrite_once 默认 false,打开后把证据不支持的句子交回写作者只改一次。entities 默认 link + 不写取设置里的默认;judge.rewrite_once 默认 false,打开后把证据相矛盾、证据不足的句子交回写作者只改一次。 + entities 默认 link (核对表名、字段名),报告里根本不提表名时才写 off - output 的出具契约:config.contract = {report_from: 报告节点id, metrics_from:[口径卡id], required:[必需指标id], expected:[期望指标id], strict: true}。成果字段写 {{ nodes.报告节点id.text }}, diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 9894614e..9085f4c9 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -66,10 +66,13 @@ from app.engine.evidence import ( normalize_quote, query_entry_fields, render_metric, + schema_column, + schema_table, sql_tables, + table_fields, uncited_claims, ) -from app.engine.judge import JUDGED, VERDICTS, candidates +from app.engine.judge import FIELDS_MAX, SETTLED, UNSUPPORTED, VERDICTS, candidates, outdated from app.engine.toolcalls import QUERY_PREFIX router = APIRouter(prefix="/api/runs", tags=["evidence"]) @@ -727,7 +730,8 @@ async def _chain(seg: dict[str, Any], doc: dict[str, Any], sealed: _Sealed, if seg.get("kind") == "entity" and seg.get("issue") in SUSPICIOUS: return [_suspicious_step(seg, cite, sealed, report_node)] if seg.get("kind") == "entity" and seg.get("state") == "deterministic" and cite.get("status") == "resolved": - return [_entity_step(seg, cite, entry, sealed)] + hidden = await _entity_hidden(entry, sealed, masks) if entry.get("kind") == "table" else set() + return [_entity_step(seg, cite, entry, sealed, hidden=hidden)] if seg.get("kind") == "quote" and seg.get("state") == "deterministic" and cite.get("status") == "resolved": return [_quote_step(seg, cite, entry, sealed)] if _missing_input(cite, entry): @@ -928,22 +932,10 @@ def _fetch(artifact: str) -> tuple[Any, bool | None]: return content, (True if content is not None else None) -def _schema_table(content: Any, name: Any) -> dict[str, Any] | None: - """表结构快照里的一张表:表名一字不差的优先,其次不分大小写、全名(schema.表)也认。""" - tables = content.get("tables") if isinstance(content, dict) else None - if not isinstance(tables, dict) or not name: - return None - if isinstance(tables.get(name), dict): - return tables[name] - lowered = str(name).lower() - return next((t for key, t in tables.items() if isinstance(t, dict) - and lowered in (str(key).lower(), str(t.get("qualified") or "").lower())), None) - - -def _schema_column(table: dict[str, Any] | None, name: Any) -> dict[str, Any] | None: - columns = [c for c in (table or {}).get("columns") or [] if isinstance(c, dict)] - return next((c for c in columns if c.get("name") == name), None) \ - or next((c for c in columns if str(c.get("name") or "").lower() == str(name or "").lower()), None) +#: 表结构快照里找表、找字段:和裁判的摘录同一套(engine/evidence.py)。表名实体步骤的字段清单也和裁判的 +#: 摘录同一个上限(judge.FIELDS_MAX) +_schema_table = schema_table +_schema_column = schema_column def _entity_present(content: Any, kind: str, name: str, table: Any, column: Any, tables: list[Any]) -> bool: @@ -963,12 +955,34 @@ def _query_present(content: Any, kind: str, name: str, column: Any) -> bool: return str(column) in [str(c) for c in content.get("columns") or []] -def _entity_step(seg: dict[str, Any], cite: dict[str, Any], entry: dict[str, Any], sealed: _Sealed) -> dict[str, Any]: +async def _entity_hidden(entry: dict[str, Any], sealed: _Sealed, masks: _Masks) -> set[str]: + """表的字段清单要遮的列(小写):数据源现在设的,加上这次运行封存的查询快照里、同一个数据源当时记下的 + (数据源改名、删掉了也照遮,和查询步骤同一个口径)。""" + source = str(entry.get("source") or "") + hidden, _ = await masks.of(source) if source else (set(), True) + hidden = set(hidden) + for artifact, info in sealed.queries.items(): + if not sealed.trusted: + break + snap, ok = _fetch(artifact) + if ok is not True or not isinstance(snap, dict): + continue + if not source or str(info.get("source") or snap.get("source") or "") == source: + recorded = snap.get("mask_columns") + hidden |= {str(c).lower() for c in recorded if isinstance(c, str)} if isinstance(recorded, list) else set() + return hidden + + +def _entity_step(seg: dict[str, Any], cite: dict[str, Any], entry: dict[str, Any], sealed: _Sealed, *, + hidden: set[str] | frozenset[str] = frozenset()) -> dict[str, Any]: """实体步骤:这个名字出现在哪几次查询里(SQL 用到的表、结果的列)、表结构快照什么时候同步的、字段类型。 来源(entry.sources)逐个核对:表结构快照要出现在封存范围内的 tool.end.schema_artifact 或 schema 台账 条目里,查询快照要是封存范围内交回过的那几件。类型、同步时间只从核对过、复验过哈希的表结构快照取, 取不到就不给。eid 背后的那件工件核对通过、名字确实在里面,这一步才算已封存。 + + 表另带字段清单 fields: [{name, type}](同一份核对过的快照,最多 FIELDS_MAX 个,多出来的记 fields_more): + 讲表结构的句子,读的人在「挂的依据」下直接看得到有哪些字段。hidden 里的列(遮罩)不列。 """ kind = str(cite.get("kind") or entry.get("kind") or "") locator = dict(entry.get("locator") or cite.get("locator") or {}) @@ -1022,7 +1036,8 @@ def _entity_step(seg: dict[str, Any], cite: dict[str, Any], entry: dict[str, Any step["sources"].append(item) step["sealed"] = sealed.trusted and backed and step["eid_ok"] if snapshots: - _describe_entity(step, snapshots[0], kind, name, table, column, list(entry.get("tables") or [])) + _describe_entity(step, snapshots[0], kind, name, table, column, list(entry.get("tables") or []), + hidden=hidden) if kind == "column" and not any(o.get("kind") == "schema" for o in step["sources"]): # 聚合的别名这类只在结果里出现的列:类型取查询时从驱动的原始值记下的列类型。表结构里有的列 # 只认表结构快照的类型,快照核对不过就不给——不拿结果列的类型顶上 @@ -1033,8 +1048,8 @@ def _entity_step(seg: dict[str, Any], cite: dict[str, Any], entry: dict[str, Any def _describe_entity(step: dict[str, Any], snap: dict[str, Any], kind: str, name: str, table: Any, column: Any, - tables: list[Any]) -> None: - """从表结构快照里补上同步时间、表的概况、字段的类型。""" + tables: list[Any], *, hidden: set[str] | frozenset[str] = frozenset()) -> None: + """从表结构快照里补上同步时间、表的概况(含字段清单)、字段的类型。""" if snap.get("synced_at"): step["synced_at"] = snap["synced_at"] if snap.get("truncated"): @@ -1045,6 +1060,12 @@ def _describe_entity(step: dict[str, Any], snap: dict[str, Any], kind: str, name step["is_view"] = bool(info.get("is_view")) if info.get("comment"): step["comment"] = info["comment"] + fields, more, masked = table_fields(info, hidden, limit=FIELDS_MAX) + step["fields"] = fields + if more: + step["fields_more"] = more + if masked: + step["fields_masked"] = True return owners = [table] if table else tables found = {str(t): (tinfo, _schema_column(tinfo, column)) for t in owners @@ -1200,6 +1221,8 @@ UNSEALED_JUDGE = "运行尚未结束并封存(仍在运行、停在人工审 LEGACY_JUDGE = "这次运行发起时系统尚未记录证据,不支持按需裁判" BROKEN_JUDGE = "封存核对未通过:封存后有事件被修改、删除或插入,报告和证据已不可信,无法再请模型裁判" JUDGED_ALREADY = "这句已裁判" +#: 这句封存后按需判过,但那次早于当前的裁判规则(没有规则版本,或者是旧取值「证据不支持」):可以按当前规则重判 +OUTDATED_JUDGE = "这句的判定早于当前的裁判规则,可以请模型按当前规则重新判断" #: 裁判期间运行被接着跑了(失败的运行可以继续),或者接着跑完重新封存了:判定照样交回,不记进运行记录 NOT_RECORDED = "运行已继续执行,本次裁判结果未写入运行记录,刷新后将不再显示。请在运行结束并封存后重新裁判" NOT_A_CLAIM = "不是结论句(标题、连接性文字、表格单元格或代码),无需模型裁判" @@ -1232,7 +1255,8 @@ def _effective_verdicts(report: _Report, sealed: _Sealed doc = report.doc or {} ids = {str(u.get("id")) for _, u in iter_units(doc)} out = {str(u.get("id")): u["verdict"] for _, u in iter_units(doc) if _verdict_ok(u.get("verdict"))} - sealed_judged = {uid for uid, v in out.items() if v.get("status") in JUDGED} + # 旧数据里的 unsupported 同样算判过(SETTLED):封存的判定用什么取值,都不能被追加的判定盖掉 + sealed_judged = {uid for uid, v in out.items() if v.get("status") in SETTLED} later: dict[str, dict[str, Any]] = {} for _, _, data in sealed.judged: if data.get("report") != report.node_id or data.get("doc_artifact") != report.doc_artifact: @@ -1242,17 +1266,48 @@ def _effective_verdicts(report: _Report, sealed: _Sealed uid = str(uid) if uid not in ids or uid in sealed_judged or not _verdict_ok(verdict): continue - if later.get(uid, {}).get("status") in JUDGED and verdict.get("status") not in JUDGED: + if later.get(uid, {}).get("status") in SETTLED and verdict.get("status") not in SETTLED: continue later[uid] = out[uid] = {**verdict, "post_seal": True} return out, later def _judgement_of(report: _Report, sealed: _Sealed) -> dict[str, Any]: - """证据图、审计表里每份报告带的结论句情况:写作时的策略、封存的裁判摘要、封存之后按需追加的判定。""" + """证据图、审计表里每份报告带的结论句情况:写作时的策略、封存的裁判摘要、封存之后按需追加的判定, + 和最近一次按需裁判用的模型(judge_meta,没按需裁判过是 None)。""" doc = report.doc or {} return {"claims": report.claims, "judge": doc.get("judge") if isinstance(doc.get("judge"), dict) else None, - "post_seal_verdicts": _effective_verdicts(report, sealed)[1] if report.doc is not None else {}} + "post_seal_verdicts": _effective_verdicts(report, sealed)[1] if report.doc is not None else {}, + "judge_meta": _judge_meta(report, sealed)} + + +def _writer_model(sealed: _Sealed, report: _Report) -> str | None: + """这份报告的写作模型:文档的裁判摘要里记的(写作时当场记下),没有就取报告撰写节点封存的产出里的 model + (实际用的模型 id,老文档也有)。都取不到是 None。""" + judge = (report.doc or {}).get("judge") + if isinstance(judge, dict) and isinstance(judge.get("writer_model"), str) and judge["writer_model"]: + return judge["writer_model"] + payload = sealed.payload_of([report.node_id]) or {} + model = payload.get("model") + return model if isinstance(model, str) and model else None + + +def _same_model(model: Any, writer: str | None) -> bool: + return bool(model) and bool(writer) and model == writer + + +def _judge_meta(report: _Report, sealed: _Sealed) -> dict[str, Any] | None: + """最近一次按需裁判(evidence.judged,指向这份文档的)用的模型:{model, same_model, priced, writer_model}。 + + 事件里没记 same_model、writer_model 的(改造前追加的),按这份报告的写作模型补上。""" + data = next((d for _, _, d in reversed(sealed.judged) + if d.get("report") == report.node_id and d.get("doc_artifact") == report.doc_artifact), None) + if data is None: + return None + writer = data.get("writer_model") if "writer_model" in data else _writer_model(sealed, report) + same = data.get("same_model") if isinstance(data.get("same_model"), bool) else _same_model(data.get("model"), writer) + return {"model": data.get("model"), "same_model": same, + "priced": data.get("priced") if isinstance(data.get("priced"), bool) else None, "writer_model": writer} def _on_demand(sealed: _Sealed, doc: dict[str, Any], uid: str, verdict: dict[str, Any] | None) -> dict[str, Any]: @@ -1266,15 +1321,25 @@ def _on_demand(sealed: _Sealed, doc: dict[str, Any], uid: str, verdict: dict[str return no("legacy", LEGACY_JUDGE) if not candidates(doc, [uid]): return no("not_a_claim", f"这句{NOT_A_CLAIM}") - if isinstance(verdict, dict) and verdict.get("status") in JUDGED: + stale = _stale(verdict) + if isinstance(verdict, dict) and verdict.get("status") in SETTLED and not stale: return no("judged", JUDGED_ALREADY) if not sealed.sealed or sealed.status not in _TERMINAL: return no("unsealed", UNSEALED_JUDGE) if not sealed.trusted: return no("seal_broken", BROKEN_JUDGE) + if stale: + return {"available": True, "reason": "outdated", "message": OUTDATED_JUDGE} return {"available": True, "reason": None, "message": None} +def _stale(verdict: Any) -> bool: + """封存之后按需追加的判定早于当前规则:按需裁判当它还没按当前规则判过,重判一次(不按摘录内容判断)。 + + 封存的判定(文档里的,post_seal 为 False)不在此列:追加的判定盖不掉它,版本再旧也不重开。""" + return isinstance(verdict, dict) and bool(verdict.get("post_seal")) and outdated(verdict) + + def _sealed_loader(sealed: _Sealed) -> Callable[[str], Any]: """给裁判取证据的 loader:只认封存范围内的事件交回过的工件(查询、表结构、检索快照和台账里记过的), 取回时照旧复验哈希。别的一律当取不到——人为插进工件表的行、封存之后才追加的事件引用的快照, @@ -1371,13 +1436,15 @@ async def _judge_now(sealed: _Sealed, report: _Report, units: list[str]) -> dict doc = report.doc or {} catalog = doc.get("catalog") or {} known = _effective_verdicts(report, sealed)[0] - reused = [u for u in units if known.get(u, {}).get("status") in JUDGED] + # 当前规则下判过的直接给;早于当前规则的(封存之后追加的旧判定)重判一次,和片段接口的 outdated 同一条件 + reused = [u for u in units if known.get(u, {}).get("status") in SETTLED and not _stale(known.get(u))] pending = [u for u in units if u not in reused] verdicts = {u: known[u] for u in reused} node_judge = _node_judge(sealed, report.node_id) async with SessionLocal() as session: settings = await judging.judge_settings(session) budget = judging.click_budget(settings, node_judge) + writer = _writer_model(sealed, report) outcome: dict[str, Any] | None = None skipped: dict[str, str] = {} event = None @@ -1391,7 +1458,13 @@ async def _judge_now(sealed: _Sealed, report: _Report, units: list[str]) -> dict async with SessionLocal() as session: spec = await judging.judge_model_spec(session, node_judge, settings=settings) outcome = await judging.run_request(request, spec=spec, budget=budget, post_seal=True, click=True) - verdicts.update(outcome["verdicts"]) + for uid, verdict in outcome["verdicts"].items(): + # 重判没判成(触顶、模型不可用):旧判定照旧生效,和证据图、片段接口说的一样 + older = known.get(uid) + keep = verdict.get("status") not in SETTLED and isinstance(older, dict) and older.get("status") in SETTLED + verdicts[uid] = older if keep else verdict + outcome["writer_model"] = writer + outcome["same_model"] = _same_model(outcome.get("model"), writer) if outcome["calls"] or outcome["cost_usd"]: # 没问模型(触顶、模型没配好)就没有判定可记:说给点的人听,不往运行记录里追加 event = await _append_judged(sealed, report, pending, outcome) @@ -1403,12 +1476,14 @@ async def _judge_now(sealed: _Sealed, report: _Report, units: list[str]) -> dict return { "run_id": sealed.run_id, "report": {"node_id": report.node_id, "doc_artifact": report.doc_artifact}, "units": units, "verdicts": {u: verdicts[u] for u in units if u in verdicts}, "reused": reused, - "judged": [u for u in pending if (outcome or {}).get("verdicts", {}).get(u, {}).get("status") in JUDGED], + "judged": [u for u in pending if (outcome or {}).get("verdicts", {}).get(u, {}).get("status") in SETTLED], "skipped": skipped, "unjudged": dict(outcome["unjudged"]) if outcome else {}, "limits_hit": limits, "limited": bool(limits), "message": message, "adjust": [JUDGE_ADJUST[r] for r in limits if r in JUDGE_ADJUST], "gaps": list(outcome["gaps"]) if outcome else [], "notes": list(outcome["notes"]) if outcome else [], "model": (outcome or {}).get("model"), "priced": (outcome or {}).get("priced"), + # 裁判模型和这份报告的写作模型相同:判定照常出,界面上标出来(审查缺乏独立性) + "same_model": bool((outcome or {}).get("same_model")), "writer_model": writer, "cost_usd": (outcome or {}).get("cost_usd", 0.0), "calls": (outcome or {}).get("calls", 0), "duration_ms": (outcome or {}).get("duration_ms", 0), "budget": budget.as_dict() if outcome else None, "event": event, "post_seal": True, @@ -1461,7 +1536,8 @@ async def _append_judged(sealed: _Sealed, report: _Report, units: list[str], "report": report.node_id, "doc_artifact": report.doc_artifact, "units": units, "verdicts": outcome["verdicts"], "keys": {u: outcome["keys"][u] for u in outcome["verdicts"] if u in outcome["keys"]}, - "model": outcome["model"], "priced": outcome["priced"], "cost_usd": outcome["cost_usd"], + "model": outcome["model"], "priced": outcome["priced"], "same_model": outcome["same_model"], + "writer_model": outcome["writer_model"], "cost_usd": outcome["cost_usd"], "calls": outcome["calls"], "duration_ms": outcome["duration_ms"], "budget": outcome["budget"], "limits_hit": outcome["limits_hit"], "unjudged": outcome["unjudged"], "gaps": outcome["gaps"], "notes": outcome["notes"], "skipped": outcome["skipped"], "post_seal": True}) @@ -1501,6 +1577,13 @@ UNCITED_CLAIM_JUDGED = "这句结论没有附依据。本报告的结论句由 UNCITED_CLAIM_JUDGE_REQUIRED = ("这句结论没有附依据。本报告的结论句由模型裁判,同样要求附依据,但目前没有出具契约" "据此判档(未配置契约,或尚未到出具步骤)") LEGACY_UNMATCHED = "旧版出具中这个数字与任何指标都不相符" +#: 模型判定有问题的结论句各一行(生效的判定:封存的,没有就是封存后按需追加的):判定 → 审计行的 issue。 +#: 旧数据里的 unsupported(当时没分矛盾和不足)记 unsupported_claim,说法照旧是「证据不支持」 +CLAIM_ISSUE = {"contradicted": "contradicted_claim", "insufficient": "insufficient_claim", + UNSUPPORTED: "unsupported_claim"} +CLAIM_VERDICT_NOTE = {"contradicted": "模型判断:证据与这句结论相矛盾", "insufficient": "模型判断:证据不足,无法判断这句结论", + UNSUPPORTED: "模型判断:证据不支持这句结论"} +CLAIM_POST_SEAL = "该判定为封存后按需追加,不在封存范围内" #: CSV 的列:(表头, 行里的键) CSV_COLUMNS = ( @@ -1521,6 +1604,7 @@ _CSV_LABELS: dict[str, dict[Any, str]] = { "state": {"deterministic": "有出处", "none": "无证据", "candidate": "候选"}, "issue": {"uncited_number": "没有出处的数字", "unresolved_ref": "引用无法解析", "unknown_entity": "疑似不存在的名称", "unverified_entity": "无法核实", "uncited_claim": "结论句未附依据", "no_candidate": "未找到候选", + "contradicted_claim": "证据相矛盾", "insufficient_claim": "证据不足", "unsupported_claim": "证据不支持", **{code: "文档不一致" for code in _INTEGRITY}}, "evidence_kind": {"metric": "口径卡指标", "cell": "查询单元格", "input": "运行输入", "table": "表", "column": "字段", "quote": "知识库原话", "query": "查询结果"}, @@ -1670,6 +1754,7 @@ def _audit_doc(report: _Report, sealed: _Sealed, inputs: dict[str, Any], "issue": "uncited_claim", "ref": None, "alias": None, "evidence_kind": None, "evidence": None, "eid": None, "artifact": None, "node_id": None, "sealed": None, "span": claim["span"], "sentence": claim["text"], "note": claim_note}) + rows.extend(_verdict_rows(report, sealed, field_name, markdown)) for v in doc.get("violations") or []: if not isinstance(v, dict) or (str(v.get("segment")) in listed and v.get("code") not in _INTEGRITY): continue @@ -1683,6 +1768,32 @@ def _audit_doc(report: _Report, sealed: _Sealed, inputs: dict[str, Any], return rows +def _verdict_rows(report: _Report, sealed: _Sealed, field_name: str | None, markdown: str) -> list[dict[str, Any]]: + """模型判定证据相矛盾、证据不足(旧数据里的证据不支持)的结论句,各一行,进「无证据」一组(问题)。 + + 判定是模型的判断,不是证据:sealed 为 None,说明里写明是模型判断;封存后按需追加的另外注明。""" + effective, _ = _effective_verdicts(report, sealed) + units = {str(u.get("id")): u for _, u in iter_units(report.doc or {})} + rows = [] + for uid, verdict in effective.items(): + status = verdict.get("status") + if status not in CLAIM_ISSUE or uid not in units: + continue + span = units[uid].get("span") or [0, 0] + text = markdown[span[0]:span[1]] + missing = verdict.get("missing") if status == "insufficient" and isinstance(verdict.get("missing"), str) else "" + rationale = str(verdict.get("rationale") or "").rstrip("。") + note = CLAIM_VERDICT_NOTE[status] + (f"(缺少:{missing})" if missing else "") \ + + (f"。理由:{rationale}" if rationale else "") + (f"。{CLAIM_POST_SEAL}" if verdict.get("post_seal") else "") + rows.append({"group": "none", "report": report.node_id, "field": field_name, "segment": None, "unit": uid, + "kind": "claim", "text": text, "state": "none", "issue": CLAIM_ISSUE[status], "ref": None, + "alias": None, "evidence_kind": None, "evidence": None, "eid": None, "artifact": None, + "node_id": None, "sealed": None, "span": span, "sentence": text, "note": note, + "verdict": {k: verdict[k] for k in ("status", "rationale", "missing", "judge", "post_seal") + if k in verdict}}) + return rows + + def _segment_row(group: str, report: _Report, field_name: str | None, seg: dict[str, Any], unit: dict[str, Any], sentence: str, catalog: dict[str, Any], sealed: _Sealed, inputs: dict[str, Any]) -> dict[str, Any]: cite = seg.get("cite") or {} diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index 2e2965c3..ecd3d662 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -413,6 +413,37 @@ def schema_entry_fields(content: Any) -> dict[str, Any] | None: return fields +def schema_table(content: Any, name: Any) -> dict[str, Any] | None: + """表结构快照里的一张表:表名一字不差的优先,其次不分大小写、全名(schema.表)也认。""" + tables = content.get("tables") if isinstance(content, dict) else None + if not isinstance(tables, dict) or not name: + return None + if isinstance(tables.get(name), dict): + return tables[name] + lowered = str(name).lower() + return next((t for key, t in tables.items() if isinstance(t, dict) + and lowered in (str(key).lower(), str(t.get("qualified") or "").lower())), None) + + +def schema_column(table: dict[str, Any] | None, name: Any) -> dict[str, Any] | None: + """表结构快照里一张表的一个字段:名字一字不差的优先,其次不分大小写。""" + columns = [c for c in (table or {}).get("columns") or [] if isinstance(c, dict)] + return next((c for c in columns if c.get("name") == name), None) \ + or next((c for c in columns if str(c.get("name") or "").lower() == str(name or "").lower()), None) + + +def table_fields(table: dict[str, Any] | None, hidden: set[str] | frozenset[str] = frozenset(), *, + limit: int = 60) -> tuple[list[dict[str, str]], int, bool]: + """一张表的字段清单:([{name, type}] 最多 limit 个, 另有几个没列, 有没有字段按遮罩没列)。 + + hidden 是要遮的列(小写):遮罩的字段不列,也不计入「另有几个」。证据面板和裁判的摘录同一个口径。 + """ + columns = [c for c in (table or {}).get("columns") or [] if isinstance(c, dict) and c.get("name")] + shown = [c for c in columns if str(c["name"]).lower() not in hidden] + fields = [{"name": str(c["name"]), "type": str(c.get("type") or "")} for c in shown[:limit]] + return fields, max(0, len(shown) - limit), len(shown) < len(columns) + + def schema_ledger_entry(artifact: str, *, node_id: str, exec_no: int, loader: Callable[[str], Any] | None = None) -> dict[str, Any] | None: """一件表结构快照 → 台账的 schema 条目。快照取不回来(或者哈希对不上)返回 None。 diff --git a/backend/app/engine/judge.py b/backend/app/engine/judge.py index 0db8927e..e638e5e8 100644 --- a/backend/app/engine/judge.py +++ b/backend/app/engine/judge.py @@ -5,9 +5,11 @@ 1. 预筛(claim_priority):只把结论句送去判。挂了依据的排第一,有方向词、因果词的排第二,其余有数字、 实体这类实质内容的排第三;标题、表格单元格、代码、连接性的话、只有过渡词的、短句不送 -2. 证据摘录(prepare):句子挂的每件证据摘一段。指标写「名称 = 值」加原式;查询给 SQL 前 300 字和被 - 引用的行(最多 20 行,数据源遮罩的列不给;宽表先给被引用的列,再补满 12 列);知识库片段最多 600 字。 - 规则里写明数字已经由系统核对过 +2. 证据摘录(prepare):句子挂的每件证据摘一段。指标写「名称 = 值」加原式,再附上输入取自哪次查询、 + 哪一格和那次查询的 SQL;查询给 SQL 前 300 字和被引用的行(最多 20 行,数据源遮罩的列不给;宽表先给 + 被引用的列,再补满 12 列);表附上字段清单和类型(最多 60 个,遮罩的列不列,快照不完整要注明), + 字段附上类型和所属的表,两样都附上出现过的查询 SQL;知识库片段最多 600 字。快照一律经 loader 取 + (按需裁判传只认封存工件的那个)。规则里写明数字已经由系统核对过 3. 调用(judge_doc):一份报告合并成一次调用,超过每批的句数就分批;结构化输出失败退回 JSON 解析, 再失败就记未裁判。thinking 关掉,max_tokens 4096 4. 预算:每份报告的句数、金额、时长和全局每日金额,每一项都能是 None(不限)。调用前按目录价估算, @@ -15,6 +17,10 @@ 5. 每日计数落在设置表(SPEND_KEY),按本地日期滚动。目录里没有价格的模型估不出金额,金额上限对它 不起作用,照实说「按令牌估不出金额」 +判定五选一:supported、partial、contradicted(证据和句子冲突)、insufficient(证据里没有判断所需的 +信息,missing 写缺什么)、not_a_claim。旧数据里的 unsupported(当时没分矛盾和不足)原样保留,判档当 +contradicted;新判的不再产出它。 + 判定只标注、不改写正文。报告节点在正式运行里调它(结果写进文档、随文档封存);探索运行按需裁判的 接口也调它(post_seal=True,结果落在封存之后)。所有模型调用都经 get_chat_model,测试换成剧本。 """ @@ -41,6 +47,7 @@ from app.db.base import SessionLocal from app.db.models import Setting from app.engine.evidence import ( _DIRECTIONAL, + ENTITY_KINDS, MISSING, RenderError, _auto_entity, @@ -48,6 +55,9 @@ from app.engine.evidence import ( _mask_names, iter_units, render_cell, + schema_column, + schema_table, + table_fields, ) from app.engine.expressions import CellError, column_kind, locate_cell from app.engine.nodes.llm import _split_structured, _usage_of @@ -59,11 +69,27 @@ from app.providers.factory import ModelSpec, ProviderNotConfigured, get_chat_mod # 常量 # -------------------------------------------------------------------------- -#: 模型给的四种判定;unjudged 是系统记的(没判成),不是模型说的 -JUDGED = ("supported", "partial", "unsupported", "not_a_claim") -VERDICTS = (*JUDGED, "unjudged") -#: 模型的理由最多几个字 -RATIONALE_MAX = 60 +#: 模型给的五种判定;unjudged 是系统记的(没判成),不是模型说的 +JUDGED = ("supported", "partial", "contradicted", "insufficient", "not_a_claim") +#: 旧的判定「证据不支持」:当时没分「证据相矛盾」和「证据不足」。已封存的文档、已有的事件里原样保留, +#: 判档照旧当 contradicted;新判的一律用新取值(模型照旧写了的,按 contradicted 收下) +UNSUPPORTED = "unsupported" +#: 读已有的判定时算「判过」的:新的五种加旧的 unsupported +SETTLED = (*JUDGED, UNSUPPORTED) +#: 判档按「证据相矛盾」处置的(on_unsupported:降档或不予出具) +CONTRADICTED = ("contradicted", UNSUPPORTED) +#: 判定对象里 status 的全部取值 +VERDICTS = (*SETTLED, "unjudged") +#: 各判定的句数(stats、counts、出具横幅、审计导出都用这组键)。unsupported 保留给旧数据,新判的恒为 0 +COUNT_KEYS = ("supported", "partial", "contradicted", "insufficient", "not_a_claim", "unjudged", UNSUPPORTED) +#: 模型的理由最多几个字(超长在句子边界截断加「…」);裁判规则里要求的字数 +RATIONALE_MAX = 120 +RATIONALE_ASK = 80 +#: insufficient 时「缺的是什么」最多几个字 +MISSING_MAX = 30 +#: 判定的规则版本:新判的判定带着它(rule_version),也进 request_key。报告节点的断点里换了规则的旧判定不复用; +#: 按需裁判遇到早于当前规则的判定(没有版本、版本更小、旧取值 unsupported)重判一次 +RULES_VERSION = 2 #: 一次调用最多判几句:再多,4096 个输出令牌装不下每句一条判定加理由 BATCH_SIZE = 40 MAX_TOKENS = 4096 @@ -73,13 +99,17 @@ SQL_CHARS = 300 EXCERPT_ROWS = 20 EXCERPT_COLS = 12 CHUNK_CHARS = 600 -#: 表名、字段名的摘录里附上它出现过的查询的 SQL,最多几次(「按会员号去重计数」这种句子,证据在 SQL 里) +#: 表名、字段名的摘录里附上它出现过的查询的 SQL,最多几次(「按会员号去重计数」这种句子,证据在 SQL 里); +#: 口径卡指标的输入取自的查询也按这个数给 SQL ENTITY_QUERIES = 3 EXCERPT_HITS = 3 +#: 表的摘录里最多列几个字段(多出来的注明另有几个);指标的摘录里最多列几个输入 +FIELDS_MAX = 60 +METRIC_INPUTS = 8 #: 短句:看得见的字(不算标点、空白)少于这么多,又没挂依据、没有方向词的,不送裁判 SHORT_CHARS = 6 -#: 估算输出令牌:每句一条判定(理由 60 字上下,加上 JSON 的键) -_OUT_PER_UNIT = 90 +#: 估算输出 token:每句一条判定(理由 80 字上下、缺什么,加上 JSON 的键) +_OUT_PER_UNIT = 120 _OUT_BASE = 20 #: 未裁判的原因。前四个是上限,报告里写「已到上限」 @@ -88,17 +118,21 @@ REASONS = (*LIMITS, "error", "format", "missing", "on_demand") JUDGE_ROLE = "你是报告核查员:逐句判断报告里的话,能不能被它挂的证据支持。你不写报告,也不改报告。" -JUDGE_RULES = """判定(verdict)四选一: +JUDGE_RULES = f"""判定(verdict)五选一: - supported:证据足以支持这句话的说法——方向、比较、范围、因果都对得上 - partial:一部分说法有证据支持,另一部分(原因、范围、程度)证据里没有 -- unsupported:证据不支持、和证据矛盾,或者这句在陈述数据事实却没有任何证据 +- contradicted:证据和这句话冲突——证据里的方向、比较、范围、排名,或者 SQL 的算法、字段,和句子说的对不上 +- insufficient:证据里没有判断这句话所需的信息(比如摘录里没有 SQL、没有字段清单、没有取数的查询),或者这句在陈述数据事实却没有挂任何证据 - not_a_claim:这句不陈述数据事实(过渡、背景、建议、计划),不需要证据 规则: 1. 数字已经由系统核对过,不要复算:每个数都是系统从证据里取出来、按固定规则渲染的,不要因为数字本身或它的格式质疑这句话;你只判断句子的说法有没有证据支持。 2. 只看下面给的证据摘录,不用常识、行业经验或外部知识补证据;摘录里没有的就是没有。 -3. rationale 用中文写,不超过 60 个字,说清依据是什么,或者缺了什么。 -4. used 写你实际用到的证据编号(摘录里【】中的那个,比如 m:gmv、Q1),没用到就写空数组。 -5. 每句都要给判定,unit 照抄句子前面方括号里的编号。""" +3. 只有证据和句子冲突时才判 contradicted;证据里没有相关信息判 insufficient——「没有」不等于「矛盾」。 +4. 摘录注明「表结构快照不完整」时,「某张表、某个字段不存在」这类说法只能判 insufficient。 +5. rationale 用中文写,不超过 {RATIONALE_ASK} 个字,说清依据是什么,或者缺了什么。 +6. 判 insufficient 时 missing 写缺的是什么,越短越好,比如「SQL」「字段清单」「取数的查询」;其余判定 missing 写空字符串。 +7. used 写你实际用到的证据编号(摘录里【】中的那个,比如 m:gmv、Q1),没用到就写空数组。 +8. 每句都要给判定,unit 照抄句子前面方括号里的编号。""" #: 报告节点 claims 为 judge 时拼在写作规则后面:写作者要知道还有一道按证据的核对 JUDGE_RULE = ("这份报告的结论句还会由另一个模型逐句核对:它只看你挂的依据,判断这句话站不站得住。" @@ -114,10 +148,12 @@ JUDGE_SCHEMA: dict[str, Any] = { "properties": { "unit": {"type": "string", "description": "句子编号,照抄 [u3] 里的 u3"}, "verdict": {"type": "string", "enum": list(JUDGED)}, - "rationale": {"type": "string", "description": f"判定的理由,中文,不超过 {RATIONALE_MAX} 个字"}, + "rationale": {"type": "string", "description": f"判定的理由,中文,不超过 {RATIONALE_ASK} 个字"}, + "missing": {"type": "string", + "description": "判 insufficient 时写缺的是什么(比如 SQL、字段清单、取数的查询),其余判定写空字符串"}, "used": {"type": "array", "items": {"type": "string"}, "description": "实际用到的证据编号"}, }, - "required": ["unit", "verdict", "rationale", "used"], + "required": ["unit", "verdict", "rationale", "missing", "used"], }}}, "required": ["items"], } @@ -324,7 +360,90 @@ def _query_excerpt(alias: str, entry: dict[str, Any], rows: set[int], cols: Iter return "\n".join(lines) -def _metric_excerpt(alias: str, entry: dict[str, Any], fetch: _Fetch) -> str: +def _sql_line(label: str, sql: str) -> str: + return f"{label}:{sql[:SQL_CHARS]}{'…' if len(sql) > SQL_CHARS else ''}" + + +def _source_hidden(source: Any, catalog: dict[str, Any], fetch: _Fetch, masked: dict[str, Any] | None) -> set[str]: + """一个数据源要遮的列(小写):数据源现在设的,加上这次运行里它的查询快照当时记下的(数据源改名、删掉了 + 也照遮)。说不清是哪个数据源的,所有遮罩都算上:宁可多遮,不能露。""" + name = str(source or "") + names: set[str] = set() + for key, cols in (masked or {}).items(): + if not name or str(key) == name: + names |= _mask_names(cols) + for entry in catalog.values(): + if not isinstance(entry, dict) or entry.get("kind") != "query": + continue + snapshot = fetch(entry.get("artifact")) + if isinstance(snapshot, dict) and (not name or str(entry.get("source") or snapshot.get("source") or "") == name): + names |= _mask_names(snapshot.get("mask_columns")) + return names + + +def _query_alias(catalog: dict[str, Any], artifact: Any) -> str | None: + """这份查询快照在报告目录里的编号(Q1):口径卡输入记的是工件,靠它对上。""" + return next((a for a, e in catalog.items() if isinstance(e, dict) and e.get("kind") == "query" + and artifact and e.get("artifact") == artifact), None) + + +def _cell_words(locator: dict[str, Any], hidden: set[str]) -> str: + """输入落在哪一格:「r0 行 gmv 列」,数组字段是一段行「r0–r4 行 amount 列」。遮罩的列不写列名。""" + rows = locator.get("rows") + if isinstance(locator.get("row"), int): + where = f"r{locator['row']} 行" + elif isinstance(rows, list) and len(rows) == 2 and all(isinstance(r, int) for r in rows): + where = f"r{rows[0]}–r{rows[1]} 行" + else: + where = "" + cols = [locator["column"]] if locator.get("column") else list((locator.get("columns") or {}).values()) + cols = [str(c) for c in cols if c not in (None, "")] + shown = [c for c in cols if c.lower() not in hidden] + parts = [where] if where else [] + if shown: + parts.append("、".join(shown) + " 列") + if len(shown) < len(cols): + parts.append("(所在的列按数据源的设置遮罩,不写列名)") + return " ".join(parts) or "某一格" + + +def _metric_inputs(metric: dict[str, Any], fetch: _Fetch, catalog: dict[str, Any], + masked: dict[str, Any] | None) -> list[str]: + """指标的输入取自哪次查询、哪一格,再附上那次查询的 SQL(截到 SQL_CHARS,最多 ENTITY_QUERIES 次)。 + + 「活跃用户按会员号去重」这种讲口径的句子,证据在取数的 SQL 里;只给原式,裁判只能判证据不足。 + 位置来自封存的口径卡;SQL 经 fetch 取,取不到(不在封存范围里)的不给。没追到查询的输入照实写。 + """ + items = [i for i in metric.get("inputs") or [] if isinstance(i, dict)] + lines: list[str] = [] + sqls: list[str] = [] + seen: set[str] = set() + for item in items[:METRIC_INPUTS]: + path = _clean(str(item.get("path") or item.get("field") or "")) or "(未命名)" + artifact = item.get("artifact") if item.get("via") in ("tool_cell", "agent_field") else None + if not artifact: + origin = f"(来自节点 {item['node_id']})" if item.get("node_id") else "" + lines.append(f"输入 {path}:未追溯到查询{origin}") + continue + alias = _query_alias(catalog, artifact) + snapshot = fetch(artifact) + snap = snapshot if isinstance(snapshot, dict) else {} + source = (catalog.get(alias) or {}).get("source") if alias else None + hidden = _source_hidden(source or snap.get("source"), catalog, fetch, masked) \ + | _mask_names(snap.get("mask_columns")) + label = f"查询 {alias} " if alias else "一次查询(不在这份报告的证据目录中)" + lines.append(f"输入 {path}:取自{label}的 {_cell_words(item.get('locator') or {}, hidden)}") + sql = _clean(str(snap.get("sql") or "")) + if sql and str(artifact) not in seen and len(seen) < ENTITY_QUERIES: + seen.add(str(artifact)) + sqls.append(_sql_line(f"查询 {alias} 的 SQL" if alias else "这次查询的 SQL", sql)) + if len(items) > METRIC_INPUTS: + lines.append(f"…另有 {len(items) - METRIC_INPUTS} 个输入") + return lines + sqls + + +def _metric_excerpt(alias: str, entry: dict[str, Any], fetch: _Fetch, catalog: dict[str, Any], + masked: dict[str, Any] | None) -> str: name = entry.get("name") or (entry.get("locator") or {}).get("metric") or alias rendered = entry.get("rendered") where = " ".join(v for v in (f"口径「{entry['caliber']}」" if entry.get("caliber") else "", @@ -334,13 +453,83 @@ def _metric_excerpt(alias: str, entry: dict[str, Any], fetch: _Fetch) -> str: lines = [f"【{alias}】口径卡指标:{value}" + (f"({where})" if where else "")] card = fetch(entry.get("artifact")) wanted = (entry.get("locator") or {}).get("metric") - for metric in (card.get("metrics") if isinstance(card, dict) else None) or []: - if isinstance(metric, dict) and metric.get("id") == wanted and metric.get("expression"): + metric = next((m for m in (card.get("metrics") if isinstance(card, dict) else None) or [] + if isinstance(m, dict) and m.get("id") == wanted), None) + if metric is not None: + if metric.get("expression"): lines.append(f"原式:{_clean(str(metric['expression']))}") - break + lines.extend(_metric_inputs(metric, fetch, catalog, masked)) return "\n".join(lines) +def _table_lines(entry: dict[str, Any], catalog: dict[str, Any], fetch: _Fetch, + masked: dict[str, Any] | None) -> list[str]: + """表的字段清单和类型:取自封存范围内(经 fetch)的表结构快照,最多 FIELDS_MAX 个,遮罩的列不列。 + + 讲表结构的句子(「订单表按会员号记录」)只给表名,裁判只能判证据不足。快照不完整(数据源的表太多、 + 只存了一部分)要注明:这时「某字段不存在」这类说法只能判 insufficient。 + """ + name = entry.get("name") or (entry.get("locator") or {}).get("table") + origins = [o for o in entry.get("sources") or [] if isinstance(o, dict) and o.get("kind") == "schema" + and o.get("artifact")] + truncated = any(o.get("truncated") for o in origins) + info: dict[str, Any] | None = None + snapshot: dict[str, Any] = {} + readable = False + for origin in origins: + content = fetch(origin["artifact"]) + readable = readable or isinstance(content, dict) + if (found := schema_table(content, name)) is not None: + info, snapshot = found, content + break + lines: list[str] = [] + if info is None: + lines.append("字段清单:没有(" + ("本次运行的表结构快照中没有这张表" if readable or not origins + else "无法读取这张表所在的表结构快照") + ")") + else: + truncated = truncated or bool(snapshot.get("truncated")) + hidden = _source_hidden(entry.get("source") or snapshot.get("source"), catalog, fetch, masked) + fields, more, masked_any = table_fields(info, hidden, limit=FIELDS_MAX) + listed = " | ".join(f"{f['name']} {f['type']}".strip() for f in fields) or "无" + lines.append(f"字段清单(名称 类型,共 {len(fields) + more} 个):{listed}" + + (f" | …另有 {more} 个字段" if more else "")) + if masked_any: + lines.append("(有的字段按数据源的设置遮罩了,没有列出)") + if truncated: + lines.append("注意:表结构快照不完整(数据源的表太多,只保存了一部分),不能据此断定某张表或某个字段不存在") + return lines + + +def _column_lines(entry: dict[str, Any], fetch: _Fetch) -> list[str]: + """字段所属的表和类型。类型只从封存范围内的表结构快照取;表结构里没有的列(聚合的别名)才取查询快照 + 记下的列类型——和证据面板同一个口径。""" + locator = entry.get("locator") or {} + table, column = locator.get("table"), locator.get("column") or entry.get("name") + owners = [str(table)] if table else [str(t) for t in entry.get("tables") or []] + origins = [o for o in entry.get("sources") or [] if isinstance(o, dict) and o.get("artifact")] + types: dict[str, str] = {} + for origin in origins: + if origin.get("kind") != "schema": + continue + content = fetch(origin["artifact"]) + for owner in owners: + if (col := schema_column(schema_table(content, owner), column)) is not None and col.get("type"): + types.setdefault(owner, str(col["type"])) + lines = ["所属的表:" + "、".join(owners)] if owners else [] + if len(set(types.values())) == 1: + lines.append(f"类型:{next(iter(types.values()))}(取自表结构快照)") + elif types: + lines.append("类型:" + ";".join(f"{t} 表中为 {k}" for t, k in types.items()) + "(取自表结构快照)") + elif not any(o.get("kind") == "schema" for o in origins): + for origin in origins: + content = fetch(origin["artifact"]) if origin.get("kind") == "result" else None + kinds = content.get("column_types") if isinstance(content, dict) else None + if isinstance(kinds, dict) and isinstance(kinds.get(str(column)), str): + lines.append(f"类型:{kinds[str(column)]}(取自查询结果)") + break + return lines + + def _retrieval_excerpt(alias: str, entry: dict[str, Any], picked: list[int], whole: bool, fetch: _Fetch) -> str | None: content = fetch(entry.get("artifact")) hits = content.get("hits") if isinstance(content, dict) else None @@ -364,7 +553,7 @@ def _excerpt(alias: str, entry: dict[str, Any], cands: list[Candidate], fetch: _ masked: dict[str, Any] | None, catalog: dict[str, Any]) -> str | None: kind = entry.get("kind") if kind == "metric": - return _metric_excerpt(alias, entry, fetch) + return _metric_excerpt(alias, entry, fetch, catalog, masked) if kind == "input": field_name = (entry.get("locator") or {}).get("field") or alias return f"【{alias}】运行输入:{field_name} = {entry.get('rendered')}" @@ -377,11 +566,11 @@ def _excerpt(alias: str, entry: dict[str, Any], cands: list[Candidate], fetch: _ return _retrieval_excerpt(alias, entry, picked, any(alias in c.whole for c in cands), fetch) if kind == "table": head = f"【{alias}】表 {entry.get('name')}" + (f"(数据源 {entry['source']})" if entry.get("source") else "") - return "\n".join([head, *_entity_sql(entry, catalog, fetch)]) + return "\n".join([head, *_table_lines(entry, catalog, fetch, masked), *_entity_sql(entry, catalog, fetch)]) if kind == "column": table = (entry.get("locator") or {}).get("table") head = f"【{alias}】字段 {f'{table}.' if table else ''}{(entry.get('locator') or {}).get('column') or entry.get('name')}" - return "\n".join([head, *_entity_sql(entry, catalog, fetch)]) + return "\n".join([head, *_column_lines(entry, fetch), *_entity_sql(entry, catalog, fetch)]) return None @@ -445,7 +634,8 @@ class JudgeRequest: HumanMessage(content=( f"证据摘录(【】里是编号,判定时 used 写它):\n{evidence}\n\n" "要判断的句子(每句都要给判定):\n" + "\n".join(self.unit_line(c) for c in ordered) + "\n\n" - '只输出 JSON:{"items": [{"unit": "u1", "verdict": "supported", "rationale": "…", "used": ["m:gmv"]}]}' + '只输出 JSON:{"items": [{"unit": "u1", "verdict": "supported", "rationale": "…", "missing": "", ' + '"used": ["m:gmv"]}]}' )), ] @@ -498,7 +688,7 @@ def request_key(request: JudgeRequest, spec: ModelSpec, budget: Budget, known: d return content_hash(canonical_json({ "units": [[c.unit, c.priority, c.text, c.section, list(c.aliases)] for c in request.cands], "excerpts": request.excerpts, "model": [spec.provider, spec.model], "budget": budget.as_dict(), - "known": sorted(known or {}), + "known": sorted(known or {}), "rules": RULES_VERSION, })) @@ -650,25 +840,41 @@ async def judge_model_spec(session: Any, node_judge: dict[str, Any] | None = Non async def source_masks(catalog: dict[str, Any], *, loader: Callable[[str], Any] | None = None ) -> dict[str, list[str]]: - """目录里的查询来自的数据源现在设的遮罩:{数据源名: 列名}。和证据面板同一个口径。 + """目录里的证据来自的数据源现在设的遮罩:{数据源名: 列名}。和证据面板同一个口径。 - 旧形状的台账条目没记数据源的,按快照自己记的数据源查(快照经 loader 取,缺省读工件库; - 按需裁判的接口要传只认封存工件的 loader,和 prepare 同一个)。 + 查的数据源:查询条目的;表、字段条目的(字段清单里遮罩的列不列);口径卡指标的输入取自的查询快照的。 + 条目没记数据源的,按快照自己记的数据源查(快照经 loader 取,缺省读工件库;按需裁判的接口要传只认 + 封存工件的 loader,和 prepare 同一个)。 """ from app.data.engine import masked_columns from app.db.models import DataSource fetch = _Fetch(loader) found: set[str] = set() + + def source_of(artifact: Any) -> Any: + snapshot = fetch(artifact) + return snapshot.get("source") if isinstance(snapshot, dict) else None + for entry in catalog.values(): - if not isinstance(entry, dict) or entry.get("kind") != "query": + if not isinstance(entry, dict): continue - source = entry.get("source") - if not source: - snapshot = fetch(entry.get("artifact")) - source = snapshot.get("source") if isinstance(snapshot, dict) else None - if source: - found.add(str(source)) + kind = entry.get("kind") + sources: list[Any] = [] + if kind == "query": + sources.append(entry.get("source") or source_of(entry.get("artifact"))) + elif kind in ENTITY_KINDS: + sources.append(entry.get("source") or next( + (source_of(o.get("artifact")) for o in entry.get("sources") or [] + if isinstance(o, dict) and o.get("kind") == "schema"), None)) + elif kind == "metric": + card = fetch(entry.get("artifact")) + wanted = (entry.get("locator") or {}).get("metric") + for metric in (card.get("metrics") if isinstance(card, dict) else None) or []: + if isinstance(metric, dict) and metric.get("id") == wanted: + sources.extend(source_of(i.get("artifact")) for i in metric.get("inputs") or [] + if isinstance(i, dict) and i.get("artifact")) + found.update(str(s) for s in sources if s) names = sorted(found) if not names: return {} @@ -885,7 +1091,7 @@ async def run_request(request: JudgeRequest, *, spec: ModelSpec, budget: Budget, pending: list[Candidate] = [] for c in ranked: hit = (known or {}).get(keys[c.unit]) - if isinstance(hit, dict) and hit.get("status") in JUDGED: + if isinstance(hit, dict) and hit.get("status") in SETTLED: verdicts[c.unit] = dict(hit) reused += 1 else: @@ -998,10 +1204,38 @@ async def run_request(request: JudgeRequest, *, spec: ModelSpec, budget: Budget, return outcome +#: 截理由时认的句子边界(全角、半角都认) +_BOUNDARY = "。;,;,!?!?" + + +def clip_text(text: str, limit: int = RATIONALE_MAX) -> str: + """超过 limit 个字时截短:在句子边界(。;,)截断加「…」,总长不超过 limit。 + + 边界离开头太近(不到三分之一)就不按它截——只剩几个字的理由没用;这时退到词的边界(不把一个英文词、 + 一个数截成两半)。没超长的原样返回。 + """ + text = text.strip() + if len(text) <= limit: + return text + head = text[:limit - 1] + cut = max(head.rfind(ch) for ch in _BOUNDARY) + if cut < limit // 3: + cut = len(head) + while cut > 0 and head[cut - 1].isascii() and head[cut - 1].isalnum() and text[cut].isascii() \ + and text[cut].isalnum(): + cut -= 1 + if cut < limit // 3: + cut = len(head) + return head[:cut].rstrip(" ,,;;、") + "…" + + def _take(items: list[Any], batch: list[Candidate], request: JudgeRequest, verdicts: dict[str, dict[str, Any]], *, judge: str | None, post_seal: bool) -> None: - """模型给的判定逐条收下:不在这一批的编号不认,判定不认识的记格式不对,理由截到 60 字, - used 只留这一批摘录里真有的编号。""" + """模型给的判定逐条收下:不在这一批的编号不认,判定不认识的记格式不对,理由在句子边界截到 RATIONALE_MAX 字, + used 只留这一批摘录里真有的编号。判 insufficient 的另记 missing(缺的是什么,模型没写就不编)。 + + 模型照旧写 unsupported 的按 contradicted 收下:新判的不再产出旧取值,而 contradicted 的处置和原来的 + unsupported 一样(不能因为模型用了旧词就放过这一句)。""" ids = {c.unit for c in batch} aliases = {a for c in batch for a in c.aliases if a in request.excerpts} for item in items: @@ -1011,26 +1245,43 @@ def _take(items: list[Any], batch: list[Candidate], request: JudgeRequest, verdi if uid not in ids or uid in verdicts: continue status = str(item.get("verdict") or "").strip().lower() + if status == UNSUPPORTED: + status = "contradicted" if status not in JUDGED: verdicts[uid] = _unjudged("format", f"裁判返回的判定「{str(item.get('verdict'))[:20]}」无法识别,这句未裁判", judge=judge, post_seal=post_seal) continue used = item.get("used") if isinstance(item.get("used"), list) else [] - verdicts[uid] = {"status": status, "rationale": _clean(str(item.get("rationale") or ""))[:RATIONALE_MAX], - "judge": judge, "post_seal": post_seal, - "used": list(dict.fromkeys(u.strip() for u in used if isinstance(u, str) - and u.strip() in aliases))} + verdict: dict[str, Any] = { + "status": status, "rationale": clip_text(_clean(str(item.get("rationale") or ""))), + "judge": judge, "post_seal": post_seal, + "used": list(dict.fromkeys(u.strip() for u in used if isinstance(u, str) and u.strip() in aliases))} + missing = item.get("missing") + if status == "insufficient" and isinstance(missing, str) and (missing := clip_text(_clean(missing), MISSING_MAX)): + verdict["missing"] = missing + verdict["rule_version"] = RULES_VERSION + verdicts[uid] = verdict + + +def outdated(verdict: Any) -> bool: + """这条判过的判定早于当前规则:没有规则版本(改造前判的)、版本更小,或者是旧取值 unsupported。 + 没判成的(unjudged)谈不上新旧,返回 False。""" + if not isinstance(verdict, dict) or verdict.get("status") not in SETTLED: + return False + version = verdict.get("rule_version") + return verdict.get("status") == UNSUPPORTED or isinstance(version, bool) or not isinstance(version, int) \ + or version < RULES_VERSION def _outcome(request: JudgeRequest, verdicts: dict[str, dict[str, Any]], budget: Budget, details: dict[str, str], *, click: bool = False, shown: Budget | None = None) -> dict[str, Any]: """汇总判定。outcome.budget 是这一轮实际用的上限;缺口里写的上限按 shown(缺省同 budget)。""" label = shown or budget - counts = {s: 0 for s in VERDICTS} + counts = {s: 0 for s in COUNT_KEYS} why: dict[str, int] = {} for c in request.cands: v = verdicts[c.unit] - counts[v["status"]] += 1 + counts[v["status"]] = counts.get(v["status"], 0) + 1 if v["status"] == "unjudged": why[v["reason"]] = why.get(v["reason"], 0) + 1 gaps: list[tuple[str, str]] = [] @@ -1073,13 +1324,18 @@ async def judge_doc(doc: dict[str, Any], catalog: dict[str, Any] | None = None, #: 文档的 judge 摘要里、report.checked 和节点产出的 judge 里都有的键 SUMMARY_KEYS = ("mode", "model", "on_unsupported", "rewrite_once", "limits", "candidates", "screened", "counts", "unjudged", "limits_hit", "complete", "priced", "cost_usd", "calls", "reused", "duration_ms", - "gaps", "notes", "same_model", "rewrite") + "gaps", "notes", "same_model", "rewrite", "writer_model") def summarize(outcome: dict[str, Any], *, mode: str, on_unsupported: str = "degrade", rewrite_once: bool = False, - same_model: bool = False, rewrite: dict[str, Any] | None = None) -> dict[str, Any]: - """裁判摘要:写进文档的 judge,也原样放进 report.checked 和节点产出。""" - counts = outcome["counts"] + same_model: bool = False, rewrite: dict[str, Any] | None = None, + writer_model: str | None = None) -> dict[str, Any]: + """裁判摘要:写进文档的 judge,也原样放进 report.checked 和节点产出。 + + counts 补齐 COUNT_KEYS 的每个键(断点里缓存的旧判定只有旧的几个键);writer_model 是这份报告的写作模型, + same_model 是裁判模型和它相同。""" + raw = outcome["counts"] + counts = {k: int(raw.get(k) or 0) for k in COUNT_KEYS} return { "mode": mode, "model": outcome.get("model"), "on_unsupported": on_unsupported, "rewrite_once": rewrite_once, "limits": outcome.get("budget"), "candidates": outcome["candidates"], "screened": outcome["screened"], @@ -1088,7 +1344,7 @@ def summarize(outcome: dict[str, Any], *, mode: str, on_unsupported: str = "degr "priced": outcome.get("priced"), "cost_usd": outcome.get("cost_usd", 0.0), "calls": outcome.get("calls", 0), "reused": outcome.get("reused", 0), "duration_ms": outcome.get("duration_ms", 0), "gaps": outcome["gaps"] if mode == "inline" else [], "notes": outcome.get("notes") or [], - "same_model": same_model, "rewrite": rewrite, + "same_model": same_model, "rewrite": rewrite, "writer_model": writer_model, } @@ -1103,8 +1359,11 @@ def apply_judgement(doc: dict[str, Any], outcome: dict[str, Any], summary: dict[ return doc -def on_demand(doc: dict[str, Any], *, on_unsupported: str = "degrade", rewrite_once: bool = False) -> dict[str, Any]: - """探索运行:节点里不花这笔钱。候选句标「未裁判 · 按需」,点开哪句再判哪句。返回摘要(已写进文档)。""" +def on_demand(doc: dict[str, Any], *, on_unsupported: str = "degrade", rewrite_once: bool = False, + writer_model: str | None = None) -> dict[str, Any]: + """探索运行:节点里不花这笔钱。候选句标「未裁判 · 按需」,点开哪句再判哪句。返回摘要(已写进文档)。 + + writer_model 记进摘要:按需裁判时拿它判断裁判模型是不是和写作模型相同。""" request = prepare(doc, {}) budget = Budget() verdicts = {c.unit: _unjudged("on_demand", _why_unjudged("on_demand", budget), judge=None, post_seal=False) @@ -1112,6 +1371,7 @@ def on_demand(doc: dict[str, Any], *, on_unsupported: str = "degrade", rewrite_o outcome = _outcome(request, verdicts, budget, {}) outcome.pop("_gaps") outcome["budget"] = None - summary = summarize(outcome, mode="on_demand", on_unsupported=on_unsupported, rewrite_once=rewrite_once) + summary = summarize(outcome, mode="on_demand", on_unsupported=on_unsupported, rewrite_once=rewrite_once, + writer_model=writer_model) apply_judgement(doc, outcome, summary) return summary diff --git a/backend/app/engine/labels.py b/backend/app/engine/labels.py index ab73bb3b..e16ead9f 100644 --- a/backend/app/engine/labels.py +++ b/backend/app/engine/labels.py @@ -92,7 +92,7 @@ CONTRACT_KEY_LABEL: dict[str, str] = { JUDGE_FIELD_LABEL: dict[str, str] = { "provider": "裁判模型 · 接入", "model": "裁判模型", "max_claims": "最多裁判句数", "max_cost_usd": "金额上限(美元)", "timeout_s": "时长上限(秒)", - "rewrite_once": "证据不支持的句子退回改写一次", "on_unsupported": "证据不支持时", + "rewrite_once": "证据相矛盾或不足的句子退回改写一次", "on_unsupported": "证据相矛盾时", } #: 取值的短说法:键 → {值: 叫法}。前端下拉的选项文字去掉后半句的用途说明 diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index 2fc964ad..0134fea3 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -417,7 +417,7 @@ def _check_citations( #: 没挂依据的结论句怎么处置,从松到严。认不出的写法按 degrade 算:写错一个词不能让缺口悄悄消失 _ON_UNCITED = ("ignore", "degrade", "withhold") -#: 证据不支持的结论句怎么处置(claims: judge),从松到严。认不出的写法同样按 degrade 算 +#: 证据相矛盾的结论句怎么处置(claims: judge 的 on_unsupported),从松到严。认不出的写法同样按 degrade 算 _ON_UNSUPPORTED = ("degrade", "withhold") #: 要求结论句挂依据的策略:judge 在这一点上和 require_citation 一样严,另外再按裁判的判定判档 _CITING = ("require_citation", "judge") @@ -434,9 +434,10 @@ def _claims(out: dict[str, Any], contract: dict[str, Any], payload: dict[str, An 里 ignore 一律当 degrade:没有哪道门禁查契约里的 claims,不能让它把用户要的底线拉低。 报告节点写的是 judge 时,判定在文档里(按哈希取回的那份:判定是模型给的,出口复算不了,只能信封存的 - 文档)。正式运行在节点里判过:证据不支持的按 on_unsupported 判档(报告节点和契约取更严的),没判完的 - (裁判没跑成、触顶)把裁判摘要里的缺口照抄进来——不能判完整出具,也不当成「不支持」。探索运行按需裁判, - 这里只标注。契约要 judge、报告节点却没开的,照实记缺口:出口替不了报告节点去裁判。 + 文档)。正式运行在节点里判过:证据相矛盾的(contradicted,旧文档里的 unsupported 同样算)按 on_unsupported + 判档(报告节点和契约取更严的);证据不足的(insufficient)记一条缺口——最多降档,不会不予出具,也不会 + 完整出具;没判完的(裁判没跑成、触顶)把裁判摘要里的缺口照抄进来——不能判完整出具,也不当成「矛盾」。 + 探索运行按需裁判,这里只标注。契约要 judge、报告节点却没开的,照实记缺口:出口替不了报告节点去裁判。 数的是这里重新核对出来的 uncited,不信文档里记的 cites。升级前写的报告(节点产出里没有 claims 这个键, 包括跨着升级还没跑完的运行)一律不管,契约写了也只记一条缺口:报告生成于升级前,结论句未核对。 @@ -472,6 +473,7 @@ def _claims(out: dict[str, Any], contract: dict[str, Any], payload: dict[str, An return from app.engine.evidence import iter_units + from app.engine.judge import COUNT_KEYS, UNSUPPORTED summary = (doc or {}).get("judge") if not isinstance(summary, dict): @@ -483,7 +485,7 @@ def _claims(out: dict[str, Any], contract: dict[str, Any], payload: dict[str, An asked.append(declared.get("on_unsupported")) on_unsupported = max((a if a in _ON_UNSUPPORTED else "degrade" for a in asked), key=_ON_UNSUPPORTED.index) inline = summary.get("mode") == "inline" - flagged: dict[str, list[dict[str, Any]]] = {"unsupported": [], "partial": []} + flagged: dict[str, list[dict[str, Any]]] = {s: [] for s in ("contradicted", "insufficient", UNSUPPORTED, "partial")} if inline: # 探索运行按需裁判的判定不在文档里(在封存之后追加的 evidence.judged 事件里),这里只数正式运行的 markdown = str((doc or {}).get("markdown") or "") @@ -493,16 +495,26 @@ def _claims(out: dict[str, Any], contract: dict[str, Any], payload: dict[str, An if status in flagged: span = unit.get("span") or [0, 0] flagged[status].append({"unit": unit.get("id"), "span": span, "text": markdown[span[0]:span[1]], - "rationale": verdict.get("rationale") or ""}) + "rationale": verdict.get("rationale") or "", + **({"missing": verdict["missing"]} if status == "insufficient" + and verdict.get("missing") else {})}) out["gaps"].extend(f"报告「{title}」{gap}" for gap in summary.get("gaps") or []) - counts = {k: int((summary.get("counts") or {}).get(k) or 0) - for k in ("supported", "partial", "unsupported", "not_a_claim", "unjudged")} + if lacking := flagged["insufficient"]: + # 证据不足按缺口处理:最多降档,不因 on_unsupported 不予出具,也不能完整出具 + missing = list(dict.fromkeys(i["missing"] for i in lacking if i.get("missing"))) + what = f"(缺少:{'、'.join(missing[:5])}{'等' if len(missing) > 5 else ''})" if missing else "" + out["gaps"].append(f"报告「{title}」有 {len(lacking)} 句结论证据不足{what}:裁判未能在证据中找到判断所需的" + "信息,这些结论未经核实") + counts = {k: int((summary.get("counts") or {}).get(k) or 0) for k in COUNT_KEYS} + # 证据相矛盾的按 on_unsupported 判档;旧文档里的 unsupported(当时没分矛盾和不足)照旧当 contradicted + contradicted = [*flagged["contradicted"], *flagged[UNSUPPORTED]] out["claims_policy"] = {"policy": "judge", "on_unsupported": on_unsupported, "on_uncited": on_uncited} - out["uncited"], out["unsupported"] = uncited, flagged["unsupported"] + out["uncited"], out["unsupported"] = uncited, contradicted out["claims"] = { "policy": "judge", "uncited_claims": len(uncited), "uncited": uncited[:_LISTED], **extra, "on_unsupported": on_unsupported, "mode": "inline" if inline else "on_demand", "counts": counts, - "unsupported": flagged["unsupported"][:_LISTED], "partial": flagged["partial"][:_LISTED], + "contradicted": flagged["contradicted"][:_LISTED], "insufficient": flagged["insufficient"][:_LISTED], + "unsupported": flagged[UNSUPPORTED][:_LISTED], "partial": flagged["partial"][:_LISTED], "unjudged": dict(summary.get("unjudged") or {}), "limits_hit": list(summary.get("limits_hit") or []), "complete": bool(summary.get("complete")), "model": summary.get("model"), } diff --git a/backend/app/engine/nodes/report.py b/backend/app/engine/nodes/report.py index 3e72234b..24516992 100644 --- a/backend/app/engine/nodes/report.py +++ b/backend/app/engine/nodes/report.py @@ -20,7 +20,7 @@ - 正式运行在节点里判:写完、核对完之后判,判定写进文档的 unit.verdict,随文档一起封存。判定只标注、 不改写正文;裁判没跑成、触顶了照样产出,没判的记未裁判并留下缺口,出口按 on_unsupported 判档 - 探索运行不在节点里花这笔钱:候选句标「未裁判 · 按需」,点开哪句再判哪句(证据接口) -- rewrite_once(默认关):证据不支持的句子连同理由交回写作者只改这几句,再判一次,最多一轮; +- rewrite_once(默认关):证据相矛盾、证据不足的句子连同理由交回写作者只改这几句,再判一次,最多一轮; 改写稿冒出原稿没有的违规(只标注的可疑实体也算)就不采用 - 裁判调用包在 @task 里,排在节点所有已有调用之后:「接着跑」时直接取 checkpoint 里的判定, 不重复调用、不重复计费。写作调用不进 checkpoint,接着跑时会重写一稿——所以判定带着输入指纹, @@ -450,7 +450,7 @@ async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if await _run_class(ctx.run.run_id) != "formal": # 探索运行默认按需裁判:点开哪句才判哪句,节点里不花这笔钱 judgement = judging.on_demand(doc, on_unsupported=judge_cfg["on_unsupported"], - rewrite_once=judge_cfg["rewrite_once"]) + rewrite_once=judge_cfg["rewrite_once"], writer_model=model_id) elif not (blocking and on_violation == "fail"): # 反正要判失败的报告不值得再花钱判(节点紧接着就报错) doc, response, blocking, judgement, usages = await _judge_inline( @@ -505,13 +505,21 @@ async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]: def _rewrite_request(sentences: list[tuple[str, str]]) -> str: listed = "\n".join(f"{i}.「{text}」——{why or '证据不支持这句话'}" for i, (text, why) in enumerate(sentences, 1)) return ( - "另一个模型按证据逐句核对了你的报告,下面这几句它认为证据不支持:\n" + "另一个模型按证据逐句核对了你的报告,下面这几句它认为证据不支持(和证据相矛盾,或者证据不足):\n" f"{listed}\n\n" "请只改这几句:补上证据目录里真有的依据 [[see:…]],或者把说法收窄到证据能支持的程度,或者删掉;" "其余部分一字不改。数字仍然只能用引用标记写。不要解释改了什么,只输出改好的整篇报告正文。" ) +def _rewrite_reason(verdict: dict[str, Any]) -> str: + """交回写作者的理由:裁判的理由,证据不足的再写上缺的是什么。""" + why = str(verdict.get("rationale") or "") + if verdict.get("status") == "insufficient" and verdict.get("missing"): + why = f"{why}(缺少:{verdict['missing']})" if why else f"证据不足,缺少:{verdict['missing']}" + return why + + def _merge(first: dict[str, Any], second: dict[str, Any], budget: judging.Budget) -> dict[str, Any]: """改写前后两轮裁判合成一份:判定、句数、缺口按改写后这一稿,花费、调用、用时两轮相加。""" merged = dict(second) @@ -582,10 +590,11 @@ async def _judge_inline(ctx: NodeContext, doc: dict[str, Any], catalog: dict[str usages = [outcome["usage"]] response = messages[-1] rewrite: dict[str, Any] | None = None - bad = [uid for uid, v in outcome["verdicts"].items() if v["status"] == "unsupported"] + # 交回改写的:证据相矛盾的(含旧取值 unsupported)和证据不足的——两样原来都判作「证据不支持」 + bad = [uid for uid, v in outcome["verdicts"].items() if v["status"] in (*judging.CONTRADICTED, "insufficient")] if judge_cfg["rewrite_once"] and bad: texts = judging.unit_texts(doc) - sentences = [(texts.get(uid, ""), outcome["verdicts"][uid].get("rationale") or "") for uid in bad] + sentences = [(texts.get(uid, ""), _rewrite_reason(outcome["verdicts"][uid])) for uid in bad] ctx.emit(EventType.LOG, level="warn", code="report_rewrite", message=f"裁判认为 {len(bad)} 句结论证据不支持({_named([{'text': t} for t, _ in sentences])})," "已退回写作模型仅修改这几句") @@ -620,7 +629,8 @@ async def _judge_inline(ctx: NodeContext, doc: dict[str, Any], catalog: dict[str rewrite.update(applied=True, units=[at.get(texts.get(uid, "")) for uid in bad], changed=[uid for uid, text in now.items() if text not in before]) summary = judging.summarize(outcome, mode="inline", on_unsupported=judge_cfg["on_unsupported"], - rewrite_once=judge_cfg["rewrite_once"], same_model=same_model, rewrite=rewrite) + rewrite_once=judge_cfg["rewrite_once"], same_model=same_model, rewrite=rewrite, + writer_model=model_id) judging.apply_judgement(doc, outcome, summary) return doc, response, blocking, summary, usages diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index 5ed99873..a0be2442 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -425,7 +425,7 @@ CLAIMS_JUDGE = "judge" CLAIMS_VALUES = (*REPORT_CLAIMS, CLAIMS_JUDGE) #: judge 子配置能写的键。三个上限写 null 表示不限;没写的键运行时取设置里的默认(设置 → judge 一组) JUDGE_KEYS = ("provider", "model", "max_claims", "max_cost_usd", "timeout_s", "rewrite_once", "on_unsupported") -#: 证据不支持时正式运行怎么判档:degrade 降档(默认)/ withhold 不予出具。探索运行只标注 +#: 证据相矛盾时正式运行怎么判档:degrade 降档(默认)/ withhold 不予出具。探索运行只标注 JUDGE_ON_UNSUPPORTED = ("degrade", "withhold") diff --git a/backend/tests/test_contract_judge.py b/backend/tests/test_contract_judge.py index d259ed5c..6e783a74 100644 --- a/backend/tests/test_contract_judge.py +++ b/backend/tests/test_contract_judge.py @@ -152,24 +152,25 @@ async def test_every_claim_supported_is_a_full_issuance(monkeypatch): assert issuance["tier"] == "formal" and issuance["gaps"] == [], issuance claims = issuance["claims"] assert claims["policy"] == "judge" and claims["mode"] == "inline" and claims["complete"] is True - assert claims["counts"] == {"supported": 2, "partial": 0, "unsupported": 0, "not_a_claim": 0, "unjudged": 0} + assert claims["counts"] == {"supported": 2, "partial": 0, "contradicted": 0, "insufficient": 0, "not_a_claim": 0, + "unjudged": 0, "unsupported": 0} assert claims["on_unsupported"] == "degrade" and claims["uncited_claims"] == 0 - assert claims["unsupported"] == [] and claims["model"] == JUDGE_MODEL + assert claims["contradicted"] == [] and claims["insufficient"] == [] and claims["model"] == JUDGE_MODEL -async def test_an_unsupported_claim_degrades_by_default(monkeypatch): - Models(monkeypatch, verdicts={"新客": "unsupported"}) +async def test_a_contradicted_claim_degrades_by_default(monkeypatch): + Models(monkeypatch, verdicts={"新客": "contradicted"}) issuance = await issued(weekly()) assert issuance["tier"] == "degraded", "不支持按缺口降档,不是不予出具(契约是 strict 的)" assert issuance["gaps"] == [], "判了就不是「没核」:档位来自判定,不来自缺口" - [flagged] = issuance["claims"]["unsupported"] + [flagged] = issuance["claims"]["contradicted"] assert flagged["text"] == "增长主要来自新客首单。" and flagged["rationale"] == "证据里没有新客维度" assert flagged["unit"] and len(flagged["span"]) == 2 - assert issuance["claims"]["counts"]["unsupported"] == 1 + assert issuance["claims"]["counts"]["contradicted"] == 1 async def test_withhold_withholds(monkeypatch): - Models(monkeypatch, verdicts={"新客": "unsupported"}) + Models(monkeypatch, verdicts={"新客": "contradicted"}) issuance = await issued(weekly(report={"judge": {"max_cost_usd": 0.05, "on_unsupported": "withhold"}})) assert issuance["tier"] == "withheld" and issuance["claims"]["on_unsupported"] == "withhold" @@ -209,23 +210,24 @@ async def test_an_uncited_claim_still_counts_under_judge(monkeypatch): async def test_the_issuance_event_carries_the_counts(monkeypatch): - Models(monkeypatch, verdicts={"新客": "unsupported"}) + Models(monkeypatch, verdicts={"新客": "contradicted"}) run = await run_manager.start(graph=weekly(), input_payload={}, run_class="formal") row = await wait(run.id) event = await issuance_event(row.id) assert event["tier"] == "degraded" assert event["claims"] == {"policy": "judge", "uncited_claims": 0, "on_unsupported": "degrade", - "mode": "inline", "counts": {"supported": 1, "partial": 0, "unsupported": 1, + "mode": "inline", "counts": {"supported": 1, "partial": 0, "contradicted": 1, + "insufficient": 0, "unsupported": 0, "not_a_claim": 0, "unjudged": 0}} async def test_a_governed_formal_run_grades_by_the_verdicts(client, monkeypatch): - Models(monkeypatch, verdicts={"新客": "unsupported"}) + Models(monkeypatch, verdicts={"新客": "contradicted"}) graph = weekly(report={"numbers": "strict", "on_violation": "fail", "judge": {"max_cost_usd": None, "on_unsupported": "withhold"}}) row = await governed(client, graph) issuance = row.output["_issuance"] - assert issuance["tier"] == "withheld" and issuance["claims"]["unsupported"][0]["text"] == "增长主要来自新客首单。" + assert issuance["tier"] == "withheld" and issuance["claims"]["contradicted"][0]["text"] == "增长主要来自新客首单。" # -------------------------------------------------------------------------- @@ -234,7 +236,7 @@ async def test_a_governed_formal_run_grades_by_the_verdicts(client, monkeypatch) async def test_explore_runs_only_annotate(monkeypatch): - models = Models(monkeypatch, verdicts={"新客": "unsupported"}) + models = Models(monkeypatch, verdicts={"新客": "contradicted"}) issuance = await issued(weekly(report={"judge": {"max_cost_usd": 0.05, "on_unsupported": "withhold"}}), run_class="exploratory") assert models.judges == [], "探索运行节点里不花这笔钱" @@ -242,7 +244,7 @@ async def test_explore_runs_only_annotate(monkeypatch): claims = issuance["claims"] assert claims["mode"] == "on_demand" and claims["complete"] is False assert claims["counts"]["unjudged"] == 2 and claims["unjudged"] == {"on_demand": 2} - assert claims["unsupported"] == [] + assert claims["contradicted"] == [] and claims["insufficient"] == [] async def test_explore_runs_still_count_uncited_claims(monkeypatch): @@ -257,14 +259,14 @@ async def test_explore_runs_still_count_uncited_claims(monkeypatch): async def test_the_contract_can_tighten_on_unsupported(monkeypatch): - Models(monkeypatch, verdicts={"新客": "unsupported"}) + Models(monkeypatch, verdicts={"新客": "contradicted"}) issuance = await issued(weekly(contract={"claims": {"policy": "judge", "on_unsupported": "withhold"}})) assert issuance["tier"] == "withheld" and issuance["claims"]["on_unsupported"] == "withhold" assert not any("不支持" in g and "这一版" in g for g in issuance["gaps"]) async def test_the_contract_cannot_loosen_on_unsupported(monkeypatch): - Models(monkeypatch, verdicts={"新客": "unsupported"}) + Models(monkeypatch, verdicts={"新客": "contradicted"}) issuance = await issued(weekly(report={"judge": {"max_cost_usd": 0.05, "on_unsupported": "withhold"}}, contract={"claims": {"policy": "judge", "on_unsupported": "degrade"}})) assert issuance["tier"] == "withheld" @@ -295,7 +297,7 @@ async def test_runs_from_before_the_upgrade_ignore_judge(monkeypatch): return None monkeypatch.setattr(runner_mod, "_agent_limits", no_snapshot) - models = Models(monkeypatch, text=UNCITED, verdicts={"新客": "unsupported"}) + models = Models(monkeypatch, text=UNCITED, verdicts={"新客": "contradicted"}) issuance = await issued(weekly()) assert models.judges == [] and issuance["tier"] == "formal" and "claims" not in issuance diff --git a/backend/tests/test_evidence_judge.py b/backend/tests/test_evidence_judge.py index f274bebc..1c3c421c 100644 --- a/backend/tests/test_evidence_judge.py +++ b/backend/tests/test_evidence_judge.py @@ -3,7 +3,8 @@ 数字、实体是确定性的,系统自己能核对;「增长主要来自华东」这种话说得对不对,只能交给另一个模型按 证据判断。这是概率性的判断,所以每一步都要留余地、说实话: -- 一份报告合并成一次调用(超过每批的句数就分批),每句给 supported / partial / unsupported / not_a_claim +- 一份报告合并成一次调用(超过每批的句数就分批),每句给 supported / partial / contradicted / insufficient / + not_a_claim - 模型不支持结构化输出、或者给的不是合法结构时退回 JSON 解析,再失败就记「未裁判」,不编判定 - 裁判模型关掉 thinking,max_tokens 4096 - 预算(每份报告的句数、金额、时长,全局每日金额)每一项都能写 null 表示不限;触顶就停、已判的保留、 @@ -183,7 +184,7 @@ def status_of(outcome, doc, text): async def test_one_call_for_the_whole_report_and_the_verdict_shape(monkeypatch, stored): - judge = Judge(monkeypatch, {"u3": "unsupported"}) + judge = Judge(monkeypatch, {"u3": "contradicted"}) catalog = make_catalog(stored) doc = compose_doc(RAW, catalog) outcome = await judge_doc(doc, catalog, spec=SPEC, budget=UNLIMITED) @@ -192,7 +193,8 @@ async def test_one_call_for_the_whole_report_and_the_verdict_shape(monkeypatch, v = status_of(outcome, doc, CITED) assert v["status"] == "supported" and v["judge"] == PRICED and v["post_seal"] is False assert v["rationale"] and v["used"] == ["m:gmv"] - assert outcome["counts"] == {"supported": 3, "partial": 0, "unsupported": 1, "not_a_claim": 0, "unjudged": 0} + assert outcome["counts"] == {"supported": 3, "partial": 0, "contradicted": 1, "insufficient": 0, "not_a_claim": 0, + "unjudged": 0, "unsupported": 0} assert outcome["model"] == PRICED and outcome["calls"] == 1 and outcome["gaps"] == [] assert units_of(doc)["下面看细节。"] not in outcome["verdicts"], "连接性的话不送裁判" @@ -226,7 +228,7 @@ async def test_structured_output_failure_falls_back_to_json(monkeypatch, stored) async def test_an_array_written_as_json_text_is_still_read(monkeypatch, stored): """有的模型走工具调用时把嵌套的数组写成一段 JSON 文本({"items": "[…]"}):照样认,不为它再花一次调用。""" def stringified(units): - items = [{"unit": u, "verdict": "unsupported", "rationale": "对不上", "used": []} for u in units] + items = [{"unit": u, "verdict": "contradicted", "rationale": "对不上", "used": []} for u in units] return json.dumps({"items": json.dumps(items, ensure_ascii=False)}, ensure_ascii=False) judge = Judge(monkeypatch, structured=stringified) @@ -234,7 +236,7 @@ async def test_an_array_written_as_json_text_is_still_read(monkeypatch, stored): doc = compose_doc(RAW, catalog) outcome = await judge_doc(doc, catalog, spec=SPEC, budget=UNLIMITED) assert [c["kind"] for c in judge.calls] == ["structured"] - assert {v["status"] for v in outcome["verdicts"].values()} == {"unsupported"} + assert {v["status"] for v in outcome["verdicts"].values()} == {"contradicted"} async def test_a_broken_provider_is_not_called_batch_after_batch(monkeypatch, stored): @@ -266,18 +268,18 @@ async def test_bad_items_are_not_trusted(monkeypatch, stored): def messy(units): return json.dumps({"items": [ - {"unit": ids[CITED], "verdict": "supported", "rationale": "长" * 100, "used": ["m:gmv", "m:nope", "Q9"]}, + {"unit": ids[CITED], "verdict": "supported", "rationale": "长" * 200, "used": ["m:gmv", "m:nope", "Q9"]}, {"unit": ids[CITED2], "verdict": "maybe", "rationale": "不认识的判定", "used": []}, - {"unit": ids[CAUSAL], "verdict": "unsupported", "rationale": "没有新客维度", "used": []}, + {"unit": ids[CAUSAL], "verdict": "contradicted", "rationale": "没有新客维度", "used": []}, {"unit": "u999", "verdict": "supported", "rationale": "不存在的句子", "used": []}, ]}, ensure_ascii=False) Judge(monkeypatch, structured=messy) outcome = await judge_doc(doc, catalog, spec=SPEC, budget=UNLIMITED) v = outcome["verdicts"] - assert v[ids[CITED]]["rationale"] == "长" * RATIONALE_MAX and v[ids[CITED]]["used"] == ["m:gmv"] + assert v[ids[CITED]]["rationale"] == "长" * (RATIONALE_MAX - 1) + "…" and v[ids[CITED]]["used"] == ["m:gmv"] assert (v[ids[CITED2]]["status"], v[ids[CITED2]]["reason"]) == ("unjudged", "format") - assert v[ids[CAUSAL]]["status"] == "unsupported" + assert v[ids[CAUSAL]]["status"] == "contradicted" assert (v[ids[NUMBER]]["status"], v[ids[NUMBER]]["reason"]) == ("unjudged", "missing") assert "u999" not in v @@ -532,7 +534,7 @@ async def test_a_model_without_a_price_ignores_money_limits(monkeypatch, stored) async def test_known_sentences_are_not_judged_again(monkeypatch, stored): - judge = Judge(monkeypatch, {"u2": "unsupported"}) + judge = Judge(monkeypatch, {"u2": "contradicted"}) catalog = make_catalog(stored) doc = compose_doc(RAW, catalog) first = await judge_doc(doc, catalog, spec=SPEC, budget=UNLIMITED) diff --git a/backend/tests/test_evidence_judge_api.py b/backend/tests/test_evidence_judge_api.py index 8bb20cc2..1f87e71e 100644 --- a/backend/tests/test_evidence_judge_api.py +++ b/backend/tests/test_evidence_judge_api.py @@ -175,7 +175,7 @@ async def save_setting(key: str, value) -> None: async def test_an_on_demand_verdict_lands_after_the_seal_and_the_seal_still_holds(client, monkeypatch): - models = Models(monkeypatch, verdicts={"新客": "unsupported"}) + models = Models(monkeypatch, verdicts={"新客": "contradicted"}) row = await finish(weekly(claims="judge")) assert row.status == "succeeded" and models.judges == [], "探索运行节点里不判" doc = doc_of(row) @@ -185,7 +185,7 @@ async def test_an_on_demand_verdict_lands_after_the_seal_and_the_seal_still_hold assert res.status_code == 200, res.text body = res.json() verdict = body["verdicts"][ids[CAUSAL]] - assert verdict["status"] == "unsupported" and verdict["post_seal"] is True and verdict["judge"] == PRICED + assert verdict["status"] == "contradicted" and verdict["post_seal"] is True and verdict["judge"] == PRICED assert verdict["rationale"] and body["judged"] == [ids[CAUSAL]] and body["reused"] == [] assert body["report"] == {"node_id": "write", "doc_artifact": row.output["_evidence"]["doc_artifact"]} assert body["post_seal"] is True and body["limited"] is False and body["calls"] == 1 @@ -276,9 +276,9 @@ async def test_a_failed_attempt_can_be_retried(client, monkeypatch): failed = (await judge(client, row.id, [uid])).json() assert failed["verdicts"][uid]["status"] == "unjudged" and failed["verdicts"][uid]["reason"] == "error" assert "网关 502" in failed["message"] and failed["limited"] is False - models = Models(monkeypatch, verdicts={"新客": "unsupported"}) + models = Models(monkeypatch, verdicts={"新客": "contradicted"}) retried = (await judge(client, row.id, [uid])).json() - assert retried["verdicts"][uid]["status"] == "unsupported" and retried["reused"] == [] and len(models.judges) == 1 + assert retried["verdicts"][uid]["status"] == "contradicted" and retried["reused"] == [] and len(models.judges) == 1 # -------------------------------------------------------------------------- @@ -414,7 +414,7 @@ async def test_a_broken_seal_is_not_judged(client, monkeypatch): async def test_a_run_continued_during_the_judge_call_records_no_event(client, monkeypatch, meanwhile): """判定只接在点击开始时的那份封存后面。运行在裁判期间被接着跑了,再追加就落进一次活着的运行中间, 还会被封进下一份清单:不记进运行记录,判定照样交回给点的人,并说明为什么没记。""" - models = Models(monkeypatch, verdicts={"新客": "unsupported"}) + models = Models(monkeypatch, verdicts={"新客": "contradicted"}) row = await finish(weekly(claims="judge")) uid = unit_ids(doc_of(row))[CAUSAL] ask_model = judge_mod.get_chat_model @@ -430,7 +430,7 @@ async def test_a_run_continued_during_the_judge_call_records_no_event(client, mo assert res.status_code == 200, res.text body = res.json() assert len(models.judges) == 1 and body["calls"] == 1, "模型问过了" - assert body["verdicts"][uid]["status"] == "unsupported" and body["judged"] == [uid], "判定照样交回" + assert body["verdicts"][uid]["status"] == "contradicted" and body["judged"] == [uid], "判定照样交回" assert body["event"] is None and "运行已继续执行" in body["message"] and "未写入运行记录" in body["message"] assert await judged_events(row.id) == [] async with SessionLocal() as session: @@ -444,7 +444,7 @@ async def test_a_continue_that_wins_the_row_between_check_and_write_gets_no_even from app.core.bus import bus from app.core.config import settings - Models(monkeypatch, verdicts={"新客": "unsupported"}) + Models(monkeypatch, verdicts={"新客": "contradicted"}) row = await finish(weekly(claims="judge")) uid = unit_ids(doc_of(row))[CAUSAL] set_seq = bus.set_seq @@ -462,7 +462,7 @@ async def test_a_continue_that_wins_the_row_between_check_and_write_gets_no_even monkeypatch.setattr(bus, "set_seq", claimed_in_between) body = (await judge(client, row.id, [uid])).json() - assert body["verdicts"][uid]["status"] == "unsupported" and body["event"] is None + assert body["verdicts"][uid]["status"] == "contradicted" and body["event"] is None assert "未写入运行记录" in body["message"] and await judged_events(row.id) == [] async with SessionLocal() as session: after = await session.get(Run, row.id) @@ -503,7 +503,7 @@ async def test_sentences_that_are_not_claims_are_skipped(client, monkeypatch): async def test_the_segment_shows_the_verdict_and_whether_it_can_still_be_asked(client, monkeypatch): - Models(monkeypatch, verdicts={"新客": "unsupported"}) + Models(monkeypatch, verdicts={"新客": "contradicted"}) row = await finish(weekly(claims="judge")) doc = doc_of(row) ids = unit_ids(doc) @@ -513,7 +513,7 @@ async def test_the_segment_shows_the_verdict_and_whether_it_can_still_be_asked(c await judge(client, row.id, [ids[CAUSAL]]) after = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg_in(doc, ids[CAUSAL])}")).json() - assert after["unit"]["verdict"]["status"] == "unsupported" and after["unit"]["verdict"]["post_seal"] is True + assert after["unit"]["verdict"]["status"] == "contradicted" and after["unit"]["verdict"]["post_seal"] is True assert after["unit"]["on_demand"]["available"] is False and after["unit"]["on_demand"]["reason"] == "judged" assert "模型" in after["note"] and "后续版本" not in after["note"] other = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg_in(doc, ids[CITED])}")).json() @@ -531,11 +531,11 @@ async def test_the_segment_shows_the_verdict_and_whether_it_can_still_be_asked(c async def test_sealed_verdicts_of_a_formal_run_are_shown_as_sealed(client, monkeypatch): - Models(monkeypatch, verdicts={"新客": "unsupported"}) + Models(monkeypatch, verdicts={"新客": "contradicted"}) row = await finish(weekly(claims="judge", judge={"max_cost_usd": 0.05}), run_class="formal") doc = doc_of(row) body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg_in(doc, unit_ids(doc)[CAUSAL])}")).json() - assert body["unit"]["verdict"]["status"] == "unsupported" and body["unit"]["verdict"]["post_seal"] is False + assert body["unit"]["verdict"]["status"] == "contradicted" and body["unit"]["verdict"]["post_seal"] is False assert body["unit"]["on_demand"]["reason"] == "formal" graph = (await client.get(f"/api/runs/{row.id}/evidence")).json() assert graph["reports"][0]["post_seal_verdicts"] == {} and graph["reports"][0]["judge"]["mode"] == "inline" @@ -543,7 +543,7 @@ async def test_sealed_verdicts_of_a_formal_run_are_shown_as_sealed(client, monke async def test_a_forged_event_cannot_overwrite_a_sealed_verdict(client, monkeypatch): """封存后追加的判定只填补没判过的句子:封存内判过的,后面追加什么都盖不掉。""" - Models(monkeypatch, verdicts={"新客": "unsupported"}) + Models(monkeypatch, verdicts={"新客": "contradicted"}) row = await finish(weekly(claims="judge", judge={"max_cost_usd": 0.05}), run_class="formal") doc = doc_of(row) uid = unit_ids(doc)[CAUSAL] @@ -552,7 +552,7 @@ async def test_a_forged_event_cannot_overwrite_a_sealed_verdict(client, monkeypa doc_artifact=doc_artifact, post_seal=True, verdicts={uid: {"status": "supported", "rationale": "改好了", "judge": "x", "post_seal": True}}) body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg_in(doc, uid)}")).json() - assert body["unit"]["verdict"]["status"] == "unsupported" + assert body["unit"]["verdict"]["status"] == "contradicted" async def test_appended_verdicts_always_read_as_post_seal(client, monkeypatch): diff --git a/backend/tests/test_governance_judge.py b/backend/tests/test_governance_judge.py index 627dff9f..6462cdaf 100644 --- a/backend/tests/test_governance_judge.py +++ b/backend/tests/test_governance_judge.py @@ -249,7 +249,7 @@ WITHHOLD = {**JUDGED, "judge": {"max_cost_usd": 0.05, "on_unsupported": "withhol ]) def test_relaxing_on_unsupported_on_the_node_is_loosening(judge, dropped): [reason] = _changed(WITHHOLD, {**WITHHOLD, "judge": judge}) - assert "「报告撰写」" in reason and "「结论句裁判 · 证据不支持时」" in reason + assert "「报告撰写」" in reason and "「结论句裁判 · 证据相矛盾时」" in reason assert "「不予出具」" in reason and "「出具降档」" in reason assert ("删除了已设置的值" in reason) is dropped, reason @@ -258,7 +258,7 @@ def test_replacing_the_whole_judge_block_to_add_a_budget_is_loosening(): """补预算把 judge 整个换成 {max_cost_usd}:连同裁判模型、withhold 一起丢了。""" before = {**STRICT, "claims": "judge", "judge": {"model": "judge-model", "on_unsupported": "withhold"}} [reason] = _changed(before, {**before, "judge": {"max_cost_usd": 0.05}}) - assert "「结论句裁判 · 证据不支持时」" in reason and "「不予出具」" in reason + assert "「结论句裁判 · 证据相矛盾时」" in reason and "「不予出具」" in reason assert _changed(before, {**before, "judge": {**before["judge"], "max_cost_usd": 0.05}}) == [], \ "带着原来的键补预算不算放宽" @@ -268,7 +268,7 @@ def test_a_withhold_from_the_graph_defaults_lost_by_a_node_judge_is_loosening(): defaults = {"judge": {"on_unsupported": "withhold", "max_cost_usd": 0.05}} report = {**STRICT, "claims": "judge"} [reason] = _changed(report, {**report, "judge": {"max_cost_usd": 0.05}}, defaults=defaults) - assert "「结论句裁判 · 证据不支持时」" in reason and "从「不予出具」(跟随工作流默认设置)" in reason, reason + assert "「结论句裁判 · 证据相矛盾时」" in reason and "从「不予出具」(跟随工作流默认设置)" in reason, reason assert "不再跟随工作流默认设置" in reason, "说清楚为什么全图默认里的 withhold 不生效了" assert _changed(report, {**report, "judge": {"max_cost_usd": 0.05, "on_unsupported": "withhold"}}, defaults=defaults) == [] @@ -280,7 +280,7 @@ def test_relaxing_on_unsupported_in_the_graph_defaults_is_loosening(): new = copy.deepcopy(old) new["defaults"]["judge"]["on_unsupported"] = "degrade" [reason] = forbidden_changes(old, new) - assert "「报告撰写」" in reason and "「结论句裁判 · 证据不支持时」" in reason + assert "「报告撰写」" in reason and "「结论句裁判 · 证据相矛盾时」" in reason @pytest.mark.parametrize("before, after", [ @@ -297,7 +297,7 @@ def test_tightening_or_keeping_on_unsupported_is_fine(before, after): def test_dropping_withhold_along_with_judge_is_one_reason(): """从 judge 退回 require_citation:claims 那一条已经说了,不再为 on_unsupported 另记一条。""" [reason] = _changed(WITHHOLD, {**STRICT, "judge": {"max_cost_usd": 0.05}}) - assert "「未附依据的结论句」" in reason and "证据不支持时" not in reason + assert "「未附依据的结论句」" in reason and "证据相矛盾时" not in reason @pytest.mark.parametrize("before", ["require_citation", "judge"]) diff --git a/backend/tests/test_judge_rework_api.py b/backend/tests/test_judge_rework_api.py new file mode 100644 index 00000000..c1905e32 --- /dev/null +++ b/backend/tests/test_judge_rework_api.py @@ -0,0 +1,486 @@ +"""结论句裁判改造(JR)的接口:片段接口的字段清单、按需裁判的模型标记、证据图的 judge_meta、审计行。 + +- 片段接口里表名实体那一步带上字段清单 fields: [{name, type}](封存范围内的表结构快照,最多 60 个, + 多出来的记 fields_more),遮罩的列不列 +- 按需裁判也判断裁判模型和写作模型是否相同(写作模型取这份报告撰写节点实际用的模型),并带上 + priced、writer_model;evidence.judged 事件同样记下 +- GET /evidence 每份报告带 judge_meta:最近一次按需裁判的 {model, same_model, priced, writer_model} +- 交给裁判的新摘录(字段清单、指标输入的 SQL)只经封存范围内的 loader 读 +- 审计行的 issue 新增 contradicted_claim、insufficient_claim +- 旧的 unsupported 判定照旧算「判过」:封存之后追加的判定盖不掉它 + +所有裁判都用 mock 模型给剧本,不调真接口。 +""" +from __future__ import annotations + +import asyncio +import csv +import io +import json +import re +import sqlite3 +from types import SimpleNamespace + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage +from sqlalchemy import delete, select + +from app.core import artifact_store +from app.core.events import EventType +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import DataSource, Run, RunEvent, Setting +from app.engine import judge as judge_mod +from app.engine.evidence import iter_segments, iter_units +from app.engine.judge import JUDGE_ROLE, SPEND_KEY +from app.engine.runner import run_manager +from app.main import app +from app.providers.mock_model import MockChatModel + +PRICED = "claude-sonnet-5" +WRITER = "mock-fast" +SOURCE = "jr_shop" +TOOL = f"db_query__{SOURCE}" +SQL = "SELECT region, SUM(amount) AS gmv FROM orders GROUP BY region ORDER BY gmv DESC" +WIDE_COLS = [f"f{i:02d}" for i in range(65)] +METRIC = "东区销售额最高,达 [[m:gmv]]。[[see:m:gmv]]" +METHOD = "这个数由 [[t:orders]] 按 [[c:orders.region]] 分组求和得出。" +WIDE = "明细在 [[t:wide_orders]] 里。" +TEXT = METRIC + METHOD + WIDE + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + async with SessionLocal() as session: + await session.execute(delete(Setting).where(Setting.key.in_([SPEND_KEY, "judge", "copilot"]))) + await session.commit() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest.fixture(autouse=True) +async def shop(tmp_path): + """探查过结构的示例库:orders(带手机号列,数据源设了遮罩)和一张 65 列的宽表。""" + from app.data.introspect import introspect + + path = tmp_path / "jr_shop.db" + db = sqlite3.connect(path) + db.executescript( + "CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, amount REAL, phone TEXT);" + f"CREATE TABLE wide_orders ({', '.join(f'{c} TEXT' for c in WIDE_COLS)});") + db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", + [(1, "east", 300.5, "p1"), (2, "west", 200.0, "p2"), (3, "east", 100.0, "p3")]) + db.commit() + db.close() + probe = SimpleNamespace(id=f"probe-{path.name}", name=SOURCE, kind="sqlite", database=str(path), host=None, + port=None, username=None, password=None, options={}, readonly=True, schema_cache={}) + cache = await introspect(probe) + await engines.invalidate(probe.id) + async with SessionLocal() as session: + row = (await session.execute(select(DataSource).where(DataSource.name == SOURCE))).scalar_one_or_none() + if row is None: + row = DataSource(name=SOURCE, kind="sqlite", database=str(path), readonly=True, enabled=True) + session.add(row) + row.database, row.kind, row.enabled, row.readonly, row.schema_cache = str(path), "sqlite", True, True, cache + row.options = {"mask_columns": "phone"} + await session.commit() + source_id = row.id + await engines.invalidate(source_id) + yield source_id + await engines.invalidate(source_id) + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def weekly(**report) -> dict: + nodes = [ + node("start", "input"), + node("fetch", "tool", tool=TOOL, args={"sql": SQL}), + node("card", "metrics", caliber="周报口径", caliber_version="v2", metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "cell(nodes.fetch, 0, 'gmv')"}]), + node("write", "report", instructions="写周报", on_violation="flag", claims="judge", **report), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]), + ] + ids = [n["id"] for n in nodes] + return {"nodes": nodes, "edges": [{"source": a, "target": b} for a, b in zip(ids, ids[1:])]} + + +class Models: + """写作者回 text;裁判按句子里的字给判定:verdicts = {句中片段: (判定, missing)}。judges 记下请求原文。""" + + def __init__(self, monkeypatch, verdicts=None, *, model=PRICED, text=TEXT): + self.judges: list[str] = [] + verdicts = verdicts or {} + + def decide(chat, messages): + if JUDGE_ROLE in str(messages[0].content): + request = str(messages[-1].content) + self.judges.append(request) + items = [] + for uid, line in re.findall(r"^\[(u\d+)\]([^\n]*)$", request, re.M): + status, missing = next((v for frag, v in verdicts.items() if frag in line), ("supported", "")) + items.append({"unit": uid, "verdict": status, "rationale": f"判的是:{line.strip()[:12]}", + "missing": missing, "used": []}) + return AIMessage(content=json.dumps({"items": items}, ensure_ascii=False)) + return AIMessage(content=text) + + monkeypatch.setattr(MockChatModel, "_decide", decide) + self.model = model + + async def judge_model_of(session, spec): + return MockChatModel(model_name=self.model), self.model + + monkeypatch.setattr(judge_mod, "get_chat_model", judge_model_of) + + +async def wait(run_id: str) -> Run: + 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") and row.manifest_seq is not None: + return row + raise AssertionError(f"run {run_id} 没跑完:{row.status}") + + +async def explore_run(graph=None) -> tuple[Run, dict]: + started = await run_manager.start(graph=graph or weekly(), input_payload={}, run_class="exploratory") + row = await wait(started.id) + assert row.status == "succeeded", row.error + return row, artifact_store.load(row.output["_evidence"]["doc_artifact"]) + + +def unit_of(doc: dict, prefix: str) -> str: + md = doc["markdown"] + return next(u["id"] for _, u in iter_units(doc) if md[u["span"][0]:u["span"][1]].strip().startswith(prefix)) + + +async def judged_events(run_id: str) -> list[RunEvent]: + async with SessionLocal() as session: + return list((await session.execute(select(RunEvent).where( + RunEvent.run_id == run_id, RunEvent.type == EventType.EVIDENCE_JUDGED).order_by(RunEvent.seq))).scalars()) + + +async def ask(client, run_id: str, units: list[str]) -> dict: + res = await client.post(f"/api/runs/{run_id}/evidence/judge", json={"units": units}) + assert res.status_code == 200, res.text + return res.json() + + +async def reseal(run_id: str) -> None: + async with SessionLocal() as session: + run = await session.get(Run, run_id) + rows = [tuple(r) for r in await session.execute( + select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data) + .where(RunEvent.run_id == run_id, RunEvent.seq <= run.manifest_seq).order_by(RunEvent.seq))] + run.manifest_hash = artifact_store.manifest_hash(rows) + await session.commit() + + +async def rewrite(run_id: str, etype: str, node_id: str, change) -> None: + async with SessionLocal() as session: + for row in (await session.execute(select(RunEvent).where( + RunEvent.run_id == run_id, RunEvent.type == etype, RunEvent.node_id == node_id))).scalars(): + row.data = change(dict(row.data)) + await session.commit() + + +# -------------------------------------------------------------------------- +# 片段接口:表名实体步骤的字段清单 +# -------------------------------------------------------------------------- + + +async def open_entity(client, run_id: str, doc: dict, ref: str) -> dict: + seg = next(s for s in iter_segments(doc) if s.get("kind") == "entity" and s.get("ref") == ref) + res = await client.get(f"/api/runs/{run_id}/evidence/segments/{seg['id']}") + assert res.status_code == 200, res.text + [step] = res.json()["chain"] + return step + + +async def test_a_table_step_lists_its_fields_without_masked_columns(client, monkeypatch): + Models(monkeypatch) + row, doc = await explore_run() + step = await open_entity(client, row.id, doc, "t:orders") + assert step["step"] == "entity" and step["kind"] == "table" + assert step["fields"] == [{"name": "id", "type": "INTEGER"}, {"name": "region", "type": "TEXT"}, + {"name": "amount", "type": "REAL"}], "遮罩的 phone 不列" + assert "fields_more" not in step and step["columns"] == 4 + + +async def test_a_wide_table_step_stops_at_sixty_fields(client, monkeypatch): + Models(monkeypatch) + row, doc = await explore_run() + step = await open_entity(client, row.id, doc, "t:wide_orders") + assert [f["name"] for f in step["fields"]] == WIDE_COLS[:60] + assert step["fields_more"] == 5 + + +async def test_a_table_step_outside_the_seal_has_no_fields(client, monkeypatch): + Models(monkeypatch) + row, doc = await explore_run() + await rewrite(row.id, "tool.end", "fetch", lambda d: {k: v for k, v in d.items() if k != "schema_artifact"}) + await rewrite(row.id, "node.finished", "fetch", + lambda d: {**d, "evidence": [e for e in d.get("evidence") or [] if e.get("kind") != "schema"]}) + await reseal(row.id) + step = await open_entity(client, row.id, doc, "t:orders") + assert "fields" not in step and "fields_more" not in step + + +# -------------------------------------------------------------------------- +# 按需裁判:新的判定、摘录、模型标记 +# -------------------------------------------------------------------------- + + +async def test_on_demand_judging_sees_fields_and_metric_sql_and_reports_the_models(client, monkeypatch): + models = Models(monkeypatch, verdicts={"分组求和": ("insufficient", "字段清单"), "东区销售额": ("contradicted", "")}) + row, doc = await explore_run() + method, metric = unit_of(doc, "这个数由"), unit_of(doc, "东区销售额") + + graph = (await client.get(f"/api/runs/{row.id}/evidence")).json() + assert graph["reports"][0]["judge_meta"] is None, "还没按需裁判过" + + body = await ask(client, row.id, [method, metric]) + request = models.judges[0] + assert "region TEXT" in request and "id INTEGER" in request, "表附上字段清单和类型" + assert "phone" not in request.lower(), "遮罩的列不出现" + assert "查询 Q1 的 SQL" in request and "SUM(amount)" in request and "r0" in request, "指标附上输入的查询和格子" + + assert body["verdicts"][method]["status"] == "insufficient" and body["verdicts"][method]["missing"] == "字段清单" + assert body["verdicts"][metric]["status"] == "contradicted" + assert body["same_model"] is False and body["priced"] is True and body["writer_model"] == WRITER + + [event] = await judged_events(row.id) + assert event.data["same_model"] is False and event.data["priced"] is True + assert event.data["writer_model"] == WRITER + + graph = (await client.get(f"/api/runs/{row.id}/evidence")).json() + assert graph["reports"][0]["judge_meta"] == {"model": PRICED, "same_model": False, "priced": True, + "writer_model": WRITER} + + +async def test_judging_with_the_writer_model_is_flagged(client, monkeypatch): + models = Models(monkeypatch, model=WRITER) + row, doc = await explore_run() + body = await ask(client, row.id, [unit_of(doc, "这个数由")]) + assert models.judges and body["model"] == WRITER + assert body["same_model"] is True and body["writer_model"] == WRITER, "判定照常出,另带标记" + assert body["verdicts"][unit_of(doc, "这个数由")]["status"] == "supported" + [event] = await judged_events(row.id) + assert event.data["same_model"] is True and event.data["writer_model"] == WRITER + + # judge_meta 取最近一次:换一个价格目录里没有的模型再判一句,就按新的这次 + models.model = "house-judge-model" + body = await ask(client, row.id, [unit_of(doc, "东区销售额")]) + assert body["same_model"] is False and body["priced"] is False, "目录里没有价格的模型:金额上限对它不生效" + meta = (await client.get(f"/api/runs/{row.id}/evidence")).json()["reports"][0]["judge_meta"] + assert meta == {"model": "house-judge-model", "same_model": False, "priced": False, "writer_model": WRITER} + + +async def test_the_new_excerpts_only_read_sealed_artifacts(client, monkeypatch): + """表结构快照、查询快照被挪出封存范围(事件里追不到了):字段清单、指标输入的 SQL 都不进裁判的摘录。""" + models = Models(monkeypatch) + row, doc = await explore_run() + await rewrite(row.id, "tool.end", "fetch", + lambda d: {k: v for k, v in d.items() if k not in ("schema_artifact", "query_artifact")}) + await rewrite(row.id, "node.finished", "fetch", + lambda d: {**d, "evidence": [e for e in d.get("evidence") or [] + if e.get("kind") not in ("schema", "query")]}) + await reseal(row.id) + await ask(client, row.id, [unit_of(doc, "这个数由"), unit_of(doc, "东区销售额")]) + request = models.judges[0] + assert "INTEGER" not in request and "region TEXT" not in request + assert "SELECT" not in request + + +# -------------------------------------------------------------------------- +# 审计行 +# -------------------------------------------------------------------------- + + +async def test_audit_rows_list_contradicted_and_insufficient_claims(client, monkeypatch): + Models(monkeypatch, verdicts={"分组求和": ("insufficient", "字段清单"), "东区销售额": ("contradicted", "")}) + row, doc = await explore_run() + method, metric = unit_of(doc, "这个数由"), unit_of(doc, "东区销售额") + await ask(client, row.id, [method, metric]) + + body = (await client.get(f"/api/runs/{row.id}/evidence/audit")).json() + rows = [r for g in body["groups"] for r in g["rows"] if r["kind"] == "claim"] + by_issue = {r["issue"]: r for r in rows} + assert by_issue["contradicted_claim"]["unit"] == metric and by_issue["contradicted_claim"]["group"] == "none" + assert by_issue["insufficient_claim"]["unit"] == method + assert "字段清单" in by_issue["insufficient_claim"]["note"] + assert by_issue["insufficient_claim"]["verdict"]["missing"] == "字段清单" + assert by_issue["contradicted_claim"]["verdict"]["post_seal"] is True + + text = (await client.get(f"/api/runs/{row.id}/evidence/audit?format=csv")).text + issues = {line[8] for line in csv.reader(io.StringIO(text.lstrip("")))} + assert {"证据相矛盾", "证据不足"} <= issues + + +# -------------------------------------------------------------------------- +# 旧的 unsupported:照旧算判过 +# -------------------------------------------------------------------------- + + +def test_an_old_unsupported_verdict_still_counts_as_judged(): + from app.api.evidence import _effective_verdicts, _on_demand, _Report, _Sealed + from app.engine.evidence import compose_doc + + doc = compose_doc("增长主要来自新客首单。", {}, node_id="write", run_id="r1") + [unit] = [u for _, u in iter_units(doc)] + unit["verdict"] = {"status": "unsupported", "rationale": "证据不支持", "judge": PRICED, "post_seal": False, + "used": []} + report = _Report(node_id="write", doc_artifact="d" * 64, ok=True, repairs=0, stats={}, doc=doc, hash_ok=True, + fields=[], claims="judge") + later = {"report": "write", "doc_artifact": "d" * 64, + "verdicts": {unit["id"]: {"status": "supported", "rationale": "有依据", "judge": PRICED}}} + sealed = _Sealed(run_id="r1", graph={}, seal={"sealed": True, "ok": True, "manifest_seq": 5, "legacy": False}, + events=[], judged=[(9, "write", later)], run_class="exploratory", status="succeeded", + ledger_on=True) + effective, appended = _effective_verdicts(report, sealed) + assert effective[unit["id"]]["status"] == "unsupported" and appended == {}, "追加的判定盖不掉封存的判定" + assert _on_demand(sealed, doc, unit["id"], effective[unit["id"]])["reason"] == "judged" + + +def test_the_writer_model_of_an_old_report_comes_from_the_sealed_node_output(): + """改造前的文档裁判摘要里没有 writer_model、改造前的事件里没有 same_model:按报告撰写节点封存的产出里 + 实际用的模型补上。""" + from app.api.evidence import _judge_meta, _Report, _Sealed, _writer_model + from app.engine.evidence import compose_doc + + text = artifact_store.canonical_json({"text": "增长主要来自新客首单。", "model": "writer-old"}) + output = artifact_store.content_hash(text) + path = artifact_store._path_of(output) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + doc = compose_doc("增长主要来自新客首单。", {}, node_id="write", run_id="r2") + doc["judge"] = {"mode": "on_demand", "model": None, "counts": {}} + report = _Report(node_id="write", doc_artifact="e" * 64, ok=True, repairs=0, stats={}, doc=doc, hash_ok=True, + fields=[], claims="judge") + old_event = {"report": "write", "doc_artifact": "e" * 64, "model": "writer-old", "priced": False, + "verdicts": {}} + sealed = _Sealed(run_id="r2", graph={}, seal={"sealed": True, "ok": True, "manifest_seq": 5, "legacy": False}, + events=[(3, "node.finished", "write", {"artifact": output})], outputs={"write": output}, + judged=[(9, "write", old_event)], run_class="exploratory", status="succeeded", ledger_on=True) + assert _writer_model(sealed, report) == "writer-old" + assert _judge_meta(report, sealed) == {"model": "writer-old", "same_model": True, "priced": False, + "writer_model": "writer-old"} + + +# -------------------------------------------------------------------------- +# 按需裁判:早于当前规则的判定可以重判,当前规则下判过的照旧复用 +# -------------------------------------------------------------------------- + + +def on_demand_of(verdict: dict) -> dict: + from app.api.evidence import _on_demand, _Sealed + from app.engine.evidence import compose_doc + + doc = compose_doc("增长主要来自新客首单。", {}, node_id="write", run_id="r3") + [unit] = [u for _, u in iter_units(doc)] + sealed = _Sealed(run_id="r3", graph={}, seal={"sealed": True, "ok": True, "manifest_seq": 5, "legacy": False}, + events=[], run_class="exploratory", status="succeeded", ledger_on=True) + return _on_demand(sealed, doc, unit["id"], verdict) + + +@pytest.mark.parametrize("verdict", [ + {"status": "supported", "rationale": "有依据", "post_seal": True}, + {"status": "unsupported", "rationale": "证据不支持", "post_seal": True}, + {"status": "insufficient", "rationale": "缺 SQL", "post_seal": True, "rule_version": 1}, +]) +def test_an_outdated_appended_verdict_can_be_asked_again(verdict): + got = on_demand_of(verdict) + assert got["available"] is True and got["reason"] == "outdated" and got["message"] + + +def test_a_current_verdict_is_not_asked_again(): + got = on_demand_of({"status": "contradicted", "rationale": "对不上", "post_seal": True, + "rule_version": judge_mod.RULES_VERSION}) + assert got["available"] is False and got["reason"] == "judged" + + +def test_a_sealed_verdict_is_never_reopened_by_version(): + """封存的判定(文档里的,post_seal 为 False)盖不掉:版本再旧也不开放重判。""" + got = on_demand_of({"status": "unsupported", "rationale": "证据不支持", "post_seal": False}) + assert got["available"] is False and got["reason"] == "judged" + + +async def age(run_id: str, uid: str, **changes) -> None: + """把封存之后追加的那条判定改成改造前的样子(封存之后的事件不在封存核对范围里)。""" + async with SessionLocal() as session: + for row in (await session.execute(select(RunEvent).where( + RunEvent.run_id == run_id, RunEvent.type == EventType.EVIDENCE_JUDGED))).scalars(): + data = json.loads(json.dumps(row.data)) + if uid in data.get("verdicts", {}): + verdict = {k: v for k, v in data["verdicts"][uid].items() if k != "rule_version"} + data["verdicts"][uid] = {**verdict, **changes} + row.data = data + await session.commit() + + +async def segment_of(client, run_id: str, doc: dict, uid: str) -> dict: + seg = next(s["id"] for _, u in iter_units(doc) if u["id"] == uid for s in u["segments"]) + res = await client.get(f"/api/runs/{run_id}/evidence/segments/{seg}") + assert res.status_code == 200, res.text + return res.json()["unit"] + + +async def test_an_outdated_verdict_is_really_judged_again(client, monkeypatch): + models = Models(monkeypatch, verdicts={"东区销售额": ("contradicted", "")}) + row, doc = await explore_run() + uid = unit_of(doc, "东区销售额") + first = await ask(client, row.id, [uid]) + assert first["judged"] == [uid] and len(models.judges) == 1 + assert first["verdicts"][uid]["rule_version"] == judge_mod.RULES_VERSION + + # 当前规则下判过的:照旧复用,不再调用 + again = await ask(client, row.id, [uid]) + assert again["reused"] == [uid] and again["judged"] == [] and len(models.judges) == 1 + assert (await segment_of(client, row.id, doc, uid))["on_demand"]["reason"] == "judged" + + # 改造前判的(旧取值、没有规则版本):按钮照常出现,POST 真的重判一次,新判定追加在封存之后 + await age(row.id, uid, status="unsupported") + unit = await segment_of(client, row.id, doc, uid) + assert unit["verdict"]["status"] == "unsupported" + assert unit["on_demand"]["available"] is True and unit["on_demand"]["reason"] == "outdated" + redo = await ask(client, row.id, [uid]) + assert len(models.judges) == 2 and redo["judged"] == [uid] and redo["reused"] == [] + assert redo["verdicts"][uid]["status"] == "contradicted" and redo["event"] is not None + assert len(await judged_events(row.id)) == 2 + graph = (await client.get(f"/api/runs/{row.id}/evidence")).json() + latest = graph["reports"][0]["post_seal_verdicts"][uid] + assert latest["status"] == "contradicted" and latest["rule_version"] == judge_mod.RULES_VERSION + assert (await segment_of(client, row.id, doc, uid))["on_demand"]["reason"] == "judged" + + +async def test_a_failed_rejudge_keeps_the_older_verdict_in_force(client, monkeypatch): + """重判没判成(模型不可用):旧判定照旧生效,答复和证据图说的一样;按钮还在,可以再点。""" + models = Models(monkeypatch) + row, doc = await explore_run() + uid = unit_of(doc, "东区销售额") + await ask(client, row.id, [uid]) + await age(row.id, uid) + + async def broken(session, spec): + raise RuntimeError("网关 502") + + monkeypatch.setattr(judge_mod, "get_chat_model", broken) + body = await ask(client, row.id, [uid]) + assert body["judged"] == [] and "网关 502" in body["message"] + assert body["verdicts"][uid]["status"] == "supported" and "rule_version" not in body["verdicts"][uid] + graph = (await client.get(f"/api/runs/{row.id}/evidence")).json() + assert graph["reports"][0]["post_seal_verdicts"][uid] == body["verdicts"][uid] + assert (await segment_of(client, row.id, doc, uid))["on_demand"]["reason"] == "outdated" + assert len(models.judges) == 1 diff --git a/backend/tests/test_judge_rework_engine.py b/backend/tests/test_judge_rework_engine.py new file mode 100644 index 00000000..b457fd0f --- /dev/null +++ b/backend/tests/test_judge_rework_engine.py @@ -0,0 +1,455 @@ +"""结论句裁判改造(JR)的内核:判定拆档、补全证据摘录、理由不硬截断。 + +- 判定拆成 contradicted(证据相矛盾)和 insufficient(证据不足):只有证据和句子冲突才判 contradicted, + 证据里没有相关信息判 insufficient,并写明缺的是什么(missing)。新判的不再产出 unsupported: + 模型照旧写 unsupported 的,按更严的 contradicted 收下 +- 讲方法、讲结构的句子只挂了表名、字段名,裁判以前只拿到名字:表附上字段清单和类型(最多 60 个, + 快照不完整要注明),字段附上类型和所属的表,口径卡指标附上输入取自哪次查询、哪一格和那次查询的 SQL。 + 一律只经封存范围内的 loader 读工件,遮罩的列不出现 +- 理由上限 120 个字,超长时在句子边界截断加「…」;裁判规则要求 80 个字以内 + +所有模型调用都是 mock 剧本,不碰真接口。 +""" +from __future__ import annotations + +import json +import re + +import pytest +from langchain_core.messages import AIMessage +from sqlalchemy import delete + +from app.db.base import SessionLocal +from app.db.models import DataSource, Setting +from app.engine import judge as judge_mod +from app.engine.evidence import build_catalog, compose_doc, iter_units +from app.engine.judge import ( + JUDGE_ROLE, + JUDGE_SCHEMA, + JUDGED, + SPEND_KEY, + SQL_CHARS, + Budget, + judge_doc, + prepare, + source_masks, + summarize, +) +from app.providers.factory import ModelSpec +from app.providers.mock_model import MockChatModel + +PRICED = "claude-sonnet-5" +UNLIMITED = Budget(max_claims=None, max_cost_usd=None, timeout_s=None, daily_max_usd=None) +SPEC = ModelSpec(model=PRICED) + +SOURCE = "warehouse" +SCHEMA_ART, Q1_ART, Q2_ART, CARD_ART = "5" * 64, "a" * 64, "b" * 64, "c" * 64 +SQL1 = "SELECT COUNT(DISTINCT member_no) AS active_users FROM orders WHERE created_at >= '2026-09-01'" +SQL2 = "SELECT region, SUM(amount) AS gmv FROM orders GROUP BY region" +ORDER_COLS = [("id", "INTEGER"), ("member_no", "TEXT"), ("region", "TEXT"), ("amount", "REAL"), + ("phone", "TEXT"), ("created_at", "TEXT")] + + +def schema_snapshot(columns=ORDER_COLS, *, truncated=False) -> dict: + return {"source": SOURCE, "synced_at": "2026-09-01T00:00:00+00:00", "schema": None, + "tables": {"orders": {"columns": [{"name": n, "type": t, "nullable": True} for n, t in columns], + "primary_key": ["id"], "comment": None, "is_view": False}, + "refunds": {"columns": [{"name": "id", "type": "INTEGER"}, {"name": "amount", "type": "REAL"}], + "primary_key": ["id"], "is_view": False}}, + **({"truncated": True, "total": 500} if truncated else {})} + + +def query_entry(art: str, columns: list[str], tables: list[str]) -> dict: + return {"kind": "query", "node_id": "fetch", "exec": 1, "artifact": art, "tool": f"db_query__{SOURCE}", + "source": SOURCE, "columns": columns, "rows": 1, "truncated": False, "schema_artifact": SCHEMA_ART, + "tables": tables} + + +def schema_entry(snapshot: dict) -> dict: + tables = {name: [c["name"] for c in meta["columns"]] for name, meta in snapshot["tables"].items()} + return {"kind": "schema", "node_id": "fetch", "exec": 1, "artifact": SCHEMA_ART, "source": SOURCE, + "tables": tables, **({"truncated": True, "total": 500} if snapshot.get("truncated") else {})} + + +def snaps(snapshot=None, **extra) -> dict: + out = {SCHEMA_ART: snapshot or schema_snapshot(), + Q1_ART: {"columns": ["active_users"], "rows": [[321]], "sql": SQL1, "source": SOURCE}, + Q2_ART: {"columns": ["region", "gmv"], "rows": [["华东", 100.0]], "sql": SQL2, "source": SOURCE}} + out.update(extra) + return out + + +def entity_catalog(snapshot=None) -> dict: + snapshot = snapshot or schema_snapshot() + return build_catalog(nodes={}, ledger=[query_entry(Q1_ART, ["active_users"], ["orders"]), + query_entry(Q2_ART, ["region", "gmv"], ["orders"]), schema_entry(snapshot)]) + + +def excerpts(text: str, *, snapshot=None, loader=None, masked=None) -> dict[str, str]: + cat = entity_catalog(snapshot) + doc = compose_doc(text, cat) + store = snaps(snapshot) + return prepare(doc, cat, loader=loader or store.get, masked=masked).excerpts + + +def field_line(excerpt: str) -> str: + [line] = [x for x in excerpt.splitlines() if x.startswith("字段清单")] + return line + + +# -------------------------------------------------------------------------- +# 判定拆档:取值、规则、schema +# -------------------------------------------------------------------------- + + +def test_the_schema_offers_contradicted_and_insufficient_instead_of_unsupported(): + item = JUDGE_SCHEMA["properties"]["items"]["items"] + assert item["properties"]["verdict"]["enum"] == list(JUDGED) == [ + "supported", "partial", "contradicted", "insufficient", "not_a_claim"] + assert "unsupported" not in JUDGED + assert "missing" in item["properties"] and "missing" in item["required"] + assert "80" in item["properties"]["rationale"]["description"] + + +def test_the_rules_separate_conflict_from_missing_information(): + rules = judge_mod.JUDGE_RULES + assert "unsupported" not in rules, "新判的一律用新取值" + assert "contradicted" in rules and "insufficient" in rules and "missing" in rules + # 只有冲突才判 contradicted;没有相关信息判 insufficient,并写明缺什么 + [conflict] = [line for line in rules.splitlines() if line.startswith("- contradicted")] + assert "冲突" in conflict + [lacking] = [line for line in rules.splitlines() if line.startswith("- insufficient")] + assert "没有" in lacking + assert "80 个字" in rules and "60 个字" not in rules + assert "表结构快照不完整" in rules, "快照不完整时「某字段不存在」只能判 insufficient" + assert "数字已经由系统核对过,不要复算" in rules, "旧规则保留" + + +def test_count_keys_keep_unsupported_for_old_data(): + assert set(judge_mod.COUNT_KEYS) == {"supported", "partial", "contradicted", "insufficient", "unsupported", "not_a_claim", + "unjudged"} + + +# -------------------------------------------------------------------------- +# 判定拆档:调用与收下 +# -------------------------------------------------------------------------- + +RAW = ("活跃用户数由 [[t:orders]] 按 [[c:orders.member_no]] 去重计数得出。" + "各区销售额按 [[c:orders.region]] 汇总。[[see:Q2]]" + "华东的销售额是全国最高的,领先第二名一倍。[[see:Q2]]" + "订单表里没有退款字段。[[see:t:orders]]") + + +class Judge: + """裁判剧本:items(units, request) 给出每句的条目;calls 记下每次交给裁判的请求原文。""" + + def __init__(self, monkeypatch, items, *, model_id=PRICED): + self.calls: list[str] = [] + + def decide(model, messages): + assert JUDGE_ROLE in str(messages[0].content) + request = str(messages[-1].content) + self.calls.append("\n".join(str(m.content) for m in messages)) + units = re.findall(r"^\[(u\d+)\]", request, re.M) + return AIMessage(content=json.dumps({"items": items(units)}, ensure_ascii=False)) + + monkeypatch.setattr(MockChatModel, "_decide", decide) + + async def fake_model(session, spec): + return MockChatModel(model_name=model_id), model_id + + monkeypatch.setattr(judge_mod, "get_chat_model", fake_model) + + +def ids_of(doc) -> dict[str, str]: + md = doc["markdown"] + return {md[u["span"][0]:u["span"][1]].strip(): u["id"] for _, u in iter_units(doc)} + + +@pytest.fixture(autouse=True) +async def clean_settings(): + async with SessionLocal() as session: + await session.execute(delete(Setting).where(Setting.key.in_([SPEND_KEY, "judge", "copilot"]))) + await session.commit() + yield + + +async def test_contradicted_and_insufficient_come_back_with_missing(monkeypatch): + cat = entity_catalog() + doc = compose_doc(RAW, cat) + ids = ids_of(doc) + method, grouped, ranked, absent = (ids[t] for t in list(ids)[:4]) + script = { + method: {"verdict": "supported", "rationale": "Q1 的 SQL 写的是 COUNT(DISTINCT member_no)", "missing": ""}, + grouped: {"verdict": "insufficient", "rationale": "摘录里没有这次汇总的 SQL", "missing": "SQL"}, + ranked: {"verdict": "contradicted", "rationale": "Q2 里华东不是最高", "missing": "应当被丢掉"}, + absent: {"verdict": "insufficient", "rationale": "", "missing": ""}, + } + Judge(monkeypatch, lambda units: [{"unit": u, "used": [], **script[u]} for u in units]) + outcome = await judge_doc(doc, cat, spec=SPEC, budget=UNLIMITED, loader=snaps().get) + v = outcome["verdicts"] + assert v[grouped]["status"] == "insufficient" and v[grouped]["missing"] == "SQL" + assert v[ranked]["status"] == "contradicted" and "missing" not in v[ranked], "只有 insufficient 带 missing" + assert v[absent]["status"] == "insufficient" and "missing" not in v[absent], "没写缺什么就不编" + assert v[method]["status"] == "supported" and "missing" not in v[method] + assert set(v[grouped]) == {"status", "rationale", "judge", "post_seal", "used", "missing", "rule_version"} + assert outcome["counts"] == {"supported": 1, "partial": 0, "contradicted": 1, "insufficient": 2, + "not_a_claim": 0, "unjudged": 0, "unsupported": 0} + + +async def test_a_model_still_saying_unsupported_is_taken_as_contradicted(monkeypatch): + """新判的不再产出 unsupported:模型照旧写 unsupported 的按更严的 contradicted 收下,不当格式错误放过。""" + cat = entity_catalog() + doc = compose_doc(RAW, cat) + Judge(monkeypatch, lambda units: [{"unit": u, "verdict": "unsupported", "rationale": "对不上", "used": []} + for u in units]) + outcome = await judge_doc(doc, cat, spec=SPEC, budget=UNLIMITED, loader=snaps().get) + assert {x["status"] for x in outcome["verdicts"].values()} == {"contradicted"} + assert outcome["counts"]["unsupported"] == 0 and outcome["counts"]["contradicted"] == 4 + + +def test_summary_counts_always_carry_every_key(): + """缓存里的旧判定只有旧的五个键:摘要照样补齐 contradicted、insufficient。""" + outcome = {"counts": {"supported": 1, "partial": 0, "unsupported": 1, "not_a_claim": 0, "unjudged": 0}, + "candidates": 2, "screened": [], "unjudged": {}, "limits_hit": [], "gaps": []} + summary = summarize(outcome, mode="inline", writer_model="writer-x") + assert summary["counts"] == {"supported": 1, "partial": 0, "contradicted": 0, "insufficient": 0, + "not_a_claim": 0, "unjudged": 0, "unsupported": 1} + assert summary["writer_model"] == "writer-x" + + +# -------------------------------------------------------------------------- +# 理由不硬截断 +# -------------------------------------------------------------------------- + + +def test_a_rationale_under_the_cap_is_kept_whole(): + text = "摘录里" + "有对应的指标和查询" * 10 + "。" # 94 个字:旧规则会截在 60 字的半句中间 + assert 60 < len(text) <= judge_mod.RATIONALE_MAX == 120 + assert judge_mod.clip_text(text) == text + + +def test_a_long_rationale_is_cut_at_a_sentence_boundary(): + first = "Q2 的 SQL 按 region 汇总,华东 100 元排第一" + text = first + ";" + "第二名的数字摘录里没有给出," * 12 + got = judge_mod.clip_text(text) + assert len(got) <= judge_mod.RATIONALE_MAX and got.endswith("…") + body = got[:-1] + assert text.startswith(body) and text[len(body)] in "。;,", f"截在句子边界:{got}" + + +def test_a_long_run_without_punctuation_is_not_cut_inside_a_word(): + text = " ".join(["evidence"] * 30) + got = judge_mod.clip_text(text) + assert len(got) <= judge_mod.RATIONALE_MAX and got.endswith("…") + assert got[:-1].rstrip().split(" ")[-1] == "evidence", got + + +async def test_the_judge_keeps_a_long_rationale_up_to_the_cap(monkeypatch): + cat = entity_catalog() + doc = compose_doc("各区销售额按 [[c:orders.region]] 汇总。[[see:Q2]]", cat) + long = "Q2 的 SQL 是按 region 分组求和," * 3 + "句子说的汇总方式和 SQL 一致," * 8 + Judge(monkeypatch, lambda units: [{"unit": u, "verdict": "supported", "rationale": long, "used": []} + for u in units]) + outcome = await judge_doc(doc, cat, spec=SPEC, budget=UNLIMITED, loader=snaps().get) + [v] = outcome["verdicts"].values() + assert 60 < len(v["rationale"]) <= judge_mod.RATIONALE_MAX and v["rationale"].endswith("…") + assert long.startswith(v["rationale"][:-1]) + + +# -------------------------------------------------------------------------- +# 补全证据摘录:表 +# -------------------------------------------------------------------------- + + +def test_a_table_excerpt_lists_its_fields_and_types(): + ex = excerpts("活跃用户数由 [[t:orders]] 去重计数得出。") + table = ex["t:orders"] + line = field_line(table) + for name, kind in ORDER_COLS: + assert f"{name} {kind}" in line, line + assert "COUNT(DISTINCT member_no)" in table, "出现过的查询 SQL 保留" + assert "表结构快照不完整" not in table + + +def test_the_field_list_stops_at_sixty(): + columns = [(f"col_{i:02d}", "TEXT") for i in range(75)] + ex = excerpts("活跃用户数由 [[t:orders]] 去重计数得出。", snapshot=schema_snapshot(columns)) + line = field_line(ex["t:orders"]) + listed = re.findall(r"col_\d\d", line) + assert judge_mod.FIELDS_MAX == 60 and listed == [f"col_{i:02d}" for i in range(60)] + assert "另有 15 个" in line + + +def test_masked_fields_are_never_listed(): + ex = excerpts("活跃用户数由 [[t:orders]] 去重计数得出。", masked={SOURCE: ["Phone"]}) + table = ex["t:orders"] + assert "phone" not in field_line(table).lower() and "member_no TEXT" in field_line(table) + assert "遮罩" in table, "说明有字段按遮罩没列出:裁判不能据此说表里没有这个字段" + + +def test_masks_recorded_in_a_query_snapshot_also_hide_fields(): + """数据源改名、删掉了,现在查不到遮罩:查询当时记下的遮罩照样生效(证据面板同一个口径)。""" + cat = entity_catalog() + doc = compose_doc("活跃用户数由 [[t:orders]] 去重计数得出。", cat) + store = snaps(**{Q1_ART: {"columns": ["active_users"], "rows": [[321]], "sql": SQL1, "source": SOURCE, + "mask_columns": ["phone"]}}) + table = prepare(doc, cat, loader=store.get).excerpts["t:orders"] + assert "phone" not in field_line(table).lower() + + +def test_a_truncated_snapshot_is_called_out(): + ex = excerpts("活跃用户数由 [[t:orders]] 去重计数得出。", snapshot=schema_snapshot(truncated=True)) + table = ex["t:orders"] + assert "表结构快照不完整" in table and "id INTEGER" in field_line(table) + + +def test_only_the_sealed_loader_feeds_the_field_list(): + """schema 快照不在封存范围里(loader 取不到):一个字段都不给,照实说没有字段清单。""" + store = snaps() + ex = excerpts("活跃用户数由 [[t:orders]] 去重计数得出。", loader=lambda a: None if a == SCHEMA_ART else store.get(a)) + table = ex["t:orders"] + assert "INTEGER" not in table and "member_no TEXT" not in table + assert "没有" in field_line(table) + + +def test_new_excerpt_content_counts_toward_the_estimate(): + cat = entity_catalog() + doc = compose_doc("活跃用户数由 [[t:orders]] 去重计数得出。", cat) + store = snaps() + full = prepare(doc, cat, loader=store.get) + bare = prepare(doc, cat, loader=lambda a: None if a == SCHEMA_ART else store.get(a)) + assert full.cost(PRICED, full.cands) > bare.cost(PRICED, bare.cands) + + +# -------------------------------------------------------------------------- +# 补全证据摘录:字段 +# -------------------------------------------------------------------------- + + +def test_a_column_excerpt_carries_its_type_and_table(): + column = excerpts("活跃用户数按 [[c:orders.member_no]] 去重计数得出。")["c:orders.member_no"] + assert "类型:TEXT" in column and "所属的表:orders" in column + assert "COUNT(DISTINCT member_no)" in column, "出现过的查询 SQL 保留" + + +def test_a_result_column_takes_its_type_from_the_query_snapshot(): + """聚合的别名(gmv)不在表结构里:类型取查询快照记下的列类型。""" + cat = entity_catalog() + doc = compose_doc("汇总在 [[c:gmv]] 这一列。[[see:Q2]]", cat) + store = snaps(**{Q2_ART: {"columns": ["region", "gmv"], "rows": [["华东", 100.0]], "sql": SQL2, "source": SOURCE, + "column_types": {"region": "text", "gmv": "number"}}}) + column = prepare(doc, cat, loader=store.get).excerpts["c:gmv"] + assert "类型:number" in column + + +# -------------------------------------------------------------------------- +# 补全证据摘录:口径卡指标 +# -------------------------------------------------------------------------- + +LONG_SQL = "SELECT COUNT(DISTINCT member_no) AS active_users FROM orders WHERE " + " AND ".join( + f"flag_{i} = 1" for i in range(80)) +CARD = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2", "metrics": [ + {"id": "active", "name": "活跃用户数", "unit": "人", "value": 321, "decimals": 0, "format": "plain", "status": "ok", + "expression": "cell(nodes.fetch, 0, 'active_users')", "rendered": "321人", + "inputs": [{"path": "cell(nodes.fetch, 0, 'active_users')", "value": 321, "node_id": None, "via": "tool_cell", + "artifact": Q1_ART, "locator": {"row": 0, "column": "active_users"}, "status": "ok"}, + {"path": "vars.target", "value": 300, "node_id": "plan", "via": "transform", "status": "ok"}]}]} + + +def metric_catalog(): + ledger = [{"kind": "metric_set", "node_id": "caliber", "exec": 1, "artifact": CARD_ART, "caliber": "周报口径", + "version": "v2", "metrics": ["active"]}, query_entry(Q1_ART, ["active_users"], ["orders"])] + return build_catalog(nodes={"caliber": {**CARD, "artifact": CARD_ART}}, ledger=ledger) + + +def test_a_metric_excerpt_names_the_query_and_cell_of_its_input_and_that_sql(): + cat = metric_catalog() + doc = compose_doc("活跃用户数 [[m:active]],按会员号去重计数。[[see:m:active]]", cat) + store = {**snaps(), CARD_ART: CARD} + metric = prepare(doc, cat, loader=store.get).excerpts["m:active"] + assert "原式:cell(nodes.fetch, 0, 'active_users')" in metric + [where] = [line for line in metric.splitlines() if "active_users" in line and "Q1" in line and "SQL" not in line] + assert "r0" in where, where + [sql] = [line for line in metric.splitlines() if line.startswith("查询 Q1 的 SQL")] + assert "COUNT(DISTINCT member_no)" in sql + assert any("vars.target" in line and "未追溯到查询" in line for line in metric.splitlines()) + + +def test_metric_input_sql_is_capped_and_needs_the_sealed_loader(): + cat = metric_catalog() + doc = compose_doc("活跃用户数 [[m:active]],按会员号去重计数。[[see:m:active]]", cat) + long = {**snaps(**{Q1_ART: {"columns": ["active_users"], "rows": [[321]], "sql": LONG_SQL, "source": SOURCE}}), + CARD_ART: CARD} + metric = prepare(doc, cat, loader=long.get).excerpts["m:active"] + [sql] = [line for line in metric.splitlines() if line.startswith("查询 Q1 的 SQL")] + assert LONG_SQL[:SQL_CHARS] in sql and LONG_SQL[:SQL_CHARS + 10] not in sql and sql.endswith("…") + + sealed_only = {CARD_ART: CARD} # 查询快照不在封存范围里 + metric = prepare(doc, cat, loader=sealed_only.get).excerpts["m:active"] + assert "SELECT" not in metric and "Q1" in metric, "位置照给,SQL 不给" + + +def test_a_masked_input_column_is_not_named(): + cat = metric_catalog() + doc = compose_doc("活跃用户数 [[m:active]],按会员号去重计数。[[see:m:active]]", cat) + store = {**snaps(), CARD_ART: CARD} + metric = prepare(doc, cat, loader=store.get, masked={SOURCE: ["active_users"]}).excerpts["m:active"] + [where] = [line for line in metric.splitlines() if line.startswith("输入 cell(")] + assert "r0" in where and "active_users" not in where.split(":", 1)[1] + + +# -------------------------------------------------------------------------- +# 遮罩:表、字段的数据源也要查 +# -------------------------------------------------------------------------- + + +async def test_source_masks_cover_the_sources_of_tables(): + name = "jr_mask_src" + cat = build_catalog(nodes={}, ledger=[{**schema_entry(schema_snapshot()), "source": name}]) + assert cat and all(e["kind"] in ("table", "column") for e in cat.values()) + async with SessionLocal() as session: + session.add(DataSource(name=name, kind="sqlite", database="unused.db", options={"mask_columns": "phone"})) + await session.commit() + try: + assert await source_masks(cat) == {name: ["phone"]} + finally: + async with SessionLocal() as session: + await session.execute(delete(DataSource).where(DataSource.name == name)) + await session.commit() + + +# -------------------------------------------------------------------------- +# 断点里的旧判定:规则换了就不复用 +# -------------------------------------------------------------------------- + + +def test_the_request_key_carries_the_rules_version(monkeypatch): + """报告节点按 request_key 认断点里缓存的判定:规则、取值换了(旧的 unsupported),旧判定不能原样复用。""" + cat = entity_catalog() + doc = compose_doc(RAW, cat) + request = prepare(doc, cat, loader=snaps().get) + before = judge_mod.request_key(request, SPEC, UNLIMITED) + assert before == judge_mod.request_key(request, SPEC, UNLIMITED), "同一份输入同一个指纹" + monkeypatch.setattr(judge_mod, "RULES_VERSION", judge_mod.RULES_VERSION + 1) + assert judge_mod.request_key(request, SPEC, UNLIMITED) != before + + +async def test_a_new_verdict_carries_the_rules_version(monkeypatch): + """新判的判定带上规则版本(和 request_key 里的同一个常量):按需裁判据此认出早于当前规则的旧判定。""" + cat = entity_catalog() + doc = compose_doc("各区销售额按 [[c:orders.region]] 汇总。[[see:Q2]]", cat) + Judge(monkeypatch, lambda units: [{"unit": u, "verdict": "supported", "rationale": "有依据", "used": []} + for u in units]) + outcome = await judge_doc(doc, cat, spec=SPEC, budget=UNLIMITED, loader=snaps().get) + [v] = outcome["verdicts"].values() + assert v["rule_version"] == judge_mod.RULES_VERSION + + +def test_which_verdicts_are_older_than_the_current_rules(): + current = judge_mod.RULES_VERSION + assert judge_mod.outdated({"status": "supported"}), "没有规则版本:改造前判的" + assert judge_mod.outdated({"status": "unsupported", "rule_version": current}), "旧取值一律算旧" + assert judge_mod.outdated({"status": "partial", "rule_version": current - 1}) + assert not judge_mod.outdated({"status": "contradicted", "rule_version": current}) + assert not judge_mod.outdated({"status": "unjudged", "reason": "error"}), "没判成的谈不上新旧" diff --git a/backend/tests/test_judge_rework_grading.py b/backend/tests/test_judge_rework_grading.py new file mode 100644 index 00000000..2792765f --- /dev/null +++ b/backend/tests/test_judge_rework_grading.py @@ -0,0 +1,298 @@ +"""结论句裁判改造(JR)的判档:contradicted 沿用原来 unsupported 的处置,insufficient 按缺口处理。 + +- contradicted(证据相矛盾):按 on_unsupported 降档(degrade)或不予出具(withhold),和原来的 unsupported 一样 +- insufficient(证据不足):按缺口处理,最多降档——不会不予出具,也不会判完整出具 +- 不许比现在更松:受管正式运行里,原来会因「证据不支持」降档的句子,改判成 contradicted 或 insufficient + 都不能完整出具 +- 旧文档里的 unsupported 判档照旧当 contradicted 处理 +- 统计键(stats、counts、出具横幅的结论句计数)都有 contradicted、insufficient,unsupported 保留 +- 报告节点:改写一次把证据相矛盾、证据不足的句子都交回写作者;文档的裁判摘要记下写作模型 + +所有模型调用都是 mock 剧本,不碰真接口。 +""" +from __future__ import annotations + +import asyncio +import json +import re +import uuid + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage +from sqlalchemy import delete, select + +from app.core import artifact_store +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent, Setting, Workflow, WorkflowVersion +from app.engine import judge as judge_mod +from app.engine.evidence import compose_doc, iter_units +from app.engine.issuance import decide_tier +from app.engine.judge import JUDGE_ROLE, SPEND_KEY +from app.engine.nodes.io import _claims +from app.engine.runner import run_manager +from app.main import app +from app.providers.mock_model import MockChatModel + +JUDGE_MODEL = "claude-sonnet-5" +WRITER = "mock-fast" +METRICS = [{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"}] +CITED = "本周销售额 [[m:gmv]]。[[see:m:gmv]]增长主要来自新客首单。[[see:m:gmv]]下面看细节。" +SUPPORTED, CAUSAL = "本周销售额 45,678.5元。", "增长主要来自新客首单。" + + +# -------------------------------------------------------------------------- +# 出口判档(io._claims):直接喂文档 +# -------------------------------------------------------------------------- + + +def judged_doc(statuses: dict[str, dict], *, on_unsupported="degrade") -> dict: + """两句结论:SUPPORTED_TEXT、CAUSAL_TEXT;statuses 按句子给判定。counts 照判定数。""" + doc = compose_doc("本周销售额稳定增长。增长主要来自新客首单。", {}) + md = doc["markdown"] + counts = {k: 0 for k in ("supported", "partial", "unsupported", "not_a_claim", "unjudged")} + for _, unit in iter_units(doc): + text = md[unit["span"][0]:unit["span"][1]].strip() + verdict = {"status": "supported", "rationale": "有依据", "judge": JUDGE_MODEL, "post_seal": False, "used": [], + **statuses.get(text, {})} + unit["verdict"] = verdict + counts[verdict["status"]] = counts.get(verdict["status"], 0) + 1 + doc["judge"] = {"mode": "inline", "model": JUDGE_MODEL, "on_unsupported": on_unsupported, "counts": counts, + "unjudged": {}, "limits_hit": [], "complete": True, "gaps": []} + return doc + + +def grade(doc: dict, *, governed=False, contract=None) -> tuple[str, dict]: + out = {"gaps": [], "claims_policy": None, "uncited": None, "claims": None, "unsupported": None} + _claims(out, contract or {}, {"claims": "judge", "text": doc["markdown"]}, evidence_on=True, governed=governed, + uncited=[], doc=doc, title="write") + tier = decide_tier(missing_required=[], missing_expected=[], unmatched=[], strict=True, gaps=out["gaps"], + unsupported=out["unsupported"], uncited_claims=out["uncited"], + claims_policy=out["claims_policy"]) + return tier, out + + +TEXT2 = "增长主要来自新客首单。" + + +def test_all_supported_is_still_a_full_issuance(): + tier, out = grade(judged_doc({})) + assert tier == "formal" and out["gaps"] == [] + + +@pytest.mark.parametrize(("on_unsupported", "tier"), [("degrade", "degraded"), ("withhold", "withheld")]) +def test_contradicted_follows_on_unsupported(on_unsupported, tier): + doc = judged_doc({TEXT2: {"status": "contradicted", "rationale": "证据里新客占比下降"}}, + on_unsupported=on_unsupported) + got, out = grade(doc) + assert got == tier + claims = out["claims"] + [item] = claims["contradicted"] + assert item["text"] == TEXT2 and item["rationale"] == "证据里新客占比下降" + assert claims["counts"]["contradicted"] == 1 and claims["counts"]["insufficient"] == 0 + assert out["gaps"] == [], "判档来自判定,不来自缺口" + + +def test_insufficient_is_a_gap_and_never_withholds(): + doc = judged_doc({TEXT2: {"status": "insufficient", "rationale": "摘录里没有新客维度", "missing": "新客维度的查询"}}, + on_unsupported="withhold") + tier, out = grade(doc, governed=True, contract={"claims": {"policy": "judge", "on_unsupported": "withhold"}}) + assert tier == "degraded", "证据不足最多降档:不能不予出具,也不能完整出具" + [gap] = [g for g in out["gaps"] if "证据不足" in g] + assert gap.startswith("报告「write」") and "1 句" in gap and "新客维度的查询" in gap + [item] = out["claims"]["insufficient"] + assert item["text"] == TEXT2 and item["missing"] == "新客维度的查询" + assert out["claims"]["counts"]["insufficient"] == 1 and out["unsupported"] == [] + + +def test_an_old_unsupported_verdict_is_graded_as_contradicted(): + doc = judged_doc({TEXT2: {"status": "unsupported", "rationale": "证据不支持"}}, on_unsupported="withhold") + tier, out = grade(doc) + assert tier == "withheld" + assert out["claims"]["counts"]["unsupported"] == 1 + assert [i["text"] for i in out["claims"]["unsupported"]] == [TEXT2] + assert [i["text"] for i in out["unsupported"]] == [TEXT2] + + +def test_the_issuance_counts_carry_every_key(): + _, out = grade(judged_doc({})) + assert set(out["claims"]["counts"]) == set(judge_mod.COUNT_KEYS) + + +# -------------------------------------------------------------------------- +# 端到端:正式运行、受管正式运行 +# -------------------------------------------------------------------------- + + +@pytest.fixture +async def engine_up(): + await run_manager.setup() + async with SessionLocal() as session: + await session.execute(delete(Setting).where(Setting.key.in_([SPEND_KEY, "judge", "copilot"]))) + await session.commit() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(engine_up): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def weekly(*, report=None, contract=None) -> dict: + nodes = [ + node("start", "input", fields=[{"name": "week", "default": "2026-W37"}]), + node("fetch", "transform", mode="expression", expression="{'gmv': 45678.5}", assign_to="kpi"), + node("caliber", "metrics", caliber="周报口径", caliber_version="v2", metrics=METRICS), + node("write", "report", instructions="写周报", **{"claims": "judge", "judge": {"max_cost_usd": 0.05}, + **(report or {})}), + node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], + contract={"report_from": "write", "metrics_from": ["caliber"], "required": ["gmv"], "strict": True, + **(contract or {})}), + ] + ids = [n["id"] for n in nodes] + return {"nodes": nodes, "edges": [{"source": a, "target": b} for a, b in zip(ids, ids[1:])]} + + +class Models: + """写作者依次回 writer 里的几段;裁判按句子里的字给判定:verdicts = {句中片段: (判定, missing)}。""" + + def __init__(self, monkeypatch, writer=(CITED,), verdicts=None): + self.writes: list[list] = [] + self.judges: list[str] = [] + verdicts = verdicts or {} + + def decide(model, messages): + if JUDGE_ROLE in str(messages[0].content): + request = str(messages[-1].content) + self.judges.append(request) + items = [] + for uid, line in re.findall(r"^\[(u\d+)\]([^\n]*)$", request, re.M): + status, missing = next((v for frag, v in verdicts.items() if frag in line), ("supported", "")) + items.append({"unit": uid, "verdict": status, "rationale": f"判的是:{line.strip()[:12]}", + "missing": missing, "used": []}) + return AIMessage(content=json.dumps({"items": items}, ensure_ascii=False)) + self.writes.append(list(messages)) + return AIMessage(content=writer[min(len(self.writes), len(writer)) - 1]) + + monkeypatch.setattr(MockChatModel, "_decide", decide) + + async def judge_model_of(session, spec): + return MockChatModel(model_name=JUDGE_MODEL), JUDGE_MODEL + + monkeypatch.setattr(judge_mod, "get_chat_model", judge_model_of) + + +async def wait(run_id: str) -> Run: + 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") and row.manifest_seq is not None: + return row + raise AssertionError(f"run {run_id} 没跑完:{row.status}") + + +async def run(graph, *, run_class="formal") -> Run: + started = await run_manager.start(graph=graph, input_payload={}, run_class=run_class) + row = await wait(started.id) + assert row.status == "succeeded", row.error + return row + + +async def checked_of(run_id: str) -> tuple[dict, dict]: + async with SessionLocal() as session: + [event] = (await session.execute(select(RunEvent).where( + RunEvent.run_id == run_id, RunEvent.type == "report.checked"))).scalars() + return event.data, artifact_store.load(event.data["doc_artifact"]) + + +async def issuance_event(run_id: str) -> dict: + async with SessionLocal() as session: + [event] = (await session.execute(select(RunEvent).where( + RunEvent.run_id == run_id, RunEvent.type == "issuance"))).scalars() + return event.data + + +async def test_a_formal_run_withholds_a_contradicted_claim(engine_up, monkeypatch): + Models(monkeypatch, verdicts={"新客": ("contradicted", "")}) + row = await run(weekly(report={"judge": {"max_cost_usd": 0.05, "on_unsupported": "withhold"}})) + issuance = row.output["_issuance"] + assert issuance["tier"] == "withheld" + assert [i["text"] for i in issuance["claims"]["contradicted"]] == [CAUSAL] + event = await issuance_event(row.id) + assert event["claims"]["counts"] == {"supported": 1, "partial": 0, "contradicted": 1, "insufficient": 0, + "not_a_claim": 0, "unjudged": 0, "unsupported": 0} + + +async def test_a_formal_run_only_degrades_an_insufficient_claim(engine_up, monkeypatch): + Models(monkeypatch, verdicts={"新客": ("insufficient", "新客维度的数据")}) + row = await run(weekly(report={"judge": {"max_cost_usd": 0.05, "on_unsupported": "withhold"}})) + issuance = row.output["_issuance"] + assert issuance["tier"] == "degraded", "证据不足:最多降档,不因 withhold 不予出具" + assert any("证据不足" in g and "新客维度的数据" in g for g in issuance["gaps"]), issuance["gaps"] + [item] = issuance["claims"]["insufficient"] + assert item["text"] == CAUSAL and item["missing"] == "新客维度的数据" + assert issuance["claims"]["counts"]["insufficient"] == 1 + + checked, doc = await checked_of(row.id) + assert doc["stats"]["insufficient"] == 1 and doc["stats"]["contradicted"] == 0 and doc["stats"]["unsupported"] == 0 + assert checked["stats"]["insufficient"] == 1 + assert doc["judge"]["writer_model"] == WRITER and checked["judge"]["writer_model"] == WRITER + [unit] = [u for _, u in iter_units(doc) if u.get("verdict", {}).get("status") == "insufficient"] + assert unit["verdict"]["missing"] == "新客维度的数据" + + +async def governed(client, graph) -> Run: + async with SessionLocal() as session: + wf = Workflow(name=f"jr-{uuid.uuid4().hex[:6]}", graph=graph, status="governed", published_version=1) + session.add(wf) + await session.flush() + session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph)) + await session.commit() + wf_id = wf.id + res = await client.post("/api/runs", json={"workflow_id": wf_id, "run_class": "formal"}) + assert res.status_code == 201, res.text + row = await wait(res.json()["id"]) + assert row.status == "succeeded", row.error + return row + + +@pytest.mark.parametrize("status", ["contradicted", "insufficient"]) +async def test_a_governed_formal_run_is_never_fully_issued_on_a_split_verdict(client, monkeypatch, status): + """不许比现在更松:原来判「证据不支持」要降档的句子,改判成哪一档都不能完整出具。""" + Models(monkeypatch, verdicts={"新客": (status, "新客维度的数据" if status == "insufficient" else "")}) + graph = weekly(report={"numbers": "strict", "on_violation": "fail", "judge": {"max_cost_usd": None}}) + row = await governed(client, graph) + assert row.output["_issuance"]["tier"] == "degraded" + + +async def test_rewrite_once_hands_back_contradicted_and_insufficient_sentences(engine_up, monkeypatch): + draft = ("本周销售额 [[m:gmv]]。[[see:m:gmv]]增长主要来自新客首单。[[see:m:gmv]]" + "华东贡献了大部分增长。[[see:m:gmv]]下面看细节。") + rewritten = "本周销售额 [[m:gmv]]。[[see:m:gmv]]新客、华东的贡献要等分维度的数据再看。下面看细节。" + models = Models(monkeypatch, writer=(draft, rewritten), + verdicts={"新客首单": ("contradicted", ""), "华东贡献": ("insufficient", "分区域的查询"), + "要等分维度": ("not_a_claim", "")}) + row = await run(weekly(report={"judge": {"max_cost_usd": 0.05, "rewrite_once": True}})) + assert len(models.writes) == 2 + request = str(models.writes[1][-1].content) + assert "增长主要来自新客首单。" in request and "华东贡献了大部分增长。" in request + assert "分区域的查询" in request, "证据不足的句子连同缺的是什么一起交回" + assert SUPPORTED not in request + checked, _ = await checked_of(row.id) + assert checked["judge"]["rewrite"]["applied"] is True and len(checked["judge"]["rewrite"]["sentences"]) == 2 + + +async def test_an_explore_report_records_the_writer_model_too(engine_up, monkeypatch): + models = Models(monkeypatch) + row = await run(weekly(), run_class="exploratory") + assert models.judges == [] + checked, doc = await checked_of(row.id) + assert doc["judge"]["mode"] == "on_demand" and doc["judge"]["writer_model"] == WRITER + assert set(judge_mod.COUNT_KEYS) <= set(doc["stats"]) diff --git a/backend/tests/test_report_judge.py b/backend/tests/test_report_judge.py index aa7168f3..e6589367 100644 --- a/backend/tests/test_report_judge.py +++ b/backend/tests/test_report_judge.py @@ -26,7 +26,7 @@ from app.engine import judge as judge_mod from app.engine import replay from app.engine import runner as runner_mod from app.engine.evidence import CLAIMS_RULE, iter_units -from app.engine.judge import JUDGE_ROLE, JUDGE_RULE, SPEND_KEY, SUMMARY_KEYS, daily_spend +from app.engine.judge import COUNT_KEYS, JUDGE_ROLE, JUDGE_RULE, SPEND_KEY, SUMMARY_KEYS, daily_spend from app.engine.nodes import report as report_mod from app.engine.runner import run_manager, verify_manifest from app.providers.mock_model import MockChatModel @@ -167,7 +167,7 @@ async def judge_ends(run_id: str) -> list[dict]: async def test_a_formal_run_judges_inside_the_node_and_seals_the_verdicts(monkeypatch): - models = Models(monkeypatch, verdicts={"新客": "unsupported"}) + models = Models(monkeypatch, verdicts={"新客": "contradicted"}) row = await finish(weekly(claims="judge", judge={"on_unsupported": "withhold"})) assert row.status == "succeeded", row.error assert len(models.writes) == 1 and len(models.judges) == 1 @@ -176,18 +176,19 @@ async def test_a_formal_run_judges_inside_the_node_and_seals_the_verdicts(monkey got = verdicts(doc) assert got[CITED]["status"] == "supported" and got[CITED]["judge"] == PRICED assert got[CITED]["post_seal"] is False and got[CITED]["rationale"] - assert got[CAUSAL]["status"] == "unsupported" + assert got[CAUSAL]["status"] == "contradicted" assert got[LINK] is None, "连接性的话不送裁判" assert CITED in models.judges[0] and CAUSAL in models.judges[0] and LINK not in models.judges[0] summary = checked["judge"] assert tuple(summary) == SUMMARY_KEYS, "B 段照着这份键读:不能悄悄少一个、多一个" assert summary["mode"] == "inline" and summary["model"] == PRICED and summary["on_unsupported"] == "withhold" - assert summary["counts"] == {"supported": 1, "partial": 0, "unsupported": 1, "not_a_claim": 0, "unjudged": 0} + assert summary["counts"] == {"supported": 1, "partial": 0, "contradicted": 1, "insufficient": 0, "not_a_claim": 0, + "unjudged": 0, "unsupported": 0} assert summary["complete"] is True and summary["gaps"] == [] assert checked["claims"] == output["claims"] == "judge" and output["judge"] == summary assert doc["judge"]["mode"] == "inline" and doc["judge"]["counts"] == summary["counts"] - assert {k: doc["stats"][k] for k in ("supported", "partial", "unsupported", "not_a_claim", "unjudged")} \ + assert {k: doc["stats"][k] for k in COUNT_KEYS} \ == summary["counts"] [end] = await judge_ends(row.id) @@ -277,7 +278,7 @@ REWRITTEN = "本周销售额 [[m:gmv]]。[[see:m:gmv]]新客首单的贡献要 async def test_rewrite_once_hands_back_only_the_unsupported_sentences(monkeypatch): models = Models(monkeypatch, writer=(TEXT, REWRITTEN), - verdicts={"主要来自新客": "unsupported", "要等新客维度": "not_a_claim"}) + verdicts={"主要来自新客": "contradicted", "要等新客维度": "not_a_claim"}) row = await finish(weekly(claims="judge", judge={"rewrite_once": True})) assert row.status == "succeeded", row.error assert len(models.writes) == 2 and len(models.judges) == 2 @@ -305,23 +306,23 @@ async def test_rewrite_once_hands_back_only_the_unsupported_sentences(monkeypatc async def test_rewrite_once_is_off_by_default(monkeypatch): - models = Models(monkeypatch, writer=(TEXT, REWRITTEN), verdicts={"主要来自新客": "unsupported"}) + models = Models(monkeypatch, writer=(TEXT, REWRITTEN), verdicts={"主要来自新客": "contradicted"}) row = await finish(weekly(claims="judge")) assert row.status == "succeeded", row.error assert len(models.writes) == 1 and len(models.judges) == 1 checked, doc, _ = await report_of(row.id) - assert verdicts(doc)[CAUSAL]["status"] == "unsupported" and checked["judge"].get("rewrite") is None + assert verdicts(doc)[CAUSAL]["status"] == "contradicted" and checked["judge"].get("rewrite") is None async def test_a_rewrite_that_breaks_the_numbers_is_not_used(monkeypatch): models = Models(monkeypatch, writer=(TEXT, "本周销售额 [[m:gmv]]。[[see:m:gmv]]新客贡献了 45678 元。下面看细节。"), - verdicts={"主要来自新客": "unsupported"}) + verdicts={"主要来自新客": "contradicted"}) row = await finish(weekly(claims="judge", judge={"rewrite_once": True})) assert row.status == "succeeded", row.error assert len(models.writes) == 2 and len(models.judges) == 1, "改坏了的那一稿不再花钱判" checked, doc, output = await report_of(row.id) assert CAUSAL in doc["markdown"] and "45678" not in doc["markdown"] - assert verdicts(doc)[CAUSAL]["status"] == "unsupported" + assert verdicts(doc)[CAUSAL]["status"] == "contradicted" rewrite = checked["judge"]["rewrite"] assert rewrite["applied"] is False and "45678" in rewrite["reason"] # 封存的是原稿:units 就是原稿(也就是封存文档)里的编号 @@ -346,7 +347,7 @@ async def test_a_rewrite_that_makes_up_a_name_is_not_used(monkeypatch): with_schema(monkeypatch) models = Models(monkeypatch, writer=(TEXT, "本周销售额 [[m:gmv]]。[[see:m:gmv]]新客首单的贡献要看 `refund_detail` 表。" "下面看细节。"), - verdicts={"主要来自新客": "unsupported"}) + verdicts={"主要来自新客": "contradicted"}) row = await finish(weekly(claims="judge", judge={"rewrite_once": True})) assert row.status == "succeeded", row.error assert len(models.writes) == 2 and len(models.judges) == 1 @@ -362,7 +363,7 @@ async def test_a_rewrite_that_trades_one_violation_for_another_is_not_used(monke """去掉一处违规、又冒出另一处:总数没变,可冒出来的是原稿没有的,一样不采用。""" draft = "本周销售额 [[m:gmv]]。[[see:m:gmv]]增长主要来自新客首单,共 1200 单。下面看细节。" swapped = "本周销售额 [[m:gmv]]。[[see:m:gmv]]新客首单约占 3500 单。下面看细节。" - models = Models(monkeypatch, writer=(draft, draft, swapped), verdicts={"主要来自新客": "unsupported"}) + models = Models(monkeypatch, writer=(draft, draft, swapped), verdicts={"主要来自新客": "contradicted"}) row = await finish(weekly(claims="judge", on_violation="flag", max_repairs=1, judge={"rewrite_once": True})) assert row.status == "succeeded", row.error assert len(models.writes) == 3 and len(models.judges) == 1 @@ -385,7 +386,7 @@ def test_new_violations_are_counted_one_by_one(): async def test_a_second_round_that_hits_a_limit_names_the_configured_limit(monkeypatch): """第二轮用的是第一轮剩下的钱,可说给人看的上限是节点上配的那个数,不是剩下的零头。""" - Models(monkeypatch, writer=(TEXT, REWRITTEN), verdicts={"主要来自新客": "unsupported"}) + Models(monkeypatch, writer=(TEXT, REWRITTEN), verdicts={"主要来自新客": "contradicted"}) # 每句估 1 分钱、回复不带用量(实际就按估算记):第一轮两句花掉 2 分,第二轮只剩半分,一句也装不下 monkeypatch.setattr(judge_mod.JudgeRequest, "cost", lambda self, model_id, units: 0.01 * len(units)) monkeypatch.setattr(judge_mod, "_usage_of", lambda message, model_id: {"input_tokens": 0, "output_tokens": 0}) diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx index fb54f3fc..bf5140e5 100644 --- a/frontend/src/canvas/Inspector.tsx +++ b/frontend/src/canvas/Inspector.tsx @@ -12,7 +12,7 @@ import { datasourceTools, modelOptions, providerOfModel, useCatalog, useDatasour import { api } from '../api/client' import { IconButton, JsonInput, Modal, isComposing, useRadioGroup } from '../components/ui' import { formatShortcut } from '../lib/keys' -import { JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_LABEL, JUDGE_SETTING_TEXT, SUBGRAPH_UPGRADE_HELP, judgeByModelText, upgradeNewerText } from '../lib/terms' +import { JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_HINT, JUDGE_ON_UNSUPPORTED_LABEL, JUDGE_SETTING_TEXT, SUBGRAPH_UPGRADE_HELP, judgeByModelText, upgradeNewerText } from '../lib/terms' import { TemplateText } from './TemplateText' import type { NodeType, ValidationIssue, WorkflowVersion } from '../types' @@ -624,7 +624,8 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ const JUDGE_SUB_KEYS = new Set(['provider', 'model', 'max_claims', 'max_cost_usd', 'timeout_s', 'rewrite_once', 'on_unsupported']) /** - * 结论句裁判(claims: judge)的子配置:裁判模型、三项上限(各自可以设成不限)、改写一次、证据不支持时怎么判档。 + * 结论句裁判(claims: judge)的子配置:裁判模型、三项上限(各自可以设成不限)、改写一次、证据相矛盾时怎么判档 + * (键名仍是 on_unsupported;证据不足的句子最多降档)。 * * 上限有三种写法,得分得清:没写(跟随设置里「证据裁判」的默认值)、写数、写 null(不限)。勾「不限」写 null, * 清空数字框回到没写。全都没写时整个 judge 去掉,不留一个空对象。受管级别发布要求写了 max_cost_usd(数或 null) @@ -705,7 +706,7 @@ function JudgeConfig({ id, value, config, issues, onChange }: { {JUDGE_FIELD_LABEL.rewrite_once} - 默认关闭。开启后,裁判判定为证据不支持的句子会连同理由交回撰写模型,仅改写这些句子并重新判断一次,最多一轮。 + 默认关闭。开启后,裁判判定为证据相矛盾或证据不足的句子会连同理由交回撰写模型,仅改写这些句子并重新判断一次,最多一轮。 会额外产生一次撰写和裁判的费用;改写稿如出现原稿没有的问题,则不予采用 @@ -724,7 +725,7 @@ function JudgeConfig({ id, value, config, issues, onChange }: { )} -
{JUDGE_SETTING_TEXT.differ}
+ {judgeNotSet ? ( + // 接入和模型都没填:裁判会用助手的模型(往往就是写报告的那个)。醒目地说,并给出建议; + // 填了任何一项就换回平常那句建议 +{JUDGE_SETTING_TEXT.differ}
+ )}{JUDGE_TEXT.noCites}
} + {basis &&- {meta?.glyph && {meta.glyph}} - {meta ? meta.label : JUDGE_TEXT.notClaim} +
+ {meta?.glyph && {meta.glyph}} + {meta ? meta.label : JUDGE_TEXT.notClaim} +
+ {outdated && ( + + {JUDGE_TEXT.outdated} + + )} ++ {JUDGE_TEXT.missingLead}{missing}
)} + {(flags.sameModel || flags.unpriced) && ( + // 判定照常给出,但读的人要知道它的分量:左侧一道提醒色的竖线加图标,字用正文色——醒目,不刺眼 +
+
+
{resMessage} @@ -588,8 +643,9 @@ function JudgePart({ doc, unit, block, runId, runClass, onDemand, pending }: {
{JUDGE_TEXT.notJudgedBy(verdict.judge)}
)} {verdict?.rationale && !shownLimit && ( - // 理由是模型写的话:斜体,和系统给的说明分开 -{verdict.rationale}
+ // 理由是模型写的话:斜体,和系统给的说明分开。最长 120 字、可能夹着 SQL 片段和长的字段名:照原样折行, + // 不截断,长串也在栏内断开 +{verdict.rationale}
)} {shownLimit && ( :{step.comment}
} {owners.length > 1 &&{EVIDENCE_TEXT.ownerTables(owners)}
} {step?.types && !step.type && Object.keys(step.types).length > 0 && ( @@ -769,6 +838,125 @@ function EntityPart({ step, entry, cite, seal, pending, failed, catalog }: { ) } +/** + * 表的字段清单:证据接口给的 fields(封存范围内的表结构快照,最多 60 个,遮罩的列不列入),多出来的写「另有 N 个」, + * 快照只存了部分表时注明(partial,实体步骤那一节另有这句时不重复)。长名字、长类型都在栏内折行,不截断 + */ +function FieldList({ fields, more, partial, masked, label }: { + fields: NonNullable{EVIDENCE_TEXT.fieldsMore(extra)}
} + {masked &&{EVIDENCE_TEXT.fieldsMasked}
} + {partial && ( +{EVIDENCE_TEXT.snapshotPartial}
+ )} +{JUDGE_TEXT.basisNoRun}
+ const stepOf = (seg?: string) => { + const slot = seg ? segments[segmentKey(runId, seg, report)] : undefined + const step = slot?.status === 'ok' ? (slot.data?.chain ?? []).find((c) => c?.step === 'entity') : undefined + return { step, pending: !!seg && (!slot || slot.status === 'loading') } + } + return ( ++ {JUDGE_TEXT.basisFields(t.name)}:{pending ? JUDGE_TEXT.basisPending : JUDGE_TEXT.fieldsMissing} +
+ )} +
+ {sql}
+
+ ) : (
+ {artifacts[i] && (!slot || slot.status === 'loading') ? JUDGE_TEXT.basisPending : JUDGE_TEXT.sqlMissing}
+ )} +{JUDGE_TEXT.basisMoreQueries(basis.more)}
} +