feat(tools-pve/asr-pipeline): multi-backend ASR web app with LiteLLM routing
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s

This commit is contained in:
Codex
2026-11-01 22:07:57 -08:00
parent d1790a05b0
commit 2367c5eb4e
23 changed files with 2462 additions and 451 deletions
@@ -1 +1 @@
"""Standalone ASR service for web, Agent API, and SSH drop-folder intake."""
"""Standalone WebUI, REST API, and background worker service for Meeting ASR."""
@@ -3,23 +3,27 @@ from __future__ import annotations
import os
from pathlib import Path
from asrpipe.config import CFG
DATA = Path(os.getenv("ASR_DATA_DIR", "/data"))
INBOX = DATA / "inbox"
PROCESSING = DATA / "processing"
PROCESSED = DATA / "processed"
FAILED = DATA / "failed"
TRANSCRIPTS = DATA / "transcripts"
STATE = Path(os.getenv("ASR_STATE_DIR", "/state"))
DRAFTS = DATA / "artifacts" / "drafts"
RESULTS = DATA / "artifacts" / "results"
STATE = Path(os.getenv("ASR_STATE_DIR", str(CFG.state_dir)))
DRAFTS = Path(os.getenv("ASR_DRAFTS_DIR", str(DATA / "artifacts" / "drafts")))
RESULTS = Path(os.getenv("ASR_RESULTS_DIR", str(DATA / "artifacts" / "results")))
LOGS = DATA / "logs"
META = STATE / "uploads"
REVISION_QUEUE = STATE / "revision-queue"
REVISION_QUEUE = Path(os.getenv("ASR_REVISION_QUEUE_DIR", str(STATE / "revision-queue")))
DB = STATE / "intake.db"
SUPPORTED = {".m4a", ".mp3", ".wav", ".mp4", ".aac", ".ogg", ".opus", ".webm", ".flac"}
PUBLIC_URL = os.getenv("ASR_PUBLIC_URL", "http://192.168.50.49:8788").rstrip("/")
AGENT_TOKEN = os.getenv("ASR_AGENT_TOKEN", "")
DIARIZE_URL = os.getenv("ASR_DIARIZE_URL", "http://192.168.50.100:10099/asr")
WEB_PASSWORD = os.getenv("ASR_WEB_PASSWORD", "")
DIARIZE_URL = os.getenv("ASR_DIARIZE_URL", CFG.diarize_url)
DEFAULT_PROVIDER = os.getenv("ASR_PROVIDER", CFG.asr_provider)
MAX_UPLOAD = int(os.getenv("ASR_UPLOAD_MAX_BYTES", str(4 * 1024**3)))
POLL_SECONDS = max(1, int(os.getenv("ASR_POLL_SECONDS", "5")))
STABLE_SECONDS = max(1, int(os.getenv("ASR_STABLE_SECONDS", "10")))
@@ -27,8 +31,13 @@ MAX_ATTEMPTS = max(1, int(os.getenv("ASR_MAX_ATTEMPTS", "3")))
def ensure_dirs() -> None:
for path in (INBOX, PROCESSING, PROCESSED, FAILED, TRANSCRIPTS, DRAFTS,
RESULTS, STATE, LOGS, META, REVISION_QUEUE, DATA / "cache",
DATA / "artifacts" / "raw", DATA / "vault" / "minutes",
DATA / "vault" / "People", DATA / "vault" / "Companies"):
# Keep asrpipe CFG aligned with container/service paths when running under asrservice
CFG.state_dir
for path in (
INBOX, PROCESSING, PROCESSED, FAILED, TRANSCRIPTS, DRAFTS,
RESULTS, STATE, LOGS, META, REVISION_QUEUE, DATA / "cache",
DATA / "artifacts" / "raw", DATA / "artifacts" / "results",
DATA / "vault" / "minutes", DATA / "vault" / "People",
DATA / "vault" / "Companies", DATA / "vault" / "Projects",
):
path.mkdir(parents=True, exist_ok=True)
+47 -20
View File
@@ -1,14 +1,15 @@
from __future__ import annotations
import json
import sqlite3
import time
from typing import Any
from .config import DB, ensure_dirs
from .config import DB, DEFAULT_PROVIDER, ensure_dirs
STATUSES = {"uploading", "queued", "transcribing", "refining", "review",
"publishing", "complete", "failed"}
STATUSES = {
"uploading", "queued", "transcribing", "refining", "review",
"publishing", "complete", "failed",
}
def connect() -> sqlite3.Connection:
@@ -26,6 +27,8 @@ def connect() -> sqlite3.Connection:
draft TEXT NOT NULL DEFAULT '', result TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'web', sha256 TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0, error_code TEXT NOT NULL DEFAULT '',
asr_provider TEXT NOT NULL DEFAULT 'qwen3cam',
asr_model TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL, updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS job_event(
@@ -37,9 +40,14 @@ def connect() -> sqlite3.Connection:
""")
cols = {r[1] for r in con.execute("PRAGMA table_info(intake_job)")}
additions = {
"draft": "TEXT NOT NULL DEFAULT ''", "result": "TEXT NOT NULL DEFAULT ''",
"source": "TEXT NOT NULL DEFAULT 'web'", "sha256": "TEXT NOT NULL DEFAULT ''",
"attempts": "INTEGER NOT NULL DEFAULT 0", "error_code": "TEXT NOT NULL DEFAULT ''",
"draft": "TEXT NOT NULL DEFAULT ''",
"result": "TEXT NOT NULL DEFAULT ''",
"source": "TEXT NOT NULL DEFAULT 'web'",
"sha256": "TEXT NOT NULL DEFAULT ''",
"attempts": "INTEGER NOT NULL DEFAULT 0",
"error_code": "TEXT NOT NULL DEFAULT ''",
"asr_provider": f"TEXT NOT NULL DEFAULT '{DEFAULT_PROVIDER}'",
"asr_model": "TEXT NOT NULL DEFAULT ''",
}
for name, ddl in additions.items():
if name not in cols:
@@ -48,23 +56,40 @@ def connect() -> sqlite3.Connection:
return con
def create_job(job_id: str, original_name: str, stored_name: str, *, title: str = "",
context: str = "", size: int = 0, source: str = "web") -> dict[str, Any]:
def create_job(
job_id: str,
original_name: str,
stored_name: str,
*,
title: str = "",
context: str = "",
size: int = 0,
source: str = "web",
asr_provider: str = "",
asr_model: str = "",
) -> dict[str, Any]:
now = time.time()
prov = (asr_provider or DEFAULT_PROVIDER or "qwen3cam").strip().lower()
with connect() as con:
con.execute("INSERT INTO intake_job(id,original_name,stored_name,title,context,size,"
"status,source,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
(job_id, original_name, stored_name, title, context, size,
"queued", source, now, now))
con.execute("INSERT INTO job_event(job_id,status,detail,created_at) VALUES(?,?,?,?)",
(job_id, "queued", "录音已进入队列", now))
con.execute(
"INSERT INTO intake_job(id,original_name,stored_name,title,context,size,"
"status,source,asr_provider,asr_model,created_at,updated_at) "
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
(job_id, original_name, stored_name, title, context, size,
"queued", source, prov, asr_model, now, now))
con.execute(
"INSERT INTO job_event(job_id,status,detail,created_at) VALUES(?,?,?,?)",
(job_id, "queued", f"录音已进入队列 (ASR: {prov})", now))
return get_job(job_id) or {}
def update_job(job_id: str, status: str, detail: str = "", **fields: Any) -> bool:
if status not in STATUSES:
raise ValueError(f"invalid status: {status}")
allowed = {"transcript", "draft", "result", "sha256", "attempts", "error_code"}
allowed = {
"transcript", "draft", "result", "sha256", "attempts",
"error_code", "asr_provider", "asr_model",
}
changes = {k: v for k, v in fields.items() if k in allowed and v is not None}
sets = ["status=?", "updated_at=?", "detail=?"]
values: list[Any] = [status, time.time(), detail[:1000]]
@@ -75,8 +100,9 @@ def update_job(job_id: str, status: str, detail: str = "", **fields: Any) -> boo
with connect() as con:
cur = con.execute(f"UPDATE intake_job SET {','.join(sets)} WHERE id=?", values)
if cur.rowcount:
con.execute("INSERT INTO job_event(job_id,status,detail,created_at) VALUES(?,?,?,?)",
(job_id, status, detail[:1000], time.time()))
con.execute(
"INSERT INTO job_event(job_id,status,detail,created_at) VALUES(?,?,?,?)",
(job_id, status, detail[:1000], time.time()))
return bool(cur.rowcount)
@@ -88,8 +114,9 @@ def get_job(job_id: str) -> dict[str, Any] | None:
def find_by_stored(stored_name: str) -> dict[str, Any] | None:
with connect() as con:
row = con.execute("SELECT * FROM intake_job WHERE stored_name=? ORDER BY created_at DESC LIMIT 1",
(stored_name,)).fetchone()
row = con.execute(
"SELECT * FROM intake_job WHERE stored_name=? ORDER BY created_at DESC LIMIT 1",
(stored_name,)).fetchone()
return dict(row) if row else None
@@ -2,4 +2,6 @@ import time
from .config import STATE
heartbeat = STATE / "worker.heartbeat"
raise SystemExit(0 if heartbeat.exists() and time.time() - heartbeat.stat().st_mtime < 90 else 1)
raise SystemExit(
0 if heartbeat.exists() and time.time() - heartbeat.stat().st_mtime < 90 else 1
)
@@ -15,21 +15,28 @@ def send(status: str, filename: str = "", detail: str = "") -> bool:
chat = os.getenv("TELEGRAM_CHAT_ID", "")
if not token or not chat:
return False
body = parse.urlencode({"chat_id": chat,
"text": "\n".join(x for x in (status, filename, detail) if x)}).encode()
req = request.Request(f"https://api.telegram.org/bot{token}/sendMessage",
data=body, method="POST")
body = parse.urlencode({
"chat_id": chat,
"text": "\n".join(x for x in (status, filename, detail) if x),
}).encode()
req = request.Request(
f"https://api.telegram.org/bot{token}/sendMessage",
data=body, method="POST")
elif backend == "hermes":
url = os.getenv("HERMES_WEBHOOK_URL", "")
secret = os.getenv("HERMES_WEBHOOK_SECRET", "")
if not url or not secret:
return False
body = json.dumps({"status": status, "filename": filename, "detail": detail},
ensure_ascii=False).encode()
body = json.dumps(
{"status": status, "filename": filename, "detail": detail},
ensure_ascii=False).encode()
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
req = request.Request(url, data=body, method="POST",
headers={"Content-Type": "application/json",
"X-Webhook-Signature": sig})
req = request.Request(
url, data=body, method="POST",
headers={
"Content-Type": "application/json",
"X-Webhook-Signature": sig,
})
else:
return False
with request.urlopen(req, timeout=10) as response:
File diff suppressed because it is too large Load Diff
@@ -6,17 +6,17 @@ import json
import os
import shutil
import sqlite3
import subprocess
import time
import uuid
from pathlib import Path
from asrpipe import refine, review, revise, vault
from asrpipe import graph, lexicon, providers, refine, review, revise, vault
from . import db
from .config import (DIARIZE_URL, DRAFTS, FAILED, INBOX, MAX_ATTEMPTS, META,
POLL_SECONDS, PROCESSED, PROCESSING, PUBLIC_URL,
REVISION_QUEUE, STABLE_SECONDS, STATE, SUPPORTED, TRANSCRIPTS, LOGS,
ensure_dirs)
from .config import (
DATA, DEFAULT_PROVIDER, DRAFTS, FAILED, INBOX, LOGS, MAX_ATTEMPTS,
POLL_SECONDS, PROCESSED, PROCESSING, PUBLIC_URL, REVISION_QUEUE,
STABLE_SECONDS, STATE, SUPPORTED, TRANSCRIPTS, ensure_dirs,
)
from .notify import send
@@ -35,69 +35,83 @@ def sha256(path: Path) -> str:
def transcript_from_response(payload: dict) -> str:
segments = payload.get("segments")
if not isinstance(segments, list) or not segments:
raise RuntimeError("ASR response contains no segments")
vp = payload.get("voiceprint") or {}
sid = str(vp.get("session_id") or "")
matched = [f"{m.get('speaker_local')}={m.get('person_name')}" for m in vp.get("matches", [])
if m.get("status") == "matched"]
speakers = {str(s.get("speaker")) for s in segments if s.get("speaker") is not None}
lines = [f"<!-- voiceprint_session: {sid} clusters: {len(speakers)} -->",
"# 转写(Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹)"
+ (" | 声纹命名: " + ", ".join(matched) if matched else ""), ""]
turns: list[list[str]] = []
for segment in segments:
speaker, text = str(segment.get("speaker", "?")), str(segment.get("text") or "").strip()
if not text:
continue
if turns and turns[-1][0] == speaker:
turns[-1][1] += text
else:
turns.append([speaker, text])
lines.extend(f"【{speaker}】{text}\n" for speaker, text in turns)
return "\n".join(lines).strip() + "\n"
"""Backward-compatible helper for qwen3cam JSON responses."""
return providers.transcript_from_qwen3cam_response(payload)
def transcribe(audio: Path, job_id: str) -> Path:
def transcribe(
audio: Path,
job_id: str,
*,
provider: str = "",
model: str = "",
title: str = "",
user_context: str = "",
) -> tuple[Path, providers.TranscribeResult]:
res = providers.transcribe_audio(
audio,
provider=provider or DEFAULT_PROVIDER,
model=model,
title=title,
user_context=user_context,
)
response_path = TRANSCRIPTS / f"{job_id}.asr.json"
result = subprocess.run([
"curl", "-sS", "--fail-with-body", "--max-time", "900",
"-F", f"file=@{audio}", "-F", "language=Chinese",
"-F", "save_voiceprint_session=true", DIARIZE_URL,
], capture_output=True, timeout=930)
if result.returncode:
raise RuntimeError(f"ASR HTTP failed ({result.returncode})")
response_path.write_bytes(result.stdout)
try:
payload = json.loads(result.stdout)
except ValueError as exc:
raise RuntimeError("ASR returned invalid JSON") from exc
response_path.write_text(
json.dumps(res.raw_response, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception:
pass
transcript = TRANSCRIPTS / f"{job_id}.txt"
transcript.write_text(transcript_from_response(payload), encoding="utf-8")
return transcript
transcript.write_text(res.transcript_text, encoding="utf-8")
return transcript, res
def process_audio(source: Path, job: dict) -> None:
job_id = job["id"]
attempts = int(job.get("attempts") or 0) + 1
prov = providers.normalize_provider_name(job.get("asr_provider") or DEFAULT_PROVIDER)
model = str(job.get("asr_model") or "")
digest = sha256(source)
db.update_job(job_id, "transcribing", "正在进行分角色转写", sha256=digest,
attempts=attempts, error_code="")
transcript = transcribe(source, job_id)
intake = {"job_id": job_id, "title": job.get("title", ""),
"context": job.get("context", ""), "source": job.get("source", "")}
prov_label = providers.PROVIDERS.get(prov, {}).get("label", prov)
db.update_job(
job_id, "transcribing", f"正在转写 ({prov_label})",
sha256=digest, attempts=attempts, error_code="",
asr_provider=prov, asr_model=model,
)
transcript, res = transcribe(
source, job_id,
provider=prov, model=model,
title=str(job.get("title") or ""),
user_context=str(job.get("context") or ""),
)
intake = {
"job_id": job_id,
"title": job.get("title", ""),
"context": job.get("context", ""),
"source": job.get("source", ""),
"asr_provider": res.provider,
"asr_model": res.model,
}
raw = transcript.read_text(encoding="utf-8")
raw = f"<!-- intake: {json.dumps(intake, ensure_ascii=False)} -->\n" + raw
transcript.write_text(raw, encoding="utf-8")
db.update_job(job_id, "refining", "转写完成,正在生成整理稿", transcript=str(transcript))
result = refine.refine(raw, date=time.strftime("%Y-%m-%d"),
transcript_path=str(transcript), queue_review=True)
db.update_job(
job_id, "refining", f"转写完成 ({res.provider}:{res.model}),正在生成整理稿",
transcript=str(transcript), asr_provider=res.provider, asr_model=res.model,
)
result = refine.refine(
raw, date=time.strftime("%Y-%m-%d"),
transcript_path=str(transcript), queue_review=True,
)
draft = DRAFTS / f"{job_id}.md"
vault.write_draft_file(draft, result.draft)
questions = review.enqueue_draft(str(transcript), draft)
db.update_job(job_id, "review", f"整理稿已生成,{questions} 个问题待确认",
transcript=str(transcript), draft=str(draft))
db.update_job(
job_id, "review", f"整理稿已生成,{questions} 个问题待确认",
transcript=str(transcript), draft=str(draft),
)
target = PROCESSED / source.name
if target.exists():
target = PROCESSED / f"{source.stem}-{job_id[:8]}{source.suffix}"
@@ -110,8 +124,11 @@ def claim(path: Path) -> tuple[Path, dict]:
job = db.find_by_stored(path.name)
if not job:
job_id = uuid.uuid4().hex[:24]
job = db.create_job(job_id, path.name, path.name, title=path.stem,
size=path.stat().st_size, source="ssh")
job = db.create_job(
job_id, path.name, path.name, title=path.stem,
size=path.stat().st_size, source="ssh",
asr_provider=DEFAULT_PROVIDER,
)
target = PROCESSING / path.name
if target.exists():
target = PROCESSING / f"{path.stem}-{job['id'][:8]}{path.suffix}"
@@ -132,10 +149,12 @@ def scan_inbox() -> None:
except Exception as exc:
attempts = int((db.get_job(job["id"]) or {}).get("attempts") or 0)
retry = attempts < MAX_ATTEMPTS
db.update_job(job["id"], "queued" if retry else "failed",
"处理失败,等待重试" if retry else "处理失败",
error_code=type(exc).__name__)
log(f"job={job['id']} failed type={type(exc).__name__}")
db.update_job(
job["id"], "queued" if retry else "failed",
f"处理失败,等待重试 ({exc})" if retry else f"处理失败:{exc}",
error_code=type(exc).__name__,
)
log(f"job={job['id']} failed type={type(exc).__name__} err={exc}")
target = INBOX / source.name if retry else FAILED / source.name
if source.exists():
shutil.move(str(source), target)
@@ -151,27 +170,41 @@ def process_revisions() -> None:
revise.revise(draft)
current = db.get_job(job_id) or {}
if current.get("status") == "complete":
send("ASR 正式记录已生成", current.get("title") or current.get("original_name", ""),
send("ASR 正式记录已生成",
current.get("title") or current.get("original_name", ""),
f"{PUBLIC_URL}/jobs/{job_id}/")
elif current.get("status") != "review":
db.update_job(job_id, "review", "定稿预览已生成,等待发布确认", draft=str(draft))
db.update_job(
job_id, "review", "定稿预览已生成,等待发布确认",
draft=str(draft),
)
marker.unlink(missing_ok=True)
except Exception as exc:
log(f"revision marker={marker.name} failed type={type(exc).__name__}")
log(f"revision marker={marker.name} failed type={type(exc).__name__} err={exc}")
def recover() -> None:
for job in db.pending_recovery():
if job["status"] in {"transcribing", "refining"}:
candidate = next((p for p in PROCESSING.iterdir() if p.name == job["stored_name"]), None)
candidate = next(
(p for p in PROCESSING.iterdir() if p.name == job["stored_name"]),
None,
)
if candidate:
shutil.move(str(candidate), INBOX / candidate.name)
db.update_job(job["id"], "queued", "worker 重启后重新排队")
def sync_vault_knowledge() -> None:
try:
lexicon.sync_from_vault()
graph.sync()
except Exception as exc:
log(f"vault knowledge sync skipped: {exc}")
def backup_databases() -> None:
# STATE is local disk; durable copies belong on the NFS data volume.
backup_dir = Path("/data/backups")
backup_dir = DATA / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
for name in ("intake.db", "lexicon.db"):
source = STATE / name
@@ -184,22 +217,34 @@ def backup_databases() -> None:
temp.replace(target)
def main() -> None:
def run_loop() -> None:
os.environ.setdefault("ASR_ROLE", "worker")
os.environ.setdefault("ASR_REVISION_QUEUE_DIR", str(REVISION_QUEUE))
ensure_dirs()
lock = (STATE / "worker.lock").open("w")
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
recover()
sync_vault_knowledge()
(STATE / "worker.heartbeat").write_text(str(time.time()))
last_backup = 0.0
last_vault_sync = time.time()
while True:
scan_inbox()
process_revisions()
if time.time() - last_backup >= 3600:
now = time.time()
if now - last_vault_sync >= 900:
sync_vault_knowledge()
last_vault_sync = now
if now - last_backup >= 3600:
backup_databases()
last_backup = time.time()
(STATE / "worker.heartbeat").write_text(str(time.time()))
last_backup = now
(STATE / "worker.heartbeat").write_text(str(now))
time.sleep(POLL_SECONDS)
def main() -> None:
run_loop()
if __name__ == "__main__":
main()