Files
AgentLab/backend/app/engine/analysis_claims.py
T

907 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""分析说法核对(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. **点名的段**:判断词所在分句里出现的结论结果表的维度值(全半角归一,两个字以上、不是纯数字)。来源类要判定说了主要
来源(driver、concentrated,比率拆解的 mix_driven、rate_driven),点名的段都在 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 LEDGER_KIND, OP_LABELS, catalog_result, method_label
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})
# --------------------------------------------------------------------------
# 说法表 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_VERDICTS = frozenset({"driver", "concentrated", "mix_driven", "rate_driven"})
#: 有这几类判断的分句里,因果词并进它们核(「下降主要由华东造成」:说的是记账意义上的来源)
_SOURCE_KEYS = frozenset({"source", "source_not", "dispersed", "offset", "mix"})
#: 拆解结果表里不是维度的列(params.by 对不上时按「不是度量列」认维度,跳过这些)
_MEASURE_COLUMNS = frozenset({
"base", "target", "delta", "pct", "share", "flag", "n_base", "n_target", "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 列(因子的叫法),差异检验是 a、b 两组;
拆解的 by 对不上表头时按「不是度量列」认。别的算子没有段。"""
table = result.get("table") or {}
columns = [str(c) for c in table.get("columns") or []]
if result.get("op") == "differ":
return [columns.index(c) for c in ("a", "b") if c in columns]
if result.get("op") != "decompose":
return []
if result.get("mode") == "factor" and "factor" in columns:
return [columns.index("factor")]
by = (result.get("params") or {}).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 any(ch.isascii() and ch.isalnum() for ch in (before, after) if ch)):
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 _both_ways(result: Mapping[str, Any]) -> bool:
"""结果表里各段的变化有涨有跌。"""
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 False
signs = {d > 0 for row in table.get("rows") or [] if isinstance(row, list) and col < len(row)
and (d := _decimal(row[col])) is not None and d != 0}
return signs == {True, False}
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 _SOURCE_VERDICTS
if key == "source_not":
inside = [n for n in named if _named_value(n) in {_named_value(s) for s in subjects}]
if top and (inside or not named):
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":
# 抵消记在 offset 里;判定为基本持平的,判定语本身就说了此消彼长(不一定再接 offset),各段有涨有跌就算
if "offset" in _caveats(result) or (code == "flat" and _both_ways(result)):
return None
return _Miss("没有记下此消彼长(各段的变化方向大多一致)", "这份结论没有记下各段此消彼长")
if key == "mix":
if result.get("mode") != "ratio":
done, wanted = method_label("decompose", result.get("mode")), method_label("decompose", "ratio")
return _Miss(f"是{done},说明不了结构变化:结构变化要挂{wanted}",
f"这是{done},说明不了结构变化:结构变化要挂{wanted}")
return None if code in ("mix_driven", "both") else _verdict_miss(result)
if key == "anomaly":
if code not in ("outside_band", "far_outside_band"):
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 method_label(claim.op, claim.mode) if claim.mode else OP_LABELS.get(claim.op or "", claim.op or "")
def _snippet(shown: str, start: int, end: int, limit: int = 16) -> str:
"""判断词连同它后面半句(给人看的样子),最多 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], load: Any = None) -> dict[str, dict[str, Any]]:
"""写作目录里的分析结论:{A 编号: 结论}(contract.catalog_result 读)。条目只带摘要、不带整份结论的(存进报告文档的
目录就是这样)按 load(编号, 条目) 取回(evidence 的 _Snapshots.analysis:按工件取、复验哈希);没给 load、取不回的
不在里面。"""
out: dict[str, dict[str, Any]] = {}
for alias, entry in catalog.items():
if not isinstance(entry, dict) or entry.get("kind") != LEDGER_KIND:
continue
result = catalog_result(entry)
if result is None and load is not None:
result = load(str(alias), entry)
if isinstance(result, dict):
out[str(alias)] = dict(result)
return out
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, load: Any = None) -> list[dict[str, Any]]:
"""一份报告文档的说法违规。cited 是 {id(unit): 这句按重新解析的引用算的依据}(verify_doc 交进来),不给就用文档里
记的 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:
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,
load: Any = 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, load=load)
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",
]