Files
ai-kefu-admin/app/engine/services/ai_service.py
T
浅念 e924b3a6a4 feat(site-import): 两段生成器与数字校验器
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>
2026-10-20 20:10:16 +08:00

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),
}