From bf730377ebe6bb82b477335dcdc0ab827c9079d6 Mon Sep 17 00:00:00 2001 From: junlan <15167915727@163.com> Date: Tue, 3 Feb 2026 22:48:22 +0800 Subject: [PATCH] project init. --- .gitignore | 10 + README.md | 102 ++++++ config.yaml | 83 +++++ input/test.docx | Bin 0 -> 62487 bytes json_to_excel.py | 202 +++++++++++ main.py | 251 +++++++++++++ output/output_llm.json | 682 +++++++++++++++++++++++++++++++++++ requirements.txt | 6 + src/__init__.py | 20 + src/document_parser.py | 597 ++++++++++++++++++++++++++++++ src/json_generator.py | 214 +++++++++++ src/llm_interface.py | 197 ++++++++++ src/requirement_extractor.py | 643 +++++++++++++++++++++++++++++++++ src/utils.py | 134 +++++++ 14 files changed, 3141 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.yaml create mode 100644 input/test.docx create mode 100644 json_to_excel.py create mode 100644 main.py create mode 100644 output/output_llm.json create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/document_parser.py create mode 100644 src/json_generator.py create mode 100644 src/llm_interface.py create mode 100644 src/requirement_extractor.py create mode 100644 src/utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff5aae0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.vscode/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.env +.venv +venv/ +*.log +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..10b5e0b --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# SRS需求文档解析工具 + +一个智能的SRS(软件需求规格说明书)文档解析工具,支持PDF和Docx格式,能够自动提取需求并生成结构化JSON输出。 + +## 特性 + +- **LLM增强**:集成阿里云千问大模型,智能识别和提取需求 +- **多格式支持**:支持PDF和Docx格式的SRS文档 +- **非严格GJB结构**:支持不完全遵循GJB 438B标准的文档结构 +- **智能过滤**:自动过滤系统描述、重复需求等非需求内容 +- **结构化输出**:按章节层次组织的JSON格式输出 +- **灵活模式**:支持纯规则提取和LLM增强两种模式 +- **表格需求识别**:支持从表格中提取功能/接口/其他需求 + +## 快速开始 + +### 安装依赖 + +```bash +pip install -r requirements.txt + +# 如果使用LLM功能,还需安装: +pip install dashscope +``` + +### 配置API密钥(LLM模式) + +```bash +# 方式1:环境变量(推荐) +# Linux/Mac +export DASHSCOPE_API_KEY="your-api-key" + +# Windows PowerShell +$env:DASHSCOPE_API_KEY="your-api-key" + +# 方式2:在config.yaml中配置 +# llm: +# api_key: "your-api-key" +``` + +### 运行 + +```bash +# LLM增强模式 +python main.py -i DC-SRS.pdf -o output.json + +# 纯规则模式(不使用LLM) +python main.py -i DC-SRS.pdf -o output.json --no-llm +``` + + + +## 需求类型说明 + +工具统计**三类**需求类型: + +| 类型 | 描述 | +|------|------| +| **功能需求** | 系统应该提供的功能和行为 | +| **接口需求** | 系统的输入/输出接口规范、通信协议等 | +| **其他需求** | 性能、安全、可靠性等非功能性需求 | + +### 接口需求扩展字段 + +接口需求除了基本的"需求编号"和"需求描述"外,还包含以下字段: + +| 字段 | 说明 | +|------|------| +| **接口名称** | 接口的名称 +| **接口类型** | 接口的类型(如:CAN总线接口、以太网接口、串口等) | +| **来源** | 数据或信号的来源/发送方 | +| **目的地** | 数据或信号的目的地/接收方 | + +### 需求描述规则 + +- **功能需求**:保持原文描述,不改写润色 +- **接口需求**:允许改写润色,确保描述清晰完整 +- **其他需求**:保持原文描述,不改写润色 + +## 目录结构 + +``` +SRS_reqs_qwen/ +├── main.py # 主程序入口 +├── config.yaml # 配置文件 +├── requirements.txt # 依赖 +├── src/ +│ ├── document_parser.py # 文档解析器 +│ ├── requirement_extractor.py # 需求提取器 +│ ├── json_generator.py # JSON生成器 +│ ├── llm_interface.py # LLM接口 +│ └── utils.py # 工具函数 +├── docs/ +│ ├── README.md # 项目说明 +│ ├── ARCHITECTURE.md # 架构文档 +│ └── USAGE.md # 使用指南 +└── tests/ # 测试文件 +``` + diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..7644bab --- /dev/null +++ b/config.yaml @@ -0,0 +1,83 @@ +# 配置文件 - SRS 需求文档解析工具 (LLM增强版) +# Configuration file for SRS Requirement Document Parser (LLM Enhanced Version) + +# LLM配置 - 阿里云千问 +llm: + # 是否启用LLM(设为false则使用纯规则提取) + enabled: true + # LLM提供商:qwen(阿里云千问) + provider: "qwen" + # 模型名称 + model: "qwen3-max" + # API密钥(建议使用环境变量 DASHSCOPE_API_KEY) + api_key: "sk-7097f7842f724f0c9e70c4bf3b16dacb" + # 可选参数 + temperature: 0.3 + max_tokens: 1024 + +# 文档解析配置 +document: + supported_formats: + - ".pdf" + - ".docx" + # 标题识别的样式列表 + heading_styles: + - "Heading 1" + - "Heading 2" + - "Heading 3" + - "Heading 4" + - "Heading 5" + # 需要过滤的非需求章节(GJB438B标准) + non_requirement_sections: + - "标识" + - "系统概述" + - "文档概述" + - "引用文档" + - "合格性规定" + - "需求可追踪性" + - "注释" + - "附录" + +# 需求提取配置 +extraction: + # 需求类型关键字(用于自动判断需求类型) + requirement_types: + 功能需求: + prefix: "FR" + keywords: ["功能", "feature", "requirement", "CSCI组成", "控制", "处理", "监测", "显示"] + priority: 1 + 接口需求: + prefix: "IR" + keywords: ["接口", "interface", "api", "外部接口", "内部接口", "CAN", "以太网", "通信"] + priority: 2 + 性能需求: + prefix: "PR" + keywords: ["性能", "performance", "速度", "响应时间", "吞吐量"] + priority: 3 + 安全需求: + prefix: "SR" + keywords: ["安全", "security", "安全性", "报警"] + priority: 4 + 可靠性需求: + prefix: "RR" + keywords: ["可靠", "reliability", "容错", "恢复", "冗余"] + priority: 5 + 其他需求: + prefix: "OR" + keywords: ["约束", "资源", "适应性", "保密", "环境", "计算机", "质量", "设计", "人员", "培训", "保障", "验收", "交付"] + priority: 6 + +# 输出配置 +output: + format: "json" + indent: 2 + # 是否美化输出(格式化) + pretty_print: true + # 是否包含元数据 + include_metadata: true + +# 日志配置 +logging: + level: "INFO" # DEBUG, INFO, WARNING, ERROR + format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + file: "srs_parser.log" diff --git a/input/test.docx b/input/test.docx new file mode 100644 index 0000000000000000000000000000000000000000..0062709b47e685caf167037182fca547acc90043 GIT binary patch literal 62487 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 None: + self.children.append(child) + child.parent = self + + def add_content(self, text: str) -> None: + if self.content: + self.content += "\n" + text + else: + self.content = text + + def add_table(self, table_data: List[List[str]]) -> None: + self.tables.append(table_data) + + def generate_auto_number(self, parent_number: str = "", sibling_index: int = 1) -> None: + """ + 自动生成章节编号(当章节没有编号时) + + Args: + parent_number: 父章节编号 + sibling_index: 在同级章节中的序号(从1开始) + """ + if not self.number: + if parent_number: + self.number = f"{parent_number}.{sibling_index}" + else: + self.number = str(sibling_index) + + def __repr__(self) -> str: + return f"Section(level={self.level}, number='{self.number}', title='{self.title}')" + + +class DocumentParser(ABC): + """文档解析器基类""" + + def __init__(self, file_path: str): + self.file_path = file_path + self.sections: List[Section] = [] + self.document_title = "" + self.raw_text = "" + self.llm = None + self._uid_counter = 0 + + def set_llm(self, llm) -> None: + """设置LLM实例""" + self.llm = llm + + @abstractmethod + def parse(self) -> List[Section]: + pass + + def get_document_title(self) -> str: + return self.document_title + + def _next_uid(self) -> str: + self._uid_counter += 1 + return f"sec-{self._uid_counter}" + + def _auto_number_sections(self, sections: List[Section], parent_number: str = "") -> None: + """ + 为没有编号的章节自动生成编号 + + 规则:使用Word样式确定级别,跳过前置章节(目录、概述等), + 从第一个正文章节(如"外部接口")开始编号为1 + + Args: + sections: 章节列表 + parent_number: 父章节编号 + """ + # 仅在顶级章节重编号 + if not parent_number: + # 前置章节关键词(需要跳过的) + skip_keywords = ['目录', '封面', '扉页', '未命名', '年', '月'] + # 正文章节关键词(遇到这些说明正文开始) + content_keywords = ['外部接口', '接口', '软件需求', '需求', '功能', '性能', '设计', '概述', '标识', '引言'] + + start_index = 0 + for idx, section in enumerate(sections): + # 优先检查是否是正文章节 + is_content = any(kw in section.title for kw in content_keywords) + if is_content and section.level == 1: + start_index = idx + break + + # 重新编号所有章节 + counter = 1 + for i, section in enumerate(sections): + if i < start_index: + # 前置章节不编号 + section.number = "" + else: + # 正文章节:顶级章节从1开始编号 + if section.level == 1: + section.number = str(counter) + counter += 1 + + # 递归处理子章节 + if section.children: + self._auto_number_sections(section.children, section.number) + else: + # 子章节编号 + for i, section in enumerate(sections, 1): + if not section.number or self._is_chinese_number(section.number): + section.generate_auto_number(parent_number, i) + if section.children: + self._auto_number_sections(section.children, section.number) + + def _is_chinese_number(self, text: str) -> bool: + """检查是否是中文数字编号""" + chinese_numbers = '一二三四五六七八九十百千万' + return text and all(c in chinese_numbers for c in text) + + +class DocxParser(DocumentParser): + """DOCX格式文档解析器""" + + def __init__(self, file_path: str): + if not HAS_DOCX: + raise ImportError("python-docx库未安装,请运行: pip install python-docx") + super().__init__(file_path) + self.document = None + + def parse(self) -> List[Section]: + try: + self.document = Document(self.file_path) + self.document_title = self.document.core_properties.title or "SRS Document" + + section_stack = {} + + for block in self._iter_block_items(self.document): + from docx.text.paragraph import Paragraph + from docx.table import Table + if isinstance(block, Paragraph): + text = block.text.strip() + if not text: + continue + + heading_info = self._parse_heading(block, text) + if heading_info: + number, title, level = heading_info + section = Section(level=level, title=title, number=number, uid=self._next_uid()) + + if level == 1 or not section_stack: + self.sections.append(section) + section_stack = {1: section} + else: + parent_level = level - 1 + while parent_level >= 1 and parent_level not in section_stack: + parent_level -= 1 + + if parent_level >= 1 and parent_level in section_stack: + section_stack[parent_level].add_child(section) + elif self.sections: + self.sections[-1].add_child(section) + + section_stack[level] = section + for l in list(section_stack.keys()): + if l > level: + del section_stack[l] + else: + # 添加内容到当前章节 + if section_stack: + max_level = max(section_stack.keys()) + section_stack[max_level].add_content(text) + else: + # 没有标题时,创建默认章节 + default_section = Section(level=1, title="未命名章节", number="", uid=self._next_uid()) + default_section.add_content(text) + self.sections.append(default_section) + section_stack = {1: default_section} + elif isinstance(block, Table): + # 表格处理 + table_data = self._extract_table_data(block) + if table_data: + if section_stack: + max_level = max(section_stack.keys()) + section_stack[max_level].add_table(table_data) + else: + default_section = Section(level=1, title="未命名章节", number="", uid=self._next_uid()) + default_section.add_table(table_data) + self.sections.append(default_section) + section_stack = {1: default_section} + + # 为没有编号的章节自动生成编号 + self._auto_number_sections(self.sections) + + logger.info(f"完成Docx解析,提取{len(self.sections)}个顶级章节") + return self.sections + + except Exception as e: + logger.error(f"解析Docx文档失败: {e}") + raise + + def _is_valid_heading(self, text: str) -> bool: + """检查是否是有效的标题""" + if len(text) > 120 or '...' in text: + return False + # 标题应包含中文或字母 + if not re.search(r'[\u4e00-\u9fa5A-Za-z]', text): + return False + # 过滤目录项(标题后跟页码,如"概述 2"或"概述 . . . . 2") + if re.search(r'\s{2,}\d+$', text): # 多个空格后跟数字结尾 + return False + if re.search(r'[\.。\s]+\d+$', text): # 点号或空格后跟数字结尾 + return False + return True + + def _parse_heading(self, paragraph, text: str) -> Optional[Tuple[str, str, int]]: + """解析标题,返回(编号, 标题, 级别)""" + style_name = paragraph.style.name if paragraph.style else "" + is_heading_style = style_name.lower().startswith('heading') if style_name else False + + # 数字编号标题 + match = re.match(r'^(\d+(?:\.\d+)*)\s*[\.、]?\s*(.+)$', text) + if match and self._is_valid_heading(match.group(2)): + number = match.group(1) + title = match.group(2).strip() + level = len(number.split('.')) + return number, title, level + + # 中文编号标题 + match = re.match(r'^([一二三四五六七八九十]+)[、\.]+\s*(.+)$', text) + if match and self._is_valid_heading(match.group(2)): + number = match.group(1) + title = match.group(2).strip() + level = 1 + return number, title, level + + # 样式标题 + if is_heading_style and self._is_valid_heading(text): + level = 1 + level_match = re.search(r'(\d+)', style_name) + if level_match: + level = int(level_match.group(1)) + return "", text, level + + return None + + def _iter_block_items(self, parent): + """按文档顺序迭代段落和表格""" + from docx.text.paragraph import Paragraph + from docx.table import Table + from docx.oxml.text.paragraph import CT_P + from docx.oxml.table import CT_Tbl + + for child in parent.element.body.iterchildren(): + if isinstance(child, CT_P): + yield Paragraph(child, parent) + elif isinstance(child, CT_Tbl): + yield Table(child, parent) + + def _extract_table_data(self, table) -> List[List[str]]: + """提取表格数据""" + table_data = [] + for row in table.rows: + row_data = [] + for cell in row.cells: + text = cell.text.replace('\n', ' ').strip() + text = re.sub(r'\s+', ' ', text) + row_data.append(text) + if any(cell for cell in row_data): + table_data.append(row_data) + return table_data + + +class PDFParser(DocumentParser): + """PDF格式文档解析器 - LLM增强版""" + + # GJB438B标准SRS文档的有效章节标题关键词 + VALID_TITLE_KEYWORDS = [ + '范围', '标识', '概述', '引用', '文档', + '需求', '功能', '接口', '性能', '安全', '保密', + '环境', '资源', '质量', '设计', '约束', + '人员', '培训', '保障', '验收', '交付', '包装', + '优先', '关键', '合格', '追踪', '注释', + 'CSCI', '计算机', '软件', '硬件', '通信', '通讯', + '数据', '适应', '可靠', '内部', '外部', + '描述', '要求', '规定', '说明', '定义', + '电场', '防护', '装置', '控制', '监控', '显控' + ] + + # 明显无效的章节标题模式(噪声) + INVALID_TITLE_PATTERNS = [ + '本文档可作为', '参比电位', '补偿电流', '以太网', + '电源', '软件接', '功能\\', '性能 \\', '输入/输出 \\', + '数据处理要求 \\', '固件 \\', '质量控制要求', + '信安科技', '浙江', '公司' + ] + + def __init__(self, file_path: str): + if not HAS_PDF: + raise ImportError("PyPDF2库未安装,请运行: pip install PyPDF2") + super().__init__(file_path) + self.document_title = "SRS Document" + + def parse(self) -> List[Section]: + """解析PDF文档""" + try: + # 1. 提取所有文本 + self.raw_text = self._extract_all_text() + + # 2. 清洗文本 + cleaned_text = self._clean_text(self.raw_text) + + # 3. 识别章节结构 + self.sections = self._parse_sections(cleaned_text) + + # 4. 使用LLM验证和清理章节(如果可用) + if self.llm: + self.sections = self._llm_validate_sections(self.sections) + + # 5. 为没有编号的章节自动生成编号 + self._auto_number_sections(self.sections) + + logger.info(f"完成PDF解析,提取{len(self.sections)}个顶级章节") + return self.sections + + except Exception as e: + logger.error(f"解析PDF文档失败: {e}") + raise + + def _extract_all_text(self) -> str: + """从PDF提取所有文本""" + all_text = [] + with open(self.file_path, 'rb') as f: + pdf_reader = PyPDF2.PdfReader(f) + for page in pdf_reader.pages: + text = page.extract_text() + if text: + all_text.append(text) + return '\n'.join(all_text) + + def _clean_text(self, text: str) -> str: + """清洗PDF提取的文本""" + lines = text.split('\n') + cleaned_lines = [] + + for line in lines: + line = line.strip() + if not line: + continue + # 跳过页码(通常是1-3位数字单独一行) + if re.match(r'^\d{1,3}$', line): + continue + # 跳过目录行 + if line.count('.') > 10 and '...' in line: + continue + + cleaned_lines.append(line) + + return '\n'.join(cleaned_lines) + + def _parse_sections(self, text: str) -> List[Section]: + """解析章节结构""" + sections = [] + section_stack = {} + lines = text.split('\n') + current_section = None + content_buffer = [] + found_sections = set() + + for line in lines: + line = line.strip() + if not line: + continue + + # 尝试匹配章节标题 + section_info = self._match_section_header(line, found_sections) + + if section_info: + number, title = section_info + level = len(number.split('.')) + + # 保存之前章节的内容 + if current_section and content_buffer: + current_section.add_content('\n'.join(content_buffer)) + content_buffer = [] + + # 创建新章节 + section = Section(level=level, title=title, number=number, uid=self._next_uid()) + found_sections.add(number) + + # 建立层次结构 + if level == 1: + sections.append(section) + section_stack = {1: section} + else: + parent_level = level - 1 + while parent_level >= 1 and parent_level not in section_stack: + parent_level -= 1 + + if parent_level >= 1 and parent_level in section_stack: + section_stack[parent_level].add_child(section) + elif sections: + sections[-1].add_child(section) + else: + sections.append(section) + section_stack = {1: section} + + section_stack[level] = section + for l in list(section_stack.keys()): + if l > level: + del section_stack[l] + + current_section = section + else: + # 收集内容 + if line and not self._is_noise(line): + content_buffer.append(line) + + # 保存最后一个章节的内容 + if current_section and content_buffer: + current_section.add_content('\n'.join(content_buffer)) + + return sections + + def _match_section_header(self, line: str, found_sections: set) -> Optional[Tuple[str, str]]: + """ + 匹配章节标题 + + Returns: + (章节编号, 章节标题) 或 None + """ + # 模式: "3.1功能需求" 或 "3.1 功能需求" + match = re.match(r'^(\d+(?:\.\d+)*)\s*(.+)$', line) + if not match: + return None + + number = match.group(1) + title = match.group(2).strip() + + # 排除目录行 + if '...' in title or title.count('.') > 5: + return None + + # 验证章节编号 + parts = number.split('.') + first_part = int(parts[0]) + + # 放宽一级章节编号范围(非严格GJB结构) + if first_part < 1 or first_part > 30: + return None + + # 检查子部分是否合理 + for part in parts[1:]: + if int(part) > 20: + return None + + # 避免重复 + if number in found_sections: + return None + + # 标题长度检查 + if len(title) > 60 or len(title) < 2: + return None + + # 标题必须包含中文 + if not re.search(r'[\u4e00-\u9fa5]', title): + return None + + # 放宽标题关键词要求(非严格GJB结构) + if not re.search(r'[\u4e00-\u9fa5A-Za-z]', title): + return None + + # 检查是否包含无效模式 + for invalid_pattern in self.INVALID_TITLE_PATTERNS: + if invalid_pattern in title: + return None + + # 标题不能以数字开头 + if title[0].isdigit(): + return None + + # 数字比例检查 + digit_ratio = sum(c.isdigit() for c in title) / max(len(title), 1) + if digit_ratio > 0.3: + return None + + # 检查标题是否包含反斜杠(通常是表格噪声) + if '\\' in title and '需求' not in title: + return None + + return (number, title) + + def _is_noise(self, line: str) -> bool: + """检查是否是噪声行""" + # 纯数字行 + if re.match(r'^[\d\s,.]+$', line): + return True + # 非常短的行 + if len(line) < 3: + return True + # 罗马数字 + if re.match(r'^[ivxIVX]+$', line): + return True + return False + + def _llm_validate_sections(self, sections: List[Section]) -> List[Section]: + """使用LLM验证章节是否有效""" + if not self.llm: + return sections + + validated_sections = [] + + for section in sections: + # 验证顶级章节 + if self._is_valid_section_with_llm(section): + # 递归验证子章节 + section.children = self._validate_children(section.children) + validated_sections.append(section) + + return validated_sections + + def _validate_children(self, children: List[Section]) -> List[Section]: + """递归验证子章节""" + validated = [] + for child in children: + if self._is_valid_section_with_llm(child): + child.children = self._validate_children(child.children) + validated.append(child) + return validated + + def _is_valid_section_with_llm(self, section: Section) -> bool: + """使用LLM判断章节是否有效""" + # 先用规则快速过滤明显无效的章节 + invalid_titles = [ + '本文档可作为', '故障', '实时', '输入/输出', + '固件', '功能\\', '\\4.', '\\3.' + ] + for invalid in invalid_titles: + if invalid in section.title: + logger.debug(f"过滤无效章节: {section.number} {section.title}") + return False + + # 对于需求相关章节(第3章),额外验证 + if section.number and section.number.startswith('3'): + # 检查标题是否看起来像是有效的需求章节标题 + # 有效的标题应该是完整的中文短语 + if '\\' in section.title or '/' in section.title: + if not any(kw in section.title for kw in ['输入', '输出', '接口']): + return False + + return True + + +def create_parser(file_path: str) -> DocumentParser: + """ + 工厂函数:根据文件扩展名创建相应的解析器 + """ + ext = Path(file_path).suffix.lower() + + if ext == '.docx': + return DocxParser(file_path) + elif ext == '.pdf': + return PDFParser(file_path) + else: + raise ValueError(f"不支持的文件格式: {ext}") diff --git a/src/json_generator.py b/src/json_generator.py new file mode 100644 index 0000000..0eca52c --- /dev/null +++ b/src/json_generator.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +""" +JSON生成器模块 - LLM增强版 +将提取的需求和章节结构转换为结构化JSON输出 +""" + +import json +import logging +from datetime import datetime +from typing import List, Dict, Any, Optional +from .document_parser import Section +from .requirement_extractor import Requirement + +logger = logging.getLogger(__name__) + + +class JSONGenerator: + """JSON输出生成器""" + + # 需求类型中文映射 + TYPE_CHINESE = { + 'functional': '功能需求', + 'interface': '接口需求', + 'performance': '其他需求', + 'security': '其他需求', + 'reliability': '其他需求', + 'other': '其他需求' + } + + # 非需求章节(不输出到JSON) + NON_REQUIREMENT_SECTIONS = [ + '标识', '系统概述', '文档概述', '引用文档', + '合格性规定', '需求可追踪性', '注释', '附录', + '范围', '概述' + ] + + def __init__(self, config: Dict = None): + self.config = config or {} + + def generate(self, sections: List[Section], requirements: List[Requirement], + document_title: str = "SRS Document") -> Dict[str, Any]: + """ + 生成JSON输出 + + Args: + sections: 章节列表 + requirements: 需求列表 + document_title: 文档标题 + + Returns: + 结构化JSON字典 + """ + # 按章节组织需求 + reqs_by_section = self._group_requirements_by_section(requirements) + + # 统计需求类型 + type_stats = self._calculate_type_statistics(requirements) + + # 构建输出结构 + output = { + "文档元数据": { + "标题": document_title, + "生成时间": datetime.now().isoformat(), + "总需求数": len(requirements), + "需求类型统计": type_stats + }, + "需求内容": self._build_requirement_content(sections, reqs_by_section) + } + + logger.info(f"生成JSON输出,共{len(requirements)}个需求") + return output + + def _group_requirements_by_section(self, requirements: List[Requirement]) -> Dict[str, List[Requirement]]: + """按章节编号分组需求""" + grouped = {} + for req in requirements: + section_key = req.section_uid or req.section_number or 'unknown' + if section_key not in grouped: + grouped[section_key] = [] + grouped[section_key].append(req) + return grouped + + def _calculate_type_statistics(self, requirements: List[Requirement]) -> Dict[str, int]: + """计算需求类型统计""" + stats = {} + for req in requirements: + type_chinese = self.TYPE_CHINESE.get(req.type, '其他需求') + if type_chinese not in stats: + stats[type_chinese] = 0 + stats[type_chinese] += 1 + return stats + + def _should_include_section(self, section: Section) -> bool: + """判断章节是否应该包含在输出中""" + # 排除非需求章节 + for keyword in self.NON_REQUIREMENT_SECTIONS: + if keyword in section.title: + return False + + return True + + def _build_requirement_content(self, sections: List[Section], + reqs_by_section: Dict[str, List[Requirement]]) -> Dict[str, Any]: + """构建需求内容的层次结构""" + content = {} + + for section in sections: + # 只处理需求相关章节 + if not self._should_include_section(section): + # 但仍需检查子章节 + for child in section.children: + child_content = self._build_section_content_recursive(child, reqs_by_section) + if child_content: + key = f"{child.number} {child.title}" if child.number else child.title + content[key] = child_content + continue + + section_content = self._build_section_content_recursive(section, reqs_by_section) + if section_content: + key = f"{section.number} {section.title}" if section.number else section.title + content[key] = section_content + + return content + + def _build_section_content_recursive(self, section: Section, + reqs_by_section: Dict[str, List[Requirement]]) -> Optional[Dict[str, Any]]: + """递归构建章节内容""" + # 检查是否应该包含此章节 + if not self._should_include_section(section): + return None + + # 章节基本信息 + result = { + "章节信息": { + "章节编号": section.number or "", + "章节标题": section.title, + "章节级别": section.level + } + } + + # 检查是否有子章节 + has_valid_children = False + subsections = {} + + for child in section.children: + child_content = self._build_section_content_recursive(child, reqs_by_section) + if child_content: + has_valid_children = True + key = f"{child.number} {child.title}" if child.number else child.title + subsections[key] = child_content + + # 添加当前章节需求 + reqs = reqs_by_section.get(section.uid or section.number or 'unknown', []) + if reqs: + result["需求列表"] = [] + for req in reqs: + # 需求类型放在最前面 + type_chinese = self.TYPE_CHINESE.get(req.type, '功能需求') + req_dict = { + "需求类型": type_chinese, + "需求编号": req.id, + "需求描述": req.description + } + # 接口需求增加额外字段 + if req.type == 'interface': + req_dict["接口名称"] = req.interface_name + req_dict["接口类型"] = req.interface_type + req_dict["来源"] = req.source + req_dict["目的地"] = req.destination + result["需求列表"].append(req_dict) + + # 如果有子章节,添加子章节 + if has_valid_children: + result["子章节"] = subsections + + # 如果章节既没有需求也没有子章节,返回None + if "需求列表" not in result and "子章节" not in result: + return None + + return result + + def save_to_file(self, output: Dict[str, Any], file_path: str) -> None: + """ + 将输出保存到文件 + + Args: + output: 输出字典 + file_path: 输出文件路径 + """ + try: + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(output, f, ensure_ascii=False, indent=2) + logger.info(f"成功保存JSON到: {file_path}") + except Exception as e: + logger.error(f"保存JSON文件失败: {e}") + raise + + def generate_and_save(self, sections: List[Section], requirements: List[Requirement], + document_title: str, file_path: str) -> Dict[str, Any]: + """ + 生成并保存JSON + + Args: + sections: 章节列表 + requirements: 需求列表 + document_title: 文档标题 + file_path: 输出文件路径 + + Returns: + 生成的输出字典 + """ + output = self.generate(sections, requirements, document_title) + self.save_to_file(output, file_path) + return output diff --git a/src/llm_interface.py b/src/llm_interface.py new file mode 100644 index 0000000..b2db801 --- /dev/null +++ b/src/llm_interface.py @@ -0,0 +1,197 @@ +# src/llm_interface.py +""" +LLM接口模块 - 支持多个LLM提供商 +""" + +import logging +import json +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Any + +from .utils import get_env_or_config + +logger = logging.getLogger(__name__) + + +class LLMInterface(ABC): + """LLM接口基类""" + + def __init__(self, api_key: str = None, model: str = None, **kwargs): + """ + 初始化LLM接口 + + Args: + api_key: API密钥 + model: 模型名称 + **kwargs: 其他参数(如temperature, max_tokens等) + """ + self.api_key = api_key + self.model = model + self.extra_params = kwargs + + @abstractmethod + def call(self, prompt: str) -> str: + """ + 调用LLM API + + Args: + prompt: 提示词 + + Returns: + LLM的响应文本 + """ + pass + + @abstractmethod + def call_json(self, prompt: str) -> Dict[str, Any]: + """ + 调用LLM API并获取JSON格式的响应 + + Args: + prompt: 提示词 + + Returns: + 解析后的JSON字典 + """ + pass + + def validate_config(self) -> bool: + """验证配置是否完整""" + return bool(self.api_key and self.model) + + +class QwenLLM(LLMInterface): + """阿里云千问LLM实现""" + + def __init__(self, api_key: str = None, model: str = "qwen-plus", + api_endpoint: str = None, **kwargs): + """ + 初始化千问LLM + + Args: + api_key: 阿里云API密钥 + model: 模型名称(如qwen-plus, qwen-turbo) + api_endpoint: API端点地址 + **kwargs: 其他参数 + """ + super().__init__(api_key, model, **kwargs) + self.api_endpoint = api_endpoint or "https://dashscope.aliyuncs.com/compatible-mode/v1" + self._check_dashscope_import() + + def _check_dashscope_import(self) -> None: + """检查dashscope库是否已安装""" + try: + import dashscope + self.dashscope = dashscope + except ImportError: + logger.error("dashscope库未安装,请运行: pip install dashscope") + raise + + def call(self, prompt: str) -> str: + """ + 调用千问LLM + + Args: + prompt: 提示词 + + Returns: + LLM的响应文本 + """ + if not self.validate_config(): + raise ValueError("LLM配置不完整(api_key或model未设置)") + + try: + from dashscope import Generation + + # 设置API密钥 + self.dashscope.api_key = self.api_key + + # 构建请求参数 - dashscope 1.7.0 格式 + response = Generation.call( + model=self.model, + messages=[ + {'role': 'user', 'content': prompt} + ], + result_format='message' # 使用message格式 + ) + + # 调试输出 + logger.debug(f"API响应类型: {type(response)}") + logger.debug(f"API响应内容: {response}") + + # 处理响应 + if isinstance(response, dict): + # dict格式响应 + status_code = response.get('status_code', 200) + if status_code == 200: + output = response.get('output', {}) + if 'choices' in output: + return output['choices'][0]['message']['content'] + elif 'text' in output: + return output['text'] + else: + # 尝试直接获取text + return output.get('text', str(output)) + else: + error_msg = response.get('message', response.get('code', 'Unknown error')) + logger.error(f"千问API返回错误: {error_msg}") + raise Exception(f"API调用失败: {error_msg}") + else: + # 对象格式响应 + if hasattr(response, 'status_code') and response.status_code == 200: + output = response.output + if hasattr(output, 'choices'): + return output.choices[0].message.content + elif hasattr(output, 'text'): + return output.text + else: + return str(output) + elif hasattr(response, 'status_code'): + error_msg = getattr(response, 'message', str(response)) + raise Exception(f"API调用失败: {error_msg}") + else: + return str(response) + + except Exception as e: + logger.error(f"调用千问LLM失败: {e}") + raise + + def call_json(self, prompt: str) -> Dict[str, Any]: + """ + 调用千问LLM并获取JSON格式响应 + + Args: + prompt: 提示词 + + Returns: + 解析后的JSON字典 + """ + # 添加JSON格式要求到提示词 + json_prompt = prompt + "\n\n请确保响应是有效的JSON格式。" + + response = self.call(json_prompt) + + try: + # 尝试解析JSON + # 首先尝试直接解析 + return json.loads(response) + except json.JSONDecodeError: + # 尝试提取JSON代码块 + try: + import re + # 查找JSON代码块 + json_match = re.search(r'```json\s*(.*?)\s*```', response, re.DOTALL) + if json_match: + return json.loads(json_match.group(1)) + + # 尝试查找任何JSON对象 + json_match = re.search(r'\{.*\}', response, re.DOTALL) + if json_match: + return json.loads(json_match.group(0)) + + except Exception as e: + logger.warning(f"无法从响应中提取JSON: {e}") + + # 如果都失败,返回错误信息 + logger.error(f"无法解析LLM响应为JSON: {response}") + return {"error": "Failed to parse response as JSON", "raw_response": response} diff --git a/src/requirement_extractor.py b/src/requirement_extractor.py new file mode 100644 index 0000000..6a9a1a0 --- /dev/null +++ b/src/requirement_extractor.py @@ -0,0 +1,643 @@ +# -*- coding: utf-8 -*- +""" +需求提取器模块 - LLM增强版 +使用阿里云千问大模型智能提取和分类需求 +""" + +import re +import json +import logging +from typing import List, Dict, Optional, Tuple, Any +from .document_parser import Section + +logger = logging.getLogger(__name__) + + +class Requirement: + """表示一个需求项""" + + def __init__(self, req_id: str, description: str, req_type: str = "functional", + section_number: str = "", section_title: str = "", + interface_name: str = "", interface_type: str = "", + section_uid: str = "", + source: str = "", destination: str = ""): + self.id = req_id + self.description = description + self.type = req_type + self.section_number = section_number + self.section_title = section_title + self.section_uid = section_uid + # 接口需求特有字段 + self.interface_name = interface_name + self.interface_type = interface_type + self.source = source + self.destination = destination + + def to_dict(self) -> Dict: + result = { + "需求编号": self.id, + "需求描述": self.description + } + # 接口需求增加额外字段 + if self.type == 'interface': + result["接口名称"] = self.interface_name + result["接口类型"] = self.interface_type + result["来源"] = self.source + result["目的地"] = self.destination + return result + + def __repr__(self) -> str: + return f"Requirement(id='{self.id}', type='{self.type}')" + + +class RequirementExtractor: + """需求提取器 - LLM增强版""" + + # 需求类型前缀映射 + TYPE_PREFIX = { + 'functional': 'FR', + 'interface': 'IR', + 'performance': 'PR', + 'security': 'SR', + 'reliability': 'RR', + 'other': 'OR' + } + + # 中文类型到英文的映射 + TYPE_MAPPING = { + '功能需求': 'functional', + '接口需求': 'interface', + '其他需求': 'other' + } + + # 非需求章节(应该跳过的) + NON_REQUIREMENT_SECTIONS = [ + '标识', '系统概述', '文档概述', '引用文档', + '合格性规定', '需求可追踪性', '注释', '附录', + '范围', '概述' + ] + + def __init__(self, config: Dict = None, llm=None): + self.config = config or {} + self.llm = llm + self.requirements: List[Requirement] = [] + self._req_counters: Dict[str, Dict[str, int]] = {} # {section_number: {type: count}} + + def extract_from_sections(self, sections: List[Section]) -> List[Requirement]: + """ + 从章节列表中提取需求 + + Args: + sections: 解析后的章节列表 + + Returns: + 需求列表 + """ + self.requirements = [] + self._req_counters = {} + + for section in sections: + self._process_section(section) + + logger.info(f"共提取 {len(self.requirements)} 个需求项") + return self.requirements + + def _process_section(self, section: Section, depth: int = 0) -> None: + """递归处理章节,提取需求""" + # 检查是否应该跳过此章节 + if self._should_skip_section(section): + logger.debug(f"跳过非需求章节: {section.number} {section.title}") + for child in section.children: + self._process_section(child, depth + 1) + return + + # 先提取当前章节需求(包含表格) + reqs = self._extract_requirements_from_section(section) + self.requirements.extend(reqs) + + # 再递归处理子章节 + for child in section.children: + self._process_section(child, depth + 1) + + def _should_skip_section(self, section: Section) -> bool: + """判断是否应该跳过此章节""" + # 检查标题是否包含非需求关键词 + for keyword in self.NON_REQUIREMENT_SECTIONS: + if keyword in section.title: + return True + + # 检查是否是系统描述章节(如3.1.1通常是系统描述) + if self._is_system_description(section): + return True + + return False + + def _is_system_description(self, section: Section) -> bool: + """判断是否是系统描述章节(应该跳过)""" + # 检查标题 + desc_keywords = ['系统描述', '功能描述', '概述', '示意图', '组成'] + for kw in desc_keywords: + if kw in section.title: + return True + + # 使用LLM判断 + if self.llm and section.content: + try: + result = self._llm_check_system_description(section) + return result + except Exception as e: + logger.warning(f"LLM判断失败,使用规则判断: {e}") + + return False + + def _llm_check_system_description(self, section: Section) -> bool: + """使用LLM判断是否是系统描述""" + prompt = f"""请判断以下章节是否是对系统的整体描述(而不是具体的功能需求)。 + +章节编号:{section.number} +章节标题:{section.title} +章节内容(前500字符): +{section.content[:500] if section.content else '无'} + +请只回答"是"或"否": +- "是":这是系统整体描述、功能模块组成介绍、系统架构说明等概述性内容 +- "否":这是具体的功能需求、接口需求、性能要求等可提取的需求内容 + +回答(只需要回答"是"或"否"):""" + + response = self.llm.call(prompt).strip() + return '是' in response + + def _extract_requirements_from_section(self, section: Section) -> List[Requirement]: + """从单个章节提取需求""" + requirements = [] + + # 获取需求类型 + req_type = self._identify_requirement_type(section.title, section.content) + + if self.llm: + # 使用LLM提取需求 + reqs = self._llm_extract_requirements(section, req_type) + requirements.extend(reqs) + else: + # 使用规则提取 + reqs = self._rule_extract_requirements(section, req_type) + requirements.extend(reqs) + + return requirements + + def _llm_extract_requirements(self, section: Section, req_type: str) -> List[Requirement]: + """使用LLM提取需求""" + requirements = [] + + content_text = section.content or "" + table_text = self._format_tables_for_prompt(section.tables) + if len(content_text.strip()) < 8 and not table_text: + return requirements + + # 根据需求类型构建不同的提示词 + if req_type == 'interface': + # 接口需求:允许改写润色,并提取接口详细信息 + prompt = f"""请从以下SRS文档章节中提取具体的接口需求,并对需求描述进行改写润色。同时智能识别每个接口的详细信息。 + +章节编号:{section.number} +章节标题:{section.title} +章节内容: +{content_text} + +章节内表格(若有): +{table_text if table_text else '无'} + +提取要求: +1. 只提取具体的、可验证的接口需求 +2. 不要提取系统描述、背景说明等非需求内容 +3. 去除原文中的换行符、表格格式噪声 +4. 对提取的需求描述进行改写润色,使其更加清晰完整 +5. 每条需求应该是完整的句子,描述清楚接口规范 +6. 如果有多条需求,请分别列出 +7. 对于每条接口需求,请智能识别以下信息: + - interface_name: 接口名称 + - interface_type: 接口类型 (如:CAN接口、以太网接口、串口等) + - source: 来源/发送方(数据或信号从哪里来) + - destination: 目的地/接收方(数据或信号发送到哪里) +8. 如果某个字段无法从文本中识别,请填写"未知" +9. 若原文给出需求编号,请优先使用原文编号(req_id) + +请以JSON格式输出,格式如下: +{{ + "requirements": [ + {{ + "req_id": "需求编号(如有)", + "description": "接口需求描述", + "interface_name": "接口名称", + "interface_type": "接口类型", + "source": "来源", + "destination": "目的地" + }} + ] +}} + +如果该章节没有可提取的需求,返回空数组: +{{"requirements": []}} + +JSON输出:""" + else: + # 功能需求、其他需求:保留原文描述,不改写润色 + prompt = f"""请从以下SRS文档章节中提取具体的软件需求。保持原文描述,不要改写或润色。 + +章节编号:{section.number} +章节标题:{section.title} +章节内容: +{content_text} + +章节内表格(若有): +{table_text if table_text else '无'} + +提取要求: +1. 同时提取正文与表格中的具体、可验证的软件需求 +2. 不要提取系统描述、背景说明等非需求内容 +3. 保持原文描述,不要对需求进行改写、润色或重新组织 +4. 去除原文中的多余换行符和表格格式符号,但保留语句内容 +5. 每条需求应该是完整的句子 +6. 如果有多条需求,请分别列出 +7. 如果一段需求描述内有多条需求,请尽量拆分成独立的需求项 +8. 过滤重复或过于相似的需求,只保留独特的需求 +9. 若原文给出需求编号,请优先使用原文编号(req_id) + +请以JSON格式输出,格式如下: +{{ + "requirements": [ + {{"req_id": "需求编号(如有)", "description": "需求描述1"}}, + {{"req_id": "需求编号(如有)", "description": "需求描述2"}} + ] +}} + +如果该章节没有可提取的需求,返回空数组: +{{"requirements": []}} + +JSON输出:""" + + try: + response = self.llm.call(prompt) + data = self._parse_llm_json_response(response) + + if data and 'requirements' in data: + # 查找父需求编号(第一个合法完整编号的需求) + parent_req_id = "" + complete_id_pattern = r'^[A-Za-z0-9]{2,10}[-_].+$' + for req_data in data['requirements']: + temp_id = self._normalize_req_id(req_data.get('req_id', '') or req_data.get('id', '')) + if not temp_id: + temp_desc = req_data.get('description', '').strip() + temp_id, _ = self._extract_requirement_id_from_text(temp_desc) + # 验证是否为合法的完整编号格式 + if temp_id and re.match(complete_id_pattern, temp_id): + parent_req_id = temp_id.replace('_', '-') + break + + for i, req_data in enumerate(data['requirements'], 1): + desc = req_data.get('description', '').strip() + if desc and len(desc) > 5: + # 清理描述中的多余换行符和表格符号 + desc = self._clean_description(desc) + + # 需求ID优先使用文档给出的编号 + doc_req_id = self._normalize_req_id(req_data.get('req_id', '') or req_data.get('id', '')) + if not doc_req_id: + doc_req_id, desc = self._extract_requirement_id_from_text(desc) + + # 生成最终的需求ID(三级优先级) + req_id = self._generate_requirement_id(req_type, section.number, i, doc_req_id, parent_req_id) + + # 接口需求提取额外字段 + interface_name = "" + interface_type = "" + source = "" + destination = "" + if req_type == 'interface': + interface_name = req_data.get('interface_name', '未知').strip() + interface_type = req_data.get('interface_type', '未知').strip() + source = req_data.get('source', '未知').strip() + destination = req_data.get('destination', '未知').strip() + + req = Requirement( + req_id=req_id, + description=desc, + req_type=req_type, + section_number=section.number, + section_title=section.title, + section_uid=section.uid, + interface_name=interface_name, + interface_type=interface_type, + source=source, + destination=destination + ) + requirements.append(req) + except Exception as e: + logger.warning(f"LLM提取需求失败: {e},使用规则提取") + return self._rule_extract_requirements(section, req_type) + + return requirements + + def _rule_extract_requirements(self, section: Section, req_type: str) -> List[Requirement]: + """使用规则提取需求(备用方法)""" + requirements = [] + content = section.content + + # 正文需求 + descriptions = [] + if content and len(content.strip()) >= 8: + descriptions = self._extract_list_items(content) + + if not descriptions: + # 如果没有列表项,将整个内容作为一个需求 + desc = self._clean_description(content) + if len(desc) > 5: + descriptions = [f"{section.title}:{desc}"] + + # 表格需求 + table_requirements = self._extract_requirements_from_tables_rule(section.tables) + + # 查找父需求编号(第一个合法完整编号) + parent_req_id = "" + complete_id_pattern = r'^[A-Za-z0-9]{2,10}[-_].+$' + for desc in descriptions: + temp_id, _ = self._extract_requirement_id_from_text(desc) + # 验证是否为合法的完整编号格式 + if temp_id and re.match(complete_id_pattern, temp_id): + parent_req_id = temp_id.replace('_', '-') + break + if not parent_req_id: + for temp_id, _ in table_requirements: + # 验证是否为合法的完整编号格式 + if temp_id and re.match(complete_id_pattern, temp_id): + parent_req_id = temp_id.replace('_', '-') + break + + index = 1 + for desc in descriptions: + desc = self._clean_description(desc) + if len(desc) > 5: + doc_req_id, cleaned_desc = self._extract_requirement_id_from_text(desc) + # 生成最终的需求ID(三级优先级) + req_id = self._generate_requirement_id(req_type, section.number, index, doc_req_id, parent_req_id) + req = Requirement( + req_id=req_id, + description=cleaned_desc, + req_type=req_type, + section_number=section.number, + section_title=section.title, + section_uid=section.uid + ) + requirements.append(req) + index += 1 + + for doc_req_id, desc in table_requirements: + # 生成最终的需求ID(三级优先级) + req_id = self._generate_requirement_id(req_type, section.number, index, doc_req_id, parent_req_id) + req = Requirement( + req_id=req_id, + description=desc, + req_type=req_type, + section_number=section.number, + section_title=section.title, + section_uid=section.uid + ) + requirements.append(req) + index += 1 + + return requirements + + def _extract_list_items(self, content: str) -> List[str]: + """提取列表项""" + items = [] + + # 模式1: a) b) c) 或 1) 2) 3) + patterns = [ + r'([a-z][\))])\s*(.+?)(?=[a-z][\))]|$)', + r'(\d+[\))])\s*(.+?)(?=\d+[\))]|$)', + r'([①②③④⑤⑥⑦⑧⑨⑩])\s*(.+?)(?=[①②③④⑤⑥⑦⑧⑨⑩]|$)' + ] + + for pattern in patterns: + matches = re.findall(pattern, content, re.DOTALL) + if matches: + for marker, text in matches: + text = text.strip() + if text and len(text) > 5: + items.append(text) + break + + return items + + def _identify_requirement_type(self, title: str, content: str) -> str: + """ + 通过标题和内容识别需求类型 + + 根据章节标题和内容判断需求类型: + - 标题或内容中包含"接口"相关词汇 -> 接口需求 + - 其他情况 -> 功能需求(默认) + + 注意:不能仅靠标题判断是否为功能需求,若无法识别具体类型,默认为功能需求 + """ + title_lower = title.lower() + content_lower = (content or "").lower()[:500] # 只检查前500字符 + combined_text = title_lower + " " + content_lower + + # 优先识别接口需求,根据具体文件情况修改关键词 + interface_keywords = ['接口', 'interface', 'api', '串口', '通信协议', '数据交换'] + for keyword in interface_keywords: + if keyword in combined_text: + return 'interface' + + # 默认为功能需求(不能仅靠标题判断,无法识别时默认为功能需求) + return 'functional' + + def _generate_requirement_id(self, req_type: str, section_number: str, index: int, + doc_req_id: str = "", parent_req_id: str = "") -> str: + """ + 生成需求ID(三级优先级) + + 优先级规则: + 1. 如果doc_req_id是合法的完整编号(以2-10个字母或数字开头,后跟分隔符),直接使用 + 例如: NY01-01、FR-3.1.2-1、AIRSAT07-GD03-04 + 2. 如果doc_req_id是代号/序号,且有parent_req_id,则组合 + 格式: {parent_req_id}-{doc_req_id},例如: NY01-01-K101 + 3. 否则自动生成 + 格式: {PREFIX}-{section_number}-{index},例如: IR-4.1.1-1(保留章节号中的点号) + + Args: + req_type: 需求类型 + section_number: 章节编号 + index: 序号 + doc_req_id: 文档中提取的编号/代号 + parent_req_id: 父需求编号(用于子需求) + """ + # 优先级1:合法的完整编号(以2-10个字母或数字开头,后跟分隔符) + if doc_req_id: + # 检查是否为合法的完整编号格式:2-10个字母或数字开头 + 分隔符 + 其他字符 + # 例如: NY01-01、FR-3.1.2-1、AIRSAT07-GD03-04 + complete_id_pattern = r'^[A-Za-z0-9]{2,10}[-_].+$' + if re.match(complete_id_pattern, doc_req_id): + return doc_req_id.replace('_', '-') + + # 优先级2:代号/序号 + 父需求编号 + if doc_req_id and parent_req_id: + return f"{parent_req_id}-{doc_req_id}" + + # 优先级3:自动生成(保留章节号中的点号) + prefix = self.TYPE_PREFIX.get(req_type, 'FR') # 默认FR(功能需求) + section_part = section_number if section_number else "NA" + return f"{prefix}-{section_part}-{index}" + + def _normalize_req_id(self, req_id: str) -> str: + """规范化需求编号""" + if not req_id: + return "" + req_id = str(req_id).strip() + return req_id + + def _clean_description(self, text: str) -> str: + """清理需求描述""" + # 替换换行符为空格 + text = re.sub(r'\n+', ' ', text) + # 替换多个空格为单个空格 + text = re.sub(r'\s+', ' ', text) + # 去除表格噪声 + text = re.sub(r'[\|│┃]+', ' ', text) + # 去除首尾空白 + text = text.strip() + # 限制长度 + if len(text) > 1000: + text = text[:1000] + '...' + return text + + def _format_tables_for_prompt(self, tables: List[List[List[str]]]) -> str: + """格式化表格内容用于LLM提示词""" + if not tables: + return "" + lines = [] + for idx, table in enumerate(tables, 1): + lines.append(f"表格{idx}:") + for row in table: + row_text = " | ".join(self._clean_description(cell) for cell in row if cell) + if row_text: + lines.append(row_text) + return "\n".join(lines) + + def _extract_requirement_id_from_text(self, text: str) -> Tuple[Optional[str], str]: + """ + 从文本中提取需求编号 + + 支持的格式: + 1. 完整编号:NY01-01、FR-3.1.2-1 + 2. 代号/序号:K101、D61、a)、1) + """ + if not text: + return None, text + + # 模式1:完整需求编号(如 NY01-01、FR-3.1.2-1) + pattern1 = r'^\s*([A-Za-z]{2,6}[-_]\d+(?:[-.\d]+)*)\s*[::\)\]】]?\s*(.+)$' + match = re.match(pattern1, text) + if match: + return match.group(1).strip(), match.group(2).strip() + + # 模式2:代号(如 K101、D61) + pattern2 = r'^\s*([A-Za-z]\d+)\s*[::\)\]】]?\s*(.+)$' + match = re.match(pattern2, text) + if match: + return match.group(1).strip(), match.group(2).strip() + + # 模式3:序号(如 a)、1)) + pattern3 = r'^\s*([a-z0-9]{1,2}[\))])\s*(.+)$' + match = re.match(pattern3, text) + if match: + code = match.group(1).strip().rstrip('))') + return code, match.group(2).strip() + + return None, text + + def _extract_requirements_from_tables_rule(self, tables: List[List[List[str]]]) -> List[Tuple[Optional[str], str]]: + """从表格中提取需求(规则方式)""" + results = [] + if not tables: + return results + + id_keywords = ['需求编号', '编号', '序号', 'id', 'ID'] + desc_keywords = ['需求', '描述', '内容', '说明', '要求'] + + for table in tables: + if not table: + continue + header = table[0] if table else [] + header_lower = [h.lower() for h in header] + id_idx = None + desc_idx = None + for i, h in enumerate(header_lower): + if any(k.lower() in h for k in id_keywords): + id_idx = i + if any(k.lower() in h for k in desc_keywords): + desc_idx = i + + start_row = 1 if (id_idx is not None or desc_idx is not None) else 0 + for row in table[start_row:]: + if not row: + continue + row = [self._clean_description(cell) for cell in row] + if not any(row): + continue + + req_id = None + desc = "" + if id_idx is not None and id_idx < len(row): + req_id = self._normalize_req_id(row[id_idx]) + if desc_idx is not None and desc_idx < len(row): + desc = row[desc_idx] + if not desc: + # 如果无明确描述列,拼接整行作为描述 + desc = " | ".join([cell for cell in row if cell]) + + # 若描述里包含编号,尝试再次提取 + if not req_id: + req_id, desc = self._extract_requirement_id_from_text(desc) + + if desc and len(desc) > 5: + results.append((req_id, desc)) + + return results + + def _parse_llm_json_response(self, response: str) -> Optional[Dict]: + """解析LLM的JSON响应""" + try: + return json.loads(response) + except json.JSONDecodeError: + # 尝试提取JSON代码块 + try: + json_match = re.search(r'```(?:json)?\s*(.*?)\s*```', response, re.DOTALL) + if json_match: + return json.loads(json_match.group(1)) + + # 尝试查找JSON对象 + json_match = re.search(r'\{.*\}', response, re.DOTALL) + if json_match: + return json.loads(json_match.group(0)) + except Exception: + pass + + logger.warning(f"无法解析LLM响应为JSON: {response[:200]}") + return None + + def get_statistics(self) -> Dict: + """获取需求统计信息""" + stats = { + 'total': len(self.requirements), + 'by_type': {} + } + + for req in self.requirements: + req_type = req.type + if req_type not in stats['by_type']: + stats['by_type'][req_type] = 0 + stats['by_type'][req_type] += 1 + + return stats diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..53e5a65 --- /dev/null +++ b/src/utils.py @@ -0,0 +1,134 @@ +# src/utils.py +""" +工具函数模块 - 提供各种辅助功能 +""" + +import os +import logging +from pathlib import Path +from typing import Dict, Any, List, Optional +import yaml + +logger = logging.getLogger(__name__) + + +def load_config(config_path: str = None) -> Dict[str, Any]: + """ + 加载配置文件 + + Args: + config_path: 配置文件路径,如果为None则使用默认路径 + + Returns: + 配置字典 + """ + if config_path is None: + config_path = os.path.join(os.path.dirname(__file__), '..', 'config.yaml') + + if not os.path.exists(config_path): + logger.warning(f"配置文件不存在: {config_path}") + return {} + + try: + with open(config_path, 'r', encoding='utf-8') as f: + config = yaml.safe_load(f) + logger.info(f"成功加载配置文件: {config_path}") + return config or {} + except Exception as e: + logger.error(f"加载配置文件失败: {e}") + return {} + + +def setup_logging(config: Dict[str, Any]) -> None: + """ + 配置日志系统 + + Args: + config: 配置字典 + """ + logging_config = config.get('logging', {}) + level = logging_config.get('level', 'INFO') + log_format = logging_config.get('format', '%(asctime)s - %(name)s - %(levelname)s - %(message)s') + log_file = logging_config.get('file', None) + + # 创建logger + logging.basicConfig( + level=getattr(logging, level), + format=log_format, + handlers=[ + logging.StreamHandler(), + logging.FileHandler(log_file) if log_file else logging.NullHandler() + ] + ) + + +def validate_file_path(file_path: str, allowed_extensions: List[str] = None) -> bool: + """ + 验证文件路径的合法性 + + Args: + file_path: 文件路径 + allowed_extensions: 允许的文件扩展名列表(如['.pdf', '.docx']) + + Returns: + 文件是否合法 + """ + if not os.path.exists(file_path): + logger.error(f"文件不存在: {file_path}") + return False + + if not os.path.isfile(file_path): + logger.error(f"路径不是文件: {file_path}") + return False + + if allowed_extensions: + ext = Path(file_path).suffix.lower() + if ext not in allowed_extensions: + logger.error(f"不支持的文件格式: {ext}") + return False + + return True + + +def ensure_directory_exists(directory: str) -> bool: + """ + 确保目录存在,如果不存在则创建 + + Args: + directory: 目录路径 + + Returns: + 目录是否存在或创建成功 + """ + try: + Path(directory).mkdir(parents=True, exist_ok=True) + return True + except Exception as e: + logger.error(f"创建目录失败: {e}") + return False + + +def get_env_or_config(env_var: str, config_dict: Dict[str, Any], + default: Any = None) -> Any: + """ + 优先从环境变量读取,其次从配置字典读取 + + Args: + env_var: 环境变量名 + config_dict: 配置字典 + default: 默认值 + + Returns: + 获取到的值 + """ + # 尝试从环境变量读取 + env_value = os.environ.get(env_var) + if env_value: + return env_value + + # 尝试从配置字典读取 + config_value = config_dict.get(env_var) + if config_value and not config_value.startswith('${'): + return config_value + + return default