叫法消毒做到不再变化、截到第一个分句标点、不许夹带判断词(复核 R12、R18):clean_label 反复去引用标记、数字、符号、空括号直到不变(「销售额[ [v:Q1.r0.gmv] ]」去完数字拼出的「[[v:Q.r.gmv]]」接着去掉),再去掉剩下的方括号、截到第一个分句标点(,。;:!?、,.;:!?)之前、去掉截完没配上对的左括号,整个过程再做到不变,消过的再消一遍不变——复算拿记下的叫法再消,判定语逐字相同。消完的叫法里有说法表的判断词(engine/analysis_claims.scan,在函数里导入)就整个不要,退回下一个来源;目录叫法、当叫法用的列名同一个函数,同样处理

This commit is contained in:
ThearchyHelios
2026-10-21 15:37:48 +08:00
parent cf8ec15831
commit 0acf9fb3a8
3 changed files with 93 additions and 7 deletions
+58 -7
View File
@@ -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:
+13
View File
@@ -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")
+22
View File
@@ -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))