deploy standalone ASR pipeline on tools-pve
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user