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""" {html.escape(title)} · Meeting ASR
ASR 录音工作台
{body}
{script}""".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'' ) return "".join(opts) def login_page(err: str = "") -> bytes: alert = f'

{html.escape(err)}

' if err else "" body = f"""

访问验证

请输入工作台访问口令。

{alert}

""" return shell("登录", body) def home() -> bytes: options = _provider_options(DEFAULT_PROVIDER) body = f"""

上传会议 / 通话录音

支持 m4a、mp3、wav、mp4、aac、ogg、opus、webm、flac。支持内网 Qwen3-ASR+CAM++ 及线上 Soniox / Gemini 3.5 / OpenAI / Grok。

最近任务

读取中…
""" 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"

{html.escape(LABELS.get(e['status'], e['status']))} " f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(e['created_at']))}" f"
{html.escape(e['detail'])}

" for e in db.events(jid) ) links = " · ".join( f'下载{label} (.md/.txt)' for n, label in (("result", "正式纪要"), ("draft", "精修草稿"), ("transcript", "原始转写")) if job.get(n) ) audio_html = ( f'' if find_audio_file(job) else '

原音频文件不在活动目录

' ) pend = pending(job) review_banner = ( f'
当前有 {len(pend)} 个问题待你确认 · ' f'立即前往确认页面 →
' if pend else "" ) options = _provider_options(job.get("asr_provider") or DEFAULT_PROVIDER) retry_html = f"""
切换 ASR 模型重跑 / 重新处理该录音

""" 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"""
在线微调 {label} Markdown

""" sections.append( f'
' f'{label}
{html.escape(content)}
{edit_form}
' ) prov_tag = f"{job.get('asr_provider') or DEFAULT_PROVIDER}" if job.get("asr_model"): prov_tag += f":{job['asr_model']}" body = f"""

{html.escape(job.get('title') or job['original_name'])}

ASR: {html.escape(prov_tag)} 来源: {html.escape(str(job.get('source') or 'web'))} 尝试: {int(job.get('attempts') or 0)} 次
{html.escape(LABELS.get(job['status'], job['status']))}

{html.escape(job.get('detail', ''))}

{review_banner} {audio_html}

{links}

{retry_html}
{''.join(sections)}

处理时间线

{events_html}
""" 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'

当前没有待确认问题

' f'

所有已入队的确认项均已处理完毕。

' f'

← 返回任务详情

', ) row = rows[0] payload = json.loads(row["payload"] or "{}") choices = payload.get("candidates") or [] buttons = "".join( f'' for v in choices ) buttons += ( '' ) 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'
{html.escape(sentence)}
' if sentence else "" draft_html = ( f'
' f'展开查看完整草稿
{html.escape(draft)}
' if draft else "" ) body = f"""
{html.escape(title_str)} · 剩余 {len(rows)} 个问题 · #{row['id']} 查看任务详情

{html.escape(row['question'])}

{source_html}{draft_html}
{buttons}
""" 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"{html.escape(alias_key)}{html.escape(canon)}" f"{html.escape(kind)}{html.escape(origin)}" f"{'已确认' if confirmed else '候选'}" ) notice = f'
{html.escape(msg)}
' if msg else "" table_html = ( "" "" + "".join(learned_rows[:120]) + "
简称 / 误识别词规范全名类型来源状态
" if learned_rows else '

暂无人工新增映射

' ) body = f"""{notice}

实体与纠错词库 (SQLite + Obsidian)

实体库:{st.get('entities', 0)} 个实体({st.get('with_company', 0)} 含公司归属), {st.get('aliases', 0)} 条别名(卡片 {st.get('aliases_card', 0)} + 学习/术语表 {st.get('aliases_learned', 0)}), {st.get('corrections', 0)} 条发音纠错;关系图谱:{gst.get('relations', 0)} 条关系, {gst.get('documents', 0)} 篇已索引纪要。

新增确定性别名 / ASR 纠错映射

已学习的别名与发音纠错(前 120 条)

{table_html}
""" 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 = '就绪' if p["ready"] else '未配置密钥' prov_rows.append( f"{html.escape(p['id'])}" f"{html.escape(p['label'])}" f"{html.escape(p['model'])}" f"{html.escape(p['via'])}" f"{badge}" ) 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"""

系统状态与配置

Worker 状态{html.escape(hb_text)}
默认 ASR 通道{html.escape(DEFAULT_PROVIDER)}
后处理 LLM 网关 (LiteLLM){html.escape(CFG.endpoint)}
S1c/S2/S3 清洗与关键词模型{html.escape(CFG.clean_model)}
S4/Revise 纪要成稿模型{html.escape(CFG.draft_model)}
Hindsight 记忆服务{html.escape(CFG.hindsight_api)} (bank: {html.escape(CFG.hindsight_bank)}) · {'在线' if hs_ok else '离线或未连接'}
Obsidian Vault 路径{html.escape(str(CFG.vault))} · {'已挂载' if vault_ok else '未挂载'} · Git 自动同步: {'开启' if CFG.git_sync else '关闭'}

语音识别模型 (ASR Providers)

{''.join(prov_rows)}
ID名称默认型号接入方式状态
""" 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", "

Agent API

" "

所有 /api/v1/* 接口使用 Authorization: Bearer $ASR_AGENT_TOKEN。

" "
", )) 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()