Files
infra-docker-configs/servers/tools-pve/asr-pipeline/asrpipe/vault.py
T
Codex 9debd1c47d
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s
fix(tools-pve/asr-pipeline): support /state/vault and /state/ssh inside container
2026-11-02 03:07:41 -08:00

199 lines
6.4 KiB
Python

#!/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 os
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:
env = dict(os.environ)
for key_candidate in (
CFG.state_dir / "ssh" / "id_ed25519_gitea",
Path("/state/ssh/id_ed25519_gitea"),
Path("/root/.ssh/id_ed25519_gitea"),
Path.home() / ".ssh" / "id_ed25519_gitea",
):
if key_candidate.exists():
env.setdefault(
"GIT_SSH_COMMAND",
f"ssh -i {key_candidate} -o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes",
)
break
return subprocess.run(
["git", "-c", f"safe.directory={CFG.vault}", *args],
cwd=CFG.vault, capture_output=True,
text=True, timeout=180, check=check, env=env,
)
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:
"""Commit + push. Returns 'pushed' | 'local' | 'noop' | 'disabled' | 'error'."""
if not CFG.git_sync:
return "disabled"
try:
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)")
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))