H022329 commited on
Commit
6b62834
·
verified ·
1 Parent(s): dd2215e

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +47 -0
  2. .gitattributes +5 -0
  3. .gitignore +16 -0
  4. README.md +779 -0
  5. agentic_rag/__init__.py +3 -0
  6. agentic_rag/__main__.py +6 -0
  7. agentic_rag/agent/__init__.py +1 -0
  8. agentic_rag/agent/react_engine.py +608 -0
  9. agentic_rag/agent/react_parser.py +208 -0
  10. agentic_rag/agent/react_prompt.py +76 -0
  11. agentic_rag/agent/router.py +135 -0
  12. agentic_rag/config/__init__.py +1 -0
  13. agentic_rag/config/defaults.yaml +80 -0
  14. agentic_rag/config/prompts.py +231 -0
  15. agentic_rag/config/settings.py +364 -0
  16. agentic_rag/core/__init__.py +1 -0
  17. agentic_rag/core/mcp/__init__.py +1 -0
  18. agentic_rag/core/mcp/client.py +214 -0
  19. agentic_rag/core/mcp/server.py +129 -0
  20. agentic_rag/core/multimodal/__init__.py +1 -0
  21. agentic_rag/core/multimodal/audio.py +58 -0
  22. agentic_rag/core/multimodal/image.py +78 -0
  23. agentic_rag/core/multimodal/video.py +80 -0
  24. agentic_rag/core/voice/__init__.py +1 -0
  25. agentic_rag/core/voice/stt.py +287 -0
  26. agentic_rag/core/voice/tts.py +277 -0
  27. agentic_rag/data/__init__.py +1 -0
  28. agentic_rag/data/db/__init__.py +183 -0
  29. agentic_rag/data/db/session_repo.py +165 -0
  30. agentic_rag/data/models.py +185 -0
  31. agentic_rag/data/schemas/__init__.py +1 -0
  32. agentic_rag/entrypoints/__init__.py +1 -0
  33. agentic_rag/entrypoints/cli/__init__.py +1 -0
  34. agentic_rag/entrypoints/cli/main.py +130 -0
  35. agentic_rag/entrypoints/gateway/__init__.py +22 -0
  36. agentic_rag/entrypoints/gateway/base.py +206 -0
  37. agentic_rag/entrypoints/gateway/dingtalk/__init__.py +191 -0
  38. agentic_rag/entrypoints/gateway/qqbot/__init__.py +372 -0
  39. agentic_rag/entrypoints/gateway/router.py +30 -0
  40. agentic_rag/entrypoints/gateway/session.py +89 -0
  41. agentic_rag/entrypoints/gateway/wechat_work/__init__.py +359 -0
  42. agentic_rag/entrypoints/gateway/wechat_work/crypto.py +113 -0
  43. agentic_rag/entrypoints/rest/__init__.py +1 -0
  44. agentic_rag/entrypoints/rest/app.py +170 -0
  45. agentic_rag/entrypoints/rest/routes/__init__.py +1 -0
  46. agentic_rag/entrypoints/rest/routes/chat.py +447 -0
  47. agentic_rag/entrypoints/rest/routes/health.py +23 -0
  48. agentic_rag/entrypoints/rest/routes/mcp.py +73 -0
  49. agentic_rag/entrypoints/rest/routes/rag.py +558 -0
  50. agentic_rag/entrypoints/rest/routes/session.py +82 -0
