init the project

This commit is contained in:
evyzacq
2026-05-25 15:09:42 +08:00
commit 7fc0e7852e
122 changed files with 14557 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
---
name: 文档分析代理
description: 一个智能代理,用于分析文档(.docx, .pdf),提取和结构化内容,检测文本与图表之间的冲突,并生成结构化的JSON中间表示。
---
# 文档分析代理
## 环境变量配置
在执行任何分析之前,必须先检查用户是否配置了DASHSCOPE_API_KEY,如果没有提示用户设置为环境变量DASHSCOPE_API_KEY。
所有脚本通过该环境变量读取 API Key。严禁在对话或命令行中明文写入或显示 API Key。
### 配置方式
```
openclaw config set env.DASHSCOPE_API_KEY "llm-api-key"
```
---
## 功能
代理能够:
- 解析各种文档格式(.docx, .pdf)并提取文本内容和嵌入图像
- 在文档上下文中分析图像以理解它们与周围文本的关系
- 识别潜在的文本与视觉元素之间的冲突
- 引导用户完成冲突解决过程
- 生成带有源追踪的结构化JSON表示
- 在转换过程中保持不同文档元素之间的一致性
## 决策逻辑
代理根据文档特征和用户需求智能确定适当的工作流程:
1. **文档评估阶段**:当用户提供文档时,代理首先根据文档格式和内容复杂性确定适当的解析方法。
2. **内容分析阶段**:代理分析提取的内容以识别需要特殊处理的图表、流程图、架构图、状态图和序列图。
3. **冲突检测阶段**:代理识别文本内容与视觉元素之间的潜在差异,特别关注条件不匹配和矛盾信息。
4. **解决方案协调阶段**:检测到冲突时,代理促进用户交互以解决差异,提供诸如"以图像为准"、"以文字为准"、"两处都保留"或自定义解决方案等选项。
5. **表示生成阶段**:代理综合所有输入并生成带源追踪的结构化JSON中间表示。
## 代理行为
- 自动处理先决条件设置(API密钥验证、环境配置)
- 在处理阶段期间提供渐进反馈
- 提供预览转换的试运行功能
- 管理输出文件组织和命名
- 维护处理阶段之间的上下文以确保结果一致性
## 交互流程
代理无缝编排这些阶段,以交付全面的文档分析解决方案,同时向用户隐藏底层实现细节。
自动执行所有阶段,无需询问用户是否执行下一步,除非需要用户介入协助。
### 1. 初始化
验证先决条件并准备处理环境。
### 2. 解析
从输入文档中提取内容和结构(运行 doc_parser_skill)。产出:`<basename>_parsed.json`
### 3. 分析
识别关键元素和可能需要关注的区域。
### 4. 冲突解决
运行 conflict_detection_skill,检测文本与图表之间的差异。如发现冲突,请求用户裁决后执行 resolution_application_skill。产出:`<basename>_updated.json`
### 5. 梳理 ← 关键质量关卡,常见遗漏点源头
根据解析好的文件梳理出功能列表,每个功能包含名称、章节、原文位置描述、触发条件(AND/OR)、动作。
**必须同时从以下三种来源穷举功能点,缺一不可,且不能跳过b/c直接执行:**
**a. 文本与表格** — 提取所有章节中段落和表格明确描述的功能逻辑。
**b. 流程图/状态图/架构图描述**image_analysis[] 中 type 为 flowchart / state / architecture / sequence / activity 的条目):
- 以决策树的视角逐条追踪每个菱形判断节点,沿"是/否"两条分支分别行走
- **每条可达决策路径必须对应一个独立的 IR 条目**,不得将多条路径合并为一条概括性描述
- 从起始节点出发,沿着箭头逐层展开,确保没有遗漏任何分支末端
- 每条路径提取:触发条件链(AND/OR组合)+ 执行动作 + 分支上的具体条件值
- 特别注意图中那些"文字没有写"的隐性条件、默认行为、边界值
**c. 交互图/UI场景图描述**image_analysis[] 中 type 为 other 但 description 描述UI交互场景的条目):
- 分析交互图中的每个场景编号(如01、02、A、B、C等)
- 每个场景拆分出一个独立的功能点,包含:触发条件(用户操作或系统状态)、系统响应(Toast文案、页面变化等)
- 如果多个场景在逻辑上形成流程链,保留上下游关系
**d. 交叉验证** — 对每个已识别的功能点标注来源(text / image_rIdXX),确保 text 来源覆盖了文档所有文字,image 来源覆盖了所有 image_analysis 中有行为逻辑的条目。
### 6. 合成
根据梳理的功能列表,生成最终结构化表示(IR),输出到输出目录的 `<文档名>_ir.json` 文件中。
使用 ir_generation_skill 脚本。
### 7. 检查
对比解析好的文件(parsed.json/updated.json)和合成的 IR 文件,**对照以下清单逐项验证**,找到遗漏点:
**7a. 文本覆盖检查** — 遍历 parsed.json 中所有 sections[].blocks(包括 para 和 table),对每个段/格判断:
- 是否描述了某个功能行为?
- 若是,该行为是否已在 IR 中?
- 遗漏的标记出来。
**7b. 流程图路径完整性检查** — 逐张流程图 description,对照决策树结构:
- □ 每条"是"分支路径 → IR 中有对应条目
- □ 每条"否"分支路径 → IR 中有对应条目
- □ 起始节点的每个出口都被追踪到
- □ 边角情况(分支合并、默认走法)也已覆盖
**7c. 交互图场景检查** — 逐张交互图 description
- □ 每个场景编号(01/02/A/B/C等)→ IR 中有对应条目
- □ 每个 UX 文案 → IR 中有对应条目或作为已有条目的动作细节
- □ 特殊行为 → IR 中有对应条件
**7d. 功能列表交叉检查** — 如果文档有功能列表表格:
- □ 表格中的每一行 → IR 中有对应条目
- □ 章节中的详细描述 → IR 中有对应条目,且粒度不粗于功能列表
### 8. 补充
根据检查结果,将遗漏功能加入已有 IR 文件。
使用 ir_generation_skill 添加每个遗漏点。
**每补充完一批,回到步骤7重新检查**,直到所有检查项均为 ✅(即功能点一致)。
### 9. 输出
提供带追踪信息的完整分析结果,包括:
- 功能点清单总览
- 冲突解决记录
- 文档质量备注(如图片错误、占位符、不一致等)
+2
View File
@@ -0,0 +1,2 @@
openai
python-docx
@@ -0,0 +1,37 @@
---
name: 冲突检测技能
description: 分析解析后的文档,检测图表类图像与其相应文本描述之间的矛盾和条件不匹配。
---
# 冲突检测技能
## 概述
此技能识别解析文档中文本内容与视觉内容之间的潜在冲突。它特别针对图表类图像(流程图、架构图、状态图、序列图和活动图)并交叉检查其描述与同文档部分的文本内容。
## 功能
该技能:
- 从解析的文档结构中识别图表类图像
- 将图像描述与同一文档部分中的相应文本内容进行交叉引用
- 检测视觉表示和文本表示之间的矛盾和条件不匹配
- 生成包含其位置的已识别冲突的结构化列表
- 专门针对流程图、架构图、状态图、序列图和活动图
## 输入要求
- 解析文档JSON文件的路径(由文档解析技能生成)
- 可选输出目录规范
- 可选试运行标志,在不调用API的情况下预览大语言模型提示
## 输出
该技能生成一个结构化JSON文件,文件名为输入文档的基本名称后跟'_conflicts.json',包含:
- 带有关于差异详情的冲突对象列表
- 标识每个冲突发生位置的节标识符
- 冲突图像和文本内容的片段
- 每个冲突的类型分类(例如,矛盾、条件不匹配)
## 集成点
此技能消耗文档解析技能的输出并为解决方案应用技能提供输入。冲突解决过程通常需要人工输入才能进入下一阶段。
@@ -0,0 +1,105 @@
import logging
import os
import time
from typing import Optional
from openai import OpenAI
logger = logging.getLogger(__name__)
class LLMClient:
"""Low-level OpenAI-compatible LLM client with retry and token tracking.
Usage::
llm = LLMClient()
content = llm.chat("qwen3.5-flash", [{"role": "user", "content": "Hello"}])
print(llm.usage)
"""
IMAGE_MODEL = "qwen3-vl-plus"
TEXT_MODEL = "qwen3.5-flash-2026-02-23"
TIMEOUT = 120
MAX_RETRIES = 3
def __init__(
self,
*,
base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1",
timeout: int | None = None,
):
key = os.environ.get("DASHSCOPE_API_KEY", "")
if not key:
raise ValueError("DASHSCOPE_API_KEY environment variable is not set.")
self._client = OpenAI(api_key=key, base_url=base_url)
self._timeout = timeout or self.TIMEOUT
self._prompt_tokens = 0
self._completion_tokens = 0
@property
def usage(self) -> dict:
"""Return accumulated token counts as ``{prompt, completion, total}``."""
return {
"prompt_tokens": self._prompt_tokens,
"completion_tokens": self._completion_tokens,
"total_tokens": self._prompt_tokens + self._completion_tokens,
}
@staticmethod
def estimate_tokens(text: str) -> int:
"""Quick token estimate. CJK ≈1.7/token, others ≈3.0/token."""
cjk = sum(1 for c in text if '' <= c <= '鿿' or ' ' <= c <= '')
other = len(text) - cjk
return max(1, int(cjk / 1.7 + other / 3.0))
@staticmethod
def estimate_image_tokens() -> int:
"""Fixed estimate for one vision-model image (~500 tokens)."""
return 500
def chat(
self, model: str, messages: list[dict], *, timeout: int | None = None,
response_format: dict | None = None,
) -> str:
"""Send a chat completion request and return the response content.
Automatically retries on failure and accumulates token usage.
"""
label = f"chat({model})"
def _call():
t0 = time.time()
kwargs = dict(model=model, messages=messages, timeout=timeout or self._timeout)
if response_format is not None:
kwargs["response_format"] = response_format
kwargs["temperature"] = 0
resp = self._client.chat.completions.create(**kwargs)
content = resp.choices[0].message.content
usg = resp.usage
if usg:
self._prompt_tokens += usg.prompt_tokens
self._completion_tokens += usg.completion_tokens
elapsed = time.time() - t0
logger.info("%s: %d chars in %.1fs", label, len(content) if content else 0, elapsed)
if not content:
raise RuntimeError("Empty response from LLM")
return content
return self._retry(_call, label)
def _retry(self, fn, label: str) -> str:
"""Call *fn()* with exponential-backoff retry."""
last_error: Optional[Exception] = None
for attempt in range(self.MAX_RETRIES):
try:
return fn()
except Exception as e:
last_error = e
logger.warning(
"%s error (attempt %d/%d): %s",
label, attempt + 1, self.MAX_RETRIES, e,
)
if attempt < self.MAX_RETRIES - 1:
time.sleep(2 ** attempt)
raise RuntimeError(f"{label}: all retries exhausted") from last_error
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""Detect logical conflicts between image analysis and text in ``_parsed.json``.
Usage::
python scripts/detect_conflicts.py D:/projects/jike/output/车机娱乐系统禁止功能文档_精简_parsed.json [--output-dir DIR]
For each diagram-type image (flowchart, architecture, state, sequence, activity),
the script locates its section via *image_sources*, grabs the corresponding text
blocks, and calls an LLM to find contradictions/condition-mismatches between the
image description and the text.
Output: ``<basename>_conflicts.json``
"""
import argparse
import json
import logging
import os
import re
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from LLM import LLMClient
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
RATE_LIMIT_DELAY = 0.5
DIAGRAM_TYPES = {"flowchart", "architecture", "state", "sequence", "activity"}
MIN_TEXT_CHARS = 20
PROMPT_DETECT_CONFLICT = """你是一个文档一致性检查专家。以下内容来自同一份需求文档的同一个章节,包含两部分:
## 部分1:图片(流程图/架构图/状态图)的描述
```
{image_description}
```
## 部分2:同章节的文字描述
```
{text_description}
```
## 你的任务
检查这两部分之间是否存在**逻辑矛盾或条件不一致**。
你需要关注的冲突类型:
1. **condition_mismatch**(条件不一致):两者描述了同一规则,但触发条件、阈值、时序不同。
例如:图片说"车速≥15km/h且持续5秒",文字说"车速≥10km/h且持续3秒"
例如:图片说"非P档限制",文字说"车速>0限制"
2. **contradiction**(直接矛盾):两者对同一事物的描述完全相反。
例如:图片说"功能X被禁止",文字说"功能X可用"
例如:图片说"开关默认关闭",文字说"开关默认开启"
3. **scope_mismatch**(范围不一致):两者描述的场景/地域/设备范围不同。
例如:图片说"国内方案",文字说"海外方案"
例如:图片说"CSD中控屏",文字描述包含"PSD副驾屏"
## 输出格式
如果**没有冲突**,只输出:
```
[[NO_CONFLICT]]
```
如果**有冲突**,输出以下JSON数组(不要任何其他文字):
```json
[
{{
"conflict_type": "condition_mismatch",
"severity": "high",
"section": "{section_name}",
"image_snippet": "图片中描述的关键内容(摘录)",
"text_snippet": "文字中描述的关键内容(摘录)",
"description": "用中文说明冲突的具体差异"
}}
]
```
注意:
- 每个冲突一个条目,不要合并
- severity: high(功能正确性受影响)| medium(边界条件模糊)| low(表达方式差异)
- 输出必须是**严格合法的JSON数组**,不要有尾随逗号
- 如果没有严格冲突,输出 [[NO_CONFLICT]]
"""
def _build_text_for_section(sections: list[dict], section_name: str) -> str:
"""Build a single text block for the given section name."""
texts: list[str] = []
for sec in sections:
if sec.get("source", "") == section_name:
for blk in sec.get("blocks", []):
if blk["type"] == "para":
texts.append(blk["text"])
elif blk["type"] == "table":
table_lines = [f"表格 {blk['table']}:"]
for ri, row in enumerate(blk.get("rows", [])):
cols = row.get("columns", [])
parts = [f"{c['name']}: {c['text']}" for c in cols]
table_lines.append(f"{ri + 1}: {' | '.join(parts)}")
texts.append("\n".join(table_lines))
return "\n\n".join(texts)
def _parse_conflict_json(content: str) -> list[dict]:
"""Extract JSON array from LLM response, handling markdown fences."""
stripped = content.strip()
if "[[NO_CONFLICT]]" in stripped:
return []
# Remove markdown code fences
if "```json" in stripped:
stripped = stripped.split("```json", 1)[1]
if "```" in stripped:
stripped = stripped.split("```", 1)[0]
elif "```" in stripped:
stripped = stripped.split("```", 1)[1]
if "```" in stripped:
stripped = stripped.split("```", 1)[0]
stripped = stripped.strip()
if not stripped:
return []
# Try to find a JSON array
match = re.search(r"\[\s*\{.*\}\s*\]", stripped, re.DOTALL)
if match:
stripped = match.group()
try:
conflicts = json.loads(stripped)
if isinstance(conflicts, list):
return conflicts
return []
except json.JSONDecodeError as e:
logger.warning("Failed to parse conflict JSON: %s", e)
logger.debug("Raw content: %s", stripped)
return []
def detect_conflicts(
parsed_path: str,
output_dir: str | None = None,
*,
dry_run: bool = False,
) -> list[dict]:
"""Load ``_parsed.json`` and detect image-vs-text conflicts.
Returns a flat list of conflict dicts and writes to ``<basename>_conflicts.json``.
"""
with open(parsed_path, "r", encoding="utf-8") as f:
data = json.load(f)
basename = os.path.splitext(os.path.basename(parsed_path))[0]
if basename.endswith("_parsed"):
basename = basename[:-7]
if output_dir is None:
output_dir = os.path.dirname(os.path.abspath(parsed_path))
os.makedirs(output_dir, exist_ok=True)
sections = data.get("sections", [])
image_sources = data.get("image_sources", {})
image_analysis = data.get("image_analysis", [])
llm = LLMClient()
all_conflicts: list[dict] = []
# ---- For each diagram image, compare with its section text -------------
for img in image_analysis:
img_type = img.get("type", "other")
rid = img.get("rid", "")
description = img.get("description", "").strip()
if img_type not in DIAGRAM_TYPES or not description:
logger.info("Skip conflict check: rid=%s type=%s", rid, img_type)
continue
# Find source section
src = image_sources.get(rid, {})
section_name = src.get("section", "")
if not section_name:
logger.warning("No section found for rid=%s, skipping", rid)
continue
# Build text from the same section
text_content = _build_text_for_section(sections, section_name)
text_len = len(text_content.strip())
if text_len < MIN_TEXT_CHARS:
logger.info("Section text too short (%d chars) for rid=%s, skip", text_len, rid)
continue
logger.info("Checking conflicts: rid=%s section=%s (desc=%d chars, text=%d chars)",
rid, section_name, len(description), text_len)
if dry_run:
logger.info(" [DRY RUN] would call LLM to detect conflicts")
continue
prompt = PROMPT_DETECT_CONFLICT.format(
image_description=description,
text_description=text_content,
section_name=section_name,
)
try:
raw = llm.chat(
model=LLMClient.TEXT_MODEL,
messages=[{"role": "user", "content": prompt}],
)
logger.info("Conflict check response: %d chars", len(raw))
except RuntimeError as e:
logger.error("Conflict check failed: %s", e)
continue
conflicts = _parse_conflict_json(raw)
# Enrich with location info
for c in conflicts:
c["rid"] = rid
c["image_path"] = img.get("path", "")
if "section" not in c:
c["section"] = section_name
if src.get("table"):
c.setdefault("source_location", {})["table"] = src["table"]
if src.get("row"):
c.setdefault("source_location", {})["image_row"] = src["row"]
all_conflicts.extend(conflicts)
logger.info(" Found %d conflicts for rid=%s", len(conflicts), rid)
if any(x.get("type") in DIAGRAM_TYPES
for x in image_analysis
if x.get("rid", "") != rid):
time.sleep(RATE_LIMIT_DELAY)
# ---- Save ---------------------------------------------------------------
conflicts_path = os.path.join(output_dir, f"{basename}_conflicts.json")
with open(conflicts_path, "w", encoding="utf-8") as f:
json.dump(all_conflicts, f, ensure_ascii=False, indent=2)
logger.info("Saved: %s (%d conflicts)", conflicts_path, len(all_conflicts))
# ---- Summary ------------------------------------------------------------
usg = llm.usage
logger.info("Tokens: %d prompt + %d completion = %d total",
usg["prompt_tokens"], usg["completion_tokens"], usg["total_tokens"])
return all_conflicts
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Detect image-vs-text conflicts in parsed document.",
)
parser.add_argument("input", metavar="parsed.json", help="Path to _parsed.json from doc_parser")
parser.add_argument("--output-dir", metavar="DIR", default=None,
help="Output directory (default: same as input)")
parser.add_argument("--dry-run", action="store_true",
help="Print LLM prompts without calling the API.")
args = parser.parse_args()
detect_conflicts(args.input, output_dir=args.output_dir, dry_run=args.dry_run)
@@ -0,0 +1,36 @@
---
name: 文档解析技能
description: 解析文档(.docx, .pdf)以提取图像和文本结构,并使用视觉大语言模型分析每个图像的类型和描述。
---
# 文档解析技能
## 概述
此技能从文档(.docx, .pdf)中提取内容并准备进行进一步分析。它提取文本内容和嵌入图像,并对图像执行初始分析以了解其类型和内容。
## 功能
该技能:
- 从文档中提取文本结构(段落、表格、标题)
- 识别并提取嵌入的图像
- 使用视觉大语言模型分析每个图像并确定其类型和内容描述
- 生成结构化输出,将图像映射到其在文档中的位置
- 创建文档的初始解析表示,供后续处理阶段使用
## 输入要求
- 文档文件路径(必需,支持.docx和.pdf格式)
- 可选输出目录(默认为'output/'
- 可选试运行标志,在不调用API的情况下预览大语言模型提示
## 输出
该技能生成一个结构化JSON文件,文件名为输入文档的基本名称后跟'_parsed.json',包含:
- `sections`:按标题分组的文档文本结构
- `image_sources`:从图像标识符到其在文档中位置的映射
- `image_analysis`:由视觉大语言模型确定的每个图像的类型和内容描述
## 集成点
此技能作为文档分析管道中的初始处理步骤。其输出被冲突检测技能消费以识别文本和视觉内容之间的差异。
@@ -0,0 +1,105 @@
import logging
import os
import time
from typing import Optional
from openai import OpenAI
logger = logging.getLogger(__name__)
class LLMClient:
"""Low-level OpenAI-compatible LLM client with retry and token tracking.
Usage::
llm = LLMClient()
content = llm.chat("qwen3.5-flash", [{"role": "user", "content": "Hello"}])
print(llm.usage)
"""
IMAGE_MODEL = "qwen3-vl-plus"
TEXT_MODEL = "qwen3.5-flash-2026-02-23"
TIMEOUT = 120
MAX_RETRIES = 3
def __init__(
self,
*,
base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1",
timeout: int | None = None,
):
key = os.environ.get("DASHSCOPE_API_KEY", "")
if not key:
raise ValueError("DASHSCOPE_API_KEY environment variable is not set.")
self._client = OpenAI(api_key=key, base_url=base_url)
self._timeout = timeout or self.TIMEOUT
self._prompt_tokens = 0
self._completion_tokens = 0
@property
def usage(self) -> dict:
"""Return accumulated token counts as ``{prompt, completion, total}``."""
return {
"prompt_tokens": self._prompt_tokens,
"completion_tokens": self._completion_tokens,
"total_tokens": self._prompt_tokens + self._completion_tokens,
}
@staticmethod
def estimate_tokens(text: str) -> int:
"""Quick token estimate. CJK ≈1.7/token, others ≈3.0/token."""
cjk = sum(1 for c in text if '' <= c <= '鿿' or ' ' <= c <= '')
other = len(text) - cjk
return max(1, int(cjk / 1.7 + other / 3.0))
@staticmethod
def estimate_image_tokens() -> int:
"""Fixed estimate for one vision-model image (~500 tokens)."""
return 500
def chat(
self, model: str, messages: list[dict], *, timeout: int | None = None,
response_format: dict | None = None,
) -> str:
"""Send a chat completion request and return the response content.
Automatically retries on failure and accumulates token usage.
"""
label = f"chat({model})"
def _call():
t0 = time.time()
kwargs = dict(model=model, messages=messages, timeout=timeout or self._timeout)
if response_format is not None:
kwargs["response_format"] = response_format
kwargs["temperature"] = 0
resp = self._client.chat.completions.create(**kwargs)
content = resp.choices[0].message.content
usg = resp.usage
if usg:
self._prompt_tokens += usg.prompt_tokens
self._completion_tokens += usg.completion_tokens
elapsed = time.time() - t0
logger.info("%s: %d chars in %.1fs", label, len(content) if content else 0, elapsed)
if not content:
raise RuntimeError("Empty response from LLM")
return content
return self._retry(_call, label)
def _retry(self, fn, label: str) -> str:
"""Call *fn()* with exponential-backoff retry."""
last_error: Optional[Exception] = None
for attempt in range(self.MAX_RETRIES):
try:
return fn()
except Exception as e:
last_error = e
logger.warning(
"%s error (attempt %d/%d): %s",
label, attempt + 1, self.MAX_RETRIES, e,
)
if attempt < self.MAX_RETRIES - 1:
time.sleep(2 ** attempt)
raise RuntimeError(f"{label}: all retries exhausted") from last_error
@@ -0,0 +1,106 @@
import argparse
import json
import logging
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from image_parser import ImageParser
from LLM import LLMClient
from word_parser import WordParser
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
RATE_LIMIT_DELAY = 0.5
def parse_document(
docx_path: str,
output_dir: str = "output",
*,
dry_run: bool = False,
) -> dict:
"""Parse a .docx file: extract text structure and parse embedded images.
Produces ``<basename>_parsed.json`` in *output_dir*.
"""
word = WordParser(docx_path)
basename = os.path.splitext(os.path.basename(docx_path))[0]
os.makedirs(output_dir, exist_ok=True)
images_dir = os.path.join(output_dir, "images")
# ---- extract sections and images -----------------------------------------
sections, image_sources = word.extract_sections()
logger.info("Document has %d sections, %d image sources", len(sections), len(image_sources))
# ---- parse images ----------------------------------------------------------
images = word.extract_images(images_dir)
logger.info("Found %d images in document", len(images))
image_analysis: list[dict] = []
if images:
llm = ImageParser()
for i, img in enumerate(images):
logger.info("[image %d/%d] rid=%s", i + 1, len(images), img["rid"])
if dry_run:
est = LLMClient.estimate_image_tokens()
logger.info(" [DRY RUN] would call vision LLM (~%d tokens)", est)
result = {"type": "other", "description": "[DRY RUN]"}
else:
result = llm.parse_image(img["path"])
if result is None:
result = {"type": "other", "description": ""}
result["rid"] = img["rid"]
result["path"] = img["path"]
image_analysis.append(result)
if i < len(images) - 1:
time.sleep(RATE_LIMIT_DELAY)
usg = llm.usage
logger.info("Tokens: %d prompt + %d completion = %d total",
usg["prompt_tokens"], usg["completion_tokens"], usg["total_tokens"])
else:
logger.info("No images found in document")
# ---- build output --------------------------------------------------------
output = {
"source": os.path.abspath(docx_path),
"sections": sections,
"image_sources": image_sources,
"image_analysis": image_analysis,
}
parsed_path = os.path.join(output_dir, f"{basename}_parsed.json")
with open(parsed_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
logger.info("Saved: %s", parsed_path)
return output
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Parse a .docx file: extract text structure and parse images.",
)
parser.add_argument("input", metavar="input.docx", help="Path to the Word document")
parser.add_argument("output_dir", nargs="?", default="output", metavar="output_dir",
help="Directory for output files (default: output/)")
parser.add_argument("--dry-run", action="store_true",
help="Print LLM prompts without calling the API.")
args = parser.parse_args()
parse_document(args.input, args.output_dir, dry_run=args.dry_run)
@@ -0,0 +1,123 @@
import base64
import logging
import os
from typing import Optional
from LLM import LLMClient
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Prompts
# ---------------------------------------------------------------------------
PROMPT_IMAGE = """请分析这张图片,判断类型并输出文字描述。
## 判断图片类型
如果是 **流程图 / 架构图 / 状态图 / 时序图 / 活动图**,详细描述:
- 图中所有节点/步骤/状态/组件的名称
- 所有连线/箭头/转换关系及其方向
- 所有分支条件、判断逻辑和判断结果
- 所有文字标注、注释、标签
- 图的整体结构和逻辑流程
- 如果图片包含多个子图,拆解描述
如果是 **其他类型**(UI原型图 / 界面截图 / 设计稿 / 手机屏幕截图 / 网页截图等),简要描述图片内容。
## 输出格式
**1. 类型标签(单独一行):**
type: <flowchart|architecture|state|sequence|activity|other>
**2. 文字描述:**
该图片的详细文字描述。
不要输出 ---YAML--- 分隔符或 YAML 内容,不要添加任何额外的解释或问候语。"""
# ---------------------------------------------------------------------------
# ImageParser
# ---------------------------------------------------------------------------
class ImageParser:
"""Vision LLM wrapper for parsing images (type + description).
Usage::
parser = ImageParser()
result = parser.parse_image("images/img1.png")
"""
_VALID_TYPES = {"flowchart", "architecture", "state", "sequence", "activity", "text"}
def __init__(self, llm: LLMClient | None = None):
self._llm = llm or LLMClient()
@property
def usage(self) -> dict:
return self._llm.usage
def parse_image(self, image_path: str) -> Optional[dict]:
"""Parse an image and return its type and description (no YAML IR).
Returns ``{type, description}``, or *None* for UI mockups.
"""
logger.info("Parsing image: %s", image_path)
with open(image_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
mime = self._mime_type(image_path)
try:
content = self._llm.chat(
model=LLMClient.IMAGE_MODEL,
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{img_b64}"}},
{"type": "text", "text": PROMPT_IMAGE},
],
}],
)
except RuntimeError as e:
logger.error(str(e))
return {"type": "other", "description": "", "error": str(e)}
parsed = self._parse_type_and_description(content)
if parsed is None:
return None
return {"type": parsed[0], "description": parsed[1]}
# ---- internals ----------------------------------------------------------
def _parse_type_and_description(self, content: str) -> Optional[tuple[str, str]]:
"""Extract ``(type, description)`` from LLM response.
Returns *None* for ``[[UI]]`` (skip).
"""
content = content.strip()
if content == "[[UI]]" or content.startswith("[[UI]]"):
return None
parsed_type = "other"
desc_lines: list[str] = []
for line in content.splitlines():
stripped = line.strip()
if (stripped.startswith("type:") or stripped.startswith("类型:")) and parsed_type == "other":
type_val = stripped.split(":", 1)[1].strip().lower()
if type_val in self._VALID_TYPES:
parsed_type = type_val
else:
desc_lines.append(line)
return parsed_type, "\n".join(desc_lines).strip()
@staticmethod
def _mime_type(image_path: str) -> str:
ext = os.path.splitext(image_path)[1].lstrip(".").lower()
return {
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"gif": "image/gif", "bmp": "image/bmp",
"webp": "image/webp", "svg": "image/svg+xml", "tiff": "image/tiff",
}.get(ext, "image/png")
@@ -0,0 +1,239 @@
import logging
import os
from docx import Document
from docx.table import Table
from docx.text.paragraph import Paragraph
logger = logging.getLogger(__name__)
IMAGE_EXT = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/bmp": ".bmp",
"image/tiff": ".tiff",
"image/webp": ".webp",
"image/x-emf": ".emf",
"image/x-wmf": ".wmf",
"image/svg+xml": ".svg",
}
class WordParser:
"""Parse a .docx file — extract images, split body into sections.
Usage::
parser = WordParser("doc.docx")
parser.extract_images("images/")
sections, image_sources = parser.extract_sections()
"""
HEADER_CELL_MAX_LEN = 20 # max chars per cell to treat first row as header
WML_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
DRAW_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
def __init__(self, docx_path: str):
if not os.path.isfile(docx_path):
raise FileNotFoundError(f"Document not found: {docx_path}")
self._doc = Document(docx_path)
# ---- public API ---------------------------------------------------------
def extract_images(self, images_dir: str) -> list[dict]:
"""Save all images to *images_dir*. Returns ``[{rid, path}, ...]``."""
os.makedirs(images_dir, exist_ok=True)
images: list[dict] = []
for rel in self._doc.part.rels.values():
if "image" not in rel.reltype:
continue
ext = IMAGE_EXT.get(rel.target_part.content_type, ".png")
name = f"image_{rel.rId}{ext}"
path = os.path.join(images_dir, name)
with open(path, "wb") as f:
f.write(rel.target_part.blob)
images.append({"rid": rel.rId, "path": path})
return images
def extract_sections(self) -> tuple[list[dict], dict[str, dict]]:
"""Walk document body and split into sections by heading.
Returns:
*sections* — ``[{source, blocks, images}, ...]``
Each block is ``{type, index, text}`` (paragraph) or
``{type, table, headers, rows}`` (table).
*image_sources* — ``rid → {section, table?, row?, column?, name?}``
"""
sections: list[dict] = []
current_source = ""
blocks: list[dict] = []
section_images: list[str] = []
image_sources: dict[str, dict] = {}
para_idx = 0
tbl_idx = 0
for child in self._doc.element.body:
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
if tag == "p":
para = Paragraph(child, self._doc)
if self._heading_level(para) is not None:
heading_text = para.text.strip()
if heading_text: # ignore empty heading-like paragraphs
if blocks or section_images:
sections.append({
"source": current_source,
"blocks": blocks,
"images": list(section_images),
})
blocks = []
section_images = []
para_idx = 0
tbl_idx = 0
current_source = heading_text
continue
text = para.text.strip()
# Scan for images — append [[IMAGE:rid]] markers
for run in para.runs:
for rid in self._images_in(run._element):
text += f" [[IMAGE:{rid}]]"
section_images.append(rid)
image_sources[rid] = {"section": current_source}
if text.strip():
blocks.append({"type": "para", "index": para_idx + 1, "text": text.strip()})
para_idx += 1
elif tag == "tbl":
tbl_idx += 1
table = Table(child, self._doc)
# Collect all rows as [[cell_text, ...], ...]
all_rows: list[list[str]] = []
all_images: list[list[list[str]]] = [] # row → col → [rids]
for row in table.rows:
row_texts: list[str] = []
row_cell_images: list[list[str]] = []
for cell in row.cells:
cell_text = cell.text.strip()
cell_imgs: list[str] = []
for cp in cell.paragraphs:
for run in cp.runs:
for rid in self._images_in(run._element):
cell_imgs.append(rid)
# Replace images with markers in text
for rid in cell_imgs:
cell_text += f" [[IMAGE:{rid}]]"
section_images.append(rid)
row_texts.append(cell_text.strip())
row_cell_images.append(cell_imgs)
if any(row_texts) or any(row_cell_images):
all_rows.append(row_texts)
all_images.append(row_cell_images)
if len(all_rows) >= 2:
# Heuristic: first row is a header if every cell is short
first_row = all_rows[0]
has_header = all(len(c) < self.HEADER_CELL_MAX_LEN for c in first_row)
if has_header:
headers = first_row
data_rows_slice = zip(all_rows[1:], all_images[1:])
else:
headers = [f"{ci + 1}" for ci in range(len(first_row))]
data_rows_slice = zip(all_rows, all_images)
data_rows: list[dict] = []
for ri, (row_data, row_imgs) in enumerate(data_rows_slice):
columns: list[dict] = []
max_cols = max(len(headers), len(row_data))
for ci in range(max_cols):
hdr = headers[ci] if ci < len(headers) else ""
txt = row_data[ci] if ci < len(row_data) else ""
columns.append({
"name": hdr,
"row": ri + 1,
"col": ci + 1,
"text": txt,
})
# Register image sources with structured location
imgs = row_imgs[ci] if ci < len(row_imgs) else []
for rid in imgs:
image_sources[rid] = {
"section": current_source,
"table": tbl_idx,
"row": ri + 1,
"column": ci + 1,
"name": hdr,
}
data_rows.append({"columns": columns})
blocks.append({
"type": "table",
"table": tbl_idx,
"headers": headers,
"rows": data_rows,
})
elif all_rows:
# Degenerate table (only header or single row) — treat as plain rows
for ri, row_data in enumerate(all_rows):
row_text = " | ".join(row_data)
if row_text.strip():
blocks.append({
"type": "para",
"index": para_idx + 1,
"text": row_text,
})
para_idx += 1
if blocks or section_images:
sections.append({
"source": current_source,
"blocks": blocks,
"images": list(section_images),
})
return sections, image_sources
# ---- internals ----------------------------------------------------------
def _heading_level(self, para: Paragraph) -> int | None:
"""Heading level 1-9, or *None* if not a heading."""
if para.style and para.style.name:
name = para.style.name
for prefix in ("Heading", "标题"):
if name.startswith(prefix):
try:
return int(name.split()[-1])
except (ValueError, IndexError):
pass
pPr = para._element.find(f"{{{self.WML_NS}}}pPr")
if pPr is not None:
ol = pPr.find(f"{{{self.WML_NS}}}outlineLvl")
if ol is not None:
val = ol.get(f"{{{self.WML_NS}}}val")
if val is not None:
try:
return int(val) + 1
except ValueError:
pass
return None
def _images_in(self, element) -> list[str]:
"""Return rId values for drawings embedded in *element*."""
rids: list[str] = []
for drawing in element.findall(f".//{{{self.WML_NS}}}drawing"):
blip = drawing.find(f".//{{{self.DRAW_NS}}}blip")
if blip is not None:
rid = blip.get(f"{{{self.REL_NS}}}embed")
if rid:
rids.append(rid)
return rids
@@ -0,0 +1,34 @@
---
name: ir_generation_skill
description: 通过自然语言描述,向JSON文件中添加以IR表示的功能点。
---
# add_ir
## 概述
通过自然语言描述由大语言模型自动转换为标准IR格式并写入到JSON文件或追加到JSON文件中。
## 用法
- **命令行传入**:直接传入功能的文字描述
- **文件传入**:通过 `-f` 参数从文本文件读取描述
- **追加模式**:新生成的功能点追加到已有 `_ir.json` 末尾,不影响已有条目
## 输入要求
- 功能的自然语言描述(命令行参数或 `-f` 文件)
- 目标IR JSON文件路径(`--ir`,默认 `output/ir.json`
## 输出
在目标IR JSON文件中追加新的功能点条目,每个条目包含:
- `function`:功能名称
- `source.section`:所属章节
- `source.location`:位置描述
- `trigger`:触发条件(可选)
- `actions`:执行动作(可选)
## 使用提示
描述中应尽量包含功能的触发条件、执行动作和所属章节信息,以提高转换质量。
@@ -0,0 +1,105 @@
import logging
import os
import time
from typing import Optional
from openai import OpenAI
logger = logging.getLogger(__name__)
class LLMClient:
"""Low-level OpenAI-compatible LLM client with retry and token tracking.
Usage::
llm = LLMClient()
content = llm.chat("qwen3.5-flash", [{"role": "user", "content": "Hello"}])
print(llm.usage)
"""
IMAGE_MODEL = "qwen3-vl-plus"
TEXT_MODEL = "qwen3.5-flash-2026-02-23"
TIMEOUT = 120
MAX_RETRIES = 3
def __init__(
self,
*,
base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1",
timeout: int | None = None,
):
key = os.environ.get("DASHSCOPE_API_KEY", "")
if not key:
raise ValueError("DASHSCOPE_API_KEY environment variable is not set.")
self._client = OpenAI(api_key=key, base_url=base_url)
self._timeout = timeout or self.TIMEOUT
self._prompt_tokens = 0
self._completion_tokens = 0
@property
def usage(self) -> dict:
"""Return accumulated token counts as ``{prompt, completion, total}``."""
return {
"prompt_tokens": self._prompt_tokens,
"completion_tokens": self._completion_tokens,
"total_tokens": self._prompt_tokens + self._completion_tokens,
}
@staticmethod
def estimate_tokens(text: str) -> int:
"""Quick token estimate. CJK ≈1.7/token, others ≈3.0/token."""
cjk = sum(1 for c in text if '' <= c <= '鿿' or ' ' <= c <= '')
other = len(text) - cjk
return max(1, int(cjk / 1.7 + other / 3.0))
@staticmethod
def estimate_image_tokens() -> int:
"""Fixed estimate for one vision-model image (~500 tokens)."""
return 500
def chat(
self, model: str, messages: list[dict], *, timeout: int | None = None,
response_format: dict | None = None,
) -> str:
"""Send a chat completion request and return the response content.
Automatically retries on failure and accumulates token usage.
"""
label = f"chat({model})"
def _call():
t0 = time.time()
kwargs = dict(model=model, messages=messages, timeout=timeout or self._timeout)
if response_format is not None:
kwargs["response_format"] = response_format
kwargs["temperature"] = 0
resp = self._client.chat.completions.create(**kwargs)
content = resp.choices[0].message.content
usg = resp.usage
if usg:
self._prompt_tokens += usg.prompt_tokens
self._completion_tokens += usg.completion_tokens
elapsed = time.time() - t0
logger.info("%s: %d chars in %.1fs", label, len(content) if content else 0, elapsed)
if not content:
raise RuntimeError("Empty response from LLM")
return content
return self._retry(_call, label)
def _retry(self, fn, label: str) -> str:
"""Call *fn()* with exponential-backoff retry."""
last_error: Optional[Exception] = None
for attempt in range(self.MAX_RETRIES):
try:
return fn()
except Exception as e:
last_error = e
logger.warning(
"%s error (attempt %d/%d): %s",
label, attempt + 1, self.MAX_RETRIES, e,
)
if attempt < self.MAX_RETRIES - 1:
time.sleep(2 ** attempt)
raise RuntimeError(f"{label}: all retries exhausted") from last_error
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Add missing features to an existing ``_ir.json``.
Converts a natural-language description of missing features into structured
IR entries via LLM, then appends them to the IR file.
Usage::
python scripts/add_missing.py "遗漏功能描述" --ir output/<basename>_ir.json
python scripts/add_missing.py -f missing.txt --ir output/<basename>_ir.json
"""
import argparse
import json
import logging
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from LLM import LLMClient
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Prompt
# ---------------------------------------------------------------------------
PROMPT = """你是一个需求文档分析助手。用户发现已生成的功能点列表中有遗漏,请将以下描述中提到的遗漏功能点转换为标准JSON格式。
## 遗漏功能描述
{description}
## JSON格式要求
每个功能点输出为:
{
"function": "功能名称",
"source": {
"section": "章节名(如果描述中提到了章节信息则填写,否则填"未知"",
"location": "原文位置描述(如果描述中提供了则填写,否则填"用户补充""
},
"trigger": {
"type": "AND或者OR",
"conditions": [
"触发条件1",
"触发条件2"
]
},
"actions": {
"场景/角色": [
"动作1",
"动作2"
]
}
}
## 输出原则
1. 将描述中的每个独立功能点拆分为一个独立的JSON对象
2. 如果描述中包含多个功能点,输出一个JSON数组
3. 没有 trigger 或 actions 的字段直接**省略**,不要写 null 或空列表/空对象
4. 尽量从描述中推断 source.section 和 source.location
5. 功能名称应简洁明确,概括该功能的核心行为
6. 直接输出纯JSON,不要用 ```json 代码块包裹
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_llm_response(raw: str) -> list | dict | str | None:
"""Parse JSON from LLM response, handling markdown code fences."""
if raw is None:
return None
stripped = raw.strip()
if stripped.startswith("```"):
nl = stripped.find("\n")
stripped = stripped[nl + 1:] if nl != -1 else stripped[3:]
if stripped.endswith("```"):
stripped = stripped[:-3]
try:
return json.loads(stripped)
except json.JSONDecodeError:
logger.warning("Failed to parse JSON, returning raw text")
return raw
# ---------------------------------------------------------------------------
# Core
# ---------------------------------------------------------------------------
def add_missing(description: str, ir_path: str) -> list[dict]:
"""Convert missing feature description to IR entries and append to IR file.
Returns the newly added entries.
"""
llm = LLMClient()
prompt = PROMPT.replace("{description}", description)
logger.info("Sending missing features to LLM...")
try:
raw = llm.chat(
model=LLMClient.TEXT_MODEL,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
)
logger.info("Response: %d chars", len(raw))
except RuntimeError as e:
logger.error("LLM call failed: %s", e)
sys.exit(1)
parsed = _parse_llm_response(raw)
if isinstance(parsed, list):
new_entries = parsed
elif isinstance(parsed, dict):
new_entries = [parsed]
else:
logger.error("Unparseable response")
sys.exit(1)
# Load existing IR or start fresh
if os.path.exists(ir_path):
with open(ir_path, "r", encoding="utf-8") as f:
existing = json.load(f)
logger.info("Loaded existing IR: %d entries", len(existing))
else:
existing = []
logger.info("No existing IR file, creating new")
existing.extend(new_entries)
# Write back
os.makedirs(os.path.dirname(ir_path) or ".", exist_ok=True)
with open(ir_path, "w", encoding="utf-8") as f:
json.dump(existing, f, ensure_ascii=False, indent=2)
logger.info("Saved: %s (%d entries, +%d new)", ir_path, len(existing), len(new_entries))
usg = llm.usage
logger.info("Tokens: %d prompt + %d completion = %d total",
usg["prompt_tokens"], usg["completion_tokens"], usg["total_tokens"])
return new_entries
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Add missing features to an IR JSON file via LLM.",
)
parser.add_argument(
"description", nargs="?", metavar="description",
help="Natural-language description of missing features",
)
parser.add_argument(
"-f", "--file", metavar="file.txt",
help="Read description from a file instead of command line",
)
parser.add_argument(
"--ir", default="output/ir.json", metavar="ir.json",
help="Path to the IR JSON file (default: output/ir.json)",
)
args = parser.parse_args()
if args.file:
with open(args.file, "r", encoding="utf-8") as f:
description = f.read().strip()
elif args.description:
description = args.description
else:
parser.error("Either provide a description string or use -f to read from a file")
if not description:
parser.error("Description is empty")
add_missing(description, args.ir)
@@ -0,0 +1,42 @@
---
name: 解决方案应用技能
description: 应用用户提供的冲突解决方案以创建合并了更正的更新文档表示。
---
# 解决方案应用技能
## 概述
此技能采用用户提供的冲突解决方案并将它们应用到解析的文档中,创建一个协调文本和视觉内容差异的校正版本。它生成包含应用更正的更新文档表示。
## 功能
该技能:
- 接受用户提供的冲突解决方案决策
- 支持多种解决方案类型:"以图像为准"、"以文字为准"、"两处都保留"或自定义文本
- 更新解析的文档结构以合并解决方案决策
- 创建文档表示的校正版本,包含应用的更改
- 维护所有应用更正的源可追溯性
- 向输出添加包含更正指令的resolved_conflicts数组
## 输入要求
- 解析文档JSON文件的路径(带有已识别的冲突)
- 包含用户决策的解决方案JSON文件的路径
- 可选输出目录规范
- 解决方案JSON应包含具有以下内容的对象:
- `conflict_id`:冲突数组中的冲突索引
- `resolution`:决策类型("以图片为准"、"以文字为准"、"两处都保留")或自定义文本
- `custom_text`:解决方案的可选自定义文本
## 输出
该技能生成一个结构化JSON文件,文件名为输入文档的基本名称后跟'_updated.json',包含:
- 包含应用更正的原始文档结构
- 详细说明应用更改的`resolved_conflicts`数组
- 关于每个冲突类型和应用更正的信息
- 用于可追溯性的源跟踪信息
## 集成点
此技能消耗冲突检测技能的输出(带冲突的文档)和用户提供的解决方案。其输出被IR生成技能使用以创建最终结构化表示。
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Apply user resolutions to ``_parsed.json`` using ``_conflicts.json``.
Usage::
python scripts/apply_resolutions.py <parsed.json> --resolutions <resolutions.json> [--output-dir DIR]
The *resolutions.json* file is created by the agent after user arbitration.
Each resolution maps a conflict_id to a decision.
Resolution format (``resolutions.json``)::
[
{
"conflict_id": 0, // 0-based index into conflicts array
"resolution": "以文字为准",
"custom_text": null
}
]
Outputs ``<basename>_updated.json`` — identical to *parsed.json* plus a
``resolved_conflicts`` top-level array with correction instructions for the IR generator.
"""
import argparse
import json
import logging
import os
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
def apply_resolutions(
parsed_path: str,
resolutions_path: str,
output_dir: str | None = None,
) -> dict:
"""Load *parsed.json*, apply resolutions, write *updated.json*."""
with open(parsed_path, "r", encoding="utf-8") as f:
data = json.load(f)
with open(resolutions_path, "r", encoding="utf-8") as f:
resolutions = json.load(f)
base_dir = os.path.dirname(os.path.abspath(parsed_path))
# Try to find _conflicts.json alongside parsed.json
basename = os.path.splitext(os.path.basename(parsed_path))[0]
stem = basename[:-7] if basename.endswith("_parsed") else basename
candidate = os.path.join(base_dir, f"{stem}_conflicts.json")
conflicts = data.get("_conflicts", [])
if not conflicts and os.path.isfile(candidate):
with open(candidate, "r", encoding="utf-8") as f:
conflicts = json.load(f)
if output_dir is None:
output_dir = base_dir
os.makedirs(output_dir, exist_ok=True)
# Build resolved_conflicts with correction instructions for ir_generator
resolved = []
for res in resolutions:
cid = res.get("conflict_id")
if cid is None or cid < 0 or cid >= len(conflicts):
logger.warning("Invalid conflict_id: %s", cid)
continue
conflict = conflicts[cid]
choice = res.get("resolution", "")
custom = res.get("custom_text")
entry = {
"conflict_id": cid,
"conflict_type": conflict.get("conflict_type"),
"section": conflict.get("section", ""),
"resolution": choice,
}
# Build a correction instruction string
image_val = conflict.get("image_snippet", "")
text_val = conflict.get("text_snippet", "")
if choice == "以图片为准":
entry["correction"] = image_val
entry["source"] = "图片"
elif choice == "以文字为准":
entry["correction"] = text_val
entry["source"] = "文字"
elif choice == "两处都保留":
entry["correction"] = f"{text_val}(另外的观点:{image_val}"
entry["source"] = "两者兼容"
elif custom:
entry["correction"] = custom
entry["source"] = "自定义"
logger.info("Conflict %d: custom: %s", cid, custom[:60])
else:
entry["correction"] = text_val
entry["source"] = "文字(默认)"
logger.warning("Conflict %d: unknown resolution '%s', defaulting to text", cid, choice)
logger.info("Conflict %d: %s%s", cid, choice, entry["source"])
resolved.append(entry)
data["resolved_conflicts"] = resolved
logger.info("Applied %d resolutions", len(resolved))
# Write output
if basename.endswith("_parsed"):
out_name = f"{stem}_updated.json"
else:
out_name = f"{basename}_updated.json"
output_path = os.path.join(output_dir, out_name)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info("Saved: %s", output_path)
return data
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Apply user resolutions to parsed.json.",
)
parser.add_argument("input", metavar="parsed.json",
help="Path to _parsed.json")
parser.add_argument("--resolutions", "-r", required=True,
help="Path to resolutions JSON file")
parser.add_argument("--output-dir", default=None,
help="Output directory (default: same as input)")
args = parser.parse_args()
apply_resolutions(args.input, args.resolutions, args.output_dir)
+207
View File
@@ -0,0 +1,207 @@
# 任务:重写 IR Generation,实现高可靠性、可测试的中间表示提取
## 一、背景与目标
我们正在为吉利汽车车机测试团队验证 AI 自动生成测试用例的可行性。输入是产品需求文档(PRD),输出是 Robot Framework 测试脚本。
当前流程中,文档解析(doc_parser)已稳定,能将 Word 文档转为结构化 JSON,并将流程图图片转为精确的 `logic_tree` JSON。但直接基于这份 JSON 生成 IR(中间表示)时,LLM 经常丢失分支,稳定性差。
现在需要重新实现 IR 生成,采用“分阶段理解 + 程序化校验”架构,彻底解决分支丢失问题。
**核心原则**:LLM 负责“关联理解”和“受限翻译”,程序负责“确定性校验”和“完整性审计”。
## 二、已有资产与路径
- **输入文件**`C:\Users\peterz\.openclaw\workspace\skills\doc_parser_skill\output\车机娱乐系统禁止功能文档_脱敏 v0.9_v2_updated.json`
- 结构:包含 `sections` 数组和 `image_analysis` 数组。
- `sections` 每个元素有 `source`(章节标题)、`blocks``para``table`)、`images`(图片引用 ID 列表)。
- `image_analysis` 中,流程图类型的图片包含 `logic_tree`,是一个有根、有节点、有分支的树形结构,分支准确。
- **工作目录**`C:\Users\peterz\.openclaw\workspace\skills\ir_generation_new_skill`
- 请在此目录下创建 Python 脚本、Prompt 模板、测试等。
- **最终输出文件**:放在 `C:\Users\peterz\.openclaw\workspace\skills\doc_parser_skill\output\`
- `ir_final.json`:最终的 IR JSON。
- `ir_audit_report.md`:完整性审计报告,供人工快速审查。
## 三、IR Schema 定义
最终 IR 是一组**可测试的功能规则**,每条规则包含触发条件、动作、来源锚点,足以让规则引擎自动生成覆盖正常/边界/异常的 Robot Framework 测试用例。
**目标 Schema(示例)**
```json
{
"feature": "行车娱乐限制",
"feature_id": "DRL-001",
"rules": [
{
"rule_id": "DRL-001-SYS-FG-01",
"description": "开关开启,系统限制应用在前台,车速≥15km/h 且持续超过5秒且非P档时,应用被打断并退至后台,同时弹出特定 Toast",
"priority": "P0",
"sources": [
{"type": "table", "section": "3.1.1", "row": 2},
{"type": "logic_tree", "image_id": "rId16", "node_ids": ["n19","n21","n23","n25","n26"]}
],
"precondition": {
"switch": "开启",
"app_type": "系统限制",
"app_state": "前台"
},
"trigger": {
"operator": "AND",
"conditions": [
{"signal": "车速", "operator": ">=", "value": 15, "unit": "km/h"},
{"signal": "车速_持续时间", "operator": ">", "value": 5, "unit": "秒"},
{"signal": "档位", "operator": "!=", "value": "P"}
]
},
"actions": [
{"type": "system", "description": "打断应用前台进程"},
{"type": "system", "description": "将应用调入后台"},
{"type": "user_interaction", "description": "显示Toast", "content": "在行车状态下无法使用该应用"}
]
}
]
}
注:具体字段可根据实际文档内容灵活扩充,但必须包含 rule_id、description、sources、trigger、actions。
## 四、新实现流程(分三个阶段)
阶段一:宏观语义索引(Semantic Index
目标:让 LLM 一次性阅读整个文档,生成一份语义索引,识别出所有功能单元(function unit)并建立概念映射,但不提取具体规则细节。
输入:完整的 xxx_parsed.json(所有 sections + image_analysis)。
LLM 调用:一次。
任务要求:
编写一个 Python 脚本 step1_semantic_index.py,其功能:
读取输入 JSON 文件。
构造 Prompt,要求 LLM 输出如下结构的 JSON:
json
{
"feature_name": "行车娱乐限制",
"concepts": [
{"name": "行车娱乐限制", "aliases": ["行车娱乐限制", "行车娱乐禁止"], "defined_in": ["3.1", "3.1.1"]},
{"name": "系统限制", "aliases": [], "defined_in": ["3.1", "3.1.1"]},
...
],
"function_units": [
{
"unit_id": "FU-001",
"name": "系统限制-前台-行车打断",
"description": "当开关开启、应用为系统限制类型且处于前台时,满足车速和档位条件后,系统打断应用并显示Toast",
"sources": [
{"section": "3.1.1", "type": "table", "row": 2, "text_snippet": "打断:车速≥15km/h...退至后台"},
{"image_id": "rId16", "logic_tree_nodes": ["n19","n21","n23","n25","n26"]},
{"image_id": "rId17", "logic_tree_nodes": ["n1","n2","n3"]}
]
},
...
]
}
Prompt 中必须强调:功能单元应覆盖文档描述的所有主要行为,特别是图片逻辑树中的决策路径。不允许遗漏分支。
调用 LLM(可以使用 anthropic.Anthropic 或内部 API),将结果保存为 semantic_index.json。
自检测试:编写 test_step1.py,读取 semantic_index.json 并验证:
所有 function_units 的 sources 中引用的 image_id 必须存在于原输入的 image_analysis 中。
每个 function_unit 至少引用一张图片或一段文字。
所有 logic_tree_nodes 引用的节点 ID 必须在对应 logic_tree 的节点 id 中存在。
无空的 unit_id 或 name。
迭代指引:如果测试失败,分析原因,调整 Prompt(增加更严格的结构化输出指令、Few-shot 示例)并重新运行,直到全部通过。
## 阶段二:逐功能单元 IR 提取
目标:对阶段一产出的每个功能单元,准备一个仅含相关上下文的数据包,让 LLM 据此填充详细的 IR 规则。
输入:semantic_index.json 和原始文档 JSON。
LLM 调用:每个功能单元一次,可并行。
任务要求:
编写 step2_ir_extraction.py
加载 semantic_index.json 和原始文档 JSON。
为每个 function_unit 构造一个“精准上下文包”:
从原始文档中提取该单元引用的具体段落、表格行、以及完整的 logic_tree(如果引用了节点,则提取相关子树;简单起见可以提取整棵树)。
上下文包示例结构:
json
{
"unit_id": "FU-001",
"unit_name": "系统限制-前台-行车打断",
"texts": ["打断:车速≥15km/h且持续5秒后,将目标应用/功能退至后台或暂停对应功能..."],
"tables": [{"headers":["功能","功能详细说明"], "rows":[...]}],
"logic_trees": [
{"image_id": "rId16", "tree": {...}},
{"image_id": "rId17", "tree": {...}}
]
}
构造 Prompt,要求 LLM 输出一个或多个符合 IR Schema 的规则 JSON 对象(数组 rules)。
强制要求:
每条规则的 sources 必须包含引用的逻辑树节点 ID 列表和文本来源。
触发条件必须精确,包含运算符和数值(如 >=15),不可模糊。
动作必须明确区分系统行为和用户可见交互。
如果文档中存在矛盾(如图片和文字冲突),请优先采用图片逻辑树,并在 description 中注明差异。
提供 IR Schema 和 1-2 个 Few-shot 示例。
将所有功能单元产出的规则数组合并,保存为 ir_fragments.json,格式:[ { "unit_id": "FU-001", "rules": [...] }, ... ]
自检测试:编写 test_step2.py
验证每个 ir_fragment 的 rules 非空。
验证每条规则的 sources 中有逻辑树节点引用。
验证 trigger.conditions 中的每个条件均有 signal、operator、value。
检查是否有重复的 rule_id(可暂时用 unit_id + 序号,但最终合并时会处理)。
迭代指引:如果某些片段规则为空或条件缺失,检查是否为上下文包裁剪太狠(丢失了关键文本),优化提取逻辑或调整 Prompt,再次运行。
阶段三:确定性合并与完整性校验
目标:用程序逻辑将多个片段的规则去重、合并,并基于逻辑树节点覆盖率生成审计报告,告知人工哪里可能遗漏。
LLM 调用:无(或用极简 LLM 做最终文案规范化)。
任务要求:
编写 step3_merge_and_audit.py
加载 ir_fragments.json 和原始文档 JSON(含所有 logic_tree 节点)。
合并去重:
按 trigger 和 actions 的语义相似度进行规则合并。简化方案:如果两条规则的触发条件和动作完全相同(值比较),只保留一条,合并 sources。
合并后重新分配稳定的 rule_id(如 DRL-001-SYS-FG-01)。
完整性审计(生成 ir_audit_report.md):
逻辑树节点覆盖率:遍历所有 logic_tree 中的每个 decision 和 action 节点,检查是否有至少一条规则的 sources 引用了该节点。列出未被覆盖的节点及其所在的图片 ID。
表格枚举覆盖:识别所有表格中列出“应用类型”、“限制方式”的枚举值,检查这些值是否出现在规则的 precondition 或条件中。
全局开关覆盖:检查所有涉及开关状态的规则,是否完整覆盖了“开启”和“关闭”两种状态。
报告格式:Markdown 表格,列出检查项、状态(✅/⚠️/❌)、详情。
合并后的最终 IR 保存为 ir_final.json。
人类检查提示:审计报告最上方用醒目的文字说明:“请人工审查以下 ⚠️ 和 ❌ 项,确认是文档遗漏还是 IR 提取遗漏。如无需修改,则在对应项后标注‘已确认’。”
编写 test_step3.py,验证:
ir_final.json 中无重复 rule_id。
所有 rule_id 符合命名规范(DRL-001-...)。
ir_audit_report.md 文件存在且包含覆盖率统计。
迭代指引:如果审计报告显示大量节点未覆盖,需回溯检查阶段二的提取质量,可能是某些功能单元未被识别,或提取时丢失了节点引用。可补充遗漏的单元,重新提取片段后再次合并审计。
## 五、总体执行顺序与依赖
首先实现并运行 step1_semantic_index.py,通过测试。
接着运行 step2_ir_extraction.py,通过测试。
最后运行 step3_merge_and_audit.py,生成最终交付物。
所有脚本应可重复执行,且运行 python main.py(如有)可一键完成所有步骤。
六、开发与协作要求
所有 Python 代码需有清晰的注释,关键函数有 docstring。
Prompt 模板可以单独作为 .txt 或 .md 文件存放,便于调试。
测试脚本独立,并输出明确的通过/失败信息。
如果某个阶段需要人类帮助(例如需要确认某个功能划分是否合理),请在该阶段输出清晰的问题并暂停,等待人类回复后继续。
你将在工作目录 C:\Users\peterz\.openclaw\workspace\skills\ir_generation_new_skill 中完成所有工作。
现在开始执行。首先请通读输入文件(路径已给出),理解其结构,然后着手实现阶段一。
+121
View File
@@ -0,0 +1,121 @@
"""
Shared configuration for the IR Generation pipeline.
Reads API keys from a secrets.yaml file, falling back to environment variables.
"""
import os
import json
import yaml
# ---- Paths ----
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
WORKSPACE_DIR = os.path.dirname(BASE_DIR)
DOC_PARSER_OUTPUT = os.path.join(WORKSPACE_DIR, "doc_parser_skill", "output")
PROMPTS_DIR = os.path.join(BASE_DIR, "prompts")
TESTS_DIR = os.path.join(BASE_DIR, "tests")
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
# Input file (the parsed PRD JSON)
_DEFAULT_INPUT = os.path.join(
DOC_PARSER_OUTPUT,
"车机娱乐系统禁止功能文档_脱敏 v0.9_v2_updated.json",
)
INPUT_JSON = os.environ.get("IR_INPUT_JSON", _DEFAULT_INPUT)
def set_input_file(path: str) -> None:
"""Override the default input JSON path."""
global INPUT_JSON
INPUT_JSON = path
# Secrets file (shared with workspace-document-analyzer)
# .openclaw/workspace/skills/ir_generation_new_skill -> .openclaw/workspace-document-analyzer
OPENCLAW_HOME = os.path.dirname(os.path.dirname(WORKSPACE_DIR))
SECRETS_YAML = os.path.join(
OPENCLAW_HOME, "workspace-document-analyzer", "config", "secrets.yaml",
)
# Intermediate outputs
SEMANTIC_INDEX_JSON = os.path.join(OUTPUT_DIR, "semantic_index.json")
IR_FRAGMENTS_JSON = os.path.join(OUTPUT_DIR, "ir_fragments.json")
# Final deliverables (placed in doc_parser output per spec)
IR_FINAL_JSON = os.path.join(DOC_PARSER_OUTPUT, "ir_final.json")
IR_AUDIT_REPORT_MD = os.path.join(DOC_PARSER_OUTPUT, "ir_audit_report.md")
# ---- LLM API ----
# Choose provider: "deepseek" | "dashscope"
LLM_PROVIDER = os.environ.get("IR_PROVIDER", "deepseek")
# Model names per provider
PROVIDER_MODELS = {
"deepseek": os.environ.get("IR_MODEL", "deepseek-v4-pro"),
"dashscope": os.environ.get("IR_MODEL", "qwen-max"),
}
MODEL_NAME = PROVIDER_MODELS.get(LLM_PROVIDER, PROVIDER_MODELS["deepseek"])
# Maximum tokens for LLM responses
MAX_TOKENS = int(os.environ.get("IR_MAX_TOKENS", "16000"))
TEMPERATURE = float(os.environ.get("IR_TEMPERATURE", "0.1"))
def _load_secrets() -> dict[str, dict[str, str]]:
"""Load provider credentials from secrets.yaml.
Returns a dict like: {"deepseek": {"apiKey": "...", "baseUrl": "..."}, ...}
"""
if os.path.isfile(SECRETS_YAML):
with open(SECRETS_YAML, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
return {}
def _get_provider_config(provider: str) -> dict[str, str]:
"""Get {apiKey, baseUrl} for a provider from secrets, with env-var fallback."""
secrets = _load_secrets()
entry = secrets.get(provider, {})
env_prefix = provider.upper()
api_key = (
os.environ.get(f"{env_prefix}_API_KEY")
or entry.get("apiKey", "")
)
base_url = (
os.environ.get(f"{env_prefix}_BASE_URL")
or entry.get("baseUrl", "https://api.deepseek.com/v1")
)
if not api_key:
raise RuntimeError(
f"No API key found for provider '{provider}'. "
f"Check {SECRETS_YAML} or set {env_prefix}_API_KEY."
)
return {"apiKey": api_key, "baseUrl": base_url}
def llm_client():
"""Return an OpenAI-compatible client configured from secrets.yaml."""
from openai import OpenAI
cfg = _get_provider_config(LLM_PROVIDER)
return OpenAI(base_url=cfg["baseUrl"], api_key=cfg["apiKey"])
def load_input_document(path: str | None = None) -> dict:
"""Load the parsed PRD JSON document."""
path = path or INPUT_JSON
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_json(data, path: str) -> None:
"""Save data as formatted JSON."""
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_json(path: str) -> dict:
"""Load a JSON file."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
+134
View File
@@ -0,0 +1,134 @@
"""
IR Generation Pipeline Orchestrator.
Run all three stages sequentially:
python main.py [--skip-step1] [--skip-step2] [--skip-step3] [--test-only]
The pipeline reads the parsed PRD JSON from doc_parser and produces:
- ir_final.json: the final IR rules
- ir_audit_report.md: completeness audit report for human review
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
BASE_DIR = Path(__file__).parent
def _subprocess_env(extra: dict | None = None) -> dict:
"""Build environment dict for subprocesses, carrying forward overrides."""
env = os.environ.copy()
env.update(extra or {})
return env
def run_step(script_name: str, description: str, extra_env: dict | None = None) -> bool:
"""Run a single pipeline step script, return True if it succeeded."""
print(f"\n{'#' * 60}")
print(f"# {description}")
print(f"{'#' * 60}")
script_path = BASE_DIR / script_name
if not script_path.exists():
print(f"错误: 脚本不存在 {script_path}")
return False
result = subprocess.run(
[sys.executable, str(script_path)],
cwd=str(BASE_DIR),
env=_subprocess_env(extra_env),
)
return result.returncode == 0
def run_test(test_name: str, description: str, extra_env: dict | None = None) -> bool:
"""Run a test script, return True if all tests passed."""
print(f"\n{'='*60}")
print(f"测试: {description}")
print(f"{'='*60}")
test_path = BASE_DIR / "tests" / test_name
if not test_path.exists():
print(f"错误: 测试脚本不存在 {test_path}")
return False
result = subprocess.run(
[sys.executable, str(test_path)],
cwd=str(BASE_DIR),
env=_subprocess_env(extra_env),
)
return result.returncode == 0
def main():
parser = argparse.ArgumentParser(description="IR Generation Pipeline")
parser.add_argument("--skip-step1", action="store_true", help="跳过阶段一(语义索引)")
parser.add_argument("--skip-step2", action="store_true", help="跳过阶段二(IR 提取)")
parser.add_argument("--skip-step3", action="store_true", help="跳过阶段三(合并与审计)")
parser.add_argument("--test-only", action="store_true", help="仅运行测试,不调用 LLM")
parser.add_argument(
"--input", "-i", type=str, default=None,
help="输入 JSON 文件路径(覆盖默认的 doc_parser 输出)"
)
parser.add_argument(
"--provider", "-p", type=str, default=None,
help="LLM provider: deepseek | dashscope(覆盖 IR_PROVIDER 环境变量)"
)
args = parser.parse_args()
# Build extra env vars for subprocesses
extra_env = {}
if args.input:
extra_env["IR_INPUT_JSON"] = args.input
print(f"输入文件: {args.input}")
if args.provider:
extra_env["IR_PROVIDER"] = args.provider
print(f"LLM Provider: {args.provider}")
if args.test_only:
all_ok = True
all_ok &= run_test("test_step1.py", "Step 1 验证", extra_env)
all_ok &= run_test("test_step2.py", "Step 2 验证", extra_env)
all_ok &= run_test("test_step3.py", "Step 3 验证", extra_env)
sys.exit(0 if all_ok else 1)
failures = []
# Stage 1
if not args.skip_step1:
ok = run_step("step1_semantic_index.py", "阶段一:宏观语义索引", extra_env)
if not ok:
failures.append("阶段一")
print("\n阶段一失败,停止流水线。修复后重试。")
sys.exit(1)
run_test("test_step1.py", "Step 1 验证", extra_env)
# Stage 2
if not args.skip_step2:
ok = run_step("step2_ir_extraction.py", "阶段二:逐功能单元 IR 提取", extra_env)
if not ok:
failures.append("阶段二")
print("\n阶段二失败,停止流水线。修复后重试。")
sys.exit(1)
run_test("test_step2.py", "Step 2 验证", extra_env)
# Stage 3
if not args.skip_step3:
ok = run_step("step3_merge_and_audit.py", "阶段三:确定性合并与完整性校验", extra_env)
if not ok:
failures.append("阶段三")
sys.exit(1)
run_test("test_step3.py", "Step 3 验证", extra_env)
if failures:
print(f"\n失败阶段: {', '.join(failures)}")
sys.exit(1)
print(f"\n{'='*60}")
print("流水线全部完成!")
print(f"最终 IR: config.IR_FINAL_JSON")
print(f"审计报告: config.IR_AUDIT_REPORT_MD")
print(f"{'='*60}")
if __name__ == "__main__":
main()
@@ -0,0 +1,69 @@
你是吉利汽车车机系统(XX Auto)的产品需求分析师。你的任务是从行车娱乐限制功能 PRD 文档中提取"语义索引"——一份结构化的功能清单,而不是逐字翻译。
## 文档结构说明
下面是一份 Word 文档的解析结果,包含:
1. **sections**:按章节组织的混合内容(段落 + 表格),每个 section 有 `source`(章节标题)、`blocks``para` 文本段落和 `table` 结构表格)、`images`(引用的图片 ID 列表)
2. **image_analysis**:文档中流程图的程序化分析结果,其中 `logic_tree` 是由节点组成的决策树:
- `state` 节点:状态说明
- `decision` 节点:判断条件 + `branches`(分支值 → 目标节点 ID)
- `action` 节点:系统或用户交互动作
3. **resolved_conflicts**:文档中图文冲突的仲裁结果,明确指出应以文字还是图片为准
## 文档全文
{document_json}
## 你的任务
阅读整份文档后,输出一份 **语义索引 JSON**,包含:
### 1. feature_name
功能名称,如"行车娱乐限制"
### 2. concepts
文档中定义或使用的关键概念列表。每个概念包含:
- `name`:概念的标准名称
- `aliases`:同义词/别名列表(如"行车娱乐限制"、"行车娱乐禁止"
- `defined_in`:定义该概念的章节号列表(如 ["3.1", "3.1.1"]
应识别的概念类型包括但不限于:功能名称、应用类型(系统限制、SDK限制、其他应用)、限制方式(打断、禁止、暂停)、触发条件(车速、档位、持续时间)、开关状态等。
### 3. function_units
文档中描述的所有主要功能行为的列表。**每个 function_unit 对应一条完整的"如果...则..."规则**。每个 function unit 包含:
- `unit_id`:唯一标识,格式 "FU-001", "FU-002"...
- `name`:简短名称,如"系统限制-前台-行车打断"
- `description`1-3 句描述该规则的行为
- `sources`:该规则在文档中的来源锚点列表,每项包含:
- `section`:章节号
- `type`:来源类型,`"table"` 或 `"para"` 或 `"logic_tree"`
- `row`:如果是表格行(从 1 开始)
- `text_snippet`:前 200 字的关键文字
- `image_id`:如果是逻辑树来源,填写图片 rId
- `logic_tree_nodes`:如果是逻辑树来源,列出相关节点 ID 列表
## 关键要求
1. **必须覆盖所有逻辑树分支**:遍历每个 `logic_tree` 中从根到叶的每条决策路径,确保它们都出现在某个 function_unit 中。逻辑树中的每个 `decision` 节点及其分支、每个 `action` 节点都必须被至少一个 function_unit 引用。
2. **必须覆盖表格中的所有规则**:表格中列出的每种"限制方法"、"限制规则"都要有对应的 function_unit。
3. **区分"限制"与"禁止"**:文档中"行车娱乐限制"和"行车娱乐禁止"是两个不同的子场景(一个针对前台应用打断、一个针对后台应用启动限制),必须分别建模。
4. **区分不同应用类型**:系统限制、SDK 限制、其他应用的行为路径不同。
5. **包含开关状态**:开关"开启"和"关闭"两种状态下的行为都要覆盖。
6. **如果 resolved_conflicts 中以图片为准**,则优先按逻辑树的路径描述行为;**如果以文字为准**,则优先按表格文字描述。
## 输出格式
**只输出 JSON,不要有 markdown 代码块标记或其他文字**:
{
"feature_name": "...",
"concepts": [ ... ],
"function_units": [ ... ]
}
+162
View File
@@ -0,0 +1,162 @@
你是吉利汽车车机系统的需求分析专家。你的任务是基于给定的精准上下文包,为单个功能单元(Function Unit)提取详细的 **IR 规则(Intermediate Representation Rule**。
## 上下文
下面是一个功能单元的精准上下文包,包含了从原始需求文档中提取的相关文字、表格和逻辑树:
### 功能单元概要
- **unit_id**: {unit_id}
- **unit_name**: {unit_name}
- **unit_description**: {unit_description}
### 相关文字段落
{texts}
### 相关表格
{tables}
### 相关逻辑树
{logic_trees}
### 图文冲突仲裁(如有)
{resolved_conflicts}
## IR Schema
你需要为这个功能单元输出一个 **规则数组(rules)**。每条规则遵循以下 schema:
```json
{{
"rule_id": "{unit_id}-SYS-FG-01",
"description": "用完整的中文自然语言描述该规则的触发条件和行为,一句话概括",
"priority": "P0",
"sources": [
{{"type": "table", "section": "3.1.1", "row": 2, "text_snippet": "打断:车速≥15km/h..."}},
{{"type": "logic_tree", "image_id": "rId16", "node_ids": ["n19", "n21", "n23", "n25", "n26"]}}
],
"precondition": {{
"switch": "开启",
"app_type": "系统限制",
"app_state": "前台"
}},
"trigger": {{
"operator": "AND",
"conditions": [
{{"signal": "车速", "operator": ">=", "value": 15, "unit": "km/h"}},
{{"signal": "车速_持续时间", "operator": ">", "value": 5, "unit": "秒"}},
{{"signal": "档位", "operator": "!=", "value": "P"}}
]
}},
"actions": [
{{"type": "system", "description": "打断应用前台进程"}},
{{"type": "system", "description": "将应用调入后台"}},
{{"type": "user_interaction", "description": "显示Toast", "content": "在行车状态下无法使用该应用"}}
]
}}
```
### 字段说明(必读)
1. **rule_id**: 格式为 `{unit_id}-类型-序号`,其中类型可以是 SYS(系统行为)、UI(用户交互)、SDK(SDK 限制)。序号从 01 开始。
2. **description**: 完整但简洁地描述整个规则,包括前置条件、触发条件和所有动作。用中文。
3. **priority**: P0(核心安全规则)、P1(重要规则)、P2(边界情况)。
4. **sources**: 每条规则必须列出所有数据来源,包括:
- 引用的表格行(section, row, text_snippet
- 引用的逻辑树节点 ID 列表(image_id, node_ids)。**注意:node_ids 必须列举该规则在逻辑树中经历的所有 decision 和 action 节点。**
5. **precondition**: 规则生效的前置状态条件(开关状态、应用类型、应用前后台状态等)。可以是空对象 `{{}}` 如果无条件。
6. **trigger**: 触发条件对象,包含:
- `operator`: 条件组合方式,`"AND"` 或 `"OR"`
- `conditions`: 条件数组,每个条件必须有 `signal`(信号名)、`operator`(比较运算符)、`value`(数值或字符串)。如果有单位,加 `unit` 字段。
如果触发器是瞬时事件(如用户点击),使用 `event` 字段代替 `conditions`。
7. **actions**: 每个动作必须有 `type``"system"` 或 `"user_interaction"`)和 `description`。用户可见交互(Toast、弹窗、语音播报)必须用 `"user_interaction"` 类型,并包含 `content` 字段。
## Few-shot 示例
### 示例 1:行车娱乐限制(前台打断)
**输入上下文**:开关开启,系统限制类应用在前台,车速≥15km/h且持续>5秒且非P档时,打断应用并显示Toast。
**期望输出**
```json
{{
"rule_id": "FU-001-SYS-01",
"description": "开关开启时,系统限制类应用在前台,当车速≥15km/h且持续超过5秒且非P档时,系统打断应用前台进程、将应用调入后台,并弹出Toast提示'在行车状态下无法使用该应用'",
"priority": "P0",
"sources": [
{{"type": "table", "section": "3.1.1", "row": 2, "text_snippet": "行车娱乐限制:目标应用/功能处于前台时 ○ 打断:车速≥15km/h且持续5秒后..."}},
{{"type": "logic_tree", "image_id": "rId16", "node_ids": ["n2", "n8", "n9", "n11", "n13", "n19", "n21", "n23", "n25", "n26"]}}
],
"precondition": {{
"switch": "开启",
"app_type": "系统限制",
"app_state": "前台"
}},
"trigger": {{
"operator": "AND",
"conditions": [
{{"signal": "车速", "operator": ">=", "value": 15, "unit": "km/h"}},
{{"signal": "车速_持续时间", "operator": ">", "value": 5, "unit": "秒"}},
{{"signal": "档位", "operator": "!=", "value": "P"}}
]
}},
"actions": [
{{"type": "system", "description": "打断应用前台进程"}},
{{"type": "system", "description": "将应用调入后台"}},
{{"type": "user_interaction", "description": "显示Toast", "content": "在行车状态下无法使用该应用"}}
]
}}
```
### 示例 2:行车娱乐禁止(后台启动拦截)
**输入上下文**:开关开启,应用在后台且非前台,非P档时阻止应用启动,并提示。
**期望输出**
```json
{{
"rule_id": "FU-002-SYS-01",
"description": "开关开启时,目标应用处于后台,当档位非P档时,限制应用启动,并弹出Toast提示'请在P挡时使用该功能/应用'",
"priority": "P0",
"sources": [
{{"type": "table", "section": "3.1.1", "row": 2, "text_snippet": "行车娱乐禁止:目标应用/功能处于后台时 ○ 限制:非P挡时,限制目标应用/功能启用..."}},
{{"type": "logic_tree", "image_id": "rId17", "node_ids": ["n5", "n6"]}}
],
"precondition": {{
"switch": "开启",
"app_state": "后台"
}},
"trigger": {{
"operator": "AND",
"conditions": [
{{"signal": "应用请求启动", "operator": "==", "value": true}}
]
}},
"actions": [
{{"type": "system", "description": "限制应用/功能启用"}},
{{"type": "user_interaction", "description": "显示Toast", "content": "请在P挡时使用该功能/应用"}}
]
}}
```
## 关键要求
1. **信号和数值必须精确**:不要写"车速超过阈值",必须写 `"车速 >= 15 km/h"`。
2. **条件必须完整**:如果文档说"车速≥15km/h 且持续超过5秒 且非P档",这三个条件必须全部出现在 trigger.conditions 中。
3. **逻辑树节点必须追踪**:在 sources 中列出该规则在逻辑树中经历的所有 decision 节点和 action 节点。这样做是为了后续审计(检查逻辑树覆盖率)。
4. **优先图片逻辑树**:如果文字和图片存在矛盾,优先采用逻辑树中的路径,但保留文字作为补充参考(将两者都列入 sources)。
5. **动作类型区分**:系统行为(打断进程、限制启动)用 `"system"`,用户可见交互(Toast、弹窗、语音)用 `"user_interaction"`。
6. **多条规则**:如果一个功能单元包含多个独立的行为分支(如正常情况+异常情况),输出多条规则分别描述。规则之间通过 precondition 或 trigger 的条件值来区分。
7. **开关关闭状态**:如果功能单元涉及开关,也要考虑开关关闭时的行为。开关关闭时所有限制失效,这也是一条规则。
## 输出格式
**只输出 JSON 数组,不要有任何其他文字或 markdown 标记**
[
{{ ... }},
{{ ... }}
]
注意:即使只有一个规则,也必须用数组格式 `[...]`。
+200
View File
@@ -0,0 +1,200 @@
"""
Stage 1: Semantic Index Generation.
Reads the full parsed PRD JSON, calls the LLM once to produce a semantic index
identifying all function units, concepts, and their document sources.
Output: output/semantic_index.json
"""
import json
import re
import sys
import time
from pathlib import Path
import config
def format_document_for_prompt(doc: dict) -> str:
"""Render the full parsed document as a readable string for the LLM prompt."""
lines = []
# ---- Sections ----
lines.append("=== SECTIONS ===")
for i, section in enumerate(doc.get("sections", [])):
source = section.get("source", f"(无标题-章节{i})")
lines.append(f"\n--- Section: {source} ---")
for block in section.get("blocks", []):
if block["type"] == "para":
lines.append(f"[段落 {block['index']}] {block['text']}")
elif block["type"] == "table":
lines.append(f"[表格 {block.get('table', '?')}]")
headers = block.get("headers", [])
lines.append(f" 表头: {' | '.join(headers)}")
for row in block.get("rows", []):
cols = row.get("columns", [])
cell_texts = []
for c in cols:
cell_texts.append(f"[行{c.get('row','?')}]{c.get('name','')}: {c.get('text','')}")
lines.append(f" {'; '.join(cell_texts)}")
images = section.get("images", [])
if images:
lines.append(f" 图片引用: {', '.join(images)}")
# ---- Image Analysis ----
lines.append("\n\n=== IMAGE_ANALYSIS (流程图逻辑树) ===")
for img in doc.get("image_analysis", []):
rid = img.get("rid", "?")
img_type = img.get("type", "?")
lines.append(f"\n--- Image: {rid} (type={img_type}) ---")
lines.append(f" 描述: {img.get('description', '')[:300]}")
lt = img.get("logic_tree")
if lt:
lines.append(f" 逻辑树根节点: {lt.get('root', '?')}")
lines.append(" 节点详情:")
for node in lt.get("nodes", []):
nid = node.get("id", "?")
ntype = node.get("type", "?")
desc = node.get("description", "") or node.get("condition", "")
lines.append(f" [{ntype}] {nid}: {desc}")
branches = node.get("branches", [])
if branches:
for br in branches:
lines.append(f"{br['value']}{br['target']}")
# ---- Resolved Conflicts ----
conflicts = doc.get("resolved_conflicts", [])
if conflicts:
lines.append("\n\n=== RESOLVED_CONFLICTS (图文冲突仲裁) ===")
for c in conflicts:
lines.append(
f" [{c.get('conflict_type','?')}] {c.get('section','?')}: "
f"{c.get('source','?')}为准 — {c.get('correction','')}"
)
return "\n".join(lines)
def build_prompt(doc: dict) -> str:
"""Load the prompt template and inject the formatted document."""
template_path = Path(config.PROMPTS_DIR) / "step1_semantic_index.txt"
template = template_path.read_text(encoding="utf-8")
formatted_doc = format_document_for_prompt(doc)
prompt = template.replace("{document_json}", formatted_doc)
return prompt
def extract_json_from_response(text: str) -> str:
"""Robustly extract JSON from LLM response, handling markdown fences."""
# Try ```json ... ``` first
m = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
if m:
return m.group(1).strip()
# Try to find the outermost { ... }
start = text.find("{")
if start == -1:
raise ValueError("No JSON object found in LLM response")
depth = 0
for i in range(start, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
raise ValueError("Unclosed JSON object in LLM response")
def call_llm(prompt: str, max_retries: int = 2) -> dict:
"""Send the prompt to the LLM and return the parsed semantic index JSON."""
client = config.llm_client()
for attempt in range(max_retries + 1):
print(f" LLM 调用 (尝试 {attempt + 1}/{max_retries + 1})...", flush=True)
try:
resp = client.chat.completions.create(
model=config.MODEL_NAME,
messages=[
{"role": "system", "content": "你是一个精确的 JSON 输出引擎。只输出合法的 JSON,不输出任何其他文字。"},
{"role": "user", "content": prompt},
],
temperature=config.TEMPERATURE,
max_tokens=config.MAX_TOKENS,
)
content = resp.choices[0].message.content
if content is None:
raise RuntimeError("LLM returned empty response")
json_str = extract_json_from_response(content)
result = json.loads(json_str)
# Quick structural validation
_validate_schema(result)
return result
except (json.JSONDecodeError, ValueError) as e:
print(f" JSON 解析失败: {e}")
if attempt < max_retries:
time.sleep(2)
else:
raise RuntimeError(f"无法从 LLM 响应中解析 JSON: {e}") from e
def _validate_schema(data: dict) -> None:
"""Validate the top-level schema of the semantic index."""
if "feature_name" not in data:
raise ValueError("semantic_index 缺少 'feature_name' 字段")
if "function_units" not in data:
raise ValueError("semantic_index 缺少 'function_units' 字段")
units = data["function_units"]
if not isinstance(units, list) or len(units) == 0:
raise ValueError("function_units 必须是非空数组")
for i, fu in enumerate(units):
if not fu.get("unit_id"):
raise ValueError(f"function_unit[{i}] 缺少 unit_id")
if not fu.get("name"):
raise ValueError(f"function_unit[{i}] 缺少 name")
def main():
print("=" * 60)
print("阶段一:宏观语义索引 (Semantic Index)")
print("=" * 60)
# 1. Load input
print(f"\n[1/4] 加载输入文档: {config.INPUT_JSON}")
doc = config.load_input_document()
print(f" 已加载 {len(doc.get('sections', []))} 个 section, "
f"{len(doc.get('image_analysis', []))} 张图片分析")
# 2. Build prompt
print(f"\n[2/4] 构造 Prompt...")
prompt = build_prompt(doc)
print(f" Prompt 长度: {len(prompt)} 字符 (~{len(prompt)//3} tokens)")
# 3. Call LLM
print(f"\n[3/4] 调用 LLM ({config.MODEL_NAME})...")
semantic_index = call_llm(prompt)
# 4. Save result
print(f"\n[4/4] 保存语义索引: {config.SEMANTIC_INDEX_JSON}")
config.save_json(semantic_index, config.SEMANTIC_INDEX_JSON)
n_concepts = len(semantic_index.get("concepts", []))
n_units = len(semantic_index.get("function_units", []))
print(f"\n完成! 提取了 {n_concepts} 个概念, {n_units} 个功能单元.")
print(f"输出: {config.SEMANTIC_INDEX_JSON}")
if __name__ == "__main__":
main()
+410
View File
@@ -0,0 +1,410 @@
"""
Stage 2: Per Function Unit IR Extraction.
For each function unit from the semantic index, constructs a precision context
package and calls the LLM to extract detailed IR rules.
Runs multiple LLM calls in parallel (up to MAX_CONCURRENCY).
Output: output/ir_fragments.json
"""
import concurrent.futures
import json
import re
import sys
import time
from pathlib import Path
import config
MAX_CONCURRENCY = 3 # Max parallel LLM calls
def load_semantic_index() -> dict:
"""Load the semantic index from Stage 1."""
return config.load_json(config.SEMANTIC_INDEX_JSON)
def build_document_lookup(doc: dict):
"""Build lookup structures for fast context extraction from the document."""
# sections_by_source: "3.1.1" -> section dict
sections_by_source = {}
for section in doc.get("sections", []):
source = section.get("source", "")
# Normalize: extract leading number like "3.1.1"
parts = source.split()
if parts:
key = parts[0].strip()
sections_by_source[key] = section
# image_by_rid: "rId16" -> image_analysis entry
image_by_rid = {}
for img in doc.get("image_analysis", []):
rid = img.get("rid", "")
if rid:
image_by_rid[rid] = img
# Conflicts indexed by section
conflicts_by_section = {}
for c in doc.get("resolved_conflicts", []):
section = c.get("section", "")
key = section.split()[0] if section else ""
conflicts_by_section.setdefault(key, []).append(c)
return sections_by_source, image_by_rid, conflicts_by_section
def extract_context_package(
fu: dict, doc: dict, sections_by_source: dict, image_by_rid: dict,
conflicts_by_section: dict
) -> dict:
"""Build a precision context package for a single function unit."""
texts = []
tables = []
logic_trees = []
seen_sections = set()
seen_images = set()
for src in fu.get("sources", []):
src_type = src.get("type", "")
section_key = src.get("section", "").split()[0] if src.get("section") else ""
# --- Text source ---
if src_type in ("table", "para") and section_key:
if section_key in seen_sections:
continue
seen_sections.add(section_key)
section = sections_by_source.get(section_key)
if section is None:
# Fuzzy match by prefix
for key in sections_by_source:
if key.startswith(section_key):
section = sections_by_source[key]
break
if section:
for block in section.get("blocks", []):
if block["type"] == "para":
texts.append({
"section": section_key,
"text": block["text"]
})
elif block["type"] == "table":
row_num = src.get("row") if src_type == "table" else None
if row_num is not None:
# Extract only the specific row
matching_rows = []
for r in block.get("rows", []):
for c in r.get("columns", []):
if c.get("row") == row_num:
matching_rows.append({
"headers": block.get("headers", []),
"cells": {
col["name"]: col["text"]
for col in r["columns"]
},
"row": row_num
})
break
tables.append({
"section": section_key,
"headers": block.get("headers", []),
"rows": matching_rows,
"all_rows": [
{
"row": col.get("row"),
"name": col.get("name"),
"text": col.get("text")
}
for row in block.get("rows", [])
for col in row.get("columns", [])
]
})
else:
# Include full table
tables.append({
"section": section_key,
"headers": block.get("headers", []),
"all_rows": [
{
"row": col.get("row"),
"name": col.get("name"),
"text": col.get("text")
}
for row in block.get("rows", [])
for col in row.get("columns", [])
]
})
# --- Logic tree source ---
if src_type == "logic_tree":
image_id = src.get("image_id", "")
if not image_id or image_id in seen_images:
continue
seen_images.add(image_id)
img = image_by_rid.get(image_id)
if img:
lt = img.get("logic_tree")
if lt:
logic_trees.append({
"image_id": image_id,
"description": img.get("description", ""),
"tree": lt
})
# Include relevant resolved conflicts
relevant_conflicts = []
for section_key in seen_sections:
for c in conflicts_by_section.get(section_key, []):
relevant_conflicts.append(c)
return {
"unit_id": fu["unit_id"],
"unit_name": fu.get("name", ""),
"unit_description": fu.get("description", ""),
"texts": texts,
"tables": tables,
"logic_trees": logic_trees,
"resolved_conflicts": relevant_conflicts
}
def format_context_package(pkg: dict) -> str:
"""Format a context package as a readable string for the prompt."""
parts = []
# Texts
parts.append("【文字段落】")
for i, t in enumerate(pkg.get("texts", [])):
parts.append(f"[{t.get('section', '?')}] {t.get('text', '')}")
if not pkg.get("texts"):
parts.append("(无)")
# Tables
parts.append("\n【表格数据】")
for i, tbl in enumerate(pkg.get("tables", [])):
parts.append(f"表格 {i+1} (section={tbl.get('section', '?')})")
headers = tbl.get("headers", [])
parts.append(f" 表头: {headers}")
parts.append(" 全部行数据:")
for row in tbl.get("all_rows", []):
parts.append(
f"{row.get('row','?')}[{row.get('name','?')}]: {row.get('text','')}"
)
# Highlight matched rows if any
matched = tbl.get("rows", [])
if matched:
parts.append(" <重点关注行>:")
for mr in matched:
parts.append(f"{mr.get('row','?')}: {mr.get('cells', {})}")
if not pkg.get("tables"):
parts.append("(无)")
# Logic trees
parts.append("\n【逻辑树】")
for i, lt in enumerate(pkg.get("logic_trees", [])):
parts.append(f"逻辑树 {i+1} (image_id={lt.get('image_id', '?')})")
parts.append(f" 描述: {lt.get('description', '')[:200]}")
tree = lt.get("tree", {})
parts.append(f" 根: {tree.get('root', '?')}")
parts.append(" 节点:")
for node in tree.get("nodes", []):
nid = node.get("id", "?")
ntype = node.get("type", "?")
desc = node.get("description", "") or node.get("condition", "")
parts.append(f" [{ntype}] {nid}: {desc}")
for br in node.get("branches", []):
parts.append(f"{br['value']}{br['target']}")
if not pkg.get("logic_trees"):
parts.append("(无)")
# Conflicts
conflicts = pkg.get("resolved_conflicts", [])
if conflicts:
parts.append("\n【图文冲突仲裁】")
for c in conflicts:
parts.append(
f" [{c.get('conflict_type', '?')}] 以{c.get('source', '?')}为准: "
f"{c.get('correction', '')}"
)
return "\n".join(parts)
def _escape_json_for_format(s: str) -> str:
"""Escape curly braces in a JSON string for use with str.format()."""
return s.replace("{", "{{").replace("}", "}}")
def build_prompt(pkg: dict) -> str:
"""Build the LLM prompt for a single function unit."""
template_path = Path(config.PROMPTS_DIR) / "step2_ir_extraction.txt"
template = template_path.read_text(encoding="utf-8")
prompt = template.format(
unit_id=pkg["unit_id"],
unit_name=pkg["unit_name"],
unit_description=pkg["unit_description"],
texts=_escape_json_for_format(
json.dumps(pkg.get("texts", []), ensure_ascii=False, indent=2)
),
tables=_escape_json_for_format(
json.dumps(pkg.get("tables", []), ensure_ascii=False, indent=2)
),
logic_trees=_escape_json_for_format(
json.dumps(pkg.get("logic_trees", []), ensure_ascii=False, indent=2)
),
resolved_conflicts=_escape_json_for_format(
json.dumps(pkg.get("resolved_conflicts", []), ensure_ascii=False, indent=2)
),
)
return prompt
def extract_json_from_response(text: str) -> str:
"""Extract JSON array from LLM response."""
m = re.search(r"```(?:json)?\s*(\[[\s\S]*?\])\s*```", text)
if m:
return m.group(1).strip()
# Find outermost [ ... ]
start = text.find("[")
if start == -1:
raise ValueError("No JSON array found in LLM response")
depth = 0
for i in range(start, len(text)):
if text[i] == "[":
depth += 1
elif text[i] == "]":
depth -= 1
if depth == 0:
return text[start : i + 1]
raise ValueError("Unclosed JSON array in LLM response")
def extract_rules_for_unit(pkg: dict, max_retries: int = 2) -> list[dict]:
"""Call LLM for one function unit, return its IR rules."""
prompt = build_prompt(pkg)
client = config.llm_client()
for attempt in range(max_retries + 1):
try:
resp = client.chat.completions.create(
model=config.MODEL_NAME,
messages=[
{
"role": "system",
"content": "你是一个精确的 JSON 输出引擎。只输出合法的 JSON 数组,不输出任何其他文字。"
},
{"role": "user", "content": prompt},
],
temperature=config.TEMPERATURE,
max_tokens=config.MAX_TOKENS,
)
content = resp.choices[0].message.content
if content is None:
raise RuntimeError("LLM returned empty response")
json_str = extract_json_from_response(content)
rules = json.loads(json_str)
if not isinstance(rules, list):
raise ValueError(f"Expected JSON array, got {type(rules).__name__}")
return rules
except (json.JSONDecodeError, ValueError) as e:
print(f" JSON 解析失败 (尝试 {attempt + 1}): {e}")
if attempt < max_retries:
time.sleep(2)
return []
def extract_all_rules(
semantic_index: dict, doc: dict
) -> list[dict]:
"""Extract IR rules for all function units. Runs in parallel up to MAX_CONCURRENCY."""
sections_by_source, image_by_rid, conflicts_by_section = build_document_lookup(doc)
function_units = semantic_index.get("function_units", [])
print(f"{len(function_units)} 个功能单元待处理")
print(f" 最大并发: {MAX_CONCURRENCY}")
# Build context packages (serial — fast)
packages = []
for fu in function_units:
pkg = extract_context_package(
fu, doc, sections_by_source, image_by_rid, conflicts_by_section
)
packages.append(pkg)
# Run LLM calls in parallel
fragments = []
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_CONCURRENCY) as executor:
futures = {}
for i, pkg in enumerate(packages):
future = executor.submit(extract_rules_for_unit, pkg)
futures[future] = (i, pkg["unit_id"], pkg["unit_name"])
for future in concurrent.futures.as_completed(futures):
i, uid, uname = futures[future]
try:
rules = future.result()
fragments.append({
"unit_id": uid,
"unit_name": uname,
"rules": rules
})
print(f"{uid} ({uname}): {len(rules)} 条规则")
except Exception as e:
print(f"{uid} ({uname}): 失败 — {e}")
fragments.append({
"unit_id": uid,
"unit_name": uname,
"rules": [],
"error": str(e)
})
# Sort by unit_id to maintain stable ordering
fragments.sort(key=lambda f: f["unit_id"])
return fragments
def main():
print("=" * 60)
print("阶段二:逐功能单元 IR 提取")
print("=" * 60)
# 1. Load inputs
print(f"\n[1/3] 加载输入...")
semantic_index = load_semantic_index()
doc = config.load_input_document()
n_units = len(semantic_index.get("function_units", []))
print(f" 语义索引: {n_units} 个功能单元")
# 2. Extract rules
print(f"\n[2/3] 逐单元提取 IR 规则...")
fragments = extract_all_rules(semantic_index, doc)
# 3. Save
print(f"\n[3/3] 保存 IR 片段...")
config.save_json(fragments, config.IR_FRAGMENTS_JSON)
total_rules = sum(len(f["rules"]) for f in fragments)
failed_units = [f for f in fragments if f.get("error")]
print(f"\n完成! {len(fragments)} 个功能单元, 共 {total_rules} 条规则")
if failed_units:
print(f" ⚠️ {len(failed_units)} 个单元提取失败: "
f"{[f['unit_id'] for f in failed_units]}")
print(f"输出: {config.IR_FRAGMENTS_JSON}")
if __name__ == "__main__":
main()
+474
View File
@@ -0,0 +1,474 @@
"""
Stage 3: Deterministic Merge & Completeness Audit.
- Merges IR rule fragments, deduplicating by trigger+actions similarity.
- Reassigns stable rule_ids.
- Generates an audit report covering:
1. Logic tree node coverage
2. Table enumeration coverage
3. Global switch state coverage
Outputs:
- ir_final.json (in doc_parser output per spec)
- ir_audit_report.md (in doc_parser output)
"""
import json
import hashlib
import sys
from collections import defaultdict
from pathlib import Path
import config
PASS = "[PASS]"
WARN = "[WARN]"
FAIL = "[FAIL]"
def load_fragments() -> list[dict]:
"""Load IR fragments from Stage 2."""
return config.load_json(config.IR_FRAGMENTS_JSON)
def load_semantic_index() -> dict:
"""Load semantic index from Stage 1."""
return config.load_json(config.SEMANTIC_INDEX_JSON)
def rule_signature(rule: dict) -> str:
"""Generate a dedup signature from trigger + actions.
Two rules with identical trigger conditions and actions produce
the same signature and should be merged.
"""
trigger = rule.get("trigger", {})
actions = rule.get("actions", [])
# Normalize: sort conditions by signal name for stability
conditions = sorted(trigger.get("conditions", []), key=lambda c: c.get("signal", ""))
# Sort actions by description
sorted_actions = sorted(actions, key=lambda a: a.get("description", ""))
sig_data = {
"conditions": conditions,
"actions": sorted_actions,
}
sig_json = json.dumps(sig_data, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(sig_json.encode()).hexdigest()[:16]
def merge_rules(fragments: list[dict]) -> list[dict]:
"""Merge rules across all fragments, deduplicating by trigger+actions."""
signature_map: dict[str, dict] = {}
order = []
for fragment in fragments:
for rule in fragment.get("rules", []):
sig = rule_signature(rule)
if sig in signature_map:
# Merge sources
existing = signature_map[sig]
existing_sources = existing.setdefault("sources", [])
for src in rule.get("sources", []):
if src not in existing_sources:
existing_sources.append(src)
# Use the more detailed description
if len(rule.get("description", "")) > len(existing.get("description", "")):
existing["description"] = rule["description"]
else:
signature_map[sig] = dict(rule)
order.append(sig)
merged = [signature_map[sig] for sig in order]
print(f" 合并前: {sum(len(f.get('rules', [])) for f in fragments)} 条规则")
print(f" 合并后: {len(merged)} 条规则")
return merged
def assign_rule_ids(rules: list[dict], feature_id: str = "DRL-001") -> list[dict]:
"""Reassign stable rule_ids based on type and sequence."""
type_counters = defaultdict(int)
for rule in rules:
# Determine type from the first action's type
actions = rule.get("actions", [])
if any(a.get("type") == "user_interaction" for a in actions) and \
not any(a.get("type") == "system" for a in actions):
rtype = "UI"
elif any("SDK" in str(a) for a in actions):
rtype = "SDK"
else:
rtype = "SYS"
type_counters[rtype] += 1
seq = type_counters[rtype]
rule["rule_id"] = f"{feature_id}-{rtype}-FG-{seq:02d}"
# Also generate top-level feature metadata
return rules
def find_all_logic_tree_nodes(doc: dict) -> dict[str, list[dict]]:
"""Return {image_id: [all nodes]} for all logic trees."""
result = {}
for img in doc.get("image_analysis", []):
lt = img.get("logic_tree")
rid = img.get("rid", "")
if lt and rid:
result[rid] = lt.get("nodes", [])
return result
def find_referenced_nodes(rules: list[dict]) -> dict[str, set[str]]:
"""Return {image_id: {referenced node ids}} across all rules."""
referenced = defaultdict(set)
for rule in rules:
for src in rule.get("sources", []):
if src.get("type") == "logic_tree":
image_id = src.get("image_id", "")
for nid in src.get("node_ids", []):
referenced[image_id].add(nid)
return dict(referenced)
def audit_logic_tree_coverage(
doc: dict, rules: list[dict]
) -> list[dict]:
"""Generate coverage statistics for logic tree nodes."""
all_nodes = find_all_logic_tree_nodes(doc)
referenced = find_referenced_nodes(rules)
results = []
for image_id, nodes in all_nodes.items():
ref_set = referenced.get(image_id, set())
decision_nodes = [n for n in nodes if n["type"] == "decision"]
action_nodes = [n for n in nodes if n["type"] == "action"]
state_nodes = [n for n in nodes if n["type"] == "state"]
decisions_covered = [n for n in decision_nodes if n["id"] in ref_set]
actions_covered = [n for n in action_nodes if n["id"] in ref_set]
decisions_uncovered = [n for n in decision_nodes if n["id"] not in ref_set]
actions_uncovered = [n for n in action_nodes if n["id"] not in ref_set]
total_checkable = len(decision_nodes) + len(action_nodes)
total_covered = len(decisions_covered) + len(actions_covered)
coverage = (total_covered / total_checkable * 100) if total_checkable > 0 else 100
status = PASS if coverage >= 95 else (WARN if coverage >= 70 else FAIL)
detail_parts = [f"{total_covered}/{total_checkable} decision+action 节点被引用"]
if decisions_uncovered:
detail_parts.append(
f"未覆盖的 decision: {[n['id'] + ': ' + n.get('condition','')[:40] for n in decisions_uncovered]}"
)
if actions_uncovered:
detail_parts.append(
f"未覆盖的 action: {[n['id'] + ': ' + n.get('description','')[:40] for n in actions_uncovered]}"
)
results.append({
"check": f"逻辑树 {image_id} 节点覆盖率",
"status": status,
"coverage_pct": round(coverage, 1),
"detail": "; ".join(detail_parts),
"image_id": image_id,
"uncovered_decisions": decisions_uncovered,
"uncovered_actions": actions_uncovered,
})
return results
def find_table_enums(doc: dict) -> list[dict]:
"""Find enumerated values in tables (e.g., app types, limit methods)."""
enums = []
for section in doc.get("sections", []):
for block in section.get("blocks", []):
if block["type"] != "table":
continue
headers = block.get("headers", [])
if not headers:
continue
# Look for the "功能" / "功能详细说明" table pattern (key-value pairs)
if "功能" in headers and "功能详细说明" in headers:
for row in block.get("rows", []):
cols = row.get("columns", [])
key_col = next((c for c in cols if c.get("name") == "功能"), None)
val_col = next(
(c for c in cols if c.get("name") == "功能详细说明"), None
)
if key_col and val_col:
enums.append({
"section": section.get("source", ""),
"row": key_col.get("row"),
"key": key_col.get("text", ""),
"value": val_col.get("text", ""),
})
else:
# Generic table: record first column values as potential enum
first_col_name = headers[0] if headers else ""
values = []
for row in block.get("rows", []):
for col in row.get("columns", []):
if col.get("name") == first_col_name:
values.append(col.get("text", ""))
if values:
enums.append({
"section": section.get("source", ""),
"column": first_col_name,
"values": values,
})
return enums
def audit_table_enums(rules: list[dict], doc: dict) -> list[dict]:
"""Check if key enumerated values appear in rule preconditions."""
results = []
table_enums = find_table_enums(doc)
# Collect all rule precondition fields and their values
rule_preconditions = []
for rule in rules:
precond = rule.get("precondition", {})
rule_preconditions.append(precond)
# Check specific enum categories
app_types = {"系统限制", "SDK限制", "其他应用"}
switch_states = {"开启", "关闭"}
app_states = {"前台", "后台"}
# App type coverage
found_app_types = set()
for precond in rule_preconditions:
at = precond.get("app_type", "")
if at:
found_app_types.add(at)
missing_types = app_types - found_app_types
results.append({
"check": "应用类型枚举覆盖",
"status": PASS if not missing_types else WARN,
"detail": f"已覆盖: {found_app_types or ''}"
+ (f"; 未覆盖: {missing_types}" if missing_types else ""),
})
# App state coverage
found_states = set()
for precond in rule_preconditions:
st = precond.get("app_state", "")
if st:
found_states.add(st)
missing_states = app_states - found_states
results.append({
"check": "应用前后台状态覆盖",
"status": PASS if not missing_states else WARN,
"detail": f"已覆盖: {found_states or ''}"
+ (f"; 未覆盖: {missing_states}" if missing_states else ""),
})
# Trigger signal coverage (check each table enum key appears)
trigger_signals = set()
for rule in rules:
for cond in rule.get("trigger", {}).get("conditions", []):
signal = cond.get("signal", "")
if signal:
trigger_signals.add(signal)
# Check if key concepts from the doc appear in signals
key_signals = {"车速", "档位", "车速_持续时间", "应用请求启动"}
missing_signals = key_signals - trigger_signals
results.append({
"check": "触发信号覆盖(车速/档位/持续时间/启动请求)",
"status": PASS if not missing_signals else WARN,
"detail": f"已覆盖信号: {sorted(trigger_signals)}"
+ (f"; 未覆盖: {missing_signals}" if missing_signals else ""),
})
return results
def audit_switch_coverage(rules: list[dict]) -> list[dict]:
"""Check that rules cover both switch ON and OFF states."""
switch_on = False
switch_off = False
switch_rules = []
for rule in rules:
precond = rule.get("precondition", {})
sw = precond.get("switch", "")
if sw == "开启":
switch_on = True
switch_rules.append(rule.get("rule_id", "?"))
elif sw == "关闭":
switch_off = True
switch_rules.append(rule.get("rule_id", "?"))
status = PASS
detail_parts = []
if switch_on:
detail_parts.append(f"开关=开启: 有规则覆盖")
else:
detail_parts.append(f"开关=开启: 未找到规则")
status = FAIL
if switch_off:
detail_parts.append(f"开关=关闭: 有规则覆盖")
else:
detail_parts.append(f"开关=关闭: 未找到规则")
status = FAIL
return [{
"check": "开关状态完整性(开启/关闭)",
"status": status,
"detail": "; ".join(detail_parts),
}]
def generate_audit_report(
rules: list[dict],
doc: dict,
feature_name: str,
lt_results: list[dict],
enum_results: list[dict],
switch_results: list[dict],
) -> str:
"""Generate ir_audit_report.md in Markdown format."""
lines = []
lines.append(f"# IR 完整性审计报告")
lines.append(f"")
lines.append(f"**功能**: {feature_name}")
lines.append(f"**规则总数**: {len(rules)}")
lines.append(f"**生成时间**: {__import__('datetime').datetime.now().isoformat()}")
lines.append(f"")
# Human review notice
lines.append(f"> ⚠️ **重要**: 请人工审查以下 ⚠️ 和 ❌ 项,确认是文档遗漏还是 IR 提取遗漏。")
lines.append(f'> 如无需修改,在对应项后标注 **"已确认"**。')
lines.append(f"")
# ---- Logic Tree Coverage ----
lines.append(f"## 1. 逻辑树节点覆盖率")
lines.append(f"")
lines.append(f"| 图片 ID | 覆盖率 | 状态 | 详情 |")
lines.append(f"|---------|--------|------|------|")
for r in lt_results:
lines.append(
f"| {r['image_id']} | {r['coverage_pct']}% | {r['status']} | {r['detail']} |"
)
lines.append(f"")
# Uncovered node details
for r in lt_results:
if r["uncovered_decisions"] or r["uncovered_actions"]:
lines.append(f"### {r['image_id']} 未覆盖节点详情")
lines.append(f"")
for n in r.get("uncovered_decisions", []):
lines.append(f"- **Decision** `{n['id']}`: {n.get('condition', '?')}")
for n in r.get("uncovered_actions", []):
lines.append(f"- **Action** `{n['id']}`: {n.get('description', '?')}")
lines.append(f"")
# ---- Table Enumeration Coverage ----
lines.append(f"## 2. 表格枚举覆盖")
lines.append(f"")
lines.append(f"| 检查项 | 状态 | 详情 |")
lines.append(f"|--------|------|------|")
for r in enum_results:
lines.append(f"| {r['check']} | {r['status']} | {r['detail']} |")
lines.append(f"")
# ---- Switch Coverage ----
lines.append(f"## 3. 全局开关状态覆盖")
lines.append(f"")
lines.append(f"| 检查项 | 状态 | 详情 |")
lines.append(f"|--------|------|------|")
for r in switch_results:
lines.append(f"| {r['check']} | {r['status']} | {r['detail']} |")
lines.append(f"")
# ---- Rule Summary ----
lines.append(f"## 4. 规则清单")
lines.append(f"")
lines.append(f"| rule_id | Priority | 简述 |")
lines.append(f"|---------|----------|------|")
for rule in rules:
desc = rule.get("description", "")[:80]
lines.append(f"| {rule.get('rule_id', '?')} | {rule.get('priority', '?')} | {desc} |")
lines.append(f"")
return "\n".join(lines)
def main():
print("=" * 60)
print("阶段三:确定性合并与完整性校验")
print("=" * 60)
# 1. Load inputs
print(f"\n[1/5] 加载输入...")
fragments = load_fragments()
doc = config.load_input_document()
semantic_index = load_semantic_index()
feature_name = semantic_index.get("feature_name", "行车娱乐限制")
feature_id = "DRL-001"
print(f" 功能: {feature_name} ({feature_id})")
print(f" 片段数: {len(fragments)}")
# 2. Merge rules
print(f"\n[2/5] 合并去重...")
merged_rules = merge_rules(fragments)
# 3. Reassign rule IDs
print(f"\n[3/5] 重分配 rule_id...")
final_rules = assign_rule_ids(merged_rules, feature_id)
print(f" 已分配 {len(final_rules)} 个稳定 ID")
# Collect top-level metadata
ir_final = {
"feature": feature_name,
"feature_id": feature_id,
"rules": final_rules,
}
# Save ir_final.json
print(f"\n[4/5] 生成审计报告...")
lt_results = audit_logic_tree_coverage(doc, final_rules)
enum_results = audit_table_enums(final_rules, doc)
switch_results = audit_switch_coverage(final_rules)
report = generate_audit_report(
final_rules, doc, feature_name,
lt_results, enum_results, switch_results
)
# 5. Save outputs
print(f"\n[5/5] 保存输出...")
config.save_json(ir_final, config.IR_FINAL_JSON)
print(f" IR: {config.IR_FINAL_JSON}")
with open(config.IR_AUDIT_REPORT_MD, "w", encoding="utf-8") as f:
f.write(report)
print(f" 审计报告: {config.IR_AUDIT_REPORT_MD}")
# Print quick summary
print(f"\n完成!")
issue_count = sum(
1 for r in lt_results + enum_results + switch_results
if r["status"] in (WARN, FAIL)
)
print(f" 规则: {len(final_rules)}")
print(f" 审计问题: {issue_count} 个需要人工审查")
if issue_count > 0:
print(f"\n 请查看 {config.IR_AUDIT_REPORT_MD} 并审查标记项。")
if __name__ == "__main__":
main()
+238
View File
@@ -0,0 +1,238 @@
"""
Tests for Stage 1 (Semantic Index).
Validates that the generated semantic_index.json meets all completeness
and structural requirements.
"""
import json
import sys
from pathlib import Path
# Allow running from project root or tests/ directory
sys.path.insert(0, str(Path(__file__).parent.parent))
import config
PASS = "[PASS]"
FAIL = "[FAIL]"
WARN = "[WARN]"
def load_inputs():
"""Load semantic_index.json and the original parsed document."""
try:
si = config.load_json(config.SEMANTIC_INDEX_JSON)
except FileNotFoundError:
print(f"{FAIL} semantic_index.json 未找到: {config.SEMANTIC_INDEX_JSON}")
print(" 请先运行 step1_semantic_index.py")
sys.exit(1)
doc = config.load_input_document()
return si, doc
def build_image_index(doc: dict) -> dict[str, dict]:
"""Build lookup: image rId -> image_analysis entry."""
idx = {}
for img in doc.get("image_analysis", []):
rid = img.get("rid", "")
if rid:
idx[rid] = img
return idx
def build_logic_tree_node_index(doc: dict) -> dict[str, set[str]]:
"""Build lookup: image rId -> set of all node IDs in that logic_tree."""
idx = {}
for img in doc.get("image_analysis", []):
rid = img.get("rid", "")
lt = img.get("logic_tree")
if lt and rid:
node_ids = {n["id"] for n in lt.get("nodes", [])}
idx[rid] = node_ids
return idx
def check_unit_ids(units: list[dict]) -> list[str]:
"""Check that every function_unit has a non-empty unit_id and name."""
errors = []
seen_ids = set()
for i, fu in enumerate(units):
uid = fu.get("unit_id", "")
name = fu.get("name", "")
if not uid:
errors.append(f"function_unit[{i}]: unit_id 为空")
elif uid in seen_ids:
errors.append(f"function_unit[{i}]: unit_id '{uid}' 重复")
seen_ids.add(uid)
if not name:
errors.append(f"function_unit[{i}] ({uid}): name 为空")
return errors
def check_sources_exist(
units: list[dict], image_index: dict[str, dict], node_index: dict[str, set[str]]
) -> list[str]:
"""Check that all source references point to real content."""
errors = []
for fu in units:
uid = fu.get("unit_id", "?")
sources = fu.get("sources", [])
if not sources:
errors.append(f"{uid}: sources 为空,必须至少引用一张图片或一段文字")
continue
has_text = False
has_image = False
for j, src in enumerate(sources):
src_type = src.get("type", "")
if src_type in ("table", "para"):
has_text = True
section = src.get("section", "")
if not section:
errors.append(f"{uid}.sources[{j}]: 缺少 section")
elif src_type == "logic_tree":
has_image = True
image_id = src.get("image_id", "")
if not image_id:
errors.append(f"{uid}.sources[{j}]: logic_tree 缺少 image_id")
continue
# Check image exists
if image_id not in image_index:
errors.append(
f"{uid}.sources[{j}]: image_id '{image_id}' "
f"在 image_analysis 中不存在"
)
continue
# Check logic_tree_nodes if provided
node_ids = src.get("logic_tree_nodes", [])
if node_ids and image_id in node_index:
valid_nodes = node_index[image_id]
for nid in node_ids:
if nid not in valid_nodes:
errors.append(
f"{uid}.sources[{j}]: 节点 '{nid}'"
f"{image_id} 的逻辑树中不存在"
)
elif not node_ids:
errors.append(
f"{uid}.sources[{j}]: logic_tree 类型但未提供 logic_tree_nodes"
)
if not has_text and not has_image:
errors.append(f"{uid}: 必须至少引用一个文本或图片来源")
return errors
def check_logic_tree_coverage(
units: list[dict], node_index: dict[str, set[str]]
) -> list[str]:
"""Check that decision and action nodes in logic trees are covered."""
warnings = []
for image_id, all_nodes in node_index.items():
# Collect all nodes referenced across all function_units for this image
referenced = set()
for fu in units:
for src in fu.get("sources", []):
if src.get("image_id") == image_id:
for nid in src.get("logic_tree_nodes", []):
referenced.add(nid)
uncovered = all_nodes - referenced
if uncovered:
# Get node types from the document
doc = config.load_input_document()
node_types = {}
for img in doc.get("image_analysis", []):
if img.get("rid") == image_id:
lt = img.get("logic_tree", {})
for n in lt.get("nodes", []):
node_types[n["id"]] = n.get("type", "?")
break
decision_action_uncovered = [
n for n in uncovered if node_types.get(n) in ("decision", "action")
]
if decision_action_uncovered:
warnings.append(
f"{image_id}: {len(decision_action_uncovered)}"
f"decision/action 节点未被引用: {decision_action_uncovered}"
)
return warnings
def run_all_tests():
print("=" * 60)
print("Step 1 自检测试")
print("=" * 60)
si, doc = load_inputs()
units = si.get("function_units", [])
image_index = build_image_index(doc)
node_index = build_logic_tree_node_index(doc)
all_errors = []
all_warnings = []
# Test 1: unit_id and name validity
errors = check_unit_ids(units)
if errors:
print(f"\n{FAIL} unit_id/name 检查: {len(errors)} 个错误")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} unit_id/name 检查: 全部通过 ({len(units)} 个功能单元)")
# Test 2: source references exist
errors = check_sources_exist(units, image_index, node_index)
if errors:
print(f"\n{FAIL} 来源引用检查: {len(errors)} 个错误")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 来源引用检查: 全部通过")
# Test 3: Logic tree coverage
warnings = check_logic_tree_coverage(units, node_index)
if warnings:
print(f"\n{WARN} 逻辑树节点覆盖率: {len(warnings)} 个警告")
for w in warnings:
print(f" - {w}")
all_warnings.extend(warnings)
else:
print(f"\n{PASS} 逻辑树节点覆盖率: 全部通过")
# Summary
print(f"\n{'='*60}")
total_failures = len(all_errors)
total_warnings = len(all_warnings)
if total_failures == 0 and total_warnings == 0:
print(f"{PASS} 所有测试通过!")
elif total_failures == 0:
print(f"{WARN} 全部通过但有 {total_warnings} 个警告")
else:
print(f"{FAIL} 测试失败: {total_failures} 个错误, {total_warnings} 个警告")
print("\n请检查 LLM 输出质量,可能需要调整 Prompt 并重新运行 step1_semantic_index.py")
print(f"\n统计:")
print(f" 功能单元数: {len(units)}")
print(f" 概念数: {len(si.get('concepts', []))}")
print(f" 逻辑树图片数: {len(node_index)}")
return total_failures == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)
+228
View File
@@ -0,0 +1,228 @@
"""
Tests for Stage 2 (IR Extraction).
Validates that ir_fragments.json meets quality and structural requirements:
- All fragments have non-empty rules
- All rules have source references with logic tree nodes
- All trigger conditions have signal/operator/value
- No duplicate rule_ids (across all fragments)
"""
import json
import sys
from pathlib import Path
from collections import Counter
sys.path.insert(0, str(Path(__file__).parent.parent))
import config
PASS = "[PASS]"
FAIL = "[FAIL]"
WARN = "[WARN]"
def load_fragments():
"""Load ir_fragments.json."""
try:
return config.load_json(config.IR_FRAGMENTS_JSON)
except FileNotFoundError:
print(f"{FAIL} ir_fragments.json 未找到: {config.IR_FRAGMENTS_JSON}")
print(" 请先运行 step2_ir_extraction.py")
sys.exit(1)
def check_non_empty_rules(fragments: list[dict]) -> list[str]:
"""Every fragment must have at least one rule."""
errors = []
for f in fragments:
uid = f.get("unit_id", "?")
rules = f.get("rules", [])
if not rules:
if f.get("error"):
errors.append(f"{uid}: 提取失败 — {f['error']}")
else:
errors.append(f"{uid}: rules 为空")
return errors
def check_sources_have_logic_tree_nodes(fragments: list[dict]) -> list[str]:
"""Every rule should reference at least one logic tree node in its sources."""
errors = []
for f in fragments:
uid = f.get("unit_id", "?")
for j, rule in enumerate(f.get("rules", [])):
rid = rule.get("rule_id", f"rule[{j}]")
sources = rule.get("sources", [])
has_logic_tree = any(
src.get("type") == "logic_tree" and src.get("node_ids")
for src in sources
)
# NOTE: Some rules might only reference text (e.g., switch-off rules),
# so we flag as warning rather than error
if not has_logic_tree:
has_text = any(
src.get("type") in ("table", "para") for src in sources
)
if not has_text:
errors.append(f"{rid}: sources 中既无逻辑树引用也无文字引用")
return errors
def check_trigger_conditions(fragments: list[dict]) -> list[str]:
"""Every trigger condition must have signal, operator, value."""
errors = []
for f in fragments:
uid = f.get("unit_id", "?")
for j, rule in enumerate(f.get("rules", [])):
rid = rule.get("rule_id", f"rule[{j}]")
trigger = rule.get("trigger", {})
conditions = trigger.get("conditions", [])
# Check if trigger uses 'event' instead of conditions
if trigger.get("event") is not None:
continue # event-based trigger is valid
for k, cond in enumerate(conditions):
signal = cond.get("signal", "")
operator = cond.get("operator", "")
# value can be 0, False, "", so check with 'in'
has_value = "value" in cond
if not signal:
errors.append(f"{rid}.condition[{k}]: 缺少 signal")
if not operator:
errors.append(f"{rid}.condition[{k}]: 缺少 operator")
if not has_value:
errors.append(f"{rid}.condition[{k}]: 缺少 value")
return errors
def check_duplicate_rule_ids(fragments: list[dict]) -> list[str]:
"""Check for duplicate rule_ids across all fragments."""
all_rule_ids = []
for f in fragments:
for rule in f.get("rules", []):
rid = rule.get("rule_id", "")
if rid:
all_rule_ids.append(rid)
duplicates = [rid for rid, count in Counter(all_rule_ids).items() if count > 1]
errors = []
if duplicates:
errors.append(f"重复 rule_id: {duplicates}")
return errors
def check_action_types(fragments: list[dict]) -> list[str]:
"""Verify that actions have valid types."""
valid_types = {"system", "user_interaction"}
errors = []
for f in fragments:
for j, rule in enumerate(f.get("rules", [])):
rid = rule.get("rule_id", f"rule[{j}]")
for k, action in enumerate(rule.get("actions", [])):
atype = action.get("type", "")
if atype not in valid_types:
errors.append(
f"{rid}.action[{k}]: type='{atype}' 无效, "
f"应为 {valid_types}"
)
if atype == "user_interaction" and "content" not in action:
errors.append(
f"{rid}.action[{k}]: user_interaction 类型缺少 content 字段"
)
return errors
def run_all_tests():
print("=" * 60)
print("Step 2 自检测试")
print("=" * 60)
fragments = load_fragments()
all_errors = []
total_units = len(fragments)
total_rules = sum(len(f.get("rules", [])) for f in fragments)
# Test 1: Non-empty rules
errors = check_non_empty_rules(fragments)
if errors:
print(f"\n{FAIL} 非空规则检查: {len(errors)} 个错误")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 非空规则检查: 全部通过 ({total_units} 个片段)")
# Test 2: Sources have logic tree references
errors = check_sources_have_logic_tree_nodes(fragments)
if errors:
print(f"\n{FAIL} 来源节点引用: {len(errors)} 个规则缺少来源引用")
for e in errors[:10]: # Show first 10
print(f" - {e}")
if len(errors) > 10:
print(f" ... 还有 {len(errors) - 10}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 来源节点引用: 全部通过")
# Test 3: Trigger conditions completeness
errors = check_trigger_conditions(fragments)
if errors:
print(f"\n{FAIL} 触发条件完整性: {len(errors)} 个条件不完整")
for e in errors[:10]:
print(f" - {e}")
if len(errors) > 10:
print(f" ... 还有 {len(errors) - 10}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 触发条件完整性: 全部通过")
# Test 4: No duplicate rule_ids
errors = check_duplicate_rule_ids(fragments)
if errors:
print(f"\n{FAIL} rule_id 唯一性: 发现重复")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} rule_id 唯一性: 全部通过")
# Test 5: Valid action types
errors = check_action_types(fragments)
if errors:
print(f"\n{FAIL} 动作类型检查: {len(errors)} 个问题")
for e in errors[:10]:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 动作类型检查: 全部通过")
# Summary
print(f"\n{'='*60}")
total_failures = len(all_errors)
if total_failures == 0:
print(f"{PASS} 所有测试通过!")
else:
print(f"{FAIL} 测试失败: {total_failures} 个错误")
print("\n建议:")
print(" 1. 检查 ir_fragments.json 中出错的规则")
print(" 2. 如果某些功能单元的规则为空,检查上下文包是否丢失了关键信息")
print(" 3. 调整 Promptprompts/step2_ir_extraction.txt)或上下文提取逻辑后重新运行")
print(f"\n统计:")
print(f" 功能单元数: {total_units}")
print(f" 规则总数: {total_rules}")
error_units = sum(1 for f in fragments if f.get("error"))
if error_units:
print(f" 提取失败的单元: {error_units}")
return total_failures == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)
+191
View File
@@ -0,0 +1,191 @@
"""
Tests for Stage 3 (Merge & Audit).
Validates:
- ir_final.json exists and is well-formed
- No duplicate rule_ids
- All rule_ids follow naming convention
- ir_audit_report.md exists and contains required sections
"""
import re
import sys
from pathlib import Path
from collections import Counter
sys.path.insert(0, str(Path(__file__).parent.parent))
import config
PASS = "[PASS]"
FAIL = "[FAIL]"
WARN = "[WARN]"
def load_ir_final():
"""Load ir_final.json."""
try:
return config.load_json(config.IR_FINAL_JSON)
except FileNotFoundError:
print(f"{FAIL} ir_final.json 未找到: {config.IR_FINAL_JSON}")
print(" 请先运行 step3_merge_and_audit.py")
sys.exit(1)
def load_audit_report():
"""Load ir_audit_report.md if it exists."""
try:
with open(config.IR_AUDIT_REPORT_MD, "r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
print(f"{FAIL} ir_audit_report.md 未找到: {config.IR_AUDIT_REPORT_MD}")
print(" 请先运行 step3_merge_and_audit.py")
sys.exit(1)
def check_rule_ids(ir: dict) -> list[str]:
"""Check for duplicate rule_ids and naming convention."""
errors = []
rules = ir.get("rules", [])
rule_ids = [r.get("rule_id", "") for r in rules]
# No duplicates
duplicates = [rid for rid, count in Counter(rule_ids).items() if count > 1]
if duplicates:
errors.append(f"重复 rule_id: {duplicates}")
# Naming convention: FEATURE-TYPE-CAT-NN
pattern = re.compile(r"^[A-Z]+-\d{3}-(SYS|UI|SDK)-FG-\d{2}$")
for rid in rule_ids:
if rid and not pattern.match(rid):
errors.append(f"rule_id 命名不规范: '{rid}' (期望格式: DRL-001-SYS-FG-01)")
return errors
def check_top_level_structure(ir: dict) -> list[str]:
"""Check that ir_final has the required top-level fields."""
errors = []
for field in ["feature", "feature_id", "rules"]:
if field not in ir:
errors.append(f"ir_final 缺少顶层字段: {field}")
if not isinstance(ir.get("rules"), list):
errors.append("ir_final.rules 必须是数组")
elif len(ir["rules"]) == 0:
errors.append("ir_final.rules 为空")
return errors
def check_rule_completeness(rules: list[dict]) -> list[str]:
"""Check each rule has all required fields."""
errors = []
required_fields = ["rule_id", "description", "priority", "sources", "trigger", "actions"]
for i, rule in enumerate(rules):
rid = rule.get("rule_id", f"rule[{i}]")
for field in required_fields:
if field not in rule:
errors.append(f"{rid}: 缺少字段 '{field}'")
# sources must be non-empty
if not rule.get("sources"):
errors.append(f"{rid}: sources 为空")
# actions must be non-empty
if not rule.get("actions"):
errors.append(f"{rid}: actions 为空")
return errors
def check_audit_report(report: str) -> list[str]:
"""Check audit report has required sections."""
errors = []
required_sections = [
"逻辑树节点覆盖率",
"表格枚举覆盖",
"开关状态",
]
for section in required_sections:
if section not in report:
errors.append(f"审计报告缺少章节: {section}")
# Should have at least one coverage percentage
if "覆盖率" not in report and "%" not in report:
errors.append("审计报告中未找到覆盖率统计")
# Should have the human review notice
if "人工审查" not in report:
errors.append("审计报告缺少人工审查提示")
return errors
def run_all_tests():
print("=" * 60)
print("Step 3 自检测试")
print("=" * 60)
ir = load_ir_final()
report = load_audit_report()
rules = ir.get("rules", [])
all_errors = []
# Test 1: Top-level structure
errors = check_top_level_structure(ir)
if errors:
print(f"\n{FAIL} 顶层结构检查: {len(errors)} 个错误")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 顶层结构检查: 通过 (feature={ir.get('feature')}, feature_id={ir.get('feature_id')})")
# Test 2: rule_id uniqueness and naming
errors = check_rule_ids(ir)
if errors:
print(f"\n{FAIL} rule_id 检查: {len(errors)} 个错误")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} rule_id 检查: 全部通过 ({len(rules)} 个唯一 ID)")
# Test 3: Rule field completeness
errors = check_rule_completeness(rules)
if errors:
print(f"\n{FAIL} 规则字段完整性: {len(errors)} 个错误")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 规则字段完整性: 全部通过")
# Test 4: Audit report content
errors = check_audit_report(report)
if errors:
print(f"\n{FAIL} 审计报告检查: {len(errors)} 个错误")
for e in errors:
print(f" - {e}")
all_errors.extend(errors)
else:
print(f"\n{PASS} 审计报告检查: 全部通过")
# Summary
print(f"\n{'='*60}")
total_failures = len(all_errors)
if total_failures == 0:
print(f"{PASS} 所有测试通过!")
print(f"\n最终交付物:")
print(f" - {config.IR_FINAL_JSON} ({len(rules)} 条规则)")
print(f" - {config.IR_AUDIT_REPORT_MD}")
else:
print(f"{FAIL} 测试失败: {total_failures} 个错误")
print("\n建议: 检查 ir_fragments.json 和合并逻辑,修复问题后重新运行 step3_merge_and_audit.py")
return total_failures == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)