from __future__ import annotations
import hashlib
import hmac
import html
import json
import mimetypes
import os
import re
import shutil
import socket
import threading
import time
import urllib.parse
import urllib.request
import uuid
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from asrpipe import graph, lexicon, providers, review, vault, webauth
from asrpipe.config import CFG
from . import db
from .config import (
AGENT_TOKEN, DRAFTS, FAILED, INBOX, MAX_UPLOAD,
PROCESSED, PROCESSING, PUBLIC_URL, RESULTS, REVISION_QUEUE, STATE,
SUPPORTED, TRANSCRIPTS, ensure_dirs,
)
SHANGHAI = ZoneInfo("Asia/Shanghai")
BUSY_STATUSES = {"transcribing", "refining", "publishing"}
_LOGIN_FAILS: dict[str, tuple[int, float]] = {}
SETTING_TEXT_KEYS = (
"litellm_base_url", "clean_model", "keyword_model", "draft_model",
"asr_provider", "diarize_url", "voiceprint_enroll_url",
"soniox_api_base", "soniox_model", "gemini_asr_model", "openai_asr_model",
"grok_api_base", "grok_asr_model", "hindsight_api", "hindsight_bank",
)
SETTING_SECRET_KEYS = ("litellm_api_key", "soniox_api_key", "grok_api_key", "web_password")
SETTING_BOOL_KEYS = ("use_hindsight", "use_termbank", "git_sync")
LEXICON_PAGE_SIZE = 40
class ClientError(Exception):
def __init__(self, status: int, message: str):
super().__init__(message)
self.status = status
self.message = message
def fmt_ts(ts: float, with_seconds: bool = True) -> str:
stamp = datetime.fromtimestamp(float(ts), SHANGHAI)
return stamp.strftime("%Y-%m-%d %H:%M:%S" if with_seconds else "%Y-%m-%d %H:%M")
def content_disposition(filename: str, inline: bool = True) -> str:
"""ASCII fallback plus RFC 5987 filename* so non-ASCII names stay in the header."""
kind = "inline" if inline else "attachment"
suffix = Path(filename).suffix
if not suffix.isascii():
suffix = ""
fallback = re.sub(r"[^A-Za-z0-9._-]+", "_", "download" + suffix).strip("._") or "download"
encoded = urllib.parse.quote(filename, safe="")
return f"{kind}; filename=\"{fallback}\"; filename*=UTF-8''{encoded}"
def _parse_byte_range(header: str, size: int) -> tuple[int, int, int]:
"""Return (status, start, end). status is 200, 206, or 416."""
if size <= 0:
return 200, 0, 0
if not header:
return 200, 0, size - 1
if not header.startswith("bytes=") or "," in header:
return 416, 0, 0
spec = header[6:].strip()
if spec.startswith("-"):
try:
suffix = int(spec[1:])
except ValueError:
return 416, 0, 0
if suffix <= 0:
return 416, 0, 0
start = max(0, size - suffix)
return 206, start, size - 1
match = re.match(r"^(\d+)-(\d*)$", spec)
if not match:
return 416, 0, 0
start = int(match.group(1))
end = int(match.group(2)) if match.group(2) else size - 1
end = min(end, size - 1)
if start >= size or start > end:
return 416, 0, 0
return 206, start, end
def path_is_under(path: Path, roots: list[Path]) -> bool:
try:
resolved = path.resolve()
except OSError:
return False
for root in roots:
try:
resolved.relative_to(root.resolve())
return True
except (OSError, ValueError):
continue
return False
def artifact_roots() -> list[Path]:
return [
INBOX, PROCESSING, PROCESSED, FAILED, TRANSCRIPTS, DRAFTS, RESULTS,
CFG.drafts, CFG.minutes, CFG.vault / "minutes",
CFG.vault / "People", CFG.vault / "Companies",
]
LABELS = {
"uploading": "上传中",
"queued": "等待处理",
"transcribing": "正在转写",
"refining": "正在整理",
"review": "等待确认",
"publishing": "正在定稿",
"complete": "已完成",
"failed": "处理失败",
}
KIND_LABELS = {
"background_check": "背景核对",
"voiceprint": "说话人身份",
"draft_question": "正文存疑",
"entity_card": "建卡建议",
"draft_overall": "整体评估",
"publish_approval": "定稿发布",
"low_confidence": "专有名词确认",
}
def safe(value: str, limit: int) -> str:
return re.sub(
r"\s+", " ", re.sub(r"[\x00-\x1f\x7f]+", " ", value or "")
).strip()[:limit]
def stem(value: str) -> str:
return (
re.sub(r"[^\w\u3400-\u9fff.-]+", "-", safe(value, 80)).strip("-.")
or "录音"
)
def find_audio_file(job: dict) -> Path | None:
stored = Path(str(job.get("stored_name") or "")).name
if not stored or stored in {".", ".."}:
return None
jid = str(job.get("id") or "")[:8]
base_stem = Path(stored).stem
suffix = Path(stored).suffix
for folder in (PROCESSED, PROCESSING, INBOX, FAILED):
if not folder.exists():
continue
direct = folder / stored
if direct.is_file():
return direct
if jid:
alt = folder / f"{base_stem}-{jid}{suffix}"
if alt.is_file():
return alt
return None
def delete_job_and_artifacts(
job_id: str,
*,
delete_files: bool = True,
delete_result: bool = False,
) -> bool:
job = db.delete_job(job_id)
if not job:
return False
review.delete_for_job(
transcript_path=str(job.get("transcript") or ""),
draft_path=str(job.get("draft") or ""),
job_id=job_id,
)
for marker in REVISION_QUEUE.glob(f"*{job_id}*.json"):
marker.unlink(missing_ok=True)
if delete_files:
audio = find_audio_file(job)
if audio and audio.is_file() and path_is_under(audio, artifact_roots()):
audio.unlink(missing_ok=True)
for p in (
TRANSCRIPTS / f"{job_id}.txt",
TRANSCRIPTS / f"{job_id}.asr.json",
DRAFTS / f"{job_id}.md",
Path(str(job.get("transcript") or "")) if job.get("transcript") else None,
Path(str(job.get("draft") or "")) if job.get("draft") else None,
):
if p and p.is_file() and path_is_under(p, artifact_roots()):
p.unlink(missing_ok=True)
if delete_result and job.get("result"):
rpath = Path(str(job["result"]))
if rpath.is_file() and path_is_under(rpath, artifact_roots()):
rpath.unlink(missing_ok=True)
return True
def _filter_settings(body: dict) -> dict[str, str]:
updates: dict[str, str] = {}
for key, value in body.items():
if key in SETTING_TEXT_KEYS:
text = safe(str(value), 200)
if text:
updates[key] = text
elif key in SETTING_SECRET_KEYS:
raw = str(value).strip()
if raw == "CLEAR":
updates[key] = ""
elif raw and raw not in {"已配置", "未配置"}:
updates[key] = raw
elif key in SETTING_BOOL_KEYS:
updates[key] = "0" if str(value).lower() in {"0", "false", "no", "off"} else "1"
if "asr_provider" in updates:
updates["asr_provider"] = providers.normalize_provider_name(updates["asr_provider"])
return updates
def _requeue_audio(job: dict, prov: str, model: str) -> str | None:
if job.get("status") in BUSY_STATUSES:
return "任务正在处理,未重新排队"
source = find_audio_file(job)
if not source:
return "找不到原音频,未重新排队"
if source.parent.resolve() == PROCESSING.resolve():
return "音频仍在处理目录,未重新排队"
stored = Path(str(job.get("stored_name") or source.name)).name
dest = INBOX / stored
if source.resolve() != dest.resolve():
shutil.copyfile(source, dest)
old_ts = time.time() - 60
os.utime(dest, (old_ts, old_ts))
db.update_job(
job["id"], "queued", f"重新排队 (ASR: {prov})",
error_code="", asr_provider=prov, asr_model=model,
)
return None
def queue_rerefine(job: dict) -> bool:
if job.get("status") in BUSY_STATUSES:
return False
if not job.get("transcript"):
return False
REVISION_QUEUE.mkdir(parents=True, exist_ok=True)
jid = job["id"]
marker = REVISION_QUEUE / f"rerefine-{jid}.json"
tmp = REVISION_QUEUE / f".rerefine-{jid}.{os.getpid()}.tmp"
tmp.write_text(
json.dumps({"action": "rerefine", "job_id": jid, "draft": str(job.get("draft") or "")}, ensure_ascii=False),
encoding="utf-8",
)
tmp.replace(marker)
db.update_job(jid, "refining", "已排队重新生成精修草稿(免重跑 ASR)")
return True
def queue_revise_draft(job: dict) -> bool:
draft_str = str(job.get("draft") or "")
if not draft_str:
return False
review._persist_draft_feedback(str(job.get("transcript") or ""), draft_str)
return review._request_revision(draft_str)
def public(row: dict, detailed: bool = False) -> dict:
keys = (
"id", "original_name", "title", "size", "status", "detail", "source",
"sha256", "attempts", "error_code", "asr_provider", "asr_model",
"created_at", "updated_at",
)
out = {k: row.get(k) for k in keys}
out["asr_provider"] = out.get("asr_provider") or CFG.asr_provider
out["status_label"] = LABELS.get(row["status"], row["status"])
out["web_url"] = f"{PUBLIC_URL}/jobs/{row['id']}/"
out["review_url"] = f"{PUBLIC_URL}/jobs/{row['id']}/review/"
out["pending_reviews"] = len(pending(row))
out["artifacts"] = {
name: f"/api/v1/jobs/{row['id']}/artifacts/{name}"
for name in ("transcript", "draft", "result")
if row.get(name)
}
if detailed:
out["context"] = row.get("context", "")
out["events"] = db.events(row["id"])
return out
def _match_question_to_job(row: dict, job: dict) -> bool:
payload = json.loads(row.get("payload") or "{}")
return bool(
(job.get("transcript") and row.get("recording") == job["transcript"])
or (job.get("draft") and payload.get("draft_path") == job["draft"])
or payload.get("job_id") == job["id"]
or (payload.get("draft_path") and Path(str(payload["draft_path"])).stem == job["id"])
or (row.get("recording") and Path(str(row["recording"])).stem == job["id"])
)
def pending(job: dict | None = None) -> list[dict]:
all_rows = review.pending(1000)
if not job:
return all_rows
return [row for row in all_rows if _match_question_to_job(row, job)]
def _group_pending_by_job() -> list[tuple[dict | None, list[dict]]]:
"""Group all pending review questions by their parent recording job."""
all_rows = review.pending(1000)
if not all_rows:
return []
jobs = db.list_jobs(200)
grouped: dict[str, list[dict]] = {}
job_map: dict[str, dict] = {j["id"]: j for j in jobs}
unmatched: list[dict] = []
for row in all_rows:
matched_id = ""
for j in jobs:
if _match_question_to_job(row, j):
matched_id = j["id"]
break
if matched_id:
grouped.setdefault(matched_id, []).append(row)
else:
unmatched.append(row)
out: list[tuple[dict | None, list[dict]]] = []
for j in jobs:
if j["id"] in grouped:
out.append((j, grouped[j["id"]]))
if unmatched:
out.append((None, unmatched))
return out
def _session_token() -> str:
return webauth.session_token()
def _inject_csrf(body: str) -> str:
token = html.escape(webauth.csrf_token(), quote=True)
field = f' '
def repl(match: re.Match[str]) -> str:
return match.group(0) + field
return re.sub(r"
'
f'基于已有转写重新生成草稿 (免重跑 ASR) '
if has_transcript else ""
)
meta_form = f"""
编辑任务信息 / 补充背景 / 切换 ASR 模型重跑
指定 ASR 子型号(可选)
已知背景说明(修改后可点「保存并重新生成草稿」)
{html.escape(str(job.get('context') or ''))}
仅保存修改
{'保存并重新生成草稿 (免重跑 ASR) ' if has_transcript else ''}
{'保存并重新转写音频 (重跑 ASR,会再次占用识别服务) ' if job.get("status") not in BUSY_STATUSES else '正在处理,不能同时重新转写 '}
"""
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_form(row: dict, action_url: str) -> str:
"""One form per question so Enter submits this answer, not another question."""
qid = int(row["id"])
payload = json.loads(row.get("payload") or "{}")
kind = str(payload.get("kind") or "")
kind_label = KIND_LABELS.get(kind, kind or "待确认")
choices = payload.get("candidates") or []
sentence = str(payload.get("sentence") or "")
placeholder = (
"输入真实姓名,如:高瑶"
if kind == "voiceprint"
else ("若背景有误,请填写正确背景说明"
if kind == "background_check"
else "输入正确名称或补充修改说明")
)
quick_btns = "".join(
f'{html.escape(str(v))} '
for v in choices
)
quick_btns += (
f'跳过 / 保留原文 '
)
sentence_html = f'{html.escape(sentence)}
' if sentence else ""
return f"""
提交第 {qid} 题
{html.escape(kind_label)} #{qid} · {html.escape(row['question'])}
{sentence_html}
{quick_btns}
本题回答
提交本题
"""
def _render_batch_select(row: dict) -> str:
qid = int(row["id"])
payload = json.loads(row.get("payload") or "{}")
choices = payload.get("candidates") or []
select_opts = '-- 批量:选择预设 -- ' + "".join(
f'{html.escape(str(v))} '
for v in [*choices, "无法确认,保留原文"]
)
return (
f'#{qid} '
f'{select_opts} '
)
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)}
{fmt_ts(j['created_at'], with_seconds=False)} · 待确认 {len(rows)} 题
{audio_bar}{draft_box}"""
else:
hdr_html = f"""
未关联任务的历史待确认项 ({len(rows)} 题) """
q_forms = "".join(_render_question_form(r, action_url) for r in rows)
batch_selects = "".join(_render_batch_select(r) for r in rows)
q_ids_csv = ",".join(str(r["id"]) for r in rows)
group_cards.append(f"""
{hdr_html}
{q_forms}
{batch_selects}
每一题有自己的输入框,回车只提交该题。下面的下拉框用于一次提交多题。
批量提交本录音已选预设
本场全部保留原文
""")
if not group_cards:
back_url = f"/jobs/{focus_job['id']}/" if focus_job else "/"
revise_btn = ""
if focus_job and focus_job.get("draft"):
revise_btn = (
f''
f'立即应用已确认结果并触发修订定稿 '
)
group_cards.append(
f'当前没有待确认问题 '
f'所有已入队的确认项均已处理完毕。
{revise_btn}'
f'← 返回任务工作台
'
)
# Recently answered history with ability to reopen or modify answer
answered = review.recent_answered(25)
ans_rows = []
for r in answered:
qid = int(r["id"])
rec_stem = Path(str(r.get("recording") or "")).stem
ans_rows.append(
f'#{qid} {html.escape(rec_stem[:16])} '
f'{html.escape(str(r["question"])[:70])} '
f''
f' '
f' '
f'改答 '
f'撤回重答 '
f' '
)
history_html = (
f''
f'近期已确认记录(最近 {len(ans_rows)} 条,支持修改或撤回重答) '
f'ID 问题 已确认回答 / 操作 '
f'{"".join(ans_rows)}
'
if ans_rows else ""
)
body = f"""{notice}
多录音人工确认中心
支持按录音分组切换、单题快捷点击与单场录音批量提交
{switcher_html}
{''.join(group_cards)}
{history_html}"""
return shell("人工确认中心", body)
def _lexicon_pager(tab: str, q: str, page: int, total: int) -> str:
pages = max(1, (total + LEXICON_PAGE_SIZE - 1) // LEXICON_PAGE_SIZE)
page = min(max(page, 1), pages)
qv = urllib.parse.quote(q)
def href(n: int) -> str:
return f"/lexicon?tab={tab}&q={qv}&page={n}"
prev = f'上一页 ' if page > 1 else ""
nxt = f'下一页 ' if page < pages else ""
return (
f'共 {total} 条,第 {page}/{pages} 页,每页 {LEXICON_PAGE_SIZE} 条。 {prev} {nxt}
'
)
def lexicon_page(tab: str = "aliases", q: str = "", msg: str = "", page: int = 1) -> bytes:
tab = tab if tab in ("aliases", "corrections", "entities", "relations") else "aliases"
page = max(1, page)
offset = (page - 1) * LEXICON_PAGE_SIZE
try:
st = lexicon.stats()
except Exception:
st = {"entities": 0, "with_company": 0, "with_related": 0,
"aliases": 0, "aliases_card": 0, "aliases_learned": 0, "corrections": 0}
try:
gst = graph.stats()
except Exception:
gst = {"relations": 0, "interactions": 0, "documents": 0}
notice = f'{html.escape(msg)}
' if msg else ""
tabs_html = "".join(
f'{lbl} '
for t, lbl in (
("aliases", f"别名映射 ({st.get('aliases', 0)})"),
("corrections", f"发音纠错 ({st.get('corrections', 0)})"),
("entities", f"实体卡片 ({st.get('entities', 0)})"),
("relations", f"人物/机构关系 ({gst.get('relations', 0)})"),
)
)
content_html = ""
if tab == "aliases":
total = lexicon.count_aliases(q=q)
rows = lexicon.list_aliases(q=q, limit=LEXICON_PAGE_SIZE, offset=offset)
pager = _lexicon_pager(tab, q, page, total)
tr_list = []
for r in rows:
al = str(r["alias"])
can = str(r["canonical"])
knd = str(r.get("kind") or "人名")
org = str(r.get("origin") or "manual")
conf = int(r.get("confirmed") or 0)
nt = str(r.get("note") or "")
tr_list.append(f"""
人名
公司
项目
术语
{html.escape(org)}
已确认
保存
删除
""")
content_html = f"""
别名映射列表 {pager}
简称 / 别名 规范全名 类型 来源 确认 备注 操作
{''.join(tr_list) or '暂无数据 '}
"""
elif tab == "corrections":
total = lexicon.count_corrections(q=q)
rows = lexicon.list_corrections(q=q, limit=LEXICON_PAGE_SIZE, offset=offset)
pager = _lexicon_pager(tab, q, page, total)
tr_list = []
for r in rows:
sf = str(r["surface"])
can = str(r["canonical"])
cnt = int(r.get("n") or 1)
conf = int(r.get("confirmed") or 0)
nt = str(r.get("note") or "")
tr_list.append(f"""
{cnt} 次
已确认
保存
删除
""")
content_html = f"""
发音纠错列表 {pager}
误识别词 正确规范词 频次 确认 备注 操作
{''.join(tr_list) or '暂无数据 '}
"""
elif tab == "entities":
total = lexicon.count_entities(q=q)
rows = lexicon.list_entities(q=q, limit=LEXICON_PAGE_SIZE, offset=offset)
pager = _lexicon_pager(tab, q, page, total)
tr_list = []
for r in rows:
can = str(r["canonical"])
knd = str(r.get("kind") or "人名")
comp = str(r.get("company") or "")
role = str(r.get("role") or "")
rel = str(r.get("related") or "")
src_tag = "Obsidian卡" if r.get("card") else "手动维护"
tr_list.append(f"""
人名
公司
项目
{src_tag}
保存
删除
""")
content_html = f"""新增实体卡片记录(手动维护实体不会被 Vault 同步覆盖)
新增实体
实体列表 {pager}
规范名称 类型 公司/机构 职务/角色 关联人 来源 操作
{''.join(tr_list) or '暂无数据 '}
"""
else: # relations
total = lexicon.count_relations(q=q)
rows = lexicon.list_relations(q=q, limit=LEXICON_PAGE_SIZE, offset=offset)
pager = _lexicon_pager(tab, q, page, total)
tr_list = []
for r in rows:
subj = str(r["subject"])
pred = str(r["predicate"])
obj = str(r["object"])
conf = float(r.get("confidence") or 0.8)
src = str(r.get("source") or "manual")
act = int(r.get("active") if r.get("active") is not None else 1)
tr_list.append(f"""
启用
保存
删除
""")
content_html = f"""
关系图谱列表 {pager}
主体 关系 客体 置信度 状态 操作
{''.join(tr_list) or '暂无数据 '}
"""
body = f"""{notice}
实体与纠错词库管理 (SQLite + Obsidian)
从 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:
return "已配置" if val else "未配置"
def _configured(val: str) -> str:
return "已配置(内容已隐藏)" if val else "未配置"
def settings_page(msg: str = "") -> bytes:
notice = f'{html.escape(msg)}
' if msg else ""
hb_path = STATE / "worker.heartbeat"
hb_ok = hb_path.exists() and (time.time() - hb_path.stat().st_mtime < 90)
hb_text = "运行正常 (Ready)" if hb_ok else "未检测到近期心跳 (Standby / 独立模式)"
prov_rows = []
for p in providers.provider_status():
badge = '就绪 ' if p["ready"] else '未配密钥 '
prov_rows.append(
f"{html.escape(p['id'])} "
f"{html.escape(p['label'])} "
f"{html.escape(p['model'])} "
f"{_configured(p['endpoint'])} "
f"{html.escape(p['via'])} "
f"{badge} "
)
hs_ok = False
if CFG.use_hindsight and CFG.hindsight_api:
try:
with urllib.request.urlopen(f"{CFG.hindsight_api}/health", timeout=3) as r:
hs_ok = r.status == 200
except Exception:
hs_ok = False
vault_ok = CFG.vault.is_dir()
s = db.get_all_settings()
litellm_key_mask = _mask_secret(s.get("litellm_api_key", ""))
soniox_key_mask = _mask_secret(s.get("soniox_api_key", ""))
grok_key_mask = _mask_secret(s.get("grok_api_key", ""))
web_pw_mask = _mask_secret(webauth.password())
prov_opts = _provider_options(CFG.asr_provider)
body = f"""{notice}
实时运行与通道就绪状态
Worker 进程状态 {html.escape(hb_text)}
Hindsight 记忆服务 {_configured(CFG.hindsight_api)} · {'在线 ' if hs_ok else '离线或未连接 '}
Obsidian 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 setup(self) -> None:
super().setup()
self.connection.settimeout(30)
self._suppress_body = False
def handle_one_request(self) -> None:
try:
super().handle_one_request()
except ClientError as exc:
try:
self.json({"error": exc.message}, exc.status)
except Exception:
pass
except (TimeoutError, socket.timeout):
try:
self.json({"error": "timeout"}, 408)
except Exception:
pass
except Exception:
try:
self.json({"error": "internal"}, 500)
except Exception:
pass
def send_data(
self,
data: bytes,
status: int = 200,
ctype: str = "text/html; charset=utf-8",
disposition: str = "",
extra_headers: dict[str, str] | None = None,
) -> None:
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'",
)
if disposition:
self.send_header("Content-Disposition", disposition)
if extra_headers:
for k, v in extra_headers.items():
self.send_header(k, v)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
if not getattr(self, "_suppress_body", False):
self.wfile.write(data)
def redirect(self, location: str) -> None:
self.send_response(303)
self.send_header("Location", location)
self.end_headers()
def json(self, obj: object, status: int = 200) -> None:
self.send_data(
json.dumps(obj, ensure_ascii=False).encode("utf-8"),
status,
"application/json; charset=utf-8",
)
def auth(self) -> bool:
value = self.headers.get("Authorization", "")
return bool(
AGENT_TOKEN
and value.startswith("Bearer ")
and hmac.compare_digest(value[7:], AGENT_TOKEN)
)
def web_authed(self) -> bool:
if webauth.allow_anon():
return True
if not webauth.password():
return False
if self.auth():
return True
return webauth.cookie_matches(self.headers.get("Cookie", ""))
def need_web_auth(self) -> bool:
if self.web_authed():
return True
path = urllib.parse.urlsplit(self.path).path
if path.startswith("/ui/") or path.endswith("models-probe"):
self.json({"error": "unauthorized"}, 401)
else:
self.redirect("/login")
return False
def _declared_length(self, max_bytes: int) -> int:
raw = self.headers.get("Content-Length")
if raw is None or raw == "":
return 0
try:
n = int(raw)
except ValueError:
raise ClientError(400, "bad content-length") from None
if n < 0:
raise ClientError(400, "bad content-length")
if n > max_bytes:
raise ClientError(413, "payload too large")
return n
def _read_exact(self, n: int) -> bytes:
if n <= 0:
return b""
data = self.rfile.read(n)
if len(data) < n:
raise ClientError(400, "incomplete body")
return data
def _csrf_ok(self, form: dict[str, list[str]] | None = None) -> bool:
if self.auth():
return True
supplied = self.headers.get("X-CSRF-Token", "")
if not supplied and form is not None:
supplied = (form.get("csrf") or [""])[0]
return webauth.csrf_matches(supplied)
def need_auth(self) -> bool:
if self.auth():
return True
self.json({"error": "unauthorized"}, 401)
return False
def header(self, name: str, limit: int) -> str:
return safe(urllib.parse.unquote(self.headers.get(name, "")), limit)
def read_form(self, max_bytes: int = 65536, *, csrf: bool = True) -> dict[str, list[str]]:
length = self._declared_length(max_bytes)
raw = self._read_exact(length).decode("utf-8", errors="ignore") if length else ""
form = urllib.parse.parse_qs(raw, keep_blank_values=True)
if csrf and not self._csrf_ok(form):
raise ClientError(403, "csrf")
return form
def upload(self, source: str, protected: bool) -> None:
if protected and not self.auth():
return self.json({"error": "unauthorized"}, 401)
if not protected and not self.web_authed():
return self.json({"error": "unauthorized"}, 401)
if not protected and not self._csrf_ok():
return self.json({"error": "csrf"}, 403)
length = self._declared_length(MAX_UPLOAD)
name = self.header("X-Filename", 240)
suffix = Path(name).suffix.lower()
if length <= 0 or length > MAX_UPLOAD:
return self.json({"error": "invalid size"}, 413)
if suffix not in SUPPORTED:
return self.json({"error": "unsupported type"}, 415)
jid = uuid.uuid4().hex[:24]
title = self.header("X-Title", 80)
prov = providers.normalize_provider_name(
self.header("X-ASR-Provider", 40) or CFG.asr_provider
)
model = self.header("X-ASR-Model", 80)
stored = f"{time.strftime('%Y-%m-%d_%H_%M_%S')}-{stem(title or Path(name).stem)}-{jid[:8]}{suffix}"
staging = INBOX / ".uploading"
staging.mkdir(parents=True, exist_ok=True)
part = staging / f"{jid}.part"
remaining = length
digest = hashlib.sha256()
try:
with part.open("xb") as out:
while remaining:
chunk = self.rfile.read(min(1024 * 1024, remaining))
if not chunk:
raise ClientError(400, "incomplete upload")
out.write(chunk)
digest.update(chunk)
remaining -= len(chunk)
out.flush()
os.fsync(out.fileno())
part.replace(INBOX / stored)
job = db.create_job(
jid, name, stored,
title=title,
context=self.header("X-Context", 2000),
size=length,
source=source,
asr_provider=prov,
asr_model=model,
)
db.update_job(
jid, "queued", f"录音已落盘,等待 worker (ASR: {prov})",
sha256=digest.hexdigest(),
asr_provider=prov,
asr_model=model,
)
self.json(public(job), 201)
except ClientError:
part.unlink(missing_ok=True)
raise
except Exception:
part.unlink(missing_ok=True)
self.json({"error": "upload failed"}, 500)
def artifact(self, job: dict | None, name: str, api: bool = False) -> None:
if not job:
return self.json({"error": "not found"}, 404)
text = _read_artifact_text(job, name)
if not text:
return self.json({"error": "not found"}, 404)
path = Path(str(job.get(name) or f"{name}.md"))
self.send_data(
text.encode("utf-8"),
200,
"text/markdown; charset=utf-8",
content_disposition(path.name),
)
def stream_audio(self, job: dict | None) -> None:
if not job:
return self.send_data(b"Not found", 404, "text/plain")
audio_path = find_audio_file(job)
if not audio_path or not audio_path.is_file():
return self.send_data(b"Audio not found", 404, "text/plain")
mime = mimetypes.guess_type(audio_path.name)[0] or "audio/mpeg"
size = audio_path.stat().st_size
status, start, end = _parse_byte_range(self.headers.get("Range", ""), size)
if status == 416:
self.send_response(416)
self.send_header("Content-Range", f"bytes */{size}")
self.send_header("Content-Length", "0")
self.end_headers()
return
length = end - start + 1
self.send_response(status)
self.send_header("Content-Type", mime)
self.send_header("Accept-Ranges", "bytes")
if status == 206:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.send_header("Content-Length", str(length))
self.end_headers()
if getattr(self, "_suppress_body", False):
return
with audio_path.open("rb") as f:
f.seek(start)
rem = length
while rem > 0:
buf = f.read(min(256 * 1024, rem))
if not buf:
break
self.wfile.write(buf)
rem -= len(buf)
def do_GET(self) -> None:
parsed = urllib.parse.urlsplit(self.path)
path = parsed.path
q = urllib.parse.parse_qs(parsed.query)
if path in ("/health", "/healthz"):
return self.json({"ok": True, "service": "asr-web"})
if path == "/readyz":
try:
db.connect().close()
hb = STATE / "worker.heartbeat"
ok = hb.exists() and time.time() - hb.stat().st_mtime < 90
except Exception:
ok = False
return self.json({"ready": ok}, 200 if ok else 503)
if path == "/login":
return self.send_data(login_page())
if path == "/logout":
self.send_response(303)
self.send_header("Location", "/login")
self.send_header(
"Set-Cookie",
"asr_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
)
self.end_headers()
return
if path == "/api/v1/providers":
if not self.need_auth():
return
return self.json({"providers": providers.provider_status(), "default": CFG.asr_provider})
if path == "/api/v1/settings":
if not self.need_auth():
return
raw_s = db.get_all_settings()
redacted = {
k: (_mask_secret(v) if (k.endswith("_api_key") or k == "web_password") else v)
for k, v in raw_s.items()
}
return self.json({"settings": redacted})
if path == "/api/v1/jobs":
if not self.need_auth():
return
return self.json({
"jobs": [
public(x)
for x in db.list_jobs(
int(q.get("limit", ["30"])[0]),
int(q.get("offset", ["0"])[0]),
q.get("status", [""])[0],
q.get("q", [""])[0],
q.get("provider", [""])[0],
)
]
})
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})", path)
if m:
if not self.need_auth():
return
job = db.get_job(m.group(1))
return self.json(public(job, True)) if job else self.json({"error": "not found"}, 404)
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/artifacts/(transcript|draft|result)", path)
if m:
if not self.need_auth():
return
return self.artifact(db.get_job(m.group(1)), m.group(2), True)
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/reviews", path)
if m:
if not self.need_auth():
return
job = db.get_job(m.group(1))
if not job:
return self.json({"error": "not found"}, 404)
rows = []
for row in pending(job):
payload = json.loads(row["payload"] or "{}")
rows.append({
"id": row["id"],
"question": row["question"],
"kind": payload.get("kind", ""),
"sentence": payload.get("sentence", ""),
"candidates": payload.get("candidates") or [],
})
return self.json({"reviews": rows})
# Web UI routes below
if not self.need_web_auth():
return
if path == "/":
return self.send_data(home(q.get("msg", [""])[0]))
if path == "/review/":
return self.send_data(review_page(None, q.get("msg", [""])[0]))
if path == "/lexicon":
return self.send_data(
lexicon_page(
tab=q.get("tab", ["aliases"])[0],
q=q.get("q", [""])[0],
msg=q.get("msg", [""])[0],
page=max(1, int(q.get("page", ["1"])[0] or 1)) if str(q.get("page", ["1"])[0]).isdigit() else 1,
)
)
if path == "/settings":
return self.send_data(settings_page(q.get("msg", [""])[0]))
if path == "/settings/models-probe":
return self.json(_probe_litellm_models())
if path == "/docs":
doc = {
"service": "meeting-asr",
"endpoints": [
"GET /health, /readyz",
"GET /api/v1/providers",
"GET /api/v1/settings",
"POST /api/v1/settings",
"POST /api/v1/jobs (headers: X-Filename, X-Title, X-Context, X-ASR-Provider, X-ASR-Model)",
"GET /api/v1/jobs?status=&provider=&q=&limit=30&offset=0",
"GET /api/v1/jobs/{job_id}",
"DELETE /api/v1/jobs/{job_id}",
"GET /api/v1/jobs/{job_id}/artifacts/{transcript|draft|result}",
"GET /api/v1/jobs/{job_id}/reviews",
"POST /api/v1/jobs/{job_id}/reviews/{question_id}",
"POST /api/v1/jobs/{job_id}/retry (json: {\"provider\": \"soniox\", \"model\": \"\"})",
"POST /api/v1/jobs/{job_id}/rerefine",
],
}
return self.send_data(shell("API 文档", f'API 接口说明 {html.escape(json.dumps(doc, ensure_ascii=False, indent=2))} '))
if path == "/ui/jobs":
status_flt = q.get("status", [""])[0]
prov_flt = q.get("provider", [""])[0]
q_flt = q.get("q", [""])[0]
return self.json({
"jobs": [
public(x)
for x in db.list_jobs(60, 0, status=status_flt, q=q_flt, provider=prov_flt)
]
})
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/", path)
if m:
job = db.get_job(m.group(1))
return self.send_data(job_page(job, q.get("msg", [""])[0])) if job else self.send_data(b"Not found", 404, "text/plain")
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/review/", path)
if m:
job = db.get_job(m.group(1))
return self.send_data(review_page(job, q.get("msg", [""])[0])) if job else self.send_data(b"Not found", 404, "text/plain")
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/audio", path)
if m:
return self.stream_audio(db.get_job(m.group(1)))
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/artifact/(transcript|draft|result)", path)
if m:
return self.artifact(db.get_job(m.group(1)), m.group(2), False)
self.send_data(shell(
"未找到",
'',
), 404)
def do_HEAD(self) -> None:
self._suppress_body = True
try:
self.do_GET()
finally:
self._suppress_body = False
def do_OPTIONS(self) -> None:
self.send_response(204)
self.send_header("Allow", "GET, HEAD, POST, DELETE, OPTIONS")
self.send_header("Content-Length", "0")
self.end_headers()
def do_DELETE(self) -> None:
path = urllib.parse.urlsplit(self.path).path
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})", path)
if m:
if not self.need_auth():
return
ok = delete_job_and_artifacts(m.group(1), delete_files=True, delete_result=False)
return self.json({"ok": ok}, 200 if ok else 404)
self.json({"error": "not found"}, 404)
def do_POST(self) -> None:
path = urllib.parse.urlsplit(self.path).path
if path == "/login":
origin = self.headers.get("Origin", "")
host = self.headers.get("Host", "")
if origin and urllib.parse.urlsplit(origin).netloc not in {"", host}:
return self.send_data(login_page("来源不被接受"), 403)
form = self.read_form(4096, csrf=False)
pw = form.get("password", [""])[0]
if not webauth.password():
return self.send_data(login_page(), 401)
ip = self.client_address[0]
fails, stamp = _LOGIN_FAILS.get(ip, (0, 0.0))
if time.time() - stamp > 600:
fails = 0
if fails >= 8:
return self.send_data(login_page("尝试过多,请 10 分钟后再试"), 429)
if hmac.compare_digest(pw, webauth.password()):
_LOGIN_FAILS.pop(ip, None)
secure = ""
if os.getenv("ASR_WEB_COOKIE_SECURE", "").strip().lower() in {"1", "true", "yes", "on"}:
secure = "; Secure"
self.send_response(303)
self.send_header("Location", "/")
self.send_header(
"Set-Cookie",
f"asr_session={webauth.session_token()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400{secure}",
)
self.end_headers()
return
_LOGIN_FAILS[ip] = (fails + 1, time.time())
return self.send_data(login_page("口令错误,请重试"), 401)
if path == "/ui/upload":
return self.upload("web", False)
if path == "/api/v1/jobs":
return self.upload("agent", True)
if path == "/api/v1/settings":
if not self.need_auth():
return
length = self._declared_length(32768)
body = json.loads(self._read_exact(length).decode("utf-8") or "{}")
if not isinstance(body, dict):
raise ClientError(400, "settings must be an object")
db.save_settings(_filter_settings(body))
return self.json({"ok": True})
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/reviews/(\d+)", path)
if m:
if not self.need_auth():
return
job = db.get_job(m.group(1))
qid = int(m.group(2))
if not job or qid not in {x["id"] for x in pending(job)}:
return self.json({"error": "not found"}, 404)
length = self._declared_length(4096)
body = json.loads(self._read_exact(length).decode("utf-8") or "{}")
ans = str(body.get("answer", "")).strip()
if not ans:
return self.json({"error": "answer required"}, 400)
return self.json(review.answer(qid, ans[:200]))
m = re.fullmatch(r"/(?:api/v1/)?jobs/([0-9a-f]{24})/rerefine", path)
if m:
api = path.startswith("/api/")
if api and not self.need_auth():
return
if not api and not self.need_web_auth():
return
if not api:
self.read_form(8192)
job = db.get_job(m.group(1))
if not job:
return self.json({"error": "not found"}, 404)
ok = queue_rerefine(job)
if api:
return self.json({"ok": ok}, 202 if ok else 409)
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已加入重新精修队列"))
m = re.fullmatch(r"/(?:api/v1/)?jobs/([0-9a-f]{24})/retry", path)
if m:
api = path.startswith("/api/")
if api and not self.need_auth():
return
if not api and not self.need_web_auth():
return
job = db.get_job(m.group(1))
if not job:
return self.json({"error": "not found"}, 404)
new_prov = job.get("asr_provider") or CFG.asr_provider
new_model = job.get("asr_model") or ""
if api:
length = self._declared_length(4096)
raw_body = self._read_exact(length).decode("utf-8", errors="ignore") if length else ""
if raw_body.strip().startswith("{"):
try:
body_json = json.loads(raw_body)
if body_json.get("provider"):
new_prov = providers.normalize_provider_name(body_json["provider"])
if "model" in body_json:
new_model = safe(str(body_json.get("model") or ""), 80)
except ValueError:
raise ClientError(400, "bad json") from None
else:
form = self.read_form(8192)
if form.get("provider"):
new_prov = providers.normalize_provider_name(form["provider"][0])
if "model" in form:
new_model = safe(form.get("model", [""])[0], 80)
err = _requeue_audio(job, new_prov, new_model)
if err:
if api:
return self.json({"error": err}, 409)
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote(err))
if api:
return self.json({"ok": True, "asr_provider": new_prov, "asr_model": new_model}, 202)
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已重新排队转写"))
if not self.need_web_auth():
return
# ---- Task deletion & metadata editing ----
if path == "/jobs/batch-delete":
form = self.read_form(65536)
del_files = form.get("delete_files", ["1"])[0] == "1"
if form.get("cleanup_failed"):
failed_jobs = db.list_jobs(200, 0, status="failed")
for fj in failed_jobs:
delete_job_and_artifacts(fj["id"], delete_files=del_files, delete_result=False)
return self.redirect("/?msg=" + urllib.parse.quote(f"已清理 {len(failed_jobs)} 个失败任务"))
ids = form.get("job_id", [])
n_del = 0
for jid in ids:
if delete_job_and_artifacts(jid, delete_files=del_files, delete_result=False):
n_del += 1
return self.redirect("/?msg=" + urllib.parse.quote(f"已删除 {n_del} 个任务"))
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/delete", path)
if m:
form = self.read_form(4096)
del_files = form.get("delete_files", ["0"])[0] == "1"
del_res = form.get("delete_result", ["0"])[0] == "1"
ok = delete_job_and_artifacts(m.group(1), delete_files=del_files, delete_result=del_res)
msg = "任务已删除" if ok else "没有找到这个任务,未删除"
return self.redirect("/?msg=" + urllib.parse.quote(msg))
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/meta", path)
if m:
job = db.get_job(m.group(1))
if not job:
return self.send_data(b"Not found", 404, "text/plain")
form = self.read_form(32768)
raw_title = form.get("title", [""])[0]
raw_prov = form.get("provider", [CFG.asr_provider])[0].strip().lower()
new_title = safe(raw_title, 80)
new_ctx = safe(form.get("context", [""])[0], 2000)
new_prov = providers.normalize_provider_name(raw_prov)
new_model = safe(form.get("model", [""])[0], 80)
db.update_job_meta(
job["id"], title=new_title, context=new_ctx,
asr_provider=new_prov, asr_model=new_model,
)
notes = []
if len(raw_title) > 80:
notes.append("标题已截断到 80 字")
if raw_prov and raw_prov not in providers.PROVIDERS:
notes.append(f"未知引擎已改用 {new_prov}")
action = form.get("next_action", ["save"])[0]
updated_job = db.get_job(job["id"]) or job
if action == "rerefine":
queued = queue_rerefine(updated_job)
msg = "已保存并排队重新生成草稿" if queued else "已保存;任务正在处理,未重新生成草稿"
elif action == "retry":
err = _requeue_audio(updated_job, new_prov, new_model)
msg = err or "已保存并重新排队转写"
else:
msg = "任务信息已更新"
if notes:
msg += "(" + ";".join(notes) + ")"
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote(msg))
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/revise", path)
if m:
job = db.get_job(m.group(1))
if not job:
return self.send_data(b"Not found", 404, "text/plain")
queue_revise_draft(job)
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已触发修订与定稿流程"))
# ---- Multi-recording review submission ----
if path == "/review/" or re.fullmatch(r"/jobs/([0-9a-f]{24})/review/", path):
m_job = re.fullmatch(r"/jobs/([0-9a-f]{24})/review/", path)
job = db.get_job(m_job.group(1)) if m_job else None
form = self.read_form(65536)
allowed_ids = {x["id"] for x in pending(job)}
answered_count = 0
# Case 1: Single preset button clicked (`single_submit` = "qid::answer")
if form.get("single_submit"):
raw_val = form["single_submit"][0]
if "::" in raw_val:
qid_str, ans_val = raw_val.split("::", 1)
if qid_str.isdigit() and int(qid_str) in allowed_ids and ans_val.strip():
review.answer(int(qid_str), ans_val.strip()[:200])
answered_count += 1
# Case 2: Single custom button clicked (`single_custom` = "qid")
elif form.get("single_custom"):
qid_str = form["single_custom"][0]
if qid_str.isdigit() and int(qid_str) in allowed_ids:
ans_val = (
form.get(f"custom_{qid_str}", [""])[0].strip()
or form.get(f"preset_{qid_str}", [""])[0].strip()
)
if ans_val:
review.answer(int(qid_str), ans_val[:200])
answered_count += 1
# Case 3: Skip all questions in the recording group
elif form.get("batch_skip_all"):
qids = [
int(x) for x in form.get("question_ids", [""])[0].split(",")
if x.strip().isdigit() and int(x) in allowed_ids
]
for qid in qids:
review.answer(qid, "无法确认,保留原文")
answered_count += 1
# Case 4: Batch submit filled questions in the recording group
elif form.get("batch_submit"):
qids = [
int(x) for x in form.get("question_ids", [""])[0].split(",")
if x.strip().isdigit() and int(x) in allowed_ids
]
for qid in qids:
ans_val = (
form.get(f"custom_{qid}", [""])[0].strip()
or form.get(f"preset_{qid}", [""])[0].strip()
)
if ans_val:
review.answer(qid, ans_val[:200])
answered_count += 1
# Legacy fallback (`id` + `answer`/`custom`)
elif form.get("id"):
raw_id = form.get("id", ["0"])[0]
if not str(raw_id).isdigit():
raise ClientError(400, "bad id")
qid = int(raw_id)
ans_val = form.get("custom", [""])[0].strip() or form.get("answer", [""])[0].strip()
if qid in allowed_ids and ans_val:
review.answer(qid, ans_val[:200])
answered_count += 1
return self.redirect(f"{path}?msg=" + urllib.parse.quote(f"已提交 {answered_count} 项确认"))
if path == "/review/update-answer":
form = self.read_form(8192)
raw_id = form.get("id", ["0"])[0]
if not str(raw_id).isdigit():
raise ClientError(400, "bad id")
qid = int(raw_id)
if qid > 0:
if form.get("reopen"):
review.reopen_question(qid)
return self.redirect("/review/?msg=" + urllib.parse.quote(f"问题 #{qid} 已撤回至待确认队列"))
new_ans = safe(form.get("answer", [""])[0], 200)
if new_ans:
review.answer(qid, new_ans, allow_update=True)
return self.redirect("/review/?msg=" + urllib.parse.quote(f"问题 #{qid} 回答已更新"))
return self.redirect("/review/")
m_edit = re.fullmatch(r"/jobs/([0-9a-f]{24})/edit", path)
if m_edit:
job = db.get_job(m_edit.group(1))
if not job:
return self.send_data(b"Not found", 404, "text/plain")
form = self.read_form(2 * 1024 * 1024)
target = form.get("target", [""])[0]
content = form.get("content", [""])[0]
if target not in ("draft", "result"):
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:只能编辑草稿或正式纪要"))
if not content.strip():
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:内容为空"))
if not job.get(target):
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:还没有这个文件"))
tpath = Path(str(job[target]))
if not path_is_under(tpath, artifact_roots()):
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:文件不在允许的目录"))
vault.write_draft_file(tpath, content)
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("Markdown 修改已保存"))
# ---- Lexicon CRUD endpoints ----
if path == "/lexicon/learn":
form = self.read_form(8192)
alias_val = safe(form.get("alias", [""])[0], 40)
canon_val = safe(form.get("canonical", [""])[0], 40)
kind_val = safe(form.get("kind", [""])[0], 20)
note_val = safe(form.get("note", [""])[0], 120)
tab = safe(form.get("tab", ["aliases"])[0], 20)
if alias_val and canon_val:
lexicon.learn_alias(alias_val, canon_val, kind=kind_val, origin="manual", note=note_val)
lexicon.learn_correction(alias_val, canon_val, note=note_val)
return self.redirect(f"/lexicon?tab={tab}&msg=" + urllib.parse.quote(f"已添加映射:{alias_val} → {canon_val}"))
return self.redirect(f"/lexicon?tab={tab}&msg=" + urllib.parse.quote("请填写完整的词条映射"))
if path == "/lexicon/alias/update":
form = self.read_form(8192)
old_a = safe(form.get("old_alias", [""])[0], 40)
old_c = safe(form.get("old_canonical", [""])[0], 40)
new_a = safe(form.get("alias", [""])[0], 40)
new_c = safe(form.get("canonical", [""])[0], 40)
kind = safe(form.get("kind", ["人名"])[0], 20)
conf = 1 if form.get("confirmed") else 0
note = safe(form.get("note", [""])[0], 120)
if not (new_a and new_c):
return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote("未填写完整,没有修改别名"))
lexicon.update_alias(old_a or new_a, old_c or new_c, new_a, new_c, kind=kind, confirmed=conf, note=note)
return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote(f"已更新别名:{new_a} → {new_c}"))
if path == "/lexicon/alias/delete":
form = self.read_form(8192)
old_a = safe(form.get("old_alias", form.get("alias", [""]))[0], 40)
old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 40)
removed = lexicon.delete_alias(old_a, old_c)
msg = f"已删除别名:{old_a}" if removed else f"没有找到别名「{old_a}」,未删除"
return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote(msg))
if path == "/lexicon/correction/update":
form = self.read_form(8192)
old_s = safe(form.get("old_surface", [""])[0], 40)
old_c = safe(form.get("old_canonical", [""])[0], 40)
new_s = safe(form.get("surface", [""])[0], 40)
new_c = safe(form.get("canonical", [""])[0], 40)
conf = 1 if form.get("confirmed") else 0
note = safe(form.get("note", [""])[0], 120)
if new_s and new_c:
lexicon.update_correction(old_s or new_s, old_c or new_c, new_s, new_c, confirmed=conf, note=note)
return self.redirect("/lexicon?tab=corrections&msg=" + urllib.parse.quote(f"已更新纠错:{new_s} → {new_c}"))
if path == "/lexicon/correction/delete":
form = self.read_form(8192)
old_s = safe(form.get("old_surface", form.get("surface", [""]))[0], 40)
old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 40)
lexicon.delete_correction(old_s, old_c)
return self.redirect("/lexicon?tab=corrections&msg=" + urllib.parse.quote(f"已删除纠错:{old_s}"))
if path == "/lexicon/entity/update":
form = self.read_form(8192)
old_c = safe(form.get("old_canonical", [""])[0], 60)
new_c = safe(form.get("canonical", [""])[0], 60)
kind = safe(form.get("kind", ["人名"])[0], 20)
comp = safe(form.get("company", [""])[0], 80)
role = safe(form.get("role", [""])[0], 80)
rel = safe(form.get("related", [""])[0], 160)
if new_c:
if old_c:
lexicon.update_entity(old_c, new_c, kind, company=comp, role=role, related=rel)
else:
lexicon.upsert_entity(new_c, kind, company=comp, role=role, related=rel)
return self.redirect("/lexicon?tab=entities&msg=" + urllib.parse.quote(f"已保存实体:{new_c}"))
if path == "/lexicon/entity/delete":
form = self.read_form(8192)
old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 60)
lexicon.delete_entity(old_c)
return self.redirect("/lexicon?tab=entities&msg=" + urllib.parse.quote(f"已删除实体:{old_c}"))
if path == "/lexicon/relation/update":
form = self.read_form(8192)
old_s = safe(form.get("old_subject", [""])[0], 60)
old_p = safe(form.get("old_predicate", [""])[0], 40)
old_o = safe(form.get("old_object", [""])[0], 60)
old_src = safe(form.get("old_source", ["manual"])[0], 120)
subj = safe(form.get("subject", [""])[0], 60)
pred = safe(form.get("predicate", [""])[0], 40)
obj = safe(form.get("object", [""])[0], 60)
try:
conf = float(form.get("confidence", ["0.85"])[0])
except ValueError:
conf = 0.85
act = 1 if form.get("active") else 0
if subj and pred and obj:
if old_s and old_p and old_o:
lexicon.update_relation(old_s, old_p, old_o, old_src, subj, pred, obj, confidence=conf, active=act)
else:
lexicon.upsert_relation(subj, pred, obj, confidence=conf, source="manual")
return self.redirect("/lexicon?tab=relations&msg=" + urllib.parse.quote(f"已保存关系:{subj} {pred} {obj}"))
if path == "/lexicon/relation/delete":
form = self.read_form(8192)
old_s = safe(form.get("old_subject", form.get("subject", [""]))[0], 60)
old_p = safe(form.get("old_predicate", form.get("predicate", [""]))[0], 40)
old_o = safe(form.get("old_object", form.get("object", [""]))[0], 60)
old_src = safe(form.get("old_source", ["manual"])[0], 120)
lexicon.delete_relation(old_s, old_p, old_o, old_src)
return self.redirect("/lexicon?tab=relations&msg=" + urllib.parse.quote(f"已删除关系:{old_s} {old_p} {old_o}"))
if path == "/lexicon/sync":
p_res = vault.pull()
l_res = lexicon.sync_from_vault(force=True)
g_res = graph.sync(force=True)
return self.redirect("/lexicon?msg=" + urllib.parse.quote(f"同步完成(Git:{p_res}):词库 {l_res},图谱 {g_res}"))
# ---- SQLite Settings Save ----
if path == "/settings":
form = self.read_form(32768)
updates: dict[str, str] = {}
text_keys = (
"litellm_base_url", "clean_model", "keyword_model", "draft_model",
"asr_provider", "diarize_url", "voiceprint_enroll_url",
"soniox_api_base", "soniox_model", "gemini_asr_model", "openai_asr_model",
"grok_api_base", "grok_asr_model", "hindsight_api", "hindsight_bank",
)
for k in text_keys:
if k not in form:
continue
text = safe(form[k][0], 200)
if not text:
continue
updates[k] = text
if "asr_provider" in updates:
updates["asr_provider"] = providers.normalize_provider_name(updates["asr_provider"])
for sk in ("litellm_api_key", "soniox_api_key", "grok_api_key", "web_password"):
raw_k = form.get(sk, [""])[0].strip()
if raw_k == "CLEAR":
updates[sk] = ""
elif raw_k:
updates[sk] = raw_k
for bk in ("use_hindsight", "use_termbank", "git_sync"):
updates[bk] = "1" if form.get(bk) else "0"
db.save_settings(updates)
return self.redirect("/settings?msg=" + urllib.parse.quote("配置已保存至 SQLite 并立即生效"))
self.send_data(shell(
"未找到",
'',
), 404)
def main() -> None:
if not webauth.password() and not webauth.allow_anon():
print(
"ASR_WEB_PASSWORD is empty; the browser UI stays closed until it is set. "
"ASR_WEB_ALLOW_ANON=1 opens it with no password.",
flush=True,
)
os.environ.setdefault("ASR_ROLE", "web")
os.environ.setdefault("ASR_REVISION_QUEUE_DIR", str(REVISION_QUEUE))
ensure_dirs()
db.connect().close()
if os.environ.get("ASR_ROLE", "").lower() == "all":
from . import worker
t = threading.Thread(target=worker.run_loop, name="asr-worker", daemon=True)
t.start()
port = int(os.environ.get("ASR_PORT", os.environ.get("ASR_REVIEW_PORT", "8788")))
host = os.environ.get("ASR_HOST", "0.0.0.0")
server = ThreadingHTTPServer((host, port), Handler)
server.daemon_threads = True
server.serve_forever()
if __name__ == "__main__":
main()