deploy standalone ASR pipeline on tools-pve

This commit is contained in:
Codex
2026-09-18 02:32:14 -07:00
parent f30f72b295
commit b23f00f41a
38 changed files with 182773 additions and 0 deletions
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Vault side effects: draft file, pending index, git sync, notification.
Ported from the legacy shell implementation with its hard-won details preserved:
* a same date+title never overwrites an existing draft (suffix -2, -3, …)
* the pending index row is created BEFORE staging, so one commit carries both
* the 5-minute `obsidian-sync.sh` cron may have already staged the files; that is
tolerated rather than treated as an error
* a failed push is reported as "local only", never as success
"""
from __future__ import annotations
import re
import subprocess
import tempfile
from pathlib import Path
from .config import CFG
from .log import log
PENDING_HEADER = """---
type: index
title: 待确认草稿
note: 自动维护,确认定稿后请把对应行删除
---
# 待确认草稿 (ASR 自动精修)
| 日期 | 标题 | 草稿 |
|---|---|---|
"""
def slug_parts(slug: str) -> tuple[str, str]:
"""`2026-07-21-125915录音` -> ('2026-07-21', '125915录音')."""
m = re.match(r"^(\d{4}-\d{2}-\d{2})[-_](.+)$", slug)
if m:
return m.group(1), m.group(2)
import time
return time.strftime("%Y-%m-%d"), slug
def draft_path(date: str, title: str) -> Path:
CFG.drafts.mkdir(parents=True, exist_ok=True)
p = CFG.drafts / f"{date}-{title}.md"
if not p.exists():
return p
n = 2
while (CFG.drafts / f"{date}-{title}-{n}.md").exists():
n += 1
return CFG.drafts / f"{date}-{title}-{n}.md"
def write_draft_file(path: Path, body: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if not body.endswith("\n"):
body += "\n"
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent,
prefix=f".{path.name}.", delete=False) as f:
f.write(body)
tmp = Path(f.name)
tmp.replace(path)
def find_draft_for_transcript(transcript: str) -> Path | None:
"""Recover an interrupted run without paying for or creating a second draft."""
if not CFG.drafts.exists():
return None
needle = f"transcript: {transcript}"
for p in CFG.drafts.glob("*.md"):
try:
if needle in p.read_text(errors="ignore")[:2000]:
return p
except OSError:
continue
return None
def update_pending(date: str, title: str, path: Path) -> None:
p = CFG.pending_index
if not p.exists():
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(PENDING_HEADER, encoding="utf-8")
base = path.stem
text = p.read_text(errors="ignore")
if f"[[{base}]]" in text:
return
text += f"| {date} | {title} | [[{base}]] |\n"
write_draft_file(p, text)
def _git(*args, check=False) -> subprocess.CompletedProcess:
return subprocess.run(["git", *args], cwd=CFG.vault, capture_output=True,
text=True, timeout=180, check=check)
def sync(paths: list[Path], message: str) -> str:
"""Commit + push. Returns 'pushed' | 'local' | 'noop' | 'disabled' | 'error'."""
if not CFG.git_sync:
return "disabled"
try:
rel = [str(p.relative_to(CFG.vault)) for p in paths if p.exists()]
if not rel:
return "noop"
_git("add", "--", *rel)
if _git("diff", "--cached", "--quiet").returncode == 0:
log("nothing staged (likely already committed by the auto-sync cron)")
return "noop"
# Commit only our paths. The 5-minute vault sync may already have unrelated
# files staged; absorbing those into this transaction makes rollback and
# diagnosis needlessly difficult.
c = _git("commit", "-q", "--only", "-m", message, "--", *rel)
if c.returncode != 0:
log(f"git commit failed: {c.stderr.strip()[:200]}", level="ERROR")
return "error"
p = _git("push", "-q")
if p.returncode != 0:
log(f"committed but push failed (local only): {p.stderr.strip()[:200]}",
level="WARN")
return "local"
return "pushed"
except Exception as e:
log(f"git sync error: {e}", level="ERROR")
return "error"
def notify(title: str, body: str, *, redact_log: bool = False) -> None:
if not CFG.notify:
return
n = CFG.notifier
if not n.exists():
log(f"NOTIFY (no notifier): {title} — {body}")
return
try:
r = subprocess.run(["python3", str(n), "草稿已生成,待确认", title, body],
capture_output=True, text=True, timeout=30)
detail = "[sensitive body redacted]" if redact_log else body
log(f"NOTIFY: {title} — {detail} ({r.stdout.strip()[:80]})")
except Exception as e:
log(f"notify failed: {e}", level="WARN")
def mark_done(slug: str) -> Path:
CFG.state.mkdir(parents=True, exist_ok=True)
m = CFG.state / f"{slug}.done"
m.touch()
return m
def is_done(slug: str) -> bool:
return (CFG.state / f"{slug}.done").exists()
def pending_transcripts() -> list[Path]:
if not CFG.inbox.exists():
return []
return sorted(p for p in CFG.inbox.glob("*.txt") if not is_done(p.stem))