fix(tools-pve/asr-pipeline): mount gitea ssh key for Obsidian vault sync and fix provider source_detail
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s

This commit is contained in:
Codex
2026-11-02 02:57:42 -08:00
parent c42456be22
commit 56e0305188
10 changed files with 156 additions and 45 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ FROM python:3.13-slim-bookworm@sha256:ed86c82274b3c69b52fb5820f358f0bd7df0b60333
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl ffmpeg git tini \
ca-certificates curl ffmpeg git openssh-client tini \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -16,12 +16,18 @@ import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass, field
import http.client
from pathlib import Path
from typing import Any
from .config import CFG
from .log import log
class EmptyAudioError(RuntimeError):
"""Raised when an audio file contains silence/noise or produces zero ASR speech tokens."""
pass
PROVIDERS: dict[str, dict[str, str]] = {
"qwen3cam": {
"label": "内网 Qwen3-ASR 1.7B + CAM++ (分角色 + 声纹)",
@@ -203,7 +209,9 @@ def format_diarized_transcript(
matched_names: list[str] | None = None,
) -> str:
if not segments:
raise RuntimeError(f"ASR ({provider}) response contains no segments")
raise EmptyAudioError(
f"ASR ({provider}) 未识别到有效语音片段(音频可能为静音、设备未拾音或音量过低)"
)
speaker_map: dict[str, str] = {}
turns: list[list[str]] = []
speakers_seen: set[str] = set()
@@ -221,7 +229,9 @@ def format_diarized_transcript(
else:
turns.append([spk, text])
if not turns:
raise RuntimeError(f"ASR ({provider}) segments contained no text")
raise EmptyAudioError(
f"ASR ({provider}) 未识别到任何有效文字(音频可能为静音或底噪)"
)
lines = [f"<!-- asr_provider: {provider} model: {model} -->"]
if voiceprint_session or provider == "qwen3cam":
@@ -240,7 +250,7 @@ def format_diarized_transcript(
def transcript_from_qwen3cam_response(payload: dict[str, Any]) -> str:
segments = payload.get("segments")
if not isinstance(segments, list) or not segments:
raise RuntimeError("ASR response contains no segments")
raise EmptyAudioError("ASR (qwen3cam) 未识别到有效语音片段(音频可能为静音、设备未拾音或音量过低)")
vp = payload.get("voiceprint") or {}
sid = str(vp.get("session_id") or "")
matched = [
@@ -431,16 +441,33 @@ def _http_multipart(
file_path: Path,
headers: dict[str, str] | None = None,
timeout: int = 900,
retries: int = 3,
) -> dict[str, Any]:
data, content_type = _encode_multipart(fields, file_field, file_path)
req_headers = {"Content-Type": content_type, **(headers or {})}
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="ignore")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")[:300]
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from exc
last_exc: Exception | None = None
for attempt in range(1, max(1, retries) + 1):
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="ignore")
break
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")[:300]
if exc.code in (502, 503, 504) and attempt < retries:
last_exc = exc
time.sleep(2 * attempt)
continue
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from exc
except (urllib.error.URLError, http.client.HTTPException, ConnectionError, TimeoutError, OSError) as exc:
last_exc = exc
if attempt < retries:
log(f"multipart upload transient error (attempt {attempt}/{retries}): {exc}")
time.sleep(2 * attempt)
continue
raise RuntimeError(f"ASR upload connection error after {retries} attempts: {exc}") from exc
else:
raise RuntimeError(f"ASR upload failed: {last_exc}")
try:
return json.loads(raw)
except ValueError as exc:
@@ -454,18 +481,34 @@ def _http_json(
payload: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: int = 120,
retries: int = 3,
) -> dict[str, Any]:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
req_headers = {"Accept": "application/json", **(headers or {})}
if data is not None:
req_headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="ignore")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")[:300]
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from exc
last_exc: Exception | None = None
for attempt in range(1, max(1, retries) + 1):
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="ignore")
break
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")[:300]
if exc.code in (502, 503, 504) and attempt < retries:
last_exc = exc
time.sleep(1.5 * attempt)
continue
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from exc
except (urllib.error.URLError, http.client.HTTPException, ConnectionError, TimeoutError, OSError) as exc:
last_exc = exc
if attempt < retries:
time.sleep(1.5 * attempt)
continue
raise RuntimeError(f"ASR HTTP request failed after {retries} attempts: {exc}") from exc
else:
raise RuntimeError(f"ASR HTTP request failed: {last_exc}")
if not raw.strip():
return {}
try:
@@ -575,14 +618,29 @@ def transcribe_soniox(
chosen_model = model or CFG.soniox_model or "stt-async-v5"
headers = {"Authorization": f"Bearer {key}"}
file_resp = _http_multipart(
f"{base}/files",
fields={},
file_field="file",
file_path=audio,
headers=headers,
timeout=600,
)
with tempfile.TemporaryDirectory(prefix="asr-soniox-") as tmp:
upload_path = audio
if audio.exists() and audio.stat().st_size > 8 * 1024 * 1024 and shutil.which("ffmpeg"):
compressed = Path(tmp) / "soniox_upload.mp3"
try:
subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(audio), "-ar", "16000", "-ac", "1", "-b:a", "48k",
str(compressed),
], check=True, timeout=600)
if compressed.exists() and 0 < compressed.stat().st_size < audio.stat().st_size:
upload_path = compressed
except Exception as exc:
log(f"soniox audio pre-compress skipped: {exc}")
file_resp = _http_multipart(
f"{base}/files",
fields={},
file_field="file",
file_path=upload_path,
headers=headers,
timeout=600,
)
file_id = str(file_resp.get("id") or "")
if not file_id:
raise RuntimeError(f"Soniox file upload returned no id: {file_resp}")
@@ -622,7 +680,7 @@ def transcribe_soniox(
else:
raise RuntimeError("Soniox transcription timed out waiting for completion")
if "tokens" in status_resp or "segments" in status_resp:
if status_resp.get("tokens") or status_resp.get("segments"):
transcript_payload = status_resp
else:
transcript_payload = _http_json(
@@ -377,9 +377,13 @@ def write_draft(cleaned: str, meta: dict, context: str, date: str,
if ctx:
ctx = "本场背景(来自既有笔记与长期记忆;引用其中内容时必须按上面第 3 条加 〔背景〕):\n" \
+ context
detail = SOURCE_DETAIL.get(meta.get("backend", ""), "ASR")
backend = meta.get("backend") or meta.get("asr_provider") or ""
model_name = meta.get("asr_model") or ""
detail = SOURCE_DETAIL.get(backend, "ASR")
if model_name and backend in ("soniox", "gemini", "openai", "grok") and model_name not in detail:
detail = f"{detail} ({model_name})"
prompt = prompts.DRAFT_PROMPT.format(
speaker_note=prompts.SPEAKER_NOTE.get(meta.get("backend", ""), ""),
speaker_note=prompts.SPEAKER_NOTE.get(backend, ""),
context=ctx, date=date, source_detail=detail,
transcript=transcript_path, extra_fm=extra, body=cleaned)
return chat(CFG.draft_model, prompt, max_tokens=12000, usage=usage)
@@ -54,12 +54,14 @@ def _safe_title(markdown: str) -> str:
return title[:72] or "通话记录"
def _formal_path(markdown: str) -> Path:
def _formal_path(markdown: str, root: Path | None = None) -> Path:
target_dir = root or CFG.minutes
target_dir.mkdir(parents=True, exist_ok=True)
date = _field(markdown, "date") or time.strftime("%Y-%m-%d")
base = CFG.minutes / f"{date}-{_safe_title(markdown)}.md"
base = target_dir / f"{date}-{_safe_title(markdown)}.md"
transcript = _field(markdown, "transcript")
if CFG.minutes.exists() and transcript:
for path in CFG.minutes.glob("*.md"):
if target_dir.exists() and transcript:
for path in target_dir.glob("*.md"):
try:
if _field(path.read_text(errors="ignore"), "transcript") == transcript:
return path
@@ -256,13 +258,21 @@ def publish(path: Path) -> bool:
destination = _formal_path(out)
vault.write_draft_file(destination, out)
vault_minutes_dir = CFG.vault / "minutes"
vault_destination = (
destination
if destination.parent.resolve() == vault_minutes_dir.resolve()
else (vault_minutes_dir / destination.name)
)
if vault_destination != destination:
vault.write_draft_file(vault_destination, out)
date = _field(out, "date") or time.strftime("%Y-%m-%d")
touched = [destination]
touched = [vault_destination]
for name, kind in _answered_card_requests(path):
card = _create_card(name, kind, destination, date)
card = _create_card(name, kind, vault_destination, date)
if card:
touched.append(card)
state = vault.sync(touched, f"minutes: {destination.stem}(Web 确认后正式发布)")
state = vault.sync(touched, f"minutes: {vault_destination.stem}(Web 确认后正式发布)")
if state == "error":
raise RuntimeError("formal minute commit failed")
@@ -22,7 +22,7 @@ import re
from dataclasses import dataclass, field
VOICEPRINT_HDR = re.compile(r"^<!--\s*voiceprint_session:")
PROVIDER_HDR = re.compile(r"^<!--\s*asr_provider:\s*(\S+)(?:\s+model:\s*(\S+))?\s*-->")
PROVIDER_HDR = re.compile(r"^<!--\s*asr_provider:\s*(\S+)(?:\s+model:\s*(\S+))?\s*-->", re.M)
SEGMENT_BANNER = re.compile(r"^---\s*第\d+段.*---\s*$")
TURN_TAG = re.compile(r"^【([^】]+)】\s*(.*)$", re.S)
VIBE_TAG = re.compile(r"^\[(\d{1,2}:\d{2})\]\s*(S\d+)\s*[::]\s*(.*)$", re.S)
@@ -148,8 +148,10 @@ def parse(raw: str) -> tuple[list[Turn], dict]:
continue
body_lines.append(ln)
backend = detect_backend(raw)
backend = meta["asr_provider"] or str(meta["intake"].get("asr_provider") or "") or detect_backend(raw)
meta["backend"] = backend
if not meta["asr_model"] and meta["intake"].get("asr_model"):
meta["asr_model"] = str(meta["intake"]["asr_model"])
turns: list[Turn] = []
if backend == "vibevoice":
@@ -90,8 +90,26 @@ def update_pending(date: str, title: str, path: Path) -> None:
def _git(*args, check=False) -> subprocess.CompletedProcess:
return subprocess.run(["git", *args], cwd=CFG.vault, capture_output=True,
text=True, timeout=180, check=check)
return subprocess.run(
["git", "-c", f"safe.directory={CFG.vault}", *args],
cwd=CFG.vault, capture_output=True,
text=True, timeout=180, check=check,
)
def pull() -> str:
"""Pull latest remote changes into CFG.vault when git_sync is enabled."""
if not CFG.git_sync or not (CFG.vault / ".git").exists():
return "disabled"
try:
r = _git("pull", "--rebase", "--autostash", "-q")
if r.returncode != 0:
log(f"git pull failed: {r.stderr.strip()[:200]}", level="WARN")
return "error"
return "pulled"
except Exception as e:
log(f"git pull error: {e}", level="WARN")
return "error"
def sync(paths: list[Path], message: str) -> str:
@@ -99,9 +117,18 @@ def sync(paths: list[Path], message: str) -> str:
if not CFG.git_sync:
return "disabled"
try:
rel = [str(p.relative_to(CFG.vault)) for p in paths if p.exists()]
vault_root = CFG.vault.resolve()
rel = []
for p in paths:
if not p.exists():
continue
try:
rel.append(str(p.resolve().relative_to(vault_root)))
except ValueError:
continue
if not rel:
return "noop"
_git("pull", "--rebase", "--autostash", "-q")
_git("add", "--", *rel)
if _git("diff", "--cached", "--quiet").returncode == 0:
log("nothing staged (likely already committed by the auto-sync cron)")
@@ -1808,9 +1808,10 @@ class Handler(BaseHTTPRequestHandler):
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"同步完成:词库 {l_res},图谱 {g_res}"))
return self.redirect("/lexicon?msg=" + urllib.parse.quote(f"同步完成(Git:{p_res}):词库 {l_res},图谱 {g_res}"))
# ---- SQLite Settings Save ----
if path == "/settings":
@@ -191,13 +191,14 @@ def scan_inbox() -> None:
process_audio(source, job)
except Exception as exc:
attempts = int((db.get_job(job["id"]) or {}).get("attempts") or 0)
retry = attempts < MAX_ATTEMPTS
is_empty_audio = isinstance(exc, providers.EmptyAudioError)
retry = (attempts < MAX_ATTEMPTS) and not is_empty_audio
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}")
log(f"job={job['id']} failed type={type(exc).__name__} retry={retry} err={exc}")
target = INBOX / source.name if retry else FAILED / source.name
if source.exists():
shutil.move(str(source), target)
@@ -255,6 +256,7 @@ def recover() -> None:
def sync_vault_knowledge() -> None:
try:
vault.pull()
lexicon.sync_from_vault()
graph.sync()
except Exception as exc:
@@ -42,6 +42,7 @@ services:
volumes:
- /mnt/truenas/personal/ASR-Pipeline:/data
- /opt/asr-pipeline/state:/state
- /opt/asr-pipeline/ssh:/root/.ssh:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8788/health"]
interval: 30s
@@ -63,6 +64,7 @@ services:
volumes:
- /mnt/truenas/personal/ASR-Pipeline:/data
- /opt/asr-pipeline/state:/state
- /opt/asr-pipeline/ssh:/root/.ssh:ro
healthcheck:
test: ["CMD", "python", "-m", "asrservice.healthcheck"]
interval: 30s
@@ -71,15 +71,20 @@ class ProviderAndServiceTests(unittest.TestCase):
]
}
text = providers.transcript_from_soniox_response(payload, model="stt-async-v5")
parsed_turns, meta = segment.parse(text)
with_intake = '<!-- intake: {"job_id": "abc", "asr_provider": "soniox", "asr_model": "stt-async-v5"} -->\n' + text
parsed_turns, meta = segment.parse(with_intake)
self.assertEqual(meta["asr_provider"], "soniox")
self.assertEqual(meta["asr_model"], "stt-async-v5")
self.assertEqual(meta["backend"], "soniox")
self.assertEqual(len(parsed_turns), 2)
self.assertEqual(parsed_turns[0].speaker, "speaker_0")
self.assertEqual(parsed_turns[0].text, "大家好,今天讨论预算。")
self.assertEqual(parsed_turns[1].speaker, "speaker_1")
self.assertEqual(parsed_turns[1].text, "收到,我来汇报。")
with self.assertRaises(providers.EmptyAudioError):
providers.transcript_from_soniox_response({"tokens": [], "text": ""}, model="stt-async-v5")
def test_soniox_context_includes_lexicon_and_intake(self):
lexicon.learn_alias("lite网关", "LiteLLM", note="test")
ctx = providers.build_soniox_context(