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