#!/usr/bin/env python3 """Persistent mobile control plane for recording intake and human review.""" from __future__ import annotations import html import json import os import re import secrets import sqlite3 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" DB = CFG.state_dir / "intake.db" RECORDINGS = Path(os.environ.get( "ASR_RECORDINGS_ROOT", str(CFG.obsidian / "recordings"))) INBOX = RECORDINGS / "inbox" META = RECORDINGS / "state" / "uploads" MAX_UPLOAD = int(os.environ.get("ASR_UPLOAD_MAX_BYTES", str(4 * 1024**3))) CHUNK = 1024 * 1024 SUPPORTED = {".m4a", ".mp3", ".wav", ".mp4", ".aac", ".ogg", ".opus", ".webm", ".flac"} STATUS_LABEL = { "uploading": "上传中", "queued": "等待转写", "transcribing": "正在转写", "refining": "正在整理", "review": "等待确认", "publishing": "正在发布", "complete": "已完成", "failed": "处理失败", } def _connect() -> sqlite3.Connection: CFG.state_dir.mkdir(parents=True, exist_ok=True) con = sqlite3.connect(DB, timeout=20) con.row_factory = sqlite3.Row con.execute("PRAGMA journal_mode=WAL") con.execute(""" CREATE TABLE IF NOT EXISTS intake_job( id TEXT PRIMARY KEY, original_name TEXT NOT NULL, stored_name TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', context TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', transcript TEXT NOT NULL DEFAULT '', created_at REAL NOT NULL, updated_at REAL NOT NULL ) """) columns = {row["name"] for row in con.execute("PRAGMA table_info(intake_job)")} for name in ("draft", "result"): if name not in columns: con.execute( f"ALTER TABLE intake_job ADD COLUMN {name} TEXT NOT NULL DEFAULT ''") return con def update_job(job_id: str, status: str, detail: str = "", transcript: str = "", draft: str = "", result: str = "") -> bool: if os.environ.get("ASR_ROLE"): from asrservice.db import update_job as service_update_job return service_update_job(job_id, status, detail, transcript=transcript, draft=draft, result=result) if not job_id: return False con = _connect() fields = ["status=?", "updated_at=?"] values: list[object] = [status, time.time()] if detail: fields.append("detail=?") values.append(detail[:1000]) if transcript: fields.append("transcript=?") values.append(transcript) if draft: fields.append("draft=?") values.append(draft) if result: fields.append("result=?") values.append(result) values.append(job_id) cur = con.execute( f"UPDATE intake_job SET {','.join(fields)} WHERE id=?", values) con.commit() con.close() return cur.rowcount > 0 def _jobs(limit: int = 30) -> list[dict]: con = _connect() rows = con.execute( "SELECT * FROM intake_job ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() con.close() return [dict(r) for r in rows] def _job(job_id: str) -> dict | None: con = _connect() row = con.execute("SELECT * FROM intake_job WHERE id=?", (job_id,)).fetchone() con.close() return dict(row) if row else None def _review_url(job_id: str = "") -> str: base = f"http://{CFG.review_public_host}:{CFG.review_port}" return f"{base}/jobs/{job_id}/review/" if job_id else f"{base}/review/" def _pending_for_job(job: dict, limit: int = 200) -> list[dict]: transcript = str(job.get("transcript") or "") draft = str(job.get("draft") or "") rows = [] for row in review.pending(limit): payload = json.loads(row["payload"] or "{}") if ((transcript and row["recording"] == transcript) or (draft and payload.get("draft_path") == draft) or payload.get("job_id") == job["id"]): rows.append(row) return rows def _safe_text(value: str, limit: int) -> str: value = re.sub(r"[\x00-\x1f\x7f]+", " ", value or "") return re.sub(r"\s+", " ", value).strip()[:limit] def _safe_stem(value: str) -> str: value = _safe_text(value, 80) value = re.sub(r"[^\w\u3400-\u9fff.-]+", "-", value, flags=re.UNICODE) return value.strip("-.") or "录音" def _write_state() -> None: CFG.state_dir.mkdir(parents=True, exist_ok=True) data = { "pid": os.getpid(), "url": _review_url(), "home": f"http://{CFG.review_public_host}:{CFG.review_port}/", "port": CFG.review_port, "started_at": time.time(), } 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 _shell(title: str, body: str, script: str = "") -> bytes: return f""" {html.escape(title)}
录音工作台
{body}
{script}""".encode("utf-8") def _home() -> bytes: body = """

