178 Commits

Author SHA1 Message Date
caoxiaozhu
222ba0bfdc refactor(server): split oversized backend services 2026-05-22 10:42:31 +08:00
caoxiaozhu
2e57702638 docs: add agent code size standards 2026-05-22 10:42:19 +08:00
caoxiaozhu
5fe3b201d9 feat: 重构报销单服务并完善前端提交与审核交互
重构 expense_claims 服务模块结构并优化差旅票据审核逻辑,
增强用户代理服务的票据类型识别,前端报销创建页面拆分为
附件模型和会话模型模块,重构提交编排器和草稿关联确认流
程,更新知识库索引,补充单元测试。
2026-05-22 08:58:59 +08:00
caoxiaozhu
f6f787ff38 refactor(frontend): split large reimbursement and audit modules 2026-05-21 23:53:03 +08:00
caoxiaozhu
2908dda024 fix(reimbursement): harden assistant draft and claim cleanup 2026-05-21 23:52:34 +08:00
caoxiaozhu
e701fa01da feat: 增强差旅报销审核流程与票据智能推理
优化本体解析和编排器的差旅场景处理能力,完善报销单草稿
保存和费用明细同步逻辑,前端报销创建页面增加行程推理和
票据审核交互,新增助手会话快照工具函数,补充单元测试。
2026-05-21 16:09:47 +08:00
caoxiaozhu
f28d7e6d16 feat: 完善差旅票据行程提取与费用明细回填逻辑
增强文档智能识别的票据场景关键词和字段提取能力,优化
会话关联草稿报销单的解析路径,修复费用明细合并和票据
去重边界问题,前端改进报销创建和审批详情交互,补充单
元测试覆盖。
2026-05-21 14:24:51 +08:00
caoxiaozhu
b183b0bd5e feat: 细化差旅票据费用明细分类并自动计算出差补贴
将差旅费用明细拆分为火车票、机票、住宿票、乘车等细分类
型,根据票据字段自动生成行程/事由描述,结合规则引擎自
动计算出差补贴金额,前端适配费用明细编辑和差旅票据审
核交互,补充单元测试覆盖。
2026-05-21 10:57:06 +08:00
caoxiaozhu
8f65661809 feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分
直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层
缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流
交互并补充单元测试。
2026-05-21 09:28:33 +08:00
caoxiaozhu
002bf4f756 feat: 完善报销单审批流程及退回原因追踪
新增直属领导审批通过接口和审批待办列表查询,报销单退回
支持原因码分类和审批环节标记,优化票据附件去重和路径
回退查找,前端新增退回原因对话框、审批收件箱和工作台
图标组件,补充工具函数和单元测试覆盖。
2026-05-20 21:00:47 +08:00
caoxiaozhu
f8b25a7ccc fix: 修复员工服务、报销单审批及前端交互细节
- 修复员工创建时组织架构关联与邮箱校验逻辑
- 修复报销单API端点参数及预审流程调用
- 优化审批中心、差旅详情等前端页面交互
- 更新侧边栏导航与请求视图模型
- 补充员工服务与报销单相关测试用例
2026-05-20 14:32:35 +08:00
caoxiaozhu
d7e98a58b9 feat: 增强员工管理与报销单全流程功能
- 新增员工Excel导入服务(employee_spreadsheet)及导入/导出API端点
- 员工服务增加批量创建、邮箱唯一校验、组织架构关联等能力
- 报销单提交补充身份回填、部门信息透传及预审结果展示优化
- 认证流程增加部门信息(departmentName)并在schema中同步扩展
- 用户Agent服务增加部门关联与报销单回填逻辑
- 前端员工管理页面全面重构,新增导入导出、搜索过滤、分页等功能
- 前端审批中心、审计、差旅报销等视图交互与样式优化
- 新增TableLoadingState共享组件及员工导入测试用例
2026-05-20 14:21:56 +08:00
caoxiaozhu
57957d11a0 feat: 重构报销单AI预审流程并添加平台风险规则引擎
- 将AI验审改为AI预审,高风险不再拦截而是随单流转给审批人复核
- 新增平台风险规则评估引擎,支持事由过短、票据异常、重复发票等多种评估器
- 用户上下文增加部门信息(department_name),认证流程同步关联组织架构
- 规则scenario_json改为中文标签(差旅/费用科目),统一场景分类
- 新增orchestrator审核流程测试用例
- 前端更新审计视图、差旅报销等相关页面
2026-05-20 09:36:01 +08:00
caoxiaozhu
2574bc81d1 chore: 更新个人工作台和差旅报销相关功能 2026-05-19 17:24:13 +00:00
caoxiaozhu
54ffef66d3 feat: 添加风险规则及 agent assets 功能增强 2026-05-19 16:19:03 +00:00
caoxiaozhu
d460ee0fe7 fix(agent): 修复规则中心表格版本和修改记录
补齐规则资产 JSON 读写接口和前端调用,修复 AuditView 导入缺失。

Excel 在线编辑改为比对所有页签并生成最近修改记录,版本快照统一保存到 rules/finance-rules/.versions。

隔离规则表测试存储,避免测试或旧入口写入真实规则目录与 storage/agent_assets。
2026-05-19 15:41:53 +00:00
caoxiaozhu
9472813739 refactor: 重构 AuditView 和 TravelReimbursementCreateView 相关代码
- 优化 agent_assets、agent_foundation、user_agent 服务层结构
- 更新 AuditView 视图和脚本
- 更新 TravelReimbursementCreateView 脚本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 20:23:58 +08:00
caoxiaozhu
dc007f948a feat(rules): 添加公司通信费报销规则 Excel 文件并更新差旅费规则表 2026-05-18 10:06:23 +00:00
caoxiaozhu
9db663e81f feat(AuditView): 扩展场景标签配置
- 新增通信费报销(communication_expense)和费用标准(expense_standard)场景标签
2026-05-18 10:02:04 +00:00
caoxiaozhu
813ac81950 feat(finance): 添加公司通信费报销规则
- 新增通信费报销规则代码和文件名常量
- 在初始化数据中创建公司通信费报销规则资产
- 添加对应的版本和审核记录
- 标记为 v1.0.0 版本并审核通过
2026-05-18 10:01:40 +00:00
caoxiaozhu
9902a3b968 test: 添加 OnlyOffice 回调摘要测试 2026-05-18 09:45:05 +00:00
caoxiaozhu
29df4eee3b test: 添加规则服务与电子表格变更记录的测试用例
- AgentAssetService 测试覆盖版本创建、送审、审核通过等流程
- 新增电子表格变更记录的前端测试
2026-05-18 09:44:04 +00:00
caoxiaozhu
5106d286a1 feat(agent_assets): 添加规则版本送审时的命名副本创建逻辑
当提交的版本与当前工作版本不同时,自动创建命名副本:
- 新增 _create_named_working_copy_for_review 方法处理送审时的版本复制
- 支持将工作版本快照复制为指定版本进行送审
- 新增 AgentAssetSpreadsheetChangeRecordRead schema
- API 端点新增 /rules/{id}/spreadsheet-versions/{version}/change-records 接口
2026-05-18 09:42:23 +00:00
caoxiaozhu
64ec27949f feat(AuditView): 删除差旅费报销规则详情的审核按钮并优化显示
- 删除详情中间区域的提交审核、审核通过、驳回版本三个按钮
- 删除底部 footer 的提交审核、审核通过、驳回版本、正式上线四个按钮
- 将下载 Excel 按钮改为下载表格
- 简化电子表格编辑器的 meta 信息显示
2026-05-18 09:39:41 +00:00
caoxiaozhu
8814fe7dfa chore: 更新配置和构建脚本 2026-05-18 02:53:06 +00:00
caoxiaozhu
9b97f456cf test: 添加资产和运行服务测试用例 2026-05-18 02:52:13 +00:00
caoxiaozhu
9d90bf5299 feat: 更新前端UI,增强审计和日志视图功能 2026-05-18 02:51:25 +00:00
caoxiaozhu
35a3783481 feat: 更新 user_agent 服务,增强用户代理功能 2026-05-18 02:50:32 +00:00
caoxiaozhu
4414ffb34c feat: 增强知识库功能,优化索引和RAG检索 2026-05-18 02:49:39 +00:00
caoxiaozhu
55e0591a5e feat: 增强 agent_assets 功能,支持更多资产操作 2026-05-18 02:48:51 +00:00
caoxiaozhu
68f663f2f4 feat: 重构知识库系统,移除Hermes集成,增强RAG和同步功能
主要变更:
- 移除Hermes智能体及相关回调服务
- 新增知识库RAG、同步、调度、规范化和索引任务服务
- 重构orchestrator服务,增强运行时聊天功能
- 更新前端聊天、政策制度、设置等页面样式和逻辑
- 更新expense_claims和document_intelligence服务
- 删除llm_wiki相关服务和测试文件
- 更新docker-compose配置和启动脚本
2026-05-17 08:38:41 +00:00
caoxiaozhu
212c935308 feat: 集成Hermes智能体系统,增强聊天和差旅报销功能 2026-05-16 06:14:08 +00:00
caoxiaozhu
763afa0ee2 chore(server): 更新知识库索引文件,包含文档元数据和知识候选摘要信息 2026-05-15 09:38:04 +00:00
caoxiaozhu
72ea05ae0d feat(web): 更新应用外壳路由视图和政策制度页面,增强前端路由嵌套和页面展示能力 2026-05-15 09:37:42 +00:00
caoxiaozhu
02f54ea208 feat(web): 新增日志详情和日志列表页面,包含完整的视图组件和业务脚本逻辑 2026-05-15 09:37:24 +00:00
caoxiaozhu
94adb82acc feat(web): 更新路由配置和图标数据定义,优化前端路由导航和UI图标管理 2026-05-15 09:36:33 +00:00
caoxiaozhu
1bab7c22d7 feat(web): 新增系统日志服务代理和智能体资源服务,添加访问控制工具函数,增强前端服务层能力 2026-05-15 09:36:16 +00:00
caoxiaozhu
891cecb4a8 feat(web): 新增导航组合式函数,更新应用外壳组合式函数,增强前端路由和状态管理 2026-05-15 09:36:32 +00:00
caoxiaozhu
3a3000cacd feat(web): 更新侧边栏和顶部导航组件,新增日志趋势图表组件,增强前端展示能力 2026-05-15 09:36:17 +00:00
caoxiaozhu
e9735f1606 style(web): 更新应用样式和政策制度页面样式,新增日志详情和日志列表页面样式 2026-05-15 09:35:58 +00:00
caoxiaozhu
7066707492 docs: 添加日志管理界面截图,用于文档说明 2026-05-15 09:35:03 +00:00
caoxiaozhu
11435468f1 feat(server): 新增智能体运行服务,优化知识库索引存储结构,增强运行时状态管理 2026-05-15 09:34:21 +00:00
caoxiaozhu
6793b6f832 feat(server): 重构知识库服务和路由配置,优化LLM维基知识管理接口,增强知识检索能力 2026-05-15 09:33:59 +00:00
caoxiaozhu
7a3feb14a0 docs: 更新Hermes MVP开发计划文档,优化第六天智能体开发任务描述 2026-05-15 09:33:18 +00:00
caoxiaozhu
1d5d009bc7 feat(server): 新增系统日志服务模块,包含API端点、schema定义和服务实现,用于系统操作日志记录和查询 2026-05-15 09:33:20 +00:00
caoxiaozhu
8691385a8e feat(server): 更新LLM维基服务实现,新增知识库索引存储支持 2026-05-15 06:59:12 +00:00
caoxiaozhu
5b4e2b5d84 feat(server): 更新知识库schema定义和服务实现,优化索引管理逻辑 2026-05-15 06:58:39 +00:00
caoxiaozhu
4f3556a38b docs: 新增agent开发文档和风险评估文档 2026-05-15 06:58:21 +00:00
caoxiaozhu
45abd36430 feat(server): 新增费用规则运行时服务和系统赫尔墨斯服务,增强报销规则执行和系统监控能力 2026-05-15 06:58:03 +00:00
caoxiaozhu
ea339d883a feat(server): 新增LLM维基服务,实现大语言模型知识库管理和查询功能 2026-05-15 06:57:45 +00:00
caoxiaozhu
244b3a58f7 feat(web): 更新审批中心、审计、政策制度页面及对应的业务脚本,增强前端交互逻辑 2026-05-15 06:57:07 +00:00
caoxiaozhu
344ac126b3 feat(web): 更新个人工作台组件和应用外壳组合式函数,新增请求数据组合式函数 2026-05-15 06:56:51 +00:00
caoxiaozhu
d8d0415bf4 style(web): 更新审批中心、审计、政策制度页面样式,增强页面布局和视觉效果 2026-05-15 06:56:39 +00:00
caoxiaozhu
511337df95 feat(server): 更新用户代理服务,新增编排器服务测试用例,完善服务编排能力 2026-05-15 06:56:26 +00:00
caoxiaozhu
c9cc0b0641 feat(server): 扩展智能体基础服务,新增端点测试和资产服务测试用例 2026-05-15 06:56:14 +00:00
caoxiaozhu
68a448a551 feat(server): 优化费用报销服务,改进报销单创建和数据校验逻辑,增强单元测试覆盖 2026-05-15 06:56:00 +00:00
caoxiaozhu
4b1dae7ebc feat(server): 扩展知识库服务,添加knowledge API端点和schema定义,前端新增knowledge服务模块 2026-05-15 06:56:17 +00:00
caoxiaozhu
7209c75ad8 style(web): 更新差旅报销创建页面样式和业务脚本,增强前端交互和状态管理 2026-05-14 15:43:10 +00:00
caoxiaozhu
98f68c47b0 feat(web): 更新侧边栏组件和个人工作台组件,增强导航和业务展示功能 2026-05-14 15:43:28 +00:00
caoxiaozhu
056d6dbe22 feat(web): 新增前端编排器和报销单服务模块,封装对应的API调用方法 2026-05-14 15:42:47 +00:00
caoxiaozhu
910c959829 feat(server): 新增编排器和报销单API端点,扩展智能体对话服务接口 2026-05-14 15:42:39 +00:00
caoxiaozhu
c99a423f6a feat(server): 扩展文档智能识别服务,新增Azure Document Intelligence集成和测试用例 2026-05-14 15:42:29 +00:00
caoxiaozhu
e21f0d82e9 feat(server): 重构报销单服务,优化费用报销流程和数据校验逻辑,包含schema定义和服务实现 2026-05-14 15:42:45 +00:00
caoxiaozhu
ad16358e71 feat(server): 更新用户代理服务架构,增强用户行为追踪和会话管理功能,包含schema、service和单元测试 2026-05-14 15:42:33 +00:00
caoxiaozhu
fad583ee7c feat(server): 新增编排器和报销单API接口,扩展服务编排和报销管理端点 2026-05-14 15:42:22 +00:00
caoxiaozhu
af98acceb3 feat(web): 优化应用外壳组合式函数和差旅报销创建脚本,增强前端状态管理 2026-05-14 12:34:43 +00:00
caoxiaozhu
3bc7668f6c feat(server): 优化费用报销服务,增强报销单数据校验和状态流转逻辑 2026-05-14 12:34:54 +00:00
caoxiaozhu
32a43cf6bb feat(server): 新增编排器服务,实现服务间流程编排和任务调度功能 2026-05-14 12:34:12 +00:00
caoxiaozhu
dbf6c36c65 feat(server): 重构用户代理服务模块,优化用户行为跟踪和代理逻辑 2026-05-14 12:33:58 +00:00
caoxiaozhu
f36e1bbee7 docs: 添加测试流程配置图 2026-05-14 09:34:32 +00:00
caoxiaozhu
1cbf790fcf feat(web): 更新请求列表、差旅报销创建、差旅请求详情页面及对应的业务脚本逻辑 2026-05-14 09:33:23 +00:00
caoxiaozhu
f9f91380ad style(web): 更新审批中心、审计、员工管理、请求列表、差旅报销创建、差旅请求详情等页面的样式文件 2026-05-14 09:33:03 +00:00
caoxiaozhu
bac3f00ae4 feat(server): 新增运行时聊天服务和用户代理服务,支持智能对话和用户行为分析 2026-05-14 09:32:49 +00:00
caoxiaozhu
b0fef46fc6 feat(server): 重构费用报销服务,优化报销单创建和审批流程逻辑 2026-05-14 09:32:36 +00:00
caoxiaozhu
8b39f48dec feat(server): 新增文档智能识别服务,扩展OCR接口支持 Azure Document Intelligence 2026-05-14 09:32:15 +00:00
caoxiaozhu
8adeefe4a9 feat(web): 更新TableEmptyState空状态组件和RequestsView脚本,增强表格空状态展示和请求列表逻辑 2026-05-14 07:16:22 +00:00
caoxiaozhu
7df62edb78 style(web): 更新审批中心、审计、员工管理、请求列表页面的样式文件,增加内边距和调整布局间距 2026-05-14 07:16:35 +00:00
caoxiaozhu
4a72b977ba feat(web): update travel request and reimbursement views 2026-05-14 07:10:46 +00:00
caoxiaozhu
476d5fdf93 feat(web): update TopBar component and useAppShell composable 2026-05-14 07:10:31 +00:00
caoxiaozhu
64ea1bc5fd style(web): update travel views styles 2026-05-14 07:10:20 +00:00
caoxiaozhu
f53c343cd3 feat(web): update views
- AppShellRouteView.vue: update app shell route view
- PoliciesView.vue: update policies view
- TravelReimbursementCreateView.vue: update travel reimbursement create view
2026-05-14 03:15:22 +00:00
caoxiaozhu
f65ddb52f9 feat(web): update useAppShell composable
- useAppShell.js: update app shell composable
2026-05-14 03:15:04 +00:00
caoxiaozhu
12da82409e feat(web): update SidebarRail component
- SidebarRail.vue: update sidebar rail component
2026-05-14 03:14:15 +00:00
caoxiaozhu
87b6a6c21d style(web): update app and policies styles
- app.css: update app-level styles
- policies-view.css: update policies view styles
2026-05-14 03:12:15 +00:00
caoxiaozhu
00b72c3d43 style(web): update app styles and AppShellRouteView
- app.css: update app-level styles
- AppShellRouteView.vue: update app shell route view
2026-05-14 03:00:56 +00:00
caoxiaozhu
b9cb6d9253 feat(web): update composables and utils
- useAppShell.js: update app shell composable
- useNavigation.js: update navigation composable
- utils/accessControl.js: update access control utility
2026-05-14 02:59:54 +00:00
caoxiaozhu
736cc6b52b feat(web): update employee management view
- EmployeeManagementView.vue: update employee management view component
- scripts/EmployeeManagementView.js: update employee management view logic
2026-05-14 02:58:55 +00:00
caoxiaozhu
feacf2765d refactor(web): update service clients
- services/agentAssets.js: update agent assets service client
- services/api.js: update API service client
- services/employees.js: update employees service client
2026-05-14 02:58:35 +00:00
caoxiaozhu
3c28cab288 test(backend): update auth and employee service tests
- tests/test_auth_service.py: update auth service tests
- tests/test_employee_service.py: update employee service tests
2026-05-14 02:57:00 +00:00
caoxiaozhu
53c060de97 refactor(backend): update employees endpoint and service
- endpoints/employees.py: update employees API endpoint
- services/employee.py: update employee service
2026-05-14 02:55:58 +00:00
caoxiaozhu
c0401dbd0d refactor(web): update API service, view script and add tests
Frontend:
- services/api.js: update API service client
- views/scripts/EmployeeManagementView.js: update employee management view script
- tests/api-request.test.mjs: update API request tests

