from __future__ import annotations
import hashlib
import hmac
import html
import json
import mimetypes
import os
import re
import shutil
import threading
import time
import urllib.parse
import urllib.request
import uuid
from http import cookies
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from asrpipe import graph, lexicon, providers, review, vault
from asrpipe.config import CFG
from . import db
from .config import (
AGENT_TOKEN, DRAFTS, FAILED, INBOX, MAX_UPLOAD,
PROCESSED, PROCESSING, PUBLIC_URL, RESULTS, REVISION_QUEUE, STATE,
SUPPORTED, TRANSCRIPTS, WEB_PASSWORD, ensure_dirs,
)
LABELS = {
"uploading": "上传中",
"queued": "等待处理",
"transcribing": "正在转写",
"refining": "正在整理",
"review": "等待确认",
"publishing": "正在定稿",
"complete": "已完成",
"failed": "处理失败",
}
KIND_LABELS = {
"background_check": "背景核对",
"voiceprint": "说话人身份",
"draft_question": "正文存疑",
"entity_card": "建卡建议",
"draft_overall": "整体评估",
"publish_approval": "定稿发布",
"low_confidence": "专有名词确认",
}
def safe(value: str, limit: int) -> str:
return re.sub(
r"\s+", " ", re.sub(r"[\x00-\x1f\x7f]+", " ", value or "")
).strip()[:limit]
def stem(value: str) -> str:
return (
re.sub(r"[^\w\u3400-\u9fff.-]+", "-", safe(value, 80)).strip("-.")
or "录音"
)
def find_audio_file(job: dict) -> Path | None:
stored = str(job.get("stored_name") or "")
if not stored:
return None
jid = str(job.get("id") or "")[:8]
base_stem = Path(stored).stem
suffix = Path(stored).suffix
for folder in (PROCESSED, PROCESSING, INBOX, FAILED):
if not folder.exists():
continue
direct = folder / stored
if direct.is_file():
return direct
if jid:
alt = folder / f"{base_stem}-{jid}{suffix}"
if alt.is_file():
return alt
return None
def delete_job_and_artifacts(
job_id: str,
*,
delete_files: bool = True,
delete_result: bool = False,
) -> bool:
job = db.delete_job(job_id)
if not job:
return False
review.delete_for_job(
transcript_path=str(job.get("transcript") or ""),
draft_path=str(job.get("draft") or ""),
job_id=job_id,
)
for marker in REVISION_QUEUE.glob(f"*{job_id}*.json"):
marker.unlink(missing_ok=True)
if delete_files:
audio = find_audio_file(job)
if audio and audio.is_file():
audio.unlink(missing_ok=True)
for p in (
TRANSCRIPTS / f"{job_id}.txt",
TRANSCRIPTS / f"{job_id}.asr.json",
DRAFTS / f"{job_id}.md",
Path(str(job.get("transcript") or "")) if job.get("transcript") else None,
Path(str(job.get("draft") or "")) if job.get("draft") else None,
):
if p and p.is_file():
p.unlink(missing_ok=True)
if delete_result and job.get("result"):
rpath = Path(str(job["result"]))
if rpath.is_file():
rpath.unlink(missing_ok=True)
return True
def queue_rerefine(job: dict) -> bool:
if not job.get("transcript"):
return False
REVISION_QUEUE.mkdir(parents=True, exist_ok=True)
jid = job["id"]
marker = REVISION_QUEUE / f"rerefine-{jid}.json"
tmp = REVISION_QUEUE / f".rerefine-{jid}.{os.getpid()}.tmp"
tmp.write_text(
json.dumps({"action": "rerefine", "job_id": jid, "draft": str(job.get("draft") or "")}, ensure_ascii=False),
encoding="utf-8",
)
tmp.replace(marker)
db.update_job(jid, "refining", "已排队重新生成精修草稿(免重跑 ASR)")
return True
def queue_revise_draft(job: dict) -> bool:
draft_str = str(job.get("draft") or "")
if not draft_str:
return False
review._persist_draft_feedback(str(job.get("transcript") or ""), draft_str)
return review._request_revision(draft_str)
def public(row: dict, detailed: bool = False) -> dict:
keys = (
"id", "original_name", "title", "size", "status", "detail", "source",
"sha256", "attempts", "error_code", "asr_provider", "asr_model",
"created_at", "updated_at",
)
out = {k: row.get(k) for k in keys}
out["asr_provider"] = out.get("asr_provider") or CFG.asr_provider
out["status_label"] = LABELS.get(row["status"], row["status"])
out["web_url"] = f"{PUBLIC_URL}/jobs/{row['id']}/"
out["review_url"] = f"{PUBLIC_URL}/jobs/{row['id']}/review/"
out["pending_reviews"] = len(pending(row))
out["artifacts"] = {
name: f"/api/v1/jobs/{row['id']}/artifacts/{name}"
for name in ("transcript", "draft", "result")
if row.get(name)
}
if detailed:
out["context"] = row.get("context", "")
out["events"] = db.events(row["id"])
return out
def _match_question_to_job(row: dict, job: dict) -> bool:
payload = json.loads(row.get("payload") or "{}")
return bool(
(job.get("transcript") and row.get("recording") == job["transcript"])
or (job.get("draft") and payload.get("draft_path") == job["draft"])
or payload.get("job_id") == job["id"]
or (payload.get("draft_path") and Path(str(payload["draft_path"])).stem == job["id"])
or (row.get("recording") and Path(str(row["recording"])).stem == job["id"])
)
def pending(job: dict | None = None) -> list[dict]:
all_rows = review.pending(1000)
if not job:
return all_rows
return [row for row in all_rows if _match_question_to_job(row, job)]
def _group_pending_by_job() -> list[tuple[dict | None, list[dict]]]:
"""Group all pending review questions by their parent recording job."""
all_rows = review.pending(1000)
if not all_rows:
return []
jobs = db.list_jobs(200)
grouped: dict[str, list[dict]] = {}
job_map: dict[str, dict] = {j["id"]: j for j in jobs}
unmatched: list[dict] = []
for row in all_rows:
matched_id = ""
for j in jobs:
if _match_question_to_job(row, j):
matched_id = j["id"]
break
if matched_id:
grouped.setdefault(matched_id, []).append(row)
else:
unmatched.append(row)
out: list[tuple[dict | None, list[dict]]] = []
for j in jobs:
if j["id"] in grouped:
out.append((j, grouped[j["id"]]))
if unmatched:
out.append((None, unmatched))
return out
def _session_token() -> str:
secret = (WEB_PASSWORD or AGENT_TOKEN or "local").encode("utf-8")
return hmac.new(secret, b"asr-web-session-v1", hashlib.sha256).hexdigest()
def shell(title: str, body: str, script: str = "") -> bytes:
pend_count = len(review.pending(500))
pend_badge = (
f' {pend_count} '
if pend_count else ""
)
return f"""
{html.escape(title)} · Meeting ASR {body} {script}""".encode("utf-8")
def _provider_options(selected: str = "") -> str:
sel = providers.normalize_provider_name(selected or CFG.asr_provider)
opts = []
for p in providers.provider_status():
pid = p["id"]
is_sel = " selected" if pid == sel else ""
status_note = "" if p["ready"] else " (待配密钥)"
opts.append(
f''
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(msg: str = "") -> bytes:
options = _provider_options(CFG.asr_provider)
notice = f'{html.escape(msg)}
' if msg else ""
body = f"""{notice}上传会议 / 通话录音
支持 m4a、mp3、wav、mp4、aac、ogg、opus、webm、flac。默认引擎与模型参数均可在 模型与系统配置 中直接修改并落盘至 SQLite。
"""
script = """"""
return shell("任务工作台", body, script)
def _read_artifact_text(job: dict, name: str) -> str:
roots = {"transcript": TRANSCRIPTS, "draft": DRAFTS, "result": RESULTS}
raw_path = str(job.get(name) or "")
if not raw_path:
return ""
path = Path(raw_path)
allowed_roots = [roots[name].resolve(), CFG.minutes.resolve(), CFG.drafts.resolve()]
for root in allowed_roots:
try:
path.resolve().relative_to(root)
if path.is_file():
return path.read_text(encoding="utf-8", errors="ignore")
except (OSError, ValueError):
continue
return ""
def job_page(job: dict, msg: str = "") -> bytes:
jid = job["id"]
notice = f'{html.escape(msg)}
' if msg else ""
events_html = "".join(
f"{html.escape(LABELS.get(e['status'], e['status']))} "
f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(e['created_at']))} "
f"{html.escape(e['detail'])}
"
for e in db.events(jid)
)
links = " · ".join(
f'下载{label} (.md/.txt) '
for n, label in (("result", "正式纪要"), ("draft", "精修草稿"), ("transcript", "原始转写"))
if job.get(n)
)
audio_html = (
f' '
if find_audio_file(job)
else '原音频文件不在活动目录
'
)
pend = pending(job)
review_banner = (
f''
if pend else ""
)
options = _provider_options(job.get("asr_provider") or CFG.asr_provider)
has_transcript = bool(job.get("transcript") and Path(str(job["transcript"])).is_file())
rerefine_btn = (
f''
f'基于已有转写重新生成草稿 (免重跑 ASR) '
if has_transcript else ""
)
meta_form = f"""
编辑任务信息 / 补充背景 / 切换 ASR 模型重跑
指定 ASR 子型号(可选)
已知背景说明(修改后可点「保存并重新生成草稿」)
{html.escape(str(job.get('context') or ''))}
仅保存修改
{'保存并重新生成草稿 (免重跑 ASR) ' if has_transcript else ''}
保存并重新转写音频 (重跑 ASR)
"""
delete_form = f"""
删除此任务与产物…
同时删除原音频、原始转写与私有草稿文件
同时删除已写入 Obsidian 的正式纪要文件(慎选)
确认永久删除此任务
"""
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)}
保存 Markdown 修改
"""
sections.append(
f''
f'{label} {html.escape(content)} {edit_form} '
)
prov_tag = f"{job.get('asr_provider') or CFG.asr_provider}"
if job.get("asr_model"):
prov_tag += f":{job['asr_model']}"
ctx_html = (
f'已知背景: {html.escape(str(job["context"]))}
'
if job.get("context") else ""
)
body = f"""{notice}
{html.escape(job.get('title') or job['original_name'])}
ASR: {html.escape(prov_tag)}
文件: {html.escape(job['original_name'])}
尝试: {int(job.get('attempts') or 0)} 次
{html.escape(LABELS.get(job['status'], job['status']))}
{html.escape(job.get('detail', ''))}
{ctx_html}
{review_banner}
{audio_html}
{meta_form}
{delete_form}
{''.join(sections)}
"""
return shell("任务详情", body)
def _render_question_card(row: dict, action_url: str) -> str:
qid = int(row["id"])
payload = json.loads(row.get("payload") or "{}")
kind = str(payload.get("kind") or "")
kind_label = KIND_LABELS.get(kind, kind or "待确认")
choices = payload.get("candidates") or []
sentence = str(payload.get("sentence") or "")
placeholder = (
"输入真实姓名,如:高瑶"
if kind == "voiceprint"
else ("若背景有误,请填写正确背景说明"
if kind == "background_check"
else "输入正确名称或补充修改说明")
)
quick_btns = "".join(
f'{html.escape(str(v))} '
for v in choices
)
quick_btns += (
f'跳过 / 保留原文 '
)
select_opts = '-- 选择预设项(用于下方批量提交) -- ' + "".join(
f'{html.escape(str(v))} '
for v in [*choices, "无法确认,保留原文"]
)
sentence_html = f'{html.escape(sentence)}
' if sentence else ""
return f"""
{html.escape(kind_label)} #{qid} · {html.escape(row['question'])}
{sentence_html}
{quick_btns}
"""
def review_page(focus_job: dict | None = None, msg: str = "") -> bytes:
groups = _group_pending_by_job()
notice = f'{html.escape(msg)}
' if msg else ""
# Top switcher bar across all recordings that have pending questions
switcher_items = []
total_q = sum(len(qs) for _, qs in groups)
all_active = " active" if not focus_job else ""
switcher_items.append(
f'全部待确认录音 ({len(groups)} 场 / {total_q} 题) '
)
for j, qs in groups:
if j:
is_act = " active" if (focus_job and focus_job["id"] == j["id"]) else ""
title_lbl = j.get("title") or j["original_name"]
switcher_items.append(
f''
f'{html.escape(title_lbl)} ({len(qs)}) '
)
switcher_html = (
f'{"".join(switcher_items)}
'
if groups else ""
)
# Filter groups if user focused on one specific recording job
display_groups = (
[(j, qs) for j, qs in groups if j and j["id"] == focus_job["id"]]
if focus_job else groups
)
if focus_job and not display_groups:
rem = pending(focus_job)
if rem:
display_groups = [(focus_job, rem)]
group_cards = []
for j, rows in display_groups:
action_url = f"/jobs/{j['id']}/review/" if (focus_job and j) else "/review/"
if j:
jid = j["id"]
title_str = j.get("title") or j["original_name"]
prov_str = j.get("asr_provider") or CFG.asr_provider
audio_bar = (
f' '
if find_audio_file(j) else ""
)
draft_text = _read_artifact_text(j, "draft")
draft_box = (
f''
f'展开对照本场完整精修草稿 {html.escape(draft_text)} '
if draft_text else ""
)
hdr_html = f"""
ASR: {html.escape(prov_str)}
{time.strftime('%Y-%m-%d %H:%M', time.localtime(j['created_at']))} · 待确认 {len(rows)} 题
{audio_bar}{draft_box}"""
else:
hdr_html = f"""
未关联任务的历史待确认项 ({len(rows)} 题) """
q_cards = "".join(_render_question_card(r, action_url) for r in rows)
q_ids_csv = ",".join(str(r["id"]) for r in rows)
group_cards.append(f"""
{hdr_html}
{q_cards}
提示:既可点击上方候选按钮单题即时提交,也可在下拉/输入框填写多题后点击右侧批量提交。
本场全部保留原文
批量提交本录音已填项
""")
if not group_cards:
back_url = f"/jobs/{focus_job['id']}/" if focus_job else "/"
revise_btn = ""
if focus_job and focus_job.get("draft"):
revise_btn = (
f''
f'立即应用已确认结果并触发修订定稿 '
)
group_cards.append(
f'当前没有待确认问题 '
f'所有已入队的确认项均已处理完毕。
{revise_btn}'
f'← 返回任务工作台
'
)
# Recently answered history with ability to reopen or modify answer
answered = review.recent_answered(25)
ans_rows = []
for r in answered:
qid = int(r["id"])
rec_stem = Path(str(r.get("recording") or "")).stem
ans_rows.append(
f'#{qid} {html.escape(rec_stem[:16])} '
f'{html.escape(str(r["question"])[:70])} '
f''
f' '
f' '
f'改答 '
f'撤回重答 '
f' '
)
history_html = (
f''
f'近期已确认记录(最近 {len(ans_rows)} 条,支持修改或撤回重答) '
f'ID 问题 已确认回答 / 操作 '
f'{"".join(ans_rows)}
'
if ans_rows else ""
)
body = f"""{notice}
多录音人工确认中心
支持按录音分组切换、单题快捷点击与单场录音批量提交
{switcher_html}
{''.join(group_cards)}
{history_html}"""
return shell("人工确认中心", body)
def lexicon_page(tab: str = "aliases", q: str = "", msg: str = "") -> bytes:
tab = tab if tab in ("aliases", "corrections", "entities", "relations") else "aliases"
try:
st = lexicon.stats()
except Exception:
st = {"entities": 0, "with_company": 0, "with_related": 0,
"aliases": 0, "aliases_card": 0, "aliases_learned": 0, "corrections": 0}
try:
gst = graph.stats()
except Exception:
gst = {"relations": 0, "interactions": 0, "documents": 0}
notice = f'{html.escape(msg)}
' if msg else ""
tabs_html = "".join(
f'{lbl} '
for t, lbl in (
("aliases", f"别名映射 ({st.get('aliases', 0)})"),
("corrections", f"发音纠错 ({st.get('corrections', 0)})"),
("entities", f"实体卡片 ({st.get('entities', 0)})"),
("relations", f"人物/机构关系 ({gst.get('relations', 0)})"),
)
)
content_html = ""
if tab == "aliases":
rows = lexicon.list_aliases(q=q, limit=180)
tr_list = []
for r in rows:
al = str(r["alias"])
can = str(r["canonical"])
knd = str(r.get("kind") or "人名")
org = str(r.get("origin") or "manual")
conf = int(r.get("confirmed") or 0)
nt = str(r.get("note") or "")
tr_list.append(f"""
人名
公司
项目
术语
{html.escape(org)}
已确认
保存
删除
""")
content_html = f"""
别名映射列表(支持行内直接编辑与删除,共显示 {len(tr_list)} 条)
简称 / 别名 规范全名 类型 来源 确认 备注 操作
{''.join(tr_list) or '暂无数据 '}
"""
elif tab == "corrections":
rows = lexicon.list_corrections(q=q, limit=180)
tr_list = []
for r in rows:
sf = str(r["surface"])
can = str(r["canonical"])
cnt = int(r.get("n") or 1)
conf = int(r.get("confirmed") or 0)
nt = str(r.get("note") or "")
tr_list.append(f"""
{cnt} 次
已确认
保存
删除
""")
content_html = f"""
发音纠错列表(支持行内编辑与删除,共显示 {len(tr_list)} 条)
误识别词 正确规范词 频次 确认 备注 操作
{''.join(tr_list) or '暂无数据 '}
"""
elif tab == "entities":
rows = lexicon.list_entities(q=q, limit=180)
tr_list = []
for r in rows:
can = str(r["canonical"])
knd = str(r.get("kind") or "人名")
comp = str(r.get("company") or "")
role = str(r.get("role") or "")
rel = str(r.get("related") or "")
src_tag = "Obsidian卡" if r.get("card") else "手动维护"
tr_list.append(f"""
人名
公司
项目
{src_tag}
保存
删除
""")
content_html = f"""新增实体卡片记录(手动维护实体不会被 Vault 同步覆盖)
新增实体
实体列表(支持行内编辑与删除,共显示 {len(tr_list)} 条)
规范名称 类型 公司/机构 职务/角色 关联人 来源 操作
{''.join(tr_list) or '暂无数据 '}
"""
else: # relations
rows = lexicon.list_relations(q=q, limit=180)
tr_list = []
for r in rows:
subj = str(r["subject"])
pred = str(r["predicate"])
obj = str(r["object"])
conf = float(r.get("confidence") or 0.8)
src = str(r.get("source") or "manual")
act = int(r.get("active") if r.get("active") is not None else 1)
tr_list.append(f"""
启用
保存
删除
""")
content_html = f"""
关系图谱列表(支持行内编辑与删除,共显示 {len(tr_list)} 条)
主体 关系 客体 置信度 状态 操作
{''.join(tr_list) or '暂无数据 '}
"""
body = f"""{notice}
实体与纠错词库管理 (SQLite + Obsidian)
从 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)} 篇已索引纪要。
{content_html}"""
return shell("实体词库管理", body)
def _mask_secret(val: str) -> str:
if not val:
return "未配置"
if len(val) <= 8:
return "已配置 (****)"
return f"已配置 ({val[:4]}…{val[-4:]})"
def settings_page(msg: str = "") -> bytes:
notice = f'{html.escape(msg)}
' if msg else ""
hb_path = STATE / "worker.heartbeat"
hb_ok = hb_path.exists() and (time.time() - hb_path.stat().st_mtime < 90)
hb_text = "运行正常 (Ready)" if hb_ok else "未检测到近期心跳 (Standby / 独立模式)"
prov_rows = []
for p in providers.provider_status():
badge = '就绪 ' if p["ready"] else '未配密钥 '
prov_rows.append(
f"{html.escape(p['id'])} "
f"{html.escape(p['label'])} "
f"{html.escape(p['model'])} "
f"{html.escape(p['endpoint'])} "
f"{html.escape(p['via'])} "
f"{badge} "
)
hs_ok = False
if CFG.use_hindsight and CFG.hindsight_api:
try:
with urllib.request.urlopen(f"{CFG.hindsight_api}/health", timeout=3) as r:
hs_ok = r.status == 200
except Exception:
hs_ok = False
vault_ok = CFG.vault.is_dir()
s = db.get_all_settings()
litellm_key_mask = _mask_secret(s.get("litellm_api_key", ""))
soniox_key_mask = _mask_secret(s.get("soniox_api_key", ""))
grok_key_mask = _mask_secret(s.get("grok_api_key", ""))
prov_opts = _provider_options(CFG.asr_provider)
body = f"""{notice}
实时运行与通道就绪状态
Worker 进程状态 {html.escape(hb_text)}
Hindsight 记忆服务 {html.escape(CFG.hindsight_api)} (bank: {html.escape(CFG.hindsight_bank)}) · {'在线 ' if hs_ok else '离线或未连接 '}
Obsidian Vault 路径 {html.escape(str(CFG.vault))} · {'已挂载 ' if vault_ok else '未挂载 '}
ASR 通道表
ID 名称 默认型号 接口地址 接入方式 状态
{''.join(prov_rows)}
"""
script = """"""
return shell("模型与系统配置", body, script)
def _probe_litellm_models() -> dict[str, Any]:
try:
url = f"{CFG.litellm_base_url}/models"
req = urllib.request.Request(
url,
headers={"Authorization": f"Bearer {CFG.api_key()}", "Accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read().decode("utf-8", errors="ignore"))
models = sorted({str(m.get("id")) for m in (data.get("data") or []) if m.get("id")})
return {"ok": True, "models": models}
except Exception as exc:
return {"ok": False, "error": str(exc), "models": []}
class Handler(BaseHTTPRequestHandler):
server_version = "ASRService/3"
def log_message(self, *_):
pass
def send_data(
self,
data: bytes,
status: int = 200,
ctype: str = "text/html; charset=utf-8",
disposition: str = "",
extra_headers: dict[str, str] | None = None,
) -> None:
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'",
)
if disposition:
self.send_header("Content-Disposition", disposition)
if extra_headers:
for k, v in extra_headers.items():
self.send_header(k, v)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def redirect(self, location: str) -> None:
self.send_response(303)
self.send_header("Location", location)
self.end_headers()
def json(self, obj: object, status: int = 200) -> None:
self.send_data(
json.dumps(obj, ensure_ascii=False).encode("utf-8"),
status,
"application/json; charset=utf-8",
)
def auth(self) -> bool:
value = self.headers.get("Authorization", "")
return bool(
AGENT_TOKEN
and value.startswith("Bearer ")
and hmac.compare_digest(value[7:], AGENT_TOKEN)
)
def web_authed(self) -> bool:
if not WEB_PASSWORD:
return True
if self.auth():
return True
raw_cookie = self.headers.get("Cookie", "")
jar = cookies.SimpleCookie()
try:
jar.load(raw_cookie)
except Exception:
return False
morsel = jar.get("asr_session")
return bool(morsel and hmac.compare_digest(morsel.value, _session_token()))
def need_web_auth(self) -> bool:
if self.web_authed():
return True
self.redirect("/login")
return False
def need_auth(self) -> bool:
if self.auth():
return True
self.json({"error": "unauthorized"}, 401)
return False
def header(self, name: str, limit: int) -> str:
return safe(urllib.parse.unquote(self.headers.get(name, "")), limit)
def read_form(self, max_bytes: int = 65536) -> dict[str, list[str]]:
length = min(int(self.headers.get("Content-Length", "0")), max_bytes)
raw = self.rfile.read(length).decode("utf-8", errors="ignore") if length > 0 else ""
return urllib.parse.parse_qs(raw, keep_blank_values=True)
def upload(self, source: str, protected: bool) -> None:
if protected and not self.auth():
return self.json({"error": "unauthorized"}, 401)
if not protected and not self.web_authed():
return self.json({"error": "unauthorized"}, 401)
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = 0
name = self.header("X-Filename", 240)
suffix = Path(name).suffix.lower()
if length <= 0 or length > MAX_UPLOAD:
return self.json({"error": "invalid size"}, 413)
if suffix not in SUPPORTED:
return self.json({"error": "unsupported type"}, 415)
jid = uuid.uuid4().hex[:24]
title = self.header("X-Title", 80)
prov = providers.normalize_provider_name(
self.header("X-ASR-Provider", 40) or CFG.asr_provider
)
model = self.header("X-ASR-Model", 80)
stored = f"{time.strftime('%Y-%m-%d_%H_%M_%S')}-{stem(title or Path(name).stem)}-{jid[:8]}{suffix}"
staging = INBOX / ".uploading"
staging.mkdir(parents=True, exist_ok=True)
part = staging / f"{jid}.part"
remaining = length
digest = hashlib.sha256()
try:
with part.open("xb") as out:
while remaining:
chunk = self.rfile.read(min(1024 * 1024, remaining))
if not chunk:
raise EOFError
out.write(chunk)
digest.update(chunk)
remaining -= len(chunk)
out.flush()
os.fsync(out.fileno())
job = db.create_job(
jid, name, stored,
title=title,
context=self.header("X-Context", 2000),
size=length,
source=source,
asr_provider=prov,
asr_model=model,
)
db.update_job(
jid, "queued", f"录音已落盘,等待 worker (ASR: {prov})",
sha256=digest.hexdigest(),
asr_provider=prov,
asr_model=model,
)
part.replace(INBOX / stored)
self.json(public(job), 201)
except Exception:
part.unlink(missing_ok=True)
self.json({"error": "upload failed"}, 500)
def artifact(self, job: dict | None, name: str, api: bool = False) -> None:
if not job:
return self.json({"error": "not found"}, 404)
text = _read_artifact_text(job, name)
if not text:
return self.json({"error": "not found"}, 404)
path = Path(str(job.get(name) or f"{name}.md"))
self.send_data(
text.encode("utf-8"),
200,
"text/markdown; charset=utf-8",
f'inline; filename="{path.name}"',
)
def stream_audio(self, job: dict | None) -> None:
if not job:
return self.send_data(b"Not found", 404, "text/plain")
audio_path = find_audio_file(job)
if not audio_path or not audio_path.is_file():
return self.send_data(b"Audio not found", 404, "text/plain")
mime = mimetypes.guess_type(audio_path.name)[0] or "audio/mpeg"
size = audio_path.stat().st_size
range_hdr = self.headers.get("Range", "")
start, end = 0, size - 1
status = 200
if range_hdr.startswith("bytes="):
m = re.match(r"bytes=(\d*)-(\d*)", range_hdr)
if m:
if m.group(1):
start = int(m.group(1))
if m.group(2):
end = min(size - 1, int(m.group(2)))
if 0 <= start <= end < size:
status = 206
length = end - start + 1
self.send_response(status)
self.send_header("Content-Type", mime)
self.send_header("Accept-Ranges", "bytes")
if status == 206:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.send_header("Content-Length", str(length))
self.end_headers()
with audio_path.open("rb") as f:
f.seek(start)
rem = length
while rem > 0:
buf = f.read(min(256 * 1024, rem))
if not buf:
break
self.wfile.write(buf)
rem -= len(buf)
def do_GET(self) -> None:
parsed = urllib.parse.urlsplit(self.path)
path = parsed.path
q = urllib.parse.parse_qs(parsed.query)
if path in ("/health", "/healthz"):
return self.json({"ok": True, "service": "asr-web"})
if path == "/readyz":
try:
db.connect().close()
hb = STATE / "worker.heartbeat"
ok = hb.exists() and time.time() - hb.stat().st_mtime < 90
except Exception:
ok = False
return self.json({"ready": ok}, 200 if ok else 503)
if path == "/login":
return self.send_data(login_page())
if path == "/api/v1/providers":
if not self.need_auth():
return
return self.json({"providers": providers.provider_status(), "default": CFG.asr_provider})
if path == "/api/v1/settings":
if not self.need_auth():
return
raw_s = db.get_all_settings()
redacted = {
k: (_mask_secret(v) if k.endswith("_api_key") else v)
for k, v in raw_s.items()
}
return self.json({"settings": redacted})
if path == "/api/v1/jobs":
if not self.need_auth():
return
return self.json({
"jobs": [
public(x)
for x in db.list_jobs(
int(q.get("limit", ["30"])[0]),
int(q.get("offset", ["0"])[0]),
q.get("status", [""])[0],
q.get("q", [""])[0],
q.get("provider", [""])[0],
)
]
})
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})", path)
if m:
if not self.need_auth():
return
job = db.get_job(m.group(1))
return self.json(public(job, True)) if job else self.json({"error": "not found"}, 404)
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/artifacts/(transcript|draft|result)", path)
if m:
if not self.need_auth():
return
return self.artifact(db.get_job(m.group(1)), m.group(2), True)
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/reviews", path)
if m:
if not self.need_auth():
return
job = db.get_job(m.group(1))
if not job:
return self.json({"error": "not found"}, 404)
rows = []
for row in pending(job):
payload = json.loads(row["payload"] or "{}")
rows.append({
"id": row["id"],
"question": row["question"],
"kind": payload.get("kind", ""),
"sentence": payload.get("sentence", ""),
"candidates": payload.get("candidates") or [],
})
return self.json({"reviews": rows})
# Web UI routes below
if not self.need_web_auth():
return
if path == "/":
return self.send_data(home(q.get("msg", [""])[0]))
if path == "/review/":
return self.send_data(review_page(None, q.get("msg", [""])[0]))
if path == "/lexicon":
return self.send_data(
lexicon_page(
tab=q.get("tab", ["aliases"])[0],
q=q.get("q", [""])[0],
msg=q.get("msg", [""])[0],
)
)
if path == "/settings":
return self.send_data(settings_page(q.get("msg", [""])[0]))
if path == "/settings/models-probe":
return self.json(_probe_litellm_models())
if path == "/docs":
doc = {
"service": "meeting-asr",
"endpoints": [
"GET /health, /readyz",
"GET /api/v1/providers",
"GET /api/v1/settings",
"POST /api/v1/settings",
"POST /api/v1/jobs (headers: X-Filename, X-Title, X-Context, X-ASR-Provider, X-ASR-Model)",
"GET /api/v1/jobs?status=&provider=&q=&limit=30&offset=0",
"GET /api/v1/jobs/{job_id}",
"DELETE /api/v1/jobs/{job_id}",
"GET /api/v1/jobs/{job_id}/artifacts/{transcript|draft|result}",
"GET /api/v1/jobs/{job_id}/reviews",
"POST /api/v1/jobs/{job_id}/reviews/{question_id}",
"POST /api/v1/jobs/{job_id}/retry (json: {\"provider\": \"soniox\", \"model\": \"\"})",
"POST /api/v1/jobs/{job_id}/rerefine",
],
}
return self.send_data(shell("API 文档", f'API 接口说明 {html.escape(json.dumps(doc, ensure_ascii=False, indent=2))} '))
if path == "/ui/jobs":
status_flt = q.get("status", [""])[0]
prov_flt = q.get("provider", [""])[0]
q_flt = q.get("q", [""])[0]
return self.json({
"jobs": [
public(x)
for x in db.list_jobs(60, 0, status=status_flt, q=q_flt, provider=prov_flt)
]
})
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/", path)
if m:
job = db.get_job(m.group(1))
return self.send_data(job_page(job, q.get("msg", [""])[0])) if job else self.send_data(b"Not found", 404, "text/plain")
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/review/", path)
if m:
job = db.get_job(m.group(1))
return self.send_data(review_page(job, q.get("msg", [""])[0])) if job else self.send_data(b"Not found", 404, "text/plain")
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/audio", path)
if m:
return self.stream_audio(db.get_job(m.group(1)))
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/artifact/(transcript|draft|result)", path)
if m:
return self.artifact(db.get_job(m.group(1)), m.group(2), False)
self.send_data(b"Not found", 404, "text/plain")
def do_DELETE(self) -> None:
path = urllib.parse.urlsplit(self.path).path
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})", path)
if m:
if not self.need_auth():
return
ok = delete_job_and_artifacts(m.group(1), delete_files=True, delete_result=False)
return self.json({"ok": ok}, 200 if ok else 404)
self.json({"error": "not found"}, 404)
def do_POST(self) -> None:
path = urllib.parse.urlsplit(self.path).path
if path == "/login":
form = self.read_form(4096)
pw = form.get("password", [""])[0]
if WEB_PASSWORD and hmac.compare_digest(pw, WEB_PASSWORD):
self.send_response(303)
self.send_header("Location", "/")
self.send_header(
"Set-Cookie",
f"asr_session={_session_token()}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000",
)
self.end_headers()
return
return self.send_data(login_page("口令错误,请重试"), 401)
if path == "/ui/upload":
return self.upload("web", False)
if path == "/api/v1/jobs":
return self.upload("agent", True)
if path == "/api/v1/settings":
if not self.need_auth():
return
length = min(int(self.headers.get("Content-Length", "0")), 32768)
body = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
db.save_settings(body)
return self.json({"ok": True})
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/reviews/(\d+)", path)
if m:
if not self.need_auth():
return
job = db.get_job(m.group(1))
qid = int(m.group(2))
if not job or qid not in {x["id"] for x in pending(job)}:
return self.json({"error": "not found"}, 404)
length = min(int(self.headers.get("Content-Length", "0")), 4096)
body = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
ans = str(body.get("answer", "")).strip()
if not ans:
return self.json({"error": "answer required"}, 400)
return self.json(review.answer(qid, ans[:200]))
m = re.fullmatch(r"/(?:api/v1/)?jobs/([0-9a-f]{24})/rerefine", path)
if m:
api = path.startswith("/api/")
if api and not self.need_auth():
return
if not api and not self.need_web_auth():
return
job = db.get_job(m.group(1))
if not job:
return self.json({"error": "not found"}, 404)
ok = queue_rerefine(job)
if api:
return self.json({"ok": ok}, 202 if ok else 409)
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已加入重新精修队列"))
m = re.fullmatch(r"/(?:api/v1/)?jobs/([0-9a-f]{24})/retry", path)
if m:
api = path.startswith("/api/")
if api and not self.need_auth():
return
if not api and not self.need_web_auth():
return
job = db.get_job(m.group(1))
if not job:
return self.json({"error": "not found"}, 404)
source = find_audio_file(job)
if not source or job["status"] in {"transcribing", "refining", "publishing"}:
return self.json({"error": "cannot retry"}, 409)
length = min(int(self.headers.get("Content-Length", "0")), 4096)
raw_body = self.rfile.read(length).decode("utf-8", errors="ignore") if length > 0 else ""
new_prov = job.get("asr_provider") or CFG.asr_provider
new_model = job.get("asr_model") or ""
if raw_body:
if api and raw_body.strip().startswith("{"):
try:
body_json = json.loads(raw_body)
if body_json.get("provider"):
new_prov = providers.normalize_provider_name(body_json["provider"])
if "model" in body_json:
new_model = safe(str(body_json.get("model") or ""), 80)
except ValueError:
pass
else:
form = urllib.parse.parse_qs(raw_body)
if form.get("provider"):
new_prov = providers.normalize_provider_name(form["provider"][0])
if "model" in form:
new_model = safe(form.get("model", [""])[0], 80)
dest = INBOX / job["stored_name"]
if source.resolve() != dest.resolve():
shutil.copyfile(source, dest) if source.parent == PROCESSED else source.replace(dest)
old_ts = time.time() - 60
os.utime(dest, (old_ts, old_ts))
db.update_job(
job["id"], "queued", f"重新排队 (ASR: {new_prov})",
error_code="", asr_provider=new_prov, asr_model=new_model,
)
if api:
return self.json({"ok": True, "asr_provider": new_prov, "asr_model": new_model}, 202)
return self.redirect(f"/jobs/{job['id']}/")
if not self.need_web_auth():
return
# ---- Task deletion & metadata editing ----
if path == "/jobs/batch-delete":
form = self.read_form(65536)
del_files = form.get("delete_files", ["1"])[0] == "1"
if form.get("cleanup_failed"):
failed_jobs = db.list_jobs(200, 0, status="failed")
for fj in failed_jobs:
delete_job_and_artifacts(fj["id"], delete_files=del_files, delete_result=False)
return self.redirect("/?msg=" + urllib.parse.quote(f"已清理 {len(failed_jobs)} 个失败任务"))
ids = form.get("job_id", [])
n_del = 0
for jid in ids:
if delete_job_and_artifacts(jid, delete_files=del_files, delete_result=False):
n_del += 1
return self.redirect("/?msg=" + urllib.parse.quote(f"已删除 {n_del} 个任务"))
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/delete", path)
if m:
form = self.read_form(4096)
del_files = form.get("delete_files", ["0"])[0] == "1"
del_res = form.get("delete_result", ["0"])[0] == "1"
delete_job_and_artifacts(m.group(1), delete_files=del_files, delete_result=del_res)
return self.redirect("/?msg=" + urllib.parse.quote("任务已删除"))
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/meta", path)
if m:
job = db.get_job(m.group(1))
if not job:
return self.send_data(b"Not found", 404, "text/plain")
form = self.read_form(32768)
new_title = safe(form.get("title", [""])[0], 80)
new_ctx = safe(form.get("context", [""])[0], 2000)
new_prov = providers.normalize_provider_name(form.get("provider", [CFG.asr_provider])[0])
new_model = safe(form.get("model", [""])[0], 80)
db.update_job_meta(
job["id"], title=new_title, context=new_ctx,
asr_provider=new_prov, asr_model=new_model,
)
action = form.get("next_action", ["save"])[0]
updated_job = db.get_job(job["id"]) or job
if action == "rerefine":
queue_rerefine(updated_job)
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已保存并排队重新生成草稿"))
if action == "retry":
source = find_audio_file(updated_job)
if source:
dest = INBOX / updated_job["stored_name"]
if source.resolve() != dest.resolve():
shutil.copyfile(source, dest) if source.parent == PROCESSED else source.replace(dest)
old_ts = time.time() - 60
os.utime(dest, (old_ts, old_ts))
db.update_job(
job["id"], "queued", f"重新排队转写 (ASR: {new_prov})",
error_code="", asr_provider=new_prov, asr_model=new_model,
)
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已保存并重新排队转写"))
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("任务信息已更新"))
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/revise", path)
if m:
job = db.get_job(m.group(1))
if not job:
return self.send_data(b"Not found", 404, "text/plain")
queue_revise_draft(job)
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已触发修订与定稿流程"))
# ---- Multi-recording review submission ----
if path == "/review/" or re.fullmatch(r"/jobs/([0-9a-f]{24})/review/", path):
m_job = re.fullmatch(r"/jobs/([0-9a-f]{24})/review/", path)
job = db.get_job(m_job.group(1)) if m_job else None
form = self.read_form(65536)
allowed_ids = {x["id"] for x in pending(job)}
answered_count = 0
# Case 1: Single preset button clicked (`single_submit` = "qid::answer")
if form.get("single_submit"):
raw_val = form["single_submit"][0]
if "::" in raw_val:
qid_str, ans_val = raw_val.split("::", 1)
if qid_str.isdigit() and int(qid_str) in allowed_ids and ans_val.strip():
review.answer(int(qid_str), ans_val.strip()[:200])
answered_count += 1
# Case 2: Single custom button clicked (`single_custom` = "qid")
elif form.get("single_custom"):
qid_str = form["single_custom"][0]
if qid_str.isdigit() and int(qid_str) in allowed_ids:
ans_val = (
form.get(f"custom_{qid_str}", [""])[0].strip()
or form.get(f"preset_{qid_str}", [""])[0].strip()
)
if ans_val:
review.answer(int(qid_str), ans_val[:200])
answered_count += 1
# Case 3: Skip all questions in the recording group
elif form.get("batch_skip_all"):
qids = [
int(x) for x in form.get("question_ids", [""])[0].split(",")
if x.strip().isdigit() and int(x) in allowed_ids
]
for qid in qids:
review.answer(qid, "无法确认,保留原文")
answered_count += 1
# Case 4: Batch submit filled questions in the recording group
elif form.get("batch_submit"):
qids = [
int(x) for x in form.get("question_ids", [""])[0].split(",")
if x.strip().isdigit() and int(x) in allowed_ids
]
for qid in qids:
ans_val = (
form.get(f"custom_{qid}", [""])[0].strip()
or form.get(f"preset_{qid}", [""])[0].strip()
)
if ans_val:
review.answer(qid, ans_val[:200])
answered_count += 1
# Legacy fallback (`id` + `answer`/`custom`)
elif form.get("id"):
qid = int(form.get("id", ["0"])[0] or 0)
ans_val = form.get("custom", [""])[0].strip() or form.get("answer", [""])[0].strip()
if qid in allowed_ids and ans_val:
review.answer(qid, ans_val[:200])
answered_count += 1
return self.redirect(f"{path}?msg=" + urllib.parse.quote(f"已提交 {answered_count} 项确认"))
if path == "/review/update-answer":
form = self.read_form(8192)
qid = int(form.get("id", ["0"])[0] or 0)
if qid > 0:
if form.get("reopen"):
review.reopen_question(qid)
return self.redirect("/review/?msg=" + urllib.parse.quote(f"问题 #{qid} 已撤回至待确认队列"))
new_ans = safe(form.get("answer", [""])[0], 200)
if new_ans:
review.answer(qid, new_ans, allow_update=True)
return self.redirect("/review/?msg=" + urllib.parse.quote(f"问题 #{qid} 回答已更新"))
return self.redirect("/review/")
m_edit = re.fullmatch(r"/jobs/([0-9a-f]{24})/edit", path)
if m_edit:
job = db.get_job(m_edit.group(1))
if not job:
return self.send_data(b"Not found", 404, "text/plain")
form = self.read_form(2 * 1024 * 1024)
target = form.get("target", [""])[0]
content = form.get("content", [""])[0]
if target in ("draft", "result") and job.get(target) and content.strip():
tpath = Path(str(job[target]))
vault.write_draft_file(tpath, content)
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("Markdown 修改已保存"))
# ---- Lexicon CRUD endpoints ----
if path == "/lexicon/learn":
form = self.read_form(8192)
alias_val = safe(form.get("alias", [""])[0], 40)
canon_val = safe(form.get("canonical", [""])[0], 40)
kind_val = safe(form.get("kind", [""])[0], 20)
note_val = safe(form.get("note", [""])[0], 120)
tab = safe(form.get("tab", ["aliases"])[0], 20)
if alias_val and canon_val:
lexicon.learn_alias(alias_val, canon_val, kind=kind_val, origin="manual", note=note_val)
lexicon.learn_correction(alias_val, canon_val, note=note_val)
return self.redirect(f"/lexicon?tab={tab}&msg=" + urllib.parse.quote(f"已添加映射:{alias_val} → {canon_val}"))
return self.redirect(f"/lexicon?tab={tab}&msg=" + urllib.parse.quote("请填写完整的词条映射"))
if path == "/lexicon/alias/update":
form = self.read_form(8192)
old_a = safe(form.get("old_alias", [""])[0], 40)
old_c = safe(form.get("old_canonical", [""])[0], 40)
new_a = safe(form.get("alias", [""])[0], 40)
new_c = safe(form.get("canonical", [""])[0], 40)
kind = safe(form.get("kind", ["人名"])[0], 20)
conf = 1 if form.get("confirmed") else 0
note = safe(form.get("note", [""])[0], 120)
if new_a and new_c:
lexicon.update_alias(old_a or new_a, old_c or new_c, new_a, new_c, kind=kind, confirmed=conf, note=note)
return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote(f"已更新别名:{new_a} → {new_c}"))
if path == "/lexicon/alias/delete":
form = self.read_form(8192)
old_a = safe(form.get("old_alias", form.get("alias", [""]))[0], 40)
old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 40)
lexicon.delete_alias(old_a, old_c)
return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote(f"已删除别名:{old_a}"))
if path == "/lexicon/correction/update":
form = self.read_form(8192)
old_s = safe(form.get("old_surface", [""])[0], 40)
old_c = safe(form.get("old_canonical", [""])[0], 40)
new_s = safe(form.get("surface", [""])[0], 40)
new_c = safe(form.get("canonical", [""])[0], 40)
conf = 1 if form.get("confirmed") else 0
note = safe(form.get("note", [""])[0], 120)
if new_s and new_c:
lexicon.update_correction(old_s or new_s, old_c or new_c, new_s, new_c, confirmed=conf, note=note)
return self.redirect("/lexicon?tab=corrections&msg=" + urllib.parse.quote(f"已更新纠错:{new_s} → {new_c}"))
if path == "/lexicon/correction/delete":
form = self.read_form(8192)
old_s = safe(form.get("old_surface", form.get("surface", [""]))[0], 40)
old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 40)
lexicon.delete_correction(old_s, old_c)
return self.redirect("/lexicon?tab=corrections&msg=" + urllib.parse.quote(f"已删除纠错:{old_s}"))
if path == "/lexicon/entity/update":
form = self.read_form(8192)
old_c = safe(form.get("old_canonical", [""])[0], 60)
new_c = safe(form.get("canonical", [""])[0], 60)
kind = safe(form.get("kind", ["人名"])[0], 20)
comp = safe(form.get("company", [""])[0], 80)
role = safe(form.get("role", [""])[0], 80)
rel = safe(form.get("related", [""])[0], 160)
if new_c:
if old_c:
lexicon.update_entity(old_c, new_c, kind, company=comp, role=role, related=rel)
else:
lexicon.upsert_entity(new_c, kind, company=comp, role=role, related=rel)
return self.redirect("/lexicon?tab=entities&msg=" + urllib.parse.quote(f"已保存实体:{new_c}"))
if path == "/lexicon/entity/delete":
form = self.read_form(8192)
old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 60)
lexicon.delete_entity(old_c)
return self.redirect("/lexicon?tab=entities&msg=" + urllib.parse.quote(f"已删除实体:{old_c}"))
if path == "/lexicon/relation/update":
form = self.read_form(8192)
old_s = safe(form.get("old_subject", [""])[0], 60)
old_p = safe(form.get("old_predicate", [""])[0], 40)
old_o = safe(form.get("old_object", [""])[0], 60)
old_src = safe(form.get("old_source", ["manual"])[0], 120)
subj = safe(form.get("subject", [""])[0], 60)
pred = safe(form.get("predicate", [""])[0], 40)
obj = safe(form.get("object", [""])[0], 60)
try:
conf = float(form.get("confidence", ["0.85"])[0])
except ValueError:
conf = 0.85
act = 1 if form.get("active") else 0
if subj and pred and obj:
if old_s and old_p and old_o:
lexicon.update_relation(old_s, old_p, old_o, old_src, subj, pred, obj, confidence=conf, active=act)
else:
lexicon.upsert_relation(subj, pred, obj, confidence=conf, source="manual")
return self.redirect("/lexicon?tab=relations&msg=" + urllib.parse.quote(f"已保存关系:{subj} {pred} {obj}"))
if path == "/lexicon/relation/delete":
form = self.read_form(8192)
old_s = safe(form.get("old_subject", form.get("subject", [""]))[0], 60)
old_p = safe(form.get("old_predicate", form.get("predicate", [""]))[0], 40)
old_o = safe(form.get("old_object", form.get("object", [""]))[0], 60)
old_src = safe(form.get("old_source", ["manual"])[0], 120)
lexicon.delete_relation(old_s, old_p, old_o, old_src)
return self.redirect("/lexicon?tab=relations&msg=" + urllib.parse.quote(f"已删除关系:{old_s} {old_p} {old_o}"))
if path == "/lexicon/sync":
p_res = vault.pull()
l_res = lexicon.sync_from_vault(force=True)
g_res = graph.sync(force=True)
return self.redirect("/lexicon?msg=" + urllib.parse.quote(f"同步完成(Git:{p_res}):词库 {l_res},图谱 {g_res}"))
# ---- SQLite Settings Save ----
if path == "/settings":
form = self.read_form(32768)
updates: dict[str, str] = {}
text_keys = (
"litellm_base_url", "clean_model", "keyword_model", "draft_model",
"asr_provider", "diarize_url", "voiceprint_enroll_url",
"soniox_api_base", "soniox_model", "gemini_asr_model", "openai_asr_model",
"grok_api_base", "grok_asr_model", "hindsight_api", "hindsight_bank",
)
for k in text_keys:
if k in form:
updates[k] = safe(form[k][0], 200)
if "asr_provider" in updates:
updates["asr_provider"] = providers.normalize_provider_name(updates["asr_provider"])
for sk in ("litellm_api_key", "soniox_api_key", "grok_api_key"):
raw_k = form.get(sk, [""])[0].strip()
if raw_k == "CLEAR":
updates[sk] = ""
elif raw_k:
updates[sk] = raw_k
for bk in ("use_hindsight", "use_termbank", "git_sync"):
updates[bk] = "1" if form.get(bk) else "0"
db.save_settings(updates)
return self.redirect("/settings?msg=" + urllib.parse.quote("配置已保存至 SQLite 并立即生效"))
self.send_data(b"Not found", 404, "text/plain")
def main() -> None:
os.environ.setdefault("ASR_ROLE", "web")
os.environ.setdefault("ASR_REVISION_QUEUE_DIR", str(REVISION_QUEUE))
ensure_dirs()
db.connect().close()
if os.environ.get("ASR_ROLE", "").lower() == "all":
from . import worker
t = threading.Thread(target=worker.run_loop, name="asr-worker", daemon=True)
t.start()
port = int(os.environ.get("ASR_PORT", os.environ.get("ASR_REVIEW_PORT", "8788")))
host = os.environ.get("ASR_HOST", "0.0.0.0")
server = ThreadingHTTPServer((host, port), Handler)
server.daemon_threads = True
server.serve_forever()
if __name__ == "__main__":
main()