Files
infra-docker-configs/servers/tools-pve/asr-pipeline/asrservice/web.py
T
Codex 2367c5eb4e
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s
feat(tools-pve/asr-pipeline): multi-backend ASR web app with LiteLLM routing
2026-11-01 22:07:57 -08:00

956 lines
44 KiB
Python

from __future__ import annotations
import hashlib
import hmac
import html
import json
import mimetypes
import os
import re
import shutil
import threading
import time
import urllib.parse
import urllib.request
import uuid
from http import cookies
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from asrpipe import graph, lexicon, providers, review, vault
from asrpipe.config import CFG
from . import db
from .config import (
AGENT_TOKEN, DEFAULT_PROVIDER, DRAFTS, FAILED, INBOX, MAX_UPLOAD,
PROCESSED, PROCESSING, PUBLIC_URL, RESULTS, REVISION_QUEUE, STATE,
SUPPORTED, TRANSCRIPTS, WEB_PASSWORD, ensure_dirs,
)
LABELS = {
"uploading": "上传中",
"queued": "等待处理",
"transcribing": "正在转写",
"refining": "正在整理",
"review": "等待确认",
"publishing": "正在定稿",
"complete": "已完成",
"failed": "处理失败",
}
def safe(value: str, limit: int) -> str:
return re.sub(
r"\s+", " ", re.sub(r"[\x00-\x1f\x7f]+", " ", value or "")
).strip()[:limit]
def stem(value: str) -> str:
return (
re.sub(r"[^\w\u3400-\u9fff.-]+", "-", safe(value, 80)).strip("-.")
or "录音"
)
def find_audio_file(job: dict) -> Path | None:
stored = str(job.get("stored_name") or "")
if not stored:
return None
jid = str(job.get("id") or "")[:8]
base_stem = Path(stored).stem
suffix = Path(stored).suffix
for folder in (PROCESSED, PROCESSING, INBOX, FAILED):
if not folder.exists():
continue
direct = folder / stored
if direct.is_file():
return direct
if jid:
alt = folder / f"{base_stem}-{jid}{suffix}"
if alt.is_file():
return alt
return None
def public(row: dict, detailed: bool = False) -> dict:
keys = (
"id", "original_name", "title", "size", "status", "detail", "source",
"sha256", "attempts", "error_code", "asr_provider", "asr_model",
"created_at", "updated_at",
)
out = {k: row.get(k) for k in keys}
out["asr_provider"] = out.get("asr_provider") or DEFAULT_PROVIDER
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: dict | None = None) -> list[dict]:
if not job:
return review.pending(200)
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"])
or payload.get("job_id") == job["id"]
):
found.append(row)
return found
def _session_token() -> str:
secret = (WEB_PASSWORD or AGENT_TOKEN or "local").encode("utf-8")
return hmac.new(secret, b"asr-web-session-v1", hashlib.sha256).hexdigest()
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">
<title>{html.escape(title)} · Meeting ASR</title><style>
:root{{--bg:#f4f6f8;--card:#fff;--ink:#18212b;--muted:#647180;--line:#d9dfe6;
--accent:#176b5b;--accent-soft:#e8f3f0;--warn:#986b12;--bad:#b33a2b}}
@media(prefers-color-scheme:dark){{:root{{--bg:#11161b;--card:#192128;--ink:#eef3f5;
--muted:#9aa7b4;--line:#2e3a44;--accent:#5bc2a9;--accent-soft:#1d3531;--warn:#e0a84b;--bad:#e06c5f}}}}
*{{box-sizing:border-box}}body{{margin:0;background:var(--bg);color:var(--ink);
font:15px/1.58 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}}
header{{background:var(--card);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:10}}
header .bar,main{{max-width:960px;margin:auto;padding:14px 16px}}
header .bar,.row{{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}}
a{{color:var(--accent);text-decoration:none;font-weight:600}}a:hover{{text-decoration:underline}}
nav{{display:flex;gap:16px;flex-wrap:wrap}}
.card{{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:18px;margin:14px 0}}
h1{{font-size:21px;margin:0 0 10px}}h2{{font-size:17px;margin:0 0 10px}}
.job{{padding:13px 0;border-top:1px solid var(--line)}}.job:first-child{{border:0}}
.muted{{color:var(--muted);font-size:13.5px}}
.badge{{display:inline-block;padding:2px 8px;border-radius:99px;font-size:12.5px;font-weight:700;
background:var(--accent-soft);color:var(--accent)}}
.badge.failed{{background:rgba(179,58,43,.14);color:var(--bad)}}
.badge.review{{background:rgba(152,107,18,.15);color:var(--warn)}}
.tag{{display:inline-block;padding:1px 7px;border:1px solid var(--line);border-radius:6px;
font-size:12px;color:var(--muted);margin-right:6px}}
input,textarea,select,button{{font:inherit}}
input,textarea,select{{width:100%;padding:10px 12px;border:1px solid var(--line);
border-radius:7px;background:var(--card);color:var(--ink)}}
textarea{{min-height:84px;resize:vertical}}
label{{display:block;margin:11px 0 4px;font-weight:650;font-size:14px}}
button{{border:0;border-radius:7px;padding:10px 16px;background:var(--accent);color:#fff;
font-weight:700;cursor:pointer}}button:disabled{{opacity:.55;cursor:not-allowed}}
button.alt{{background:transparent;color:var(--ink);border:1px solid var(--line)}}
progress{{width:100%;height:10px;margin-top:8px}}
pre{{white-space:pre-wrap;overflow-wrap:anywhere;max-height:58vh;overflow:auto;
background:var(--bg);padding:14px;border-radius:8px;border:1px solid var(--line);
font:13.5px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace}}
.timeline{{border-left:2px solid var(--line);padding-left:16px}}
.choice{{display:block;width:100%;margin:7px 0;text-align:left}}
.grid2{{display:grid;grid-template-columns:1fr 1fr;gap:12px}}
.source-box{{margin:12px 0;padding:12px;border-left:4px solid var(--warn);background:var(--bg);border-radius:4px}}
audio{{width:100%;margin:10px 0}}
table{{width:100%;border-collapse:collapse;font-size:14px}}
th,td{{padding:8px 10px;border-bottom:1px solid var(--line);text-align:left}}
@media(max-width:600px){{.grid2{{grid-template-columns:1fr}}}}
</style></head><body><header><div class="bar">
<strong><a href="/" style="color:inherit;text-decoration:none">ASR 录音工作台</a></strong>
<nav><a href="/">任务</a><a href="/review/">待确认</a><a href="/lexicon">实体词库</a>
<a href="/settings">状态与配置</a><a href="/docs">API</a></nav>
</div></header><main>{body}</main>{script}</body></html>""".encode("utf-8")
def _provider_options(selected: str = "") -> str:
sel = providers.normalize_provider_name(selected or DEFAULT_PROVIDER)
opts = []
for p in providers.provider_status():
pid = p["id"]
is_sel = " selected" if pid == sel else ""
status_note = "" if p["ready"] else " (待配置密钥)"
opts.append(
f'<option value="{html.escape(pid)}"{is_sel}>'
f'{html.escape(p["label"])}{status_note}</option>'
)
return "".join(opts)
def login_page(err: str = "") -> bytes:
alert = f'<p style="color:var(--bad);font-weight:700">{html.escape(err)}</p>' if err else ""
body = f"""<section class="card" style="max-width:420px;margin:40px auto">
<h1>访问验证</h1><p class="muted">请输入工作台访问口令。</p>{alert}
<form method="post" action="/login">
<label for="pw">访问口令</label>
<input id="pw" name="password" type="password" required autofocus>
<p style="margin-top:14px"><button type="submit" style="width:100%">登录</button></p>
</form></section>"""
return shell("登录", body)
def home() -> bytes:
options = _provider_options(DEFAULT_PROVIDER)
body = f"""<section class="card"><h1>上传会议 / 通话录音</h1>
<p class="muted">支持 m4a、mp3、wav、mp4、aac、ogg、opus、webm、flac。支持内网 Qwen3-ASR+CAM++ 及线上 Soniox / Gemini 3.5 / OpenAI / Grok。</p>
<form id="up">
<div class="grid2">
<div><label for="file">录音文件</label><input id="file" type="file" accept="audio/*,video/mp4" required></div>
<div><label for="provider">语音识别模型 (ASR Provider)</label><select id="provider">{options}</select></div>
</div>
<div class="grid2">
<div><label for="title">标题(可选)</label><input id="title" maxlength="80" placeholder="例如:洛启生物尽调通话"></div>
<div><label for="model">指定子型号(可选,留空用默认)</label><input id="model" maxlength="80" placeholder="留空自动使用所选通道默认型号"></div>
</div>
<label for="context">已知背景(可选)</label>
<textarea id="context" maxlength="2000" placeholder="参与人、公司、项目或重点关注问题。将单独标记为用户提供背景,不会混入转写原文,并自动用于 Soniox/OpenAI 热词增强。"></textarea>
<p style="margin-top:14px"><button id="send" type="submit">上传并开始处理</button></p>
<progress id="bar" max="100" value="0" hidden></progress>
<div id="msg" class="muted"></div></form></section>
<section class="card"><div class="row"><h2>最近任务</h2>
<div><select id="filter" style="width:auto;padding:5px 10px">
<option value="">全部状态</option><option value="review">等待确认</option>
<option value="transcribing">正在转写</option><option value="refining">正在整理</option>
<option value="complete">已完成</option><option value="failed">处理失败</option>
</select></div></div>
<div id="jobs">读取中…</div></section>"""
script = """<script>
const E=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const flt=document.querySelector('#filter');
async function load(){
const st=flt.value;
const r=await fetch('/ui/jobs'+(st?'?status='+encodeURIComponent(st):''),{cache:'no-store'});
const d=await r.json();
const box=document.querySelector('#jobs');
box.innerHTML=d.jobs.length?d.jobs.map(j=>`<div class="job"><div class="row">
<div><strong><a href="/jobs/${E(j.id)}/">${E(j.title||j.original_name)}</a></strong>
<span class="tag">${E(j.asr_provider||'qwen3cam')}${j.asr_model?':'+E(j.asr_model):''}</span></div>
<span class="badge ${E(j.status)}">${E(j.status_label)}</span></div>
<div class="muted">${new Date(j.created_at*1000).toLocaleString()} · ${E(j.original_name)} · ${E(j.detail||'')}</div>
<div style="margin-top:6px"><a href="/jobs/${E(j.id)}/">查看详情</a>
${j.status==='review'?` · <a href="/jobs/${E(j.id)}/review/">继续人工确认 →</a>`:''}</div>
</div>`).join(''):'<p class="muted">暂无任务</p>';
}
flt.onchange=load; load(); setInterval(load,8000);
document.querySelector('#up').onsubmit=x=>{
x.preventDefault();
const f=document.querySelector('#file').files[0]; if(!f)return;
const q=new XMLHttpRequest(), btn=document.querySelector('#send'), bar=document.querySelector('#bar'), msg=document.querySelector('#msg');
btn.disabled=true; bar.hidden=false; msg.textContent='正在上传…';
q.open('POST','/ui/upload');
q.setRequestHeader('X-Filename',encodeURIComponent(f.name));
q.setRequestHeader('X-Title',encodeURIComponent(document.querySelector('#title').value));
q.setRequestHeader('X-Context',encodeURIComponent(document.querySelector('#context').value));
q.setRequestHeader('X-ASR-Provider',encodeURIComponent(document.querySelector('#provider').value));
q.setRequestHeader('X-ASR-Model',encodeURIComponent(document.querySelector('#model').value));
q.upload.onprogress=v=>{if(v.lengthComputable)bar.value=Math.round(v.loaded/v.total*100)};
q.onload=()=>{
btn.disabled=false; bar.hidden=true;
if(q.status===201){msg.textContent='上传成功,已进入处理队列。';x.target.reset();load();}
else{msg.textContent='上传失败:'+q.responseText;}
};
q.onerror=()=>{btn.disabled=false;msg.textContent='网络中断,请重试';};
q.send(f);
};
</script>"""
return shell("任务工作台", body, script)
def _read_artifact_text(job: dict, name: str) -> str:
roots = {"transcript": TRANSCRIPTS, "draft": DRAFTS, "result": RESULTS}
raw_path = str(job.get(name) or "")
if not raw_path:
return ""
path = Path(raw_path)
allowed_roots = [roots[name].resolve(), CFG.minutes.resolve(), CFG.drafts.resolve()]
for root in allowed_roots:
try:
path.resolve().relative_to(root)
if path.is_file():
return path.read_text(encoding="utf-8", errors="ignore")
except (OSError, ValueError):
continue
return ""
def job_page(job: dict) -> bytes:
jid = job["id"]
events_html = "".join(
f"<p><strong>{html.escape(LABELS.get(e['status'], e['status']))}</strong> "
f"<span class='muted'>{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(e['created_at']))}</span>"
f"<br><span class='muted'>{html.escape(e['detail'])}</span></p>"
for e in db.events(jid)
)
links = " · ".join(
f'<a href="/jobs/{jid}/artifact/{n}">下载{label} (.md/.txt)</a>'
for n, label in (("result", "正式纪要"), ("draft", "精修草稿"), ("transcript", "原始转写"))
if job.get(n)
)
audio_html = (
f'<audio controls preload="metadata" src="/jobs/{jid}/audio"></audio>'
if find_audio_file(job)
else '<p class="muted">原音频文件不在活动目录</p>'
)
pend = pending(job)
review_banner = (
f'<div class="source-box"><strong>当前有 {len(pend)} 个问题待你确认</strong> · '
f'<a href="/jobs/{jid}/review/">立即前往确认页面 →</a></div>'
if pend else ""
)
options = _provider_options(job.get("asr_provider") or DEFAULT_PROVIDER)
retry_html = f"""<details style="margin-top:12px"><summary style="cursor:pointer;font-weight:650">
切换 ASR 模型重跑 / 重新处理该录音</summary>
<form method="post" action="/jobs/{jid}/retry" style="margin-top:10px">
<div class="grid2">
<div><label>选择 ASR 模型</label><select name="provider">{options}</select></div>
<div><label>指定子型号(可选)</label><input name="model" value="{html.escape(str(job.get('asr_model') or ''))}" placeholder="留空用默认"></div>
</div>
<p style="margin-top:10px"><button type="submit">重新排队转写与精修</button></p>
</form></details>"""
sections = []
for key, label in (("result", "正式纪要"), ("draft", "精修草稿"), ("transcript", "原始转写")):
content = _read_artifact_text(job, key)
if not content:
continue
open_attr = " open" if key in ("result", "draft") else ""
edit_form = ""
if key in ("draft", "result"):
edit_form = f"""<details style="margin-top:8px"><summary class="muted" style="cursor:pointer">在线微调 {label} Markdown</summary>
<form method="post" action="/jobs/{jid}/edit">
<input type="hidden" name="target" value="{key}">
<textarea name="content" style="min-height:260px;font-family:monospace">{html.escape(content)}</textarea>
<p><button type="submit">保存修改</button></p></form></details>"""
sections.append(
f'<section class="card"><details{open_attr}><summary style="font-weight:700;font-size:16px;cursor:pointer">'
f'{label}</summary><pre style="margin-top:12px">{html.escape(content)}</pre>{edit_form}</details></section>'
)
prov_tag = f"{job.get('asr_provider') or DEFAULT_PROVIDER}"
if job.get("asr_model"):
prov_tag += f":{job['asr_model']}"
body = f"""<section class="card"><div class="row">
<div><h1>{html.escape(job.get('title') or job['original_name'])}</h1>
<span class="tag">ASR: {html.escape(prov_tag)}</span>
<span class="tag">来源: {html.escape(str(job.get('source') or 'web'))}</span>
<span class="tag">尝试: {int(job.get('attempts') or 0)} 次</span></div>
<span class="badge {html.escape(job['status'])}">{html.escape(LABELS.get(job['status'], job['status']))}</span>
</div>
<p>{html.escape(job.get('detail', ''))}</p>
{review_banner}
{audio_html}
<p>{links}</p>
{retry_html}
</section>
{''.join(sections)}
<section class="card"><h2>处理时间线</h2><div class="timeline">{events_html}</div></section>"""
return shell("任务详情", body)
def review_page(job: dict | None = None) -> bytes:
rows = pending(job)
back_url = f"/jobs/{job['id']}/" if job else "/"
action_url = f"/jobs/{job['id']}/review/" if job else "/review/"
if not rows:
return shell(
"人工确认",
f'<section class="card"><h1>当前没有待确认问题</h1>'
f'<p class="muted">所有已入队的确认项均已处理完毕。</p>'
f'<p><a href="{html.escape(back_url)}">← 返回任务详情</a></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 alt" type="submit" name="answer" '
'value="无法确认,保留原文">都不是 / 无法确认(保留原文)</button>'
)
draft = ""
path = Path(str(payload.get("draft_path") or ""))
for root in (DRAFTS, CFG.drafts):
try:
path.resolve().relative_to(root.resolve())
if path.is_file():
draft = path.read_text(encoding="utf-8", errors="ignore")
break
except (OSError, ValueError):
continue
kind = str(payload.get("kind") or "")
placeholder = (
"只输入真实姓名,例如:高瑶"
if kind == "voiceprint"
else ("若背景偏差较大,请在此填写正确背景"
if kind == "background_check"
else "输入正确名称或修改说明")
)
title_str = (job.get("title") or job["original_name"]) if job else "全局待确认队列"
sentence = str(payload.get("sentence") or "")
source_html = f'<div class="source-box">{html.escape(sentence)}</div>' if sentence else ""
draft_html = (
f'<details style="margin:12px 0"><summary style="cursor:pointer;font-weight:650">'
f'展开查看完整草稿</summary><pre style="margin-top:8px">{html.escape(draft)}</pre></details>'
if draft else ""
)
body = f"""<section class="card">
<div class="row"><span class="muted">{html.escape(title_str)} · 剩余 {len(rows)} 个问题 · #{row['id']}</span>
<a href="{html.escape(back_url)}">查看任务详情</a></div>
<h1 style="margin-top:8px">{html.escape(row['question'])}</h1>
{source_html}{draft_html}
<form method="post" action="{html.escape(action_url)}">
<input type="hidden" name="id" value="{row['id']}">
{buttons}
<label>自定义回答 / 补充说明</label>
<div class="row" style="flex-wrap:nowrap">
<input name="custom" maxlength="120" autocomplete="off" placeholder="{html.escape(placeholder, quote=True)}">
<button type="submit" style="white-space:nowrap">提交</button>
</div>
</form></section>"""
return shell("人工确认", body)
def lexicon_page(msg: str = "") -> bytes:
try:
st = lexicon.stats()
except Exception:
st = {"entities": 0, "with_company": 0, "with_related": 0,
"aliases": 0, "aliases_card": 0, "aliases_learned": 0, "corrections": 0}
try:
gst = graph.stats()
except Exception:
gst = {"relations": 0, "interactions": 0, "documents": 0}
am = lexicon.alias_map()
learned_rows = []
for alias_key, targets in sorted(am.items()):
for canon, kind, origin, confirmed in targets:
if origin != "card":
learned_rows.append(
f"<tr><td>{html.escape(alias_key)}</td><td><strong>{html.escape(canon)}</strong></td>"
f"<td>{html.escape(kind)}</td><td>{html.escape(origin)}</td>"
f"<td>{'已确认' if confirmed else '候选'}</td></tr>"
)
notice = f'<div class="source-box">{html.escape(msg)}</div>' if msg else ""
table_html = (
"<table><thead><tr><th>简称 / 误识别词</th><th>规范全名</th><th>类型</th>"
"<th>来源</th><th>状态</th></tr></thead><tbody>"
+ "".join(learned_rows[:120]) + "</tbody></table>"
if learned_rows else '<p class="muted">暂无人工新增映射</p>'
)
body = f"""{notice}<section class="card">
<div class="row"><h1>实体与纠错词库 (SQLite + Obsidian)</h1>
<form method="post" action="/lexicon/sync"><button type="submit" class="alt">从 Obsidian Vault 同步卡片与图谱</button></form></div>
<p class="muted">实体库:<strong>{st.get('entities', 0)}</strong> 个实体({st.get('with_company', 0)} 含公司归属),
<strong>{st.get('aliases', 0)}</strong> 条别名(卡片 {st.get('aliases_card', 0)} + 学习/术语表 {st.get('aliases_learned', 0)}),
<strong>{st.get('corrections', 0)}</strong> 条发音纠错;关系图谱:<strong>{gst.get('relations', 0)}</strong> 条关系,
<strong>{gst.get('documents', 0)}</strong> 篇已索引纪要。</p>
<h2>新增确定性别名 / ASR 纠错映射</h2>
<form method="post" action="/lexicon/learn">
<div class="grid2">
<div><label>口语简称或 ASR 误识别词</label><input name="alias" required maxlength="40" placeholder="例如:老万 / 张泰海"></div>
<div><label>对应规范全名</label><input name="canonical" required maxlength="40" placeholder="例如:万亚坤 / 张成海"></div>
</div>
<label>备注(可选)</label><input name="note" maxlength="120" placeholder="例如:洛启生物实控人">
<p style="margin-top:12px"><button type="submit">保存并立即生效</button></p>
</form></section>
<section class="card"><h2>已学习的别名与发音纠错(前 120 条)</h2>{table_html}</section>"""
return shell("实体词库", body)
def settings_page() -> bytes:
hb_path = STATE / "worker.heartbeat"
hb_ok = hb_path.exists() and (time.time() - hb_path.stat().st_mtime < 90)
hb_text = "运行正常 (Ready)" if hb_ok else "未检测到近期心跳 (Standby / 独立模式)"
prov_rows = []
for p in providers.provider_status():
badge = '<span class="badge">就绪</span>' if p["ready"] else '<span class="badge failed">未配置密钥</span>'
prov_rows.append(
f"<tr><td><strong>{html.escape(p['id'])}</strong></td>"
f"<td>{html.escape(p['label'])}</td>"
f"<td><code>{html.escape(p['model'])}</code></td>"
f"<td>{html.escape(p['via'])}</td>"
f"<td>{badge}</td></tr>"
)
hs_ok = False
if CFG.use_hindsight and CFG.hindsight_api:
try:
with urllib.request.urlopen(f"{CFG.hindsight_api}/health", timeout=3) as r:
hs_ok = r.status == 200
except Exception:
hs_ok = False
vault_ok = CFG.vault.is_dir()
body = f"""<section class="card"><h1>系统状态与配置</h1>
<table><tbody>
<tr><th>Worker 状态</th><td>{html.escape(hb_text)}</td></tr>
<tr><th>默认 ASR 通道</th><td><code>{html.escape(DEFAULT_PROVIDER)}</code></td></tr>
<tr><th>后处理 LLM 网关 (LiteLLM)</th><td><code>{html.escape(CFG.endpoint)}</code></td></tr>
<tr><th>S1c/S2/S3 清洗与关键词模型</th><td><code>{html.escape(CFG.clean_model)}</code></td></tr>
<tr><th>S4/Revise 纪要成稿模型</th><td><code>{html.escape(CFG.draft_model)}</code></td></tr>
<tr><th>Hindsight 记忆服务</th><td><code>{html.escape(CFG.hindsight_api)}</code> (bank: <code>{html.escape(CFG.hindsight_bank)}</code>) · {'<span class="badge">在线</span>' if hs_ok else '<span class="badge review">离线或未连接</span>'}</td></tr>
<tr><th>Obsidian Vault 路径</th><td><code>{html.escape(str(CFG.vault))}</code> · {'<span class="badge">已挂载</span>' if vault_ok else '<span class="badge review">未挂载</span>'} · Git 自动同步: <code>{'开启' if CFG.git_sync else '关闭'}</code></td></tr>
</tbody></table></section>
<section class="card"><h2>语音识别模型 (ASR Providers)</h2>
<table><thead><tr><th>ID</th><th>名称</th><th>默认型号</th><th>接入方式</th><th>状态</th></tr></thead>
<tbody>{''.join(prov_rows)}</tbody></table></section>"""
return shell("系统状态与配置", body)
class Handler(BaseHTTPRequestHandler):
server_version = "ASRService/2"
def log_message(self, *_):
pass
def send_data(
self,
data: bytes,
status: int = 200,
ctype: str = "text/html; charset=utf-8",
disposition: str = "",
extra_headers: dict[str, str] | None = None,
) -> None:
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'; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'",
)
if disposition:
self.send_header("Content-Disposition", disposition)
if extra_headers:
for k, v in extra_headers.items():
self.send_header(k, v)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def json(self, obj: object, status: int = 200) -> None:
self.send_data(
json.dumps(obj, ensure_ascii=False).encode("utf-8"),
status,
"application/json; charset=utf-8",
)
def auth(self) -> bool:
value = self.headers.get("Authorization", "")
return bool(
AGENT_TOKEN
and value.startswith("Bearer ")
and hmac.compare_digest(value[7:], AGENT_TOKEN)
)
def web_authed(self) -> bool:
if not WEB_PASSWORD:
return True
if self.auth():
return True
raw_cookie = self.headers.get("Cookie", "")
jar = cookies.SimpleCookie()
try:
jar.load(raw_cookie)
except Exception:
return False
morsel = jar.get("asr_session")
return bool(morsel and hmac.compare_digest(morsel.value, _session_token()))
def need_web_auth(self) -> bool:
if self.web_authed():
return True
self.send_response(303)
self.send_header("Location", "/login")
self.end_headers()
return False
def need_auth(self) -> bool:
if self.auth():
return True
self.json({"error": "unauthorized"}, 401)
return False
def header(self, name: str, limit: int) -> str:
return safe(urllib.parse.unquote(self.headers.get(name, "")), limit)
def upload(self, source: str, protected: bool) -> None:
if protected and not self.auth():
return self.json({"error": "unauthorized"}, 401)
if not protected and not self.web_authed():
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)
prov = providers.normalize_provider_name(
self.header("X-ASR-Provider", 40) or DEFAULT_PROVIDER
)
model = self.header("X-ASR-Model", 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,
asr_provider=prov,
asr_model=model,
)
db.update_job(
jid, "queued", f"录音已落盘,等待 worker (ASR: {prov})",
sha256=digest.hexdigest(),
asr_provider=prov,
asr_model=model,
)
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: dict | None, name: str, api: bool = False) -> None:
if not job:
return self.json({"error": "not found"}, 404)
text = _read_artifact_text(job, name)
if not text:
return self.json({"error": "not found"}, 404)
path = Path(str(job.get(name) or f"{name}.md"))
self.send_data(
text.encode("utf-8"),
200,
"text/markdown; charset=utf-8",
f'inline; filename="{path.name}"',
)
def stream_audio(self, job: dict | None) -> None:
if not job:
return self.send_data(b"Not found", 404, "text/plain")
audio_path = find_audio_file(job)
if not audio_path or not audio_path.is_file():
return self.send_data(b"Audio not found", 404, "text/plain")
mime = mimetypes.guess_type(audio_path.name)[0] or "audio/mpeg"
size = audio_path.stat().st_size
range_hdr = self.headers.get("Range", "")
start, end = 0, size - 1
status = 200
if range_hdr.startswith("bytes="):
m = re.match(r"bytes=(\d*)-(\d*)", range_hdr)
if m:
if m.group(1):
start = int(m.group(1))
if m.group(2):
end = min(size - 1, int(m.group(2)))
if 0 <= start <= end < size:
status = 206
length = end - start + 1
self.send_response(status)
self.send_header("Content-Type", mime)
self.send_header("Accept-Ranges", "bytes")
if status == 206:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.send_header("Content-Length", str(length))
self.end_headers()
with audio_path.open("rb") as f:
f.seek(start)
rem = length
while rem > 0:
buf = f.read(min(256 * 1024, rem))
if not buf:
break
self.wfile.write(buf)
rem -= len(buf)
def do_GET(self) -> None:
parsed = urllib.parse.urlsplit(self.path)
path = parsed.path
if path in ("/health", "/healthz"):
return self.json({"ok": True, "service": "asr-web"})
if path == "/readyz":
try:
db.connect().close()
hb = STATE / "worker.heartbeat"
ok = hb.exists() and time.time() - hb.stat().st_mtime < 90
except Exception:
ok = False
return self.json({"ready": ok}, 200 if ok else 503)
if path == "/login":
return self.send_data(login_page())
if path == "/api/v1/providers":
if not self.need_auth():
return
return self.json({"providers": providers.provider_status()})
if path == "/api/v1/jobs":
if not self.need_auth():
return
q = urllib.parse.parse_qs(parsed.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})
# Web UI routes below (check optional web password)
if not self.need_web_auth():
return
if path == "/":
return self.send_data(home())
if path == "/review/":
return self.send_data(review_page(None))
if path == "/lexicon":
return self.send_data(lexicon_page())
if path == "/settings":
return self.send_data(settings_page())
if path == "/docs":
return self.send_data(shell(
"Agent API",
"<section class='card'><h1>Agent API</h1>"
"<p>所有 <code>/api/v1/*</code> 接口使用 <code>Authorization: Bearer $ASR_AGENT_TOKEN</code>。</p>"
"<ul><li><code>POST /api/v1/jobs</code>:二进制流上传录音,支持请求头 <code>X-Filename</code>、<code>X-Title</code>、<code>X-Context</code>、<code>X-ASR-Provider</code> (<code>qwen3cam|soniox|gemini|openai|grok</code>)、<code>X-ASR-Model</code>。</li>"
"<li><code>GET /api/v1/jobs</code> & <code>GET /api/v1/jobs/{id}</code>:查询任务与事件。</li>"
"<li><code>GET /api/v1/jobs/{id}/reviews</code> & <code>POST /api/v1/jobs/{id}/reviews/{qid}</code>:读取与提交人工确认。</li>"
"<li><code>POST /api/v1/jobs/{id}/retry</code>:支持传入 <code>{\"provider\":\"soniox\"}</code> 切换模型重试。</li>"
"<li><code>GET /api/v1/providers</code>:查询所有语音模型就绪状态。</li></ul></section>",
))
if path == "/ui/jobs":
q = urllib.parse.parse_qs(parsed.query)
st = q.get("status", [""])[0]
return self.json({"jobs": [public(x) for x in db.list_jobs(50, 0, st)]})
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})/audio", path)
if m:
return self.stream_audio(db.get_job(m.group(1)))
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))
self.send_data(b"Not found", 404, "text/plain")
def do_POST(self) -> None:
path = urllib.parse.urlsplit(self.path).path
if path == "/login":
length = min(int(self.headers.get("Content-Length", "0")), 4096)
form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8", errors="ignore"))
pw = form.get("password", [""])[0]
if WEB_PASSWORD and hmac.compare_digest(pw, WEB_PASSWORD):
self.send_response(303)
self.send_header(
"Set-Cookie",
f"asr_session={_session_token()}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000",
)
self.send_header("Location", "/")
self.end_headers()
return
return self.send_data(login_page("口令不正确"), 401)
if path == "/ui/upload":
return self.upload("web", False)
if path == "/api/v1/jobs":
return self.upload("agent", True)
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]))
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
if not api and not self.need_web_auth():
return
job = db.get_job(m.group(1))
if not job:
return self.json({"error": "not found"}, 404)
source = find_audio_file(job)
if not source or job["status"] in {"transcribing", "refining", "publishing"}:
return self.json({"error": "cannot retry"}, 409)
length = min(int(self.headers.get("Content-Length", "0")), 4096)
raw_body = self.rfile.read(length).decode("utf-8", errors="ignore") if length > 0 else ""
new_prov = job.get("asr_provider") or DEFAULT_PROVIDER
new_model = job.get("asr_model") or ""
if raw_body:
if api and raw_body.strip().startswith("{"):
try:
body_json = json.loads(raw_body)
if body_json.get("provider"):
new_prov = providers.normalize_provider_name(body_json["provider"])
if "model" in body_json:
new_model = safe(str(body_json.get("model") or ""), 80)
except ValueError:
pass
else:
form = urllib.parse.parse_qs(raw_body)
if form.get("provider"):
new_prov = providers.normalize_provider_name(form["provider"][0])
if "model" in form:
new_model = safe(form.get("model", [""])[0], 80)
dest = INBOX / job["stored_name"]
if source.resolve() != dest.resolve():
shutil.copyfile(source, dest) if source.parent == PROCESSED else source.replace(dest)
# Touch mtime slightly in the past so worker picks it up without extra wait
old_ts = time.time() - 60
os.utime(dest, (old_ts, old_ts))
db.update_job(
job["id"], "queued", f"重新排队 (ASR: {new_prov})",
error_code="", asr_provider=new_prov, asr_model=new_model,
)
if api:
return self.json({"ok": True, "asr_provider": new_prov, "asr_model": new_model}, 202)
self.send_response(303)
self.send_header("Location", f"/jobs/{job['id']}/")
self.end_headers()
return
if not self.need_web_auth():
return
if path == "/review/" or re.fullmatch(r"/jobs/([0-9a-f]{24})/review/", path):
m_job = re.fullmatch(r"/jobs/([0-9a-f]{24})/review/", path)
job = db.get_job(m_job.group(1)) if m_job else None
length = min(int(self.headers.get("Content-Length", "0")), 4096)
form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8", errors="ignore"))
qid = int(form.get("id", ["0"])[0])
answer = form.get("custom", [""])[0].strip() or form.get("answer", [""])[0].strip()
allowed_ids = {x["id"] for x in pending(job)}
if (m_job and not job) or qid not in allowed_ids 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_edit = re.fullmatch(r"/jobs/([0-9a-f]{24})/edit", path)
if m_edit:
job = db.get_job(m_edit.group(1))
if not job:
return self.send_data(b"Not found", 404, "text/plain")
length = min(int(self.headers.get("Content-Length", "0")), 2 * 1024 * 1024)
form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8", errors="ignore"))
target = form.get("target", [""])[0]
content = form.get("content", [""])[0]
if target in ("draft", "result") and job.get(target) and content.strip():
tpath = Path(str(job[target]))
vault.write_draft_file(tpath, content)
self.send_response(303)
self.send_header("Location", f"/jobs/{job['id']}/")
self.end_headers()
return
if path == "/lexicon/learn":
length = min(int(self.headers.get("Content-Length", "0")), 4096)
form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8", errors="ignore"))
alias_val = safe(form.get("alias", [""])[0], 40)
canon_val = safe(form.get("canonical", [""])[0], 40)
note_val = safe(form.get("note", [""])[0], 120)
if alias_val and canon_val:
lexicon.learn_alias(alias_val, canon_val, note=note_val)
lexicon.learn_correction(alias_val, canon_val, note=note_val)
return self.send_data(lexicon_page(f"已添加映射:{alias_val} → {canon_val}"))
return self.send_data(lexicon_page("请填写完整的别名与规范名"), 400)
if path == "/lexicon/sync":
l_res = lexicon.sync_from_vault(force=True)
g_res = graph.sync(force=True)
return self.send_data(lexicon_page(f"同步完成:词库 {l_res},图谱 {g_res}"))
self.send_data(b"Not found", 404, "text/plain")
def main() -> None:
os.environ.setdefault("ASR_ROLE", "web")
os.environ.setdefault("ASR_REVISION_QUEUE_DIR", str(REVISION_QUEUE))
ensure_dirs()
db.connect().close()
if os.environ.get("ASR_ROLE", "").lower() == "all":
from . import worker
t = threading.Thread(target=worker.run_loop, name="asr-worker", daemon=True)
t.start()
port = int(os.environ.get("ASR_PORT", os.environ.get("ASR_REVIEW_PORT", "8788")))
host = os.environ.get("ASR_HOST", "0.0.0.0")
server = ThreadingHTTPServer((host, port), Handler)
server.daemon_threads = True
server.serve_forever()
if __name__ == "__main__":
main()