238 lines
10 KiB
Python
238 lines
10 KiB
Python
# Chat API — loads prompt template, injects dynamic context, calls LLM
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
from fastapi import APIRouter
|
||
from pydantic import BaseModel
|
||
|
||
from server.config import settings
|
||
from server.core.llm_provider.router import router as llm_router
|
||
|
||
logger = logging.getLogger("testflow")
|
||
router = APIRouter()
|
||
|
||
|
||
class ChatRequest(BaseModel):
|
||
message: str
|
||
context: dict = {}
|
||
history: list[dict] = []
|
||
|
||
# ============================================================================
|
||
# Prompt template
|
||
# ============================================================================
|
||
|
||
PROMPT_PATH = Path(__file__).parent.parent.parent / "services" / "prompts" / "chat_system.md"
|
||
PROMPT_TEMPLATE = PROMPT_PATH.read_text(encoding="utf-8") if PROMPT_PATH.exists() else "{persona}\n{principles}\n{page_context}\n{data_context}\n{action_format}"
|
||
|
||
|
||
# ============================================================================
|
||
# Personality & principles
|
||
# ============================================================================
|
||
|
||
def _load_persona() -> str:
|
||
parts = []
|
||
try:
|
||
p_path = settings.SOUL_DIR / "persona.yaml"
|
||
if p_path.exists():
|
||
data = yaml.safe_load(p_path.open(encoding="utf-8")) or {}
|
||
p = data.get("persona", {})
|
||
parts.append(f"## 你的身份\n你是 {p.get('name','Zeeker')},一名{p.get('role','资深测试架构师')}。")
|
||
parts.append(f"经验: {p.get('experience','')}")
|
||
parts.append(f"语气: {p.get('tone','专业、严谨、耐心')}")
|
||
traits = p.get("traits", [])
|
||
if traits: parts.append("特点: " + "; ".join(traits))
|
||
limits = p.get("limitations", [])
|
||
if limits: parts.append("限制: " + "; ".join(limits))
|
||
except Exception as e:
|
||
logger.warning("persona load failed: %s", e)
|
||
return "\n".join(parts)
|
||
|
||
|
||
def _load_principles() -> str:
|
||
parts = ["## 不可违背的原则"]
|
||
try:
|
||
p_path = settings.SOUL_DIR / "principles.yaml"
|
||
if p_path.exists():
|
||
data = yaml.safe_load(p_path.open(encoding="utf-8")) or {}
|
||
for pr in data.get("principles", []):
|
||
parts.append(f"- [{pr.get('priority','?')}] {pr.get('rule','')}")
|
||
except Exception as e:
|
||
logger.warning("principles load failed: %s", e)
|
||
return "\n".join(parts)
|
||
|
||
|
||
# ============================================================================
|
||
# Dynamic context builders (page-aware)
|
||
# ============================================================================
|
||
|
||
def _build_page_context(ctx: dict) -> str:
|
||
"""Describe what the user is looking at on the left panel."""
|
||
page = ctx.get("current_page", "unknown")
|
||
lines = []
|
||
|
||
if page == "home":
|
||
lines.append("- 用户正在主页,可以上传 PRD 文件")
|
||
if ctx.get("has_prd") and not ctx.get("has_ir"):
|
||
lines.append("- PRD 已上传,IR 尚未生成。建议用户点击生成 IR。")
|
||
elif ctx.get("has_prd") and ctx.get("has_ir"):
|
||
lines.append("- PRD 和 IR 已生成。用户可以前往 IR 确认或查看测试用例。")
|
||
|
||
elif page == "ir-confirm":
|
||
n = len(ctx.get("ir_rules_summary", "").split("\n")) - 2 # skip header rows
|
||
lines.append(f"- 用户正在 **IR 确认页**,当前 IR 含 {max(0,n)} 条规则")
|
||
if ctx.get("ir_id"):
|
||
lines.append(f"- IR ID: {ctx['ir_id']}")
|
||
lines.append("- 用户可以查看思维导图、编辑 YAML、点击节点定位代码")
|
||
lines.append("- 你可以帮用户审查规则完整性、增删改规则、调整优先级")
|
||
|
||
elif page == "cases":
|
||
lines.append(f"- 用户正在 **测试用例页**,当前 {ctx.get('testcase_count', 0)} 条用例")
|
||
if ctx.get("tc_set_id"):
|
||
lines.append(f"- 用例集 ID: {ctx['tc_set_id']}")
|
||
if ctx.get("ir_id"):
|
||
lines.append(f"- 这些用例基于 IR: {ctx['ir_id']}")
|
||
lines.append("- 你可以帮用户增加测试用例、检查覆盖率、修改用例内容、导出用例")
|
||
|
||
existing = ctx.get("existing_tc_ids", [])
|
||
if existing:
|
||
lines.append(f"\n已有用例 ID: {', '.join(existing[:20])}" + (f" ...共{len(existing)}条" if len(existing) > 20 else ""))
|
||
|
||
sel = ctx.get("selected_case")
|
||
if sel and isinstance(sel, dict) and sel.get("id"):
|
||
lines.append(f"\n⚠️ **用户当前选中的测试用例:**")
|
||
lines.append(f"- ID: {sel['id']} | 标题: {sel.get('title','')}")
|
||
lines.append(f"- 步骤: {sel.get('steps','')[:200]}")
|
||
lines.append(f"- 预期: {sel.get('expected','')}")
|
||
lines.append(f"用户说\"这条\"/\"这个\"时,指的是测试用例 {sel['id']},不是 IR 规则。在 cases 页面操作的是测试用例!")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _build_data_context(ctx: dict) -> str:
|
||
"""Inject the actual data from the left panel."""
|
||
parts = []
|
||
|
||
if ctx.get("prd_text"):
|
||
parts.append(f"### PRD 文本(前 2000 字符)\n```\n{ctx['prd_text'][:2000]}\n```")
|
||
|
||
if ctx.get("ir_rules_summary"):
|
||
parts.append(f"### IR 规则列表\n{ctx['ir_rules_summary']}")
|
||
|
||
if ctx.get("testcase_count", 0) > 0 and ctx.get("testcase_summary"):
|
||
parts.append(f"### 测试用例概览 ({ctx['testcase_count']} 条)\n```\n{ctx['testcase_summary']}\n```")
|
||
|
||
return "\n".join(parts) if parts else "(暂无左侧数据)"
|
||
|
||
|
||
def _build_action_format(ctx: dict) -> str:
|
||
"""Page-specific action instructions."""
|
||
page = ctx.get("current_page", "unknown")
|
||
lines = ["修改 IR 规则时使用 rule_id 精准定位 (格式 IR-{FEATURE}-{section}-{NNN}):",
|
||
' {"action": "add_rule", "rule": {...}}',
|
||
' {"action": "delete_rule", "rule_id": "IR-ZWM-4.2.1-001"}',
|
||
' {"action": "modify_rule", "rule_id": "IR-ZWM-4.2.1-001", "changes": {"priority": "P0"}}',
|
||
' {"action": "generate_cases", "ir_id": "<ir_id>"}']
|
||
|
||
if page == "cases":
|
||
lines.append("\n在测试用例页面时,使用 add_case/delete_case/modify_case:")
|
||
lines.append(' {"action": "add_case", "rule": {"case_title":"...","steps":"Given..When..Then..","expected_result":"...","priority":"P0|P1|P2","tags":["正向"],"module":"模块名"}}')
|
||
lines.append(' {"action": "modify_case", "case_id": "TC-ZEEKER-002", "changes": {"case_title": "新标题"}}')
|
||
lines.append(' {"action": "delete_case", "case_id": "TC-ZEEKER-002"}')
|
||
lines.append("注意: add_case 时不要填写 id 字段(系统自动分配)。module 字段尽量填写。delete_case 的 case_id 必须是页面上已有的真实 ID。")
|
||
lines.append("用户要求增改用例时,必须输出 action!只描述不输出 action = 什么都没做。")
|
||
|
||
lines.append("\n用户每次要求增删改时,必须在回复末尾输出对应 action 块。不输出 = 没执行。")
|
||
return "\n".join(lines)
|
||
|
||
|
||
# ============================================================================
|
||
# Memory
|
||
# ============================================================================
|
||
|
||
def _load_memory(session_id: str) -> list[dict]:
|
||
f = settings.MEMORY_DIR / f"chat_{session_id}.json"
|
||
if f.exists():
|
||
try: return json.loads(f.read_text(encoding="utf-8"))
|
||
except: pass
|
||
return []
|
||
|
||
def _save_memory(session_id: str, messages: list[dict]):
|
||
os.makedirs(settings.MEMORY_DIR, exist_ok=True)
|
||
f = settings.MEMORY_DIR / f"chat_{session_id}.json"
|
||
try:
|
||
to_save = messages[-30:] if len(messages) > 30 else messages
|
||
f.write_text(json.dumps(to_save, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
except Exception as e:
|
||
logger.warning("memory save failed: %s", e)
|
||
|
||
|
||
# ============================================================================
|
||
# Build full system prompt
|
||
# ============================================================================
|
||
|
||
def _build_system_prompt(context: dict) -> str:
|
||
return PROMPT_TEMPLATE.replace("{persona}", _load_persona()) \
|
||
.replace("{principles}", _load_principles()) \
|
||
.replace("{page_context}", _build_page_context(context)) \
|
||
.replace("{data_context}", _build_data_context(context)) \
|
||
.replace("{action_format}", _build_action_format(context))
|
||
|
||
|
||
# ============================================================================
|
||
# Chat endpoint
|
||
# ============================================================================
|
||
|
||
@router.post("")
|
||
@router.post("/")
|
||
async def chat(request: ChatRequest):
|
||
session_id = request.context.get("session_id", "default")
|
||
memory = _load_memory(session_id)
|
||
|
||
system_msg = _build_system_prompt(request.context)
|
||
messages = [{"role": "system", "content": system_msg}]
|
||
messages.extend(memory[-8:])
|
||
for h in (request.history or [])[-6:]:
|
||
messages.append(h)
|
||
messages.append({"role": "user", "content": request.message})
|
||
|
||
logger.info("[CHAT] session=%s page=%s memory=%d prompt=%d chars",
|
||
session_id, request.context.get("current_page","?"),
|
||
len(memory)//2, len(system_msg))
|
||
|
||
client = llm_router.get_chat_client()
|
||
try:
|
||
raw = client.chat(model=llm_router.text_model, messages=messages, temperature=0.3)
|
||
except Exception as e:
|
||
logger.error("[CHAT] LLM failed: %s", e)
|
||
return {"reply": f"LLM 调用失败: {e}", "actions": []}
|
||
|
||
reply, actions = _extract_actions(raw)
|
||
memory.append({"role": "user", "content": request.message})
|
||
memory.append({"role": "assistant", "content": reply})
|
||
_save_memory(session_id, memory)
|
||
|
||
logger.info("[CHAT] reply=%d chars actions=%d", len(reply), len(actions))
|
||
return {"reply": reply, "actions": actions}
|
||
|
||
|
||
def _extract_actions(text: str) -> tuple[str, list[dict]]:
|
||
actions, clean = [], text
|
||
for m in re.findall(r"```action\s*\n([\s\S]*?)```", text):
|
||
for line in m.strip().split("\n"):
|
||
try:
|
||
a = json.loads(line.strip())
|
||
if isinstance(a, dict) and "action" in a: actions.append(a)
|
||
except: pass
|
||
clean = re.sub(r"```action\s*\n[\s\S]*?```", "", clean)
|
||
for s in re.findall(r'\{"action":\s*"[^"]+"[^}]*\}', text):
|
||
try:
|
||
a = json.loads(s)
|
||
if a not in actions: actions.append(a)
|
||
except: pass
|
||
return clean.strip(), actions
|