Scripts:
- create_employee.sh: add employee creation script
2026-05-14 02:25:15 +00:00
caoxiaozhu
3965c1ec42 test(backend): update employee service tests
- tests/test_employee_service.py: update employee service tests
2026-05-14 02:23:33 +00:00
caoxiaozhu
1b371ad7bb refactor(backend): update employee schema
- schemas/employee.py: update employee data schemas
2026-05-14 02:21:45 +00:00
caoxiaozhu
89f1bd613d test(backend): update orchestrator service tests
- tests/test_orchestrator_service.py: update orchestrator service tests
2026-05-13 15:43:56 +00:00
caoxiaozhu
98792c7584 refactor(backend): update expense claims service and tests
- services/expense_claims.py: update expense claims service
- tests/test_expense_claim_service.py: update expense claim service tests
2026-05-13 15:42:25 +00:00
caoxiaozhu
edb484e2f6 refactor: update orchestrator service and travel form view
- services/orchestrator.py: update orchestrator service
- views/TravelReimbursementCreateView.vue: update travel form view
- views/scripts/TravelReimbursementCreateView.js: update travel form script
2026-05-13 15:40:41 +00:00
caoxiaozhu
f804a23239 feat(web): update useRequests composable
- useRequests.js: update requests composable
2026-05-13 15:38:59 +00:00
caoxiaozhu
ada0eb40ca feat(web): update TopBar component
- TopBar.vue: update top bar component
2026-05-13 15:37:32 +00:00
caoxiaozhu
63c94da216 style(web): update travel reimbursement create view styles
- travel-reimbursement-create-view.css: update travel form styles
2026-05-13 15:35:53 +00:00
caoxiaozhu
68a3907920 refactor(backend): update expense claims service and tests
- services/expense_claims.py: update expense claims service
- tests/test_orchestrator_service.py: update orchestrator service tests
2026-05-13 15:33:35 +00:00
caoxiaozhu
44b2838a12 refactor(backend): update service layers
- services/agent_conversations.py: update agent conversations service
- services/agent_foundation.py: update agent foundation service
- services/orchestrator.py: update orchestrator service
- services/user_agent.py: update user agent service
2026-05-13 15:31:04 +00:00
caoxiaozhu
de51ed2e9f chore(backend): update config and user agent schema
- core/config.py: update application configuration
- schemas/user_agent.py: update user agent data schemas
2026-05-13 15:29:25 +00:00
caoxiaozhu
14379c4e59 style(web): update travel reimbursement create view styles
- travel-reimbursement-create-view.css: update travel form styles
2026-05-13 13:20:24 +00:00
caoxiaozhu
bc7aff8858 refactor(backend): update user agent service and tests
- services/user_agent.py: update user agent service
- tests/test_orchestrator_service.py: update orchestrator tests
2026-05-13 13:18:05 +00:00
caoxiaozhu
da7684a6bb refactor(web): update view scripts
- TravelReimbursementCreateView.js: update travel form script
- TravelRequestDetailView.js: update travel detail script
2026-05-13 13:16:11 +00:00
caoxiaozhu
c6a599fcec feat(web): update views
- AppShellRouteView.vue: update app shell route view
- TravelReimbursementCreateView.vue: update travel form view
- TravelRequestDetailView.vue: update travel request detail view
2026-05-13 13:14:48 +00:00
caoxiaozhu
778d203443 feat(web): update composable and service
- useNavigation.js: update navigation composable
- services/orchestrator.js: update orchestrator service client
2026-05-13 13:14:17 +00:00
caoxiaozhu
eec4efe207 feat(web): update components
- PersonalWorkbench.vue: update personal workbench component
- SidebarRail.vue: update sidebar rail component
- TopBar.vue: update top bar component
- ConfirmDialog.vue: update confirm dialog component
2026-05-13 13:12:28 +00:00
caoxiaozhu
97b0851e26 style(web): update travel request styles
- travel-reimbursement-create-view.css: update travel form styles
- travel-request-detail-view.css: update travel detail styles
2026-05-13 13:10:25 +00:00
caoxiaozhu
a6526c5159 test(backend): update orchestrator service tests
- test_orchestrator_service.py: update orchestrator service tests
2026-05-13 13:08:22 +00:00
caoxiaozhu
70cff69b7f refactor(backend): update orchestrator endpoint and services
- endpoints/orchestrator.py: update orchestrator API endpoint
- services/agent_conversations.py: update agent conversations service
- services/orchestrator.py: update orchestrator service
- services/user_agent.py: update user agent service
2026-05-13 13:06:52 +00:00
caoxiaozhu
0f7bd43ce3 test(web): update travel request detail tests and styles
- test_reimbursement_endpoints.py: update reimbursement endpoint tests
- travel-request-detail-view.css: update travel request detail styles
- TravelRequestDetailView.vue: update travel request detail view
2026-05-13 06:56:30 +00:00
caoxiaozhu
755f935c9d refactor(web): update composables, utils and scripts
- useRequests.js: update requests composable
- utils/requestViewModel.js: update request view model utility
- scripts/TravelRequestDetailView.js: update travel detail view script
2026-05-13 06:55:23 +00:00
caoxiaozhu
999872a060 refactor(backend): update financial record model, schema and expense claims
- models/financial_record.py: update financial record model
- schemas/reimbursement.py: update reimbursement schema
- services/expense_claims.py: update expense claims service
2026-05-13 06:54:27 +00:00
caoxiaozhu
9459476d82 feat(web): add TableEmptyState component
- components/shared/TableEmptyState.vue: add shared empty state component
2026-05-13 06:53:02 +00:00
caoxiaozhu
151787ada2 refactor(web): update view scripts
- AuditView.js: update audit view logic
- EmployeeManagementView.js: update employee management logic
- RequestsView.js: update requests view logic
- TravelRequestDetailView.js: update travel detail view logic
2026-05-13 06:52:30 +00:00
caoxiaozhu
fcaed5b2ec feat(web): update views
- AuditView.vue: update audit view
- EmployeeManagementView.vue: update employee management view
- RequestsView.vue: update requests view
- TravelRequestDetailView.vue: update travel request detail view
2026-05-13 06:51:12 +00:00
caoxiaozhu
b637a2bf08 refactor(web): update reimbursements service
- services/reimbursements.js: update reimbursement API service
2026-05-13 06:49:58 +00:00
caoxiaozhu
6c8947f40f feat(web): update composables
- useAppShell.js: update app shell composable
- useRequests.js: update requests composable
2026-05-13 06:48:27 +00:00
caoxiaozhu
8bc919386b style(web): update travel request detail view styles
- travel-request-detail-view.css: update travel request detail styles
2026-05-13 06:47:46 +00:00
caoxiaozhu
13df8fc9dc test(backend): update and add service tests
Updated tests:
- test_ontology_service.py: update ontology service tests

New tests:
- test_expense_claim_service.py: add expense claim service tests
- test_reimbursement_endpoints.py: add reimbursement endpoint tests
2026-05-13 06:46:24 +00:00
caoxiaozhu
6317fc0ccd refactor(backend): update reimbursement and related services
- endpoints/reimbursements.py: update reimbursement API endpoint
- schemas/reimbursement.py: update reimbursement data schemas
- services/expense_claims.py: update expense claims service
- services/ontology.py: update ontology service
- services/user_agent.py: update user agent service
2026-05-13 06:45:04 +00:00
caoxiaozhu
4db5e8ec16 refactor(backend): update services and tests
- services/expense_claims.py: update expense claims service
- services/user_agent.py: update user agent service
- tests/test_orchestrator_service.py: update orchestrator service tests
- tests/test_user_agent_service.py: update user agent service tests
2026-05-13 03:39:41 +00:00
caoxiaozhu
fae9966a11 feat(web): add reimbursements service and ConfirmDialog component
- services/reimbursements.js: add reimbursement API service client
- components/shared/ConfirmDialog.vue: add shared confirm dialog component
2026-05-13 03:37:47 +00:00
caoxiaozhu
46644d429f refactor(web): update view scripts
- AuditView.js: update audit view logic
- EmployeeManagementView.js: update employee management logic
- PoliciesView.js: update policies view logic
- RequestsView.js: update requests view logic
- TravelReimbursementCreateView.js: update travel form logic
- TravelRequestDetailView.js: update travel detail view logic
2026-05-13 03:35:44 +00:00
caoxiaozhu
8b72f4e962 feat(web): update views
- AppShellRouteView.vue: update app shell route view
- AuditView.vue: update audit view
- EmployeeManagementView.vue: update employee management view
- PoliciesView.vue: update policies view
- RequestsView.vue: update requests view
- TravelReimbursementCreateView.vue: update travel form view
- TravelRequestDetailView.vue: update travel detail view
2026-05-13 03:33:11 +00:00
caoxiaozhu
473198c669 refactor(web): update data and utils
- data/requests.js: update requests data
- utils/requestViewModel.js: update request view model utility
2026-05-13 03:31:07 +00:00
caoxiaozhu
2bf133d232 feat(web): update composables
- useAppShell.js: update app shell composable
- useNavigation.js: update navigation composable
- useRequests.js: update requests composable
2026-05-13 03:29:10 +00:00
caoxiaozhu
478c2a0e09 feat(web): update layout and business components
- PersonalWorkbench.vue: update personal workbench component
- SidebarRail.vue: update sidebar rail component
- TopBar.vue: update top bar component
2026-05-13 03:27:30 +00:00
caoxiaozhu
99231f30f1 style(web): update view styles
- audit-view.css: update audit view styles
- requests-view.css: update requests view styles
- travel-reimbursement-create-view.css: update travel form styles
- travel-request-detail-view.css: update travel detail view styles
2026-05-13 03:24:49 +00:00
caoxiaozhu
cea8239370 refactor(backend): update reimbursement and expense claims
- endpoints/reimbursements.py: update reimbursement API endpoint
- schemas/reimbursement.py: update reimbursement data schemas
- services/expense_claims.py: update expense claims service logic
2026-05-13 03:22:52 +00:00
caoxiaozhu
6147b690b2 style(web): update view styles
- overview-view.css: update overview view styles
- travel-reimbursement-create-view.css: update travel form styles
2026-05-12 15:39:55 +00:00
caoxiaozhu
ce04f5aa86 feat(web): update Vue components
- App.vue: update app root component
- PersonalWorkbench.vue: update personal workbench component
2026-05-12 15:39:00 +00:00
caoxiaozhu
024999626c style(web): update app and global styles
- app.css: update app-level styles
- global.css: update global styles
2026-05-12 15:38:16 +00:00
caoxiaozhu
5c3786f179 assets(web): update image assets
- web/public/assets/header.png: update header avatar image
- web/public/assets/person.png: update person avatar image
- web/UI/对话界面.png: add chat interface image
2026-05-12 15:17:20 +00:00
caoxiaozhu
8246c0d4dd refactor(web): update travel reimbursement view script
- scripts/TravelReimbursementCreateView.js: update travel form view logic
2026-05-12 15:16:18 +00:00
caoxiaozhu
13d0cab86e feat(web): update Vue components
- App.vue: update app root component
- SidebarRail.vue: update sidebar rail component
- TravelReimbursementCreateView.vue: update travel form view
2026-05-12 15:15:43 +00:00
caoxiaozhu
a6720942fb style(web): update styles
- app.css: update app-level styles
- global.css: update global styles
- backend-unavailable-view.css: update backend unavailable view styles
- login-view.css: update login view styles
- setup-view.css: update setup view styles
- travel-reimbursement-create-view.css: update travel form styles
2026-05-12 15:14:34 +00:00
caoxiaozhu
bc593d4473 test(backend): update service tests
- test_ontology_service.py: update ontology service tests
- test_orchestrator_service.py: update orchestrator service tests
- test_user_agent_service.py: update user agent service tests
2026-05-12 15:13:57 +00:00
caoxiaozhu
4d9b071e37 refactor(backend): update service layers
- services/ontology.py: update ontology service logic
- services/user_agent.py: update user agent service logic
2026-05-12 15:12:57 +00:00
caoxiaozhu
6137bb5cdc docs(agent-week-plan): update weekly plan documents
- MASTER_TODO.md: update master todo list
- day_3_semantic_ontology_mvp.md: update semantic ontology tasks
- day_5_user_agent_mvp.md: update user agent tasks
2026-05-12 15:12:32 +00:00
caoxiaozhu
2e1b0645c0 refactor(web): refactor avatar assets to use PNG instead of inline SVG
- TravelReimbursementCreateView.js: change avatar imports to use PNG paths
- web/public/assets: add PNG avatar assets (header.png, person.png)
2026-05-12 07:25:45 +00:00
caoxiaozhu
b1e67cc99b feat(web): add SVG assets and update travel reimbursement script
- web/src/assets/header.svg: add header SVG asset
- web/src/assets/person.svg: add person SVG asset
- scripts/TravelReimbursementCreateView.js: update travel form logic
2026-05-12 07:23:29 +00:00
caoxiaozhu
4d748bcdeb feat(web): update travel reimbursement view
- travel-reimbursement-create-view.css: update form styles
- TravelReimbursementCreateView.vue: update view component
- scripts/TravelReimbursementCreateView.js: update view logic
2026-05-12 07:22:11 +00:00
caoxiaozhu
bff20d8eb3 test(backend): update service tests
- test_orchestrator_service.py: update orchestrator service tests
- test_user_agent_service.py: update user agent service tests
2026-05-12 07:20:59 +00:00
caoxiaozhu
a3d40ad9f5 refactor(backend): update service layers
- services/ontology.py: update ontology service logic
- services/orchestrator.py: update orchestrator service logic
- services/user_agent.py: update user agent service logic
2026-05-12 07:19:21 +00:00
caoxiaozhu
3ad16405a1 docs: update agent plan and week plan documents
- agent plan/04_orchestrator_and_runtime_flow.md: update orchestrator runtime flow docs
- agent week plan/day_3_semantic_ontology_mvp.md: update semantic ontology tasks
- agent week plan/day_4_orchestrator_runtime.md: update orchestrator runtime tasks
- agent week plan/day_5_user_agent_mvp.md: update user agent tasks
2026-05-12 07:18:01 +00:00
caoxiaozhu
df450cf79f refactor(server): enhance expense claims resolution logic
- expense_claims.py: add review_form_values fallback for expense type, employee name resolution
2026-05-12 06:42:50 +00:00
caoxiaozhu
4592ba3bb2 refactor(server): update expense claims service
- services/expense_claims.py: update expense claims business logic
2026-05-12 06:41:55 +00:00
caoxiaozhu
5a66e98fc8 refactor(backend): update user_agent schema and service
- schemas/user_agent.py: update user agent data schemas
- services/user_agent.py: update user agent service logic
2026-05-12 06:40:59 +00:00
caoxiaozhu
c263fc9752 feat(web): update views and services
Views:
- AppShellRouteView.vue: update route view
- SettingsView.vue: update settings view
- TravelReimbursementCreateView.vue: update travel form view
- scripts/SettingsView.js: update settings view logic
- scripts/TravelReimbursementCreateView.js: update travel form logic

Services:
- services/orchestrator.js: update orchestrator service client
2026-05-12 06:40:19 +00:00
caoxiaozhu
f6a5eeb620 feat(web): update components and composables
- PersonalWorkbench.vue: update personal workbench component
- useAppShell.js: update app shell composable
2026-05-12 06:39:26 +00:00
caoxiaozhu
c2f208da31 style(web): update view styles
- settings-view.css: update settings view styling
- travel-reimbursement-create-view.css: update travel reimbursement form styles
2026-05-12 06:38:45 +00:00
caoxiaozhu
e53c0aa5d1 test(backend): update service tests
- test_orchestrator_service.py: update orchestrator service tests
- test_settings_persistence.py: update settings persistence tests
- test_user_agent_service.py: update user agent service tests
2026-05-12 06:37:59 +00:00
caoxiaozhu
e416818ae2 feat(backend): update orchestrator endpoint
- endpoints/orchestrator.py: update orchestrator API endpoint with new features
2026-05-12 06:36:58 +00:00
caoxiaozhu
01df3452fd refactor(backend): update and add service layers
- services/ontology.py: update ontology service
- services/orchestrator.py: update orchestrator service
- services/user_agent.py: update user agent service
- services/settings.py: update settings service
- services/expense_claims.py: update expense claims service
- services/agent_conversations.py: add new agent conversations service
2026-05-12 06:36:09 +00:00
caoxiaozhu
a6a28ba865 refactor(backend): update data schemas
- schemas/orchestrator.py: update orchestrator schemas
- schemas/settings.py: update settings schemas
- schemas/user_agent.py: update user agent schemas
2026-05-12 06:35:17 +00:00
caoxiaozhu
665a744a43 feat(backend): update database models and add agent_conversation
- base.py: update database base configuration
- models/__init__.py: update models exports
- models/system_setting.py: update system setting model
- models/agent_conversation.py: add new agent conversation model
2026-05-12 06:34:36 +00:00
caoxiaozhu
8227e0ab5c docs(agent-week-plan): update weekly plan documents
- MASTER_TODO.md: update master todo list
- day_3_semantic_ontology_mvp.md: update semantic ontology tasks
- day_4_orchestrator_runtime.md: update orchestrator runtime tasks
- day_5_user_agent_mvp.md: update user agent tasks
2026-05-12 06:33:55 +00:00
caoxiaozhu
d81ac10894 chore(server): update server start script dependencies check
- server_start.sh: add multipart to dependencies check for file upload support
2026-05-12 03:07:09 +00:00
caoxiaozhu
035be110b6 feat(web): add OCR service and update travel reimbursement view
- web/src/services/ocr.js: add OCR service API client
- web/src/views/scripts/TravelReimbursementCreateView.js: update travel form script with OCR integration
2026-05-12 03:05:51 +00:00
caoxiaozhu
a3f3421ebc test(server): add OCR endpoint and service tests
New tests:
- server/tests/test_ocr_endpoints.py: OCR API endpoint tests
- server/tests/test_ocr_service.py: OCR service unit tests

Updated tests:
- server/tests/test_openapi_schema.py: update OpenAPI schema tests
- server/tests/test_orchestrator_service.py: update orchestrator service tests
2026-05-12 03:05:05 +00:00
caoxiaozhu
fb23a6976a feat(server): add OCR invoice processing functionality
New endpoints:
- server/src/app/api/v1/endpoints/ocr.py: OCR API endpoints for invoice scanning

New schemas:
- server/src/app/schemas/ocr.py: OCR request/response data schemas

New services:
- server/src/app/services/ocr.py: OCR processing business logic
- server/src/app/services/expense_claims.py: expense claims management service

Scripts:
- server/scripts/bootstrap_paddleocr_mobile.sh: PaddleOCR mobile setup script
- server/scripts/paddle_ocr_worker.py: PaddleOCR worker process
2026-05-12 03:04:10 +00:00
caoxiaozhu
ca29025063 refactor(backend): update services and register OCR router
- router.py: register ocr_router with OCR tag
- ontology.py: update ontology service logic
- orchestrator.py: update orchestrator service logic
- user_agent.py: update user agent schema and service
- schemas/user_agent.py: update user agent data schemas
2026-05-12 03:03:15 +00:00
caoxiaozhu
33826929ba chore(server): update backend configuration files
- pyproject.toml: update project dependencies and metadata
- config.py: update application configuration settings
- openapi.py: update OpenAPI documentation
2026-05-12 03:01:24 +00:00
caoxiaozhu
4792b5074f docs(agent-week-plan): update master todo and daily plan docs
- MASTER_TODO.md: update master todo list for current sprint
- day_3_semantic_ontology_mvp.md: update semantic ontology tasks
- day_4_orchestrator_runtime.md: update orchestrator runtime tasks
- day_5_user_agent_mvp.md: update user agent tasks

