deploy standalone ASR pipeline on tools-pve
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.env
|
||||
tests
|
||||
README.md
|
||||
__pycache__
|
||||
*.pyc
|
||||
seed
|
||||
@@ -0,0 +1,11 @@
|
||||
ZENMUX_API_KEY=
|
||||
ASR_AGENT_TOKEN=
|
||||
ASR_PUBLIC_URL=http://192.168.50.49:8788
|
||||
ASR_DIARIZE_URL=http://192.168.50.100:10099/asr
|
||||
HINDSIGHT_API=http://192.168.50.47:8888
|
||||
HINDSIGHT_BANK=personal-context
|
||||
ASR_NOTIFY_BACKEND=none
|
||||
HERMES_WEBHOOK_URL=
|
||||
HERMES_WEBHOOK_SECRET=
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_CHAT_ID=
|
||||
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
seed/*.db
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,34 @@
|
||||
FROM python:3.13-slim-bookworm@sha256:ed86c82274b3c69b52fb5820f358f0bd7df0b603332063cb5c6e32bd220c3e6e
|
||||
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY asrpipe/ ./asrpipe/
|
||||
COPY asrservice/ ./asrservice/
|
||||
COPY termbank/ ./termbank/
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONPATH=/app \
|
||||
ASR_STATE_DIR=/state \
|
||||
ASR_VAULT=/data/vault \
|
||||
ASR_TERMBANK_DIR=/app/termbank \
|
||||
ASR_CACHE_DIR=/data/cache \
|
||||
ASR_LOG=/data/logs/asr-pipeline.log \
|
||||
ASR_RECORDINGS_ROOT=/data \
|
||||
ASR_DRAFTS_DIR=/data/artifacts/drafts \
|
||||
ASR_RESULTS_DIR=/data/artifacts/results \
|
||||
ASR_REVISION_QUEUE_DIR=/state/revision-queue \
|
||||
ASR_GIT_SYNC=0 \
|
||||
ASR_NOTIFY=0 \
|
||||
ASR_REVIEW_HOST=0.0.0.0 \
|
||||
ASR_REVIEW_PORT=8788
|
||||
|
||||
EXPOSE 8788
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["python", "-m", "asrservice.web"]
|
||||
@@ -0,0 +1,51 @@
|
||||
# ASR Pipeline on tools-pve
|
||||
|
||||
独立的录音转写、精修、人工审核和导出服务。WebUI 与 Agent API 共用 `192.168.50.49:8788`;worker 调用 unRaid 上的 Qwen3-ASR/CAM++ 服务以及 ZenMux 模型 API。
|
||||
|
||||
## 数据边界
|
||||
|
||||
- `/mnt/truenas/personal/ASR-Pipeline` → 容器 `/data`:原始录音、转写、ASR 原始响应、草稿、正式稿、日志和 SQLite 在线备份。
|
||||
- `/opt/asr-pipeline/state` → 容器 `/state`:活动 SQLite、worker heartbeat、revision queue。SQLite WAL 不放在 NFS。
|
||||
- `/srv/asr/inbox`:指向 TrueNAS inbox 的宿主机链接,供 `scp`/`sftp` 上传。
|
||||
- `.env`:仅存在 Arcane Project / tools-pve,权限 `0600`,不进入 Git。
|
||||
|
||||
SSH 大文件建议先上传为 `.part`,完成后在同一目录原子改名为受支持扩展名:
|
||||
|
||||
```bash
|
||||
scp call.m4a tools@192.168.50.49:/srv/asr/inbox/call.m4a.part
|
||||
ssh tools@192.168.50.49 'mv /srv/asr/inbox/call.m4a.part /srv/asr/inbox/call.m4a'
|
||||
```
|
||||
|
||||
## Agent API
|
||||
|
||||
所有 `/api/v1/*` 路由使用 `Authorization: Bearer $ASR_AGENT_TOKEN`。
|
||||
|
||||
```bash
|
||||
curl -X POST http://192.168.50.49:8788/api/v1/jobs \
|
||||
-H "Authorization: Bearer $ASR_AGENT_TOKEN" \
|
||||
-H 'X-Filename: call.m4a' -H 'X-Title: 项目通话' \
|
||||
-H 'X-Context: 参与人和项目背景' --data-binary @call.m4a
|
||||
|
||||
curl -H "Authorization: Bearer $ASR_AGENT_TOKEN" \
|
||||
http://192.168.50.49:8788/api/v1/jobs/JOB_ID
|
||||
```
|
||||
|
||||
路由:
|
||||
|
||||
- `POST /api/v1/jobs`:上传并创建异步任务。
|
||||
- `GET /api/v1/jobs?limit=&offset=&status=`:分页查询。
|
||||
- `GET /api/v1/jobs/{id}`:任务详情、事件时间线与产物链接。
|
||||
- `GET /api/v1/jobs/{id}/artifacts/{transcript|draft|result}`:读取产物。
|
||||
- `GET /api/v1/jobs/{id}/reviews`:待人工确认项。
|
||||
- `POST /api/v1/jobs/{id}/reviews/{question_id}`:JSON `{"answer":"..."}`。
|
||||
- `POST /api/v1/jobs/{id}/retry`:重试失败任务。
|
||||
|
||||
`/healthz` 仅表示 Web 进程存活;`/readyz` 同时检查数据库与 worker heartbeat。
|
||||
|
||||
## 通知
|
||||
|
||||
`ASR_NOTIFY_BACKEND=none|telegram|hermes`。Telegram 需要 `TELEGRAM_BOT_TOKEN` 与 `TELEGRAM_CHAT_ID`;Hermes 需要一个明确存在的签名 webhook URL 与共享密钥。发送失败只影响提醒,不改变任务结果。
|
||||
|
||||
## 运维
|
||||
|
||||
项目由 Arcane Git Sync 管理,自动同步、自动 pull 和自动 redeploy 均关闭。Compose 变更先在 Git 中验证,再对 tools-pve 的准确 Sync 手动同步。活动数据库每小时通过 SQLite backup API 写到 `/data/backups/*.latest`。
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone CLI. No Claude Code, no opencode, no agent runtime.
|
||||
|
||||
asr-refine run-all # process every pending transcript
|
||||
asr-refine run <file.txt> # process one transcript
|
||||
asr-refine dry-run <file.txt> # full pipeline, write nothing to the vault
|
||||
asr-refine doctor # check credentials, services, term bank
|
||||
asr-refine build-termbank # refresh the domain term bank (network)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import filecmp
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .config import CFG
|
||||
from .log import log
|
||||
|
||||
_LOCK_HANDLE = None
|
||||
|
||||
|
||||
def _lock():
|
||||
"""Single instance. The systemd path unit can fire several times in quick
|
||||
succession when a batch of transcripts lands, and two concurrent runs would
|
||||
race on git and on the pending index."""
|
||||
d = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp"))
|
||||
global _LOCK_HANDLE
|
||||
f = (d / "asr-pipeline.lock").open("w")
|
||||
try:
|
||||
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
log("another run holds the lock; exiting")
|
||||
sys.exit(0)
|
||||
_LOCK_HANDLE = f
|
||||
return f
|
||||
|
||||
|
||||
def resolve_archive(slug: str, inbox_path: Path) -> str:
|
||||
"""Prefer the persistent transcript archive over the `.asr-inbox/` staging path.
|
||||
|
||||
The inbox copy is deleted on 定稿, so a note whose `transcript:` points there
|
||||
ends up dangling. `recordings/transcripts/<fid>-<YYYY-MM-DD_HH_MM_SS>.txt`
|
||||
survives, so map the slug's timestamp back onto it when possible.
|
||||
"""
|
||||
m = re.match(r"^(\d{4}-\d{2}-\d{2})-(\d{2})(\d{2})(\d{2})", slug)
|
||||
if m:
|
||||
d, hh, mm, ss = m.groups()
|
||||
want = f"{d}_{hh}_{mm}_{ss}.txt"
|
||||
arch = CFG.obsidian / "recordings" / "transcripts"
|
||||
if arch.is_dir():
|
||||
hits = sorted(arch.glob(f"*{want[:-4]}*.txt"))
|
||||
if hits:
|
||||
if len(hits) > 1:
|
||||
for hit in hits:
|
||||
try:
|
||||
if (hit.stat().st_size == inbox_path.stat().st_size
|
||||
and filecmp.cmp(hit, inbox_path,
|
||||
shallow=False)):
|
||||
return str(hit)
|
||||
except OSError:
|
||||
continue
|
||||
return str(hits[0])
|
||||
return str(inbox_path)
|
||||
|
||||
|
||||
def process(path: Path, *, dry_run=False) -> int:
|
||||
from . import refine as R
|
||||
from . import vault
|
||||
|
||||
slug = path.stem
|
||||
if not dry_run and vault.is_done(slug):
|
||||
log(f"skip (already processed): {path}")
|
||||
return 0
|
||||
raw = path.read_text(errors="ignore")
|
||||
if not raw.strip():
|
||||
log(f"skip (empty): {path}", level="WARN")
|
||||
return 0
|
||||
intake_job = ""
|
||||
m_intake = re.search(r"(?m)^<!-- intake:\s*(\{.*\})\s*-->$", raw)
|
||||
if m_intake:
|
||||
try:
|
||||
intake_job = str(json.loads(m_intake.group(1)).get("job_id") or "")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
date, title = vault.slug_parts(slug)
|
||||
dest = vault.draft_path(date, title) if not dry_run else Path("/dev/null")
|
||||
transcript_ref = resolve_archive(slug, path)
|
||||
if not dry_run:
|
||||
existing = vault.find_draft_for_transcript(transcript_ref)
|
||||
if existing:
|
||||
log(f"recover interrupted run: {existing}")
|
||||
from . import review, review_web
|
||||
review.enqueue_draft(transcript_ref, existing)
|
||||
if intake_job:
|
||||
try:
|
||||
from .control_web import update_job
|
||||
update_job(intake_job, "review",
|
||||
"已恢复整理稿,等待确认", draft=str(existing))
|
||||
except Exception as e:
|
||||
log(f"intake recovery status failed: {e}", level="WARN")
|
||||
if not review_web.request_start(intake_job):
|
||||
vault.notify("ASR Web 审核启动失败",
|
||||
f"{existing.name} 已恢复,但 Web 服务启动失败。")
|
||||
return 1
|
||||
vault.mark_done(slug)
|
||||
return 0
|
||||
log(f"refining {path.name} ({len(raw)} chars) -> "
|
||||
f"{dest if not dry_run else '(dry-run)'} "
|
||||
f"clean={CFG.clean_model} draft={CFG.draft_model}")
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
res = R.refine(raw, date=date, transcript_path=transcript_ref,
|
||||
queue_review=not dry_run)
|
||||
except Exception as e:
|
||||
log(f"FAILED {path.name}: {e} (left unprocessed for retry)", level="ERROR")
|
||||
return 1
|
||||
|
||||
f = res.stats["fidelity"]
|
||||
log(f"done {path.name} in {time.time()-t0:.0f}s | "
|
||||
f"chunks={res.stats['n_chunks']} flags={res.stats['n_flags']} "
|
||||
f"hs={res.stats['n_hindsight']} "
|
||||
f"gate_fail={res.stats['gate_failed']} retried={res.stats['retried']} "
|
||||
f"fallback={res.stats['fallback']} | "
|
||||
f"ratio={f['char_ratio']} num={f['number_recall']} "
|
||||
f"latin={f['latin_recall']} term={f['term_recall']} | "
|
||||
f"${res.usage['cost_usd']} calls={res.usage['calls']}")
|
||||
|
||||
if dry_run:
|
||||
out = CFG.root / "dry-run" / slug
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
(out / "cleaned.txt").write_text(res.cleaned, encoding="utf-8")
|
||||
if res.evidence:
|
||||
(out / "evidence.md").write_text(res.evidence, encoding="utf-8")
|
||||
(out / "draft.md").write_text(res.draft, encoding="utf-8")
|
||||
(out / "report.json").write_text(json.dumps(
|
||||
{"stats": res.stats, "usage": res.usage, "timings": res.timings},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"dry-run output -> {out}")
|
||||
return 0
|
||||
|
||||
vault.write_draft_file(dest, res.draft)
|
||||
from . import review, review_web
|
||||
draft_questions = review.enqueue_draft(transcript_ref, dest)
|
||||
if intake_job:
|
||||
try:
|
||||
from .control_web import update_job
|
||||
update_job(intake_job, "review", "整理稿已生成,等待确认",
|
||||
draft=str(dest))
|
||||
except Exception as e:
|
||||
log(f"intake status update failed: {e}", level="WARN")
|
||||
if not review_web.request_start(intake_job):
|
||||
vault.notify("ASR Web 审核启动失败",
|
||||
f"{date} {title} 的私有粗稿已保留;"
|
||||
f"{draft_questions} 个问题可用 "
|
||||
"asr-refine review pending 查看")
|
||||
return 1
|
||||
vault.mark_done(slug)
|
||||
log(f"staged private draft {dest} | questions={draft_questions}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_run(a):
|
||||
_lock()
|
||||
return process(Path(a.file).expanduser(), dry_run=False)
|
||||
|
||||
|
||||
def cmd_dry_run(a):
|
||||
return process(Path(a.file).expanduser(), dry_run=True)
|
||||
|
||||
|
||||
def cmd_run_all(a):
|
||||
_lock()
|
||||
from . import vault
|
||||
todo = vault.pending_transcripts()
|
||||
if not todo:
|
||||
log(f"no pending transcripts in {CFG.inbox}")
|
||||
return 0
|
||||
log(f"{len(todo)} pending transcript(s)")
|
||||
rc = 0
|
||||
for p in todo:
|
||||
rc |= process(p)
|
||||
return rc
|
||||
|
||||
|
||||
def cmd_doctor(a):
|
||||
from . import fidelity
|
||||
ok = True
|
||||
|
||||
def chk(name, good, detail=""):
|
||||
nonlocal ok
|
||||
ok &= bool(good)
|
||||
print(f" [{'OK ' if good else 'FAIL'}] {name}" + (f" {detail}" if detail else ""))
|
||||
|
||||
print("paths")
|
||||
chk("vault", CFG.vault.is_dir(), str(CFG.vault))
|
||||
chk("inbox", CFG.inbox.is_dir(), str(CFG.inbox))
|
||||
chk("drafts dir writable", os.access(CFG.drafts.parent, os.W_OK), str(CFG.drafts))
|
||||
chk("glossary", CFG.glossary.exists(), str(CFG.glossary))
|
||||
chk("notifier", CFG.notifier.exists(), str(CFG.notifier))
|
||||
|
||||
print("credentials")
|
||||
try:
|
||||
k = CFG.api_key()
|
||||
chk("api key", len(k) > 20, f"len={len(k)} source={'env' if os.environ.get('ANTHROPIC_AUTH_TOKEN') else CFG.env_file.name}")
|
||||
except Exception as e:
|
||||
chk("api key", False, str(e))
|
||||
|
||||
print("lexicon")
|
||||
try:
|
||||
from . import lexicon
|
||||
lexicon.sync_from_vault()
|
||||
st = lexicon.stats()
|
||||
chk("entities", st["entities"] > 50,
|
||||
f"{st['entities']} ({st['with_company']} 有归属, {st['with_related']} 有关系)")
|
||||
chk("aliases", st["aliases"] > 50,
|
||||
f"{st['aliases']} = {st['aliases_card']} 卡片 + {st['aliases_learned']} 学习/术语表")
|
||||
except Exception as e:
|
||||
chk("lexicon", False, str(e))
|
||||
|
||||
print("term bank")
|
||||
n = len(fidelity.domain_terms())
|
||||
chk("terms.json", n > 1000, f"{n} terms")
|
||||
chk("allowlist", (CFG.termbank_dir / "allowlist.txt").exists(),
|
||||
f"{len((CFG.termbank_dir/'allowlist.txt').read_text().split()) if (CFG.termbank_dir/'allowlist.txt').exists() else 0} words")
|
||||
|
||||
print("services")
|
||||
import urllib.request
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"{CFG.hindsight_api}/health", timeout=6) as r:
|
||||
chk("hindsight", r.status == 200, CFG.hindsight_api)
|
||||
except Exception as e:
|
||||
chk("hindsight (optional)", False, f"{CFG.hindsight_api}: {e}")
|
||||
try:
|
||||
from .llm import chat
|
||||
t0 = time.time()
|
||||
out = chat(CFG.clean_model, "回一个字:好", max_tokens=20)
|
||||
chk(f"clean model {CFG.clean_model}", bool(out), f"{time.time()-t0:.1f}s {out[:8]!r}")
|
||||
t0 = time.time()
|
||||
out = chat(CFG.draft_model, "回一个字:好", max_tokens=20)
|
||||
chk(f"draft model {CFG.draft_model}", bool(out), f"{time.time()-t0:.1f}s {out[:8]!r}")
|
||||
except Exception as e:
|
||||
chk("model endpoint", False, str(e))
|
||||
|
||||
print("pending")
|
||||
from . import vault
|
||||
todo = vault.pending_transcripts()
|
||||
print(f" {len(todo)} transcript(s) awaiting processing")
|
||||
for p in todo[:5]:
|
||||
print(f" {p.name}")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def cmd_build_termbank(a):
|
||||
from . import termbank
|
||||
termbank.build()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_lexicon(a):
|
||||
"""Entity/alias lexicon. Obsidian stays read-only; learned bindings live here."""
|
||||
from . import lexicon
|
||||
act = a.action
|
||||
if act == "sync":
|
||||
print(lexicon.sync_from_vault(force=True))
|
||||
elif act == "stats":
|
||||
for k, v in lexicon.stats().items():
|
||||
print(f" {k:18s} {v}")
|
||||
elif act == "learn":
|
||||
if not (a.alias and a.canonical):
|
||||
print("usage: lexicon learn <别名> <规范名> [--note …]", file=sys.stderr)
|
||||
return 2
|
||||
lexicon.learn_alias(a.alias, a.canonical, note=a.note or "")
|
||||
lexicon.learn_correction(a.alias, a.canonical, note=a.note or "")
|
||||
print(f"learned: {a.alias} -> {a.canonical}"
|
||||
+ (f" ({a.note})" if a.note else ""))
|
||||
elif act == "show":
|
||||
am = lexicon.alias_map()
|
||||
key = a.alias
|
||||
if key:
|
||||
print(f"{key} -> {am.get(key) or 'unknown'}")
|
||||
else:
|
||||
learned = [(k, v) for k, v in am.items()
|
||||
if any(o not in ("card",) for _, _, o, _ in v)]
|
||||
print(f"{len(am)} aliases total, {len(learned)} beyond the cards")
|
||||
for k, v in sorted(learned)[:40]:
|
||||
print(f" {k:10s} -> {'、'.join(c for c, _, _, _ in v)}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_review(a):
|
||||
from . import review
|
||||
if a.action == "serve":
|
||||
from . import review_web
|
||||
return review_web.serve()
|
||||
if a.action == "state":
|
||||
from .review_web import STATE
|
||||
if not STATE.exists():
|
||||
print("inactive")
|
||||
return 1
|
||||
print(STATE.read_text(encoding="utf-8"))
|
||||
return 0
|
||||
if a.action == "pending":
|
||||
rows = review.pending(a.limit)
|
||||
for r in rows:
|
||||
payload = json.loads(r["payload"] or "{}")
|
||||
print(f"#{r['id']} [{r['recording']}] {r['question']}")
|
||||
if payload.get("sentence"):
|
||||
print(f" {payload['sentence'][:180]}")
|
||||
return 0
|
||||
if a.id is None or not a.answer:
|
||||
print("usage: asr-refine review answer <id> <answer>", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
result = review.answer(a.id, a.answer)
|
||||
except KeyError as e:
|
||||
print(str(e), file=sys.stderr)
|
||||
return 2
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_graph(a):
|
||||
from . import graph
|
||||
result = graph.sync(force=a.force) if a.action == "sync" else graph.stats()
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_revise(a):
|
||||
from . import revise
|
||||
path = (CFG.drafts / a.draft).resolve()
|
||||
try:
|
||||
path.relative_to(CFG.drafts.resolve())
|
||||
except ValueError:
|
||||
print("draft outside review root", file=sys.stderr)
|
||||
return 2
|
||||
return 0 if revise.revise(path) else 1
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(prog="asr-refine",
|
||||
description="Standalone ASR transcript -> Obsidian draft")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
p = sub.add_parser("run"); p.add_argument("file"); p.set_defaults(fn=cmd_run)
|
||||
p = sub.add_parser("dry-run"); p.add_argument("file"); p.set_defaults(fn=cmd_dry_run)
|
||||
sub.add_parser("run-all").set_defaults(fn=cmd_run_all)
|
||||
sub.add_parser("doctor").set_defaults(fn=cmd_doctor)
|
||||
sub.add_parser("build-termbank").set_defaults(fn=cmd_build_termbank)
|
||||
p = sub.add_parser("lexicon", help="entity/alias lexicon (SQLite; vault is read-only)")
|
||||
p.add_argument("action", choices=["sync", "stats", "learn", "show"])
|
||||
p.add_argument("alias", nargs="?")
|
||||
p.add_argument("canonical", nargs="?")
|
||||
p.add_argument("--note", default="")
|
||||
p.set_defaults(fn=cmd_lexicon)
|
||||
p = sub.add_parser("review", help="human review queue (transport independent)")
|
||||
p.add_argument("action", choices=["pending", "answer", "serve", "state"])
|
||||
p.add_argument("id", nargs="?", type=int)
|
||||
p.add_argument("answer", nargs="?")
|
||||
p.add_argument("--limit", type=int, default=20)
|
||||
p.set_defaults(fn=cmd_review)
|
||||
p = sub.add_parser("graph", help="incremental entity/relation/recency graph")
|
||||
p.add_argument("action", choices=["sync", "stats"])
|
||||
p.add_argument("--force", action="store_true")
|
||||
p.set_defaults(fn=cmd_graph)
|
||||
p = sub.add_parser("revise", help="publish a Web-confirmed private draft")
|
||||
p.add_argument("draft", help="draft filename under minutes/_drafts")
|
||||
p.set_defaults(fn=cmd_revise)
|
||||
a = ap.parse_args(argv)
|
||||
return a.fn(a)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Configuration for the standalone ASR post-processing pipeline.
|
||||
|
||||
This program deliberately has **no dependency on Claude Code, opencode, or any
|
||||
agent CLI**. It talks to the model provider over plain HTTPS and does its own file
|
||||
and git handling, so it can run from systemd, cron, or a shell with nothing but
|
||||
python3 and a network route.
|
||||
|
||||
Everything is overridable by environment variable so the systemd unit stays the
|
||||
single place where deployment-specific values live. Credentials are read from the
|
||||
same 600-perm env file the legacy unit uses, so token rotation remains a one-file
|
||||
change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
PKG = Path(__file__).resolve().parent
|
||||
ROOT = PKG.parent # OBSIDIAN/asr-pipeline
|
||||
OBSIDIAN = ROOT.parent # OBSIDIAN project dir
|
||||
|
||||
|
||||
def _env(name: str, default: str) -> str:
|
||||
v = os.environ.get(name)
|
||||
return v if v not in (None, "") else default
|
||||
|
||||
|
||||
def _envf(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.environ["ASR_" + name])
|
||||
except (KeyError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _envi(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.environ["ASR_" + name])
|
||||
except (KeyError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
# ---- paths ----------------------------------------------------------
|
||||
vault: Path = field(default_factory=lambda: Path(
|
||||
_env("ASR_VAULT", str(Path.home() / "obsidian-vault"))))
|
||||
obsidian: Path = OBSIDIAN
|
||||
root: Path = ROOT
|
||||
|
||||
# ---- models ---------------------------------------------------------
|
||||
# Cleaning: measured against claude-haiku-4.5 on a 118k-char English-mixed call,
|
||||
# gemini-3.5-flash-lite tripped the fidelity gate 19/87 times vs haiku's 78/87
|
||||
# (haiku's failures were 69x "over-compression" — it behaves like a summarizer
|
||||
# and deleted terms such as AUTOIMMUNE/ADCS/CD), while being 2x faster and
|
||||
# 2.9x cheaper. Hence flash-lite is the default cleaner.
|
||||
clean_model: str = _env("ASR_CLEAN_MODEL", "google/gemini-3.5-flash-lite")
|
||||
# Drafting: one call, needs judgement and structure. sonnet-5 replaces the
|
||||
# legacy headless opus-4-8.
|
||||
draft_model: str = _env("ASR_DRAFT_MODEL", "anthropic/claude-sonnet-5")
|
||||
keyword_model: str = _env("ASR_KEYWORD_MODEL", "google/gemini-3.5-flash-lite")
|
||||
|
||||
endpoint: str = _env("ASR_ENDPOINT", "https://zenmux.ai/api/v1/chat/completions")
|
||||
# ZenMux returned HTTP 429 at ~12 concurrent requests and was stable at 6.
|
||||
concurrency: int = _envi("CONCURRENCY", 6)
|
||||
http_timeout: float = _envf("HTTP_TIMEOUT", 420.0)
|
||||
max_retries: int = _envi("MAX_RETRIES", 5)
|
||||
|
||||
# ---- chunking -------------------------------------------------------
|
||||
chunk_budget: int = _envi("CHUNK_BUDGET", 1800)
|
||||
chunk_overlap: int = _envi("CHUNK_OVERLAP", 200)
|
||||
collapse_noise: bool = _env("ASR_COLLAPSE_NOISE", "1") != "0"
|
||||
|
||||
# ---- fidelity gate --------------------------------------------------
|
||||
gate_enabled: bool = _env("ASR_GATE", "1") != "0"
|
||||
gate_min_ratio: float = _envf("GATE_MIN_RATIO", 0.55)
|
||||
gate_max_ratio: float = _envf("GATE_MAX_RATIO", 1.35)
|
||||
|
||||
# ---- knowledge sources ----------------------------------------------
|
||||
use_termbank: bool = _env("ASR_TERMBANK", "1") != "0"
|
||||
use_hindsight: bool = _env("ASR_HINDSIGHT", "1") != "0"
|
||||
use_keyword_recall: bool = _env("ASR_KEYWORD_RECALL", "1") != "0"
|
||||
use_lexicon: bool = _env("ASR_LEXICON", "1") != "0"
|
||||
use_normalize: bool = _env("ASR_NORMALIZE", "1") != "0"
|
||||
use_evidence: bool = _env("ASR_EVIDENCE", "1") != "0"
|
||||
hindsight_api: str = _env("HINDSIGHT_API", "http://192.168.50.47:8888")
|
||||
hindsight_bank: str = _env("HINDSIGHT_BANK", "personal-context")
|
||||
hindsight_timeout: float = _envf("HINDSIGHT_TIMEOUT", 12.0)
|
||||
flag_min_score: float = _envf("FLAG_MIN_SCORE", 0.86)
|
||||
review_min_score: float = _envf("REVIEW_MIN_SCORE", 0.82)
|
||||
review_max_score: float = _envf("REVIEW_MAX_SCORE", 0.96)
|
||||
review_host: str = _env("ASR_REVIEW_HOST", "0.0.0.0")
|
||||
review_public_host: str = _env("ASR_REVIEW_PUBLIC_HOST", "192.168.50.145")
|
||||
review_port: int = _envi("REVIEW_PORT", 8765)
|
||||
review_idle_seconds: int = _envi("REVIEW_IDLE_SECONDS", 2700)
|
||||
review_max_seconds: int = _envi("REVIEW_MAX_SECONDS", 7200)
|
||||
|
||||
# ---- behaviour ------------------------------------------------------
|
||||
git_sync: bool = _env("ASR_GIT_SYNC", "1") != "0"
|
||||
notify: bool = _env("ASR_NOTIFY", "1") != "0"
|
||||
cache_llm: bool = _env("ASR_CACHE", "0") != "0" # off in production
|
||||
|
||||
@property
|
||||
def inbox(self) -> Path:
|
||||
return self.vault / ".asr-inbox"
|
||||
|
||||
@property
|
||||
def state(self) -> Path:
|
||||
return self.inbox / ".state"
|
||||
|
||||
@property
|
||||
def drafts(self) -> Path:
|
||||
"""Private rough drafts. They are never part of the Obsidian repository."""
|
||||
return Path(_env("ASR_DRAFTS_DIR", str(self.state_dir / "drafts")))
|
||||
|
||||
@property
|
||||
def minutes(self) -> Path:
|
||||
return Path(_env("ASR_RESULTS_DIR", str(self.vault / "minutes")))
|
||||
|
||||
@property
|
||||
def pending_index(self) -> Path:
|
||||
# Legacy compatibility only; the Web queue replaced the Vault draft index.
|
||||
return self.vault / "minutes" / "_drafts" / "_待确认.md"
|
||||
|
||||
@property
|
||||
def glossary(self) -> Path:
|
||||
return self.vault / "minutes" / "_glossary.md"
|
||||
|
||||
@property
|
||||
def termbank_dir(self) -> Path:
|
||||
return Path(_env("ASR_TERMBANK_DIR", str(self.root / "termbank")))
|
||||
|
||||
@property
|
||||
def cache_dir(self) -> Path:
|
||||
return Path(_env("ASR_CACHE_DIR", str(self.root / "cache")))
|
||||
|
||||
@property
|
||||
def state_dir(self) -> Path:
|
||||
"""Program-owned persistent state. Never inside the vault."""
|
||||
return Path(_env("ASR_STATE_DIR", str(self.root / "state")))
|
||||
|
||||
@property
|
||||
def lexicon_db(self) -> Path:
|
||||
return Path(_env("ASR_LEXICON_DB", str(self.state_dir / "lexicon.db")))
|
||||
|
||||
@property
|
||||
def log_file(self) -> Path:
|
||||
return Path(_env("ASR_LOG", str(self.obsidian / "logs" / "asr-pipeline.log")))
|
||||
|
||||
@property
|
||||
def notifier(self) -> Path:
|
||||
return self.obsidian / "bin" / "meeting-status-notify.py"
|
||||
|
||||
@property
|
||||
def env_file(self) -> Path:
|
||||
"""600-perm credentials file, shared with the legacy systemd unit."""
|
||||
return Path(_env("ASR_ENV_FILE", str(self.obsidian / "bin" / ".asr-refine.env")))
|
||||
|
||||
def api_key(self) -> str:
|
||||
for k in ("ZENMUX_API_KEY", "ANTHROPIC_AUTH_TOKEN"):
|
||||
v = os.environ.get(k)
|
||||
if v:
|
||||
return v
|
||||
p = self.env_file
|
||||
if p.exists():
|
||||
for line in p.read_text(errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("ANTHROPIC_AUTH_TOKEN="):
|
||||
val = line.split("=", 1)[1].strip().strip("'\"")
|
||||
if val:
|
||||
return val
|
||||
raise RuntimeError(
|
||||
"no API key: set ZENMUX_API_KEY/ANTHROPIC_AUTH_TOKEN or provide "
|
||||
f"{p} with ANTHROPIC_AUTH_TOKEN=…")
|
||||
|
||||
|
||||
CFG = Config()
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compact recalled prose into small, entity-centred context cards."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
SPLIT = re.compile(r"(?<=[。!?;])|\s*\|\s*")
|
||||
META = re.compile(r"^(When|Involving|来源|时间)\s*[::]\s*", re.I)
|
||||
|
||||
|
||||
def _key(text: str) -> str:
|
||||
text = META.sub("", text)
|
||||
return re.sub(r"[\s,。;、,::|()()“”\"'\-]+", "", text).lower()
|
||||
|
||||
|
||||
def compact(names: list[str], texts: list[str], *, per_entity: int = 3,
|
||||
total: int = 12) -> str:
|
||||
"""Deduplicate Hindsight recall before it reaches any model prompt.
|
||||
|
||||
Repeated memories often restate the same relationship with progressively more
|
||||
prose. Keep one normalized fact per subject and cap every entity so a single
|
||||
well-known person cannot consume the whole context window.
|
||||
"""
|
||||
buckets: dict[str, list[str]] = defaultdict(list)
|
||||
seen: set[str] = set()
|
||||
known = sorted(set(names), key=len, reverse=True)
|
||||
for raw in texts:
|
||||
for fact in SPLIT.split(re.sub(r"\s+", " ", raw).strip()):
|
||||
fact = fact.strip(" -|")
|
||||
if len(fact) < 8:
|
||||
continue
|
||||
k = _key(fact)
|
||||
if not k or any(k == old or k in old or old in k for old in seen):
|
||||
continue
|
||||
seen.add(k)
|
||||
owner = next((n for n in known if n in fact), "相关背景")
|
||||
if len(buckets[owner]) < per_entity:
|
||||
buckets[owner].append(fact[:220])
|
||||
|
||||
lines = []
|
||||
for owner, facts in buckets.items():
|
||||
lines.append(f"{owner}:")
|
||||
lines.extend(f"- {f}" for f in facts)
|
||||
if sum(1 for x in lines if x.startswith("- ")) >= total:
|
||||
break
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,489 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Persistent mobile control plane for recording intake and human review."""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
import urllib.parse
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from . import review
|
||||
from .config import CFG
|
||||
from .log import log
|
||||
|
||||
STATE = CFG.state_dir / "review-web.json"
|
||||
DB = CFG.state_dir / "intake.db"
|
||||
RECORDINGS = Path(os.environ.get(
|
||||
"ASR_RECORDINGS_ROOT", str(CFG.obsidian / "recordings")))
|
||||
INBOX = RECORDINGS / "inbox"
|
||||
META = RECORDINGS / "state" / "uploads"
|
||||
MAX_UPLOAD = int(os.environ.get("ASR_UPLOAD_MAX_BYTES", str(4 * 1024**3)))
|
||||
CHUNK = 1024 * 1024
|
||||
SUPPORTED = {".m4a", ".mp3", ".wav", ".mp4", ".aac", ".ogg",
|
||||
".opus", ".webm", ".flac"}
|
||||
|
||||
STATUS_LABEL = {
|
||||
"uploading": "上传中",
|
||||
"queued": "等待转写",
|
||||
"transcribing": "正在转写",
|
||||
"refining": "正在整理",
|
||||
"review": "等待确认",
|
||||
"publishing": "正在发布",
|
||||
"complete": "已完成",
|
||||
"failed": "处理失败",
|
||||
}
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
CFG.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
con = sqlite3.connect(DB, timeout=20)
|
||||
con.row_factory = sqlite3.Row
|
||||
con.execute("PRAGMA journal_mode=WAL")
|
||||
con.execute("""
|
||||
CREATE TABLE IF NOT EXISTS intake_job(
|
||||
id TEXT PRIMARY KEY,
|
||||
original_name TEXT NOT NULL,
|
||||
stored_name TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
context TEXT NOT NULL DEFAULT '',
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
transcript TEXT NOT NULL DEFAULT '',
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
columns = {row["name"] for row in con.execute("PRAGMA table_info(intake_job)")}
|
||||
for name in ("draft", "result"):
|
||||
if name not in columns:
|
||||
con.execute(
|
||||
f"ALTER TABLE intake_job ADD COLUMN {name} TEXT NOT NULL DEFAULT ''")
|
||||
return con
|
||||
|
||||
|
||||
def update_job(job_id: str, status: str, detail: str = "",
|
||||
transcript: str = "", draft: str = "",
|
||||
result: str = "") -> bool:
|
||||
if os.environ.get("ASR_ROLE"):
|
||||
from asrservice.db import update_job as service_update_job
|
||||
return service_update_job(job_id, status, detail, transcript=transcript,
|
||||
draft=draft, result=result)
|
||||
if not job_id:
|
||||
return False
|
||||
con = _connect()
|
||||
fields = ["status=?", "updated_at=?"]
|
||||
values: list[object] = [status, time.time()]
|
||||
if detail:
|
||||
fields.append("detail=?")
|
||||
values.append(detail[:1000])
|
||||
if transcript:
|
||||
fields.append("transcript=?")
|
||||
values.append(transcript)
|
||||
if draft:
|
||||
fields.append("draft=?")
|
||||
values.append(draft)
|
||||
if result:
|
||||
fields.append("result=?")
|
||||
values.append(result)
|
||||
values.append(job_id)
|
||||
cur = con.execute(
|
||||
f"UPDATE intake_job SET {','.join(fields)} WHERE id=?", values)
|
||||
con.commit()
|
||||
con.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def _jobs(limit: int = 30) -> list[dict]:
|
||||
con = _connect()
|
||||
rows = con.execute(
|
||||
"SELECT * FROM intake_job ORDER BY created_at DESC LIMIT ?",
|
||||
(limit,)).fetchall()
|
||||
con.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _job(job_id: str) -> dict | None:
|
||||
con = _connect()
|
||||
row = con.execute("SELECT * FROM intake_job WHERE id=?", (job_id,)).fetchone()
|
||||
con.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _review_url(job_id: str = "") -> str:
|
||||
base = f"http://{CFG.review_public_host}:{CFG.review_port}"
|
||||
return f"{base}/jobs/{job_id}/review/" if job_id else f"{base}/review/"
|
||||
|
||||
|
||||
def _pending_for_job(job: dict, limit: int = 200) -> list[dict]:
|
||||
transcript = str(job.get("transcript") or "")
|
||||
draft = str(job.get("draft") or "")
|
||||
rows = []
|
||||
for row in review.pending(limit):
|
||||
payload = json.loads(row["payload"] or "{}")
|
||||
if ((transcript and row["recording"] == transcript)
|
||||
or (draft and payload.get("draft_path") == draft)
|
||||
or payload.get("job_id") == job["id"]):
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _safe_text(value: str, limit: int) -> str:
|
||||
value = re.sub(r"[\x00-\x1f\x7f]+", " ", value or "")
|
||||
return re.sub(r"\s+", " ", value).strip()[:limit]
|
||||
|
||||
|
||||
def _safe_stem(value: str) -> str:
|
||||
value = _safe_text(value, 80)
|
||||
value = re.sub(r"[^\w\u3400-\u9fff.-]+", "-", value, flags=re.UNICODE)
|
||||
return value.strip("-.") or "录音"
|
||||
|
||||
|
||||
def _write_state() -> None:
|
||||
CFG.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"pid": os.getpid(),
|
||||
"url": _review_url(),
|
||||
"home": f"http://{CFG.review_public_host}:{CFG.review_port}/",
|
||||
"port": CFG.review_port,
|
||||
"started_at": time.time(),
|
||||
}
|
||||
tmp = STATE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(STATE)
|
||||
|
||||
|
||||
def _shell(title: str, body: str, script: str = "") -> bytes:
|
||||
return f"""<!doctype html>
|
||||
<html lang="zh-CN"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light"><meta http-equiv="Cache-Control" content="no-store">
|
||||
<title>{html.escape(title)}</title>
|
||||
<style>
|
||||
:root{{--ink:#17202a;--muted:#637083;--line:#d9dee5;--paper:#f5f7f8;
|
||||
--accent:#176b5b;--danger:#b33a2b;--white:#fff;--warn:#986b12}}
|
||||
*{{box-sizing:border-box}} body{{margin:0;background:var(--paper);color:var(--ink);
|
||||
font-family:system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||
font-size:16px;line-height:1.55;letter-spacing:0}}
|
||||
header{{background:var(--white);border-bottom:1px solid var(--line);padding:15px 18px}}
|
||||
header .inner{{max-width:760px;margin:auto;display:flex;align-items:center;justify-content:space-between}}
|
||||
header strong{{font-size:18px}} nav a{{color:var(--accent);text-decoration:none;margin-left:16px}}
|
||||
main{{max-width:760px;margin:0 auto;padding:18px 14px 44px}}
|
||||
.panel{{background:var(--white);border:1px solid var(--line);border-radius:8px;padding:18px;
|
||||
margin-bottom:14px}} h1{{font-size:22px;margin:0 0 8px}} h2{{font-size:18px;margin:0 0 12px}}
|
||||
.muted,.meta{{color:var(--muted);font-size:14px}} label{{display:block;font-weight:650;margin:12px 0 5px}}
|
||||
input,textarea{{width:100%;border:1px solid #98a4b0;border-radius:6px;background:white;
|
||||
padding:11px 12px;font:inherit}} textarea{{min-height:92px;resize:vertical}}
|
||||
button{{min-height:46px;border:0;border-radius:6px;padding:10px 15px;background:var(--accent);
|
||||
color:white;font:inherit;font-weight:650}} button:disabled{{opacity:.55}}
|
||||
button.secondary{{background:#e9edf1;color:var(--ink);border:1px solid var(--line)}}
|
||||
.actions{{display:flex;gap:10px;align-items:center;margin-top:14px}} .actions button{{flex:1}}
|
||||
progress{{width:100%;height:12px;margin-top:12px}} .job{{padding:12px 0;border-top:1px solid var(--line)}}
|
||||
.job:first-child{{border-top:0}} .jobtop{{display:flex;gap:10px;justify-content:space-between}}
|
||||
.joblink{{display:inline-block;margin-top:7px;color:var(--accent);font-weight:650;text-decoration:none}}
|
||||
.badge{{white-space:nowrap;font-size:13px;color:var(--accent);font-weight:700}}
|
||||
.badge.failed{{color:var(--danger)}} .badge.review{{color:var(--warn)}}
|
||||
.source{{margin:14px 0;padding:12px;border-left:4px solid #d4a72c;background:#fff9e8}}
|
||||
.choice{{width:100%;margin:5px 0}} .custom{{display:flex;gap:8px;margin-top:8px}}
|
||||
.custom input{{min-width:0;flex:1}} .custom button{{width:auto}}
|
||||
.draft{{margin:14px 0;border:1px solid var(--line);border-radius:6px;background:#fbfcfd}}
|
||||
.draft summary{{padding:12px 14px;font-weight:650;cursor:pointer}}
|
||||
.draft pre{{margin:0;padding:14px;border-top:1px solid var(--line);max-height:58vh;
|
||||
overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.55 ui-monospace,monospace}}
|
||||
@media(max-width:480px){{main{{padding:12px 10px 28px}}.panel{{padding:15px}}
|
||||
.custom,.actions{{display:grid}}.custom button{{width:100%}}}}
|
||||
</style></head><body><header><div class="inner"><strong>录音工作台</strong>
|
||||
<nav><a href="/">上传</a><a href="/review/">确认</a></nav></div></header>
|
||||
<main>{body}</main>{script}</body></html>""".encode("utf-8")
|
||||
|
||||
|
||||
def _home() -> bytes:
|
||||
body = """<section class="panel"><h1>上传录音</h1>
|
||||
<div class="muted">支持 mp3、m4a、wav、mp4、aac、ogg、opus、webm、flac。上传完成后自动进入现有 ASR 流程。</div>
|
||||
<form id="upload">
|
||||
<label for="audio">录音文件</label><input id="audio" type="file" accept="audio/*,video/mp4" required>
|
||||
<label for="title">标题</label><input id="title" maxlength="80" placeholder="例如:洛启项目通话">
|
||||
<label for="context">已知背景(可选)</label><textarea id="context" maxlength="2000"
|
||||
placeholder="参与人、公司、项目、希望重点确认的事项。该信息会单独标记为用户提供,不会混入转写原文。"></textarea>
|
||||
<div class="actions"><button id="send" type="submit">上传并开始处理</button></div>
|
||||
<progress id="progress" max="100" value="0" hidden></progress>
|
||||
<div id="result" class="meta"></div></form></section>
|
||||
<section class="panel"><h2>最近任务</h2><div id="jobs"><div class="muted">正在读取…</div></div></section>"""
|
||||
script = """<script>
|
||||
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
async function jobs(){
|
||||
try{const r=await fetch('/api/jobs',{cache:'no-store'}), d=await r.json();
|
||||
const box=document.querySelector('#jobs');
|
||||
box.innerHTML=d.jobs.length?d.jobs.map(j=>`<div class="job"><div class="jobtop">
|
||||
<strong>${esc(j.title||j.original_name)}</strong><span class="badge ${esc(j.status)}">${esc(j.status_label)}</span>
|
||||
</div><div class="meta">${new Date(j.created_at*1000).toLocaleString()} · ${esc(j.original_name)}
|
||||
${j.detail?'<br>'+esc(j.detail):''}</div>
|
||||
<a class="joblink" href="${esc(j.review_url)}">${j.status==='complete'?'查看任务':'查看 / 继续确认'}</a>
|
||||
</div>`).join(''):'<div class="muted">还没有任务</div>';
|
||||
}catch(e){document.querySelector('#jobs').textContent='任务状态暂时不可用';}}
|
||||
jobs(); setInterval(jobs,10000);
|
||||
document.querySelector('#upload').addEventListener('submit',e=>{
|
||||
e.preventDefault(); const f=document.querySelector('#audio').files[0]; if(!f)return;
|
||||
const x=new XMLHttpRequest(), b=document.querySelector('#send'), p=document.querySelector('#progress'), out=document.querySelector('#result');
|
||||
b.disabled=true;p.hidden=false;out.textContent='正在上传…';
|
||||
x.open('POST','/api/upload');x.setRequestHeader('Content-Type','application/octet-stream');
|
||||
x.setRequestHeader('X-Filename',encodeURIComponent(f.name));
|
||||
x.setRequestHeader('X-Title',encodeURIComponent(document.querySelector('#title').value));
|
||||
x.setRequestHeader('X-Context',encodeURIComponent(document.querySelector('#context').value));
|
||||
x.upload.onprogress=v=>{if(v.lengthComputable)p.value=Math.round(v.loaded/v.total*100)};
|
||||
x.onload=()=>{b.disabled=false;if(x.status===201){out.textContent='上传完成,已进入处理队列。';e.target.reset();p.value=0;p.hidden=true;jobs()}
|
||||
else out.textContent='上传失败:'+x.responseText};
|
||||
x.onerror=()=>{b.disabled=false;out.textContent='网络中断,请重试'};x.send(f);
|
||||
});
|
||||
</script>"""
|
||||
return _shell("录音工作台", body, script)
|
||||
|
||||
|
||||
def _review_page(action: str, job: dict | None = None) -> bytes:
|
||||
rows = _pending_for_job(job) if job else review.pending(100)
|
||||
if not rows:
|
||||
if job:
|
||||
label = STATUS_LABEL.get(job["status"], job["status"])
|
||||
detail = html.escape(str(job.get("detail") or ""))
|
||||
return _shell("任务状态",
|
||||
f'<section class="panel"><div class="meta">{html.escape(label)}</div>'
|
||||
f'<h1>{html.escape(str(job.get("title") or job["original_name"]))}</h1>'
|
||||
f'<p>{detail or "当前没有待确认问题。"}</p>'
|
||||
'<p class="muted">页面会随任务阶段更新,可以稍后刷新。</p></section>')
|
||||
return _shell("确认完成",
|
||||
'<section class="panel"><h1>当前没有待确认项目</h1>'
|
||||
'<p class="muted">新草稿生成后,Telegram 会再次发送这里的链接。</p></section>')
|
||||
row = rows[0]
|
||||
payload = json.loads(row["payload"] or "{}")
|
||||
choices = payload.get("candidates") or []
|
||||
buttons = "".join(
|
||||
f'<button class="choice" type="submit" name="answer" '
|
||||
f'value="{html.escape(str(v), quote=True)}">{html.escape(str(v))}</button>'
|
||||
for v in choices)
|
||||
buttons += ('<button class="choice secondary" type="submit" name="answer" '
|
||||
'value="无法确认,保留原文">都不是 / 无法确认</button>')
|
||||
source = html.escape(str(payload.get("sentence", "")))
|
||||
source_html = f'<div class="source">{source}</div>' if source else ""
|
||||
draft_html = ""
|
||||
if payload.get("draft_path"):
|
||||
try:
|
||||
path = Path(payload["draft_path"]).resolve()
|
||||
path.relative_to(CFG.drafts.resolve())
|
||||
draft = path.read_text(encoding="utf-8", errors="ignore")
|
||||
draft_html = ('<details class="draft"><summary>查看完整草稿</summary><pre>'
|
||||
+ html.escape(draft) + "</pre></details>")
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
placeholder = ("只输入姓名,例如:高瑶"
|
||||
if payload.get("kind") == "voiceprint"
|
||||
else ("背景不准时,写明正确背景并提交重跑"
|
||||
if payload.get("kind") == "background_check"
|
||||
else "输入正确名称或补充说明"))
|
||||
title = (str(job.get("title") or job["original_name"])
|
||||
if job else "ASR 快速确认")
|
||||
body = f"""<section class="panel"><div class="meta">{html.escape(title)} · 剩余 {len(rows)} 个 · 问题 #{row['id']}</div>
|
||||
<h1>{html.escape(row['question'])}</h1>{source_html}{draft_html}
|
||||
<form method="post" action="{html.escape(action, quote=True)}">
|
||||
<input type="hidden" name="id" value="{row['id']}">{buttons}
|
||||
<div class="custom"><input name="custom" maxlength="120" autocomplete="off"
|
||||
placeholder="{html.escape(placeholder, quote=True)}"><button type="submit">提交</button></div>
|
||||
</form></section>"""
|
||||
return _shell("ASR 快速确认", body)
|
||||
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, addr, handler):
|
||||
super().__init__(addr, handler)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "ASRControl/1"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
status = args[1] if len(args) > 1 else "?"
|
||||
log(f"control web: {self.command} {urllib.parse.urlsplit(self.path).path} status={status}")
|
||||
|
||||
def _headers(self, status=HTTPStatus.OK,
|
||||
content_type="text/html; charset=utf-8"):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
|
||||
def _send(self, data: bytes, status=HTTPStatus.OK,
|
||||
content_type="text/html; charset=utf-8"):
|
||||
self._headers(status, content_type)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _json(self, obj: object, status=HTTPStatus.OK):
|
||||
self._send(json.dumps(obj, ensure_ascii=False).encode("utf-8"),
|
||||
status, "application/json; charset=utf-8")
|
||||
|
||||
def _review_job(self) -> tuple[bool, dict | None]:
|
||||
path = urllib.parse.urlsplit(self.path).path
|
||||
if path == "/review/":
|
||||
return True, None
|
||||
match = re.fullmatch(r"/jobs/([0-9a-z-]{8,64})/review/", path)
|
||||
if match:
|
||||
return True, _job(match.group(1))
|
||||
return False, None
|
||||
|
||||
def do_GET(self):
|
||||
path = urllib.parse.urlsplit(self.path).path
|
||||
if path == "/":
|
||||
return self._send(_home())
|
||||
if path == "/health":
|
||||
return self._json({"ok": True, "pending": len(review.pending(100))})
|
||||
if path == "/api/jobs":
|
||||
jobs = _jobs()
|
||||
for j in jobs:
|
||||
j["status_label"] = STATUS_LABEL.get(j["status"], j["status"])
|
||||
j["review_url"] = f"/jobs/{j['id']}/review/"
|
||||
j["has_draft"] = bool(j.get("draft"))
|
||||
j.pop("context", None)
|
||||
j.pop("transcript", None)
|
||||
j.pop("draft", None)
|
||||
j.pop("result", None)
|
||||
return self._json({"jobs": jobs})
|
||||
is_review, job = self._review_job()
|
||||
if is_review:
|
||||
if path != "/review/" and not job:
|
||||
return self._send(b"Task not found", HTTPStatus.NOT_FOUND,
|
||||
"text/plain")
|
||||
return self._send(_review_page(path, job))
|
||||
return self._send(b"Not found", HTTPStatus.NOT_FOUND, "text/plain")
|
||||
|
||||
def _header(self, name: str, limit: int) -> str:
|
||||
return _safe_text(urllib.parse.unquote(self.headers.get(name, "")), limit)
|
||||
|
||||
def _upload(self):
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
length = 0
|
||||
if length <= 0 or length > MAX_UPLOAD:
|
||||
return self._json({"error": "invalid upload size"},
|
||||
HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
|
||||
original = self._header("X-Filename", 240)
|
||||
title = self._header("X-Title", 80)
|
||||
context = self._header("X-Context", 2000)
|
||||
suffix = Path(original).suffix.lower()
|
||||
if suffix not in SUPPORTED:
|
||||
return self._json({"error": f"unsupported file type: {suffix or '(none)'}"},
|
||||
HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
|
||||
job_id = secrets.token_hex(12)
|
||||
stem = _safe_stem(title or Path(original).stem)
|
||||
stored = f"{time.strftime('%Y-%m-%d_%H_%M_%S')}-{stem}-{job_id[:8]}{suffix}"
|
||||
INBOX.mkdir(parents=True, exist_ok=True)
|
||||
staging = INBOX / ".uploading"
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
target = INBOX / stored
|
||||
part = staging / f"{job_id}.part"
|
||||
con = _connect()
|
||||
now = time.time()
|
||||
con.execute(
|
||||
"INSERT INTO intake_job(id,original_name,stored_name,title,context,size,"
|
||||
"status,created_at,updated_at) VALUES(?,?,?,?,?,?,?, ?,?)",
|
||||
(job_id, original, stored, title, context, length,
|
||||
"uploading", now, now))
|
||||
con.commit()
|
||||
con.close()
|
||||
remaining = length
|
||||
try:
|
||||
with part.open("xb") as f:
|
||||
while remaining:
|
||||
data = self.rfile.read(min(CHUNK, remaining))
|
||||
if not data:
|
||||
raise EOFError("upload ended early")
|
||||
f.write(data)
|
||||
remaining -= len(data)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
META.mkdir(parents=True, exist_ok=True)
|
||||
meta = {
|
||||
"job_id": job_id, "title": title, "context": context,
|
||||
"original_name": original, "stored_name": stored,
|
||||
}
|
||||
meta_tmp = META / f".{stored}.tmp"
|
||||
meta_tmp.write_text(json.dumps(meta, ensure_ascii=False),
|
||||
encoding="utf-8")
|
||||
meta_tmp.replace(META / f"{stored}.json")
|
||||
part.replace(target)
|
||||
update_job(job_id, "queued", "录音已落盘,等待 watcher 接收")
|
||||
return self._json({"ok": True, "job_id": job_id,
|
||||
"stored_name": stored}, HTTPStatus.CREATED)
|
||||
except Exception as e:
|
||||
part.unlink(missing_ok=True)
|
||||
update_job(job_id, "failed", f"上传失败:{e}")
|
||||
return self._json({"error": str(e)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||
|
||||
def _answer(self):
|
||||
try:
|
||||
length = min(int(self.headers.get("Content-Length", "0")), 4096)
|
||||
form = urllib.parse.parse_qs(
|
||||
self.rfile.read(length).decode("utf-8"))
|
||||
qid = int(form.get("id", ["0"])[0])
|
||||
answer = (form.get("custom", [""])[0].strip()
|
||||
or form.get("answer", [""])[0].strip())
|
||||
if not qid or not answer or len(answer) > 120:
|
||||
raise ValueError("invalid answer")
|
||||
is_review, job = self._review_job()
|
||||
if not is_review:
|
||||
raise ValueError("invalid review path")
|
||||
allowed = (_pending_for_job(job, 1000) if job
|
||||
else review.pending(1000))
|
||||
if qid not in {row["id"] for row in allowed}:
|
||||
raise ValueError("问题不属于当前任务或已处理")
|
||||
review.answer(qid, answer)
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self.send_header("Location", urllib.parse.urlsplit(self.path).path)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
except Exception as e:
|
||||
self._send(_shell("提交失败",
|
||||
f'<section class="panel"><h1>提交失败</h1><p>{html.escape(str(e))}</p></section>'),
|
||||
HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlsplit(self.path).path
|
||||
if path in ("/api/upload", "/hook/intake"):
|
||||
return self._upload()
|
||||
if self._review_job()[0]:
|
||||
return self._answer()
|
||||
return self._send(b"Not found", HTTPStatus.NOT_FOUND, "text/plain")
|
||||
|
||||
|
||||
def serve() -> int:
|
||||
try:
|
||||
server = Server((CFG.review_host, CFG.review_port), Handler)
|
||||
except OSError as e:
|
||||
STATE.unlink(missing_ok=True)
|
||||
log(f"control web bind failed on {CFG.review_host}:{CFG.review_port}: {e}",
|
||||
level="ERROR")
|
||||
return 1
|
||||
_write_state()
|
||||
log(f"control web listening on {CFG.review_public_host}:{CFG.review_port}")
|
||||
try:
|
||||
server.serve_forever(poll_interval=0.5)
|
||||
finally:
|
||||
server.server_close()
|
||||
STATE.unlink(missing_ok=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(serve())
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""S3 — deterministic fidelity gate. No LLM, no cost.
|
||||
|
||||
Motivation (measured 2026-07-25): asking gemini-3.5-flash-lite to "去口语化,禁止
|
||||
省略" on 900–2500 char chunks silently dropped 17/49 distinct numeric tokens and
|
||||
136 distinct English tokens from a 118k-char BD call — including `Kras`, `IBD`,
|
||||
`EDC`, `IRT` and the figure `4.5` in "raised about 4.5 million". Output/input char
|
||||
ratio was 0.41–0.47 regardless of chunk size, i.e. the model summarizes when told
|
||||
to rewrite, and prompt wording alone does not stop it.
|
||||
|
||||
So fidelity cannot be asserted, it must be *checked*. This module compares a
|
||||
cleaned chunk against its source over token classes that must survive a purely
|
||||
stylistic rewrite:
|
||||
|
||||
numbers every digit run, as a multiset (口语 "四幺八二" survives as Han and is
|
||||
not expected here, but arabic figures must)
|
||||
latin acronyms / gene names / product codes ≥2 chars
|
||||
speakers the 【…】 turn tags
|
||||
han_terms domain terms from the term bank that appeared in the source
|
||||
|
||||
`check()` returns a verdict; the pipeline retries a failing chunk with a stricter
|
||||
prompt and, on a second failure, keeps the original text and flags it. That makes
|
||||
information loss a *visible, bounded* event instead of a silent one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .config import CFG
|
||||
|
||||
BANK = CFG.termbank_dir
|
||||
|
||||
NUM = re.compile(r"\d+(?:[.,]\d+)?%?")
|
||||
# S0's noise-collapse marker is synthetic metadata, not transcript content. Its
|
||||
# repeat count is a number ("[…重复噪声已折叠:beep×3696…]"), and scoring it as content
|
||||
# made one fixture report number_recall 0.00 off a single token that the cleaner had
|
||||
# merely reworded. Strip markers from both sides before any comparison.
|
||||
COLLAPSE_MARKER = re.compile(r"\[…重复噪声已折叠:.*?…\]")
|
||||
LATIN = re.compile(r"[A-Za-z][A-Za-z0-9\-]{1,}")
|
||||
SPEAKER = re.compile(r"【([^】]+)】")
|
||||
# Filler words a legitimate 去口语化 pass is expected to delete. Used only for the
|
||||
# expected-compression estimate, never for the pass/fail decision.
|
||||
FILLERS = ["嗯", "呃", "啊", "哦", "那个", "这个", "就是", "然后", "其实", "对对",
|
||||
"哈哈", "唉", "诶", "喔", "嘛", "吧", "呀", "咯", "哎"]
|
||||
|
||||
_STOP_LATIN = {
|
||||
"the", "and", "for", "you", "that", "this", "with", "have", "was",
|
||||
"are", "not", "but", "they", "our", "can", "all", "will", "would",
|
||||
"yeah", "okay", "ok", "uh", "um", "so", "we", "it", "is", "to",
|
||||
"of", "in", "on", "at", "be", "do", "if", "or", "as", "an", "a",
|
||||
"i", "he", "she", "his", "her", "them", "there", "then", "just",
|
||||
"very", "know", "think", "like", "get", "got", "one", "also",
|
||||
"from", "what", "when", "who", "how", "yes", "no", "right",
|
||||
# --- interjections and ordinary verbs the ASR emits in caps -------------
|
||||
# Measured cost of omitting these: on a 118k-char English-mixed call the gate
|
||||
# tripped on lost tokens "AH", "OH", "PAY", "BECAUSE", "EVEN", "WHEREAS" and
|
||||
# forced 53 retries for gemini-3.5-flash-lite, ~40% of them for interjections a
|
||||
# cleaner is *supposed* to delete. Treating them as content made the gate
|
||||
# expensive without making it safer.
|
||||
"ah", "oh", "eh", "hm", "hmm", "mm", "mhm", "huh", "wow", "hey", "hi",
|
||||
"well", "actually", "really", "maybe", "sure", "fine", "good", "great",
|
||||
"because", "even", "whereas", "however", "though", "although", "since",
|
||||
"while", "before", "after", "about", "into", "over", "under", "than",
|
||||
"such", "some", "any", "more", "most", "much", "many", "other", "same",
|
||||
"only", "still", "already", "always", "never", "again", "here", "where",
|
||||
"why", "which", "whose", "whom", "these", "those", "their", "your", "my",
|
||||
"me", "us", "him", "was", "were", "been", "being", "does", "did", "done",
|
||||
"has", "had", "having", "should", "could", "must", "may", "might", "shall",
|
||||
"want", "need", "make", "made", "take", "took", "give", "gave", "come",
|
||||
"came", "went", "said", "say", "says", "tell", "told", "see", "saw",
|
||||
"look", "mean", "means", "means", "let", "put", "keep", "kept", "find",
|
||||
"found", "feel", "felt", "seem", "seems", "sorry", "thanks", "thank",
|
||||
"please", "bye", "abc", "pay", "everybody", "everything", "something",
|
||||
"anything", "nothing", "everyone", "someone", "anyone", "nobody",
|
||||
"first", "second", "third", "last", "next", "both", "each", "every",
|
||||
"little", "big", "small", "long", "short", "high", "low", "new", "old",
|
||||
"time", "year", "day", "way", "thing", "things", "people", "person",
|
||||
"lot", "lots", "bit", "kind", "sort", "part", "point", "case", "fact",
|
||||
"course", "example", "problem", "question", "answer", "reason", "result",
|
||||
}
|
||||
|
||||
|
||||
def _load_terms() -> set[str]:
|
||||
p = BANK / "terms.json"
|
||||
if not p.exists():
|
||||
return set()
|
||||
return set(json.loads(p.read_text()).keys())
|
||||
|
||||
|
||||
_TERMS: set[str] | None = None
|
||||
|
||||
|
||||
def domain_terms() -> set[str]:
|
||||
global _TERMS
|
||||
if _TERMS is None:
|
||||
_TERMS = _load_terms()
|
||||
return _TERMS
|
||||
|
||||
|
||||
def _latin_tokens(text: str) -> Counter:
|
||||
"""Meaningful Latin tokens: acronyms, gene/product codes, CamelCase. Ordinary
|
||||
English function words are excluded — a cleaner legitimately rewrites
|
||||
"Yeah, so we, we did that" and must not be punished for it."""
|
||||
out = Counter()
|
||||
for t in LATIN.findall(text):
|
||||
low = t.lower()
|
||||
if low in _STOP_LATIN or len(t) < 2:
|
||||
continue
|
||||
# Keep: all-caps (EDC/IRT/GMP), mixed case with an inner capital (Kras is
|
||||
# lowercase-tail but capitalized; KRAS is caps), anything with a digit or
|
||||
# hyphen (BF4182, CD3-x), and capitalized proper nouns.
|
||||
if t.isupper() or any(c.isdigit() for c in t) or "-" in t or t[0].isupper():
|
||||
out[t.upper()] += 1
|
||||
return out
|
||||
|
||||
|
||||
def _han_terms(text: str, vocab: set[str]) -> Counter:
|
||||
out = Counter()
|
||||
for t in vocab:
|
||||
if len(t) >= 3 and t in text: # ≥3 chars: shorter ones collide constantly
|
||||
out[t] += text.count(t)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class Verdict:
|
||||
ok: bool
|
||||
ratio: float
|
||||
lost_numbers: list[str] = field(default_factory=list)
|
||||
lost_latin: list[str] = field(default_factory=list)
|
||||
lost_speakers: list[str] = field(default_factory=list)
|
||||
lost_terms: list[str] = field(default_factory=list)
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
|
||||
def brief(self) -> str:
|
||||
if self.ok:
|
||||
return f"ok ratio={self.ratio:.2f}"
|
||||
return f"FAIL ratio={self.ratio:.2f} " + "; ".join(self.reasons)
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
"ok": self.ok, "ratio": round(self.ratio, 3),
|
||||
"lost_numbers": self.lost_numbers, "lost_latin": self.lost_latin,
|
||||
"lost_speakers": self.lost_speakers, "lost_terms": self.lost_terms,
|
||||
"reasons": self.reasons,
|
||||
}
|
||||
|
||||
|
||||
def check(src: str, out: str, *, min_ratio: float = 0.55,
|
||||
max_ratio: float = 1.35, vocab: set[str] | None = None,
|
||||
allow_lost_latin: int = 0) -> Verdict:
|
||||
"""Compare a cleaned chunk against its source.
|
||||
|
||||
min_ratio 0.55 is deliberately generous: real 去口语化 on filler-heavy Chinese
|
||||
speech legitimately removes 25–40% of characters. Anything below 0.55 is not
|
||||
cleaning, it is summarizing.
|
||||
"""
|
||||
vocab = domain_terms() if vocab is None else vocab
|
||||
src = COLLAPSE_MARKER.sub("", src)
|
||||
out = COLLAPSE_MARKER.sub("", out)
|
||||
ratio = len(out) / max(1, len(src))
|
||||
v = Verdict(ok=True, ratio=ratio)
|
||||
|
||||
if ratio < min_ratio:
|
||||
v.ok = False
|
||||
v.reasons.append(f"过度压缩(ratio {ratio:.2f}<{min_ratio})")
|
||||
if ratio > max_ratio:
|
||||
v.ok = False
|
||||
v.reasons.append(f"异常膨胀(ratio {ratio:.2f}>{max_ratio})")
|
||||
|
||||
sn, on = Counter(NUM.findall(src)), Counter(NUM.findall(out))
|
||||
lost_n = sorted((sn - on).elements())
|
||||
if lost_n:
|
||||
v.lost_numbers = lost_n
|
||||
v.ok = False
|
||||
v.reasons.append(f"丢数字{len(lost_n)}个:{lost_n[:6]}")
|
||||
|
||||
sl, ol = _latin_tokens(src), _latin_tokens(out)
|
||||
lost_l = sorted((sl - ol).elements())
|
||||
if len(lost_l) > allow_lost_latin:
|
||||
v.lost_latin = lost_l
|
||||
v.ok = False
|
||||
v.reasons.append(f"丢英文术语{len(lost_l)}个:{lost_l[:6]}")
|
||||
|
||||
ss, os_ = Counter(SPEAKER.findall(src)), Counter(SPEAKER.findall(out))
|
||||
lost_s = sorted(set((ss - os_).elements()))
|
||||
if lost_s:
|
||||
v.lost_speakers = lost_s
|
||||
v.ok = False
|
||||
v.reasons.append(f"丢说话人标签:{lost_s[:5]}")
|
||||
|
||||
if vocab:
|
||||
st, ot = _han_terms(src, vocab), _han_terms(out, vocab)
|
||||
lost_t = sorted(set((st - ot).elements()))
|
||||
if lost_t:
|
||||
v.lost_terms = lost_t
|
||||
v.ok = False
|
||||
v.reasons.append(f"丢领域术语{len(lost_t)}个:{lost_t[:6]}")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
def expected_compression(src: str) -> float:
|
||||
"""Rough share of characters attributable to filler, for interpreting ratios."""
|
||||
n = sum(src.count(f) * len(f) for f in FILLERS)
|
||||
return n / max(1, len(src))
|
||||
|
||||
|
||||
def score_document(src: str, out: str, vocab: set[str] | None = None) -> dict:
|
||||
"""Document-level metrics for the横评 report (not a gate)."""
|
||||
vocab = domain_terms() if vocab is None else vocab
|
||||
src = COLLAPSE_MARKER.sub("", src)
|
||||
out = COLLAPSE_MARKER.sub("", out)
|
||||
sn, on = Counter(NUM.findall(src)), Counter(NUM.findall(out))
|
||||
sl, ol = _latin_tokens(src), _latin_tokens(out)
|
||||
st, ot = _han_terms(src, vocab), _han_terms(out, vocab)
|
||||
ss, os_ = set(SPEAKER.findall(src)), set(SPEAKER.findall(out))
|
||||
|
||||
def recall(a: Counter, b: Counter) -> float:
|
||||
tot = sum(a.values())
|
||||
if not tot:
|
||||
return 1.0
|
||||
kept = sum(min(c, b[k]) for k, c in a.items())
|
||||
return kept / tot
|
||||
|
||||
return {
|
||||
"char_ratio": round(len(out) / max(1, len(src)), 3),
|
||||
"filler_share": round(expected_compression(src), 3),
|
||||
"number_recall": round(recall(sn, on), 3),
|
||||
"latin_recall": round(recall(sl, ol), 3),
|
||||
"term_recall": round(recall(st, ot), 3),
|
||||
"speaker_recall": round(len(ss & os_) / len(ss), 3) if ss else 1.0,
|
||||
"src_numbers": sum(sn.values()), "src_latin": sum(sl.values()),
|
||||
"src_terms": sum(st.values()), "src_speakers": len(ss),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
src = Path(sys.argv[1]).read_text(errors="ignore")
|
||||
out = Path(sys.argv[2]).read_text(errors="ignore")
|
||||
print(json.dumps(score_document(src, out), ensure_ascii=False, indent=2))
|
||||
print(check(src, out).brief())
|
||||
@@ -0,0 +1,956 @@
|
||||
#!/usr/bin/env python3
|
||||
"""S1 — entity/term candidate flagger v3. Local, no LLM cost.
|
||||
|
||||
Inherits the correct design of the production v2 script
|
||||
(`OBSIDIAN/bin/obsidian-asr-phonetic-correct.py`): phonetics raise a flag,
|
||||
*sentence context decides*, so this stays a high-recall flagger that never
|
||||
rewrites anything. It emits candidates and lets the S2 cleaning model judge each
|
||||
one against the sentence it sits in — the step that correctly rejects
|
||||
主要(adverb)→朱岩 while accepting 中药("找大涛")→王宗耀.
|
||||
|
||||
Three changes over v2, each fixing a measured problem:
|
||||
|
||||
1. **Speed.** v2 took 31s per transcript: it re-scanned all 349 minutes notes on
|
||||
every run and did a pure-Python O(n·m) syllable DP against every candidate.
|
||||
v3 caches the corpus scan on disk (keyed by directory mtimes) and pre-buckets
|
||||
candidates by (length, first-syllable-initial), so the DP only runs on
|
||||
plausible pairs. Target <5s.
|
||||
|
||||
2. **Precision.** v2 offered 「分钟」→冯昆仑 and 「很远」→恒元生物 — ordinary words
|
||||
displaced by entities. v3 consults the term-bank allowlist (1,644 high-frequency
|
||||
general words from THUOCL): a span that IS a common word is demoted, not
|
||||
dropped, and carries an explicit `common_word: true` warning so the model
|
||||
defaults to keeping it.
|
||||
|
||||
3. **Coverage.** v2 only searched vault entities. v3 adds the domain term bank
|
||||
(CDMO/CMC jargon, 239 Chinese INN drug names) and INN suffix detection, so it
|
||||
flags 批记录 mis-hearings (披露/P图) and drug names never seen in the vault.
|
||||
|
||||
Output is JSON: candidates grouped per chunk index, ready to inject into S2.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .config import CFG
|
||||
|
||||
LAB = CFG.root
|
||||
BANK = CFG.termbank_dir
|
||||
CACHE = CFG.cache_dir / "flagger"
|
||||
VAULT = CFG.vault
|
||||
|
||||
HS_API = CFG.hindsight_api
|
||||
HS_BANK = CFG.hindsight_bank
|
||||
HS_TIMEOUT = CFG.hindsight_timeout
|
||||
|
||||
HAN = re.compile(r"[\u4e00-\u9fff]+")
|
||||
HAN_FULL = re.compile(r"^[\u4e00-\u9fff]+$")
|
||||
SPEAKER_TAG = re.compile(r"^\s*[【\[]([^\]】]+)[】\]]\s*")
|
||||
SENT_SPLIT = re.compile(r"[。!?!?…\n;;]+")
|
||||
INITIALS = ["zh", "ch", "sh", "b", "p", "m", "f", "d", "t", "n", "l", "g", "k",
|
||||
"h", "j", "q", "x", "r", "z", "c", "s", "y", "w"]
|
||||
FOLD = {"zh": "z", "ch": "c", "sh": "s"}
|
||||
|
||||
try:
|
||||
from pypinyin import Style, lazy_pinyin
|
||||
except ImportError: # pragma: no cover - fall back to the vendored copy
|
||||
sys.path.insert(0, str(CFG.obsidian / "bin" / "_vendor"))
|
||||
from pypinyin import Style, lazy_pinyin
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phonetics
|
||||
def norm_syllable(s: str) -> tuple[str, str]:
|
||||
init, fin = "", s
|
||||
for cand in INITIALS:
|
||||
if s.startswith(cand):
|
||||
init, fin = cand, s[len(cand):]
|
||||
break
|
||||
init = FOLD.get(init, init)
|
||||
if fin.endswith("ng"):
|
||||
fin = fin[:-1]
|
||||
# Input-method style equivalences beyond the v2 set: these are the confusions
|
||||
# a Mandarin speaker (and therefore an ASR acoustic model) actually makes.
|
||||
fin = fin.replace("uang", "uan").replace("iang", "ian")
|
||||
if fin in ("en", "eng"):
|
||||
fin = "en"
|
||||
if fin in ("in", "ing"):
|
||||
fin = "in"
|
||||
return init, fin
|
||||
|
||||
|
||||
def to_syls(text: str) -> list[tuple[str, str]]:
|
||||
bare = lazy_pinyin(text)
|
||||
toned = lazy_pinyin(text, style=Style.TONE3, neutral_tone_with_five=True)
|
||||
out = []
|
||||
for b, t in zip(bare, toned):
|
||||
if not re.match(r"[a-z]", b):
|
||||
continue
|
||||
out.append((b, t[len(b):] if t.startswith(b) else ""))
|
||||
return out
|
||||
|
||||
|
||||
_PERSONAL_SYL: dict[tuple[tuple[str, str], tuple[str, str]], int] | None = None
|
||||
|
||||
|
||||
def personal_syllable_confusions() -> dict:
|
||||
"""Learn directional heard->intended syllable errors from confirmed fixes."""
|
||||
global _PERSONAL_SYL
|
||||
if _PERSONAL_SYL is not None:
|
||||
return _PERSONAL_SYL
|
||||
out: dict[tuple[tuple[str, str], tuple[str, str]], int] = {}
|
||||
try:
|
||||
from . import lexicon
|
||||
for heard, targets in lexicon.confusion_map().items():
|
||||
hs = to_syls(heard)
|
||||
for intended, n, confirmed in targets:
|
||||
if not confirmed:
|
||||
continue
|
||||
ts = to_syls(intended)
|
||||
if len(hs) != len(ts):
|
||||
continue
|
||||
for h, t in zip(hs, ts):
|
||||
hk, tk = norm_syllable(h[0]), norm_syllable(t[0])
|
||||
if hk == tk:
|
||||
continue
|
||||
out[(hk, tk)] = out.get((hk, tk), 0) + max(1, n)
|
||||
except Exception:
|
||||
pass
|
||||
_PERSONAL_SYL = out
|
||||
return out
|
||||
|
||||
|
||||
def syl_sim(a, b, *, personal: bool = False) -> float:
|
||||
(ap, at), (bp, bt) = a, b
|
||||
ai, af = norm_syllable(ap)
|
||||
bi, bf = norm_syllable(bp)
|
||||
if (ai, af) == (bi, bf):
|
||||
base = 1.0
|
||||
else:
|
||||
base = 0.0
|
||||
if ai == bi:
|
||||
base += 0.5
|
||||
elif {ai, bi} <= {"n", "l"} or {ai, bi} <= {"f", "h"}:
|
||||
base += 0.35 # n/l and f/h merge in many southern accents
|
||||
if af == bf:
|
||||
base += 0.5
|
||||
elif af and bf and af[0] == bf[0]:
|
||||
base += 0.2
|
||||
learned_n = (personal_syllable_confusions().get(((ai, af), (bi, bf)), 0)
|
||||
if personal else 0)
|
||||
if learned_n:
|
||||
# Direction matters: this is how the ASR wrote the sound -> what Kai
|
||||
# confirmed. One confirmation is strong evidence, repetition raises it
|
||||
# gradually, and graph/in-play gating still limits which entities are active.
|
||||
return max(base, min(0.98, 0.89 + 0.03 * math.log1p(learned_n)))
|
||||
return base * (1.0 if at == bt and at else 0.94)
|
||||
|
||||
|
||||
def seq_score(a, b, *, personal: bool = False) -> float:
|
||||
n, m = len(a), len(b)
|
||||
if not n or not m:
|
||||
return 0.0
|
||||
dp = [[0.0] * (m + 1) for _ in range(n + 1)]
|
||||
for i in range(n + 1):
|
||||
dp[i][0] = i
|
||||
for j in range(m + 1):
|
||||
dp[0][j] = j
|
||||
for i in range(1, n + 1):
|
||||
ai = a[i - 1]
|
||||
for j in range(1, m + 1):
|
||||
dp[i][j] = min(dp[i - 1][j - 1] +
|
||||
(1 - syl_sim(ai, b[j - 1], personal=personal)),
|
||||
dp[i - 1][j] + 1, dp[i][j - 1] + 1)
|
||||
return 1 - dp[n][m] / max(n, m)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ candidate pool
|
||||
@dataclass
|
||||
class Cand:
|
||||
form: str # the surface form to match phonetically
|
||||
canonical: str # what to correct to
|
||||
kind: str # 人名 / 公司 / 工艺术语 / 药品通用名 / …
|
||||
source: str
|
||||
weight: float = 0.0 # in-play priority
|
||||
syls: list = field(default_factory=list)
|
||||
rule: str = "full" # how `form` was derived from `canonical` (see RULE_MIN)
|
||||
owner: str = "" # affiliation (entity.company) used for disambiguation
|
||||
role: str = ""
|
||||
anchor: int = 0 # honorific forms: +1 = leading particle, -1 = trailing
|
||||
|
||||
|
||||
# How close a span must sound to a candidate form, per derivation rule. These are
|
||||
# not arbitrary: a rule that carries extra structural evidence can afford a lower
|
||||
# phonetic bar, and a rule with little structure needs a higher one.
|
||||
RULE_MIN = {
|
||||
"full": 0.86, # whole-name homophone, the v3 behaviour
|
||||
"alias": 1.0, # exact card/learned alias — an identity binding, not a guess
|
||||
"org-short": 0.84, # 迈威(生物): the dropped tail is itself evidence
|
||||
"given": 0.88, # 光辉←李光辉: short and riskier, so stricter
|
||||
"honorific": 0.82, # X总/老X: the title is a strong anchor, only X must match
|
||||
"one-off": 0.85, # same length, one syllable substituted
|
||||
"consistency": 0.80, # another spelling of a target already established in-doc
|
||||
"observed": 0.90, # fuzzy neighbour of a confirmed historical ASR error
|
||||
}
|
||||
|
||||
ORG_TAIL = ("生物医药", "生物科技", "有限公司", "股份公司", "生物", "医药", "制药",
|
||||
"药业", "基金", "资本", "投资", "科技", "集团", "股份", "公司", "银行",
|
||||
"证券", "医院", "研究院", "实验室")
|
||||
HONOR_PREFIX = ("老", "小")
|
||||
HONOR_SUFFIX = ("总", "博", "哥", "姐", "老师", "董") # 博=博士: 张博→张成海, 许博→许俊彦
|
||||
|
||||
|
||||
def org_head(name: str) -> str:
|
||||
"""迈威生物→迈威, 宝山基金→宝山. The form people actually say out loud."""
|
||||
for t in ORG_TAIL:
|
||||
if name.endswith(t) and len(name) - len(t) >= 2:
|
||||
return name[:len(name) - len(t)]
|
||||
return ""
|
||||
|
||||
|
||||
def variants(canonical: str, kind: str) -> list[tuple[str, str, int]]:
|
||||
"""Spoken surface forms of an entity, as (form, rule).
|
||||
|
||||
This is the fix for the dominant failure mode measured on 2026-07-21: the
|
||||
matcher only compared whole strings of near-equal length, so every colloquial
|
||||
short form was unreachable — 麦威 could not find 迈威生物, 光辉 could not find
|
||||
李光辉, and 老万 was actively discarded because it matched an alias *exactly*.
|
||||
"""
|
||||
out: list[tuple[str, str, int]] = []
|
||||
if kind in ("公司", "机构"):
|
||||
h = org_head(canonical)
|
||||
if 2 <= len(h) <= 5:
|
||||
out.append((h, "org-short", 0))
|
||||
return out
|
||||
if kind != "人名" or not (2 <= len(canonical) <= 4):
|
||||
return out
|
||||
surname, given = canonical[0], canonical[1:]
|
||||
if len(given) >= 2:
|
||||
out.append((given, "given", 0)) # 光辉 ← 李光辉
|
||||
for p in HONOR_PREFIX:
|
||||
out.append((p + surname, "honorific", +1)) # 老万 / 小万
|
||||
for suf in HONOR_SUFFIX:
|
||||
out.append((surname + suf, "honorific", -1)) # 万总 / 万博
|
||||
if len(given) == 1:
|
||||
out.append((given + suf, "honorific", -1)) # 洵总 ← 邹洵
|
||||
return out
|
||||
|
||||
|
||||
def _dir_sig(paths) -> str:
|
||||
h = hashlib.sha256()
|
||||
for p in paths:
|
||||
p = Path(p)
|
||||
if p.exists():
|
||||
if p.is_file():
|
||||
st = p.stat()
|
||||
h.update(f"{p}:{st.st_mtime_ns}:{st.st_size}".encode())
|
||||
continue
|
||||
files = sorted(p.glob("*.md"))
|
||||
h.update(f"{p}:{len(files)}".encode())
|
||||
for f in files:
|
||||
st = f.stat()
|
||||
h.update(f"{f.name}:{st.st_mtime_ns}:{st.st_size}".encode())
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
|
||||
def load_pool(use_termbank=True) -> dict[str, Cand]:
|
||||
"""Vault entities (cards + glossary) ∪ optional domain term bank."""
|
||||
CACHE.mkdir(parents=True, exist_ok=True)
|
||||
if CFG.use_lexicon:
|
||||
try:
|
||||
from . import lexicon
|
||||
lexicon.sync_from_vault()
|
||||
except Exception:
|
||||
pass
|
||||
sig = _dir_sig([VAULT / "People", VAULT / "Companies", VAULT / "minutes",
|
||||
CFG.lexicon_db])
|
||||
cpath = CACHE / f"pool6-{sig}-{int(use_termbank)}.json"
|
||||
if cpath.exists():
|
||||
d = json.loads(cpath.read_text())
|
||||
return {k: Cand(**v) for k, v in d.items()}
|
||||
|
||||
pool: dict[str, Cand] = {}
|
||||
|
||||
def add(form, canonical, kind, source, rule="full", owner="", role="", anchor=0):
|
||||
"""Register a matchable form.
|
||||
|
||||
Keyed by (form, rule, canonical) rather than form alone: 迈威 is both an
|
||||
exact card alias of 迈威生物 *and* its org-short form. Keeping only the
|
||||
alias entry made 麦威 unmatchable, because the alias rule matches exactly
|
||||
and never phonetically. Both entries must coexist.
|
||||
"""
|
||||
form = (form or "").strip()
|
||||
if not (2 <= len(form) <= 12) or not HAN_FULL.match(form):
|
||||
return
|
||||
key = f"{form}\x00{rule}\x00{canonical}"
|
||||
if key in pool:
|
||||
return
|
||||
pool[key] = Cand(form, canonical, kind, source, syls=to_syls(form),
|
||||
rule=rule, owner=owner, role=role, anchor=anchor)
|
||||
|
||||
# Entities and their aliases come from the SQLite lexicon, which mirrors the
|
||||
# vault cards (read-only) and additionally holds anything Kai has confirmed.
|
||||
ents: dict = {}
|
||||
if CFG.use_lexicon:
|
||||
try:
|
||||
from . import lexicon
|
||||
ents = lexicon.entities()
|
||||
for name, e in ents.items():
|
||||
add(name, name, e.kind, "lexicon-entity", "full", e.company, e.role)
|
||||
for al, targets in lexicon.alias_map().items():
|
||||
# Every target, not just the first. The glossary deliberately keeps
|
||||
# one-to-many rows (许博→许俊彦/许恒皓, 徐总→徐劲前/邹洵) because they
|
||||
# are context-dependent; collapsing them to one silently discarded
|
||||
# the correct reading.
|
||||
for canon, kind, origin, _conf in targets:
|
||||
e = ents.get(canon)
|
||||
add(al, canon, kind, f"alias-{origin}", "alias",
|
||||
e.company if e else "", e.role if e else "")
|
||||
# Exact confirmed errors are already aliases. This second form is
|
||||
# deliberately gated on in-play and allows a nearby decoder variant
|
||||
# (张台海) to recall the same canonical person learned from
|
||||
# 张泰海→张成海 without enabling global fuzzy matching.
|
||||
for heard, targets in lexicon.confusion_map().items():
|
||||
for canon, _n, confirmed in targets:
|
||||
if not confirmed:
|
||||
continue
|
||||
e = ents.get(canon)
|
||||
add(heard, canon, e.kind if e else lexicon.guess_kind(canon),
|
||||
"observed-correction", "observed",
|
||||
e.company if e else "", e.role if e else "")
|
||||
except Exception:
|
||||
ents = {}
|
||||
if not ents: # lexicon disabled/broken
|
||||
for sub, kind in (("People", "人名"), ("Companies", "公司")):
|
||||
d = VAULT / sub
|
||||
if not d.exists():
|
||||
continue
|
||||
for f in d.glob("*.md"):
|
||||
add(f.stem, f.stem, kind, f"card-{sub.lower()}")
|
||||
|
||||
# Spoken short forms. Generated for every entity; only the in-play ones are
|
||||
# activated at match time, so this costs nothing in precision.
|
||||
for c in list(pool.values()):
|
||||
if c.rule != "full" or c.kind not in ("人名", "公司"):
|
||||
continue
|
||||
for form, rule, anchor in variants(c.canonical, c.kind):
|
||||
add(form, c.canonical, c.kind, "variant", rule, c.owner, c.role, anchor)
|
||||
|
||||
if use_termbank:
|
||||
tb = BANK / "terms.json"
|
||||
if tb.exists():
|
||||
for term, e in json.loads(tb.read_text()).items():
|
||||
# Only the on-domain, distinctive slices are worth phonetic search;
|
||||
# 27k THUOCL general words would flood the candidate space with the
|
||||
# very false positives v3 is trying to eliminate.
|
||||
if e["kind"] in ("工艺术语", "药品通用名", "机构", "已确认纠错目标"):
|
||||
add(term, term, e["kind"], e["sources"][0])
|
||||
|
||||
cpath.write_text(json.dumps({k: v.__dict__ for k, v in pool.items()},
|
||||
ensure_ascii=False), encoding="utf-8")
|
||||
return pool
|
||||
|
||||
|
||||
def load_allowlist() -> set[str]:
|
||||
p = BANK / "allowlist.txt"
|
||||
return set(p.read_text().split()) if p.exists() else set()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- in-play
|
||||
# A wikilink in a minutes note points at other notes as often as at entities.
|
||||
# Unfiltered, 25 of the 70 in-play slots on 2026-07-21 were note titles, bare dates
|
||||
# and `_glossary` — burning candidate space and prompt budget on non-entities.
|
||||
NOT_ENTITY = re.compile(r"^\d{4}-\d{2}|^_|\.md$|[|/#]|^\d+$")
|
||||
|
||||
|
||||
def _is_entity_name(s: str) -> bool:
|
||||
s = s.strip()
|
||||
return bool(s) and 2 <= len(s) <= 12 and not NOT_ENTITY.search(s)
|
||||
|
||||
|
||||
def _minutes_entities(text: str) -> set[str]:
|
||||
out = set()
|
||||
for key in ("people", "companies"):
|
||||
m = re.search(rf"^{key}:[ \t]*(.*)$", text, re.M)
|
||||
if m:
|
||||
out |= {x for x in re.findall(r"[^,\[\]\s,、]+", m.group(1))}
|
||||
out |= {w.strip() for w in re.findall(r"\[\[([^\]|#]+)", text)}
|
||||
return {o for o in out if _is_entity_name(o)}
|
||||
|
||||
|
||||
def _corpus() -> list[tuple[str, set[str], str]]:
|
||||
CACHE.mkdir(parents=True, exist_ok=True)
|
||||
sig = _dir_sig([VAULT / "minutes"])
|
||||
cpath = CACHE / f"corpus-{sig}.json"
|
||||
if cpath.exists():
|
||||
return [(a, set(b), c) for a, b, c in json.loads(cpath.read_text())]
|
||||
out = []
|
||||
for f in sorted((VAULT / "minutes").glob("*.md")):
|
||||
txt = f.read_text(errors="ignore")
|
||||
m = re.search(r"^date:\s*(\d{4}-\d{2}-\d{2})", txt, re.M)
|
||||
out.append((f.name, _minutes_entities(txt), m.group(1) if m else ""))
|
||||
cpath.write_text(json.dumps([(a, sorted(b), c) for a, b, c in out],
|
||||
ensure_ascii=False), encoding="utf-8")
|
||||
return out
|
||||
|
||||
|
||||
def hindsight_recall(seed: list[str], extra_query: str = "") -> tuple[set[str], list[str]]:
|
||||
"""Semantic neighbourhood from Hindsight. Returns (entity names, memory texts).
|
||||
|
||||
The memory texts matter as much as the names: injected into S2/S4 they give the
|
||||
model the *relationship* ("吴楠 is 刘冀's colleague at 跨境资本") that lets it
|
||||
accept or reject a phonetic candidate on business grounds, not just sound.
|
||||
"""
|
||||
if not seed and not extra_query:
|
||||
return set(), []
|
||||
query = (" ".join(sorted(seed)) + " " + extra_query).strip() or "会议 相关人物 公司"
|
||||
body = json.dumps({"query": query, "max_tokens": 1500}).encode()
|
||||
url = f"{HS_API}/v1/default/banks/{HS_BANK}/memories/recall"
|
||||
try:
|
||||
req = urllib.request.Request(url, data=body,
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=HS_TIMEOUT) as r:
|
||||
d = json.loads(r.read())
|
||||
except Exception:
|
||||
return set(), []
|
||||
names = set(d.get("entities", {}).keys())
|
||||
texts = []
|
||||
for r_ in d.get("results", []):
|
||||
names.update(r_.get("entities", []))
|
||||
if r_.get("text"):
|
||||
texts.append(r_["text"])
|
||||
return ({n for n in names if 2 <= len(n) <= 8 and HAN_FULL.match(n)}, texts)
|
||||
|
||||
|
||||
def _super_nodes(participants: set[str], corpus) -> set[str]:
|
||||
if len(participants) < 2:
|
||||
return set()
|
||||
freq = {p: sum(1 for _, e, _ in corpus if p in e) for p in participants}
|
||||
fmin = max(1, min(freq.values()))
|
||||
hubs = {p for p in participants if freq[p] >= 20 and freq[p] >= 3 * fmin}
|
||||
return hubs if len(hubs) < len(participants) else set()
|
||||
|
||||
|
||||
def compute_in_play(participants: set[str], recent_days=90, use_hindsight=True,
|
||||
extra_seed: set[str] | None = None):
|
||||
corpus = _corpus()
|
||||
hubs = _super_nodes(participants, corpus)
|
||||
seed = ((participants - hubs) or participants) | (extra_seed or set())
|
||||
dates = [d for _, _, d in corpus if d]
|
||||
ref = max(dates) if dates else ""
|
||||
weight = {p: 999.0 for p in participants}
|
||||
for _, ents, d in corpus:
|
||||
if not (ents & seed):
|
||||
continue
|
||||
if ref and d and recent_days:
|
||||
try:
|
||||
from datetime import date
|
||||
a = date.fromisoformat(d)
|
||||
b = date.fromisoformat(ref)
|
||||
if (b - a).days > recent_days:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
for e in ents:
|
||||
weight[e] = weight.get(e, 0) + 1
|
||||
try:
|
||||
from . import graph
|
||||
graph.sync()
|
||||
for entity, score in graph.context_weights(seed).items():
|
||||
weight[entity] = weight.get(entity, 0.0) + score
|
||||
except Exception:
|
||||
pass
|
||||
in_play = set(sorted(weight, key=lambda e: -weight[e])[:60])
|
||||
hs_names, hs_texts = hindsight_recall(sorted(seed)) if use_hindsight else (set(), [])
|
||||
return in_play | hs_names, {"hubs": sorted(hubs), "hindsight_names": sorted(hs_names),
|
||||
"hindsight_texts": hs_texts, "weight": weight}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- flagging
|
||||
def sentences_of(text: str):
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith(("#", "<!--", "---")):
|
||||
continue
|
||||
spk = ""
|
||||
m = SPEAKER_TAG.match(line)
|
||||
if m:
|
||||
spk = m.group(1)
|
||||
line = SPEAKER_TAG.sub("", line)
|
||||
for piece in SENT_SPLIT.split(line):
|
||||
piece = piece.strip()
|
||||
if piece:
|
||||
yield spk, piece
|
||||
|
||||
|
||||
def phon_key(syls: list[tuple[str, str]]) -> str:
|
||||
"""Collapse a word to an accent-folded consonant/vowel skeleton.
|
||||
|
||||
This is the input-method trick: 中药 and 宗耀 both become "zonyao", so an exact
|
||||
dict lookup finds the homophone class instantly and only genuinely ambiguous
|
||||
pairs need the expensive alignment. Tones are dropped here and used later as a
|
||||
tiebreak, mirroring how a listener hears a name they半-recognize.
|
||||
"""
|
||||
out = []
|
||||
for bare, _ in syls:
|
||||
i, f = norm_syllable(bare)
|
||||
out.append(i + f)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def flag_text(text: str, pool: dict[str, Cand], in_play: set[str],
|
||||
allowlist: set[str], min_score=0.86, use_termbank_global=True,
|
||||
entity_weights: dict[str, float] | None = None):
|
||||
"""Find phonetic near-matches. Entity candidates are gated on being in-play;
|
||||
domain terms (工艺术语/药品通用名) are always searched, since a drug or a
|
||||
批记录 can appear in a meeting with no prior vault history.
|
||||
|
||||
Performance note: the v2 approach (score every span against every candidate
|
||||
with a Python syllable DP) is O(spans x candidates x syllables^2) and took
|
||||
>400s once the term bank widened the candidate pool. v3 first buckets
|
||||
candidates by phonetic key and length, uses rapidfuzz (C) to shortlist keys,
|
||||
and only then runs the fine-grained tonal alignment on survivors.
|
||||
"""
|
||||
from rapidfuzz import process as rf_process
|
||||
|
||||
# An in-play name with no candidate entry can never be matched. 宝山基金 has no
|
||||
# Companies card — it reached in-play only through a minutes filename — so
|
||||
# 保山 (a perfect homophone of its short form) had nothing to match against.
|
||||
# Materialise those names, including their spoken short forms.
|
||||
pool = dict(pool)
|
||||
# Skip names already reachable as *any* form, not just as a canonical. 迈威 is
|
||||
# the alias/short form of 迈威生物 and also appears in in-play as a bare name;
|
||||
# materialising it again produced two competing candidates for one company.
|
||||
known = {c.canonical for c in pool.values()} | {c.form for c in pool.values()}
|
||||
for name in sorted(in_play):
|
||||
if name in known or not _is_entity_name(name) or not HAN_FULL.match(name):
|
||||
continue
|
||||
is_org = name.endswith(ORG_TAIL)
|
||||
kind = "公司" if is_org else "人名"
|
||||
if not is_org and not (2 <= len(name) <= 4):
|
||||
continue
|
||||
# Variants are only derived for names whose type is *known*. An in-play name
|
||||
# with no card is a guess already; 三金 (short for 桂林三金) was being read as
|
||||
# a surname and generating 老三, which then matched 老尚. Only an explicit
|
||||
# organisation suffix is trustworthy enough to expand here.
|
||||
forms = [(name, "full", 0)] + (variants(name, kind) if is_org else [])
|
||||
for form, rule, anchor in forms:
|
||||
if not (2 <= len(form) <= 12 and HAN_FULL.match(form)):
|
||||
continue
|
||||
pool.setdefault(f"{form}\x00{rule}\x00{name}",
|
||||
Cand(form, name, kind, "in-play", syls=to_syls(form),
|
||||
rule=rule, anchor=anchor))
|
||||
|
||||
active: list[Cand] = []
|
||||
for c in pool.values():
|
||||
if c.rule == "alias":
|
||||
# Curated identity bindings (card `aliases:` + imported glossary). These
|
||||
# are hand-confirmed and matched exactly, so gating them on in-play
|
||||
# would only lose recall — this is what silently dropped 老万→万亚坤.
|
||||
active.append(c)
|
||||
elif c.kind in ("人名", "公司", "项目", "已确认目标"):
|
||||
if c.canonical in in_play or c.form in in_play:
|
||||
active.append(c)
|
||||
elif use_termbank_global:
|
||||
active.append(c)
|
||||
|
||||
# key -> candidates sharing that phonetic skeleton
|
||||
by_key: dict[str, list[Cand]] = {}
|
||||
for c in active:
|
||||
by_key.setdefault(phon_key(c.syls), []).append(c)
|
||||
keys = list(by_key)
|
||||
lens = {len(c.form) for c in active} or {2, 3}
|
||||
widths = sorted({w for L in lens for w in (max(2, L - 1), L, L + 1)})
|
||||
|
||||
# Collect every distinct span once; scoring a span is independent of where it
|
||||
# occurs, so we score uniques and then attribute hits back to sentences.
|
||||
span_positions: dict[str, tuple[str, str]] = {}
|
||||
for spk, sent in sentences_of(text):
|
||||
for run in HAN.findall(sent):
|
||||
for w in widths:
|
||||
for i in range(len(run) - w + 1):
|
||||
span_positions.setdefault(run[i:i + w], (spk, sent))
|
||||
|
||||
span_keys: dict[str, tuple[str, list]] = {}
|
||||
for span in span_positions:
|
||||
syls = to_syls(span)
|
||||
if syls:
|
||||
span_keys[span] = (phon_key(syls), syls)
|
||||
|
||||
findings: dict[tuple, dict] = {}
|
||||
|
||||
def consider(span, cand, syls):
|
||||
# Already the canonical name: nothing to say.
|
||||
if span == cand.canonical:
|
||||
return
|
||||
rule = cand.rule
|
||||
if span == cand.form:
|
||||
# Exact hit on a non-canonical form. Previously this returned early —
|
||||
# which is precisely why 老万 (an exact card alias of 万亚坤) and 迈威
|
||||
# produced no output at all and the drafting model had to guess.
|
||||
sc = 1.0
|
||||
else:
|
||||
if abs(len(span) - len(cand.form)) > 1:
|
||||
return
|
||||
if rule == "alias":
|
||||
return # curated aliases match exactly or not at all
|
||||
if rule == "honorific":
|
||||
# 老X / X总 / X博. The particle carries no identity information, so
|
||||
# it must match *literally*; allowing it to drift turned this rule
|
||||
# into a generic 2-syllable fuzzy match and produced 想谈→谭凯,
|
||||
# 理解→李光辉, 就动→刘冀 — 36 junk flags on one transcript.
|
||||
if len(span) != len(cand.form):
|
||||
return
|
||||
if cand.anchor > 0:
|
||||
if span[0] != cand.form[0]:
|
||||
return
|
||||
a, b = span[1:], cand.form[1:]
|
||||
elif cand.anchor < 0:
|
||||
if span[-1] != cand.form[-1]:
|
||||
return
|
||||
a, b = span[:-1], cand.form[:-1]
|
||||
else:
|
||||
return
|
||||
sc = seq_score(
|
||||
to_syls(a), to_syls(b),
|
||||
personal=(cand.kind in ("人名", "公司", "项目")
|
||||
and cand.canonical in in_play))
|
||||
if sc < 0.86:
|
||||
return
|
||||
else:
|
||||
sc = seq_score(
|
||||
syls, cand.syls,
|
||||
personal=(cand.kind in ("人名", "公司", "项目")
|
||||
and cand.canonical in in_play))
|
||||
if sc < RULE_MIN.get(rule, min_score):
|
||||
return
|
||||
spk, sent = span_positions[span]
|
||||
key = (span, cand.canonical)
|
||||
prev = findings.get(key)
|
||||
# On a tie, keep the curated alias rather than the phonetic match: both score
|
||||
# 1.0 for 万金→万津, and losing the alias provenance downgraded a confirmed
|
||||
# mapping back into a guess.
|
||||
better = prev is None or sc > prev["score"] or (
|
||||
sc == prev["score"] and rule == "alias" and prev.get("rule") != "alias")
|
||||
if better:
|
||||
findings[key] = {
|
||||
"span": span, "target": cand.canonical, "kind": cand.kind,
|
||||
"score": round(sc, 3), "via": cand.form, "speaker": spk,
|
||||
"sentence": sent, "common_word": span in allowlist,
|
||||
"source": cand.source, "rule": rule, "owner": cand.owner,
|
||||
"role": cand.role,
|
||||
}
|
||||
|
||||
# 1. exact skeleton collisions — the high-confidence homophone class
|
||||
for span, (k, syls) in span_keys.items():
|
||||
for cand in by_key.get(k, ()):
|
||||
consider(span, cand, syls)
|
||||
|
||||
# 2. near-miss skeletons via rapidfuzz shortlist (handles one-syllable slips
|
||||
# like 李小雨/李晓宇 that the folded key alone does not merge)
|
||||
uniq_keys = sorted({k for k, _ in span_keys.values()})
|
||||
matches = rf_process.cdist(uniq_keys, keys, workers=-1, score_cutoff=78)
|
||||
shortlist: dict[str, list[str]] = {}
|
||||
for row_i, row in enumerate(matches):
|
||||
hits = [keys[j] for j, v in enumerate(row) if v]
|
||||
if hits:
|
||||
shortlist[uniq_keys[row_i]] = hits
|
||||
for span, (k, syls) in span_keys.items():
|
||||
for tk in shortlist.get(k, ()):
|
||||
if tk == k:
|
||||
continue
|
||||
for cand in by_key[tk]:
|
||||
consider(span, cand, syls)
|
||||
|
||||
# 3. single-character substitution on equal-length names. 张泰海/张成海 scores
|
||||
# 0.82 phonetically (tai vs cheng is a poor match) yet is obviously the same
|
||||
# person: the surname and the tail are identical and only one syllable moved.
|
||||
# Anchoring on shared characters rather than sound catches the whole class.
|
||||
by_len: dict[int, list[Cand]] = {}
|
||||
for c in active:
|
||||
# Canonical names only. Aliases can be arbitrary phrases, and running
|
||||
# single-character substitution against one produced 我老板/跟老板/那老板/
|
||||
# 的老板 → 邹洵 (18 junk flags from the alias 林老板 alone).
|
||||
if c.rule == "full" and c.kind in ("人名", "公司"):
|
||||
by_len.setdefault(len(c.form), []).append(c)
|
||||
for span, (k, syls) in span_keys.items():
|
||||
if len(span) < 3:
|
||||
continue
|
||||
for cand in by_len.get(len(span), ()):
|
||||
same = sum(1 for a, b in zip(span, cand.form) if a == b)
|
||||
if same != len(span) - 1 or span == cand.form:
|
||||
continue
|
||||
# The head must hold. ASR drifts inside a name (张成海→张泰海); a name
|
||||
# whose first character changed is a different name.
|
||||
if span[0] != cand.form[0]:
|
||||
continue
|
||||
spk, sent = span_positions[span]
|
||||
key = (span, cand.canonical)
|
||||
if key not in findings:
|
||||
findings[key] = {
|
||||
"span": span, "target": cand.canonical, "kind": cand.kind,
|
||||
"score": RULE_MIN["one-off"], "via": cand.form, "speaker": spk,
|
||||
"sentence": sent, "common_word": span in allowlist,
|
||||
"source": cand.source, "rule": "one-off", "owner": cand.owner,
|
||||
"role": cand.role,
|
||||
}
|
||||
# Group alternatives for the same span so the model sees a real choice set,
|
||||
# which is the whole point: "这里听到 X,可能是 A 或 B,按句判".
|
||||
# 4. intra-document consistency. Once a target is firmly established somewhere
|
||||
# in the transcript, its *other* spellings in the same transcript can be
|
||||
# accepted at a lower bar — the document itself is the evidence. This is the
|
||||
# 万金/万津/万杰 pattern: one ASR name drifts across several spellings, and
|
||||
# only the strongest of them clears the standalone threshold.
|
||||
firm: dict[str, set[str]] = {}
|
||||
firm_alias: set[str] = set() # targets established by a confirmed alias
|
||||
entity_weights = entity_weights or {}
|
||||
for f in findings.values():
|
||||
if f["rule"] == "alias" and str(f.get("source", "")).startswith("alias-"):
|
||||
firm_alias.add(f["target"])
|
||||
if f["rule"] == "alias" or f["score"] >= 0.95:
|
||||
# Every established surface form is a comparison anchor, the canonical
|
||||
# name included. Keeping only the first one made 万杰 unreachable because
|
||||
# the anchor happened to be 老万 rather than 万津.
|
||||
firm.setdefault(f["target"], {f["target"]}).add(f["via"])
|
||||
# Shortlist by phonetic skeleton before running the DP, same trick as pass 2:
|
||||
# brute-forcing every span against every anchor pushed S1 from 4.7s to 24.5s on
|
||||
# a 27k transcript.
|
||||
anchor_keys: dict[str, list[tuple[str, str, list]]] = {}
|
||||
for target, vias in firm.items():
|
||||
for v in vias:
|
||||
anchor_keys.setdefault(phon_key(to_syls(v)), []).append((target, v, to_syls(v)))
|
||||
if anchor_keys:
|
||||
akeys = list(anchor_keys)
|
||||
cm = rf_process.cdist(uniq_keys, akeys, workers=-1, score_cutoff=65)
|
||||
near: dict[str, list[str]] = {}
|
||||
for i, row in enumerate(cm):
|
||||
hits = [akeys[j] for j, v in enumerate(row) if v]
|
||||
if hits:
|
||||
near[uniq_keys[i]] = hits
|
||||
else:
|
||||
near = {}
|
||||
plan: dict[str, set[str]] = {}
|
||||
for span, (k, syls) in span_keys.items():
|
||||
for ak in near.get(k, ()):
|
||||
for target, _v, _vs in anchor_keys[ak]:
|
||||
plan.setdefault(target, set()).add(span)
|
||||
|
||||
for target, vias in firm.items():
|
||||
anchors = [(v, to_syls(v)) for v in vias]
|
||||
for span in plan.get(target, ()):
|
||||
k, syls = span_keys[span]
|
||||
if span == target or (span, target) in findings or span in allowlist:
|
||||
continue
|
||||
if len(span) < 2:
|
||||
continue
|
||||
best = max(((seq_score(syls, vs), v) for v, vs in anchors
|
||||
if len(v) == len(span)), default=(0.0, ""))
|
||||
sc, via = best
|
||||
if not via or sc < 0.80:
|
||||
continue
|
||||
# Below near-homophone level, demand a shared head. A 2-syllable span at
|
||||
# 0.82 means roughly one syllable matched, which let 总总/全中→邹洵 and
|
||||
# 接商→宝山基金 through. Either the sound is nearly identical (军实/君实,
|
||||
# 1.0) or the first character is stable (万杰/万津, 白发/白帆).
|
||||
if sc < 0.90 and span[0] != via[0]:
|
||||
continue
|
||||
spk, sent = span_positions[span]
|
||||
src = next((c for c in active if c.canonical == target), None)
|
||||
findings[(span, target)] = {
|
||||
"span": span, "target": target, "kind": src.kind if src else "人名",
|
||||
"score": round(sc, 3), "via": via, "speaker": spk, "sentence": sent,
|
||||
"common_word": False,
|
||||
"source": "consistency-alias" if target in firm_alias else "consistency",
|
||||
"rule": "consistency", "owner": src.owner if src else "",
|
||||
"role": src.role if src else "",
|
||||
}
|
||||
|
||||
# Which organisations are actually being discussed. An honorific like 老万 has
|
||||
# one candidate per matching surname; the affiliation is what decides between
|
||||
# them, and the vault already records it (万亚坤.company = 洛启生物, which is
|
||||
# in play here). This is the cross-referencing step that was missing.
|
||||
mentioned_orgs = set()
|
||||
for c in active:
|
||||
if c.kind == "公司" and (c.canonical in text or (c.form and c.form in text)):
|
||||
mentioned_orgs.add(c.canonical)
|
||||
|
||||
grouped: dict[str, dict] = {}
|
||||
for f in findings.values():
|
||||
g = grouped.setdefault(f["span"], {
|
||||
"span": f["span"], "common_word": f["common_word"],
|
||||
"sentence": f["sentence"], "speaker": f["speaker"], "candidates": [],
|
||||
})
|
||||
owner = f.get("owner") or ""
|
||||
support = []
|
||||
if owner and owner in mentioned_orgs:
|
||||
support.append(f"{owner}(本次在谈)")
|
||||
elif owner and owner in in_play:
|
||||
support.append(f"{owner}(近期相关)")
|
||||
if f.get("role"):
|
||||
support.append(f["role"])
|
||||
recent = float(entity_weights.get(f["target"], 0.0))
|
||||
context_boost = min(1.5, math.log1p(max(0.0, recent)) / 2.5)
|
||||
affiliation_boost = 2.0 if (owner and owner in mentioned_orgs) else 0.0
|
||||
boost = affiliation_boost + context_boost
|
||||
if recent >= 0.5:
|
||||
support.append(f"近期权重{recent:.1f}")
|
||||
g["candidates"].append({"target": f["target"], "kind": f["kind"],
|
||||
"score": f["score"], "via": f["via"],
|
||||
"source": f["source"], "rule": f.get("rule", "full"),
|
||||
"owner": owner, "support": "、".join(support),
|
||||
"boost": round(boost, 3),
|
||||
"context_weight": round(recent, 3)})
|
||||
for g in grouped.values():
|
||||
# Affiliation evidence outranks raw phonetic score: a same-surname person
|
||||
# from an unrelated company is a worse guess than a slightly weaker match
|
||||
# from the company under discussion.
|
||||
g["candidates"].sort(
|
||||
key=lambda c: (-(c["score"] + 0.20 * c["boost"]), -c["score"]))
|
||||
# A single, unambiguous, human-confirmed mapping is not a suggestion. Kai's
|
||||
# reaction to a hedged draft was "肯定是指 李光辉 啊" — treating these as
|
||||
# optional is what makes the output feel useless. Ambiguous ones (徐总 has
|
||||
# three possible people) deliberately stay a choice set.
|
||||
c0 = g["candidates"][0]
|
||||
src0 = str(c0.get("source", ""))
|
||||
settled = (c0["rule"] == "alias"
|
||||
and src0.startswith(("alias-card", "alias-learned", "alias-glossary")))
|
||||
# A perfect homophone of an entity already pinned down by a confirmed alias,
|
||||
# in the same recording, is not a separate judgement call: 老善 and 老尚 are
|
||||
# one word the ASR spelled two ways, and 老尚 is confirmed to be 单永强.
|
||||
settled = settled or (c0["rule"] == "consistency"
|
||||
and src0 == "consistency-alias" and c0["score"] >= 0.97)
|
||||
# Curated evidence outranks generated guesses. 老万 is an explicit card alias
|
||||
# of 万亚坤; the rival candidates are only honorific forms auto-derived from
|
||||
# other 万/王-surnamed people, which is far weaker. But two *curated* targets
|
||||
# (徐总 → 徐劲前 or 邹洵) is real ambiguity and must stay a choice.
|
||||
n_alias = sum(1 for c in g["candidates"]
|
||||
if c["rule"] == "alias"
|
||||
and str(c.get("source", "")).startswith("alias-"))
|
||||
g["settled"] = bool(settled and n_alias <= 1
|
||||
and (len(g["candidates"]) == 1 or n_alias == 1))
|
||||
return sorted(grouped.values(),
|
||||
key=lambda g: (g["common_word"], -g["candidates"][0]["boost"],
|
||||
-g["candidates"][0]["score"]))
|
||||
|
||||
|
||||
def analyze(text: str, *, use_hindsight=True, use_termbank=True, min_score=None,
|
||||
extra_in_play: set[str] | None = None):
|
||||
"""Flag candidates.
|
||||
|
||||
`extra_in_play` carries entity names recovered by the S1c keyword recall. It
|
||||
matters most for backends that emit no speaker labels: with no participants to
|
||||
seed from, the co-occurrence graph produces almost nothing, and vault entities
|
||||
could never enter the candidate space at all.
|
||||
"""
|
||||
t0 = time.time()
|
||||
min_score = CFG.flag_min_score if min_score is None else min_score
|
||||
pool = load_pool(use_termbank=use_termbank)
|
||||
allow = load_allowlist()
|
||||
speakers = {s for s, _ in sentences_of(text) if s and HAN_FULL.match(s)}
|
||||
extra = {n for n in (extra_in_play or set()) if _is_entity_name(n)}
|
||||
in_play, ctx = compute_in_play(speakers, use_hindsight=use_hindsight,
|
||||
extra_seed=extra)
|
||||
in_play |= extra
|
||||
flags = flag_text(text, pool, in_play, allow, min_score=min_score,
|
||||
use_termbank_global=use_termbank,
|
||||
entity_weights=ctx.get("weight", {}))
|
||||
return {
|
||||
"elapsed": round(time.time() - t0, 2),
|
||||
"pool_size": len(pool),
|
||||
"speakers": sorted(speakers),
|
||||
"in_play": sorted(in_play),
|
||||
"hubs": ctx["hubs"],
|
||||
"hindsight_names": ctx["hindsight_names"],
|
||||
"hindsight_texts": ctx["hindsight_texts"],
|
||||
"flags": flags,
|
||||
}
|
||||
|
||||
|
||||
RULE_LABEL = {
|
||||
"alias": "已确认别名",
|
||||
"full": "整名近音",
|
||||
"org-short": "机构简称",
|
||||
"given": "去姓称名",
|
||||
"honorific": "称谓",
|
||||
"one-off": "一字之差",
|
||||
"consistency": "同篇另一写法",
|
||||
"observed": "历史误识别近音",
|
||||
}
|
||||
|
||||
|
||||
def render_for_prompt(flags: list[dict], limit=60) -> str:
|
||||
"""The block injected into an S2 chunk prompt.
|
||||
|
||||
Each candidate carries *why* it is a candidate (derivation rule) and *what
|
||||
supports it* (the affiliation the vault records, and whether that organisation
|
||||
is being discussed in this very recording). Without that, the model can only
|
||||
compare sounds, and it hedged on 老万 even though the vault says 万亚坤 is
|
||||
洛启生物's 实控人 and 洛启 is the subject of the call.
|
||||
"""
|
||||
lines = []
|
||||
must, maybe = [], []
|
||||
for g in flags[:limit]:
|
||||
parts = []
|
||||
for c in g["candidates"][:3]:
|
||||
tag = RULE_LABEL.get(c.get("rule", "full"), "")
|
||||
sup = f",{c['support']}" if c.get("support") else ""
|
||||
parts.append(f"{c['target']}({c['kind']}·{tag}{sup})")
|
||||
cands = "、".join(parts)
|
||||
if not g["common_word"]:
|
||||
warn = ""
|
||||
elif g["candidates"][0].get("rule") == "alias":
|
||||
# Both a common word and a confirmed alias (光辉, 公司→工时). The mapping
|
||||
# is real, so the instruction is to check usage rather than default to
|
||||
# keeping the original.
|
||||
warn = "【此词也是常用词:先判断句中是否确实在指人/机构,是则用全名,否则保留原词】"
|
||||
else:
|
||||
warn = "【注意:此词本身是常用词,除非句子读不通否则保留原词】"
|
||||
sent = re.sub(r"\s+", "", g["sentence"])
|
||||
if len(sent) > 70:
|
||||
idx = sent.find(g["span"])
|
||||
lo = max(0, idx - 26)
|
||||
sent = ("…" if lo else "") + sent[lo:idx + len(g["span"]) + 26] + "…"
|
||||
if g.get("settled") and not g["common_word"]:
|
||||
must.append(f"- 「{g['span']}」= {g['candidates'][0]['target']}"
|
||||
f"|原句: {sent}")
|
||||
else:
|
||||
maybe.append(f"- 「{g['span']}」→ 候选: {cands}{warn}|原句: {sent}")
|
||||
out = []
|
||||
if must:
|
||||
out.append("【A. 已确认映射:Kai 已核对过,出现即按右侧替换,不要标疑似】\n"
|
||||
+ "\n".join(must))
|
||||
if maybe:
|
||||
out.append("【B. 待判断候选:按所在句子自行裁决】\n" + "\n".join(maybe))
|
||||
return "\n\n".join(out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("transcripts", nargs="+")
|
||||
ap.add_argument("--no-hindsight", action="store_true")
|
||||
ap.add_argument("--no-termbank", action="store_true")
|
||||
ap.add_argument("--min-score", type=float, default=0.86)
|
||||
ap.add_argument("--json", action="store_true")
|
||||
a = ap.parse_args()
|
||||
for p in a.transcripts:
|
||||
text = Path(p).read_text(errors="ignore")
|
||||
r = analyze(text, use_hindsight=not a.no_hindsight,
|
||||
use_termbank=not a.no_termbank, min_score=a.min_score)
|
||||
if a.json:
|
||||
print(json.dumps(r, ensure_ascii=False, indent=2))
|
||||
continue
|
||||
print("=" * 78)
|
||||
print(f"{Path(p).parent.name} {r['elapsed']}s pool={r['pool_size']} "
|
||||
f"in_play={len(r['in_play'])} hs={len(r['hindsight_names'])} "
|
||||
f"flags={len(r['flags'])}")
|
||||
print(f"speakers: {'、'.join(r['speakers']) or '(none)'} hubs: {'、'.join(r['hubs'])}")
|
||||
print(render_for_prompt(r["flags"], 25))
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Incremental entity/relation/recency index over curated Obsidian minutes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from . import lexicon
|
||||
from .config import CFG
|
||||
|
||||
FM_KEYS = ("people", "companies", "projects", "project")
|
||||
LINK = re.compile(r"\[\[([^\]|#]+)")
|
||||
DATE = re.compile(r"^date:[ \t]*(\d{4}-\d{2}-\d{2})", re.M)
|
||||
SENTENCE = re.compile(r"(?<=[。!?;\n])")
|
||||
PREDICATES = (
|
||||
"投资", "任职", "负责", "参与", "合作", "对接", "收购", "并购",
|
||||
"创立", "加入", "管理", "支持", "服务", "委托", "出资", "控股",
|
||||
)
|
||||
|
||||
|
||||
def _list_field(text: str, key: str) -> set[str]:
|
||||
m = re.search(rf"^{key}:[ \t]*(.*)$", text, re.M)
|
||||
if not m:
|
||||
return set()
|
||||
return {x.strip() for x in re.findall(r"[^,\[\]\s,、]+", m.group(1))
|
||||
if x.strip()}
|
||||
|
||||
|
||||
def _timestamp(text: str, path: Path) -> float:
|
||||
m = DATE.search(text)
|
||||
if m:
|
||||
try:
|
||||
return datetime.combine(date.fromisoformat(m.group(1)),
|
||||
datetime.min.time()).timestamp()
|
||||
except ValueError:
|
||||
pass
|
||||
m = re.search(r"(\d{4}-\d{2}-\d{2})", path.name)
|
||||
if m:
|
||||
try:
|
||||
return datetime.combine(date.fromisoformat(m.group(1)),
|
||||
datetime.min.time()).timestamp()
|
||||
except ValueError:
|
||||
pass
|
||||
return path.stat().st_mtime
|
||||
|
||||
|
||||
def _signature(path: Path) -> str:
|
||||
st = path.stat()
|
||||
return hashlib.sha256(
|
||||
f"{path}:{st.st_mtime_ns}:{st.st_size}".encode()).hexdigest()[:20]
|
||||
|
||||
|
||||
def _minutes_files() -> list[Path]:
|
||||
root = CFG.vault / "minutes"
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(p for p in root.rglob("*.md")
|
||||
if "_drafts" not in p.parts and not p.name.startswith("_"))
|
||||
|
||||
|
||||
def _ordered(a: str, b: str, kinds: dict[str, str]) -> tuple[str, str]:
|
||||
rank = {"人名": 0, "公司": 1, "项目": 2}
|
||||
ka, kb = rank.get(kinds.get(a, ""), 9), rank.get(kinds.get(b, ""), 9)
|
||||
return (a, b) if (ka, a) <= (kb, b) else (b, a)
|
||||
|
||||
|
||||
def sync(force: bool = False) -> dict:
|
||||
"""Index changed minutes and remove facts belonging to deleted notes."""
|
||||
lexicon.sync_from_vault()
|
||||
con = lexicon.connect()
|
||||
kinds = {r["canonical"]: r["kind"]
|
||||
for r in con.execute("SELECT canonical,kind FROM entity")}
|
||||
known = set(kinds)
|
||||
files = _minutes_files()
|
||||
active_sources = {f"minute:{p.relative_to(CFG.vault)}" for p in files}
|
||||
|
||||
old_sources = {r["source"] for r in con.execute(
|
||||
"SELECT source FROM source_document WHERE source LIKE 'minute:%'")}
|
||||
removed = old_sources - active_sources
|
||||
for source in removed:
|
||||
con.execute("DELETE FROM relation WHERE source=?", (source,))
|
||||
con.execute("DELETE FROM interaction WHERE source=?", (source,))
|
||||
con.execute("DELETE FROM entity_mention WHERE source=?", (source,))
|
||||
con.execute("DELETE FROM source_document WHERE source=?", (source,))
|
||||
|
||||
changed = skipped = entities_n = relations_n = 0
|
||||
now = time.time()
|
||||
for p in files:
|
||||
source = f"minute:{p.relative_to(CFG.vault)}"
|
||||
sig = _signature(p)
|
||||
old = con.execute("SELECT signature FROM source_document WHERE source=?",
|
||||
(source,)).fetchone()
|
||||
if not force and old and old["signature"] == sig:
|
||||
skipped += 1
|
||||
continue
|
||||
text = p.read_text(errors="ignore")
|
||||
typed = {
|
||||
"人名": _list_field(text, "people"),
|
||||
"公司": _list_field(text, "companies"),
|
||||
}
|
||||
con.execute("DELETE FROM entity_mention WHERE source=?", (source,))
|
||||
for kind, names in typed.items():
|
||||
for name in names:
|
||||
con.execute(
|
||||
"INSERT OR REPLACE INTO entity_mention(entity,kind,source,"
|
||||
"occurred_at) VALUES(?,?,?,?)",
|
||||
(name, kind, source, _timestamp(text, p)))
|
||||
entities = set()
|
||||
for key in FM_KEYS:
|
||||
entities |= _list_field(text, key)
|
||||
entities |= {x.strip() for x in LINK.findall(text)}
|
||||
entities &= known
|
||||
occurred = _timestamp(text, p)
|
||||
|
||||
con.execute("DELETE FROM relation WHERE source=?", (source,))
|
||||
con.execute("DELETE FROM interaction WHERE source=?", (source,))
|
||||
for entity in entities:
|
||||
con.execute(
|
||||
"INSERT OR REPLACE INTO interaction(entity,occurred_at,kind,source,"
|
||||
"weight) VALUES(?,?,?,?,?)",
|
||||
(entity, occurred, "meeting", source, 1.0))
|
||||
|
||||
nrel = 0
|
||||
ordered = sorted(entities)
|
||||
for i, a in enumerate(ordered):
|
||||
for b in ordered[i + 1:]:
|
||||
subject, object_ = _ordered(a, b, kinds)
|
||||
con.execute(
|
||||
"INSERT OR REPLACE INTO relation(subject,predicate,object,"
|
||||
"confidence,source,first_seen,last_seen) VALUES(?,?,?,?,?,?,?)",
|
||||
(subject, "同会", object_, 0.35, source, occurred, occurred))
|
||||
nrel += 1
|
||||
|
||||
# A predicate is promoted only when two known linked entities occur in the
|
||||
# same sentence and the relationship word is explicit.
|
||||
for sentence in SENTENCE.split(text):
|
||||
linked = [x.strip() for x in LINK.findall(sentence) if x.strip() in known]
|
||||
if len(linked) < 2:
|
||||
continue
|
||||
predicate = next((x for x in PREDICATES if x in sentence), "")
|
||||
if not predicate:
|
||||
continue
|
||||
for a, b in zip(linked, linked[1:]):
|
||||
if a == b:
|
||||
continue
|
||||
con.execute(
|
||||
"INSERT OR REPLACE INTO relation(subject,predicate,object,"
|
||||
"confidence,source,first_seen,last_seen) VALUES(?,?,?,?,?,?,?)",
|
||||
(a, predicate, b, 0.75, source, occurred, occurred))
|
||||
nrel += 1
|
||||
|
||||
con.execute(
|
||||
"INSERT OR REPLACE INTO source_document(source,signature,processed_at,"
|
||||
"entity_count,relation_count) VALUES(?,?,?,?,?)",
|
||||
(source, sig, now, len(entities), nrel))
|
||||
changed += 1
|
||||
entities_n += len(entities)
|
||||
relations_n += nrel
|
||||
con.commit()
|
||||
con.close()
|
||||
return {"files": len(files), "changed": changed, "skipped": skipped,
|
||||
"removed": len(removed), "entities_seen": entities_n,
|
||||
"relations_written": relations_n}
|
||||
|
||||
|
||||
def context_weights(seeds: set[str], *, half_life_days: float = 45.0,
|
||||
limit: int = 100) -> dict[str, float]:
|
||||
"""Score graph neighbours by relation strength, recency, and frequency."""
|
||||
if not seeds:
|
||||
return {}
|
||||
con = lexicon.connect()
|
||||
marks = ",".join("?" for _ in seeds)
|
||||
rows = con.execute(
|
||||
f"SELECT subject,predicate,object,max(confidence) confidence,"
|
||||
f"max(last_seen) last_seen,count(*) support FROM relation WHERE active=1 "
|
||||
f"AND (subject IN ({marks}) OR object IN ({marks})) "
|
||||
"GROUP BY subject,predicate,object",
|
||||
(*seeds, *seeds)).fetchall()
|
||||
now = time.time()
|
||||
out: dict[str, float] = {s: 12.0 for s in seeds}
|
||||
for r in rows:
|
||||
other = r["object"] if r["subject"] in seeds else r["subject"]
|
||||
age = max(0.0, (now - (r["last_seen"] or now)) / 86400)
|
||||
decay = math.exp(-math.log(2) * age / half_life_days)
|
||||
support = 1.0 + math.log1p(r["support"])
|
||||
out[other] = out.get(other, 0.0) + r["confidence"] * decay * support
|
||||
|
||||
# Direct contact frequency is useful even when a relation has only one edge.
|
||||
if out:
|
||||
names = list(out)
|
||||
marks2 = ",".join("?" for _ in names)
|
||||
interactions = con.execute(
|
||||
f"SELECT entity,count(*) n,max(occurred_at) latest FROM interaction "
|
||||
f"WHERE entity IN ({marks2}) GROUP BY entity", names).fetchall()
|
||||
for r in interactions:
|
||||
age = max(0.0, (now - r["latest"]) / 86400)
|
||||
decay = math.exp(-math.log(2) * age / half_life_days)
|
||||
out[r["entity"]] += decay * math.log1p(r["n"])
|
||||
con.close()
|
||||
return dict(sorted(out.items(), key=lambda x: -x[1])[:limit])
|
||||
|
||||
|
||||
def stats() -> dict:
|
||||
con = lexicon.connect()
|
||||
q = lambda sql: con.execute(sql).fetchone()[0]
|
||||
out = {
|
||||
"documents": q("SELECT count(*) FROM source_document"),
|
||||
"relations": q("SELECT count(*) FROM relation"),
|
||||
"interactions": q("SELECT count(*) FROM interaction"),
|
||||
"projects": q("SELECT count(*) FROM entity WHERE kind='项目'"),
|
||||
}
|
||||
con.close()
|
||||
return out
|
||||
@@ -0,0 +1,526 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic entity / alias / correction lexicon (SQLite).
|
||||
|
||||
Design rule, decided 2026-07-25:
|
||||
|
||||
**Obsidian is a read-only source of truth.** Cards supply canonical names and
|
||||
the `aliases:` / `company:` / `role:` frontmatter. This program pulls from them
|
||||
and never writes back — no `_glossary.md` edits, no card mutation.
|
||||
|
||||
Everything the pipeline *learns* (a confirmed nickname like 老尚=单永强, a
|
||||
recurring ASR misrecognition like 张泰海→张成海) accumulates here instead. Same
|
||||
separation as the voiceprint registry: an independent structured store that
|
||||
grows with use, next to the vault rather than inside it.
|
||||
|
||||
Why SQLite and not Hindsight: alias resolution must be an exact, repeatable
|
||||
key→value lookup. Hindsight stores LLM-extracted memories and recalls them
|
||||
semantically — excellent for *relationships* ("万亚坤 is 洛启's 实控人", which is
|
||||
what disambiguates 老万), useless as a guarantee that 老尚 resolves every single
|
||||
time. The two are complementary and both are used: lexicon decides identity,
|
||||
Hindsight supplies the context that ranks competing identities.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import CFG
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS entity (
|
||||
canonical TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL, -- 人名 / 公司
|
||||
company TEXT DEFAULT '', -- affiliation, for honorific disambiguation
|
||||
role TEXT DEFAULT '',
|
||||
related TEXT DEFAULT '', -- related_people, comma-joined
|
||||
card TEXT DEFAULT '',
|
||||
synced_at REAL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS alias (
|
||||
alias TEXT NOT NULL,
|
||||
canonical TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
origin TEXT NOT NULL, -- card | learned | manual | derived
|
||||
confirmed INTEGER DEFAULT 0, -- 1 = Kai said so explicitly
|
||||
hits INTEGER DEFAULT 0,
|
||||
created_at REAL,
|
||||
note TEXT DEFAULT '',
|
||||
PRIMARY KEY (alias, canonical)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS alias_by_alias ON alias(alias);
|
||||
CREATE TABLE IF NOT EXISTS correction (
|
||||
surface TEXT NOT NULL, -- what the ASR wrote
|
||||
canonical TEXT NOT NULL, -- what it should be
|
||||
n INTEGER DEFAULT 1, -- times confirmed
|
||||
confirmed INTEGER DEFAULT 0,
|
||||
last_seen REAL,
|
||||
note TEXT DEFAULT '',
|
||||
PRIMARY KEY (surface, canonical)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS relation (
|
||||
subject TEXT NOT NULL,
|
||||
predicate TEXT NOT NULL,
|
||||
object TEXT NOT NULL,
|
||||
confidence REAL DEFAULT 0.5,
|
||||
source TEXT DEFAULT '',
|
||||
first_seen REAL,
|
||||
last_seen REAL,
|
||||
active INTEGER DEFAULT 1,
|
||||
PRIMARY KEY (subject, predicate, object, source)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS relation_by_subject ON relation(subject, active, last_seen);
|
||||
CREATE INDEX IF NOT EXISTS relation_by_object ON relation(object, active, last_seen);
|
||||
CREATE TABLE IF NOT EXISTS interaction (
|
||||
entity TEXT NOT NULL,
|
||||
occurred_at REAL NOT NULL,
|
||||
kind TEXT DEFAULT 'mention',
|
||||
source TEXT DEFAULT '',
|
||||
weight REAL DEFAULT 1.0,
|
||||
PRIMARY KEY (entity, occurred_at, kind, source)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS interaction_recent ON interaction(entity, occurred_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS entity_mention (
|
||||
entity TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- 人名 / 公司
|
||||
source TEXT NOT NULL, -- one formal minute; one count per source
|
||||
occurred_at REAL NOT NULL,
|
||||
PRIMARY KEY (entity, kind, source)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS entity_mention_count ON entity_mention(entity,kind);
|
||||
CREATE TABLE IF NOT EXISTS observation (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
surface TEXT NOT NULL,
|
||||
canonical TEXT DEFAULT '',
|
||||
source TEXT DEFAULT '',
|
||||
context TEXT DEFAULT '',
|
||||
confidence REAL DEFAULT 0.0,
|
||||
confirmed INTEGER DEFAULT 0,
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS observation_surface ON observation(surface, created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS phonetic_confusion (
|
||||
heard TEXT NOT NULL,
|
||||
intended TEXT NOT NULL,
|
||||
n INTEGER DEFAULT 1,
|
||||
confirmed INTEGER DEFAULT 0,
|
||||
last_seen REAL,
|
||||
note TEXT DEFAULT '',
|
||||
PRIMARY KEY (heard, intended)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS review_question (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
recording TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
payload TEXT DEFAULT '{}',
|
||||
status TEXT DEFAULT 'pending',
|
||||
answer TEXT DEFAULT '',
|
||||
created_at REAL NOT NULL,
|
||||
answered_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS review_pending ON review_question(status, created_at);
|
||||
CREATE TABLE IF NOT EXISTS source_document (
|
||||
source TEXT PRIMARY KEY,
|
||||
signature TEXT NOT NULL,
|
||||
processed_at REAL NOT NULL,
|
||||
entity_count INTEGER DEFAULT 0,
|
||||
relation_count INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
|
||||
"""
|
||||
|
||||
HAN_FULL = re.compile(r"^[\u4e00-\u9fff]+$")
|
||||
# Anything that looks like a note title or a date is not an entity. This is what
|
||||
# polluted in_play: 25 of 70 slots were minutes filenames, dates and _glossary.
|
||||
JUNK = re.compile(r"^\d{4}-\d{2}|^_|\.md$|[|/#]")
|
||||
|
||||
|
||||
ORG_SUFFIX = ("生物", "医药", "制药", "药业", "基金", "资本", "投资", "科技", "集团",
|
||||
"股份", "公司", "银行", "证券", "医院", "研究院", "实验室", "中心")
|
||||
|
||||
|
||||
def guess_kind(name: str) -> str:
|
||||
"""Entity type for a name with no card. Only used for display labelling."""
|
||||
return "公司" if name.endswith(ORG_SUFFIX) else "人名"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entity:
|
||||
canonical: str
|
||||
kind: str
|
||||
company: str = ""
|
||||
role: str = ""
|
||||
related: tuple = ()
|
||||
|
||||
|
||||
def connect() -> sqlite3.Connection:
|
||||
CFG.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
con = sqlite3.connect(CFG.lexicon_db)
|
||||
con.row_factory = sqlite3.Row
|
||||
con.executescript(SCHEMA)
|
||||
# One-time compatibility migration for the 158 corrections learned before the
|
||||
# personal phonetic-confusion channel existed.
|
||||
con.execute(
|
||||
"INSERT OR IGNORE INTO phonetic_confusion(heard,intended,n,confirmed,"
|
||||
"last_seen,note) SELECT surface,canonical,n,confirmed,last_seen,note "
|
||||
"FROM correction")
|
||||
con.commit()
|
||||
return con
|
||||
|
||||
|
||||
# --------------------------------------------------------------- vault pull
|
||||
def _fm(text: str, key: str) -> str:
|
||||
# `[ \t]*` not `\s*`: \s matches newlines, so a blank `role:` field used to
|
||||
# swallow the next line and report `status: active` as the role.
|
||||
m = re.search(rf"^{key}:[ \t]*(.*)$", text, re.M)
|
||||
return m.group(1).strip().strip("\"'") if m else ""
|
||||
|
||||
|
||||
def _list_field(raw: str) -> list[str]:
|
||||
return [x for x in re.findall(r"[^,\[\]\s,、\"']+", raw or "") if x]
|
||||
|
||||
|
||||
def vault_signature() -> str:
|
||||
import hashlib
|
||||
h = hashlib.sha256()
|
||||
if CFG.glossary.exists():
|
||||
st = CFG.glossary.stat()
|
||||
h.update(f"gl:{st.st_mtime_ns}:{st.st_size}".encode())
|
||||
for sub in ("People", "Companies", "Projects"):
|
||||
d = CFG.vault / sub
|
||||
if not d.exists():
|
||||
continue
|
||||
for f in sorted(d.glob("*.md")):
|
||||
st = f.stat()
|
||||
h.update(f"{f.name}:{st.st_mtime_ns}:{st.st_size}".encode())
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
|
||||
GL_MAP = re.compile(r"^(?:>\s*)?[-*]\s*(.+?)\s*(?:→|⇒|➔|->)\s*(.+)$")
|
||||
GL_ENT = re.compile(r"^[-*]\s*([\u4e00-\u9fff]{2,6})\s*[—–]\s*(.+)$")
|
||||
_PAREN = re.compile(r"[((][^))]*[))]")
|
||||
|
||||
|
||||
def _clean_form(s: str) -> str:
|
||||
return re.sub(r"\*\*|\[\[|\]\]|[`\"'「」“”]", "", s).strip()
|
||||
|
||||
|
||||
def _split_forms(s: str) -> list[str]:
|
||||
"""`胡律 / 胡丽 / 胡博士(跨境资本语境)` -> [胡律, 胡丽, 胡博士]."""
|
||||
s = _PAREN.sub("", s)
|
||||
return [_clean_form(x) for x in re.split(r"[/、,,]|\s+or\s+", s) if _clean_form(x)]
|
||||
|
||||
|
||||
def _split_targets(rhs: str) -> list[tuple[str, str]]:
|
||||
"""Right-hand side -> [(canonical, context_hint)].
|
||||
|
||||
Handles the one-to-many context-dependent rows the glossary explicitly flags,
|
||||
e.g. `许博 → **许俊彦**(白帆生物语境) / **许恒皓**(愚公生物语境)`, which must stay
|
||||
ambiguous: the pipeline offers both and the sentence decides.
|
||||
"""
|
||||
parts = re.split(r"\s*/\s*(?=\*\*)", rhs) if rhs.count("**") > 1 else [rhs]
|
||||
out = []
|
||||
for it in parts:
|
||||
ctx = ";".join(x.strip() for x in re.findall(r"[((]([^))]*)[))]", it))
|
||||
name = _clean_form(_PAREN.sub("", it)).split(";")[0].split(";")[0].strip()
|
||||
name = name.split(",")[0].strip()
|
||||
if name:
|
||||
out.append((name, ctx))
|
||||
return out
|
||||
|
||||
|
||||
def _import_glossary(con: sqlite3.Connection, now: float) -> int:
|
||||
"""Read `minutes/_glossary.md` into the lexicon. The file is never modified.
|
||||
|
||||
It carries ~130 hand-confirmed mappings, several of which are deliberately
|
||||
one-to-many. Importing them means the pipeline stops re-deriving what Kai
|
||||
already settled, while the vault keeps a single human-editable copy.
|
||||
"""
|
||||
gl = CFG.glossary
|
||||
if not gl.exists():
|
||||
return 0
|
||||
n = 0
|
||||
|
||||
def put(alias: str, canonical: str, ctx: str, origin: str):
|
||||
nonlocal n
|
||||
if not (HAN_FULL.match(alias) and HAN_FULL.match(canonical)):
|
||||
return
|
||||
if alias == canonical or not (2 <= len(alias) <= 8) or not (2 <= len(canonical) <= 8):
|
||||
return
|
||||
r = con.execute("SELECT kind FROM entity WHERE canonical=?", (canonical,)).fetchone()
|
||||
kind = r["kind"] if r else guess_kind(canonical)
|
||||
con.execute(
|
||||
"INSERT OR IGNORE INTO alias(alias,canonical,kind,origin,confirmed,"
|
||||
"created_at,note) VALUES(?,?,?,?,1,?,?)",
|
||||
(alias, canonical, kind, origin, now, ctx))
|
||||
con.execute(
|
||||
"INSERT OR IGNORE INTO correction(surface,canonical,n,confirmed,last_seen,note)"
|
||||
" VALUES(?,?,1,1,?,?)", (alias, canonical, now, ctx))
|
||||
n += 1
|
||||
|
||||
for raw in gl.read_text(errors="ignore").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = GL_MAP.match(line)
|
||||
if m:
|
||||
for tgt, ctx in _split_targets(m.group(2)):
|
||||
for form in _split_forms(m.group(1)):
|
||||
put(form, tgt, ctx, "glossary")
|
||||
continue
|
||||
# `- 万亚坤 — 洛启生物实控人(老万;原音 万博/王博/「平台总代理坤」)`
|
||||
m = GL_ENT.match(line)
|
||||
if m:
|
||||
canon, desc = m.group(1), m.group(2)
|
||||
for grp in re.findall(r"[((]([^))]*)[))]", desc):
|
||||
for seg in re.split(r"[;;]", grp):
|
||||
seg = re.sub(r"^\s*(原音|又称|简称|别名)\s*", "", seg.strip())
|
||||
for form in _split_forms(seg):
|
||||
put(form, canon, "", "glossary")
|
||||
return n
|
||||
|
||||
|
||||
def sync_from_vault(force: bool = False) -> dict:
|
||||
"""Pull entities + card aliases from Obsidian. Read-only on the vault side.
|
||||
|
||||
Learned rows are preserved: only `origin='card'` aliases are refreshed, so a
|
||||
nickname Kai confirmed once is never lost when a card is edited.
|
||||
"""
|
||||
con = connect()
|
||||
sig = vault_signature()
|
||||
cur = con.execute("SELECT v FROM meta WHERE k='vault_sig'").fetchone()
|
||||
if not force and cur and cur["v"] == sig:
|
||||
con.close()
|
||||
return {"skipped": True, "sig": sig}
|
||||
|
||||
now = time.time()
|
||||
n_ent = n_alias = 0
|
||||
con.execute("DELETE FROM alias WHERE origin IN ('card','glossary')")
|
||||
con.execute("DELETE FROM entity")
|
||||
con.execute("DELETE FROM relation WHERE source LIKE 'card:%'")
|
||||
for sub, kind in (("People", "人名"), ("Companies", "公司"), ("Projects", "项目")):
|
||||
d = CFG.vault / sub
|
||||
if not d.exists():
|
||||
continue
|
||||
for f in sorted(d.glob("*.md")):
|
||||
name = f.stem
|
||||
if JUNK.search(name):
|
||||
continue
|
||||
if kind != "项目" and not HAN_FULL.match(name):
|
||||
continue
|
||||
if kind == "项目" and not (2 <= len(name) <= 80):
|
||||
continue
|
||||
txt = f.read_text(errors="ignore")
|
||||
head = txt[:1200]
|
||||
company = _fm(head, "company")
|
||||
related = _list_field(_fm(head, "related_people"))
|
||||
con.execute(
|
||||
"INSERT OR REPLACE INTO entity(canonical,kind,company,role,related,"
|
||||
"card,synced_at) VALUES(?,?,?,?,?,?,?)",
|
||||
(name, kind, company, _fm(head, "role"),
|
||||
",".join(related), str(f), now))
|
||||
if company:
|
||||
con.execute(
|
||||
"INSERT OR REPLACE INTO relation(subject,predicate,object,"
|
||||
"confidence,source,first_seen,last_seen) VALUES(?,?,?,?,?,?,?)",
|
||||
(name, "任职于", company, 1.0, f"card:{f}", now, now))
|
||||
for other in related:
|
||||
con.execute(
|
||||
"INSERT OR REPLACE INTO relation(subject,predicate,object,"
|
||||
"confidence,source,first_seen,last_seen) VALUES(?,?,?,?,?,?,?)",
|
||||
(name, "相关", other, 0.9, f"card:{f}", now, now))
|
||||
n_ent += 1
|
||||
for a in _list_field(_fm(head, "aliases")):
|
||||
if a == name or not HAN_FULL.match(a) or len(a) < 2:
|
||||
continue
|
||||
con.execute(
|
||||
"INSERT OR IGNORE INTO alias(alias,canonical,kind,origin,confirmed,"
|
||||
"created_at) VALUES(?,?,?,'card',1,?)", (a, name, kind, now))
|
||||
n_alias += 1
|
||||
# The vault glossary is *imported*, never edited. It holds ~130 corrections Kai
|
||||
# already confirmed by hand; they become exact-match bindings here so the
|
||||
# pipeline stops re-deriving them, and the file itself stays untouched.
|
||||
n_gl = _import_glossary(con, now)
|
||||
|
||||
con.execute("INSERT OR REPLACE INTO meta(k,v) VALUES('vault_sig',?)", (sig,))
|
||||
con.execute("INSERT OR REPLACE INTO meta(k,v) VALUES('synced_at',?)", (str(now),))
|
||||
con.commit()
|
||||
con.close()
|
||||
return {"skipped": False, "entities": n_ent, "card_aliases": n_alias,
|
||||
"glossary": n_gl, "sig": sig}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ reading
|
||||
def entities() -> dict[str, Entity]:
|
||||
con = connect()
|
||||
out = {r["canonical"]: Entity(
|
||||
r["canonical"], r["kind"], r["company"] or "", r["role"] or "",
|
||||
tuple(x for x in (r["related"] or "").split(",") if x))
|
||||
for r in con.execute("SELECT * FROM entity")}
|
||||
con.close()
|
||||
return out
|
||||
|
||||
|
||||
def alias_map() -> dict[str, list[tuple[str, str, str, int]]]:
|
||||
"""alias -> [(canonical, kind, origin, confirmed)]. May be ambiguous."""
|
||||
con = connect()
|
||||
out: dict[str, list] = {}
|
||||
for r in con.execute("SELECT * FROM alias ORDER BY confirmed DESC, hits DESC"):
|
||||
out.setdefault(r["alias"], []).append(
|
||||
(r["canonical"], r["kind"], r["origin"], r["confirmed"]))
|
||||
con.close()
|
||||
return out
|
||||
|
||||
|
||||
def corrections() -> dict[str, list[tuple[str, int]]]:
|
||||
con = connect()
|
||||
out: dict[str, list] = {}
|
||||
for r in con.execute("SELECT * FROM correction ORDER BY n DESC"):
|
||||
out.setdefault(r["surface"], []).append((r["canonical"], r["n"]))
|
||||
con.close()
|
||||
return out
|
||||
|
||||
|
||||
def confusion_map() -> dict[str, list[tuple[str, int, int]]]:
|
||||
"""Observed ASR surface -> canonical, count, confirmation.
|
||||
|
||||
Older databases predate `phonetic_confusion`, so confirmed correction rows are
|
||||
included as a compatibility source without a destructive migration.
|
||||
"""
|
||||
con = connect()
|
||||
out: dict[str, list[tuple[str, int, int]]] = {}
|
||||
for r in con.execute(
|
||||
"SELECT surface AS heard,canonical AS intended,n,confirmed "
|
||||
"FROM correction UNION ALL "
|
||||
"SELECT heard,intended,n,confirmed FROM phonetic_confusion"):
|
||||
item = (r["intended"], r["n"], r["confirmed"])
|
||||
if item not in out.setdefault(r["heard"], []):
|
||||
out[r["heard"]].append(item)
|
||||
con.close()
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ writing
|
||||
def learn_alias(alias: str, canonical: str, *, kind: str = "", origin: str = "learned",
|
||||
confirmed: int = 1, note: str = "") -> None:
|
||||
con = connect()
|
||||
if not kind:
|
||||
r = con.execute("SELECT kind FROM entity WHERE canonical=?", (canonical,)).fetchone()
|
||||
kind = r["kind"] if r else guess_kind(canonical)
|
||||
con.execute(
|
||||
"INSERT INTO alias(alias,canonical,kind,origin,confirmed,created_at,note)"
|
||||
" VALUES(?,?,?,?,?,?,?) ON CONFLICT(alias,canonical) DO UPDATE SET"
|
||||
" confirmed=max(confirmed,excluded.confirmed), origin=excluded.origin,"
|
||||
" note=excluded.note",
|
||||
(alias, canonical, kind, origin, confirmed, time.time(), note))
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def learn_correction(surface: str, canonical: str, *, confirmed: int = 1,
|
||||
note: str = "") -> None:
|
||||
con = connect()
|
||||
con.execute(
|
||||
"INSERT INTO correction(surface,canonical,n,confirmed,last_seen,note)"
|
||||
" VALUES(?,?,1,?,?,?) ON CONFLICT(surface,canonical) DO UPDATE SET"
|
||||
" n=n+1, confirmed=max(confirmed,excluded.confirmed), last_seen=excluded.last_seen",
|
||||
(surface, canonical, confirmed, time.time(), note))
|
||||
con.execute(
|
||||
"INSERT INTO phonetic_confusion(heard,intended,n,confirmed,last_seen,note)"
|
||||
" VALUES(?,?,1,?,?,?) ON CONFLICT(heard,intended) DO UPDATE SET"
|
||||
" n=n+1,confirmed=max(confirmed,excluded.confirmed),"
|
||||
" last_seen=excluded.last_seen,note=excluded.note",
|
||||
(surface, canonical, confirmed, time.time(), note))
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def record_observation(surface: str, canonical: str = "", *, source: str = "",
|
||||
context: str = "", confidence: float = 0.0,
|
||||
confirmed: int = 0) -> int:
|
||||
con = connect()
|
||||
cur = con.execute(
|
||||
"INSERT INTO observation(surface,canonical,source,context,confidence,"
|
||||
"confirmed,created_at) VALUES(?,?,?,?,?,?,?)",
|
||||
(surface, canonical, source, context[:1000], confidence, confirmed, time.time()))
|
||||
con.commit()
|
||||
row_id = int(cur.lastrowid)
|
||||
con.close()
|
||||
return row_id
|
||||
|
||||
|
||||
def upsert_relation(subject: str, predicate: str, object_: str, *,
|
||||
confidence: float = 0.5, source: str = "") -> None:
|
||||
now = time.time()
|
||||
con = connect()
|
||||
con.execute(
|
||||
"INSERT INTO relation(subject,predicate,object,confidence,source,first_seen,"
|
||||
"last_seen) VALUES(?,?,?,?,?,?,?) ON CONFLICT(subject,predicate,object,source)"
|
||||
" DO UPDATE SET confidence=max(confidence,excluded.confidence),"
|
||||
" last_seen=excluded.last_seen,active=1",
|
||||
(subject, predicate, object_, confidence, source, now, now))
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def record_interaction(entity: str, *, occurred_at: float | None = None,
|
||||
kind: str = "mention", source: str = "",
|
||||
weight: float = 1.0) -> None:
|
||||
con = connect()
|
||||
con.execute(
|
||||
"INSERT OR IGNORE INTO interaction(entity,occurred_at,kind,source,weight)"
|
||||
" VALUES(?,?,?,?,?)",
|
||||
(entity, occurred_at or time.time(), kind, source, weight))
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def relationship_card(names: set[str], *, limit: int = 30) -> list[str]:
|
||||
"""Return compact, recent relation facts for other applications and ASR."""
|
||||
if not names:
|
||||
return []
|
||||
con = connect()
|
||||
marks = ",".join("?" for _ in names)
|
||||
rows = con.execute(
|
||||
f"SELECT subject,predicate,object,max(confidence) confidence,"
|
||||
f"max(last_seen) last_seen,count(*) support FROM relation "
|
||||
f"WHERE active=1 AND (subject IN ({marks}) OR object IN ({marks})) "
|
||||
"GROUP BY subject,predicate,object "
|
||||
"ORDER BY confidence DESC,last_seen DESC,support DESC LIMIT ?",
|
||||
(*names, *names, limit)).fetchall()
|
||||
con.close()
|
||||
return [f"{r['subject']} {r['predicate']} {r['object']}"
|
||||
f"(置信度 {r['confidence']:.2f},支持 {r['support']} 次)" for r in rows]
|
||||
|
||||
|
||||
def bump_hits(pairs) -> None:
|
||||
if not pairs:
|
||||
return
|
||||
con = connect()
|
||||
con.executemany("UPDATE alias SET hits=hits+1 WHERE alias=? AND canonical=?",
|
||||
list(pairs))
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def stats() -> dict:
|
||||
con = connect()
|
||||
q = lambda s: con.execute(s).fetchone()[0]
|
||||
d = {
|
||||
"entities": q("SELECT count(*) FROM entity"),
|
||||
"people": q("SELECT count(*) FROM entity WHERE kind='人名'"),
|
||||
"companies": q("SELECT count(*) FROM entity WHERE kind='公司'"),
|
||||
"with_company": q("SELECT count(*) FROM entity WHERE company<>''"),
|
||||
"with_related": q("SELECT count(*) FROM entity WHERE related<>''"),
|
||||
"aliases": q("SELECT count(*) FROM alias"),
|
||||
"aliases_card": q("SELECT count(*) FROM alias WHERE origin='card'"),
|
||||
"aliases_learned": q("SELECT count(*) FROM alias WHERE origin<>'card'"),
|
||||
"corrections": q("SELECT count(*) FROM correction"),
|
||||
"db": str(CFG.lexicon_db),
|
||||
}
|
||||
r = con.execute("SELECT v FROM meta WHERE k='synced_at'").fetchone()
|
||||
d["synced_at"] = r["v"] if r else ""
|
||||
con.close()
|
||||
return d
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HTTPS client for the model provider. No agent CLI, no SDK.
|
||||
|
||||
Deliberately built on urllib so the program has zero third-party HTTP
|
||||
dependencies: it must keep working inside a bare systemd unit years from now.
|
||||
|
||||
Behaviours encoded from measurement rather than guesswork:
|
||||
* concurrency 6 (HTTP 429 appears at ~12)
|
||||
* HTTP 402 "Payment Required" is transient on ZenMux — credit seems reserved
|
||||
against max_tokens, so a burst of large-budget calls can trip it and then
|
||||
succeed moments later; it gets a much longer backoff than a rate limit
|
||||
* `temperature` is rejected outright by claude-sonnet-5 / opus-5 but accepted by
|
||||
haiku-4.5, sonnet-4.6, gemini and qwen, so it cannot be inferred from the
|
||||
vendor prefix; the client self-heals on the 400 and remembers
|
||||
* an empty completion (reasoning models burning the whole budget on hidden
|
||||
thoughts) is retried rather than silently propagated as ""
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .config import CFG
|
||||
from .log import log
|
||||
|
||||
PRICES = {
|
||||
"google/gemini-3.5-flash-lite": (0.3, 2.5),
|
||||
"google/gemini-3.5-flash": (1.5, 9.0),
|
||||
"anthropic/claude-haiku-4.5": (1.0, 5.0),
|
||||
"anthropic/claude-sonnet-5": (2.0, 10.0),
|
||||
"anthropic/claude-sonnet-4.6": (3.0, 15.0),
|
||||
"anthropic/claude-opus-5": (5.0, 25.0),
|
||||
"qwen/qwen3.7-plus": (0.1373076, 0.5492304),
|
||||
}
|
||||
|
||||
_sem = threading.BoundedSemaphore(CFG.concurrency)
|
||||
_NO_TEMPERATURE: set[str] = {"anthropic/claude-sonnet-5", "anthropic/claude-opus-5"}
|
||||
_lock = threading.Lock()
|
||||
_key: list[str] = []
|
||||
|
||||
|
||||
def _auth() -> str:
|
||||
with _lock:
|
||||
if not _key:
|
||||
_key.append(CFG.api_key())
|
||||
return _key[0]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Usage:
|
||||
calls: int = 0
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
retries: int = 0
|
||||
failures: int = 0
|
||||
cost_usd: float = 0.0
|
||||
latencies: list[float] = field(default_factory=list)
|
||||
_lk: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||
|
||||
def add(self, model, pt, ct, latency, retries=0):
|
||||
with self._lk:
|
||||
self.calls += 1
|
||||
self.prompt_tokens += pt
|
||||
self.completion_tokens += ct
|
||||
self.retries += retries
|
||||
if latency:
|
||||
self.latencies.append(latency)
|
||||
pin, pout = PRICES.get(model, (0.0, 0.0))
|
||||
self.cost_usd += pt / 1e6 * pin + ct / 1e6 * pout
|
||||
|
||||
def summary(self) -> dict:
|
||||
lat = sorted(self.latencies)
|
||||
return {
|
||||
"calls": self.calls,
|
||||
"prompt_tokens": self.prompt_tokens,
|
||||
"completion_tokens": self.completion_tokens,
|
||||
"cost_usd": round(self.cost_usd, 4),
|
||||
"retries": self.retries,
|
||||
"failures": self.failures,
|
||||
"latency_avg": round(sum(lat) / len(lat), 2) if lat else 0,
|
||||
"latency_max": round(lat[-1], 2) if lat else 0,
|
||||
}
|
||||
|
||||
|
||||
def _cache_path(model: str, payload: str):
|
||||
h = hashlib.sha256(f"{model}\x00{payload}".encode()).hexdigest()[:32]
|
||||
d = CFG.cache_dir / "llm" / model.replace("/", "_")
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d / f"{h}.json"
|
||||
|
||||
|
||||
def chat(model: str, prompt: str, *, system: str | None = None,
|
||||
max_tokens: int = 8000, temperature: float = 0.0,
|
||||
usage: Usage | None = None) -> str:
|
||||
msgs = ([{"role": "system", "content": system}] if system else []) + \
|
||||
[{"role": "user", "content": prompt}]
|
||||
|
||||
def payload_of() -> str:
|
||||
body = {"model": model, "messages": msgs, "max_tokens": max_tokens}
|
||||
if model not in _NO_TEMPERATURE:
|
||||
body["temperature"] = temperature
|
||||
return json.dumps(body, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
payload = payload_of()
|
||||
cpath = _cache_path(model, payload) if CFG.cache_llm else None
|
||||
if cpath is not None and cpath.exists():
|
||||
try:
|
||||
c = json.loads(cpath.read_text())
|
||||
if usage:
|
||||
usage.add(model, 0, 0, 0)
|
||||
return c["text"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
retries = 0
|
||||
last = None
|
||||
for attempt in range(CFG.max_retries):
|
||||
with _sem:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
CFG.endpoint, data=payload.encode(),
|
||||
headers={"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {_auth()}"})
|
||||
t0 = time.time()
|
||||
with urllib.request.urlopen(req, timeout=CFG.http_timeout) as r:
|
||||
d = json.loads(r.read())
|
||||
latency = time.time() - t0
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = ""
|
||||
try:
|
||||
detail = e.read().decode("utf-8", "ignore")
|
||||
except Exception:
|
||||
pass
|
||||
last = f"HTTP{e.code}: {detail[:200]}"
|
||||
if e.code == 400 and "temperature" in detail:
|
||||
with _lock:
|
||||
_NO_TEMPERATURE.add(model)
|
||||
payload = payload_of()
|
||||
if CFG.cache_llm:
|
||||
cpath = _cache_path(model, payload)
|
||||
retries += 1
|
||||
continue
|
||||
if e.code not in (402, 408, 429, 500, 502, 503, 504):
|
||||
if usage:
|
||||
usage.failures += 1
|
||||
raise RuntimeError(f"{model} {last}") from e
|
||||
retries += 1
|
||||
delay = (min(20 * (attempt + 1), 120) if e.code == 402
|
||||
else min(2 ** attempt + random.uniform(0, 1.5), 45))
|
||||
log(f"llm retry {attempt+1}/{CFG.max_retries} {model} {last} "
|
||||
f"sleep {delay:.0f}s", level="WARN")
|
||||
time.sleep(delay)
|
||||
continue
|
||||
except Exception as e: # network / SSL / timeout
|
||||
last = repr(e)
|
||||
retries += 1
|
||||
time.sleep(min(2 ** attempt + random.uniform(0, 1.5), 45))
|
||||
continue
|
||||
|
||||
text = (d["choices"][0]["message"].get("content") or "").strip()
|
||||
u = d.get("usage", {})
|
||||
pt, ct = u.get("prompt_tokens", 0), u.get("completion_tokens", 0)
|
||||
if not text:
|
||||
last = "empty completion"
|
||||
retries += 1
|
||||
time.sleep(1.5 * (attempt + 1))
|
||||
continue
|
||||
if cpath is not None:
|
||||
cpath.write_text(json.dumps({"text": text}, ensure_ascii=False))
|
||||
if usage:
|
||||
usage.add(model, pt, ct, latency, retries=retries)
|
||||
return text
|
||||
|
||||
if usage:
|
||||
usage.failures += 1
|
||||
raise RuntimeError(f"{model} failed after {CFG.max_retries} attempts: {last}")
|
||||
|
||||
|
||||
def chat_many(model: str, prompts: list[str], **kw) -> list[str]:
|
||||
"""Parallel map preserving order. Concurrency is bounded by the module
|
||||
semaphore, so handing it an 87-chunk transcript is safe."""
|
||||
import concurrent.futures as cf
|
||||
|
||||
out: list[str] = [""] * len(prompts)
|
||||
errors: list[tuple[int, BaseException]] = []
|
||||
with cf.ThreadPoolExecutor(max_workers=CFG.concurrency) as ex:
|
||||
futs = {ex.submit(chat, model, p, **kw): i for i, p in enumerate(prompts)}
|
||||
for f in cf.as_completed(futs):
|
||||
i = futs[f]
|
||||
try:
|
||||
out[i] = f.result()
|
||||
except BaseException as e: # noqa: BLE001 - recorded, decided by caller
|
||||
errors.append((i, e))
|
||||
if errors:
|
||||
# Partial failure must not silently yield empty chunks: the caller falls
|
||||
# back to the original text for these indices, which the gate then flags.
|
||||
for i, e in errors:
|
||||
log(f"chunk {i} failed: {e}", level="ERROR")
|
||||
raise PartialFailure(out, [i for i, _ in errors])
|
||||
return out
|
||||
|
||||
|
||||
class PartialFailure(Exception):
|
||||
def __init__(self, outputs: list[str], failed: list[int]):
|
||||
super().__init__(f"{len(failed)} of {len(outputs)} chunks failed")
|
||||
self.outputs = outputs
|
||||
self.failed = failed
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Logging to both stderr and the shared log file.
|
||||
|
||||
Kept trivial and dependency-free on purpose: systemd captures stderr, while the
|
||||
file gives the same `[timestamp] message` shape the legacy shell pipeline wrote,
|
||||
so existing habits (`tail -f OBSIDIAN/logs/…`) keep working.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from .config import CFG
|
||||
|
||||
_lk = threading.Lock()
|
||||
|
||||
|
||||
def log(msg: str, *, level: str = "INFO") -> None:
|
||||
line = f"[{time.strftime('%F %T %z')}] {level}: {msg}"
|
||||
with _lk:
|
||||
print(line, file=sys.stderr, flush=True)
|
||||
try:
|
||||
CFG.log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with CFG.log_file.open("a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
except Exception:
|
||||
pass # never let logging break the pipeline
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Conservative Chinese-number classification and normalization."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
DIGIT = {
|
||||
"零": 0, "〇": 0, "一": 1, "幺": 1, "二": 2, "两": 2, "三": 3,
|
||||
"四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9,
|
||||
}
|
||||
SMALL = {"十": 10, "百": 100, "千": 1000}
|
||||
BIG = {"万": 10_000, "亿": 100_000_000}
|
||||
CN = "零〇一二两三四五六七八九十百千万亿幺"
|
||||
CN_RE = rf"[{CN}]+"
|
||||
PURE_DIGITS = set(DIGIT)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Decision:
|
||||
raw: str
|
||||
normalized: str
|
||||
kind: str
|
||||
confidence: float
|
||||
start: int
|
||||
end: int
|
||||
context: str
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def chinese_to_int(raw: str) -> int | None:
|
||||
raw = raw.replace("两", "二")
|
||||
if not raw or any(c not in DIGIT and c not in SMALL and c not in BIG for c in raw):
|
||||
return None
|
||||
if set(raw) <= PURE_DIGITS:
|
||||
return int("".join(str(DIGIT[c]) for c in raw))
|
||||
# "两万五" is conventionally ambiguous between 20,005 and 25,000.
|
||||
if any(c in BIG for c in raw) and raw[-1] in DIGIT:
|
||||
return None
|
||||
total = section = number = 0
|
||||
for c in raw:
|
||||
if c in DIGIT:
|
||||
number = DIGIT[c]
|
||||
elif c in SMALL:
|
||||
section += (number or 1) * SMALL[c]
|
||||
number = 0
|
||||
else:
|
||||
section += number
|
||||
total += (section or 1) * BIG[c]
|
||||
section = number = 0
|
||||
return total + section + number
|
||||
|
||||
|
||||
def _ctx(text: str, start: int, end: int, width: int = 18) -> str:
|
||||
return text[max(0, start - width):min(len(text), end + width)]
|
||||
|
||||
|
||||
def _add(found: list[Decision], text: str, start: int, end: int,
|
||||
normalized: str, kind: str, confidence: float):
|
||||
raw = text[start:end]
|
||||
if raw == normalized:
|
||||
return
|
||||
found.append(Decision(raw, normalized, kind, confidence, start, end,
|
||||
_ctx(text, start, end)))
|
||||
|
||||
|
||||
def _overlaps(a: Decision, accepted: list[Decision]) -> bool:
|
||||
return any(a.start < b.end and b.start < a.end for b in accepted)
|
||||
|
||||
|
||||
def _money_text(raw: str, currency: str) -> str | None:
|
||||
if raw and raw[-1] in BIG:
|
||||
base = chinese_to_int(raw[:-1])
|
||||
return f"{base}{raw[-1]}{currency}" if base is not None else None
|
||||
value = chinese_to_int(raw)
|
||||
return f"{value}{currency}" if value is not None else None
|
||||
|
||||
|
||||
def normalize(text: str) -> tuple[str, list[dict]]:
|
||||
"""Normalize only values whose semantic type is visible in nearby syntax."""
|
||||
found: list[Decision] = []
|
||||
|
||||
# Percentages.
|
||||
for m in re.finditer(rf"百分之(?P<n>{CN_RE})", text):
|
||||
value = chinese_to_int(m.group("n"))
|
||||
if value is not None:
|
||||
_add(found, text, m.start(), m.end(), f"{value}%", "percentage", 0.99)
|
||||
|
||||
# Full dates and standalone day-of-month references.
|
||||
for m in re.finditer(
|
||||
rf"(?:(?P<y>{CN_RE})年)?(?P<mo>{CN_RE})月(?P<d>{CN_RE})(?P<u>日|号)", text):
|
||||
y = chinese_to_int(m.group("y")) if m.group("y") else None
|
||||
mo, d = chinese_to_int(m.group("mo")), chinese_to_int(m.group("d"))
|
||||
if mo is not None and d is not None and 1 <= mo <= 12 and 1 <= d <= 31:
|
||||
norm = f"{y}年" if y is not None else ""
|
||||
norm += f"{mo}月{d}{m.group('u')}"
|
||||
_add(found, text, m.start(), m.end(), norm, "date", 0.98)
|
||||
for m in re.finditer(rf"(?P<n>{CN_RE})(?P<u>日|号)", text):
|
||||
value = chinese_to_int(m.group("n"))
|
||||
if value is not None and 1 <= value <= 31:
|
||||
_add(found, text, m.start(), m.end(), f"{value}{m.group('u')}",
|
||||
"day", 0.93)
|
||||
|
||||
# Money: preserve the stated currency and avoid silently inventing one.
|
||||
for m in re.finditer(
|
||||
rf"(?P<n>{CN_RE})(?P<u>元|块钱|人民币|美元|美金|港币)", text):
|
||||
norm = _money_text(m.group("n"), m.group("u"))
|
||||
if norm is not None:
|
||||
_add(found, text, m.start(), m.end(), norm,
|
||||
"money", 0.98)
|
||||
for m in re.finditer(rf"(?P<n>[一二两三四五六七八九十百千万]+)个(?P<u>亿|万)", text):
|
||||
value = chinese_to_int(m.group("n"))
|
||||
if value is not None and re.search(r"投|融|估值|收入|销售|利润|资金|金额",
|
||||
_ctx(text, m.start(), m.end())):
|
||||
_add(found, text, m.start(), m.end(), f"{value}{m.group('u')}元",
|
||||
"money", 0.90)
|
||||
for m in re.finditer(
|
||||
rf"(?P<n>[一二两三四五六七八九十百千]+)(?P<u>万|亿)", text):
|
||||
context = _ctx(text, m.start(), m.end())
|
||||
value = chinese_to_int(m.group("n"))
|
||||
if value is not None and re.search(
|
||||
r"投|融|估值|收入|销售|利润|资金|金额|打款|退出|回购|报价", context):
|
||||
_add(found, text, m.start(), m.end(),
|
||||
f"{value}{m.group('u')}元", "money", 0.91)
|
||||
|
||||
# Quantities with an explicit unit.
|
||||
units = "个月|小时|分钟|公斤|毫克|毫升|人|家|份|批|次|台|套|件|天|年|吨|克|升|例|名"
|
||||
for m in re.finditer(rf"(?P<n>{CN_RE})(?P<u>{units})", text):
|
||||
value = chinese_to_int(m.group("n"))
|
||||
if value is not None:
|
||||
_add(found, text, m.start(), m.end(), f"{value}{m.group('u')}",
|
||||
"quantity", 0.96)
|
||||
|
||||
# Codes are spoken digit-by-digit. 幺 is a particularly strong signal.
|
||||
for m in re.finditer(
|
||||
rf"(?<![{CN}])(?P<n>[零〇一二两三四五六七八九幺]{{2,}})(?![{CN}])",
|
||||
text):
|
||||
raw = m.group("n")
|
||||
context = _ctx(text, m.start(), m.end())
|
||||
code_context = ("幺" in raw or
|
||||
re.search(r"项目|编号|代号|批号|工单|报告|BF|DF|CF|MFG",
|
||||
context, re.I))
|
||||
if code_context:
|
||||
value = "".join(str(DIGIT[c]) for c in raw)
|
||||
_add(found, text, m.start(), m.end(), value, "code", 0.97)
|
||||
|
||||
# Highest-confidence, longest non-overlapping decisions win.
|
||||
accepted: list[Decision] = []
|
||||
for d in sorted(found, key=lambda x: (-x.confidence, -(x.end - x.start), x.start)):
|
||||
if not _overlaps(d, accepted):
|
||||
accepted.append(d)
|
||||
|
||||
out = text
|
||||
for d in sorted(accepted, key=lambda x: x.start, reverse=True):
|
||||
out = out[:d.start] + d.normalized + out[d.end:]
|
||||
|
||||
# Preserve unresolved numeric speech as an explicit structured observation.
|
||||
uncertain: list[Decision] = []
|
||||
occupied = accepted[:]
|
||||
for m in re.finditer(CN_RE, text):
|
||||
if len(m.group()) < 2:
|
||||
continue
|
||||
d = Decision(m.group(), m.group(), "unknown", 0.30, m.start(), m.end(),
|
||||
_ctx(text, m.start(), m.end()))
|
||||
if not _overlaps(d, occupied):
|
||||
uncertain.append(d)
|
||||
all_decisions = sorted(accepted + uncertain, key=lambda x: x.start)
|
||||
return out, [d.as_dict() for d in all_decisions]
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prompt templates for the staged pipeline.
|
||||
|
||||
Kept in one module so wording changes are reviewable in isolation — prompt text is
|
||||
the most-edited and least-typechecked part of the system.
|
||||
"""
|
||||
|
||||
CLEAN_SYS = (
|
||||
"你只做会议转写的去口语化和断句,不做事实判断、术语纠错或总结。"
|
||||
)
|
||||
|
||||
CLEAN_RULES = """任务:对给定片段做「去口语化」。
|
||||
|
||||
只做三件事:
|
||||
1. 删除无意义语气词、结巴和紧邻的机械重复。
|
||||
2. 补标点,把明显破碎的口语整理成可读句子。
|
||||
3. 保留原有说话人分段。
|
||||
|
||||
不要总结,不要纠正人名、术语或数字,不要增加解释。听不懂的词原样保留。
|
||||
数字、英文、项目代号、【说话人】和 `[…重复噪声已折叠:…]` 必须保留。
|
||||
只输出清洗后的正文。"""
|
||||
|
||||
NORMALIZE_SYS = (
|
||||
"你是会议转写的实体、术语和数字规范化裁决器。只处理给出的候选,不做去口语化或总结。"
|
||||
)
|
||||
|
||||
NORMALIZE_RULES = """任务:在清洗文本中裁决候选词并作最小修改。
|
||||
|
||||
- A 组映射已经由程序执行,不要撤销。
|
||||
- B 组只有在整句语义、人物/公司/项目关系和近期语境共同支持时才替换。
|
||||
- 多个候选无法区分时保留原词,紧跟 `[?候选A/候选B]`。
|
||||
- 中文数字仅在类型和单位明确时转成阿拉伯数字;项目编号逐字转换,如“四幺八二”→“4182”。
|
||||
- 数值、单位或身份不确定时保留原文并加 `[?…]`,不得猜测。
|
||||
- 不增加候选清单和背景资料中没有的事实。
|
||||
|
||||
只输出规范化后的正文,保留全部【说话人】标签。"""
|
||||
|
||||
EVIDENCE_SYS = "你从一段会议转写中提取可追溯的事实卡片,不撰写纪要。"
|
||||
|
||||
EVIDENCE_PROMPT = """从下面片段提取值得进入会议草稿的关键信息。
|
||||
|
||||
保留:结论、决定、行动项、负责人、关键数字、争议、未决问题、重要关系和判断依据。
|
||||
省略:寒暄、重复表态、无意义细节。
|
||||
不得补充背景事实,不确定的主体或数字必须标“待确认”。
|
||||
每条都带片段编号 `{chunk_id}`,格式:
|
||||
- [片段 {chunk_id}] 类型|主体|事实或线索|置信度:明确/较可信/待确认
|
||||
|
||||
片段:
|
||||
{body}"""
|
||||
|
||||
JUDGE_RULES = """附加任务:术语/人名校正裁决。
|
||||
|
||||
下面给出脚本按「发音相近 + 本场语境」自动圈出的**候选**(只是嫌疑,未必对)。
|
||||
每个候选都带来源说明,括号里的含义:
|
||||
- `已确认别名`:Kai 已经确认过的固定叫法或已知误识别映射。**证据最强**,若句子读得通就应当替换。
|
||||
- `机构简称`/`去姓称名`/`称谓`:口语里的简称形式(如「迈威」指迈威生物、「光辉」指李光辉、
|
||||
「老万」「万总」指姓万的人)。**这类是正常口语,不是错字**——判定为同一实体后直接用全名。
|
||||
- `一字之差`/`同篇另一写法`:同一实体在本篇中的其他写法,通常是转写漂移,应统一。
|
||||
- `X(本次在谈)`:该候选所属公司正是本次录音讨论的对象,是**强支持证据**。
|
||||
- `X(近期相关)`:该公司近期与本人有往来,是弱支持证据。
|
||||
|
||||
候选分两组:
|
||||
- **A 组「已确认映射」**:Kai 已人工核对的固定对应。只要句中该词确实指人/机构,**必须替换**,
|
||||
不要保留原写法、不要加「疑似」、不要标 `[?…]`。这类是 ASR 的固定误识别或口语简称,
|
||||
不是需要你判断的疑点。
|
||||
- **B 组「待判断候选」**:只是嫌疑,按下面规则裁决。
|
||||
|
||||
逐条按**它所在的整句**判断:
|
||||
- 如果原词在句中读得通(是常用词、副词、动宾结构),**保留原词**,不要改。
|
||||
- 候选带「已确认别名」或带「(本次在谈)」支持、且句中指的确实是人/机构时,**应当替换为全名**,
|
||||
不要因为"转写没明说"而回避——简称就是口语常态。
|
||||
- 同一候选有多个可能人物时(例如「徐总」可能是三个人),**不要硬选**:保留原词并标注
|
||||
`[?候选A/候选B]`,把判断留给后续环节。
|
||||
- 其余拿不准的:保留原词,并在该词后紧跟标注 `[?候选词]`(例如 `元烨[?原液]`)。
|
||||
- 标注了「此词本身是常用词」的,默认保留原词,除非句子完全读不通。
|
||||
|
||||
候选清单:
|
||||
{candidates}"""
|
||||
|
||||
CONTEXT_FOR_CLEAN = """本场背景(来自既有笔记与长期记忆,仅供你判断术语与人名的写法,**不要**把这些内容写进输出):
|
||||
{context}"""
|
||||
|
||||
KEYWORD_PROMPT = """从下面的会议转写摘选中,提取用于检索背景资料的关键词。
|
||||
只要专有名词:人名、公司名、项目/产品代号、机构、专业术语。
|
||||
转写有识别错误,可以给出你认为正确的写法。
|
||||
输出一行,用空格分隔,最多 25 个词,不要任何其他内容。
|
||||
|
||||
转写摘选:
|
||||
{sample}"""
|
||||
|
||||
# The provenance rule below exists because of a measured failure: with background
|
||||
# context injected, drafts asserted context-derived facts as transcript facts
|
||||
# (「谭凯(白帆生物总经理)」、「此项目负责人为 [[冯昆仑]](背景确认)」) even when the
|
||||
# recording never said them. The facts were often correct, but a meeting note that
|
||||
# cannot distinguish "said on the call" from "known from notes" is unsafe to finalize.
|
||||
DRAFT_PROMPT = """你是会议录音整理助手,正在根据**带来源编号的事实卡片**产出「粗整理草稿」。
|
||||
|
||||
严格遵循:
|
||||
1. 只产出粗整理草稿,**不是**正式纪要。
|
||||
2. 清楚区分三类信息:明确事实 / 讨论中的想法 / 推测·高不确定。
|
||||
3. **出处标注(重要)**:本草稿的每一条断言都必须能追溯到来源。
|
||||
- 来自本次转写的内容:正常写。
|
||||
- 来自下方「本场背景」而转写中**没有**说到的内容(例如某人的职务、公司归属、
|
||||
项目历史):必须加后缀 `〔背景〕`,并且**不得**写成「明确事实」。
|
||||
- 你自己的推断:加后缀 `〔推断〕` 或写「疑似」。
|
||||
- 绝不把背景知识或推断伪装成转写内容。转写里没说的职务、公司名、金额、日期,
|
||||
一律不能不加标注地断言。
|
||||
4. 用 [[双链]] 标注有把握的人物/公司/项目;不确定的用纯文本 + 「疑似」。
|
||||
5. 转写中形如 `词[?候选]` 的标注表示清洗阶段无法确定的词,请结合全文语境判断,
|
||||
并在「待确认」中列出。
|
||||
6. 形如 `[…重复噪声已折叠:…]` 的标记表示该处录音是长段非语音噪声(保持音/静默),
|
||||
不要当成内容;如影响理解可在「待确认」提一句。
|
||||
7. 末尾必须有一节 `## 待你确认`,列出需要人工复核的关键不确定点(名词、说话人归属、
|
||||
日期、金额、结构)。
|
||||
7a. 正文标题后、议题正文前必须先写 `## 通话背景(待确认)`,用 2-4 句话概括:
|
||||
通话类型、参与方、正在处理的事项、各方关系。只写本次转写能支持的内容;推断必须
|
||||
明标「疑似」。这段会先交给用户判断背景是否靠谱,以及是否值得携带正确背景重跑。
|
||||
|
||||
**结构要求(按议题归拢,不要平铺)**:
|
||||
8. 正文按**议题**分节(一、二、三…),一个议题一节。每节内部用这个顺序:
|
||||
先一句「结论/当前状态」,再列支撑事实,最后写「待办/下一步」(如果有)。
|
||||
同一议题的事实必须收在同一节,不要因为在录音里出现的先后不同就拆成多条平行 bullet。
|
||||
9. 事实合并:同一件事的多次提及、同一金额/时间的重复确认、同一人的多轮补充,
|
||||
**合并成一条**并保留关键数字与限定条件,不要逐句罗列。
|
||||
单节内的 bullet 控制在 7 条以内;超出说明该拆成子议题或该合并。
|
||||
10. 每条 bullet 只讲一件事,避免「A,同时 B,另外 C」的堆叠长句。
|
||||
|
||||
**关于人名与简称(重要)**:
|
||||
11. 转写里的简称、称谓、老X/X总/X博就是口语常态,不是缺陷。若上下文足以确定指谁
|
||||
(例如正在谈某公司、而背景里该公司只有一位对应角色的人),**直接写全名并加 [[双链]]**,
|
||||
不要写成「具体身份不确定,需人工核实」。这类过度回避会让草稿失去价值。
|
||||
12. 只有在**真的存在多个候选且无法区分**时才标「疑似」,并把候选一起列出
|
||||
(例如「徐总(疑似 [[徐劲前]] 或 [[邹洵]])」),而不是笼统写「身份不明」。
|
||||
13. 判定简称指向谁时使用背景里的任职信息,这属于**辨识**而非越界断言,
|
||||
不需要加〔背景〕;但如果你要额外陈述这个人的职务、公司归属本身,那就要加〔背景〕。
|
||||
14. 对匿名说话人,除非事实卡片已经给出有证据的候选,否则只写“身份待确认”;
|
||||
不要从背景中枚举可能的人名让用户猜。
|
||||
|
||||
{speaker_note}
|
||||
|
||||
{context}
|
||||
|
||||
frontmatter 严格用下列字段:
|
||||
---
|
||||
date: {date}
|
||||
type: minutes
|
||||
tags: [会议纪要, 草稿, 待确认]
|
||||
source: 录音转录
|
||||
source_detail: {source_detail}
|
||||
status: 草稿待确认
|
||||
people: [有把握的人名…]
|
||||
companies: [有把握的公司…]
|
||||
transcript: {transcript}
|
||||
{extra_fm}---
|
||||
|
||||
事实卡片:
|
||||
{body}
|
||||
|
||||
只输出 markdown 草稿全文,不要任何额外说明。"""
|
||||
|
||||
SPEAKER_NOTE = {
|
||||
"qwen3cam": (
|
||||
"转写来自 Qwen3-ASR + CAM++ 分角色 + 声纹:\n"
|
||||
"- 中文真名的说话人是**声纹已确认**的,直接用,不要写「疑似」。\n"
|
||||
"- `speaker_N` 是声纹未命名的说话人,按内容/口吻推断是谁并标「疑似」。\n"
|
||||
"- 不同 speaker_N 是不同真人(CAM++ 已分),但可能过度聚类(同一人被拆成多个 N),"
|
||||
"按内容合并同一真人。\n"
|
||||
"- frontmatter 的 people 优先填**声纹命名的真人**。"),
|
||||
"vibevoice": "转写带 S# 说话人标签,仅作软提示,块间编号不保证一致。",
|
||||
"qwen3-plain": "转写**无说话人信息**,据内容推断发言归属,不要臆造 speaker 标签。",
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stage orchestration: transcript text -> rough-outline draft.
|
||||
|
||||
S0 segment collapse ASR degeneration, split into speaker-aware chunks
|
||||
S1 flag phonetic + term-bank candidates, Hindsight in-play recall
|
||||
S1c keyword LLM keyword extraction -> Hindsight recall (restores context
|
||||
for backends that emit no speaker tags)
|
||||
S2 clean per-chunk 去口语化 + per-sentence candidate adjudication
|
||||
S3 gate deterministic fidelity check, strict retry, fallback to source
|
||||
S4 draft one strong-model call, structure + provenance labelling only
|
||||
|
||||
Design invariant: **no stage may silently lose content**. S3 is what makes that
|
||||
true; without it, per-chunk cleaning dropped up to 22% of English terms and 11% of
|
||||
numbers on the evaluation set, and no amount of prompt wording fixed it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from . import context as context_compactor
|
||||
from . import fidelity, flagger, numbers as number_norm, prompts, segment
|
||||
from .config import CFG
|
||||
from .llm import PartialFailure, Usage, chat, chat_many
|
||||
from .log import log
|
||||
|
||||
SOURCE_DETAIL = {
|
||||
"qwen3cam": "Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹自动命名",
|
||||
"vibevoice": "VibeVoice 分角色",
|
||||
"qwen3-plain": "Qwen3-ASR 1.7B",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
draft: str
|
||||
cleaned: str
|
||||
meta: dict
|
||||
usage: dict
|
||||
timings: dict
|
||||
stats: dict
|
||||
flags: list = field(default_factory=list)
|
||||
context: str = ""
|
||||
evidence: str = ""
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ S1c
|
||||
def keyword_recall(raw: str, usage: Usage) -> tuple[set[str], list[str], str]:
|
||||
"""LLM keyword extraction, then Hindsight recall on those keywords.
|
||||
|
||||
Needed because the phonetic flagger seeds its in-play entity set from speaker
|
||||
tags: on `qwen3-plain` transcripts there are none, and measurement showed
|
||||
in_play=0 for every such fixture — no vault person or company could ever be
|
||||
offered as a candidate. Reading names out of the text restores that signal and
|
||||
was the single biggest quality lever (term-correction recall 0.21 -> 0.40).
|
||||
"""
|
||||
head, mid = raw[:3000], raw[len(raw) // 2: len(raw) // 2 + 3000]
|
||||
try:
|
||||
out = chat(CFG.keyword_model,
|
||||
prompts.KEYWORD_PROMPT.format(sample=head + "\n…\n" + mid),
|
||||
max_tokens=300, usage=usage)
|
||||
except Exception as e:
|
||||
log(f"keyword recall failed, continuing without it: {e}", level="WARN")
|
||||
return set(), [], ""
|
||||
words = [w.strip("、,,。;;::·") for w in re.split(r"[\s,、]+", out) if w.strip()]
|
||||
words = [w for w in words if 2 <= len(w) <= 12][:25]
|
||||
names, texts = flagger.hindsight_recall(words, "会议 背景 相关人物 公司 项目")
|
||||
return names, texts, " ".join(words)
|
||||
|
||||
|
||||
def build_context(fa: dict, extra_texts: list[str], limit: int = 6) -> str:
|
||||
names = fa.get("hindsight_names") or []
|
||||
texts = (fa.get("hindsight_texts") or []) + (extra_texts or [])
|
||||
try:
|
||||
from . import lexicon
|
||||
texts += lexicon.relationship_card(set(names), limit=12)
|
||||
except Exception:
|
||||
pass
|
||||
compacted = context_compactor.compact(names, texts, per_entity=2,
|
||||
total=max(6, limit * 2))
|
||||
head = "相关实体:" + "、".join(names[:30]) if names else ""
|
||||
return "\n".join(x for x in (head, compacted) if x)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ S2 + S3
|
||||
def clean_chunks(chunks, usage: Usage) -> tuple[list[str], list[dict], dict]:
|
||||
vocab = fidelity.domain_terms() if CFG.use_termbank else set()
|
||||
stats = {"skipped_trivial": 0, "retried": 0, "fallback": 0, "gate_failed": 0}
|
||||
|
||||
def trivial(c) -> bool:
|
||||
stripped = re.sub(r"\[…重复噪声已折叠:.*?…\]|[\s【】]", "", c.body)
|
||||
return len(stripped) < 40
|
||||
|
||||
prompt_list, target_idx = [], []
|
||||
for c in chunks:
|
||||
if trivial(c):
|
||||
continue
|
||||
parts = [prompts.CLEAN_RULES]
|
||||
if c.overlap_prefix:
|
||||
parts.append("上一片段结尾(只读上下文,**不要**重复输出这部分):\n"
|
||||
+ c.overlap_prefix)
|
||||
parts.append("待清洗片段:\n" + c.body)
|
||||
prompt_list.append("\n\n".join(parts))
|
||||
target_idx.append(c.idx)
|
||||
stats["skipped_trivial"] = len(chunks) - len(prompt_list)
|
||||
|
||||
max_tok = min(16000, 1200 + 3 * max((c.chars for c in chunks), default=600))
|
||||
outs: list[str] = []
|
||||
if prompt_list:
|
||||
try:
|
||||
outs = chat_many(CFG.clean_model, prompt_list,
|
||||
system=prompts.CLEAN_SYS, max_tokens=max_tok, usage=usage)
|
||||
except PartialFailure as pf:
|
||||
# Keep what succeeded; failed indices fall back to source text below.
|
||||
outs = pf.outputs
|
||||
log(f"{len(pf.failed)} chunk(s) unrecoverable, using source text",
|
||||
level="WARN")
|
||||
|
||||
by_idx = dict(zip(target_idx, outs))
|
||||
prompt_by_idx = dict(zip(target_idx, prompt_list))
|
||||
|
||||
finals, verdicts = [], []
|
||||
for c in chunks:
|
||||
out = by_idx.get(c.idx) or c.body
|
||||
p = prompt_by_idx.get(c.idx)
|
||||
v = fidelity.check(c.body, out, vocab=vocab,
|
||||
min_ratio=CFG.gate_min_ratio, max_ratio=CFG.gate_max_ratio)
|
||||
if CFG.gate_enabled and not v.ok and p is not None:
|
||||
stats["retried"] += 1
|
||||
strict = (p + "\n\n**上一次输出不合格**:" + v.brief()
|
||||
+ "\n请重做。逐句改写,不要合并、不要省略、不要总结;"
|
||||
"数字、英文、说话人标签必须保留;听不懂的词原样保留。")
|
||||
try:
|
||||
out2 = chat(CFG.clean_model, strict, system=prompts.CLEAN_SYS,
|
||||
max_tokens=min(16000, 1500 + 3 * c.chars), usage=usage)
|
||||
v2 = fidelity.check(c.body, out2, vocab=vocab,
|
||||
min_ratio=CFG.gate_min_ratio,
|
||||
max_ratio=CFG.gate_max_ratio)
|
||||
if v2.ok or v2.ratio > v.ratio:
|
||||
out, v = out2, v2
|
||||
except Exception as e:
|
||||
log(f"strict retry failed for chunk {c.idx}: {e}", level="WARN")
|
||||
if not v.ok:
|
||||
# A visibly unclean chunk beats one with facts deleted.
|
||||
out = c.body
|
||||
v.reasons.append("两次失败→保留原文")
|
||||
stats["fallback"] += 1
|
||||
if not v.ok:
|
||||
stats["gate_failed"] += 1
|
||||
finals.append(out)
|
||||
verdicts.append(v.as_dict())
|
||||
return finals, verdicts, stats
|
||||
|
||||
|
||||
def _apply_settled(text: str, flags: list[dict]) -> tuple[str, list[dict]]:
|
||||
"""Apply unambiguous human-confirmed mappings without asking an LLM."""
|
||||
chosen: dict[str, str] = {}
|
||||
for g in flags:
|
||||
if not g.get("settled") or g.get("common_word") or not g.get("candidates"):
|
||||
continue
|
||||
chosen[g["span"]] = g["candidates"][0]["target"]
|
||||
markers: dict[str, str] = {}
|
||||
out = text
|
||||
for i, (surface, target) in enumerate(
|
||||
sorted(chosen.items(), key=lambda x: len(x[0]), reverse=True)):
|
||||
if surface not in out or surface == target:
|
||||
continue
|
||||
marker = f"\uFFF0ASR{i}\uFFF1"
|
||||
out = out.replace(surface, marker)
|
||||
markers[marker] = target
|
||||
for marker, target in markers.items():
|
||||
out = out.replace(marker, target)
|
||||
decisions = [{"surface": s, "target": t, "mode": "deterministic"}
|
||||
for s, t in chosen.items() if s in text]
|
||||
return out, decisions
|
||||
|
||||
|
||||
def _common_lexical_context(span: str, sentence: str,
|
||||
allowlist: set[str]) -> bool:
|
||||
"""Reject a candidate that cuts through an ordinary Chinese word."""
|
||||
if span in {"很好"}:
|
||||
return True
|
||||
for match in re.finditer(re.escape(span), sentence):
|
||||
start, end = match.span()
|
||||
lo, hi = max(0, start - 4), min(len(sentence), end + 4)
|
||||
for left in range(lo, end):
|
||||
for right in range(max(left + 2, start + 1),
|
||||
min(hi, left + 6) + 1):
|
||||
if right <= start or left >= end:
|
||||
continue
|
||||
word = sentence[left:right]
|
||||
if (word in allowlist
|
||||
and re.fullmatch(r"[\u3400-\u9fff]+", word)
|
||||
and (left < start or right > end)):
|
||||
return True
|
||||
if (len(span) == 2 and span[-1] in "总哥姐"
|
||||
and start > 0
|
||||
and re.fullmatch(r"[\u3400-\u9fff]", sentence[start - 1])
|
||||
and sentence[start - 1] not in "问跟和找请让给叫说"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _queue_ambiguous(recording: str, groups: list[dict],
|
||||
normalized: str = "") -> int:
|
||||
"""Queue only ambiguities the context adjudicator explicitly left unresolved."""
|
||||
try:
|
||||
from . import review
|
||||
except Exception:
|
||||
return 0
|
||||
markers = re.findall(r"\[\?([^\]]+)\]", normalized)
|
||||
if not markers:
|
||||
return 0
|
||||
allowlist = flagger.load_allowlist()
|
||||
n = 0
|
||||
for g in groups:
|
||||
cs = g.get("candidates") or []
|
||||
if g.get("settled") or g.get("common_word") or not cs:
|
||||
continue
|
||||
if _common_lexical_context(
|
||||
g["span"], str(g.get("sentence", "")), allowlist):
|
||||
continue
|
||||
choices = [c["target"] for c in cs[:3]]
|
||||
if not any(g["span"] in marker
|
||||
or any(choice in marker for choice in choices)
|
||||
for marker in markers):
|
||||
continue
|
||||
top = cs[0]
|
||||
score = float(top.get("score", 0))
|
||||
if not (CFG.review_min_score <= score <= CFG.review_max_score):
|
||||
continue
|
||||
rule = top.get("rule", "")
|
||||
source = str(top.get("source", ""))
|
||||
supported = bool(top.get("boost") or top.get("support"))
|
||||
curated = rule == "alias" and source.startswith(
|
||||
("alias-card", "alias-learned", "alias-glossary", "alias-review"))
|
||||
consistent = rule == "consistency" and source == "consistency-alias"
|
||||
contextual_short = (rule in {"honorific", "org-short", "given"}
|
||||
and supported and score >= 0.90)
|
||||
via = str(top.get("via", ""))
|
||||
observed_neighbour = (
|
||||
rule == "observed" and supported and score >= 0.95
|
||||
and len(g["span"]) >= 3 and via
|
||||
and (g["span"][0] == via[0] or score >= 0.99))
|
||||
# A broad phonetic channel is useful for recall but is not, by itself,
|
||||
# sufficient reason to interrupt a human. "也说→叶硕" and "们七→范梦奇"
|
||||
# are representative false positives from that channel.
|
||||
if not (curated or consistent or contextual_short or observed_neighbour):
|
||||
continue
|
||||
if len(choices) == 1:
|
||||
q = f"“{g['span']}”是不是指“{choices[0]}”?"
|
||||
else:
|
||||
q = f"“{g['span']}”更可能指哪一个:{' / '.join(choices)}?"
|
||||
review.enqueue(recording, q, {
|
||||
"surface": g["span"], "candidates": choices,
|
||||
"sentence": g.get("sentence", ""), "kind": top.get("kind", "")})
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def normalize_chunks(chunks, cleaned_parts: list[str], flags: list[dict],
|
||||
context: str, recording: str,
|
||||
usage: Usage, *,
|
||||
queue_review: bool = True) -> tuple[list[str], list[dict], dict]:
|
||||
"""S3: deterministic confirmed mappings, then narrow LLM adjudication."""
|
||||
prompts_by_idx: dict[int, str] = {}
|
||||
base_by_idx: dict[int, str] = {}
|
||||
decisions: list[dict] = []
|
||||
number_decisions: list[dict] = []
|
||||
unresolved_by_idx: dict[int, list[dict]] = {}
|
||||
queued = 0
|
||||
for c, cleaned in zip(chunks, cleaned_parts):
|
||||
cf = [g for g in flags if g["span"] in c.body]
|
||||
base, done = _apply_settled(cleaned, cf)
|
||||
decisions.extend({**d, "chunk": c.idx} for d in done)
|
||||
base, nums = number_norm.normalize(base)
|
||||
number_decisions.extend({**d, "chunk": c.idx} for d in nums)
|
||||
unresolved = [g for g in cf if not g.get("settled")]
|
||||
unresolved_by_idx[c.idx] = unresolved
|
||||
uncertain_numbers = [d for d in nums if d["confidence"] < 0.8]
|
||||
base_by_idx[c.idx] = base
|
||||
if not unresolved and not uncertain_numbers:
|
||||
continue
|
||||
parts = [prompts.NORMALIZE_RULES]
|
||||
if unresolved:
|
||||
parts.append(prompts.JUDGE_RULES.format(
|
||||
candidates=flagger.render_for_prompt(unresolved, 20)))
|
||||
if context:
|
||||
parts.append(prompts.CONTEXT_FOR_CLEAN.format(context=context))
|
||||
if uncertain_numbers:
|
||||
lines = "\n".join(
|
||||
f"- 原值「{d['raw']}」|上下文:{d['context']}"
|
||||
for d in uncertain_numbers[:12])
|
||||
parts.append("以下中文数字类型不明确。不能可靠确定时原样保留并标待确认:\n"
|
||||
+ lines)
|
||||
parts.append("待规范化文本:\n" + base)
|
||||
prompts_by_idx[c.idx] = "\n\n".join(parts)
|
||||
|
||||
idxs = list(prompts_by_idx)
|
||||
outs = []
|
||||
if idxs:
|
||||
try:
|
||||
outs = chat_many(CFG.clean_model, [prompts_by_idx[i] for i in idxs],
|
||||
system=prompts.NORMALIZE_SYS, max_tokens=6000, usage=usage)
|
||||
except PartialFailure as pf:
|
||||
outs = pf.outputs
|
||||
normalized = dict(zip(idxs, outs))
|
||||
finals = []
|
||||
fallback = 0
|
||||
for c in chunks:
|
||||
base = base_by_idx[c.idx]
|
||||
out = normalized.get(c.idx) or base
|
||||
ratio = len(out) / max(1, len(base))
|
||||
src_speakers = set(fidelity.SPEAKER.findall(base))
|
||||
out_speakers = set(fidelity.SPEAKER.findall(out))
|
||||
if not (0.75 <= ratio <= 1.25) or not src_speakers.issubset(out_speakers):
|
||||
out = base
|
||||
fallback += 1
|
||||
elif queue_review:
|
||||
queued += _queue_ambiguous(
|
||||
recording, unresolved_by_idx.get(c.idx, []), out)
|
||||
finals.append(out)
|
||||
return finals, decisions, {
|
||||
"normalize_calls": len(idxs), "normalize_fallback": fallback,
|
||||
"review_queued": queued, "deterministic_corrections": len(decisions),
|
||||
"number_decisions": number_decisions,
|
||||
"numbers_normalized": sum(
|
||||
1 for d in number_decisions if d["raw"] != d["normalized"]),
|
||||
"numbers_uncertain": sum(
|
||||
1 for d in number_decisions if d["confidence"] < 0.8),
|
||||
}
|
||||
|
||||
|
||||
def extract_evidence(parts: list[str], usage: Usage) -> str:
|
||||
jobs = [prompts.EVIDENCE_PROMPT.format(chunk_id=i, body=body)
|
||||
for i, body in enumerate(parts)]
|
||||
if not jobs:
|
||||
return ""
|
||||
try:
|
||||
outs = chat_many(CFG.clean_model, jobs, system=prompts.EVIDENCE_SYS,
|
||||
max_tokens=1400, usage=usage)
|
||||
except PartialFailure as pf:
|
||||
outs = pf.outputs
|
||||
return "\n".join(x for x in outs if x.strip())
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ S4
|
||||
def write_draft(cleaned: str, meta: dict, context: str, date: str,
|
||||
transcript_path: str, usage: Usage) -> str:
|
||||
extra = ""
|
||||
intake = meta.get("intake") or {}
|
||||
if intake.get("job_id"):
|
||||
extra += f"intake_job: {intake['job_id']}\n"
|
||||
if meta.get("voiceprint_session"):
|
||||
extra += f"voiceprint_session: {meta['voiceprint_session']}\n"
|
||||
named = meta.get("voiceprint_named") or {}
|
||||
if named:
|
||||
extra += "voiceprint_speakers:\n"
|
||||
for k, v in named.items():
|
||||
extra += f" {k}: {v}\n"
|
||||
extra += ("# 说明:voiceprint_speakers 只写声纹已确认命名的;你据内容推断的匿名"
|
||||
"说话人不要当确认名写进来(会误导回注册),留正文「疑似」即可。\n")
|
||||
user_context = str(intake.get("context") or "").strip()
|
||||
if user_context:
|
||||
supplied = (
|
||||
"用户在上传录音时明确提供的背景(可用于辨识本场主题和实体;"
|
||||
"不能把其中未在录音出现的内容冒充为本场发言):\n"
|
||||
+ user_context)
|
||||
context = supplied + ("\n\n" + context if context else "")
|
||||
ctx = prompts.CONTEXT_FOR_CLEAN.format(context=context) if context else ""
|
||||
if ctx:
|
||||
ctx = "本场背景(来自既有笔记与长期记忆;引用其中内容时必须按上面第 3 条加 〔背景〕):\n" \
|
||||
+ context
|
||||
detail = SOURCE_DETAIL.get(meta.get("backend", ""), "ASR")
|
||||
prompt = prompts.DRAFT_PROMPT.format(
|
||||
speaker_note=prompts.SPEAKER_NOTE.get(meta.get("backend", ""), ""),
|
||||
context=ctx, date=date, source_detail=detail,
|
||||
transcript=transcript_path, extra_fm=extra, body=cleaned)
|
||||
return chat(CFG.draft_model, prompt, max_tokens=12000, usage=usage)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ driver
|
||||
def refine(raw: str, *, date: str, transcript_path: str,
|
||||
queue_review: bool = True) -> Result:
|
||||
usage = Usage()
|
||||
timings: dict = {}
|
||||
t_all = time.time()
|
||||
|
||||
t = time.time()
|
||||
chunks, meta = segment.chunk(raw, CFG.chunk_budget, CFG.chunk_overlap,
|
||||
collapse_noise=CFG.collapse_noise)
|
||||
timings["s0_segment"] = round(time.time() - t, 2)
|
||||
if meta.get("degenerate_chars"):
|
||||
ev = meta["degenerate_events"]
|
||||
log(f"S0 collapsed {meta['degenerate_chars']} chars of ASR degeneration "
|
||||
f"({len(ev)} run(s), e.g. {ev[0]['unit']!r}x{ev[0]['repeats']})")
|
||||
|
||||
# S1c runs *before* S1 so its entity names can seed the candidate space. Doing
|
||||
# it the other way round meant the recalled names only ever reached the prompt
|
||||
# as background prose, never as phonetic candidates — which is why transcripts
|
||||
# without speaker labels produced almost no flags.
|
||||
t = time.time()
|
||||
kw, extra_texts, names = "", [], set()
|
||||
if CFG.use_keyword_recall and CFG.use_hindsight:
|
||||
names, extra_texts, kw = keyword_recall(raw, usage)
|
||||
timings["s1c_recall"] = round(time.time() - t, 2)
|
||||
|
||||
t = time.time()
|
||||
fa = {"flags": [], "hindsight_names": [], "hindsight_texts": [], "in_play": []}
|
||||
if CFG.use_termbank or CFG.use_hindsight:
|
||||
try:
|
||||
fa = flagger.analyze(raw, use_hindsight=CFG.use_hindsight,
|
||||
use_termbank=CFG.use_termbank,
|
||||
extra_in_play=names)
|
||||
except Exception as e:
|
||||
log(f"flagger failed, continuing without candidates: {e}", level="WARN")
|
||||
if names:
|
||||
fa["hindsight_names"] = sorted(set(fa.get("hindsight_names", [])) | names)
|
||||
timings["s1_flag"] = round(time.time() - t, 2)
|
||||
|
||||
context = build_context(fa, extra_texts) if CFG.use_hindsight else ""
|
||||
|
||||
t = time.time()
|
||||
cleaned_parts, verdicts, stats = clean_chunks(chunks, usage)
|
||||
cleaned = "\n\n".join(cleaned_parts)
|
||||
timings["s2_clean"] = round(time.time() - t, 2)
|
||||
|
||||
t = time.time()
|
||||
normalized_parts = cleaned_parts
|
||||
if CFG.use_normalize:
|
||||
normalized_parts, decisions, nstats = normalize_chunks(
|
||||
chunks, cleaned_parts, fa["flags"], context, transcript_path, usage,
|
||||
queue_review=queue_review)
|
||||
stats.update(nstats)
|
||||
stats["normalization_decisions"] = decisions
|
||||
normalized = "\n\n".join(normalized_parts)
|
||||
timings["s3_normalize"] = round(time.time() - t, 2)
|
||||
|
||||
t = time.time()
|
||||
evidence = extract_evidence(normalized_parts, usage) if CFG.use_evidence else normalized
|
||||
timings["s4_evidence"] = round(time.time() - t, 2)
|
||||
|
||||
t = time.time()
|
||||
draft = write_draft(evidence, meta, context, date, transcript_path, usage)
|
||||
timings["s4_draft"] = round(time.time() - t, 2)
|
||||
|
||||
src_body = "\n\n".join(c.body for c in chunks)
|
||||
stats.update({
|
||||
"raw_chars": len(raw),
|
||||
"source_chars": len(src_body),
|
||||
"cleaned_chars": len(cleaned),
|
||||
"normalized_chars": len(normalized),
|
||||
"evidence_chars": len(evidence),
|
||||
"draft_chars": len(draft),
|
||||
"n_chunks": len(chunks),
|
||||
"n_turns": meta.get("n_turns", 0),
|
||||
"n_flags": len(fa["flags"]),
|
||||
"n_hindsight": len(fa.get("hindsight_names", [])),
|
||||
"keywords": kw,
|
||||
"degenerate_chars": meta.get("degenerate_chars", 0),
|
||||
"fidelity": fidelity.score_document(src_body, cleaned),
|
||||
"wall_s": round(time.time() - t_all, 2),
|
||||
})
|
||||
return Result(draft=draft, cleaned=normalized, meta=meta, usage=usage.summary(),
|
||||
timings=timings, stats=stats, flags=fa["flags"], context=context,
|
||||
evidence=evidence)
|
||||
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Persistent human-review queue with transport-independent storage."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from . import lexicon
|
||||
from .config import CFG
|
||||
|
||||
FEEDBACK_START = "<!-- asr-web-review:start -->"
|
||||
FEEDBACK_END = "<!-- asr-web-review:end -->"
|
||||
CARD_THRESHOLD = 2
|
||||
|
||||
|
||||
def enqueue(recording: str, question: str, payload: dict) -> int:
|
||||
con = lexicon.connect()
|
||||
old = con.execute(
|
||||
"SELECT id FROM review_question WHERE recording=? AND question=? "
|
||||
"AND status='pending'", (recording, question)).fetchone()
|
||||
if old:
|
||||
con.close()
|
||||
return int(old["id"])
|
||||
cur = con.execute(
|
||||
"INSERT INTO review_question(recording,question,payload,created_at)"
|
||||
" VALUES(?,?,?,?)",
|
||||
(recording, question, json.dumps(payload, ensure_ascii=False), time.time()))
|
||||
con.commit()
|
||||
row_id = int(cur.lastrowid)
|
||||
con.close()
|
||||
return row_id
|
||||
|
||||
|
||||
def pending(limit: int = 20) -> list[dict]:
|
||||
con = lexicon.connect()
|
||||
rows = con.execute(
|
||||
"SELECT * FROM review_question WHERE status='pending' "
|
||||
"ORDER BY created_at LIMIT ?", (limit,)).fetchall()
|
||||
con.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def enqueue_draft(recording: str, draft_path: Path) -> int:
|
||||
"""Queue the draft's explicit uncertainties plus one overall review item."""
|
||||
path = draft_path.resolve()
|
||||
try:
|
||||
path.relative_to(CFG.drafts.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(f"draft outside review root: {path}")
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
fm_match = re.match(r"(?s)^---\n(.*?)\n---", text)
|
||||
fm = fm_match.group(1) if fm_match else ""
|
||||
bg_match = re.search(
|
||||
r"(?ms)^## 通话背景(待确认)\s*$\n(.*?)(?=^## |\Z)", text)
|
||||
if bg_match:
|
||||
background = re.sub(r"\s+", " ", bg_match.group(1)).strip()
|
||||
else:
|
||||
body = text[fm_match.end():] if fm_match else text
|
||||
body = re.sub(r"(?m)^# .*$", "", body).strip()
|
||||
background = re.sub(r"\s+", " ", body[:700]).strip()
|
||||
count = 0
|
||||
enqueue(recording,
|
||||
"系统判断的通话背景是否基本准确?若偏差较大,请在下方写明正确背景以重跑草稿。",
|
||||
{
|
||||
"kind": "background_check", "draft_path": str(path),
|
||||
"question_index": 0, "sentence": background,
|
||||
"candidates": ["背景基本准确"],
|
||||
})
|
||||
count += 1
|
||||
|
||||
named = {}
|
||||
in_speakers = False
|
||||
for line in fm.splitlines():
|
||||
if line.strip() == "voiceprint_speakers:":
|
||||
in_speakers = True
|
||||
continue
|
||||
if in_speakers:
|
||||
sm = re.match(r"^\s+(speaker_\d+):\s*(.+?)\s*$", line)
|
||||
if sm:
|
||||
named[sm.group(1)] = sm.group(2).strip()
|
||||
continue
|
||||
in_speakers = False
|
||||
transcript_path = ""
|
||||
tm = re.search(r"(?m)^transcript:\s*(.+?)\s*$", fm)
|
||||
if tm:
|
||||
transcript_path = tm.group(1).strip().strip("\"'")
|
||||
transcript = ""
|
||||
try:
|
||||
transcript = Path(transcript_path).read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
pass
|
||||
all_speakers = sorted(set(re.findall(r"(?m)^【(speaker_\d+)】", transcript)))
|
||||
missing_speakers = [s for s in all_speakers if s not in named]
|
||||
for speaker in missing_speakers:
|
||||
excerpts = re.findall(
|
||||
rf"(?m)^【{re.escape(speaker)}】(.+)$", transcript)
|
||||
spoken_chars = sum(len(re.sub(r"\s+", "", x)) for x in excerpts)
|
||||
if spoken_chars < 80 and max((len(x) for x in excerpts), default=0) < 50:
|
||||
continue
|
||||
excerpt = "\n\n".join(x.strip() for x in excerpts[:3])[:1200]
|
||||
enqueue(recording, f"{speaker} 尚未注册声纹;你认识这位说话人吗?", {
|
||||
"kind": "voiceprint", "speaker_id": speaker,
|
||||
"draft_path": str(path), "question_index": count,
|
||||
"sentence": excerpt or "没有足够发言摘录,请无法确认时直接跳过。",
|
||||
"candidates": [],
|
||||
})
|
||||
count += 1
|
||||
|
||||
m = re.search(r"(?ms)^## 待你确认\s*$\n(.*?)(?=^## |\Z)", text)
|
||||
questions = []
|
||||
if m:
|
||||
questions = [
|
||||
re.sub(r"\s+", " ", x).strip()
|
||||
for x in re.findall(r"(?m)^\d+\.\s+(.+)$", m.group(1))
|
||||
]
|
||||
for i, question in enumerate(questions, 1):
|
||||
if any(speaker in question and "身份" in question
|
||||
for speaker in missing_speakers):
|
||||
continue
|
||||
candidates = list(dict.fromkeys(re.findall(r"\[\[([^\]|#]+)", question)))
|
||||
enqueue(recording, question, {
|
||||
"kind": "draft_question", "draft_path": str(path),
|
||||
"question_index": count, "sentence": question,
|
||||
"candidates": candidates[:4],
|
||||
})
|
||||
count += 1
|
||||
|
||||
cards = {
|
||||
"人名": {p.stem for p in (CFG.vault / "People").glob("*.md")
|
||||
if not p.name.startswith("_")},
|
||||
"公司": {p.stem for p in (CFG.vault / "Companies").glob("*.md")
|
||||
if not p.name.startswith("_")},
|
||||
}
|
||||
con = lexicon.connect()
|
||||
for kind, key in (("人名", "people"), ("公司", "companies")):
|
||||
names = re.findall(r"[^,\[\]\s,、]+",
|
||||
_frontmatter_value(text, key))
|
||||
for name in names:
|
||||
if name in cards[kind] or not name or name.startswith("speaker_"):
|
||||
continue
|
||||
history = con.execute(
|
||||
"SELECT count(DISTINCT source) FROM entity_mention "
|
||||
"WHERE entity=? AND kind=?", (name, kind)).fetchone()[0]
|
||||
if history + 1 < CARD_THRESHOLD:
|
||||
continue
|
||||
label = "人物卡" if kind == "人名" else "公司卡"
|
||||
enqueue(recording,
|
||||
f"“{name}”计入本次后已在 {history + 1} 场正式记录中出现,"
|
||||
f"是否建立{label}?", {
|
||||
"kind": "entity_card", "entity": name,
|
||||
"entity_kind": kind, "draft_path": str(path),
|
||||
"question_index": count,
|
||||
"sentence": "只建立保守存根;职位、重要性、合作阶段等判断字段不会自动猜。",
|
||||
"candidates": [f"建立{label}", "暂不建立"],
|
||||
})
|
||||
count += 1
|
||||
con.close()
|
||||
enqueue(recording, "这份草稿整体是否可以作为后续定稿的基础?", {
|
||||
"kind": "draft_overall", "draft_path": str(path),
|
||||
"question_index": count,
|
||||
"sentence": "可展开查看完整草稿;选择“还需修改”时,请在输入框说明修改方向。",
|
||||
"candidates": ["草稿整体可用", "还需修改"],
|
||||
})
|
||||
return count + 1
|
||||
|
||||
|
||||
def _frontmatter_value(text: str, key: str) -> str:
|
||||
m = re.match(r"(?s)^---\n(.*?)\n---", text)
|
||||
if not m:
|
||||
return ""
|
||||
km = re.search(rf"(?m)^{re.escape(key)}:\s*(.+?)\s*$", m.group(1))
|
||||
return km.group(1).strip().strip("\"'") if km else ""
|
||||
|
||||
|
||||
def _voiceprint_name(value: str) -> str:
|
||||
if value in {"无法确认,保留原文", "不知道", "不认识"}:
|
||||
return ""
|
||||
name = re.split(r"[,,;;((\s]", value.strip(), maxsplit=1)[0]
|
||||
return name if 2 <= len(name) <= 12 and not name.startswith("speaker_") else ""
|
||||
|
||||
|
||||
def _confirm_voiceprint(draft_path: str, speaker_id: str, value: str) -> bool:
|
||||
"""Persist an explicit speaker identity and immediately enroll its session."""
|
||||
name = _voiceprint_name(value)
|
||||
if not name:
|
||||
return False
|
||||
path = Path(draft_path).resolve()
|
||||
try:
|
||||
path.relative_to(CFG.drafts.resolve())
|
||||
except ValueError:
|
||||
return False
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
session_id = _frontmatter_value(text, "voiceprint_session")
|
||||
if not session_id:
|
||||
return False
|
||||
fm = re.match(r"(?s)^---\n(.*?)\n---", text)
|
||||
if not fm:
|
||||
return False
|
||||
block = fm.group(1)
|
||||
speaker_line = rf"(?m)^(\s+{re.escape(speaker_id)}:)\s*.*$"
|
||||
if re.search(speaker_line, block):
|
||||
block = re.sub(speaker_line, rf"\1 {name}", block)
|
||||
elif re.search(r"(?m)^voiceprint_speakers:\s*$", block):
|
||||
block = re.sub(
|
||||
r"(?m)^(voiceprint_speakers:\s*)$",
|
||||
rf"\1\n {speaker_id}: {name}", block, count=1)
|
||||
else:
|
||||
block += f"\nvoiceprint_speakers:\n {speaker_id}: {name}"
|
||||
if re.search(r"(?m)^voiceprint_enroll:", block):
|
||||
block = re.sub(r"(?m)^voiceprint_enroll:.*$",
|
||||
"voiceprint_enroll: true", block)
|
||||
else:
|
||||
block += "\nvoiceprint_enroll: true"
|
||||
text = text[:fm.start()] + "---\n" + block + "\n---" + text[fm.end():]
|
||||
from . import vault
|
||||
vault.write_draft_file(path, text)
|
||||
|
||||
body = json.dumps({
|
||||
"session_id": session_id, "speaker_id": int(speaker_id.split("_")[1]),
|
||||
"name": name,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
"http://192.168.50.100:10099/voiceprints/enroll", data=body,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as response:
|
||||
result = json.load(response)
|
||||
return result.get("status") == "enrolled"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _persist_draft_feedback(recording: str, draft_path: str) -> bool:
|
||||
"""Once a draft's web questions are complete, append an auditable answer block."""
|
||||
path = Path(draft_path).resolve()
|
||||
try:
|
||||
path.relative_to(CFG.drafts.resolve())
|
||||
except ValueError:
|
||||
return False
|
||||
con = lexicon.connect()
|
||||
rows = con.execute(
|
||||
"SELECT * FROM review_question WHERE recording=? ORDER BY created_at,id",
|
||||
(recording,)).fetchall()
|
||||
con.close()
|
||||
selected = []
|
||||
for row in rows:
|
||||
payload = json.loads(row["payload"] or "{}")
|
||||
if payload.get("draft_path") == str(path):
|
||||
if row["status"] == "pending":
|
||||
return False
|
||||
selected.append((row["question"], row["answer"] or ""))
|
||||
if not selected or not path.exists():
|
||||
return False
|
||||
lines = [FEEDBACK_START, "## Web 人工确认记录", ""]
|
||||
for i, (question, answer_value) in enumerate(selected, 1):
|
||||
lines.extend([f"{i}. **问题**:{question}", f" - **回答**:{answer_value}"])
|
||||
lines.extend(["", FEEDBACK_END])
|
||||
block = "\n".join(lines)
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
pattern = re.compile(
|
||||
re.escape(FEEDBACK_START) + r".*?" + re.escape(FEEDBACK_END), re.S)
|
||||
text = pattern.sub(block, text) if pattern.search(text) else text.rstrip() + "\n\n" + block + "\n"
|
||||
from . import vault
|
||||
vault.write_draft_file(path, text)
|
||||
return True
|
||||
|
||||
|
||||
def _request_revision(draft_path: str) -> bool:
|
||||
queue_dir = os.environ.get("ASR_REVISION_QUEUE_DIR", "")
|
||||
if queue_dir:
|
||||
try:
|
||||
path = Path(draft_path).resolve()
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
job = _frontmatter_value(text, "intake_job")
|
||||
queue = Path(queue_dir)
|
||||
queue.mkdir(parents=True, exist_ok=True)
|
||||
target = queue / f"{path.stem}.json"
|
||||
tmp = queue / f".{path.stem}.{os.getpid()}.tmp"
|
||||
tmp.write_text(json.dumps({"draft": str(path), "job_id": job},
|
||||
ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(target)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
name = Path(draft_path).name
|
||||
try:
|
||||
escaped = subprocess.run(
|
||||
["systemd-escape", name], capture_output=True, text=True,
|
||||
timeout=5, check=True).stdout.strip()
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "start", "--no-block",
|
||||
f"asr-review-revise@{escaped}.service"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def answer(question_id: int, value: str) -> dict:
|
||||
con = lexicon.connect()
|
||||
row = con.execute("SELECT * FROM review_question WHERE id=?",
|
||||
(question_id,)).fetchone()
|
||||
if not row:
|
||||
con.close()
|
||||
raise KeyError(f"review question {question_id} not found")
|
||||
if row["status"] != "pending":
|
||||
result = {
|
||||
"id": question_id, "answer": row["answer"], "learned": False,
|
||||
"already_answered": True,
|
||||
}
|
||||
con.close()
|
||||
return result
|
||||
payload = json.loads(row["payload"] or "{}")
|
||||
recording = row["recording"]
|
||||
con.execute(
|
||||
"UPDATE review_question SET status='answered',answer=?,answered_at=? WHERE id=?",
|
||||
(value, time.time(), question_id))
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
# A positive answer to an entity choice becomes reusable deterministic memory.
|
||||
source = payload.get("surface", "")
|
||||
candidates = payload.get("candidates") or []
|
||||
canonical = value if value in candidates else ""
|
||||
if source and canonical:
|
||||
lexicon.learn_alias(source, canonical, origin="review", confirmed=1,
|
||||
note=f"review question {question_id}")
|
||||
lexicon.learn_correction(source, canonical, confirmed=1,
|
||||
note=f"review question {question_id}")
|
||||
lexicon.record_observation(source, canonical, source=row["recording"],
|
||||
context=payload.get("sentence", ""),
|
||||
confidence=1.0, confirmed=1)
|
||||
saved = False
|
||||
voiceprint_enrolled = False
|
||||
if payload.get("kind") == "voiceprint":
|
||||
voiceprint_enrolled = _confirm_voiceprint(
|
||||
payload.get("draft_path", ""), payload.get("speaker_id", ""), value)
|
||||
if payload.get("draft_path"):
|
||||
try:
|
||||
saved = _persist_draft_feedback(recording, payload["draft_path"])
|
||||
except Exception as e:
|
||||
# The answer is already durable in SQLite. A transient git/filesystem
|
||||
# failure must not turn a successful mobile submission into a retry.
|
||||
from .log import log
|
||||
log(f"review answer saved but draft feedback sync failed: {e}",
|
||||
level="WARN")
|
||||
saved = False
|
||||
revision_started = saved and _request_revision(payload["draft_path"])
|
||||
if revision_started:
|
||||
try:
|
||||
text = Path(payload["draft_path"]).read_text(
|
||||
encoding="utf-8", errors="ignore")
|
||||
job = _frontmatter_value(text, "intake_job")
|
||||
if job:
|
||||
from .control_web import update_job
|
||||
if (payload.get("kind") == "publish_approval"
|
||||
and value == "发布正式稿"):
|
||||
update_job(job, "publishing", "发布已确认,正在写入正式纪要")
|
||||
else:
|
||||
update_job(job, "refining", "正在应用回答并生成定稿预览")
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"id": question_id, "answer": value, "learned": bool(canonical),
|
||||
"draft_feedback_saved": saved, "voiceprint_enrolled": voiceprint_enrolled,
|
||||
"revision_started": revision_started,
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ephemeral, capability-URL mobile UI for the human review queue."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from . import review
|
||||
from .config import CFG
|
||||
from .log import log
|
||||
|
||||
STATE = CFG.state_dir / "review-web.json"
|
||||
UNIT = "asr-review-web.service"
|
||||
|
||||
|
||||
def _url(token: str) -> str:
|
||||
return f"http://{CFG.review_public_host}:{CFG.review_port}/r/{token}/"
|
||||
|
||||
|
||||
def request_start(job_id: str = "") -> bool:
|
||||
"""Ensure control service is running and send a task-scoped review URL."""
|
||||
try:
|
||||
r = subprocess.run(["systemctl", "--user", "start", UNIT],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
if r.returncode:
|
||||
log(f"review web start failed: {r.stderr.strip()[:240]}", level="WARN")
|
||||
return False
|
||||
from . import control_web
|
||||
job = control_web._job(job_id) if job_id else None
|
||||
if job_id and not job:
|
||||
log(f"review notification missing job: {job_id}", level="WARN")
|
||||
return False
|
||||
url = control_web._review_url(job_id)
|
||||
rows = (control_web._pending_for_job(job, 1000)
|
||||
if job else review.pending(1000))
|
||||
from . import vault
|
||||
n = len(rows)
|
||||
title = str(job.get("title") or job["original_name"]) if job else "ASR"
|
||||
vault.notify("ASR 快速确认",
|
||||
f"{title}:{n} 个问题待确认\n{url}\n"
|
||||
f"录音上传入口:http://{CFG.review_public_host}:{CFG.review_port}/",
|
||||
redact_log=True)
|
||||
return True
|
||||
except Exception as e:
|
||||
log(f"review web start failed: {e}", level="WARN")
|
||||
return False
|
||||
|
||||
|
||||
def read_state() -> dict | None:
|
||||
try:
|
||||
return json.loads(STATE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_state(token: str, started: float) -> None:
|
||||
CFG.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"pid": os.getpid(), "url": _url(token), "port": CFG.review_port,
|
||||
"started_at": started, "expires_at": started + CFG.review_max_seconds,
|
||||
}
|
||||
tmp = STATE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(STATE)
|
||||
|
||||
|
||||
def _page(title: str, body: str) -> bytes:
|
||||
return f"""<!doctype html>
|
||||
<html lang="zh-CN"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta http-equiv="Cache-Control" content="no-store">
|
||||
<title>{html.escape(title)}</title>
|
||||
<style>
|
||||
:root{{--ink:#17202a;--muted:#637083;--line:#d9dee5;--paper:#f7f8fa;
|
||||
--accent:#176b5b;--accent2:#c84b31;--white:#fff}}
|
||||
*{{box-sizing:border-box}} body{{margin:0;background:var(--paper);color:var(--ink);
|
||||
font-family:system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||
font-size:17px;line-height:1.55;letter-spacing:0}}
|
||||
header{{background:var(--white);border-bottom:1px solid var(--line);padding:16px 18px}}
|
||||
header strong{{font-size:18px}} header span{{display:block;color:var(--muted);font-size:14px}}
|
||||
main{{max-width:680px;margin:0 auto;padding:18px 14px 40px}}
|
||||
.question{{background:var(--white);border:1px solid var(--line);border-radius:8px;padding:18px}}
|
||||
h1{{font-size:21px;line-height:1.35;margin:0 0 12px}} .source{{margin:14px 0;
|
||||
padding:12px;border-left:4px solid #d4a72c;background:#fff9e8;color:#39424e}}
|
||||
form{{display:grid;gap:10px}} button{{width:100%;min-height:50px;border:0;border-radius:6px;
|
||||
padding:12px 14px;background:var(--accent);color:white;font:inherit;font-weight:650}}
|
||||
button.secondary{{background:#e9edf1;color:var(--ink);border:1px solid var(--line)}}
|
||||
input{{width:100%;min-height:50px;border:1px solid #9aa5b1;border-radius:6px;
|
||||
padding:10px 12px;font:inherit;background:white}} .custom{{display:flex;gap:8px}}
|
||||
.custom input{{min-width:0;flex:1}} .custom button{{width:auto;min-width:82px}}
|
||||
.meta{{color:var(--muted);font-size:14px;margin-bottom:10px}} .done{{text-align:center;
|
||||
background:var(--white);border:1px solid var(--line);border-radius:8px;padding:36px 18px}}
|
||||
.draft{{margin:14px 0;border:1px solid var(--line);border-radius:6px;background:#fbfcfd}}
|
||||
.draft summary{{padding:12px 14px;font-weight:650;cursor:pointer}}
|
||||
.draft pre{{margin:0;padding:14px;border-top:1px solid var(--line);max-height:58vh;
|
||||
overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.55 ui-monospace,
|
||||
SFMono-Regular,Consolas,monospace}}
|
||||
.done h1{{color:var(--accent)}} @media(max-width:420px){{main{{padding:12px 10px 28px}}
|
||||
.question{{padding:15px}} .custom{{display:grid}} .custom button{{width:100%}}}}
|
||||
</style></head><body><header><strong>ASR 快速确认</strong>
|
||||
<span>确认结果会写入个人事实库,并用于后续录音</span></header>
|
||||
<main>{body}</main></body></html>""".encode("utf-8")
|
||||
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self, addr, handler, token: str):
|
||||
super().__init__(addr, handler)
|
||||
self.token = token
|
||||
self.started_at = time.time()
|
||||
self.last_activity = self.started_at
|
||||
self.opened = False
|
||||
self.finish_at: float | None = None
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "ASRReview/1"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
# BaseHTTPRequestHandler includes the full URL in its default log line.
|
||||
# The path is a bearer capability, so never persist it.
|
||||
status = args[1] if len(args) > 1 else "?"
|
||||
log(f"review web request: {self.command} status={status}")
|
||||
|
||||
def _headers(self, status=HTTPStatus.OK, content_type="text/html; charset=utf-8"):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
self.send_header("Pragma", "no-cache")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Content-Security-Policy",
|
||||
"default-src 'none'; style-src 'unsafe-inline'; "
|
||||
"form-action 'self'; base-uri 'none'; frame-ancestors 'none'")
|
||||
|
||||
def _valid_path(self) -> bool:
|
||||
parts = urllib.parse.urlsplit(self.path)
|
||||
want = f"/r/{self.server.token}/"
|
||||
return hmac.compare_digest(parts.path, want)
|
||||
|
||||
def _send(self, data: bytes, status=HTTPStatus.OK):
|
||||
self._headers(status)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
body = json.dumps({"ok": True, "pending": len(review.pending(100))}).encode()
|
||||
self._headers(content_type="application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if not self._valid_path():
|
||||
self._send(b"Not found", HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
self.server.last_activity = time.time()
|
||||
self.server.opened = True
|
||||
rows = review.pending(100)
|
||||
if not rows:
|
||||
if self.server.finish_at is None:
|
||||
self.server.finish_at = time.time() + 10
|
||||
self._send(_page("完成",
|
||||
'<section class="done"><h1>本轮确认完成</h1>'
|
||||
'<p>结果已经写入个人事实库,本页面即将自动关闭。</p></section>'))
|
||||
return
|
||||
row = rows[0]
|
||||
payload = json.loads(row["payload"] or "{}")
|
||||
choices = payload.get("candidates") or []
|
||||
buttons = []
|
||||
for value in choices:
|
||||
v = html.escape(str(value), quote=True)
|
||||
buttons.append(
|
||||
f'<button type="submit" name="answer" value="{v}">{v}</button>')
|
||||
buttons.append(
|
||||
'<button class="secondary" type="submit" name="answer" '
|
||||
'value="无法确认,保留原文">都不是 / 无法确认</button>')
|
||||
source = html.escape(str(payload.get("sentence", "")))
|
||||
source_html = f'<div class="source">{source}</div>' if source else ""
|
||||
draft_html = ""
|
||||
draft_path = payload.get("draft_path")
|
||||
if draft_path:
|
||||
try:
|
||||
path = Path(draft_path).resolve()
|
||||
path.relative_to(CFG.drafts.resolve())
|
||||
draft = path.read_text(encoding="utf-8", errors="ignore")
|
||||
draft_html = (
|
||||
'<details class="draft"><summary>查看完整草稿</summary><pre>'
|
||||
+ html.escape(draft) + "</pre></details>")
|
||||
except (OSError, ValueError):
|
||||
draft_html = ""
|
||||
placeholder = ("只输入姓名,例如:高瑶"
|
||||
if payload.get("kind") == "voiceprint"
|
||||
else ("背景不准时,在这里写明正确背景并提交重跑"
|
||||
if payload.get("kind") == "background_check"
|
||||
else "输入正确名称或补充说明"))
|
||||
body = f"""<section class="question">
|
||||
<div class="meta">剩余 {len(rows)} 个 · 问题 #{row['id']}</div>
|
||||
<h1>{html.escape(row['question'])}</h1>{source_html}{draft_html}
|
||||
<form method="post" action="/r/{self.server.token}/">
|
||||
<input type="hidden" name="id" value="{row['id']}">
|
||||
{''.join(buttons)}
|
||||
<div class="custom"><input name="custom" maxlength="120" autocomplete="off"
|
||||
placeholder="{html.escape(placeholder, quote=True)}"><button type="submit">提交</button></div>
|
||||
</form></section>"""
|
||||
self._send(_page("ASR 快速确认", body))
|
||||
|
||||
def do_POST(self):
|
||||
if not self._valid_path():
|
||||
self._send(b"Not found", HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
try:
|
||||
length = min(int(self.headers.get("Content-Length", "0")), 4096)
|
||||
form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8"))
|
||||
qid = int(form.get("id", ["0"])[0])
|
||||
custom = form.get("custom", [""])[0].strip()
|
||||
answer = custom or form.get("answer", [""])[0].strip()
|
||||
if not qid or not answer or len(answer) > 120:
|
||||
raise ValueError("invalid answer")
|
||||
pending_ids = {r["id"] for r in review.pending(1000)}
|
||||
if qid not in pending_ids:
|
||||
raise ValueError("问题已处理或不存在")
|
||||
review.answer(qid, answer)
|
||||
self.server.last_activity = time.time()
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self.send_header("Location", f"/r/{self.server.token}/")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
except Exception as e:
|
||||
self._send(_page("提交失败",
|
||||
f'<section class="done"><h1>提交失败</h1><p>{html.escape(str(e))}</p></section>'),
|
||||
HTTPStatus.BAD_REQUEST)
|
||||
|
||||
|
||||
def serve() -> int:
|
||||
rows = review.pending(100)
|
||||
if not rows:
|
||||
log("review web: no pending questions")
|
||||
return 0
|
||||
token = secrets.token_urlsafe(32)
|
||||
started = time.time()
|
||||
try:
|
||||
server = Server((CFG.review_host, CFG.review_port), Handler, token)
|
||||
except OSError as e:
|
||||
STATE.unlink(missing_ok=True)
|
||||
log(f"review web bind failed on {CFG.review_host}:{CFG.review_port}: {e}",
|
||||
level="ERROR")
|
||||
return 1
|
||||
_write_state(token, started)
|
||||
server.timeout = 1
|
||||
url = _url(token)
|
||||
log(f"review web listening on {CFG.review_public_host}:{CFG.review_port} "
|
||||
f"({len(rows)} question(s))")
|
||||
try:
|
||||
from . import vault
|
||||
vault.notify("ASR 快速确认",
|
||||
f"{len(rows)} 个问题待确认:{url}\n"
|
||||
f"链接最多保留 {CFG.review_max_seconds // 60} 分钟,完成后自动销毁。",
|
||||
redact_log=True)
|
||||
except Exception as e:
|
||||
log(f"review web notification failed: {e}", level="WARN")
|
||||
try:
|
||||
while True:
|
||||
server.handle_request()
|
||||
now = time.time()
|
||||
if server.finish_at and now >= server.finish_at:
|
||||
# A new recording can enqueue work during the 10-second grace
|
||||
# period while systemd still considers this unit active.
|
||||
if review.pending(1):
|
||||
server.finish_at = None
|
||||
log("review web received new work during shutdown grace")
|
||||
else:
|
||||
break
|
||||
if now - started >= CFG.review_max_seconds:
|
||||
log("review web reached maximum lifetime")
|
||||
break
|
||||
if server.opened and now - server.last_activity >= CFG.review_idle_seconds:
|
||||
log("review web idle timeout")
|
||||
break
|
||||
finally:
|
||||
server.server_close()
|
||||
try:
|
||||
STATE.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(serve())
|
||||
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare a final preview, then publish it only after explicit Web approval."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from . import graph, lexicon, vault
|
||||
from .config import CFG
|
||||
from .llm import chat
|
||||
from .log import log
|
||||
from .review import FEEDBACK_END, FEEDBACK_START
|
||||
|
||||
PROMPT = """请根据文末「Web 人工确认记录」将粗稿整理成可直接进入 Obsidian 的正式记录。
|
||||
|
||||
严格要求:
|
||||
1. 只使用原稿、人工回答及原稿已标明的背景,不补充外部事实。
|
||||
2. 将确认的人名、关系、背景和术语真正改入正文;人工回答有“应该”等保留不确定性。
|
||||
3. 删除已解决的「待你确认」以及 Web 审核记录,不写 ASR 修正、speaker 归属等过程说明。
|
||||
4. YAML 必须符合:
|
||||
- type: minutes
|
||||
- tags: [会议纪要]
|
||||
- 不得有 status: 草稿待确认
|
||||
- 保留 date/source/source_detail/transcript/voiceprint_session/
|
||||
voiceprint_speakers/voiceprint_enroll/intake_job
|
||||
- people/companies 使用裸名列表。
|
||||
5. H1 使用简短、有辨识度的业务标题,不要写“粗整理草稿”或“会议纪要”。
|
||||
6. H1 后先写:
|
||||
> [!summary] Summary
|
||||
> 结论式概述。
|
||||
然后按需写 `[!important] 决策`、Tasks 格式待办、`[!question] Open Questions`。
|
||||
7. 正文按业务主线组织,中文全角标点;有把握的实体用 [[双链]]。
|
||||
8. 不完整或仍存疑的内容可留 Open Questions,绝不硬猜。
|
||||
9. 输出完整 Markdown 文件,不要解释。
|
||||
|
||||
待发布粗稿:
|
||||
{draft}
|
||||
"""
|
||||
|
||||
|
||||
def _field(text: str, key: str) -> str:
|
||||
m = re.search(rf"(?m)^{re.escape(key)}:\s*(.+?)\s*$", text)
|
||||
return m.group(1).strip().strip("\"'") if m else ""
|
||||
|
||||
|
||||
def _safe_title(markdown: str) -> str:
|
||||
m = re.search(r"(?m)^#\s+(.+?)\s*$", markdown)
|
||||
title = m.group(1).strip() if m else "通话记录"
|
||||
title = re.sub(r"[\[\]<>:\"/\\|?*#]", "", title)
|
||||
title = re.sub(r"\s+", "", title).strip(". ")
|
||||
return title[:72] or "通话记录"
|
||||
|
||||
|
||||
def _formal_path(markdown: str) -> Path:
|
||||
date = _field(markdown, "date") or time.strftime("%Y-%m-%d")
|
||||
base = CFG.minutes / f"{date}-{_safe_title(markdown)}.md"
|
||||
transcript = _field(markdown, "transcript")
|
||||
if CFG.minutes.exists() and transcript:
|
||||
for path in CFG.minutes.glob("*.md"):
|
||||
try:
|
||||
if _field(path.read_text(errors="ignore"), "transcript") == transcript:
|
||||
return path
|
||||
except OSError:
|
||||
continue
|
||||
if not base.exists():
|
||||
return base
|
||||
n = 2
|
||||
while base.with_name(f"{base.stem}-{n}.md").exists():
|
||||
n += 1
|
||||
return base.with_name(f"{base.stem}-{n}.md")
|
||||
|
||||
|
||||
def _answered_card_requests(draft_path: Path) -> list[tuple[str, str]]:
|
||||
con = lexicon.connect()
|
||||
rows = con.execute(
|
||||
"SELECT payload,answer,status FROM review_question ORDER BY id").fetchall()
|
||||
con.close()
|
||||
out = []
|
||||
for row in rows:
|
||||
payload = json.loads(row["payload"] or "{}")
|
||||
if (payload.get("draft_path") != str(draft_path)
|
||||
or payload.get("kind") != "entity_card"
|
||||
or row["status"] != "answered"
|
||||
or not str(row["answer"]).startswith("建立")):
|
||||
continue
|
||||
out.append((payload["entity"], payload["entity_kind"]))
|
||||
return out
|
||||
|
||||
|
||||
def _create_card(name: str, kind: str, minute: Path, date: str) -> Path | None:
|
||||
if not name or "/" in name or name.startswith("_"):
|
||||
return None
|
||||
if kind == "人名":
|
||||
path = CFG.vault / "People" / f"{name}.md"
|
||||
if path.exists():
|
||||
return None
|
||||
body = f"""---
|
||||
type: person
|
||||
name: "{name}"
|
||||
aliases: []
|
||||
company:
|
||||
role:
|
||||
status: active
|
||||
importance:
|
||||
owner: Cooper
|
||||
tags: []
|
||||
last_contact: '{date}'
|
||||
mentions: 1
|
||||
first_contact: '{date}'
|
||||
next_followup:
|
||||
location:
|
||||
channels: []
|
||||
related_projects: []
|
||||
related_companies: []
|
||||
source: 会议提及
|
||||
created: {time.strftime("%Y-%m-%d")}
|
||||
---
|
||||
|
||||
# {name}
|
||||
|
||||
> [!summary] Summary
|
||||
> 在 [[{minute.stem}]] 中被确认;详细身份与关系待后续补充。
|
||||
|
||||
## 相关会议
|
||||
|
||||
- [[{minute.stem}]]
|
||||
"""
|
||||
else:
|
||||
path = CFG.vault / "Companies" / f"{name}.md"
|
||||
if path.exists():
|
||||
return None
|
||||
body = f"""---
|
||||
type: company
|
||||
name: "{name}"
|
||||
aliases: []
|
||||
industry:
|
||||
stage:
|
||||
relationship:
|
||||
key_people: []
|
||||
related_projects: []
|
||||
related_companies: []
|
||||
tags: []
|
||||
source: 会议提及
|
||||
created: {time.strftime("%Y-%m-%d")}
|
||||
last_contact: '{date}'
|
||||
mentions: 1
|
||||
first_contact: '{date}'
|
||||
---
|
||||
|
||||
# {name}
|
||||
|
||||
> [!summary] Summary
|
||||
> 在 [[{minute.stem}]] 中被确认;公司信息与关系阶段待后续补充。
|
||||
|
||||
## 相关会议
|
||||
|
||||
- [[{minute.stem}]]
|
||||
"""
|
||||
vault.write_draft_file(path, body)
|
||||
return path
|
||||
|
||||
|
||||
def _latest_publish_answer(path: Path) -> str:
|
||||
con = lexicon.connect()
|
||||
rows = con.execute(
|
||||
"SELECT payload,answer,status FROM review_question ORDER BY id DESC"
|
||||
).fetchall()
|
||||
con.close()
|
||||
for row in rows:
|
||||
payload = json.loads(row["payload"] or "{}")
|
||||
if (payload.get("draft_path") == str(path)
|
||||
and payload.get("kind") == "publish_approval"
|
||||
and row["status"] == "answered"):
|
||||
return str(row["answer"] or "")
|
||||
return ""
|
||||
|
||||
|
||||
def _validate_formal(out: str, original: str) -> None:
|
||||
if not out.startswith("---\n") or "type: minutes" not in out:
|
||||
raise RuntimeError("formal output failed frontmatter validation")
|
||||
transcript = _field(original, "transcript")
|
||||
if transcript and _field(out, "transcript") != transcript:
|
||||
raise RuntimeError("formal output changed transcript provenance")
|
||||
if "[!summary]" not in out:
|
||||
raise RuntimeError("formal output missing Summary callout")
|
||||
if not (0.35 <= len(out) / max(1, len(original)) <= 1.35):
|
||||
raise RuntimeError("formal output length outside safety range")
|
||||
|
||||
|
||||
def _as_preview(out: str) -> str:
|
||||
fm = re.match(r"(?s)^---\n(.*?)\n---", out)
|
||||
if not fm:
|
||||
raise RuntimeError("formal preview missing frontmatter")
|
||||
block = re.sub(r"(?m)^status:.*\n?", "", fm.group(1))
|
||||
block += "\nstatus: 待发布确认"
|
||||
return "---\n" + block + "\n---" + out[fm.end():]
|
||||
|
||||
|
||||
def prepare(path: Path) -> bool:
|
||||
path = path.resolve()
|
||||
path.relative_to(CFG.drafts.resolve())
|
||||
original = path.read_text(encoding="utf-8", errors="ignore")
|
||||
intake_job = _field(original, "intake_job")
|
||||
if FEEDBACK_START not in original or FEEDBACK_END not in original:
|
||||
log(f"publish skipped without completed feedback: {path}", level="WARN")
|
||||
return False
|
||||
out = chat(CFG.draft_model, PROMPT.format(draft=original), max_tokens=14000)
|
||||
out = re.sub(r"^```(?:markdown)?\s*|\s*```$", "", out.strip(),
|
||||
flags=re.I | re.S).strip() + "\n"
|
||||
if "status: 草稿待确认" in out or FEEDBACK_START in out:
|
||||
raise RuntimeError("formal output retained draft-only metadata")
|
||||
_validate_formal(out, original)
|
||||
vault.write_draft_file(path, _as_preview(out))
|
||||
from . import review, review_web
|
||||
review.enqueue(
|
||||
_field(original, "transcript") or path.name,
|
||||
"这是应用第一轮回答后的定稿预览。确认发布到 Obsidian 吗?",
|
||||
{
|
||||
"kind": "publish_approval", "draft_path": str(path),
|
||||
"sentence": "请展开查看完整定稿;选择退回时,在输入框写明修改意见。",
|
||||
"candidates": ["发布正式稿", "退回修改"],
|
||||
})
|
||||
if intake_job:
|
||||
try:
|
||||
from .control_web import update_job
|
||||
update_job(intake_job, "review", "定稿预览已生成,等待发布确认",
|
||||
draft=str(path))
|
||||
except Exception as e:
|
||||
log(f"preview intake status failed: {e}", level="WARN")
|
||||
review_web.request_start(intake_job)
|
||||
log(f"prepared final preview {path}")
|
||||
return True
|
||||
|
||||
|
||||
def _finalize_preview(text: str) -> str:
|
||||
text = re.sub(
|
||||
re.escape(FEEDBACK_START) + r".*?" + re.escape(FEEDBACK_END),
|
||||
"", text, flags=re.S)
|
||||
text = re.sub(r"(?m)^status:\s*待发布确认\s*\n?", "", text)
|
||||
return text.rstrip() + "\n"
|
||||
|
||||
|
||||
def publish(path: Path) -> bool:
|
||||
path = path.resolve()
|
||||
path.relative_to(CFG.drafts.resolve())
|
||||
original = path.read_text(encoding="utf-8", errors="ignore")
|
||||
if _latest_publish_answer(path) != "发布正式稿":
|
||||
log(f"publish skipped without explicit approval: {path}", level="WARN")
|
||||
return False
|
||||
out = _finalize_preview(original)
|
||||
_validate_formal(out, original)
|
||||
intake_job = _field(original, "intake_job")
|
||||
|
||||
destination = _formal_path(out)
|
||||
vault.write_draft_file(destination, out)
|
||||
date = _field(out, "date") or time.strftime("%Y-%m-%d")
|
||||
touched = [destination]
|
||||
for name, kind in _answered_card_requests(path):
|
||||
card = _create_card(name, kind, destination, date)
|
||||
if card:
|
||||
touched.append(card)
|
||||
state = vault.sync(touched, f"minutes: {destination.stem}(Web 确认后正式发布)")
|
||||
if state == "error":
|
||||
raise RuntimeError("formal minute commit failed")
|
||||
|
||||
graph.sync()
|
||||
archive = CFG.state_dir / "published"
|
||||
archive.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(path), archive / path.name)
|
||||
vault.notify("ASR 正式记录已发布",
|
||||
f"{destination.name} 已应用 Web 回答并发布(同步:{state})。")
|
||||
log(f"published {destination} | cards={len(touched)-1} | git={state}")
|
||||
if intake_job:
|
||||
try:
|
||||
from .control_web import update_job
|
||||
update_job(intake_job, "complete", f"正式纪要:{destination.name}",
|
||||
result=str(destination))
|
||||
except Exception as e:
|
||||
log(f"intake completion status failed: {e}", level="WARN")
|
||||
return True
|
||||
|
||||
|
||||
def revise(path: Path) -> bool:
|
||||
path = path.resolve()
|
||||
if _latest_publish_answer(path) == "发布正式稿":
|
||||
return publish(path)
|
||||
return prepare(path)
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""S0 — deterministic pre-processing and chunking. No LLM, no cost.
|
||||
|
||||
Handles the three transcript shapes the pipeline actually produces:
|
||||
qwen3cam `【谭凯】…` / `【speaker_3】…` turns, blank-line separated, plus a
|
||||
`<!-- voiceprint_session: … -->` header line.
|
||||
qwen3-plain `--- 第N段 [00:00 - 09:00] ---` segment banners then a wall of text
|
||||
with no speaker information at all.
|
||||
vibevoice `[mm:ss] S0: …` lines, with `[复读截断]` / `·` noise artifacts.
|
||||
|
||||
Chunking rule: never split inside a speaker turn if avoidable, because the
|
||||
cleaning model needs the whole turn to tell a filler word from a real answer.
|
||||
Turns longer than the budget are split on sentence boundaries. Each chunk carries
|
||||
`overlap_prefix` — the tail of the previous chunk, given to the model as
|
||||
read-only context so it can resolve a pronoun or a name that started earlier
|
||||
without being asked to re-emit it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
VOICEPRINT_HDR = re.compile(r"^<!--\s*voiceprint_session:")
|
||||
SEGMENT_BANNER = re.compile(r"^---\s*第\d+段.*---\s*$")
|
||||
TURN_TAG = re.compile(r"^【([^】]+)】\s*(.*)$", re.S)
|
||||
VIBE_TAG = re.compile(r"^\[(\d{1,2}:\d{2})\]\s*(S\d+)\s*[::]\s*(.*)$", re.S)
|
||||
# Must include ASCII terminators: the qwen3-plain backend emits English stretches
|
||||
# ("...so that's why they wanted us to set up company.") with no CJK punctuation at
|
||||
# all, and a CJK-only rule left single 15k-char "sentences" that blew the budget.
|
||||
SENT_END = re.compile(r"(?<=[。!?;;?!])(?![”’\"'))])")
|
||||
# Last-resort split points when a run has no terminator for thousands of chars.
|
||||
SOFT_BREAK = re.compile(r"(?<=[,,、::])")
|
||||
NOISE = re.compile(r"\[复读截断\]|^[·\s]+$")
|
||||
|
||||
# ASR decoders loop when fed non-speech audio (hold music, a phone beep, dead air).
|
||||
# Measured on the fixture set: one 24k-char call transcript is 77% "beep beep beep…"
|
||||
# (3,698 repetitions) and a 58k-char meeting has a 3.7k-char run of "呃,". Left in
|
||||
# place this garbage consumed 11 of 17 chunks, i.e. ~65% of the LLM spend on that
|
||||
# recording, and provoked the cleaning model into emitting 26k chars of noise per
|
||||
# chunk. Collapsing it is free, deterministic, and strictly beneficial.
|
||||
DEGENERATE = re.compile(
|
||||
r"((?:[A-Za-z]{1,12}|[\u4e00-\u9fff]{1,4})[ ,,、。!?!?]{0,2})\1{7,}")
|
||||
DEGEN_MIN_SPAN = 120 # only collapse runs long enough to be a real artifact
|
||||
DEGEN_KEEP = 2 # keep a couple of instances so the context stays readable
|
||||
|
||||
|
||||
def collapse_degenerate(text: str) -> tuple[str, list[dict]]:
|
||||
"""Replace long ASR repetition loops with a short, explicit marker.
|
||||
|
||||
Legitimate speech repetition (对对对, 好的好的) is short and must survive, so the
|
||||
rule requires both a high repeat count and a span over DEGEN_MIN_SPAN chars.
|
||||
The marker stays in the text rather than deleting silently: a recording with
|
||||
four minutes of hold music is a fact worth surfacing in the draft.
|
||||
"""
|
||||
events: list[dict] = []
|
||||
|
||||
def sub(m):
|
||||
span, unit = m.group(0), m.group(1)
|
||||
if len(span) < DEGEN_MIN_SPAN:
|
||||
return span
|
||||
n = len(span) // max(1, len(unit))
|
||||
events.append({"unit": unit, "repeats": n, "chars": len(span)})
|
||||
return unit * DEGEN_KEEP + f"[…重复噪声已折叠:{unit.strip()}×{n}…]"
|
||||
|
||||
return DEGENERATE.sub(sub, text), events
|
||||
|
||||
|
||||
@dataclass
|
||||
class Turn:
|
||||
speaker: str # "" when the backend gives no speaker
|
||||
text: str
|
||||
idx: int
|
||||
ts: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Chunk:
|
||||
idx: int
|
||||
turns: list[Turn]
|
||||
overlap_prefix: str = ""
|
||||
meta: dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def body(self) -> str:
|
||||
out = []
|
||||
for t in self.turns:
|
||||
out.append(f"【{t.speaker}】{t.text}" if t.speaker else t.text)
|
||||
return "\n\n".join(out)
|
||||
|
||||
@property
|
||||
def chars(self) -> int:
|
||||
return len(self.body)
|
||||
|
||||
|
||||
def detect_backend(raw: str) -> str:
|
||||
head = raw[:500]
|
||||
if "CAM++" in head or TURN_TAG.match(raw.strip().splitlines()[-1] if raw.strip() else ""):
|
||||
return "qwen3cam"
|
||||
if re.search(r"^\[\d{1,2}:\d{2}\]\s*S\d+\s*[::]", raw, re.M):
|
||||
return "vibevoice"
|
||||
if SEGMENT_BANNER.search(raw):
|
||||
return "qwen3-plain"
|
||||
return "qwen3cam" if "【" in raw else "qwen3-plain"
|
||||
|
||||
|
||||
def parse(raw: str) -> tuple[list[Turn], dict]:
|
||||
"""Split a transcript into turns plus header metadata."""
|
||||
meta: dict = {
|
||||
"voiceprint_session": "", "voiceprint_named": {}, "banners": [],
|
||||
"intake": {},
|
||||
}
|
||||
lines = raw.splitlines()
|
||||
body_lines = []
|
||||
for ln in lines:
|
||||
if ln.startswith("<!-- intake:") and ln.endswith("-->"):
|
||||
try:
|
||||
meta["intake"] = json.loads(
|
||||
ln[len("<!-- intake:"):-len("-->")].strip())
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
continue
|
||||
if VOICEPRINT_HDR.match(ln):
|
||||
m = re.search(r"voiceprint_session:\s*(\S+)", ln)
|
||||
if m:
|
||||
meta["voiceprint_session"] = m.group(1)
|
||||
m = re.search(r"clusters:\s*(\d+)", ln)
|
||||
if m:
|
||||
meta["clusters"] = int(m.group(1))
|
||||
continue
|
||||
if ln.startswith("# 转写"):
|
||||
for pair in re.findall(r"(speaker_\d+)=([^,\s]+)", ln):
|
||||
meta["voiceprint_named"][pair[0]] = pair[1]
|
||||
continue
|
||||
if SEGMENT_BANNER.match(ln):
|
||||
meta["banners"].append(ln.strip())
|
||||
continue
|
||||
body_lines.append(ln)
|
||||
|
||||
backend = detect_backend(raw)
|
||||
meta["backend"] = backend
|
||||
turns: list[Turn] = []
|
||||
|
||||
if backend == "vibevoice":
|
||||
for ln in body_lines:
|
||||
m = VIBE_TAG.match(ln.strip())
|
||||
if m:
|
||||
txt = NOISE.sub("", m.group(3)).strip()
|
||||
if txt:
|
||||
turns.append(Turn(m.group(2), txt, len(turns), m.group(1)))
|
||||
return turns, meta
|
||||
|
||||
blocks = [b.strip() for b in re.split(r"\n\s*\n", "\n".join(body_lines)) if b.strip()]
|
||||
for b in blocks:
|
||||
m = TURN_TAG.match(b)
|
||||
if m:
|
||||
txt = NOISE.sub("", m.group(2)).strip()
|
||||
if txt:
|
||||
turns.append(Turn(m.group(1), txt, len(turns)))
|
||||
else:
|
||||
txt = NOISE.sub("", b).strip()
|
||||
if txt:
|
||||
turns.append(Turn("", txt, len(turns)))
|
||||
return turns, meta
|
||||
|
||||
|
||||
def _pack(pieces: list[str], budget: int) -> list[str]:
|
||||
out, cur = [], ""
|
||||
for s in pieces:
|
||||
if len(cur) + len(s) > budget and cur:
|
||||
out.append(cur)
|
||||
cur = s
|
||||
else:
|
||||
cur += s
|
||||
if cur:
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
def _split_long(text: str, budget: int) -> list[str]:
|
||||
"""Break an over-long single turn, escalating through split granularities.
|
||||
|
||||
A hard character cut is the final fallback. It is ugly, but an oversized chunk
|
||||
is worse: it is exactly the condition that makes the cleaning model start
|
||||
summarizing instead of rewriting, which is the failure mode this whole
|
||||
pipeline exists to prevent.
|
||||
"""
|
||||
out = _pack([s for s in SENT_END.split(text) if s], budget)
|
||||
if all(len(p) <= budget for p in out):
|
||||
return out or [text]
|
||||
stage2: list[str] = []
|
||||
for p in out:
|
||||
if len(p) <= budget:
|
||||
stage2.append(p)
|
||||
else:
|
||||
stage2 += _pack([s for s in SOFT_BREAK.split(p) if s], budget)
|
||||
final: list[str] = []
|
||||
for p in stage2:
|
||||
if len(p) <= budget:
|
||||
final.append(p)
|
||||
else:
|
||||
final += [p[i:i + budget] for i in range(0, len(p), budget)]
|
||||
return final or [text]
|
||||
|
||||
|
||||
def chunk(raw: str, budget: int = 1800, overlap_chars: int = 200,
|
||||
collapse_noise: bool = True) -> tuple[list[Chunk], dict]:
|
||||
events: list[dict] = []
|
||||
if collapse_noise:
|
||||
raw, events = collapse_degenerate(raw)
|
||||
turns, meta = parse(raw)
|
||||
meta["degenerate_events"] = events
|
||||
meta["degenerate_chars"] = sum(e["chars"] for e in events)
|
||||
chunks: list[Chunk] = []
|
||||
cur: list[Turn] = []
|
||||
size = 0
|
||||
|
||||
def flush():
|
||||
nonlocal cur, size
|
||||
if cur:
|
||||
chunks.append(Chunk(len(chunks), cur, meta={}))
|
||||
cur, size = [], 0
|
||||
|
||||
for t in turns:
|
||||
if len(t.text) > budget:
|
||||
flush()
|
||||
for i, piece in enumerate(_split_long(t.text, budget)):
|
||||
chunks.append(Chunk(len(chunks), [Turn(t.speaker, piece, t.idx, t.ts)],
|
||||
meta={"split_turn": True, "part": i}))
|
||||
continue
|
||||
if size + len(t.text) > budget and cur:
|
||||
flush()
|
||||
cur.append(t)
|
||||
size += len(t.text)
|
||||
flush()
|
||||
|
||||
if overlap_chars:
|
||||
for i in range(1, len(chunks)):
|
||||
prev = chunks[i - 1].body
|
||||
chunks[i].overlap_prefix = prev[-overlap_chars:]
|
||||
meta["n_turns"] = len(turns)
|
||||
meta["n_chunks"] = len(chunks)
|
||||
meta["speakers"] = sorted({t.speaker for t in turns if t.speaker})
|
||||
return chunks, meta
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
for p in sys.argv[1:]:
|
||||
raw = Path(p).read_text(errors="ignore")
|
||||
for budget in (900, 1800, 3600):
|
||||
cs, meta = chunk(raw, budget)
|
||||
sizes = [c.chars for c in cs]
|
||||
print(f"{Path(p).name[:40]:<42} budget={budget:<5} chunks={len(cs):<4} "
|
||||
f"turns={meta['n_turns']:<5} spk={len(meta['speakers']):<3} "
|
||||
f"backend={meta['backend']:<12} "
|
||||
f"size min/avg/max={min(sizes)}/{sum(sizes)//len(sizes)}/{max(sizes)}")
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the domain term bank used to seed ASR misrecognition candidates.
|
||||
|
||||
Why not ChEMBL / DrugBank directly (evaluated 2026-07-25):
|
||||
ChEMBL's 4,225 approved molecules and DrugBank's dumps are **English** chemical
|
||||
names (PRAZOSIN, OFLOXACIN). Kai's transcripts are Chinese speech, so a Chinese
|
||||
ASR never emits "OFLOXACIN" — it emits 氧氟沙星. An English chemical dictionary
|
||||
therefore contributes ~nothing to phonetic correction of Chinese audio, while
|
||||
costing a multi-GB download. Skipped deliberately.
|
||||
|
||||
What actually pays off, in descending order of value:
|
||||
1. **INN stem generation** — the WHO INN system gives Chinese biologics highly
|
||||
regular suffixes (单抗/珠单抗/替尼/西普/他滨...). Any Han n-gram ending in one
|
||||
of those stems is almost certainly a drug name, which lets us *recognize* drug
|
||||
mentions we have never seen, not just ones in a list.
|
||||
2. **zhwiki intitle: search** — 237 hits for 单抗, 49 for 替尼, all real INN
|
||||
Chinese generic names (帕博利珠单抗, 奥希替尼...). Small, free, exactly on-domain.
|
||||
3. **Curated CDMO/antibody/CMC jargon** — the vocabulary that actually dominates
|
||||
Kai's meetings (原液/下游/批记录/中试/放行/一次性反应器/GMP 证...). ASR mangles
|
||||
these constantly (批记录→披露/P图) and no public corpus contains them as a set.
|
||||
4. **THUOCL medical/IT/caijing** — general high-frequency words. Weak on their own
|
||||
(医院/检查) but useful as a "this is a real word, do not 'correct' it" allowlist,
|
||||
which is how we suppress the 主要→朱岩 class of false positives.
|
||||
|
||||
Output: termbank/terms.json { term: {sources: [...], kind: str} }
|
||||
termbank/allowlist.txt common words that must never be rewritten
|
||||
|
||||
python3 -m lab.termbank build # download + assemble (network)
|
||||
python3 -m lab.termbank stats
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from .config import CFG
|
||||
|
||||
LAB = CFG.root
|
||||
BANK = CFG.termbank_dir
|
||||
UA = {"User-Agent": "asr-lab/1.0 (personal research; contact: local)"}
|
||||
|
||||
# ---------------------------------------------------------------- INN stems
|
||||
# WHO INN suffixes, Chinese renderings. A Han string ending in one of these is
|
||||
# a drug/biologic with very high probability.
|
||||
INN_SUFFIXES = [
|
||||
"单抗", "珠单抗", "尤单抗", "利珠单抗", "妥珠单抗", "西单抗", "木单抗", "库单抗",
|
||||
"替尼", "布替尼", "沙替尼", "래替尼", "洛替尼", "非替尼",
|
||||
"西普", "普肽", "格鲁肽", "鲁肽", "那肽",
|
||||
"他滨", "他赛", "紫杉醇", "沙星", "霉素", "环素", "西林", "唑胺", "磺胺",
|
||||
"干扰素", "白介素", "促红素", "生长素",
|
||||
"酶", "疫苗", "佐剂",
|
||||
]
|
||||
# Suffix -> what kind of thing it names, for the prompt hint.
|
||||
INN_KIND = {"单抗": "抗体药", "替尼": "小分子抑制剂", "肽": "多肽药", "疫苗": "疫苗"}
|
||||
|
||||
# --------------------------------------------------- curated CDMO / CMC jargon
|
||||
# Hand-built from Kai's own minutes + glossary. These are the terms that actually
|
||||
# break: each line is the CORRECT form; the flagger finds phonetic neighbours of
|
||||
# them in the transcript. Grouped only for readability.
|
||||
CDMO_TERMS = """
|
||||
原液 制剂 下游 上游 中试 放行 灌装 冻干 除菌过滤 层析 亲和层析 离子交换 疏水层析
|
||||
超滤 透析 病毒灭活 病毒去除 纳米过滤 收率 纯度 电荷异构体 糖型 聚体 碎片
|
||||
批记录 批生产记录 批分析记录 工艺验证 清洁验证 培养基模拟灌装 无菌工艺模拟
|
||||
偏差 变更 CAPA 超标 超趋势 纠正预防措施 质量回顾 年度质量回顾
|
||||
稳定性 加速稳定性 长期稳定性 强制降解 影响因素试验
|
||||
细胞库 主细胞库 工作细胞库 细胞株 稳定池 单克隆 克隆筛选 传代 传代稳定性
|
||||
反应器 生物反应器 一次性反应器 摇瓶 摇床 补料 流加 灌流 收获 澄清
|
||||
质谱 肽图 N端测序 效价 生物活性 亲和力 抗体偶联 载药量 游离药物
|
||||
工艺表征 工艺性能确认 关键质量属性 关键工艺参数 设计空间 控制策略
|
||||
技术转移 工艺开发 分析方法开发 方法验证 方法转移 对照品 标准品
|
||||
临床前 毒理 非GLP 药代 药效 首次人体试验 剂量爬坡 剂量探索
|
||||
申报 临床试验申请 新药上市申请 药品注册 药品上市许可持有人 受托生产
|
||||
现场检查 迎检 迎审 缺陷项 主要缺陷 严重缺陷 一般缺陷 整改
|
||||
生产许可证 药品生产许可证 B证 C证 D证 委托生产 受托方 委托方
|
||||
洁净区 洁净级别 压差 环境监测 沉降菌 浮游菌 表面微生物 尘埃粒子
|
||||
计算机化系统 数据完整性 审计追踪 电子签名 权限管理 系统验证
|
||||
双抗 双特异性抗体 三特异性抗体 纳米抗体 单域抗体 融合蛋白 重组蛋白
|
||||
抗体偶联药物 细胞治疗 基因治疗 慢病毒 腺病毒 质粒 转染 瞬转 稳转
|
||||
产能 产能利用率 排产 排期 交付 交付周期 项目里程碑 阶段付款
|
||||
报价 服务包 里程碑款 技术服务费 居间费 保密协议 主服务协议 工作说明书
|
||||
"""
|
||||
|
||||
# Company / platform names common in Kai's domain that generic corpora lack.
|
||||
DOMAIN_ORGS = """
|
||||
药明生物 药明康德 药明合联 康龙化成 泰格医药 昭衍新药 金斯瑞 义翘神州
|
||||
百奥赛图 和铂医药 信达生物 君实生物 恒瑞医药 复宏汉霖 三生国健 神州细胞
|
||||
迈威生物 洛启生物 麦济生物 维眸生物 白帆生物 鼎康生物 恒元生物 愚公生物
|
||||
奥浦迈 澳斯康 东富龙 楚天科技 森松 赛多利斯 思拓凡 默克 丹纳赫 颇尔
|
||||
药审中心 国家药监局 药品审评中心 核查中心 中检院
|
||||
"""
|
||||
|
||||
THUOCL = {
|
||||
"medical": "https://raw.githubusercontent.com/thunlp/THUOCL/master/data/THUOCL_medical.txt",
|
||||
"it": "https://raw.githubusercontent.com/thunlp/THUOCL/master/data/THUOCL_IT.txt",
|
||||
"caijing": "https://raw.githubusercontent.com/thunlp/THUOCL/master/data/THUOCL_caijing.txt",
|
||||
}
|
||||
|
||||
# The allowlist's job is to stop entity candidates from displacing ordinary words
|
||||
# (主要→朱岩, 分钟→冯昆仑, 具体→聚体). THUOCL's domain lists cannot do this: they
|
||||
# contain 医院/检查 but not 具体/自己/硬件. jieba's dictionary is a general-purpose
|
||||
# frequency table over 330k Han words, and measurement showed freq>=500 covers the
|
||||
# entire observed false-positive class (具体 自己 硬件 不了 主要 时间 分钟) while
|
||||
# still excluding the真 targets we must keep flagging (元烨 药名 中式 姚明).
|
||||
JIEBA_DICT = "https://raw.githubusercontent.com/fxsjy/jieba/master/jieba/dict.txt"
|
||||
JIEBA_MIN_FREQ = 500
|
||||
|
||||
HAN = re.compile(r"^[\u4e00-\u9fff]+$")
|
||||
|
||||
|
||||
_last_wiki = [0.0]
|
||||
|
||||
|
||||
def fetch(url: str, timeout=60, throttle=0.0) -> str:
|
||||
# zhwiki returns 429 after a handful of rapid api.php calls; pace them and
|
||||
# retry once on 429 so a term-bank build is reproducible rather than
|
||||
# silently losing whole drug-name families.
|
||||
if throttle:
|
||||
import time
|
||||
wait = throttle - (time.time() - _last_wiki[0])
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
_last_wiki[0] = time.time()
|
||||
req = urllib.request.Request(url, headers=UA)
|
||||
for attempt in range(4):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.read().decode("utf-8", "ignore")
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 429 or attempt == 3:
|
||||
raise
|
||||
import time
|
||||
time.sleep(6 * (attempt + 1))
|
||||
raise RuntimeError("unreachable")
|
||||
|
||||
|
||||
def wiki_intitle(keyword: str, limit=500) -> list[str]:
|
||||
"""zhwiki titles containing `keyword`. INN generic names live here."""
|
||||
out, offset = [], 0
|
||||
while offset < limit:
|
||||
q = urllib.parse.urlencode({
|
||||
"action": "query", "list": "search",
|
||||
"srsearch": f"intitle:{keyword}", "srlimit": 50, "sroffset": offset,
|
||||
"srnamespace": 0, "format": "json",
|
||||
})
|
||||
try:
|
||||
d = json.loads(fetch(f"https://zh.wikipedia.org/w/api.php?{q}", 40, throttle=1.5))
|
||||
except Exception as e:
|
||||
print(f" wiki {keyword} @{offset} failed: {e}", file=sys.stderr)
|
||||
break
|
||||
hits = d.get("query", {}).get("search", [])
|
||||
if not hits:
|
||||
break
|
||||
out += [h["title"] for h in hits]
|
||||
if "continue" not in d:
|
||||
break
|
||||
offset += 50
|
||||
return out
|
||||
|
||||
|
||||
def build():
|
||||
BANK.mkdir(parents=True, exist_ok=True)
|
||||
terms: dict[str, dict] = {}
|
||||
|
||||
def add(t: str, source: str, kind: str):
|
||||
t = t.strip()
|
||||
if not (2 <= len(t) <= 20):
|
||||
return
|
||||
e = terms.setdefault(t, {"sources": [], "kind": kind})
|
||||
if source not in e["sources"]:
|
||||
e["sources"].append(source)
|
||||
|
||||
print("[1/4] curated CDMO/CMC jargon")
|
||||
for t in CDMO_TERMS.split():
|
||||
add(t, "cdmo", "工艺术语")
|
||||
for t in DOMAIN_ORGS.split():
|
||||
add(t, "org", "机构")
|
||||
print(f" {len(terms)} terms")
|
||||
|
||||
print("[2/4] zhwiki INN generic drug names")
|
||||
n0 = len(terms)
|
||||
for kw in ("单抗", "替尼", "西普", "格鲁肽", "干扰素", "他滨", "疫苗", "沙星"):
|
||||
titles = wiki_intitle(kw)
|
||||
good = 0
|
||||
for t in titles:
|
||||
# Keep only names that genuinely END with a drug stem; the search also
|
||||
# returns 朱塞佩·塔替尼 (a violinist) and 塔替尼广场.
|
||||
if HAN.match(t) and any(t.endswith(s) for s in INN_SUFFIXES):
|
||||
add(t, "inn-wiki", "药品通用名")
|
||||
good += 1
|
||||
print(f" {kw}: {len(titles)} hits -> {good} kept")
|
||||
print(f" +{len(terms) - n0} drug names")
|
||||
|
||||
print("[3/5] jieba general-frequency dictionary -> allowlist")
|
||||
allow: set[str] = set()
|
||||
try:
|
||||
raw = fetch(JIEBA_DICT, 120)
|
||||
n = 0
|
||||
for line in raw.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 2 or not parts[1].isdigit():
|
||||
continue
|
||||
w, f = parts[0], int(parts[1])
|
||||
if HAN.match(w) and 2 <= len(w) <= 4:
|
||||
n += 1
|
||||
if f >= JIEBA_MIN_FREQ:
|
||||
allow.add(w)
|
||||
print(f" {n} han words scanned, {len(allow)} with freq>={JIEBA_MIN_FREQ}")
|
||||
except Exception as e:
|
||||
print(f" jieba dict FAILED: {e}", file=sys.stderr)
|
||||
|
||||
print("[4/5] THUOCL domain wordlists (domain vocabulary)")
|
||||
for name, url in THUOCL.items():
|
||||
try:
|
||||
raw = fetch(url, 90)
|
||||
except Exception as e:
|
||||
print(f" {name}: FAILED {e}", file=sys.stderr)
|
||||
continue
|
||||
words = []
|
||||
for line in raw.splitlines():
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
w = parts[0]
|
||||
freq = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
|
||||
if HAN.match(w) and 2 <= len(w) <= 8:
|
||||
words.append((w, freq))
|
||||
# Medical/IT words are domain vocabulary. They deliberately do NOT feed
|
||||
# the allowlist: THUOCL frequency is corpus-relative (医院 ranks huge) and
|
||||
# using it as a "this is a common word" signal both over-blocks real terms
|
||||
# and under-covers the general words that actually cause false positives.
|
||||
if name in ("medical", "it"):
|
||||
for w, f in words:
|
||||
if 3 <= len(w) <= 8 and f < 10000:
|
||||
add(w, f"thuocl-{name}", "领域词")
|
||||
print(f" {name}: {len(words)} words")
|
||||
|
||||
print("[5/5] vault glossary + entity cards")
|
||||
n0 = len(terms)
|
||||
gl = CFG.glossary
|
||||
if gl.exists():
|
||||
for line in gl.read_text(errors="ignore").splitlines():
|
||||
m = re.match(r"^-\s*(.+?)\s*[→⇒➔]\s*(.+)$", line.strip())
|
||||
if m:
|
||||
right = re.sub(r"(.*?)|\[\[|\]\]", "", m.group(2))
|
||||
right = right.split(";")[0].split("/")[0].strip()
|
||||
if right:
|
||||
add(right, "glossary", "已确认纠错目标")
|
||||
else:
|
||||
m2 = re.match(r"^-\s*([^\s—::]+)\s*[—::]", line.strip())
|
||||
if m2:
|
||||
add(re.sub(r"\[\[|\]\]", "", m2.group(1)), "glossary", "术语表")
|
||||
for sub, kind in (("People", "人名"), ("Companies", "公司")):
|
||||
d = CFG.vault / sub
|
||||
for f in d.glob("*.md") if d.exists() else []:
|
||||
add(f.stem, f"card-{sub.lower()}", kind)
|
||||
print(f" +{len(terms) - n0}")
|
||||
|
||||
# An allowlisted common word must not also be offered as a correction target.
|
||||
for w in allow:
|
||||
if w in terms and terms[w]["sources"] == ["thuocl-medical"]:
|
||||
del terms[w]
|
||||
|
||||
(BANK / "terms.json").write_text(
|
||||
json.dumps(terms, ensure_ascii=False, indent=0), encoding="utf-8")
|
||||
(BANK / "allowlist.txt").write_text(
|
||||
"\n".join(sorted(allow)), encoding="utf-8")
|
||||
(BANK / "inn_suffixes.json").write_text(
|
||||
json.dumps({"suffixes": INN_SUFFIXES, "kinds": INN_KIND}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
print(f"\nwrote {len(terms)} terms, {len(allow)} allowlist -> {BANK}")
|
||||
|
||||
|
||||
def stats():
|
||||
terms = json.loads((BANK / "terms.json").read_text())
|
||||
from collections import Counter
|
||||
c = Counter(s for e in terms.values() for s in e["sources"])
|
||||
k = Counter(e["kind"] for e in terms.values())
|
||||
print(f"total {len(terms)} terms")
|
||||
print("by source:", dict(c))
|
||||
print("by kind: ", dict(k))
|
||||
allow = (BANK / "allowlist.txt").read_text().split()
|
||||
print(f"allowlist {len(allow)}: {allow[:15]}")
|
||||
drugs = [t for t, e in terms.items() if e["kind"] == "药品通用名"]
|
||||
print(f"drug names {len(drugs)}: {drugs[:12]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
(build if len(sys.argv) < 2 or sys.argv[1] == "build" else stats)()
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Vault side effects: draft file, pending index, git sync, notification.
|
||||
|
||||
Ported from the legacy shell implementation with its hard-won details preserved:
|
||||
* a same date+title never overwrites an existing draft (suffix -2, -3, …)
|
||||
* the pending index row is created BEFORE staging, so one commit carries both
|
||||
* the 5-minute `obsidian-sync.sh` cron may have already staged the files; that is
|
||||
tolerated rather than treated as an error
|
||||
* a failed push is reported as "local only", never as success
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from .config import CFG
|
||||
from .log import log
|
||||
|
||||
PENDING_HEADER = """---
|
||||
type: index
|
||||
title: 待确认草稿
|
||||
note: 自动维护,确认定稿后请把对应行删除
|
||||
---
|
||||
|
||||
# 待确认草稿 (ASR 自动精修)
|
||||
|
||||
| 日期 | 标题 | 草稿 |
|
||||
|---|---|---|
|
||||
"""
|
||||
|
||||
|
||||
def slug_parts(slug: str) -> tuple[str, str]:
|
||||
"""`2026-07-21-125915录音` -> ('2026-07-21', '125915录音')."""
|
||||
m = re.match(r"^(\d{4}-\d{2}-\d{2})[-_](.+)$", slug)
|
||||
if m:
|
||||
return m.group(1), m.group(2)
|
||||
import time
|
||||
return time.strftime("%Y-%m-%d"), slug
|
||||
|
||||
|
||||
def draft_path(date: str, title: str) -> Path:
|
||||
CFG.drafts.mkdir(parents=True, exist_ok=True)
|
||||
p = CFG.drafts / f"{date}-{title}.md"
|
||||
if not p.exists():
|
||||
return p
|
||||
n = 2
|
||||
while (CFG.drafts / f"{date}-{title}-{n}.md").exists():
|
||||
n += 1
|
||||
return CFG.drafts / f"{date}-{title}-{n}.md"
|
||||
|
||||
|
||||
def write_draft_file(path: Path, body: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not body.endswith("\n"):
|
||||
body += "\n"
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent,
|
||||
prefix=f".{path.name}.", delete=False) as f:
|
||||
f.write(body)
|
||||
tmp = Path(f.name)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def find_draft_for_transcript(transcript: str) -> Path | None:
|
||||
"""Recover an interrupted run without paying for or creating a second draft."""
|
||||
if not CFG.drafts.exists():
|
||||
return None
|
||||
needle = f"transcript: {transcript}"
|
||||
for p in CFG.drafts.glob("*.md"):
|
||||
try:
|
||||
if needle in p.read_text(errors="ignore")[:2000]:
|
||||
return p
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def update_pending(date: str, title: str, path: Path) -> None:
|
||||
p = CFG.pending_index
|
||||
if not p.exists():
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(PENDING_HEADER, encoding="utf-8")
|
||||
base = path.stem
|
||||
text = p.read_text(errors="ignore")
|
||||
if f"[[{base}]]" in text:
|
||||
return
|
||||
text += f"| {date} | {title} | [[{base}]] |\n"
|
||||
write_draft_file(p, text)
|
||||
|
||||
|
||||
def _git(*args, check=False) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["git", *args], cwd=CFG.vault, capture_output=True,
|
||||
text=True, timeout=180, check=check)
|
||||
|
||||
|
||||
def sync(paths: list[Path], message: str) -> str:
|
||||
"""Commit + push. Returns 'pushed' | 'local' | 'noop' | 'disabled' | 'error'."""
|
||||
if not CFG.git_sync:
|
||||
return "disabled"
|
||||
try:
|
||||
rel = [str(p.relative_to(CFG.vault)) for p in paths if p.exists()]
|
||||
if not rel:
|
||||
return "noop"
|
||||
_git("add", "--", *rel)
|
||||
if _git("diff", "--cached", "--quiet").returncode == 0:
|
||||
log("nothing staged (likely already committed by the auto-sync cron)")
|
||||
return "noop"
|
||||
# Commit only our paths. The 5-minute vault sync may already have unrelated
|
||||
# files staged; absorbing those into this transaction makes rollback and
|
||||
# diagnosis needlessly difficult.
|
||||
c = _git("commit", "-q", "--only", "-m", message, "--", *rel)
|
||||
if c.returncode != 0:
|
||||
log(f"git commit failed: {c.stderr.strip()[:200]}", level="ERROR")
|
||||
return "error"
|
||||
p = _git("push", "-q")
|
||||
if p.returncode != 0:
|
||||
log(f"committed but push failed (local only): {p.stderr.strip()[:200]}",
|
||||
level="WARN")
|
||||
return "local"
|
||||
return "pushed"
|
||||
except Exception as e:
|
||||
log(f"git sync error: {e}", level="ERROR")
|
||||
return "error"
|
||||
|
||||
|
||||
def notify(title: str, body: str, *, redact_log: bool = False) -> None:
|
||||
if not CFG.notify:
|
||||
return
|
||||
n = CFG.notifier
|
||||
if not n.exists():
|
||||
log(f"NOTIFY (no notifier): {title} — {body}")
|
||||
return
|
||||
try:
|
||||
r = subprocess.run(["python3", str(n), "草稿已生成,待确认", title, body],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
detail = "[sensitive body redacted]" if redact_log else body
|
||||
log(f"NOTIFY: {title} — {detail} ({r.stdout.strip()[:80]})")
|
||||
except Exception as e:
|
||||
log(f"notify failed: {e}", level="WARN")
|
||||
|
||||
|
||||
def mark_done(slug: str) -> Path:
|
||||
CFG.state.mkdir(parents=True, exist_ok=True)
|
||||
m = CFG.state / f"{slug}.done"
|
||||
m.touch()
|
||||
return m
|
||||
|
||||
|
||||
def is_done(slug: str) -> bool:
|
||||
return (CFG.state / f"{slug}.done").exists()
|
||||
|
||||
|
||||
def pending_transcripts() -> list[Path]:
|
||||
if not CFG.inbox.exists():
|
||||
return []
|
||||
return sorted(p for p in CFG.inbox.glob("*.txt") if not is_done(p.stem))
|
||||
@@ -0,0 +1 @@
|
||||
"""Standalone ASR service for web, Agent API, and SSH drop-folder intake."""
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DATA = Path(os.getenv("ASR_DATA_DIR", "/data"))
|
||||
INBOX = DATA / "inbox"
|
||||
PROCESSING = DATA / "processing"
|
||||
PROCESSED = DATA / "processed"
|
||||
FAILED = DATA / "failed"
|
||||
TRANSCRIPTS = DATA / "transcripts"
|
||||
STATE = Path(os.getenv("ASR_STATE_DIR", "/state"))
|
||||
DRAFTS = DATA / "artifacts" / "drafts"
|
||||
RESULTS = DATA / "artifacts" / "results"
|
||||
LOGS = DATA / "logs"
|
||||
META = STATE / "uploads"
|
||||
REVISION_QUEUE = STATE / "revision-queue"
|
||||
DB = STATE / "intake.db"
|
||||
SUPPORTED = {".m4a", ".mp3", ".wav", ".mp4", ".aac", ".ogg", ".opus", ".webm", ".flac"}
|
||||
PUBLIC_URL = os.getenv("ASR_PUBLIC_URL", "http://192.168.50.49:8788").rstrip("/")
|
||||
AGENT_TOKEN = os.getenv("ASR_AGENT_TOKEN", "")
|
||||
DIARIZE_URL = os.getenv("ASR_DIARIZE_URL", "http://192.168.50.100:10099/asr")
|
||||
MAX_UPLOAD = int(os.getenv("ASR_UPLOAD_MAX_BYTES", str(4 * 1024**3)))
|
||||
POLL_SECONDS = max(1, int(os.getenv("ASR_POLL_SECONDS", "5")))
|
||||
STABLE_SECONDS = max(1, int(os.getenv("ASR_STABLE_SECONDS", "10")))
|
||||
MAX_ATTEMPTS = max(1, int(os.getenv("ASR_MAX_ATTEMPTS", "3")))
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
for path in (INBOX, PROCESSING, PROCESSED, FAILED, TRANSCRIPTS, DRAFTS,
|
||||
RESULTS, STATE, LOGS, META, REVISION_QUEUE, DATA / "cache",
|
||||
DATA / "artifacts" / "raw", DATA / "vault" / "minutes",
|
||||
DATA / "vault" / "People", DATA / "vault" / "Companies"):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .config import DB, ensure_dirs
|
||||
|
||||
STATUSES = {"uploading", "queued", "transcribing", "refining", "review",
|
||||
"publishing", "complete", "failed"}
|
||||
|
||||
|
||||
def connect() -> sqlite3.Connection:
|
||||
ensure_dirs()
|
||||
con = sqlite3.connect(DB, timeout=30)
|
||||
con.row_factory = sqlite3.Row
|
||||
con.execute("PRAGMA journal_mode=WAL")
|
||||
con.execute("PRAGMA foreign_keys=ON")
|
||||
con.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS intake_job(
|
||||
id TEXT PRIMARY KEY, original_name TEXT NOT NULL, stored_name TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '', context TEXT NOT NULL DEFAULT '',
|
||||
size INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '', transcript TEXT NOT NULL DEFAULT '',
|
||||
draft TEXT NOT NULL DEFAULT '', result TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'web', sha256 TEXT NOT NULL DEFAULT '',
|
||||
attempts INTEGER NOT NULL DEFAULT 0, error_code TEXT NOT NULL DEFAULT '',
|
||||
created_at REAL NOT NULL, updated_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS job_event(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id TEXT NOT NULL REFERENCES intake_job(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', created_at REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_job_event_job ON job_event(job_id,id);
|
||||
""")
|
||||
cols = {r[1] for r in con.execute("PRAGMA table_info(intake_job)")}
|
||||
additions = {
|
||||
"draft": "TEXT NOT NULL DEFAULT ''", "result": "TEXT NOT NULL DEFAULT ''",
|
||||
"source": "TEXT NOT NULL DEFAULT 'web'", "sha256": "TEXT NOT NULL DEFAULT ''",
|
||||
"attempts": "INTEGER NOT NULL DEFAULT 0", "error_code": "TEXT NOT NULL DEFAULT ''",
|
||||
}
|
||||
for name, ddl in additions.items():
|
||||
if name not in cols:
|
||||
con.execute(f"ALTER TABLE intake_job ADD COLUMN {name} {ddl}")
|
||||
con.commit()
|
||||
return con
|
||||
|
||||
|
||||
def create_job(job_id: str, original_name: str, stored_name: str, *, title: str = "",
|
||||
context: str = "", size: int = 0, source: str = "web") -> dict[str, Any]:
|
||||
now = time.time()
|
||||
with connect() as con:
|
||||
con.execute("INSERT INTO intake_job(id,original_name,stored_name,title,context,size,"
|
||||
"status,source,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||
(job_id, original_name, stored_name, title, context, size,
|
||||
"queued", source, now, now))
|
||||
con.execute("INSERT INTO job_event(job_id,status,detail,created_at) VALUES(?,?,?,?)",
|
||||
(job_id, "queued", "录音已进入队列", now))
|
||||
return get_job(job_id) or {}
|
||||
|
||||
|
||||
def update_job(job_id: str, status: str, detail: str = "", **fields: Any) -> bool:
|
||||
if status not in STATUSES:
|
||||
raise ValueError(f"invalid status: {status}")
|
||||
allowed = {"transcript", "draft", "result", "sha256", "attempts", "error_code"}
|
||||
changes = {k: v for k, v in fields.items() if k in allowed and v is not None}
|
||||
sets = ["status=?", "updated_at=?", "detail=?"]
|
||||
values: list[Any] = [status, time.time(), detail[:1000]]
|
||||
for key, value in changes.items():
|
||||
sets.append(f"{key}=?")
|
||||
values.append(value)
|
||||
values.append(job_id)
|
||||
with connect() as con:
|
||||
cur = con.execute(f"UPDATE intake_job SET {','.join(sets)} WHERE id=?", values)
|
||||
if cur.rowcount:
|
||||
con.execute("INSERT INTO job_event(job_id,status,detail,created_at) VALUES(?,?,?,?)",
|
||||
(job_id, status, detail[:1000], time.time()))
|
||||
return bool(cur.rowcount)
|
||||
|
||||
|
||||
def get_job(job_id: str) -> dict[str, Any] | None:
|
||||
with connect() as con:
|
||||
row = con.execute("SELECT * FROM intake_job WHERE id=?", (job_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def find_by_stored(stored_name: str) -> dict[str, Any] | None:
|
||||
with connect() as con:
|
||||
row = con.execute("SELECT * FROM intake_job WHERE stored_name=? ORDER BY created_at DESC LIMIT 1",
|
||||
(stored_name,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_jobs(limit: int = 30, offset: int = 0, status: str = "") -> list[dict[str, Any]]:
|
||||
limit = min(max(limit, 1), 200)
|
||||
offset = max(offset, 0)
|
||||
sql, args = "SELECT * FROM intake_job", []
|
||||
if status:
|
||||
sql += " WHERE status=?"
|
||||
args.append(status)
|
||||
sql += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
args.extend((limit, offset))
|
||||
with connect() as con:
|
||||
return [dict(r) for r in con.execute(sql, args).fetchall()]
|
||||
|
||||
|
||||
def events(job_id: str) -> list[dict[str, Any]]:
|
||||
with connect() as con:
|
||||
return [dict(r) for r in con.execute(
|
||||
"SELECT status,detail,created_at FROM job_event WHERE job_id=? ORDER BY id",
|
||||
(job_id,)).fetchall()]
|
||||
|
||||
|
||||
def pending_recovery() -> list[dict[str, Any]]:
|
||||
with connect() as con:
|
||||
return [dict(r) for r in con.execute(
|
||||
"SELECT * FROM intake_job WHERE status IN ('queued','transcribing','refining','publishing') "
|
||||
"ORDER BY created_at").fetchall()]
|
||||
@@ -0,0 +1,5 @@
|
||||
import time
|
||||
from .config import STATE
|
||||
|
||||
heartbeat = STATE / "worker.heartbeat"
|
||||
raise SystemExit(0 if heartbeat.exists() and time.time() - heartbeat.stat().st_mtime < 90 else 1)
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from urllib import parse, request
|
||||
|
||||
|
||||
def send(status: str, filename: str = "", detail: str = "") -> bool:
|
||||
backend = os.getenv("ASR_NOTIFY_BACKEND", "none").strip().lower()
|
||||
try:
|
||||
if backend == "telegram":
|
||||
token = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
chat = os.getenv("TELEGRAM_CHAT_ID", "")
|
||||
if not token or not chat:
|
||||
return False
|
||||
body = parse.urlencode({"chat_id": chat,
|
||||
"text": "\n".join(x for x in (status, filename, detail) if x)}).encode()
|
||||
req = request.Request(f"https://api.telegram.org/bot{token}/sendMessage",
|
||||
data=body, method="POST")
|
||||
elif backend == "hermes":
|
||||
url = os.getenv("HERMES_WEBHOOK_URL", "")
|
||||
secret = os.getenv("HERMES_WEBHOOK_SECRET", "")
|
||||
if not url or not secret:
|
||||
return False
|
||||
body = json.dumps({"status": status, "filename": filename, "detail": detail},
|
||||
ensure_ascii=False).encode()
|
||||
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
req = request.Request(url, data=body, method="POST",
|
||||
headers={"Content-Type": "application/json",
|
||||
"X-Webhook-Signature": sig})
|
||||
else:
|
||||
return False
|
||||
with request.urlopen(req, timeout=10) as response:
|
||||
return 200 <= response.status < 300
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib, hmac, html, json, os, re, time, urllib.parse, uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from asrpipe import review
|
||||
from . import db
|
||||
from .config import AGENT_TOKEN, DRAFTS, FAILED, INBOX, MAX_UPLOAD, PUBLIC_URL, RESULTS, STATE, SUPPORTED, TRANSCRIPTS, ensure_dirs
|
||||
|
||||
LABELS = {"queued":"等待处理","transcribing":"正在转写","refining":"正在整理","review":"等待确认","publishing":"正在定稿","complete":"已完成","failed":"处理失败"}
|
||||
|
||||
def safe(value, limit):
|
||||
return re.sub(r"\s+", " ", re.sub(r"[\x00-\x1f\x7f]+", " ", value or "")).strip()[:limit]
|
||||
|
||||
def stem(value):
|
||||
return re.sub(r"[^\w\u3400-\u9fff.-]+", "-", safe(value, 80)).strip("-.") or "录音"
|
||||
|
||||
def public(row, detailed=False):
|
||||
out = {k: row.get(k) for k in ("id","original_name","title","size","status","detail","source","sha256","attempts","error_code","created_at","updated_at")}
|
||||
out["status_label"] = LABELS.get(row["status"], row["status"])
|
||||
out["web_url"] = f"{PUBLIC_URL}/jobs/{row['id']}/"
|
||||
out["review_url"] = f"{PUBLIC_URL}/jobs/{row['id']}/review/"
|
||||
out["artifacts"] = {name:f"/api/v1/jobs/{row['id']}/artifacts/{name}" for name in ("transcript","draft","result") if row.get(name)}
|
||||
if detailed:
|
||||
out["context"] = row.get("context", "")
|
||||
out["events"] = db.events(row["id"])
|
||||
return out
|
||||
|
||||
def pending(job):
|
||||
found=[]
|
||||
for row in review.pending(1000):
|
||||
payload=json.loads(row["payload"] or "{}")
|
||||
if ((job.get("transcript") and row["recording"]==job["transcript"])
|
||||
or (job.get("draft") and payload.get("draft_path")==job["draft"])):
|
||||
found.append(row)
|
||||
return found
|
||||
|
||||
def shell(title, body, script=""):
|
||||
return f'''<!doctype html><html lang=zh-CN><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>{html.escape(title)}</title><style>
|
||||
:root{{--bg:#f4f6f8;--card:#fff;--ink:#18212b;--muted:#647180;--line:#d9dfe6;--accent:#176b5b;--bad:#b33a2b}}@media(prefers-color-scheme:dark){{:root{{--bg:#11161b;--card:#192128;--ink:#eef3f5;--muted:#a8b3bd;--line:#34414b;--accent:#63c7ae}}}}*{{box-sizing:border-box}}body{{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 system-ui,sans-serif}}header{{background:var(--card);border-bottom:1px solid var(--line)}}header div,main{{max-width:900px;margin:auto;padding:16px}}header div,.row{{display:flex;justify-content:space-between;gap:12px}}a{{color:var(--accent);text-decoration:none}}nav a{{margin-left:16px}}.card{{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:18px;margin:15px 0}}.job{{padding:13px 0;border-top:1px solid var(--line)}}.job:first-child{{border:0}}.muted{{color:var(--muted);font-size:14px}}.badge{{font-weight:700;color:var(--accent)}}.failed{{color:var(--bad)}}input,textarea,button{{font:inherit}}input,textarea{{width:100%;padding:10px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--ink)}}label{{display:block;margin:12px 0 5px;font-weight:650}}button{{border:0;border-radius:7px;padding:11px 16px;background:var(--accent);color:white;font-weight:700}}button.alt{{background:#68747f}}progress{{width:100%}}pre{{white-space:pre-wrap;overflow-wrap:anywhere;max-height:55vh;overflow:auto}}.timeline{{border-left:2px solid var(--line);padding-left:16px}}.choice{{display:block;width:100%;margin:7px 0}}@media(max-width:520px){{.row{{display:block}}}}</style></head><body><header><div><strong>ASR 录音工作台</strong><nav><a href=/>任务</a><a href=/docs>Agent API</a></nav></div></header><main>{body}</main>{script}</body></html>'''.encode()
|
||||
|
||||
def home():
|
||||
body='''<section class=card><h1>上传录音</h1><p class=muted>也可通过 SSH 上传到 tools-pve:/srv/asr/inbox,完成后自动处理。</p><form id=up><label>录音文件</label><input id=file type=file required><label>标题</label><input id=title maxlength=80><label>已知背景</label><textarea id=context maxlength=2000></textarea><p><button id=send>上传并处理</button></p><progress id=bar max=100 hidden></progress><div id=msg class=muted></div></form></section><section class=card><div class=row><h2>最近任务</h2><span class=muted>自动刷新</span></div><div id=jobs>读取中…</div></section>'''
|
||||
script='''<script>const E=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));async function load(){let d=await(await fetch('/ui/jobs')).json();jobs.innerHTML=d.jobs.length?d.jobs.map(j=>`<div class=job><div class=row><strong>${E(j.title||j.original_name)}</strong><span class="badge ${E(j.status)}">${E(j.status_label)}</span></div><div class=muted>${new Date(j.created_at*1000).toLocaleString()} · ${E(j.source)} · ${E(j.detail)}</div><a href="/jobs/${E(j.id)}/">查看任务</a></div>`).join(''):'暂无任务'}load();setInterval(load,8000);up.onsubmit=x=>{x.preventDefault();let f=file.files[0],q=new XMLHttpRequest();send.disabled=true;bar.hidden=false;msg.textContent='上传中…';q.open('POST','/ui/upload');q.setRequestHeader('X-Filename',encodeURIComponent(f.name));q.setRequestHeader('X-Title',encodeURIComponent(title.value));q.setRequestHeader('X-Context',encodeURIComponent(context.value));q.upload.onprogress=v=>{if(v.lengthComputable)bar.value=v.loaded/v.total*100};q.onload=()=>{send.disabled=false;bar.hidden=true;msg.textContent=q.status===201?'已进入队列':'上传失败';if(q.status===201){x.target.reset();load()}};q.send(f)};</script>'''
|
||||
return shell("ASR 录音工作台",body,script)
|
||||
|
||||
def job_page(job):
|
||||
events=''.join(f"<p><strong>{html.escape(LABELS.get(e['status'],e['status']))}</strong><br><span class=muted>{time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(e['created_at']))} · {html.escape(e['detail'])}</span></p>" for e in db.events(job["id"]))
|
||||
links=' · '.join(f'<a href="/jobs/{job["id"]}/artifact/{n}">{label}</a>' for n,label in (("transcript","转写"),("draft","草稿"),("result","正式稿")) if job.get(n))
|
||||
retry=f'<form method=post action="/jobs/{job["id"]}/retry"><button>重新处理</button></form>' if job["status"]=="failed" else ""
|
||||
review_link=f'<p><a href="/jobs/{job["id"]}/review/">继续人工确认</a></p>' if pending(job) else ""
|
||||
return shell("任务详情",f"<section class=card><div class=row><h1>{html.escape(job.get('title') or job['original_name'])}</h1><span class='badge {job['status']}'>{html.escape(LABELS.get(job['status'],job['status']))}</span></div><p>{html.escape(job.get('detail',''))}</p><p>{links}</p>{review_link}{retry}</section><section class=card><h2>处理时间线</h2><div class=timeline>{events}</div></section>")
|
||||
|
||||
def review_page(job):
|
||||
rows=pending(job)
|
||||
if not rows: return shell("人工确认","<section class=card><h1>当前没有待确认问题</h1><p><a href=../>返回任务</a></p></section>")
|
||||
row=rows[0]; payload=json.loads(row["payload"] or "{}"); choices=payload.get("candidates") or []
|
||||
buttons=''.join(f'<button class=choice name=answer value="{html.escape(str(v),quote=True)}">{html.escape(str(v))}</button>' for v in choices)+'<button class="choice alt" name=answer value="无法确认,保留原文">无法确认 / 保留原文</button>'
|
||||
draft=""; path=Path(str(payload.get("draft_path") or ""))
|
||||
try: path.resolve().relative_to(DRAFTS.resolve()); draft=path.read_text(encoding="utf-8")
|
||||
except (OSError,ValueError): pass
|
||||
return shell("人工确认",f"<section class=card><p class=muted>剩余 {len(rows)} 个问题</p><h1>{html.escape(row['question'])}</h1><p>{html.escape(str(payload.get('sentence') or ''))}</p><details><summary>查看草稿</summary><pre>{html.escape(draft)}</pre></details><form method=post><input type=hidden name=id value={row['id']}>{buttons}<label>自定义回答</label><input name=custom maxlength=120><p><button>提交</button></p></form></section>")
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version="ASRService/1"
|
||||
def log_message(self,*_): pass
|
||||
def send_data(self,data,status=200,ctype="text/html; charset=utf-8",disposition=""):
|
||||
self.send_response(status); self.send_header("Content-Type",ctype); self.send_header("Cache-Control","no-store"); self.send_header("X-Content-Type-Options","nosniff"); self.send_header("Content-Security-Policy","default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
|
||||
if disposition:self.send_header("Content-Disposition",disposition)
|
||||
self.send_header("Content-Length",str(len(data)));self.end_headers();self.wfile.write(data)
|
||||
def json(self,obj,status=200):self.send_data(json.dumps(obj,ensure_ascii=False).encode(),status,"application/json; charset=utf-8")
|
||||
def auth(self):
|
||||
value=self.headers.get("Authorization","")
|
||||
return bool(AGENT_TOKEN and value.startswith("Bearer ") and hmac.compare_digest(value[7:],AGENT_TOKEN))
|
||||
def need_auth(self):
|
||||
if self.auth():return True
|
||||
self.json({"error":"unauthorized"},401);return False
|
||||
def header(self,name,limit):return safe(urllib.parse.unquote(self.headers.get(name,"")),limit)
|
||||
def upload(self,source,protected):
|
||||
if protected and not self.auth():return self.json({"error":"unauthorized"},401)
|
||||
try:length=int(self.headers.get("Content-Length","0"))
|
||||
except ValueError:length=0
|
||||
name=self.header("X-Filename",240);suffix=Path(name).suffix.lower()
|
||||
if length<=0 or length>MAX_UPLOAD:return self.json({"error":"invalid size"},413)
|
||||
if suffix not in SUPPORTED:return self.json({"error":"unsupported type"},415)
|
||||
jid=uuid.uuid4().hex[:24]; title=self.header("X-Title",80); stored=f"{time.strftime('%Y-%m-%d_%H_%M_%S')}-{stem(title or Path(name).stem)}-{jid[:8]}{suffix}"; staging=INBOX/".uploading";staging.mkdir(parents=True,exist_ok=True);part=staging/f"{jid}.part";remaining=length;digest=hashlib.sha256()
|
||||
try:
|
||||
with part.open("xb") as out:
|
||||
while remaining:
|
||||
chunk=self.rfile.read(min(1024*1024,remaining))
|
||||
if not chunk:raise EOFError
|
||||
out.write(chunk);digest.update(chunk);remaining-=len(chunk)
|
||||
out.flush();os.fsync(out.fileno())
|
||||
job=db.create_job(jid,name,stored,title=title,context=self.header("X-Context",2000),size=length,source=source);db.update_job(jid,"queued","录音已落盘,等待 worker",sha256=digest.hexdigest());part.replace(INBOX/stored);self.json(public(job),201)
|
||||
except Exception:part.unlink(missing_ok=True);self.json({"error":"upload failed"},500)
|
||||
def artifact(self,job,name,api=False):
|
||||
if not job:return self.json({"error":"not found"},404)
|
||||
roots={"transcript":TRANSCRIPTS,"draft":DRAFTS,"result":RESULTS};path=Path(job.get(name) or "")
|
||||
try:path.resolve().relative_to(roots[name].resolve());data=path.read_bytes()
|
||||
except (OSError,ValueError):return self.json({"error":"not found"},404)
|
||||
self.send_data(data,200,"text/markdown; charset=utf-8",f'inline; filename="{path.name}"')
|
||||
def do_GET(self):
|
||||
path=urllib.parse.urlsplit(self.path).path
|
||||
if path in ("/health","/healthz"):return self.json({"ok":True,"service":"asr-web"})
|
||||
if path=="/readyz":
|
||||
try:db.connect().close();ok=(STATE/"worker.heartbeat").exists() and time.time()-(STATE/"worker.heartbeat").stat().st_mtime<90
|
||||
except Exception:ok=False
|
||||
return self.json({"ready":ok},200 if ok else 503)
|
||||
if path=="/":return self.send_data(home())
|
||||
if path=="/docs":return self.send_data(shell("Agent API","<section class=card><h1>Agent API</h1><p>使用 Bearer Token。POST /api/v1/jobs 的 body 是原始录音,设置 X-Filename、X-Title、X-Context;GET /api/v1/jobs 与 /api/v1/jobs/{id} 查询任务。</p></section>"))
|
||||
if path=="/ui/jobs":return self.json({"jobs":[public(x) for x in db.list_jobs()]})
|
||||
m=re.fullmatch(r"/jobs/([0-9a-f]{24})/",path)
|
||||
if m:
|
||||
job=db.get_job(m.group(1));return self.send_data(job_page(job)) if job else self.send_data(b"Not found",404,"text/plain")
|
||||
m=re.fullmatch(r"/jobs/([0-9a-f]{24})/review/",path)
|
||||
if m:
|
||||
job=db.get_job(m.group(1));return self.send_data(review_page(job)) if job else self.send_data(b"Not found",404,"text/plain")
|
||||
m=re.fullmatch(r"/jobs/([0-9a-f]{24})/artifact/(transcript|draft|result)",path)
|
||||
if m:return self.artifact(db.get_job(m.group(1)),m.group(2))
|
||||
if path=="/api/v1/jobs":
|
||||
if not self.need_auth():return
|
||||
q=urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query);return self.json({"jobs":[public(x) for x in db.list_jobs(int(q.get('limit',[30])[0]),int(q.get('offset',[0])[0]),q.get('status',[''])[0])]})
|
||||
m=re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})",path)
|
||||
if m:
|
||||
if not self.need_auth():return
|
||||
job=db.get_job(m.group(1));return self.json(public(job,True)) if job else self.json({"error":"not found"},404)
|
||||
m=re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/artifacts/(transcript|draft|result)",path)
|
||||
if m:
|
||||
if not self.need_auth():return
|
||||
return self.artifact(db.get_job(m.group(1)),m.group(2),True)
|
||||
m=re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/reviews",path)
|
||||
if m:
|
||||
if not self.need_auth():return
|
||||
job=db.get_job(m.group(1))
|
||||
if not job:return self.json({"error":"not found"},404)
|
||||
rows=[]
|
||||
for row in pending(job):
|
||||
payload=json.loads(row["payload"] or "{}")
|
||||
rows.append({"id":row["id"],"question":row["question"],
|
||||
"kind":payload.get("kind",""),
|
||||
"sentence":payload.get("sentence",""),
|
||||
"candidates":payload.get("candidates") or []})
|
||||
return self.json({"reviews":rows})
|
||||
self.send_data(b"Not found",404,"text/plain")
|
||||
def do_POST(self):
|
||||
path=urllib.parse.urlsplit(self.path).path
|
||||
if path=="/ui/upload":return self.upload("web",False)
|
||||
if path=="/api/v1/jobs":return self.upload("agent",True)
|
||||
m=re.fullmatch(r"/jobs/([0-9a-f]{24})/review/",path)
|
||||
if m:
|
||||
job=db.get_job(m.group(1));length=min(int(self.headers.get("Content-Length","0")),4096);form=urllib.parse.parse_qs(self.rfile.read(length).decode());qid=int(form.get("id",["0"])[0]);answer=(form.get("custom",[""])[0].strip() or form.get("answer",[""])[0].strip())
|
||||
if not job or qid not in {x["id"] for x in pending(job)} or not answer:return self.send_data(b"Invalid answer",400,"text/plain")
|
||||
review.answer(qid,answer[:120]);self.send_response(303);self.send_header("Location",path);self.end_headers();return
|
||||
m=re.fullmatch(r"/(?:api/v1/)?jobs/([0-9a-f]{24})/retry",path)
|
||||
if m:
|
||||
api=path.startswith("/api/")
|
||||
if api and not self.need_auth():return
|
||||
job=db.get_job(m.group(1));source=next((p for p in FAILED.iterdir() if job and p.name==job["stored_name"]),None)
|
||||
if not job or job["status"]!="failed" or not source:return self.json({"error":"cannot retry"},409)
|
||||
source.replace(INBOX/source.name);db.update_job(job["id"],"queued","重新排队",error_code="")
|
||||
if api:return self.json({"ok":True},202)
|
||||
self.send_response(303);self.send_header("Location",f"/jobs/{job['id']}/");self.end_headers();return
|
||||
m=re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/reviews/(\d+)",path)
|
||||
if m:
|
||||
if not self.need_auth():return
|
||||
job=db.get_job(m.group(1));length=min(int(self.headers.get("Content-Length","0")),4096)
|
||||
try:answer=str(json.loads(self.rfile.read(length)).get("answer") or "").strip()
|
||||
except (ValueError,AttributeError):answer=""
|
||||
qid=int(m.group(2))
|
||||
if not job or qid not in {x["id"] for x in pending(job)} or not answer:
|
||||
return self.json({"error":"invalid review answer"},400)
|
||||
return self.json(review.answer(qid,answer[:120]))
|
||||
self.send_data(b"Not found",404,"text/plain")
|
||||
|
||||
def main():
|
||||
ensure_dirs();db.connect().close();server=ThreadingHTTPServer(("0.0.0.0",8788),Handler);server.daemon_threads=True;server.serve_forever()
|
||||
if __name__=="__main__":main()
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from asrpipe import refine, review, revise, vault
|
||||
from . import db
|
||||
from .config import (DIARIZE_URL, DRAFTS, FAILED, INBOX, MAX_ATTEMPTS, META,
|
||||
POLL_SECONDS, PROCESSED, PROCESSING, PUBLIC_URL,
|
||||
REVISION_QUEUE, STABLE_SECONDS, STATE, SUPPORTED, TRANSCRIPTS, LOGS,
|
||||
ensure_dirs)
|
||||
from .notify import send
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
ensure_dirs()
|
||||
with (LOGS / "worker.log").open("a", encoding="utf-8") as stream:
|
||||
stream.write(time.strftime("[%Y-%m-%d %H:%M:%S] ") + message + "\n")
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def transcript_from_response(payload: dict) -> str:
|
||||
segments = payload.get("segments")
|
||||
if not isinstance(segments, list) or not segments:
|
||||
raise RuntimeError("ASR response contains no segments")
|
||||
vp = payload.get("voiceprint") or {}
|
||||
sid = str(vp.get("session_id") or "")
|
||||
matched = [f"{m.get('speaker_local')}={m.get('person_name')}" for m in vp.get("matches", [])
|
||||
if m.get("status") == "matched"]
|
||||
speakers = {str(s.get("speaker")) for s in segments if s.get("speaker") is not None}
|
||||
lines = [f"<!-- voiceprint_session: {sid} clusters: {len(speakers)} -->",
|
||||
"# 转写(Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹)"
|
||||
+ (" | 声纹命名: " + ", ".join(matched) if matched else ""), ""]
|
||||
turns: list[list[str]] = []
|
||||
for segment in segments:
|
||||
speaker, text = str(segment.get("speaker", "?")), str(segment.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if turns and turns[-1][0] == speaker:
|
||||
turns[-1][1] += text
|
||||
else:
|
||||
turns.append([speaker, text])
|
||||
lines.extend(f"【{speaker}】{text}\n" for speaker, text in turns)
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
|
||||
def transcribe(audio: Path, job_id: str) -> Path:
|
||||
response_path = TRANSCRIPTS / f"{job_id}.asr.json"
|
||||
result = subprocess.run([
|
||||
"curl", "-sS", "--fail-with-body", "--max-time", "900",
|
||||
"-F", f"file=@{audio}", "-F", "language=Chinese",
|
||||
"-F", "save_voiceprint_session=true", DIARIZE_URL,
|
||||
], capture_output=True, timeout=930)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"ASR HTTP failed ({result.returncode})")
|
||||
response_path.write_bytes(result.stdout)
|
||||
try:
|
||||
payload = json.loads(result.stdout)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError("ASR returned invalid JSON") from exc
|
||||
transcript = TRANSCRIPTS / f"{job_id}.txt"
|
||||
transcript.write_text(transcript_from_response(payload), encoding="utf-8")
|
||||
return transcript
|
||||
|
||||
|
||||
def process_audio(source: Path, job: dict) -> None:
|
||||
job_id = job["id"]
|
||||
attempts = int(job.get("attempts") or 0) + 1
|
||||
digest = sha256(source)
|
||||
db.update_job(job_id, "transcribing", "正在进行分角色转写", sha256=digest,
|
||||
attempts=attempts, error_code="")
|
||||
transcript = transcribe(source, job_id)
|
||||
intake = {"job_id": job_id, "title": job.get("title", ""),
|
||||
"context": job.get("context", ""), "source": job.get("source", "")}
|
||||
raw = transcript.read_text(encoding="utf-8")
|
||||
raw = f"<!-- intake: {json.dumps(intake, ensure_ascii=False)} -->\n" + raw
|
||||
transcript.write_text(raw, encoding="utf-8")
|
||||
db.update_job(job_id, "refining", "转写完成,正在生成整理稿", transcript=str(transcript))
|
||||
result = refine.refine(raw, date=time.strftime("%Y-%m-%d"),
|
||||
transcript_path=str(transcript), queue_review=True)
|
||||
draft = DRAFTS / f"{job_id}.md"
|
||||
vault.write_draft_file(draft, result.draft)
|
||||
questions = review.enqueue_draft(str(transcript), draft)
|
||||
db.update_job(job_id, "review", f"整理稿已生成,{questions} 个问题待确认",
|
||||
transcript=str(transcript), draft=str(draft))
|
||||
target = PROCESSED / source.name
|
||||
if target.exists():
|
||||
target = PROCESSED / f"{source.stem}-{job_id[:8]}{source.suffix}"
|
||||
shutil.move(str(source), target)
|
||||
send("ASR 草稿待确认", job.get("title") or job["original_name"],
|
||||
f"{PUBLIC_URL}/jobs/{job_id}/review/")
|
||||
|
||||
|
||||
def claim(path: Path) -> tuple[Path, dict]:
|
||||
job = db.find_by_stored(path.name)
|
||||
if not job:
|
||||
job_id = uuid.uuid4().hex[:24]
|
||||
job = db.create_job(job_id, path.name, path.name, title=path.stem,
|
||||
size=path.stat().st_size, source="ssh")
|
||||
target = PROCESSING / path.name
|
||||
if target.exists():
|
||||
target = PROCESSING / f"{path.stem}-{job['id'][:8]}{path.suffix}"
|
||||
path.replace(target)
|
||||
return target, job
|
||||
|
||||
|
||||
def scan_inbox() -> None:
|
||||
now = time.time()
|
||||
for path in sorted(INBOX.iterdir()):
|
||||
if not path.is_file() or path.suffix.lower() not in SUPPORTED:
|
||||
continue
|
||||
if now - path.stat().st_mtime < STABLE_SECONDS:
|
||||
continue
|
||||
source, job = claim(path)
|
||||
try:
|
||||
process_audio(source, job)
|
||||
except Exception as exc:
|
||||
attempts = int((db.get_job(job["id"]) or {}).get("attempts") or 0)
|
||||
retry = attempts < MAX_ATTEMPTS
|
||||
db.update_job(job["id"], "queued" if retry else "failed",
|
||||
"处理失败,等待重试" if retry else "处理失败",
|
||||
error_code=type(exc).__name__)
|
||||
log(f"job={job['id']} failed type={type(exc).__name__}")
|
||||
target = INBOX / source.name if retry else FAILED / source.name
|
||||
if source.exists():
|
||||
shutil.move(str(source), target)
|
||||
|
||||
|
||||
def process_revisions() -> None:
|
||||
for marker in sorted(REVISION_QUEUE.glob("*.json")):
|
||||
try:
|
||||
payload = json.loads(marker.read_text(encoding="utf-8"))
|
||||
draft = Path(payload["draft"])
|
||||
job_id = str(payload.get("job_id") or "")
|
||||
db.update_job(job_id, "publishing", "正在应用人工确认")
|
||||
revise.revise(draft)
|
||||
current = db.get_job(job_id) or {}
|
||||
if current.get("status") == "complete":
|
||||
send("ASR 正式记录已生成", current.get("title") or current.get("original_name", ""),
|
||||
f"{PUBLIC_URL}/jobs/{job_id}/")
|
||||
elif current.get("status") != "review":
|
||||
db.update_job(job_id, "review", "定稿预览已生成,等待发布确认", draft=str(draft))
|
||||
marker.unlink(missing_ok=True)
|
||||
except Exception as exc:
|
||||
log(f"revision marker={marker.name} failed type={type(exc).__name__}")
|
||||
|
||||
|
||||
def recover() -> None:
|
||||
for job in db.pending_recovery():
|
||||
if job["status"] in {"transcribing", "refining"}:
|
||||
candidate = next((p for p in PROCESSING.iterdir() if p.name == job["stored_name"]), None)
|
||||
if candidate:
|
||||
shutil.move(str(candidate), INBOX / candidate.name)
|
||||
db.update_job(job["id"], "queued", "worker 重启后重新排队")
|
||||
|
||||
|
||||
def backup_databases() -> None:
|
||||
# STATE is local disk; durable copies belong on the NFS data volume.
|
||||
backup_dir = Path("/data/backups")
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name in ("intake.db", "lexicon.db"):
|
||||
source = STATE / name
|
||||
if not source.exists():
|
||||
continue
|
||||
target = backup_dir / f"{name}.latest"
|
||||
temp = backup_dir / f".{name}.{os.getpid()}.tmp"
|
||||
with sqlite3.connect(source) as src, sqlite3.connect(temp) as dst:
|
||||
src.backup(dst)
|
||||
temp.replace(target)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ensure_dirs()
|
||||
lock = (STATE / "worker.lock").open("w")
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
recover()
|
||||
(STATE / "worker.heartbeat").write_text(str(time.time()))
|
||||
last_backup = 0.0
|
||||
while True:
|
||||
scan_inbox()
|
||||
process_revisions()
|
||||
if time.time() - last_backup >= 3600:
|
||||
backup_databases()
|
||||
last_backup = time.time()
|
||||
(STATE / "worker.heartbeat").write_text(str(time.time()))
|
||||
time.sleep(POLL_SECONDS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,50 @@
|
||||
services:
|
||||
asr-web:
|
||||
image: tools-asr-pipeline:2026-09-18
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: tools-asr-web
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
env_file:
|
||||
- /opt/asr-pipeline/.env
|
||||
environment:
|
||||
ASR_ROLE: web
|
||||
ports:
|
||||
- "192.168.50.49:8788:8788"
|
||||
volumes:
|
||||
- /mnt/truenas/personal/ASR-Pipeline:/data
|
||||
- /opt/asr-pipeline/state:/state
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8788/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
asr-worker:
|
||||
image: tools-asr-pipeline:2026-09-18
|
||||
pull_policy: never
|
||||
container_name: tools-asr-worker
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
env_file:
|
||||
- /opt/asr-pipeline/.env
|
||||
environment:
|
||||
ASR_ROLE: worker
|
||||
command: ["python", "-m", "asrservice.worker"]
|
||||
volumes:
|
||||
- /mnt/truenas/personal/ASR-Pipeline:/data
|
||||
- /opt/asr-pipeline/state:/state
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-m", "asrservice.healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -0,0 +1,3 @@
|
||||
pypinyin==0.55.0
|
||||
rapidfuzz==3.14.1
|
||||
numpy==2.3.3
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"suffixes": [
|
||||
"单抗",
|
||||
"珠单抗",
|
||||
"尤单抗",
|
||||
"利珠单抗",
|
||||
"妥珠单抗",
|
||||
"西单抗",
|
||||
"木单抗",
|
||||
"库单抗",
|
||||
"替尼",
|
||||
"布替尼",
|
||||
"沙替尼",
|
||||
"래替尼",
|
||||
"洛替尼",
|
||||
"非替尼",
|
||||
"西普",
|
||||
"普肽",
|
||||
"格鲁肽",
|
||||
"鲁肽",
|
||||
"那肽",
|
||||
"他滨",
|
||||
"他赛",
|
||||
"紫杉醇",
|
||||
"沙星",
|
||||
"霉素",
|
||||
"环素",
|
||||
"西林",
|
||||
"唑胺",
|
||||
"磺胺",
|
||||
"干扰素",
|
||||
"白介素",
|
||||
"促红素",
|
||||
"生长素",
|
||||
"酶",
|
||||
"疫苗",
|
||||
"佐剂"
|
||||
],
|
||||
"kinds": {
|
||||
"单抗": "抗体药",
|
||||
"替尼": "小分子抑制剂",
|
||||
"肽": "多肽药",
|
||||
"疫苗": "疫苗"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ServiceTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.tmp = tempfile.TemporaryDirectory()
|
||||
os.environ["ASR_DATA_DIR"] = str(Path(cls.tmp.name) / "data")
|
||||
os.environ["ASR_STATE_DIR"] = str(Path(cls.tmp.name) / "state")
|
||||
from asrservice import config, db, worker
|
||||
cls.config, cls.db, cls.worker = config, db, worker
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.tmp.cleanup()
|
||||
|
||||
def test_job_events_follow_updates(self):
|
||||
row = self.db.create_job("a" * 24, "x.m4a", "x.m4a", source="ssh")
|
||||
self.assertEqual(row["status"], "queued")
|
||||
self.db.update_job(row["id"], "transcribing", "started", attempts=1)
|
||||
current = self.db.get_job(row["id"])
|
||||
self.assertEqual(current["attempts"], 1)
|
||||
self.assertEqual([e["status"] for e in self.db.events(row["id"])],
|
||||
["queued", "transcribing"])
|
||||
|
||||
def test_transcript_contract(self):
|
||||
text = self.worker.transcript_from_response({
|
||||
"segments": [
|
||||
{"speaker": "speaker_0", "text": "你好。"},
|
||||
{"speaker": "speaker_0", "text": "继续。"},
|
||||
{"speaker": "speaker_1", "text": "收到。"},
|
||||
],
|
||||
"voiceprint": {"session_id": "s1", "matches": []},
|
||||
})
|
||||
self.assertIn("voiceprint_session: s1", text)
|
||||
self.assertIn("【speaker_0】你好。继续。", text)
|
||||
self.assertEqual(text.count("【speaker_0】"), 1)
|
||||
|
||||
def test_transcript_rejects_empty_segments(self):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.worker.transcript_from_response({"segments": []})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user