#!/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)