chore: add server/.venv-ocr312 to .gitignore
2026-05-12 03:00:54 +00:00
caoxiaozhu
434944abc3 feat(web): add ontology and orchestrator service modules
- web/src/services/ontology.js: ontology service API client
- web/src/services/orchestrator.js: orchestrator service API client
2026-05-12 01:28:38 +00:00
caoxiaozhu
93b1a5e746 feat(web): update Vue components and composables
- PersonalWorkbench.vue: update personal workbench component
- useAppShell.js: update app shell composable
- useChat.js: update chat composable with new features
- AppShellRouteView.vue: update route view
- ChatView.vue: update chat view with enhanced UI
- TravelReimbursementCreateView.vue: update travel reimbursement form
- ChatView.js: update chat view script logic
- TravelReimbursementCreateView.js: update travel form script logic
2026-05-12 01:27:49 +00:00
caoxiaozhu
e3548dfaba style(web): update chat and travel reimbursement styles
- chat-view.css: update chat view styling
- travel-reimbursement-create-view.css: update travel reimbursement form styles
2026-05-12 01:26:56 +00:00
caoxiaozhu
441e27145d refactor(backend): register ontology and orchestrator routers
- router.py: import and register ontology_router and orchestrator_router
- openapi.py: add ontology and orchestrator tags to OpenAPI documentation
- agent_runs.py: extend agent runs service with additional functionality
- test_openapi_schema.py: update OpenAPI schema tests
2026-05-12 01:26:13 +00:00
caoxiaozhu
22d47cbf2b feat(backend): add ontology and orchestrator API endpoints
New endpoints:
- server/src/app/api/v1/endpoints/ontology.py: ontology API
- server/src/app/api/v1/endpoints/orchestrator.py: orchestrator API

New schemas:
- server/src/app/schemas/ontology.py: ontology data schemas
- server/src/app/schemas/orchestrator.py: orchestrator data schemas
- server/src/app/schemas/user_agent.py: user agent data schemas

New services:
- server/src/app/services/ontology.py: ontology business logic
- server/src/app/services/orchestrator.py: orchestrator business logic
- server/src/app/services/runtime_chat.py: runtime chat service
- server/src/app/services/user_agent.py: user agent service

New tests:
- server/tests/test_ontology_service.py
- server/tests/test_orchestrator_service.py
- server/tests/test_user_agent_service.py
2026-05-12 01:24:39 +00:00
caoxiaozhu
19da459bb3 docs(agent-week-plan-html): update HTML plan documents
- Update day-1.html to day-7.html: update daily plan content
- Update index.html: refresh week plan index page
2026-05-12 01:23:33 +00:00
caoxiaozhu
ba28627f11 docs(agent-week-plan): update weekly execution plan documents
- Update 00_README.md: refresh week plan overview and structure
- Update MASTER_TODO.md: update master todo list for new week
- Update day_1_foundation_models.md: expand foundation models tasks
- Update day_2_rule_center_integration.md: add rule center integration tasks
- Update day_3_semantic_ontology_mvp.md: add semantic ontology tasks
- Update day_4_orchestrator_runtime.md: add orchestrator runtime tasks
- Update day_5_user_agent_mvp.md: add user agent tasks
- Update day_6_hermes_mvp.md: add hermes agent tasks
- Update day_7_hardening_demo_acceptance.md: add hardening tasks
2026-05-12 01:22:38 +00:00
caoxiaozhu
9b88ee2901 docs(agent-plan): update architecture docs and remove weekly_execution_details
- Update 00_README.md: refresh architecture overview
- Update 02_semantic_ontology.md: expand semantic layer design
- Update 04_orchestrator_and_runtime_flow.md: add runtime flow details
- Update 05_development_roadmap.md: refine milestone timeline
- Update 06_data_contracts_and_governance.md: add contract specifications
- Update 10_evaluation_and_testset.md: add evaluation framework
- Update 11_ocr_invoice_architecture.md: enhance OCR architecture
- Update 14_financial_document_canonical_model.md: complete model design
- Remove weekly_execution_details/: deprecated in favor of agent week plan
2026-05-12 01:20:53 +00:00
caoxiaozhu
0b63be2d39 style(audit): simplify asset list interactions 2026-05-11 06:33:46 +00:00
caoxiaozhu
83286712e5 docs(agent-plan): record day 2 rule center completion 2026-05-11 06:32:49 +00:00
caoxiaozhu
e9eeb2e41d feat(audit): connect rule center to live asset APIs 2026-05-11 06:32:38 +00:00
caoxiaozhu
9b39df6277 chore(skill): add split commit and push workflow 2026-05-11 06:31:08 +00:00
678 changed files with 174536 additions and 14700 deletions

View File

@@ -0,0 +1,60 @@
---
name: split-commit-and-push
description: Use when the user asks to commit or push code and wants the changes split into logical git commits with clear commit messages, verification before commit, and a final push to the remote branch.
---
# Split Commit And Push
## Overview
This skill standardizes git delivery work when the user wants commits and a push, especially when the workspace contains multiple logical change groups. It helps Codex separate unrelated edits, write high-signal commit messages, verify the tree before each commit, and push only after the requested changes are complete.
## When To Use
- The user explicitly asks to `commit`, `push`, `提交代码`, `分批提交`, or asks for better commit descriptions.
- The workspace contains multiple change groups that should not be collapsed into one commit.
- The task needs a final remote push after verification.
## Workflow
1. Inspect `git status --short` and `git diff` to identify logical change groups.
2. Separate changes by user-visible outcome or engineering boundary. Do not mix unrelated docs, refactors, and UI tweaks in one commit unless they are inseparable.
3. Verify each change group before committing. Prefer targeted build/test commands that are cheap and relevant.
4. Stage only the files for the current group.
5. Write a commit message that states scope and result clearly.
6. Repeat for remaining groups.
7. Confirm branch and remote, then push the current branch to the requested remote.
## Commit Rules
- One commit per logical modification point.
- Do not include unrelated files just to keep the tree clean.
- Prefer non-interactive git commands.
- Do not rewrite history unless the user explicitly asks.
- If the same file spans multiple logical changes, split carefully instead of collapsing the work into one generic commit.
- Before pushing, make sure the working tree reflects the intended post-push state.
## Commit Message Rules
- Prefer concise prefixes such as `feat`, `fix`, `docs`, `refactor`, `style`, or `chore`.
- Mention the subsystem or page when useful, for example `feat(audit): connect rule center to asset APIs`.
- The subject should describe the delivered outcome, not just the files changed.
- When multiple commits are requested, ensure adjacent commit subjects are distinguishable.
## Verification Rules
- Run the smallest relevant verification for each batch.
- Report verification honestly in the final response.
- If push fails, report the exact blocker and leave the local commits intact.
## Example Outputs
- `feat(audit): connect rule center to live asset APIs`
- `docs(agent-plan): mark day 2 rule center integration complete`
- `style(audit): simplify list table interactions`
## Final Response Checklist
- List the commits in order.
- State the verification that was run.
- State whether push succeeded and which branch was pushed.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Split Commit and Push"
short_description: "Help split git changes into clear commits"
default_prompt: "Use $split-commit-and-push to group the current changes into logical commits and push the branch."

1
.env
View File

@@ -30,6 +30,7 @@ ONLYOFFICE_ENABLED=true
ONLYOFFICE_PUBLIC_URL=http://10.10.10.122:8082
ONLYOFFICE_BACKEND_URL=http://main:8000
ONLYOFFICE_JWT_SECRET=change-me-onlyoffice
HERMES_AGENT_SHARED_TOKEN=change-me-hermes
POSTGRES_HOST=10.10.10.189
POSTGRES_PORT=5432

View File

@@ -30,6 +30,7 @@ ONLYOFFICE_ENABLED=false
ONLYOFFICE_PUBLIC_URL=http://127.0.0.1:8082
ONLYOFFICE_BACKEND_URL=http://127.0.0.1:8000
ONLYOFFICE_JWT_SECRET=change-me-onlyoffice
HERMES_AGENT_SHARED_TOKEN=change-me-hermes
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432

1
.gitignore vendored
View File

@@ -14,4 +14,5 @@ Thumbs.db
__pycache__/
*.pyc
server/.venv/
server/.venv-ocr312
server/.secrets/

View File

@@ -0,0 +1,22 @@
from typing import TYPE_CHECKING, Any
from ._version import __version__ as __version__
__all__ = ["LightRAG", "QueryParam", "__version__"]
if TYPE_CHECKING:
from .lightrag import LightRAG as LightRAG, QueryParam as QueryParam
def __getattr__(name: str) -> Any:
if name in {"LightRAG", "QueryParam"}:
from .lightrag import LightRAG, QueryParam
value = {"LightRAG": LightRAG, "QueryParam": QueryParam}[name]
globals()[name] = value
return value
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__author__ = "Zirui Guo"
__url__ = "https://github.com/HKUDS/LightRAG"

View File

@@ -0,0 +1,4 @@
"""Lightweight version definitions shared by packaging and runtime code."""
__version__ = "1.4.16"
__api_version__ = "0291"

View File

@@ -0,0 +1 @@
from .._version import __api_version__ as __api_version__

View File

@@ -0,0 +1,163 @@
from datetime import datetime, timedelta, timezone
import jwt
from dotenv import load_dotenv
from fastapi import HTTPException, status
from pydantic import BaseModel
from ..utils import logger
from .config import DEFAULT_TOKEN_SECRET, global_args
from .passwords import verify_password
# use the .env that is inside the current folder
# allows to use different .env file for each lightrag instance
# the OS environment variables take precedence over the .env file
load_dotenv(dotenv_path=".env", override=False)
class TokenPayload(BaseModel):
sub: str # Username
exp: datetime # Expiration time
role: str = "user" # User role, default is regular user
metadata: dict = {} # Additional metadata
class AuthHandler:
def __init__(self):
auth_accounts = global_args.auth_accounts
self.secret = global_args.token_secret
if not self.secret:
if auth_accounts:
raise ValueError(
"TOKEN_SECRET must be explicitly set to a non-default value when AUTH_ACCOUNTS is configured."
)
self.secret = DEFAULT_TOKEN_SECRET
logger.warning(
"TOKEN_SECRET not set and AUTH_ACCOUNTS is not configured. "
"Falling back to the default guest-mode JWT secret. "
)
algorithm = global_args.jwt_algorithm
if not algorithm or algorithm.lower() == "none":
raise ValueError(
"JWT_ALGORITHM must be set to a secure algorithm (e.g. HS256). "
"The 'none' algorithm is not permitted."
)
self.algorithm = algorithm
self.expire_hours = global_args.token_expire_hours
self.guest_expire_hours = global_args.guest_token_expire_hours
self.accounts = {}
invalid_accounts = []
if auth_accounts:
for account in auth_accounts.split(","):
try:
username, password = account.split(":", 1)
if not username or not password:
raise ValueError
self.accounts[username] = password
except ValueError:
invalid_accounts.append(account)
if invalid_accounts:
invalid_entries = ", ".join(invalid_accounts)
logger.error(f"Invalid account format in AUTH_ACCOUNTS: {invalid_entries}")
raise ValueError(
"AUTH_ACCOUNTS must use comma-separated user:password pairs."
)
def verify_password(self, username: str, plain_password: str) -> bool:
"""
Verify password for a user. Supports explicit bcrypt values and plaintext.
Args:
username: Username to verify
plain_password: Plaintext password to check
Returns:
bool: True if password is correct, False otherwise
"""
if username not in self.accounts:
return False
stored_password = self.accounts[username]
return verify_password(plain_password, stored_password)
def create_token(
self,
username: str,
role: str = "user",
custom_expire_hours: int = None,
metadata: dict = None,
) -> str:
"""
Create JWT token
Args:
username: Username
role: User role, default is "user", guest is "guest"
custom_expire_hours: Custom expiration time (hours), if None use default value
metadata: Additional metadata
Returns:
str: Encoded JWT token
"""
# Choose default expiration time based on role
if custom_expire_hours is None:
if role == "guest":
expire_hours = self.guest_expire_hours
else:
expire_hours = self.expire_hours
else:
expire_hours = custom_expire_hours
expire = datetime.now(timezone.utc) + timedelta(hours=expire_hours)
# Create payload
payload = TokenPayload(
sub=username, exp=expire, role=role, metadata=metadata or {}
)
return jwt.encode(payload.model_dump(), self.secret, algorithm=self.algorithm)
def validate_token(self, token: str) -> dict:
"""
Validate JWT token
Args:
token: JWT token
Returns:
dict: Dictionary containing user information
Raises:
HTTPException: If token is invalid or expired
"""
try:
# Explicitly exclude 'none' to prevent algorithm confusion attacks
allowed_algorithms = [self.algorithm]
if "none" in (a.lower() for a in allowed_algorithms):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Insecure JWT algorithm configuration",
)
payload = jwt.decode(token, self.secret, algorithms=allowed_algorithms)
expire_timestamp = payload["exp"]
expire_time = datetime.fromtimestamp(expire_timestamp, timezone.utc)
if datetime.now(timezone.utc) > expire_time:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
)
# Return complete payload instead of just username
return {
"username": payload["sub"],
"role": payload.get("role", "user"),
"metadata": payload.get("metadata", {}),
"exp": expire_time,
}
except jwt.PyJWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
)
auth_handler = AuthHandler()

View File

