init. project

This commit is contained in:
2026-04-13 11:34:23 +08:00
commit c7c0659a85
202 changed files with 31196 additions and 0 deletions

24
.github/AGENTS.md vendored Normal file
View File

@@ -0,0 +1,24 @@
# 测试生成智能体约定
## 适用范围
- 本工作区包含基于 Tool Calling 与 Skill Calling 的测试内容生成链路。
- 当用户提出测试项分解、测试用例生成或预期成果生成需求时,必须触发 testing-orchestrator。
## 已注册技能
- identify-requirement-type将用户需求文本识别为明确的测试需求类型为后续测试项分解与测试用例生成提供分类依据。
- decompose-test-items按需求类型规则生成正常测试与异常测试测试项。
- generate-test-cases按测试项生成可执行测试用例至少 1 条/测试项。
- testing-orchestrator按标准顺序编排工具调用并输出结构化结果。
## 强制调用链
1. identify-requirement-type
2. decompose-test-items
3. generate-test-cases
4. build_expected_results
5. format_output
## 约束规则
- 除非用户明确要求只看中间步骤,否则禁止跳步。
- 每一步都必须显式接收上一步输出作为上下文输入。
- 若无法识别类型,必须输出未知类型及候选类型,并继续执行通用分解。
- 最终输出必须严格包含测试项、测试用例、预期成果三段,并按正常测试/异常测试分组。

View File

@@ -0,0 +1,46 @@
---
name: decompose-test-items
description: "当需要基于需求类型把需求文本分解为可执行的正常/异常测试项时使用。"
---
# decompose-test-items
## 目标
基于用户需求文本和已识别需求类型,生成测试项列表。
## 输入
- user_requirement_text
- requirement_type
## 输出
- normal_test_items完整、可执行的正常测试项列表。
- abnormal_test_items完整、可执行的异常测试项列表。
## 强制规则
1. 每个软件功能至少应被正常测试与被认可的异常场景覆盖;复杂功能需继续细分。
2. 每个测试项必须语义完整、可直接执行。
3. 覆盖必须包含:正常流程、边界条件(适用时)、异常条件。
4. 粒度需适中,避免过粗或过细。
5. 对未知类型必须执行通用分解,并保持正常/异常分组。
6. 对需求说明未显式给出但在用户手册或操作手册体现的功能,也应补充测试项覆盖。
## 14类最小分解检查点
- 功能测试:正常覆盖功能主路径、基本数据类型、合法边界值与状态转换;异常覆盖非法输入、不规则输入、非法边界值与最坏情况。
- 性能测试:正常覆盖处理精度、响应时间、处理数据量与模块协调性;异常覆盖超负荷、软硬件限制、负载潜力上限与资源占用异常。
- 外部接口测试:正常覆盖全部外部接口格式与内容正确性;异常覆盖每个输入输出接口的错误格式、错误内容与异常交互。
- 人机交互界面测试:正常覆盖界面风格一致性与标准操作流程;异常覆盖误操作、快速操作、非法输入、错误命令与错误流程提示。
- 强度测试:正常覆盖设计极限下系统功能和性能表现;异常覆盖超出极限时的降级行为、健壮性与饱和表现。
- 余量测试:正常覆盖存储、通道、处理时间余量是否满足要求;异常覆盖余量不足或耗尽时系统告警与受控行为。
- 可靠性测试:正常覆盖典型环境、运行剖面与输入变量组合;异常覆盖失效等级场景、边界环境变化、不合法输入域及失效记录。
- 安全性测试:正常覆盖安全关键部件、安全结构与合法操作路径;异常覆盖危险状态、故障模式、边界接合部、非法进入与数据完整性保护。
- 恢复性测试:正常覆盖故障探测、备用切换、恢复后继续执行;异常覆盖故障中作业保护、状态保护与恢复失败路径。
- 边界测试:正常覆盖输入输出域边界、状态转换端点与功能界限;异常覆盖性能界限、容量界限和越界端点。
- 安装性测试:正常覆盖标准及不同配置下安装卸载流程;异常覆盖安装规程错误、依赖异常与中断后的处理。
- 互操作性测试:正常覆盖两个或多个软件同时运行与互操作过程;异常覆盖互操作失败、并行冲突与协同异常。
- 敏感性测试:正常覆盖有效输入类中典型数据组合;异常覆盖引发不稳定或不正常处理的特殊数据组合。
- 测试充分性要求:正常覆盖需求覆盖率、配置项覆盖与代码覆盖达标;异常覆盖未覆盖部分逐项分析、确认与报告输出。
## 未知类型容错
- 当 requirement_type 无法确定时,仍需输出正常/异常两组测试项。
- 通用正常项至少包含:主流程正确性、合法边界值、标准输入输出。
- 通用异常项至少包含:非法输入、越界输入、资源异常或状态冲突。

View File

@@ -0,0 +1,45 @@
---
name: generate-test-cases
description: "当需要根据已分解测试项生成包含操作步骤与测试内容的具体测试用例时使用。"
---
# generate-test-cases
## 目标
按测试项生成测试用例,每个测试项至少对应 1 条用例。
## 输入
- normal_test_items
- abnormal_test_items
## 输出
- normal_test_cases
- abnormal_test_cases
每条测试用例必须包含:
- operation_steps
- test_content
- expected_result_placeholder
## 规则
1. 测试项与测试用例应保持一一对应关系。
2. 每个测试项必须至少生成 1 条测试用例。
3. 必须区分正常测试用例与异常测试用例。
4. 操作步骤应可顺序执行,避免歧义。
5. 操作步骤必须包含明确动作、对象和输入条件,禁止笼统动作词。
6. test_content 必须包含可验证条件,便于后续生成可度量预期成果。
## expected_result_placeholder 映射
- {{return_value}}:接口或函数返回值验证。
- {{state_change}}:系统状态变化验证。
- {{error_message}}:异常场景错误信息验证。
- {{data_persistence}}:数据库或存储落库结果验证。
- {{ui_display}}:界面显示反馈验证。
## 禁止模糊描述
- 错误示例:"检查功能正常";正确示例:"验证返回状态码为200且响应体包含status=success"。
- 错误示例:"输入合法数据";正确示例:"在用户名输入框输入长度为8的字母数字字符串并提交"。
- 错误示例:"系统提示错误";正确示例:"触发非法输入后显示错误码E400和字段级提示文案"。
## 预期结果耦合
- 每条用例必须可在下一步绑定一条明确、可验证的预期成果。

View File

@@ -0,0 +1,62 @@
---
name: identify-requirement-type
description: "当需要在测试项分解与测试用例生成之前识别需求类型时使用。"
---
# identify-requirement-type
## 目标
将用户需求文本识别为明确的测试需求类型,为后续测试项分解与测试用例生成提供分类依据。
## 输入
- user_requirement_text用户原始需求文本。
## 输出
- requirement_type以下之一
- 功能测试
- 性能测试
- 外部接口测试
- 人机交互界面测试
- 强度测试
- 余量测试
- 可靠性测试
- 安全性测试
- 恢复性测试
- 边界测试
- 安装性测试
- 互操作性测试
- 敏感性测试
- 测试充分性要求
- 未知类型
- reason简要判断依据。
- candidates当 requirement_type 为未知类型时,给出 1-3 个最接近候选类型。
## 类型识别信号
- 功能测试:关注功能需求逐项验证、业务流程正确性、输入输出行为、状态转换与边界值处理。
- 性能测试:关注处理精度、响应时间、处理数据量、系统协调性、负载潜力与运行占用空间。
- 外部接口测试:关注外部输入输出接口的格式、内容、协议与正常/异常交互表现。
- 人机交互界面测试:关注界面一致性、界面风格、操作流程、误操作健壮性与错误提示能力。
- 强度测试:关注系统在极限、超负荷、饱和和降级条件下的稳定性与承受能力。
- 余量测试:关注存储余量、输入输出通道余量、功能处理时间余量等资源裕度。
- 可靠性测试:关注真实或仿真环境下的失效等级、运行剖面、输入覆盖和长期稳定运行能力。
- 安全性测试:关注危险状态响应、安全关键部件、异常输入防护、非法访问阻断和数据完整性保护。
- 恢复性测试:关注故障探测、备用切换、系统状态保护与从无错误状态继续执行能力。
- 边界测试:关注输入输出域边界、状态转换端点、功能界限、性能界限与容量界限。
- 安装性测试:关注不同配置下安装卸载流程和安装规程执行正确性。
- 互操作性测试:关注多个软件并行运行时的互操作能力与协同正确性。
- 敏感性测试:关注有效输入类中可能引发不稳定或不正常处理的数据组合。
- 测试充分性要求:关注需求覆盖率、配置项覆盖、语句覆盖、分支覆盖及未覆盖分析确认。
## 规则
1. 优先依据需求文本中的显式表述进行分类。
2. 分类应以语义意图为主,不能只做关键词机械匹配。
3. 置信度不足时输出未知类型,并提供候选类型。
4. 判断依据需简洁、可追溯到文本证据。
## 容错
- 当需求描述过于笼统或跨多类型混合时,输出未知类型,并在 candidates 给出最接近类型。
- 当识别不稳定时,优先保守分类,不强行归入单一类型。
- 未知类型不阻断后续流程,应继续执行通用测试项分解。
## 调试
- debug 模式下返回每个类型的分类分数 classification_scores。

View File

