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 typing import Any from asrpipe import graph, lexicon, providers, review, vault from asrpipe.config import CFG from . import db from .config import ( AGENT_TOKEN, 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": "处理失败", } KIND_LABELS = { "background_check": "背景核对", "voiceprint": "说话人身份", "draft_question": "正文存疑", "entity_card": "建卡建议", "draft_overall": "整体评估", "publish_approval": "定稿发布", "low_confidence": "专有名词确认", } 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 delete_job_and_artifacts( job_id: str, *, delete_files: bool = True, delete_result: bool = False, ) -> bool: job = db.delete_job(job_id) if not job: return False review.delete_for_job( transcript_path=str(job.get("transcript") or ""), draft_path=str(job.get("draft") or ""), job_id=job_id, ) for marker in REVISION_QUEUE.glob(f"*{job_id}*.json"): marker.unlink(missing_ok=True) if delete_files: audio = find_audio_file(job) if audio and audio.is_file(): audio.unlink(missing_ok=True) for p in ( TRANSCRIPTS / f"{job_id}.txt", TRANSCRIPTS / f"{job_id}.asr.json", DRAFTS / f"{job_id}.md", Path(str(job.get("transcript") or "")) if job.get("transcript") else None, Path(str(job.get("draft") or "")) if job.get("draft") else None, ): if p and p.is_file(): p.unlink(missing_ok=True) if delete_result and job.get("result"): rpath = Path(str(job["result"])) if rpath.is_file(): rpath.unlink(missing_ok=True) return True def queue_rerefine(job: dict) -> bool: if not job.get("transcript"): return False REVISION_QUEUE.mkdir(parents=True, exist_ok=True) jid = job["id"] marker = REVISION_QUEUE / f"rerefine-{jid}.json" tmp = REVISION_QUEUE / f".rerefine-{jid}.{os.getpid()}.tmp" tmp.write_text( json.dumps({"action": "rerefine", "job_id": jid, "draft": str(job.get("draft") or "")}, ensure_ascii=False), encoding="utf-8", ) tmp.replace(marker) db.update_job(jid, "refining", "已排队重新生成精修草稿(免重跑 ASR)") return True def queue_revise_draft(job: dict) -> bool: draft_str = str(job.get("draft") or "") if not draft_str: return False review._persist_draft_feedback(str(job.get("transcript") or ""), draft_str) return review._request_revision(draft_str) 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 CFG.asr_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["pending_reviews"] = len(pending(row)) 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 _match_question_to_job(row: dict, job: dict) -> bool: payload = json.loads(row.get("payload") or "{}") return bool( (job.get("transcript") and row.get("recording") == job["transcript"]) or (job.get("draft") and payload.get("draft_path") == job["draft"]) or payload.get("job_id") == job["id"] or (payload.get("draft_path") and Path(str(payload["draft_path"])).stem == job["id"]) or (row.get("recording") and Path(str(row["recording"])).stem == job["id"]) ) def pending(job: dict | None = None) -> list[dict]: all_rows = review.pending(1000) if not job: return all_rows return [row for row in all_rows if _match_question_to_job(row, job)] def _group_pending_by_job() -> list[tuple[dict | None, list[dict]]]: """Group all pending review questions by their parent recording job.""" all_rows = review.pending(1000) if not all_rows: return [] jobs = db.list_jobs(200) grouped: dict[str, list[dict]] = {} job_map: dict[str, dict] = {j["id"]: j for j in jobs} unmatched: list[dict] = [] for row in all_rows: matched_id = "" for j in jobs: if _match_question_to_job(row, j): matched_id = j["id"] break if matched_id: grouped.setdefault(matched_id, []).append(row) else: unmatched.append(row) out: list[tuple[dict | None, list[dict]]] = [] for j in jobs: if j["id"] in grouped: out.append((j, grouped[j["id"]])) if unmatched: out.append((None, unmatched)) return out 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: pend_count = len(review.pending(500)) pend_badge = ( f' {pend_count}' if pend_count else "" ) 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 CFG.asr_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(msg: str = "") -> bytes: options = _provider_options(CFG.asr_provider) notice = f'
{html.escape(msg)}
' if msg else "" body = f"""{notice}

上传会议 / 通话录音

支持 m4a、mp3、wav、mp4、aac、ogg、opus、webm、flac。默认引擎与模型参数均可在 模型与系统配置 中直接修改并落盘至 SQLite。

任务列表

读取中…
""" 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, msg: str = "") -> bytes: jid = job["id"] notice = f'
{html.escape(msg)}
' if msg else "" 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 CFG.asr_provider) has_transcript = bool(job.get("transcript") and Path(str(job["transcript"])).is_file()) rerefine_btn = ( f'
' f'
' if has_transcript else "" ) meta_form = f"""
编辑任务信息 / 补充背景 / 切换 ASR 模型重跑
{'' if has_transcript else ''}
""" delete_form = f"""
删除此任务与产物…

""" 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 CFG.asr_provider}" if job.get("asr_model"): prov_tag += f":{job['asr_model']}" ctx_html = ( f'

已知背景:{html.escape(str(job["context"]))}

' if job.get("context") else "" ) body = f"""{notice}

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

ASR: {html.escape(prov_tag)} 文件: {html.escape(job['original_name'])} 尝试: {int(job.get('attempts') or 0)} 次
{html.escape(LABELS.get(job['status'], job['status']))}

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

{ctx_html} {review_banner} {audio_html}
{links}
{rerefine_btn}
{meta_form} {delete_form}
{''.join(sections)}

处理时间线

{events_html}
""" return shell("任务详情", body) def _render_question_card(row: dict, action_url: str) -> str: qid = int(row["id"]) payload = json.loads(row.get("payload") or "{}") kind = str(payload.get("kind") or "") kind_label = KIND_LABELS.get(kind, kind or "待确认") choices = payload.get("candidates") or [] sentence = str(payload.get("sentence") or "") placeholder = ( "输入真实姓名,如:高瑶" if kind == "voiceprint" else ("若背景有误,请填写正确背景说明" if kind == "background_check" else "输入正确名称或补充修改说明") ) quick_btns = "".join( f'' for v in choices ) quick_btns += ( f'' ) select_opts = '' + "".join( f'' for v in [*choices, "无法确认,保留原文"] ) sentence_html = f'
{html.escape(sentence)}
' if sentence else "" return f"""
{html.escape(kind_label)} #{qid} · {html.escape(row['question'])}
{sentence_html}
{quick_btns}
""" def review_page(focus_job: dict | None = None, msg: str = "") -> bytes: groups = _group_pending_by_job() notice = f'
{html.escape(msg)}
' if msg else "" # Top switcher bar across all recordings that have pending questions switcher_items = [] total_q = sum(len(qs) for _, qs in groups) all_active = " active" if not focus_job else "" switcher_items.append( f'全部待确认录音 ({len(groups)} 场 / {total_q} 题)' ) for j, qs in groups: if j: is_act = " active" if (focus_job and focus_job["id"] == j["id"]) else "" title_lbl = j.get("title") or j["original_name"] switcher_items.append( f'' f'{html.escape(title_lbl)} ({len(qs)})' ) switcher_html = ( f'
{"".join(switcher_items)}
' if groups else "" ) # Filter groups if user focused on one specific recording job display_groups = ( [(j, qs) for j, qs in groups if j and j["id"] == focus_job["id"]] if focus_job else groups ) if focus_job and not display_groups: rem = pending(focus_job) if rem: display_groups = [(focus_job, rem)] group_cards = [] for j, rows in display_groups: action_url = f"/jobs/{j['id']}/review/" if (focus_job and j) else "/review/" if j: jid = j["id"] title_str = j.get("title") or j["original_name"] prov_str = j.get("asr_provider") or CFG.asr_provider audio_bar = ( f'' if find_audio_file(j) else "" ) draft_text = _read_artifact_text(j, "draft") draft_box = ( f'
' f'展开对照本场完整精修草稿
{html.escape(draft_text)}
' if draft_text else "" ) hdr_html = f"""

{html.escape(title_str)}

ASR: {html.escape(prov_str)} {time.strftime('%Y-%m-%d %H:%M', time.localtime(j['created_at']))} · 待确认 {len(rows)} 题
查看任务详情
{audio_bar}{draft_box}""" else: hdr_html = f"""

未关联任务的历史待确认项 ({len(rows)} 题)

""" q_cards = "".join(_render_question_card(r, action_url) for r in rows) q_ids_csv = ",".join(str(r["id"]) for r in rows) group_cards.append(f"""
{hdr_html}
{q_cards}
提示:既可点击上方候选按钮单题即时提交,也可在下拉/输入框填写多题后点击右侧批量提交。
""") if not group_cards: back_url = f"/jobs/{focus_job['id']}/" if focus_job else "/" revise_btn = "" if focus_job and focus_job.get("draft"): revise_btn = ( f'
' f'
' ) group_cards.append( f'

当前没有待确认问题

' f'

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

{revise_btn}' f'

← 返回任务工作台

' ) # Recently answered history with ability to reopen or modify answer answered = review.recent_answered(25) ans_rows = [] for r in answered: qid = int(r["id"]) rec_stem = Path(str(r.get("recording") or "")).stem ans_rows.append( f'#{qid}{html.escape(rec_stem[:16])}
' f'{html.escape(str(r["question"])[:70])}' f'
' f'' f'' f'' f'' f'
' ) history_html = ( f'
' f'近期已确认记录(最近 {len(ans_rows)} 条,支持修改或撤回重答)' f'' f'{"".join(ans_rows)}
ID问题已确认回答 / 操作
' if ans_rows else "" ) body = f"""{notice}

多录音人工确认中心

支持按录音分组切换、单题快捷点击与单场录音批量提交
{switcher_html}
{''.join(group_cards)} {history_html}""" return shell("人工确认中心", body) def lexicon_page(tab: str = "aliases", q: str = "", msg: str = "") -> bytes: tab = tab if tab in ("aliases", "corrections", "entities", "relations") else "aliases" 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} notice = f'
{html.escape(msg)}
' if msg else "" tabs_html = "".join( f'{lbl}' for t, lbl in ( ("aliases", f"别名映射 ({st.get('aliases', 0)})"), ("corrections", f"发音纠错 ({st.get('corrections', 0)})"), ("entities", f"实体卡片 ({st.get('entities', 0)})"), ("relations", f"人物/机构关系 ({gst.get('relations', 0)})"), ) ) content_html = "" if tab == "aliases": rows = lexicon.list_aliases(q=q, limit=180) tr_list = [] for r in rows: al = str(r["alias"]) can = str(r["canonical"]) knd = str(r.get("kind") or "人名") org = str(r.get("origin") or "manual") conf = int(r.get("confirmed") or 0) nt = str(r.get("note") or "") tr_list.append(f"""
{html.escape(org)}
""") content_html = f"""

新增别名映射 (同时写入发音纠错)

别名映射列表(支持行内直接编辑与删除,共显示 {len(tr_list)} 条)

{''.join(tr_list) or ''}
简称 / 别名规范全名类型来源确认备注操作
暂无数据
""" elif tab == "corrections": rows = lexicon.list_corrections(q=q, limit=180) tr_list = [] for r in rows: sf = str(r["surface"]) can = str(r["canonical"]) cnt = int(r.get("n") or 1) conf = int(r.get("confirmed") or 0) nt = str(r.get("note") or "") tr_list.append(f"""
{cnt} 次
""") content_html = f"""

新增同音 / ASR 误识纠错

发音纠错列表(支持行内编辑与删除,共显示 {len(tr_list)} 条)

{''.join(tr_list) or ''}
误识别词正确规范词频次确认备注操作
暂无数据
""" elif tab == "entities": rows = lexicon.list_entities(q=q, limit=180) tr_list = [] for r in rows: can = str(r["canonical"]) knd = str(r.get("kind") or "人名") comp = str(r.get("company") or "") role = str(r.get("role") or "") rel = str(r.get("related") or "") src_tag = "Obsidian卡" if r.get("card") else "手动维护" tr_list.append(f"""
{src_tag}
""") content_html = f"""

新增实体卡片记录(手动维护实体不会被 Vault 同步覆盖)

实体列表(支持行内编辑与删除,共显示 {len(tr_list)} 条)

{''.join(tr_list) or ''}
规范名称类型公司/机构职务/角色关联人来源操作
暂无数据
""" else: # relations rows = lexicon.list_relations(q=q, limit=180) tr_list = [] for r in rows: subj = str(r["subject"]) pred = str(r["predicate"]) obj = str(r["object"]) conf = float(r.get("confidence") or 0.8) src = str(r.get("source") or "manual") act = int(r.get("active") if r.get("active") is not None else 1) tr_list.append(f"""
""") content_html = f"""

新增人物 / 机构关系

关系图谱列表(支持行内编辑与删除,共显示 {len(tr_list)} 条)

{''.join(tr_list) or ''}
主体关系客体置信度状态操作
暂无数据
""" 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)} 篇已索引纪要。

{tabs_html}
{f'清除' if q else ''}
{content_html}""" return shell("实体词库管理", body) def _mask_secret(val: str) -> str: if not val: return "未配置" if len(val) <= 8: return "已配置 (****)" return f"已配置 ({val[:4]}…{val[-4:]})" def settings_page(msg: str = "") -> bytes: notice = f'
{html.escape(msg)}
' if msg else "" 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['endpoint'])}" 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() s = db.get_all_settings() litellm_key_mask = _mask_secret(s.get("litellm_api_key", "")) soniox_key_mask = _mask_secret(s.get("soniox_api_key", "")) grok_key_mask = _mask_secret(s.get("grok_api_key", "")) prov_opts = _provider_options(CFG.asr_provider) body = f"""{notice}

模型与系统配置 (SQLite 持久化)

存储位置: {html.escape(str(STATE / 'intake.db'))} (表 app_setting)

所有模型名称、网关地址与 API 密钥均直接保存在 SQLite 数据库中,修改后对 Web 与 Worker 进程立即生效,无需修改环境变量或重启容器。

1. 后处理大模型网关 (LiteLLM) 与各阶段模型

2. 语音识别引擎 (ASR Providers) 配置

3. 知识增强 (Hindsight / Termbank / Obsidian)

实时运行与通道就绪状态

Worker 进程状态{html.escape(hb_text)}
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 '未挂载'}

