import json import logging from collections.abc import AsyncGenerator from pathlib import Path from app.engine.providers.base import AIProvider from app.engine.providers.glm import GLMProvider from app.engine.config import settings from app.engine.services.boundary_filter import DEFAULT_WALL logger = logging.getLogger(__name__) def _format_messages(messages: list[dict]) -> str: lines = [] for m in messages: role = m.get("role", "unknown") content = m.get("content", "") label = {"user": "客户", "assistant": "顾问"}.get(role, role) lines.append(f"{label}: {content}") return "\n".join(lines) # 话术池注入的语言行序(确定性输出;未列语言按字典序补位,不丢池) _DEFLECTION_LANG_ORDER = ("en", "zh", "es") def _format_deflection_pool(deflections) -> str: """wall.json deflections 池 → system.md $DEFLECTION_POOL 注入块。 按语言分组一行("- EN: …" / "- ZH: …"),条目引号包裹 " / " 分隔—— 紧凑不膨胀(设计 R1: 话术单一事实源,提示词侧只引用不复制)。 只渲染主池(deflections);deflections_contact 是已留资态的墙替换池, 本就不属于 system.md 教学内容,不注入。 已知边界(审查 Minor-1 注记): 条目自身含双引号/换行时暂不转义—— 现网池线均为单行纯文本,Phase 2 后台编辑器批次统一处理(输入校验+转义)。 """ pools = dict(deflections or {}) langs = [l for l in _DEFLECTION_LANG_ORDER if pools.get(l)] langs += sorted(l for l in pools if l not in _DEFLECTION_LANG_ORDER and pools.get(l)) return "\n".join( f"- {lang.upper()}: " + " / ".join(f'"{line}"' for line in pools[lang]) for lang in langs ) class PromptProvider: """租户提示词四件套持有者(Task 6, R1 话术单一事实源)。 - from_dir(dir): 读 system/company_facts/extractor/evaluator 四个 md, 存实例字段——提示词属于租户(seed_profiles//prompts/),不再有 代码级 app/engine/prompts 副本; - build_system(wall_deflections): system.md 的 $DEFLECTION_POOL 占位符 替换为按语言枚举的话术清单 + "\\n\\n" + company_facts(行为 prompt 在前、事实卡居中、当轮提醒最后拼接——拼接位在 AIService 内); - build_extractor/build_evaluator: $existing_info/$messages/$profile/ $recent_messages 替换逻辑自 AIService 原样搬入(行为等价)。 """ def __init__(self, system: str, company_facts: str, extractor: str, evaluator: str): self.system = system self.company_facts = company_facts self.extractor = extractor self.evaluator = evaluator @classmethod def from_dir(cls, prompts_dir) -> "PromptProvider": d = Path(prompts_dir) return cls( system=(d / "system.md").read_text(encoding="utf-8"), company_facts=(d / "company_facts.md").read_text(encoding="utf-8"), extractor=(d / "extractor.md").read_text(encoding="utf-8"), evaluator=(d / "evaluator.md").read_text(encoding="utf-8"), ) def build_system(self, wall_deflections=None) -> str: system = self.system if "$DEFLECTION_POOL" in system: system = system.replace( "$DEFLECTION_POOL", _format_deflection_pool(wall_deflections)) return system + "\n\n" + self.company_facts def build_extractor(self, existing_info: dict, messages: list[dict]) -> str: return self.extractor.replace( "$existing_info", json.dumps(existing_info or {}, ensure_ascii=False) ).replace("$messages", _format_messages(messages)) def build_evaluator(self, profile: dict, recent_messages: list[dict]) -> str: return self.evaluator.replace( "$profile", json.dumps(profile, ensure_ascii=False) ).replace("$recent_messages", _format_messages(recent_messages[-6:])) # 代码级默认 = jiancai 出厂(路径锚定模块位置,与 cwd 无关): 旧测试与 # 无租户上下文的默认路径用;租户路径一律经 TenantRuntime.prompts 注入。 DEFAULT_PROVIDER = PromptProvider.from_dir( Path(__file__).resolve().parents[3] / "seed_profiles" / "jiancai" / "prompts") def create_provider() -> AIProvider: if settings.AI_PROVIDER == "glm": return GLMProvider( api_key=settings.ZHIPU_API_KEY, model=settings.AI_MODEL, base_url=settings.ZHIPU_BASE_URL, ) raise ValueError(f"Unknown AI provider: {settings.AI_PROVIDER}") class AIService: def __init__(self, provider: AIProvider | None = None, prompts: PromptProvider | None = None, wall_deflections: dict | None = None): self.provider = provider or create_provider() # Task 6: 提示词经 PromptProvider 注入(租户四件套);默认 = jiancai # 出厂 + 出厂墙话术池(与 DEFAULT_PROVIDER 同源,单一事实源 wall.json) self.prompts = prompts or DEFAULT_PROVIDER self.wall_deflections = (wall_deflections if wall_deflections is not None else DEFAULT_WALL.deflections) # 对话基座: 行为 prompt 在前(红线显著性),事实卡居中(参考资料), # 当轮 context 提醒最后拼接(在 chat/stream_chat 内,保持最新鲜) self._base_chat_prompt = self.prompts.build_system(self.wall_deflections) async def chat(self, messages: list[dict], context: str = "") -> str: """Main conversation: get AI reply to user message.""" prompt = self._base_chat_prompt + ("\n\n" + context if context else "") return await self.provider.chat(messages, prompt) async def stream_chat(self, messages: list[dict], context: str = "") -> AsyncGenerator[str, None]: """Stream AI reply token by token.""" prompt = self._base_chat_prompt + ("\n\n" + context if context else "") async for token in self.provider.stream_chat(messages, prompt): yield token async def extract_info( self, messages: list[dict], existing_info: dict ) -> dict: """Extract structured info from conversation history.""" prompt = self.prompts.build_extractor(existing_info, messages) _, extracted = await self.provider.chat_with_extraction( messages=[], system_prompt=prompt ) return extracted async def evaluate( self, profile: dict, recent_messages: list[dict] ) -> dict: """Evaluate customer intent level.""" prompt = self.prompts.build_evaluator(profile, recent_messages) _, result = await self.provider.chat_with_extraction( messages=[], system_prompt=prompt ) return self._normalize_evaluation(result) @staticmethod def _normalize_evaluation(result: dict) -> dict: intent = result.get("intent_level", "pending") if intent not in ("interested", "not_interested", "pending"): intent = "pending" return { "intent_level": intent, "reasoning": result.get("reasoning", ""), "contact_info": result.get("contact_info", {}), "info_sufficient": result.get("info_sufficient", False), }