mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:41:58 +00:00
说法核对接上证据接入(W1-b)和真算子(W1-a):一句的分析依据按引用的编号认(A 开头、目录条目 kind analysis),判定语 [[a:A1]]、具名标量 [[a:A1.键]] 整段不扫,[[see:A1]]、[[see:A1.r0]]、[[see:a:A1]] 都当依据;目录条目只带摘要的(存进文档的那份)复核时按工件取回整份结论(_Snapshots.analysis)。算子、模式的叫法从契约取(OP_LABELS、method_label)。比率拆解的结构为主、各段自身为主(判定语就说「主要来自…」)也能挂来源类判断;ASCII 的段名认整词时,挨着的汉字不算连着。去掉测试里代为解析 [[see:A…]] 的替身;新增挂真算子结论的绑定测试:可加拆解的主要来源与拖累带动、分散、持平带抵消、比率拆解的结构变化、乘积拆解的因子、波动基线的方向和新高新低与正常波动、趋势的方向和连续期数、差异检验两组的先后、算不了的基线,以及经组装和复核时判定语、具名标量不扫
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user