上传录音

支持 mp3、m4a、wav、mp4、aac、ogg、opus、webm、flac。上传完成后自动进入现有 ASR 流程。

最近任务

正在读取…
""" script = """""" return _shell("录音工作台", body, script) def _review_page(action: str, job: dict | None = None) -> bytes: rows = _pending_for_job(job) if job else review.pending(100) if not rows: if job: label = STATUS_LABEL.get(job["status"], job["status"]) detail = html.escape(str(job.get("detail") or "")) return _shell("任务状态", f'
{html.escape(label)}
' f'

{html.escape(str(job.get("title") or job["original_name"]))}

' f'

{detail or "当前没有待确认问题。"}

' '

页面会随任务阶段更新,可以稍后刷新。

') return _shell("确认完成", '

当前没有待确认项目

' '

新草稿生成后,Telegram 会再次发送这里的链接。

') row = rows[0] payload = json.loads(row["payload"] or "{}") choices = payload.get("candidates") or [] buttons = "".join( f'' for v in choices) buttons += ('') source = html.escape(str(payload.get("sentence", ""))) source_html = f'
{source}
' if source else "" draft_html = "" if payload.get("draft_path"): try: path = Path(payload["draft_path"]).resolve() path.relative_to(CFG.drafts.resolve()) draft = path.read_text(encoding="utf-8", errors="ignore") draft_html = ('
查看完整草稿
'
                          + html.escape(draft) + "
") except (OSError, ValueError): pass placeholder = ("只输入姓名,例如:高瑶" if payload.get("kind") == "voiceprint" else ("背景不准时,写明正确背景并提交重跑" if payload.get("kind") == "background_check" else "输入正确名称或补充说明")) title = (str(job.get("title") or job["original_name"]) if job else "ASR 快速确认") body = f"""
{html.escape(title)} · 剩余 {len(rows)} 个 · 问题 #{row['id']}

{html.escape(row['question'])}

