from __future__ import annotations import hashlib import hmac import html import json import mimetypes import os import re import shutil import socket import threading import time import urllib.parse import urllib.request import uuid from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any from zoneinfo import ZoneInfo from asrpipe import graph, lexicon, providers, review, vault, webauth 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, ensure_dirs, ) SHANGHAI = ZoneInfo("Asia/Shanghai") BUSY_STATUSES = {"transcribing", "refining", "publishing"} _LOGIN_FAILS: dict[str, tuple[int, float]] = {} SETTING_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", ) SETTING_SECRET_KEYS = ("litellm_api_key", "soniox_api_key", "grok_api_key", "web_password") SETTING_BOOL_KEYS = ("use_hindsight", "use_termbank", "git_sync") LEXICON_PAGE_SIZE = 40 class ClientError(Exception): def __init__(self, status: int, message: str): super().__init__(message) self.status = status self.message = message def fmt_ts(ts: float, with_seconds: bool = True) -> str: stamp = datetime.fromtimestamp(float(ts), SHANGHAI) return stamp.strftime("%Y-%m-%d %H:%M:%S" if with_seconds else "%Y-%m-%d %H:%M") def content_disposition(filename: str, inline: bool = True) -> str: """ASCII fallback plus RFC 5987 filename* so non-ASCII names stay in the header.""" kind = "inline" if inline else "attachment" suffix = Path(filename).suffix if not suffix.isascii(): suffix = "" fallback = re.sub(r"[^A-Za-z0-9._-]+", "_", "download" + suffix).strip("._") or "download" encoded = urllib.parse.quote(filename, safe="") return f"{kind}; filename=\"{fallback}\"; filename*=UTF-8''{encoded}" def _parse_byte_range(header: str, size: int) -> tuple[int, int, int]: """Return (status, start, end). status is 200, 206, or 416.""" if size <= 0: return 200, 0, 0 if not header: return 200, 0, size - 1 if not header.startswith("bytes=") or "," in header: return 416, 0, 0 spec = header[6:].strip() if spec.startswith("-"): try: suffix = int(spec[1:]) except ValueError: return 416, 0, 0 if suffix <= 0: return 416, 0, 0 start = max(0, size - suffix) return 206, start, size - 1 match = re.match(r"^(\d+)-(\d*)$", spec) if not match: return 416, 0, 0 start = int(match.group(1)) end = int(match.group(2)) if match.group(2) else size - 1 end = min(end, size - 1) if start >= size or start > end: return 416, 0, 0 return 206, start, end def path_is_under(path: Path, roots: list[Path]) -> bool: try: resolved = path.resolve() except OSError: return False for root in roots: try: resolved.relative_to(root.resolve()) return True except (OSError, ValueError): continue return False def artifact_roots() -> list[Path]: return [ INBOX, PROCESSING, PROCESSED, FAILED, TRANSCRIPTS, DRAFTS, RESULTS, CFG.drafts, CFG.minutes, CFG.vault / "minutes", CFG.vault / "People", CFG.vault / "Companies", ] 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 = Path(str(job.get("stored_name") or "")).name if not stored or stored in {".", ".."}: 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() and path_is_under(audio, artifact_roots()): 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() and path_is_under(p, artifact_roots()): p.unlink(missing_ok=True) if delete_result and job.get("result"): rpath = Path(str(job["result"])) if rpath.is_file() and path_is_under(rpath, artifact_roots()): rpath.unlink(missing_ok=True) return True def _filter_settings(body: dict) -> dict[str, str]: updates: dict[str, str] = {} for key, value in body.items(): if key in SETTING_TEXT_KEYS: text = safe(str(value), 200) if text: updates[key] = text elif key in SETTING_SECRET_KEYS: raw = str(value).strip() if raw == "CLEAR": updates[key] = "" elif raw and raw not in {"已配置", "未配置"}: updates[key] = raw elif key in SETTING_BOOL_KEYS: updates[key] = "0" if str(value).lower() in {"0", "false", "no", "off"} else "1" if "asr_provider" in updates: updates["asr_provider"] = providers.normalize_provider_name(updates["asr_provider"]) return updates def _requeue_audio(job: dict, prov: str, model: str) -> str | None: if job.get("status") in BUSY_STATUSES: return "任务正在处理,未重新排队" source = find_audio_file(job) if not source: return "找不到原音频,未重新排队" if source.parent.resolve() == PROCESSING.resolve(): return "音频仍在处理目录,未重新排队" stored = Path(str(job.get("stored_name") or source.name)).name dest = INBOX / stored if source.resolve() != dest.resolve(): shutil.copyfile(source, dest) old_ts = time.time() - 60 os.utime(dest, (old_ts, old_ts)) db.update_job( job["id"], "queued", f"重新排队 (ASR: {prov})", error_code="", asr_provider=prov, asr_model=model, ) return None def queue_rerefine(job: dict) -> bool: if job.get("status") in BUSY_STATUSES: return False 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: return webauth.session_token() def _inject_csrf(body: str) -> str: token = html.escape(webauth.csrf_token(), quote=True) field = f'' def repl(match: re.Match[str]) -> str: return match.group(0) + field return re.sub(r"]*>", repl, body, flags=re.IGNORECASE) def shell(title: str, body: str, script: str = "", *, nav: bool = True) -> bytes: pend_badge = "" if nav: pend_count = len(review.pending(500)) if pend_count: pend_badge = ( f' {pend_count}' ) nav_html = "" if nav: nav_html = ( f'' ) body = _inject_csrf(body) token = html.escape(webauth.csrf_token(), quote=True) guard = f"""""" return f""" {html.escape(title)} · Meeting ASR
ASR 录音工作台 {nav_html}
{body}
{guard}{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: if not webauth.password() and not webauth.allow_anon(): err = err or "未配置访问口令。请设置环境变量 ASR_WEB_PASSWORD 后重启。仅本机调试可另设 ASR_WEB_ALLOW_ANON=1。" alert = f'

{html.escape(err)}

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

访问验证

请输入工作台访问口令。口令来自环境变量 ASR_WEB_PASSWORD。

{alert}

""" return shell("登录", body, nav=False) 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。单文件上限以服务端 ASR_UPLOAD_MAX_BYTES 为准(默认 4 GiB)。默认引擎可在模型配置中修改。

任务列表

读取中…
""" 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"{fmt_ts(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 ''} {'' if job.get("status") not in BUSY_STATUSES 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_form(row: dict, action_url: str) -> str: """One form per question so Enter submits this answer, not another question.""" 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'' ) 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 _render_batch_select(row: dict) -> str: qid = int(row["id"]) payload = json.loads(row.get("payload") or "{}") choices = payload.get("candidates") or [] select_opts = '' + "".join( f'' for v in [*choices, "无法确认,保留原文"] ) return ( f'' f'' ) 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)} {fmt_ts(j['created_at'], with_seconds=False)} · 待确认 {len(rows)} 题
{audio_bar}{draft_box}""" else: hdr_html = f"""

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

""" q_forms = "".join(_render_question_form(r, action_url) for r in rows) batch_selects = "".join(_render_batch_select(r) for r in rows) q_ids_csv = ",".join(str(r["id"]) for r in rows) group_cards.append(f"""
{hdr_html} {q_forms}
{batch_selects}
每一题有自己的输入框,回车只提交该题。下面的下拉框用于一次提交多题。
""") 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_pager(tab: str, q: str, page: int, total: int) -> str: pages = max(1, (total + LEXICON_PAGE_SIZE - 1) // LEXICON_PAGE_SIZE) page = min(max(page, 1), pages) qv = urllib.parse.quote(q) def href(n: int) -> str: return f"/lexicon?tab={tab}&q={qv}&page={n}" prev = f'上一页' if page > 1 else "" nxt = f'下一页' if page < pages else "" return ( f'

共 {total} 条,第 {page}/{pages} 页,每页 {LEXICON_PAGE_SIZE} 条。 {prev} {nxt}

' ) def lexicon_page(tab: str = "aliases", q: str = "", msg: str = "", page: int = 1) -> bytes: tab = tab if tab in ("aliases", "corrections", "entities", "relations") else "aliases" page = max(1, page) offset = (page - 1) * LEXICON_PAGE_SIZE 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": total = lexicon.count_aliases(q=q) rows = lexicon.list_aliases(q=q, limit=LEXICON_PAGE_SIZE, offset=offset) pager = _lexicon_pager(tab, q, page, total) 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"""

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

别名映射列表

{pager} {''.join(tr_list) or ''}
简称 / 别名规范全名类型来源确认备注操作
暂无数据
""" elif tab == "corrections": total = lexicon.count_corrections(q=q) rows = lexicon.list_corrections(q=q, limit=LEXICON_PAGE_SIZE, offset=offset) pager = _lexicon_pager(tab, q, page, total) 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 误识纠错

发音纠错列表

{pager} {''.join(tr_list) or ''}
误识别词正确规范词频次确认备注操作
暂无数据
""" elif tab == "entities": total = lexicon.count_entities(q=q) rows = lexicon.list_entities(q=q, limit=LEXICON_PAGE_SIZE, offset=offset) pager = _lexicon_pager(tab, q, page, total) 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 同步覆盖)

实体列表

{pager} {''.join(tr_list) or ''}
规范名称类型公司/机构职务/角色关联人来源操作
暂无数据
""" else: # relations total = lexicon.count_relations(q=q) rows = lexicon.list_relations(q=q, limit=LEXICON_PAGE_SIZE, offset=offset) pager = _lexicon_pager(tab, q, page, total) 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"""

新增人物 / 机构关系

关系图谱列表

{pager} {''.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: return "已配置" if val else "未配置" def _configured(val: str) -> str: return "已配置(内容已隐藏)" if val else "未配置" 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"{_configured(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", "")) web_pw_mask = _mask_secret(webauth.password()) prov_opts = _provider_options(CFG.asr_provider) body = f"""{notice}

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

配置保存在 SQLite app_setting,页面不回显地址和密钥

模型名会显示在下面的输入框里。网关地址和 API 密钥只显示「已配置 / 未配置」,留空表示保持原值。保存后下一次模型调用会读取新密钥。

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

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

3. 知识增强与访问安全 (Hindsight / Termbank / Obsidian / Web 口令)

实时运行与通道就绪状态

Worker 进程状态{html.escape(hb_text)}
Hindsight 记忆服务{_configured(CFG.hindsight_api)} · {'在线' if hs_ok else '离线或未连接'}
Obsidian 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 setup(self) -> None: super().setup() self.connection.settimeout(30) self._suppress_body = False def handle_one_request(self) -> None: try: super().handle_one_request() except ClientError as exc: try: self.json({"error": exc.message}, exc.status) except Exception: pass except (TimeoutError, socket.timeout): try: self.json({"error": "timeout"}, 408) except Exception: pass except Exception: try: self.json({"error": "internal"}, 500) except Exception: 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() if not getattr(self, "_suppress_body", False): 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 webauth.allow_anon(): return True if not webauth.password(): return False if self.auth(): return True return webauth.cookie_matches(self.headers.get("Cookie", "")) def need_web_auth(self) -> bool: if self.web_authed(): return True path = urllib.parse.urlsplit(self.path).path if path.startswith("/ui/") or path.endswith("models-probe"): self.json({"error": "unauthorized"}, 401) else: self.redirect("/login") return False def _declared_length(self, max_bytes: int) -> int: raw = self.headers.get("Content-Length") if raw is None or raw == "": return 0 try: n = int(raw) except ValueError: raise ClientError(400, "bad content-length") from None if n < 0: raise ClientError(400, "bad content-length") if n > max_bytes: raise ClientError(413, "payload too large") return n def _read_exact(self, n: int) -> bytes: if n <= 0: return b"" data = self.rfile.read(n) if len(data) < n: raise ClientError(400, "incomplete body") return data def _csrf_ok(self, form: dict[str, list[str]] | None = None) -> bool: if self.auth(): return True supplied = self.headers.get("X-CSRF-Token", "") if not supplied and form is not None: supplied = (form.get("csrf") or [""])[0] return webauth.csrf_matches(supplied) 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, *, csrf: bool = True) -> dict[str, list[str]]: length = self._declared_length(max_bytes) raw = self._read_exact(length).decode("utf-8", errors="ignore") if length else "" form = urllib.parse.parse_qs(raw, keep_blank_values=True) if csrf and not self._csrf_ok(form): raise ClientError(403, "csrf") return form 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) if not protected and not self._csrf_ok(): return self.json({"error": "csrf"}, 403) length = self._declared_length(MAX_UPLOAD) 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 ClientError(400, "incomplete upload") out.write(chunk) digest.update(chunk) remaining -= len(chunk) out.flush() os.fsync(out.fileno()) part.replace(INBOX / stored) 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, ) self.json(public(job), 201) except ClientError: part.unlink(missing_ok=True) raise 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", content_disposition(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 status, start, end = _parse_byte_range(self.headers.get("Range", ""), size) if status == 416: self.send_response(416) self.send_header("Content-Range", f"bytes */{size}") self.send_header("Content-Length", "0") self.end_headers() return 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() if getattr(self, "_suppress_body", False): return 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 == "/logout": self.send_response(303) self.send_header("Location", "/login") self.send_header( "Set-Cookie", "asr_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0", ) self.end_headers() return 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") or k == "web_password") 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], page=max(1, int(q.get("page", ["1"])[0] or 1)) if str(q.get("page", ["1"])[0]).isdigit() else 1, ) ) 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(shell( "未找到", '

页面不存在

' '

这个地址没有对应的页面。

' '

返回任务列表

', ), 404) def do_HEAD(self) -> None: self._suppress_body = True try: self.do_GET() finally: self._suppress_body = False def do_OPTIONS(self) -> None: self.send_response(204) self.send_header("Allow", "GET, HEAD, POST, DELETE, OPTIONS") self.send_header("Content-Length", "0") self.end_headers() 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": origin = self.headers.get("Origin", "") host = self.headers.get("Host", "") if origin and urllib.parse.urlsplit(origin).netloc not in {"", host}: return self.send_data(login_page("来源不被接受"), 403) form = self.read_form(4096, csrf=False) pw = form.get("password", [""])[0] if not webauth.password(): return self.send_data(login_page(), 401) ip = self.client_address[0] fails, stamp = _LOGIN_FAILS.get(ip, (0, 0.0)) if time.time() - stamp > 600: fails = 0 if fails >= 8: return self.send_data(login_page("尝试过多,请 10 分钟后再试"), 429) if hmac.compare_digest(pw, webauth.password()): _LOGIN_FAILS.pop(ip, None) secure = "" if os.getenv("ASR_WEB_COOKIE_SECURE", "").strip().lower() in {"1", "true", "yes", "on"}: secure = "; Secure" self.send_response(303) self.send_header("Location", "/") self.send_header( "Set-Cookie", f"asr_session={webauth.session_token()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400{secure}", ) self.end_headers() return _LOGIN_FAILS[ip] = (fails + 1, time.time()) 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 = self._declared_length(32768) body = json.loads(self._read_exact(length).decode("utf-8") or "{}") if not isinstance(body, dict): raise ClientError(400, "settings must be an object") db.save_settings(_filter_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 = self._declared_length(4096) body = json.loads(self._read_exact(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 if not api: self.read_form(8192) 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) new_prov = job.get("asr_provider") or CFG.asr_provider new_model = job.get("asr_model") or "" if api: length = self._declared_length(4096) raw_body = self._read_exact(length).decode("utf-8", errors="ignore") if length else "" if 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: raise ClientError(400, "bad json") from None else: form = self.read_form(8192) 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) err = _requeue_audio(job, new_prov, new_model) if err: if api: return self.json({"error": err}, 409) return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote(err)) if api: return self.json({"ok": True, "asr_provider": new_prov, "asr_model": new_model}, 202) return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已重新排队转写")) 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" ok = delete_job_and_artifacts(m.group(1), delete_files=del_files, delete_result=del_res) msg = "任务已删除" if ok else "没有找到这个任务,未删除" return self.redirect("/?msg=" + urllib.parse.quote(msg)) 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) raw_title = form.get("title", [""])[0] raw_prov = form.get("provider", [CFG.asr_provider])[0].strip().lower() new_title = safe(raw_title, 80) new_ctx = safe(form.get("context", [""])[0], 2000) new_prov = providers.normalize_provider_name(raw_prov) 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, ) notes = [] if len(raw_title) > 80: notes.append("标题已截断到 80 字") if raw_prov and raw_prov not in providers.PROVIDERS: notes.append(f"未知引擎已改用 {new_prov}") action = form.get("next_action", ["save"])[0] updated_job = db.get_job(job["id"]) or job if action == "rerefine": queued = queue_rerefine(updated_job) msg = "已保存并排队重新生成草稿" if queued else "已保存;任务正在处理,未重新生成草稿" elif action == "retry": err = _requeue_audio(updated_job, new_prov, new_model) msg = err or "已保存并重新排队转写" else: msg = "任务信息已更新" if notes: msg += "(" + ";".join(notes) + ")" return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote(msg)) 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"): raw_id = form.get("id", ["0"])[0] if not str(raw_id).isdigit(): raise ClientError(400, "bad id") qid = int(raw_id) 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) raw_id = form.get("id", ["0"])[0] if not str(raw_id).isdigit(): raise ClientError(400, "bad id") qid = int(raw_id) 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 not in ("draft", "result"): return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:只能编辑草稿或正式纪要")) if not content.strip(): return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:内容为空")) if not job.get(target): return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:还没有这个文件")) tpath = Path(str(job[target])) if not path_is_under(tpath, artifact_roots()): return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:文件不在允许的目录")) 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 not (new_a and new_c): return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote("未填写完整,没有修改别名")) 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) removed = lexicon.delete_alias(old_a, old_c) msg = f"已删除别名:{old_a}" if removed else f"没有找到别名「{old_a}」,未删除" return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote(msg)) 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 not in form: continue text = safe(form[k][0], 200) if not text: continue updates[k] = text 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", "web_password"): 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(shell( "未找到", '

页面不存在

返回任务列表

', ), 404) def main() -> None: if not webauth.password() and not webauth.allow_anon(): print( "ASR_WEB_PASSWORD is empty; the browser UI stays closed until it is set. " "ASR_WEB_ALLOW_ANON=1 opens it with no password.", flush=True, ) 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()