Files
Neo-ZQYY/apps/backend/app/ai/prompts/app5_tactics_prompt.py
Neo 2dfc926f96 feat(ai): W1-AI-CLOSURE 超级 Sprint — 9 APP 全链路收口 + chat 上下文真激活
Phase 2.3 chat 上下文捕获链路从未真正激活到完整工作:
- 14 处 ai-float-button 补 sourcePage,chat.ts 三分支同步设 pageFilters.contextId
- 后端 page_context 4 层 BUG 修(列名错位 + RLS site_id 未重设)
- xcx_chat filters.pop 破坏 body.page_context 引用 — dict() 浅拷贝隔离
- chat 流式 markdown 实时解析(表格/标题/列表/加粗 + KPI 富卡)
- reference_card KPI 富卡接入 SSE 路径,db 真写入
- 维客线索 source 显示规则:AI 来源用机器人 icon 替代长文字

数据库:
- public.member_retention_clue 加 emoji + runtime_mode + sandbox_instance_id
- biz.ai_run_logs 加 assistant_id + 复合索引
- chk_ai_cache_type CHECK 约束 8 类应用名
- cache_type / app_type 命名统一(app6_note / app7_customer / app8_consolidation)
- 历史 emoji 抽取脚本 44/44 成功

后端 silent failure 修:
- cleanup_service WHERE app_type → cache_type(90 天清理 + 20K 上限重新生效)
- _build_ai_insight 字段错位修复(app4 → app7 + 字段对齐 prompt schema)
- task_manager talkingPoints 改 app5_tactics + tactics 字段
- task_manager aiSuggestion 改取 one_line_summary
- cache_service.CACHE_EXPIRY_DAYS 加 app2a_finance_area
- WS /ws/ai-cache 加 token + JWT + site_id 校验(P0 信息泄露漏洞)
- internal_ai token 改 hmac.compare_digest

工具/文档:
- main.py 加 RotatingFileHandler logs/backend.log + uvicorn /health 过滤
- 新建 utils/clue_category.py(VI 6 类配色 + emoji fallback + source 显示规则)
- 新建 utils/markdown.ts(轻量 md 转 rich-text 解析 + streaming 容错)
- audit + 数据库变更说明 + backlog §七 #14 收口 + #15-#38 残余子任务
- backlog 追加 §十一 App1 参数/MCP/沙箱审计 + §十二 百炼/SQL MCP 主任务线

实地 MCP 走查:14 入口数据层 + 5 代表入口 sourcePage 注入 + customer-detail 全模块 + chat md 渲染 + reference_card 富卡 都已验证。9 项预先 BUG/UX 登记 §七 #29-#38 后续修复。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:39:07 +08:00

