分析说法核对的说法表与绑定(engine/analysis_claims):判断词分来源、分散、抵消、结构、异常、新高新低、正常、趋势、无趋势、差异、无差异、因果几类,一处定义、带版本(LEXICON_VERSION 1);只收强判断词(「持续」要跟着涨跌、「异常」后面是订单数据这类名词或前面是数据系统的不算、「相当」只认句读前的,「大幅」按细案收进异常类、要跟着涨跌动词);否定(前 4 个字以内、跳过「出现」「明显」这类填充词,「不但」「不少」「无论」不算)翻到反面类;来源与因果分界(因果词所在分句点名的是拆解的段或因子按来源核),推测说法、同句的知识库原话和运行输入豁免因果,说的是数据本身的原因不算;不扫判定语(a:)、知识库原话(q:)、别的引用渲染出来的字、标题、代码块、表头。绑定:一句的分析依据按这次重新解析的引用算,A 条目经 contract.catalog_result 读,核判定、点名的段(全半角归一)、拖累拉动的方向、新高新低、连续的期数,挂了算不了的结论算对不上;违规三种(analysis_claim_uncited / analysis_claim_mismatch / causal_unsupported),每条带判断词、句子、类、要的 op、这句挂的 A、给人看的和交回写作者的各一句,同一分句同一类只报一处。挂点:compose_doc 收 analysis_claims(check / strict)时文档顶层记下处置和说法表版本,verify_doc 只核记了这一项的文档(按记下的版本,认不出的记 bad_schema),老文档复核结果和统计一字不变;_DIRECTIONAL 补主要、集中、异常、偏离、持续、趋势、显著、明显高于 / 低于等预筛词(列提纲、建议、引出图表的写法除外),裁判优先判这类句子

