mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:51:58 +00:00
1cd9cab7b4
文档里叫「月活」,卡上指标叫「月度活跃买家」;同义词丢了,以后按名字找这份口径就永远对不上。 合并时带上(大小写与空白不算新说法),并让口径描述把它交出去。
231 lines
10 KiB
Python
231 lines
10 KiB
Python
"""口径草稿的纯逻辑(app/ledger/docdraft.py)。
|
||
|
||
- 引用定位:跨行、连续空格、全角空格都能还原到原文下标,找不到就是 None;
|
||
- 指标归一化:缺名、没有可核对的引用、带 SQL 痕迹整条丢弃;引用太短、chunk 不存在逐条丢弃;顺手删掉取数字段;
|
||
- 回复解析:围栏、前后废话、尾随逗号都认,截断的抛 ValueError;
|
||
- 对齐:new / duplicate / conflict(单位或定义不同);
|
||
- 合并定义:同名或同义词命中,不动 expression / id,匹配不上进 warnings;
|
||
- extract:注入假模型(不调真模型),输入超限按 chunk 边界截断。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from types import SimpleNamespace
|
||
|
||
from app.ledger import docdraft
|
||
|
||
CHUNK = ("本季度销售额按订单创建时间统计,退款订单不计入销售额。"
|
||
"订单数按已支付订单统计,一个订单算一单。")
|
||
CHUNKS = {"c1": {"ordinal": 0, "content": CHUNK, "document_id": "doc-1"}}
|
||
|
||
|
||
def metric(name="销售额", quote="销售额按订单创建时间统计", **extra):
|
||
item = {
|
||
"name": name,
|
||
"synonyms": ["营业额"],
|
||
"unit": "元",
|
||
"definition": {"text": "按订单创建时间统计", "citations": [{"chunk": "c1", "quote": quote}]},
|
||
}
|
||
item.update(extra)
|
||
return item
|
||
|
||
|
||
# ==========================================================================
|
||
# verify_quote
|
||
# ==========================================================================
|
||
|
||
def test_verify_quote_restores_original_span_across_lines_and_spaces():
|
||
text = "口径说明:\n销售额\u3000按订单创建时间统计,\n\n退款不计入。\n"
|
||
span = docdraft.verify_quote(text, "销售额 按订单创建时间统计, 退款")
|
||
assert span is not None
|
||
start, end = span
|
||
assert text[start:end] == "销售额\u3000按订单创建时间统计,\n\n退款"
|
||
|
||
|
||
def test_verify_quote_ignores_extra_spaces():
|
||
text = "订单 数"
|
||
assert docdraft.verify_quote(text, "订单 数") == (0, 6)
|
||
assert docdraft.verify_quote(text, " 订单\t数 ") == (0, 6)
|
||
|
||
|
||
def test_verify_quote_handles_full_width_space_and_missing_quote():
|
||
text = "销售额\u3000合计"
|
||
assert docdraft.verify_quote(text, "销售额 合计") == (0, 6)
|
||
assert docdraft.verify_quote(text, "毛利率") is None
|
||
assert docdraft.verify_quote(text, " ") is None
|
||
|
||
|
||
# ==========================================================================
|
||
# normalize_metrics
|
||
# ==========================================================================
|
||
|
||
def test_normalize_metrics_keeps_a_verified_metric_and_hands_over_span():
|
||
good, dropped = docdraft.normalize_metrics({"metrics": [metric()]}, CHUNKS)
|
||
assert dropped == []
|
||
[item] = good
|
||
[citation] = item["citations"]
|
||
assert citation["document_id"] == "doc-1"
|
||
assert citation["chunk"] == "c1"
|
||
assert CHUNK[citation["span"][0]:citation["span"][1]] == "销售额按订单创建时间统计"
|
||
assert item["definition"]["citations"] == item["citations"]
|
||
|
||
|
||
def test_normalize_metrics_drops_a_metric_without_a_name():
|
||
good, dropped = docdraft.normalize_metrics({"metrics": [metric(name=" ")]}, CHUNKS)
|
||
assert good == []
|
||
assert dropped[0]["reason"] == "缺少指标名称"
|
||
|
||
|
||
def test_normalize_metrics_drops_short_quotes_and_unknown_chunks_per_citation():
|
||
item = metric(quote="销售额")
|
||
item["definition"]["citations"].append({"chunk": "missing", "quote": "退款订单不计入销售额"})
|
||
good, dropped = docdraft.normalize_metrics({"metrics": [item]}, CHUNKS)
|
||
# 两条引用都不可用:一条太短,一条 chunk 不存在
|
||
assert good == []
|
||
assert "引用" in dropped[0]["reason"]
|
||
|
||
# 一条可用、一条不可用:只丢不可用的那条
|
||
item = metric()
|
||
item["definition"]["citations"] += [{"chunk": "missing", "quote": "退款订单不计入销售额"}]
|
||
good, dropped = docdraft.normalize_metrics({"metrics": [item]}, CHUNKS)
|
||
assert dropped == []
|
||
assert len(good[0]["citations"]) == 1
|
||
|
||
|
||
def test_normalize_metrics_drops_sql_traces_and_strips_forbidden_fields():
|
||
traced = metric(notes="select sum(amount) from orders")
|
||
good, dropped = docdraft.normalize_metrics({"metrics": [traced]}, CHUNKS)
|
||
assert good == []
|
||
assert "SQL" in dropped[0]["reason"]
|
||
|
||
templated = metric(notes="{{ input.period }}")
|
||
good, dropped = docdraft.normalize_metrics({"metrics": [templated]}, CHUNKS)
|
||
assert good == []
|
||
assert "SQL" in dropped[0]["reason"]
|
||
|
||
clean = metric(table="orders", column="amount", sql="", filter={"tables": ["orders"]})
|
||
# sql 是空串,没有 select … from,按字段删除处理而不是整条丢弃
|
||
good, dropped = docdraft.normalize_metrics({"metrics": [clean]}, CHUNKS)
|
||
assert dropped == []
|
||
assert set(good[0]) & set(docdraft.FORBIDDEN_FIELDS) == set()
|
||
assert good[0]["filter"] == {}
|
||
|
||
|
||
# ==========================================================================
|
||
# parse_reply
|
||
# ==========================================================================
|
||
|
||
def test_parse_reply_accepts_fences_chatter_and_trailing_commas():
|
||
text = '好的,结果如下:\n```json\n{"metrics": [{"name": "销售额",},],}\n```\n以上。'
|
||
assert docdraft.parse_reply(text) == {"metrics": [{"name": "销售额"}]}
|
||
|
||
|
||
def test_parse_reply_rejects_a_truncated_object():
|
||
try:
|
||
docdraft.parse_reply('{"metrics": [{"name": "销售额"')
|
||
except ValueError as e:
|
||
assert "不完整" in str(e) or "没有" in str(e)
|
||
else: # pragma: no cover - 只有实现走样时才会到这里
|
||
raise AssertionError("截断的 JSON 应当抛 ValueError")
|
||
|
||
|
||
# ==========================================================================
|
||
# alignment
|
||
# ==========================================================================
|
||
|
||
EXISTING = [
|
||
{"workflow_id": "wf-1", "version": 3, "node_id": "card", "name": "销售额",
|
||
"synonyms": ["GMV"], "unit": "元", "definition_text": "按订单创建时间统计"},
|
||
{"workflow_id": "wf-2", "version": 1, "node_id": "card", "name": "订单数",
|
||
"synonyms": [], "unit": "单", "definition_text": "按已支付订单统计"},
|
||
]
|
||
|
||
|
||
def test_alignment_reports_new_duplicate_and_conflict():
|
||
draft = [
|
||
{"name": "成交额", "synonyms": ["gmv "], "unit": "元", "definition": {"text": "按订单创建时间统计"}},
|
||
{"name": "订单数 ", "unit": "万元", "definition": {"text": "按已支付订单统计"}},
|
||
{"name": "客单价", "unit": "元", "definition": {"text": "销售额除以订单数"}},
|
||
]
|
||
result = docdraft.alignment(draft, EXISTING)
|
||
assert result["new"] == ["客单价"]
|
||
assert result["duplicate"] == [{"name": "成交额", "workflow_id": "wf-1", "version": 3, "node_id": "card"}]
|
||
[conflict] = result["conflict"]
|
||
assert conflict["name"] == "订单数"
|
||
assert conflict["workflow_id"] == "wf-2"
|
||
assert "单位不同" in conflict["reason"]
|
||
|
||
|
||
def test_alignment_conflicts_when_only_the_definition_differs():
|
||
draft = [{"name": "销售额", "unit": "元", "definition": {"text": "按发货时间统计"}}]
|
||
[conflict] = docdraft.alignment(draft, EXISTING)["conflict"]
|
||
assert conflict["reason"] == "定义说明不同"
|
||
|
||
|
||
# ==========================================================================
|
||
# merge_definition
|
||
# ==========================================================================
|
||
|
||
def test_merge_definition_matches_by_synonym_and_keeps_expression():
|
||
cards = [{"id": "gmv", "name": "销售额", "synonyms": ["GMV"], "expression": "cell(nodes.q, 0, '金额')",
|
||
"definition": {"text": "旧说明", "owner": "财务"}}]
|
||
drafts = [
|
||
{"name": "成交额", "synonyms": ["gmv"], "definition": {"text": "新说明", "scope": "全渠道"}},
|
||
{"name": "客单价", "definition": {"text": "销售额除以订单数"}},
|
||
]
|
||
merged, warnings = docdraft.merge_definition(cards, drafts)
|
||
[card] = merged
|
||
assert card["id"] == "gmv"
|
||
assert card["expression"] == "cell(nodes.q, 0, '金额')"
|
||
assert card["definition"] == {"text": "新说明", "owner": "财务", "scope": "全渠道"}
|
||
# 文档里的叫法要留在卡上:以后按同义词找这份口径,「成交额」才对得上
|
||
assert card["synonyms"] == ["GMV"]
|
||
assert len(warnings) == 1 and "客单价" in warnings[0]
|
||
|
||
|
||
# ==========================================================================
|
||
# extract
|
||
# ==========================================================================
|
||
|
||
class FakeModel:
|
||
model_name = "fake-model"
|
||
|
||
def __init__(self, text: str) -> None:
|
||
self.text = text
|
||
self.messages: list = []
|
||
|
||
async def ainvoke(self, messages):
|
||
self.messages = messages
|
||
return SimpleNamespace(content=self.text)
|
||
|
||
|
||
async def test_extract_uses_the_injected_model_and_normalizes_its_reply():
|
||
reply = """```json
|
||
{"metrics": [{"name": "销售额", "unit": "元",
|
||
"definition": {"text": "按订单创建时间统计",
|
||
"citations": [{"chunk": "c1", "quote": "销售额按订单创建时间统计"}]}}],
|
||
"open_questions": ["退款订单是否算销售额?"]}
|
||
```"""
|
||
model = FakeModel(reply)
|
||
result = await docdraft.extract(None, docs=[{"document_id": "doc-1", "title": "口径说明"}],
|
||
chunks=CHUNKS, model=model)
|
||
assert result["model_id"] == "fake-model"
|
||
assert result["truncated"] is False
|
||
assert result["open_questions"] == ["退款订单是否算销售额?"]
|
||
[item] = result["metrics"]
|
||
assert item["citations"][0]["document_id"] == "doc-1"
|
||
|
||
|
||
async def test_extract_truncates_at_chunk_boundaries_and_flags_it():
|
||
first = "第一段:" + "销售额按订单创建时间统计。" * 2000
|
||
second = "第二段:" + "退款订单不计入销售额。" * 2000
|
||
chunks = {
|
||
"c1": {"ordinal": 0, "content": first, "document_id": "doc-1"},
|
||
"c2": {"ordinal": 1, "content": second, "document_id": "doc-1"},
|
||
}
|
||
model = FakeModel('{"metrics": [], "open_questions": []}')
|
||
result = await docdraft.extract(None, docs=[], chunks=chunks, model=model)
|
||
assert result["truncated"] is True
|
||
sent = "\n".join(str(getattr(m, "content", "")) for m in model.messages)
|
||
assert "第二段" not in sent
|
||
assert len(sent) <= docdraft.MAX_INPUT_CHARS
|