feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s

This commit is contained in:
Codex
2026-11-02 06:37:33 -08:00
parent 9debd1c47d
commit 3c70861463
44 changed files with 4354 additions and 333 deletions
@@ -0,0 +1,86 @@
from __future__ import annotations
import json
import os
import tempfile
import unittest
from pathlib import Path
TMP = tempfile.TemporaryDirectory()
os.environ["ASR_STATE_DIR"] = str(Path(TMP.name) / "state")
os.environ["ASR_VAULT"] = str(Path(TMP.name) / "vault")
os.environ["ASR_RECORDINGS_ROOT"] = str(Path(TMP.name) / "recordings")
from asrpipe import control_web, review, segment # noqa: E402
class ControlWebTests(unittest.TestCase):
def setUp(self):
self.db_dir = Path(TMP.name) / "control-state"
self.db_dir.mkdir(parents=True, exist_ok=True)
control_web.DB = self.db_dir / "intake.db"
def test_job_state_is_persistent(self):
con = control_web._connect()
now = 100.0
con.execute(
"INSERT INTO intake_job(id,original_name,stored_name,title,context,size,"
"status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",
("j1", "call.mp3", "stored.mp3", "项目通话", "背景", 123,
"queued", now, now))
con.commit()
con.close()
self.assertTrue(control_web.update_job(
"j1", "transcribing", "正在转写"))
job = control_web._jobs(1)[0]
self.assertEqual(job["status"], "transcribing")
self.assertEqual(job["detail"], "正在转写")
def test_schema_migrates_task_review_fields(self):
con = control_web._connect()
columns = {row["name"] for row in con.execute(
"PRAGMA table_info(intake_job)")}
con.close()
self.assertIn("draft", columns)
self.assertIn("result", columns)
def test_review_queue_is_scoped_to_one_job(self):
con = control_web._connect()
now = 200.0
con.execute(
"INSERT INTO intake_job(id,original_name,stored_name,title,context,size,"
"status,detail,transcript,created_at,updated_at,draft,result) "
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
("job-scope", "scope.wav", "scope.wav", "范围测试", "", 10,
"review", "", "/archive/scope.txt", now, now,
"/draft/scope.md", ""))
con.commit()
con.close()
own = review.enqueue("/archive/scope.txt", "本任务问题?", {
"kind": "draft_question"})
review.enqueue("/archive/other.txt", "其他任务问题?", {
"kind": "draft_question"})
job = control_web._job("job-scope")
rows = control_web._pending_for_job(job)
self.assertEqual([row["id"] for row in rows], [own])
self.assertTrue(control_web._review_url("job-scope").endswith(
"/jobs/job-scope/review/"))
def test_intake_metadata_is_not_transcript_body(self):
metadata = {
"job_id": "abc", "title": "项目通话",
"context": "参与人是张三和李四",
}
raw = (
"<!-- intake: " + json.dumps(metadata, ensure_ascii=False) + " -->\n"
"<!-- voiceprint_session: sess clusters: 1 -->\n"
"# 转写(Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹)\n\n"
"【speaker_0】测试正文。\n")
turns, meta = segment.parse(raw)
self.assertEqual(meta["intake"], metadata)
self.assertEqual(turns[0].text, "测试正文。")
self.assertNotIn("参与人", turns[0].text)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,409 @@
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()
@@ -0,0 +1,47 @@
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class PortabilityTests(unittest.TestCase):
def probe(self, code, overrides):
env = {**os.environ, **overrides, "PYTHONPATH": str(ROOT),
"PYTHONDONTWRITEBYTECODE": "1"}
return subprocess.check_output([sys.executable, "-c", code],
env=env, text=True).strip()
def test_workspace_and_notifier_are_explicit(self):
with tempfile.TemporaryDirectory() as tmp:
result = self.probe(
"from asrpipe.config import CFG; print(CFG.obsidian); print(CFG.notifier)",
{"ASR_WORKSPACE": tmp, "ASR_NOTIFIER": tmp + "/notify.py"})
self.assertEqual(result.splitlines(), [tmp, tmp + "/notify.py"])
def test_watcher_does_not_assume_claude_workspace(self):
with tempfile.TemporaryDirectory() as tmp:
result = self.probe(
"import runpy; from pathlib import Path; "
"d=runpy.run_path('recordings/bin/meeting-watch.py',run_name='probe'); "
"print(d['CONTROL_DB']); print(d['ASR_INBOX'])",
{"ASR_STATE_DIR": tmp + "/state", "ASR_VAULT": tmp + "/vault"})
self.assertEqual(result.splitlines(),
[tmp + "/state/intake.db", tmp + "/vault/.asr-inbox"])
def test_notifier_accepts_external_webhook_without_sending(self):
result = self.probe(
"import runpy; d=runpy.run_path('scripts/meeting-status-notify.py',run_name='probe'); "
"print(d['URL']); print(d['secret']() == 'synthetic-only')",
{"MEETING_STATUS_WEBHOOK_URL": "http://example.invalid/hook",
"MEETING_STATUS_WEBHOOK_SECRET": "synthetic-only"})
self.assertEqual(result.splitlines(), ["http://example.invalid/hook", "True"])
if __name__ == "__main__":
unittest.main()
@@ -235,29 +235,42 @@ class ProviderAndServiceTests(unittest.TestCase):
q2 = review.enqueue(str(t1), "第一场问题B?", {"kind": "draft_question", "candidates": ["选项B1"]})
q3 = review.enqueue(str(t2), "第二场问题C?", {"kind": "draft_question", "candidates": ["选项C1"]})
os.environ["ASR_WEB_PASSWORD"] = "test-web-password"
os.environ.pop("ASR_WEB_ALLOW_ANON", None)
svc_web.AGENT_TOKEN = "test-agent-token"
server = ThreadingHTTPServer(("127.0.0.1", 0), svc_web.Handler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
import http.cookiejar
from asrpipe import webauth
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
login_body = urllib.parse.urlencode({"password": "test-web-password"}).encode()
opener.open(urllib.request.Request(
f"http://127.0.0.1:{port}/login", data=login_body, method="POST",
), timeout=5)
def fetch(path: str, headers: dict[str, str] | None = None) -> tuple[int, str]:
req = urllib.request.Request(
f"http://127.0.0.1:{port}{path}",
headers=headers or {},
)
with urllib.request.urlopen(req, timeout=5) as resp:
with opener.open(req, timeout=5) as resp:
return resp.status, resp.read().decode("utf-8")
def post_form(path: str, data: dict[str, str]) -> tuple[int, str]:
encoded = urllib.parse.urlencode(data).encode("utf-8")
payload = dict(data)
payload.setdefault("csrf", webauth.csrf_token())
encoded = urllib.parse.urlencode(payload).encode("utf-8")
req = urllib.request.Request(
f"http://127.0.0.1:{port}{path}",
data=encoded,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
with opener.open(req, timeout=5) as resp:
return resp.status, resp.read().decode("utf-8")
status, body = fetch("/health")
@@ -0,0 +1,375 @@
"""Regression checks for the review and the 8788 black-box report."""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
import tempfile
import threading
import unittest
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unittest import mock
if "asrservice.config" not in sys.modules:
_boot = tempfile.mkdtemp(prefix="asr-harden-")
os.environ["ASR_DATA_DIR"] = str(Path(_boot) / "data")
os.environ["ASR_STATE_DIR"] = str(Path(_boot) / "state")
os.environ["ASR_VAULT"] = str(Path(_boot) / "vault")
os.environ["ASR_NOTIFY"] = "0"
os.environ["ASR_GIT_SYNC"] = "0"
os.environ["ASR_AGENT_TOKEN"] = "test-agent-token"
from asrpipe import locking, refine, review, webauth # noqa: E402
from asrpipe.config import Config # noqa: E402
from asrpipe import providers # noqa: E402
from asrservice import config as svc_cfg # noqa: E402
from asrservice import db as svc_db # noqa: E402
from asrservice import web as svc_web # noqa: E402
from asrservice import worker # noqa: E402
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise urllib.error.HTTPError(req.full_url, code, msg, headers, fp)
def _opener(follow=True):
if follow:
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor())
return urllib.request.build_opener(_NoRedirect)
def _start_server():
server = ThreadingHTTPServer(("127.0.0.1", 0), svc_web.Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, server.server_address[1]
def _raw(port: int, payload: bytes) -> bytes:
sock = socket.create_connection(("127.0.0.1", port), timeout=5)
try:
sock.sendall(payload)
sock.shutdown(socket.SHUT_WR)
chunks = []
while True:
part = sock.recv(65536)
if not part:
break
chunks.append(part)
return b"".join(chunks)
finally:
sock.close()
class HardeningTests(unittest.TestCase):
def setUp(self):
os.environ.pop("ASR_WEB_ALLOW_ANON", None)
os.environ["ASR_WEB_PASSWORD"] = "test-web-password"
os.environ["ASR_NOTIFY"] = "0"
os.environ["ASR_GIT_SYNC"] = "0"
svc_cfg.ensure_dirs()
svc_db.connect().close()
self._hindsight = Config.use_hindsight.__get__(svc_web.CFG, type(svc_web.CFG))
svc_web.CFG.use_hindsight = False
def tearDown(self):
svc_web.CFG.use_hindsight = self._hindsight
def test_closed_without_password_and_csrf_and_bad_length(self):
os.environ["ASR_WEB_PASSWORD"] = ""
server, port = _start_server()
try:
opener = _opener(False)
with self.assertRaises(urllib.error.HTTPError) as caught:
opener.open(f"http://127.0.0.1:{port}/review/", timeout=5)
self.assertEqual(caught.exception.code, 303)
self.assertIn("/login", caught.exception.headers.get("Location", ""))
page = urllib.request.urlopen(f"http://127.0.0.1:{port}/login", timeout=5).read().decode()
self.assertIn("ASR_WEB_PASSWORD", page)
raw = _raw(port, b"POST /login HTTP/1.0\r\nContent-Length: abc\r\n\r\n")
self.assertIn(b" 400 ", raw)
self.assertNotIn(b"Empty", raw)
finally:
server.shutdown()
server.server_close()
os.environ["ASR_WEB_PASSWORD"] = "test-web-password"
def test_pages_hide_secrets_escape_titles_and_reject_bad_edits(self):
svc_db.save_settings({
"litellm_api_key": "sk-SECRET-abcd-wxyz",
"diarize_url": "http://192.168.50.100:10099/asr",
})
jid = "abc123abc123abc123abc123"
title = "X');window.__xss=1;//"
svc_db.create_job(jid, "录音.wav", "sample.wav", title=title, size=100, source="web")
audio = svc_cfg.PROCESSED / "sample.wav"
audio.write_bytes(b"0123456789" * 10)
draft = svc_cfg.DRAFTS / f"{jid}.md"
draft.write_text("原文一行\n", encoding="utf-8")
result = svc_cfg.RESULTS / "2026-纪要.md"
result.write_text("# 纪要\n", encoding="utf-8")
svc_db.update_job(jid, "complete", "完成", draft=str(draft), result=str(result))
q1 = review.enqueue("rec-a", "第一题?", {"kind": "draft_question", "candidates": ["甲"]})
q2 = review.enqueue("rec-b", "第二题?", {"kind": "draft_question", "candidates": ["乙"]})
server, port = _start_server()
try:
opener = _opener(True)
opener.open(urllib.request.Request(
f"http://127.0.0.1:{port}/login",
data=urllib.parse.urlencode({"password": "test-web-password"}).encode(),
method="POST",
), timeout=5)
home = opener.open(f"http://127.0.0.1:{port}/", timeout=5).read().decode()
self.assertNotIn('onclick="delOne(', home)
self.assertIn("data-del", home)
settings = opener.open(f"http://127.0.0.1:{port}/settings", timeout=5).read().decode()
self.assertNotIn("192.168.50.100", settings)
self.assertNotIn("sk-SECRET", settings)
self.assertIn("已配置", settings)
review_html = opener.open(f"http://127.0.0.1:{port}/review/", timeout=5).read().decode()
forms = review_html.split('<form class="q-item"')
self.assertGreaterEqual(len(forms), 3)
for form in forms[1:]:
head = form.split("</form>", 1)[0]
self.assertLess(head.find('name="single_custom"'), head.find("custom_"))
missing = _raw(
port,
(
"POST /jobs/%s/meta HTTP/1.0\r\nHost: 127.0.0.1\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
"Cookie: asr_session=%s\r\nContent-Length: 10\r\n\r\ntitle=nope"
% (jid, webauth.session_token())
).encode(),
)
self.assertIn(b" 403 ", missing)
edit = urllib.parse.urlencode({
"csrf": webauth.csrf_token(),
"target": "../../x",
"content": "x",
}).encode()
req = urllib.request.Request(
f"http://127.0.0.1:{port}/jobs/{jid}/edit", data=edit, method="POST",
)
body = opener.open(req, timeout=5).read().decode()
self.assertIn("未保存", body)
self.assertEqual(draft.read_text(encoding="utf-8"), "原文一行\n")
empty = urllib.parse.urlencode({
"csrf": webauth.csrf_token(), "target": "draft", "content": " ",
}).encode()
body = opener.open(urllib.request.Request(
f"http://127.0.0.1:{port}/jobs/{jid}/edit", data=empty, method="POST",
), timeout=5).read().decode()
self.assertIn("内容为空", body)
header = (
f"POST /jobs/{jid}/edit HTTP/1.0\r\nCookie: asr_session={webauth.session_token()}\r\n"
"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: 3000000\r\n\r\n"
).encode()
huge = _raw(port, header)
self.assertIn(b" 413 ", huge)
art = urllib.request.Request(f"http://127.0.0.1:{port}/jobs/{jid}/artifact/result")
with opener.open(art, timeout=5) as resp:
disp = resp.headers.get("Content-Disposition", "")
text = resp.read().decode()
disp.encode("latin-1")
self.assertIn("filename*=UTF-8''", disp)
self.assertIn("filename=\"download.md\"", disp)
self.assertIn("纪要", text)
ranged = urllib.request.Request(
f"http://127.0.0.1:{port}/jobs/{jid}/audio",
headers={"Range": "bytes=80-10"},
)
try:
opener.open(ranged, timeout=5)
self.fail("reverse range should be rejected")
except urllib.error.HTTPError as exc:
self.assertEqual(exc.code, 416)
self.assertIn("bytes */100", exc.headers.get("Content-Range", ""))
suffix = urllib.request.Request(
f"http://127.0.0.1:{port}/jobs/{jid}/audio",
headers={"Range": "bytes=-10"},
)
with opener.open(suffix, timeout=5) as resp:
self.assertEqual(resp.status, 206)
self.assertEqual(resp.headers.get("Content-Range"), "bytes 90-99/100")
self.assertEqual(len(resp.read()), 10)
try:
opener.open(f"http://127.0.0.1:{port}/no-such-page", timeout=5)
self.fail("missing page should be 404")
except urllib.error.HTTPError as exc:
self.assertEqual(exc.code, 404)
self.assertIn("页面不存在", exc.read().decode())
finally:
server.shutdown()
server.server_close()
review.delete_for_job(transcript_path="rec-a")
review.delete_for_job(transcript_path="rec-b")
self.assertIsNotNone(q1)
self.assertIsNotNone(q2)
def test_busy_retry_does_not_move_processing_audio(self):
jid = "fedcba9876543210fedcba98"
name = "busy.wav"
svc_db.create_job(jid, name, name, title="进行中", size=4, source="web")
audio = svc_cfg.PROCESSING / name
audio.write_bytes(b"wave")
svc_db.update_job(jid, "transcribing", "正在转写")
server, port = _start_server()
try:
opener = _opener(True)
opener.open(urllib.request.Request(
f"http://127.0.0.1:{port}/login",
data=urllib.parse.urlencode({"password": "test-web-password"}).encode(),
method="POST",
), timeout=5)
body = urllib.parse.urlencode({
"csrf": webauth.csrf_token(),
"title": "进行中",
"provider": "not-a-real-engine",
"next_action": "retry",
}).encode()
page = opener.open(urllib.request.Request(
f"http://127.0.0.1:{port}/jobs/{jid}/meta", data=body, method="POST",
), timeout=5).read().decode()
self.assertIn("未重新排队", page)
self.assertIn("未知引擎", page)
self.assertTrue(audio.is_file())
self.assertFalse((svc_cfg.INBOX / name).exists())
self.assertEqual(svc_db.get_job(jid)["status"], "transcribing")
finally:
server.shutdown()
server.server_close()
def test_revision_failure_keeps_marker_and_answers(self):
jid = "a1b2c3d4e5f60718293a4b5c"
transcript = svc_cfg.TRANSCRIPTS / f"{jid}.txt"
transcript.write_text("【speaker_0】原文\n", encoding="utf-8")
draft = svc_cfg.DRAFTS / f"{jid}.md"
draft.write_text("---\ntype: minutes\n---\n# 稿\n", encoding="utf-8")
svc_db.create_job(jid, "a.wav", "a.wav", title="重精修", size=1, source="web")
svc_db.update_job(jid, "review", "待确认", transcript=str(transcript), draft=str(draft))
qid = review.enqueue(str(transcript), "已确认的问题?", {
"kind": "draft_question", "draft_path": str(draft), "candidates": ["保留"],
})
review.answer(qid, "保留")
pending_id = review.enqueue(str(transcript), "仍待确认?", {
"kind": "draft_question", "draft_path": str(draft), "candidates": ["甲"],
})
with mock.patch("asrservice.worker.refine.refine", side_effect=RuntimeError("llm down")):
with self.assertRaises(RuntimeError):
worker.rerefine_job(svc_db.get_job(jid))
answered = review.recent_answered(20)
self.assertIn(qid, [row["id"] for row in answered])
self.assertIn(pending_id, [row["id"] for row in review.pending(50)])
marker = svc_cfg.REVISION_QUEUE / f"revise-{jid}.json"
marker.write_text(json.dumps({
"action": "revise", "job_id": jid, "draft": "/missing/draft.md",
}), encoding="utf-8")
worker.process_revisions()
self.assertTrue(marker.is_file())
payload = json.loads(marker.read_text(encoding="utf-8"))
self.assertEqual(payload["attempts"], 1)
self.assertEqual(svc_db.get_job(jid)["status"], "review")
def test_recover_processing_and_shared_lock_and_stream(self):
jid = "00112233445566778899aabb"
name = f"stuck-{jid[:8]}.wav"
audio = svc_cfg.PROCESSING / name
audio.write_bytes(b"stuck")
svc_db.create_job(jid, name, name, title="卡住", size=5, source="web")
svc_db.update_job(jid, "transcribing", "中断")
worker.recover()
self.assertFalse(audio.exists())
parked = svc_cfg.INBOX / name
self.assertTrue(parked.is_file())
self.assertEqual(svc_db.get_job(jid)["status"], "queued")
worker.beat()
self.assertTrue((svc_cfg.STATE / "worker.heartbeat").is_file())
lock_path = Path(tempfile.mkdtemp()) / "consumer.lock"
handle = locking.acquire_recording_lock(lock_path)
try:
with self.assertRaises(SystemExit):
locking.acquire_recording_lock(lock_path)
finally:
handle.close()
blob = b"STREAMTEST" + b"q" * (256 * 1024)
audio_path = Path(tempfile.mkdtemp()) / "stream.wav"
audio_path.write_bytes(blob)
seen = {}
class Capture(BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers["Content-Length"])
body = self.rfile.read(n)
seen["n"] = len(body)
seen["head"] = b"STREAMTEST" in body
seen["tail"] = body.endswith(b"q" + b"\r\n--" + body.split(b"----ASRBoundary", 1)[0][:0] or b"q")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"ok": true}')
def log_message(self, *_):
pass
cap = ThreadingHTTPServer(("127.0.0.1", 0), Capture)
threading.Thread(target=cap.serve_forever, daemon=True).start()
try:
out = providers._http_multipart(
f"http://127.0.0.1:{cap.server_address[1]}/up",
fields={"model": "unit"},
file_field="file",
file_path=audio_path,
timeout=10,
retries=1,
)
finally:
cap.shutdown()
cap.server_close()
self.assertTrue(out["ok"])
self.assertGreater(seen["n"], len(blob))
self.assertTrue(seen["head"])
def test_wording_timezone_git_default_and_lexicon_page(self):
self.assertEqual(
refine.scrub_internal_wording("卡片未提供职务,卡片提及张三"),
"记录未写明职务,录音中提到张三",
)
stamp = datetime(2026, 11, 2, 13, 8, 26, tzinfo=timezone.utc).timestamp()
self.assertEqual(svc_web.fmt_ts(stamp), "2026-11-02 21:08:26")
header = worker.intake_header({"context": "a-->b } c"})
self.assertEqual(header.count("-->"), 1)
self.assertTrue(header.endswith("-->"))
self.assertNotIn("a-->", header)
self.assertTrue(header.startswith("<!-- intake:"))
for i in range(3):
from asrpipe import lexicon
lexicon.learn_alias(f"别名{i}", f"规范{i}", origin="manual")
from asrpipe import lexicon
self.assertGreaterEqual(lexicon.count_aliases(q="别名"), 3)
page = lexicon.list_aliases(q="别名", limit=2, offset=0)
self.assertEqual(len(page), 2)
env = os.environ.copy()
env.pop("ASR_GIT_SYNC", None)
env.pop("ASR_NOTIFY", None)
env["ASR_STATE_DIR"] = tempfile.mkdtemp()
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1])
out = subprocess.check_output(
[sys.executable, "-c",
"from asrpipe.config import Config; c=Config(); print(int(c.git_sync), int(c.notify))"],
env=env, text=True,
).strip()
self.assertEqual(out, "0 0")
if __name__ == "__main__":
unittest.main()