完善skills;测试用例生成页面功能初步实现
This commit is contained in:
45
.github/skills/METHOD_ID_REGISTRY.md
vendored
Normal file
45
.github/skills/METHOD_ID_REGISTRY.md
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# METHOD_ID_REGISTRY
|
||||
|
||||
## Purpose
|
||||
Provide a single source of truth for test method identifiers used across skills.
|
||||
|
||||
## Format
|
||||
- Recommended output format: `Mxx|方法名`
|
||||
- Routing key: `Mxx`
|
||||
- Display name: 方法名
|
||||
|
||||
## B.1 Black-box Methods (M01-M12)
|
||||
| Method ID | 方法名 | 推荐占位符 | Route Skill |
|
||||
| --- | --- | --- | --- |
|
||||
| M01 | 功能分解 | {{return_value}}, {{state_change}} | generate-test-cases-blackbox |
|
||||
| M02 | 等价类划分 | {{return_value}}, {{error_message}} | generate-test-cases-blackbox |
|
||||
| M03 | 边界值分析 | {{return_value}}, {{precision_tolerance}} | generate-test-cases-blackbox |
|
||||
| M04 | 判定表 | {{state_change}}, {{sequence_event}} | generate-test-cases-blackbox |
|
||||
| M05 | 因果图 | {{error_message}}, {{error_handling}} | generate-test-cases-blackbox |
|
||||
| M06 | 场景法 | {{sequence_event}}, {{state_change}} | generate-test-cases-blackbox |
|
||||
| M07 | 功能图法 | {{state_change}}, {{sequence_event}} | generate-test-cases-blackbox |
|
||||
| M08 | 随机测试 | {{resource_usage}}, {{time_constraint}} | generate-test-cases-blackbox |
|
||||
| M09 | 猜错法 | {{error_message}}, {{error_handling}} | generate-test-cases-blackbox |
|
||||
| M10 | 正交试验法 | {{return_value}}, {{data_persistence}} | generate-test-cases-blackbox |
|
||||
| M11 | 组合测试法 | {{return_value}}, {{sequence_event}} | generate-test-cases-blackbox |
|
||||
| M12 | 蜕变测试法 | {{pass_criteria}}, {{precision_tolerance}} | generate-test-cases-blackbox |
|
||||
|
||||
## B.2 White-box Methods (M13-M18)
|
||||
| Method ID | 方法名 | 证据锚点 | Route Skill |
|
||||
| --- | --- | --- | --- |
|
||||
| M13 | 控制流测试 | 路径编号 | generate-test-cases-whitebox |
|
||||
| M14 | 数据流测试 | DU对编号 | generate-test-cases-whitebox |
|
||||
| M15 | 程序变异 | 变异体编号 | generate-test-cases-whitebox |
|
||||
| M16 | 程序插桩 | 插桩点编号 | generate-test-cases-whitebox |
|
||||
| M17 | 域测试 | 域编号 | generate-test-cases-whitebox |
|
||||
| M18 | 符号求值 | 约束表达式编号 | generate-test-cases-whitebox |
|
||||
|
||||
## Routing Rules
|
||||
1. Use Method ID (`Mxx`) as the only routing key.
|
||||
2. `M01-M12` route to `generate-test-cases-blackbox`.
|
||||
3. `M13-M18` route to `generate-test-cases-whitebox`.
|
||||
4. Unknown IDs should be recorded in `method_alignment_report.gaps`.
|
||||
|
||||
## Backward Compatibility
|
||||
- If input methods are provided as plain method names, map them to Method IDs before routing.
|
||||
- Keep Chinese method names for readability in `case_summary`.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: build-expected-results
|
||||
description: "当需要将测试用例中的 expected_result_placeholder 展开为可度量预期成果时使用。"
|
||||
description: "当测试用例包含 expected_result_placeholder 时使用,将其展开为可验证、可量化、可判定通过/失败的预期成果集合。"
|
||||
---
|
||||
|
||||
# build-expected-results
|
||||
|
||||
5
.github/skills/decompose-test-items/SKILL.md
vendored
5
.github/skills/decompose-test-items/SKILL.md
vendored
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: decompose-test-items
|
||||
description: "当需要基于需求类型把需求文本分解为可执行的正常/异常测试项时使用。"
|
||||
description: "当已获得 requirement_type 后,需将需求文本拆解为可执行的正常/异常测试项并输出 coverage_analysis(覆盖率、缺口、补充建议)时使用。"
|
||||
---
|
||||
|
||||
# decompose-test-items
|
||||
@@ -11,8 +11,7 @@ description: "当需要基于需求类型把需求文本分解为可执行的正
|
||||
## 输入
|
||||
- user_requirement_text
|
||||
- requirement_type
|
||||
- recommended_test_methods(可选)
|
||||
- suggested_decompose_template(可选)
|
||||
- recommended_test_methods(可选,格式建议 `Mxx|方法名`)
|
||||
|
||||
## 输出
|
||||
- normal_test_items:完整、可执行的正常测试项列表。
|
||||
|
||||
2
.github/skills/format-output/SKILL.md
vendored
2
.github/skills/format-output/SKILL.md
vendored
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: format-output
|
||||
description: "当需要将测试项、测试用例、预期成果按统一格式输出时使用。"
|
||||
description: "当需将测试项、测试用例、预期成果统一整理为三段式 Markdown 输出时使用,要求正常/异常分组且编号追踪一致。"
|
||||
---
|
||||
|
||||
# format-output
|
||||
|
||||
59
.github/skills/generate-test-cases-blackbox/SKILL.md
vendored
Normal file
59
.github/skills/generate-test-cases-blackbox/SKILL.md
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: generate-test-cases-blackbox
|
||||
description: "当 recommended_test_methods 命中 M01-M12 时使用,按黑盒方法特征生成正常/异常测试用例并输出 method_alignment_report。"
|
||||
---
|
||||
|
||||
# generate-test-cases-blackbox
|
||||
|
||||
## 目标
|
||||
对输入测试项应用黑盒方法(M01-M12),生成可重复执行、可验证的测试用例。
|
||||
|
||||
## 输入
|
||||
- normal_test_items
|
||||
- abnormal_test_items
|
||||
- requirement_type(可选)
|
||||
- recommended_test_methods(可选,格式建议 `Mxx|方法名`)
|
||||
|
||||
## 输出
|
||||
- normal_test_cases
|
||||
- abnormal_test_cases
|
||||
- method_alignment_report
|
||||
|
||||
## 黑盒方法范围
|
||||
- M01 功能分解
|
||||
- M02 等价类划分
|
||||
- M03 边界值分析
|
||||
- M04 判定表
|
||||
- M05 因果图
|
||||
- M06 场景法
|
||||
- M07 功能图法
|
||||
- M08 随机测试
|
||||
- M09 猜错法
|
||||
- M10 正交试验法
|
||||
- M11 组合测试法
|
||||
- M12 蜕变测试法
|
||||
|
||||
## 通用规则
|
||||
1. 每个测试项至少绑定一个黑盒方法;高风险测试项建议绑定两种方法。
|
||||
2. `test_inputs` 必须反映方法特征(类编号、边界点、规则号、场景路径、组合强度、随机参数等)。
|
||||
3. `operation_steps` 必须体现方法步骤链,禁止空泛表述。
|
||||
4. `case_summary` 必须标注 `Mxx|方法名`。
|
||||
5. 每条用例应优先使用黑盒占位符:`{{return_value}}`、`{{state_change}}`、`{{error_message}}`。
|
||||
|
||||
## 方法详细说明(M01-M12)
|
||||
- M01 功能分解:适用于规格说明可分层拆解的场景。先按功能抽象把程序分解为功能层次和最低层子功能,再按数据抽象为每个子功能的输入/输出取值集合设计数据。`test_inputs` 需显式包含子功能标识、前置依赖和数据抽象来源,`operation_steps` 按“分层覆盖 -> 子功能执行 -> I/O 校验”编写。
|
||||
- M02 等价类划分:在规格约束基础上划分有效等价类与无效等价类,并为每个等价类分配唯一编号。每条用例至少映射一个目标类,建议用“类编号+代表值+预期策略”组织 `test_inputs`。执行时应同时验证有效类的主功能正确性和无效类的错误处理一致性。
|
||||
- M03 边界值分析:先识别全部边界条件,再为每个边界给出满足边界和不满足边界的数据。建议采用 `L-1/L/L+1` 与 `U-1/U/U+1`,并对多维边界补充组合边界样本。预期结果需区分“计算错误”(边界内)和“域错误”(边界外),作为 `expected_results` 的判定要点。
|
||||
- M04 判定表:以“条件桩、条件条目、动作桩、动作条目”构建规则表,并将每一列规则转化为测试用例。适用前提包括:条件/规则顺序不影响操作、规则命中后无需再检验其他规则、多动作执行顺序无关。`operation_steps` 需记录规则命中过程,`expected_results` 需核验动作集合完整且无多执行。
|
||||
- M05 因果图:先识别原因(输入)与结果(输出)并编号,再构建因果关系和约束,随后转换为判定表并逐列生成用例。适用于输入组合关系复杂的需求;若因果图过大导致用例爆炸,应在 `gaps` 中说明并采用降级策略(如关键链路抽样)。用例中应保留原因/结果编号,保证可追踪。
|
||||
- M06 场景法:以用况路径为单位覆盖基本流与备选流,形成从开始到结束的可执行场景。步骤应包括:确定流、组合场景、设计用例、审验并去冗余、为用例补充测试数据。`operation_steps` 要体现场景触发条件和路径切换点,避免只写主流程。
|
||||
- M07 功能图法:使用“状态转移图 + 逻辑功能模型”联合建模。先在每个状态内用因果图/判定表生成局部用例,再由状态转移图导出路径用例,最后合成实用测试用例。`test_inputs` 需同时体现状态路径和状态内输入条件,`expected_results` 要同时验证迁移结果与局部逻辑输出。
|
||||
- M08 随机测试:在输入取值区间上随机取样,并在必要时约束随机分布与种子以保证可复现。该方法在预期输出难以精确构造时更常用于可靠性测试和强度测试。`method_alignment_report` 需包含采样范围、分布、样本量和失败样本聚类结论。
|
||||
- M09 猜错法:由有经验测试人员基于历史缺陷和易错情况表设计用例。适合补齐规格难覆盖的风险点(如空值、边界格式、异常时序、重复操作)。用例应明确“猜错依据”,并在结果中验证防护是否生效。
|
||||
- M10 正交试验法:把影响功能实现的操作对象和外部因素作为因子,把因子取值作为水平,构造因素分析表并选用正交表进行代表性组合。目标是在减少用例规模的同时保持较高覆盖效率。建议在 `test_inputs` 中保留因子-水平映射,便于回归和复现。
|
||||
- M11 组合测试法:面向多参数输入生成组合用例集,并按组合强度评估覆盖充分性。常见强度包括单一选择、基本选择、成对组合、全组合和 K 强度组合。黑盒场景中组合参数通常是外部输入,需在用例中标注强度等级和约束条件。
|
||||
- M12 蜕变测试法:适用于单个用例预期结果难判定的场景。通过构造蜕变关系验证“源用例与跟随用例”的结果是否满足关系;不满足即判定失败。该方法可与其他方法联合:既可用于结果验证,也可生成补充用例。
|
||||
|
||||
## 对齐输出要求
|
||||
- 在 `method_alignment_report` 中记录:`case_id`、`selected_methods`、`alignment_score`、`gaps`、`fix_suggestions`。
|
||||
- 无法落地的方法必须进入 `gaps` 并提供可执行修复建议。
|
||||
47
.github/skills/generate-test-cases-whitebox/SKILL.md
vendored
Normal file
47
.github/skills/generate-test-cases-whitebox/SKILL.md
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: generate-test-cases-whitebox
|
||||
description: "当 recommended_test_methods 命中 M13-M18 时使用,按路径/数据/变异/插桩证据链生成可追踪测试用例并输出 method_alignment_report。"
|
||||
---
|
||||
|
||||
# generate-test-cases-whitebox
|
||||
|
||||
## 目标
|
||||
对输入测试项应用白盒方法(M13-M18),生成带可追踪执行证据的测试用例。
|
||||
|
||||
## 输入
|
||||
- normal_test_items
|
||||
- abnormal_test_items
|
||||
- requirement_type(可选)
|
||||
- recommended_test_methods(可选,格式建议 `Mxx|方法名`)
|
||||
|
||||
## 输出
|
||||
- normal_test_cases
|
||||
- abnormal_test_cases
|
||||
- method_alignment_report
|
||||
|
||||
## 白盒方法范围
|
||||
- M13 控制流测试
|
||||
- M14 数据流测试
|
||||
- M15 程序变异
|
||||
- M16 程序插桩
|
||||
- M17 域测试
|
||||
- M18 符号求值
|
||||
|
||||
## 通用规则
|
||||
1. 白盒用例必须包含证据锚点(路径编号、DU对、变异体、插桩点、域编号、约束编号)。
|
||||
2. `test_inputs` 应能触发目标证据链,禁止只描述功能输入。
|
||||
3. `operation_steps` 必须包含证据采集或比对动作。
|
||||
4. `case_summary` 必须标注 `Mxx|方法名`。
|
||||
5. 白盒用例可结合 `{{pass_criteria}}`、`{{resource_usage}}`、`{{state_change}}` 进行结果绑定。
|
||||
|
||||
## 方法详细说明(M13-M18)
|
||||
- M13 控制流测试:依据控制流图生成用例,目标是覆盖不同控制结构。常用覆盖包括语句覆盖、分支覆盖、条件覆盖、条件组合覆盖、MC/DC 和路径覆盖。标准步骤为:流程图转控制流图、路径表达式求解、路径树生成、路径编码与译码、按目标路径枚举测试用例。`method_alignment_report` 应记录覆盖准则与未覆盖路径原因。
|
||||
- M14 数据流测试:在控制流图上分析变量定义、使用和消除,重点发现“未定义就使用”与“定义后未使用”等数据流异常。建议步骤为:构建控制流图、标注链路数据操作符、选择数据流覆盖策略、导出测试路径、再生成输入与用例。除静态分析外,可结合动态数据流检查提高真实性,但要说明路径覆盖局限。
|
||||
- M15 程序变异:以错误驱动方式定义变异算子并生成语法合法的变异体,通过测试数据逐个执行变异体判断检测能力。可区分强变异与弱变异,并在报告中统计“杀死率、存活率、等价变异占比”。该方法既用于找缺陷,也用于评价现有测试用例集的有效性。
|
||||
- M16 程序插桩:通过在程序中插入观测操作实现证据采集,但不得改变被测程序原有功能与运行语义。建议步骤为:定位插桩点、插入采样逻辑、执行测试、回收与分析数据、移除或关闭插桩。由于数据记录量通常较大,宜借助工具完成并在报告中注明插桩开销与可信度。
|
||||
- M17 域测试:目标是验证程序对输入空间划分是否正确,重点检查域内、边界和跨域样本的判定一致性。该方法约束较多、使用门槛较高,通常用于有特殊质量要求的场景。用例中应保留域划分依据与判定规则,避免仅给样本值不说明域归属。
|
||||
- M18 符号求值:允许变量取符号值,通过符号执行检查公式与分支约束是否满足预期,并可导出程序路径用于生成测试数据。适用于公式密集或约束明确的逻辑。复杂分支推荐工具化处理,分支较少时可人工推导并在用例中记录约束来源与推导过程。
|
||||
|
||||
## 对齐输出要求
|
||||
- 在 `method_alignment_report` 中记录:`case_id`、`selected_methods`、`alignment_score`、`gaps`、`fix_suggestions`。
|
||||
- 对无法构造证据链的方法,必须输出 `gaps` 与降级策略说明。
|
||||
47
.github/skills/generate-test-cases/SKILL.md
vendored
47
.github/skills/generate-test-cases/SKILL.md
vendored
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: generate-test-cases
|
||||
description: "当需要根据已分解测试项生成包含操作步骤与测试内容的具体测试用例时使用。"
|
||||
description: "当已有 normal/abnormal 测试项且需按统一规范生成可执行测试用例时使用;不负责测试方法选择,输出测试用例与合规校验报告。"
|
||||
---
|
||||
|
||||
# generate-test-cases
|
||||
@@ -12,12 +12,11 @@ description: "当需要根据已分解测试项生成包含操作步骤与测试
|
||||
- normal_test_items
|
||||
- abnormal_test_items
|
||||
- requirement_type(可选)
|
||||
- recommended_test_methods(可选)
|
||||
|
||||
## 输出
|
||||
- normal_test_cases
|
||||
- abnormal_test_cases
|
||||
- method_alignment_report
|
||||
- case_compliance_report
|
||||
|
||||
每条测试用例必须包含:
|
||||
- case_id
|
||||
@@ -39,12 +38,24 @@ description: "当需要根据已分解测试项生成包含操作步骤与测试
|
||||
3. 必须区分正常测试用例与异常测试用例。
|
||||
4. 操作步骤应可顺序执行,避免歧义。
|
||||
5. 操作步骤必须包含明确动作、对象和输入条件,禁止笼统动作词。
|
||||
6. test_content 必须包含可验证条件,便于后续生成可度量预期成果。
|
||||
6. `case_summary` 与 `evaluation_criteria` 必须包含可验证条件,便于后续生成可度量预期成果。
|
||||
7. 测试用例应符合可重复执行原则,初始条件和参数必须可复现。
|
||||
8. 测试输入必须标识有效值/无效值/边界值性质、输入来源、真实或模拟属性及事件顺序。
|
||||
9. 每个操作步骤应包含测试输入、动作、中间期望、评估准则与异常终止信号。
|
||||
10. pass_criteria 必须明确是否通过的判定条件,禁止模糊描述。
|
||||
|
||||
## 统一生成规范(与测试方法无关)
|
||||
1. 字段完整性:每条用例必须包含本 Skill 规定的全部字段,缺失字段不得省略,应给出“待补充”占位。
|
||||
2. 可执行性:`operation_steps` 必须按可顺序执行的步骤组织,单步只表达一个核心动作,避免多动作混写。
|
||||
3. 可复现性:`initialization_requirements`、`test_inputs`、`preconditions_constraints` 必须足以让第三方复现实验。
|
||||
4. 可观测性:每条步骤必须关联可观测结果或中间检查点,避免只描述操作不描述观测。
|
||||
5. 可判定性:`evaluation_criteria` 与 `pass_criteria` 必须给出明确判定阈值或布尔条件,禁止模糊词。
|
||||
6. 异常完备性:异常用例必须包含异常触发条件、预期保护动作、终止条件与恢复后状态要求。
|
||||
7. 去歧义:字段中禁止“适当”“正常”“合理”等无量纲表述,需替换为可验证条件。
|
||||
8. 去重一致性:相同测试项下语义重复的用例应合并,保留最完整版本并保证编号稳定。
|
||||
9. 追踪关系:`test_traceability` 必须指向来源测试项,保证“测试项 -> 测试用例”可追踪。
|
||||
10. 结果衔接:`expected_result_placeholder` 必须可直接用于下一步预期成果展开,不得出现不可映射占位符。
|
||||
|
||||
## expected_result_placeholder 映射(用于 Step 4 展开)
|
||||
- {{return_value}}:接口或函数返回值验证。
|
||||
- {{state_change}}:系统状态变化验证。
|
||||
@@ -59,30 +70,6 @@ description: "当需要根据已分解测试项生成包含操作步骤与测试
|
||||
- {{resource_usage}}:资源占用与空间约束验证。
|
||||
- {{pass_criteria}}:用例通过准则验证。
|
||||
|
||||
## 常用测试方法应用清单(B.1.1-B.2.6)
|
||||
每个用例必须在 summary 中标注所用方法,并在 steps 中体现方法特征。
|
||||
|
||||
| 方法 | 适用场景 | 输入构造规则 | 步骤设计特征 |
|
||||
| --- | --- | --- | --- |
|
||||
| 功能分解 | 功能/接口主流程 | 按子功能拆输入集 | 用例按子功能逐级覆盖 |
|
||||
| 等价类划分 | 功能/接口/边界 | 有效类与无效类分别取代表值 | 每类至少一条步骤 |
|
||||
| 边界值分析 | 输入输出边界 | 取边界、邻近、越界值 | 连续执行边界点序列 |
|
||||
| 判定表 | 多条件组合逻辑 | 依据条件桩生成规则列 | 一列规则对应一条步骤流 |
|
||||
| 因果图 | 条件与结果耦合 | 构建原因-结果及约束 | 覆盖关键因果链路 |
|
||||
| 场景法 | 事件驱动流程 | 基本流与备选流输入 | 按场景路径组织步骤 |
|
||||
| 功能图法 | 状态与逻辑联合 | 状态转移输入序列 | 状态路径+局部逻辑组合 |
|
||||
| 随机测试 | 可靠性/强度 | 输入区间+概率分布 | 指定随机规则和样本量 |
|
||||
| 猜错法 | 高风险经验缺陷 | 构造易错输入集合 | 直接验证高风险点 |
|
||||
| 正交试验法 | 多因子组合优化 | 因子-水平表+正交表 | 覆盖代表组合点 |
|
||||
| 组合测试法 | 参数组合覆盖 | 选定组合强度(pairwise/K) | 按组合强度生成步骤 |
|
||||
| 蜕变测试法 | 预期结果难判定 | 构造蜕变关系输入组 | 校验用例间关系一致性 |
|
||||
| 控制流测试 | 白盒流程覆盖 | 按路径目标构造输入 | 步骤对应语句/分支路径 |
|
||||
| 数据流测试 | 白盒变量使用 | 定义-使用链路输入 | 覆盖关键定义引用对 |
|
||||
| 程序变异 | 错误驱动验证 | 针对变异体生成输入 | 验证是否杀死变异体 |
|
||||
| 程序插桩 | 运行行为观测 | 插桩点对应输入 | 步骤包含采样与比对 |
|
||||
| 域测试 | 输入空间划分检验 | 构造域边界/域内样本 | 验证域划分正确性 |
|
||||
| 符号求值 | 公式与路径推导 | 依据符号约束反推输入 | 校验符号关系与结果 |
|
||||
|
||||
## 禁止模糊描述
|
||||
- 错误示例:"检查功能正常";正确示例:"验证返回状态码为200且响应体包含status=success"。
|
||||
- 错误示例:"输入合法数据";正确示例:"在用户名输入框输入长度为8的字母数字字符串并提交"。
|
||||
@@ -92,5 +79,5 @@ description: "当需要根据已分解测试项生成包含操作步骤与测试
|
||||
- 每条用例必须可在下一步绑定一条明确、可验证的预期成果。
|
||||
- 每条 expected_result_placeholder 必须可映射到定量或可观察的检查项。
|
||||
|
||||
## 对齐校验
|
||||
- 输出 method_alignment_report,至少包含:case_id、selected_methods、alignment_score、gaps、fix_suggestions。
|
||||
## 合规校验输出
|
||||
- 输出 case_compliance_report,至少包含:case_id、completeness_score、executability_score、ambiguity_flags、gaps、fix_suggestions。
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: identify-requirement-type
|
||||
description: "当需要在测试项分解与测试用例生成之前识别需求类型时使用。"
|
||||
description: "当输入为原始需求文本且需判定测试需求主类型(含未知回退)、候选类型与推荐方法ID(Mxx|方法名)时使用。"
|
||||
---
|
||||
|
||||
# identify-requirement-type
|
||||
|
||||
## 目标
|
||||
将用户需求文本识别为明确的测试需求类型,为后续测试项分解与测试用例生成提供分类依据、推荐测试方法与分解模板。
|
||||
将用户需求文本识别为明确的测试需求类型,为后续测试项分解与测试用例生成提供分类依据与推荐测试方法。
|
||||
|
||||
## 输入
|
||||
- user_requirement_text:用户原始需求文本。
|
||||
@@ -31,8 +31,7 @@ description: "当需要在测试项分解与测试用例生成之前识别需求
|
||||
- 未知类型
|
||||
- reason:简要判断依据。
|
||||
- candidates:当 requirement_type 为未知类型时,给出 1-3 个最接近候选类型。
|
||||
- recommended_test_methods:基于类型推荐的测试方法列表(按优先级排序)。
|
||||
- suggested_decompose_template:推荐的测试项分解模板名称。
|
||||
- recommended_test_methods:基于类型推荐的测试方法列表(按优先级排序),格式为 `Mxx|方法名`。
|
||||
- type_signals:触发该类型判断的关键语义信号。
|
||||
|
||||
## 类型识别信号
|
||||
@@ -51,24 +50,24 @@ 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 |
|
||||
## 附录 A:类型到方法映射
|
||||
| 需求类型 | 推荐测试方法(优先顺序,方法ID+中文名) |
|
||||
| --- | --- |
|
||||
| 功能测试 | M01|功能分解, M02|等价类划分, M03|边界值分析, M06|场景法, M04|判定表, M05|因果图, M11|组合测试法, M09|猜错法 |
|
||||
| 性能测试 | M03|边界值分析, M11|组合测试法, M10|正交试验法, M08|随机测试, M06|场景法, M07|功能图法, M16|程序插桩 |
|
||||
| 外部接口测试 | M02|等价类划分, M03|边界值分析, M04|判定表, M05|因果图, M11|组合测试法, M06|场景法, M09|猜错法 |
|
||||
| 人机交互界面测试 | M06|场景法, M09|猜错法, M02|等价类划分, M03|边界值分析, M11|组合测试法, M04|判定表 |
|
||||
| 强度测试 | M08|随机测试, M11|组合测试法, M03|边界值分析, M10|正交试验法, M06|场景法, M16|程序插桩 |
|
||||
| 余量测试 | M03|边界值分析, M11|组合测试法, M06|场景法, M10|正交试验法, M08|随机测试 |
|
||||
| 可靠性测试 | M08|随机测试, M06|场景法, M11|组合测试法, M12|蜕变测试法, M09|猜错法, M16|程序插桩, M07|功能图法 |
|
||||
| 安全性测试 | M03|边界值分析, M06|场景法, M09|猜错法, M05|因果图, M04|判定表, M11|组合测试法, M07|功能图法 |
|
||||
| 恢复性测试 | M06|场景法, M07|功能图法, M09|猜错法, M11|组合测试法, M04|判定表, M16|程序插桩 |
|
||||
| 边界测试 | M03|边界值分析, M02|等价类划分, M17|域测试, M11|组合测试法, M04|判定表, M06|场景法 |
|
||||
| 安装性测试 | M06|场景法, M09|猜错法, M11|组合测试法, M02|等价类划分, M03|边界值分析 |
|
||||
| 互操作性测试 | M06|场景法, M11|组合测试法, M05|因果图, M04|判定表, M07|功能图法, M09|猜错法 |
|
||||
| 敏感性测试 | M11|组合测试法, M10|正交试验法, M08|随机测试, M09|猜错法, M03|边界值分析, M12|蜕变测试法 |
|
||||
| 测试充分性要求 | M13|控制流测试, M14|数据流测试, M15|程序变异, M16|程序插桩, M17|域测试, M18|符号求值, M11|组合测试法 |
|
||||
| 未知类型 | M01|功能分解, M02|等价类划分, M03|边界值分析, M06|场景法, M11|组合测试法, M09|猜错法 |
|
||||
|
||||
## 规则
|
||||
1. 优先依据需求文本中的显式表述进行分类。
|
||||
@@ -77,13 +76,14 @@ description: "当需要在测试项分解与测试用例生成之前识别需求
|
||||
4. 判断依据需简洁、可追溯到文本证据。
|
||||
5. 若需求同时覆盖多个类型,输出主类型 requirement_type,并将次类型放入 candidates。
|
||||
6. recommended_test_methods 至少返回 2 个方法,优先返回文档中可直接执行的方法。
|
||||
7. suggested_decompose_template 必须与 requirement_type 一致。
|
||||
7. 方法标识以 Method ID(Mxx)为路由主键,中文方法名仅用于可读性展示。
|
||||
|
||||
## 容错
|
||||
- 当需求描述过于笼统或跨多类型混合时,输出未知类型,并在 candidates 给出最接近类型。
|
||||
- 当识别不稳定时,优先保守分类,不强行归入单一类型。
|
||||
- 未知类型不阻断后续流程,应继续执行通用测试项分解。
|
||||
- 当文本信息不足以支持白盒方法推荐时,保留黑盒优先推荐并标记 reason。
|
||||
- 当仅能识别到中文方法名时,先映射为 Method ID 后再输出。
|
||||
|
||||
## 调试
|
||||
- debug 模式下返回每个类型的分类分数 classification_scores。
|
||||
|
||||
13
.github/skills/testing-orchestrator/SKILL.md
vendored
13
.github/skills/testing-orchestrator/SKILL.md
vendored
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: testing-orchestrator
|
||||
description: "当用户要求测试项分解或测试用例生成且需要完整工具调用链时使用。"
|
||||
description: "当需要从需求文本一键生成测试项、测试用例与预期成果时使用,按 identify→decompose→generate→build→format 全链路编排并透传上下文。"
|
||||
---
|
||||
|
||||
# testing-orchestrator
|
||||
@@ -31,15 +31,13 @@ description: "当用户要求测试项分解或测试用例生成且需要完整
|
||||
- requirement_type
|
||||
- reason
|
||||
- candidates
|
||||
- recommended_test_methods
|
||||
- suggested_decompose_template
|
||||
- recommended_test_methods(格式:`Mxx|方法名`)
|
||||
|
||||
### 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
|
||||
@@ -56,6 +54,13 @@ description: "当用户要求测试项分解或测试用例生成且需要完整
|
||||
- abnormal_test_cases
|
||||
- method_alignment_report
|
||||
|
||||
#### Step 3 路由规则(按需调用)
|
||||
1. 从 `recommended_test_methods` 提取 Method ID(`Mxx`)。
|
||||
2. 若命中 `M01-M12`,调用 `generate-test-cases-blackbox`。
|
||||
3. 若命中 `M13-M18`,调用 `generate-test-cases-whitebox`。
|
||||
4. 同一测试项命中黑盒+白盒时,并行生成后去重合并。
|
||||
5. 合并后统一输出 `case_id` 与 `method_alignment_report`,并记录未落地方法到 `gaps`。
|
||||
|
||||
### Step 4: build-expected-results
|
||||
- 输入:
|
||||
- normal_test_cases(来自 Step 3)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add interface fields to srs requirements
|
||||
|
||||
Revision ID: b7217f0c3d92
|
||||
Revises: a4f9c89b7d11
|
||||
Create Date: 2026-04-18 19:10:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b7217f0c3d92"
|
||||
down_revision: Union[str, None] = "a4f9c89b7d11"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("srs_requirements", sa.Column("section_uid", sa.String(length=64), nullable=True))
|
||||
op.add_column("srs_requirements", sa.Column("interface_name", sa.String(length=255), nullable=True))
|
||||
op.add_column("srs_requirements", sa.Column("interface_type", sa.String(length=128), nullable=True))
|
||||
op.add_column("srs_requirements", sa.Column("data_source", sa.String(length=255), nullable=True))
|
||||
op.add_column("srs_requirements", sa.Column("data_destination", sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("srs_requirements", "data_destination")
|
||||
op.drop_column("srs_requirements", "data_source")
|
||||
op.drop_column("srs_requirements", "interface_type")
|
||||
op.drop_column("srs_requirements", "interface_name")
|
||||
op.drop_column("srs_requirements", "section_uid")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""add testing generation history table
|
||||
|
||||
Revision ID: c9f6e7a1bd34
|
||||
Revises: b7217f0c3d92
|
||||
Create Date: 2026-04-26 20:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c9f6e7a1bd34"
|
||||
down_revision: Union[str, None] = "b7217f0c3d92"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"testing_generations",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("job_id", sa.Integer(), nullable=False),
|
||||
sa.Column("source_job_id", sa.Integer(), nullable=True),
|
||||
sa.Column("source_document_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("generated_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("total_requirements", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("knowledge_base_id", sa.Integer(), nullable=True),
|
||||
sa.Column("generated_file", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["job_id"], ["tool_jobs.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["knowledge_base_id"], ["knowledge_bases.id"]),
|
||||
sa.ForeignKeyConstraint(["source_job_id"], ["tool_jobs.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("job_id"),
|
||||
)
|
||||
op.create_index(op.f("ix_testing_generations_id"), "testing_generations", ["id"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_testing_generations_source_job_id"),
|
||||
"testing_generations",
|
||||
["source_job_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_testing_generations_knowledge_base_id"),
|
||||
"testing_generations",
|
||||
["knowledge_base_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_testing_generations_knowledge_base_id"), table_name="testing_generations")
|
||||
op.drop_index(op.f("ix_testing_generations_source_job_id"), table_name="testing_generations")
|
||||
op.drop_index(op.f("ix_testing_generations_id"), table_name="testing_generations")
|
||||
op.drop_table("testing_generations")
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -15,6 +17,8 @@ from app.services.testing_pipeline import run_testing_pipeline
|
||||
from app.services.vector_store import VectorStoreFactory
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
MODEL_PIPELINE_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
async def _build_kb_vector_stores(db: Session, knowledge_bases: List[KnowledgeBase]) -> List[Dict[str, Any]]:
|
||||
@@ -47,38 +51,75 @@ async def generate_testing_content(
|
||||
|
||||
knowledge_context = (payload.knowledge_context or "").strip()
|
||||
if payload.knowledge_base_ids:
|
||||
knowledge_bases = (
|
||||
db.query(KnowledgeBase)
|
||||
.filter(
|
||||
KnowledgeBase.id.in_(payload.knowledge_base_ids),
|
||||
KnowledgeBase.user_id == current_user.id,
|
||||
try:
|
||||
knowledge_bases = (
|
||||
db.query(KnowledgeBase)
|
||||
.filter(
|
||||
KnowledgeBase.id.in_(payload.knowledge_base_ids),
|
||||
KnowledgeBase.user_id == current_user.id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
.all()
|
||||
|
||||
kb_vector_stores = await _build_kb_vector_stores(db, knowledge_bases)
|
||||
if kb_vector_stores:
|
||||
retriever = MultiKBRetriever(
|
||||
reranker_weight=settings.RERANKER_WEIGHT,
|
||||
)
|
||||
retrieval_rows = await retriever.retrieve(
|
||||
query=payload.requirement_text,
|
||||
kb_vector_stores=kb_vector_stores,
|
||||
fetch_k_per_kb=max(12, payload.retrieval_top_k * 2),
|
||||
top_k=payload.retrieval_top_k,
|
||||
)
|
||||
if retrieval_rows:
|
||||
knowledge_context = format_retrieval_context(retrieval_rows)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Testing generation retrieval fallback triggered for user=%s knowledge_base_ids=%s: %s",
|
||||
current_user.id,
|
||||
payload.knowledge_base_ids,
|
||||
exc,
|
||||
)
|
||||
|
||||
pipeline_kwargs = {
|
||||
"user_requirement_text": payload.requirement_text,
|
||||
"requirement_type_input": payload.requirement_type,
|
||||
"debug": payload.debug,
|
||||
"knowledge_context": knowledge_context,
|
||||
"use_model_generation": payload.use_model_generation,
|
||||
"max_items_per_group": payload.max_items_per_group,
|
||||
"cases_per_item": payload.cases_per_item,
|
||||
"max_focus_points": payload.max_focus_points,
|
||||
"max_llm_calls": payload.max_llm_calls,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.to_thread(run_testing_pipeline, **pipeline_kwargs),
|
||||
timeout=MODEL_PIPELINE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
logger.exception(
|
||||
"Testing pipeline timed out for user=%s use_model_generation=%s after %s seconds",
|
||||
current_user.id,
|
||||
payload.use_model_generation,
|
||||
MODEL_PIPELINE_TIMEOUT_SECONDS,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail=f"LLM generation timed out after {MODEL_PIPELINE_TIMEOUT_SECONDS} seconds",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Testing pipeline failed for user=%s use_model_generation=%s: %s",
|
||||
current_user.id,
|
||||
payload.use_model_generation,
|
||||
exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"LLM generation failed: {exc}",
|
||||
) from exc
|
||||
|
||||
kb_vector_stores = await _build_kb_vector_stores(db, knowledge_bases)
|
||||
if kb_vector_stores:
|
||||
retriever = MultiKBRetriever(
|
||||
reranker_weight=settings.RERANKER_WEIGHT,
|
||||
)
|
||||
retrieval_rows = await retriever.retrieve(
|
||||
query=payload.requirement_text,
|
||||
kb_vector_stores=kb_vector_stores,
|
||||
fetch_k_per_kb=max(12, payload.retrieval_top_k * 2),
|
||||
top_k=payload.retrieval_top_k,
|
||||
)
|
||||
if retrieval_rows:
|
||||
knowledge_context = format_retrieval_context(retrieval_rows)
|
||||
|
||||
result = run_testing_pipeline(
|
||||
user_requirement_text=payload.requirement_text,
|
||||
requirement_type_input=payload.requirement_type,
|
||||
debug=payload.debug,
|
||||
knowledge_context=knowledge_context,
|
||||
use_model_generation=payload.use_model_generation,
|
||||
max_items_per_group=payload.max_items_per_group,
|
||||
cases_per_item=payload.cases_per_item,
|
||||
max_focus_points=payload.max_focus_points,
|
||||
max_llm_calls=payload.max_llm_calls,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
import shutil
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.tooling import SRSExtraction, ToolJob
|
||||
from app.models.knowledge import KnowledgeBase
|
||||
from app.models.tooling import SRSExtraction, TestingGeneration, ToolJob
|
||||
from app.models.user import User
|
||||
from app.schemas.tooling import (
|
||||
SRSToolCreateJobResponse,
|
||||
SRSToolHistoryItem,
|
||||
SRSToolJobStatusResponse,
|
||||
SRSToolRequirementsSaveRequest,
|
||||
SRSToolResultResponse,
|
||||
TestingGenerationCreateRequest,
|
||||
TestingGenerationCreateResponse,
|
||||
TestingGenerationHistoryItem,
|
||||
TestingGenerationJobStatusResponse,
|
||||
TestingGenerationResultResponse,
|
||||
TestingGenerationSaveRequest,
|
||||
ToolDefinitionResponse,
|
||||
)
|
||||
from app.services.srs_job_service import (
|
||||
build_srs_upload_path,
|
||||
build_result_response,
|
||||
delete_srs_job,
|
||||
ensure_upload_path,
|
||||
list_srs_history,
|
||||
replace_requirements,
|
||||
run_srs_job,
|
||||
)
|
||||
from app.services.testing_generation_service import (
|
||||
build_testing_generation_response,
|
||||
create_testing_generation,
|
||||
delete_testing_generation,
|
||||
list_testing_history,
|
||||
)
|
||||
from app.services.testing_generation_job_service import run_testing_generation_job
|
||||
from app.tools.registry import ToolRegistry
|
||||
from app.tools.srs_reqs_qwen import get_srs_tool
|
||||
|
||||
@@ -173,3 +193,223 @@ async def save_srs_requirements(
|
||||
db.refresh(extraction)
|
||||
|
||||
return build_result_response(job, extraction)
|
||||
|
||||
|
||||
@router.get("/srs/history", response_model=List[SRSToolHistoryItem])
|
||||
async def get_srs_history(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
return list_srs_history(db, current_user.id)
|
||||
|
||||
|
||||
@router.delete("/srs/jobs/{job_id}")
|
||||
async def delete_srs_job_api(
|
||||
job_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
job = (
|
||||
db.query(ToolJob)
|
||||
.filter(ToolJob.id == job_id, ToolJob.user_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
upload_path = build_srs_upload_path(job_id)
|
||||
delete_srs_job(db, job)
|
||||
|
||||
if upload_path.exists():
|
||||
shutil.rmtree(upload_path, ignore_errors=True)
|
||||
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.post("/testing/generations", response_model=TestingGenerationResultResponse)
|
||||
async def save_testing_generation(
|
||||
payload: TestingGenerationSaveRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
if payload.source_job_id is not None:
|
||||
source_job = (
|
||||
db.query(ToolJob)
|
||||
.filter(
|
||||
ToolJob.id == payload.source_job_id,
|
||||
ToolJob.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not source_job:
|
||||
raise HTTPException(status_code=404, detail="来源文件不存在")
|
||||
|
||||
if payload.knowledge_base_id is not None:
|
||||
knowledge_base = (
|
||||
db.query(KnowledgeBase)
|
||||
.filter(
|
||||
KnowledgeBase.id == payload.knowledge_base_id,
|
||||
KnowledgeBase.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not knowledge_base:
|
||||
raise HTTPException(status_code=404, detail="知识库不存在")
|
||||
|
||||
return create_testing_generation(db, current_user.id, payload)
|
||||
|
||||
|
||||
@router.post("/testing/jobs", response_model=TestingGenerationCreateResponse)
|
||||
async def create_testing_generation_job(
|
||||
background_tasks: BackgroundTasks,
|
||||
payload: TestingGenerationCreateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
if payload.source_job_id is not None:
|
||||
source_job = (
|
||||
db.query(ToolJob)
|
||||
.filter(
|
||||
ToolJob.id == payload.source_job_id,
|
||||
ToolJob.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not source_job:
|
||||
raise HTTPException(status_code=404, detail="来源文件不存在")
|
||||
|
||||
if payload.knowledge_base_id is not None:
|
||||
knowledge_base = (
|
||||
db.query(KnowledgeBase)
|
||||
.filter(
|
||||
KnowledgeBase.id == payload.knowledge_base_id,
|
||||
KnowledgeBase.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not knowledge_base:
|
||||
raise HTTPException(status_code=404, detail="知识库不存在")
|
||||
|
||||
job = ToolJob(
|
||||
user_id=current_user.id,
|
||||
tool_name="testing.case_generator",
|
||||
status="pending",
|
||||
input_file_name=payload.source_document_name,
|
||||
input_file_path="",
|
||||
output_summary={
|
||||
"source_document_name": payload.source_document_name,
|
||||
"current_step": 0,
|
||||
"total_steps": len(payload.requirements),
|
||||
},
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
|
||||
background_tasks.add_task(
|
||||
run_testing_generation_job,
|
||||
job.id,
|
||||
payload.dict(),
|
||||
)
|
||||
|
||||
return {
|
||||
"job_id": job.id,
|
||||
"status": job.status,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/testing/jobs/{job_id}", response_model=TestingGenerationJobStatusResponse)
|
||||
async def get_testing_generation_job_status(
|
||||
job_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
job = (
|
||||
db.query(ToolJob)
|
||||
.filter(
|
||||
ToolJob.id == job_id,
|
||||
ToolJob.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
summary = job.output_summary or {}
|
||||
return {
|
||||
"job_id": job.id,
|
||||
"tool_name": job.tool_name,
|
||||
"status": job.status,
|
||||
"error_message": job.error_message,
|
||||
"started_at": job.started_at,
|
||||
"completed_at": job.completed_at,
|
||||
"source_document_name": summary.get("source_document_name"),
|
||||
"current_step": summary.get("current_step"),
|
||||
"total_steps": summary.get("total_steps"),
|
||||
"current_requirement_id": summary.get("current_requirement_id"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/testing/history", response_model=List[TestingGenerationHistoryItem])
|
||||
async def get_testing_history(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
return list_testing_history(db, current_user.id)
|
||||
|
||||
|
||||
@router.get("/testing/jobs/{job_id}/result", response_model=TestingGenerationResultResponse)
|
||||
async def get_testing_generation_result(
|
||||
job_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
job = (
|
||||
db.query(ToolJob)
|
||||
.filter(
|
||||
ToolJob.id == job_id,
|
||||
ToolJob.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
generation = (
|
||||
db.query(TestingGeneration)
|
||||
.filter(TestingGeneration.job_id == job.id)
|
||||
.first()
|
||||
)
|
||||
if not generation:
|
||||
raise HTTPException(status_code=404, detail="任务结果不存在")
|
||||
|
||||
return build_testing_generation_response(job, generation)
|
||||
|
||||
|
||||
@router.delete("/testing/jobs/{job_id}")
|
||||
async def delete_testing_generation_api(
|
||||
job_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> Any:
|
||||
job = (
|
||||
db.query(ToolJob)
|
||||
.filter(
|
||||
ToolJob.id == job_id,
|
||||
ToolJob.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
generation = (
|
||||
db.query(TestingGeneration)
|
||||
.filter(TestingGeneration.job_id == job.id)
|
||||
.first()
|
||||
)
|
||||
if not generation:
|
||||
raise HTTPException(status_code=404, detail="任务结果不存在")
|
||||
|
||||
delete_testing_generation(db, job)
|
||||
return {"message": "删除成功"}
|
||||
|
||||
@@ -2,7 +2,7 @@ from .user import User
|
||||
from .knowledge import KnowledgeBase, Document, DocumentChunk
|
||||
from .chat import Chat, Message
|
||||
from .api_key import APIKey
|
||||
from .tooling import ToolJob, SRSExtraction, SRSRequirement
|
||||
from .tooling import ToolJob, SRSExtraction, SRSRequirement, TestingGeneration
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -15,4 +15,5 @@ __all__ = [
|
||||
"ToolJob",
|
||||
"SRSExtraction",
|
||||
"SRSRequirement",
|
||||
"TestingGeneration",
|
||||
]
|
||||
|
||||
@@ -29,6 +29,13 @@ class ToolJob(Base, TimestampMixin):
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
testing_generation = relationship(
|
||||
"TestingGeneration",
|
||||
back_populates="job",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
foreign_keys="TestingGeneration.job_id",
|
||||
)
|
||||
|
||||
|
||||
class SRSExtraction(Base, TimestampMixin):
|
||||
@@ -63,9 +70,14 @@ class SRSRequirement(Base, TimestampMixin):
|
||||
priority = Column(String(16), nullable=False, default="中")
|
||||
acceptance_criteria = Column(JSON, nullable=False)
|
||||
source_field = Column(String(255), nullable=False)
|
||||
section_uid = Column(String(64), nullable=True)
|
||||
section_number = Column(String(64), nullable=True)
|
||||
section_title = Column(String(255), nullable=True)
|
||||
requirement_type = Column(String(64), nullable=True)
|
||||
interface_name = Column(String(255), nullable=True)
|
||||
interface_type = Column(String(128), nullable=True)
|
||||
data_source = Column(String(255), nullable=True)
|
||||
data_destination = Column(String(255), nullable=True)
|
||||
sort_order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
extraction = relationship("SRSExtraction", back_populates="requirements")
|
||||
@@ -74,3 +86,19 @@ class SRSRequirement(Base, TimestampMixin):
|
||||
sa.UniqueConstraint("extraction_id", "requirement_uid", name="uq_srs_extraction_requirement_uid"),
|
||||
sa.Index("idx_srs_requirements_extraction_sort", "extraction_id", "sort_order"),
|
||||
)
|
||||
|
||||
|
||||
class TestingGeneration(Base, TimestampMixin):
|
||||
__tablename__ = "testing_generations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
job_id = Column(Integer, ForeignKey("tool_jobs.id", ondelete="CASCADE"), nullable=False, unique=True)
|
||||
source_job_id = Column(Integer, ForeignKey("tool_jobs.id"), nullable=True, index=True)
|
||||
source_document_name = Column(String(255), nullable=False)
|
||||
generated_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
total_requirements = Column(Integer, nullable=False, default=0)
|
||||
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id"), nullable=True, index=True)
|
||||
generated_file = Column(JSON, nullable=False)
|
||||
|
||||
job = relationship("ToolJob", back_populates="testing_generation", foreign_keys=[job_id])
|
||||
|
||||
|
||||
@@ -29,14 +29,18 @@ class SRSToolJobStatusResponse(BaseModel):
|
||||
|
||||
class SRSToolRequirementItem(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
priority: str
|
||||
acceptanceCriteria: List[str]
|
||||
sourceField: str
|
||||
sectionUid: Optional[str] = None
|
||||
sectionNumber: Optional[str] = None
|
||||
sectionTitle: Optional[str] = None
|
||||
requirementType: Optional[str] = None
|
||||
interfaceName: Optional[str] = None
|
||||
interfaceType: Optional[str] = None
|
||||
dataSource: Optional[str] = None
|
||||
dataDestination: Optional[str] = None
|
||||
sortOrder: int
|
||||
|
||||
|
||||
@@ -46,7 +50,72 @@ class SRSToolResultResponse(BaseModel):
|
||||
generatedAt: str
|
||||
statistics: Dict[str, Any]
|
||||
requirements: List[SRSToolRequirementItem]
|
||||
rawOutput: Dict[str, Any]
|
||||
|
||||
|
||||
class SRSToolHistoryItem(BaseModel):
|
||||
jobId: int
|
||||
documentName: str
|
||||
generatedAt: str
|
||||
totalRequirements: int
|
||||
status: str
|
||||
createdAt: str
|
||||
updatedAt: str
|
||||
|
||||
|
||||
class SRSToolRequirementsSaveRequest(BaseModel):
|
||||
requirements: List[SRSToolRequirementItem]
|
||||
|
||||
|
||||
class TestingGenerationSaveRequest(BaseModel):
|
||||
source_job_id: Optional[int] = None
|
||||
source_document_name: str
|
||||
knowledge_base_id: Optional[int] = None
|
||||
generated_file: Dict[str, Any]
|
||||
|
||||
|
||||
class TestingGenerationCreateRequest(BaseModel):
|
||||
source_job_id: Optional[int] = None
|
||||
source_document_name: str
|
||||
knowledge_base_id: Optional[int] = None
|
||||
requirements: List[SRSToolRequirementItem]
|
||||
|
||||
|
||||
class TestingGenerationCreateResponse(BaseModel):
|
||||
job_id: int
|
||||
status: str
|
||||
|
||||
|
||||
class TestingGenerationJobStatusResponse(BaseModel):
|
||||
job_id: int
|
||||
tool_name: str
|
||||
status: str
|
||||
error_message: Optional[str] = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
source_document_name: Optional[str] = None
|
||||
current_step: Optional[int] = None
|
||||
total_steps: Optional[int] = None
|
||||
current_requirement_id: Optional[str] = None
|
||||
|
||||
|
||||
class TestingGenerationResultResponse(BaseModel):
|
||||
jobId: int
|
||||
sourceJobId: Optional[int] = None
|
||||
sourceDocumentName: str
|
||||
generatedAt: str
|
||||
totalRequirements: int
|
||||
knowledgeBaseId: Optional[int] = None
|
||||
generatedFile: Dict[str, Any]
|
||||
|
||||
|
||||
class TestingGenerationHistoryItem(BaseModel):
|
||||
jobId: int
|
||||
sourceJobId: Optional[int] = None
|
||||
sourceDocumentName: str
|
||||
generatedAt: str
|
||||
totalRequirements: int
|
||||
knowledgeBaseId: Optional[int] = None
|
||||
status: str
|
||||
createdAt: str
|
||||
updatedAt: str
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -10,6 +11,45 @@ from app.db.session import SessionLocal
|
||||
from app.models.tooling import SRSExtraction, SRSRequirement, ToolJob
|
||||
from app.tools.srs_reqs_qwen import get_srs_tool
|
||||
|
||||
TYPE_TO_CHINESE = {
|
||||
"functional": "功能需求",
|
||||
"interface": "接口需求",
|
||||
"performance": "性能需求",
|
||||
"security": "安全需求",
|
||||
"reliability": "可靠性需求",
|
||||
"other": "其他需求",
|
||||
}
|
||||
|
||||
|
||||
def _build_internal_title(description: Any, fallback: str, index: int = 0) -> str:
|
||||
text = str(description or "").strip()
|
||||
if not text:
|
||||
return fallback or f"需求项 {index + 1}"
|
||||
|
||||
for separator in ("。", ";", "\n", ";", "."):
|
||||
if separator in text:
|
||||
text = text.split(separator, 1)[0].strip()
|
||||
break
|
||||
|
||||
text = text[:20].strip()
|
||||
return text or fallback or f"需求项 {index + 1}"
|
||||
|
||||
|
||||
def _normalize_requirement_type(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if text in {"functional", "interface", "performance", "security", "reliability", "other"}:
|
||||
return text
|
||||
|
||||
chinese_map = {
|
||||
"接口需求": "interface",
|
||||
"性能需求": "performance",
|
||||
"安全需求": "security",
|
||||
"可靠性需求": "reliability",
|
||||
"其他需求": "other",
|
||||
"功能需求": "functional",
|
||||
}
|
||||
return chinese_map.get(text, "functional")
|
||||
|
||||
|
||||
def run_srs_job(job_id: int) -> None:
|
||||
db = SessionLocal()
|
||||
@@ -41,14 +81,19 @@ def run_srs_job(job_id: int) -> None:
|
||||
requirement = SRSRequirement(
|
||||
extraction_id=extraction.id,
|
||||
requirement_uid=item["id"],
|
||||
title=item.get("title") or item["id"],
|
||||
title=_build_internal_title(item.get("description"), item["id"]),
|
||||
description=item.get("description") or "",
|
||||
priority=item.get("priority") or "中",
|
||||
acceptance_criteria=item.get("acceptance_criteria") or ["待补充验收标准"],
|
||||
source_field=item.get("source_field") or "文档解析",
|
||||
section_uid=item.get("section_uid"),
|
||||
section_number=item.get("section_number"),
|
||||
section_title=item.get("section_title"),
|
||||
requirement_type=item.get("requirement_type"),
|
||||
interface_name=item.get("interface_name"),
|
||||
interface_type=item.get("interface_type"),
|
||||
data_source=item.get("data_source"),
|
||||
data_destination=item.get("data_destination"),
|
||||
sort_order=int(item.get("sort_order") or 0),
|
||||
)
|
||||
db.add(requirement)
|
||||
@@ -97,22 +142,8 @@ def ensure_upload_path(job_id: int, file_name: str) -> Path:
|
||||
|
||||
|
||||
def build_result_response(job: ToolJob, extraction: SRSExtraction) -> Dict[str, Any]:
|
||||
requirements: List[Dict[str, Any]] = []
|
||||
for item in extraction.requirements:
|
||||
requirements.append(
|
||||
{
|
||||
"id": item.requirement_uid,
|
||||
"title": item.title,
|
||||
"description": item.description,
|
||||
"priority": item.priority,
|
||||
"acceptanceCriteria": item.acceptance_criteria or [],
|
||||
"sourceField": item.source_field,
|
||||
"sectionNumber": item.section_number,
|
||||
"sectionTitle": item.section_title,
|
||||
"requirementType": item.requirement_type,
|
||||
"sortOrder": item.sort_order,
|
||||
}
|
||||
)
|
||||
requirements = [_requirement_model_to_payload(item) for item in extraction.requirements]
|
||||
raw_output = _merge_updates_into_raw_output(extraction.raw_output, requirements, extraction.document_name)
|
||||
|
||||
return {
|
||||
"jobId": job.id,
|
||||
@@ -120,10 +151,12 @@ def build_result_response(job: ToolJob, extraction: SRSExtraction) -> Dict[str,
|
||||
"generatedAt": extraction.generated_at.isoformat(),
|
||||
"statistics": extraction.statistics or {},
|
||||
"requirements": requirements,
|
||||
"rawOutput": raw_output,
|
||||
}
|
||||
|
||||
|
||||
def replace_requirements(db: Session, extraction: SRSExtraction, updates: List[Dict[str, Any]]) -> None:
|
||||
normalized_updates = [_normalize_update_payload(item, index) for index, item in enumerate(updates)]
|
||||
existing = {
|
||||
req.requirement_uid: req
|
||||
for req in db.query(SRSRequirement)
|
||||
@@ -132,51 +165,95 @@ def replace_requirements(db: Session, extraction: SRSExtraction, updates: List[D
|
||||
}
|
||||
seen_ids = set()
|
||||
|
||||
for index, item in enumerate(updates):
|
||||
for index, item in enumerate(normalized_updates):
|
||||
uid = item["id"]
|
||||
seen_ids.add(uid)
|
||||
req = existing.get(uid)
|
||||
if req is None:
|
||||
description = item.get("description") if item.get("description") is not None else ""
|
||||
req = SRSRequirement(
|
||||
extraction_id=extraction.id,
|
||||
requirement_uid=uid,
|
||||
title=item.get("title") or uid,
|
||||
description=item.get("description") if item.get("description") is not None else "",
|
||||
title=_build_internal_title(description, uid, int(item.get("sortOrder") or index)),
|
||||
description=description,
|
||||
priority=item.get("priority") or "中",
|
||||
acceptance_criteria=item.get("acceptanceCriteria") or ["待补充验收标准"],
|
||||
source_field=item.get("sourceField") or "文档解析",
|
||||
section_uid=item.get("sectionUid"),
|
||||
section_number=item.get("sectionNumber"),
|
||||
section_title=item.get("sectionTitle"),
|
||||
requirement_type=item.get("requirementType"),
|
||||
sort_order=int(item.get("sortOrder") or index),
|
||||
interface_name=item.get("interfaceName"),
|
||||
interface_type=item.get("interfaceType"),
|
||||
data_source=item.get("dataSource"),
|
||||
data_destination=item.get("dataDestination"),
|
||||
sort_order=int(item.get("sortOrder") or 0),
|
||||
)
|
||||
db.add(req)
|
||||
continue
|
||||
|
||||
req.title = item.get("title", req.title)
|
||||
req.description = item.get("description", req.description)
|
||||
req.title = _build_internal_title(req.description, req.requirement_uid, int(item.get("sortOrder") or index))
|
||||
req.priority = item.get("priority", req.priority)
|
||||
req.acceptance_criteria = item.get("acceptanceCriteria", req.acceptance_criteria)
|
||||
req.source_field = item.get("sourceField", req.source_field)
|
||||
req.section_uid = item.get("sectionUid", req.section_uid)
|
||||
req.section_number = item.get("sectionNumber", req.section_number)
|
||||
req.section_title = item.get("sectionTitle", req.section_title)
|
||||
req.requirement_type = item.get("requirementType", req.requirement_type)
|
||||
req.sort_order = int(item.get("sortOrder", index))
|
||||
req.interface_name = item.get("interfaceName", req.interface_name)
|
||||
req.interface_type = item.get("interfaceType", req.interface_type)
|
||||
req.data_source = item.get("dataSource", req.data_source)
|
||||
req.data_destination = item.get("dataDestination", req.data_destination)
|
||||
req.sort_order = int(item.get("sortOrder", req.sort_order))
|
||||
|
||||
for uid, req in existing.items():
|
||||
if uid not in seen_ids:
|
||||
db.delete(req)
|
||||
|
||||
extraction.total_requirements = len(updates)
|
||||
extraction.total_requirements = len(normalized_updates)
|
||||
extraction.statistics = {
|
||||
"total": len(updates),
|
||||
"by_type": _count_requirement_types(updates),
|
||||
}
|
||||
extraction.raw_output = {
|
||||
"document_name": extraction.document_name,
|
||||
"generated_at": extraction.generated_at.isoformat(),
|
||||
"requirements": updates,
|
||||
"total": len(normalized_updates),
|
||||
"by_type": _count_requirement_types(normalized_updates),
|
||||
}
|
||||
extraction.raw_output = _merge_updates_into_raw_output(
|
||||
extraction.raw_output,
|
||||
normalized_updates,
|
||||
extraction.document_name,
|
||||
)
|
||||
|
||||
|
||||
def list_srs_history(db: Session, user_id: int) -> List[Dict[str, Any]]:
|
||||
records: List[Tuple[ToolJob, SRSExtraction]] = (
|
||||
db.query(ToolJob, SRSExtraction)
|
||||
.join(SRSExtraction, SRSExtraction.job_id == ToolJob.id)
|
||||
.filter(ToolJob.user_id == user_id)
|
||||
.order_by(ToolJob.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
items: List[Dict[str, Any]] = []
|
||||
for job, extraction in records:
|
||||
items.append(
|
||||
{
|
||||
"jobId": job.id,
|
||||
"documentName": extraction.document_name,
|
||||
"generatedAt": extraction.generated_at.isoformat(),
|
||||
"totalRequirements": extraction.total_requirements,
|
||||
"status": job.status,
|
||||
"createdAt": job.created_at.isoformat(),
|
||||
"updatedAt": job.updated_at.isoformat(),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def delete_srs_job(db: Session, job: ToolJob) -> None:
|
||||
db.delete(job)
|
||||
db.commit()
|
||||
|
||||
|
||||
def build_srs_upload_path(job_id: int) -> Path:
|
||||
return Path("uploads") / "srs_jobs" / str(job_id)
|
||||
|
||||
|
||||
def _count_requirement_types(items: List[Dict[str, Any]]) -> Dict[str, int]:
|
||||
@@ -185,3 +262,210 @@ def _count_requirement_types(items: List[Dict[str, Any]]) -> Dict[str, int]:
|
||||
req_type = item.get("requirementType") or "functional"
|
||||
stats[req_type] = stats.get(req_type, 0) + 1
|
||||
return stats
|
||||
|
||||
|
||||
def _requirement_model_to_payload(item: SRSRequirement) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": item.requirement_uid,
|
||||
"description": item.description,
|
||||
"priority": item.priority,
|
||||
"acceptanceCriteria": item.acceptance_criteria or [],
|
||||
"sourceField": item.source_field,
|
||||
"sectionUid": item.section_uid,
|
||||
"sectionNumber": item.section_number,
|
||||
"sectionTitle": item.section_title,
|
||||
"requirementType": item.requirement_type,
|
||||
"interfaceName": item.interface_name,
|
||||
"interfaceType": item.interface_type,
|
||||
"dataSource": item.data_source,
|
||||
"dataDestination": item.data_destination,
|
||||
"sortOrder": item.sort_order,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_update_payload(item: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
requirement_type = _normalize_requirement_type(item.get("requirementType"))
|
||||
normalized = {
|
||||
"id": str(item.get("id") or f"REQ-{index + 1:03d}"),
|
||||
"description": str(item.get("description") or "").strip(),
|
||||
"priority": item.get("priority") or "中",
|
||||
"acceptanceCriteria": item.get("acceptanceCriteria") or ["待补充验收标准"],
|
||||
"sourceField": item.get("sourceField") or "文档解析",
|
||||
"sectionUid": item.get("sectionUid"),
|
||||
"sectionNumber": item.get("sectionNumber"),
|
||||
"sectionTitle": item.get("sectionTitle"),
|
||||
"requirementType": requirement_type,
|
||||
"interfaceName": item.get("interfaceName") or "",
|
||||
"interfaceType": item.get("interfaceType") or "",
|
||||
"dataSource": item.get("dataSource") or "",
|
||||
"dataDestination": item.get("dataDestination") or "",
|
||||
"sortOrder": int(item.get("sortOrder") or index),
|
||||
}
|
||||
|
||||
if requirement_type != "interface":
|
||||
normalized["interfaceName"] = ""
|
||||
normalized["interfaceType"] = ""
|
||||
normalized["dataSource"] = ""
|
||||
normalized["dataDestination"] = ""
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _merge_updates_into_raw_output(
|
||||
raw_output: Dict[str, Any] | None,
|
||||
updates: List[Dict[str, Any]],
|
||||
document_name: str,
|
||||
) -> Dict[str, Any]:
|
||||
if not isinstance(raw_output, dict) or "需求内容" not in raw_output:
|
||||
return _build_raw_output_from_flat(updates, document_name)
|
||||
|
||||
result = deepcopy(raw_output)
|
||||
content = result.get("需求内容")
|
||||
if not isinstance(content, dict):
|
||||
return _build_raw_output_from_flat(updates, document_name)
|
||||
|
||||
updates_by_section = _group_updates_by_section(updates)
|
||||
_rewrite_content_requirements(content, updates_by_section)
|
||||
_append_unmatched_sections(content, updates_by_section)
|
||||
_refresh_metadata(result, updates)
|
||||
return result
|
||||
|
||||
|
||||
def _group_updates_by_section(updates: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
|
||||
grouped: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for item in updates:
|
||||
key = _section_key(item.get("sectionUid"), item.get("sectionNumber"), item.get("sectionTitle"))
|
||||
grouped.setdefault(key, []).append(item)
|
||||
|
||||
for values in grouped.values():
|
||||
values.sort(key=lambda value: int(value.get("sortOrder") or 0))
|
||||
return grouped
|
||||
|
||||
|
||||
def _rewrite_content_requirements(content: Dict[str, Any], updates_by_section: Dict[str, List[Dict[str, Any]]]) -> None:
|
||||
for section in content.values():
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
|
||||
section_info = section.get("章节信息") or {}
|
||||
section_key = _section_key(
|
||||
section_info.get("章节UID"),
|
||||
section_info.get("章节编号"),
|
||||
section_info.get("章节标题"),
|
||||
)
|
||||
if section_key in updates_by_section:
|
||||
section["需求列表"] = [_to_raw_requirement_item(item) for item in updates_by_section.pop(section_key)]
|
||||
elif "需求列表" in section:
|
||||
section["需求列表"] = []
|
||||
|
||||
children = section.get("子章节")
|
||||
if isinstance(children, dict):
|
||||
_rewrite_content_requirements(children, updates_by_section)
|
||||
|
||||
|
||||
def _append_unmatched_sections(content: Dict[str, Any], updates_by_section: Dict[str, List[Dict[str, Any]]]) -> None:
|
||||
if not updates_by_section:
|
||||
return
|
||||
|
||||
orphan_key = "未归类章节"
|
||||
orphan_section = content.get(orphan_key)
|
||||
if not isinstance(orphan_section, dict):
|
||||
orphan_section = {
|
||||
"章节信息": {
|
||||
"章节编号": "",
|
||||
"章节标题": orphan_key,
|
||||
"章节级别": 1,
|
||||
},
|
||||
"需求列表": [],
|
||||
}
|
||||
content[orphan_key] = orphan_section
|
||||
|
||||
all_reqs: List[Dict[str, Any]] = orphan_section.get("需求列表") or []
|
||||
for values in updates_by_section.values():
|
||||
for item in values:
|
||||
all_reqs.append(_to_raw_requirement_item(item))
|
||||
orphan_section["需求列表"] = all_reqs
|
||||
updates_by_section.clear()
|
||||
|
||||
|
||||
def _refresh_metadata(raw_output: Dict[str, Any], updates: List[Dict[str, Any]]) -> None:
|
||||
metadata = raw_output.get("文档元数据")
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
raw_output["文档元数据"] = metadata
|
||||
|
||||
metadata["总需求数"] = len(updates)
|
||||
metadata["生成时间"] = datetime.now().isoformat()
|
||||
|
||||
type_stats: Dict[str, int] = {}
|
||||
for item in updates:
|
||||
req_type = item.get("requirementType") or "functional"
|
||||
cn_type = TYPE_TO_CHINESE.get(req_type, "其他需求")
|
||||
type_stats[cn_type] = type_stats.get(cn_type, 0) + 1
|
||||
metadata["需求类型统计"] = type_stats
|
||||
|
||||
|
||||
def _to_raw_requirement_item(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
req_type = item.get("requirementType") or "functional"
|
||||
raw_item = {
|
||||
"需求类型": TYPE_TO_CHINESE.get(req_type, "其他需求"),
|
||||
"需求编号": item.get("id") or "",
|
||||
"需求描述": item.get("description") or "",
|
||||
"优先级": item.get("priority") or "中",
|
||||
}
|
||||
if req_type == "interface":
|
||||
raw_item["接口名称"] = item.get("interfaceName") or ""
|
||||
raw_item["接口类型"] = item.get("interfaceType") or ""
|
||||
raw_item["数据来源"] = item.get("dataSource") or ""
|
||||
raw_item["数据目的地"] = item.get("dataDestination") or ""
|
||||
return raw_item
|
||||
|
||||
|
||||
def _build_raw_output_from_flat(updates: List[Dict[str, Any]], document_name: str) -> Dict[str, Any]:
|
||||
grouped = _group_updates_by_section(updates)
|
||||
content: Dict[str, Any] = {}
|
||||
for key, values in grouped.items():
|
||||
number, title = _parse_section_key(key)
|
||||
section_title = title or "未归类章节"
|
||||
display_key = f"{number} {section_title}".strip()
|
||||
content[display_key] = {
|
||||
"章节信息": {
|
||||
"章节编号": number,
|
||||
"章节标题": section_title,
|
||||
"章节级别": max(len(number.split(".")), 1) if number else 1,
|
||||
},
|
||||
"需求列表": [_to_raw_requirement_item(item) for item in values],
|
||||
}
|
||||
|
||||
raw_output = {
|
||||
"文档元数据": {
|
||||
"标题": document_name,
|
||||
"生成时间": datetime.now().isoformat(),
|
||||
"总需求数": len(updates),
|
||||
"需求类型统计": {},
|
||||
},
|
||||
"需求内容": content,
|
||||
}
|
||||
_refresh_metadata(raw_output, updates)
|
||||
return raw_output
|
||||
|
||||
|
||||
def _section_key(section_uid: Any, section_number: Any, section_title: Any) -> str:
|
||||
uid = str(section_uid or "").strip()
|
||||
if uid:
|
||||
return f"uid::{uid}"
|
||||
number = str(section_number or "").strip()
|
||||
title = str(section_title or "").strip()
|
||||
return f"number::{number}::title::{title}"
|
||||
|
||||
|
||||
def _parse_section_key(value: str) -> Tuple[str, str]:
|
||||
if value.startswith("uid::"):
|
||||
return "", "未归类章节"
|
||||
number = ""
|
||||
title = ""
|
||||
parts = value.split("::")
|
||||
if len(parts) >= 4:
|
||||
number = parts[1]
|
||||
title = parts[3]
|
||||
return number, title
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.knowledge import Document, KnowledgeBase
|
||||
from app.models.tooling import TestingGeneration, ToolJob
|
||||
from app.services.embedding.embedding_factory import EmbeddingsFactory
|
||||
from app.services.retrieval.multi_kb_retriever import MultiKBRetriever, format_retrieval_context
|
||||
from app.services.testing_pipeline import run_testing_pipeline
|
||||
from app.services.vector_store import VectorStoreFactory
|
||||
|
||||
def _flatten_record(value: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
||||
items: List[Dict[str, Any]] = []
|
||||
for current in value.values():
|
||||
items.extend(current)
|
||||
return items
|
||||
|
||||
|
||||
def _build_kb_vector_stores(db: Session, knowledge_bases: List[KnowledgeBase]) -> List[Dict[str, Any]]:
|
||||
embeddings = EmbeddingsFactory.create()
|
||||
kb_vector_stores: List[Dict[str, Any]] = []
|
||||
|
||||
for kb in knowledge_bases:
|
||||
documents = db.query(Document).filter(Document.knowledge_base_id == kb.id).all()
|
||||
if not documents:
|
||||
continue
|
||||
|
||||
store = VectorStoreFactory.create(
|
||||
store_type=settings.VECTOR_STORE_TYPE,
|
||||
collection_name=f"kb_{kb.id}",
|
||||
embedding_function=embeddings,
|
||||
)
|
||||
kb_vector_stores.append({"kb_id": kb.id, "store": store})
|
||||
|
||||
return kb_vector_stores
|
||||
|
||||
|
||||
def _resolve_knowledge_context(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int,
|
||||
requirement_text: str,
|
||||
knowledge_base_id: int | None,
|
||||
) -> str:
|
||||
if knowledge_base_id is None:
|
||||
return ""
|
||||
|
||||
try:
|
||||
knowledge_bases = (
|
||||
db.query(KnowledgeBase)
|
||||
.filter(
|
||||
KnowledgeBase.id == knowledge_base_id,
|
||||
KnowledgeBase.user_id == user_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
kb_vector_stores = _build_kb_vector_stores(db, knowledge_bases)
|
||||
if not kb_vector_stores:
|
||||
return ""
|
||||
|
||||
retriever = MultiKBRetriever(
|
||||
reranker_weight=settings.RERANKER_WEIGHT,
|
||||
)
|
||||
rows = asyncio.run(
|
||||
retriever.retrieve(
|
||||
query=requirement_text,
|
||||
kb_vector_stores=kb_vector_stores,
|
||||
fetch_k_per_kb=16,
|
||||
top_k=8,
|
||||
)
|
||||
)
|
||||
if rows:
|
||||
return format_retrieval_context(rows)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _build_generated_requirement(req: Dict[str, Any], pipeline_result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
test_items = [
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"content": item.get("content"),
|
||||
}
|
||||
for item in _flatten_record(pipeline_result.get("test_items", {}))
|
||||
]
|
||||
test_cases = [
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"itemId": item.get("item_id"),
|
||||
"testContent": item.get("test_content"),
|
||||
"operationSteps": item.get("operation_steps", []),
|
||||
"expectedResultPlaceholder": item.get("expected_result_placeholder"),
|
||||
}
|
||||
for item in _flatten_record(pipeline_result.get("test_cases", {}))
|
||||
]
|
||||
expected_results = [
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"caseId": item.get("case_id"),
|
||||
"result": item.get("result"),
|
||||
}
|
||||
for item in _flatten_record(pipeline_result.get("expected_results", {}))
|
||||
]
|
||||
|
||||
return {
|
||||
**req,
|
||||
"测试项": test_items,
|
||||
"测试用例": test_cases,
|
||||
"预期结果": expected_results,
|
||||
}
|
||||
|
||||
|
||||
def _mark_job_failed(job_id: int, error_message: str) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
job = db.query(ToolJob).filter(ToolJob.id == job_id).first()
|
||||
if not job:
|
||||
return
|
||||
job.status = "failed"
|
||||
job.completed_at = datetime.utcnow()
|
||||
job.error_message = error_message[:2000]
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def run_testing_generation_job(job_id: int, payload: Dict[str, Any]) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
job = db.query(ToolJob).filter(ToolJob.id == job_id).first()
|
||||
if not job:
|
||||
return
|
||||
|
||||
requirements = payload.get("requirements") or []
|
||||
source_document_name = str(payload.get("source_document_name") or job.input_file_name or "")
|
||||
source_job_id = payload.get("source_job_id")
|
||||
knowledge_base_id = payload.get("knowledge_base_id")
|
||||
|
||||
job.status = "processing"
|
||||
job.started_at = datetime.utcnow()
|
||||
job.error_message = None
|
||||
job.output_summary = {
|
||||
"source_document_name": source_document_name,
|
||||
"current_step": 0,
|
||||
"total_steps": len(requirements),
|
||||
}
|
||||
db.commit()
|
||||
|
||||
generated_requirements: List[Dict[str, Any]] = []
|
||||
|
||||
for index, req in enumerate(requirements):
|
||||
req_id = str(req.get("id") or f"REQ-{index + 1:03d}")
|
||||
job.output_summary = {
|
||||
"source_document_name": source_document_name,
|
||||
"current_step": index + 1,
|
||||
"total_steps": len(requirements),
|
||||
"current_requirement_id": req_id,
|
||||
}
|
||||
db.commit()
|
||||
|
||||
description = str(req.get("description") or "").strip()
|
||||
if not description:
|
||||
generated_requirements.append(
|
||||
{
|
||||
**req,
|
||||
"测试项": [],
|
||||
"测试用例": [],
|
||||
"预期结果": [],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
knowledge_context = _resolve_knowledge_context(
|
||||
db,
|
||||
user_id=job.user_id,
|
||||
requirement_text=description,
|
||||
knowledge_base_id=knowledge_base_id,
|
||||
)
|
||||
|
||||
pipeline_result = run_testing_pipeline(
|
||||
user_requirement_text=description,
|
||||
requirement_type_input=req.get("requirementType"),
|
||||
debug=False,
|
||||
knowledge_context=knowledge_context,
|
||||
use_model_generation=True,
|
||||
max_items_per_group=12,
|
||||
cases_per_item=2,
|
||||
max_focus_points=6,
|
||||
max_llm_calls=10,
|
||||
)
|
||||
generated_requirements.append(_build_generated_requirement(req, pipeline_result))
|
||||
|
||||
generated_at = datetime.utcnow()
|
||||
generated_file = {
|
||||
"sourceDocument": source_document_name,
|
||||
"sourceJobId": source_job_id,
|
||||
"generatedAt": generated_at.isoformat(),
|
||||
"totalRequirements": len(generated_requirements),
|
||||
"knowledgeBaseId": knowledge_base_id,
|
||||
"requirements": generated_requirements,
|
||||
}
|
||||
|
||||
generation = TestingGeneration(
|
||||
job_id=job.id,
|
||||
source_job_id=source_job_id,
|
||||
source_document_name=source_document_name,
|
||||
generated_at=generated_at,
|
||||
total_requirements=len(generated_requirements),
|
||||
knowledge_base_id=knowledge_base_id,
|
||||
generated_file=generated_file,
|
||||
)
|
||||
db.add(generation)
|
||||
|
||||
job.status = "completed"
|
||||
job.completed_at = datetime.utcnow()
|
||||
job.output_summary = {
|
||||
"source_document_name": source_document_name,
|
||||
"current_step": len(generated_requirements),
|
||||
"total_steps": len(generated_requirements),
|
||||
"total_requirements": len(generated_requirements),
|
||||
"knowledge_base_id": knowledge_base_id,
|
||||
}
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
_mark_job_failed(job_id, str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
111
rag-web-ui/backend/app/services/testing_generation_service.py
Normal file
111
rag-web-ui/backend/app/services/testing_generation_service.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.tooling import TestingGeneration, ToolJob
|
||||
from app.schemas.tooling import TestingGenerationSaveRequest
|
||||
|
||||
TESTING_TOOL_NAME = "testing.case_generator"
|
||||
|
||||
|
||||
def _resolve_total_requirements(generated_file: Dict[str, Any]) -> int:
|
||||
requirements = generated_file.get("requirements")
|
||||
if isinstance(requirements, list):
|
||||
return len(requirements)
|
||||
|
||||
total = generated_file.get("totalRequirements")
|
||||
if isinstance(total, int) and total >= 0:
|
||||
return total
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def build_testing_generation_response(job: ToolJob, generation: TestingGeneration) -> Dict[str, Any]:
|
||||
return {
|
||||
"jobId": job.id,
|
||||
"sourceJobId": generation.source_job_id,
|
||||
"sourceDocumentName": generation.source_document_name,
|
||||
"generatedAt": generation.generated_at.isoformat(),
|
||||
"totalRequirements": generation.total_requirements,
|
||||
"knowledgeBaseId": generation.knowledge_base_id,
|
||||
"generatedFile": generation.generated_file or {},
|
||||
}
|
||||
|
||||
|
||||
def create_testing_generation(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
payload: TestingGenerationSaveRequest,
|
||||
) -> Dict[str, Any]:
|
||||
now = datetime.utcnow()
|
||||
total_requirements = _resolve_total_requirements(payload.generated_file)
|
||||
|
||||
job = ToolJob(
|
||||
user_id=user_id,
|
||||
tool_name=TESTING_TOOL_NAME,
|
||||
status="completed",
|
||||
input_file_name=payload.source_document_name,
|
||||
input_file_path="",
|
||||
started_at=now,
|
||||
completed_at=now,
|
||||
output_summary={
|
||||
"source_document_name": payload.source_document_name,
|
||||
"total_requirements": total_requirements,
|
||||
"knowledge_base_id": payload.knowledge_base_id,
|
||||
},
|
||||
)
|
||||
db.add(job)
|
||||
db.flush()
|
||||
|
||||
generation = TestingGeneration(
|
||||
job_id=job.id,
|
||||
source_job_id=payload.source_job_id,
|
||||
source_document_name=payload.source_document_name,
|
||||
generated_at=now,
|
||||
total_requirements=total_requirements,
|
||||
knowledge_base_id=payload.knowledge_base_id,
|
||||
generated_file=payload.generated_file,
|
||||
)
|
||||
db.add(generation)
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
db.refresh(generation)
|
||||
|
||||
return build_testing_generation_response(job, generation)
|
||||
|
||||
|
||||
def list_testing_history(db: Session, user_id: int) -> List[Dict[str, Any]]:
|
||||
rows: List[Tuple[ToolJob, TestingGeneration]] = (
|
||||
db.query(ToolJob, TestingGeneration)
|
||||
.join(TestingGeneration, TestingGeneration.job_id == ToolJob.id)
|
||||
.filter(
|
||||
ToolJob.user_id == user_id,
|
||||
ToolJob.tool_name == TESTING_TOOL_NAME,
|
||||
)
|
||||
.order_by(ToolJob.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for job, generation in rows:
|
||||
items.append(
|
||||
{
|
||||
"jobId": job.id,
|
||||
"sourceJobId": generation.source_job_id,
|
||||
"sourceDocumentName": generation.source_document_name,
|
||||
"generatedAt": generation.generated_at.isoformat(),
|
||||
"totalRequirements": generation.total_requirements,
|
||||
"knowledgeBaseId": generation.knowledge_base_id,
|
||||
"status": job.status,
|
||||
"createdAt": job.created_at.isoformat(),
|
||||
"updatedAt": job.updated_at.isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def delete_testing_generation(db: Session, job: ToolJob) -> None:
|
||||
db.delete(job)
|
||||
db.commit()
|
||||
@@ -4,7 +4,6 @@ from time import perf_counter
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from app.services.llm.llm_factory import LLMFactory
|
||||
from app.services.testing_pipeline.tools import build_default_tool_chain
|
||||
|
||||
|
||||
@@ -42,6 +41,8 @@ def run_testing_pipeline(
|
||||
llm_model = None
|
||||
if use_model_generation:
|
||||
try:
|
||||
from app.services.llm.llm_factory import LLMFactory
|
||||
|
||||
llm_model = LLMFactory.create(streaming=False)
|
||||
except Exception:
|
||||
llm_model = None
|
||||
|
||||
@@ -137,6 +137,19 @@ class DocumentParser(ABC):
|
||||
chinese_numbers = '一二三四五六七八九十百千万'
|
||||
return text and all(c in chinese_numbers for c in text)
|
||||
|
||||
def _section_sort_key(self, section: 'Section') -> Tuple[int, List[int], str]:
|
||||
number = (section.number or "").strip()
|
||||
if number and re.match(r'^\d+(?:\.\d+)*$', number):
|
||||
return (0, [int(part) for part in number.split('.')], section.title or "")
|
||||
return (1, [section.level], section.title or "")
|
||||
|
||||
def _sort_sections_by_number(self, sections: List['Section']) -> List['Section']:
|
||||
ordered = sorted(sections, key=self._section_sort_key)
|
||||
for section in ordered:
|
||||
if section.children:
|
||||
section.children = self._sort_sections_by_number(section.children)
|
||||
return ordered
|
||||
|
||||
|
||||
class DocxParser(DocumentParser):
|
||||
"""DOCX格式文档解析器"""
|
||||
@@ -210,6 +223,7 @@ class DocxParser(DocumentParser):
|
||||
|
||||
# 为没有编号的章节自动生成编号
|
||||
self._auto_number_sections(self.sections)
|
||||
self.sections = self._sort_sections_by_number(self.sections)
|
||||
|
||||
logger.info(f"完成Docx解析,提取{len(self.sections)}个顶级章节")
|
||||
return self.sections
|
||||
@@ -236,12 +250,17 @@ class DocxParser(DocumentParser):
|
||||
"""解析标题,返回(编号, 标题, 级别)"""
|
||||
style_name = paragraph.style.name if paragraph.style else ""
|
||||
is_heading_style = style_name.lower().startswith('heading') if style_name else False
|
||||
|
||||
if self._is_calendar_line(text):
|
||||
return None
|
||||
|
||||
# 数字编号标题
|
||||
match = re.match(r'^(\d+(?:\.\d+)*)\s*[\.、]?\s*(.+)$', text)
|
||||
match = re.match(r'^(\d+(?:\.\d+)*)\s*[\.、.))::\-_/]?\s*(.+)$', text)
|
||||
if match and self._is_valid_heading(match.group(2)):
|
||||
number = match.group(1)
|
||||
title = match.group(2).strip()
|
||||
if not self._is_valid_numbered_heading(number, title):
|
||||
return None
|
||||
level = len(number.split('.'))
|
||||
return number, title, level
|
||||
|
||||
@@ -263,6 +282,31 @@ class DocxParser(DocumentParser):
|
||||
|
||||
return None
|
||||
|
||||
def _is_calendar_line(self, text: str) -> bool:
|
||||
value = (text or "").strip().replace(" ", "")
|
||||
return bool(re.match(r'^\d{4}年\d{1,2}月(?:\d{1,2}日)?$', value))
|
||||
|
||||
def _is_valid_numbered_heading(self, number: str, title: str) -> bool:
|
||||
parts = number.split('.')
|
||||
if len(parts) > 6:
|
||||
return False
|
||||
|
||||
first = int(parts[0])
|
||||
if first < 1 or first > 30:
|
||||
return False
|
||||
|
||||
for part in parts[1:]:
|
||||
if int(part) > 30:
|
||||
return False
|
||||
|
||||
if len(parts) == 1 and re.match(r'^年\d{1,2}月', title):
|
||||
return False
|
||||
|
||||
if title and title[0].isdigit():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _iter_block_items(self, parent):
|
||||
"""按文档顺序迭代段落和表格"""
|
||||
from docx.text.paragraph import Paragraph
|
||||
@@ -356,6 +400,7 @@ class PDFParser(DocumentParser):
|
||||
|
||||
# 6. 为没有编号的章节自动生成编号
|
||||
self._auto_number_sections(self.sections)
|
||||
self.sections = self._sort_sections_by_number(self.sections)
|
||||
|
||||
logger.info(f"完成PDF解析,提取{len(self.sections)}个顶级章节")
|
||||
return self.sections
|
||||
@@ -599,18 +644,6 @@ class PDFParser(DocumentParser):
|
||||
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
|
||||
|
||||
@@ -645,10 +678,6 @@ 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:
|
||||
@@ -670,7 +699,10 @@ class PDFParser(DocumentParser):
|
||||
(章节编号, 章节标题) 或 None
|
||||
"""
|
||||
# 模式: "3.1 功能需求" / "3.1.2 电场..."
|
||||
match = re.match(r'^(\d+(?:\.\d+)*)[\s、.))]*(.+)$', line)
|
||||
if self._is_calendar_line(line):
|
||||
return None
|
||||
|
||||
match = re.match(r'^(\d+(?:\.\d+)*)[\s、..))::\-_/]*(.+)$', line)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
@@ -692,7 +724,7 @@ class PDFParser(DocumentParser):
|
||||
|
||||
# 检查子部分是否合理
|
||||
for part in parts[1:]:
|
||||
if int(part) > 20:
|
||||
if int(part) > 30:
|
||||
return None
|
||||
|
||||
# 避免重复
|
||||
@@ -747,6 +779,10 @@ class PDFParser(DocumentParser):
|
||||
|
||||
return (number, title)
|
||||
|
||||
def _is_calendar_line(self, text: str) -> bool:
|
||||
value = (text or "").strip().replace(" ", "")
|
||||
return bool(re.match(r'^\d{4}年\d{1,2}月(?:\d{1,2}日)?$', value))
|
||||
|
||||
def _looks_like_statement(self, title: str) -> bool:
|
||||
"""判断标题是否更像正文语句而非章节名。"""
|
||||
if not title:
|
||||
|
||||
@@ -51,6 +51,8 @@ class SRSTool:
|
||||
"other": "低",
|
||||
}
|
||||
|
||||
UNKNOWN_INTERFACE_VALUES = {"", "未知", "unknown", "n/a", "-", "--", "无", "none", "null"}
|
||||
|
||||
def __init__(self) -> None:
|
||||
ToolRegistry.register(self.DEFINITION)
|
||||
|
||||
@@ -90,24 +92,78 @@ class SRSTool:
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
for index, req in enumerate(extracted, start=1):
|
||||
description = (req.description or "").strip()
|
||||
title = description[:40] if description else f"需求项 {index}"
|
||||
title = self._build_short_title(description, index)
|
||||
requirement_type = self._normalize_requirement_type(
|
||||
req_type=getattr(req, "type", "functional"),
|
||||
interface_name=getattr(req, "interface_name", ""),
|
||||
interface_type=getattr(req, "interface_type", ""),
|
||||
data_source=getattr(req, "source", ""),
|
||||
data_destination=getattr(req, "destination", ""),
|
||||
)
|
||||
source_field = f"{req.section_number} {req.section_title}".strip() or "文档解析"
|
||||
normalized.append(
|
||||
{
|
||||
"id": req.id,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"priority": self.PRIORITY_BY_TYPE.get(req.type, "中"),
|
||||
"priority": "中",
|
||||
"acceptance_criteria": [description] if description else ["待补充验收标准"],
|
||||
"source_field": source_field,
|
||||
"section_uid": req.section_uid,
|
||||
"section_number": req.section_number,
|
||||
"section_title": req.section_title,
|
||||
"requirement_type": req.type,
|
||||
"requirement_type": requirement_type,
|
||||
"interface_name": req.interface_name if requirement_type == "interface" else "",
|
||||
"interface_type": req.interface_type if requirement_type == "interface" else "",
|
||||
"data_source": req.source if requirement_type == "interface" else "",
|
||||
"data_destination": req.destination if requirement_type == "interface" else "",
|
||||
"sort_order": index,
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
def _normalize_requirement_type(
|
||||
self,
|
||||
req_type: Any,
|
||||
interface_name: Any,
|
||||
interface_type: Any,
|
||||
data_source: Any,
|
||||
data_destination: Any,
|
||||
) -> str:
|
||||
raw_type = str(req_type or "").strip()
|
||||
mapping = {
|
||||
"功能需求": "functional",
|
||||
"接口需求": "interface",
|
||||
"性能需求": "performance",
|
||||
"安全需求": "security",
|
||||
"可靠性需求": "reliability",
|
||||
"其他需求": "other",
|
||||
}
|
||||
normalized_type = mapping.get(raw_type, raw_type)
|
||||
if normalized_type not in self.PRIORITY_BY_TYPE:
|
||||
normalized_type = "functional"
|
||||
|
||||
fields = [interface_name, interface_type, data_source, data_destination]
|
||||
has_interface_fields = any(
|
||||
str(value or "").strip().lower() not in self.UNKNOWN_INTERFACE_VALUES for value in fields
|
||||
)
|
||||
|
||||
if normalized_type == "interface" or has_interface_fields:
|
||||
return "interface"
|
||||
return normalized_type
|
||||
|
||||
def _build_short_title(self, description: str, index: int) -> str:
|
||||
text = (description or "").strip()
|
||||
if not text:
|
||||
return f"需求项 {index}"
|
||||
for separator in ("。", ";", "\n", ";", "."):
|
||||
if separator in text:
|
||||
text = text.split(separator, 1)[0].strip()
|
||||
break
|
||||
if len(text) <= 20:
|
||||
return text
|
||||
return f"{text[:20].rstrip()}"
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
config_path = Path(__file__).with_name("default_config.yaml")
|
||||
if config_path.exists():
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function NewChatPage() {
|
||||
开始对话前,请先创建至少一个知识库。
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/knowledge"
|
||||
href="/dashboard/knowledge/document"
|
||||
className="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import {
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileJson,
|
||||
FileText,
|
||||
History,
|
||||
Loader2,
|
||||
Save,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import DashboardLayout from "@/components/layout/dashboard-layout";
|
||||
@@ -23,6 +26,23 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
downloadJson,
|
||||
@@ -35,11 +55,15 @@ import {
|
||||
} from "@/lib/document-mock";
|
||||
import {
|
||||
createSrsJob,
|
||||
deleteSrsJob,
|
||||
getSrsJobResult,
|
||||
getSrsJobStatus,
|
||||
listSrsHistory,
|
||||
saveSrsRequirements,
|
||||
SrsHistoryItem,
|
||||
toExtractionResult,
|
||||
} from "@/lib/srs-tools-api";
|
||||
import { buildSectionTree, rebuildRawOutput, SectionTreeNode } from "@/lib/srs-json";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const formatDateTime = (value: string) => {
|
||||
@@ -69,6 +93,89 @@ const wait = (ms: number) =>
|
||||
|
||||
const EXTRACTION_JOB_KEY = "doc_processing_extraction_job_id";
|
||||
|
||||
const normalizeRequirementType = (value: unknown): RequirementItem["requirementType"] => {
|
||||
if (
|
||||
value === "functional" ||
|
||||
value === "interface" ||
|
||||
value === "performance" ||
|
||||
value === "security" ||
|
||||
value === "reliability" ||
|
||||
value === "other"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (value === "接口需求") {
|
||||
return "interface";
|
||||
}
|
||||
if (value === "性能需求") {
|
||||
return "performance";
|
||||
}
|
||||
if (value === "安全需求") {
|
||||
return "security";
|
||||
}
|
||||
if (value === "可靠性需求") {
|
||||
return "reliability";
|
||||
}
|
||||
if (value === "其他需求") {
|
||||
return "other";
|
||||
}
|
||||
return "functional";
|
||||
};
|
||||
|
||||
const hasInterfaceMetadata = (item: RequirementItem) => {
|
||||
const candidates = [item.interfaceName, item.interfaceType, item.dataSource, item.dataDestination];
|
||||
return candidates.some((field) => {
|
||||
const value = (field || "").trim();
|
||||
return Boolean(value) && !["未知", "unknown", "n/a", "-", "--", "无"].includes(value.toLowerCase());
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeRequirement = (item: RequirementItem, index: number): RequirementItem => {
|
||||
const normalizedType = normalizeRequirementType(item.requirementType);
|
||||
const requirementType =
|
||||
normalizedType === "interface" || hasInterfaceMetadata(item) ? "interface" : normalizedType;
|
||||
|
||||
return {
|
||||
...item,
|
||||
priority: item.priority || "中",
|
||||
requirementType,
|
||||
sectionNumber: item.sectionNumber || "",
|
||||
sectionTitle: item.sectionTitle || "未归类章节",
|
||||
sortOrder: typeof item.sortOrder === "number" ? item.sortOrder : index,
|
||||
interfaceName: requirementType === "interface" ? item.interfaceName || "" : "",
|
||||
interfaceType: requirementType === "interface" ? item.interfaceType || "" : "",
|
||||
dataSource: requirementType === "interface" ? item.dataSource || "" : "",
|
||||
dataDestination: requirementType === "interface" ? item.dataDestination || "" : "",
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeExtractionResult = (
|
||||
extraction: RequirementExtractionResult
|
||||
): RequirementExtractionResult => {
|
||||
const requirements = extraction.requirements.map((item, index) =>
|
||||
normalizeRequirement(item, index)
|
||||
);
|
||||
return {
|
||||
...extraction,
|
||||
requirements,
|
||||
rawOutput: rebuildRawOutput(
|
||||
extraction.rawOutput as Record<string, unknown> | undefined,
|
||||
requirements,
|
||||
extraction.documentName
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const collectSectionKeys = (nodes: SectionTreeNode[]): string[] => {
|
||||
const keys: string[] = [];
|
||||
const walk = (node: SectionTreeNode) => {
|
||||
keys.push(node.key);
|
||||
node.children.forEach((child) => walk(child));
|
||||
};
|
||||
nodes.forEach((node) => walk(node));
|
||||
return keys;
|
||||
};
|
||||
|
||||
export default function RequirementExtractionPage() {
|
||||
const [documentFile, setDocumentFile] = useState<File | null>(null);
|
||||
const [extraction, setExtraction] = useState<RequirementExtractionResult | null>(
|
||||
@@ -81,9 +188,35 @@ export default function RequirementExtractionPage() {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [activeJobId, setActiveJobId] = useState<number | null>(null);
|
||||
const [isImportingJson, setIsImportingJson] = useState(false);
|
||||
const [historyItems, setHistoryItems] = useState<SrsHistoryItem[]>([]);
|
||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
|
||||
const [expandedSectionKeys, setExpandedSectionKeys] = useState<string[]>([]);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
const jsonInputRef = useRef<HTMLInputElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const setExtractionAndPersist = useCallback((value: RequirementExtractionResult | null) => {
|
||||
if (!value) {
|
||||
setExtraction(null);
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeExtractionResult(value);
|
||||
setExtraction(normalized);
|
||||
saveExtractionDraft(normalized);
|
||||
}, []);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
setIsHistoryLoading(true);
|
||||
try {
|
||||
const items = await listSrsHistory();
|
||||
setHistoryItems(items);
|
||||
} catch {
|
||||
setHistoryItems([]);
|
||||
} finally {
|
||||
setIsHistoryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const storedJobId = window.localStorage.getItem(EXTRACTION_JOB_KEY);
|
||||
if (storedJobId) {
|
||||
@@ -95,12 +228,15 @@ export default function RequirementExtractionPage() {
|
||||
|
||||
const draft = loadExtractionDraft();
|
||||
if (!draft) {
|
||||
void loadHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
setExtraction(draft);
|
||||
const normalized = normalizeExtractionResult(draft);
|
||||
setExtraction(normalized);
|
||||
setSelectedRequirementId(draft.requirements[0]?.id ?? null);
|
||||
}, []);
|
||||
void loadHistory();
|
||||
}, [loadHistory]);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
const first = acceptedFiles[0];
|
||||
@@ -125,24 +261,74 @@ export default function RequirementExtractionPage() {
|
||||
(item) => item.id === selectedRequirementId
|
||||
);
|
||||
|
||||
const sectionTree = useMemo(() => {
|
||||
if (!extraction) {
|
||||
return [];
|
||||
}
|
||||
return buildSectionTree(
|
||||
extraction.rawOutput as Record<string, unknown> | undefined,
|
||||
extraction.requirements
|
||||
);
|
||||
}, [extraction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionTree.length) {
|
||||
setExpandedSectionKeys([]);
|
||||
return;
|
||||
}
|
||||
setExpandedSectionKeys((prev) => {
|
||||
if (prev.length > 0) {
|
||||
return prev;
|
||||
}
|
||||
return collectSectionKeys(sectionTree).slice(0, 2);
|
||||
});
|
||||
}, [sectionTree]);
|
||||
|
||||
const updateRequirement = (
|
||||
requirementId: string,
|
||||
updater: (item: RequirementItem) => RequirementItem
|
||||
) => {
|
||||
let nextSelected = selectedRequirementId;
|
||||
setExtraction((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const index = prev.requirements.findIndex((item) => item.id === requirementId);
|
||||
if (index < 0) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const current = prev.requirements[index];
|
||||
const updated = normalizeRequirement(updater(current), index);
|
||||
const requirements = [...prev.requirements];
|
||||
requirements[index] = {
|
||||
...updated,
|
||||
sortOrder: index,
|
||||
};
|
||||
|
||||
const next: RequirementExtractionResult = {
|
||||
...prev,
|
||||
requirements: prev.requirements.map((item) =>
|
||||
item.id === requirementId ? updater(item) : item
|
||||
requirements,
|
||||
rawOutput: rebuildRawOutput(
|
||||
prev.rawOutput as Record<string, unknown> | undefined,
|
||||
requirements,
|
||||
prev.documentName
|
||||
),
|
||||
};
|
||||
|
||||
saveExtractionDraft(next);
|
||||
|
||||
if (selectedRequirementId === requirementId) {
|
||||
nextSelected = updated.id;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
if (nextSelected) {
|
||||
setSelectedRequirementId(nextSelected);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtract = async () => {
|
||||
@@ -167,7 +353,7 @@ export default function RequirementExtractionPage() {
|
||||
const status = await getSrsJobStatus(job.job_id);
|
||||
if (status.status === "completed") {
|
||||
const rawResult = await getSrsJobResult(job.job_id);
|
||||
finalResult = toExtractionResult(rawResult);
|
||||
finalResult = normalizeExtractionResult(toExtractionResult(rawResult));
|
||||
break;
|
||||
}
|
||||
if (status.status === "failed") {
|
||||
@@ -180,9 +366,9 @@ export default function RequirementExtractionPage() {
|
||||
throw new Error("提取任务超时,请稍后重试");
|
||||
}
|
||||
|
||||
setExtraction(finalResult);
|
||||
setExtractionAndPersist(finalResult);
|
||||
setSelectedRequirementId(finalResult.requirements[0]?.id ?? null);
|
||||
saveExtractionDraft(finalResult);
|
||||
await loadHistory();
|
||||
toast({
|
||||
title: "提取完成",
|
||||
description: `已生成 ${finalResult.requirements.length} 条需求项。`,
|
||||
@@ -210,11 +396,10 @@ export default function RequirementExtractionPage() {
|
||||
setIsImportingJson(true);
|
||||
try {
|
||||
const content = await file.text();
|
||||
const parsed = parseRequirementJson(content);
|
||||
setExtraction(parsed);
|
||||
const parsed = normalizeExtractionResult(parseRequirementJson(content));
|
||||
setExtractionAndPersist(parsed);
|
||||
setSelectedRequirementId(parsed.requirements[0]?.id ?? null);
|
||||
setActiveJobId(null);
|
||||
saveExtractionDraft(parsed);
|
||||
window.localStorage.removeItem(EXTRACTION_JOB_KEY);
|
||||
toast({
|
||||
title: "导入成功",
|
||||
@@ -238,9 +423,11 @@ export default function RequirementExtractionPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeExtractionResult(extraction);
|
||||
|
||||
const persist = async () => {
|
||||
if (!activeJobId) {
|
||||
saveExtractionDraft(extraction);
|
||||
setExtractionAndPersist(normalized);
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "当前需求编辑结果已保存到本地。",
|
||||
@@ -250,10 +437,10 @@ export default function RequirementExtractionPage() {
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const saved = await saveSrsRequirements(activeJobId, extraction);
|
||||
const next = toExtractionResult(saved);
|
||||
setExtraction(next);
|
||||
saveExtractionDraft(next);
|
||||
const saved = await saveSrsRequirements(activeJobId, normalized);
|
||||
const next = normalizeExtractionResult(toExtractionResult(saved));
|
||||
setExtractionAndPersist(next);
|
||||
await loadHistory();
|
||||
toast({
|
||||
title: "保存成功",
|
||||
description: "修改内容已保存到服务端。",
|
||||
@@ -279,13 +466,125 @@ export default function RequirementExtractionPage() {
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
downloadJson(`requirements-${timestamp}.json`, extraction);
|
||||
const payload = rebuildRawOutput(
|
||||
extraction.rawOutput as Record<string, unknown> | undefined,
|
||||
extraction.requirements,
|
||||
extraction.documentName
|
||||
);
|
||||
downloadJson(`requirements-${timestamp}.json`, payload);
|
||||
toast({
|
||||
title: "导出成功",
|
||||
description: "JSON 文件已下载。",
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSection = (sectionKey: string) => {
|
||||
setExpandedSectionKeys((prev) =>
|
||||
prev.includes(sectionKey)
|
||||
? prev.filter((key) => key !== sectionKey)
|
||||
: [...prev, sectionKey]
|
||||
);
|
||||
};
|
||||
|
||||
const handleLoadHistoryItem = async (jobId: number) => {
|
||||
try {
|
||||
const result = await getSrsJobResult(jobId);
|
||||
const normalized = normalizeExtractionResult(toExtractionResult(result));
|
||||
setExtractionAndPersist(normalized);
|
||||
setSelectedRequirementId(normalized.requirements[0]?.id ?? null);
|
||||
setActiveJobId(jobId);
|
||||
window.localStorage.setItem(EXTRACTION_JOB_KEY, String(jobId));
|
||||
setIsHistoryOpen(false);
|
||||
toast({
|
||||
title: "加载成功",
|
||||
description: `已加载 ${normalized.documentName}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "加载历史文件失败";
|
||||
toast({
|
||||
title: "加载失败",
|
||||
description: message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteHistoryItem = async (jobId: number) => {
|
||||
try {
|
||||
await deleteSrsJob(jobId);
|
||||
if (activeJobId === jobId) {
|
||||
setActiveJobId(null);
|
||||
window.localStorage.removeItem(EXTRACTION_JOB_KEY);
|
||||
setExtraction(null);
|
||||
setSelectedRequirementId(null);
|
||||
}
|
||||
await loadHistory();
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "历史文件已删除。",
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "删除历史文件失败";
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderSectionNode = (node: SectionTreeNode) => {
|
||||
const expanded = expandedSectionKeys.includes(node.key);
|
||||
const sectionLabel =
|
||||
`${node.sectionNumber ? `${node.sectionNumber} ` : ""}${node.sectionTitle}`.trim() ||
|
||||
"未归类章节";
|
||||
return (
|
||||
<div key={node.key} className="space-y-2">
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-muted"
|
||||
onClick={() => toggleSection(node.key)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("h-4 w-4 text-muted-foreground transition-transform", expanded && "rotate-90")}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">{sectionLabel}</p>
|
||||
<Badge variant="outline">{node.requirements.length}</Badge>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="space-y-2 border-l pl-4">
|
||||
{node.requirements.map((item) => {
|
||||
const active = item.id === selectedRequirementId;
|
||||
return (
|
||||
<button
|
||||
key={`${node.key}-${item.id}`}
|
||||
onClick={() => setSelectedRequirementId(item.id)}
|
||||
className={cn(
|
||||
"w-full rounded-lg border p-3 text-left transition-colors",
|
||||
active ? "border-primary bg-primary/5" : "hover:border-primary/40"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium text-sm line-clamp-1">{item.id}</p>
|
||||
<Badge variant={priorityVariant[item.priority]}>{item.priority}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">{item.id}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{node.children.map((child) => renderSectionNode(child))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
@@ -359,6 +658,78 @@ export default function RequirementExtractionPage() {
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
导出 JSON
|
||||
</Button>
|
||||
|
||||
<Dialog open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" onClick={() => void loadHistory()}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
历史文件
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>历史提取文件</DialogTitle>
|
||||
<DialogDescription>
|
||||
仅展示通过“开始提取”生成的历史文件,可加载、覆盖保存或删除。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>文档名称</TableHead>
|
||||
<TableHead>需求数</TableHead>
|
||||
<TableHead>提取时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isHistoryLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
正在加载历史文件...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isHistoryLoading && historyItems.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
暂无历史文件
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!isHistoryLoading &&
|
||||
historyItems.map((item) => (
|
||||
<TableRow key={item.jobId}>
|
||||
<TableCell>{item.documentName}</TableCell>
|
||||
<TableCell>{item.totalRequirements}</TableCell>
|
||||
<TableCell>{formatDateTime(item.generatedAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleLoadHistoryItem(item.jobId)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => void handleDeleteHistoryItem(item.jobId)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<input
|
||||
ref={jsonInputRef}
|
||||
type="file"
|
||||
@@ -380,8 +751,8 @@ export default function RequirementExtractionPage() {
|
||||
<div className="grid gap-6 lg:grid-cols-[320px_1fr]">
|
||||
<Card className="min-h-[500px]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">需求项列表</CardTitle>
|
||||
<CardDescription>点击左侧条目可在右侧编辑详情</CardDescription>
|
||||
<CardTitle className="text-lg">需求项树</CardTitle>
|
||||
<CardDescription>按章节层级折叠展开,快速定位需求所属章节</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!extraction && (
|
||||
@@ -391,34 +762,12 @@ export default function RequirementExtractionPage() {
|
||||
)}
|
||||
{extraction && (
|
||||
<div className="space-y-3 max-h-[520px] overflow-y-auto pr-1">
|
||||
{extraction.requirements.map((item) => {
|
||||
const active = item.id === selectedRequirementId;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setSelectedRequirementId(item.id)}
|
||||
className={cn(
|
||||
"w-full rounded-lg border p-3 text-left transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:border-primary/40"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium text-sm line-clamp-1">{item.title}</p>
|
||||
<Badge variant={priorityVariant[item.priority]}>
|
||||
{item.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
{item.id} · {item.sourceField}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{sectionTree.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground text-center">
|
||||
需求项为空
|
||||
</div>
|
||||
)}
|
||||
{sectionTree.map((node) => renderSectionNode(node))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -427,7 +776,7 @@ export default function RequirementExtractionPage() {
|
||||
<Card className="min-h-[500px]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">需求详情编辑</CardTitle>
|
||||
<CardDescription>字段级编辑后可保存到本地并导出 JSON</CardDescription>
|
||||
<CardDescription>支持接口需求开关与字段级编辑,保存后可覆盖历史记录</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedRequirement && (
|
||||
@@ -452,20 +801,6 @@ export default function RequirementExtractionPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-title">需求标题</Label>
|
||||
<Input
|
||||
id="req-title"
|
||||
value={selectedRequirement.title}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
title: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-priority">优先级</Label>
|
||||
<select
|
||||
@@ -486,17 +821,28 @@ export default function RequirementExtractionPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-source">来源字段</Label>
|
||||
<Input
|
||||
id="req-source"
|
||||
value={selectedRequirement.sourceField}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
sourceField: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<Label htmlFor="is-interface">是否接口需求</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
开启后可编辑接口名称、接口类型、数据来源和数据目的地。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="is-interface"
|
||||
checked={selectedRequirement.requirementType === "interface"}
|
||||
onCheckedChange={(checked) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
requirementType: checked ? "interface" : "functional",
|
||||
interfaceName: checked ? item.interfaceName || "" : "",
|
||||
interfaceType: checked ? item.interfaceType || "" : "",
|
||||
dataSource: checked ? item.dataSource || "" : "",
|
||||
dataDestination: checked ? item.dataDestination || "" : "",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -528,6 +874,66 @@ export default function RequirementExtractionPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedRequirement.requirementType === "interface" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-interface-name">接口名称</Label>
|
||||
<Input
|
||||
id="req-interface-name"
|
||||
value={selectedRequirement.interfaceName || ""}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
interfaceName: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-interface-type">接口类型</Label>
|
||||
<Input
|
||||
id="req-interface-type"
|
||||
value={selectedRequirement.interfaceType || ""}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
interfaceType: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-data-source">数据来源</Label>
|
||||
<Input
|
||||
id="req-data-source"
|
||||
value={selectedRequirement.dataSource || ""}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
dataSource: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="req-data-destination">数据目的地</Label>
|
||||
<Input
|
||||
id="req-data-destination"
|
||||
value={selectedRequirement.dataDestination || ""}
|
||||
onChange={(event) =>
|
||||
updateRequirement(selectedRequirement.id, (item) => ({
|
||||
...item,
|
||||
dataDestination: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Braces, FolderCog, Wrench } from "lucide-react";
|
||||
import DashboardLayout from "@/components/layout/dashboard-layout";
|
||||
|
||||
const plannedFeatures = [
|
||||
{
|
||||
title: "仓库接入",
|
||||
description: "后续接入代码仓库、分支与目录范围管理能力。",
|
||||
icon: FolderCog,
|
||||
},
|
||||
{
|
||||
title: "代码索引",
|
||||
description: "后续支持代码解析、结构化索引与语义检索。",
|
||||
icon: Braces,
|
||||
},
|
||||
{
|
||||
title: "能力扩展",
|
||||
description: "后续补充问答、关联分析与工程化工具链集成。",
|
||||
icon: Wrench,
|
||||
},
|
||||
];
|
||||
|
||||
export default function CodeKnowledgeBasePage() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight">代码知识库</h2>
|
||||
<p className="max-w-3xl text-muted-foreground">
|
||||
当前页面先提供前端结构,后续再逐步补充代码接入、索引、检索和问答能力。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{plannedFeatures.map((item) => (
|
||||
<section
|
||||
key={item.title}
|
||||
className="space-y-4 rounded-lg border bg-card p-6"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<item.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">{item.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-dashed bg-card p-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">当前状态</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
已完成页面入口、导航层级和基础占位内容,暂未接入后端接口和业务流程。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/dashboard/knowledge/document"
|
||||
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
返回文档知识库
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FileIcon, defaultStyles } from "react-file-icon";
|
||||
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";
|
||||
|
||||
interface KnowledgeBase {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
documents: Document[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Document {
|
||||
id: number;
|
||||
file_name: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
content_type: string;
|
||||
knowledge_base_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
processing_tasks: any[];
|
||||
}
|
||||
|
||||
export default function DocumentKnowledgeBasePage() {
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [collapsedDocumentSections, setCollapsedDocumentSections] = useState<
|
||||
Record<number, boolean>
|
||||
>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
void fetchKnowledgeBases();
|
||||
}, []);
|
||||
|
||||
const fetchKnowledgeBases = async () => {
|
||||
try {
|
||||
const data = await api.get("/api/knowledge-base");
|
||||
setKnowledgeBases(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch knowledge bases:", error);
|
||||
if (error instanceof ApiError) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("确定要删除这个知识库吗?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/api/knowledge-base/${id}`);
|
||||
setKnowledgeBases((prev) => prev.filter((kb) => kb.id !== id));
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "知识库删除成功",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete knowledge base:", error);
|
||||
if (error instanceof ApiError) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDocumentsSection = (knowledgeBaseId: number) => {
|
||||
setCollapsedDocumentSections((prev) => ({
|
||||
...prev,
|
||||
[knowledgeBaseId]: !prev[knowledgeBaseId],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">文档知识库</h2>
|
||||
<p className="text-muted-foreground">管理你的知识库与文档</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/knowledge/new"
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建知识库
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{knowledgeBases.map((kb) => {
|
||||
const isDocumentsCollapsed =
|
||||
collapsedDocumentSections[kb.id] ?? true;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={kb.id}
|
||||
className="space-y-4 rounded-lg border bg-card p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{kb.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{kb.description || "暂无描述"}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{kb.documents.length} 个文档 ·{" "}
|
||||
{new Date(kb.created_at).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Link
|
||||
href={`/dashboard/knowledge/${kb.id}`}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-secondary"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/test-retrieval/${kb.id}`}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-secondary"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => void handleDelete(kb.id)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-destructive/10 hover:bg-destructive/20"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kb.documents.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDocumentsSection(kb.id)}
|
||||
className="mb-2 flex w-full items-center justify-between rounded-md px-1 py-1 text-left hover:bg-accent/50"
|
||||
>
|
||||
<h4 className="text-sm font-medium">文档</h4>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>{isDocumentsCollapsed ? "展开" : "收起"}</span>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 transition-transform ${
|
||||
isDocumentsCollapsed ? "" : "rotate-90"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!isDocumentsCollapsed && (
|
||||
<div className="flex max-h-[400px] flex-wrap gap-2 overflow-y-auto">
|
||||
{kb.documents.slice(0, 9).map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex h-[150px] w-[150px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border bg-card p-2 transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<div className="mb-2 h-8 w-8">
|
||||
{doc.content_type.toLowerCase().includes("pdf") ? (
|
||||
<FileIcon extension="pdf" {...defaultStyles.pdf} />
|
||||
) : doc.content_type.toLowerCase().includes("doc") ? (
|
||||
<FileIcon extension="doc" {...defaultStyles.docx} />
|
||||
) : doc.content_type.toLowerCase().includes("txt") ? (
|
||||
<FileIcon extension="txt" {...defaultStyles.txt} />
|
||||
) : doc.content_type.toLowerCase().includes("md") ? (
|
||||
<FileIcon extension="md" {...defaultStyles.md} />
|
||||
) : (
|
||||
<FileIcon
|
||||
extension={doc.file_name.split(".").pop() || ""}
|
||||
color="#E2E8F0"
|
||||
labelColor="#94A3B8"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-w-[100px] text-center text-sm font-medium">
|
||||
<div className="line-clamp-2 overflow-hidden text-ellipsis">
|
||||
{doc.file_name}
|
||||
</div>
|
||||
</div>
|
||||
<span className="mt-1 text-xs text-muted-foreground">
|
||||
{new Date(doc.created_at).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{kb.documents.length > 9 && (
|
||||
<Link
|
||||
href={`/dashboard/knowledge/${kb.id}`}
|
||||
className="flex h-[150px] w-[150px] cursor-pointer flex-col items-center justify-center rounded-lg border bg-card p-2 transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<div className="mb-2 flex h-8 w-8 items-center justify-center">
|
||||
<ArrowRight className="h-6 w-6" />
|
||||
</div>
|
||||
<span className="text-center text-sm font-medium">
|
||||
查看全部文档
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-muted-foreground">
|
||||
共 {kb.documents.length} 个
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!loading && knowledgeBases.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground">暂无知识库,请先创建一个。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="space-y-4">
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-primary/30 border-t-primary" />
|
||||
<p className="animate-pulse text-muted-foreground">
|
||||
正在加载知识库...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,259 +1,5 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FileIcon, defaultStyles } from "react-file-icon";
|
||||
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";
|
||||
|
||||
interface KnowledgeBase {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
documents: Document[];
|
||||
created_at: string;
|
||||
}
|
||||
interface Document {
|
||||
id: number;
|
||||
file_name: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
content_type: string;
|
||||
knowledge_base_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
processing_tasks: any[];
|
||||
}
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [collapsedDocumentSections, setCollapsedDocumentSections] = useState<
|
||||
Record<number, boolean>
|
||||
>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchKnowledgeBases();
|
||||
}, []);
|
||||
|
||||
const fetchKnowledgeBases = async () => {
|
||||
try {
|
||||
const data = await api.get("/api/knowledge-base");
|
||||
setKnowledgeBases(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch knowledge bases:", error);
|
||||
if (error instanceof ApiError) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("确定要删除这个知识库吗?"))
|
||||
return;
|
||||
try {
|
||||
await api.delete(`/api/knowledge-base/${id}`);
|
||||
setKnowledgeBases((prev) => prev.filter((kb) => kb.id !== id));
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "知识库删除成功",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete knowledge base:", error);
|
||||
if (error instanceof ApiError) {
|
||||
toast({
|
||||
title: "错误",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDocumentsSection = (knowledgeBaseId: number) => {
|
||||
setCollapsedDocumentSections((prev) => ({
|
||||
...prev,
|
||||
[knowledgeBaseId]: !prev[knowledgeBaseId],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
知识库
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
管理你的知识库与文档
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/knowledge/new"
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建知识库
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{knowledgeBases.map((kb) => {
|
||||
const isDocumentsCollapsed =
|
||||
collapsedDocumentSections[kb.id] ?? true;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={kb.id}
|
||||
className="rounded-lg border bg-card p-6 space-y-4"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{kb.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{kb.description || "暂无描述"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{kb.documents.length} 个文档 •{" "}
|
||||
{new Date(kb.created_at).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Link
|
||||
href={`/dashboard/knowledge/${kb.id}`}
|
||||
className="inline-flex items-center justify-center rounded-md bg-secondary w-8 h-8"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/test-retrieval/${kb.id}`}
|
||||
className="inline-flex items-center justify-center rounded-md bg-secondary w-8 h-8"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(kb.id)}
|
||||
className="inline-flex items-center justify-center rounded-md bg-destructive/10 hover:bg-destructive/20 w-8 h-8"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kb.documents.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDocumentsSection(kb.id)}
|
||||
className="mb-2 flex w-full items-center justify-between rounded-md px-1 py-1 text-left hover:bg-accent/50"
|
||||
>
|
||||
<h4 className="text-sm font-medium">文档</h4>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>{isDocumentsCollapsed ? "展开" : "收起"}</span>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 transition-transform ${
|
||||
isDocumentsCollapsed ? "" : "rotate-90"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!isDocumentsCollapsed && (
|
||||
<div className="flex flex-wrap gap-2 max-h-[400px] overflow-y-auto">
|
||||
{kb.documents.slice(0, 9).map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex flex-col items-center gap-2 p-2 rounded-lg border bg-card hover:bg-accent/50 cursor-pointer transition-colors w-[150px] h-[150px] justify-center"
|
||||
>
|
||||
<div className="w-8 h-8 mb-2">
|
||||
{doc.content_type.toLowerCase().includes("pdf") ? (
|
||||
<FileIcon extension="pdf" {...defaultStyles.pdf} />
|
||||
) : doc.content_type.toLowerCase().includes("doc") ? (
|
||||
<FileIcon extension="doc" {...defaultStyles.docx} />
|
||||
) : doc.content_type.toLowerCase().includes("txt") ? (
|
||||
<FileIcon extension="txt" {...defaultStyles.txt} />
|
||||
) : doc.content_type.toLowerCase().includes("md") ? (
|
||||
<FileIcon extension="md" {...defaultStyles.md} />
|
||||
) : (
|
||||
<FileIcon
|
||||
extension={doc.file_name.split(".").pop() || ""}
|
||||
color="#E2E8F0"
|
||||
labelColor="#94A3B8"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-center max-w-[100px]">
|
||||
<div className="line-clamp-2 overflow-hidden text-ellipsis">
|
||||
{doc.file_name}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(doc.created_at).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{kb.documents.length > 9 && (
|
||||
<Link
|
||||
href={`/dashboard/knowledge/${kb.id}`}
|
||||
className="flex flex-col items-center p-2 rounded-lg border bg-card hover:bg-accent/50 cursor-pointer transition-colors w-[150px] h-[150px] justify-center"
|
||||
>
|
||||
<div className="w-8 h-8 mb-2 flex items-center justify-center">
|
||||
<ArrowRight className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-center">
|
||||
查看全部文档
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground mt-1">
|
||||
共 {kb.documents.length} 个
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!loading && knowledgeBases.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
暂无知识库,请先创建一个。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="space-y-4">
|
||||
<div className="w-8 h-8 border-4 border-primary/30 border-t-primary rounded-full animate-spin mx-auto"></div>
|
||||
<p className="text-muted-foreground animate-pulse">
|
||||
正在加载知识库...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
export default function KnowledgePage() {
|
||||
redirect("/dashboard/knowledge/document");
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/dashboard/knowledge"
|
||||
href="/dashboard/knowledge/document"
|
||||
className="mt-6 flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium"
|
||||
>
|
||||
查看全部知识库
|
||||
@@ -152,7 +152,7 @@ export default function DashboardPage() {
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/dashboard/knowledge"
|
||||
href="/dashboard/knowledge/document"
|
||||
className="flex flex-col items-center justify-center rounded-2xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-8 shadow-sm hover:shadow-md transition-all hover:border-indigo-500 dark:hover:border-indigo-500"
|
||||
>
|
||||
<div className="rounded-full bg-indigo-100 dark:bg-indigo-900/30 p-4 mb-4">
|
||||
@@ -222,7 +222,7 @@ export default function DashboardPage() {
|
||||
向知识库上传 PDF、DOCX、MD 或 TXT 文件。系统会自动完成处理与索引,支持 AI 检索。
|
||||
</p>
|
||||
<a
|
||||
href="/dashboard/knowledge"
|
||||
href="/dashboard/knowledge/document"
|
||||
className="mt-4 inline-flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 text-sm font-medium"
|
||||
>
|
||||
去上传
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Book,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
LogOut,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Search,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Breadcrumb from "@/components/ui/breadcrumb";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type NavigationChild = {
|
||||
name: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type NavigationItem = {
|
||||
name: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
href?: string;
|
||||
children?: Array<{
|
||||
name: string;
|
||||
href: string;
|
||||
}>;
|
||||
children?: NavigationChild[];
|
||||
defaultOpen?: boolean;
|
||||
};
|
||||
|
||||
export default function DashboardLayout({
|
||||
@@ -34,9 +37,10 @@ export default function DashboardLayout({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [isDocProcessingOpen, setIsDocProcessingOpen] = useState(
|
||||
pathname.startsWith("/dashboard/doc-processing")
|
||||
);
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
||||
knowledge: pathname.startsWith("/dashboard/knowledge"),
|
||||
"doc-processing": pathname.startsWith("/dashboard/doc-processing"),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("token");
|
||||
@@ -51,13 +55,31 @@ export default function DashboardLayout({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/dashboard/knowledge")) {
|
||||
setOpenGroups((prev) => ({ ...prev, knowledge: true }));
|
||||
}
|
||||
if (pathname.startsWith("/dashboard/doc-processing")) {
|
||||
setIsDocProcessingOpen(true);
|
||||
setOpenGroups((prev) => ({ ...prev, "doc-processing": true }));
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setOpenGroups((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
const navigation: NavigationItem[] = [
|
||||
{ name: "知识库", href: "/dashboard/knowledge", icon: Book },
|
||||
{
|
||||
name: "知识库",
|
||||
icon: Book,
|
||||
defaultOpen: true,
|
||||
children: [
|
||||
{ name: "文档知识库", href: "/dashboard/knowledge/document" },
|
||||
{ name: "代码知识库", href: "/dashboard/knowledge/code" },
|
||||
],
|
||||
},
|
||||
{ name: "对话", href: "/dashboard/chat", icon: MessageSquare },
|
||||
{
|
||||
name: "文档处理",
|
||||
@@ -80,42 +102,39 @@ export default function DashboardLayout({
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Mobile menu button */}
|
||||
<div className="lg:hidden fixed top-0 left-0 m-4 z-50">
|
||||
<div className="fixed left-0 top-0 z-50 m-4 lg:hidden">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="p-2 rounded-md bg-primary text-primary-foreground"
|
||||
className="rounded-md bg-primary p-2 text-primary-foreground"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`fixed inset-y-0 left-0 z-40 w-64 transform bg-card border-r transition-transform duration-200 ease-in-out lg:translate-x-0 ${
|
||||
className={`fixed inset-y-0 left-0 z-40 w-64 transform border-r bg-card transition-transform duration-200 ease-in-out lg:translate-x-0 ${
|
||||
isMobileMenuOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sidebar header */}
|
||||
<div className="flex h-16 items-center border-b pl-8">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center text-lg font-semibold hover:text-primary transition-colors"
|
||||
className="flex items-center text-lg font-semibold transition-colors hover:text-primary"
|
||||
>
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="标志"
|
||||
className="w-16 h-16 rounded-lg"
|
||||
/>
|
||||
<img src="/logo.svg" alt="标志" className="h-16 w-16 rounded-lg" />
|
||||
RAG 知识助手
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-2 px-4 py-6">
|
||||
{navigation.map((item) => {
|
||||
if (item.children) {
|
||||
const groupKey =
|
||||
item.children[0]?.href.split("/")[2] === "knowledge"
|
||||
? "knowledge"
|
||||
: "doc-processing";
|
||||
const isOpen = openGroups[groupKey] ?? item.defaultOpen ?? false;
|
||||
const hasActiveChild = item.children.some((child) =>
|
||||
pathname.startsWith(child.href)
|
||||
);
|
||||
@@ -124,7 +143,7 @@ export default function DashboardLayout({
|
||||
<div key={item.name} className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDocProcessingOpen((prev) => !prev)}
|
||||
onClick={() => toggleGroup(groupKey)}
|
||||
className={cn(
|
||||
"group flex w-full items-center rounded-lg px-4 py-3 text-sm font-medium transition-all duration-200",
|
||||
hasActiveChild
|
||||
@@ -136,7 +155,7 @@ export default function DashboardLayout({
|
||||
className={cn(
|
||||
"mr-3 h-5 w-5 transition-transform duration-200",
|
||||
hasActiveChild
|
||||
? "text-primary scale-110"
|
||||
? "scale-110 text-primary"
|
||||
: "group-hover:scale-110"
|
||||
)}
|
||||
/>
|
||||
@@ -144,12 +163,12 @@ export default function DashboardLayout({
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4 transition-transform duration-200",
|
||||
isDocProcessingOpen ? "rotate-90" : ""
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isDocProcessingOpen && (
|
||||
{isOpen && (
|
||||
<div className="ml-7 space-y-1 border-l pl-3">
|
||||
{item.children.map((child) => {
|
||||
const isChildActive = pathname.startsWith(child.href);
|
||||
@@ -188,18 +207,20 @@ export default function DashboardLayout({
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href || "/dashboard"}
|
||||
className={`group flex items-center rounded-lg px-4 py-3 text-sm font-medium transition-all duration-200 ${
|
||||
className={cn(
|
||||
"group flex items-center rounded-lg px-4 py-3 text-sm font-medium transition-all duration-200",
|
||||
isActive
|
||||
? "bg-gradient-to-r from-primary/10 to-primary/5 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground hover:shadow-sm"
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={`mr-3 h-5 w-5 transition-transform duration-200 ${
|
||||
className={cn(
|
||||
"mr-3 h-5 w-5 transition-transform duration-200",
|
||||
isActive
|
||||
? "text-primary scale-110"
|
||||
? "scale-110 text-primary"
|
||||
: "group-hover:scale-110"
|
||||
}`}
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
{isActive && (
|
||||
@@ -209,11 +230,11 @@ export default function DashboardLayout({
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{/* User profile and logout */}
|
||||
<div className="border-t p-4 space-y-4">
|
||||
|
||||
<div className="space-y-4 border-t p-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors duration-200"
|
||||
className="flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-medium text-destructive transition-colors duration-200 hover:bg-destructive/10"
|
||||
>
|
||||
<LogOut className="mr-3 h-4 w-4" />
|
||||
退出登录
|
||||
@@ -222,9 +243,8 @@ export default function DashboardLayout({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-64">
|
||||
<main className="min-h-screen py-6 px-4 sm:px-6 lg:px-8">
|
||||
<main className="min-h-screen px-4 py-6 sm:px-6 lg:px-8">
|
||||
<Breadcrumb />
|
||||
{children}
|
||||
</main>
|
||||
@@ -237,10 +257,15 @@ export const dashboardConfig = {
|
||||
mainNav: [],
|
||||
sidebarNav: [
|
||||
{
|
||||
title: "知识库",
|
||||
href: "/dashboard/knowledge",
|
||||
title: "文档知识库",
|
||||
href: "/dashboard/knowledge/document",
|
||||
icon: "database",
|
||||
},
|
||||
{
|
||||
title: "代码知识库",
|
||||
href: "/dashboard/knowledge/code",
|
||||
icon: "braces",
|
||||
},
|
||||
{
|
||||
title: "对话",
|
||||
href: "/dashboard/chat",
|
||||
|
||||
@@ -9,6 +9,8 @@ const Breadcrumb = () => {
|
||||
const labelMap: Record<string, string> = {
|
||||
dashboard: "主界面",
|
||||
knowledge: "知识库",
|
||||
document: "文档知识库",
|
||||
code: "代码知识库",
|
||||
chat: "对话",
|
||||
"doc-processing": "文档处理",
|
||||
extract: "需求提取",
|
||||
@@ -24,11 +26,10 @@ const Breadcrumb = () => {
|
||||
|
||||
const generateBreadcrumbs = () => {
|
||||
const paths = pathname.split("/").filter(Boolean);
|
||||
const breadcrumbs = paths.map((path, index) => {
|
||||
return paths.map((path, index) => {
|
||||
const href = "/" + paths.slice(0, index + 1).join("/");
|
||||
const label = labelMap[path] || path.replace(/-/g, " ");
|
||||
const isLast = index === paths.length - 1;
|
||||
|
||||
const displayLabel = /^\d+$/.test(path) ? "详情" : label;
|
||||
|
||||
return {
|
||||
@@ -37,8 +38,6 @@ const Breadcrumb = () => {
|
||||
isLast,
|
||||
};
|
||||
});
|
||||
|
||||
return breadcrumbs;
|
||||
};
|
||||
|
||||
const breadcrumbs = generateBreadcrumbs();
|
||||
@@ -46,25 +45,25 @@ const Breadcrumb = () => {
|
||||
if (pathname === "/") return null;
|
||||
|
||||
return (
|
||||
<nav className="flex items-center space-x-2 text-base text-muted-foreground mb-6">
|
||||
<nav className="mb-6 flex items-center space-x-2 text-base text-muted-foreground">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center hover:text-foreground transition-colors"
|
||||
className="flex items-center transition-colors hover:text-foreground"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
{breadcrumbs.map((breadcrumb, index) => (
|
||||
{breadcrumbs.map((breadcrumb) => (
|
||||
<div key={breadcrumb.href} className="flex items-center">
|
||||
<ChevronRight className="h-4 w-4 mx-2 text-muted-foreground/50" />
|
||||
<ChevronRight className="mx-2 h-4 w-4 text-muted-foreground/50" />
|
||||
{breadcrumb.isLast ? (
|
||||
<span className="text-foreground font-medium">
|
||||
<span className="font-medium text-foreground">
|
||||
{breadcrumb.label}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={breadcrumb.href}
|
||||
className="hover:text-foreground transition-colors"
|
||||
className="transition-colors hover:text-foreground"
|
||||
>
|
||||
{breadcrumb.label}
|
||||
</Link>
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
export type PriorityLevel = "高" | "中" | "低";
|
||||
export type SeverityLevel = "高" | "中" | "低";
|
||||
export type RequirementType =
|
||||
| "functional"
|
||||
| "interface"
|
||||
| "performance"
|
||||
| "security"
|
||||
| "reliability"
|
||||
| "other";
|
||||
|
||||
export interface RequirementItem {
|
||||
id: string;
|
||||
title: string;
|
||||
title?: string;
|
||||
description: string;
|
||||
priority: PriorityLevel;
|
||||
acceptanceCriteria: string[];
|
||||
sourceField: string;
|
||||
sectionUid?: string;
|
||||
sectionNumber?: string;
|
||||
sectionTitle?: string;
|
||||
requirementType?: RequirementType;
|
||||
interfaceName?: string;
|
||||
interfaceType?: string;
|
||||
dataSource?: string;
|
||||
dataDestination?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface RequirementExtractionResult {
|
||||
documentName: string;
|
||||
generatedAt: string;
|
||||
requirements: RequirementItem[];
|
||||
rawOutput?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestCaseItem {
|
||||
@@ -72,6 +89,52 @@ const asPriority = (value: unknown): PriorityLevel => {
|
||||
return "中";
|
||||
};
|
||||
|
||||
const asRequirementType = (value: unknown): RequirementType => {
|
||||
if (
|
||||
value === "functional" ||
|
||||
value === "interface" ||
|
||||
value === "performance" ||
|
||||
value === "security" ||
|
||||
value === "reliability" ||
|
||||
value === "other"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (value === "接口需求") {
|
||||
return "interface";
|
||||
}
|
||||
if (value === "性能需求") {
|
||||
return "performance";
|
||||
}
|
||||
if (value === "安全需求") {
|
||||
return "security";
|
||||
}
|
||||
if (value === "可靠性需求") {
|
||||
return "reliability";
|
||||
}
|
||||
if (value === "其他需求") {
|
||||
return "other";
|
||||
}
|
||||
return "functional";
|
||||
};
|
||||
|
||||
const summarizeTitle = (value: string, fallbackIndex: number) => {
|
||||
const source = (value || "").trim();
|
||||
if (!source) {
|
||||
return `需求项 ${fallbackIndex + 1}`;
|
||||
}
|
||||
|
||||
for (const separator of ["。", ";", "\n", ";", "."]) {
|
||||
if (source.includes(separator)) {
|
||||
const first = source.split(separator, 1)[0]?.trim();
|
||||
if (first) {
|
||||
return first.slice(0, 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
return source.slice(0, 20);
|
||||
};
|
||||
|
||||
const asSeverity = (value: unknown): SeverityLevel => {
|
||||
if (value === "高" || value === "中" || value === "低") {
|
||||
return value;
|
||||
@@ -139,7 +202,7 @@ const normalizeRequirementItem = (
|
||||
title:
|
||||
typeof item.title === "string" && item.title.trim().length > 0
|
||||
? item.title
|
||||
: `未命名需求 ${fallbackIndex + 1}`,
|
||||
: undefined,
|
||||
description:
|
||||
typeof item.description === "string" ? item.description : "",
|
||||
priority: asPriority(item.priority),
|
||||
@@ -149,9 +212,227 @@ const normalizeRequirementItem = (
|
||||
typeof item.sourceField === "string" && item.sourceField.trim().length > 0
|
||||
? item.sourceField
|
||||
: `章节 ${fallbackIndex + 1}`,
|
||||
sectionUid:
|
||||
typeof item.sectionUid === "string" ? item.sectionUid : undefined,
|
||||
sectionNumber:
|
||||
typeof item.sectionNumber === "string" ? item.sectionNumber : undefined,
|
||||
sectionTitle:
|
||||
typeof item.sectionTitle === "string" ? item.sectionTitle : undefined,
|
||||
requirementType: asRequirementType(item.requirementType),
|
||||
interfaceName:
|
||||
typeof item.interfaceName === "string" ? item.interfaceName : "",
|
||||
interfaceType:
|
||||
typeof item.interfaceType === "string" ? item.interfaceType : "",
|
||||
dataSource:
|
||||
typeof item.dataSource === "string" ? item.dataSource : "",
|
||||
dataDestination:
|
||||
typeof item.dataDestination === "string" ? item.dataDestination : "",
|
||||
sortOrder:
|
||||
typeof item.sortOrder === "number" && Number.isFinite(item.sortOrder)
|
||||
? item.sortOrder
|
||||
: fallbackIndex,
|
||||
};
|
||||
};
|
||||
|
||||
const buildRawOutputFromRequirements = (
|
||||
requirements: RequirementItem[],
|
||||
documentName: string,
|
||||
generatedAt: string
|
||||
): Record<string, unknown> => {
|
||||
const sectionMap = new Map<
|
||||
string,
|
||||
{
|
||||
sectionNumber: string;
|
||||
sectionTitle: string;
|
||||
list: Array<Record<string, unknown>>;
|
||||
}
|
||||
>();
|
||||
|
||||
const byType: Record<string, number> = {};
|
||||
|
||||
requirements.forEach((req) => {
|
||||
const number = req.sectionNumber || "";
|
||||
const title = req.sectionTitle || "未归类章节";
|
||||
const key = `${number}__${title}`;
|
||||
if (!sectionMap.has(key)) {
|
||||
sectionMap.set(key, {
|
||||
sectionNumber: number,
|
||||
sectionTitle: title,
|
||||
list: [],
|
||||
});
|
||||
}
|
||||
|
||||
const reqType = asRequirementType(req.requirementType);
|
||||
const typeLabel =
|
||||
reqType === "interface"
|
||||
? "接口需求"
|
||||
: reqType === "performance"
|
||||
? "性能需求"
|
||||
: reqType === "security"
|
||||
? "安全需求"
|
||||
: reqType === "reliability"
|
||||
? "可靠性需求"
|
||||
: reqType === "other"
|
||||
? "其他需求"
|
||||
: "功能需求";
|
||||
byType[typeLabel] = (byType[typeLabel] || 0) + 1;
|
||||
|
||||
const reqEntry: Record<string, unknown> = {
|
||||
需求类型: typeLabel,
|
||||
需求编号: req.id,
|
||||
需求描述: req.description,
|
||||
优先级: req.priority || "中",
|
||||
};
|
||||
if (reqType === "interface") {
|
||||
reqEntry["接口名称"] = req.interfaceName || "";
|
||||
reqEntry["接口类型"] = req.interfaceType || "";
|
||||
reqEntry["数据来源"] = req.dataSource || "";
|
||||
reqEntry["数据目的地"] = req.dataDestination || "";
|
||||
}
|
||||
|
||||
sectionMap.get(key)?.list.push(reqEntry);
|
||||
});
|
||||
|
||||
const content: Record<string, unknown> = {};
|
||||
for (const section of sectionMap.values()) {
|
||||
const display = `${section.sectionNumber} ${section.sectionTitle}`.trim();
|
||||
content[display || "未归类章节"] = {
|
||||
章节信息: {
|
||||
章节编号: section.sectionNumber,
|
||||
章节标题: section.sectionTitle,
|
||||
章节级别: section.sectionNumber
|
||||
? section.sectionNumber.split(".").length
|
||||
: 1,
|
||||
},
|
||||
需求列表: section.list,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
文档元数据: {
|
||||
标题: documentName,
|
||||
生成时间: generatedAt,
|
||||
总需求数: requirements.length,
|
||||
需求类型统计: byType,
|
||||
},
|
||||
需求内容: content,
|
||||
};
|
||||
};
|
||||
|
||||
const parseRawOutputRequirements = (parsed: Record<string, unknown>) => {
|
||||
const metadata =
|
||||
parsed["文档元数据"] && typeof parsed["文档元数据"] === "object"
|
||||
? (parsed["文档元数据"] as Record<string, unknown>)
|
||||
: {};
|
||||
const content =
|
||||
parsed["需求内容"] && typeof parsed["需求内容"] === "object"
|
||||
? (parsed["需求内容"] as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
if (!content) {
|
||||
throw new Error("JSON 中缺少 需求内容 字段");
|
||||
}
|
||||
|
||||
const requirements: RequirementItem[] = [];
|
||||
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== "object") {
|
||||
return;
|
||||
}
|
||||
const sectionNode = node as Record<string, unknown>;
|
||||
const sectionInfo =
|
||||
sectionNode["章节信息"] && typeof sectionNode["章节信息"] === "object"
|
||||
? (sectionNode["章节信息"] as Record<string, unknown>)
|
||||
: {};
|
||||
const sectionNumber =
|
||||
typeof sectionInfo["章节编号"] === "string" ? sectionInfo["章节编号"] : "";
|
||||
const sectionTitle =
|
||||
typeof sectionInfo["章节标题"] === "string"
|
||||
? sectionInfo["章节标题"]
|
||||
: "未归类章节";
|
||||
const sectionUid =
|
||||
typeof sectionInfo["章节UID"] === "string" ? sectionInfo["章节UID"] : undefined;
|
||||
|
||||
const reqList = Array.isArray(sectionNode["需求列表"])
|
||||
? (sectionNode["需求列表"] as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
|
||||
reqList.forEach((req, index) => {
|
||||
const description =
|
||||
typeof req["需求描述"] === "string" ? req["需求描述"] : "";
|
||||
const reqType = asRequirementType(req["需求类型"]);
|
||||
requirements.push(
|
||||
normalizeRequirementItem(
|
||||
{
|
||||
id:
|
||||
typeof req["需求编号"] === "string" && req["需求编号"].trim().length > 0
|
||||
? req["需求编号"]
|
||||
: undefined,
|
||||
title:
|
||||
typeof req["需求标题"] === "string" && req["需求标题"].trim().length > 0
|
||||
? req["需求标题"]
|
||||
: undefined,
|
||||
description,
|
||||
priority: asPriority(req["优先级"]),
|
||||
acceptanceCriteria: description ? [description] : ["待补充验收标准"],
|
||||
sourceField: `${sectionNumber} ${sectionTitle}`.trim() || "文档解析",
|
||||
sectionUid,
|
||||
sectionNumber,
|
||||
sectionTitle,
|
||||
requirementType: reqType,
|
||||
interfaceName:
|
||||
reqType === "interface" && typeof req["接口名称"] === "string"
|
||||
? req["接口名称"]
|
||||
: "",
|
||||
interfaceType:
|
||||
reqType === "interface" && typeof req["接口类型"] === "string"
|
||||
? req["接口类型"]
|
||||
: "",
|
||||
dataSource:
|
||||
reqType === "interface" && typeof req["数据来源"] === "string"
|
||||
? req["数据来源"]
|
||||
: "",
|
||||
dataDestination:
|
||||
reqType === "interface" && typeof req["数据目的地"] === "string"
|
||||
? req["数据目的地"]
|
||||
: "",
|
||||
sortOrder: requirements.length + index,
|
||||
},
|
||||
requirements.length + index
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const children =
|
||||
sectionNode["子章节"] && typeof sectionNode["子章节"] === "object"
|
||||
? (sectionNode["子章节"] as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
if (!children) {
|
||||
return;
|
||||
}
|
||||
Object.values(children).forEach((child) => walk(child));
|
||||
};
|
||||
|
||||
Object.values(content).forEach((section) => walk(section));
|
||||
|
||||
const generatedAt =
|
||||
typeof metadata["生成时间"] === "string" && metadata["生成时间"].trim().length > 0
|
||||
? metadata["生成时间"]
|
||||
: toIso();
|
||||
const documentName =
|
||||
typeof metadata["标题"] === "string" && metadata["标题"].trim().length > 0
|
||||
? metadata["标题"]
|
||||
: "导入需求文件";
|
||||
|
||||
return {
|
||||
documentName,
|
||||
generatedAt,
|
||||
requirements,
|
||||
rawOutput: parsed,
|
||||
} as RequirementExtractionResult;
|
||||
};
|
||||
|
||||
export const parseRequirementJson = (
|
||||
content: string
|
||||
): RequirementExtractionResult => {
|
||||
@@ -165,8 +446,18 @@ export const parseRequirementJson = (
|
||||
documentName?: unknown;
|
||||
generatedAt?: unknown;
|
||||
requirements?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
if (
|
||||
value["需求内容"] &&
|
||||
typeof value["需求内容"] === "object" &&
|
||||
value["文档元数据"] &&
|
||||
typeof value["文档元数据"] === "object"
|
||||
) {
|
||||
return parseRawOutputRequirements(value as Record<string, unknown>);
|
||||
}
|
||||
|
||||
if (!Array.isArray(value.requirements)) {
|
||||
throw new Error("JSON 中缺少 requirements 数组字段");
|
||||
}
|
||||
@@ -185,6 +476,15 @@ export const parseRequirementJson = (
|
||||
? value.generatedAt
|
||||
: toIso(),
|
||||
requirements,
|
||||
rawOutput: buildRawOutputFromRequirements(
|
||||
requirements,
|
||||
typeof value.documentName === "string" && value.documentName.trim().length > 0
|
||||
? value.documentName
|
||||
: "导入需求文件",
|
||||
typeof value.generatedAt === "string" && value.generatedAt.trim().length > 0
|
||||
? value.generatedAt
|
||||
: toIso()
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -238,31 +538,34 @@ export const mockGenerateTestCases = async (
|
||||
): Promise<TestCaseGenerationResult> => {
|
||||
await wait(WAIT_TIME.generateCases);
|
||||
|
||||
const cases: TestCaseItem[] = extraction.requirements.map((item, index) => ({
|
||||
id: `TC-${String(index + 1).padStart(3, "0")}`,
|
||||
requirementId: item.id,
|
||||
requirementTitle: item.title,
|
||||
title: `${item.title} - 功能验证`,
|
||||
preconditions: [
|
||||
"系统已启动并完成账号登录",
|
||||
"测试数据准备完毕",
|
||||
"目标模块已开启对应配置",
|
||||
],
|
||||
steps: [
|
||||
`进入 ${item.sourceField} 对应功能页面`,
|
||||
"输入合法数据并提交",
|
||||
"观察页面反馈与状态变化",
|
||||
"触发一次异常输入场景",
|
||||
"再次执行提交并确认恢复能力",
|
||||
],
|
||||
expectedResults: [
|
||||
"系统返回成功提示且状态更新正确",
|
||||
"异常场景出现清晰错误提示,不会清空已填内容",
|
||||
"操作日志可追踪,结果可导出",
|
||||
],
|
||||
priority: item.priority,
|
||||
tags: ["自动生成", "需求映射", item.sourceField],
|
||||
}));
|
||||
const cases: TestCaseItem[] = extraction.requirements.map((item, index) => {
|
||||
const displayTitle = item.title || summarizeTitle(item.description, index);
|
||||
return {
|
||||
id: `TC-${String(index + 1).padStart(3, "0")}`,
|
||||
requirementId: item.id,
|
||||
requirementTitle: displayTitle,
|
||||
title: `${displayTitle} - 功能验证`,
|
||||
preconditions: [
|
||||
"系统已启动并完成账号登录",
|
||||
"测试数据准备完毕",
|
||||
"目标模块已开启对应配置",
|
||||
],
|
||||
steps: [
|
||||
`进入 ${item.sourceField} 对应功能页面`,
|
||||
"输入合法数据并提交",
|
||||
"观察页面反馈与状态变化",
|
||||
"触发一次异常输入场景",
|
||||
"再次执行提交并确认恢复能力",
|
||||
],
|
||||
expectedResults: [
|
||||
"系统返回成功提示且状态更新正确",
|
||||
"异常场景出现清晰错误提示,不会清空已填内容",
|
||||
"操作日志可追踪,结果可导出",
|
||||
],
|
||||
priority: item.priority,
|
||||
tags: ["自动生成", "需求映射", item.sourceField],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
sourceDocument: extraction.documentName,
|
||||
@@ -406,6 +709,16 @@ export const loadExtractionDraft = () => {
|
||||
requirements: value.requirements.map((item, index) =>
|
||||
normalizeRequirementItem(item, index)
|
||||
),
|
||||
rawOutput:
|
||||
value.rawOutput && typeof value.rawOutput === "object"
|
||||
? value.rawOutput
|
||||
: buildRawOutputFromRequirements(
|
||||
value.requirements.map((item, index) =>
|
||||
normalizeRequirementItem(item, index)
|
||||
),
|
||||
value.documentName,
|
||||
value.generatedAt
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
543
rag-web-ui/frontend/src/lib/srs-json.ts
Normal file
543
rag-web-ui/frontend/src/lib/srs-json.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
import { RequirementItem, RequirementType } from "@/lib/document-mock";
|
||||
|
||||
export interface SectionTreeNode {
|
||||
key: string;
|
||||
sectionNumber: string;
|
||||
sectionTitle: string;
|
||||
level: number;
|
||||
requirements: RequirementItem[];
|
||||
children: SectionTreeNode[];
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<RequirementType, string> = {
|
||||
functional: "功能需求",
|
||||
interface: "接口需求",
|
||||
performance: "性能需求",
|
||||
security: "安全需求",
|
||||
reliability: "可靠性需求",
|
||||
other: "其他需求",
|
||||
};
|
||||
|
||||
const LABEL_TYPE: Record<string, RequirementType> = Object.entries(TYPE_LABEL).reduce(
|
||||
(acc, [type, label]) => {
|
||||
acc[label] = type as RequirementType;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, RequirementType>
|
||||
);
|
||||
|
||||
const asRequirementType = (value: unknown): RequirementType => {
|
||||
if (
|
||||
value === "functional" ||
|
||||
value === "interface" ||
|
||||
value === "performance" ||
|
||||
value === "security" ||
|
||||
value === "reliability" ||
|
||||
value === "other"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && LABEL_TYPE[value]) {
|
||||
return LABEL_TYPE[value];
|
||||
}
|
||||
return "functional";
|
||||
};
|
||||
|
||||
const sectionKey = (number?: string, title?: string) =>
|
||||
`${(number || "").trim()}__${(title || "").trim()}`;
|
||||
|
||||
const normalizeSectionNumber = (value?: string) =>
|
||||
(value || "").replace(/\s+/g, "").trim();
|
||||
|
||||
const parseSectionParts = (value?: string): number[] | null => {
|
||||
const normalized = normalizeSectionNumber(value);
|
||||
if (!normalized || !/^\d+(?:\.\d+)*$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized.split(".").map((part) => Number(part));
|
||||
};
|
||||
|
||||
const compareSectionNumbers = (left?: string, right?: string) => {
|
||||
const leftParts = parseSectionParts(left);
|
||||
const rightParts = parseSectionParts(right);
|
||||
|
||||
if (leftParts && rightParts) {
|
||||
const len = Math.max(leftParts.length, rightParts.length);
|
||||
for (let index = 0; index < len; index += 1) {
|
||||
const a = leftParts[index];
|
||||
const b = rightParts[index];
|
||||
if (a === undefined) {
|
||||
return -1;
|
||||
}
|
||||
if (b === undefined) {
|
||||
return 1;
|
||||
}
|
||||
if (a !== b) {
|
||||
return a - b;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (leftParts) {
|
||||
return -1;
|
||||
}
|
||||
if (rightParts) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (left || "").localeCompare(right || "", "zh-CN", { numeric: true });
|
||||
};
|
||||
|
||||
const compareSectionNodes = (left: SectionTreeNode, right: SectionTreeNode) => {
|
||||
const byNumber = compareSectionNumbers(left.sectionNumber, right.sectionNumber);
|
||||
if (byNumber !== 0) {
|
||||
return byNumber;
|
||||
}
|
||||
|
||||
return left.sectionTitle.localeCompare(right.sectionTitle, "zh-CN", {
|
||||
numeric: true,
|
||||
});
|
||||
};
|
||||
|
||||
const sortRequirements = (requirements: RequirementItem[]) => {
|
||||
return [...requirements].sort((left, right) => {
|
||||
const leftOrder = typeof left.sortOrder === "number" ? left.sortOrder : Number.MAX_SAFE_INTEGER;
|
||||
const rightOrder = typeof right.sortOrder === "number" ? right.sortOrder : Number.MAX_SAFE_INTEGER;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return left.id.localeCompare(right.id, "zh-CN", { numeric: true });
|
||||
});
|
||||
};
|
||||
|
||||
const sortSectionTree = (nodes: SectionTreeNode[]): SectionTreeNode[] => {
|
||||
return [...nodes]
|
||||
.map((node) => ({
|
||||
...node,
|
||||
requirements: sortRequirements(node.requirements),
|
||||
children: sortSectionTree(node.children),
|
||||
}))
|
||||
.sort(compareSectionNodes);
|
||||
};
|
||||
|
||||
const flattenSectionTree = (nodes: SectionTreeNode[]): SectionTreeNode[] => {
|
||||
const result: SectionTreeNode[] = [];
|
||||
const walk = (node: SectionTreeNode) => {
|
||||
result.push(node);
|
||||
node.children.forEach((child) => walk(child));
|
||||
};
|
||||
nodes.forEach((node) => walk(node));
|
||||
return result;
|
||||
};
|
||||
|
||||
const buildSectionTreeWithParents = (sourceNodes: SectionTreeNode[]): SectionTreeNode[] => {
|
||||
const numbered = new Map<string, SectionTreeNode>();
|
||||
const fallbackNodes: SectionTreeNode[] = [];
|
||||
const flattened = flattenSectionTree(sourceNodes);
|
||||
|
||||
flattened.forEach((source) => {
|
||||
const sectionNumber = normalizeSectionNumber(source.sectionNumber);
|
||||
const normalized: SectionTreeNode = {
|
||||
...source,
|
||||
sectionNumber,
|
||||
requirements: sortRequirements(source.requirements),
|
||||
children: [],
|
||||
};
|
||||
|
||||
const numberParts = parseSectionParts(sectionNumber);
|
||||
if (!numberParts) {
|
||||
fallbackNodes.push(normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = numbered.get(sectionNumber);
|
||||
if (!existing) {
|
||||
numbered.set(sectionNumber, normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
existing.requirements = sortRequirements([...existing.requirements, ...normalized.requirements]);
|
||||
if (!existing.sectionTitle && normalized.sectionTitle) {
|
||||
existing.sectionTitle = normalized.sectionTitle;
|
||||
}
|
||||
if (existing.sectionTitle === "未归类章节" && normalized.sectionTitle) {
|
||||
existing.sectionTitle = normalized.sectionTitle;
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(numbered.keys()).forEach((number) => {
|
||||
const parts = parseSectionParts(number);
|
||||
if (!parts || parts.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = 1; index < parts.length; index += 1) {
|
||||
const parentNumber = parts.slice(0, index).join(".");
|
||||
if (numbered.has(parentNumber)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
numbered.set(parentNumber, {
|
||||
key: `synthetic-${parentNumber}`,
|
||||
sectionNumber: parentNumber,
|
||||
sectionTitle: "",
|
||||
level: index,
|
||||
requirements: [],
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const roots: SectionTreeNode[] = [];
|
||||
const sortedNumbers = Array.from(numbered.keys()).sort(compareSectionNumbers);
|
||||
sortedNumbers.forEach((number) => {
|
||||
const node = numbered.get(number);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = parseSectionParts(number);
|
||||
node.level = parts ? parts.length : node.level;
|
||||
if (!parts || parts.length <= 1) {
|
||||
roots.push(node);
|
||||
return;
|
||||
}
|
||||
|
||||
const parentNumber = parts.slice(0, -1).join(".");
|
||||
const parent = numbered.get(parentNumber);
|
||||
if (!parent) {
|
||||
roots.push(node);
|
||||
return;
|
||||
}
|
||||
parent.children.push(node);
|
||||
});
|
||||
|
||||
const sortedRoots = sortSectionTree(roots);
|
||||
const sortedFallback = sortSectionTree(fallbackNodes);
|
||||
return [...sortedRoots, ...sortedFallback];
|
||||
};
|
||||
|
||||
const deepClone = (value: Record<string, unknown>) =>
|
||||
JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
|
||||
|
||||
const toRawRequirement = (item: RequirementItem): Record<string, unknown> => {
|
||||
const reqType = asRequirementType(item.requirementType);
|
||||
const raw: Record<string, unknown> = {
|
||||
需求类型: TYPE_LABEL[reqType],
|
||||
需求编号: item.id,
|
||||
需求描述: item.description,
|
||||
优先级: item.priority || "中",
|
||||
};
|
||||
|
||||
if (reqType === "interface") {
|
||||
raw["接口名称"] = item.interfaceName || "";
|
||||
raw["接口类型"] = item.interfaceType || "";
|
||||
raw["数据来源"] = item.dataSource || "";
|
||||
raw["数据目的地"] = item.dataDestination || "";
|
||||
}
|
||||
|
||||
return raw;
|
||||
};
|
||||
|
||||
const groupRequirementsBySection = (requirements: RequirementItem[]) => {
|
||||
const grouped = new Map<string, RequirementItem[]>();
|
||||
requirements.forEach((item) => {
|
||||
const key = sectionKey(item.sectionNumber, item.sectionTitle);
|
||||
const list = grouped.get(key) || [];
|
||||
list.push(item);
|
||||
grouped.set(key, list);
|
||||
});
|
||||
|
||||
for (const value of grouped.values()) {
|
||||
value.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
||||
}
|
||||
|
||||
return grouped;
|
||||
};
|
||||
|
||||
const rewriteContentRequirements = (
|
||||
content: Record<string, unknown>,
|
||||
grouped: Map<string, RequirementItem[]>
|
||||
) => {
|
||||
Object.values(content).forEach((section) => {
|
||||
if (!section || typeof section !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionNode = section as Record<string, unknown>;
|
||||
const sectionInfo =
|
||||
sectionNode["章节信息"] && typeof sectionNode["章节信息"] === "object"
|
||||
? (sectionNode["章节信息"] as Record<string, unknown>)
|
||||
: {};
|
||||
const key = sectionKey(
|
||||
typeof sectionInfo["章节编号"] === "string" ? sectionInfo["章节编号"] : "",
|
||||
typeof sectionInfo["章节标题"] === "string" ? sectionInfo["章节标题"] : ""
|
||||
);
|
||||
|
||||
const sectionReqs = grouped.get(key);
|
||||
if (sectionReqs) {
|
||||
sectionNode["需求列表"] = sectionReqs.map((item) => toRawRequirement(item));
|
||||
grouped.delete(key);
|
||||
} else if (Array.isArray(sectionNode["需求列表"])) {
|
||||
sectionNode["需求列表"] = [];
|
||||
}
|
||||
|
||||
const children =
|
||||
sectionNode["子章节"] && typeof sectionNode["子章节"] === "object"
|
||||
? (sectionNode["子章节"] as Record<string, unknown>)
|
||||
: null;
|
||||
if (children) {
|
||||
rewriteContentRequirements(children, grouped);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const appendUnmatched = (
|
||||
content: Record<string, unknown>,
|
||||
grouped: Map<string, RequirementItem[]>
|
||||
) => {
|
||||
if (grouped.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let orphan = content["未归类章节"];
|
||||
if (!orphan || typeof orphan !== "object") {
|
||||
orphan = {
|
||||
章节信息: {
|
||||
章节编号: "",
|
||||
章节标题: "未归类章节",
|
||||
章节级别: 1,
|
||||
},
|
||||
需求列表: [],
|
||||
};
|
||||
content["未归类章节"] = orphan;
|
||||
}
|
||||
|
||||
const orphanNode = orphan as Record<string, unknown>;
|
||||
const orphanReqs = Array.isArray(orphanNode["需求列表"])
|
||||
? (orphanNode["需求列表"] as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
|
||||
grouped.forEach((items) => {
|
||||
items.forEach((item) => orphanReqs.push(toRawRequirement(item)));
|
||||
});
|
||||
orphanNode["需求列表"] = orphanReqs;
|
||||
};
|
||||
|
||||
const buildFlatRawOutput = (
|
||||
requirements: RequirementItem[],
|
||||
documentName: string
|
||||
): Record<string, unknown> => {
|
||||
const grouped = groupRequirementsBySection(requirements);
|
||||
const content: Record<string, unknown> = {};
|
||||
|
||||
grouped.forEach((items, key) => {
|
||||
const [number = "", title = "未归类章节"] = key.split("__");
|
||||
const display = `${number} ${title}`.trim() || "未归类章节";
|
||||
content[display] = {
|
||||
章节信息: {
|
||||
章节编号: number,
|
||||
章节标题: title || "未归类章节",
|
||||
章节级别: number ? number.split(".").length : 1,
|
||||
},
|
||||
需求列表: items.map((item) => toRawRequirement(item)),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
文档元数据: {
|
||||
标题: documentName,
|
||||
生成时间: new Date().toISOString(),
|
||||
总需求数: requirements.length,
|
||||
需求类型统计: {},
|
||||
},
|
||||
需求内容: content,
|
||||
};
|
||||
};
|
||||
|
||||
const refreshMetadata = (
|
||||
rawOutput: Record<string, unknown>,
|
||||
requirements: RequirementItem[],
|
||||
documentName: string
|
||||
) => {
|
||||
const metadata =
|
||||
rawOutput["文档元数据"] && typeof rawOutput["文档元数据"] === "object"
|
||||
? (rawOutput["文档元数据"] as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const typeStats: Record<string, number> = {};
|
||||
requirements.forEach((item) => {
|
||||
const reqType = asRequirementType(item.requirementType);
|
||||
const label = TYPE_LABEL[reqType] || "功能需求";
|
||||
typeStats[label] = (typeStats[label] || 0) + 1;
|
||||
});
|
||||
|
||||
metadata["标题"] = documentName;
|
||||
metadata["生成时间"] = new Date().toISOString();
|
||||
metadata["总需求数"] = requirements.length;
|
||||
metadata["需求类型统计"] = typeStats;
|
||||
rawOutput["文档元数据"] = metadata;
|
||||
};
|
||||
|
||||
const toFallbackRequirement = (
|
||||
req: Record<string, unknown>,
|
||||
sectionNumber: string,
|
||||
sectionTitle: string,
|
||||
order: number
|
||||
): RequirementItem => {
|
||||
const requirementType = asRequirementType(req["需求类型"]);
|
||||
const description = typeof req["需求描述"] === "string" ? req["需求描述"] : "";
|
||||
return {
|
||||
id:
|
||||
typeof req["需求编号"] === "string" && req["需求编号"].trim().length > 0
|
||||
? req["需求编号"]
|
||||
: `REQ-${String(order + 1).padStart(3, "0")}`,
|
||||
description,
|
||||
priority:
|
||||
req["优先级"] === "高" || req["优先级"] === "中" || req["优先级"] === "低"
|
||||
? req["优先级"]
|
||||
: "中",
|
||||
acceptanceCriteria: description ? [description] : ["待补充验收标准"],
|
||||
sourceField: `${sectionNumber} ${sectionTitle}`.trim() || "文档解析",
|
||||
sectionNumber,
|
||||
sectionTitle,
|
||||
requirementType,
|
||||
interfaceName: typeof req["接口名称"] === "string" ? req["接口名称"] : "",
|
||||
interfaceType: typeof req["接口类型"] === "string" ? req["接口类型"] : "",
|
||||
dataSource: typeof req["数据来源"] === "string" ? req["数据来源"] : "",
|
||||
dataDestination: typeof req["数据目的地"] === "string" ? req["数据目的地"] : "",
|
||||
sortOrder: order,
|
||||
};
|
||||
};
|
||||
|
||||
export const rebuildRawOutput = (
|
||||
rawOutput: Record<string, unknown> | undefined,
|
||||
requirements: RequirementItem[],
|
||||
documentName: string
|
||||
): Record<string, unknown> => {
|
||||
if (!rawOutput || typeof rawOutput !== "object") {
|
||||
const fallback = buildFlatRawOutput(requirements, documentName);
|
||||
refreshMetadata(fallback, requirements, documentName);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const cloned = deepClone(rawOutput);
|
||||
const content =
|
||||
cloned["需求内容"] && typeof cloned["需求内容"] === "object"
|
||||
? (cloned["需求内容"] as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
if (!content) {
|
||||
const fallback = buildFlatRawOutput(requirements, documentName);
|
||||
refreshMetadata(fallback, requirements, documentName);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const grouped = groupRequirementsBySection(requirements);
|
||||
rewriteContentRequirements(content, grouped);
|
||||
appendUnmatched(content, grouped);
|
||||
refreshMetadata(cloned, requirements, documentName);
|
||||
return cloned;
|
||||
};
|
||||
|
||||
export const buildSectionTree = (
|
||||
rawOutput: Record<string, unknown> | undefined,
|
||||
requirements: RequirementItem[]
|
||||
): SectionTreeNode[] => {
|
||||
const byId = new Map(requirements.map((item) => [item.id, item]));
|
||||
|
||||
const content =
|
||||
rawOutput && rawOutput["需求内容"] && typeof rawOutput["需求内容"] === "object"
|
||||
? (rawOutput["需求内容"] as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
if (!content) {
|
||||
const grouped = groupRequirementsBySection(requirements);
|
||||
const nodes = Array.from(grouped.entries()).map(([key, items], index) => {
|
||||
const [sectionNumber = "", sectionTitle = "未归类章节"] = key.split("__");
|
||||
return {
|
||||
key: `${sectionNumber || "root"}-${index}`,
|
||||
sectionNumber,
|
||||
sectionTitle,
|
||||
level: sectionNumber ? sectionNumber.split(".").length : 1,
|
||||
requirements: items,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
return buildSectionTreeWithParents(nodes);
|
||||
}
|
||||
|
||||
let fallbackOrder = 0;
|
||||
|
||||
const walk = (node: unknown, path: string): SectionTreeNode | null => {
|
||||
if (!node || typeof node !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sectionNode = node as Record<string, unknown>;
|
||||
const sectionInfo =
|
||||
sectionNode["章节信息"] && typeof sectionNode["章节信息"] === "object"
|
||||
? (sectionNode["章节信息"] as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const sectionNumber =
|
||||
typeof sectionInfo["章节编号"] === "string"
|
||||
? normalizeSectionNumber(sectionInfo["章节编号"])
|
||||
: "";
|
||||
const sectionTitle =
|
||||
typeof sectionInfo["章节标题"] === "string"
|
||||
? sectionInfo["章节标题"]
|
||||
: "未归类章节";
|
||||
const level =
|
||||
typeof sectionInfo["章节级别"] === "number"
|
||||
? sectionInfo["章节级别"]
|
||||
: sectionNumber
|
||||
? sectionNumber.split(".").length
|
||||
: 1;
|
||||
|
||||
const reqList = Array.isArray(sectionNode["需求列表"])
|
||||
? (sectionNode["需求列表"] as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
|
||||
const mappedRequirements = reqList.map((req) => {
|
||||
const reqId = typeof req["需求编号"] === "string" ? req["需求编号"] : "";
|
||||
const current = byId.get(reqId);
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
const fallback = toFallbackRequirement(req, sectionNumber, sectionTitle, fallbackOrder);
|
||||
fallbackOrder += 1;
|
||||
return fallback;
|
||||
});
|
||||
|
||||
const childrenRoot =
|
||||
sectionNode["子章节"] && typeof sectionNode["子章节"] === "object"
|
||||
? (sectionNode["子章节"] as Record<string, unknown>)
|
||||
: null;
|
||||
const children = childrenRoot
|
||||
? Object.entries(childrenRoot)
|
||||
.map(([name, child]) => walk(child, `${path}/${name}`))
|
||||
.filter((child): child is SectionTreeNode => Boolean(child))
|
||||
: [];
|
||||
|
||||
if (mappedRequirements.length === 0 && children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: `${path}/${sectionNumber}-${sectionTitle}`,
|
||||
sectionNumber,
|
||||
sectionTitle,
|
||||
level,
|
||||
requirements: mappedRequirements,
|
||||
children,
|
||||
};
|
||||
};
|
||||
|
||||
const rawNodes = Object.entries(content)
|
||||
.map(([name, node]) => walk(node, name))
|
||||
.filter((node): node is SectionTreeNode => Boolean(node));
|
||||
|
||||
return buildSectionTreeWithParents(rawNodes);
|
||||
};
|
||||
@@ -19,14 +19,125 @@ export interface SrsResultResponse {
|
||||
documentName: string;
|
||||
generatedAt: string;
|
||||
statistics: Record<string, unknown>;
|
||||
requirements: Array<
|
||||
RequirementItem & {
|
||||
sectionNumber?: string | null;
|
||||
sectionTitle?: string | null;
|
||||
requirementType?: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
>;
|
||||
requirements: RequirementItem[];
|
||||
rawOutput: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SrsHistoryItem {
|
||||
jobId: number;
|
||||
documentName: string;
|
||||
generatedAt: string;
|
||||
totalRequirements: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface TestingPipelineItem {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TestingPipelineCase {
|
||||
id: string;
|
||||
item_id: string;
|
||||
operation_steps: string[];
|
||||
test_content: string;
|
||||
expected_result_placeholder: string;
|
||||
}
|
||||
|
||||
export interface TestingPipelineExpectedResult {
|
||||
id: string;
|
||||
case_id: string;
|
||||
result: string;
|
||||
}
|
||||
|
||||
export interface TestingPipelineResponse {
|
||||
trace_id: string;
|
||||
requirement_type: string;
|
||||
reason: string;
|
||||
candidates: string[];
|
||||
test_items: Record<string, TestingPipelineItem[]>;
|
||||
test_cases: Record<string, TestingPipelineCase[]>;
|
||||
expected_results: Record<string, TestingPipelineExpectedResult[]>;
|
||||
formatted_output: string;
|
||||
pipeline_summary: string;
|
||||
knowledge_used: boolean;
|
||||
}
|
||||
|
||||
export interface TestingGenerationSaveRequest {
|
||||
source_job_id?: number;
|
||||
source_document_name: string;
|
||||
knowledge_base_id?: number;
|
||||
generated_file: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestingGenerationJobCreateRequest {
|
||||
source_job_id?: number;
|
||||
source_document_name: string;
|
||||
knowledge_base_id?: number;
|
||||
requirements: Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
priority: string;
|
||||
acceptanceCriteria: string[];
|
||||
sourceField: string;
|
||||
sectionUid?: string;
|
||||
sectionNumber?: string;
|
||||
sectionTitle?: string;
|
||||
requirementType?: string;
|
||||
interfaceName?: string;
|
||||
interfaceType?: string;
|
||||
dataSource?: string;
|
||||
dataDestination?: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TestingGenerationJobCreateResponse {
|
||||
job_id: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TestingGenerationJobStatusResponse {
|
||||
job_id: number;
|
||||
tool_name: string;
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
error_message?: string | null;
|
||||
started_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
source_document_name?: string | null;
|
||||
current_step?: number | null;
|
||||
total_steps?: number | null;
|
||||
current_requirement_id?: string | null;
|
||||
}
|
||||
|
||||
export interface TestingGenerationResult {
|
||||
jobId: number;
|
||||
sourceJobId?: number | null;
|
||||
sourceDocumentName: string;
|
||||
generatedAt: string;
|
||||
totalRequirements: number;
|
||||
knowledgeBaseId?: number | null;
|
||||
generatedFile: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestingGenerationHistoryItem {
|
||||
jobId: number;
|
||||
sourceJobId?: number | null;
|
||||
sourceDocumentName: string;
|
||||
generatedAt: string;
|
||||
totalRequirements: number;
|
||||
knowledgeBaseId?: number | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const createSrsJob = async (file: File): Promise<SrsJobCreateResponse> => {
|
||||
@@ -51,11 +162,18 @@ export const saveSrsRequirements = async (
|
||||
): Promise<SrsResultResponse> => {
|
||||
const requirements = extraction.requirements.map((item, index) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
priority: item.priority,
|
||||
acceptanceCriteria: item.acceptanceCriteria,
|
||||
sourceField: item.sourceField,
|
||||
sectionUid: item.sectionUid,
|
||||
sectionNumber: item.sectionNumber,
|
||||
sectionTitle: item.sectionTitle,
|
||||
requirementType: item.requirementType,
|
||||
interfaceName: item.interfaceName,
|
||||
interfaceType: item.interfaceType,
|
||||
dataSource: item.dataSource,
|
||||
dataDestination: item.dataDestination,
|
||||
sortOrder: index,
|
||||
}));
|
||||
|
||||
@@ -71,5 +189,61 @@ export const toExtractionResult = (
|
||||
documentName: result.documentName,
|
||||
generatedAt: result.generatedAt,
|
||||
requirements: result.requirements,
|
||||
rawOutput: result.rawOutput,
|
||||
};
|
||||
};
|
||||
|
||||
export const listSrsHistory = async (): Promise<SrsHistoryItem[]> => {
|
||||
return api.get("/api/tools/srs/history") as Promise<SrsHistoryItem[]>;
|
||||
};
|
||||
|
||||
export const deleteSrsJob = async (jobId: number): Promise<void> => {
|
||||
await api.delete(`/api/tools/srs/jobs/${jobId}`);
|
||||
};
|
||||
|
||||
export const listKnowledgeBases = async (): Promise<KnowledgeBaseSummary[]> => {
|
||||
return api.get("/api/knowledge-base") as Promise<KnowledgeBaseSummary[]>;
|
||||
};
|
||||
|
||||
export const generateTestingContent = async (
|
||||
requirementText: string,
|
||||
knowledgeBaseId?: number
|
||||
): Promise<TestingPipelineResponse> => {
|
||||
return api.post("/api/testing/generate", {
|
||||
requirement_text: requirementText,
|
||||
knowledge_base_ids: knowledgeBaseId ? [knowledgeBaseId] : [],
|
||||
use_model_generation: true,
|
||||
}) as Promise<TestingPipelineResponse>;
|
||||
};
|
||||
|
||||
export const createTestingGenerationJob = async (
|
||||
payload: TestingGenerationJobCreateRequest
|
||||
): Promise<TestingGenerationJobCreateResponse> => {
|
||||
return api.post("/api/tools/testing/jobs", payload) as Promise<TestingGenerationJobCreateResponse>;
|
||||
};
|
||||
|
||||
export const getTestingGenerationJobStatus = async (
|
||||
jobId: number
|
||||
): Promise<TestingGenerationJobStatusResponse> => {
|
||||
return api.get(`/api/tools/testing/jobs/${jobId}`) as Promise<TestingGenerationJobStatusResponse>;
|
||||
};
|
||||
|
||||
export const saveTestingGeneration = async (
|
||||
payload: TestingGenerationSaveRequest
|
||||
): Promise<TestingGenerationResult> => {
|
||||
return api.post("/api/tools/testing/generations", payload) as Promise<TestingGenerationResult>;
|
||||
};
|
||||
|
||||
export const listTestingHistory = async (): Promise<TestingGenerationHistoryItem[]> => {
|
||||
return api.get("/api/tools/testing/history") as Promise<TestingGenerationHistoryItem[]>;
|
||||
};
|
||||
|
||||
export const getTestingGenerationResult = async (
|
||||
jobId: number
|
||||
): Promise<TestingGenerationResult> => {
|
||||
return api.get(`/api/tools/testing/jobs/${jobId}/result`) as Promise<TestingGenerationResult>;
|
||||
};
|
||||
|
||||
export const deleteTestingGeneration = async (jobId: number): Promise<void> => {
|
||||
await api.delete(`/api/tools/testing/jobs/${jobId}`);
|
||||
};
|
||||
|
||||
@@ -38,6 +38,9 @@ http {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
# Frontend
|
||||
@@ -81,4 +84,4 @@ http {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user