diff --git a/frontend/assets/app.js b/frontend/assets/app.js index be5fb07..f2c8534 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -91,10 +91,18 @@ function meetingById(meetingId) { return state.meetings.find((item) => item.id === meetingId) || null; } +function templateMetaByName(name) { + return state.templates.find((item) => item.name === name) || null; +} + function isMarkdownFile(name = "") { return name.toLowerCase().endsWith(".md"); } +function templateNameFromGuideName(name) { + return name; +} + function renderMeetingStatus(meeting) { const name = $("#sidebar-meeting-name"); const meta = $("#sidebar-meeting-meta"); @@ -273,6 +281,17 @@ function renderNode(node, parent, depth) { label.textContent = node.name; row.appendChild(label); + if (node.delete_mode === "template") { + const del = document.createElement("span"); + del.className = "del-btn"; + del.textContent = "删除"; + del.addEventListener("click", async (event) => { + event.stopPropagation(); + await deleteTemplateNode(node.name); + }); + row.appendChild(del); + } + row.addEventListener("click", async () => { await openTreeResource(node.path); }); @@ -351,17 +370,31 @@ function syncTemplateSelection(name) { $("#tpl-select").value = name; } +function updateGuideButton(resource) { + const button = $("#btn-reparse-guide"); + if (!resource || (resource.type !== "template" && resource.type !== "template-guide")) { + button.disabled = true; + button.textContent = "生成解析说明"; + return; + } + + const hasGuide = resource.type === "template-guide" || Boolean(resource.hasGuide); + button.disabled = false; + button.textContent = hasGuide ? "重新生成说明" : "生成解析说明"; +} + function applyRightResource(resource) { state.rightResource = resource; $("#editor-resource-label").textContent = `当前资源:${resource.label}`; $("#side-editor").value = resource.content || ""; $("#btn-toggle-side-edit").disabled = !resource.editable; $("#btn-toggle-side-edit").textContent = resource.editable ? "编辑资源" : "只读资源"; + updateGuideButton(resource); state.rightEditMode = false; renderSidePreview(resource); - if (resource.type === "template") { - syncTemplateSelection(resource.name); + if (resource.type === "template" || resource.type === "template-guide") { + syncTemplateSelection(resource.templateName || resource.name); } } @@ -396,8 +429,25 @@ async function openTemplate(name, treeKey = `file:templates/${name}`) { await openRightResource({ type: "template", name, + templateName: name, label: `模板 / ${name}`, content: data.content, + hasGuide: Boolean(data.has_guide), + editable: true, + treeKey, + }); +} + +async function openTemplateGuide(name, treeKey = `file:template_guides/${name}`) { + const templateName = templateNameFromGuideName(name); + const data = await api(`/api/templates/${encodeURIComponent(templateName)}/guide`); + await openRightResource({ + type: "template-guide", + name, + templateName, + label: `模板说明 / ${templateName}`, + content: data.content, + hasGuide: true, editable: true, treeKey, }); @@ -458,6 +508,10 @@ async function openTreeResource(path) { await openPrompt(parts.slice(1).join("/"), treeKey); return; } + if (group === "template_guides") { + await openTemplateGuide(parts.slice(1).join("/"), treeKey); + return; + } const meetingId = parts[1]; const filename = parts.slice(2).join("/"); @@ -509,6 +563,29 @@ async function deleteMeetingNode(meetingId, deleteMode) { } } +async function deleteTemplateNode(name) { + if (!window.confirm(`确定删除模板 ${name} 及其对应使用说明吗?`)) { + return; + } + + await api(`/api/templates/${encodeURIComponent(name)}`, { method: "DELETE" }); + toast(`模板已删除:${name}`); + + if ( + state.rightResource && + (state.rightResource.templateName === name || state.rightResource.name === name) + ) { + state.rightResource = null; + } + + if (state.templateName === name) { + state.templateName = ""; + savePreferences({ templateName: "" }); + } + + await refresh(); +} + function initResize(gutterId, leftId, rightId) { const gutter = document.getElementById(gutterId); const left = document.getElementById(leftId); @@ -619,6 +696,15 @@ async function refresh() { return; } + if ( + state.rightResource && + state.rightResource.type === "template-guide" && + templateData.some((item) => item.name === state.rightResource.templateName) + ) { + await openTemplateGuide(state.rightResource.name, state.rightResource.treeKey); + return; + } + if (!state.rightResource && state.templateName) { await openTemplate(state.templateName); } @@ -743,6 +829,12 @@ $("#btn-toggle-side-edit").addEventListener("click", async () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), }); + } else if (resource.type === "template-guide") { + await api(`/api/templates/${encodeURIComponent(resource.templateName)}/guide`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); } else if (resource.type === "prompt") { await api(`/api/prompts/${encodeURIComponent(resource.name)}`, { method: "PUT", @@ -768,6 +860,21 @@ $("#btn-open-template").addEventListener("click", async () => { await openTemplate(state.templateName); }); +$("#btn-reparse-guide").addEventListener("click", async () => { + const resource = state.rightResource; + if (!resource || (resource.type !== "template" && resource.type !== "template-guide")) { + return; + } + + const templateName = resource.templateName || resource.name; + const result = await api(`/api/templates/${encodeURIComponent(templateName)}/guide/reparse`, { + method: "POST", + }); + toast(`模板说明已重新解析:${templateName}`); + await refresh(); + await openTemplateGuide(result.name); +}); + $("#btn-import").addEventListener("click", () => { $("#import-name").value = ""; $("#import-file").value = ""; diff --git a/frontend/index.html b/frontend/index.html index 940c1e2..a710ac7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -79,6 +79,7 @@ + diff --git a/meeting_summary.py b/meeting_summary.py index fe1026e..4344ce0 100644 --- a/meeting_summary.py +++ b/meeting_summary.py @@ -11,6 +11,7 @@ DATA_DIR = PROJECT_ROOT / "data" / "meetings" RESULTS_MD_DIR = PROJECT_ROOT / "data" / "results" / "md" RESULTS_JSON_DIR = PROJECT_ROOT / "data" / "results" / "json" TEMPLATE_DIR = PROJECT_ROOT / "template" +TEMPLATE_GUIDE_DIR = PROJECT_ROOT / "template_guides" EXAMPLES_DIR = PROJECT_ROOT / "examples" @@ -52,6 +53,13 @@ def read_template(template_name: str) -> str: return template_path.read_text(encoding="utf-8") +def read_template_guide(template_name: str) -> str: + guide_path = TEMPLATE_GUIDE_DIR / template_name + if not guide_path.exists(): + return "" + return guide_path.read_text(encoding="utf-8") + + def collect_stream(response) -> str: content = [] current_part = None @@ -110,6 +118,7 @@ def main(): target_name, transcript, transcript_path = load_transcript(args) template = read_template(args.template) + template_guide = read_template_guide(args.template) prompt = load_prompt("meeting_summary", "zh") print(f"Processing transcript: {transcript_path}") @@ -121,6 +130,8 @@ def main(): sub_topics = collect_stream(get_qwen_response(args.model, system_prompt, user_prompt)) system_prompt = prompt["system"]["role"] + prompt["mode_contracts"]["data_summary"].format(template=template) + if template_guide: + system_prompt += f"\n\n模板使用说明:\n{template_guide}" user_prompt = prompt["user_template"]["article_summary"].format(article=transcript, sub_topices=sub_topics) summary_text = collect_stream(get_qwen_response(args.model, system_prompt, user_prompt)) diff --git a/template/template2.md b/template/template2.md index f1fa5d4..36eaba3 100644 --- a/template/template2.md +++ b/template/template2.md @@ -80,15 +80,3 @@ - XXX - XXX - XXX - ---- - -📝 **使用说明(输出时请删除):** -- 会议内容第一部分以一段文字根据实际汇报人员和回报内容总结关键汇报内容 -- 保留一级结构,二级和三级内容根据实际会议内容动态生成 -- 请将 `X`、`XX` 等占位符替换为实际内容 -- 会议中没被提到的占位符保留,不做替换 -- 领导强调方面总结为精简的词组,不宜过长 -- 无事实依据支撑的小节、问答、决策或行动项直接省略 -- 不要为了凑模板强行写“无相关内容” -- 议题数量可增减,不强制必须正好 3 个 diff --git a/template_guides/template1.md b/template_guides/template1.md new file mode 100644 index 0000000..13a70d7 --- /dev/null +++ b/template_guides/template1.md @@ -0,0 +1,10 @@ +- 严格保留模板的Markdown标题层级(## 主模块、### 子模块),主模块标题可保留原模板的Emoji标识。 +- 所有方括号 `[]` 内的文本、示例标题(如“议题1/2/3”“关于产品定位”)及括号提示语均为结构占位符,生成时必须替换为真实会议内容,严禁原样输出。 +- 若某模块或子项在会议实际内容中无对应信息,应直接省略该部分,不得留空或强行补全占位符。 +- 各模块下的子条目数量(如议题数量、观点子项、行动项等)允许根据实际讨论内容动态增减,不强制匹配模板示例数量。 +- 保留问答对的固定排版:使用 `Q: ` 与 `A: ` 标识问答内容,不同问答对之间以 `---` 分隔。 +- 保留观点陈述的引用块格式:统一使用 `> ` 开头包裹具体观点或共识内容。 +- 保留行动项的复选框格式:统一使用 `- [ ] ` 开头列出后续待办任务。 +- 保留分析类与决策类内容的键值对列表格式:使用 `- 标签: 内容` 的结构呈现各项分析或决议。 +- 子模块标题可根据实际议题语义进行重命名,但需维持原模板的模块分类逻辑与层级归属。 +- 生成内容需严格遵循“有则保留对应格式,无则直接省略该条目”的原则,禁止输出任何未经验证的结构化空壳或示例文字。 \ No newline at end of file diff --git a/template_guides/template2.md b/template_guides/template2.md new file mode 100644 index 0000000..38c16b0 --- /dev/null +++ b/template_guides/template2.md @@ -0,0 +1,7 @@ +- 保持顶层框架(主标题、核心内容模块)固定,议题数量及中层及以下标题、子项、列表按实际内容动态增删,不强制匹配示例条目数。 +- 模板中的 `X`、`XX`、`XXX` 等占位符需优先替换为真实信息;若会议材料未提供对应内容,则原样保留占位符,严禁编造或强行替换。 +- 无事实依据支撑的空白小节、示例问答、决策或行动项必须直接省略,严禁输出“无相关内容”等凑数文字。 +- 领导强调/部署类内容需统一采用“发言人/负责人 -> 分类加粗项 -> 要点列表”的层级格式,且要点必须提炼为精简词组,避免长句。 +- 部门汇报类内容需整合为一段连贯的总结性文字,按实际汇报顺序提取关键信息与数据,不展开为多级列表。 +- 严格保留 Markdown 标题层级(`#`、`##` 等)与段落分隔符(`---`)的结构意义,确保输出排版层级清晰。 +- 输出时自动剔除模板自带的“使用说明”“提示语”等元数据,仅生成最终会议纪要正文。 \ No newline at end of file diff --git a/web/__pycache__/server.cpython-314.pyc b/web/__pycache__/server.cpython-314.pyc index b0df7c0..9dbcbb4 100644 Binary files a/web/__pycache__/server.cpython-314.pyc and b/web/__pycache__/server.cpython-314.pyc differ diff --git a/web/server.py b/web/server.py index 348d958..3f70ed1 100644 --- a/web/server.py +++ b/web/server.py @@ -23,6 +23,7 @@ DATA_DIR = DATA_ROOT / "meetings" RESULTS_MD_DIR = PROJECT_ROOT / "data" / "results" / "md" RESULTS_JSON_DIR = PROJECT_ROOT / "data" / "results" / "json" TEMPLATE_DIR = PROJECT_ROOT / "template" +TEMPLATE_GUIDE_DIR = PROJECT_ROOT / "template_guides" PROMPT_DIR = PROJECT_ROOT / "prompt" / "zh" EXAMPLES_DIR = PROJECT_ROOT / "examples" CONFIG_FILE = PROJECT_ROOT / "config.json" @@ -32,6 +33,7 @@ DATA_DIR.mkdir(parents=True, exist_ok=True) RESULTS_MD_DIR.mkdir(parents=True, exist_ok=True) RESULTS_JSON_DIR.mkdir(parents=True, exist_ok=True) FRONTEND_ASSETS_DIR.mkdir(parents=True, exist_ok=True) +TEMPLATE_GUIDE_DIR.mkdir(parents=True, exist_ok=True) app = FastAPI(title="Meeting Summary Web") app.mount("/assets", StaticFiles(directory=str(FRONTEND_ASSETS_DIR)), name="assets") @@ -112,6 +114,48 @@ def _resolve_child(base_dir: Path, name: str) -> Path: return target +def _guide_path(template_name: str) -> Path: + return _resolve_child(TEMPLATE_GUIDE_DIR, template_name) + + +def _collect_llm_content(client, model, system_prompt: str, user_prompt: str, max_token: int = 64000) -> str: + content = [] + for chunk_type, chunk_content in _llm_stream(client, model, system_prompt, user_prompt, max_token=max_token): + if chunk_type == "content" and chunk_content: + content.append(str(chunk_content)) + return "".join(content).strip() + + +def _parse_template_guide(template_name: str, template_content: str, cfg: dict | None = None) -> str: + prompt = load_prompt("templatet_parser", "zh") + config = cfg or _load_config() + client = _get_llm_client(config) + system_prompt = prompt["system"]["role"] + prompt["mode_contracts"]["parse_template_requirements"] + user_prompt = prompt["user_template"]["template_input"].format( + template_name=template_name, + template_content=template_content, + ) + return _collect_llm_content(client, config["model_name"], system_prompt, user_prompt) + + +def _ensure_template_guide(template_name: str, *, force: bool = False, cfg: dict | None = None) -> str: + template_path = _resolve_child(TEMPLATE_DIR, template_name) + if not template_path.exists(): + raise HTTPException(404, f"Template not found: {template_name}") + + guide_path = _guide_path(template_name) + if guide_path.exists() and not force: + return guide_path.read_text(encoding="utf-8") + + guide_content = _parse_template_guide( + template_name=template_name, + template_content=template_path.read_text(encoding="utf-8"), + cfg=cfg, + ) + guide_path.write_text(guide_content, encoding="utf-8") + return guide_content + + def _find_transcript_file(meeting_id: str) -> Path | None: mdir = DATA_DIR / meeting_id for ext in (".txt", ".md"): @@ -201,25 +245,27 @@ async def file_tree(): ) tree["children"].append(branch) - def _build_flat_branch(label, base_dir, prefix, suffixes): + def _build_flat_branch(label, base_dir, prefix, suffixes, delete_mode=None): branch = {"name": label, "type": "folder", "children": []} if base_dir.exists(): for f in sorted(base_dir.iterdir()): if f.is_file() and f.suffix in suffixes: - branch["children"].append( - { - "name": f.name, - "type": "file", - "path": f"{prefix}/{f.name}", - } - ) + node = { + "name": f.name, + "type": "file", + "path": f"{prefix}/{f.name}", + } + if delete_mode: + node["delete_mode"] = delete_mode + branch["children"].append(node) tree["children"].append(branch) _build_branch("会议原文", DATA_DIR, "meetings", "meeting") _build_branch("会议结果", RESULTS_MD_DIR, "results_md", "results") _build_branch("结构化主题", RESULTS_JSON_DIR, "results_json", "results") _build_flat_branch("提示词", PROMPT_DIR, "prompts", {".yaml", ".yml"}) - _build_flat_branch("模板", TEMPLATE_DIR, "templates", {".md"}) + _build_flat_branch("模板", TEMPLATE_DIR, "templates", {".md"}, delete_mode="template") + _build_flat_branch("模板说明", TEMPLATE_GUIDE_DIR, "template_guides", {".md"}) return tree @@ -341,7 +387,7 @@ async def list_templates(): if TEMPLATE_DIR.exists(): for f in sorted(TEMPLATE_DIR.iterdir()): if f.is_file() and f.suffix == ".md": - templates.append({"name": f.name}) + templates.append({"name": f.name, "has_guide": _guide_path(f.name).exists()}) return templates @@ -350,7 +396,11 @@ async def get_template(name: str): fp = _resolve_child(TEMPLATE_DIR, name) if not fp.exists(): raise HTTPException(404, f"Template not found: {name}") - return {"name": name, "content": fp.read_text(encoding="utf-8")} + return { + "name": name, + "content": fp.read_text(encoding="utf-8"), + "has_guide": _guide_path(name).exists(), + } @app.put("/api/templates/{name}") @@ -362,6 +412,49 @@ async def save_template(name: str, payload: dict): return {"ok": True} +@app.delete("/api/templates/{name}") +async def delete_template(name: str): + template_path = _resolve_child(TEMPLATE_DIR, name) + if not template_path.exists(): + raise HTTPException(404, f"Template not found: {name}") + template_path.unlink() + + guide_path = _guide_path(name) + if guide_path.exists(): + guide_path.unlink() + return {"ok": True} + + +@app.get("/api/templates/{name}/guide") +async def get_template_guide(name: str): + template_path = _resolve_child(TEMPLATE_DIR, name) + if not template_path.exists(): + raise HTTPException(404, f"Template not found: {name}") + guide_path = _guide_path(name) + if not guide_path.exists(): + raise HTTPException(404, f"Template guide not found: {name}") + content = guide_path.read_text(encoding="utf-8") + return {"name": name, "content": content} + + +@app.put("/api/templates/{name}/guide") +async def save_template_guide(name: str, payload: dict): + content = payload.get("content") + if content is None: + raise HTTPException(400, "Missing content field") + template_path = _resolve_child(TEMPLATE_DIR, name) + if not template_path.exists(): + raise HTTPException(404, f"Template not found: {name}") + _guide_path(name).write_text(content, encoding="utf-8") + return {"ok": True} + + +@app.post("/api/templates/{name}/guide/reparse") +async def reparse_template_guide(name: str): + content = _ensure_template_guide(name, force=True) + return {"name": name, "content": content} + + @app.get("/api/prompts") async def list_prompts(): prompts = [] @@ -427,6 +520,7 @@ async def process_meeting(meeting_id: str, request: Request, template_name: str if not template_path.exists(): raise HTTPException(404, f"Template not found: {template_name}") template_content = template_path.read_text(encoding="utf-8") + template_guide = _ensure_template_guide(template_name) prompt = load_prompt("meeting_summary", "zh") cfg = _load_config() @@ -466,6 +560,8 @@ async def process_meeting(meeting_id: str, request: Request, template_name: str system_prompt = prompt["system"]["role"] + prompt["mode_contracts"]["data_summary"].format( template=template_content ) + if template_guide: + system_prompt += f"\n\n模板使用说明:\n{template_guide}" user_prompt = prompt["user_template"]["article_summary"].format( article=transcript, sub_topices=sub_topics,