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 }: { + {/* 出具档位和降档原因写在报告上方:降档时只在侧栏小标签里写「SQL 检查未通过」,报告上却是绿色的 + 「10 个数字都有出处」,读的人会以为一切正常。SQL 检查导致的降档在横幅里单列一块 */} + {!!output?._issuance?.tier && ( +
+ +
+ )} {mode === 'cited' && reports.map((r) => { const doc = docOf(r.node_id) @@ -157,6 +165,7 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: { {doc ? ( { handles.current.set(r.node_id!, h) }} doc={doc} artifact={r.doc_artifact} runId={run.id} runClass={run.run_class} label={r.fields?.join('、') || r.node_id} tally + tier={typeof output?._issuance?.tier === 'string' ? output._issuance.tier : undefined} panel={wide ? 'dock' : 'auto'} dock={dock} onView={(o) => onView(r.node_id!, o)} /> ) : slot?.status === 'error' ? (

{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 ( +
+
+ {RUN_SQL_CHECK_TEXT.issuanceTitle(tier)} +
+
    + {checks.map((c, i) => ( +
  • + {c.query || RUN_SQL_CHECK_TEXT.issuanceQuery} + {!!c.problems?.length && :{c.problems.join(';')}} + {!!c.refs?.length && ({RUN_SQL_CHECK_TEXT.issuanceAffected(c.refs.join('、'))})} +
  • + ))} +
+
{RUN_SQL_CHECK_TEXT.issuanceNext}
+
+ ) +} + const TIER_META: Record = { formal: { color: 'var(--st-done)', soft: 'var(--st-done-soft)', hint: ISSUANCE_HINT.formal }, degraded: { color: 'var(--st-waiting)', soft: 'var(--st-waiting-soft)', hint: ISSUANCE_HINT.degraded }, @@ -2292,7 +2318,12 @@ export function IssuanceBanner({ issuance, runClass, evidence }: { if (!tier) return null const meta = TIER_META[tier] ?? { color: 'var(--st-waiting)', soft: 'var(--st-waiting-soft)', hint: '' } const unmatched: any[] = issuance?.unmatched_numbers ?? [] - const gaps: string[] = Array.isArray(issuance?.gaps) ? issuance.gaps.map(String) : [] + // 没通过 SQL 检查的查询(出具声明的 sql_checks)单列一块、写在显眼处:数字都有出处,降档却是因为算数的查询本身有问题。 + // 单列了的那几句(gap)不在其余缺口里重复 + const sqlChecks: IssuanceSqlCheck[] = (Array.isArray(issuance?.sql_checks) ? issuance.sql_checks : []) + .filter((x: unknown): x is IssuanceSqlCheck => !!x && typeof x === 'object') + const sqlGaps = new Set(sqlChecks.map((x) => x.gap).filter(Boolean)) + const gaps: string[] = (Array.isArray(issuance?.gaps) ? issuance.gaps.map(String) : []).filter((g: string) => !sqlGaps.has(g)) const calibers: any[] = issuance?.calibers ?? [] const matched = Number(issuance?.matched_numbers ?? 0) // 逐个数字的出处(matched[].caliber 形如「口径名 @ v2」):有它就能按卡数清楚, @@ -2309,6 +2340,8 @@ export function IssuanceBanner({ issuance, runClass, evidence }: { const uncitedClaims = Number(issuance?.claims?.uncited_claims ?? 0) || 0 const numbers = counts ? evidenceTally(counts.cited, counts.total, counts.other) : '' const numbersOk = !counts?.none && !counts?.other + // 数字都有出处只在完整出具时用绿色:降档时「10 个数字都有出处」画成绿的,读的人会以为一切正常 + const numbersColor = !numbersOk ? 'var(--st-waiting)' : tier === 'formal' ? 'var(--st-done)' : 'var(--text-dim)' // 四期:结论句有判定(开了裁判,或探索运行里按需判过)时多一段「结论 4 句(支持 3 · 无证据 1)」。 // 没开裁判的文档里没挂依据的都数在这一段的「无证据」里,不再另起一句;开了裁判的文档里,没挂依据却送了 // 裁判的句子按判定数、预筛放掉的不数——出具照样按没挂依据计缺口(require_citation、judge 都是),这时 @@ -2340,7 +2373,7 @@ export function IssuanceBanner({ issuance, runClass, evidence }: { // 结论句靠句末徽标定位;只是没挂依据的结论句没有徽标,定位不到
{numbers && ( - {numbers} + {numbers} )} {suspect > 0 && ( @@ -2362,9 +2395,10 @@ export function IssuanceBanner({ issuance, runClass, evidence }: {
)} + {!!sqlChecks.length && } {!!gaps.length && ( -
-
校验未全部完成:
+
+
{RUN_SQL_CHECK_TEXT.otherGaps}
    {gaps.map((g) =>
  • · {g}
  • )}
diff --git a/frontend/src/run/EvidenceDoc.tsx b/frontend/src/run/EvidenceDoc.tsx index 11285ff7..7cac5672 100644 --- a/frontend/src/run/EvidenceDoc.tsx +++ b/frontend/src/run/EvidenceDoc.tsx @@ -212,7 +212,13 @@ export const EvidenceDoc = forwardRef(function EvidenceDoc({ doc, artifact, runId, dense = false, label, panel = 'auto', dock, onView, tally = false, runClass }, ref) { + /** + * 这次出具的档位(output._issuance.tier)。不是完整出具时,「N 个数字都有出处」不画成绿色: + * 数字的出处都对得上,档位却因为别的原因(比如查询没通过 SQL 检查)降了 + */ + tier?: string +}>(function EvidenceDoc({ doc, artifact, runId, dense = false, label, panel = 'auto', dock, onView, tally = false, runClass, + tier }, ref) { const blocks = useMemo(() => doc.blocks ?? [], [doc]) const report = doc.node_id || undefined // 封存之后按需追加的判定:盖过文档里的「未裁判 · 按需」。只换句末徽标,封存的文档一个字不改 @@ -447,7 +453,7 @@ export const EvidenceDoc = forwardRef{summary}

{announce}
{tally && (counts.total > 0 || counts.other > 0 || !!counts.suspect || !!claims?.total) && ( - { + { const target = step(model.alerts, current, 1) if (target) { focusSeg(target, { flash: true }); openSeg(target) } else openViolations() }} onList={doc.violations?.length ? openViolations : undefined} /> @@ -520,10 +526,12 @@ function usePanelMode(panel: 'auto' | PanelMode, dense: boolean): PanelMode { * 证据条:没有出具横幅时由文档自己说「7/12 数字有出处 · 无证据 5 · 另有 1 处引用解析不了」, * 有结论句判定时接着说「结论 4 句(支持 3 · 不支持 1)」 */ -export function EvidenceTally({ counts, onNext, onList }: { +export function EvidenceTally({ counts, onNext, onList, tier }: { counts: Pick onNext?: () => void onList?: () => void + /** 出具档位:给了、又不是完整出具时,数字都有出处也不用绿色 */ + tier?: string }) { const suspect = counts.suspect ?? 0 const claims = counts.claims @@ -532,7 +540,8 @@ export function EvidenceTally({ counts, onNext, onList }: { return (
{numbers && ( - + {numbers} )} diff --git a/frontend/src/run/__tests__/synthetic.ts b/frontend/src/run/__tests__/synthetic.ts index cfee2d4b..3177ed1f 100644 --- a/frontend/src/run/__tests__/synthetic.ts +++ b/frontend/src/run/__tests__/synthetic.ts @@ -188,6 +188,29 @@ export const CALIBERS_OUTPUT = { }, } +/** + * 数字都有出处,却因为算数的查询没通过 SQL 检查而降档(engine/nodes/io.py 的 sql_checks):横幅单列一块说明, + * 那一句不在其余缺口里重复;「都有出处」不画成绿色 + */ +export const SQL_GAP = `查询「订单金额查询」(Q1)未通过 SQL 检查(${'「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算'}),结果不可靠;受影响的引用:指标「订单金额」、Q1 第 1 行「gmv」` +export const SQL_DEGRADED_OUTPUT = { + answer: '上周订单金额 **119,160** 元。', + _issuance: { + tier: 'degraded', + calibers: [{ node: 'k1', caliber: '订单口径', version: 'v2' }], + metrics_checked: 1, + missing_required: [], + missing_expected: [], + unmatched_numbers: [], + matched_numbers: 1, + matched: [{ token: '119,160', metric: 'gmv', caliber: '订单口径 @ v2' }], + gaps: [SQL_GAP, '协作团队「分析团队」用完 2 轮仍未完成,交付的是成员最后的回复'], + sql_checks: [{ query: '查询「订单金额查询」(Q1)', node_id: 'q_a', + problems: ['「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算'], + refs: ['指标「订单金额」', 'Q1 第 1 行「gmv」'], gap: SQL_GAP }], + }, +} + /** 只有一张口径卡、逐个出处都指向它:直说「都来自这张卡」 */ export const CALIBER_ONE_OUTPUT = { answer: '上周订单 **128** 单,已付款 **96** 单。', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4729e179..2df7b610 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2112,6 +2112,21 @@ export interface EvidenceMergeInput { checks?: SqlCheckItem[] } +/** + * 出具声明(output._issuance、issuance 事件)里的 sql_checks:没通过 SQL 检查的查询,一条一个。报告页、出具横幅据此 + * 在显眼处说「因为 SQL 检查没通过而降档」。gap 是 gaps 里对应的那一句(单列之后不在其余缺口里重复) + */ +export interface IssuanceSqlCheck { + /** 「查询「取数」(Q1)」;给不出时没有 */ + query?: string + node_id?: string + /** 每条问题那半句 */ + problems?: string[] + /** 受影响的引用:「指标「订单金额」」「Q1 第 1 行「gmv」」 */ + refs?: string[] + gap?: string +} + /** 查询用到的一张表、查询当时它的数据目录版本 */ export interface EvidenceCatalogVersion { /** 表结构里的表名 */ diff --git a/scripts/check-runs.mjs b/scripts/check-runs.mjs index fc4e9ef4..16f37a15 100644 --- a/scripts/check-runs.mjs +++ b/scripts/check-runs.mjs @@ -1981,6 +1981,37 @@ await section('证据页签:左边报告、右边常驻面板、下方审计 await page.keyboard.press('Escape') }) +await section('证据页签:降档时报告上方写明档位和原因,SQL 检查没通过的单独说,「都有出处」不画成绿色(B5)', async () => { + // 成果的 _issuance 带 sql_checks(engine/nodes/io.py):数字都有出处,降档却是因为算数的查询没通过 SQL 检查 + const EV_SQL = 'fake0evsqldegrade000000000000' + const PROBLEM = '「订单」关联「订单明细」是一对多,对「订单」的「订单金额」求和会重复计算' + const GAP = `查询「取订单」(Q1)未通过 SQL 检查(${PROBLEM}),结果不可靠;受影响的引用:Q1 第 1 行「gmv」` + const issuance = { tier: 'degraded', calibers: [], metrics_checked: 0, missing_required: [], missing_expected: [], + unmatched_numbers: [], matched_numbers: 10, matched: [], gaps: [GAP], + sql_checks: [{ query: '查询「取订单」(Q1)', node_id: 'fetch', problems: [PROBLEM], refs: ['Q1 第 1 行「gmv」'], gap: GAP }] } + evFakes(EV_SQL, { output: { ...fxe.output, _issuance: issuance }, graph: fxe.graph, audit: fxe.audit, csv: fxe.audit_csv }) + await page.route(new RegExp(`/api/runs/${EV_SQL}/evidence/segments/`), (r) => r.fulfill({ status: 404, json: { detail: '没有这个片段' } })) + try { + await openEvidence(EV_SQL) + await page.locator('[data-evidence-report=write] [data-evidence-doc]').waitFor({ timeout: 5000 }).catch(() => {}) + const banner = page.locator('[data-view-pane=evidence] [data-evidence-issuance] [data-issuance-banner=degraded]') + check('报告上方写明出具档位', await banner.count() === 1 && (await banner.innerText().catch(() => '')).includes('降档出具')) + const sql = (await banner.locator('[data-issuance-sql-checks]').innerText().catch(() => '')).replace(/\s+/g, ' ') + check('……SQL 检查导致的降档在显眼处说明:哪次查询、什么问题、受影响的引用', sql.includes('降档原因:所依据的查询未通过 SQL 检查') + && sql.includes('查询「取订单」(Q1)') && sql.includes(PROBLEM) && sql.includes('Q1 第 1 行「gmv」'), sql) + check('……那一句不在其余缺口里重复', await banner.locator('[data-issuance-gaps]').count() === 0) + const tally = page.locator('[data-evidence-report=write] [data-evidence-tally-numbers]') + const [color, done] = await Promise.all([tally.evaluate((el) => getComputedStyle(el).color).catch(() => ''), + page.evaluate(() => { const s = document.createElement('span'); s.style.color = 'var(--st-done)'; document.body.append(s) + const c = getComputedStyle(s).color; s.remove(); return c })]) + check('降档时报告上的数字计数不画成绿色', !!color && color !== done, `${color} vs ${done}`) + } finally { + for (const k of [...fakes.keys()]) if (k.includes(EV_SQL)) fakes.delete(k) + } + await openEvidence(EV) + check('没有出具契约的运行:报告上方不出出具横幅', await page.locator('[data-view-pane=evidence] [data-evidence-issuance]').count() === 0) +}) + await section('证据页签:同一段文字里好几条同样的违规、正文里点不开的行', async () => { // 夹具同样是后端真跑的:粗体、链接里反引号写的可疑名字只记违规、不切片段——同一段文字上两条 unknown_entity、 // 两条 unverified_entity(片段和 code 都相同),列表序号 100. 是结构片段上的裸数字。这几行正文里都点不开 diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs index 1ce628a2..f4660f41 100644 --- a/scripts/check-stream.mjs +++ b/scripts/check-stream.mjs @@ -1303,6 +1303,17 @@ await section('放弃、结构化的报错、恢复的轮次、出具横幅(RE check('恢复的轮次:头部照样写几次查询', head.includes('3 次查询'), head.replace(/\n/g, ' ')) await r.page.close() + // 降档是因为查询没通过 SQL 检查(B5):横幅单列一块写是哪次查询、什么问题、影响了哪些引用;那一句不在其余缺口里重复 + const q = await open('syn=issued-sql', { w: 1100, h: 900, name: 'issued-sql' }) + const banner = q.page.locator('[data-issuance-banner=degraded]') + const sql = (await banner.locator('[data-issuance-sql-checks]').innerText().catch(() => '')).replace(/\s+/g, ' ') + check('SQL 检查导致的降档单列一块:写降档原因、哪次查询、问题、受影响的引用、下一步', sql.includes('降档原因:所依据的查询未通过 SQL 检查') + && sql.includes('查询「订单金额查询」(Q1)') && sql.includes('求和会重复计算') && sql.includes('受影响:指标「订单金额」、Q1 第 1 行「gmv」') + && sql.includes('修改来源查询的 SQL'), sql) + const rest = (await banner.locator('[data-issuance-gaps]').innerText().catch(() => '')).replace(/\s+/g, ' ') + check('……那一句不在「校验未全部完成」里重复,其余缺口照列', !rest.includes('未通过 SQL 检查') && rest.includes('协作团队'), rest) + await q.page.close() + const i = await open('syn=issued', { w: 1100, h: 900 }) check('出具横幅带 data-issuance-banner(画布印章据此滚过来)', await i.page.locator('[data-issuance-banner]').count() === 1) const live = await i.page.locator('[data-turn] [role="status"][aria-live="polite"]').first().innerText()