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
@@ -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)