.env.example ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============ LLM/VLM 服务 ============
2
+ DEFAULT_PROVIDER=local
3
+ LLM_PROVIDERS__LOCAL__API_BASE=http://192.168.1.100:8009/v1
4
+ LLM_PROVIDERS__LOCAL__API_KEY=not-needed
5
+ LLM_PROVIDERS__LOCAL__MODEL=your-chat-model
6
+ LLM_PROVIDERS__LOCAL__VISION_MODEL=your-vision-model
7
+ LLM_PROVIDERS__LOCAL__MAX_TOKENS=4096
8
+ LLM_PROVIDERS__LOCAL__TEMPERATURE=0.7
9
+
10
+ # ============ Embedding 服务 ============
11
+ EMBEDDING__PROVIDER=local
12
+ EMBEDDING__API_BASE=http://192.168.1.100:8010/v1
13
+ EMBEDDING__API_KEY=not-needed
14
+ EMBEDDING__MODEL=AXERA-TECH/jina-embeddings-v5-omni-nano-retrieval-AX650-P128-CTX2047
15
+ EMBEDDING__MODEL_TYPE=multimodal
16
+ EMBEDDING__DIM=768
17
+ EMBEDDING__BATCH_SIZE=4
18
+
19
+ # Milvus Lite 的向量维度必须与 Embedding 输出一致
20
+ MILVUS__DIM=768
21
+
22
+ # ============ 可选:SenseVoice STT ============
23
+ VOICE__STT_PROVIDER=sensevoice
24
+ VOICE__STT_MODEL=sensevoice
25
+ VOICE__STT_API_BASE=http://192.168.1.100:8011
26
+ VOICE__STT_LANGUAGE=auto
27
+ VOICE__SAMPLE_RATE=16000
28
+
29
+ # ============ 可选:Kokoro TTS ============
30
+ VOICE__TTS_PROVIDER=kokoro
31
+ VOICE__TTS_MODEL=kokoro
32
+ VOICE__TTS_API_BASE=http://192.168.1.100:8012
33
+ VOICE__TTS_LANGUAGE=zh
34
+ VOICE__TTS_VOICE=zf_xiaoyi
35
+ VOICE__TTS_SPEED=1.0
36
+ VOICE__TTS_RESPONSE_FORMAT=wav
37
+
38
+ # ============ 可选:PaddleOCR-VL ============
39
+ OCR__ENABLED=true
40
+ OCR__API_BASE=http://192.168.1.100:8013/v1
41
+ OCR__MODEL=PaddlePaddle/PaddleOCR-VL
42
+ OCR__API_KEY=not-needed
43
+ OCR__MAX_PAGES=50
44
+
45
+ # ============ Agentic RAG Web 服务 ============
46
+ API__HOST=0.0.0.0
47
+ API__PORT=8007
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ data/agentic_rag.db-wal filter=lfs diff=lfs merge=lfs -text
37
+ frontend/node_modules/@oxlint/binding-linux-x64-gnu/oxlint.linux-x64-gnu.node filter=lfs diff=lfs merge=lfs -text
38
+ frontend/node_modules/@rolldown/binding-linux-x64-gnu/rolldown-binding.linux-x64-gnu.node filter=lfs diff=lfs merge=lfs -text
39
+ frontend/node_modules/lightningcss-linux-x64-gnu/lightningcss.linux-x64-gnu.node filter=lfs diff=lfs merge=lfs -text
40
+ image-1.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .env
7
+ .venv/
8
+ venv/
9
+ *.db
10
+ workspace/
11
+ data/user/
12
+ .DS_Store
13
+ .pytest_cache/
14
+ .coverage
15
+ htmlcov/
16
+ uphg.py
README.md ADDED
@@ -0,0 +1,779 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agentic RAG 智能问答系统
2
+
3
+ <p align="center"><b>ReAct Agent 驱动</b> | <b>多模态知识库</b> | <b>MCP 工具扩展</b> | <b>边缘/本地部署</b></p>
4
+
5
+ ---
6
+
7
+ 基于 **ReAct Agent** 的多模态检索增强生成(RAG)系统,支持文本、图片、音频、视频的统一入库与跨模态检索,提供智能问答、工具调用、MCP 扩展、流式对话、语音交互等完整能力。支持 OpenAI / 本地 OpenAI-compatible 模型,可灵活部署在云端或边缘设备上。
8
+
9
+ [![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
10
+ [![FastAPI](https://img.shields.io/badge/FastAPI-0.115%2B-009688.svg)](https://fastapi.tiangolo.com/)
11
+ [![React 19](https://img.shields.io/badge/React-19-61DAFB.svg)](https://react.dev/)
12
+ [![Milvus Lite](https://img.shields.io/badge/Vector%20Store-Milvus%20Lite-orange.svg)](https://milvus.io/)
13
+ [![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
14
+
15
+ > 当前版本:`0.1.0`。部分能力还需要进一步开发验证。
16
+ >
17
+ ---
18
+
19
+ ![alt text](image.png)
20
+
21
+ ![alt text](image-1.png)
22
+
23
+ ---
24
+
25
+ ## 目录
26
+
27
+ - [项目背景](#项目背景)
28
+ - [核心特性](#核心特性)
29
+ - [系统架构](#系统架构)
30
+ - [快速开始](#快速开始)
31
+ - [使用方式](#使用方式)
32
+ - [其他功能](#其他功能)
33
+ - [多模态知识库](#多模态知识库)
34
+ - [MCP 工具扩展](#mcp-工具扩展)
35
+ - [语音与消息网关](#语音与消息网关)
36
+ - [功能状态](#功能状态)
37
+ - [AX8850 运行示例](#ax8850-运行示例)
38
+
39
+
40
+ ---
41
+
42
+ ## 项目背景
43
+
44
+ ### 为什么需要 Agentic RAG?
45
+
46
+ 传统 RAG 系统只能被动检索,而现实场景中的问题往往需要多步推理、工具调用和动态决策:
47
+
48
+ - **静态检索局限** — 一次性检索难以回答需要多轮推理的复杂问题。
49
+ - **模态割裂** — 文本、图片、音视频分散存储,无法统一检索,大量非文本信息被浪费。
50
+ - **工具孤岛** — 检索、计算、外部 API 等能力各自独立,Agent 无法根据上下文自主选择工具。
51
+ - **部署复杂** — 多数方案依赖云端服务,存在隐私泄露风险和高昂调用成本。
52
+
53
+ ### 本项目的解决思路
54
+
55
+ 本项目将 **ReAct Agent** 与 **多模态 RAG** 深度融合,让 Agent 能够"思考→行动→观察→再思考",在推理过程中自主决定何时检索知识库、何时调用外部工具、何时生成最终答案:
56
+
57
+ - 🧠 **Agent 驱动检索** — Agent 根据问题复杂度自主决定检索策略,支持多轮推理和工具链调用。
58
+ - 🔗 **MCP 生态接入** — 通过 Model Context Protocol 接入外部工具,Agent 能力可无限扩展。
59
+ - 🎨 **多模态统一** — 文本、图片、音频、视频统一向量空间,跨模态语义检索。
60
+ - 🏠 **本地优先** — 支持本地 LLM / Embedding 服务,数据不出设备,隐私安全可控。
61
+
62
+ ### 应用场景
63
+
64
+ | 领域 | 典型场景 | 核心价值 |
65
+ |------|----------|----------|
66
+ | 🏢 **企业知识管理** | 智能客服、内部培训、文档问答 | 多轮对话理解上下文,自动调用内部工具查询数据 |
67
+ | 🔬 **研发辅助** | 代码库问答、技术文档检索、API 集成 | Agent 自主检索代码示例、调用调试工具、生成修复建议 |
68
+ | 📚 **教育科研** | 文献综述、课件问答、实验数据分析 | 跨文献多轮推理,自动提取关键信息并生成综述 |
69
+ | 🎬 **内容创作** | 素材检索、脚本生成、多模态内容理解 | 以文搜图/以图搜视频,Agent 辅助创作全流程 |
70
+ | 🏥 **专业领域** | 医疗文献问答、法律条文检索、金融报告分析 | 严格的数据隐私要求下本地运行,专业工具链集成 |
71
+
72
+ ---
73
+
74
+ ## 核心特性
75
+
76
+ ### 🚀 功能特性
77
+
78
+ - **ReAct Agent 引擎** — 支持 `Thought → Action → Observation → Final Answer` 循环,以及原生 Function Calling,Agent 可自主规划多步推理。
79
+ - **多模式智能路由** — 按查询意图动态装配工具集(知识库检索、联网搜索、媒体理解及 MCP 扩展工具),Agent 每轮推理均可自主调用工具。
80
+ - **四模态统一检索** — 文本、图片、音频、视频在同一向量空间中表示,支持以文搜图、以图搜视频等跨模态查询。
81
+ - **混合检索策略** — 支持 `naive` 纯向量检索和 `hybrid` 向量 + 知识图谱混合检索,提升召回准确率。
82
+ - **流式对话体验** — REST SSE 与 WebSocket 双通道流式输出,实时展示 Agent 思考过程和工具调用。
83
+ - **多入口灵活接入** — Web UI、REST API、WebSocket、CLI、异步 Python SDK,满足不同场景需求。
84
+
85
+ ### 🔧 技术特性
86
+
87
+ - **MCP 工具扩展** — 启动时自动连接外部 MCP Server,将工具注册到 Agent,实现能力热插拔。
88
+ - **多提供商 LLM** — 内置 OpenAI、以及任意 OpenAI-compatible 本地服务适配。
89
+ - **模块化架构** — Agent 引擎、知识管线、向量存储、LLM 服务、记忆系统分层��耦,可独立替换升级。
90
+ - **本地数据持久化** — SQLite 会话存储、Milvus Lite 向量库、JSON 知识图谱,零外部依赖即可运行。
91
+ - **可配置预处理** — 文本分块大小、图片处理策略、音频切片参数等均可通过环境变量调节。
92
+
93
+ ---
94
+
95
+ ## 系统架构
96
+
97
+ ### 整体架构
98
+
99
+ ```text
100
+ ┌─────────────────────────────────────────────────────────────┐
101
+ │ 接入层 (Entry Points) │
102
+ │ Web UI │ REST API │ WebSocket │ CLI │ Python SDK │
103
+ └──────────────────────────┬──────────────────────────────────┘
104
+
105
+
106
+ ┌─────────────────────────────────────────────────────────────┐
107
+ │ Agent 路由与引擎层 │
108
+ │ AgentRouter (模式路由) + ReActEngine (推理循环) │
109
+ │ ↓ Thought → Action → Observation ↑ │
110
+ └──────────────────────────┬──────────────────────────────────┘
111
+
112
+ ┌───────────────┼───────────────┐
113
+ ▼ ▼ ▼
114
+ ┌─────────┐ ┌──────────┐ ┌──────────┐
115
+ │ RAG 工具 │ │ MCP 工具 │ │ 媒体工具 │
116
+ │(知识库) │ │(外部扩展)│ │(语音/图像)│
117
+ └────┬────┘ └──────────┘ └──────────┘
118
+
119
+
120
+ ┌─────────────────────────────────────────────────────────────┐
121
+ │ 知识管线 (Knowledge Pipeline) │
122
+ │ Parse → Process → Embed → Milvus Lite │
123
+ │ └────────→ Knowledge Graph (可选) │
124
+ └─────────────────────────────────────────────────────────────┘
125
+
126
+ ┌─────────────────────────────────────────────────────────────┐
127
+ │ 数据持久化层 │
128
+ │ SQLite (会话/消息) │ Milvus Lite (向量) │ JSON (KG) │
129
+ └─────────────────────────────────────────────────────────────┘
130
+ ```
131
+
132
+ ### 知识管线流程
133
+
134
+ ```text
135
+ 原始输入 (文本/图片/音频/视频/PDF/Office)
136
+
137
+
138
+ ┌───────────────┐
139
+ │ 统一解析层 │ → ContentList (统一内容表示)
140
+ │ Parse │
141
+ └───────┬───────┘
142
+
143
+
144
+ ┌───────────────┐
145
+ │ 模态处理层 │ → 文本分块 / 图片处理 / 音频切片 / 视频帧提取
146
+ │ Process │
147
+ └───────┬───────┘
148
+
149
+
150
+ ┌───────────────┐ ┌───────────────┐
151
+ │ 向量化层 │ ──→ │ 知识图谱层 │ (可选,enable_kg=true)
152
+ │ Embed │ │ KG Builder │
153
+ └───────┬───────┘ └───────────────┘
154
+
155
+
156
+ ┌───────────────┐
157
+ │ 向量存储层 │ → Milvus Lite (本地持久化)
158
+ │ Vector Store │
159
+ └───────────────┘
160
+ ```
161
+
162
+ ### Agent 推理流程
163
+
164
+ ```text
165
+ 用户问题
166
+
167
+
168
+ ┌─────────────┐
169
+ │ AgentRouter│
170
+ └──────┬──────┘
171
+
172
+
173
+ ┌────────────────────────────────────────┐
174
+ │ ReAct 推理循环 │
175
+ │ ┌─────────┐ ┌─────────┐ ┌────────┐ │
176
+ │ │ Thought │ → │ Action │ → │Observe │ │
177
+ │ │ (思考) │ │ (行���) │ │ (观察) │ │
178
+ │ └─────────┘ └─────────┘ └────────┘ │
179
+ │ ↑ │ │
180
+ │ └───────────────────────────┘ │
181
+ │ (最多 N 轮) │
182
+ └────────────────────────────────────────┘
183
+
184
+
185
+ ┌─────────────┐
186
+ │ Final Answer │ → 生成最终回答,附来源引用
187
+ └─────────────┘
188
+ ```
189
+
190
+
191
+
192
+ ### 项目结构
193
+
194
+ ```text
195
+ Agentic_RAG/
196
+ ├── agentic_rag/ # 后端核心代码
197
+ │ ├── agent/ # ReAct 引擎、Prompt 模板、模式路由
198
+ │ ├── config/ # Pydantic Settings、默认配置
199
+ │ ├── core/ # MCP 客户端、多模态处理、STT/TTS
200
+ │ ├── data/ # 数据模型、SQLite Repository
201
+ │ ├── entrypoints/ # 接入层
202
+ │ │ ├── rest/ # FastAPI REST API
203
+ │ │ ├── websocket/ # WebSocket 服务
204
+ │ │ ├── cli/ # 命令行接口
205
+ │ │ ├── sdk/ # Python SDK
206
+ │ │ └── gateway/ # 消息平台网关
207
+ │ ├── orchestration/ # L1 工具、L2 能力编排
208
+ │ ├── runtime/ # 运行时上下文、流总线、轮次协调
209
+ │ ├── services/ # LLM、知识管线、记忆、会话、向量存储
210
+ │ └── utils/ # 通用工具
211
+ ├── frontend/ # React 19 + Vite 8 前端
212
+ │ ├── src/
213
+ │ └── vite.config.js
214
+ ├── static/ # 前端构建产物(生产模式)
215
+ ├── tests/ # 测试
216
+ │ ├── unit/ # 单元测试
217
+ │ ├── integration/ # 集成测试
218
+ │ └── dir/ # 测试数据
219
+ ├── scripts/ # 辅助脚本(预留)
220
+ ├── data/ # SQLite 运行时数据
221
+ ├── workspace/ # 上传文件、向量库、知识图谱
222
+ ├── .env # 环境变量配置(由 .env.example 复制)
223
+ ├── .env.example # 环境变量模板
224
+ ├── mcp_servers.json # MCP 配置(JSON)
225
+ ├── mcp_servers.yaml # MCP 配置(YAML)
226
+ ├── pyproject.toml # Python 项目配置
227
+ └── README.md
228
+ ```
229
+
230
+ ---
231
+
232
+ ## 快速开始
233
+
234
+ ### 1. 环境要求
235
+
236
+ - **Python** 3.10+
237
+ - **Node.js** 18+(构建 Web UI 需要;生产环境直接使用已构建的 `static/` 产物时可省略)
238
+
239
+
240
+ ### 2. 安装
241
+
242
+ ```bash
243
+ git clone <repository-url>
244
+ cd Agentic_RAG
245
+
246
+ python -m venv .venv
247
+ source .venv/bin/activate # Linux/macOS
248
+ # .venv\Scripts\activate # Windows PowerShell
249
+
250
+ python -m pip install --upgrade pip
251
+ pip install -e ".[dev]"
252
+
253
+ # 完整安装:Anthropic、语音、视频、文档解析和消息网关
254
+ # pip install -e ".[dev,anthropic,voice,media,documents,gateways]"
255
+
256
+ # 可选:PDF/Office 完整解析
257
+ pip install pymupdf # PDF 文本提取及 OCR 页面渲染
258
+ # 或按 Docling 官方说明安装 docling
259
+ ```
260
+
261
+
262
+ ### 3. 配置环境变量
263
+
264
+ 从模板创建 `.env` 文件:
265
+
266
+ ```bash
267
+ cp .env.example .env
268
+ ```
269
+
270
+ 然后按需修改(配置使用**无前缀变量名**):
271
+
272
+ ```bash
273
+ # ============ LLM 配置 ============
274
+ DEFAULT_PROVIDER=local
275
+
276
+ LLM_PROVIDERS__LOCAL__API_BASE=http://localhost:8009/v1
277
+ LLM_PROVIDERS__LOCAL__MODEL=your-chat-model
278
+ LLM_PROVIDERS__LOCAL__API_KEY=not-needed
279
+ LLM_PROVIDERS__LOCAL__VISION_MODEL=your-vision-model
280
+
281
+ # ============ Embedding 配置 ============
282
+ EMBEDDING__PROVIDER=local
283
+ EMBEDDING__API_BASE=http://localhost:8010/v1
284
+ EMBEDDING__API_KEY=not-needed
285
+ EMBEDDING__MODEL=your-embedding-model
286
+ EMBEDDING__DIM=768
287
+ EMBEDDING__BATCH_SIZE=4
288
+
289
+ # ============ 向量库配置 ============
290
+ MILVUS__DIM=768
291
+
292
+ # ============ API 服务配置 ============
293
+ API__HOST=0.0.0.0
294
+ API__PORT=8007
295
+ ```
296
+
297
+ > ⚠️ `EMBEDDING__DIM` 与 `MILVUS__DIM` 必须一致。若需修改已创建集合的维度,请先备份并删除旧的 `workspace/milvus_lite.db`,再重新建库。
298
+
299
+ **使用官方云服务:**
300
+
301
+ ```bash
302
+ # OpenAI
303
+ DEFAULT_PROVIDER=openai
304
+ LLM_PROVIDERS__OPENAI__API_KEY=your-openai-api-key
305
+ LLM_PROVIDERS__OPENAI__API_BASE=https://api.openai.com/v1
306
+ LLM_PROVIDERS__OPENAI__MODEL=gpt-4o
307
+
308
+ # Anthropic Claude(Embedding 仍需单独配置)
309
+ DEFAULT_PROVIDER=claude
310
+ LLM_PROVIDERS__CLAUDE__API_KEY=your-anthropic-api-key
311
+ LLM_PROVIDERS__CLAUDE__API_BASE=https://api.anthropic.com
312
+ LLM_PROVIDERS__CLAUDE__MODEL=your-claude-model
313
+ ```
314
+
315
+ ### 4. 构建前端
316
+
317
+ ```bash
318
+ cd frontend
319
+ npm run build
320
+ cd ..
321
+ ```
322
+
323
+ Vite 产���输出到 `static/`,FastAPI 挂载 `/static` 并在 `/` 返回 SPA 首页。生产环境只需构建一次;前端源码未改动时无需重复执行。
324
+
325
+ ### 5. 启动服务
326
+
327
+ ```bash
328
+ # 生产模式
329
+ python -m agentic_rag serve --host 0.0.0.0 --port 8007
330
+
331
+ # 开发模式(自动重载)
332
+ python -m agentic_rag serve --port 8007 --reload
333
+ # 或
334
+ uvicorn agentic_rag.entrypoints.rest.app:app --host 0.0.0.0 --port 8007 --reload
335
+ ```
336
+
337
+ ### 6. 验证运行
338
+
339
+ ```bash
340
+ curl http://localhost:8007/health
341
+ curl http://localhost:8007/ready
342
+ ```
343
+
344
+ | 地址 | 说明 |
345
+ |------|------|
346
+ | `http://localhost:8007/` | Web UI 主界面 |
347
+ | `http://localhost:8007/docs` | Swagger API 文档 |
348
+ | `http://localhost:8007/health` | 进程健康检查 |
349
+ | `http://localhost:8007/ready` | LLM 配置就绪检查 |
350
+
351
+ ✅ 打开 `http://localhost:8007/`,看到聊天界面即部署成功。若页面空白或报资源加载失败,通常是 `static/` 缺失或过期,请回到第 4 步重新执行 `npm run build`。
352
+
353
+ ---
354
+
355
+ ## 使用方式
356
+
357
+ 系统提供多种使用入口:**Web UI** 是浏览器中的完整交互界面,适合直接使用;CLI / REST API / WebSocket / Python SDK 面向脚本调用与二次开发。
358
+
359
+ ### Web UI
360
+
361
+ ```bash
362
+ # 1. 构建前端(首次或前端有更新时执行)
363
+ cd frontend && npm install && npm run build && cd ..
364
+
365
+ # 2. 启动服务(后端托管 Web UI)
366
+ python -m agentic_rag serve --port 8007
367
+ ```
368
+
369
+ 启动后在浏览器打开 `http://localhost:8007/`:
370
+
371
+ - **对话交互** — 输入问题即开始问答,流式展示 Agent 的 Thought / Action / Observation 推理过程
372
+ - **文件上传** — 上传文本、图片、音频、视频入库,对应 `/api/v1/rag/upload`
373
+ - **会话管理** — 多会话切换,历史记录持久化在本地 SQLite
374
+
375
+
376
+ ### CLI 命令行
377
+
378
+ ```bash
379
+ # 普通问答
380
+ python -m agentic_rag chat "什么是 RAG?"
381
+
382
+ # 自动路由或指定模式
383
+ python -m agentic_rag chat --mode research "深入总结知识库中的检索方法"
384
+
385
+ # 流式输出
386
+ python -m agentic_rag chat --stream "解释 ReAct 的执行过程"
387
+
388
+ # 指定已配置的 Provider
389
+ python -m agentic_rag chat --provider local "你好"
390
+
391
+ # 文本文件入库
392
+ python -m agentic_rag ingest --file document.txt --source cli
393
+
394
+ # 查看当前配置信息
395
+ python -m agentic_rag info
396
+
397
+ # 查看帮助
398
+ python -m agentic_rag --help
399
+ ```
400
+
401
+ ### REST API
402
+
403
+ #### 非流式聊天
404
+
405
+ ```bash
406
+ curl -X POST http://localhost:8007/api/v1/chat \
407
+ -H 'Content-Type: application/json' \
408
+ -d '{"message":"什么是 RAG?","mode":"auto"}'
409
+ ```
410
+
411
+ #### SSE 流式聊天
412
+
413
+ ```bash
414
+ curl -N -X POST http://localhost:8007/api/v1/chat/stream \
415
+ -H 'Content-Type: application/json' \
416
+ -d '{"message":"检索知识库中的向量数据库资料","mode":"research"}'
417
+ ```
418
+
419
+ 主要事件类型:`text_delta`、`tool_call_start`、`tool_call_result`、`error`、`done`。
420
+
421
+ #### 知识库检索
422
+
423
+ ```bash
424
+ curl -X POST http://localhost:8007/api/v1/rag/search \
425
+ -H 'Content-Type: application/json' \
426
+ -d '{"query":"向量数据库","top_k":5,"mode":"hybrid"}'
427
+ ```
428
+
429
+ #### 文本入库
430
+
431
+ ```bash
432
+ curl -X POST http://localhost:8007/api/v1/rag/ingest \
433
+ -H 'Content-Type: application/json' \
434
+ -d '{"content":"Milvus 是一个向量数据库。","source":"manual"}'
435
+ ```
436
+
437
+ #### 文件/多模态上传
438
+
439
+ ```bash
440
+ curl -X POST http://localhost:8007/api/v1/rag/upload \
441
+ -F 'file=@document.pdf' \
442
+ -F 'source=manual-upload' \
443
+ -F 'ingest_mode=multimodal' \
444
+ -F 'mm_method=pure' \
445
+ -F 'enable_kg=true'
446
+ ```
447
+
448
+ 支持的文件格式:
449
+ - **文本/文档**:`.txt` `.md` `.json` `.yaml` `.csv` `.py` `.html` `.pdf` `.docx`
450
+ - **图片**:`.jpg` `.png` `.gif` `.webp` `.bmp` `.svg`
451
+ - **视频**:`.mp4` `.avi` `.mov` `.mkv` `.webm`
452
+ - **音频**:`.mp3` `.wav` `.m4a` `.ogg` `.flac`
453
+
454
+ #### 会话管理
455
+
456
+ ```bash
457
+ # 创建会话(user_id 是查询参数)
458
+ curl -X POST 'http://localhost:8007/api/v1/session?user_id=user123'
459
+
460
+ # 获取会话列表
461
+ curl 'http://localhost:8007/api/v1/sessions?user_id=user123'
462
+
463
+ # 获取会话消息
464
+ curl http://localhost:8007/api/v1/session/<session_id>/messages
465
+
466
+ # 删除会话
467
+ curl -X DELETE http://localhost:8007/api/v1/session/<session_id>
468
+ ```
469
+
470
+
471
+ ### WebSocket
472
+
473
+ ```javascript
474
+ const sessionId = crypto.randomUUID()
475
+ const ws = new WebSocket(`ws://localhost:8007/ws/${sessionId}`)
476
+
477
+ ws.addEventListener('open', () => {
478
+ ws.send(JSON.stringify({
479
+ type: 'chat',
480
+ payload: {
481
+ message: '检索知识库中的 ReAct 资料',
482
+ mode: 'research',
483
+ },
484
+ }))
485
+ })
486
+
487
+ ws.addEventListener('message', (event) => {
488
+ const message = JSON.parse(event.data)
489
+ console.log(message.type, message.data)
490
+ })
491
+ ```
492
+
493
+ ### Python SDK
494
+
495
+ ```python
496
+ import asyncio
497
+ from agentic_rag.entrypoints.sdk.client import AgenticRAGClient
498
+
499
+
500
+ async def main() -> None:
501
+ async with AgenticRAGClient("http://localhost:8007") as client:
502
+ # 非流式聊天
503
+ response = await client.chat("什么是 RAG?", mode="auto")
504
+ print(response["answer"])
505
+
506
+ # 流��聊天
507
+ async for event in client.chat_stream("检索知识库"):
508
+ print(event)
509
+
510
+ # 文本入库
511
+ result = await client.rag_ingest(
512
+ "这是一段需要写入知识库的文本。",
513
+ source="sdk",
514
+ )
515
+ print(result)
516
+
517
+
518
+ asyncio.run(main())
519
+ ```
520
+
521
+ ---
522
+
523
+ ## 其他功能
524
+
525
+ ### 多模态知识库
526
+
527
+ #### 入库模式
528
+
529
+ `/api/v1/rag/upload` 参数说明:
530
+
531
+ | 参数 | 默认值 | 说明 |
532
+ |------|--------|------|
533
+ | `ingest_mode` | `multimodal` | `text` 跳过媒体项;`multimodal` 处理媒体项 |
534
+ | `mm_method` | `pure` | `pure` / `caption` / `both` |
535
+ | `chunk_size` | `512` | 文本分块字符数 |
536
+ | `chunk_overlap` | `50` | 分块重叠字符数 |
537
+ | `enable_kg` | `false` | 构建并持久化知识图谱 |
538
+
539
+ `mm_method` 说明:
540
+ - `pure` — 直接构造多模态 Embedding 输入;纯文本 Embedding API 会退化为占位文本
541
+ - `caption` — 使用视觉模型生成图片描述,再对描述文本做 Embedding
542
+ - `both` — 同时使用原始媒体和描述文本
543
+
544
+ #### 本地数据
545
+
546
+ 运行时产生的数据文件:
547
+
548
+ ```text
549
+ data/agentic_rag.db # 会话与消息历史
550
+ workspace/milvus_lite.db # Milvus Lite 向量数据库
551
+ workspace/knowledge_graph.json # 知识图谱(启用 KG 时)
552
+ workspace/uploads/ # 上传文件缓存
553
+ ```
554
+
555
+ > 🔒 这些文件可能包含用户内容或模型数据,部署时请配置合适的访问控制、备份和清理策略。
556
+
557
+ ### MCP 工具扩展
558
+
559
+ 项目支持通过 Model Context Protocol (MCP) 接入外部工具,Agent 可自动发现并使用这些工具。
560
+
561
+ #### 配置方式
562
+
563
+ 按优先级查找配置:
564
+
565
+ 1. `mcp_servers.json`
566
+ 2. `mcp_servers.yaml`
567
+ 3. MCP 环境变量
568
+
569
+ 推荐使用不含密钥的配置文件,通过环境变量提供凭据:
570
+
571
+ ```json
572
+ {
573
+ "mcpServers": {
574
+ "example-search": {
575
+ "command": "npx",
576
+ "args": ["-y", "example-search-mcp"],
577
+ "disabled": false
578
+ }
579
+ }
580
+ }
581
+ ```
582
+
583
+ ```bash
584
+ export EXAMPLE_SEARCH_API_KEY='your-key'
585
+ python -m agentic_rag serve
586
+ ```
587
+
588
+ 启动日志会显示每个 MCP Server 的连接结果。连接成功的工具会注册到工具中心,并在所有 Agent 模式中可用。
589
+
590
+ > ⚠️ 不要把真实 API Key 提交到 Git。若密钥曾进入仓库历史,请立即撤销并轮换。
591
+
592
+ ### 语音与消息网关
593
+
594
+ #### 语音 REST 接口
595
+
596
+ ```bash
597
+ curl -N -X POST http://localhost:8007/api/v1/chat/voice \
598
+ -F 'audio=@recording.wav' \
599
+ -F 'sid=voice-demo' \
600
+ -F 'tts=true'
601
+ ```
602
+
603
+ 响应为 SSE,事件类型:`transcript`、`text_delta`、工具事件、`audio`、`error`、`done`。
604
+
605
+ 支持的配置:
606
+ - **STT**:`sensevoice`、`whisper`、`openai`
607
+ - **TTS**:`qwen`、`kokoro`、`edge`、`openai`
608
+
609
+ #### 消息网关
610
+
611
+ 支持企业微信、QQ Bot、钉钉。总开关 `GATEWAY__ENABLED=true`,各平台需单独配置凭据。
612
+
613
+ > 生产使用前请完成平台签名校验、回调地址、权限和消息发送链路测试。
614
+
615
+ ### 功能状态
616
+
617
+ | 能力 | 状态 | 说明 |
618
+ |------|:----:|------|
619
+ | 非流式/流式聊天 | ✅ | REST、CLI;WebSocket 支持流式事件 |
620
+ | `rag_search` | ✅ | Chat API 启动时自动注册 |
621
+ | 文本与文件入库 | ✅ | REST 与 CLI 均有入口 |
622
+ | 多模态上传 | ✅ | REST `/api/v1/rag/upload` |
623
+ | `chat` / `research` 模式 | ✅ | 均可使用 RAG 与已连接的 MCP 工具 |
624
+ | `rag` 模式 Agent 自主入库 | ⚠️ | 路由声明了 `rag_ingest`,但 Chat API 默认只注册 `rag_search` |
625
+ | `media` 模式工具 | ⚠️ | 路由已定义,媒体工具未由 Chat API 默认注册 |
626
+ | MCP | ⚠️ | 取决于本机命令、依赖、网络和环境变量 |
627
+ | PDF/Office 解析 | ⚠️ | 需要 PaddleOCR-VL 服务、`pymupdf` 或 `docling` |
628
+ | 语音对话 | ⚠️ | 需要可用的 STT/TTS 服务或本地模型 |
629
+ | 消息平台网关 | ⚠️ | 企业微信、QQ Bot、钉钉需按平台配置与联调 |
630
+
631
+ ---
632
+
633
+ ## AX8850 运行示例
634
+
635
+ 本节介绍在爱芯(AXERA)AX650N/AX8850 边缘设备上的两种部署方式:
636
+
637
+ - **分离部署** — 仅模型推理服务运行在 NPU 设备上,通过 OpenAI 兼容接口对外提供;Agentic RAG 主机通过 HTTP 连接这些服务。
638
+ - **全量部署** (本节默认方式)— 完整的 Agentic RAG 服务也运行在 NPU 设备上,推理与应用同机完成,数据不出设备。
639
+
640
+ ### 1. 下载模型与运行组件
641
+
642
+ 可从以下官方资源选择适配 AX650N/AX8850 的模型和运行组件:
643
+
644
+ - [AXERA-TECH Hugging Face 模型仓库](https://huggingface.co/AXERA-TECH)
645
+ - [AXERA-TECH/ax-llm](https://github.com/AXERA-TECH/ax-llm)(LLM/VLM/Embedding 推理及 OpenAI 兼容服务)
646
+ - [AX650 Community Hub](https://github.com/AXERA-TECH/AX650-Community-Hub)(SDK、部署文档和模型示例)
647
+
648
+ 本项目至少需要以下两类模型:
649
+
650
+ | 服务 | 用途 | 接口要求 | 示例端口 |
651
+ |------|------|----------|---------:|
652
+ | Chat LLM/VLM | 对话、Agent 推理、图片理解 | OpenAI 兼容 `/v1/chat/completions` | `8009` |
653
+ | Embedding | 文本/图片/音视频向量化 | OpenAI 兼容 `/v1/embeddings` | `8010` |
654
+
655
+ 可选模型服务:
656
+
657
+ | 服务 | 用途 | 接口要求 | 示例端口 |
658
+ |------|------|----------|---------:|
659
+ | [SenseVoice](https://huggingface.co/AXERA-TECH/SenseVoice_AgenticRAG) | 语音识别 | `/v1/audio/transcriptions` 或 `/asr` | `8011` |
660
+ | [Kokoro TTS](https://modelscope.cn/models/AXERA-TECH/kokoro.axera) | 语音合成 | `POST /tts`,返回 WAV | `8012` |
661
+ | PaddleOCR-VL | PDF/图片 OCR | OpenAI 兼容接口 | `8013` |
662
+
663
+ ### 2. 准备运行环境
664
+
665
+ Agentic RAG 主机安装项目依赖。若启用语音、文档和媒体处理,建议安装完整可选依赖:
666
+
667
+ ```bash
668
+ python -m venv .venv
669
+ source .venv/bin/activate
670
+ pip install -e ".[voice,documents,media]"
671
+
672
+ # 压缩音频解码还需要系统提供 ffmpeg
673
+ ffmpeg -version
674
+ ```
675
+
676
+ 如果只使用文本问答和 RAG,可直接执行:
677
+
678
+ ```bash
679
+ pip install -e .
680
+ ```
681
+
682
+ ### 3. 启动模型服务
683
+
684
+ 以下命令在 **AX8850 设备**上执行。将模型目录替换为实际下载路径:
685
+
686
+ ```bash
687
+ # LLM/VLM 模型
688
+ axllm serve /path/to/llm-or-vlm-model --host 0.0.0.0 --port 8009
689
+
690
+ # Embedding 模型
691
+ axllm serve /path/to/embeddings-model --host 0.0.0.0 --port 8010
692
+
693
+ # SenseVoice 模型
694
+ python /path/to/SenseVoice/python/openai_server.py --port 8011
695
+
696
+ # Kokoro 模型
697
+ python /path/to/kokoro/kokoro_svr.py --port 8012
698
+ ```
699
+
700
+ SenseVoice、Kokoro 和 PaddleOCR-VL 的启动命令以各自模型仓库为准。配置前应确认它们分别满足本项目使用的接口约定:
701
+
702
+ - SenseVoice:优先支持 `POST /v1/audio/transcriptions`,也兼容 `POST /asr`
703
+ - Kokoro:支持 `POST /tts`,接收 `text`、`language`、`voice`、`speed` 字段并返回音频字节
704
+ - PaddleOCR-VL:提供 OpenAI 兼容的视觉模型接口
705
+
706
+ ### 4. 配置环境变量
707
+
708
+ 在项目根目录创建或修改 `.env`。以下示例假设所有模型服务都运行在 `192.168.1.100`:
709
+
710
+ ```bash
711
+ # ============ LLM/VLM 服务 ============
712
+ DEFAULT_PROVIDER=local
713
+ LLM_PROVIDERS__LOCAL__API_BASE=http://192.168.1.100:8009/v1
714
+ LLM_PROVIDERS__LOCAL__API_KEY=not-needed
715
+ LLM_PROVIDERS__LOCAL__MODEL=your-chat-model
716
+ LLM_PROVIDERS__LOCAL__VISION_MODEL=your-vision-model
717
+ LLM_PROVIDERS__LOCAL__MAX_TOKENS=4096
718
+ LLM_PROVIDERS__LOCAL__TEMPERATURE=0.7
719
+
720
+ # ============ Embedding 服务 ============
721
+ EMBEDDING__PROVIDER=local
722
+ EMBEDDING__API_BASE=http://192.168.1.100:8010/v1
723
+ EMBEDDING__API_KEY=not-needed
724
+ EMBEDDING__MODEL=AXERA-TECH/jina-embeddings-v5-omni-nano-retrieval-AX650-P128-CTX2047
725
+ EMBEDDING__MODEL_TYPE=multimodal
726
+ EMBEDDING__DIM=768
727
+ EMBEDDING__BATCH_SIZE=4
728
+
729
+ # Milvus Lite 的向量维度必须与 Embedding 输出一致
730
+ MILVUS__DIM=768
731
+
732
+ # ============ 可选:SenseVoice STT ============
733
+ VOICE__STT_PROVIDER=sensevoice
734
+ VOICE__STT_MODEL=sensevoice
735
+ VOICE__STT_API_BASE=http://192.168.1.100:8011
736
+ VOICE__STT_LANGUAGE=auto
737
+ VOICE__SAMPLE_RATE=16000
738
+
739
+ # ============ 可选:Kokoro TTS ============
740
+ VOICE__TTS_PROVIDER=kokoro
741
+ VOICE__TTS_MODEL=kokoro
742
+ VOICE__TTS_API_BASE=http://192.168.1.100:8012
743
+ VOICE__TTS_LANGUAGE=zh
744
+ VOICE__TTS_VOICE=zf_xiaoyi
745
+ VOICE__TTS_SPEED=1.0
746
+ VOICE__TTS_RESPONSE_FORMAT=wav
747
+
748
+ # ============ 可选:PaddleOCR-VL ============
749
+ OCR__ENABLED=true
750
+ OCR__API_BASE=http://192.168.1.100:8013/v1
751
+ OCR__MODEL=PaddlePaddle/PaddleOCR-VL
752
+ OCR__API_KEY=not-needed
753
+ OCR__MAX_PAGES=50
754
+
755
+ # ============ Agentic RAG Web 服务 ============
756
+ API__HOST=0.0.0.0
757
+ API__PORT=8007
758
+ ```
759
+
760
+ 注意事项:
761
+
762
+ 1. `LLM_PROVIDERS__LOCAL__MODEL` 必须与模型服务实际暴露的模型名一致。这里使用已注册的 `local` Provider 连接 AX8850 上的 OpenAI 兼容服务,无需新增 Provider 类型。
763
+ 2. 纯文本模型不支持图片输入时,将 `VISION_MODEL` 配置为单独的 VLM;如果服务中没有视觉模型,请留空并避免使用图片理解功能。
764
+ 3. `EMBEDDING__DIM` 和 `MILVUS__DIM` 必须一致。更换向量维度后,需要备份并删除旧的 `workspace/milvus_lite.db`,再重新入库。
765
+ 4. 使用多模态 Embedding 时建议设置 `EMBEDDING__MODEL_TYPE=multimodal`。
766
+ 5. 如果 AX8850 上只启动了核心的 LLM 和 Embedding 服务,可删除或注释 STT、TTS、OCR 配置。
767
+
768
+ ### 5. 启动项目
769
+
770
+ ```bash
771
+ # 首次运行或前端发生变化时构建 Web UI
772
+ cd frontend && npm install && npm run build && cd ..
773
+
774
+ # 启动后端及 Web UI
775
+ python -m agentic_rag serve --host 0.0.0.0 --port 8007
776
+ ```
777
+
778
+ ---
779
+
agentic_rag/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Agentic RAG - Multi-modal ReAct-powered RAG System."""
2
+
3
+ __version__ = "0.1.0"
agentic_rag/__main__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Entry point for `python -m agentic_rag`."""
2
+
3
+ from agentic_rag.entrypoints.cli.main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
agentic_rag/agent/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Agentic RAG module."""
agentic_rag/agent/react_engine.py ADDED
@@ -0,0 +1,608 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReAct Engine — the core Think → Act → Observe loop."""
2
+
3
+ import asyncio
4
+ import json
5
+ import time
6
+ import uuid
7
+ from typing import AsyncIterator, Optional
8
+
9
+ from agentic_rag.data.models import (
10
+ AgentEvent,
11
+ AgentEventType,
12
+ AgentInput,
13
+ AgentOutput,
14
+ LLMChunk,
15
+ LLMResponse,
16
+ Message,
17
+ ToolCall,
18
+ ToolCallResult,
19
+ ToolDefinition,
20
+ )
21
+ from agentic_rag.agent.react_parser import (
22
+ ReActStep,
23
+ extract_final_answer,
24
+ format_observation,
25
+ parse_react_output,
26
+ )
27
+ from agentic_rag.agent.react_prompt import build_react_prompt, build_tools_description
28
+ from agentic_rag.services.llm.base import (
29
+ BaseLLMProvider,
30
+ ReasoningStreamFilter,
31
+ strip_reasoning,
32
+ )
33
+
34
+
35
+ class ReActEngine:
36
+ """ReAct (Reasoning + Acting) reasoning engine.
37
+
38
+ Executes the Think → Act → Observe loop that powers the agent.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ llm: BaseLLMProvider,
44
+ tools: list,
45
+ system_prompt_template: str,
46
+ max_iterations: int = 10,
47
+ stop_on_error: bool = False,
48
+ enable_native_tool_calls: bool = True,
49
+ require_tool_call: bool = False,
50
+ ):
51
+ """
52
+ Args:
53
+ llm: LLM provider for generation.
54
+ tools: List of BaseTool instances available to the agent.
55
+ system_prompt_template: Template string for the system prompt.
56
+ max_iterations: Maximum ReAct loop iterations.
57
+ stop_on_error: If True, stop on first tool error.
58
+ enable_native_tool_calls: If False, tools are NOT sent to the LLM
59
+ (pure ReAct text mode for models without function calling).
60
+ require_tool_call: If True, reject a final answer until at least one
61
+ available tool has completed successfully. Used for freshness-
62
+ sensitive queries where model memory is not acceptable evidence.
63
+ """
64
+ self.llm = llm
65
+ self.tools = tools
66
+ self.system_prompt_template = system_prompt_template
67
+ self.max_iterations = max_iterations
68
+ self.stop_on_error = stop_on_error
69
+ self.enable_native_tool_calls = enable_native_tool_calls
70
+ self.require_tool_call = require_tool_call
71
+ self._tool_map = {t.name: t for t in tools}
72
+
73
+ async def run(self, input: AgentInput, turn_id: str = "") -> AgentOutput:
74
+ """Execute the ReAct loop (non-streaming)."""
75
+ if not turn_id:
76
+ turn_id = uuid.uuid4().hex
77
+
78
+ messages = self._build_initial_messages(input)
79
+ tool_calls_made: list[ToolCallResult] = []
80
+ total_usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0}
81
+ invalid_output_count = 0 # Track consecutive invalid outputs
82
+ max_invalid_attempts = 2 # Force final answer after 2 consecutive invalid outputs
83
+ executed_sigs: set[str] = set() # (tool, args) dedupe — mirrors stream()
84
+
85
+ for iteration in range(self.max_iterations):
86
+ # Contract for the NEXT turn: a native function-call round ends with
87
+ # "continue", so if no new tool result was appended after this point
88
+ # the model must answer in text ReAct format (never emit format-only
89
+ # text alongside a function call, or the final tool result gets
90
+ # ignored and the last observation is lost).
91
+ n_msgs_before = len(messages)
92
+ messages.append(Message.user(
93
+ "【重要】如果你决定调用工具,请只发起工具调用,不要同时输出任何 Thought/Action 格式行;"
94
+ "如果你不调用工具,请按格式输出:Thought: ... Final Answer: ...(或 Thought/Action/Action Input 发起文本式工具调用)。"
95
+ ))
96
+ response = await self.llm.agenerate(messages, self._get_llm_tool_definitions())
97
+ if len(messages) > n_msgs_before:
98
+ messages.pop() # remove the contract — don't pollute history
99
+
100
+ total_usage["prompt_tokens"] += response.usage.get("prompt_tokens", 0)
101
+ total_usage["completion_tokens"] += response.usage.get("completion_tokens", 0)
102
+
103
+ # Handle native tool calls (from providers that support function calling)
104
+ if response.tool_calls:
105
+ invalid_output_count = 0 # Reset on valid tool call
106
+ for tc in response.tool_calls:
107
+ args = tc.arguments or {}
108
+ if not args:
109
+ messages.append(Message.tool(
110
+ content=f"Error: '{tc.name}' 缺少参数",
111
+ tool_call_id=tc.id,
112
+ ))
113
+ continue
114
+ sig = (tc.name, json.dumps(args, sort_keys=True, ensure_ascii=False))
115
+ if sig in executed_sigs:
116
+ messages.append(Message.tool(
117
+ content=f"Error: 禁止重复调用 '{tc.name}'(参数相同)。请基于已有 Observation 直��输出 Final Answer。",
118
+ tool_call_id=tc.id,
119
+ ))
120
+ continue
121
+ executed_sigs.add(sig)
122
+ result = await self._execute_tool(tc.name, args)
123
+ tool_calls_made.append(result)
124
+ messages.append(Message.tool(
125
+ content=str(result.result) if not result.error else f"Error: {result.error}",
126
+ tool_call_id=tc.id,
127
+ ))
128
+ continue
129
+
130
+ # Parse ReAct format output
131
+ step = parse_react_output(response.content, list(self._tool_map.keys()))
132
+
133
+ if step.is_final:
134
+ if self.require_tool_call and not self._has_successful_tool_result(tool_calls_made):
135
+ invalid_output_count += 1
136
+ messages.append(Message.assistant(strip_reasoning(response.content)))
137
+ messages.append(Message.user(
138
+ "该问题必须先调用可用的网络搜索工具并获得成功的 Observation。"
139
+ "不得依据模型记忆直接回答,也不得自行生成引用。请立即调用工具。"
140
+ ))
141
+ continue
142
+ return AgentOutput(
143
+ messages=messages,
144
+ final_answer=step.final_answer,
145
+ tool_calls_made=tool_calls_made,
146
+ usage=total_usage,
147
+ iterations=iteration + 1,
148
+ )
149
+
150
+ if step.action:
151
+ invalid_output_count = 0 # Reset on valid action
152
+ # Guard: skip if action_input is empty (broken parse)
153
+ if not step.action_input:
154
+ messages.append(Message.user(
155
+ f"'{step.action}' 需要参数,请在 Action Input 中提供 JSON。"
156
+ ))
157
+ continue
158
+ sig = (step.action, json.dumps(step.action_input, sort_keys=True, ensure_ascii=False))
159
+ if sig in executed_sigs:
160
+ messages.append(Message.user(
161
+ f"你已经用相同参数调用过 '{step.action}' 了。请基于已有 Observation 直接输出 Final Answer。"
162
+ ))
163
+ continue
164
+ executed_sigs.add(sig)
165
+ # Execute the tool
166
+ result = await self._execute_tool(step.action, step.action_input)
167
+ tool_calls_made.append(result)
168
+
169
+ # Format observation and append to messages
170
+ observation = format_observation(
171
+ step.action,
172
+ str(result.result) if result.result else "",
173
+ result.error,
174
+ )
175
+ # Append the assistant's ReAct output + observation as a single message
176
+ messages.append(Message.assistant(response.content))
177
+ messages.append(Message.user(observation))
178
+ else:
179
+ # No valid action and no final answer — LLM output is unparseable
180
+ invalid_output_count += 1
181
+ if invalid_output_count >= max_invalid_attempts:
182
+ if self.require_tool_call and not self._has_successful_tool_result(tool_calls_made):
183
+ return AgentOutput(
184
+ messages=messages,
185
+ final_answer=self._live_search_failure_answer(),
186
+ tool_calls_made=tool_calls_made,
187
+ usage=total_usage,
188
+ iterations=iteration + 1,
189
+ )
190
+ # Force exit after repeated invalid outputs to prevent infinite loop
191
+ final = extract_final_answer(response.content)
192
+ if not final:
193
+ # Use accumulated content as fallback answer
194
+ final = response.content.strip() or "抱歉,我暂时无法回答这个问题。"
195
+ return AgentOutput(
196
+ messages=messages,
197
+ final_answer=final,
198
+ tool_calls_made=tool_calls_made,
199
+ usage=total_usage,
200
+ iterations=iteration + 1,
201
+ )
202
+ # Prompt LLM to continue with correct format
203
+ messages.append(Message.user(
204
+ "你的输出格式不正确。请严格按照以下格式之一输出:\n"
205
+ "1. 调用工具:Thought: ...\nAction: tool_name\nAction Input: {\"param\": \"value\"}\n"
206
+ "2. 最终答案:Thought: ...\nFinal Answer: ..."
207
+ ))
208
+
209
+ # Max iterations reached
210
+ if self.require_tool_call and not self._has_successful_tool_result(tool_calls_made):
211
+ final = self._live_search_failure_answer()
212
+ else:
213
+ final = await self._force_final_answer(messages)
214
+ return AgentOutput(
215
+ messages=messages,
216
+ final_answer=final,
217
+ tool_calls_made=tool_calls_made,
218
+ usage=total_usage,
219
+ iterations=self.max_iterations,
220
+ )
221
+
222
+ async def stream(self, input: AgentInput, turn_id: str = "") -> AsyncIterator[AgentEvent]:
223
+ """Execute the ReAct loop with streaming events."""
224
+ if not turn_id:
225
+ turn_id = uuid.uuid4().hex
226
+
227
+ messages = self._build_initial_messages(input)
228
+ tool_calls_made: list[ToolCallResult] = []
229
+ invalid_output_count = 0 # Track consecutive invalid outputs
230
+ max_invalid_attempts = 2 # Force final answer after 2 consecutive invalid outputs
231
+ executed_sigs: set[tuple[str, str]] = set()
232
+
233
+ for iteration in range(self.max_iterations):
234
+ # Emit thought event
235
+ yield AgentEvent(
236
+ event_type=AgentEventType.THOUGHT,
237
+ data={"iteration": iteration},
238
+ turn_id=turn_id,
239
+ )
240
+
241
+ # Stream LLM generation — collect both text deltas AND native tool calls.
242
+ full_content = ""
243
+ # Native function-calling state (accumulated across streaming chunks)
244
+ native_tool_name = ""
245
+ native_tool_args = ""
246
+ has_native_tool_call = False
247
+ stream_error = None
248
+ # Incremental reasoning stripper — thinking models (<think> blocks)
249
+ # must not leak their reasoning to the UI or into the ReAct parser.
250
+ think_filter = ReasoningStreamFilter()
251
+ # Answer gating: only stream the actual answer text to the UI.
252
+ # "Thought:" lines and any pre-answer monologue are buffered and
253
+ # dropped — the final answer is extracted from full_content below.
254
+ pending_delta = ""
255
+ answer_streaming = False
256
+ try:
257
+ async for chunk in self.llm.agenerate_stream(messages, self._get_llm_tool_definitions()):
258
+ if chunk.content_delta:
259
+ delta = think_filter.feed(chunk.content_delta)
260
+ full_content += delta
261
+ # After tool call detection, further text is likely
262
+ # post-tool narration — suppress to reduce noise.
263
+ if not delta or has_native_tool_call:
264
+ continue
265
+ if answer_streaming:
266
+ emit = delta
267
+ else:
268
+ pending_delta += delta
269
+ if "Final Answer:" in pending_delta:
270
+ emit = pending_delta.split("Final Answer:", 1)[1].lstrip()
271
+ answer_streaming = True
272
+ else:
273
+ continue
274
+ if emit:
275
+ yield AgentEvent(
276
+ event_type=AgentEventType.TEXT_DELTA,
277
+ data={"content": emit},
278
+ turn_id=turn_id,
279
+ )
280
+ # Collect native function-call deltas (Qwen / OpenAI function calling)
281
+ if chunk.tool_call_delta:
282
+ has_native_tool_call = True
283
+ if chunk.tool_call_delta.get("name"):
284
+ native_tool_name = chunk.tool_call_delta["name"]
285
+ if chunk.tool_call_delta.get("arguments"):
286
+ native_tool_args += chunk.tool_call_delta["arguments"]
287
+ # Flush text held back for partial-tag detection
288
+ tail = think_filter.flush()
289
+ if tail:
290
+ full_content += tail
291
+ if not has_native_tool_call:
292
+ if answer_streaming:
293
+ yield AgentEvent(
294
+ event_type=AgentEventType.TEXT_DELTA,
295
+ data={"content": tail},
296
+ turn_id=turn_id,
297
+ )
298
+ else:
299
+ pending_delta += tail
300
+ if "Final Answer:" in pending_delta:
301
+ emit = pending_delta.split("Final Answer:", 1)[1].lstrip()
302
+ if emit:
303
+ answer_streaming = True
304
+ yield AgentEvent(
305
+ event_type=AgentEventType.TEXT_DELTA,
306
+ data={"content": emit},
307
+ turn_id=turn_id,
308
+ )
309
+ except Exception as e:
310
+ stream_error = str(e)
311
+ import sys
312
+ print(f" [ReAct] ⚠ LLM stream error (iteration {iteration}): {e}", flush=True)
313
+ sys.stdout.flush()
314
+
315
+ # ── Handle stream error ──
316
+ if stream_error and not full_content.strip():
317
+ yield AgentEvent(
318
+ event_type=AgentEventType.ERROR,
319
+ data={"error": f"LLM stream failed: {stream_error}"},
320
+ turn_id=turn_id,
321
+ )
322
+ yield AgentEvent(
323
+ event_type=AgentEventType.DONE,
324
+ data={"final_answer": f"抱歉,模型服务连接中断:{stream_error},请稍后重试。"},
325
+ turn_id=turn_id,
326
+ )
327
+ return
328
+
329
+ # ── Resolve action: native function calling takes priority ──
330
+ if has_native_tool_call and native_tool_name:
331
+ invalid_output_count = 0 # Reset on valid tool call
332
+ try:
333
+ action_input = json.loads(native_tool_args) if native_tool_args else {}
334
+ except json.JSONDecodeError:
335
+ action_input = {"query": native_tool_args} if native_tool_args else {}
336
+ sig = (native_tool_name, json.dumps(action_input, sort_keys=True, ensure_ascii=False))
337
+ if sig in executed_sigs:
338
+ messages.append(Message.user(
339
+ f"你已经用相同参数调用过 '{native_tool_name}'。请根据已有 Observation 输出 Final Answer。"
340
+ ))
341
+ continue
342
+ executed_sigs.add(sig)
343
+
344
+ yield AgentEvent(
345
+ event_type=AgentEventType.TOOL_CALL_START,
346
+ data={"tool": native_tool_name, "input": action_input},
347
+ turn_id=turn_id,
348
+ )
349
+ result = await self._execute_tool(native_tool_name, action_input)
350
+ tool_calls_made.append(result)
351
+ yield AgentEvent(
352
+ event_type=AgentEventType.TOOL_CALL_RESULT,
353
+ data={
354
+ "tool": native_tool_name,
355
+ "success": not result.error,
356
+ "result": str(result.result)[:500] if result.result else "",
357
+ "error": result.error,
358
+ },
359
+ turn_id=turn_id,
360
+ )
361
+ observation = format_observation(
362
+ native_tool_name,
363
+ str(result.result) if result.result else "",
364
+ result.error,
365
+ )
366
+ messages.append(Message.assistant(
367
+ strip_reasoning(full_content).strip()
368
+ or f"Thought: 调用 {native_tool_name}\nAction: {native_tool_name}\nAction Input: {json.dumps(action_input, ensure_ascii=False)}"
369
+ ))
370
+ messages.append(Message.user(observation))
371
+ continue
372
+
373
+ # ── Fallback: parse ReAct text format ──
374
+ step = parse_react_output(full_content, list(self._tool_map.keys()))
375
+
376
+ if step.is_final:
377
+ if self.require_tool_call and not self._has_successful_tool_result(tool_calls_made):
378
+ invalid_output_count += 1
379
+ messages.append(Message.assistant(strip_reasoning(full_content)))
380
+ messages.append(Message.user(
381
+ "该问题必须先调用可用的网络搜索工具并获得成功的 Observation。"
382
+ "不得依据模型记忆直接回答,也不得自行生成引用。请立即调用工具。"
383
+ ))
384
+ continue
385
+ yield AgentEvent(
386
+ event_type=AgentEventType.DONE,
387
+ data={"final_answer": step.final_answer, "iterations": iteration + 1},
388
+ turn_id=turn_id,
389
+ )
390
+ return
391
+
392
+ if step.action:
393
+ invalid_output_count = 0 # Reset on valid action
394
+ # Guard: if action_input is empty and the tool requires args, skip
395
+ if not step.action_input:
396
+ messages.append(Message.user(
397
+ f"'{step.action}' 需要参数,请在 Action Input 中提供 JSON。"
398
+ ))
399
+ continue
400
+ sig = (step.action, json.dumps(step.action_input, sort_keys=True, ensure_ascii=False))
401
+ if sig in executed_sigs:
402
+ messages.append(Message.user(
403
+ f"你已经用相同参数调用过 '{step.action}'。请根据已有 Observation 输出 Final Answer。"
404
+ ))
405
+ continue
406
+ executed_sigs.add(sig)
407
+
408
+ yield AgentEvent(
409
+ event_type=AgentEventType.TOOL_CALL_START,
410
+ data={"tool": step.action, "input": step.action_input},
411
+ turn_id=turn_id,
412
+ )
413
+
414
+ result = await self._execute_tool(step.action, step.action_input)
415
+ tool_calls_made.append(result)
416
+
417
+ yield AgentEvent(
418
+ event_type=AgentEventType.TOOL_CALL_RESULT,
419
+ data={
420
+ "tool": step.action,
421
+ "success": not result.error,
422
+ "result": str(result.result)[:500] if result.result else "",
423
+ "error": result.error,
424
+ },
425
+ turn_id=turn_id,
426
+ )
427
+
428
+ observation = format_observation(
429
+ step.action,
430
+ str(result.result) if result.result else "",
431
+ result.error,
432
+ )
433
+ messages.append(Message.assistant(strip_reasoning(full_content)))
434
+ messages.append(Message.user(observation))
435
+ else:
436
+ # No valid action and no final answer — LLM output is unparseable
437
+ invalid_output_count += 1
438
+ if invalid_output_count >= max_invalid_attempts:
439
+ if self.require_tool_call and not self._has_successful_tool_result(tool_calls_made):
440
+ yield AgentEvent(
441
+ event_type=AgentEventType.DONE,
442
+ data={"final_answer": self._live_search_failure_answer(),
443
+ "iterations": iteration + 1},
444
+ turn_id=turn_id,
445
+ )
446
+ return
447
+ # Force exit after repeated invalid outputs to prevent infinite loop
448
+ final = extract_final_answer(full_content)
449
+ if not final:
450
+ final = await self._force_final_answer(messages)
451
+ if not final or ("Thought:" in final and "Action" in final):
452
+ final = full_content.strip()
453
+ yield AgentEvent(
454
+ event_type=AgentEventType.DONE,
455
+ data={"final_answer": final or "抱歉,我暂时无法回答这个问题。",
456
+ "iterations": iteration + 1},
457
+ turn_id=turn_id,
458
+ )
459
+ return
460
+ messages.append(Message.user(
461
+ "你的输出格式不正确。请严格按照以下格式之一输出:\n"
462
+ "1. 调用工具:Thought: ...\nAction: tool_name\nAction Input: {\"param\": \"value\"}\n"
463
+ "2. 最终答案:Thought: ...\nFinal Answer: ..."
464
+ ))
465
+
466
+ # Max iterations — force LLM to give a final answer with what it has
467
+ if self.require_tool_call and not self._has_successful_tool_result(tool_calls_made):
468
+ final = self._live_search_failure_answer()
469
+ else:
470
+ final = await self._force_final_answer(messages)
471
+ yield AgentEvent(
472
+ event_type=AgentEventType.DONE,
473
+ data={"final_answer": final or "Max iterations reached. Could not complete the task.",
474
+ "iterations": self.max_iterations},
475
+ turn_id=turn_id,
476
+ )
477
+
478
+ def _build_initial_messages(self, input: AgentInput) -> list[Message]:
479
+ """Build the initial message list for the ReAct loop."""
480
+ tools_desc = build_tools_description(self._get_tool_definitions())
481
+ memory_context = self._format_messages(input.messages)
482
+
483
+ system_prompt = build_react_prompt(
484
+ tools_description=tools_desc,
485
+ memory_context=memory_context,
486
+ )
487
+
488
+ messages = [Message.system(system_prompt)]
489
+
490
+ # Check if input already has a multimodal user message
491
+ has_multimodal_query = any(
492
+ isinstance(m.content, list) and m.role.value == "user"
493
+ for m in input.messages
494
+ )
495
+
496
+ # Add conversation history (excluding system messages)
497
+ for msg in input.messages:
498
+ if msg.role.value != "system":
499
+ messages.append(msg)
500
+
501
+ # Add current query — skip if already included as multimodal message
502
+ if not has_multimodal_query:
503
+ query = input.query
504
+ if input.multimodal and input.multimodal.text:
505
+ query = input.multimodal.text
506
+ messages.append(Message.user(query))
507
+
508
+ return messages
509
+
510
+ async def _execute_tool(self, name: str, arguments: dict) -> ToolCallResult:
511
+ """Execute a tool by name with arguments.
512
+
513
+ Falls back to the global tool registry when the tool isn't in this
514
+ engine's filtered set: the model sometimes emits a tool it knows from
515
+ context (e.g. an MCP tool) that the router didn't hand it, and
516
+ answering "tool not found" wastes a turn when the tool actually
517
+ exists in the process.
518
+ """
519
+ call_id = f"call_{uuid.uuid4().hex[:12]}"
520
+
521
+ tool = self._tool_map.get(name)
522
+ if tool is None:
523
+ try:
524
+ from agentic_rag.orchestration.l1_tools.registry import get_tool_registry
525
+ tool = get_tool_registry().get(name)
526
+ except Exception:
527
+ tool = None
528
+ if tool is None:
529
+ return ToolCallResult(
530
+ call_id=call_id,
531
+ name=name,
532
+ result=None,
533
+ error=f"Tool '{name}' not found. Available: {list(self._tool_map.keys())}",
534
+ )
535
+
536
+ try:
537
+ # Add timeout to prevent hanging on slow tools
538
+ result = await asyncio.wait_for(tool.execute(**arguments), timeout=60.0)
539
+ return ToolCallResult(
540
+ call_id=call_id,
541
+ name=name,
542
+ result=result,
543
+ )
544
+ except asyncio.TimeoutError:
545
+ return ToolCallResult(
546
+ call_id=call_id,
547
+ name=name,
548
+ result=None,
549
+ error=f"Tool '{name}' execution timed out after 60 seconds",
550
+ )
551
+ except Exception as e:
552
+ return ToolCallResult(
553
+ call_id=call_id,
554
+ name=name,
555
+ result=None,
556
+ error=str(e),
557
+ )
558
+
559
+ @staticmethod
560
+ def _has_successful_tool_result(results: list[ToolCallResult]) -> bool:
561
+ """Return whether at least one tool produced usable evidence."""
562
+ return any(not result.error and result.result for result in results)
563
+
564
+ @staticmethod
565
+ def _live_search_failure_answer() -> str:
566
+ """Fail closed when fresh information could not be retrieved."""
567
+ return (
568
+ "当前问题需要实时网络信息,但本次未能获得有效的搜索结果,"
569
+ "因此无法可靠确认。请稍后重试;为避免误导,我不会使用模型记忆猜测答案或编造来源。"
570
+ )
571
+
572
+ def _get_tool_definitions(self) -> list[ToolDefinition]:
573
+ """Get all tool definitions for prompt construction and parsing."""
574
+ return [t.to_definition() for t in self.tools]
575
+
576
+ def _get_llm_tool_definitions(self) -> list[ToolDefinition]:
577
+ """Get definitions passed through the provider's native tools API."""
578
+ if not self.enable_native_tool_calls:
579
+ return []
580
+ return self._get_tool_definitions()
581
+
582
+ @staticmethod
583
+ def _format_messages(messages: list[Message]) -> str:
584
+ """Format conversation history for the prompt."""
585
+ if not messages:
586
+ return ""
587
+ lines = []
588
+ for msg in messages[-10:]: # Last 10 messages
589
+ content = msg.content
590
+ if isinstance(content, list):
591
+ # Extract text parts for history summary
592
+ texts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("text")]
593
+ img_count = sum(1 for p in content if isinstance(p, dict) and p.get("type") == "image_url")
594
+ parts = texts
595
+ if img_count:
596
+ parts.append(f"[{img_count} image(s)]")
597
+ content = " ".join(parts) if parts else "[multimodal content]"
598
+ lines.append(f"{msg.role.value}: {str(content)[:200]}")
599
+ return "\n".join(lines)
600
+
601
+ async def _force_final_answer(self, messages: list[Message]) -> str:
602
+ """Force the LLM to produce a final answer when max iterations are reached."""
603
+ messages.append(Message.user(
604
+ "已达到最大步数。请基于已有信息给出 Final Answer。"
605
+ ))
606
+ response = await self.llm.agenerate(messages)
607
+ final = extract_final_answer(response.content)
608
+ return final or response.content
agentic_rag/agent/react_parser.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parser for ReAct outputs — extracts Thought, Action, and Final Answer."""
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+
9
+ @dataclass
10
+ class ReActStep:
11
+ """A single step of the ReAct loop."""
12
+ thought: str = ""
13
+ action: str = ""
14
+ action_input: dict = None
15
+ is_final: bool = False
16
+ final_answer: str = ""
17
+ raw_text: str = ""
18
+
19
+ def __post_init__(self):
20
+ if self.action_input is None:
21
+ self.action_input = {}
22
+
23
+
24
+ def parse_react_output(text: str, tool_names: list[str] | None = None) -> ReActStep:
25
+ """Parse the LLM output into a ReAct step.
26
+
27
+ Handles these patterns:
28
+ - Thought: ...
29
+ - Action: tool_name
30
+ - Action Input: {...}
31
+ OR
32
+ - Thought: ...
33
+ - Final Answer: ...
34
+ """
35
+ # Truncate repetitive output — if the model loops on the same sentence,
36
+ # cut at the first repetition to keep only useful content.
37
+ text = _trim_repetition(text)
38
+
39
+ # Strip any residual reasoning (<think> blocks) — reasoning text may
40
+ # contain rehearsed "Thought:/Action:" lines that must NOT be parsed as
41
+ # real actions. Providers normally strip this already; this is a safety net.
42
+ from agentic_rag.services.llm.base import strip_reasoning
43
+ text = strip_reasoning(text)
44
+
45
+ result = ReActStep(raw_text=text)
46
+
47
+ # Extract Thought
48
+ thought_match = re.search(r'Thought:\s*(.+?)(?=\n(?:Action|Final Answer)|$)', text, re.DOTALL)
49
+ if thought_match:
50
+ result.thought = thought_match.group(1).strip()
51
+
52
+ # Check for Final Answer first
53
+ final_match = re.search(r'Final Answer:\s*(.+)', text, re.DOTALL)
54
+ if final_match:
55
+ result.is_final = True
56
+ result.final_answer = final_match.group(1).strip()
57
+ return result
58
+
59
+ # Extract Action — handle Chinese-descriptive Action lines like:
60
+ # Action: 使用 rag_search 搜索...
61
+ # Action: 调用 mcp__tavily-mcp__tavily_search 查询...
62
+ action_match = re.search(r'Action:\s*(.+?)(?:\n|$)', text)
63
+ if action_match:
64
+ action_text = action_match.group(1).strip()
65
+ result.action = _resolve_tool_name(action_text, tool_names or [])
66
+ # Re-parse with stricter matching if failed
67
+ if not result.action:
68
+ action_match2 = re.search(r'Action:\s*(\S+)', text)
69
+ if action_match2:
70
+ result.action = _resolve_tool_name(action_match2.group(1).strip(), tool_names or [])
71
+
72
+ # Extract Action Input (try JSON first, then key=value)
73
+ action_input_match = re.search(r'Action Input:\s*(\{.+?\}|.+)', text, re.DOTALL)
74
+ if action_input_match:
75
+ input_str = action_input_match.group(1).strip()
76
+ result.action_input = parse_action_input(input_str)
77
+
78
+ return result
79
+
80
+
81
+ def _trim_repetition(text: str) -> str:
82
+ """Detect and cut repetitive LLM output at the first repetition point.
83
+
84
+ When a model loops — e.g. "I will output the answer... I will output the answer..."
85
+ — truncate everything after the first occurrence of the repeated line.
86
+ """
87
+ lines = text.split("\n")
88
+ seen: set[str] = set()
89
+ clean_lines: list[str] = []
90
+
91
+ for line in lines:
92
+ stripped = line.strip()
93
+ # Skip empty lines in repetition check
94
+ if not stripped:
95
+ clean_lines.append(line)
96
+ continue
97
+ # Normalize for comparison
98
+ norm = stripped.lower().rstrip(".。!!??,,")
99
+ if norm in seen:
100
+ # Found repetition — stop here
101
+ break
102
+ if len(norm) > 15: # only track meaningful lines
103
+ seen.add(norm)
104
+ clean_lines.append(line)
105
+
106
+ return "\n".join(clean_lines)
107
+
108
+
109
+ def _resolve_tool_name(action_text: str, tool_names: list[str]) -> str:
110
+ """Extract the actual tool name from an Action line that may contain Chinese description.
111
+
112
+ Example inputs → outputs:
113
+ "rag_search" → "rag_search"
114
+ "使用 rag_search 搜索" → "rag_search"
115
+ "调用 mcp__tavily-mcp__tavily_search 查询" → "mcp__tavily-mcp__tavily_search"
116
+ "搜索文档" → "" (no tool found)
117
+ """
118
+ # Already a clean single identifier
119
+ if re.match(r'^[a-zA-Z_][a-zA-Z0-9_\-/]*$', action_text):
120
+ if tool_names:
121
+ if action_text in tool_names:
122
+ return action_text
123
+ # Fuzzy: unique tool ending with the model's text
124
+ # ("tavily_search" → "mcp__tavily-mcp__tavily_search")
125
+ matches = [t for t in tool_names if t.endswith(action_text)]
126
+ if len(matches) == 1:
127
+ return matches[0]
128
+ return ""
129
+ return action_text
130
+
131
+ # Find tool-like patterns in the text: lowercase_with_underscores, possibly with __ or /
132
+ candidates = re.findall(r'[a-zA-Z_][a-zA-Z0-9_\-/]{2,}', action_text)
133
+ for c in candidates:
134
+ # Must contain underscore (real tools look like rag_search, mcp__xxx__yyy)
135
+ if '_' in c:
136
+ if tool_names:
137
+ if c in tool_names:
138
+ return c
139
+ else:
140
+ return c
141
+
142
+ # Last resort: pick the first ascii word
143
+ for c in candidates:
144
+ if tool_names:
145
+ if c in tool_names:
146
+ return c
147
+ else:
148
+ return c
149
+
150
+ # Fuzzy match: unique tool whose name contains the model's text.
151
+ # Handles cases like "tavily_search" → "mcp__tavily-mcp__tavily_search".
152
+ # The model's text must be a real suffix/substring, and only ONE tool may
153
+ # match (otherwise it's ambiguous and we refuse to guess).
154
+ if tool_names and len(action_text) >= 4 and '_' in action_text:
155
+ matches = [t for t in tool_names if t.endswith(action_text) or t.split('__')[-1] == action_text]
156
+ if len(matches) == 1:
157
+ return matches[0]
158
+
159
+ return ""
160
+
161
+
162
+ def parse_action_input(input_str: str) -> dict:
163
+ """Parse action input string into a dict. Tries JSON first, then key=value."""
164
+ # Try JSON
165
+ try:
166
+ return json.loads(input_str)
167
+ except json.JSONDecodeError:
168
+ pass
169
+
170
+ # Try to extract JSON from within the string
171
+ json_match = re.search(r'\{[^{}]*\}', input_str)
172
+ if json_match:
173
+ try:
174
+ return json.loads(json_match.group(0))
175
+ except json.JSONDecodeError:
176
+ pass
177
+
178
+ # Fallback: treat as raw string
179
+ if input_str:
180
+ return {"query": input_str}
181
+
182
+ return {}
183
+
184
+
185
+ def extract_final_answer(text: str) -> Optional[str]:
186
+ """Extract the final answer from text if present."""
187
+ from agentic_rag.services.llm.base import strip_reasoning
188
+ text = strip_reasoning(text)
189
+ match = re.search(r'Final Answer:\s*(.+)', text, re.DOTALL)
190
+ if match:
191
+ return match.group(1).strip()
192
+ return None
193
+
194
+
195
+ def is_final_answer(text: str) -> bool:
196
+ """Check if the text contains a Final Answer marker."""
197
+ return "Final Answer:" in text
198
+
199
+
200
+ def format_observation(tool_name: str, result: str, error: Optional[str] = None) -> str:
201
+ """Format a tool execution result as an Observation."""
202
+ if error:
203
+ return f"Observation: Error executing '{tool_name}': {error}"
204
+ # Truncate very long results — keeps the ReAct prompt compact so the
205
+ # model has less material to over-analyze on the next turn.
206
+ if len(result) > 1500:
207
+ result = result[:1500] + "... (truncated)"
208
+ return f"Observation: {result}"
agentic_rag/agent/react_prompt.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ReAct prompt builder — delegates to centralized prompts module.
2
+
3
+ v2: Compact tool descriptions, Chinese-first with English fallback.
4
+ """
5
+
6
+ from agentic_rag.config.prompts import Prompts
7
+
8
+ # Legacy alias — still used by router.py and react_engine.py
9
+ SYSTEM_PROMPT = Prompts.react_system()
10
+
11
+
12
+ def build_react_prompt(
13
+ tools_description: str,
14
+ memory_context: str = "",
15
+ ) -> str:
16
+ """Build the ReAct system prompt with tool descriptions and conversation history.
17
+
18
+ Uses the Chinese-first prompt by default. Call ``build_react_prompt_en()``
19
+ for the English variant.
20
+ """
21
+ return SYSTEM_PROMPT.format(
22
+ tools_description=tools_description,
23
+ memory_context=memory_context or "(无历史)",
24
+ )
25
+
26
+
27
+ def build_react_prompt_en(
28
+ tools_description: str,
29
+ memory_context: str = "",
30
+ ) -> str:
31
+ """Build the English variant of the ReAct system prompt."""
32
+ return Prompts.react_system_en().format(
33
+ tools_description=tools_description,
34
+ memory_context=memory_context or "(no history)",
35
+ )
36
+
37
+
38
+ def build_tools_description(tools) -> str:
39
+ """Build a compact text description of available tools.
40
+
41
+ Format (one line per tool + compact param list):
42
+ rag_search: 搜索知识库
43
+ query (required): 搜索关键词
44
+ top_k: 返回数量 [default: 5]
45
+ """
46
+ lines = []
47
+ for tool in tools:
48
+ # Handle both ToolDefinition/Pydantic model and plain dict
49
+ if hasattr(tool, 'name'):
50
+ name = tool.name
51
+ desc = tool.description
52
+ params = tool.parameters
53
+ else:
54
+ name = tool.get("name", "unknown")
55
+ desc = tool.get("description", "")
56
+ params = tool.get("parameters", {})
57
+
58
+ params_props = params.get("properties", {}) if isinstance(params, dict) else {}
59
+ if isinstance(params_props, dict) and params_props:
60
+ required_params = params.get("required", []) if isinstance(params, dict) else []
61
+ param_strs = []
62
+ for pname, pinfo in params_props.items():
63
+ req_mark = " (必填)" if pname in required_params else ""
64
+ pdesc = pinfo.get("description", "") if isinstance(pinfo, dict) else str(pinfo)
65
+ if isinstance(pinfo, dict) and "default" in pinfo:
66
+ pdesc += f" [默认: {pinfo['default']}]"
67
+ if isinstance(pinfo, dict) and "enum" in pinfo:
68
+ pdesc += f" (可选: {', '.join(str(v) for v in pinfo['enum'])})"
69
+ param_strs.append(f" {pname}: {pdesc}{req_mark}")
70
+ params_block = "\n".join(param_strs)
71
+ else:
72
+ params_block = " (无参数)"
73
+
74
+ lines.append(f"{name}: {desc}\n{params_block}")
75
+
76
+ return "\n".join(lines)
agentic_rag/agent/router.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent Router — lightweight query analysis to select the right tool set.
2
+
3
+ With a single ReAct prompt, the router's job is simply to decide which tools
4
+ to give the agent based on the query. The agent itself decides how to use them.
5
+ """
6
+
7
+ import re
8
+
9
+ from agentic_rag.agent.react_engine import ReActEngine
10
+ from agentic_rag.agent.react_prompt import SYSTEM_PROMPT
11
+ from agentic_rag.services.llm.base import BaseLLMProvider
12
+
13
+
14
+ class AgentRouter:
15
+ """Routes queries by selecting the appropriate tool subset.
16
+
17
+ The core insight: one agent, one prompt, variable tool sets.
18
+ The ReAct engine decides which tool to invoke — the router just
19
+ provides the right tools for the job.
20
+ """
21
+
22
+ # Tool subsets for different query types.
23
+ # Web search is built-in (DuckDuckGo fallback); additional fetch and code
24
+ # execution tools are provided via MCP servers.
25
+ TOOL_SETS = {
26
+ "chat": ["rag_search", "web_search"],
27
+ "rag": ["rag_search", "rag_ingest"],
28
+ "research": ["rag_search", "web_search"],
29
+ "media": ["image_understand", "audio_transcribe", "video_analyze"],
30
+ "meta": [], # No tools needed for meta questions about the agent itself
31
+ }
32
+
33
+ # Keyword hints for routing
34
+ MODE_HINTS = {
35
+ "rag": ["搜索", "查找", "检索", "知识库", "文档", "search", "find", "retrieve"],
36
+ "research": ["研究", "调查", "报告", "深入", "research", "investigate", "comprehensive"],
37
+ "media": ["图片", "图像", "视频", "音频", "语音", "image", "picture", "video", "audio"],
38
+ "meta": ["你是谁", "你是什么", "你能做什么", "你的功能", "自我介绍", "who are you", "what are you", "introduce yourself"],
39
+ }
40
+
41
+ def __init__(self, llm: BaseLLMProvider, tool_registry):
42
+ self.llm = llm
43
+ self.tool_registry = tool_registry
44
+ # Read native-tool-calling preference from the active LLM provider config
45
+ try:
46
+ from agentic_rag.config.settings import get_settings
47
+ settings = get_settings()
48
+ provider_cfg = settings.llm_providers.get(settings.default_provider)
49
+ self._native_tool_calls = provider_cfg.enable_native_tool_calls if provider_cfg else True
50
+ except Exception:
51
+ self._native_tool_calls = True
52
+
53
+ async def route(
54
+ self,
55
+ query: str,
56
+ has_media: bool = False,
57
+ preferred_mode: str | None = None,
58
+ ) -> ReActEngine:
59
+ """Route a query to a ReActEngine with the right tool set.
60
+
61
+ Args:
62
+ query: User's query text.
63
+ has_media: Whether media is attached to the input.
64
+ preferred_mode: Force a specific tool set.
65
+
66
+ Returns:
67
+ Configured ReActEngine ready to execute.
68
+ """
69
+ # Force media tools when media is present
70
+ if has_media:
71
+ mode = "media"
72
+ elif preferred_mode and preferred_mode in self.TOOL_SETS:
73
+ mode = preferred_mode
74
+ else:
75
+ mode = self._classify(query)
76
+
77
+ tool_names = self.TOOL_SETS.get(mode, self.TOOL_SETS["chat"])
78
+ tools = self.tool_registry.filter(tool_names)
79
+
80
+ # MCP tools (web search etc.) — available in all modes except meta.
81
+ # Meta questions about the agent itself don't need external tools.
82
+ if mode != "meta":
83
+ mcp_tools = self.tool_registry.get_mcp_tools()
84
+ seen = {t.name for t in tools}
85
+ for mt in mcp_tools:
86
+ if mt.name not in seen:
87
+ tools.append(mt)
88
+
89
+ # Freshness-sensitive queries receive only tools that advertise a
90
+ # fresh-information capability. MCP tools are selected by semantic
91
+ # metadata rather than by a specific provider or server name.
92
+ requires_live_web = self._requires_live_web(query)
93
+ if requires_live_web:
94
+ live_search_tools = [
95
+ t for t in tools if t.has_capability("fresh_information")
96
+ ]
97
+ external_search_tools = [
98
+ t for t in live_search_tools if t.source == "mcp"
99
+ ]
100
+ # Prefer any external MCP search provider as a class; the built-in
101
+ # DDGS search remains a fallback only when no MCP search capability
102
+ # is connected. No provider or server name is hard-coded here.
103
+ tools = external_search_tools or live_search_tools
104
+
105
+ return ReActEngine(
106
+ llm=self.llm,
107
+ tools=tools,
108
+ system_prompt_template=SYSTEM_PROMPT,
109
+ max_iterations=8 if mode == "research" else 4,
110
+ enable_native_tool_calls=self._native_tool_calls,
111
+ require_tool_call=requires_live_web,
112
+ )
113
+
114
+ @staticmethod
115
+ def _requires_live_web(query: str) -> bool:
116
+ """Return True for queries whose answer depends on current events."""
117
+ query_lower = query.lower()
118
+ live_hints = (
119
+ "最新", "目前", "现在", "今日", "今天", "实时", "结果", "冠军",
120
+ "四强", "4强", "决赛", "半决赛", "排名", "比分", "现任",
121
+ "latest", "current", "today", "result", "winner", "semifinal",
122
+ "semi-final", "ranking", "score",
123
+ )
124
+ # A contemporary year is also a strong signal. This intentionally uses
125
+ # a broad lower bound so deployments don't need annual keyword updates.
126
+ has_recent_year = any(int(y) >= 2025 for y in re.findall(r"\b20\d{2}\b", query_lower))
127
+ return has_recent_year or any(hint in query_lower for hint in live_hints)
128
+
129
+ def _classify(self, query: str) -> str:
130
+ """Simple keyword-based classification."""
131
+ query_lower = query.lower()
132
+ for mode, keywords in self.MODE_HINTS.items():
133
+ if any(kw in query_lower for kw in keywords):
134
+ return mode
135
+ return "chat"
agentic_rag/config/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Agentic RAG module."""
agentic_rag/config/defaults.yaml ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agentic RAG Default Configuration
2
+
3
+ app_name: agentic_rag
4
+ debug: false
5
+ log_level: INFO
6
+
7
+ default_provider: openai
8
+
9
+ # LLM Providers
10
+ llm_providers:
11
+ openai:
12
+ api_key: "${OPENAI_API_KEY}"
13
+ api_base: "https://api.openai.com/v1"
14
+ model: "gpt-4o"
15
+ max_tokens: 4096
16
+ temperature: 0.7
17
+ vision_model: "gpt-4o"
18
+
19
+ claude:
20
+ api_key: "${ANTHROPIC_API_KEY}"
21
+ api_base: "https://api.anthropic.com"
22
+ model: "claude-sonnet-4-20250514"
23
+ max_tokens: 4096
24
+ temperature: 0.7
25
+ vision_model: "claude-sonnet-4-20250514"
26
+
27
+ local:
28
+ api_key: ""
29
+ api_base: "http://localhost:11434/v1"
30
+ model: "llama3"
31
+ max_tokens: 4096
32
+ temperature: 0.7
33
+ vision_model: ""
34
+
35
+ # Embedding Model (for RAG vector search)
36
+ # Can use a different provider from the LLM
37
+ embedding:
38
+ provider: "openai" # Which provider config to use for API key
39
+ model: "text-embedding-3-small" # Embedding model name
40
+ dim: 1536 # Vector dimension (must match Milvus)
41
+ api_key: "" # Override API key (empty = reuse provider's)
42
+ api_base: "" # Override API base (empty = reuse provider's)
43
+ batch_size: 100 # Max texts per batch
44
+
45
+ # Milvus
46
+ milvus:
47
+ host: "localhost"
48
+ port: 19530
49
+ collection_prefix: "agentic_rag"
50
+ dim: 1536
51
+ index_type: "IVF_FLAT"
52
+ metric_type: "COSINE"
53
+
54
+ # Memory
55
+ memory:
56
+ short_term_max_tokens: 8000
57
+ long_term_top_k: 10
58
+ working_memory_max_keys: 50
59
+
60
+ # Session
61
+ session:
62
+ ttl_seconds: 3600
63
+ cleanup_interval_seconds: 300
64
+
65
+ # API
66
+ api:
67
+ host: "0.0.0.0"
68
+ port: 8000
69
+ cors_origins: ["*"]
70
+ rate_limit_per_minute: 60
71
+
72
+ # Voice
73
+ voice:
74
+ stt_model: "whisper-1"
75
+ tts_provider: "edge"
76
+ tts_voice: "zh-CN-XiaoxiaoNeural"
77
+ sample_rate: 16000
78
+
79
+ # MCP Servers (external)
80
+ mcp_servers: {}
agentic_rag/config/prompts.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Centralized prompts for Agentic RAG.
2
+
3
+ All prompt text lives here so they can be reviewed, tuned, and versioned
4
+ in one place. Every consumer imports from this module instead of embedding
5
+ prompt strings inline.
6
+
7
+ Design principles (aligned with LangChain / Claude / NeMo best practices):
8
+ - Strong directives: MUST / 禁止 over fuzzy "should"
9
+ - Thought length cap: prevents verbose narration loops
10
+ - Anti-repetition rules in prompt, not just post-processing
11
+ - Few-shot examples for format grounding
12
+ - Chinese-first output with English fallback
13
+
14
+ Usage::
15
+
16
+ from agentic_rag.config.prompts import Prompts
17
+ prompt = Prompts.react_system().format(tools_description="...", memory_context="...")
18
+ """
19
+
20
+ from datetime import datetime, timezone
21
+
22
+
23
+ class Prompts:
24
+ """Namespace for all prompt templates used across the project."""
25
+
26
+ # ═══════════════════════════════════════════════════════════════
27
+ # ReAct Agent (agent/react_prompt.py)
28
+ # ═══════════════════════════════════════════════════════════════
29
+
30
+ @staticmethod
31
+ def react_system() -> str:
32
+ """ReAct system prompt — pure text mode, no function calling required.
33
+
34
+ The model MUST output ReAct text format. The react_parser.py parses
35
+ Thought/Action/Action Input markers. No native tool calling needed.
36
+
37
+ Key design:
38
+ - Opening instruction forces Thought: as the FIRST line.
39
+ - ≤20 char Thought cap with positive/negative examples.
40
+ - Few-shot example grounds the expected rhythm.
41
+ - "开始!" marker signals the model to start immediately.
42
+ """
43
+ _today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
44
+ return (
45
+ f"你是信息检索智能体。{_today}\n"
46
+ f"用中文回答,除非用户使用英文。\n\n"
47
+
48
+ f"## 输出格式(严格按此顺序)\n"
49
+ f"Thought: <动作,≤50字>\n"
50
+ f"Action: <工具名>\n"
51
+ f"Action Input: <JSON>\n"
52
+ f"[等待工具返回 Observation]\n"
53
+ f"... (可重复)\n"
54
+ f"Final Answer: <答案>\n\n"
55
+
56
+ f"## 示例\n"
57
+ f"用户: 什么是向量数据库?\n"
58
+ f"Thought: 查询知识库\n"
59
+ f"Action: rag_search\n"
60
+ f"Action Input: {{{{'query': '向量数据库'}}}}\n"
61
+ f"Observation: [R1] 向量数据库存储和检索向量嵌入...\n"
62
+ f"Thought: 信息充足\n"
63
+ f"Final Answer: 向量数据库通过ANN算法实现高维向量的相似性搜索。"
64
+ f"📚 参考来源\\n[R1] ...\n\n"
65
+ f"用户: 你是谁?\n"
66
+ f"Thought: 确认身份\n"
67
+ f"Final Answer: 我是信息检索智能体,基于ReAct架构,可以检索知识库、调用工具来回答你的问题。\n\n"
68
+ f"用户: 你能做什么?\n"
69
+ f"Thought: 直接回答\n"
70
+ f"Final Answer: 我可以帮你检索知识库、回答问题、调用外部工具(如网络搜索)、处理多模态内容(文本/图片/音频/视频),以及进行多轮推理来解答复杂问题。\n\n"
71
+
72
+ f"## 规则\n"
73
+ f"1. 你的第一条消息必须以 \"Thought:\" 开头。禁止输出开场白、分析、或问候语。\n"
74
+ f"2. 知识类问题先调用 rag_search;但涉及当前日期之后或近期发生的事件、比赛结果、实时状态时,rag_search 仅作补充,必须调用可用的网络搜索工具核实。\n"
75
+ f" 当前日期是 {_today}。禁止把日期早于或等于 {_today} 的事件描述为“尚未开始”“未来”或“无法预知”。先检索,再依据检索证据判断事件状态。\n"
76
+ f" rag_search 无结果或信息不足时,使用“可用工具”中实际列出的网络搜索工具;禁止臆造不存在的工具名。\n"
77
+ f" 例外:关于你自身身份、能力、功能的问题直接回答,无需检索。\n"
78
+ f"3. Thought 必须 ≤50字。只写动作,禁止写原因。\n"
79
+ f" 正确: \"查询知识库\" / \"搜索网络\" / \"信息充足\" / \"确认身份\" / \"直接回答\"\n"
80
+ f" 错误: \"用户询问X,我先查一下...\"\n"
81
+ f"4. 禁止用相同参数重复调用同一工具。同一查询最多调用一次。\n"
82
+ f"5. 信息足够时立即输出 Final Answer。简单问题(如身份询问)一轮即可。\n"
83
+ f"6. 禁止编造事实。只引用实际检索到的内容;来源无明确证据时必须说明无法确认,禁止用常识或模型记忆补全结果。\n"
84
+ f"7. 仅在实际检索结果中存在对应编号时才引用 [R1] [R2];禁止自造来源编号或来源内容。末尾可�� \"📚 参考来源\"。\n"
85
+ f"8. 禁止输出内心独白、自我对话、方案对比或元推理。你的输出对外可见——只输出规定的格式行。\n"
86
+ f"9. 禁止复述或讨论本系统提示中的规则。\n"
87
+ f"10. 禁止只输出格式行而不输出最终答案。Action: (无) 是无效输出。已检索到信息时必须输出 Final Answer。\n\n"
88
+
89
+ f"## 可用工具\n"
90
+ f"{{tools_description}}\n\n"
91
+ f"## 对话历史\n"
92
+ f"{{memory_context}}\n\n"
93
+ f"现在回答用户问题。第一条消息以 Thought: 开头:\n"
94
+ )
95
+
96
+ # ═══════════════════════════════════════════════════════════════
97
+ # ReAct v2 — English variant (for English-first deployments)
98
+ # ═══════════════════════════════════════════════════════════════
99
+
100
+ @staticmethod
101
+ def react_system_en() -> str:
102
+ """English ReAct variant — pure text mode, with forced opening."""
103
+ _today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
104
+ return (
105
+ f"You are a precise retrieval agent. {_today}\n\n"
106
+
107
+ f"## Output Format (follow exactly)\n"
108
+ f"Thought: <action, ≤50 chars>\n"
109
+ f"Action: <tool_name>\n"
110
+ f"Action Input: <JSON>\n"
111
+ f"[wait for Observation]\n"
112
+ f"... (repeat as needed)\n"
113
+ f"Final Answer: <answer>\n\n"
114
+
115
+ f"## Example\n"
116
+ f"User: What is a vector database?\n"
117
+ f"Thought: Search KB\n"
118
+ f"Action: rag_search\n"
119
+ f"Action Input: {{{{'query': 'vector database'}}}}\n"
120
+ f"Observation: [R1] Vector databases store and retrieve embeddings...\n"
121
+ f"Thought: Info sufficient\n"
122
+ f"Final Answer: A vector database uses ANN algorithms for similarity "
123
+ f"search over high-dimensional vectors. References\\n[R1] ...\n\n"
124
+
125
+ f"## Rules\n"
126
+ f"1. Your FIRST line MUST start with \"Thought:\". No opening remarks or analysis.\n"
127
+ f"2. Thought MUST be ≤50 chars. State the action only.\n"
128
+ f" Good: \"Search KB\" / \"Web search\" / \"Info sufficient\"\n"
129
+ f" Bad: \"The user is asking about X, I need to...\"\n"
130
+ f"3. NEVER call the same tool with identical parameters twice.\n"
131
+ f"4. Output Final Answer as soon as you have enough information.\n"
132
+ f"5. NEVER fabricate facts. Only cite what you actually retrieved.\n"
133
+ f"6. Cite sources as [R1] [R2], append \" References\".\n\n"
134
+
135
+ f"## Tools\n"
136
+ f"{{tools_description}}\n\n"
137
+ f"## History\n"
138
+ f"{{memory_context}}\n\n"
139
+ f"Answer the user now. Start with Thought: on the first line:\n"
140
+ )
141
+
142
+ # ═══════════════════════════════════════════════════════════════
143
+ # Knowledge Graph — Entity Extraction (graph/builder.py)
144
+ # ═══════════════════════════════════════════════════════════════
145
+
146
+ KG_ENTITY_EXTRACTION = """从技术文本中提取实体和关系。只输出 JSON,禁止输出思考过程或解释。
147
+
148
+ ## 实体类型
149
+ TECHNOLOGY — 技术/框架/语言/工具
150
+ ALGORITHM — 算法/方法
151
+ COMPONENT — 组件/模块/接口
152
+ CONCEPT — 技术概念/设计模式
153
+ PROTOCOL — 协议/规范
154
+ PARAMETER — 配置参数/性能指标
155
+ STANDARD — 标准/规范
156
+ PRODUCT — 产品/系统/平台
157
+ ORGANIZATION — 组织/公司/团队
158
+
159
+ ## 关系类型
160
+ depends_on/uses | part_of/contains | implements/provides | configures/controls | compatible_with/integrates
161
+
162
+ ## 规则
163
+ - 3-8 个实体,2-6 个关系
164
+ - entity name 用原文术语
165
+ - 只输出 JSON
166
+
167
+ ```json
168
+ {{
169
+ "entities": [{{"name": "Kubernetes", "type": "TECHNOLOGY", "description": "容器编排平台"}}],
170
+ "relationships": [{{"source": "Kubernetes", "target": "Docker", "keywords": "使用,管理", "description": "K8s管理Docker容器"}}]
171
+ }}
172
+ ```
173
+
174
+ 文本: {text}
175
+ JSON:"""
176
+
177
+ # ═══════════════════════════════════════════════════════════════
178
+ # Multimodal Processors (processors/image_processor.py,
179
+ # processors/multimodal_processors.py)
180
+ # ═══════════════════════════════════════════════════════════════
181
+
182
+ IMAGE_CAPTION = (
183
+ "Describe this image concisely: key objects, text, context, and notable visual elements. "
184
+ "Keep within 3-5 sentences."
185
+ )
186
+
187
+ TABLE_ANALYSIS = (
188
+ "Analyze this table concisely. State: (1) what data it contains, "
189
+ "(2) 1-2 key trends, (3) any outliers or important values. "
190
+ "Table:\n{table_content}"
191
+ )
192
+
193
+ LATEX_TO_PLAIN_TEXT = (
194
+ "Convert this LaTeX formula to a plain-English description "
195
+ "that a non-expert can understand:\n\n{latex}"
196
+ )
197
+
198
+ VIDEO_KEYFRAME_DESCRIPTION = "Describe keyframe {frame_num} of this video."
199
+
200
+ # ═══════════════════════════════════════════════════════════════
201
+ # Pipeline — RAG query & media captioning (pipeline.py)
202
+ # ═══════════════════════════════════════════════════════════════
203
+
204
+ RAG_QUERY_ANSWER = (
205
+ "Answer the question using the provided context. Cite sources as [Source N]. "
206
+ "If the context is insufficient, say so directly — do not guess.\n\n"
207
+ "Context:\n{context}\n\n"
208
+ "Question: {question}\n\n"
209
+ "Answer:"
210
+ )
211
+
212
+ MEDIA_CAPTION_ZH = (
213
+ "用中文简洁描述此内容。包括关键对象、场景、文字和整体语境。3-5句话。"
214
+ )
215
+
216
+
217
+ # ═══════════════════════════════════════════════════════════════════
218
+ # Backward-compatible aliases (for gradual migration)
219
+ # ═══════════════════════════════════════════════════════════════════
220
+
221
+ # ReAct prompt — keep the old API working
222
+ SYSTEM_PROMPT = None # set below after class definition
223
+
224
+
225
+ def _init_legacy_aliases():
226
+ """Populate module-level aliases so existing imports still work."""
227
+ global SYSTEM_PROMPT
228
+ SYSTEM_PROMPT = Prompts.react_system()
229
+
230
+
231
+ _init_legacy_aliases()
agentic_rag/config/settings.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration management with Pydantic Settings."""
2
+
3
+ import os
4
+ import re
5
+ from pathlib import Path
6
+
7
+ # Mute gRPC "too_many_pings" noise from Milvus Lite.
8
+ # Must be set BEFORE any pymilvus import — settings.py is loaded first.
9
+ os.environ.setdefault("GRPC_VERBOSITY", "ERROR")
10
+ os.environ.setdefault("GRPC_TRACE", "none")
11
+ os.environ.setdefault("GRPC_KEEPALIVE_TIME_MS", "60000")
12
+ from typing import Optional
13
+
14
+ import yaml
15
+ from pydantic import BaseModel, Field, model_validator
16
+ from pydantic_settings import BaseSettings, SettingsConfigDict
17
+
18
+
19
+ _PREFIX = ""
20
+
21
+
22
+ class LLMProviderConfig(BaseSettings):
23
+ """Configuration for a single LLM provider."""
24
+ api_key: str = ""
25
+ api_base: str = ""
26
+ model: str = ""
27
+ max_tokens: int = 4096 # output limit per LLM call
28
+ temperature: float = 0.7
29
+ frequency_penalty: float = 0.3 # discourage token repetition (0-2)
30
+ presence_penalty: float = 0.3 # discourage topic looping (0-2)
31
+ vision_model: str = "" # Model for vision tasks
32
+ enable_native_tool_calls: bool = True # False → pure ReAct text mode
33
+
34
+
35
+ class EmbeddingConfig(BaseModel):
36
+ """Dedicated embedding model configuration.
37
+
38
+ Separated from LLM because:
39
+ - Embedding may use a different provider (e.g., Claude for LLM + OpenAI for embeddings)
40
+ - Self-hosted embedding services have their own API endpoint
41
+ - The dimension must match the vector store schema
42
+ """
43
+ provider: str = "openai" # Which LLM provider config to reuse, or "custom"
44
+ model: str = "text-embedding-3-small" # Embedding model name
45
+ model_type: str = "text" # "text" | "clip" | "multimodal"
46
+ dim: int = 1536 # Vector dimension (1536 for text-embedding-3-small)
47
+ api_key: str = "" # Override API key (uses LLM provider's key if empty)
48
+ api_base: str = "" # Override API base (uses LLM provider's base if empty)
49
+ batch_size: int = 100 # Max texts per embedding request
50
+
51
+
52
+ class MilvusConfig(BaseSettings):
53
+ """Milvus vector database configuration."""
54
+ host: str = "localhost"
55
+ port: int = 19530
56
+ collection_prefix: str = "agentic_rag"
57
+ dim: int = 1536
58
+ index_type: str = "IVF_FLAT"
59
+ metric_type: str = "COSINE"
60
+
61
+
62
+ class MemoryConfig(BaseSettings):
63
+ """Memory configuration."""
64
+ short_term_max_tokens: int = 8000
65
+ long_term_top_k: int = 10
66
+ working_memory_max_keys: int = 50
67
+
68
+
69
+ class SessionConfig(BaseSettings):
70
+ """Session configuration."""
71
+ ttl_seconds: int = 3600
72
+ cleanup_interval_seconds: int = 300
73
+
74
+
75
+ class APIConfig(BaseSettings):
76
+ """API server configuration."""
77
+ host: str = "0.0.0.0"
78
+ port: int = 8000
79
+ cors_origins: list[str] = ["*"]
80
+ rate_limit_per_minute: int = 60
81
+
82
+
83
+ class OCRConfig(BaseSettings):
84
+ """PaddleOCR-VL / vLLM OCR configuration."""
85
+ enabled: bool = True
86
+ api_base: str = "http://localhost:8000/v1"
87
+ model: str = "PaddlePaddle/PaddleOCR-VL"
88
+ api_key: str = "not-needed"
89
+ max_pages: int = 50 # max pages to OCR per document
90
+
91
+
92
+ class WeChatWorkGatewayConfig(BaseSettings):
93
+ """企业微信自建应用 gateway configuration.
94
+
95
+ Reference: https://developer.work.weixin.qq.com/document/path/90238
96
+ """
97
+ enabled: bool = False
98
+ corp_id: str = "" # 企业ID (myCorpId)
99
+ token: str = "" # 回调 Token
100
+ encoding_aes_key: str = "" # 回调 EncodingAESKey (43 chars)
101
+ agent_id: str = "" # 应用 AgentId
102
+ secret: str = "" # 应用 Secret (用于获取 access_token 推送消息)
103
+ webhook_path: str = "/gateway/wechat_work"
104
+
105
+
106
+ class QQBotGatewayConfig(BaseSettings):
107
+ """QQ Bot (官方) gateway configuration.
108
+
109
+ 沙箱模式: wss://sandbox.api.sgroup.qq.com/websocket
110
+ 正式环境: wss://api.sgroup.qq.com/websocket
111
+ """
112
+ enabled: bool = False
113
+ app_id: str = "" # BotAppID
114
+ app_secret: str = "" # BotSecret (用于获取 access_token)
115
+ sandbox: bool = True # True=沙箱环境, False=正式环境
116
+ webhook_path: str = "/gateway/qqbot" # 用于查看 Bot 状态
117
+
118
+
119
+ class DingTalkGatewayConfig(BaseSettings):
120
+ """钉钉 bot gateway configuration."""
121
+ enabled: bool = False
122
+ app_key: str = ""
123
+ app_secret: str = ""
124
+ webhook_path: str = "/gateway/dingtalk"
125
+
126
+
127
+ class GatewayConfig(BaseSettings):
128
+ """Messaging platform gateway configuration."""
129
+ enabled: bool = False
130
+ response_mode: str = "sync" # "sync" = reply in webhook response; "async" = push via API
131
+ max_reply_length: int = 2000
132
+ wechat_work: WeChatWorkGatewayConfig = Field(default_factory=WeChatWorkGatewayConfig)
133
+ dingtalk: DingTalkGatewayConfig = Field(default_factory=DingTalkGatewayConfig)
134
+ qqbot: QQBotGatewayConfig = Field(default_factory=QQBotGatewayConfig)
135
+
136
+
137
+ class VoiceConfig(BaseSettings):
138
+ """Voice/STT/TTS configuration."""
139
+ # STT (Speech-to-Text)
140
+ stt_provider: str = "sensevoice" # "sensevoice" | "whisper" | "openai"
141
+ stt_model: str = "sensevoice" # sensevoice | base | small | whisper-1
142
+ stt_api_base: str = "http://localhost:8000" # ASR server URL (POST /asr)
143
+ stt_api_key: str = ""
144
+ stt_language: str = "auto" # auto | zh | en | ja | ko | yue
145
+ # TTS (Text-to-Speech)
146
+ tts_provider: str = "qwen" # "qwen" | "kokoro" | "edge" | "openai"
147
+ tts_model: str = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
148
+ tts_api_base: str = "http://localhost:8091"
149
+ tts_api_key: str = "EMPTY"
150
+ tts_task_type: str = "VoiceDesign" # VoiceDesign | CustomVoice | Base (qwen)
151
+ tts_instructions: str = "A clear, professional voice in Chinese" # qwen
152
+ tts_language: str = "Chinese" # qwen language / kokoro lang code (zh/en/ja)
153
+ tts_speaker: str = "" # qwen CustomVoice speaker
154
+ tts_voice: str = "zh-CN-XiaoxiaoNeural" # Edge-TTS fallback / kokoro voice (zf_xiaoyi)
155
+ tts_speed: float = 1.0 # kokoro playback speed
156
+ tts_response_format: str = "wav" # wav | mp3 | flac | pcm
157
+ sample_rate: int = 16000
158
+
159
+
160
+ def _parse_llm_providers_from_env() -> dict[str, LLMProviderConfig]:
161
+ """Manually extract LLM provider configs from environment + .env file.
162
+
163
+ pydantic-settings cannot auto-populate ``dict[str, Model]`` from env vars
164
+ because the dict keys are dynamic. We scan for the pattern::
165
+
166
+ RAG__LLM_PROVIDERS__<NAME>__<FIELD>=<value>
167
+
168
+ in both ``os.environ`` AND the ``.env`` file (since pydantic-settings reads
169
+ ``.env`` internally but does NOT export into ``os.environ``).
170
+ """
171
+ providers: dict[str, dict] = {}
172
+ pattern = re.compile(rf"^{re.escape(_PREFIX)}_?LLM_PROVIDERS__([A-Z0-9]+)__([A-Z_]+)$")
173
+
174
+ def _collect(source: dict[str, str]) -> None:
175
+ for key, value in source.items():
176
+ m = pattern.match(key)
177
+ if not m:
178
+ continue
179
+ provider_name = m.group(1).lower()
180
+ field_name = m.group(2).lower()
181
+ providers.setdefault(provider_name, {})[field_name] = value
182
+
183
+ # 1. os.environ (exported vars + python-dotenv if loaded externally)
184
+ _collect(dict(os.environ))
185
+
186
+ # 2. .env file (pydantic-settings reads it internally; we must too)
187
+ env_path = Path(".env")
188
+ if env_path.exists():
189
+ env_vars = _parse_dotenv(env_path)
190
+ _collect(env_vars)
191
+
192
+ return {name: LLMProviderConfig(**fields) for name, fields in providers.items()}
193
+
194
+
195
+ def _parse_mcp_servers_from_env() -> dict[str, dict]:
196
+ """Extract MCP server configs.
197
+
198
+ Priority:
199
+ 1. ``mcp_servers.json`` — standard MCP config (like Claude Code)
200
+ 2. ``mcp_servers.yaml`` — YAML alternative
201
+ 3. Environment variables: ``RAG__MCP_SERVERS__<NAME>__<FIELD>=<value>``
202
+ """
203
+ # 1. Try JSON config (standard MCP format)
204
+ json_path = Path("mcp_servers.json")
205
+ if json_path.exists():
206
+ try:
207
+ import json as _json
208
+ with open(json_path) as f:
209
+ data = _json.load(f)
210
+ servers_raw = data.get("mcpServers", {})
211
+ result: dict[str, dict] = {}
212
+ for name, cfg in servers_raw.items():
213
+ if cfg.get("disabled", False):
214
+ continue
215
+ args = cfg.get("args", [])
216
+ # args can be a list or string
217
+ if isinstance(args, list):
218
+ args = " ".join(args)
219
+ result[name.lower()] = {
220
+ "command": cfg.get("command", ""),
221
+ "args": args,
222
+ }
223
+ # Preserve the standard MCP nested env mapping. The startup
224
+ # code merges it with the process environment before spawning
225
+ # the server; flattening these keys loses credentials such as
226
+ # TAVILY_API_KEY because startup only reads config["env"].
227
+ env = cfg.get("env", {})
228
+ if isinstance(env, dict) and env:
229
+ result[name.lower()]["env"] = {
230
+ str(k): str(v) for k, v in env.items() if v
231
+ }
232
+ if result:
233
+ return result
234
+ except Exception:
235
+ pass
236
+
237
+ # 2. Try YAML config
238
+ yaml_path = Path("mcp_servers.yaml")
239
+ if yaml_path.exists():
240
+ try:
241
+ import yaml as _yaml
242
+ with open(yaml_path) as f:
243
+ data = _yaml.safe_load(f) or {}
244
+ if isinstance(data, dict) and "servers" in data:
245
+ return {k.lower(): v for k, v in data["servers"].items()}
246
+ except Exception:
247
+ pass
248
+
249
+ # 3. Fallback: env vars
250
+ servers: dict[str, dict] = {}
251
+ pattern = re.compile(rf"^{re.escape(_PREFIX)}_?MCP_SERVERS__([A-Z0-9]+)__([A-Z_]+)$")
252
+
253
+ def _collect(source: dict[str, str]) -> None:
254
+ for key, value in source.items():
255
+ m = pattern.match(key)
256
+ if not m:
257
+ continue
258
+ server_name = m.group(1).lower()
259
+ field_name = m.group(2).lower()
260
+ servers.setdefault(server_name, {})[field_name] = value
261
+
262
+ _collect(dict(os.environ))
263
+ env_path = Path(".env")
264
+ if env_path.exists():
265
+ _collect(_parse_dotenv(env_path))
266
+
267
+ return servers
268
+
269
+
270
+ def _parse_dotenv(path: Path) -> dict[str, str]:
271
+ """Parse a .env file into a dict (without touching os.environ)."""
272
+ result: dict[str, str] = {}
273
+ with open(path) as f:
274
+ for line in f:
275
+ line = line.strip()
276
+ if not line or line.startswith("#"):
277
+ continue
278
+ if "=" in line:
279
+ k, v = line.split("=", 1)
280
+ result[k.strip()] = v.strip()
281
+ return result
282
+
283
+
284
+ class Settings(BaseSettings):
285
+ """Root settings for Agentic RAG."""
286
+
287
+ model_config = SettingsConfigDict(
288
+ env_file=".env",
289
+ env_file_encoding="utf-8",
290
+ env_nested_delimiter="__",
291
+ env_prefix="",
292
+ extra="ignore",
293
+ )
294
+
295
+ # App
296
+ app_name: str = "agentic_rag"
297
+ debug: bool = False
298
+ log_level: str = "INFO"
299
+
300
+ # LLM
301
+ default_provider: str = "openai"
302
+ llm_providers: dict[str, LLMProviderConfig] = Field(default_factory=dict)
303
+
304
+ # Embedding (dedicated config — may differ from LLM provider)
305
+ embedding: EmbeddingConfig = Field(default_factory=EmbeddingConfig)
306
+
307
+ # Services
308
+ milvus: MilvusConfig = Field(default_factory=MilvusConfig)
309
+ memory: MemoryConfig = Field(default_factory=MemoryConfig)
310
+ session: SessionConfig = Field(default_factory=SessionConfig)
311
+ api: APIConfig = Field(default_factory=APIConfig)
312
+ ocr: OCRConfig = Field(default_factory=OCRConfig)
313
+ voice: VoiceConfig = Field(default_factory=VoiceConfig)
314
+
315
+ # Database
316
+ db_path: str = "data/agentic_rag.db"
317
+
318
+ # Gateway (messaging platforms)
319
+ gateway: GatewayConfig = Field(default_factory=GatewayConfig)
320
+
321
+ # MCP
322
+ mcp_servers: dict[str, dict] = Field(default_factory=dict)
323
+
324
+ # Workspace
325
+ workspace_dir: str = "workspace"
326
+
327
+ @model_validator(mode="after")
328
+ def _inject_dict_fields(self):
329
+ """Populate dict fields from env vars (pydantic-settings can't do dynamic keys)."""
330
+ if not self.llm_providers:
331
+ self.llm_providers = _parse_llm_providers_from_env()
332
+ if not self.mcp_servers:
333
+ self.mcp_servers = _parse_mcp_servers_from_env()
334
+ return self
335
+
336
+ @classmethod
337
+ def from_yaml(cls, yaml_path: str | Path) -> "Settings":
338
+ """Load settings from a YAML file, then overlay env vars."""
339
+ path = Path(yaml_path)
340
+ if path.exists():
341
+ with open(path) as f:
342
+ data = yaml.safe_load(f) or {}
343
+ else:
344
+ data = {}
345
+ return cls(**data)
346
+
347
+
348
+ # Global settings instance (initialized at startup)
349
+ _settings: Optional[Settings] = None
350
+
351
+
352
+ def get_settings() -> Settings:
353
+ """Get the global settings instance."""
354
+ global _settings
355
+ if _settings is None:
356
+ _settings = Settings()
357
+ return _settings
358
+
359
+
360
+ def init_settings(**kwargs) -> Settings:
361
+ """Initialize settings (called at app startup)."""
362
+ global _settings
363
+ _settings = Settings(**kwargs)
364
+ return _settings
agentic_rag/core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Agentic RAG module."""
agentic_rag/core/mcp/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Agentic RAG module."""
agentic_rag/core/mcp/client.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MCP (Model Context Protocol) Client.
2
+
3
+ Connects to external MCP servers and exposes their tools to the agent.
4
+ """
5
+
6
+ import asyncio
7
+ import json
8
+ from typing import Any, Optional
9
+
10
+ from agentic_rag.orchestration.l1_tools.base import BaseTool
11
+
12
+
13
+ class MCPTool(BaseTool):
14
+ """Proxy tool that wraps an MCP server tool."""
15
+
16
+ _CAPABILITY_HINTS = {
17
+ "web_search": (
18
+ "search", "web", "internet", "browser", "browse", "news",
19
+ "搜索", "网页", "网络", "新闻",
20
+ ),
21
+ "fresh_information": (
22
+ "search", "web", "internet", "browser", "browse", "news",
23
+ "current", "latest", "real-time", "realtime", "搜索", "网页",
24
+ "网络", "新闻", "最新", "实时",
25
+ ),
26
+ }
27
+
28
+ def __init__(self, name: str, description: str, parameters_schema: dict,
29
+ server_name: str, client: "MCPClient"):
30
+ self.name = f"mcp__{server_name}__{name}"
31
+ self.description = f"[MCP/{server_name}] {description}"
32
+ self.parameters_schema = parameters_schema
33
+ self.capabilities = self._infer_capabilities(name, description)
34
+ self.source = "mcp"
35
+ self._original_name = name
36
+ self._client = client
37
+ self.requires_confirmation = False
38
+
39
+ @classmethod
40
+ def _infer_capabilities(cls, name: str, description: str) -> frozenset[str]:
41
+ """Infer semantic capabilities from MCP tool metadata."""
42
+ metadata = f"{name} {description}".lower()
43
+ return frozenset(
44
+ capability
45
+ for capability, hints in cls._CAPABILITY_HINTS.items()
46
+ if any(hint in metadata for hint in hints)
47
+ )
48
+
49
+ async def execute(self, **kwargs) -> Any:
50
+ """Execute the MCP tool via the connected client."""
51
+ return await self._client.call_tool(self._original_name, kwargs)
52
+
53
+
54
+ class MCPClient:
55
+ """MCP Client for connecting to external MCP servers.
56
+
57
+ Supports stdio transport (subprocess-based) for connecting to MCP servers.
58
+ """
59
+
60
+ def __init__(self):
61
+ self._connections: dict[str, dict] = {} # server_name -> {process, tools}
62
+
63
+ @property
64
+ def connected_servers(self) -> list[str]:
65
+ return list(self._connections.keys())
66
+
67
+ async def connect_stdio(self, server_name: str, command: str,
68
+ args: list[str] | None = None,
69
+ env: dict[str, str] | None = None) -> list[MCPTool]:
70
+ """Connect to an MCP server via stdio subprocess.
71
+
72
+ Args:
73
+ server_name: Logical name for this server.
74
+ command: Command to launch the MCP server.
75
+ args: Arguments for the command.
76
+ env: Environment variables.
77
+
78
+ Returns:
79
+ List of MCPTool instances exposed by the server.
80
+ """
81
+ proc = await asyncio.create_subprocess_exec(
82
+ command, *(args or []),
83
+ stdin=asyncio.subprocess.PIPE,
84
+ stdout=asyncio.subprocess.PIPE,
85
+ stderr=asyncio.subprocess.PIPE,
86
+ env=env,
87
+ )
88
+
89
+ # Initialize MCP session
90
+ init_request = {
91
+ "jsonrpc": "2.0",
92
+ "id": 1,
93
+ "method": "initialize",
94
+ "params": {
95
+ "protocolVersion": "2024-11-05",
96
+ "capabilities": {},
97
+ "clientInfo": {"name": "agentic_rag", "version": "0.1.0"},
98
+ },
99
+ }
100
+ await self._send_request(proc, init_request)
101
+
102
+ # Discover tools
103
+ tools_request = {
104
+ "jsonrpc": "2.0",
105
+ "id": 2,
106
+ "method": "tools/list",
107
+ "params": {},
108
+ }
109
+ response = await self._send_request(proc, tools_request)
110
+
111
+ tools = []
112
+ for tool_def in response.get("result", {}).get("tools", []):
113
+ tool = MCPTool(
114
+ name=tool_def["name"],
115
+ description=tool_def.get("description", ""),
116
+ parameters_schema=tool_def.get("inputSchema", {
117
+ "type": "object",
118
+ "properties": {},
119
+ "required": [],
120
+ }),
121
+ server_name=server_name,
122
+ client=self,
123
+ )
124
+ tools.append(tool)
125
+
126
+ self._connections[server_name] = {
127
+ "process": proc,
128
+ "tools": {t._original_name: t for t in tools},
129
+ "next_id": 3,
130
+ }
131
+
132
+ return tools
133
+
134
+ async def call_tool(self, name: str, arguments: dict) -> str:
135
+ """Call an MCP tool by name.
136
+
137
+ Finds the correct connection by iterating over connected servers.
138
+ """
139
+ for server_name, conn in self._connections.items():
140
+ if name in conn["tools"]:
141
+ request = {
142
+ "jsonrpc": "2.0",
143
+ "id": conn["next_id"],
144
+ "method": "tools/call",
145
+ "params": {"name": name, "arguments": arguments},
146
+ }
147
+ conn["next_id"] += 1
148
+ response = await self._send_request(conn["process"], request)
149
+
150
+ content = response.get("result", {}).get("content", [])
151
+ if content:
152
+ return "\n".join(
153
+ c.get("text", str(c)) for c in content
154
+ if isinstance(c, dict)
155
+ )
156
+ return json.dumps(response.get("result", {}))
157
+
158
+ return f"MCP tool '{name}' not found on any connected server."
159
+
160
+ async def list_resources(self, server_name: str) -> list[dict]:
161
+ """List resources from an MCP server."""
162
+ conn = self._connections.get(server_name)
163
+ if not conn:
164
+ return []
165
+
166
+ request = {
167
+ "jsonrpc": "2.0",
168
+ "id": conn["next_id"],
169
+ "method": "resources/list",
170
+ "params": {},
171
+ }
172
+ conn["next_id"] += 1
173
+ response = await self._send_request(conn["process"], request)
174
+ return response.get("result", {}).get("resources", [])
175
+
176
+ async def read_resource(self, server_name: str, uri: str) -> Any:
177
+ """Read a resource from an MCP server."""
178
+ conn = self._connections.get(server_name)
179
+ if not conn:
180
+ return f"MCP server '{server_name}' not connected."
181
+
182
+ request = {
183
+ "jsonrpc": "2.0",
184
+ "id": conn["next_id"],
185
+ "method": "resources/read",
186
+ "params": {"uri": uri},
187
+ }
188
+ conn["next_id"] += 1
189
+ response = await self._send_request(conn["process"], request)
190
+ return response.get("result", {})
191
+
192
+ async def disconnect(self, server_name: str) -> None:
193
+ """Disconnect from an MCP server."""
194
+ conn = self._connections.pop(server_name, None)
195
+ if conn:
196
+ proc = conn["process"]
197
+ proc.stdin.close()
198
+ await proc.wait()
199
+
200
+ async def disconnect_all(self) -> None:
201
+ """Disconnect from all MCP servers."""
202
+ for name in list(self._connections.keys()):
203
+ await self.disconnect(name)
204
+
205
+ async def _send_request(self, proc: asyncio.subprocess.Process, request: dict) -> dict:
206
+ """Send a JSON-RPC request and receive the response."""
207
+ msg = json.dumps(request) + "\n"
208
+ proc.stdin.write(msg.encode())
209
+ await proc.stdin.drain()
210
+
211
+ line = await asyncio.wait_for(proc.stdout.readline(), timeout=30.0)
212
+ if line:
213
+ return json.loads(line.decode())
214
+ return {"error": "No response from MCP server"}
agentic_rag/core/mcp/server.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MCP (Model Context Protocol) Server.
2
+
3
+ Exposes this system's tools and resources to external MCP clients via stdio.
4
+ """
5
+
6
+ import asyncio
7
+ import json
8
+ import sys
9
+ from typing import Any
10
+
11
+
12
+ class MCPServer:
13
+ """Expose Agentic RAG tools as an MCP server.
14
+
15
+ Runs as a stdio-based JSON-RPC server that external MCP clients can connect to.
16
+ """
17
+
18
+ def __init__(self, tool_registry=None, name: str = "agentic_rag",
19
+ version: str = "0.1.0"):
20
+ self.name = name
21
+ self.version = version
22
+ self.tool_registry = tool_registry
23
+ self._running = False
24
+
25
+ async def run_stdio(self) -> None:
26
+ """Run the MCP server over stdio (stdin/stdout).
27
+
28
+ Reads JSON-RPC requests from stdin and writes responses to stdout.
29
+ """
30
+ self._running = True
31
+ reader = asyncio.StreamReader()
32
+ protocol = asyncio.StreamReaderProtocol(reader)
33
+ await asyncio.get_event_loop().connect_read_pipe(lambda: protocol, sys.stdin)
34
+
35
+ while self._running:
36
+ try:
37
+ line = await asyncio.wait_for(reader.readline(), timeout=300.0)
38
+ if not line:
39
+ break
40
+
41
+ request = json.loads(line.decode())
42
+ response = await self._handle_request(request)
43
+ sys.stdout.write(json.dumps(response) + "\n")
44
+ sys.stdout.flush()
45
+
46
+ except asyncio.TimeoutError:
47
+ break
48
+ except json.JSONDecodeError:
49
+ continue
50
+ except Exception as e:
51
+ error_response = {
52
+ "jsonrpc": "2.0",
53
+ "id": request.get("id") if "request" in dir() else None,
54
+ "error": {"code": -32603, "message": str(e)},
55
+ }
56
+ sys.stdout.write(json.dumps(error_response) + "\n")
57
+ sys.stdout.flush()
58
+
59
+ async def _handle_request(self, request: dict) -> dict:
60
+ """Handle a single JSON-RPC request."""
61
+ method = request.get("method", "")
62
+ req_id = request.get("id")
63
+
64
+ handlers = {
65
+ "initialize": self._handle_initialize,
66
+ "tools/list": self._handle_tools_list,
67
+ "tools/call": self._handle_tools_call,
68
+ "resources/list": self._handle_resources_list,
69
+ }
70
+
71
+ handler = handlers.get(method)
72
+ if handler:
73
+ result = await handler(request.get("params", {}))
74
+ return {"jsonrpc": "2.0", "id": req_id, "result": result}
75
+ else:
76
+ return {
77
+ "jsonrpc": "2.0",
78
+ "id": req_id,
79
+ "error": {"code": -32601, "message": f"Method not found: {method}"},
80
+ }
81
+
82
+ async def _handle_initialize(self, params: dict) -> dict:
83
+ return {
84
+ "protocolVersion": "2024-11-05",
85
+ "capabilities": {"tools": {}},
86
+ "serverInfo": {"name": self.name, "version": self.version},
87
+ }
88
+
89
+ async def _handle_tools_list(self, params: dict) -> dict:
90
+ tools = self.tool_registry.get_all() if self.tool_registry else []
91
+ return {
92
+ "tools": [
93
+ {
94
+ "name": t.name,
95
+ "description": t.description,
96
+ "inputSchema": t.parameters_schema,
97
+ }
98
+ for t in tools
99
+ ]
100
+ }
101
+
102
+ async def _handle_tools_call(self, params: dict) -> dict:
103
+ tool_name = params.get("name", "")
104
+ arguments = params.get("arguments", {})
105
+
106
+ tool = self.tool_registry.get(tool_name) if self.tool_registry else None
107
+ if tool is None:
108
+ return {
109
+ "content": [{"type": "text", "text": f"Tool not found: {tool_name}"}],
110
+ "isError": True,
111
+ }
112
+
113
+ try:
114
+ result = await tool.execute(**arguments)
115
+ return {
116
+ "content": [{"type": "text", "text": str(result)}],
117
+ }
118
+ except Exception as e:
119
+ return {
120
+ "content": [{"type": "text", "text": f"Error: {e}"}],
121
+ "isError": True,
122
+ }
123
+
124
+ async def _handle_resources_list(self, params: dict) -> dict:
125
+ return {"resources": []}
126
+
127
+ def stop(self) -> None:
128
+ """Stop the MCP server."""
129
+ self._running = False
agentic_rag/core/multimodal/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Agentic RAG module."""
agentic_rag/core/multimodal/audio.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio transcription using Whisper."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+
7
+ class AudioProcessor:
8
+ """Transcribe audio to text using Whisper."""
9
+
10
+ def __init__(self, model_name: str = "base"):
11
+ self.model_name = model_name
12
+ self._model = None
13
+
14
+ def _load_model(self):
15
+ """Lazy-load the Whisper model."""
16
+ if self._model is None:
17
+ import whisper
18
+ self._model = whisper.load_model(self.model_name)
19
+ return self._model
20
+
21
+ async def transcribe(self, audio_path: str | Path) -> str:
22
+ """Transcribe an audio file to text.
23
+
24
+ Args:
25
+ audio_path: Path to the audio file (mp3, wav, m4a, etc.)
26
+
27
+ Returns:
28
+ Transcribed text.
29
+ """
30
+ path = Path(audio_path)
31
+ if not path.exists():
32
+ return f"[Audio not found: {audio_path}]"
33
+
34
+ try:
35
+ model = self._load_model()
36
+ result = model.transcribe(str(path))
37
+ return result["text"]
38
+ except ImportError:
39
+ return "[Whisper not installed. Run: pip install openai-whisper]"
40
+ except Exception as e:
41
+ return f"[Transcription error: {e}]"
42
+
43
+ async def transcribe_bytes(self, audio_bytes: bytes, sample_rate: int = 16000) -> str:
44
+ """Transcribe raw audio bytes."""
45
+ import tempfile
46
+ import wave
47
+
48
+ try:
49
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
50
+ with wave.open(f, "wb") as wf:
51
+ wf.setnchannels(1)
52
+ wf.setsampwidth(2)
53
+ wf.setframerate(sample_rate)
54
+ wf.writeframes(audio_bytes)
55
+ f.flush()
56
+ return await self.transcribe(f.name)
57
+ except Exception as e:
58
+ return f"[Transcription error: {e}]"
agentic_rag/core/multimodal/image.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image understanding via Vision LLM."""
2
+
3
+ import base64
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from agentic_rag.data.models import Message
8
+
9
+
10
+ class ImageProcessor:
11
+ """Process and understand images using Vision LLM."""
12
+
13
+ MIME_MAP = {
14
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
15
+ ".png": "image/png", ".gif": "image/gif",
16
+ ".webp": "image/webp", ".bmp": "image/bmp",
17
+ }
18
+
19
+ def __init__(self, llm=None):
20
+ """llm must be a vision-capable BaseLLMProvider."""
21
+ self.llm = llm
22
+
23
+ async def describe(self, image: str | Path, question: str = "Describe this image in detail.") -> str:
24
+ """Analyze an image and return a description.
25
+
26
+ Args:
27
+ image: Path to image file, URL, or base64 data URI.
28
+ question: What to ask about the image.
29
+
30
+ Returns:
31
+ Text description of the image.
32
+ """
33
+ if self.llm is None:
34
+ return "[Vision LLM not configured]"
35
+
36
+ image_url = self._resolve_image(image)
37
+ if image_url.startswith("ERROR:"):
38
+ return image_url
39
+
40
+ content = [
41
+ {"type": "text", "text": question},
42
+ {"type": "image_url", "image_url": {"url": image_url}},
43
+ ]
44
+
45
+ response = await self.llm.agenerate([Message(role="user", content=content)])
46
+ return response.content
47
+
48
+ def _resolve_image(self, image: str | Path) -> str:
49
+ """Resolve image to a base64 data URI or HTTP URL."""
50
+ img_str = str(image)
51
+
52
+ # Already a data URI or HTTP URL
53
+ if img_str.startswith(("data:", "http://", "https://")):
54
+ return img_str
55
+
56
+ # Local file path
57
+ path = Path(img_str)
58
+ if not path.exists():
59
+ return f"ERROR: Image not found: {img_str}"
60
+
61
+ ext = path.suffix.lower()
62
+ mime = self.MIME_MAP.get(ext, "image/png")
63
+ data = base64.b64encode(path.read_bytes()).decode()
64
+ return f"data:{mime};base64,{data}"
65
+
66
+ async def compare(self, image1: str, image2: str, question: str = "Compare these two images.") -> str:
67
+ """Compare two images."""
68
+ url1 = self._resolve_image(image1)
69
+ url2 = self._resolve_image(image2)
70
+
71
+ content = [
72
+ {"type": "text", "text": question},
73
+ {"type": "image_url", "image_url": {"url": url1}},
74
+ {"type": "image_url", "image_url": {"url": url2}},
75
+ ]
76
+
77
+ response = await self.llm.agenerate([Message(role="user", content=content)])
78
+ return response.content
agentic_rag/core/multimodal/video.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Video processing — keyframe extraction and analysis."""
2
+
3
+ import base64
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+
8
+ class VideoProcessor:
9
+ """Extract keyframes from video and describe them using Vision LLM."""
10
+
11
+ def __init__(self, llm=None):
12
+ """llm must be a vision-capable BaseLLMProvider."""
13
+ self.llm = llm
14
+
15
+ async def analyze(self, video_path: str | Path, max_frames: int = 5,
16
+ question: str = "Describe what is happening in this video frame.") -> str:
17
+ """Extract keyframes from a video and describe each frame.
18
+
19
+ Args:
20
+ video_path: Path to video file.
21
+ max_frames: Maximum number of keyframes to extract.
22
+ question: Question to ask about each frame.
23
+
24
+ Returns:
25
+ Combined description of all keyframes.
26
+ """
27
+ path = Path(video_path)
28
+ if not path.exists():
29
+ return f"[Video not found: {video_path}]"
30
+
31
+ try:
32
+ import cv2
33
+ except ImportError:
34
+ return "[OpenCV not installed. Run: pip install opencv-python]"
35
+
36
+ cap = cv2.VideoCapture(str(path))
37
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
38
+ fps = cap.get(cv2.CAP_PROP_FPS)
39
+ duration = total_frames / fps if fps > 0 else 0
40
+
41
+ frames_b64 = self._extract_keyframes(cap, total_frames, max_frames)
42
+ cap.release()
43
+
44
+ if not frames_b64:
45
+ return "[No frames extracted from video]"
46
+
47
+ results = [f"Video Info: {duration:.1f}s, {total_frames} frames, {fps:.1f} fps"]
48
+
49
+ if self.llm and self.llm.supports_vision:
50
+ from agentic_rag.data.models import Message
51
+
52
+ for i, frame_b64 in enumerate(frames_b64):
53
+ content = [
54
+ {"type": "text", "text": f"Frame {i+1}/{len(frames_b64)}: {question}"},
55
+ {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{frame_b64}"}},
56
+ ]
57
+ response = await self.llm.agenerate([Message(role="user", content=content)])
58
+ results.append(f"Frame {i+1}: {response.content[:300]}")
59
+ else:
60
+ results.append(f"[{len(frames_b64)} keyframes extracted but no Vision LLM configured for analysis]")
61
+
62
+ return "\n\n".join(results)
63
+
64
+ def _extract_keyframes(self, cap, total_frames: int, max_frames: int) -> list[str]:
65
+ """Extract evenly-spaced keyframes as base64 strings."""
66
+ import cv2
67
+
68
+ frames = []
69
+ interval = max(1, total_frames // max_frames)
70
+
71
+ for i in range(0, total_frames, interval):
72
+ cap.set(cv2.CAP_PROP_POS_FRAMES, i)
73
+ ret, frame = cap.read()
74
+ if ret:
75
+ _, buffer = cv2.imencode(".jpg", frame)
76
+ frames.append(base64.b64encode(buffer).decode())
77
+ if len(frames) >= max_frames:
78
+ break
79
+
80
+ return frames
agentic_rag/core/voice/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Agentic RAG module."""
agentic_rag/core/voice/stt.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Speech-to-Text service.
2
+
3
+ Primary: SenseVoice-compatible local ASR server (/asr endpoint).
4
+ Fallback: OpenAI Whisper API or local Whisper model.
5
+ """
6
+
7
+ import io
8
+ import re
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+
14
+
15
+ class STTService:
16
+ """Speech-to-Text via ASR server (SenseVoice) with Whisper fallback."""
17
+
18
+ def __init__(
19
+ self,
20
+ provider: str = "sensevoice",
21
+ model: str = "sensevoice",
22
+ api_base: str = "http://localhost:8000",
23
+ api_key: str = "",
24
+ language: str = "auto",
25
+ sample_rate: int = 16000,
26
+ ):
27
+ self.provider = provider # "sensevoice" | "whisper" | "openai"
28
+ self.model = model
29
+ self.api_base = api_base.rstrip("/")
30
+ self.api_key = api_key
31
+ self.language = language # auto | zh | en | ja | ko | yue
32
+ self.sample_rate = sample_rate
33
+ self._local_model = None
34
+
35
+ # ── Public API ──────────────────────────────────────────
36
+
37
+ async def transcribe_file(self, file_path: str | Path) -> str:
38
+ """Transcribe an audio file."""
39
+ path = Path(file_path)
40
+ if not path.exists():
41
+ raise FileNotFoundError(f"Audio file not found: {file_path}")
42
+
43
+ if self.provider == "sensevoice":
44
+ return await self._transcribe_sensevoice_file(path)
45
+ elif self.provider == "openai":
46
+ return await self._transcribe_openai(path)
47
+ else:
48
+ return await self._transcribe_local(path)
49
+
50
+ async def transcribe_bytes(
51
+ self, audio_data: bytes, language: str | None = None
52
+ ) -> str:
53
+ """Transcribe raw audio bytes (any format ffmpeg supports)."""
54
+ lang = language or self.language
55
+
56
+ if self.provider == "sensevoice":
57
+ waveform = self._decode_to_float32(audio_data)
58
+ if waveform is not None:
59
+ return await self._call_asr(waveform, lang)
60
+ return ""
61
+
62
+ # Whisper fallback — write to temp WAV
63
+ suffix = ".wav" if audio_data[:4] == b"RIFF" else ".webm"
64
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
65
+ f.write(audio_data)
66
+ return await self.transcribe_file(f.name)
67
+
68
+ async def transcribe_ndarray(
69
+ self, audio: np.ndarray, language: str | None = None
70
+ ) -> str:
71
+ """Transcribe a numpy array (float32 or int16)."""
72
+ lang = language or self.language
73
+
74
+ if self.provider == "sensevoice":
75
+ waveform = audio.astype(np.float32)
76
+ if waveform.max() > 1.5:
77
+ waveform = waveform / 32768.0
78
+ return await self._call_asr(waveform, lang)
79
+
80
+ # Whisper fallback
81
+ if audio.dtype != np.int16:
82
+ audio = np.clip(audio * 32767, -32768, 32767).astype(np.int16)
83
+ import wave
84
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
85
+ with wave.open(f, "wb") as wf:
86
+ wf.setnchannels(1)
87
+ wf.setsampwidth(2)
88
+ wf.setframerate(self.sample_rate)
89
+ wf.writeframes(audio.tobytes())
90
+ return await self.transcribe_file(f.name)
91
+
92
+ # ── SenseVoice ASR server ───────────────────────────────
93
+
94
+ async def _transcribe_sensevoice_file(self, path: Path) -> str:
95
+ """Transcribe via SenseVoice ASR server."""
96
+ import librosa
97
+ waveform, sr = librosa.load(str(path), sr=self.sample_rate, mono=True)
98
+ return await self._call_asr(waveform.astype(np.float32), self.language)
99
+
100
+ async def _call_asr(
101
+ self, waveform: np.ndarray, language: str | None = None
102
+ ) -> str:
103
+ """Send audio to ASR server.
104
+
105
+ Primary: POST /v1/audio/transcriptions (OpenAI-compatible, openai_server.py).
106
+ Fallback: POST /asr (float32 JSON, legacy server.py).
107
+ """
108
+ lang = language or self.language
109
+
110
+ # 1) OpenAI-compatible (standard)
111
+ result = await self._try_openai_asr(waveform, lang)
112
+ if result:
113
+ return result
114
+
115
+ # 2) Legacy /asr fallback
116
+ result = await self._try_native_asr(waveform, lang)
117
+ return result or ""
118
+
119
+ async def _try_native_asr(
120
+ self, waveform: np.ndarray, lang: str
121
+ ) -> str | None:
122
+ """Try POST /asr with float32 JSON (server.py). Returns None on 404."""
123
+ import aiohttp
124
+
125
+ asr_url = f"{self.api_base}/asr"
126
+ try:
127
+ async with aiohttp.ClientSession() as session:
128
+ async with session.post(
129
+ asr_url,
130
+ json={
131
+ "audio_data": waveform.tolist(),
132
+ "sample_rate": self.sample_rate,
133
+ "language": lang,
134
+ },
135
+ timeout=aiohttp.ClientTimeout(total=300),
136
+ ) as resp:
137
+ if resp.status == 404:
138
+ return None # Signal fallback
139
+ if resp.status != 200:
140
+ text = await resp.text()
141
+ print(f" [STT] ASR error {resp.status}: {text[:200]}", flush=True)
142
+ return ""
143
+ result = await resp.json()
144
+ text = result.get("text", "")
145
+ return self._clean_sensevoice(text)
146
+ except Exception as e:
147
+ print(f" [STT] ASR connection error: {e}", flush=True)
148
+ return ""
149
+ return None
150
+
151
+ async def _try_openai_asr(
152
+ self, waveform: np.ndarray, lang: str
153
+ ) -> str:
154
+ """Try POST /v1/audio/transcriptions with WAV file upload (openai_server.py)."""
155
+ import aiohttp
156
+ import io
157
+ import soundfile as sf
158
+
159
+ transcribe_url = f"{self.api_base}/v1/audio/transcriptions"
160
+ try:
161
+ # Write float32 waveform to WAV in memory
162
+ wav_buf = io.BytesIO()
163
+ sf.write(wav_buf, waveform.astype(np.float32), self.sample_rate, format="WAV")
164
+ wav_buf.seek(0)
165
+
166
+ form = aiohttp.FormData()
167
+ form.add_field("file", wav_buf.read(),
168
+ filename="audio.wav",
169
+ content_type="audio/wav")
170
+ form.add_field("language", lang)
171
+ form.add_field("response_format", "json")
172
+
173
+ async with aiohttp.ClientSession() as session:
174
+ async with session.post(
175
+ transcribe_url,
176
+ data=form,
177
+ timeout=aiohttp.ClientTimeout(total=300),
178
+ ) as resp:
179
+ if resp.status != 200:
180
+ text = await resp.text()
181
+ print(f" [STT] OpenAI ASR error {resp.status}: {text[:200]}", flush=True)
182
+ return ""
183
+ result = await resp.json()
184
+ text = result.get("text", "")
185
+ return self._clean_sensevoice(text)
186
+ except Exception as e:
187
+ print(f" [STT] OpenAI ASR error: {e}", flush=True)
188
+ return ""
189
+
190
+ @staticmethod
191
+ def _clean_sensevoice(text: str) -> str:
192
+ """Remove SenseVoice special tokens like <|zh|>, <|emotion|>."""
193
+ text = re.sub(r'<\|[^|]*\|>', '', text)
194
+ text = re.sub(r'\s+', ' ', text).strip()
195
+ return text
196
+
197
+ # ── Local Whisper ───────────────────────────────────────
198
+
199
+ async def _transcribe_local(self, path: Path) -> str:
200
+ """Transcribe using local Whisper model."""
201
+ if self._local_model is None:
202
+ import whisper
203
+ self._local_model = whisper.load_model(self.model or "base")
204
+ result = self._local_model.transcribe(str(path))
205
+ return result["text"].strip()
206
+
207
+ # ── OpenAI Whisper API ──────────────────────────────────
208
+
209
+ async def _transcribe_openai(self, path: Path) -> str:
210
+ """Transcribe using OpenAI Whisper API."""
211
+ from openai import AsyncOpenAI
212
+
213
+ client = AsyncOpenAI(
214
+ api_key=self.api_key or "not-needed",
215
+ base_url=self.api_base or "https://api.openai.com/v1",
216
+ )
217
+ with open(path, "rb") as audio_file:
218
+ transcript = await client.audio.transcriptions.create(
219
+ model="whisper-1",
220
+ file=audio_file,
221
+ )
222
+ return transcript.text.strip()
223
+
224
+ # ── Audio decoding ──────────────────────────────────────
225
+
226
+ @staticmethod
227
+ def _decode_to_float32(audio_data: bytes) -> np.ndarray | None:
228
+ """Decode audio bytes to float32 numpy array in [-1, 1]."""
229
+ # WAV → soundfile
230
+ if audio_data[:4] == b"RIFF":
231
+ try:
232
+ import soundfile as sf
233
+ wav_buf = io.BytesIO(audio_data)
234
+ waveform, _ = sf.read(wav_buf, dtype="float32")
235
+ if waveform.ndim > 1:
236
+ waveform = waveform.mean(axis=1)
237
+ return waveform.astype(np.float32)
238
+ except Exception:
239
+ pass
240
+
241
+ # Compressed → pydub (ffmpeg)
242
+ try:
243
+ from pydub import AudioSegment
244
+ import soundfile as sf
245
+
246
+ suffix = _detect_suffix(audio_data)
247
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
248
+ tmp.write(audio_data)
249
+ tmp_path = tmp.name
250
+ try:
251
+ audio = AudioSegment.from_file(tmp_path)
252
+ audio = audio.set_channels(1).set_frame_rate(16000)
253
+ wav_buf = io.BytesIO()
254
+ audio.export(wav_buf, format="wav")
255
+ wav_buf.seek(0)
256
+ waveform, _ = sf.read(wav_buf, dtype="float32")
257
+ if waveform.ndim > 1:
258
+ waveform = waveform.mean(axis=1)
259
+ return waveform.astype(np.float32)
260
+ finally:
261
+ Path(tmp_path).unlink(missing_ok=True)
262
+ except ImportError:
263
+ pass
264
+ except Exception:
265
+ pass
266
+
267
+ # Raw int16 PCM (last resort)
268
+ try:
269
+ arr = np.frombuffer(audio_data, dtype=np.int16).astype(np.float32)
270
+ return arr / 32768.0
271
+ except Exception:
272
+ return None
273
+
274
+
275
+ def _detect_suffix(data: bytes) -> str:
276
+ """Guess file extension from magic bytes."""
277
+ if data[:4] == b"RIFF":
278
+ return ".wav"
279
+ if data[:3] == b"ID3":
280
+ return ".mp3"
281
+ if data[:4] == b"fLaC":
282
+ return ".flac"
283
+ if data[:4] == b"OggS":
284
+ return ".ogg"
285
+ if data[:4] == b"\x1a\x45\xdf\xa3":
286
+ return ".webm"
287
+ return ".webm"
agentic_rag/core/voice/tts.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text-to-Speech service.
2
+
3
+ Primary: Qwen3-TTS via OpenAI-compatible /v1/audio/speech API.
4
+ - VoiceDesign: generate voice from text description (instructions)
5
+ - CustomVoice: use predefined speaker names
6
+ - Base: voice cloning from reference audio
7
+ Fallback: Edge-TTS (free, good quality for Chinese).
8
+ """
9
+
10
+ import asyncio
11
+ import io
12
+ import tempfile
13
+ import time
14
+ from pathlib import Path
15
+
16
+
17
+ class TTSService:
18
+ """Text-to-Speech via Qwen3-TTS / Kokoro with Edge-TTS fallback."""
19
+
20
+ def __init__(
21
+ self,
22
+ provider: str = "qwen",
23
+ model: str = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
24
+ api_base: str = "http://0.0.0.0:8091",
25
+ api_key: str = "EMPTY",
26
+ task_type: str = "VoiceDesign",
27
+ instructions: str = "A clear, professional voice in Chinese",
28
+ language: str = "Chinese",
29
+ speaker: str = "",
30
+ voice: str = "zh-CN-XiaoxiaoNeural", # Edge-TTS fallback / kokoro voice
31
+ speed: float = 1.0, # kokoro playback speed
32
+ response_format: str = "wav",
33
+ max_new_tokens: int = 0,
34
+ ):
35
+ self.provider = provider # "qwen" | "kokoro" | "edge" | "openai"
36
+ self.model = model
37
+ self.api_base = api_base.rstrip("/")
38
+ self.api_key = api_key
39
+ self.task_type = task_type # VoiceDesign | CustomVoice | Base (qwen)
40
+ self.instructions = instructions # Voice description (qwen)
41
+ self.language = language # Chinese | zh | en | ja (qwen/kokoro)
42
+ self.speaker = speaker # Speaker name (qwen CustomVoice)
43
+ self.voice = voice # Edge-TTS / kokoro voice name
44
+ self.speed = speed # kokoro speed
45
+ self.response_format = response_format # wav | mp3 | flac | pcm
46
+ self.max_new_tokens = max_new_tokens
47
+
48
+ # ── Text cleaning ───────────────────────────────────────
49
+
50
+ @staticmethod
51
+ def _clean_for_tts(text: str) -> str:
52
+ """Strip Markdown, URLs, emoji, and formatting for TTS."""
53
+ import re
54
+
55
+ # Remove URLs
56
+ text = re.sub(r'https?://\S+', '', text)
57
+ # Remove Markdown images/links: ![alt](url) and [text](url)
58
+ text = re.sub(r'!\[.*?\]\(.*?\)', '', text)
59
+ text = re.sub(r'\[([^\]]*)\]\(.*?\)', r'\1', text)
60
+ # Remove Markdown formatting markers
61
+ text = re.sub(r'\*{1,3}([^*]+)\*{1,3}', r'\1', text) # **bold**, *italic*
62
+ text = re.sub(r'#{1,6}\s*', '', text) # headers
63
+ text = re.sub(r'`{1,3}[^`]*`{1,3}', '', text) # inline code / code blocks
64
+ text = re.sub(r'^[-*+]\s+', '', text, flags=re.MULTILINE) # list markers
65
+ text = re.sub(r'^\d+\.\s+', '', text, flags=re.MULTILINE) # numbered lists
66
+ # Remove reference markers like [R1], [R3]
67
+ text = re.sub(r'\[R\d+\]', '', text)
68
+ # Remove emoji and other non-speech symbols
69
+ text = re.sub(r'[^一-鿿 -〿＀-￯a-zA-Z0-9\s.,!?;:,。!?;:、""\'\'()【】《》…\-\']+', ' ', text)
70
+ # Collapse whitespace
71
+ text = re.sub(r'\s+', ' ', text).strip()
72
+ # Remove leading/trailing punctuation-only lines
73
+ text = re.sub(r'^[,.!?;:,。!?;:、\s]+$', '', text, flags=re.MULTILINE)
74
+ return text.strip()
75
+
76
+ # ── Public API ──────────────────────────────────────────
77
+
78
+ async def synthesize(self, text: str) -> bytes:
79
+ """Convert text to speech, returning raw audio bytes."""
80
+ if not text.strip():
81
+ return b""
82
+
83
+ # Clean text before TTS (strip Markdown/URLs/emoji)
84
+ clean_text = self._clean_for_tts(text)
85
+ if not clean_text:
86
+ return b""
87
+
88
+ if self.provider == "qwen":
89
+ result = await self._synthesize_qwen(clean_text)
90
+ if result:
91
+ return result
92
+ print(f" [TTS] Qwen3-TTS failed, falling back to Edge-TTS", flush=True)
93
+ return await self._synthesize_edge(clean_text)
94
+ elif self.provider == "kokoro":
95
+ result = await self._synthesize_kokoro(clean_text)
96
+ if result:
97
+ return result
98
+ print(f" [TTS] Kokoro failed, falling back to Edge-TTS", flush=True)
99
+ return await self._synthesize_edge(clean_text)
100
+ elif self.provider == "openai":
101
+ return await self._synthesize_openai(text)
102
+ else:
103
+ return await self._synthesize_edge(text)
104
+
105
+ async def synthesize_to_file(
106
+ self, text: str, output_path: str | Path
107
+ ) -> Path:
108
+ """Synthesize speech and save to a file."""
109
+ audio_bytes = await self.synthesize(text)
110
+ path = Path(output_path)
111
+ path.parent.mkdir(parents=True, exist_ok=True)
112
+ path.write_bytes(audio_bytes)
113
+ return path
114
+
115
+ # ── Qwen3-TTS (VoiceDesign) ���────────────────────────────
116
+
117
+ async def _synthesize_qwen(self, text: str) -> bytes:
118
+ """Synthesize via Qwen3-TTS OpenAI-compatible /v1/audio/speech."""
119
+ import aiohttp
120
+
121
+ payload = {
122
+ "model": self.model,
123
+ "input": text,
124
+ "response_format": self.response_format,
125
+ }
126
+ if self.task_type:
127
+ payload["task_type"] = self.task_type
128
+ if self.speaker:
129
+ payload["voice"] = self.speaker
130
+ if self.instructions:
131
+ payload["instructions"] = self.instructions
132
+ if self.language:
133
+ payload["language"] = self.language
134
+ if self.max_new_tokens > 0:
135
+ payload["max_new_tokens"] = self.max_new_tokens
136
+
137
+ api_url = f"{self.api_base}/v1/audio/speech"
138
+ headers = {
139
+ "Content-Type": "application/json",
140
+ "Authorization": f"Bearer {self.api_key}",
141
+ }
142
+
143
+ for attempt in range(3):
144
+ try:
145
+ async with aiohttp.ClientSession() as session:
146
+ async with session.post(
147
+ api_url,
148
+ json=payload,
149
+ headers=headers,
150
+ timeout=aiohttp.ClientTimeout(total=120),
151
+ ) as resp:
152
+ if resp.status != 200:
153
+ err = await _read_error(resp)
154
+ print(f" [TTS] Qwen error (attempt {attempt+1}/3): "
155
+ f"{resp.status} {err[:100]}", flush=True)
156
+ if attempt < 2:
157
+ await asyncio.sleep(2 ** attempt)
158
+ continue
159
+ return await resp.read()
160
+ except Exception as e:
161
+ print(f" [TTS] Qwen request error (attempt {attempt+1}/3): {e}",
162
+ flush=True)
163
+ if attempt < 2:
164
+ await asyncio.sleep(2 ** attempt)
165
+
166
+ return b""
167
+
168
+ # ── Edge-TTS (fallback) ─────────────────────────────────
169
+
170
+ async def _synthesize_edge(self, text: str) -> bytes:
171
+ """Synthesize using Edge-TTS (free, good Chinese quality)."""
172
+ try:
173
+ import edge_tts
174
+ communicate = edge_tts.Communicate(text, self.voice)
175
+ with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
176
+ temp_path = f.name
177
+ await communicate.save(temp_path)
178
+ data = Path(temp_path).read_bytes()
179
+ Path(temp_path).unlink(missing_ok=True)
180
+ return data
181
+ except ImportError:
182
+ print(f" [TTS] edge-tts not installed", flush=True)
183
+ return b""
184
+ except Exception as e:
185
+ print(f" [TTS] Edge-TTS error: {e}", flush=True)
186
+ return b""
187
+
188
+ # ── Kokoro TTS (Axera NPU) ────────────────────────────
189
+
190
+ async def _synthesize_kokoro(self, text: str) -> bytes:
191
+ """Synthesize via Kokoro TTS server (POST /tts → WAV bytes).
192
+
193
+ API: POST /tts with form/JSON body
194
+ text/sentence: text to speak
195
+ language/lang: zh | en | ja
196
+ voice: voice name (e.g., zf_xiaoyi, zf_xiaoxiao)
197
+ speed: playback speed (default 1.0)
198
+ Returns: WAV audio bytes
199
+ """
200
+ import aiohttp
201
+
202
+ # Map internal language codes to Kokoro codes
203
+ lang_map = {
204
+ "zh": "zh", "chinese": "zh",
205
+ "en": "en", "english": "en",
206
+ "ja": "ja", "japanese": "ja",
207
+ "auto": "zh",
208
+ }
209
+ kokoro_lang = lang_map.get(
210
+ (self.language or "zh").lower(), "zh"
211
+ )
212
+
213
+ payload = {
214
+ "text": text,
215
+ "language": kokoro_lang,
216
+ "voice": self.voice or "zf_xiaoyi",
217
+ "speed": str(self.speed),
218
+ }
219
+
220
+ api_url = f"{self.api_base}/tts"
221
+
222
+ for attempt in range(3):
223
+ try:
224
+ async with aiohttp.ClientSession() as session:
225
+ async with session.post(
226
+ api_url,
227
+ data=payload,
228
+ timeout=aiohttp.ClientTimeout(total=120),
229
+ ) as resp:
230
+ if resp.status != 200:
231
+ err_body = await resp.text()
232
+ print(
233
+ f" [TTS] Kokoro error (attempt {attempt+1}/3): "
234
+ f"{resp.status} {err_body[:200]}", flush=True,
235
+ )
236
+ if attempt < 2:
237
+ await asyncio.sleep(2 ** attempt)
238
+ continue
239
+ return await resp.read()
240
+ except Exception as e:
241
+ print(
242
+ f" [TTS] Kokoro request error (attempt {attempt+1}/3): {e}",
243
+ flush=True,
244
+ )
245
+ if attempt < 2:
246
+ await asyncio.sleep(2 ** attempt)
247
+
248
+ return b""
249
+
250
+ # ── OpenAI TTS ──────────────────────────────────────────
251
+
252
+ async def _synthesize_openai(self, text: str) -> bytes:
253
+ """Synthesize using OpenAI TTS API."""
254
+ from openai import AsyncOpenAI
255
+
256
+ client = AsyncOpenAI(
257
+ api_key=self.api_key or "not-needed",
258
+ base_url=self.api_base or "https://api.openai.com/v1",
259
+ )
260
+ response = await client.audio.speech.create(
261
+ model="tts-1",
262
+ voice="alloy",
263
+ input=text,
264
+ )
265
+ return response.content
266
+
267
+
268
+ async def _read_error(resp) -> str:
269
+ """Extract error detail from a failed TTS response."""
270
+ try:
271
+ data = await resp.json()
272
+ return str(data.get("detail", data.get("error", str(data))))[:200]
273
+ except Exception:
274
+ try:
275
+ return (await resp.text())[:200]
276
+ except Exception:
277
+ return str(resp.status)
agentic_rag/data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Agentic RAG module."""
agentic_rag/data/db/__init__.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Database layer — SQLite-backed persistence for sessions, documents, and metadata.
2
+
3
+ Replaces the in-memory dicts used during early development with a proper
4
+ persistent store. Uses aiosqlite for async access so it integrates cleanly
5
+ with the FastAPI / asyncio stack.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sqlite3
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+
15
+ # ═══════════════════════════════════════════════════════════════
16
+ # Connection management
17
+ # ═══════════════════════════════════════════════════════════════
18
+
19
+ class Database:
20
+ """Thin wrapper around a SQLite connection with schema management."""
21
+
22
+ def __init__(self, db_path: str | Path = "data/agentic_rag.db"):
23
+ self._path = Path(db_path)
24
+ self._path.parent.mkdir(parents=True, exist_ok=True)
25
+ self._conn: Optional[sqlite3.Connection] = None
26
+
27
+ # ── Lifecycle ─────────────────────────────────
28
+
29
+ @property
30
+ def conn(self) -> sqlite3.Connection:
31
+ if self._conn is None:
32
+ self._conn = sqlite3.connect(str(self._path))
33
+ self._conn.row_factory = sqlite3.Row
34
+ self._conn.execute("PRAGMA journal_mode=WAL")
35
+ self._conn.execute("PRAGMA foreign_keys=ON")
36
+ return self._conn
37
+
38
+ def close(self) -> None:
39
+ if self._conn:
40
+ self._conn.close()
41
+ self._conn = None
42
+
43
+ # ── Schema ────────────────────────────────────
44
+
45
+ SCHEMA_VERSION = 1
46
+
47
+ def init_schema(self) -> None:
48
+ """Create tables if they don't exist (idempotent)."""
49
+ c = self.conn
50
+ c.executescript("""
51
+ -- Schema versioning
52
+ CREATE TABLE IF NOT EXISTS _schema (
53
+ version INTEGER PRIMARY KEY,
54
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
55
+ );
56
+
57
+ -- Sessions (chat conversation containers)
58
+ CREATE TABLE IF NOT EXISTS sessions (
59
+ id TEXT PRIMARY KEY,
60
+ user_id TEXT NOT NULL DEFAULT 'default',
61
+ title TEXT NOT NULL DEFAULT '',
62
+ metadata TEXT NOT NULL DEFAULT '{}',
63
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
64
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
65
+ );
66
+
67
+ -- Messages within a session
68
+ CREATE TABLE IF NOT EXISTS messages (
69
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
70
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
71
+ role TEXT NOT NULL, -- 'system' | 'user' | 'assistant' | 'tool'
72
+ content TEXT NOT NULL DEFAULT '',
73
+ tool_calls TEXT NOT NULL DEFAULT '[]',
74
+ tool_call_id TEXT DEFAULT NULL,
75
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
76
+ );
77
+ CREATE INDEX IF NOT EXISTS idx_messages_session
78
+ ON messages(session_id, id);
79
+
80
+ -- Documents ingested into the knowledge base
81
+ CREATE TABLE IF NOT EXISTS documents (
82
+ id TEXT PRIMARY KEY,
83
+ source TEXT NOT NULL DEFAULT '',
84
+ source_type TEXT NOT NULL DEFAULT 'text',
85
+ content_preview TEXT NOT NULL DEFAULT '',
86
+ item_count INTEGER NOT NULL DEFAULT 0,
87
+ metadata TEXT NOT NULL DEFAULT '{}',
88
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
89
+ );
90
+
91
+ -- Content items (individual chunks / images / tables)
92
+ CREATE TABLE IF NOT EXISTS content_items (
93
+ id TEXT PRIMARY KEY,
94
+ doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
95
+ type TEXT NOT NULL DEFAULT 'text',
96
+ text TEXT NOT NULL DEFAULT '',
97
+ img_path TEXT NOT NULL DEFAULT '',
98
+ table_body TEXT NOT NULL DEFAULT '',
99
+ page_idx INTEGER NOT NULL DEFAULT 0,
100
+ metadata TEXT NOT NULL DEFAULT '{}',
101
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
102
+ );
103
+ CREATE INDEX IF NOT EXISTS idx_content_doc
104
+ ON content_items(doc_id);
105
+ CREATE INDEX IF NOT EXISTS idx_content_type
106
+ ON content_items(type);
107
+
108
+ -- Platform-to-session bindings (for messaging gateway)
109
+ CREATE TABLE IF NOT EXISTS platform_sessions (
110
+ platform TEXT NOT NULL,
111
+ platform_user_id TEXT NOT NULL,
112
+ chat_id TEXT NOT NULL DEFAULT '',
113
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
114
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
115
+ PRIMARY KEY (platform, platform_user_id, chat_id)
116
+ );
117
+
118
+ -- Key-value store for application config / cache
119
+ CREATE TABLE IF NOT EXISTS kv_store (
120
+ key TEXT PRIMARY KEY,
121
+ value TEXT NOT NULL DEFAULT '',
122
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
123
+ );
124
+ """)
125
+
126
+ # Record schema version
127
+ existing = c.execute(
128
+ "SELECT version FROM _schema WHERE version = ?",
129
+ (self.SCHEMA_VERSION,),
130
+ ).fetchone()
131
+ if not existing:
132
+ c.execute(
133
+ "INSERT INTO _schema (version) VALUES (?)",
134
+ (self.SCHEMA_VERSION,),
135
+ )
136
+ c.commit()
137
+
138
+ # ── Raw SQL helpers ───────────────────────────
139
+
140
+ def execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor:
141
+ return self.conn.execute(sql, params)
142
+
143
+ def executemany(self, sql: str, params_list: list[tuple]) -> sqlite3.Cursor:
144
+ return self.conn.executemany(sql, params_list)
145
+
146
+ def commit(self) -> None:
147
+ self.conn.commit()
148
+
149
+ def fetchone(self, sql: str, params: tuple = ()) -> Optional[sqlite3.Row]:
150
+ return self.conn.execute(sql, params).fetchone()
151
+
152
+ def fetchall(self, sql: str, params: tuple = ()) -> list[sqlite3.Row]:
153
+ return self.conn.execute(sql, params).fetchall()
154
+
155
+
156
+ # ═══════════════════════════════════════════════════════════════
157
+ # Global instance
158
+ # ═══════════════════════════════════════════════════════════════
159
+
160
+ _db: Optional[Database] = None
161
+
162
+
163
+ def get_db(db_path: str | Path = "") -> Database:
164
+ """Get or create the global database instance."""
165
+ global _db
166
+ if _db is None:
167
+ path = db_path or "data/agentic_rag.db"
168
+ try:
169
+ from agentic_rag.config.settings import get_settings
170
+ path = get_settings().db_path
171
+ except Exception:
172
+ pass
173
+ _db = Database(path)
174
+ _db.init_schema()
175
+ return _db
176
+
177
+
178
+ def init_db(db_path: str | Path = "") -> Database:
179
+ """Explicitly initialise the database (call at app startup)."""
180
+ global _db
181
+ _db = Database(db_path or "data/agentic_rag.db")
182
+ _db.init_schema()
183
+ return _db
agentic_rag/data/db/session_repo.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Session repository — persistence layer for chat sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from typing import Optional
9
+
10
+ from agentic_rag.data.db import get_db
11
+
12
+
13
+ class SessionRepo:
14
+ """CRUD operations for sessions."""
15
+
16
+ def __init__(self) -> None:
17
+ self._db = get_db()
18
+
19
+ # ── Session CRUD ──────────────────────────────
20
+
21
+ def create(self, user_id: str = "default", title: str = "") -> dict:
22
+ sid = uuid.uuid4().hex
23
+ return self.create_with_id(sid, user_id, title)
24
+
25
+ def create_with_id(self, sid: str, user_id: str = "default", title: str = "") -> dict:
26
+ now = _now()
27
+ self._db.execute(
28
+ "INSERT INTO sessions (id, user_id, title, created_at, updated_at) "
29
+ "VALUES (?, ?, ?, ?, ?)",
30
+ (sid, user_id, title, now, now),
31
+ )
32
+ self._db.commit()
33
+ return {"id": sid, "user_id": user_id, "title": title,
34
+ "created_at": now, "updated_at": now}
35
+
36
+ def get(self, session_id: str) -> Optional[dict]:
37
+ row = self._db.fetchone(
38
+ "SELECT * FROM sessions WHERE id = ?", (session_id,),
39
+ )
40
+ return dict(row) if row else None
41
+
42
+ def list(self, user_id: str = "default", limit: int = 50) -> list[dict]:
43
+ rows = self._db.fetchall(
44
+ "SELECT * FROM sessions WHERE user_id = ? "
45
+ "ORDER BY updated_at DESC LIMIT ?",
46
+ (user_id, limit),
47
+ )
48
+ return [dict(r) for r in rows]
49
+
50
+ def update(self, session_id: str, **fields) -> bool:
51
+ if not fields:
52
+ return False
53
+ sets = [f"{k} = ?" for k in fields]
54
+ values = list(fields.values())
55
+ values.append(_now())
56
+ values.append(session_id)
57
+ self._db.execute(
58
+ f"UPDATE sessions SET {', '.join(sets)}, updated_at = ? "
59
+ f"WHERE id = ?",
60
+ tuple(values),
61
+ )
62
+ self._db.commit()
63
+ return True
64
+
65
+ def delete(self, session_id: str) -> bool:
66
+ self._db.execute("DELETE FROM platform_sessions WHERE session_id = ?", (session_id,))
67
+ self._db.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
68
+ self._db.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
69
+ self._db.commit()
70
+ return True
71
+
72
+ def delete_all(self, user_id: str = "default") -> int:
73
+ """Delete all sessions and messages for a user. Returns count of deleted sessions."""
74
+ sessions = self._db.fetchall(
75
+ "SELECT id FROM sessions WHERE user_id = ?", (user_id,),
76
+ )
77
+ count = len(sessions)
78
+ for s in sessions:
79
+ self._db.execute("DELETE FROM platform_sessions WHERE session_id = ?", (s["id"],))
80
+ self._db.execute("DELETE FROM messages WHERE session_id = ?", (s["id"],))
81
+ self._db.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
82
+ self._db.commit()
83
+ return count
84
+
85
+ # ── Platform Session Binding ──────────────────
86
+
87
+ def get_platform_session(
88
+ self, platform: str, platform_user_id: str, chat_id: str = ""
89
+ ) -> Optional[str]:
90
+ """Get the internal session_id for a platform user+chat combination."""
91
+ row = self._db.fetchone(
92
+ "SELECT session_id FROM platform_sessions "
93
+ "WHERE platform = ? AND platform_user_id = ? AND chat_id = ?",
94
+ (platform, platform_user_id, chat_id),
95
+ )
96
+ return row["session_id"] if row else None
97
+
98
+ def clear_messages(self, session_id: str) -> int:
99
+ """Delete all messages from a session, keep the session. Returns count of deleted messages."""
100
+ cursor = self._db.execute(
101
+ "SELECT COUNT(*) as cnt FROM messages WHERE session_id = ?",
102
+ (session_id,),
103
+ )
104
+ count = cursor.fetchone()["cnt"]
105
+ self._db.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
106
+ self._db.commit()
107
+ return count
108
+
109
+ def bind_platform_session(
110
+ self, platform: str, platform_user_id: str,
111
+ chat_id: str, session_id: str,
112
+ ) -> None:
113
+ """Bind a platform user+chat to an internal session (idempotent upsert)."""
114
+ self._db.execute(
115
+ "INSERT OR REPLACE INTO platform_sessions "
116
+ "(platform, platform_user_id, chat_id, session_id) "
117
+ "VALUES (?, ?, ?, ?)",
118
+ (platform, platform_user_id, chat_id, session_id),
119
+ )
120
+ self._db.commit()
121
+ """Delete all sessions and messages for a user. Returns count of deleted sessions."""
122
+ sessions = self._db.fetchall(
123
+ "SELECT id FROM sessions WHERE user_id = ?", (user_id,),
124
+ )
125
+ count = len(sessions)
126
+ for s in sessions:
127
+ self._db.execute("DELETE FROM messages WHERE session_id = ?", (s["id"],))
128
+ self._db.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
129
+ self._db.commit()
130
+ return count
131
+
132
+ # ── Messages ──────────────────────────────────
133
+
134
+ def add_message(self, session_id: str, role: str, content: str,
135
+ tool_calls: list | None = None,
136
+ tool_call_id: str | None = None) -> int:
137
+ self._db.execute(
138
+ "UPDATE sessions SET updated_at = ? WHERE id = ?",
139
+ (_now(), session_id),
140
+ )
141
+ cursor = self._db.execute(
142
+ "INSERT INTO messages (session_id, role, content, tool_calls, tool_call_id) "
143
+ "VALUES (?, ?, ?, ?, ?)",
144
+ (session_id, role, content,
145
+ json.dumps(tool_calls or []),
146
+ tool_call_id),
147
+ )
148
+ self._db.commit()
149
+ return cursor.lastrowid
150
+
151
+ def get_messages(self, session_id: str, limit: int = 50) -> list[dict]:
152
+ rows = self._db.fetchall(
153
+ "SELECT * FROM messages WHERE session_id = ? "
154
+ "ORDER BY id ASC LIMIT ?",
155
+ (session_id, limit),
156
+ )
157
+ return [dict(r) for r in rows]
158
+
159
+
160
+ # ═══════════════════════════════════════════════════════════════
161
+ # Helpers
162
+ # ═══════════════════════════════════════════════════════════════
163
+
164
+ def _now() -> str:
165
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
agentic_rag/data/models.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core domain models for Agentic RAG."""
2
+
3
+ import time
4
+ import uuid
5
+ from enum import Enum
6
+ from typing import Any, Optional
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ # ──────────────────────────────────────────────
12
+ # Message & Conversation Models
13
+ # ──────────────────────────────────────────────
14
+
15
+ class MessageRole(str, Enum):
16
+ SYSTEM = "system"
17
+ USER = "user"
18
+ ASSISTANT = "assistant"
19
+ TOOL = "tool"
20
+
21
+
22
+ class ToolCall(BaseModel):
23
+ """A tool call made by the LLM."""
24
+ id: str = Field(default_factory=lambda: f"call_{uuid.uuid4().hex[:12]}")
25
+ name: str
26
+ arguments: dict[str, Any] = Field(default_factory=dict)
27
+
28
+
29
+ class ToolCallResult(BaseModel):
30
+ """Result of a tool call."""
31
+ call_id: str
32
+ name: str
33
+ result: Any
34
+ error: Optional[str] = None
35
+
36
+
37
+ class Message(BaseModel):
38
+ """A single message in a conversation."""
39
+ role: MessageRole
40
+ content: str | list[dict[str, Any]] # text or multimodal content
41
+ tool_calls: list[ToolCall] = Field(default_factory=list)
42
+ tool_call_id: Optional[str] = None
43
+ timestamp: float = Field(default_factory=time.time)
44
+
45
+ @classmethod
46
+ def system(cls, content: str) -> "Message":
47
+ return cls(role=MessageRole.SYSTEM, content=content)
48
+
49
+ @classmethod
50
+ def user(cls, content: str) -> "Message":
51
+ return cls(role=MessageRole.USER, content=content)
52
+
53
+ @classmethod
54
+ def assistant(cls, content: str, tool_calls: list[ToolCall] | None = None) -> "Message":
55
+ return cls(role=MessageRole.ASSISTANT, content=content,
56
+ tool_calls=tool_calls or [])
57
+
58
+ @classmethod
59
+ def tool(cls, content: str, tool_call_id: str) -> "Message":
60
+ return cls(role=MessageRole.TOOL, content=content, tool_call_id=tool_call_id)
61
+
62
+
63
+ # ──────────────────────────────────────────────
64
+ # LLM Models
65
+ # ──────────────────────────────────────────────
66
+
67
+ class ToolDefinition(BaseModel):
68
+ """Tool definition for LLM function calling."""
69
+ name: str
70
+ description: str
71
+ parameters: dict[str, Any] # JSON Schema
72
+
73
+
74
+ class LLMResponse(BaseModel):
75
+ """Response from an LLM provider."""
76
+ content: str
77
+ tool_calls: list[ToolCall] = Field(default_factory=list)
78
+ stop_reason: Optional[str] = None
79
+ usage: dict[str, int] = Field(default_factory=dict)
80
+
81
+
82
+ class LLMChunk(BaseModel):
83
+ """Streaming chunk from an LLM provider."""
84
+ content_delta: str = ""
85
+ tool_call_delta: Optional[dict] = None
86
+ stop_reason: Optional[str] = None
87
+
88
+
89
+ # ──────────────────────────────────────────────
90
+ # Multimodal Input Models
91
+ # ──────────────────────────────────────────────
92
+
93
+ class MediaType(str, Enum):
94
+ IMAGE = "image"
95
+ VIDEO = "video"
96
+ AUDIO = "audio"
97
+ TEXT = "text"
98
+
99
+
100
+ class MultimodalInput(BaseModel):
101
+ """User input that may contain multiple modalities."""
102
+ text: Optional[str] = None
103
+ images: list[str] = Field(default_factory=list) # base64 or file paths
104
+ audio: Optional[str] = None # base64 or file path
105
+ video: Optional[str] = None # file path
106
+ metadata: dict[str, Any] = Field(default_factory=dict)
107
+
108
+
109
+ class ProcessedContent(BaseModel):
110
+ """Content after multimodal processing."""
111
+ text: str
112
+ source_types: list[MediaType] = Field(default_factory=list)
113
+ metadata: dict[str, Any] = Field(default_factory=dict)
114
+
115
+
116
+ # ──────────────────────────────────────────────
117
+ # Agent Models
118
+ # ──────────────────────────────────────────────
119
+
120
+ class AgentEventType(str, Enum):
121
+ THOUGHT = "thought"
122
+ ACTION = "action"
123
+ OBSERVATION = "observation"
124
+ TEXT_DELTA = "text_delta"
125
+ TOOL_CALL_START = "tool_call_start"
126
+ TOOL_CALL_RESULT = "tool_call_result"
127
+ ERROR = "error"
128
+ DONE = "done"
129
+
130
+
131
+ class AgentEvent(BaseModel):
132
+ """An event emitted during agent execution."""
133
+ event_type: AgentEventType
134
+ data: dict[str, Any] = Field(default_factory=dict)
135
+ turn_id: str = ""
136
+ timestamp: float = Field(default_factory=time.time)
137
+
138
+
139
+ class AgentInput(BaseModel):
140
+ """Input to an agent."""
141
+ messages: list[Message] = Field(default_factory=list)
142
+ query: str = ""
143
+ multimodal: Optional[MultimodalInput] = None
144
+ parameters: dict[str, Any] = Field(default_factory=dict)
145
+
146
+
147
+ class AgentOutput(BaseModel):
148
+ """Output from an agent."""
149
+ messages: list[Message] = Field(default_factory=list)
150
+ final_answer: str = ""
151
+ tool_calls_made: list[ToolCallResult] = Field(default_factory=list)
152
+ usage: dict[str, int] = Field(default_factory=dict)
153
+ iterations: int = 0
154
+
155
+
156
+ # ──────────────────────────────────────────────
157
+ # RAG Models
158
+ # ──────────────────────────────────────────────
159
+
160
+ class Document(BaseModel):
161
+ """A document in the knowledge base."""
162
+ id: str = Field(default_factory=lambda: uuid.uuid4().hex)
163
+ text: str
164
+ metadata: dict[str, Any] = Field(default_factory=dict)
165
+ embedding: list[float] = Field(default_factory=list)
166
+
167
+
168
+ class RetrievalResult(BaseModel):
169
+ """Result of a retrieval operation."""
170
+ documents: list[Document] = Field(default_factory=list)
171
+ scores: list[float] = Field(default_factory=list)
172
+ query: str = ""
173
+
174
+
175
+ # ──────────────────────────────────────────────
176
+ # Session Models
177
+ # ──────────────────────────────────────────────
178
+
179
+ class Session(BaseModel):
180
+ """A user session."""
181
+ id: str = Field(default_factory=lambda: uuid.uuid4().hex)
182
+ user_id: str = "default"
183
+ created_at: float = Field(default_factory=time.time)
184
+ expires_at: float = Field(default_factory=lambda: time.time() + 3600)
185
+ metadata: dict[str, Any] = Field(default_factory=dict)
agentic_rag/data/schemas/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Agentic RAG module."""
agentic_rag/entrypoints/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Agentic RAG module."""
agentic_rag/entrypoints/cli/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Agentic RAG module."""
agentic_rag/entrypoints/cli/main.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLI entry point for Agentic RAG."""
2
+
3
+ import asyncio
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.markdown import Markdown
10
+ from rich.panel import Panel
11
+
12
+ app = typer.Typer(help="Agentic RAG — Multi-modal ReAct-powered RAG System")
13
+ console = Console()
14
+
15
+
16
+ @app.command()
17
+ def chat(
18
+ message: str = typer.Argument(..., help="Your message/question"),
19
+ mode: str = typer.Option("auto", help="Agent mode: auto, chat, rag, research, media"),
20
+ stream: bool = typer.Option(False, "--stream", "-s", help="Stream the response"),
21
+ provider: str = typer.Option("", help="LLM provider to use"),
22
+ ):
23
+ """Send a message to the agent."""
24
+ from agentic_rag.services.llm.factory import get_llm
25
+ from agentic_rag.orchestration.l1_tools.registry import get_tool_registry
26
+ from agentic_rag.agent.router import AgentRouter
27
+ from agentic_rag.data.models import AgentInput
28
+
29
+ async def _run():
30
+ llm = get_llm(provider) if provider else get_llm()
31
+ tool_registry = get_tool_registry()
32
+ _register_tools(tool_registry)
33
+
34
+ router = AgentRouter(llm, tool_registry)
35
+ engine = await router.route(query=message, preferred_mode=mode if mode != "auto" else None)
36
+
37
+ if stream:
38
+ input_data = AgentInput(query=message)
39
+ async for event in engine.stream(input_data):
40
+ if event.event_type.value == "text_delta":
41
+ console.print(event.data.get("content", ""), end="")
42
+ elif event.event_type.value == "tool_call_start":
43
+ console.print(f"\n[dim]🔧 {event.data['tool']}...[/dim]")
44
+ elif event.event_type.value == "tool_call_result":
45
+ status = "✓" if event.data.get("success") else "✗"
46
+ console.print(f"[dim] {status} Done[/dim]")
47
+ console.print()
48
+ else:
49
+ with console.status("[bold green]Thinking..."):
50
+ input_data = AgentInput(query=message)
51
+ output = await engine.run(input_data)
52
+
53
+ console.print(Panel(Markdown(output.final_answer), title="Answer"))
54
+ if output.tool_calls_made:
55
+ console.print(f"[dim]Tools used: {len(output.tool_calls_made)}, Iterations: {output.iterations}[/dim]")
56
+
57
+ asyncio.run(_run())
58
+
59
+
60
+ @app.command()
61
+ def serve(
62
+ host: str = typer.Option("0.0.0.0", help="Host to bind"),
63
+ port: int = typer.Option(8000, help="Port to bind"),
64
+ reload: bool = typer.Option(False, help="Enable auto-reload"),
65
+ ):
66
+ """Start the API server."""
67
+ import uvicorn
68
+ console.print(f"[bold green]Starting Agentic RAG server on {host}:{port}[/bold green]")
69
+ uvicorn.run(
70
+ "agentic_rag.entrypoints.rest.app:app",
71
+ host=host,
72
+ port=port,
73
+ reload=reload,
74
+ log_level="info",
75
+ )
76
+
77
+
78
+ @app.command()
79
+ def ingest(
80
+ file: str = typer.Option(..., "--file", "-f", help="File to ingest"),
81
+ source: str = typer.Option("cli", help="Source identifier"),
82
+ ):
83
+ """Ingest a document into the knowledge base."""
84
+ from agentic_rag.orchestration.l1_tools.rag_tools import RAGIngestTool
85
+
86
+ async def _run():
87
+ path = Path(file)
88
+ if not path.exists():
89
+ console.print(f"[red]File not found: {file}[/red]")
90
+ sys.exit(1)
91
+
92
+ content = path.read_text()
93
+ tool = RAGIngestTool()
94
+ result = await tool.execute(content=content, source=source)
95
+ console.print(f"[green]{result}[/green]")
96
+
97
+ asyncio.run(_run())
98
+
99
+
100
+ @app.command()
101
+ def info():
102
+ """Show system information."""
103
+ from agentic_rag import __version__
104
+ from agentic_rag.config.settings import get_settings
105
+
106
+ settings = get_settings()
107
+
108
+ console.print(Panel(f"Agentic RAG v{__version__}", title="System Info"))
109
+ console.print(f"Default LLM Provider: {settings.default_provider}")
110
+ for name, cfg in settings.llm_providers.items():
111
+ console.print(f" {name}: {cfg.model} @ {cfg.api_base}")
112
+ console.print(f"Milvus: {settings.milvus.host}:{settings.milvus.port}")
113
+ console.print(f"Embedding: {settings.embedding.model} (dim={settings.embedding.dim})")
114
+
115
+
116
+ def _register_tools(registry):
117
+ """Register built-in tools."""
118
+ if registry.tool_count == 0:
119
+ from agentic_rag.orchestration.l1_tools.rag_tools import RAGSearchTool
120
+ from agentic_rag.orchestration.l1_tools.web_tools import WebFetchTool, WebSearchTool
121
+ from agentic_rag.orchestration.l1_tools.code_tools import CodeExecuteTool
122
+
123
+ registry.register(RAGSearchTool())
124
+ registry.register(WebSearchTool())
125
+ registry.register(WebFetchTool())
126
+ registry.register(CodeExecuteTool())
127
+
128
+
129
+ if __name__ == "__main__":
130
+ app()
agentic_rag/entrypoints/gateway/__init__.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gateway adaptors for messaging platforms (WeChat Work, DingTalk, Feishu).
2
+
3
+ Each platform sub-package implements a webhook endpoint that:
4
+ 1. Verifies the incoming request signature
5
+ 2. Parses the platform-specific message format
6
+ 3. Routes the query to the Agentic RAG engine
7
+ 4. Returns a formatted response
8
+ """
9
+
10
+ from agentic_rag.entrypoints.gateway.base import (
11
+ BasePlatformAdaptor,
12
+ PlatformMessage,
13
+ PlatformResponse,
14
+ )
15
+ from agentic_rag.entrypoints.gateway.session import PlatformSessionMap
16
+
17
+ __all__ = [
18
+ "BasePlatformAdaptor",
19
+ "PlatformMessage",
20
+ "PlatformResponse",
21
+ "PlatformSessionMap",
22
+ ]
agentic_rag/entrypoints/gateway/base.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base adaptor interface and shared models for messaging platform gateways."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from fastapi import Request, Response
11
+
12
+ if TYPE_CHECKING:
13
+ from agentic_rag.data.models import AgentOutput
14
+
15
+
16
+ # ═══════════════════════════════════════════════════════════════════
17
+ # Normalized Message / Response Models
18
+ # ═══════════════════════════════════════════════════════════════════
19
+
20
+ @dataclass
21
+ class PlatformMessage:
22
+ """Normalized inbound message from any messaging platform."""
23
+
24
+ platform: str # "wechat_work" | "dingtalk" | "feishu"
25
+ sender_id: str # Platform-specific user ID
26
+ sender_name: str = "" # Display name (optional)
27
+ chat_id: str = "" # Group chat ID or individual chat ID
28
+ chat_type: str = "single" # "single" | "group"
29
+ text: str = "" # Extracted text content
30
+ msg_type: str = "text" # "text" | "image" | "voice" | "event"
31
+ raw_payload: dict[str, Any] = field(default_factory=dict) # Original platform payload
32
+ reply_token: str = "" # Token/URL needed to send a reply
33
+
34
+
35
+ @dataclass
36
+ class PlatformResponse:
37
+ """Normalized outbound response to be sent back to a platform."""
38
+
39
+ content: str # Formatted reply text
40
+ msg_type: str = "text" # "text" | "markdown" | "news" | "image"
41
+ status_code: int = 200
42
+ extra: dict[str, Any] = field(default_factory=dict) # Platform-specific extras (at_list, buttons, etc.)
43
+
44
+
45
+ # ═══════════════════════════════════════════════════════════════════
46
+ # Base Adaptor (Template Method)
47
+ # ═══════════════════════════════════════════════════════════════════
48
+
49
+ class BasePlatformAdaptor(ABC):
50
+ """Template-method adaptor for a messaging platform.
51
+
52
+ Subclasses override the platform-specific steps; the pipeline
53
+ (verify → parse → route → format) is shared.
54
+ """
55
+
56
+ platform_name: str = ""
57
+
58
+ def __init__(self, config: Any) -> None:
59
+ self.config = config
60
+
61
+ # ── Template method ──────────────────────────────────────
62
+
63
+ async def process(self, request: Request) -> Response:
64
+ """Full pipeline: verify → parse → run agent → format → respond."""
65
+ # 1. Verify
66
+ if not await self.verify_request(request):
67
+ return Response(status_code=403, content="Signature verification failed")
68
+
69
+ # 2. Parse
70
+ msg = await self.parse_message(request)
71
+
72
+ # Skip non-text messages gracefully
73
+ if msg.msg_type not in ("text",):
74
+ return Response(status_code=200, content=self._empty_ack())
75
+
76
+ if not msg.text.strip():
77
+ return Response(status_code=200, content=self._empty_ack())
78
+
79
+ # 3. Quick ACK if async mode
80
+ try:
81
+ from agentic_rag.config.settings import get_settings
82
+ response_mode = get_settings().gateway.response_mode
83
+ except Exception:
84
+ response_mode = "sync"
85
+
86
+ # 4. Run agent (may be long)
87
+ if response_mode == "async":
88
+ # Fire-and-forget: return 200 immediately, push result later
89
+ import asyncio
90
+ asyncio.create_task(self._process_async(msg))
91
+ return Response(status_code=200, content=self._empty_ack())
92
+
93
+ # 5. Sync: run agent inline and return result
94
+ output = await self._run_agent(msg)
95
+ presp = await self.format_response(output, msg)
96
+ return await self._build_http_response(presp)
97
+
98
+ # ── Steps subclasses must implement ──────────────────────
99
+
100
+ @abstractmethod
101
+ async def verify_request(self, request: Request) -> bool:
102
+ """Verify the incoming webhook signature/token."""
103
+ ...
104
+
105
+ @abstractmethod
106
+ async def parse_message(self, request: Request) -> PlatformMessage:
107
+ """Parse the platform-specific payload into a PlatformMessage."""
108
+ ...
109
+
110
+ @abstractmethod
111
+ async def format_response(
112
+ self, output: "AgentOutput", msg: PlatformMessage
113
+ ) -> PlatformResponse:
114
+ """Convert AgentOutput to a platform-compatible response."""
115
+ ...
116
+
117
+ @abstractmethod
118
+ async def _build_http_response(self, presp: PlatformResponse) -> Response:
119
+ """Build the HTTP response object for this platform."""
120
+ ...
121
+
122
+ @abstractmethod
123
+ async def push_message(self, msg: PlatformMessage, text: str) -> None:
124
+ """Send a message to the platform's push API (used in async mode)."""
125
+ ...
126
+
127
+ # ── Shared agent invocation ──────────────────────────────
128
+
129
+ async def _run_agent(self, msg: PlatformMessage) -> "AgentOutput":
130
+ """Route the message to the RAG agent engine and return the result."""
131
+ from agentic_rag.services.llm.factory import get_llm
132
+ from agentic_rag.orchestration.l1_tools.registry import get_tool_registry
133
+ from agentic_rag.agent.router import AgentRouter
134
+ from agentic_rag.data.models import AgentInput
135
+
136
+ # Resolve session
137
+ sid = await self._resolve_session(msg)
138
+
139
+ llm = get_llm()
140
+ registry = get_tool_registry()
141
+
142
+ # Register RAG search tool if not present
143
+ from agentic_rag.orchestration.l1_tools.rag_tools import RAGSearchTool
144
+ try:
145
+ registry.get("rag_search")
146
+ except Exception:
147
+ registry.register(RAGSearchTool())
148
+
149
+ router = AgentRouter(llm, registry)
150
+ engine = await router.route(query=msg.text)
151
+
152
+ input_data = AgentInput(
153
+ query=msg.text,
154
+ parameters={"session_id": sid, "platform": msg.platform},
155
+ )
156
+
157
+ return await engine.run(input_data, turn_id=uuid.uuid4().hex)
158
+
159
+ async def _resolve_session(self, msg: PlatformMessage) -> str:
160
+ """Map (platform, sender_id, chat_id) → internal session_id."""
161
+ from agentic_rag.entrypoints.gateway.session import get_platform_session_map
162
+ session_map = get_platform_session_map()
163
+ return session_map.get_or_create(
164
+ platform=msg.platform,
165
+ user_id=msg.sender_id,
166
+ chat_id=msg.chat_id,
167
+ )
168
+
169
+ async def _process_async(self, msg: PlatformMessage) -> None:
170
+ """Background: run agent and push result to platform."""
171
+ try:
172
+ output = await self._run_agent(msg)
173
+ presp = await self.format_response(output, msg)
174
+ await self.push_message(msg, presp.content)
175
+ except Exception:
176
+ import sys
177
+ print(f" [Gateway/{self.platform_name}] ⚠ Async process failed", flush=True)
178
+ sys.stdout.flush()
179
+
180
+ def _empty_ack(self) -> str:
181
+ return ""
182
+
183
+ # ── Content helpers ──────────────────────────────────────
184
+
185
+ def _chunk_text(self, text: str, max_len: int | None = None) -> list[str]:
186
+ """Split long text into platform-friendly chunks."""
187
+ if max_len is None:
188
+ try:
189
+ from agentic_rag.config.settings import get_settings
190
+ max_len = get_settings().gateway.max_reply_length
191
+ except Exception:
192
+ max_len = 2000
193
+ chunks = []
194
+ while len(text) > max_len:
195
+ split_at = text.rfind("\n", 0, max_len)
196
+ if split_at < max_len // 2:
197
+ split_at = text.rfind("。", 0, max_len)
198
+ if split_at < max_len // 2:
199
+ split_at = text.rfind(". ", 0, max_len)
200
+ if split_at < max_len // 2:
201
+ split_at = max_len
202
+ chunks.append(text[: split_at + 1])
203
+ text = text[split_at + 1 :].lstrip()
204
+ if text.strip():
205
+ chunks.append(text)
206
+ return chunks
agentic_rag/entrypoints/gateway/dingtalk/__init__.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """钉钉 (DingTalk) bot gateway adaptor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import time
10
+ from typing import Optional
11
+
12
+ import httpx
13
+ from fastapi import APIRouter, Request, Response
14
+
15
+ from agentic_rag.entrypoints.gateway.base import (
16
+ BasePlatformAdaptor,
17
+ PlatformMessage,
18
+ PlatformResponse,
19
+ )
20
+
21
+ router = APIRouter()
22
+
23
+
24
+ # ═══════════════════════════════════════════════════════════════
25
+ # DingTalk Adaptor
26
+ # ═══════════════════════════════════════════════════════════════
27
+
28
+ class DingTalkAdaptor(BasePlatformAdaptor):
29
+ """钉钉 bot webhook adaptor.
30
+
31
+ Reference: https://open.dingtalk.com/document/orgapp/receive-messages
32
+ """
33
+
34
+ platform_name = "dingtalk"
35
+
36
+ # ── Verify ────────────────────────────────────────────────
37
+
38
+ async def verify_request(self, request: Request) -> bool:
39
+ """Verify DingTalk HMAC-SHA256 signature."""
40
+ timestamp = request.headers.get("timestamp", "")
41
+ sign = request.headers.get("sign", "")
42
+ if not timestamp or not sign:
43
+ return False
44
+
45
+ secret = getattr(self.config, "app_secret", "")
46
+ if not secret:
47
+ return True # No secret configured — skip verification
48
+
49
+ expected = self._hmac_sign(timestamp, secret)
50
+ return sign == expected
51
+
52
+ @staticmethod
53
+ def _hmac_sign(timestamp: str, secret: str) -> str:
54
+ """Compute DingTalk HMAC-SHA256 signature."""
55
+ raw = f"{timestamp}\n{secret}"
56
+ mac = hmac.new(
57
+ secret.encode("utf-8"),
58
+ raw.encode("utf-8"),
59
+ hashlib.sha256,
60
+ )
61
+ return base64.b64encode(mac.digest()).decode()
62
+
63
+ # ── Parse ─────────────────────────────────────────────────
64
+
65
+ async def parse_message(self, request: Request) -> PlatformMessage:
66
+ """Parse DingTalk robot callback JSON body."""
67
+ try:
68
+ body = await request.json()
69
+ except Exception:
70
+ return PlatformMessage(platform="dingtalk", sender_id="", msg_type="unknown")
71
+
72
+ msg_type = body.get("msgtype", "unknown")
73
+ sender_id = body.get("senderStaffId", body.get("senderId", ""))
74
+ sender_name = body.get("senderNick", "")
75
+ conversation_id = body.get("conversationId", "")
76
+ chat_type = body.get("conversationType", "1") # 1=single, 2=group
77
+ session_webhook = body.get("sessionWebhook", "")
78
+
79
+ # Extract text
80
+ text = ""
81
+ if msg_type == "text":
82
+ text = body.get("text", {}).get("content", "")
83
+ elif msg_type == "image":
84
+ text = "[图片消息]"
85
+
86
+ return PlatformMessage(
87
+ platform="dingtalk",
88
+ sender_id=str(sender_id),
89
+ sender_name=sender_name,
90
+ chat_id=conversation_id,
91
+ chat_type="group" if str(chat_type) == "2" else "single",
92
+ text=text,
93
+ msg_type=msg_type,
94
+ raw_payload=body,
95
+ reply_token=session_webhook,
96
+ )
97
+
98
+ # ── Format Response ───────────────────────────────────────
99
+
100
+ async def format_response(
101
+ self, output, msg: PlatformMessage
102
+ ) -> PlatformResponse:
103
+ """Convert agent output to DingTalk markdown."""
104
+ from agentic_rag.config.settings import get_settings
105
+ max_len = get_settings().gateway.max_reply_length
106
+
107
+ answer = output.final_answer or "抱歉,我暂时无法回答这个问题。"
108
+
109
+ # Truncate if too long
110
+ if len(answer) > max_len:
111
+ chunks = self._chunk_text(answer, max_len)
112
+ # Return first chunk; rest sent via push if needed
113
+ answer = chunks[0]
114
+ if len(chunks) > 1:
115
+ answer += f"\n\n...(共 {len(chunks)} 段,第 1 段)"
116
+
117
+ return PlatformResponse(
118
+ content=answer,
119
+ msg_type="text",
120
+ )
121
+
122
+ # ── HTTP Response ─────────────────────────────────────────
123
+
124
+ async def _build_http_response(self, presp: PlatformResponse) -> Response:
125
+ """Build DingTalk-compatible JSON response."""
126
+ return Response(
127
+ content=json.dumps(
128
+ {"msgtype": "text", "text": {"content": presp.content}},
129
+ ensure_ascii=False,
130
+ ),
131
+ media_type="application/json",
132
+ status_code=presp.status_code,
133
+ )
134
+
135
+ # ── Push (async mode) ─────────────────────────────────────
136
+
137
+ async def push_message(self, msg: PlatformMessage, text: str) -> None:
138
+ """Send message back via DingTalk sessionWebhook."""
139
+ webhook_url = msg.reply_token
140
+ if not webhook_url:
141
+ return
142
+
143
+ try:
144
+ async with httpx.AsyncClient(timeout=10) as client:
145
+ await client.post(
146
+ webhook_url,
147
+ json={"msgtype": "text", "text": {"content": text}},
148
+ )
149
+ except Exception:
150
+ import sys
151
+ print(f" [DingTalk] ⚠ Push to webhook failed", flush=True)
152
+ sys.stdout.flush()
153
+
154
+
155
+ # ── Global adaptor instance (lazy) ──────────────────────────────
156
+
157
+ _adaptor: Optional[DingTalkAdaptor] = None
158
+
159
+
160
+ def _get_adaptor() -> DingTalkAdaptor:
161
+ global _adaptor
162
+ if _adaptor is None:
163
+ from agentic_rag.config.settings import get_settings
164
+ _adaptor = DingTalkAdaptor(get_settings().gateway.dingtalk)
165
+ return _adaptor
166
+
167
+
168
+ # ═══════════════════════════════════════════════════════════════
169
+ # Webhook Endpoints
170
+ # ═══════════════════════════════════════════════════════════════
171
+
172
+ @router.post("/gateway/dingtalk")
173
+ async def dingtalk_callback(request: Request):
174
+ """Receive DingTalk robot callback messages.
175
+
176
+ DingTalk POSTs JSON with headers:
177
+ - timestamp: Unix timestamp string
178
+ - sign: HMAC-SHA256(timestamp + "\\n" + app_secret)
179
+
180
+ Body (text message):
181
+ {
182
+ "conversationId": "...",
183
+ "senderId": "...",
184
+ "senderNick": "张三",
185
+ "msgtype": "text",
186
+ "text": {"content": "hello"},
187
+ "sessionWebhook": "https://oapi.dingtalk.com/robot/..."
188
+ }
189
+ """
190
+ adaptor = _get_adaptor()
191
+ return await adaptor.process(request)
agentic_rag/entrypoints/gateway/qqbot/__init__.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """QQ Bot (官方) gateway adaptor.
2
+
3
+ QQ Bot 使用 WebSocket 接收事件 + REST API 发送消息,不需要公网 webhook。
4
+
5
+ 沙箱注册: https://q.qq.com
6
+ 文档: https://bot.q.qq.com/wiki
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import uuid
14
+ from typing import Optional
15
+
16
+ import httpx
17
+ import websockets
18
+ from fastapi import APIRouter
19
+
20
+ from agentic_rag.entrypoints.gateway.session import get_platform_session_map
21
+
22
+ router = APIRouter()
23
+
24
+ # QQ Bot 事件类型
25
+ EVENT_C2C_MESSAGE = "C2C_MESSAGE_CREATE" # 单聊消息
26
+ EVENT_GROUP_AT = "GROUP_AT_MESSAGE_CREATE" # 群聊 @消息
27
+ EVENT_READY = "READY" # 连接就绪
28
+ OPCODE_DISPATCH = 0 # 服务端推送事件
29
+ OPCODE_HEARTBEAT = 1 # 心跳
30
+ OPCODE_IDENTIFY = 2 # 鉴权
31
+ OPCODE_RECONNECT = 7 # 服务端要求重连
32
+ OPCODE_HELLO = 10 # 服务端下发心跳周期
33
+
34
+
35
+ # ═══════════════════════════════════════════════════════════════
36
+ # QQ Bot Client
37
+ # ═══════════════════════════════════════════════════════════════
38
+
39
+ class QQBotClient:
40
+ """QQ Bot WebSocket 客户端。
41
+
42
+ 在后台运行,维护与 QQ 服务器的长连接,接收消息并调用 Agent。
43
+
44
+ 使用方式:
45
+ client = QQBotClient(config)
46
+ asyncio.create_task(client.run())
47
+ """
48
+
49
+ def __init__(self, config) -> None:
50
+ self.config = config
51
+ self._ws_url = (
52
+ "wss://sandbox.api.sgroup.qq.com/websocket"
53
+ if config.sandbox
54
+ else "wss://api.sgroup.qq.com/websocket"
55
+ )
56
+ self._http_base = (
57
+ "https://sandbox.api.sgroup.qq.com"
58
+ if config.sandbox
59
+ else "https://api.sgroup.qq.com"
60
+ )
61
+ self._app_id = config.app_id
62
+ self._app_secret = config.app_secret
63
+ self._access_token: str = ""
64
+ self._session_id: str = ""
65
+ self._seq: int = 0
66
+ self._running = False
67
+
68
+ # ── Lifecycle ──────────────────────────────────────────────
69
+
70
+ async def run(self) -> None:
71
+ """主循环: 连接 → 处理事件 → 断线重连"""
72
+ self._running = True
73
+ backoff = 1
74
+
75
+ while self._running:
76
+ try:
77
+ await self._connect()
78
+ backoff = 1 # 成功连接后重置退避
79
+ except Exception as e:
80
+ import sys
81
+ print(f" [QQBot] ⚠ Connection lost: {e} (retry in {backoff}s)", flush=True)
82
+ sys.stdout.flush()
83
+ await asyncio.sleep(backoff)
84
+ backoff = min(backoff * 2, 60)
85
+
86
+ async def stop(self) -> None:
87
+ """停止客户端"""
88
+ self._running = False
89
+
90
+ # ── Connection ─────────────────────────────────────────────
91
+
92
+ async def _connect(self) -> None:
93
+ """建立 WebSocket 连接并进入事件循环"""
94
+ if not await self._get_token():
95
+ return
96
+
97
+ print(f" [QQBot] Connecting to {self._ws_url}...", flush=True)
98
+
99
+ async with websockets.connect(self._ws_url, ping_interval=None) as ws:
100
+ # 等待服务端 Hello
101
+ hello = json.loads(await ws.recv())
102
+ if hello.get("op") != OPCODE_HELLO:
103
+ raise RuntimeError(f"Expected Hello, got op={hello.get('op')}")
104
+
105
+ heartbeat_interval = hello["d"]["heartbeat_interval"] # ms
106
+ print(f" [QQBot] Connected, heartbeat={heartbeat_interval}ms", flush=True)
107
+
108
+ # 发送鉴权
109
+ await self._identify(ws)
110
+
111
+ # 等待 Ready
112
+ await self._wait_ready(ws)
113
+
114
+ # 启动心跳
115
+ heartbeat_task = asyncio.create_task(
116
+ self._heartbeat_loop(ws, heartbeat_interval)
117
+ )
118
+
119
+ try:
120
+ # 事件循环
121
+ await self._event_loop(ws)
122
+ finally:
123
+ heartbeat_task.cancel()
124
+
125
+ async def _identify(self, ws) -> None:
126
+ """发送鉴权帧"""
127
+ payload = {
128
+ "op": OPCODE_IDENTIFY,
129
+ "d": {
130
+ "token": f"QQBot {self._access_token}",
131
+ "intents": (1 << 25) | (1 << 0), # C2C | GUILDS (群@)
132
+ "shard": [0, 1],
133
+ "properties": {},
134
+ },
135
+ }
136
+ await ws.send(json.dumps(payload))
137
+ print(" [QQBot] Identify sent", flush=True)
138
+
139
+ async def _wait_ready(self, ws) -> None:
140
+ """等待 Ready 事件(含 session_id)"""
141
+ while True:
142
+ raw = await ws.recv()
143
+ msg = json.loads(raw)
144
+ if msg.get("t") == EVENT_READY:
145
+ self._session_id = msg["d"].get("session_id", "")
146
+ user = msg["d"].get("user", {})
147
+ print(
148
+ f" [QQBot] READY — bot: {user.get('username', '?')}#{user.get('id', '?')}",
149
+ flush=True,
150
+ )
151
+ return
152
+
153
+ # ── Event Loop ─────────────────────────────────────────────
154
+
155
+ async def _event_loop(self, ws) -> None:
156
+ """接收并处理事件"""
157
+ async for raw in ws:
158
+ msg = json.loads(raw)
159
+ op = msg.get("op")
160
+ self._seq = msg.get("s", self._seq)
161
+
162
+ if op == OPCODE_RECONNECT:
163
+ print(" [QQBot] Server requested reconnect", flush=True)
164
+ return
165
+
166
+ if op == OPCODE_DISPATCH:
167
+ event_type = msg.get("t", "")
168
+ event_data = msg.get("d", {})
169
+ asyncio.create_task(self._handle_event(event_type, event_data))
170
+
171
+ async def _heartbeat_loop(self, ws, interval_ms: int) -> None:
172
+ """定时发送心跳"""
173
+ interval_s = interval_ms / 1000.0 * 0.8 # 留点余量
174
+ while True:
175
+ await asyncio.sleep(interval_s)
176
+ try:
177
+ await ws.send(json.dumps({"op": OPCODE_HEARTBEAT, "d": self._seq}))
178
+ except Exception:
179
+ break
180
+
181
+ # ── Token ──────────────────────────────────────────────────
182
+
183
+ async def _get_token(self) -> bool:
184
+ """获取 access_token"""
185
+ try:
186
+ async with httpx.AsyncClient(timeout=10) as client:
187
+ resp = await client.post(
188
+ "https://bots.qq.com/app/getAppAccessToken",
189
+ json={
190
+ "appId": self._app_id,
191
+ "clientSecret": self._app_secret,
192
+ },
193
+ )
194
+ data = resp.json()
195
+ self._access_token = data.get("access_token", "")
196
+ if self._access_token:
197
+ print(f" [QQBot] Token OK ({len(self._access_token)} chars)", flush=True)
198
+ return True
199
+ print(f" [QQBot] Token failed: {data}", flush=True)
200
+ return False
201
+ except Exception as e:
202
+ print(f" [QQBot] Token error: {e}", flush=True)
203
+ return False
204
+
205
+ # ── Event Handling ─────────────────────────────────────────
206
+
207
+ async def _handle_event(self, event_type: str, data: dict) -> None:
208
+ """处理 QQ 事件"""
209
+ if event_type == EVENT_C2C_MESSAGE:
210
+ await self._on_private_message(data)
211
+ elif event_type == EVENT_GROUP_AT:
212
+ await self._on_group_at(data)
213
+
214
+ async def _on_private_message(self, data: dict) -> None:
215
+ """处理单聊消息"""
216
+ author = data.get("author", {})
217
+ user_id = author.get("id", "")
218
+ content = data.get("content", "").strip()
219
+ msg_id = data.get("id", "")
220
+
221
+ if not content or not user_id:
222
+ return
223
+
224
+ # 去掉命令前缀 "/" 和 @ 的干扰
225
+ content = content.lstrip("/").strip()
226
+
227
+ print(f" [QQBot] 📩 C2C: {user_id} → {content[:50]}", flush=True)
228
+ await self._process_message(user_id, f"qq:{user_id}", content, msg_id)
229
+
230
+ async def _on_group_at(self, data: dict) -> None:
231
+ """处理群聊 @消息"""
232
+ author = data.get("author", {})
233
+ user_id = author.get("id", "")
234
+ group_id = data.get("group_openid", data.get("group_id", ""))
235
+ content = data.get("content", "").strip()
236
+ msg_id = data.get("id", "")
237
+
238
+ if not content or not user_id:
239
+ return
240
+
241
+ # 内容中可能包含 @机器人 的 mention,去掉
242
+ # QQ 消息格式可能是: "<@!bot_id> 内容" 或直接是纯文本
243
+ import re
244
+ content = re.sub(r"<@!\d+>", "", content).strip()
245
+
246
+ if not content:
247
+ return
248
+
249
+ print(f" [QQBot] 📩 Group@: {user_id} in {group_id} → {content[:50]}", flush=True)
250
+ await self._process_message(user_id, f"qq:group:{group_id}:{user_id}", content, msg_id)
251
+
252
+ async def _process_message(
253
+ self, user_id: str, session_key: str, text: str, msg_id: str = ""
254
+ ) -> None:
255
+ """调用 Agent Engine 并回复"""
256
+ try:
257
+ # 解析 session
258
+ session_map = get_platform_session_map()
259
+ sid = session_map.get_or_create(platform="qqbot", user_id=session_key)
260
+
261
+ # 调用 Agent
262
+ output = await self._run_agent(sid, text)
263
+
264
+ # 回复
265
+ answer = output.final_answer or "抱歉,我暂时无法回答。"
266
+ # 限制长度
267
+ if len(answer) > 2000:
268
+ answer = answer[:1950] + "\n\n...(内容过长已截断)"
269
+
270
+ await self._send_reply(user_id, answer, msg_id)
271
+ except Exception as e:
272
+ import sys
273
+ print(f" [QQBot] ⚠ Process error: {e}", flush=True)
274
+ sys.stdout.flush()
275
+ await self._send_reply(user_id, "处理消息时出错了,请稍后重试。", msg_id)
276
+
277
+ async def _run_agent(self, session_id: str, query: str):
278
+ """调用 ReAct Agent Engine"""
279
+ from agentic_rag.services.llm.factory import get_llm
280
+ from agentic_rag.orchestration.l1_tools.registry import get_tool_registry
281
+ from agentic_rag.agent.router import AgentRouter
282
+ from agentic_rag.data.models import AgentInput
283
+
284
+ llm = get_llm()
285
+ registry = get_tool_registry()
286
+
287
+ from agentic_rag.orchestration.l1_tools.rag_tools import RAGSearchTool
288
+ try:
289
+ registry.get("rag_search")
290
+ except Exception:
291
+ registry.register(RAGSearchTool())
292
+
293
+ router = AgentRouter(llm, registry)
294
+ engine = await router.route(query=query)
295
+
296
+ input_data = AgentInput(
297
+ query=query,
298
+ parameters={"session_id": session_id, "platform": "qqbot"},
299
+ )
300
+
301
+ return await engine.run(input_data, turn_id=uuid.uuid4().hex)
302
+
303
+ async def _send_reply(self, user_id: str, text: str, msg_id: str = "") -> None:
304
+ """通过 REST API 发送回复"""
305
+ try:
306
+ async with httpx.AsyncClient(timeout=10) as client:
307
+ resp = await client.post(
308
+ f"{self._http_base}/v2/users/{user_id}/messages",
309
+ headers={
310
+ "Authorization": f"QQBot {self._access_token}",
311
+ "Content-Type": "application/json",
312
+ },
313
+ json={
314
+ "content": text,
315
+ "msg_type": 0, # 文本消息
316
+ "msg_id": msg_id,
317
+ },
318
+ )
319
+ if resp.status_code != 200:
320
+ print(f" [QQBot] ⚠ Reply failed ({resp.status_code}): {resp.text[:100]}", flush=True)
321
+ except Exception as e:
322
+ print(f" [QQBot] ⚠ Reply error: {e}", flush=True)
323
+
324
+
325
+ # ═══════════════════════════════════════════════════════════════
326
+ # Global client instance
327
+ # ═══════════════════════════════════════════════════════════════
328
+
329
+ _client: Optional[QQBotClient] = None
330
+
331
+
332
+ async def start_qqbot() -> None:
333
+ """在 app 启动时启动 QQ Bot 客户端"""
334
+ from agentic_rag.config.settings import get_settings
335
+
336
+ settings = get_settings()
337
+ cfg = settings.gateway.qqbot
338
+ if not cfg.enabled:
339
+ return
340
+
341
+ global _client
342
+ if _client is not None:
343
+ return
344
+
345
+ _client = QQBotClient(cfg)
346
+ asyncio.create_task(_client.run())
347
+ print(f" [QQBot] Started {'(sandbox)' if cfg.sandbox else '(production)'}", flush=True)
348
+
349
+
350
+ async def stop_qqbot() -> None:
351
+ """在 app 关闭时停止 QQ Bot"""
352
+ global _client
353
+ if _client:
354
+ await _client.stop()
355
+ _client = None
356
+
357
+
358
+ # ═══════════════════════════════════════════════════════════════
359
+ # Status endpoint
360
+ # ═══════════════════════════════════════════════════════════════
361
+
362
+ @router.get("/gateway/qqbot/status")
363
+ async def qqbot_status():
364
+ """查看 QQ Bot 连接状态"""
365
+ global _client
366
+ if _client is None:
367
+ return {"status": "disabled"}
368
+ return {
369
+ "status": "running" if _client._running else "stopped",
370
+ "session_id": _client._session_id,
371
+ "sandbox": _client.config.sandbox,
372
+ }
agentic_rag/entrypoints/gateway/router.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Aggregated FastAPI router that mounts all enabled platform webhook routers."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+
6
+ def get_gateway_router() -> APIRouter:
7
+ """Build a single APIRouter that mounts all enabled platform webhook routers."""
8
+ from agentic_rag.config.settings import get_settings
9
+
10
+ settings = get_settings()
11
+ gw = settings.gateway
12
+
13
+ if not gw.enabled:
14
+ return APIRouter()
15
+
16
+ router = APIRouter(prefix="", tags=["Gateway"])
17
+
18
+ if gw.wechat_work.enabled:
19
+ from agentic_rag.entrypoints.gateway.wechat_work import router as wx_router
20
+ router.include_router(wx_router)
21
+
22
+ if gw.dingtalk.enabled:
23
+ from agentic_rag.entrypoints.gateway.dingtalk import router as dd_router
24
+ router.include_router(dd_router)
25
+
26
+ if gw.qqbot.enabled:
27
+ from agentic_rag.entrypoints.gateway.qqbot import router as qq_router
28
+ router.include_router(qq_router)
29
+
30
+ return router
agentic_rag/entrypoints/gateway/session.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Platform session mapping — binds (platform, user_id, chat_id) → internal session_id.
2
+
3
+ Uses an in-memory LRU cache for hot-path lookups, backed by SQLite for persistence.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections import OrderedDict
9
+ from typing import Optional
10
+
11
+ from agentic_rag.data.db.session_repo import SessionRepo
12
+
13
+
14
+ class PlatformSessionMap:
15
+ """Maps (platform, platform_user_id, chat_id) → internal session_id."""
16
+
17
+ def __init__(self, max_cache_size: int = 1000) -> None:
18
+ self._cache: OrderedDict[tuple[str, str, str], str] = OrderedDict()
19
+ self._max_cache = max_cache_size
20
+
21
+ # ── Public API ──────────────────────────────────────────
22
+
23
+ def get_or_create(self, platform: str, user_id: str, chat_id: str = "") -> str:
24
+ """Look up an existing session or create a new one."""
25
+ cache_key = (platform, user_id, chat_id)
26
+
27
+ # 1. Cache hit
28
+ if cache_key in self._cache:
29
+ self._cache.move_to_end(cache_key)
30
+ return self._cache[cache_key]
31
+
32
+ # 2. DB lookup
33
+ sid = self._db_get(platform, user_id, chat_id)
34
+ if sid:
35
+ self._set_cache(cache_key, sid)
36
+ return sid
37
+
38
+ # 3. Create new session
39
+ repo = SessionRepo()
40
+ display_name = f"{platform}:{user_id}"
41
+ if chat_id:
42
+ display_name += f":{chat_id}"
43
+ session = repo.create(user_id=display_name)
44
+ sid = session["id"]
45
+ self._db_set(platform, user_id, chat_id, sid)
46
+
47
+ self._set_cache(cache_key, sid)
48
+ return sid
49
+
50
+ def invalidate(self, platform: str, user_id: str, chat_id: str = "") -> None:
51
+ """Remove a cached mapping (on session deletion, etc.)."""
52
+ cache_key = (platform, user_id, chat_id)
53
+ self._cache.pop(cache_key, None)
54
+
55
+ # ── Internal cache helpers ───────────────────────────────
56
+
57
+ def _set_cache(self, key: tuple[str, str, str], sid: str) -> None:
58
+ if len(self._cache) >= self._max_cache:
59
+ self._cache.popitem(last=False) # evict oldest
60
+ self._cache[key] = sid
61
+
62
+ # ── DB helpers (delegated to SessionRepo) ────────────────
63
+
64
+ @staticmethod
65
+ def _db_get(platform: str, user_id: str, chat_id: str) -> Optional[str]:
66
+ try:
67
+ return SessionRepo().get_platform_session(platform, user_id, chat_id)
68
+ except Exception:
69
+ return None
70
+
71
+ @staticmethod
72
+ def _db_set(platform: str, user_id: str, chat_id: str, session_id: str) -> None:
73
+ try:
74
+ SessionRepo().bind_platform_session(platform, user_id, chat_id, session_id)
75
+ except Exception:
76
+ pass
77
+
78
+
79
+ # ── Singleton ──────────────────────────────────────────────────
80
+
81
+ _platform_session_map: Optional[PlatformSessionMap] = None
82
+
83
+
84
+ def get_platform_session_map() -> PlatformSessionMap:
85
+ """Get the global PlatformSessionMap singleton."""
86
+ global _platform_session_map
87
+ if _platform_session_map is None:
88
+ _platform_session_map = PlatformSessionMap()
89
+ return _platform_session_map
agentic_rag/entrypoints/gateway/wechat_work/__init__.py ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """企业微信 (WeChat Work) 自建应用 gateway adaptor.
2
+
3
+ Reference: https://developer.work.weixin.qq.com/document/path/90238
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import time as time_module
10
+ import xml.etree.ElementTree as ET
11
+ from typing import Optional
12
+
13
+ import httpx
14
+ from fastapi import APIRouter, Query, Request, Response
15
+
16
+ from agentic_rag.entrypoints.gateway.base import (
17
+ BasePlatformAdaptor,
18
+ PlatformMessage,
19
+ PlatformResponse,
20
+ )
21
+
22
+ router = APIRouter()
23
+
24
+
25
+ # ═══════════════════════════════════════════════════════════════
26
+ # Token Manager (for active push)
27
+ # ═══════════════════════════════════════════════════════════════
28
+
29
+ class WeChatTokenManager:
30
+ """Cache and refresh WeChat Work access_token.
31
+
32
+ Token expires in 7200s; we refresh at half-life.
33
+ Ref: https://developer.work.weixin.qq.com/document/path/91039
34
+ """
35
+
36
+ def __init__(self, corp_id: str, secret: str) -> None:
37
+ self._corp_id = corp_id
38
+ self._secret = secret
39
+ self._token: str = ""
40
+ self._expires_at: float = 0.0
41
+
42
+ async def get_token(self) -> str:
43
+ """Get a valid access_token, refreshing if needed."""
44
+ if self._token and time_module.time() < self._expires_at - 300:
45
+ return self._token
46
+
47
+ try:
48
+ async with httpx.AsyncClient(timeout=10) as client:
49
+ resp = await client.get(
50
+ "https://qyapi.weixin.qq.com/cgi-bin/gettoken",
51
+ params={
52
+ "corpid": self._corp_id,
53
+ "corpsecret": self._secret,
54
+ },
55
+ )
56
+ data = resp.json()
57
+ if data.get("errcode") == 0:
58
+ self._token = data["access_token"]
59
+ self._expires_at = time_module.time() + data.get("expires_in", 7200)
60
+ return self._token
61
+ except Exception:
62
+ pass
63
+ return self._token
64
+
65
+
66
+ # ═══════════════════════════════════════════════════════════════
67
+ # Adaptor
68
+ # ═══════════════════════════════════════════════════════════════
69
+
70
+ class WeChatWorkAdaptor(BasePlatformAdaptor):
71
+ """企业微信自建应用 adaptor."""
72
+
73
+ platform_name = "wechat_work"
74
+
75
+ def __init__(self, config) -> None:
76
+ super().__init__(config)
77
+ from agentic_rag.entrypoints.gateway.wechat_work.crypto import WeChatCrypto
78
+ self._crypto = WeChatCrypto(
79
+ token=config.token,
80
+ encoding_aes_key=config.encoding_aes_key,
81
+ corp_id=config.corp_id,
82
+ )
83
+ self._token_mgr: Optional[WeChatTokenManager] = None
84
+ if config.secret:
85
+ self._token_mgr = WeChatTokenManager(config.corp_id, config.secret)
86
+
87
+ # ── URL Verification (GET) ────────────────────────────────
88
+
89
+ async def verify_url(
90
+ self, msg_signature: str, timestamp: str, nonce: str, echostr: str
91
+ ) -> str:
92
+ """Handle URL verification challenge from WeChat Work.
93
+
94
+ Returns the decrypted echostr string (plain text), or raises ValueError.
95
+ """
96
+ if not self._crypto.verify_signature(msg_signature, timestamp, nonce, echostr):
97
+ raise ValueError("Signature verification failed")
98
+ return self._crypto.decrypt(echostr)
99
+
100
+ # ── Verify (POST) ─────────────────────────────────────────
101
+
102
+ async def verify_request(self, request: Request) -> bool:
103
+ """Verify WeChat Work callback signature."""
104
+ msg_signature = request.query_params.get("msg_signature", "")
105
+ timestamp = request.query_params.get("timestamp", "")
106
+ nonce = request.query_params.get("nonce", "")
107
+
108
+ if not msg_signature or not timestamp or not nonce:
109
+ return False
110
+
111
+ # Read body and verify; body is encrypted XML
112
+ try:
113
+ body = (await request.body()).decode("utf-8")
114
+ except Exception:
115
+ return False
116
+
117
+ # Extract <Encrypt> from XML for signature
118
+ try:
119
+ root = ET.fromstring(body)
120
+ encrypt_text = root.findtext("Encrypt", "")
121
+ except Exception:
122
+ encrypt_text = body
123
+
124
+ return self._crypto.verify_signature(
125
+ msg_signature, timestamp, nonce, encrypt_text
126
+ )
127
+
128
+ # ── Parse ─────────────────────────────��───────────────────
129
+
130
+ async def parse_message(self, request: Request) -> PlatformMessage:
131
+ """Decrypt and parse WeChat Work callback XML."""
132
+ msg_signature = request.query_params.get("msg_signature", "")
133
+ timestamp = request.query_params.get("timestamp", "")
134
+ nonce = request.query_params.get("nonce", "")
135
+
136
+ body = (await request.body()).decode("utf-8")
137
+
138
+ # Extract and decrypt
139
+ try:
140
+ root = ET.fromstring(body)
141
+ encrypt_text = root.findtext("Encrypt", "")
142
+ except ET.ParseError:
143
+ return PlatformMessage(platform="wechat_work", sender_id="", msg_type="unknown")
144
+
145
+ if not encrypt_text:
146
+ return PlatformMessage(platform="wechat_work", sender_id="", msg_type="unknown")
147
+
148
+ plain_xml = self._crypto.decrypt(encrypt_text)
149
+
150
+ # Parse decrypted XML
151
+ try:
152
+ msg_root = ET.fromstring(plain_xml)
153
+ except ET.ParseError:
154
+ return PlatformMessage(platform="wechat_work", sender_id="", msg_type="unknown")
155
+
156
+ msg_type = msg_root.findtext("MsgType", "unknown")
157
+ sender_id = msg_root.findtext("FromUserName", "")
158
+ agent_id = msg_root.findtext("AgentID", "")
159
+ create_time = msg_root.findtext("CreateTime", "")
160
+
161
+ text = ""
162
+ if msg_type == "text":
163
+ text = msg_root.findtext("Content", "")
164
+ elif msg_type == "image":
165
+ text = "[图片消息]"
166
+ elif msg_type == "voice":
167
+ recognition = msg_root.findtext("Recognition", "") # 语音识别结果
168
+ text = recognition or "[语音消息]"
169
+ elif msg_type == "event":
170
+ event_type = msg_root.findtext("Event", "")
171
+ if event_type == "click":
172
+ event_key = msg_root.findtext("EventKey", "")
173
+ text = event_key # 菜单点击
174
+ elif event_type == "subscribe":
175
+ text = "hello" # 关注事件 → 触发欢迎语
176
+
177
+ return PlatformMessage(
178
+ platform="wechat_work",
179
+ sender_id=sender_id,
180
+ sender_name="",
181
+ chat_id=sender_id, # WeChat Work 单聊; 群聊场景有 ChatId
182
+ chat_type="single",
183
+ text=text,
184
+ msg_type=msg_type,
185
+ raw_payload={
186
+ "agent_id": agent_id,
187
+ "create_time": create_time,
188
+ "msg_type": msg_type,
189
+ "plain_xml": plain_xml,
190
+ },
191
+ reply_token=json.dumps({
192
+ "to_user": sender_id,
193
+ "agent_id": agent_id,
194
+ }),
195
+ )
196
+
197
+ # ── Format ────────────────────────────────────────────────
198
+
199
+ async def format_response(
200
+ self, output, msg: PlatformMessage
201
+ ) -> PlatformResponse:
202
+ """Convert agent output to WeChat Work text response."""
203
+ from agentic_rag.config.settings import get_settings
204
+ max_len = get_settings().gateway.max_reply_length
205
+
206
+ answer = output.final_answer or "抱歉,我暂时无法回答这个问题。"
207
+
208
+ # WeChat Work text limit is 2048 chars
209
+ effective_max = min(max_len, 2048)
210
+ if len(answer) > effective_max:
211
+ chunks = self._chunk_text(answer, effective_max)
212
+ answer = chunks[0]
213
+ if len(chunks) > 1:
214
+ answer += f"\n\n...(共 {len(chunks)} 段,第 1 段)"
215
+
216
+ # Remove markdown that WeChat Work doesn't support
217
+ answer = self._strip_markdown(answer)
218
+
219
+ return PlatformResponse(
220
+ content=answer,
221
+ msg_type="text",
222
+ extra=msg.raw_payload,
223
+ )
224
+
225
+ # ── Build HTTP Response (encrypted XML) ──────────────────
226
+
227
+ async def _build_http_response(self, presp: PlatformResponse) -> Response:
228
+ """Build encrypted XML response for WeChat Work."""
229
+ to_user = presp.extra.get("to_user", "")
230
+ agent_id = presp.extra.get("agent_id", "")
231
+ agent_id_from_cfg = getattr(self.config, "agent_id", "")
232
+
233
+ # Build plain text reply XML
234
+ create_time = str(int(time_module.time()))
235
+ reply_xml = (
236
+ "<xml>"
237
+ f"<ToUserName><![CDATA[{to_user}]]></ToUserName>"
238
+ f"<FromUserName><![CDATA[{agent_id or agent_id_from_cfg}]]></FromUserName>"
239
+ f"<CreateTime>{create_time}</CreateTime>"
240
+ "<MsgType><![CDATA[text]]></MsgType>"
241
+ f"<Content><![CDATA[{presp.content}]]></Content>"
242
+ "</xml>"
243
+ )
244
+
245
+ # Encrypt
246
+ encrypted = self._crypto.encrypt(reply_xml)
247
+ sig, ts, nonce = self._crypto.build_response_signature(encrypted)
248
+
249
+ # Wrap in encrypted XML envelope
250
+ response_xml = (
251
+ "<xml>"
252
+ f"<Encrypt><![CDATA[{encrypted}]]></Encrypt>"
253
+ f"<MsgSignature><![CDATA[{sig}]]></MsgSignature>"
254
+ f"<TimeStamp>{ts}</TimeStamp>"
255
+ f"<Nonce><![CDATA[{nonce}]]></Nonce>"
256
+ "</xml>"
257
+ )
258
+
259
+ return Response(content=response_xml, media_type="application/xml")
260
+
261
+ # ── Push (async mode via WeChat API) ──────────────────────
262
+
263
+ async def push_message(self, msg: PlatformMessage, text: str) -> None:
264
+ """Send message via WeChat Work API (POST /cgi-bin/message/send)."""
265
+ if not self._token_mgr:
266
+ return
267
+
268
+ to_user = msg.sender_id
269
+ agent_id = getattr(self.config, "agent_id", "")
270
+ token = await self._token_mgr.get_token()
271
+ if not token:
272
+ return
273
+
274
+ text = self._strip_markdown(text)
275
+
276
+ body = {
277
+ "touser": to_user,
278
+ "msgtype": "text",
279
+ "agentid": int(agent_id) if agent_id.isdigit() else agent_id,
280
+ "text": {"content": text},
281
+ }
282
+
283
+ try:
284
+ async with httpx.AsyncClient(timeout=10) as client:
285
+ await client.post(
286
+ f"https://qyapi.weixin.qq.com/cgi-bin/message/send"
287
+ f"?access_token={token}",
288
+ json=body,
289
+ )
290
+ except Exception:
291
+ import sys
292
+ print(f" [WeChatWork] ⚠ Push message failed", flush=True)
293
+ sys.stdout.flush()
294
+
295
+ # ── Helpers ───────────────────────────────────────────────
296
+
297
+ @staticmethod
298
+ def _strip_markdown(text: str) -> str:
299
+ """Remove unsupported markdown for WeChat Work text messages."""
300
+ import re
301
+ # Bold → plain
302
+ text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
303
+ # Code blocks → plain
304
+ text = re.sub(r"```[\s\S]*?```", "", text)
305
+ text = re.sub(r"`([^`]+)`", r"\1", text)
306
+ # Headers → plain
307
+ text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
308
+ # Horizontal rules → remove
309
+ text = re.sub(r"^-{3,}$", "", text, flags=re.MULTILINE)
310
+ # Links → text only
311
+ text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
312
+ return text
313
+
314
+
315
+ # ── Global adaptor instance ────────────────────────────────────
316
+
317
+ _adaptor: Optional[WeChatWorkAdaptor] = None
318
+
319
+
320
+ def _get_adaptor() -> WeChatWorkAdaptor:
321
+ global _adaptor
322
+ if _adaptor is None:
323
+ from agentic_rag.config.settings import get_settings
324
+ _adaptor = WeChatWorkAdaptor(get_settings().gateway.wechat_work)
325
+ return _adaptor
326
+
327
+
328
+ # ═══════════════════════════════════════════════════════════════
329
+ # Webhook Endpoints
330
+ # ═══════════════════════════════════════════════════════════════
331
+
332
+ @router.get("/gateway/wechat_work")
333
+ async def wechat_work_verify(
334
+ msg_signature: str = Query(...),
335
+ timestamp: str = Query(...),
336
+ nonce: str = Query(...),
337
+ echostr: str = Query(...),
338
+ ):
339
+ """URL verification — WeChat Work server sends a GET with echostr challenge.
340
+
341
+ Must return the decrypted echostr as plain text (not JSON, not XML).
342
+ """
343
+ adaptor = _get_adaptor()
344
+ try:
345
+ decrypted = await adaptor.verify_url(msg_signature, timestamp, nonce, echostr)
346
+ return Response(content=decrypted, media_type="text/plain")
347
+ except ValueError:
348
+ return Response(status_code=403, content="Verification failed")
349
+
350
+
351
+ @router.post("/gateway/wechat_work")
352
+ async def wechat_work_callback(request: Request):
353
+ """Receive WeChat Work callback messages (encrypted XML).
354
+
355
+ Query params: msg_signature, timestamp, nonce
356
+ Body: encrypted XML with <Encrypt>...</Encrypt>
357
+ """
358
+ adaptor = _get_adaptor()
359
+ return await adaptor.process(request)
agentic_rag/entrypoints/gateway/wechat_work/crypto.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """WeChat Work message encryption/decryption (AES-256-CBC + SHA1).
2
+
3
+ Reference: https://developer.work.weixin.qq.com/document/path/90968
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import hashlib
10
+ import random
11
+ import struct
12
+ import time
13
+
14
+
15
+ class WeChatCrypto:
16
+ """Handle WeChat Work callback message encryption and signature verification.
17
+
18
+ The EncodingAESKey is a 43-char Base64 string that decodes to a 32-byte AES key.
19
+ Messages use AES-256-CBC with the AES key as IV (first 16 bytes of key).
20
+ """
21
+
22
+ BLOCK_SIZE = 32
23
+
24
+ def __init__(self, token: str, encoding_aes_key: str, corp_id: str) -> None:
25
+ self.token = token
26
+ self.corp_id = corp_id
27
+ # EncodingAESKey is 43-char Base64 → 32-byte AES key (add padding '=' for decoding)
28
+ self.aes_key = base64.b64decode(encoding_aes_key + "=")
29
+
30
+ # ── Signature Verification ─────────────────────────────────
31
+
32
+ def verify_signature(
33
+ self, msg_signature: str, timestamp: str, nonce: str, echostr: str
34
+ ) -> bool:
35
+ """Verify callback signature from WeChat Work server."""
36
+ expected = self._sha1(timestamp, nonce, echostr)
37
+ return msg_signature == expected
38
+
39
+ # ── Message Decryption ─────────────────────────────────────
40
+
41
+ def decrypt(self, ciphertext: str) -> str:
42
+ """Decrypt an encrypted message XML from WeChat Work.
43
+
44
+ Returns the plain text XML string.
45
+ """
46
+ raw = base64.b64decode(ciphertext)
47
+ plain = self._aes_decrypt(raw)
48
+
49
+ # Strip PKCS#7 padding
50
+ pad = plain[-1]
51
+ plain = plain[:-pad]
52
+
53
+ # Parse: random(16) + msg_len(4, big-endian) + msg + corp_id
54
+ msg_len = struct.unpack("!I", plain[16:20])[0]
55
+ msg = plain[20 : 20 + msg_len].decode("utf-8")
56
+ received_corp_id = plain[20 + msg_len :].decode("utf-8")
57
+
58
+ if received_corp_id != self.corp_id:
59
+ raise ValueError(
60
+ f"Corp ID mismatch: expected {self.corp_id!r}, got {received_corp_id!r}"
61
+ )
62
+ return msg
63
+
64
+ # ── Message Encryption ─────────────────────────────────────
65
+
66
+ def encrypt(self, plain_text: str) -> str:
67
+ """Encrypt a reply message for WeChat Work.
68
+
69
+ Returns base64-encoded ciphertext.
70
+ """
71
+ random_bytes = bytes(random.getrandbits(8) for _ in range(16))
72
+ text_bytes = plain_text.encode("utf-8")
73
+ corp_bytes = self.corp_id.encode("utf-8")
74
+ msg_len = struct.pack("!I", len(text_bytes))
75
+
76
+ raw = random_bytes + msg_len + text_bytes + corp_bytes
77
+
78
+ # PKCS#7 padding to 32-byte block
79
+ pad = self.BLOCK_SIZE - len(raw) % self.BLOCK_SIZE
80
+ raw += bytes([pad] * pad)
81
+
82
+ encrypted = self._aes_encrypt(raw)
83
+ return base64.b64encode(encrypted).decode()
84
+
85
+ # ── Signature Generation (for responses) ───────────────────
86
+
87
+ def build_response_signature(self, encrypted_msg: str) -> str:
88
+ """Build msg_signature for an encrypted response."""
89
+ ts = str(int(time.time()))
90
+ nonce = self._random_nonce()
91
+ sig = self._sha1(ts, nonce, encrypted_msg)
92
+ return sig, ts, nonce
93
+
94
+ # ── Internal helpers ───────────────────────────────────────
95
+
96
+ def _sha1(self, timestamp: str, nonce: str, msg: str) -> str:
97
+ """SHA1 of sorted [token, timestamp, nonce, msg]."""
98
+ params = sorted([self.token, timestamp, nonce, msg])
99
+ return hashlib.sha1("".join(params).encode("utf-8")).hexdigest()
100
+
101
+ def _aes_decrypt(self, data: bytes) -> bytes:
102
+ from Crypto.Cipher import AES
103
+ cipher = AES.new(self.aes_key, AES.MODE_CBC, iv=self.aes_key[:16])
104
+ return cipher.decrypt(data)
105
+
106
+ def _aes_encrypt(self, data: bytes) -> bytes:
107
+ from Crypto.Cipher import AES
108
+ cipher = AES.new(self.aes_key, AES.MODE_CBC, iv=self.aes_key[:16])
109
+ return cipher.encrypt(data)
110
+
111
+ @staticmethod
112
+ def _random_nonce() -> str:
113
+ return "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=16))
agentic_rag/entrypoints/rest/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Agentic RAG module."""
agentic_rag/entrypoints/rest/app.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application — the main REST entry point."""
2
+
3
+ import time
4
+ import uuid
5
+ from pathlib import Path
6
+ from contextlib import asynccontextmanager
7
+
8
+ from fastapi import FastAPI, Request
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import FileResponse, JSONResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+
13
+ from agentic_rag.config.settings import get_settings
14
+
15
+
16
+ @asynccontextmanager
17
+ async def lifespan(app: FastAPI):
18
+ """Application lifespan — startup and shutdown."""
19
+ settings = get_settings()
20
+ print(f"[{settings.app_name}] Starting on {settings.api.host}:{settings.api.port}")
21
+
22
+ # Initialize database
23
+ try:
24
+ from agentic_rag.data.db import init_db
25
+ init_db(settings.db_path)
26
+ print(f"[{settings.app_name}] Database ready ({settings.db_path})")
27
+ except Exception as e:
28
+ print(f"[{settings.app_name}] ⚠ Database unavailable: {e}")
29
+
30
+ # Connect MCP servers FIRST (before gRPC/Milvus to avoid fork conflicts)
31
+ mcp_servers = settings.mcp_servers
32
+ if mcp_servers:
33
+ from agentic_rag.core.mcp.client import MCPClient
34
+ from agentic_rag.orchestration.l1_tools.registry import get_tool_registry
35
+ mcp_client = MCPClient()
36
+ registry = get_tool_registry()
37
+ for name, config in mcp_servers.items():
38
+ try:
39
+ cmd = config.get("command", "")
40
+ args_raw = config.get("args", "")
41
+ # args can be a JSON array or space-separated string
42
+ if isinstance(args_raw, list):
43
+ arg_list = args_raw
44
+ else:
45
+ arg_list = args_raw.split() if args_raw else []
46
+ # Merge MCP env with current process env (needs PATH etc.)
47
+ import os as _os
48
+ env = dict(_os.environ)
49
+ nested_env = config.get("env", {})
50
+ if isinstance(nested_env, dict):
51
+ env.update({str(k).upper(): str(v) for k, v in nested_env.items() if v})
52
+ tools = await mcp_client.connect_stdio(
53
+ server_name=name, command=cmd,
54
+ args=arg_list, env=env if env else None,
55
+ )
56
+ for tool in tools:
57
+ registry.register_mcp(tool, name)
58
+ print(f"[{settings.app_name}] MCP/{name} connected — {len(tools)} tools "
59
+ f"({cmd} {' '.join(arg_list[:2])}...)")
60
+ except Exception as e:
61
+ print(f"[{settings.app_name}] ⚠ MCP/{name} failed: {e}")
62
+
63
+ # Initialize knowledge pipeline AFTER MCP (avoids gRPC fork conflicts)
64
+ try:
65
+ from agentic_rag.services.knowledge.pipeline import init_knowledge_pipeline
66
+ init_knowledge_pipeline()
67
+ print(f"[{settings.app_name}] Knowledge pipeline ready "
68
+ f"(embedding={settings.embedding.model}, dim={settings.embedding.dim})")
69
+ except Exception as e:
70
+ print(f"[{settings.app_name}] ⚠ Knowledge pipeline unavailable: {e}")
71
+
72
+ # Start QQ Bot (WebSocket client — not a webhook)
73
+ if settings.gateway.qqbot.enabled:
74
+ from agentic_rag.entrypoints.gateway.qqbot import start_qqbot
75
+ await start_qqbot()
76
+
77
+ yield
78
+
79
+ # Shutdown: stop QQ Bot
80
+ if settings.gateway.qqbot.enabled:
81
+ from agentic_rag.entrypoints.gateway.qqbot import stop_qqbot
82
+ await stop_qqbot()
83
+
84
+ try:
85
+ from agentic_rag.data.db import _db
86
+ if _db:
87
+ _db.close()
88
+ except Exception:
89
+ pass
90
+ print(f"[{settings.app_name}] Shutting down")
91
+
92
+
93
+ def create_app() -> FastAPI:
94
+ """Create and configure the FastAPI application."""
95
+ settings = get_settings()
96
+
97
+ app = FastAPI(
98
+ title=settings.app_name,
99
+ version="0.1.0",
100
+ description="Agentic RAG — Multi-modal, ReAct-powered, MCP-enabled RAG System",
101
+ lifespan=lifespan,
102
+ )
103
+
104
+ # CORS
105
+ app.add_middleware(
106
+ CORSMiddleware,
107
+ allow_origins=settings.api.cors_origins,
108
+ allow_credentials=True,
109
+ allow_methods=["*"],
110
+ allow_headers=["*"],
111
+ )
112
+
113
+ # Request ID middleware
114
+ @app.middleware("http")
115
+ async def add_request_id(request: Request, call_next):
116
+ request_id = request.headers.get("X-Request-ID", uuid.uuid4().hex[:12])
117
+ request.state.request_id = request_id
118
+ response = await call_next(request)
119
+ response.headers["X-Request-ID"] = request_id
120
+ return response
121
+
122
+ # Register routes
123
+ from agentic_rag.entrypoints.rest.routes import chat, health, mcp, rag, session, settings
124
+ app.include_router(health.router, tags=["Health"])
125
+ app.include_router(chat.router, prefix="/api/v1", tags=["Chat"])
126
+ app.include_router(rag.router, prefix="/api/v1", tags=["RAG"])
127
+ app.include_router(session.router, prefix="/api/v1", tags=["Session"])
128
+ app.include_router(mcp.router, prefix="/api/v1", tags=["MCP"])
129
+ app.include_router(settings.router, prefix="/api/v1", tags=["Settings"])
130
+
131
+ # Gateway routes (messaging platform webhooks)
132
+ if get_settings().gateway.enabled:
133
+ from agentic_rag.entrypoints.gateway.router import get_gateway_router
134
+ gateway_router = get_gateway_router()
135
+ if gateway_router.routes:
136
+ app.include_router(gateway_router)
137
+
138
+ # WebSocket routes
139
+ try:
140
+ from agentic_rag.entrypoints.websocket.handler import router as ws_router
141
+ app.include_router(ws_router, tags=["WebSocket"])
142
+ except ImportError:
143
+ pass
144
+
145
+ # Static files & SPA fallback
146
+ static_dir = Path(__file__).resolve().parent.parent.parent.parent / "static"
147
+ if static_dir.exists():
148
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
149
+
150
+ @app.get("/", include_in_schema=False)
151
+ async def spa_root():
152
+ return FileResponse(str(static_dir / "index.html"))
153
+
154
+ # Global exception handler
155
+ @app.exception_handler(Exception)
156
+ async def global_exception_handler(request: Request, exc: Exception):
157
+ return JSONResponse(
158
+ status_code=500,
159
+ content={
160
+ "error": str(exc),
161
+ "type": type(exc).__name__,
162
+ "request_id": getattr(request.state, "request_id", "unknown"),
163
+ "timestamp": time.time(),
164
+ },
165
+ )
166
+
167
+ return app
168
+
169
+
170
+ app = create_app()
agentic_rag/entrypoints/rest/routes/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Agentic RAG module."""
agentic_rag/entrypoints/rest/routes/chat.py ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chat endpoints — the primary agent interaction interface."""
2
+
3
+ import uuid
4
+ from typing import Optional
5
+
6
+ from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
7
+ from fastapi.responses import StreamingResponse
8
+ from pydantic import BaseModel, Field
9
+ try:
10
+ from sse_starlette.sse import EventSourceResponse
11
+ except ImportError:
12
+ EventSourceResponse = None
13
+
14
+ from agentic_rag.data.models import AgentEvent, AgentInput
15
+ from agentic_rag.services.llm.factory import get_llm
16
+ from agentic_rag.orchestration.l1_tools.registry import get_tool_registry
17
+ from agentic_rag.agent.router import AgentRouter
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ class ChatRequest(BaseModel):
23
+ """Chat request body."""
24
+ message: str
25
+ session_id: Optional[str] = None
26
+ mode: Optional[str] = None # "chat", "rag", "research", "media"
27
+ stream: bool = False
28
+ images: list[str] = Field(default_factory=list) # image paths for vision
29
+
30
+
31
+ class ChatResponse(BaseModel):
32
+ """Chat response body."""
33
+ answer: str
34
+ session_id: str
35
+ mode: str
36
+ tool_calls: list = Field(default_factory=list)
37
+
38
+
39
+ def _build_input(query: str, images: list[str]) -> AgentInput:
40
+ """Build agent input — embeds images directly if LLM supports vision.
41
+
42
+ ``images`` can be:
43
+ - File paths (``/tmp/photo.jpg``) — read from disk and base64-encode
44
+ - Data URIs (``data:image/jpeg;base64,...``) — used as-is
45
+ """
46
+ from agentic_rag.data.models import AgentInput, Message, MessageRole
47
+ import base64
48
+ from pathlib import Path as _Path
49
+
50
+ if not images:
51
+ return AgentInput(query=query)
52
+
53
+ # Check if current LLM supports vision
54
+ try:
55
+ from agentic_rag.services.llm.factory import get_llm
56
+ llm = get_llm()
57
+ except Exception:
58
+ llm = None
59
+
60
+ if not getattr(llm, 'supports_vision', False):
61
+ return AgentInput(query=f"{query}\n\n[Attached: {len(images)} image(s)]")
62
+
63
+ # Vision LLM — embed images directly in multimodal message
64
+ content = [{"type": "text", "text": query}]
65
+ for img in images:
66
+ if img.startswith("data:"):
67
+ # Already a data URI — use directly
68
+ content.append({
69
+ "type": "image_url",
70
+ "image_url": {"url": img},
71
+ })
72
+ else:
73
+ # File path — read and encode
74
+ p = _Path(img)
75
+ if p.exists():
76
+ b64 = base64.b64encode(p.read_bytes()).decode()
77
+ ext = p.suffix.lower()
78
+ mime_map = {".jpg": "image/jpeg", ".jpeg": "image/jpeg",
79
+ ".png": "image/png", ".webp": "image/webp",
80
+ ".gif": "image/gif", ".bmp": "image/bmp"}
81
+ mime = mime_map.get(ext, "image/png")
82
+ content.append({
83
+ "type": "image_url",
84
+ "image_url": {"url": f"data:{mime};base64,{b64}"},
85
+ })
86
+
87
+ msg = Message(role=MessageRole.USER, content=content)
88
+ return AgentInput(query=query, messages=[msg])
89
+
90
+
91
+ @router.post("/chat", response_model=ChatResponse)
92
+ async def chat(req: ChatRequest, request: Request):
93
+ """Send a message and get a response (non-streaming)."""
94
+ try:
95
+ llm = get_llm()
96
+ except ValueError as e:
97
+ raise HTTPException(status_code=503, detail=str(e))
98
+
99
+ sid = req.session_id or uuid.uuid4().hex
100
+ tool_registry = get_tool_registry()
101
+
102
+ # Initialize built-in tools if needed
103
+ _ensure_tools_registered(tool_registry)
104
+
105
+ router = AgentRouter(llm, tool_registry)
106
+ input_data = _build_input(req.message, req.images) if req.images else AgentInput(
107
+ query=req.message, parameters={"session_id": sid})
108
+ input_data.parameters["sid"] = sid
109
+
110
+ engine = await router.route(
111
+ query=input_data.query,
112
+ preferred_mode=req.mode,
113
+ )
114
+ output = await engine.run(input_data, turn_id=uuid.uuid4().hex)
115
+
116
+ return ChatResponse(
117
+ answer=output.final_answer,
118
+ session_id=sid,
119
+ mode=req.mode or "auto",
120
+ tool_calls=[tc.model_dump() for tc in output.tool_calls_made],
121
+ )
122
+
123
+
124
+ @router.post("/chat/stream")
125
+ async def chat_stream(req: ChatRequest, request: Request):
126
+ """Send a message and stream the response via SSE."""
127
+ try:
128
+ llm = get_llm()
129
+ except ValueError as e:
130
+ raise HTTPException(status_code=503, detail=str(e))
131
+
132
+ sid = req.session_id or uuid.uuid4().hex
133
+ tool_registry = get_tool_registry()
134
+ _ensure_tools_registered(tool_registry)
135
+
136
+ # Load conversation history from DB & save user message
137
+ from agentic_rag.data.models import Message as MsgModel, MessageRole
138
+ from agentic_rag.data.db.session_repo import SessionRepo
139
+ repo = SessionRepo()
140
+ history_messages = []
141
+ try:
142
+ session = repo.get(sid)
143
+ if not session:
144
+ # Session was deleted (e.g., user cleared messages) — re-create it
145
+ repo.create_with_id(sid, user_id="web_ui")
146
+ print(f" [chat] Session {sid[:12]} re-created (was deleted)", flush=True)
147
+ else:
148
+ repo.add_message(sid, role="user", content=req.message)
149
+ print(f" [chat] ✓ User message saved to session {sid[:12]}...", flush=True)
150
+ # Load conversation history; trim to stay within ~8000 chars (~5K tokens)
151
+ db_msgs = repo.get_messages(sid, limit=10)
152
+ if db_msgs and db_msgs[-1].get("role") == "user":
153
+ db_msgs = db_msgs[:-1] # drop current query, passed separately
154
+
155
+ total_chars = 0
156
+ for m in reversed(db_msgs): # newest first, accumulate until limit
157
+ role = m.get("role", "user")
158
+ content = m.get("content", "")
159
+ if role == "system":
160
+ continue
161
+ if len(content) > 2000:
162
+ content = content[:2000] + "..."
163
+ if total_chars + len(content) > 8000:
164
+ break # stop adding older messages
165
+ total_chars += len(content)
166
+ try:
167
+ r = MessageRole(role)
168
+ except ValueError:
169
+ r = MessageRole.USER
170
+ history_messages.insert(0, MsgModel(role=r, content=content))
171
+ except Exception as e:
172
+ print(f" [chat] ⚠ History load failed: {e}", flush=True)
173
+
174
+ router = AgentRouter(llm, tool_registry)
175
+ input_data = _build_input(req.message, req.images) if req.images else AgentInput(
176
+ query=req.message, messages=history_messages, parameters={"session_id": sid})
177
+ input_data.parameters["sid"] = sid
178
+
179
+ engine = await router.route(
180
+ query=input_data.query,
181
+ preferred_mode=req.mode,
182
+ )
183
+
184
+ async def event_generator():
185
+ full_text_parts = []
186
+ final_answer = ""
187
+ in_think = False # <think> tag state
188
+ _prefinal_buf = "" # buffer for Thought/Action text before Final Answer
189
+ _line_freq: dict[str, int] = {} # detect repetitive lines
190
+ _suppress_thought = False # stop forwarding when thought loops
191
+ _done_emitted = False # track if engine already emitted done
192
+
193
+ try:
194
+ async for event in engine.stream(input_data, turn_id=uuid.uuid4().hex):
195
+ payload = event.data if hasattr(event, 'data') else {}
196
+ if event.event_type.value == "done":
197
+ final_answer = payload.get("final_answer", "")
198
+ _done_emitted = True
199
+ elif event.event_type.value == "text_delta":
200
+ chunk = payload.get("content", "") or payload.get("delta", "")
201
+ if chunk:
202
+ # Strip <think>...</think> tags
203
+ cleaned = chunk
204
+ while "<think>" in cleaned or "</think>" in cleaned or in_think:
205
+ if in_think:
206
+ end = cleaned.find("</think>")
207
+ if end >= 0:
208
+ cleaned = cleaned[end + 8:]
209
+ in_think = False
210
+ else:
211
+ cleaned = ""
212
+ break
213
+ else:
214
+ start = cleaned.find("<think>")
215
+ if start >= 0:
216
+ in_think = True
217
+ cleaned = cleaned[:start]
218
+ else:
219
+ break
220
+ if not cleaned:
221
+ continue
222
+
223
+ # Detect "Final Answer:" — flush only answer from here on
224
+ if "Final Answer:" in _prefinal_buf + cleaned:
225
+ idx = (_prefinal_buf + cleaned).find("Final Answer:") + len("Final Answer:")
226
+ combined = _prefinal_buf + cleaned
227
+ after = combined[idx:].strip()
228
+ _prefinal_buf = ""
229
+ if after:
230
+ full_text_parts.append(after)
231
+ payload["content"] = after
232
+ yield {
233
+ "event": "text_delta",
234
+ "data": event.model_dump_json() if hasattr(event, 'model_dump_json') else "{}",
235
+ }
236
+ continue # skip bottom yield — already emitted Final Answer
237
+
238
+ # Still in Thought phase — detect repetitive looping
239
+ if not _suppress_thought:
240
+ _prefinal_buf += cleaned
241
+
242
+ # Check for repetition: same meaningful line appearing 3+ times
243
+ lines = _prefinal_buf.split("\n")
244
+ for line in lines[-3:]: # check recent lines only
245
+ norm = line.strip().lower().rstrip(".。!!??,,")
246
+ if len(norm) > 12:
247
+ _line_freq[norm] = _line_freq.get(norm, 0) + 1
248
+ if _line_freq[norm] >= 3:
249
+ _suppress_thought = True
250
+ break
251
+
252
+ if not _suppress_thought:
253
+ # Also suppress if thought is >2000 chars — clearly looping
254
+ if len(_prefinal_buf) > 2000:
255
+ _suppress_thought = True
256
+
257
+ if not _suppress_thought:
258
+ full_text_parts.append(cleaned)
259
+ payload["content"] = cleaned
260
+ else:
261
+ continue # skip yield — suppressing verbose thought
262
+ elif event.event_type.value == "error":
263
+ pass
264
+ yield {
265
+ "event": event.event_type.value,
266
+ "data": event.model_dump_json() if hasattr(event, 'model_dump_json') else "{}",
267
+ }
268
+ except Exception as e:
269
+ import sys
270
+ print(f" [chat] ⚠ Event generator crashed: {e}", flush=True)
271
+ sys.stdout.flush()
272
+ yield {
273
+ "event": "error",
274
+ "data": f'{{"error": "Stream crashed: {e}"}}',
275
+ }
276
+
277
+ # Use final_answer (already clean) if available, else concatenated deltas
278
+ response_text = final_answer.strip() or "".join(full_text_parts).strip()
279
+ if response_text:
280
+ import sys
281
+ print(f" [chat] Saving assistant message ({len(response_text)} chars) to session {sid[:12]}...", flush=True)
282
+ try:
283
+ repo.add_message(sid, role="assistant", content=response_text)
284
+ print(f" [chat] ✓ Assistant message saved", flush=True)
285
+ except Exception as e:
286
+ print(f" [chat] ⚠ Failed to save assistant message: {e}", flush=True)
287
+ else:
288
+ print(f" [chat] ⚠ No assistant text collected (final_answer={bool(final_answer)}, deltas={len(full_text_parts)})", flush=True)
289
+
290
+ # Only emit done if engine didn't already emit one
291
+ if not _done_emitted:
292
+ yield {"event": "done", "data": "{}"}
293
+
294
+ if EventSourceResponse is None:
295
+ raise HTTPException(status_code=501, detail="SSE streaming not available. Install sse-starlette.")
296
+ return EventSourceResponse(event_generator())
297
+
298
+
299
+ class VoiceRequest(BaseModel):
300
+ """Voice chat request — audio in, audio out (base64, kept for WS compat)."""
301
+ audio: str = ""
302
+ sid: str = ""
303
+ mode: str = ""
304
+ tts: bool = True
305
+
306
+
307
+ @router.post("/chat/voice")
308
+ async def chat_voice(
309
+ audio: UploadFile | None = File(None),
310
+ sid: str = Form(""),
311
+ mode: str = Form(""),
312
+ tts: bool = Form(True),
313
+ ):
314
+ """Voice chat with SSE streaming: audio → STT → Agent stream → TTS.
315
+
316
+ SSE events: transcript | text_delta | tool_* | audio | done
317
+ """
318
+ import base64, sys, json as _json
319
+
320
+ if audio is None:
321
+ raise HTTPException(status_code=400, detail="No audio file provided")
322
+ audio_bytes = await audio.read()
323
+ if not audio_bytes:
324
+ raise HTTPException(status_code=400, detail="Empty audio file")
325
+
326
+ from agentic_rag.config.settings import get_settings
327
+ vs = get_settings().voice
328
+ from agentic_rag.core.voice.stt import STTService
329
+ stt_svc = STTService(
330
+ provider=vs.stt_provider, model=vs.stt_model,
331
+ api_base=vs.stt_api_base, api_key=vs.stt_api_key,
332
+ language=vs.stt_language, sample_rate=vs.sample_rate,
333
+ )
334
+
335
+ def _sse(event: str, data: dict) -> dict:
336
+ return {"event": event, "data": _json.dumps(data, ensure_ascii=False)}
337
+
338
+ async def _generator():
339
+ # Step 1: STT — yield transcript immediately
340
+ transcript = await stt_svc.transcribe_bytes(audio_bytes)
341
+ if not transcript.strip():
342
+ yield _sse("error", {"error": "No speech detected"})
343
+ yield _sse("done", {})
344
+ return
345
+ print(f" [Voice] STT: \"{transcript[:80]}...\"", flush=True)
346
+ yield _sse("transcript", {"text": transcript})
347
+
348
+ # Step 2: Agent — stream response
349
+ sid_val = sid or uuid.uuid4().hex
350
+ try:
351
+ llm = get_llm()
352
+ except ValueError as e:
353
+ yield _sse("error", {"error": str(e)})
354
+ yield _sse("done", {})
355
+ return
356
+
357
+ tool_registry = get_tool_registry()
358
+ _ensure_tools_registered(tool_registry)
359
+
360
+ from agentic_rag.data.db.session_repo import SessionRepo
361
+ repo = SessionRepo()
362
+ try:
363
+ if not repo.get(sid_val):
364
+ repo.add_message(sid_val, role="user", content=f"[Voice] {transcript}")
365
+ except Exception:
366
+ pass
367
+
368
+ agent_router = AgentRouter(llm, tool_registry)
369
+ input_data = AgentInput(query=transcript, parameters={"session_id": sid_val})
370
+ engine = await agent_router.route(query=transcript, preferred_mode=mode)
371
+
372
+ full_answer = ""
373
+ async for event in engine.stream(input_data, turn_id=uuid.uuid4().hex):
374
+ evt_type = event.event_type.value
375
+ payload = event.data if hasattr(event, 'data') else {}
376
+ if evt_type == "text_delta":
377
+ chunk = payload.get("content", "") or payload.get("delta", "")
378
+ if chunk:
379
+ import re
380
+ chunk = re.sub(r'<think>[\s\S]*?</think>', '', chunk)
381
+ if chunk.strip():
382
+ full_answer += chunk
383
+ yield _sse("text_delta", {"content": chunk})
384
+ elif evt_type == "done":
385
+ ans = payload.get("final_answer", "")
386
+ if ans:
387
+ full_answer = ans
388
+ elif evt_type in ("tool_call_start", "tool_call_result", "error"):
389
+ yield _sse(evt_type, payload)
390
+
391
+ print(f" [Voice] Answer: \"{full_answer[:80]}...\"", flush=True)
392
+
393
+ # Save assistant message
394
+ if full_answer.strip():
395
+ try:
396
+ repo.add_message(sid_val, role="assistant", content=full_answer)
397
+ except Exception:
398
+ pass
399
+
400
+ # Step 3: TTS
401
+ if tts and full_answer.strip():
402
+ print(" [Voice] Synthesizing TTS...", flush=True)
403
+ from agentic_rag.core.voice.tts import TTSService
404
+ tts_svc = TTSService(
405
+ provider=vs.tts_provider, model=vs.tts_model,
406
+ api_base=vs.tts_api_base, api_key=vs.tts_api_key,
407
+ task_type=vs.tts_task_type, instructions=vs.tts_instructions,
408
+ language=vs.tts_language, speaker=vs.tts_speaker,
409
+ voice=vs.tts_voice, speed=vs.tts_speed,
410
+ response_format=vs.tts_response_format,
411
+ )
412
+ audio_out = await tts_svc.synthesize(full_answer)
413
+ if audio_out:
414
+ audio_b64 = base64.b64encode(audio_out).decode()
415
+ print(f" [Voice] TTS: {len(audio_out)} bytes", flush=True)
416
+ yield _sse("audio", {"audio": audio_b64, "format": vs.tts_response_format})
417
+ yield _sse("done", {"final_answer": full_answer, "session_id": sid_val})
418
+ return
419
+ yield _sse("audio", {})
420
+ yield _sse("done", {"final_answer": full_answer, "session_id": sid_val})
421
+
422
+ if EventSourceResponse is None:
423
+ raise HTTPException(status_code=501, detail="SSE streaming not available. Install sse-starlette.")
424
+ return EventSourceResponse(_generator())
425
+
426
+
427
+
428
+
429
+ def _ensure_tools_registered(registry):
430
+ """Register built-in tools (idempotent — skips already-registered names).
431
+
432
+ ``web_search`` is registered as a built-in fallback (DuckDuckGo) even
433
+ though web search is primarily expected via MCP: prompt rule 2 tells the
434
+ model to use 网络搜索 when rag_search comes up empty, and without a
435
+ tool literally named ``web_search`` the model emits an Action for a
436
+ non-existent tool and the turn stalls.
437
+ """
438
+ from agentic_rag.orchestration.l1_tools.rag_tools import RAGSearchTool
439
+ from agentic_rag.orchestration.l1_tools.web_tools import WebSearchTool
440
+ _register_if_missing(registry, RAGSearchTool())
441
+ _register_if_missing(registry, WebSearchTool())
442
+
443
+
444
+ def _register_if_missing(registry, tool):
445
+ """Register a tool only if its name isn't already taken."""
446
+ if tool.name not in registry.list_names():
447
+ registry.register(tool)
agentic_rag/entrypoints/rest/routes/health.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Health check endpoints."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ router = APIRouter()
6
+
7
+
8
+ @router.get("/health")
9
+ async def health_check():
10
+ """Basic health check."""
11
+ return {"status": "ok", "service": "agentic_rag"}
12
+
13
+
14
+ @router.get("/ready")
15
+ async def readiness_check():
16
+ """Readiness check — verifies LLM connectivity."""
17
+ from agentic_rag.services.llm.factory import get_llm
18
+ try:
19
+ llm = get_llm()
20
+ provider_info = {"provider": llm.provider_name, "model": llm.model_name}
21
+ return {"status": "ready", "llm": provider_info}
22
+ except Exception as e:
23
+ return {"status": "not_ready", "error": str(e)}
agentic_rag/entrypoints/rest/routes/mcp.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MCP server configuration management endpoints."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from fastapi import APIRouter, HTTPException
7
+ from pydantic import BaseModel
8
+
9
+ router = APIRouter()
10
+
11
+ MCP_CONFIG_PATH = Path("mcp_servers.json")
12
+
13
+
14
+ def _read_config() -> dict:
15
+ """Read current MCP config from JSON file."""
16
+ if MCP_CONFIG_PATH.exists():
17
+ try:
18
+ return json.loads(MCP_CONFIG_PATH.read_text())
19
+ except json.JSONDecodeError:
20
+ pass
21
+ return {"mcpServers": {}}
22
+
23
+
24
+ def _write_config(data: dict) -> None:
25
+ """Write MCP config to JSON file."""
26
+ MCP_CONFIG_PATH.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
27
+
28
+
29
+ class MCPServerEntry(BaseModel):
30
+ """A single MCP server config entry."""
31
+ command: str = ""
32
+ args: list[str] = []
33
+ env: dict[str, str] = {}
34
+ disabled: bool = False
35
+
36
+
37
+ class MCPServersUpdate(BaseModel):
38
+ """Full MCP servers config update."""
39
+ servers: dict[str, MCPServerEntry]
40
+
41
+
42
+ @router.get("/mcp/servers")
43
+ async def get_mcp_servers():
44
+ """Return the current MCP server configuration."""
45
+ config = _read_config()
46
+ return {"servers": config.get("mcpServers", {})}
47
+
48
+
49
+ @router.put("/mcp/servers")
50
+ async def update_mcp_servers(data: MCPServersUpdate):
51
+ """Replace the entire MCP server configuration and write to disk."""
52
+ config = {"mcpServers": {}}
53
+ for name, entry in data.servers.items():
54
+ config["mcpServers"][name] = {
55
+ "command": entry.command,
56
+ "args": entry.args if isinstance(entry.args, list) else entry.args.split(),
57
+ "env": entry.env or {},
58
+ "disabled": entry.disabled,
59
+ }
60
+ _write_config(config)
61
+ return {"status": "saved", "servers": len(config["mcpServers"])}
62
+
63
+
64
+ @router.delete("/mcp/servers/{name}")
65
+ async def delete_mcp_server(name: str):
66
+ """Delete a single MCP server from the config."""
67
+ config = _read_config()
68
+ servers = config.get("mcpServers", {})
69
+ if name not in servers:
70
+ raise HTTPException(status_code=404, detail=f"MCP server '{name}' not found")
71
+ del servers[name]
72
+ _write_config(config)
73
+ return {"status": "deleted", "name": name}
agentic_rag/entrypoints/rest/routes/rag.py ADDED
@@ -0,0 +1,558 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RAG endpoints — knowledge base query, ingestion, and file upload."""
2
+
3
+ import os
4
+ import uuid
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
9
+ from pydantic import BaseModel
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ def _parse_doc_id(result: str) -> str:
15
+ """Extract the UUID document ID from an ingestion result message.
16
+
17
+ The RAGIngestTool returns a multi-line string like::
18
+
19
+ Content ingested successfully.
20
+ Document ID: abc123-def456-...
21
+ Source: upload
22
+
23
+ We only want the UUID, not the trailing "Source: ..." text.
24
+ """
25
+ if "ID:" in result:
26
+ # Grab the line containing "ID:" and extract the UUID
27
+ for line in result.split("\n"):
28
+ if "ID:" in line:
29
+ return line.split("ID:")[-1].strip()
30
+ return "unknown"
31
+
32
+
33
+ @router.get("/rag/stats")
34
+ async def rag_stats():
35
+ """Return knowledge base statistics."""
36
+ from agentic_rag.services.knowledge.pipeline import get_knowledge_pipeline
37
+ pipe = get_knowledge_pipeline()
38
+ vs_stats = {}
39
+ if pipe.vector_store and hasattr(pipe.vector_store, 'stats'):
40
+ try:
41
+ vs_stats = pipe.vector_store.stats()
42
+ except Exception:
43
+ pass
44
+
45
+ # Count from Milvus (persistent) + pipeline (in-memory)
46
+ chunk_count = 0
47
+ doc_count = len(pipe._documents)
48
+ try:
49
+ from pymilvus import MilvusClient
50
+ from agentic_rag.config.settings import get_settings
51
+ ws = get_settings().workspace_dir
52
+ mc = MilvusClient(f"{ws}/milvus_lite.db")
53
+ if "knowledge" in (mc.list_collections() or []):
54
+ mc.load_collection("knowledge")
55
+ # Count chunks
56
+ res = mc.query("knowledge", filter="id != ''", output_fields=["id", "source"])
57
+ chunk_count = len(res) if res else 0
58
+ # Count distinct documents (by source field)
59
+ if res:
60
+ sources = set(r.get("source", "") for r in res if r.get("source"))
61
+ doc_count = max(doc_count, len(sources))
62
+ except Exception:
63
+ pass
64
+
65
+ kg_stats = pipe.graph.stats() if pipe.graph else {}
66
+ return {
67
+ "chunks": chunk_count,
68
+ "documents": doc_count,
69
+ "graph": kg_stats,
70
+ "vector_store": vs_stats,
71
+ }
72
+
73
+
74
+ @router.get("/rag/documents")
75
+ async def rag_documents():
76
+ """List all indexed documents with metadata (server-side, browser-agnostic)."""
77
+ from agentic_rag.services.knowledge.pipeline import get_knowledge_pipeline
78
+ pipe = get_knowledge_pipeline()
79
+ return {"documents": pipe.list_files()}
80
+
81
+
82
+ @router.get("/rag/graph")
83
+ async def rag_graph():
84
+ """Return the full knowledge graph (entities + relations) for visualization."""
85
+ from agentic_rag.services.knowledge.pipeline import get_knowledge_pipeline
86
+ pipe = get_knowledge_pipeline()
87
+
88
+ if not pipe.graph:
89
+ return {"entities": [], "relations": [], "stats": {"entities": 0, "relations": 0}}
90
+
91
+ graph_dict = pipe.graph.to_dict()
92
+ return {
93
+ "entities": [
94
+ {
95
+ "id": eid,
96
+ "name": ent["name"][:80],
97
+ "type": ent["type"],
98
+ "content_type": ent.get("content_type", ""),
99
+ "text_preview": ent.get("content_text", "")[:120],
100
+ }
101
+ for eid, ent in graph_dict.get("entities", {}).items()
102
+ ],
103
+ "relations": [
104
+ {
105
+ "source": r["source"][:12],
106
+ "target": r["target"][:12],
107
+ "type": r["type"],
108
+ "weight": round(r.get("weight", 1.0), 2),
109
+ }
110
+ for r in graph_dict.get("relations", [])
111
+ ],
112
+ "stats": pipe.graph.stats(),
113
+ }
114
+
115
+
116
+ class RAGSearchRequest(BaseModel):
117
+ """Raw RAG search request — no LLM generation."""
118
+ query: str
119
+ top_k: int = 10
120
+ mode: str = "hybrid"
121
+ modality_filter: list[str] = None
122
+
123
+
124
+ class RAGQueryRequest(BaseModel):
125
+ """RAG query request."""
126
+ query: str
127
+ top_k: int = 5
128
+ include_raw_docs: bool = False
129
+
130
+
131
+ class RAGQueryResponse(BaseModel):
132
+ """RAG query response."""
133
+ answer: str
134
+ sources: list[dict] = []
135
+
136
+
137
+ class RAGIngestRequest(BaseModel):
138
+ """Document ingestion request."""
139
+ content: str
140
+ source: str = "api"
141
+ metadata: dict = {}
142
+
143
+
144
+ class RAGIngestResponse(BaseModel):
145
+ """Document ingestion response."""
146
+ doc_id: str
147
+ status: str
148
+
149
+
150
+ @router.post("/rag/search")
151
+ async def rag_search(req: RAGSearchRequest):
152
+ """Search the knowledge base — returns raw chunks, no LLM generation."""
153
+ from agentic_rag.services.knowledge.pipeline import get_knowledge_pipeline
154
+ from agentic_rag.services.knowledge.content_list import ContentType
155
+ pipe = get_knowledge_pipeline()
156
+ # Parse modality filter
157
+ mf = None
158
+ if req.modality_filter:
159
+ mf = [ContentType(t) for t in req.modality_filter if t in ContentType.__members__.values()]
160
+ mf = mf or None
161
+ results = await pipe.retrieve(query=req.query, top_k=req.top_k, mode=req.mode, modality_filter=mf)
162
+ # Resolve doc_id → display name
163
+ doc_names = {}
164
+ for r in results:
165
+ meta = r.content_item.metadata or {}
166
+ did = meta.get("source", "") # Milvus "source" field = doc_id
167
+ if did and did not in doc_names and len(did) > 20:
168
+ cl = pipe._documents.get(did)
169
+ raw = cl.source if cl else did[:12]
170
+ # Show filename only, not full path
171
+ doc_names[did] = raw.split("/")[-1] if "/" in raw else raw
172
+
173
+ # Filter out very low-score results (embedding noise)
174
+ MIN_SCORE = 0.25
175
+ filtered = [r for r in results if r.score >= MIN_SCORE]
176
+ if not filtered:
177
+ filtered = results[:3] # always return at least top 3
178
+
179
+ return {
180
+ "query": req.query,
181
+ "results": [
182
+ {
183
+ "content": r.content_item.to_searchable_text() or r.content_item.text or "",
184
+ "content_type": r.content_item.type.value,
185
+ "score": r.score,
186
+ "source": r.source,
187
+ "document": doc_names.get(r.content_item.metadata.get("source", ""), ""),
188
+ "image_path": r.content_item.img_path or "",
189
+ "video_path": r.content_item.video_path or "",
190
+ "audio_path": r.content_item.audio_path or "",
191
+ "table_body": r.content_item.table_body or "",
192
+ "page_idx": r.content_item.page_idx,
193
+ }
194
+ for r in filtered
195
+ ],
196
+ }
197
+
198
+
199
+ @router.post("/rag/query", response_model=RAGQueryResponse)
200
+ async def rag_query(req: RAGQueryRequest):
201
+ """Query the knowledge base with RAG."""
202
+ from agentic_rag.orchestration.l1_tools.rag_tools import RAGSearchTool
203
+ from agentic_rag.services.llm.factory import get_llm
204
+ from agentic_rag.data.models import Message
205
+
206
+ llm = get_llm()
207
+ tool = RAGSearchTool()
208
+ results = await tool.execute(query=req.query, top_k=req.top_k)
209
+
210
+ # Generate answer from retrieved context
211
+ messages = [
212
+ Message.system("Answer the user's question using the provided context. Cite sources when possible."),
213
+ Message.user(f"Context:\n{results}\n\nQuestion: {req.query}"),
214
+ ]
215
+ response = await llm.agenerate(messages)
216
+
217
+ return RAGQueryResponse(
218
+ answer=response.content,
219
+ sources=[{"content": results}],
220
+ )
221
+
222
+
223
+ @router.get("/rag/file/{file_path:path}")
224
+ async def serve_kb_file(file_path: str):
225
+ """Serve a file from the knowledge base (for image/video preview)."""
226
+ from pathlib import Path as _Path
227
+ from fastapi.responses import FileResponse
228
+ p = _Path(file_path)
229
+ if not p.is_absolute():
230
+ p = _Path.cwd() / file_path
231
+ if not p.exists():
232
+ raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
233
+ return FileResponse(str(p))
234
+
235
+
236
+ @router.get("/rag/document/{doc_id}")
237
+ async def get_document(doc_id: str):
238
+ """Get the content of an indexed document for preview."""
239
+ from agentic_rag.services.knowledge.pipeline import get_knowledge_pipeline
240
+ pipe = get_knowledge_pipeline()
241
+
242
+ # Try in-memory first
243
+ cl = pipe._documents.get(doc_id)
244
+ if cl:
245
+ items = []
246
+ for item in cl.items:
247
+ items.append({
248
+ "text": item.to_searchable_text() or item.text or "",
249
+ "type": item.type.value,
250
+ "page_idx": item.page_idx,
251
+ "image_path": item.img_path or "",
252
+ "video_path": item.video_path or "",
253
+ "audio_path": item.audio_path or "",
254
+ "table_body": item.table_body or "",
255
+ })
256
+ return {"doc_id": doc_id, "source": cl.source, "items": items}
257
+
258
+ # Fallback: query Milvus by source (works after server restart)
259
+ items = []
260
+ try:
261
+ from agentic_rag.config.settings import get_settings
262
+ from pymilvus import MilvusClient
263
+ ws = get_settings().workspace_dir
264
+ mc = MilvusClient(f"{ws}/milvus_lite.db")
265
+ if "knowledge" in (mc.list_collections() or []):
266
+ mc.load_collection("knowledge")
267
+ res = mc.query("knowledge", filter=f'source == "{doc_id}"', output_fields=["text", "content_type", "image_path", "video_path", "audio_path", "table_body"], limit=100)
268
+ for r in res:
269
+ items.append({
270
+ "text": r.get("text", ""),
271
+ "type": r.get("content_type", "text"),
272
+ "image_path": r.get("image_path", ""),
273
+ "video_path": r.get("video_path", ""),
274
+ "audio_path": r.get("audio_path", ""),
275
+ "table_body": r.get("table_body", ""),
276
+ })
277
+ except Exception as e:
278
+ print(f" [rag] document query error: {e}", flush=True)
279
+
280
+ if not items:
281
+ raise HTTPException(status_code=404, detail=f"Document {doc_id} not found")
282
+ return {"doc_id": doc_id, "items": items}
283
+
284
+
285
+ @router.delete("/rag/document/{doc_id}")
286
+ async def delete_document(doc_id: str):
287
+ """Delete an indexed document and all its chunks from the knowledge base."""
288
+ deleted_chunks = 0
289
+ try:
290
+ from agentic_rag.config.settings import get_settings
291
+ from pymilvus import MilvusClient
292
+ ws = get_settings().workspace_dir
293
+ mc = MilvusClient(f"{ws}/milvus_lite.db")
294
+ if "knowledge" in (mc.list_collections() or []):
295
+ mc.load_collection("knowledge")
296
+ res = mc.query("knowledge", filter=f'source == "{doc_id}"', output_fields=["id"], limit=1000)
297
+ ids = [r["id"] for r in res]
298
+ if ids:
299
+ mc.delete("knowledge", ids=ids)
300
+ deleted_chunks = len(ids)
301
+ except Exception as e:
302
+ raise HTTPException(status_code=500, detail=f"Deletion failed: {e}")
303
+
304
+ # Also remove from in-memory pipeline
305
+ from agentic_rag.services.knowledge.pipeline import get_knowledge_pipeline
306
+ pipe = get_knowledge_pipeline()
307
+ pipe._documents.pop(doc_id, None)
308
+
309
+ return {"doc_id": doc_id, "deleted_chunks": deleted_chunks, "status": "deleted"}
310
+
311
+
312
+ @router.post("/rag/clear")
313
+ async def clear_knowledge_base():
314
+ """Clear ALL documents and chunks from the knowledge base."""
315
+ from agentic_rag.config.settings import get_settings
316
+ from pymilvus import MilvusClient
317
+ ws = get_settings().workspace_dir
318
+ mc = MilvusClient(f"{ws}/milvus_lite.db")
319
+ if "knowledge" in (mc.list_collections() or []):
320
+ mc.drop_collection("knowledge")
321
+ from pymilvus import DataType
322
+ dim = get_settings().embedding.dim
323
+ schema = mc.create_schema(enable_dynamic_field=True)
324
+ schema.add_field("id", DataType.VARCHAR, max_length=64, is_primary=True)
325
+ schema.add_field("vector", DataType.FLOAT_VECTOR, dim=dim)
326
+ mc.create_collection("knowledge", schema=schema, dimension=dim)
327
+ mc.load_collection("knowledge")
328
+
329
+ # Clear in-memory state
330
+ from agentic_rag.services.knowledge.pipeline import get_knowledge_pipeline
331
+ from agentic_rag.services.knowledge.graph.index import KnowledgeGraph
332
+ pipe = get_knowledge_pipeline()
333
+ pipe._documents.clear()
334
+ pipe._content_cache.clear()
335
+ pipe._file_registry.clear()
336
+ # Reset knowledge graph
337
+ pipe.graph = KnowledgeGraph()
338
+ # Delete persisted graph file
339
+ import os
340
+ gpath = pipe._graph_path
341
+ if os.path.exists(gpath):
342
+ os.remove(gpath)
343
+
344
+ return {"status": "cleared", "message": "All knowledge base data has been removed."}
345
+
346
+
347
+ @router.post("/rag/ingest", response_model=RAGIngestResponse)
348
+ async def rag_ingest(req: RAGIngestRequest):
349
+ """Ingest a document into the knowledge base."""
350
+ from agentic_rag.orchestration.l1_tools.rag_tools import RAGIngestTool
351
+ from agentic_rag.services.knowledge.pipeline import get_knowledge_pipeline
352
+
353
+ tool = RAGIngestTool()
354
+ result = await tool.execute(content=req.content, source=req.source)
355
+
356
+ doc_id = _parse_doc_id(result)
357
+ # Register for cross-browser document listing
358
+ pipe = get_knowledge_pipeline()
359
+ pipe.register_file(doc_id, req.source or "text_ingest", len(req.content), "text")
360
+
361
+ return RAGIngestResponse(
362
+ doc_id=doc_id,
363
+ status="success",
364
+ )
365
+
366
+
367
+ # ── File Upload ─────────────────────────────────────────────────
368
+
369
+ MIME_TO_CATEGORY = {
370
+ # Text / documents
371
+ "text/plain": "text",
372
+ "text/markdown": "text",
373
+ "text/csv": "text",
374
+ "text/html": "text",
375
+ "application/json": "text",
376
+ "application/pdf": "text",
377
+ "application/x-yaml": "text",
378
+ # Images
379
+ "image/jpeg": "image",
380
+ "image/png": "image",
381
+ "image/gif": "image",
382
+ "image/webp": "image",
383
+ "image/bmp": "image",
384
+ "image/svg+xml": "image",
385
+ # Video
386
+ "video/mp4": "video",
387
+ "video/avi": "video",
388
+ "video/quicktime": "video",
389
+ "video/x-matroska": "video",
390
+ "video/webm": "video",
391
+ # Audio
392
+ "audio/mpeg": "audio",
393
+ "audio/wav": "audio",
394
+ "audio/mp4": "audio",
395
+ "audio/ogg": "audio",
396
+ "audio/flac": "audio",
397
+ "audio/x-m4a": "audio",
398
+ }
399
+
400
+ EXT_TO_CATEGORY = {
401
+ ".txt": "text", ".md": "text", ".markdown": "text",
402
+ ".json": "text", ".yaml": "text", ".yml": "text",
403
+ ".csv": "text", ".py": "text", ".html": "text",
404
+ ".pdf": "text", ".docx": "text", ".doc": "text",
405
+ ".jpg": "image", ".jpeg": "image", ".png": "image",
406
+ ".gif": "image", ".webp": "image", ".bmp": "image", ".svg": "image",
407
+ ".mp4": "video", ".avi": "video", ".mov": "video",
408
+ ".mkv": "video", ".webm": "video",
409
+ ".mp3": "audio", ".wav": "audio", ".m4a": "audio",
410
+ ".ogg": "audio", ".flac": "audio",
411
+ }
412
+
413
+
414
+ @router.post("/rag/upload")
415
+ async def rag_upload(
416
+ file: UploadFile = File(...),
417
+ source: str = Form("web_ui"),
418
+ ingest_mode: str = Form("multimodal"),
419
+ mm_method: str = Form("pure"),
420
+ chunk_size: int = Form(512),
421
+ chunk_overlap: int = Form(50),
422
+ enable_kg: bool = Form(False),
423
+ ):
424
+ """Upload a file for ingestion into the knowledge base.
425
+
426
+ Supports text files (.txt, .md, .pdf, .json, .yaml, .csv),
427
+ images (.jpg, .png, .gif, .webp), video (.mp4, .avi, .mov),
428
+ and audio (.mp3, .wav, .m4a).
429
+ """
430
+ import sys
431
+ print(f" [Upload] received: {file.filename} ({file.content_type}), source={source}, "
432
+ f"chunk={chunk_size}/{chunk_overlap}, kg={enable_kg}", flush=True)
433
+
434
+ # Detect content category
435
+ mime = file.content_type or ""
436
+ ext = Path(file.filename or "").suffix.lower()
437
+
438
+ category = MIME_TO_CATEGORY.get(mime) or EXT_TO_CATEGORY.get(ext)
439
+ if category is None:
440
+ # Fallback: try to read as text
441
+ category = "text"
442
+
443
+ # Save file to workspace temp directory
444
+ from agentic_rag.config.settings import get_settings
445
+ settings = get_settings()
446
+ workspace = Path(settings.workspace_dir) / "uploads"
447
+ workspace.mkdir(parents=True, exist_ok=True)
448
+
449
+ safe_name = f"{uuid.uuid4().hex}_{file.filename or 'upload'}"
450
+ file_path = workspace / safe_name
451
+
452
+ try:
453
+ content_bytes = await file.read()
454
+ file_path.write_bytes(content_bytes)
455
+ except Exception as e:
456
+ raise HTTPException(status_code=500, detail=f"Failed to save file: {e}")
457
+
458
+ file_size = len(content_bytes)
459
+
460
+ # Ingest based on category
461
+ from agentic_rag.orchestration.l1_tools.rag_tools import RAGIngestTool, RAGMultiModalIngestTool
462
+ from agentic_rag.services.knowledge.pipeline import get_knowledge_pipeline
463
+
464
+ try:
465
+ # Apply per-request pipeline settings
466
+ pipeline = get_knowledge_pipeline()
467
+ # Ensure bool conversion (FastAPI Form may deliver "true"/"false" strings)
468
+ kg_enabled = enable_kg if isinstance(enable_kg, bool) else str(enable_kg).lower() in ("true", "1", "yes", "on")
469
+ pipeline.ingest_mode = ingest_mode
470
+ pipeline.mm_method = mm_method
471
+ pipeline.enable_kg = kg_enabled
472
+ pipeline.extract_entities = kg_enabled # semantic extraction follows KG toggle
473
+ pipeline.chunk_size = int(chunk_size)
474
+ pipeline.chunk_overlap = int(chunk_overlap)
475
+ # Ensure LLM function is available for semantic extraction
476
+ if pipeline.extract_entities and pipeline.llm_func is None:
477
+ from agentic_rag.services.knowledge.pipeline import _create_kg_llm_func
478
+ pipeline.llm_func = _create_kg_llm_func()
479
+ # Reset processors so chunk settings take effect
480
+ pipeline._processors = {}
481
+
482
+ print(f" [Upload] pipeline config: mode={ingest_mode}/{mm_method}, "
483
+ f"chunk={pipeline.chunk_size}/{pipeline.chunk_overlap}, "
484
+ f"kg={pipeline.enable_kg}, extract_entities={pipeline.extract_entities}, "
485
+ f"llm={'OK' if pipeline.llm_func else 'MISSING'}", flush=True)
486
+
487
+ if category == "text":
488
+ # PDF / docx / office files → parse via pipeline (supports docling + fallback)
489
+ if ext in (".pdf", ".docx", ".doc", ".pptx", ".ppt", ".xlsx", ".xls"):
490
+ print(f" [Upload] parsing document via pipeline: {ext}", flush=True)
491
+ doc_id = await pipeline.ingest(source=str(file_path), source_type=ext.lstrip("."))
492
+ # Override stored source with original filename for display
493
+ cl = pipeline._documents.get(doc_id)
494
+ if cl:
495
+ cl.source = file.filename or str(file_path)
496
+ result = f"Content ingested successfully.\nDocument ID: {doc_id}\nSource: {source}"
497
+ else:
498
+ # Plain text files — pass pipeline explicitly so KG settings take effect
499
+ try:
500
+ text_content = content_bytes.decode("utf-8")
501
+ except UnicodeDecodeError:
502
+ text_content = content_bytes.decode("latin-1", errors="replace")
503
+
504
+ print(f" [Upload] text content: {len(text_content)} chars, "
505
+ f"first 80: {text_content[:80].replace(chr(10),' ')}", flush=True)
506
+
507
+ tool = RAGIngestTool(pipeline=pipeline)
508
+ result = await tool.execute(
509
+ content=text_content,
510
+ source=source or file.filename or "upload",
511
+ content_type="text",
512
+ )
513
+ print(f" [Upload] result: {str(result)[:120]}", flush=True)
514
+ print(f" [Upload] KG after ingest: entities={pipeline.graph.entity_count if pipeline.graph else 0}", flush=True)
515
+
516
+ elif category == "image":
517
+ tool = RAGMultiModalIngestTool(pipeline=pipeline)
518
+ result = await tool.execute(
519
+ images=[str(file_path)],
520
+ source=source or file.filename or "upload",
521
+ )
522
+
523
+ elif category == "video":
524
+ tool = RAGMultiModalIngestTool(pipeline=pipeline)
525
+ result = await tool.execute(
526
+ videos=[str(file_path)],
527
+ source=source or file.filename or "upload",
528
+ )
529
+
530
+ elif category == "audio":
531
+ tool = RAGMultiModalIngestTool(pipeline=pipeline)
532
+ result = await tool.execute(
533
+ audio_files=[str(file_path)],
534
+ source=source or file.filename or "upload",
535
+ )
536
+ else:
537
+ raise HTTPException(status_code=400, detail=f"Unsupported file category: {category}")
538
+
539
+ doc_id = _parse_doc_id(str(result))
540
+
541
+ # Register file metadata for cross-browser document listing
542
+ pipeline.register_file(doc_id, file.filename or "unknown", file_size, category)
543
+
544
+ return {
545
+ "doc_id": doc_id,
546
+ "filename": file.filename,
547
+ "content_type": category,
548
+ "size": file_size,
549
+ "status": "success",
550
+ }
551
+
552
+ except HTTPException:
553
+ raise
554
+ except Exception as e:
555
+ # Cleanup temp file on failure
556
+ if file_path.exists():
557
+ file_path.unlink()
558
+ raise HTTPException(status_code=500, detail=f"Ingestion failed: {e}")
agentic_rag/entrypoints/rest/routes/session.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Session management endpoints — backed by SQLite via SessionRepo."""
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+
5
+ from agentic_rag.data.db.session_repo import SessionRepo
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ def _repo() -> SessionRepo:
11
+ return SessionRepo()
12
+
13
+
14
+ @router.post("/session")
15
+ async def create_session(user_id: str = "default"):
16
+ """Create a new session."""
17
+ session = _repo().create(user_id=user_id)
18
+ return {"session_id": session["id"], "user_id": session["user_id"]}
19
+
20
+
21
+ @router.get("/session/{session_id}")
22
+ async def get_session(session_id: str):
23
+ """Get session details."""
24
+ session = _repo().get(session_id)
25
+ if not session:
26
+ raise HTTPException(status_code=404, detail="Session not found")
27
+ return session
28
+
29
+
30
+ @router.put("/session/{session_id}")
31
+ async def update_session(session_id: str, title: str = None):
32
+ """Update session metadata (e.g., title)."""
33
+ fields = {}
34
+ if title is not None:
35
+ fields["title"] = title
36
+ if not fields:
37
+ raise HTTPException(status_code=400, detail="No fields to update")
38
+ ok = _repo().update(session_id, **fields)
39
+ if not ok:
40
+ raise HTTPException(status_code=404, detail="Session not found")
41
+ return {"status": "updated"}
42
+
43
+
44
+ @router.get("/sessions")
45
+ async def list_sessions(user_id: str = "default"):
46
+ """List sessions for a user."""
47
+ return {"sessions": _repo().list(user_id=user_id)}
48
+
49
+
50
+ @router.delete("/sessions")
51
+ async def delete_all_sessions(user_id: str = "default"):
52
+ """Delete ALL sessions and messages for a user."""
53
+ count = _repo().delete_all(user_id=user_id)
54
+ return {"status": "deleted", "count": count}
55
+
56
+
57
+ @router.delete("/session/{session_id}/messages")
58
+ async def clear_session_messages(session_id: str):
59
+ """Clear all messages in a session (keep the session itself)."""
60
+ session = _repo().get(session_id)
61
+ if not session:
62
+ raise HTTPException(status_code=404, detail="Session not found")
63
+ count = _repo().clear_messages(session_id)
64
+ return {"status": "cleared", "count": count}
65
+
66
+
67
+ @router.delete("/session/{session_id}")
68
+ async def delete_session(session_id: str):
69
+ """Delete a session and its messages."""
70
+ ok = _repo().delete(session_id)
71
+ if not ok:
72
+ raise HTTPException(status_code=404, detail="Session not found")
73
+ return {"status": "deleted"}
74
+
75
+
76
+ @router.get("/session/{session_id}/messages")
77
+ async def get_messages(session_id: str):
78
+ """Get all messages for a session."""
79
+ session = _repo().get(session_id)
80
+ if not session:
81
+ raise HTTPException(status_code=404, detail="Session not found")
82
+ return {"messages": _repo().get_messages(session_id)}