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:
@@ -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