Files
infra-docker-configs/servers/tools-pve/asr-pipeline/asrservice/web.py
T
Codex 3c70861463
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s
feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
2026-11-02 06:37:33 -08:00

2231 lines
106 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'<input type="hidden" name="csrf" value="{token}">'
def repl(match: re.Match[str]) -> str:
return match.group(0) + field
return re.sub(r"<form\b[^>]*>", 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' <span class="badge review" style="font-size:11.5px;padding:1px 6px">{pend_count}</span>'
)
nav_html = ""
if nav:
nav_html = (
f'<nav><a href="/">任务管理</a><a href="/review/">待确认{pend_badge}</a>'
f'<a href="/lexicon">实体词库</a><a href="/settings">模型与系统配置</a>'
f'<a href="/docs">API</a><a href="/logout">退出</a></nav>'
)
body = _inject_csrf(body)
token = html.escape(webauth.csrf_token(), quote=True)
guard = f"""<script>
const CSRF=document.querySelector('meta[name="csrf-token"]').content;
document.querySelectorAll('form').forEach(f=>{{
if(!f.querySelector('input[name=csrf]')){{
const i=document.createElement('input'); i.type='hidden'; i.name='csrf'; i.value=CSRF; f.prepend(i);
}}
}});
const _fetch=window.fetch;
window.fetch=function(u,o){{
o=o||{{}};
const m=(o.method||'GET').toUpperCase();
if(m!=='GET'&&m!=='HEAD'){{
const h=new Headers(o.headers||{{}});
if(!h.has('X-CSRF-Token')) h.set('X-CSRF-Token', CSRF);
o.headers=h;
}}
return _fetch(u,o);
}};
const _open=XMLHttpRequest.prototype.open, _send=XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open=function(m,u){{ this._asrMethod=m; return _open.apply(this, arguments); }};
XMLHttpRequest.prototype.send=function(){{
if((this._asrMethod||'GET').toUpperCase()!=='GET'){{
try{{ this.setRequestHeader('X-CSRF-Token', CSRF); }}catch(e){{}}
}}
return _send.apply(this, arguments);
}};
</script>"""
return f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="csrf-token" content="{token}">
<title>{html.escape(title)} · Meeting ASR</title><style>
:root{{--bg:#f4f6f8;--card:#fff;--ink:#18212b;--muted:#647180;--line:#d9dfe6;
--accent:#176b5b;--accent-soft:#e8f3f0;--warn:#986b12;--bad:#b33a2b}}
@media(prefers-color-scheme:dark){{:root{{--bg:#11161b;--card:#192128;--ink:#eef3f5;
--muted:#9aa7b4;--line:#2e3a44;--accent:#5bc2a9;--accent-soft:#1d3531;--warn:#e0a84b;--bad:#e06c5f}}}}
*{{box-sizing:border-box}}body{{margin:0;background:var(--bg);color:var(--ink);
font:15px/1.58 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}}
header{{background:var(--card);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:10}}
header .bar,main{{max-width:1040px;margin:auto;padding:14px 16px}}
header .bar,.row{{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}}
a{{color:var(--accent);text-decoration:none;font-weight:600}}a:hover{{text-decoration:underline}}
nav{{display:flex;gap:18px;flex-wrap:wrap;align-items:center}}
.card{{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:18px;margin:14px 0}}
h1{{font-size:21px;margin:0 0 10px}}h2{{font-size:17px;margin:0 0 10px}}h3{{font-size:15px;margin:0 0 8px}}
.job{{padding:13px 0;border-top:1px solid var(--line)}}.job:first-child{{border:0}}
.muted{{color:var(--muted);font-size:13.5px}}
.badge{{display:inline-block;padding:2px 8px;border-radius:99px;font-size:12.5px;font-weight:700;
background:var(--accent-soft);color:var(--accent)}}
.badge.failed{{background:rgba(179,58,43,.14);color:var(--bad)}}
.badge.review{{background:rgba(152,107,18,.15);color:var(--warn)}}
.tag{{display:inline-block;padding:1px 7px;border:1px solid var(--line);border-radius:6px;
font-size:12px;color:var(--muted);margin-right:6px}}
input,textarea,select,button{{font:inherit}}
input:not([type=checkbox]):not([type=radio]),textarea,select{{width:100%;padding:9px 11px;border:1px solid var(--line);
border-radius:7px;background:var(--card);color:var(--ink)}}
textarea{{min-height:84px;resize:vertical}}
label{{display:block;margin:10px 0 4px;font-weight:650;font-size:13.5px}}
button{{border:0;border-radius:7px;padding:9px 15px;background:var(--accent);color:#fff;
font-weight:700;cursor:pointer;font-size:14px}}button:disabled{{opacity:.55;cursor:not-allowed}}
button.alt{{background:transparent;color:var(--ink);border:1px solid var(--line)}}
button.danger{{background:var(--bad);color:#fff}}
button.sm{{padding:5px 10px;font-size:12.5px;border-radius:6px}}
progress{{width:100%;height:10px;margin-top:8px}}
pre{{white-space:pre-wrap;overflow-wrap:anywhere;max-height:58vh;overflow:auto;
background:var(--bg);padding:14px;border-radius:8px;border:1px solid var(--line);
font:13.5px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace}}
.timeline{{border-left:2px solid var(--line);padding-left:16px}}
.choice{{display:inline-block;margin:4px 6px 4px 0;text-align:left}}
.grid2{{display:grid;grid-template-columns:1fr 1fr;gap:12px}}
.grid3{{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}}
.source-box{{margin:10px 0;padding:11px 13px;border-left:4px solid var(--warn);background:var(--bg);border-radius:4px;font-size:14px}}
.q-item{{padding:14px;border:1px solid var(--line);border-radius:8px;margin:10px 0;background:var(--bg)}}
.tabs{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;border-bottom:1px solid var(--line);padding-bottom:10px}}
.tab{{padding:6px 13px;border-radius:7px;border:1px solid var(--line);color:var(--ink);font-weight:650;font-size:14px}}
.tab.active{{background:var(--accent);color:#fff;border-color:var(--accent)}}
audio{{width:100%;margin:10px 0}}
table{{width:100%;border-collapse:collapse;font-size:13.5px;display:block;max-width:100%;overflow-x:auto}}
th,td{{padding:8px 8px;border-bottom:1px solid var(--line);text-align:left;vertical-align:middle}}
td input:not([type=checkbox]),td select{{padding:5px 8px;font-size:13px}}
input,select,textarea{{max-width:100%}}
nav a{{display:inline-flex;align-items:center;min-height:32px}}
@media(max-width:680px){{.grid2,.grid3{{grid-template-columns:1fr}}
nav a,button,button.sm{{min-height:44px}}
input[type=checkbox]{{width:18px;height:18px}}}}
</style></head><body><header><div class="bar">
<strong><a href="/" style="color:inherit;text-decoration:none">ASR 录音工作台</a></strong>
{nav_html}
</div></header><main>{body}</main>{guard}{script}</body></html>""".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'<option value="{html.escape(pid)}"{is_sel}>'
f'{html.escape(p["label"])}{status_note}</option>'
)
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'<p style="color:var(--bad);font-weight:700">{html.escape(err)}</p>' if err else ""
body = f"""<section class="card" style="max-width:420px;margin:40px auto">
<h1>访问验证</h1><p class="muted">请输入工作台访问口令。口令来自环境变量 ASR_WEB_PASSWORD。</p>{alert}
<form method="post" action="/login">
<label for="pw">访问口令</label>
<input id="pw" name="password" type="password" autocomplete="current-password" required autofocus>
<p style="margin-top:14px"><button type="submit" style="width:100%">登录</button></p>
</form></section>"""
return shell("登录", body, nav=False)
def home(msg: str = "") -> bytes:
options = _provider_options(CFG.asr_provider)
notice = f'<div class="source-box">{html.escape(msg)}</div>' if msg else ""
body = f"""{notice}<section class="card"><h1>上传会议 / 通话录音</h1>
<p class="muted">支持 m4a、mp3、wav、mp4、aac、ogg、opus、webm、flac。单文件上限以服务端 ASR_UPLOAD_MAX_BYTES 为准(默认 4 GiB)。默认引擎可在模型配置中修改。</p>
<form id="up">
<div class="grid2">
<div><label for="file">录音文件</label><input id="file" type="file" accept="audio/*,video/mp4" required></div>
<div><label for="provider">语音识别模型 (ASR Provider)</label><select id="provider">{options}</select></div>
</div>
<div class="grid2">
<div><label for="title">标题(可选)</label><input id="title" maxlength="80" placeholder="例如:洛启生物尽调通话"></div>
<div><label for="model">指定子型号(可选,留空用默认)</label><input id="model" maxlength="80" placeholder="留空自动使用所选通道默认型号"></div>
</div>
<label for="context">已知背景(可选)</label>
<textarea id="context" maxlength="2000" placeholder="参与人、公司、项目或重点关注问题。将单独标记为用户提供背景,不会混入转写原文,并自动用于 Soniox/OpenAI 热词增强。"></textarea>
<p style="margin-top:14px"><button id="send" type="submit">上传并开始处理</button></p>
<progress id="bar" max="100" value="0" hidden></progress>
<div id="msg" class="muted"></div></form></section>
<section class="card">
<div class="row">
<h2>任务列表</h2>
<div class="row" style="gap:8px">
<input id="q" placeholder="搜索标题 / 文件名 / 背景…" style="width:210px;padding:6px 10px">
<select id="filter" style="width:auto;padding:6px 10px">
<option value="">全部状态</option><option value="review">等待确认</option>
<option value="queued">排队中</option><option value="transcribing">正在转写</option>
<option value="refining">正在整理</option><option value="complete">已完成</option>
<option value="failed">处理失败</option>
</select>
<select id="pfilter" style="width:auto;padding:6px 10px">
<option value="">全部引擎</option><option value="qwen3cam">qwen3cam</option>
<option value="soniox">soniox</option><option value="gemini">gemini</option>
<option value="openai">openai</option><option value="grok">grok</option>
</select>
</div>
</div>
<form method="post" action="/jobs/batch-delete" id="batchForm" onsubmit="return confirm('确认删除所选任务及其关联音频/转写/草稿?')">
<div class="row" style="margin:10px 0;padding-bottom:8px;border-bottom:1px solid var(--line)">
<div class="row" style="gap:10px">
<label style="margin:0;font-weight:500"><input type="checkbox" id="selAll"> 全选</label>
<input type="hidden" name="delete_files" value="1">
<button type="submit" class="sm danger" id="batchDelBtn" disabled>批量删除选中</button>
</div>
<div class="row" style="gap:8px">
<button type="submit" name="cleanup_failed" value="1" class="sm alt" onclick="return confirm('确认清理全部失败任务?')">清理所有失败任务</button>
</div>
</div>
<div id="jobs">读取中…</div>
</form>
</section>"""
script = """<script>
const E=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const flt=document.querySelector('#filter'), pflt=document.querySelector('#pfilter'), qIn=document.querySelector('#q');
const selAll=document.querySelector('#selAll'), batchBtn=document.querySelector('#batchDelBtn');
function syncBatchBtn(){
const checked=document.querySelectorAll('.job-chk:checked').length;
batchBtn.disabled=checked===0;
batchBtn.textContent=checked?`批量删除选中 (${checked})`:'批量删除选中';
}
selAll.onchange=()=>{
document.querySelectorAll('.job-chk').forEach(c=>{c.checked=selAll.checked});
syncBatchBtn();
};
async function delOne(id,name){
if(!confirm(`确认删除任务「${name}」及其音频、转写和草稿文件?`))return;
const fd=new URLSearchParams({delete_files:'1'});
await fetch(`/jobs/${id}/delete`,{method:'POST',body:fd});
load();
}
async function load(){
const params=new URLSearchParams();
if(flt.value)params.set('status',flt.value);
if(pflt.value)params.set('provider',pflt.value);
if(qIn.value.trim())params.set('q',qIn.value.trim());
const r=await fetch('/ui/jobs'+(params.toString()?'?'+params.toString():''),{cache:'no-store'});
const d=await r.json();
const box=document.querySelector('#jobs');
const prevChecked=new Set(Array.from(document.querySelectorAll('.job-chk:checked')).map(c=>c.value));
box.innerHTML=d.jobs.length?d.jobs.map(j=>`<div class="job"><div class="row">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<input type="checkbox" class="job-chk" name="job_id" value="${E(j.id)}" ${prevChecked.has(j.id)?'checked':''} onchange="syncBatchBtn()">
<strong><a href="/jobs/${E(j.id)}/">${E(j.title||j.original_name)}</a></strong>
<span class="tag">${E(j.asr_provider||'qwen3cam')}${j.asr_model?':'+E(j.asr_model):''}</span>
${j.pending_reviews?`<a href="/jobs/${E(j.id)}/review/" class="badge review">待确认 ${j.pending_reviews} 项</a>`:''}
</div>
<div class="row" style="gap:8px">
<span class="badge ${E(j.status)}">${E(j.status_label)}</span>
<button type="button" class="sm alt js-del" style="color:var(--bad)" data-del="${E(j.id)}" data-name="${E(j.title||j.original_name)}">删除</button>
</div></div>
<div class="muted" style="margin-left:24px">${new Date(j.created_at*1000).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false})} · ${E(j.original_name)} (${(j.size/1048576).toFixed(1)} MB) · ${E(j.detail||'')}</div>
<div style="margin-top:6px;margin-left:24px">
<a href="/jobs/${E(j.id)}/">任务详情 / 试听与编辑</a>
${j.pending_reviews?` · <a href="/jobs/${E(j.id)}/review/">前往人工确认 (${j.pending_reviews}) →</a>`:''}
${j.artifacts&&j.artifacts.result?` · <a href="/jobs/${E(j.id)}/artifact/result">下载正式纪要</a>`:''}
</div>
</div>`).join(''):'<p class="muted">暂无匹配任务</p>';
box.querySelectorAll('.js-del').forEach(b=>b.addEventListener('click',()=>delOne(b.getAttribute('data-del'), b.getAttribute('data-name')||'')));
syncBatchBtn();
}
flt.onchange=load; pflt.onchange=load; qIn.oninput=()=>{clearTimeout(window._qt);window._qt=setTimeout(load,300)};
load(); setInterval(()=>{if(!document.querySelector('.job-chk:checked'))load()},8000);
document.querySelector('#up').onsubmit=x=>{
x.preventDefault();
const f=document.querySelector('#file').files[0]; if(!f)return;
if(!/\\.(m4a|mp3|wav|mp4|aac|ogg|opus|webm|flac)$/i.test(f.name)){
document.querySelector('#msg').textContent='不支持的文件类型,请选择音频或 mp4。'; return;
}
const q=new XMLHttpRequest(), btn=document.querySelector('#send'), bar=document.querySelector('#bar'), msg=document.querySelector('#msg');
btn.disabled=true; bar.hidden=false; msg.textContent='正在上传…';
q.open('POST','/ui/upload');
q.setRequestHeader('X-Filename',encodeURIComponent(f.name));
q.setRequestHeader('X-Title',encodeURIComponent(document.querySelector('#title').value));
q.setRequestHeader('X-Context',encodeURIComponent(document.querySelector('#context').value));
q.setRequestHeader('X-ASR-Provider',encodeURIComponent(document.querySelector('#provider').value));
q.setRequestHeader('X-ASR-Model',encodeURIComponent(document.querySelector('#model').value));
q.upload.onprogress=v=>{if(v.lengthComputable)bar.value=Math.round(v.loaded/v.total*100)};
q.onload=()=>{
btn.disabled=false; bar.hidden=true;
if(q.status===201){msg.textContent='上传成功,已进入处理队列。';x.target.reset();load();}
else{
let t=q.responseText||('HTTP '+q.status);
try{const j=JSON.parse(q.responseText); const map={{'unsupported type':'不支持的文件类型','invalid size':'文件大小不合法','unauthorized':'需要登录','csrf':'页面已过期,请刷新后再试','incomplete upload':'上传未完成'}}; t=map[j.error]||j.error||t;}catch(e){{}}
msg.textContent='上传失败:'+t;
}
};
q.onerror=()=>{btn.disabled=false;msg.textContent='网络中断,请重试';};
q.send(f);
};
</script>"""
return shell("任务工作台", body, script)
def _read_artifact_text(job: dict, name: str) -> str:
roots = {"transcript": TRANSCRIPTS, "draft": DRAFTS, "result": RESULTS}
raw_path = str(job.get(name) or "")
if not raw_path:
return ""
path = Path(raw_path)
allowed_roots = [roots[name].resolve(), CFG.minutes.resolve(), CFG.drafts.resolve()]
for root in allowed_roots:
try:
path.resolve().relative_to(root)
if path.is_file():
return path.read_text(encoding="utf-8", errors="ignore")
except (OSError, ValueError):
continue
return ""
def job_page(job: dict, msg: str = "") -> bytes:
jid = job["id"]
notice = f'<div class="source-box">{html.escape(msg)}</div>' if msg else ""
events_html = "".join(
f"<p><strong>{html.escape(LABELS.get(e['status'], e['status']))}</strong> "
f"<span class='muted'>{fmt_ts(e['created_at'])}</span>"
f"<br><span class='muted'>{html.escape(e['detail'])}</span></p>"
for e in db.events(jid)
)
links = " · ".join(
f'<a href="/jobs/{jid}/artifact/{n}">下载{label} (.md/.txt)</a>'
for n, label in (("result", "正式纪要"), ("draft", "精修草稿"), ("transcript", "原始转写"))
if job.get(n)
)
audio_html = (
f'<audio controls preload="metadata" src="/jobs/{jid}/audio"></audio>'
if find_audio_file(job)
else '<p class="muted">原音频文件不在活动目录</p>'
)
pend = pending(job)
review_banner = (
f'<div class="source-box"><strong>当前有 {len(pend)} 个问题待你确认</strong> · '
f'<a href="/jobs/{jid}/review/">立即前往确认页面 →</a></div>'
if pend else ""
)
options = _provider_options(job.get("asr_provider") or CFG.asr_provider)
has_transcript = bool(job.get("transcript") and Path(str(job["transcript"])).is_file())
rerefine_btn = (
f'<form method="post" action="/jobs/{jid}/rerefine" style="display:inline">'
f'<button type="submit" class="alt sm">基于已有转写重新生成草稿 (免重跑 ASR)</button></form>'
if has_transcript else ""
)
meta_form = f"""<details style="margin-top:12px"><summary style="cursor:pointer;font-weight:650">
编辑任务信息 / 补充背景 / 切换 ASR 模型重跑</summary>
<form method="post" action="/jobs/{jid}/meta" style="margin-top:10px">
<div class="grid2">
<div><label>任务标题</label><input name="title" maxlength="80" value="{html.escape(str(job.get('title') or ''))}"></div>
<div><label>语音识别引擎 (ASR Provider)</label><select name="provider">{options}</select></div>
</div>
<label>指定 ASR 子型号(可选)</label>
<input name="model" maxlength="80" value="{html.escape(str(job.get('asr_model') or ''))}" placeholder="留空使用默认型号">
<label>已知背景说明(修改后可点「保存并重新生成草稿」)</label>
<textarea name="context" maxlength="2000">{html.escape(str(job.get('context') or ''))}</textarea>
<div class="row" style="margin-top:10px;justify-content:flex-start;gap:10px">
<button type="submit" name="next_action" value="save" class="sm">仅保存修改</button>
{'<button type="submit" name="next_action" value="rerefine" class="sm alt">保存并重新生成草稿 (免重跑 ASR)</button>' if has_transcript else ''}
{'<button type="submit" name="next_action" value="retry" class="sm alt">保存并重新转写音频 (重跑 ASR,会再次占用识别服务)</button>' if job.get("status") not in BUSY_STATUSES else '<span class="muted">正在处理,不能同时重新转写</span>'}
</div>
</form></details>"""
delete_form = f"""<details style="margin-top:10px"><summary style="cursor:pointer;font-weight:650;color:var(--bad)">
删除此任务与产物…</summary>
<form method="post" action="/jobs/{jid}/delete" onsubmit="return confirm('确认永久删除该任务?')" style="margin-top:8px;padding:12px;border:1px solid var(--bad);border-radius:8px">
<label style="margin:4px 0;font-weight:500"><input type="checkbox" name="delete_files" value="1" checked> 同时删除原音频、原始转写与私有草稿文件</label>
<label style="margin:4px 0;font-weight:500"><input type="checkbox" name="delete_result" value="1"> 同时删除已写入 Obsidian 的正式纪要文件(慎选)</label>
<p style="margin-top:10px"><button type="submit" class="danger sm">确认永久删除此任务</button></p>
</form></details>"""
sections = []
for key, label in (("result", "正式纪要"), ("draft", "精修草稿"), ("transcript", "原始转写")):
content = _read_artifact_text(job, key)
if not content:
continue
open_attr = " open" if key in ("result", "draft") else ""
edit_form = ""
if key in ("draft", "result"):
edit_form = f"""<details style="margin-top:8px"><summary class="muted" style="cursor:pointer">在线编辑 {label} Markdown</summary>
<form method="post" action="/jobs/{jid}/edit">
<input type="hidden" name="target" value="{key}">
<textarea name="content" style="min-height:260px;font-family:monospace">{html.escape(content)}</textarea>
<div class="row" style="margin-top:8px;justify-content:flex-start;gap:10px">
<button type="submit" class="sm">保存 Markdown 修改</button>
</div></form></details>"""
sections.append(
f'<section class="card"><details{open_attr}><summary style="font-weight:700;font-size:16px;cursor:pointer">'
f'{label}</summary><pre style="margin-top:12px">{html.escape(content)}</pre>{edit_form}</details></section>'
)
prov_tag = f"{job.get('asr_provider') or CFG.asr_provider}"
if job.get("asr_model"):
prov_tag += f":{job['asr_model']}"
ctx_html = (
f'<p class="muted"><strong>已知背景:</strong>{html.escape(str(job["context"]))}</p>'
if job.get("context") else ""
)
body = f"""{notice}<section class="card"><div class="row">
<div><h1>{html.escape(job.get('title') or job['original_name'])}</h1>
<span class="tag">ASR: {html.escape(prov_tag)}</span>
<span class="tag">文件: {html.escape(job['original_name'])}</span>
<span class="tag">尝试: {int(job.get('attempts') or 0)} 次</span></div>
<span class="badge {html.escape(job['status'])}">{html.escape(LABELS.get(job['status'], job['status']))}</span>
</div>
<p>{html.escape(job.get('detail', ''))}</p>
{ctx_html}
{review_banner}
{audio_html}
<div class="row" style="margin-top:8px;justify-content:space-between">
<div>{links}</div>
<div>{rerefine_btn}</div>
</div>
{meta_form}
{delete_form}
</section>
{''.join(sections)}
<section class="card"><h2>处理时间线</h2><div class="timeline">{events_html}</div></section>"""
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'<button class="choice sm" type="submit" name="single_submit" '
f'value="{qid}::{html.escape(str(v), quote=True)}">{html.escape(str(v))}</button>'
for v in choices
)
quick_btns += (
f'<button class="choice sm alt" type="submit" name="single_submit" '
f'value="{qid}::无法确认,保留原文">跳过 / 保留原文</button>'
)
sentence_html = f'<div class="source-box">{html.escape(sentence)}</div>' if sentence else ""
return f"""<form class="q-item" method="post" action="{html.escape(action_url)}">
<button type="submit" name="single_custom" value="{qid}" hidden>提交第 {qid} 题</button>
<div class="row">
<div><span class="tag">{html.escape(kind_label)}</span> <strong>#{qid} · {html.escape(row['question'])}</strong></div>
</div>
{sentence_html}
<div style="margin:8px 0">{quick_btns}</div>
<div class="row" style="flex-wrap:nowrap;gap:6px;margin-top:8px">
<label class="muted" for="custom_{qid}" style="margin:0">本题回答</label>
<input id="custom_{qid}" name="custom_{qid}" maxlength="200" autocomplete="off" aria-label="第 {qid} 题的自定义回答" placeholder="{html.escape(placeholder, quote=True)}">
<button type="submit" name="single_custom" value="{qid}" class="sm alt" style="white-space:nowrap">提交本题</button>
</div>
</form>"""
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 = '<option value="">-- 批量:选择预设 --</option>' + "".join(
f'<option value="{html.escape(str(v), quote=True)}">{html.escape(str(v))}</option>'
for v in [*choices, "无法确认,保留原文"]
)
return (
f'<label class="muted" for="preset_{qid}">#{qid}</label>'
f'<select id="preset_{qid}" name="preset_{qid}" aria-label="第 {qid} 题批量预设">{select_opts}</select>'
)
def review_page(focus_job: dict | None = None, msg: str = "") -> bytes:
groups = _group_pending_by_job()
notice = f'<div class="source-box">{html.escape(msg)}</div>' 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'<a class="tab{all_active}" href="/review/">全部待确认录音 ({len(groups)} 场 / {total_q} 题)</a>'
)
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'<a class="tab{is_act}" href="/jobs/{html.escape(j["id"])}/review/">'
f'{html.escape(title_lbl)} ({len(qs)})</a>'
)
switcher_html = (
f'<div class="tabs">{"".join(switcher_items)}</div>'
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'<audio controls preload="metadata" src="/jobs/{jid}/audio"></audio>'
if find_audio_file(j) else ""
)
draft_text = _read_artifact_text(j, "draft")
draft_box = (
f'<details style="margin:8px 0"><summary style="cursor:pointer;font-weight:650">'
f'展开对照本场完整精修草稿</summary><pre style="margin-top:8px">{html.escape(draft_text)}</pre></details>'
if draft_text else ""
)
hdr_html = f"""<div class="row">
<div><h2><a href="/jobs/{html.escape(jid)}/">{html.escape(title_str)}</a></h2>
<span class="tag">ASR: {html.escape(prov_str)}</span>
<span class="muted">{fmt_ts(j['created_at'], with_seconds=False)} · 待确认 {len(rows)} 题</span></div>
<div class="row" style="gap:8px">
<a href="/jobs/{html.escape(jid)}/" class="tab">查看任务详情</a>
</div></div>{audio_bar}{draft_box}"""
else:
hdr_html = f"""<div class="row"><h2>未关联任务的历史待确认项 ({len(rows)} 题)</h2></div>"""
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"""<section class="card">
{hdr_html}
{q_forms}
<form method="post" action="{html.escape(action_url)}">
<input type="hidden" name="question_ids" value="{q_ids_csv}">
<div class="grid2" style="margin-top:8px">{batch_selects}</div>
<div class="row" style="margin-top:12px;padding-top:10px;border-top:1px solid var(--line)">
<span class="muted">每一题有自己的输入框,回车只提交该题。下面的下拉框用于一次提交多题。</span>
<div class="row" style="gap:8px">
<button type="submit" name="batch_submit" value="1">批量提交本录音已选预设</button>
<button type="submit" name="batch_skip_all" value="1" class="alt sm" onclick="return confirm('确认将本场录音剩余问题全部标记为保留原文?')">本场全部保留原文</button>
</div>
</div>
</form></section>""")
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'<form method="post" action="/jobs/{html.escape(focus_job["id"])}/revise" style="margin-top:12px">'
f'<button type="submit">立即应用已确认结果并触发修订定稿</button></form>'
)
group_cards.append(
f'<section class="card"><h1>当前没有待确认问题</h1>'
f'<p class="muted">所有已入队的确认项均已处理完毕。</p>{revise_btn}'
f'<p style="margin-top:12px"><a href="{html.escape(back_url)}">← 返回任务工作台</a></p></section>'
)
# 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'<tr><td>#{qid}</td><td><span class="muted">{html.escape(rec_stem[:16])}</span><br>'
f'{html.escape(str(r["question"])[:70])}</td>'
f'<td><form method="post" action="/review/update-answer" class="row" style="flex-wrap:nowrap;gap:6px">'
f'<input type="hidden" name="id" value="{qid}">'
f'<input name="answer" value="{html.escape(str(r.get("answer") or ""), quote=True)}" style="min-width:140px">'
f'<button type="submit" class="sm alt" onclick="return confirm(\'修改回答会写入确认记录。若这是说话人身份,会再次向声纹服务登记。\')">改答</button>'
f'<button type="submit" name="reopen" value="1" class="sm alt" onclick="return confirm(\'撤回后该题回到待确认,已学习的别名不会自动删除。\')">撤回重答</button>'
f'</form></td></tr>'
)
history_html = (
f'<section class="card"><details><summary style="cursor:pointer;font-weight:700">'
f'近期已确认记录(最近 {len(ans_rows)} 条,支持修改或撤回重答)</summary>'
f'<table style="margin-top:10px"><thead><tr><th>ID</th><th>问题</th><th>已确认回答 / 操作</th></tr></thead>'
f'<tbody>{"".join(ans_rows)}</tbody></table></details></section>'
if ans_rows else ""
)
body = f"""{notice}<section class="card">
<div class="row"><h1>多录音人工确认中心</h1>
<span class="muted">支持按录音分组切换、单题快捷点击与单场录音批量提交</span></div>
{switcher_html}
</section>
{''.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'<a class="tab" href="{href(page - 1)}">上一页</a>' if page > 1 else ""
nxt = f'<a class="tab" href="{href(page + 1)}">下一页</a>' if page < pages else ""
return (
f'<p class="muted">共 {total} 条,第 {page}/{pages} 页,每页 {LEXICON_PAGE_SIZE} 条。 {prev} {nxt}</p>'
)
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'<div class="source-box">{html.escape(msg)}</div>' if msg else ""
tabs_html = "".join(
f'<a class="tab{" active" if tab == t else ""}" href="/lexicon?tab={t}&q={urllib.parse.quote(q)}">{lbl}</a>'
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"""<tr>
<form method="post" action="/lexicon/alias/update">
<td><input type="hidden" name="old_alias" value="{html.escape(al, quote=True)}">
<input type="hidden" name="old_canonical" value="{html.escape(can, quote=True)}">
<input name="alias" value="{html.escape(al, quote=True)}" required maxlength="40"></td>
<td><input name="canonical" value="{html.escape(can, quote=True)}" required maxlength="40"></td>
<td><select name="kind">
<option value="人名"{' selected' if knd == '人名' else ''}>人名</option>
<option value="公司"{' selected' if knd == '公司' else ''}>公司</option>
<option value="项目"{' selected' if knd == '项目' else ''}>项目</option>
<option value="术语"{' selected' if knd == '术语' else ''}>术语</option>
</select></td>
<td><span class="tag">{html.escape(org)}</span></td>
<td><label style="margin:0"><input type="checkbox" name="confirmed" value="1"{' checked' if conf else ''}> 已确认</label></td>
<td><input name="note" value="{html.escape(nt, quote=True)}" maxlength="120" placeholder="备注"></td>
<td style="white-space:nowrap">
<button type="submit" class="sm">保存</button>
<button type="submit" formaction="/lexicon/alias/delete" class="sm danger" onclick="return confirm('确认删除该别名映射?')">删除</button>
</td>
</form></tr>""")
content_html = f"""<section class="card"><h2>新增别名映射 (同时写入发音纠错)</h2>
<form method="post" action="/lexicon/learn">
<div class="grid3">
<div><label>口语简称 / 误识别词</label><input name="alias" required maxlength="40" placeholder="例如:老万 / 张泰海"></div>
<div><label>对应规范全名</label><input name="canonical" required maxlength="40" placeholder="例如:万亚坤 / 张成海"></div>
<div><label>类型</label><select name="kind"><option value="人名">人名</option><option value="公司">公司</option><option value="项目">项目</option><option value="术语">术语</option></select></div>
</div>
<label>备注(可选)</label><input name="note" maxlength="120" placeholder="例如:洛启生物实控人">
<p style="margin-top:10px"><button type="submit" class="sm">新增并保存</button></p>
</form></section>
<section class="card"><h2>别名映射列表</h2>{pager}
<table><thead><tr><th>简称 / 别名</th><th>规范全名</th><th>类型</th><th>来源</th><th>确认</th><th>备注</th><th>操作</th></tr></thead>
<tbody>{''.join(tr_list) or '<tr><td colspan="7" class="muted">暂无数据</td></tr>'}</tbody></table></section>"""
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"""<tr>
<form method="post" action="/lexicon/correction/update">
<td><input type="hidden" name="old_surface" value="{html.escape(sf, quote=True)}">
<input type="hidden" name="old_canonical" value="{html.escape(can, quote=True)}">
<input name="surface" value="{html.escape(sf, quote=True)}" required maxlength="40"></td>
<td><input name="canonical" value="{html.escape(can, quote=True)}" required maxlength="40"></td>
<td>{cnt} 次</td>
<td><label style="margin:0"><input type="checkbox" name="confirmed" value="1"{' checked' if conf else ''}> 已确认</label></td>
<td><input name="note" value="{html.escape(nt, quote=True)}" maxlength="120" placeholder="备注"></td>
<td style="white-space:nowrap">
<button type="submit" class="sm">保存</button>
<button type="submit" formaction="/lexicon/correction/delete" class="sm danger" onclick="return confirm('确认删除该纠错条目?')">删除</button>
</td>
</form></tr>""")
content_html = f"""<section class="card"><h2>新增同音 / ASR 误识纠错</h2>
<form method="post" action="/lexicon/learn">
<input type="hidden" name="tab" value="corrections">
<div class="grid2">
<div><label>ASR 误识别词 (Surface)</label><input name="alias" required maxlength="40" placeholder="例如:张泰海"></div>
<div><label>正确规范词 (Canonical)</label><input name="canonical" required maxlength="40" placeholder="例如:张成海"></div>
</div>
<label>备注(可选)</label><input name="note" maxlength="120" placeholder="例如:发音相近纠错">
<p style="margin-top:10px"><button type="submit" class="sm">新增纠错</button></p>
</form></section>
<section class="card"><h2>发音纠错列表</h2>{pager}
<table><thead><tr><th>误识别词</th><th>正确规范词</th><th>频次</th><th>确认</th><th>备注</th><th>操作</th></tr></thead>
<tbody>{''.join(tr_list) or '<tr><td colspan="6" class="muted">暂无数据</td></tr>'}</tbody></table></section>"""
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"""<tr>
<form method="post" action="/lexicon/entity/update">
<td><input type="hidden" name="old_canonical" value="{html.escape(can, quote=True)}">
<input name="canonical" value="{html.escape(can, quote=True)}" required maxlength="60"></td>
<td><select name="kind">
<option value="人名"{' selected' if knd == '人名' else ''}>人名</option>
<option value="公司"{' selected' if knd == '公司' else ''}>公司</option>
<option value="项目"{' selected' if knd == '项目' else ''}>项目</option>
</select></td>
<td><input name="company" value="{html.escape(comp, quote=True)}" maxlength="80" placeholder="所属公司"></td>
<td><input name="role" value="{html.escape(role, quote=True)}" maxlength="80" placeholder="职务/角色"></td>
<td><input name="related" value="{html.escape(rel, quote=True)}" maxlength="160" placeholder="关联人物(逗号分隔)"></td>
<td><span class="tag">{src_tag}</span></td>
<td style="white-space:nowrap">
<button type="submit" class="sm">保存</button>
<button type="submit" formaction="/lexicon/entity/delete" class="sm danger" onclick="return confirm('确认删除该实体记录?')">删除</button>
</td>
</form></tr>""")
content_html = f"""<section class="card"><h2>新增实体卡片记录(手动维护实体不会被 Vault 同步覆盖)</h2>
<form method="post" action="/lexicon/entity/update">
<input type="hidden" name="old_canonical" value="">
<div class="grid3">
<div><label>规范名称</label><input name="canonical" required maxlength="60" placeholder="例如:万亚坤 / 洛启生物"></div>
<div><label>类型</label><select name="kind"><option value="人名">人名</option><option value="公司">公司</option><option value="项目">项目</option></select></div>
<div><label>所属公司 / 机构</label><input name="company" maxlength="80" placeholder="例如:洛启生物"></div>
</div>
<div class="grid2">
<div><label>职务 / 角色</label><input name="role" maxlength="80" placeholder="例如:创始人 / 实控人"></div>
<div><label>关联人(逗号分隔)</label><input name="related" maxlength="160" placeholder="例如:高瑶,谭凯"></div>
</div>
<p style="margin-top:10px"><button type="submit" class="sm">新增实体</button></p>
</form></section>
<section class="card"><h2>实体列表</h2>{pager}
<table><thead><tr><th>规范名称</th><th>类型</th><th>公司/机构</th><th>职务/角色</th><th>关联人</th><th>来源</th><th>操作</th></tr></thead>
<tbody>{''.join(tr_list) or '<tr><td colspan="7" class="muted">暂无数据</td></tr>'}</tbody></table></section>"""
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"""<tr>
<form method="post" action="/lexicon/relation/update">
<td><input type="hidden" name="old_subject" value="{html.escape(subj, quote=True)}">
<input type="hidden" name="old_predicate" value="{html.escape(pred, quote=True)}">
<input type="hidden" name="old_object" value="{html.escape(obj, quote=True)}">
<input type="hidden" name="old_source" value="{html.escape(src, quote=True)}">
<input name="subject" value="{html.escape(subj, quote=True)}" required maxlength="60"></td>
<td><input name="predicate" value="{html.escape(pred, quote=True)}" required maxlength="40"></td>
<td><input name="object" value="{html.escape(obj, quote=True)}" required maxlength="60"></td>
<td><input name="confidence" type="number" step="0.05" min="0" max="1" value="{conf:.2f}" style="width:75px"></td>
<td><label style="margin:0"><input type="checkbox" name="active" value="1"{' checked' if act else ''}> 启用</label></td>
<td style="white-space:nowrap">
<button type="submit" class="sm">保存</button>
<button type="submit" formaction="/lexicon/relation/delete" class="sm danger" onclick="return confirm('确认删除该关系记录?')">删除</button>
</td>
</form></tr>""")
content_html = f"""<section class="card"><h2>新增人物 / 机构关系</h2>
<form method="post" action="/lexicon/relation/update">
<input type="hidden" name="old_subject" value="">
<div class="grid3">
<div><label>主体 (Subject)</label><input name="subject" required maxlength="60" placeholder="例如:万亚坤"></div>
<div><label>关系谓词 (Predicate)</label><input name="predicate" required maxlength="40" placeholder="例如:任职于 / 实控人 / 投资"></div>
<div><label>客体 (Object)</label><input name="object" required maxlength="60" placeholder="例如:洛启生物"></div>
</div>
<input type="hidden" name="confidence" value="0.95">
<input type="hidden" name="active" value="1">
<p style="margin-top:10px"><button type="submit" class="sm">新增关系</button></p>
</form></section>
<section class="card"><h2>关系图谱列表</h2>{pager}
<table><thead><tr><th>主体</th><th>关系</th><th>客体</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead>
<tbody>{''.join(tr_list) or '<tr><td colspan="6" class="muted">暂无数据</td></tr>'}</tbody></table></section>"""
body = f"""{notice}<section class="card">
<div class="row"><h1>实体与纠错词库管理 (SQLite + Obsidian)</h1>
<form method="post" action="/lexicon/sync"><button type="submit" class="alt sm">从 Obsidian Vault 同步卡片与图谱</button></form></div>
<p class="muted">实体库:<strong>{st.get('entities', 0)}</strong> 个实体({st.get('with_company', 0)} 含公司归属),
<strong>{st.get('aliases', 0)}</strong> 条别名(卡片 {st.get('aliases_card', 0)} + 学习/人工 {st.get('aliases_learned', 0)}),
<strong>{st.get('corrections', 0)}</strong> 条发音纠错;关系图谱:<strong>{gst.get('relations', 0)}</strong> 条关系,
<strong>{gst.get('documents', 0)}</strong> 篇已索引纪要。</p>
<div class="row" style="margin-top:10px">
<div class="tabs" style="margin-bottom:0;border-bottom:0;padding-bottom:0">{tabs_html}</div>
<form method="get" action="/lexicon" class="row" style="gap:6px">
<input type="hidden" name="tab" value="{html.escape(tab)}">
<input name="q" value="{html.escape(q, quote=True)}" placeholder="搜索当前分类…" style="width:190px;padding:6px 10px">
<button type="submit" class="sm alt">搜索</button>
{f'<a href="/lexicon?tab={html.escape(tab)}" class="muted">清除</a>' if q else ''}
</form>
</div>
</section>
{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'<div class="source-box">{html.escape(msg)}</div>' 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 = '<span class="badge">就绪</span>' if p["ready"] else '<span class="badge failed">未配密钥</span>'
prov_rows.append(
f"<tr><td><strong>{html.escape(p['id'])}</strong></td>"
f"<td>{html.escape(p['label'])}</td>"
f"<td><code>{html.escape(p['model'])}</code></td>"
f"<td>{_configured(p['endpoint'])}</td>"
f"<td>{html.escape(p['via'])}</td>"
f"<td>{badge}</td></tr>"
)
hs_ok = False
if CFG.use_hindsight and CFG.hindsight_api:
try:
with urllib.request.urlopen(f"{CFG.hindsight_api}/health", timeout=3) as r:
hs_ok = r.status == 200
except Exception:
hs_ok = False
vault_ok = CFG.vault.is_dir()
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}<section class="card">
<div class="row"><h1>模型与系统配置 (SQLite 持久化)</h1>
<span class="tag">配置保存在 SQLite app_setting,页面不回显地址和密钥</span></div>
<p class="muted">模型名会显示在下面的输入框里。网关地址和 API 密钥只显示「已配置 / 未配置」,留空表示保持原值。保存后下一次模型调用会读取新密钥。</p>
<form method="post" action="/settings">
<h2>1. 后处理大模型网关 (LiteLLM) 与各阶段模型</h2>
<div class="grid2">
<div><label>LiteLLM Base URL</label>
<input name="litellm_base_url" value="" placeholder="留空保持不变(当前:{_configured(CFG.litellm_base_url)})" autocomplete="off"></div>
<div><label>LiteLLM API Key(当前:{html.escape(litellm_key_mask)};留空保持不变)</label>
<input name="litellm_api_key" type="password" autocomplete="new-password" placeholder="留空不修改已有密钥;输入 CLEAR 清空"></div>
</div>
<div class="grid3">
<div><label>清洗与去口语化模型 (Clean Model)</label>
<input id="clean_model" name="clean_model" value="{html.escape(CFG.clean_model, quote=True)}"></div>
<div><label>实体候选词召回模型 (Keyword Model)</label>
<input id="keyword_model" name="keyword_model" value="{html.escape(CFG.keyword_model, quote=True)}"></div>
<div><label>纪要成稿与裁决模型 (Draft Model)</label>
<input id="draft_model" name="draft_model" value="{html.escape(CFG.draft_model, quote=True)}"></div>
</div>
<p style="margin:8px 0">
<button type="button" class="sm alt" onclick="probeModels()">探测当前 LiteLLM 可用模型列表</button>
<span id="probeMsg" class="muted" style="margin-left:8px"></span>
</p>
<div id="modelChips" style="margin:6px 0"></div>
<h2 style="margin-top:18px">2. 语音识别引擎 (ASR Providers) 配置</h2>
<div class="grid3">
<div><label>默认 ASR 引擎</label><select name="asr_provider">{prov_opts}</select></div>
<div><label>内网 Qwen3-ASR+CAM++ 转写地址</label>
<input name="diarize_url" value="" placeholder="留空保持不变({_configured(CFG.diarize_url)})" autocomplete="off"></div>
<div><label>内网声纹增量注册地址</label>
<input name="voiceprint_enroll_url" value="" placeholder="留空保持不变({_configured(CFG.voiceprint_enroll_url)})" autocomplete="off"></div>
</div>
<div class="grid3">
<div><label>Soniox API Base</label>
<input name="soniox_api_base" value="" placeholder="留空保持不变({_configured(CFG.soniox_api_base)})" autocomplete="off"></div>
<div><label>Soniox 默认模型</label>
<input name="soniox_model" value="{html.escape(CFG.soniox_model, quote=True)}"></div>
<div><label>Soniox API Key(当前:{html.escape(soniox_key_mask)})</label>
<input name="soniox_api_key" type="password" autocomplete="new-password" placeholder="留空保持不变;输入 CLEAR 清空"></div>
</div>
<div class="grid2">
<div><label>Gemini 语音转写模型(经 LiteLLM 路由)</label>
<input id="gemini_asr_model" name="gemini_asr_model" value="{html.escape(CFG.gemini_asr_model, quote=True)}"></div>
<div><label>OpenAI 语音转写模型(经 LiteLLM 路由)</label>
<input id="openai_asr_model" name="openai_asr_model" value="{html.escape(CFG.openai_asr_model, quote=True)}"></div>
</div>
<div class="grid3">
<div><label>Grok (xAI) API Base</label>
<input name="grok_api_base" value="" placeholder="留空保持不变({_configured(CFG.grok_api_base)})" autocomplete="off"></div>
<div><label>Grok 语音转写模型</label>
<input name="grok_asr_model" value="{html.escape(CFG.grok_asr_model, quote=True)}"></div>
<div><label>Grok XAI API Key(当前:{html.escape(grok_key_mask)})</label>
<input name="grok_api_key" type="password" autocomplete="new-password" placeholder="留空保持不变;输入 CLEAR 清空"></div>
</div>
<h2 style="margin-top:18px">3. 知识增强与访问安全 (Hindsight / Termbank / Obsidian / Web 口令)</h2>
<div class="grid3">
<div><label>Hindsight API 地址</label>
<input name="hindsight_api" value="" placeholder="留空保持不变({_configured(CFG.hindsight_api)})" autocomplete="off"></div>
<div><label>Hindsight Bank 名称</label>
<input name="hindsight_bank" value="{html.escape(CFG.hindsight_bank, quote=True)}"></div>
<div><label>Web 访问口令(当前:{html.escape(web_pw_mask)};环境变量优先)</label>
<input name="web_password" type="password" autocomplete="new-password" placeholder="留空保持不变;修改后需重新登录"></div>
</div>
<div class="row" style="justify-content:flex-start;gap:24px;margin-top:10px">
<label style="margin:0"><input type="checkbox" name="use_hindsight" value="1"{' checked' if CFG.use_hindsight else ''}> 启用 Hindsight 记忆召回与写回</label>
<label style="margin:0"><input type="checkbox" name="use_termbank" value="1"{' checked' if CFG.use_termbank else ''}> 启用专业术语库 (Termbank)</label>
<label style="margin:0"><input type="checkbox" name="git_sync" value="1"{' checked' if CFG.git_sync else ''}> 正式纪要发布后执行 Vault Git 自动推送</label>
</div>
<p style="margin-top:16px"><button type="submit">保存全部配置至 SQLite</button></p>
</form></section>
<section class="card"><h2>实时运行与通道就绪状态</h2>
<table><tbody>
<tr><th>Worker 进程状态</th><td>{html.escape(hb_text)}</td></tr>
<tr><th>Hindsight 记忆服务</th><td>{_configured(CFG.hindsight_api)} · {'<span class="badge">在线</span>' if hs_ok else '<span class="badge review">离线或未连接</span>'}</td></tr>
<tr><th>Obsidian Vault</th><td>{'<span class="badge">已挂载</span>' if vault_ok else '<span class="badge review">未挂载</span>'}</td></tr>
</tbody></table>
<h3 style="margin-top:14px">ASR 通道表</h3>
<table><thead><tr><th>ID</th><th>名称</th><th>默认型号</th><th>接口地址</th><th>接入方式</th><th>状态</th></tr></thead>
<tbody>{''.join(prov_rows)}</tbody></table></section>"""
script = """<script>
async function probeModels(){
const msg=document.querySelector('#probeMsg'), box=document.querySelector('#modelChips');
msg.textContent='正在查询 LiteLLM /models…';
try{
const r=await fetch('/settings/models-probe',{cache:'no-store'});
const d=await r.json();
if(!d.ok){msg.textContent='查询失败:'+(d.error||'unknown');return;}
msg.textContent=`共发现 ${d.models.length} 个模型(点击模型名可填入下方对应输入框):`;
box.replaceChildren();
d.models.forEach(m=>{
const s=document.createElement('span');
s.className='tag'; s.style.cursor='pointer'; s.style.marginBottom='4px';
s.textContent=m;
s.addEventListener('click',()=>{ const el=document.querySelector('#draft_model'); if(el) el.value=m; });
box.appendChild(s);
});
}catch(e){msg.textContent='请求异常:'+e;}
}
</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'<section class="card"><h1>API 接口说明</h1><pre>{html.escape(json.dumps(doc, ensure_ascii=False, indent=2))}</pre></section>'))
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(
"未找到",
'<section class="card"><h1>页面不存在</h1>'
'<p class="muted">这个地址没有对应的页面。</p>'
'<p><a href="/">返回任务列表</a></p></section>',
), 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(
"未找到",
'<section class="card"><h1>页面不存在</h1><p><a href="/">返回任务列表</a></p></section>',
), 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()