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>
This commit is contained in:
@@ -6,14 +6,96 @@ 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__)
|
||||
|
||||
PROMPTS_DIR = Path(__file__).resolve().parent.parent / "prompts"
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _load_prompt(filename: str) -> str:
|
||||
return (PROMPTS_DIR / filename).read_text(encoding="utf-8")
|
||||
# 话术池注入的语言行序(确定性输出;未列语言按字典序补位,不丢池)
|
||||
_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:
|
||||
@@ -26,28 +108,28 @@ def create_provider() -> AIProvider:
|
||||
raise ValueError(f"Unknown AI provider: {settings.AI_PROVIDER}")
|
||||
|
||||
|
||||
SYSTEM_PROMPT = _load_prompt("system.md")
|
||||
COMPANY_FACTS = _load_prompt("company_facts.md") # 公司事实卡(知识与行为分离)
|
||||
EXTRACTOR_PROMPT = _load_prompt("extractor.md")
|
||||
EVALUATOR_PROMPT = _load_prompt("evaluator.md")
|
||||
|
||||
# 对话基座: 行为 prompt 在前(红线显著性),事实卡居中(参考资料),
|
||||
# 当轮 context 提醒最后拼接(在 chat/stream_chat 内,保持最新鲜)
|
||||
_BASE_CHAT_PROMPT = SYSTEM_PROMPT + "\n\n" + COMPANY_FACTS
|
||||
|
||||
|
||||
class AIService:
|
||||
def __init__(self, provider: AIProvider | None = None):
|
||||
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 = _BASE_CHAT_PROMPT + ("\n\n" + context if context else "")
|
||||
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 = _BASE_CHAT_PROMPT + ("\n\n" + context if context else "")
|
||||
prompt = self._base_chat_prompt + ("\n\n" + context if context else "")
|
||||
async for token in self.provider.stream_chat(messages, prompt):
|
||||
yield token
|
||||
|
||||
@@ -55,9 +137,7 @@ class AIService:
|
||||
self, messages: list[dict], existing_info: dict
|
||||
) -> dict:
|
||||
"""Extract structured info from conversation history."""
|
||||
prompt = EXTRACTOR_PROMPT.replace(
|
||||
"$existing_info", json.dumps(existing_info or {}, ensure_ascii=False)
|
||||
).replace("$messages", self._format_messages(messages))
|
||||
prompt = self.prompts.build_extractor(existing_info, messages)
|
||||
_, extracted = await self.provider.chat_with_extraction(
|
||||
messages=[], system_prompt=prompt
|
||||
)
|
||||
@@ -67,24 +147,12 @@ class AIService:
|
||||
self, profile: dict, recent_messages: list[dict]
|
||||
) -> dict:
|
||||
"""Evaluate customer intent level."""
|
||||
prompt = EVALUATOR_PROMPT.replace(
|
||||
"$profile", json.dumps(profile, ensure_ascii=False)
|
||||
).replace("$recent_messages", self._format_messages(recent_messages[-6:]))
|
||||
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 _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)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_evaluation(result: dict) -> dict:
|
||||
intent = result.get("intent_level", "pending")
|
||||
|
||||
@@ -327,20 +327,24 @@ def _boundary_rotate() -> int:
|
||||
|
||||
|
||||
class ConversationManager:
|
||||
def __init__(self, db: AsyncSession, ai_service: AIService | None = None,
|
||||
runtime: "TenantRuntime | None" = None):
|
||||
def __init__(self, db: AsyncSession, *,
|
||||
runtime: "TenantRuntime",
|
||||
ai_service: AIService | None = None):
|
||||
self.db = db
|
||||
self.ai = ai_service or AIService()
|
||||
# Task 5b 装配线注入: 持有租户运行时引用(session_factory/compiled_wall/
|
||||
# config 就位;prompts 仍为模块级,Task 6 才 PromptProvider 化;email/push
|
||||
# 等 settings→config 业务接线是 Task 9)。None = 无租户上下文(直连测试)。
|
||||
# Task 6: runtime 必填(Task 5b 质量审查关门建议)——提示词/墙/会话
|
||||
# 工厂全部租户化,无租户上下文的构造不再合法;默认 AI 服务用该租户
|
||||
# 的 PromptProvider + compiled_wall.deflections 组装($DEFLECTION_POOL
|
||||
# 单一事实源注入)。email/push 等 settings→config 业务接线是 Task 9。
|
||||
self.runtime = runtime
|
||||
self.ai = ai_service or AIService(
|
||||
prompts=runtime.prompts,
|
||||
wall_deflections=runtime.compiled_wall.deflections)
|
||||
|
||||
@property
|
||||
def session_factory(self):
|
||||
"""推送后台任务写结果用的工厂(写回派发租户的库);无 runtime 时
|
||||
None,lead_push_service 回退模块级兜底。"""
|
||||
return self.runtime.session_factory if self.runtime is not None else None
|
||||
"""推送后台任务写结果用的工厂(写回派发租户的库);runtime 未懒建
|
||||
engine 时 None,lead_push_service 回退模块级兜底。"""
|
||||
return self.runtime.session_factory
|
||||
|
||||
async def create_conversation(
|
||||
self,
|
||||
|
||||
Reference in New Issue
Block a user