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:
@@ -61,6 +61,38 @@ def _reset_rate_limit():
|
||||
_reset_for_test()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jiancai_runtime():
|
||||
"""直连测试用的 jiancai TenantRuntime(Task 6: manager 的 runtime 必填)。
|
||||
|
||||
委托 build_jiancai_runtime()(见其注释: 只走加载期,无 engine 无句柄,
|
||||
跨测试共享单例安全)。
|
||||
"""
|
||||
return build_jiancai_runtime()
|
||||
|
||||
|
||||
_JIANCAI_RUNTIME = None
|
||||
|
||||
|
||||
def build_jiancai_runtime():
|
||||
"""(非夹具)jiancai TenantRuntime——给批量集成测试的 _setup 辅助线用,
|
||||
免去百处测试签名逐一加夹具。
|
||||
|
||||
只走加载期(config/prompts/compiled_wall 就位),从不 ensure_ready——
|
||||
engine/session_factory 保持 None: 直连测试自管 async_db(NullPool 跨
|
||||
循环纪律),manager.session_factory 为 None 时 lead_push_service 回退
|
||||
模块级兜底,与旧 runtime=None 行为一致。无 engine 即无句柄,无需清理;
|
||||
data_dir 仅拼 db_path 字符串(无 IO),模块级缓存单例跨测试复用。
|
||||
"""
|
||||
global _JIANCAI_RUNTIME
|
||||
if _JIANCAI_RUNTIME is None:
|
||||
reg = TenantRegistry.load_from_dir(
|
||||
SEED_DIR, data_dir=REPO_ROOT / "tenants")
|
||||
_JIANCAI_RUNTIME = next(
|
||||
rt for rt in reg.runtimes() if rt.config.slug == "jiancai")
|
||||
return _JIANCAI_RUNTIME
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
"""TestClient + 每测试独立 Registry(tmp_path 作 data_dir)。
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
"""公司事实卡: 拼接位置与墙安全性静态测试。"""
|
||||
from pathlib import Path
|
||||
"""公司事实卡: 拼接位置与墙安全性静态测试。
|
||||
|
||||
from app.engine.services.ai_service import AIService, COMPANY_FACTS, SYSTEM_PROMPT
|
||||
Task 6 迁移: 提示词四件套随租户化落位 seed_profiles/<slug>/prompts/,
|
||||
代码级默认 = DEFAULT_PROVIDER(jiancai 出厂,与 seed_profiles/jiancai/
|
||||
prompts 逐字节同源)——本文件断言对象从模块常量改为 DEFAULT_PROVIDER
|
||||
字段,断言语义等价。
|
||||
"""
|
||||
from app.engine.services.ai_service import AIService, DEFAULT_PROVIDER
|
||||
|
||||
FACTS_PATH = Path("app/engine/prompts/company_facts.md")
|
||||
SYSTEM_PROMPT = DEFAULT_PROVIDER.system
|
||||
COMPANY_FACTS = DEFAULT_PROVIDER.company_facts
|
||||
EXTRACTOR_PROMPT = DEFAULT_PROVIDER.extractor
|
||||
EVALUATOR_PROMPT = DEFAULT_PROVIDER.evaluator
|
||||
|
||||
|
||||
class _CapturingProvider:
|
||||
@@ -28,14 +35,16 @@ def test_facts_concatenated_between_system_and_context():
|
||||
asyncio.run(svc.chat([{"role": "user", "content": "hi"}], context="⚠️ 提醒"))
|
||||
p = svc.provider.prompts[0]
|
||||
assert "HESUPIPE" in p and "HDPE" in p # 建材事实在场
|
||||
assert p.index(SYSTEM_PROMPT) < p.index(COMPANY_FACTS) < p.index("⚠️ 提醒")
|
||||
# Task 6: system 组装含 $DEFLECTION_POOL 话术池注入,SYSTEM_PROMPT 原文
|
||||
# 不再是组装结果的连续子串——改以首行/事实卡标题锚定顺序(语义等价)
|
||||
assert (p.index(SYSTEM_PROMPT.splitlines()[0])
|
||||
< p.index("# HESUPIPE 公司事实卡") < p.index("⚠️ 提醒"))
|
||||
|
||||
|
||||
def test_facts_absent_from_extractor_prompt():
|
||||
# extractor/evaluator 不拼事实卡(它们有自己的 prompt,不该看到公司事实)
|
||||
# Task 4 起 extractor/evaluator 含通用品类词(HDPE/PVC等字段示例)——
|
||||
# 隔离断言只针对品牌/公司事实标记(HESUPIPE),不再卡通用品类词
|
||||
from app.engine.services.ai_service import EVALUATOR_PROMPT, EXTRACTOR_PROMPT
|
||||
assert "HESUPIPE" not in EXTRACTOR_PROMPT
|
||||
assert "HESUPIPE" not in EVALUATOR_PROMPT
|
||||
# 河南和硕/和硕集团 等公司主体信息同样不得泄入
|
||||
|
||||
@@ -20,17 +20,17 @@ async def _raw_info_for(async_db, conv_id) -> dict:
|
||||
return profile.raw_info or {}
|
||||
|
||||
|
||||
async def test_no_context_keeps_raw_info_empty(async_db):
|
||||
async def test_no_context_keeps_raw_info_empty(async_db, jiancai_runtime):
|
||||
async with async_db() as db:
|
||||
mgr = ConversationManager(db)
|
||||
mgr = ConversationManager(db, runtime=jiancai_runtime)
|
||||
conv = await mgr.create_conversation(context=None)
|
||||
raw = await _raw_info_for(async_db, conv.id)
|
||||
assert raw == {}
|
||||
|
||||
|
||||
async def test_context_seeds_whitelist_only(async_db):
|
||||
async def test_context_seeds_whitelist_only(async_db, jiancai_runtime):
|
||||
async with async_db() as db:
|
||||
mgr = ConversationManager(db)
|
||||
mgr = ConversationManager(db, runtime=jiancai_runtime)
|
||||
conv = await mgr.create_conversation(
|
||||
context={
|
||||
"product_name": "HDPE PE100 Pipe DN110",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
"""提取/评估提示词建材化: 字段与占位符静态断言(Task 4)。"""
|
||||
from app.engine.services.ai_service import EVALUATOR_PROMPT, EXTRACTOR_PROMPT
|
||||
"""提取/评估提示词建材化: 字段与占位符静态断言(Task 4)。
|
||||
|
||||
Task 6 迁移: 模块常量 EXTRACTOR_PROMPT/EVALUATOR_PROMPT 随 PromptProvider 化
|
||||
删除,断言对象改为 DEFAULT_PROVIDER 字段(jiancai 出厂,与
|
||||
seed_profiles/jiancai/prompts 同源)——断言语义等价。
|
||||
"""
|
||||
from app.engine.services.ai_service import DEFAULT_PROVIDER
|
||||
|
||||
EXTRACTOR_PROMPT = DEFAULT_PROVIDER.extractor
|
||||
EVALUATOR_PROMPT = DEFAULT_PROVIDER.evaluator
|
||||
|
||||
# 建材字段清单(与 system.md "关注的信息"节口径一致)
|
||||
PIPE_FIELDS = (
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.engine.models import (
|
||||
)
|
||||
from app.engine.services.conversation import CONTACT_ASK_TRIGGER_RE, ConversationManager
|
||||
from app.engine.services import email_service
|
||||
from tests.conftest import build_jiancai_runtime
|
||||
|
||||
|
||||
class FakeAI:
|
||||
@@ -77,7 +78,9 @@ def _setup(async_db, monkeypatch, source=ConversationSource.widget, ai=None):
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
manager = ConversationManager(db, ai_service=ai or FakeAI())
|
||||
manager = ConversationManager(
|
||||
db, runtime=build_jiancai_runtime(),
|
||||
ai_service=ai or FakeAI())
|
||||
conv = await manager.create_conversation(source=source)
|
||||
return manager, conv
|
||||
return asyncio.run(run()), sent
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import select
|
||||
from app.engine.models import ConversationSource, LeadPushLog
|
||||
from app.engine.services import email_service, lead_push_service
|
||||
from app.engine.services.conversation import ConversationManager
|
||||
from tests.conftest import build_jiancai_runtime
|
||||
|
||||
|
||||
class FakeAI:
|
||||
@@ -55,7 +56,8 @@ def _setup(async_db, monkeypatch, ai, lead_succeeds=True):
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
manager = ConversationManager(db, ai_service=ai)
|
||||
manager = ConversationManager(db, runtime=build_jiancai_runtime(),
|
||||
ai_service=ai)
|
||||
conv = await manager.create_conversation(
|
||||
source=ConversationSource.widget)
|
||||
return manager, conv
|
||||
@@ -169,7 +171,8 @@ def test_push_disabled_writes_nothing(async_db, monkeypatch):
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
manager = ConversationManager(db, ai_service=ai)
|
||||
manager = ConversationManager(db, runtime=build_jiancai_runtime(),
|
||||
ai_service=ai)
|
||||
conv = await manager.create_conversation(
|
||||
source=ConversationSource.widget)
|
||||
return manager, conv
|
||||
@@ -201,7 +204,8 @@ def test_fire_end_to_end_single_loop(async_db, monkeypatch):
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
manager = ConversationManager(db, ai_service=ai)
|
||||
manager = ConversationManager(db, runtime=build_jiancai_runtime(),
|
||||
ai_service=ai)
|
||||
conv = await manager.create_conversation(
|
||||
source=ConversationSource.widget)
|
||||
async for _ in manager.stream_process_message(
|
||||
@@ -260,7 +264,8 @@ def test_fire_happens_only_after_commit(async_db, monkeypatch):
|
||||
|
||||
async def run():
|
||||
async with async_db() as db:
|
||||
manager = ConversationManager(db, ai_service=ai)
|
||||
manager = ConversationManager(db, runtime=build_jiancai_runtime(),
|
||||
ai_service=ai)
|
||||
conv = await manager.create_conversation(
|
||||
source=ConversationSource.widget)
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""PromptProvider(Task 6): 租户提示词四件套 + $DEFLECTION_POOL 单一事实源注入。
|
||||
|
||||
- 提示词从租户目录(seed_profiles/<slug>/prompts)读,不再有代码级
|
||||
app/engine/prompts 副本;
|
||||
- system.md 的迂回话术枚举段替换为 $DEFLECTION_POOL 占位符,组装时注入
|
||||
wall.json deflections 池(话术只存 wall.json 一份,改一处两边同步变);
|
||||
- extractor/evaluator 的 $existing_info/$messages/$profile/$recent_messages
|
||||
替换逻辑从 AIService 原样搬入 Provider(行为等价)。
|
||||
|
||||
路径纪律: 一律 Path(__file__) 锚定仓库根,禁 cwd 相对。
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.services.ai_service import DEFAULT_PROVIDER, PromptProvider
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parents[1]
|
||||
JC_PROMPTS = REPO / "seed_profiles" / "jiancai" / "prompts"
|
||||
AUTO_PROMPTS = REPO / "seed_profiles" / "auto" / "prompts"
|
||||
|
||||
|
||||
def _wall(slug: str) -> dict:
|
||||
return json.loads(
|
||||
(REPO / "seed_profiles" / slug / "wall.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# ---- 加载 --------------------------------------------------------------
|
||||
|
||||
def test_provider_loads_from_tenant_dir():
|
||||
p = PromptProvider.from_dir(JC_PROMPTS)
|
||||
assert "HESUPIPE" in p.company_facts or "管道" in p.system
|
||||
|
||||
|
||||
def test_provider_reads_all_four_prompts():
|
||||
p = PromptProvider.from_dir(JC_PROMPTS)
|
||||
assert "$existing_info" in p.extractor and "$messages" in p.extractor
|
||||
assert "$profile" in p.evaluator and "$recent_messages" in p.evaluator
|
||||
|
||||
|
||||
def test_default_provider_is_jiancai_seed():
|
||||
# 旧测试/默认路径的代码级默认 = jiancai 出厂(Path(__file__) 锚定,不随 cwd 漂移)
|
||||
assert DEFAULT_PROVIDER.system == PromptProvider.from_dir(JC_PROMPTS).system
|
||||
|
||||
|
||||
# ---- $DEFLECTION_POOL 注入(单一事实源) ----------------------------------
|
||||
|
||||
def test_system_prompt_injects_deflection_pool():
|
||||
p = PromptProvider.from_dir(JC_PROMPTS)
|
||||
wall_json = _wall("jiancai")
|
||||
sysprompt = p.build_system(wall_deflections=wall_json["deflections"])
|
||||
for lang, lines in wall_json["deflections"].items():
|
||||
assert lines[0] in sysprompt # 话术池注入 = 单一事实源
|
||||
assert "$DEFLECTION_POOL" not in sysprompt
|
||||
assert "HESUPIPE" in sysprompt # 事实卡拼接保留
|
||||
|
||||
|
||||
def test_system_md_carries_placeholder():
|
||||
# 防漂移锚: 两租户 system.md 必须保留占位符,丢了它话术池就静默失联
|
||||
for prompts_dir in (JC_PROMPTS, AUTO_PROMPTS):
|
||||
assert "$DEFLECTION_POOL" in PromptProvider.from_dir(prompts_dir).system
|
||||
|
||||
|
||||
def test_build_system_appends_facts_after_system():
|
||||
p = PromptProvider.from_dir(JC_PROMPTS)
|
||||
out = p.build_system(wall_deflections=_wall("jiancai")["deflections"])
|
||||
# 顺序: 行为 prompt(含注入话术池) → 事实卡(与旧 _BASE_CHAT_PROMPT 拼接同构)
|
||||
assert out.index("## 语言规则") < out.index("# HESUPIPE 公司事实卡")
|
||||
|
||||
|
||||
def test_build_system_auto_tenant_pool():
|
||||
# auto 墙只有 en/zh 池(无 es)——注入按实际池语言枚举,不猜不补
|
||||
p = PromptProvider.from_dir(AUTO_PROMPTS)
|
||||
wall = _wall("auto")
|
||||
sysprompt = p.build_system(wall_deflections=wall["deflections"])
|
||||
for lang, lines in wall["deflections"].items():
|
||||
assert lines[0] in sysprompt
|
||||
assert "$DEFLECTION_POOL" not in sysprompt
|
||||
assert "- ES:" not in sysprompt
|
||||
|
||||
|
||||
def test_build_system_injection_is_language_grouped():
|
||||
# 注入格式: 按语言分组一行(- EN: … / - ZH: …),紧凑不膨胀
|
||||
p = PromptProvider.from_dir(JC_PROMPTS)
|
||||
out = p.build_system(wall_deflections=_wall("jiancai")["deflections"])
|
||||
assert "- EN:" in out and "- ZH:" in out and "- ES:" in out
|
||||
# 分组行序: EN 在 ZH 前(确定性输出,防未来语言序随机漂移断言抖动)
|
||||
assert out.index("- EN:") < out.index("- ZH:") < out.index("- ES:")
|
||||
|
||||
|
||||
def test_injected_pool_lines_are_wall_clean():
|
||||
# 注入让话术池进入 system prompt(AI 会照抄)——池线必须过两墙;
|
||||
# 既有 test_spanish_pools_are_wall_clean 只盖 ES,这里补全两租户全语言
|
||||
from app.engine.services.boundary_filter import (
|
||||
no_contact_value, violates_boundary,
|
||||
)
|
||||
for slug in ("jiancai", "auto"):
|
||||
for lines in _wall(slug)["deflections"].values():
|
||||
for line in lines:
|
||||
assert not violates_boundary(line), line[:40]
|
||||
assert not no_contact_value(line), line[:40]
|
||||
|
||||
|
||||
# ---- extractor/evaluator 组装(自 AIService 原样搬入) ----------------------
|
||||
|
||||
def test_build_extractor_replaces_placeholders():
|
||||
p = PromptProvider.from_dir(JC_PROMPTS)
|
||||
out = p.build_extractor(
|
||||
{"目标国家": "Egypt"},
|
||||
[{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello there"}],
|
||||
)
|
||||
assert "$existing_info" not in out and "$messages" not in out
|
||||
assert '"目标国家": "Egypt"' in out # json.dumps(ensure_ascii=False)
|
||||
assert "客户: hi" in out and "顾问: hello there" in out
|
||||
|
||||
|
||||
def test_build_evaluator_replaces_placeholders_and_caps_messages():
|
||||
p = PromptProvider.from_dir(JC_PROMPTS)
|
||||
msgs = [{"role": "user", "content": f"m{i}"} for i in range(10)]
|
||||
out = p.build_evaluator({"intent_level": "pending"}, msgs)
|
||||
assert "$profile" not in out and "$recent_messages" not in out
|
||||
assert '"intent_level": "pending"' in out
|
||||
assert "客户: m9" in out # 取最近 6 条
|
||||
assert "客户: m0" not in out and "客户: m3" not in out
|
||||
|
||||
|
||||
def test_build_extractor_handles_none_info():
|
||||
p = PromptProvider.from_dir(JC_PROMPTS)
|
||||
out = p.build_extractor(None, [])
|
||||
assert "{}" in out # existing_info or {} 语义保留
|
||||
|
||||
|
||||
# ---- runtime 接线 --------------------------------------------------------
|
||||
|
||||
def test_registry_runtime_carries_prompts(tmp_path):
|
||||
from app.platform.registry import TenantRegistry
|
||||
reg = TenantRegistry.load_from_dir(REPO / "seed_profiles", data_dir=tmp_path)
|
||||
by_slug = {rt.config.slug: rt for rt in reg.runtimes()}
|
||||
assert "管道" in by_slug["jiancai"].prompts.system
|
||||
assert "汽车" in by_slug["auto"].prompts.system
|
||||
# runtime 的 prompts 即租户目录四件套(与手工 from_dir 等价)
|
||||
assert by_slug["jiancai"].prompts.system == PromptProvider.from_dir(JC_PROMPTS).system
|
||||
|
||||
|
||||
def test_conversation_manager_runtime_is_required():
|
||||
# Task 5b 质量审查关门建议: runtime 从可选转必填——缺参必须显性炸
|
||||
from app.engine.services.conversation import ConversationManager
|
||||
with pytest.raises(TypeError):
|
||||
ConversationManager(db=None)
|
||||
@@ -38,13 +38,15 @@ def _tenant(slug, domains=("a.example.com",), **extra):
|
||||
|
||||
def _write_seed(tmp_path, tenants):
|
||||
"""tmp 下造最小 seed 目录:每租户目录 = 入参 tenant.json + 拷贝真实
|
||||
jiancai wall.json(墙数据不重复维护,compile_wall 直接可过)。"""
|
||||
jiancai wall.json 与 prompts/ 四件套(墙/提示词数据不重复维护,
|
||||
compile_wall 与 PromptProvider.from_dir 直接可过)。"""
|
||||
seed = tmp_path / "seed"
|
||||
seed.mkdir()
|
||||
for slug, tenant in tenants.items():
|
||||
tdir = seed / slug
|
||||
tdir.mkdir()
|
||||
shutil.copyfile(SEED_DIR / "jiancai" / "wall.json", tdir / "wall.json")
|
||||
shutil.copytree(SEED_DIR / "jiancai" / "prompts", tdir / "prompts")
|
||||
(tdir / "tenant.json").write_text(
|
||||
json.dumps(tenant, ensure_ascii=False), encoding="utf-8")
|
||||
return seed
|
||||
|
||||
@@ -18,8 +18,12 @@
|
||||
|
||||
|
||||
def _read_system_md() -> str:
|
||||
with open("app/engine/prompts/system.md", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
# Task 6: 提示词迁至 seed_profiles(jiancai 出厂 = 代码级默认源),
|
||||
# Path(__file__) 锚定仓库根——cwd 相对路径随运行目录漂移的旧病一并修
|
||||
from pathlib import Path
|
||||
md = (Path(__file__).resolve().parents[1]
|
||||
/ "seed_profiles" / "jiancai" / "prompts" / "system.md")
|
||||
return md.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _note_body(note: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user