deploy standalone ASR pipeline on tools-pve
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ephemeral, capability-URL mobile UI for the human review queue."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from . import review
|
||||
from .config import CFG
|
||||
from .log import log
|
||||
|
||||
STATE = CFG.state_dir / "review-web.json"
|
||||
UNIT = "asr-review-web.service"
|
||||
|
||||
|
||||
def _url(token: str) -> str:
|
||||
return f"http://{CFG.review_public_host}:{CFG.review_port}/r/{token}/"
|
||||
|
||||
|
||||
def request_start(job_id: str = "") -> bool:
|
||||
"""Ensure control service is running and send a task-scoped review URL."""
|
||||
try:
|
||||
r = subprocess.run(["systemctl", "--user", "start", UNIT],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
if r.returncode:
|
||||
log(f"review web start failed: {r.stderr.strip()[:240]}", level="WARN")
|
||||
return False
|
||||
from . import control_web
|
||||
job = control_web._job(job_id) if job_id else None
|
||||
if job_id and not job:
|
||||
log(f"review notification missing job: {job_id}", level="WARN")
|
||||
return False
|
||||
url = control_web._review_url(job_id)
|
||||
rows = (control_web._pending_for_job(job, 1000)
|
||||
if job else review.pending(1000))
|
||||
from . import vault
|
||||
n = len(rows)
|
||||
title = str(job.get("title") or job["original_name"]) if job else "ASR"
|
||||
vault.notify("ASR 快速确认",
|
||||
f"{title}:{n} 个问题待确认\n{url}\n"
|
||||
f"录音上传入口:http://{CFG.review_public_host}:{CFG.review_port}/",
|
||||
redact_log=True)
|
||||
return True
|
||||
except Exception as e:
|
||||
log(f"review web start failed: {e}", level="WARN")
|
||||
return False
|
||||
|
||||
|
||||
def read_state() -> dict | None:
|
||||
try:
|
||||
return json.loads(STATE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_state(token: str, started: float) -> None:
|
||||
CFG.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"pid": os.getpid(), "url": _url(token), "port": CFG.review_port,
|
||||
"started_at": started, "expires_at": started + CFG.review_max_seconds,
|
||||
}
|
||||
tmp = STATE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(STATE)
|
||||
|
||||
|
||||
def _page(title: str, body: str) -> bytes:
|
||||
return f"""<!doctype html>
|
||||
<html lang="zh-CN"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta http-equiv="Cache-Control" content="no-store">
|
||||
<title>{html.escape(title)}</title>
|
||||
<style>
|
||||
:root{{--ink:#17202a;--muted:#637083;--line:#d9dee5;--paper:#f7f8fa;
|
||||
--accent:#176b5b;--accent2:#c84b31;--white:#fff}}
|
||||
*{{box-sizing:border-box}} body{{margin:0;background:var(--paper);color:var(--ink);
|
||||
font-family:system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||
font-size:17px;line-height:1.55;letter-spacing:0}}
|
||||
header{{background:var(--white);border-bottom:1px solid var(--line);padding:16px 18px}}
|
||||
header strong{{font-size:18px}} header span{{display:block;color:var(--muted);font-size:14px}}
|
||||
main{{max-width:680px;margin:0 auto;padding:18px 14px 40px}}
|
||||
.question{{background:var(--white);border:1px solid var(--line);border-radius:8px;padding:18px}}
|
||||
h1{{font-size:21px;line-height:1.35;margin:0 0 12px}} .source{{margin:14px 0;
|
||||
padding:12px;border-left:4px solid #d4a72c;background:#fff9e8;color:#39424e}}
|
||||
form{{display:grid;gap:10px}} button{{width:100%;min-height:50px;border:0;border-radius:6px;
|
||||
padding:12px 14px;background:var(--accent);color:white;font:inherit;font-weight:650}}
|
||||
button.secondary{{background:#e9edf1;color:var(--ink);border:1px solid var(--line)}}
|
||||
input{{width:100%;min-height:50px;border:1px solid #9aa5b1;border-radius:6px;
|
||||
padding:10px 12px;font:inherit;background:white}} .custom{{display:flex;gap:8px}}
|
||||
.custom input{{min-width:0;flex:1}} .custom button{{width:auto;min-width:82px}}
|
||||
.meta{{color:var(--muted);font-size:14px;margin-bottom:10px}} .done{{text-align:center;
|
||||
background:var(--white);border:1px solid var(--line);border-radius:8px;padding:36px 18px}}
|
||||
.draft{{margin:14px 0;border:1px solid var(--line);border-radius:6px;background:#fbfcfd}}
|
||||
.draft summary{{padding:12px 14px;font-weight:650;cursor:pointer}}
|
||||
.draft pre{{margin:0;padding:14px;border-top:1px solid var(--line);max-height:58vh;
|
||||
overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.55 ui-monospace,
|
||||
SFMono-Regular,Consolas,monospace}}
|
||||
.done h1{{color:var(--accent)}} @media(max-width:420px){{main{{padding:12px 10px 28px}}
|
||||
.question{{padding:15px}} .custom{{display:grid}} .custom button{{width:100%}}}}
|
||||
</style></head><body><header><strong>ASR 快速确认</strong>
|
||||
<span>确认结果会写入个人事实库,并用于后续录音</span></header>
|
||||
<main>{body}</main></body></html>""".encode("utf-8")
|
||||
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self, addr, handler, token: str):
|
||||
super().__init__(addr, handler)
|
||||
self.token = token
|
||||
self.started_at = time.time()
|
||||
self.last_activity = self.started_at
|
||||
self.opened = False
|
||||
self.finish_at: float | None = None
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "ASRReview/1"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
# BaseHTTPRequestHandler includes the full URL in its default log line.
|
||||
# The path is a bearer capability, so never persist it.
|
||||
status = args[1] if len(args) > 1 else "?"
|
||||
log(f"review web request: {self.command} status={status}")
|
||||
|
||||
def _headers(self, status=HTTPStatus.OK, content_type="text/html; charset=utf-8"):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
self.send_header("Pragma", "no-cache")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Content-Security-Policy",
|
||||
"default-src 'none'; style-src 'unsafe-inline'; "
|
||||
"form-action 'self'; base-uri 'none'; frame-ancestors 'none'")
|
||||
|
||||
def _valid_path(self) -> bool:
|
||||
parts = urllib.parse.urlsplit(self.path)
|
||||
want = f"/r/{self.server.token}/"
|
||||
return hmac.compare_digest(parts.path, want)
|
||||
|
||||
def _send(self, data: bytes, status=HTTPStatus.OK):
|
||||
self._headers(status)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
body = json.dumps({"ok": True, "pending": len(review.pending(100))}).encode()
|
||||
self._headers(content_type="application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if not self._valid_path():
|
||||
self._send(b"Not found", HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
self.server.last_activity = time.time()
|
||||
self.server.opened = True
|
||||
rows = review.pending(100)
|
||||
if not rows:
|
||||
if self.server.finish_at is None:
|
||||
self.server.finish_at = time.time() + 10
|
||||
self._send(_page("完成",
|
||||
'<section class="done"><h1>本轮确认完成</h1>'
|
||||
'<p>结果已经写入个人事实库,本页面即将自动关闭。</p></section>'))
|
||||
return
|
||||
row = rows[0]
|
||||
payload = json.loads(row["payload"] or "{}")
|
||||
choices = payload.get("candidates") or []
|
||||
buttons = []
|
||||
for value in choices:
|
||||
v = html.escape(str(value), quote=True)
|
||||
buttons.append(
|
||||
f'<button type="submit" name="answer" value="{v}">{v}</button>')
|
||||
buttons.append(
|
||||
'<button class="secondary" type="submit" name="answer" '
|
||||
'value="无法确认,保留原文">都不是 / 无法确认</button>')
|
||||
source = html.escape(str(payload.get("sentence", "")))
|
||||
source_html = f'<div class="source">{source}</div>' if source else ""
|
||||
draft_html = ""
|
||||
draft_path = payload.get("draft_path")
|
||||
if draft_path:
|
||||
try:
|
||||
path = Path(draft_path).resolve()
|
||||
path.relative_to(CFG.drafts.resolve())
|
||||
draft = path.read_text(encoding="utf-8", errors="ignore")
|
||||
draft_html = (
|
||||
'<details class="draft"><summary>查看完整草稿</summary><pre>'
|
||||
+ html.escape(draft) + "</pre></details>")
|
||||
except (OSError, ValueError):
|
||||
draft_html = ""
|
||||
placeholder = ("只输入姓名,例如:高瑶"
|
||||
if payload.get("kind") == "voiceprint"
|
||||
else ("背景不准时,在这里写明正确背景并提交重跑"
|
||||
if payload.get("kind") == "background_check"
|
||||
else "输入正确名称或补充说明"))
|
||||
body = f"""<section class="question">
|
||||
<div class="meta">剩余 {len(rows)} 个 · 问题 #{row['id']}</div>
|
||||
<h1>{html.escape(row['question'])}</h1>{source_html}{draft_html}
|
||||
<form method="post" action="/r/{self.server.token}/">
|
||||
<input type="hidden" name="id" value="{row['id']}">
|
||||
{''.join(buttons)}
|
||||
<div class="custom"><input name="custom" maxlength="120" autocomplete="off"
|
||||
placeholder="{html.escape(placeholder, quote=True)}"><button type="submit">提交</button></div>
|
||||
</form></section>"""
|
||||
self._send(_page("ASR 快速确认", body))
|
||||
|
||||
def do_POST(self):
|
||||
if not self._valid_path():
|
||||
self._send(b"Not found", HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
try:
|
||||
length = min(int(self.headers.get("Content-Length", "0")), 4096)
|
||||
form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8"))
|
||||
qid = int(form.get("id", ["0"])[0])
|
||||
custom = form.get("custom", [""])[0].strip()
|
||||
answer = custom or form.get("answer", [""])[0].strip()
|
||||
if not qid or not answer or len(answer) > 120:
|
||||
raise ValueError("invalid answer")
|
||||
pending_ids = {r["id"] for r in review.pending(1000)}
|
||||
if qid not in pending_ids:
|
||||
raise ValueError("问题已处理或不存在")
|
||||
review.answer(qid, answer)
|
||||
self.server.last_activity = time.time()
|
||||
self.send_response(HTTPStatus.SEE_OTHER)
|
||||
self.send_header("Location", f"/r/{self.server.token}/")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
except Exception as e:
|
||||
self._send(_page("提交失败",
|
||||
f'<section class="done"><h1>提交失败</h1><p>{html.escape(str(e))}</p></section>'),
|
||||
HTTPStatus.BAD_REQUEST)
|
||||
|
||||
|
||||
def serve() -> int:
|
||||
rows = review.pending(100)
|
||||
if not rows:
|
||||
log("review web: no pending questions")
|
||||
return 0
|
||||
token = secrets.token_urlsafe(32)
|
||||
started = time.time()
|
||||
try:
|
||||
server = Server((CFG.review_host, CFG.review_port), Handler, token)
|
||||
except OSError as e:
|
||||
STATE.unlink(missing_ok=True)
|
||||
log(f"review web bind failed on {CFG.review_host}:{CFG.review_port}: {e}",
|
||||
level="ERROR")
|
||||
return 1
|
||||
_write_state(token, started)
|
||||
server.timeout = 1
|
||||
url = _url(token)
|
||||
log(f"review web listening on {CFG.review_public_host}:{CFG.review_port} "
|
||||
f"({len(rows)} question(s))")
|
||||
try:
|
||||
from . import vault
|
||||
vault.notify("ASR 快速确认",
|
||||
f"{len(rows)} 个问题待确认:{url}\n"
|
||||
f"链接最多保留 {CFG.review_max_seconds // 60} 分钟,完成后自动销毁。",
|
||||
redact_log=True)
|
||||
except Exception as e:
|
||||
log(f"review web notification failed: {e}", level="WARN")
|
||||
try:
|
||||
while True:
|
||||
server.handle_request()
|
||||
now = time.time()
|
||||
if server.finish_at and now >= server.finish_at:
|
||||
# A new recording can enqueue work during the 10-second grace
|
||||
# period while systemd still considers this unit active.
|
||||
if review.pending(1):
|
||||
server.finish_at = None
|
||||
log("review web received new work during shutdown grace")
|
||||
else:
|
||||
break
|
||||
if now - started >= CFG.review_max_seconds:
|
||||
log("review web reached maximum lifetime")
|
||||
break
|
||||
if server.opened and now - server.last_activity >= CFG.review_idle_seconds:
|
||||
log("review web idle timeout")
|
||||
break
|
||||
finally:
|
||||
server.server_close()
|
||||
try:
|
||||
STATE.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(serve())
|
||||
Reference in New Issue
Block a user