diff --git a/backend/app/engine/issuance.py b/backend/app/engine/issuance.py
index c357cd07..b71a6687 100644
--- a/backend/app/engine/issuance.py
+++ b/backend/app/engine/issuance.py
@@ -466,6 +466,12 @@ def incomplete_gaps(metrics: list[dict[str, Any]]) -> list[str]:
def sql_check_gaps(metrics: list[dict[str, Any]], cited: dict[str, list[str]] | None = None, *,
label: Callable[[str, str | None], str | None] | None = None) -> list[str]:
+ """sql_check_findings 的那几句缺口(见下)。"""
+ return [f["text"] for f in sql_check_findings(metrics, cited, label=label)]
+
+
+def sql_check_findings(metrics: list[dict[str, Any]], cited: dict[str, list[str]] | None = None, *,
+ label: Callable[[str, str | None], str | None] | None = None) -> list[dict[str, Any]]:
"""没通过 SQL 检查(error 级:一对多关联后重复计算、存量跨期加总)的查询,每条一句缺口。
两处来历:契约收来的指标(口径卡标了 sql_check_failed,sql_check_sources 记着取数的查询快照),以及报告直接
@@ -475,9 +481,12 @@ def sql_check_gaps(metrics: list[dict[str, Any]], cited: dict[str, list[str]] |
按出问题的那条查询归并(合并查询顺着输入找到源查询):同一条查询算出三个指标、报告又引用了它的格,也只记一句,
句子里列出受影响的引用。label(快照 id, 节点 id) 给出「查询「取数」(Q1)」这样的称呼,给不出就写「所依据的查询」。
指标标了 sql_check_failed、快照却读不出来(被清理、被改过)的,退回口径卡记下的那句:缺口不能因为读不到快照就消失。
+
+ 每条一个 dict:text(缺口那句)、query(称呼)、artifact、node_id、problems、refs。出具声明里另存一份
+ sql_checks:报告页据此在显眼处说「因为 SQL 检查没通过而降档」,不用从缺口的中文里认。
"""
groups: dict[str, dict[str, Any]] = {}
- fallback: list[str] = []
+ fallback: list[dict[str, Any]] = []
def add(artifact: Any, ref: str) -> bool:
found = query_problems(artifact) if isinstance(artifact, str) and artifact else []
@@ -495,7 +504,8 @@ def sql_check_gaps(metrics: list[dict[str, Any]], cited: dict[str, list[str]] |
name = f"指标「{m.get('name') or m.get('id')}」"
hits = [add(src.get("artifact"), name) for src in m.get("sql_check_sources") or [] if isinstance(src, dict)]
if not any(hits):
- fallback.append(f"{name}{m.get('sql_check_reason') or '所依据的查询未通过 SQL 检查'}")
+ fallback.append({"text": f"{name}{m.get('sql_check_reason') or '所依据的查询未通过 SQL 检查'}",
+ "query": None, "artifact": None, "node_id": None, "problems": [], "refs": [name]})
for artifact, refs in (cited or {}).items():
for ref in refs:
add(artifact, ref)
@@ -503,10 +513,13 @@ def sql_check_gaps(metrics: list[dict[str, Any]], cited: dict[str, list[str]] |
out = []
for artifact, group in groups.items():
problem = group["problem"]
- who = (label(artifact, problem.node_id) if label else None) or "所依据的查询"
+ named = label(artifact, problem.node_id) if label else None
+ who = named or "所依据的查询"
refs = group["refs"]
listed = "、".join(refs[:_SQL_REFS]) + (f"等 {len(refs)} 处" if len(refs) > _SQL_REFS else "")
- out.append(f"{who}{problem_clause(problem.problems)};受影响的引用:{listed}")
+ out.append({"text": f"{who}{problem_clause(problem.problems)};受影响的引用:{listed}", "query": named,
+ "artifact": artifact, "node_id": problem.node_id, "problems": problem.problems,
+ "refs": refs[:_SQL_REFS * 2]})
return out + fallback
diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py
index c1be3d45..a7de8542 100644
--- a/backend/app/engine/nodes/io.py
+++ b/backend/app/engine/nodes/io.py
@@ -147,7 +147,7 @@ def _apply_contract(
) -> dict[str, Any]:
from datetime import datetime, timezone
- from app.engine.issuance import decide_tier, incomplete_gaps, sql_check_gaps, trace_numbers
+ from app.engine.issuance import decide_tier, incomplete_gaps, sql_check_findings, trace_numbers
nodes = state.get("nodes") or {}
# 写了 report_from 就是引用模式:按报告文档逐段复核,不再按数值回指叙述
@@ -236,7 +236,17 @@ def _apply_contract(
return f"查询「{title}」({alias})"
return f"查询「{title}」" if title else (f"查询 {alias}" if alias else "")
- gaps[sql_at:sql_at] = sql_check_gaps(metrics, cited.get("queries"), label=query_label)
+ findings = sql_check_findings(metrics, cited.get("queries"), label=query_label)
+ gaps[sql_at:sql_at] = [f["text"] for f in findings]
+ # 出具声明另存一份结构化的:报告页在显眼处说「因为 SQL 检查没通过而降档」,不从缺口的中文里认。没有就不加这个键
+ from app.engine.sql_problems import node_of
+
+ for f in findings:
+ if not f.get("node_id") and f.get("artifact"):
+ f["node_id"] = node_of(f["artifact"], state.get("evidence"))
+ # gap 是 gaps 里对应的那一句:界面单列这几条时,据它不在「其余缺口」里重复
+ sql_checks = [{**{k: f[k] for k in ("query", "node_id", "problems", "refs") if f.get(k)}, "gap": f["text"]}
+ for f in findings]
# 声明了 metrics_from 却一个指标都没收到,同样是"没查成"
if sources and not metrics:
@@ -265,7 +275,7 @@ def _apply_contract(
return _declare_citations(tier, cited, calibers=calibers, metrics=metrics, gaps=gaps,
missing_required=missing_required, missing_expected=missing_expected,
report_from=str(report_from), declared_at=datetime.now(timezone.utc),
- ctx=ctx)
+ ctx=ctx, sql_checks=sql_checks)
# 逐个数字的出处。以前只有一个计数,界面标不出「这个数来自哪个指标」
matched = [
@@ -284,6 +294,7 @@ def _apply_contract(
# 校验没跑全的原因照实印出来——读的人要能分辨"查过都对"和"根本没查"
"gaps": gaps,
"declared_at": datetime.now(timezone.utc).isoformat(),
+ **({"sql_checks": sql_checks} if sql_checks else {}),
}
ctx.emit(
EventType.ISSUANCE,
@@ -298,6 +309,7 @@ def _apply_contract(
metrics_checked=len(metrics),
matched_numbers=len(trace.matched) if trace else 0,
matched=matched,
+ **({"sql_checks": sql_checks} if sql_checks else {}),
)
return issuance
@@ -584,7 +596,7 @@ def _cited_queries(doc: dict[str, Any], catalog: dict[str, Any]) -> tuple[dict[s
def _declare_citations(
tier: str, cited: dict[str, Any], *, calibers: list[dict[str, str]], metrics: list[dict[str, Any]],
gaps: list[str], missing_required: list[str], missing_expected: list[str], report_from: str,
- declared_at: Any, ctx: NodeContext,
+ declared_at: Any, ctx: NodeContext, sql_checks: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""引用模式的 _issuance。旧字段一个不少(老前端照常显示),另加 mode、report、unresolved。"""
report = {"node_id": report_from, "doc_artifact": cited["doc_artifact"]}
@@ -606,6 +618,8 @@ def _declare_citations(
# 结论句策略和可疑实体:只在用上时才有这两个键,升级前的运行和没用上的契约形状不变
**({"claims": cited["claims"]} if cited.get("claims") else {}),
**({"entities": cited["entities"]} if cited.get("entities") else {}),
+ # 没通过 SQL 检查的查询(结构化):报告页据此在显眼处说明降档原因。没有就不加这个键
+ **({"sql_checks": sql_checks} if sql_checks else {}),
}
entities = cited.get("entities")
ctx.emit(
@@ -619,6 +633,7 @@ def _declare_citations(
unresolved=len(cited["unresolved"]),
calibers=calibers,
gaps=gaps,
+ **({"sql_checks": sql_checks} if sql_checks else {}),
metrics_checked=len(metrics),
matched_numbers=len(cited["matched"]),
matched=cited["matched"][:50],
diff --git a/backend/tests/test_report_sqlcheck_cells.py b/backend/tests/test_report_sqlcheck_cells.py
index 1c9ffdf3..d039beed 100644
--- a/backend/tests/test_report_sqlcheck_cells.py
+++ b/backend/tests/test_report_sqlcheck_cells.py
@@ -97,6 +97,12 @@ async def test_one_failing_query_is_one_gap_and_one_timeline_row(scenic_source,
[gap] = [g for g in issuance["gaps"] if "SQL 检查" in g]
assert gap.count(PROBLEM) == 1
assert "指标「订单金额」" in gap and "指标「订单金额(整形后)」" in gap and "Q1 第 1 行「gmv」" in gap
+ # 出具声明和事件另带结构化的 sql_checks:报告页在显眼处说明降档原因,不从缺口的中文里认(B5)
+ [found] = issuance["sql_checks"]
+ assert found == {"query": "查询「fetch」(Q1)", "node_id": "fetch", "problems": [PROBLEM],
+ "refs": ["指标「订单金额」", "指标「订单金额(整形后)」", "Q1 第 1 行「gmv」"], "gap": gap}, found
+ [declared] = [e.data for e in await events(row.id, "issuance")]
+ assert declared["sql_checks"] == issuance["sql_checks"]
# 运行时间线:同一条查询的问题一行,列出受影响的指标;带着结构化字段,界面不用解析中文
[log] = [e.data for e in await events(row.id, "log", "card") if e.data.get("code") == "metric_sql_check"]
diff --git a/frontend/src/dev/preview.tsx b/frontend/src/dev/preview.tsx
index a279c684..15d215d9 100644
--- a/frontend/src/dev/preview.tsx
+++ b/frontend/src/dev/preview.tsx
@@ -14,7 +14,7 @@ import mdSamples from '../run/__tests__/markdown-samples.json'
import evidenceFixture from '../run/__tests__/evidence-doc.json'
import evidenceJudge from '../run/__tests__/evidence-judge.json'
import {
- CALIBER_ONE_OUTPUT, CALIBERS_OUTPUT, COPILOT_CATALOG_PATCH, COPILOT_CONTEXT, COPILOT_CONTEXT_FALLBACK, COPILOT_STUCK, COPILOT_TOOLS_DROPPED, MIXED_OUTPUT, abandonedRun, cancelledRun, exhaustedTeam, longLoop,
+ CALIBER_ONE_OUTPUT, CALIBERS_OUTPUT, SQL_DEGRADED_OUTPUT, COPILOT_CATALOG_PATCH, COPILOT_CONTEXT, COPILOT_CONTEXT_FALLBACK, COPILOT_STUCK, COPILOT_TOOLS_DROPPED, MIXED_OUTPUT, abandonedRun, cancelledRun, exhaustedTeam, longLoop,
markupRun, mergeRun, mixedRun, parallelQueryRun, pipelineRun, repairRun, teamRun, timeoutRun,
} from '../run/__tests__/synthetic'
import '../index.css'
@@ -302,6 +302,11 @@ function synthetic(name: string): StreamTurn[] {
steps: decodeRun(ev, { status: 'succeeded' }), output: MIXED_OUTPUT, runId: 'syn-issued',
runClass: 'exploratory', review: REVIEW_DEGRADED_TURN.review }]
}
+ case 'issued-sql':
+ // 数字都有出处,却因为查询没通过 SQL 检查而降档:横幅单列说明
+ return [{ id: name, question: '上周订单金额', phase: 'done', status: '已完成',
+ steps: decodeRun(parallelQueryRun(), { status: 'succeeded' }), runId: 'syn-issued-sql', runClass: 'formal',
+ output: SQL_DEGRADED_OUTPUT }]
case 'calibers':
case 'caliber-one': {
// 按口径卡清点回指的数字:两张卡各数各的;只有一张卡时直说都来自它
diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts
index 5d3fc031..14cd0067 100644
--- a/frontend/src/lib/terms.ts
+++ b/frontend/src/lib/terms.ts
@@ -2598,6 +2598,13 @@ export const RUN_SQL_CHECK_TEXT = {
/** 「打开设置」落到来源查询节点的 SQL 上 */
openSql: (label?: string) => (label ? `打开「${label}」的 SQL` : '打开来源查询的 SQL'),
metricNext: '指标照常计算,但结果不可靠,出具按缺口降档。请修改来源查询的 SQL 后重新运行;检查依据有误时,请在数据目录中修正对应项',
+ /** 出具横幅、报告页:SQL 检查导致的降档单列一块 */
+ issuanceTitle: (tier: string) => (tier === 'degraded' ? '降档原因:所依据的查询未通过 SQL 检查' : '所依据的查询未通过 SQL 检查'),
+ issuanceQuery: '所依据的查询',
+ issuanceAffected: (refs: string) => `受影响:${refs}`,
+ issuanceNext: '数字的出处都对得上,但算出这些数的查询本身有问题,结果不可靠。请修改来源查询的 SQL 后重新运行;检查依据有误时,请在数据目录中修正对应项',
+ /** 其余缺口的标题(SQL 检查的那几条单列之后) */
+ otherGaps: '校验未全部完成:',
}
export const COPILOT_CONTEXT_TEXT = {
diff --git a/frontend/src/pages/runs/EvidencePane.tsx b/frontend/src/pages/runs/EvidencePane.tsx
index 4bec5f5d..f04e3298 100644
--- a/frontend/src/pages/runs/EvidencePane.tsx
+++ b/frontend/src/pages/runs/EvidencePane.tsx
@@ -13,6 +13,7 @@ import {
} from '../../lib/evidence'
import { formatNumber } from '../../lib/format'
import { EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms'
+import { IssuanceBanner } from '../../run/AssistantStream'
import { EvidenceDoc, type EvidenceDocHandle } from '../../run/EvidenceDoc'
import { EvidenceGuessView } from '../../run/EvidenceGuess'
import { Markdown, inlineStyles } from '../../run/Markdown'
@@ -142,6 +143,13 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: {
{T.docGone(labelOf(r.node_id) ?? r.node_id ?? '')}
diff --git a/frontend/src/run/AssistantStream.tsx b/frontend/src/run/AssistantStream.tsx index cca9f80a..9ecf00fb 100644 --- a/frontend/src/run/AssistantStream.tsx +++ b/frontend/src/run/AssistantStream.tsx @@ -25,7 +25,7 @@ import { import { CODE_COLUMN, CopyChip, LEADING_ZERO, Markdown, type MarkSpec } from './Markdown' import { ClaimTally, EvidenceField, type EvidenceDocHandle } from './EvidenceDoc' import { EVIDENCE_STATE, claimProblems, docTally, evidenceFields, issuanceMarks, type EvidenceTally } from '../lib/evidence' -import type { EvidenceDocData } from '../types' +import type { EvidenceDocData, IssuanceSqlCheck } from '../types' import { useRunClock } from './useRunClock' import { CatalogPatchCard } from './CatalogPatchCard' import type { CatalogPatch } from './catalogPatch' @@ -2262,6 +2262,32 @@ function OutputValue({ value, marks, label }: { value: unknown; marks?: MarkSpec ) } +/** + * 出具声明里没通过 SQL 检查的查询(sql_checks):每条写是哪次查询、什么问题、影响了哪些引用,下一步写改来源查询的 SQL。 + * 出具横幅和报告页顶部共用。颜色跟着档位:降档用提醒色,不予出具用失败色 + */ +export function IssuanceSqlChecks({ tier, checks }: { tier: string; checks: IssuanceSqlCheck[] }) { + const tone = tier === 'withheld' ? 'var(--st-failed)' : 'var(--st-waiting)' + return ( +