init the project

This commit is contained in:
evyzacq
2026-05-25 15:09:42 +08:00
commit 7fc0e7852e
122 changed files with 14557 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# ===== Python =====
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.venv/
venv/
*.egg
# Environment
.env
.env.local
.env.*.local
# ===== Node / Next.js =====
node_modules/
.next/
out/
# ===== IDE =====
.vscode/
.idea/
*.swp
*.swo
# ===== OS =====
.DS_Store
Thumbs.db
# ===== Logs =====
*.log
logs/
nohup.out
# ===== Claude =====
.claude/
# ===== Test =====
.pytest_cache/
coverage/
htmlcov/
output
View File
View File
+122
View File
@@ -0,0 +1,122 @@
[
{
"role": "user",
"content": "把这个改成大小小于40MB"
},
{
"role": "assistant",
"content": "好的,已将 ZEE-SYS-FG-01 的文件大小限制从 **≤10MB** 修改为 **≤40MB**,同时保留原有文件格式检查逻辑。\n\n输出 actionmodify_rule。"
},
{
"role": "user",
"content": "增加一个新用例,只是测试“添加用例”接口,所以内容你随便填"
},
{
"role": "assistant",
"content": "好的,已为您增加一条用于测试“添加用例”接口的用例,内容为简单的正向验证。"
},
{
"role": "user",
"content": "这条改为yaml和json"
},
{
"role": "assistant",
"content": "明白您的意思,您希望将 **IR-ZEEKER-4.2.3-002**(原导出格式为 YAML+CSV)修改为只支持 **YAML 和 JSON** 两种格式。\n\n已执行修改:\n- 原描述:`用例导出功能:YAML格式用于自动化执行,CSV用于Excel评审归档`\n- 新描述:`用例导出功能:YAML格式用于自动化执行,JSON用于自动化或评审归档`"
},
{
"role": "user",
"content": "这条拆分成两条yaml和json分开测试"
},
{
"role": "assistant",
"content": "已按您的要求将 **IR-ZEEKER-4.2.3-002** 拆分为两条独立的规则,分别对应 YAML 导出和 JSON 导出测试。测试用例目前维持原状,您可后续指示是否也同步拆分对应用例。\n\n执行操作: \n1. 删除原规则 `IR-ZEEKER-4.2.3-002` \n2. 新增规则 `IR-ZEEKER-4.2.3-003`YAML 导出) \n3. 新增规则 `IR-ZEEKER-4.2.3-004`JSON 导出)"
},
{
"role": "user",
"content": "这条增加png"
},
{
"role": "assistant",
"content": "已为您更新 **IR-ZEEKER-4.2.1-001**,在支持的文件格式中增加了 `.png`,大小限制仍为 ≤40MB。\n\n> **提示**:由于上传格式列表已变更,建议同步更新 **IR-ZEEKER-4.2.1-002**(不支持格式的错误提示),将其中“仅支持 .md/.txt/.docx/.pdf”扩展为包含 `.png`,否则测试时 PNG 文件会继续被错误拒绝。您可以确认后指示修改。\n\n操作执行:"
},
{
"role": "user",
"content": "把tc002修改为每种格式一条tc"
},
{
"role": "assistant",
"content": "已根据您的要求,将 TC-ZEEKER-002 修改为针对特定不支持格式的测试用例,并新增了两种常见不支持格式的独立用例,实现“每种格式一条 TC”的拆分。\n\n已执行操作:\n1. 修改 TC-ZEEKER-002:明确测试 `.jpg` 格式的上传拒绝\n2. 新增 TC-ZEEKER-006:测试 `.gif` 格式的上传拒绝\n3. 新增 TC-ZEEKER-007:测试 `.bmp` 格式的上传拒绝\n\n> **说明**:由于 PRD 未列出所有不支持格式,此处选取了业界最常用的三种非支持格式作为代表。如需覆盖更多格式(如 `.doc`、`.xls`、`.zip` 等),可进一步指示补充。"
},
{
"role": "user",
"content": "把IR002的文件支持改为20MB"
},
{
"role": "assistant",
"content": "收到您的指令,但这里存在一个歧义需要确认:\n\n当前 IR 列表中:\n- **IR-ZEEKER-4.2.1-001**:涉及**文件格式 + 大小限制(≤10MB**\n- **IR-ZEEKER-4.2.1-002**:仅描述**不支持格式的拒绝提示**,与大小无关\n\n您说“把IR002的文件支持改为20MB”:\n- **可能一**:您想修改 **IR001** 中的大小限制(10MB → 20MB),但误写了 `IR002`。\n- **可能二**:您确实想修改 **IR002**,但 IR002 不涉及大小,需要重新定义其描述为“上传文件大小超过20MB时拒绝”等。\n\n请您确认具体意图:\n- 若为**可能一**,我将执行 `modify_rule` 修改 IR001 的 size 为 ≤20MB。\n- 若为**可能二**,请提供 IR002 的新描述内容。\n\n在您确认前,我暂不执行操作。"
},
{
"role": "user",
"content": "第一个"
},
{
"role": "assistant",
"content": "已确认您选择的是**可能一**,现在将 **IR-ZEEKER-4.2.1-001** 的文件大小限制从 ≤10MB 修改为 ≤20MB。\n\n操作执行:\n\n1. 修改 IR-ZEEKER-4.2.1-001 的 description"
},
{
"role": "user",
"content": "把tc001改成每个支持的格式一条tc"
},
{
"role": "assistant",
"content": "明白了,您希望将原先涵盖所有格式的正向用例 **TC-ZEEKER-001** 拆分为每个支持格式独立一条正向用例。支持格式目前包括 `.md`、`.txt`、`.docx`、`.pdf`、`.png`(根据修改后的IR-ZEEKER-4.2.1-001)。\n\n操作分两步执行(每次最多3个action),先修改原有TC为第一种格式,并新增两条;之后再补全剩余两条。\n\n**第一次执行:**\n1. **修改 TC-ZEEKER-001**:改为仅测试 `.md` 格式的上传成功\n2. **新增 TC-ZEEKER-008**:测试 `.txt` 格式\n3. **新增 TC-ZEEKER-009**:测试 `.docx` 格式\n\n\n\n\n\n**第二次执行(请确认后继续)**:补全 `.pdf` 和 `.png` 两条用例。"
},
{
"role": "user",
"content": "把tc001改成每个支持的文件格式一个tc"
},
{
"role": "assistant",
"content": "好的,已理解您的需求:将 **TC-ZEEKER-001** 分解为每个支持的文件格式(`.md`、`.txt`、`.docx`、`.pdf`)各一条独立的正向用例,并且同步更新大小限制为 **≤20MB**(根据之前 IR001 的修改)。\n\n由于每次最多输出 **3 个 action**,我先执行前三个操作:\n\n1. **修改 TC-ZEEKER-001**:改为仅测试 `.md` 格式,大小限制更新为 20MB \n2. **新增 TC-ZEEKER-008**:测试 `.txt` 格式,大小限制 20MB,正向 \n3. **新增 TC-ZEEKER-009**:测试 `.docx` 格式,大小限制 20MB,正向 \n\n稍后我会再执行一次,补上 `.pdf` 格式的 **TC-ZEEKER-010**。\n\n---\n\n**操作执行:**\n\n\n\n---\n\n接下来需要再执行一次 **新增 TC-ZEEKER-010.pdf 格式)**,请您确认是否立即继续?"
},
{
"role": "user",
"content": "全部执行"
},
{
"role": "assistant",
"content": "好的,我现在连续执行两轮操作,完成全部4个用例的拆分。\n\n**第一轮:修改 TC-ZEEKER-001(改为 .md),新增 .txt 和 .docx**\n\n\n\n\n\n\n\n**第二轮:新增 .pdf 格式用例**\n\n\n\n两轮操作已全部输出,请按顺序执行。"
},
{
"role": "user",
"content": "把 TC-ZEEKER-002 标题改为测试标题"
},
{
"role": "assistant",
"content": "收到,将 TC-ZEEKER-002 的标题从“原标题”修改为“测试标题”。"
},
{
"role": "user",
"content": "把 TC-ZEEKER-002 标题改为测试修改功能"
},
{
"role": "assistant",
"content": "好的,将 TC-ZEEKER-002 标题修改为“测试修改功能”。"
},
{
"role": "user",
"content": "把这条的大小改成50MB"
},
{
"role": "assistant",
"content": "根据您的要求,“这条”指的是上一轮操作中涉及的正向用例(TC-ZEEKER-001、008、009、010),需要将其大小限制从 20MB 统一改为 50MB。由于每次最多输出 3 个操作,我先修改前三个用例,再补充最后一个。\n\n---\n\n**第一轮:修改 TC-ZEEKER-001、008、009 的大小限制为 50MB**\n\n\n\n---\n\n**第二轮:修改 TC-ZEEKER-010 的大小限制为 50MB**\n\n\n\n两轮操作已全部输出,请按顺序执行。如有需要,后续可同步更新异常用例(如超过50MB的大文件)中的大小阈值。"
},
{
"role": "user",
"content": "把tc006改成两条,yaml和csv分开测试"
},
{
"role": "assistant",
"content": "好的,将 TC-ZEEKER-006(原导出功能用例)拆分为 YAML 和 CSV 两条独立用例。先删除原用例,再新增两条。\n\n---\n\n**操作序列:**\n\n\n\n\n\n三个操作已输出,请按顺序执行。"
}
]
@@ -0,0 +1,50 @@
[
{
"role": "user",
"content": "把IR001支持的文件大小改成30MB"
},
{
"role": "assistant",
"content": "好的,我来将 IR001 中的文件大小限制从 10MB 修改为 30MB。"
},
{
"role": "user",
"content": "改TC001改成每个格式一个tc"
},
{
"role": "assistant",
"content": "好的,我来将 TC001(对应 IR-ZEEKER-4.2.1-001 的正向用例)拆分为每个文件格式一条独立的测试用例。当前 TC001 覆盖了所有格式,我将删除它,并新增 4 条正向用例,分别针对 .md、.txt、.docx、.pdf 格式。由于一次最多输出 3 个 action,我先执行删除和新增前两个格式的用例,后续两个将在下一次回复中补充。\n\n**操作步骤:**\n1. 删除原 TC001(假设 ID 为 `TC-ZEEKER-001`\n2. 新增 .md 格式正向用例(ID: `TC-ZEEKER-001`,复用)\n3. 新增 .txt 格式正向用例(ID: `TC-ZEEKER-002`\n\n\n\n接下来还需要新增 .docx 和 .pdf 格式的两个用例,请确认是否继续?"
},
{
"role": "user",
"content": "好"
},
{
"role": "assistant",
"content": "好的,我来继续新增 .docx 和 .pdf 格式的正向用例,同时完成 IR001 的文件大小修改。以下是三个操作:\n\n1. 新增 .docx 格式正向用例(ID: TC-ZEEKER-003\n2. 新增 .pdf 格式正向用例(ID: TC-ZEEKER-004\n3. 修改 IR001IR-ZEEKER-4.2.1-001)的文件大小限制为 30MB\n\n\n\n\n\n\n\n至此,所有四个格式的正向用例已全部就绪,IR001 的文件大小也已更新为 30MB。需要我帮您再检查一下覆盖率或生成对应的异常用例吗?"
},
{
"role": "user",
"content": "把新增的001-004前面的模块改成“我的测试”"
},
{
"role": "assistant",
"content": "明白,您希望将新增的 4 条用例(TC-ZEEKER-001 至 004)的“所属模块”字段修改为“我的测试”。目前测试用例的结构中,我看到的字段包括 `case_title`、`steps`、`expected_result`、`priority`、`tags`,并没有显式的“模块”字段。请问您所指的“模块”是指:\n\n- **tags**(标签)?例如将标签改为 `[\"我的测试\"]`\n- 还是用例标题前方显示的类别(例如 `[P0]` 后面的部分)?\n- 或者您希望为每条用例增加一个自定义属性(如 `module`)?\n\n为了准确执行,请确认具体是哪个字段,我将立即为您修改。"
},
{
"role": "user",
"content": "执行"
},
{
"role": "assistant",
"content": "根据您的要求,我将为 TC-ZEEKER-001 至 TC-ZEEKER-004 这四条用例增加 `module` 字段并设置为 `\"我的测试\"`。由于一次最多 3 个操作,我将分两次执行。\n\n**第一次操作(前三条用例)**\n\n1. 修改 TC-ZEEKER-001,增加 module: \"我的测试\"\n2. 修改 TC-ZEEKER-002,增加 module: \"我的测试\"\n3. 修改 TC-ZEEKER-003,增加 module: \"我的测试\"\n\n\n\n**第二次操作(第四条用例)**\n\n4. 修改 TC-ZEEKER-004,增加 module: \"我的测试\"\n\n\n\n请在第一次操作确认后,我再继续执行第二次操作。"
},
{
"role": "user",
"content": "全部执行不要再问我"
},
{
"role": "assistant",
"content": "好的,全部执行,不再询问。由于系统限制每次最多 3 个操作,我将分两个 action 块一次性输出,请您依次确认执行。\n\n**操作块一:修改 TC-ZEEKER-001、002、003,增加 module: \"我的测试\"**\n\n**操作块二:修改 TC-ZEEKER-004,增加 module: \"我的测试\"**"
}
]
@@ -0,0 +1,26 @@
[
{
"role": "user",
"content": "改ir001的车速为20km/h"
},
{
"role": "assistant",
"content": "已识别 IR-FEAT-0-001 规则,将车速条件从 15km/h 修改为 20km/h,对应的 rule_id 和 changes 如下:"
},
{
"role": "user",
"content": "把tc001改成两条,等于20km/h和大于20km/h"
},
{
"role": "assistant",
"content": "好的,我来处理。首先需要明确 tc001 对应的 case_id。根据左侧概览,第一条用例是 `[P0] 当行车娱乐限制开关开启,受限应用处于前台,且车速达到20km/h并持续5秒时,系统自动将该应用退至后台或暂停对应功能,并`,其 ID 可能为 `TC-ZWM-001`(系统默认格式)。我将按此ID操作,如果不对请您指正。\n\n同时,您之前两次要求修改 ir001 的车速为 20km/h,我上次未执行 action,这次一并处理。\n\n**操作步骤说明:**\n1. 修改 IR-FEAT-0-001 描述中的车速条件(将原15km/h改为20km/h),注意IR描述中已隐含20km/h,但确认精确。\n2. 删除原 tc001(假设id=TC-ZWM-001)。\n3. 添加两条新用例,分别覆盖车速 **等于20km/h** 和 **大于20km/h**(如30km/h)的场景,保持“持续5秒”和其他前置条件不变。\n\n由于一次最多3个action,我先执行 **modify_rule**、**delete_case** 和 **add_case(equal)**,第二条(大于)将在后续操作中补充。\n\n\n\n\n\n\n\n以上已添加 **等于20km/h** 的用例,并删除了原用例。接下来还需添加 **大于20km/h** 的用例,我将在您确认当前操作后继续补充。请您检查 case_id 是否正确,以及新用例内容是否符合预期。"
},
{
"role": "user",
"content": "再试一次"
},
{
"role": "assistant",
"content": "好的,我来执行。先删除原 tc001ID 假设为 TC-ZWM-001),然后添加两条新用例,分别覆盖车速 **等于20km/h** 和 **大于20km/h**(以30km/h为例),其余前置条件与 IR-FEAT-0-001 一致。\n\n注意:为避免与现有用例 ID 冲突,第二条用例使用 TC-ZWM-012(因为当前已有11条,ID可能到011)。\n\n\n\n\n\n请检查执行结果。如果 ID 需要调整请告知。"
}
]
@@ -0,0 +1,18 @@
[
{
"role": "user",
"content": "帮我把ir001的速度改为30km/h"
},
{
"role": "assistant",
"content": "好的,根据你的要求,我将更新 IR-XXAUTO-0-001 规则中的速度条件。\n\n以下是为您生成的修改请求:\n\n**修改内容:**\n- **rule_id**: IR-XXAUTO-0-001\n- **description**: `当行车娱乐限制开关开启时,若受限应用处于前台且车速≥30km/h持续5秒,系统自动将应用退至后台或暂停对应功能,并弹出T`\n- **priority**: P0(保持不变)\n- **变更说明**:将车速触发条件从 15km/h 调整为 30km/h,其余内容保持不变。\n\n**请注意**:该速度条件在 IR-011、IR-013 等规则中也有引用。若需同步调整这些规则,请告知我。"
},
{
"role": "user",
"content": "把tc001的大于等于30km/h改成大于30km/h和等于30km/h两条测试用例"
},
{
"role": "assistant",
"content": "好的,我来处理。根据你的要求,将当前 TC-XXAUTO-001(对应 IR-XXAUTO-0-001 的大于等于30km/h)拆分为两条用例:一条测试等于30km/h,另一条测试大于30km/h。同时删除原用例。\n\n以下是操作指令:\n\n\n\n**说明**\n- 删除了原 `TC-XXAUTO-001` 用例。\n- 新增两条正向用例分别覆盖 `=30km/h`(边界值)和 `>30km/h` 场景。\n- 其他条件(开关开启、非P挡、5秒计时等)保持不变。\n- 用例描述保持了与 IR 规则的可追溯性。"
}
]
@@ -0,0 +1,16 @@
你是一位资深的软件测试架构师,擅长从产品需求文档中提取测试要素。
请阅读以下 PRD 文本,提取所有功能点和测试要素,以 YAML 格式输出中间表示(IR)。
输出要求:
1. 严格遵循提供的 JSON Schema 结构
2. 每个功能点包含:模块名、功能名称、功能描述、输入/输出、前置条件、约束条件
3. 标注功能优先级(P0/P1/P2
4. 识别功能之间的依赖关系
{% raw %}
PRD 文本:
{{ prd_text }}
请输出 YAML 格式的 IR
{% endraw %}
@@ -0,0 +1,24 @@
你是一位资深的软件测试工程师。基于以下中间表示(IR),请生成完整的测试用例集。
测试设计原则:
1. 每个功能点至少覆盖一条正向用例和一条异常用例
2. 对于 P0 功能点,额外覆盖边界值测试
3. 用例步骤使用 Given-When-Then 结构
4. 预期结果必须明确可验证
输出格式:JSON 数组,每个测试用例包含以下字段:
- module: 模块名
- feature: 功能点名称
- case_title: 用例标题
- preconditions: 前置条件
- steps: 测试步骤(Given-When-Then
- expected_result: 预期结果
- priority: 用例优先级
- tags: 标签(如:正向/异常/边界)
{% raw %}
IR 内容:
{{ ir_content }}
请输出 JSON 格式的测试用例集:
{% endraw %}
@@ -0,0 +1,59 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["meta", "features"],
"properties": {
"meta": {
"type": "object",
"required": ["prd_title", "extraction_date", "skill_used"],
"properties": {
"prd_title": { "type": "string", "description": "PRD 标题" },
"extraction_date": { "type": "string", "description": "提取日期" },
"skill_used": { "type": "string", "description": "使用的 Skill 名称" }
}
},
"features": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["module", "feature_name", "description", "priority"],
"properties": {
"module": { "type": "string", "description": "所属模块" },
"feature_name": { "type": "string", "description": "功能名称" },
"description": { "type": "string", "description": "功能描述" },
"inputs": {
"type": "array",
"items": { "type": "string" },
"description": "输入参数列表"
},
"outputs": {
"type": "array",
"items": { "type": "string" },
"description": "预期输出列表"
},
"preconditions": {
"type": "array",
"items": { "type": "string" },
"description": "前置条件"
},
"constraints": {
"type": "array",
"items": { "type": "string" },
"description": "约束条件"
},
"priority": {
"type": "string",
"enum": ["P0", "P1", "P2"],
"description": "功能优先级"
},
"dependencies": {
"type": "array",
"items": { "type": "string" },
"description": "依赖的其他功能名称"
}
}
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
# Skill 元信息
name: "default"
version: "1.0.0"
display_name: "通用测试设计"
description: "适用于通用 Web 后台和业务流程的默认测试设计方法论"
domain: "general"
author: "ZeekerWatchman Team"
# Skill 触发条件:当用户上传 PRD 时是否自动匹配
auto_match:
keywords: []
always_available: true
# 依赖的 LLM 能力要求
requirements:
min_context_window: 8192
recommended_model: "deepseek"
@@ -0,0 +1,26 @@
你是一位资深的电商后台测试架构师,精通订单、商品、库存、支付、会员、营销等电商核心业务。
请阅读以下电商后台 PRD 文本,提取所有功能点和测试要素,以 YAML 格式输出中间表示(IR)。
## 电商领域测试要点
在分析时,请额外关注以下电商特有场景:
1. **并发冲突**:库存扣减、优惠券领取、秒杀下单等并发场景
2. **状态流转**:订单状态(待支付→已支付→发货→完成→售后)、退款状态
3. **金额计算**:满减、折扣叠加、运费计算、税费计算
4. **时效控制**:支付超时取消、售后申请期限、优惠券有效期
5. **数据一致性**:库存、订单、支付三方数据一致性
6. **权限控制**:买家/卖家/管理员不同角色的操作权限
## 输出要求
1. 每个功能点包含:模块名(从给定枚举中选择)、功能名称、功能描述、输入/输出、前置条件、约束条件、业务规则、数据一致性要求
2. P0 功能点:下单、支付、库存扣减、退款等核心交易链路
3. P1 功能点:商品管理、会员管理、营销活动等支撑功能
4. P2 功能点:数据分析、日志查询等辅助功能
5. 识别功能之间的依赖关系和调用链
PRD 文本:
{{ prd_text }}
请输出 YAML 格式的 IR
@@ -0,0 +1,35 @@
你是一位资深的电商后台测试工程师。基于以下中间表示(IR),请生成完整的测试用例集。
## 电商测试设计原则
1. **交易链路优先**:P0 功能点(下单、支付、库存)必须覆盖:
- 正向流程 ×1
- 异常场景(支付失败、库存不足、网络超时) ×2+
- 并发场景(同时下单、重复提交) ×1+
- 边界值(最大金额、最小库存、超时临界点) ×1+
2. **金额精度**:涉及金额的场景需验证小数精度、优惠叠加计算、退款金额
3. **状态机覆盖**:订单、售后等需覆盖合法/非法状态转换和并发状态冲突
4. **数据一致性**:库存-订单-支付三方数据校验
5. 用例步骤使用 Given-When-Then 结构,预期结果必须量化可验证
## 输出格式
纯 JSON 数组,每个测试用例包含:
- id: 唯一标识(如 EC-001
- module: 模块名
- feature: 功能点名称
- case_title: 用例标题
- preconditions: 前置条件
- steps: 测试步骤(Given-When-Then
- expected_result: 预期结果(必须量化)
- priority: P0/P1/P2
- tags: 标签数组(如:["正向", "异常", "并发", "边界", "金额"]
## IR 内容:
{{ ir_content }}
请只输出 JSON 数组,不要用 ```json 代码块包裹。
@@ -0,0 +1,73 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["meta", "features"],
"properties": {
"meta": {
"type": "object",
"required": ["prd_title", "extraction_date", "skill_used"],
"properties": {
"prd_title": { "type": "string", "description": "PRD 标题" },
"extraction_date": { "type": "string", "description": "提取日期" },
"skill_used": { "type": "string", "description": "使用的 Skill 名称" }
}
},
"features": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["module", "feature_name", "description", "priority"],
"properties": {
"module": {
"type": "string",
"description": "所属模块",
"enum": ["商品管理", "订单管理", "库存管理", "会员管理", "营销活动", "支付结算", "物流配送", "售后管理", "数据分析", "系统管理"]
},
"feature_name": { "type": "string", "description": "功能名称" },
"description": { "type": "string", "description": "功能描述" },
"inputs": {
"type": "array",
"items": { "type": "string" },
"description": "输入参数列表"
},
"outputs": {
"type": "array",
"items": { "type": "string" },
"description": "预期输出列表"
},
"preconditions": {
"type": "array",
"items": { "type": "string" },
"description": "前置条件"
},
"constraints": {
"type": "array",
"items": { "type": "string" },
"description": "约束条件,电商场景需额外关注:并发库存扣减、优惠叠加规则、支付超时、售后时效"
},
"priority": {
"type": "string",
"enum": ["P0", "P1", "P2"],
"description": "功能优先级"
},
"dependencies": {
"type": "array",
"items": { "type": "string" },
"description": "依赖的其他功能名称"
},
"business_rules": {
"type": "array",
"items": { "type": "string" },
"description": "电商特有:业务规则(如满减规则、优惠券叠加逻辑、库存预留策略)"
},
"data_consistency": {
"type": "string",
"enum": ["强一致", "最终一致", "不要求"],
"description": "电商特有:数据一致性要求级别"
}
}
}
}
}
}
@@ -0,0 +1,23 @@
# Skill 元信息 - 电商后台领域
name: "ecommerce"
version: "1.0.0"
display_name: "PRD管理"
description: "PRD的版本管理"
domain: "ecommerce"
author: "ZeekerWatchman Team"
auto_match:
keywords:
- "订单"
- "商品管理"
- "库存"
- "营销"
- "会员"
- "支付"
- "电商"
- "购物车"
always_available: true
requirements:
min_context_window: 8192
recommended_model: "deepseek"
+13
View File
@@ -0,0 +1,13 @@
# Agent 人格定义
persona:
name: "ZeekerWatchman"
role: "资深测试架构师"
experience: "15年软件测试经验,精通测试方法论"
tone: "专业、严谨、耐心"
traits:
- "擅长从 PRD 中识别隐含需求和边界场景"
- "习惯用结构化思维分解复杂功能"
- "偏好以数据血缘保证测试用例的可追溯性"
limitations:
- "不猜测 PRD 中未明确的功能"
- "不确定的信息会明确标注'待确认'"
+23
View File
@@ -0,0 +1,23 @@
# Agent 不可违背的最高准则
# 这些原则通过 Core/personality/loader 注入到每次 LLM 调用中
principles:
- id: "safety-first"
rule: "安全相关的功能点必须覆盖异常场景和边界条件"
priority: critical
- id: "traceability"
rule: "每个生成的测试用例必须可追溯到 PRD 中的具体功能点"
priority: high
- id: "no-hallucination"
rule: "不得虚构 PRD 中不存在的功能、参数或场景;不确定时标注为待确认"
priority: critical
- id: "completeness"
rule: "对 PRD 中每个功能点至少生成一条正向用例和一条异常用例"
priority: high
- id: "clarity"
rule: "用例标题和步骤必须使用明确的主谓宾结构,避免模糊措辞"
priority: medium
+146
View File
@@ -0,0 +1,146 @@
# ZeekerWatchman
测试用例智能管理平台 — 将 PRD 转化为可执行测试用例。
## 快速开始
```bash
./start.sh
```
自动安装依赖、启动后端 (8765) 和前端 (3000)。访问 http://localhost:3000。
### 手动启动
```bash
# 后端
cd server && pip install -r requirements.txt
PYTHONPATH=. uvicorn server.main:app --reload --port 8765
# 前端
cd web && npm install && npm run dev
```
### 环境配置
```env
# .env
ZEEKER_USE_MOCK=true # 左侧流水线 Mock 开关
ZEEKER_CHAT_USE_MOCK=false # 右侧 AI 助手 Mock 开关
DEEPSEEK_API_KEY=sk-xxx # DeepSeek API Key
DASHSCOPE_API_KEY=sk-xxx # Qwen VL API Key
```
Mock 模式可离线演示全部流程;填入真实 Key 后关闭 Mock 即可使用 LLM。
## 核心工作流
```
上传 PRD (.md/.txt/.docx/.pdf)
→ 3 阶段 IR 流水线 (语义索引 → 逐单元提取 → 合并审计)
→ IR 可视化确认 (YAML 编辑器 + 思维导图 + 审计报告)
→ 生成测试用例 (可编辑表格 + 手动/Chat 增删改)
→ 导出 YAML / CSV / JSON
```
## 架构
```
.zeekerwatchmen/ Agent 灵魂 (Skill/记忆/人格, 热插拔)
server/core/ 底座层 (LLM 客户端/路由, 仅基础设施)
server/services/ 业务层 (PRD 解析, IR 流水线, 用例引擎, Chat, prompt 模板)
web/ Next.js 前端 (左右分栏: 主工作区 + AI Chat Panel)
```
## 主要功能
**PRD 解析**
- .docx 解析: 类型化 blocks (para/table)、表头检测、`[[IMAGE:rid]]` 标记
- 图片分析: Qwen VL 识别流程图/架构图/UI 截图
- .md/.txt/.pdf 支持
**3 阶段 IR 流水线**
- Stage 1: 语义索引 — 全文档 → function_units + concepts
- Stage 2: 逐单元 IR 提取 — 精准上下文包 → 详细规则 (trigger/actions/precondition)
- Stage 3: 合并去重 + 审计报告 (覆盖率/优先级分布)
**IR 可视化**
- Monaco 编辑器 + SVG 思维导图 (按 section 分组, 多行自适应换行)
- 点击思维导图节点 → 编辑器定位对应行
- Audit Report 折叠面板
**测试用例**
- 表格支持行内编辑 (文本/下拉/textarea)
- 手动添加 + Chat LLM 增删改
- 导出 YAML / CSV / JSON (前端直出, 无需后端)
- ID 格式: `TC-{FEATURE}-{NNN}`, 自动分配不重复
**右侧 AI Chat Panel**
- 跨页面持久 (Zustand store)
- 页面感知 (IR/Cases/Home 不同上下文)
- 增量操作 (add/delete/modify) + 自动执行/确认双模式
- 选中行上下文传递 (说"这条"即操作选中条目)
- Session ID 自动刷新, 支持新建会话
**Skill 热插拔**
- `GET /api/skills` 自动扫描 `.zeekerwatchmen/skills/`
- `POST /api/skills/reload` 热重载
- 内置 default (通用) + ecommerce (电商) 双 Skill
**LLM**
- 文本: DeepSeek (`deepseek-v4-flash`)
- 图片: Qwen VL (`qwen3-vl-plus`)
- 独立 Mock 开关 (左侧流水线 / 右侧 Chat 分开控制)
## 项目结构
```
zeekerWatchmen/
├── .zeekerwatchmen/ # Agent 灵魂
│ ├── soul/ # principles.yaml + persona.yaml
│ ├── skills/{default,ecommerce}/
│ └── memory/ # 会话记忆
├── server/
│ ├── api/routes/ # prd, ir, testcase, skills, chat
│ ├── core/
│ │ ├── llm_provider/ # base, router, mock_client
│ │ ├── prompt/templates/ # 框架级 prompt
│ │ ├── personality/ # 动态人格加载
│ │ └── utils/ # logger, diff, export
│ ├── services/
│ │ ├── prompts/ # chat_system.md, ir_step1/2
│ │ ├── prd_manager/ # WordParser, ImageParser
│ │ ├── ir_engine/ # pipeline (3-stage), validator, diff
│ │ ├── testcase_engine/ # generator, exporter
│ │ └── skill_manager/ # Skill 扫描/热加载
│ ├── main.py, config.py
│ └── requirements.txt
├── web/
│ ├── src/
│ │ ├── components/ # Layout, UploadZone, IrWorkspace, MindMap,
│ │ │ # TestCaseTable, PipelineProgress, ChatPanel
│ │ ├── pages/ # index, ir-confirm, cases
│ │ └── lib/ # api, types, store
│ └── package.json
├── docs/
│ ├── PRD.md
│ ├── ARCHITECTURE.md
│ ├── ir_schema.json # IR JSON Schema
│ └── tc_schema.json # Test Case JSON Schema
├── start.sh # 一键启动
├── .env
└── .gitignore
```
## API
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/prd/upload | 上传 PRD 文件 |
| GET | /api/prd/{id} | 获取 PRD 详情 |
| GET | /api/ir/generate-stream | SSE 流式 IR 生成 |
| POST | /api/ir/generate | 同步 IR 生成 |
| PUT | /api/ir/{id} | 更新 IR |
| GET | /api/skills | 列出 Skill |
| POST | /api/testcase/generate | 生成测试用例 |
| POST | /api/chat | AI 对话 |
+81
View File
@@ -0,0 +1,81 @@
testcase-platform/
├── .zeekerwatchmen/ # Agent 灵魂:Skill/记忆/人格
│ ├── soul/
│ │ ├── principles.yaml
│ │ └── persona.yaml
│ ├── skills/
│ │ └── default/ # 内置默认 Skill
│ │ ├── skill.yaml
│ │ ├── extract_ir_prompt.j2
│ │ ├── ir_schema.json
│ │ └── gen_cases_prompt.j2
│ └── memory/ # 空占位,后续自动生成会话记忆
├── server/ # Python FastAPI 后端
│ ├── api/
│ │ ├── __init__.py
│ │ └── routes/
│ │ ├── __init__.py
│ │ ├── prd.py # PRD 相关接口
│ │ ├── ir.py # IR 相关接口
│ │ └── testcase.py # 用例导出接口
│ ├── core/
│ │ ├── __init__.py
│ │ ├── prompt/
│ │ │ └── templates/ # 系统级提示词模板(可被 skill 覆盖)
│ │ ├── llm_provider/
│ │ │ ├── __init__.py
│ │ │ ├── base.py # 抽象模型客户端
│ │ │ ├── router.py # 模型别名路由器
│ │ │ └── mock_client.py # Mock 实现
│ │ ├── reasoning/
│ │ │ ├── __init__.py
│ │ │ ├── graph.py # LangGraph 流程占位
│ │ │ └── mock.py
│ │ ├── utils/
│ │ │ ├── logger.py # 结构化日志
│ │ │ ├── diff.py # YAML/JSON 对比
│ │ │ └── export_utils.py # 导出格式化
│ │ └── personality/
│ │ ├── __init__.py
│ │ └── loader.py # 从 .zeekerwatchmen 动态加载人格
│ ├── services/ # 业务逻辑层
│ │ ├── __init__.py
│ │ ├── prd_manager/
│ │ │ ├── __init__.py
│ │ │ └── service.py
│ │ ├── ir_engine/
│ │ │ ├── __init__.py
│ │ │ ├── generator.py # 调用 LLM 生成 IR(当前 Mock
│ │ │ ├── validator.py # 基于 ir_schema 验证
│ │ │ └── diff.py
│ │ └── testcase_engine/
│ │ ├── __init__.py
│ │ ├── generator.py # 用例生成
│ │ └── exporter.py # 导出 YAML/CSV/XMind
│ ├── main.py # FastAPI 入口
│ ├── config.py # 配置管理(模型列表、路径等)
│ └── requirements.txt
├── web/ # Next.js 前端
│ ├── src/
│ │ ├── components/
│ │ │ ├── Layout.tsx
│ │ │ ├── UploadZone.tsx
│ │ │ ├── IrWorkspace.tsx # 双栏 IR 编辑器 + 思维导图预览
│ │ │ ├── TestCaseTable.tsx
│ │ │ └── ExportPanel.tsx
│ │ ├── pages/
│ │ │ ├── index.tsx # 主页(上传/选择 skill)
│ │ │ ├── ir-confirm.tsx # IR 确认页
│ │ │ └── cases.tsx # 用例预览导出页
│ │ ├── hooks/ # 自定义 hooks (usePrd, useIr, useTestcases)
│ │ └── lib/
│ │ ├── api.ts # 后端 API 封装
│ │ └── types.ts # 前端类型定义
│ ├── package.json
│ ├── tsconfig.json
│ ├── next.config.js
│ └── tailwind.config.js
├── docs/ # 文档
│ ├── PRD.md
│ └── ARCHITECTURE.md
└── README.md
+242
View File
@@ -0,0 +1,242 @@
# 产品需求文档:ZeekerWatchman (测试用例智能管理平台)
| 文档版本 | 修订日期 | 修订人 | 修订说明 |
| -------- | ---------- | ------ | -------------------------------------- |
| V0.1 | 2026-05-22 | - | 初稿,整合核心架构与用户流程 |
| V0.2 | 2026-05-22 | - | 细化 IR 规范与 Skill 接口,补充非功能需求 |
| V1.0 | 2026-05-22 | - | P2-P4 完成,更新实现状态、架构分层、3 阶段流水线、Chat Panel |
---
## 1. 产品概述与愿景
### 1.1 产品愿景
将"产品需求文档(PRD"到"可执行测试用例"的转换过程,从依赖人工经验的黑盒,升级为**可解释、可迭代、可插拔的知识工程系统**。
### 1.2 核心价值主张
- **输入**:非结构化的 PRD 文本(.md, .txt, .docx, .pdf
- **处理**:AI 驱动的 3 阶段流水线:语义索引 → 逐单元 IR 提取 → 合并审计
- **输出**:多格式测试用例(YAML / CSV / JSON),含完整审计报告
- **差异化**Agent 人格化、Skill 热插拔、Chat Panel 交互式编辑
---
## 2. 用户与核心场景
### 2.1 目标用户画像
- **测试架构师/资深 QA**:定义测试策略,设计 Skill 包,审核 IR 与审计报告
- **测试工程师**:上传 PRD,执行生成,通过 Chat Panel 微调并导出用例
- **开发工程师**:查看 YAML 格式用例,集成到 CI/CD
### 2.2 核心用户故事
- **作为**测试工程师,**我希望**上传 PRD 后自动生成 IR 并在思维导图中可视化确认
- **作为**测试架构师,**我希望**将团队方法论打包成 Skill,一键切换
- **作为**测试工程师,**我希望**通过右侧 Chat Panel 用自然语言修改 IR 内容和用例
---
## 3. 系统架构总览(实现后)
```
┌──────────────────────────────────────────────────┐
│ .zeekerwatchmen/ (Agent 灵魂) │
│ ├── skills/{default,ecommerce}/ (热插拔 Skill) │
│ ├── memory/ (会话记忆) │
│ └── soul/ (人格/原则) │
└──────────────────────────────────────────────────┘
↓ 动态注入
┌──────────────────────────────────────────────────┐
│ Core (底座层 — 仅基础设施) │
│ ├── llm_provider/ (DeepSeek/Qwen 客户端+路由) │
│ ├── prompt/templates/ (宽泛框架级 prompt) │
│ ├── personality/ (动态人格装配) │
│ ├── reasoning/ (ReAct/LangGraph 编排) │
│ └── utils/ (Logger, Diff, Export) │
└──────────────────────────────────────────────────┘
↓ 能力支撑
┌──────────────────────────────────────────────────┐
│ Services (业务层 — 领域逻辑 + 业务 prompt) │
│ ├── prompts/ (ir_step1/2 业务 prompt) │
│ ├── prd_manager/ (Word/PDF 解析+图片分析) │
│ ├── ir_engine/ (3 阶段流水线+验证+Diff) │
│ ├── testcase_engine/ (用例生成+导出) │
│ └── skill_manager/ (Skill 扫描+热加载) │
└──────────────────────────────────────────────────┘
↓ API 服务
┌──────────────────────────────────────────────────┐
│ Web UI (Next.js 左右分栏布局) │
│ ┌──────────────────────┬────────────────────┐ │
│ │ 左侧:主工作区 │ 右侧:AI Chat │ │
│ │ 上传→IR可视化→用例 │ 自然语言交互 │ │
│ │ 思维导图+审计报告 │ 操作左侧内容 │ │
│ └──────────────────────┴────────────────────┘ │
└──────────────────────────────────────────────────┘
```
### 3.1 分层原则
- **core/**(底座层):只有基础设施,不含业务逻辑。更新底座只改 core,不改 services
- **services/prompts/**(业务 prompt):所有领域相关的 prompt 模板放在此处
- **services/**(业务层):领域逻辑调用 core 的 LLM 接口,引用自己的 prompt
---
## 4. 核心功能详述(已实现)
### 4.1 Skill 热插拔体系 (`.zeekerwatchmen`)
- `soul/principles.yaml`5 条不可违背准则
- `soul/persona.yaml`Agent 人格定义("Zeeker"
- `skills/{name}/`:每个 Skill 独立目录,含 skill.yaml + ir_schema.json + extract/gen_cases_prompt.j2
- `skills/default/`:通用测试设计
- `skills/ecommerce/`:电商后台(含 business_rules、data_consistency 等电商特有字段)
- `GET /api/skills` 自动扫描,`POST /api/skills/reload` 热重载
### 4.2 PRD 解析 (`prd_manager`)
- **WordParser**`services/prd_manager/word_parser.py`):直接遍历 document.body XML
- 类型化 blocks`{type: "para"|"table", index, text/headers/rows}`
- 表格列结构:`columns[{name, row, col, text}]`
- 表头检测启发式:首行所有单元格 < 20 字符则视为表头
- `[[IMAGE:rid]]` 标记嵌入文本
- image_sources 含精确定位:`{section, table, row, column, name}`
- **ImageParser**`services/prd_manager/image_parser.py`):Qwen VL 分析图片(类型+描述)
- 支持格式:.md / .txt / .docx / .pdf
- Markdown 解析:`#` 标题 → sections`|` 表格 → table blocks
### 4.3 3 阶段 IR 生成流水线 (核心升级)
```
PRD parsed JSON ──→ Stage 1 ──→ Stage 2 ──→ Stage 3 ──→ ir_final.json
语义索引 逐单元提取 合并审计 + audit_report.md
```
#### Stage 1:宏观语义索引
- 输入:完整 parsed PRD JSONsections + image_analysis
- ``format_document_for_prompt()``:渲染为 sections/blocks/tables/images 可读文本
- Prompt 模板:`services/prompts/ir_step1_semantic_index.txt`
- 输出:`{feature_name, concepts[], function_units[{unit_id, name, description, sources[]}]}`
- System prompt: "精确的 JSON 输出引擎" + 健壮 `extract_json_from_response()` + 3 次重试
- 失败降级:`_fallback_semantic_index()` 从段落/表格自动提取
#### Stage 2:逐功能单元 IR 提取
- 输入:semantic_index + parsed PRD
- `build_context_package()`:为每个 function_unit 构建精准上下文包(texts + tables + images + conflicts
- Prompt 模板:`services/prompts/ir_step2_extraction.txt`
- IR Schema``trigger{operator, conditions[{signal,operator,value,unit}]}`` + ``actions[{type,description,content}]`` + ``precondition``
- `ThreadPoolExecutor(max_workers=3)` 并行调用
- 输出:`[{unit_id, rules[{description, trigger, actions, sources, priority}]}]`
#### Stage 3:确定性合并与审计
- ``rule_signature(rule)``SHA256(trigger.conditions + actions) 去重
- ``merge_rules()``:去重,合并 sources
- ``assign_rule_ids()``:格式 `{FEATURE}-{TYPE}-FG-{NN}`SYS/UI/SDK
- **_generate_audit()**:章节覆盖、图片引用覆盖、优先级分布
- **_format_audit_report()**Markdown 格式审计报告
### 4.4 Chat Panel 交互式编辑
- 布局:左侧 380px 固定宽度 ChatPanel + 右侧 flex-1 主内容
- 状态桥:Zustand store (`web/src/lib/store.ts`) 在页面和 ChatPanel 间共享 PRD/IR/Testcases
- 后端 API`POST /api/chat` — 接收 context + history,返回 reply + actions
- Action 机制:
- `{"action": "update_ir", "content": "..."}` — 更新左侧 IR 编辑器
- `{"action": "generate_cases", "ir_id": "..."}` — 跳转生成用例
- `{"action": "navigate", "page": "..."}` — 页面跳转
### 4.5 思维导图可视化 (`web/src/components/MindMap.tsx`)
- 双格式兼容:YAML IRfeatures 树)和 JSON pipeline IRrules 树)
- SVG 递归渲染:节点矩形 + 贝塞尔曲线连接 + 优先级颜色标签
- Hover 提示:鼠标悬停显示完整文本
### 4.6 测试用例生成与导出
- **规则→用例转换**`_rules_to_cases()`):trigger.conditions → Given 子句,actions → Then 子句
- 导出格式:YAML / CSV / JSON`GET /api/testcase/export/{format}`
### 4.7 LLM 模型配置
- 文本模型:DeepSeek (`deepseek-chat` / `deepseek-v4-pro`)base_url: `https://api.deepseek.com`
- 图片模型:Qwen VL (`qwen3-vl-plus`)base_url: `https://dashscope.aliyuncs.com/compatible-mode/v1`
- 无 API Key 时自动降级为 Mock 模式
---
## 5. 技术栈
| 层 | 技术 |
|---|------|
| 后端框架 | Python FastAPI + Uvicorn |
| LLM 客户端 | OpenAI SDK(兼容 DeepSeek + Qwen DashScope |
| LLM 模型 | DeepSeek (文本) + Qwen VL (图片) |
| 前端框架 | Next.js 15 + React 19 (Pages Router) |
| 样式 | Tailwind CSS 3.4 |
| 编辑器 | Monaco Editor (`@monaco-editor/react`) |
| 状态管理 | Zustand 5 |
| 文档解析 | python-docx + PyPDF2 |
| 提示模板 | Jinja2 |
| 校验 | jsonschema + PyYAML |
---
## 6. 里程碑与推进路径
| 阶段 | 核心任务 | 状态 |
| ---- | -------- | ---- |
| P1 | PRD 定稿与架构评审 | ✅ 完成 |
| P2 | 代码框架搭建(脚手架、空壳运行、接口占位) | ✅ 完成 |
| P3 | 核心链条实现(DeepSeek+QwenPRD→IR→用例全流程) | ✅ 完成 |
| P4 | 热插拔改造(Skill 扫描/加载,电商 Skill 验证,左右分栏 Chat Panel | ✅ 完成 |
| P5 | 体验打磨(动画、错误处理、性能优化、更多 Skill) | 进行中 |
---
## 7. 项目结构(实现后)
```
zeekerWatchmen/
├── .zeekerwatchmen/ # Agent 灵魂:Skill/记忆/人格
│ ├── soul/ # principles.yaml + persona.yaml
│ ├── skills/ # default/ + ecommerce/
│ └── memory/ # 会话记忆
├── server/ # Python FastAPI 后端
│ ├── api/routes/ # prd.py, ir.py, testcase.py, skills.py, chat.py
│ ├── core/ # 底座层(仅基础设施)
│ │ ├── llm_provider/ # base.py, router.py, mock_client.py
│ │ ├── prompt/templates/ # 宽泛框架级 prompt
│ │ ├── personality/ # loader.py (动态人格装配)
│ │ ├── reasoning/ # graph.py (LangGraph 占位)
│ │ └── utils/ # logger.py, diff.py, export_utils.py
│ ├── services/ # 业务层
│ │ ├── prompts/ # 业务 prompt 模板
│ │ │ ├── ir_step1_semantic_index.txt
│ │ │ └── ir_step2_extraction.txt
│ │ ├── prd_manager/ # WordParser + ImageParser + service
│ │ ├── ir_engine/ # pipeline.py + generator + validator + diff
│ │ ├── testcase_engine/ # generator.py + exporter.py
│ │ └── skill_manager/ # service.py (Skill 扫描/热加载)
│ ├── main.py # FastAPI 入口
│ ├── config.py # 配置管理 (pydantic-settings + .env)
│ └── requirements.txt
├── web/ # Next.js 前端
│ ├── src/
│ │ ├── components/ # Layout, UploadZone, IrWorkspace, MindMap,
│ │ │ # TestCaseTable, ExportPanel, ChatPanel
│ │ ├── pages/ # index.tsx, ir-confirm.tsx, cases.tsx
│ │ ├── hooks/ # usePrd.ts, useIr.ts, useTestcases.ts
│ │ ├── lib/ # api.ts, types.ts, store.ts
│ │ └── styles/ # globals.css
│ ├── package.json, tsconfig.json, next.config.js, tailwind.config.js
├── docs/ # PRD.md, ARCHITECTURE.md
├── start.sh # 一键启动脚本
├── .env # 环境变量 (API Keys, 端口, 模型配置)
├── .gitignore
└── README.md
```
---
## 8. 一键启动
```bash
# Linux/WSL:
chmod +x start.sh && ./start.sh
# 访问 http://localhost:3000
```
start.sh 自动完成:依赖安装检查 → 后端启动 (8765) → 前端启动 (3000) → 输出访问地址。
+156
View File
@@ -0,0 +1,156 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://zeekerwatchman/schemas/ir.json",
"title": "IR (Intermediate Representation) Schema",
"description": "3-stage pipeline output: feature analysis rules with trigger/action semantics",
"type": "object",
"required": ["feature", "feature_id", "rules"],
"properties": {
"feature": {
"type": "string",
"description": "功能名称,从 PRD 中提取"
},
"feature_id": {
"type": "string",
"description": "功能缩写,用于生成 rule_id 前缀,如 PRD, AUTH, ORDER",
"pattern": "^[A-Z]{2,8}$"
},
"rule_count": {
"type": "integer",
"description": "规则总数"
},
"generated_at": {
"type": "string",
"format": "date-time"
},
"rules": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/definitions/IRRule"
}
}
},
"definitions": {
"IRRule": {
"type": "object",
"required": ["rule_id", "description", "priority"],
"properties": {
"rule_id": {
"type": "string",
"description": "唯一标识。格式: IR-{feature_id}-{section}-{seq},如 IR-PRD-4.2.1-001",
"pattern": "^IR-[A-Z]{2,8}-[0-9.]+-[0-9]{3}$",
"examples": ["IR-PRD-4.2.1-001", "IR-AUTH-2.1-001"]
},
"description": {
"type": "string",
"description": "完整的中文自然语言描述,包含前置条件、触发条件和所有动作",
"minLength": 10
},
"priority": {
"type": "string",
"enum": ["P0", "P1", "P2"],
"description": "P0=核心安全规则, P1=重要规则, P2=边界情况"
},
"sources": {
"type": "array",
"items": { "$ref": "#/definitions/Source" },
"description": "规则的数据来源锚点"
},
"precondition": {
"type": "object",
"description": "规则生效的前置状态条件。可以是空对象",
"properties": {
"switch": { "type": "string" },
"app_type": { "type": "string", "description": "如: 系统限制, SDK限制, 其他应用" },
"app_state": { "type": "string", "description": "如: 前台, 后台" }
}
},
"trigger": {
"type": "object",
"required": ["operator", "conditions"],
"properties": {
"operator": {
"type": "string",
"enum": ["AND", "OR"],
"description": "多个条件的组合方式"
},
"conditions": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/TriggerCondition" }
}
}
},
"actions": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/Action" }
}
}
},
"TriggerCondition": {
"type": "object",
"required": ["signal", "operator", "value"],
"properties": {
"signal": {
"type": "string",
"description": "触发信号名,如 车速, 档位, 文件格式",
"examples": ["车速", "文件大小", "应用请求启动"]
},
"operator": {
"type": "string",
"enum": [">=", ">", "<=", "<", "==", "!=", "in", "not_in", "exists"],
"description": "比较运算符"
},
"value": {
"description": "比较值,可以是数字、字符串或数组"
},
"unit": {
"type": "string",
"description": "数值单位,如 km/h, 秒, MB",
"examples": ["km/h", "秒", "MB"]
}
}
},
"Action": {
"type": "object",
"required": ["type", "description"],
"properties": {
"type": {
"type": "string",
"enum": ["system", "user_interaction"],
"description": "system=系统自动行为, user_interaction=用户可见交互"
},
"description": {
"type": "string",
"description": "动作描述"
},
"content": {
"type": "string",
"description": "用户可见内容(仅 user_interaction 类型需要),如 Toast 文案"
}
}
},
"Source": {
"type": "object",
"required": ["type"],
"properties": {
"type": {
"type": "string",
"enum": ["para", "table", "image", "logic_tree"],
"description": "来源类型"
},
"section": { "type": "string", "description": "章节号" },
"row": { "type": "integer", "description": "表格行号" },
"text_snippet": { "type": "string", "description": "前200字关键文字" },
"image_id": { "type": "string", "description": "图片 rId" }
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://zeekerwatchman/schemas/testcase.json",
"title": "Test Case Schema",
"description": "从 IR 规则生成的测试用例格式",
"type": "object",
"required": ["testcases"],
"properties": {
"testcases": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/TestCase" }
},
"metadata": {
"type": "object",
"properties": {
"total": { "type": "integer" },
"ir_id": { "type": "string", "description": "来源 IR ID" },
"generated_at": { "type": "string", "format": "date-time" }
}
}
},
"definitions": {
"TestCase": {
"type": "object",
"required": ["id", "module", "case_title", "steps", "expected_result", "priority"],
"properties": {
"id": {
"type": "string",
"description": "唯一标识。格式: TC-{feature_id}-{seq},如 TC-PRD-001",
"pattern": "^TC-[A-Z]{2,8}-[0-9]{3}$",
"examples": ["TC-PRD-001", "TC-AUTH-002"]
},
"module": {
"type": "string",
"description": "所属模块名"
},
"feature": {
"type": "string",
"description": "功能点名称"
},
"case_title": {
"type": "string",
"description": "用例标题,格式: 正向/异常/边界-简短描述",
"examples": ["正向-上传md格式PRD成功", "异常-上传不支持的文件格式"]
},
"preconditions": {
"type": "string",
"description": "前置条件"
},
"steps": {
"type": "string",
"description": "测试步骤,Given-When-Then 格式,换行分隔",
"examples": ["Given 用户已登录\nWhen 上传.md文件\nThen 成功解析"]
},
"expected_result": {
"type": "string",
"description": "预期结果,必须量化可验证"
},
"priority": {
"type": "string",
"enum": ["P0", "P1", "P2"],
"description": "P0=冒烟/核心, P1=重要, P2=补充"
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "标签: 正向, 异常, 边界, 并发, 安全 等",
"examples": [["正向", "冒烟"], ["异常", "安全"]]
}
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
# ZeekerWatchman - Environment Configuration
# Debug mode
ZEEKER_DEBUG=true
# LLM Configuration - 默认使用 DeepSeek
ZEEKER_LLM_PROVIDER=deepseek
ZEEKER_TEXT_MODEL=deepseek-v4-flash
ZEEKER_IMAGE_MODEL=qwen3-vl-plus
# Mock 开关 — 左侧流水线 (IR生成/用例生成) true=Mock快速演示 / false=真实DeepSeek
ZEEKER_USE_MOCK=true
# Mock 开关 — 右侧AI助手 (Chat Panel) true=Mock演示 / false=真实DeepSeek对话
ZEEKER_CHAT_USE_MOCK=true
# DeepSeek API (文本生成)
DEEPSEEK_API_KEY=sk-xxxx
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
# Qwen DashScope API (图片理解,OpenAI 兼容模式)
DASHSCOPE_API_KEY=sk-xxx
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# CORS - 前端地址
ZEEKER_CORS_ORIGINS=["http://localhost:3000"]
# Upload
ZEEKER_MAX_UPLOAD_SIZE_MB=10
+127
View File
@@ -0,0 +1,127 @@
---
name: 文档分析代理
description: 一个智能代理,用于分析文档(.docx, .pdf),提取和结构化内容,检测文本与图表之间的冲突,并生成结构化的JSON中间表示。
---
# 文档分析代理
## 环境变量配置
在执行任何分析之前,必须先检查用户是否配置了DASHSCOPE_API_KEY,如果没有提示用户设置为环境变量DASHSCOPE_API_KEY。
所有脚本通过该环境变量读取 API Key。严禁在对话或命令行中明文写入或显示 API Key。
### 配置方式
```
openclaw config set env.DASHSCOPE_API_KEY "llm-api-key"
```
---
## 功能
代理能够:
- 解析各种文档格式(.docx, .pdf)并提取文本内容和嵌入图像
- 在文档上下文中分析图像以理解它们与周围文本的关系
- 识别潜在的文本与视觉元素之间的冲突
- 引导用户完成冲突解决过程
- 生成带有源追踪的结构化JSON表示
- 在转换过程中保持不同文档元素之间的一致性
## 决策逻辑
代理根据文档特征和用户需求智能确定适当的工作流程:
1. **文档评估阶段**:当用户提供文档时,代理首先根据文档格式和内容复杂性确定适当的解析方法。
2. **内容分析阶段**:代理分析提取的内容以识别需要特殊处理的图表、流程图、架构图、状态图和序列图。
3. **冲突检测阶段**:代理识别文本内容与视觉元素之间的潜在差异,特别关注条件不匹配和矛盾信息。
4. **解决方案协调阶段**:检测到冲突时,代理促进用户交互以解决差异,提供诸如"以图像为准"、"以文字为准"、"两处都保留"或自定义解决方案等选项。
5. **表示生成阶段**:代理综合所有输入并生成带源追踪的结构化JSON中间表示。
## 代理行为
- 自动处理先决条件设置(API密钥验证、环境配置)
- 在处理阶段期间提供渐进反馈
- 提供预览转换的试运行功能
- 管理输出文件组织和命名
- 维护处理阶段之间的上下文以确保结果一致性
## 交互流程
代理无缝编排这些阶段,以交付全面的文档分析解决方案,同时向用户隐藏底层实现细节。
自动执行所有阶段,无需询问用户是否执行下一步,除非需要用户介入协助。
### 1. 初始化
验证先决条件并准备处理环境。
### 2. 解析
从输入文档中提取内容和结构(运行 doc_parser_skill)。产出:`<basename>_parsed.json`
### 3. 分析
识别关键元素和可能需要关注的区域。
### 4. 冲突解决
运行 conflict_detection_skill,检测文本与图表之间的差异。如发现冲突,请求用户裁决后执行 resolution_application_skill。产出:`<basename>_updated.json`
### 5. 梳理 ← 关键质量关卡,常见遗漏点源头
根据解析好的文件梳理出功能列表,每个功能包含名称、章节、原文位置描述、触发条件(AND/OR)、动作。
**必须同时从以下三种来源穷举功能点,缺一不可,且不能跳过b/c直接执行:**
**a. 文本与表格** — 提取所有章节中段落和表格明确描述的功能逻辑。
**b. 流程图/状态图/架构图描述**image_analysis[] 中 type 为 flowchart / state / architecture / sequence / activity 的条目):
- 以决策树的视角逐条追踪每个菱形判断节点,沿"是/否"两条分支分别行走
- **每条可达决策路径必须对应一个独立的 IR 条目**,不得将多条路径合并为一条概括性描述
- 从起始节点出发,沿着箭头逐层展开,确保没有遗漏任何分支末端
- 每条路径提取:触发条件链(AND/OR组合)+ 执行动作 + 分支上的具体条件值
- 特别注意图中那些"文字没有写"的隐性条件、默认行为、边界值
**c. 交互图/UI场景图描述**image_analysis[] 中 type 为 other 但 description 描述UI交互场景的条目):
- 分析交互图中的每个场景编号(如01、02、A、B、C等)
- 每个场景拆分出一个独立的功能点,包含:触发条件(用户操作或系统状态)、系统响应(Toast文案、页面变化等)
- 如果多个场景在逻辑上形成流程链,保留上下游关系
**d. 交叉验证** — 对每个已识别的功能点标注来源(text / image_rIdXX),确保 text 来源覆盖了文档所有文字,image 来源覆盖了所有 image_analysis 中有行为逻辑的条目。
### 6. 合成
根据梳理的功能列表,生成最终结构化表示(IR),输出到输出目录的 `<文档名>_ir.json` 文件中。
使用 ir_generation_skill 脚本。
### 7. 检查
对比解析好的文件(parsed.json/updated.json)和合成的 IR 文件,**对照以下清单逐项验证**,找到遗漏点:
**7a. 文本覆盖检查** — 遍历 parsed.json 中所有 sections[].blocks(包括 para 和 table),对每个段/格判断:
- 是否描述了某个功能行为?
- 若是,该行为是否已在 IR 中?
- 遗漏的标记出来。
**7b. 流程图路径完整性检查** — 逐张流程图 description,对照决策树结构:
- □ 每条"是"分支路径 → IR 中有对应条目
- □ 每条"否"分支路径 → IR 中有对应条目
- □ 起始节点的每个出口都被追踪到
- □ 边角情况(分支合并、默认走法)也已覆盖
**7c. 交互图场景检查** — 逐张交互图 description
- □ 每个场景编号(01/02/A/B/C等)→ IR 中有对应条目
- □ 每个 UX 文案 → IR 中有对应条目或作为已有条目的动作细节
- □ 特殊行为 → IR 中有对应条件
**7d. 功能列表交叉检查** — 如果文档有功能列表表格:
- □ 表格中的每一行 → IR 中有对应条目
- □ 章节中的详细描述 → IR 中有对应条目,且粒度不粗于功能列表
### 8. 补充
根据检查结果,将遗漏功能加入已有 IR 文件。
使用 ir_generation_skill 添加每个遗漏点。
**每补充完一批,回到步骤7重新检查**,直到所有检查项均为 ✅(即功能点一致)。
### 9. 输出
提供带追踪信息的完整分析结果,包括:
- 功能点清单总览
- 冲突解决记录
- 文档质量备注(如图片错误、占位符、不一致等)
+2
View File
@@ -0,0 +1,2 @@
openai
python-docx
@@ -0,0 +1,37 @@
---
name: 冲突检测技能
description: 分析解析后的文档,检测图表类图像与其相应文本描述之间的矛盾和条件不匹配。
---
# 冲突检测技能
## 概述
此技能识别解析文档中文本内容与视觉内容之间的潜在冲突。它特别针对图表类图像(流程图、架构图、状态图、序列图和活动图)并交叉检查其描述与同文档部分的文本内容。
## 功能
该技能:
- 从解析的文档结构中识别图表类图像
- 将图像描述与同一文档部分中的相应文本内容进行交叉引用
- 检测视觉表示和文本表示之间的矛盾和条件不匹配
- 生成包含其位置的已识别冲突的结构化列表
- 专门针对流程图、架构图、状态图、序列图和活动图
## 输入要求
- 解析文档JSON文件的路径(由文档解析技能生成)
- 可选输出目录规范
- 可选试运行标志,在不调用API的情况下预览大语言模型提示
## 输出
该技能生成一个结构化JSON文件,文件名为输入文档的基本名称后跟'_conflicts.json',包含:
- 带有关于差异详情的冲突对象列表
- 标识每个冲突发生位置的节标识符
- 冲突图像和文本内容的片段
- 每个冲突的类型分类(例如,矛盾、条件不匹配)
## 集成点
此技能消耗文档解析技能的输出并为解决方案应用技能提供输入。冲突解决过程通常需要人工输入才能进入下一阶段。
@@ -0,0 +1,105 @@
import logging
import os
import time
from typing import Optional
from openai import OpenAI
logger = logging.getLogger(__name__)
class LLMClient:
"""Low-level OpenAI-compatible LLM client with retry and token tracking.
Usage::
llm = LLMClient()
content = llm.chat("qwen3.5-flash", [{"role": "user", "content": "Hello"}])
print(llm.usage)
"""
IMAGE_MODEL = "qwen3-vl-plus"
TEXT_MODEL = "qwen3.5-flash-2026-02-23"
TIMEOUT = 120
MAX_RETRIES = 3
def __init__(
self,
*,
base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1",
timeout: int | None = None,
):
key = os.environ.get("DASHSCOPE_API_KEY", "")
if not key:
raise ValueError("DASHSCOPE_API_KEY environment variable is not set.")
self._client = OpenAI(api_key=key, base_url=base_url)
self._timeout = timeout or self.TIMEOUT
self._prompt_tokens = 0
self._completion_tokens = 0
@property
def usage(self) -> dict:
"""Return accumulated token counts as ``{prompt, completion, total}``."""
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:
"""Quick token estimate. CJK ≈1.7/token, others ≈3.0/token."""
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:
"""Fixed estimate for one vision-model image (~500 tokens)."""
return 500
def chat(
self, model: str, messages: list[dict], *, timeout: int | None = None,
response_format: dict | None = None,
) -> str:
"""Send a chat completion request and return the response content.
Automatically retries on failure and accumulates token usage.
"""
label = f"chat({model})"
def _call():
t0 = time.time()
kwargs = dict(model=model, messages=messages, timeout=timeout or self._timeout)
if response_format is not None:
kwargs["response_format"] = response_format
kwargs["temperature"] = 0
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("%s: %d chars in %.1fs", label, len(content) if content else 0, elapsed)
if not content:
raise RuntimeError("Empty response from LLM")
return content
return self._retry(_call, label)
def _retry(self, fn, label: str) -> str:
"""Call *fn()* with exponential-backoff retry."""
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,280 @@
#!/usr/bin/env python3
"""Detect logical conflicts between image analysis and text in ``_parsed.json``.
Usage::
python scripts/detect_conflicts.py D:/projects/jike/output/车机娱乐系统禁止功能文档_精简_parsed.json [--output-dir DIR]
For each diagram-type image (flowchart, architecture, state, sequence, activity),
the script locates its section via *image_sources*, grabs the corresponding text
blocks, and calls an LLM to find contradictions/condition-mismatches between the
image description and the text.
Output: ``<basename>_conflicts.json``
"""
import argparse
import json
import logging
import os
import re
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from LLM import LLMClient
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
RATE_LIMIT_DELAY = 0.5
DIAGRAM_TYPES = {"flowchart", "architecture", "state", "sequence", "activity"}
MIN_TEXT_CHARS = 20
PROMPT_DETECT_CONFLICT = """你是一个文档一致性检查专家。以下内容来自同一份需求文档的同一个章节,包含两部分:
## 部分1:图片(流程图/架构图/状态图)的描述
```
{image_description}
```
## 部分2:同章节的文字描述
```
{text_description}
```
## 你的任务
检查这两部分之间是否存在**逻辑矛盾或条件不一致**。
你需要关注的冲突类型:
1. **condition_mismatch**(条件不一致):两者描述了同一规则,但触发条件、阈值、时序不同。
例如:图片说"车速≥15km/h且持续5秒",文字说"车速≥10km/h且持续3秒"
例如:图片说"非P档限制",文字说"车速>0限制"
2. **contradiction**(直接矛盾):两者对同一事物的描述完全相反。
例如:图片说"功能X被禁止",文字说"功能X可用"
例如:图片说"开关默认关闭",文字说"开关默认开启"
3. **scope_mismatch**(范围不一致):两者描述的场景/地域/设备范围不同。
例如:图片说"国内方案",文字说"海外方案"
例如:图片说"CSD中控屏",文字描述包含"PSD副驾屏"
## 输出格式
如果**没有冲突**,只输出:
```
[[NO_CONFLICT]]
```
如果**有冲突**,输出以下JSON数组(不要任何其他文字):
```json
[
{{
"conflict_type": "condition_mismatch",
"severity": "high",
"section": "{section_name}",
"image_snippet": "图片中描述的关键内容(摘录)",
"text_snippet": "文字中描述的关键内容(摘录)",
"description": "用中文说明冲突的具体差异"
}}
]
```
注意:
- 每个冲突一个条目,不要合并
- severity: high(功能正确性受影响)| medium(边界条件模糊)| low(表达方式差异)
- 输出必须是**严格合法的JSON数组**,不要有尾随逗号
- 如果没有严格冲突,输出 [[NO_CONFLICT]]
"""
def _build_text_for_section(sections: list[dict], section_name: str) -> str:
"""Build a single text block for the given section name."""
texts: list[str] = []
for sec in sections:
if sec.get("source", "") == section_name:
for blk in sec.get("blocks", []):
if blk["type"] == "para":
texts.append(blk["text"])
elif blk["type"] == "table":
table_lines = [f"表格 {blk['table']}:"]
for ri, row in enumerate(blk.get("rows", [])):
cols = row.get("columns", [])
parts = [f"{c['name']}: {c['text']}" for c in cols]
table_lines.append(f"{ri + 1}: {' | '.join(parts)}")
texts.append("\n".join(table_lines))
return "\n\n".join(texts)
def _parse_conflict_json(content: str) -> list[dict]:
"""Extract JSON array from LLM response, handling markdown fences."""
stripped = content.strip()
if "[[NO_CONFLICT]]" in stripped:
return []
# Remove markdown code fences
if "```json" in stripped:
stripped = stripped.split("```json", 1)[1]
if "```" in stripped:
stripped = stripped.split("```", 1)[0]
elif "```" in stripped:
stripped = stripped.split("```", 1)[1]
if "```" in stripped:
stripped = stripped.split("```", 1)[0]
stripped = stripped.strip()
if not stripped:
return []
# Try to find a JSON array
match = re.search(r"\[\s*\{.*\}\s*\]", stripped, re.DOTALL)
if match:
stripped = match.group()
try:
conflicts = json.loads(stripped)
if isinstance(conflicts, list):
return conflicts
return []
except json.JSONDecodeError as e:
logger.warning("Failed to parse conflict JSON: %s", e)
logger.debug("Raw content: %s", stripped)
return []
def detect_conflicts(
parsed_path: str,
output_dir: str | None = None,
*,
dry_run: bool = False,
) -> list[dict]:
"""Load ``_parsed.json`` and detect image-vs-text conflicts.
Returns a flat list of conflict dicts and writes to ``<basename>_conflicts.json``.
"""
with open(parsed_path, "r", encoding="utf-8") as f:
data = json.load(f)
basename = os.path.splitext(os.path.basename(parsed_path))[0]
if basename.endswith("_parsed"):
basename = basename[:-7]
if output_dir is None:
output_dir = os.path.dirname(os.path.abspath(parsed_path))
os.makedirs(output_dir, exist_ok=True)
sections = data.get("sections", [])
image_sources = data.get("image_sources", {})
image_analysis = data.get("image_analysis", [])
llm = LLMClient()
all_conflicts: list[dict] = []
# ---- For each diagram image, compare with its section text -------------
for img in image_analysis:
img_type = img.get("type", "other")
rid = img.get("rid", "")
description = img.get("description", "").strip()
if img_type not in DIAGRAM_TYPES or not description:
logger.info("Skip conflict check: rid=%s type=%s", rid, img_type)
continue
# Find source section
src = image_sources.get(rid, {})
section_name = src.get("section", "")
if not section_name:
logger.warning("No section found for rid=%s, skipping", rid)
continue
# Build text from the same section
text_content = _build_text_for_section(sections, section_name)
text_len = len(text_content.strip())
if text_len < MIN_TEXT_CHARS:
logger.info("Section text too short (%d chars) for rid=%s, skip", text_len, rid)
continue
logger.info("Checking conflicts: rid=%s section=%s (desc=%d chars, text=%d chars)",
rid, section_name, len(description), text_len)
if dry_run:
logger.info(" [DRY RUN] would call LLM to detect conflicts")
continue
prompt = PROMPT_DETECT_CONFLICT.format(
image_description=description,
text_description=text_content,
section_name=section_name,
)
try:
raw = llm.chat(
model=LLMClient.TEXT_MODEL,
messages=[{"role": "user", "content": prompt}],
)
logger.info("Conflict check response: %d chars", len(raw))
except RuntimeError as e:
logger.error("Conflict check failed: %s", e)
continue
conflicts = _parse_conflict_json(raw)
# Enrich with location info
for c in conflicts:
c["rid"] = rid
c["image_path"] = img.get("path", "")
if "section" not in c:
c["section"] = section_name
if src.get("table"):
c.setdefault("source_location", {})["table"] = src["table"]
if src.get("row"):
c.setdefault("source_location", {})["image_row"] = src["row"]
all_conflicts.extend(conflicts)
logger.info(" Found %d conflicts for rid=%s", len(conflicts), rid)
if any(x.get("type") in DIAGRAM_TYPES
for x in image_analysis
if x.get("rid", "") != rid):
time.sleep(RATE_LIMIT_DELAY)
# ---- Save ---------------------------------------------------------------
conflicts_path = os.path.join(output_dir, f"{basename}_conflicts.json")
with open(conflicts_path, "w", encoding="utf-8") as f:
json.dump(all_conflicts, f, ensure_ascii=False, indent=2)
logger.info("Saved: %s (%d conflicts)", conflicts_path, len(all_conflicts))
# ---- Summary ------------------------------------------------------------
usg = llm.usage
logger.info("Tokens: %d prompt + %d completion = %d total",
usg["prompt_tokens"], usg["completion_tokens"], usg["total_tokens"])
return all_conflicts
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Detect image-vs-text conflicts in parsed document.",
)
parser.add_argument("input", metavar="parsed.json", help="Path to _parsed.json from doc_parser")
parser.add_argument("--output-dir", metavar="DIR", default=None,
help="Output directory (default: same as input)")
parser.add_argument("--dry-run", action="store_true",
help="Print LLM prompts without calling the API.")
args = parser.parse_args()
detect_conflicts(args.input, output_dir=args.output_dir, dry_run=args.dry_run)
@@ -0,0 +1,36 @@
---
name: 文档解析技能
description: 解析文档(.docx, .pdf)以提取图像和文本结构,并使用视觉大语言模型分析每个图像的类型和描述。
---
# 文档解析技能
## 概述
此技能从文档(.docx, .pdf)中提取内容并准备进行进一步分析。它提取文本内容和嵌入图像,并对图像执行初始分析以了解其类型和内容。
## 功能
该技能:
- 从文档中提取文本结构(段落、表格、标题)
- 识别并提取嵌入的图像
- 使用视觉大语言模型分析每个图像并确定其类型和内容描述
- 生成结构化输出,将图像映射到其在文档中的位置
- 创建文档的初始解析表示,供后续处理阶段使用
## 输入要求
- 文档文件路径(必需,支持.docx和.pdf格式)
- 可选输出目录(默认为'output/'
- 可选试运行标志,在不调用API的情况下预览大语言模型提示
## 输出
该技能生成一个结构化JSON文件,文件名为输入文档的基本名称后跟'_parsed.json',包含:
- `sections`:按标题分组的文档文本结构
- `image_sources`:从图像标识符到其在文档中位置的映射
- `image_analysis`:由视觉大语言模型确定的每个图像的类型和内容描述
## 集成点
此技能作为文档分析管道中的初始处理步骤。其输出被冲突检测技能消费以识别文本和视觉内容之间的差异。
@@ -0,0 +1,105 @@
import logging
import os
import time
from typing import Optional
from openai import OpenAI
logger = logging.getLogger(__name__)
class LLMClient:
"""Low-level OpenAI-compatible LLM client with retry and token tracking.
Usage::
llm = LLMClient()
content = llm.chat("qwen3.5-flash", [{"role": "user", "content": "Hello"}])
print(llm.usage)
"""
IMAGE_MODEL = "qwen3-vl-plus"
TEXT_MODEL = "qwen3.5-flash-2026-02-23"
TIMEOUT = 120
MAX_RETRIES = 3
def __init__(
self,
*,
base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1",
timeout: int | None = None,
):
key = os.environ.get("DASHSCOPE_API_KEY", "")
if not key:
raise ValueError("DASHSCOPE_API_KEY environment variable is not set.")
self._client = OpenAI(api_key=key, base_url=base_url)
self._timeout = timeout or self.TIMEOUT
self._prompt_tokens = 0
self._completion_tokens = 0
@property
def usage(self) -> dict:
"""Return accumulated token counts as ``{prompt, completion, total}``."""
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:
"""Quick token estimate. CJK ≈1.7/token, others ≈3.0/token."""
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:
"""Fixed estimate for one vision-model image (~500 tokens)."""
return 500
def chat(
self, model: str, messages: list[dict], *, timeout: int | None = None,
response_format: dict | None = None,
) -> str:
"""Send a chat completion request and return the response content.
Automatically retries on failure and accumulates token usage.
"""
label = f"chat({model})"
def _call():
t0 = time.time()
kwargs = dict(model=model, messages=messages, timeout=timeout or self._timeout)
if response_format is not None:
kwargs["response_format"] = response_format
kwargs["temperature"] = 0
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("%s: %d chars in %.1fs", label, len(content) if content else 0, elapsed)
if not content:
raise RuntimeError("Empty response from LLM")
return content
return self._retry(_call, label)
def _retry(self, fn, label: str) -> str:
"""Call *fn()* with exponential-backoff retry."""
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,106 @@
import argparse
import json
import logging
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from image_parser import ImageParser
from LLM import LLMClient
from word_parser import WordParser
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
RATE_LIMIT_DELAY = 0.5
def parse_document(
docx_path: str,
output_dir: str = "output",
*,
dry_run: bool = False,
) -> dict:
"""Parse a .docx file: extract text structure and parse embedded images.
Produces ``<basename>_parsed.json`` in *output_dir*.
"""
word = WordParser(docx_path)
basename = os.path.splitext(os.path.basename(docx_path))[0]
os.makedirs(output_dir, exist_ok=True)
images_dir = os.path.join(output_dir, "images")
# ---- extract sections and images -----------------------------------------
sections, image_sources = word.extract_sections()
logger.info("Document has %d sections, %d image sources", len(sections), len(image_sources))
# ---- parse images ----------------------------------------------------------
images = word.extract_images(images_dir)
logger.info("Found %d images in document", len(images))
image_analysis: list[dict] = []
if images:
llm = ImageParser()
for i, img in enumerate(images):
logger.info("[image %d/%d] rid=%s", i + 1, len(images), img["rid"])
if dry_run:
est = LLMClient.estimate_image_tokens()
logger.info(" [DRY RUN] would call vision LLM (~%d tokens)", est)
result = {"type": "other", "description": "[DRY RUN]"}
else:
result = llm.parse_image(img["path"])
if result is None:
result = {"type": "other", "description": ""}
result["rid"] = img["rid"]
result["path"] = img["path"]
image_analysis.append(result)
if i < len(images) - 1:
time.sleep(RATE_LIMIT_DELAY)
usg = llm.usage
logger.info("Tokens: %d prompt + %d completion = %d total",
usg["prompt_tokens"], usg["completion_tokens"], usg["total_tokens"])
else:
logger.info("No images found in document")
# ---- build output --------------------------------------------------------
output = {
"source": os.path.abspath(docx_path),
"sections": sections,
"image_sources": image_sources,
"image_analysis": image_analysis,
}
parsed_path = os.path.join(output_dir, f"{basename}_parsed.json")
with open(parsed_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
logger.info("Saved: %s", parsed_path)
return output
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Parse a .docx file: extract text structure and parse images.",
)
parser.add_argument("input", metavar="input.docx", help="Path to the Word document")
parser.add_argument("output_dir", nargs="?", default="output", metavar="output_dir",
help="Directory for output files (default: output/)")
parser.add_argument("--dry-run", action="store_true",
help="Print LLM prompts without calling the API.")
args = parser.parse_args()
parse_document(args.input, args.output_dir, dry_run=args.dry_run)
@@ -0,0 +1,123 @@
import base64
import logging
import os
from typing import Optional
from LLM import LLMClient
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Prompts
# ---------------------------------------------------------------------------
PROMPT_IMAGE = """请分析这张图片,判断类型并输出文字描述。
## 判断图片类型
如果是 **流程图 / 架构图 / 状态图 / 时序图 / 活动图**,详细描述:
- 图中所有节点/步骤/状态/组件的名称
- 所有连线/箭头/转换关系及其方向
- 所有分支条件、判断逻辑和判断结果
- 所有文字标注、注释、标签
- 图的整体结构和逻辑流程
- 如果图片包含多个子图,拆解描述
如果是 **其他类型**(UI原型图 / 界面截图 / 设计稿 / 手机屏幕截图 / 网页截图等),简要描述图片内容。
## 输出格式
**1. 类型标签(单独一行):**
type: <flowchart|architecture|state|sequence|activity|other>
**2. 文字描述:**
该图片的详细文字描述。
不要输出 ---YAML--- 分隔符或 YAML 内容,不要添加任何额外的解释或问候语。"""
# ---------------------------------------------------------------------------
# ImageParser
# ---------------------------------------------------------------------------
class ImageParser:
"""Vision LLM wrapper for parsing images (type + description).
Usage::
parser = ImageParser()
result = parser.parse_image("images/img1.png")
"""
_VALID_TYPES = {"flowchart", "architecture", "state", "sequence", "activity", "text"}
def __init__(self, llm: LLMClient | None = None):
self._llm = llm or LLMClient()
@property
def usage(self) -> dict:
return self._llm.usage
def parse_image(self, image_path: str) -> Optional[dict]:
"""Parse an image and return its type and description (no YAML IR).
Returns ``{type, description}``, or *None* for UI mockups.
"""
logger.info("Parsing image: %s", image_path)
with open(image_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
mime = self._mime_type(image_path)
try:
content = self._llm.chat(
model=LLMClient.IMAGE_MODEL,
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{img_b64}"}},
{"type": "text", "text": PROMPT_IMAGE},
],
}],
)
except RuntimeError as e:
logger.error(str(e))
return {"type": "other", "description": "", "error": str(e)}
parsed = self._parse_type_and_description(content)
if parsed is None:
return None
return {"type": parsed[0], "description": parsed[1]}
# ---- internals ----------------------------------------------------------
def _parse_type_and_description(self, content: str) -> Optional[tuple[str, str]]:
"""Extract ``(type, description)`` from LLM response.
Returns *None* for ``[[UI]]`` (skip).
"""
content = content.strip()
if content == "[[UI]]" or content.startswith("[[UI]]"):
return None
parsed_type = "other"
desc_lines: list[str] = []
for line in content.splitlines():
stripped = line.strip()
if (stripped.startswith("type:") or stripped.startswith("类型:")) and parsed_type == "other":
type_val = stripped.split(":", 1)[1].strip().lower()
if type_val in self._VALID_TYPES:
parsed_type = type_val
else:
desc_lines.append(line)
return parsed_type, "\n".join(desc_lines).strip()
@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")
@@ -0,0 +1,239 @@
import logging
import os
from docx import Document
from docx.table import Table
from docx.text.paragraph import Paragraph
logger = logging.getLogger(__name__)
IMAGE_EXT = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/bmp": ".bmp",
"image/tiff": ".tiff",
"image/webp": ".webp",
"image/x-emf": ".emf",
"image/x-wmf": ".wmf",
"image/svg+xml": ".svg",
}
class WordParser:
"""Parse a .docx file — extract images, split body into sections.
Usage::
parser = WordParser("doc.docx")
parser.extract_images("images/")
sections, image_sources = parser.extract_sections()
"""
HEADER_CELL_MAX_LEN = 20 # max chars per cell to treat first row as header
WML_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
DRAW_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
def __init__(self, docx_path: str):
if not os.path.isfile(docx_path):
raise FileNotFoundError(f"Document not found: {docx_path}")
self._doc = Document(docx_path)
# ---- public API ---------------------------------------------------------
def extract_images(self, images_dir: str) -> list[dict]:
"""Save all images to *images_dir*. Returns ``[{rid, path}, ...]``."""
os.makedirs(images_dir, exist_ok=True)
images: list[dict] = []
for rel in self._doc.part.rels.values():
if "image" not in rel.reltype:
continue
ext = IMAGE_EXT.get(rel.target_part.content_type, ".png")
name = f"image_{rel.rId}{ext}"
path = os.path.join(images_dir, name)
with open(path, "wb") as f:
f.write(rel.target_part.blob)
images.append({"rid": rel.rId, "path": path})
return images
def extract_sections(self) -> tuple[list[dict], dict[str, dict]]:
"""Walk document body and split into sections by heading.
Returns:
*sections* — ``[{source, blocks, images}, ...]``
Each block is ``{type, index, text}`` (paragraph) or
``{type, table, headers, rows}`` (table).
*image_sources* — ``rid → {section, table?, row?, column?, name?}``
"""
sections: list[dict] = []
current_source = ""
blocks: list[dict] = []
section_images: list[str] = []
image_sources: dict[str, dict] = {}
para_idx = 0
tbl_idx = 0
for child in self._doc.element.body:
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
if tag == "p":
para = Paragraph(child, self._doc)
if self._heading_level(para) is not None:
heading_text = para.text.strip()
if heading_text: # ignore empty heading-like paragraphs
if blocks or section_images:
sections.append({
"source": current_source,
"blocks": blocks,
"images": list(section_images),
})
blocks = []
section_images = []
para_idx = 0
tbl_idx = 0
current_source = heading_text
continue
text = para.text.strip()
# Scan for images — append [[IMAGE:rid]] markers
for run in para.runs:
for rid in self._images_in(run._element):
text += f" [[IMAGE:{rid}]]"
section_images.append(rid)
image_sources[rid] = {"section": current_source}
if text.strip():
blocks.append({"type": "para", "index": para_idx + 1, "text": text.strip()})
para_idx += 1
elif tag == "tbl":
tbl_idx += 1
table = Table(child, self._doc)
# Collect all rows as [[cell_text, ...], ...]
all_rows: list[list[str]] = []
all_images: list[list[list[str]]] = [] # row → col → [rids]
for row in table.rows:
row_texts: list[str] = []
row_cell_images: list[list[str]] = []
for cell in row.cells:
cell_text = cell.text.strip()
cell_imgs: list[str] = []
for cp in cell.paragraphs:
for run in cp.runs:
for rid in self._images_in(run._element):
cell_imgs.append(rid)
# Replace images with markers in text
for rid in cell_imgs:
cell_text += f" [[IMAGE:{rid}]]"
section_images.append(rid)
row_texts.append(cell_text.strip())
row_cell_images.append(cell_imgs)
if any(row_texts) or any(row_cell_images):
all_rows.append(row_texts)
all_images.append(row_cell_images)
if len(all_rows) >= 2:
# Heuristic: first row is a header if every cell is short
first_row = all_rows[0]
has_header = all(len(c) < self.HEADER_CELL_MAX_LEN for c in first_row)
if has_header:
headers = first_row
data_rows_slice = zip(all_rows[1:], all_images[1:])
else:
headers = [f"{ci + 1}" for ci in range(len(first_row))]
data_rows_slice = zip(all_rows, all_images)
data_rows: list[dict] = []
for ri, (row_data, row_imgs) in enumerate(data_rows_slice):
columns: list[dict] = []
max_cols = max(len(headers), len(row_data))
for ci in range(max_cols):
hdr = headers[ci] if ci < len(headers) else ""
txt = row_data[ci] if ci < len(row_data) else ""
columns.append({
"name": hdr,
"row": ri + 1,
"col": ci + 1,
"text": txt,
})
# Register image sources with structured location
imgs = row_imgs[ci] if ci < len(row_imgs) else []
for rid in imgs:
image_sources[rid] = {
"section": current_source,
"table": tbl_idx,
"row": ri + 1,
"column": ci + 1,
"name": hdr,
}
data_rows.append({"columns": columns})
blocks.append({
"type": "table",
"table": tbl_idx,
"headers": headers,
"rows": data_rows,
})
elif all_rows:
# Degenerate table (only header or single row) — treat as plain rows
for ri, row_data in enumerate(all_rows):
row_text = " | ".join(row_data)
if row_text.strip():
blocks.append({
"type": "para",
"index": para_idx + 1,
"text": row_text,
})
para_idx += 1
if blocks or section_images:
sections.append({
"source": current_source,
"blocks": blocks,
"images": list(section_images),
})
return sections, image_sources
# ---- internals ----------------------------------------------------------
def _heading_level(self, para: Paragraph) -> int | None:
"""Heading level 1-9, or *None* if not a heading."""
if para.style and para.style.name:
name = para.style.name
for prefix in ("Heading", "标题"):
if name.startswith(prefix):
try:
return int(name.split()[-1])
except (ValueError, IndexError):
pass
pPr = para._element.find(f"{{{self.WML_NS}}}pPr")
if pPr is not None:
ol = pPr.find(f"{{{self.WML_NS}}}outlineLvl")
if ol is not None:
val = ol.get(f"{{{self.WML_NS}}}val")
if val is not None:
try:
return int(val) + 1
except ValueError:
pass
return None
def _images_in(self, element) -> list[str]:
"""Return rId values for drawings embedded in *element*."""
rids: list[str] = []
for drawing in element.findall(f".//{{{self.WML_NS}}}drawing"):
blip = drawing.find(f".//{{{self.DRAW_NS}}}blip")
if blip is not None:
rid = blip.get(f"{{{self.REL_NS}}}embed")
if rid:
rids.append(rid)
return rids
@@ -0,0 +1,34 @@
---
name: ir_generation_skill
description: 通过自然语言描述,向JSON文件中添加以IR表示的功能点。
---
# add_ir
## 概述
通过自然语言描述由大语言模型自动转换为标准IR格式并写入到JSON文件或追加到JSON文件中。
## 用法
- **命令行传入**:直接传入功能的文字描述
- **文件传入**:通过 `-f` 参数从文本文件读取描述
- **追加模式**:新生成的功能点追加到已有 `_ir.json` 末尾,不影响已有条目
## 输入要求
- 功能的自然语言描述(命令行参数或 `-f` 文件)
- 目标IR JSON文件路径(`--ir`,默认 `output/ir.json`
## 输出
在目标IR JSON文件中追加新的功能点条目,每个条目包含:
- `function`:功能名称
- `source.section`:所属章节
- `source.location`:位置描述
- `trigger`:触发条件(可选)
- `actions`:执行动作(可选)
## 使用提示
描述中应尽量包含功能的触发条件、执行动作和所属章节信息,以提高转换质量。
@@ -0,0 +1,105 @@
import logging
import os
import time
from typing import Optional
from openai import OpenAI
logger = logging.getLogger(__name__)
class LLMClient:
"""Low-level OpenAI-compatible LLM client with retry and token tracking.
Usage::
llm = LLMClient()
content = llm.chat("qwen3.5-flash", [{"role": "user", "content": "Hello"}])
print(llm.usage)
"""
IMAGE_MODEL = "qwen3-vl-plus"
TEXT_MODEL = "qwen3.5-flash-2026-02-23"
TIMEOUT = 120
MAX_RETRIES = 3
def __init__(
self,
*,
base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1",
timeout: int | None = None,
):
key = os.environ.get("DASHSCOPE_API_KEY", "")
if not key:
raise ValueError("DASHSCOPE_API_KEY environment variable is not set.")
self._client = OpenAI(api_key=key, base_url=base_url)
self._timeout = timeout or self.TIMEOUT
self._prompt_tokens = 0
self._completion_tokens = 0
@property
def usage(self) -> dict:
"""Return accumulated token counts as ``{prompt, completion, total}``."""
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:
"""Quick token estimate. CJK ≈1.7/token, others ≈3.0/token."""
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:
"""Fixed estimate for one vision-model image (~500 tokens)."""
return 500
def chat(
self, model: str, messages: list[dict], *, timeout: int | None = None,
response_format: dict | None = None,
) -> str:
"""Send a chat completion request and return the response content.
Automatically retries on failure and accumulates token usage.
"""
label = f"chat({model})"
def _call():
t0 = time.time()
kwargs = dict(model=model, messages=messages, timeout=timeout or self._timeout)
if response_format is not None:
kwargs["response_format"] = response_format
kwargs["temperature"] = 0
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("%s: %d chars in %.1fs", label, len(content) if content else 0, elapsed)
if not content:
raise RuntimeError("Empty response from LLM")
return content
return self._retry(_call, label)
def _retry(self, fn, label: str) -> str:
"""Call *fn()* with exponential-backoff retry."""
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,191 @@
#!/usr/bin/env python3
"""Add missing features to an existing ``_ir.json``.
Converts a natural-language description of missing features into structured
IR entries via LLM, then appends them to the IR file.
Usage::
python scripts/add_missing.py "遗漏功能描述" --ir output/<basename>_ir.json
python scripts/add_missing.py -f missing.txt --ir output/<basename>_ir.json
"""
import argparse
import json
import logging
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from LLM import LLMClient
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Prompt
# ---------------------------------------------------------------------------
PROMPT = """你是一个需求文档分析助手。用户发现已生成的功能点列表中有遗漏,请将以下描述中提到的遗漏功能点转换为标准JSON格式。
## 遗漏功能描述
{description}
## JSON格式要求
每个功能点输出为:
{
"function": "功能名称",
"source": {
"section": "章节名(如果描述中提到了章节信息则填写,否则填"未知"",
"location": "原文位置描述(如果描述中提供了则填写,否则填"用户补充""
},
"trigger": {
"type": "AND或者OR",
"conditions": [
"触发条件1",
"触发条件2"
]
},
"actions": {
"场景/角色": [
"动作1",
"动作2"
]
}
}
## 输出原则
1. 将描述中的每个独立功能点拆分为一个独立的JSON对象
2. 如果描述中包含多个功能点,输出一个JSON数组
3. 没有 trigger 或 actions 的字段直接**省略**,不要写 null 或空列表/空对象
4. 尽量从描述中推断 source.section 和 source.location
5. 功能名称应简洁明确,概括该功能的核心行为
6. 直接输出纯JSON,不要用 ```json 代码块包裹
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_llm_response(raw: str) -> list | dict | str | None:
"""Parse JSON from LLM response, handling markdown code fences."""
if raw is None:
return None
stripped = raw.strip()
if stripped.startswith("```"):
nl = stripped.find("\n")
stripped = stripped[nl + 1:] if nl != -1 else stripped[3:]
if stripped.endswith("```"):
stripped = stripped[:-3]
try:
return json.loads(stripped)
except json.JSONDecodeError:
logger.warning("Failed to parse JSON, returning raw text")
return raw
# ---------------------------------------------------------------------------
# Core
# ---------------------------------------------------------------------------
def add_missing(description: str, ir_path: str) -> list[dict]:
"""Convert missing feature description to IR entries and append to IR file.
Returns the newly added entries.
"""
llm = LLMClient()
prompt = PROMPT.replace("{description}", description)
logger.info("Sending missing features to LLM...")
try:
raw = llm.chat(
model=LLMClient.TEXT_MODEL,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
)
logger.info("Response: %d chars", len(raw))
except RuntimeError as e:
logger.error("LLM call failed: %s", e)
sys.exit(1)
parsed = _parse_llm_response(raw)
if isinstance(parsed, list):
new_entries = parsed
elif isinstance(parsed, dict):
new_entries = [parsed]
else:
logger.error("Unparseable response")
sys.exit(1)
# Load existing IR or start fresh
if os.path.exists(ir_path):
with open(ir_path, "r", encoding="utf-8") as f:
existing = json.load(f)
logger.info("Loaded existing IR: %d entries", len(existing))
else:
existing = []
logger.info("No existing IR file, creating new")
existing.extend(new_entries)
# Write back
os.makedirs(os.path.dirname(ir_path) or ".", exist_ok=True)
with open(ir_path, "w", encoding="utf-8") as f:
json.dump(existing, f, ensure_ascii=False, indent=2)
logger.info("Saved: %s (%d entries, +%d new)", ir_path, len(existing), len(new_entries))
usg = llm.usage
logger.info("Tokens: %d prompt + %d completion = %d total",
usg["prompt_tokens"], usg["completion_tokens"], usg["total_tokens"])
return new_entries
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Add missing features to an IR JSON file via LLM.",
)
parser.add_argument(
"description", nargs="?", metavar="description",
help="Natural-language description of missing features",
)
parser.add_argument(
"-f", "--file", metavar="file.txt",
help="Read description from a file instead of command line",
)
parser.add_argument(
"--ir", default="output/ir.json", metavar="ir.json",
help="Path to the IR JSON file (default: output/ir.json)",
)
args = parser.parse_args()
if args.file:
with open(args.file, "r", encoding="utf-8") as f:
description = f.read().strip()
elif args.description:
description = args.description
else:
parser.error("Either provide a description string or use -f to read from a file")
if not description:
parser.error("Description is empty")
add_missing(description, args.ir)
@@ -0,0 +1,42 @@
---
name: 解决方案应用技能
description: 应用用户提供的冲突解决方案以创建合并了更正的更新文档表示。
---
# 解决方案应用技能
## 概述
此技能采用用户提供的冲突解决方案并将它们应用到解析的文档中,创建一个协调文本和视觉内容差异的校正版本。它生成包含应用更正的更新文档表示。
## 功能
该技能:
- 接受用户提供的冲突解决方案决策
- 支持多种解决方案类型:"以图像为准"、"以文字为准"、"两处都保留"或自定义文本
- 更新解析的文档结构以合并解决方案决策
- 创建文档表示的校正版本,包含应用的更改
- 维护所有应用更正的源可追溯性
- 向输出添加包含更正指令的resolved_conflicts数组
## 输入要求
- 解析文档JSON文件的路径(带有已识别的冲突)
- 包含用户决策的解决方案JSON文件的路径
- 可选输出目录规范
- 解决方案JSON应包含具有以下内容的对象:
- `conflict_id`:冲突数组中的冲突索引
- `resolution`:决策类型("以图片为准"、"以文字为准"、"两处都保留")或自定义文本
- `custom_text`:解决方案的可选自定义文本
## 输出
该技能生成一个结构化JSON文件,文件名为输入文档的基本名称后跟'_updated.json',包含:
- 包含应用更正的原始文档结构
- 详细说明应用更改的`resolved_conflicts`数组
- 关于每个冲突类型和应用更正的信息
- 用于可追溯性的源跟踪信息
## 集成点
此技能消耗冲突检测技能的输出(带冲突的文档)和用户提供的解决方案。其输出被IR生成技能使用以创建最终结构化表示。
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Apply user resolutions to ``_parsed.json`` using ``_conflicts.json``.
Usage::
python scripts/apply_resolutions.py <parsed.json> --resolutions <resolutions.json> [--output-dir DIR]
The *resolutions.json* file is created by the agent after user arbitration.
Each resolution maps a conflict_id to a decision.
Resolution format (``resolutions.json``)::
[
{
"conflict_id": 0, // 0-based index into conflicts array
"resolution": "以文字为准",
"custom_text": null
}
]
Outputs ``<basename>_updated.json`` — identical to *parsed.json* plus a
``resolved_conflicts`` top-level array with correction instructions for the IR generator.
"""
import argparse
import json
import logging
import os
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
def apply_resolutions(
parsed_path: str,
resolutions_path: str,
output_dir: str | None = None,
) -> dict:
"""Load *parsed.json*, apply resolutions, write *updated.json*."""
with open(parsed_path, "r", encoding="utf-8") as f:
data = json.load(f)
with open(resolutions_path, "r", encoding="utf-8") as f:
resolutions = json.load(f)
base_dir = os.path.dirname(os.path.abspath(parsed_path))
# Try to find _conflicts.json alongside parsed.json
basename = os.path.splitext(os.path.basename(parsed_path))[0]
stem = basename[:-7] if basename.endswith("_parsed") else basename
candidate = os.path.join(base_dir, f"{stem}_conflicts.json")
conflicts = data.get("_conflicts", [])
if not conflicts and os.path.isfile(candidate):
with open(candidate, "r", encoding="utf-8") as f:
conflicts = json.load(f)
if output_dir is None:
output_dir = base_dir
os.makedirs(output_dir, exist_ok=True)
# Build resolved_conflicts with correction instructions for ir_generator
resolved = []
for res in resolutions:
cid = res.get("conflict_id")
if cid is None or cid < 0 or cid >= len(conflicts):
logger.warning("Invalid conflict_id: %s", cid)
continue
conflict = conflicts[cid]
choice = res.get("resolution", "")
custom = res.get("custom_text")
entry = {
"conflict_id": cid,
"conflict_type": conflict.get("conflict_type"),
"section": conflict.get("section", ""),
"resolution": choice,
}
# Build a correction instruction string
image_val = conflict.get("image_snippet", "")
text_val = conflict.get("text_snippet", "")
if choice == "以图片为准":
entry["correction"] = image_val
entry["source"] = "图片"
elif choice == "以文字为准":
entry["correction"] = text_val
entry["source"] = "文字"
elif choice == "两处都保留":
entry["correction"] = f"{text_val}(另外的观点:{image_val}"
entry["source"] = "两者兼容"
elif custom:
entry["correction"] = custom
entry["source"] = "自定义"
logger.info("Conflict %d: custom: %s", cid, custom[:60])
else:
entry["correction"] = text_val
entry["source"] = "文字(默认)"
logger.warning("Conflict %d: unknown resolution '%s', defaulting to text", cid, choice)
logger.info("Conflict %d: %s%s", cid, choice, entry["source"])
resolved.append(entry)
data["resolved_conflicts"] = resolved
logger.info("Applied %d resolutions", len(resolved))
# Write output
if basename.endswith("_parsed"):
out_name = f"{stem}_updated.json"
else:
out_name = f"{basename}_updated.json"
output_path = os.path.join(output_dir, out_name)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info("Saved: %s", output_path)
return data
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Apply user resolutions to parsed.json.",
)
parser.add_argument("input", metavar="parsed.json",
help="Path to _parsed.json")
parser.add_argument("--resolutions", "-r", required=True,
help="Path to resolutions JSON file")
parser.add_argument("--output-dir", default=None,
help="Output directory (default: same as input)")
args = parser.parse_args()
apply_resolutions(args.input, args.resolutions, args.output_dir)
+207
View File
@@ -0,0 +1,207 @@
# 任务:重写 IR Generation,实现高可靠性、可测试的中间表示提取
## 一、背景与目标
我们正在为吉利汽车车机测试团队验证 AI 自动生成测试用例的可行性。输入是产品需求文档(PRD),输出是 Robot Framework 测试脚本。
当前流程中,文档解析(doc_parser)已稳定,能将 Word 文档转为结构化 JSON,并将流程图图片转为精确的 `logic_tree` JSON。但直接基于这份 JSON 生成 IR(中间表示)时,LLM 经常丢失分支,稳定性差。
现在需要重新实现 IR 生成,采用“分阶段理解 + 程序化校验”架构,彻底解决分支丢失问题。
**核心原则**:LLM 负责“关联理解”和“受限翻译”,程序负责“确定性校验”和“完整性审计”。
## 二、已有资产与路径
- **输入文件**`C:\Users\peterz\.openclaw\workspace\skills\doc_parser_skill\output\车机娱乐系统禁止功能文档_脱敏 v0.9_v2_updated.json`
- 结构:包含 `sections` 数组和 `image_analysis` 数组。
- `sections` 每个元素有 `source`(章节标题)、`blocks``para``table`)、`images`(图片引用 ID 列表)。
- `image_analysis` 中,流程图类型的图片包含 `logic_tree`,是一个有根、有节点、有分支的树形结构,分支准确。
- **工作目录**`C:\Users\peterz\.openclaw\workspace\skills\ir_generation_new_skill`
- 请在此目录下创建 Python 脚本、Prompt 模板、测试等。
- **最终输出文件**:放在 `C:\Users\peterz\.openclaw\workspace\skills\doc_parser_skill\output\`
- `ir_final.json`:最终的 IR JSON。
- `ir_audit_report.md`:完整性审计报告,供人工快速审查。
## 三、IR Schema 定义
最终 IR 是一组**可测试的功能规则**,每条规则包含触发条件、动作、来源锚点,足以让规则引擎自动生成覆盖正常/边界/异常的 Robot Framework 测试用例。
**目标 Schema(示例)**
```json
{
"feature": "行车娱乐限制",
"feature_id": "DRL-001",
"rules": [
{
"rule_id": "DRL-001-SYS-FG-01",
"description": "开关开启,系统限制应用在前台,车速≥15km/h 且持续超过5秒且非P档时,应用被打断并退至后台,同时弹出特定 Toast",
"priority": "P0",
"sources": [
{"type": "table", "section": "3.1.1", "row": 2},
{"type": "logic_tree", "image_id": "rId16", "node_ids": ["n19","n21","n23","n25","n26"]}
],
"precondition": {
"switch": "开启",
"app_type": "系统限制",
"app_state": "前台"
},
"trigger": {
"operator": "AND",
"conditions": [
{"signal": "车速", "operator": ">=", "value": 15, "unit": "km/h"},
{"signal": "车速_持续时间", "operator": ">", "value": 5, "unit": "秒"},
{"signal": "档位", "operator": "!=", "value": "P"}
]
},
"actions": [
{"type": "system", "description": "打断应用前台进程"},
{"type": "system", "description": "将应用调入后台"},
{"type": "user_interaction", "description": "显示Toast", "content": "在行车状态下无法使用该应用"}
]
}
]
}
注:具体字段可根据实际文档内容灵活扩充,但必须包含 rule_id、description、sources、trigger、actions。
## 四、新实现流程(分三个阶段)
阶段一:宏观语义索引(Semantic Index
目标:让 LLM 一次性阅读整个文档,生成一份语义索引,识别出所有功能单元(function unit)并建立概念映射,但不提取具体规则细节。
输入:完整的 xxx_parsed.json(所有 sections + image_analysis)。
LLM 调用:一次。
任务要求:
编写一个 Python 脚本 step1_semantic_index.py,其功能:
读取输入 JSON 文件。
构造 Prompt,要求 LLM 输出如下结构的 JSON:
json
{
"feature_name": "行车娱乐限制",
"concepts": [
{"name": "行车娱乐限制", "aliases": ["行车娱乐限制", "行车娱乐禁止"], "defined_in": ["3.1", "3.1.1"]},
{"name": "系统限制", "aliases": [], "defined_in": ["3.1", "3.1.1"]},
...
],
"function_units": [
{
"unit_id": "FU-001",
"name": "系统限制-前台-行车打断",
"description": "当开关开启、应用为系统限制类型且处于前台时,满足车速和档位条件后,系统打断应用并显示Toast",
"sources": [
{"section": "3.1.1", "type": "table", "row": 2, "text_snippet": "打断:车速≥15km/h...退至后台"},
{"image_id": "rId16", "logic_tree_nodes": ["n19","n21","n23","n25","n26"]},
{"image_id": "rId17", "logic_tree_nodes": ["n1","n2","n3"]}
]
},
...
]
}
Prompt 中必须强调:功能单元应覆盖文档描述的所有主要行为,特别是图片逻辑树中的决策路径。不允许遗漏分支。
调用 LLM(可以使用 anthropic.Anthropic 或内部 API),将结果保存为 semantic_index.json。
自检测试:编写 test_step1.py,读取 semantic_index.json 并验证:
所有 function_units 的 sources 中引用的 image_id 必须存在于原输入的 image_analysis 中。
每个 function_unit 至少引用一张图片或一段文字。
所有 logic_tree_nodes 引用的节点 ID 必须在对应 logic_tree 的节点 id 中存在。
无空的 unit_id 或 name。
迭代指引:如果测试失败,分析原因,调整 Prompt(增加更严格的结构化输出指令、Few-shot 示例)并重新运行,直到全部通过。
## 阶段二:逐功能单元 IR 提取
目标:对阶段一产出的每个功能单元,准备一个仅含相关上下文的数据包,让 LLM 据此填充详细的 IR 规则。
输入:semantic_index.json 和原始文档 JSON。
LLM 调用:每个功能单元一次,可并行。
任务要求:
编写 step2_ir_extraction.py
加载 semantic_index.json 和原始文档 JSON。
为每个 function_unit 构造一个“精准上下文包”:
从原始文档中提取该单元引用的具体段落、表格行、以及完整的 logic_tree(如果引用了节点,则提取相关子树;简单起见可以提取整棵树)。
上下文包示例结构:
json
{
"unit_id": "FU-001",
"unit_name": "系统限制-前台-行车打断",
"texts": ["打断:车速≥15km/h且持续5秒后,将目标应用/功能退至后台或暂停对应功能..."],
"tables": [{"headers":["功能","功能详细说明"], "rows":[...]}],
"logic_trees": [
{"image_id": "rId16", "tree": {...}},
{"image_id": "rId17", "tree": {...}}
]
}
构造 Prompt,要求 LLM 输出一个或多个符合 IR Schema 的规则 JSON 对象(数组 rules)。
强制要求:
每条规则的 sources 必须包含引用的逻辑树节点 ID 列表和文本来源。
触发条件必须精确,包含运算符和数值(如 >=15),不可模糊。
动作必须明确区分系统行为和用户可见交互。
如果文档中存在矛盾(如图片和文字冲突),请优先采用图片逻辑树,并在 description 中注明差异。
提供 IR Schema 和 1-2 个 Few-shot 示例。
将所有功能单元产出的规则数组合并,保存为 ir_fragments.json,格式:[ { "unit_id": "FU-001", "rules": [...] }, ... ]
自检测试:编写 test_step2.py
验证每个 ir_fragment 的 rules 非空。
验证每条规则的 sources 中有逻辑树节点引用。
验证 trigger.conditions 中的每个条件均有 signal、operator、value。
检查是否有重复的 rule_id(可暂时用 unit_id + 序号,但最终合并时会处理)。
迭代指引:如果某些片段规则为空或条件缺失,检查是否为上下文包裁剪太狠(丢失了关键文本),优化提取逻辑或调整 Prompt,再次运行。
阶段三:确定性合并与完整性校验
目标:用程序逻辑将多个片段的规则去重、合并,并基于逻辑树节点覆盖率生成审计报告,告知人工哪里可能遗漏。
LLM 调用:无(或用极简 LLM 做最终文案规范化)。
任务要求:
编写 step3_merge_and_audit.py
加载 ir_fragments.json 和原始文档 JSON(含所有 logic_tree 节点)。
合并去重:
按 trigger 和 actions 的语义相似度进行规则合并。简化方案:如果两条规则的触发条件和动作完全相同(值比较),只保留一条,合并 sources。
合并后重新分配稳定的 rule_id(如 DRL-001-SYS-FG-01)。
完整性审计(生成 ir_audit_report.md):
逻辑树节点覆盖率:遍历所有 logic_tree 中的每个 decision 和 action 节点,检查是否有至少一条规则的 sources 引用了该节点。列出未被覆盖的节点及其所在的图片 ID。
表格枚举覆盖:识别所有表格中列出“应用类型”、“限制方式”的枚举值,检查这些值是否出现在规则的 precondition 或条件中。
全局开关覆盖:检查所有涉及开关状态的规则,是否完整覆盖了“开启”和“关闭”两种状态。
报告格式:Markdown 表格,列出检查项、状态(✅/⚠️/❌)、详情。
合并后的最终 IR 保存为 ir_final.json。
人类检查提示:审计报告最上方用醒目的文字说明:“请人工审查以下 ⚠️ 和 ❌ 项,确认是文档遗漏还是 IR 提取遗漏。如无需修改,则在对应项后标注‘已确认’。”
编写 test_step3.py,验证:
ir_final.json 中无重复 rule_id。
所有 rule_id 符合命名规范(DRL-001-...)。
ir_audit_report.md 文件存在且包含覆盖率统计。
迭代指引:如果审计报告显示大量节点未覆盖,需回溯检查阶段二的提取质量,可能是某些功能单元未被识别,或提取时丢失了节点引用。可补充遗漏的单元,重新提取片段后再次合并审计。
## 五、总体执行顺序与依赖
首先实现并运行 step1_semantic_index.py,通过测试。
接着运行 step2_ir_extraction.py,通过测试。
最后运行 step3_merge_and_audit.py,生成最终交付物。
所有脚本应可重复执行,且运行 python main.py(如有)可一键完成所有步骤。
六、开发与协作要求
所有 Python 代码需有清晰的注释,关键函数有 docstring。
Prompt 模板可以单独作为 .txt 或 .md 文件存放,便于调试。
测试脚本独立,并输出明确的通过/失败信息。
如果某个阶段需要人类帮助(例如需要确认某个功能划分是否合理),请在该阶段输出清晰的问题并暂停,等待人类回复后继续。
你将在工作目录 C:\Users\peterz\.openclaw\workspace\skills\ir_generation_new_skill 中完成所有工作。
现在开始执行。首先请通读输入文件(路径已给出),理解其结构,然后着手实现阶段一。
+121
View File
@@ -0,0 +1,121 @@
"""
Shared configuration for the IR Generation pipeline.
Reads API keys from a secrets.yaml file, falling back to environment variables.
"""
import os
import json
import yaml
# ---- Paths ----
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
WORKSPACE_DIR = os.path.dirname(BASE_DIR)
DOC_PARSER_OUTPUT = os.path.join(WORKSPACE_DIR, "doc_parser_skill", "output")
PROMPTS_DIR = os.path.join(BASE_DIR, "prompts")
TESTS_DIR = os.path.join(BASE_DIR, "tests")
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
# Input file (the parsed PRD JSON)
_DEFAULT_INPUT = os.path.join(
DOC_PARSER_OUTPUT,
"车机娱乐系统禁止功能文档_脱敏 v0.9_v2_updated.json",
)
INPUT_JSON = os.environ.get("IR_INPUT_JSON", _DEFAULT_INPUT)
def set_input_file(path: str) -> None:
"""Override the default input JSON path."""
global INPUT_JSON
INPUT_JSON = path
# Secrets file (shared with workspace-document-analyzer)
# .openclaw/workspace/skills/ir_generation_new_skill -> .openclaw/workspace-document-analyzer
OPENCLAW_HOME = os.path.dirname(os.path.dirname(WORKSPACE_DIR))
SECRETS_YAML = os.path.join(
OPENCLAW_HOME, "workspace-document-analyzer", "config", "secrets.yaml",
)
# Intermediate outputs
SEMANTIC_INDEX_JSON = os.path.join(OUTPUT_DIR, "semantic_index.json")
IR_FRAGMENTS_JSON = os.path.join(OUTPUT_DIR, "ir_fragments.json")
# Final deliverables (placed in doc_parser output per spec)
IR_FINAL_JSON = os.path.join(DOC_PARSER_OUTPUT, "ir_final.json")
IR_AUDIT_REPORT_MD = os.path.join(DOC_PARSER_OUTPUT, "ir_audit_report.md")
# ---- LLM API ----
# Choose provider: "deepseek" | "dashscope"
LLM_PROVIDER = os.environ.get("IR_PROVIDER", "deepseek")
# Model names per provider
PROVIDER_MODELS = {
"deepseek": os.environ.get("IR_MODEL", "deepseek-v4-pro"),
"dashscope": os.environ.get("IR_MODEL", "qwen-max"),
}
MODEL_NAME = PROVIDER_MODELS.get(LLM_PROVIDER, PROVIDER_MODELS["deepseek"])
# Maximum tokens for LLM responses
MAX_TOKENS = int(os.environ.get("IR_MAX_TOKENS", "16000"))
TEMPERATURE = float(os.environ.get("IR_TEMPERATURE", "0.1"))
def _load_secrets() -> dict[str, dict[str, str]]:
"""Load provider credentials from secrets.yaml.
Returns a dict like: {"deepseek": {"apiKey": "...", "baseUrl": "..."}, ...}
"""
if os.path.isfile(SECRETS_YAML):
with open(SECRETS_YAML, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
return {}
def _get_provider_config(provider: str) -> dict[str, str]:
"""Get {apiKey, baseUrl} for a provider from secrets, with env-var fallback."""
secrets = _load_secrets()
entry = secrets.get(provider, {})
env_prefix = provider.upper()
api_key = (
os.environ.get(f"{env_prefix}_API_KEY")
or entry.get("apiKey", "")
)
base_url = (
os.environ.get(f"{env_prefix}_BASE_URL")
or entry.get("baseUrl", "https://api.deepseek.com/v1")
)
if not api_key:
raise RuntimeError(
f"No API key found for provider '{provider}'. "
f"Check {SECRETS_YAML} or set {env_prefix}_API_KEY."
)
return {"apiKey": api_key, "baseUrl": base_url}
def llm_client():
"""Return an OpenAI-compatible client configured from secrets.yaml."""
from openai import OpenAI
cfg = _get_provider_config(LLM_PROVIDER)
return OpenAI(base_url=cfg["baseUrl"], api_key=cfg["apiKey"])
def load_input_document(path: str | None = None) -> dict:
"""Load the parsed PRD JSON document."""
path = path or INPUT_JSON
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_json(data, path: str) -> None:
"""Save data as formatted JSON."""
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_json(path: str) -> dict:
"""Load a JSON file."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
+134
View File
@@ -0,0 +1,134 @@
"""
IR Generation Pipeline Orchestrator.
Run all three stages sequentially:
python main.py [--skip-step1] [--skip-step2] [--skip-step3] [--test-only]
The pipeline reads the parsed PRD JSON from doc_parser and produces:
- ir_final.json: the final IR rules
- ir_audit_report.md: completeness audit report for human review
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
BASE_DIR = Path(__file__).parent
def _subprocess_env(extra: dict | None = None) -> dict:
"""Build environment dict for subprocesses, carrying forward overrides."""
env = os.environ.copy()
env.update(extra or {})
return env
def run_step(script_name: str, description: str, extra_env: dict | None = None) -> bool:
"""Run a single pipeline step script, return True if it succeeded."""
print(f"\n{'#' * 60}")
print(f"# {description}")
print(f"{'#' * 60}")
script_path = BASE_DIR / script_name
if not script_path.exists():
print(f"错误: 脚本不存在 {script_path}")
return False
result = subprocess.run(
[sys.executable, str(script_path)],
cwd=str(BASE_DIR),
env=_subprocess_env(extra_env),
)
return result.returncode == 0
def run_test(test_name: str, description: str, extra_env: dict | None = None) -> bool:
"""Run a test script, return True if all tests passed."""
print(f"\n{'='*60}")
print(f"测试: {description}")
print(f"{'='*60}")
test_path = BASE_DIR / "tests" / test_name
if not test_path.exists():
print(f"错误: 测试脚本不存在 {test_path}")
return False
result = subprocess.run(
[sys.executable, str(test_path)],
cwd=str(BASE_DIR),
env=_subprocess_env(extra_env),
)
return result.returncode == 0
def main():
parser = argparse.ArgumentParser(description="IR Generation Pipeline")
parser.add_argument("--skip-step1", action="store_true", help="跳过阶段一(语义索引)")
parser.add_argument("--skip-step2", action="store_true", help="跳过阶段二(IR 提取)")
parser.add_argument("--skip-step3", action="store_true", help="跳过阶段三(合并与审计)")
parser.add_argument("--test-only", action="store_true", help="仅运行测试,不调用 LLM")
parser.add_argument(
"--input", "-i", type=str, default=None,
help="输入 JSON 文件路径(覆盖默认的 doc_parser 输出)"
)
parser.add_argument(
"--provider", "-p", type=str, default=None,
help="LLM provider: deepseek | dashscope(覆盖 IR_PROVIDER 环境变量)"
)
args = parser.parse_args()
# Build extra env vars for subprocesses
extra_env = {}
if args.input:
extra_env["IR_INPUT_JSON"] = args.input
print(f"输入文件: {args.input}")
if args.provider:
extra_env["IR_PROVIDER"] = args.provider
print(f"LLM Provider: {args.provider}")
if args.test_only:
all_ok = True
all_ok &= run_test("test_step1.py", "Step 1 验证", extra_env)
all_ok &= run_test("test_step2.py", "Step 2 验证", extra_env)
all_ok &= run_test("test_step3.py", "Step 3 验证", extra_env)
sys.exit(0 if all_ok else 1)
failures = []
# Stage 1
if not args.skip_step1:
ok = run_step("step1_semantic_index.py", "阶段一:宏观语义索引", extra_env)
if not ok:
failures.append("阶段一")
print("\n阶段一失败,停止流水线。修复后重试。")
sys.exit(1)
run_test("test_step1.py", "Step 1 验证", extra_env)
# Stage 2
if not args.skip_step2:
ok = run_step("step2_ir_extraction.py", "阶段二:逐功能单元 IR 提取", extra_env)
if not ok:
failures.append("阶段二")
print("\n阶段二失败,停止流水线。修复后重试。")
sys.exit(1)
run_test("test_step2.py", "Step 2 验证", extra_env)
# Stage 3
if not args.skip_step3:
ok = run_step("step3_merge_and_audit.py", "阶段三:确定性合并与完整性校验", extra_env)
if not ok:
failures.append("阶段三")
sys.exit(1)
run_test("test_step3.py", "Step 3 验证", extra_env)
if failures:
print(f"\n失败阶段: {', '.join(failures)}")
sys.exit(1)
print(f"\n{'='*60}")
print("流水线全部完成!")
print(f"最终 IR: config.IR_FINAL_JSON")
print(f"审计报告: config.IR_AUDIT_REPORT_MD")
print(f"{'='*60}")
if __name__ == "__main__":
main()
@@ -0,0 +1,69 @@
你是吉利汽车车机系统(XX Auto)的产品需求分析师。你的任务是从行车娱乐限制功能 PRD 文档中提取"语义索引"——一份结构化的功能清单,而不是逐字翻译。
## 文档结构说明
下面是一份 Word 文档的解析结果,包含:
1. **sections**:按章节组织的混合内容(段落 + 表格),每个 section 有 `source`(章节标题)、`blocks``para` 文本段落和 `table` 结构表格)、`images`(引用的图片 ID 列表)
2. **image_analysis**:文档中流程图的程序化分析结果,其中 `logic_tree` 是由节点组成的决策树:
- `state` 节点:状态说明
- `decision` 节点:判断条件 + `branches`(分支值 → 目标节点 ID)
- `action` 节点:系统或用户交互动作
3. **resolved_conflicts**:文档中图文冲突的仲裁结果,明确指出应以文字还是图片为准
## 文档全文
{document_json}
## 你的任务
阅读整份文档后,输出一份 **语义索引 JSON**,包含:
### 1. feature_name
功能名称,如"行车娱乐限制"
### 2. concepts
文档中定义或使用的关键概念列表。每个概念包含:
- `name`:概念的标准名称
- `aliases`:同义词/别名列表(如"行车娱乐限制"、"行车娱乐禁止"
- `defined_in`:定义该概念的章节号列表(如 ["3.1", "3.1.1"]
应识别的概念类型包括但不限于:功能名称、应用类型(系统限制、SDK限制、其他应用)、限制方式(打断、禁止、暂停)、触发条件(车速、档位、持续时间)、开关状态等。
### 3. function_units
文档中描述的所有主要功能行为的列表。**每个 function_unit 对应一条完整的"如果...则..."规则**。每个 function unit 包含:
- `unit_id`:唯一标识,格式 "FU-001", "FU-002"...
- `name`:简短名称,如"系统限制-前台-行车打断"
- `description`1-3 句描述该规则的行为
- `sources`:该规则在文档中的来源锚点列表,每项包含:
- `section`:章节号
- `type`:来源类型,`"table"` 或 `"para"` 或 `"logic_tree"`
- `row`:如果是表格行(从 1 开始)
- `text_snippet`:前 200 字的关键文字
- `image_id`:如果是逻辑树来源,填写图片 rId
- `logic_tree_nodes`:如果是逻辑树来源,列出相关节点 ID 列表
## 关键要求
1. **必须覆盖所有逻辑树分支**:遍历每个 `logic_tree` 中从根到叶的每条决策路径,确保它们都出现在某个 function_unit 中。逻辑树中的每个 `decision` 节点及其分支、每个 `action` 节点都必须被至少一个 function_unit 引用。
2. **必须覆盖表格中的所有规则**:表格中列出的每种"限制方法"、"限制规则"都要有对应的 function_unit。
3. **区分"限制"与"禁止"**:文档中"行车娱乐限制"和"行车娱乐禁止"是两个不同的子场景(一个针对前台应用打断、一个针对后台应用启动限制),必须分别建模。
4. **区分不同应用类型**:系统限制、SDK 限制、其他应用的行为路径不同。
5. **包含开关状态**:开关"开启"和"关闭"两种状态下的行为都要覆盖。
6. **如果 resolved_conflicts 中以图片为准**,则优先按逻辑树的路径描述行为;**如果以文字为准**,则优先按表格文字描述。
## 输出格式
**只输出 JSON,不要有 markdown 代码块标记或其他文字**:
{
"feature_name": "...",
"concepts": [ ... ],
"function_units": [ ... ]
}
+162
View File
@@ -0,0 +1,162 @@
你是吉利汽车车机系统的需求分析专家。你的任务是基于给定的精准上下文包,为单个功能单元(Function Unit)提取详细的 **IR 规则(Intermediate Representation Rule**。
## 上下文
下面是一个功能单元的精准上下文包,包含了从原始需求文档中提取的相关文字、表格和逻辑树:
### 功能单元概要
- **unit_id**: {unit_id}
- **unit_name**: {unit_name}
- **unit_description**: {unit_description}
### 相关文字段落
{texts}
### 相关表格
{tables}
### 相关逻辑树
{logic_trees}
### 图文冲突仲裁(如有)
{resolved_conflicts}
## IR Schema
你需要为这个功能单元输出一个 **规则数组(rules)**。每条规则遵循以下 schema:
```json
{{
"rule_id": "{unit_id}-SYS-FG-01",
"description": "用完整的中文自然语言描述该规则的触发条件和行为,一句话概括",
"priority": "P0",
"sources": [
{{"type": "table", "section": "3.1.1", "row": 2, "text_snippet": "打断:车速≥15km/h..."}},
{{"type": "logic_tree", "image_id": "rId16", "node_ids": ["n19", "n21", "n23", "n25", "n26"]}}
],
"precondition": {{
"switch": "开启",
"app_type": "系统限制",
"app_state": "前台"
}},
"trigger": {{
"operator": "AND",
"conditions": [
{{"signal": "车速", "operator": ">=", "value": 15, "unit": "km/h"}},
{{"signal": "车速_持续时间", "operator": ">", "value": 5, "unit": "秒"}},
{{"signal": "档位", "operator": "!=", "value": "P"}}
]
}},
"actions": [
{{"type": "system", "description": "打断应用前台进程"}},
{{"type": "system", "description": "将应用调入后台"}},
{{"type": "user_interaction", "description": "显示Toast", "content": "在行车状态下无法使用该应用"}}
]
}}
```
### 字段说明(必读)
1. **rule_id**: 格式为 `{unit_id}-类型-序号`,其中类型可以是 SYS(系统行为)、UI(用户交互)、SDK(SDK 限制)。序号从 01 开始。
2. **description**: 完整但简洁地描述整个规则,包括前置条件、触发条件和所有动作。用中文。
3. **priority**: P0(核心安全规则)、P1(重要规则)、P2(边界情况)。
4. **sources**: 每条规则必须列出所有数据来源,包括:
- 引用的表格行(section, row, text_snippet
- 引用的逻辑树节点 ID 列表(image_id, node_ids)。**注意:node_ids 必须列举该规则在逻辑树中经历的所有 decision 和 action 节点。**
5. **precondition**: 规则生效的前置状态条件(开关状态、应用类型、应用前后台状态等)。可以是空对象 `{{}}` 如果无条件。
6. **trigger**: 触发条件对象,包含:
- `operator`: 条件组合方式,`"AND"` 或 `"OR"`
- `conditions`: 条件数组,每个条件必须有 `signal`(信号名)、`operator`(比较运算符)、`value`(数值或字符串)。如果有单位,加 `unit` 字段。
如果触发器是瞬时事件(如用户点击),使用 `event` 字段代替 `conditions`。
7. **actions**: 每个动作必须有 `type``"system"` 或 `"user_interaction"`)和 `description`。用户可见交互(Toast、弹窗、语音播报)必须用 `"user_interaction"` 类型,并包含 `content` 字段。
## Few-shot 示例
### 示例 1:行车娱乐限制(前台打断)
**输入上下文**:开关开启,系统限制类应用在前台,车速≥15km/h且持续>5秒且非P档时,打断应用并显示Toast。
**期望输出**
```json
{{
"rule_id": "FU-001-SYS-01",
"description": "开关开启时,系统限制类应用在前台,当车速≥15km/h且持续超过5秒且非P档时,系统打断应用前台进程、将应用调入后台,并弹出Toast提示'在行车状态下无法使用该应用'",
"priority": "P0",
"sources": [
{{"type": "table", "section": "3.1.1", "row": 2, "text_snippet": "行车娱乐限制:目标应用/功能处于前台时 ○ 打断:车速≥15km/h且持续5秒后..."}},
{{"type": "logic_tree", "image_id": "rId16", "node_ids": ["n2", "n8", "n9", "n11", "n13", "n19", "n21", "n23", "n25", "n26"]}}
],
"precondition": {{
"switch": "开启",
"app_type": "系统限制",
"app_state": "前台"
}},
"trigger": {{
"operator": "AND",
"conditions": [
{{"signal": "车速", "operator": ">=", "value": 15, "unit": "km/h"}},
{{"signal": "车速_持续时间", "operator": ">", "value": 5, "unit": "秒"}},
{{"signal": "档位", "operator": "!=", "value": "P"}}
]
}},
"actions": [
{{"type": "system", "description": "打断应用前台进程"}},
{{"type": "system", "description": "将应用调入后台"}},
{{"type": "user_interaction", "description": "显示Toast", "content": "在行车状态下无法使用该应用"}}
]
}}
```
### 示例 2:行车娱乐禁止(后台启动拦截)
**输入上下文**:开关开启,应用在后台且非前台,非P档时阻止应用启动,并提示。
**期望输出**
```json
{{
"rule_id": "FU-002-SYS-01",
"description": "开关开启时,目标应用处于后台,当档位非P档时,限制应用启动,并弹出Toast提示'请在P挡时使用该功能/应用'",
"priority": "P0",
"sources": [
{{"type": "table", "section": "3.1.1", "row": 2, "text_snippet": "行车娱乐禁止:目标应用/功能处于后台时 ○ 限制:非P挡时,限制目标应用/功能启用..."}},
{{"type": "logic_tree", "image_id": "rId17", "node_ids": ["n5", "n6"]}}
],
"precondition": {{
"switch": "开启",
"app_state": "后台"
}},
"trigger": {{
"operator": "AND",
"conditions": [
{{"signal": "应用请求启动", "operator": "==", "value": true}}
]
}},
"actions": [
{{"type": "system", "description": "限制应用/功能启用"}},
{{"type": "user_interaction", "description": "显示Toast", "content": "请在P挡时使用该功能/应用"}}
]
}}
```
## 关键要求
1. **信号和数值必须精确**:不要写"车速超过阈值",必须写 `"车速 >= 15 km/h"`。
2. **条件必须完整**:如果文档说"车速≥15km/h 且持续超过5秒 且非P档",这三个条件必须全部出现在 trigger.conditions 中。
3. **逻辑树节点必须追踪**:在 sources 中列出该规则在逻辑树中经历的所有 decision 节点和 action 节点。这样做是为了后续审计(检查逻辑树覆盖率)。
4. **优先图片逻辑树**:如果文字和图片存在矛盾,优先采用逻辑树中的路径,但保留文字作为补充参考(将两者都列入 sources)。
5. **动作类型区分**:系统行为(打断进程、限制启动)用 `"system"`,用户可见交互(Toast、弹窗、语音)用 `"user_interaction"`。
6. **多条规则**:如果一个功能单元包含多个独立的行为分支(如正常情况+异常情况),输出多条规则分别描述。规则之间通过 precondition 或 trigger 的条件值来区分。
7. **开关关闭状态**:如果功能单元涉及开关,也要考虑开关关闭时的行为。开关关闭时所有限制失效,这也是一条规则。
## 输出格式
**只输出 JSON 数组,不要有任何其他文字或 markdown 标记**
[
{{ ... }},
{{ ... }}
]
注意:即使只有一个规则,也必须用数组格式 `[...]`。
+200
View File
@@ -0,0 +1,200 @@
"""
Stage 1: Semantic Index Generation.
Reads the full parsed PRD JSON, calls the LLM once to produce a semantic index
identifying all function units, concepts, and their document sources.
Output: output/semantic_index.json
"""
import json
import re
import sys
import time
from pathlib import Path
import config
def format_document_for_prompt(doc: dict) -> str:
"""Render the full parsed document as a readable string for the LLM prompt."""
lines = []
# ---- Sections ----
lines.append("=== SECTIONS ===")
for i, section in enumerate(doc.get("sections", [])):
source = section.get("source", f"(无标题-章节{i})")
lines.append(f"\n--- Section: {source} ---")
for block in section.get("blocks", []):
if block["type"] == "para":
lines.append(f"[段落 {block['index']}] {block['text']}")
elif block["type"] == "table":
lines.append(f"[表格 {block.get('table', '?')}]")
headers = block.get("headers", [])
lines.append(f" 表头: {' | '.join(headers)}")
for row in block.get("rows", []):
cols = row.get("columns", [])
cell_texts = []
for c in cols:
cell_texts.append(f"[行{c.get('row','?')}]{c.get('name','')}: {c.get('text','')}")
lines.append(f" {'; '.join(cell_texts)}")
images = section.get("images", [])
if images:
lines.append(f" 图片引用: {', '.join(images)}")
# ---- Image Analysis ----
lines.append("\n\n=== IMAGE_ANALYSIS (流程图逻辑树) ===")
for img in doc.get("image_analysis", []):
rid = img.get("rid", "?")
img_type = img.get("type", "?")
lines.append(f"\n--- Image: {rid} (type={img_type}) ---")
lines.append(f" 描述: {img.get('description', '')[:300]}")
lt = img.get("logic_tree")
if lt:
lines.append(f" 逻辑树根节点: {lt.get('root', '?')}")
lines.append(" 节点详情:")
for node in lt.get("nodes", []):
nid = node.get("id", "?")
ntype = node.get("type", "?")
desc = node.get("description", "") or node.get("condition", "")
lines.append(f" [{ntype}] {nid}: {desc}")
branches = node.get("branches", [])
if branches:
for br in branches:
lines.append(f"{br['value']}{br['target']}")
# ---- Resolved Conflicts ----
conflicts = doc.get("resolved_conflicts", [])
if conflicts:
lines.append("\n\n=== RESOLVED_CONFLICTS (图文冲突仲裁) ===")
for c in conflicts:
lines.append(
f" [{c.get('conflict_type','?')}] {c.get('section','?')}: "
f"{c.get('source','?')}为准 — {c.get('correction','')}"
)
return "\n".join(lines)
def build_prompt(doc: dict) -> str:
"""Load the prompt template and inject the formatted document."""
template_path = Path(config.PROMPTS_DIR) / "step1_semantic_index.txt"
template = template_path.read_text(encoding="utf-8")
formatted_doc = format_document_for_prompt(doc)
prompt = template.replace("{document_json}", formatted_doc)
return prompt
def extract_json_from_response(text: str) -> str:
"""Robustly extract JSON from LLM response, handling markdown fences."""
# Try ```json ... ``` first
m = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
if m:
return m.group(1).strip()
# Try to find the outermost { ... }
start = text.find("{")
if start == -1:
raise ValueError("No JSON object found in LLM response")
depth = 0
for i in range(start, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
raise ValueError("Unclosed JSON object in LLM response")
def call_llm(prompt: str, max_retries: int = 2) -> dict:
"""Send the prompt to the LLM and return the parsed semantic index JSON."""
client = config.llm_client()
for attempt in range(max_retries + 1):
print(f" LLM 调用 (尝试 {attempt + 1}/{max_retries + 1})...", flush=True)
try:
resp = client.chat.completions.create(
model=config.MODEL_NAME,
messages=[
{"role": "system", "content": "你是一个精确的 JSON 输出引擎。只输出合法的 JSON,不输出任何其他文字。"},
{"role": "user", "content": prompt},
],
temperature=config.TEMPERATURE,
max_tokens=config.MAX_TOKENS,
)
content = resp.choices[0].message.content
if content is None:
raise RuntimeError("LLM returned empty response")
json_str = extract_json_from_response(content)
result = json.loads(json_str)
# Quick structural validation
_validate_schema(result)
return result
except (json.JSONDecodeError, ValueError) as e:
print(f" JSON 解析失败: {e}")
if attempt < max_retries:
time.sleep(2)
else:
raise RuntimeError(f"无法从 LLM 响应中解析 JSON: {e}") from e
def _validate_schema(data: dict) -> None:
"""Validate the top-level schema of the semantic index."""
if "feature_name" not in data:
raise ValueError("semantic_index 缺少 'feature_name' 字段")
if "function_units" not in data:
raise ValueError("semantic_index 缺少 'function_units' 字段")
units = data["function_units"]
if not isinstance(units, list) or len(units) == 0:
raise ValueError("function_units 必须是非空数组")
for i, fu in enumerate(units):
if not fu.get("unit_id"):
raise ValueError(f"function_unit[{i}] 缺少 unit_id")
if not fu.get("name"):
raise ValueError(f"function_unit[{i}] 缺少 name")
def main():
print("=" * 60)
print("阶段一:宏观语义索引 (Semantic Index)")
print("=" * 60)
# 1. Load input
print(f"\n[1/4] 加载输入文档: {config.INPUT_JSON}")
doc = config.load_input_document()
print(f" 已加载 {len(doc.get('sections', []))} 个 section, "
f"{len(doc.get('image_analysis', []))} 张图片分析")
# 2. Build prompt
print(f"\n[2/4] 构造 Prompt...")
prompt = build_prompt(doc)
print(f" Prompt 长度: {len(prompt)} 字符 (~{len(prompt)//3} tokens)")
# 3. Call LLM
print(f"\n[3/4] 调用 LLM ({config.MODEL_NAME})...")
semantic_index = call_llm(prompt)
# 4. Save result
print(f"\n[4/4] 保存语义索引: {config.SEMANTIC_INDEX_JSON}")
config.save_json(semantic_index, config.SEMANTIC_INDEX_JSON)
n_concepts = len(semantic_index.get("concepts", []))
n_units = len(semantic_index.get("function_units", []))
print(f"\n完成! 提取了 {n_concepts} 个概念, {n_units} 个功能单元.")
print(f"输出: {config.SEMANTIC_INDEX_JSON}")
if __name__ == "__main__":
main()
+410
View File
@@ -0,0 +1,410 @@
"""
Stage 2: Per Function Unit IR Extraction.
For each function unit from the semantic index, constructs a precision context
package and calls the LLM to extract detailed IR rules.
Runs multiple LLM calls in parallel (up to MAX_CONCURRENCY).
Output: output/ir_fragments.json
"""
import concurrent.futures
import json
import re
import sys
import time
from pathlib import Path
import config
MAX_CONCURRENCY = 3 # Max parallel LLM calls
def load_semantic_index() -> dict:
"""Load the semantic index from Stage 1."""
return config.load_json(config.SEMANTIC_INDEX_JSON)
def build_document_lookup(doc: dict):
"""Build lookup structures for fast context extraction from the document."""
# sections_by_source: "3.1.1" -> section dict
sections_by_source = {}
for section in doc.get("sections", []):
source = section.get("source", "")
# Normalize: extract leading number like "3.1.1"
parts = source.split()
if parts:
key = parts[0].strip()
sections_by_source[key] = section
# image_by_rid: "rId16" -> image_analysis entry
image_by_rid = {}
for img in doc.get("image_analysis", []):
rid = img.get("rid", "")
if rid:
image_by_rid[rid] = img
# Conflicts indexed by section
conflicts_by_section = {}
for c in doc.get("resolved_conflicts", []):
section = c.get("section", "")
key = section.split()[0] if section else ""
conflicts_by_section.setdefault(key, []).append(c)
return sections_by_source, image_by_rid, conflicts_by_section
def extract_context_package(
fu: dict, doc: dict, sections_by_source: dict, image_by_rid: dict,
conflicts_by_section: dict
) -> dict:
"""Build a precision context package for a single function unit."""
texts = []
tables = []
logic_trees = []
seen_sections = set()
seen_images = set()
for src in fu.get("sources", []):
src_type = src.get("type", "")
section_key = src.get("section", "").split()[0] if src.get("section") else ""
# --- Text source ---
if src_type in ("table", "para") and section_key:
if section_key in seen_sections:
continue
seen_sections.add(section_key)
section = sections_by_source.get(section_key)
if section is None:
# Fuzzy match by prefix
for key in sections_by_source:
if key.startswith(section_key):
section = sections_by_source[key]
break
if section:
for block in section.get("blocks", []):
if block["type"] == "para":
texts.append({
"section": section_key,
"text": block["text"]
})
elif block["type"] == "table":
row_num = src.get("row") if src_type == "table" else None
if row_num is not None:
# Extract only the specific row
matching_rows = []
for r in block.get("rows", []):
for c in r.get("columns", []):
if c.get("row") == row_num:
matching_rows.append({
"headers": block.get("headers", []),
"cells": {
col["name"]: col["text"]
for col in r["columns"]
},
"row": row_num
})
break
tables.append({
"section": section_key,
"headers": block.get("headers", []),
"rows": matching_rows,
"all_rows": [
{
"row": col.get("row"),
"name": col.get("name"),
"text": col.get("text")
}
for row in block.get("rows", [])
for col in row.get("columns", [])
]
})
else:
# Include full table
tables.append({
"section": section_key,
"headers": block.get("headers", []),
"all_rows": [
{
"row": col.get("row"),
"name": col.get("name"),
"text": col.get("text")
}
for row in block.get("rows", [])
for col in row.get("columns", [])
]
})
# --- Logic tree source ---
if src_type == "logic_tree":
image_id = src.get("image_id", "")
if not image_id or image_id in seen_images:
continue
seen_images.add(image_id)
img = image_by_rid.get(image_id)
if img:
lt = img.get("logic_tree")
if lt:
logic_trees.append({
"image_id": image_id,
"description": img.get("description", ""),
"tree": lt
})
# Include relevant resolved conflicts
relevant_conflicts = []
for section_key in seen_sections:
for c in conflicts_by_section.get(section_key, []):
relevant_conflicts.append(c)
return {
"unit_id": fu["unit_id"],
"unit_name": fu.get("name", ""),
"unit_description": fu.get("description", ""),
"texts": texts,
"tables": tables,
"logic_trees": logic_trees,
"resolved_conflicts": relevant_conflicts
}
def format_context_package(pkg: dict) -> str:
"""Format a context package as a readable string for the prompt."""
parts = []
# Texts
parts.append("【文字段落】")
for i, t in enumerate(pkg.get("texts", [])):
parts.append(f"[{t.get('section', '?')}] {t.get('text', '')}")
if not pkg.get("texts"):
parts.append("(无)")
# Tables
parts.append("\n【表格数据】")
for i, tbl in enumerate(pkg.get("tables", [])):
parts.append(f"表格 {i+1} (section={tbl.get('section', '?')})")
headers = tbl.get("headers", [])
parts.append(f" 表头: {headers}")
parts.append(" 全部行数据:")
for row in tbl.get("all_rows", []):
parts.append(
f"{row.get('row','?')}[{row.get('name','?')}]: {row.get('text','')}"
)
# Highlight matched rows if any
matched = tbl.get("rows", [])
if matched:
parts.append(" <重点关注行>:")
for mr in matched:
parts.append(f"{mr.get('row','?')}: {mr.get('cells', {})}")
if not pkg.get("tables"):
parts.append("(无)")
# Logic trees
parts.append("\n【逻辑树】")
for i, lt in enumerate(pkg.get("logic_trees", [])):
parts.append(f"逻辑树 {i+1} (image_id={lt.get('image_id', '?')})")
parts.append(f" 描述: {lt.get('description', '')[:200]}")
tree = lt.get("tree", {})
parts.append(f" 根: {tree.get('root', '?')}")
parts.append(" 节点:")
for node in tree.get("nodes", []):
nid = node.get("id", "?")
ntype = node.get("type", "?")
desc = node.get("description", "") or node.get("condition", "")
parts.append(f" [{ntype}] {nid}: {desc}")
for br in node.get("branches", []):
parts.append(f"{br['value']}{br['target']}")
if not pkg.get("logic_trees"):
parts.append("(无)")
# Conflicts
conflicts = pkg.get("resolved_conflicts", [])
if conflicts:
parts.append("\n【图文冲突仲裁】")
for c in conflicts:
parts.append(
f" [{c.get('conflict_type', '?')}] 以{c.get('source', '?')}为准: "
f"{c.get('correction', '')}"
)
return "\n".join(parts)
def _escape_json_for_format(s: str) -> str:
"""Escape curly braces in a JSON string for use with str.format()."""
return s.replace("{", "{{").replace("}", "}}")
def build_prompt(pkg: dict) -> str:
"""Build the LLM prompt for a single function unit."""
template_path = Path(config.PROMPTS_DIR) / "step2_ir_extraction.txt"
template = template_path.read_text(encoding="utf-8")
prompt = template.format(
unit_id=pkg["unit_id"],
unit_name=pkg["unit_name"],
unit_description=pkg["unit_description"],
texts=_escape_json_for_format(
json.dumps(pkg.get("texts", []), ensure_ascii=False, indent=2)
),
tables=_escape_json_for_format(
json.dumps(pkg.get("tables", []), ensure_ascii=False, indent=2)
),
logic_trees=_escape_json_for_format(
json.dumps(pkg.get("logic_trees", []), ensure_ascii=False, indent=2)
),
resolved_conflicts=_escape_json_for_format(
json.dumps(pkg.get("resolved_conflicts", []), ensure_ascii=False, indent=2)
),
)
return prompt
def extract_json_from_response(text: str) -> str:
"""Extract JSON array from LLM response."""
m = re.search(r"```(?:json)?\s*(\[[\s\S]*?\])\s*```", text)
if m:
return m.group(1).strip()
# Find outermost [ ... ]
start = text.find("[")
if start == -1:
raise ValueError("No JSON array found in LLM response")
depth = 0
for i in range(start, len(text)):
if text[i] == "[":
depth += 1
elif text[i] == "]":
depth -= 1
if depth == 0:
return text[start : i + 1]
raise ValueError("Unclosed JSON array in LLM response")
def extract_rules_for_unit(pkg: dict, max_retries: int = 2) -> list[dict]:
"""Call LLM for one function unit, return its IR rules."""
prompt = build_prompt(pkg)
client = config.llm_client()
for attempt in range(max_retries + 1):
try:
resp = client.chat.completions.create(
model=config.MODEL_NAME,
messages=[
{
"role": "system",
"content": "你是一个精确的 JSON 输出引擎。只输出合法的 JSON 数组,不输出任何其他文字。"
},
{"role": "user", "content": prompt},
],
temperature=config.TEMPERATURE,
max_tokens=config.MAX_TOKENS,
)
content = resp.choices[0].message.content
if content is None:
raise RuntimeError("LLM returned empty response")
json_str = extract_json_from_response(content)
rules = json.loads(json_str)
if not isinstance(rules, list):
raise ValueError(f"Expected JSON array, got {type(rules).__name__}")
return rules
except (json.JSONDecodeError, ValueError) as e:
print(f" JSON 解析失败 (尝试 {attempt + 1}): {e}")
if attempt < max_retries:
time.sleep(2)
return []
def extract_all_rules(
semantic_index: dict, doc: dict
) -> list[dict]:
"""Extract IR rules for all function units. Runs in parallel up to MAX_CONCURRENCY."""
sections_by_source, image_by_rid, conflicts_by_section = build_document_lookup(doc)
function_units = semantic_index.get("function_units", [])
print(f"{len(function_units)} 个功能单元待处理")
print(f" 最大并发: {MAX_CONCURRENCY}")
# Build context packages (serial — fast)
packages = []
for fu in function_units:
pkg = extract_context_package(
fu, doc, sections_by_source, image_by_rid, conflicts_by_section
)
packages.append(pkg)
# Run LLM calls in parallel
fragments = []
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_CONCURRENCY) as executor:
futures = {}
for i, pkg in enumerate(packages):
future = executor.submit(extract_rules_for_unit, pkg)
futures[future] = (i, pkg["unit_id"], pkg["unit_name"])
for future in concurrent.futures.as_completed(futures):
i, uid, uname = futures[future]
try:
rules = future.result()
fragments.append({
"unit_id": uid,
"unit_name": uname,
"rules": rules
})
print(f"{uid} ({uname}): {len(rules)} 条规则")
except Exception as e:
print(f"{uid} ({uname}): 失败 — {e}")
fragments.append({
"unit_id": uid,
"unit_name": uname,
"rules": [],
"error": str(e)
})
# Sort by unit_id to maintain stable ordering
fragments.sort(key=lambda f: f["unit_id"])
return fragments
def main():
print("=" * 60)
print("阶段二:逐功能单元 IR 提取")
print("=" * 60)
# 1. Load inputs
print(f"\n[1/3] 加载输入...")
semantic_index = load_semantic_index()
doc = config.load_input_document()
n_units = len(semantic_index.get("function_units", []))
print(f" 语义索引: {n_units} 个功能单元")
# 2. Extract rules
print(f"\n[2/3] 逐单元提取 IR 规则...")
fragments = extract_all_rules(semantic_index, doc)
# 3. Save
print(f"\n[3/3] 保存 IR 片段...")
config.save_json(fragments, config.IR_FRAGMENTS_JSON)
total_rules = sum(len(f["rules"]) for f in fragments)
failed_units = [f for f in fragments if f.get("error")]
print(f"\n完成! {len(fragments)} 个功能单元, 共 {total_rules} 条规则")
if failed_units:
print(f" ⚠️ {len(failed_units)} 个单元提取失败: "
f"{[f['unit_id'] for f in failed_units]}")
print(f"输出: {config.IR_FRAGMENTS_JSON}")
if __name__ == "__main__":
main()
+474
View File
@@ -0,0 +1,474 @@
"""
Stage 3: Deterministic Merge & Completeness Audit.
- Merges IR rule fragments, deduplicating by trigger+actions similarity.
- Reassigns stable rule_ids.
- Generates an audit report covering:
1. Logic tree node coverage
2. Table enumeration coverage
3. Global switch state coverage
Outputs:
- ir_final.json (in doc_parser output per spec)
- ir_audit_report.md (in doc_parser output)
"""
import json
import hashlib
import sys
from collections import defaultdict
from pathlib import Path
import config
PASS = "[PASS]"
WARN = "[WARN]"
FAIL = "[FAIL]"
def load_fragments() -> list[dict]:
"""Load IR fragments from Stage 2."""
return config.load_json(config.IR_FRAGMENTS_JSON)
def load_semantic_index() -> dict:
"""Load semantic index from Stage 1."""
return config.load_json(config.SEMANTIC_INDEX_JSON)
def rule_signature(rule: dict) -> str:
"""Generate a dedup signature from trigger + actions.
Two rules with identical trigger conditions and actions produce
the same signature and should be merged.
"""
trigger = rule.get("trigger", {})
actions = rule.get("actions", [])
# Normalize: sort conditions by signal name for stability
conditions = sorted(trigger.get("conditions", []), key=lambda c: c.get("signal", ""))
# Sort actions by description
sorted_actions = sorted(actions, key=lambda a: a.get("description", ""))
sig_data = {
"conditions": conditions,
"actions": sorted_actions,
}
sig_json = json.dumps(sig_data, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(sig_json.encode()).hexdigest()[:16]
def merge_rules(fragments: list[dict]) -> list[dict]:
"""Merge rules across all fragments, deduplicating by trigger+actions."""
signature_map: dict[str, dict] = {}
order = []
for fragment in fragments:
for rule in fragment.get("rules", []):
sig = rule_signature(rule)
if sig in signature_map:
# Merge sources
existing = signature_map[sig]
existing_sources = existing.setdefault("sources", [])
for src in rule.get("sources", []):
if src not in existing_sources:
existing_sources.append(src)
# Use the more detailed description
if len(rule.get("description", "")) > len(existing.get("description", "")):
existing["description"] = rule["description"]
else:
signature_map[sig] = dict(rule)
order.append(sig)
merged = [signature_map[sig] for sig in order]
print(f" 合并前: {sum(len(f.get('rules', [])) for f in fragments)} 条规则")
print(f" 合并后: {len(merged)} 条规则")
return merged
def assign_rule_ids(rules: list[dict], feature_id: str = "DRL-001") -> list[dict]:
"""Reassign stable rule_ids based on type and sequence."""
type_counters = defaultdict(int)
for rule in rules:
# Determine type from the first action's type
actions = rule.get("actions", [])
if any(a.get("type") == "user_interaction" for a in actions) and \
not any(a.get("type") == "system" for a in actions):
rtype = "UI"
elif any("SDK" in str(a) for a in actions):
rtype = "SDK"
else:
rtype = "SYS"
type_counters[rtype] += 1
seq = type_counters[rtype]
rule["rule_id"] = f"{feature_id}-{rtype}-FG-{seq:02d}"
# Also generate top-level feature metadata
return rules
def find_all_logic_tree_nodes(doc: dict) -> dict[str, list[dict]]:
"""Return {image_id: [all nodes]} for all logic trees."""
result = {}
for img in doc.get("image_analysis", []):
lt = img.get("logic_tree")
rid = img.get("rid", "")
if lt and rid:
result[rid] = lt.get("nodes", [])
return result
def find_referenced_nodes(rules: list[dict]) -> dict[str, set[str]]:
"""Return {image_id: {referenced node ids}} across all rules."""
referenced = defaultdict(set)
for rule in rules:
for src in rule.get("sources", []):
if src.get("type") == "logic_tree":
image_id = src.get("image_id", "")
for nid in src.get("node_ids", []):
referenced[image_id].add(nid)
return dict(referenced)
def audit_logic_tree_coverage(
doc: dict, rules: list[dict]
) -> list[dict]:
"""Generate coverage statistics for logic tree nodes."""
all_nodes = find_all_logic_tree_nodes(doc)
referenced = find_referenced_nodes(rules)
results = []
for image_id, nodes in all_nodes.items():
ref_set = referenced.get(image_id, set())
decision_nodes = [n for n in nodes if n["type"] == "decision"]
action_nodes = [n for n in nodes if n["type"] == "action"]
state_nodes = [n for n in nodes if n["type"] == "state"]
decisions_covered = [n for n in decision_nodes if n["id"] in ref_set]
actions_covered = [n for n in action_nodes if n["id"] in ref_set]
decisions_uncovered = [n for n in decision_nodes if n["id"] not in ref_set]
actions_uncovered = [n for n in action_nodes if n["id"] not in ref_set]
total_checkable = len(decision_nodes) + len(action_nodes)
total_covered = len(decisions_covered) + len(actions_covered)
coverage = (total_covered / total_checkable * 100) if total_checkable > 0 else 100
status = PASS if coverage >= 95 else (WARN if coverage >= 70 else FAIL)
detail_parts = [f"{total_covered}/{total_checkable} decision+action 节点被引用"]
if decisions_uncovered:
detail_parts.append(
f"未覆盖的 decision: {[n['id'] + ': ' + n.get('condition','')[:40] for n in decisions_uncovered]}"
)
if actions_uncovered:
detail_parts.append(
f"未覆盖的 action: {[n['id'] + ': ' + n.get('description','')[:40] for n in actions_uncovered]}"
)
results.append({
"check": f"逻辑树 {image_id} 节点覆盖率",
"status": status,
"coverage_pct": round(coverage, 1),
"detail": "; ".join(detail_parts),
"image_id": image_id,
"uncovered_decisions": decisions_uncovered,
"uncovered_actions": actions_uncovered,
})
return results
def find_table_enums(doc: dict) -> list[dict]:
"""Find enumerated values in tables (e.g., app types, limit methods)."""
enums = []
for section in doc.get("sections", []):
for block in section.get("blocks", []):
if block["type"] != "table":
continue
headers = block.get("headers", [])
if not headers:
continue
# Look for the "功能" / "功能详细说明" table pattern (key-value pairs)
if "功能" in headers and "功能详细说明" in headers:
for row in block.get("rows", []):
cols = row.get("columns", [])
key_col = next((c for c in cols if c.get("name") == "功能"), None)
val_col = next(
(c for c in cols if c.get("name") == "功能详细说明"), None
)
if key_col and val_col:
enums.append({
"section": section.get("source", ""),
"row": key_col.get("row"),
"key": key_col.get("text", ""),
"value": val_col.get("text", ""),
})
else:
# Generic table: record first column values as potential enum
first_col_name = headers[0] if headers else ""
values = []
for row in block.get("rows", []):
for col in row.get("columns", []):
if col.get("name") == first_col_name:
values.append(col.get("text", ""))
if values:
enums.append({
"section": section.get("source", ""),
"column": first_col_name,
"values": values,
})
return enums
def audit_table_enums(rules: list[dict], doc: dict) -> list[dict]:
"""Check if key enumerated values appear in rule preconditions."""
results = []
table_enums = find_table_enums(doc)
# Collect all rule precondition fields and their values
rule_preconditions = []
for rule in rules:
precond = rule.get("precondition", {})
rule_preconditions.append(precond)
# Check specific enum categories
app_types = {"系统限制", "SDK限制", "其他应用"}
switch_states = {"开启", "关闭"}
app_states = {"前台", "后台"}
# App type coverage
found_app_types = set()
for precond in rule_preconditions:
at = precond.get("app_type", "")
if at:
found_app_types.add(at)
missing_types = app_types - found_app_types
results.append({
"check": "应用类型枚举覆盖",
"status": PASS if not missing_types else WARN,
"detail": f"已覆盖: {found_app_types or ''}"
+ (f"; 未覆盖: {missing_types}" if missing_types else ""),
})
# App state coverage
found_states = set()
for precond in rule_preconditions:
st = precond.get("app_state", "")
if st:
found_states.add(st)
missing_states = app_states - found_states
results.append({
"check": "应用前后台状态覆盖",
"status": PASS if not missing_states else WARN,
"detail": f"已覆盖: {found_states or ''}"
+ (f"; 未覆盖: {missing_states}" if missing_states else ""),
})
# Trigger signal coverage (check each table enum key appears)
trigger_signals = set()
for rule in rules:
for cond in rule.get("trigger", {}).get("conditions", []):
signal = cond.get("signal", "")
if signal:
trigger_signals.add(signal)
# Check if key concepts from the doc appear in signals
key_signals = {"车速", "档位", "车速_持续时间", "应用请求启动"}
missing_signals = key_signals - trigger_signals
results.append({
"check": "触发信号覆盖(车速/档位/持续时间/启动请求)",
"status": PASS if not missing_signals else WARN,
"detail": f"已覆盖信号: {sorted(trigger_signals)}"
+ (f"; 未覆盖: {missing_signals}" if missing_signals else ""),
})
return results
def audit_switch_coverage(rules: list[dict]) -> list[dict]:
"""Check that rules cover both switch ON and OFF states."""
switch_on = False
switch_off = False
switch_rules = []
for rule in rules:
precond = rule.get("precondition", {})
sw = precond.get("switch", "")
if sw == "开启":
switch_on = True
switch_rules.append(rule.get("rule_id", "?"))
elif sw == "关闭":
switch_off = True
switch_rules.append(rule.get("rule_id", "?"))
status = PASS
detail_parts = []
if switch_on:
detail_parts.append(f"开关=开启: 有规则覆盖")
else:
detail_parts.append(f"开关=开启: 未找到规则")
status = FAIL
if switch_off:
detail_parts.append(f"开关=关闭: 有规则覆盖")
else:
detail_parts.append(f"开关=关闭: 未找到规则")
status = FAIL
return [{
"check": "开关状态完整性(开启/关闭)",
"status": status,
"detail": "; ".join(detail_parts),
}]
def generate_audit_report(
rules: list[dict],
doc: dict,
feature_name: str,
lt_results: list[dict],
enum_results: list[dict],
switch_results: list[dict],
) -> str:
"""Generate ir_audit_report.md in Markdown format."""
lines = []
lines.append(f"# IR 完整性审计报告")
lines.append(f"")
lines.append(f"**功能**: {feature_name}")
lines.append(f"**规则总数**: {len(rules)}")
lines.append(f"**生成时间**: {__import__('datetime').datetime.now().isoformat()}")
lines.append(f"")
# Human review notice
lines.append(f"> ⚠️ **重要**: 请人工审查以下 ⚠️ 和 ❌ 项,确认是文档遗漏还是 IR 提取遗漏。")
lines.append(f'> 如无需修改,在对应项后标注 **"已确认"**。')
lines.append(f"")
# ---- Logic Tree Coverage ----
lines.append(f"## 1. 逻辑树节点覆盖率")
lines.append(f"")
lines.append(f"| 图片 ID | 覆盖率 | 状态 | 详情 |")
lines.append(f"|---------|--------|------|------|")
for r in lt_results:
lines.append(
f"| {r['image_id']} | {r['coverage_pct']}% | {r['status']} | {r['detail']} |"
)
lines.append(f"")
# Uncovered node details
for r in lt_results:
if r["uncovered_decisions"] or r["uncovered_actions"]:
lines.append(f"### {r['image_id']} 未覆盖节点详情")
lines.append(f"")
for n in r.get("uncovered_decisions", []):
lines.append(f"- **Decision** `{n['id']}`: {n.get('condition', '?')}")
for n in r.get("uncovered_actions", []):
lines.append(f"- **Action** `{n['id']}`: {n.get('description', '?')}")
lines.append(f"")
# ---- Table Enumeration Coverage ----
lines.append(f"## 2. 表格枚举覆盖")
lines.append(f"")
lines.append(f"| 检查项 | 状态 | 详情 |")
lines.append(f"|--------|------|------|")
for r in enum_results:
lines.append(f"| {r['check']} | {r['status']} | {r['detail']} |")
lines.append(f"")
# ---- Switch Coverage ----
lines.append(f"## 3. 全局开关状态覆盖")
lines.append(f"")
lines.append(f"| 检查项 | 状态 | 详情 |")
lines.append(f"|--------|------|------|")
for r in switch_results:
lines.append(f"| {r['check']} | {r['status']} | {r['detail']} |")
lines.append(f"")
# ---- Rule Summary ----
lines.append(f"## 4. 规则清单")
lines.append(f"")
lines.append(f"| rule_id | Priority | 简述 |")
lines.append(f"|---------|----------|------|")
for rule in rules:
desc = rule.get("description", "")[:80]
lines.append(f"| {rule.get('rule_id', '?')} | {rule.get('priority', '?')} | {desc} |")
lines.append(f"")
return "\n".join(lines)
def main():
print("=" * 60)
print("阶段三:确定性合并与完整性校验")
print("=" * 60)
# 1. Load inputs
print(f"\n[1/5] 加载输入...")
fragments = load_fragments()
doc = config.load_input_document()
semantic_index = load_semantic_index()
feature_name = semantic_index.get("feature_name", "行车娱乐限制")
feature_id = "DRL-001"
print(f" 功能: {feature_name} ({feature_id})")
print(f" 片段数: {len(fragments)}")
# 2. Merge rules
print(f"\n[2/5] 合并去重...")
merged_rules = merge_rules(fragments)
# 3. Reassign rule IDs
print(f"\n[3/5] 重分配 rule_id...")
final_rules = assign_rule_ids(merged_rules, feature_id)
print(f" 已分配 {len(final_rules)} 个稳定 ID")
# Collect top-level metadata
ir_final = {
"feature": feature_name,
"feature_id": feature_id,
"rules": final_rules,
}
# Save ir_final.json
print(f"\n[4/5] 生成审计报告...")
lt_results = audit_logic_tree_coverage(doc, final_rules)
enum_results = audit_table_enums(final_rules, doc)
switch_results = audit_switch_coverage(final_rules)
report = generate_audit_report(
final_rules, doc, feature_name,
lt_results, enum_results, switch_results
)
# 5. Save outputs
print(f"\n[5/5] 保存输出...")
config.save_json(ir_final, config.IR_FINAL_JSON)
print(f" IR: {config.IR_FINAL_JSON}")
with open(config.IR_AUDIT_REPORT_MD, "w", encoding="utf-8") as f:
f.write(report)
print(f" 审计报告: {config.IR_AUDIT_REPORT_MD}")
# Print quick summary
print(f"\n完成!")
issue_count = sum(
1 for r in lt_results + enum_results + switch_results
if r["status"] in (WARN, FAIL)
)
print(f" 规则: {len(final_rules)}")
print(f" 审计问题: {issue_count} 个需要人工审查")
if issue_count > 0:
print(f"\n 请查看 {config.IR_AUDIT_REPORT_MD} 并审查标记项。")
if __name__ == "__main__":
main()
+238
View File
@@ -0,0 +1,238 @@
"""
Tests for Stage 1 (Semantic Index).
Validates that the generated semantic_index.json meets all completeness
and structural requirements.
"""
import json
import sys
from pathlib import Path
# Allow running from project root or tests/ directory
sys.path.insert(0, str(Path(__file__).parent.parent))
import config
PASS = "[PASS]"
FAIL = "[FAIL]"
WARN = "[WARN]"
def load_inputs():
"""Load semantic_index.json and the original parsed document."""
try:
si = config.load_json(config.SEMANTIC_INDEX_JSON)
except FileNotFoundError:
print(f"{FAIL} semantic_index.json 未找到: {config.SEMANTIC_INDEX_JSON}")
print(" 请先运行 step1_semantic_index.py")
sys.exit(1)
doc = config.load_input_document()
return si, doc
def build_image_index(doc: dict) -> dict[str, dict]:
"""Build lookup: image rId -> image_analysis entry."""
idx = {}
for img in doc.get("image_analysis", []):
rid = img.get("rid", "")
if rid:
idx[rid] = img
return idx
def build_logic_tree_node_index(doc: dict) -> dict[str, set[str]]:
"""Build lookup: image rId -> set of all node IDs in that logic_tree."""
idx = {}
for img in doc.get("image_analysis", []):
rid = img.get("rid", "")
lt = img.get("logic_tree")
if lt and rid:
node_ids = {n["id"] for n in lt.get("nodes", [])}
idx[rid] = node_ids
return idx
def check_unit_ids(units: list[dict]) -> list[str]:
"""Check that every function_unit has a non-empty unit_id and name."""
errors = []
seen_ids = set()
for i, fu in enumerate(units):
uid = fu.get("unit_id", "")
name = fu.get("name", "")
if not uid:
errors.append(f"function_unit[{i}]: unit_id 为空")
elif uid in seen_ids:
errors.append(f"function_unit[{i}]: unit_id '{uid}' 重复")
seen_ids.add(uid)
if not name:
errors.append(f"function_unit[{i}] ({uid}): name 为空")
return errors
def check_sources_exist(
units: list[dict], image_index: dict[str, dict], node_index: dict[str, set[str]]
) -> list[str]:
"""Check that all source references point to real content."""
errors = []
for fu in units:
uid = fu.get("unit_id", "?")
sources = fu.get("sources", [])
if not sources:
errors.append(f"{uid}: sources 为空,必须至少引用一张图片或一段文字")
continue
has_text = False
has_image = False
for j, src in enumerate(sources):
src_type = src.get("type", "")
if src_type in ("table", "para"):
has_text = True
section = src.get("section", "")
if not section:
errors.append(f"{uid}.sources[{j}]: 缺少 section")
elif src_type == "logic_tree":
has_image = True
image_id = src.get("image_id", "")
if not image_id:
errors.append(f"{uid}.sources[{j}]: logic_tree 缺少 image_id")
continue
# Check image exists
if image_id not in image_index:
errors.append(
f"{uid}.sources[{j}]: image_id '{image_id}' "
f"在 image_analysis 中不存在"
)
continue
# Check logic_tree_nodes if provided
node_ids = src.get("logic_tree_nodes", [])
if node_ids and image_id in node_index:
valid_nodes = node_index[image_id]
for nid in node_ids:
if nid not in valid_nodes:
errors.append(
f"{uid}.sources[{j}]: 节点 '{nid}'"
f"{image_id} 的逻辑树中不存在"
)
elif not node_ids:
errors.append(
f"{uid}.sources[{j}]: logic_tree 类型但未提供 logic_tree_nodes"
)
if not has_text and not has_image:
errors.append(f"{uid}: 必须至少引用一个文本或图片来源")
return errors
def check_logic_tree_coverage(
units: list[dict], node_index: dict[str, set[str]]
) -> list[str]:
"""Check that decision and action nodes in logic trees are covered."""
warnings = []
for image_id, all_nodes in node_index.items():
# Collect all nodes referenced across all function_units for this image
referenced = set()
for fu in units:
for src in fu.get("sources", []):
if src.get("image_id") == image_id:
for nid in src.get("logic_tree_nodes", []):
referenced.add(nid)
uncovered = all_nodes - referenced
if uncovered:
# Get node types from the document
doc = config.load_input_document()
node_types = {}
for img in doc.get("image_analysis", []):
if img.get("rid") == image_id:
lt = img.get("logic_tree", {})
for n in lt.get("nodes", []):
node_types[n["id"]] = n.get("type", "?")
break
decision_action_uncovered = [
n for n in uncovered if node_types.get(n) in ("decision", "action")
]
if decision_action_uncovered:
warnings.append(
f"{image_id}: {len(decision_action_uncovered)}"
f"decision/action 节点未被引用: {decision_action_uncovered}"
)
return warnings
def run_all_tests():
print("=" * 60)
print("Step 1 自检测试")
print("=" * 60)
si, doc = load_inputs()
units = si.get("function_units", [])
image_index = build_image_index(doc)
node_index = build_logic_tree_node_index(doc)
all_errors = []
all_warnings = []
# Test 1: unit_id and name validity
errors = check_unit_ids(units)
if errors:
print(f"\n{FAIL} unit_id/name 检查: {len(errors)} 个错误")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} unit_id/name 检查: 全部通过 ({len(units)} 个功能单元)")
# Test 2: source references exist
errors = check_sources_exist(units, image_index, node_index)
if errors:
print(f"\n{FAIL} 来源引用检查: {len(errors)} 个错误")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 来源引用检查: 全部通过")
# Test 3: Logic tree coverage
warnings = check_logic_tree_coverage(units, node_index)
if warnings:
print(f"\n{WARN} 逻辑树节点覆盖率: {len(warnings)} 个警告")
for w in warnings:
print(f" - {w}")
all_warnings.extend(warnings)
else:
print(f"\n{PASS} 逻辑树节点覆盖率: 全部通过")
# Summary
print(f"\n{'='*60}")
total_failures = len(all_errors)
total_warnings = len(all_warnings)
if total_failures == 0 and total_warnings == 0:
print(f"{PASS} 所有测试通过!")
elif total_failures == 0:
print(f"{WARN} 全部通过但有 {total_warnings} 个警告")
else:
print(f"{FAIL} 测试失败: {total_failures} 个错误, {total_warnings} 个警告")
print("\n请检查 LLM 输出质量,可能需要调整 Prompt 并重新运行 step1_semantic_index.py")
print(f"\n统计:")
print(f" 功能单元数: {len(units)}")
print(f" 概念数: {len(si.get('concepts', []))}")
print(f" 逻辑树图片数: {len(node_index)}")
return total_failures == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)
+228
View File
@@ -0,0 +1,228 @@
"""
Tests for Stage 2 (IR Extraction).
Validates that ir_fragments.json meets quality and structural requirements:
- All fragments have non-empty rules
- All rules have source references with logic tree nodes
- All trigger conditions have signal/operator/value
- No duplicate rule_ids (across all fragments)
"""
import json
import sys
from pathlib import Path
from collections import Counter
sys.path.insert(0, str(Path(__file__).parent.parent))
import config
PASS = "[PASS]"
FAIL = "[FAIL]"
WARN = "[WARN]"
def load_fragments():
"""Load ir_fragments.json."""
try:
return config.load_json(config.IR_FRAGMENTS_JSON)
except FileNotFoundError:
print(f"{FAIL} ir_fragments.json 未找到: {config.IR_FRAGMENTS_JSON}")
print(" 请先运行 step2_ir_extraction.py")
sys.exit(1)
def check_non_empty_rules(fragments: list[dict]) -> list[str]:
"""Every fragment must have at least one rule."""
errors = []
for f in fragments:
uid = f.get("unit_id", "?")
rules = f.get("rules", [])
if not rules:
if f.get("error"):
errors.append(f"{uid}: 提取失败 — {f['error']}")
else:
errors.append(f"{uid}: rules 为空")
return errors
def check_sources_have_logic_tree_nodes(fragments: list[dict]) -> list[str]:
"""Every rule should reference at least one logic tree node in its sources."""
errors = []
for f in fragments:
uid = f.get("unit_id", "?")
for j, rule in enumerate(f.get("rules", [])):
rid = rule.get("rule_id", f"rule[{j}]")
sources = rule.get("sources", [])
has_logic_tree = any(
src.get("type") == "logic_tree" and src.get("node_ids")
for src in sources
)
# NOTE: Some rules might only reference text (e.g., switch-off rules),
# so we flag as warning rather than error
if not has_logic_tree:
has_text = any(
src.get("type") in ("table", "para") for src in sources
)
if not has_text:
errors.append(f"{rid}: sources 中既无逻辑树引用也无文字引用")
return errors
def check_trigger_conditions(fragments: list[dict]) -> list[str]:
"""Every trigger condition must have signal, operator, value."""
errors = []
for f in fragments:
uid = f.get("unit_id", "?")
for j, rule in enumerate(f.get("rules", [])):
rid = rule.get("rule_id", f"rule[{j}]")
trigger = rule.get("trigger", {})
conditions = trigger.get("conditions", [])
# Check if trigger uses 'event' instead of conditions
if trigger.get("event") is not None:
continue # event-based trigger is valid
for k, cond in enumerate(conditions):
signal = cond.get("signal", "")
operator = cond.get("operator", "")
# value can be 0, False, "", so check with 'in'
has_value = "value" in cond
if not signal:
errors.append(f"{rid}.condition[{k}]: 缺少 signal")
if not operator:
errors.append(f"{rid}.condition[{k}]: 缺少 operator")
if not has_value:
errors.append(f"{rid}.condition[{k}]: 缺少 value")
return errors
def check_duplicate_rule_ids(fragments: list[dict]) -> list[str]:
"""Check for duplicate rule_ids across all fragments."""
all_rule_ids = []
for f in fragments:
for rule in f.get("rules", []):
rid = rule.get("rule_id", "")
if rid:
all_rule_ids.append(rid)
duplicates = [rid for rid, count in Counter(all_rule_ids).items() if count > 1]
errors = []
if duplicates:
errors.append(f"重复 rule_id: {duplicates}")
return errors
def check_action_types(fragments: list[dict]) -> list[str]:
"""Verify that actions have valid types."""
valid_types = {"system", "user_interaction"}
errors = []
for f in fragments:
for j, rule in enumerate(f.get("rules", [])):
rid = rule.get("rule_id", f"rule[{j}]")
for k, action in enumerate(rule.get("actions", [])):
atype = action.get("type", "")
if atype not in valid_types:
errors.append(
f"{rid}.action[{k}]: type='{atype}' 无效, "
f"应为 {valid_types}"
)
if atype == "user_interaction" and "content" not in action:
errors.append(
f"{rid}.action[{k}]: user_interaction 类型缺少 content 字段"
)
return errors
def run_all_tests():
print("=" * 60)
print("Step 2 自检测试")
print("=" * 60)
fragments = load_fragments()
all_errors = []
total_units = len(fragments)
total_rules = sum(len(f.get("rules", [])) for f in fragments)
# Test 1: Non-empty rules
errors = check_non_empty_rules(fragments)
if errors:
print(f"\n{FAIL} 非空规则检查: {len(errors)} 个错误")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 非空规则检查: 全部通过 ({total_units} 个片段)")
# Test 2: Sources have logic tree references
errors = check_sources_have_logic_tree_nodes(fragments)
if errors:
print(f"\n{FAIL} 来源节点引用: {len(errors)} 个规则缺少来源引用")
for e in errors[:10]: # Show first 10
print(f" - {e}")
if len(errors) > 10:
print(f" ... 还有 {len(errors) - 10}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 来源节点引用: 全部通过")
# Test 3: Trigger conditions completeness
errors = check_trigger_conditions(fragments)
if errors:
print(f"\n{FAIL} 触发条件完整性: {len(errors)} 个条件不完整")
for e in errors[:10]:
print(f" - {e}")
if len(errors) > 10:
print(f" ... 还有 {len(errors) - 10}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 触发条件完整性: 全部通过")
# Test 4: No duplicate rule_ids
errors = check_duplicate_rule_ids(fragments)
if errors:
print(f"\n{FAIL} rule_id 唯一性: 发现重复")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} rule_id 唯一性: 全部通过")
# Test 5: Valid action types
errors = check_action_types(fragments)
if errors:
print(f"\n{FAIL} 动作类型检查: {len(errors)} 个问题")
for e in errors[:10]:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 动作类型检查: 全部通过")
# Summary
print(f"\n{'='*60}")
total_failures = len(all_errors)
if total_failures == 0:
print(f"{PASS} 所有测试通过!")
else:
print(f"{FAIL} 测试失败: {total_failures} 个错误")
print("\n建议:")
print(" 1. 检查 ir_fragments.json 中出错的规则")
print(" 2. 如果某些功能单元的规则为空,检查上下文包是否丢失了关键信息")
print(" 3. 调整 Promptprompts/step2_ir_extraction.txt)或上下文提取逻辑后重新运行")
print(f"\n统计:")
print(f" 功能单元数: {total_units}")
print(f" 规则总数: {total_rules}")
error_units = sum(1 for f in fragments if f.get("error"))
if error_units:
print(f" 提取失败的单元: {error_units}")
return total_failures == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)
+191
View File
@@ -0,0 +1,191 @@
"""
Tests for Stage 3 (Merge & Audit).
Validates:
- ir_final.json exists and is well-formed
- No duplicate rule_ids
- All rule_ids follow naming convention
- ir_audit_report.md exists and contains required sections
"""
import re
import sys
from pathlib import Path
from collections import Counter
sys.path.insert(0, str(Path(__file__).parent.parent))
import config
PASS = "[PASS]"
FAIL = "[FAIL]"
WARN = "[WARN]"
def load_ir_final():
"""Load ir_final.json."""
try:
return config.load_json(config.IR_FINAL_JSON)
except FileNotFoundError:
print(f"{FAIL} ir_final.json 未找到: {config.IR_FINAL_JSON}")
print(" 请先运行 step3_merge_and_audit.py")
sys.exit(1)
def load_audit_report():
"""Load ir_audit_report.md if it exists."""
try:
with open(config.IR_AUDIT_REPORT_MD, "r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
print(f"{FAIL} ir_audit_report.md 未找到: {config.IR_AUDIT_REPORT_MD}")
print(" 请先运行 step3_merge_and_audit.py")
sys.exit(1)
def check_rule_ids(ir: dict) -> list[str]:
"""Check for duplicate rule_ids and naming convention."""
errors = []
rules = ir.get("rules", [])
rule_ids = [r.get("rule_id", "") for r in rules]
# No duplicates
duplicates = [rid for rid, count in Counter(rule_ids).items() if count > 1]
if duplicates:
errors.append(f"重复 rule_id: {duplicates}")
# Naming convention: FEATURE-TYPE-CAT-NN
pattern = re.compile(r"^[A-Z]+-\d{3}-(SYS|UI|SDK)-FG-\d{2}$")
for rid in rule_ids:
if rid and not pattern.match(rid):
errors.append(f"rule_id 命名不规范: '{rid}' (期望格式: DRL-001-SYS-FG-01)")
return errors
def check_top_level_structure(ir: dict) -> list[str]:
"""Check that ir_final has the required top-level fields."""
errors = []
for field in ["feature", "feature_id", "rules"]:
if field not in ir:
errors.append(f"ir_final 缺少顶层字段: {field}")
if not isinstance(ir.get("rules"), list):
errors.append("ir_final.rules 必须是数组")
elif len(ir["rules"]) == 0:
errors.append("ir_final.rules 为空")
return errors
def check_rule_completeness(rules: list[dict]) -> list[str]:
"""Check each rule has all required fields."""
errors = []
required_fields = ["rule_id", "description", "priority", "sources", "trigger", "actions"]
for i, rule in enumerate(rules):
rid = rule.get("rule_id", f"rule[{i}]")
for field in required_fields:
if field not in rule:
errors.append(f"{rid}: 缺少字段 '{field}'")
# sources must be non-empty
if not rule.get("sources"):
errors.append(f"{rid}: sources 为空")
# actions must be non-empty
if not rule.get("actions"):
errors.append(f"{rid}: actions 为空")
return errors
def check_audit_report(report: str) -> list[str]:
"""Check audit report has required sections."""
errors = []
required_sections = [
"逻辑树节点覆盖率",
"表格枚举覆盖",
"开关状态",
]
for section in required_sections:
if section not in report:
errors.append(f"审计报告缺少章节: {section}")
# Should have at least one coverage percentage
if "覆盖率" not in report and "%" not in report:
errors.append("审计报告中未找到覆盖率统计")
# Should have the human review notice
if "人工审查" not in report:
errors.append("审计报告缺少人工审查提示")
return errors
def run_all_tests():
print("=" * 60)
print("Step 3 自检测试")
print("=" * 60)
ir = load_ir_final()
report = load_audit_report()
rules = ir.get("rules", [])
all_errors = []
# Test 1: Top-level structure
errors = check_top_level_structure(ir)
if errors:
print(f"\n{FAIL} 顶层结构检查: {len(errors)} 个错误")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 顶层结构检查: 通过 (feature={ir.get('feature')}, feature_id={ir.get('feature_id')})")
# Test 2: rule_id uniqueness and naming
errors = check_rule_ids(ir)
if errors:
print(f"\n{FAIL} rule_id 检查: {len(errors)} 个错误")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} rule_id 检查: 全部通过 ({len(rules)} 个唯一 ID)")
# Test 3: Rule field completeness
errors = check_rule_completeness(rules)
if errors:
print(f"\n{FAIL} 规则字段完整性: {len(errors)} 个错误")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 规则字段完整性: 全部通过")
# Test 4: Audit report content
errors = check_audit_report(report)
if errors:
print(f"\n{FAIL} 审计报告检查: {len(errors)} 个错误")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 审计报告检查: 全部通过")
# Summary
print(f"\n{'='*60}")
total_failures = len(all_errors)
if total_failures == 0:
print(f"{PASS} 所有测试通过!")
print(f"\n最终交付物:")
print(f" - {config.IR_FINAL_JSON} ({len(rules)} 条规则)")
print(f" - {config.IR_AUDIT_REPORT_MD}")
else:
print(f"{FAIL} 测试失败: {total_failures} 个错误")
print("\n建议: 检查 ir_fragments.json 和合并逻辑,修复问题后重新运行 step3_merge_and_audit.py")
return total_failures == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)
View File
View File
View File
+237
View File
@@ -0,0 +1,237 @@
# 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
+191
View File
@@ -0,0 +1,191 @@
# IR 生成、验证、Diff 接口
import asyncio
import json
import logging
from concurrent.futures import ThreadPoolExecutor
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from server.services.ir_engine.generator import ir_generator
from server.services.ir_engine.pipeline import ir_pipeline
from server.services.ir_engine.validator import ir_validator
from server.services.ir_engine.diff import ir_diff
from server.services.prd_manager.service import prd_service
logger = logging.getLogger(__name__)
router = APIRouter()
_pipeline_executor = ThreadPoolExecutor(max_workers=2)
@router.get("/generate-stream")
async def generate_ir_stream(prd_id: str = Query(...), skill_name: str = "default"):
"""基于 PRD 生成 IR,通过 SSE 流式返回进度"""
prd = await prd_service.get_prd(prd_id)
if not prd:
raise HTTPException(404, "PRD not found")
async def event_stream():
# Wait for background parsing if still in progress
if prd.get("status") == "parsing":
yield f"data: {json.dumps({'stage': 0, 'status': 'running', 'message': '文档解析中,请稍候...', 'detail': '图片分析可能需要 30-60 秒', 'stage_total': 4})}\n\n"
waited = 0
while waited < 120:
await asyncio.sleep(1)
waited += 1
p = await prd_service.get_prd(prd_id)
if not p or p.get("status") in ("ready", "error"):
break
if waited % 5 == 0:
yield f"data: {json.dumps({'stage': 0, 'status': 'running', 'message': f'文档解析中...({waited}s)', 'stage_total': 4})}\n\n"
p = await prd_service.get_prd(prd_id)
if p and p.get("status") == "error":
yield f"data: {json.dumps({'error': p.get('error', '解析失败'), 'stage_total': 4})}\n\n"
return
# Reload after parsing
prd2 = await prd_service.get_prd(prd_id)
parsed_doc = _load_parsed_doc(prd2 or prd)
if not parsed_doc:
yield f"data: {json.dumps({'error': '无法加载解析后的文档'})}\n\n"
return
yield f"data: {json.dumps({'stage': 0, 'status': 'done', 'message': '文档解析完成', 'stage_total': 4})}\n\n"
# Run pipeline in thread → Queue → SSE
queue: asyncio.Queue = asyncio.Queue()
def _run_in_thread():
try:
for event in ir_pipeline.run_streaming(prd_id, parsed_doc):
queue.put_nowait(event)
except Exception as e:
logger.exception("Pipeline error")
queue.put_nowait({"error": str(e), "done": True})
finally:
queue.put_nowait(None)
loop = asyncio.get_event_loop()
loop.run_in_executor(_pipeline_executor, _run_in_thread)
while True:
event = await queue.get()
if event is None:
break
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
yield "data: {\"done\": true}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
def _load_parsed_doc(prd: dict) -> dict | None:
"""Load parsed document from disk or build from PRD data."""
import os as _os
parsed_path = prd.get("parsed_path", "")
if parsed_path and _os.path.isfile(parsed_path):
try:
with open(parsed_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
sections = prd.get("sections", [])
if not sections and prd.get("full_text"):
sections = [{"source": "正文", "blocks": [{"type": "para", "index": 1, "text": prd["full_text"]}], "images": []}]
if sections or prd.get("full_text"):
return {
"source": "",
"sections": sections,
"image_sources": {},
"image_analysis": prd.get("images", []),
"resolved_conflicts": [],
}
return None
@router.post("/generate")
async def generate_ir(prd_id: str, skill_name: str = "default"):
"""基于 PRD 和 Skill 生成 IR"""
prd = await prd_service.get_prd(prd_id)
if not prd:
raise HTTPException(404, "PRD not found")
prd_text = prd.get("full_text", "")
if not prd_text:
raise HTTPException(400, "PRD has no text content")
result = await ir_generator.generate(prd_id, prd_text, skill_name)
if "error" in result:
raise HTTPException(500, result["error"])
return {
"ir_id": result["ir_id"],
"prd_id": result["prd_id"],
"yaml_content": result["yaml_content"],
"ir_json": result.get("ir_json", {}),
"audit": result.get("audit", {}),
"audit_report": result.get("audit_report", ""),
"skill_used": result["skill_used"],
"created_at": result["created_at"],
"pipeline_stats": result.get("pipeline_stats", {}),
}
@router.get("/{ir_id}")
async def get_ir(ir_id: str):
"""获取 IR 详情"""
ir = await ir_generator.get_ir(ir_id)
if not ir:
raise HTTPException(404, "IR not found")
return ir
@router.put("/{ir_id}")
async def update_ir(ir_id: str, body: dict):
"""更新 IR 内容(编辑后同步回后端)"""
ir = await ir_generator.get_ir(ir_id)
if not ir:
raise HTTPException(404, "IR not found")
new_content = body.get("yaml_content", "")
if new_content:
ir["yaml_content"] = new_content
try:
ir["ir_json"] = json.loads(new_content)
except json.JSONDecodeError:
pass
return {"ir_id": ir_id, "status": "updated"}
@router.post("/{ir_id}/validate")
async def validate_ir(ir_id: str):
"""验证 IR 是否符合 Schema 和 Principles"""
ir = await ir_generator.get_ir(ir_id)
if not ir:
raise HTTPException(404, "IR not found")
result = await ir_validator.validate(ir["yaml_content"], ir.get("skill_used", "default"))
return {
"ir_id": ir_id,
"valid": result["valid"],
"issues": result["issues"],
}
@router.get("/diff")
async def diff_ir(ir_id_a: str, ir_id_b: str):
"""对比两个 IR 版本的差异"""
result = await ir_diff.diff(ir_id_a, ir_id_b)
if "error" in result:
raise HTTPException(400, result["error"])
return result
+81
View File
@@ -0,0 +1,81 @@
# PRD 上传与解析接口
import logging
from concurrent.futures import ThreadPoolExecutor
from fastapi import APIRouter, UploadFile, File, HTTPException
from server.services.prd_manager.service import prd_service
logger = logging.getLogger(__name__)
router = APIRouter()
ALLOWED_EXTENSIONS = {".md", ".txt", ".docx", ".pdf"}
_parse_executor = ThreadPoolExecutor(max_workers=2)
@router.post("/upload")
async def upload_prd(file: UploadFile = File(...)):
"""上传 PRD 文件,立即返回 PRD ID,后台异步解析"""
ext = "." + file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(400, f"不支持的文件格式: {ext},支持: {', '.join(ALLOWED_EXTENSIONS)}")
content = await file.read()
if len(content) == 0:
raise HTTPException(400, "文件为空")
try:
# Quick: save and return immediately
prd_version = await prd_service.upload_quick(file.filename, content)
# Background: parse async (image analysis may take 60s+)
_parse_executor.submit(_parse_in_background, prd_version["prd_id"], file.filename, content)
return {
"prd_id": prd_version["prd_id"],
"filename": prd_version["filename"],
"status": "parsing",
"uploaded_at": prd_version["uploaded_at"],
}
except Exception as e:
logger.error("PRD upload failed: %s", e)
raise HTTPException(500, f"上传失败: {e}")
def _parse_in_background(prd_id: str, filename: str, content: bytes):
"""Run full parsing (text + images + vision analysis) in background thread."""
import asyncio
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(prd_service.parse_async(prd_id, filename, content))
except Exception as e:
logger.error("Background parse failed for %s: %s", prd_id, e)
prd_service.set_status(prd_id, "error", str(e))
@router.get("/{prd_id}/status")
async def get_prd_status(prd_id: str):
"""获取 PRD 解析状态"""
status = await prd_service.get_status(prd_id)
if not status:
raise HTTPException(404, "PRD not found")
return status
@router.get("/{prd_id}")
async def get_prd(prd_id: str):
"""获取 PRD 详情与版本"""
prd = await prd_service.get_prd(prd_id)
if not prd:
raise HTTPException(404, "PRD not found")
return {
"prd_id": prd["prd_id"],
"filename": prd["filename"],
"status": prd.get("status", "ready"),
"full_text": prd.get("full_text", ""),
"sections": prd.get("sections", []),
"images": prd.get("images", []),
"uploaded_at": prd["uploaded_at"],
}
+26
View File
@@ -0,0 +1,26 @@
# Skill 列表与热加载接口
import logging
from fastapi import APIRouter
from server.services.skill_manager.service import skill_manager
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("")
@router.get("/")
async def list_skills():
"""列出所有可用 Skill"""
skills = skill_manager.list_skills()
return {"skills": skills, "count": len(skills)}
@router.post("/reload")
async def reload_skills():
"""热重新加载 Skill(无需重启服务)"""
skill_manager.reload()
skills = skill_manager.list_skills()
return {"skills": skills, "count": len(skills), "message": "Skills reloaded"}
+47
View File
@@ -0,0 +1,47 @@
# 测试用例生成与导出接口
import logging
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from server.services.testcase_engine.generator import tc_generator
from server.services.testcase_engine.exporter import tc_exporter
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/generate")
async def generate_testcases(ir_id: str):
"""基于 IR 生成测试用例集"""
result = await tc_generator.generate(ir_id)
if "error" in result:
raise HTTPException(500, result["error"])
return {
"tc_set_id": result["tc_set_id"],
"ir_id": result["ir_id"],
"case_count": result["case_count"],
"cases": result["cases"],
"created_at": result["created_at"],
}
@router.get("/export/{format}")
async def export_testcases(tc_set_id: str, format: str):
"""导出测试用例为指定格式 (yaml/csv/json)"""
if format not in ("yaml", "csv", "json"):
raise HTTPException(400, f"Unsupported format: {format}")
try:
content, mime_type, filename = await tc_exporter.export(tc_set_id, format)
except ValueError as e:
raise HTTPException(404, str(e))
content_bytes = content.encode("utf-8") if isinstance(content, str) else content
return Response(
content=content_bytes,
media_type=mime_type,
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
+60
View File
@@ -0,0 +1,60 @@
# ZeekerWatchman - Configuration Management
import os
from pathlib import Path
from dotenv import load_dotenv
from pydantic_settings import BaseSettings
import logging
# Load .env into os.environ so non-prefixed keys (API keys) are also available
load_dotenv(Path(__file__).parent.parent / ".env")
# Configure testflow logger — used by all services
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s.%(msecs)03d %(levelname)-5s %(message)s",
datefmt="%H:%M:%S",
)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("openai").setLevel(logging.WARNING)
class Settings(BaseSettings):
APP_NAME: str = "ZeekerWatchman"
VERSION: str = "0.1.0"
DEBUG: bool = True
# Paths
ZEEKERWATCHMEN_DIR: Path = Path(__file__).parent.parent / ".zeekerwatchmen"
SKILLS_DIR: Path = ZEEKERWATCHMEN_DIR / "skills"
MEMORY_DIR: Path = ZEEKERWATCHMEN_DIR / "memory"
SOUL_DIR: Path = ZEEKERWATCHMEN_DIR / "soul"
OUTPUT_DIR: Path = Path(__file__).parent.parent / "output"
# LLM Configuration
LLM_PROVIDER: str = "deepseek"
TEXT_MODEL: str = "deepseek-chat"
IMAGE_MODEL: str = "qwen3-vl-plus"
USE_MOCK: bool = True # True=左侧流水线用Mock / False=真实LLM
CHAT_USE_MOCK: bool = False # True=右侧AI助手用Mock / False=真实LLM
# CORS
CORS_ORIGINS: list = ["http://localhost:3000"]
# Upload
MAX_UPLOAD_SIZE_MB: int = 10
class Config:
env_file = ".env"
env_prefix = "ZEEKER_"
extra = "ignore"
settings = Settings()
# API Keys loaded from env directly (no prefix)
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
DEEPSEEK_BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1")
DASHSCOPE_API_KEY = os.environ.get("DASHSCOPE_API_KEY", "")
DASHSCOPE_BASE_URL = os.environ.get("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
View File
+5
View File
@@ -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"]
+145
View File
@@ -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
+463
View File
@@ -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."
+67
View File
@@ -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()
View File
+41
View File
@@ -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. 如果信息不足,标注为"待确认"而非猜测
View File
+17
View File
@@ -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
+9
View File
@@ -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": [],
}
+71
View File
@@ -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
+29
View File
@@ -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""
+28
View File
@@ -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()
+33
View File
@@ -0,0 +1,33 @@
# ZeekerWatchman - FastAPI Application Entry Point
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from server.config import settings
from server.api.routes import prd, ir, testcase, skills, chat
app = FastAPI(
title="ZeekerWatchman",
description="测试用例智能管理平台 - 将 PRD 转化为可执行测试用例",
version="0.1.0",
redirect_slashes=False,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(prd.router, prefix="/api/prd", tags=["PRD"])
app.include_router(ir.router, prefix="/api/ir", tags=["IR"])
app.include_router(testcase.router, prefix="/api/testcase", tags=["Testcase"])
app.include_router(skills.router, prefix="/api/skills", tags=["Skills"])
app.include_router(chat.router, prefix="/api/chat", tags=["Chat"])
@app.get("/api/health")
async def health_check():
return {"status": "ok", "version": "0.1.0"}
+27
View File
@@ -0,0 +1,27 @@
# ZeekerWatchman - Python Backend Dependencies
# Web Framework
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
python-multipart>=0.0.9
# Config & Settings
pydantic>=2.0.0
pydantic-settings>=2.0.0
python-dotenv>=1.0.0
# LLM Client (OpenAI-compatible)
openai>=1.0.0
# YAML & Data
pyyaml>=6.0
jsonschema>=4.0.0
# Document Parsing
python-docx>=1.0.0
PyPDF2>=3.0.0
# Dev
pytest>=8.0.0
pytest-asyncio>=0.24.0
httpx>=0.27.0
View File
+46
View File
@@ -0,0 +1,46 @@
# IR 版本差异对比服务
import logging
import yaml
from server.core.utils.diff import compute_diff
from server.services.ir_engine.generator import ir_generator
logger = logging.getLogger(__name__)
class IRDiffService:
"""Compare two IR versions and return a structured diff."""
async def diff(self, ir_id_a: str, ir_id_b: str) -> dict:
"""Compute diff between two IR versions."""
ir_a = await ir_generator.get_ir(ir_id_a)
ir_b = await ir_generator.get_ir(ir_id_b)
if not ir_a or not ir_b:
return {"error": "One or both IR versions not found", "diff": {}}
yaml_a = ir_a.get("yaml_content", "")
yaml_b = ir_b.get("yaml_content", "")
try:
data_a = yaml.safe_load(yaml_a)
data_b = yaml.safe_load(yaml_b)
except yaml.YAMLError as e:
return {"error": f"YAML parsing error: {e}", "diff": {}}
diff_result = compute_diff(data_a or {}, data_b or {})
logger.info("IR diff: %d added, %d removed, %d modified",
len(diff_result.get("added", [])),
len(diff_result.get("removed", [])),
len(diff_result.get("modified", [])))
return {
"ir_id_a": ir_id_a,
"ir_id_b": ir_id_b,
"diff": diff_result,
}
ir_diff = IRDiffService()
+76
View File
@@ -0,0 +1,76 @@
# IR Generator — delegates to the 3-stage pipeline
import logging
from server.services.ir_engine.pipeline import ir_pipeline
from server.services.prd_manager.service import prd_service
logger = logging.getLogger(__name__)
class IRGenerator:
"""IR generator that runs the full 3-stage pipeline on parsed PRD documents."""
async def generate(self, prd_id: str, prd_text: str = "", skill_name: str = "default") -> dict:
"""Run the pipeline on a parsed PRD document."""
# Get the parsed document from the PRD service
prd = await prd_service.get_prd(prd_id)
if not prd:
return {"error": f"PRD not found: {prd_id}", "ir_id": ""}
# Fetch full parsed data from disk if available
parsed_doc = self._load_parsed(prd)
if not parsed_doc:
# Fallback: build minimal doc from PRD text
parsed_doc = {
"source": "",
"sections": [{"source": "正文", "blocks": [{"type": "para", "index": 1, "text": prd.get("full_text", "")}], "images": []}],
"image_sources": {},
"image_analysis": [],
"resolved_conflicts": [],
}
result = await ir_pipeline.run(prd_id, parsed_doc)
return {
"ir_id": result["ir_id"],
"prd_id": result["prd_id"],
"yaml_content": result["yaml_content"],
"ir_json": result["ir_json"],
"audit": result["audit"],
"audit_report": result["audit_report"],
"skill_used": skill_name,
"created_at": result["created_at"],
"pipeline_stats": result["pipeline_stats"],
}
async def get_ir(self, ir_id: str) -> dict | None:
return await ir_pipeline.get_ir(ir_id)
def _load_parsed(self, prd: dict) -> dict | None:
"""Load the full parsed JSON from disk if available."""
import json
parsed_path = prd.get("parsed_path", "")
if parsed_path:
try:
with open(parsed_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
# Build from in-memory data
if prd.get("sections") or prd.get("full_text"):
sections = prd.get("sections", [])
if not sections and prd.get("full_text"):
sections = [{"source": "正文", "blocks": [{"type": "para", "index": 1, "text": prd["full_text"]}], "images": []}]
return {
"source": "",
"sections": sections,
"image_sources": {},
"image_analysis": prd.get("images", []),
"resolved_conflicts": [],
}
return None
ir_generator = IRGenerator()
+753
View File
@@ -0,0 +1,753 @@
# 3-Stage IR Generation Pipeline
#
# Stage 1: Semantic Index — full document → function_units + concepts
# Stage 2: Per-Unit IR Extraction — precision context → detailed IR rules
# Stage 3: Deterministic Merge & Audit — dedup, assign IDs, coverage report
import concurrent.futures
import hashlib
import json
import logging
import re
import time
import uuid
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from server.config import settings
from server.core.llm_provider.router import router
logger = logging.getLogger(__name__)
PROMPTS_DIR = Path(__file__).parent.parent / "prompts" # services/prompts/
MAX_CONCURRENCY = 5
PASS, WARN, FAIL = "[PASS]", "[WARN]", "[FAIL]"
# =============================================================================
# Stage 1: Semantic Index Generation
# =============================================================================
def format_document_for_prompt(doc: dict) -> str:
"""Render the full parsed document as a readable string for the LLM prompt."""
lines = ["=== SECTIONS ==="]
for i, section in enumerate(doc.get("sections", [])):
source = section.get("source", f"(无标题-章节{i})")
lines.append(f"\n--- Section: {source} ---")
for block in section.get("blocks", []):
if block["type"] == "para":
lines.append(f"[段落 {block['index']}] {block['text']}")
elif block["type"] == "table":
lines.append(f"[表格 {block.get('table', '?')}]")
headers = block.get("headers", [])
lines.append(f" 表头: {' | '.join(headers)}")
for row in block.get("rows", []):
cols = row.get("columns", [])
cell_texts = []
for c in cols:
cell_texts.append(f"[行{c.get('row','?')}]{c.get('name','')}: {c.get('text','')}")
lines.append(f" {'; '.join(cell_texts)}")
images = section.get("images", [])
if images:
lines.append(f" 图片引用: {', '.join(images)}")
# Image Analysis
lines.append("\n\n=== IMAGE_ANALYSIS ===")
for img in doc.get("image_analysis", []):
rid = img.get("rid", "?")
img_type = img.get("type", "?")
lines.append(f"\n--- Image: {rid} (type={img_type}) ---")
lines.append(f" 描述: {img.get('description', '')[:500]}")
# Resolved Conflicts
conflicts = doc.get("resolved_conflicts", [])
if conflicts:
lines.append("\n\n=== RESOLVED_CONFLICTS ===")
for c in conflicts:
lines.append(
f" [{c.get('conflict_type','?')}] {c.get('section','?')}: "
f"{c.get('source','?')}为准 — {c.get('correction','')}"
)
return "\n".join(lines)
def extract_json_from_response(text: str, expect_array: bool = False) -> str:
"""Robustly extract JSON object or array from LLM response."""
open_char = "[" if expect_array else "{"
close_char = "]" if expect_array else "}"
# Try code fence first
pattern = rf"```(?:json)?\s*(\[{open_char}[\s\S]*?\{close_char}\])\s*```" if expect_array else rf"```(?:json)?\s*(\{{[\s\S]*?\}})\s*```"
m = re.search(pattern, text)
if m:
return m.group(1).strip()
# Find outermost bracket
start = text.find(open_char)
if start == -1:
raise ValueError(f"No JSON {'array' if expect_array else 'object'} found in LLM response")
depth = 0
for i in range(start, len(text)):
if text[i] == open_char:
depth += 1
elif text[i] == close_char:
depth -= 1
if depth == 0:
return text[start: i + 1]
raise ValueError(f"Unclosed JSON {'array' if expect_array else 'object'} in LLM response")
async def stage1_semantic_index(doc: dict) -> dict:
"""Stage 1: Generate semantic index from full document."""
logger.info("[PIPELINE] ═══ Stage 1/3: 语义索引 ═══")
template_path = PROMPTS_DIR / "ir_step1_semantic_index.txt"
if not template_path.exists():
logger.warning("[PIPELINE] Stage 1 prompt template not found, using fallback")
return _fallback_semantic_index(doc)
template = template_path.read_text(encoding="utf-8")
formatted_doc = format_document_for_prompt(doc)
prompt = template.replace("{document_json}", formatted_doc)
logger.info("[PIPELINE] Prompt: %d chars (~%d tokens)", len(prompt), len(prompt) // 3)
client = router.get_text_client()
for attempt in range(3):
try:
raw = client.chat(
model=router.text_model,
messages=[
{"role": "system", "content": "你是一个精确的 JSON 输出引擎。只输出合法的 JSON,不输出任何其他文字。"},
{"role": "user", "content": prompt},
],
temperature=0.1,
)
json_str = extract_json_from_response(raw)
result = json.loads(json_str)
# Validate
if "function_units" not in result or not result["function_units"]:
raise ValueError("Missing function_units")
logger.info("[PIPELINE] ✅ Stage 1 完成: %d function_units, %d concepts",
len(result["function_units"]), len(result.get("concepts", [])))
return result
except Exception as e:
logger.warning("[PIPELINE] Stage 1 attempt %d failed: %s", attempt + 1, e)
if attempt < 2:
time.sleep(2)
# Fallback
logger.warning("[PIPELINE] Stage 1 全部重试失败 exhausted, using fallback")
return _fallback_semantic_index(doc)
def _fallback_semantic_index(doc: dict) -> dict:
"""Generate a basic semantic index without LLM (for mock/demo)."""
units = []
unit_idx = 1
for section in doc.get("sections", []):
source = section.get("source", "")
for block in section.get("blocks", []):
if block["type"] == "para":
# Create a function unit from each substantial paragraph
text = block.get("text", "")
if len(text) > 30:
units.append({
"unit_id": f"FU-{unit_idx:03d}",
"name": text[:60],
"description": text[:200],
"sources": [{"section": source, "type": "para", "text_snippet": text[:200]}],
})
unit_idx += 1
elif block["type"] == "table":
for row in block.get("rows", []):
cols = row.get("columns", [])
texts = [f"{c.get('name','')}: {c.get('text','')}" for c in cols if c.get('text')]
if texts:
combined = "; ".join(texts)
units.append({
"unit_id": f"FU-{unit_idx:03d}",
"name": combined[:60],
"description": combined[:200],
"sources": [{"section": source, "type": "table", "row": row.get("columns", [{}])[0].get("row", 1) if cols else 1, "text_snippet": combined[:200]}],
})
unit_idx += 1
return {
"feature_name": doc.get("sections", [{}])[0].get("source", "未命名功能"),
"concepts": [],
"function_units": units,
}
# =============================================================================
# Stage 2: Per-Unit IR Extraction
# =============================================================================
def build_context_package(fu: dict, doc: dict, image_sources: dict) -> dict:
"""Build a precision context package for one function unit."""
texts = []
tables = []
images = []
seen_sections = set()
img_by_rid = {img.get("rid", ""): img for img in doc.get("image_analysis", [])}
for src in fu.get("sources", []):
section_key = src.get("section", "")
src_type = src.get("type", "")
if src_type in ("para", "table") and section_key:
if section_key in seen_sections:
continue
seen_sections.add(section_key)
# Find matching section
for section in doc.get("sections", []):
if section.get("source", "") == section_key:
for block in section.get("blocks", []):
if block["type"] == "para":
texts.append({
"section": section_key,
"text": block["text"],
})
elif block["type"] == "table":
all_rows = []
for row in block.get("rows", []):
for col in row.get("columns", []):
all_rows.append({
"row": col.get("row"),
"name": col.get("name"),
"text": col.get("text"),
})
# Trim: max 20 cells per table to reduce prompt size
tables.append({
"section": section_key,
"headers": block.get("headers", []),
"all_rows": all_rows[:20],
"_truncated": len(all_rows) > 20,
})
break
if src_type in ("image", "logic_tree"):
img_id = src.get("image_id", "")
if img_id and img_id in img_by_rid:
img = img_by_rid[img_id]
images.append({
"image_id": img_id,
"type": img.get("type", "other"),
"description": img.get("description", ""),
})
return {
"unit_id": fu["unit_id"],
"unit_name": fu.get("name", ""),
"unit_description": fu.get("description", ""),
"texts": texts,
"tables": tables,
"images": images,
"resolved_conflicts": doc.get("resolved_conflicts", []),
}
async def stage2_extract_rules(semantic_index: dict, doc: dict) -> list[dict]:
"""Stage 2: Extract detailed IR rules for each function unit."""
logger.info("=" * 50)
logger.info("[PIPELINE] ═══ Stage 2/3: Per-Unit IR Extraction")
template_path = PROMPTS_DIR / "ir_step2_extraction.txt"
if not template_path.exists():
return _fallback_rules(semantic_index)
template = template_path.read_text(encoding="utf-8")
function_units = semantic_index.get("function_units", [])
image_sources = doc.get("image_sources", {})
logger.info("Processing %d function units (max concurrency: %d)", len(function_units), MAX_CONCURRENCY)
# Build context packages
packages = []
for fu in function_units:
pkg = build_context_package(fu, doc, image_sources)
packages.append(pkg)
# Parallel LLM calls
fragments = []
client = router.get_text_client()
def extract_one(pkg: dict) -> dict:
"""Extract rules for one function unit."""
prompt = _build_stage2_prompt(template, pkg)
unit_id = pkg["unit_id"]
for attempt in range(3):
try:
raw = client.chat(
model=router.text_model,
messages=[
{"role": "system", "content": "你是一个精确的 JSON 输出引擎。只输出合法的 JSON 数组,不输出任何其他文字。"},
{"role": "user", "content": prompt},
],
temperature=0.1,
)
json_str = extract_json_from_response(raw, expect_array=True)
rules = json.loads(json_str)
if not isinstance(rules, list):
raise ValueError(f"Expected array, got {type(rules).__name__}")
return {"unit_id": unit_id, "unit_name": pkg["unit_name"], "rules": rules}
except Exception as e:
if attempt < 2:
time.sleep(2)
else:
logger.warning("Unit %s failed after 3 attempts: %s", unit_id, e)
return {"unit_id": unit_id, "unit_name": pkg["unit_name"], "rules": [], "error": str(e)}
return {"unit_id": unit_id, "unit_name": pkg["unit_name"], "rules": [], "error": "unknown"}
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_CONCURRENCY) as executor:
futures = {executor.submit(extract_one, pkg): pkg for pkg in packages}
for future in concurrent.futures.as_completed(futures):
result = future.result()
fragments.append(result)
n_rules = len(result.get("rules", []))
logger.info("[PIPELINE] %s: %d rules %s", result["unit_id"], n_rules,
"" if result.get("error") else "")
fragments.sort(key=lambda f: f["unit_id"])
total = sum(len(f.get("rules", [])) for f in fragments)
logger.info("[PIPELINE] ✅ Stage 2 完成: %d fragments, %d total rules", len(fragments), total)
return fragments
def _build_stage2_prompt(template: str, pkg: dict) -> str:
"""Render the stage 2 prompt template with context package data."""
# Simple str.replace (avoids format issues with curly braces in JSON)
prompt = template
prompt = prompt.replace("{unit_id}", pkg["unit_id"])
prompt = prompt.replace("{unit_name}", pkg["unit_name"])
prompt = prompt.replace("{unit_description}", pkg["unit_description"])
prompt = prompt.replace("{texts}", json.dumps(pkg.get("texts", []), ensure_ascii=False, indent=2))
prompt = prompt.replace("{tables}", json.dumps(pkg.get("tables", []), ensure_ascii=False, indent=2))
prompt = prompt.replace("{images}", json.dumps(pkg.get("images", []), ensure_ascii=False, indent=2))
prompt = prompt.replace("{resolved_conflicts}", json.dumps(pkg.get("resolved_conflicts", []), ensure_ascii=False, indent=2))
return prompt
def _fallback_rules(semantic_index: dict) -> list[dict]:
"""Generate basic rules without LLM."""
fragments = []
for fu in semantic_index.get("function_units", []):
fragments.append({
"unit_id": fu["unit_id"],
"unit_name": fu.get("name", ""),
"rules": [{
"description": fu.get("description", ""),
"priority": "P2",
"sources": fu.get("sources", []),
"precondition": {},
"trigger": {"operator": "AND", "conditions": []},
"actions": [],
}],
})
return fragments
# =============================================================================
# Stage 3: Merge & Audit
# =============================================================================
def rule_signature(rule: dict) -> str:
"""SHA256 signature from trigger + actions for dedup."""
trigger = rule.get("trigger", {})
actions = rule.get("actions", [])
conditions = sorted(trigger.get("conditions", []), key=lambda c: json.dumps(c, sort_keys=True))
sorted_actions = sorted(actions, key=lambda a: json.dumps(a, sort_keys=True))
sig = json.dumps({"conditions": conditions, "actions": sorted_actions}, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(sig.encode()).hexdigest()[:16]
def stage3_merge_and_audit(fragments: list[dict], semantic_index: dict, doc: dict) -> dict:
"""Stage 3: Merge rules, assign IDs, and generate audit report."""
logger.info("=" * 50)
logger.info("[PIPELINE] ═══ Stage 3/3: Merge & Audit")
# Merge and dedup
sig_map: dict[str, dict] = {}
order = []
for f in fragments:
for rule in f.get("rules", []):
sig = rule_signature(rule)
if sig in sig_map:
existing = sig_map[sig]
existing_sources = existing.setdefault("sources", [])
for src in rule.get("sources", []):
if src not in existing_sources:
existing_sources.append(src)
if len(rule.get("description", "")) > len(existing.get("description", "")):
existing["description"] = rule["description"]
else:
sig_map[sig] = dict(rule)
order.append(sig)
merged = [sig_map[sig] for sig in order]
logger.info("[PIPELINE] 合并: %d%d rules (dedup)", sum(len(f.get("rules", [])) for f in fragments), len(merged))
# Assign rule IDs: IR-{feature_id}-{section}-{seq}
section_counters = defaultdict(int)
feature_name = semantic_index.get("feature_name", "FEATURE")
feature_id = re.sub(r'[^A-Z]', '', feature_name.upper())[:6] or "FEAT"
if len(feature_id) < 2:
feature_id = (feature_id + "FEATURE")[:4]
for rule in merged:
# Extract section from first para/table source, fallback to "0"
section = "0"
for src in rule.get("sources", []):
s = src.get("section", "")
if s:
# Extract first number pattern like "4.2.1" or "3.1"
m = re.search(r'[\d]+(?:\.[\d]+)*', s)
if m:
section = m.group()
break
section_counters[section] += 1
rule["rule_id"] = f"IR-{feature_id}-{section}-{section_counters[section]:03d}"
# Build final IR
ir_final = {
"feature": feature_name,
"feature_id": feature_id,
"rules": merged,
"rule_count": len(merged),
"generated_at": datetime.now(timezone.utc).isoformat(),
}
# Generate audit report
audit_results = _generate_audit(merged, doc)
report = _format_audit_report(feature_name, len(merged), audit_results)
logger.info("[PIPELINE] ✅ Stage 3 完成: %d rules, %d audit issues", len(merged), audit_results.get("issue_count", 0))
return {"ir": ir_final, "audit": audit_results, "audit_report": report}
def _generate_audit(rules: list[dict], doc: dict) -> dict:
"""Run coverage audits and return structured results."""
results = []
# 1. Source coverage: check that all sections with content are referenced
all_sources = set()
for rule in rules:
for src in rule.get("sources", []):
section = src.get("section", "")
if section:
all_sources.add(section)
doc_sections = set()
for section in doc.get("sections", []):
source = section.get("source", "")
if source and section.get("blocks"):
doc_sections.add(source)
uncovered = doc_sections - all_sources
results.append({
"check": "文档章节覆盖",
"status": PASS if not uncovered else WARN,
"detail": f"已覆盖 {len(all_sources)}/{len(doc_sections)} 章节"
+ (f"; 未覆盖: {list(uncovered)[:5]}" if uncovered else ""),
})
# 2. Image coverage
img_count = len(doc.get("image_analysis", []))
img_referenced = set()
for rule in rules:
for src in rule.get("sources", []):
if src.get("type") in ("image", "logic_tree"):
img_referenced.add(src.get("image_id", ""))
results.append({
"check": "图片覆盖率",
"status": PASS if len(img_referenced) >= img_count else (WARN if img_referenced else FAIL),
"detail": f"已引用 {len(img_referenced)}/{img_count} 张图片",
})
# 3. Priority distribution
p0 = sum(1 for r in rules if r.get("priority") == "P0")
p1 = sum(1 for r in rules if r.get("priority") == "P1")
p2 = sum(1 for r in rules if r.get("priority") == "P2")
results.append({
"check": "优先级分布",
"status": PASS if p0 > 0 else WARN,
"detail": f"P0={p0}, P1={p1}, P2={p2}",
})
issue_count = sum(1 for r in results if r["status"] in (WARN, FAIL))
return {"checks": results, "issue_count": issue_count}
def _sync_call(async_fn, *args, **kwargs):
"""Run an async function synchronously using the existing event loop if available."""
import asyncio
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No event loop running — just use asyncio.run
return asyncio.run(async_fn(*args, **kwargs))
else:
# Event loop is running (e.g., inside FastAPI) — must use run_until_complete
# Create a new loop in a thread to avoid conflicts
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
future = ex.submit(asyncio.run, async_fn(*args, **kwargs))
return future.result()
def _format_audit_report(feature_name: str, rule_count: int, audit: dict) -> str:
"""Generate Markdown audit report."""
lines = [
f"# IR 完整性审计报告",
f"",
f"**功能**: {feature_name}",
f"**规则总数**: {rule_count}",
f"**生成时间**: {datetime.now(timezone.utc).isoformat()}",
f"",
f"> ⚠️ 请人工审查以下 ⚠️ 和 ❌ 项。",
f"",
f"## 审计结果",
f"",
f"| 检查项 | 状态 | 详情 |",
f"|--------|------|------|",
]
for r in audit.get("checks", []):
lines.append(f"| {r['check']} | {r['status']} | {r['detail']} |")
lines.append("")
lines.append(f"**审计问题总计**: {audit.get('issue_count', 0)} 个需要人工审查")
return "\n".join(lines)
# =============================================================================
# Pipeline Orchestrator
# =============================================================================
class IRPipeline:
"""Orchestrates the 3-stage IR generation pipeline."""
def __init__(self):
self._store: dict[str, dict] = {}
def run_streaming(self, prd_id: str, parsed_doc: dict):
"""Run pipeline as a sync generator yielding progress events.
This is a plain generator (NOT async) so it never blocks the event loop
between yields. The SSE endpoint wraps it in a thread + Queue bridge.
"""
t_total_start = time.time()
n_sections = len(parsed_doc.get("sections", []))
est_units = max(n_sections * 2, 3)
est_total = 20 + (est_units * 20 / 3) + 1
# --- Stage 1 ---
yield {"stage": 1, "status": "running", "message": "Stage 1: 分析文档结构,识别功能单元...",
"elapsed": 0, "stage_total": 3, "detail": f"文档含 {n_sections} 个章节"}
t0 = time.time()
try:
semantic_index = _sync_call(stage1_semantic_index, parsed_doc)
except Exception as e:
yield {"stage": 1, "status": "error", "message": f"Stage 1 失败: {e}", "stage_total": 3}
return
t1 = time.time()
n_units = len(semantic_index.get("function_units", []))
est_total = 20 + (max(n_units, 1) * 25 / 3) + 1
yield {"stage": 1, "status": "done", "message": f"Stage 1 完成: 识别 {n_units} 个功能单元",
"elapsed": round(t1 - t0, 1), "stage_total": 3, "detail": f"识别 {n_units} 个功能单元",
"n_units": n_units, "estimated_total": round(est_total)}
# --- Stage 2 ---
est_stage2 = max(n_units, 1) * 25 / min(MAX_CONCURRENCY, max(n_units, 1))
yield {"stage": 2, "status": "running", "message": f"Stage 2: 逐单元提取 IR 规则 ({n_units} 个单元)...",
"elapsed": 0, "stage_total": 3, "detail": f"预计 {est_stage2:.0f}s{n_units} 个单元 × {min(MAX_CONCURRENCY, n_units)} 并发",
"n_units": n_units, "estimated_total": round(est_total)}
t1b = time.time()
completed = 0
total_rules = 0
fragments = []
function_units = semantic_index.get("function_units", [])
# Optimization: filter out trivial units (very short desc, no text sources)
filtered_units = []
for fu in function_units:
sources = fu.get("sources", [])
desc = fu.get("description", "")
has_text = any(s.get("type") in ("para", "table") for s in sources)
if len(desc) < 15 and not has_text:
logger.info("[PIPELINE] 跳过空单元: %s", fu.get("unit_id", "?"))
continue
filtered_units.append(fu)
skipped = len(function_units) - len(filtered_units)
if skipped:
logger.info("[PIPELINE] Stage 2 过滤掉 %d 个空单元,剩余 %d", skipped, len(filtered_units))
function_units = filtered_units
image_sources = parsed_doc.get("image_sources", {})
template_path = PROMPTS_DIR / "ir_step2_extraction.txt"
template = template_path.read_text(encoding="utf-8") if template_path.exists() else ""
client = router.get_text_client()
packages = [build_context_package(fu, parsed_doc, image_sources) for fu in function_units]
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_CONCURRENCY) as executor:
futures = {}
for i, pkg in enumerate(packages):
future = executor.submit(self._extract_one_sync, template, pkg, client)
futures[future] = (i, pkg["unit_id"], pkg["unit_name"])
for future in concurrent.futures.as_completed(futures):
i, uid, uname = futures[future]
try:
rules = future.result()
fragments.append({"unit_id": uid, "unit_name": uname, "rules": rules})
total_rules += len(rules)
except Exception as e:
logger.warning("Unit %s failed: %s", uid, e)
fragments.append({"unit_id": uid, "unit_name": uname, "rules": [], "error": str(e)})
completed += 1
elapsed = round(time.time() - t1b, 1)
yield {"stage": 2, "status": "running",
"message": f"Stage 2: {completed}/{n_units} 单元完成 ({total_rules} 条规则)",
"elapsed": elapsed, "stage_total": 3,
"detail": f"已完成 {completed}/{n_units},累计 {total_rules} 条规则",
"completed": completed, "total": n_units, "rules_so_far": total_rules,
"estimated_total": round(est_total)}
fragments.sort(key=lambda f: f["unit_id"])
t2 = time.time()
logger.info("[PIPELINE] ✅ Stage 2 完成: %d fragments, %d total rules", len(fragments), total_rules)
yield {"stage": 2, "status": "done",
"message": f"Stage 2 完成: {len(fragments)} 个单元 → {total_rules} 条规则",
"elapsed": round(t2 - t1b, 1), "stage_total": 3,
"detail": f"{len(fragments)} 个单元 → {total_rules} 条规则",
"total_rules": total_rules}
# --- Stage 3 ---
yield {"stage": 3, "status": "running", "message": "Stage 3: 合并去重 + 审计报告...",
"elapsed": 0, "stage_total": 3, "detail": "去重 + 分配 ID + 生成审计报告"}
result = stage3_merge_and_audit(fragments, semantic_index, parsed_doc)
t3 = time.time()
n_rules = len(result["ir"]["rules"])
ir_id = str(uuid.uuid4())[:8]
output = {
"ir_id": ir_id, "prd_id": prd_id,
"yaml_content": json.dumps(result["ir"], ensure_ascii=False, indent=2),
"ir_json": result["ir"],
"audit": result["audit"],
"audit_report": result["audit_report"],
"skill_used": "default",
"created_at": datetime.now(timezone.utc).isoformat(),
"pipeline_stats": {
"stage1_seconds": round(t1 - t0, 1),
"stage2_seconds": round(t2 - t1b, 1),
"stage3_seconds": round(t3 - t2, 1),
"total_seconds": round(t3 - t_total_start, 1),
},
}
self._store[ir_id] = output
# Save to disk
output_dir = settings.OUTPUT_DIR / prd_id
output_dir.mkdir(parents=True, exist_ok=True)
with open(output_dir / f"ir_{ir_id}.json", "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2, default=str)
yield {"stage": 3, "status": "done", "message": f"流水线完成!共 {n_rules} 条规则",
"elapsed": round(t3 - t2, 1), "stage_total": 3,
"detail": f"合并后 {n_rules} 条规则,审计问题 {result['audit'].get('issue_count', 0)}",
"done": True, "result": output}
def _extract_one_sync(self, template: str, pkg: dict, client) -> list[dict]:
"""Synchronous rule extraction for one unit (runs in thread pool)."""
prompt = _build_stage2_prompt(template, pkg)
for attempt in range(3):
try:
raw = client.chat(
model=router.text_model,
messages=[
{"role": "system", "content": "你是一个精确的 JSON 输出引擎。只输出合法的 JSON 数组,不输出任何其他文字。"},
{"role": "user", "content": prompt},
],
temperature=0.1,
)
json_str = extract_json_from_response(raw, expect_array=True)
rules = json.loads(json_str)
if isinstance(rules, list):
return rules
except Exception as e:
if attempt == 2:
raise
time.sleep(2)
return []
async def run(self, prd_id: str, parsed_doc: dict) -> dict:
"""Run the full 3-stage pipeline and return the final IR + audit."""
logger.info("[PIPELINE] ═══ 流水线启动 for PRD %s", prd_id)
# Stage 1
t0 = time.time()
semantic_index = await stage1_semantic_index(parsed_doc)
t1 = time.time()
logger.info("[PIPELINE] Stage 1 耗时: %.1fs", t1 - t0)
# Stage 2
fragments = await stage2_extract_rules(semantic_index, parsed_doc)
t2 = time.time()
logger.info("[PIPELINE] Stage 2 耗时 %.1fs", t2 - t1)
# Stage 3
result = stage3_merge_and_audit(fragments, semantic_index, parsed_doc)
t3 = time.time()
logger.info("[PIPELINE] Stage 3 耗时 %.1fs", t3 - t2)
ir_id = str(uuid.uuid4())[:8]
output = {
"ir_id": ir_id,
"prd_id": prd_id,
"yaml_content": json.dumps(result["ir"], ensure_ascii=False, indent=2),
"ir_json": result["ir"],
"audit": result["audit"],
"audit_report": result["audit_report"],
"skill_used": "default",
"created_at": datetime.now(timezone.utc).isoformat(),
"pipeline_stats": {
"stage1_seconds": round(t1 - t0, 1),
"stage2_seconds": round(t2 - t1, 1),
"stage3_seconds": round(t3 - t2, 1),
"total_seconds": round(t3 - t0, 1),
},
}
self._store[ir_id] = output
# Save output
output_dir = settings.OUTPUT_DIR / prd_id
output_dir.mkdir(parents=True, exist_ok=True)
with open(output_dir / f"ir_{ir_id}.json", "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2, default=str)
logger.info("[PIPELINE] ✅ 流水线完成: ir_id=%s, rules=%d, total=%.1fs",
ir_id, len(result["ir"]["rules"]), t3 - t0)
return output
async def get_ir(self, ir_id: str) -> dict | None:
return self._store.get(ir_id)
ir_pipeline = IRPipeline()
+100
View File
@@ -0,0 +1,100 @@
# IR 验证器:Schema 校验 + Principles 规则检查
import json
import logging
import jsonschema
import yaml
from server.config import settings
from server.services.ir_engine.generator import ir_generator
logger = logging.getLogger(__name__)
class IRValidator:
"""Validate IR against skill schema and soul principles."""
async def validate(self, ir_yaml: str, skill_name: str = "default") -> dict:
"""Validate IR YAML content and return {valid, issues}."""
issues = []
# 1. Parse YAML
try:
ir_data = yaml.safe_load(ir_yaml)
except yaml.YAMLError as e:
return {"valid": False, "issues": [{
"severity": "error",
"message": f"YAML 解析失败: {e}",
}]}
if not isinstance(ir_data, dict):
return {"valid": False, "issues": [{
"severity": "error",
"message": "IR 内容必须是 YAML 字典/对象",
}]}
# 2. Schema validation
skill = ir_generator.load_skill(skill_name)
schema = skill.get("schema", {})
if schema:
try:
jsonschema.validate(instance=ir_data, schema=schema)
except jsonschema.ValidationError as e:
issues.append({
"severity": "error",
"message": f"Schema 校验失败: {e.message}",
"location": ".".join(str(p) for p in e.absolute_path),
})
# 3. Principles validation
principles = self._load_principles()
for principle in principles:
rule = principle.get("rule", "")
pid = principle.get("id", "")
# Check completeness: each feature must have both positive and negative paths
if pid == "completeness":
features = ir_data.get("features", [])
if not features:
issues.append({
"severity": "warning",
"message": f"原则 '{pid}': 未检测到功能点,无法验证完整性",
})
# Check traceability: features should have descriptions
if pid == "traceability":
features = ir_data.get("features", [])
for i, feat in enumerate(features):
if not feat.get("description"):
issues.append({
"severity": "warning",
"message": f"原则 '{pid}': 功能点 {feat.get('feature_name', f'#{i}')} 缺少描述",
})
# Check no-hallucination: warn about potentially vague features
if pid == "no-hallucination":
features = ir_data.get("features", [])
for feat in features:
desc = feat.get("description", "")
if "应该" in desc or "可能" in desc or "猜测" in desc:
issues.append({
"severity": "warning",
"message": f"原则 '{pid}': 功能点 '{feat.get('feature_name', '')}' 描述包含不确定措辞",
})
valid = len([i for i in issues if i["severity"] == "error"]) == 0
logger.info("IR validation: valid=%s, issues=%d", valid, len(issues))
return {"valid": valid, "issues": issues}
def _load_principles(self) -> list[dict]:
"""Load principles from .zeekerwatchmen/soul/principles.yaml."""
principles_path = settings.SOUL_DIR / "principles.yaml"
if not principles_path.exists():
return []
with open(principles_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
return data.get("principles", []) if data else []
ir_validator = IRValidator()
@@ -0,0 +1,85 @@
# Vision LLM wrapper for analyzing document images (type + description).
import logging
from typing import Optional
from server.core.llm_provider.router import router
logger = logging.getLogger(__name__)
PROMPT_IMAGE = """请分析这张图片,判断类型并输出文字描述。
## 判断图片类型
如果是 **流程图 / 架构图 / 状态图 / 时序图 / 活动图**,详细描述:
- 图中所有节点/步骤/状态/组件的名称
- 所有连线/箭头/转换关系及其方向
- 所有分支条件、判断逻辑和判断结果
- 所有文字标注、注释、标签
- 图的整体结构和逻辑流程
- 如果图片包含多个子图,拆解描述
如果是 **其他类型**(UI原型图 / 界面截图 / 设计稿 / 手机屏幕截图 / 网页截图等),简要描述图片内容。
## 输出格式
**1. 类型标签(单独一行):**
type: <flowchart|architecture|state|sequence|activity|other>
**2. 文字描述:**
该图片的详细文字描述。
不要输出 ---YAML--- 分隔符或 YAML 内容,不要添加任何额外的解释或问候语。"""
class ImageParser:
"""Analyze document images using Qwen VL model.
Usage::
parser = ImageParser()
result = parser.parse_image("images/flow.png")
# {"type": "flowchart", "description": "..."}
"""
_VALID_TYPES = {"flowchart", "architecture", "state", "sequence", "activity", "other"}
def __init__(self):
self._llm = router.get_image_client()
@property
def usage(self) -> dict:
return self._llm.usage
def parse_image(self, image_path: str) -> Optional[dict]:
"""Parse an image and return {type, description}."""
logger.info("Parsing image: %s", image_path)
try:
content = self._llm.chat_with_image(
model=router.image_model,
image_path=image_path,
prompt=PROMPT_IMAGE,
)
except Exception as e:
logger.error("Image parsing failed: %s", e)
return {"type": "other", "description": "", "error": str(e)}
return self._parse_response(content)
def _parse_response(self, content: str) -> dict:
"""Extract (type, description) from vision model response."""
content = content.strip()
parsed_type = "other"
desc_lines: list[str] = []
for line in content.splitlines():
stripped = line.strip()
if (stripped.startswith("type:") or stripped.startswith("类型:")):
type_val = stripped.split(":", 1)[1].strip().lower()
if type_val in self._VALID_TYPES:
parsed_type = type_val
else:
desc_lines.append(line)
return {"type": parsed_type, "description": "\n".join(desc_lines).strip()}
+260
View File
@@ -0,0 +1,260 @@
# PRD 管理服务:上传、解析、版本快照
import json
import logging
import os
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from server.config import settings
from server.services.prd_manager.word_parser import WordParser
from server.services.prd_manager.image_parser import ImageParser
logger = logging.getLogger("testflow")
RATE_LIMIT_DELAY = 0.5
class PRDService:
"""PRD management: handle file upload, text extraction, and version snapshots."""
def __init__(self):
self.output_dir = settings.OUTPUT_DIR
os.makedirs(self.output_dir, exist_ok=True)
self._prds: dict[str, dict] = {}
async def upload_and_parse(self, filename: str, content: bytes) -> dict:
prd_id = str(uuid.uuid4())[:8]
basename = Path(filename).stem
ext = Path(filename).suffix.lower()
upload_dir = self.output_dir / prd_id
os.makedirs(upload_dir, exist_ok=True)
filepath = upload_dir / filename
t0 = time.time()
with open(filepath, "wb") as f:
f.write(content)
logger.info("[UPLOAD] 收到文件: %s (%.1f KB, %s)", filename, len(content) / 1024, ext)
if ext in (".txt", ".md"):
text = content.decode("utf-8", errors="replace")
sections = self._parse_markdown_sections(text)
parsed = {
"source": str(filepath), "sections": sections,
"image_sources": {}, "image_analysis": [], "full_text": text,
}
logger.info("[PARSE] %.4s %s%d 章节, %d 字符, %.1fs",
prd_id, ext, len(sections), len(text), time.time() - t0)
elif ext == ".docx":
logger.info("[PARSE] %.4s → 开始解析 .docx...", prd_id)
parsed = await self._parse_docx(str(filepath), upload_dir)
logger.info("[PARSE] %.4s .docx 完成: %d 章节, %.1fs",
prd_id, len(parsed.get("sections", [])), time.time() - t0)
elif ext == ".pdf":
logger.info("[PARSE] %.4s → 开始解析 .pdf...", prd_id)
parsed = await self._parse_pdf(str(filepath), upload_dir)
logger.info("[PARSE] %.4s .pdf 完成: %d 页, %.1fs",
prd_id, len(parsed.get("sections", [])), time.time() - t0)
else:
raise ValueError(f"Unsupported file format: {ext}")
parsed_path = upload_dir / f"{basename}_parsed.json"
with open(parsed_path, "w", encoding="utf-8") as f:
json.dump(parsed, f, ensure_ascii=False, indent=2)
prd_version = {
"prd_id": prd_id, "filename": filename,
"uploaded_at": datetime.now(timezone.utc).isoformat(), "status": "ready",
"parsed_path": str(parsed_path), "full_text": parsed.get("full_text", ""),
"sections": parsed.get("sections", []), "images": parsed.get("image_analysis", []),
}
self._prds[prd_id] = prd_version
n_paras = sum(1 for s in parsed.get("sections", []) for b in s.get("blocks", []) if b["type"] == "para")
n_tables = sum(1 for s in parsed.get("sections", []) for b in s.get("blocks", []) if b["type"] == "table")
logger.info("[UPLOAD] %.4s 解析完成: %d章节 %d段落 %d表格 %d图片 → %s",
prd_id, len(parsed.get("sections", [])), n_paras, n_tables,
len(parsed.get("image_analysis", [])), parsed_path)
return prd_version
async def upload_quick(self, filename: str, content: bytes) -> dict:
"""Quick save — returns immediately, parsing happens in background."""
prd_id = str(uuid.uuid4())[:8]
upload_dir = self.output_dir / prd_id
os.makedirs(upload_dir, exist_ok=True)
filepath = upload_dir / filename
with open(filepath, "wb") as f:
f.write(content)
logger.info("[UPLOAD] %.4s 快速保存: %s (%.1f KB)", prd_id, filename, len(content) / 1024)
prd_version = {
"prd_id": prd_id, "filename": filename,
"uploaded_at": datetime.now(timezone.utc).isoformat(),
"status": "parsing", "full_text": "", "sections": [], "images": [],
"parsed_path": "", "filepath": str(filepath),
}
self._prds[prd_id] = prd_version
return prd_version
async def parse_async(self, prd_id: str, filename: str, content: bytes):
"""Full parse (text + images + vision) — called in background thread."""
basename = Path(filename).stem
ext = Path(filename).suffix.lower()
upload_dir = self.output_dir / prd_id
filepath = upload_dir / filename
t0 = time.time()
# Parse
if ext in (".txt", ".md"):
text = content.decode("utf-8", errors="replace")
sections = self._parse_markdown_sections(text)
parsed = {"source": str(filepath), "sections": sections,
"image_sources": {}, "image_analysis": [], "full_text": text}
elif ext == ".docx":
parsed = await self._parse_docx(str(filepath), upload_dir)
elif ext == ".pdf":
parsed = await self._parse_pdf(str(filepath), upload_dir)
else:
self.set_status(prd_id, "error", f"Unsupported format: {ext}")
return
# Save parsed
parsed_path = upload_dir / f"{basename}_parsed.json"
with open(parsed_path, "w", encoding="utf-8") as f:
json.dump(parsed, f, ensure_ascii=False, indent=2)
# Update in-memory
prd = self._prds.get(prd_id, {})
prd.update({
"status": "ready", "parsed_path": str(parsed_path),
"full_text": parsed.get("full_text", ""),
"sections": parsed.get("sections", []),
"images": parsed.get("image_analysis", []),
})
n_paras = sum(1 for s in parsed.get("sections", []) for b in s.get("blocks", []) if b["type"] == "para")
n_tables = sum(1 for s in parsed.get("sections", []) for b in s.get("blocks", []) if b["type"] == "table")
logger.info("[UPLOAD] %.4s 后台解析完成: %d章节 %d段落 %d表格 %d图片, %.1fs",
prd_id, len(parsed.get("sections", [])), n_paras, n_tables,
len(parsed.get("image_analysis", [])), time.time() - t0)
def set_status(self, prd_id: str, status: str, error: str = ""):
if prd_id in self._prds:
self._prds[prd_id]["status"] = status
if error:
self._prds[prd_id]["error"] = error
async def get_status(self, prd_id: str) -> dict | None:
prd = self._prds.get(prd_id)
if not prd:
return None
return {"prd_id": prd_id, "status": prd.get("status", "ready"),
"error": prd.get("error", ""),
"sections_count": len(prd.get("sections", [])),
"images_count": len(prd.get("images", []))}
async def get_prd(self, prd_id: str) -> dict | None:
return self._prds.get(prd_id)
# ---- internal parsers ----
async def _parse_docx(self, filepath: str, output_dir: Path) -> dict:
word = WordParser(filepath)
sections, image_sources = word.extract_sections()
full_text = word.extract_full_text()
images_dir = output_dir / "images"
images = word.extract_images(str(images_dir))
image_analysis = []
n_chart_images = 0
if images:
parser = ImageParser()
logger.info("[IMAGE] %.4s 发现 %d 张图片,开始视觉分析...", output_dir.parent.name, len(images))
for i, img in enumerate(images):
t_img = time.time()
logger.info("[IMAGE] [%d/%d] rid=%s, 调用 Qwen VL...", i + 1, len(images), img["rid"])
result = parser.parse_image(img["path"])
if result is None:
result = {"type": "other", "description": ""}
result["rid"] = img["rid"]
result["path"] = img["path"]
if img["rid"] in image_sources:
result["context"] = image_sources[img["rid"]]
image_analysis.append(result)
if result.get("type") in ("flowchart", "architecture", "state", "sequence", "activity"):
n_chart_images += 1
logger.info("[IMAGE] [%d/%d] rid=%s 完成,类型=%s (%.1fs)",
i + 1, len(images), img["rid"], result.get("type", "?"), time.time() - t_img)
if i < len(images) - 1:
time.sleep(RATE_LIMIT_DELAY)
usg = parser.usage
logger.info("[IMAGE] %.4s 图片分析完成: %d张, 图表类%d张, tokens(p=%d c=%d t=%d)",
output_dir.parent.name, len(images), n_chart_images,
usg["prompt_tokens"], usg["completion_tokens"], usg["total_tokens"])
else:
logger.info("[IMAGE] %.4s 文档无图片", output_dir.parent.name)
return {
"source": filepath, "sections": sections,
"image_sources": image_sources, "image_analysis": image_analysis,
"full_text": full_text,
}
def _parse_markdown_sections(self, text: str) -> list[dict]:
import re
sections: list[dict] = []
current_source = "正文"
current_blocks: list[dict] = []
para_idx = 0
for line in text.split("\n"):
stripped = line.strip()
if not stripped:
continue
heading_match = re.match(r"^(#{1,6})\s+(.+)", stripped)
if heading_match:
if current_blocks:
sections.append({"source": current_source, "blocks": current_blocks, "images": []})
current_blocks = []
level = len(heading_match.group(1))
current_source = f"{'#' * level} {heading_match.group(2)}"
continue
if "|" in stripped and stripped.count("|") >= 2:
cells = [c.strip() for c in stripped.split("|") if c.strip()]
if cells:
current_blocks.append({
"type": "table",
"table": len([b for b in current_blocks if b["type"] == "table"]) + 1,
"headers": cells,
"rows": [{"columns": [{"name": c, "row": 1, "col": i + 1, "text": c} for i, c in enumerate(cells)]}],
})
continue
para_idx += 1
current_blocks.append({"type": "para", "index": para_idx, "text": stripped})
if current_blocks:
sections.append({"source": current_source, "blocks": current_blocks, "images": []})
return sections
async def _parse_pdf(self, filepath: str, output_dir: Path) -> dict:
from PyPDF2 import PdfReader
reader = PdfReader(filepath)
full_text_parts = []
for i, page in enumerate(reader.pages):
text = page.extract_text()
if text:
full_text_parts.append(text)
full_text = "\n".join(full_text_parts)
sections = [{"source": "PDF 内容", "blocks": [{"type": "para", "index": i + 1, "text": t} for i, t in enumerate(full_text_parts)], "images": []}]
return {"source": filepath, "sections": sections, "image_sources": {}, "image_analysis": [], "full_text": full_text}
prd_service = PRDService()
+255
View File
@@ -0,0 +1,255 @@
# Microsoft Word (.docx) parser — extracts structured sections with typed blocks,
# image markers, and detailed location metadata for downstream IR generation.
import logging
import os
from docx import Document
from docx.table import Table
from docx.text.paragraph import Paragraph
logger = logging.getLogger(__name__)
IMAGE_EXT = {
"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif",
"image/bmp": ".bmp", "image/tiff": ".tiff", "image/webp": ".webp",
"image/x-emf": ".emf", "image/x-wmf": ".wmf", "image/svg+xml": ".svg",
}
HEADER_CELL_MAX_LEN = 20
WML_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
DRAW_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
class WordParser:
"""Parse a .docx file — extract images, split body into sections.
Sections contain typed blocks (para/table) with structured table columns
and IMAGE:rid markers, matching the reference document analyzer format.
Usage::
parser = WordParser("doc.docx")
parser.extract_images("images/")
sections, image_sources = parser.extract_sections()
"""
def __init__(self, docx_path: str):
if not os.path.isfile(docx_path):
raise FileNotFoundError(f"Document not found: {docx_path}")
self._doc = Document(docx_path)
# ---- public API ---------------------------------------------------------
def extract_images(self, images_dir: str) -> list[dict]:
"""Save all images to *images_dir*. Returns [{rid, path}, ...]."""
os.makedirs(images_dir, exist_ok=True)
images: list[dict] = []
for rel in self._doc.part.rels.values():
if "image" not in rel.reltype:
continue
ext = IMAGE_EXT.get(rel.target_part.content_type, ".png")
name = f"image_{rel.rId}{ext}"
path = os.path.join(images_dir, name)
with open(path, "wb") as f:
f.write(rel.target_part.blob)
images.append({"rid": rel.rId, "path": path})
return images
def extract_sections(self) -> tuple[list[dict], dict[str, dict]]:
"""Walk document body and split into sections by heading.
Returns:
*sections* — [{source, blocks, images}, ...]
Each block is {type, index, text} (paragraph) or
{type, table, headers, rows} (table with structured columns).
*image_sources* — rid → {section, table?, row?, column?, name?}
"""
sections: list[dict] = []
current_source = ""
blocks: list[dict] = []
section_images: list[str] = []
image_sources: dict[str, dict] = {}
para_idx = 0
tbl_idx = 0
for child in self._doc.element.body:
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
if tag == "p":
para = Paragraph(child, self._doc)
# Heading detection
if self._heading_level(para) is not None:
heading_text = para.text.strip()
if heading_text:
# Flush previous section
if blocks or section_images:
sections.append({
"source": current_source,
"blocks": blocks,
"images": list(section_images),
})
blocks = []
section_images = []
para_idx = 0
tbl_idx = 0
current_source = heading_text
continue
text = para.text.strip()
# Scan for inline images — append [[IMAGE:rid]] markers
for run in para.runs:
for rid in self._images_in(run._element):
text += f" [[IMAGE:{rid}]]"
section_images.append(rid)
image_sources[rid] = {"section": current_source}
if text.strip():
blocks.append({"type": "para", "index": para_idx + 1, "text": text.strip()})
para_idx += 1
elif tag == "tbl":
tbl_idx += 1
table = Table(child, self._doc)
# Collect all rows with cell text and embedded images
all_rows: list[list[str]] = []
all_images: list[list[list[str]]] = [] # row → col → [rids]
for row in table.rows:
row_texts: list[str] = []
row_cell_images: list[list[str]] = []
for cell in row.cells:
cell_text = cell.text.strip()
cell_imgs: list[str] = []
for cp in cell.paragraphs:
for run in cp.runs:
for rid in self._images_in(run._element):
cell_imgs.append(rid)
for rid in cell_imgs:
cell_text += f" [[IMAGE:{rid}]]"
section_images.append(rid)
row_texts.append(cell_text.strip())
row_cell_images.append(cell_imgs)
if any(row_texts) or any(row_cell_images):
all_rows.append(row_texts)
all_images.append(row_cell_images)
if len(all_rows) >= 2:
# Header heuristic: first row is header if all cells are short
first_row = all_rows[0]
has_header = all(len(c) < HEADER_CELL_MAX_LEN for c in first_row)
if has_header:
headers = first_row
data_rows_slice = zip(all_rows[1:], all_images[1:])
else:
headers = [f"{ci + 1}" for ci in range(len(first_row))]
data_rows_slice = zip(all_rows, all_images)
data_rows: list[dict] = []
for ri, (row_data, row_imgs) in enumerate(data_rows_slice):
columns: list[dict] = []
max_cols = max(len(headers), len(row_data))
for ci in range(max_cols):
hdr = headers[ci] if ci < len(headers) else ""
txt = row_data[ci] if ci < len(row_data) else ""
columns.append({
"name": hdr,
"row": ri + 1,
"col": ci + 1,
"text": txt,
})
# Register image sources with structured location
imgs = row_imgs[ci] if ci < len(row_imgs) else []
for rid in imgs:
image_sources[rid] = {
"section": current_source,
"table": tbl_idx,
"row": ri + 1,
"column": ci + 1,
"name": hdr,
}
data_rows.append({"columns": columns})
blocks.append({
"type": "table",
"table": tbl_idx,
"headers": headers,
"rows": data_rows,
})
elif all_rows:
# Degenerate table — treat as plain rows
for ri, row_data in enumerate(all_rows):
row_text = " | ".join(row_data)
if row_text.strip():
blocks.append({
"type": "para",
"index": para_idx + 1,
"text": row_text,
})
para_idx += 1
# Flush final section
if blocks or section_images:
sections.append({
"source": current_source,
"blocks": blocks,
"images": list(section_images),
})
return sections, image_sources
def extract_full_text(self) -> str:
"""Return all text content as a single string."""
lines = []
for para in self._doc.paragraphs:
text = para.text.strip()
if text:
if self._heading_level(para) is not None:
lines.append(f"\n## {text}\n")
else:
lines.append(text)
for table in self._doc.tables:
for row in table.rows:
lines.append(" | ".join(cell.text.strip() for cell in row.cells))
return "\n".join(lines)
# ---- internals ----------------------------------------------------------
def _heading_level(self, para: Paragraph) -> int | None:
"""Heading level 1-9, or None if not a heading."""
if para.style and para.style.name:
name = para.style.name
for prefix in ("Heading", "标题"):
if name.startswith(prefix):
try:
return int(name.split()[-1])
except (ValueError, IndexError):
pass
pPr = para._element.find(f"{{{WML_NS}}}pPr")
if pPr is not None:
ol = pPr.find(f"{{{WML_NS}}}outlineLvl")
if ol is not None:
val = ol.get(f"{{{WML_NS}}}val")
if val is not None:
try:
return int(val) + 1
except ValueError:
pass
return None
def _images_in(self, element) -> list[str]:
"""Return rId values for drawings embedded in *element*."""
rids: list[str] = []
for drawing in element.findall(f".//{{{WML_NS}}}drawing"):
blip = drawing.find(f".//{{{DRAW_NS}}}blip")
if blip is not None:
rid = blip.get(f"{{{REL_NS}}}embed")
if rid:
rids.append(rid)
return rids
+30
View File
@@ -0,0 +1,30 @@
{persona}
{principles}
## 你的能力
你运行在 ZeekerWatchman 平台中,是一个测试用例管理助手。你可以帮助用户:
1. 分析 PRD 文档,提取功能点
2. 审查和修改 IR(中间表示)
3. 生成和优化测试用例
4. 回答关于测试设计的问题
## 当前左侧面板状态
{page_context}
## 左侧数据
{data_context}
## 操作指令
{action_format}
### IR 规则 ID 格式
IR 规则 ID 格式为 `IR-{FEATURE}-{section}-{NNN}`,如 `IR-ZWM-4.2.1-001`
测试用例 ID 格式为 `TC-{FEATURE}-{NNN}`,如 `TC-ZWM-001`
### 操作格式
当需要让系统执行操作时,在回复末尾附加 action JSON 块:
```action
{{"action": "<action_name>", ...}}
```
每次最多 3 个操作。无操作时不输出。
@@ -0,0 +1,56 @@
你是一个产品需求分析师。你的任务是从 PRD 文档中提取"语义索引"——一份结构化的功能清单,而不是逐字翻译。
## 文档结构说明
下面是一份文档的解析结果,包含:
1. **sections**:按章节组织的混合内容(段落 + 表格),每个 section 有 `source`(章节标题)、`blocks``para` 文本段落和 `table` 结构表格)、`images`(引用的图片 ID 列表)
2. **image_analysis**:文档中图片的程序化分析结果,包含图片类型(flowchart/architecture/state/sequence/activity/other)和文字描述
3. **resolved_conflicts**:文档中图文冲突的仲裁结果(如有)
## 文档全文
{document_json}
## 你的任务
阅读整份文档后,输出一份 **语义索引 JSON**,包含:
### 1. feature_name
从文档中识别的主要功能名称
### 2. concepts
文档中定义或使用的关键概念列表。每个概念包含:
- `name`:概念的标准名称
- `aliases`:同义词/别名列表
- `defined_in`:定义该概念的章节号列表
### 3. function_units
文档中描述的所有主要功能行为的列表。**每个 function_unit 对应一条完整的"如果...则..."规则**。每个 function unit 包含:
- `unit_id`:唯一标识,格式 "FU-001", "FU-002"...
- `name`:简短名称
- `description`1-3 句描述该规则的行为
- `sources`:该规则在文档中的来源锚点列表,每项包含:
- `section`:章节号
- `type`:来源类型,"table"/"para"/"logic_tree"/"image"
- `row`:如果是表格行(从 1 开始)
- `text_snippet`:前 200 字的关键文字
- `image_id`:如果是图片来源,填写图片 rId
## 关键要求
1. **必须覆盖所有图片分析中的逻辑**:图片描述中的每个行为都要出现在某个 function_unit 中
2. **必须覆盖表格中的所有规则**:表格中列出的每条规则都要有对应的 function_unit
3. **区分不同的应用场景和状态**:不同前置条件/状态下的行为分别建模
4. **包含开关/状态切换**:功能开关"开启"和"关闭"两种状态下的行为都要覆盖
## 输出格式
**只输出 JSON,不要有 markdown 代码块标记或其他文字**:
{
"feature_name": "...",
"concepts": [ ... ],
"function_units": [ ... ]
}
@@ -0,0 +1,81 @@
你是一个需求分析专家。你的任务是基于给定的精准上下文包,为单个功能单元(Function Unit)提取详细的 **IR 规则(Intermediate Representation Rule**。
## 上下文
下面是一个功能单元的精准上下文包,包含了从原始需求文档中提取的相关文字、表格和逻辑:
### 功能单元概要
- **unit_id**: {unit_id}
- **unit_name**: {unit_name}
- **unit_description**: {unit_description}
### 相关文字段落
{texts}
### 相关表格
{tables}
### 相关图片分析
{images}
### 冲突仲裁(如有)
{resolved_conflicts}
## IR Schema
你需要为这个功能单元输出一个 **规则数组(rules)**。每条规则遵循以下 schema:
```json
{{
"description": "用完整的中文自然语言描述该规则的触发条件和行为,一句话概括",
"priority": "P0",
"sources": [
{{"type": "table", "section": "...", "row": 2, "text_snippet": "..."}},
{{"type": "image", "image_id": "rId16", "description": "..."}}
],
"precondition": {{
"app_type": "...",
"app_state": "..."
}},
"trigger": {{
"operator": "AND",
"conditions": [
{{"signal": "车速", "operator": ">=", "value": 15, "unit": "km/h"}},
{{"signal": "持续时间", "operator": ">", "value": 5, "unit": "秒"}}
]
}},
"actions": [
{{"type": "system", "description": "系统执行的自动动作"}},
{{"type": "user_interaction", "description": "对用户的提示", "content": "提示文案内容"}}
]
}}
```
### 字段说明
1. **description**: 完整但简洁地描述整个规则,包含前置条件、触发条件和所有动作
2. **priority**: P0(核心规则)、P1(重要规则)、P2(边界情况)
3. **sources**: 每条规则必须列出所有数据来源,包括引用的表格行和图片
4. **precondition**: 规则生效的前置状态条件。可以是空对象 `{{}}`
5. **trigger**: 触发条件对象:
- `operator`: "AND" 或 "OR"
- `conditions`: 条件数组,每个条件有 `signal`、`operator`、`value`,可选 `unit`
6. **actions**: 每个动作有 `type`"system"/"user_interaction")和 `description`。用户可见交互包含 `content` 字段
## 关键要求
1. **信号和数值必须精确**:不写"车速超过阈值",写 `"车速 >= 15 km/h"`
2. **条件必须完整**:文档中的所有触发条件都要出现在 trigger.conditions 中
3. **动作类型区分**:系统行为用 "system",用户可见交互用 "user_interaction"
4. **多条规则**:如果一个功能单元包含多个独立的行为分支,输出多条规则分别描述
## 输出格式
**只输出 JSON 数组,不要有任何其他文字或 markdown 标记**
[
{{ ... }},
{{ ... }}
]
即使只有一个规则,也必须用数组格式 `[...]`。
+108
View File
@@ -0,0 +1,108 @@
# Skill 管理服务:扫描、验证、列出所有可用 Skill
import json
import logging
from pathlib import Path
import yaml
from server.config import settings
logger = logging.getLogger("testflow")
REQUIRED_SKILL_FILES = ["skill.yaml", "ir_schema.json", "extract_ir_prompt.j2", "gen_cases_prompt.j2"]
class SkillManager:
"""Discover and validate skill packages from .zeekerwatchmen/skills/."""
def __init__(self):
self._skills_dir = settings.SKILLS_DIR
self._cache: dict[str, dict] = {}
self._scan()
def _scan(self):
"""Scan the skills directory and cache valid skills."""
self._cache = {}
if not self._skills_dir.exists():
logger.warning("[SKILL] s directory not found: %s", self._skills_dir)
return
for skill_dir in sorted(self._skills_dir.iterdir()):
if not skill_dir.is_dir():
continue
skill_name = skill_dir.name
skill = self._load_skill(skill_name)
if skill:
self._cache[skill_name] = skill
logger.info("[SKILL] 加载: %s (v%s)", skill_name, skill.get("version", "?"))
logger.info("[SKILL] 发现 %d skills: %s", len(self._cache), list(self._cache.keys()))
def _load_skill(self, skill_name: str) -> dict | None:
"""Load and validate a single skill package."""
skill_dir = self._skills_dir / skill_name
# Check required files
missing = []
for filename in REQUIRED_SKILL_FILES:
if not (skill_dir / filename).exists():
missing.append(filename)
if missing:
logger.warning("[SKILL] '%s' missing files: %s", skill_name, missing)
return None
# Load metadata
try:
with open(skill_dir / "skill.yaml", "r", encoding="utf-8") as f:
meta = yaml.safe_load(f) or {}
except yaml.YAMLError as e:
logger.error("[SKILL] '%s': invalid skill.yaml: %s", skill_name, e)
return None
# Load schema
try:
with open(skill_dir / "ir_schema.json", "r", encoding="utf-8") as f:
schema = json.load(f)
except (json.JSONDecodeError, IOError) as e:
logger.error("[SKILL] '%s': invalid ir_schema.json: %s", skill_name, e)
return None
return {
"name": skill_name,
"display_name": meta.get("display_name", skill_name),
"description": meta.get("description", ""),
"version": meta.get("version", "0.0.0"),
"domain": meta.get("domain", "general"),
"author": meta.get("author", ""),
"auto_match": meta.get("auto_match", {}),
"schema_fields": list(schema.get("properties", {}).get("features", {}).get("items", {}).get("properties", {}).keys()),
}
def list_skills(self) -> list[dict]:
"""Return metadata for all discovered skills."""
return [
{
"name": s["name"],
"display_name": s["display_name"],
"description": s["description"],
"version": s["version"],
"domain": s["domain"],
}
for s in self._cache.values()
]
def get_skill(self, name: str) -> dict | None:
"""Get a specific skill's full metadata."""
return self._cache.get(name)
def reload(self):
"""Re-scan the skills directory (for hot-reload)."""
logger.info("[SKILL] 热重载 skills...")
self._cache = {}
self._scan()
skill_manager = SkillManager()
@@ -0,0 +1,66 @@
# 用例导出服务:将用例集转换为 YAML / CSV 格式
import csv
import io
import logging
import yaml
from server.services.testcase_engine.generator import tc_generator
logger = logging.getLogger(__name__)
class TestCaseExporter:
"""Export test case sets to YAML, CSV, or structured data."""
async def export(self, tc_set_id: str, fmt: str) -> tuple[bytes | str, str]:
"""Export testcases in specified format. Returns (content, mime_type, filename)."""
tc_set = await tc_generator.get_tc_set(tc_set_id)
if not tc_set:
raise ValueError(f"Test case set not found: {tc_set_id}")
cases = tc_set.get("cases", [])
if fmt == "yaml":
return self._to_yaml(cases), "application/x-yaml", f"testcases_{tc_set_id}.yaml"
elif fmt == "csv":
return self._to_csv(cases), "text/csv", f"testcases_{tc_set_id}.csv"
elif fmt == "json":
import json
return json.dumps(tc_set, ensure_ascii=False, indent=2), "application/json", f"testcases_{tc_set_id}.json"
else:
raise ValueError(f"Unsupported export format: {fmt}")
def _to_yaml(self, cases: list[dict]) -> str:
"""Convert cases to YAML string."""
output = {
"testcases": cases,
"metadata": {
"total": len(cases),
"exported_format": "yaml",
},
}
return yaml.dump(output, allow_unicode=True, sort_keys=False, default_flow_style=False)
def _to_csv(self, cases: list[dict]) -> str:
"""Convert cases to CSV string."""
if not cases:
return ""
# Flatten tags for CSV
flattened = []
for c in cases:
row = {k: v for k, v in c.items() if k != "tags"}
row["tags"] = ";".join(c.get("tags", []))
flattened.append(row)
output = io.StringIO()
fieldnames = ["id", "module", "feature", "case_title", "preconditions", "steps", "expected_result", "priority", "tags"]
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(flattened)
return output.getvalue()
tc_exporter = TestCaseExporter()
@@ -0,0 +1,212 @@
# 测试用例生成器:基于 IR 生成测试用例集
import json
import logging
import uuid
from datetime import datetime, timezone
from server.config import settings
from server.core.llm_provider.router import router
from server.services.ir_engine.generator import ir_generator
logger = logging.getLogger("testflow")
DEFAULT_GEN_PROMPT = """你是一位资深的软件测试工程师。基于以下中间表示(IR),请生成完整的测试用例集。
测试设计原则:
1. 每个功能点至少覆盖一条正向用例和一条异常用例
2. 对于 P0 功能点,额外覆盖边界值测试
3. 用例步骤使用 Given-When-Then 结构
4. 预期结果必须明确可验证
输出格式:纯 JSON 数组,每个测试用例包含以下字段:
- id: 唯一标识符(字符串)
- module: 模块名
- feature: 功能点名称
- case_title: 用例标题
- preconditions: 前置条件
- steps: 测试步骤(Given-When-Then 格式)
- expected_result: 预期结果
- priority: 用例优先级 (P0/P1/P2)
- tags: 标签数组(如:["正向", "异常", "边界"]
## IR 内容:
{{ ir_content }}
请只输出 JSON 数组,不要用 ```json 代码块包裹。"""
class TestCaseGenerator:
"""Generate test case sets from IR YAML using LLM."""
def __init__(self):
self._tc_store: dict[str, dict] = {}
async def generate(self, ir_id: str, skill_name: str = "default") -> dict:
"""Generate test cases from an IR version."""
# Get IR content
ir_version = await ir_generator.get_ir(ir_id)
if not ir_version:
return {"error": f"IR version not found: {ir_id}", "cases": []}
# Handle both old YAML and new pipeline JSON formats
ir_content = ir_version.get("yaml_content", "")
ir_json = ir_version.get("ir_json")
# If we have the new pipeline format (rules array), convert to testcases directly
if ir_json and ir_json.get("rules"):
logger.info("[TC] 从 %d 条 IR 规则转换测试用例...", len(ir_json["rules"]))
cases = self._rules_to_cases(ir_json["rules"], ir_json.get("feature", ""))
tc_set_id = str(uuid.uuid4())[:8]
tc_set = self._store_tc(tc_set_id, ir_id, cases)
# Log distribution
p0 = sum(1 for c in cases if c.get("priority") == "P0")
p1 = sum(1 for c in cases if c.get("priority") == "P1")
logger.info("[TC] ✅ 生成 %d 条用例 (P0:%d P1:%d P2:%d)",
len(cases), p0, p1, len(cases) - p0 - p1)
return tc_set
# Fallback: use LLM with skill prompt
if not ir_content:
return {"error": "IR has no content", "cases": [], "tc_set_id": ""}
# Load skill prompt
skill = ir_generator.load_skill(skill_name)
prompt_template = skill.get("gen_cases_prompt", DEFAULT_GEN_PROMPT)
prompt = prompt_template.replace("{{ ir_content }}", ir_content)
logger.info("[TC] 调用 LLM 生成测试用例 for IR %s with skill %s", ir_id, skill_name)
client = router.get_text_client()
try:
raw = client.chat(
model=router.text_model,
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
)
except Exception as e:
logger.error("Test case generation failed: %s", e)
return {"error": str(e), "cases": [], "tc_set_id": ""}
# Parse JSON response
cases = self._parse_json(raw)
# Create test case set
tc_set_id = str(uuid.uuid4())[:8]
tc_set = {
"tc_set_id": tc_set_id,
"ir_id": ir_id,
"cases": cases,
"case_count": len(cases),
"created_at": datetime.now(timezone.utc).isoformat(),
}
self._tc_store[tc_set_id] = tc_set
# Save to output
output_dir = settings.OUTPUT_DIR / ir_version.get("prd_id", "unknown") / "tc_sets"
output_dir.mkdir(parents=True, exist_ok=True)
tc_path = output_dir / f"tc_{tc_set_id}.json"
with open(tc_path, "w", encoding="utf-8") as f:
json.dump(tc_set, f, ensure_ascii=False, indent=2)
usg = client.usage
logger.info("Test cases generated: id=%s, count=%d, tokens: prompt=%d completion=%d total=%d",
tc_set_id, len(cases), usg["prompt_tokens"], usg["completion_tokens"], usg["total_tokens"])
return tc_set
async def get_tc_set(self, tc_set_id: str) -> dict | None:
"""Get a test case set by ID."""
return self._tc_store.get(tc_set_id)
@staticmethod
def _parse_json(raw: str) -> list[dict]:
"""Parse JSON from LLM response, handling code fences."""
text = raw.strip()
if text.startswith("```json") or text.startswith("```"):
first_nl = text.find("\n")
if first_nl != -1:
text = text[first_nl + 1:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return parsed
if isinstance(parsed, dict) and "cases" in parsed:
return parsed["cases"]
return [parsed] if isinstance(parsed, dict) else []
except json.JSONDecodeError:
logger.warning("Failed to parse test case JSON, returning empty")
return []
def _rules_to_cases(self, rules: list[dict], feature: str) -> list[dict]:
"""Convert pipeline IR rules to test case format. TC IDs: TC-{feature_id}-{seq}"""
import re
feature_id = re.sub(r'[^A-Z]', '', feature.upper())[:6] or "FEAT"
if len(feature_id) < 2:
feature_id = (feature_id + "FEAT")[:4]
cases = []
for i, rule in enumerate(rules):
tc_id = f"TC-{feature_id}-{i+1:03d}"
desc = rule.get("description", "")
# Build steps string from trigger + actions
trigger = rule.get("trigger", {})
conditions = trigger.get("conditions", [])
cond_texts = []
for c in conditions:
unit = f" {c.get('unit', '')}" if c.get('unit') else ""
cond_texts.append(f"{c.get('signal','?')} {c.get('operator','?')} {c.get('value','?')}{unit}")
given = "Given " + (", ".join(cond_texts) if cond_texts else "触发条件满足")
actions = rule.get("actions", [])
action_texts = []
for a in actions:
if a.get("type") == "user_interaction":
action_texts.append(f"{a.get('description','')}('{a.get('content','')}')")
else:
action_texts.append(a.get("description", ""))
then = "Then " + ("; ".join(action_texts) if action_texts else "执行动作")
steps = f"{given}\nWhen 条件触发\n{then}"
# Determine tags
tags = ["正向"]
priority = rule.get("priority", "P2")
if priority == "P0":
tags.append("冒烟")
precond = rule.get("precondition", {})
if precond:
tags.append(f"{precond.get('app_type', '')} {precond.get('app_state', '')}".strip())
cases.append({
"id": tc_id,
"ir_rule_id": rule.get("rule_id", ""),
"module": feature,
"feature": rule.get("description", desc)[:40],
"case_title": desc[:60],
"preconditions": json.dumps(precond, ensure_ascii=False) if precond else "",
"steps": steps,
"expected_result": action_texts[-1] if action_texts else desc,
"priority": priority,
"tags": [t for t in tags if t],
})
return cases
def _store_tc(self, tc_set_id: str, ir_id: str, cases: list[dict]) -> dict:
"""Store a test case set and return its metadata."""
tc_set = {
"tc_set_id": tc_set_id,
"ir_id": ir_id,
"cases": cases,
"case_count": len(cases),
"created_at": datetime.now(timezone.utc).isoformat(),
}
self._tc_store[tc_set_id] = tc_set
return tc_set
tc_generator = TestCaseGenerator()
Executable
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
#
# ZeekerWatchman — 一键启动脚本
# 自动安装依赖、启动后端和前端服务
#
set -e
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
BACKEND_PORT=8765
FRONTEND_PORT=3000
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
cleanup() {
echo -e "\n${BLUE}正在停止服务...${NC}"
kill $BACKEND_PID 2>/dev/null || true
kill $FRONTEND_PID 2>/dev/null || true
echo -e "${GREEN}服务已停止${NC}"
exit 0
}
trap cleanup SIGINT SIGTERM
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} ZeekerWatchman — 测试用例智能管理平台${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# ---- 检查环境 ----
echo -e "${BLUE}[1/5] 检查运行环境...${NC}"
if ! command -v python3 &>/dev/null; then
echo -e "${RED}错误: 未找到 python3${NC}"
exit 1
fi
echo -e " Python: $(python3 --version)"
if ! command -v node &>/dev/null; then
echo -e "${RED}错误: 未找到 node${NC}"
exit 1
fi
echo -e " Node: $(node --version)"
echo -e " npm: $(npm --version)"
# ---- 安装 Python 依赖 ----
echo -e "\n${BLUE}[2/5] 安装 Python 依赖...${NC}"
cd "$ROOT_DIR"
pip install -q -r server/requirements.txt 2>&1 | tail -1 || {
echo -e "${RED}Python 依赖安装失败,请检查 server/requirements.txt${NC}"
exit 1
}
echo -e "${GREEN} Python 依赖就绪${NC}"
# ---- 安装前端依赖 ----
echo -e "\n${BLUE}[3/5] 安装前端依赖...${NC}"
cd "$ROOT_DIR/web"
if [ ! -d "node_modules" ]; then
npm install --silent 2>&1 | tail -1
fi
echo -e "${GREEN} 前端依赖就绪${NC}"
# ---- 检查端口 ----
echo -e "\n${BLUE}[4/5] 检查端口占用...${NC}"
if lsof -i:$BACKEND_PORT &>/dev/null 2>&1; then
echo -e " 端口 $BACKEND_PORT 被占用,尝试释放..."
kill $(lsof -t -i:$BACKEND_PORT) 2>/dev/null || true
sleep 1
fi
if lsof -i:$FRONTEND_PORT &>/dev/null 2>&1; then
echo -e " 端口 $FRONTEND_PORT 被占用,尝试释放..."
kill $(lsof -t -i:$FRONTEND_PORT) 2>/dev/null || true
sleep 1
fi
echo -e "${GREEN} 端口可用${NC}"
# ---- 启动服务 ----
echo -e "\n${BLUE}[5/5] 启动服务...${NC}"
# 后端
cd "$ROOT_DIR"
PYTHONPATH=. python -m uvicorn server.main:app \
--host 0.0.0.0 --port $BACKEND_PORT \
--log-level info \
> /tmp/testflow-backend.log 2>&1 &
BACKEND_PID=$!
echo -e " 后端 PID: $BACKEND_PID (端口 $BACKEND_PORT)"
# 等待后端就绪
for i in $(seq 1 15); do
if curl -s http://localhost:$BACKEND_PORT/api/health &>/dev/null; then
echo -e " ${GREEN}后端就绪 ✓${NC}"
break
fi
if [ $i -eq 15 ]; then
echo -e " ${RED}后端启动超时,查看日志: tail /tmp/testflow-backend.log${NC}"
cleanup
exit 1
fi
sleep 1
done
# 前端
cd "$ROOT_DIR/web"
npx next dev --port $FRONTEND_PORT --hostname 0.0.0.0 \
> /tmp/testflow-frontend.log 2>&1 &
FRONTEND_PID=$!
echo -e " 前端 PID: $FRONTEND_PID (端口 $FRONTEND_PORT)"
# 等待前端就绪
for i in $(seq 1 20); do
if curl -s -o /dev/null -w "%{http_code}" http://localhost:$FRONTEND_PORT 2>/dev/null | grep -q "200"; then
echo -e " ${GREEN}前端就绪 ✓${NC}"
break
fi
if [ $i -eq 20 ]; then
echo -e " ${RED}前端启动超时,查看日志: tail /tmp/testflow-frontend.log${NC}"
cleanup
exit 1
fi
sleep 2
done
# ---- 完成 ----
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} ZeekerWatchman 启动完成!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e " 前端页面: ${BLUE}http://localhost:$FRONTEND_PORT${NC}"
echo -e " 后端 API: ${BLUE}http://localhost:$BACKEND_PORT/api/health${NC}"
echo -e " API 文档: ${BLUE}http://localhost:$BACKEND_PORT/docs${NC}"
echo ""
echo -e " 后端日志: tail -f /tmp/testflow-backend.log"
echo -e " 前端日志: tail -f /tmp/testflow-frontend.log"
echo ""
echo -e "${RED}Ctrl+C${NC} 停止所有服务"
echo ""
# 保持前台运行
wait
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
+15
View File
@@ -0,0 +1,15 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://localhost:8765/api/:path*',
},
];
},
};
module.exports = nextConfig;
+2371
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "zeekerwatchman-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@monaco-editor/react": "^4.6.0",
"react-dropzone": "^14.2.0",
"zustand": "^5.0.0",
"react-hot-toast": "^2.4.0",
"react-virtuoso": "^4.10.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/node": "^22.0.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Some files were not shown because too many files have changed in this diff Show More