ASR 通道表

{''.join(prov_rows)}
ID名称默认型号接口地址接入方式状态
""" script = """""" return shell("模型与系统配置", body, script) def _probe_litellm_models() -> dict[str, Any]: try: url = f"{CFG.litellm_base_url}/models" req = urllib.request.Request( url, headers={"Authorization": f"Bearer {CFG.api_key()}", "Accept": "application/json"}, ) with urllib.request.urlopen(req, timeout=8) as resp: data = json.loads(resp.read().decode("utf-8", errors="ignore")) models = sorted({str(m.get("id")) for m in (data.get("data") or []) if m.get("id")}) return {"ok": True, "models": models} except Exception as exc: return {"ok": False, "error": str(exc), "models": []} class Handler(BaseHTTPRequestHandler): server_version = "ASRService/3" 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 redirect(self, location: str) -> None: self.send_response(303) self.send_header("Location", location) self.end_headers() 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.redirect("/login") 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 read_form(self, max_bytes: int = 65536) -> dict[str, list[str]]: length = min(int(self.headers.get("Content-Length", "0")), max_bytes) raw = self.rfile.read(length).decode("utf-8", errors="ignore") if length > 0 else "" return urllib.parse.parse_qs(raw, keep_blank_values=True) 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 CFG.asr_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 q = urllib.parse.parse_qs(parsed.query) 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(), "default": CFG.asr_provider}) if path == "/api/v1/settings": if not self.need_auth(): return raw_s = db.get_all_settings() redacted = { k: (_mask_secret(v) if k.endswith("_api_key") else v) for k, v in raw_s.items() } return self.json({"settings": redacted}) if path == "/api/v1/jobs": if not self.need_auth(): return 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], q.get("q", [""])[0], q.get("provider", [""])[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 if not self.need_web_auth(): return if path == "/": return self.send_data(home(q.get("msg", [""])[0])) if path == "/review/": return self.send_data(review_page(None, q.get("msg", [""])[0])) if path == "/lexicon": return self.send_data( lexicon_page( tab=q.get("tab", ["aliases"])[0], q=q.get("q", [""])[0], msg=q.get("msg", [""])[0], ) ) if path == "/settings": return self.send_data(settings_page(q.get("msg", [""])[0])) if path == "/settings/models-probe": return self.json(_probe_litellm_models()) if path == "/docs": doc = { "service": "meeting-asr", "endpoints": [ "GET /health, /readyz", "GET /api/v1/providers", "GET /api/v1/settings", "POST /api/v1/settings", "POST /api/v1/jobs (headers: X-Filename, X-Title, X-Context, X-ASR-Provider, X-ASR-Model)", "GET /api/v1/jobs?status=&provider=&q=&limit=30&offset=0", "GET /api/v1/jobs/{job_id}", "DELETE /api/v1/jobs/{job_id}", "GET /api/v1/jobs/{job_id}/artifacts/{transcript|draft|result}", "GET /api/v1/jobs/{job_id}/reviews", "POST /api/v1/jobs/{job_id}/reviews/{question_id}", "POST /api/v1/jobs/{job_id}/retry (json: {\"provider\": \"soniox\", \"model\": \"\"})", "POST /api/v1/jobs/{job_id}/rerefine", ], } return self.send_data(shell("API 文档", f'

API 接口说明

{html.escape(json.dumps(doc, ensure_ascii=False, indent=2))}
')) if path == "/ui/jobs": status_flt = q.get("status", [""])[0] prov_flt = q.get("provider", [""])[0] q_flt = q.get("q", [""])[0] return self.json({ "jobs": [ public(x) for x in db.list_jobs(60, 0, status=status_flt, q=q_flt, provider=prov_flt) ] }) 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, q.get("msg", [""])[0])) 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, q.get("msg", [""])[0])) 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})/artifact/(transcript|draft|result)", path) if m: return self.artifact(db.get_job(m.group(1)), m.group(2), False) self.send_data(b"Not found", 404, "text/plain") def do_DELETE(self) -> None: path = urllib.parse.urlsplit(self.path).path m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})", path) if m: if not self.need_auth(): return ok = delete_job_and_artifacts(m.group(1), delete_files=True, delete_result=False) return self.json({"ok": ok}, 200 if ok else 404) self.json({"error": "not found"}, 404) def do_POST(self) -> None: path = urllib.parse.urlsplit(self.path).path if path == "/login": form = self.read_form(4096) pw = form.get("password", [""])[0] if WEB_PASSWORD and hmac.compare_digest(pw, WEB_PASSWORD): self.send_response(303) self.send_header("Location", "/") self.send_header( "Set-Cookie", f"asr_session={_session_token()}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000", ) 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) if path == "/api/v1/settings": if not self.need_auth(): return length = min(int(self.headers.get("Content-Length", "0")), 32768) body = json.loads(self.rfile.read(length).decode("utf-8") or "{}") db.save_settings(body) return self.json({"ok": 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)) qid = int(m.group(2)) if not job or qid not in {x["id"] for x in pending(job)}: return self.json({"error": "not found"}, 404) length = min(int(self.headers.get("Content-Length", "0")), 4096) body = json.loads(self.rfile.read(length).decode("utf-8") or "{}") ans = str(body.get("answer", "")).strip() if not ans: return self.json({"error": "answer required"}, 400) return self.json(review.answer(qid, ans[:200])) m = re.fullmatch(r"/(?:api/v1/)?jobs/([0-9a-f]{24})/rerefine", 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) ok = queue_rerefine(job) if api: return self.json({"ok": ok}, 202 if ok else 409) return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已加入重新精修队列")) 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 CFG.asr_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) 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) return self.redirect(f"/jobs/{job['id']}/") if not self.need_web_auth(): return # ---- Task deletion & metadata editing ---- if path == "/jobs/batch-delete": form = self.read_form(65536) del_files = form.get("delete_files", ["1"])[0] == "1" if form.get("cleanup_failed"): failed_jobs = db.list_jobs(200, 0, status="failed") for fj in failed_jobs: delete_job_and_artifacts(fj["id"], delete_files=del_files, delete_result=False) return self.redirect("/?msg=" + urllib.parse.quote(f"已清理 {len(failed_jobs)} 个失败任务")) ids = form.get("job_id", []) n_del = 0 for jid in ids: if delete_job_and_artifacts(jid, delete_files=del_files, delete_result=False): n_del += 1 return self.redirect("/?msg=" + urllib.parse.quote(f"已删除 {n_del} 个任务")) m = re.fullmatch(r"/jobs/([0-9a-f]{24})/delete", path) if m: form = self.read_form(4096) del_files = form.get("delete_files", ["0"])[0] == "1" del_res = form.get("delete_result", ["0"])[0] == "1" delete_job_and_artifacts(m.group(1), delete_files=del_files, delete_result=del_res) return self.redirect("/?msg=" + urllib.parse.quote("任务已删除")) m = re.fullmatch(r"/jobs/([0-9a-f]{24})/meta", path) if m: job = db.get_job(m.group(1)) if not job: return self.send_data(b"Not found", 404, "text/plain") form = self.read_form(32768) new_title = safe(form.get("title", [""])[0], 80) new_ctx = safe(form.get("context", [""])[0], 2000) new_prov = providers.normalize_provider_name(form.get("provider", [CFG.asr_provider])[0]) new_model = safe(form.get("model", [""])[0], 80) db.update_job_meta( job["id"], title=new_title, context=new_ctx, asr_provider=new_prov, asr_model=new_model, ) action = form.get("next_action", ["save"])[0] updated_job = db.get_job(job["id"]) or job if action == "rerefine": queue_rerefine(updated_job) return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已保存并排队重新生成草稿")) if action == "retry": source = find_audio_file(updated_job) if source: dest = INBOX / updated_job["stored_name"] if source.resolve() != dest.resolve(): shutil.copyfile(source, dest) if source.parent == PROCESSED else source.replace(dest) 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, ) return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已保存并重新排队转写")) return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("任务信息已更新")) m = re.fullmatch(r"/jobs/([0-9a-f]{24})/revise", path) if m: job = db.get_job(m.group(1)) if not job: return self.send_data(b"Not found", 404, "text/plain") queue_revise_draft(job) return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已触发修订与定稿流程")) # ---- Multi-recording review submission ---- 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 form = self.read_form(65536) allowed_ids = {x["id"] for x in pending(job)} answered_count = 0 # Case 1: Single preset button clicked (`single_submit` = "qid::answer") if form.get("single_submit"): raw_val = form["single_submit"][0] if "::" in raw_val: qid_str, ans_val = raw_val.split("::", 1) if qid_str.isdigit() and int(qid_str) in allowed_ids and ans_val.strip(): review.answer(int(qid_str), ans_val.strip()[:200]) answered_count += 1 # Case 2: Single custom button clicked (`single_custom` = "qid") elif form.get("single_custom"): qid_str = form["single_custom"][0] if qid_str.isdigit() and int(qid_str) in allowed_ids: ans_val = ( form.get(f"custom_{qid_str}", [""])[0].strip() or form.get(f"preset_{qid_str}", [""])[0].strip() ) if ans_val: review.answer(int(qid_str), ans_val[:200]) answered_count += 1 # Case 3: Skip all questions in the recording group elif form.get("batch_skip_all"): qids = [ int(x) for x in form.get("question_ids", [""])[0].split(",") if x.strip().isdigit() and int(x) in allowed_ids ] for qid in qids: review.answer(qid, "无法确认,保留原文") answered_count += 1 # Case 4: Batch submit filled questions in the recording group elif form.get("batch_submit"): qids = [ int(x) for x in form.get("question_ids", [""])[0].split(",") if x.strip().isdigit() and int(x) in allowed_ids ] for qid in qids: ans_val = ( form.get(f"custom_{qid}", [""])[0].strip() or form.get(f"preset_{qid}", [""])[0].strip() ) if ans_val: review.answer(qid, ans_val[:200]) answered_count += 1 # Legacy fallback (`id` + `answer`/`custom`) elif form.get("id"): qid = int(form.get("id", ["0"])[0] or 0) ans_val = form.get("custom", [""])[0].strip() or form.get("answer", [""])[0].strip() if qid in allowed_ids and ans_val: review.answer(qid, ans_val[:200]) answered_count += 1 return self.redirect(f"{path}?msg=" + urllib.parse.quote(f"已提交 {answered_count} 项确认")) if path == "/review/update-answer": form = self.read_form(8192) qid = int(form.get("id", ["0"])[0] or 0) if qid > 0: if form.get("reopen"): review.reopen_question(qid) return self.redirect("/review/?msg=" + urllib.parse.quote(f"问题 #{qid} 已撤回至待确认队列")) new_ans = safe(form.get("answer", [""])[0], 200) if new_ans: review.answer(qid, new_ans, allow_update=True) return self.redirect("/review/?msg=" + urllib.parse.quote(f"问题 #{qid} 回答已更新")) return self.redirect("/review/") 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") form = self.read_form(2 * 1024 * 1024) 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) return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("Markdown 修改已保存")) # ---- Lexicon CRUD endpoints ---- if path == "/lexicon/learn": form = self.read_form(8192) alias_val = safe(form.get("alias", [""])[0], 40) canon_val = safe(form.get("canonical", [""])[0], 40) kind_val = safe(form.get("kind", [""])[0], 20) note_val = safe(form.get("note", [""])[0], 120) tab = safe(form.get("tab", ["aliases"])[0], 20) if alias_val and canon_val: lexicon.learn_alias(alias_val, canon_val, kind=kind_val, origin="manual", note=note_val) lexicon.learn_correction(alias_val, canon_val, note=note_val) return self.redirect(f"/lexicon?tab={tab}&msg=" + urllib.parse.quote(f"已添加映射:{alias_val} → {canon_val}")) return self.redirect(f"/lexicon?tab={tab}&msg=" + urllib.parse.quote("请填写完整的词条映射")) if path == "/lexicon/alias/update": form = self.read_form(8192) old_a = safe(form.get("old_alias", [""])[0], 40) old_c = safe(form.get("old_canonical", [""])[0], 40) new_a = safe(form.get("alias", [""])[0], 40) new_c = safe(form.get("canonical", [""])[0], 40) kind = safe(form.get("kind", ["人名"])[0], 20) conf = 1 if form.get("confirmed") else 0 note = safe(form.get("note", [""])[0], 120) if new_a and new_c: lexicon.update_alias(old_a or new_a, old_c or new_c, new_a, new_c, kind=kind, confirmed=conf, note=note) return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote(f"已更新别名:{new_a} → {new_c}")) if path == "/lexicon/alias/delete": form = self.read_form(8192) old_a = safe(form.get("old_alias", form.get("alias", [""]))[0], 40) old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 40) lexicon.delete_alias(old_a, old_c) return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote(f"已删除别名:{old_a}")) if path == "/lexicon/correction/update": form = self.read_form(8192) old_s = safe(form.get("old_surface", [""])[0], 40) old_c = safe(form.get("old_canonical", [""])[0], 40) new_s = safe(form.get("surface", [""])[0], 40) new_c = safe(form.get("canonical", [""])[0], 40) conf = 1 if form.get("confirmed") else 0 note = safe(form.get("note", [""])[0], 120) if new_s and new_c: lexicon.update_correction(old_s or new_s, old_c or new_c, new_s, new_c, confirmed=conf, note=note) return self.redirect("/lexicon?tab=corrections&msg=" + urllib.parse.quote(f"已更新纠错:{new_s} → {new_c}")) if path == "/lexicon/correction/delete": form = self.read_form(8192) old_s = safe(form.get("old_surface", form.get("surface", [""]))[0], 40) old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 40) lexicon.delete_correction(old_s, old_c) return self.redirect("/lexicon?tab=corrections&msg=" + urllib.parse.quote(f"已删除纠错:{old_s}")) if path == "/lexicon/entity/update": form = self.read_form(8192) old_c = safe(form.get("old_canonical", [""])[0], 60) new_c = safe(form.get("canonical", [""])[0], 60) kind = safe(form.get("kind", ["人名"])[0], 20) comp = safe(form.get("company", [""])[0], 80) role = safe(form.get("role", [""])[0], 80) rel = safe(form.get("related", [""])[0], 160) if new_c: if old_c: lexicon.update_entity(old_c, new_c, kind, company=comp, role=role, related=rel) else: lexicon.upsert_entity(new_c, kind, company=comp, role=role, related=rel) return self.redirect("/lexicon?tab=entities&msg=" + urllib.parse.quote(f"已保存实体:{new_c}")) if path == "/lexicon/entity/delete": form = self.read_form(8192) old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 60) lexicon.delete_entity(old_c) return self.redirect("/lexicon?tab=entities&msg=" + urllib.parse.quote(f"已删除实体:{old_c}")) if path == "/lexicon/relation/update": form = self.read_form(8192) old_s = safe(form.get("old_subject", [""])[0], 60) old_p = safe(form.get("old_predicate", [""])[0], 40) old_o = safe(form.get("old_object", [""])[0], 60) old_src = safe(form.get("old_source", ["manual"])[0], 120) subj = safe(form.get("subject", [""])[0], 60) pred = safe(form.get("predicate", [""])[0], 40) obj = safe(form.get("object", [""])[0], 60) try: conf = float(form.get("confidence", ["0.85"])[0]) except ValueError: conf = 0.85 act = 1 if form.get("active") else 0 if subj and pred and obj: if old_s and old_p and old_o: lexicon.update_relation(old_s, old_p, old_o, old_src, subj, pred, obj, confidence=conf, active=act) else: lexicon.upsert_relation(subj, pred, obj, confidence=conf, source="manual") return self.redirect("/lexicon?tab=relations&msg=" + urllib.parse.quote(f"已保存关系:{subj} {pred} {obj}")) if path == "/lexicon/relation/delete": form = self.read_form(8192) old_s = safe(form.get("old_subject", form.get("subject", [""]))[0], 60) old_p = safe(form.get("old_predicate", form.get("predicate", [""]))[0], 40) old_o = safe(form.get("old_object", form.get("object", [""]))[0], 60) old_src = safe(form.get("old_source", ["manual"])[0], 120) lexicon.delete_relation(old_s, old_p, old_o, old_src) return self.redirect("/lexicon?tab=relations&msg=" + urllib.parse.quote(f"已删除关系:{old_s} {old_p} {old_o}")) if path == "/lexicon/sync": p_res = vault.pull() l_res = lexicon.sync_from_vault(force=True) g_res = graph.sync(force=True) return self.redirect("/lexicon?msg=" + urllib.parse.quote(f"同步完成(Git:{p_res}):词库 {l_res},图谱 {g_res}")) # ---- SQLite Settings Save ---- if path == "/settings": form = self.read_form(32768) updates: dict[str, str] = {} text_keys = ( "litellm_base_url", "clean_model", "keyword_model", "draft_model", "asr_provider", "diarize_url", "voiceprint_enroll_url", "soniox_api_base", "soniox_model", "gemini_asr_model", "openai_asr_model", "grok_api_base", "grok_asr_model", "hindsight_api", "hindsight_bank", ) for k in text_keys: if k in form: updates[k] = safe(form[k][0], 200) if "asr_provider" in updates: updates["asr_provider"] = providers.normalize_provider_name(updates["asr_provider"]) for sk in ("litellm_api_key", "soniox_api_key", "grok_api_key"): raw_k = form.get(sk, [""])[0].strip() if raw_k == "CLEAR": updates[sk] = "" elif raw_k: updates[sk] = raw_k for bk in ("use_hindsight", "use_termbank", "git_sync"): updates[bk] = "1" if form.get(bk) else "0" db.save_settings(updates) return self.redirect("/settings?msg=" + urllib.parse.quote("配置已保存至 SQLite 并立即生效")) 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()