diff --git a/backend/app/analysis/phrases.py b/backend/app/analysis/phrases.py index 334d17a2..c379364a 100644 --- a/backend/app/analysis/phrases.py +++ b/backend/app/analysis/phrases.py @@ -11,9 +11,11 @@ - 百分点:比率的变化(2.1% 到 2.5% 是上升 0.4 个百分点)。 **label 消毒**(§7.3):判定语里的度量叫法、因子名、维度叫法是 agent 或数据给的。agent 给的 label(以及目录里的叫法、 -当叫法用的列名)去掉数字、百分号、单位符号、引用标记,不超过 20 字,消毒后为空就退回下一个来源:不然模型可以把 -「销售额(同比 +30%)」塞进 label,借系统渲染绕过裸数字检测。去数字按出具校验抽数字的同一个函数(engine/issuance 的 -extract_numbers:阿拉伯数字、中文数字、带万 / 亿 / 成 / 倍的写法),再去掉剩下的数字字符。维度值是数据,照原样。 +当叫法用的列名)去掉数字、百分号、单位符号、引用标记,截到第一个分句标点之前,不超过 20 字;里面有说法表的判断词的整个 +不要;消毒后为空就退回下一个来源:不然模型可以把「销售额(同比 +30%)」「销售额,主要来自华东」塞进 label,借系统渲染绕过 +裸数字检测和说法核对(判定语不受说法核对)。去数字按出具校验抽数字的同一个函数(engine/issuance 的 extract_numbers:阿拉伯 +数字、中文数字、带万 / 亿 / 成 / 倍的写法),再去掉剩下的数字字符。消毒反复做到不再变化:消过的再消一遍不变,复算拿记下的 +叫法再消,判定语逐字相同。维度值是数据,照原样。 **附带说明**:判定语后面用「;」接,最多两条(按 PHRASE_ORDER 的先后),其余只在证据面板。度量类型没标、以各段之和为 总数这类方法上的交代不进判定语。 @@ -168,13 +170,63 @@ _DIGITS = re.compile(r"[0-90-9]") _SYMBOLS = re.compile(r"[%%‰‱¥¥$$€£₩℃°++\-−–±×÷==<><>~~^*]") _EMPTY_BRACKETS = re.compile(r"[((【\[]\s*[))】\]]") _EDGE_PUNCT = ",,、::;;。.!!??  " +#: 分句的标点:叫法截到第一个之前(「销售额,主要来自华东」只留「销售额」) +_CLAUSE = re.compile(r"[,。;:!?、,.;:!?]") +#: 截出来以后还没配上对的左括号(「销售额(含税,元)」截成「销售额(含税」)连同后面的都不要 +_OPENERS = {"(": ")", "(": ")", "【": "】"} def clean_label(text: Any) -> str: - """消毒(见文件头):去引用标记、数字(出具校验认的数字写法连同后缀,再加剩下的数字字符)、百分号和单位符号、空括号, - 压掉多余空白,不超过 LABEL_MAX 字。可能消成空串。""" + """消毒(见文件头):去引用标记、数字(出具校验认的数字写法连同后缀,再加剩下的数字字符)、百分号和单位符号、空括号、 + 多余空白,反复做到不再变化(去掉一处可能拼出新的一处:「销售额[ [v:Q1.r0.gmv] ]」去完数字成了「销售额[[v:Q.r.gmv]]」), + 再去掉剩下的方括号,截到第一个分句标点之前、去掉没配上对的左括号,不超过 LABEL_MAX 字;整个过程再做到不再变化,所以 + 消过的再消一遍不变。消完的叫法里有说法表的判断词(engine/analysis_claims.scan 认得出的)就整个不要。可能消成空串。""" if not isinstance(text, str): return "" + out = text + for _ in range(len(text) + 2): + again = _shorten(_settle(out)) + if again == out: + break + out = again + return "" if out and _judges(out) else out + + +def _settle(text: str) -> str: + """去引用标记、数字、符号、空括号,做到不再变化(每一遍只会变短或只换空白,一定停得下来)。""" + out = text + for _ in range(len(text) + 2): + again = _scrub(out) + if again == out: + break + out = again + return out + + +def _shorten(text: str) -> str: + """去掉剩下的方括号,截到第一个分句标点之前,去掉没配上对的左括号连同后面的,不超过 LABEL_MAX 字。""" + out = _CLAUSE.split(text.replace("[", "").replace("]", ""), maxsplit=1)[0] + open_at: list[tuple[int, str]] = [] + for i, ch in enumerate(out): + if ch in _OPENERS: + open_at.append((i, _OPENERS[ch])) + elif open_at and ch == open_at[-1][1]: + open_at.pop() + if open_at: + out = out[:open_at[0][0]] + out = re.sub(r"\s+", " ", out).strip(_EDGE_PUNCT) + return out[:LABEL_MAX].strip(_EDGE_PUNCT) + + +def _judges(text: str) -> bool: + """叫法里有没有说法表的判断词(「主要来自」「历史新高」……)。说法核对在 engine 里,在这里导入免得循环导入。""" + from app.engine.analysis_claims import scan + + return bool(scan(text)) + + +def _scrub(text: str) -> str: + """一遍:去引用标记、数字、符号,收拾去掉以后留下的空白、标点、空括号。""" from app.engine.issuance import extract_numbers raw = _MARKER.sub("", text) @@ -193,8 +245,7 @@ def clean_label(text: Any) -> str: out = re.sub(r"([,,、::;;])[,,、::;;]+", r"\1", out) while _EMPTY_BRACKETS.search(out): out = _EMPTY_BRACKETS.sub("", out) - out = re.sub(r"\s+", " ", out).strip(_EDGE_PUNCT) - return out[:LABEL_MAX].strip(_EDGE_PUNCT) + return re.sub(r"\s+", " ", out).strip(_EDGE_PUNCT) def pick_label(*candidates: Any, fallback: str = "这一列") -> str: diff --git a/backend/tests/test_analysis_decompose.py b/backend/tests/test_analysis_decompose.py index dbc31725..9710e192 100644 --- a/backend/tests/test_analysis_decompose.py +++ b/backend/tests/test_analysis_decompose.py @@ -448,3 +448,16 @@ async def test_dimension_columns_keep_the_type_recorded_at_query_time(): factor_result = await factor([["W41", "1000", 100, "10"], ["W42", "1320", 120, "11"]]) assert factor_result["table"]["column_types"] == {"factor": "text", "base": "number", "target": "number", "pct": "number", "contrib": "number", "share": "number"} + + + +async def test_a_label_cannot_carry_a_judgment_into_the_phrase(): + cut = await additive(label="销售额,主要来自华南") + assert cut["params"]["label"] == "销售额" and cut["phrase"].startswith("销售额下降 194") + assert "华南" not in cut["phrase"] + judged = await additive(label="主要来自华南的销售额") + assert "label" not in judged["params"] # 消成空了,退回目录里的叫法 + assert judged["phrase"].startswith("销售额下降 194") and "华南" not in judged["phrase"] + # 消过一遍会变的叫法:复算拿记下的叫法再消,判定语逐字相同(analyze 核对复算逐字段相同) + marked = await additive(label="销售额[ [v:Q1.r0.gmv] ]") + assert marked["params"]["label"] == "销售额" and marked["phrase"].startswith("销售额下降 194") diff --git a/backend/tests/test_analysis_phrases.py b/backend/tests/test_analysis_phrases.py index 147d53d8..108becf1 100644 --- a/backend/tests/test_analysis_phrases.py +++ b/backend/tests/test_analysis_phrases.py @@ -60,6 +60,28 @@ def test_labels_are_scrubbed_of_numbers_units_and_markers(): assert ph.pick_label("2026", None, "2026") == "这一列" +def test_scrubbing_a_scrubbed_label_changes_nothing(): + # 去掉一处可能拼出新的一处:去完数字的「销售额[[v:Q.r.gmv]]」又是一个引用标记,要接着去 + assert ph.clean_label("销售额[ [v:Q1.r0.gmv] ]") == "销售额" + assert ph.clean_label("[ [see:A1] ]销售额") == "销售额" and ph.clean_label("销售额[1[v:x]2]") == "销售额" + for text in ("销售额[ [v:Q1.r0.gmv] ]", "销售额 [ [ a:A1 ] ]", "销售额( )(3)", "销售额(同比 +30%,3.5万)", + "近三成客户的退款率", "【线上】销售额", "退款率 (%)", "销售额(", " ,销售额。", "一[二]三", + "销售额" * 30, "[[a:A1]]主要来自[[v:Q1.r0.region]]"): + once = ph.clean_label(text) + assert ph.clean_label(once) == once, text + + +def test_labels_stop_at_the_first_clause_and_judgments_fall_back(): + assert ph.clean_label("销售额,主要来自华东") == "销售额" + assert ph.clean_label("订单数; 销售额") == "订单数" and ph.clean_label("订单数、销售额") == "订单数" + assert ph.clean_label("销售额(含税,元)") == "销售额" # 截完没配上对的括号也不要 + # 判定语不受说法核对:叫法里有判断词的整个不要,退回下一个来源(目录叫法、当叫法用的列名同样处理) + assert ph.clean_label("销售额主要来自华东") == "" and ph.clean_label("历史新高的销售额") == "" + assert ph.pick_label("销售额主要来自华东", "实收金额", "gmv") == "实收金额" + assert ph.pick_label(None, "历史新高的销售额", "gmv") == "gmv" + assert ph.pick_label(None, None, "主要来自华东") == "这一列" + + def test_numbers_stuffed_into_a_label_never_reach_the_phrase(): label = ph.pick_label("销售额(同比 +30%,3.5万)", "销售额", "gmv") phrase = ph.decompose_driver(label, ph.change(-194, 2220), "华东", "-120", Fraction(6186, 10000))