init the project
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
from server.core.llm_provider.base import LLMClient
|
||||
from server.core.llm_provider.router import ModelRouter, router
|
||||
from server.core.llm_provider.mock_client import MockLLMClient
|
||||
|
||||
__all__ = ["LLMClient", "ModelRouter", "router", "MockLLMClient"]
|
||||
@@ -0,0 +1,145 @@
|
||||
# OpenAI-compatible LLM client with retry, token tracking, and vision support.
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
logger = logging.getLogger("testflow")
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Generic OpenAI-compatible LLM client.
|
||||
|
||||
Usage::
|
||||
|
||||
client = LLMClient(api_key="sk-xxx", base_url="https://api.deepseek.com/v1")
|
||||
text = client.chat("deepseek-chat", [{"role": "user", "content": "Hello"}])
|
||||
print(client.usage)
|
||||
"""
|
||||
|
||||
TIMEOUT = 120
|
||||
MAX_RETRIES = 3
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str = "",
|
||||
base_url: str = "",
|
||||
model: str = "",
|
||||
timeout: int | None = None,
|
||||
):
|
||||
if not api_key:
|
||||
raise ValueError(f"API key is required for LLMClient")
|
||||
self._client = OpenAI(api_key=api_key, base_url=base_url)
|
||||
self._timeout = timeout or self.TIMEOUT
|
||||
self._model = model
|
||||
self._prompt_tokens = 0
|
||||
self._completion_tokens = 0
|
||||
|
||||
@property
|
||||
def usage(self) -> dict:
|
||||
return {
|
||||
"prompt_tokens": self._prompt_tokens,
|
||||
"completion_tokens": self._completion_tokens,
|
||||
"total_tokens": self._prompt_tokens + self._completion_tokens,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def estimate_tokens(text: str) -> int:
|
||||
cjk = sum(1 for c in text if '一' <= c <= '鿿' or ' ' <= c <= '〿')
|
||||
other = len(text) - cjk
|
||||
return max(1, int(cjk / 1.7 + other / 3.0))
|
||||
|
||||
@staticmethod
|
||||
def estimate_image_tokens() -> int:
|
||||
return 500
|
||||
|
||||
def chat(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
response_format: dict | None = None,
|
||||
temperature: float = 0.0,
|
||||
) -> str:
|
||||
# Estimate prompt size
|
||||
prompt_chars = sum(len(str(m.get("content", ""))) for m in messages)
|
||||
label = f"chat({model})"
|
||||
|
||||
def _call():
|
||||
t0 = time.time()
|
||||
kwargs = dict(
|
||||
model=model,
|
||||
messages=messages,
|
||||
timeout=timeout or self._timeout,
|
||||
temperature=temperature,
|
||||
)
|
||||
if response_format is not None:
|
||||
kwargs["response_format"] = response_format
|
||||
logger.info("[LLM] → %s (prompt ~%d chars / ~%d tokens)",
|
||||
model, prompt_chars, prompt_chars // 3)
|
||||
resp = self._client.chat.completions.create(**kwargs)
|
||||
content = resp.choices[0].message.content
|
||||
usg = resp.usage
|
||||
if usg:
|
||||
self._prompt_tokens += usg.prompt_tokens
|
||||
self._completion_tokens += usg.completion_tokens
|
||||
elapsed = time.time() - t0
|
||||
logger.info("[LLM] ← %s: %d chars, p:%d c:%d tokens, %.1fs",
|
||||
model, len(content) if content else 0,
|
||||
usg.prompt_tokens if usg else 0,
|
||||
usg.completion_tokens if usg else 0,
|
||||
elapsed)
|
||||
if not content:
|
||||
raise RuntimeError("Empty response from LLM")
|
||||
return content
|
||||
|
||||
return self._retry(_call, label)
|
||||
|
||||
def chat_with_image(
|
||||
self,
|
||||
model: str,
|
||||
image_path: str,
|
||||
prompt: str,
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
) -> str:
|
||||
"""Send a prompt with an image attachment (for vision models)."""
|
||||
with open(image_path, "rb") as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode()
|
||||
mime = self._mime_type(image_path)
|
||||
|
||||
messages = [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{img_b64}"}},
|
||||
{"type": "text", "text": prompt},
|
||||
],
|
||||
}]
|
||||
return self.chat(model, messages, timeout=timeout)
|
||||
|
||||
@staticmethod
|
||||
def _mime_type(image_path: str) -> str:
|
||||
ext = os.path.splitext(image_path)[1].lstrip(".").lower()
|
||||
return {
|
||||
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||||
"gif": "image/gif", "bmp": "image/bmp",
|
||||
"webp": "image/webp", "svg": "image/svg+xml", "tiff": "image/tiff",
|
||||
}.get(ext, "image/png")
|
||||
|
||||
def _retry(self, fn, label: str) -> str:
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(self.MAX_RETRIES):
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning("%s error (attempt %d/%d): %s", label, attempt + 1, self.MAX_RETRIES, e)
|
||||
if attempt < self.MAX_RETRIES - 1:
|
||||
time.sleep(2 ** attempt)
|
||||
raise RuntimeError(f"{label}: all retries exhausted") from last_error
|
||||
@@ -0,0 +1,463 @@
|
||||
# Mock LLM client for offline development/testing.
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from server.core.llm_provider.base import LLMClient
|
||||
|
||||
logger = logging.getLogger("testflow")
|
||||
|
||||
MOCK_IR_YAML = """meta:
|
||||
prd_title: ZeekerWatchman 产品需求文档
|
||||
extraction_date: "2026-05-22"
|
||||
skill_used: default
|
||||
|
||||
features:
|
||||
- module: PRD管理
|
||||
feature_name: PRD文件上传
|
||||
description: 支持拖拽上传或文本粘贴,解析.md/.txt/.docx/.pdf格式文件
|
||||
inputs:
|
||||
- PRD文件 (.md/.txt/.docx/.pdf)
|
||||
outputs:
|
||||
- PRD_Version记录
|
||||
- 纯文本摘要
|
||||
preconditions:
|
||||
- 用户已登录平台
|
||||
constraints:
|
||||
- 文件大小不超过10MB
|
||||
- 仅支持指定格式
|
||||
priority: P0
|
||||
dependencies: []
|
||||
|
||||
- module: PRD管理
|
||||
feature_name: PRD版本快照
|
||||
description: 上传后即时保存为不可变版本,支持历史回溯
|
||||
inputs:
|
||||
- 已上传的PRD
|
||||
outputs:
|
||||
- 版本快照记录
|
||||
preconditions:
|
||||
- PRD已成功上传
|
||||
constraints:
|
||||
- 版本不可修改
|
||||
priority: P1
|
||||
dependencies:
|
||||
- PRD文件上传
|
||||
|
||||
- module: IR引擎
|
||||
feature_name: IR生成
|
||||
description: 调用LLM利用Skill的extract_ir_prompt将PRD转化为符合IR Schema的YAML
|
||||
inputs:
|
||||
- PRD文本
|
||||
- Skill名称
|
||||
outputs:
|
||||
- IR YAML
|
||||
preconditions:
|
||||
- PRD解析完成
|
||||
- Skill已选择
|
||||
constraints:
|
||||
- 必须符合ir_schema.json
|
||||
- 必须满足principles.yaml约束
|
||||
priority: P0
|
||||
dependencies:
|
||||
- PRD文件上传
|
||||
|
||||
- module: IR引擎
|
||||
feature_name: IR可视化确认
|
||||
description: 双栏展示,左侧YAML编辑器,右侧思维导图实时渲染
|
||||
inputs:
|
||||
- IR YAML
|
||||
outputs:
|
||||
- 用户确认/编辑后的IR
|
||||
preconditions:
|
||||
- IR已生成
|
||||
constraints:
|
||||
- 支持实时编辑保存
|
||||
priority: P1
|
||||
dependencies:
|
||||
- IR生成
|
||||
|
||||
- module: 用例引擎
|
||||
feature_name: 测试用例生成
|
||||
description: 基于确认后的IR调用gen_cases_prompt生成JSON格式用例集
|
||||
inputs:
|
||||
- 最终IR YAML
|
||||
- Skill的gen_cases_prompt
|
||||
outputs:
|
||||
- TestCase_Set
|
||||
preconditions:
|
||||
- IR已确认
|
||||
constraints:
|
||||
- P0功能必须覆盖异常和边界场景
|
||||
- 用例标题使用Given-When-Then结构
|
||||
priority: P0
|
||||
dependencies:
|
||||
- IR生成
|
||||
|
||||
- module: 用例引擎
|
||||
feature_name: 测试用例导出
|
||||
description: 支持一键导出为YAML/CSV/XMind格式
|
||||
inputs:
|
||||
- TestCase_Set
|
||||
- 目标格式
|
||||
outputs:
|
||||
- 下载文件
|
||||
preconditions:
|
||||
- 用例已生成
|
||||
constraints:
|
||||
- YAML用于自动化,CSV用于评审,XMind用于展示
|
||||
priority: P1
|
||||
dependencies:
|
||||
- 测试用例生成"""
|
||||
|
||||
MOCK_TESTCASES = [
|
||||
{
|
||||
"id": "TC-ZWM-001",
|
||||
"module": "PRD管理",
|
||||
"feature": "PRD文件上传",
|
||||
"case_title": "正向-上传md格式PRD成功",
|
||||
"preconditions": "用户已登录,文件为有效md格式",
|
||||
"steps": "Given 用户在主页面\nWhen 拖拽或选择一个.md文件上传\nThen 系统解析成功并显示PRD摘要",
|
||||
"expected_result": "返回PRD ID,状态为ready,显示文本摘要",
|
||||
"priority": "P0",
|
||||
"tags": ["正向", "冒烟"]
|
||||
},
|
||||
{
|
||||
"id": "TC-ZWM-002",
|
||||
"module": "PRD管理",
|
||||
"feature": "PRD文件上传",
|
||||
"case_title": "异常-上传不支持的文件格式",
|
||||
"preconditions": "用户已登录",
|
||||
"steps": "Given 用户在主页面\nWhen 上传一个.exe文件\nThen 系统返回错误提示",
|
||||
"expected_result": "返回400错误,提示仅支持.md/.txt/.docx/.pdf",
|
||||
"priority": "P0",
|
||||
"tags": ["异常"]
|
||||
},
|
||||
{
|
||||
"id": "TC-ZWM-003",
|
||||
"module": "PRD管理",
|
||||
"feature": "PRD文件上传",
|
||||
"case_title": "边界-上传超过10MB的文件",
|
||||
"preconditions": "用户已登录",
|
||||
"steps": "Given 用户在主页面\nWhen 上传一个11MB的md文件\nThen 系统拒绝并提示文件过大",
|
||||
"expected_result": "返回错误,提示文件大小不超过10MB",
|
||||
"priority": "P1",
|
||||
"tags": ["边界"]
|
||||
},
|
||||
{
|
||||
"id": "TC-ZWM-004",
|
||||
"module": "IR引擎",
|
||||
"feature": "IR生成",
|
||||
"case_title": "正向-从PRD生成IR成功",
|
||||
"preconditions": "PRD已解析,Skill已选择",
|
||||
"steps": "Given PRD文本可用\nWhen 调用IR生成接口\nThen 返回符合IR Schema的YAML",
|
||||
"expected_result": "生成包含features列表的YAML,功能点齐全",
|
||||
"priority": "P0",
|
||||
"tags": ["正向"]
|
||||
},
|
||||
{
|
||||
"id": "TC-ZWM-005",
|
||||
"module": "IR引擎",
|
||||
"feature": "IR生成",
|
||||
"case_title": "异常-空PRD文本生成IR",
|
||||
"preconditions": "PRD文本为空",
|
||||
"steps": "Given PRD内容为空\nWhen 调用IR生成接口\nThen 返回错误提示",
|
||||
"expected_result": "返回400错误,提示PRD无文本内容",
|
||||
"priority": "P0",
|
||||
"tags": ["异常"]
|
||||
},
|
||||
{
|
||||
"id": "TC-ZWM-006",
|
||||
"module": "用例引擎",
|
||||
"feature": "测试用例生成",
|
||||
"case_title": "正向-基于IR生成测试用例",
|
||||
"preconditions": "IR已确认",
|
||||
"steps": "Given IR YAML可用\nWhen 调用用例生成接口\nThen 返回JSON格式的用例集",
|
||||
"expected_result": "用例集包含P0功能的正向和异常用例,步骤为Given-When-Then格式",
|
||||
"priority": "P0",
|
||||
"tags": ["正向"]
|
||||
},
|
||||
{
|
||||
"id": "TC-ZWM-007",
|
||||
"module": "用例引擎",
|
||||
"feature": "测试用例导出",
|
||||
"case_title": "正向-导出用例为YAML格式",
|
||||
"preconditions": "用例集已生成",
|
||||
"steps": "Given 用例集可用\nWhen 选择YAML格式导出\nThen 下载YAML文件",
|
||||
"expected_result": "下载的YAML文件包含所有用例及元数据",
|
||||
"priority": "P1",
|
||||
"tags": ["正向"]
|
||||
},
|
||||
{
|
||||
"id": "TC-ZWM-008",
|
||||
"module": "用例引擎",
|
||||
"feature": "测试用例导出",
|
||||
"case_title": "正向-导出用例为CSV格式",
|
||||
"preconditions": "用例集已生成",
|
||||
"steps": "Given 用例集可用\nWhen 选择CSV格式导出\nThen 下载CSV文件",
|
||||
"expected_result": "CSV文件包含用例ID、模块、标题、步骤、预期结果等列",
|
||||
"priority": "P1",
|
||||
"tags": ["正向"]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
MOCK_SEMANTIC_INDEX = {
|
||||
"feature_name": "ZeekerWatchman 测试用例智能管理平台",
|
||||
"concepts": [
|
||||
{"name": "PRD", "aliases": ["产品需求文档", "需求文档"], "defined_in": ["1"]},
|
||||
{"name": "IR", "aliases": ["中间表示", "Intermediate Representation"], "defined_in": ["1", "4.2.2"]},
|
||||
{"name": "Skill", "aliases": ["技能包", "测试方法论"], "defined_in": ["4.1"]},
|
||||
{"name": "TestCase", "aliases": ["测试用例", "用例"], "defined_in": ["4.2.3"]},
|
||||
],
|
||||
"function_units": [
|
||||
{
|
||||
"unit_id": "FU-001",
|
||||
"name": "PRD文件上传与解析",
|
||||
"description": "用户上传.md/.txt/.docx/.pdf格式的PRD文件,系统调用解析器提取纯文本和图片,生成版本快照",
|
||||
"sources": [{"section": "4.2.1 PRD 输入与解析", "type": "para", "text_snippet": "支持拖拽上传或直接粘贴文本。服务端调用LLM或本地解析库提取纯文本"}],
|
||||
},
|
||||
{
|
||||
"unit_id": "FU-002",
|
||||
"name": "IR生成",
|
||||
"description": "基于PRD文本和选定的Skill,调用LLM生成符合IR Schema的YAML中间表示",
|
||||
"sources": [{"section": "4.2.2 中间表示 IR 生成与确认", "type": "para", "text_snippet": "reasoning模块调用llm_provider,利用Skill的extract_ir_prompt.j2生成符合ir_schema.json的YAML"}],
|
||||
},
|
||||
{
|
||||
"unit_id": "FU-003",
|
||||
"name": "IR验证与自检",
|
||||
"description": "对生成的IR进行JSON Schema校验和Principles规则检查,输出审核意见",
|
||||
"sources": [{"section": "4.2.2 中间表示 IR 生成与确认", "type": "para", "text_snippet": "LangGraph节点会校验IR是否满足schema和soul/principles.yaml"}],
|
||||
},
|
||||
{
|
||||
"unit_id": "FU-004",
|
||||
"name": "IR人工确认与编辑",
|
||||
"description": "用户可在双栏界面编辑YAML并保存,保存触发新版本生成",
|
||||
"sources": [{"section": "4.2.2 中间表示 IR 生成与确认", "type": "para", "text_snippet": "用户可直接编辑YAML并保存,保存操作触发新IR_Version生成"}],
|
||||
},
|
||||
{
|
||||
"unit_id": "FU-005",
|
||||
"name": "测试用例生成",
|
||||
"description": "基于确认后的IR调用gen_cases_prompt生成JSON格式用例集,P0功能覆盖正向和异常场景",
|
||||
"sources": [{"section": "4.2.3 测试用例生成与导出", "type": "para", "text_snippet": "基于确认后的最终IR,调用gen_cases_prompt.j2生成JSON格式的用例集"}],
|
||||
},
|
||||
{
|
||||
"unit_id": "FU-006",
|
||||
"name": "测试用例导出",
|
||||
"description": "支持将用例集导出为YAML/CSV/XMind三种格式",
|
||||
"sources": [{"section": "4.2.3 测试用例生成与导出", "type": "para", "text_snippet": "YAML带语法高亮的代码预览和文件下载,CSV文件,XMind服务端生成.xmind文件下载"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
MOCK_IR_RULES = [
|
||||
{
|
||||
"description": "用户上传PRD文件时,系统检查文件格式(.md/.txt/.docx/.pdf)和大小(≤10MB),通过后调用解析器提取纯文本并创建不可变版本快照",
|
||||
"priority": "P0",
|
||||
"sources": [{"type": "para", "section": "4.2.1 PRD 输入与解析", "text_snippet": "支持拖拽上传或直接粘贴文本"}],
|
||||
"precondition": {"app_type": "Web应用", "app_state": "已登录"},
|
||||
"trigger": {
|
||||
"operator": "AND",
|
||||
"conditions": [
|
||||
{"signal": "文件格式", "operator": "in", "value": [".md", ".txt", ".docx", ".pdf"]},
|
||||
{"signal": "文件大小", "operator": "<=", "value": 10, "unit": "MB"},
|
||||
],
|
||||
},
|
||||
"actions": [
|
||||
{"type": "system", "description": "保存原始文件"},
|
||||
{"type": "system", "description": "调用解析器提取纯文本"},
|
||||
{"type": "system", "description": "创建PRD_Version快照"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"description": "上传不支持的文件格式时,系统拒绝并返回错误提示,仅支持.md/.txt/.docx/.pdf",
|
||||
"priority": "P0",
|
||||
"sources": [{"type": "para", "section": "4.2.1", "text_snippet": "支持拖拽上传"}],
|
||||
"precondition": {},
|
||||
"trigger": {
|
||||
"operator": "OR",
|
||||
"conditions": [
|
||||
{"signal": "文件格式", "operator": "not_in", "value": [".md", ".txt", ".docx", ".pdf"]},
|
||||
{"signal": "文件大小", "operator": ">", "value": 10, "unit": "MB"},
|
||||
],
|
||||
},
|
||||
"actions": [
|
||||
{"type": "user_interaction", "description": "显示错误提示", "content": "不支持的文件格式,仅支持.md/.txt/.docx/.pdf,且不超过10MB"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"description": "基于PRD文本和选定Skill调用DeepSeek生成IR YAML,结果需符合ir_schema.json和principles.yaml",
|
||||
"priority": "P0",
|
||||
"sources": [{"type": "para", "section": "4.2.2", "text_snippet": "reasoning模块调用llm_provider生成IR"}],
|
||||
"precondition": {"app_state": "PRD已解析"},
|
||||
"trigger": {
|
||||
"operator": "AND",
|
||||
"conditions": [
|
||||
{"signal": "PRD文本", "operator": "exists", "value": True},
|
||||
{"signal": "Skill", "operator": "selected", "value": True},
|
||||
],
|
||||
},
|
||||
"actions": [
|
||||
{"type": "system", "description": "加载Skill的extract_ir_prompt.j2模板"},
|
||||
{"type": "system", "description": "调用LLM生成IR YAML"},
|
||||
{"type": "system", "description": "持久化IR_Version"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"description": "IR生成后自动进行JSON Schema校验和Principles规则检查,输出审核意见包含error和warning",
|
||||
"priority": "P1",
|
||||
"sources": [{"type": "para", "section": "4.2.2", "text_snippet": "LangGraph节点会校验IR"}],
|
||||
"precondition": {"app_state": "IR已生成"},
|
||||
"trigger": {"operator": "AND", "conditions": [{"signal": "IR", "operator": "exists", "value": True}]},
|
||||
"actions": [
|
||||
{"type": "system", "description": "JSON Schema校验"},
|
||||
{"type": "system", "description": "Principles规则检查"},
|
||||
{"type": "user_interaction", "description": "展示审核意见列表"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"description": "基于确认后的IR生成JSON格式测试用例集,每个P0功能点至少包含正向和异常各1条用例,使用Given-When-Then结构",
|
||||
"priority": "P0",
|
||||
"sources": [{"type": "para", "section": "4.2.3", "text_snippet": "基于确认后的最终IR生成JSON格式的用例集"}],
|
||||
"precondition": {"app_state": "IR已确认"},
|
||||
"trigger": {"operator": "AND", "conditions": [{"signal": "IR已确认", "operator": "==", "value": True}]},
|
||||
"actions": [
|
||||
{"type": "system", "description": "加载Skill的gen_cases_prompt.j2模板"},
|
||||
{"type": "system", "description": "调用LLM生成用例JSON"},
|
||||
{"type": "system", "description": "创建TestCase_Set并关联IR_Version"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"description": "用例导出功能:YAML格式用于自动化执行,CSV用于Excel评审归档",
|
||||
"priority": "P1",
|
||||
"sources": [{"type": "para", "section": "4.2.3", "text_snippet": "支持一键导出YAML/CSV/XMind"}],
|
||||
"precondition": {"app_state": "用例已生成"},
|
||||
"trigger": {"operator": "OR", "conditions": [
|
||||
{"signal": "导出格式", "operator": "==", "value": "yaml"},
|
||||
{"signal": "导出格式", "operator": "==", "value": "csv"},
|
||||
{"signal": "导出格式", "operator": "==", "value": "xmind"},
|
||||
]},
|
||||
"actions": [
|
||||
{"type": "system", "description": "格式化用例为指定格式"},
|
||||
{"type": "user_interaction", "description": "触发文件下载"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _mock_chat_reply(prompt: str) -> str:
|
||||
"""Generate a mock chat reply based on user message content."""
|
||||
# Extract the last user message
|
||||
user_msg = ""
|
||||
for line in prompt.split("\n"):
|
||||
if line.startswith("## 当前上下文"):
|
||||
break
|
||||
if line and not line.startswith("#") and not line.startswith("```"):
|
||||
user_msg += line + " "
|
||||
|
||||
if "分析" in user_msg or "功能点" in user_msg:
|
||||
return (
|
||||
"根据 PRD 内容分析,我发现了以下功能点:\n\n"
|
||||
"1. **用户登录** - 支持邮箱/手机号登录,包含密码验证和错误锁定机制\n"
|
||||
"2. **用户注册** - 新用户注册流程,需要邮箱验证\n"
|
||||
"3. **密码重置** - 通过邮箱验证码重置密码\n\n"
|
||||
"建议操作:\n"
|
||||
"- 点击「生成 IR」将这些功能点转换为结构化 IR\n"
|
||||
"- 我可以帮你检查是否有遗漏的功能点"
|
||||
)
|
||||
if "用例" in user_msg or "测试" in user_msg:
|
||||
return (
|
||||
"当前测试用例包含以下覆盖:\n\n"
|
||||
"- P0 正向用例:覆盖核心登录、注册流程\n"
|
||||
"- P0 异常用例:密码错误、账号锁定\n"
|
||||
"- P1 边界用例:连续错误锁定 15 分钟\n\n"
|
||||
"建议:增加并发登录和 Token 过期测试"
|
||||
)
|
||||
return (
|
||||
"你好!我是 ZeekerWatchman 助手。我可以帮你:\n\n"
|
||||
"- 分析 PRD 文档,提取功能点\n"
|
||||
"- 审查和修改 IR(中间表示)\n"
|
||||
"- 生成和优化测试用例\n"
|
||||
"- 导出测试用例为 YAML/CSV 格式\n\n"
|
||||
"请上传一个 PRD 文档开始,或者告诉我你需要什么帮助。"
|
||||
)
|
||||
|
||||
|
||||
class MockLLMClient(LLMClient):
|
||||
"""Mock client that returns realistic dummy responses for demo/testing."""
|
||||
|
||||
def __init__(self, model_name: str = "mock"):
|
||||
self._client = None
|
||||
self._timeout = 60
|
||||
self._model = model_name
|
||||
self._prompt_tokens = 0
|
||||
self._completion_tokens = 0
|
||||
|
||||
def chat(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
response_format: dict | None = None,
|
||||
temperature: float = 0.0,
|
||||
) -> str:
|
||||
# Check both system prompt and last message for signal phrases
|
||||
full_prompt = ""
|
||||
sys_prompt = ""
|
||||
last_msg = ""
|
||||
if messages:
|
||||
for m in messages:
|
||||
content = m.get("content", "")
|
||||
content = content if isinstance(content, str) else str(content)
|
||||
full_prompt += content + "\n"
|
||||
if m.get("role") == "system":
|
||||
sys_prompt = content
|
||||
last_msg = messages[-1].get("content", "")
|
||||
last_msg = last_msg if isinstance(last_msg, str) else str(last_msg)
|
||||
|
||||
# Detect mode: check system prompt first, then last user message
|
||||
is_chat = (
|
||||
"测试用例管理助手" in full_prompt
|
||||
or "## 当前上下文" in full_prompt
|
||||
)
|
||||
is_semantic = (
|
||||
"语义索引" in full_prompt
|
||||
or "function_units" in full_prompt
|
||||
or "semantic" in full_prompt.lower()
|
||||
)
|
||||
is_tc = (
|
||||
"生成完整的测试用例集" in full_prompt
|
||||
or "Given-When-Then" in full_prompt
|
||||
or "## IR 内容" in full_prompt
|
||||
)
|
||||
is_stage2 = (
|
||||
"精准上下文包" in full_prompt
|
||||
or "IR Schema" in full_prompt
|
||||
or "unit_id" in full_prompt
|
||||
)
|
||||
|
||||
if is_chat:
|
||||
result = _mock_chat_reply(last_msg)
|
||||
elif is_semantic:
|
||||
result = json.dumps(MOCK_SEMANTIC_INDEX, ensure_ascii=False)
|
||||
elif is_stage2:
|
||||
result = json.dumps(MOCK_IR_RULES, ensure_ascii=False)
|
||||
elif is_tc:
|
||||
result = json.dumps(MOCK_TESTCASES, ensure_ascii=False)
|
||||
else:
|
||||
result = MOCK_IR_YAML
|
||||
|
||||
logger.info("[MOCK] → %d chars (mode=%s)", len(result),
|
||||
"chat" if is_chat else "semantic" if is_semantic else "stage2" if is_stage2 else "testcases" if is_tc else "ir_yaml")
|
||||
return result
|
||||
|
||||
def chat_with_image(
|
||||
self,
|
||||
model: str,
|
||||
image_path: str,
|
||||
prompt: str,
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
) -> str:
|
||||
return "type: other\nMock image analysis - this is a demo response."
|
||||
@@ -0,0 +1,67 @@
|
||||
# Model router: creates LLM clients based on provider configuration.
|
||||
|
||||
import logging
|
||||
|
||||
from server.config import settings, DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DASHSCOPE_API_KEY, DASHSCOPE_BASE_URL
|
||||
from server.core.llm_provider.base import LLMClient
|
||||
from server.core.llm_provider.mock_client import MockLLMClient
|
||||
|
||||
logger = logging.getLogger("testflow")
|
||||
|
||||
|
||||
class ModelRouter:
|
||||
"""Creates and caches LLM client instances.
|
||||
|
||||
Two independent mock toggles:
|
||||
- USE_MOCK → controls pipeline (left panel): text + image clients
|
||||
- CHAT_USE_MOCK → controls AI assistant (right panel): chat client
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._clients: dict[str, LLMClient] = {}
|
||||
self._log_status()
|
||||
|
||||
def _log_status(self):
|
||||
pipe = "MOCK" if settings.USE_MOCK else "REAL"
|
||||
chat = "MOCK" if settings.CHAT_USE_MOCK else "REAL"
|
||||
logger.info("[ROUTER] 流水线=%s | AI助手=%s (DeepSeek %s / Qwen %s)",
|
||||
pipe, chat, settings.TEXT_MODEL, settings.IMAGE_MODEL)
|
||||
|
||||
def get_text_client(self) -> LLMClient:
|
||||
"""Pipeline text client (controlled by USE_MOCK)."""
|
||||
return self._get_or_create("text", settings.USE_MOCK, settings.TEXT_MODEL,
|
||||
DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL)
|
||||
|
||||
def get_image_client(self) -> LLMClient:
|
||||
"""Pipeline image client (controlled by USE_MOCK)."""
|
||||
return self._get_or_create("image", settings.USE_MOCK, settings.IMAGE_MODEL,
|
||||
DASHSCOPE_API_KEY, DASHSCOPE_BASE_URL)
|
||||
|
||||
def get_chat_client(self) -> LLMClient:
|
||||
"""AI assistant chat client (controlled by CHAT_USE_MOCK)."""
|
||||
return self._get_or_create("chat", settings.CHAT_USE_MOCK, settings.TEXT_MODEL,
|
||||
DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL)
|
||||
|
||||
def _get_or_create(self, key: str, use_mock: bool, model: str,
|
||||
api_key: str, base_url: str) -> LLMClient:
|
||||
if key not in self._clients:
|
||||
if use_mock:
|
||||
logger.info("[ROUTER] %s client → MOCK", key)
|
||||
self._clients[key] = MockLLMClient(model_name=model)
|
||||
else:
|
||||
logger.info("[ROUTER] %s client → REAL (%s)", key, model)
|
||||
self._clients[key] = LLMClient(
|
||||
api_key=api_key, base_url=base_url, model=model,
|
||||
)
|
||||
return self._clients[key]
|
||||
|
||||
@property
|
||||
def text_model(self) -> str:
|
||||
return settings.TEXT_MODEL
|
||||
|
||||
@property
|
||||
def image_model(self) -> str:
|
||||
return settings.IMAGE_MODEL
|
||||
|
||||
|
||||
router = ModelRouter()
|
||||
@@ -0,0 +1,41 @@
|
||||
# 动态人格装配器:从 .zeekerwatchmen/soul/ 加载 Agent 人格
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
from server.config import settings
|
||||
|
||||
|
||||
class PersonalityLoader:
|
||||
"""从 .zeekerwatchmen/soul/ 加载 principles 和 persona 配置"""
|
||||
|
||||
def __init__(self, soul_dir: Path | None = None):
|
||||
self.soul_dir = soul_dir or settings.SOUL_DIR
|
||||
|
||||
def load_principles(self) -> dict:
|
||||
"""加载 principles.yaml,返回原则列表"""
|
||||
path = self.soul_dir / "principles.yaml"
|
||||
if not path.exists():
|
||||
return {"principles": []}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def load_persona(self) -> dict:
|
||||
"""加载 persona.yaml,返回人格配置"""
|
||||
path = self.soul_dir / "persona.yaml"
|
||||
if not path.exists():
|
||||
return {"persona": {}}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def build_system_context(self) -> dict:
|
||||
"""组装完整的人格上下文,用于注入 LLM 系统提示词"""
|
||||
principles = self.load_principles()
|
||||
persona = self.load_persona()
|
||||
return {
|
||||
"principles": principles.get("principles", []),
|
||||
"persona": persona.get("persona", {}),
|
||||
}
|
||||
|
||||
|
||||
personality = PersonalityLoader()
|
||||
@@ -0,0 +1 @@
|
||||
你是一个精确的 JSON 输出引擎。只输出合法的 JSON,不输出任何其他文字。
|
||||
@@ -0,0 +1,6 @@
|
||||
请按以下要求输出结构化内容:
|
||||
|
||||
1. 使用明确的字段和值,不要模糊表述
|
||||
2. 数值必须精确,带上单位
|
||||
3. 列举所有条件,不要用"等"省略
|
||||
4. 如果信息不足,标注为"待确认"而非猜测
|
||||
@@ -0,0 +1,17 @@
|
||||
# LangGraph 推理编排:定义 PRD → IR → TestCase 的 Agent 工作流
|
||||
|
||||
"""
|
||||
LangGraph 流程占位,Phase 3 实现。
|
||||
|
||||
预期工作流节点:
|
||||
1. parse_prd: 解析上传的 PRD 文档
|
||||
2. extract_ir: 调用 Skill 的 extract_ir_prompt 生成 IR
|
||||
3. validate_ir: 校验 IR 是否符合 Schema 与 Principles
|
||||
4. human_review: 等待人工确认(中断点)
|
||||
5. generate_cases: 调用 Skill 的 gen_cases_prompt 生成用例
|
||||
6. export: 格式化导出
|
||||
"""
|
||||
|
||||
# TODO: Phase 3 实现 LangGraph StateGraph
|
||||
# from langgraph.graph import StateGraph, END
|
||||
# from langgraph.checkpointing import MemorySaver
|
||||
@@ -0,0 +1,9 @@
|
||||
# Mock 推理引擎:Phase 2 空壳运行时的占位
|
||||
|
||||
async def mock_reasoning_chain(prd_text: str, skill_name: str = "default") -> dict:
|
||||
"""模拟完整的推理链路,返回占位数据"""
|
||||
return {
|
||||
"ir_yaml": f"# Mock IR generated for PRD ({len(prd_text)} chars)\nfeatures: []",
|
||||
"validation": {"valid": True, "issues": []},
|
||||
"testcases": [],
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# YAML/JSON 差异对比工具
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def compute_diff(doc_a: dict | str, doc_b: dict | str) -> dict:
|
||||
"""Compute diff between two dicts or YAML strings."""
|
||||
if isinstance(doc_a, str):
|
||||
try:
|
||||
doc_a = yaml.safe_load(doc_a) or {}
|
||||
except yaml.YAMLError:
|
||||
doc_a = {}
|
||||
if isinstance(doc_b, str):
|
||||
try:
|
||||
doc_b = yaml.safe_load(doc_b) or {}
|
||||
except yaml.YAMLError:
|
||||
doc_b = {}
|
||||
|
||||
added, removed, modified = _recursive_diff(doc_a, doc_b, "")
|
||||
return {
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"modified": modified,
|
||||
}
|
||||
|
||||
|
||||
def _recursive_diff(a: dict | list, b: dict | list, path: str) -> tuple[list, list, list]:
|
||||
added, removed, modified = [], [], []
|
||||
|
||||
if isinstance(a, dict) and isinstance(b, dict):
|
||||
all_keys = set(a.keys()) | set(b.keys())
|
||||
for key in sorted(all_keys):
|
||||
new_path = f"{path}.{key}" if path else key
|
||||
if key not in a:
|
||||
added.append({"path": new_path, "value": b[key]})
|
||||
elif key not in b:
|
||||
removed.append({"path": new_path, "value": a[key]})
|
||||
elif a[key] != b[key]:
|
||||
if isinstance(a[key], (dict, list)) and isinstance(b[key], (dict, list)):
|
||||
a_sub, r_sub, m_sub = _recursive_diff(a[key], b[key], new_path)
|
||||
added.extend(a_sub)
|
||||
removed.extend(r_sub)
|
||||
modified.extend(m_sub)
|
||||
else:
|
||||
modified.append({
|
||||
"path": new_path,
|
||||
"old_value": a[key],
|
||||
"new_value": b[key],
|
||||
})
|
||||
|
||||
elif isinstance(a, list) and isinstance(b, list):
|
||||
for i in range(max(len(a), len(b))):
|
||||
new_path = f"{path}[{i}]"
|
||||
if i >= len(a):
|
||||
added.append({"path": new_path, "value": b[i]})
|
||||
elif i >= len(b):
|
||||
removed.append({"path": new_path, "value": a[i]})
|
||||
elif a[i] != b[i]:
|
||||
if isinstance(a[i], (dict, list)) and isinstance(b[i], (dict, list)):
|
||||
a_sub, r_sub, m_sub = _recursive_diff(a[i], b[i], new_path)
|
||||
added.extend(a_sub)
|
||||
removed.extend(r_sub)
|
||||
modified.extend(m_sub)
|
||||
else:
|
||||
modified.append({
|
||||
"path": new_path,
|
||||
"old_value": a[i],
|
||||
"new_value": b[i],
|
||||
})
|
||||
|
||||
return added, removed, modified
|
||||
@@ -0,0 +1,29 @@
|
||||
# 导出格式化工具:将测试用例转换为 YAML/CSV/XMind 格式
|
||||
|
||||
import yaml
|
||||
import csv
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
|
||||
def to_yaml(cases: list[dict]) -> str:
|
||||
"""将用例集序列化为 YAML 字符串"""
|
||||
# TODO: Phase 4 按实际 IR schema 调整输出结构
|
||||
return yaml.dump(cases, allow_unicode=True, sort_keys=False)
|
||||
|
||||
|
||||
def to_csv(cases: list[dict]) -> str:
|
||||
"""将用例集序列化为 CSV 字符串"""
|
||||
if not cases:
|
||||
return ""
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=cases[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(cases)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def to_xmind(cases: list[dict]) -> bytes:
|
||||
"""将用例集导出为 XMind 文件(占位,Phase 4 实现)"""
|
||||
# TODO: Phase 4 使用 xmind 库生成 .xmind 文件
|
||||
return b""
|
||||
@@ -0,0 +1,28 @@
|
||||
# 结构化日志工具
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from server.config import settings
|
||||
|
||||
|
||||
def setup_logger(name: str = "testflow") -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
logger.setLevel(logging.DEBUG if settings.DEBUG else logging.INFO)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"[%(asctime)s] %(levelname)s [%(name)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
|
||||
logger = setup_logger()
|
||||
Reference in New Issue
Block a user