commit 7fc0e7852e6db0892e8d4b9eebd313ba554c6c14 Author: evyzacq Date: Mon May 25 15:09:42 2026 +0800 init the project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3091658 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.zeekerwatchmen/AGENT.md b/.zeekerwatchmen/AGENT.md new file mode 100644 index 0000000..e69de29 diff --git a/.zeekerwatchmen/memory/.gitkeep b/.zeekerwatchmen/memory/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.zeekerwatchmen/memory/chat_default.json b/.zeekerwatchmen/memory/chat_default.json new file mode 100644 index 0000000..a4863d9 --- /dev/null +++ b/.zeekerwatchmen/memory/chat_default.json @@ -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三个操作已输出,请按顺序执行。" + } +] \ No newline at end of file diff --git a/.zeekerwatchmen/memory/chat_sess_mpjtbj45.json b/.zeekerwatchmen/memory/chat_sess_mpjtbj45.json new file mode 100644 index 0000000..c66c865 --- /dev/null +++ b/.zeekerwatchmen/memory/chat_sess_mpjtbj45.json @@ -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: \"我的测试\"**" + } +] \ No newline at end of file diff --git a/.zeekerwatchmen/memory/chat_sess_mpjtsryu.json b/.zeekerwatchmen/memory/chat_sess_mpjtsryu.json new file mode 100644 index 0000000..2befe35 --- /dev/null +++ b/.zeekerwatchmen/memory/chat_sess_mpjtsryu.json @@ -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 需要调整请告知。" + } +] \ No newline at end of file diff --git a/.zeekerwatchmen/memory/chat_sess_mpkkdpmn.json b/.zeekerwatchmen/memory/chat_sess_mpkkdpmn.json new file mode 100644 index 0000000..394c32c --- /dev/null +++ b/.zeekerwatchmen/memory/chat_sess_mpkkdpmn.json @@ -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 规则的可追溯性。" + } +] \ No newline at end of file diff --git a/.zeekerwatchmen/skills/default/extract_ir_prompt.j2 b/.zeekerwatchmen/skills/default/extract_ir_prompt.j2 new file mode 100644 index 0000000..cf338ea --- /dev/null +++ b/.zeekerwatchmen/skills/default/extract_ir_prompt.j2 @@ -0,0 +1,16 @@ +你是一位资深的软件测试架构师,擅长从产品需求文档中提取测试要素。 + +请阅读以下 PRD 文本,提取所有功能点和测试要素,以 YAML 格式输出中间表示(IR)。 + +输出要求: +1. 严格遵循提供的 JSON Schema 结构 +2. 每个功能点包含:模块名、功能名称、功能描述、输入/输出、前置条件、约束条件 +3. 标注功能优先级(P0/P1/P2) +4. 识别功能之间的依赖关系 + +{% raw %} +PRD 文本: +{{ prd_text }} + +请输出 YAML 格式的 IR: +{% endraw %} diff --git a/.zeekerwatchmen/skills/default/gen_cases_prompt.j2 b/.zeekerwatchmen/skills/default/gen_cases_prompt.j2 new file mode 100644 index 0000000..379fccb --- /dev/null +++ b/.zeekerwatchmen/skills/default/gen_cases_prompt.j2 @@ -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 %} diff --git a/.zeekerwatchmen/skills/default/ir_schema.json b/.zeekerwatchmen/skills/default/ir_schema.json new file mode 100644 index 0000000..066cdf0 --- /dev/null +++ b/.zeekerwatchmen/skills/default/ir_schema.json @@ -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": "依赖的其他功能名称" + } + } + } + } + } +} diff --git a/.zeekerwatchmen/skills/default/skill.yaml b/.zeekerwatchmen/skills/default/skill.yaml new file mode 100644 index 0000000..adab0ff --- /dev/null +++ b/.zeekerwatchmen/skills/default/skill.yaml @@ -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" diff --git a/.zeekerwatchmen/skills/ecommerce/extract_ir_prompt.j2 b/.zeekerwatchmen/skills/ecommerce/extract_ir_prompt.j2 new file mode 100644 index 0000000..069f5cb --- /dev/null +++ b/.zeekerwatchmen/skills/ecommerce/extract_ir_prompt.j2 @@ -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: \ No newline at end of file diff --git a/.zeekerwatchmen/skills/ecommerce/gen_cases_prompt.j2 b/.zeekerwatchmen/skills/ecommerce/gen_cases_prompt.j2 new file mode 100644 index 0000000..22f19a5 --- /dev/null +++ b/.zeekerwatchmen/skills/ecommerce/gen_cases_prompt.j2 @@ -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 代码块包裹。 diff --git a/.zeekerwatchmen/skills/ecommerce/ir_schema.json b/.zeekerwatchmen/skills/ecommerce/ir_schema.json new file mode 100644 index 0000000..a70a900 --- /dev/null +++ b/.zeekerwatchmen/skills/ecommerce/ir_schema.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": "电商特有:数据一致性要求级别" + } + } + } + } + } +} diff --git a/.zeekerwatchmen/skills/ecommerce/skill.yaml b/.zeekerwatchmen/skills/ecommerce/skill.yaml new file mode 100644 index 0000000..bae21f4 --- /dev/null +++ b/.zeekerwatchmen/skills/ecommerce/skill.yaml @@ -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" diff --git a/.zeekerwatchmen/soul/persona.yaml b/.zeekerwatchmen/soul/persona.yaml new file mode 100644 index 0000000..107e920 --- /dev/null +++ b/.zeekerwatchmen/soul/persona.yaml @@ -0,0 +1,13 @@ +# Agent 人格定义 +persona: + name: "ZeekerWatchman" + role: "资深测试架构师" + experience: "15年软件测试经验,精通测试方法论" + tone: "专业、严谨、耐心" + traits: + - "擅长从 PRD 中识别隐含需求和边界场景" + - "习惯用结构化思维分解复杂功能" + - "偏好以数据血缘保证测试用例的可追溯性" + limitations: + - "不猜测 PRD 中未明确的功能" + - "不确定的信息会明确标注'待确认'" diff --git a/.zeekerwatchmen/soul/principles.yaml b/.zeekerwatchmen/soul/principles.yaml new file mode 100644 index 0000000..5bd72cc --- /dev/null +++ b/.zeekerwatchmen/soul/principles.yaml @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..fcef35b --- /dev/null +++ b/README.md @@ -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 对话 | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..a30ebdb --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..0e6bec0 --- /dev/null +++ b/docs/PRD.md @@ -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) → 输出访问地址。 diff --git a/docs/ir_schema.json b/docs/ir_schema.json new file mode 100644 index 0000000..9a0268a --- /dev/null +++ b/docs/ir_schema.json @@ -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" } + } + } + } +} diff --git a/docs/tc_schema.json b/docs/tc_schema.json new file mode 100644 index 0000000..ce63f20 --- /dev/null +++ b/docs/tc_schema.json @@ -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": [["正向", "冒烟"], ["异常", "安全"]] + } + } + } + } +} diff --git a/example_.env b/example_.env new file mode 100644 index 0000000..b679871 --- /dev/null +++ b/example_.env @@ -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 diff --git a/ref/document_analyzer_20260521/agents/AGENT.md b/ref/document_analyzer_20260521/agents/AGENT.md new file mode 100755 index 0000000..c4d1aaf --- /dev/null +++ b/ref/document_analyzer_20260521/agents/AGENT.md @@ -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)。产出:`_parsed.json`。 + +### 3. 分析 +识别关键元素和可能需要关注的区域。 + +### 4. 冲突解决 +运行 conflict_detection_skill,检测文本与图表之间的差异。如发现冲突,请求用户裁决后执行 resolution_application_skill。产出:`_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. 输出 +提供带追踪信息的完整分析结果,包括: +- 功能点清单总览 +- 冲突解决记录 +- 文档质量备注(如图片错误、占位符、不一致等) diff --git a/ref/document_analyzer_20260521/requirements.txt b/ref/document_analyzer_20260521/requirements.txt new file mode 100755 index 0000000..bab5fa6 --- /dev/null +++ b/ref/document_analyzer_20260521/requirements.txt @@ -0,0 +1,2 @@ +openai +python-docx diff --git a/ref/document_analyzer_20260521/skills/conflict_detection_skill/SKILL.md b/ref/document_analyzer_20260521/skills/conflict_detection_skill/SKILL.md new file mode 100755 index 0000000..a24c92e --- /dev/null +++ b/ref/document_analyzer_20260521/skills/conflict_detection_skill/SKILL.md @@ -0,0 +1,37 @@ +--- +name: 冲突检测技能 +description: 分析解析后的文档,检测图表类图像与其相应文本描述之间的矛盾和条件不匹配。 +--- + +# 冲突检测技能 + +## 概述 + +此技能识别解析文档中文本内容与视觉内容之间的潜在冲突。它特别针对图表类图像(流程图、架构图、状态图、序列图和活动图)并交叉检查其描述与同文档部分的文本内容。 + +## 功能 + +该技能: +- 从解析的文档结构中识别图表类图像 +- 将图像描述与同一文档部分中的相应文本内容进行交叉引用 +- 检测视觉表示和文本表示之间的矛盾和条件不匹配 +- 生成包含其位置的已识别冲突的结构化列表 +- 专门针对流程图、架构图、状态图、序列图和活动图 + +## 输入要求 + +- 解析文档JSON文件的路径(由文档解析技能生成) +- 可选输出目录规范 +- 可选试运行标志,在不调用API的情况下预览大语言模型提示 + +## 输出 + +该技能生成一个结构化JSON文件,文件名为输入文档的基本名称后跟'_conflicts.json',包含: +- 带有关于差异详情的冲突对象列表 +- 标识每个冲突发生位置的节标识符 +- 冲突图像和文本内容的片段 +- 每个冲突的类型分类(例如,矛盾、条件不匹配) + +## 集成点 + +此技能消耗文档解析技能的输出并为解决方案应用技能提供输入。冲突解决过程通常需要人工输入才能进入下一阶段。 \ No newline at end of file diff --git a/ref/document_analyzer_20260521/skills/conflict_detection_skill/scripts/LLM.py b/ref/document_analyzer_20260521/skills/conflict_detection_skill/scripts/LLM.py new file mode 100755 index 0000000..e6f2099 --- /dev/null +++ b/ref/document_analyzer_20260521/skills/conflict_detection_skill/scripts/LLM.py @@ -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 diff --git a/ref/document_analyzer_20260521/skills/conflict_detection_skill/scripts/detect_conflicts.py b/ref/document_analyzer_20260521/skills/conflict_detection_skill/scripts/detect_conflicts.py new file mode 100755 index 0000000..ddafd33 --- /dev/null +++ b/ref/document_analyzer_20260521/skills/conflict_detection_skill/scripts/detect_conflicts.py @@ -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: ``_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 ``_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) diff --git a/ref/document_analyzer_20260521/skills/doc_parser_skill/SKILL.md b/ref/document_analyzer_20260521/skills/doc_parser_skill/SKILL.md new file mode 100755 index 0000000..53329b8 --- /dev/null +++ b/ref/document_analyzer_20260521/skills/doc_parser_skill/SKILL.md @@ -0,0 +1,36 @@ +--- +name: 文档解析技能 +description: 解析文档(.docx, .pdf)以提取图像和文本结构,并使用视觉大语言模型分析每个图像的类型和描述。 +--- + +# 文档解析技能 + +## 概述 + +此技能从文档(.docx, .pdf)中提取内容并准备进行进一步分析。它提取文本内容和嵌入图像,并对图像执行初始分析以了解其类型和内容。 + +## 功能 + +该技能: +- 从文档中提取文本结构(段落、表格、标题) +- 识别并提取嵌入的图像 +- 使用视觉大语言模型分析每个图像并确定其类型和内容描述 +- 生成结构化输出,将图像映射到其在文档中的位置 +- 创建文档的初始解析表示,供后续处理阶段使用 + +## 输入要求 + +- 文档文件路径(必需,支持.docx和.pdf格式) +- 可选输出目录(默认为'output/') +- 可选试运行标志,在不调用API的情况下预览大语言模型提示 + +## 输出 + +该技能生成一个结构化JSON文件,文件名为输入文档的基本名称后跟'_parsed.json',包含: +- `sections`:按标题分组的文档文本结构 +- `image_sources`:从图像标识符到其在文档中位置的映射 +- `image_analysis`:由视觉大语言模型确定的每个图像的类型和内容描述 + +## 集成点 + +此技能作为文档分析管道中的初始处理步骤。其输出被冲突检测技能消费以识别文本和视觉内容之间的差异。 \ No newline at end of file diff --git a/ref/document_analyzer_20260521/skills/doc_parser_skill/scripts/LLM.py b/ref/document_analyzer_20260521/skills/doc_parser_skill/scripts/LLM.py new file mode 100755 index 0000000..e6f2099 --- /dev/null +++ b/ref/document_analyzer_20260521/skills/doc_parser_skill/scripts/LLM.py @@ -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 diff --git a/ref/document_analyzer_20260521/skills/doc_parser_skill/scripts/doc_parser.py b/ref/document_analyzer_20260521/skills/doc_parser_skill/scripts/doc_parser.py new file mode 100755 index 0000000..65b5454 --- /dev/null +++ b/ref/document_analyzer_20260521/skills/doc_parser_skill/scripts/doc_parser.py @@ -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 ``_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) diff --git a/ref/document_analyzer_20260521/skills/doc_parser_skill/scripts/image_parser.py b/ref/document_analyzer_20260521/skills/doc_parser_skill/scripts/image_parser.py new file mode 100755 index 0000000..443b7b4 --- /dev/null +++ b/ref/document_analyzer_20260521/skills/doc_parser_skill/scripts/image_parser.py @@ -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: + +**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") diff --git a/ref/document_analyzer_20260521/skills/doc_parser_skill/scripts/word_parser.py b/ref/document_analyzer_20260521/skills/doc_parser_skill/scripts/word_parser.py new file mode 100755 index 0000000..c0410df --- /dev/null +++ b/ref/document_analyzer_20260521/skills/doc_parser_skill/scripts/word_parser.py @@ -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 diff --git a/ref/document_analyzer_20260521/skills/ir_generation_skill/SKILL.md b/ref/document_analyzer_20260521/skills/ir_generation_skill/SKILL.md new file mode 100755 index 0000000..32a92c3 --- /dev/null +++ b/ref/document_analyzer_20260521/skills/ir_generation_skill/SKILL.md @@ -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`:执行动作(可选) + +## 使用提示 + +描述中应尽量包含功能的触发条件、执行动作和所属章节信息,以提高转换质量。 diff --git a/ref/document_analyzer_20260521/skills/ir_generation_skill/scripts/LLM.py b/ref/document_analyzer_20260521/skills/ir_generation_skill/scripts/LLM.py new file mode 100755 index 0000000..e6f2099 --- /dev/null +++ b/ref/document_analyzer_20260521/skills/ir_generation_skill/scripts/LLM.py @@ -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 diff --git a/ref/document_analyzer_20260521/skills/ir_generation_skill/scripts/add_ir.py b/ref/document_analyzer_20260521/skills/ir_generation_skill/scripts/add_ir.py new file mode 100755 index 0000000..200ea91 --- /dev/null +++ b/ref/document_analyzer_20260521/skills/ir_generation_skill/scripts/add_ir.py @@ -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/_ir.json + python scripts/add_missing.py -f missing.txt --ir output/_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) diff --git a/ref/document_analyzer_20260521/skills/resolution_application_skill/SKILL.md b/ref/document_analyzer_20260521/skills/resolution_application_skill/SKILL.md new file mode 100755 index 0000000..59f0799 --- /dev/null +++ b/ref/document_analyzer_20260521/skills/resolution_application_skill/SKILL.md @@ -0,0 +1,42 @@ +--- +name: 解决方案应用技能 +description: 应用用户提供的冲突解决方案以创建合并了更正的更新文档表示。 +--- + +# 解决方案应用技能 + +## 概述 + +此技能采用用户提供的冲突解决方案并将它们应用到解析的文档中,创建一个协调文本和视觉内容差异的校正版本。它生成包含应用更正的更新文档表示。 + +## 功能 + +该技能: +- 接受用户提供的冲突解决方案决策 +- 支持多种解决方案类型:"以图像为准"、"以文字为准"、"两处都保留"或自定义文本 +- 更新解析的文档结构以合并解决方案决策 +- 创建文档表示的校正版本,包含应用的更改 +- 维护所有应用更正的源可追溯性 +- 向输出添加包含更正指令的resolved_conflicts数组 + +## 输入要求 + +- 解析文档JSON文件的路径(带有已识别的冲突) +- 包含用户决策的解决方案JSON文件的路径 +- 可选输出目录规范 +- 解决方案JSON应包含具有以下内容的对象: + - `conflict_id`:冲突数组中的冲突索引 + - `resolution`:决策类型("以图片为准"、"以文字为准"、"两处都保留")或自定义文本 + - `custom_text`:解决方案的可选自定义文本 + +## 输出 + +该技能生成一个结构化JSON文件,文件名为输入文档的基本名称后跟'_updated.json',包含: +- 包含应用更正的原始文档结构 +- 详细说明应用更改的`resolved_conflicts`数组 +- 关于每个冲突类型和应用更正的信息 +- 用于可追溯性的源跟踪信息 + +## 集成点 + +此技能消耗冲突检测技能的输出(带冲突的文档)和用户提供的解决方案。其输出被IR生成技能使用以创建最终结构化表示。 \ No newline at end of file diff --git a/ref/document_analyzer_20260521/skills/resolution_application_skill/scripts/apply_resolutions.py b/ref/document_analyzer_20260521/skills/resolution_application_skill/scripts/apply_resolutions.py new file mode 100755 index 0000000..2f8a9dc --- /dev/null +++ b/ref/document_analyzer_20260521/skills/resolution_application_skill/scripts/apply_resolutions.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Apply user resolutions to ``_parsed.json`` using ``_conflicts.json``. + +Usage:: + + python scripts/apply_resolutions.py --resolutions [--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 ``_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) diff --git a/ref/ir_generation_new_skill/IR_Generation_Task.md b/ref/ir_generation_new_skill/IR_Generation_Task.md new file mode 100755 index 0000000..dd873f2 --- /dev/null +++ b/ref/ir_generation_new_skill/IR_Generation_Task.md @@ -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 中完成所有工作。 + +现在开始执行。首先请通读输入文件(路径已给出),理解其结构,然后着手实现阶段一。 \ No newline at end of file diff --git a/ref/ir_generation_new_skill/config.py b/ref/ir_generation_new_skill/config.py new file mode 100755 index 0000000..64ac4f4 --- /dev/null +++ b/ref/ir_generation_new_skill/config.py @@ -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) diff --git a/ref/ir_generation_new_skill/main.py b/ref/ir_generation_new_skill/main.py new file mode 100755 index 0000000..74584c4 --- /dev/null +++ b/ref/ir_generation_new_skill/main.py @@ -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() diff --git a/ref/ir_generation_new_skill/prompts/step1_semantic_index.txt b/ref/ir_generation_new_skill/prompts/step1_semantic_index.txt new file mode 100755 index 0000000..3167a3a --- /dev/null +++ b/ref/ir_generation_new_skill/prompts/step1_semantic_index.txt @@ -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": [ ... ] +} diff --git a/ref/ir_generation_new_skill/prompts/step2_ir_extraction.txt b/ref/ir_generation_new_skill/prompts/step2_ir_extraction.txt new file mode 100755 index 0000000..2df6e89 --- /dev/null +++ b/ref/ir_generation_new_skill/prompts/step2_ir_extraction.txt @@ -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 标记**: + +[ + {{ ... }}, + {{ ... }} +] + +注意:即使只有一个规则,也必须用数组格式 `[...]`。 diff --git a/ref/ir_generation_new_skill/step1_semantic_index.py b/ref/ir_generation_new_skill/step1_semantic_index.py new file mode 100755 index 0000000..8b6ac71 --- /dev/null +++ b/ref/ir_generation_new_skill/step1_semantic_index.py @@ -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() diff --git a/ref/ir_generation_new_skill/step2_ir_extraction.py b/ref/ir_generation_new_skill/step2_ir_extraction.py new file mode 100755 index 0000000..c662c67 --- /dev/null +++ b/ref/ir_generation_new_skill/step2_ir_extraction.py @@ -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() diff --git a/ref/ir_generation_new_skill/step3_merge_and_audit.py b/ref/ir_generation_new_skill/step3_merge_and_audit.py new file mode 100755 index 0000000..2d48661 --- /dev/null +++ b/ref/ir_generation_new_skill/step3_merge_and_audit.py @@ -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() diff --git a/ref/ir_generation_new_skill/tests/test_step1.py b/ref/ir_generation_new_skill/tests/test_step1.py new file mode 100755 index 0000000..4508781 --- /dev/null +++ b/ref/ir_generation_new_skill/tests/test_step1.py @@ -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) diff --git a/ref/ir_generation_new_skill/tests/test_step2.py b/ref/ir_generation_new_skill/tests/test_step2.py new file mode 100755 index 0000000..acf5c48 --- /dev/null +++ b/ref/ir_generation_new_skill/tests/test_step2.py @@ -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) diff --git a/ref/ir_generation_new_skill/tests/test_step3.py b/ref/ir_generation_new_skill/tests/test_step3.py new file mode 100755 index 0000000..36da165 --- /dev/null +++ b/ref/ir_generation_new_skill/tests/test_step3.py @@ -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) diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/api/__init__.py b/server/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/api/routes/__init__.py b/server/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/api/routes/chat.py b/server/api/routes/chat.py new file mode 100644 index 0000000..4a81f31 --- /dev/null +++ b/server/api/routes/chat.py @@ -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": ""}'] + + 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 diff --git a/server/api/routes/ir.py b/server/api/routes/ir.py new file mode 100644 index 0000000..e5ab63c --- /dev/null +++ b/server/api/routes/ir.py @@ -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 diff --git a/server/api/routes/prd.py b/server/api/routes/prd.py new file mode 100644 index 0000000..93278d2 --- /dev/null +++ b/server/api/routes/prd.py @@ -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"], + } diff --git a/server/api/routes/skills.py b/server/api/routes/skills.py new file mode 100644 index 0000000..37775d0 --- /dev/null +++ b/server/api/routes/skills.py @@ -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"} diff --git a/server/api/routes/testcase.py b/server/api/routes/testcase.py new file mode 100644 index 0000000..4cebe05 --- /dev/null +++ b/server/api/routes/testcase.py @@ -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}"}, + ) diff --git a/server/config.py b/server/config.py new file mode 100644 index 0000000..cedde25 --- /dev/null +++ b/server/config.py @@ -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") diff --git a/server/core/__init__.py b/server/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/core/llm_provider/__init__.py b/server/core/llm_provider/__init__.py new file mode 100644 index 0000000..7399e54 --- /dev/null +++ b/server/core/llm_provider/__init__.py @@ -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"] diff --git a/server/core/llm_provider/base.py b/server/core/llm_provider/base.py new file mode 100644 index 0000000..d3e4346 --- /dev/null +++ b/server/core/llm_provider/base.py @@ -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 diff --git a/server/core/llm_provider/mock_client.py b/server/core/llm_provider/mock_client.py new file mode 100644 index 0000000..07e988b --- /dev/null +++ b/server/core/llm_provider/mock_client.py @@ -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." diff --git a/server/core/llm_provider/router.py b/server/core/llm_provider/router.py new file mode 100644 index 0000000..8314db1 --- /dev/null +++ b/server/core/llm_provider/router.py @@ -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() diff --git a/server/core/personality/__init__.py b/server/core/personality/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/core/personality/loader.py b/server/core/personality/loader.py new file mode 100644 index 0000000..b21caa8 --- /dev/null +++ b/server/core/personality/loader.py @@ -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() diff --git a/server/core/prompt/templates/.gitkeep b/server/core/prompt/templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/core/prompt/templates/json_output_engine.txt b/server/core/prompt/templates/json_output_engine.txt new file mode 100644 index 0000000..f5ba27e --- /dev/null +++ b/server/core/prompt/templates/json_output_engine.txt @@ -0,0 +1 @@ +你是一个精确的 JSON 输出引擎。只输出合法的 JSON,不输出任何其他文字。 diff --git a/server/core/prompt/templates/structured_output.txt b/server/core/prompt/templates/structured_output.txt new file mode 100644 index 0000000..35e1535 --- /dev/null +++ b/server/core/prompt/templates/structured_output.txt @@ -0,0 +1,6 @@ +请按以下要求输出结构化内容: + +1. 使用明确的字段和值,不要模糊表述 +2. 数值必须精确,带上单位 +3. 列举所有条件,不要用"等"省略 +4. 如果信息不足,标注为"待确认"而非猜测 diff --git a/server/core/reasoning/__init__.py b/server/core/reasoning/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/core/reasoning/graph.py b/server/core/reasoning/graph.py new file mode 100644 index 0000000..5df3474 --- /dev/null +++ b/server/core/reasoning/graph.py @@ -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 diff --git a/server/core/reasoning/mock.py b/server/core/reasoning/mock.py new file mode 100644 index 0000000..d22e282 --- /dev/null +++ b/server/core/reasoning/mock.py @@ -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": [], + } diff --git a/server/core/utils/diff.py b/server/core/utils/diff.py new file mode 100644 index 0000000..f3f7b85 --- /dev/null +++ b/server/core/utils/diff.py @@ -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 diff --git a/server/core/utils/export_utils.py b/server/core/utils/export_utils.py new file mode 100644 index 0000000..64389f5 --- /dev/null +++ b/server/core/utils/export_utils.py @@ -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"" diff --git a/server/core/utils/logger.py b/server/core/utils/logger.py new file mode 100644 index 0000000..f4dfdf3 --- /dev/null +++ b/server/core/utils/logger.py @@ -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() diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..696c4e1 --- /dev/null +++ b/server/main.py @@ -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"} diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..bcb4062 --- /dev/null +++ b/server/requirements.txt @@ -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 diff --git a/server/services/__init__.py b/server/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/services/ir_engine/__init__.py b/server/services/ir_engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/services/ir_engine/diff.py b/server/services/ir_engine/diff.py new file mode 100644 index 0000000..94cf12f --- /dev/null +++ b/server/services/ir_engine/diff.py @@ -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() diff --git a/server/services/ir_engine/generator.py b/server/services/ir_engine/generator.py new file mode 100644 index 0000000..758b1ff --- /dev/null +++ b/server/services/ir_engine/generator.py @@ -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() diff --git a/server/services/ir_engine/pipeline.py b/server/services/ir_engine/pipeline.py new file mode 100644 index 0000000..ce59d9d --- /dev/null +++ b/server/services/ir_engine/pipeline.py @@ -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() diff --git a/server/services/ir_engine/validator.py b/server/services/ir_engine/validator.py new file mode 100644 index 0000000..6f982cc --- /dev/null +++ b/server/services/ir_engine/validator.py @@ -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() diff --git a/server/services/prd_manager/__init__.py b/server/services/prd_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/services/prd_manager/image_parser.py b/server/services/prd_manager/image_parser.py new file mode 100644 index 0000000..1604f25 --- /dev/null +++ b/server/services/prd_manager/image_parser.py @@ -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: + +**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()} diff --git a/server/services/prd_manager/service.py b/server/services/prd_manager/service.py new file mode 100644 index 0000000..cca11c7 --- /dev/null +++ b/server/services/prd_manager/service.py @@ -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() diff --git a/server/services/prd_manager/word_parser.py b/server/services/prd_manager/word_parser.py new file mode 100644 index 0000000..5836f5f --- /dev/null +++ b/server/services/prd_manager/word_parser.py @@ -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 diff --git a/server/services/prompts/chat_system.md b/server/services/prompts/chat_system.md new file mode 100644 index 0000000..be7c425 --- /dev/null +++ b/server/services/prompts/chat_system.md @@ -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": "", ...}} +``` +每次最多 3 个操作。无操作时不输出。 diff --git a/server/services/prompts/ir_step1_semantic_index.txt b/server/services/prompts/ir_step1_semantic_index.txt new file mode 100644 index 0000000..b8067b9 --- /dev/null +++ b/server/services/prompts/ir_step1_semantic_index.txt @@ -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": [ ... ] +} diff --git a/server/services/prompts/ir_step2_extraction.txt b/server/services/prompts/ir_step2_extraction.txt new file mode 100644 index 0000000..422aa7b --- /dev/null +++ b/server/services/prompts/ir_step2_extraction.txt @@ -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 标记**: + +[ + {{ ... }}, + {{ ... }} +] + +即使只有一个规则,也必须用数组格式 `[...]`。 diff --git a/server/services/skill_manager/__init__.py b/server/services/skill_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/services/skill_manager/service.py b/server/services/skill_manager/service.py new file mode 100644 index 0000000..44f3ce5 --- /dev/null +++ b/server/services/skill_manager/service.py @@ -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() diff --git a/server/services/testcase_engine/__init__.py b/server/services/testcase_engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/services/testcase_engine/exporter.py b/server/services/testcase_engine/exporter.py new file mode 100644 index 0000000..a55e457 --- /dev/null +++ b/server/services/testcase_engine/exporter.py @@ -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() diff --git a/server/services/testcase_engine/generator.py b/server/services/testcase_engine/generator.py new file mode 100644 index 0000000..743fe28 --- /dev/null +++ b/server/services/testcase_engine/generator.py @@ -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() diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..1dcd799 --- /dev/null +++ b/start.sh @@ -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 diff --git a/web/next-env.d.ts b/web/next-env.d.ts new file mode 100644 index 0000000..254b73c --- /dev/null +++ b/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/web/next.config.js b/web/next.config.js new file mode 100644 index 0000000..d51c384 --- /dev/null +++ b/web/next.config.js @@ -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; diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..c40de31 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2371 @@ +{ + "name": "zeekerwatchman-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zeekerwatchman-web", + "version": "0.1.0", + "dependencies": { + "@monaco-editor/react": "^4.6.0", + "next": "^15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-dropzone": "^14.2.0", + "react-hot-toast": "^2.4.0", + "react-virtuoso": "^4.10.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.5.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@next/env": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.18.tgz", + "integrity": "sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.18.tgz", + "integrity": "sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.18.tgz", + "integrity": "sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.18.tgz", + "integrity": "sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.18.tgz", + "integrity": "sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.18.tgz", + "integrity": "sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.18.tgz", + "integrity": "sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.18.tgz", + "integrity": "sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.18.tgz", + "integrity": "sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", + "integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/goober": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", + "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.18.tgz", + "integrity": "sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.18", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.18", + "@next/swc-darwin-x64": "15.5.18", + "@next/swc-linux-arm64-gnu": "15.5.18", + "@next/swc-linux-arm64-musl": "15.5.18", + "@next/swc-linux-x64-gnu": "15.5.18", + "@next/swc-linux-x64-musl": "15.5.18", + "@next/swc-win32-arm64-msvc": "15.5.18", + "@next/swc-win32-x64-msvc": "15.5.18", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.45", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.45.tgz", + "integrity": "sha512-iIbHXV9eBB2nB0wa7oTsrrXq+qQt+9SIlx9AX3T96YgobtEQfis5n6TJ6vV+3QP8DwdriEAcGhARaFCu37peBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-dropzone": { + "version": "14.4.1", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz", + "integrity": "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-virtuoso": { + "version": "4.18.7", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.7.tgz", + "integrity": "sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==", + "license": "MIT", + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..2f82608 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/src/components/ChatPanel.tsx b/web/src/components/ChatPanel.tsx new file mode 100644 index 0000000..6a0bda6 --- /dev/null +++ b/web/src/components/ChatPanel.tsx @@ -0,0 +1,289 @@ +// ChatPanel — 右侧 LLM 对话窗口 + 增量编辑确认 + +'use client'; + +import { useState, useRef, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/router'; +import { useAppStore, type ChatMessage } from '@/lib/store'; + +interface Action { + action: string; + rule_id?: string; + rule?: any; + changes?: any; + ir_id?: string; + page?: string; +} + +function rulesToSummary(rules: { rule_id: string; description: string; priority: string }[]): string { + if (!rules || rules.length === 0) return ''; + const lines = ['| rule_id | priority | description |', '|---------|----------|-------------|']; + for (const r of rules.slice(0, 30)) { + lines.push(`| ${r.rule_id} | ${r.priority} | ${r.description.slice(0, 60)} |`); + } + return `### 当前 IR 规则 (${rules.length} 条)\n${lines.join('\n')}`; +} + +export default function ChatPanel() { + const router = useRouter(); + const store = useAppStore(); + const messages = store.chatMessages; + const setMessages = store.setChatMessages; + + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + const [autoApply, setAutoApply] = useState(true); + const [confirmAction, setConfirmAction] = useState<{ msgIdx: number; actIdx: number; action: Action } | null>(null); + const bottomRef = useRef(null); + + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, confirmAction]); + + const sendMessage = useCallback(async () => { + const text = input.trim(); + if (!text || loading) return; + setInput(''); + setLoading(true); + + const history = messages.map((m) => ({ role: m.role, content: m.content })); + + try { + // Build context: tell the LLM exactly what's on the left side + const rulesSummary = rulesToSummary(store.irRules); + const tcCount = store.testcases?.length || 0; + const tcSummary = tcCount > 0 + ? store.testcases.slice(0, 5).map((c: any) => + `[${c.priority}] ${c.case_title?.slice(0, 60)}`).join('\n') + : ''; + + const res = await fetch('/api/chat/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: text, + context: { + session_id: store.sessionId, + current_page: store.currentPage, + has_prd: !!store.prdText, + has_ir: store.irRules.length > 0, + has_cases: tcCount > 0, + prd_id: store.prdId, ir_id: store.irId, tc_set_id: store.tcSetId, + prd_text: store.prdText, + ir_rules_summary: rulesSummary, + ir_content_snippet: store.irContent.slice(0, 800), + testcase_count: tcCount, + testcase_summary: tcSummary, + // Existing TC IDs (for delete/modify reference) + existing_tc_ids: store.testcases?.map((c: any) => c.id) || [], + // Currently selected item + selected_case: store.selectedCase, + selected_rule: store.selectedRule, + }, + history, + }), + }); + + if (!res.ok) throw new Error(`Chat error: ${res.status}`); + const data = await res.json(); + + const reply = data.reply || ''; + const actions: Action[] = data.actions || []; + + setMessages([...messages, { role: 'user', content: text }, { + role: 'assistant', content: reply, actions, + confirmed: autoApply || actions.length === 0, + }]); + + // Auto-apply actions immediately when toggle is on + if (autoApply && actions.length > 0) { + applyActions(actions); + } + } catch (err: any) { + setMessages([...messages, { role: 'user', content: text }, { role: 'assistant', content: `错误: ${err.message}` }]); + } finally { + setLoading(false); + } + }, [input, loading, messages, store.irRules, store.prdText, store.irContent, store.testcases, store.currentPage]); + + const applyActions = useCallback((acts: Action[]) => { + const batch: { action: string; data: any }[] = []; + for (const act of acts) { + switch (act.action) { + case 'navigate': + if (act.page === 'ir-confirm') router.push(`/ir-confirm?prdId=${store.prdId}`); + else if (act.page === 'cases') router.push(`/cases?irId=${store.irId}`); + else if (act.page === 'home') router.push('/'); + break; + case 'update_ir': + store.setIr(store.irId, act.content || ''); + store.triggerIrRefresh(); + break; + case 'add_rule': + case 'delete_rule': + case 'modify_rule': + case 'add_case': + case 'delete_case': + case 'modify_case': + batch.push({ action: act.action, data: act }); + break; + case 'generate_cases': + router.push(`/cases?irId=${act.ir_id || store.irId}`); + break; + } + } + if (batch.length > 0) { + store.setPendingEdits(batch); + } + }, [store, router]); + + const handleConfirmAction = (msgIdx: number, actIdx: number) => { + const msg = messages[msgIdx]; + if (!msg?.actions) return; + const act = msg.actions[actIdx]; + setConfirmAction({ msgIdx, actIdx, action: act }); + }; + + const handleApplyConfirmed = () => { + if (!confirmAction) return; + applyActions([confirmAction.action]); + // Mark this action as confirmed + const updated = [...messages]; + const msg = { ...updated[confirmAction.msgIdx] }; + msg.confirmed = true; + if (msg.actions) { + msg.actions = [...msg.actions]; + msg.actions[confirmAction.actIdx] = { ...msg.actions[confirmAction.actIdx], _applied: true }; + } + updated[confirmAction.msgIdx] = msg; + setMessages(updated); + setConfirmAction(null); + }; + + const handleRejectAction = () => { + setConfirmAction(null); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }; + + return ( +
+ {/* Header */} +
+
+

