e924b3a6a4
Task 5:app/platform/site_import/generator.py——AI 两段(行业分析/ 内容填槽,GEN1/GEN2 系统提示含数据边界声明与 JSON 严格指令)+ 数字校验器(全半角/单位/千分位/波浪号归一比对,违例剔除+孤立单位 清理)+ spec §4 草稿组装(占位符填槽/出厂默认兜底/规格公开分支/ 模板残留剔除/墙尾巴三语 es 降级 null)。create_provider 加可选 max_tokens 透传(默认 None 既有调用零影响);MAX_TOKENS_TARGET=8192 (Task 1 探针结论)。AI 调用 wait_for 120s 包裹,超时不吃重试预算。 测试 38 条(provider 全 fake),全量 774 passed + 2 skipped。 Co-Authored-By: Claude Code <noreply@anthropic.com>
211 lines
9.4 KiB
Python
211 lines
9.4 KiB
Python
import json
|
|
import logging
|
|
from collections.abc import AsyncGenerator
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from app.engine.providers.base import AIProvider
|
|
from app.engine.providers.glm import GLMProvider
|
|
from app.engine.services.boundary_filter import DEFAULT_WALL
|
|
from app.engine.wall_schema import CompiledWall
|
|
# Task 10: AI 参数 resolver 收拢至 config_views(单一定义点)
|
|
from app.platform.config_views import resolve_ai_settings
|
|
|
|
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
|
|
)
|
|
|
|
|
|
# 四件套键名(单一事实源): PromptProvider 构造校验 / FileTenantSource 读盘
|
|
# / DbTenantSource 快照组形态, 三处消费同一清单
|
|
PROMPTS_KEYS = ("system", "company_facts", "extractor", "evaluator")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PromptProvider:
|
|
"""租户提示词四件套持有者(Task 6, R1 话术单一事实源)。
|
|
|
|
frozen(Task 9 收口, Task 6 审查遗留): 实例跨租户运行时/模块级
|
|
DEFAULT_PROVIDER 共享, 不可变防共享单例被毒化。
|
|
|
|
- from_dir(dir): 读 system/company_facts/extractor/evaluator 四个 md
|
|
委托 from_config——提示词属于租户(seed_profiles/<slug>/prompts/),
|
|
不再有代码级 app/engine/prompts 副本;
|
|
- from_config(md: dict)(Phase 2 Task 4): 快照 prompts 组(dict 四键)
|
|
直接构造——registry 对文件/DB 两源的统一入口(文件源读盘入快照,
|
|
DB 源嵌 config_json);缺键/值非字符串 → 显性 ValueError 带键名;
|
|
- 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 原样搬入(行为等价)。
|
|
"""
|
|
system: str
|
|
company_facts: str
|
|
extractor: str
|
|
evaluator: str
|
|
|
|
@classmethod
|
|
def from_config(cls, md: dict) -> "PromptProvider":
|
|
"""快照 prompts 组 → PromptProvider。缺键/非字符串显性炸带键名
|
|
(DB 快照坏数据不留到 build_system 才炸)。"""
|
|
if not isinstance(md, dict):
|
|
# 入口类型守卫(Task 6 的 PUT prompts 会接请求体): 字符串/
|
|
# 列表进来给 ValueError,不让 .get 的 AttributeError 漏成 500
|
|
raise ValueError(
|
|
f"prompts 必须是 dict: {{{', '.join(PROMPTS_KEYS)}}}: "
|
|
f"实际 {type(md).__name__}")
|
|
bad = [k for k in PROMPTS_KEYS if not isinstance(md.get(k), str)]
|
|
if bad:
|
|
raise ValueError(f"prompts 配置缺少(或非字符串)键: {', '.join(bad)}")
|
|
return cls(
|
|
system=md["system"],
|
|
company_facts=md["company_facts"],
|
|
extractor=md["extractor"],
|
|
evaluator=md["evaluator"])
|
|
|
|
@classmethod
|
|
def from_dir(cls, prompts_dir) -> "PromptProvider":
|
|
d = Path(prompts_dir)
|
|
return cls.from_config({
|
|
"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(config_data: dict | None = None,
|
|
max_tokens: int | None = None) -> AIProvider:
|
|
"""provider 工厂(Task 9: 按租户配置实例化 glm;None 走 env 平台默认)。
|
|
|
|
max_tokens(site-import Task 5): 可选透传 GLMProvider(默认 None 不限,
|
|
既有调用零影响)——生成器长输出场景用,对话路径不传。
|
|
"""
|
|
cfg = resolve_ai_settings(config_data)
|
|
if cfg["provider"] == "glm":
|
|
return GLMProvider(
|
|
api_key=cfg["api_key"],
|
|
model=cfg["model"],
|
|
base_url=cfg["base_url"],
|
|
max_tokens=max_tokens,
|
|
)
|
|
raise ValueError(f"Unknown AI provider: {cfg['provider']}")
|
|
|
|
|
|
class AIService:
|
|
def __init__(self, provider: AIProvider | None = None,
|
|
prompts: PromptProvider | None = None,
|
|
wall: CompiledWall | None = None,
|
|
config_data: dict | None = None):
|
|
self.provider = provider or create_provider(config_data)
|
|
# Task 6: 提示词经 PromptProvider 注入(租户四件套);默认 = jiancai
|
|
# 出厂 + 出厂墙话术池(与 DEFAULT_PROVIDER 同源,单一事实源 wall.json)
|
|
self.prompts = prompts or DEFAULT_PROVIDER
|
|
# Task 9 收口: wall 参数定型为 CompiledWall(话术池只取 .deflections,
|
|
# 与 prompts 同为租户运行时产物);缺省 = jiancai 出厂墙
|
|
self.wall_deflections = (wall.deflections if wall 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),
|
|
}
|