{source_html}{draft_html}
{buttons}
""" return _shell("ASR 快速确认", body) class Server(ThreadingHTTPServer): daemon_threads = True allow_reuse_address = True def __init__(self, addr, handler): super().__init__(addr, handler) class Handler(BaseHTTPRequestHandler): server_version = "ASRControl/1" def log_message(self, fmt, *args): status = args[1] if len(args) > 1 else "?" log(f"control web: {self.command} {urllib.parse.urlsplit(self.path).path} 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("X-Content-Type-Options", "nosniff") def _send(self, data: bytes, status=HTTPStatus.OK, content_type="text/html; charset=utf-8"): self._headers(status, content_type) self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def _json(self, obj: object, status=HTTPStatus.OK): self._send(json.dumps(obj, ensure_ascii=False).encode("utf-8"), status, "application/json; charset=utf-8") def _review_job(self) -> tuple[bool, dict | None]: path = urllib.parse.urlsplit(self.path).path if path == "/review/": return True, None match = re.fullmatch(r"/jobs/([0-9a-z-]{8,64})/review/", path) if match: return True, _job(match.group(1)) return False, None def do_GET(self): path = urllib.parse.urlsplit(self.path).path if path == "/": return self._send(_home()) if path == "/health": return self._json({"ok": True, "pending": len(review.pending(100))}) if path == "/api/jobs": jobs = _jobs() for j in jobs: j["status_label"] = STATUS_LABEL.get(j["status"], j["status"]) j["review_url"] = f"/jobs/{j['id']}/review/" j["has_draft"] = bool(j.get("draft")) j.pop("context", None) j.pop("transcript", None) j.pop("draft", None) j.pop("result", None) return self._json({"jobs": jobs}) is_review, job = self._review_job() if is_review: if path != "/review/" and not job: return self._send(b"Task not found", HTTPStatus.NOT_FOUND, "text/plain") return self._send(_review_page(path, job)) return self._send(b"Not found", HTTPStatus.NOT_FOUND, "text/plain") def _header(self, name: str, limit: int) -> str: return _safe_text(urllib.parse.unquote(self.headers.get(name, "")), limit) def _upload(self): try: length = int(self.headers.get("Content-Length", "0")) except ValueError: length = 0 if length <= 0 or length > MAX_UPLOAD: return self._json({"error": "invalid upload size"}, HTTPStatus.REQUEST_ENTITY_TOO_LARGE) original = self._header("X-Filename", 240) title = self._header("X-Title", 80) context = self._header("X-Context", 2000) suffix = Path(original).suffix.lower() if suffix not in SUPPORTED: return self._json({"error": f"unsupported file type: {suffix or '(none)'}"}, HTTPStatus.UNSUPPORTED_MEDIA_TYPE) job_id = secrets.token_hex(12) stem = _safe_stem(title or Path(original).stem) stored = f"{time.strftime('%Y-%m-%d_%H_%M_%S')}-{stem}-{job_id[:8]}{suffix}" INBOX.mkdir(parents=True, exist_ok=True) staging = INBOX / ".uploading" staging.mkdir(parents=True, exist_ok=True) target = INBOX / stored part = staging / f"{job_id}.part" con = _connect() now = time.time() con.execute( "INSERT INTO intake_job(id,original_name,stored_name,title,context,size," "status,created_at,updated_at) VALUES(?,?,?,?,?,?,?, ?,?)", (job_id, original, stored, title, context, length, "uploading", now, now)) con.commit() con.close() remaining = length try: with part.open("xb") as f: while remaining: data = self.rfile.read(min(CHUNK, remaining)) if not data: raise EOFError("upload ended early") f.write(data) remaining -= len(data) f.flush() os.fsync(f.fileno()) META.mkdir(parents=True, exist_ok=True) meta = { "job_id": job_id, "title": title, "context": context, "original_name": original, "stored_name": stored, } meta_tmp = META / f".{stored}.tmp" meta_tmp.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8") meta_tmp.replace(META / f"{stored}.json") part.replace(target) update_job(job_id, "queued", "录音已落盘,等待 watcher 接收") return self._json({"ok": True, "job_id": job_id, "stored_name": stored}, HTTPStatus.CREATED) except Exception as e: part.unlink(missing_ok=True) update_job(job_id, "failed", f"上传失败:{e}") return self._json({"error": str(e)}, HTTPStatus.INTERNAL_SERVER_ERROR) def _answer(self): 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]) answer = (form.get("custom", [""])[0].strip() or form.get("answer", [""])[0].strip()) if not qid or not answer or len(answer) > 120: raise ValueError("invalid answer") is_review, job = self._review_job() if not is_review: raise ValueError("invalid review path") allowed = (_pending_for_job(job, 1000) if job else review.pending(1000)) if qid not in {row["id"] for row in allowed}: raise ValueError("问题不属于当前任务或已处理") review.answer(qid, answer) self.send_response(HTTPStatus.SEE_OTHER) self.send_header("Location", urllib.parse.urlsplit(self.path).path) self.send_header("Cache-Control", "no-store") self.end_headers() except Exception as e: self._send(_shell("提交失败", f'

提交失败

{html.escape(str(e))}

'), HTTPStatus.BAD_REQUEST) def do_POST(self): path = urllib.parse.urlsplit(self.path).path if path in ("/api/upload", "/hook/intake"): return self._upload() if self._review_job()[0]: return self._answer() return self._send(b"Not found", HTTPStatus.NOT_FOUND, "text/plain") def serve() -> int: try: server = Server((CFG.review_host, CFG.review_port), Handler) except OSError as e: STATE.unlink(missing_ok=True) log(f"control web bind failed on {CFG.review_host}:{CFG.review_port}: {e}", level="ERROR") return 1 _write_state() log(f"control web listening on {CFG.review_public_host}:{CFG.review_port}") try: server.serve_forever(poll_interval=0.5) finally: server.server_close() STATE.unlink(missing_ok=True) return 0 if __name__ == "__main__": raise SystemExit(serve())