This commit is contained in:
ThearchyHelios
2026-10-21 13:11:56 +08:00
parent 38906a92ea
commit 7acee0faed
3 changed files with 1377 additions and 1 deletions
+878
View File
@@ -0,0 +1,878 @@
"""分析说法核对(docs/design/analysis-round/00-总方案.md §3.5,细案 01 §9):报告里的判断词要挂对应的分析结论,判定对得上。
「叙述层无算术」往前走一步:「主要来自华东」「超出常规波动」「持续上升」这类判定和数字一样由系统算、由系统渲染([[a:A3]]),
写作者只引用。写作者自己写了判断词的,这里确定地核对(不调模型,D6):
1. **说法表**(LEXICONS,一处定义,带版本 LEXICON_VERSION):判断词分成来源、分散、抵消、结构、异常、新高新低、正常、
趋势、无趋势、差异、无差异、因果几类,每类要挂一种分析(op),判定要对得上(细案 01 §9.1)。只收强判断词,宁可漏报
不误报:「持续」要跟着涨跌(「持续关注」不算),「异常」后面接着订单、数据这类名词的是业务分类、前面是数据、系统的是
数据质量,都不算;「相当」只认句读前的(「相当于」「相当高」不算)
2. **否定**:判断词前 4 个字以内(跳过「出现」「明显」这类填充词)有「不、没有、没、并非、并不、未、无」的翻到反面类:
「并未出现明显异常」是正常,「没有明显的上升趋势」是无趋势,「并非主要来自华东」要拆解、华东不在主要来源里;
「不但」「不少」「无论」「未来」这类不是否定
3. **怎么挂**:一句话的分析依据 = 句中的 [[a:]]、[[v:A…]]、[[table:A…]] 和句末 [[see:A…]](组装、复核时按重新解析的
引用算,见 evidence.verify_doc)。类要的 op 必须在依据里出现,几个里任一个对得上就算对;挂了算不了的结论算对不上
4. **点名的段**:判断词所在分句里出现的结论结果表的维度值(全半角归一,两个字以上、不是纯数字)。来源类要点名的段都在
判定的 subjects 里;拖累要那一段变化为负,拉动、带动为正(不在 subjects 里、但那一段的变化占总变化过半的也算拖累)
5. **来源和因果的分界**:句子挂了拆解、因果词所在分句点名的是这份结论的段或因子(「下降主要由华东造成」)→ 按来源核,
这是记账意义上的「来自」;点名的不是(「由于促销结束导致下降」)→ 按因果核:同一句要有知识库原话([[q:]])或运行
输入([[i:]])作依据,或者写成推测(可能、或许、推测、待核实、不排除、疑似)。分句里已有来源一类判断词的,因果词
并进那一处核。原因说的是数据本身(「由于数据只到 10 月 16 日」「样本少」)的不算
6. **不扫的地方**:知识库原话([[q:]] 片段)、系统渲染的判定语([[a:]] 片段)、别的引用渲染出来的字、标题、代码块、
表头;分析范围是文档的结构化字段(analysis_scope),本来就不在正文里
违规三种(CODES):analysis_claim_uncited(有判断词,依据里没有对应种类的分析结论)、analysis_claim_mismatch(挂了,判定、
点名的段、方向对不上,含挂了算不了的结论)、causal_unsupported(因果词没有依据、也不是推测)。每条带:代码、判断词
(text)、所在句子(context)、类(claim)、要的 op、这句挂的分析(analyses)、给人看的一句(message)和交回写作者的
一句(for_model)。
报告文档记下按哪种处置、哪一版说法表核(DOC_FIELD:{"mode": check / strict, "lexicon": 1},参与内容哈希):只有带这一项
的文档在复核时核这一项(verify),老文档复核结果不变;复核按文档记下的那一版说法表,说法表升了版老文档照旧。
整个模块是纯函数,不碰数据库、不调模型。
"""
from __future__ import annotations
import re
import unicodedata
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
from typing import Any
from app.analysis.contract import catalog_result
from app.engine.evidence import _Reason, _violation, iter_units
#: 说法表的版本:改了判断词、类、绑定规则就升版本,老版本留在 LEXICONS 里给老文档复核用
LEXICON_VERSION = 1
#: 报告节点 analysis_claims 的取值里要核的两种(off 不核、文档里也不记)
MODES = ("check", "strict")
#: 报告文档里记下核对处置和说法表版本的字段
DOC_FIELD = "analysis_claims"
UNCITED = "analysis_claim_uncited"
MISMATCH = "analysis_claim_mismatch"
CAUSAL = "causal_unsupported"
CODES = frozenset({UNCITED, MISMATCH, CAUSAL})
#: 各算子给人看的叫法(违规说法里用)
_OP_TEXT = {"decompose": "变化拆解", "baseline": "波动基线", "trend": "趋势", "differ": "差异检验",
"comparable": "可比性检查"}
# --------------------------------------------------------------------------
# 说法表 v1
# --------------------------------------------------------------------------
#: 涨跌的说法。「扩大」「缩小」「收窄」不收:亏损扩大、差距缩小说不清是哪个方向
_UP = "上升|增长|增加|提升|提高|上涨|攀升|走高|上扬|走强|回升|反弹|增多|上行|抬升|拉升"
_DOWN = "下降|下滑|减少|降低|下跌|走低|回落|走弱|萎缩|减弱|下行|下挫|走软|缩水"
_DIR = f"(?:{_UP}|{_DOWN})"
_UP_RE = re.compile(_UP)
_DOWN_RE = re.compile(_DOWN)
#: 分句的边界(顿号、冒号不算:「集中在华东、华北」「主要来自:华东」还是一句里的事)
_STOPS = ",,;;。!?!?\n"
_NOT_STOP = f"[^{_STOPS}]"
@dataclass(frozen=True)
class Claim:
"""说法表的一类:判断词(words)、要挂的分析(op,结构变化另要 mode ratio)、判断词前有否定时翻到哪一类(flip,None:
否定了就不核,宁可漏报)。negative:这一类的说法本身就是否定的写法(「并不异常」「没有明显趋势」),不再看前面的否定。
同一类可以有几条(key 相同):翻到这一类时用第一条。"""
key: str
label: str
op: str | None
words: re.Pattern[str]
flip: str | None = None
mode: str | None = None
negative: bool = False
#: 「连续…上升」:另要末尾连续同向至少 3 期
run: bool = False
@dataclass(frozen=True)
class Lexicon:
version: int
claims: tuple[Claim, ...]
#: 判断词本身带的方向(不看上下文):骤降是跌、激增是涨、拖累要那一段是跌
intrinsic: tuple[tuple[re.Pattern[str], str], ...]
#: 判断词前面的否定(_negated)
negation: re.Pattern[str]
#: 否定词和判断词之间可以隔着的填充词(「并未出现明显异常」)
filler: re.Pattern[str]
#: 推测的说法:因果句有它就不算没依据
guess: re.Pattern[str]
#: 因果说的是数据本身(数据只到哪天、口径、样本):不当原因判断核
method: re.Pattern[str]
#: 「异常」「正常」前面是这些的,说的是数据质量(数据异常、系统无异常),不是指标的波动
quality: re.Pattern[str]
#: 拖累、拉动、带动:点名的段不在 subjects 里时,按那一段变化的正负和占比核
pulls: frozenset[str] = field(default_factory=frozenset)
def claim(self, key: str) -> Claim:
return next(c for c in self.claims if c.key == key)
_V1 = Lexicon(
version=1,
claims=(
Claim("source", "来源", "decompose", re.compile(
r"主要(?:来自|源自|出自|由|集中(?:在|于))"
rf"|主要是(?!因为|由于|受|指|看|说|想|希望){_NOT_STOP}{{0,12}}?(?:{_DIR}|贡献|拖累|带动|拉动|驱动)"
r"|(?:绝)?大(?:部分|多数|多)(?:来自|源自|出自|由|集中(?:在|于))"
r"|集中(?:在|于)"
r"|主导(?!地位|产品|品类|品牌|产业)"
r"|拖累|拉动|带动"
r"|贡献(?:最大|最多|最高)|最大(?:的)?贡献|贡献了(?:绝)?大部分"), flip="source_not"),
Claim("source_not", "来源(否定)", "decompose", re.compile(r"(?!)"), negative=True),
Claim("dispersed", "分散", "decompose", re.compile(
r"分散(?!注意|精力|风险|投资|采购)"
rf"|普遍(?:有所|出现|呈现)?{_DIR}"
rf"|全面{_DIR}"
rf"|各(?:个)?{_NOT_STOP}{{1,6}}?(?:都|均)(?:有所|出现|在)?{_DIR}"), flip="source"),
Claim("offset", "抵消", "decompose", re.compile(r"抵消|抵冲|此消彼长|一增一减|一升一降|有增有减")),
Claim("mix", "结构", "decompose", re.compile(
r"结构性|结构(?:变化|变动|效应)|占比(?:的)?变化(?:带来|造成|导致|引起)"), mode="ratio"),
Claim("anomaly", "异常", "baseline", re.compile(
r"异常(?!订单|单|值|数据|记录|交易|码|状态|类|退款|商品|门店|账户|用户|日志|处理|检测)"
r"|反常|罕见"
r"|(?:明显|显著|大幅|严重)偏离|偏离(?:了)?(?:正常|常规|往常|以往|历史|过往)"
rf"|(?:明显|显著)?(?:超出|超过|跳出|突破|高于|低于)(?:了)?{_NOT_STOP}{{0,14}}?(?:正常|常规)(?:的)?(?:波动)?(?:范围|水平|区间)"
r"|超出(?:了)?(?:正常|常规)"
rf"|大幅(?:度)?(?:地)?(?:{_DIR}|波动|变化|变动)"
r"|骤降|骤减|暴跌|暴降|锐减|激增|飙升|暴涨|猛增|骤增|陡增|陡降"
r"|剧烈(?:地)?(?:波动|变化|变动|震荡|起伏)|(?:波动|变化|变动)(?:十分|非常|较为|比较)?剧烈"),
flip="normal"),
Claim("record", "新高新低", "baseline", re.compile(
r"历史(?:新高|新低|最高|最低)|创(?:下)?(?:了)?(?:历史)?(?:新高(?!地)|新低)|前所未有")),
Claim("normal", "正常", "baseline", re.compile(
r"(?:正常|常规)(?:的)?波动(?:范围)?(?:内|之内)?"
r"|(?:正常|常规)(?:的)?(?:范围|区间)(?:内|之内)?"
r"|(?:属于|处于|在)(?:正常|常规)(?:水平|区间|范围)"), flip="anomaly"),
Claim("normal", "正常", "baseline", re.compile(
r"(?:并不|并非|并没有|没有|未见|并无|不算|不属于|不是)(?:明显)?(?:的)?异常|无异常"), negative=True),
Claim("trend", "趋势", "trend", re.compile(
rf"{_DIR}(?:的)?(?:趋势|态势|势头)"
rf"|(?:趋势|态势|势头)(?:性)?(?:地)?{_DIR}"
rf"|(?:呈|呈现|保持|维持|显示)(?:出)?{_DIR}?(?:的)?(?:趋势|态势)"
rf"|持续(?:地|性)?{_DIR}"
rf"|一路(?:{_DIR}|走高|走低|上扬)"
rf"|逐步{_DIR}|稳步{_DIR}"
r"|节节(?:攀升|升高|走高|上涨|上升|下滑|下降|败退|走低)"), flip="no_trend"),
Claim("trend", "趋势", "trend", re.compile(
rf"连续(?:[\x00\s0-90-9一二三四五六七八九十两几多数个周月天日季度年期]|都|在|有所|出现)*?{_DIR}"),
flip="no_trend", run=True),
Claim("no_trend", "无趋势", "trend", re.compile(
r"(?:没有|并没有|没|无|未见|并无|未呈现|未出现)(?:明显|显著)?(?:的)?"
r"(?:(?:上升|下降|增长|下滑|向上|向下)(?:或|和|、|与|/)?)*(?:的)?(?:趋势|态势)"
rf"|{_DIR}?(?:的)?(?:趋势|态势)(?:并)?(?:不明显|不显著|不清晰|不突出)"
r"|基本平稳|走势平稳"), negative=True),
Claim("differ", "差异", "differ", re.compile(
r"(?:显著|明显)(?:地)?(?:高于|低于|大于|小于|多于|少于|好于|差于|优于|劣于)"
r"|确实(?:更高|更低|更多|更少|高于|低于)"
r"|差(?:异|别)(?:十分|非常|很|较为)?(?:明显|显著)"
r"|(?:明显|显著)(?:的)?(?:差异|差别)"), flip="no_diff"),
Claim("no_diff", "无差异", "differ", re.compile(
r"差(?:异|别)(?:不大|很小|较小|不明显|并不明显|不显著|有限)"
r"|(?:没有|并没有|没|无|并无)(?:明显|显著)?(?:的)?(?:差异|差别|区别)"
r"|不相上下|旗鼓相当"
rf"|(?:基本|大致|大体)?相当(?=[{_STOPS}\s\x00]|$)"), negative=True),
Claim("causal", "因果", None, re.compile(
r"因为|由于|导致|造成|引起(?!关注|重视|注意|警惕|讨论|兴趣)|归因(?:于|为)|原因(?:主要)?(?:是|在于)"
r"|源于|所致|得益于"
rf"|受(?!到?影响)(?:到)?{_NOT_STOP}{{1,12}}?(?:的)?影响")),
),
intrinsic=(
(re.compile(r"拖累|骤降|骤减|暴跌|暴降|锐减|陡降|新低|最低|低于|小于|少于|更低|更少"), "down"),
(re.compile(r"拉动|带动|激增|飙升|暴涨|猛增|骤增|陡增|新高|最高|高于|大于|多于|更高|更多"), "up"),
),
negation=re.compile(r"不(?![但仅只光论少同断久管过如然错禁免妨要])|没有|没|并非|并不|未(?![来必])|无(?![论疑关需])"),
filler=re.compile(r"(?:出现|发现|看到|见到|存在|呈现|表现出|明显|显著|什么|任何|多大|太|很|特别|有|过|的|了|[\x00\s])$"),
guess=re.compile(r"可能|或许|也许|推测|猜测|待核实|有待核实|需(?:要)?(?:进一步)?(?:核实|确认|验证)|待确认|待验证"
r"|不排除|疑似|大概率"),
method=re.compile(r"数据|口径|统计|查询|目录|样本|取数|截断|截止|只到|不完整|未覆盖|篇幅|省略|证据"),
quality=re.compile(r"(?:数据|系统|接口|取数|口径|同步|查询|日志|字段|格式|编码)(?:出现|存在|有|无|没有|并无|未见)?$"),
pulls=frozenset({"拖累", "拉动", "带动"}),
)
LEXICONS: dict[int, Lexicon] = {1: _V1}
#: 有这几类判断的分句里,因果词并进它们核(「下降主要由华东造成」:说的是记账意义上的来源)
_SOURCE_KEYS = frozenset({"source", "source_not", "dispersed", "offset", "mix"})
#: 结果表里不是维度的列(按维度认点名的段时跳过)
_MEASURE_COLUMNS = frozenset({
"base", "target", "delta", "pct", "share", "flag", "d_base", "d_target", "w_base", "w_target", "r_base",
"r_target", "mix", "rate", "total", "contrib",
})
#: 一段的变化记在哪一列:可加拆解的 delta、比率拆解的 total(结构 + 自身)、乘积拆解的 contrib
_DELTA_COLUMNS = ("delta", "total", "contrib")
def lexicon(version: int | None = None) -> Lexicon:
"""某一版说法表;不认识的版本按当前版。"""
return LEXICONS.get(version if isinstance(version, int) else LEXICON_VERSION, LEXICONS[LEXICON_VERSION])
# --------------------------------------------------------------------------
# 扫判断词
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Hit:
"""句子里的一处判断词:类(否定翻过之后的)、在句子里的起止、原文、方向(up / down / None)。"""
claim: str
start: int
end: int
word: str
direction: str | None
negated: bool = False
run: bool = False
def _clause(text: str, start: int, end: int) -> tuple[int, int]:
"""[start, end) 所在的分句:往前往后各找到最近的句读。"""
left = max((text.rfind(ch, 0, start) for ch in _STOPS), default=-1) + 1
rights = [i for i in (text.find(ch, end) for ch in _STOPS) if i >= 0]
return left, min(rights) if rights else len(text)
def _negated(lex: Lexicon, before: str) -> bool:
"""判断词前 4 个字以内有否定(先去掉紧挨着的填充词:「并未出现明显异常」的否定隔着「出现明显」)。"""
while (m := lex.filler.search(before)) and m.group(0):
before = before[:m.start()]
return lex.negation.search(before[-4:]) is not None
def _direction(lex: Lexicon, word: str, after: str, clause: str) -> str | None:
"""判断词说的方向:判断词本身带的(骤降、拉动),再看判断词里、紧跟着的几个字、整个分句里的涨跌说法。一个分句里
涨跌都有的不判方向。"""
for pattern, direction in lex.intrinsic:
if pattern.search(word):
return direction
for text in (word, after[:4], clause):
up, down = bool(_UP_RE.search(text)), bool(_DOWN_RE.search(text))
if up != down:
return "up" if up else "down"
if up and down:
return None
return None
def scan(text: str, *, version: int | None = None) -> list[Hit]:
"""一句话里的判断词(text 是写作者自己写的字,别的片段已经涂成 \\x00)。同一处有几种读法时取最靠前、最长的:
「超出正常范围」是异常不是正常,「没有明显的上升或下降趋势」是无趋势不是趋势。"""
lex = lexicon(version)
found: list[tuple[int, int, int, Claim]] = []
for order, claim in enumerate(lex.claims):
for m in claim.words.finditer(text):
if m.end() > m.start():
found.append((m.start(), -(m.end() - m.start()), order, claim))
hits: list[Hit] = []
taken = 0
for start, minus, _, claim in sorted(found, key=lambda f: (f[0], f[1], f[2])):
end = start - minus
if start < taken:
continue
left, right = _clause(text, start, end)
before = text[left:start]
if claim.key in ("anomaly", "normal") and lex.quality.search(
re.sub(r"(?:不|没有|没|并不|并非|未|无)$", "", before.rstrip("\x00 "))):
taken = end
continue
key, negated = claim.key, False
if not claim.negative and _negated(lex, before):
negated = True
if claim.flip is None:
taken = end
continue
key = claim.flip
word = text[start:end]
direction = _direction(lex, word, text[end:right], text[left:right])
hits.append(Hit(claim=key, start=start, end=end, word=word, direction=direction, negated=negated,
run=claim.run and not negated))
taken = end
return hits
# --------------------------------------------------------------------------
# 一句话
# --------------------------------------------------------------------------
@dataclass
class Sentence:
"""交给核对的一句话(和标记解析解耦:文档里怎么切、怎么解析引用由 evidence 管,这里只要组装好的单元)。
text、names、shown 等长,位置一一对应:text 只留写作者自己写的字(判断词在这里找),names 另留引用渲染出来的值(点名
的段在这里认),两者都把判定语、知识库原话涂成 \\x00;shown 是给人看的整句(排版符号涂成 \\x00,显示时去掉)。没给
names、shown 的按 text。analyses 是这句的分析依据(A 编号),quoted / inputs:同一句里有知识库原话、运行输入。base 是
text[0] 在正文里的位置(违规带 span 要用),pieces 是各片段在 text 里的起止和编号(违规带所在片段)。"""
text: str
names: str = ""
shown: str = ""
analyses: list[str] = field(default_factory=list)
quoted: bool = False
inputs: bool = False
base: int | None = None
unit: str | None = None
pieces: list[tuple[int, int, str | None]] = field(default_factory=list)
def __post_init__(self) -> None:
if len(self.names) != len(self.text):
self.names = self.text
if len(self.shown) != len(self.text):
self.shown = self.names
def _same_length_norm(text: str) -> str:
"""逐字归一(NFKC 会把个别字展开成几个字,逐字做、展开的取第一个,位置就和原文对得上)。"""
out = []
for ch in text:
n = unicodedata.normalize("NFKC", ch).casefold()
out.append(n[:1] if n else ch)
return "".join(out)
_NUMERIC = re.compile(r"^[\d.,+\-%‰\s]+$")
def _named_value(value: Any) -> str | None:
"""结果表里一个维度值能不能拿来认点名:两个字以上、不是纯数字。"""
if value is None or isinstance(value, bool):
return None
text = _same_length_norm(str(value).strip())
if len(text) < 2 or _NUMERIC.match(text):
return None
return text
def _dims(result: Mapping[str, Any]) -> list[int]:
"""结果表里维度列的位置:拆解按 params.by,乘积拆解是 factor 列,别的按「不是度量列」认。"""
table = result.get("table") or {}
columns = [str(c) for c in table.get("columns") or []]
params = result.get("params") or {}
if result.get("mode") == "factor" and "factor" in columns:
return [columns.index("factor")]
by = params.get("by")
wanted = by if isinstance(by, list) else [by] if isinstance(by, str) else []
picked = [columns.index(str(b)) for b in wanted if str(b) in columns]
return picked or [i for i, c in enumerate(columns) if c not in _MEASURE_COLUMNS]
def _segment_names(result: Mapping[str, Any]) -> dict[str, str]:
"""{归一后的维度值或因子名: 原样}:结果表里各段的维度值,加上判定点名的 subjects。"""
out: dict[str, str] = {}
rows = (result.get("table") or {}).get("rows") or []
for row in rows if isinstance(rows, list) else []:
if not isinstance(row, list):
continue
for i in _dims(result):
if i < len(row) and (named := _named_value(row[i])):
out.setdefault(named, str(row[i]).strip())
for subject in _subjects(result):
if named := _named_value(subject):
out.setdefault(named, subject)
return out
def _verdict(result: Mapping[str, Any]) -> Mapping[str, Any]:
verdict = result.get("verdict")
return verdict if isinstance(verdict, Mapping) else {}
def _subjects(result: Mapping[str, Any]) -> list[str]:
verdict = _verdict(result)
out: list[str] = []
for subject in verdict.get("subjects") or []:
values = subject if isinstance(subject, list) else [subject]
out.extend(str(v).strip() for v in values if v is not None)
return out
def _named(result: Mapping[str, Any], names: str) -> list[str]:
"""分句里点名的段(原样,按出现的先后)。ASCII 的名字要整词出现(「A」不算点名了「AB」)。"""
flat = _same_length_norm(names)
hits: list[tuple[int, str]] = []
for key, original in _segment_names(result).items():
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()))):
hits.append((at, original))
break
at = flat.find(key, at + 1)
return [name for _, name in sorted(hits)]
def _decimal(value: Any) -> Decimal | None:
if value is None or isinstance(value, bool):
return None
try:
return Decimal(str(value).replace(",", ""))
except (InvalidOperation, ValueError):
return None
def _segment_change(result: Mapping[str, Any], name: str) -> tuple[Decimal | None, Decimal | None]:
"""一段(维度值或因子名)的变化和占总变化的比重:结果表里这一段所在各行的变化相加;表里没有的为 None。"""
table = result.get("table") or {}
columns = [str(c) for c in table.get("columns") or []]
col = next((columns.index(c) for c in _DELTA_COLUMNS if c in columns), None)
if col is None:
return None, None
share_col = columns.index("share") if "share" in columns else None
want = _named_value(name)
total: Decimal | None = None
share: Decimal | None = None
for row in table.get("rows") or []:
if not isinstance(row, list) or not any(i < len(row) and _named_value(row[i]) == want for i in _dims(result)):
continue
if (d := _decimal(row[col] if col < len(row) else None)) is not None:
total = d if total is None else total + d
if share_col is not None and (s := _decimal(row[share_col] if share_col < len(row) else None)) is not None:
share = s if share is None else share + s
return total, share
def _caveats(result: Mapping[str, Any]) -> set[str]:
return {str(c.get("code")) for c in result.get("caveats") or [] if isinstance(c, dict)}
def _value(result: Mapping[str, Any], key: str) -> Decimal | None:
return _decimal((result.get("values") or {}).get(key))
def _dir_word(direction: str | None) -> str:
return {"up": "上升", "down": "下降"}.get(direction or "", "")
def _run_direction(raw: Any) -> str | None:
text = str(raw or "").strip().lower()
if text in ("up", "rising", "上升"):
return "up"
if text in ("down", "falling", "下降"):
return "down"
return None
# --------------------------------------------------------------------------
# 判定对不对得上
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class _Miss:
"""一份结论对不上的原因:给写作者的、给人看的各一句(不带结论编号,外面接上)。"""
model: str
human: str
def _phrase(result: Mapping[str, Any]) -> str:
return str(result.get("phrase") or "").strip()
def _verdict_miss(result: Mapping[str, Any]) -> _Miss:
said = f"判定是「{_phrase(result)}」" if _phrase(result) else "判定不是这一类"
return _Miss(said, said)
def _check(lex: Lexicon, hit: Hit, result: Mapping[str, Any], names: str) -> _Miss | None:
"""这处判断词能不能挂这份结论:对得上返回 None,对不上返回原因。names 是判断词所在分句(点名用的样子)。"""
if result.get("status") != "ok":
reason = str(result.get("reason") or "算不了")
return _Miss(f"没有做成({reason})", f"这份结论没有做成({reason})")
verdict = _verdict(result)
code = verdict.get("code")
key = hit.claim
if key in ("source", "source_not"):
subjects = _subjects(result)
named = _named(result, names)
top = code in ("driver", "concentrated")
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):
who = "、".join(inside or subjects)
return _Miss(f"判的主要来源正是{who}" if who else _verdict_miss(result).model,
f"判定的主要来源正是{who}" if who else _verdict_miss(result).human)
return None
if not top:
return _verdict_miss(result)
wanted = {_named_value(s) for s in subjects}
stray = [n for n in named if _named_value(n) not in wanted]
pull = hit.word in lex.pulls
if stray and pull:
# 拖累、拉动、带动:点名的段不在主要来源里,变化方向对、占总变化过半的也算(整体上涨、华东下跌拖累了涨幅)
main = _decimal((result.get("settings") or {}).get("main_share")) or Decimal("0.5")
stray = [n for n in stray if not ((share := _segment_change(result, n)[1]) is not None
and abs(share) >= main)]
if stray:
subject = "、".join(subjects)
return _Miss(f"判的主要来源是{subject},不是{'、'.join(stray)}",
f"判定的主要来源是{subject},不是{'、'.join(stray)}")
if pull and hit.direction:
for name in named or subjects:
change, _ = _segment_change(result, name)
if change is None or change == 0:
continue
if (change > 0) != (hit.direction == "up"):
moved = "上升" if change > 0 else "下降"
return _Miss(f"里{name}的变化是{moved},不能说「{hit.word}」",
f"其中{name}的变化是{moved},与「{hit.word}」的方向相反")
return None
if key == "dispersed":
return None if code == "dispersed" else _verdict_miss(result)
if key == "offset":
return None if "offset" in _caveats(result) else _Miss(
"没有记下此消彼长(各段的变化方向大多一致)", "这份结论没有记下各段此消彼长")
if key == "mix":
if result.get("mode") != "ratio":
return _Miss("是按维度的可加拆解,说明不了结构变化:结构变化要挂比率拆解",
"这是按维度的可加拆解,说明不了结构变化(结构变化要挂比率拆解)")
return None if code in ("mix_driven", "both") else _verdict_miss(result)
if key == "anomaly":
if code not in ("outside_band", "far_outside_band"):
return _verdict_miss(result)
return _direction_miss(hit, verdict.get("direction"))
if key == "record":
caveats = _caveats(result)
wanted = {"up": {"record_high"}, "down": {"record_low"}}.get(hit.direction or "", {"record_high", "record_low"})
if caveats & wanted:
return None
return _Miss("没有记下本期是历史最高或最低", "这份结论没有记下本期是历史最高或最低")
if key == "normal":
return None if code == "within_band" else _verdict_miss(result)
if key == "trend":
if code not in ("rising", "falling"):
return _verdict_miss(result)
if miss := _direction_miss(hit, "up" if code == "rising" else "down"):
return miss
if hit.run:
run = _value(result, "run_length")
if run is None or run < 3:
shown = "没有" if run is None else f"只有 {run} 期"
return _Miss(f"末尾连续同向的期数{shown},不够说「连续」(至少 3 期)",
f"末尾连续同向的期数{shown},不够说「连续」")
ran = _run_direction((result.get("values") or {}).get("run_direction"))
if ran and hit.direction and ran != hit.direction:
return _Miss(f"末尾连续{_dir_word(ran)},不是连续{_dir_word(hit.direction)}",
f"末尾连续{_dir_word(ran)},与「{hit.word}」的方向相反")
return None
if key == "no_trend":
return None if code == "no_trend" else _verdict_miss(result)
if key == "differ":
if code != "differs":
return _verdict_miss(result)
subjects = _subjects(result)
direction = verdict.get("direction")
if len(subjects) == 2 and hit.direction and direction in ("up", "down"):
# 判定语的写法是「A 的…高于 B」:A 是 subjects 的第一个。句子里两组都点名了才核方向,后点名的先写就反过来
flat = _same_length_norm(names)
spots = [flat.find(_named_value(s) or "￿") for s in subjects]
if all(at >= 0 for at in spots):
expected = direction if spots[0] < spots[1] else ("down" if direction == "up" else "up")
if expected != hit.direction:
higher, lower = (subjects if direction == "up" else subjects[::-1])
return _Miss(f"判的是{higher}高于{lower},方向写反了", f"判定是{higher}高于{lower},方向写反了")
return None
if key == "no_diff":
return None if code == "no_evidence" else _verdict_miss(result)
return None
def _direction_miss(hit: Hit, direction: Any) -> _Miss | None:
if hit.direction and direction in ("up", "down") and hit.direction != direction:
return _Miss(f"判的方向是{_dir_word(direction)},不是{_dir_word(hit.direction)}",
f"判定的方向是{_dir_word(direction)},与「{hit.word}」相反")
return None
# --------------------------------------------------------------------------
# 违规
# --------------------------------------------------------------------------
#: 没挂对分析结论时交回写作者的「那就怎么写」
_FALLBACK = {
"decompose": "只写各段各自的变化,不下「主要」「集中」「拖累」「分散」这类判断",
"mix": "只写比率和各段的变化,不说结构变化",
"baseline": "只写本期的数和变化,不说「异常」「大幅」「激增」「历史新高」「正常波动」这类判断",
"trend": "只写各期的数,不说「持续」「趋势」「连续」这类判断",
"differ": "只写各组各自的数,不说「显著」「明显高于」「差异不大」这类判断",
}
def _op_text(claim: Claim) -> str:
return "比率拆解" if claim.mode == "ratio" else _OP_TEXT.get(claim.op or "", claim.op or "")
def _snippet(shown: str, start: int, end: int, limit: int = 16) -> str:
"""判断词连同它后面半句(给人看的样子),最多 limit 个字。"""
right = _clause(shown, start, end)[1]
piece = shown[start:max(end, min(right, start + limit))].replace("\x00", "").strip()
return piece or shown[start:end]
def _uncited(claim: Claim, word: str, available: Mapping[str, list[str]]) -> _Reason:
op_text = _op_text(claim)
pool = available.get(f"{claim.op}:{claim.mode}" if claim.mode else claim.op or "", [])
fallback = _FALLBACK["mix" if claim.mode == "ratio" else claim.op or "decompose"]
if pool:
model = (f"「{word}」要挂{op_text}的结论:照它的判定写、句末挂 [[see:{pool[0]}]],或者直接引用判定 [[a:{pool[0]}]];"
f"判定和你想写的不一样就照判定写,否则{fallback}")
else:
model = f"这次没有{op_text}的结论,不能写「{word}」:{fallback}"
return _Reason(f"「{word}」是分析判断,这句没有挂{op_text}的分析结论,系统无法核对", model)
def _mismatch(alias: str, snippet: str, miss: _Miss) -> _Reason:
return _Reason(f"「{snippet}」与所挂的分析结论 {alias} 对不上:{miss.human}",
f"{alias} {miss.model},不能写「{snippet}」:照判定改写(或者直接引用判定 [[a:{alias}]]),"
"或者删掉这个判断")
def _causal(snippet: str) -> _Reason:
return _Reason(f"「{snippet}」是原因判断,这句没有知识库原话或运行输入作依据,也没有写成推测",
f"「{snippet}」没有依据:数据只能说明变化来自哪里;要写原因,引用知识库原话,或写成「可能与…有关,待核实」")
def check_sentence(sentence: Sentence, results: Mapping[str, Mapping[str, Any]], *,
available: Mapping[str, list[str]] | None = None, version: int | None = None) -> list[dict[str, Any]]:
"""一句话的说法违规。results 是写作目录里的分析结论 {A 编号: 结论},available 是 {op: [这次有的 A 编号]}(没挂时
告诉写作者可以挂哪一份),不给就按 results 算。"""
lex = lexicon(version)
if available is None:
available = _available(results)
hits = scan(sentence.text, version=lex.version)
if not hits:
return []
mine = [(a, results[a]) for a in dict.fromkeys(sentence.analyses) if a in results]
out: list[dict[str, Any]] = []
#: 同一分句里同一类只报一处(「由于促销结束导致下降」是一处没依据的原因,不是两处)
seen: set[tuple[str, str, int]] = set()
guessed = lex.guess.search(sentence.text) is not None
for hit in hits:
claim = lex.claim(hit.claim)
left, right = _clause(sentence.text, hit.start, hit.end)
names = sentence.names[left:right]
snippet = _snippet(sentence.shown, hit.start, hit.end)
if claim.key == "causal":
clause = sentence.text[left:right]
if sentence.quoted or sentence.inputs or guessed or lex.method.search(clause):
continue
if any(h is not hit and h.claim in _SOURCE_KEYS and left <= h.start < right for h in hits):
continue # 「主要由华东造成」:并进来源那一处核
decomposes = [(a, r) for a, r in mine if r.get("op") == "decompose"]
if any(_named(r, names) for _, r in decomposes):
# 因果词点名的是拆解的段或因子:按来源核(记账意义上的「来自」)
hit = Hit("source", hit.start, hit.end, hit.word, hit.direction)
claim = lex.claim("source")
else:
key = (CAUSAL, claim.key, left)
if key not in seen:
seen.add(key)
out.append(_record(CAUSAL, _causal(snippet), sentence, hit, claim, mine))
continue
candidates = [(a, r) for a, r in mine if r.get("op") == claim.op]
if not candidates:
key = (UNCITED, claim.key, left)
if key not in seen:
seen.add(key)
out.append(_record(UNCITED, _uncited(claim, hit.word, available), sentence, hit, claim, mine))
continue
misses = [(a, _check(lex, hit, r, names)) for a, r in candidates]
if any(miss is None for _, miss in misses):
continue
alias, miss = misses[0]
key = (MISMATCH, claim.key, left)
if key not in seen:
seen.add(key)
out.append(_record(MISMATCH, _mismatch(alias, snippet, miss), sentence, hit, claim, mine)) # type: ignore[arg-type]
return out
def _record(code: str, message: _Reason, sentence: Sentence, hit: Hit, claim: Claim,
mine: list[tuple[str, Mapping[str, Any]]]) -> dict[str, Any]:
span = [sentence.base + hit.start, sentence.base + hit.end] if sentence.base is not None else None
segment = next((sid for s, e, sid in sentence.pieces if s <= hit.start < e), None)
shown = sentence.shown.replace("\x00", "").strip()
return _violation(code, message, text=hit.word, span=span, context=shown[:200] or None,
unit=sentence.unit, segment=segment, claim=claim.key, op=claim.op,
**({"mode": claim.mode} if claim.mode else {}), analyses=[a for a, _ in mine])
def _available(results: Mapping[str, Mapping[str, Any]]) -> dict[str, list[str]]:
"""{op: [这次有的 A 编号]},另按「op:mode」再记一份(结构变化要比率拆解)。只收做成了的:没挂时不教写作者去挂一份
算不了的结论。"""
out: dict[str, list[str]] = {}
for alias, result in results.items():
if result.get("status") != "ok":
continue
op = str(result.get("op") or "")
out.setdefault(op, []).append(alias)
if result.get("mode"):
out.setdefault(f"{op}:{result['mode']}", []).append(alias)
return out
# --------------------------------------------------------------------------
# 报告文档
# --------------------------------------------------------------------------
def results_of(catalog: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
"""写作目录里的分析结论:{A 编号: 结论}(contract.catalog_result 读)。"""
out: dict[str, dict[str, Any]] = {}
for alias, entry in catalog.items():
if isinstance(entry, dict) and (result := catalog_result(entry)) is not None:
out[str(alias)] = dict(result)
return out
def _sentence(unit: dict[str, Any], aliases: Iterable[str], results: Mapping[str, Any]) -> Sentence | None:
"""文档里的一个句子单元 → Sentence。写作者自己写的字(文字片段、没有出处的数字)留在 text;引用渲染出来的值、表名
字段名留在 names(点名用);判定语(a:)、知识库原话(q:)、排版符号两边都涂掉。"""
segments = [s for s in unit.get("segments") or [] if isinstance(s, dict)]
if not segments:
return None
scan_parts: list[str] = []
name_parts: list[str] = []
shown_parts: list[str] = []
pieces: list[tuple[int, int, str | None]] = []
quoted = False
pos = 0
for seg in segments:
text = str(seg.get("text") or "")
kind, ref = seg.get("kind"), str(seg.get("ref") or "")
head = ref.partition(":")[0].strip() if ref else ""
blank = "\x00" * len(text)
if kind == "structural":
scan_parts.append(blank)
name_parts.append(blank)
elif head in ("a", "q"):
scan_parts.append(blank)
name_parts.append(blank)
quoted = quoted or (head == "q" and seg.get("state") == "deterministic")
elif ref or kind == "entity":
scan_parts.append(blank)
name_parts.append(text)
else:
scan_parts.append(text)
name_parts.append(text)
shown_parts.append(blank if kind == "structural" else text)
pieces.append((pos, pos + len(text), seg.get("id")))
pos += len(text)
span = segments[0].get("span")
base = span[0] if isinstance(span, list) and span and isinstance(span[0], int) else None
cited = list(dict.fromkeys(str(a) for a in aliases))
return Sentence(text="".join(scan_parts), names="".join(name_parts), shown="".join(shown_parts),
analyses=[a for a in cited if a in results], quoted=quoted,
inputs=any(a.startswith("i:") for a in cited), base=base, unit=unit.get("id"), pieces=pieces)
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]]:
"""一份报告文档的说法违规。cited 是 {id(unit): 这句按重新解析的引用算的依据}(verify_doc 交进来),不给就用文档里
记的 cites。标题、代码块、表头不核。"""
results = results_of(catalog)
available = _available(results)
out: list[dict[str, Any]] = []
for block, unit in iter_units(doc):
if block.get("type") in ("heading", "code") or unit.get("kind") in ("heading", "code") \
or (unit.get("loc") or {}).get("row") == -1:
continue
aliases = cited.get(id(unit), []) if cited is not None else unit.get("cites") or []
sentence = _sentence(unit, aliases, results)
if sentence is not None:
out.extend(check_sentence(sentence, results, available=available, version=version))
return out
def record(mode: str) -> dict[str, Any]:
"""报告文档里记下的那一项:按哪种处置、哪一版说法表核。"""
return {"mode": mode, "lexicon": LEXICON_VERSION}
def recorded(doc: Any) -> tuple[str | None, int | None] | None:
"""文档记下的 (处置, 说法表版本):没有这一项(老文档、报告节点关掉了核对)返回 None;记了认不出的值,认不出的那一样
为 None。"""
if not isinstance(doc, dict) or DOC_FIELD not in doc:
return None
raw = doc.get(DOC_FIELD)
raw = raw if isinstance(raw, dict) else {}
mode = raw.get("mode") if raw.get("mode") in MODES else None
version = raw.get("lexicon") if type(raw.get("lexicon")) is int and raw.get("lexicon") in LEXICONS else None
return mode, version
def verify(doc: Any, catalog: Mapping[str, Any], *, cited: Mapping[int, list[str]] | None = None
) -> list[dict[str, Any]] | None:
"""复核一份文档的说法(evidence.verify_doc 调):没记这一项的文档返回 None(不核,老文档复核结果不变);记了的按记下
的那一版说法表核。记了认不出的处置、版本的另记一条 bad_schema(文档可能被改过),照当前版核。"""
found = recorded(doc)
if found is None:
return None
mode, version = found
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)
def counts(violations: Iterable[Mapping[str, Any]]) -> dict[str, int]:
"""三种说法违规各几处(只数有的)。"""
out: dict[str, int] = {}
for v in violations:
if v.get("code") in CODES:
out[str(v["code"])] = out.get(str(v["code"]), 0) + 1
return out
def summary(violations: list[Mapping[str, Any]]) -> str:
"""几处说法违规怎么说:「1 处判断没挂分析结论、2 处与所挂结论的判定对不上、1 处原因没有依据」。"""
said = {UNCITED: "判断没挂分析结论", MISMATCH: "与所挂结论的判定对不上", CAUSAL: "原因没有依据"}
found = counts(violations)
return "、".join(f"{found[code]} 处{said[code]}" for code in (UNCITED, MISMATCH, CAUSAL) if code in found)
# --------------------------------------------------------------------------
# 写作规则(给模型看)
# --------------------------------------------------------------------------
#: 写作目录里有分析结论时,拼在写作规则后面
ANALYSIS_RULES_PROMPT = (
"分析结论(系统会逐句核对判断词):\n"
"- 「主要来自」「集中在」「拖累」「带动」「分散」「异常」「大幅」「激增」「历史新高」「正常波动」「持续上升」「趋势」"
"「显著高于」「差异不大」这类判断,只能照证据目录里分析结论的判定写,句末挂对应的依据,比如 [[see:A1]];最好直接引用"
"判定语 [[a:A1]],它由系统按判定渲染。来源、集中、拖累、分散挂变化拆解,结构变化挂比率拆解,异常、新高新低、正常波动"
"挂波动基线,持续、趋势、连续挂趋势,显著、明显高于、差异不大挂差异检验。\n"
"- 判定和你想写的不一样时照判定写;点名的地区、渠道要在判定点名的段里;没有做成的分析照实写没做成的原因,不下这类判断。"
"没有对应的分析结论,就只陈述数字和变化。\n"
"- 拆解只说明变化来自哪里,不说明为什么。写原因(因为、由于、导致、受…影响)要有知识库原话或运行输入作依据,"
"否则写成待核实的推测,比如「可能与促销结束有关,待核实」。"
)
#: 写作目录里没有分析结论时,拼在写作规则后面
ANALYSIS_NONE_PROMPT = (
"判断词(系统会逐句核对):这次没有分析结论,不要写「主要来自」「集中在」「拖累」「带动」「分散」「异常」「大幅」"
"「激增」「历史新高」「正常波动」「持续上升」「趋势」「显著高于」「差异不大」这类判断,只陈述数字和变化(各段各自是多少、"
"变了多少)。写原因(因为、由于、导致、受…影响)要有知识库原话或运行输入作依据,否则写成待核实的推测,"
"比如「可能与促销结束有关,待核实」。"
)
#: 重写请求里有说法违规时补的一句
ANALYSIS_REPAIR_PROMPT = ("判断词照分析结论的判定写并挂上依据,没有对应结论的就删掉判断、只陈述数字和变化;"
"原因没有依据的写成待核实的推测。")
def rules_prompt(catalog: Mapping[str, Any]) -> str:
"""写作规则里说判断词的那一段:目录里有分析结论和没有时各一种说法。"""
return ANALYSIS_RULES_PROMPT if results_of(catalog) else ANALYSIS_NONE_PROMPT
__all__ = [
"ANALYSIS_NONE_PROMPT", "ANALYSIS_REPAIR_PROMPT", "ANALYSIS_RULES_PROMPT", "CAUSAL", "CODES", "DOC_FIELD",
"LEXICONS", "LEXICON_VERSION", "MISMATCH", "MODES", "UNCITED", "Claim", "Hit", "Lexicon", "Sentence",
"check_doc", "check_sentence", "counts", "lexicon", "record", "recorded", "results_of", "rules_prompt", "scan",
"summary", "verify",
]
+25 -1
View File
@@ -2663,10 +2663,17 @@ def _context(text: str, start: int, end: int) -> str:
#: 粗体:**…** 或 __…__,里面可以有标记(标记里的下划线不算收尾,[[m:refund_rate]] 很常见)
_BOLD = re.compile(r"(\*\*|__)(?P<inner>(?:\[\[" + _MARKER_GUARD + r"[^\[\]\n]*?\]\]|(?!\1)[^\n])+?)\1")
#: 方向词、因果词:没有数字也没有引用的句子,含这些词才算结论。「同比」「环比」不在里面:它们只说明
#: 跟谁比,本身不是涨跌;真正的比较结论还会带「增长」「下降」或者数字
#: 跟谁比,本身不是涨跌;真正的比较结论还会带「增长」「下降」或者数字。
#: 分析判断词(分析能力一轮,engine/analysis_claims 的说法表)也算:「主要来自」「集中在」「异常」「持续下降」「趋势」
#: 「显著」,裁判优先判这类句子(judge.claim_priority)。「主要」后面是「包括、内容、分为、有、看」这类的是在列提纲,
#: 「持续关注」「持续跟进」是建议,「趋势图」「趋势如下」是在引出图表,「集中采购」「分散风险」说的不是数据,都不算
_DIRECTIONAL = re.compile(
r"增长|增加|上升|提升|提高|上涨|下降|下滑|减少|降低|回落|下跌|反弹|超过|高于|低于|领先|落后|最高|最低"
r"|最多|最少|来自|导致|因为|由于|带动|拉动|拖累|驱动|原因|归因|贡献|占比|翻倍|持平|波动"
r"|主要(?!包括|内容|分为|分成|有|看|从|是指|指|结论|发现|观点|工作|任务|目的|目标|问题|方面)"
r"|集中(?!采购|管理|处理|展示|讨论|培训)|异常|偏离"
r"|持续(?!关注|跟踪|跟进|观察|监控|推进|优化|改进|更新)|趋势(?![图表线如::])|显著|明显高于|明显低于|大幅|激增"
r"|骤降|暴跌|飙升|新高|新低|抵消|分散(?!注意|精力|风险|投资|采购)"
r"|(?<![A-Za-z])(?:increase|decrease|grow|growth|decline|drop|rise|due to|because|driven)(?![A-Za-z])",
re.I)
#: 定义句:「同比是将本期与上年同月对比」「增长率是指……」。解释一个概念怎么算,不是对数据下结论
@@ -2838,12 +2845,15 @@ def compose_doc(
entities: bool = True,
absence: bool = False,
masked: dict[str, Any] | None = None,
analysis_claims: str | None = None,
) -> dict[str, Any]:
"""模型写的原文(带标记)→ 报告文档:渲染后的 markdown、块、句、片段、统计、违规。
entities=False(报告节点 entities: off):表名、字段名整层不管——不链接、不核对反引号,
[[t:]] / [[c:]] 解析不了,原因是 ENTITIES_OFF_REASON。
absence=True(写作目录有省略):另查缺失断言(absence_claims),和句中的 [[see:]] 一样只在组装时查。
analysis_claims(报告节点的分析说法核对,check / strict):文档顶层记下处置和说法表版本(engine/analysis_claims),
判断词核对和裸数字一样在 verify_doc 里做,出口复核照它再核一遍。不给(None、off)的文档不记、不核,和以前一字不差。
masked({查询快照工件: 取自遮罩列的结果列},报告节点按数据源的遮罩算好交进来):另查直接引用了这些列的格子
(masked_cells),同样只在组装时查。没给的和以前一字不差。目录里值不给的指标(withhold_metrics 记了 masked 的)被引用
时也在这里记违规(masked_metrics),不靠这个参数:目录本身说了哪几个不给。
@@ -3002,9 +3012,13 @@ def compose_doc(
# 没有它的(期 4 之前组装的)一律按原样展示。写在顶层、参与内容哈希,事后补不上:补了哈希就变,封存链就断
# cell_display:有单元格因为显示规则改了写法(ISO 日期时间)才记,记的是渲染得出这些格子的最低一版,复核照它渲染;
# 没有的文档和以前一字不差
# analysis_claims:报告节点要核分析说法时记下处置和说法表版本,复核照它走(只有带这一项的文档核判断词)
from app.engine import analysis_claims as said
doc: dict[str, Any] = {
"schema": DOC_SCHEMA, "entity_syntax": snaps.syntax, "provenance": DOC_PROVENANCE,
**({"cell_display": snaps.needed} if snaps.needed > 1 else {}),
**({said.DOC_FIELD: said.record(analysis_claims)} if analysis_claims in said.MODES else {}),
"run_id": run_id, "node_id": node_id,
"markdown": markdown, "source": source, "catalog": kept, "blocks": out_blocks,
}
@@ -3236,6 +3250,7 @@ def verify_doc(
masks: list[tuple[int, int]] = [] # 引用渲染出来的字
syntax: list[tuple[int, int]] = [] # 结构片段:只涂符号,数字照查
owners: list[tuple[int, int, str, str]] = [] # (start, end, segment id, unit id)
cited_by: dict[int, list[str]] = {} # 每句按这次核对的引用算的依据(分析说法核对用)
pos, tiled = 0, True
for block, unit in iter_units(doc):
stats["units"] += 1
@@ -3351,6 +3366,7 @@ def verify_doc(
_unresolved_message("依据", f"see:{again['ref']}", again),
ref=again["ref"], unit=unit.get("id"),
span=list(unit.get("span") or []) or None))
cited_by[id(unit)] = cited
if kind == "claim" and not cited:
stats["uncited_claims"] += 1
where = unit.get("span")
@@ -3359,6 +3375,14 @@ def verify_doc(
"text": markdown[where[0]:where[1]] if ok_span else ""})
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)
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)
masks += _header_masks(list(doc.get("blocks") or []), catalog) + _name_masks(markdown, catalog, snaps)
for token in _bare_numbers(markdown, masks, allow_numbers, syntax):
+474
View File
@@ -0,0 +1,474 @@
"""分析说法核对(engine/analysis_claims.py,细案 01 §9):判断词要挂对应的分析结论、判定对得上,因果词要有依据。
- 说法表:每类的判断词认得出,常见的非判断写法(持续关注、异常订单、相当于、数据异常)不算;「大幅」收进异常类,看清它在
常见写法里的表现
- 否定:判断词前 4 个字以内(跳过填充词)有否定的翻到反面类;「不但」「不少」不是否定
- 绑定:类要的 op 在这句的依据里出现、判定对得上;点名的段在 subjects 里(全半角归一);拖累、拉动看那一段的方向;
新高新低看 caveat;「连续」看末尾连续的期数;挂了算不了的结论算对不上
- 来源与因果的分界:因果词点名的是拆解的段 → 按来源核;不是 → 按因果核;推测说法、知识库原话、运行输入豁免因果
- 不扫:判定语(a:)、知识库原话(q:)、别的引用渲染出来的字、标题、代码块、表头
- 文档:只有记了这一项的文档在复核时核,老文档复核结果不变;记了认不出的版本记 bad_schema
"""
from __future__ import annotations
import re
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_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_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": "由于样本少,差异不能下结论。"},
])
def test_guesses_quotes_inputs_and_data_caveats_are_not_unsupported_causes(sentence):
assert ac.check_sentence(ac.Sentence(**sentence), {}) == []
# --------------------------------------------------------------------------
# 文档:哪里不扫、怎么挂、版本
# --------------------------------------------------------------------------
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
@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]
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_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(see_a):
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]
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_make_a_sentence_a_claim_for_the_judge():
from app.engine.judge import claim_priority
doc = compose_doc("华东异常。\n\n下面主要看三个方面。\n\n建议持续关注。\n\n近 8 周的趋势图如下:", catalog())
kinds = {doc["markdown"][slice(*u["span"])]: (u["kind"], claim_priority(u, b, doc["markdown"]))
for b, u in evidence.iter_units(doc)}
assert kinds["华东异常。"] == ("claim", 2)
assert kinds["下面主要看三个方面。"][0] == "connective" and kinds["建议持续关注。"][0] == "connective"
assert kinds["近 8 周的趋势图如下:"][0] == "connective", "引出图表的话不算判断"