@@ -0,0 +1,697 @@
"""
Configs for the LightRAG API.
"""
import os
import re
import argparse
import logging
from dotenv import load_dotenv
from lightrag.utils import get_env_value, logger
from lightrag.llm.binding_options import (
GeminiEmbeddingOptions,
GeminiLLMOptions,
OllamaEmbeddingOptions,
OllamaLLMOptions,
OpenAILLMOptions,
)
from lightrag.base import OllamaServerInfos
import sys
from lightrag.constants import (
DEFAULT_WOKERS,
DEFAULT_TIMEOUT,
DEFAULT_TOP_K,
DEFAULT_CHUNK_TOP_K,
DEFAULT_HISTORY_TURNS,
DEFAULT_MAX_ENTITY_TOKENS,
DEFAULT_MAX_RELATION_TOKENS,
DEFAULT_MAX_TOTAL_TOKENS,
DEFAULT_COSINE_THRESHOLD,
DEFAULT_RELATED_CHUNK_NUMBER,
DEFAULT_MIN_RERANK_SCORE,
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,
DEFAULT_MAX_ASYNC,
DEFAULT_SUMMARY_MAX_TOKENS,
DEFAULT_SUMMARY_LENGTH_RECOMMENDED,
DEFAULT_SUMMARY_CONTEXT_SIZE,
DEFAULT_SUMMARY_LANGUAGE,
DEFAULT_EMBEDDING_FUNC_MAX_ASYNC,
DEFAULT_EMBEDDING_BATCH_NUM,
DEFAULT_OLLAMA_MODEL_NAME,
DEFAULT_OLLAMA_MODEL_TAG,
DEFAULT_RERANK_BINDING,
DEFAULT_ENTITY_TYPES,
)
# use the .env that is inside the current folder
# allows to use different .env file for each lightrag instance
# the OS environment variables take precedence over the .env file
load_dotenv(dotenv_path=".env", override=False)
ollama_server_infos = OllamaServerInfos()
DEFAULT_TOKEN_SECRET = "lightrag-jwt-default-secret-key!"
NO_PREFIX_SENTINEL = "NO_PREFIX"
PROVIDER_ASYMMETRIC_EMBEDDING_BINDINGS = {"gemini", "jina", "voyageai"}
PREFIX_ASYMMETRIC_EMBEDDING_BINDINGS = {"azure_openai", "ollama", "openai"}
class DefaultRAGStorageConfig:
KV_STORAGE = "JsonKVStorage"
VECTOR_STORAGE = "NanoVectorDBStorage"
GRAPH_STORAGE = "NetworkXStorage"
DOC_STATUS_STORAGE = "JsonDocStatusStorage"
def get_default_host(binding_type: str) -> str:
default_hosts = {
"ollama": os.getenv("LLM_BINDING_HOST", "http://localhost:11434"),
"lollms": os.getenv("LLM_BINDING_HOST", "http://localhost:9600"),
"azure_openai": os.getenv("AZURE_OPENAI_ENDPOINT", "https://api.openai.com/v1"),
"openai": os.getenv("LLM_BINDING_HOST", "https://api.openai.com/v1"),
"gemini": os.getenv(
"LLM_BINDING_HOST", "https://generativelanguage.googleapis.com"
),
}
return default_hosts.get(
binding_type, os.getenv("LLM_BINDING_HOST", "http://localhost:11434")
) # fallback to ollama if unknown
def resolve_asymmetric_embedding_opt_in(
*,
binding: str,
embedding_asymmetric: bool,
embedding_asymmetric_configured: bool,
query_prefix: str | None,
document_prefix: str | None,
query_prefix_configured: bool = False,
document_prefix_configured: bool = False,
) -> bool:
"""Resolve whether query/document-aware embedding behavior should be enabled."""
has_non_empty_prefix = bool(query_prefix or document_prefix)
has_prefix_config = query_prefix_configured or document_prefix_configured
if not embedding_asymmetric:
if has_prefix_config:
state = "false" if embedding_asymmetric_configured else "unset"
logger.warning(
f"EMBEDDING_ASYMMETRIC is {state}; "
"EMBEDDING_QUERY_PREFIX and EMBEDDING_DOCUMENT_PREFIX will be ignored."
)
return False
if binding in PROVIDER_ASYMMETRIC_EMBEDDING_BINDINGS:
if has_prefix_config:
logger.warning(
f"{binding} embeddings use provider task parameters for asymmetric "
"mode; EMBEDDING_QUERY_PREFIX and EMBEDDING_DOCUMENT_PREFIX will be ignored."
)
return True
if binding in PREFIX_ASYMMETRIC_EMBEDDING_BINDINGS:
if not query_prefix_configured or not document_prefix_configured:
raise ValueError(
f"EMBEDDING_ASYMMETRIC=true for {binding} embeddings requires both "
"EMBEDDING_QUERY_PREFIX and EMBEDDING_DOCUMENT_PREFIX. Use "
f"{NO_PREFIX_SENTINEL} for a side that should intentionally have no prefix."
)
if not has_non_empty_prefix:
raise ValueError(
"At least one of EMBEDDING_QUERY_PREFIX or EMBEDDING_DOCUMENT_PREFIX "
f"must be non-empty. Use {NO_PREFIX_SENTINEL} only for the side that "
"should intentionally have no prefix."
)
return True
raise ValueError(
f"EMBEDDING_ASYMMETRIC=true is not supported for {binding} embeddings."
)
def get_embedding_prefix_config(env_key: str) -> tuple[str | None, bool]:
"""Read an embedding prefix and whether it was explicitly configured."""
if env_key not in os.environ:
return None, False
value = os.environ[env_key]
if value == "None":
return None, False
if value == NO_PREFIX_SENTINEL:
return "", True
if value == "":
raise ValueError(
f"{env_key} is empty. Use {NO_PREFIX_SENTINEL} to explicitly request "
"no prefix, or remove the variable to leave it unconfigured."
)
return value, True
def validate_auth_configuration(args: argparse.Namespace) -> None:
"""Reject insecure JWT auth settings before the API starts."""
auth_accounts = (getattr(args, "auth_accounts", "") or "").strip()
token_secret = (getattr(args, "token_secret", "") or "").strip()
if auth_accounts and (not token_secret or token_secret == DEFAULT_TOKEN_SECRET):
raise ValueError(
"TOKEN_SECRET must be explicitly set to a non-default value when AUTH_ACCOUNTS is configured."
)
def parse_args() -> argparse.Namespace:
"""
Parse command line arguments with environment variable fallback
Args:
is_uvicorn_mode: Whether running under uvicorn mode
Returns:
argparse.Namespace: Parsed arguments
"""
parser = argparse.ArgumentParser(description="LightRAG API Server")
# Server configuration
parser.add_argument(
"--host",
default=get_env_value("HOST", "0.0.0.0"),
help="Server host (default: from env or 0.0.0.0)",
)
parser.add_argument(
"--port",
type=int,
default=get_env_value("PORT", 9621, int),
help="Server port (default: from env or 9621)",
)
# Directory configuration
parser.add_argument(
"--working-dir",
default=get_env_value("WORKING_DIR", "./rag_storage"),
help="Working directory for RAG storage (default: from env or ./rag_storage)",
)
parser.add_argument(
"--input-dir",
default=get_env_value("INPUT_DIR", "./inputs"),
help="Directory containing input documents (default: from env or ./inputs)",
)
parser.add_argument(
"--timeout",
default=get_env_value("TIMEOUT", DEFAULT_TIMEOUT, int, special_none=True),
type=int,
help="Timeout in seconds (useful when using slow AI). Use None for infinite timeout",
)
# RAG configuration
parser.add_argument(
"--max-async",
type=int,
default=get_env_value("MAX_ASYNC", DEFAULT_MAX_ASYNC, int),
help=f"Maximum async operations (default: from env or {DEFAULT_MAX_ASYNC})",
)
parser.add_argument(
"--summary-max-tokens",
type=int,
default=get_env_value("SUMMARY_MAX_TOKENS", DEFAULT_SUMMARY_MAX_TOKENS, int),
help=f"Maximum token size for entity/relation summary(default: from env or {DEFAULT_SUMMARY_MAX_TOKENS})",
)
parser.add_argument(
"--summary-context-size",
type=int,
default=get_env_value(
"SUMMARY_CONTEXT_SIZE", DEFAULT_SUMMARY_CONTEXT_SIZE, int
),
help=f"LLM Summary Context size (default: from env or {DEFAULT_SUMMARY_CONTEXT_SIZE})",
)
parser.add_argument(
"--summary-length-recommended",
type=int,
default=get_env_value(
"SUMMARY_LENGTH_RECOMMENDED", DEFAULT_SUMMARY_LENGTH_RECOMMENDED, int
),
help=f"LLM Summary Context size (default: from env or {DEFAULT_SUMMARY_LENGTH_RECOMMENDED})",
)
# Logging configuration
parser.add_argument(
"--log-level",
default=get_env_value("LOG_LEVEL", "INFO"),
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Logging level (default: from env or INFO)",
)
parser.add_argument(
"--verbose",
action="store_true",
default=get_env_value("VERBOSE", False, bool),
help="Enable verbose debug output(only valid for DEBUG log-level)",
)
parser.add_argument(
"--key",
type=str,
default=get_env_value("LIGHTRAG_API_KEY", None),
help="API key for authentication. This protects lightrag server against unauthorized access",
)
# Optional https parameters
parser.add_argument(
"--ssl",
action="store_true",
default=get_env_value("SSL", False, bool),
help="Enable HTTPS (default: from env or False)",
)
parser.add_argument(
"--ssl-certfile",
default=get_env_value("SSL_CERTFILE", None),
help="Path to SSL certificate file (required if --ssl is enabled)",
)
parser.add_argument(
"--ssl-keyfile",
default=get_env_value("SSL_KEYFILE", None),
help="Path to SSL private key file (required if --ssl is enabled)",
)
# Ollama model configuration
parser.add_argument(
"--simulated-model-name",
type=str,
default=get_env_value("OLLAMA_EMULATING_MODEL_NAME", DEFAULT_OLLAMA_MODEL_NAME),
help="Name for the simulated Ollama model (default: from env or lightrag)",
)
parser.add_argument(
"--simulated-model-tag",
type=str,
default=get_env_value("OLLAMA_EMULATING_MODEL_TAG", DEFAULT_OLLAMA_MODEL_TAG),
help="Tag for the simulated Ollama model (default: from env or latest)",
)
# Namespace
parser.add_argument(
"--workspace",
type=str,
default=get_env_value("WORKSPACE", ""),
help="Default workspace for all storage",
)
# Server workers configuration
parser.add_argument(
"--workers",
type=int,
default=get_env_value("WORKERS", DEFAULT_WOKERS, int),
help="Number of worker processes (default: from env or 1)",
)
# LLM and embedding bindings
parser.add_argument(
"--llm-binding",
type=str,
default=get_env_value("LLM_BINDING", "ollama"),
choices=[
"lollms",
"ollama",
"openai",
"openai-ollama",
"azure_openai",
"aws_bedrock",
"gemini",
],
help="LLM binding type (default: from env or ollama)",
)
parser.add_argument(
"--embedding-binding",
type=str,
default=get_env_value("EMBEDDING_BINDING", "ollama"),
choices=[
"lollms",
"ollama",
"openai",
"azure_openai",
"aws_bedrock",
"jina",
"gemini",
"voyageai",
],
help="Embedding binding type (default: from env or ollama)",
)
parser.add_argument(
"--rerank-binding",
type=str,
default=get_env_value("RERANK_BINDING", DEFAULT_RERANK_BINDING),
choices=["null", "cohere", "jina", "aliyun"],
help=f"Rerank binding type (default: from env or {DEFAULT_RERANK_BINDING})",
)
# Document loading engine configuration
parser.add_argument(
"--docling",
action="store_true",
default=False,
help="Enable DOCLING document loading engine (default: from env or DEFAULT)",
)
# Conditionally add binding-specific options (Ollama, OpenAI, Azure OpenAI, Gemini)
# This registers command line arguments (e.g., --openai-llm-temperature)
# and reads corresponding environment variables (e.g., OPENAI_LLM_TEMPERATURE)
# Determine LLM binding value consistently from command line or environment
llm_binding_value = None
if "--llm-binding" in sys.argv:
try:
idx = sys.argv.index("--llm-binding")
if idx + 1 < len(sys.argv) and not sys.argv[idx + 1].startswith("-"):
llm_binding_value = sys.argv[idx + 1]
except IndexError:
pass
# Fall back to environment variable using same function as argparse default
if llm_binding_value is None:
llm_binding_value = get_env_value("LLM_BINDING", "ollama")
# Add LLM binding options based on determined value
if llm_binding_value == "ollama":
OllamaLLMOptions.add_args(parser)
elif llm_binding_value in ["openai", "azure_openai"]:
OpenAILLMOptions.add_args(parser)
elif llm_binding_value == "gemini":
GeminiLLMOptions.add_args(parser)
# Determine embedding binding value consistently from command line or environment
embedding_binding_value = None
if "--embedding-binding" in sys.argv:
try:
idx = sys.argv.index("--embedding-binding")
if idx + 1 < len(sys.argv) and not sys.argv[idx + 1].startswith("-"):
embedding_binding_value = sys.argv[idx + 1]
except IndexError:
pass
# Fall back to environment variable using same function as argparse default
if embedding_binding_value is None:
embedding_binding_value = get_env_value("EMBEDDING_BINDING", "ollama")
# Add embedding binding options based on determined value
if embedding_binding_value == "ollama":
OllamaEmbeddingOptions.add_args(parser)
elif embedding_binding_value == "gemini":
GeminiEmbeddingOptions.add_args(parser)
args = parser.parse_args()
# convert relative path to absolute path
args.working_dir = os.path.abspath(args.working_dir)
args.input_dir = os.path.abspath(args.input_dir)
# Inject storage configuration from environment variables
args.kv_storage = get_env_value(
"LIGHTRAG_KV_STORAGE", DefaultRAGStorageConfig.KV_STORAGE
)
args.doc_status_storage = get_env_value(
"LIGHTRAG_DOC_STATUS_STORAGE", DefaultRAGStorageConfig.DOC_STATUS_STORAGE
)
args.graph_storage = get_env_value(
"LIGHTRAG_GRAPH_STORAGE", DefaultRAGStorageConfig.GRAPH_STORAGE
)
args.vector_storage = get_env_value(
"LIGHTRAG_VECTOR_STORAGE", DefaultRAGStorageConfig.VECTOR_STORAGE
)
# Get MAX_PARALLEL_INSERT from environment
args.max_parallel_insert = get_env_value("MAX_PARALLEL_INSERT", 2, int)
# Get MAX_GRAPH_NODES from environment
args.max_graph_nodes = get_env_value("MAX_GRAPH_NODES", 1000, int)
# Handle openai-ollama special case
if args.llm_binding == "openai-ollama":
args.llm_binding = "openai"
args.embedding_binding = "ollama"
args.llm_binding_host = get_env_value(
"LLM_BINDING_HOST", get_default_host(args.llm_binding)
)
args.embedding_binding_host = get_env_value(
"EMBEDDING_BINDING_HOST", get_default_host(args.embedding_binding)
)
args.llm_binding_api_key = get_env_value("LLM_BINDING_API_KEY", None)
args.embedding_binding_api_key = get_env_value("EMBEDDING_BINDING_API_KEY", "")
# Inject model configuration
args.llm_model = get_env_value("LLM_MODEL", "mistral-nemo:latest")
# EMBEDDING_MODEL defaults to None - each binding will use its own default model
# e.g., OpenAI uses "text-embedding-3-small", Jina uses "jina-embeddings-v4"
args.embedding_model = get_env_value("EMBEDDING_MODEL", None, special_none=True)
# EMBEDDING_DIM defaults to None - each binding will use its own default dimension
# Value is inherited from provider defaults via wrap_embedding_func_with_attrs decorator
args.embedding_dim = get_env_value("EMBEDDING_DIM", None, int, special_none=True)
args.embedding_send_dim = get_env_value("EMBEDDING_SEND_DIM", False, bool)
# Inject chunk configuration
args.chunk_size = get_env_value("CHUNK_SIZE", 1200, int)
args.chunk_overlap_size = get_env_value("CHUNK_OVERLAP_SIZE", 100, int)
# Inject LLM cache configuration
args.enable_llm_cache_for_extract = get_env_value(
"ENABLE_LLM_CACHE_FOR_EXTRACT", True, bool
)
args.enable_llm_cache = get_env_value("ENABLE_LLM_CACHE", True, bool)
# Set document_loading_engine from --docling flag
if args.docling:
args.document_loading_engine = "DOCLING"
else:
args.document_loading_engine = get_env_value(
"DOCUMENT_LOADING_ENGINE", "DEFAULT"
)
# PDF decryption password
args.pdf_decrypt_password = get_env_value("PDF_DECRYPT_PASSWORD", None)
# Add environment variables that were previously read directly
args.cors_origins = get_env_value("CORS_ORIGINS", "*")
args.summary_language = get_env_value("SUMMARY_LANGUAGE", DEFAULT_SUMMARY_LANGUAGE)
args.entity_types = get_env_value("ENTITY_TYPES", DEFAULT_ENTITY_TYPES, list)
args.whitelist_paths = get_env_value("WHITELIST_PATHS", "/health,/api/*")
# For JWT Auth
args.auth_accounts = get_env_value("AUTH_ACCOUNTS", "")
args.token_secret = get_env_value("TOKEN_SECRET", None)
args.token_expire_hours = get_env_value("TOKEN_EXPIRE_HOURS", 48, float)
args.guest_token_expire_hours = get_env_value("GUEST_TOKEN_EXPIRE_HOURS", 24, float)
args.jwt_algorithm = get_env_value("JWT_ALGORITHM", "HS256")
# Token auto-renewal configuration (sliding window expiration)
args.token_auto_renew = get_env_value("TOKEN_AUTO_RENEW", True, bool)
args.token_renew_threshold = get_env_value("TOKEN_RENEW_THRESHOLD", 0.5, float)
# Rerank model configuration
args.rerank_model = get_env_value("RERANK_MODEL", None)
args.rerank_binding_host = get_env_value("RERANK_BINDING_HOST", None)
args.rerank_binding_api_key = get_env_value("RERANK_BINDING_API_KEY", None)
# Note: rerank_binding is already set by argparse, no need to override from env
# Min rerank score configuration
args.min_rerank_score = get_env_value(
"MIN_RERANK_SCORE", DEFAULT_MIN_RERANK_SCORE, float
)
# Query configuration
args.history_turns = get_env_value("HISTORY_TURNS", DEFAULT_HISTORY_TURNS, int)
args.top_k = get_env_value("TOP_K", DEFAULT_TOP_K, int)
args.chunk_top_k = get_env_value("CHUNK_TOP_K", DEFAULT_CHUNK_TOP_K, int)
args.max_entity_tokens = get_env_value(
"MAX_ENTITY_TOKENS", DEFAULT_MAX_ENTITY_TOKENS, int
)
args.max_relation_tokens = get_env_value(
"MAX_RELATION_TOKENS", DEFAULT_MAX_RELATION_TOKENS, int
)
args.max_total_tokens = get_env_value(
"MAX_TOTAL_TOKENS", DEFAULT_MAX_TOTAL_TOKENS, int
)
args.cosine_threshold = get_env_value(
"COSINE_THRESHOLD", DEFAULT_COSINE_THRESHOLD, float
)
args.related_chunk_number = get_env_value(
"RELATED_CHUNK_NUMBER", DEFAULT_RELATED_CHUNK_NUMBER, int
)
# Add missing environment variables for health endpoint
args.force_llm_summary_on_merge = get_env_value(
"FORCE_LLM_SUMMARY_ON_MERGE", DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE, int
)
args.embedding_func_max_async = get_env_value(
"EMBEDDING_FUNC_MAX_ASYNC", DEFAULT_EMBEDDING_FUNC_MAX_ASYNC, int
)
args.embedding_batch_num = get_env_value(
"EMBEDDING_BATCH_NUM", DEFAULT_EMBEDDING_BATCH_NUM, int
)
# Embedding token limit configuration
args.embedding_token_limit = get_env_value(
"EMBEDDING_TOKEN_LIMIT", None, int, special_none=True
)
# File upload size limit (in bytes, None for unlimited)
# Default: 100MB (104857600 bytes)
args.max_upload_size = get_env_value(
"MAX_UPLOAD_SIZE", 104857600, int, special_none=True
)
# Embedding prefix configuration for context-aware embeddings. Empty prefixes
# must be explicit via NO_PREFIX so missing config is distinguishable.
(
args.embedding_document_prefix,
args.embedding_document_prefix_configured,
) = get_embedding_prefix_config("EMBEDDING_DOCUMENT_PREFIX")
(
args.embedding_query_prefix,
args.embedding_query_prefix_configured,
) = get_embedding_prefix_config("EMBEDDING_QUERY_PREFIX")
args.embedding_prefix_no_prefix_sentinel = NO_PREFIX_SENTINEL
args.embedding_prefixes_configured = (
args.embedding_document_prefix_configured
or args.embedding_query_prefix_configured
)
# Asymmetric embedding behavior toggle
args.embedding_asymmetric_configured = "EMBEDDING_ASYMMETRIC" in os.environ
args.embedding_asymmetric = get_env_value("EMBEDDING_ASYMMETRIC", False, bool)
ollama_server_infos.LIGHTRAG_NAME = args.simulated_model_name
ollama_server_infos.LIGHTRAG_TAG = args.simulated_model_tag
# Sanitize workspace: only alphanumeric characters and underscores are allowed
if args.workspace:
sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", args.workspace)
if sanitized != args.workspace:
logging.warning(
f"Workspace name '{args.workspace}' contains invalid characters. "
f"It has been sanitized to '{sanitized}'. "
"Only alphanumeric characters and underscores are allowed."
)
args.workspace = sanitized
validate_auth_configuration(args)
return args
def update_uvicorn_mode_config():
# If in uvicorn mode and workers > 1, force it to 1 and log warning
if global_args.workers > 1:
original_workers = global_args.workers
global_args.workers = 1
# Log warning directly here
logging.debug(
f">> Forcing workers=1 in uvicorn mode(Ignoring workers={original_workers})"
)
# Global configuration with lazy initialization
_global_args = None
_initialized = False
def initialize_config(args=None, force=False):
"""Initialize global configuration
This function allows explicit initialization of the configuration,
which is useful for programmatic usage, testing, or embedding LightRAG
in other applications.
Args:
args: Pre-parsed argparse.Namespace or None to parse from sys.argv
force: Force re-initialization even if already initialized
Returns:
argparse.Namespace: The configured arguments
Example:
# Use parsed command line arguments (default)
initialize_config()
# Use custom configuration programmatically
custom_args = argparse.Namespace(
host='localhost',
port=8080,
working_dir='./custom_rag',
# ... other config
)
initialize_config(custom_args)
"""
global _global_args, _initialized
if _initialized and not force:
return _global_args
resolved_args = args if args is not None else parse_args()
validate_auth_configuration(resolved_args)
_global_args = resolved_args
_initialized = True
return _global_args
def get_config():
"""Get global configuration, auto-initializing if needed
Returns:
argparse.Namespace: The configured arguments
"""
if not _initialized:
initialize_config()
return _global_args
class _GlobalArgsProxy:
"""Proxy object that auto-initializes configuration on first access
This maintains backward compatibility with existing code while
allowing programmatic control over initialization timing.
The proxy fully delegates to the underlying argparse.Namespace,
including support for vars() calls which is used by binding_options
to extract provider-specific configuration options.
"""
def __getattribute__(self, name):
"""Override attribute access to support vars() and regular attribute access.
This method intercepts __dict__ access (used by vars()) and delegates
to the underlying _global_args namespace, ensuring binding options
can be properly extracted.
"""
global _initialized, _global_args
# Handle __dict__ access for vars() support
if name == "__dict__":
if not _initialized:
initialize_config()
return vars(_global_args)
# Handle class-level attributes that should come from the proxy itself
if name in ("__class__", "__repr__", "__getattribute__", "__setattr__"):
return object.__getattribute__(self, name)
# Delegate all other attribute access to the underlying namespace
if not _initialized:
initialize_config()
return getattr(_global_args, name)
def __setattr__(self, name, value):
global _initialized, _global_args
if not _initialized:
initialize_config()
setattr(_global_args, name, value)
def __repr__(self):
global _initialized, _global_args
if not _initialized:
return "<GlobalArgsProxy: Not initialized>"
return repr(_global_args)
# Create proxy instance for backward compatibility
# Existing code like `from config import global_args` continues to work
# The proxy will auto-initialize on first attribute access
global_args = _GlobalArgsProxy()

View File

