867 lines
30 KiB
Python
867 lines
30 KiB
Python
import asyncio
|
|
import json
|
|
import queue
|
|
import shutil
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
|
from fastapi.responses import FileResponse, StreamingResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from prompt_loader import load_prompt
|
|
|
|
FRONTEND_DIR = PROJECT_ROOT / "frontend"
|
|
FRONTEND_ASSETS_DIR = FRONTEND_DIR / "assets"
|
|
DATA_ROOT = PROJECT_ROOT / "data"
|
|
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"
|
|
APP_STATE_FILE = DATA_ROOT / "app_state.json"
|
|
|
|
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_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")
|
|
_template_lock_guard = threading.Lock()
|
|
_template_locks: dict[str, threading.Lock] = {}
|
|
|
|
|
|
def _normalize_config(raw: dict | None = None) -> dict:
|
|
data = dict(raw or {})
|
|
profiles = data.get("api_profiles") or []
|
|
default_max_tokens = int(data.get("max_tokens", 64000) or 64000)
|
|
|
|
# Backward compatibility: migrate older single-config or multi-key formats.
|
|
if not profiles:
|
|
if data.get("api_keys"):
|
|
profiles = [
|
|
{
|
|
"name": item.get("name", f"?? {index}"),
|
|
"api_base_url": data.get("api_base_url", "http://10.100.53.199:9527/v1"),
|
|
"api_key": item.get("key", ""),
|
|
"model_name": data.get("model_name", "Qwen3.6-35B"),
|
|
"max_tokens": int(item.get("max_tokens", default_max_tokens) or default_max_tokens),
|
|
}
|
|
for index, item in enumerate(data.get("api_keys") or [], start=1)
|
|
]
|
|
elif data.get("api_key"):
|
|
profiles = [
|
|
{
|
|
"name": "????",
|
|
"api_base_url": data.get("api_base_url", "http://10.100.53.199:9527/v1"),
|
|
"api_key": data.get("api_key", ""),
|
|
"model_name": data.get("model_name", "Qwen3.6-35B"),
|
|
"max_tokens": default_max_tokens,
|
|
}
|
|
]
|
|
|
|
normalized_profiles = []
|
|
for index, item in enumerate(profiles, start=1):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
api_key = str(item.get("api_key", "")).strip()
|
|
api_base_url = str(item.get("api_base_url", "")).strip()
|
|
model_name = str(item.get("model_name", "")).strip()
|
|
max_tokens = int(item.get("max_tokens", default_max_tokens) or default_max_tokens)
|
|
if not api_key or not api_base_url or not model_name:
|
|
continue
|
|
profile_name = str(item.get("name", "")).strip() or f"?? {index}"
|
|
normalized_profiles.append(
|
|
{
|
|
"name": profile_name,
|
|
"api_base_url": api_base_url,
|
|
"api_key": api_key,
|
|
"model_name": model_name,
|
|
"max_tokens": max_tokens,
|
|
}
|
|
)
|
|
|
|
if not normalized_profiles:
|
|
normalized_profiles = [
|
|
{
|
|
"name": "????",
|
|
"api_base_url": "http://10.100.53.199:9527/v1",
|
|
"api_key": "unis123",
|
|
"model_name": "Qwen3.6-35B",
|
|
"max_tokens": default_max_tokens,
|
|
}
|
|
]
|
|
|
|
active_profile_name = str(data.get("active_api_profile_name") or data.get("active_api_key_name") or "").strip()
|
|
if not any(item["name"] == active_profile_name for item in normalized_profiles):
|
|
active_profile_name = normalized_profiles[0]["name"]
|
|
|
|
active_profile = next(
|
|
(item for item in normalized_profiles if item["name"] == active_profile_name),
|
|
normalized_profiles[0],
|
|
)
|
|
return {
|
|
"api_profiles": normalized_profiles,
|
|
"active_api_profile_name": active_profile_name,
|
|
"api_base_url": active_profile["api_base_url"],
|
|
"api_key": active_profile["api_key"],
|
|
"model_name": active_profile["model_name"],
|
|
"max_tokens": active_profile["max_tokens"],
|
|
}
|
|
|
|
|
|
def _load_config() -> dict:
|
|
if CONFIG_FILE.exists():
|
|
return _normalize_config(json.loads(CONFIG_FILE.read_text(encoding="utf-8")))
|
|
return _normalize_config()
|
|
|
|
|
|
def _save_config(cfg: dict):
|
|
CONFIG_FILE.write_text(
|
|
json.dumps(_normalize_config(cfg), ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _load_app_state() -> dict:
|
|
if APP_STATE_FILE.exists():
|
|
return json.loads(APP_STATE_FILE.read_text(encoding="utf-8"))
|
|
return {"active_meeting_id": None}
|
|
|
|
|
|
def _save_app_state(state: dict):
|
|
APP_STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
|
|
def _set_active_meeting(meeting_id: str | None):
|
|
state = _load_app_state()
|
|
state["active_meeting_id"] = meeting_id
|
|
_save_app_state(state)
|
|
|
|
|
|
def _get_llm_client(cfg: dict):
|
|
from openai import OpenAI
|
|
|
|
return OpenAI(api_key=cfg["api_key"], base_url=cfg["api_base_url"])
|
|
|
|
|
|
def _cfg_max_tokens(cfg: dict) -> int:
|
|
return int(cfg.get("max_tokens", 64000) or 64000)
|
|
|
|
|
|
def _llm_stream(client, model, system_prompt, user_prompt, max_token=64000):
|
|
response = client.chat.completions.create(
|
|
model=model,
|
|
messages=[
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": user_prompt},
|
|
],
|
|
temperature=0.7,
|
|
max_tokens=max_token,
|
|
stream=True,
|
|
)
|
|
for chunk in response:
|
|
delta = chunk.choices[0].delta
|
|
content = getattr(delta, "content", None)
|
|
reasoning = (
|
|
getattr(delta, "reasoning", None)
|
|
or getattr(delta, "reasoning_content", None)
|
|
)
|
|
if isinstance(content, list):
|
|
content = "".join(str(item) for item in content if item)
|
|
if isinstance(reasoning, list):
|
|
reasoning = "".join(str(item) for item in reasoning if item)
|
|
|
|
if reasoning:
|
|
yield "reasoning", reasoning
|
|
if content:
|
|
yield "content", content
|
|
|
|
|
|
def _read_meeting_meta(meeting_id: str) -> dict:
|
|
meta_path = DATA_DIR / meeting_id / "meta.json"
|
|
if meta_path.exists():
|
|
return json.loads(meta_path.read_text(encoding="utf-8"))
|
|
return {"name": meeting_id, "created_at": ""}
|
|
|
|
|
|
def _write_meeting_meta(meeting_id: str, meta: dict):
|
|
meta_path = DATA_DIR / meeting_id / "meta.json"
|
|
meta_path.parent.mkdir(parents=True, exist_ok=True)
|
|
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
|
|
def _resolve_child(base_dir: Path, name: str) -> Path:
|
|
target = (base_dir / name).resolve()
|
|
if target.parent != base_dir.resolve():
|
|
raise HTTPException(400, "Invalid resource path")
|
|
return target
|
|
|
|
|
|
def _guide_path(template_name: str) -> Path:
|
|
return _resolve_child(TEMPLATE_GUIDE_DIR, template_name)
|
|
|
|
|
|
def _get_template_lock(template_name: str) -> threading.Lock:
|
|
with _template_lock_guard:
|
|
if template_name not in _template_locks:
|
|
_template_locks[template_name] = threading.Lock()
|
|
return _template_locks[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,
|
|
user_notes: str = "",
|
|
) -> 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,
|
|
)
|
|
if user_notes.strip():
|
|
user_prompt += f"\n\n用户补充的模板使用说明(优先参考,可为空):\n{user_notes.strip()}"
|
|
return _collect_llm_content(client, config["model_name"], system_prompt, user_prompt)
|
|
|
|
|
|
def _build_template_guide_prompts(
|
|
template_name: str,
|
|
template_content: str,
|
|
user_notes: str = "",
|
|
) -> tuple[dict, str, str]:
|
|
prompt = load_prompt("templatet_parser", "zh")
|
|
cfg = _load_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,
|
|
)
|
|
if user_notes.strip():
|
|
user_prompt += f"\n\n??????????????????????\n{user_notes.strip()}"
|
|
return cfg, system_prompt, user_prompt
|
|
|
|
|
|
def _ensure_template_guide(
|
|
template_name: str,
|
|
*,
|
|
force: bool = False,
|
|
cfg: dict | None = None,
|
|
user_notes: str = "",
|
|
) -> str:
|
|
template_path = _resolve_child(TEMPLATE_DIR, template_name)
|
|
if not template_path.exists():
|
|
raise HTTPException(404, f"Template not found: {template_name}")
|
|
|
|
lock = _get_template_lock(template_name)
|
|
with lock:
|
|
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,
|
|
user_notes=user_notes,
|
|
)
|
|
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"):
|
|
fp = mdir / f"transcript{ext}"
|
|
if fp.exists():
|
|
return fp
|
|
return None
|
|
|
|
|
|
def _meeting_summary(meeting_id: str) -> dict:
|
|
meta = _read_meeting_meta(meeting_id)
|
|
transcript_file = _find_transcript_file(meeting_id)
|
|
result_md = RESULTS_MD_DIR / meeting_id / "meeting_summary.md"
|
|
result_json = RESULTS_JSON_DIR / meeting_id / "sub_topic.json"
|
|
return {
|
|
"id": meeting_id,
|
|
"name": meta.get("name", meeting_id),
|
|
"created_at": meta.get("created_at", ""),
|
|
"original_filename": meta.get("original_filename", ""),
|
|
"transcript_filename": transcript_file.name if transcript_file else "",
|
|
"has_transcript": transcript_file is not None,
|
|
"has_summary": result_md.exists(),
|
|
"has_topics": result_json.exists(),
|
|
}
|
|
|
|
|
|
def _list_meeting_ids() -> list[str]:
|
|
if not DATA_DIR.exists():
|
|
return []
|
|
return sorted([p.name for p in DATA_DIR.iterdir() if p.is_dir()], reverse=True)
|
|
|
|
|
|
def _list_meetings() -> list[dict]:
|
|
return [_meeting_summary(meeting_id) for meeting_id in _list_meeting_ids()]
|
|
|
|
|
|
@app.get("/")
|
|
async def index():
|
|
return FileResponse(str(FRONTEND_DIR / "index.html"))
|
|
|
|
|
|
@app.get("/api/settings")
|
|
async def get_settings():
|
|
return _load_config()
|
|
|
|
|
|
@app.put("/api/settings")
|
|
async def save_settings(cfg: dict):
|
|
required = {"api_profiles", "active_api_profile_name"}
|
|
if not required.issubset(cfg.keys()):
|
|
raise HTTPException(400, f"Missing fields: {required - set(cfg.keys())}")
|
|
normalized = _normalize_config(cfg)
|
|
if not normalized["api_profiles"]:
|
|
raise HTTPException(400, "At least one API profile is required")
|
|
_save_config(normalized)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/tree")
|
|
async def file_tree():
|
|
active_meeting_id = _load_app_state().get("active_meeting_id")
|
|
tree = {"name": "workspace", "type": "folder", "children": []}
|
|
|
|
def _build_branch(label, base_dir, prefix, delete_mode):
|
|
branch = {"name": label, "type": "folder", "children": []}
|
|
if base_dir.exists():
|
|
for subdir in sorted(base_dir.iterdir()):
|
|
if not subdir.is_dir():
|
|
continue
|
|
meta = _read_meeting_meta(subdir.name)
|
|
children = []
|
|
for f in sorted(subdir.iterdir()):
|
|
if f.is_file() and f.name != "meta.json":
|
|
children.append(
|
|
{
|
|
"name": f.name,
|
|
"type": "file",
|
|
"path": f"{prefix}/{subdir.name}/{f.name}",
|
|
}
|
|
)
|
|
branch["children"].append(
|
|
{
|
|
"name": meta.get("name", subdir.name),
|
|
"type": "folder",
|
|
"id": subdir.name,
|
|
"active": subdir.name == active_meeting_id,
|
|
"delete_mode": delete_mode,
|
|
"children": children,
|
|
}
|
|
)
|
|
tree["children"].append(branch)
|
|
|
|
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:
|
|
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"}, delete_mode="template")
|
|
_build_flat_branch("模板说明", TEMPLATE_GUIDE_DIR, "template_guides", {".md"})
|
|
return tree
|
|
|
|
|
|
@app.get("/api/meetings")
|
|
async def list_meetings():
|
|
active_meeting_id = _load_app_state().get("active_meeting_id")
|
|
return {"active_meeting_id": active_meeting_id, "meetings": _list_meetings()}
|
|
|
|
|
|
@app.get("/api/current-meeting")
|
|
async def get_current_meeting():
|
|
active_meeting_id = _load_app_state().get("active_meeting_id")
|
|
if not active_meeting_id:
|
|
return {"active_meeting_id": None, "meeting": None}
|
|
if not (DATA_DIR / active_meeting_id).exists():
|
|
_set_active_meeting(None)
|
|
return {"active_meeting_id": None, "meeting": None}
|
|
return {
|
|
"active_meeting_id": active_meeting_id,
|
|
"meeting": _meeting_summary(active_meeting_id),
|
|
}
|
|
|
|
|
|
@app.put("/api/current-meeting")
|
|
async def set_current_meeting(payload: dict):
|
|
meeting_id = payload.get("meeting_id")
|
|
if meeting_id is not None and not (DATA_DIR / meeting_id).exists():
|
|
raise HTTPException(404, "Meeting not found")
|
|
_set_active_meeting(meeting_id)
|
|
return {
|
|
"ok": True,
|
|
"active_meeting_id": meeting_id,
|
|
"meeting": _meeting_summary(meeting_id) if meeting_id else None,
|
|
}
|
|
|
|
|
|
@app.post("/api/meetings/import")
|
|
async def import_meeting(name: str = Form(...), file: UploadFile = File(...)):
|
|
if not file.filename:
|
|
raise HTTPException(400, "No file selected")
|
|
ext = Path(file.filename).suffix.lower()
|
|
if ext not in (".txt", ".md"):
|
|
raise HTTPException(400, "Only .txt and .md files are supported")
|
|
|
|
meeting_id = str(int(time.time() * 1000))
|
|
meeting_dir = DATA_DIR / meeting_id
|
|
meeting_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
content = await file.read()
|
|
try:
|
|
text = content.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
text = content.decode("gbk", errors="replace")
|
|
|
|
dest = "transcript" + ext
|
|
(meeting_dir / dest).write_text(text, encoding="utf-8")
|
|
_write_meeting_meta(
|
|
meeting_id,
|
|
{
|
|
"name": name,
|
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"original_filename": file.filename,
|
|
},
|
|
)
|
|
_set_active_meeting(meeting_id)
|
|
return {"id": meeting_id, "name": name}
|
|
|
|
|
|
@app.post("/api/templates/import")
|
|
async def import_template(name: str = Form(...), file: UploadFile = File(...)):
|
|
if not file.filename:
|
|
raise HTTPException(400, "No file selected")
|
|
|
|
template_name = name.strip()
|
|
if not template_name:
|
|
raise HTTPException(400, "Template name is required")
|
|
if not template_name.lower().endswith(".md"):
|
|
template_name += ".md"
|
|
|
|
ext = Path(file.filename).suffix.lower()
|
|
if ext != ".md":
|
|
raise HTTPException(400, "Only .md template files are supported")
|
|
|
|
content = await file.read()
|
|
try:
|
|
text = content.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
text = content.decode("gbk", errors="replace")
|
|
|
|
target = _resolve_child(TEMPLATE_DIR, template_name)
|
|
target.write_text(text, encoding="utf-8")
|
|
|
|
guide_path = _guide_path(template_name)
|
|
if guide_path.exists():
|
|
guide_path.unlink()
|
|
|
|
return {"name": template_name}
|
|
|
|
|
|
@app.delete("/api/meetings/{meeting_id}")
|
|
async def delete_meeting(meeting_id: str):
|
|
if not (DATA_DIR / meeting_id).exists():
|
|
raise HTTPException(404, "Meeting not found")
|
|
|
|
active_meeting_id = _load_app_state().get("active_meeting_id")
|
|
for base in (DATA_DIR, RESULTS_MD_DIR, RESULTS_JSON_DIR):
|
|
meeting_dir = base / meeting_id
|
|
if meeting_dir.exists():
|
|
shutil.rmtree(str(meeting_dir))
|
|
|
|
if active_meeting_id == meeting_id:
|
|
remaining = _list_meeting_ids()
|
|
_set_active_meeting(remaining[0] if remaining else None)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.delete("/api/meetings/{meeting_id}/results")
|
|
async def delete_meeting_results(meeting_id: str):
|
|
deleted = False
|
|
for base in (RESULTS_MD_DIR, RESULTS_JSON_DIR):
|
|
meeting_dir = base / meeting_id
|
|
if meeting_dir.exists():
|
|
shutil.rmtree(str(meeting_dir))
|
|
deleted = True
|
|
if not deleted:
|
|
raise HTTPException(404, "Meeting results not found")
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/meetings/{meeting_id}/file/{filename:path}")
|
|
async def get_meeting_file(meeting_id: str, filename: str):
|
|
for base in (DATA_DIR, RESULTS_MD_DIR, RESULTS_JSON_DIR):
|
|
fp = base / meeting_id / filename
|
|
if fp.exists():
|
|
return {"content": fp.read_text(encoding="utf-8"), "filename": filename}
|
|
raise HTTPException(404, f"File not found: {filename}")
|
|
|
|
|
|
@app.get("/api/examples/{filename:path}")
|
|
async def get_example_file(filename: str):
|
|
fp = EXAMPLES_DIR / filename
|
|
if not fp.exists():
|
|
raise HTTPException(404, f"File not found: {filename}")
|
|
return {"content": fp.read_text(encoding="utf-8"), "filename": filename}
|
|
|
|
|
|
@app.get("/api/templates")
|
|
async def list_templates():
|
|
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, "has_guide": _guide_path(f.name).exists()})
|
|
return templates
|
|
|
|
|
|
@app.get("/api/templates/{name}")
|
|
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"),
|
|
"has_guide": _guide_path(name).exists(),
|
|
}
|
|
|
|
|
|
@app.put("/api/templates/{name}")
|
|
async def save_template(name: str, payload: dict):
|
|
content = payload.get("content")
|
|
if content is None:
|
|
raise HTTPException(400, "Missing content field")
|
|
_resolve_child(TEMPLATE_DIR, name).write_text(content, encoding="utf-8")
|
|
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}")
|
|
with _get_template_lock(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, user_notes: str = ""):
|
|
content = _ensure_template_guide(name, force=True, user_notes=user_notes)
|
|
return {"name": name, "content": content}
|
|
|
|
|
|
@app.get("/api/templates/{name}/guide/reparse/stream")
|
|
async def reparse_template_guide_stream(name: str, request: Request, user_notes: str = ""):
|
|
template_path = _resolve_child(TEMPLATE_DIR, name)
|
|
if not template_path.exists():
|
|
raise HTTPException(404, f"Template not found: {name}")
|
|
|
|
template_content = template_path.read_text(encoding="utf-8")
|
|
events = queue.Queue()
|
|
|
|
def run():
|
|
try:
|
|
lock = _get_template_lock(name)
|
|
with lock:
|
|
cfg, system_prompt, user_prompt = _build_template_guide_prompts(name, template_content, user_notes)
|
|
client = _get_llm_client(cfg)
|
|
events.put({"type": "status", "data": "parsing"})
|
|
|
|
result = ""
|
|
for chunk_type, chunk_content in _llm_stream(
|
|
client,
|
|
cfg["model_name"],
|
|
system_prompt,
|
|
user_prompt,
|
|
max_token=_cfg_max_tokens(cfg),
|
|
):
|
|
if not chunk_content:
|
|
continue
|
|
text = str(chunk_content)
|
|
events.put({"type": "chunk", "data": {"chunk_type": chunk_type, "text": text}})
|
|
if chunk_type == "content":
|
|
result += text
|
|
|
|
_guide_path(name).write_text(result, encoding="utf-8")
|
|
events.put({"type": "done", "data": {"name": name, "content": result}})
|
|
except Exception as exc:
|
|
events.put({"type": "error", "data": str(exc)})
|
|
|
|
threading.Thread(target=run, daemon=True).start()
|
|
|
|
async def gen():
|
|
loop = asyncio.get_running_loop()
|
|
while True:
|
|
if await request.is_disconnected():
|
|
break
|
|
try:
|
|
event = await loop.run_in_executor(None, events.get, True, 0.5)
|
|
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
|
if event["type"] in {"done", "error"}:
|
|
break
|
|
except queue.Empty:
|
|
yield ": heartbeat\n\n"
|
|
|
|
return StreamingResponse(
|
|
gen(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no",
|
|
"Connection": "keep-alive",
|
|
},
|
|
)
|
|
|
|
|
|
@app.get("/api/prompts")
|
|
async def list_prompts():
|
|
prompts = []
|
|
if PROMPT_DIR.exists():
|
|
for f in sorted(PROMPT_DIR.iterdir()):
|
|
if f.is_file() and f.suffix in {".yaml", ".yml"}:
|
|
prompts.append({"name": f.name})
|
|
return prompts
|
|
|
|
|
|
@app.get("/api/prompts/{name}")
|
|
async def get_prompt(name: str):
|
|
fp = _resolve_child(PROMPT_DIR, name)
|
|
if not fp.exists():
|
|
raise HTTPException(404, f"Prompt not found: {name}")
|
|
return {"name": name, "content": fp.read_text(encoding="utf-8")}
|
|
|
|
|
|
@app.put("/api/prompts/{name}")
|
|
async def save_prompt(name: str, payload: dict):
|
|
content = payload.get("content")
|
|
if content is None:
|
|
raise HTTPException(400, "Missing content field")
|
|
_resolve_child(PROMPT_DIR, name).write_text(content, encoding="utf-8")
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/meetings/{meeting_id}/transcript")
|
|
async def get_meeting_transcript(meeting_id: str):
|
|
fp = _find_transcript_file(meeting_id)
|
|
if fp:
|
|
return {"content": fp.read_text(encoding="utf-8"), "filename": fp.name}
|
|
raise HTTPException(404, "No transcript found")
|
|
|
|
|
|
@app.put("/api/meetings/{meeting_id}/summary")
|
|
async def save_meeting_summary(meeting_id: str, payload: dict):
|
|
content = payload.get("content")
|
|
if content is None:
|
|
raise HTTPException(400, "Missing content field")
|
|
if not (DATA_DIR / meeting_id).exists():
|
|
raise HTTPException(404, "Meeting not found")
|
|
|
|
summary_dir = RESULTS_MD_DIR / meeting_id
|
|
summary_dir.mkdir(parents=True, exist_ok=True)
|
|
(summary_dir / "meeting_summary.md").write_text(content, encoding="utf-8")
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/meetings/{meeting_id}/process")
|
|
async def process_meeting(
|
|
meeting_id: str,
|
|
request: Request,
|
|
template_name: str = "template1.md",
|
|
user_notes: str = "",
|
|
):
|
|
meeting_dir = DATA_DIR / meeting_id
|
|
if not meeting_dir.exists():
|
|
raise HTTPException(404, "Meeting not found")
|
|
_set_active_meeting(meeting_id)
|
|
|
|
transcript_file = _find_transcript_file(meeting_id)
|
|
if transcript_file is None:
|
|
raise HTTPException(400, "No transcript found")
|
|
transcript = transcript_file.read_text(encoding="utf-8")
|
|
|
|
template_path = _resolve_child(TEMPLATE_DIR, template_name)
|
|
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()
|
|
model_name = cfg["model_name"]
|
|
events = queue.Queue()
|
|
|
|
def run():
|
|
try:
|
|
client = _get_llm_client(cfg)
|
|
|
|
events.put({"type": "status", "data": "preprocessing"})
|
|
system_prompt = prompt["system"]["role"] + prompt["mode_contracts"]["data_preproces"]
|
|
user_prompt = prompt["user_template"]["article_preproces"].format(article=transcript)
|
|
|
|
sub_topics = ""
|
|
for chunk_type, chunk_content in _llm_stream(
|
|
client,
|
|
model_name,
|
|
system_prompt,
|
|
user_prompt,
|
|
max_token=_cfg_max_tokens(cfg),
|
|
):
|
|
if chunk_content:
|
|
text = str(chunk_content)
|
|
events.put({"type": "chunk", "data": {"stage": 1, "chunk_type": chunk_type, "text": text}})
|
|
if chunk_type == "content":
|
|
sub_topics += text
|
|
|
|
events.put({"type": "status", "data": "preprocessing_done"})
|
|
|
|
results_json_dir = RESULTS_JSON_DIR / meeting_id
|
|
results_json_dir.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
data = json.loads(sub_topics)
|
|
(results_json_dir / "sub_topic.json").write_text(
|
|
json.dumps(data, ensure_ascii=False, indent=4),
|
|
encoding="utf-8",
|
|
)
|
|
except Exception:
|
|
(results_json_dir / "sub_topic.json").write_text(sub_topics, encoding="utf-8")
|
|
|
|
events.put({"type": "status", "data": "summarizing"})
|
|
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,
|
|
)
|
|
if user_notes.strip():
|
|
user_prompt += f"\n\n用户补充要点(优先参考,可为空):\n{user_notes.strip()}"
|
|
|
|
result = ""
|
|
for chunk_type, chunk_content in _llm_stream(
|
|
client,
|
|
model_name,
|
|
system_prompt,
|
|
user_prompt,
|
|
max_token=_cfg_max_tokens(cfg),
|
|
):
|
|
if chunk_content:
|
|
text = str(chunk_content)
|
|
events.put({"type": "chunk", "data": {"stage": 2, "chunk_type": chunk_type, "text": text}})
|
|
if chunk_type == "content":
|
|
result += text
|
|
|
|
results_md_dir = RESULTS_MD_DIR / meeting_id
|
|
results_md_dir.mkdir(parents=True, exist_ok=True)
|
|
(results_md_dir / "meeting_summary.md").write_text(result, encoding="utf-8")
|
|
events.put({"type": "done", "data": {"result": result}})
|
|
except Exception as exc:
|
|
events.put({"type": "error", "data": str(exc)})
|
|
|
|
threading.Thread(target=run, daemon=True).start()
|
|
|
|
async def gen():
|
|
loop = asyncio.get_running_loop()
|
|
while True:
|
|
if await request.is_disconnected():
|
|
break
|
|
try:
|
|
event = await loop.run_in_executor(None, events.get, True, 0.5)
|
|
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
|
if event["type"] in {"done", "error"}:
|
|
break
|
|
except queue.Empty:
|
|
yield ": heartbeat\n\n"
|
|
|
|
return StreamingResponse(
|
|
gen(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no",
|
|
"Connection": "keep-alive",
|
|
},
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|