mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
582 lines
36 KiB
Python
582 lines
36 KiB
Python
"""分析说法核对(engine/analysis_claims.py,细案 01 §9):判断词要挂对应的分析结论、判定对得上,因果词要有依据。
|
||
|
||
- 说法表:每类的判断词认得出,常见的非判断写法(持续关注、异常订单、相当于、数据异常)不算;「大幅」收进异常类,看清它在
|
||
常见写法里的表现
|
||
- 否定:判断词前 4 个字以内(跳过填充词)有否定的翻到反面类;「不但」「不少」不是否定
|
||
- 绑定:类要的 op 在这句的依据里出现、判定对得上;点名的段在 subjects 里(全半角归一);拖累、拉动看那一段的方向;
|
||
新高新低看 caveat;「连续」看末尾连续的期数;挂了算不了的结论算对不上
|
||
- 来源与因果的分界:因果词点名的是拆解的段 → 按来源核;不是 → 按因果核;推测说法、知识库原话、运行输入豁免因果
|
||
- 不扫:判定语(a:)、知识库原话(q:)、别的引用渲染出来的字、标题、代码块、表头
|
||
- 文档:只有记了这一项的文档在复核时核,老文档复核结果不变;记了认不出的版本记 bad_schema
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from app.analysis.testing import fake_result
|
||
from app.engine import analysis_claims as ac
|
||
from app.engine import evidence
|
||
from app.engine.evidence import build_catalog, compose_doc, make_eid, verify_doc
|
||
|
||
|
||
def hits(text: str) -> list[tuple[str, str, str | None]]:
|
||
return [(h.claim, h.word, h.direction) for h in ac.scan(text)]
|
||
|
||
|
||
def claims(text: str) -> list[str]:
|
||
return [h.claim for h in ac.scan(text)]
|
||
|
||
|
||
def said(text: str, *analyses: str, results=None, **sentence) -> list[dict]:
|
||
"""一句话、挂了 analyses 这几份结论:违规清单。"""
|
||
return ac.check_sentence(ac.Sentence(text=text, analyses=list(analyses), **sentence), results or {})
|
||
|
||
|
||
def codes(found: list[dict]) -> list[str]:
|
||
return [v["code"] for v in found]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 说法表:每类认得出、常见的非判断写法不算
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize("text, claim", [
|
||
("下降主要来自华东。", "source"), ("增长主要由线上贡献。", "source"), ("下降集中在华东、华北。", "source"),
|
||
("增长绝大部分来自线下。", "source"), ("华东拖累了整体。", "source"), ("线上带动了增长。", "source"),
|
||
("华东贡献最大。", "source"), ("下降主要是华东下滑造成的。", "source"),
|
||
("变化分散在各地区。", "dispersed"), ("各地区都在下降。", "dispersed"), ("各渠道普遍下滑。", "dispersed"),
|
||
("全面下降。", "dispersed"),
|
||
("华东和华南此消彼长。", "offset"), ("两者互相抵消。", "offset"),
|
||
("退款率上升来自结构变化。", "mix"), ("这是结构性的上升。", "mix"),
|
||
("本周销售额异常下降。", "anomaly"), ("取消单激增。", "anomaly"), ("订单数骤降。", "anomaly"),
|
||
("销售额明显偏离往常。", "anomaly"), ("超出过去 12 周的常规波动范围。", "anomaly"),
|
||
("销售额明显高于正常水平。", "anomaly"), ("波动剧烈。", "anomaly"),
|
||
("本周创历史新高。", "record"), ("销售额创新高。", "record"), ("前所未有。", "record"),
|
||
("在常规波动范围内。", "normal"), ("属于正常波动。", "normal"), ("这一降幅并不异常。", "normal"),
|
||
("线上销售额持续上升。", "trend"), ("呈上升趋势。", "trend"), ("一路走低。", "trend"), ("稳步增长。", "trend"),
|
||
("连续三周下降。", "trend"), ("连续下滑。", "trend"),
|
||
("没有明显的上升或下降趋势。", "no_trend"), ("上升趋势不明显。", "no_trend"), ("基本平稳。", "no_trend"),
|
||
("西南的退款率显著高于华东。", "differ"), ("两组差异明显。", "differ"),
|
||
("两组差异不大。", "no_diff"), ("两组没有明显差异。", "no_diff"), ("两个渠道的退款率相当。", "no_diff"),
|
||
("由于促销结束,销售额下降。", "causal"), ("受天气影响,客流下降。", "causal"), ("增长得益于新客。", "causal"),
|
||
])
|
||
def test_every_class_is_recognized(text, claim):
|
||
assert claim in claims(text), hits(text)
|
||
|
||
|
||
@pytest.mark.parametrize("text", [
|
||
"建议持续关注华东的退款。", # 「持续」后面不是涨跌:建议
|
||
"异常订单 12 单。", # 「异常」后面是名词:业务分类
|
||
"本周没有发现数据异常。", # 数据质量
|
||
"系统无异常。",
|
||
"销售额相当于上周的两倍。", # 「相当于」
|
||
"华东的销售额相当高。", # 「相当」是副词
|
||
"这个问题引起关注。", # 「引起关注」不是因果
|
||
"主导地位没有变。", # 「主导地位」
|
||
"降幅较大。", # 不是「大幅」
|
||
"整体平稳。", # 只收「基本平稳」:宁可漏报
|
||
"趋势值得关注。", # 「趋势」不带涨跌
|
||
"华东销售额下降,华南销售额上升。", # 只陈述数字和变化
|
||
])
|
||
def test_ordinary_wording_is_not_a_judgement(text):
|
||
assert ac.scan(text) == []
|
||
|
||
|
||
def test_the_longest_reading_wins_where_two_overlap():
|
||
# 「超出正常范围」是异常,不是正常;「没有明显的上升或下降趋势」是无趋势,不是上升趋势
|
||
assert claims("超出正常范围。") == ["anomaly"]
|
||
assert claims("没有明显的上升或下降趋势。") == ["no_trend"]
|
||
assert claims("下降主要由华东造成。") == ["source", "causal"]
|
||
|
||
|
||
def test_the_word_carries_its_direction():
|
||
assert [h.run for h in ac.scan("连续三周下降,持续下降。")] == [True, False]
|
||
assert hits("取消单激增。") == [("anomaly", "激增", "up")]
|
||
assert hits("订单数骤降。") == [("anomaly", "骤降", "down")]
|
||
assert hits("线上销售额持续上升。") == [("trend", "持续上升", "up")]
|
||
assert hits("本周销售额异常下降。")[0][2] == "down" # 方向在判断词后面
|
||
assert hits("销售额下降,属于异常。")[0][2] is None # 分句里没有涨跌
|
||
assert hits("华东拖累了整体。")[0][2] == "down" and hits("线上带动了增长。")[0][2] == "up"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 「大幅」:收进异常类,看清它在常见写法里的表现
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize("text, direction", [
|
||
("本周销售额大幅增长。", "up"), ("订单数大幅下降。", "down"), ("退款大幅度减少。", "down"),
|
||
("客单价大幅提升,订单数基本不变。", "up"), ("销售额同比大幅下滑。", "down"),
|
||
])
|
||
def test_dafu_is_an_anomaly_claim_with_the_direction_of_its_verb(text, direction):
|
||
[(claim, word, got)] = hits(text)
|
||
assert claim == "anomaly" and word.startswith("大幅") and got == direction
|
||
|
||
|
||
@pytest.mark.parametrize("text", ["销售额大幅领先华南。", "幅度较大。", "大幅的促销海报已经撤下。"])
|
||
def test_dafu_without_a_change_verb_is_not_counted(text):
|
||
assert ac.scan(text) == []
|
||
|
||
|
||
def test_dafu_needs_a_baseline_outside_the_band_in_the_same_direction():
|
||
up = fake_result("baseline", None, "outside_band", direction="up")
|
||
down = fake_result("baseline", None, "outside_band", direction="down")
|
||
normal = fake_result("baseline", None, "within_band", direction="up")
|
||
text = "本周销售额大幅增长。"
|
||
assert codes(said(text)) == [ac.UNCITED] # 没挂:要挂波动基线
|
||
assert said(text, "A1", results={"A1": up}) == []
|
||
[miss] = said(text, "A1", results={"A1": down})
|
||
assert miss["code"] == ac.MISMATCH and "方向" in miss["message"]
|
||
[miss] = said(text, "A1", results={"A1": normal})
|
||
assert miss["code"] == ac.MISMATCH and "常规波动范围内" in miss["message"]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 否定
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize("text, claim", [
|
||
("订单数并未出现明显异常。", "normal"), # 否定隔着「出现明显」
|
||
("本周销售额没有出现异常。", "normal"),
|
||
("不在正常范围内。", "anomaly"),
|
||
("不属于正常波动。", "anomaly"),
|
||
("没有显著高于华东。", "no_diff"),
|
||
("下降并非主要来自华东。", "source_not"),
|
||
("下降并不分散。", "source"),
|
||
("没有持续上升。", "no_trend"),
|
||
])
|
||
def test_a_negation_flips_to_the_opposite_class(text, claim):
|
||
assert claims(text) == [claim]
|
||
|
||
|
||
@pytest.mark.parametrize("text", ["不但大幅增长,", "不少门店大幅下滑。", "无论是否大幅增长,"])
|
||
def test_words_that_only_look_negative_do_not_flip(text):
|
||
assert claims(text) == ["anomaly"]
|
||
|
||
|
||
def test_negated_causes_and_records_are_left_alone():
|
||
assert ac.scan("并非由于促销。") == [] and ac.scan("并非历史新高。") == []
|
||
|
||
|
||
def test_a_negated_source_needs_the_named_segment_outside_the_main_sources():
|
||
driver = fake_result() # 主要来自华东
|
||
assert said("下降并非主要来自华南。", "A1", results={"A1": driver}) == []
|
||
[miss] = said("下降并非主要来自华东。", "A1", results={"A1": driver})
|
||
assert miss["code"] == ac.MISMATCH and "华东" in miss["message"]
|
||
dispersed = fake_result(verdict="dispersed")
|
||
assert said("下降并非主要来自某一个地区。", "A1", results={"A1": dispersed}) == []
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 绑定
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_the_required_op_must_be_among_the_evidence():
|
||
baseline = fake_result("baseline", None, "outside_band")
|
||
[v] = said("下降主要来自华东。", "A1", results={"A1": baseline})
|
||
assert v["code"] == ac.UNCITED and v["op"] == "decompose" and v["claim"] == "source" and v["analyses"] == ["A1"]
|
||
# 有好几个时任一个对得上就算对
|
||
assert said("下降主要来自华东。", "A1", "A2", results={"A1": baseline, "A2": fake_result()}) == []
|
||
|
||
|
||
def test_named_segments_must_be_among_the_subjects():
|
||
concentrated = fake_result(verdict="concentrated", subjects=[["华东"], ["华北"]])
|
||
assert said("下降集中在华东、华北。", "A1", results={"A1": concentrated}) == []
|
||
[miss] = said("下降集中在华东、华南。", "A1", results={"A1": concentrated})
|
||
assert miss["code"] == ac.MISMATCH and "不是华南" in miss["message"]
|
||
# 别的分句里提到的段不算点名
|
||
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}) == []
|
||
# XAB 不是点名 AB、一个字的名字不认:都当没点名哪一段,说的来源不是拆解里的段
|
||
[miss] = said("下降主要来自XAB。", "A1", results={"A1": result})
|
||
assert miss["code"] == ac.MISMATCH and "不是这份拆解里的段" in miss["message"]
|
||
assert codes(said("下降主要来自A。", "A1", results={"A1": result})) == [ac.MISMATCH]
|
||
|
||
|
||
def test_named_segments_are_matched_across_full_and_half_width():
|
||
result = fake_result(subjects=[["A店"]])
|
||
result["table"]["rows"][0][0] = "A店"
|
||
assert said("下降主要来自A店。", "A1", results={"A1": result}) == []
|
||
assert codes(said("下降主要来自华北。", "A1", results={"A1": result})) == [ac.MISMATCH]
|
||
|
||
|
||
def test_a_dispersed_verdict_cannot_back_a_main_source():
|
||
[miss] = said("下降主要来自华东。", "A3", results={"A3": fake_result(verdict="dispersed")})
|
||
assert miss["code"] == ac.MISMATCH and miss["text"] == "主要来自"
|
||
assert "A3" in miss["message"] and "分散在各地区" in miss["message"]
|
||
assert "[[a:A3]]" in miss["for_model"] and "不能写「主要来自华东」" in miss["for_model"]
|
||
|
||
|
||
def test_drag_and_pull_follow_the_direction_of_the_segment():
|
||
up = fake_result(direction="up")
|
||
up["table"]["rows"][0][3] = "120" # 华东 +120
|
||
assert said("华东带动了增长。", "A1", results={"A1": up}) == []
|
||
[miss] = said("华东拖累了整体。", "A1", results={"A1": up})
|
||
assert miss["code"] == ac.MISMATCH and "上升" in miss["message"]
|
||
assert said("华东拖累了整体。", "A1", results={"A1": fake_result()}) == [] # 华东 −120
|
||
|
||
|
||
def test_a_drag_outside_the_main_sources_counts_when_it_is_a_large_share():
|
||
# 整体上涨、华南是主要来源,华东下跌拖累了涨幅:华东的变化占总变化过半(绝对值)也算拖累
|
||
result = fake_result(subjects=[["华南"]], direction="up")
|
||
result["table"]["rows"][0][3:6] = ["-120", "-0.1463", "-1.5"]
|
||
assert said("华东拖累了涨幅。", "A1", results={"A1": result}) == []
|
||
result["table"]["rows"][0][5] = "-0.2"
|
||
assert codes(said("华东拖累了涨幅。", "A1", results={"A1": result})) == [ac.MISMATCH]
|
||
|
||
|
||
def test_records_need_the_caveat_in_the_same_direction():
|
||
high = fake_result("baseline", None, "far_outside_band", direction="up",
|
||
caveats=[{"code": "record_high", "text": "本期是历史最高"}])
|
||
assert said("本周创历史新高。", "A1", results={"A1": high}) == []
|
||
assert codes(said("本周创历史新低。", "A1", results={"A1": high})) == [ac.MISMATCH]
|
||
plain = fake_result("baseline", None, "outside_band", direction="up")
|
||
assert codes(said("本周创历史新高。", "A1", results={"A1": plain})) == [ac.MISMATCH]
|
||
|
||
|
||
def test_trends_check_the_direction_and_runs_check_the_length():
|
||
rising = fake_result("trend", None, "rising", direction="up", values={"run_length": "4", "run_direction": "up"})
|
||
assert said("线上销售额持续上升。", "A1", results={"A1": rising}) == []
|
||
assert codes(said("线上销售额持续下降。", "A1", results={"A1": rising})) == [ac.MISMATCH]
|
||
assert said("线上销售额连续四周上升。", "A1", results={"A1": rising}) == []
|
||
short = fake_result("trend", None, "rising", direction="up", values={"run_length": "2", "run_direction": "up"})
|
||
[miss] = said("线上销售额连续上升。", "A1", results={"A1": short})
|
||
assert miss["code"] == ac.MISMATCH and "连续" in miss["message"]
|
||
flat = fake_result("trend", None, "no_trend", direction=None)
|
||
assert said("没有明显的上升或下降趋势。", "A1", results={"A1": flat}) == []
|
||
assert codes(said("线上销售额持续上升。", "A1", results={"A1": flat})) == [ac.MISMATCH]
|
||
|
||
|
||
def test_runs_follow_the_run_direction_and_the_number_of_periods_they_claim():
|
||
"""「连续 N 期上升」按趋势结论记的末尾连续同向核(run_direction、run_length):方向一致、期数不少于 N(没写 N 的至少
|
||
3 期),不看整体的趋势判定——判定语在方向相反时写「但最近已连续 N 期上升」。"""
|
||
rising = fake_result("trend", None, "rising", direction="up", values={"run_length": "4", "run_direction": "up"})
|
||
assert said("线上销售额已连续 4 周上升。", "A1", results={"A1": rising}) == []
|
||
[miss] = said("线上销售额持续增长,已连续 5 周上升。", "A1", results={"A1": rising})
|
||
assert miss["code"] == ac.MISMATCH and miss["claim"] == "trend" and "只有 4 期,不够说连续 5 期" in miss["message"]
|
||
# 整体下降、最近连续上升:「连续上升」对得上,「持续上升」对不上
|
||
turned = fake_result("trend", None, "falling", direction="down", values={"run_length": "4", "run_direction": "up"})
|
||
assert said("订单数最近已连续四周上升。", "A1", results={"A1": turned}) == []
|
||
assert codes(said("订单数已连续三周下降。", "A1", results={"A1": turned})) == [ac.MISMATCH]
|
||
assert codes(said("订单数持续上升。", "A1", results={"A1": turned})) == [ac.MISMATCH]
|
||
# 没有明显趋势、末尾连续下降:也能说连续
|
||
flat = fake_result("trend", None, "no_trend", direction=None, values={"run_length": "3", "run_direction": "down"})
|
||
assert said("订单数三连降。", "A1", results={"A1": flat}) == []
|
||
assert codes(said("订单数四连降。", "A1", results={"A1": flat})) == [ac.MISMATCH]
|
||
assert codes(said("订单数三连涨。", "A1", results={"A1": flat})) == [ac.MISMATCH]
|
||
# 没记 run_direction 的老结论按整体的趋势方向
|
||
old = fake_result("trend", None, "rising", direction="up", values={"run_length": "3"})
|
||
assert said("订单数连续上升。", "A1", results={"A1": old}) == []
|
||
assert codes(said("订单数连续下降。", "A1", results={"A1": old})) == [ac.MISMATCH]
|
||
|
||
|
||
def test_differences_check_the_verdict_and_the_order_of_the_two_groups():
|
||
differs = fake_result("differ", "rate", "differs", subjects=[["西南"], ["华东"]], direction="up")
|
||
assert said("西南的退款率显著高于华东。", "A1", results={"A1": differs}) == []
|
||
assert said("华东的退款率显著低于西南。", "A1", results={"A1": differs}) == []
|
||
[miss] = said("西南的退款率显著低于华东。", "A1", results={"A1": differs})
|
||
assert miss["code"] == ac.MISMATCH and "西南高于华东" in miss["message"]
|
||
nothing = fake_result("differ", "rate", "no_evidence", subjects=[["西南"], ["华东"]], direction=None)
|
||
assert said("两组差异不大。", "A1", results={"A1": nothing}) == []
|
||
assert codes(said("西南的退款率显著高于华东。", "A1", results={"A1": nothing})) == [ac.MISMATCH]
|
||
|
||
|
||
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"]
|
||
mix = fake_result(mode="ratio", verdict="mix_driven")
|
||
assert said("退款率上升来自结构变化。", "A1", results={"A1": mix}) == []
|
||
|
||
|
||
def test_offsets_need_the_caveat():
|
||
assert codes(said("华东和华南此消彼长。", "A1", results={"A1": fake_result()})) == [ac.MISMATCH]
|
||
offset = fake_result(caveats=[{"code": "offset", "text": "下降的段合计 −120"}])
|
||
assert said("华东和华南此消彼长。", "A1", results={"A1": offset}) == []
|
||
|
||
|
||
def test_a_refused_conclusion_backs_nothing():
|
||
refused = fake_result("baseline", None, None, status="refused", reason_code="history_short")
|
||
[miss] = said("本周销售额异常下降。", "A1", results={"A1": refused})
|
||
assert miss["code"] == ac.MISMATCH and "没有做成" in miss["message"] and "历史只有 5 期" in miss["message"]
|
||
|
||
|
||
def test_uncited_advice_points_at_a_conclusion_that_exists():
|
||
[v] = said("下降主要来自华东。", results={"A2": fake_result()})
|
||
assert "[[see:A2]]" in v["for_model"] and "[[a:A2]]" in v["for_model"]
|
||
[v] = said("下降主要来自华东。")
|
||
assert "这次没有变化拆解的结论" in v["for_model"] and "只写各段各自的变化" in v["for_model"]
|
||
# 结构变化只推荐比率拆解;算不了的结论不推荐
|
||
[v] = said("这是结构性的上升。", results={"A2": fake_result(), "A3": fake_result(mode="ratio", verdict="both")})
|
||
assert "[[see:A3]]" in v["for_model"]
|
||
[v] = said("下降主要来自华东。", results={"A2": fake_result(status="refused")})
|
||
assert "[[see:A2]]" not in v["for_model"]
|
||
|
||
|
||
def test_one_violation_per_class_and_clause():
|
||
found = said("由于促销结束导致下降。")
|
||
assert codes(found) == [ac.CAUSAL] and found[0]["text"] == "由于"
|
||
assert "「由于促销结束导致下降」" in found[0]["message"]
|
||
assert codes(said("由于促销结束,下降;由于天气,客流减少。")) == [ac.CAUSAL, ac.CAUSAL]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 来源与因果
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_a_cause_naming_a_segment_of_the_decomposition_is_checked_as_a_source():
|
||
driver = fake_result()
|
||
assert said("下降主要由华东造成。", "A1", results={"A1": driver}) == []
|
||
assert said("增长得益于华东。", "A1", results={"A1": fake_result(direction="up")}) == []
|
||
[miss] = said("下降是华南造成的。", "A1", results={"A1": driver})
|
||
assert miss["code"] == ac.MISMATCH and miss["claim"] == "source"
|
||
|
||
|
||
def test_a_cause_outside_the_decomposition_needs_its_own_evidence():
|
||
[v] = said("由于促销结束导致下降。", "A1", results={"A1": fake_result()})
|
||
assert v["code"] == ac.CAUSAL and v["claim"] == "causal" and "op" not in v
|
||
assert "可能与…有关,待核实" in v["for_model"]
|
||
|
||
|
||
@pytest.mark.parametrize("sentence", [
|
||
{"text": "可能与促销结束有关,待核实。"},
|
||
{"text": "下降或许是由于促销结束。"},
|
||
{"text": "疑似由于促销结束导致下降。"},
|
||
{"text": "由于促销结束导致下降。", "quoted": True},
|
||
{"text": "由于促销结束导致下降。", "inputs": True},
|
||
{"text": "由于数据只到 10 月 16 日,本周不完整。"},
|
||
{"text": "由于样本少,差异不能下结论。"},
|
||
{"text": "以上是本周下降的主要原因。"}, # 原因只是指代:列出原因的那几句各自核
|
||
{"text": "下降的原因主要是:"},
|
||
])
|
||
def test_guesses_quotes_inputs_and_data_caveats_are_not_unsupported_causes(sentence):
|
||
assert ac.check_sentence(ac.Sentence(**sentence), {}) == []
|
||
|
||
|
||
@pytest.mark.parametrize("text, found", [
|
||
("下降主要由于促销结束。", [(ac.CAUSAL, "causal")]), # 「主要由」不吃掉「由于」
|
||
("华东下降主要由于促销结束。", [(ac.CAUSAL, "causal")]), # 原因在「由于」后面,前面的华东不算
|
||
("促销结束导致华东下降。", [(ac.CAUSAL, "causal")]), # 原因在「导致」前面
|
||
("华东受促销结束影响下降。", [(ac.CAUSAL, "causal")]), # 原因在「受…影响」中间
|
||
("下降主要由促销结束造成。", [(ac.MISMATCH, "source"), (ac.CAUSAL, "causal")]),
|
||
("下降主要来自线上渠道。", [(ac.MISMATCH, "source")]), # 说的来源不是拆解(按地区)里的段
|
||
("下降主要来自新客流失。", [(ac.MISMATCH, "source")]),
|
||
("华东下降主要来自线上渠道。", [(ac.MISMATCH, "source")]), # 「主要来自」看后面
|
||
("下降主要由华东造成。", []),
|
||
("华东是本周下降的主要原因。", []), # 原因点名的是段:按来源核
|
||
("下降的主要原因是华东门店客流减少。", []),
|
||
("华南是本周下降的主要原因。", [(ac.MISMATCH, "source")]),
|
||
("下降主要源于华东。", []),
|
||
("华东下滑,拖累了整体。", []), # 拖累看整个分句,没点名的按主要来源
|
||
])
|
||
def test_sources_and_causes_attached_to_a_decomposition(text, found):
|
||
"""挂了按地区拆解、判「主要来自华东」的结论(复核 R4):原因按因果词的那一边认;说的来源得是拆解里的段。"""
|
||
assert [(v["code"], v["claim"]) for v in said(text, "A1", results={"A1": fake_result()})] == found
|
||
|
||
|
||
def test_a_source_needs_no_name_when_the_clause_quotes_the_phrase_or_says_structure():
|
||
driver = fake_result()
|
||
phrase = ac.Sentence(text="下降主要来自以下地区:\x00\x00\x00\x00。", analyses=["A1"], phrases=[(11, 15, "A1")])
|
||
assert ac.check_sentence(phrase, {"A1": driver}) == []
|
||
elsewhere = ac.Sentence(text="\x00\x00\x00\x00,下降主要来自这一地区。", analyses=["A1"], phrases=[(0, 4, "A1")])
|
||
assert codes(ac.check_sentence(elsewhere, {"A1": driver})) == [ac.MISMATCH], "判定语在别的分句"
|
||
mix = fake_result(mode="ratio", verdict="mix_driven", subjects=[["华东"]])
|
||
assert said("退款率上升主要来自结构变化。", "A1", results={"A1": mix}) == []
|
||
rate = fake_result(mode="ratio", verdict="rate_driven", subjects=[["华东"]])
|
||
assert said("退款率上升主要来自各段自身的变化。", "A1", results={"A1": rate}) == []
|
||
assert [v["claim"] for v in said("退款率上升主要来自结构变化。", "A1", results={"A1": rate})] == ["source", "mix"]
|
||
|
||
|
||
def test_before_side_sources_look_back_to_the_nearest_named_clause():
|
||
driver = fake_result()
|
||
assert said("华东门店表现最弱,贡献最大。", "A1", results={"A1": driver}) == []
|
||
assert said("华南基本持平,华东贡献最大。", "A1", results={"A1": driver}) == [], "最近的分句点了名就不再往前找"
|
||
assert codes(said("新客贡献最大。", "A1", results={"A1": driver})) == [ac.MISMATCH]
|
||
|
||
|
||
@pytest.mark.parametrize("text, results, found", [
|
||
("下降主要由华东造成。", {}, [ac.UNCITED]), # 没有拆解可比:并进来源
|
||
("下降主要由华东造成。", {"A2": fake_result()}, [ac.UNCITED]), # 华东是目录里拆解的段:并进来源
|
||
("下降主要由促销结束造成。", {"A2": fake_result()}, [ac.UNCITED, ac.CAUSAL]),
|
||
("下降主要由促销结束造成。", {}, [ac.UNCITED]),
|
||
])
|
||
def test_without_a_decomposition_attached_a_cause_merges_into_the_source_only_when_it_could_be_one(text, results,
|
||
found):
|
||
assert codes(said(text, results=results)) == found
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 文档:哪里不扫、怎么挂、版本
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def a_entry(alias: str, result: dict) -> dict:
|
||
artifact = "a" * 63 + alias[-1]
|
||
return {"alias": alias, "kind": "analysis", "artifact": artifact, "eid": make_eid("analysis", artifact, {}),
|
||
"locator": {}, "result": result}
|
||
|
||
|
||
def catalog(**analyses) -> dict:
|
||
out = build_catalog(nodes={"caliber": {
|
||
"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2", "artifact": "f" * 64,
|
||
"metrics": [{"id": "gmv", "name": "销售额", "value": 45678.5, "unit": "元", "decimals": 1}]}},
|
||
inputs={"promo": "满减"})
|
||
out.update({alias: a_entry(alias, result) for alias, result in analyses.items()})
|
||
return out
|
||
|
||
|
||
def found(doc: dict) -> list[dict]:
|
||
return [v for v in doc["violations"] if v["code"] in ac.CODES]
|
||
|
||
|
||
def test_a_doc_records_the_mode_and_lexicon_only_when_asked():
|
||
raw = "本周销售额 [[m:gmv]],下降主要来自华东。[[see:m:gmv]]"
|
||
doc = compose_doc(raw, catalog(), analysis_claims="check")
|
||
assert doc[ac.DOC_FIELD] == {"mode": "check", "lexicon": ac.LEXICON_VERSION}
|
||
[v] = found(doc)
|
||
assert v["code"] == ac.UNCITED and doc["markdown"][slice(*v["span"])] == "主要来自"
|
||
assert v["unit"] and v["segment"] and v["context"] == "本周销售额 45,678.5元,下降主要来自华东。"
|
||
assert doc["stats"]["analysis_claims"] == 1
|
||
# 没要求(off、老调用):文档一字不差,统计也不多这个键
|
||
for mode in (None, "off"):
|
||
plain = compose_doc(raw, catalog(), analysis_claims=mode)
|
||
assert ac.DOC_FIELD not in plain and found(plain) == [] and "analysis_claims" not in plain["stats"]
|
||
|
||
|
||
def test_old_documents_verify_exactly_as_before():
|
||
raw = "本周销售额 [[m:gmv]],下降主要来自华东,由于促销结束。[[see:m:gmv]]"
|
||
old = compose_doc(raw, catalog())
|
||
assert verify_doc(old, catalog()) == verify_doc(compose_doc(raw, catalog()), catalog())
|
||
assert verify_doc(old, catalog())["violations"] == [] and "analysis_claims" not in verify_doc(old, catalog())["stats"]
|
||
new = compose_doc(raw, catalog(), analysis_claims="strict")
|
||
again = verify_doc(new, catalog())
|
||
assert [v["code"] for v in again["violations"]] == [ac.UNCITED, ac.CAUSAL]
|
||
assert again["stats"]["analysis_claims"] == 2 and again["violations"] == new["violations"]
|
||
|
||
|
||
def test_an_unrecognized_record_is_reported_and_checked_with_the_current_lexicon():
|
||
doc = compose_doc("下降主要来自华东。", catalog(), analysis_claims="check")
|
||
doc[ac.DOC_FIELD] = {"mode": "check", "lexicon": 99}
|
||
checked = verify_doc(doc, catalog())
|
||
assert [v["code"] for v in checked["violations"]] == ["bad_schema", ac.UNCITED]
|
||
assert "分析说法核对规则" in checked["violations"][0]["message"]
|
||
|
||
|
||
def test_system_rendered_phrases_quotes_headings_and_code_are_not_scanned():
|
||
doc = {"markdown": "", "blocks": [
|
||
{"type": "heading", "units": [{"id": "u0", "kind": "heading", "segments": [
|
||
{"id": "s0", "kind": "text", "text": "下降主要来自华东", "span": [0, 8]}]}]},
|
||
{"type": "code", "units": [{"id": "u1", "kind": "code", "segments": [
|
||
{"id": "s1", "kind": "text", "text": "-- 异常订单激增", "span": [8, 17]}]}]},
|
||
{"type": "paragraph", "units": [{"id": "u2", "kind": "claim", "segments": [
|
||
{"id": "s2", "kind": "value", "text": "销售额下降 194 万,主要来自华东(-120 万,占 62%)", "ref": "a:A1"},
|
||
{"id": "s3", "kind": "text", "text": "。售后说「"},
|
||
{"id": "s4", "kind": "quote", "text": "退款主要集中在东区,由于物流延误", "ref": "q:K1|退款主要集中在东区,由于物流延误",
|
||
"state": "deterministic"},
|
||
{"id": "s5", "kind": "text", "text": "」。"}]}]},
|
||
{"type": "table", "units": [{"id": "u3", "kind": "connective", "loc": {"row": -1, "col": 0}, "segments": [
|
||
{"id": "s6", "kind": "text", "text": "是否异常"}]}]},
|
||
]}
|
||
assert ac.check_doc(doc, catalog()) == []
|
||
# 同一句换成写作者自己写的字就核
|
||
doc["blocks"][2]["units"][0]["segments"][0] = {"id": "s2", "kind": "text", "text": "销售额下降,主要来自华东"}
|
||
assert codes(ac.check_doc(doc, catalog())) == [ac.UNCITED]
|
||
|
||
|
||
def test_table_cells_written_by_the_writer_are_not_checked():
|
||
"""表格的格不核(表头、正文都不核):一格是一个值不是一句话,状态列的「异常」、说明列的「主要来自线上」不是判断
|
||
(judge.claim_priority 也不送表格的格)。表格外的段落、列表照核。"""
|
||
raw = ("本周销售额 [[m:gmv]]。[[see:m:gmv]]\n\n| 门店 | 状态 | 说明 |\n|---|---|---|\n| 甲店 | 异常 | 主要来自线上 |\n"
|
||
"| 乙店 | 正常 | 持续增长 |\n\n```\n异常订单激增\n```\n\n## 主要来自华东的下降\n\n- 华东拖累明显\n")
|
||
doc = compose_doc(raw, catalog(), analysis_claims="check")
|
||
tables = {u["id"] for b in doc["blocks"] if b["type"] == "table" for u in b["units"]}
|
||
assert len(tables) == 9, "表格切成了一格一格"
|
||
[v] = found(doc)
|
||
assert v["code"] == ac.UNCITED and v["text"] == "拖累" and v["unit"] not in tables
|
||
assert [v["text"] for v in found(verify_doc(doc, catalog()))] == ["拖累"]
|
||
|
||
|
||
def test_rendered_values_name_segments_but_are_not_judgement_words():
|
||
# [[v:…]] 渲染出来的「华东」能点名,渲染出来的「异常」不是写作者下的判断
|
||
doc = {"markdown": "", "blocks": [{"type": "paragraph", "units": [{"id": "u0", "kind": "claim", "cites": ["A1"],
|
||
"segments": [
|
||
{"id": "s0", "kind": "text", "text": "下降主要来自", "span": [0, 6]},
|
||
{"id": "s1", "kind": "value", "text": "华南", "ref": "v:Q1.r1.region", "span": [6, 8]},
|
||
{"id": "s2", "kind": "text", "text": ",状态为", "span": [8, 12]},
|
||
{"id": "s3", "kind": "value", "text": "异常", "ref": "v:Q1.r1.status", "span": [12, 14]}]}]}]}
|
||
[miss] = ac.check_doc(doc, {"A1": a_entry("A1", fake_result())})
|
||
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():
|
||
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"] == []
|
||
bad = compose_doc("销售额 [[m:gmv]],下降主要来自华南。[[see:A1]]", cat, analysis_claims="check")
|
||
[miss] = found(bad)
|
||
assert miss["code"] == ac.MISMATCH and miss["analyses"] == ["A1"]
|
||
# 复核按这次重新解析的引用算,不信文档里记的 cites:依据删掉了就是没挂
|
||
tampered = compose_doc("销售额 [[m:gmv]],下降主要来自华东。[[see:A1]]", cat, analysis_claims="check")
|
||
tampered["blocks"][0]["units"][0]["see"] = []
|
||
assert [v["code"] for v in verify_doc(tampered, cat)["violations"]] == [ac.UNCITED]
|
||
|
||
|
||
@pytest.mark.parametrize("raw, want", [
|
||
("销售额 [[m:gmv]],下降主要来自线上渠道。[[see:A1]]", [(ac.MISMATCH, "主要来自")]),
|
||
("销售额 [[m:gmv]],下降主要来自新客流失。[[see:A1]]", [(ac.MISMATCH, "主要来自")]),
|
||
("销售额 [[m:gmv]],下降主要由于促销结束。[[see:A1]]", [(ac.CAUSAL, "由于")]),
|
||
("销售额 [[m:gmv]],下降主要由促销结束造成。[[see:A1]]", [(ac.MISMATCH, "主要由"), (ac.CAUSAL, "造成")]),
|
||
("销售额 [[m:gmv]],下降主要来自华东。[[see:A1]]", []),
|
||
("销售额 [[m:gmv]],下降主要来自 [[v:A1.r0.region]]。[[see:A1]]", []),
|
||
("销售额 [[m:gmv]],下降主要来自以下地区:[[a:A1]]。", []),
|
||
("[[a:A1]],下降主要来自这一地区。", [(ac.MISMATCH, "主要来自")]),
|
||
])
|
||
def test_strict_documents_close_the_source_and_cause_loopholes(raw, want):
|
||
"""复核 R4 的几个口子(strict 下挂一份按地区拆解、判「主要来自华东」的 A1):经组装和复核都核出来。"""
|
||
cat = catalog(A1=fake_result())
|
||
doc = compose_doc(raw, cat, analysis_claims="strict")
|
||
assert [(v["code"], v["text"]) for v in found(doc)] == want
|
||
assert [(v["code"], v["text"]) for v in found(verify_doc(doc, cat))] == want
|
||
|
||
|
||
def test_inputs_and_quotes_in_the_same_sentence_support_a_cause():
|
||
doc = compose_doc("由于本周有 [[i:promo]] 活动,销售额 [[m:gmv]]。[[see:m:gmv]]", catalog(), analysis_claims="check")
|
||
assert found(doc) == []
|
||
doc = compose_doc("由于本周有活动,销售额 [[m:gmv]]。[[see:m:gmv,i:promo]]", catalog(), analysis_claims="check")
|
||
assert found(doc) == []
|
||
doc = compose_doc("由于本周有活动,销售额 [[m:gmv]]。[[see:m:gmv]]", catalog(), analysis_claims="check")
|
||
assert codes(found(doc)) == [ac.CAUSAL]
|
||
|
||
|
||
def test_the_prompt_rules_depend_on_whether_there_are_conclusions():
|
||
assert ac.rules_prompt(catalog()) == ac.ANALYSIS_NONE_PROMPT
|
||
assert ac.rules_prompt(catalog(A1=fake_result())) == ac.ANALYSIS_RULES_PROMPT
|
||
assert "只陈述数字和变化" in ac.ANALYSIS_NONE_PROMPT and "待核实" in ac.ANALYSIS_NONE_PROMPT
|
||
assert "[[a:A1]]" in ac.ANALYSIS_RULES_PROMPT
|
||
|
||
|
||
def test_judgement_words_rank_claims_for_the_judge_but_do_not_make_sentences_claims():
|
||
"""判断词只管裁判送判的先后(judge._JUDGEMENT):一句话算不算结论仍按原来的方向词、因果词(evidence._DIRECTIONAL),
|
||
结论句策略要求挂依据、出具缺口照它数——扩了就改了已发布版本的档位。"""
|
||
from app.engine.judge import claim_priority
|
||
|
||
doc = compose_doc("华东异常。\n\n华东异常,退款 12 单。\n\n下面是主要渠道的表现。\n\n促销期间活动效果显著。\n\n"
|
||
"整体运行平稳,未见异常。\n\n建议持续关注。\n\n近 8 周的趋势图如下:\n\n华东退款 12 单。", catalog())
|
||
kinds = {doc["markdown"][slice(*u["span"])]: (u["kind"], claim_priority(u, b, doc["markdown"]))
|
||
for b, u in evidence.iter_units(doc)}
|
||
for text in ("华东异常。", "下面是主要渠道的表现。", "促销期间活动效果显著。", "整体运行平稳,未见异常。", "建议持续关注。",
|
||
"近 8 周的趋势图如下:"):
|
||
assert kinds[text] == ("connective", None), text
|
||
assert doc["stats"]["uncited_claims"] == 2, "只有两句带数字的算结论"
|
||
assert kinds["华东异常,退款 12 单。"] == ("claim", 2), "结论句里的判断词让裁判先判它"
|
||
assert kinds["华东退款 12 单。"] == ("claim", 3)
|