@@ -0,0 +1,162 @@
# gunicorn_config.py
import os
import logging
from lightrag.kg.shared_storage import finalize_share_data
from lightrag.utils import setup_logger, get_env_value
from lightrag.constants import (
DEFAULT_LOG_MAX_BYTES,
DEFAULT_LOG_BACKUP_COUNT,
DEFAULT_LOG_FILENAME,
)
# Get log directory path from environment variable
log_dir = os.getenv("LOG_DIR", os.getcwd())
log_file_path = os.path.abspath(os.path.join(log_dir, DEFAULT_LOG_FILENAME))
# Ensure log directory exists
os.makedirs(os.path.dirname(log_file_path), exist_ok=True)
# Get log file max size and backup count from environment variables
log_max_bytes = get_env_value("LOG_MAX_BYTES", DEFAULT_LOG_MAX_BYTES, int)
log_backup_count = get_env_value("LOG_BACKUP_COUNT", DEFAULT_LOG_BACKUP_COUNT, int)
# These variables will be set by run_with_gunicorn.py
workers = None
bind = None
loglevel = None
certfile = None
keyfile = None
# Enable preload_app option
preload_app = True
# Use Uvicorn worker
worker_class = "uvicorn.workers.UvicornWorker"
# Other Gunicorn configurations
# Logging configuration
errorlog = os.getenv("ERROR_LOG", log_file_path) # Default write to lightrag.log
accesslog = os.getenv("ACCESS_LOG", log_file_path) # Default write to lightrag.log
logconfig_dict = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "standard",
"stream": "ext://sys.stdout",
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"formatter": "standard",
"filename": log_file_path,
"maxBytes": log_max_bytes,
"backupCount": log_backup_count,
"encoding": "utf8",
},
},
"filters": {
"path_filter": {
"()": "lightrag.utils.LightragPathFilter",
},
},
"loggers": {
"lightrag": {
"handlers": ["console", "file"],
"level": loglevel.upper() if loglevel else "INFO",
"propagate": False,
},
"gunicorn": {
"handlers": ["console", "file"],
"level": loglevel.upper() if loglevel else "INFO",
"propagate": False,
},
"gunicorn.error": {
"handlers": ["console", "file"],
"level": loglevel.upper() if loglevel else "INFO",
"propagate": False,
},
"gunicorn.access": {
"handlers": ["console", "file"],
"level": loglevel.upper() if loglevel else "INFO",
"propagate": False,
"filters": ["path_filter"],
},
},
}
def on_starting(server):
"""
Executed when Gunicorn starts, before forking the first worker processes
You can use this function to do more initialization tasks for all processes
"""
print("=" * 80)
print(f"GUNICORN MASTER PROCESS: on_starting jobs for {workers} worker(s)")
print(f"Process ID: {os.getpid()}")
print("=" * 80)
# Memory usage monitoring
try:
import psutil
process = psutil.Process(os.getpid())
memory_info = process.memory_info()
msg = (
f"Memory usage after initialization: {memory_info.rss / 1024 / 1024:.2f} MB"
)
print(msg)
except ImportError:
print("psutil not installed, skipping memory usage reporting")
# Log the location of the LightRAG log file
print(f"LightRAG log file: {log_file_path}\n")
print("Gunicorn initialization complete, forking workers...\n")
def on_exit(server):
"""
Executed when Gunicorn is shutting down.
This is a good place to release shared resources.
"""
print("=" * 80)
print("GUNICORN MASTER PROCESS: Shutting down")
print(f"Process ID: {os.getpid()}")
print("Finalizing shared storage...")
finalize_share_data()
print("Gunicorn shutdown complete")
print("=" * 80)
def post_fork(server, worker):
"""
Executed after a worker has been forked.
This is a good place to set up worker-specific configurations.
"""
# Set up main loggers
log_level = loglevel.upper() if loglevel else "INFO"
setup_logger("uvicorn", log_level, add_filter=False, log_file_path=log_file_path)
setup_logger(
"uvicorn.access", log_level, add_filter=True, log_file_path=log_file_path
)
setup_logger("lightrag", log_level, add_filter=True, log_file_path=log_file_path)
# Set up lightrag submodule loggers
for name in logging.root.manager.loggerDict:
if name.startswith("lightrag."):
setup_logger(name, log_level, add_filter=True, log_file_path=log_file_path)
# Disable uvicorn.error logger
uvicorn_error_logger = logging.getLogger("uvicorn.error")
uvicorn_error_logger.handlers = []
uvicorn_error_logger.setLevel(logging.CRITICAL)
uvicorn_error_logger.propagate = False

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
import bcrypt
BCRYPT_PASSWORD_PREFIX = "{bcrypt}"
def hash_password(password: str) -> str:
"""Return an AUTH_ACCOUNTS-ready bcrypt password value."""
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
return f"{BCRYPT_PASSWORD_PREFIX}{hashed}"
def verify_password(plain_password: str, stored_password: str) -> bool:
"""Verify a plaintext password against a stored password spec."""
if stored_password.startswith(BCRYPT_PASSWORD_PREFIX):
hashed_password = stored_password[len(BCRYPT_PASSWORD_PREFIX) :]
if not hashed_password:
return False
try:
return bcrypt.checkpw(
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
)
except ValueError:
return False
return stored_password == plain_password

View File

@@ -0,0 +1,10 @@
"""
This module contains all the routers for the LightRAG API.
"""
from .document_routes import router as document_router
from .query_routes import router as query_router
from .graph_routes import router as graph_router
from .ollama_api import OllamaAPI
__all__ = ["document_router", "query_router", "graph_router", "OllamaAPI"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,688 @@
"""
This module contains all graph-related routes for the LightRAG API.
"""
from typing import Optional, Dict, Any
import traceback
from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel, Field
from lightrag.utils import logger
from ..utils_api import get_combined_auth_dependency
router = APIRouter(tags=["graph"])
class EntityUpdateRequest(BaseModel):
entity_name: str
updated_data: Dict[str, Any]
allow_rename: bool = False
allow_merge: bool = False
class RelationUpdateRequest(BaseModel):
source_id: str
target_id: str
updated_data: Dict[str, Any]
class EntityMergeRequest(BaseModel):
entities_to_change: list[str] = Field(
...,
description="List of entity names to be merged and deleted. These are typically duplicate or misspelled entities.",
min_length=1,
examples=[["Elon Msk", "Ellon Musk"]],
)
entity_to_change_into: str = Field(
...,
description="Target entity name that will receive all relationships from the source entities. This entity will be preserved.",
min_length=1,
examples=["Elon Musk"],
)
class EntityCreateRequest(BaseModel):
entity_name: str = Field(
...,
description="Unique name for the new entity",
min_length=1,
examples=["Tesla"],
)
entity_data: Dict[str, Any] = Field(
...,
description="Dictionary containing entity properties. Common fields include 'description' and 'entity_type'.",
examples=[
{
"description": "Electric vehicle manufacturer",
"entity_type": "ORGANIZATION",
}
],
)
class RelationCreateRequest(BaseModel):
source_entity: str = Field(
...,
description="Name of the source entity. This entity must already exist in the knowledge graph.",
min_length=1,
examples=["Elon Musk"],
)
target_entity: str = Field(
...,
description="Name of the target entity. This entity must already exist in the knowledge graph.",
min_length=1,
examples=["Tesla"],
)
relation_data: Dict[str, Any] = Field(
...,
description="Dictionary containing relationship properties. Common fields include 'description', 'keywords', and 'weight'.",
examples=[
{
"description": "Elon Musk is the CEO of Tesla",
"keywords": "CEO, founder",
"weight": 1.0,
}
],
)
def create_graph_routes(rag, api_key: Optional[str] = None):
combined_auth = get_combined_auth_dependency(api_key)
@router.get("/graph/label/list", dependencies=[Depends(combined_auth)])
async def get_graph_labels():
"""
Get all graph labels
Returns:
List[str]: List of graph labels
"""
try:
return await rag.get_graph_labels()
except Exception as e:
logger.error(f"Error getting graph labels: {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error getting graph labels: {str(e)}"
)
@router.get("/graph/label/popular", dependencies=[Depends(combined_auth)])
async def get_popular_labels(
limit: int = Query(
300, description="Maximum number of popular labels to return", ge=1, le=1000
),
):
"""
Get popular labels by node degree (most connected entities)
Args:
limit (int): Maximum number of labels to return (default: 300, max: 1000)
Returns:
List[str]: List of popular labels sorted by degree (highest first)
"""
try:
return await rag.chunk_entity_relation_graph.get_popular_labels(limit)
except Exception as e:
logger.error(f"Error getting popular labels: {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error getting popular labels: {str(e)}"
)
@router.get("/graph/label/search", dependencies=[Depends(combined_auth)])
async def search_labels(
q: str = Query(..., description="Search query string"),
limit: int = Query(
50, description="Maximum number of search results to return", ge=1, le=100
),
):
"""
Search labels with fuzzy matching
Args:
q (str): Search query string
limit (int): Maximum number of results to return (default: 50, max: 100)
Returns:
List[str]: List of matching labels sorted by relevance
"""
try:
return await rag.chunk_entity_relation_graph.search_labels(q, limit)
except Exception as e:
logger.error(f"Error searching labels with query '{q}': {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error searching labels: {str(e)}"
)
@router.get("/graphs", dependencies=[Depends(combined_auth)])
async def get_knowledge_graph(
label: str = Query(..., description="Label to get knowledge graph for"),
max_depth: int = Query(3, description="Maximum depth of graph", ge=1),
max_nodes: int = Query(1000, description="Maximum nodes to return", ge=1),
):
"""
Retrieve a connected subgraph of nodes where the label includes the specified label.
When reducing the number of nodes, the prioritization criteria are as follows:
1. Hops(path) to the staring node take precedence
2. Followed by the degree of the nodes
Args:
label (str): Label of the starting node
max_depth (int, optional): Maximum depth of the subgraph,Defaults to 3
max_nodes: Maxiumu nodes to return
Returns:
Dict[str, List[str]]: Knowledge graph for label
"""
try:
# Log the label parameter to check for leading spaces
logger.debug(
f"get_knowledge_graph called with label: '{label}' (length: {len(label)}, repr: {repr(label)})"
)
return await rag.get_knowledge_graph(
node_label=label,
max_depth=max_depth,
max_nodes=max_nodes,
)
except Exception as e:
logger.error(f"Error getting knowledge graph for label '{label}': {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error getting knowledge graph: {str(e)}"
)
@router.get("/graph/entity/exists", dependencies=[Depends(combined_auth)])
async def check_entity_exists(
name: str = Query(..., description="Entity name to check"),
):
"""
Check if an entity with the given name exists in the knowledge graph
Args:
name (str): Name of the entity to check
Returns:
Dict[str, bool]: Dictionary with 'exists' key indicating if entity exists
"""
try:
exists = await rag.chunk_entity_relation_graph.has_node(name)
return {"exists": exists}
except Exception as e:
logger.error(f"Error checking entity existence for '{name}': {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error checking entity existence: {str(e)}"
)
@router.post("/graph/entity/edit", dependencies=[Depends(combined_auth)])
async def update_entity(request: EntityUpdateRequest):
"""
Update an entity's properties in the knowledge graph
This endpoint allows updating entity properties, including renaming entities.
When renaming to an existing entity name, the behavior depends on allow_merge:
Args:
request (EntityUpdateRequest): Request containing:
- entity_name (str): Name of the entity to update
- updated_data (Dict[str, Any]): Dictionary of properties to update
- allow_rename (bool): Whether to allow entity renaming (default: False)
- allow_merge (bool): Whether to merge into existing entity when renaming
causes name conflict (default: False)
Returns:
Dict with the following structure:
{
"status": "success",
"message": "Entity updated successfully" | "Entity merged successfully into 'target_name'",
"data": {
"entity_name": str, # Final entity name
"description": str, # Entity description
"entity_type": str, # Entity type
"source_id": str, # Source chunk IDs
... # Other entity properties
},
"operation_summary": {
"merged": bool, # Whether entity was merged into another
"merge_status": str, # "success" | "failed" | "not_attempted"
"merge_error": str | None, # Error message if merge failed
"operation_status": str, # "success" | "partial_success" | "failure"
"target_entity": str | None, # Target entity name if renaming/merging
"final_entity": str, # Final entity name after operation
"renamed": bool # Whether entity was renamed
}
}
operation_status values explained:
- "success": All operations completed successfully
* For simple updates: entity properties updated
* For renames: entity renamed successfully
* For merges: non-name updates applied AND merge completed
- "partial_success": Update succeeded but merge failed
* Non-name property updates were applied successfully
* Merge operation failed (entity not merged)
* Original entity still exists with updated properties
* Use merge_error for failure details
- "failure": Operation failed completely
* If merge_status == "failed": Merge attempted but both update and merge failed
* If merge_status == "not_attempted": Regular update failed
* No changes were applied to the entity
merge_status values explained:
- "success": Entity successfully merged into target entity
- "failed": Merge operation was attempted but failed
- "not_attempted": No merge was attempted (normal update/rename)
Behavior when renaming to an existing entity:
- If allow_merge=False: Raises ValueError with 400 status (default behavior)
- If allow_merge=True: Automatically merges the source entity into the existing target entity,
preserving all relationships and applying non-name updates first
Example Request (simple update):
POST /graph/entity/edit
{
"entity_name": "Tesla",
"updated_data": {"description": "Updated description"},
"allow_rename": false,
"allow_merge": false
}
Example Response (simple update success):
{
"status": "success",
"message": "Entity updated successfully",
"data": { ... },
"operation_summary": {
"merged": false,
"merge_status": "not_attempted",
"merge_error": null,
"operation_status": "success",
"target_entity": null,
"final_entity": "Tesla",
"renamed": false
}
}
Example Request (rename with auto-merge):
POST /graph/entity/edit
{
"entity_name": "Elon Msk",
"updated_data": {
"entity_name": "Elon Musk",
"description": "Corrected description"
},
"allow_rename": true,
"allow_merge": true
}
Example Response (merge success):
{
"status": "success",
"message": "Entity merged successfully into 'Elon Musk'",
"data": { ... },
"operation_summary": {
"merged": true,
"merge_status": "success",
"merge_error": null,
"operation_status": "success",
"target_entity": "Elon Musk",
"final_entity": "Elon Musk",
"renamed": true
}
}
Example Response (partial success - update succeeded but merge failed):
{
"status": "success",
"message": "Entity updated successfully",
"data": { ... }, # Data reflects updated "Elon Msk" entity
"operation_summary": {
"merged": false,
"merge_status": "failed",
"merge_error": "Target entity locked by another operation",
"operation_status": "partial_success",
"target_entity": "Elon Musk",
"final_entity": "Elon Msk", # Original entity still exists
"renamed": true
}
}
"""
try:
result = await rag.aedit_entity(
entity_name=request.entity_name,
updated_data=request.updated_data,
allow_rename=request.allow_rename,
allow_merge=request.allow_merge,
)
# Extract operation_summary from result, with fallback for backward compatibility
operation_summary = result.get(
"operation_summary",
{
"merged": False,
"merge_status": "not_attempted",
"merge_error": None,
"operation_status": "success",
"target_entity": None,
"final_entity": request.updated_data.get(
"entity_name", request.entity_name
),
"renamed": request.updated_data.get(
"entity_name", request.entity_name
)
!= request.entity_name,
},
)
# Separate entity data from operation_summary for clean response
entity_data = dict(result)
entity_data.pop("operation_summary", None)
# Generate appropriate response message based on merge status
response_message = (
f"Entity merged successfully into '{operation_summary['final_entity']}'"
if operation_summary.get("merged")
else "Entity updated successfully"
)
return {
"status": "success",
"message": response_message,
"data": entity_data,
"operation_summary": operation_summary,
}
except ValueError as ve:
logger.error(
f"Validation error updating entity '{request.entity_name}': {str(ve)}"
)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error(f"Error updating entity '{request.entity_name}': {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error updating entity: {str(e)}"
)
@router.post("/graph/relation/edit", dependencies=[Depends(combined_auth)])
async def update_relation(request: RelationUpdateRequest):
"""Update a relation's properties in the knowledge graph
Args:
request (RelationUpdateRequest): Request containing source ID, target ID and updated data
Returns:
Dict: Updated relation information
"""
try:
result = await rag.aedit_relation(
source_entity=request.source_id,
target_entity=request.target_id,
updated_data=request.updated_data,
)
return {
"status": "success",
"message": "Relation updated successfully",
"data": result,
}
except ValueError as ve:
logger.error(
f"Validation error updating relation between '{request.source_id}' and '{request.target_id}': {str(ve)}"
)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error(
f"Error updating relation between '{request.source_id}' and '{request.target_id}': {str(e)}"
)
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error updating relation: {str(e)}"
)
@router.post("/graph/entity/create", dependencies=[Depends(combined_auth)])
async def create_entity(request: EntityCreateRequest):
"""
Create a new entity in the knowledge graph
This endpoint creates a new entity node in the knowledge graph with the specified
properties. The system automatically generates vector embeddings for the entity
to enable semantic search and retrieval.
Request Body:
entity_name (str): Unique name identifier for the entity
entity_data (dict): Entity properties including:
- description (str): Textual description of the entity
- entity_type (str): Category/type of the entity (e.g., PERSON, ORGANIZATION, LOCATION)
- source_id (str): Related chunk_id from which the description originates
- Additional custom properties as needed
Response Schema:
{
"status": "success",
"message": "Entity 'Tesla' created successfully",
"data": {
"entity_name": "Tesla",
"description": "Electric vehicle manufacturer",
"entity_type": "ORGANIZATION",
"source_id": "chunk-123<SEP>chunk-456"
... (other entity properties)
}
}
HTTP Status Codes:
200: Entity created successfully
400: Invalid request (e.g., missing required fields, duplicate entity)
500: Internal server error
Example Request:
POST /graph/entity/create
{
"entity_name": "Tesla",
"entity_data": {
"description": "Electric vehicle manufacturer",
"entity_type": "ORGANIZATION"
}
}
"""
try:
# Use the proper acreate_entity method which handles:
# - Graph lock for concurrency
# - Vector embedding creation in entities_vdb
# - Metadata population and defaults
# - Index consistency via _edit_entity_done
result = await rag.acreate_entity(
entity_name=request.entity_name,
entity_data=request.entity_data,
)
return {
"status": "success",
"message": f"Entity '{request.entity_name}' created successfully",
"data": result,
}
except ValueError as ve:
logger.error(
f"Validation error creating entity '{request.entity_name}': {str(ve)}"
)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error(f"Error creating entity '{request.entity_name}': {str(e)}")
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error creating entity: {str(e)}"
)
@router.post("/graph/relation/create", dependencies=[Depends(combined_auth)])
async def create_relation(request: RelationCreateRequest):
"""
Create a new relationship between two entities in the knowledge graph
This endpoint establishes an undirected relationship between two existing entities.
The provided source/target order is accepted for convenience, but the backend
stored edge is undirected and may be returned with the entities swapped.
Both entities must already exist in the knowledge graph. The system automatically
generates vector embeddings for the relationship to enable semantic search and graph traversal.
Prerequisites:
- Both source_entity and target_entity must exist in the knowledge graph
- Use /graph/entity/create to create entities first if they don't exist
Request Body:
source_entity (str): Name of the source entity (relationship origin)
target_entity (str): Name of the target entity (relationship destination)
relation_data (dict): Relationship properties including:
- description (str): Textual description of the relationship
- keywords (str): Comma-separated keywords describing the relationship type
- source_id (str): Related chunk_id from which the description originates
- weight (float): Relationship strength/importance (default: 1.0)
- Additional custom properties as needed
Response Schema:
{
"status": "success",
"message": "Relation created successfully between 'Elon Musk' and 'Tesla'",
"data": {
"src_id": "Elon Musk",
"tgt_id": "Tesla",
"description": "Elon Musk is the CEO of Tesla",
"keywords": "CEO, founder",
"source_id": "chunk-123<SEP>chunk-456"
"weight": 1.0,
... (other relationship properties)
}
}
HTTP Status Codes:
200: Relationship created successfully
400: Invalid request (e.g., missing entities, invalid data, duplicate relationship)
500: Internal server error
Example Request:
POST /graph/relation/create
{
"source_entity": "Elon Musk",
"target_entity": "Tesla",
"relation_data": {
"description": "Elon Musk is the CEO of Tesla",
"keywords": "CEO, founder",
"weight": 1.0
}
}
"""
try:
# Use the proper acreate_relation method which handles:
# - Graph lock for concurrency
# - Entity existence validation
# - Duplicate relation checks
# - Vector embedding creation in relationships_vdb
# - Index consistency via _edit_relation_done
result = await rag.acreate_relation(
source_entity=request.source_entity,
target_entity=request.target_entity,
relation_data=request.relation_data,
)
return {
"status": "success",
"message": f"Relation created successfully between '{request.source_entity}' and '{request.target_entity}'",
"data": result,
}
except ValueError as ve:
logger.error(
f"Validation error creating relation between '{request.source_entity}' and '{request.target_entity}': {str(ve)}"
)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error(
f"Error creating relation between '{request.source_entity}' and '{request.target_entity}': {str(e)}"
)
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error creating relation: {str(e)}"
)
@router.post("/graph/entities/merge", dependencies=[Depends(combined_auth)])
async def merge_entities(request: EntityMergeRequest):
"""
Merge multiple entities into a single entity, preserving all relationships
This endpoint consolidates duplicate or misspelled entities while preserving the entire
graph structure. It's particularly useful for cleaning up knowledge graphs after document
processing or correcting entity name variations.
What the Merge Operation Does:
1. Deletes the specified source entities from the knowledge graph
2. Transfers all relationships from source entities to the target entity
3. Intelligently merges duplicate relationships (if multiple sources have the same relationship)
4. Updates vector embeddings for accurate retrieval and search
5. Preserves the complete graph structure and connectivity
6. Maintains relationship properties and metadata
Use Cases:
- Fixing spelling errors in entity names (e.g., "Elon Msk" -> "Elon Musk")
- Consolidating duplicate entities discovered after document processing
- Merging name variations (e.g., "NY", "New York", "New York City")
- Cleaning up the knowledge graph for better query performance
- Standardizing entity names across the knowledge base
Request Body:
entities_to_change (list[str]): List of entity names to be merged and deleted
entity_to_change_into (str): Target entity that will receive all relationships
Response Schema:
{
"status": "success",
"message": "Successfully merged 2 entities into 'Elon Musk'",
"data": {
"merged_entity": "Elon Musk",
"deleted_entities": ["Elon Msk", "Ellon Musk"],
"relationships_transferred": 15,
... (merge operation details)
}
}
HTTP Status Codes:
200: Entities merged successfully
400: Invalid request (e.g., empty entity list, target entity doesn't exist)
500: Internal server error
Example Request:
POST /graph/entities/merge
{
"entities_to_change": ["Elon Msk", "Ellon Musk"],
"entity_to_change_into": "Elon Musk"
}
Note:
- The target entity (entity_to_change_into) must exist in the knowledge graph
- Source entities will be permanently deleted after the merge
- This operation cannot be undone, so verify entity names before merging
"""
try:
result = await rag.amerge_entities(
source_entities=request.entities_to_change,
target_entity=request.entity_to_change_into,
)
return {
"status": "success",
"message": f"Successfully merged {len(request.entities_to_change)} entities into '{request.entity_to_change_into}'",
"data": result,
}
except ValueError as ve:
logger.error(
f"Validation error merging entities {request.entities_to_change} into '{request.entity_to_change_into}': {str(ve)}"
)
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
logger.error(
f"Error merging entities {request.entities_to_change} into '{request.entity_to_change_into}': {str(e)}"
)
logger.error(traceback.format_exc())
raise HTTPException(
status_code=500, detail=f"Error merging entities: {str(e)}"
)
return router

