From 0c2ed67e2a3f8b742a671d3c54ceecf99ac898b8 Mon Sep 17 00:00:00 2001 From: junlan <15167915727@163.com> Date: Sat, 18 Apr 2026 21:13:33 +0800 Subject: [PATCH] =?UTF-8?q?=E9=92=88=E5=AF=B9=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E7=94=9F=E6=88=90=E6=B7=BB=E5=8A=A0=E4=BA=86=E5=B8=B8?= =?UTF-8?q?=E7=94=A8=E6=B5=8B=E8=AF=95=E6=96=B9=E6=B3=95=EF=BC=9B=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E4=BA=86=E9=9C=80=E6=B1=82=E6=8F=90=E5=8F=96=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/AGENTS.md | 60 +- .../skills/build-expected-results/SKILL.md | 67 + .github/skills/decompose-test-items/SKILL.md | 84 +- .github/skills/format-output/SKILL.md | 77 ++ .github/skills/generate-test-cases/SKILL.md | 57 +- .../skills/identify-requirement-type/SKILL.md | 30 +- .github/skills/testing-orchestrator/SKILL.md | 101 +- .github/常用测试方法.md | 116 ++ .github/测试项与测试用例技术要求.md | 54 + .vscode/settings.json | 4 + README.md | 9 - .../tools/srs_reqs_qwen/default_config.yaml | 96 +- .../srs_reqs_qwen/src/document_parser.py | 223 +++- .../tools/srs_reqs_qwen/src/json_generator.py | 4 +- .../tools/srs_reqs_qwen/src/llm_interface.py | 34 + .../src/requirement_extractor.py | 1178 ++++++++++++----- .../srs_reqs_qwen/src/requirement_splitter.py | 21 +- .../app/tools/srs_reqs_qwen/src/settings.py | 134 ++ .../backend/app/tools/srs_reqs_qwen/tool.py | 7 +- rag-web-ui/backend/requirements.txt | 1 + .../src/app/dashboard/knowledge/page.tsx | 153 ++- 21 files changed, 2029 insertions(+), 481 deletions(-) create mode 100644 .github/skills/build-expected-results/SKILL.md create mode 100644 .github/skills/format-output/SKILL.md create mode 100644 .github/常用测试方法.md create mode 100644 .github/测试项与测试用例技术要求.md create mode 100644 .vscode/settings.json diff --git a/.github/AGENTS.md b/.github/AGENTS.md index c433ad2..cad46f2 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -3,22 +3,66 @@ ## 适用范围 - 本工作区包含基于 Tool Calling 与 Skill Calling 的测试内容生成链路。 - 当用户提出测试项分解、测试用例生成或预期成果生成需求时,必须触发 testing-orchestrator。 +- 本约定用于约束技能间数据传递、输出结构与容错策略。 ## 已注册技能 -- identify-requirement-type:将用户需求文本识别为明确的测试需求类型,为后续测试项分解与测试用例生成提供分类依据。 -- decompose-test-items:按需求类型规则生成正常测试与异常测试测试项。 -- generate-test-cases:按测试项生成可执行测试用例,至少 1 条/测试项。 -- testing-orchestrator:按标准顺序编排工具调用并输出结构化结果。 +- identify-requirement-type:将需求文本识别为测试需求类型,并给出推荐测试方法。 +- decompose-test-items:按需求类型分解为可执行的正常/异常测试项,并输出覆盖分析。 +- generate-test-cases:按测试项生成结构化测试用例,覆盖测试用例要素。 +- build-expected-results:将测试用例中的预期占位符展开为可度量预期成果。 +- format-output:将测试项、测试用例、预期成果整理为统一三段式输出。 +- testing-orchestrator:按标准顺序编排技能并管理上下文传递。 ## 强制调用链 1. identify-requirement-type 2. decompose-test-items 3. generate-test-cases -4. build_expected_results -5. format_output +4. build-expected-results +5. format-output -## 约束规则 +## 步骤输入输出契约 + +### Step 1: identify-requirement-type +- 输入:user_requirement_text +- 输出:requirement_type, reason, candidates, recommended_test_methods, suggested_decompose_template + +### Step 2: decompose-test-items +- 输入:user_requirement_text, requirement_type, recommended_test_methods, suggested_decompose_template +- 输出:normal_test_items, abnormal_test_items, coverage_analysis + +### Step 3: generate-test-cases +- 输入:normal_test_items, abnormal_test_items, requirement_type, recommended_test_methods +- 输出:normal_test_cases, abnormal_test_cases, method_alignment_report + +### Step 4: build-expected-results +- 输入:normal_test_cases, abnormal_test_cases, requirement_type, recommended_test_methods +- 输出:normal_expected_results, abnormal_expected_results + +### Step 5: format-output +- 输入:normal_test_items, abnormal_test_items, normal_test_cases, abnormal_test_cases, normal_expected_results, abnormal_expected_results, coverage_analysis +- 输出:三段式结构化 Markdown + +## 全局约束规则 - 除非用户明确要求只看中间步骤,否则禁止跳步。 - 每一步都必须显式接收上一步输出作为上下文输入。 -- 若无法识别类型,必须输出未知类型及候选类型,并继续执行通用分解。 +- 若 requirement_type 为未知类型,必须继续执行通用分解,不得中断调用链。 +- 每个测试项必须同时覆盖正常与异常视角;复杂功能必须细分到可直接对应测试用例的粒度。 +- 每个测试项、测试用例、预期成果必须具备唯一标识,并保持可追踪映射关系。 - 最终输出必须严格包含测试项、测试用例、预期成果三段,并按正常测试/异常测试分组。 +- 测试项和测试用例设计必须对齐以下规范: + - 测试项分解要求 + - 测试项与测试用例技术要求 + - 常用测试方法 + +## 失败降级策略 +| 失败场景 | 处理策略 | 是否继续 | +| --- | --- | --- | +| Step 1 无法稳定识别类型 | requirement_type=未知类型,输出 1-3 个候选类型 | 是 | +| Step 2 覆盖率不足 | 输出 coverage_analysis 与补充建议 | 是 | +| Step 3 方法不完全对齐 | 输出 method_alignment_report 并标记低对齐项 | 是 | +| Step 4 缺少可量化指标 | 使用通用默认口径并标记待确认字段 | 是 | +| Step 5 格式化失败 | 回退为结构化 JSON 输出,不得丢失三段内容 | 是 | + +## 调试模式 +- debug=true 时,每一步追加 step_name, input_summary, output_summary, success, fallback_used, duration_ms。 +- debug=true 时,最终输出追加覆盖矩阵与方法追踪矩阵。 diff --git a/.github/skills/build-expected-results/SKILL.md b/.github/skills/build-expected-results/SKILL.md new file mode 100644 index 0000000..0a21c33 --- /dev/null +++ b/.github/skills/build-expected-results/SKILL.md @@ -0,0 +1,67 @@ +--- +name: build-expected-results +description: "当需要将测试用例中的 expected_result_placeholder 展开为可度量预期成果时使用。" +--- + +# build-expected-results + +## 目标 +将测试用例中的预期占位符展开为可验证、可测量、可判定通过/失败的预期成果。 + +## 输入 +- normal_test_cases +- abnormal_test_cases +- requirement_type +- recommended_test_methods(可选) +- quality_criteria(可选,精度、时间、误差、资源阈值) + +## 输出 +- normal_expected_results +- abnormal_expected_results + +每条预期成果至少包含: +- result_id +- case_id +- expected_results_detail +- evaluation_criteria +- pass_criteria +- termination_condition + +## 强制规则 +1. 预期成果必须来源于对应测试用例,不得脱离 case_id 独立生成。 +2. 每条预期成果必须可验证,禁止使用模糊描述。 +3. 预期成果必须覆盖:结果值、状态变化、时间要求、异常处理、通过准则。 +4. 若包含定量结果,必须给出精度或允许偏差范围。 +5. 若实际结果存在不确定性,必须定义重测条件。 + +## placeholder 展开映射 +- {{return_value}} -> 返回码、返回体字段、字段值/类型约束 +- {{state_change}} -> 状态机迁移、数据库字段变化、持久化副作用 +- {{error_message}} -> 错误码、提示文案、触发条件 +- {{data_persistence}} -> 数据落库、版本号、审计轨迹 +- {{ui_display}} -> 页面元素、提示位置、可见性与文案 +- {{precision_tolerance}} -> 精度阈值、允许误差上限与下限 +- {{time_constraint}} -> 响应时间上限/下限、事件间隔 +- {{retry_condition}} -> 触发重测的条件与次数限制 +- {{error_handling}} -> 异常处理动作、回滚策略、保护措施 +- {{sequence_event}} -> 事件顺序、状态切换顺序、时序关系 +- {{resource_usage}} -> CPU/内存/磁盘/连接占用阈值 +- {{pass_criteria}} -> 最终通过判定表达式 + +## 结果构造模板 +每条 expected_results_detail 推荐按以下结构输出: +- observable_result:可直接观测的结果 +- measurable_constraints:可量化约束(精度、时间、阈值等) +- side_effects:副作用与状态变更 +- error_handling_expectation:异常处理期望 +- retry_policy:重测策略 +- final_pass_rule:通过准则 + +## 默认口径 +当 quality_criteria 缺失时,使用以下默认规则: +- 时间约束:若无明确要求,标记为待确认,不擅自给固定毫秒值。 +- 精度约束:若需求涉及计算,至少给出精度位数或误差范围占位。 +- 资源阈值:若需求未给出,标记为待确认并保留观测项。 + +## 调试 +- debug=true 时输出 placeholder_expansion_trace,包含 case_id、placeholder、expansion_source、fallback_used。 diff --git a/.github/skills/decompose-test-items/SKILL.md b/.github/skills/decompose-test-items/SKILL.md index 16f8e04..d122dbd 100644 --- a/.github/skills/decompose-test-items/SKILL.md +++ b/.github/skills/decompose-test-items/SKILL.md @@ -6,15 +6,29 @@ description: "当需要基于需求类型把需求文本分解为可执行的正 # decompose-test-items ## 目标 -基于用户需求文本和已识别需求类型,生成测试项列表。 +基于用户需求文本和已识别需求类型,生成完整、可执行、可追踪的测试项列表。 ## 输入 - user_requirement_text - requirement_type +- recommended_test_methods(可选) +- suggested_decompose_template(可选) ## 输出 - normal_test_items:完整、可执行的正常测试项列表。 - abnormal_test_items:完整、可执行的异常测试项列表。 +- coverage_analysis:覆盖分析,包含覆盖率、缺口与补充建议。 + +每个测试项至少包含以下字段(对齐 C.1 要素): +- item_id:测试项唯一标识。 +- item_name:测试项名称。 +- item_description:测试项说明(测试目标和测试内容)。 +- test_method:测试方法(可包含 1-N 个)。 +- test_data_strategy:测试数据生成/注入/捕获/分析方式。 +- adequacy_requirement:测试充分性要求。 +- termination_condition:正常终止与异常终止条件。 +- priority:优先级(高/中/低)。 +- traceability:追踪关系(需求点、子功能、接口或性能项)。 ## 强制规则 1. 每个软件功能至少应被正常测试与被认可的异常场景覆盖;复杂功能需继续细分。 @@ -23,24 +37,62 @@ description: "当需要基于需求类型把需求文本分解为可执行的正 4. 粒度需适中,避免过粗或过细。 5. 对未知类型必须执行通用分解,并保持正常/异常分组。 6. 对需求说明未显式给出但在用户手册或操作手册体现的功能,也应补充测试项覆盖。 +7. 每个测试项必须至少绑定一种测试方法,并标明追踪来源。 +8. 每组测试项都需覆盖合法边界值与非法边界值(适用时)。 -## 14类最小分解检查点 -- 功能测试:正常覆盖功能主路径、基本数据类型、合法边界值与状态转换;异常覆盖非法输入、不规则输入、非法边界值与最坏情况。 -- 性能测试:正常覆盖处理精度、响应时间、处理数据量与模块协调性;异常覆盖超负荷、软硬件限制、负载潜力上限与资源占用异常。 -- 外部接口测试:正常覆盖全部外部接口格式与内容正确性;异常覆盖每个输入输出接口的错误格式、错误内容与异常交互。 -- 人机交互界面测试:正常覆盖界面风格一致性与标准操作流程;异常覆盖误操作、快速操作、非法输入、错误命令与错误流程提示。 -- 强度测试:正常覆盖设计极限下系统功能和性能表现;异常覆盖超出极限时的降级行为、健壮性与饱和表现。 -- 余量测试:正常覆盖存储、通道、处理时间余量是否满足要求;异常覆盖余量不足或耗尽时系统告警与受控行为。 -- 可靠性测试:正常覆盖典型环境、运行剖面与输入变量组合;异常覆盖失效等级场景、边界环境变化、不合法输入域及失效记录。 -- 安全性测试:正常覆盖安全关键部件、安全结构与合法操作路径;异常覆盖危险状态、故障模式、边界接合部、非法进入与数据完整性保护。 -- 恢复性测试:正常覆盖故障探测、备用切换、恢复后继续执行;异常覆盖故障中作业保护、状态保护与恢复失败路径。 -- 边界测试:正常覆盖输入输出域边界、状态转换端点与功能界限;异常覆盖性能界限、容量界限和越界端点。 -- 安装性测试:正常覆盖标准及不同配置下安装卸载流程;异常覆盖安装规程错误、依赖异常与中断后的处理。 -- 互操作性测试:正常覆盖两个或多个软件同时运行与互操作过程;异常覆盖互操作失败、并行冲突与协同异常。 -- 敏感性测试:正常覆盖有效输入类中典型数据组合;异常覆盖引发不稳定或不正常处理的特殊数据组合。 -- 测试充分性要求:正常覆盖需求覆盖率、配置项覆盖与代码覆盖达标;异常覆盖未覆盖部分逐项分析、确认与报告输出。 +## 14类详细分解执行清单 +- 功能测试 + - 正常分解:主路径功能、基本数据类型、合法边界值、状态转换、运行模式与时间约束。 + - 异常分解:不规则输入、非法边界值、最坏情况(超负荷/饱和)、手册中隐含功能异常路径。 +- 性能测试 + - 正常分解:处理精度、响应时间、可处理数据量、高低速协调。 + - 异常分解:软硬件瓶颈、过载极限、空间占用异常、性能退化阈值。 +- 外部接口测试 + - 正常分解:接口格式合法性、字段内容正确性、输入输出链路可达。 + - 异常分解:格式错误、字段缺失/越界、协议不一致、异常 I/O 交互。 +- 人机交互界面测试 + - 正常分解:界面风格一致性、标准流程操作、手册逐条操作一致。 + - 异常分解:误操作、快速操作、非法输入、错误命令与错误流程提示。 +- 强度测试 + - 正常分解:设计极限内性能、降级行为前置阈值。 + - 异常分解:超极限、系统饱和、降级能力失效、健壮性边界。 +- 余量测试 + - 正常分解:存储余量、I/O 通道余量、处理时间余量。 + - 异常分解:余量不足(默认小于 20%)、余量耗尽时保护与告警。 +- 可靠性测试 + - 正常分解:运行剖面、概率分布输入、重要输入组合与典型环境一致性。 + - 异常分解:失效等级场景、不合法输入域、环境边界变化、失效现象与时间记录。 +- 安全性测试 + - 正常分解:安全关键部件、容错/冗余/中断处理、合法输入下安全行为。 + - 异常分解:危险状态、硬件/软件故障模式、界外与边界接合部、非法入侵与数据完整性攻击。 +- 恢复性测试 + - 正常分解:错误探测、备用切换、恢复后从无错状态继续执行。 + - 异常分解:故障中作业保护失败、状态损坏、重置失败与恢复中断。 +- 边界测试 + - 正常分解:输入/输出域边界点、状态转换端点、功能与性能临界点。 + - 异常分解:越界点、容量上限/下限之外、边界接合断裂行为。 +- 安装性测试 + - 正常分解:不同配置下安装和卸载流程、安装规程一致性。 + - 异常分解:依赖缺失、安装中断、重复安装、回滚失败。 +- 互操作性测试 + - 正常分解:多软件并行运行、标准互操作流程。 + - 异常分解:互操作失败、版本不兼容、并发冲突与消息错序。 +- 敏感性测试 + - 正常分解:有效输入类典型组合。 + - 异常分解:引起不稳定或不正常处理的有效输入组合。 +- 测试充分性要求 + - 正常分解:需求覆盖率 100%、配置项要求覆盖、编译器一致性。 + - 异常分解:语句/分支未覆盖点逐项分析、确认并形成分析结论。 + +## coverage_analysis 输出要求 +- requirement_points:需求点列表。 +- covered_points:已覆盖需求点。 +- uncovered_points:未覆盖需求点。 +- requirement_coverage_rate:覆盖率,计算规则为 covered_points/requirement_points。 +- recommended_supplementary_items:建议补充测试项。 ## 未知类型容错 - 当 requirement_type 无法确定时,仍需输出正常/异常两组测试项。 - 通用正常项至少包含:主流程正确性、合法边界值、标准输入输出。 - 通用异常项至少包含:非法输入、越界输入、资源异常或状态冲突。 +- 未知类型场景下,默认使用功能分解、等价类划分、边界值分析三种方法。 diff --git a/.github/skills/format-output/SKILL.md b/.github/skills/format-output/SKILL.md new file mode 100644 index 0000000..307176e --- /dev/null +++ b/.github/skills/format-output/SKILL.md @@ -0,0 +1,77 @@ +--- +name: format-output +description: "当需要将测试项、测试用例、预期成果按统一格式输出时使用。" +--- + +# format-output + +## 目标 +将测试链路中生成的结构化数据整理为标准化三段式输出,确保可读性与追踪关系。 + +## 输入 +- normal_test_items +- abnormal_test_items +- normal_test_cases +- abnormal_test_cases +- normal_expected_results +- abnormal_expected_results +- coverage_analysis(可选) +- method_alignment_report(可选) +- debug(可选) + +## 输出 +- markdown_output + +## 强制规则 +1. 输出必须包含三段:测试项、测试用例、预期成果。 +2. 每段必须按正常测试与异常测试分组。 +3. 编号必须保持追踪关系: + - 测试项编号:TI-Nxx / TI-Exx + - 测试用例编号:TC-Nxx / TC-Exx + - 预期成果编号:ER-Nxx / ER-Exx +4. 测试用例必须显示对应测试项;预期成果必须显示对应测试用例。 +5. 任一分段数据为空时,必须显式输出待补充,不允许静默省略。 +6. 每条输出使用一段完整描述,不强制拆分为多个字段子项。 + +## 输出模板 + +**测试项** + +**正常测试** +1. [TI-N01]:... + +**异常测试** +1. [TI-E01]:... + +**测试用例** + +**正常测试** +1. [TC-N01](对应 TI-N01):... + +**异常测试** +1. [TC-E01](对应 TI-E01):... + +**预期成果** + +**正常测试** +1. [ER-N01](对应 TC-N01):... + +**异常测试** +1. [ER-E01](对应 TC-E01):... + +## 调试模式 +当 debug=true 时追加: + +### 覆盖率分析 +- requirement_coverage_rate +- uncovered_points +- recommended_supplementary_items + +### 方法对齐分析 +- method_alignment_report + +### 步骤日志 +- step_name +- success +- fallback_used +- duration_ms diff --git a/.github/skills/generate-test-cases/SKILL.md b/.github/skills/generate-test-cases/SKILL.md index 5e83e99..51c2b81 100644 --- a/.github/skills/generate-test-cases/SKILL.md +++ b/.github/skills/generate-test-cases/SKILL.md @@ -6,20 +6,32 @@ description: "当需要根据已分解测试项生成包含操作步骤与测试 # generate-test-cases ## 目标 -按测试项生成测试用例,每个测试项至少对应 1 条用例。 +按测试项生成测试用例,每个测试项至少对应 1 条可重复执行、可评估通过的测试用例。 ## 输入 - normal_test_items - abnormal_test_items +- requirement_type(可选) +- recommended_test_methods(可选) ## 输出 - normal_test_cases - abnormal_test_cases +- method_alignment_report 每条测试用例必须包含: +- case_id +- case_name +- test_traceability +- case_summary +- initialization_requirements +- test_inputs - operation_steps -- test_content - expected_result_placeholder +- evaluation_criteria +- preconditions_constraints +- termination_condition +- pass_criteria ## 规则 1. 测试项与测试用例应保持一一对应关系。 @@ -28,13 +40,48 @@ description: "当需要根据已分解测试项生成包含操作步骤与测试 4. 操作步骤应可顺序执行,避免歧义。 5. 操作步骤必须包含明确动作、对象和输入条件,禁止笼统动作词。 6. test_content 必须包含可验证条件,便于后续生成可度量预期成果。 +7. 测试用例应符合可重复执行原则,初始条件和参数必须可复现。 +8. 测试输入必须标识有效值/无效值/边界值性质、输入来源、真实或模拟属性及事件顺序。 +9. 每个操作步骤应包含测试输入、动作、中间期望、评估准则与异常终止信号。 +10. pass_criteria 必须明确是否通过的判定条件,禁止模糊描述。 -## expected_result_placeholder 映射 +## expected_result_placeholder 映射(用于 Step 4 展开) - {{return_value}}:接口或函数返回值验证。 - {{state_change}}:系统状态变化验证。 - {{error_message}}:异常场景错误信息验证。 - {{data_persistence}}:数据库或存储落库结果验证。 - {{ui_display}}:界面显示反馈验证。 +- {{precision_tolerance}}:精度与允许误差范围验证。 +- {{time_constraint}}:时间上限/下限或事件间隔验证。 +- {{retry_condition}}:不确定结果时重测触发条件验证。 +- {{error_handling}}:出错处理流程与保护动作验证。 +- {{sequence_event}}:时序、状态切换或事件顺序验证。 +- {{resource_usage}}:资源占用与空间约束验证。 +- {{pass_criteria}}:用例通过准则验证。 + +## 常用测试方法应用清单(B.1.1-B.2.6) +每个用例必须在 summary 中标注所用方法,并在 steps 中体现方法特征。 + +| 方法 | 适用场景 | 输入构造规则 | 步骤设计特征 | +| --- | --- | --- | --- | +| 功能分解 | 功能/接口主流程 | 按子功能拆输入集 | 用例按子功能逐级覆盖 | +| 等价类划分 | 功能/接口/边界 | 有效类与无效类分别取代表值 | 每类至少一条步骤 | +| 边界值分析 | 输入输出边界 | 取边界、邻近、越界值 | 连续执行边界点序列 | +| 判定表 | 多条件组合逻辑 | 依据条件桩生成规则列 | 一列规则对应一条步骤流 | +| 因果图 | 条件与结果耦合 | 构建原因-结果及约束 | 覆盖关键因果链路 | +| 场景法 | 事件驱动流程 | 基本流与备选流输入 | 按场景路径组织步骤 | +| 功能图法 | 状态与逻辑联合 | 状态转移输入序列 | 状态路径+局部逻辑组合 | +| 随机测试 | 可靠性/强度 | 输入区间+概率分布 | 指定随机规则和样本量 | +| 猜错法 | 高风险经验缺陷 | 构造易错输入集合 | 直接验证高风险点 | +| 正交试验法 | 多因子组合优化 | 因子-水平表+正交表 | 覆盖代表组合点 | +| 组合测试法 | 参数组合覆盖 | 选定组合强度(pairwise/K) | 按组合强度生成步骤 | +| 蜕变测试法 | 预期结果难判定 | 构造蜕变关系输入组 | 校验用例间关系一致性 | +| 控制流测试 | 白盒流程覆盖 | 按路径目标构造输入 | 步骤对应语句/分支路径 | +| 数据流测试 | 白盒变量使用 | 定义-使用链路输入 | 覆盖关键定义引用对 | +| 程序变异 | 错误驱动验证 | 针对变异体生成输入 | 验证是否杀死变异体 | +| 程序插桩 | 运行行为观测 | 插桩点对应输入 | 步骤包含采样与比对 | +| 域测试 | 输入空间划分检验 | 构造域边界/域内样本 | 验证域划分正确性 | +| 符号求值 | 公式与路径推导 | 依据符号约束反推输入 | 校验符号关系与结果 | ## 禁止模糊描述 - 错误示例:"检查功能正常";正确示例:"验证返回状态码为200且响应体包含status=success"。 @@ -43,3 +90,7 @@ description: "当需要根据已分解测试项生成包含操作步骤与测试 ## 预期结果耦合 - 每条用例必须可在下一步绑定一条明确、可验证的预期成果。 +- 每条 expected_result_placeholder 必须可映射到定量或可观察的检查项。 + +## 对齐校验 +- 输出 method_alignment_report,至少包含:case_id、selected_methods、alignment_score、gaps、fix_suggestions。 diff --git a/.github/skills/identify-requirement-type/SKILL.md b/.github/skills/identify-requirement-type/SKILL.md index d6e45b1..c24ee31 100644 --- a/.github/skills/identify-requirement-type/SKILL.md +++ b/.github/skills/identify-requirement-type/SKILL.md @@ -6,10 +6,11 @@ description: "当需要在测试项分解与测试用例生成之前识别需求 # identify-requirement-type ## 目标 -将用户需求文本识别为明确的测试需求类型,为后续测试项分解与测试用例生成提供分类依据。 +将用户需求文本识别为明确的测试需求类型,为后续测试项分解与测试用例生成提供分类依据、推荐测试方法与分解模板。 ## 输入 - user_requirement_text:用户原始需求文本。 +- debug(可选):是否返回分类分数和证据切片。 ## 输出 - requirement_type:以下之一 @@ -30,6 +31,9 @@ description: "当需要在测试项分解与测试用例生成之前识别需求 - 未知类型 - reason:简要判断依据。 - candidates:当 requirement_type 为未知类型时,给出 1-3 个最接近候选类型。 +- recommended_test_methods:基于类型推荐的测试方法列表(按优先级排序)。 +- suggested_decompose_template:推荐的测试项分解模板名称。 +- type_signals:触发该类型判断的关键语义信号。 ## 类型识别信号 - 功能测试:关注功能需求逐项验证、业务流程正确性、输入输出行为、状态转换与边界值处理。 @@ -47,16 +51,40 @@ description: "当需要在测试项分解与测试用例生成之前识别需求 - 敏感性测试:关注有效输入类中可能引发不稳定或不正常处理的数据组合。 - 测试充分性要求:关注需求覆盖率、配置项覆盖、语句覆盖、分支覆盖及未覆盖分析确认。 +## 附录 A:类型到方法与模板映射 +| 需求类型 | 推荐测试方法(优先顺序) | 推荐分解模板 | +| --- | --- | --- | +| 功能测试 | 功能分解, 等价类划分, 边界值分析, 场景法 | functional-standard-template | +| 性能测试 | 边界值分析, 组合测试法, 随机测试, 正交试验法 | performance-metric-template | +| 外部接口测试 | 等价类划分, 边界值分析, 判定表, 因果图 | external-interface-template | +| 人机交互界面测试 | 场景法, 猜错法, 等价类划分, 边界值分析 | hmi-flow-template | +| 强度测试 | 随机测试, 组合测试法, 边界值分析 | stress-limit-template | +| 余量测试 | 边界值分析, 组合测试法 | margin-capacity-template | +| 可靠性测试 | 随机测试, 场景法, 组合测试法, 蜕变测试法 | reliability-profile-template | +| 安全性测试 | 边界值分析, 场景法, 猜错法, 因果图 | safety-hazard-template | +| 恢复性测试 | 场景法, 功能图法, 猜错法 | recovery-fault-template | +| 边界测试 | 边界值分析, 等价类划分, 组合测试法 | boundary-focused-template | +| 安装性测试 | 场景法, 猜错法 | install-config-template | +| 互操作性测试 | 场景法, 组合测试法, 因果图 | interoperability-template | +| 敏感性测试 | 组合测试法, 正交试验法, 随机测试, 猜错法 | sensitivity-combination-template | +| 测试充分性要求 | 控制流测试, 数据流测试, 程序变异, 程序插桩 | adequacy-coverage-template | +| 未知类型 | 功能分解, 等价类划分, 边界值分析 | generic-fallback-template | + ## 规则 1. 优先依据需求文本中的显式表述进行分类。 2. 分类应以语义意图为主,不能只做关键词机械匹配。 3. 置信度不足时输出未知类型,并提供候选类型。 4. 判断依据需简洁、可追溯到文本证据。 +5. 若需求同时覆盖多个类型,输出主类型 requirement_type,并将次类型放入 candidates。 +6. recommended_test_methods 至少返回 2 个方法,优先返回文档中可直接执行的方法。 +7. suggested_decompose_template 必须与 requirement_type 一致。 ## 容错 - 当需求描述过于笼统或跨多类型混合时,输出未知类型,并在 candidates 给出最接近类型。 - 当识别不稳定时,优先保守分类,不强行归入单一类型。 - 未知类型不阻断后续流程,应继续执行通用测试项分解。 +- 当文本信息不足以支持白盒方法推荐时,保留黑盒优先推荐并标记 reason。 ## 调试 - debug 模式下返回每个类型的分类分数 classification_scores。 +- debug 模式下追加 evidence_spans(触发分类的文本片段)与 method_selection_reason。 diff --git a/.github/skills/testing-orchestrator/SKILL.md b/.github/skills/testing-orchestrator/SKILL.md index d9b5be5..a06beab 100644 --- a/.github/skills/testing-orchestrator/SKILL.md +++ b/.github/skills/testing-orchestrator/SKILL.md @@ -6,14 +6,14 @@ description: "当用户要求测试项分解或测试用例生成且需要完整 # testing-orchestrator ## 目标 -严格执行测试生成调用链,并显式传递每一步上下文。 +严格执行测试生成调用链,确保每一步上下文显式传递、结构一致、可追踪。 ## 标准调用链 1. identify-requirement-type 2. decompose-test-items 3. generate-test-cases -4. build_expected_results -5. format_output +4. build-expected-results +5. format-output ## 编排规则 1. 优先使用 Skill 与 Tool,不使用临时硬编码逻辑替代。 @@ -21,32 +21,76 @@ description: "当用户要求测试项分解或测试用例生成且需要完整 3. 每一步必须显式接收上一步输出。 4. 分类失败时输出未知类型并继续执行通用分解。 +## 步骤契约 + +### Step 1: identify-requirement-type +- 输入: + - user_requirement_text + - debug(可选) +- 输出: + - requirement_type + - reason + - candidates + - recommended_test_methods + - suggested_decompose_template + +### Step 2: decompose-test-items +- 输入: + - user_requirement_text + - requirement_type(来自 Step 1) + - recommended_test_methods(来自 Step 1) + - suggested_decompose_template(来自 Step 1) +- 输出: + - normal_test_items + - abnormal_test_items + - coverage_analysis + +### Step 3: generate-test-cases +- 输入: + - normal_test_items(来自 Step 2) + - abnormal_test_items(来自 Step 2) + - requirement_type(来自 Step 1) + - recommended_test_methods(来自 Step 1) +- 输出: + - normal_test_cases + - abnormal_test_cases + - method_alignment_report + +### Step 4: build-expected-results +- 输入: + - normal_test_cases(来自 Step 3) + - abnormal_test_cases(来自 Step 3) + - requirement_type(来自 Step 1) + - recommended_test_methods(来自 Step 1) +- 输出: + - normal_expected_results + - abnormal_expected_results + +### Step 5: format-output +- 输入: + - normal_test_items, abnormal_test_items(来自 Step 2) + - normal_test_cases, abnormal_test_cases(来自 Step 3) + - normal_expected_results, abnormal_expected_results(来自 Step 4) + - coverage_analysis(来自 Step 2) + - method_alignment_report(来自 Step 3) + - debug(可选) +- 输出: + - markdown_output + +## 执行顺序与回退 +1. Step 1 必须先执行,若无法确定类型则输出未知类型并继续。 +2. Step 2 若覆盖率不足,不中断流程,记录 coverage_analysis.gaps 并继续。 +3. Step 3 若方法对齐不足,不中断流程,输出 method_alignment_report。 +4. Step 4 若缺少定量口径,使用通用默认口径并标记待确认字段。 +5. Step 5 若 Markdown 格式化失败,回退为结构化 JSON,不丢失三段内容。 + +## 成果完整性检查 +- 最终输出必须同时包含:测试项、测试用例、预期成果。 +- 每个测试用例必须能追踪到测试项,每个预期成果必须能追踪到测试用例。 +- 输出必须按正常测试/异常测试分组。 + ## 输出模板 -最终输出必须严格遵循以下分组结构: - -**测试项** - -**正常测试**: -1. [测试项 N1]:... - -**异常测试**: -1. [测试项 E1]:... - -**测试用例** - -**正常测试**: -1. [用例 N1](对应测试项 N1):... - -**异常测试**: -1. [用例 E1](对应测试项 E1):... - -**预期成果** - -**正常测试**: -1. [预期 N1](对应用例 N1):... - -**异常测试**: -1. [预期 E1](对应用例 E1):... +最终输出由 format-output 统一生成,必须遵循三段式结构并保持编号追踪。 ## 调试模式 当 debug=true 时,输出步骤日志并包含: @@ -55,3 +99,4 @@ description: "当用户要求测试项分解或测试用例生成且需要完整 - output_summary - success - fallback_used +- duration_ms diff --git a/.github/常用测试方法.md b/.github/常用测试方法.md new file mode 100644 index 0000000..a8f88d5 --- /dev/null +++ b/.github/常用测试方法.md @@ -0,0 +1,116 @@ +B.1 常用的黑盒测试方法 + +B.1.1 功能分解 +功能分解是将规格说明中每一个功能加以分解,确保各个功能被全面地测试。功能分解是一种较常用的方法。 +功能分解方法的步骤为: +a. 使用程序设计中的功能抽象方法把程序分解为功能单元; +b. 使用数据抽象方法产生测试每个功能单元的数据。 +功能抽象中程序被看成一种抽象的功能层次,每个层次可标识被测试的功能,层次结构中的某一功能由其下一层功能定义。按照功能层次进行分解,可以得到众多的最低层次的子功能,以这些子功能为对象,进行测试用例设计。 +数据抽象中,数据结构可以由抽象数据类型的层次图来描述,每个抽象数据类型有其取值集合。 +程序的每一个输入和输出量的取值集合用数据抽象来描述。 + +B.1.2 等价类划分 +等价类划分是在分析规格说明的基础上,把程序的输入域划分成若干部分,然后在每部分中选取代表性数据形成测试用例。 +等价类划分方法的步骤为: +a. 划分有效等价类:对规格说明是有意义、合理的输入数据所构成的集合; +b. 划分无效等价类:对规格说明是无意义、不合理的输入数据所构成的集合; +c. 为每一个等价类定义一个唯一的编号; +d. 为每一个等价类设计一组测试用例,确保覆盖相应的等价类。 + +B.1.3 边界值分析 +边界值分析是使用等于、小于或大于边界值的数据对程序进行测试的方法。边界值分析方法的步骤为: +a. 通过分析规格说明,找出所有可能的边界条件; +b. 对每一个边界条件,给出满足和不满足边界值的输入数据; +c. 设计相应的测试用例。 +对满足边界值的输入可以发现计算错误,对不满足的输入可以发现域错误。该方法会为其他测试方法补充一些测试用例,绝大多数测试都会用到本方法。 + +B.1.4 判定表 +判定表由四部分组成:条件桩,条件条目,动作桩,动作条目。任何一个条件组合的取值及其相应要执行的操作构成规则,条目中的每一列是一条规则。 +条件引用输入的等价类,动作引用被测软件的主要功能处理部分,规则就是测试用例。 +建立并优化判定表,把判定表中每一列表示的情况写成测试用例。该方法的使用有以下要求: +a. 规格说明以判定表形式给出,或是很容易转换成判定表; +b. 条件的排列顺序不会影响执行哪些操作; +c. 规则的排列顺序不会影响执行哪些操作; +d. 每当某一规则的条件已经满足,并确定要执行的操作后,不必检验别的规则; +e. 如果某一规则的条件得到满足,将执行多个操作,这些操作的执行与顺序无关。 + +B.1.5 因果图 +因果图方法是通过画因果图,把用自然语言描述的功能说明转换为判定表,然后为判定表的每一列设计一个测试用例。 +因果图方法的步骤为: +a. 分析规格说明,引出原因(输入条件)和结果(输出结果),并给每个原因和结果赋予一个标识符; +b. 分析规格说明中语义的内容,并将其表示成连接各个原因和各个结果的“因果图”; +c. 在因果图上标明约束条件; +d. 通过跟踪因果图中的状态条件,把因果图转换成有限项的判定表; +e. 把判定表中每一列表示的情况生成测试用例。 +如果规格说明中含有输入条件的组合,宜采用本方法。有些软件的因果图可能非常庞大,以致于根据因果图得到的测试用例数目非常大,此时不宜使用本方法。 + +B.1.6 场景法 +通常,对由事件触发控制流程的软件,事件触发时的情景形成了场景。同一事件可能有不同的触发顺序和处理结果。因此,用况场景不仅描述了软件预期的或所希望的使用方式,也描述了流经用况的路径,包括从用况开始到结束遍历这条路径上所有的基本流和备选流。基本流是经过用况的最简单的路径。一个备选流可能从基本流开始,也可能起源于另一个备选流,备选流在某个特定条件下执行,最后可能重新加入基本流中,或者终止用况而不再重新加入到某个流中。按照经过用况的每个可能路径,可以确定不同的用况场景。每个用况场景的执行都是在特定条件触发下发生的,因此,可根据某个特定条件为每个场景生成测试用例。 +场景法的步骤为: +a. 确定用况的基本流和备选流; +b. 根据基本流和备选流确定不同的场景; +c. 针对每一个场景设计测试用例,可采用矩阵或判定表设计测试用例; +d. 对 c.中设计的全部测试用例进行审验,取消多余或等效的测试用例,确保测试用例准确和适度; +e. 针对每一个测试用例设计测试数据。 + +B.1.7 功能图法 +功能图法用功能图形式化地表示程序的功能说明,并生成测试用例。测试用例是由测试中经过的一系列状态和在每个状态中必须依靠输入/输出数据满足的一对条件组成。 +功能图模型由状态转移图和逻辑功能模型构成。状态转移图用于表示输入数据序列以及相应的输出数据,在状态转移图中,由输入数据和当前状态决定输出数据和后续状态。逻辑功能模型用于表示在状态中输入条件和输出条件之间的对应关系,且输出数据仅由输入数据决定。逻辑功能模型可用因果图和判定表来描述。 +功能图法生成测试用例的步骤为: +a. 由逻辑功能模型导出测试用例。对每个状态用因果图法生成局部测试用例; +b. 由状态转移图导出测试用例。用节点代替状态,用弧代替迁移,将状态转移图转化为控制流图形式。用白盒测试方法中介绍的控制流测试生成路径测试用例; +c. 合成测试用例。把局部测试用例分配给测试路径上的某一个状态,将路径测试用例与局部测试用例结合在一起,生成实用的功能图的测试用例。 + +B.1.8 随机测试 +随机测试指测试输入数据是在所有可能输入值中随机选取的。测试人员只需规定输入变量的取值区间,在需要时提供必要的变换机制,使产生的随机数服从预期的概率分布。该方法获得预期输出比较困难,多用于可靠性测试和系统强度测试。 + +B.1.9 猜错法 +猜错法是有经验的测试人员,通过列出可能有的错误和易错情况表,写出测试用例的方法。 + +B.1.10 正交试验法 +正交试验法是从大量的实验点中挑出适量的、有代表性的点,应用正交表,合理地安排实验的一种科学的实验设计方法。 +利用正交试验法来设计测试用例时,首先要根据被测软件的规格说明书找出影响功能实现的操作对象和外部因素,把它们当作因子,而把各个因子的取值当作状态,生成二元的因素分析表。然后,利用正交表进行各因子的状态的组合,构造有效的测试输入数据集,并由此建立因果图。这样得出的测试用例的数目将大大减少。 + +B.1.11 组合测试法 +组合测试是一种测试用例的生成方法。被测对象的不同输入参数分别有若干个取值。可以为不同输入参数轮流取不同值,以组合生成一组测试用例集。在黑盒测试中,组合的参数可以是软件的输入数据;在白盒测试中,组合的参数通常对应于单元的输入参数。 +对组合生成的测试用例集的检验,是看其能否满足不同的组合强度覆盖要求。常见的组合强度覆盖有单一选择、基本选择、成对组合、全组合和 K 强度组合等。 + +B.1.12 蜕变测试法 +蜕变测试是一种测试结果验证方法。当被测软件结构复杂,单个测试用例的测试结果的正确性难以判定时,可以通过构造蜕变关系,验证该测试用例与其他相关测试用例的测试结果是否满足蜕变关系,如果不满足蜕变关系,则这些测试用例不通过,并由此发现失效。蜕变测试可以与其他测试方法共同使用,通过蜕变测试对测试结果进行验证,或生成补充测试用例。 + +B.2 常用的白盒测试方法 + +B.2.1 控制流测试 +依据控制流程图产生测试用例,通过对不同控制结构成份的测试验证程序的控制结构。所谓验证某种控制结构即指使这种控制结构在程序运行中得到执行,也称这一过程为覆盖。常用的覆盖有语句覆盖、分支覆盖、条件覆盖、条件组合覆盖、修正的条件判定覆盖(MC/DC)、路径覆盖等。 +控制流测试的步骤为: +a. 将程序流程图转换成控制流图; +b. 经过语法分析求得路径表达式; +c. 生成路径树; +d. 进行路径编码; +e. 经过译码得到执行的路径; +f. 通过路径枚举产生特定路径的测试用例。 + +B.2.2 数据流测试 +数据流测试是用控制流程图对变量的定义和引用进行分析,查找出未定义的变量或定义了而未使用的变量,这些变量可能是拼错的变量、变量混淆或丢失了语句。数据流测试一般使用工具进行。 +数据流测试通过一定的覆盖准则,检查程序中每个数据对象的每次定义、使用和消除的情况。数据流测试方法的步骤为: +a. 将程序流程图转换成控制流图; +b. 在每个链路上标注对有关变量的数据操作的操作符号或符号序列; +c. 选定数据流测试策略; +d. 根据测试策略得到测试路径; +e. 根据路径可以获得测试输入数据和测试用例。 +动态数据流异常检查在程序运行时执行,获得的是对数据对象的真实操作序列,克服了静态分析检查的局限,但动态方式检查是沿与测试输入有关的一部分路径进行的,检查的全面性和程序结构覆 盖有关。 + +B.2.3 程序变异 +程序变异是一种错误驱动测试方法。该方法针对某类特定程序错误,定义一系列的变异算子,将变异算子作用于程序代码,产生若干符合语法的变异体,实际是将故障植入程序,用测试数据逐个测试变异体。根据变异程度的不同,可以分为强变异和弱变异。程序变异方法具有针对性强、系统性强的特点,在对软件进行测试的同时,也可评价测试用例集的错误检测能力。 + +B.2.4 程序插桩 +程序插桩是向被测程序中插入操作以实现测试目的方法。程序插桩不应该影响被测程序的运行过程和功能。 +有很多的工具有程序插桩功能。由于数据记录量大,手工进行将是一件很烦琐的事。 + +B.2.5 域测试 +域测试是要判别程序对输入空间的划分是否正确。该方法限制太多,使用不方便,供有特殊要求 +的测试使用。 + +B.2.6 符号求值 +符号求值是允许数值变量取“符号值”以及数值。符号求值可以检查公式的执行结果是否达到程序预期的目的,也可以通过程序的符号执行,产生出程序的路径,用于产生测试数据。 +符号求值最好使用工具,在公式分支较少时手工推导也是可行的。 diff --git a/.github/测试项与测试用例技术要求.md b/.github/测试项与测试用例技术要求.md new file mode 100644 index 0000000..9f387f7 --- /dev/null +++ b/.github/测试项与测试用例技术要求.md @@ -0,0 +1,54 @@ +C.1 测试项 +测试项是指通过测试需求分析所得到的需要测试的特定科目。测试项有明确的测试目标和测试方法。通常一个测试对象对应多个测试项,一个测试项可划分成多个测试子项。测试项可对应由一种或 多种测试类型覆盖。 +测试项(或测试子项)的要素至少应包括: +a. 名称和标识。每个测试项应有唯一的名称和标识; +b. 测试项说明。简要描述测试目标和测试内容; +c. 测试方法。说明对测试项进行测试所采用的策略,包括采用的测试方法以及测试数据生成方法、测试数据注入方法、测试结果捕获方法及分析方法、使用的测试工具等; +d. 测试充分性要求。说明为实现测试目标,测试项应覆盖的范围及覆盖程度; +e. 测试项终止条件。说明正常终止的条件(例如,测试充分性是否达到要求)和导致测试异常终止的可能情况; +f. 优先级。说明测试项的优先顺序; +g. 追踪关系。说明测试项对测试依据的追踪关系,应追踪到测试依据的某个具体功能(或子功能)、接口、性能等。测试依据一般包括软件测试任务书/合同、软件开发文档、软件更改报告等。 + +C.2 测试用例 +C.2.1 设计原则 +设计测试用例应遵循以下原则: +a. 基于测试需求的原则。测试需求来自于软件测试任务书、合同或其他等效文件、软件开发文档、软件更改报告、软件代码等。应在测试项的基础上设计测试用例; +b. 基于测试方法的原则。应明确所采用的测试用例设计方法。为达到测试充分性要求,应采用相应的测试方法; +c. 兼顾测试充分性和效率的原则。测试用例集应兼顾测试的充分性和测试效率,每个测试用例应完整、具有可操作性; +d. 测试执行的可重复性原则。应保证测试用例执行的可重复性。 + +C.2.2 要素内容 +测试用例是针对测试项所设计的,描述测试输入、测试方法、操作步骤、预期结果的集合。通常一个测试项由多个测试用例来覆盖。 +测试用例至少应包括以下要素: +a. 名称和标识。每个测试用例应有唯一的名称和标识。 +b. 测试追踪。说明测试所依据的内容来源,通常是对测试大纲/计划的追踪关系,应追踪到测试大纲/计划中的具体测试项或子测试项。 +c. 用例综述。简要描述测试目的和所采用的测试方法。 +d. 测试的初始化要求。应考虑下述初始化要求: + 1. 硬件配置。被测系统的硬件配置情况,包括硬件条件或电气状态; + 2. 软件配置。被测系统的软件配置情况,包括测试的初始条件; + 3. 测试配置。测试系统的配置情况,例如,用于测试的模拟系统和测试工具等的配置情况; + 4. 参数设置。测试开始前的设置,例如,标志、第一断点、指针、控制参数和初始化数据等的设置。 + 5. 其他对于测试用例的特殊说明。 +e. 测试的输入。在测试用例执行中发送给被测对象的所有测试命令、数据和信号等。对于每个测试用例应提供: + 1. 每个测试输入的具体内容(例如,确定的数值、状态或信号等)及其性质(例如,有效值、无效值、边界值等); + 2. 测试输入的来源(例如,测试程序产生、磁盘文件、通过网络接收、人工键盘输入等),以及选择输入所使用的方法(例如,等价类划分、边界值分析、错误推测、因果图、功能 图方法等); + 3. 测试输入是真实的还是模拟的; + 4. 测试输入的时间顺序或事件顺序。 +f. 期望结果。说明测试用例执行中由被测软件所产生的期望测试结果,即经过验证,认为正确的结果。必要时,应提供中间的期望结果。期望测试结果应该有具体内容,例如,确定的数值、 状态或信号等,不应是不确切的概念或笼统的描述。 +g. 测试结果评估准则。判断测试用例执行中产生的中间和最后结果是否正确的标准。对于每个测试结果,应根据不同情况提供: + 1. 实际测试结果所需的精度; + 2. 实际测试结果与期望结果之间的差异允许的上限、下限; + 3. 时间的最大和/或最小间隔,或事件数目的最大和/或最小值; + 4. 实际测试结果不确定时,再测试的条件; + 5. 与产生测试结果有关的出错处理; + 6. 上面没有提及的其他标准。 +h. 操作步骤。实施测试用例的执行步骤。对于每个操作应提供: + 1. 每一步的测试输入; + 2. 每一步所需的操作动作、测试程序的输入/输出操作、设备操作等; + 3. 每一步的期望结果; + 4. 每一步的评估准则; + 5. 程序终止伴随的动作或错误指示; + 6. 获取和分析实际测试结果的过程。 +i. 前提和约束。在测试用例说明中施加的所有前提条件和约束条件,如果有特别限制、参数偏差或异常处理,应该标识出来,并说明它们对测试用例的影响。 +j. 测试用例终止条件。说明测试正常终止和异常终止的条件。 +k. 测试用例通过准则。判断测试用例是否通过的标准。 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4b5a294 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:conda", + "python-envs.defaultPackageManager": "ms-python.python:conda" +} \ No newline at end of file diff --git a/README.md b/README.md index 66112f6..cd85a8a 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,8 @@
- 基于 RAG 的知识库问答与文档处理平台
- -
简介 •
核心能力 •
diff --git a/rag-web-ui/backend/app/tools/srs_reqs_qwen/default_config.yaml b/rag-web-ui/backend/app/tools/srs_reqs_qwen/default_config.yaml
index eec6cc3..4e2ae26 100644
--- a/rag-web-ui/backend/app/tools/srs_reqs_qwen/default_config.yaml
+++ b/rag-web-ui/backend/app/tools/srs_reqs_qwen/default_config.yaml
@@ -3,14 +3,14 @@
# LLM配置 - 阿里云千问
llm:
- # 是否启用LLM(设为false则使用纯规则提取)
+ # 是否启用LLM(当前版本必须为true)
enabled: true
# LLM提供商:qwen(阿里云千问)
provider: "qwen"
# 模型名称
- model: "qwen3-max"
- # API密钥统一由 rag-web-ui 的环境变量提供
- api_key: ""
+ model: "glm-5"
+ # API密钥(建议使用环境变量 DASHSCOPE_API_KEY)
+ api_key: "sk-7097f7842f724f0c9e70c4bf3b16dacb"
# 可选参数
temperature: 0.3
max_tokens: 1024
@@ -48,7 +48,7 @@ extraction:
priority: 1
接口需求:
prefix: "IR"
- keywords: ["接口", "interface", "api", "外部接口", "内部接口", "CAN", "以太网", "通信"]
+ keywords: ["接口", "interface", "api", "外部接口", "内部接口", "输入输出"]
priority: 2
性能需求:
prefix: "PR"
@@ -68,23 +68,105 @@ extraction:
priority: 6
splitter:
enabled: true
- max_sentence_len: 120
- min_clause_len: 12
+ max_sentence_len: 160
+ min_clause_len: 20
+ semantic_type_policy:
+ interface_section_hints:
+ - "接口描述"
+ - "接口需求"
+ - "接口要求"
+ - "外部接口"
+ - "内部接口"
+ - "I/O"
+ interface_title_excludes:
+ - "计算机通信需求"
+ - "通信需求"
+ - "通信要求"
+ functional_section_hints:
+ - "功能需求"
+ - "功能要求"
+ other_section_hints:
+ - "安全性需求"
+ - "保密性需求"
+ - "适应性需求"
+ - "环境需求"
+ - "资源需求"
+ - "质量"
+ - "设计约束"
+ - "培训需求"
+ - "软件保障"
+ - "验收"
+ - "交付"
+ - "包装"
+ - "通信需求"
+ - "计算机通信需求"
+ - "硬件环境"
+ - "软件环境"
+ - "运行环境"
semantic_guard:
enabled: true
preserve_condition_action_chain: true
preserve_alarm_chain: true
+ system_description_hints:
+ - "系统描述"
+ - "功能描述"
+ - "概述"
+ - "示意图"
+ - "组成"
+ - "架构"
+ - "原理"
table_strategy:
llm_semantic_enabled: true
sequence_table_merge: "single_requirement"
merge_time_series_rows_min: 3
+ skip_keywords:
+ - "系统功能要求"
+ - "性能要求"
+ - "系统性能要求"
+ - "系统接口要求"
+ - "功能矩阵"
+ - "能力对照"
+ - "性能指标对照"
+ interface_keywords:
+ - "接口"
+ - "interface"
+ - "输入输出"
+ - "I/O"
+ - "数据来源"
+ - "数据目的地"
+ - "来源"
+ - "目的地"
+ single_requirement_keywords:
+ - "硬件要求"
+ - "软件要求"
+ - "运行环境"
+ - "硬件环境"
+ - "软件环境"
+ - "运行硬件环境"
+ - "运行软件环境"
+ - "环境需求"
+ - "资源需求"
+ - "计算机资源"
rewrite_policy:
llm_light_rewrite_enabled: true
preserve_ratio_min: 0.65
max_length_growth_ratio: 1.25
+ non_interface_max_edit_distance: 20
renumber_policy:
enabled: true
mode: "section_continuous"
+ dedup_policy:
+ similarity_threshold: 0.88
+ enable_cross_section_dedup: true
+ prefer_text_over_table: true
+ interface_policy:
+ unknown_fallback: "未知"
+ normalization_policy:
+ ocr_spacing_normalize: true
+ fidelity_policy:
+ preserve_source_text_for_text_blocks: true
+ punctuation_policy:
+ ensure_terminal_period: true
# 输出配置
output:
diff --git a/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/document_parser.py b/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/document_parser.py
index 859029a..dd4505d 100644
--- a/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/document_parser.py
+++ b/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/document_parser.py
@@ -4,7 +4,6 @@
支持PDF和Docx格式,针对GJB438B标准SRS文档优化
"""
-import os
import re
import logging
import importlib
@@ -119,43 +118,19 @@ class DocumentParser(ABC):
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)
+ if not sections:
+ return
+
+ # 仅为缺失编号的章节补号;已存在的文档原始编号必须保留。
+ sibling_index = 0
+ for section in sections:
+ has_number = bool((section.number or "").strip()) and not self._is_chinese_number(section.number)
+ if not has_number:
+ sibling_index += 1
+ section.generate_auto_number(parent_number, sibling_index)
+
+ if section.children:
+ self._auto_number_sections(section.children, section.number)
def _is_chinese_number(self, text: str) -> bool:
"""检查是否是中文数字编号"""
@@ -327,8 +302,13 @@ class PDFParser(DocumentParser):
'优先', '关键', '合格', '追踪', '注释',
'CSCI', '计算机', '软件', '硬件', '通信', '通讯',
'数据', '适应', '可靠', '内部', '外部',
- '描述', '要求', '规定', '说明', '定义',
- '电场', '防护', '装置', '控制', '监控', '显控'
+ '描述', '要求', '规定', '说明', '定义'
+ ]
+
+ TOP_LEVEL_TITLE_KEYWORDS = [
+ '范围', '标识', '概述', '引用', '文档', '需求', '接口', '性能',
+ '安全', '保密', '环境', '资源', '质量', '设计', '约束', '验收',
+ '交付', '包装', '注释'
]
# 明显无效的章节标题模式(噪声)
@@ -411,21 +391,41 @@ class PDFParser(DocumentParser):
if page_idx < len(self._page_texts):
page_text = self._page_texts[page_idx]
- extracted_tables = page.extract_tables() or []
- for table_idx, table in enumerate(extracted_tables):
+ table_objs = page.find_tables() or []
+ if table_objs:
+ extracted_tables = [(idx, t.extract(), t.bbox) for idx, t in enumerate(table_objs)]
+ else:
+ raw_tables = page.extract_tables() or []
+ extracted_tables = [(idx, t, None) for idx, t in enumerate(raw_tables)]
+
+ for table_idx, table, bbox in extracted_tables:
cleaned_table: List[List[str]] = []
for row in table or []:
cells = [re.sub(r'\s+', ' ', str(cell or '')).strip() for cell in row]
+ # 只要存在非空单元格就保留,避免有效行被误丢弃。
if any(cells):
cleaned_table.append(cells)
if cleaned_table:
+ section_hint = ""
+ if bbox:
+ try:
+ top = float(bbox[1])
+ text_above = page.crop((0, 0, page.width, top)).extract_text() or ""
+ section_hint = self._find_last_section_number(text_above)
+ except Exception:
+ section_hint = ""
+
+ table_ref = self._extract_table_reference(cleaned_table)
+
tables.append(
{
"page_idx": page_idx,
"table_idx": table_idx,
"page_text": page_text,
"data": cleaned_table,
+ "section_hint": section_hint,
+ "table_ref": table_ref,
}
)
except Exception as e:
@@ -435,16 +435,86 @@ class PDFParser(DocumentParser):
logger.info(f"PDF表格提取完成,共{len(tables)}个表格")
return tables
+ def _extract_table_reference(self, table: List[List[str]]) -> str:
+ """从表格前几行中提取表号引用,如“表3-5”。"""
+ if not table:
+ return ""
+
+ head_rows = table[:2]
+ merged = " ".join(" ".join(str(c or "") for c in row) for row in head_rows)
+ merged = re.sub(r"\s+", "", merged)
+ m = re.search(r"表\s*(\d+(?:[--]\d+){1,3})", merged)
+ if not m:
+ return ""
+ return m.group(1).replace("-", "-")
+
+ def _build_table_reference_index(self, sections: List[Section]) -> Dict[str, List[Section]]:
+ """构建“表号 -> 章节”索引,用于优先精确挂接表格。"""
+ index: Dict[str, List[Section]] = {}
+ for section in sections:
+ content = re.sub(r"\s+", "", section.content or "")
+ for m in re.finditer(r"表\s*(\d+(?:[--]\d+){1,3})", content):
+ ref = m.group(1).replace("-", "-")
+ index.setdefault(ref, []).append(section)
+ return index
+
+ def _find_last_section_number(self, text: str) -> str:
+ """从文本中提取最后出现的章节号。"""
+ if not text:
+ return ""
+
+ found = ""
+ for line in text.split("\n"):
+ line = line.strip()
+ if not line:
+ continue
+ section_info = self._match_section_header(line, set())
+ if section_info:
+ found = section_info[0]
+ return found
+
def _attach_pdf_tables_to_sections(self, tables: List[Dict[str, Any]]) -> None:
"""将提取出的PDF表格挂接到最匹配的章节。"""
flat_sections = self._flatten_sections(self.sections)
if not flat_sections:
return
+ section_by_number = {
+ (s.number or "").strip(): s
+ for s in flat_sections
+ if (s.number or "").strip()
+ }
+ table_ref_index = self._build_table_reference_index(flat_sections)
+
last_section: Optional[Section] = None
for table in tables:
- matched = self._match_table_section(table.get("page_text", ""), flat_sections)
- target = matched or last_section or flat_sections[0]
+ target = None
+
+ table_ref = (table.get("table_ref") or "").strip()
+ if table_ref and table_ref in table_ref_index:
+ candidates = table_ref_index[table_ref]
+ # 同表号命中多个章节时,优先更深层章节,避免父级“汇总章节”抢占。
+ target = max(candidates, key=lambda s: (s.level, len(s.content or "")))
+
+ section_hint = (table.get("section_hint") or "").strip()
+ if not target and section_hint and section_hint in section_by_number:
+ target = section_by_number[section_hint]
+
+ if not target:
+ target = self._match_table_section(table.get("page_text", ""), flat_sections)
+
+ # 兜底优先使用上一个命中章节,避免错误挂到首章节造成跨章污染。
+ if not target:
+ target = last_section
+
+ if not target:
+ logger.warning(
+ "未定位到表格归属章节,跳过: page=%s table=%s",
+ table.get("page_idx", -1),
+ table.get("table_idx", -1),
+ )
+ continue
+
target.add_table(table["data"])
last_section = target
@@ -464,7 +534,7 @@ class PDFParser(DocumentParser):
return None
matched: Optional[Section] = None
- matched_score = -1
+ matched_score = (-1, -1)
for section in sections:
title = (section.title or "").strip()
if not title:
@@ -479,7 +549,7 @@ class PDFParser(DocumentParser):
for candidate in candidates:
normalized_candidate = re.sub(r"\s+", "", candidate).lower()
if normalized_candidate and normalized_candidate in normalized_page:
- score = len(normalized_candidate)
+ score = (len(normalized_candidate), section.level)
if score > matched_score:
matched = section
matched_score = score
@@ -514,6 +584,7 @@ class PDFParser(DocumentParser):
current_section = None
content_buffer = []
found_sections = set()
+ last_top_level_number = 0
for line in lines:
line = line.strip()
@@ -526,6 +597,22 @@ class PDFParser(DocumentParser):
if section_info:
number, title = section_info
level = len(number.split('.'))
+ top_level_number = int(number.split('.')[0])
+
+ # 顶级章节序号大幅跳跃通常是误识别(如正文中的“8 表...”)。
+ if level == 1 and last_top_level_number and top_level_number > last_top_level_number + 1:
+ if line and not self._is_noise(line):
+ content_buffer.append(line)
+ continue
+
+ # 顶级章节编号倒退通常是正文枚举项被误识别(如“1 综合监控...”)。
+ if level == 1 and last_top_level_number and top_level_number < last_top_level_number:
+ if line and not self._is_noise(line):
+ content_buffer.append(line)
+ continue
+
+ if level > 6:
+ continue
# 保存之前章节的内容
if current_section and content_buffer:
@@ -540,6 +627,7 @@ class PDFParser(DocumentParser):
if level == 1:
sections.append(section)
section_stack = {1: section}
+ last_top_level_number = top_level_number
else:
parent_level = level - 1
while parent_level >= 1 and parent_level not in section_stack:
@@ -557,6 +645,10 @@ class PDFParser(DocumentParser):
for l in list(section_stack.keys()):
if l > level:
del section_stack[l]
+
+ # 若出现层级跳跃(如1->3),自动回退到父级+1。
+ if level > 1 and (level - 1) not in section_stack:
+ section.level = max(section_stack.keys()) if section_stack else 1
current_section = section
else:
@@ -577,13 +669,14 @@ class PDFParser(DocumentParser):
Returns:
(章节编号, 章节标题) 或 None
"""
- # 模式: "3.1功能需求" 或 "3.1 功能需求"
- match = re.match(r'^(\d+(?:\.\d+)*)\s*(.+)$', line)
+ # 模式: "3.1 功能需求" / "3.1.2 电场..."
+ match = re.match(r'^(\d+(?:\.\d+)*)[\s、.))]*(.+)$', line)
if not match:
return None
number = match.group(1)
title = match.group(2).strip()
+ level = len(number.split('.'))
# 排除目录行
if '...' in title or title.count('.') > 5:
@@ -609,6 +702,18 @@ class PDFParser(DocumentParser):
# 标题长度检查
if len(title) > 60 or len(title) < 2:
return None
+
+ # 过滤更像正文描述的句式。
+ if self._looks_like_statement(title):
+ return None
+
+ # 过滤疑似正文句子(含句号/分号且过长)。
+ if len(title) > 24 and re.search(r'[。;;]', title):
+ return None
+
+ # 过滤指令拼接噪声标题(逗号过多通常是正文残片)。
+ if title.count(',') >= 2 and len(title) > 20:
+ return None
# 放宽标题字符要求(兼容部分PDF字体导致中文抽取异常的情况)
if not re.search(r'[\u4e00-\u9fa5A-Za-z]', title):
@@ -631,8 +736,30 @@ class PDFParser(DocumentParser):
# 检查标题是否包含反斜杠(通常是表格噪声)
if '\\' in title and '需求' not in title:
return None
+
+ # 常见有效标题关键词兜底,降低正文被识别为标题的概率。
+ if not any(k in title for k in self.VALID_TITLE_KEYWORDS):
+ return None
+
+ # 顶级章节标题需符合SRS结构性关键词,避免“综合监控”“电场”等正文短语被识别。
+ if level == 1 and not any(k in title for k in self.TOP_LEVEL_TITLE_KEYWORDS):
+ return None
return (number, title)
+
+ def _looks_like_statement(self, title: str) -> bool:
+ """判断标题是否更像正文语句而非章节名。"""
+ if not title:
+ return False
+
+ statement_hints = ["应", "能够", "可以", "进行", "通过", "并", "同时", "当", "如果", "则"]
+ if any(h in title for h in statement_hints):
+ return True
+
+ if len(title) > 24 and re.search(r'[,。;;::]', title):
+ return True
+
+ return False
def _is_noise(self, line: str) -> bool:
"""检查是否是噪声行"""
diff --git a/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/json_generator.py b/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/json_generator.py
index 1bc46a3..b6408b8 100644
--- a/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/json_generator.py
+++ b/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/json_generator.py
@@ -146,8 +146,8 @@ class JSONGenerator:
if req.type == 'interface':
req_dict["接口名称"] = req.interface_name
req_dict["接口类型"] = req.interface_type
- req_dict["来源"] = req.source
- req_dict["目的地"] = req.destination
+ req_dict["数据来源"] = req.source
+ req_dict["数据目的地"] = req.destination
result["需求列表"].append(req_dict)
# 如果有子章节,添加子章节
diff --git a/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/llm_interface.py b/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/llm_interface.py
index b2db801..054125e 100644
--- a/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/llm_interface.py
+++ b/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/llm_interface.py
@@ -7,6 +7,7 @@ import logging
import json
from abc import ABC, abstractmethod
from typing import Dict, List, Optional, Any
+import requests
from .utils import get_env_or_config
@@ -86,6 +87,34 @@ class QwenLLM(LLMInterface):
except ImportError:
logger.error("dashscope库未安装,请运行: pip install dashscope")
raise
+
+ def _call_compatible_mode(self, prompt: str) -> str:
+ """使用OpenAI兼容模式HTTP接口调用千问。"""
+ endpoint = self.api_endpoint.rstrip("/") + "/chat/completions"
+ headers = {
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ }
+ payload = {
+ "model": self.model,
+ "messages": [{"role": "user", "content": prompt}],
+ "temperature": self.extra_params.get("temperature", 0.3),
+ "max_tokens": self.extra_params.get("max_tokens", 1024),
+ }
+
+ resp = requests.post(endpoint, headers=headers, json=payload, timeout=120)
+ if resp.status_code != 200:
+ raise Exception(f"兼容模式API调用失败: {resp.status_code} {resp.text[:300]}")
+
+ data = resp.json()
+ choices = data.get("choices", [])
+ if not choices:
+ raise Exception(f"兼容模式API返回无choices: {str(data)[:300]}")
+ message = choices[0].get("message", {})
+ content = message.get("content", "")
+ if not content:
+ raise Exception(f"兼容模式API返回空内容: {str(data)[:300]}")
+ return content
def call(self, prompt: str) -> str:
"""
@@ -153,6 +182,11 @@ class QwenLLM(LLMInterface):
return str(response)
except Exception as e:
+ err_msg = str(e)
+ # DashScope旧路径报url error时,回退到兼容模式接口。
+ if "url error" in err_msg.lower() or "status: 400" in err_msg.lower():
+ logger.warning("DashScope调用失败,回退兼容模式接口: %s", err_msg)
+ return self._call_compatible_mode(prompt)
logger.error(f"调用千问LLM失败: {e}")
raise
diff --git a/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/requirement_extractor.py b/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/requirement_extractor.py
index dbfef14..f589f80 100644
--- a/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/requirement_extractor.py
+++ b/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/requirement_extractor.py
@@ -7,6 +7,7 @@
import re
import json
import logging
+from difflib import SequenceMatcher
from typing import List, Dict, Optional, Tuple, Any
from .document_parser import Section
from .settings import AppSettings
@@ -51,8 +52,8 @@ class Requirement:
if self.type == 'interface':
result["接口名称"] = self.interface_name
result["接口类型"] = self.interface_type
- result["来源"] = self.source
- result["目的地"] = self.destination
+ result["数据来源"] = self.source
+ result["数据目的地"] = self.destination
return result
def __repr__(self) -> str:
@@ -94,6 +95,9 @@ class RequirementExtractor:
for section in sections:
self._process_section(section)
+ if self.settings.enable_cross_section_dedup:
+ self.requirements = self._global_deduplicate_requirements(self.requirements)
+
# 去重后统一连续重编号,避免出现跳号。
if self.settings.renumber_enabled:
self.requirements = self._renumber_requirements_continuous(self.requirements)
@@ -122,29 +126,53 @@ class RequirementExtractor:
"""判断是否应该跳过此章节"""
if self.settings.is_non_requirement_section(section.title):
return True
+
+ if self._is_diagram_or_overview_section(section.title):
+ return True
# 检查是否是系统描述章节(如3.1.1通常是系统描述)
if self._is_system_description(section):
return True
return False
+
+ def _is_diagram_or_overview_section(self, title: str) -> bool:
+ t = (title or "").strip()
+ if not t:
+ return False
+ # 示意图/概述章节通常不承载可验证需求。
+ if any(k in t for k in ["示意图", "概述"]):
+ if "需求" not in t and "要求" not in t:
+ return True
+ # 即使包含“需求/要求”,若明确是示意图也仍跳过。
+ if "示意图" in t:
+ return True
+ return False
def _is_system_description(self, section: Section) -> bool:
"""判断是否是系统描述章节(应该跳过)"""
+ title = section.title or ""
+
+ # 明确的需求语义章节优先提取,避免误判导致漏提。
+ if self._is_requirement_semantic_section(title):
+ return False
+
# 检查标题
- desc_keywords = ['系统描述', '功能描述', '概述', '示意图', '组成']
+ desc_keywords = self.settings.system_description_hints
for kw in desc_keywords:
- if kw in section.title:
+ if kw in title:
return True
- # 使用LLM判断
- if self.llm and section.content:
+ if not self.llm:
+ raise RuntimeError("LLM实例未初始化,当前版本仅支持LLM提取")
+
+ if section.content:
try:
result = self._llm_check_system_description(section)
return result
except Exception as e:
- logger.warning(f"LLM判断失败,使用规则判断: {e}")
-
+ raise RuntimeError(f"LLM系统描述判断失败: section={section.number} {section.title}, error={e}") from e
+
return False
def _llm_check_system_description(self, section: Section) -> bool:
@@ -163,17 +191,62 @@ class RequirementExtractor:
回答(只需要回答"是"或"否"):"""
response = self.llm.call(prompt).strip()
- return '是' in response
+ return response.startswith("是")
+
+ def _is_requirement_semantic_section(self, title: str) -> bool:
+ """章节标题是否具有明确需求语义。"""
+ return (
+ self.settings.is_interface_semantic_title(title)
+ or self.settings.is_functional_semantic_title(title)
+ or self.settings.is_other_semantic_title(title)
+ or ("需求" in (title or ""))
+ or ("要求" in (title or ""))
+ )
+
+ def _should_extract_text_block(self, section: Section, text: str, req_type: str) -> bool:
+ """判断文本块是否值得提取需求。"""
+ cleaned = (text or "").strip()
+ if len(cleaned) < 8:
+ return False
+
+ if self._is_requirement_semantic_section(section.title):
+ return True
+
+ hard_requirement_hints = ["应", "必须", "需", "shall", "should", "不得", "支持"]
+ if any(h in cleaned for h in hard_requirement_hints):
+ return True
+
+ prompt = f"""请判断以下文本是否包含具体、可验证的软件需求。
+
+章节标题:{section.title}
+需求类型:{req_type}
+文本内容:
+{cleaned[:700]}
+
+判定规则:
+- 回答“是”:包含可执行、可测试、可验收的具体要求。
+- 回答“否”:仅为概括、背景、系统组成说明、引言或泛化描述。
+
+回答仅输出“是”或“否”。"""
+
+ response = self.llm.call(prompt).strip()
+ return response.startswith("是")
def _extract_requirements_from_section(self, section: Section) -> List[Requirement]:
"""从单个章节按文档顺序提取需求。"""
requirements: List[Requirement] = []
+ if not self.llm:
+ raise RuntimeError("LLM实例未初始化,当前版本仅支持LLM提取")
+
req_type = self._identify_requirement_type(section.title, section.content)
+ is_environment_section = self._is_environment_section(section.title)
+
+ if self._should_suppress_section_requirements(section, req_type):
+ return []
blocks = self._iter_section_blocks(section)
for block in blocks:
block_type = block.get("type", "text")
- block_order = int(block.get("order", 0))
temp_section = Section(
level=section.level,
@@ -184,20 +257,39 @@ class RequirementExtractor:
)
if block_type == "text":
- temp_section.content = block.get("text", "")
- if self.llm:
- block_reqs = self._llm_extract_requirements(temp_section, req_type)
- else:
- block_reqs = self._rule_extract_requirements(temp_section, req_type)
+ # 环境类章节以表格为准,一表一条,正文不单独拆多条。
+ if is_environment_section:
+ continue
+ temp_section.content = self._sanitize_block_text(block.get("text", ""), section.number)
+ if len(temp_section.content.strip()) < 8:
+ continue
+ if not self._should_extract_text_block(temp_section, temp_section.content, req_type):
+ continue
+ block_reqs = self._llm_extract_requirements(temp_section, req_type, source_type="text")
table_index = -1
else:
table_data = block.get("table", [])
temp_section.tables = [table_data] if table_data else []
table_index = int(block.get("table_index", -1))
- if self.llm and self.settings.table_llm_semantic_enabled:
- block_reqs = self._llm_extract_table_requirements(temp_section, req_type)
+
+ # 安全/保密/适应性章节若误挂接口风格表格,直接忽略。
+ if self._should_skip_table_for_section(section.title, table_data, req_type):
+ continue
+
+ if is_environment_section:
+ table_mode = "single"
else:
- block_reqs = self._rule_extract_requirements(temp_section, req_type)
+ table_mode = self._classify_table_mode(section.title, table_data, req_type)
+ if table_mode == "skip":
+ continue
+ if table_mode == "single":
+ block_reqs = self._llm_extract_table_as_single_requirement(temp_section, req_type)
+ else:
+ interface_rows = self._extract_interface_rows_from_table(table_data) if table_mode == "interface" else []
+ if self.llm and self.settings.table_llm_semantic_enabled:
+ block_reqs = self._llm_extract_table_requirements(temp_section, req_type, interface_rows=interface_rows)
+ else:
+ block_reqs = self._llm_extract_requirements(temp_section, req_type, source_type="table")
for req in block_reqs:
self._global_order += 1
@@ -205,11 +297,188 @@ class RequirementExtractor:
req.source_order = self._global_order
req.source_table_index = table_index
req.source_row_span = block.get("row_span", "")
- req.description = self._maybe_light_rewrite(req.description, block_type)
+ req.description = self._maybe_light_rewrite(req.description, block_type, req.type)
+ req.description = self._clean_description(req.description)
+ if self._is_low_quality_requirement(req.description, req.type):
+ continue
+ if req.type != 'interface' and block_type == 'text':
+ req.description = self._snap_to_source_sentence(req.description, temp_section.content)
+ if self._is_low_quality_requirement(req.description, req.type):
+ continue
+ if req.type == 'interface':
+ if self._is_generic_reference_requirement(req.description):
+ continue
+ req.interface_name = self._normalize_interface_field(req.interface_name)
+ req.interface_type = self._normalize_interface_field(req.interface_type)
+ req.source = self._normalize_interface_field(req.source)
+ req.destination = self._normalize_interface_field(req.destination)
requirements.append(req)
requirements = self._semantic_integrity_postprocess(requirements)
- return self._deduplicate_requirements(requirements)
+ requirements = self._merge_fragment_requirements(requirements)
+ requirements = self._deduplicate_requirements(requirements)
+ requirements = self._drop_inferior_interface_duplicates(requirements)
+ return requirements
+
+ def _is_environment_section(self, title: str) -> bool:
+ t = (title or "").strip()
+ return any(k in t for k in ["硬件环境", "软件环境", "运行环境", "计算机硬件", "计算机软件"])
+
+ def _should_skip_table_for_section(self, section_title: str, table: List[List[str]], req_type: str) -> bool:
+ if not table:
+ return False
+ title = (section_title or "")
+ header_text = " ".join(str(c or "") for c in table[0]).lower() if table else ""
+
+ # 安全/保密/适应性章节中出现接口风格表头,通常是误挂表。
+ if any(k in title for k in ["安全性需求", "保密性需求", "适应性需求"]):
+ interface_style_hints = ["来源", "目的地", "标识", "数据消息", "接口"]
+ if any(h in header_text for h in interface_style_hints):
+ return True
+
+ return False
+
+ def _is_low_quality_requirement(self, description: str, req_type: str) -> bool:
+ text = (description or "").strip()
+ if len(text) < 6:
+ return True
+
+ normalized = re.sub(r"\s+", "", text)
+ if "无。" in normalized and "否。" in normalized:
+ return True
+ bad_markers = ["无。", "否。", "是。", "无,", "否,"]
+ if any(m in normalized for m in bad_markers):
+ action_hints = ["应", "必须", "需", "支持", "通过", "提供", "实现", "具备", "监测", "控制", "接口"]
+ if not any(h in normalized for h in action_hints):
+ return True
+
+ # 类似“4。xxx。MOD_XXX。否。”的噪声拼接句。
+ if re.match(r"^\d+[。.、]", normalized):
+ if normalized.count("。") >= 3 and ("否。" in normalized or "无。" in normalized):
+ return True
+
+ # 标点占比过高且缺少动作谓词,通常是表格噪声拼接。
+ punct_count = len(re.findall(r"[,,。;;::()()\[\]{}]", text))
+ if punct_count / max(len(text), 1) > 0.22:
+ action_hints = ["应", "必须", "需", "支持", "通过", "提供", "实现", "具备", "监测", "控制", "接口"]
+ if not any(h in text for h in action_hints):
+ return True
+
+ # OCR断裂文本(大量单字空格分隔)通常不是高质量需求。
+ if re.search(r"(?:[\u4e00-\u9fa5A-Za-z0-9]\s+){5,}", text):
+ return True
+
+ # 短“标题:残句”且无明确需求谓词,通常是截断噪声。
+ modal_hints = ["应", "必须", "需", "shall", "should"]
+ if ":" in text and len(text) < 35 and not any(h in text for h in modal_hints):
+ return True
+
+ # 典型截断尾词。
+ if text.endswith(("与。", "上。", "设。")) and len(text) < 50:
+ return True
+
+ return False
+
+ def _should_suppress_section_requirements(self, section: Section, req_type: str) -> bool:
+ """抑制父级汇总章节提取,优先保留子章节明细。"""
+ if not section.children:
+ return False
+
+ title = section.title or ""
+ child_titles = " ".join((c.title or "") for c in section.children)
+
+ # 接口父章节若存在“接口描述”子章节,则仅保留子章节。
+ if self.settings.is_interface_semantic_title(title) and ("接口描述" in child_titles):
+ return True
+
+ # 功能/性能父章节常见“汇总表”,若有子章节则抑制父级抽取。
+ summary_hints = ["系统功能要求", "功能要求", "性能要求", "汇总", "总表"]
+ if req_type == "functional" and section.children and "功能要求" in title:
+ return True
+
+ if req_type != "interface" and any(h in title for h in ["功能要求", "性能要求", "需求"]):
+ if any(any(h in " ".join(str(c or "") for c in row) for h in summary_hints) for table in section.tables for row in table[:1]):
+ return True
+
+ return False
+
+ def _is_generic_reference_requirement(self, text: str) -> bool:
+ """过滤仅引用其他章节的泛化接口描述。"""
+ t = (text or "").strip()
+ if not t:
+ return True
+ generic_patterns = [
+ r"应满足.*章节",
+ r"应满足本文件",
+ r"应满足.*规定",
+ r"适用于测试类",
+ r"合格性方法",
+ ]
+ return any(re.search(p, t) for p in generic_patterns)
+
+ def _merge_fragment_requirements(self, requirements: List[Requirement]) -> List[Requirement]:
+ """将短碎片需求并入前一条主需求,避免“指令左舷启停”类条目独立存在。"""
+ if not requirements:
+ return requirements
+
+ merged: List[Requirement] = [requirements[0]]
+ for req in requirements[1:]:
+ prev = merged[-1]
+ if prev.section_uid == req.section_uid and prev.type == req.type and self._is_fragment_requirement(req.description):
+ prev.description = self._clean_description(
+ f"{prev.description.rstrip(';;。')};{req.description.lstrip(';;。')}"
+ )
+ else:
+ merged.append(req)
+ return merged
+
+ def _is_fragment_requirement(self, description: str) -> bool:
+ text = (description or "").strip()
+ if not text:
+ return True
+ if len(text) <= 18:
+ return True
+
+ strong_modal = ["应", "必须", "需", "shall", "should", "不得"]
+ if not any(m in text for m in strong_modal):
+ weak_tail = ["启停", "报警", "电流", "电压", "参数", "状态"]
+ if len(text) <= 30 and any(text.rstrip("。").endswith(w) for w in weak_tail):
+ return True
+ return False
+
+ def _sanitize_block_text(self, text: str, current_section_number: str) -> str:
+ """清理正文块并裁剪误混入的其他章节内容。"""
+ cleaned = self._normalize_ocr_spacing(text or "")
+ cleaned = self._trim_text_to_current_section(cleaned, current_section_number)
+ return cleaned.strip()
+
+ def _trim_text_to_current_section(self, text: str, current_section_number: str) -> str:
+ """遇到后续章节标题时截断,避免跨章节污染。"""
+ if not text:
+ return ""
+
+ current = (current_section_number or "").strip()
+ if not current:
+ return text
+
+ current_depth = current.count(".") + 1
+ kept: List[str] = []
+ for raw_line in text.splitlines():
+ line = raw_line.strip()
+ if not line:
+ kept.append(raw_line)
+ continue
+
+ m = re.match(r"^(\d+(?:\.\d+){1,5})\s*[、.))]?\s*(.+)$", line)
+ if m:
+ found_no = m.group(1)
+ found_depth = found_no.count(".") + 1
+ # 命中其他同级/上级或下级章节号时,认为当前章节正文到此结束。
+ if found_no != current and (found_depth <= current_depth or found_no.startswith(f"{current}.")):
+ break
+ kept.append(raw_line)
+
+ return "\n".join(kept)
def _iter_section_blocks(self, section: Section) -> List[Dict[str, Any]]:
"""返回章节中的顺序块(文本/表格)。"""
@@ -256,9 +525,125 @@ class RequirementExtractor:
)
fallback_order += 1
return blocks
+
+ def _classify_table_mode(self, section_title: str, table: List[List[str]], req_type: str) -> str:
+ """
+ 表格模式分类。
+ 返回:skip | single | interface | generic
+ """
+ if not table:
+ return "generic"
+
+ header_text = " ".join(str(c or "") for c in table[0])
+ combined = f"{section_title} {header_text}".lower()
+
+ has_interface_hint = any(k.lower() in combined for k in self.settings.table_interface_keywords)
+ has_skip_hint = any(k.lower() in combined for k in self.settings.table_skip_keywords)
+
+ if has_skip_hint and not has_interface_hint:
+ return "skip"
+
+ if any(k.lower() in combined for k in self.settings.table_single_requirement_keywords):
+ return "single"
+
+ if req_type == "interface":
+ return "interface"
+
+ # 接口表必须满足章节语义优先,避免“计算机通信需求”等章节被关键词误判为接口需求。
+ if has_interface_hint and self.settings.is_interface_semantic_title(section_title):
+ return "interface"
+
+ return "generic"
+
+ def _extract_interface_rows_from_table(self, table: List[List[str]]) -> List[Dict[str, str]]:
+ """从接口表中提取接口名称/类型/来源/目的地字段。"""
+ if not table or len(table) < 2:
+ return []
+
+ header = [self._clean_description(str(c or "")) for c in table[0]]
+
+ def find_col(candidates: List[str]) -> int:
+ for idx, h in enumerate(header):
+ if any(k in h for k in candidates):
+ return idx
+ return -1
+
+ name_idx = find_col(["接口名称", "接口名", "名称"])
+ type_idx = find_col(["接口类型", "类型", "通信类型"])
+ source_idx = find_col(["数据来源", "来源", "发送方", "源"])
+ dst_idx = find_col(["数据目的地", "目的地", "接收方", "去向"])
+
+ rows: List[Dict[str, str]] = []
+ for row in table[1:]:
+ if not row or not any(str(c or "").strip() for c in row):
+ continue
+
+ def pick(col_idx: int) -> str:
+ if col_idx < 0 or col_idx >= len(row):
+ return self.settings.interface_unknown_fallback
+ return self._normalize_interface_field(row[col_idx])
+
+ rows.append(
+ {
+ "interface_name": pick(name_idx),
+ "interface_type": pick(type_idx),
+ "source": pick(source_idx),
+ "destination": pick(dst_idx),
+ }
+ )
+
+ return rows
+
+ def _llm_extract_table_as_single_requirement(self, section: Section, req_type: str) -> List[Requirement]:
+ """硬件/软件/运行环境类表格按“一表一条”提取。"""
+ if not section.tables:
+ return []
+
+ table_text = self._format_tables_for_prompt(section.tables)
+ prompt = f"""请将下列表格合并为一条完整需求。
+
+章节标题:{section.title}
+表格内容:
+{table_text}
+
+要求:
+1. 仅输出1条需求。
+2. 保留关键配置项、数值、阈值、版本信息。
+3. 使用原文措辞,尽量不改写。
+
+输出JSON:
+{{
+ "requirements": [
+ {{"req_id": "可为空", "description": "一条完整需求"}}
+ ]
+}}"""
+
+ response = self.llm.call(prompt)
+ data = self._parse_llm_json_response(response)
+ if not data or not isinstance(data.get("requirements"), list) or not data["requirements"]:
+ return []
+
+ req_data = data["requirements"][0]
+ desc = self._clean_description(req_data.get("description", ""))
+ if not desc:
+ return []
+
+ doc_req_id = self._normalize_req_id(req_data.get("req_id", ""))
+ req_id = self._generate_requirement_id(req_type, section.number, 1, doc_req_id, "")
+ return [
+ Requirement(
+ req_id=req_id,
+ description=desc,
+ req_type=req_type,
+ section_number=section.number,
+ section_title=section.title,
+ section_uid=section.uid,
+ source_type="table",
+ )
+ ]
- def _llm_extract_requirements(self, section: Section, req_type: str) -> List[Requirement]:
- """使用LLM提取需求"""
+ def _llm_extract_requirements(self, section: Section, req_type: str, source_type: str = "text") -> List[Requirement]:
+ """使用LLM提取需求。source_type: text|table"""
requirements = []
content_text = section.content or ""
@@ -266,10 +651,19 @@ class RequirementExtractor:
if len(content_text.strip()) < 8 and not table_text:
return requirements
+ is_text_source = source_type == "text"
+
# 根据需求类型构建不同的提示词
if req_type == 'interface':
- # 接口需求:允许改写润色,并提取接口详细信息
- prompt = f"""请从以下SRS文档章节中提取具体的接口需求,并对需求描述进行改写润色。同时智能识别每个接口的详细信息。
+ if is_text_source:
+ if self.settings.preserve_source_text_for_text_blocks:
+ rewrite_rule = "需求描述必须尽量保持原文句式,不得润色改写,只允许去除换行、编号前缀和明显OCR噪声。"
+ else:
+ rewrite_rule = "可在不改变语义和数值的前提下做轻微整理,使句子完整清晰。"
+ else:
+ rewrite_rule = "可在不改变语义和数值的前提下做轻微整理,使句子完整清晰。"
+
+ prompt = f"""请从以下SRS文档章节中提取具体的接口需求,并智能识别每个接口的详细信息。
章节编号:{section.number}
章节标题:{section.title}
@@ -283,14 +677,16 @@ class RequirementExtractor:
1. 只提取具体的、可验证的接口需求
2. 不要提取系统描述、背景说明等非需求内容
3. 去除原文中的换行符、表格格式噪声
-4. 对提取的需求描述进行改写润色,使其更加清晰完整
+4. {rewrite_rule}
5. 每条需求应该是完整的句子,描述清楚接口规范
6. 如果有多条需求,请分别列出
-7. 对于每条接口需求,请智能识别以下信息:
+6.1 不要输出“a)/b)/1)”等前缀编号
+6.2 不要输出短语碎片(如“左舷艏侧推启停”),应并入主需求句
+7. 对于每条接口需求,请智能识别以下信息(若表格中存在对应列,优先按表格填写):
- interface_name: 接口名称
- interface_type: 接口类型 (如:CAN接口、以太网接口、串口等)
- - source: 来源/发送方(数据或信号从哪里来)
- - destination: 目的地/接收方(数据或信号发送到哪里)
+ - source: 数据来源/发送方(数据或信号从哪里来)
+ - destination: 数据目的地/接收方(数据或信号发送到哪里)
8. 如果某个字段无法从文本中识别,请填写"未知"
9. 若原文给出需求编号,请优先使用原文编号(req_id)
@@ -302,8 +698,8 @@ class RequirementExtractor:
"description": "接口需求描述",
"interface_name": "接口名称",
"interface_type": "接口类型",
- "source": "来源",
- "destination": "目的地"
+ "source": "数据来源",
+ "destination": "数据目的地"
}}
]
}}
@@ -313,8 +709,17 @@ class RequirementExtractor:
JSON输出:"""
else:
- # 功能需求、其他需求:以原文为主,允许轻微扩写补全
- prompt = f"""请从以下SRS文档章节中提取具体的软件需求。以原文为主,允许轻微扩写补全语义。
+ if is_text_source:
+ if self.settings.preserve_source_text_for_text_blocks:
+ source_rule = "需求描述必须保持原文,不得润色改写;仅允许移除换行、表格线噪声和列表编号。"
+ else:
+ source_rule = "需求描述以原文为主,可做轻微重组以形成完整句子。"
+ split_rule = "仅在语义完全独立且原文可明确拆分时拆分,禁止将同一主句拆成碎片。"
+ else:
+ source_rule = "需求描述以原文为主,可做轻微重组以形成完整句子。"
+ split_rule = "仅在语义独立可验证时拆分。"
+
+ prompt = f"""请从以下SRS文档章节中提取具体的软件需求。
章节编号:{section.number}
章节标题:{section.title}
@@ -327,16 +732,18 @@ JSON输出:"""
提取要求:
1. 同时提取正文与表格中的具体、可验证的软件需求
2. 不要提取系统描述、背景说明等非需求内容
-3. 需求描述应保留原文大部分词语(建议保留率>=70%),仅做轻微补充以增强语义完整性
+3. {source_rule}
4. 严禁改变任何数值、阈值、状态名、信号名和逻辑条件
5. 去除原文中的多余换行符和表格格式符号,但保留语句内容
5. 每条需求应该是完整的句子
6. 如果有多条需求,请分别列出
-7. 如果一段需求描述内有多条需求点,必须拆分成多个独立需求项
-8. 拆分判定:出现“并/并且/同时/然后/且/以及”,或一条句子中出现多个动作(如判断+监测+发送)时必须拆分
-9. 每条需求尽量满足“单一动作、可单独验证”
-8. 过滤重复或过于相似的需求,只保留独特的需求
-9. 若原文给出需求编号,请优先使用原文编号(req_id)
+6.1 去除“a)/b)/1)”等编号前缀
+7. 如果一段需求描述内有多条需求点,按规则判断是否拆分
+8. 拆分判定:{split_rule} 条件-动作链、并列限定语、因果承接不应强拆
+8.1 对“具体包括/其中包括”后的短项,不要单独成条,必须并入主句
+9. 优先保持需求语义完整,避免过度拆分导致碎片化
+10. 过滤重复或过于相似的需求,只保留独特的需求
+11. 若原文给出需求编号,请优先使用原文编号(req_id)
请以JSON格式输出,格式如下:
{{
@@ -401,10 +808,18 @@ JSON输出:"""
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()
+ interface_name = self._normalize_interface_field(
+ req_data.get('interface_name', self.settings.interface_unknown_fallback)
+ )
+ interface_type = self._normalize_interface_field(
+ req_data.get('interface_type', self.settings.interface_unknown_fallback)
+ )
+ source = self._normalize_interface_field(
+ req_data.get('source', self.settings.interface_unknown_fallback)
+ )
+ destination = self._normalize_interface_field(
+ req_data.get('destination', self.settings.interface_unknown_fallback)
+ )
req = Requirement(
req_id=req_id,
@@ -420,59 +835,23 @@ JSON输出:"""
)
requirements.append(req)
except Exception as e:
- logger.warning(f"LLM提取需求失败: {e},使用规则提取")
- return self._rule_extract_requirements(section, req_type)
+ raise RuntimeError(
+ f"LLM提取需求失败: section={section.number} {section.title}, error={e}"
+ ) from e
return requirements
- def _build_table_requirements_rule(self, section: Section, req_type: str, start_index: int) -> List[Requirement]:
- """仅从表格构建规则需求,用于LLM模式补充召回。"""
- requirements: List[Requirement] = []
- table_requirements = self._extract_requirements_from_tables_rule(section.tables)
- if not table_requirements:
- return requirements
-
- parent_req_id = ""
- complete_id_pattern = r'^[A-Za-z0-9]{2,10}[-_].+$'
- 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 = start_index
- for doc_req_id, desc in table_requirements:
- split_descs = self._split_requirement_description(desc)
- if not split_descs:
- split_descs = [desc]
-
- for split_idx, split_desc in enumerate(split_descs, 1):
- req_id = self._generate_requirement_id(
- req_type=req_type,
- section_number=section.number,
- index=index,
- doc_req_id=doc_req_id,
- parent_req_id=parent_req_id,
- split_index=split_idx,
- split_total=len(split_descs),
- )
- requirements.append(
- Requirement(
- req_id=req_id,
- description=split_desc,
- req_type=req_type,
- section_number=section.number,
- section_title=section.title,
- section_uid=section.uid,
- )
- )
- index += 1
-
- return requirements
-
- def _llm_extract_table_requirements(self, section: Section, req_type: str) -> List[Requirement]:
+ def _llm_extract_table_requirements(
+ self,
+ section: Section,
+ req_type: str,
+ interface_rows: Optional[List[Dict[str, str]]] = None,
+ ) -> List[Requirement]:
"""使用LLM语义化提取表格需求。"""
- if not self.llm or not section.tables:
- return self._rule_extract_requirements(section, req_type)
+ if not self.llm:
+ raise RuntimeError("LLM实例未初始化,当前版本仅支持LLM提取")
+ if not section.tables:
+ return []
table = section.tables[0]
is_sequence_table = self._is_time_series_table(table)
@@ -514,6 +893,11 @@ JSON输出:"""
continue
doc_req_id = self._normalize_req_id(req_data.get("req_id", ""))
req_id = self._generate_requirement_id(req_type, section.number, i, doc_req_id, "")
+
+ row_info = {}
+ if req_type == "interface" and interface_rows and i - 1 < len(interface_rows):
+ row_info = interface_rows[i - 1]
+
requirements.append(
Requirement(
req_id=req_id,
@@ -522,26 +906,75 @@ JSON输出:"""
section_number=section.number,
section_title=section.title,
section_uid=section.uid,
+ interface_name=row_info.get("interface_name", self.settings.interface_unknown_fallback) if req_type == 'interface' else "",
+ interface_type=row_info.get("interface_type", self.settings.interface_unknown_fallback) if req_type == 'interface' else "",
+ source=row_info.get("source", self.settings.interface_unknown_fallback) if req_type == 'interface' else "",
+ destination=row_info.get("destination", self.settings.interface_unknown_fallback) if req_type == 'interface' else "",
source_type="table",
)
)
+ # 小型表格兜底:若LLM漏行,则将剩余有效行补齐为需求,避免“6行只提4行”。
+ expected_rows = [
+ [self._clean_description(str(c or "")) for c in row]
+ for row in (table[1:] if len(table) > 1 else table)
+ if row and any(str(c or "").strip() for c in row)
+ ]
+ if req_type in {"functional", "other"} and expected_rows and len(expected_rows) <= 12 and len(requirements) < len(expected_rows):
+ next_index = len(requirements) + 1
+ for row in expected_rows[len(requirements):]:
+ row_desc = self._clean_description(",".join([c for c in row if c]))
+ if not row_desc or len(row_desc) < 6:
+ continue
+
+ # 避免补齐时引入明显重复。
+ duplicated = any(
+ SequenceMatcher(None, self._normalize_text_for_dedup(row_desc), self._normalize_text_for_dedup(r.description)).ratio() >= 0.92
+ for r in requirements
+ )
+ if duplicated:
+ continue
+
+ req_id = self._generate_requirement_id(req_type, section.number, next_index, "", "")
+ requirements.append(
+ Requirement(
+ req_id=req_id,
+ description=row_desc,
+ req_type=req_type,
+ section_number=section.number,
+ section_title=section.title,
+ section_uid=section.uid,
+ source_type="table",
+ )
+ )
+ next_index += 1
+
if not requirements:
- return self._rule_extract_requirements(section, req_type)
+ logger.warning(
+ "LLM表格提取未产出需求: section=%s %s",
+ section.number,
+ section.title,
+ )
+ return []
return requirements
except Exception as e:
- logger.warning(f"LLM表格语义化提取失败,回退规则模式: {e}")
- return self._rule_extract_requirements(section, req_type)
+ raise RuntimeError(
+ f"LLM表格语义化提取失败: section={section.number} {section.title}, error={e}"
+ ) from e
- def _maybe_light_rewrite(self, description: str, source_type: str) -> str:
+ def _maybe_light_rewrite(self, description: str, source_type: str, req_type: str) -> str:
"""仅在LLM模式做轻微扩写,且通过保真校验。"""
description = self._clean_description(description)
if not description:
return description
+ if req_type != "interface":
+ return description
+ if source_type != "table":
+ return description
if not self.llm or not self.settings.llm_light_rewrite_enabled:
return description
- need_rewrite = source_type == "table" or len(description) < 28
+ need_rewrite = len(description) < 28
if not need_rewrite:
return description
@@ -572,6 +1005,33 @@ JSON输出:"""
except Exception:
return description
+ def _edit_distance(self, a: str, b: str) -> int:
+ """计算字符串编辑距离(Levenshtein)。"""
+ if a == b:
+ return 0
+ if not a:
+ return len(b)
+ if not b:
+ return len(a)
+
+ n, m = len(a), len(b)
+ dp = [[0] * (m + 1) for _ in range(n + 1)]
+ for i in range(n + 1):
+ dp[i][0] = i
+ for j in range(m + 1):
+ dp[0][j] = j
+
+ for i in range(1, n + 1):
+ for j in range(1, m + 1):
+ cost = 0 if a[i - 1] == b[j - 1] else 1
+ dp[i][j] = min(
+ dp[i - 1][j] + 1,
+ dp[i][j - 1] + 1,
+ dp[i - 1][j - 1] + cost,
+ )
+
+ return dp[n][m]
+
def _calculate_preserve_ratio(self, original: str, rewritten: str) -> float:
original_tokens = [c for c in re.sub(r"\s+", "", original) if c]
rewritten_tokens = set(c for c in re.sub(r"\s+", "", rewritten) if c)
@@ -642,121 +1102,6 @@ JSON输出:"""
return ordered
- 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 and not section.tables:
- 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)
- split_descs = self._split_requirement_description(cleaned_desc)
- if not split_descs:
- split_descs = [cleaned_desc]
-
- for split_idx, split_desc in enumerate(split_descs, 1):
- req_id = self._generate_requirement_id(
- req_type,
- section.number,
- index,
- doc_req_id,
- parent_req_id,
- split_idx,
- len(split_descs),
- )
- req = Requirement(
- req_id=req_id,
- description=split_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:
- split_descs = self._split_requirement_description(desc)
- if not split_descs:
- split_descs = [desc]
-
- for split_idx, split_desc in enumerate(split_descs, 1):
- req_id = self._generate_requirement_id(
- req_type,
- section.number,
- index,
- doc_req_id,
- parent_req_id,
- split_idx,
- len(split_descs),
- )
- req = Requirement(
- req_id=req_id,
- description=split_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:
"""
通过标题和内容识别需求类型
@@ -803,22 +1148,93 @@ JSON输出:"""
def _normalize_req_id(self, req_id: str) -> str:
"""规范化需求编号"""
return self.id_generator.normalize(req_id)
+
+ def _normalize_ocr_spacing(self, text: str) -> str:
+ """归一化OCR导致的词内断裂空格。"""
+ normalized = str(text or "")
+ if not getattr(self.settings, "ocr_spacing_normalize", True):
+ return normalized
+
+ normalized = normalized.replace("\u3000", " ").replace("\xa0", " ")
+ normalized = re.sub(r"(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])", "", normalized)
+ normalized = re.sub(r"(?<=[\u4e00-\u9fff])\s+(?=[A-Za-z0-9])", "", normalized)
+ normalized = re.sub(r"(?<=[A-Za-z0-9])\s+(?=[\u4e00-\u9fff])", "", normalized)
+ return normalized
def _clean_description(self, text: str) -> str:
"""清理需求描述"""
+ text = self._normalize_ocr_spacing(text)
# 替换换行符为空格
text = re.sub(r'\n+', ' ', text)
# 替换多个空格为单个空格
text = re.sub(r'\s+', ' ', text)
# 去除表格噪声
text = re.sub(r'[\|│┃]+', ' ', text)
+ # 去除前缀枚举编号(如a)/b)/1)/①))但保留正文语义。
+ text = self._strip_leading_enumeration(text)
+ text = self._normalize_ocr_spacing(text)
# 去除首尾空白
text = text.strip()
+ text = text.rstrip(";;,,")
# 限制长度
if len(text) > 1000:
text = text[:1000] + '...'
+ if self.settings.ensure_terminal_period:
+ text = self._ensure_terminal_period(text)
return text
+ def _strip_leading_enumeration(self, text: str) -> str:
+ cleaned = (text or "").strip()
+ patterns = [
+ r"^\d+\s*(?=[A-Za-z][\))])",
+ r"^(?:[A-Za-z]|\d+|[①②③④⑤⑥⑦⑧⑨⑩]|[一二三四五六七八九十]+)\s*[\))、:]\s*",
+ r"^(?:[A-Za-z]|\d+|[①②③④⑤⑥⑦⑧⑨⑩]|[一二三四五六七八九十]+)\s*\.\s*(?!\d)",
+ ]
+ for p in patterns:
+ cleaned = re.sub(p, "", cleaned)
+ return cleaned
+
+ def _ensure_terminal_period(self, text: str) -> str:
+ if not text:
+ return text
+ if text.endswith(("。", "!", "?")):
+ return text
+ return text + "。"
+
+ def _normalize_interface_field(self, value: Any) -> str:
+ text = self._normalize_ocr_spacing(str(value or "")).strip()
+ if not text:
+ return self.settings.interface_unknown_fallback
+ if text in {"N/A", "NA", "None", "null", "NULL", "-", "--", "未知"}:
+ return self.settings.interface_unknown_fallback
+ text = text.rstrip(";;,,。")
+ return text if text else self.settings.interface_unknown_fallback
+
+ def _snap_to_source_sentence(self, description: str, source_text: str) -> str:
+ """将非接口需求尽量贴合回原文句子。"""
+ source_text = self._normalize_ocr_spacing(source_text or "").strip()
+ if not description or not source_text:
+ return description
+
+ if description in source_text:
+ return description
+
+ normalized_desc = self._normalize_ocr_spacing(description)
+
+ candidates = [
+ self._clean_description(x)
+ for x in re.split(r"[\n。;;]", source_text)
+ if self._clean_description(x)
+ ]
+ if not candidates:
+ return description
+
+ best = min(candidates, key=lambda c: self._edit_distance(normalized_desc, self._normalize_ocr_spacing(c)))
+ dist = self._edit_distance(normalized_desc, self._normalize_ocr_spacing(best))
+ if dist <= self.settings.non_interface_max_edit_distance:
+ return best
+ return description
+
def _format_tables_for_prompt(self, tables: List[List[List[str]]]) -> str:
"""格式化表格内容用于LLM提示词"""
if not tables:
@@ -845,6 +1261,8 @@ JSON输出:"""
def _split_requirement_description(self, text: str) -> List[str]:
if not text:
return []
+ if any(h in text for h in ["具体包括", "其中包括", "包括但不限于", "主要包括"]):
+ return [text]
if "时间序列" in text and "执行指令" in text:
return [text]
if not self.splitter:
@@ -852,72 +1270,266 @@ JSON输出:"""
return self.splitter.split(text)
def _deduplicate_requirements(self, requirements: List[Requirement]) -> List[Requirement]:
- seen = set()
deduped: List[Requirement] = []
+ threshold = self.settings.dedup_similarity_threshold
for req in requirements:
- normalized_desc = re.sub(r'\s+', ' ', req.description).strip().lower()
- key = (req.type, normalized_desc)
- if key in seen:
- continue
- seen.add(key)
- deduped.append(req)
+ normalized_desc = self._normalize_text_for_dedup(req.description)
+ drop = False
+ for kept in deduped:
+ if kept.type != req.type:
+ continue
+
+ # 接口需求优先按结构化键去重(同章节同接口同源同目的)。
+ if req.type == "interface" and kept.section_uid == req.section_uid:
+ if self._interface_dedup_key(kept) == self._interface_dedup_key(req):
+ # 优先保留字段更完整的条目。
+ if self._interface_field_completeness(req) > self._interface_field_completeness(kept):
+ kept.interface_name = req.interface_name
+ kept.interface_type = req.interface_type
+ kept.source = req.source
+ kept.destination = req.destination
+ kept.description = req.description
+ kept.source_type = req.source_type
+ kept.source_order = req.source_order
+ drop = True
+ break
+
+ # 同一来源与目的地的接口需求若语义高度重合,保留更完整一条。
+ kept_src = self._normalize_text_for_dedup(kept.source)
+ req_src = self._normalize_text_for_dedup(req.source)
+ kept_dst = self._normalize_text_for_dedup(kept.destination)
+ req_dst = self._normalize_text_for_dedup(req.destination)
+ unknown = self._normalize_text_for_dedup(self.settings.interface_unknown_fallback)
+ if kept_src and req_src and kept_dst and req_dst and kept_src == req_src and kept_dst == req_dst:
+ if kept_src != unknown and kept_dst != unknown:
+ name_sim = SequenceMatcher(
+ None,
+ self._normalize_text_for_dedup(kept.interface_name),
+ self._normalize_text_for_dedup(req.interface_name),
+ ).ratio()
+ sim_sd = SequenceMatcher(
+ None,
+ normalized_desc,
+ self._normalize_text_for_dedup(kept.description),
+ ).ratio()
+ if sim_sd >= 0.72 or name_sim >= 0.72:
+ if self._interface_field_completeness(req) >= self._interface_field_completeness(kept):
+ kept.interface_name = req.interface_name
+ kept.interface_type = req.interface_type
+ kept.source = req.source
+ kept.destination = req.destination
+ if len(req.description) >= len(kept.description):
+ kept.description = req.description
+ kept.source_type = req.source_type
+ kept.source_order = min(kept.source_order, req.source_order)
+ drop = True
+ break
+
+ sim = SequenceMatcher(
+ None,
+ normalized_desc,
+ self._normalize_text_for_dedup(kept.description),
+ ).ratio()
+ if sim >= threshold:
+ # 同章节优先保留正文块。
+ if (
+ self.settings.prefer_text_over_table
+ and kept.section_uid == req.section_uid
+ and kept.source_type == "table"
+ and req.source_type == "text"
+ ):
+ kept.description = req.description
+ kept.source_type = req.source_type
+ kept.source_order = req.source_order
+ drop = True
+ break
+ if not drop:
+ deduped.append(req)
return deduped
- 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:
+ def _interface_dedup_key(self, req: Requirement) -> Tuple[str, str, str, str]:
+ return (
+ self._normalize_text_for_dedup(req.interface_name),
+ self._normalize_text_for_dedup(req.interface_type),
+ self._normalize_text_for_dedup(req.source),
+ self._normalize_text_for_dedup(req.destination),
+ )
+
+ def _interface_field_completeness(self, req: Requirement) -> int:
+ fields = [req.interface_name, req.interface_type, req.source, req.destination]
+ score = 0
+ for f in fields:
+ value = (f or "").strip()
+ if value and value != self.settings.interface_unknown_fallback:
+ score += 1
+ return score
+
+ def _drop_inferior_interface_duplicates(self, requirements: List[Requirement]) -> List[Requirement]:
+ """同章节同接口名/来源/目的地重复时,保留字段更完整条目。"""
+ if not requirements:
+ return requirements
+
+ grouped: Dict[Tuple[str, str, str, str], List[Requirement]] = {}
+ others: List[Requirement] = []
+ for req in requirements:
+ if req.type != "interface":
+ others.append(req)
continue
- if self._is_time_series_table(table) and self.settings.sequence_table_merge == "single_requirement":
- merged_desc = self._build_sequence_table_requirement(table)
- if merged_desc:
- results.append((None, merged_desc))
+ key = (
+ req.section_uid or req.section_number or "",
+ self._normalize_text_for_dedup(req.interface_name),
+ self._normalize_text_for_dedup(req.source),
+ self._normalize_text_for_dedup(req.destination),
+ )
+ grouped.setdefault(key, []).append(req)
+
+ kept_interfaces: List[Requirement] = []
+ for group in grouped.values():
+ if len(group) == 1:
+ kept_interfaces.append(group[0])
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:
+ best = group[0]
+ for cand in group[1:]:
+ best_score = self._interface_field_completeness(best)
+ cand_score = self._interface_field_completeness(cand)
+ if cand_score > best_score:
+ best = cand
continue
- row = [self._clean_description(cell) for cell in row]
- if not any(row):
+ if cand_score == best_score and len(cand.description) > len(best.description):
+ best = cand
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
+ if cand_score == best_score and len(cand.description) == len(best.description):
+ if cand.source_order < best.source_order:
+ best = cand
+ kept_interfaces.append(best)
+
+ # 第二阶段:同章节同来源/目的地下,删除与“已知类型”高度相似的“未知类型”重复项。
+ by_section_src_dst: Dict[Tuple[str, str, str], List[Requirement]] = {}
+ for req in kept_interfaces:
+ key = (
+ req.section_uid or req.section_number or "",
+ self._normalize_text_for_dedup(req.source),
+ self._normalize_text_for_dedup(req.destination),
+ )
+ by_section_src_dst.setdefault(key, []).append(req)
+
+ pruned_interfaces: List[Requirement] = []
+ unknown_norm = self._normalize_text_for_dedup(self.settings.interface_unknown_fallback)
+ for key, group in by_section_src_dst.items():
+ known = [
+ r for r in group
+ if self._normalize_text_for_dedup(r.interface_type) != unknown_norm
+ ]
+ if not known:
+ pruned_interfaces.extend(group)
+ continue
+
+ for cand in group:
+ cand_type = self._normalize_text_for_dedup(cand.interface_type)
+ if cand_type != unknown_norm:
+ pruned_interfaces.append(cand)
+ continue
+
+ should_drop = False
+ for k in known:
+ name_sim = SequenceMatcher(
+ None,
+ self._normalize_text_for_dedup(cand.interface_name),
+ self._normalize_text_for_dedup(k.interface_name),
+ ).ratio()
+ desc_sim = SequenceMatcher(
+ None,
+ self._normalize_text_for_dedup(cand.description),
+ self._normalize_text_for_dedup(k.description),
+ ).ratio()
+ if name_sim >= 0.60 or desc_sim >= 0.60:
+ should_drop = True
+ break
+
+ if not should_drop:
+ pruned_interfaces.append(cand)
+
+ merged = others + pruned_interfaces
+ return sorted(merged, key=lambda r: (r.source_order, r.section_number or ""))
+
+ def _normalize_text_for_dedup(self, text: str) -> str:
+ normalized = self._normalize_ocr_spacing(text or "").strip().lower()
+ normalized = normalized.translate(str.maketrans({
+ ",": ",",
+ ";": ";",
+ ":": ":",
+ "(": "(",
+ ")": ")",
+ "。": ".",
+ }))
+ normalized = re.sub(r"\s+", " ", normalized)
+ return normalized
+
+ def _global_deduplicate_requirements(self, requirements: List[Requirement]) -> List[Requirement]:
+ deduped: List[Requirement] = []
+ threshold = self.settings.dedup_similarity_threshold
+ for req in requirements:
+ current_text = self._normalize_text_for_dedup(req.description)
+ duplicate_idx = -1
+ for i, kept in enumerate(deduped):
+ if kept.type != req.type:
+ continue
+ sim = SequenceMatcher(
+ None,
+ current_text,
+ self._normalize_text_for_dedup(kept.description),
+ ).ratio()
+ if sim >= threshold:
+ duplicate_idx = i
+ break
+
+ if duplicate_idx < 0:
+ deduped.append(req)
+ continue
+
+ kept = deduped[duplicate_idx]
+ if self._prefer_requirement_for_dedup(kept, req):
+ deduped[duplicate_idx] = req
+
+ return sorted(deduped, key=lambda r: (r.source_order, r.section_number or ""))
+
+ def _section_depth(self, section_number: str) -> int:
+ sn = (section_number or "").strip()
+ if not sn:
+ return 0
+ return sn.count(".") + 1
+
+ def _prefer_requirement_for_dedup(self, kept: Requirement, candidate: Requirement) -> bool:
+ # 同章节优先保留正文。
+ if (
+ self.settings.prefer_text_over_table
+ and kept.section_uid == candidate.section_uid
+ and kept.source_type == "table"
+ and candidate.source_type == "text"
+ ):
+ return True
+
+ # 优先保留更深层级章节(子章节)需求,抑制父级汇总重复。
+ kept_depth = self._section_depth(kept.section_number)
+ cand_depth = self._section_depth(candidate.section_number)
+ if cand_depth > kept_depth:
+ return True
+ if cand_depth < kept_depth:
+ return False
+
+ # 接口需求中,优先保留非“泛引用”条目。
+ if kept.type == "interface":
+ kept_generic = self._is_generic_reference_requirement(kept.description)
+ cand_generic = self._is_generic_reference_requirement(candidate.description)
+ if kept_generic and not cand_generic:
+ return True
+ if not kept_generic and cand_generic:
+ return False
+
+ # 同层级时保留更早出现的原文顺序。
+ return candidate.source_order < kept.source_order
def _is_time_series_table(self, table: List[List[str]]) -> bool:
if not table:
@@ -939,38 +1551,6 @@ JSON输出:"""
return (header_has_time and header_has_action) or (time_like_rows >= self.settings.merge_time_series_rows_min)
- def _build_sequence_table_requirement(self, table: List[List[str]]) -> str:
- if not table or len(table) < 2:
- return ""
-
- header = table[0]
- time_idx = 0
- action_idx = 1 if len(header) > 1 else 0
- for i, col in enumerate(header):
- col_text = (col or "")
- if any(k in col_text for k in ["时间", "时刻", "time", "TIME"]):
- time_idx = i
- if any(k in col_text for k in ["指令", "动作", "行为", "操作", "名称"]):
- action_idx = i
-
- sequence_parts = []
- for row in table[1:]:
- if not row:
- continue
- row = [self._clean_description(c) for c in row]
- if not any(row):
- continue
- t = row[time_idx] if time_idx < len(row) else ""
- a = row[action_idx] if action_idx < len(row) else ""
- if t and a:
- sequence_parts.append(f"{t}执行{a}")
- elif a:
- sequence_parts.append(a)
-
- if not sequence_parts:
- return ""
- return "系统应按以下时间序列依次执行指令:" + ";".join(sequence_parts)
-
def _parse_llm_json_response(self, response: str) -> Optional[Dict]:
"""解析LLM的JSON响应"""
try:
diff --git a/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/requirement_splitter.py b/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/requirement_splitter.py
index b062082..1b80292 100644
--- a/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/requirement_splitter.py
+++ b/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/requirement_splitter.py
@@ -33,8 +33,10 @@ class RequirementSplitter:
CONNECTOR_HINTS = ["并", "并且", "同时", "然后", "且", "以及", "及"]
CONDITIONAL_HINTS = ["如果", "当", "若", "在", "其中", "此时", "满足"]
CONTEXT_PRONOUN_HINTS = ["该", "其", "上述", "此", "这些", "那些"]
+ CHAIN_HINTS = ["从而", "以便", "用于", "以实现", "并据此", "进而", "从而实现"]
+ ENUMERATION_HINTS = ["具体包括", "包括但不限于", "主要包括", "其中包括", "如下"]
- def __init__(self, max_sentence_len: int = 120, min_clause_len: int = 12):
+ def __init__(self, max_sentence_len: int = 160, min_clause_len: int = 20):
self.max_sentence_len = max_sentence_len
self.min_clause_len = min_clause_len
@@ -107,6 +109,14 @@ class RequirementSplitter:
if len(current) < self.min_clause_len:
return False
+ # “具体包括/其中包括”后的列举项通常是上一句延伸,不应拆分为独立需求。
+ if any(h in current for h in self.ENUMERATION_HINTS):
+ return False
+
+ # 承接链条短语一般不是独立需求动作,避免切断语义链。
+ if any(fragment.startswith(h) for h in self.CHAIN_HINTS):
+ return False
+
# 指代承接片段通常是语义延续,不应切断。
if any(fragment.startswith(h) for h in self.CONTEXT_PRONOUN_HINTS):
return False
@@ -123,6 +133,12 @@ class RequirementSplitter:
has_action = any(h in fragment for h in self.ACTION_HINTS)
current_has_action = any(h in current for h in self.ACTION_HINTS)
+ # 并列连接词后接“控制/处理/显示”等限定短语时,优先视为同一需求。
+ if has_connector and len(fragment) < self.max_sentence_len // 3 and not any(
+ kw in fragment for kw in ["并输出", "并上传", "并记录", "并触发"]
+ ):
+ return False
+
# 连接词 + 动作词,且当前片段已经包含动作,优先拆分。
if has_connector and has_action and current_has_action:
return True
@@ -147,6 +163,9 @@ class RequirementSplitter:
return merged
def _should_merge(self, prev: str, current: str) -> bool:
+ if any(h in prev for h in self.ENUMERATION_HINTS):
+ return True
+
# 指代开头:如“该报警信号...”。
if any(current.startswith(h) for h in self.CONTEXT_PRONOUN_HINTS):
return True
diff --git a/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/settings.py b/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/settings.py
index 55e7fc0..abe3adc 100644
--- a/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/settings.py
+++ b/rag-web-ui/backend/app/tools/srs_reqs_qwen/src/settings.py
@@ -60,6 +60,46 @@ class AppSettings:
"other": "OR",
}
+ DEFAULT_INTERFACE_SECTION_HINTS = [
+ "接口描述",
+ "接口需求",
+ "接口要求",
+ "外部接口",
+ "内部接口",
+ "i/o",
+ ]
+
+ DEFAULT_INTERFACE_TITLE_EXCLUDES = [
+ "计算机通信需求",
+ "通信需求",
+ "通信要求",
+ ]
+
+ DEFAULT_FUNCTIONAL_SECTION_HINTS = [
+ "功能需求",
+ "功能要求",
+ ]
+
+ DEFAULT_OTHER_SECTION_HINTS = [
+ "安全性需求",
+ "保密性需求",
+ "适应性需求",
+ "环境需求",
+ "资源需求",
+ "质量",
+ "设计约束",
+ "培训需求",
+ "软件保障",
+ "验收",
+ "交付",
+ "包装",
+ "通信需求",
+ "计算机通信需求",
+ "硬件环境",
+ "软件环境",
+ "运行环境",
+ ]
+
def __init__(self, config: Dict[str, Any] = None):
self.config = config or {}
@@ -75,6 +115,20 @@ class AppSettings:
self.type_prefix = self._build_type_prefix(req_types_cfg)
self.type_chinese = self._build_type_chinese(req_types_cfg)
+ semantic_type_cfg = extraction_cfg.get("semantic_type_policy", {})
+ self.interface_section_hints = [
+ str(x).lower() for x in semantic_type_cfg.get("interface_section_hints", self.DEFAULT_INTERFACE_SECTION_HINTS)
+ ]
+ self.interface_title_excludes = [
+ str(x).lower() for x in semantic_type_cfg.get("interface_title_excludes", self.DEFAULT_INTERFACE_TITLE_EXCLUDES)
+ ]
+ self.functional_section_hints = [
+ str(x).lower() for x in semantic_type_cfg.get("functional_section_hints", self.DEFAULT_FUNCTIONAL_SECTION_HINTS)
+ ]
+ self.other_section_hints = [
+ str(x).lower() for x in semantic_type_cfg.get("other_section_hints", self.DEFAULT_OTHER_SECTION_HINTS)
+ ]
+
splitter_cfg = extraction_cfg.get("splitter", {})
self.splitter_max_sentence_len = int(splitter_cfg.get("max_sentence_len", 120))
self.splitter_min_clause_len = int(splitter_cfg.get("min_clause_len", 12))
@@ -91,16 +145,61 @@ class AppSettings:
self.table_llm_semantic_enabled = bool(table_cfg.get("llm_semantic_enabled", True))
self.sequence_table_merge = table_cfg.get("sequence_table_merge", "single_requirement")
self.merge_time_series_rows_min = int(table_cfg.get("merge_time_series_rows_min", 3))
+ self.table_skip_keywords = list(
+ table_cfg.get(
+ "skip_keywords",
+ ["系统功能要求", "性能要求", "功能矩阵", "能力对照", "性能指标对照"],
+ )
+ )
+ self.table_interface_keywords = list(
+ table_cfg.get(
+ "interface_keywords",
+ ["接口", "interface", "输入输出", "I/O", "数据来源", "数据目的地", "来源", "目的地"],
+ )
+ )
+ self.table_single_requirement_keywords = list(
+ table_cfg.get(
+ "single_requirement_keywords",
+ ["硬件要求", "软件要求", "运行环境", "环境需求", "资源需求", "计算机资源"],
+ )
+ )
rewrite_cfg = extraction_cfg.get("rewrite_policy", {})
self.llm_light_rewrite_enabled = bool(rewrite_cfg.get("llm_light_rewrite_enabled", True))
self.preserve_ratio_min = float(rewrite_cfg.get("preserve_ratio_min", 0.65))
self.max_length_growth_ratio = float(rewrite_cfg.get("max_length_growth_ratio", 1.25))
+ self.non_interface_max_edit_distance = int(rewrite_cfg.get("non_interface_max_edit_distance", 20))
+
+ self.system_description_hints = list(
+ extraction_cfg.get(
+ "system_description_hints",
+ ["系统描述", "功能描述", "概述", "示意图", "组成", "架构", "原理"],
+ )
+ )
renumber_cfg = extraction_cfg.get("renumber_policy", {})
self.renumber_enabled = bool(renumber_cfg.get("enabled", True))
self.renumber_mode = renumber_cfg.get("mode", "section_continuous")
+ dedup_cfg = extraction_cfg.get("dedup_policy", {})
+ self.dedup_similarity_threshold = float(dedup_cfg.get("similarity_threshold", 0.88))
+ self.enable_cross_section_dedup = bool(dedup_cfg.get("enable_cross_section_dedup", True))
+ self.prefer_text_over_table = bool(dedup_cfg.get("prefer_text_over_table", True))
+
+ interface_cfg = extraction_cfg.get("interface_policy", {})
+ self.interface_unknown_fallback = str(interface_cfg.get("unknown_fallback", "未知"))
+
+ normalization_cfg = extraction_cfg.get("normalization_policy", {})
+ self.ocr_spacing_normalize = bool(normalization_cfg.get("ocr_spacing_normalize", True))
+
+ fidelity_cfg = extraction_cfg.get("fidelity_policy", {})
+ self.preserve_source_text_for_text_blocks = bool(
+ fidelity_cfg.get("preserve_source_text_for_text_blocks", True)
+ )
+
+ punctuation_cfg = extraction_cfg.get("punctuation_policy", {})
+ self.ensure_terminal_period = bool(punctuation_cfg.get("ensure_terminal_period", True))
+
def _build_rules(self, req_types_cfg: Dict[str, Dict[str, Any]]) -> List[RequirementTypeRule]:
rules: List[RequirementTypeRule] = []
if not req_types_cfg:
@@ -153,10 +252,45 @@ class AppSettings:
def is_non_requirement_section(self, title: str) -> bool:
return any(keyword in title for keyword in self.non_requirement_sections)
+ def is_interface_semantic_title(self, title: str) -> bool:
+ t = (title or "").strip().lower()
+ if not t:
+ return False
+
+ excluded = any(x in t for x in self.interface_title_excludes)
+ if excluded and "接口" not in t:
+ return False
+
+ return any(h in t for h in self.interface_section_hints)
+
+ def is_functional_semantic_title(self, title: str) -> bool:
+ t = (title or "").strip().lower()
+ if not t:
+ return False
+ return any(h in t for h in self.functional_section_hints)
+
+ def is_other_semantic_title(self, title: str) -> bool:
+ t = (title or "").strip().lower()
+ if not t:
+ return False
+ return any(h in t for h in self.other_section_hints)
+
def detect_requirement_type(self, title: str, content: str) -> str:
+ # 章节语义优先:接口仅由接口类章节触发;安全/保密/适应性等统一归其他需求。
+ if self.is_interface_semantic_title(title):
+ return "interface"
+ if self.is_functional_semantic_title(title):
+ return "functional"
+ if self.is_other_semantic_title(title):
+ return "other"
+
combined_text = f"{title} {(content or '')[:500]}".lower()
for rule in self.requirement_rules:
+ if rule.key == "interface" and not self.is_interface_semantic_title(title):
+ continue
for keyword in rule.keywords:
if keyword.lower() in combined_text:
+ if rule.key in {"performance", "security", "reliability", "other"}:
+ return "other"
return rule.key
return "functional"
diff --git a/rag-web-ui/backend/app/tools/srs_reqs_qwen/tool.py b/rag-web-ui/backend/app/tools/srs_reqs_qwen/tool.py
index 70acaf7..c5cdb04 100644
--- a/rag-web-ui/backend/app/tools/srs_reqs_qwen/tool.py
+++ b/rag-web-ui/backend/app/tools/srs_reqs_qwen/tool.py
@@ -55,6 +55,9 @@ class SRSTool:
ToolRegistry.register(self.DEFINITION)
def run(self, file_path: str, enable_llm: bool = True) -> Dict[str, Any]:
+ if not enable_llm:
+ raise ValueError("当前版本仅支持LLM模式,请将 enable_llm 设为 true")
+
config = self._load_config()
llm = self._build_llm(config, enable_llm=enable_llm)
@@ -122,12 +125,12 @@ class SRSTool:
def _build_llm(self, config: Dict[str, Any], enable_llm: bool) -> QwenLLM | None:
if not enable_llm:
- return None
+ raise ValueError("当前版本仅支持LLM模式")
llm_cfg = config.get("llm", {})
api_key = llm_cfg.get("api_key")
if not api_key:
- return None
+ raise ValueError("未配置API密钥:请设置 DASH_SCOPE_API_KEY 或 DASHSCOPE_API_KEY")
return QwenLLM(
api_key=api_key,
diff --git a/rag-web-ui/backend/requirements.txt b/rag-web-ui/backend/requirements.txt
index e1ef6b7..736f889 100644
--- a/rag-web-ui/backend/requirements.txt
+++ b/rag-web-ui/backend/requirements.txt
@@ -22,6 +22,7 @@ unstructured[md]>=0.10.0
openai>=1.30.0
email-validator
dashscope>=1.13.6
+requests>=2.31.0
langchain-deepseek==0.1.1
langchain-ollama==0.2.3
docx2txt==0.8
diff --git a/rag-web-ui/frontend/src/app/dashboard/knowledge/page.tsx b/rag-web-ui/frontend/src/app/dashboard/knowledge/page.tsx
index ee65f2f..5f9559c 100644
--- a/rag-web-ui/frontend/src/app/dashboard/knowledge/page.tsx
+++ b/rag-web-ui/frontend/src/app/dashboard/knowledge/page.tsx
@@ -3,7 +3,14 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { FileIcon, defaultStyles } from "react-file-icon";
-import { ArrowRight, Plus, Settings, Trash2, Search } from "lucide-react";
+import {
+ ArrowRight,
+ ChevronRight,
+ Plus,
+ Search,
+ Settings,
+ Trash2,
+} from "lucide-react";
import DashboardLayout from "@/components/layout/dashboard-layout";
import { api, ApiError } from "@/lib/api";
import { useToast } from "@/components/ui/use-toast";
@@ -29,6 +36,9 @@ interface Document {
export default function KnowledgeBasePage() {
const [knowledgeBases, setKnowledgeBases] = useState{kb.name}
@@ -139,61 +160,79 @@ export default function KnowledgeBasePage() {
{kb.documents.length > 0 && (
文档
-