@@ -0,0 +1,57 @@
---
name: testing-orchestrator
description: "当用户要求测试项分解或测试用例生成且需要完整工具调用链时使用。"
---
# testing-orchestrator
## 目标
严格执行测试生成调用链,并显式传递每一步上下文。
## 标准调用链
1. identify-requirement-type
2. decompose-test-items
3. generate-test-cases
4. build_expected_results
5. format_output
## 编排规则
1. 优先使用 Skill 与 Tool不使用临时硬编码逻辑替代。
2. 除非用户明确要求,否则不得跳过任何步骤。
3. 每一步必须显式接收上一步输出。
4. 分类失败时输出未知类型并继续执行通用分解。
## 输出模板
最终输出必须严格遵循以下分组结构:
**测试项**
**正常测试**
1. [测试项 N1]...
**异常测试**
1. [测试项 E1]...
**测试用例**
**正常测试**
1. [用例 N1](对应测试项 N1...
**异常测试**
1. [用例 E1](对应测试项 E1...
**预期成果**
**正常测试**
1. [预期 N1](对应用例 N1...
**异常测试**
1. [预期 E1](对应用例 E1...
## 调试模式
当 debug=true 时,输出步骤日志并包含:
- step_name
- input_summary
- output_summary
- success
- fallback_used

108
.github/测试项分解要求.md vendored Normal file
View File

@@ -0,0 +1,108 @@
5.4.5.1功能测试
功能测试是对软件需求规格说明中的功能需求逐项进行的测试,以验证其功能是否满足要求。功能测试一般需进行:
1. 每一个软件功能应至少被一个测试用例和一个被认可的异常所覆盖,对大的功能应进一步分解为更细的功能,使测试用例可以直接和功能对应:
2. 用基本数据类型和数据值测试:
3. 用一系列合理的数据类型和数据值运行,测试超负荷、饱和及其它“最坏情况”的结果;
4. 用假想的数据类型和数据值运行,测试排斥不规则输入的能力;
5. 每个功能的合法边界值和非法边界值都应被作为测试用例;
6. 应考虑软件功能对操作模式、运行环境、运行状态、状态转换、运行时间等的覆盖要求;
7. 对于在需求规格说明中没有指明,而在用户使用手册、操作手册中表明出来的每一功能及操作,都应有相应测试用例覆盖。
5.4.5.2性能测试
性能测试是对软件需求规格说明中的性能需求逐项进行的测试,以验证其性能是否满足要求。性能测试一般需进行:
1. 测试程序在获得定量结果时程序计算的精确性(处理精度. ;
2. 测试程序在有速度要求时完成功能的时间(响应时间. ;
3. 测试程序完成功能所能处理的数据量;
4. 测试程序各部分的协调性,如高速、低速操作的协调:
5. 测试软/硬件中因素是否限制了程序的性能;
6. 测试程序的负载潜力;
7. 测试程序运行占用的空间。
5.4.5.3外部接口测试
外部接口测试是对软件需求规格说明中的外部接口需求逐项进行的测试。外部接口测试一般需进行:
1. 测试所有外部接口,检查接口信息的格式及内容;
2. 对每一个外部的输入/输出接口做正常和异常情况的测试。
5.4.5.4人机交互界面测试
人机交互界面测试是对所有人机交互界面提供的操作和显示界面进行的测试,以检验是否满足用户的要求。人机交互界面测试一般需进行:
1. 测试操作和显示界面及界面风格与软件需求规格说明中要求的一致性和符合性:
2. 以非常规操作、误操作、快速操作来检验界面的健壮性;
3. 测试对错误命令或非法数据输入的检测能力与提示情况;
4. 测试对错误操作流程的检测与提示:
5. 如果有用户手册或操作手册,应对照手册逐条进行操作和观察。
5.4.5.5强度测试
强度测试是强制软件运行在不正常到发生故障的情况下(设计的极限状态到超出极限. ,检验软件可以运行到何种程度的测试。强度测试一般需进行:
1. 性能的强度测试;
2. 降级能力的强度测试;
3. 系统健壮性测试;
4. 系统饱和测试。
强度测试在某种程度上可看作性能测试的延伸测出软件功能、性能的实际极限。其详细要求可参见附录B“强度测试”。
5.4.5.6余量测试
余量测试是对软件是否达到需求规格说明中要求的余量的测试。若无明确要求时一般至少留有20%的余量。根据测试要求,余量测试一般需提供:
1. 全部存储量的余量;
2. 输入、输出及通道的余量;
3. 功能处理时间的余量。
5.4.5.7可靠性测试
可靠性测试是在真实的和仿真的环境中,为做出软件可靠性估计而对软件进行的功能测试(其输入覆盖和环境覆盖一般大于普通的功能测试. ,可靠性测试中必须按照运行剖面和使用的概率分布随机地选择测试用例。可靠性测试一般需:
1. 测试环境应与典型使用环境的统计特性相一致,必要时使用测试平台;
2. 定义软件失效等级;
3. 建立软件运行剖面/操作剖面;
4. 测试记录更为详细、准确,应记录失效现象和时间;
5. 必须保证输入覆盖,应覆盖重要的输入变量值、各种使用功能、相关输入变量可能组合以及不合法输入域等;
6. 对于可能导致软件运行方式改变的一些边界条件和环境条件,必须进行针对性测试。
有关可靠性测试的详细要求参见附录C“可靠性测试”。
5.4.5.8安全性测试
A、B、C级软件需要进行安全性测试。安全性测试是检验软件中已存在的安全性、安
全保密性措施是否有效的测试。安全性测试一般:
1. 应进行软件安全性分析,并且在软件需求中明确每一个危险状态及导致危险的可能原因,在测试中全面检验软件在这些危险状态下的反应;
2. 对安全性关键的软件部件,应单独测试,以确认该软件部件满足安全性需求:
3. 对软件设计中用于提高安全性的结构、算法、容错、冗余、中断处理等方案应进行针对性测试;
4. 测试应尽可能在符合实际使用的条件下进行;
5. 除在正常条件下测试外,应在异常条件下测试软件,以表明不会因可能的单个或多个输入错误而导致不安全状态;
6. 应包含硬件及软件输入故障模式测试:
7. 应包含边界、界外及边界接合部的测试;
8. 应包括“0”、穿越“0”以及从两个方向趋近于“0”的输入值
9. 应包含在最坏情况配置下的最小和最大输入数据率,以确定系统的固有能力及对这些环境的反应;
10. 操作员接口测试应包括在安全性关键操作中的操作员错误,以验证安全系统对这些错误的响应;
11. 应测试双工切换、多机替换的正确性和连续性;
12. 应测试防止非法进入系统并保护系统数据完整性的能力。
5.4.5.9恢复性测试
恢复性测试是对有恢复或重置(reset. 功能的软件的每一类导致恢复或重置的情况,逐一进行的测试,以验证其恢复或重置功能。恢复性测试是要证实在克服硬件故障后,系统能否正常地继续进行工作,且不对系统造成任何损害。恢复性测试一般需进行:
1. 探测错误功能的测试;
2. 能否切换或自动启动备用硬件的测试;
3. 在故障发生时能否保护正在运行的作业和系统状态的测试;
4. 在系统恢复后,能否从最后记录下来的无错误状态开始继续执行作业的测试。
5.4.5.10边界测试
边界测试是对软件处在边界或端点情况下运行状态的测试。边界测试一般需进行:
1. 软件的输入域或输出域的边界或端点的测试;
2. 状态转换的边界或端点的测试;
3. 功能界限的边界或端点的测试;
4. 性能界限的边界或端点的测试;
5. 容量界限的边界或端点的测试。
5.4.5.11安装性测试
安装性测试是对安装过程是否符合安装规程的测试,以发现安装过程中的错误。安装性测试一般需进行:
1. 不同配置下的安装和卸载测试;
2. 安装规程的正确性的测试。
5.4.5.12互操作性测试
互操作性测试是为验证不同软件之间的互操作能力而进行的测试。互操作性测试一般:
1. 必须同时运行两个或多个不同的软件;
2. 软件之间发生互操作。
5.4.5.13敏感性测试
敏感性测试是为发现在有效输入类中可能引起某种不稳定性或不正常处理的某些数据的组合而进行的测试。敏感性测试一般需进行:
1. 发现有效输入类中可能引起某种不稳定性的数据组合的测试;
2. 发现有效输入类中可能引起某种不正常处理的数据组合的测试。
5.4.5.14测试充分性要求
1. 对软件需求规格说明中明确和隐含的需求(包括功能、性能、接口、质量要求等. 的覆盖率应达到100%;
2. 配置项测试应使用与软件开发相同的编译器,全面覆盖软件需求说明文档中的所有要求。
3. 对于A、B级嵌入式软件对配置项源程序测试的语句、分支覆盖率均应达到100%。对用高级语言编制的A、B级嵌入式软件应对配置项目标码进行结构分析和测试测试的目标码语句、分支覆盖率均应达到100%。对覆盖率达不到要求的软件,应对未覆盖的部分逐一进行分析和确认,并提供分析报告。

164
README.md Normal file
View File

@@ -0,0 +1,164 @@
<div align="center">
<img src="./docs/images/github-cover-new.png" alt="RAG Web UI" />
<br />
<p>
<strong>基于 RAG 的知识库问答与文档处理平台</strong>
</p>
<p>
<a href="https://github.com/rag-web-ui/rag-web-ui/blob/main/LICENSE"><img src="https://img.shields.io/github/license/rag-web-ui/rag-web-ui" alt="License" /></a>
<a href="#"><img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python" /></a>
<a href="#"><img src="https://img.shields.io/badge/node-%3E%3D18-green.svg" alt="Node" /></a>
<a href="#"><img src="https://github.com/rag-web-ui/rag-web-ui/actions/workflows/test.yml/badge.svg" alt="CI" /></a>
</p>
<p>
<a href="#简介">简介</a> •
<a href="#核心能力">核心能力</a> •
<a href="#项目结构">项目结构</a> •
<a href="#快速开始">快速开始</a> •
<a href="#配置说明">配置说明</a> •
<a href="#api-概览">API 概览</a> •
<a href="#常见问题">常见问题</a>
</p>
</div>
## 简介
RAG Web UI 是一个面向企业知识库场景的全栈项目,提供从文档接入、向量化检索、对话问答到文档工程化处理的完整链路。
项目采用前后端分离架构:
- 后端基于 FastAPI负责认证、知识库、向量检索、文档处理任务与工具调用。
- 前端基于 Next.js提供知识库管理、聊天、文档处理等可视化页面。
- 基础设施使用 MySQL、ChromaDB、MinIO可通过 Docker Compose 一键启动。
## 核心能力
- 知识库管理:支持 PDF、DOCX、Markdown、TXT 上传,预览、异步处理、状态追踪。
- RAG 对话:支持多轮上下文问答,结合知识库检索结果生成回答。
- 文档处理中心:提供需求提取、测试内容生成等工程化能力。
- 工具中心:后端内置工具注册与调用机制,支持后续扩展更多可被模型调用的工具。
- 多模型支持:支持 OpenAI、DashScope、DeepSeek、Ollama 等配置方式。
## 项目结构
```text
rag-web-ui/
backend/ # FastAPI 后端
app/
api/api_v1/ # 业务 API 路由auth/chat/knowledge-base/testing/tools
services/ # 文档处理、检索、向量存储等服务层
tools/ # 工具中心(包含 SRS 需求提取工具)
models/ # SQLAlchemy 模型
schemas/ # Pydantic 数据模型
alembic/ # 数据库迁移
frontend/ # Next.js 前端
src/app/ # 页面与路由
src/components/ # 组件
src/lib/ # API 封装、工具调用封装
docs/ # 文档与教程
docker-compose.yml # 标准部署编排
docker-compose.dev.yml # 开发调试编排
```
## 快速开始
### 环境要求
- Docker 与 Docker Compose v2+
- Node.js 18+
- Python 3.9+
- 推荐 8GB 及以上内存
### 1. 准备环境变量
```bash
cp .env.example .env
```
### 2. 启动服务
```bash
docker compose up -d --build
```
### 3. 访问服务
- 前端页面: http://127.0.0.1.nip.io
- API 文档: http://127.0.0.1.nip.io/redoc
- MinIO 控制台: http://127.0.0.1.nip.io:9001
## 配置说明
### 核心配置
| 配置项 | 说明 | 示例 |
| --- | --- | --- |
| MYSQL_SERVER | MySQL 主机 | localhost 或 db |
| MYSQL_PORT | MySQL 端口 | 3306 |
| MYSQL_USER | MySQL 用户名 | ragagent |
| MYSQL_PASSWORD | MySQL 密码 | ragagent |
| MYSQL_DATABASE | MySQL 库名 | ragagent |
| SECRET_KEY | JWT 密钥 | 自定义随机字符串 |
| ACCESS_TOKEN_EXPIRE_MINUTES | Token 过期时间(分钟) | 10080 |
### 模型与向量配置
| 配置项 | 说明 | 示例 |
| --- | --- | --- |
| CHAT_PROVIDER | 对话模型提供商 | dashscope/openai/deepseek/ollama |
| EMBEDDINGS_PROVIDER | 向量模型提供商 | dashscope/openai/ollama |
| DASH_SCOPE_API_KEY | DashScope Key | sk-xxx |
| DASH_SCOPE_CHAT_MODEL | DashScope 对话模型 | qwen3.5-plus|
| DASH_SCOPE_EMBEDDINGS_MODEL | DashScope 向量模型 | text-embedding-v4 |
| VECTOR_STORE_TYPE | 向量库类型 | chroma 或 qdrant |
说明:
- 当使用 DashScope 兼容模式时,建议使用文本向量模型(例如 text-embedding-v4
- 项目支持通过 API_KEY 作为统一兜底密钥,再由各 provider 配置覆盖。
### 存储配置
| 配置项 | 说明 | 示例 |
| --- | --- | --- |
| MINIO_ENDPOINT | MinIO 地址 | localhost:9000 |
| MINIO_ACCESS_KEY | MinIO 用户名 | minioadmin |
| MINIO_SECRET_KEY | MinIO 密码 | minioadmin |
| MINIO_BUCKET_NAME | 桶名 | documents |
## API 概览
后端统一前缀为 /api。
主要路由:
- /api/auth: 登录、注册、令牌。
- /api/knowledge-base: 知识库与文档上传/处理/任务状态。
- /api/chat: 会话与消息。
- /api/testing: 测试内容生成流水线。
- /api/tools: 工具中心(包含 SRS 需求提取任务接口)。
## 开发与测试
### 前端类型检查
```bash
cd frontend
pnpm exec tsc --noEmit
```
### 后端测试
```bash
cd backend
python -m pytest tests/test_testing_pipeline.py
```
### 数据库迁移
```bash
cd backend
alembic upgrade head
```
## 常见问题
### 1) 提示某张表不存在(例如 tool_jobs
原因:数据库迁移未执行到最新版本。
处理:执行 alembic upgrade head或重启后端让启动迁移自动执行。
### 2) 知识库文档处理失败并出现向量模型错误
原因:模型配置与 provider 兼容性不匹配,或账号配额不足。
处理:
- 检查 EMBEDDINGS_PROVIDER 与模型名是否匹配。
- DashScope 兼容模式优先使用 text-embedding-v4。
- 若报配额不足AllocationQuota.FreeTierOnly请切换可用 API Key 或开通付费资源。
### 3) 重复点击“开始处理”后状态异常
后端已增加幂等处理与重试兜底,仍建议单次提交后等待任务轮询完成再重复操作。
## 许可证
本项目基于 LICENSE 文件中定义的条款发布。

55
rag-web-ui/.env.example Normal file
View File

@@ -0,0 +1,55 @@
PROJECT_NAME=RAG Agent
VERSION=0.1.0
API_V1_STR=/api
MYSQL_SERVER=localhost
MYSQL_PORT=3306
MYSQL_USER=ragagent
MYSQL_PASSWORD=ragagent
MYSQL_DATABASE=ragagent
API_KEY= # API Key服务商官网获取
OPENAI_API_KEY= # API Key和上面一致
OPENAI_API_BASE= # base-url服务商官网获取
OPENAI_MODEL= # 文本生成模型名称,例如 qwen3.5-plus
OPENAI_EMBEDDINGS_MODEL= # 向量模型名称,例如 text-embedding-v4
SECRET_KEY=dev-secret-key-change-me
ACCESS_TOKEN_EXPIRE_MINUTES=10080
CHAT_PROVIDER=dashscope
EMBEDDINGS_PROVIDER=dashscope
DASH_SCOPE_API_KEY= # API Key和上面一致
DASH_SCOPE_API_BASE= # base-url和上面一致
DASH_SCOPE_CHAT_MODEL= # 文本生成模型名称
DASH_SCOPE_EMBEDDINGS_MODEL= # 向量模型名称
VECTOR_STORE_TYPE=chroma
CHROMA_DB_HOST=localhost
CHROMA_DB_PORT=8001
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET_NAME=documents
RERANKER_API_URL= # reranker 模型的 base-url服务商官网获取
RERANKER_API_KEY= # API Key和上面一致
RERANKER_MODEL= # reranker 模型名称,例如 qwen3-vl-rerank
RERANKER_TIMEOUT_SECONDS=10
RERANKER_WEIGHT=0.75
GRAPHRAG_ENABLED=true
GRAPHRAG_WORKING_DIR=./graphrag_cache
GRAPHRAG_GRAPH_STORAGE=neo4j
GRAPHRAG_QUERY_LEVEL=2
GRAPHRAG_LOCAL_TOP_K=20
GRAPHRAG_ENTITY_EXTRACT_MAX_GLEANING=1
GRAPHRAG_EMBEDDING_DIM=1024
GRAPHRAG_EMBEDDING_MAX_TOKEN_SIZE=8192
NEO4J_URL=bolt://localhost:7687
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=12345678

27
rag-web-ui/.gitattributes vendored Normal file
View File

@@ -0,0 +1,27 @@
# Set default behavior to automatically normalize line endings
* text=auto
# Unix/Linux/macOS style files (using LF)
*.sh text eol=lf
*.bash text eol=lf
Dockerfile text eol=lf
.dockerignore text eol=lf
docker-compose*.yml text eol=lf
*.py text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.md text eol=lf
# Windows style files (using CRLF)
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
*.ps1 text eol=crlf
# Binary files (no conversion)
*.png binary
*.jpg binary
*.gif binary
*.ico binary
*.zip binary
*.pdf binary

60
rag-web-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,60 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.pytest_cache/
.coverage
htmlcov/
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
chroma_db/
# Node/Next.js
node_modules/
.next/
out/
.DS_Store
*.pem
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.vercel
.turbo
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Project specific
backend/static/
backend/media/
frontend/.env
backend/.env

201
rag-web-ui/LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 Marcus Schiesser
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,35 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
build-essential \
default-libmysqlclient-dev \
pkg-config \
netcat-traditional \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements file
COPY requirements.txt .
# Install Python packages
RUN pip install --no-cache-dir -r requirements.txt
# Copy entrypoint script first
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
# Copy application files
COPY . .
# Create uploads directory
RUN mkdir -p uploads
# Set Python path and environment
ENV PYTHONPATH=/app
ENV ENVIRONMENT=production
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,37 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
build-essential \
default-libmysqlclient-dev \
pkg-config \
netcat-traditional \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements file
COPY requirements.txt .
# Install Python packages with retry mechanism
RUN pip install --no-cache-dir -r requirements.txt || \
(echo "Retrying in 5 seconds..." && sleep 5 && pip install --no-cache-dir -r requirements.txt) || \
(echo "Retrying in 10 seconds..." && sleep 10 && pip install --no-cache-dir -r requirements.txt)
# Copy entrypoint script first
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
# Copy the rest of the application
COPY . .
# Create uploads directory
RUN mkdir -p uploads
# Set Python path and environment
ENV PYTHONPATH=/app
ENV ENVIRONMENT=development
# Run the application
ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,42 @@
[alembic]
script_location = alembic
sqlalchemy.url = mysql+mysqlconnector://ragagent:ragagent@db/ragagent
[loggers]
keys = root,sqlalchemy,alembic,uvicorn
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_uvicorn]
level = INFO
handlers =
qualname = uvicorn
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -0,0 +1,90 @@
import os
import sys
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Add the parent directory to Python path
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.models.base import Base
from app.models.user import User
from app.models.knowledge import KnowledgeBase, Document
from app.models.chat import Chat, Message
from app.models.tooling import ToolJob, SRSExtraction, SRSRequirement
from app.core.config import settings
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_url():
return settings.get_database_url
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = get_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = get_url()
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,36 @@
"""increase_api_key_length
Revision ID: 3580c0dcd005
Revises: e214adf7fb66
Create Date: 2024-01-20 14:25:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3580c0dcd005'
down_revision: Union[str, None] = 'e214adf7fb66'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('api_keys', 'key',
existing_type=sa.String(length=64),
type_=sa.String(length=128),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('api_keys', 'key',
existing_type=sa.String(length=128),
type_=sa.String(length=64),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,116 @@
"""rename_metadata_to_chunk_metadata
Revision ID: 59cfa0f1361d
Revises: initial_schema
Create Date: 2025-01-13 23:26:38.232326
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = '59cfa0f1361d'
down_revision: Union[str, None] = 'initial_schema'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_index(op.f('ix_chats_id'), 'chats', ['id'], unique=False)
op.add_column('document_chunks', sa.Column('document_id', sa.Integer(), nullable=False))
op.add_column('document_chunks', sa.Column('chunk_metadata', sa.JSON(), nullable=True))
op.alter_column('document_chunks', 'created_at',
existing_type=mysql.TIMESTAMP(),
type_=sa.DateTime(),
nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.alter_column('document_chunks', 'updated_at',
existing_type=mysql.TIMESTAMP(),
type_=sa.DateTime(),
nullable=False,
existing_server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'))
op.drop_index('idx_hash', table_name='document_chunks')
op.create_index(op.f('ix_document_chunks_hash'), 'document_chunks', ['hash'], unique=False)
op.create_foreign_key(None, 'document_chunks', 'knowledge_bases', ['kb_id'], ['id'])
op.create_foreign_key(None, 'document_chunks', 'documents', ['document_id'], ['id'])
op.drop_column('document_chunks', 'metadata')
op.drop_index('idx_file_hash', table_name='documents')
op.create_index(op.f('ix_documents_file_hash'), 'documents', ['file_hash'], unique=False)
op.create_index(op.f('ix_documents_id'), 'documents', ['id'], unique=False)
op.create_index(op.f('ix_knowledge_bases_id'), 'knowledge_bases', ['id'], unique=False)
op.create_index(op.f('ix_messages_id'), 'messages', ['id'], unique=False)
op.alter_column('processing_tasks', 'knowledge_base_id',
existing_type=mysql.INTEGER(),
nullable=True)
op.alter_column('processing_tasks', 'document_id',
existing_type=mysql.INTEGER(),
nullable=True)
op.alter_column('processing_tasks', 'status',
existing_type=mysql.VARCHAR(length=50),
nullable=True)
op.alter_column('processing_tasks', 'created_at',
existing_type=mysql.DATETIME(),
nullable=True)
op.alter_column('processing_tasks', 'updated_at',
existing_type=mysql.DATETIME(),
nullable=True)
op.create_index(op.f('ix_processing_tasks_id'), 'processing_tasks', ['id'], unique=False)
op.drop_index('email', table_name='users')
op.drop_index('username', table_name='users')
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.create_index('username', 'users', ['username'], unique=True)
op.create_index('email', 'users', ['email'], unique=True)
op.drop_index(op.f('ix_processing_tasks_id'), table_name='processing_tasks')
op.alter_column('processing_tasks', 'updated_at',
existing_type=mysql.DATETIME(),
nullable=False)
op.alter_column('processing_tasks', 'created_at',
existing_type=mysql.DATETIME(),
nullable=False)
op.alter_column('processing_tasks', 'status',
existing_type=mysql.VARCHAR(length=50),
nullable=False)
op.alter_column('processing_tasks', 'document_id',
existing_type=mysql.INTEGER(),
nullable=False)
op.alter_column('processing_tasks', 'knowledge_base_id',
existing_type=mysql.INTEGER(),
nullable=False)
op.drop_index(op.f('ix_messages_id'), table_name='messages')
op.drop_index(op.f('ix_knowledge_bases_id'), table_name='knowledge_bases')
op.drop_index(op.f('ix_documents_id'), table_name='documents')
op.drop_index(op.f('ix_documents_file_hash'), table_name='documents')
op.create_index('idx_file_hash', 'documents', ['file_hash'], unique=False)
op.add_column('document_chunks', sa.Column('metadata', mysql.JSON(), nullable=True))
op.drop_constraint(None, 'document_chunks', type_='foreignkey')
op.drop_constraint(None, 'document_chunks', type_='foreignkey')
op.drop_index(op.f('ix_document_chunks_hash'), table_name='document_chunks')
op.create_index('idx_hash', 'document_chunks', ['hash'], unique=False)
op.alter_column('document_chunks', 'updated_at',
existing_type=sa.DateTime(),
type_=mysql.TIMESTAMP(),
nullable=True,
existing_server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'))
op.alter_column('document_chunks', 'created_at',
existing_type=sa.DateTime(),
type_=mysql.TIMESTAMP(),
nullable=True,
existing_server_default=sa.text('CURRENT_TIMESTAMP'))
op.drop_column('document_chunks', 'chunk_metadata')
op.drop_column('document_chunks', 'document_id')
op.drop_index(op.f('ix_chats_id'), table_name='chats')
# ### end Alembic commands ###

View File

@@ -0,0 +1,37 @@
"""add_document_upload_id_to_processing_tasks
Revision ID: 5be054bd6587
Revises: fd73eebc87c1
Create Date: 2025-01-14 01:17:24.164593
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5be054bd6587'
down_revision: Union[str, None] = 'fd73eebc87c1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. 添加 document_upload_id 字段
op.execute("""
ALTER TABLE processing_tasks
ADD COLUMN document_upload_id INT,
ADD CONSTRAINT processing_tasks_document_upload_id_fkey
FOREIGN KEY (document_upload_id) REFERENCES document_uploads(id)
""")
def downgrade() -> None:
# 1. 删除外键约束和字段
op.execute("""
ALTER TABLE processing_tasks
DROP FOREIGN KEY processing_tasks_document_upload_id_fkey,
DROP COLUMN document_upload_id
""")

View File

@@ -0,0 +1,102 @@
"""add tool jobs and srs tables
Revision ID: a4f9c89b7d11
Revises: 3580c0dcd005
Create Date: 2026-04-12 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "a4f9c89b7d11"
down_revision: Union[str, None] = "3580c0dcd005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"tool_jobs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("tool_name", sa.String(length=128), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending"),
sa.Column("input_file_name", sa.String(length=255), nullable=False),
sa.Column("input_file_path", sa.String(length=512), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.Column("output_summary", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_tool_jobs_id"), "tool_jobs", ["id"], unique=False)
op.create_index(op.f("ix_tool_jobs_tool_name"), "tool_jobs", ["tool_name"], unique=False)
op.create_index(op.f("ix_tool_jobs_user_id"), "tool_jobs", ["user_id"], unique=False)
op.create_table(
"srs_extractions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("job_id", sa.Integer(), nullable=False),
sa.Column("document_name", sa.String(length=255), nullable=False),
sa.Column("document_title", sa.String(length=255), nullable=False),
sa.Column("generated_at", sa.DateTime(), nullable=False),
sa.Column("total_requirements", sa.Integer(), nullable=False),
sa.Column("statistics", sa.JSON(), nullable=True),
sa.Column("raw_output", sa.JSON(), nullable=True),
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.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("job_id"),
)
op.create_index(op.f("ix_srs_extractions_id"), "srs_extractions", ["id"], unique=False)
op.create_table(
"srs_requirements",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("extraction_id", sa.Integer(), nullable=False),
sa.Column("requirement_uid", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("description", mysql.LONGTEXT(), nullable=False),
sa.Column("priority", sa.String(length=16), nullable=False),
sa.Column("acceptance_criteria", sa.JSON(), nullable=False),
sa.Column("source_field", sa.String(length=255), nullable=False),
sa.Column("section_number", sa.String(length=64), nullable=True),
sa.Column("section_title", sa.String(length=255), nullable=True),
sa.Column("requirement_type", sa.String(length=64), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["extraction_id"], ["srs_extractions.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("extraction_id", "requirement_uid", name="uq_srs_extraction_requirement_uid"),
)
op.create_index(op.f("ix_srs_requirements_id"), "srs_requirements", ["id"], unique=False)
op.create_index(
"idx_srs_requirements_extraction_sort",
"srs_requirements",
["extraction_id", "sort_order"],
unique=False,
)
def downgrade() -> None:
op.drop_index("idx_srs_requirements_extraction_sort", table_name="srs_requirements")
op.drop_index(op.f("ix_srs_requirements_id"), table_name="srs_requirements")
op.drop_table("srs_requirements")
op.drop_index(op.f("ix_srs_extractions_id"), table_name="srs_extractions")
op.drop_table("srs_extractions")
op.drop_index(op.f("ix_tool_jobs_user_id"), table_name="tool_jobs")
op.drop_index(op.f("ix_tool_jobs_tool_name"), table_name="tool_jobs")
op.drop_index(op.f("ix_tool_jobs_id"), table_name="tool_jobs")
op.drop_table("tool_jobs")

View File

@@ -0,0 +1,49 @@
"""add_api_keys_table
Revision ID: e214adf7fb66
Revises: 5be054bd6587
Create Date: 2024-01-20 13:24:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e214adf7fb66'
down_revision: Union[str, None] = '5be054bd6587'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'api_keys',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('key', sa.String(length=64), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'), onupdate=sa.text('CURRENT_TIMESTAMP')),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key')
)
op.create_index(op.f('ix_api_keys_id'), 'api_keys', ['id'], unique=False)
op.create_index(op.f('ix_api_keys_key'), 'api_keys', ['key'], unique=True)
op.create_index(op.f('ix_api_keys_name'), 'api_keys', ['name'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_api_keys_name'), table_name='api_keys')
op.drop_index(op.f('ix_api_keys_key'), table_name='api_keys')
op.drop_index(op.f('ix_api_keys_id'), table_name='api_keys')
op.drop_table('api_keys')
# ### end Alembic commands ###

View File

@@ -0,0 +1,44 @@
"""add document uploads table
Revision ID: fd73eebc87c1
Revises: 59cfa0f1361d
Create Date: 2024-01-13 16:24:07.182834
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fd73eebc87c1'
down_revision = '59cfa0f1361d'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'document_uploads',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('knowledge_base_id', sa.Integer(), nullable=False),
sa.Column('file_name', sa.String(255), nullable=False),
sa.Column('file_hash', sa.String(64), nullable=False),
sa.Column('file_size', sa.BigInteger(), nullable=False),
sa.Column('content_type', sa.String(100), nullable=False),
sa.Column('temp_path', sa.String(255), nullable=False),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False),
sa.Column('status', sa.String(50), nullable=False, server_default='pending'),
sa.Column('error_message', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['knowledge_base_id'], ['knowledge_bases.id'], ondelete='CASCADE')
)
# 添加索引以加速查询
op.create_index('ix_document_uploads_created_at', 'document_uploads', ['created_at'])
op.create_index('ix_document_uploads_status', 'document_uploads', ['status'])
def downgrade() -> None:
op.drop_index('ix_document_uploads_status')
op.drop_index('ix_document_uploads_created_at')
op.drop_table('document_uploads')

View File

@@ -0,0 +1,148 @@
"""initial schema
Revision ID: initial_schema
Revises:
Create Date: 2024-01-13 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = 'initial_schema'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create users table
op.create_table(
'users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(255), nullable=False),
sa.Column('username', sa.String(255), nullable=False),
sa.Column('hashed_password', sa.String(255), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('is_superuser', sa.Boolean(), nullable=True, default=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('username')
)
# Create knowledge_bases table
op.create_table(
'knowledge_bases',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('description', mysql.LONGTEXT(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# Create documents table
op.create_table(
'documents',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('file_path', sa.String(255), nullable=False),
sa.Column('file_name', sa.String(255), nullable=False),
sa.Column('file_size', sa.Integer(), nullable=True),
sa.Column('content_type', sa.String(100), nullable=True),
sa.Column('file_hash', sa.String(64), nullable=True),
sa.Column('knowledge_base_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['knowledge_base_id'], ['knowledge_bases.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('knowledge_base_id', 'file_name', name='uq_kb_file_name')
)
# Create document_chunks table
op.create_table(
'document_chunks',
sa.Column('id', sa.String(64), nullable=False),
sa.Column('kb_id', sa.Integer(), nullable=False),
sa.Column('file_name', sa.String(255), nullable=False),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('hash', sa.String(64), nullable=False),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
)
# Create chats table
op.create_table(
'chats',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(255), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# Create chat_knowledge_bases table (association table)
op.create_table(
'chat_knowledge_bases',
sa.Column('chat_id', sa.Integer(), nullable=False),
sa.Column('knowledge_base_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['chat_id'], ['chats.id'], ),
sa.ForeignKeyConstraint(['knowledge_base_id'], ['knowledge_bases.id'], ),
sa.PrimaryKeyConstraint('chat_id', 'knowledge_base_id')
)
# Create messages table
op.create_table(
'messages',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('chat_id', sa.Integer(), nullable=False),
sa.Column('role', sa.String(50), nullable=False),
sa.Column('content', mysql.LONGTEXT(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['chat_id'], ['chats.id'], ),
sa.PrimaryKeyConstraint('id')
)
# Create processing_tasks table
op.create_table(
'processing_tasks',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('knowledge_base_id', sa.Integer(), nullable=False),
sa.Column('document_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(50), nullable=False, default='pending'),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ),
sa.ForeignKeyConstraint(['knowledge_base_id'], ['knowledge_bases.id'], ),
sa.PrimaryKeyConstraint('id')
)
# Create indexes
op.create_index('idx_kb_file_name', 'document_chunks', ['kb_id', 'file_name'])
op.create_index('idx_hash', 'document_chunks', ['hash'])
op.create_index('idx_file_hash', 'documents', ['file_hash'])
def downgrade() -> None:
# Drop indexes
op.drop_index('idx_hash', table_name='document_chunks')
op.drop_index('idx_kb_file_name', table_name='document_chunks')
op.drop_index('idx_file_hash', table_name='documents')
# Drop tables in reverse order
op.drop_table('processing_tasks')
op.drop_table('messages')
op.drop_table('chat_knowledge_bases')
op.drop_table('chats')
op.drop_table('document_chunks')
op.drop_table('documents')
op.drop_table('knowledge_bases')
op.drop_table('users')

View File

View File

View File

@@ -0,0 +1,11 @@
from fastapi import APIRouter
from app.api.api_v1 import api_keys, auth, chat, knowledge_base, testing, tools
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(knowledge_base.router, prefix="/knowledge-base", tags=["knowledge-base"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(api_keys.router, prefix="/api-keys", tags=["api-keys"])
api_router.include_router(testing.router, prefix="/testing", tags=["testing"])
api_router.include_router(tools.router, prefix="/tools", tags=["tools"])

View File

@@ -0,0 +1,84 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
import logging
from app import models, schemas
from app.db.session import get_db
from app.services.api_key import APIKeyService
from app.core.security import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/", response_model=List[schemas.APIKey])
def read_api_keys(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(get_current_user),
) -> Any:
"""
Retrieve API keys.
"""
api_keys = APIKeyService.get_api_keys(
db=db, user_id=current_user.id, skip=skip, limit=limit
)
return api_keys
@router.post("/", response_model=schemas.APIKey)
def create_api_key(
*,
db: Session = Depends(get_db),
api_key_in: schemas.APIKeyCreate,
current_user: models.User = Depends(get_current_user),
) -> Any:
"""
Create new API key.
"""
api_key = APIKeyService.create_api_key(
db=db, user_id=current_user.id, name=api_key_in.name
)
logger.info(f"API key created: {api_key.key} for user {current_user.id}")
return api_key
@router.put("/{id}", response_model=schemas.APIKey)
def update_api_key(
*,
db: Session = Depends(get_db),
id: int,
api_key_in: schemas.APIKeyUpdate,
current_user: models.User = Depends(get_current_user),
) -> Any:
"""
Update API key.
"""
api_key = APIKeyService.get_api_key(db=db, api_key_id=id)
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
if api_key.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
api_key = APIKeyService.update_api_key(db=db, api_key=api_key, update_data=api_key_in)
logger.info(f"API key updated: {api_key.key} for user {current_user.id}")
return api_key
@router.delete("/{id}", response_model=schemas.APIKey)
def delete_api_key(
*,
db: Session = Depends(get_db),
id: int,
current_user: models.User = Depends(get_current_user),
) -> Any:
"""
Delete API key.
"""
api_key = APIKeyService.get_api_key(db=db, api_key_id=id)
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
if api_key.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
APIKeyService.delete_api_key(db=db, api_key=api_key)
logger.info(f"API key deleted: {api_key.key} for user {current_user.id}")
return api_key

View File

@@ -0,0 +1,88 @@
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from requests.exceptions import RequestException
from app.core import security
from app.core.security import get_current_user
from app.core.config import settings
from app.db.session import get_db
from app.models.user import User
from app.schemas.token import Token
from app.schemas.user import UserCreate, UserResponse
router = APIRouter()
@router.post("/register", response_model=UserResponse)
def register(*, db: Session = Depends(get_db), user_in: UserCreate) -> Any:
"""
Register a new user.
"""
try:
# Check if user with this email exists
user = db.query(User).filter(User.email == user_in.email).first()
if user:
raise HTTPException(
status_code=400,
detail="A user with this email already exists.",
)
# Check if user with this username exists
user = db.query(User).filter(User.username == user_in.username).first()
if user:
raise HTTPException(
status_code=400,
detail="A user with this username already exists.",
)
# Create new user
user = User(
email=user_in.email,
username=user_in.username,
hashed_password=security.get_password_hash(user_in.password),
)
db.add(user)
db.commit()
db.refresh(user)
return user
except RequestException as e:
raise HTTPException(
status_code=503,
detail="Network error or server is unreachable. Please try again later.",
) from e
@router.post("/token", response_model=Token)
def login_access_token(
db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests.
"""
user = db.query(User).filter(User.username == form_data.username).first()
if not user or not security.verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
elif not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = security.create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/test-token", response_model=UserResponse)
def test_token(current_user: User = Depends(get_current_user)) -> Any:
"""
Test access token by getting current user.
"""
return current_user

View File

@@ -0,0 +1,155 @@
from typing import List, Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session, joinedload
from app.db.session import get_db
from app.models.user import User
from app.models.chat import Chat, Message
from app.models.knowledge import KnowledgeBase
from app.schemas.chat import (
ChatCreate,
ChatResponse,
ChatUpdate,
MessageCreate,
MessageResponse
)
from app.core.security import get_current_user
from app.services.chat_service import generate_response
router = APIRouter()
@router.post("/", response_model=ChatResponse)
def create_chat(
*,
db: Session = Depends(get_db),
chat_in: ChatCreate,
current_user: User = Depends(get_current_user)
) -> Any:
# Verify knowledge bases exist and belong to user
knowledge_bases = (
db.query(KnowledgeBase)
.filter(
KnowledgeBase.id.in_(chat_in.knowledge_base_ids),
KnowledgeBase.user_id == current_user.id
)
.all()
)
if len(knowledge_bases) != len(chat_in.knowledge_base_ids):
raise HTTPException(
status_code=400,
detail="One or more knowledge bases not found"
)
chat = Chat(
title=chat_in.title,
user_id=current_user.id,
)
chat.knowledge_bases = knowledge_bases
db.add(chat)
db.commit()
db.refresh(chat)
return chat
@router.get("/", response_model=List[ChatResponse])
def get_chats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
skip: int = 0,
limit: int = 100
) -> Any:
chats = (
db.query(Chat)
.filter(Chat.user_id == current_user.id)
.offset(skip)
.limit(limit)
.all()
)
return chats
@router.get("/{chat_id}", response_model=ChatResponse)
def get_chat(
*,
db: Session = Depends(get_db),
chat_id: int,
current_user: User = Depends(get_current_user)
) -> Any:
chat = (
db.query(Chat)
.filter(
Chat.id == chat_id,
Chat.user_id == current_user.id
)
.first()
)
if not chat:
raise HTTPException(status_code=404, detail="Chat not found")
return chat
@router.post("/{chat_id}/messages")
async def create_message(
*,
db: Session = Depends(get_db),
chat_id: int,
messages: dict,
current_user: User = Depends(get_current_user)
) -> StreamingResponse:
chat = (
db.query(Chat)
.options(joinedload(Chat.knowledge_bases))
.filter(
Chat.id == chat_id,
Chat.user_id == current_user.id
)
.first()
)
if not chat:
raise HTTPException(status_code=404, detail="Chat not found")
# Get the last user message
last_message = messages["messages"][-1]
if last_message["role"] != "user":
raise HTTPException(status_code=400, detail="Last message must be from user")
# Get knowledge base IDs
knowledge_base_ids = [kb.id for kb in chat.knowledge_bases]
async def response_stream():
async for chunk in generate_response(
query=last_message["content"],
messages=messages,
knowledge_base_ids=knowledge_base_ids,
chat_id=chat_id,
db=db
):
yield chunk
return StreamingResponse(
response_stream(),
media_type="text/event-stream",
headers={
"x-vercel-ai-data-stream": "v1"
}
)
@router.delete("/{chat_id}")
def delete_chat(
*,
db: Session = Depends(get_db),
chat_id: int,
current_user: User = Depends(get_current_user)
) -> Any:
chat = (
db.query(Chat)
.filter(
Chat.id == chat_id,
Chat.user_id == current_user.id
)
.first()
)
if not chat:
raise HTTPException(status_code=404, detail="Chat not found")
db.delete(chat)
db.commit()
return {"status": "success"}

View File

@@ -0,0 +1,575 @@
import hashlib
from typing import List, Any, Dict
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, BackgroundTasks, Query
from sqlalchemy.orm import Session
from langchain_chroma import Chroma
from sqlalchemy import text
import logging
from datetime import datetime, timedelta
from pydantic import BaseModel
from sqlalchemy.orm import selectinload
import time
import asyncio
from app.db.session import get_db
from app.models.user import User
from app.core.security import get_current_user
from app.models.knowledge import KnowledgeBase, Document, ProcessingTask, DocumentChunk, DocumentUpload
from app.schemas.knowledge import (
KnowledgeBaseCreate,
KnowledgeBaseResponse,
KnowledgeBaseUpdate,
DocumentResponse,
PreviewRequest
)
from app.services.document_processor import process_document_background, upload_document, preview_document, PreviewResult
from app.core.config import settings
from app.core.minio import get_minio_client
from minio.error import MinioException
from app.services.vector_store import VectorStoreFactory
from app.services.embedding.embedding_factory import EmbeddingsFactory
router = APIRouter()
logger = logging.getLogger(__name__)
class TestRetrievalRequest(BaseModel):
query: str
kb_id: int
top_k: int
@router.post("", response_model=KnowledgeBaseResponse)
def create_knowledge_base(
*,
db: Session = Depends(get_db),
kb_in: KnowledgeBaseCreate,
current_user: User = Depends(get_current_user)
) -> Any:
"""
Create new knowledge base.
"""
kb = KnowledgeBase(
name=kb_in.name,
description=kb_in.description,
user_id=current_user.id
)
db.add(kb)
db.commit()
db.refresh(kb)
logger.info(f"Knowledge base created: {kb.name} for user {current_user.id}")
return kb
@router.get("", response_model=List[KnowledgeBaseResponse])
def get_knowledge_bases(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
skip: int = 0,
limit: int = 100
) -> Any:
"""
Retrieve knowledge bases.
"""
knowledge_bases = (
db.query(KnowledgeBase)
.filter(KnowledgeBase.user_id == current_user.id)
.offset(skip)
.limit(limit)
.all()
)
return knowledge_bases
@router.get("/{kb_id}", response_model=KnowledgeBaseResponse)
def get_knowledge_base(
*,
db: Session = Depends(get_db),
kb_id: int,
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get knowledge base by ID.
"""
from sqlalchemy.orm import joinedload
kb = (
db.query(KnowledgeBase)
.options(
joinedload(KnowledgeBase.documents)
.joinedload(Document.processing_tasks)
)
.filter(
KnowledgeBase.id == kb_id,
KnowledgeBase.user_id == current_user.id
)
.first()
)
if not kb:
raise HTTPException(status_code=404, detail="Knowledge base not found")
return kb
@router.put("/{kb_id}", response_model=KnowledgeBaseResponse)
def update_knowledge_base(
*,
db: Session = Depends(get_db),
kb_id: int,
kb_in: KnowledgeBaseUpdate,
current_user: User = Depends(get_current_user)
) -> Any:
"""
Update knowledge base.
"""
kb = db.query(KnowledgeBase).filter(
KnowledgeBase.id == kb_id,
KnowledgeBase.user_id == current_user.id
).first()
if not kb:
raise HTTPException(status_code=404, detail="Knowledge base not found")
for field, value in kb_in.dict(exclude_unset=True).items():
setattr(kb, field, value)
db.add(kb)
db.commit()
db.refresh(kb)
logger.info(f"Knowledge base updated: {kb.name} for user {current_user.id}")
return kb
@router.delete("/{kb_id}")
async def delete_knowledge_base(
*,
db: Session = Depends(get_db),
kb_id: int,
current_user: User = Depends(get_current_user)
) -> Any:
"""
Delete knowledge base and all associated resources.
"""
logger = logging.getLogger(__name__)
kb = (
db.query(KnowledgeBase)
.filter(
KnowledgeBase.id == kb_id,
KnowledgeBase.user_id == current_user.id
)
.first()
)
if not kb:
raise HTTPException(status_code=404, detail="Knowledge base not found")
try:
# Get all document file paths before deletion
document_paths = [doc.file_path for doc in kb.documents]
# Initialize services
minio_client = get_minio_client()
embeddings = EmbeddingsFactory.create()
vector_store = VectorStoreFactory.create(
store_type=settings.VECTOR_STORE_TYPE,
collection_name=f"kb_{kb_id}",
embedding_function=embeddings,
)
# Clean up external resources first
cleanup_errors = []
# 1. Clean up MinIO files
try:
# Delete all objects with prefix kb_{kb_id}/
objects = minio_client.list_objects(settings.MINIO_BUCKET_NAME, prefix=f"kb_{kb_id}/")
for obj in objects:
minio_client.remove_object(settings.MINIO_BUCKET_NAME, obj.object_name)
logger.info(f"Cleaned up MinIO files for knowledge base {kb_id}")
except MinioException as e:
cleanup_errors.append(f"Failed to clean up MinIO files: {str(e)}")
logger.error(f"MinIO cleanup error for kb {kb_id}: {str(e)}")
# 2. Clean up vector store
try:
vector_store._store.delete_collection(f"kb_{kb_id}")
logger.info(f"Cleaned up vector store for knowledge base {kb_id}")
except Exception as e:
cleanup_errors.append(f"Failed to clean up vector store: {str(e)}")
logger.error(f"Vector store cleanup error for kb {kb_id}: {str(e)}")
# Finally, delete database records in a single transaction
db.delete(kb)
db.commit()
# Report any cleanup errors in the response
if cleanup_errors:
return {
"message": "Knowledge base deleted with cleanup warnings",
"warnings": cleanup_errors
}
return {"message": "Knowledge base and all associated resources deleted successfully"}
except Exception as e:
db.rollback()
logger.error(f"Failed to delete knowledge base {kb_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to delete knowledge base: {str(e)}")
# Batch upload documents
@router.post("/{kb_id}/documents/upload")
async def upload_kb_documents(
kb_id: int,
files: List[UploadFile],
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Upload multiple documents to MinIO.
"""
kb = db.query(KnowledgeBase).filter(
KnowledgeBase.id == kb_id,
KnowledgeBase.user_id == current_user.id
).first()
if not kb:
raise HTTPException(status_code=404, detail="Knowledge base not found")
results = []
for file in files:
# 1. 计算文件 hash
file_content = await file.read()
file_hash = hashlib.sha256(file_content).hexdigest()
# 2. 检查是否存在完全相同的文件名称和hash都相同
existing_document = db.query(Document).filter(
Document.file_name == file.filename,
Document.file_hash == file_hash,
Document.knowledge_base_id == kb_id
).first()
if existing_document:
# 完全相同的文件,直接返回
results.append({
"document_id": existing_document.id,
"file_name": existing_document.file_name,
"status": "exists",
"message": "文件已存在且已处理完成",
"skip_processing": True
})
continue
# 3. 上传到临时目录
temp_path = f"kb_{kb_id}/temp/{file.filename}"
await file.seek(0)
try:
minio_client = get_minio_client()
file_size = len(file_content) # 使用之前读取的文件内容长度
minio_client.put_object(
bucket_name=settings.MINIO_BUCKET_NAME,
object_name=temp_path,
data=file.file,
length=file_size, # 指定文件大小
content_type=file.content_type
)
except MinioException as e:
logger.error(f"Failed to upload file to MinIO: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to upload file")
# 4. 创建上传记录
upload = DocumentUpload(
knowledge_base_id=kb_id,
file_name=file.filename,
file_hash=file_hash,
file_size=len(file_content),
content_type=file.content_type,
temp_path=temp_path
)
db.add(upload)
db.commit()
db.refresh(upload)
results.append({
"upload_id": upload.id,
"file_name": file.filename,
"temp_path": temp_path,
"status": "pending",
"skip_processing": False
})
return results
@router.post("/{kb_id}/documents/preview")
async def preview_kb_documents(
kb_id: int,
preview_request: PreviewRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> Dict[int, PreviewResult]:
"""
Preview multiple documents' chunks.
"""
results = {}
for doc_id in preview_request.document_ids:
document = db.query(Document).join(KnowledgeBase).filter(
Document.id == doc_id,
Document.knowledge_base_id == kb_id,
KnowledgeBase.user_id == current_user.id
).first()
if document:
file_path = document.file_path
else:
upload = db.query(DocumentUpload).join(KnowledgeBase).filter(
DocumentUpload.id == doc_id,
DocumentUpload.knowledge_base_id == kb_id,
KnowledgeBase.user_id == current_user.id
).first()
if not upload:
raise HTTPException(status_code=404, detail=f"Document {doc_id} not found")
file_path = upload.temp_path
preview = await preview_document(
file_path,
chunk_size=preview_request.chunk_size,
chunk_overlap=preview_request.chunk_overlap
)
results[doc_id] = preview
return results
@router.post("/{kb_id}/documents/process")
async def process_kb_documents(
kb_id: int,
upload_results: List[dict],
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Process multiple documents asynchronously.
"""
start_time = time.time()
kb = db.query(KnowledgeBase).filter(
KnowledgeBase.id == kb_id,
KnowledgeBase.user_id == current_user.id
).first()
if not kb:
raise HTTPException(status_code=404, detail="Knowledge base not found")
task_info = []
upload_ids = []
for result in upload_results:
if result.get("skip_processing"):
continue
upload_ids.append(result["upload_id"])
if not upload_ids:
return {"tasks": []}
uploads = db.query(DocumentUpload).filter(DocumentUpload.id.in_(upload_ids)).all()
uploads_dict = {upload.id: upload for upload in uploads}
all_tasks = []
for upload_id in upload_ids:
upload = uploads_dict.get(upload_id)
if not upload:
continue
task = ProcessingTask(
document_upload_id=upload_id,
knowledge_base_id=kb_id,
status="pending"
)
all_tasks.append(task)
db.add_all(all_tasks)
db.commit()
for task in all_tasks:
db.refresh(task)
task_data = []
for i, upload_id in enumerate(upload_ids):
if i < len(all_tasks):
task = all_tasks[i]
upload = uploads_dict.get(upload_id)
task_info.append({
"upload_id": upload_id,
"task_id": task.id
})
if upload:
task_data.append({
"task_id": task.id,
"upload_id": upload_id,
"temp_path": upload.temp_path,
"file_name": upload.file_name
})
background_tasks.add_task(
add_processing_tasks_to_queue,
task_data,
kb_id
)
return {"tasks": task_info}
async def add_processing_tasks_to_queue(task_data, kb_id):
"""Helper function to add document processing tasks to the queue without blocking the main response."""
for data in task_data:
asyncio.create_task(
process_document_background(
data["temp_path"],
data["file_name"],
kb_id,
data["task_id"],
None
)
)
logger.info(f"Added {len(task_data)} document processing tasks to queue")
@router.post("/cleanup")
async def cleanup_temp_files(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Clean up expired temporary files.
"""
expired_time = datetime.utcnow() - timedelta(hours=24)
expired_uploads = db.query(DocumentUpload).filter(
DocumentUpload.created_at < expired_time
).all()
minio_client = get_minio_client()
for upload in expired_uploads:
try:
minio_client.remove_object(
bucket_name=settings.MINIO_BUCKET_NAME,
object_name=upload.temp_path
)
except MinioException as e:
logger.error(f"Failed to delete temp file {upload.temp_path}: {str(e)}")
db.delete(upload)
db.commit()
return {"message": f"Cleaned up {len(expired_uploads)} expired uploads"}
@router.get("/{kb_id}/documents/tasks")
async def get_processing_tasks(
kb_id: int,
task_ids: str = Query(..., description="Comma-separated list of task IDs to check status for"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Get status of multiple processing tasks.
"""
task_id_list = [int(id.strip()) for id in task_ids.split(",")]
kb = db.query(KnowledgeBase).filter(
KnowledgeBase.id == kb_id,
KnowledgeBase.user_id == current_user.id
).first()
if not kb:
raise HTTPException(status_code=404, detail="Knowledge base not found")
tasks = (
db.query(ProcessingTask)
.options(
selectinload(ProcessingTask.document_upload)
)
.filter(
ProcessingTask.id.in_(task_id_list),
ProcessingTask.knowledge_base_id == kb_id
)
.all()
)
return {
task.id: {
"document_id": task.document_id,
"status": task.status,
"error_message": task.error_message,
"upload_id": task.document_upload_id,
"file_name": task.document_upload.file_name if task.document_upload else None
}
for task in tasks
}
@router.get("/{kb_id}/documents/{doc_id}", response_model=DocumentResponse)
async def get_document(
*,
db: Session = Depends(get_db),
kb_id: int,
doc_id: int,
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get document details by ID.
"""
document = (
db.query(Document)
.join(KnowledgeBase)
.filter(
Document.id == doc_id,
Document.knowledge_base_id == kb_id,
KnowledgeBase.user_id == current_user.id
)
.first()
)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
return document
@router.post("/test-retrieval")
async def test_retrieval(
request: TestRetrievalRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> Any:
"""
Test retrieval quality for a given query against a knowledge base.
"""
try:
kb = db.query(KnowledgeBase).filter(
KnowledgeBase.id == request.kb_id,
KnowledgeBase.user_id == current_user.id
).first()
if not kb:
raise HTTPException(
status_code=404,
detail=f"Knowledge base {request.kb_id} not found",
)
embeddings = EmbeddingsFactory.create()
vector_store = VectorStoreFactory.create(
store_type=settings.VECTOR_STORE_TYPE,
collection_name=f"kb_{request.kb_id}",
embedding_function=embeddings,
)
results = vector_store.similarity_search_with_score(request.query, k=request.top_k)
response = []
for doc, score in results:
response.append({
"content": doc.page_content,
"metadata": doc.metadata,
"score": float(score)
})
return {"results": response}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,84 @@
from typing import Any, Dict, List
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.security import get_current_user
from app.db.session import get_db
from app.models.knowledge import Document, KnowledgeBase
from app.models.user import User
from app.schemas.testing import TestingPipelineRequest, TestingPipelineResponse
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
router = APIRouter()
async 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
@router.post("/generate", response_model=TestingPipelineResponse)
async def generate_testing_content(
*,
payload: TestingPipelineRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Any:
_ = current_user
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,
)
.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)
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

View File

@@ -0,0 +1,175 @@
from pathlib import Path
from typing import Any, List
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.user import User
from app.schemas.tooling import (
SRSToolCreateJobResponse,
SRSToolJobStatusResponse,
SRSToolRequirementsSaveRequest,
SRSToolResultResponse,
ToolDefinitionResponse,
)
from app.services.srs_job_service import (
build_result_response,
ensure_upload_path,
replace_requirements,
run_srs_job,
)
from app.tools.registry import ToolRegistry
from app.tools.srs_reqs_qwen import get_srs_tool
router = APIRouter()
# Register SRS tool when the router is imported.
get_srs_tool()
ALLOWED_EXTENSIONS = {".pdf", ".docx"}
@router.get("", response_model=List[ToolDefinitionResponse])
async def list_tools(
current_user: User = Depends(get_current_user),
) -> Any:
_ = current_user
return ToolRegistry.list()
@router.post("/srs/jobs", response_model=SRSToolCreateJobResponse)
async def create_srs_job(
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Any:
safe_name = Path(file.filename or "").name
ext = Path(safe_name).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail="仅支持 .pdf/.docx 文件")
job = ToolJob(
user_id=current_user.id,
tool_name="srs.requirement_extractor",
status="pending",
input_file_name=safe_name,
input_file_path="",
)
db.add(job)
db.commit()
db.refresh(job)
target_path = ensure_upload_path(job.id, safe_name)
try:
content = await file.read()
target_path.write_bytes(content)
except Exception as exc:
job.status = "failed"
job.error_message = f"保存上传文件失败: {exc}"
db.add(job)
db.commit()
raise HTTPException(status_code=500, detail="上传文件保存失败")
job.input_file_path = str(target_path.resolve())
db.add(job)
db.commit()
background_tasks.add_task(run_srs_job, job.id)
return {
"job_id": job.id,
"status": job.status,
}
@router.get("/srs/jobs/{job_id}", response_model=SRSToolJobStatusResponse)
async def get_srs_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="任务不存在")
extraction = (
db.query(SRSExtraction)
.filter(SRSExtraction.job_id == job.id)
.first()
)
return {
"job_id": job.id,
"tool_name": job.tool_name,
"status": job.status,
"error_message": job.error_message,
"extraction_id": extraction.id if extraction else None,
"started_at": job.started_at,
"completed_at": job.completed_at,
}
@router.get("/srs/jobs/{job_id}/result", response_model=SRSToolResultResponse)
async def get_srs_job_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="任务不存在")
if job.status != "completed":
raise HTTPException(status_code=409, detail="任务尚未完成")
extraction = (
db.query(SRSExtraction)
.filter(SRSExtraction.job_id == job.id)
.first()
)
if not extraction:
raise HTTPException(status_code=404, detail="任务结果不存在")
return build_result_response(job, extraction)
@router.put("/srs/jobs/{job_id}/requirements", response_model=SRSToolResultResponse)
async def save_srs_requirements(
job_id: int,
payload: SRSToolRequirementsSaveRequest,
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="任务不存在")
extraction = (
db.query(SRSExtraction)
.filter(SRSExtraction.job_id == job.id)
.first()
)
if not extraction:
raise HTTPException(status_code=404, detail="任务结果不存在")
replace_requirements(db, extraction, [item.dict() for item in payload.requirements])
db.add(extraction)
db.commit()
db.refresh(extraction)
return build_result_response(job, extraction)

View File

@@ -0,0 +1,6 @@
from fastapi import APIRouter
from app.api.openapi import knowledge
router = APIRouter()
router.include_router(knowledge.router, prefix="/knowledge", tags=["knowledge"])

View File

@@ -0,0 +1,60 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from langchain_chroma import Chroma
from app.services.vector_store import VectorStoreFactory
from app import models
from app.db.session import get_db
from app.core.security import get_api_key_user
from app.core.config import settings
from app.services.embedding.embedding_factory import EmbeddingsFactory
router = APIRouter()
@router.get("/{knowledge_base_id}/query")
def query_knowledge_base(
*,
db: Session = Depends(get_db),
knowledge_base_id: int,
query: str,
top_k: int = 3,
current_user: models.User = Depends(get_api_key_user),
) -> Any:
"""
Query a specific knowledge base using API key authentication
"""
try:
kb = db.query(models.KnowledgeBase).filter(
models.KnowledgeBase.id == knowledge_base_id,
models.KnowledgeBase.user_id == current_user.id
).first()
if not kb:
raise HTTPException(
status_code=404,
detail=f"Knowledge base {knowledge_base_id} not found",
)
embeddings = EmbeddingsFactory.create()
vector_store = VectorStoreFactory.create(
store_type=settings.VECTOR_STORE_TYPE,
collection_name=f"kb_{knowledge_base_id}",
embedding_function=embeddings,
)
results = vector_store.similarity_search_with_score(query, k=top_k)
response = []
for doc, score in results:
response.append({
"content": doc.page_content,
"metadata": doc.metadata,
"score": float(score)
})
return {"results": response}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

View File

@@ -0,0 +1,123 @@
import os
from typing import List, Optional
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
PROJECT_NAME: str = "RAG Web UI" # Project name
VERSION: str = "0.1.0" # Project version
API_V1_STR: str = "/api" # API version string
# MySQL settings
MYSQL_SERVER: str = os.getenv("MYSQL_SERVER", "localhost")
MYSQL_PORT: int = int(os.getenv("MYSQL_PORT", "3306"))
MYSQL_USER: str = os.getenv("MYSQL_USER", "ragagent")
MYSQL_PASSWORD: str = os.getenv("MYSQL_PASSWORD", "ragagent")
MYSQL_DATABASE: str = os.getenv("MYSQL_DATABASE", "ragagent")
SQLALCHEMY_DATABASE_URI: Optional[str] = None
@property
def get_database_url(self) -> str:
if self.SQLALCHEMY_DATABASE_URI:
return self.SQLALCHEMY_DATABASE_URI
return (
f"mysql+mysqlconnector://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}"
f"@{self.MYSQL_SERVER}:{self.MYSQL_PORT}/{self.MYSQL_DATABASE}"
)
# JWT settings
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-here")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080"))
# Chat Provider settings
CHAT_PROVIDER: str = os.getenv("CHAT_PROVIDER", "openai")
# Embeddings settings
EMBEDDINGS_PROVIDER: str = os.getenv("EMBEDDINGS_PROVIDER", "openai")
# MinIO settings
MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "localhost:9000")
MINIO_ACCESS_KEY: str = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY: str = os.getenv("MINIO_SECRET_KEY", "minioadmin")
MINIO_BUCKET_NAME: str = os.getenv("MINIO_BUCKET_NAME", "documents")
# Shared model API key fallback
API_KEY: str = os.getenv("API_KEY", "")
# OpenAI settings
OPENAI_API_BASE: str = os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1")
OPENAI_API_KEY: str = os.getenv(
"OPENAI_API_KEY", os.getenv("API_KEY", "your-openai-api-key-here")
)
OPENAI_MODEL: str = os.getenv("OPENAI_MODEL", "gpt-4")
OPENAI_EMBEDDINGS_MODEL: str = os.getenv("OPENAI_EMBEDDINGS_MODEL", "text-embedding-ada-002")
# DashScope settings
DASH_SCOPE_API_KEY: str = os.getenv(
"DASH_SCOPE_API_KEY",
os.getenv("DASHSCOPE_API_KEY", os.getenv("API_KEY", "")),
)
DASH_SCOPE_API_BASE: str = os.getenv(
"DASH_SCOPE_API_BASE", "https://dashscope.aliyuncs.com/compatible-mode/v1"
)
DASH_SCOPE_CHAT_MODEL: str = os.getenv("DASH_SCOPE_CHAT_MODEL", "qwen3-max")
DASH_SCOPE_EMBEDDINGS_MODEL: str = os.getenv("DASH_SCOPE_EMBEDDINGS_MODEL", "")
# Vector Store settings
VECTOR_STORE_TYPE: str = os.getenv("VECTOR_STORE_TYPE", "chroma")
# External reranker settings
RERANKER_API_URL: str = os.getenv("RERANKER_API_URL", "")
RERANKER_API_KEY: str = os.getenv(
"RERANKER_API_KEY",
os.getenv(
"DASH_SCOPE_API_KEY",
os.getenv("DASHSCOPE_API_KEY", os.getenv("API_KEY", "")),
),
)
RERANKER_MODEL: str = os.getenv("RERANKER_MODEL", "")
RERANKER_TIMEOUT_SECONDS: float = float(os.getenv("RERANKER_TIMEOUT_SECONDS", "8"))
RERANKER_WEIGHT: float = float(os.getenv("RERANKER_WEIGHT", "0.75"))
# GraphRAG settings
GRAPHRAG_ENABLED: bool = os.getenv("GRAPHRAG_ENABLED", "false").lower() == "true"
GRAPHRAG_WORKING_DIR: str = os.getenv("GRAPHRAG_WORKING_DIR", "./graphrag_cache")
GRAPHRAG_GRAPH_STORAGE: str = os.getenv("GRAPHRAG_GRAPH_STORAGE", "neo4j")
GRAPHRAG_QUERY_LEVEL: int = int(os.getenv("GRAPHRAG_QUERY_LEVEL", "2"))
GRAPHRAG_LOCAL_TOP_K: int = int(os.getenv("GRAPHRAG_LOCAL_TOP_K", "20"))
GRAPHRAG_ENTITY_EXTRACT_MAX_GLEANING: int = int(os.getenv("GRAPHRAG_ENTITY_EXTRACT_MAX_GLEANING", "1"))
GRAPHRAG_EMBEDDING_DIM: int = int(os.getenv("GRAPHRAG_EMBEDDING_DIM", "1024"))
GRAPHRAG_EMBEDDING_MAX_TOKEN_SIZE: int = int(os.getenv("GRAPHRAG_EMBEDDING_MAX_TOKEN_SIZE", "8192"))
# Neo4j settings
NEO4J_URL: str = os.getenv("NEO4J_URL", "bolt://localhost:7687")
NEO4J_USERNAME: str = os.getenv("NEO4J_USERNAME", "neo4j")
NEO4J_PASSWORD: str = os.getenv("NEO4J_PASSWORD", "neo4j")
# Chroma DB settings
CHROMA_DB_HOST: str = os.getenv("CHROMA_DB_HOST", "chromadb")
CHROMA_DB_PORT: int = int(os.getenv("CHROMA_DB_PORT", "8000"))
# Qdrant DB settings
QDRANT_URL: str = os.getenv("QDRANT_URL", "http://localhost:6333")
QDRANT_PREFER_GRPC: bool = os.getenv("QDRANT_PREFER_GRPC", "true").lower() == "true"
# Deepseek settings
DEEPSEEK_API_KEY: str = ""
DEEPSEEK_API_BASE: str = "https://api.deepseek.com/v1" # 默认 API 地址
DEEPSEEK_MODEL: str = "deepseek-chat" # 默认模型名称
# Ollama settings
OLLAMA_API_BASE: str = "http://localhost:11434"
OLLAMA_MODEL: str = "deepseek-r1:7b"
OLLAMA_EMBEDDINGS_MODEL: str = os.getenv(
"OLLAMA_EMBEDDINGS_MODEL", "nomic-embed-text"
) # Added this line
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -0,0 +1,29 @@
import logging
from minio import Minio
from app.core.config import settings
logger = logging.getLogger(__name__)
def get_minio_client() -> Minio:
"""
Get a MinIO client instance.
"""
logger.info("Creating MinIO client instance.")
return Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=False # Set to True if using HTTPS
)
def init_minio():
"""
Initialize MinIO by creating the bucket if it doesn't exist.
"""
client = get_minio_client()
logger.info(f"Checking if bucket {settings.MINIO_BUCKET_NAME} exists.")
if not client.bucket_exists(settings.MINIO_BUCKET_NAME):
logger.info(f"Bucket {settings.MINIO_BUCKET_NAME} does not exist. Creating bucket.")
client.make_bucket(settings.MINIO_BUCKET_NAME)
else:
logger.info(f"Bucket {settings.MINIO_BUCKET_NAME} already exists.")

View File

@@ -0,0 +1,27 @@
import logging
from app.core.config import Settings
logger = logging.getLogger(__name__)
def validate_runtime_settings(settings: Settings) -> None:
errors = []
if settings.GRAPHRAG_ENABLED:
if settings.GRAPHRAG_GRAPH_STORAGE.lower() not in {"neo4j", "networkx"}:
errors.append("GRAPHRAG_GRAPH_STORAGE must be either 'neo4j' or 'networkx'.")
if settings.GRAPHRAG_GRAPH_STORAGE.lower() == "neo4j":
if not settings.NEO4J_URL:
errors.append("NEO4J_URL is required when GraphRAG Neo4j storage is enabled.")
if not settings.NEO4J_USERNAME:
errors.append("NEO4J_USERNAME is required when GraphRAG Neo4j storage is enabled.")
if not settings.NEO4J_PASSWORD:
errors.append("NEO4J_PASSWORD is required when GraphRAG Neo4j storage is enabled.")
if settings.RERANKER_API_URL and not settings.RERANKER_MODEL:
logger.warning("RERANKER_API_URL is configured but RERANKER_MODEL is empty. The API may reject requests.")
if errors:
raise ValueError("Runtime configuration validation failed: " + " | ".join(errors))

View File

@@ -0,0 +1,84 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
import bcrypt
from app.core.config import settings
from fastapi import Depends, HTTPException, status, Security
from fastapi.security import OAuth2PasswordBearer, APIKeyHeader
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.user import User
from app.services.api_key import APIKeyService
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login/access-token")
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def get_current_user(
db: Session = Depends(get_db),
token: str = Depends(oauth2_scheme)
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.username == username).first()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def get_api_key_user(
db: Session = Depends(get_db),
api_key: str = Security(api_key_header),
) -> User:
if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API key header missing",
)
api_key_obj = APIKeyService.get_api_key_by_key(db=db, key=api_key)
if not api_key_obj:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
if not api_key_obj.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive API key",
)
APIKeyService.update_last_used(db=db, api_key=api_key_obj)
return api_key_obj.user

View File

View File

@@ -0,0 +1,13 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(settings.get_database_url)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,47 @@
import logging
from app.api.api_v1.api import api_router
from app.api.openapi.api import router as openapi_router
from app.core.config import settings
from app.core.minio import init_minio
from app.core.runtime_checks import validate_runtime_settings
from app.startup.migarate import DatabaseMigrator
from fastapi import FastAPI
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
app = FastAPI(
title=settings.PROJECT_NAME,
version=settings.VERSION,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
)
# Include routers
app.include_router(api_router, prefix=settings.API_V1_STR)
app.include_router(openapi_router, prefix="/openapi")
@app.on_event("startup")
async def startup_event():
validate_runtime_settings(settings)
# Initialize MinIO
init_minio()
# Run database migrations
migrator = DatabaseMigrator(settings.get_database_url)
migrator.run_migrations()
@app.get("/")
def root():
return {"message": "Welcome to RAG Web UI API"}
@app.get("/api/health")
async def health_check():
return {
"status": "healthy",
"version": settings.VERSION,
}

View File

@@ -0,0 +1,18 @@
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
__all__ = [
"User",
"KnowledgeBase",
"Document",
"DocumentChunk",
"Chat",
"Message",
"APIKey",
"ToolJob",
"SRSExtraction",
"SRSRequirement",
]

View File

@@ -0,0 +1,18 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, VARCHAR
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.models.base import Base, TimestampMixin
class APIKey(Base, TimestampMixin):
__tablename__ = "api_keys"
id = Column(Integer, primary_key=True, index=True)
key = Column(VARCHAR(128), unique=True, index=True, nullable=False)
name = Column(String(255), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
last_used_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
user = relationship("User", back_populates="api_keys")

View File

@@ -0,0 +1,9 @@
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, DateTime
from datetime import datetime
Base = declarative_base()
class TimestampMixin:
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View File

@@ -0,0 +1,39 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Table
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.orm import relationship
from app.models.base import Base, TimestampMixin
# Association table for many-to-many relationship between Chat and KnowledgeBase
chat_knowledge_bases = Table(
"chat_knowledge_bases",
Base.metadata,
Column("chat_id", Integer, ForeignKey("chats.id"), primary_key=True),
Column("knowledge_base_id", Integer, ForeignKey("knowledge_bases.id"), primary_key=True),
)
class Chat(Base, TimestampMixin):
__tablename__ = "chats"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Relationships
messages = relationship("Message", back_populates="chat", cascade="all, delete-orphan")
user = relationship("User", back_populates="chats")
knowledge_bases = relationship(
"KnowledgeBase",
secondary=chat_knowledge_bases,
backref="chats"
)
class Message(Base, TimestampMixin):
__tablename__ = "messages"
id = Column(Integer, primary_key=True, index=True)
content = Column(LONGTEXT, nullable=False)
role = Column(String(50), nullable=False)
chat_id = Column(Integer, ForeignKey("chats.id"), nullable=False)
# Relationships
chat = relationship("Chat", back_populates="messages")

View File

@@ -0,0 +1,97 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, JSON, BigInteger, TIMESTAMP, text
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.orm import relationship
from app.models.base import Base, TimestampMixin
from datetime import datetime
import sqlalchemy as sa
class KnowledgeBase(Base, TimestampMixin):
__tablename__ = "knowledge_bases"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
description = Column(LONGTEXT)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
documents = relationship("Document", back_populates="knowledge_base", cascade="all, delete-orphan")
user = relationship("User", back_populates="knowledge_bases")
processing_tasks = relationship("ProcessingTask", back_populates="knowledge_base")
chunks = relationship("DocumentChunk", back_populates="knowledge_base", cascade="all, delete-orphan")
document_uploads = relationship("DocumentUpload", back_populates="knowledge_base", cascade="all, delete-orphan")
class Document(Base, TimestampMixin):
__tablename__ = "documents"
id = Column(Integer, primary_key=True, index=True)
file_path = Column(String(255), nullable=False) # Path in MinIO
file_name = Column(String(255), nullable=False) # Actual file name
file_size = Column(BigInteger, nullable=False) # File size in bytes
content_type = Column(String(100), nullable=False) # MIME type
file_hash = Column(String(64), index=True) # SHA-256 hash of file content
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
knowledge_base = relationship("KnowledgeBase", back_populates="documents")
processing_tasks = relationship("ProcessingTask", back_populates="document")
chunks = relationship("DocumentChunk", back_populates="document", cascade="all, delete-orphan")
__table_args__ = (
# Ensure file_name is unique within each knowledge base
sa.UniqueConstraint('knowledge_base_id', 'file_name', name='uq_kb_file_name'),
)
class DocumentUpload(Base):
__tablename__ = "document_uploads"
id = Column(Integer, primary_key=True, index=True)
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id", ondelete="CASCADE"), nullable=False)
file_name = Column(String, nullable=False)
file_hash = Column(String, nullable=False)
file_size = Column(BigInteger, nullable=False)
content_type = Column(String, nullable=False)
temp_path = Column(String, nullable=False)
created_at = Column(TIMESTAMP, nullable=False, server_default=text("now()"))
status = Column(String, nullable=False, server_default="pending")
error_message = Column(Text)
# Relationships
knowledge_base = relationship("KnowledgeBase", back_populates="document_uploads")
class ProcessingTask(Base):
__tablename__ = "processing_tasks"
id = Column(Integer, primary_key=True, index=True)
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id"))
document_id = Column(Integer, ForeignKey("documents.id"), nullable=True)
document_upload_id = Column(Integer, ForeignKey("document_uploads.id"), nullable=True)
status = Column(String(50), default="pending") # pending, processing, completed, failed
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
knowledge_base = relationship("KnowledgeBase", back_populates="processing_tasks")
document = relationship("Document", back_populates="processing_tasks")
document_upload = relationship("DocumentUpload", backref="processing_tasks")
class DocumentChunk(Base, TimestampMixin):
__tablename__ = "document_chunks"
id = Column(String(64), primary_key=True) # SHA-256 hash as ID
kb_id = Column(Integer, ForeignKey("knowledge_bases.id"), nullable=False)
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
file_name = Column(String(255), nullable=False)
chunk_metadata = Column(JSON, nullable=True)
hash = Column(String(64), nullable=False, index=True) # Content hash for change detection
# Relationships
knowledge_base = relationship("KnowledgeBase", back_populates="chunks")
document = relationship("Document", back_populates="chunks")
__table_args__ = (
sa.Index('idx_kb_file_name', 'kb_id', 'file_name'),
)

View File

@@ -0,0 +1,76 @@
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy import Column, DateTime, ForeignKey, Integer, JSON, String, Text
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.orm import relationship
from app.models.base import Base, TimestampMixin
class ToolJob(Base, TimestampMixin):
__tablename__ = "tool_jobs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
tool_name = Column(String(128), nullable=False, index=True)
status = Column(String(32), nullable=False, default="pending")
input_file_name = Column(String(255), nullable=False)
input_file_path = Column(String(512), nullable=False)
error_message = Column(Text, nullable=True)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
output_summary = Column(JSON, nullable=True)
user = relationship("User")
srs_extraction = relationship(
"SRSExtraction",
back_populates="job",
uselist=False,
cascade="all, delete-orphan",
)
class SRSExtraction(Base, TimestampMixin):
__tablename__ = "srs_extractions"
id = Column(Integer, primary_key=True, index=True)
job_id = Column(Integer, ForeignKey("tool_jobs.id", ondelete="CASCADE"), nullable=False, unique=True)
document_name = Column(String(255), nullable=False)
document_title = Column(String(255), nullable=False)
generated_at = Column(DateTime, default=datetime.utcnow, nullable=False)
total_requirements = Column(Integer, nullable=False, default=0)
statistics = Column(JSON, nullable=True)
raw_output = Column(JSON, nullable=True)
job = relationship("ToolJob", back_populates="srs_extraction")
requirements = relationship(
"SRSRequirement",
back_populates="extraction",
cascade="all, delete-orphan",
order_by="SRSRequirement.sort_order",
)
class SRSRequirement(Base, TimestampMixin):
__tablename__ = "srs_requirements"
id = Column(Integer, primary_key=True, index=True)
extraction_id = Column(Integer, ForeignKey("srs_extractions.id", ondelete="CASCADE"), nullable=False)
requirement_uid = Column(String(64), nullable=False)
title = Column(String(255), nullable=False)
description = Column(LONGTEXT, nullable=False)
priority = Column(String(16), nullable=False, default="")
acceptance_criteria = Column(JSON, nullable=False)
source_field = Column(String(255), nullable=False)
section_number = Column(String(64), nullable=True)
section_title = Column(String(255), nullable=True)
requirement_type = Column(String(64), nullable=True)
sort_order = Column(Integer, nullable=False, default=0)
extraction = relationship("SRSExtraction", back_populates="requirements")
__table_args__ = (
sa.UniqueConstraint("extraction_id", "requirement_uid", name="uq_srs_extraction_requirement_uid"),
sa.Index("idx_srs_requirements_extraction_sort", "extraction_id", "sort_order"),
)

View File

@@ -0,0 +1,18 @@
from sqlalchemy import Boolean, Column, Integer, String
from sqlalchemy.orm import relationship
from app.models.base import Base, TimestampMixin
class User(Base, TimestampMixin):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
username = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
# Relationships
knowledge_bases = relationship("KnowledgeBase", back_populates="user")
chats = relationship("Chat", back_populates="user")
api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan")

View File

@@ -0,0 +1,12 @@
from .api_key import APIKey, APIKeyCreate, APIKeyUpdate, APIKeyInDB
from .user import UserBase, UserCreate, UserUpdate, UserResponse
from .token import Token, TokenPayload
from .knowledge import KnowledgeBaseBase, KnowledgeBaseCreate, KnowledgeBaseUpdate, KnowledgeBaseResponse
from .testing import (
ExpectedResultEntry,
StepLogEntry,
TestCaseEntry,
TestItemEntry,
TestingPipelineRequest,
TestingPipelineResponse,
)

View File

@@ -0,0 +1,28 @@
from typing import Optional
from datetime import datetime
from pydantic import BaseModel
class APIKeyBase(BaseModel):
name: str
is_active: bool = True
class APIKeyCreate(APIKeyBase):
pass
class APIKeyUpdate(BaseModel):
name: Optional[str] = None
is_active: Optional[bool] = None
class APIKey(APIKeyBase):
id: int
key: str
user_id: int
last_used_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class APIKeyInDB(APIKey):
pass

View File

@@ -0,0 +1,39 @@
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
class MessageBase(BaseModel):
content: str
role: str
class MessageCreate(MessageBase):
chat_id: int
class MessageResponse(MessageBase):
id: int
chat_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class ChatBase(BaseModel):
title: str
class ChatCreate(ChatBase):
knowledge_base_ids: List[int]
class ChatUpdate(ChatBase):
knowledge_base_ids: Optional[List[int]] = None
class ChatResponse(ChatBase):
id: int
user_id: int
created_at: datetime
updated_at: datetime
messages: List[MessageResponse] = []
knowledge_base_ids: List[int] = []
class Config:
from_attributes = True

View File

@@ -0,0 +1,85 @@
from typing import Optional, List
from datetime import datetime
from pydantic import BaseModel
class KnowledgeBaseBase(BaseModel):
name: str
description: Optional[str] = None
class KnowledgeBaseCreate(KnowledgeBaseBase):
pass
class KnowledgeBaseUpdate(KnowledgeBaseBase):
pass
class DocumentBase(BaseModel):
file_name: str
file_path: str
file_hash: str
file_size: int
content_type: str
class DocumentCreate(DocumentBase):
knowledge_base_id: int
class DocumentUploadBase(BaseModel):
file_name: str
file_hash: str
file_size: int
content_type: str
temp_path: str
status: str = "pending"
error_message: Optional[str] = None
class DocumentUploadCreate(DocumentUploadBase):
knowledge_base_id: int
class DocumentUploadResponse(DocumentUploadBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class ProcessingTaskBase(BaseModel):
status: str
error_message: Optional[str] = None
class ProcessingTaskCreate(ProcessingTaskBase):
document_id: int
knowledge_base_id: int
class ProcessingTask(ProcessingTaskBase):
id: int
document_id: int
knowledge_base_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class DocumentResponse(DocumentBase):
id: int
knowledge_base_id: int
created_at: datetime
updated_at: datetime
processing_tasks: List[ProcessingTask] = []
class Config:
from_attributes = True
class KnowledgeBaseResponse(KnowledgeBaseBase):
id: int
user_id: int
created_at: datetime
updated_at: datetime
documents: List[DocumentResponse] = []
class Config:
from_attributes = True
class PreviewRequest(BaseModel):
document_ids: List[int]
chunk_size: int = 1000
chunk_overlap: int = 200

View File

@@ -0,0 +1,59 @@
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
class TestItemEntry(BaseModel):
id: str
content: str
class TestCaseEntry(BaseModel):
id: str
item_id: str
operation_steps: List[str]
test_content: str
expected_result_placeholder: str
class ExpectedResultEntry(BaseModel):
id: str
case_id: str
result: str
class StepLogEntry(BaseModel):
step_name: str
input_summary: str
output_summary: str
success: bool
fallback_used: bool
duration_ms: float
class TestingPipelineRequest(BaseModel):
requirement_text: str = Field(..., min_length=1)
requirement_type: Optional[str] = None
knowledge_base_ids: List[int] = []
retrieval_top_k: int = Field(default=8, ge=1, le=20)
knowledge_context: Optional[str] = None
use_model_generation: bool = True
max_items_per_group: int = Field(default=12, ge=4, le=30)
cases_per_item: int = Field(default=2, ge=1, le=5)
max_focus_points: int = Field(default=6, ge=3, le=12)
max_llm_calls: int = Field(default=10, ge=0, le=100)
debug: bool = False
class TestingPipelineResponse(BaseModel):
trace_id: str
requirement_type: str
reason: str
candidates: List[str]
test_items: Dict[str, List[TestItemEntry]]
test_cases: Dict[str, List[TestCaseEntry]]
expected_results: Dict[str, List[ExpectedResultEntry]]
formatted_output: str
pipeline_summary: str
knowledge_used: bool = False
step_logs: List[StepLogEntry] = []

View File

@@ -0,0 +1,9 @@
from pydantic import BaseModel
from typing import Optional
class Token(BaseModel):
access_token: str
token_type: str
class TokenPayload(BaseModel):
sub: Optional[int] = None

View File

@@ -0,0 +1,52 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
class ToolDefinitionResponse(BaseModel):
name: str
version: str
description: str
input_schema: Dict[str, Any]
output_schema: Dict[str, Any]
class SRSToolCreateJobResponse(BaseModel):
job_id: int
status: str
class SRSToolJobStatusResponse(BaseModel):
job_id: int
tool_name: str
status: str
error_message: Optional[str] = None
extraction_id: Optional[int] = None
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
class SRSToolRequirementItem(BaseModel):
id: str
title: str
description: str
priority: str
acceptanceCriteria: List[str]
sourceField: str
sectionNumber: Optional[str] = None
sectionTitle: Optional[str] = None
requirementType: Optional[str] = None
sortOrder: int
class SRSToolResultResponse(BaseModel):
jobId: int
documentName: str
generatedAt: str
statistics: Dict[str, Any]
requirements: List[SRSToolRequirementItem]
class SRSToolRequirementsSaveRequest(BaseModel):
requirements: List[SRSToolRequirementItem]

View File

@@ -0,0 +1,23 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
class UserBase(BaseModel):
email: EmailStr
username: str
is_active: bool = True
is_superuser: bool = False
class UserCreate(UserBase):
password: str
class UserUpdate(UserBase):
password: Optional[str] = None
class UserResponse(UserBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,61 @@
from typing import List, Optional
from datetime import datetime
import secrets
from sqlalchemy.orm import Session
from app.models.api_key import APIKey
from app.schemas.api_key import APIKeyCreate, APIKeyUpdate
class APIKeyService:
@staticmethod
def get_api_keys(db: Session, user_id: int, skip: int = 0, limit: int = 100) -> List[APIKey]:
return (
db.query(APIKey)
.filter(APIKey.user_id == user_id)
.offset(skip)
.limit(limit)
.all()
)
@staticmethod
def create_api_key(db: Session, user_id: int, name: str) -> APIKey:
api_key = APIKey(
key=f"sk-{secrets.token_hex(32)}",
name=name,
user_id=user_id,
is_active=True
)
db.add(api_key)
db.commit()
db.refresh(api_key)
return api_key
@staticmethod
def get_api_key(db: Session, api_key_id: int) -> Optional[APIKey]:
return db.query(APIKey).filter(APIKey.id == api_key_id).first()
@staticmethod
def get_api_key_by_key(db: Session, key: str) -> Optional[APIKey]:
return db.query(APIKey).filter(APIKey.key == key).first()
@staticmethod
def update_api_key(db: Session, api_key: APIKey, update_data: APIKeyUpdate) -> APIKey:
for field, value in update_data.model_dump(exclude_unset=True).items():
setattr(api_key, field, value)
db.add(api_key)
db.commit()
db.refresh(api_key)
return api_key
@staticmethod
def delete_api_key(db: Session, api_key: APIKey) -> None:
db.delete(api_key)
db.commit()
@staticmethod
def update_last_used(db: Session, api_key: APIKey) -> APIKey:
api_key.last_used_at = datetime.utcnow()
db.add(api_key)
db.commit()
db.refresh(api_key)
return api_key

View File

@@ -0,0 +1,532 @@
import base64
import json
import re
from collections import defaultdict
from typing import Any, AsyncGenerator, Dict, List, Optional
from app.core.config import settings
from app.models.chat import Message
from app.models.knowledge import Document, KnowledgeBase
from app.services.embedding.embedding_factory import EmbeddingsFactory
from app.services.fusion_prompts import (
GENERAL_CHAT_PROMPT_TEMPLATE,
GRAPH_GLOBAL_PROMPT_TEMPLATE,
GRAPH_LOCAL_PROMPT_TEMPLATE,
HYBRID_RAG_PROMPT_TEMPLATE,
)
from app.services.graph.graphrag_adapter import GraphRAGAdapter
from app.services.intent_router import route_intent
from app.services.llm.llm_factory import LLMFactory
from app.services.reranker.external_api import ExternalRerankerClient
from app.services.retrieval.multi_kb_retriever import MultiKBRetriever, format_retrieval_context
from app.services.testing_pipeline.pipeline import run_testing_pipeline
from app.services.testing_pipeline.rules import REQUIREMENT_TYPES
from app.services.vector_store import VectorStoreFactory
TESTING_TARGET_KEYWORDS = [
"测试项",
"测试用例",
"预期成果",
"需求类型",
"测试分解",
"分解",
"正常测试",
"异常测试",
"测试充分性",
]
TESTING_ACTION_KEYWORDS = [
"生成",
"输出",
"给出",
"",
"编写",
"设计",
"整理",
"列出",
"提供",
"制定",
]
TYPE_ALIAS_MAP = {
"接口测试": "外部接口测试",
"ui测试": "人机交互界面测试",
"界面测试": "人机交互界面测试",
"恢复测试": "恢复性测试",
"可靠性": "可靠性测试",
"安全性": "安全性测试",
"边界": "边界测试",
"安装": "安装性测试",
"互操作": "互操作性测试",
"敏感性": "敏感性测试",
"充分性": "测试充分性要求",
}
def _escape_stream_text(text: str) -> str:
return text.replace('"', '\\"').replace("\n", "\\n")
def _extract_stream_text(chunk: Any) -> str:
content = getattr(chunk, "content", chunk)
if isinstance(content, str):
return content
if isinstance(content, list):
parts: List[str] = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
maybe_text = item.get("text")
if isinstance(maybe_text, str):
parts.append(maybe_text)
else:
parts.append(str(item))
return "".join(parts)
return str(content)
def _preview_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
preview = []
for row in rows[:10]:
doc = row["document"]
metadata = doc.metadata or {}
preview.append(
{
"kb_id": row.get("kb_id"),
"source": metadata.get("source") or metadata.get("file_name") or "unknown",
"chunk_id": metadata.get("chunk_id") or "unknown",
"score": row.get("final_score", 0),
"reranker_score": row.get("reranker_score"),
}
)
return preview
def _context_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
context_rows: List[Dict[str, Any]] = []
for row in rows:
doc = row["document"]
metadata = dict(doc.metadata or {})
if "kb_id" not in metadata and row.get("kb_id") is not None:
metadata["kb_id"] = row.get("kb_id")
metadata.setdefault("retrieval_score", row.get("final_score", 0))
if row.get("reranker_score") is not None:
metadata.setdefault("reranker_score", row.get("reranker_score"))
context_rows.append(
{
"page_content": doc.page_content.strip(),
"metadata": metadata,
}
)
return context_rows
def _build_local_graph_context_fallback(rows: List[Dict[str, Any]]) -> str:
entities = set()
relations: List[Dict[str, Any]] = []
evidences: List[str] = []
for row in rows:
doc = row["document"]
metadata = doc.metadata or {}
for ent in metadata.get("extracted_entities", []):
entities.add(str(ent))
for rel in metadata.get("extracted_relations", []):
if isinstance(rel, dict):
relations.append(rel)
evidences.append(doc.page_content.strip())
entity_block = "\n".join(f"- {name}" for name in sorted(entities)[:80]) or "- 暂无结构化实体,已使用向量检索回退。"
relation_lines: List[str] = []
for rel in relations[:120]:
src = rel.get("source") or rel.get("src") or rel.get("src_id") or "UNKNOWN"
tgt = rel.get("target") or rel.get("tgt") or rel.get("tgt_id") or "UNKNOWN"
rel_type = rel.get("type") or rel.get("relation_type") or "其他"
desc = rel.get("description") or ""
relation_lines.append(f"- {src} -> {tgt} | 类型={rel_type} | 说明={desc}")
relation_block = "\n".join(relation_lines) or "- 暂无结构化关系,已使用证据片段回答。"
evidence_block = "\n\n".join(
f"[证据{i}] {snippet}" for i, snippet in enumerate(evidences[:8], start=1)
)
if not evidence_block:
evidence_block = "无可用证据。"
return (
"实体列表:\n"
f"{entity_block}\n\n"
"关系列表:\n"
f"{relation_block}\n\n"
"原文证据:\n"
f"{evidence_block}"
)
def _build_global_community_context_fallback(rows: List[Dict[str, Any]]) -> str:
groups: Dict[str, List[str]] = defaultdict(list)
for row in rows:
doc = row["document"]
metadata = doc.metadata or {}
community_ids = metadata.get("community_ids") or []
if isinstance(community_ids, list) and community_ids:
keys = [str(item) for item in community_ids]
else:
source = metadata.get("source") or metadata.get("file_name") or "unknown"
keys = [f"source:{source}"]
for key in keys:
groups[key].append(doc.page_content.strip())
if not groups:
return "暂无社区摘要数据,已回退为基于证据片段的全局总结。"
lines: List[str] = []
for idx, (community_id, snippets) in enumerate(groups.items(), start=1):
merged = " ".join(snippets[:3])
lines.append(f"社区{idx} ({community_id}) 摘要: {merged}")
return "\n\n".join(lines)
async def _build_kb_vector_stores(db: Any, 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 _build_reranker_client() -> ExternalRerankerClient:
return ExternalRerankerClient(
api_url=settings.RERANKER_API_URL,
api_key=settings.RERANKER_API_KEY,
model=settings.RERANKER_MODEL,
timeout_seconds=settings.RERANKER_TIMEOUT_SECONDS,
)
def _is_testing_generation_request(query: str) -> bool:
text = (query or "").strip()
if not text:
return False
normalized = text.lower()
if normalized.startswith("/testing"):
return True
if any(
token in normalized
for token in (
"testing_orchestrator",
"testing-orchestrator",
"identify_requirement_type",
"identify-requirement-type",
)
):
return True
has_target = any(keyword in text for keyword in TESTING_TARGET_KEYWORDS)
has_action = any(keyword in text for keyword in TESTING_ACTION_KEYWORDS)
if has_target and has_action:
return True
if any(keyword in text for keyword in ("测试项", "测试用例", "预期成果")):
if re.search(r"(请|帮|给|麻烦).{0,12}(写|生成|设计|整理|编写|列出|提供|制定)", text):
return True
if text.startswith(("生成", "编写", "设计", "整理", "输出", "列出", "提供", "制定")):
return True
return False
def _extract_requirement_type_from_query(query: str) -> Optional[str]:
text = (query or "").strip()
if not text:
return None
for req_type in REQUIREMENT_TYPES:
if req_type in text:
return req_type
lowered = text.lower()
for alias, req_type in TYPE_ALIAS_MAP.items():
if alias in text or alias in lowered:
return req_type
return None
async def generate_response(
query: str,
messages: dict,
knowledge_base_ids: List[int],
chat_id: int,
db: Any,
) -> AsyncGenerator[str, None]:
try:
user_message = Message(content=query, role="user", chat_id=chat_id)
db.add(user_message)
db.commit()
bot_message = Message(content="", role="assistant", chat_id=chat_id)
db.add(bot_message)
db.commit()
if _is_testing_generation_request(query):
explicit_type = _extract_requirement_type_from_query(query)
retrieval_rows: List[Dict[str, Any]] = []
knowledge_context = ""
kb_vector_stores = []
if knowledge_base_ids:
testing_kbs = (
db.query(KnowledgeBase)
.filter(KnowledgeBase.id.in_(knowledge_base_ids))
.all()
)
kb_vector_stores = await _build_kb_vector_stores(db, testing_kbs)
if kb_vector_stores:
testing_retriever = MultiKBRetriever(
reranker_weight=settings.RERANKER_WEIGHT,
)
retrieval_rows = await testing_retriever.retrieve(
query=query,
kb_vector_stores=kb_vector_stores,
fetch_k_per_kb=16,
top_k=8,
)
if retrieval_rows:
knowledge_context = format_retrieval_context(retrieval_rows)
pipeline_result = run_testing_pipeline(
user_requirement_text=query,
requirement_type_input=explicit_type,
debug=True,
knowledge_context=knowledge_context,
use_model_generation=True,
max_items_per_group=6,
cases_per_item=1,
max_focus_points=6,
max_llm_calls=2,
)
context_payload = {
"route": {
"intent": "TESTING",
"reason": "命中测试生成意图,已自动调用测试工具链。",
},
"intent": "TESTING",
"skill_profile": "testing-orchestrator",
"tool_chain": [
"identify-requirement-type",
"decompose-test-items",
"generate-test-cases",
"build_expected_results",
"format_output",
],
"selected_chain": "TESTING_PIPELINE",
"graph_used": False,
"reranker_enabled": False,
"retrieval_preview": _preview_rows(retrieval_rows),
"context": _context_rows(retrieval_rows),
"testing_pipeline": {
"trace_id": pipeline_result.get("trace_id"),
"requirement_type": pipeline_result.get("requirement_type"),
"candidates": pipeline_result.get("candidates", []),
"pipeline_summary": pipeline_result.get("pipeline_summary", ""),
"knowledge_used": pipeline_result.get("knowledge_used", False),
"step_logs": pipeline_result.get("step_logs", []),
},
}
escaped_context = json.dumps(context_payload, ensure_ascii=False)
base64_context = base64.b64encode(escaped_context.encode()).decode()
separator = "__LLM_RESPONSE__"
full_response = f"{base64_context}{separator}"
yield f'0:"{base64_context}{separator}"\n'
rendered_text = pipeline_result.get("formatted_output", "").strip()
if not rendered_text:
rendered_text = "未生成测试内容,请补充更明确的需求后重试。"
full_response += rendered_text
yield f'0:"{_escape_stream_text(rendered_text)}"\n'
yield 'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
bot_message.content = full_response
db.commit()
return
knowledge_bases = (
db.query(KnowledgeBase)
.filter(KnowledgeBase.id.in_(knowledge_base_ids))
.all()
)
kb_ids = [kb.id for kb in knowledge_bases]
llm = LLMFactory.create()
decision = await route_intent(llm=llm, query=query, messages=messages)
intent = decision["intent"]
kb_vector_stores = await _build_kb_vector_stores(db, knowledge_bases)
if intent in {"B", "C", "D"} and not kb_vector_stores:
intent = "A"
decision = {
"intent": "A",
"reason": "未发现可用知识库向量集合,已降级为通用对话路。",
}
reranker_client = _build_reranker_client()
retriever = MultiKBRetriever(
reranker_client=reranker_client,
reranker_weight=settings.RERANKER_WEIGHT,
)
retrieval_rows: List[Dict[str, Any]] = []
graph_used = False
selected_chain = intent
prompt_text = ""
if intent == "A":
prompt_text = GENERAL_CHAT_PROMPT_TEMPLATE.format(query=query)
elif intent == "B":
retrieval_rows = await retriever.retrieve(
query=query,
kb_vector_stores=kb_vector_stores,
fetch_k_per_kb=16,
top_k=12,
)
context = format_retrieval_context(retrieval_rows) or "无可用证据。"
prompt_text = HYBRID_RAG_PROMPT_TEMPLATE.format(query=query, context=context)
elif intent == "C":
graph_context = ""
used_kb_ids: List[int] = []
if settings.GRAPHRAG_ENABLED and kb_ids:
try:
adapter = GraphRAGAdapter()
graph_context, used_kb_ids = await adapter.local_context_multi(
kb_ids,
query,
top_k=settings.GRAPHRAG_LOCAL_TOP_K,
level=settings.GRAPHRAG_QUERY_LEVEL,
)
graph_used = bool(graph_context)
except Exception:
graph_context = ""
if not graph_context:
retrieval_rows = await retriever.retrieve(
query=query,
kb_vector_stores=kb_vector_stores,
fetch_k_per_kb=18,
top_k=14,
)
graph_context = _build_local_graph_context_fallback(retrieval_rows)
selected_chain = "C_fallback_B"
else:
selected_chain = "C_graph"
prompt_text = GRAPH_LOCAL_PROMPT_TEMPLATE.format(
query=query,
graph_context=graph_context,
)
else:
community_context = ""
if settings.GRAPHRAG_ENABLED and kb_ids:
try:
adapter = GraphRAGAdapter()
community_context, used_kb_ids = await adapter.global_context_multi(
kb_ids,
query,
level=settings.GRAPHRAG_QUERY_LEVEL,
)
graph_used = bool(community_context)
except Exception:
community_context = ""
if not community_context:
retrieval_rows = await retriever.retrieve(
query=query,
kb_vector_stores=kb_vector_stores,
fetch_k_per_kb=20,
top_k=14,
)
community_context = _build_global_community_context_fallback(retrieval_rows)
selected_chain = "D_fallback_B"
else:
selected_chain = "D_graph"
prompt_text = GRAPH_GLOBAL_PROMPT_TEMPLATE.format(
query=query,
community_context=community_context,
)
context_payload = {
"route": decision,
"intent": intent,
"selected_chain": selected_chain,
"graph_used": graph_used,
"reranker_enabled": reranker_client.enabled,
"retrieval_preview": _preview_rows(retrieval_rows),
"context": _context_rows(retrieval_rows),
}
escaped_context = json.dumps(context_payload, ensure_ascii=False)
base64_context = base64.b64encode(escaped_context.encode()).decode()
separator = "__LLM_RESPONSE__"
full_response = f"{base64_context}{separator}"
yield f'0:"{base64_context}{separator}"\n'
async for chunk in llm.astream(prompt_text):
text = _extract_stream_text(chunk)
if not text:
continue
full_response += text
yield f'0:"{_escape_stream_text(text)}"\n'
yield 'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
bot_message.content = full_response
db.commit()
except Exception as e:
error_message = f"Error generating response: {str(e)}"
print(error_message)
yield "3:{text}\n".format(text=error_message)
if "bot_message" in locals():
bot_message.content = error_message
db.commit()
finally:
db.close()

View File

@@ -0,0 +1,69 @@
from typing import Optional, List, Dict, Set
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.models.knowledge import DocumentChunk
import json
class ChunkRecord:
"""Manages chunk-level record keeping for incremental updates"""
def __init__(self, kb_id: int):
self.kb_id = kb_id
self.engine = create_engine(settings.get_database_url)
def list_chunks(self, file_name: Optional[str] = None) -> Set[str]:
"""List all chunk hashes for the given file"""
with Session(self.engine) as session:
query = session.query(DocumentChunk.hash).filter(
DocumentChunk.kb_id == self.kb_id
)
if file_name:
query = query.filter(DocumentChunk.file_name == file_name)
return {row[0] for row in query.all()}
def add_chunks(self, chunks: List[Dict]):
"""Add new chunks to the database"""
if not chunks:
return
with Session(self.engine) as session:
for chunk_data in chunks:
chunk = DocumentChunk(
id=chunk_data['id'],
kb_id=chunk_data['kb_id'],
document_id=chunk_data['document_id'],
file_name=chunk_data['file_name'],
chunk_metadata=chunk_data['metadata'],
hash=chunk_data['hash']
)
session.merge(chunk) # Use merge instead of add to handle updates
session.commit()
def delete_chunks(self, chunk_ids: List[str]):
"""Delete chunks by their IDs"""
if not chunk_ids:
return
with Session(self.engine) as session:
session.query(DocumentChunk).filter(
DocumentChunk.kb_id == self.kb_id,
DocumentChunk.id.in_(chunk_ids)
).delete(synchronize_session=False)
session.commit()
def get_deleted_chunks(self, current_hashes: Set[str], file_name: Optional[str] = None) -> List[str]:
"""Get IDs of chunks that no longer exist in the current version"""
with Session(self.engine) as session:
query = session.query(DocumentChunk.id).filter(
DocumentChunk.kb_id == self.kb_id
)
if file_name:
query = query.filter(DocumentChunk.file_name == file_name)
if current_hashes:
query = query.filter(DocumentChunk.hash.notin_(current_hashes))
return [row[0] for row in query.all()]

View File

@@ -0,0 +1,582 @@
import logging
import os
import hashlib
import tempfile
import traceback
import json
from app.db.session import SessionLocal
from io import BytesIO
from typing import Optional, List, Dict, Any
from fastapi import UploadFile
from langchain_community.document_loaders import (
PyPDFLoader,
Docx2txtLoader,
UnstructuredMarkdownLoader,
TextLoader
)
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_core.documents import Document as LangchainDocument
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.minio import get_minio_client
from app.models.knowledge import ProcessingTask, Document, DocumentChunk
from app.services.chunk_record import ChunkRecord
from minio.error import MinioException
from minio.commonconfig import CopySource
from app.services.vector_store import VectorStoreFactory
from app.services.embedding.embedding_factory import EmbeddingsFactory
class UploadResult(BaseModel):
file_path: str
file_name: str
file_size: int
content_type: str
file_hash: str
class TextChunk(BaseModel):
content: str
metadata: Optional[Dict] = None
class PreviewResult(BaseModel):
chunks: List[TextChunk]
total_chunks: int
def _estimate_token_count(text: str) -> int:
# Lightweight estimation without adding tokenizer dependencies.
return len(text)
def _build_enriched_chunk_metadata(
*,
source_metadata: Optional[Dict[str, Any]],
chunk_id: str,
file_name: str,
file_path: str,
kb_id: int,
document_id: int,
chunk_index: int,
chunk_text: str,
) -> Dict[str, Any]:
source_metadata = source_metadata or {}
token_count = _estimate_token_count(chunk_text)
return {
**source_metadata,
"source": file_name,
"chunk_id": chunk_id,
"file_name": file_name,
"file_path": file_path,
"kb_id": kb_id,
"document_id": document_id,
"chunk_index": chunk_index,
"chunk_text": chunk_text,
"token_count": token_count,
"language": source_metadata.get("language", "zh"),
"source_type": "document",
"mission_phase": source_metadata.get("mission_phase"),
"section_title": source_metadata.get("section_title"),
"publish_time": source_metadata.get("publish_time"),
# Keep graph-linked fields for future graph/vector federation.
"extracted_entities": source_metadata.get("extracted_entities", []),
"extracted_entity_types": source_metadata.get("extracted_entity_types", []),
"extracted_relations": source_metadata.get("extracted_relations", []),
"graph_node_ids": source_metadata.get("graph_node_ids", []),
"graph_edge_ids": source_metadata.get("graph_edge_ids", []),
"community_ids": source_metadata.get("community_ids", []),
}
def _sanitize_metadata_for_vector_store(metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Normalize metadata to satisfy Chroma's strict metadata constraints."""
if not metadata:
return {}
sanitized: Dict[str, Any] = {}
scalar_types = (str, int, float, bool)
for key, value in metadata.items():
if value is None:
continue
if isinstance(value, scalar_types):
sanitized[key] = value
continue
if isinstance(value, list):
primitive_items = [item for item in value if isinstance(item, scalar_types)]
if primitive_items:
sanitized[key] = primitive_items
elif value:
sanitized[key] = json.dumps(value, ensure_ascii=False)
continue
if isinstance(value, dict):
sanitized[key] = json.dumps(value, ensure_ascii=False)
continue
sanitized[key] = str(value)
return sanitized
async def process_document(file_path: str, file_name: str, kb_id: int, document_id: int, chunk_size: int = 1000, chunk_overlap: int = 200) -> None:
"""Process document and store in vector database with incremental updates"""
logger = logging.getLogger(__name__)
try:
preview_result = await preview_document(file_path, chunk_size, chunk_overlap)
# Initialize embeddings
logger.info("Initializing OpenAI embeddings...")
embeddings = EmbeddingsFactory.create()
logger.info(f"Initializing vector store with collection: kb_{kb_id}")
vector_store = VectorStoreFactory.create(
store_type=settings.VECTOR_STORE_TYPE,
collection_name=f"kb_{kb_id}",
embedding_function=embeddings,
)
# Initialize chunk record manager
chunk_manager = ChunkRecord(kb_id)
# Get existing chunk hashes for this file
existing_hashes = chunk_manager.list_chunks(file_name)
# Prepare new chunks
new_chunks = []
current_hashes = set()
documents_to_update = []
for i, chunk in enumerate(preview_result.chunks):
# Calculate chunk hash
chunk_hash = hashlib.sha256(
(chunk.content + str(chunk.metadata)).encode()
).hexdigest()
current_hashes.add(chunk_hash)
# Skip if chunk hasn't changed
if chunk_hash in existing_hashes:
continue
# Create unique ID for the chunk
chunk_id = hashlib.sha256(
f"{kb_id}:{file_name}:{chunk_hash}".encode()
).hexdigest()
metadata = _build_enriched_chunk_metadata(
source_metadata=chunk.metadata,
chunk_id=chunk_id,
file_name=file_name,
file_path=file_path,
kb_id=kb_id,
document_id=document_id,
chunk_index=i,
chunk_text=chunk.content,
)
vector_metadata = _sanitize_metadata_for_vector_store(metadata)
new_chunks.append({
"id": chunk_id,
"kb_id": kb_id,
"document_id": document_id,
"file_name": file_name,
"metadata": metadata,
"hash": chunk_hash
})
# Prepare document for vector store
doc = LangchainDocument(
page_content=chunk.content,
metadata=vector_metadata
)
documents_to_update.append(doc)
# Add new chunks to database and vector store
if new_chunks:
logger.info(f"Adding {len(new_chunks)} new/updated chunks")
chunk_manager.add_chunks(new_chunks)
vector_store.add_documents(documents_to_update)
if settings.GRAPHRAG_ENABLED:
try:
from app.services.graph.graphrag_adapter import GraphRAGAdapter
graph_adapter = GraphRAGAdapter()
source_texts = [doc.page_content for doc in documents_to_update if doc.page_content.strip()]
await graph_adapter.ingest_texts(kb_id, source_texts)
logger.info("GraphRAG ingestion completed in incremental processing")
except Exception as graph_exc:
logger.error(f"GraphRAG ingestion failed in incremental processing: {graph_exc}")
# Delete removed chunks
chunks_to_delete = chunk_manager.get_deleted_chunks(current_hashes, file_name)
if chunks_to_delete:
logger.info(f"Removing {len(chunks_to_delete)} deleted chunks")
chunk_manager.delete_chunks(chunks_to_delete)
vector_store.delete(chunks_to_delete)
logger.info("Document processing completed successfully")
except Exception as e:
logger.error(f"Error processing document: {str(e)}")
raise
async def upload_document(file: UploadFile, kb_id: int) -> UploadResult:
"""Step 1: Upload document to MinIO"""
content = await file.read()
file_size = len(content)
file_hash = hashlib.sha256(content).hexdigest()
# Clean and normalize filename
file_name = "".join(c for c in file.filename if c.isalnum() or c in ('-', '_', '.')).strip()
object_path = f"kb_{kb_id}/{file_name}"
content_types = {
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".md": "text/markdown",
".txt": "text/plain"
}
_, ext = os.path.splitext(file_name)
content_type = content_types.get(ext.lower(), "application/octet-stream")
# Upload to MinIO
minio_client = get_minio_client()
try:
minio_client.put_object(
bucket_name=settings.MINIO_BUCKET_NAME,
object_name=object_path,
data=BytesIO(content),
length=file_size,
content_type=content_type
)
except Exception as e:
logging.error(f"Failed to upload file to MinIO: {str(e)}")
raise
return UploadResult(
file_path=object_path,
file_name=file_name,
file_size=file_size,
content_type=content_type,
file_hash=file_hash
)
async def preview_document(file_path: str, chunk_size: int = 1000, chunk_overlap: int = 200) -> PreviewResult:
"""Step 2: Generate preview chunks"""
# Get file from MinIO
minio_client = get_minio_client()
_, ext = os.path.splitext(file_path)
ext = ext.lower()
# Download to temp file
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as temp_file:
minio_client.fget_object(
bucket_name=settings.MINIO_BUCKET_NAME,
object_name=file_path,
file_path=temp_file.name
)
temp_path = temp_file.name
try:
# Select appropriate loader
if ext == ".pdf":
loader = PyPDFLoader(temp_path)
elif ext == ".docx":
loader = Docx2txtLoader(temp_path)
elif ext == ".md":
loader = UnstructuredMarkdownLoader(temp_path)
else: # Default to text loader
loader = TextLoader(temp_path)
# Load and split the document
documents = loader.load()
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap
)
chunks = text_splitter.split_documents(documents)
# Convert to preview format
preview_chunks = [
TextChunk(
content=chunk.page_content,
metadata=chunk.metadata
)
for chunk in chunks
]
return PreviewResult(
chunks=preview_chunks,
total_chunks=len(chunks)
)
finally:
os.unlink(temp_path)
async def process_document_background(
temp_path: str,
file_name: str,
kb_id: int,
task_id: int,
db: Session = None,
chunk_size: int = 1000,
chunk_overlap: int = 200
) -> None:
"""Process document in background"""
logger = logging.getLogger(__name__)
logger.info(f"Starting background processing for task {task_id}, file: {file_name}")
# if we don't pass in db, create a new database session
if db is None:
db = SessionLocal()
should_close_db = True
else:
should_close_db = False
task = db.query(ProcessingTask).get(task_id)
if not task:
logger.error(f"Task {task_id} not found")
return
minio_client = None
local_temp_path = None
try:
logger.info(f"Task {task_id}: Setting status to processing")
task.status = "processing"
db.commit()
# 1. 从临时目录下载文件
minio_client = get_minio_client()
try:
local_temp_path = f"/tmp/temp_{task_id}_{file_name}" # 使用系统临时目录
logger.info(f"Task {task_id}: Downloading file from MinIO: {temp_path} to {local_temp_path}")
minio_client.fget_object(
bucket_name=settings.MINIO_BUCKET_NAME,
object_name=temp_path,
file_path=local_temp_path
)
logger.info(f"Task {task_id}: File downloaded successfully")
except MinioException as e:
# Idempotent fallback: temp object may already be consumed by another task.
# If the final document is already created, treat current task as completed.
if "NoSuchKey" in str(e) and task.document_upload:
existing_document = db.query(Document).filter(
Document.knowledge_base_id == kb_id,
Document.file_name == file_name,
Document.file_hash == task.document_upload.file_hash,
).first()
if existing_document:
logger.warning(
f"Task {task_id}: Temp object missing but document already exists, "
f"marking task as completed (document_id={existing_document.id})"
)
task.status = "completed"
task.document_id = existing_document.id
task.error_message = None
task.document_upload.status = "completed"
task.document_upload.error_message = None
db.commit()
return
error_msg = f"Failed to download temp file: {str(e)}"
logger.error(f"Task {task_id}: {error_msg}")
raise Exception(error_msg)
try:
# 2. 加载和分块文档
_, ext = os.path.splitext(file_name)
ext = ext.lower()
logger.info(f"Task {task_id}: Loading document with extension {ext}")
# 选择合适的加载器
if ext == ".pdf":
loader = PyPDFLoader(local_temp_path)
elif ext == ".docx":
loader = Docx2txtLoader(local_temp_path)
elif ext == ".md":
loader = UnstructuredMarkdownLoader(local_temp_path)
else: # 默认使用文本加载器
loader = TextLoader(local_temp_path)
logger.info(f"Task {task_id}: Loading document content")
documents = loader.load()
logger.info(f"Task {task_id}: Document loaded successfully")
logger.info(f"Task {task_id}: Splitting document into chunks")
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap
)
chunks = text_splitter.split_documents(documents)
logger.info(f"Task {task_id}: Document split into {len(chunks)} chunks")
# 3. 创建向量存储
logger.info(f"Task {task_id}: Initializing vector store")
embeddings = EmbeddingsFactory.create()
vector_store = VectorStoreFactory.create(
store_type=settings.VECTOR_STORE_TYPE,
collection_name=f"kb_{kb_id}",
embedding_function=embeddings,
)
# 4. 将临时文件移动到永久目录
permanent_path = f"kb_{kb_id}/{file_name}"
try:
logger.info(f"Task {task_id}: Moving file to permanent storage")
# 复制到永久目录
source = CopySource(settings.MINIO_BUCKET_NAME, temp_path)
minio_client.copy_object(
bucket_name=settings.MINIO_BUCKET_NAME,
object_name=permanent_path,
source=source
)
logger.info(f"Task {task_id}: File moved to permanent storage")
# 删除临时文件
logger.info(f"Task {task_id}: Removing temporary file from MinIO")
minio_client.remove_object(
bucket_name=settings.MINIO_BUCKET_NAME,
object_name=temp_path
)
logger.info(f"Task {task_id}: Temporary file removed")
except MinioException as e:
error_msg = f"Failed to move file to permanent storage: {str(e)}"
logger.error(f"Task {task_id}: {error_msg}")
raise Exception(error_msg)
# 5. 创建文档记录
logger.info(f"Task {task_id}: Creating document record")
document = Document(
file_name=file_name,
file_path=permanent_path,
file_hash=task.document_upload.file_hash,
file_size=task.document_upload.file_size,
content_type=task.document_upload.content_type,
knowledge_base_id=kb_id
)
db.add(document)
db.flush()
db.refresh(document)
logger.info(f"Task {task_id}: Document record created with ID {document.id}")
# 6. 存储文档块
logger.info(f"Task {task_id}: Storing document chunks")
for i, chunk in enumerate(chunks):
# 为每个 chunk 生成唯一的 ID
chunk_id = hashlib.sha256(
f"{kb_id}:{file_name}:{chunk.page_content}".encode()
).hexdigest()
metadata = _build_enriched_chunk_metadata(
source_metadata=chunk.metadata,
chunk_id=chunk_id,
file_name=file_name,
file_path=permanent_path,
kb_id=kb_id,
document_id=document.id,
chunk_index=i,
chunk_text=chunk.page_content,
)
chunk.metadata = metadata
doc_chunk = DocumentChunk(
id=chunk_id, # 添加 ID 字段
document_id=document.id,
kb_id=kb_id,
file_name=file_name,
chunk_metadata={
"page_content": chunk.page_content,
**metadata
},
hash=hashlib.sha256(
(chunk.page_content + str(metadata)).encode()
).hexdigest()
)
db.add(doc_chunk)
if i > 0 and i % 100 == 0:
logger.info(f"Task {task_id}: Stored {i} chunks")
db.flush()
# 7. 添加到向量存储
logger.info(f"Task {task_id}: Adding chunks to vector store")
vector_chunks = [
LangchainDocument(
page_content=chunk.page_content,
metadata=_sanitize_metadata_for_vector_store(chunk.metadata),
)
for chunk in chunks
]
vector_store.add_documents(vector_chunks)
# 移除 persist() 调用,因为新版本不需要
logger.info(f"Task {task_id}: Chunks added to vector store")
if settings.GRAPHRAG_ENABLED:
try:
from app.services.graph.graphrag_adapter import GraphRAGAdapter
logger.info(f"Task {task_id}: Starting GraphRAG ingestion")
graph_adapter = GraphRAGAdapter()
source_texts = [doc.page_content for doc in documents if doc.page_content.strip()]
await graph_adapter.ingest_texts(kb_id, source_texts)
logger.info(f"Task {task_id}: GraphRAG ingestion completed")
except Exception as graph_exc:
logger.error(f"Task {task_id}: GraphRAG ingestion failed: {graph_exc}")
# 8. 更新任务状态
logger.info(f"Task {task_id}: Updating task status to completed")
task.status = "completed"
task.document_id = document.id # 更新为新创建的文档ID
# 9. 更新上传记录状态
upload = task.document_upload # 直接通过关系获取
if upload:
logger.info(f"Task {task_id}: Updating upload record status to completed")
upload.status = "completed"
db.commit()
logger.info(f"Task {task_id}: Processing completed successfully")
finally:
# 清理本地临时文件
try:
if os.path.exists(local_temp_path):
logger.info(f"Task {task_id}: Cleaning up local temp file")
os.remove(local_temp_path)
logger.info(f"Task {task_id}: Local temp file cleaned up")
except Exception as e:
logger.warning(f"Task {task_id}: Failed to clean up local temp file: {str(e)}")
except Exception as e:
logger.error(f"Task {task_id}: Error processing document: {str(e)}")
logger.error(f"Task {task_id}: Stack trace: {traceback.format_exc()}")
db.rollback()
failed_task = db.query(ProcessingTask).get(task_id)
if failed_task:
failed_task.status = "failed"
failed_task.error_message = str(e)
if failed_task.document_upload:
failed_task.document_upload.status = "failed"
failed_task.document_upload.error_message = str(e)
db.commit()
# 清理临时文件
try:
logger.info(f"Task {task_id}: Cleaning up temporary file after error")
if minio_client is not None:
minio_client.remove_object(
bucket_name=settings.MINIO_BUCKET_NAME,
object_name=temp_path
)
logger.info(f"Task {task_id}: Temporary file cleaned up after error")
except:
logger.warning(f"Task {task_id}: Failed to clean up temporary file after error")
finally:
# if we create the db session, we need to close it
if should_close_db and db:
db.close()

View File

@@ -0,0 +1,46 @@
from app.core.config import settings
from langchain_openai import OpenAIEmbeddings
from langchain_ollama import OllamaEmbeddings
# If you plan on adding other embeddings, import them here
# from some_other_module import AnotherEmbeddingClass
class EmbeddingsFactory:
@staticmethod
def create():
"""
Factory method to create an embeddings instance based on .env config.
"""
# Suppose your .env has a value like EMBEDDINGS_PROVIDER=openai
embeddings_provider = settings.EMBEDDINGS_PROVIDER.lower()
if embeddings_provider == "openai":
return OpenAIEmbeddings(
openai_api_key=settings.OPENAI_API_KEY,
openai_api_base=settings.OPENAI_API_BASE,
model=settings.OPENAI_EMBEDDINGS_MODEL
)
elif embeddings_provider == "dashscope":
return OpenAIEmbeddings(
openai_api_key=settings.DASH_SCOPE_API_KEY,
openai_api_base=settings.DASH_SCOPE_API_BASE,
model=settings.DASH_SCOPE_EMBEDDINGS_MODEL,
# DashScope OpenAI-compatible embedding expects string input,
# while LangChain's len-safe path may send token ids.
check_embedding_ctx_length=False,
tiktoken_enabled=False,
skip_empty=True,
# DashScope embedding API supports at most 10 inputs per batch.
chunk_size=10,
)
elif embeddings_provider == "ollama":
return OllamaEmbeddings(
model=settings.OLLAMA_EMBEDDINGS_MODEL,
base_url=settings.OLLAMA_API_BASE
)
# Extend with other providers:
# elif embeddings_provider == "another_provider":
# return AnotherEmbeddingClass(...)
else:
raise ValueError(f"Unsupported embeddings provider: {embeddings_provider}")

View File

@@ -0,0 +1,116 @@
"""Fusion RAG prompts for aerospace Chinese QA."""
ROUTER_SYSTEM_PROMPT = """
你是一个检索路由器。你的唯一任务是把用户请求分类到以下四类之一。
分类标签:
A: 通用对话路
- 适用:问候、寒暄、角色扮演、无须知识库支持的常识闲聊。
- 特征:没有明确的专业实体约束,也不依赖当前知识库文档。
B: 混合检索路 (Hybrid RAG)
- 适用:单实体事实查询、定义解释、时间/数值/指标问答。
- 特征:问题通常可由少量文本片段直接回答,核心是“找准证据”。
C: 局部图检索路 (Graph Local Search)
- 适用:实体关系、多跳因果、组件依赖、跨段落链式推理。
- 特征:问题包含“谁影响谁/为什么/如何传导/依赖链”。
D: 全局图检索路 (Graph Global Search)
- 适用:全局总结、趋势分析、跨系统比较、宏观评估。
- 特征:问题面向整个语料或多个主题社区,不是单点事实。
判定规则(按优先级):
1. 若请求明确是问候、寒暄、开放闲聊,判 A。
2. 若请求强调全局综述、趋势、横向比较,判 D。
3. 若请求强调实体关系、影响路径、多跳推理,判 C。
4. 其余知识查询默认判 B。
输出要求:
- 只能输出 JSON不要额外文本。
- 格式必须是:
{
"intent": "A/B/C/D",
"reason": "中文简要理由"
}
""".strip()
ROUTER_USER_PROMPT_TEMPLATE = """
请基于以下用户问题进行路由分类。
历史对话(可选):
{chat_history}
用户问题:
{query}
""".strip()
GENERAL_CHAT_PROMPT_TEMPLATE = """
你是中文航天问答助手。当前请求被路由为“通用对话路”。
请直接回答用户问题,要求:
- 简洁自然
- 不要伪造具体文献或数据来源
- 若涉及专业细节但无上下文支撑,请明确说明是一般性知识
用户问题:
{query}
""".strip()
HYBRID_RAG_PROMPT_TEMPLATE = """
你是航天领域事实问答助手。你会收到按相关性排序的文本证据片段,请严格基于证据作答。
要求:
1. 回答正文应自然连贯,不要使用“直接答案”“证据依据”等分节标题。
2. 关键信息需要有可追溯引用,引用编号使用 [1]、[2] 等格式。
3. 引用标号尽量集中放在回答末尾,不要在句中频繁插入。
4. 不得编造未在证据中出现的事实、时间、参数、型号。
5. 若证据不足,明确写:信息不足,缺少 xxx。
6. 输出中文,术语严谨,避免冗长。
问题:
{query}
证据片段:
{context}
""".strip()
GRAPH_LOCAL_PROMPT_TEMPLATE = """
你是航天知识图谱推理助手。你将获得一个局部子图上下文(实体、关系、证据)。
要求:
1. 输出结构固定为:
- 结论
- 推理链路
- 证据映射
- 不确定性
2. 推理链路需按步骤编号步骤1、步骤2...),明确“实体 -> 关系 -> 实体/结论”的链式过程。
3. 若局部子图不完整,必须指出断点,不能臆造链路。
4. 输出中文。
问题:
{query}
局部子图上下文:
{graph_context}
""".strip()
GRAPH_GLOBAL_PROMPT_TEMPLATE = """
你是航天领域全局分析助手。你将获得多个社区摘要,请进行跨社区综合研判。
要求:
1. 输出结构固定为:
- 总体结论
- 跨社区共性
- 关键差异
- 趋势判断
- 风险与建议
2. 每条关键判断尽量给出对应社区编号。
3. 仅依据输入摘要,证据不足时明确说明。
4. 输出中文,适合技术管理层阅读。
问题:
{query}
社区摘要:
{community_context}
""".strip()

View File

@@ -0,0 +1,3 @@
from app.services.graph.graphrag_adapter import GraphRAGAdapter
__all__ = ["GraphRAGAdapter"]

View File

@@ -0,0 +1,183 @@
import asyncio
import importlib
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
from app.core.config import settings
from app.services.embedding.embedding_factory import EmbeddingsFactory
from app.services.llm.llm_factory import LLMFactory
class GraphRAGAdapter:
_instance_lock = asyncio.Lock()
def __init__(self):
self._graphrag_instances: Dict[int, Any] = {}
self._kb_locks: Dict[int, asyncio.Lock] = {}
self._embedding_model = EmbeddingsFactory.create()
self._llm_model = LLMFactory.create(streaming=False)
self._symbols = self._load_symbols()
def _load_symbols(self) -> Dict[str, Any]:
module = importlib.import_module("nano_graphrag")
storage_module = importlib.import_module("nano_graphrag._storage")
utils_module = importlib.import_module("nano_graphrag._utils")
return {
"GraphRAG": module.GraphRAG,
"QueryParam": module.QueryParam,
"Neo4jStorage": getattr(storage_module, "Neo4jStorage"),
"NetworkXStorage": getattr(storage_module, "NetworkXStorage"),
"EmbeddingFunc": getattr(utils_module, "EmbeddingFunc"),
}
def _get_kb_lock(self, kb_id: int) -> asyncio.Lock:
if kb_id not in self._kb_locks:
self._kb_locks[kb_id] = asyncio.Lock()
return self._kb_locks[kb_id]
async def _llm_complete(self, prompt: str, system_prompt: Optional[str] = None, history_messages: Optional[List[Any]] = None, **kwargs: Any) -> str:
history_messages = history_messages or []
history_lines: List[str] = []
for item in history_messages:
if isinstance(item, dict):
role = str(item.get("role", "user"))
content = item.get("content", "")
if isinstance(content, list):
joined = " ".join(str(part.get("text", "")) for part in content if isinstance(part, dict))
history_lines.append(f"{role}: {joined}")
else:
history_lines.append(f"{role}: {content}")
else:
history_lines.append(str(item))
full_prompt = "\n\n".join(
part
for part in [
f"系统提示: {system_prompt}" if system_prompt else "",
"历史对话:\n" + "\n".join(history_lines) if history_lines else "",
"用户输入:\n" + prompt,
]
if part
)
model = self._llm_model
max_tokens = kwargs.get("max_tokens")
if max_tokens is not None:
try:
model = model.bind(max_tokens=max_tokens)
except Exception:
pass
response = await model.ainvoke(full_prompt)
content = getattr(response, "content", response)
if isinstance(content, str):
return content
return str(content)
async def _embedding_call(self, texts: List[str]) -> np.ndarray:
vectors = await asyncio.to_thread(self._embedding_model.embed_documents, texts)
return np.array(vectors)
async def _get_or_create(self, kb_id: int) -> Any:
if kb_id in self._graphrag_instances:
return self._graphrag_instances[kb_id]
async with GraphRAGAdapter._instance_lock:
if kb_id in self._graphrag_instances:
return self._graphrag_instances[kb_id]
GraphRAG = self._symbols["GraphRAG"]
EmbeddingFunc = self._symbols["EmbeddingFunc"]
embedding_func = EmbeddingFunc(
embedding_dim=settings.GRAPHRAG_EMBEDDING_DIM,
max_token_size=settings.GRAPHRAG_EMBEDDING_MAX_TOKEN_SIZE,
func=self._embedding_call,
)
graph_storage_cls = self._symbols["NetworkXStorage"]
addon_params: Dict[str, Any] = {}
if settings.GRAPHRAG_GRAPH_STORAGE.lower() == "neo4j":
graph_storage_cls = self._symbols["Neo4jStorage"]
addon_params = {
"neo4j_url": settings.NEO4J_URL,
"neo4j_auth": (settings.NEO4J_USERNAME, settings.NEO4J_PASSWORD),
}
working_dir = str(Path(settings.GRAPHRAG_WORKING_DIR) / f"kb_{kb_id}")
rag = GraphRAG(
working_dir=working_dir,
enable_local=True,
enable_naive_rag=True,
graph_storage_cls=graph_storage_cls,
addon_params=addon_params,
embedding_func=embedding_func,
best_model_func=self._llm_complete,
cheap_model_func=self._llm_complete,
entity_extract_max_gleaning=settings.GRAPHRAG_ENTITY_EXTRACT_MAX_GLEANING,
)
self._graphrag_instances[kb_id] = rag
return rag
async def ingest_texts(self, kb_id: int, texts: List[str]) -> None:
cleaned = [text.strip() for text in texts if text and text.strip()]
if not cleaned:
return
rag = await self._get_or_create(kb_id)
lock = self._get_kb_lock(kb_id)
async with lock:
await rag.ainsert(cleaned)
async def local_context(self, kb_id: int, query: str, *, top_k: int = 20, level: int = 2) -> str:
rag = await self._get_or_create(kb_id)
QueryParam = self._symbols["QueryParam"]
param = QueryParam(
mode="local",
top_k=top_k,
level=level,
only_need_context=True,
)
return await rag.aquery(query, param)
async def global_context(self, kb_id: int, query: str, *, level: int = 2) -> str:
rag = await self._get_or_create(kb_id)
QueryParam = self._symbols["QueryParam"]
param = QueryParam(
mode="global",
level=level,
only_need_context=True,
)
return await rag.aquery(query, param)
async def local_context_multi(self, kb_ids: List[int], query: str, *, top_k: int = 20, level: int = 2) -> Tuple[str, List[int]]:
contexts: List[str] = []
used_kb_ids: List[int] = []
for kb_id in kb_ids:
try:
ctx = await self.local_context(kb_id, query, top_k=top_k, level=level)
if ctx:
contexts.append(f"[KB:{kb_id}]\n{ctx}")
used_kb_ids.append(kb_id)
except Exception:
continue
return "\n\n".join(contexts), used_kb_ids
async def global_context_multi(self, kb_ids: List[int], query: str, *, level: int = 2) -> Tuple[str, List[int]]:
contexts: List[str] = []
used_kb_ids: List[int] = []
for kb_id in kb_ids:
try:
ctx = await self.global_context(kb_id, query, level=level)
if ctx:
contexts.append(f"[KB:{kb_id}]\n{ctx}")
used_kb_ids.append(kb_id)
except Exception:
continue
return "\n\n".join(contexts), used_kb_ids

View File

@@ -0,0 +1,85 @@
import re
from typing import Any, Dict, List
from app.services.vector_store.base import BaseVectorStore
def _tokenize_for_keyword_score(text: str) -> List[str]:
"""Simple multilingual tokenizer for lexical matching without extra dependencies."""
tokens = re.findall(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]", text.lower())
return [token for token in tokens if token.strip()]
def _keyword_score(query: str, doc_text: str) -> float:
query_terms = set(_tokenize_for_keyword_score(query))
doc_terms = set(_tokenize_for_keyword_score(doc_text))
if not query_terms or not doc_terms:
return 0.0
overlap = len(query_terms.intersection(doc_terms))
return overlap / max(1, len(query_terms))
def hybrid_search(
vector_store: BaseVectorStore,
query: str,
top_k: int = 6,
fetch_k: int = 20,
alpha: float = 0.65,
) -> List[Dict[str, Any]]:
"""
Hybrid retrieval via vector candidate generation + lexical reranking.
score = alpha * vector_rank_score + (1 - alpha) * keyword_score
"""
raw_results = vector_store.similarity_search_with_score(query, k=fetch_k)
if not raw_results:
return []
ranked: List[Dict[str, Any]] = []
total = len(raw_results)
for index, item in enumerate(raw_results):
if not isinstance(item, (tuple, list)) or len(item) < 1:
continue
doc = item[0]
if not hasattr(doc, "page_content"):
continue
rank_score = 1.0 - (index / max(1, total))
lexical_score = _keyword_score(query, doc.page_content)
final_score = alpha * rank_score + (1.0 - alpha) * lexical_score
ranked.append(
{
"document": doc,
"vector_rank_score": round(rank_score, 6),
"keyword_score": round(lexical_score, 6),
"final_score": round(final_score, 6),
}
)
ranked.sort(key=lambda row: row["final_score"], reverse=True)
return ranked[:top_k]
def format_hybrid_context(rows: List[Dict[str, Any]]) -> str:
parts: List[str] = []
for i, row in enumerate(rows, start=1):
doc = row["document"]
metadata = doc.metadata or {}
source = metadata.get("source") or metadata.get("file_name") or "unknown"
chunk_id = metadata.get("chunk_id") or "unknown"
parts.append(
(
f"[{i}] source={source}, chunk_id={chunk_id}, "
f"score={row['final_score']}\n"
f"{doc.page_content.strip()}"
)
)
return "\n\n".join(parts)

View File

@@ -0,0 +1,120 @@
import json
import re
from typing import Any, Dict, List
from app.services.fusion_prompts import (
ROUTER_SYSTEM_PROMPT,
ROUTER_USER_PROMPT_TEMPLATE,
)
VALID_INTENTS = {"A", "B", "C", "D"}
def _extract_json_object(raw_text: str) -> Dict[str, str]:
"""Extract and parse the first JSON object from model output."""
cleaned = raw_text.strip()
cleaned = cleaned.replace("```json", "").replace("```", "").strip()
match = re.search(r"\{[\s\S]*\}", cleaned)
if not match:
raise ValueError("No JSON object found in router output")
data = json.loads(match.group(0))
if not isinstance(data, dict):
raise ValueError("Router output JSON is not an object")
intent = str(data.get("intent", "")).strip().upper()
reason = str(data.get("reason", "")).strip()
if intent not in VALID_INTENTS:
raise ValueError(f"Invalid intent: {intent}")
if not reason:
reason = "模型未提供理由,已按规则兜底。"
return {"intent": intent, "reason": reason}
def _build_history_text(messages: dict, max_turns: int = 6) -> str:
if not isinstance(messages, dict):
return ""
history = messages.get("messages", [])
if not isinstance(history, list):
return ""
tail = history[-max_turns:]
rows: List[str] = []
for msg in tail:
role = str(msg.get("role", "unknown")).strip()
content = str(msg.get("content", "")).strip().replace("\n", " ")
if content:
rows.append(f"{role}: {content}")
return "\n".join(rows)
def _heuristic_route(query: str) -> Dict[str, str]:
text = query.strip().lower()
general_chat_patterns = [
"你好",
"您好",
"在吗",
"谢谢",
"早上好",
"晚上好",
"你是谁",
"讲个笑话",
]
global_patterns = [
"总结",
"综述",
"整体",
"全局",
"趋势",
"对比",
"比较",
"宏观",
"共性",
"差异",
]
local_graph_patterns = [
"关系",
"依赖",
"影响",
"导致",
"原因",
"链路",
"多跳",
"传导",
"耦合",
"约束",
]
if any(token in text for token in general_chat_patterns):
return {"intent": "A", "reason": "命中通用对话关键词,且不依赖知识库检索。"}
if any(token in text for token in global_patterns):
return {"intent": "D", "reason": "问题指向全局总结或跨主题趋势分析。"}
if any(token in text for token in local_graph_patterns):
return {"intent": "C", "reason": "问题强调实体关系与链式推理。"}
return {"intent": "B", "reason": "默认归入事实查询,适合混合检索链路。"}
async def route_intent(llm: Any, query: str, messages: dict) -> Dict[str, str]:
"""Route user query to A/B/C/D with LLM-first and heuristic fallback."""
history_text = _build_history_text(messages)
user_prompt = ROUTER_USER_PROMPT_TEMPLATE.format(
chat_history=history_text or "",
query=query,
)
try:
full_prompt = f"{ROUTER_SYSTEM_PROMPT}\n\n{user_prompt}"
model_resp = await llm.ainvoke(full_prompt)
content = getattr(model_resp, "content", model_resp)
raw_text = content if isinstance(content, str) else str(content)
return _extract_json_object(raw_text)
except Exception:
return _heuristic_route(query)

View File

@@ -0,0 +1,57 @@
from typing import Optional
from langchain_core.language_models import BaseChatModel
from langchain_openai import ChatOpenAI
from langchain_deepseek import ChatDeepSeek
from langchain_ollama import OllamaLLM
from app.core.config import settings
class LLMFactory:
@staticmethod
def create(
provider: Optional[str] = None,
temperature: float = 0,
streaming: bool = True,
) -> BaseChatModel:
"""
Create a LLM instance based on the provider
"""
# If no provider specified, use the one from settings
provider = provider or settings.CHAT_PROVIDER
if provider.lower() == "openai":
return ChatOpenAI(
temperature=temperature,
streaming=streaming,
model=settings.OPENAI_MODEL,
openai_api_key=settings.OPENAI_API_KEY,
openai_api_base=settings.OPENAI_API_BASE
)
elif provider.lower() == "deepseek":
return ChatDeepSeek(
temperature=temperature,
streaming=streaming,
model=settings.DEEPSEEK_MODEL,
api_key=settings.DEEPSEEK_API_KEY,
api_base=settings.DEEPSEEK_API_BASE
)
elif provider.lower() == "dashscope":
return ChatOpenAI(
temperature=temperature,
streaming=streaming,
model=settings.DASH_SCOPE_CHAT_MODEL,
openai_api_key=settings.DASH_SCOPE_API_KEY,
openai_api_base=settings.DASH_SCOPE_API_BASE,
)
elif provider.lower() == "ollama":
# Initialize Ollama model
return OllamaLLM(
model=settings.OLLAMA_MODEL,
base_url=settings.OLLAMA_API_BASE,
temperature=temperature,
streaming=streaming
)
# Add more providers here as needed
# elif provider.lower() == "anthropic":
# return ChatAnthropic(...)
else:
raise ValueError(f"Unsupported LLM provider: {provider}")

View File

@@ -0,0 +1,3 @@
from app.services.reranker.external_api import ExternalRerankerClient
__all__ = ["ExternalRerankerClient"]

View File

@@ -0,0 +1,164 @@
import asyncio
import json
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from urllib import request
@dataclass
class ExternalRerankerClient:
api_url: str
api_key: str = ""
model: str = ""
timeout_seconds: float = 8.0
@property
def enabled(self) -> bool:
return bool(self.api_url)
@property
def is_dashscope_rerank(self) -> bool:
return "dashscope.aliyuncs.com" in self.api_url and "/services/rerank/" in self.api_url
async def rerank(
self,
*,
query: str,
documents: List[str],
top_n: Optional[int] = None,
metadata: Optional[List[Dict[str, Any]]] = None,
) -> Optional[List[float]]:
if not self.enabled:
return None
if not documents:
return []
payload = self._build_payload(
query=query,
documents=documents,
top_n=top_n or len(documents),
metadata=metadata,
)
try:
response = await asyncio.to_thread(self._post_json, payload)
scores = self._parse_scores(response, len(documents))
return scores
except Exception:
return None
def _post_json(self, payload: Dict[str, Any]) -> Dict[str, Any]:
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
req = request.Request(
self.api_url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
with request.urlopen(req, timeout=self.timeout_seconds) as resp:
body = resp.read().decode("utf-8")
return json.loads(body)
def _build_payload(
self,
*,
query: str,
documents: List[str],
top_n: int,
metadata: Optional[List[Dict[str, Any]]],
) -> Dict[str, Any]:
if self.is_dashscope_rerank:
payload = {
"model": self.model,
"input": {
"query": query,
"documents": documents,
},
"parameters": {
"return_documents": True,
"top_n": top_n,
},
}
if metadata:
payload["metadata"] = metadata
return payload
payload = {
"model": self.model,
"query": query,
"documents": documents,
"top_n": top_n,
}
if metadata:
payload["metadata"] = metadata
return payload
def _parse_scores(self, response: Dict[str, Any], expected_len: int) -> List[float]:
# DashScope format:
# {"output": {"results": [{"index": 0, "relevance_score": 0.98}, ...]}}
output_block = response.get("output")
if isinstance(output_block, dict) and isinstance(output_block.get("results"), list):
raw_results = output_block["results"]
scores = [0.0] * expected_len
for item in raw_results:
if not isinstance(item, dict):
continue
idx = item.get("index")
score = item.get("relevance_score", item.get("score", 0.0))
if isinstance(idx, int) and 0 <= idx < expected_len:
try:
scores[idx] = float(score)
except Exception:
scores[idx] = 0.0
return scores
# Common response format #1:
# {"results": [{"index": 0, "relevance_score": 0.98}, ...]}
if isinstance(response.get("results"), list):
raw_results = response["results"]
scores = [0.0] * expected_len
for item in raw_results:
if not isinstance(item, dict):
continue
idx = item.get("index")
score = item.get("relevance_score", item.get("score", 0.0))
if isinstance(idx, int) and 0 <= idx < expected_len:
try:
scores[idx] = float(score)
except Exception:
scores[idx] = 0.0
return scores
# Common response format #2:
# {"scores": [0.9, 0.1, ...]}
if isinstance(response.get("scores"), list):
values = response["scores"]
scores: List[float] = []
for i in range(expected_len):
try:
scores.append(float(values[i]))
except Exception:
scores.append(0.0)
return scores
# Common response format #3:
# {"data": [{"index": 0, "score": 0.88}, ...]}
if isinstance(response.get("data"), list):
raw_results = response["data"]
scores = [0.0] * expected_len
for item in raw_results:
if not isinstance(item, dict):
continue
idx = item.get("index")
score = item.get("score", item.get("relevance_score", 0.0))
if isinstance(idx, int) and 0 <= idx < expected_len:
try:
scores[idx] = float(score)
except Exception:
scores[idx] = 0.0
return scores
return [0.0] * expected_len

View File

@@ -0,0 +1,3 @@
from app.services.retrieval.multi_kb_retriever import MultiKBRetriever, format_retrieval_context
__all__ = ["MultiKBRetriever", "format_retrieval_context"]

View File

@@ -0,0 +1,131 @@
import re
from typing import Any, Dict, List, Optional
from app.services.reranker.external_api import ExternalRerankerClient
def _tokenize(text: str) -> List[str]:
tokens = re.findall(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]", text.lower())
return [token for token in tokens if token.strip()]
def _keyword_score(query: str, text: str) -> float:
query_terms = set(_tokenize(query))
text_terms = set(_tokenize(text))
if not query_terms or not text_terms:
return 0.0
overlap = len(query_terms.intersection(text_terms))
return overlap / max(1, len(query_terms))
def format_retrieval_context(rows: List[Dict[str, Any]]) -> str:
blocks: List[str] = []
for i, row in enumerate(rows, start=1):
doc = row["document"]
metadata = doc.metadata or {}
blocks.append(
(
f"[{i}] kb_id={row.get('kb_id')}, source={metadata.get('source') or metadata.get('file_name') or 'unknown'}, "
f"chunk_id={metadata.get('chunk_id') or 'unknown'}, score={row.get('final_score', 0):.6f}\n"
f"{doc.page_content.strip()}"
)
)
return "\n\n".join(blocks)
class MultiKBRetriever:
def __init__(
self,
*,
reranker_client: Optional[ExternalRerankerClient] = None,
reranker_weight: float = 0.75,
vector_weight: float = 0.2,
keyword_weight: float = 0.05,
):
self.reranker_client = reranker_client
self.reranker_weight = reranker_weight
self.vector_weight = vector_weight
self.keyword_weight = keyword_weight
async def retrieve(
self,
*,
query: str,
kb_vector_stores: List[Dict[str, Any]],
fetch_k_per_kb: int = 12,
top_k: int = 12,
) -> List[Dict[str, Any]]:
candidates: List[Dict[str, Any]] = []
for kb_store in kb_vector_stores:
kb_id = kb_store["kb_id"]
vector_store = kb_store["store"]
raw = vector_store.similarity_search_with_score(query, k=fetch_k_per_kb)
total = len(raw)
for index, item in enumerate(raw):
if not isinstance(item, (tuple, list)) or not item:
continue
doc = item[0]
if not hasattr(doc, "page_content"):
continue
metadata = doc.metadata or {}
rank_score = 1.0 - (index / max(1, total))
lexical_score = _keyword_score(query, doc.page_content)
candidates.append(
{
"kb_id": kb_id,
"document": doc,
"chunk_key": f"{kb_id}:{metadata.get('chunk_id', index)}",
"vector_rank_score": round(rank_score, 6),
"keyword_score": round(lexical_score, 6),
}
)
if not candidates:
return []
# Dedupe by KB + chunk id to avoid repeated chunks from same collection.
unique_map: Dict[str, Dict[str, Any]] = {}
for row in candidates:
key = row["chunk_key"]
existing = unique_map.get(key)
if existing is None:
unique_map[key] = row
continue
if row["vector_rank_score"] > existing["vector_rank_score"]:
unique_map[key] = row
merged = list(unique_map.values())
merged.sort(key=lambda x: x["vector_rank_score"], reverse=True)
reranker_scores: Optional[List[float]] = None
if self.reranker_client is not None and self.reranker_client.enabled:
reranker_scores = await self.reranker_client.rerank(
query=query,
documents=[row["document"].page_content for row in merged],
top_n=min(top_k, len(merged)),
metadata=[{"kb_id": row["kb_id"]} for row in merged],
)
for idx, row in enumerate(merged):
base_score = (
self.vector_weight * row["vector_rank_score"]
+ self.keyword_weight * row["keyword_score"]
)
if reranker_scores is not None:
rerank_value = float(reranker_scores[idx])
final_score = self.reranker_weight * rerank_value + (1 - self.reranker_weight) * base_score
row["reranker_score"] = round(rerank_value, 6)
else:
final_score = base_score
row["reranker_score"] = None
row["final_score"] = round(final_score, 6)
merged.sort(key=lambda x: x["final_score"], reverse=True)
return merged[:top_k]

View File

@@ -0,0 +1,187 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
from sqlalchemy.orm import Session
from app.db.session import SessionLocal
from app.models.tooling import SRSExtraction, SRSRequirement, ToolJob
from app.tools.srs_reqs_qwen import get_srs_tool
def run_srs_job(job_id: int) -> None:
db = SessionLocal()
try:
job = db.query(ToolJob).filter(ToolJob.id == job_id).first()
if not job:
return
job.status = "processing"
job.started_at = datetime.utcnow()
job.error_message = None
db.commit()
payload = get_srs_tool().run(job.input_file_path)
extraction = SRSExtraction(
job_id=job.id,
document_name=payload["document_name"],
document_title=payload.get("document_title") or payload["document_name"],
generated_at=_parse_generated_at(payload.get("generated_at")),
total_requirements=len(payload.get("requirements", [])),
statistics=payload.get("statistics", {}),
raw_output=payload.get("raw_output", {}),
)
db.add(extraction)
db.flush()
for item in payload.get("requirements", []):
requirement = SRSRequirement(
extraction_id=extraction.id,
requirement_uid=item["id"],
title=item.get("title") or 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_number=item.get("section_number"),
section_title=item.get("section_title"),
requirement_type=item.get("requirement_type"),
sort_order=int(item.get("sort_order") or 0),
)
db.add(requirement)
job.status = "completed"
job.completed_at = datetime.utcnow()
job.output_summary = {
"total_requirements": extraction.total_requirements,
"document_name": extraction.document_name,
}
db.commit()
except Exception as exc:
db.rollback()
_mark_job_failed(job_id=job_id, error_message=str(exc))
finally:
db.close()
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 _parse_generated_at(value: Any) -> datetime:
if isinstance(value, str):
try:
return datetime.fromisoformat(value)
except ValueError:
return datetime.utcnow()
return datetime.utcnow()
def ensure_upload_path(job_id: int, file_name: str) -> Path:
target_dir = Path("uploads") / "srs_jobs" / str(job_id)
target_dir.mkdir(parents=True, exist_ok=True)
return target_dir / file_name
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,
}
)
return {
"jobId": job.id,
"documentName": extraction.document_name,
"generatedAt": extraction.generated_at.isoformat(),
"statistics": extraction.statistics or {},
"requirements": requirements,
}
def replace_requirements(db: Session, extraction: SRSExtraction, updates: List[Dict[str, Any]]) -> None:
existing = {
req.requirement_uid: req
for req in db.query(SRSRequirement)
.filter(SRSRequirement.extraction_id == extraction.id)
.all()
}
seen_ids = set()
for index, item in enumerate(updates):
uid = item["id"]
seen_ids.add(uid)
req = existing.get(uid)
if req is None:
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 "",
priority=item.get("priority") or "",
acceptance_criteria=item.get("acceptanceCriteria") or ["待补充验收标准"],
source_field=item.get("sourceField") or "文档解析",
section_number=item.get("sectionNumber"),
section_title=item.get("sectionTitle"),
requirement_type=item.get("requirementType"),
sort_order=int(item.get("sortOrder") or index),
)
db.add(req)
continue
req.title = item.get("title", req.title)
req.description = item.get("description", req.description)
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_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))
for uid, req in existing.items():
if uid not in seen_ids:
db.delete(req)
extraction.total_requirements = len(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,
}
def _count_requirement_types(items: List[Dict[str, Any]]) -> Dict[str, int]:
stats: Dict[str, int] = {}
for item in items:
req_type = item.get("requirementType") or "functional"
stats[req_type] = stats.get(req_type, 0) + 1
return stats

View File

@@ -0,0 +1,3 @@
from app.services.testing_pipeline.pipeline import run_testing_pipeline
__all__ = ["run_testing_pipeline"]

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict
@dataclass
class ToolExecutionResult:
context: Dict[str, Any]
output_summary: str
fallback_used: bool = False
class TestingTool(ABC):
name: str
@abstractmethod
def execute(self, context: Dict[str, Any]) -> ToolExecutionResult:
raise NotImplementedError

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
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
def _build_input_summary(context: Dict[str, Any]) -> str:
req_text = str(context.get("user_requirement_text", "")).strip()
req_type = str(context.get("requirement_type_input", "")).strip() or "auto"
short_text = req_text if len(req_text) <= 60 else f"{req_text[:60]}..."
return f"requirement_type_input={req_type}; requirement_text={short_text}"
def _build_output_summary(context: Dict[str, Any]) -> str:
req_type_result = context.get("requirement_type_result", {})
req_type = req_type_result.get("requirement_type", "")
test_items = context.get("test_items", {})
test_cases = context.get("test_cases", {})
return (
f"requirement_type={req_type}; "
f"items={len(test_items.get('normal', [])) + len(test_items.get('abnormal', []))}; "
f"cases={len(test_cases.get('normal', [])) + len(test_cases.get('abnormal', []))}"
)
def run_testing_pipeline(
user_requirement_text: str,
requirement_type_input: Optional[str] = None,
debug: bool = False,
knowledge_context: Optional[str] = None,
use_model_generation: bool = False,
max_items_per_group: int = 12,
cases_per_item: int = 2,
max_focus_points: int = 6,
max_llm_calls: int = 10,
) -> Dict[str, Any]:
llm_model = None
if use_model_generation:
try:
llm_model = LLMFactory.create(streaming=False)
except Exception:
llm_model = None
context: Dict[str, Any] = {
"trace_id": str(uuid4()),
"user_requirement_text": user_requirement_text,
"requirement_type_input": requirement_type_input,
"debug": bool(debug),
"knowledge_context": (knowledge_context or "").strip(),
"knowledge_used": bool((knowledge_context or "").strip()),
"use_model_generation": bool(use_model_generation),
"llm_model": llm_model,
"max_items_per_group": max(4, min(int(max_items_per_group), 30)),
"cases_per_item": max(1, min(int(cases_per_item), 5)),
"max_focus_points": max(3, min(int(max_focus_points), 12)),
"llm_call_budget": max(0, min(int(max_llm_calls), 100)),
}
step_logs: List[Dict[str, Any]] = []
for tool in build_default_tool_chain():
start = perf_counter()
input_summary = _build_input_summary(context)
execution = tool.execute(context)
context = execution.context
duration_ms = (perf_counter() - start) * 1000
step_logs.append(
{
"step_name": tool.name,
"input_summary": input_summary,
"output_summary": execution.output_summary,
"success": True,
"fallback_used": execution.fallback_used,
"duration_ms": round(duration_ms, 3),
}
)
req_result = context.get("requirement_type_result", {})
return {
"trace_id": context.get("trace_id"),
"requirement_type": req_result.get("requirement_type", "未知类型"),
"reason": req_result.get("reason", ""),
"candidates": req_result.get("candidates", []),
"test_items": context.get("test_items", {"normal": [], "abnormal": []}),
"test_cases": context.get("test_cases", {"normal": [], "abnormal": []}),
"expected_results": context.get("expected_results", {"normal": [], "abnormal": []}),
"formatted_output": context.get("formatted_output", ""),
"pipeline_summary": _build_output_summary(context),
"knowledge_used": bool(context.get("knowledge_used", False)),
"step_logs": step_logs if debug else [],
}

View File

@@ -0,0 +1,203 @@
from __future__ import annotations
from typing import Dict, List
REQUIREMENT_TYPES: List[str] = [
"功能测试",
"性能测试",
"外部接口测试",
"人机交互界面测试",
"强度测试",
"余量测试",
"可靠性测试",
"安全性测试",
"恢复性测试",
"边界测试",
"安装性测试",
"互操作性测试",
"敏感性测试",
"测试充分性要求",
]
TYPE_SIGNAL_RULES: Dict[str, str] = {
"功能测试": "关注功能需求逐项验证、业务流程正确性、输入输出行为、状态转换与边界值处理。",
"性能测试": "关注处理精度、响应时间、处理数据量、系统协调性、负载潜力与运行占用空间。",
"外部接口测试": "关注外部输入输出接口的格式、内容、协议与正常/异常交互表现。",
"人机交互界面测试": "关注界面一致性、界面风格、操作流程、误操作健壮性与错误提示能力。",
"强度测试": "关注系统在极限、超负荷、饱和和降级条件下的稳定性与承受能力。",
"余量测试": "关注存储余量、输入输出通道余量、功能处理时间余量等资源裕度。",
"可靠性测试": "关注真实或仿真环境下的失效等级、运行剖面、输入覆盖和长期稳定运行能力。",
"安全性测试": "关注危险状态响应、安全关键部件、异常输入防护、非法访问阻断和数据完整性保护。",
"恢复性测试": "关注故障探测、备用切换、系统状态保护与从无错误状态继续执行能力。",
"边界测试": "关注输入输出域边界、状态转换端点、功能界限、性能界限与容量界限。",
"安装性测试": "关注不同配置下安装卸载流程和安装规程执行正确性。",
"互操作性测试": "关注多个软件并行运行时的互操作能力与协同正确性。",
"敏感性测试": "关注有效输入类中可能引发不稳定或不正常处理的数据组合。",
"测试充分性要求": "关注需求覆盖率、配置项覆盖、语句覆盖、分支覆盖及未覆盖分析确认。",
}
DECOMPOSE_FORCE_RULES: List[str] = [
"每个软件功能至少应被正常测试与被认可的异常场景覆盖;复杂功能需继续细分。",
"每个测试项必须语义完整、可直接执行。",
"覆盖必须包含:正常流程、边界条件(适用时)、异常条件。",
"粒度需适中,避免过粗或过细。",
"对未知类型必须执行通用分解,并保持正常/异常分组。",
"对需求说明未显式给出但在用户手册或操作手册体现的功能,也应补充测试项覆盖。",
]
REQUIREMENT_RULES: Dict[str, Dict[str, List[str]]] = {
"功能测试": {
"keywords": ["功能", "业务流程", "输入输出", "状态转换", "边界值"],
"normal": [
"正常覆盖功能主路径、基本数据类型、合法边界值与状态转换。",
],
"abnormal": [
"异常覆盖非法输入、不规则输入、非法边界值与最坏情况。",
],
},
"性能测试": {
"keywords": ["性能", "处理精度", "响应时间", "处理数据量", "负载", "占用空间"],
"normal": [
"正常覆盖处理精度、响应时间、处理数据量与模块协调性。",
],
"abnormal": [
"异常覆盖超负荷、软硬件限制、负载潜力上限与资源占用异常。",
],
},
"外部接口测试": {
"keywords": ["外部接口", "输入接口", "输出接口", "格式", "内容", "协议", "异常交互"],
"normal": [
"正常覆盖全部外部接口格式与内容正确性。",
],
"abnormal": [
"异常覆盖每个输入输出接口的错误格式、错误内容与异常交互。",
],
},
"人机交互界面测试": {
"keywords": ["界面", "风格", "交互", "误操作", "错误提示", "操作流程"],
"normal": [
"正常覆盖界面风格一致性与标准操作流程。",
],
"abnormal": [
"异常覆盖误操作、快速操作、非法输入、错误命令与错误流程提示。",
],
},
"强度测试": {
"keywords": ["强度", "极限", "超负荷", "饱和", "降级", "健壮性"],
"normal": [
"正常覆盖设计极限下系统功能和性能表现。",
],
"abnormal": [
"异常覆盖超出极限时的降级行为、健壮性与饱和表现。",
],
},
"余量测试": {
"keywords": ["余量", "存储余量", "通道余量", "处理时间余量", "资源裕度"],
"normal": [
"正常覆盖存储、通道、处理时间余量是否满足要求。",
],
"abnormal": [
"异常覆盖余量不足或耗尽时系统告警与受控行为。",
],
},
"可靠性测试": {
"keywords": ["可靠性", "运行剖面", "失效等级", "输入覆盖", "长期稳定"],
"normal": [
"正常覆盖典型环境、运行剖面与输入变量组合。",
],
"abnormal": [
"异常覆盖失效等级场景、边界环境变化、不合法输入域及失效记录。",
],
},
"安全性测试": {
"keywords": ["安全", "危险状态", "安全关键部件", "非法进入", "完整性", "防护"],
"normal": [
"正常覆盖安全关键部件、安全结构与合法操作路径。",
],
"abnormal": [
"异常覆盖危险状态、故障模式、边界接合部、非法进入与数据完整性保护。",
],
},
"恢复性测试": {
"keywords": ["恢复", "故障探测", "备用切换", "状态保护", "继续执行", "reset"],
"normal": [
"正常覆盖故障探测、备用切换、恢复后继续执行。",
],
"abnormal": [
"异常覆盖故障中作业保护、状态保护与恢复失败路径。",
],
},
"边界测试": {
"keywords": ["边界", "端点", "输入输出域", "状态转换", "性能界限", "容量界限"],
"normal": [
"正常覆盖输入输出域边界、状态转换端点与功能界限。",
],
"abnormal": [
"异常覆盖性能界限、容量界限和越界端点。",
],
},
"安装性测试": {
"keywords": ["安装", "卸载", "配置", "安装规程", "部署", "中断"],
"normal": [
"正常覆盖标准及不同配置下安装卸载流程。",
],
"abnormal": [
"异常覆盖安装规程错误、依赖异常与中断后的处理。",
],
},
"互操作性测试": {
"keywords": ["互操作", "并行运行", "协同", "兼容", "冲突", "互操作失败"],
"normal": [
"正常覆盖两个或多个软件同时运行与互操作过程。",
],
"abnormal": [
"异常覆盖互操作失败、并行冲突与协同异常。",
],
},
"敏感性测试": {
"keywords": ["敏感性", "输入类", "数据组合", "不稳定", "不正常处理"],
"normal": [
"正常覆盖有效输入类中典型数据组合。",
],
"abnormal": [
"异常覆盖引发不稳定或不正常处理的特殊数据组合。",
],
},
"测试充分性要求": {
"keywords": ["测试充分性", "需求覆盖率", "配置项覆盖", "语句覆盖", "分支覆盖", "未覆盖分析"],
"normal": [
"正常覆盖需求覆盖率、配置项覆盖与代码覆盖达标。",
],
"abnormal": [
"异常覆盖未覆盖部分逐项分析、确认与报告输出。",
],
},
}
GENERIC_DECOMPOSITION_RULES: Dict[str, List[str]] = {
"normal": [
"主流程正确性。",
"合法边界值。",
"标准输入输出。",
],
"abnormal": [
"非法输入。",
"越界输入。",
"资源异常或状态冲突。",
],
}
EXPECTED_RESULT_PLACEHOLDER_MAP: Dict[str, str] = {
"{{return_value}}": "接口或函数返回值验证。",
"{{state_change}}": "系统状态变化验证。",
"{{error_message}}": "异常场景错误信息验证。",
"{{data_persistence}}": "数据库或存储落库结果验证。",
"{{ui_display}}": "界面显示反馈验证。",
}

View File

@@ -0,0 +1,867 @@
from __future__ import annotations
import json
import re
from collections import defaultdict
from typing import Any, Dict, List, Optional, Tuple
from app.services.testing_pipeline.base import TestingTool, ToolExecutionResult
from app.services.testing_pipeline.rules import (
DECOMPOSE_FORCE_RULES,
EXPECTED_RESULT_PLACEHOLDER_MAP,
GENERIC_DECOMPOSITION_RULES,
REQUIREMENT_RULES,
REQUIREMENT_TYPES,
TYPE_SIGNAL_RULES,
)
def _clean_text(value: str) -> str:
return " ".join((value or "").replace("\n", " ").split())
def _truncate_text(value: str, max_len: int = 2000) -> str:
text = _clean_text(value)
if len(text) <= max_len:
return text
return f"{text[:max_len]}..."
def _safe_int(value: Any, default: int, low: int, high: int) -> int:
try:
parsed = int(value)
except Exception:
parsed = default
return max(low, min(parsed, high))
def _strip_instruction_prefix(value: str) -> str:
text = _clean_text(value)
if not text:
return text
lowered = text.lower()
if lowered.startswith("/testing"):
text = _clean_text(text[len("/testing") :])
prefixes = [
"为以下需求生成测试用例",
"根据以下需求生成测试用例",
"请根据以下需求生成测试用例",
"请根据需求生成测试用例",
"请生成测试用例",
"生成测试用例",
]
for prefix in prefixes:
if text.startswith(prefix):
for sep in ("", ":"):
idx = text.find(sep)
if idx != -1:
text = _clean_text(text[idx + 1 :])
break
else:
text = _clean_text(text[len(prefix) :])
break
pattern = re.compile(r"^(请)?(根据|按|基于).{0,40}(需求|场景).{0,30}(生成|输出).{0,20}(测试项|测试用例)[:]")
matched = pattern.match(text)
if matched:
text = _clean_text(text[matched.end() :])
return text
def _extract_focus_points(value: str, max_points: int = 6) -> List[str]:
text = _strip_instruction_prefix(value)
if not text:
return []
parts = [_clean_text(part) for part in re.split(r"[,。;;]", text)]
parts = [part for part in parts if part]
ignored_tokens = ["生成测试用例", "测试项分解", "测试用例生成", "以下需求"]
filtered = [
part
for part in parts
if len(part) >= 4 and not any(token in part for token in ignored_tokens)
]
if not filtered:
filtered = parts
priority_keywords = [
"启停",
"开启",
"关闭",
"远程控制",
"保护",
"联动",
"状态",
"故障",
"恢复",
"切换",
"告警",
"模式",
"边界",
"时序",
]
priority = [part for part in filtered if any(keyword in part for keyword in priority_keywords)]
candidates = priority if priority else filtered
unique: List[str] = []
for part in candidates:
if part not in unique:
unique.append(part)
return unique[:max_points]
def _build_type_scores(text: str) -> Dict[str, int]:
scores: Dict[str, int] = {}
lowered = text.lower()
for req_type, rule in REQUIREMENT_RULES.items():
score = 0
if req_type in text:
score += 5
for keyword in rule.get("keywords", []):
if keyword.lower() in lowered:
score += 2
scores[req_type] = score
return scores
def _top_candidates(scores: Dict[str, int], top_n: int = 3) -> List[str]:
sorted_pairs = sorted(scores.items(), key=lambda pair: pair[1], reverse=True)
non_zero = [name for name, score in sorted_pairs if score > 0]
if non_zero:
return non_zero[:top_n]
return ["功能测试", "边界测试", "性能测试"][:top_n]
def _message_to_text(value: Any) -> str:
content = getattr(value, "content", value)
if isinstance(content, str):
return content
if isinstance(content, list):
chunks: List[str] = []
for item in content:
if isinstance(item, str):
chunks.append(item)
elif isinstance(item, dict):
text = item.get("text")
if isinstance(text, str):
chunks.append(text)
else:
chunks.append(str(item))
return "".join(chunks)
return str(content)
def _extract_json_object(value: str) -> Optional[Dict[str, Any]]:
text = (value or "").strip()
if not text:
return None
if text.startswith("```"):
text = re.sub(r"^```(?:json)?", "", text, flags=re.IGNORECASE).strip()
if text.endswith("```"):
text = text[:-3].strip()
try:
data = json.loads(text)
if isinstance(data, dict):
return data
except Exception:
pass
start = text.find("{")
if start == -1:
return None
depth = 0
for idx in range(start, len(text)):
ch = text[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
fragment = text[start : idx + 1]
try:
data = json.loads(fragment)
if isinstance(data, dict):
return data
except Exception:
return None
return None
def _invoke_llm_json(context: Dict[str, Any], prompt: str) -> Optional[Dict[str, Any]]:
model = context.get("llm_model")
if model is None or not context.get("use_model_generation"):
return None
budget = context.get("llm_call_budget")
if isinstance(budget, int):
if budget <= 0:
return None
context["llm_call_budget"] = budget - 1
try:
response = model.invoke(prompt)
text = _message_to_text(response)
return _extract_json_object(text)
except Exception:
return None
def _invoke_llm_text(context: Dict[str, Any], prompt: str) -> str:
model = context.get("llm_model")
if model is None or not context.get("use_model_generation"):
return ""
budget = context.get("llm_call_budget")
if isinstance(budget, int):
if budget <= 0:
return ""
context["llm_call_budget"] = budget - 1
try:
response = model.invoke(prompt)
return _clean_text(_message_to_text(response))
except Exception:
return ""
def _normalize_item_entry(item: Any) -> Optional[Dict[str, Any]]:
if isinstance(item, str):
content = _clean_text(item)
if not content:
return None
return {"content": content, "coverage_tags": []}
if isinstance(item, dict):
content = _clean_text(str(item.get("content", "")))
if not content:
return None
tags = item.get("coverage_tags") or item.get("covered_points") or []
if not isinstance(tags, list):
tags = [str(tags)]
tags = [_clean_text(str(tag)) for tag in tags if _clean_text(str(tag))]
return {"content": content, "coverage_tags": tags}
return None
def _dedupe_items(items: List[Dict[str, Any]], max_items: int) -> List[Dict[str, Any]]:
merged: Dict[str, Dict[str, Any]] = {}
for item in items:
content = _clean_text(item.get("content", ""))
if not content:
continue
existing = merged.get(content)
if existing is None:
merged[content] = {
"content": content,
"coverage_tags": list(item.get("coverage_tags") or []),
}
else:
existing_tags = set(existing.get("coverage_tags") or [])
for tag in item.get("coverage_tags") or []:
if tag and tag not in existing_tags:
existing_tags.add(tag)
existing["coverage_tags"] = list(existing_tags)
deduped = list(merged.values())
return deduped[:max_items]
def _pick_expected_result_placeholder(content: str, abnormal: bool) -> str:
text = content or ""
if abnormal or any(token in text for token in ["非法", "异常", "错误", "拒绝", "越界", "失败"]):
return "{{error_message}}"
if any(token in text for token in ["状态", "切换", "转换", "恢复"]):
return "{{state_change}}"
if any(token in text for token in ["数据库", "存储", "落库", "持久化"]):
return "{{data_persistence}}"
if any(token in text for token in ["界面", "UI", "页面", "按钮", "提示"]):
return "{{ui_display}}"
return "{{return_value}}"
class IdentifyRequirementTypeTool(TestingTool):
name = "identify-requirement-type"
def execute(self, context: Dict[str, Any]) -> ToolExecutionResult:
raw_text = _clean_text(context.get("user_requirement_text", ""))
text = _strip_instruction_prefix(raw_text)
if not text:
text = raw_text
max_focus_points = _safe_int(context.get("max_focus_points"), 6, 3, 12)
provided_type = _clean_text(context.get("requirement_type_input", ""))
focus_points = _extract_focus_points(text, max_points=max_focus_points)
fallback_used = False
if provided_type in REQUIREMENT_TYPES:
result = {
"requirement_type": provided_type,
"reason": "用户已显式指定需求类型,系统按指定类型执行。",
"candidates": [],
"scores": {},
"secondary_types": [],
}
else:
scores = _build_type_scores(text)
sorted_pairs = sorted(scores.items(), key=lambda pair: pair[1], reverse=True)
best_type, best_score = sorted_pairs[0]
secondary = [name for name, score in sorted_pairs[1:4] if score > 0]
if best_score <= 0:
fallback_used = True
candidates = _top_candidates(scores)
result = {
"requirement_type": "未知类型",
"reason": "未命中明确分类规则,已回退到未知类型并提供最接近候选。",
"candidates": candidates,
"scores": scores,
"secondary_types": [],
}
else:
signal = TYPE_SIGNAL_RULES.get(best_type, "")
result = {
"requirement_type": best_type,
"reason": f"命中{best_type}识别信号。{signal}",
"candidates": [],
"scores": scores,
"secondary_types": secondary,
}
context["requirement_type_result"] = result
context["normalized_requirement_text"] = text
context["requirement_focus_points"] = focus_points
context["knowledge_used"] = bool(context.get("knowledge_context"))
return ToolExecutionResult(
context=context,
output_summary=(
f"type={result['requirement_type']}; candidates={len(result['candidates'])}; "
f"secondary_types={len(result.get('secondary_types', []))}; focus_points={len(focus_points)}"
),
fallback_used=fallback_used,
)
class DecomposeTestItemsTool(TestingTool):
name = "decompose-test-items"
@staticmethod
def _seed_items(
req_type: str,
req_text: str,
focus_points: List[str],
max_items: int,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
if req_type in REQUIREMENT_RULES:
source_rules = REQUIREMENT_RULES[req_type]
normal_templates = list(source_rules.get("normal", []))
abnormal_templates = list(source_rules.get("abnormal", []))
else:
normal_templates = list(GENERIC_DECOMPOSITION_RULES["normal"])
abnormal_templates = list(GENERIC_DECOMPOSITION_RULES["abnormal"])
normal: List[Dict[str, Any]] = []
abnormal: List[Dict[str, Any]] = []
for template in normal_templates:
normal.append({"content": template, "coverage_tags": [req_type]})
for template in abnormal_templates:
abnormal.append({"content": template, "coverage_tags": [req_type]})
for point in focus_points:
normal.extend(
[
{
"content": f"验证{point}在标准作业流程下稳定执行且结果符合业务约束。",
"coverage_tags": [point, "正常流程"],
},
{
"content": f"验证{point}与相关联动控制、状态同步和回执反馈的一致性。",
"coverage_tags": [point, "联动一致性"],
},
]
)
abnormal.extend(
[
{
"content": f"验证{point}在非法输入、错误指令或权限异常时的保护与拒绝机制。",
"coverage_tags": [point, "异常输入"],
},
{
"content": f"验证{point}在边界条件、时序冲突或设备故障下的告警和恢复行为。",
"coverage_tags": [point, "边界异常"],
},
]
)
if any(token in req_text for token in ["手册", "操作手册", "用户手册", "作业指导"]):
normal.append(
{
"content": "验证需求说明未显式给出但在用户手册或操作手册体现的功能流程。",
"coverage_tags": ["手册功能"],
}
)
return _dedupe_items(normal, max_items), _dedupe_items(abnormal, max_items)
@staticmethod
def _generate_by_llm(context: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
req_result = context.get("requirement_type_result", {})
req_type = req_result.get("requirement_type", "未知类型")
req_text = context.get("normalized_requirement_text", "")
focus_points = context.get("requirement_focus_points", [])
max_items = _safe_int(context.get("max_items_per_group"), 12, 4, 30)
knowledge_context = _truncate_text(context.get("knowledge_context", ""), max_len=2500)
prompt = f"""
你是资深测试分析师。请根据需求、分解规则和知识库片段,生成尽可能覆盖要点的测试项。
需求文本:{req_text}
需求类型:{req_type}
需求要点:{focus_points}
知识库片段:{knowledge_context or ''}
分解约束:
1. 正常测试与异常测试必须分组输出。
2. 每条测试项必须可执行、可验证,避免模板化空话。
3. 尽可能覆盖全部需求要点每组建议输出6-{max_items}条。
4. 优先生成与需求对象/控制逻辑/异常处理/边界条件强相关的测试项。
请仅输出 JSON 对象,结构如下:
{{
"normal_test_items": [
{{"content": "...", "coverage_tags": ["..."]}}
],
"abnormal_test_items": [
{{"content": "...", "coverage_tags": ["..."]}}
]
}}
""".strip()
data = _invoke_llm_json(context, prompt)
if not data:
return [], []
normal_raw = data.get("normal_test_items", [])
abnormal_raw = data.get("abnormal_test_items", [])
normal: List[Dict[str, Any]] = []
abnormal: List[Dict[str, Any]] = []
for item in normal_raw if isinstance(normal_raw, list) else []:
normalized = _normalize_item_entry(item)
if normalized:
normal.append(normalized)
for item in abnormal_raw if isinstance(abnormal_raw, list) else []:
normalized = _normalize_item_entry(item)
if normalized:
abnormal.append(normalized)
return _dedupe_items(normal, max_items), _dedupe_items(abnormal, max_items)
def execute(self, context: Dict[str, Any]) -> ToolExecutionResult:
req_result = context.get("requirement_type_result", {})
req_type = req_result.get("requirement_type", "未知类型")
req_text = context.get("normalized_requirement_text") or _strip_instruction_prefix(
context.get("user_requirement_text", "")
)
focus_points = context.get("requirement_focus_points", [])
max_items = _safe_int(context.get("max_items_per_group"), 12, 4, 30)
seeded_normal, seeded_abnormal = self._seed_items(req_type, req_text, focus_points, max_items)
llm_normal, llm_abnormal = self._generate_by_llm(context)
merged_normal = _dedupe_items(llm_normal + seeded_normal, max_items)
merged_abnormal = _dedupe_items(llm_abnormal + seeded_abnormal, max_items)
fallback_used = not bool(llm_normal or llm_abnormal)
normal_items: List[Dict[str, Any]] = []
abnormal_items: List[Dict[str, Any]] = []
for idx, item in enumerate(merged_normal, start=1):
normal_items.append(
{
"id": f"N{idx}",
"content": item["content"],
"coverage_tags": item.get("coverage_tags", []),
}
)
for idx, item in enumerate(merged_abnormal, start=1):
abnormal_items.append(
{
"id": f"E{idx}",
"content": item["content"],
"coverage_tags": item.get("coverage_tags", []),
}
)
context["test_items"] = {
"normal": normal_items,
"abnormal": abnormal_items,
}
context["decompose_force_rules"] = DECOMPOSE_FORCE_RULES
return ToolExecutionResult(
context=context,
output_summary=(
f"normal_items={len(normal_items)}; abnormal_items={len(abnormal_items)}; "
f"llm_items={len(llm_normal) + len(llm_abnormal)}"
),
fallback_used=fallback_used,
)
class GenerateTestCasesTool(TestingTool):
name = "generate-test-cases"
@staticmethod
def _build_fallback_steps(item_content: str, abnormal: bool, variant: str) -> List[str]:
if abnormal:
return [
"确认测试前置环境、设备状态与日志采集开关已准备就绪。",
f"准备异常场景“{variant}”所需的输入数据、操作账号和触发条件。",
f"在目标对象执行异常触发操作,重点验证:{item_content}",
"持续观察系统返回码、错误文案、告警信息与日志链路完整性。",
"检查保护机制是否生效,包括拒绝策略、回滚行为和状态一致性。",
"记录证据并复位环境,确认异常处理后系统可恢复到稳定状态。",
]
return [
"确认测试环境、设备连接状态和前置业务数据均已初始化。",
f"准备“{variant}”所需输入参数、操作路径和判定阈值。",
f"在目标对象执行业务控制流程,重点验证:{item_content}",
"校验关键返回值、状态变化、控制回执及界面或接口反馈结果。",
"检查联动模块、日志记录和数据落库是否满足一致性要求。",
"沉淀测试证据并恢复环境,确保后续用例可重复执行。",
]
def _generate_cases_by_llm(
self,
context: Dict[str, Any],
item: Dict[str, Any],
abnormal: bool,
cases_per_item: int,
) -> List[Dict[str, Any]]:
req_text = context.get("normalized_requirement_text", "")
knowledge_context = _truncate_text(context.get("knowledge_context", ""), max_len=1800)
prompt = f"""
你是资深测试工程师。请围绕给定测试项生成详细测试用例。
需求:{req_text}
测试项:{item.get('content', '')}
测试类型:{'异常测试' if abnormal else '正常测试'}
知识库片段:{knowledge_context or ''}
要求:
1. 生成 {cases_per_item}-{max(cases_per_item + 1, cases_per_item)} 条测试用例。
2. 每条用例包含 test_content 与 operation_steps。
3. operation_steps 必须详细至少5步包含前置、执行、观察、校验与证据留存。
4. 内容必须围绕当前测试项,不要输出空洞模板。
仅输出 JSON
{{
"test_cases": [
{{
"title": "...",
"test_content": "...",
"operation_steps": ["...", "..."]
}}
]
}}
""".strip()
data = _invoke_llm_json(context, prompt)
if not data:
return []
raw_cases = data.get("test_cases", [])
if not isinstance(raw_cases, list):
return []
normalized_cases: List[Dict[str, Any]] = []
for case in raw_cases:
if not isinstance(case, dict):
continue
test_content = _clean_text(str(case.get("test_content", "")))
if not test_content:
continue
steps = case.get("operation_steps", [])
if not isinstance(steps, list):
continue
cleaned_steps = [_clean_text(str(step)) for step in steps if _clean_text(str(step))]
if len(cleaned_steps) < 5:
continue
normalized_cases.append(
{
"title": _clean_text(str(case.get("title", ""))),
"test_content": test_content,
"operation_steps": cleaned_steps,
}
)
return normalized_cases[: max(1, cases_per_item)]
def execute(self, context: Dict[str, Any]) -> ToolExecutionResult:
test_items = context.get("test_items", {})
cases_per_item = _safe_int(context.get("cases_per_item"), 2, 1, 5)
normal_cases: List[Dict[str, Any]] = []
abnormal_cases: List[Dict[str, Any]] = []
llm_case_count = 0
for item in test_items.get("normal", []):
generated = self._generate_cases_by_llm(context, item, abnormal=False, cases_per_item=cases_per_item)
if not generated:
generated = [
{
"title": "标准流程验证",
"test_content": f"验证{item['content']}",
"operation_steps": self._build_fallback_steps(item["content"], False, "标准流程"),
},
{
"title": "边界与联动验证",
"test_content": f"验证{item['content']}在边界条件和联动场景下的稳定性",
"operation_steps": self._build_fallback_steps(item["content"], False, "边界与联动"),
},
][:cases_per_item]
else:
llm_case_count += len(generated)
for idx, case in enumerate(generated, start=1):
merged_content = _clean_text(case.get("test_content", item["content"]))
placeholder = _pick_expected_result_placeholder(merged_content, abnormal=False)
normal_cases.append(
{
"id": f"{item['id']}-C{idx}",
"item_id": item["id"],
"title": _clean_text(case.get("title", "")),
"operation_steps": case.get("operation_steps", []),
"test_content": merged_content,
"expected_result_placeholder": placeholder,
}
)
for item in test_items.get("abnormal", []):
generated = self._generate_cases_by_llm(context, item, abnormal=True, cases_per_item=cases_per_item)
if not generated:
generated = [
{
"title": "非法输入与权限异常验证",
"test_content": f"验证{item['content']}在非法输入与权限异常下的处理表现",
"operation_steps": self._build_fallback_steps(item["content"], True, "非法输入与权限异常"),
},
{
"title": "故障与时序冲突验证",
"test_content": f"验证{item['content']}在故障和时序冲突场景下的保护行为",
"operation_steps": self._build_fallback_steps(item["content"], True, "故障与时序冲突"),
},
][:cases_per_item]
else:
llm_case_count += len(generated)
for idx, case in enumerate(generated, start=1):
merged_content = _clean_text(case.get("test_content", item["content"]))
placeholder = _pick_expected_result_placeholder(merged_content, abnormal=True)
abnormal_cases.append(
{
"id": f"{item['id']}-C{idx}",
"item_id": item["id"],
"title": _clean_text(case.get("title", "")),
"operation_steps": case.get("operation_steps", []),
"test_content": merged_content,
"expected_result_placeholder": placeholder,
}
)
context["test_cases"] = {
"normal": normal_cases,
"abnormal": abnormal_cases,
}
return ToolExecutionResult(
context=context,
output_summary=(
f"normal_cases={len(normal_cases)}; abnormal_cases={len(abnormal_cases)}; llm_cases={llm_case_count}"
),
fallback_used=llm_case_count == 0,
)
class BuildExpectedResultsTool(TestingTool):
name = "build_expected_results"
def _expected_for_case(self, context: Dict[str, Any], case: Dict[str, Any], abnormal: bool) -> str:
placeholder = case.get("expected_result_placeholder", "{{return_value}}")
if placeholder not in EXPECTED_RESULT_PLACEHOLDER_MAP:
placeholder = "{{return_value}}"
req_text = context.get("normalized_requirement_text", "")
knowledge_context = _truncate_text(context.get("knowledge_context", ""), max_len=1200)
prompt = f"""
请基于以下信息生成一条可验证、可度量的测试预期结果,避免模板化空话。
需求:{req_text}
测试内容:{case.get('test_content', '')}
测试类型:{'异常测试' if abnormal else '正常测试'}
占位符语义:{placeholder} -> {EXPECTED_RESULT_PLACEHOLDER_MAP.get(placeholder, '')}
知识库片段:{knowledge_context or ''}
输出要求:
1. 仅输出一句中文预期结果。
2. 结果必须可判定成功/失败。
3. 包含关键观测项(返回值、状态、告警、日志、数据一致性中的相关项)。
""".strip()
llm_text = _invoke_llm_text(context, prompt)
if llm_text:
return _truncate_text(llm_text, max_len=220)
test_content = _clean_text(case.get("test_content", ""))
if placeholder == "{{error_message}}":
return f"触发{test_content}后,系统应返回明确错误码与错误文案,拒绝非法请求且核心状态保持一致。"
if placeholder == "{{state_change}}":
return f"执行{test_content}后,系统状态转换应符合需求定义,状态变化可被日志与回执共同验证。"
if placeholder == "{{data_persistence}}":
return f"执行{test_content}后,数据库或存储层应产生符合约束的持久化结果且无脏数据。"
if placeholder == "{{ui_display}}":
return f"执行{test_content}后,界面应展示与控制结果一致的反馈信息且提示可被用户执行。"
if abnormal:
return f"执行异常场景“{test_content}”后,系统应触发保护策略并输出可追溯日志,业务状态保持可恢复。"
return f"执行“{test_content}”后,返回值与状态变化应满足需求约束,关键结果可通过日志或回执验证。"
def execute(self, context: Dict[str, Any]) -> ToolExecutionResult:
test_cases = context.get("test_cases", {})
normal_expected: List[Dict[str, str]] = []
abnormal_expected: List[Dict[str, str]] = []
for case in test_cases.get("normal", []):
normal_expected.append(
{
"id": case["id"],
"case_id": case["id"],
"result": self._expected_for_case(context, case, abnormal=False),
}
)
for case in test_cases.get("abnormal", []):
abnormal_expected.append(
{
"id": case["id"],
"case_id": case["id"],
"result": self._expected_for_case(context, case, abnormal=True),
}
)
context["expected_results"] = {
"normal": normal_expected,
"abnormal": abnormal_expected,
}
return ToolExecutionResult(
context=context,
output_summary=(
f"normal_expected={len(normal_expected)}; abnormal_expected={len(abnormal_expected)}"
),
)
class FormatOutputTool(TestingTool):
name = "format_output"
@staticmethod
def _format_case_block(case: Dict[str, Any], index: int) -> List[str]:
item_id = case.get("item_id", case.get("id", ""))
title = _clean_text(case.get("title", ""))
block: List[str] = []
block.append(f"{index}. [用例 {case['id']}](对应测试项 {item_id}{case.get('test_content', '')}")
if title:
block.append(f" 场景标题:{title}")
block.append(" 操作步骤:")
for step_idx, step in enumerate(case.get("operation_steps", []), start=1):
block.append(f" {step_idx}) {step}")
return block
def execute(self, context: Dict[str, Any]) -> ToolExecutionResult:
test_items = context.get("test_items", {"normal": [], "abnormal": []})
test_cases = context.get("test_cases", {"normal": [], "abnormal": []})
expected_results = context.get("expected_results", {"normal": [], "abnormal": []})
lines: List[str] = []
lines.append("**测试项**")
lines.append("")
lines.append("**正常测试**")
for index, item in enumerate(test_items.get("normal", []), start=1):
lines.append(f"{index}. [测试项 {item['id']}]{item['content']}")
lines.append("")
lines.append("**异常测试**")
for index, item in enumerate(test_items.get("abnormal", []), start=1):
lines.append(f"{index}. [测试项 {item['id']}]{item['content']}")
lines.append("")
lines.append("**测试用例**")
lines.append("")
lines.append("**正常测试**")
for index, case in enumerate(test_cases.get("normal", []), start=1):
lines.extend(self._format_case_block(case, index))
lines.append("")
lines.append("**异常测试**")
for index, case in enumerate(test_cases.get("abnormal", []), start=1):
lines.extend(self._format_case_block(case, index))
lines.append("")
lines.append("**预期成果**")
lines.append("")
lines.append("**正常测试**")
for index, expected in enumerate(expected_results.get("normal", []), start=1):
lines.append(
f"{index}. [预期 {expected['id']}](对应用例 {expected['case_id']}{expected['result']}"
)
lines.append("")
lines.append("**异常测试**")
for index, expected in enumerate(expected_results.get("abnormal", []), start=1):
lines.append(
f"{index}. [预期 {expected['id']}](对应用例 {expected['case_id']}{expected['result']}"
)
context["formatted_output"] = "\n".join(lines)
context["structured_output"] = {
"test_items": test_items,
"test_cases": test_cases,
"expected_results": expected_results,
}
return ToolExecutionResult(
context=context,
output_summary="formatted_sections=3",
)
def build_default_tool_chain() -> List[TestingTool]:
return [
IdentifyRequirementTypeTool(),
DecomposeTestItemsTool(),
GenerateTestCasesTool(),
BuildExpectedResultsTool(),
FormatOutputTool(),
]

View File

@@ -0,0 +1,122 @@
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
@dataclass
class ChunkVectorMetadata:
"""Metadata payload for vector DB and graph linkage."""
chunk_id: str
kb_id: int
document_id: int
document_name: str
document_path: str
chunk_index: int
chunk_text: str
token_count: int
language: str = "zh"
source_type: str = "document"
mission_phase: Optional[str] = None
section_title: Optional[str] = None
publish_time: Optional[str] = None
extracted_entities: List[str] = field(default_factory=list)
extracted_entity_types: List[str] = field(default_factory=list)
extracted_relations: List[Dict[str, Any]] = field(default_factory=list)
graph_node_ids: List[str] = field(default_factory=list)
graph_edge_ids: List[str] = field(default_factory=list)
community_ids: List[str] = field(default_factory=list)
embedding_model: str = ""
embedding_dim: int = 0
ingest_time: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
def to_payload(self) -> Dict[str, Any]:
return {
"chunk_id": self.chunk_id,
"kb_id": self.kb_id,
"document_id": self.document_id,
"document_name": self.document_name,
"document_path": self.document_path,
"chunk_index": self.chunk_index,
"chunk_text": self.chunk_text,
"token_count": self.token_count,
"language": self.language,
"source_type": self.source_type,
"mission_phase": self.mission_phase,
"section_title": self.section_title,
"publish_time": self.publish_time,
"extracted_entities": self.extracted_entities,
"extracted_entity_types": self.extracted_entity_types,
"extracted_relations": self.extracted_relations,
"graph_node_ids": self.graph_node_ids,
"graph_edge_ids": self.graph_edge_ids,
"community_ids": self.community_ids,
"embedding_model": self.embedding_model,
"embedding_dim": self.embedding_dim,
"ingest_time": self.ingest_time,
}
def qdrant_collection_schema(collection_name: str, vector_size: int) -> Dict[str, Any]:
"""Qdrant collection and payload index recommendations."""
return {
"collection_name": collection_name,
"vectors": {
"size": vector_size,
"distance": "Cosine",
},
"payload_indexes": [
{"field_name": "kb_id", "field_schema": "integer"},
{"field_name": "document_id", "field_schema": "integer"},
{"field_name": "document_name", "field_schema": "keyword"},
{"field_name": "chunk_id", "field_schema": "keyword"},
{"field_name": "mission_phase", "field_schema": "keyword"},
{"field_name": "community_ids", "field_schema": "keyword"},
{"field_name": "extracted_entities", "field_schema": "keyword"},
{"field_name": "ingest_time", "field_schema": "datetime"},
],
}
def milvus_collection_schema(collection_name: str, vector_size: int) -> Dict[str, Any]:
"""Milvus field design for vector+graph linkage."""
return {
"collection_name": collection_name,
"fields": [
{"name": "id", "type": "VARCHAR", "max_length": 64, "is_primary": True},
{"name": "kb_id", "type": "INT64"},
{"name": "document_id", "type": "INT64"},
{"name": "chunk_index", "type": "INT32"},
{"name": "document_name", "type": "VARCHAR", "max_length": 255},
{"name": "mission_phase", "type": "VARCHAR", "max_length": 64},
{"name": "community_ids", "type": "VARCHAR", "max_length": 512},
{"name": "extracted_entities", "type": "VARCHAR", "max_length": 2048},
{"name": "ingest_time", "type": "VARCHAR", "max_length": 64},
{"name": "embedding", "type": "FLOAT_VECTOR", "dim": vector_size},
],
"index": {
"field_name": "embedding",
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200},
},
}
DOCUMENT_CHUNK_METADATA_DDL = """
ALTER TABLE document_chunks
ADD COLUMN IF NOT EXISTS chunk_index INT NULL,
ADD COLUMN IF NOT EXISTS token_count INT NULL,
ADD COLUMN IF NOT EXISTS language VARCHAR(16) DEFAULT 'zh',
ADD COLUMN IF NOT EXISTS mission_phase VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS extracted_entities JSON NULL,
ADD COLUMN IF NOT EXISTS extracted_entity_types JSON NULL,
ADD COLUMN IF NOT EXISTS extracted_relations JSON NULL,
ADD COLUMN IF NOT EXISTS graph_node_ids JSON NULL,
ADD COLUMN IF NOT EXISTS graph_edge_ids JSON NULL,
ADD COLUMN IF NOT EXISTS community_ids JSON NULL,
ADD COLUMN IF NOT EXISTS embedding_model VARCHAR(128) NULL,
ADD COLUMN IF NOT EXISTS embedding_dim INT NULL;
""".strip()

View File

@@ -0,0 +1,11 @@
from .base import BaseVectorStore
from .chroma import ChromaVectorStore
from .qdrant import QdrantStore
from .factory import VectorStoreFactory
__all__ = [
'BaseVectorStore',
'ChromaVectorStore',
'QdrantStore',
'VectorStoreFactory'
]

View File

@@ -0,0 +1,42 @@
from abc import ABC, abstractmethod
from typing import List, Optional, Dict, Any
from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings
class BaseVectorStore(ABC):
"""Abstract base class for vector store implementations"""
@abstractmethod
def __init__(self, collection_name: str, embedding_function: Embeddings, **kwargs):
"""Initialize the vector store"""
pass
@abstractmethod
def add_documents(self, documents: List[Document]) -> None:
"""Add documents to the vector store"""
pass
@abstractmethod
def delete(self, ids: List[str]) -> None:
"""Delete documents from the vector store"""
pass
@abstractmethod
def as_retriever(self, **kwargs: Any):
"""Return a retriever interface for the vector store"""
pass
@abstractmethod
def similarity_search(self, query: str, k: int = 4, **kwargs: Any) -> List[Document]:
"""Search for similar documents"""
pass
@abstractmethod
def similarity_search_with_score(self, query: str, k: int = 4, **kwargs: Any) -> List[Document]:
"""Search for similar documents with score"""
pass
@abstractmethod
def delete_collection(self) -> None:
"""Delete the entire collection"""
pass

View File

@@ -0,0 +1,47 @@
from typing import List, Any
from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings
from langchain_chroma import Chroma
import chromadb
from app.core.config import settings
from .base import BaseVectorStore
class ChromaVectorStore(BaseVectorStore):
"""Chroma vector store implementation"""
def __init__(self, collection_name: str, embedding_function: Embeddings, **kwargs):
"""Initialize Chroma vector store"""
chroma_client = chromadb.HttpClient(
host=settings.CHROMA_DB_HOST,
port=settings.CHROMA_DB_PORT,
)
self._store = Chroma(
client=chroma_client,
collection_name=collection_name,
embedding_function=embedding_function,
)
def add_documents(self, documents: List[Document]) -> None:
"""Add documents to Chroma"""
self._store.add_documents(documents)
def delete(self, ids: List[str]) -> None:
"""Delete documents from Chroma"""
self._store.delete(ids)
def as_retriever(self, **kwargs: Any):
"""Return a retriever interface"""
return self._store.as_retriever(**kwargs)
def similarity_search(self, query: str, k: int = 4, **kwargs: Any) -> List[Document]:
"""Search for similar documents in Chroma"""
return self._store.similarity_search(query, k=k, **kwargs)
def similarity_search_with_score(self, query: str, k: int = 4, **kwargs: Any) -> List[Document]:
"""Search for similar documents in Chroma with score"""
return self._store.similarity_search_with_score(query, k=k, **kwargs)
def delete_collection(self) -> None:
"""Delete the entire collection"""
self._store._client.delete_collection(self._store._collection.name)

View File

@@ -0,0 +1,59 @@
from typing import Dict, Type, Any
from langchain_core.embeddings import Embeddings
from .base import BaseVectorStore
from .chroma import ChromaVectorStore
from .qdrant import QdrantStore
class VectorStoreFactory:
"""Factory for creating vector store instances"""
_stores: Dict[str, Type[BaseVectorStore]] = {
'chroma': ChromaVectorStore,
'qdrant': QdrantStore
}
@classmethod
def create(
cls,
store_type: str,
collection_name: str,
embedding_function: Embeddings,
**kwargs: Any
) -> BaseVectorStore:
"""Create a vector store instance
Args:
store_type: Type of vector store ('chroma', 'qdrant', etc.)
collection_name: Name of the collection
embedding_function: Embedding function to use
**kwargs: Additional arguments for specific vector store implementations
Returns:
An instance of the requested vector store
Raises:
ValueError: If store_type is not supported
"""
store_class = cls._stores.get(store_type.lower())
if not store_class:
raise ValueError(
f"Unsupported vector store type: {store_type}. "
f"Supported types are: {', '.join(cls._stores.keys())}"
)
return store_class(
collection_name=collection_name,
embedding_function=embedding_function,
**kwargs
)
@classmethod
def register_store(cls, name: str, store_class: Type[BaseVectorStore]) -> None:
"""Register a new vector store implementation
Args:
name: Name of the vector store type
store_class: Vector store class implementation
"""
cls._stores[name.lower()] = store_class

View File

@@ -0,0 +1,43 @@
from typing import List, Any
from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings
from langchain_community.vectorstores import Qdrant
from app.core.config import settings
from .base import BaseVectorStore
class QdrantStore(BaseVectorStore):
"""Qdrant vector store implementation"""
def __init__(self, collection_name: str, embedding_function: Embeddings, **kwargs):
"""Initialize Qdrant vector store"""
self._store = Qdrant(
collection_name=collection_name,
embeddings=embedding_function,
url=settings.QDRANT_URL,
prefer_grpc=settings.QDRANT_PREFER_GRPC
)
def add_documents(self, documents: List[Document]) -> None:
"""Add documents to Qdrant"""
self._store.add_documents(documents)
def delete(self, ids: List[str]) -> None:
"""Delete documents from Qdrant"""
self._store.delete(ids)
def as_retriever(self, **kwargs: Any):
"""Return a retriever interface"""
return self._store.as_retriever(**kwargs)
def similarity_search(self, query: str, k: int = 4, **kwargs: Any) -> List[Document]:
"""Search for similar documents in Qdrant"""
return self._store.similarity_search(query, k=k, **kwargs)
def similarity_search_with_score(self, query: str, k: int = 4, **kwargs: Any) -> List[Document]:
"""Search for similar documents in Qdrant with score"""
return self._store.similarity_search_with_score(query, k=k, **kwargs)
def delete_collection(self) -> None:
"""Delete the entire collection"""
self._store._client.delete_collection(self._store._collection_name)

View File

@@ -0,0 +1,100 @@
import logging
from contextlib import contextmanager
from pathlib import Path
from typing import Generator, Tuple
from alembic.config import Config
from alembic.config import main as alembic_main
from alembic.migration import MigrationContext
from sqlalchemy import create_engine
from sqlalchemy.engine import Connection
logger = logging.getLogger(__name__)
class DatabaseMigrator:
"""
Database migrator class
"""
def __init__(self, db_url: str):
self.db_url = db_url
self.alembic_cfg = self._get_alembic_config()
@contextmanager
def database_connection(self) -> Generator[Connection, None, None]:
"""
Context manager for database connections with timeout
Yields:
SQLAlchemy connection object
"""
engine = create_engine(
self.db_url, connect_args={"connect_timeout": 3} # 设置连接超时为3秒
)
try:
with engine.connect() as connection:
yield connection
except Exception as e:
logger.error(f"Database connection error: {e}")
raise
def check_migration_needed(self) -> Tuple[bool, str, str]:
"""
Check if database migration is needed
Returns:
Tuple containing:
- bool: Whether migration is needed
- str: Current revision
- str: Head revision
"""
with self.database_connection() as connection:
context = MigrationContext.configure(connection)
current_rev = context.get_current_revision()
heads = context.get_current_heads()
if not heads:
logger.warning("No migration heads found. Database might not be initialized.")
return True, current_rev or "None", "head"
head_rev = heads[0]
return current_rev != head_rev, current_rev or "None", head_rev
def _get_alembic_config(self) -> Config:
"""
Create and configure Alembic config
Returns:
Alembic config object
"""
project_root = Path(__file__).resolve().parents[2] # Go up 3 levels from migrate.py
alembic_cfg = Config(project_root / "alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", self.db_url)
return alembic_cfg
def run_migrations(self) -> None:
"""
Run database migrations if needed
Raises:
Exception: If migration fails
"""
try:
# Check if migration is needed
needs_migration, current_rev, head_rev = self.check_migration_needed()
if needs_migration:
logger.info(f"Current revision: {current_rev}, upgrading to: {head_rev}")
self.alembic_cfg.set_main_option("sqlalchemy.url", self.db_url)
# 执行 alembic 升级
alembic_main(argv=["--raiseerr", "upgrade", "head"], config=self.alembic_cfg)
logger.info("Database migrations completed successfully")
else:
logger.info(f"Database is already at the latest version: {current_rev}")
except Exception as e:
logger.error(f"Error during database migration: {e}")
raise

View File

@@ -0,0 +1,4 @@
from app.tools.base import ToolDefinition
from app.tools.registry import ToolRegistry
__all__ = ["ToolDefinition", "ToolRegistry"]

View File

@@ -0,0 +1,11 @@
from dataclasses import dataclass
from typing import Any, Dict
@dataclass(frozen=True)
class ToolDefinition:
name: str
version: str
description: str
input_schema: Dict[str, Any]
output_schema: Dict[str, Any]

View File

@@ -0,0 +1,19 @@
from typing import Dict, List
from app.tools.base import ToolDefinition
class ToolRegistry:
_tools: Dict[str, ToolDefinition] = {}
@classmethod
def register(cls, definition: ToolDefinition) -> None:
cls._tools[definition.name] = definition
@classmethod
def get(cls, name: str) -> ToolDefinition:
return cls._tools[name]
@classmethod
def list(cls) -> List[ToolDefinition]:
return list(cls._tools.values())

View File

@@ -0,0 +1,3 @@
from app.tools.srs_reqs_qwen.tool import SRSTool, get_srs_tool
__all__ = ["SRSTool", "get_srs_tool"]

View File

@@ -0,0 +1,102 @@
# 配置文件 - SRS 需求文档解析工具 (LLM增强版)
# Configuration file for SRS Requirement Document Parser (LLM Enhanced Version)
# LLM配置 - 阿里云千问
llm:
# 是否启用LLM设为false则使用纯规则提取
enabled: true
# LLM提供商qwen阿里云千问
provider: "qwen"
# 模型名称
model: "qwen3-max"
# API密钥统一由 rag-web-ui 的环境变量提供
api_key: ""
# 可选参数
temperature: 0.3
max_tokens: 1024
# 文档解析配置
document:
supported_formats:
- ".pdf"
- ".docx"
# 标题识别的样式列表
heading_styles:
- "Heading 1"
- "Heading 2"
- "Heading 3"
- "Heading 4"
- "Heading 5"
# 需要过滤的非需求章节GJB438B标准
non_requirement_sections:
- "标识"
- "系统概述"
- "文档概述"
- "引用文档"
- "合格性规定"
- "需求可追踪性"
- "注释"
- "附录"
# 需求提取配置
extraction:
# 需求类型关键字(用于自动判断需求类型)
requirement_types:
功能需求:
prefix: "FR"
keywords: ["功能", "feature", "requirement", "CSCI组成", "控制", "处理", "监测", "显示"]
priority: 1
接口需求:
prefix: "IR"
keywords: ["接口", "interface", "api", "外部接口", "内部接口", "CAN", "以太网", "通信"]
priority: 2
性能需求:
prefix: "PR"
keywords: ["性能", "performance", "速度", "响应时间", "吞吐量"]
priority: 3
安全需求:
prefix: "SR"
keywords: ["安全", "security", "安全性", "报警"]
priority: 4
可靠性需求:
prefix: "RR"
keywords: ["可靠", "reliability", "容错", "恢复", "冗余"]
priority: 5
其他需求:
prefix: "OR"
keywords: ["约束", "资源", "适应性", "保密", "环境", "计算机", "质量", "设计", "人员", "培训", "保障", "验收", "交付"]
priority: 6
splitter:
enabled: true
max_sentence_len: 120
min_clause_len: 12
semantic_guard:
enabled: true
preserve_condition_action_chain: true
preserve_alarm_chain: true
table_strategy:
llm_semantic_enabled: true
sequence_table_merge: "single_requirement"
merge_time_series_rows_min: 3
rewrite_policy:
llm_light_rewrite_enabled: true
preserve_ratio_min: 0.65
max_length_growth_ratio: 1.25
renumber_policy:
enabled: true
mode: "section_continuous"
# 输出配置
output:
format: "json"
indent: 2
# 是否美化输出(格式化)
pretty_print: true
# 是否包含元数据
include_metadata: true
# 日志配置
logging:
level: "INFO" # DEBUG, INFO, WARNING, ERROR
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
file: "srs_parser.log"

View File

@@ -0,0 +1,26 @@
# src/__init__.py
"""
SRS 需求文档解析工具包
"""
__version__ = "1.0.0"
__author__ = "SRS Parser Team"
from .document_parser import DocumentParser
from .llm_interface import LLMInterface, QwenLLM
from .requirement_extractor import RequirementExtractor
from .json_generator import JSONGenerator
from .settings import AppSettings
from .requirement_splitter import RequirementSplitter
from .requirement_id_generator import RequirementIDGenerator
__all__ = [
'DocumentParser',
'LLMInterface',
'QwenLLM',
'RequirementExtractor',
'JSONGenerator',
'AppSettings',
'RequirementSplitter',
'RequirementIDGenerator',
]

View File

@@ -0,0 +1,709 @@
# -*- coding: utf-8 -*-
"""
文档解析模块 - LLM增强版
支持PDF和Docx格式针对GJB438B标准SRS文档优化
"""
import os
import re
import logging
import importlib
from abc import ABC, abstractmethod
from typing import List, Dict, Tuple, Optional, Any
from pathlib import Path
try:
from docx import Document
HAS_DOCX = True
except ImportError:
HAS_DOCX = False
try:
import PyPDF2
HAS_PDF = True
except ImportError:
HAS_PDF = False
HAS_PDF_TABLE = importlib.util.find_spec("pdfplumber") is not None
logger = logging.getLogger(__name__)
class Section:
"""表示文档中的一个章节"""
def __init__(self, level: int, title: str, number: str = None, content: str = "", uid: str = ""):
self.level = level
self.title = title
self.number = number
self.content = content
self.uid = uid
self.parent = None
self.children = []
self.tables = []
self.blocks = []
def add_child(self, child: 'Section') -> None:
self.children.append(child)
child.parent = self
def add_content(self, text: str) -> None:
text = (text or "").strip()
if not text:
return
if self.content:
self.content += "\n" + text
else:
self.content = text
self.blocks.append({"type": "text", "text": text})
def add_table(self, table_data: List[List[str]]) -> None:
if not table_data:
return
self.tables.append(table_data)
table_index = len(self.tables) - 1
self.blocks.append({"type": "table", "table_index": table_index, "table": table_data})
def generate_auto_number(self, parent_number: str = "", sibling_index: int = 1) -> None:
"""
自动生成章节编号(当章节没有编号时)
Args:
parent_number: 父章节编号
sibling_index: 在同级章节中的序号从1开始
"""
if not self.number:
if parent_number:
self.number = f"{parent_number}.{sibling_index}"
else:
self.number = str(sibling_index)
def __repr__(self) -> str:
return f"Section(level={self.level}, number='{self.number}', title='{self.title}')"
class DocumentParser(ABC):
"""文档解析器基类"""
def __init__(self, file_path: str):
self.file_path = file_path
self.sections: List[Section] = []
self.document_title = ""
self.raw_text = ""
self.llm = None
self._uid_counter = 0
def set_llm(self, llm) -> None:
"""设置LLM实例"""
self.llm = llm
@abstractmethod
def parse(self) -> List[Section]:
pass
def get_document_title(self) -> str:
return self.document_title
def _next_uid(self) -> str:
self._uid_counter += 1
return f"sec-{self._uid_counter}"
def _auto_number_sections(self, sections: List[Section], parent_number: str = "") -> None:
"""
为没有编号的章节自动生成编号
规则使用Word样式确定级别跳过前置章节目录、概述等
从第一个正文章节(如"外部接口"开始编号为1
Args:
sections: 章节列表
parent_number: 父章节编号
"""
# 仅在顶级章节重编号
if not parent_number:
# 前置章节关键词(需要跳过的)
skip_keywords = ['目录', '封面', '扉页', '未命名', '', '']
# 正文章节关键词(遇到这些说明正文开始)
content_keywords = ['外部接口', '接口', '软件需求', '需求', '功能', '性能', '设计', '概述', '标识', '引言']
start_index = 0
for idx, section in enumerate(sections):
# 优先检查是否是正文章节
is_content = any(kw in section.title for kw in content_keywords)
if is_content and section.level == 1:
start_index = idx
break
# 重新编号所有章节
counter = 1
for i, section in enumerate(sections):
if i < start_index:
# 前置章节不编号
section.number = ""
else:
# 正文章节顶级章节从1开始编号
if section.level == 1:
section.number = str(counter)
counter += 1
# 递归处理子章节
if section.children:
self._auto_number_sections(section.children, section.number)
else:
# 子章节编号
for i, section in enumerate(sections, 1):
if not section.number or self._is_chinese_number(section.number):
section.generate_auto_number(parent_number, i)
if section.children:
self._auto_number_sections(section.children, section.number)
def _is_chinese_number(self, text: str) -> bool:
"""检查是否是中文数字编号"""
chinese_numbers = '一二三四五六七八九十百千万'
return text and all(c in chinese_numbers for c in text)
class DocxParser(DocumentParser):
"""DOCX格式文档解析器"""
def __init__(self, file_path: str):
if not HAS_DOCX:
raise ImportError("python-docx库未安装请运行: pip install python-docx")
super().__init__(file_path)
self.document = None
def parse(self) -> List[Section]:
try:
self.document = Document(self.file_path)
self.document_title = self.document.core_properties.title or "SRS Document"
section_stack = {}
for block in self._iter_block_items(self.document):
from docx.text.paragraph import Paragraph
from docx.table import Table
if isinstance(block, Paragraph):
text = block.text.strip()
if not text:
continue
heading_info = self._parse_heading(block, text)
if heading_info:
number, title, level = heading_info
section = Section(level=level, title=title, number=number, uid=self._next_uid())
if level == 1 or not section_stack:
self.sections.append(section)
section_stack = {1: section}
else:
parent_level = level - 1
while parent_level >= 1 and parent_level not in section_stack:
parent_level -= 1
if parent_level >= 1 and parent_level in section_stack:
section_stack[parent_level].add_child(section)
elif self.sections:
self.sections[-1].add_child(section)
section_stack[level] = section
for l in list(section_stack.keys()):
if l > level:
del section_stack[l]
else:
# 添加内容到当前章节
if section_stack:
max_level = max(section_stack.keys())
section_stack[max_level].add_content(text)
else:
# 没有标题时,创建默认章节
default_section = Section(level=1, title="未命名章节", number="", uid=self._next_uid())
default_section.add_content(text)
self.sections.append(default_section)
section_stack = {1: default_section}
elif isinstance(block, Table):
# 表格处理
table_data = self._extract_table_data(block)
if table_data:
if section_stack:
max_level = max(section_stack.keys())
section_stack[max_level].add_table(table_data)
else:
default_section = Section(level=1, title="未命名章节", number="", uid=self._next_uid())
default_section.add_table(table_data)
self.sections.append(default_section)
section_stack = {1: default_section}
# 为没有编号的章节自动生成编号
self._auto_number_sections(self.sections)
logger.info(f"完成Docx解析提取{len(self.sections)}个顶级章节")
return self.sections
except Exception as e:
logger.error(f"解析Docx文档失败: {e}")
raise
def _is_valid_heading(self, text: str) -> bool:
"""检查是否是有效的标题"""
if len(text) > 120 or '...' in text:
return False
# 标题应包含中文或字母
if not re.search(r'[\u4e00-\u9fa5A-Za-z]', text):
return False
# 过滤目录项(标题后跟页码,如"概述 2"或"概述 . . . . 2"
if re.search(r'\s{2,}\d+$', text): # 多个空格后跟数字结尾
return False
if re.search(r'[\.。\s]+\d+$', text): # 点号或空格后跟数字结尾
return False
return True
def _parse_heading(self, paragraph, text: str) -> Optional[Tuple[str, str, int]]:
"""解析标题,返回(编号, 标题, 级别)"""
style_name = paragraph.style.name if paragraph.style else ""
is_heading_style = style_name.lower().startswith('heading') if style_name else False
# 数字编号标题
match = re.match(r'^(\d+(?:\.\d+)*)\s*[\.、]?\s*(.+)$', text)
if match and self._is_valid_heading(match.group(2)):
number = match.group(1)
title = match.group(2).strip()
level = len(number.split('.'))
return number, title, level
# 中文编号标题
match = re.match(r'^([一二三四五六七八九十]+)[、\.]+\s*(.+)$', text)
if match and self._is_valid_heading(match.group(2)):
number = match.group(1)
title = match.group(2).strip()
level = 1
return number, title, level
# 样式标题
if is_heading_style and self._is_valid_heading(text):
level = 1
level_match = re.search(r'(\d+)', style_name)
if level_match:
level = int(level_match.group(1))
return "", text, level
return None
def _iter_block_items(self, parent):
"""按文档顺序迭代段落和表格"""
from docx.text.paragraph import Paragraph
from docx.table import Table
from docx.oxml.text.paragraph import CT_P
from docx.oxml.table import CT_Tbl
for child in parent.element.body.iterchildren():
if isinstance(child, CT_P):
yield Paragraph(child, parent)
elif isinstance(child, CT_Tbl):
yield Table(child, parent)
def _extract_table_data(self, table) -> List[List[str]]:
"""提取表格数据"""
table_data = []
for row in table.rows:
row_data = []
for cell in row.cells:
text = cell.text.replace('\n', ' ').strip()
text = re.sub(r'\s+', ' ', text)
row_data.append(text)
if any(cell for cell in row_data):
table_data.append(row_data)
return table_data
class PDFParser(DocumentParser):
"""PDF格式文档解析器 - LLM增强版"""
# GJB438B标准SRS文档的有效章节标题关键词
VALID_TITLE_KEYWORDS = [
'范围', '标识', '概述', '引用', '文档',
'需求', '功能', '接口', '性能', '安全', '保密',
'环境', '资源', '质量', '设计', '约束',
'人员', '培训', '保障', '验收', '交付', '包装',
'优先', '关键', '合格', '追踪', '注释',
'CSCI', '计算机', '软件', '硬件', '通信', '通讯',
'数据', '适应', '可靠', '内部', '外部',
'描述', '要求', '规定', '说明', '定义',
'电场', '防护', '装置', '控制', '监控', '显控'
]
# 明显无效的章节标题模式(噪声)
INVALID_TITLE_PATTERNS = [
'本文档可作为', '参比电位', '补偿电流', '以太网',
'电源', '软件接', '功能\\', '性能 \\', '输入/输出 \\',
'数据处理要求 \\', '固件 \\', '质量控制要求',
'信安科技', '浙江', '公司'
]
def __init__(self, file_path: str):
if not HAS_PDF:
raise ImportError("PyPDF2库未安装请运行: pip install PyPDF2")
super().__init__(file_path)
self.document_title = "SRS Document"
self._page_texts: List[str] = []
def parse(self) -> List[Section]:
"""解析PDF文档"""
try:
# 1. 提取所有文本
self.raw_text = self._extract_all_text()
# 2. 清洗文本
cleaned_text = self._clean_text(self.raw_text)
# 3. 识别章节结构
self.sections = self._parse_sections(cleaned_text)
# 4. 使用LLM验证和清理章节如果可用
if self.llm:
self.sections = self._llm_validate_sections(self.sections)
# 章节识别失败时,创建兜底章节避免后续表格数据丢失。
if not self.sections:
fallback = Section(level=1, title="未命名章节", number="1", uid=self._next_uid())
if cleaned_text:
fallback.add_content(cleaned_text)
self.sections = [fallback]
# 5. 提取并挂接PDF表格到章节若依赖可用
pdf_tables = self._extract_pdf_tables()
if pdf_tables:
self._attach_pdf_tables_to_sections(pdf_tables)
# 6. 为没有编号的章节自动生成编号
self._auto_number_sections(self.sections)
logger.info(f"完成PDF解析提取{len(self.sections)}个顶级章节")
return self.sections
except Exception as e:
logger.error(f"解析PDF文档失败: {e}")
raise
def _extract_all_text(self) -> str:
"""从PDF提取所有文本"""
all_text = []
with open(self.file_path, 'rb') as f:
pdf_reader = PyPDF2.PdfReader(f)
for page in pdf_reader.pages:
text = page.extract_text()
if text:
all_text.append(text)
self._page_texts = all_text
return '\n'.join(all_text)
def _extract_pdf_tables(self) -> List[Dict[str, Any]]:
"""提取PDF中的表格数据。"""
if not HAS_PDF_TABLE:
logger.warning("未安装pdfplumber跳过PDF表格提取。可执行: pip install pdfplumber")
return []
tables: List[Dict[str, Any]] = []
try:
pdfplumber = importlib.import_module("pdfplumber")
with pdfplumber.open(self.file_path) as pdf:
for page_idx, page in enumerate(pdf.pages):
page_text = ""
if page_idx < len(self._page_texts):
page_text = self._page_texts[page_idx]
extracted_tables = page.extract_tables() or []
for table_idx, table in enumerate(extracted_tables):
cleaned_table: List[List[str]] = []
for row in table or []:
cells = [re.sub(r'\s+', ' ', str(cell or '')).strip() for cell in row]
if any(cells):
cleaned_table.append(cells)
if cleaned_table:
tables.append(
{
"page_idx": page_idx,
"table_idx": table_idx,
"page_text": page_text,
"data": cleaned_table,
}
)
except Exception as e:
logger.warning(f"PDF表格提取失败继续纯文本流程: {e}")
return []
logger.info(f"PDF表格提取完成{len(tables)}个表格")
return tables
def _attach_pdf_tables_to_sections(self, tables: List[Dict[str, Any]]) -> None:
"""将提取出的PDF表格挂接到最匹配的章节。"""
flat_sections = self._flatten_sections(self.sections)
if not flat_sections:
return
last_section: Optional[Section] = None
for table in tables:
matched = self._match_table_section(table.get("page_text", ""), flat_sections)
target = matched or last_section or flat_sections[0]
target.add_table(table["data"])
last_section = target
def _flatten_sections(self, sections: List[Section]) -> List[Section]:
"""按文档顺序拉平章节树。"""
result: List[Section] = []
for section in sections:
result.append(section)
if section.children:
result.extend(self._flatten_sections(section.children))
return result
def _match_table_section(self, page_text: str, sections: List[Section]) -> Optional[Section]:
"""基于页文本匹配表格归属章节。"""
normalized_page = re.sub(r"\s+", "", (page_text or "")).lower()
if not normalized_page:
return None
matched: Optional[Section] = None
matched_score = -1
for section in sections:
title = (section.title or "").strip()
if not title:
continue
number = (section.number or "").strip()
candidates = [title]
if number:
candidates.append(f"{number}{title}")
candidates.append(f"{number} {title}")
for candidate in candidates:
normalized_candidate = re.sub(r"\s+", "", candidate).lower()
if normalized_candidate and normalized_candidate in normalized_page:
score = len(normalized_candidate)
if score > matched_score:
matched = section
matched_score = score
return matched
def _clean_text(self, text: str) -> str:
"""清洗PDF提取的文本"""
lines = text.split('\n')
cleaned_lines = []
for line in lines:
line = line.strip()
if not line:
continue
# 跳过页码通常是1-3位数字单独一行
if re.match(r'^\d{1,3}$', line):
continue
# 跳过目录行
if line.count('.') > 10 and '...' in line:
continue
cleaned_lines.append(line)
return '\n'.join(cleaned_lines)
def _parse_sections(self, text: str) -> List[Section]:
"""解析章节结构"""
sections = []
section_stack = {}
lines = text.split('\n')
current_section = None
content_buffer = []
found_sections = set()
for line in lines:
line = line.strip()
if not line:
continue
# 尝试匹配章节标题
section_info = self._match_section_header(line, found_sections)
if section_info:
number, title = section_info
level = len(number.split('.'))
# 保存之前章节的内容
if current_section and content_buffer:
current_section.add_content('\n'.join(content_buffer))
content_buffer = []
# 创建新章节
section = Section(level=level, title=title, number=number, uid=self._next_uid())
found_sections.add(number)
# 建立层次结构
if level == 1:
sections.append(section)
section_stack = {1: section}
else:
parent_level = level - 1
while parent_level >= 1 and parent_level not in section_stack:
parent_level -= 1
if parent_level >= 1 and parent_level in section_stack:
section_stack[parent_level].add_child(section)
elif sections:
sections[-1].add_child(section)
else:
sections.append(section)
section_stack = {1: section}
section_stack[level] = section
for l in list(section_stack.keys()):
if l > level:
del section_stack[l]
current_section = section
else:
# 收集内容
if line and not self._is_noise(line):
content_buffer.append(line)
# 保存最后一个章节的内容
if current_section and content_buffer:
current_section.add_content('\n'.join(content_buffer))
return sections
def _match_section_header(self, line: str, found_sections: set) -> Optional[Tuple[str, str]]:
"""
匹配章节标题
Returns:
(章节编号, 章节标题) 或 None
"""
# 模式: "3.1功能需求" 或 "3.1 功能需求"
match = re.match(r'^(\d+(?:\.\d+)*)\s*(.+)$', line)
if not match:
return None
number = match.group(1)
title = match.group(2).strip()
# 排除目录行
if '...' in title or title.count('.') > 5:
return None
# 验证章节编号
parts = number.split('.')
first_part = int(parts[0])
# 放宽一级章节编号范围非严格GJB结构
if first_part < 1 or first_part > 30:
return None
# 检查子部分是否合理
for part in parts[1:]:
if int(part) > 20:
return None
# 避免重复
if number in found_sections:
return None
# 标题长度检查
if len(title) > 60 or len(title) < 2:
return None
# 放宽标题字符要求兼容部分PDF字体导致中文抽取异常的情况
if not re.search(r'[\u4e00-\u9fa5A-Za-z]', title):
return None
# 检查是否包含无效模式
for invalid_pattern in self.INVALID_TITLE_PATTERNS:
if invalid_pattern in title:
return None
# 标题不能以数字开头
if title[0].isdigit():
return None
# 数字比例检查
digit_ratio = sum(c.isdigit() for c in title) / max(len(title), 1)
if digit_ratio > 0.3:
return None
# 检查标题是否包含反斜杠(通常是表格噪声)
if '\\' in title and '需求' not in title:
return None
return (number, title)
def _is_noise(self, line: str) -> bool:
"""检查是否是噪声行"""
# 纯数字行
if re.match(r'^[\d\s,.]+$', line):
return True
# 非常短的行
if len(line) < 3:
return True
# 罗马数字
if re.match(r'^[ivxIVX]+$', line):
return True
return False
def _llm_validate_sections(self, sections: List[Section]) -> List[Section]:
"""使用LLM验证章节是否有效"""
if not self.llm:
return sections
validated_sections = []
for section in sections:
# 验证顶级章节
if self._is_valid_section_with_llm(section):
# 递归验证子章节
section.children = self._validate_children(section.children)
validated_sections.append(section)
return validated_sections
def _validate_children(self, children: List[Section]) -> List[Section]:
"""递归验证子章节"""
validated = []
for child in children:
if self._is_valid_section_with_llm(child):
child.children = self._validate_children(child.children)
validated.append(child)
return validated
def _is_valid_section_with_llm(self, section: Section) -> bool:
"""使用LLM判断章节是否有效"""
# 先用规则快速过滤明显无效的章节
invalid_titles = [
'本文档可作为', '故障', '实时', '输入/输出',
'固件', '功能\\', '\\4.', '\\3.'
]
for invalid in invalid_titles:
if invalid in section.title:
logger.debug(f"过滤无效章节: {section.number} {section.title}")
return False
# 对于需求相关章节第3章额外验证
if section.number and section.number.startswith('3'):
# 检查标题是否看起来像是有效的需求章节标题
# 有效的标题应该是完整的中文短语
if '\\' in section.title or '/' in section.title:
if not any(kw in section.title for kw in ['输入', '输出', '接口']):
return False
return True
def create_parser(file_path: str) -> DocumentParser:
"""
工厂函数:根据文件扩展名创建相应的解析器
"""
ext = Path(file_path).suffix.lower()
if ext == '.docx':
return DocxParser(file_path)
elif ext == '.pdf':
return PDFParser(file_path)
else:
raise ValueError(f"不支持的文件格式: {ext}")

View File

@@ -0,0 +1,198 @@
# -*- coding: utf-8 -*-
"""
JSON生成器模块 - LLM增强版
将提取的需求和章节结构转换为结构化JSON输出
"""
import json
import logging
from datetime import datetime
from typing import List, Dict, Any, Optional
from .document_parser import Section
from .requirement_extractor import Requirement
from .settings import AppSettings
logger = logging.getLogger(__name__)
class JSONGenerator:
"""JSON输出生成器"""
def __init__(self, config: Dict = None):
self.config = config or {}
self.settings = AppSettings(self.config)
def generate(self, sections: List[Section], requirements: List[Requirement],
document_title: str = "SRS Document") -> Dict[str, Any]:
"""
生成JSON输出
Args:
sections: 章节列表
requirements: 需求列表
document_title: 文档标题
Returns:
结构化JSON字典
"""
# 按章节组织需求
reqs_by_section = self._group_requirements_by_section(requirements)
# 统计需求类型
type_stats = self._calculate_type_statistics(requirements)
# 构建输出结构
output = {
"文档元数据": {
"标题": document_title,
"生成时间": datetime.now().isoformat(),
"总需求数": len(requirements),
"需求类型统计": type_stats
},
"需求内容": self._build_requirement_content(sections, reqs_by_section)
}
logger.info(f"生成JSON输出{len(requirements)}个需求")
return output
def _group_requirements_by_section(self, requirements: List[Requirement]) -> Dict[str, List[Requirement]]:
"""按章节编号分组需求"""
grouped = {}
for req in requirements:
section_key = req.section_uid or req.section_number or 'unknown'
if section_key not in grouped:
grouped[section_key] = []
grouped[section_key].append(req)
return grouped
def _calculate_type_statistics(self, requirements: List[Requirement]) -> Dict[str, int]:
"""计算需求类型统计"""
stats = {}
for req in requirements:
type_chinese = self.settings.type_chinese.get(req.type, '其他需求')
if type_chinese not in stats:
stats[type_chinese] = 0
stats[type_chinese] += 1
return stats
def _should_include_section(self, section: Section) -> bool:
"""判断章节是否应该包含在输出中"""
return not self.settings.is_non_requirement_section(section.title)
def _build_requirement_content(self, sections: List[Section],
reqs_by_section: Dict[str, List[Requirement]]) -> Dict[str, Any]:
"""构建需求内容的层次结构"""
content = {}
for section in sections:
# 只处理需求相关章节
if not self._should_include_section(section):
# 但仍需检查子章节
for child in section.children:
child_content = self._build_section_content_recursive(child, reqs_by_section)
if child_content:
key = f"{child.number} {child.title}" if child.number else child.title
content[key] = child_content
continue
section_content = self._build_section_content_recursive(section, reqs_by_section)
if section_content:
key = f"{section.number} {section.title}" if section.number else section.title
content[key] = section_content
return content
def _build_section_content_recursive(self, section: Section,
reqs_by_section: Dict[str, List[Requirement]]) -> Optional[Dict[str, Any]]:
"""递归构建章节内容"""
# 检查是否应该包含此章节
if not self._should_include_section(section):
return None
# 章节基本信息
result = {
"章节信息": {
"章节编号": section.number or "",
"章节标题": section.title,
"章节级别": section.level
}
}
# 检查是否有子章节
has_valid_children = False
subsections = {}
for child in section.children:
child_content = self._build_section_content_recursive(child, reqs_by_section)
if child_content:
has_valid_children = True
key = f"{child.number} {child.title}" if child.number else child.title
subsections[key] = child_content
# 添加当前章节需求
reqs = reqs_by_section.get(section.uid or section.number or 'unknown', [])
reqs = sorted(reqs, key=lambda r: getattr(r, 'source_order', 0))
if reqs:
result["需求列表"] = []
for req in reqs:
# 需求类型放在最前面
type_chinese = self.settings.type_chinese.get(req.type, '功能需求')
req_dict = {
"需求类型": type_chinese,
"需求编号": req.id,
"需求描述": req.description
}
# 接口需求增加额外字段
if req.type == 'interface':
req_dict["接口名称"] = req.interface_name
req_dict["接口类型"] = req.interface_type
req_dict["来源"] = req.source
req_dict["目的地"] = req.destination
result["需求列表"].append(req_dict)
# 如果有子章节,添加子章节
if has_valid_children:
result["子章节"] = subsections
# 如果章节既没有需求也没有子章节返回None
if "需求列表" not in result and "子章节" not in result:
return None
return result
def save_to_file(self, output: Dict[str, Any], file_path: str) -> None:
"""
将输出保存到文件
Args:
output: 输出字典
file_path: 输出文件路径
"""
try:
output_cfg = self.config.get("output", {})
indent = output_cfg.get("indent", 2)
pretty = output_cfg.get("pretty_print", True)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(output, f, ensure_ascii=False, indent=indent if pretty else None)
logger.info(f"成功保存JSON到: {file_path}")
except Exception as e:
logger.error(f"保存JSON文件失败: {e}")
raise
def generate_and_save(self, sections: List[Section], requirements: List[Requirement],
document_title: str, file_path: str) -> Dict[str, Any]:
"""
生成并保存JSON
Args:
sections: 章节列表
requirements: 需求列表
document_title: 文档标题
file_path: 输出文件路径
Returns:
生成的输出字典
"""
output = self.generate(sections, requirements, document_title)
self.save_to_file(output, file_path)
return output

View File

@@ -0,0 +1,197 @@
# src/llm_interface.py
"""
LLM接口模块 - 支持多个LLM提供商
"""
import logging
import json
from abc import ABC, abstractmethod
from typing import Dict, List, Optional, Any
from .utils import get_env_or_config
logger = logging.getLogger(__name__)
class LLMInterface(ABC):
"""LLM接口基类"""
def __init__(self, api_key: str = None, model: str = None, **kwargs):
"""
初始化LLM接口
Args:
api_key: API密钥
model: 模型名称
**kwargs: 其他参数如temperature, max_tokens等
"""
self.api_key = api_key
self.model = model
self.extra_params = kwargs
@abstractmethod
def call(self, prompt: str) -> str:
"""
调用LLM API
Args:
prompt: 提示词
Returns:
LLM的响应文本
"""
pass
@abstractmethod
def call_json(self, prompt: str) -> Dict[str, Any]:
"""
调用LLM API并获取JSON格式的响应
Args:
prompt: 提示词
Returns:
解析后的JSON字典
"""
pass
def validate_config(self) -> bool:
"""验证配置是否完整"""
return bool(self.api_key and self.model)
class QwenLLM(LLMInterface):
"""阿里云千问LLM实现"""
def __init__(self, api_key: str = None, model: str = "qwen-plus",
api_endpoint: str = None, **kwargs):
"""
初始化千问LLM
Args:
api_key: 阿里云API密钥
model: 模型名称如qwen-plus, qwen-turbo
api_endpoint: API端点地址
**kwargs: 其他参数
"""
super().__init__(api_key, model, **kwargs)
self.api_endpoint = api_endpoint or "https://dashscope.aliyuncs.com/compatible-mode/v1"
self._check_dashscope_import()
def _check_dashscope_import(self) -> None:
"""检查dashscope库是否已安装"""
try:
import dashscope
self.dashscope = dashscope
except ImportError:
logger.error("dashscope库未安装请运行: pip install dashscope")
raise
def call(self, prompt: str) -> str:
"""
调用千问LLM
Args:
prompt: 提示词
Returns:
LLM的响应文本
"""
if not self.validate_config():
raise ValueError("LLM配置不完整api_key或model未设置")
try:
from dashscope import Generation
# 设置API密钥
self.dashscope.api_key = self.api_key
# 构建请求参数 - dashscope 1.7.0 格式
response = Generation.call(
model=self.model,
messages=[
{'role': 'user', 'content': prompt}
],
result_format='message' # 使用message格式
)
# 调试输出
logger.debug(f"API响应类型: {type(response)}")
logger.debug(f"API响应内容: {response}")
# 处理响应
if isinstance(response, dict):
# dict格式响应
status_code = response.get('status_code', 200)
if status_code == 200:
output = response.get('output', {})
if 'choices' in output:
return output['choices'][0]['message']['content']
elif 'text' in output:
return output['text']
else:
# 尝试直接获取text
return output.get('text', str(output))
else:
error_msg = response.get('message', response.get('code', 'Unknown error'))
logger.error(f"千问API返回错误: {error_msg}")
raise Exception(f"API调用失败: {error_msg}")
else:
# 对象格式响应
if hasattr(response, 'status_code') and response.status_code == 200:
output = response.output
if hasattr(output, 'choices'):
return output.choices[0].message.content
elif hasattr(output, 'text'):
return output.text
else:
return str(output)
elif hasattr(response, 'status_code'):
error_msg = getattr(response, 'message', str(response))
raise Exception(f"API调用失败: {error_msg}")
else:
return str(response)
except Exception as e:
logger.error(f"调用千问LLM失败: {e}")
raise
def call_json(self, prompt: str) -> Dict[str, Any]:
"""
调用千问LLM并获取JSON格式响应
Args:
prompt: 提示词
Returns:
解析后的JSON字典
"""
# 添加JSON格式要求到提示词
json_prompt = prompt + "\n\n请确保响应是有效的JSON格式。"
response = self.call(json_prompt)
try:
# 尝试解析JSON
# 首先尝试直接解析
return json.loads(response)
except json.JSONDecodeError:
# 尝试提取JSON代码块
try:
import re
# 查找JSON代码块
json_match = re.search(r'```json\s*(.*?)\s*```', response, re.DOTALL)
if json_match:
return json.loads(json_match.group(1))
# 尝试查找任何JSON对象
json_match = re.search(r'\{.*\}', response, re.DOTALL)
if json_match:
return json.loads(json_match.group(0))
except Exception as e:
logger.warning(f"无法从响应中提取JSON: {e}")
# 如果都失败,返回错误信息
logger.error(f"无法解析LLM响应为JSON: {response}")
return {"error": "Failed to parse response as JSON", "raw_response": response}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""
需求编号生成与提取工具。
"""
import re
from typing import Optional, Tuple, Dict
class RequirementIDGenerator:
def __init__(self, type_prefix: Dict[str, str]):
self.type_prefix = type_prefix
def normalize(self, req_id: str) -> str:
if not req_id:
return ""
return str(req_id).strip()
def extract_from_text(self, text: str) -> Tuple[Optional[str], str]:
if not text:
return None, text
pattern1 = r"^\s*([A-Za-z]{2,10}[-_]\d+(?:[-.\d]+)*)\s*[:\)\]】]?\s*(.+)$"
match = re.match(pattern1, text)
if match:
return match.group(1).strip(), match.group(2).strip()
pattern2 = r"^\s*([A-Za-z]\d+)\s*[:\)\]】]?\s*(.+)$"
match = re.match(pattern2, text)
if match:
return match.group(1).strip(), match.group(2).strip()
pattern3 = r"^\s*([a-z0-9]{1,2}[\)])\s*(.+)$"
match = re.match(pattern3, text)
if match:
code = match.group(1).strip().rstrip(")")
return code, match.group(2).strip()
return None, text
def generate(
self,
req_type: str,
section_number: str,
index: int,
doc_req_id: str = "",
parent_req_id: str = "",
split_index: int = 1,
split_total: int = 1,
) -> str:
base_id = self._generate_base(req_type, section_number, index, doc_req_id, parent_req_id)
if split_total > 1:
return f"{base_id}-S{split_index}"
return base_id
def _generate_base(
self,
req_type: str,
section_number: str,
index: int,
doc_req_id: str,
parent_req_id: str,
) -> str:
if doc_req_id:
complete_id_pattern = r"^[A-Za-z0-9]{2,10}[-_].+$"
if re.match(complete_id_pattern, doc_req_id):
return doc_req_id.replace("_", "-")
if doc_req_id and parent_req_id:
return f"{parent_req_id}-{doc_req_id}"
prefix = self.type_prefix.get(req_type, "FR")
section_part = section_number if section_number else "NA"
return f"{prefix}-{section_part}-{index}"

View File

@@ -0,0 +1,188 @@
# -*- coding: utf-8 -*-
"""
需求长句拆分器。
将复合长句拆分为可验证的原子需求片段。
"""
import re
from typing import List
class RequirementSplitter:
ACTION_HINTS = [
"产生",
"发送",
"设置",
"进入",
"退出",
"关闭",
"开启",
"监测",
"判断",
"记录",
"上传",
"重启",
"恢复",
"关断",
"断电",
"加电",
"执行",
"进行",
]
CONNECTOR_HINTS = ["", "并且", "同时", "然后", "", "以及", ""]
CONDITIONAL_HINTS = ["如果", "", "", "", "其中", "此时", "满足"]
CONTEXT_PRONOUN_HINTS = ["", "", "上述", "", "这些", "那些"]
def __init__(self, max_sentence_len: int = 120, min_clause_len: int = 12):
self.max_sentence_len = max_sentence_len
self.min_clause_len = min_clause_len
def split(self, text: str) -> List[str]:
cleaned = self._clean(text)
if not cleaned:
return []
if self._contains_strong_semantic_chain(cleaned):
return [cleaned]
# 先按强分隔符切分为主片段。
base_parts = self._split_by_strong_punctuation(cleaned)
result: List[str] = []
for part in base_parts:
if len(part) <= self.max_sentence_len:
result.append(part)
continue
# 对超长片段进一步基于逗号和连接词拆分。
refined = self._split_long_clause(part)
result.extend(refined)
result = self._merge_semantic_chain(result)
result = self._merge_too_short(result)
return self._deduplicate(result)
def _contains_strong_semantic_chain(self, text: str) -> bool:
# 条件-动作链完整时,避免强拆。
has_conditional = any(h in text for h in ["如果", "", ""])
has_result = "" in text or "" in text
action_count = sum(1 for h in self.ACTION_HINTS if h in text)
if has_conditional and has_result and action_count >= 2:
return True
return False
def _clean(self, text: str) -> str:
text = re.sub(r"\s+", " ", text or "")
return text.strip(" ;;。")
def _split_by_strong_punctuation(self, text: str) -> List[str]:
chunks = re.split(r"[;。]", text)
return [c.strip(" ,") for c in chunks if c and c.strip(" ,")]
def _split_long_clause(self, clause: str) -> List[str]:
if self._contains_strong_semantic_chain(clause):
return [clause]
raw_parts = [x.strip() for x in re.split(r"[,]", clause) if x.strip()]
if len(raw_parts) <= 1:
return [clause]
assembled: List[str] = []
current = raw_parts[0]
for fragment in raw_parts[1:]:
if self._should_split(current, fragment):
assembled.append(current.strip())
current = fragment
else:
current = f"{current}{fragment}"
if current.strip():
assembled.append(current.strip())
return assembled
def _should_split(self, current: str, fragment: str) -> bool:
if len(current) < self.min_clause_len:
return False
# 指代承接片段通常是语义延续,不应切断。
if any(fragment.startswith(h) for h in self.CONTEXT_PRONOUN_HINTS):
return False
# 条件链中带“则/并/同时”的后继片段,优先保持在同一需求中。
if self._contains_strong_semantic_chain(current + "" + fragment):
return False
frag_starts_with_condition = any(fragment.startswith(h) for h in self.CONDITIONAL_HINTS)
if frag_starts_with_condition:
return False
has_connector = any(fragment.startswith(h) for h in self.CONNECTOR_HINTS)
has_action = any(h in fragment for h in self.ACTION_HINTS)
current_has_action = any(h in current for h in self.ACTION_HINTS)
# 连接词 + 动作词,且当前片段已经包含动作,优先拆分。
if has_connector and has_action and current_has_action:
return True
# 无连接词但出现新的动作片段且整体过长,也拆分。
if has_action and current_has_action and len(current) >= self.max_sentence_len // 2:
return True
return False
def _merge_semantic_chain(self, parts: List[str]) -> List[str]:
if not parts:
return []
merged: List[str] = [parts[0]]
for part in parts[1:]:
prev = merged[-1]
if self._should_merge(prev, part):
merged[-1] = f"{prev}{part}"
else:
merged.append(part)
return merged
def _should_merge(self, prev: str, current: str) -> bool:
# 指代开头:如“该报警信号...”。
if any(current.startswith(h) for h in self.CONTEXT_PRONOUN_HINTS):
return True
# 报警触发后的持续条件与动作属于同一链。
if ("报警" in prev and "持续" in current) or ("产生" in prev and "报警" in prev and "持续" in current):
return True
# 状态迁移 + 后续控制动作保持合并。
if ("进入" in prev or "设置" in prev or "发送" in prev) and ("" in current or "连续" in current):
return True
# 条件链分裂片段重新合并。
if self._contains_strong_semantic_chain(prev + "" + current):
return True
return False
def _merge_too_short(self, parts: List[str]) -> List[str]:
if not parts:
return []
merged: List[str] = []
for part in parts:
if merged and len(part) < self.min_clause_len:
merged[-1] = f"{merged[-1]}{part}"
else:
merged.append(part)
return merged
def _deduplicate(self, parts: List[str]) -> List[str]:
seen = set()
result = []
for part in parts:
key = re.sub(r"\s+", "", part)
if key and key not in seen:
seen.add(key)
result.append(part)
return result

Some files were not shown because too many files have changed in this diff Show More