Files
zeekerWatchmen/server/api/routes/chat.py
T
2026-05-25 15:09:42 +08:00

238 lines
10 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.
# 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