2231 lines
106 KiB
Python
2231 lines
106 KiB
Python
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import hmac
|
||
import html
|
||
import json
|
||
import mimetypes
|
||
import os
|
||
import re
|
||
import shutil
|
||
import socket
|
||
import threading
|
||
import time
|
||
import urllib.parse
|
||
import urllib.request
|
||
import uuid
|
||
from datetime import datetime
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from asrpipe import graph, lexicon, providers, review, vault, webauth
|
||
from asrpipe.config import CFG
|
||
from . import db
|
||
from .config import (
|
||
AGENT_TOKEN, DRAFTS, FAILED, INBOX, MAX_UPLOAD,
|
||
PROCESSED, PROCESSING, PUBLIC_URL, RESULTS, REVISION_QUEUE, STATE,
|
||
SUPPORTED, TRANSCRIPTS, ensure_dirs,
|
||
)
|
||
|
||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||
BUSY_STATUSES = {"transcribing", "refining", "publishing"}
|
||
_LOGIN_FAILS: dict[str, tuple[int, float]] = {}
|
||
SETTING_TEXT_KEYS = (
|
||
"litellm_base_url", "clean_model", "keyword_model", "draft_model",
|
||
"asr_provider", "diarize_url", "voiceprint_enroll_url",
|
||
"soniox_api_base", "soniox_model", "gemini_asr_model", "openai_asr_model",
|
||
"grok_api_base", "grok_asr_model", "hindsight_api", "hindsight_bank",
|
||
)
|
||
SETTING_SECRET_KEYS = ("litellm_api_key", "soniox_api_key", "grok_api_key", "web_password")
|
||
SETTING_BOOL_KEYS = ("use_hindsight", "use_termbank", "git_sync")
|
||
LEXICON_PAGE_SIZE = 40
|
||
|
||
|
||
class ClientError(Exception):
|
||
def __init__(self, status: int, message: str):
|
||
super().__init__(message)
|
||
self.status = status
|
||
self.message = message
|
||
|
||
|
||
def fmt_ts(ts: float, with_seconds: bool = True) -> str:
|
||
stamp = datetime.fromtimestamp(float(ts), SHANGHAI)
|
||
return stamp.strftime("%Y-%m-%d %H:%M:%S" if with_seconds else "%Y-%m-%d %H:%M")
|
||
|
||
|
||
def content_disposition(filename: str, inline: bool = True) -> str:
|
||
"""ASCII fallback plus RFC 5987 filename* so non-ASCII names stay in the header."""
|
||
kind = "inline" if inline else "attachment"
|
||
suffix = Path(filename).suffix
|
||
if not suffix.isascii():
|
||
suffix = ""
|
||
fallback = re.sub(r"[^A-Za-z0-9._-]+", "_", "download" + suffix).strip("._") or "download"
|
||
encoded = urllib.parse.quote(filename, safe="")
|
||
return f"{kind}; filename=\"{fallback}\"; filename*=UTF-8''{encoded}"
|
||
|
||
|
||
def _parse_byte_range(header: str, size: int) -> tuple[int, int, int]:
|
||
"""Return (status, start, end). status is 200, 206, or 416."""
|
||
if size <= 0:
|
||
return 200, 0, 0
|
||
if not header:
|
||
return 200, 0, size - 1
|
||
if not header.startswith("bytes=") or "," in header:
|
||
return 416, 0, 0
|
||
spec = header[6:].strip()
|
||
if spec.startswith("-"):
|
||
try:
|
||
suffix = int(spec[1:])
|
||
except ValueError:
|
||
return 416, 0, 0
|
||
if suffix <= 0:
|
||
return 416, 0, 0
|
||
start = max(0, size - suffix)
|
||
return 206, start, size - 1
|
||
match = re.match(r"^(\d+)-(\d*)$", spec)
|
||
if not match:
|
||
return 416, 0, 0
|
||
start = int(match.group(1))
|
||
end = int(match.group(2)) if match.group(2) else size - 1
|
||
end = min(end, size - 1)
|
||
if start >= size or start > end:
|
||
return 416, 0, 0
|
||
return 206, start, end
|
||
|
||
|
||
def path_is_under(path: Path, roots: list[Path]) -> bool:
|
||
try:
|
||
resolved = path.resolve()
|
||
except OSError:
|
||
return False
|
||
for root in roots:
|
||
try:
|
||
resolved.relative_to(root.resolve())
|
||
return True
|
||
except (OSError, ValueError):
|
||
continue
|
||
return False
|
||
|
||
|
||
def artifact_roots() -> list[Path]:
|
||
return [
|
||
INBOX, PROCESSING, PROCESSED, FAILED, TRANSCRIPTS, DRAFTS, RESULTS,
|
||
CFG.drafts, CFG.minutes, CFG.vault / "minutes",
|
||
CFG.vault / "People", CFG.vault / "Companies",
|
||
]
|
||
|
||
LABELS = {
|
||
"uploading": "上传中",
|
||
"queued": "等待处理",
|
||
"transcribing": "正在转写",
|
||
"refining": "正在整理",
|
||
"review": "等待确认",
|
||
"publishing": "正在定稿",
|
||
"complete": "已完成",
|
||
"failed": "处理失败",
|
||
}
|
||
|
||
KIND_LABELS = {
|
||
"background_check": "背景核对",
|
||
"voiceprint": "说话人身份",
|
||
"draft_question": "正文存疑",
|
||
"entity_card": "建卡建议",
|
||
"draft_overall": "整体评估",
|
||
"publish_approval": "定稿发布",
|
||
"low_confidence": "专有名词确认",
|
||
}
|
||
|
||
|
||
def safe(value: str, limit: int) -> str:
|
||
return re.sub(
|
||
r"\s+", " ", re.sub(r"[\x00-\x1f\x7f]+", " ", value or "")
|
||
).strip()[:limit]
|
||
|
||
|
||
def stem(value: str) -> str:
|
||
return (
|
||
re.sub(r"[^\w\u3400-\u9fff.-]+", "-", safe(value, 80)).strip("-.")
|
||
or "录音"
|
||
)
|
||
|
||
|
||
def find_audio_file(job: dict) -> Path | None:
|
||
stored = Path(str(job.get("stored_name") or "")).name
|
||
if not stored or stored in {".", ".."}:
|
||
return None
|
||
jid = str(job.get("id") or "")[:8]
|
||
base_stem = Path(stored).stem
|
||
suffix = Path(stored).suffix
|
||
for folder in (PROCESSED, PROCESSING, INBOX, FAILED):
|
||
if not folder.exists():
|
||
continue
|
||
direct = folder / stored
|
||
if direct.is_file():
|
||
return direct
|
||
if jid:
|
||
alt = folder / f"{base_stem}-{jid}{suffix}"
|
||
if alt.is_file():
|
||
return alt
|
||
return None
|
||
|
||
|
||
def delete_job_and_artifacts(
|
||
job_id: str,
|
||
*,
|
||
delete_files: bool = True,
|
||
delete_result: bool = False,
|
||
) -> bool:
|
||
job = db.delete_job(job_id)
|
||
if not job:
|
||
return False
|
||
review.delete_for_job(
|
||
transcript_path=str(job.get("transcript") or ""),
|
||
draft_path=str(job.get("draft") or ""),
|
||
job_id=job_id,
|
||
)
|
||
for marker in REVISION_QUEUE.glob(f"*{job_id}*.json"):
|
||
marker.unlink(missing_ok=True)
|
||
if delete_files:
|
||
audio = find_audio_file(job)
|
||
if audio and audio.is_file() and path_is_under(audio, artifact_roots()):
|
||
audio.unlink(missing_ok=True)
|
||
for p in (
|
||
TRANSCRIPTS / f"{job_id}.txt",
|
||
TRANSCRIPTS / f"{job_id}.asr.json",
|
||
DRAFTS / f"{job_id}.md",
|
||
Path(str(job.get("transcript") or "")) if job.get("transcript") else None,
|
||
Path(str(job.get("draft") or "")) if job.get("draft") else None,
|
||
):
|
||
if p and p.is_file() and path_is_under(p, artifact_roots()):
|
||
p.unlink(missing_ok=True)
|
||
if delete_result and job.get("result"):
|
||
rpath = Path(str(job["result"]))
|
||
if rpath.is_file() and path_is_under(rpath, artifact_roots()):
|
||
rpath.unlink(missing_ok=True)
|
||
return True
|
||
|
||
|
||
def _filter_settings(body: dict) -> dict[str, str]:
|
||
updates: dict[str, str] = {}
|
||
for key, value in body.items():
|
||
if key in SETTING_TEXT_KEYS:
|
||
text = safe(str(value), 200)
|
||
if text:
|
||
updates[key] = text
|
||
elif key in SETTING_SECRET_KEYS:
|
||
raw = str(value).strip()
|
||
if raw == "CLEAR":
|
||
updates[key] = ""
|
||
elif raw and raw not in {"已配置", "未配置"}:
|
||
updates[key] = raw
|
||
elif key in SETTING_BOOL_KEYS:
|
||
updates[key] = "0" if str(value).lower() in {"0", "false", "no", "off"} else "1"
|
||
if "asr_provider" in updates:
|
||
updates["asr_provider"] = providers.normalize_provider_name(updates["asr_provider"])
|
||
return updates
|
||
|
||
|
||
def _requeue_audio(job: dict, prov: str, model: str) -> str | None:
|
||
if job.get("status") in BUSY_STATUSES:
|
||
return "任务正在处理,未重新排队"
|
||
source = find_audio_file(job)
|
||
if not source:
|
||
return "找不到原音频,未重新排队"
|
||
if source.parent.resolve() == PROCESSING.resolve():
|
||
return "音频仍在处理目录,未重新排队"
|
||
stored = Path(str(job.get("stored_name") or source.name)).name
|
||
dest = INBOX / stored
|
||
if source.resolve() != dest.resolve():
|
||
shutil.copyfile(source, dest)
|
||
old_ts = time.time() - 60
|
||
os.utime(dest, (old_ts, old_ts))
|
||
db.update_job(
|
||
job["id"], "queued", f"重新排队 (ASR: {prov})",
|
||
error_code="", asr_provider=prov, asr_model=model,
|
||
)
|
||
return None
|
||
|
||
|
||
def queue_rerefine(job: dict) -> bool:
|
||
if job.get("status") in BUSY_STATUSES:
|
||
return False
|
||
if not job.get("transcript"):
|
||
return False
|
||
REVISION_QUEUE.mkdir(parents=True, exist_ok=True)
|
||
jid = job["id"]
|
||
marker = REVISION_QUEUE / f"rerefine-{jid}.json"
|
||
tmp = REVISION_QUEUE / f".rerefine-{jid}.{os.getpid()}.tmp"
|
||
tmp.write_text(
|
||
json.dumps({"action": "rerefine", "job_id": jid, "draft": str(job.get("draft") or "")}, ensure_ascii=False),
|
||
encoding="utf-8",
|
||
)
|
||
tmp.replace(marker)
|
||
db.update_job(jid, "refining", "已排队重新生成精修草稿(免重跑 ASR)")
|
||
return True
|
||
|
||
|
||
def queue_revise_draft(job: dict) -> bool:
|
||
draft_str = str(job.get("draft") or "")
|
||
if not draft_str:
|
||
return False
|
||
review._persist_draft_feedback(str(job.get("transcript") or ""), draft_str)
|
||
return review._request_revision(draft_str)
|
||
|
||
|
||
def public(row: dict, detailed: bool = False) -> dict:
|
||
keys = (
|
||
"id", "original_name", "title", "size", "status", "detail", "source",
|
||
"sha256", "attempts", "error_code", "asr_provider", "asr_model",
|
||
"created_at", "updated_at",
|
||
)
|
||
out = {k: row.get(k) for k in keys}
|
||
out["asr_provider"] = out.get("asr_provider") or CFG.asr_provider
|
||
out["status_label"] = LABELS.get(row["status"], row["status"])
|
||
out["web_url"] = f"{PUBLIC_URL}/jobs/{row['id']}/"
|
||
out["review_url"] = f"{PUBLIC_URL}/jobs/{row['id']}/review/"
|
||
out["pending_reviews"] = len(pending(row))
|
||
out["artifacts"] = {
|
||
name: f"/api/v1/jobs/{row['id']}/artifacts/{name}"
|
||
for name in ("transcript", "draft", "result")
|
||
if row.get(name)
|
||
}
|
||
if detailed:
|
||
out["context"] = row.get("context", "")
|
||
out["events"] = db.events(row["id"])
|
||
return out
|
||
|
||
|
||
def _match_question_to_job(row: dict, job: dict) -> bool:
|
||
payload = json.loads(row.get("payload") or "{}")
|
||
return bool(
|
||
(job.get("transcript") and row.get("recording") == job["transcript"])
|
||
or (job.get("draft") and payload.get("draft_path") == job["draft"])
|
||
or payload.get("job_id") == job["id"]
|
||
or (payload.get("draft_path") and Path(str(payload["draft_path"])).stem == job["id"])
|
||
or (row.get("recording") and Path(str(row["recording"])).stem == job["id"])
|
||
)
|
||
|
||
|
||
def pending(job: dict | None = None) -> list[dict]:
|
||
all_rows = review.pending(1000)
|
||
if not job:
|
||
return all_rows
|
||
return [row for row in all_rows if _match_question_to_job(row, job)]
|
||
|
||
|
||
def _group_pending_by_job() -> list[tuple[dict | None, list[dict]]]:
|
||
"""Group all pending review questions by their parent recording job."""
|
||
all_rows = review.pending(1000)
|
||
if not all_rows:
|
||
return []
|
||
jobs = db.list_jobs(200)
|
||
grouped: dict[str, list[dict]] = {}
|
||
job_map: dict[str, dict] = {j["id"]: j for j in jobs}
|
||
unmatched: list[dict] = []
|
||
|
||
for row in all_rows:
|
||
matched_id = ""
|
||
for j in jobs:
|
||
if _match_question_to_job(row, j):
|
||
matched_id = j["id"]
|
||
break
|
||
if matched_id:
|
||
grouped.setdefault(matched_id, []).append(row)
|
||
else:
|
||
unmatched.append(row)
|
||
|
||
out: list[tuple[dict | None, list[dict]]] = []
|
||
for j in jobs:
|
||
if j["id"] in grouped:
|
||
out.append((j, grouped[j["id"]]))
|
||
if unmatched:
|
||
out.append((None, unmatched))
|
||
return out
|
||
|
||
|
||
def _session_token() -> str:
|
||
return webauth.session_token()
|
||
|
||
|
||
def _inject_csrf(body: str) -> str:
|
||
token = html.escape(webauth.csrf_token(), quote=True)
|
||
field = f'<input type="hidden" name="csrf" value="{token}">'
|
||
|
||
def repl(match: re.Match[str]) -> str:
|
||
return match.group(0) + field
|
||
|
||
return re.sub(r"<form\b[^>]*>", repl, body, flags=re.IGNORECASE)
|
||
|
||
|
||
def shell(title: str, body: str, script: str = "", *, nav: bool = True) -> bytes:
|
||
pend_badge = ""
|
||
if nav:
|
||
pend_count = len(review.pending(500))
|
||
if pend_count:
|
||
pend_badge = (
|
||
f' <span class="badge review" style="font-size:11.5px;padding:1px 6px">{pend_count}</span>'
|
||
)
|
||
nav_html = ""
|
||
if nav:
|
||
nav_html = (
|
||
f'<nav><a href="/">任务管理</a><a href="/review/">待确认{pend_badge}</a>'
|
||
f'<a href="/lexicon">实体词库</a><a href="/settings">模型与系统配置</a>'
|
||
f'<a href="/docs">API</a><a href="/logout">退出</a></nav>'
|
||
)
|
||
body = _inject_csrf(body)
|
||
token = html.escape(webauth.csrf_token(), quote=True)
|
||
guard = f"""<script>
|
||
const CSRF=document.querySelector('meta[name="csrf-token"]').content;
|
||
document.querySelectorAll('form').forEach(f=>{{
|
||
if(!f.querySelector('input[name=csrf]')){{
|
||
const i=document.createElement('input'); i.type='hidden'; i.name='csrf'; i.value=CSRF; f.prepend(i);
|
||
}}
|
||
}});
|
||
const _fetch=window.fetch;
|
||
window.fetch=function(u,o){{
|
||
o=o||{{}};
|
||
const m=(o.method||'GET').toUpperCase();
|
||
if(m!=='GET'&&m!=='HEAD'){{
|
||
const h=new Headers(o.headers||{{}});
|
||
if(!h.has('X-CSRF-Token')) h.set('X-CSRF-Token', CSRF);
|
||
o.headers=h;
|
||
}}
|
||
return _fetch(u,o);
|
||
}};
|
||
const _open=XMLHttpRequest.prototype.open, _send=XMLHttpRequest.prototype.send;
|
||
XMLHttpRequest.prototype.open=function(m,u){{ this._asrMethod=m; return _open.apply(this, arguments); }};
|
||
XMLHttpRequest.prototype.send=function(){{
|
||
if((this._asrMethod||'GET').toUpperCase()!=='GET'){{
|
||
try{{ this.setRequestHeader('X-CSRF-Token', CSRF); }}catch(e){{}}
|
||
}}
|
||
return _send.apply(this, arguments);
|
||
}};
|
||
</script>"""
|
||
return f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<meta name="csrf-token" content="{token}">
|
||
<title>{html.escape(title)} · Meeting ASR</title><style>
|
||
:root{{--bg:#f4f6f8;--card:#fff;--ink:#18212b;--muted:#647180;--line:#d9dfe6;
|
||
--accent:#176b5b;--accent-soft:#e8f3f0;--warn:#986b12;--bad:#b33a2b}}
|
||
@media(prefers-color-scheme:dark){{:root{{--bg:#11161b;--card:#192128;--ink:#eef3f5;
|
||
--muted:#9aa7b4;--line:#2e3a44;--accent:#5bc2a9;--accent-soft:#1d3531;--warn:#e0a84b;--bad:#e06c5f}}}}
|
||
*{{box-sizing:border-box}}body{{margin:0;background:var(--bg);color:var(--ink);
|
||
font:15px/1.58 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}}
|
||
header{{background:var(--card);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:10}}
|
||
header .bar,main{{max-width:1040px;margin:auto;padding:14px 16px}}
|
||
header .bar,.row{{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}}
|
||
a{{color:var(--accent);text-decoration:none;font-weight:600}}a:hover{{text-decoration:underline}}
|
||
nav{{display:flex;gap:18px;flex-wrap:wrap;align-items:center}}
|
||
.card{{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:18px;margin:14px 0}}
|
||
h1{{font-size:21px;margin:0 0 10px}}h2{{font-size:17px;margin:0 0 10px}}h3{{font-size:15px;margin:0 0 8px}}
|
||
.job{{padding:13px 0;border-top:1px solid var(--line)}}.job:first-child{{border:0}}
|
||
.muted{{color:var(--muted);font-size:13.5px}}
|
||
.badge{{display:inline-block;padding:2px 8px;border-radius:99px;font-size:12.5px;font-weight:700;
|
||
background:var(--accent-soft);color:var(--accent)}}
|
||
.badge.failed{{background:rgba(179,58,43,.14);color:var(--bad)}}
|
||
.badge.review{{background:rgba(152,107,18,.15);color:var(--warn)}}
|
||
.tag{{display:inline-block;padding:1px 7px;border:1px solid var(--line);border-radius:6px;
|
||
font-size:12px;color:var(--muted);margin-right:6px}}
|
||
input,textarea,select,button{{font:inherit}}
|
||
input:not([type=checkbox]):not([type=radio]),textarea,select{{width:100%;padding:9px 11px;border:1px solid var(--line);
|
||
border-radius:7px;background:var(--card);color:var(--ink)}}
|
||
textarea{{min-height:84px;resize:vertical}}
|
||
label{{display:block;margin:10px 0 4px;font-weight:650;font-size:13.5px}}
|
||
button{{border:0;border-radius:7px;padding:9px 15px;background:var(--accent);color:#fff;
|
||
font-weight:700;cursor:pointer;font-size:14px}}button:disabled{{opacity:.55;cursor:not-allowed}}
|
||
button.alt{{background:transparent;color:var(--ink);border:1px solid var(--line)}}
|
||
button.danger{{background:var(--bad);color:#fff}}
|
||
button.sm{{padding:5px 10px;font-size:12.5px;border-radius:6px}}
|
||
progress{{width:100%;height:10px;margin-top:8px}}
|
||
pre{{white-space:pre-wrap;overflow-wrap:anywhere;max-height:58vh;overflow:auto;
|
||
background:var(--bg);padding:14px;border-radius:8px;border:1px solid var(--line);
|
||
font:13.5px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace}}
|
||
.timeline{{border-left:2px solid var(--line);padding-left:16px}}
|
||
.choice{{display:inline-block;margin:4px 6px 4px 0;text-align:left}}
|
||
.grid2{{display:grid;grid-template-columns:1fr 1fr;gap:12px}}
|
||
.grid3{{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}}
|
||
.source-box{{margin:10px 0;padding:11px 13px;border-left:4px solid var(--warn);background:var(--bg);border-radius:4px;font-size:14px}}
|
||
.q-item{{padding:14px;border:1px solid var(--line);border-radius:8px;margin:10px 0;background:var(--bg)}}
|
||
.tabs{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;border-bottom:1px solid var(--line);padding-bottom:10px}}
|
||
.tab{{padding:6px 13px;border-radius:7px;border:1px solid var(--line);color:var(--ink);font-weight:650;font-size:14px}}
|
||
.tab.active{{background:var(--accent);color:#fff;border-color:var(--accent)}}
|
||
audio{{width:100%;margin:10px 0}}
|
||
table{{width:100%;border-collapse:collapse;font-size:13.5px;display:block;max-width:100%;overflow-x:auto}}
|
||
th,td{{padding:8px 8px;border-bottom:1px solid var(--line);text-align:left;vertical-align:middle}}
|
||
td input:not([type=checkbox]),td select{{padding:5px 8px;font-size:13px}}
|
||
input,select,textarea{{max-width:100%}}
|
||
nav a{{display:inline-flex;align-items:center;min-height:32px}}
|
||
@media(max-width:680px){{.grid2,.grid3{{grid-template-columns:1fr}}
|
||
nav a,button,button.sm{{min-height:44px}}
|
||
input[type=checkbox]{{width:18px;height:18px}}}}
|
||
</style></head><body><header><div class="bar">
|
||
<strong><a href="/" style="color:inherit;text-decoration:none">ASR 录音工作台</a></strong>
|
||
{nav_html}
|
||
</div></header><main>{body}</main>{guard}{script}</body></html>""".encode("utf-8")
|
||
|
||
|
||
def _provider_options(selected: str = "") -> str:
|
||
sel = providers.normalize_provider_name(selected or CFG.asr_provider)
|
||
opts = []
|
||
for p in providers.provider_status():
|
||
pid = p["id"]
|
||
is_sel = " selected" if pid == sel else ""
|
||
status_note = "" if p["ready"] else " (待配密钥)"
|
||
opts.append(
|
||
f'<option value="{html.escape(pid)}"{is_sel}>'
|
||
f'{html.escape(p["label"])}{status_note}</option>'
|
||
)
|
||
return "".join(opts)
|
||
|
||
|
||
def login_page(err: str = "") -> bytes:
|
||
if not webauth.password() and not webauth.allow_anon():
|
||
err = err or "未配置访问口令。请设置环境变量 ASR_WEB_PASSWORD 后重启。仅本机调试可另设 ASR_WEB_ALLOW_ANON=1。"
|
||
alert = f'<p style="color:var(--bad);font-weight:700">{html.escape(err)}</p>' if err else ""
|
||
body = f"""<section class="card" style="max-width:420px;margin:40px auto">
|
||
<h1>访问验证</h1><p class="muted">请输入工作台访问口令。口令来自环境变量 ASR_WEB_PASSWORD。</p>{alert}
|
||
<form method="post" action="/login">
|
||
<label for="pw">访问口令</label>
|
||
<input id="pw" name="password" type="password" autocomplete="current-password" required autofocus>
|
||
<p style="margin-top:14px"><button type="submit" style="width:100%">登录</button></p>
|
||
</form></section>"""
|
||
return shell("登录", body, nav=False)
|
||
|
||
|
||
def home(msg: str = "") -> bytes:
|
||
options = _provider_options(CFG.asr_provider)
|
||
notice = f'<div class="source-box">{html.escape(msg)}</div>' if msg else ""
|
||
body = f"""{notice}<section class="card"><h1>上传会议 / 通话录音</h1>
|
||
<p class="muted">支持 m4a、mp3、wav、mp4、aac、ogg、opus、webm、flac。单文件上限以服务端 ASR_UPLOAD_MAX_BYTES 为准(默认 4 GiB)。默认引擎可在模型配置中修改。</p>
|
||
<form id="up">
|
||
<div class="grid2">
|
||
<div><label for="file">录音文件</label><input id="file" type="file" accept="audio/*,video/mp4" required></div>
|
||
<div><label for="provider">语音识别模型 (ASR Provider)</label><select id="provider">{options}</select></div>
|
||
</div>
|
||
<div class="grid2">
|
||
<div><label for="title">标题(可选)</label><input id="title" maxlength="80" placeholder="例如:洛启生物尽调通话"></div>
|
||
<div><label for="model">指定子型号(可选,留空用默认)</label><input id="model" maxlength="80" placeholder="留空自动使用所选通道默认型号"></div>
|
||
</div>
|
||
<label for="context">已知背景(可选)</label>
|
||
<textarea id="context" maxlength="2000" placeholder="参与人、公司、项目或重点关注问题。将单独标记为用户提供背景,不会混入转写原文,并自动用于 Soniox/OpenAI 热词增强。"></textarea>
|
||
<p style="margin-top:14px"><button id="send" type="submit">上传并开始处理</button></p>
|
||
<progress id="bar" max="100" value="0" hidden></progress>
|
||
<div id="msg" class="muted"></div></form></section>
|
||
|
||
<section class="card">
|
||
<div class="row">
|
||
<h2>任务列表</h2>
|
||
<div class="row" style="gap:8px">
|
||
<input id="q" placeholder="搜索标题 / 文件名 / 背景…" style="width:210px;padding:6px 10px">
|
||
<select id="filter" style="width:auto;padding:6px 10px">
|
||
<option value="">全部状态</option><option value="review">等待确认</option>
|
||
<option value="queued">排队中</option><option value="transcribing">正在转写</option>
|
||
<option value="refining">正在整理</option><option value="complete">已完成</option>
|
||
<option value="failed">处理失败</option>
|
||
</select>
|
||
<select id="pfilter" style="width:auto;padding:6px 10px">
|
||
<option value="">全部引擎</option><option value="qwen3cam">qwen3cam</option>
|
||
<option value="soniox">soniox</option><option value="gemini">gemini</option>
|
||
<option value="openai">openai</option><option value="grok">grok</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<form method="post" action="/jobs/batch-delete" id="batchForm" onsubmit="return confirm('确认删除所选任务及其关联音频/转写/草稿?')">
|
||
<div class="row" style="margin:10px 0;padding-bottom:8px;border-bottom:1px solid var(--line)">
|
||
<div class="row" style="gap:10px">
|
||
<label style="margin:0;font-weight:500"><input type="checkbox" id="selAll"> 全选</label>
|
||
<input type="hidden" name="delete_files" value="1">
|
||
<button type="submit" class="sm danger" id="batchDelBtn" disabled>批量删除选中</button>
|
||
</div>
|
||
<div class="row" style="gap:8px">
|
||
<button type="submit" name="cleanup_failed" value="1" class="sm alt" onclick="return confirm('确认清理全部失败任务?')">清理所有失败任务</button>
|
||
</div>
|
||
</div>
|
||
<div id="jobs">读取中…</div>
|
||
</form>
|
||
</section>"""
|
||
script = """<script>
|
||
const E=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const flt=document.querySelector('#filter'), pflt=document.querySelector('#pfilter'), qIn=document.querySelector('#q');
|
||
const selAll=document.querySelector('#selAll'), batchBtn=document.querySelector('#batchDelBtn');
|
||
function syncBatchBtn(){
|
||
const checked=document.querySelectorAll('.job-chk:checked').length;
|
||
batchBtn.disabled=checked===0;
|
||
batchBtn.textContent=checked?`批量删除选中 (${checked})`:'批量删除选中';
|
||
}
|
||
selAll.onchange=()=>{
|
||
document.querySelectorAll('.job-chk').forEach(c=>{c.checked=selAll.checked});
|
||
syncBatchBtn();
|
||
};
|
||
async function delOne(id,name){
|
||
if(!confirm(`确认删除任务「${name}」及其音频、转写和草稿文件?`))return;
|
||
const fd=new URLSearchParams({delete_files:'1'});
|
||
await fetch(`/jobs/${id}/delete`,{method:'POST',body:fd});
|
||
load();
|
||
}
|
||
async function load(){
|
||
const params=new URLSearchParams();
|
||
if(flt.value)params.set('status',flt.value);
|
||
if(pflt.value)params.set('provider',pflt.value);
|
||
if(qIn.value.trim())params.set('q',qIn.value.trim());
|
||
const r=await fetch('/ui/jobs'+(params.toString()?'?'+params.toString():''),{cache:'no-store'});
|
||
const d=await r.json();
|
||
const box=document.querySelector('#jobs');
|
||
const prevChecked=new Set(Array.from(document.querySelectorAll('.job-chk:checked')).map(c=>c.value));
|
||
box.innerHTML=d.jobs.length?d.jobs.map(j=>`<div class="job"><div class="row">
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||
<input type="checkbox" class="job-chk" name="job_id" value="${E(j.id)}" ${prevChecked.has(j.id)?'checked':''} onchange="syncBatchBtn()">
|
||
<strong><a href="/jobs/${E(j.id)}/">${E(j.title||j.original_name)}</a></strong>
|
||
<span class="tag">${E(j.asr_provider||'qwen3cam')}${j.asr_model?':'+E(j.asr_model):''}</span>
|
||
${j.pending_reviews?`<a href="/jobs/${E(j.id)}/review/" class="badge review">待确认 ${j.pending_reviews} 项</a>`:''}
|
||
</div>
|
||
<div class="row" style="gap:8px">
|
||
<span class="badge ${E(j.status)}">${E(j.status_label)}</span>
|
||
<button type="button" class="sm alt js-del" style="color:var(--bad)" data-del="${E(j.id)}" data-name="${E(j.title||j.original_name)}">删除</button>
|
||
</div></div>
|
||
<div class="muted" style="margin-left:24px">${new Date(j.created_at*1000).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false})} · ${E(j.original_name)} (${(j.size/1048576).toFixed(1)} MB) · ${E(j.detail||'')}</div>
|
||
<div style="margin-top:6px;margin-left:24px">
|
||
<a href="/jobs/${E(j.id)}/">任务详情 / 试听与编辑</a>
|
||
${j.pending_reviews?` · <a href="/jobs/${E(j.id)}/review/">前往人工确认 (${j.pending_reviews}) →</a>`:''}
|
||
${j.artifacts&&j.artifacts.result?` · <a href="/jobs/${E(j.id)}/artifact/result">下载正式纪要</a>`:''}
|
||
</div>
|
||
</div>`).join(''):'<p class="muted">暂无匹配任务</p>';
|
||
box.querySelectorAll('.js-del').forEach(b=>b.addEventListener('click',()=>delOne(b.getAttribute('data-del'), b.getAttribute('data-name')||'')));
|
||
syncBatchBtn();
|
||
}
|
||
flt.onchange=load; pflt.onchange=load; qIn.oninput=()=>{clearTimeout(window._qt);window._qt=setTimeout(load,300)};
|
||
load(); setInterval(()=>{if(!document.querySelector('.job-chk:checked'))load()},8000);
|
||
document.querySelector('#up').onsubmit=x=>{
|
||
x.preventDefault();
|
||
const f=document.querySelector('#file').files[0]; if(!f)return;
|
||
if(!/\\.(m4a|mp3|wav|mp4|aac|ogg|opus|webm|flac)$/i.test(f.name)){
|
||
document.querySelector('#msg').textContent='不支持的文件类型,请选择音频或 mp4。'; return;
|
||
}
|
||
const q=new XMLHttpRequest(), btn=document.querySelector('#send'), bar=document.querySelector('#bar'), msg=document.querySelector('#msg');
|
||
btn.disabled=true; bar.hidden=false; msg.textContent='正在上传…';
|
||
q.open('POST','/ui/upload');
|
||
q.setRequestHeader('X-Filename',encodeURIComponent(f.name));
|
||
q.setRequestHeader('X-Title',encodeURIComponent(document.querySelector('#title').value));
|
||
q.setRequestHeader('X-Context',encodeURIComponent(document.querySelector('#context').value));
|
||
q.setRequestHeader('X-ASR-Provider',encodeURIComponent(document.querySelector('#provider').value));
|
||
q.setRequestHeader('X-ASR-Model',encodeURIComponent(document.querySelector('#model').value));
|
||
q.upload.onprogress=v=>{if(v.lengthComputable)bar.value=Math.round(v.loaded/v.total*100)};
|
||
q.onload=()=>{
|
||
btn.disabled=false; bar.hidden=true;
|
||
if(q.status===201){msg.textContent='上传成功,已进入处理队列。';x.target.reset();load();}
|
||
else{
|
||
let t=q.responseText||('HTTP '+q.status);
|
||
try{const j=JSON.parse(q.responseText); const map={{'unsupported type':'不支持的文件类型','invalid size':'文件大小不合法','unauthorized':'需要登录','csrf':'页面已过期,请刷新后再试','incomplete upload':'上传未完成'}}; t=map[j.error]||j.error||t;}catch(e){{}}
|
||
msg.textContent='上传失败:'+t;
|
||
}
|
||
};
|
||
q.onerror=()=>{btn.disabled=false;msg.textContent='网络中断,请重试';};
|
||
q.send(f);
|
||
};
|
||
</script>"""
|
||
return shell("任务工作台", body, script)
|
||
|
||
|
||
def _read_artifact_text(job: dict, name: str) -> str:
|
||
roots = {"transcript": TRANSCRIPTS, "draft": DRAFTS, "result": RESULTS}
|
||
raw_path = str(job.get(name) or "")
|
||
if not raw_path:
|
||
return ""
|
||
path = Path(raw_path)
|
||
allowed_roots = [roots[name].resolve(), CFG.minutes.resolve(), CFG.drafts.resolve()]
|
||
for root in allowed_roots:
|
||
try:
|
||
path.resolve().relative_to(root)
|
||
if path.is_file():
|
||
return path.read_text(encoding="utf-8", errors="ignore")
|
||
except (OSError, ValueError):
|
||
continue
|
||
return ""
|
||
|
||
|
||
def job_page(job: dict, msg: str = "") -> bytes:
|
||
jid = job["id"]
|
||
notice = f'<div class="source-box">{html.escape(msg)}</div>' if msg else ""
|
||
events_html = "".join(
|
||
f"<p><strong>{html.escape(LABELS.get(e['status'], e['status']))}</strong> "
|
||
f"<span class='muted'>{fmt_ts(e['created_at'])}</span>"
|
||
f"<br><span class='muted'>{html.escape(e['detail'])}</span></p>"
|
||
for e in db.events(jid)
|
||
)
|
||
links = " · ".join(
|
||
f'<a href="/jobs/{jid}/artifact/{n}">下载{label} (.md/.txt)</a>'
|
||
for n, label in (("result", "正式纪要"), ("draft", "精修草稿"), ("transcript", "原始转写"))
|
||
if job.get(n)
|
||
)
|
||
audio_html = (
|
||
f'<audio controls preload="metadata" src="/jobs/{jid}/audio"></audio>'
|
||
if find_audio_file(job)
|
||
else '<p class="muted">原音频文件不在活动目录</p>'
|
||
)
|
||
pend = pending(job)
|
||
review_banner = (
|
||
f'<div class="source-box"><strong>当前有 {len(pend)} 个问题待你确认</strong> · '
|
||
f'<a href="/jobs/{jid}/review/">立即前往确认页面 →</a></div>'
|
||
if pend else ""
|
||
)
|
||
options = _provider_options(job.get("asr_provider") or CFG.asr_provider)
|
||
has_transcript = bool(job.get("transcript") and Path(str(job["transcript"])).is_file())
|
||
rerefine_btn = (
|
||
f'<form method="post" action="/jobs/{jid}/rerefine" style="display:inline">'
|
||
f'<button type="submit" class="alt sm">基于已有转写重新生成草稿 (免重跑 ASR)</button></form>'
|
||
if has_transcript else ""
|
||
)
|
||
|
||
meta_form = f"""<details style="margin-top:12px"><summary style="cursor:pointer;font-weight:650">
|
||
编辑任务信息 / 补充背景 / 切换 ASR 模型重跑</summary>
|
||
<form method="post" action="/jobs/{jid}/meta" style="margin-top:10px">
|
||
<div class="grid2">
|
||
<div><label>任务标题</label><input name="title" maxlength="80" value="{html.escape(str(job.get('title') or ''))}"></div>
|
||
<div><label>语音识别引擎 (ASR Provider)</label><select name="provider">{options}</select></div>
|
||
</div>
|
||
<label>指定 ASR 子型号(可选)</label>
|
||
<input name="model" maxlength="80" value="{html.escape(str(job.get('asr_model') or ''))}" placeholder="留空使用默认型号">
|
||
<label>已知背景说明(修改后可点「保存并重新生成草稿」)</label>
|
||
<textarea name="context" maxlength="2000">{html.escape(str(job.get('context') or ''))}</textarea>
|
||
<div class="row" style="margin-top:10px;justify-content:flex-start;gap:10px">
|
||
<button type="submit" name="next_action" value="save" class="sm">仅保存修改</button>
|
||
{'<button type="submit" name="next_action" value="rerefine" class="sm alt">保存并重新生成草稿 (免重跑 ASR)</button>' if has_transcript else ''}
|
||
{'<button type="submit" name="next_action" value="retry" class="sm alt">保存并重新转写音频 (重跑 ASR,会再次占用识别服务)</button>' if job.get("status") not in BUSY_STATUSES else '<span class="muted">正在处理,不能同时重新转写</span>'}
|
||
</div>
|
||
</form></details>"""
|
||
|
||
delete_form = f"""<details style="margin-top:10px"><summary style="cursor:pointer;font-weight:650;color:var(--bad)">
|
||
删除此任务与产物…</summary>
|
||
<form method="post" action="/jobs/{jid}/delete" onsubmit="return confirm('确认永久删除该任务?')" style="margin-top:8px;padding:12px;border:1px solid var(--bad);border-radius:8px">
|
||
<label style="margin:4px 0;font-weight:500"><input type="checkbox" name="delete_files" value="1" checked> 同时删除原音频、原始转写与私有草稿文件</label>
|
||
<label style="margin:4px 0;font-weight:500"><input type="checkbox" name="delete_result" value="1"> 同时删除已写入 Obsidian 的正式纪要文件(慎选)</label>
|
||
<p style="margin-top:10px"><button type="submit" class="danger sm">确认永久删除此任务</button></p>
|
||
</form></details>"""
|
||
|
||
sections = []
|
||
for key, label in (("result", "正式纪要"), ("draft", "精修草稿"), ("transcript", "原始转写")):
|
||
content = _read_artifact_text(job, key)
|
||
if not content:
|
||
continue
|
||
open_attr = " open" if key in ("result", "draft") else ""
|
||
edit_form = ""
|
||
if key in ("draft", "result"):
|
||
edit_form = f"""<details style="margin-top:8px"><summary class="muted" style="cursor:pointer">在线编辑 {label} Markdown</summary>
|
||
<form method="post" action="/jobs/{jid}/edit">
|
||
<input type="hidden" name="target" value="{key}">
|
||
<textarea name="content" style="min-height:260px;font-family:monospace">{html.escape(content)}</textarea>
|
||
<div class="row" style="margin-top:8px;justify-content:flex-start;gap:10px">
|
||
<button type="submit" class="sm">保存 Markdown 修改</button>
|
||
</div></form></details>"""
|
||
sections.append(
|
||
f'<section class="card"><details{open_attr}><summary style="font-weight:700;font-size:16px;cursor:pointer">'
|
||
f'{label}</summary><pre style="margin-top:12px">{html.escape(content)}</pre>{edit_form}</details></section>'
|
||
)
|
||
|
||
prov_tag = f"{job.get('asr_provider') or CFG.asr_provider}"
|
||
if job.get("asr_model"):
|
||
prov_tag += f":{job['asr_model']}"
|
||
ctx_html = (
|
||
f'<p class="muted"><strong>已知背景:</strong>{html.escape(str(job["context"]))}</p>'
|
||
if job.get("context") else ""
|
||
)
|
||
body = f"""{notice}<section class="card"><div class="row">
|
||
<div><h1>{html.escape(job.get('title') or job['original_name'])}</h1>
|
||
<span class="tag">ASR: {html.escape(prov_tag)}</span>
|
||
<span class="tag">文件: {html.escape(job['original_name'])}</span>
|
||
<span class="tag">尝试: {int(job.get('attempts') or 0)} 次</span></div>
|
||
<span class="badge {html.escape(job['status'])}">{html.escape(LABELS.get(job['status'], job['status']))}</span>
|
||
</div>
|
||
<p>{html.escape(job.get('detail', ''))}</p>
|
||
{ctx_html}
|
||
{review_banner}
|
||
{audio_html}
|
||
<div class="row" style="margin-top:8px;justify-content:space-between">
|
||
<div>{links}</div>
|
||
<div>{rerefine_btn}</div>
|
||
</div>
|
||
{meta_form}
|
||
{delete_form}
|
||
</section>
|
||
{''.join(sections)}
|
||
<section class="card"><h2>处理时间线</h2><div class="timeline">{events_html}</div></section>"""
|
||
return shell("任务详情", body)
|
||
|
||
|
||
def _render_question_form(row: dict, action_url: str) -> str:
|
||
"""One form per question so Enter submits this answer, not another question."""
|
||
qid = int(row["id"])
|
||
payload = json.loads(row.get("payload") or "{}")
|
||
kind = str(payload.get("kind") or "")
|
||
kind_label = KIND_LABELS.get(kind, kind or "待确认")
|
||
choices = payload.get("candidates") or []
|
||
sentence = str(payload.get("sentence") or "")
|
||
placeholder = (
|
||
"输入真实姓名,如:高瑶"
|
||
if kind == "voiceprint"
|
||
else ("若背景有误,请填写正确背景说明"
|
||
if kind == "background_check"
|
||
else "输入正确名称或补充修改说明")
|
||
)
|
||
quick_btns = "".join(
|
||
f'<button class="choice sm" type="submit" name="single_submit" '
|
||
f'value="{qid}::{html.escape(str(v), quote=True)}">{html.escape(str(v))}</button>'
|
||
for v in choices
|
||
)
|
||
quick_btns += (
|
||
f'<button class="choice sm alt" type="submit" name="single_submit" '
|
||
f'value="{qid}::无法确认,保留原文">跳过 / 保留原文</button>'
|
||
)
|
||
sentence_html = f'<div class="source-box">{html.escape(sentence)}</div>' if sentence else ""
|
||
return f"""<form class="q-item" method="post" action="{html.escape(action_url)}">
|
||
<button type="submit" name="single_custom" value="{qid}" hidden>提交第 {qid} 题</button>
|
||
<div class="row">
|
||
<div><span class="tag">{html.escape(kind_label)}</span> <strong>#{qid} · {html.escape(row['question'])}</strong></div>
|
||
</div>
|
||
{sentence_html}
|
||
<div style="margin:8px 0">{quick_btns}</div>
|
||
<div class="row" style="flex-wrap:nowrap;gap:6px;margin-top:8px">
|
||
<label class="muted" for="custom_{qid}" style="margin:0">本题回答</label>
|
||
<input id="custom_{qid}" name="custom_{qid}" maxlength="200" autocomplete="off" aria-label="第 {qid} 题的自定义回答" placeholder="{html.escape(placeholder, quote=True)}">
|
||
<button type="submit" name="single_custom" value="{qid}" class="sm alt" style="white-space:nowrap">提交本题</button>
|
||
</div>
|
||
</form>"""
|
||
|
||
|
||
def _render_batch_select(row: dict) -> str:
|
||
qid = int(row["id"])
|
||
payload = json.loads(row.get("payload") or "{}")
|
||
choices = payload.get("candidates") or []
|
||
select_opts = '<option value="">-- 批量:选择预设 --</option>' + "".join(
|
||
f'<option value="{html.escape(str(v), quote=True)}">{html.escape(str(v))}</option>'
|
||
for v in [*choices, "无法确认,保留原文"]
|
||
)
|
||
return (
|
||
f'<label class="muted" for="preset_{qid}">#{qid}</label>'
|
||
f'<select id="preset_{qid}" name="preset_{qid}" aria-label="第 {qid} 题批量预设">{select_opts}</select>'
|
||
)
|
||
|
||
|
||
def review_page(focus_job: dict | None = None, msg: str = "") -> bytes:
|
||
groups = _group_pending_by_job()
|
||
notice = f'<div class="source-box">{html.escape(msg)}</div>' if msg else ""
|
||
|
||
# Top switcher bar across all recordings that have pending questions
|
||
switcher_items = []
|
||
total_q = sum(len(qs) for _, qs in groups)
|
||
all_active = " active" if not focus_job else ""
|
||
switcher_items.append(
|
||
f'<a class="tab{all_active}" href="/review/">全部待确认录音 ({len(groups)} 场 / {total_q} 题)</a>'
|
||
)
|
||
for j, qs in groups:
|
||
if j:
|
||
is_act = " active" if (focus_job and focus_job["id"] == j["id"]) else ""
|
||
title_lbl = j.get("title") or j["original_name"]
|
||
switcher_items.append(
|
||
f'<a class="tab{is_act}" href="/jobs/{html.escape(j["id"])}/review/">'
|
||
f'{html.escape(title_lbl)} ({len(qs)})</a>'
|
||
)
|
||
|
||
switcher_html = (
|
||
f'<div class="tabs">{"".join(switcher_items)}</div>'
|
||
if groups else ""
|
||
)
|
||
|
||
# Filter groups if user focused on one specific recording job
|
||
display_groups = (
|
||
[(j, qs) for j, qs in groups if j and j["id"] == focus_job["id"]]
|
||
if focus_job else groups
|
||
)
|
||
if focus_job and not display_groups:
|
||
rem = pending(focus_job)
|
||
if rem:
|
||
display_groups = [(focus_job, rem)]
|
||
|
||
group_cards = []
|
||
for j, rows in display_groups:
|
||
action_url = f"/jobs/{j['id']}/review/" if (focus_job and j) else "/review/"
|
||
if j:
|
||
jid = j["id"]
|
||
title_str = j.get("title") or j["original_name"]
|
||
prov_str = j.get("asr_provider") or CFG.asr_provider
|
||
audio_bar = (
|
||
f'<audio controls preload="metadata" src="/jobs/{jid}/audio"></audio>'
|
||
if find_audio_file(j) else ""
|
||
)
|
||
draft_text = _read_artifact_text(j, "draft")
|
||
draft_box = (
|
||
f'<details style="margin:8px 0"><summary style="cursor:pointer;font-weight:650">'
|
||
f'展开对照本场完整精修草稿</summary><pre style="margin-top:8px">{html.escape(draft_text)}</pre></details>'
|
||
if draft_text else ""
|
||
)
|
||
hdr_html = f"""<div class="row">
|
||
<div><h2><a href="/jobs/{html.escape(jid)}/">{html.escape(title_str)}</a></h2>
|
||
<span class="tag">ASR: {html.escape(prov_str)}</span>
|
||
<span class="muted">{fmt_ts(j['created_at'], with_seconds=False)} · 待确认 {len(rows)} 题</span></div>
|
||
<div class="row" style="gap:8px">
|
||
<a href="/jobs/{html.escape(jid)}/" class="tab">查看任务详情</a>
|
||
</div></div>{audio_bar}{draft_box}"""
|
||
else:
|
||
hdr_html = f"""<div class="row"><h2>未关联任务的历史待确认项 ({len(rows)} 题)</h2></div>"""
|
||
|
||
q_forms = "".join(_render_question_form(r, action_url) for r in rows)
|
||
batch_selects = "".join(_render_batch_select(r) for r in rows)
|
||
q_ids_csv = ",".join(str(r["id"]) for r in rows)
|
||
group_cards.append(f"""<section class="card">
|
||
{hdr_html}
|
||
{q_forms}
|
||
<form method="post" action="{html.escape(action_url)}">
|
||
<input type="hidden" name="question_ids" value="{q_ids_csv}">
|
||
<div class="grid2" style="margin-top:8px">{batch_selects}</div>
|
||
<div class="row" style="margin-top:12px;padding-top:10px;border-top:1px solid var(--line)">
|
||
<span class="muted">每一题有自己的输入框,回车只提交该题。下面的下拉框用于一次提交多题。</span>
|
||
<div class="row" style="gap:8px">
|
||
<button type="submit" name="batch_submit" value="1">批量提交本录音已选预设</button>
|
||
<button type="submit" name="batch_skip_all" value="1" class="alt sm" onclick="return confirm('确认将本场录音剩余问题全部标记为保留原文?')">本场全部保留原文</button>
|
||
</div>
|
||
</div>
|
||
</form></section>""")
|
||
|
||
if not group_cards:
|
||
back_url = f"/jobs/{focus_job['id']}/" if focus_job else "/"
|
||
revise_btn = ""
|
||
if focus_job and focus_job.get("draft"):
|
||
revise_btn = (
|
||
f'<form method="post" action="/jobs/{html.escape(focus_job["id"])}/revise" style="margin-top:12px">'
|
||
f'<button type="submit">立即应用已确认结果并触发修订定稿</button></form>'
|
||
)
|
||
group_cards.append(
|
||
f'<section class="card"><h1>当前没有待确认问题</h1>'
|
||
f'<p class="muted">所有已入队的确认项均已处理完毕。</p>{revise_btn}'
|
||
f'<p style="margin-top:12px"><a href="{html.escape(back_url)}">← 返回任务工作台</a></p></section>'
|
||
)
|
||
|
||
# Recently answered history with ability to reopen or modify answer
|
||
answered = review.recent_answered(25)
|
||
ans_rows = []
|
||
for r in answered:
|
||
qid = int(r["id"])
|
||
rec_stem = Path(str(r.get("recording") or "")).stem
|
||
ans_rows.append(
|
||
f'<tr><td>#{qid}</td><td><span class="muted">{html.escape(rec_stem[:16])}</span><br>'
|
||
f'{html.escape(str(r["question"])[:70])}</td>'
|
||
f'<td><form method="post" action="/review/update-answer" class="row" style="flex-wrap:nowrap;gap:6px">'
|
||
f'<input type="hidden" name="id" value="{qid}">'
|
||
f'<input name="answer" value="{html.escape(str(r.get("answer") or ""), quote=True)}" style="min-width:140px">'
|
||
f'<button type="submit" class="sm alt" onclick="return confirm(\'修改回答会写入确认记录。若这是说话人身份,会再次向声纹服务登记。\')">改答</button>'
|
||
f'<button type="submit" name="reopen" value="1" class="sm alt" onclick="return confirm(\'撤回后该题回到待确认,已学习的别名不会自动删除。\')">撤回重答</button>'
|
||
f'</form></td></tr>'
|
||
)
|
||
history_html = (
|
||
f'<section class="card"><details><summary style="cursor:pointer;font-weight:700">'
|
||
f'近期已确认记录(最近 {len(ans_rows)} 条,支持修改或撤回重答)</summary>'
|
||
f'<table style="margin-top:10px"><thead><tr><th>ID</th><th>问题</th><th>已确认回答 / 操作</th></tr></thead>'
|
||
f'<tbody>{"".join(ans_rows)}</tbody></table></details></section>'
|
||
if ans_rows else ""
|
||
)
|
||
|
||
body = f"""{notice}<section class="card">
|
||
<div class="row"><h1>多录音人工确认中心</h1>
|
||
<span class="muted">支持按录音分组切换、单题快捷点击与单场录音批量提交</span></div>
|
||
{switcher_html}
|
||
</section>
|
||
{''.join(group_cards)}
|
||
{history_html}"""
|
||
return shell("人工确认中心", body)
|
||
|
||
|
||
def _lexicon_pager(tab: str, q: str, page: int, total: int) -> str:
|
||
pages = max(1, (total + LEXICON_PAGE_SIZE - 1) // LEXICON_PAGE_SIZE)
|
||
page = min(max(page, 1), pages)
|
||
qv = urllib.parse.quote(q)
|
||
|
||
def href(n: int) -> str:
|
||
return f"/lexicon?tab={tab}&q={qv}&page={n}"
|
||
|
||
prev = f'<a class="tab" href="{href(page - 1)}">上一页</a>' if page > 1 else ""
|
||
nxt = f'<a class="tab" href="{href(page + 1)}">下一页</a>' if page < pages else ""
|
||
return (
|
||
f'<p class="muted">共 {total} 条,第 {page}/{pages} 页,每页 {LEXICON_PAGE_SIZE} 条。 {prev} {nxt}</p>'
|
||
)
|
||
|
||
|
||
def lexicon_page(tab: str = "aliases", q: str = "", msg: str = "", page: int = 1) -> bytes:
|
||
tab = tab if tab in ("aliases", "corrections", "entities", "relations") else "aliases"
|
||
page = max(1, page)
|
||
offset = (page - 1) * LEXICON_PAGE_SIZE
|
||
try:
|
||
st = lexicon.stats()
|
||
except Exception:
|
||
st = {"entities": 0, "with_company": 0, "with_related": 0,
|
||
"aliases": 0, "aliases_card": 0, "aliases_learned": 0, "corrections": 0}
|
||
try:
|
||
gst = graph.stats()
|
||
except Exception:
|
||
gst = {"relations": 0, "interactions": 0, "documents": 0}
|
||
|
||
notice = f'<div class="source-box">{html.escape(msg)}</div>' if msg else ""
|
||
tabs_html = "".join(
|
||
f'<a class="tab{" active" if tab == t else ""}" href="/lexicon?tab={t}&q={urllib.parse.quote(q)}">{lbl}</a>'
|
||
for t, lbl in (
|
||
("aliases", f"别名映射 ({st.get('aliases', 0)})"),
|
||
("corrections", f"发音纠错 ({st.get('corrections', 0)})"),
|
||
("entities", f"实体卡片 ({st.get('entities', 0)})"),
|
||
("relations", f"人物/机构关系 ({gst.get('relations', 0)})"),
|
||
)
|
||
)
|
||
|
||
content_html = ""
|
||
if tab == "aliases":
|
||
total = lexicon.count_aliases(q=q)
|
||
rows = lexicon.list_aliases(q=q, limit=LEXICON_PAGE_SIZE, offset=offset)
|
||
pager = _lexicon_pager(tab, q, page, total)
|
||
tr_list = []
|
||
for r in rows:
|
||
al = str(r["alias"])
|
||
can = str(r["canonical"])
|
||
knd = str(r.get("kind") or "人名")
|
||
org = str(r.get("origin") or "manual")
|
||
conf = int(r.get("confirmed") or 0)
|
||
nt = str(r.get("note") or "")
|
||
tr_list.append(f"""<tr>
|
||
<form method="post" action="/lexicon/alias/update">
|
||
<td><input type="hidden" name="old_alias" value="{html.escape(al, quote=True)}">
|
||
<input type="hidden" name="old_canonical" value="{html.escape(can, quote=True)}">
|
||
<input name="alias" value="{html.escape(al, quote=True)}" required maxlength="40"></td>
|
||
<td><input name="canonical" value="{html.escape(can, quote=True)}" required maxlength="40"></td>
|
||
<td><select name="kind">
|
||
<option value="人名"{' selected' if knd == '人名' else ''}>人名</option>
|
||
<option value="公司"{' selected' if knd == '公司' else ''}>公司</option>
|
||
<option value="项目"{' selected' if knd == '项目' else ''}>项目</option>
|
||
<option value="术语"{' selected' if knd == '术语' else ''}>术语</option>
|
||
</select></td>
|
||
<td><span class="tag">{html.escape(org)}</span></td>
|
||
<td><label style="margin:0"><input type="checkbox" name="confirmed" value="1"{' checked' if conf else ''}> 已确认</label></td>
|
||
<td><input name="note" value="{html.escape(nt, quote=True)}" maxlength="120" placeholder="备注"></td>
|
||
<td style="white-space:nowrap">
|
||
<button type="submit" class="sm">保存</button>
|
||
<button type="submit" formaction="/lexicon/alias/delete" class="sm danger" onclick="return confirm('确认删除该别名映射?')">删除</button>
|
||
</td>
|
||
</form></tr>""")
|
||
content_html = f"""<section class="card"><h2>新增别名映射 (同时写入发音纠错)</h2>
|
||
<form method="post" action="/lexicon/learn">
|
||
<div class="grid3">
|
||
<div><label>口语简称 / 误识别词</label><input name="alias" required maxlength="40" placeholder="例如:老万 / 张泰海"></div>
|
||
<div><label>对应规范全名</label><input name="canonical" required maxlength="40" placeholder="例如:万亚坤 / 张成海"></div>
|
||
<div><label>类型</label><select name="kind"><option value="人名">人名</option><option value="公司">公司</option><option value="项目">项目</option><option value="术语">术语</option></select></div>
|
||
</div>
|
||
<label>备注(可选)</label><input name="note" maxlength="120" placeholder="例如:洛启生物实控人">
|
||
<p style="margin-top:10px"><button type="submit" class="sm">新增并保存</button></p>
|
||
</form></section>
|
||
<section class="card"><h2>别名映射列表</h2>{pager}
|
||
<table><thead><tr><th>简称 / 别名</th><th>规范全名</th><th>类型</th><th>来源</th><th>确认</th><th>备注</th><th>操作</th></tr></thead>
|
||
<tbody>{''.join(tr_list) or '<tr><td colspan="7" class="muted">暂无数据</td></tr>'}</tbody></table></section>"""
|
||
|
||
elif tab == "corrections":
|
||
total = lexicon.count_corrections(q=q)
|
||
rows = lexicon.list_corrections(q=q, limit=LEXICON_PAGE_SIZE, offset=offset)
|
||
pager = _lexicon_pager(tab, q, page, total)
|
||
tr_list = []
|
||
for r in rows:
|
||
sf = str(r["surface"])
|
||
can = str(r["canonical"])
|
||
cnt = int(r.get("n") or 1)
|
||
conf = int(r.get("confirmed") or 0)
|
||
nt = str(r.get("note") or "")
|
||
tr_list.append(f"""<tr>
|
||
<form method="post" action="/lexicon/correction/update">
|
||
<td><input type="hidden" name="old_surface" value="{html.escape(sf, quote=True)}">
|
||
<input type="hidden" name="old_canonical" value="{html.escape(can, quote=True)}">
|
||
<input name="surface" value="{html.escape(sf, quote=True)}" required maxlength="40"></td>
|
||
<td><input name="canonical" value="{html.escape(can, quote=True)}" required maxlength="40"></td>
|
||
<td>{cnt} 次</td>
|
||
<td><label style="margin:0"><input type="checkbox" name="confirmed" value="1"{' checked' if conf else ''}> 已确认</label></td>
|
||
<td><input name="note" value="{html.escape(nt, quote=True)}" maxlength="120" placeholder="备注"></td>
|
||
<td style="white-space:nowrap">
|
||
<button type="submit" class="sm">保存</button>
|
||
<button type="submit" formaction="/lexicon/correction/delete" class="sm danger" onclick="return confirm('确认删除该纠错条目?')">删除</button>
|
||
</td>
|
||
</form></tr>""")
|
||
content_html = f"""<section class="card"><h2>新增同音 / ASR 误识纠错</h2>
|
||
<form method="post" action="/lexicon/learn">
|
||
<input type="hidden" name="tab" value="corrections">
|
||
<div class="grid2">
|
||
<div><label>ASR 误识别词 (Surface)</label><input name="alias" required maxlength="40" placeholder="例如:张泰海"></div>
|
||
<div><label>正确规范词 (Canonical)</label><input name="canonical" required maxlength="40" placeholder="例如:张成海"></div>
|
||
</div>
|
||
<label>备注(可选)</label><input name="note" maxlength="120" placeholder="例如:发音相近纠错">
|
||
<p style="margin-top:10px"><button type="submit" class="sm">新增纠错</button></p>
|
||
</form></section>
|
||
<section class="card"><h2>发音纠错列表</h2>{pager}
|
||
<table><thead><tr><th>误识别词</th><th>正确规范词</th><th>频次</th><th>确认</th><th>备注</th><th>操作</th></tr></thead>
|
||
<tbody>{''.join(tr_list) or '<tr><td colspan="6" class="muted">暂无数据</td></tr>'}</tbody></table></section>"""
|
||
|
||
elif tab == "entities":
|
||
total = lexicon.count_entities(q=q)
|
||
rows = lexicon.list_entities(q=q, limit=LEXICON_PAGE_SIZE, offset=offset)
|
||
pager = _lexicon_pager(tab, q, page, total)
|
||
tr_list = []
|
||
for r in rows:
|
||
can = str(r["canonical"])
|
||
knd = str(r.get("kind") or "人名")
|
||
comp = str(r.get("company") or "")
|
||
role = str(r.get("role") or "")
|
||
rel = str(r.get("related") or "")
|
||
src_tag = "Obsidian卡" if r.get("card") else "手动维护"
|
||
tr_list.append(f"""<tr>
|
||
<form method="post" action="/lexicon/entity/update">
|
||
<td><input type="hidden" name="old_canonical" value="{html.escape(can, quote=True)}">
|
||
<input name="canonical" value="{html.escape(can, quote=True)}" required maxlength="60"></td>
|
||
<td><select name="kind">
|
||
<option value="人名"{' selected' if knd == '人名' else ''}>人名</option>
|
||
<option value="公司"{' selected' if knd == '公司' else ''}>公司</option>
|
||
<option value="项目"{' selected' if knd == '项目' else ''}>项目</option>
|
||
</select></td>
|
||
<td><input name="company" value="{html.escape(comp, quote=True)}" maxlength="80" placeholder="所属公司"></td>
|
||
<td><input name="role" value="{html.escape(role, quote=True)}" maxlength="80" placeholder="职务/角色"></td>
|
||
<td><input name="related" value="{html.escape(rel, quote=True)}" maxlength="160" placeholder="关联人物(逗号分隔)"></td>
|
||
<td><span class="tag">{src_tag}</span></td>
|
||
<td style="white-space:nowrap">
|
||
<button type="submit" class="sm">保存</button>
|
||
<button type="submit" formaction="/lexicon/entity/delete" class="sm danger" onclick="return confirm('确认删除该实体记录?')">删除</button>
|
||
</td>
|
||
</form></tr>""")
|
||
content_html = f"""<section class="card"><h2>新增实体卡片记录(手动维护实体不会被 Vault 同步覆盖)</h2>
|
||
<form method="post" action="/lexicon/entity/update">
|
||
<input type="hidden" name="old_canonical" value="">
|
||
<div class="grid3">
|
||
<div><label>规范名称</label><input name="canonical" required maxlength="60" placeholder="例如:万亚坤 / 洛启生物"></div>
|
||
<div><label>类型</label><select name="kind"><option value="人名">人名</option><option value="公司">公司</option><option value="项目">项目</option></select></div>
|
||
<div><label>所属公司 / 机构</label><input name="company" maxlength="80" placeholder="例如:洛启生物"></div>
|
||
</div>
|
||
<div class="grid2">
|
||
<div><label>职务 / 角色</label><input name="role" maxlength="80" placeholder="例如:创始人 / 实控人"></div>
|
||
<div><label>关联人(逗号分隔)</label><input name="related" maxlength="160" placeholder="例如:高瑶,谭凯"></div>
|
||
</div>
|
||
<p style="margin-top:10px"><button type="submit" class="sm">新增实体</button></p>
|
||
</form></section>
|
||
<section class="card"><h2>实体列表</h2>{pager}
|
||
<table><thead><tr><th>规范名称</th><th>类型</th><th>公司/机构</th><th>职务/角色</th><th>关联人</th><th>来源</th><th>操作</th></tr></thead>
|
||
<tbody>{''.join(tr_list) or '<tr><td colspan="7" class="muted">暂无数据</td></tr>'}</tbody></table></section>"""
|
||
|
||
else: # relations
|
||
total = lexicon.count_relations(q=q)
|
||
rows = lexicon.list_relations(q=q, limit=LEXICON_PAGE_SIZE, offset=offset)
|
||
pager = _lexicon_pager(tab, q, page, total)
|
||
tr_list = []
|
||
for r in rows:
|
||
subj = str(r["subject"])
|
||
pred = str(r["predicate"])
|
||
obj = str(r["object"])
|
||
conf = float(r.get("confidence") or 0.8)
|
||
src = str(r.get("source") or "manual")
|
||
act = int(r.get("active") if r.get("active") is not None else 1)
|
||
tr_list.append(f"""<tr>
|
||
<form method="post" action="/lexicon/relation/update">
|
||
<td><input type="hidden" name="old_subject" value="{html.escape(subj, quote=True)}">
|
||
<input type="hidden" name="old_predicate" value="{html.escape(pred, quote=True)}">
|
||
<input type="hidden" name="old_object" value="{html.escape(obj, quote=True)}">
|
||
<input type="hidden" name="old_source" value="{html.escape(src, quote=True)}">
|
||
<input name="subject" value="{html.escape(subj, quote=True)}" required maxlength="60"></td>
|
||
<td><input name="predicate" value="{html.escape(pred, quote=True)}" required maxlength="40"></td>
|
||
<td><input name="object" value="{html.escape(obj, quote=True)}" required maxlength="60"></td>
|
||
<td><input name="confidence" type="number" step="0.05" min="0" max="1" value="{conf:.2f}" style="width:75px"></td>
|
||
<td><label style="margin:0"><input type="checkbox" name="active" value="1"{' checked' if act else ''}> 启用</label></td>
|
||
<td style="white-space:nowrap">
|
||
<button type="submit" class="sm">保存</button>
|
||
<button type="submit" formaction="/lexicon/relation/delete" class="sm danger" onclick="return confirm('确认删除该关系记录?')">删除</button>
|
||
</td>
|
||
</form></tr>""")
|
||
content_html = f"""<section class="card"><h2>新增人物 / 机构关系</h2>
|
||
<form method="post" action="/lexicon/relation/update">
|
||
<input type="hidden" name="old_subject" value="">
|
||
<div class="grid3">
|
||
<div><label>主体 (Subject)</label><input name="subject" required maxlength="60" placeholder="例如:万亚坤"></div>
|
||
<div><label>关系谓词 (Predicate)</label><input name="predicate" required maxlength="40" placeholder="例如:任职于 / 实控人 / 投资"></div>
|
||
<div><label>客体 (Object)</label><input name="object" required maxlength="60" placeholder="例如:洛启生物"></div>
|
||
</div>
|
||
<input type="hidden" name="confidence" value="0.95">
|
||
<input type="hidden" name="active" value="1">
|
||
<p style="margin-top:10px"><button type="submit" class="sm">新增关系</button></p>
|
||
</form></section>
|
||
<section class="card"><h2>关系图谱列表</h2>{pager}
|
||
<table><thead><tr><th>主体</th><th>关系</th><th>客体</th><th>置信度</th><th>状态</th><th>操作</th></tr></thead>
|
||
<tbody>{''.join(tr_list) or '<tr><td colspan="6" class="muted">暂无数据</td></tr>'}</tbody></table></section>"""
|
||
|
||
body = f"""{notice}<section class="card">
|
||
<div class="row"><h1>实体与纠错词库管理 (SQLite + Obsidian)</h1>
|
||
<form method="post" action="/lexicon/sync"><button type="submit" class="alt sm">从 Obsidian Vault 同步卡片与图谱</button></form></div>
|
||
<p class="muted">实体库:<strong>{st.get('entities', 0)}</strong> 个实体({st.get('with_company', 0)} 含公司归属),
|
||
<strong>{st.get('aliases', 0)}</strong> 条别名(卡片 {st.get('aliases_card', 0)} + 学习/人工 {st.get('aliases_learned', 0)}),
|
||
<strong>{st.get('corrections', 0)}</strong> 条发音纠错;关系图谱:<strong>{gst.get('relations', 0)}</strong> 条关系,
|
||
<strong>{gst.get('documents', 0)}</strong> 篇已索引纪要。</p>
|
||
<div class="row" style="margin-top:10px">
|
||
<div class="tabs" style="margin-bottom:0;border-bottom:0;padding-bottom:0">{tabs_html}</div>
|
||
<form method="get" action="/lexicon" class="row" style="gap:6px">
|
||
<input type="hidden" name="tab" value="{html.escape(tab)}">
|
||
<input name="q" value="{html.escape(q, quote=True)}" placeholder="搜索当前分类…" style="width:190px;padding:6px 10px">
|
||
<button type="submit" class="sm alt">搜索</button>
|
||
{f'<a href="/lexicon?tab={html.escape(tab)}" class="muted">清除</a>' if q else ''}
|
||
</form>
|
||
</div>
|
||
</section>
|
||
{content_html}"""
|
||
return shell("实体词库管理", body)
|
||
|
||
|
||
def _mask_secret(val: str) -> str:
|
||
return "已配置" if val else "未配置"
|
||
|
||
|
||
def _configured(val: str) -> str:
|
||
return "已配置(内容已隐藏)" if val else "未配置"
|
||
|
||
|
||
def settings_page(msg: str = "") -> bytes:
|
||
notice = f'<div class="source-box">{html.escape(msg)}</div>' if msg else ""
|
||
hb_path = STATE / "worker.heartbeat"
|
||
hb_ok = hb_path.exists() and (time.time() - hb_path.stat().st_mtime < 90)
|
||
hb_text = "运行正常 (Ready)" if hb_ok else "未检测到近期心跳 (Standby / 独立模式)"
|
||
prov_rows = []
|
||
for p in providers.provider_status():
|
||
badge = '<span class="badge">就绪</span>' if p["ready"] else '<span class="badge failed">未配密钥</span>'
|
||
prov_rows.append(
|
||
f"<tr><td><strong>{html.escape(p['id'])}</strong></td>"
|
||
f"<td>{html.escape(p['label'])}</td>"
|
||
f"<td><code>{html.escape(p['model'])}</code></td>"
|
||
f"<td>{_configured(p['endpoint'])}</td>"
|
||
f"<td>{html.escape(p['via'])}</td>"
|
||
f"<td>{badge}</td></tr>"
|
||
)
|
||
hs_ok = False
|
||
if CFG.use_hindsight and CFG.hindsight_api:
|
||
try:
|
||
with urllib.request.urlopen(f"{CFG.hindsight_api}/health", timeout=3) as r:
|
||
hs_ok = r.status == 200
|
||
except Exception:
|
||
hs_ok = False
|
||
vault_ok = CFG.vault.is_dir()
|
||
|
||
s = db.get_all_settings()
|
||
litellm_key_mask = _mask_secret(s.get("litellm_api_key", ""))
|
||
soniox_key_mask = _mask_secret(s.get("soniox_api_key", ""))
|
||
grok_key_mask = _mask_secret(s.get("grok_api_key", ""))
|
||
web_pw_mask = _mask_secret(webauth.password())
|
||
prov_opts = _provider_options(CFG.asr_provider)
|
||
|
||
body = f"""{notice}<section class="card">
|
||
<div class="row"><h1>模型与系统配置 (SQLite 持久化)</h1>
|
||
<span class="tag">配置保存在 SQLite app_setting,页面不回显地址和密钥</span></div>
|
||
<p class="muted">模型名会显示在下面的输入框里。网关地址和 API 密钥只显示「已配置 / 未配置」,留空表示保持原值。保存后下一次模型调用会读取新密钥。</p>
|
||
|
||
<form method="post" action="/settings">
|
||
<h2>1. 后处理大模型网关 (LiteLLM) 与各阶段模型</h2>
|
||
<div class="grid2">
|
||
<div><label>LiteLLM Base URL</label>
|
||
<input name="litellm_base_url" value="" placeholder="留空保持不变(当前:{_configured(CFG.litellm_base_url)})" autocomplete="off"></div>
|
||
<div><label>LiteLLM API Key(当前:{html.escape(litellm_key_mask)};留空保持不变)</label>
|
||
<input name="litellm_api_key" type="password" autocomplete="new-password" placeholder="留空不修改已有密钥;输入 CLEAR 清空"></div>
|
||
</div>
|
||
<div class="grid3">
|
||
<div><label>清洗与去口语化模型 (Clean Model)</label>
|
||
<input id="clean_model" name="clean_model" value="{html.escape(CFG.clean_model, quote=True)}"></div>
|
||
<div><label>实体候选词召回模型 (Keyword Model)</label>
|
||
<input id="keyword_model" name="keyword_model" value="{html.escape(CFG.keyword_model, quote=True)}"></div>
|
||
<div><label>纪要成稿与裁决模型 (Draft Model)</label>
|
||
<input id="draft_model" name="draft_model" value="{html.escape(CFG.draft_model, quote=True)}"></div>
|
||
</div>
|
||
<p style="margin:8px 0">
|
||
<button type="button" class="sm alt" onclick="probeModels()">探测当前 LiteLLM 可用模型列表</button>
|
||
<span id="probeMsg" class="muted" style="margin-left:8px"></span>
|
||
</p>
|
||
<div id="modelChips" style="margin:6px 0"></div>
|
||
|
||
<h2 style="margin-top:18px">2. 语音识别引擎 (ASR Providers) 配置</h2>
|
||
<div class="grid3">
|
||
<div><label>默认 ASR 引擎</label><select name="asr_provider">{prov_opts}</select></div>
|
||
<div><label>内网 Qwen3-ASR+CAM++ 转写地址</label>
|
||
<input name="diarize_url" value="" placeholder="留空保持不变({_configured(CFG.diarize_url)})" autocomplete="off"></div>
|
||
<div><label>内网声纹增量注册地址</label>
|
||
<input name="voiceprint_enroll_url" value="" placeholder="留空保持不变({_configured(CFG.voiceprint_enroll_url)})" autocomplete="off"></div>
|
||
</div>
|
||
<div class="grid3">
|
||
<div><label>Soniox API Base</label>
|
||
<input name="soniox_api_base" value="" placeholder="留空保持不变({_configured(CFG.soniox_api_base)})" autocomplete="off"></div>
|
||
<div><label>Soniox 默认模型</label>
|
||
<input name="soniox_model" value="{html.escape(CFG.soniox_model, quote=True)}"></div>
|
||
<div><label>Soniox API Key(当前:{html.escape(soniox_key_mask)})</label>
|
||
<input name="soniox_api_key" type="password" autocomplete="new-password" placeholder="留空保持不变;输入 CLEAR 清空"></div>
|
||
</div>
|
||
<div class="grid2">
|
||
<div><label>Gemini 语音转写模型(经 LiteLLM 路由)</label>
|
||
<input id="gemini_asr_model" name="gemini_asr_model" value="{html.escape(CFG.gemini_asr_model, quote=True)}"></div>
|
||
<div><label>OpenAI 语音转写模型(经 LiteLLM 路由)</label>
|
||
<input id="openai_asr_model" name="openai_asr_model" value="{html.escape(CFG.openai_asr_model, quote=True)}"></div>
|
||
</div>
|
||
<div class="grid3">
|
||
<div><label>Grok (xAI) API Base</label>
|
||
<input name="grok_api_base" value="" placeholder="留空保持不变({_configured(CFG.grok_api_base)})" autocomplete="off"></div>
|
||
<div><label>Grok 语音转写模型</label>
|
||
<input name="grok_asr_model" value="{html.escape(CFG.grok_asr_model, quote=True)}"></div>
|
||
<div><label>Grok XAI API Key(当前:{html.escape(grok_key_mask)})</label>
|
||
<input name="grok_api_key" type="password" autocomplete="new-password" placeholder="留空保持不变;输入 CLEAR 清空"></div>
|
||
</div>
|
||
|
||
<h2 style="margin-top:18px">3. 知识增强与访问安全 (Hindsight / Termbank / Obsidian / Web 口令)</h2>
|
||
<div class="grid3">
|
||
<div><label>Hindsight API 地址</label>
|
||
<input name="hindsight_api" value="" placeholder="留空保持不变({_configured(CFG.hindsight_api)})" autocomplete="off"></div>
|
||
<div><label>Hindsight Bank 名称</label>
|
||
<input name="hindsight_bank" value="{html.escape(CFG.hindsight_bank, quote=True)}"></div>
|
||
<div><label>Web 访问口令(当前:{html.escape(web_pw_mask)};环境变量优先)</label>
|
||
<input name="web_password" type="password" autocomplete="new-password" placeholder="留空保持不变;修改后需重新登录"></div>
|
||
</div>
|
||
<div class="row" style="justify-content:flex-start;gap:24px;margin-top:10px">
|
||
<label style="margin:0"><input type="checkbox" name="use_hindsight" value="1"{' checked' if CFG.use_hindsight else ''}> 启用 Hindsight 记忆召回与写回</label>
|
||
<label style="margin:0"><input type="checkbox" name="use_termbank" value="1"{' checked' if CFG.use_termbank else ''}> 启用专业术语库 (Termbank)</label>
|
||
<label style="margin:0"><input type="checkbox" name="git_sync" value="1"{' checked' if CFG.git_sync else ''}> 正式纪要发布后执行 Vault Git 自动推送</label>
|
||
</div>
|
||
<p style="margin-top:16px"><button type="submit">保存全部配置至 SQLite</button></p>
|
||
</form></section>
|
||
|
||
<section class="card"><h2>实时运行与通道就绪状态</h2>
|
||
<table><tbody>
|
||
<tr><th>Worker 进程状态</th><td>{html.escape(hb_text)}</td></tr>
|
||
<tr><th>Hindsight 记忆服务</th><td>{_configured(CFG.hindsight_api)} · {'<span class="badge">在线</span>' if hs_ok else '<span class="badge review">离线或未连接</span>'}</td></tr>
|
||
<tr><th>Obsidian Vault</th><td>{'<span class="badge">已挂载</span>' if vault_ok else '<span class="badge review">未挂载</span>'}</td></tr>
|
||
</tbody></table>
|
||
<h3 style="margin-top:14px">ASR 通道表</h3>
|
||
<table><thead><tr><th>ID</th><th>名称</th><th>默认型号</th><th>接口地址</th><th>接入方式</th><th>状态</th></tr></thead>
|
||
<tbody>{''.join(prov_rows)}</tbody></table></section>"""
|
||
|
||
script = """<script>
|
||
async function probeModels(){
|
||
const msg=document.querySelector('#probeMsg'), box=document.querySelector('#modelChips');
|
||
msg.textContent='正在查询 LiteLLM /models…';
|
||
try{
|
||
const r=await fetch('/settings/models-probe',{cache:'no-store'});
|
||
const d=await r.json();
|
||
if(!d.ok){msg.textContent='查询失败:'+(d.error||'unknown');return;}
|
||
msg.textContent=`共发现 ${d.models.length} 个模型(点击模型名可填入下方对应输入框):`;
|
||
box.replaceChildren();
|
||
d.models.forEach(m=>{
|
||
const s=document.createElement('span');
|
||
s.className='tag'; s.style.cursor='pointer'; s.style.marginBottom='4px';
|
||
s.textContent=m;
|
||
s.addEventListener('click',()=>{ const el=document.querySelector('#draft_model'); if(el) el.value=m; });
|
||
box.appendChild(s);
|
||
});
|
||
}catch(e){msg.textContent='请求异常:'+e;}
|
||
}
|
||
</script>"""
|
||
return shell("模型与系统配置", body, script)
|
||
|
||
|
||
def _probe_litellm_models() -> dict[str, Any]:
|
||
try:
|
||
url = f"{CFG.litellm_base_url}/models"
|
||
req = urllib.request.Request(
|
||
url,
|
||
headers={"Authorization": f"Bearer {CFG.api_key()}", "Accept": "application/json"},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||
data = json.loads(resp.read().decode("utf-8", errors="ignore"))
|
||
models = sorted({str(m.get("id")) for m in (data.get("data") or []) if m.get("id")})
|
||
return {"ok": True, "models": models}
|
||
except Exception as exc:
|
||
return {"ok": False, "error": str(exc), "models": []}
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
server_version = "ASRService/3"
|
||
|
||
def log_message(self, *_):
|
||
pass
|
||
|
||
def setup(self) -> None:
|
||
super().setup()
|
||
self.connection.settimeout(30)
|
||
self._suppress_body = False
|
||
|
||
def handle_one_request(self) -> None:
|
||
try:
|
||
super().handle_one_request()
|
||
except ClientError as exc:
|
||
try:
|
||
self.json({"error": exc.message}, exc.status)
|
||
except Exception:
|
||
pass
|
||
except (TimeoutError, socket.timeout):
|
||
try:
|
||
self.json({"error": "timeout"}, 408)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
try:
|
||
self.json({"error": "internal"}, 500)
|
||
except Exception:
|
||
pass
|
||
|
||
def send_data(
|
||
self,
|
||
data: bytes,
|
||
status: int = 200,
|
||
ctype: str = "text/html; charset=utf-8",
|
||
disposition: str = "",
|
||
extra_headers: dict[str, str] | None = None,
|
||
) -> None:
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", ctype)
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.send_header("X-Content-Type-Options", "nosniff")
|
||
self.send_header(
|
||
"Content-Security-Policy",
|
||
"default-src 'self'; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'",
|
||
)
|
||
if disposition:
|
||
self.send_header("Content-Disposition", disposition)
|
||
if extra_headers:
|
||
for k, v in extra_headers.items():
|
||
self.send_header(k, v)
|
||
self.send_header("Content-Length", str(len(data)))
|
||
self.end_headers()
|
||
if not getattr(self, "_suppress_body", False):
|
||
self.wfile.write(data)
|
||
|
||
def redirect(self, location: str) -> None:
|
||
self.send_response(303)
|
||
self.send_header("Location", location)
|
||
self.end_headers()
|
||
|
||
def json(self, obj: object, status: int = 200) -> None:
|
||
self.send_data(
|
||
json.dumps(obj, ensure_ascii=False).encode("utf-8"),
|
||
status,
|
||
"application/json; charset=utf-8",
|
||
)
|
||
|
||
def auth(self) -> bool:
|
||
value = self.headers.get("Authorization", "")
|
||
return bool(
|
||
AGENT_TOKEN
|
||
and value.startswith("Bearer ")
|
||
and hmac.compare_digest(value[7:], AGENT_TOKEN)
|
||
)
|
||
|
||
def web_authed(self) -> bool:
|
||
if webauth.allow_anon():
|
||
return True
|
||
if not webauth.password():
|
||
return False
|
||
if self.auth():
|
||
return True
|
||
return webauth.cookie_matches(self.headers.get("Cookie", ""))
|
||
|
||
def need_web_auth(self) -> bool:
|
||
if self.web_authed():
|
||
return True
|
||
path = urllib.parse.urlsplit(self.path).path
|
||
if path.startswith("/ui/") or path.endswith("models-probe"):
|
||
self.json({"error": "unauthorized"}, 401)
|
||
else:
|
||
self.redirect("/login")
|
||
return False
|
||
|
||
def _declared_length(self, max_bytes: int) -> int:
|
||
raw = self.headers.get("Content-Length")
|
||
if raw is None or raw == "":
|
||
return 0
|
||
try:
|
||
n = int(raw)
|
||
except ValueError:
|
||
raise ClientError(400, "bad content-length") from None
|
||
if n < 0:
|
||
raise ClientError(400, "bad content-length")
|
||
if n > max_bytes:
|
||
raise ClientError(413, "payload too large")
|
||
return n
|
||
|
||
def _read_exact(self, n: int) -> bytes:
|
||
if n <= 0:
|
||
return b""
|
||
data = self.rfile.read(n)
|
||
if len(data) < n:
|
||
raise ClientError(400, "incomplete body")
|
||
return data
|
||
|
||
def _csrf_ok(self, form: dict[str, list[str]] | None = None) -> bool:
|
||
if self.auth():
|
||
return True
|
||
supplied = self.headers.get("X-CSRF-Token", "")
|
||
if not supplied and form is not None:
|
||
supplied = (form.get("csrf") or [""])[0]
|
||
return webauth.csrf_matches(supplied)
|
||
|
||
def need_auth(self) -> bool:
|
||
if self.auth():
|
||
return True
|
||
self.json({"error": "unauthorized"}, 401)
|
||
return False
|
||
|
||
def header(self, name: str, limit: int) -> str:
|
||
return safe(urllib.parse.unquote(self.headers.get(name, "")), limit)
|
||
|
||
def read_form(self, max_bytes: int = 65536, *, csrf: bool = True) -> dict[str, list[str]]:
|
||
length = self._declared_length(max_bytes)
|
||
raw = self._read_exact(length).decode("utf-8", errors="ignore") if length else ""
|
||
form = urllib.parse.parse_qs(raw, keep_blank_values=True)
|
||
if csrf and not self._csrf_ok(form):
|
||
raise ClientError(403, "csrf")
|
||
return form
|
||
|
||
def upload(self, source: str, protected: bool) -> None:
|
||
if protected and not self.auth():
|
||
return self.json({"error": "unauthorized"}, 401)
|
||
if not protected and not self.web_authed():
|
||
return self.json({"error": "unauthorized"}, 401)
|
||
if not protected and not self._csrf_ok():
|
||
return self.json({"error": "csrf"}, 403)
|
||
length = self._declared_length(MAX_UPLOAD)
|
||
name = self.header("X-Filename", 240)
|
||
suffix = Path(name).suffix.lower()
|
||
if length <= 0 or length > MAX_UPLOAD:
|
||
return self.json({"error": "invalid size"}, 413)
|
||
if suffix not in SUPPORTED:
|
||
return self.json({"error": "unsupported type"}, 415)
|
||
jid = uuid.uuid4().hex[:24]
|
||
title = self.header("X-Title", 80)
|
||
prov = providers.normalize_provider_name(
|
||
self.header("X-ASR-Provider", 40) or CFG.asr_provider
|
||
)
|
||
model = self.header("X-ASR-Model", 80)
|
||
stored = f"{time.strftime('%Y-%m-%d_%H_%M_%S')}-{stem(title or Path(name).stem)}-{jid[:8]}{suffix}"
|
||
staging = INBOX / ".uploading"
|
||
staging.mkdir(parents=True, exist_ok=True)
|
||
part = staging / f"{jid}.part"
|
||
remaining = length
|
||
digest = hashlib.sha256()
|
||
try:
|
||
with part.open("xb") as out:
|
||
while remaining:
|
||
chunk = self.rfile.read(min(1024 * 1024, remaining))
|
||
if not chunk:
|
||
raise ClientError(400, "incomplete upload")
|
||
out.write(chunk)
|
||
digest.update(chunk)
|
||
remaining -= len(chunk)
|
||
out.flush()
|
||
os.fsync(out.fileno())
|
||
part.replace(INBOX / stored)
|
||
job = db.create_job(
|
||
jid, name, stored,
|
||
title=title,
|
||
context=self.header("X-Context", 2000),
|
||
size=length,
|
||
source=source,
|
||
asr_provider=prov,
|
||
asr_model=model,
|
||
)
|
||
db.update_job(
|
||
jid, "queued", f"录音已落盘,等待 worker (ASR: {prov})",
|
||
sha256=digest.hexdigest(),
|
||
asr_provider=prov,
|
||
asr_model=model,
|
||
)
|
||
self.json(public(job), 201)
|
||
except ClientError:
|
||
part.unlink(missing_ok=True)
|
||
raise
|
||
except Exception:
|
||
part.unlink(missing_ok=True)
|
||
self.json({"error": "upload failed"}, 500)
|
||
|
||
def artifact(self, job: dict | None, name: str, api: bool = False) -> None:
|
||
if not job:
|
||
return self.json({"error": "not found"}, 404)
|
||
text = _read_artifact_text(job, name)
|
||
if not text:
|
||
return self.json({"error": "not found"}, 404)
|
||
path = Path(str(job.get(name) or f"{name}.md"))
|
||
self.send_data(
|
||
text.encode("utf-8"),
|
||
200,
|
||
"text/markdown; charset=utf-8",
|
||
content_disposition(path.name),
|
||
)
|
||
|
||
def stream_audio(self, job: dict | None) -> None:
|
||
if not job:
|
||
return self.send_data(b"Not found", 404, "text/plain")
|
||
audio_path = find_audio_file(job)
|
||
if not audio_path or not audio_path.is_file():
|
||
return self.send_data(b"Audio not found", 404, "text/plain")
|
||
mime = mimetypes.guess_type(audio_path.name)[0] or "audio/mpeg"
|
||
size = audio_path.stat().st_size
|
||
status, start, end = _parse_byte_range(self.headers.get("Range", ""), size)
|
||
if status == 416:
|
||
self.send_response(416)
|
||
self.send_header("Content-Range", f"bytes */{size}")
|
||
self.send_header("Content-Length", "0")
|
||
self.end_headers()
|
||
return
|
||
length = end - start + 1
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", mime)
|
||
self.send_header("Accept-Ranges", "bytes")
|
||
if status == 206:
|
||
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||
self.send_header("Content-Length", str(length))
|
||
self.end_headers()
|
||
if getattr(self, "_suppress_body", False):
|
||
return
|
||
with audio_path.open("rb") as f:
|
||
f.seek(start)
|
||
rem = length
|
||
while rem > 0:
|
||
buf = f.read(min(256 * 1024, rem))
|
||
if not buf:
|
||
break
|
||
self.wfile.write(buf)
|
||
rem -= len(buf)
|
||
|
||
def do_GET(self) -> None:
|
||
parsed = urllib.parse.urlsplit(self.path)
|
||
path = parsed.path
|
||
q = urllib.parse.parse_qs(parsed.query)
|
||
if path in ("/health", "/healthz"):
|
||
return self.json({"ok": True, "service": "asr-web"})
|
||
if path == "/readyz":
|
||
try:
|
||
db.connect().close()
|
||
hb = STATE / "worker.heartbeat"
|
||
ok = hb.exists() and time.time() - hb.stat().st_mtime < 90
|
||
except Exception:
|
||
ok = False
|
||
return self.json({"ready": ok}, 200 if ok else 503)
|
||
if path == "/login":
|
||
return self.send_data(login_page())
|
||
if path == "/logout":
|
||
self.send_response(303)
|
||
self.send_header("Location", "/login")
|
||
self.send_header(
|
||
"Set-Cookie",
|
||
"asr_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
|
||
)
|
||
self.end_headers()
|
||
return
|
||
if path == "/api/v1/providers":
|
||
if not self.need_auth():
|
||
return
|
||
return self.json({"providers": providers.provider_status(), "default": CFG.asr_provider})
|
||
if path == "/api/v1/settings":
|
||
if not self.need_auth():
|
||
return
|
||
raw_s = db.get_all_settings()
|
||
redacted = {
|
||
k: (_mask_secret(v) if (k.endswith("_api_key") or k == "web_password") else v)
|
||
for k, v in raw_s.items()
|
||
}
|
||
return self.json({"settings": redacted})
|
||
if path == "/api/v1/jobs":
|
||
if not self.need_auth():
|
||
return
|
||
return self.json({
|
||
"jobs": [
|
||
public(x)
|
||
for x in db.list_jobs(
|
||
int(q.get("limit", ["30"])[0]),
|
||
int(q.get("offset", ["0"])[0]),
|
||
q.get("status", [""])[0],
|
||
q.get("q", [""])[0],
|
||
q.get("provider", [""])[0],
|
||
)
|
||
]
|
||
})
|
||
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})", path)
|
||
if m:
|
||
if not self.need_auth():
|
||
return
|
||
job = db.get_job(m.group(1))
|
||
return self.json(public(job, True)) if job else self.json({"error": "not found"}, 404)
|
||
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/artifacts/(transcript|draft|result)", path)
|
||
if m:
|
||
if not self.need_auth():
|
||
return
|
||
return self.artifact(db.get_job(m.group(1)), m.group(2), True)
|
||
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/reviews", path)
|
||
if m:
|
||
if not self.need_auth():
|
||
return
|
||
job = db.get_job(m.group(1))
|
||
if not job:
|
||
return self.json({"error": "not found"}, 404)
|
||
rows = []
|
||
for row in pending(job):
|
||
payload = json.loads(row["payload"] or "{}")
|
||
rows.append({
|
||
"id": row["id"],
|
||
"question": row["question"],
|
||
"kind": payload.get("kind", ""),
|
||
"sentence": payload.get("sentence", ""),
|
||
"candidates": payload.get("candidates") or [],
|
||
})
|
||
return self.json({"reviews": rows})
|
||
|
||
# Web UI routes below
|
||
if not self.need_web_auth():
|
||
return
|
||
if path == "/":
|
||
return self.send_data(home(q.get("msg", [""])[0]))
|
||
if path == "/review/":
|
||
return self.send_data(review_page(None, q.get("msg", [""])[0]))
|
||
if path == "/lexicon":
|
||
return self.send_data(
|
||
lexicon_page(
|
||
tab=q.get("tab", ["aliases"])[0],
|
||
q=q.get("q", [""])[0],
|
||
msg=q.get("msg", [""])[0],
|
||
page=max(1, int(q.get("page", ["1"])[0] or 1)) if str(q.get("page", ["1"])[0]).isdigit() else 1,
|
||
)
|
||
)
|
||
if path == "/settings":
|
||
return self.send_data(settings_page(q.get("msg", [""])[0]))
|
||
if path == "/settings/models-probe":
|
||
return self.json(_probe_litellm_models())
|
||
if path == "/docs":
|
||
doc = {
|
||
"service": "meeting-asr",
|
||
"endpoints": [
|
||
"GET /health, /readyz",
|
||
"GET /api/v1/providers",
|
||
"GET /api/v1/settings",
|
||
"POST /api/v1/settings",
|
||
"POST /api/v1/jobs (headers: X-Filename, X-Title, X-Context, X-ASR-Provider, X-ASR-Model)",
|
||
"GET /api/v1/jobs?status=&provider=&q=&limit=30&offset=0",
|
||
"GET /api/v1/jobs/{job_id}",
|
||
"DELETE /api/v1/jobs/{job_id}",
|
||
"GET /api/v1/jobs/{job_id}/artifacts/{transcript|draft|result}",
|
||
"GET /api/v1/jobs/{job_id}/reviews",
|
||
"POST /api/v1/jobs/{job_id}/reviews/{question_id}",
|
||
"POST /api/v1/jobs/{job_id}/retry (json: {\"provider\": \"soniox\", \"model\": \"\"})",
|
||
"POST /api/v1/jobs/{job_id}/rerefine",
|
||
],
|
||
}
|
||
return self.send_data(shell("API 文档", f'<section class="card"><h1>API 接口说明</h1><pre>{html.escape(json.dumps(doc, ensure_ascii=False, indent=2))}</pre></section>'))
|
||
if path == "/ui/jobs":
|
||
status_flt = q.get("status", [""])[0]
|
||
prov_flt = q.get("provider", [""])[0]
|
||
q_flt = q.get("q", [""])[0]
|
||
return self.json({
|
||
"jobs": [
|
||
public(x)
|
||
for x in db.list_jobs(60, 0, status=status_flt, q=q_flt, provider=prov_flt)
|
||
]
|
||
})
|
||
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/", path)
|
||
if m:
|
||
job = db.get_job(m.group(1))
|
||
return self.send_data(job_page(job, q.get("msg", [""])[0])) if job else self.send_data(b"Not found", 404, "text/plain")
|
||
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/review/", path)
|
||
if m:
|
||
job = db.get_job(m.group(1))
|
||
return self.send_data(review_page(job, q.get("msg", [""])[0])) if job else self.send_data(b"Not found", 404, "text/plain")
|
||
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/audio", path)
|
||
if m:
|
||
return self.stream_audio(db.get_job(m.group(1)))
|
||
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/artifact/(transcript|draft|result)", path)
|
||
if m:
|
||
return self.artifact(db.get_job(m.group(1)), m.group(2), False)
|
||
self.send_data(shell(
|
||
"未找到",
|
||
'<section class="card"><h1>页面不存在</h1>'
|
||
'<p class="muted">这个地址没有对应的页面。</p>'
|
||
'<p><a href="/">返回任务列表</a></p></section>',
|
||
), 404)
|
||
|
||
def do_HEAD(self) -> None:
|
||
self._suppress_body = True
|
||
try:
|
||
self.do_GET()
|
||
finally:
|
||
self._suppress_body = False
|
||
|
||
def do_OPTIONS(self) -> None:
|
||
self.send_response(204)
|
||
self.send_header("Allow", "GET, HEAD, POST, DELETE, OPTIONS")
|
||
self.send_header("Content-Length", "0")
|
||
self.end_headers()
|
||
|
||
def do_DELETE(self) -> None:
|
||
path = urllib.parse.urlsplit(self.path).path
|
||
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})", path)
|
||
if m:
|
||
if not self.need_auth():
|
||
return
|
||
ok = delete_job_and_artifacts(m.group(1), delete_files=True, delete_result=False)
|
||
return self.json({"ok": ok}, 200 if ok else 404)
|
||
self.json({"error": "not found"}, 404)
|
||
|
||
def do_POST(self) -> None:
|
||
path = urllib.parse.urlsplit(self.path).path
|
||
if path == "/login":
|
||
origin = self.headers.get("Origin", "")
|
||
host = self.headers.get("Host", "")
|
||
if origin and urllib.parse.urlsplit(origin).netloc not in {"", host}:
|
||
return self.send_data(login_page("来源不被接受"), 403)
|
||
form = self.read_form(4096, csrf=False)
|
||
pw = form.get("password", [""])[0]
|
||
if not webauth.password():
|
||
return self.send_data(login_page(), 401)
|
||
ip = self.client_address[0]
|
||
fails, stamp = _LOGIN_FAILS.get(ip, (0, 0.0))
|
||
if time.time() - stamp > 600:
|
||
fails = 0
|
||
if fails >= 8:
|
||
return self.send_data(login_page("尝试过多,请 10 分钟后再试"), 429)
|
||
if hmac.compare_digest(pw, webauth.password()):
|
||
_LOGIN_FAILS.pop(ip, None)
|
||
secure = ""
|
||
if os.getenv("ASR_WEB_COOKIE_SECURE", "").strip().lower() in {"1", "true", "yes", "on"}:
|
||
secure = "; Secure"
|
||
self.send_response(303)
|
||
self.send_header("Location", "/")
|
||
self.send_header(
|
||
"Set-Cookie",
|
||
f"asr_session={webauth.session_token()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400{secure}",
|
||
)
|
||
self.end_headers()
|
||
return
|
||
_LOGIN_FAILS[ip] = (fails + 1, time.time())
|
||
return self.send_data(login_page("口令错误,请重试"), 401)
|
||
|
||
if path == "/ui/upload":
|
||
return self.upload("web", False)
|
||
if path == "/api/v1/jobs":
|
||
return self.upload("agent", True)
|
||
|
||
if path == "/api/v1/settings":
|
||
if not self.need_auth():
|
||
return
|
||
length = self._declared_length(32768)
|
||
body = json.loads(self._read_exact(length).decode("utf-8") or "{}")
|
||
if not isinstance(body, dict):
|
||
raise ClientError(400, "settings must be an object")
|
||
db.save_settings(_filter_settings(body))
|
||
return self.json({"ok": True})
|
||
|
||
m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/reviews/(\d+)", path)
|
||
if m:
|
||
if not self.need_auth():
|
||
return
|
||
job = db.get_job(m.group(1))
|
||
qid = int(m.group(2))
|
||
if not job or qid not in {x["id"] for x in pending(job)}:
|
||
return self.json({"error": "not found"}, 404)
|
||
length = self._declared_length(4096)
|
||
body = json.loads(self._read_exact(length).decode("utf-8") or "{}")
|
||
ans = str(body.get("answer", "")).strip()
|
||
if not ans:
|
||
return self.json({"error": "answer required"}, 400)
|
||
return self.json(review.answer(qid, ans[:200]))
|
||
|
||
m = re.fullmatch(r"/(?:api/v1/)?jobs/([0-9a-f]{24})/rerefine", path)
|
||
if m:
|
||
api = path.startswith("/api/")
|
||
if api and not self.need_auth():
|
||
return
|
||
if not api and not self.need_web_auth():
|
||
return
|
||
if not api:
|
||
self.read_form(8192)
|
||
job = db.get_job(m.group(1))
|
||
if not job:
|
||
return self.json({"error": "not found"}, 404)
|
||
ok = queue_rerefine(job)
|
||
if api:
|
||
return self.json({"ok": ok}, 202 if ok else 409)
|
||
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已加入重新精修队列"))
|
||
|
||
m = re.fullmatch(r"/(?:api/v1/)?jobs/([0-9a-f]{24})/retry", path)
|
||
if m:
|
||
api = path.startswith("/api/")
|
||
if api and not self.need_auth():
|
||
return
|
||
if not api and not self.need_web_auth():
|
||
return
|
||
job = db.get_job(m.group(1))
|
||
if not job:
|
||
return self.json({"error": "not found"}, 404)
|
||
new_prov = job.get("asr_provider") or CFG.asr_provider
|
||
new_model = job.get("asr_model") or ""
|
||
if api:
|
||
length = self._declared_length(4096)
|
||
raw_body = self._read_exact(length).decode("utf-8", errors="ignore") if length else ""
|
||
if raw_body.strip().startswith("{"):
|
||
try:
|
||
body_json = json.loads(raw_body)
|
||
if body_json.get("provider"):
|
||
new_prov = providers.normalize_provider_name(body_json["provider"])
|
||
if "model" in body_json:
|
||
new_model = safe(str(body_json.get("model") or ""), 80)
|
||
except ValueError:
|
||
raise ClientError(400, "bad json") from None
|
||
else:
|
||
form = self.read_form(8192)
|
||
if form.get("provider"):
|
||
new_prov = providers.normalize_provider_name(form["provider"][0])
|
||
if "model" in form:
|
||
new_model = safe(form.get("model", [""])[0], 80)
|
||
err = _requeue_audio(job, new_prov, new_model)
|
||
if err:
|
||
if api:
|
||
return self.json({"error": err}, 409)
|
||
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote(err))
|
||
if api:
|
||
return self.json({"ok": True, "asr_provider": new_prov, "asr_model": new_model}, 202)
|
||
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已重新排队转写"))
|
||
|
||
if not self.need_web_auth():
|
||
return
|
||
|
||
# ---- Task deletion & metadata editing ----
|
||
if path == "/jobs/batch-delete":
|
||
form = self.read_form(65536)
|
||
del_files = form.get("delete_files", ["1"])[0] == "1"
|
||
if form.get("cleanup_failed"):
|
||
failed_jobs = db.list_jobs(200, 0, status="failed")
|
||
for fj in failed_jobs:
|
||
delete_job_and_artifacts(fj["id"], delete_files=del_files, delete_result=False)
|
||
return self.redirect("/?msg=" + urllib.parse.quote(f"已清理 {len(failed_jobs)} 个失败任务"))
|
||
ids = form.get("job_id", [])
|
||
n_del = 0
|
||
for jid in ids:
|
||
if delete_job_and_artifacts(jid, delete_files=del_files, delete_result=False):
|
||
n_del += 1
|
||
return self.redirect("/?msg=" + urllib.parse.quote(f"已删除 {n_del} 个任务"))
|
||
|
||
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/delete", path)
|
||
if m:
|
||
form = self.read_form(4096)
|
||
del_files = form.get("delete_files", ["0"])[0] == "1"
|
||
del_res = form.get("delete_result", ["0"])[0] == "1"
|
||
ok = delete_job_and_artifacts(m.group(1), delete_files=del_files, delete_result=del_res)
|
||
msg = "任务已删除" if ok else "没有找到这个任务,未删除"
|
||
return self.redirect("/?msg=" + urllib.parse.quote(msg))
|
||
|
||
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/meta", path)
|
||
if m:
|
||
job = db.get_job(m.group(1))
|
||
if not job:
|
||
return self.send_data(b"Not found", 404, "text/plain")
|
||
form = self.read_form(32768)
|
||
raw_title = form.get("title", [""])[0]
|
||
raw_prov = form.get("provider", [CFG.asr_provider])[0].strip().lower()
|
||
new_title = safe(raw_title, 80)
|
||
new_ctx = safe(form.get("context", [""])[0], 2000)
|
||
new_prov = providers.normalize_provider_name(raw_prov)
|
||
new_model = safe(form.get("model", [""])[0], 80)
|
||
db.update_job_meta(
|
||
job["id"], title=new_title, context=new_ctx,
|
||
asr_provider=new_prov, asr_model=new_model,
|
||
)
|
||
notes = []
|
||
if len(raw_title) > 80:
|
||
notes.append("标题已截断到 80 字")
|
||
if raw_prov and raw_prov not in providers.PROVIDERS:
|
||
notes.append(f"未知引擎已改用 {new_prov}")
|
||
action = form.get("next_action", ["save"])[0]
|
||
updated_job = db.get_job(job["id"]) or job
|
||
if action == "rerefine":
|
||
queued = queue_rerefine(updated_job)
|
||
msg = "已保存并排队重新生成草稿" if queued else "已保存;任务正在处理,未重新生成草稿"
|
||
elif action == "retry":
|
||
err = _requeue_audio(updated_job, new_prov, new_model)
|
||
msg = err or "已保存并重新排队转写"
|
||
else:
|
||
msg = "任务信息已更新"
|
||
if notes:
|
||
msg += "(" + ";".join(notes) + ")"
|
||
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote(msg))
|
||
|
||
m = re.fullmatch(r"/jobs/([0-9a-f]{24})/revise", path)
|
||
if m:
|
||
job = db.get_job(m.group(1))
|
||
if not job:
|
||
return self.send_data(b"Not found", 404, "text/plain")
|
||
queue_revise_draft(job)
|
||
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已触发修订与定稿流程"))
|
||
|
||
# ---- Multi-recording review submission ----
|
||
if path == "/review/" or re.fullmatch(r"/jobs/([0-9a-f]{24})/review/", path):
|
||
m_job = re.fullmatch(r"/jobs/([0-9a-f]{24})/review/", path)
|
||
job = db.get_job(m_job.group(1)) if m_job else None
|
||
form = self.read_form(65536)
|
||
allowed_ids = {x["id"] for x in pending(job)}
|
||
answered_count = 0
|
||
|
||
# Case 1: Single preset button clicked (`single_submit` = "qid::answer")
|
||
if form.get("single_submit"):
|
||
raw_val = form["single_submit"][0]
|
||
if "::" in raw_val:
|
||
qid_str, ans_val = raw_val.split("::", 1)
|
||
if qid_str.isdigit() and int(qid_str) in allowed_ids and ans_val.strip():
|
||
review.answer(int(qid_str), ans_val.strip()[:200])
|
||
answered_count += 1
|
||
# Case 2: Single custom button clicked (`single_custom` = "qid")
|
||
elif form.get("single_custom"):
|
||
qid_str = form["single_custom"][0]
|
||
if qid_str.isdigit() and int(qid_str) in allowed_ids:
|
||
ans_val = (
|
||
form.get(f"custom_{qid_str}", [""])[0].strip()
|
||
or form.get(f"preset_{qid_str}", [""])[0].strip()
|
||
)
|
||
if ans_val:
|
||
review.answer(int(qid_str), ans_val[:200])
|
||
answered_count += 1
|
||
# Case 3: Skip all questions in the recording group
|
||
elif form.get("batch_skip_all"):
|
||
qids = [
|
||
int(x) for x in form.get("question_ids", [""])[0].split(",")
|
||
if x.strip().isdigit() and int(x) in allowed_ids
|
||
]
|
||
for qid in qids:
|
||
review.answer(qid, "无法确认,保留原文")
|
||
answered_count += 1
|
||
# Case 4: Batch submit filled questions in the recording group
|
||
elif form.get("batch_submit"):
|
||
qids = [
|
||
int(x) for x in form.get("question_ids", [""])[0].split(",")
|
||
if x.strip().isdigit() and int(x) in allowed_ids
|
||
]
|
||
for qid in qids:
|
||
ans_val = (
|
||
form.get(f"custom_{qid}", [""])[0].strip()
|
||
or form.get(f"preset_{qid}", [""])[0].strip()
|
||
)
|
||
if ans_val:
|
||
review.answer(qid, ans_val[:200])
|
||
answered_count += 1
|
||
# Legacy fallback (`id` + `answer`/`custom`)
|
||
elif form.get("id"):
|
||
raw_id = form.get("id", ["0"])[0]
|
||
if not str(raw_id).isdigit():
|
||
raise ClientError(400, "bad id")
|
||
qid = int(raw_id)
|
||
ans_val = form.get("custom", [""])[0].strip() or form.get("answer", [""])[0].strip()
|
||
if qid in allowed_ids and ans_val:
|
||
review.answer(qid, ans_val[:200])
|
||
answered_count += 1
|
||
|
||
return self.redirect(f"{path}?msg=" + urllib.parse.quote(f"已提交 {answered_count} 项确认"))
|
||
|
||
if path == "/review/update-answer":
|
||
form = self.read_form(8192)
|
||
raw_id = form.get("id", ["0"])[0]
|
||
if not str(raw_id).isdigit():
|
||
raise ClientError(400, "bad id")
|
||
qid = int(raw_id)
|
||
if qid > 0:
|
||
if form.get("reopen"):
|
||
review.reopen_question(qid)
|
||
return self.redirect("/review/?msg=" + urllib.parse.quote(f"问题 #{qid} 已撤回至待确认队列"))
|
||
new_ans = safe(form.get("answer", [""])[0], 200)
|
||
if new_ans:
|
||
review.answer(qid, new_ans, allow_update=True)
|
||
return self.redirect("/review/?msg=" + urllib.parse.quote(f"问题 #{qid} 回答已更新"))
|
||
return self.redirect("/review/")
|
||
|
||
m_edit = re.fullmatch(r"/jobs/([0-9a-f]{24})/edit", path)
|
||
if m_edit:
|
||
job = db.get_job(m_edit.group(1))
|
||
if not job:
|
||
return self.send_data(b"Not found", 404, "text/plain")
|
||
form = self.read_form(2 * 1024 * 1024)
|
||
target = form.get("target", [""])[0]
|
||
content = form.get("content", [""])[0]
|
||
if target not in ("draft", "result"):
|
||
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:只能编辑草稿或正式纪要"))
|
||
if not content.strip():
|
||
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:内容为空"))
|
||
if not job.get(target):
|
||
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:还没有这个文件"))
|
||
tpath = Path(str(job[target]))
|
||
if not path_is_under(tpath, artifact_roots()):
|
||
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:文件不在允许的目录"))
|
||
vault.write_draft_file(tpath, content)
|
||
return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("Markdown 修改已保存"))
|
||
|
||
# ---- Lexicon CRUD endpoints ----
|
||
if path == "/lexicon/learn":
|
||
form = self.read_form(8192)
|
||
alias_val = safe(form.get("alias", [""])[0], 40)
|
||
canon_val = safe(form.get("canonical", [""])[0], 40)
|
||
kind_val = safe(form.get("kind", [""])[0], 20)
|
||
note_val = safe(form.get("note", [""])[0], 120)
|
||
tab = safe(form.get("tab", ["aliases"])[0], 20)
|
||
if alias_val and canon_val:
|
||
lexicon.learn_alias(alias_val, canon_val, kind=kind_val, origin="manual", note=note_val)
|
||
lexicon.learn_correction(alias_val, canon_val, note=note_val)
|
||
return self.redirect(f"/lexicon?tab={tab}&msg=" + urllib.parse.quote(f"已添加映射:{alias_val} → {canon_val}"))
|
||
return self.redirect(f"/lexicon?tab={tab}&msg=" + urllib.parse.quote("请填写完整的词条映射"))
|
||
|
||
if path == "/lexicon/alias/update":
|
||
form = self.read_form(8192)
|
||
old_a = safe(form.get("old_alias", [""])[0], 40)
|
||
old_c = safe(form.get("old_canonical", [""])[0], 40)
|
||
new_a = safe(form.get("alias", [""])[0], 40)
|
||
new_c = safe(form.get("canonical", [""])[0], 40)
|
||
kind = safe(form.get("kind", ["人名"])[0], 20)
|
||
conf = 1 if form.get("confirmed") else 0
|
||
note = safe(form.get("note", [""])[0], 120)
|
||
if not (new_a and new_c):
|
||
return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote("未填写完整,没有修改别名"))
|
||
lexicon.update_alias(old_a or new_a, old_c or new_c, new_a, new_c, kind=kind, confirmed=conf, note=note)
|
||
return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote(f"已更新别名:{new_a} → {new_c}"))
|
||
|
||
if path == "/lexicon/alias/delete":
|
||
form = self.read_form(8192)
|
||
old_a = safe(form.get("old_alias", form.get("alias", [""]))[0], 40)
|
||
old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 40)
|
||
removed = lexicon.delete_alias(old_a, old_c)
|
||
msg = f"已删除别名:{old_a}" if removed else f"没有找到别名「{old_a}」,未删除"
|
||
return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote(msg))
|
||
|
||
if path == "/lexicon/correction/update":
|
||
form = self.read_form(8192)
|
||
old_s = safe(form.get("old_surface", [""])[0], 40)
|
||
old_c = safe(form.get("old_canonical", [""])[0], 40)
|
||
new_s = safe(form.get("surface", [""])[0], 40)
|
||
new_c = safe(form.get("canonical", [""])[0], 40)
|
||
conf = 1 if form.get("confirmed") else 0
|
||
note = safe(form.get("note", [""])[0], 120)
|
||
if new_s and new_c:
|
||
lexicon.update_correction(old_s or new_s, old_c or new_c, new_s, new_c, confirmed=conf, note=note)
|
||
return self.redirect("/lexicon?tab=corrections&msg=" + urllib.parse.quote(f"已更新纠错:{new_s} → {new_c}"))
|
||
|
||
if path == "/lexicon/correction/delete":
|
||
form = self.read_form(8192)
|
||
old_s = safe(form.get("old_surface", form.get("surface", [""]))[0], 40)
|
||
old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 40)
|
||
lexicon.delete_correction(old_s, old_c)
|
||
return self.redirect("/lexicon?tab=corrections&msg=" + urllib.parse.quote(f"已删除纠错:{old_s}"))
|
||
|
||
if path == "/lexicon/entity/update":
|
||
form = self.read_form(8192)
|
||
old_c = safe(form.get("old_canonical", [""])[0], 60)
|
||
new_c = safe(form.get("canonical", [""])[0], 60)
|
||
kind = safe(form.get("kind", ["人名"])[0], 20)
|
||
comp = safe(form.get("company", [""])[0], 80)
|
||
role = safe(form.get("role", [""])[0], 80)
|
||
rel = safe(form.get("related", [""])[0], 160)
|
||
if new_c:
|
||
if old_c:
|
||
lexicon.update_entity(old_c, new_c, kind, company=comp, role=role, related=rel)
|
||
else:
|
||
lexicon.upsert_entity(new_c, kind, company=comp, role=role, related=rel)
|
||
return self.redirect("/lexicon?tab=entities&msg=" + urllib.parse.quote(f"已保存实体:{new_c}"))
|
||
|
||
if path == "/lexicon/entity/delete":
|
||
form = self.read_form(8192)
|
||
old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 60)
|
||
lexicon.delete_entity(old_c)
|
||
return self.redirect("/lexicon?tab=entities&msg=" + urllib.parse.quote(f"已删除实体:{old_c}"))
|
||
|
||
if path == "/lexicon/relation/update":
|
||
form = self.read_form(8192)
|
||
old_s = safe(form.get("old_subject", [""])[0], 60)
|
||
old_p = safe(form.get("old_predicate", [""])[0], 40)
|
||
old_o = safe(form.get("old_object", [""])[0], 60)
|
||
old_src = safe(form.get("old_source", ["manual"])[0], 120)
|
||
subj = safe(form.get("subject", [""])[0], 60)
|
||
pred = safe(form.get("predicate", [""])[0], 40)
|
||
obj = safe(form.get("object", [""])[0], 60)
|
||
try:
|
||
conf = float(form.get("confidence", ["0.85"])[0])
|
||
except ValueError:
|
||
conf = 0.85
|
||
act = 1 if form.get("active") else 0
|
||
if subj and pred and obj:
|
||
if old_s and old_p and old_o:
|
||
lexicon.update_relation(old_s, old_p, old_o, old_src, subj, pred, obj, confidence=conf, active=act)
|
||
else:
|
||
lexicon.upsert_relation(subj, pred, obj, confidence=conf, source="manual")
|
||
return self.redirect("/lexicon?tab=relations&msg=" + urllib.parse.quote(f"已保存关系:{subj} {pred} {obj}"))
|
||
|
||
if path == "/lexicon/relation/delete":
|
||
form = self.read_form(8192)
|
||
old_s = safe(form.get("old_subject", form.get("subject", [""]))[0], 60)
|
||
old_p = safe(form.get("old_predicate", form.get("predicate", [""]))[0], 40)
|
||
old_o = safe(form.get("old_object", form.get("object", [""]))[0], 60)
|
||
old_src = safe(form.get("old_source", ["manual"])[0], 120)
|
||
lexicon.delete_relation(old_s, old_p, old_o, old_src)
|
||
return self.redirect("/lexicon?tab=relations&msg=" + urllib.parse.quote(f"已删除关系:{old_s} {old_p} {old_o}"))
|
||
|
||
if path == "/lexicon/sync":
|
||
p_res = vault.pull()
|
||
l_res = lexicon.sync_from_vault(force=True)
|
||
g_res = graph.sync(force=True)
|
||
return self.redirect("/lexicon?msg=" + urllib.parse.quote(f"同步完成(Git:{p_res}):词库 {l_res},图谱 {g_res}"))
|
||
|
||
# ---- SQLite Settings Save ----
|
||
if path == "/settings":
|
||
form = self.read_form(32768)
|
||
updates: dict[str, str] = {}
|
||
text_keys = (
|
||
"litellm_base_url", "clean_model", "keyword_model", "draft_model",
|
||
"asr_provider", "diarize_url", "voiceprint_enroll_url",
|
||
"soniox_api_base", "soniox_model", "gemini_asr_model", "openai_asr_model",
|
||
"grok_api_base", "grok_asr_model", "hindsight_api", "hindsight_bank",
|
||
)
|
||
for k in text_keys:
|
||
if k not in form:
|
||
continue
|
||
text = safe(form[k][0], 200)
|
||
if not text:
|
||
continue
|
||
updates[k] = text
|
||
if "asr_provider" in updates:
|
||
updates["asr_provider"] = providers.normalize_provider_name(updates["asr_provider"])
|
||
for sk in ("litellm_api_key", "soniox_api_key", "grok_api_key", "web_password"):
|
||
raw_k = form.get(sk, [""])[0].strip()
|
||
if raw_k == "CLEAR":
|
||
updates[sk] = ""
|
||
elif raw_k:
|
||
updates[sk] = raw_k
|
||
for bk in ("use_hindsight", "use_termbank", "git_sync"):
|
||
updates[bk] = "1" if form.get(bk) else "0"
|
||
db.save_settings(updates)
|
||
return self.redirect("/settings?msg=" + urllib.parse.quote("配置已保存至 SQLite 并立即生效"))
|
||
|
||
self.send_data(shell(
|
||
"未找到",
|
||
'<section class="card"><h1>页面不存在</h1><p><a href="/">返回任务列表</a></p></section>',
|
||
), 404)
|
||
|
||
|
||
def main() -> None:
|
||
if not webauth.password() and not webauth.allow_anon():
|
||
print(
|
||
"ASR_WEB_PASSWORD is empty; the browser UI stays closed until it is set. "
|
||
"ASR_WEB_ALLOW_ANON=1 opens it with no password.",
|
||
flush=True,
|
||
)
|
||
os.environ.setdefault("ASR_ROLE", "web")
|
||
os.environ.setdefault("ASR_REVISION_QUEUE_DIR", str(REVISION_QUEUE))
|
||
ensure_dirs()
|
||
db.connect().close()
|
||
if os.environ.get("ASR_ROLE", "").lower() == "all":
|
||
from . import worker
|
||
t = threading.Thread(target=worker.run_loop, name="asr-worker", daemon=True)
|
||
t.start()
|
||
port = int(os.environ.get("ASR_PORT", os.environ.get("ASR_REVIEW_PORT", "8788")))
|
||
host = os.environ.get("ASR_HOST", "0.0.0.0")
|
||
server = ThreadingHTTPServer((host, port), Handler)
|
||
server.daemon_threads = True
|
||
server.serve_forever()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|