Files
ai-kefu-admin/app/engine/services/ai_service.py
T
浅念 e34378257f feat: PromptProvider 化 + $DEFLECTION_POOL 单一事实源注入 + runtime 必填化
- PromptProvider(app/engine/services/ai_service.py): 租户提示词四件套
  from_dir 加载;build_system 把 system.md 的 $DEFLECTION_POOL 占位符替换为
  wall.json deflections 话术池(按语言分组一行,EN/ZH/ES)再拼事实卡;
  extractor/evaluator 的 $existing_info/$messages/$profile/$recent_messages
  替换逻辑自 AIService 原样搬入(行为等价);删模块级 import 时读文件的
  PROMPTS_DIR 与四个常量,保留 DEFAULT_PROVIDER(jiancai 出厂,__file__ 锚定)
- 两租户 system.md「三类请求的处理」第2条的迂回话术枚举 → $DEFLECTION_POOL
  占位符(保留段落说明文字),话术只存 wall.json 一份,改一处两边同步变
- TenantRuntime 加 prompts 字段(加载期 from_dir(seed_dir/prompts));
  ConversationManager 的 runtime 转必填(keyword-only),默认 AIService 用
  runtime.prompts + compiled_wall.deflections 组装
- app/engine/prompts/ 删除(grep 无代码消费方,jiancai 出厂由
  seed_profiles/jiancai/prompts 承接);test_company_facts/
  test_system_prompt_dual_contact 的 cwd 相对路径改 Path(__file__) 锚定,
  test_extractor_evaluator_prompts 断言对象改 DEFAULT_PROVIDER(语义等价)
- 新增 tests/test_prompt_provider.py(14 用例): 加载/注入/格式/占位符防漂移/
  extractor-evaluator 组装/registry 接线/runtime 必填/池线过两墙

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 19:54:47 +08:00

167 lines
7.0 KiB
Python

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 教学内容,不注入。
"""
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/<slug>/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),
}