Files
infra-docker-configs/servers/tools-pve/asr-pipeline/asrpipe/revise.py
T
Codex 56e0305188
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s
fix(tools-pve/asr-pipeline): mount gitea ssh key for Obsidian vault sync and fix provider source_detail
2026-11-02 02:57:42 -08:00

301 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, 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 = target_dir / f"{date}-{_safe_title(markdown)}.md"
transcript = _field(markdown, "transcript")
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
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)
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 = [vault_destination]
for name, kind in _answered_card_requests(path):
card = _create_card(name, kind, vault_destination, date)
if card:
touched.append(card)
state = vault.sync(touched, f"minutes: {vault_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)