"""Select file-backed conversation prompts from semantic routing results.""" from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any from app.core.exceptions import AppException if TYPE_CHECKING: from app.models.entities import ConversationMessage PROMPT_DIRECTORY = Path(__file__).resolve().parents[1] / "prompts" DEFAULT_PROMPT_KEY = "product_manager" @dataclass(frozen=True) class PromptDefinition: name: str filename: str PROMPT_CATALOG = { DEFAULT_PROMPT_KEY: PromptDefinition("开品产品经理", "开品产品经理-美妆护肤-角色Prompt.md"), "selection_insight": PromptDefinition("选品洞察", "01-选品洞察.md"), "project_review": PromptDefinition("立项评审", "02-立项评审.md"), "cost_pricing": PromptDefinition("成本定价", "03-成本定价.md"), "compliance_risk": PromptDefinition("合规风控", "04-合规风控.md"), "listing_selling_points": PromptDefinition("上架卖点", "05-上架卖点.md"), } SPECIALIST_PROMPT_KEYS = frozenset(PROMPT_CATALOG) - {DEFAULT_PROMPT_KEY} def _read_prompt(relative_path: str) -> str: """Read only application-owned paths; model output never becomes a path.""" try: content = (PROMPT_DIRECTORY / relative_path).read_text(encoding="utf-8-sig").strip() except (OSError, UnicodeError) as exc: raise AppException("会话 Prompt 文件无法读取,请检查部署文件", code=500, status_code=500) from exc if not content: raise AppException("会话 Prompt 文件不能为空", code=500, status_code=500) return content def semantic_system_prompt() -> str: return _read_prompt("semantic_router.md") def previous_prompt_key(messages: Sequence[ConversationMessage]) -> str: """Recover the last completed turn's route, including after a restart.""" for message in reversed(messages): if message.role != "assistant": continue semantic = (message.structured_data or {}).get("semantic") if isinstance(semantic, dict): key = semantic.get("prompt_key") if isinstance(key, str) and key in PROMPT_CATALOG: return key # A newer legacy turn must not revive a stale specialist from earlier turns. return DEFAULT_PROMPT_KEY return DEFAULT_PROMPT_KEY def semantic_history( history: Sequence[dict[str, str]], messages: Sequence[ConversationMessage], ) -> list[dict[str, str]]: return [ { "role": "system", "content": ( f"上一轮已使用的主题 prompt_key={previous_prompt_key(messages)}。" "本条是应用路由元数据。用户补充材料或无新主题地继续追问时可选择 inherit;" "用户明确提出新任务时必须重新选择主题。" ), }, *history, ] def resolve_prompt_selection( semantics: dict[str, Any], messages: Sequence[ConversationMessage], ) -> dict[str, Any]: """Resolve inheritance and reject unsupported keys before reading any file.""" result = dict(semantics) requested = result.get("prompt_key") if requested == "inherit": key = previous_prompt_key(messages) source = "inherited" elif isinstance(requested, str) and requested in PROMPT_CATALOG: key = requested source = "semantic" else: key = DEFAULT_PROMPT_KEY source = "fallback" definition = PROMPT_CATALOG[key] result.update( prompt_key=key, prompt_name=definition.name, prompt_file=definition.filename, prompt_route_source=source, ) return result def reply_system_messages(semantics: dict[str, Any]) -> list[dict[str, str]]: requested = semantics.get("prompt_key") key = requested if isinstance(requested, str) and requested in PROMPT_CATALOG else DEFAULT_PROMPT_KEY base = PROMPT_CATALOG[DEFAULT_PROMPT_KEY] messages = [ {"role": "system", "content": _read_prompt(f"product_development/{base.filename}")}, ] if key != DEFAULT_PROMPT_KEY: expert = PROMPT_CATALOG[key] messages.append( {"role": "system", "content": _read_prompt(f"product_development/{expert.filename}")}, ) messages.append( { "role": "system", "content": (f"本轮主任务:{PROMPT_CATALOG[key].name}。\n" + _read_prompt("conversation_runtime.md")), }, ) return messages