feat: 新增 QMDSearch Agent Skill 包与下载入口
- 新增 skills/qmdsearch-agent(SKILL.md + references/api-examples.md),文档化 AI Agent 鉴权、文本/文件上传入库(异步轮询)与分层检索调用方式 - 打包 QMDSearch-Agent-Skill.zip 至 app/static/agent-skill/ - 后端新增 GET /agent-skill 免登录下载路由(随 ./app 卷挂载,restart 即生效) - 前端 API 说明页新增「AI Agent Skill 下载」卡片,链接至 /agent-skill
This commit is contained in:
+18
@@ -144,3 +144,21 @@ async def admin_spa(rest: str):
|
||||
return FileResponse(file_path)
|
||||
# SPA 客户端路由兜底
|
||||
return FileResponse(spa_dir / "index.html", media_type="text/html")
|
||||
|
||||
|
||||
# AI Agent Skill 压缩包(用于 Agent 上传文档与检索),随 ./app 卷挂载,restart 即生效
|
||||
_AGENT_SKILL_ZIP = _STATIC_DIR / "agent-skill" / "QMDSearch-Agent-Skill.zip"
|
||||
|
||||
|
||||
@app.get("/agent-skill", include_in_schema=False)
|
||||
async def agent_skill_download() -> FileResponse | JSONResponse:
|
||||
"""下载 AI Agent Skill 压缩包(qmdsearch-agent skill,含 SKILL.md 与示例)"""
|
||||
if not _AGENT_SKILL_ZIP.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404, content={"code": 1002, "message": "Skill 包不存在"}
|
||||
)
|
||||
return FileResponse(
|
||||
_AGENT_SKILL_ZIP,
|
||||
media_type="application/zip",
|
||||
filename="QMDSearch-Agent-Skill.zip",
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ApiOutlined, SafetyCertificateOutlined, CodeOutlined } from '@ant-design/icons-vue'
|
||||
import { ApiOutlined, SafetyCertificateOutlined, CodeOutlined, DownloadOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 以下内容整理自项目 README.md 的「API 文档」章节
|
||||
const apiList = [
|
||||
@@ -29,6 +29,8 @@ const methodColor = {
|
||||
|
||||
const baseUrl = computed(() => `${window.location.origin}/admin/`.replace(/\/admin\/$/, ''))
|
||||
|
||||
const agentSkillUrl = computed(() => `${window.location.origin}/agent-skill`)
|
||||
|
||||
const loginExample = `curl -X POST ${baseUrl.value}/api/v1/auth/login \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"username": "admin", "password": "your-password"}'`
|
||||
@@ -59,6 +61,23 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
|
||||
message="需要鉴权的接口请在请求头携带 Authorization: Bearer <token>,token 通过 /auth/login 获取。"
|
||||
/>
|
||||
|
||||
<!-- AI Agent Skill 下载 -->
|
||||
<section class="apidocs__block apidocs__skill">
|
||||
<h3 class="section-subtitle">
|
||||
<DownloadOutlined /> AI Agent Skill 下载
|
||||
</h3>
|
||||
<p class="apidocs__p">
|
||||
为 AI Agent 提供的接入包(含 <code>SKILL.md</code> 与 Python 客户端示例),覆盖文档上传(文本 / 文件)与分层检索。
|
||||
下载后解压到 Agent 的 skills 目录即可启用。
|
||||
</p>
|
||||
<a :href="agentSkillUrl" target="_blank" rel="noopener">
|
||||
<a-button type="primary">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
下载 QMDSearch-Agent-Skill.zip
|
||||
</a-button>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- 统一响应格式 -->
|
||||
<section class="apidocs__block">
|
||||
<h3 class="section-subtitle">
|
||||
@@ -152,6 +171,13 @@ const uploadExample = `curl -X POST ${baseUrl.value}/api/v1/documents/upload \\
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.apidocs__skill {
|
||||
padding: 16px 18px;
|
||||
border: 1px solid rgba(22, 119, 255, 0.25);
|
||||
border-radius: 10px;
|
||||
background: rgba(22, 119, 255, 0.04);
|
||||
}
|
||||
|
||||
.apidocs__block {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: qmdsearch-agent
|
||||
description: 通过 QMDSearch REST API 让 AI Agent 上传文档(文本/文件)并执行分层检索查询。配置服务地址与 Bearer Token 后,即可在对话中入库知识库并检索答案。
|
||||
metadata: {"clawdbot":{"emoji":"📚"}}
|
||||
---
|
||||
|
||||
# QMDSearch Agent 接入 Skill
|
||||
|
||||
本 skill 指导 AI Agent 调用 **QMDSearch**(面向 AI Agent 的分层信息检索服务)的 HTTP API,完成两类核心操作:
|
||||
|
||||
1. **上传文档**:把文本或文件送入知识库(异步入库,自动三级总结 + 向量化)。
|
||||
2. **查询 / 检索**:对知识库做分层语义检索,返回最相关原文 chunk。
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 用户希望把一份文档 / 笔记 / 网页内容加入知识库 → 调用上传接口。
|
||||
- 用户就知识库内容提问 → 调用检索接口。
|
||||
- 用户想确认文档是否入库成功 → 轮询任务状态。
|
||||
- 需要列出 / 删除知识库文档、查看统计 → 调用对应只读 / 管理接口。
|
||||
|
||||
## 前置配置(必须)
|
||||
|
||||
启用前先确定:
|
||||
|
||||
- `QMDSEARCH_BASE_URL`:服务基地址,例如 `http://localhost:8000` 或 NAS 地址 `http://<nas-ip>:8000`。
|
||||
- `QMDSEARCH_TOKEN`:Bearer Token。通过 `POST {BASE}/api/v1/auth/login`(用户名 + 密码)获取;session TTL 12h,失效后重新登录。
|
||||
|
||||
> 所有变更类请求(上传 / 删除)与检索请求都必须在 Header 携带 `Authorization: Bearer <token>`。
|
||||
> 当前代码实现中,查询类只读接口(文档列表 / 详情 / 类目 / 统计 / 健康)同样要求 Bearer(与登录态绑定),请始终携带 token 以避免 `1005` 未认证。
|
||||
|
||||
## 统一约定
|
||||
|
||||
- 路径前缀:`{BASE}/api/v1`
|
||||
- 响应体:`{ "code": 0, "data": {...}, "message": "ok" }`,`code=0` 成功;`1xxx` 客户端错误;`2xxx` 服务端错误。
|
||||
- 入库是**异步**的:上传返回 `202 + task_id`,需轮询 `GET /documents/tasks/{task_id}` 直到 `status=done`(或 `failed`)。
|
||||
|
||||
## 1. 获取 Token
|
||||
|
||||
```bash
|
||||
curl -X POST {BASE}/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"<password>"}'
|
||||
# → {"code":0,"data":{"token":"<JWT>","username":"admin","role":"admin","must_change_password":false},"message":"ok"}
|
||||
```
|
||||
|
||||
`must_change_password=true` 时,登录成功但调用其他接口会返回 `1006`,需先 `POST /api/v1/auth/password` 改密。
|
||||
|
||||
## 2. 上传文档(文本)
|
||||
|
||||
`POST {BASE}/api/v1/documents`,JSON body `{text, title?, source?, metadata?}`。
|
||||
|
||||
```bash
|
||||
curl -X POST {BASE}/api/v1/documents \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text":"QMDSearch 支持 L1/L2/L3 分层检索……", "title":"QMDSearch 介绍", "source":"notes"}'
|
||||
# → 202 {"code":0,"data":{"task_id":"<uuid>","status":"pending"},"message":"ok"}
|
||||
```
|
||||
|
||||
- `text` 必填且非空;`title` / `source` 可选;`metadata` 为 `dict[str,str]`。
|
||||
- 相同 `sha256(text)` 会命中**去重**,直接复用旧文档(`data` 中 `deduplicated=true`)。
|
||||
|
||||
## 3. 上传文件
|
||||
|
||||
`POST {BASE}/api/v1/documents/upload`,`multipart/form-data`:
|
||||
|
||||
- `file`:必填,支持 `.txt .md .html .htm .pdf .docx`
|
||||
- `title` / `source`:可选(默认取原文件名)
|
||||
- `metadata`:可选 JSON 字符串
|
||||
|
||||
```bash
|
||||
curl -X POST {BASE}/api/v1/documents/upload \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-F "file=@document.pdf" \
|
||||
-F "source=manual"
|
||||
# → 202 {"code":0,"data":{"task_id":"<uuid>","status":"pending","saved_path":"..."},"message":"ok"}
|
||||
```
|
||||
|
||||
服务端按扩展名提取纯文本(PDF 扫描件自动 OCR 降级),再复用同一入库流水线。
|
||||
|
||||
## 4. 轮询入库任务
|
||||
|
||||
`GET {BASE}/api/v1/documents/tasks/{task_id}`(无需鉴权):
|
||||
|
||||
```bash
|
||||
curl {BASE}/api/v1/documents/tasks/<task_id>
|
||||
# done → {"code":0,"data":{"task_id":...,"status":"done","result":{...文档ID/总结/分类...},"created_at":...,"updated_at":...}}
|
||||
# failed → {"code":0,"data":{"task_id":...,"status":"failed","error":"..."}}
|
||||
```
|
||||
|
||||
建议:提交后每 1–2s 轮询,直到 `status∈{done,failed}`;`done` 的 `result.document_id` 即入库文档 ID。
|
||||
|
||||
## 5. 检索(查询)
|
||||
|
||||
`POST {BASE}/api/v1/search`,JSON `{query, top_k?, summarize?}`:
|
||||
|
||||
```bash
|
||||
curl -X POST {BASE}/api/v1/search \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"如何配置 Redis 缓存","top_k":5,"summarize":false}'
|
||||
```
|
||||
|
||||
返回 `data`:`{query, hits[], routed_categories[], fallback, extracted_info, summary?}`。
|
||||
每个 `hit`:`{text, doc_id, title, section_path, score, doc_summary}`。
|
||||
|
||||
- `top_k`:返回条数(默认 `RETRIEVAL_FINAL_K`,通常 5)。
|
||||
- `summarize=true`:额外返回 `summary`(对检索结果做 AI 总结)。
|
||||
- `fallback=true`:走了全库兜底(未走类目路由),召回较广。
|
||||
- `extracted_info`:query 解析出的关键词 / 实体 / 意图 / 命中类目。
|
||||
|
||||
## 6. 其他管理接口(按需)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/documents` | 文本入库(202 + task_id) |
|
||||
| POST | `/documents/upload` | 文件入库(202 + task_id) |
|
||||
| GET | `/documents/tasks/{task_id}` | 任务状态(done / failed) |
|
||||
| GET | `/documents?limit=&offset=` | 文档列表(分页游标) |
|
||||
| GET | `/documents/{doc_id}` | 文档详情(L1 + L2/L3 + chunks 数) |
|
||||
| GET | `/documents/{doc_id}/file` | 下载原始文件 |
|
||||
| DELETE | `/documents/{doc_id}` | 删除文档(幂等) |
|
||||
| GET | `/knowledge/categories` | 知识分类类目集 |
|
||||
| GET | `/knowledge/stats` | 统计(四层点数 + 类目分布) |
|
||||
| GET | `/health` | 健康检查 |
|
||||
|
||||
## 错误码速查
|
||||
|
||||
- `1001` 参数 / 格式错误(空文本、不支持的类型、弱密码、超大小)
|
||||
- `1004` 资源不存在(task / doc 不存在)
|
||||
- `1005` 未认证或凭证无效 / 账号已禁用
|
||||
- `1006` 权限不足 / 首次登录须先改密
|
||||
- `2000` 服务端内部错误(检索 / 入库 / 列表失败)
|
||||
- `2001` 认证服务暂不可用
|
||||
|
||||
## Agent 最佳实践
|
||||
|
||||
1. **先登录拿 token**,缓存 12h,失效再用 `/auth/login` 刷新。
|
||||
2. **上传走异步**:拿到 `task_id` 后轮询,不要在对话里阻塞等待,可告诉用户“正在入库,稍后查询”。
|
||||
3. **去重友好**:重复提交同文本会自动复用,不必担心重复。
|
||||
4. **大文件 / 批量**:逐文件上传并各自轮询;注意 `UPLOAD_MAX_SIZE_MB`(默认 20MB)。
|
||||
5. **检索调参**:召回不足调大 `top_k`;需要摘要设 `summarize=true`;关注 `fallback` 判断是否需要改写 query。
|
||||
6. **凭证安全**:token 等同会话,不要写入日志或外发。
|
||||
|
||||
完整请求 / 响应示例(含 Python 客户端封装)见 `references/api-examples.md`。
|
||||
@@ -0,0 +1,170 @@
|
||||
# QMDSearch API 示例与 Python 客户端封装
|
||||
|
||||
> 基址占位符 `{BASE}` 替换为实际服务地址,例如 `http://localhost:8000` 或 `http://<nas-ip>:8000`。
|
||||
|
||||
## 一、curl 示例
|
||||
|
||||
### 登录
|
||||
|
||||
```bash
|
||||
curl -s -X POST {BASE}/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"<password>"}'
|
||||
```
|
||||
|
||||
### 文本入库(202 + task_id)
|
||||
|
||||
```bash
|
||||
curl -s -X POST {BASE}/api/v1/documents \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text":"QMDSearch 是面向 AI Agent 的分层信息检索服务……","title":"QMDSearch 介绍","source":"notes"}'
|
||||
```
|
||||
|
||||
### 文件上传(202 + task_id)
|
||||
|
||||
```bash
|
||||
curl -s -X POST {BASE}/api/v1/documents/upload \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-F "file=@document.pdf" \
|
||||
-F "source=manual"
|
||||
```
|
||||
|
||||
### 轮询任务状态
|
||||
|
||||
```bash
|
||||
curl -s {BASE}/api/v1/documents/tasks/<task_id>
|
||||
```
|
||||
|
||||
### 检索
|
||||
|
||||
```bash
|
||||
curl -s -X POST {BASE}/api/v1/search \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"如何配置 Redis 缓存","top_k":5,"summarize":false}'
|
||||
```
|
||||
|
||||
## 二、Python 客户端封装
|
||||
|
||||
可直接在 Agent 工具代码里复用:
|
||||
|
||||
```python
|
||||
import time
|
||||
import requests
|
||||
|
||||
|
||||
class QMDSearchClient:
|
||||
"""QMDSearch 最小客户端:登录、入库(文本/文件)、轮询、检索。"""
|
||||
|
||||
def __init__(self, base_url: str, username: str, password: str,
|
||||
poll_interval: float = 1.5, poll_timeout: float = 120.0):
|
||||
self.base = base_url.rstrip("/")
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.poll_interval = poll_interval
|
||||
self.poll_timeout = poll_timeout
|
||||
self.token: str | None = None
|
||||
|
||||
# ---- 鉴权 ----
|
||||
def login(self) -> str:
|
||||
r = requests.post(
|
||||
f"{self.base}/api/v1/auth/login",
|
||||
json={"username": self.username, "password": self.password},
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
if body.get("code") != 0:
|
||||
raise RuntimeError(f"登录失败: {body}")
|
||||
self.token = body["data"]["token"]
|
||||
return self.token
|
||||
|
||||
def _headers(self) -> dict:
|
||||
if not self.token:
|
||||
self.login()
|
||||
return {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
def _ok(self, resp: requests.Response):
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
if body.get("code") != 0:
|
||||
raise RuntimeError(f"API 错误 code={body.get('code')} msg={body.get('message')}")
|
||||
return body["data"]
|
||||
|
||||
# ---- 入库 ----
|
||||
def ingest_text(self, text: str, title: str = "", source: str = "",
|
||||
metadata: dict | None = None) -> str:
|
||||
data = {"text": text}
|
||||
if title:
|
||||
data["title"] = title
|
||||
if source:
|
||||
data["source"] = source
|
||||
if metadata:
|
||||
data["metadata"] = {str(k): str(v) for k, v in metadata.items()}
|
||||
resp = requests.post(f"{self.base}/api/v1/documents",
|
||||
headers=self._headers(), json=data, timeout=30)
|
||||
return self._ok(resp)["task_id"]
|
||||
|
||||
def upload_file(self, path: str, title: str = "", source: str = "",
|
||||
metadata: dict | None = None) -> str:
|
||||
files = {"file": open(path, "rb")}
|
||||
data = {}
|
||||
if title:
|
||||
data["title"] = title
|
||||
if source:
|
||||
data["source"] = source
|
||||
if metadata:
|
||||
data["metadata"] = str({str(k): str(v) for k, v in metadata.items()})
|
||||
resp = requests.post(f"{self.base}/api/v1/documents/upload",
|
||||
headers=self._headers(), files=files, data=data, timeout=60)
|
||||
return self._ok(resp)["task_id"]
|
||||
|
||||
# ---- 轮询 ----
|
||||
def wait_task(self, task_id: str) -> dict:
|
||||
deadline = time.time() + self.poll_timeout
|
||||
while time.time() < deadline:
|
||||
resp = requests.get(f"{self.base}/api/v1/documents/tasks/{task_id}", timeout=30)
|
||||
data = self._ok(resp)
|
||||
if data["status"] == "done":
|
||||
return data["result"]
|
||||
if data["status"] == "failed":
|
||||
raise RuntimeError(f"入库失败: {data.get('error')}")
|
||||
time.sleep(self.poll_interval)
|
||||
raise TimeoutError(f"任务 {task_id} 轮询超时")
|
||||
|
||||
def ingest_text_wait(self, *args, **kwargs) -> dict:
|
||||
return self.wait_task(self.ingest_text(*args, **kwargs))
|
||||
|
||||
def upload_file_wait(self, *args, **kwargs) -> dict:
|
||||
return self.wait_task(self.upload_file(*args, **kwargs))
|
||||
|
||||
# ---- 检索 ----
|
||||
def search(self, query: str, top_k: int | None = None,
|
||||
summarize: bool = False) -> dict:
|
||||
payload = {"query": query, "summarize": summarize}
|
||||
if top_k is not None:
|
||||
payload["top_k"] = top_k
|
||||
resp = requests.post(f"{self.base}/api/v1/search",
|
||||
headers=self._headers(), json=payload, timeout=60)
|
||||
return self._ok(resp)
|
||||
|
||||
|
||||
# 用法
|
||||
if __name__ == "__main__":
|
||||
client = QMDSearchClient("http://localhost:8000", "admin", "<password>")
|
||||
client.login()
|
||||
# 文本入库并等待完成
|
||||
result = client.ingest_text_wait("这是一篇关于分层检索的笔记……", title="笔记")
|
||||
print("document_id =", result["document_id"])
|
||||
# 检索
|
||||
hits = client.search("分层检索是什么", top_k=5)["hits"]
|
||||
for h in hits:
|
||||
print(f"[{h['score']:.3f}] {h['title']}: {h['text'][:80]}")
|
||||
```
|
||||
|
||||
## 三、响应结构速记
|
||||
|
||||
- 文本 / 文件入库 `202` → `data = {task_id, status:"pending"[, saved_path]}`
|
||||
- 任务 `done` → `data.result = {document_id, summary:{l1_summary,l2_outline,l3_content_outline,level}, category, chunks_count, tags, category_confidence, deduplicated}`
|
||||
- 检索 → `data = {query, hits:[{text,doc_id,title,section_path,score,doc_summary}], routed_categories, fallback, extracted_info, summary?}`
|
||||
Reference in New Issue
Block a user