AI 助手

+
+ + +
+
+

可以帮你分析 PRD、修改 IR、生成用例

+ {store.irRules.length > 0 && ( +

当前 IR: {store.irRules.length} 条规则

+ )} +
+ + {/* Messages */} +
+ {messages.length === 0 && ( +
+

试试这些:

+
+ {[ + '帮我分析当前 PRD 的功能点', + '检查 IR 是否有遗漏', + '为 P0 功能增加一条异常用例', + '把 FEAT-SYS-FG-01 的优先级改成 P0', + ].map((hint) => ( + + ))} +
+
+ )} + + {messages.map((msg, i) => ( +
+
+
{msg.content}
+ {msg.actions && msg.actions.length > 0 && ( +
+ {msg.actions.map((act, j) => ( +
+ {act._applied || autoApply ? ( + ✓ 已执行: {act.action} + ) : confirmAction?.msgIdx === i && confirmAction?.actIdx === j ? ( +
+

确认执行 {act.action}?

+ {act.rule &&

添加: {act.rule.description?.slice(0, 60)}

} + {act.rule_id &&

目标: {act.rule_id}

} + {act.changes &&

修改: {JSON.stringify(act.changes).slice(0, 80)}

} +
+ + +
+
+ ) : ( + + )} +
+ ))} +
+ )} +
+
+ ))} + + {loading && ( +
+
+ + + + + +
+
+ )} + +
+
+ + {/* Input */} +
+
+