from __future__ import annotations
import hashlib
import hmac
import html
import json
import mimetypes
import os
import re
import shutil
import threading
import time
import urllib.parse
import urllib.request
import uuid
from http import cookies
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from asrpipe import graph, lexicon, providers, review, vault
from asrpipe.config import CFG
from . import db
from .config import (
AGENT_TOKEN, DEFAULT_PROVIDER, DRAFTS, FAILED, INBOX, MAX_UPLOAD,
PROCESSED, PROCESSING, PUBLIC_URL, RESULTS, REVISION_QUEUE, STATE,
SUPPORTED, TRANSCRIPTS, WEB_PASSWORD, ensure_dirs,
)
LABELS = {
"uploading": "上传中",
"queued": "等待处理",
"transcribing": "正在转写",
"refining": "正在整理",
"review": "等待确认",
"publishing": "正在定稿",
"complete": "已完成",
"failed": "处理失败",
}
def safe(value: str, limit: int) -> str:
return re.sub(
r"\s+", " ", re.sub(r"[\x00-\x1f\x7f]+", " ", value or "")
).strip()[:limit]
def stem(value: str) -> str:
return (
re.sub(r"[^\w\u3400-\u9fff.-]+", "-", safe(value, 80)).strip("-.")
or "录音"
)
def find_audio_file(job: dict) -> Path | None:
stored = str(job.get("stored_name") or "")
if not stored:
return None
jid = str(job.get("id") or "")[:8]
base_stem = Path(stored).stem
suffix = Path(stored).suffix
for folder in (PROCESSED, PROCESSING, INBOX, FAILED):
if not folder.exists():
continue
direct = folder / stored
if direct.is_file():
return direct
if jid:
alt = folder / f"{base_stem}-{jid}{suffix}"
if alt.is_file():
return alt
return None
def public(row: dict, detailed: bool = False) -> dict:
keys = (
"id", "original_name", "title", "size", "status", "detail", "source",
"sha256", "attempts", "error_code", "asr_provider", "asr_model",
"created_at", "updated_at",
)
out = {k: row.get(k) for k in keys}
out["asr_provider"] = out.get("asr_provider") or DEFAULT_PROVIDER
out["status_label"] = LABELS.get(row["status"], row["status"])
out["web_url"] = f"{PUBLIC_URL}/jobs/{row['id']}/"
out["review_url"] = f"{PUBLIC_URL}/jobs/{row['id']}/review/"
out["artifacts"] = {
name: f"/api/v1/jobs/{row['id']}/artifacts/{name}"
for name in ("transcript", "draft", "result")
if row.get(name)
}
if detailed:
out["context"] = row.get("context", "")
out["events"] = db.events(row["id"])
return out
def pending(job: dict | None = None) -> list[dict]:
if not job:
return review.pending(200)
found = []
for row in review.pending(1000):
payload = json.loads(row["payload"] or "{}")
if (
(job.get("transcript") and row["recording"] == job["transcript"])
or (job.get("draft") and payload.get("draft_path") == job["draft"])
or payload.get("job_id") == job["id"]
):
found.append(row)
return found
def _session_token() -> str:
secret = (WEB_PASSWORD or AGENT_TOKEN or "local").encode("utf-8")
return hmac.new(secret, b"asr-web-session-v1", hashlib.sha256).hexdigest()
def shell(title: str, body: str, script: str = "") -> bytes:
return f"""
{html.escape(title)} · Meeting ASR {body} {script}""".encode("utf-8")
def _provider_options(selected: str = "") -> str:
sel = providers.normalize_provider_name(selected or DEFAULT_PROVIDER)
opts = []
for p in providers.provider_status():
pid = p["id"]
is_sel = " selected" if pid == sel else ""
status_note = "" if p["ready"] else " (待配置密钥)"
opts.append(
f''
f'{html.escape(p["label"])}{status_note} '
)
return "".join(opts)
def login_page(err: str = "") -> bytes:
alert = f'{html.escape(err)}
' if err else ""
body = f""""""
return shell("登录", body)
def home() -> bytes:
options = _provider_options(DEFAULT_PROVIDER)
body = f"""上传会议 / 通话录音
支持 m4a、mp3、wav、mp4、aac、ogg、opus、webm、flac。支持内网 Qwen3-ASR+CAM++ 及线上 Soniox / Gemini 3.5 / OpenAI / Grok。
最近任务
全部状态 等待确认
正在转写 正在整理
已完成 处理失败
读取中…
"""
script = """"""
return shell("任务工作台", body, script)
def _read_artifact_text(job: dict, name: str) -> str:
roots = {"transcript": TRANSCRIPTS, "draft": DRAFTS, "result": RESULTS}
raw_path = str(job.get(name) or "")
if not raw_path:
return ""
path = Path(raw_path)
allowed_roots = [roots[name].resolve(), CFG.minutes.resolve(), CFG.drafts.resolve()]
for root in allowed_roots:
try:
path.resolve().relative_to(root)
if path.is_file():
return path.read_text(encoding="utf-8", errors="ignore")
except (OSError, ValueError):
continue
return ""
def job_page(job: dict) -> bytes:
jid = job["id"]
events_html = "".join(
f"{html.escape(LABELS.get(e['status'], e['status']))} "
f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(e['created_at']))} "
f"{html.escape(e['detail'])}
"
for e in db.events(jid)
)
links = " · ".join(
f'下载{label} (.md/.txt) '
for n, label in (("result", "正式纪要"), ("draft", "精修草稿"), ("transcript", "原始转写"))
if job.get(n)
)
audio_html = (
f' '
if find_audio_file(job)
else '原音频文件不在活动目录
'
)
pend = pending(job)
review_banner = (
f''
if pend else ""
)
options = _provider_options(job.get("asr_provider") or DEFAULT_PROVIDER)
retry_html = f"""
切换 ASR 模型重跑 / 重新处理该录音
重新排队转写与精修
"""
sections = []
for key, label in (("result", "正式纪要"), ("draft", "精修草稿"), ("transcript", "原始转写")):
content = _read_artifact_text(job, key)
if not content:
continue
open_attr = " open" if key in ("result", "draft") else ""
edit_form = ""
if key in ("draft", "result"):
edit_form = f"""在线微调 {label} Markdown
{html.escape(content)}
保存修改
"""
sections.append(
f''
f'{label} {html.escape(content)} {edit_form} '
)
prov_tag = f"{job.get('asr_provider') or DEFAULT_PROVIDER}"
if job.get("asr_model"):
prov_tag += f":{job['asr_model']}"
body = f"""
{html.escape(job.get('title') or job['original_name'])}
ASR: {html.escape(prov_tag)}
来源: {html.escape(str(job.get('source') or 'web'))}
尝试: {int(job.get('attempts') or 0)} 次
{html.escape(LABELS.get(job['status'], job['status']))}
{html.escape(job.get('detail', ''))}
{review_banner}
{audio_html}
{links}
{retry_html}
{''.join(sections)}
"""
return shell("任务详情", body)
def review_page(job: dict | None = None) -> bytes:
rows = pending(job)
back_url = f"/jobs/{job['id']}/" if job else "/"
action_url = f"/jobs/{job['id']}/review/" if job else "/review/"
if not rows:
return shell(
"人工确认",
f'当前没有待确认问题 '
f'所有已入队的确认项均已处理完毕。
'
f'← 返回任务详情
',
)
row = rows[0]
payload = json.loads(row["payload"] or "{}")
choices = payload.get("candidates") or []
buttons = "".join(
f'{html.escape(str(v))} '
for v in choices
)
buttons += (
'都不是 / 无法确认(保留原文) '
)
draft = ""
path = Path(str(payload.get("draft_path") or ""))
for root in (DRAFTS, CFG.drafts):
try:
path.resolve().relative_to(root.resolve())
if path.is_file():
draft = path.read_text(encoding="utf-8", errors="ignore")
break
except (OSError, ValueError):
continue
kind = str(payload.get("kind") or "")
placeholder = (
"只输入真实姓名,例如:高瑶"
if kind == "voiceprint"
else ("若背景偏差较大,请在此填写正确背景"
if kind == "background_check"
else "输入正确名称或修改说明")
)
title_str = (job.get("title") or job["original_name"]) if job else "全局待确认队列"
sentence = str(payload.get("sentence") or "")
source_html = f'{html.escape(sentence)}
' if sentence else ""
draft_html = (
f''
f'展开查看完整草稿 {html.escape(draft)} '
if draft else ""
)
body = f""""""
return shell("人工确认", body)
def lexicon_page(msg: str = "") -> bytes:
try:
st = lexicon.stats()
except Exception:
st = {"entities": 0, "with_company": 0, "with_related": 0,
"aliases": 0, "aliases_card": 0, "aliases_learned": 0, "corrections": 0}
try:
gst = graph.stats()
except Exception:
gst = {"relations": 0, "interactions": 0, "documents": 0}
am = lexicon.alias_map()
learned_rows = []
for alias_key, targets in sorted(am.items()):
for canon, kind, origin, confirmed in targets:
if origin != "card":
learned_rows.append(
f"{html.escape(alias_key)} {html.escape(canon)} "
f"{html.escape(kind)} {html.escape(origin)} "
f"{'已确认' if confirmed else '候选'} "
)
notice = f'{html.escape(msg)}
' if msg else ""
table_html = (
"简称 / 误识别词 规范全名 类型 "
"来源 状态 "
+ "".join(learned_rows[:120]) + "
"
if learned_rows else '暂无人工新增映射
'
)
body = f"""{notice}
实体与纠错词库 (SQLite + Obsidian)
从 Obsidian Vault 同步卡片与图谱
实体库:{st.get('entities', 0)} 个实体({st.get('with_company', 0)} 含公司归属),
{st.get('aliases', 0)} 条别名(卡片 {st.get('aliases_card', 0)} + 学习/术语表 {st.get('aliases_learned', 0)}),
{st.get('corrections', 0)} 条发音纠错;关系图谱:{gst.get('relations', 0)} 条关系,
{gst.get('documents', 0)} 篇已索引纪要。
新增确定性别名 / ASR 纠错映射
备注(可选)
保存并立即生效
已学习的别名与发音纠错(前 120 条) {table_html} """
return shell("实体词库", body)
def settings_page() -> bytes:
hb_path = STATE / "worker.heartbeat"
hb_ok = hb_path.exists() and (time.time() - hb_path.stat().st_mtime < 90)
hb_text = "运行正常 (Ready)" if hb_ok else "未检测到近期心跳 (Standby / 独立模式)"
prov_rows = []
for p in providers.provider_status():
badge = '就绪 ' if p["ready"] else '未配置密钥 '
prov_rows.append(
f"{html.escape(p['id'])} "
f"{html.escape(p['label'])} "
f"{html.escape(p['model'])} "
f"{html.escape(p['via'])} "
f"{badge} "
)
hs_ok = False
if CFG.use_hindsight and CFG.hindsight_api:
try:
with urllib.request.urlopen(f"{CFG.hindsight_api}/health", timeout=3) as r:
hs_ok = r.status == 200
except Exception:
hs_ok = False
vault_ok = CFG.vault.is_dir()
body = f"""系统状态与配置
Worker 状态 {html.escape(hb_text)}
默认 ASR 通道 {html.escape(DEFAULT_PROVIDER)}
后处理 LLM 网关 (LiteLLM) {html.escape(CFG.endpoint)}
S1c/S2/S3 清洗与关键词模型 {html.escape(CFG.clean_model)}
S4/Revise 纪要成稿模型 {html.escape(CFG.draft_model)}
Hindsight 记忆服务 {html.escape(CFG.hindsight_api)} (bank: {html.escape(CFG.hindsight_bank)}) · {'在线 ' if hs_ok else '离线或未连接 '}
Obsidian Vault 路径 {html.escape(str(CFG.vault))} · {'已挂载 ' if vault_ok else '未挂载 '} · Git 自动同步: {'开启' if CFG.git_sync else '关闭'}
语音识别模型 (ASR Providers)
ID 名称 默认型号 接入方式 状态
{''.join(prov_rows)}
"""
return shell("系统状态与配置", body)
class Handler(BaseHTTPRequestHandler):
server_version = "ASRService/2"
def log_message(self, *_):
pass
def send_data(
self,
data: bytes,
status: int = 200,
ctype: str = "text/html; charset=utf-8",
disposition: str = "",
extra_headers: dict[str, str] | None = None,
) -> None:
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'",
)
if disposition:
self.send_header("Content-Disposition", disposition)
if extra_headers:
for k, v in extra_headers.items():
self.send_header(k, v)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def json(self, obj: object, status: int = 200) -> None:
self.send_data(
json.dumps(obj, ensure_ascii=False).encode("utf-8"),
status,
"application/json; charset=utf-8",
)
def auth(self) -> bool:
value = self.headers.get("Authorization", "")
return bool(
AGENT_TOKEN
and value.startswith("Bearer ")
and hmac.compare_digest(value[7:], AGENT_TOKEN)
)
def web_authed(self) -> bool:
if not WEB_PASSWORD:
return True
if self.auth():
return True
raw_cookie = self.headers.get("Cookie", "")
jar = cookies.SimpleCookie()
try:
jar.load(raw_cookie)
except Exception:
return False
morsel = jar.get("asr_session")
return bool(morsel and hmac.compare_digest(morsel.value, _session_token()))
def need_web_auth(self) -> bool:
if self.web_authed():
return True
self.send_response(303)
self.send_header("Location", "/login")
self.end_headers()
return False
def need_auth(self) -> bool:
if self.auth():
return True
self.json({"error": "unauthorized"}, 401)
return False
def header(self, name: str, limit: int) -> str:
return safe(urllib.parse.unquote(self.headers.get(name, "")), limit)
def upload(self, source: str, protected: bool) -> None:
if protected and not self.auth():
return self.json({"error": "unauthorized"}, 401)
if not protected and not self.web_authed():
return self.json({"error": "unauthorized"}, 401)
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = 0
name = self.header("X-Filename", 240)
suffix = Path(name).suffix.lower()
if length <= 0 or length > MAX_UPLOAD:
return self.json({"error": "invalid size"}, 413)
if suffix not in SUPPORTED:
return self.json({"error": "unsupported type"}, 415)
jid = uuid.uuid4().hex[:24]
title = self.header("X-Title", 80)
prov = providers.normalize_provider_name(
self.header("X-ASR-Provider", 40) or DEFAULT_PROVIDER
)
model = self.header("X-ASR-Model", 80)
stored = f"{time.strftime('%Y-%m-%d_%H_%M_%S')}-{stem(title or Path(name).stem)}-{jid[:8]}{suffix}"
staging = INBOX / ".uploading"
staging.mkdir(parents=True, exist_ok=True)
part = staging / f"{jid}.part"
remaining = length
digest = hashlib.sha256()
try:
with part.open("xb") as out:
while remaining:
chunk = self.rfile.read(min(1024 * 1024, remaining))
if not chunk:
raise EOFError
out.write(chunk)
digest.update(chunk)
remaining -= len(chunk)
out.flush()
os.fsync(out.fileno())
job = db.create_job(
jid, name, stored,
title=title,
context=self.header("X-Context", 2000),
size=length,
source=source,
asr_provider=prov,
asr_model=model,
)
db.update_job(
jid, "queued", f"录音已落盘,等待 worker (ASR: {prov})",
sha256=digest.hexdigest(),
asr_provider=prov,
asr_model=model,
)
part.replace(INBOX / stored)
self.json(public(job), 201)
except Exception:
part.unlink(missing_ok=True)
self.json({"error": "upload failed"}, 500)
def artifact(self, job: dict | None, name: str, api: bool = False) -> None:
if not job:
return self.json({"error": "not found"}, 404)
text = _read_artifact_text(job, name)
if not text:
return self.json({"error": "not found"}, 404)
path = Path(str(job.get(name) or f"{name}.md"))
self.send_data(
text.encode("utf-8"),
200,
"text/markdown; charset=utf-8",
f'inline; filename="{path.name}"',
)
def stream_audio(self, job: dict | None) -> None:
if not job:
return self.send_data(b"Not found", 404, "text/plain")
audio_path = find_audio_file(job)
if not audio_path or not audio_path.is_file():
return self.send_data(b"Audio not found", 404, "text/plain")
mime = mimetypes.guess_type(audio_path.name)[0] or "audio/mpeg"
size = audio_path.stat().st_size
range_hdr = self.headers.get("Range", "")
start, end = 0, size - 1
status = 200
if range_hdr.startswith("bytes="):
m = re.match(r"bytes=(\d*)-(\d*)", range_hdr)
if m:
if m.group(1):
start = int(m.group(1))
if m.group(2):
end = min(size - 1, int(m.group(2)))
if 0 <= start <= end < size:
status = 206
length = end - start + 1
self.send_response(status)
self.send_header("Content-Type", mime)
self.send_header("Accept-Ranges", "bytes")
if status == 206:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.send_header("Content-Length", str(length))
self.end_headers()
with audio_path.open("rb") as f:
f.seek(start)
rem = length
while rem > 0:
buf = f.read(min(256 * 1024, rem))
if not buf:
break
self.wfile.write(buf)
rem -= len(buf)
def do_GET(self) -> None:
parsed = urllib.parse.urlsplit(self.path)
path = parsed.path
if path in ("/health", "/healthz"):
return self.json({"ok": True, "service": "asr-web"})
if path == "/readyz":
try:
db.connect().close()
hb = STATE / "worker.heartbeat"
ok = hb.exists() and time.time() - hb.stat().st_mtime < 90
except Exception:
ok = False
return self.json({"ready": ok}, 200 if ok else 503)
if path == "/login":
return self.send_data(login_page())
if path == "/api/v1/providers":
if not self.need_auth():
return
return self.json({"providers": providers.provider_status()})
if path == "/api/v1/jobs":
if not self.need_auth():
return
q = urllib.parse.parse_qs(parsed.query)
return self.json({
"jobs": [
public(x)
for x in db.list_jobs(
int(q.get("limit", [30])[0]),
int(q.get("offset", [0])[0]),
q.get("status", [""])[0],
)
]
})
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})", path)
if m:
if not self.need_auth():
return
job = db.get_job(m.group(1))
return self.json(public(job, True)) if job else self.json({"error": "not found"}, 404)
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/artifacts/(transcript|draft|result)", path)
if m:
if not self.need_auth():
return
return self.artifact(db.get_job(m.group(1)), m.group(2), True)
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/reviews", path)
if m:
if not self.need_auth():
return
job = db.get_job(m.group(1))
if not job:
return self.json({"error": "not found"}, 404)
rows = []
for row in pending(job):
payload = json.loads(row["payload"] or "{}")
rows.append({
"id": row["id"],
"question": row["question"],
"kind": payload.get("kind", ""),
"sentence": payload.get("sentence", ""),
"candidates": payload.get("candidates") or [],
})
return self.json({"reviews": rows})
# Web UI routes below (check optional web password)
if not self.need_web_auth():
return
if path == "/":
return self.send_data(home())
if path == "/review/":
return self.send_data(review_page(None))
if path == "/lexicon":
return self.send_data(lexicon_page())
if path == "/settings":
return self.send_data(settings_page())
if path == "/docs":
return self.send_data(shell(
"Agent API",
"Agent API "
"所有 /api/v1/* 接口使用 Authorization: Bearer $ASR_AGENT_TOKEN。
"
"POST /api/v1/jobs:二进制流上传录音,支持请求头 X-Filename、X-Title、X-Context、X-ASR-Provider (qwen3cam|soniox|gemini|openai|grok)、X-ASR-Model。 "
"GET /api/v1/jobs & GET /api/v1/jobs/{id}:查询任务与事件。 "
"GET /api/v1/jobs/{id}/reviews & POST /api/v1/jobs/{id}/reviews/{qid}:读取与提交人工确认。 "
"POST /api/v1/jobs/{id}/retry:支持传入 {\"provider\":\"soniox\"} 切换模型重试。 "
"GET /api/v1/providers:查询所有语音模型就绪状态。 ",
))
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()