View File

@@ -0,0 +1,723 @@
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from typing import List, Dict, Any, Optional, Type
from lightrag.utils import logger
import time
import json
import re
from enum import Enum
from fastapi.responses import StreamingResponse
import asyncio
from lightrag import LightRAG, QueryParam
from lightrag.utils import TiktokenTokenizer
from lightrag.api.utils_api import get_combined_auth_dependency
from fastapi import Depends
# query mode according to query prefix (bypass is not LightRAG quer mode)
class SearchMode(str, Enum):
naive = "naive"
local = "local"
global_ = "global"
hybrid = "hybrid"
mix = "mix"
bypass = "bypass"
context = "context"
class OllamaMessage(BaseModel):
role: str
content: str
images: Optional[List[str]] = None
class OllamaChatRequest(BaseModel):
model: str
messages: List[OllamaMessage]
stream: bool = True
options: Optional[Dict[str, Any]] = None
system: Optional[str] = None
class OllamaChatResponse(BaseModel):
model: str
created_at: str
message: OllamaMessage
done: bool
class OllamaGenerateRequest(BaseModel):
model: str
prompt: str
system: Optional[str] = None
stream: bool = False
options: Optional[Dict[str, Any]] = None
class OllamaGenerateResponse(BaseModel):
model: str
created_at: str
response: str
done: bool
context: Optional[List[int]]
total_duration: Optional[int]
load_duration: Optional[int]
prompt_eval_count: Optional[int]
prompt_eval_duration: Optional[int]
eval_count: Optional[int]
eval_duration: Optional[int]
class OllamaVersionResponse(BaseModel):
version: str
class OllamaModelDetails(BaseModel):
parent_model: str
format: str
family: str
families: List[str]
parameter_size: str
quantization_level: str
class OllamaModel(BaseModel):
name: str
model: str
size: int
digest: str
modified_at: str
details: OllamaModelDetails
class OllamaTagResponse(BaseModel):
models: List[OllamaModel]
class OllamaRunningModelDetails(BaseModel):
parent_model: str
format: str
family: str
families: List[str]
parameter_size: str
quantization_level: str
class OllamaRunningModel(BaseModel):
name: str
model: str
size: int
digest: str
details: OllamaRunningModelDetails
expires_at: str
size_vram: int
class OllamaPsResponse(BaseModel):
models: List[OllamaRunningModel]
async def parse_request_body(
request: Request, model_class: Type[BaseModel]
) -> BaseModel:
"""
Parse request body based on Content-Type header.
Supports both application/json and application/octet-stream.
Args:
request: The FastAPI Request object
model_class: The Pydantic model class to parse the request into
Returns:
An instance of the provided model_class
"""
content_type = request.headers.get("content-type", "").lower()
try:
if content_type.startswith("application/json"):
# FastAPI already handles JSON parsing for us
body = await request.json()
elif content_type.startswith("application/octet-stream"):
# Manually parse octet-stream as JSON
body_bytes = await request.body()
body = json.loads(body_bytes.decode("utf-8"))
else:
# Try to parse as JSON for any other content type
body_bytes = await request.body()
body = json.loads(body_bytes.decode("utf-8"))
# Create an instance of the model
return model_class(**body)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON in request body")
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Error parsing request body: {str(e)}"
)
def estimate_tokens(text: str) -> int:
"""Estimate the number of tokens in text using tiktoken"""
tokens = TiktokenTokenizer().encode(text)
return len(tokens)
def parse_query_mode(query: str) -> tuple[str, SearchMode, bool, Optional[str]]:
"""Parse query prefix to determine search mode
Returns tuple of (cleaned_query, search_mode, only_need_context, user_prompt)
Examples:
- "/local[use mermaid format for diagrams] query string" -> (cleaned_query, SearchMode.local, False, "use mermaid format for diagrams")
- "/[use mermaid format for diagrams] query string" -> (cleaned_query, SearchMode.hybrid, False, "use mermaid format for diagrams")
- "/local query string" -> (cleaned_query, SearchMode.local, False, None)
"""
# Initialize user_prompt as None
user_prompt = None
# First check if there's a bracket format for user prompt
bracket_pattern = r"^/([a-z]*)\[(.*?)\](.*)"
bracket_match = re.match(bracket_pattern, query)
if bracket_match:
mode_prefix = bracket_match.group(1)
user_prompt = bracket_match.group(2)
remaining_query = bracket_match.group(3).lstrip()
# Reconstruct query, removing the bracket part
query = f"/{mode_prefix} {remaining_query}".strip()
# Unified handling of mode and only_need_context determination
mode_map = {
"/local ": (SearchMode.local, False),
"/global ": (
SearchMode.global_,
False,
), # global_ is used because 'global' is a Python keyword
"/naive ": (SearchMode.naive, False),
"/hybrid ": (SearchMode.hybrid, False),
"/mix ": (SearchMode.mix, False),
"/bypass ": (SearchMode.bypass, False),
"/context": (
SearchMode.mix,
True,
),
"/localcontext": (SearchMode.local, True),
"/globalcontext": (SearchMode.global_, True),
"/hybridcontext": (SearchMode.hybrid, True),
"/naivecontext": (SearchMode.naive, True),
"/mixcontext": (SearchMode.mix, True),
}
for prefix, (mode, only_need_context) in mode_map.items():
if query.startswith(prefix):
# After removing prefix and leading spaces
cleaned_query = query[len(prefix) :].lstrip()
return cleaned_query, mode, only_need_context, user_prompt
return query, SearchMode.mix, False, user_prompt
class OllamaAPI:
def __init__(self, rag: LightRAG, top_k: int = 60, api_key: Optional[str] = None):
self.rag = rag
self.ollama_server_infos = rag.ollama_server_infos
self.top_k = top_k
self.api_key = api_key
self.router = APIRouter(tags=["ollama"])
self.setup_routes()
def setup_routes(self):
# Create combined auth dependency for Ollama API routes
combined_auth = get_combined_auth_dependency(self.api_key)
@self.router.get("/version", dependencies=[Depends(combined_auth)])
async def get_version():
"""Get Ollama version information"""
return OllamaVersionResponse(version="0.9.3")
@self.router.get("/tags", dependencies=[Depends(combined_auth)])
async def get_tags():
"""Return available models acting as an Ollama server"""
return OllamaTagResponse(
models=[
{
"name": self.ollama_server_infos.LIGHTRAG_MODEL,
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"modified_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"size": self.ollama_server_infos.LIGHTRAG_SIZE,
"digest": self.ollama_server_infos.LIGHTRAG_DIGEST,
"details": {
"parent_model": "",
"format": "gguf",
"family": self.ollama_server_infos.LIGHTRAG_NAME,
"families": [self.ollama_server_infos.LIGHTRAG_NAME],
"parameter_size": "13B",
"quantization_level": "Q4_0",
},
}
]
)
@self.router.get("/ps", dependencies=[Depends(combined_auth)])
async def get_running_models():
"""List Running Models - returns currently running models"""
return OllamaPsResponse(
models=[
{
"name": self.ollama_server_infos.LIGHTRAG_MODEL,
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"size": self.ollama_server_infos.LIGHTRAG_SIZE,
"digest": self.ollama_server_infos.LIGHTRAG_DIGEST,
"details": {
"parent_model": "",
"format": "gguf",
"family": "llama",
"families": ["llama"],
"parameter_size": "7.2B",
"quantization_level": "Q4_0",
},
"expires_at": "2050-12-31T14:38:31.83753-07:00",
"size_vram": self.ollama_server_infos.LIGHTRAG_SIZE,
}
]
)
@self.router.post(
"/generate", dependencies=[Depends(combined_auth)], include_in_schema=True
)
async def generate(raw_request: Request):
"""Handle generate completion requests acting as an Ollama model
For compatibility purpose, the request is not processed by LightRAG,
and will be handled by underlying LLM model.
Supports both application/json and application/octet-stream Content-Types.
"""
try:
# Parse the request body manually
request = await parse_request_body(raw_request, OllamaGenerateRequest)
query = request.prompt
start_time = time.time_ns()
prompt_tokens = estimate_tokens(query)
if request.system:
self.rag.llm_model_kwargs["system_prompt"] = request.system
if request.stream:
response = await self.rag.llm_model_func(
query, stream=True, **self.rag.llm_model_kwargs
)
async def stream_generator():
first_chunk_time = None
last_chunk_time = time.time_ns()
total_response = ""
# Ensure response is an async generator
if isinstance(response, str):
# If it's a string, send in two parts
first_chunk_time = start_time
last_chunk_time = time.time_ns()
total_response = response
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"response": response,
"done": False,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
completion_tokens = estimate_tokens(total_response)
total_time = last_chunk_time - start_time
prompt_eval_time = first_chunk_time - start_time
eval_time = last_chunk_time - first_chunk_time
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"response": "",
"done": True,
"done_reason": "stop",
"context": [],
"total_duration": total_time,
"load_duration": 0,
"prompt_eval_count": prompt_tokens,
"prompt_eval_duration": prompt_eval_time,
"eval_count": completion_tokens,
"eval_duration": eval_time,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
else:
try:
async for chunk in response:
if chunk:
if first_chunk_time is None:
first_chunk_time = time.time_ns()
last_chunk_time = time.time_ns()
total_response += chunk
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"response": chunk,
"done": False,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
except (asyncio.CancelledError, Exception) as e:
error_msg = str(e)
if isinstance(e, asyncio.CancelledError):
error_msg = "Stream was cancelled by server"
else:
error_msg = f"Provider error: {error_msg}"
logger.error(f"Stream error: {error_msg}")
# Send error message to client
error_data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"response": f"\n\nError: {error_msg}",
"error": f"\n\nError: {error_msg}",
"done": False,
}
yield f"{json.dumps(error_data, ensure_ascii=False)}\n"
# Send final message to close the stream
final_data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"response": "",
"done": True,
}
yield f"{json.dumps(final_data, ensure_ascii=False)}\n"
return
if first_chunk_time is None:
first_chunk_time = start_time
completion_tokens = estimate_tokens(total_response)
total_time = last_chunk_time - start_time
prompt_eval_time = first_chunk_time - start_time
eval_time = last_chunk_time - first_chunk_time
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"response": "",
"done": True,
"done_reason": "stop",
"context": [],
"total_duration": total_time,
"load_duration": 0,
"prompt_eval_count": prompt_tokens,
"prompt_eval_duration": prompt_eval_time,
"eval_count": completion_tokens,
"eval_duration": eval_time,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
return
return StreamingResponse(
stream_generator(),
media_type="application/x-ndjson",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Content-Type": "application/x-ndjson",
"X-Accel-Buffering": "no", # Ensure proper handling of streaming responses in Nginx proxy
},
)
else:
first_chunk_time = time.time_ns()
response_text = await self.rag.llm_model_func(
query, stream=False, **self.rag.llm_model_kwargs
)
last_chunk_time = time.time_ns()
if not response_text:
response_text = "No response generated"
completion_tokens = estimate_tokens(str(response_text))
total_time = last_chunk_time - start_time
prompt_eval_time = first_chunk_time - start_time
eval_time = last_chunk_time - first_chunk_time
return {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"response": str(response_text),
"done": True,
"done_reason": "stop",
"context": [],
"total_duration": total_time,
"load_duration": 0,
"prompt_eval_count": prompt_tokens,
"prompt_eval_duration": prompt_eval_time,
"eval_count": completion_tokens,
"eval_duration": eval_time,
}
except Exception as e:
logger.error(f"Ollama generate error: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@self.router.post(
"/chat", dependencies=[Depends(combined_auth)], include_in_schema=True
)
async def chat(raw_request: Request):
"""Process chat completion requests by acting as an Ollama model.
Routes user queries through LightRAG by selecting query mode based on query prefix.
Detects and forwards OpenWebUI session-related requests (for meta data generation task) directly to LLM.
Supports both application/json and application/octet-stream Content-Types.
"""
try:
# Parse the request body manually
request = await parse_request_body(raw_request, OllamaChatRequest)
# Get all messages
messages = request.messages
if not messages:
raise HTTPException(status_code=400, detail="No messages provided")
# Validate that the last message is from a user
if messages[-1].role != "user":
raise HTTPException(
status_code=400, detail="Last message must be from user role"
)
# Get the last message as query and previous messages as history
query = messages[-1].content
# Convert OllamaMessage objects to dictionaries
conversation_history = [
{"role": msg.role, "content": msg.content} for msg in messages[:-1]
]
# Check for query prefix
cleaned_query, mode, only_need_context, user_prompt = parse_query_mode(
query
)
start_time = time.time_ns()
prompt_tokens = estimate_tokens(cleaned_query)
param_dict = {
"mode": mode.value,
"stream": request.stream,
"only_need_context": only_need_context,
"conversation_history": conversation_history,
"top_k": self.top_k,
}
# Add user_prompt to param_dict
if user_prompt is not None:
param_dict["user_prompt"] = user_prompt
query_param = QueryParam(**param_dict)
if request.stream:
# Determine if the request is prefix with "/bypass"
if mode == SearchMode.bypass:
if request.system:
self.rag.llm_model_kwargs["system_prompt"] = request.system
response = await self.rag.llm_model_func(
cleaned_query,
stream=True,
history_messages=conversation_history,
**self.rag.llm_model_kwargs,
)
else:
response = await self.rag.aquery(
cleaned_query, param=query_param
)
async def stream_generator():
first_chunk_time = None
last_chunk_time = time.time_ns()
total_response = ""
# Ensure response is an async generator
if isinstance(response, str):
# If it's a string, send in two parts
first_chunk_time = start_time
last_chunk_time = time.time_ns()
total_response = response
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"message": {
"role": "assistant",
"content": response,
"images": None,
},
"done": False,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
completion_tokens = estimate_tokens(total_response)
total_time = last_chunk_time - start_time
prompt_eval_time = first_chunk_time - start_time
eval_time = last_chunk_time - first_chunk_time
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"message": {
"role": "assistant",
"content": "",
"images": None,
},
"done_reason": "stop",
"done": True,
"total_duration": total_time,
"load_duration": 0,
"prompt_eval_count": prompt_tokens,
"prompt_eval_duration": prompt_eval_time,
"eval_count": completion_tokens,
"eval_duration": eval_time,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
else:
try:
async for chunk in response:
if chunk:
if first_chunk_time is None:
first_chunk_time = time.time_ns()
last_chunk_time = time.time_ns()
total_response += chunk
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"message": {
"role": "assistant",
"content": chunk,
"images": None,
},
"done": False,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
except (asyncio.CancelledError, Exception) as e:
error_msg = str(e)
if isinstance(e, asyncio.CancelledError):
error_msg = "Stream was cancelled by server"
else:
error_msg = f"Provider error: {error_msg}"
logger.error(f"Stream error: {error_msg}")
# Send error message to client
error_data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"message": {
"role": "assistant",
"content": f"\n\nError: {error_msg}",
"images": None,
},
"error": f"\n\nError: {error_msg}",
"done": False,
}
yield f"{json.dumps(error_data, ensure_ascii=False)}\n"
# Send final message to close the stream
final_data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"message": {
"role": "assistant",
"content": "",
"images": None,
},
"done": True,
}
yield f"{json.dumps(final_data, ensure_ascii=False)}\n"
return
if first_chunk_time is None:
first_chunk_time = start_time
completion_tokens = estimate_tokens(total_response)
total_time = last_chunk_time - start_time
prompt_eval_time = first_chunk_time - start_time
eval_time = last_chunk_time - first_chunk_time
data = {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"message": {
"role": "assistant",
"content": "",
"images": None,
},
"done_reason": "stop",
"done": True,
"total_duration": total_time,
"load_duration": 0,
"prompt_eval_count": prompt_tokens,
"prompt_eval_duration": prompt_eval_time,
"eval_count": completion_tokens,
"eval_duration": eval_time,
}
yield f"{json.dumps(data, ensure_ascii=False)}\n"
return StreamingResponse(
stream_generator(),
media_type="application/x-ndjson",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Content-Type": "application/x-ndjson",
"X-Accel-Buffering": "no", # Ensure proper handling of streaming responses in Nginx proxy
},
)
else:
first_chunk_time = time.time_ns()
# Determine if the request is prefix with "/bypass" or from Open WebUI's session title and session keyword generation task
match_result = re.search(
r"\n<chat_history>\nUSER:", cleaned_query, re.MULTILINE
)
if match_result or mode == SearchMode.bypass:
if request.system:
self.rag.llm_model_kwargs["system_prompt"] = request.system
response_text = await self.rag.llm_model_func(
cleaned_query,
stream=False,
history_messages=conversation_history,
**self.rag.llm_model_kwargs,
)
else:
response_text = await self.rag.aquery(
cleaned_query, param=query_param
)
last_chunk_time = time.time_ns()
if not response_text:
response_text = "No response generated"
completion_tokens = estimate_tokens(str(response_text))
total_time = last_chunk_time - start_time
prompt_eval_time = first_chunk_time - start_time
eval_time = last_chunk_time - first_chunk_time
return {
"model": self.ollama_server_infos.LIGHTRAG_MODEL,
"created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT,
"message": {
"role": "assistant",
"content": str(response_text),
"images": None,
},
"done_reason": "stop",
"done": True,
"total_duration": total_time,
"load_duration": 0,
"prompt_eval_count": prompt_tokens,
"prompt_eval_duration": prompt_eval_time,
"eval_count": completion_tokens,
"eval_duration": eval_time,
}
except Exception as e:
logger.error(f"Ollama chat error: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,282 @@
#!/usr/bin/env python
"""
Start LightRAG server with Gunicorn
"""
import os
import sys
import platform
import pipmaster as pm
from lightrag.api.utils_api import display_splash_screen, check_env_file
from lightrag.api.config import global_args
from lightrag.utils import get_env_value
from lightrag.kg.shared_storage import initialize_share_data
from lightrag.constants import (
DEFAULT_WOKERS,
DEFAULT_TIMEOUT,
)
def check_and_install_dependencies():
"""Check and install required dependencies"""
required_packages = [
"gunicorn",
"tiktoken",
"psutil",
# Add other required packages here
]
for package in required_packages:
if not pm.is_installed(package):
print(f"Installing {package}...")
pm.install(package)
print(f"{package} installed successfully")
def main():
# Explicitly initialize configuration for Gunicorn mode
from lightrag.api.config import initialize_config
initialize_config()
# Set Gunicorn mode flag for lifespan cleanup detection
os.environ["LIGHTRAG_GUNICORN_MODE"] = "1"
# Check .env file
if not check_env_file():
sys.exit(1)
# Check DOCLING compatibility with Gunicorn multi-worker mode on macOS
if (
platform.system() == "Darwin"
and global_args.document_loading_engine == "DOCLING"
and global_args.workers > 1
):
print("\n" + "=" * 80)
print("❌ ERROR: Incompatible configuration detected!")
print("=" * 80)
print(
"\nDOCLING engine with Gunicorn multi-worker mode is not supported on macOS"
)
print("\nReason:")
print(" PyTorch (required by DOCLING) has known compatibility issues with")
print(" fork-based multiprocessing on macOS, which can cause crashes or")
print(" unexpected behavior when using Gunicorn with multiple workers.")
print("\nCurrent configuration:")
print(" - Operating System: macOS (Darwin)")
print(f" - Document Engine: {global_args.document_loading_engine}")
print(f" - Workers: {global_args.workers}")
print("\nPossible solutions:")
print(" 1. Use single worker mode:")
print(" --workers 1")
print("\n 2. Change document loading engine in .env:")
print(" DOCUMENT_LOADING_ENGINE=DEFAULT")
print("\n 3. Deploy on Linux where multi-worker mode is fully supported")
print("=" * 80 + "\n")
sys.exit(1)
# Check macOS fork safety environment variable for multi-worker mode
if (
platform.system() == "Darwin"
and global_args.workers > 1
and os.environ.get("OBJC_DISABLE_INITIALIZE_FORK_SAFETY") != "YES"
):
print("\n" + "=" * 80)
print("❌ ERROR: Missing required environment variable on macOS!")
print("=" * 80)
print("\nmacOS with Gunicorn multi-worker mode requires:")
print(" OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES")
print("\nReason:")
print(" NumPy uses macOS's Accelerate framework (Objective-C based) for")
print(" vector computations. The Objective-C runtime has fork safety checks")
print(" that will crash worker processes when embedding functions are called.")
print("\nCurrent configuration:")
print(" - Operating System: macOS (Darwin)")
print(f" - Workers: {global_args.workers}")
print(
f" - Environment Variable: {os.environ.get('OBJC_DISABLE_INITIALIZE_FORK_SAFETY', 'NOT SET')}"
)
print("\nHow to fix:")
print(" Option 1 - Set environment variable before starting (recommended):")
print(" export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES")
print(" lightrag-gunicorn --workers 2")
print("\n Option 2 - Add to your shell profile (~/.zshrc or ~/.bash_profile):")
print(" echo 'export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES' >> ~/.zshrc")
print(" source ~/.zshrc")
print("\n Option 3 - Use single worker mode (no multiprocessing):")
print(" lightrag-server --workers 1")
print("=" * 80 + "\n")
sys.exit(1)
# Check and install dependencies
check_and_install_dependencies()
# Note: Signal handlers are NOT registered here because:
# - Master cleanup already handled by gunicorn_config.on_exit()
# Display startup information
display_splash_screen(global_args)
print("🚀 Starting LightRAG with Gunicorn")
print(f"🔄 Worker management: Gunicorn (workers={global_args.workers})")
print("🔍 Preloading app: Enabled")
print("📝 Note: Using Gunicorn's preload feature for shared data initialization")
print("\n\n" + "=" * 80)
print("MAIN PROCESS INITIALIZATION")
print(f"Process ID: {os.getpid()}")
print(f"Workers setting: {global_args.workers}")
print("=" * 80 + "\n")
# Import Gunicorn's StandaloneApplication
from gunicorn.app.base import BaseApplication
# Define a custom application class that loads our config
class GunicornApp(BaseApplication):
def __init__(self, app, options=None):
self.options = options or {}
self.application = app
super().__init__()
def load_config(self):
# Define valid Gunicorn configuration options
valid_options = {
"bind",
"workers",
"worker_class",
"timeout",
"keepalive",
"preload_app",
"errorlog",
"accesslog",
"loglevel",
"certfile",
"keyfile",
"limit_request_line",
"limit_request_fields",
"limit_request_field_size",
"graceful_timeout",
"max_requests",
"max_requests_jitter",
}
# Special hooks that need to be set separately
special_hooks = {
"on_starting",
"on_reload",
"on_exit",
"pre_fork",
"post_fork",
"pre_exec",
"pre_request",
"post_request",
"worker_init",
"worker_exit",
"nworkers_changed",
"child_exit",
}
# Import and configure the gunicorn_config module
from lightrag.api import gunicorn_config
# Set configuration variables in gunicorn_config, prioritizing command line arguments
gunicorn_config.workers = (
global_args.workers
if global_args.workers
else get_env_value("WORKERS", DEFAULT_WOKERS, int)
)
# Bind configuration prioritizes command line arguments
host = (
global_args.host
if global_args.host != "0.0.0.0"
else os.getenv("HOST", "0.0.0.0")
)
port = (
global_args.port
if global_args.port != 9621
else get_env_value("PORT", 9621, int)
)
gunicorn_config.bind = f"{host}:{port}"
# Log level configuration prioritizes command line arguments
gunicorn_config.loglevel = (
global_args.log_level.lower()
if global_args.log_level
else os.getenv("LOG_LEVEL", "info")
)
# Timeout configuration prioritizes command line arguments
gunicorn_config.timeout = (
global_args.timeout + 30
if global_args.timeout is not None
else get_env_value(
"TIMEOUT", DEFAULT_TIMEOUT + 30, int, special_none=True
)
)
# Keepalive configuration
gunicorn_config.keepalive = get_env_value("KEEPALIVE", 5, int)
# SSL configuration prioritizes command line arguments
if global_args.ssl or os.getenv("SSL", "").lower() in (
"true",
"1",
"yes",
"t",
"on",
):
gunicorn_config.certfile = (
global_args.ssl_certfile
if global_args.ssl_certfile
else os.getenv("SSL_CERTFILE")
)
gunicorn_config.keyfile = (
global_args.ssl_keyfile
if global_args.ssl_keyfile
else os.getenv("SSL_KEYFILE")
)
# Set configuration options from the module
for key in dir(gunicorn_config):
if key in valid_options:
value = getattr(gunicorn_config, key)
# Skip functions like on_starting and None values
if not callable(value) and value is not None:
self.cfg.set(key, value)
# Set special hooks
elif key in special_hooks:
value = getattr(gunicorn_config, key)
if callable(value):
self.cfg.set(key, value)
if hasattr(gunicorn_config, "logconfig_dict"):
self.cfg.set(
"logconfig_dict", getattr(gunicorn_config, "logconfig_dict")
)
def load(self):
# Import the application
from lightrag.api.lightrag_server import get_application
return get_application(global_args)
# Create the application
app = GunicornApp("")
# Force workers to be an integer and greater than 1 for multi-process mode
workers_count = global_args.workers
if workers_count > 1:
# Set a flag to indicate we're in the main process
os.environ["LIGHTRAG_MAIN_PROCESS"] = "1"
initialize_share_data(workers_count)
else:
initialize_share_data(1)
# Run the application
print("\nStarting Gunicorn with direct Python API...")
app.run()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,128 @@
"""Helpers for validating startup runtime expectations from `.env`."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import dotenv_values
_CONTAINER_RUNTIME_TARGETS = {"compose", "docker"}
@dataclass(frozen=True)
class RuntimeEnvironment:
"""Describes whether the current process is running in a container runtime."""
in_container: bool
in_docker: bool
in_kubernetes: bool
@property
def label(self) -> str:
if self.in_kubernetes:
return "Kubernetes"
if self.in_docker:
return "Docker"
return "host"
def _read_cgroup_content() -> str:
"""Best-effort read of cgroup metadata for container detection."""
for candidate in ("/proc/1/cgroup", "/proc/self/cgroup"):
try:
return Path(candidate).read_text(encoding="utf-8")
except OSError:
continue
return ""
def detect_runtime_environment(
environ: dict[str, str] | None = None,
) -> RuntimeEnvironment:
"""Detect whether the current process is running on host, Docker, or Kubernetes."""
environ = environ or os.environ
cgroup_content = _read_cgroup_content().lower()
in_kubernetes = bool(
environ.get("KUBERNETES_SERVICE_HOST")
or Path("/var/run/secrets/kubernetes.io/serviceaccount").exists()
or "kubepods" in cgroup_content
or "kubernetes" in cgroup_content
)
in_docker = bool(
Path("/.dockerenv").exists()
or Path("/run/.containerenv").exists()
or any(
marker in cgroup_content
for marker in ("docker", "containerd", "libpod", "podman")
)
)
return RuntimeEnvironment(
in_container=in_kubernetes or in_docker,
in_docker=in_docker,
in_kubernetes=in_kubernetes,
)
def load_runtime_target_from_env_file(env_path: str | Path = ".env") -> str | None:
"""Return the raw LIGHTRAG_RUNTIME_TARGET value from the `.env` file, if present."""
env_values = dotenv_values(str(env_path))
runtime_target = env_values.get("LIGHTRAG_RUNTIME_TARGET")
if runtime_target is None:
return None
return runtime_target.strip()
def validate_runtime_target(
runtime_target: str | None,
runtime_environment: RuntimeEnvironment | None = None,
) -> tuple[bool, str | None]:
"""Validate `.env` runtime target against the current runtime environment."""
if runtime_target is None:
return True, None
normalized_target = runtime_target.strip().lower()
runtime_environment = runtime_environment or detect_runtime_environment()
if normalized_target == "host":
if runtime_environment.in_container:
return (
False,
"Configuration error in .env: LIGHTRAG_RUNTIME_TARGET=host.\n"
"This value from .env requires the server process to run on the host, "
f"but the current process is running inside {runtime_environment.label}.",
)
return True, None
if normalized_target in _CONTAINER_RUNTIME_TARGETS:
if runtime_environment.in_container:
return True, None
return (
False,
f"Configuration error in .env: LIGHTRAG_RUNTIME_TARGET={runtime_target}.\n"
"This value from .env requires the server process to run inside Docker or "
"Kubernetes, but the current process is running on the host.",
)
return (
False,
f"Configuration error in .env: LIGHTRAG_RUNTIME_TARGET={runtime_target!r}.\n"
"This value from .env must be 'host' or 'compose' (alias: 'docker').",
)
def validate_runtime_target_from_env_file(
env_path: str | Path = ".env",
runtime_environment: RuntimeEnvironment | None = None,
) -> tuple[bool, str | None]:
"""Load LIGHTRAG_RUNTIME_TARGET from `.env` and validate it if declared."""
runtime_target = load_runtime_target_from_env_file(env_path)
return validate_runtime_target(runtime_target, runtime_environment)

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,451 @@
"""
Utility functions for the LightRAG API.
"""
import os
import argparse
from typing import Optional, List, Tuple
import sys
import time
import logging
from ascii_colors import ASCIIColors
from .._version import __api_version__ as api_version
from .._version import __version__ as core_version
from lightrag.constants import (
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,
)
from lightrag.api.runtime_validation import validate_runtime_target_from_env_file
from fastapi import HTTPException, Security, Request, Response, status
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
from starlette.status import HTTP_403_FORBIDDEN
from .auth import auth_handler
from .config import ollama_server_infos, global_args, get_env_value
logger = logging.getLogger("lightrag")
# ========== Token Renewal Rate Limiting ==========
# Cache to track last renewal time per user (username as key)
# Format: {username: last_renewal_timestamp}
_token_renewal_cache: dict[str, float] = {}
_RENEWAL_MIN_INTERVAL = 60 # Minimum 60 seconds between renewals for same user
# ========== Token Renewal Path Exclusions ==========
# Paths that should NOT trigger token auto-renewal
# - /health: Health check endpoint, no login required
# - /documents/paginated: Client polls this frequently (5-30s), renewal not needed
# - /documents/pipeline_status: Client polls this very frequently (2s), renewal not needed
_TOKEN_RENEWAL_SKIP_PATHS = [
"/health",
"/documents/paginated",
"/documents/pipeline_status",
]
def check_env_file():
"""
Check if .env file exists and handle user confirmation if needed.
Returns True if should continue, False if should exit.
"""
env_path = ".env"
if not os.path.exists(env_path):
warning_msg = "Warning: Startup directory must contain .env file for multi-instance support."
ASCIIColors.yellow(warning_msg)
# Check if running in interactive terminal
if sys.stdin.isatty():
response = input("Do you want to continue? (yes/NO): ")
if response.lower() != "yes":
ASCIIColors.red("Server startup cancelled")
return False
return True
is_valid, error_message = validate_runtime_target_from_env_file(env_path)
if not is_valid:
for line in error_message.splitlines():
ASCIIColors.red(line)
return False
return True
# Get whitelist paths from global_args, only once during initialization
whitelist_paths = global_args.whitelist_paths.split(",")
# Pre-compile path matching patterns
whitelist_patterns: List[Tuple[str, bool]] = []
for path in whitelist_paths:
path = path.strip()
if path:
# If path ends with /*, match all paths with that prefix
if path.endswith("/*"):
prefix = path[:-2]
whitelist_patterns.append((prefix, True)) # (prefix, is_prefix_match)
else:
whitelist_patterns.append((path, False)) # (exact_path, is_prefix_match)
# Global authentication configuration
auth_configured = bool(auth_handler.accounts)
def get_combined_auth_dependency(api_key: Optional[str] = None):
"""
Create a combined authentication dependency that implements authentication logic
based on API key, OAuth2 token, and whitelist paths.
Args:
api_key (Optional[str]): API key for validation
Returns:
Callable: A dependency function that implements the authentication logic
"""
# Use global whitelist_patterns and auth_configured variables
# whitelist_patterns and auth_configured are already initialized at module level
# Only calculate api_key_configured as it depends on the function parameter
api_key_configured = bool(api_key)
# Create security dependencies with proper descriptions for Swagger UI
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="login", auto_error=False, description="OAuth2 Password Authentication"
)
# If API key is configured, create an API key header security
api_key_header = None
if api_key_configured:
api_key_header = APIKeyHeader(
name="X-API-Key", auto_error=False, description="API Key Authentication"
)
async def combined_dependency(
request: Request,
response: Response, # Added: needed to return new token via response header
token: str = Security(oauth2_scheme),
api_key_header_value: Optional[str] = None
if api_key_header is None
else Security(api_key_header),
):
# 1. Check if path is in whitelist
path = request.url.path
for pattern, is_prefix in whitelist_patterns:
if (is_prefix and path.startswith(pattern)) or (
not is_prefix and path == pattern
):
return # Whitelist path, allow access
# 2. Validate token first if provided in the request (Ensure 401 error if token is invalid)
if token:
try:
token_info = auth_handler.validate_token(token)
# ========== Token Auto-Renewal Logic ==========
from lightrag.api.config import global_args
from datetime import datetime, timezone
if global_args.token_auto_renew:
# Check if current path should skip token renewal
skip_renewal = any(
path == skip_path or path.startswith(skip_path + "/")
for skip_path in _TOKEN_RENEWAL_SKIP_PATHS
)
if skip_renewal:
logger.debug(f"Token auto-renewal skipped for path: {path}")
else:
try:
expire_time = token_info.get("exp")
if expire_time:
# Calculate remaining time ratio
now = datetime.now(timezone.utc)
remaining_seconds = (expire_time - now).total_seconds()
# Get original token expiration duration
role = token_info.get("role", "user")
total_hours = (
auth_handler.guest_expire_hours
if role == "guest"
else auth_handler.expire_hours
)
total_seconds = total_hours * 3600
# Issue new token if remaining time < threshold
if (
remaining_seconds
< total_seconds * global_args.token_renew_threshold
):
# ========== Rate Limiting Check ==========
username = token_info["username"]
current_time = time.time()
last_renewal = _token_renewal_cache.get(username, 0)
time_since_last_renewal = (
current_time - last_renewal
)
# Only renew if enough time has passed since last renewal
if time_since_last_renewal >= _RENEWAL_MIN_INTERVAL:
new_token = auth_handler.create_token(
username=username,
role=role,
metadata=token_info.get("metadata", {}),
)
# Return new token via response header
response.headers["X-New-Token"] = new_token
# Update renewal cache
_token_renewal_cache[username] = current_time
# Optional: log renewal
logger.info(
f"Token auto-renewed for user {username} "
f"(role: {role}, remaining: {remaining_seconds:.0f}s)"
)
else:
# Log skip due to rate limit
logger.debug(
f"Token renewal skipped for {username} "
f"(rate limit: last renewal {time_since_last_renewal:.0f}s ago)"
)
# ========== End of Rate Limiting Check ==========
except Exception as e:
# Renewal failure should not affect normal request, just log
logger.warning(f"Token auto-renew failed: {e}")
# ========== End of Token Auto-Renewal Logic ==========
# Accept guest token if no auth is configured
if not auth_configured and token_info.get("role") == "guest":
return
# Accept non-guest token if auth is configured
if auth_configured and token_info.get("role") != "guest":
return
# Token validation failed, immediately return 401 error
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token. Please login again.",
)
except HTTPException as e:
# If already a 401 error, re-raise it
if e.status_code == status.HTTP_401_UNAUTHORIZED:
raise
# For other exceptions, continue processing
# 3. Acept all request if no API protection needed
if not auth_configured and not api_key_configured:
return
# 4. Validate API key if provided and API-Key authentication is configured
if (
api_key_configured
and api_key_header_value
and api_key_header_value == api_key
):
return # API key validation successful
### Authentication failed ####
# if password authentication is configured but not provided, ensure 401 error if auth_configured
if auth_configured and not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No credentials provided. Please login.",
)
# if api key is provided but validation failed
if api_key_header_value:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail="Invalid API Key",
)
# if api_key_configured but not provided
if api_key_configured and not api_key_header_value:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail="API Key required",
)
# Otherwise: refuse access and return 403 error
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail="API Key required or login authentication required.",
)
return combined_dependency
def display_splash_screen(args: argparse.Namespace) -> None:
"""
Display a colorful splash screen showing LightRAG server configuration
Args:
args: Parsed command line arguments
"""
# Banner
# Banner
top_border = "╔══════════════════════════════════════════════════════════════╗"
bottom_border = "╚══════════════════════════════════════════════════════════════╝"
width = len(top_border) - 4 # width inside the borders
line1_text = f"LightRAG Server v{core_version}/{api_version}"
line2_text = "Fast, Lightweight RAG Server Implementation"
line1 = f"{line1_text.center(width)}"
line2 = f"{line2_text.center(width)}"
banner = f"""
{top_border}
{line1}
{line2}
{bottom_border}
"""
ASCIIColors.cyan(banner)
# Server Configuration
ASCIIColors.magenta("\n📡 Server Configuration:")
ASCIIColors.white(" ├─ Host: ", end="")
ASCIIColors.yellow(f"{args.host}")
ASCIIColors.white(" ├─ Port: ", end="")
ASCIIColors.yellow(f"{args.port}")
ASCIIColors.white(" ├─ Workers: ", end="")
ASCIIColors.yellow(f"{args.workers}")
ASCIIColors.white(" ├─ Timeout: ", end="")
ASCIIColors.yellow(f"{args.timeout}")
ASCIIColors.white(" ├─ CORS Origins: ", end="")
ASCIIColors.yellow(f"{args.cors_origins}")
ASCIIColors.white(" ├─ SSL Enabled: ", end="")
ASCIIColors.yellow(f"{args.ssl}")
if args.ssl:
ASCIIColors.white(" ├─ SSL Cert: ", end="")
ASCIIColors.yellow(f"{args.ssl_certfile}")
ASCIIColors.white(" ├─ SSL Key: ", end="")
ASCIIColors.yellow(f"{args.ssl_keyfile}")
ASCIIColors.white(" ├─ Ollama Emulating Model: ", end="")
ASCIIColors.yellow(f"{ollama_server_infos.LIGHTRAG_MODEL}")
ASCIIColors.white(" ├─ Log Level: ", end="")
ASCIIColors.yellow(f"{args.log_level}")
ASCIIColors.white(" ├─ Verbose Debug: ", end="")
ASCIIColors.yellow(f"{args.verbose}")
ASCIIColors.white(" ├─ API Key: ", end="")
ASCIIColors.yellow("Set" if args.key else "Not Set")
ASCIIColors.white(" └─ JWT Auth: ", end="")
ASCIIColors.yellow("Enabled" if args.auth_accounts else "Disabled")
# Directory Configuration
ASCIIColors.magenta("\n📂 Directory Configuration:")
ASCIIColors.white(" ├─ Working Directory: ", end="")
ASCIIColors.yellow(f"{args.working_dir}")
ASCIIColors.white(" └─ Input Directory: ", end="")
ASCIIColors.yellow(f"{args.input_dir}")
# LLM Configuration
ASCIIColors.magenta("\n🤖 LLM Configuration:")
ASCIIColors.white(" ├─ Binding: ", end="")
ASCIIColors.yellow(f"{args.llm_binding}")
ASCIIColors.white(" ├─ Host: ", end="")
ASCIIColors.yellow(f"{args.llm_binding_host}")
ASCIIColors.white(" ├─ Model: ", end="")
ASCIIColors.yellow(f"{args.llm_model}")
ASCIIColors.white(" ├─ Max Async for LLM: ", end="")
ASCIIColors.yellow(f"{args.max_async}")
ASCIIColors.white(" ├─ Summary Context Size: ", end="")
ASCIIColors.yellow(f"{args.summary_context_size}")
ASCIIColors.white(" ├─ LLM Cache Enabled: ", end="")
ASCIIColors.yellow(f"{args.enable_llm_cache}")
ASCIIColors.white(" └─ LLM Cache for Extraction Enabled: ", end="")
ASCIIColors.yellow(f"{args.enable_llm_cache_for_extract}")
# Embedding Configuration
ASCIIColors.magenta("\n📊 Embedding Configuration:")
ASCIIColors.white(" ├─ Binding: ", end="")
ASCIIColors.yellow(f"{args.embedding_binding}")
ASCIIColors.white(" ├─ Host: ", end="")
ASCIIColors.yellow(f"{args.embedding_binding_host}")
ASCIIColors.white(" ├─ Model: ", end="")
ASCIIColors.yellow(f"{args.embedding_model}")
ASCIIColors.white(" ├─ Dimensions: ", end="")
ASCIIColors.yellow(f"{args.embedding_dim}")
ASCIIColors.white(" └─ Asymmetric: ", end="")
ASCIIColors.yellow(f"{args.embedding_asymmetric}")
# RAG Configuration
ASCIIColors.magenta("\n⚙️ RAG Configuration:")
ASCIIColors.white(" ├─ Summary Language: ", end="")
ASCIIColors.yellow(f"{args.summary_language}")
ASCIIColors.white(" ├─ Entity Types: ", end="")
ASCIIColors.yellow(f"{args.entity_types}")
ASCIIColors.white(" ├─ Max Parallel Insert: ", end="")
ASCIIColors.yellow(f"{args.max_parallel_insert}")
ASCIIColors.white(" ├─ Chunk Size: ", end="")
ASCIIColors.yellow(f"{args.chunk_size}")
ASCIIColors.white(" ├─ Chunk Overlap Size: ", end="")
ASCIIColors.yellow(f"{args.chunk_overlap_size}")
ASCIIColors.white(" ├─ Cosine Threshold: ", end="")
ASCIIColors.yellow(f"{args.cosine_threshold}")
ASCIIColors.white(" ├─ Top-K: ", end="")
ASCIIColors.yellow(f"{args.top_k}")
ASCIIColors.white(" └─ Force LLM Summary on Merge: ", end="")
ASCIIColors.yellow(
f"{get_env_value('FORCE_LLM_SUMMARY_ON_MERGE', DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE, int)}"
)
# System Configuration
ASCIIColors.magenta("\n💾 Storage Configuration:")
ASCIIColors.white(" ├─ KV Storage: ", end="")
ASCIIColors.yellow(f"{args.kv_storage}")
ASCIIColors.white(" ├─ Vector Storage: ", end="")
ASCIIColors.yellow(f"{args.vector_storage}")
ASCIIColors.white(" ├─ Graph Storage: ", end="")
ASCIIColors.yellow(f"{args.graph_storage}")
ASCIIColors.white(" ├─ Document Status Storage: ", end="")
ASCIIColors.yellow(f"{args.doc_status_storage}")
ASCIIColors.white(" └─ Workspace: ", end="")
ASCIIColors.yellow(f"{args.workspace if args.workspace else '-'}")
# Server Status
ASCIIColors.green("\n✨ Server starting up...\n")
# Server Access Information
protocol = "https" if args.ssl else "http"
if args.host == "0.0.0.0":
ASCIIColors.magenta("\n🌐 Server Access Information:")
ASCIIColors.white(" ├─ WebUI (local): ", end="")
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}")
ASCIIColors.white(" ├─ Remote Access: ", end="")
ASCIIColors.yellow(f"{protocol}://<your-ip-address>:{args.port}")
ASCIIColors.white(" ├─ API Documentation (local): ", end="")
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}/docs")
ASCIIColors.white(" └─ Alternative Documentation (local): ", end="")
ASCIIColors.yellow(f"{protocol}://localhost:{args.port}/redoc")
ASCIIColors.magenta("\n📝 Note:")
ASCIIColors.cyan(""" Since the server is running on 0.0.0.0:
- Use 'localhost' or '127.0.0.1' for local access
- Use your machine's IP address for remote access
- To find your IP address:
• Windows: Run 'ipconfig' in terminal
• Linux/Mac: Run 'ifconfig' or 'ip addr' in terminal
""")
else:
base_url = f"{protocol}://{args.host}:{args.port}"
ASCIIColors.magenta("\n🌐 Server Access Information:")
ASCIIColors.white(" ├─ WebUI (local): ", end="")
ASCIIColors.yellow(f"{base_url}")
ASCIIColors.white(" ├─ API Documentation: ", end="")
ASCIIColors.yellow(f"{base_url}/docs")
ASCIIColors.white(" └─ Alternative Documentation: ", end="")
ASCIIColors.yellow(f"{base_url}/redoc")
# Security Notice
if args.key:
ASCIIColors.yellow("\n⚠️ Security Notice:")
ASCIIColors.white(""" API Key authentication is enabled.
Make sure to include the X-API-Key header in all your requests.
""")
if args.auth_accounts:
ASCIIColors.yellow("\n⚠️ Security Notice:")
ASCIIColors.white(""" JWT authentication is enabled.
Make sure to login before making the request, and include the 'Authorization' in the header.
""")
# Ensure splash output flush to system log
sys.stdout.flush()

View File

@@ -0,0 +1 @@
import{H as e,R as t,S as n,U as r,b as i,c as a,f as o,n as s,r as c,u as l,v as u}from"./_createAssigner-CdflnHPZ.js";import{A as d,C as f,D as p,M as m,N as h,S as g,T as _,f as v,h as y,u as b,w as x}from"./_baseUniq-CSNgIvS9.js";var S=/\s/;function C(e){for(var t=e.length;t--&&S.test(e.charAt(t)););return t}var w=/^\s+/;function T(e){return e&&e.slice(0,C(e)+1).replace(w,``)}var E=NaN,D=/^[-+]0x[0-9a-f]+$/i,O=/^0b[01]+$/i,k=/^0o[0-7]+$/i,A=parseInt;function j(t){if(typeof t==`number`)return t;if(h(t))return E;if(e(t)){var n=typeof t.valueOf==`function`?t.valueOf():t;t=e(n)?n+``:n}if(typeof t!=`string`)return t===0?t:+t;t=T(t);var r=O.test(t);return r||k.test(t)?A(t.slice(2),r?2:8):D.test(t)?E:+t}var M=1/0,N=17976931348623157e292;function P(e){return e?(e=j(e),e===M||e===-M?(e<0?-1:1)*N:e===e?e:0):e===0?e:0}function F(e){var t=P(e),n=t%1;return t===t?n?t-n:t:0}function I(e){return e!=null&&e.length?g(e,1):[]}var L=Object.prototype,R=L.hasOwnProperty,z=c(function(e,n){e=Object(e);var r=-1,i=n.length,o=i>2?n[2]:void 0;for(o&&s(n[0],n[1],o)&&(i=1);++r<i;)for(var c=n[r],l=a(c),u=-1,d=l.length;++u<d;){var f=l[u],p=e[f];(p===void 0||t(p,L[f])&&!R.call(e,f))&&(e[f]=c[f])}return e});function B(e){var t=e==null?0:e.length;return t?e[t-1]:void 0}function V(e){return function(t,n,r){var i=Object(t);if(!u(t)){var a=v(n,3);t=p(t),n=function(e){return a(i[e],e,i)}}var o=e(t,n,r);return o>-1?i[a?t[o]:o]:void 0}}var H=Math.max;function U(e,t,n){var r=e==null?0:e.length;if(!r)return-1;var i=n==null?0:F(n);return i<0&&(i=H(r+i,0)),d(e,v(t,3),i)}var W=V(U);function G(e,t){var n=-1,r=u(e)?Array(e.length):[];return b(e,function(e,i,a){r[++n]=t(e,i,a)}),r}function K(e,t){return(i(e)?m:G)(e,v(t,3))}var q=Object.prototype.hasOwnProperty;function J(e,t){return e!=null&&q.call(e,t)}function Y(e,t){return e!=null&&y(e,t,J)}var X=`[object String]`;function Z(e){return typeof e==`string`||!i(e)&&n(e)&&r(e)==X}function Q(t,n,r,i){if(!e(t))return t;n=_(n,t);for(var a=-1,s=n.length,c=s-1,u=t;u!=null&&++a<s;){var d=x(n[a]),f=r;if(d===`__proto__`||d===`constructor`||d===`prototype`)return t;if(a!=c){var p=u[d];f=i?i(p,d,u):void 0,f===void 0&&(f=e(p)?p:l(n[a+1])?[]:{})}o(u,d,f),u=u[d]}return t}function $(e,t,n){for(var r=-1,i=t.length,a={};++r<i;){var o=t[r],s=f(e,o);n(s,o)&&Q(a,_(o,e),s)}return a}export{G as a,z as c,P as d,K as i,I as l,Z as n,W as o,Y as r,B as s,$ as t,F as u};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{n as e,t}from"./path-BmDdnQs8.js";import{$ as n,Q as r,X as i,Y as a,Z as o,at as s,it as c,nt as l,ot as u,rt as d,st as f,tt as p}from"./index-Cmfh6eB3.js";function m(e){return e.innerRadius}function h(e){return e.outerRadius}function g(e){return e.startAngle}function _(e){return e.endAngle}function v(e){return e&&e.padAngle}function y(e,t,n,r,i,a,o,s){var c=n-e,l=r-t,u=o-i,d=s-a,f=d*c-u*l;if(!(f*f<1e-12))return f=(u*(t-a)-d*(e-i))/f,[e+f*c,t+f*l]}function b(e,t,n,r,i,a,o){var s=e-n,c=t-r,d=(o?a:-a)/u(s*s+c*c),f=d*c,p=-d*s,m=e+f,h=t+p,g=n+f,_=r+p,v=(m+g)/2,y=(h+_)/2,b=g-m,x=_-h,S=b*b+x*x,C=i-a,w=m*_-g*h,T=(x<0?-1:1)*u(l(0,C*C*S-w*w)),E=(w*x-b*T)/S,D=(-w*b-x*T)/S,O=(w*x+b*T)/S,k=(-w*b+x*T)/S,A=E-v,j=D-y,M=O-v,N=k-y;return A*A+j*j>M*M+N*N&&(E=O,D=k),{cx:E,cy:D,x01:-f,y01:-p,x11:E*(i/C-1),y11:D*(i/C-1)}}function x(){var l=m,x=h,S=e(0),C=null,w=g,T=_,E=v,D=null,O=t(k);function k(){var e,t,m=+l.apply(this,arguments),h=+x.apply(this,arguments),g=w.apply(this,arguments)-p,_=T.apply(this,arguments)-p,v=a(_-g),k=_>g;if(D||=e=O(),h<m&&(t=h,h=m,m=t),!(h>1e-12))D.moveTo(0,0);else if(v>f-1e-12)D.moveTo(h*n(g),h*s(g)),D.arc(0,0,h,g,_,!k),m>1e-12&&(D.moveTo(m*n(_),m*s(_)),D.arc(0,0,m,_,g,k));else{var A=g,j=_,M=g,N=_,P=v,F=v,I=E.apply(this,arguments)/2,L=I>1e-12&&(C?+C.apply(this,arguments):u(m*m+h*h)),R=d(a(h-m)/2,+S.apply(this,arguments)),z=R,B=R,V,H;if(L>1e-12){var U=o(L/m*s(I)),W=o(L/h*s(I));(P-=U*2)>1e-12?(U*=k?1:-1,M+=U,N-=U):(P=0,M=N=(g+_)/2),(F-=W*2)>1e-12?(W*=k?1:-1,A+=W,j-=W):(F=0,A=j=(g+_)/2)}var G=h*n(A),K=h*s(A),q=m*n(N),J=m*s(N);if(R>1e-12){var Y=h*n(j),X=h*s(j),Z=m*n(M),Q=m*s(M),$;if(v<c)if($=y(G,K,Z,Q,Y,X,q,J)){var ee=G-$[0],te=K-$[1],ne=Y-$[0],re=X-$[1],ie=1/s(i((ee*ne+te*re)/(u(ee*ee+te*te)*u(ne*ne+re*re)))/2),ae=u($[0]*$[0]+$[1]*$[1]);z=d(R,(m-ae)/(ie-1)),B=d(R,(h-ae)/(ie+1))}else z=B=0}F>1e-12?B>1e-12?(V=b(Z,Q,G,K,h,B,k),H=b(Y,X,q,J,h,B,k),D.moveTo(V.cx+V.x01,V.cy+V.y01),B<R?D.arc(V.cx,V.cy,B,r(V.y01,V.x01),r(H.y01,H.x01),!k):(D.arc(V.cx,V.cy,B,r(V.y01,V.x01),r(V.y11,V.x11),!k),D.arc(0,0,h,r(V.cy+V.y11,V.cx+V.x11),r(H.cy+H.y11,H.cx+H.x11),!k),D.arc(H.cx,H.cy,B,r(H.y11,H.x11),r(H.y01,H.x01),!k))):(D.moveTo(G,K),D.arc(0,0,h,A,j,!k)):D.moveTo(G,K),!(m>1e-12)||!(P>1e-12)?D.lineTo(q,J):z>1e-12?(V=b(q,J,Y,X,m,-z,k),H=b(G,K,Z,Q,m,-z,k),D.lineTo(V.cx+V.x01,V.cy+V.y01),z<R?D.arc(V.cx,V.cy,z,r(V.y01,V.x01),r(H.y01,H.x01),!k):(D.arc(V.cx,V.cy,z,r(V.y01,V.x01),r(V.y11,V.x11),!k),D.arc(0,0,m,r(V.cy+V.y11,V.cx+V.x11),r(H.cy+H.y11,H.cx+H.x11),k),D.arc(H.cx,H.cy,z,r(H.y11,H.x11),r(H.y01,H.x01),!k))):D.arc(0,0,m,N,M,k)}if(D.closePath(),e)return D=null,e+``||null}return k.centroid=function(){var e=(+l.apply(this,arguments)+ +x.apply(this,arguments))/2,t=(+w.apply(this,arguments)+ +T.apply(this,arguments))/2-c/2;return[n(t)*e,s(t)*e]},k.innerRadius=function(t){return arguments.length?(l=typeof t==`function`?t:e(+t),k):l},k.outerRadius=function(t){return arguments.length?(x=typeof t==`function`?t:e(+t),k):x},k.cornerRadius=function(t){return arguments.length?(S=typeof t==`function`?t:e(+t),k):S},k.padRadius=function(t){return arguments.length?(C=t==null?null:typeof t==`function`?t:e(+t),k):C},k.startAngle=function(t){return arguments.length?(w=typeof t==`function`?t:e(+t),k):w},k.endAngle=function(t){return arguments.length?(T=typeof t==`function`?t:e(+t),k):T},k.padAngle=function(t){return arguments.length?(E=typeof t==`function`?t:e(+t),k):E},k.context=function(e){return arguments.length?(D=e??null,k):D},k}export{x as t};

View File

@@ -0,0 +1 @@
import"./chunk-K5T4RW27-Bdzw7m0z.js";import{n as e}from"./chunk-7N4EOEYR-Bu6dy4pK.js";export{e as createArchitectureServices};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
Array.prototype.slice;function e(e){return typeof e==`object`&&`length`in e?e:Array.from(e)}export{e as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{nn as e,tn as t}from"./index-Cmfh6eB3.js";var n=(n,r)=>e.lang.round(t.parse(n)[r]);export{n as t};

View File

@@ -0,0 +1 @@
import{_ as e,g as t,h as n,i as r,m as i,s as a,t as o,u as s,v as c}from"./chunk-K5T4RW27-Bdzw7m0z.js";var l=class extends o{static{i(this,`RadarTokenBuilder`)}constructor(){super([`radar-beta`])}},u={parser:{TokenBuilder:i(()=>new l,`TokenBuilder`),ValueConverter:i(()=>new r,`ValueConverter`)}};function d(r=n){let i=t(c(r),a),o=t(e({shared:i}),s,u);return i.ServiceRegistry.register(o),{shared:i,Radar:o}}i(d,`createRadarServices`);export{d as n,u as t};

View File

@@ -0,0 +1 @@
import{in as e}from"./index-Cmfh6eB3.js";function t(e,t){e.accDescr&&t.setAccDescription?.(e.accDescr),e.accTitle&&t.setAccTitle?.(e.accTitle),e.title&&t.setDiagramTitle?.(e.title)}e(t,`populateCommonDb`);export{t};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{bt as e,in as t}from"./index-Cmfh6eB3.js";var n=t((t,n)=>{let r;return n===`sandbox`&&(r=e(`#i`+t)),e(n===`sandbox`?r.nodes()[0].contentDocument.body:`body`).select(`[id="${t}"]`)},`getDiagramElement`);export{n as t};

View File

@@ -0,0 +1 @@
import{_ as e,a as t,g as n,h as r,i,m as a,s as o,t as s,v as c}from"./chunk-K5T4RW27-Bdzw7m0z.js";var l=class extends s{static{a(this,`GitGraphTokenBuilder`)}constructor(){super([`gitGraph`])}},u={parser:{TokenBuilder:a(()=>new l,`TokenBuilder`),ValueConverter:a(()=>new i,`ValueConverter`)}};function d(i=r){let a=n(c(i),o),s=n(e({shared:a}),t,u);return a.ServiceRegistry.register(s),{shared:a,GitGraph:s}}a(d,`createGitGraphServices`);export{d as n,u as t};

View File

@@ -0,0 +1 @@
import{_ as e,g as t,h as n,m as r,n as i,r as a,s as o,t as s,v as c}from"./chunk-K5T4RW27-Bdzw7m0z.js";var l=class extends s{static{r(this,`ArchitectureTokenBuilder`)}constructor(){super([`architecture`])}},u=class extends i{static{r(this,`ArchitectureValueConverter`)}runCustomConverter(e,t,n){if(e.name===`ARCH_ICON`)return t.replace(/[()]/g,``).trim();if(e.name===`ARCH_TEXT_ICON`)return t.replace(/["()]/g,``);if(e.name===`ARCH_TITLE`){let e=t.replace(/^\[|]$/g,``).trim();return(e.startsWith(`"`)&&e.endsWith(`"`)||e.startsWith(`'`)&&e.endsWith(`'`))&&(e=e.slice(1,-1),e=e.replace(/\\"/g,`"`).replace(/\\'/g,`'`)),e.trim()}}},d={parser:{TokenBuilder:r(()=>new l,`TokenBuilder`),ValueConverter:r(()=>new u,`ValueConverter`)}};function f(r=n){let i=t(c(r),o),s=t(e({shared:i}),a,d);return i.ServiceRegistry.register(s),{shared:i,Architecture:s}}r(f,`createArchitectureServices`);export{f as n,d as t};

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