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,290 @@
#!/usr/bin/env python3
"""Prepare a final preview, then publish it only after explicit Web approval."""
from __future__ import annotations
import json
import re
import shutil
import time
from pathlib import Path
from . import graph, lexicon, vault
from .config import CFG
from .llm import chat
from .log import log
from .review import FEEDBACK_END, FEEDBACK_START
PROMPT = """请根据文末「Web 人工确认记录」将粗稿整理成可直接进入 Obsidian 的正式记录。
严格要求:
1. 只使用原稿、人工回答及原稿已标明的背景,不补充外部事实。
2. 将确认的人名、关系、背景和术语真正改入正文;人工回答有“应该”等保留不确定性。
3. 删除已解决的「待你确认」以及 Web 审核记录,不写 ASR 修正、speaker 归属等过程说明。
4. YAML 必须符合:
- type: minutes
- tags: [会议纪要]
- 不得有 status: 草稿待确认
- 保留 date/source/source_detail/transcript/voiceprint_session/
voiceprint_speakers/voiceprint_enroll/intake_job
- people/companies 使用裸名列表。
5. H1 使用简短、有辨识度的业务标题,不要写“粗整理草稿”或“会议纪要”。
6. H1 后先写:
> [!summary] Summary
> 结论式概述。
然后按需写 `[!important] 决策`、Tasks 格式待办、`[!question] Open Questions`。
7. 正文按业务主线组织,中文全角标点;有把握的实体用 [[双链]]。
8. 不完整或仍存疑的内容可留 Open Questions,绝不硬猜。
9. 输出完整 Markdown 文件,不要解释。
待发布粗稿:
{draft}
"""
def _field(text: str, key: str) -> str:
m = re.search(rf"(?m)^{re.escape(key)}:\s*(.+?)\s*$", text)
return m.group(1).strip().strip("\"'") if m else ""
def _safe_title(markdown: str) -> str:
m = re.search(r"(?m)^#\s+(.+?)\s*$", markdown)
title = m.group(1).strip() if m else "通话记录"
title = re.sub(r"[\[\]<>:\"/\\|?*#]", "", title)
title = re.sub(r"\s+", "", title).strip(". ")
return title[:72] or "通话记录"
def _formal_path(markdown: str) -> Path:
date = _field(markdown, "date") or time.strftime("%Y-%m-%d")
base = CFG.minutes / f"{date}-{_safe_title(markdown)}.md"
transcript = _field(markdown, "transcript")
if CFG.minutes.exists() and transcript:
for path in CFG.minutes.glob("*.md"):
try:
if _field(path.read_text(errors="ignore"), "transcript") == transcript:
return path
except OSError:
continue
if not base.exists():
return base
n = 2
while base.with_name(f"{base.stem}-{n}.md").exists():
n += 1
return base.with_name(f"{base.stem}-{n}.md")
def _answered_card_requests(draft_path: Path) -> list[tuple[str, str]]:
con = lexicon.connect()
rows = con.execute(
"SELECT payload,answer,status FROM review_question ORDER BY id").fetchall()
con.close()
out = []
for row in rows:
payload = json.loads(row["payload"] or "{}")
if (payload.get("draft_path") != str(draft_path)
or payload.get("kind") != "entity_card"
or row["status"] != "answered"
or not str(row["answer"]).startswith("建立")):
continue
out.append((payload["entity"], payload["entity_kind"]))
return out
def _create_card(name: str, kind: str, minute: Path, date: str) -> Path | None:
if not name or "/" in name or name.startswith("_"):
return None
if kind == "人名":
path = CFG.vault / "People" / f"{name}.md"
if path.exists():
return None
body = f"""---
type: person
name: "{name}"
aliases: []
company:
role:
status: active
importance:
owner: Cooper
tags: []
last_contact: '{date}'
mentions: 1
first_contact: '{date}'
next_followup:
location:
channels: []
related_projects: []
related_companies: []
source: 会议提及
created: {time.strftime("%Y-%m-%d")}
---
# {name}
> [!summary] Summary
> 在 [[{minute.stem}]] 中被确认;详细身份与关系待后续补充。
## 相关会议
- [[{minute.stem}]]
"""
else:
path = CFG.vault / "Companies" / f"{name}.md"
if path.exists():
return None
body = f"""---
type: company
name: "{name}"
aliases: []
industry:
stage:
relationship:
key_people: []
related_projects: []
related_companies: []
tags: []
source: 会议提及
created: {time.strftime("%Y-%m-%d")}
last_contact: '{date}'
mentions: 1
first_contact: '{date}'
---
# {name}
> [!summary] Summary
> 在 [[{minute.stem}]] 中被确认;公司信息与关系阶段待后续补充。
## 相关会议
- [[{minute.stem}]]
"""
vault.write_draft_file(path, body)
return path
def _latest_publish_answer(path: Path) -> str:
con = lexicon.connect()
rows = con.execute(
"SELECT payload,answer,status FROM review_question ORDER BY id DESC"
).fetchall()
con.close()
for row in rows:
payload = json.loads(row["payload"] or "{}")
if (payload.get("draft_path") == str(path)
and payload.get("kind") == "publish_approval"
and row["status"] == "answered"):
return str(row["answer"] or "")
return ""
def _validate_formal(out: str, original: str) -> None:
if not out.startswith("---\n") or "type: minutes" not in out:
raise RuntimeError("formal output failed frontmatter validation")
transcript = _field(original, "transcript")
if transcript and _field(out, "transcript") != transcript:
raise RuntimeError("formal output changed transcript provenance")
if "[!summary]" not in out:
raise RuntimeError("formal output missing Summary callout")
if not (0.35 <= len(out) / max(1, len(original)) <= 1.35):
raise RuntimeError("formal output length outside safety range")
def _as_preview(out: str) -> str:
fm = re.match(r"(?s)^---\n(.*?)\n---", out)
if not fm:
raise RuntimeError("formal preview missing frontmatter")
block = re.sub(r"(?m)^status:.*\n?", "", fm.group(1))
block += "\nstatus: 待发布确认"
return "---\n" + block + "\n---" + out[fm.end():]
def prepare(path: Path) -> bool:
path = path.resolve()
path.relative_to(CFG.drafts.resolve())
original = path.read_text(encoding="utf-8", errors="ignore")
intake_job = _field(original, "intake_job")
if FEEDBACK_START not in original or FEEDBACK_END not in original:
log(f"publish skipped without completed feedback: {path}", level="WARN")
return False
out = chat(CFG.draft_model, PROMPT.format(draft=original), max_tokens=14000)
out = re.sub(r"^```(?:markdown)?\s*|\s*```$", "", out.strip(),
flags=re.I | re.S).strip() + "\n"
if "status: 草稿待确认" in out or FEEDBACK_START in out:
raise RuntimeError("formal output retained draft-only metadata")
_validate_formal(out, original)
vault.write_draft_file(path, _as_preview(out))
from . import review, review_web
review.enqueue(
_field(original, "transcript") or path.name,
"这是应用第一轮回答后的定稿预览。确认发布到 Obsidian 吗?",
{
"kind": "publish_approval", "draft_path": str(path),
"sentence": "请展开查看完整定稿;选择退回时,在输入框写明修改意见。",
"candidates": ["发布正式稿", "退回修改"],
})
if intake_job:
try:
from .control_web import update_job
update_job(intake_job, "review", "定稿预览已生成,等待发布确认",
draft=str(path))
except Exception as e:
log(f"preview intake status failed: {e}", level="WARN")
review_web.request_start(intake_job)
log(f"prepared final preview {path}")
return True
def _finalize_preview(text: str) -> str:
text = re.sub(
re.escape(FEEDBACK_START) + r".*?" + re.escape(FEEDBACK_END),
"", text, flags=re.S)
text = re.sub(r"(?m)^status:\s*待发布确认\s*\n?", "", text)
return text.rstrip() + "\n"
def publish(path: Path) -> bool:
path = path.resolve()
path.relative_to(CFG.drafts.resolve())
original = path.read_text(encoding="utf-8", errors="ignore")
if _latest_publish_answer(path) != "发布正式稿":
log(f"publish skipped without explicit approval: {path}", level="WARN")
return False
out = _finalize_preview(original)
_validate_formal(out, original)
intake_job = _field(original, "intake_job")
destination = _formal_path(out)
vault.write_draft_file(destination, out)
date = _field(out, "date") or time.strftime("%Y-%m-%d")
touched = [destination]
for name, kind in _answered_card_requests(path):
card = _create_card(name, kind, destination, date)
if card:
touched.append(card)
state = vault.sync(touched, f"minutes: {destination.stem}(Web 确认后正式发布)")
if state == "error":
raise RuntimeError("formal minute commit failed")
graph.sync()
archive = CFG.state_dir / "published"
archive.mkdir(parents=True, exist_ok=True)
shutil.move(str(path), archive / path.name)
vault.notify("ASR 正式记录已发布",
f"{destination.name} 已应用 Web 回答并发布(同步:{state})。")
log(f"published {destination} | cards={len(touched)-1} | git={state}")
if intake_job:
try:
from .control_web import update_job
update_job(intake_job, "complete", f"正式纪要:{destination.name}",
result=str(destination))
except Exception as e:
log(f"intake completion status failed: {e}", level="WARN")
return True
def revise(path: Path) -> bool:
path = path.resolve()
if _latest_publish_answer(path) == "发布正式稿":
return publish(path)
return prepare(path)