mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:21:58 +00:00
Merge pull request #19 from ThearchyHelios/fix/claim-rules
结论句判定不再误伤定义句;数字后面重复的单位、see 前面多余的空格在渲染时去掉
This commit is contained in:
+115
-12
@@ -1639,8 +1639,76 @@ def resolve_ref(ref: str, catalog: dict[str, Any], *, cells_allowed: bool = True
|
||||
return resolve_marker(_parse_ref(ref), catalog, cells_allowed=cells_allowed, loader=loader)
|
||||
|
||||
|
||||
#: 数字标记后面重复写的单位:最多隔几个空格、单位最长几个字。流式渲染放出一个数字标记之前,
|
||||
#: 要等到它后面至少有 UNIT_LOOKAHEAD 个字(或者一个换行)才能判断,多出来的 1 是单位后面那个字
|
||||
_UNIT_GAP = 2
|
||||
_UNIT_MAX = 10
|
||||
UNIT_LOOKAHEAD = _UNIT_GAP + _UNIT_MAX + 1
|
||||
_UNIT_KINDS = frozenset({"m", "i", "v"})
|
||||
#: 渲染结果末尾的单位:最后一个数字后面的字(12,345人次 → 人次,4.57万元 → 万元,12.5% → %)
|
||||
_UNIT_TAIL = re.compile(r"\d([^\d\s][^\d]*)$")
|
||||
_BLANK = " \t\u3000"
|
||||
_SEE_RUN = re.compile(r"\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\]"
|
||||
r"(?:[" + _BLANK + r"]*\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\])*")
|
||||
|
||||
|
||||
def _rendered_unit(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots,
|
||||
cells_allowed: bool) -> str:
|
||||
cite = resolve_marker(marker, catalog, cells_allowed=cells_allowed, loader=snaps)
|
||||
if cite["status"] != "resolved" or not is_number(cite.get("value")):
|
||||
return ""
|
||||
tail = _UNIT_TAIL.search(str(cite.get("rendered") or ""))
|
||||
return tail.group(1) if tail and len(tail.group(1)) <= _UNIT_MAX else ""
|
||||
|
||||
|
||||
def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: bool, prev: str = "") -> str:
|
||||
"""渲染前收拾两处排版,数和引用都不动:
|
||||
|
||||
- 数字标记后面重复写的单位去掉。[[m:visits]] 渲染出来已经带单位(12,345人次),写作者常常
|
||||
还跟着写一遍([[m:visits]] 人次),读者看到的是「12,345人次 人次」。单字的单位后面紧跟着字时
|
||||
不动:写作者写的可能是另一个单位(口径卡是「人」,正文写「人次」),硬删会把不一致藏起来;
|
||||
- [[see:…]] 前面的空白去掉。see 渲染成空,「最高月份 [[see:Q2]]。」会剩下「最高月份 。」。
|
||||
see 后面紧跟着字、要靠这个空白隔开的(「A [[see:Q1]]B」)留着;行首的缩进不动。
|
||||
|
||||
prev 是这段文字前面那个字(流式渲染分块时用),判断空白是不是在行首。
|
||||
"""
|
||||
cuts: list[tuple[int, int]] = []
|
||||
for marker in parse_markers(text):
|
||||
if marker["kind"] not in _UNIT_KINDS:
|
||||
continue
|
||||
unit = _rendered_unit(marker, catalog, snaps, cells_allowed)
|
||||
if not unit:
|
||||
continue
|
||||
hit = re.compile(f"[{_BLANK}]{{0,{_UNIT_GAP}}}" + re.escape(unit)).match(text, marker["end"])
|
||||
if hit:
|
||||
after = text[hit.end():hit.end() + 1]
|
||||
if len(unit) > 1 or not after.isalnum():
|
||||
cuts.append((marker["end"], hit.end()))
|
||||
for run in _SEE_RUN.finditer(text):
|
||||
start = run.start()
|
||||
while start > 0 and text[start - 1] in _BLANK:
|
||||
start -= 1
|
||||
before = text[start - 1] if start > 0 else prev
|
||||
after = text[run.end():run.end() + 1]
|
||||
# 几个 see 之间的空白也一样渲染成多余的空格
|
||||
parts = list(MARKER_RE.finditer(text, run.start(), run.end()))
|
||||
cuts.extend((m.end(), n.start()) for m, n in zip(parts, parts[1:]) if m.end() < n.start())
|
||||
if start < run.start() and before not in ("", "\n") and not (after.isalnum() or after == "["):
|
||||
cuts.append((start, run.start()))
|
||||
if not cuts:
|
||||
return text
|
||||
out, last = [], 0
|
||||
for a, b in sorted(cuts):
|
||||
if a < last:
|
||||
continue
|
||||
out.append(text[last:a])
|
||||
last = b
|
||||
out.append(text[last:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _render(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed: bool, prev: str = "") -> str:
|
||||
text = _expand_tables(text, catalog, snaps, cells_allowed, prev)
|
||||
text = _tidy(_expand_tables(text, catalog, snaps, cells_allowed, prev), catalog, snaps, cells_allowed, prev)
|
||||
out: list[str] = []
|
||||
last = 0
|
||||
for marker in parse_markers(text):
|
||||
@@ -1662,7 +1730,11 @@ def render_markers(text: str, catalog: dict[str, Any], *, cells_allowed: bool =
|
||||
# 流式渲染:让 llm.token 里出现的就是最终的数字
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _safe_cut(text: str) -> int:
|
||||
#: 文字末尾的空白和 [[see:…]]:流式渲染先攒着,等后面的字来了再一起交给 _tidy
|
||||
_TRAILING_SEE = re.compile(r"(?:[" + _BLANK + r"]|\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\])+$")
|
||||
|
||||
|
||||
def _safe_cut(text: str, has_unit: Callable[[dict[str, Any]], bool] | None = None) -> int:
|
||||
"""能放出去的前缀有多长:最后一个还可能长成标记的 [[ 之前都能放。
|
||||
|
||||
一个标记最长 MARKER_MAX 个字,所以没闭合的时候最多攒 MARKER_MAX - 1 = _HOLD_LIMIT 个字;
|
||||
@@ -1670,6 +1742,9 @@ def _safe_cut(text: str) -> int:
|
||||
开头可能是另一个 [。
|
||||
|
||||
整表标记闭合了也先不放:它展开时要看紧跟在后面的那个字(是不是换行),那个字还没到。
|
||||
_tidy 要往后看的也先不放:末尾的空白和 [[see:…]](后面来的字决定空白去不去掉),
|
||||
以及后面还没攒够 UNIT_LOOKAHEAD 个字的数字标记(后面可能是重复的单位)。has_unit 判断一个
|
||||
标记渲染出来带不带单位,不带的(运行输入里的周次、没有单位的格)不用攒;不给就都攒。
|
||||
"""
|
||||
cut = len(text)
|
||||
start = text.rfind("[[")
|
||||
@@ -1679,12 +1754,23 @@ def _safe_cut(text: str) -> int:
|
||||
cut = start
|
||||
if cut == len(text) and text.endswith("["):
|
||||
cut = len(text) - 1
|
||||
while (start := text.rfind("[[", 0, cut)) != -1:
|
||||
m = MARKER_RE.match(text, start)
|
||||
if not (m and m.end() == cut and m.group("kind") == "table"):
|
||||
break
|
||||
cut = start
|
||||
return cut
|
||||
while True:
|
||||
held = cut
|
||||
start = text.rfind("[[", 0, held)
|
||||
m = MARKER_RE.match(text, start) if start != -1 else None
|
||||
if m and m.end() == held and m.group("kind") == "table":
|
||||
held = start
|
||||
if (trail := _TRAILING_SEE.search(text, 0, held)) and trail.start() < held:
|
||||
held = trail.start()
|
||||
numeric = [m for m in MARKER_RE.finditer(text, 0, held) if m.group("kind") in _UNIT_KINDS
|
||||
and (has_unit is None or has_unit(parse_markers(m.group(0))[0]))]
|
||||
if numeric and len(text[numeric[-1].end():held]) < UNIT_LOOKAHEAD \
|
||||
and "\n" not in text[numeric[-1].end():held]:
|
||||
held = numeric[-1].start()
|
||||
if held == cut:
|
||||
return cut
|
||||
cut = held
|
||||
|
||||
|
||||
|
||||
class StreamRenderer:
|
||||
@@ -1709,6 +1795,9 @@ class StreamRenderer:
|
||||
self.pending = ""
|
||||
self.prev = ""
|
||||
|
||||
def _has_unit(self, marker: dict[str, Any]) -> bool:
|
||||
return bool(_rendered_unit(marker, self.catalog, self.snaps, self.cells_allowed))
|
||||
|
||||
def _release(self, ready: str) -> str:
|
||||
if not ready:
|
||||
return ""
|
||||
@@ -1718,7 +1807,7 @@ class StreamRenderer:
|
||||
|
||||
def feed(self, delta: str) -> str:
|
||||
self.pending += delta or ""
|
||||
cut = _safe_cut(self.pending)
|
||||
cut = _safe_cut(self.pending, self._has_unit)
|
||||
ready, self.pending = self.pending[:cut], self.pending[cut:]
|
||||
return self._release(ready)
|
||||
|
||||
@@ -2070,11 +2159,20 @@ def _context(text: str, start: int, end: int) -> str:
|
||||
|
||||
#: 粗体:**…** 或 __…__,里面可以有标记(标记里的下划线不算收尾,[[m:refund_rate]] 很常见)
|
||||
_BOLD = re.compile(r"(\*\*|__)(?P<inner>(?:\[\[" + _MARKER_GUARD + r"[^\[\]\n]*?\]\]|(?!\1)[^\n])+?)\1")
|
||||
#: 方向词、因果词:没有数字也没有引用的句子,含这些词才算结论。「同比」「环比」不在里面:它们只说明
|
||||
#: 跟谁比,本身不是涨跌;真正的比较结论还会带「增长」「下降」或者数字
|
||||
_DIRECTIONAL = re.compile(
|
||||
r"增长|增加|上升|提升|提高|上涨|下降|下滑|减少|降低|回落|下跌|反弹|超过|高于|低于|领先|落后|最高|最低"
|
||||
r"|最多|最少|来自|导致|因为|由于|带动|拉动|拖累|驱动|原因|归因|贡献|占比|环比|同比|翻倍|持平|波动"
|
||||
r"|最多|最少|来自|导致|因为|由于|带动|拉动|拖累|驱动|原因|归因|贡献|占比|翻倍|持平|波动"
|
||||
r"|(?<![A-Za-z])(?:increase|decrease|grow|growth|decline|drop|rise|due to|because|driven)(?![A-Za-z])",
|
||||
re.I)
|
||||
#: 定义句:「同比是将本期与上年同月对比」「增长率是指……」。解释一个概念怎么算,不是对数据下结论
|
||||
_DEFINITION = re.compile(r"是将|是指|指的是|定义为|的定义是|含义是|计算方[式法][为是]|计算公式[为是]")
|
||||
#: 定义句里出现这些词就不按定义算:「增长主要是将促销提前带来的」说的是原因
|
||||
_CAUSAL = re.compile(
|
||||
r"导致|因为|由于|带动|拉动|拖累|驱动|原因|归因|贡献|带来|所致|造成|引起|使得"
|
||||
r"|(?<![A-Za-z])(?:due to|because|driven)(?![A-Za-z])",
|
||||
re.I)
|
||||
|
||||
|
||||
def _normalize_strong(raw: str) -> tuple[str, list[tuple[int, int]]]:
|
||||
@@ -2211,12 +2309,16 @@ def _classify(kind: str | None, segments: list[dict[str, Any]], see: list[dict[s
|
||||
因果词的算结论;其余的是连接性的话。
|
||||
|
||||
自动链接的名字按文字算:它只是把正文重新切了一刀,句子算不算结论和开没开实体层无关。
|
||||
|
||||
没有数字、没有引用的定义句(「环比是将本期与上月对比,反映短期波动」)也算连接性的话:
|
||||
它在解释概念,方向词只是定义的一部分。带因果词的不算定义句,照旧是结论。
|
||||
"""
|
||||
if kind:
|
||||
return kind
|
||||
if see or any(s["kind"] not in ("text", "structural") and not _auto_entity(s) for s in segments):
|
||||
return "claim"
|
||||
if any((s["kind"] == "text" or _auto_entity(s)) and _DIRECTIONAL.search(s["text"]) for s in segments):
|
||||
words = "".join(s["text"] for s in segments if s["kind"] == "text" or _auto_entity(s))
|
||||
if _DIRECTIONAL.search(words) and not (_DEFINITION.search(words) and not _CAUSAL.search(words)):
|
||||
return "claim"
|
||||
return "connective"
|
||||
|
||||
@@ -2243,7 +2345,8 @@ def compose_doc(
|
||||
"""
|
||||
source = (raw or "").replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||
snaps = _Snapshots(loader, entities=entities)
|
||||
text, strong = _normalize_strong(_expand_tables(source, catalog, snaps, cells_allowed))
|
||||
text, strong = _normalize_strong(_tidy(_expand_tables(source, catalog, snaps, cells_allowed),
|
||||
catalog, snaps, cells_allowed))
|
||||
markers = parse_markers(text)
|
||||
for marker in markers:
|
||||
marker["strong"] = any(a <= marker["start"] and marker["end"] <= b for a, b in strong)
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""结论句的判定,以及渲染时收拾的两处排版。
|
||||
|
||||
一次真实运行降档出具:唯一的缺口是一句解释同比、环比是什么意思的话(「同比是将本期与上年同月对比……
|
||||
反映月度之间的短期波动」),里面有「同比」「环比」「波动」三个方向词,没挂依据就被算成缺依据的结论句。
|
||||
同一份报告里每个数字都显示成「12,345人次 人次」(指标渲染已带单位,写作者又写了一遍),
|
||||
「最高月份 [[see:Q2]]。」去掉 see 后剩下「最高月份 。」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.evidence import (
|
||||
UNIT_LOOKAHEAD,
|
||||
StreamRenderer,
|
||||
build_catalog,
|
||||
compose_doc,
|
||||
iter_segments,
|
||||
render_markers,
|
||||
verify_doc,
|
||||
)
|
||||
|
||||
ART = "b" * 64
|
||||
|
||||
|
||||
def metric(mid, name, value, unit="", decimals=None, fmt="thousands"):
|
||||
return {"id": mid, "name": name, "unit": unit, "value": value, "decimals": decimals,
|
||||
"format": fmt, "status": "ok" if value is not None else "missing_input", "expression": "…"}
|
||||
|
||||
|
||||
CARD = {
|
||||
"kind": "metric_set", "caliber": "客流口径", "caliber_version": "v1", "artifact": ART,
|
||||
"metrics": [
|
||||
metric("visits", "客流量", 12345, "人次"),
|
||||
metric("heads", "到访人数", 1200, "人"),
|
||||
metric("gmv", "销售额", 45678.5, "元", 1),
|
||||
metric("yoy", "同比增幅", 12.5, "%", 1, "plain"),
|
||||
metric("gone", "缺数", None, "人次"),
|
||||
],
|
||||
}
|
||||
LEDGER = [{"kind": "metric_set", "node_id": "cal", "exec": 1, "artifact": ART, "caliber": "客流口径",
|
||||
"version": "v1", "metrics": [m["id"] for m in CARD["metrics"]]}]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def catalog():
|
||||
return build_catalog(nodes={"cal": CARD}, ledger=LEDGER, inputs={"week": "2026-W37"})
|
||||
|
||||
|
||||
def units(doc):
|
||||
return [(u["kind"], "".join(s["text"] for s in u["segments"] if s["kind"] != "structural"))
|
||||
for b in doc["blocks"] for u in b["units"]]
|
||||
|
||||
|
||||
def kind_of(doc, fragment):
|
||||
return next(k for k, text in units(doc) if fragment in text)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 结论句
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
DEFINITION = ("同比与环比的比较基准不同:**同比**是将本期与上年同月对比(本期为 2026 年 9 月,基期为 2025 年 9 月),"
|
||||
"剔除季节性因素,反映年度维度的变化;**环比**是将本期与紧邻的上月对比(基期为 2026 年 8 月),"
|
||||
"反映月度之间的短期波动。")
|
||||
|
||||
|
||||
def test_the_definition_that_downgraded_a_real_report_is_not_a_claim(catalog):
|
||||
doc = compose_doc(f"本期客流 [[m:visits]]。[[see:m:visits]]\n\n{DEFINITION}两者的差异仅在基期。", catalog)
|
||||
assert kind_of(doc, "比较基准不同") == "connective"
|
||||
assert doc["stats"]["uncited_claims"] == 0
|
||||
assert verify_doc(doc, catalog)["violations"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sentence", [
|
||||
"同比与环比使用同一套汇总口径。", # 只提到同比、环比:说的是跟谁比,不是涨跌
|
||||
"增长率是指本期较上期增长的部分占上期的比例。",
|
||||
"客单价的计算方式为销售额除以订单数,订单越多摊得越低。",
|
||||
])
|
||||
def test_sentences_without_a_conclusion_are_connective(catalog, sentence):
|
||||
assert kind_of(compose_doc(sentence, catalog), sentence[:6]) == "connective"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sentence", [
|
||||
"本期客流同比增长,环比回落。",
|
||||
"环比波动明显加大。",
|
||||
"增长主要是将促销提前带来的。", # 有「是将」,但说的是原因
|
||||
"同比增幅是指增长的比例,这次的增长由于新客首单。",
|
||||
])
|
||||
def test_real_conclusions_stay_claims(catalog, sentence):
|
||||
assert kind_of(compose_doc(sentence, catalog), sentence[:6]) == "claim"
|
||||
|
||||
|
||||
def test_a_definition_with_a_number_is_still_a_claim(catalog):
|
||||
doc = compose_doc("同比是将本期 [[m:visits]] 与上年同月对比。", catalog)
|
||||
assert kind_of(doc, "同比是将") == "claim"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 重复的单位
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(("raw", "shown"), [
|
||||
("客流 [[m:visits]] 人次,创新高。", "客流 12,345人次,创新高。"),
|
||||
("客流 [[m:visits]]人次。", "客流 12,345人次。"),
|
||||
("销售额 [[m:gmv]] 元,", "销售额 45,678.5元,"),
|
||||
("销售额 [[m:gmv|万]] 万元。", "销售额 4.57万元。"),
|
||||
("同比 [[m:yoy]] %。", "同比 12.5%。"),
|
||||
("客流 [[m:visits]] 人次的水平", "客流 12,345人次的水平"), # 多字单位后面跟着字,照样去掉
|
||||
])
|
||||
def test_a_repeated_unit_is_dropped(catalog, raw, shown):
|
||||
doc = compose_doc(raw, catalog)
|
||||
assert doc["markdown"] == shown
|
||||
assert render_markers(raw, catalog) == shown
|
||||
assert doc["stats"]["numbers_cited"] == 1
|
||||
assert verify_doc(doc, catalog)["violations"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("raw", "shown"), [
|
||||
# 口径卡的单位是「人」,写作者写的是「人次」:不是同一个单位,不动,让不一致露出来
|
||||
("到访 [[m:heads]] 人次。", "到访 1,200人 人次。"),
|
||||
("销售额 [[m:gmv]] 美元。", "销售额 45,678.5元 美元。"),
|
||||
("客流 [[m:visits]] 人次。", "客流 12,345人次 人次。"), # 隔了三个空格:不是紧跟着的
|
||||
("缺数 [[m:gone]] 人次。", "缺数 — 人次。"), # 没有值,渲染成「—」,没有单位可比
|
||||
])
|
||||
def test_other_text_after_a_number_is_left_alone(catalog, raw, shown):
|
||||
assert compose_doc(raw, catalog)["markdown"] == shown
|
||||
assert render_markers(raw, catalog) == shown
|
||||
|
||||
|
||||
def test_the_segment_still_carries_the_full_rendered_value(catalog):
|
||||
doc = compose_doc("客流 [[m:visits]] 人次。[[see:m:visits]]", catalog)
|
||||
number = next(s for s in iter_segments(doc) if s["kind"] == "number")
|
||||
assert number["text"] == "12,345人次" and number["ref"] == "m:visits"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# see 前面的空白
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(("raw", "shown"), [
|
||||
("为最高月份 [[see:m:visits]]。", "为最高月份。"),
|
||||
("增长 [[see:m:visits]] [[see:m:yoy]],", "增长,"),
|
||||
("前一句 [[see:m:visits]] 后一句", "前一句 后一句"),
|
||||
("前一句 [[see:m:visits]]后一句", "前一句 后一句"), # see 后面紧跟着字:留一个空格隔开
|
||||
("末尾 [[see:m:visits]]", "末尾"),
|
||||
("| 客流 | 值 [[see:m:visits]] |", "| 客流 | 值 |"),
|
||||
])
|
||||
def test_blank_before_see_is_dropped(catalog, raw, shown):
|
||||
assert render_markers(raw, catalog) == shown
|
||||
|
||||
|
||||
def test_indentation_before_see_is_kept(catalog):
|
||||
raw = "- 第一条\n [[see:m:visits]] 续行"
|
||||
assert render_markers(raw, catalog) == "- 第一条\n 续行"
|
||||
|
||||
|
||||
def test_the_report_from_the_downgraded_run(catalog):
|
||||
raw = ("2026 年 9 月,客流量为 [[m:visits]] 人次,为本次统计区间内的最高月份 [[see:m:visits]]。\n\n"
|
||||
f"{DEFINITION}\n\n"
|
||||
"**同比**:本期 [[m:visits]] 人次,同比增幅 [[m:yoy]] [[see:m:visits,m:yoy]]。")
|
||||
doc = compose_doc(raw, catalog)
|
||||
assert doc["markdown"] == (
|
||||
"2026 年 9 月,客流量为 12,345人次,为本次统计区间内的最高月份。\n\n"
|
||||
f"{DEFINITION}\n\n"
|
||||
"**同比**:本期 12,345人次,同比增幅 12.5%。")
|
||||
assert doc["stats"]["uncited_claims"] == 0 and doc["stats"]["numbers_cited"] == 3
|
||||
assert verify_doc(doc, catalog)["violations"] == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 流式渲染:分块输出拼起来仍然等于整篇渲染
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _stream(catalog, raw, sizes):
|
||||
renderer, out, i = StreamRenderer(catalog), [], 0
|
||||
for size in sizes:
|
||||
if i >= len(raw):
|
||||
break
|
||||
out.append(renderer.feed(raw[i:i + size]))
|
||||
i += size
|
||||
if i < len(raw):
|
||||
out.append(renderer.feed(raw[i:]))
|
||||
out.append(renderer.flush())
|
||||
return out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [1, 2, 3, 5, 8, UNIT_LOOKAHEAD, 40])
|
||||
def test_stream_drops_the_repeated_unit_and_the_blank_like_the_full_render(catalog, size):
|
||||
raw = "客流 [[m:visits]] 人次,为最高月份 [[see:m:visits]]。到访 [[m:heads]] 人次 [[see:m:heads]]\n末尾 [[m:gmv]] 元"
|
||||
full = render_markers(raw, catalog)
|
||||
assert full == "客流 12,345人次,为最高月份。到访 1,200人 人次\n末尾 45,678.5元"
|
||||
out = _stream(catalog, raw, [size] * len(raw))
|
||||
assert "".join(out) == full, out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", [
|
||||
"为最高月份 [[see:m:visits]]。下一句",
|
||||
"前一句 [[see:m:visits]]后一句",
|
||||
"增长 [[see:m:visits]] [[see:m:yoy]]\n下一行",
|
||||
])
|
||||
@pytest.mark.parametrize("size", range(1, 9))
|
||||
def test_stream_holds_the_blank_before_see_until_it_knows_what_follows(catalog, raw, size):
|
||||
# 没有数字标记帮忙攒着:空白和 see 得自己攒到后面的字来了再放
|
||||
assert "".join(_stream(catalog, raw, [size] * len(raw))) == render_markers(raw, catalog)
|
||||
|
||||
|
||||
def _corpus(n):
|
||||
rng = random.Random(20260930)
|
||||
pieces = ["[[m:visits]]", "[[m:gmv|万]]", "[[m:heads]]", "[[m:yoy]]", "[[see:m:visits]]", "[[see:m:yoy]]",
|
||||
"人次", "人", "次", "万元", "元", "%", " ", " ", " ", "\n", "。", ",", "文字", "[[", "]]", "["]
|
||||
return ["".join(rng.choice(pieces) for _ in range(rng.randint(1, 16))) for _ in range(n)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", _corpus(150))
|
||||
def test_stream_matches_the_full_render_on_random_input(catalog, raw):
|
||||
rng = random.Random(raw)
|
||||
full = render_markers(raw, catalog)
|
||||
for _ in range(3):
|
||||
sizes = [rng.randint(1, 20) for _ in range(len(raw))]
|
||||
assert "".join(_stream(catalog, raw, sizes)) == full
|
||||
@@ -257,8 +257,9 @@ async def test_a_stream_that_breaks_mid_marker_leaks_nothing(monkeypatch):
|
||||
assert [e.data["code"] for e in await events(row.id, "log") if e.data.get("code") == "stream_fallback"] \
|
||||
== ["stream_fallback"]
|
||||
streamed = "".join(t.data["delta"] for t in tokens if t.node_id == "write")
|
||||
# 断之前放出去的是渲染好的字;半截标记一个字符都不能出去
|
||||
assert streamed == "本周(2026-W37)销售额 ", streamed
|
||||
# 断之前放出去的是渲染好的字;半截标记一个字符都不能出去。末尾的空格也先攒着:
|
||||
# 后面要是 [[see:…]],渲染时这个空格要去掉
|
||||
assert streamed == "本周(2026-W37)销售额", streamed
|
||||
checked, doc = await report_of(row.id)
|
||||
assert checked["ok"] is True and checked["repairs"] == 0
|
||||
assert doc["markdown"] == "## 本周概览\n\n本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单。"
|
||||
|
||||
Reference in New Issue
Block a user