171 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""应用 5 话术参考 Prompt 拼装。
App4 完成后串行触发,接收 App4 返回结果作为 task_suggestion。
- 数据源fetch_assistant_info + fetch_service_history + fetch_member_consumption_data + fetch_member_notes + context.app4_result
- 输出字段tactics 数组(每条含 scenario + script
- system prompt 在百炼控制台配置
返回:单个 prompt 字符串。
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
from app.ai.cache_service import AICacheService
from app.ai.data_fetchers import (
fetch_assistant_info,
fetch_member_consumption_data,
fetch_member_notes,
fetch_service_history,
)
from app.ai.schemas import CacheTypeEnum
from app.services.runtime_context import as_runtime_business_now_str
logger = logging.getLogger(__name__)
_MAX_PROMPT_LEN = 8000
async def build_prompt(
context: dict,
cache_svc: AICacheService | None = None,
) -> str:
"""构建 App5 prompt 字符串。
Args:
context: site_id, assistant_id, member_id, app4_result(dict|None)
Returns:
JSON 序列化后的 prompt 字符串
"""
site_id = context["site_id"]
assistant_id = context["assistant_id"]
member_id = context["member_id"]
task_suggestion = context.get("app4_result") or {}
results = await asyncio.gather(
fetch_assistant_info(site_id, assistant_id),
fetch_service_history(site_id, assistant_id, member_id),
fetch_member_consumption_data(site_id, member_id),
fetch_member_notes(site_id, member_id),
return_exceptions=True,
)
warnings: list[str] = []
assistant_info = results[0] if not isinstance(results[0], Exception) else {}
if isinstance(results[0], Exception):
warnings.append("助教信息获取失败")
logger.warning("App5 助教信息获取失败: %s", results[0])
service_history = results[1] if not isinstance(results[1], Exception) else []
if isinstance(results[1], Exception):
warnings.append("服务历史获取失败")
logger.warning("App5 服务历史获取失败: %s", results[1])
if isinstance(results[2], Exception):
member_data = _default_member_data()
warnings.append("消费数据获取失败")
logger.warning("App5 消费数据获取失败: %s", results[2])
else:
member_data = results[2]
notes = results[3] if not isinstance(results[3], Exception) else []
if isinstance(results[3], Exception):
warnings.append("备注获取失败")
logger.warning("App5 备注获取失败: %s", results[3])
payload: dict[str, Any] = {
"current_time": as_runtime_business_now_str(site_id, fmt="%Y-%m-%d %H:%M"),
"assistant_id": assistant_id,
"member_id": member_id,
"task_suggestion": task_suggestion,
"assistant_info": assistant_info or "⚠ 助教信息获取失败",
"service_history": service_history or "暂无服务记录",
"task_assignment_basis": {
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
"member_cards": member_data.get("member_cards", []),
"card_balance_total": member_data.get("card_balance_total", 0),
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
"expected_visit_date": member_data.get("expected_visit_date"),
"days_since_last_visit": member_data.get("days_since_last_visit"),
},
"customer_data": {
"member_nickname": member_data.get("member_nickname", ""),
"notes": notes or "暂无备注",
},
"reference": _build_reference(site_id, member_id, cache_svc),
}
if warnings:
payload["_data_warnings"] = warnings
return _truncate_payload(payload)
def _default_member_data() -> dict:
return {
"member_nickname": "",
"consumption_records": [],
"member_cards": [],
"card_balance_total": 0,
"stored_value_balance_total": 0,
"expected_visit_date": None,
"days_since_last_visit": None,
}
def _build_reference(
site_id: int,
member_id: int,
cache_svc: AICacheService | None,
) -> dict:
"""组装最近 2 条 App8 历史。"""
if cache_svc is None:
return {}
ref: dict = {}
history = cache_svc.get_history(
CacheTypeEnum.APP8_CONSOLIDATION.value,
site_id,
str(member_id),
limit=2,
)
if history:
ref["app8_history"] = [
{"result_json": h.get("result_json"), "generated_at": h.get("created_at")}
for h in history
]
return ref
def _truncate_payload(payload: dict) -> str:
"""按优先级截断 service_history → consumption_records → notes。"""
text = json.dumps(payload, ensure_ascii=False, default=str)
if len(text) <= _MAX_PROMPT_LEN:
return text
sh = payload.get("service_history")
if isinstance(sh, list) and len(sh) > 5:
payload["service_history"] = sh[:5]
payload["_truncated_service_history"] = f"服务记录已截断,原始 {len(sh)}"
text = json.dumps(payload, ensure_ascii=False, default=str)
if len(text) > _MAX_PROMPT_LEN:
records = payload["task_assignment_basis"].get("consumption_records")
if isinstance(records, list) and len(records) > 5:
payload["task_assignment_basis"]["consumption_records"] = records[:5]
payload["task_assignment_basis"]["_truncated"] = f"消费记录已截断,原始 {len(records)}"
text = json.dumps(payload, ensure_ascii=False, default=str)
if len(text) > _MAX_PROMPT_LEN:
n = payload["customer_data"].get("notes")
if isinstance(n, list) and len(n) > 10:
payload["customer_data"]["notes"] = n[:10]
payload["customer_data"]["_truncated_notes"] = f"备注已截断,原始 {len(n)}"
text = json.dumps(payload, ensure_ascii=False, default=str)
return text