diff --git a/backend/app/engine/analysis_claims.py b/backend/app/engine/analysis_claims.py index 7cef1666..36b46e93 100644 --- a/backend/app/engine/analysis_claims.py +++ b/backend/app/engine/analysis_claims.py @@ -12,8 +12,9 @@ 「不但」「不少」「无论」「未来」这类不是否定 3. **怎么挂**:一句话的分析依据 = 句中的 [[a:]]、[[v:A…]]、[[table:A…]] 和句末 [[see:A…]](组装、复核时按重新解析的 引用算,见 evidence.verify_doc)。类要的 op 必须在依据里出现,几个里任一个对得上就算对;挂了算不了的结论算对不上 -4. **点名的段**:判断词所在分句里出现的结论结果表的维度值(全半角归一,两个字以上、不是纯数字)。来源类要点名的段都在 - 判定的 subjects 里;拖累要那一段变化为负,拉动、带动为正(不在 subjects 里、但那一段的变化占总变化过半的也算拖累) +4. **点名的段**:判断词所在分句里出现的结论结果表的维度值(全半角归一,两个字以上、不是纯数字)。来源类要判定说了主要 + 来源(driver、concentrated,比率拆解的 mix_driven、rate_driven),点名的段都在 subjects 里;拖累要那一段变化为负, + 拉动、带动为正(不在 subjects 里、但那一段的变化占总变化过半的也算拖累) 5. **来源和因果的分界**:句子挂了拆解、因果词所在分句点名的是这份结论的段或因子(「下降主要由华东造成」)→ 按来源核, 这是记账意义上的「来自」;点名的不是(「由于促销结束导致下降」)→ 按因果核:同一句要有知识库原话([[q:]])或运行 输入([[i:]])作依据,或者写成推测(可能、或许、推测、待核实、不排除、疑似)。分句里已有来源一类判断词的,因果词 @@ -40,7 +41,7 @@ from dataclasses import dataclass, field from decimal import Decimal, InvalidOperation from typing import Any -from app.analysis.contract import catalog_result +from app.analysis.contract import LEDGER_KIND, OP_LABELS, catalog_result, method_label from app.engine.evidence import _Reason, _violation, iter_units #: 说法表的版本:改了判断词、类、绑定规则就升版本,老版本留在 LEXICONS 里给老文档复核用 @@ -54,10 +55,6 @@ MISMATCH = "analysis_claim_mismatch" CAUSAL = "causal_unsupported" CODES = frozenset({UNCITED, MISMATCH, CAUSAL}) -#: 各算子给人看的叫法(违规说法里用) -_OP_TEXT = {"decompose": "变化拆解", "baseline": "波动基线", "trend": "趋势", "differ": "差异检验", - "comparable": "可比性检查"} - # -------------------------------------------------------------------------- # 说法表 v1 # -------------------------------------------------------------------------- @@ -197,6 +194,9 @@ _V1 = Lexicon( LEXICONS: dict[int, Lexicon] = {1: _V1} +#: 能挂「主要来自」这类来源判断的拆解判定(判定语就是这么说的):一段或一个因子为主、集中在前几段,比率拆解里结构为主、 +#: 各段自身为主 +_SOURCE_VERDICTS = frozenset({"driver", "concentrated", "mix_driven", "rate_driven"}) #: 有这几类判断的分句里,因果词并进它们核(「下降主要由华东造成」:说的是记账意义上的来源) _SOURCE_KEYS = frozenset({"source", "source_not", "dispersed", "offset", "mix"}) #: 拆解结果表里不是维度的列(params.by 对不上时按「不是度量列」认维度,跳过这些) @@ -404,7 +404,7 @@ def _named(result: Mapping[str, Any], names: str) -> list[str]: at = flat.find(key) while at >= 0: before, after = flat[at - 1:at], flat[at + len(key):at + len(key) + 1] - if not (key.isascii() and ((before and before.isalnum()) or (after and after.isalnum()))): + if not (key.isascii() and any(ch.isascii() and ch.isalnum() for ch in (before, after) if ch)): hits.append((at, original)) break at = flat.find(key, at + 1) @@ -507,7 +507,8 @@ def _check(lex: Lexicon, hit: Hit, result: Mapping[str, Any], names: str) -> _Mi if key in ("source", "source_not"): subjects = _subjects(result) named = _named(result, names) - top = code in ("driver", "concentrated") + # 判定语说「主要来自」的几种:一段(一个因子)为主、集中在前几段;比率拆解的结构为主、各段自身为主 + top = code in _SOURCE_VERDICTS if key == "source_not": inside = [n for n in named if _named_value(n) in {_named_value(s) for s in subjects}] if top and (inside or not named): @@ -548,8 +549,9 @@ def _check(lex: Lexicon, hit: Hit, result: Mapping[str, Any], names: str) -> _Mi return _Miss("没有记下此消彼长(各段的变化方向大多一致)", "这份结论没有记下各段此消彼长") if key == "mix": if result.get("mode") != "ratio": - return _Miss("是按维度的可加拆解,说明不了结构变化:结构变化要挂比率拆解", - "这是按维度的可加拆解,说明不了结构变化(结构变化要挂比率拆解)") + done, wanted = method_label("decompose", result.get("mode")), method_label("decompose", "ratio") + return _Miss(f"是{done},说明不了结构变化:结构变化要挂{wanted}", + f"这是{done},说明不了结构变化:结构变化要挂{wanted}") return None if code in ("mix_driven", "both") else _verdict_miss(result) if key == "anomaly": if code not in ("outside_band", "far_outside_band"): @@ -623,7 +625,8 @@ _FALLBACK = { def _op_text(claim: Claim) -> str: - return "比率拆解" if claim.mode == "ratio" else _OP_TEXT.get(claim.op or "", claim.op or "") + """这一类要挂的分析怎么叫:算子、模式的叫法从契约取(结构变化是「变化拆解(比率)」)。""" + return method_label(claim.op, claim.mode) if claim.mode else OP_LABELS.get(claim.op or "", claim.op or "") def _snippet(shown: str, start: int, end: int, limit: int = 16) -> str: @@ -740,11 +743,18 @@ def _available(results: Mapping[str, Mapping[str, Any]]) -> dict[str, list[str]] # -------------------------------------------------------------------------- -def results_of(catalog: Mapping[str, Any]) -> dict[str, dict[str, Any]]: - """写作目录里的分析结论:{A 编号: 结论}(contract.catalog_result 读)。""" +def results_of(catalog: Mapping[str, Any], load: Any = None) -> dict[str, dict[str, Any]]: + """写作目录里的分析结论:{A 编号: 结论}(contract.catalog_result 读)。条目只带摘要、不带整份结论的(存进报告文档的 + 目录就是这样)按 load(编号, 条目) 取回(evidence 的 _Snapshots.analysis:按工件取、复验哈希);没给 load、取不回的 + 不在里面。""" out: dict[str, dict[str, Any]] = {} for alias, entry in catalog.items(): - if isinstance(entry, dict) and (result := catalog_result(entry)) is not None: + if not isinstance(entry, dict) or entry.get("kind") != LEDGER_KIND: + continue + result = catalog_result(entry) + if result is None and load is not None: + result = load(str(alias), entry) + if isinstance(result, dict): out[str(alias)] = dict(result) return out @@ -791,10 +801,10 @@ def _sentence(unit: dict[str, Any], aliases: Iterable[str], results: Mapping[str def check_doc(doc: dict[str, Any], catalog: Mapping[str, Any], *, cited: Mapping[int, list[str]] | None = None, - version: int | None = None) -> list[dict[str, Any]]: + version: int | None = None, load: Any = None) -> list[dict[str, Any]]: """一份报告文档的说法违规。cited 是 {id(unit): 这句按重新解析的引用算的依据}(verify_doc 交进来),不给就用文档里 - 记的 cites。标题、代码块、表头不核。""" - results = results_of(catalog) + 记的 cites;load 取回只带摘要的条目的整份结论(results_of)。标题、代码块、表头不核。""" + results = results_of(catalog, load) available = _available(results) out: list[dict[str, Any]] = [] for block, unit in iter_units(doc): @@ -825,8 +835,8 @@ def recorded(doc: Any) -> tuple[str | None, int | None] | None: return mode, version -def verify(doc: Any, catalog: Mapping[str, Any], *, cited: Mapping[int, list[str]] | None = None - ) -> list[dict[str, Any]] | None: +def verify(doc: Any, catalog: Mapping[str, Any], *, cited: Mapping[int, list[str]] | None = None, + load: Any = None) -> list[dict[str, Any]] | None: """复核一份文档的说法(evidence.verify_doc 调):没记这一项的文档返回 None(不核,老文档复核结果不变);记了的按记下 的那一版说法表核。记了认不出的处置、版本的另记一条 bad_schema(文档可能被改过),照当前版核。""" found = recorded(doc) @@ -836,7 +846,7 @@ def verify(doc: Any, catalog: Mapping[str, Any], *, cited: Mapping[int, list[str out: list[dict[str, Any]] = [] if mode is None or version is None: out.append(_violation("bad_schema", f"报告文档记录的分析说法核对规则「{doc.get(DOC_FIELD)}」无法识别")) - return out + check_doc(doc, catalog, cited=cited, version=version) + return out + check_doc(doc, catalog, cited=cited, version=version, load=load) def counts(violations: Iterable[Mapping[str, Any]]) -> dict[str, int]: diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index 609f4c53..3eb86261 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -3601,10 +3601,10 @@ def verify_doc( if tiled and pos != len(markdown): violations.append(_violation("segment_mismatch", "正文末尾有一段不属于任何片段,文档可能被修改过")) # 分析说法核对(engine/analysis_claims):只核记了这一项的文档,按记下的那一版说法表、这次核对的引用核。老文档不核, - # 统计也不多这个键 + # 统计也不多这个键。目录条目只带摘要的(存进文档的那份)按工件取回整份结论 from app.engine import analysis_claims as said - said_found = said.verify(doc, catalog, cited=cited_by) + said_found = said.verify(doc, catalog, cited=cited_by, load=lambda alias, entry: snaps.analysis(alias, entry)[0]) if said_found is not None: violations.extend(said_found) stats["analysis_claims"] = sum(1 for v in said_found if v["code"] in said.CODES) diff --git a/backend/tests/test_analysis_claims.py b/backend/tests/test_analysis_claims.py index 59571b13..5a5b9a8c 100644 --- a/backend/tests/test_analysis_claims.py +++ b/backend/tests/test_analysis_claims.py @@ -11,8 +11,6 @@ """ from __future__ import annotations -import re - import pytest from app.analysis.testing import fake_result @@ -193,6 +191,15 @@ def test_named_segments_must_be_among_the_subjects(): assert said("下降主要来自华东,华南基本持平。", "A1", results={"A1": fake_result()}) == [] +def test_ascii_names_need_word_boundaries_but_chinese_neighbours_are_fine(): + result = fake_result(subjects=[["AB"]]) + result["table"]["rows"][0][0] = "AB" + result["table"]["rows"][1][0] = "A" + assert said("下降主要来自AB店。", "A1", results={"A1": result}) == [] + assert said("下降主要来自XAB。", "A1", results={"A1": result}) == [], "XAB 不是点名 AB,也没点名别的段" + assert codes(said("下降主要来自A。", "A1", results={"A1": result})) == [], "一个字的名字不认" + + def test_named_segments_are_matched_across_full_and_half_width(): result = fake_result(subjects=[["A店"]]) result["table"]["rows"][0][0] = "A店" @@ -260,7 +267,7 @@ def test_differences_check_the_verdict_and_the_order_of_the_two_groups(): def test_a_structure_claim_needs_a_ratio_decomposition(): [miss] = said("退款率上升来自结构变化。", "A1", results={"A1": fake_result()}) - assert miss["code"] == ac.MISMATCH and "比率拆解" in miss["message"] + assert miss["code"] == ac.MISMATCH and "结构变化要挂变化拆解(比率)" in miss["message"] mix = fake_result(mode="ratio", verdict="mix_driven") assert said("退款率上升来自结构变化。", "A1", results={"A1": mix}) == [] @@ -348,22 +355,6 @@ def catalog(**analyses) -> dict: return out -@pytest.fixture -def see_a(monkeypatch): - """[[see:A1]] 的解析是证据接入(W1-b)的事:合进来之前这里按它的约定代为解析(编号 A{n}、目录里 kind analysis)。""" - original = evidence.resolve_support - - def resolve(ref, catalog, *, loader=None): - ref = ref.strip() - entry = catalog.get(ref) or {} - if re.fullmatch(r"A\d+", ref) and entry.get("kind") == "analysis": - return {"ref": ref, "role": "support", "alias": ref, "kind": "analysis", "status": "resolved", - "eid": entry["eid"], "locator": {}} - return original(ref, catalog, loader=loader) - - monkeypatch.setattr(evidence, "resolve_support", resolve) - - def found(doc: dict) -> list[dict]: return [v for v in doc["violations"] if v["code"] in ac.CODES] @@ -434,7 +425,7 @@ def test_rendered_values_name_segments_but_are_not_judgement_words(): assert miss["code"] == ac.MISMATCH and "不是华南" in miss["message"] and miss["span"] == [2, 6] -def test_compose_and_verify_bind_the_sentence_to_its_see_marker(see_a): +def test_compose_and_verify_bind_the_sentence_to_its_see_marker(): cat = catalog(A1=fake_result()) ok = compose_doc("销售额 [[m:gmv]],下降主要来自华东。[[see:A1]]", cat, analysis_claims="check") assert found(ok) == [] and verify_doc(ok, cat)["violations"] == [] diff --git a/backend/tests/test_analysis_claims_operators.py b/backend/tests/test_analysis_claims_operators.py new file mode 100644 index 00000000..19af5e57 --- /dev/null +++ b/backend/tests/test_analysis_claims_operators.py @@ -0,0 +1,191 @@ +"""说法核对挂真算子算出来的结论(app/analysis,第 1 波 W1-a):判定、点名的段、方向、新高新低、连续、抵消、结构、差异的两组、 +算不了,都按算子真给的形状核(tests/analysis_samples 的样例:虚构零售库 shop)。再经组装和复核走一遍:判定语 [[a:A1]] 和 +具名标量 [[a:A1.键]] 整段不扫,[[see:A1]] 当依据。""" +from __future__ import annotations + +import pytest + +from app.engine import analysis_claims as ac +from app.engine.evidence import build_catalog, compose_doc, make_eid, verify_doc +from tests.analysis_samples import analyze, fixed_prober, snap + +SQL = "SELECT region, week, SUM(amount) AS gmv FROM orders GROUP BY region, week" +TYPES = {"region": "text", "week": "text", "gmv": "number"} +WEEKLY = [["华东", "2026-W41", 820], ["华东", "2026-W42", 700], ["华北", "2026-W41", 600], ["华北", "2026-W42", 560], + ["华南", "2026-W41", 700], ["华南", "2026-W42", 666], ["西南", "2026-W41", 100], ["西南", "2026-W42", 100]] + + +async def additive(rows=WEEKLY, **params): + s = snap("Q2", SQL, ["region", "week", "gmv"], rows, types=TYPES) + return await analyze("decompose", "additive", snaps=[s], query="Q2", by=["region"], period="week", + base="2026-W41", target="2026-W42", measure="gmv", label="销售额", **params) + + +def weekly_rows(base: dict, target: dict) -> list[list]: + return [[k, "2026-W41", v] for k, v in base.items()] + [[k, "2026-W42", v] for k, v in target.items()] + + +def said(text: str, **results) -> list[str]: + """一句话挂了 results 这几份结论:违规的代码。""" + return [v["code"] for v in ac.check_sentence(ac.Sentence(text=text, analyses=list(results)), results)] + + +async def test_an_additive_driver_backs_its_segment_and_nothing_else(): + a1 = await additive() + assert a1["verdict"] == {"code": "driver", "subjects": [["华东"]], "direction": "down"} + assert said("销售额下降主要来自华东。", A1=a1) == [] + assert said("华东拖累了整体。", A1=a1) == [] + assert said("销售额下降主要来自华南。", A1=a1) == [ac.MISMATCH] + assert said("华北带动了增长。", A1=a1) == [ac.MISMATCH], "华北下降、占比不到一半,不能说带动" + assert said("各地区普遍下降。", A1=a1) == [ac.MISMATCH] + + +async def test_a_dispersed_change_backs_only_dispersion(): + a1 = await additive(weekly_rows({"华东": 100, "华北": 100, "华南": 100, "西南": 100}, + {"华东": 75, "华北": 75, "华南": 75, "西南": 75})) + assert a1["verdict"]["code"] == "dispersed" + assert said("销售额下降分散在各地区。", A1=a1) == [] + assert said("销售额下降主要来自华东。", A1=a1) == [ac.MISMATCH] + + +async def test_a_flat_total_with_opposite_segments_backs_an_offset(): + a1 = await additive(weekly_rows({"华东": 500, "华南": 500, "华北": 300}, {"华东": 550, "华南": 450, "华北": 300})) + assert a1["verdict"]["code"] == "flat" + assert said("华东和华南此消彼长。", A1=a1) == [] + assert said("销售额基本持平,主要来自华东。", A1=a1) == [ac.MISMATCH] + + +async def test_a_ratio_decomposition_backs_structure_and_its_main_source(): + s = snap("Q1", "SELECT channel, week, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders GROUP BY 1, 2", + ["channel", "week", "refunds", "orders"], + [["线上", "W41", 50, 500], ["线上", "W42", 90, 1000], ["线下", "W41", 10, 1000], ["线下", "W42", 4, 500]]) + a1 = await analyze("decompose", "ratio", snaps=[s], query="Q1", by="channel", period="week", base="W41", + target="W42", numerator="refunds", denominator="orders", label="退款率") + assert a1["verdict"] == {"code": "mix_driven", "subjects": [["线上"]], "direction": "up"} + assert said("退款率上升来自结构变化。", A1=a1) == [] + assert said("退款率上升主要来自结构变化,线上占比变大。", A1=a1) == [], "判定语就是这么说的" + assert said("退款率上升主要来自线下。", A1=a1) == [ac.MISMATCH] + + +async def test_a_factor_decomposition_names_its_factor(): + s = snap("Q1", "SELECT week, SUM(amount) AS gmv, COUNT(*) AS orders, ROUND(AVG(amount), 2) AS aov FROM orders " + "GROUP BY week", ["week", "gmv", "orders", "aov"], + [["W41", 10000, 100, 100], ["W42", 15000, 150, 100]]) + a1 = await analyze("decompose", "factor", snaps=[s], query="Q1", period="week", base="W41", target="W42", + product="gmv", factors=["orders", "aov"], label="销售额") + assert a1["verdict"]["code"] == "driver" + [[factor]] = a1["verdict"]["subjects"] + assert said(f"销售额增长主要来自{factor}的增长。", A1=a1) == [] + other = next(r[0] for r in a1["table"]["rows"] if r[0] != factor) + assert said(f"销售额增长主要来自{other}。", A1=a1) == [ac.MISMATCH] + + +NOISE = [100, 102, 98, 101, 99, 103, 100, 97, 102, 101, 99, 100, 98, 103, 101, 100, 99, 102, 100] +SERIES_SQL = "SELECT DATE(order_date, 'weekday 0', '-6 days') AS week, COUNT(*) AS orders FROM orders GROUP BY 1" + + +def weeks(values): + from datetime import date, timedelta + + start = date(2026, 10, 12) - timedelta(days=7 * (len(values) - 1)) + return [[(start + timedelta(days=7 * i)).isoformat(), v] for i, v in enumerate(values)] + + +async def series(op: str, values, **params): + probe = fixed_prober({("orders", "order_date"): ("2025-01-01", "2026-10-20")}) + s = snap("Q1", SERIES_SQL, ["week", "orders"], weeks(values), types={"week": "text", "orders": "number"}) + return await analyze(op, snaps=[s], probe=probe, query="Q1", period="week", measure="orders", label="订单数", + **params) + + +async def test_a_baseline_backs_anomalies_records_and_normal_swings_in_the_right_direction(): + far = await series("baseline", NOISE + [70]) + assert far["verdict"] == {"code": "far_outside_band", "subjects": [], "direction": "down"} + assert said("订单数本周骤降。", A1=far) == [] + assert said("订单数本周大幅下降,创历史新低。", A1=far) == [] + assert said("订单数本周异常增长。", A1=far) == [ac.MISMATCH] + assert said("订单数创历史新高。", A1=far) == [ac.MISMATCH] + assert said("订单数属于正常波动。", A1=far) == [ac.MISMATCH] + within = await series("baseline", NOISE + [101]) + assert within["verdict"]["code"] == "within_band" + assert said("订单数属于正常波动,并未出现明显异常。", A1=within) == [] + assert said("订单数异常。", A1=within) == [ac.MISMATCH] + + +async def test_a_trend_backs_its_direction_and_runs_only_when_long_enough(): + rising = await series("trend", [100 + 3 * i + (i % 3) for i in range(12)]) + assert rising["verdict"]["code"] == "rising" and rising["values"]["run_direction"] == "up" + assert said("订单数持续上升。", A1=rising) == [] + assert said("订单数已连续多周上升。", A1=rising) == [] + assert said("订单数持续下降。", A1=rising) == [ac.MISMATCH] + assert said("订单数没有明显趋势。", A1=rising) == [ac.MISMATCH] + short = dict(rising, values={**rising["values"], "run_length": "2"}) + assert said("订单数连续上升。", A1=short) == [ac.MISMATCH] + + +async def test_a_difference_backs_the_higher_group_in_either_order(): + rates = snap("Q1", "SELECT region, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders GROUP BY region", + ["region", "refunds", "orders"], [["西南", 80, 1000], ["华东", 40, 1000]]) + differs = await analyze("differ", "rate", snaps=[rates], query="Q1", by="region", numerator="refunds", + denominator="orders", label="退款率") + assert differs["verdict"] == {"code": "differs", "subjects": [["西南"], ["华东"]], "direction": "up"} + assert said("西南的退款率显著高于华东。", A1=differs) == [] + assert said("华东的退款率明显低于西南。", A1=differs) == [] + assert said("西南的退款率显著低于华东。", A1=differs) == [ac.MISMATCH] + small = snap("Q1", "SELECT region, SUM(refund_flag) AS refunds, COUNT(*) AS orders FROM orders GROUP BY region", + ["region", "refunds", "orders"], [["西南", 2, 40], ["华东", 5, 230]]) + nothing = await analyze("differ", "rate", snaps=[small], query="Q1", by="region", numerator="refunds", + denominator="orders", label="退款率") + assert nothing["verdict"]["code"] == "no_evidence" + assert said("两地的退款率差异不大。", A1=nothing) == [] + assert said("西南的退款率显著高于华东。", A1=nothing) == [ac.MISMATCH] + + +async def test_a_refused_baseline_backs_no_judgement(): + short = await series("baseline", [100, 102, 98, 70]) + assert short["status"] == "refused" and short["verdict"] is None and short["reason_code"] == "history_short" + [miss] = ac.check_sentence(ac.Sentence(text="订单数本周异常下降。", analyses=["A1"]), {"A1": short}) + assert miss["code"] == ac.MISMATCH and "没有做成" in miss["message"] + + +# -------------------------------------------------------------------------- +# 经组装和复核:判定语、具名标量不扫,[[see:A1]] 当依据 +# -------------------------------------------------------------------------- + + +@pytest.fixture +async def driver_catalog(): + a1 = await additive() + catalog = build_catalog(nodes={"caliber": { + "kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2", "artifact": "f" * 64, + "metrics": [{"id": "gmv", "name": "销售额", "value": 2026, "unit": "元"}]}}) + artifact = "e" * 64 + catalog["A1"] = {"alias": "A1", "kind": "analysis", "artifact": artifact, "eid": make_eid("analysis", artifact, {}), + "locator": {}, "op": "decompose", "mode": "additive", "status": "ok", "item": "drop", + "inputs": [], "result": a1} + return catalog + + +@pytest.mark.parametrize("raw", [ + "[[a:A1]]。", + "[[a:A1]]:下降主要来自华东。", + "华东占变化的 [[a:A1.top_share|pct]],下降主要来自华东。[[see:A1]]", + "下降主要来自华东。[[see:A1.r0]]", + "下降主要来自华东。[[see:a:A1]]", +]) +def test_phrases_and_scalars_are_not_scanned_and_see_markers_bind(driver_catalog, raw): + doc = compose_doc(raw, driver_catalog, analysis_claims="check") + assert [v for v in doc["violations"] if v["code"] in ac.CODES] == [], doc["violations"] + assert [v for v in verify_doc(doc, driver_catalog)["violations"] if v["code"] in ac.CODES] == [] + + +def test_a_contradicting_sentence_next_to_the_phrase_is_caught(driver_catalog): + doc = compose_doc("[[a:A1]]。下降主要来自华南。[[see:A1]]", driver_catalog, analysis_claims="check") + [miss] = [v for v in doc["violations"] if v["code"] in ac.CODES] + assert miss["code"] == ac.MISMATCH and miss["analyses"] == ["A1"] and miss["unit"] and miss["segment"] + assert doc["markdown"][slice(*miss["span"])] == "主要来自" + # 存进文档的目录只留摘要:按工件取不回结论时只能当作没挂(交给复核的是重建的整份目录,这里看清楚退路) + stored = {k: v for k, v in doc["catalog"]["A1"].items() if k != "result"} + assert ac.results_of({"A1": stored}) == {} + assert ac.results_of({"A1": stored}, lambda alias, entry: driver_catalog["A1"]["result"])["A1"]["verdict"]["code"] \ + == "driver" diff --git a/backend/tests/test_report_analysis_claims.py b/backend/tests/test_report_analysis_claims.py index e87d154e..7c72ddfe 100644 --- a/backend/tests/test_report_analysis_claims.py +++ b/backend/tests/test_report_analysis_claims.py @@ -11,7 +11,6 @@ from __future__ import annotations import asyncio -import re import pytest from langchain_core.messages import AIMessage @@ -22,7 +21,6 @@ from app.core import artifact_store from app.db.base import SessionLocal from app.db.models import Run, RunEvent from app.engine import analysis_claims as ac -from app.engine import evidence from app.engine import runner as runner_mod from app.engine.evidence import make_eid from app.engine.nodes import report as report_mod @@ -206,7 +204,7 @@ async def test_the_writer_role_asks_for_where_changes_come_from_and_evidence_for # -------------------------------------------------------------------------- -# 目录里有分析结论(证据接入合进来之前,这里按它的约定把 A 条目放进目录、代为解析 [[see:A1]]) +# 目录里有分析结论 # -------------------------------------------------------------------------- @@ -218,7 +216,8 @@ def a_entry(alias: str, result: dict) -> dict: @pytest.fixture def analyses(monkeypatch): - """写作目录里多几条分析结论:{A 编号: 结论}。报告节点和出口复核按同一个 report_catalog 建目录,这里一处补上。""" + """写作目录里多几条分析结论:{A 编号: 结论}。台账里的分析结论由入口(分析卡、Agent 的分析工具)记,这里直接补进 + 目录:报告节点和出口复核按同一个 report_catalog 建目录,一处补上。""" added: dict[str, dict] = {} original = report_mod.report_catalog @@ -228,16 +227,6 @@ def analyses(monkeypatch): return out monkeypatch.setattr(report_mod, "report_catalog", catalog) - resolve = evidence.resolve_support - - def support(ref, catalog, *, loader=None): - ref = ref.strip() - if re.fullmatch(r"A\d+", ref) and (catalog.get(ref) or {}).get("kind") == "analysis": - return {"ref": ref, "role": "support", "alias": ref, "kind": "analysis", "status": "resolved", - "eid": catalog[ref]["eid"], "locator": {}} - return resolve(ref, catalog, loader=loader) - - monkeypatch.setattr(evidence, "resolve_support", support) return added