diff --git a/backend/app/engine/analysis_claims.py b/backend/app/engine/analysis_claims.py index 36b46e93..5ef1baeb 100644 --- a/backend/app/engine/analysis_claims.py +++ b/backend/app/engine/analysis_claims.py @@ -5,22 +5,40 @@ 1. **说法表**(LEXICONS,一处定义,带版本 LEXICON_VERSION):判断词分成来源、分散、抵消、结构、异常、新高新低、正常、 趋势、无趋势、差异、无差异、因果几类,每类要挂一种分析(op),判定要对得上(细案 01 §9.1)。只收强判断词,宁可漏报 - 不误报:「持续」要跟着涨跌(「持续关注」不算),「异常」后面接着订单、数据这类名词的是业务分类、前面是数据、系统的是 - 数据质量,都不算;「相当」只认句读前的(「相当于」「相当高」不算) + 不误报(误报让正常的报告多重写一次、strict 下降档)。这几种写法不核: + - 说的是构成、不是变化从哪来:来源、集中、分散、主导、拉动带动要这句话到这个分句为止说了变化(涨跌、变化、降幅、持平、 + 环比……)才核——「下降主要来自华东」核,「本周订单主要来自线上渠道」「销售额集中在周末两天」「客户分散在各个城市」 + 「通过会员活动带动复购」不核;「拖累」「贡献最大」本身就在说变化,照核 + - 建议、计划、预测、假设:分句里有「建议、计划、预计、争取、需要、应当、将要……」的不核(「建议」「下周」「避免」这些 + 管到句子后面的分句:「建议加大投放,拉动新客增长」) + - 业务上的「异常」:前面是订单、物流、支付、门店、营业、库存、设备、账号或数据、系统(「部分订单异常」「系统无异常」), + 后面是名词(「异常订单」「物流异常的订单」);「无异常」「未见异常」主语得是指标或波动(「销售额未见异常」), + 「整体运行平稳,未见异常」不核 + - 别的:「持续」要跟着涨跌(「持续关注」不算);「来源于」不是「源于」;「表结构变化」不是结构变化;「相当」只认比的是 + 几组、几样的(「两个渠道的转化率相当」「规模相当」),比上期的(「订单量与上周相当」)、「相当于」「相当高」不算 2. **否定**:判断词前 4 个字以内(跳过「出现」「明显」这类填充词)有「不、没有、没、并非、并不、未、无」的翻到反面类: - 「并未出现明显异常」是正常,「没有明显的上升趋势」是无趋势,「并非主要来自华东」要拆解、华东不在主要来源里; + 「销售额并未出现明显异常」是正常,「没有明显的上升趋势」是无趋势,「并非主要来自华东」要拆解、华东不在主要来源里; 「不但」「不少」「无论」「未来」这类不是否定 3. **怎么挂**:一句话的分析依据 = 句中的 [[a:]]、[[v:A…]]、[[table:A…]] 和句末 [[see:A…]](组装、复核时按重新解析的 引用算,见 evidence.verify_doc)。类要的 op 必须在依据里出现,几个里任一个对得上就算对;挂了算不了的结论算对不上 -4. **点名的段**:判断词所在分句里出现的结论结果表的维度值(全半角归一,两个字以上、不是纯数字)。来源类要判定说了主要 - 来源(driver、concentrated,比率拆解的 mix_driven、rate_driven),点名的段都在 subjects 里;拖累要那一段变化为负, - 拉动、带动为正(不在 subjects 里、但那一段的变化占总变化过半的也算拖累) -5. **来源和因果的分界**:句子挂了拆解、因果词所在分句点名的是这份结论的段或因子(「下降主要由华东造成」)→ 按来源核, - 这是记账意义上的「来自」;点名的不是(「由于促销结束导致下降」)→ 按因果核:同一句要有知识库原话([[q:]])或运行 - 输入([[i:]])作依据,或者写成推测(可能、或许、推测、待核实、不排除、疑似)。分句里已有来源一类判断词的,因果词 - 并进那一处核。原因说的是数据本身(「由于数据只到 10 月 16 日」「样本少」)的不算 -6. **不扫的地方**:知识库原话([[q:]] 片段)、系统渲染的判定语([[a:]] 片段)、别的引用渲染出来的字、标题、代码块、 - 表头;分析范围是文档的结构化字段(analysis_scope),本来就不在正文里 +4. **点名的段**:结论结果表里的维度值、乘积拆解的因子(全半角归一,两个字以上、不是纯数字)。来源类判断看判断词说的那一边: + 「主要来自」「集中在」「以…为主」看后面、判断词里(到分句末尾),「主导」「贡献最大」看前面(同一分句没点名的往前找 + 最近点了名的分句)。判定要说了主要来源(driver、concentrated,比率拆解的 mix_driven、rate_driven),点名的段都在 + subjects 里,而且得点名:「下降主要来自线上渠道」「下降主要来自新客流失」挂了按地区的拆解,说的来源不是拆解里的段, + 对不上(同一分句引用了判定语 [[a:A1]] 的、比率拆解说「结构变化」「自身变化」的不用点名)。拖累要那一段变化为负, + 拉动、带动为正(看整个分句,没点名的按 subjects;不在 subjects 里、但那一段的变化占总变化过半的也算拖累) +5. **来源和因果的分界**:原因在因果词的哪一边:「由于、因为、原因是、源于、得益于」后面,「导致、造成、引起、所致、 + 是…的主要原因」前面(「由华东造成」是「由」和「造成」之间),「受…影响」中间。句子挂了拆解、原因点名的是这份结论的 + 段或因子(「下降主要由华东造成」「华东是下降的主要原因」)→ 按来源核,这是记账意义上的「来自」;点名的不是(「由于 + 促销结束导致下降」「下降主要由促销结束造成」)→ 按因果核:同一句要有知识库原话([[q:]])或运行输入([[i:]])作 + 依据,或者写成推测(可能、或许、推测、待核实、不排除、疑似)。句子没挂拆解、同一分句里有来源判断的,原因不是拆解 + 的段时另报(目录里有拆解可比的时候),认不出时并进来源那一处(它报没挂拆解)。原因说的是数据本身(「由于数据只到 + 10 月 16 日」「样本少」)、只是指代(「以上是下降的主要原因」,列出原因的那几句各自核)的不算 +6. **连续**:「连续 N 期上升」「三连降」按趋势结论记的末尾连续同向(run_direction、run_length)核:方向一致、期数不少于 + N(没写 N 的至少 3 期),不看整体的趋势判定(判定语在方向相反时写「但最近已连续 N 期上升」) +7. **不扫的地方**:知识库原话([[q:]] 片段)、系统渲染的判定语([[a:]] 片段)、别的引用渲染出来的字、标题、代码块、 + 表格(表头和正文的格都不扫:一格是一个值不是一句话,状态列的「异常」、说明列的「主要来自线上」不是判断;裁判的预筛 + judge.claim_priority 也不送表格的格);分析范围是文档的结构化字段(analysis_scope),本来就不在正文里 违规三种(CODES):analysis_claim_uncited(有判断词,依据里没有对应种类的分析结论)、analysis_claim_mismatch(挂了,判定、 点名的段、方向对不上,含挂了算不了的结论)、causal_unsupported(因果词没有依据、也不是推测)。每条带:代码、判断词 @@ -37,7 +55,7 @@ from __future__ import annotations import re import unicodedata from collections.abc import Iterable, Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from decimal import Decimal, InvalidOperation from typing import Any @@ -65,9 +83,19 @@ _DOWN = "下降|下滑|减少|降低|下跌|走低|回落|走弱|萎缩|减弱| _DIR = f"(?:{_UP}|{_DOWN})" _UP_RE = re.compile(_UP) _DOWN_RE = re.compile(_DOWN) +#: 说的是变化:来源、集中、分散、主导、拉动带动要这句话到这个分句为止有它才核(「下降主要来自华东」说的是变化从哪来, +#: 「本周订单主要来自线上渠道」说的是构成)。「环比」「同比」本身不是涨跌,可跟着的是变化(「环比 −5%,主要来自华东」) +_CHANGE = f"{_UP}|{_DOWN}|变化|变动|涨跌|升降|增减|涨幅|降幅|增幅|跌幅|减幅|增量|减量|增速|持平|波动|环比|同比" +#: 判断词后面紧跟着的涨跌、波动(「订单异常下降」「出现异常的增长」说的是指标,不是订单状态) +_MOVES = re.compile(f"(?:的|地)?(?:{_DIR}|波动|变化|变动)") #: 分句的边界(顿号、冒号不算:「集中在华东、华北」「主要来自:华东」还是一句里的事) _STOPS = ",,;;。!?!?\n" _NOT_STOP = f"[^{_STOPS}]" +#: 「相当」比的是几组、几样(「两个渠道的转化率相当」「规模相当」)才是差异判断 +_PEERS = re.compile(r"水平|规模|表现|幅度|程度|体量|量级|两(?:个|组|类|种|者|地|边|家|区|店)|二者|双方|几个|几组|各(?:个|组|类)") +#: 「相当」比的是上一期、往常:说的是持平,不是两组差异(「订单量与上周相当」) +_PERIODS = re.compile(r"上(?:一)?(?:个)?(?:周|月|季度|季|年|期)|前(?:一)?(?:周|月|天|日|期)|去年|同期|往年|以往|往常|昨天|昨日" + r"|此前|之前") @dataclass(frozen=True) @@ -83,8 +111,20 @@ class Claim: flip: str | None = None mode: str | None = None negative: bool = False - #: 「连续…上升」:另要末尾连续同向至少 3 期 + #: 「连续…上升」「三连降」:按末尾连续同向的期数核(_run_miss) run: bool = False + #: 这句话到这个分句为止要说了变化(Lexicon.change)才核:来源、集中、分散、主导、拉动带动 + gated: bool = False + #: 判断词前面(同一分句里)要有这些字才核(「两个渠道的转化率相当」) + needs: re.Pattern[str] | None = None + #: 分句里有这些字就不核(「订单量与上周相当」) + bars: re.Pattern[str] | None = None + #: 主语要是指标或波动(Lexicon.subject)才核:「销售额未见异常」核,「整体运行平稳,未见异常」不核 + subject: bool = False + #: 点名的段(来源类)、原因(因果类)在判断词的哪一边:after 后面到分句末尾(主要来自华东、由于促销结束)、before 前面 + #: (华东主导、华东是…的主要原因、促销结束导致)、inside 判断词里(以线上为主、受促销影响)、pull 拖累拉动带动(看整个 + #: 分句,见 _check);None 整个分句 + side: str | None = None @dataclass(frozen=True) @@ -101,10 +141,19 @@ class Lexicon: guess: re.Pattern[str] #: 因果说的是数据本身(数据只到哪天、口径、样本):不当原因判断核 method: re.Pattern[str] - #: 「异常」「正常」前面是这些的,说的是数据质量(数据异常、系统无异常),不是指标的波动 + #: 「异常」「正常」这一类的判断词前面是这些的,说的是数据质量(数据异常、系统无异常),不是指标的波动 quality: re.Pattern[str] - #: 拖累、拉动、带动:点名的段不在 subjects 里时,按那一段变化的正负和占比核 - pulls: frozenset[str] = field(default_factory=frozenset) + #: 「异常」「异动」前面是这些的,说的是业务上的状态(部分订单异常、门店营业异常);后面跟着涨跌的说的是指标(订单 + #: 异常下降、出现异常的增长),「订单大幅减少」也照核 + status: re.Pattern[str] + #: 说的是变化(Claim.gated 的类要有它) + change: re.Pattern[str] + #: 建议、计划、预测、假设:分句里有它的判断词不核(「下周将持续提升转化率」不是对数据下的判断) + plan: re.Pattern[str] + #: 同上,管到句子后面的分句(「建议加大投放,拉动新客增长」「避免缺货导致客诉」) + plan_ahead: re.Pattern[str] + #: 指标或波动(Claim.subject 的类要主语是它) + subject: re.Pattern[str] def claim(self, key: str) -> Claim: return next(c for c in self.claims if c.key == key) @@ -114,50 +163,69 @@ _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"), + r"主要(?:来自|源自|出自|源于|由(?!于)|集中(?:在|于)|依靠|依赖|靠|体现在|表现在|发生在|在于|在)" + r"|(?:绝)?大(?:部分|多数|多)(?:来自|源自|出自|由(?!于)|集中(?:在|于))" + r"|(?:将近|接近|超过|近|超|逾|过|约|多达|高达)?(?:[一二三四五六七八九两几]成|一半|半数|过半|大半|大部分|大多数)" + rf"{_NOT_STOP}{{0,8}}?(?:来自|源自|出自|集中(?:在|于))" + r"|集中(?:在|于)"), flip="source_not", gated=True, side="after"), + Claim("source", "来源", "decompose", re.compile( + rf"主要是(?!因为|由于|受|指|看|说|想|希望){_NOT_STOP}{{0,12}}?(?:{_DIR}|贡献|拖累|带动|拉动|驱动)" + rf"|以{_NOT_STOP}{{1,8}}?为主(?![题线要体流打])"), flip="source_not", gated=True, side="inside"), + Claim("source", "来源", "decompose", re.compile(r"主导(?!地位|产品|品类|品牌|产业)|贡献了(?:绝)?大部分"), + flip="source_not", gated=True, side="before"), + Claim("source", "来源", "decompose", re.compile(r"贡献(?:最大|最多|最高)|最大(?:的)?贡献"), + flip="source_not", side="before"), + Claim("source", "来源", "decompose", re.compile(r"拉动|带动"), flip="source_not", gated=True, side="pull"), + Claim("source", "来源", "decompose", re.compile(r"拖累"), flip="source_not", side="pull"), Claim("source_not", "来源(否定)", "decompose", re.compile(r"(?!)"), negative=True), + Claim("dispersed", "分散", "decompose", re.compile(r"分散(?!注意|精力|风险|投资|采购)"), flip="source", + gated=True), Claim("dispersed", "分散", "decompose", re.compile( - r"分散(?!注意|精力|风险|投资|采购)" - rf"|普遍(?:有所|出现|呈现)?{_DIR}" + rf"普遍(?:有所|出现|呈现)?{_DIR}" rf"|全面{_DIR}" rf"|各(?:个)?{_NOT_STOP}{{1,6}}?(?:都|均)(?:有所|出现|在)?{_DIR}"), flip="source"), - Claim("offset", "抵消", "decompose", re.compile(r"抵消|抵冲|此消彼长|一增一减|一升一降|有增有减")), + Claim("offset", "抵消", "decompose", re.compile( + r"抵消|抵冲|此消彼长|此增彼减|此涨彼跌|涨跌互现|有增有减|有涨有跌|有升有降" + r"|一增一减|一减一增|一升一降|一降一升|一涨一跌|一跌一涨")), Claim("mix", "结构", "decompose", re.compile( - r"结构性|结构(?:变化|变动|效应)|占比(?:的)?变化(?:带来|造成|导致|引起)"), mode="ratio"), + r"结构性|(? Lexicon: @dataclass(frozen=True) class Hit: - """句子里的一处判断词:类(否定翻过之后的)、在句子里的起止、原文、方向(up / down / None)。""" + """句子里的一处判断词:类(否定翻过之后的)、在句子里的起止、原文、方向(up / down / None)。side 是点名的段、原因在 + 判断词的哪一边(Claim.side,否定翻过来的为 None),count 是「连续 N 期」「三连降」写明的期数(没写为 None)。""" claim: str start: int @@ -229,6 +323,8 @@ class Hit: direction: str | None negated: bool = False run: bool = False + side: str | None = None + count: int | None = None def _clause(text: str, start: int, end: int) -> tuple[int, int]: @@ -260,9 +356,52 @@ def _direction(lex: Lexicon, word: str, after: str, clause: str) -> str | None: return None -def scan(text: str, *, version: int | None = None) -> list[Hit]: +def _counts(lex: Lexicon, claim: Claim, text: str, start: int, end: int, left: int, right: int) -> bool: + """这处判断词核不核:说的是数据质量、业务状态的「异常」,没说变化的来源、集中、分散(构成),建议、计划、预测、假设, + 不合条件的「相当」,都不核。""" + before = text[left:start] + if claim.key in ("anomaly", "normal"): + head = re.sub(r"(?:不|没有|没|并不|并非|未|无)$", "", before.rstrip("\x00 ")) + if lex.quality.search(head) or ("异" in text[start:end] and lex.status.search(head) + and not _MOVES.match(text, end)): + return False + if claim.gated and not lex.change.search(text[:right]): + return False + if lex.plan.search(text[left:right]) or lex.plan_ahead.search(text[:right]): + return False + if claim.needs is not None and not claim.needs.search(before): + return False + return claim.bars is None or not claim.bars.search(text[left:right]) + + +#: 「连续 N 期」「N 连降」里的期数:阿拉伯数字(全角也认)或一到九十九的中文数字 +_COUNT = re.compile(r"[0-90-9]+|[一二三四五六七八九十两]+") +_CN_DIGITS = {"一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9} + + +def _count(word: str) -> int | None: + """判断词里写明的期数:「连续三周」3、「连续 12 周」12、「十二连涨」12;「连续多周」「连续下滑」None。""" + m = _COUNT.search(word) + if m is None: + return None + raw = unicodedata.normalize("NFKC", m.group(0)) + if raw.isdigit(): + return int(raw) + if "十" not in raw: + return _CN_DIGITS.get(raw) if len(raw) == 1 else None + tens, _, ones = raw.partition("十") + if len(tens) > 1 or len(ones) > 1: + return None + return (_CN_DIGITS.get(tens, 0) if tens else 1) * 10 + (_CN_DIGITS.get(ones, 0) if ones else 0) + + +def scan(text: str, *, version: int | None = None, bare: bool = False) -> list[Hit]: """一句话里的判断词(text 是写作者自己写的字,别的片段已经涂成 \\x00)。同一处有几种读法时取最靠前、最长的: - 「超出正常范围」是异常不是正常,「没有明显的上升或下降趋势」是无趋势不是趋势。""" + 「超出正常范围」是异常不是正常,「没有明显的上升或下降趋势」是无趋势不是趋势。不核的写法(_counts)连同和它重叠的 + 读法一起跳过。 + + bare=True 只按词认、不看上下文:构成、建议计划、业务状态、「相当」的比法、「未见异常」的主语这些只对整句话成立的 + 规矩都不用——认叫法(度量的叫法、因子名)里夹带的判断词时用它,叫法是一个名字,不是一句话。""" lex = lexicon(version) found: list[tuple[int, int, int, Claim]] = [] for order, claim in enumerate(lex.claims): @@ -275,24 +414,25 @@ def scan(text: str, *, version: int | None = None) -> list[Hit]: end = start - minus if start < taken: continue + taken = end 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 + if not bare and not _counts(lex, claim, text, start, end, left, right): continue + before = text[left:start] 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] + if not bare and key == "normal" and (claim.subject or (negated and "异" in word)) \ + and not lex.subject.search(text[:right]): + continue # 「未见异常」「没有出现异动」:主语不是指标、波动的(运行平稳、系统正常)不核 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 + run = claim.run and not negated + hits.append(Hit(claim=key, start=start, end=end, word=word, direction=direction, negated=negated, run=run, + side=None if negated else claim.side, count=_count(word) if run else None)) return hits @@ -308,7 +448,8 @@ class Sentence: text、names、shown 等长,位置一一对应:text 只留写作者自己写的字(判断词在这里找),names 另留引用渲染出来的值(点名 的段在这里认),两者都把判定语、知识库原话涂成 \\x00;shown 是给人看的整句(排版符号涂成 \\x00,显示时去掉)。没给 names、shown 的按 text。analyses 是这句的分析依据(A 编号),quoted / inputs:同一句里有知识库原话、运行输入。base 是 - text[0] 在正文里的位置(违规带 span 要用),pieces 是各片段在 text 里的起止和编号(违规带所在片段)。""" + text[0] 在正文里的位置(违规带 span 要用),pieces 是各片段在 text 里的起止和编号(违规带所在片段),phrases 是判定语 + 引用 [[a:A1]] 在 text 里的起止和编号(同一分句引用了判定语的来源判断不用再点名)。""" text: str names: str = "" @@ -319,6 +460,7 @@ class Sentence: base: int | None = None unit: str | None = None pieces: list[tuple[int, int, str | None]] = field(default_factory=list) + phrases: list[tuple[int, int, str]] = field(default_factory=list) def __post_init__(self) -> None: if len(self.names) != len(self.text): @@ -496,8 +638,10 @@ def _verdict_miss(result: Mapping[str, Any]) -> _Miss: return _Miss(said, said) -def _check(lex: Lexicon, hit: Hit, result: Mapping[str, Any], names: str) -> _Miss | None: - """这处判断词能不能挂这份结论:对得上返回 None,对不上返回原因。names 是判断词所在分句(点名用的样子)。""" +def _check(lex: Lexicon, hit: Hit, result: Mapping[str, Any], names: str, *, spans: tuple[str, ...] = (), + phrased: bool = False) -> _Miss | None: + """这处判断词能不能挂这份结论:对得上返回 None,对不上返回原因。names 是判断词所在分句(点名用的样子);spans 是来源类 + 判断说的那一边(_object,近的在前,取第一个点了名的),没给按分句;phrased:同一分句引用了这份结论的判定语。""" if result.get("status") != "ok": reason = str(result.get("reason") or "算不了") return _Miss(f"没有做成({reason})", f"这份结论没有做成({reason})") @@ -506,7 +650,7 @@ def _check(lex: Lexicon, hit: Hit, result: Mapping[str, Any], names: str) -> _Mi key = hit.claim if key in ("source", "source_not"): subjects = _subjects(result) - named = _named(result, names) + named = next((found for span in spans or (names,) if (found := _named(result, span))), []) # 判定语说「主要来自」的几种:一段(一个因子)为主、集中在前几段;比率拆解的结构为主、各段自身为主 top = code in _SOURCE_VERDICTS if key == "source_not": @@ -520,16 +664,22 @@ def _check(lex: Lexicon, hit: Hit, result: Mapping[str, Any], names: str) -> _Mi 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 + pull = hit.side == "pull" 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)] + subject = "、".join(subjects) if stray: - subject = "、".join(subjects) return _Miss(f"判的主要来源是{subject},不是{'、'.join(stray)}", f"判定的主要来源是{subject},不是{'、'.join(stray)}") + if not named and hit.side in _NAMING_SIDES and not phrased \ + and not ((effect := _EFFECTS.get(str(code))) and effect.search((spans or (names,))[0])): + # 说的来源不是拆解里的段(「主要来自线上渠道」挂了按地区的拆解、「主要来自新客流失」),当没点名 + part = "因子" if result.get("mode") == "factor" else "段" + return _Miss(f"判的主要来源是{subject},这句说的来源不是这份拆解里的{part}", + f"判定的主要来源是{subject},句子里说的来源不是这份拆解里的{part}") if pull and hit.direction: for name in named or subjects: change, _ = _segment_change(result, name) @@ -566,21 +716,11 @@ def _check(lex: Lexicon, hit: Hit, result: Mapping[str, Any], names: str) -> _Mi if key == "normal": return None if code == "within_band" else _verdict_miss(result) if key == "trend": + if hit.run: + return _run_miss(hit, result) 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 + return _direction_miss(hit, "up" if code == "rising" else "down") if key == "no_trend": return None if code == "no_trend" else _verdict_miss(result) if key == "differ": @@ -591,7 +731,7 @@ def _check(lex: Lexicon, hit: Hit, result: Mapping[str, Any], names: str) -> _Mi 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] + spots = [flat.find(_named_value(s) or "\uffff") 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: @@ -603,6 +743,27 @@ def _check(lex: Lexicon, hit: Hit, result: Mapping[str, Any], names: str) -> _Mi return None +def _run_miss(hit: Hit, result: Mapping[str, Any]) -> _Miss | None: + """「连续 N 期上升」「三连降」:按趋势结论记的末尾连续同向核(values 的 run_direction、run_length),不看整体的趋势 + 判定——判定语在方向相反时写「但最近已连续 N 期上升」。期数不少于句子写的 N(没写的至少 3 期),方向一致。没记 + run_direction 的老结论按整体的趋势方向。""" + values = result.get("values") or {} + run = _value(result, "run_length") + need = hit.count if hit.count is not None and hit.count >= 2 else 3 + if run is None or run < need: + shown = "没有记下" if run is None else f"只有 {run} 期" + return _Miss(f"末尾连续同向的期数{shown},不够说连续 {need} 期", f"末尾连续同向的期数{shown},不够说连续 {need} 期") + ran = _run_direction(values.get("run_direction")) + if ran is None and "run_direction" not in values: + ran = {"rising": "up", "falling": "down"}.get(str(_verdict(result).get("code"))) + if hit.direction and ran != hit.direction: + if ran is None: + return _Miss("没有记下末尾连续变化的方向", "这份结论没有记下末尾连续变化的方向") + return _Miss(f"末尾连续{_dir_word(ran)},不是连续{_dir_word(hit.direction)}", + f"末尾连续{_dir_word(ran)},与「{hit.word.replace(chr(0), '')}」的方向相反") + 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)}", @@ -618,9 +779,9 @@ def _direction_miss(hit: Hit, direction: Any) -> _Miss | None: _FALLBACK = { "decompose": "只写各段各自的变化,不下「主要」「集中」「拖累」「分散」这类判断", "mix": "只写比率和各段的变化,不说结构变化", - "baseline": "只写本期的数和变化,不说「异常」「大幅」「激增」「历史新高」「正常波动」这类判断", + "baseline": "只写本期的数和变化,不说「异常」「大幅」「显著下降」「激增」「历史新高」「正常波动」这类判断", "trend": "只写各期的数,不说「持续」「趋势」「连续」这类判断", - "differ": "只写各组各自的数,不说「显著」「明显高于」「差异不大」这类判断", + "differ": "只写各组各自的数,不说「显著高于」「明显高于」「差异不大」这类判断", } @@ -659,6 +820,49 @@ def _causal(snippet: str) -> _Reason: f"「{snippet}」没有依据:数据只能说明变化来自哪里;要写原因,引用知识库原话,或写成「可能与…有关,待核实」") +def _object(sentence: Sentence, hit: Hit, left: int, right: int) -> tuple[str, ...]: + """来源类判断说的那一边(点名用的样子 names),近的在前:「主要来自」「集中在」后面到分句末尾,「以…为主」判断词里, + 「主导」「贡献最大」前面——同一分句里,再往前一个个分句(「线上渠道表现突出,贡献最大」);拖累、拉动、带动和别的类看 + 整个分句。""" + names = sentence.names + if hit.side == "after": + return (names[hit.start:right],) + if hit.side == "inside": + return (names[hit.start:hit.end],) + if hit.side != "before": + return (names[left:right],) + spans = [names[left:hit.end]] + cut = left - 1 + while cut > 0: + prev, _ = _clause(sentence.text, cut, cut) + spans.append(names[prev:cut]) + cut = prev - 1 + return tuple(spans) + + +def _cause(sentence: Sentence, hit: Hit, left: int, right: int) -> str: + """因果词说的原因(点名用的样子 names):「由于、因为、原因是、源于、得益于」后面到分句末尾,「受…影响」中间,「导致、 + 造成、引起、所致、是…的主要原因」前面到分句开头——前面有「由」的(「由华东造成」)从「由」后面起。""" + names = sentence.names + if hit.side == "after": + return names[hit.end:right] + if hit.side == "inside": + return names[hit.start:hit.end] + by = [m.end() for m in re.finditer(r"由(?!于)", sentence.text[left:hit.start])] + return names[left + by[-1]:hit.start] if by else names[left:hit.start] + + +#: 原因只是指代(「以上是下降的主要原因」「下降的主要原因:」,列出原因的那几句各自核):这一句不核 +_POINTER = re.compile(r"(?:这|那|此|其|以上|上述|前述|如下|以下|下列)(?:些|个|几个|几点|两点|方面|原因|因素)?") +_FILLING = re.compile(r"[\x00\s\W_]|的|都|也|就|主要|正是|便是|即") + + +def _said_cause(cause: str) -> bool: + """原因说了实在的东西:去掉标点、空白、虚词之后不是空的,也不只是指代。""" + rest = _FILLING.sub("", cause) + return bool(rest) and _POINTER.fullmatch(rest) is None + + 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 编号]}(没挂时 @@ -670,6 +874,9 @@ def check_sentence(sentence: Sentence, results: Mapping[str, Mapping[str, Any]], if not hits: return [] mine = [(a, results[a]) for a in dict.fromkeys(sentence.analyses) if a in results] + #: 这句挂的拆解(原因点名的是它的段就按来源核),和目录里所有的拆解(没挂时认原因是不是哪份拆解的段) + decomposes = [r for _, r in mine if r.get("op") == "decompose"] + known = [r for r in results.values() if r.get("op") == "decompose"] out: list[dict[str, Any]] = [] #: 同一分句里同一类只报一处(「由于促销结束导致下降」是一处没依据的原因,不是两处) seen: set[tuple[str, str, int]] = set() @@ -679,17 +886,20 @@ def check_sentence(sentence: Sentence, results: Mapping[str, Mapping[str, Any]], left, right = _clause(sentence.text, hit.start, hit.end) names = sentence.names[left:right] snippet = _snippet(sentence.shown, hit.start, hit.end) + spans = _object(sentence, hit, left, right) 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") + cause = _cause(sentence, hit, left, right) + if not _said_cause(cause): + continue # 「以上是下降的主要原因」「主要原因:」:原因在别处,各自核 + if any(_named(r, cause) for r in decomposes): + # 原因点名的是所挂拆解的段或因子(「下降主要由华东造成」):按来源核,这是记账意义上的「来自」 + hit, claim, spans = replace(hit, claim="source", side="cause"), lex.claim("source"), (cause,) + elif not decomposes and (not known or any(_named(r, cause) for r in known)) \ + and any(h is not hit and h.claim in _SOURCE_KEYS and left <= h.start < right for h in hits): + continue # 没挂拆解:并进同一分句里的来源判断,那一处报没挂拆解 else: key = (CAUSAL, claim.key, left) if key not in seen: @@ -703,7 +913,8 @@ def check_sentence(sentence: Sentence, results: Mapping[str, Mapping[str, Any]], 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] + phrased = {alias for at, _, alias in sentence.phrases if left <= at < right} + misses = [(a, _check(lex, hit, r, names, spans=spans, phrased=a in phrased)) for a, r in candidates] if any(miss is None for _, miss in misses): continue alias, miss = misses[0] @@ -761,7 +972,7 @@ def results_of(catalog: Mapping[str, Any], load: Any = None) -> dict[str, dict[s def _sentence(unit: dict[str, Any], aliases: Iterable[str], results: Mapping[str, Any]) -> Sentence | None: """文档里的一个句子单元 → Sentence。写作者自己写的字(文字片段、没有出处的数字)留在 text;引用渲染出来的值、表名 - 字段名留在 names(点名用);判定语(a:)、知识库原话(q:)、排版符号两边都涂掉。""" + 字段名留在 names(点名用);判定语(a:)、知识库原话(q:)、排版符号两边都涂掉,判定语另记下位置(phrases)。""" segments = [s for s in unit.get("segments") or [] if isinstance(s, dict)] if not segments: return None @@ -769,6 +980,7 @@ def _sentence(unit: dict[str, Any], aliases: Iterable[str], results: Mapping[str name_parts: list[str] = [] shown_parts: list[str] = [] pieces: list[tuple[int, int, str | None]] = [] + phrases: list[tuple[int, int, str]] = [] quoted = False pos = 0 for seg in segments: @@ -783,6 +995,8 @@ def _sentence(unit: dict[str, Any], aliases: Iterable[str], results: Mapping[str scan_parts.append(blank) name_parts.append(blank) quoted = quoted or (head == "q" and seg.get("state") == "deterministic") + if head == "a" and (phrase := _PHRASE_REF.match(ref)): + phrases.append((pos, pos + len(text), phrase.group(1))) elif ref or kind == "entity": scan_parts.append(blank) name_parts.append(text) @@ -797,19 +1011,20 @@ def _sentence(unit: dict[str, Any], aliases: Iterable[str], results: Mapping[str 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) + inputs=any(a.startswith("i:") for a in cited), base=base, unit=unit.get("id"), pieces=pieces, + phrases=phrases) def check_doc(doc: dict[str, Any], catalog: Mapping[str, Any], *, cited: Mapping[int, list[str]] | None = None, version: int | None = None, load: Any = None) -> list[dict[str, Any]]: """一份报告文档的说法违规。cited 是 {id(unit): 这句按重新解析的引用算的依据}(verify_doc 交进来),不给就用文档里 - 记的 cites;load 取回只带摘要的条目的整份结论(results_of)。标题、代码块、表头不核。""" + 记的 cites;load 取回只带摘要的条目的整份结论(results_of)。标题、代码块、表格(表头和正文的格)不核。""" results = results_of(catalog, load) 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: + if block.get("type") in ("heading", "code", "table") or unit.get("kind") in ("heading", "code") \ + or unit.get("loc") is not None: continue aliases = cited.get(id(unit), []) if cited is not None else unit.get("cites") or [] sentence = _sentence(unit, aliases, results) @@ -874,9 +1089,10 @@ ANALYSIS_RULES_PROMPT = ( "分析结论(系统会逐句核对判断词):\n" "- 「主要来自」「集中在」「拖累」「带动」「分散」「异常」「大幅」「激增」「历史新高」「正常波动」「持续上升」「趋势」" "「显著高于」「差异不大」这类判断,只能照证据目录里分析结论的判定写,句末挂对应的依据,比如 [[see:A1]];最好直接引用" - "判定语 [[a:A1]],它由系统按判定渲染。来源、集中、拖累、分散挂变化拆解,结构变化挂比率拆解,异常、新高新低、正常波动" - "挂波动基线,持续、趋势、连续挂趋势,显著、明显高于、差异不大挂差异检验。\n" - "- 判定和你想写的不一样时照判定写;点名的地区、渠道要在判定点名的段里;没有做成的分析照实写没做成的原因,不下这类判断。" + "判定语 [[a:A1]],它由系统按判定渲染。来源、集中、拖累、分散挂变化拆解,结构变化挂比率拆解,异常、大幅、显著上升或下降、" + "新高新低、正常波动挂波动基线,持续、趋势、连续挂趋势,显著高于、明显高于、差异不大挂差异检验。\n" + "- 判定和你想写的不一样时照判定写;写来源要点名是哪一段,点名的地区、渠道要在判定点名的段里;没有做成的分析照实写" + "没做成的原因,不下这类判断。" "没有对应的分析结论,就只陈述数字和变化。\n" "- 拆解只说明变化来自哪里,不说明为什么。写原因(因为、由于、导致、受…影响)要有知识库原话或运行输入作依据," "否则写成待核实的推测,比如「可能与促销结束有关,待核实」。" diff --git a/backend/app/engine/analysis_scope.py b/backend/app/engine/analysis_scope.py index c5755f54..a982e7c4 100644 --- a/backend/app/engine/analysis_scope.py +++ b/backend/app/engine/analysis_scope.py @@ -11,6 +11,8 @@ - **没拆**(not_decomposed):候选维度里没拆过的。候选维度确定地从数据目录算:拆解所用查询的 SQL 里的事实表(目录标了 fact / log;没标的、查询只读一张表时就是它)上标成属性、状态(attribute / status)、不遮罩、取值不多(码值表不超过 CANDIDATE_MAX 个取值,或者已列全)的列,加上它关联的维度表(目录标了 dimension,或者关系是多对一)上同样的列。 + 「取值不多」只认码值表:剖析目前不记每列的取值数(码值列另有取值分布的观察、键列只核唯一),方案 §3.6 说的「剖析的 + 取值数 ≤ 50」等剖析记了每列取值数再补(第 1 波复核 R34,记为偏离);没有码值表的属性列不进候选。 拆解按的列对不上目录、读不到目录、认不出事实表的,not_decomposed 为 None,note 写一句为什么核对不了——不乱列 - **检验**(tests):这次做了几次差异检验(做成了的);corrected:一次检验比了多对的按次数校正过,几次检验之间没有校正, 做了不止一次时为 False(契约之外加的一项,见交付说明) @@ -138,7 +140,7 @@ def _table_notes(tables: Mapping[str, Any], name: str) -> tuple[str, dict[str, A def _few_values(column: Mapping[str, Any]) -> bool: - """取值不多:码值表列出的取值不超过 CANDIDATE_MAX 个,或者标了已列全。""" + """取值不多:码值表列出的取值不超过 CANDIDATE_MAX 个,或者标了已列全。只认码值表:剖析不记每列的取值数(见模块说明)。""" codes = column.get("codes") if not isinstance(codes, Mapping) or codes.get("status") == "rejected": return False diff --git a/backend/app/engine/autofix.py b/backend/app/engine/autofix.py index e97af135..e0ed46d6 100644 --- a/backend/app/engine/autofix.py +++ b/backend/app/engine/autofix.py @@ -24,6 +24,7 @@ from typing import Any, Callable, Iterator from pydantic import BaseModel from app.engine.governance import ( + _POLICY_PROBLEMS, JUDGE_UNLIMITED, REPORT_POLICY, REPORT_POLICY_ALSO, @@ -32,6 +33,7 @@ from app.engine.governance import ( ) from app.engine.labels import CHOICE_LABEL, contract_label, field_label, option_label, q from app.engine.schema import ( + ANALYSIS_CLAIMS_VALUES, CLAIMS_JUDGE, JUDGE_ON_UNSUPPORTED, REPORT_CLAIMS, @@ -39,7 +41,6 @@ from app.engine.schema import ( GraphSpec, NodeType, _ancestors, - claims_problem, ) #: 被选中的修复按这个顺序应用:先补契约骨架,再补契约里的各项,最后是节点上的开关。 @@ -50,7 +51,7 @@ _ORDER = ( "contract.report_from_missing", "contract.report_from_invalid", "governed.report_from_required", "governed.exit_text_source", "report.metrics_from_invalid", "contract.required_missing", "contract.cells_undeclared", "contract.strict_off", "contract.claims_ignored", "report.claims_invalid", - "governed.report_policy", "governed.judge_budget", + "report.analysis_claims_invalid", "governed.report_policy", "governed.judge_budget", "governed.caliber_agent_cite_fields", "governed.caliber_compute_input", "governed.agent_approval_never", "governed.default_approval_never", "governed.subgraph_unpinned", "governed.supervisor", "contract.not_object", "governed.text_bypass", "governed.caliber_agent_schema", "governed.caliber_model_input", @@ -239,8 +240,9 @@ def _plan_exit_fields(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) def _plan_report_policy(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: - """G3:报告撰写节点写明最严的三项。答案唯一,只往严里改;写了不认识的 claims 由 claims_invalid 管。 - 已经写了更严的写法(claims: judge)的不动——改回 require_citation 就是替人放宽了。""" + """G3:报告撰写节点写明最严的几项。答案唯一,只往严里改;写了不认识的 claims、analysis_claims 不在这里改,由校验的 + claims_invalid、analysis_claims_invalid 管(门禁 G3 同样跳过它们、交给校验报)。已经写了更严的写法(claims: judge) + 的不动——改回 require_citation 就是替人放宽了。""" if node is None: return None writes: dict[str, Any] = {} @@ -250,7 +252,7 @@ def _plan_report_policy(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None effective = ctx.spec.defaults.get(key) if value in (None, "") else value if effective in (want, *REPORT_POLICY_ALSO.get(key, ())): continue - if not (key == "claims" and claims_problem(effective)): + if not ((invalid := _POLICY_PROBLEMS.get(key)) and invalid(effective)): writes[key], before[key] = want, copy.deepcopy(value) if not writes: return None @@ -277,6 +279,21 @@ def _plan_claims(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> di return _choice(f"选择「{node.title}」的「{field_label('claims')}」如何处理", "claims", options) +def _plan_analysis_claims(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + """analysis_claims 写了不认识的值,照 claims 的做法(_plan_claims):受管级别改成 strict,门禁 G3 只认它,答案唯一; + 别的级别三档都行,要人选,不给建议。""" + if node is None: + return None + before = node.config.get("analysis_claims") + if ctx.level == "governed": + return _auto(f"将「{node.title}」的「{field_label('analysis_claims')}」改为「{option_label('analysis_claims', 'strict')}」:" + "受管级别出具要求判断词没挂对分析结论的句子重写后仍有的计入缺口", "analysis_claims", before, "strict") + hints = {"off": "不核对报告里的判断词", "check": "判断词没挂对分析结论的句子交回重写一次,仍有的在报告里标出,不影响出具档位", + "strict": "重写后仍有的计入缺口,出具按档位降档"} + options = [{"value": v, "label": option_label("analysis_claims", v), "hint": hints[v]} for v in ANALYSIS_CLAIMS_VALUES] + return _choice(f"选择「{node.title}」的「{field_label('analysis_claims')}」如何处理", "analysis_claims", options) + + def _plan_judge_budget(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: """G3:claims: judge 没写每份报告的裁判预算。写多少钱只有作者能定:候选是默认金额和「不限」,不替人选、 不给建议。judge 原来写在图级 defaults 里的,写到节点上时带上 defaults 那一整份——节点写了 judge 就整个 @@ -718,6 +735,7 @@ _PLANNERS: dict[str, Callable[[_Ctx, dict[str, Any], GraphNode | None], dict[str "governed.text_bypass": _plan_text_bypass, "governed.report_policy": _plan_report_policy, "report.claims_invalid": _plan_claims, + "report.analysis_claims_invalid": _plan_analysis_claims, "governed.judge_budget": _plan_judge_budget, "contract.cells_undeclared": _plan_cells_undeclared, "governed.caliber_compute_input": _plan_compute_input, diff --git a/backend/app/engine/evidence.py b/backend/app/engine/evidence.py index 3cdbcec6..e73eeba0 100644 --- a/backend/app/engine/evidence.py +++ b/backend/app/engine/evidence.py @@ -2873,16 +2873,11 @@ def _context(text: str, start: int, end: int) -> str: _BOLD = re.compile(r"(\*\*|__)(?P(?:\[\[" + _MARKER_GUARD + r"[^\[\]\n]*?\]\]|(?!\1)[^\n])+?)\1") #: 方向词、因果词:没有数字也没有引用的句子,含这些词才算结论。「同比」「环比」不在里面:它们只说明 #: 跟谁比,本身不是涨跌;真正的比较结论还会带「增长」「下降」或者数字。 -#: 分析判断词(分析能力一轮,engine/analysis_claims 的说法表)也算:「主要来自」「集中在」「异常」「持续下降」「趋势」 -#: 「显著」,裁判优先判这类句子(judge.claim_priority)。「主要」后面是「包括、内容、分为、有、看」这类的是在列提纲, -#: 「持续关注」「持续跟进」是建议,「趋势图」「趋势如下」是在引出图表,「集中采购」「分散风险」说的不是数据,都不算 +#: 分析判断词(「主要」「异常」「趋势」「显著」……)不在这里:一句话算不算结论决定了结论句策略要不要它挂依据、出具缺口 +#: 怎么数,已发布的版本按这份词表定了档位,扩了就变;裁判优先判判断句另用 judge._JUDGEMENT 排序,只影响送判的先后 _DIRECTIONAL = re.compile( r"增长|增加|上升|提升|提高|上涨|下降|下滑|减少|降低|回落|下跌|反弹|超过|高于|低于|领先|落后|最高|最低" r"|最多|最少|来自|导致|因为|由于|带动|拉动|拖累|驱动|原因|归因|贡献|占比|翻倍|持平|波动" - r"|主要(?!包括|内容|分为|分成|有|看|从|是指|指|结论|发现|观点|工作|任务|目的|目标|问题|方面)" - r"|集中(?!采购|管理|处理|展示|讨论|培训)|异常|偏离" - r"|持续(?!关注|跟踪|跟进|观察|监控|推进|优化|改进|更新)|趋势(?![图表线如::])|显著|明显高于|明显低于|大幅|激增" - r"|骤降|暴跌|飙升|新高|新低|抵消|分散(?!注意|精力|风险|投资|采购)" r"|(?= SHORT_CHARS: return 3 diff --git a/backend/app/engine/nodes/report.py b/backend/app/engine/nodes/report.py index 8cfa16b1..84a9708b 100644 --- a/backend/app/engine/nodes/report.py +++ b/backend/app/engine/nodes/report.py @@ -54,6 +54,7 @@ from typing import Any from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage from langgraph.func import task +from app.analysis.contract import LEDGER_KIND from app.core.errors import describe_exception, not_configured from app.core.events import EventType from app.db.base import SessionLocal @@ -73,6 +74,7 @@ from app.engine.evidence import ( UNIT_RULE, StreamRenderer, WriterCatalog, + analysis_card, build_catalog, catalog_budget, compose_doc, @@ -434,19 +436,23 @@ def report_cells_allowed(spec: GraphSpec, node: GraphNode, *, governed: bool) -> #: 目录里能被报告引用的几种来源(沙箱代码节点的登记只为说清为什么不能引用,不算) -_CITABLE = {"metric": "口径卡指标", "query": "查询结果", "retrieval": "知识库检索", "input": "运行输入"} +_CITABLE = {"metric": "口径卡指标", "query": "查询结果", "retrieval": "知识库检索", "input": "运行输入", + LEDGER_KIND: "分析结论"} def _no_evidence(catalog: dict[str, Any], cells: bool) -> str | None: - """目录里没有可引用的来源时给一句话,说清缺的是哪种;有就返回 None。""" + """目录里没有可引用的来源时给一句话,说清缺的是哪种;有就返回 None。分析结论按 00-总方案 D10 认:分析卡做的总能引用 + (判定语、具名的数),Agent 现场做的和它查的格同一个规矩——受管级别的正式出具没开「单元格引用」时引用不了。""" kinds = {entry.get("kind") for entry in catalog.values()} if not kinds & set(_CITABLE): return ("报告撰写节点的上游没有可引用的来源(" + "、".join(_CITABLE.values()) + "都没有)," f"报告中的数字都会被判为没有出处。请在它前面接入取数节点或口径卡,或检查「{field_label('metrics_from')}」") - if not cells and not kinds & {"metric", "input"}: - # 只有查询和检索:单元格引用不了,检索片段本来就只能当依据,一个数都没处引 - return (f"报告撰写节点的上游只有查询结果,没有口径卡指标,而{CELLS_REASON},当前无法引用单元格," - "报告中的数字都会被判为没有出处。请在出具契约中开启「单元格引用」,或把要写的数字登记到口径卡") + if not cells and not kinds & {"metric", "input"} and not any(analysis_card(e) for e in catalog.values()): + # 只有查询、检索和 Agent 现场做的分析:单元格和这些分析结论引用不了,检索片段本来就只能当依据,一个数都没处引 + agent = LEDGER_KIND in kinds + return (f"报告撰写节点的上游只有查询结果{'和 Agent 现场做的分析' if agent else ''},没有口径卡指标,而{CELLS_REASON}," + f"当前无法引用单元格{'和这些分析结论' if agent else ''},报告中的数字都会被判为没有出处。" + "请在出具契约中开启「单元格引用」,或把要写的数字登记到口径卡") return None diff --git a/backend/app/engine/review.py b/backend/app/engine/review.py index f2a52b73..a0387a69 100644 --- a/backend/app/engine/review.py +++ b/backend/app/engine/review.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from collections import Counter from collections.abc import Iterable, Mapping from dataclasses import dataclass from typing import Any @@ -141,6 +142,13 @@ WARN_CODES: dict[str, tuple[str, str]] = { # 缺口降档),是缺口不是断裂;但换一张有这段数据的表再建一次大概率就好,所以能给出重来的指示(REBUILD) "query_no_data": ("query_no_data", DEGRADED), "metric_no_data": ("metric_no_data", DEGRADED), + # 分析能力一轮(00-总方案 §3.7):只写说明、不自动重来(NOTE_ONLY)。答案照常交付,缺口照实说一句就够了: + # 报告里判断词没挂对分析结论、原因没有依据,重写后仍有(check 标在报告里,strict 另记出具缺口) + "report_analysis_claims": ("report_analysis_claims", DEGRADED), + # 一份分析算不了(历史不够、本期不完整、截断……):算不了本身是一条结论,写作者照实写原因 + "analysis_refused": ("analysis_refused", DEGRADED), + # 口径卡上用到两次查询的指标没做成可比性检查(探查失败、算不了):指标照常算,两期能不能直接比要人自己核对 + "metric_comparability_skipped": ("metric_comparability_skipped", DEGRADED), } # 这些 kind 重跑一次大概率能好:原因说得清,且不是「问题本身没问明白」。 @@ -150,6 +158,10 @@ RETRYABLE = {"step_limit", "tool_error", "empty_completion", "node_failed"} #: 答案照常交付,复核照样可以给出「按复核重来」的指示(should_retry、chat/turn.retry_after_review 都认) REBUILD = {"query_no_data", "metric_no_data"} +#: 只写说明、不自动重来的信号(00-总方案 §3.7):都是缺口(DEGRADED)、不进 RETRYABLE / REBUILD。一次运行的信号全是 +#: 这几种时复核不叫模型,说明就照信号原话写(review):答案照常、报告里已经标出,叫一次模型只是把同一句话换个说法 +NOTE_ONLY = frozenset({"report_analysis_claims", "analysis_refused", "metric_comparability_skipped"}) + #: 串行工具模式下塞给未执行调用的占位说明。它漏到用户面前当过答案, #: 所以复核要认得出来。从 nodes.llm 导入会绕成循环,这里按内容识别。 _PLACEHOLDER_MARKS = ("本轮只执行了第一个工具",) @@ -297,6 +309,8 @@ def _step_name(node_id: str, titles: Mapping[str, str]) -> str: #: 报告节点「改过一次」的信号:按同一个节点最后一次 report.checked 的结果定档(最终通过的不是缺口) _SETTLED_BY_REPORT = ("report_repair", "report_rewrite") +#: report.checked 最多带几条违规(报告节点截到前 20 条):带满了的,后面可能还有,不当作只剩标注 +_CHECKED_CAP = 20 #: 证据不支持的几种判定(engine/judge 的 CONTRADICTED 加证据不足):最终还剩这些,改写就没有改好 _UNSUPPORTED = ("contradicted", "unsupported", "insufficient") @@ -311,20 +325,38 @@ def _final_checks(rows: list[Any]) -> dict[str, dict[str, Any]]: return out +def _annotations(final: Mapping[str, Any]) -> list[Mapping[str, Any]] | None: + """最后一次核对里剩下的违规都是只重写一次、留在报告里标注的那几类时,返回它们(没剩违规是空列表);还有别的违规返回 + None。标注的是:缺失断言(absence_claim,另发 report_absence_claim)、check 下的分析说法(engine/analysis_claims,另发 + report_analysis_claims)——各有自己的信号说清楚,「改过一次」是不是改好了不看它们。strict 下的分析说法是出具缺口, + 不算标注。""" + from app.engine.analysis_claims import CODES as ANALYSIS_CODES + + left = [v for v in final.get("violations") or [] if isinstance(v, Mapping)] + marked = {"absence_claim", *(ANALYSIS_CODES if final.get("analysis_claims") == "check" else ())} + if final.get("ok") is True and not left: + return [] + if not left or len(left) >= _CHECKED_CAP or any(v.get("code") not in marked for v in left): + return None + return left + + def _settled(code: str, final: Mapping[str, Any] | None) -> bool: - """「改过一次」之后最终是不是好了:report_repair 看最后一次核对有没有违规(ok、violations 为空);report_rewrite - 看最后的裁判摘要里还有没有证据不支持的句子。找不到最终结果(老运行、节点失败没写出 report.checked)的当没好。""" + """「改过一次」之后最终是不是好了:report_repair 看最后一次核对有没有违规(ok、violations 为空),只剩标注 + (_annotations)的也算好了;report_rewrite 看最后的裁判摘要里还有没有证据不支持的句子。找不到最终结果(老运行、 + 节点失败没写出 report.checked)的当没好。""" if not final: return False if code == "report_repair": - return final.get("ok") is True and not final.get("violations") + return _annotations(final) is not None counts = ((final.get("judge") or {}).get("counts") or {}) if isinstance(final.get("judge"), dict) else {} return bool(counts) and not any(int(counts.get(k) or 0) for k in _UNSUPPORTED) def notes(events: Iterable[Any]) -> list[str]: """不是异常、但复核模型被别的信号叫起来时该知道的事(review_prompt 的「已经处理好的」):报告首稿没过核对、重写后 - 已全部通过;裁判认为证据不支持的句子改写后都有了依据。只说处理好了的,没好的照旧是信号(scan)。""" + 已全部通过(只剩标注的照实说剩下几处,那几处各有自己的信号);裁判认为证据不支持的句子改写后都有了依据。只说处理好 + 了的,没好的照旧是信号(scan)。""" rows = list(events) finals = _final_checks(rows) titles: dict[str, str] = {} @@ -349,7 +381,14 @@ def notes(events: Iterable[Any]) -> list[str]: if code == "report_repair": head = f"首稿有 {count} 处没过核对" if isinstance(count, int) and count > 0 else "首稿没过核对" again = f"重写 {times[(node_id, code)]} 次后" if times[(node_id, code)] > 1 else "重写后" - out.append(f"{name}{head},{again}已全部通过") + left = _annotations(finals.get(node_id) or {}) or [] + if not left: + out.append(f"{name}{head},{again}已全部通过") + elif isinstance(count, int) and count > len(left): + said = {"absence_claim": "缺失断言"} + kinds = Counter(said.get(str(v.get("code")), "分析说法") for v in left) + rest = "、".join(f"{n} 处{kind}" for kind, n in kinds.items()) + out.append(f"{name}{head},{again}只剩已标出的 {rest}") else: head = f"有 {count} 句结论裁判认为证据不支持" if isinstance(count, int) and count > 0 \ else "有几句结论裁判认为证据不支持" @@ -486,6 +525,9 @@ async def review( fallback_note = ";".join(s.detail for s in signals if s.severity == BROKEN) or ";".join( s.detail for s in signals ) + if signals and all(s.kind in NOTE_ONLY for s in signals): + # 只写说明、不自动重来(NOTE_ONLY):不叫模型,照信号原话说明 + return ReviewResult(verdict="annotated", note=fallback_note, retry=False, signals=signals, severity=severity) messages = [("system", REVIEW_SYSTEM), ("human", review_prompt(question, answer, signals, locked=locked, settled=settled))] diff --git a/backend/tests/test_analysis_claims.py b/backend/tests/test_analysis_claims.py index 5a5b9a8c..7037e6df 100644 --- a/backend/tests/test_analysis_claims.py +++ b/backend/tests/test_analysis_claims.py @@ -43,7 +43,7 @@ def codes(found: list[dict]) -> list[str]: @pytest.mark.parametrize("text, claim", [ ("下降主要来自华东。", "source"), ("增长主要由线上贡献。", "source"), ("下降集中在华东、华北。", "source"), - ("绝大部分来自线下。", "source"), ("华东拖累了整体。", "source"), ("线上带动了增长。", "source"), + ("增长绝大部分来自线下。", "source"), ("华东拖累了整体。", "source"), ("线上带动了增长。", "source"), ("华东贡献最大。", "source"), ("下降主要是华东下滑造成的。", "source"), ("变化分散在各地区。", "dispersed"), ("各地区都在下降。", "dispersed"), ("各渠道普遍下滑。", "dispersed"), ("全面下降。", "dispersed"), @@ -53,7 +53,7 @@ def codes(found: list[dict]) -> list[str]: ("销售额明显偏离往常。", "anomaly"), ("超出过去 12 周的常规波动范围。", "anomaly"), ("销售额明显高于正常水平。", "anomaly"), ("波动剧烈。", "anomaly"), ("本周创历史新高。", "record"), ("销售额创新高。", "record"), ("前所未有。", "record"), - ("在常规波动范围内。", "normal"), ("属于正常波动。", "normal"), ("并不异常。", "normal"), + ("在常规波动范围内。", "normal"), ("属于正常波动。", "normal"), ("这一降幅并不异常。", "normal"), ("线上销售额持续上升。", "trend"), ("呈上升趋势。", "trend"), ("一路走低。", "trend"), ("稳步增长。", "trend"), ("连续三周下降。", "trend"), ("连续下滑。", "trend"), ("没有明显的上升或下降趋势。", "no_trend"), ("上升趋势不明显。", "no_trend"), ("基本平稳。", "no_trend"), @@ -138,13 +138,13 @@ def test_dafu_needs_a_baseline_outside_the_band_in_the_same_direction(): @pytest.mark.parametrize("text, claim", [ - ("并未出现明显异常。", "normal"), # 否定隔着「出现明显」 - ("本周没有出现异常。", "normal"), + ("订单数并未出现明显异常。", "normal"), # 否定隔着「出现明显」 + ("本周销售额没有出现异常。", "normal"), ("不在正常范围内。", "anomaly"), ("不属于正常波动。", "anomaly"), ("没有显著高于华东。", "no_diff"), - ("并非主要来自华东。", "source_not"), - ("并不分散。", "source"), + ("下降并非主要来自华东。", "source_not"), + ("下降并不分散。", "source"), ("没有持续上升。", "no_trend"), ]) def test_a_negation_flips_to_the_opposite_class(text, claim): @@ -196,8 +196,10 @@ def test_ascii_names_need_word_boundaries_but_chinese_neighbours_are_fine(): 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})) == [], "一个字的名字不认" + # 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(): @@ -254,6 +256,29 @@ def test_trends_check_the_direction_and_runs_check_the_length(): 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}) == [] @@ -330,11 +355,65 @@ def test_a_cause_outside_the_decomposition_needs_its_own_evidence(): {"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 + + # -------------------------------------------------------------------------- # 文档:哪里不扫、怎么挂、版本 # -------------------------------------------------------------------------- @@ -413,6 +492,19 @@ def test_system_rendered_phrases_quotes_headings_and_code_are_not_scanned(): 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"], @@ -438,6 +530,24 @@ def test_compose_and_verify_bind_the_sentence_to_its_see_marker(): 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) == [] @@ -454,12 +564,18 @@ def test_the_prompt_rules_depend_on_whether_there_are_conclusions(): assert "[[a:A1]]" in ac.ANALYSIS_RULES_PROMPT -def test_judgement_words_make_a_sentence_a_claim_for_the_judge(): +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下面主要看三个方面。\n\n建议持续关注。\n\n近 8 周的趋势图如下:", catalog()) + 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)} - assert kinds["华东异常。"] == ("claim", 2) - assert kinds["下面主要看三个方面。"][0] == "connective" and kinds["建议持续关注。"][0] == "connective" - assert kinds["近 8 周的趋势图如下:"][0] == "connective", "引出图表的话不算判断" + 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) diff --git a/backend/tests/test_analysis_claims_lexicon.py b/backend/tests/test_analysis_claims_lexicon.py new file mode 100644 index 00000000..ad30351c --- /dev/null +++ b/backend/tests/test_analysis_claims_lexicon.py @@ -0,0 +1,154 @@ +"""说法表的样本表(engine/analysis_claims,细案 01 §9.1):常见的周报写法逐句过 scan,每句期望认出的类(按出现的先后, +否定翻过之后的)或「不核」(空)。以后改说法表都过这张表:误报(正常的句子被打回)让报告多重写一次、strict 下降档, +漏报只是少核一处,宁可漏报不误报。 + +前半是第 1 波复核时的 70 句(复核第 4 节),后半是这次调整顺带看清楚的写法。和复核标记不一样的只有一句:「退款主要集中在 +生鲜品类」复核标的是判断,可它说的是退款的构成、没说变化,按「来源、集中只在说了变化时才核」不核。 +""" +from __future__ import annotations + +import pytest + +from app.engine import analysis_claims as ac + +#: 不核 +NO: tuple[str, ...] = () + +SAMPLES: list[tuple[str, tuple[str, ...]]] = [ + # ---- 陈述数字和变化:不核 ---- + ("本周销售额 12.3 万元,环比下降 5.2%。", NO), + ("整体平稳,各品类变化不大。", NO), + ("退款率略有下降。", NO), + ("订单数明显回升,客单价基本持平。", NO), # 「明显」只是程度,不收「明显 + 涨跌」 + ("环比基本持平。", NO), + ("本周数据只到 10 月 16 日,周数据不完整。", NO), + ("客单价相当于上周的 1.1 倍。", NO), + ("订单量与上周相当。", NO), # 比上期:说的是持平,不是两组差异 + # ---- 判断:核 ---- + ("销售额同比增长 8.1%,主要是因为国庆假期客流增加。", ("causal",)), + ("受节假日影响,线下门店客流明显回升。", ("causal",)), + ("华东销售额大幅增长 32%。", ("anomaly",)), + ("线上渠道拉动整体增长。", ("source",)), + ("华东下滑拖累了整体表现。", ("source",)), + ("各地区普遍上涨。", ("dispersed",)), + ("已连续三周下降。", ("trend",)), + ("西南地区退款率显著高于其他地区。", ("differ",)), + ("两个渠道的转化率相当。", ("no_diff",)), + ("促销效果显著,带动订单量增长。", ("source",)), + ("由于华东仓库搬迁,发货延迟导致退款增加。", ("causal", "causal")), + ("线上销售额持续增长,已连续 5 周上升。", ("trend", "trend")), + ("退款金额骤增,需重点关注。", ("anomaly",)), # 「需」在后一个分句,不管前面的判断 + ("新客贡献最大。", ("source",)), + ("这主要是由于华东仓库搬迁。", ("causal",)), + ("整体来看,各渠道均有所下降。", ("dispersed",)), + ("销量下降主要由促销结束造成。", ("source", "causal")), + ("下降主要来自客流减少。", ("source",)), + ("退款主要集中在生鲜品类。", NO), # 复核标的是判断;说的是构成、没说变化,不核 + # ---- 业务上的「异常」、数据质量:不核 ---- + ("本周异常订单 35 单,已全部处理。", NO), + ("部分门店数据异常,已提交核查。", NO), + ("部分订单异常,已退款。", NO), + ("物流异常的订单已全部退款。", NO), + ("支付异常的订单已全部退款。", NO), + ("门店营业异常 2 家。", NO), + ("整体运行平稳,未见异常。", NO), # 主语不是指标、波动 + ("系统运行正常,无异常。", NO), + # ---- 说的是构成,不是变化从哪来:不核 ---- + ("本周订单主要来自线上渠道,占 65%。", NO), + ("销售额集中在周末两天。", NO), + ("华东门店集中在上海、杭州。", NO), + ("客户分散在各个城市。", NO), + ("线上渠道占主导。", NO), + # ---- 建议、计划、预测:不核 ---- + ("下一步将全面提升门店服务水平。", NO), + ("建议加大投放,拉动新客增长。", NO), # 「建议」管到后面的分句 + ("通过会员活动带动复购。", NO), # 带动的不是变化 + ("下周将持续提升转化率。", NO), + ("建议持续降低退款率。", NO), + ("计划逐步提高线上占比。", NO), + ("下一步将大幅提升配送效率。", NO), + ("下周重点主要是提升转化率。", NO), + ("避免缺货导致客诉。", NO), + ("预计下周将继续保持增长态势。", NO), + # ---- 别的误报:不核 ---- + ("订单来源于线上渠道。", NO), # 「来源于」不是「源于」 + ("数据来源于业务系统,金额含税。", NO), + ("本周数据源表结构变化,部分字段暂缺。", NO), # 表结构不是结构变化 + # ---- 漏报,补上:核 ---- + ("华东是本周下降的主要原因。", ("causal",)), # 原因点名的是拆解的段时按来源核 + ("近七成的降幅来自华东。", ("source",)), + ("超过一半的下降来自华东。", ("source",)), + ("增长主要靠线上。", ("source",)), + ("降幅主要在华东。", ("source",)), + ("下降主要体现在华东。", ("source",)), + ("增长以线上为主。", ("source",)), + ("销售额创年内新高。", ("record",)), + ("销售额创近一年新高。", ("record",)), + ("整体趋势向好。", ("trend",)), + ("本周销售额波动较大。", ("anomaly",)), + ("订单量三连降。", ("trend",)), + ("退款率显著下降。", ("anomaly",)), + ("销售额远高于往常。", ("anomaly",)), + ("本周销售额出现异动。", ("anomaly",)), + ("华东和华南一涨一跌。", ("offset",)), + # ---- 这次调整顺带看清楚的写法 ---- + ("订单大幅减少。", ("anomaly",)), # 「订单」只在「订单异常」里是业务状态 + ("不少门店大幅下滑。", ("anomaly",)), + ("订单异常下降。", ("anomaly",)), # 「异常」后面跟着涨跌:说的是指标 + ("华东线上订单出现异常的下降。", ("anomaly",)), + ("异常的门店已全部整改。", NO), + ("两个渠道的转化率与上周相当。", NO), + ("华东和华南的退款率水平相当。", ("no_diff",)), + ("销售额并未出现明显异常。", ("normal",)), + ("本周没有出现异常。", NO), + ("退款率并未显著下降。", ("normal",)), + ("下降主要来自华东,建议加大华东投放。", ("source",)), # 「建议」在后一个分句 + ("销售额\x00\x00,环比\x00\x00,主要来自华东。", ("source",)), # 引用渲染的数涂成 \x00,「环比」说的是变化 + ("订单主要来自线上渠道,占比较上周提升 5 个百分点。", NO), # 变化在后一个分句:前面说的是构成 + ("新增客户主要来自线上渠道。", NO), + ("增量主要来自线上。", ("source",)), + ("下降主要源于华东。", ("source",)), + ("订单以线上为主。", NO), + ("线上渠道表现突出,贡献最大。", ("source",)), + ("下降的主要原因是华东门店客流减少。", ("causal",)), + ("受促销结束影响,华东销售额下降。", ("causal",)), + ("近 8 周销售额持续下降,已连续三周下降。", ("trend", "trend")), + ("线上销售额十二连涨。", ("trend",)), + ("整体走势向下。", ("trend",)), + ("销售额创下了近一年来新高。", ("record",)), + ("创新能力最高的门店是华东店。", NO), + ("预计下周销售额将创新高。", NO), +] + + +@pytest.mark.parametrize("text, expected", SAMPLES, ids=[t for t, _ in SAMPLES]) +def test_the_sample_table(text, expected): + found = ac.scan(text) + assert tuple(h.claim for h in found) == expected, [(h.claim, h.word) for h in found] + + +@pytest.mark.parametrize("text, expected", SAMPLES, ids=[t for t, _ in SAMPLES]) +def test_a_sentence_is_reported_without_conclusions_exactly_when_it_carries_a_judgement(text, expected): + """没有分析结论可挂时:认出判断的句子至少报一处(没挂、原因没依据),不核的一处都不报。""" + assert bool(ac.check_sentence(ac.Sentence(text=text), {})) == bool(expected) + + +def test_the_sample_table_is_not_shorter_than_the_review(): + assert len(SAMPLES) >= 70 and len({t for t, _ in SAMPLES}) == len(SAMPLES) + + +@pytest.mark.parametrize("text, count", [("已连续三周下降。", 3), ("线上销售额持续增长,已连续 5 周上升。", 5), + ("线上销售额十二连涨。", 12), ("连续两个月下滑。", 2), ("连续多周上升。", None), + ("订单量三连降。", 3), ("连续10周上升。", 10)]) +def test_runs_carry_the_number_of_periods_they_claim(text, count): + [run] = [h for h in ac.scan(text) if h.run] + assert run.count == count + + +@pytest.mark.parametrize("label, claims", [("主要来自华东的销售额", ["source"]), ("集中在线上的订单", ["source"]), + ("建议持续降低的退款率", ["trend"]), ("部分订单异常", ["anomaly"]), + ("销售额", [])]) +def test_bare_scanning_finds_judgement_words_in_a_name(label, claims): + """认叫法里夹带的判断词(bare=True)只按词认:叫法是一个名字不是一句话,构成、建议计划、业务状态这些整句话的规矩不用。""" + assert [h.claim for h in ac.scan(label, bare=True)] == claims + assert ac.scan(label) == [] diff --git a/backend/tests/test_governance_analysis_claims.py b/backend/tests/test_governance_analysis_claims.py index 9d481dfb..4bc494ef 100644 --- a/backend/tests/test_governance_analysis_claims.py +++ b/backend/tests/test_governance_analysis_claims.py @@ -95,6 +95,31 @@ def test_the_autofix_writes_it_in(): assert write["data"]["config"]["analysis_claims"] == "strict" +def test_an_unrecognized_value_is_left_to_its_own_fix_like_claims(): + """analysis_claims 写了认不出的值:门禁 G3 跳过、交给校验报,自动修复的 G3 那一条也不碰它(以前顺手改成了 strict), + 由校验那条问题自己的修复管——照 claims 的做法:受管级别改成 strict,别的级别三档要人选。""" + graph = traceable({**STRICT, "numbers": "off", "analysis_claims": "loose"}) + spec = GraphSpec.model_validate(graph) + fixes = {f["id"]: f for f in plan_fixes(spec, publish_issues(spec, level="governed"), level="governed")} + assert fixes["governed.report_policy:write"]["preview"] == {"field": "numbers", "before": "off", "after": "strict"} + bad = fixes["report.analysis_claims_invalid:write"] + assert bad["kind"] == "auto" and bad["preview"] == {"field": "analysis_claims", "before": "loose", "after": "strict"} + out = apply_fixes(graph, list(fixes), level="governed") + write = next(n for n in out["graph"]["nodes"] if n["id"] == "write")["data"]["config"] + assert out["ok"] is True and write["analysis_claims"] == "strict" and write["numbers"] == "strict" + + published = traceable({"analysis_claims": "loose"}) + spec = GraphSpec.model_validate(published) + fid = "report.analysis_claims_invalid:write" + [fix] = [f for f in plan_fixes(spec, publish_issues(spec, level="published"), level="published") if f["id"] == fid] + assert fix["kind"] == "choice" and [o["value"] for o in fix["options"]] == ["off", "check", "strict"] + assert [o["label"] for o in fix["options"]] == [option_label("analysis_claims", v) for v in ("off", "check", "strict")] + assert "default" not in fix + out = apply_fixes(published, [fid], {fid: "check"}, level="published") + assert out["applied"] == [fid] and next(n for n in out["graph"]["nodes"] if n["id"] == "write")[ + "data"]["config"]["analysis_claims"] == "check" + + def test_a_governed_upgrade_writes_the_strict_setting(): from app.engine.upgrade import _policy diff --git a/backend/tests/test_report_analysis_claims.py b/backend/tests/test_report_analysis_claims.py index 7c72ddfe..cdfcb074 100644 --- a/backend/tests/test_report_analysis_claims.py +++ b/backend/tests/test_report_analysis_claims.py @@ -265,6 +265,61 @@ async def test_the_scope_is_written_only_when_there_are_conclusions(monkeypatch, assert "analysis_scope" not in doc +# -------------------------------------------------------------------------- +# 上游的可引用来源认分析结论(00-总方案 D10) +# -------------------------------------------------------------------------- + + +def card_entry(alias: str) -> dict: + """分析卡做的结论(台账条目带 item、不带 call_id)。""" + return {**a_entry(alias, fake_result()), "item": "drop", "node_id": "card"} + + +def agent_entry(alias: str) -> dict: + """Agent 的分析工具现场做的结论(带 call_id)。""" + return {**a_entry(alias, fake_result()), "call_id": "call-1", "node_id": "ask"} + + +@pytest.mark.parametrize("cells", [True, False]) +def test_conclusions_from_an_analysis_card_are_citable_evidence(cells): + """「证据来自」只选了分析卡:分析卡的结论总能引用(判定语、具名的数),不报「上游没有可引用的来源」;受管级别的正式 + 出具没开「单元格引用」时也不报「只有查询结果」。""" + assert report_mod._no_evidence({"A1": card_entry("A1")}, cells) is None + assert report_mod._no_evidence({"Q1": {"kind": "query"}, "A1": card_entry("A1")}, cells) is None + + +def test_conclusions_from_an_agent_follow_the_cells_rule(): + catalog = {"Q1": {"kind": "query"}, "A1": agent_entry("A1")} + assert report_mod._no_evidence(catalog, True) is None + warning = report_mod._no_evidence(catalog, False) + assert warning and "只有查询结果和 Agent 现场做的分析" in warning and "无法引用单元格和这些分析结论" in warning + plain = report_mod._no_evidence({"Q1": {"kind": "query"}}, False) + assert plain and "只有查询结果,没有口径卡指标" in plain and "Agent" not in plain + + +def test_nothing_citable_names_analysis_conclusions_too(): + warning = report_mod._no_evidence({}, True) + assert warning and all(kind in warning for kind in ("口径卡指标", "查询结果", "知识库检索", "运行输入", "分析结论")) + + +# -------------------------------------------------------------------------- +# 判断词不改一句话算不算结论:已发布版本的档位不变 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("extra", ["整体运行平稳,未见异常。", "下面是主要渠道的表现。", "促销期间活动效果显著。"]) +async def test_judgement_words_do_not_turn_connective_sentences_into_uncited_claims(monkeypatch, extra): + """已发布的受管版本(结论句要挂依据、出口带契约):没有数字、没有引用的话照旧是连接性的话,不因为带了「主要」「异常」 + 「显著」就算没挂依据的结论、记缺口降档(判断词只给裁判排送判的先后,judge._JUDGEMENT)。""" + graph = weekly(numbers="strict", on_violation="flag", claims="require_citation") + graph["nodes"][-1]["data"]["config"]["contract"] = { + "report_from": "write", "metrics_from": ["caliber"], "required": ["gmv"], "strict": True} + script(monkeypatch, f"{PLAIN}\n\n{extra}") + issuance = (await finish(graph)).output["_issuance"] + assert issuance["tier"] == "formal" and issuance["gaps"] == [] + assert issuance["claims"] == {"policy": "require_citation", "uncited_claims": 0, "uncited": []} + + # -------------------------------------------------------------------------- # 取值写错 # -------------------------------------------------------------------------- diff --git a/backend/tests/test_review.py b/backend/tests/test_review.py index d5dbbaf8..fc1c8e20 100644 --- a/backend/tests/test_review.py +++ b/backend/tests/test_review.py @@ -476,3 +476,97 @@ def test_a_rewrite_counts_by_the_final_judgement(): judge={"counts": {"supported": 2, "contradicted": 1, "insufficient": 0}})] assert rv.scan(good, OK_OUTPUT) == [] and rv.notes(good) == ["报告「写周报」有 1 句结论裁判认为证据不支持,改写后都有了依据"] assert [s.kind for s in rv.scan(bad, OK_OUTPUT)] == ["report_rewrite"] and rv.notes(bad) == [] + + +# -------------------------------------------------------------------------- +# 分析能力一轮的信号:只写说明、不自动重来(00-总方案 §3.7) +# -------------------------------------------------------------------------- + +#: check 下判断词重写一次后仍没挂对分析结论:报告照常产出,标在报告里 +CLAIMS_LEFT = "报告有 1 处分析说法未通过核对(1 处判断没挂分析结论:「主要来自」),已要求模型重写 1 次仍未改掉:…这几处已标注" +REFUSED = "「周报分析」的波动基线没有做成:历史只有 5 期,至少要 8 期" +SKIPPED = "指标「销售额环比」用到的两次查询没做成可比性检查:探查超时。指标照常计算,两期能不能直接比请自行核对" + + +def test_analysis_signals_are_registered_as_gaps_that_only_need_a_note(): + signals = rv.scan([warn(CLAIMS_LEFT, "report_analysis_claims"), warn(REFUSED, "analysis_refused"), + warn(SKIPPED, "metric_comparability_skipped")], OK_OUTPUT) + assert [(s.kind, s.severity) for s in signals] == [ + ("report_analysis_claims", rv.DEGRADED), ("analysis_refused", rv.DEGRADED), + ("metric_comparability_skipped", rv.DEGRADED)] + assert {s.kind for s in signals} == rv.NOTE_ONLY + assert not rv.should_retry(signals) and not rv.NOTE_ONLY & (rv.RETRYABLE | rv.REBUILD) + + +@pytest.mark.asyncio +async def test_a_run_with_only_note_only_signals_is_annotated_without_calling_the_model(): + model = FakeModel(AssertionError("不该叫模型")) + signals = rv.scan([warn(CLAIMS_LEFT, "report_analysis_claims"), warn(REFUSED, "analysis_refused")], OK_OUTPUT) + result = await rv.review(model, question="q", answer="答案", signals=signals, locked=True) + assert model.calls == 0 + assert result.verdict == "annotated" and result.retry is False and result.severity == rv.DEGRADED + assert result.note == f"{CLAIMS_LEFT};{REFUSED}" and result.answer is None + # 和别的信号一起出现时照常叫模型 + mixed = [*signals, rv.Signal("tool_error", "工具 x 调用失败", rv.BROKEN)] + model = FakeModel({"note": "取数失败了一次。", "answer": "", "retry": True}) + assert (await rv.review(model, question="q", answer="答案", signals=mixed)).note == "取数失败了一次。" + assert model.calls == 1 + + +def _left_after_rewrite(mode: str, violations: list[dict], count: int = 2) -> list[dict]: + return [ + ev(EventType.NODE_STARTED, node_id="write", label="写周报"), + ev(EventType.LOG, node_id="write", level="warn", code="report_repair", count=count, attempt=1, + message=f"报告有 {count} 处未通过核对(「12」、「主要来自」),已要求模型重写(第 1 次)"), + ev(EventType.REPORT_CHECKED, node_id="write", ok=not violations, violations=violations, repairs=1, + analysis_claims=mode), + ] + + +CLAIM = {"code": "analysis_claim_uncited", "text": "主要来自", "message": "「主要来自」是分析判断"} + + +def test_check_mode_leftovers_do_not_make_the_rewrite_count_as_failed(): + """check 下重写一次后仍有的说法违规是标注(同缺失断言,各有自己的信号):「改过一次」算改好了,不再另报 report_repair; + 复核被叫起来时照实说剩下的是哪几处。""" + rows = [*_left_after_rewrite("check", [CLAIM]), warn(CLAIMS_LEFT, "report_analysis_claims")] + assert [s.kind for s in rv.scan(rows, OK_OUTPUT)] == ["report_analysis_claims"] + assert rv.notes(rows) == ["报告「写周报」首稿有 2 处没过核对,重写后只剩已标出的 1 处分析说法"] + # 一处都没改掉:不说「改好了」,剩下的由它自己的信号说 + assert rv.notes(_left_after_rewrite("check", [CLAIM], count=1)) == [] + + +def test_absence_claim_leftovers_are_annotations_too(): + absent = {"code": "absence_claim", "text": "没有查到", "message": "缺失断言"} + rows = _left_after_rewrite("check", [absent, CLAIM], count=3) + assert rv.scan(rows, OK_OUTPUT) == [] + assert rv.notes(rows) == ["报告「写周报」首稿有 3 处没过核对,重写后只剩已标出的 1 处缺失断言、1 处分析说法"] + + +@pytest.mark.parametrize("mode, violations", [ + ("strict", [CLAIM]), # strict 下是出具缺口,不是标注 + ("check", [CLAIM, {"code": "uncited_number", "text": "12"}]), # 还有别的违规 + ("off", [CLAIM]), + ("check", [CLAIM] * 20), # 带满了 20 条:后面可能还有 +]) +def test_other_leftovers_still_count_as_a_failed_rewrite(mode, violations): + rows = _left_after_rewrite(mode, violations) + assert [s.kind for s in rv.scan(rows, OK_OUTPUT)] == ["report_repair"] and rv.notes(rows) == [] + + +@pytest.mark.asyncio +async def test_review_endpoint_does_not_call_the_model_for_a_check_mode_leftover(client, monkeypatch): + model = FakeModel(AssertionError("不该叫模型")) + + async def fake_model(*a, **k): + return model, "fake" + + monkeypatch.setattr("app.api.copilot.get_chat_model", fake_model) + output = {"周报": "周报:销售额 45,678.5元,下降主要来自华东。", + "_evidence": {"report_node": "write", "doc_artifact": "a" * 64, "fields": ["周报"]}} + run_id = await _make_run(client, output, [*_left_after_rewrite("check", [CLAIM]), + warn(CLAIMS_LEFT, "report_analysis_claims")]) + body = (await client.post("/api/copilot/review", json={"run_id": run_id, "question": "q"})).json() + assert model.calls == 0 + assert body["verdict"] == "annotated" and body["note"] == CLAIMS_LEFT and body["retry_instruction"] is None + assert [s["kind"] for s in body["signals"]] == ["report_analysis_claims"]