#!/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""""""
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())