410 lines
19 KiB
Python
410 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
TMP = tempfile.TemporaryDirectory()
|
|
os.environ["ASR_STATE_DIR"] = str(Path(TMP.name) / "state")
|
|
os.environ["ASR_VAULT"] = str(Path(TMP.name) / "vault")
|
|
os.environ["XDG_RUNTIME_DIR"] = TMP.name
|
|
|
|
from asrpipe import cli, context, flagger, graph, lexicon, numbers, review, revise, vault # noqa: E402
|
|
from asrpipe.config import CFG # noqa: E402
|
|
from asrpipe.refine import _apply_settled, _queue_ambiguous # noqa: E402
|
|
from asrpipe import refine # noqa: E402
|
|
from asrpipe.segment import Chunk, Turn # noqa: E402
|
|
|
|
|
|
class CoreTests(unittest.TestCase):
|
|
def setUp(self):
|
|
CFG.vault.mkdir(parents=True, exist_ok=True)
|
|
|
|
def test_context_compacts_duplicate_recall(self):
|
|
raw = [
|
|
"刘冀是张科领弋的基金投资人,目前处于活跃合作状态。",
|
|
"刘冀是张科领弋的基金投资人,最近联系日期为2026年6月26日。",
|
|
"刘冀是张科领弋的基金投资人,目前处于活跃合作状态。",
|
|
]
|
|
out = context.compact(["刘冀"], raw, per_entity=2)
|
|
self.assertEqual(out.count("目前处于活跃合作状态"), 1)
|
|
self.assertLessEqual(out.count("\n- "), 2)
|
|
|
|
def test_settled_mapping_is_deterministic_and_common_word_is_safe(self):
|
|
flags = [
|
|
{"span": "老万", "settled": True, "common_word": False,
|
|
"candidates": [{"target": "万亚坤"}]},
|
|
{"span": "公司", "settled": True, "common_word": True,
|
|
"candidates": [{"target": "工时"}]},
|
|
]
|
|
out, decisions = _apply_settled("老万说公司暂时不投。", flags)
|
|
self.assertEqual(out, "万亚坤说公司暂时不投。")
|
|
self.assertEqual(decisions[0]["surface"], "老万")
|
|
|
|
def test_review_answer_becomes_reusable_alias(self):
|
|
qid = review.enqueue("fixture-1", "老万是不是万亚坤?", {
|
|
"surface": "老万", "candidates": ["万亚坤"],
|
|
"sentence": "老万说洛启的事情"})
|
|
result = review.answer(qid, "万亚坤")
|
|
self.assertTrue(result["learned"])
|
|
self.assertIn("万亚坤", [x[0] for x in lexicon.alias_map()["老万"]])
|
|
|
|
def test_review_answer_is_idempotent(self):
|
|
qid = review.enqueue("fixture-idempotent", "是否确认?", {
|
|
"surface": "老张", "candidates": ["张成海"]})
|
|
first = review.answer(qid, "张成海")
|
|
second = review.answer(qid, "另一个人")
|
|
self.assertTrue(first["learned"])
|
|
self.assertTrue(second["already_answered"])
|
|
self.assertEqual(second["answer"], "张成海")
|
|
|
|
def test_draft_delivery_queues_questions_and_persists_answers(self):
|
|
CFG.drafts.mkdir(parents=True, exist_ok=True)
|
|
path = CFG.drafts / "web-draft.md"
|
|
path.write_text(
|
|
"# 草稿\n\n## 通话背景(待确认)\n\n双方讨论项目安排。\n\n"
|
|
"## 待你确认\n\n"
|
|
"1. 刘总是 [[刘冀]] 还是 [[刘大涛]]?\n"
|
|
"2. speaker_0 是谁?\n",
|
|
encoding="utf-8")
|
|
count = review.enqueue_draft("web-draft-recording", path)
|
|
self.assertEqual(count, 4)
|
|
rows = [r for r in review.pending(20)
|
|
if r["recording"] == "web-draft-recording"]
|
|
self.assertEqual(len(rows), 4)
|
|
self.assertEqual(json.loads(rows[0]["payload"])["kind"],
|
|
"background_check")
|
|
question_payload = json.loads(rows[1]["payload"])
|
|
self.assertEqual(question_payload["candidates"], ["刘冀", "刘大涛"])
|
|
with mock.patch.object(vault, "sync", return_value="disabled"), \
|
|
mock.patch.object(review, "_request_revision", return_value=True):
|
|
for row, answer_value in zip(
|
|
rows, ["背景基本准确", "刘冀", "谭凯", "草稿整体可用"]):
|
|
review.answer(row["id"], answer_value)
|
|
text = path.read_text(encoding="utf-8")
|
|
self.assertIn("Web 人工确认记录", text)
|
|
self.assertIn("**回答**:刘冀", text)
|
|
self.assertIn("**回答**:草稿整体可用", text)
|
|
|
|
def test_draft_delivery_prompts_only_substantive_missing_voiceprints(self):
|
|
CFG.drafts.mkdir(parents=True, exist_ok=True)
|
|
transcripts = Path(TMP.name) / "voiceprint-transcript.txt"
|
|
transcripts.write_text(
|
|
"【speaker_0】" + "这是高瑶对协议条款和保密范围的详细说明。" * 8 + "\n"
|
|
"【speaker_2】嗯。\n"
|
|
"【谭凯】好的。\n", encoding="utf-8")
|
|
path = CFG.drafts / "voiceprint-draft.md"
|
|
path.write_text(
|
|
"---\nstatus: 草稿待确认\n"
|
|
f"transcript: {transcripts}\n"
|
|
"voiceprint_session: test-session\n"
|
|
"voiceprint_speakers:\n speaker_1: 谭凯\n---\n\n"
|
|
"# 草稿\n\n## 通话背景(待确认)\n\n协议修改沟通。\n\n"
|
|
"## 待你确认\n\n1. speaker_0 身份是谁?\n",
|
|
encoding="utf-8")
|
|
review.enqueue_draft("voiceprint-recording", path)
|
|
rows = [r for r in review.pending(20)
|
|
if r["recording"] == "voiceprint-recording"]
|
|
kinds = [json.loads(r["payload"]).get("kind") for r in rows]
|
|
self.assertEqual(kinds.count("voiceprint"), 1)
|
|
voice = next(json.loads(r["payload"]) for r in rows
|
|
if json.loads(r["payload"]).get("kind") == "voiceprint")
|
|
self.assertEqual(voice["speaker_id"], "speaker_0")
|
|
self.assertFalse(any("speaker_2" in r["question"] for r in rows))
|
|
|
|
def test_entity_card_suggestion_counts_distinct_formal_sources(self):
|
|
con = lexicon.connect()
|
|
con.execute(
|
|
"INSERT INTO entity_mention(entity,kind,source,occurred_at) "
|
|
"VALUES(?,?,?,?)", ("高瑶", "人名", "minute:older.md", 1))
|
|
con.commit()
|
|
con.close()
|
|
CFG.drafts.mkdir(parents=True, exist_ok=True)
|
|
path = CFG.drafts / "entity-suggestion.md"
|
|
path.write_text(
|
|
"---\npeople: [高瑶]\ncompanies: []\n---\n\n"
|
|
"# 草稿\n\n## 通话背景(待确认)\n\n协议沟通。\n\n"
|
|
"## 待你确认\n\n", encoding="utf-8")
|
|
review.enqueue_draft("entity-suggestion-recording", path)
|
|
rows = [r for r in review.pending(20)
|
|
if r["recording"] == "entity-suggestion-recording"]
|
|
cards = [json.loads(r["payload"]) for r in rows
|
|
if json.loads(r["payload"]).get("kind") == "entity_card"]
|
|
self.assertEqual(len(cards), 1)
|
|
self.assertEqual(cards[0]["entity"], "高瑶")
|
|
self.assertEqual(cards[0]["entity_kind"], "人名")
|
|
|
|
def test_first_review_prepares_preview_without_publishing(self):
|
|
CFG.drafts.mkdir(parents=True, exist_ok=True)
|
|
path = CFG.drafts / "publish-fixture.md"
|
|
path.write_text(
|
|
"---\ndate: 2026-07-26\ntype: minutes\n"
|
|
"tags: [会议纪要, 草稿, 待确认]\nsource: 录音转录\n"
|
|
"status: 草稿待确认\npeople: [谭凯]\ncompanies: []\n"
|
|
"transcript: /archive/publish.txt\n---\n\n# 粗整理草稿\n\n"
|
|
f"{review.FEEDBACK_START}\n## Web 人工确认记录\n\n"
|
|
"1. **问题**:背景?\n - **回答**:准确\n\n"
|
|
f"{review.FEEDBACK_END}\n", encoding="utf-8")
|
|
formal = """---
|
|
date: 2026-07-26
|
|
type: minutes
|
|
tags: [会议纪要]
|
|
source: 录音转录
|
|
people: [谭凯]
|
|
companies: []
|
|
transcript: /archive/publish.txt
|
|
---
|
|
|
|
# 谭凯通话测试
|
|
|
|
> [!summary] Summary
|
|
> 测试正式发布。
|
|
"""
|
|
with mock.patch.object(revise, "chat", return_value=formal), \
|
|
mock.patch("asrpipe.review_web.request_start", return_value=True):
|
|
self.assertTrue(revise.revise(path))
|
|
self.assertTrue(path.exists())
|
|
self.assertIn("status: 待发布确认", path.read_text())
|
|
approvals = [
|
|
r for r in review.pending(100)
|
|
if json.loads(r["payload"]).get("draft_path") == str(path)
|
|
and json.loads(r["payload"]).get("kind") == "publish_approval"
|
|
]
|
|
self.assertEqual(len(approvals), 1)
|
|
destination = CFG.minutes / "2026-07-26-谭凯通话测试.md"
|
|
self.assertFalse(destination.exists())
|
|
|
|
with mock.patch.object(review, "_request_revision", return_value=False):
|
|
review.answer(approvals[0]["id"], "发布正式稿")
|
|
with mock.patch.object(vault, "sync", return_value="pushed"), \
|
|
mock.patch.object(vault, "notify"), \
|
|
mock.patch.object(graph, "sync", return_value={}):
|
|
self.assertTrue(revise.revise(path))
|
|
self.assertTrue(destination.exists())
|
|
self.assertNotIn("草稿待确认", destination.read_text())
|
|
self.assertNotIn("待发布确认", destination.read_text())
|
|
self.assertFalse(path.exists())
|
|
self.assertTrue((CFG.state_dir / "published" / path.name).exists())
|
|
|
|
def test_normalize_can_disable_review_side_effects(self):
|
|
chunk = Chunk(0, [Turn("", "老万说洛启项目。", 0)])
|
|
flags = [{
|
|
"span": "老万", "settled": False, "common_word": False,
|
|
"sentence": "老万说洛启项目。",
|
|
"candidates": [{
|
|
"target": "万亚坤", "kind": "人名", "score": 0.9,
|
|
"rule": "alias", "source": "alias-card",
|
|
}],
|
|
}]
|
|
usage = refine.Usage()
|
|
with mock.patch.object(refine, "_queue_ambiguous") as queue, \
|
|
mock.patch.object(refine, "chat_many",
|
|
return_value=["老万说洛启项目。"]):
|
|
_parts, _decisions, stats = refine.normalize_chunks(
|
|
[chunk], [chunk.body], flags, "", "dry-run-fixture", usage,
|
|
queue_review=False)
|
|
queue.assert_not_called()
|
|
self.assertEqual(stats["review_queued"], 0)
|
|
|
|
def test_atomic_write_and_recovery_lookup(self):
|
|
CFG.drafts.mkdir(parents=True, exist_ok=True)
|
|
p = CFG.drafts / "fixture.md"
|
|
vault.write_draft_file(p, "---\ntranscript: /archive/a.txt\n---\nbody")
|
|
self.assertEqual(vault.find_draft_for_transcript("/archive/a.txt"), p)
|
|
leftovers = list(CFG.drafts.glob(".fixture.md.*"))
|
|
self.assertEqual(leftovers, [])
|
|
|
|
def test_relation_card_is_structured(self):
|
|
lexicon.upsert_relation("刘冀", "投资", "张科领弋", confidence=0.9,
|
|
source="test")
|
|
card = lexicon.relationship_card({"刘冀"})
|
|
self.assertTrue(any("刘冀 投资 张科领弋" in x for x in card))
|
|
|
|
def test_confirmed_correction_enters_personal_confusion_channel(self):
|
|
lexicon.learn_correction("张泰海", "张成海", confirmed=1,
|
|
note="ASR fixture")
|
|
rows = lexicon.confusion_map()["张泰海"]
|
|
self.assertTrue(any(canon == "张成海" and confirmed
|
|
for canon, _n, confirmed in rows))
|
|
|
|
def test_confirmed_correction_teaches_directional_syllable_error(self):
|
|
lexicon.learn_correction("张泰海", "张成海", confirmed=1)
|
|
flagger._PERSONAL_SYL = None
|
|
heard, intended = flagger.to_syls("泰")[0], flagger.to_syls("成")[0]
|
|
self.assertGreater(flagger.syl_sim(heard, intended, personal=True), 0.90)
|
|
self.assertLess(flagger.syl_sim(heard, intended, personal=False), 0.90)
|
|
|
|
def test_graph_indexes_minutes_and_recency(self):
|
|
for sub in ("People", "Companies", "Projects", "minutes"):
|
|
(CFG.vault / sub).mkdir(parents=True, exist_ok=True)
|
|
(CFG.vault / "People" / "刘冀.md").write_text(
|
|
"---\ncompany: 张科领弋\nrole: 投资人\n---\n", encoding="utf-8")
|
|
(CFG.vault / "Companies" / "张科领弋.md").write_text(
|
|
"---\n---\n", encoding="utf-8")
|
|
(CFG.vault / "Projects" / "杭州基金.md").write_text(
|
|
"---\n---\n", encoding="utf-8")
|
|
(CFG.vault / "minutes" / "2026-07-25-test.md").write_text(
|
|
"---\ndate: 2026-07-25\npeople: [刘冀]\ncompanies: [张科领弋]\n"
|
|
"---\n[[刘冀]]参与[[杭州基金]],并与[[张科领弋]]合作。\n",
|
|
encoding="utf-8")
|
|
result = graph.sync(force=True)
|
|
self.assertGreaterEqual(result["relations_written"], 3)
|
|
weights = graph.context_weights({"刘冀"})
|
|
self.assertIn("张科领弋", weights)
|
|
self.assertGreater(weights["张科领弋"], 0)
|
|
|
|
def test_number_normalization_is_typed_and_conservative(self):
|
|
text = "七月十日打款一千万元,BF四幺八二项目占百分之十五,共三十人,两万五九份待核。"
|
|
out, decisions = numbers.normalize(text)
|
|
self.assertIn("7月10日", out)
|
|
self.assertIn("1000万元", out)
|
|
self.assertIn("BF4182", out)
|
|
self.assertIn("15%", out)
|
|
self.assertIn("30人", out)
|
|
self.assertIn("两万五九份", out)
|
|
self.assertTrue(any(d["raw"] == "两万五九" and d["confidence"] < 0.8
|
|
for d in decisions))
|
|
|
|
def test_generic_discourse_counter_stays_chinese(self):
|
|
out, _ = numbers.normalize("我同步两个事,第一个是条款,第二个是公告。")
|
|
self.assertEqual(out, "我同步两个事,第一个是条款,第二个是公告。")
|
|
|
|
def test_financial_scale_without_currency_is_normalized_from_context(self):
|
|
out, decisions = numbers.normalize("八月份投资两千万,后续再退出。")
|
|
self.assertIn("投资2000万元", out)
|
|
self.assertTrue(any(d["kind"] == "money" for d in decisions))
|
|
|
|
def test_process_lock_remains_held(self):
|
|
handle = cli._lock()
|
|
lock_path = str(Path(TMP.name) / "asr-pipeline.lock")
|
|
code = (
|
|
"import fcntl,sys;"
|
|
f"f=open({lock_path!r},'w');"
|
|
"\ntry: fcntl.flock(f,fcntl.LOCK_EX|fcntl.LOCK_NB)"
|
|
"\nexcept OSError: sys.exit(23)"
|
|
"\nsys.exit(0)"
|
|
)
|
|
r = subprocess.run([os.sys.executable, "-c", code])
|
|
self.assertEqual(r.returncode, 23)
|
|
handle.close()
|
|
cli._LOCK_HANDLE = None
|
|
|
|
def test_staged_pipeline_smoke_without_network(self):
|
|
old = (CFG.use_termbank, CFG.use_hindsight, CFG.use_keyword_recall,
|
|
CFG.use_normalize, CFG.use_evidence)
|
|
CFG.use_termbank = False
|
|
CFG.use_hindsight = False
|
|
CFG.use_keyword_recall = False
|
|
CFG.use_normalize = False
|
|
CFG.use_evidence = True
|
|
|
|
def fake_many(_model, jobs, *, system=None, **_kw):
|
|
if system == refine.prompts.CLEAN_SYS:
|
|
return ["【谭凯】讨论洛启项目,下一步确认投资条款。" for _ in jobs]
|
|
return ["- [片段 0] 决策|洛启项目|下一步确认投资条款|置信度:明确"
|
|
for _ in jobs]
|
|
|
|
try:
|
|
with mock.patch.object(refine, "chat_many", side_effect=fake_many), \
|
|
mock.patch.object(refine, "chat", return_value="# 粗整理\n\n确认投资条款。"):
|
|
result = refine.refine(
|
|
"【谭凯】嗯,讨论洛启项目,然后下一步确认投资条款。",
|
|
date="2026-07-26", transcript_path="/archive/test.txt")
|
|
self.assertIn("确认投资条款", result.draft)
|
|
self.assertIn("[片段 0]", result.evidence)
|
|
self.assertEqual(result.stats["n_chunks"], 1)
|
|
finally:
|
|
(CFG.use_termbank, CFG.use_hindsight, CFG.use_keyword_recall,
|
|
CFG.use_normalize, CFG.use_evidence) = old
|
|
|
|
def test_pure_phonetic_guess_does_not_interrupt_human(self):
|
|
groups = [{
|
|
"span": "也说", "settled": False, "common_word": False,
|
|
"sentence": "但是他也说他肯定不会反驳",
|
|
"candidates": [{
|
|
"target": "叶硕", "kind": "人名", "score": 0.91,
|
|
"rule": "full", "source": "lexicon-entity",
|
|
"boost": 0, "support": "",
|
|
}],
|
|
}]
|
|
self.assertEqual(_queue_ambiguous("fixture-junk", groups), 0)
|
|
|
|
def test_context_adjudicator_must_explicitly_request_human_review(self):
|
|
groups = [{
|
|
"span": "老万", "settled": False, "common_word": False,
|
|
"sentence": "老万说洛启项目。",
|
|
"candidates": [{
|
|
"target": "万亚坤", "kind": "人名", "score": 0.92,
|
|
"rule": "alias", "source": "alias-card",
|
|
"boost": 1, "support": "关系图",
|
|
}],
|
|
}]
|
|
self.assertEqual(
|
|
_queue_ambiguous("fixture-context-decided", groups,
|
|
"老万说洛启项目。"), 0)
|
|
self.assertEqual(
|
|
_queue_ambiguous("fixture-context-uncertain", groups,
|
|
"[?万亚坤]说洛启项目。"), 1)
|
|
|
|
def test_common_words_block_embedded_name_guesses(self):
|
|
cases = [
|
|
("小问", "UPS是某个小问题", ["万亚坤", "万津"]),
|
|
("用介", "包括所有的这些公用介质报告", ["邓永杰"]),
|
|
("们总", "我上次问他们总监", ["卢萌"]),
|
|
("经签", "所有方案已经签掉", ["徐劲前"]),
|
|
("统统", "三批之间统统计一下", ["薛彤彤"]),
|
|
("峰总", "雷峰总这边的开发部", ["冯昆仑"]),
|
|
]
|
|
for i, (surface, sentence, choices) in enumerate(cases):
|
|
groups = [{
|
|
"span": surface, "settled": False, "common_word": False,
|
|
"sentence": sentence,
|
|
"candidates": [{
|
|
"target": target, "kind": "人名", "score": 0.92,
|
|
"rule": "alias", "source": "alias-card",
|
|
"boost": 1, "support": "关系图",
|
|
} for target in choices],
|
|
}]
|
|
marker = f"[?{'/'.join(choices)}]"
|
|
self.assertEqual(
|
|
_queue_ambiguous(f"fixture-common-{i}", groups, marker), 0)
|
|
|
|
def test_archive_resolution_uses_content_when_timestamp_collides(self):
|
|
old_obsidian = CFG.obsidian
|
|
root = Path(TMP.name) / "archive-resolution"
|
|
archive = root / "recordings" / "transcripts"
|
|
archive.mkdir(parents=True, exist_ok=True)
|
|
inbox = root / "2026-07-27-095902-录音-2.txt"
|
|
inbox.write_text("wav transcript", encoding="utf-8")
|
|
mp3 = archive / "aaa-2026-07-27_09_59_02.txt"
|
|
wav = archive / "bbb-2026-07-27_09_59_02.txt"
|
|
mp3.write_text("mp3 transcript", encoding="utf-8")
|
|
wav.write_text("wav transcript", encoding="utf-8")
|
|
CFG.obsidian = root
|
|
try:
|
|
self.assertEqual(
|
|
cli.resolve_archive("2026-07-27-095902-录音-2", inbox),
|
|
str(wav))
|
|
finally:
|
|
CFG.obsidian = old_obsidian
|
|
|
|
def test_observed_neighbour_needs_more_than_recent_weight(self):
|
|
groups = [{
|
|
"span": "们七", "settled": False, "common_word": False,
|
|
"sentence": "你们七月二十号签完",
|
|
"candidates": [{
|
|
"target": "范梦奇", "kind": "人名", "score": 0.96,
|
|
"rule": "observed", "source": "observed-correction",
|
|
"via": "范梦奇", "boost": 1.2, "support": "近期权重4.0",
|
|
}],
|
|
}]
|
|
self.assertEqual(_queue_ambiguous("fixture-observed-junk", groups), 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|