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