Files
kefu/wechat_rpa/knowledge_api.py
T
2026-09-21 10:34:06 +08:00

272 lines
14 KiB
Python

"""Tenant-scoped knowledge management routes."""
from __future__ import annotations
import json
from contextlib import contextmanager
from typing import Any, Literal
import httpx
from fastapi import Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field, field_validator
from archive_store import tenant_scope
from knowledge_retriever import KnowledgeRetriever, VectorIndex
from knowledge_store import KnowledgeStore
class JobBody(BaseModel):
source_account_id: str = Field(min_length=1, max_length=64)
conversation_id: str = Field(default="", max_length=64)
date_from: str = ""
date_to: str = ""
engine: Literal["rules", "model"] = "rules"
model_provider_id: str = Field(default="", max_length=128)
model_timeout_seconds: int = Field(default=90, ge=30, le=180)
max_messages: int = Field(default=10000, ge=1, le=1000000)
max_model_calls: int = Field(default=100, ge=1, le=10000)
staff_confirmed: bool = False
auto_watch: bool = False
class JobConfigBody(BaseModel):
engine: Literal['rules', 'model']
model_provider_id: str = Field(default='', max_length=128)
model_timeout_seconds: int = Field(default=90, ge=30, le=180)
max_model_calls: int = Field(default=100, ge=1, le=10000)
class EditBody(BaseModel):
revision: int = Field(ge=1)
title: str = Field(min_length=1, max_length=160)
question: str = Field(min_length=1, max_length=4000)
answer: str = Field(min_length=1, max_length=8000)
conditions: str = Field(min_length=1, max_length=2000)
category: str = Field(default="", max_length=100)
kind: Literal["qa", "procedure", "case"] = "qa"
valid_until: str = ""
class ActionBody(BaseModel):
revision: int = Field(ge=1)
confirmed: bool = False
restore_revision: int = Field(default=0, ge=0)
class SearchBody(BaseModel):
query: str = Field(min_length=2, max_length=2000)
@field_validator('query', mode='before')
@classmethod
def trim_query(cls, value):
return value.strip() if isinstance(value, str) else value
class ReviewConfirmBody(BaseModel):
confirmed: bool = False
class SettingsBody(BaseModel):
enabled: bool
@contextmanager
def errors():
try:
yield
except KeyError as exc:
raise HTTPException(404, str(exc).strip("'")) from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
except httpx.HTTPError as exc:
raise HTTPException(502, "知识索引服务连接失败,当前知识未发布,请检查服务配置后重试") from exc
def register_knowledge_routes(app, database, require, client_ip, resolve_admin_tenant, current_desktop):
store = KnowledgeStore(database)
store.initialize()
app.state.knowledge_store = store
def single(principal, account_id):
scope = resolve_admin_tenant(principal, account_id)
if not isinstance(scope, str) or scope == "*":
raise HTTPException(400, "请先选择具体的客户端账号")
return scope
from knowledge_review import KnowledgeReviewStore
reviews = KnowledgeReviewStore(store)
@app.post('/api/v2/knowledge/review-tasks/preview')
def preview_reviews(request: Request, account_id: int = -1,
principal=Depends(require('knowledge:read', 'knowledge:review', 'im:content:read'))):
with errors():
return reviews.preview(resolve_admin_tenant(principal, account_id), account_id, principal.id, client_ip(request))
@app.get('/api/v2/knowledge/review-tasks')
def review_tasks(principal=Depends(require('knowledge:read', 'knowledge:review', 'im:content:read'))):
return {'items': reviews.jobs(principal.id)}
@app.get('/api/v2/knowledge/review-tasks/{job_id}/results')
def review_results(job_id: str, result: str = 'skipped', page: int = Query(default=1, ge=1),
page_size: int = Query(default=30, ge=1, le=100),
principal=Depends(require('knowledge:read', 'knowledge:review', 'im:content:read'))):
with errors():
return reviews.results(job_id, principal.id, resolve_admin_tenant(principal, -1), result, page, page_size)
@app.get('/api/v2/knowledge/review-tasks/{job_id}')
def review_task(job_id: str, principal=Depends(require('knowledge:read', 'knowledge:review', 'im:content:read'))):
with errors():
return reviews.get(job_id, principal.id)
@app.post('/api/v2/knowledge/review-tasks/{job_id}/{action}')
def review_action(job_id: str, action: str, body: ReviewConfirmBody, request: Request,
principal=Depends(require('knowledge:read', 'knowledge:review', 'im:content:read'))):
with errors():
return reviews.action(job_id, action, principal.id, client_ip(request), body.confirmed)
@app.get("/api/v2/knowledge/overview")
def overview(account_id: int | None = None, principal=Depends(require("knowledge:read"))):
scope = resolve_admin_tenant(principal, account_id)
ts = tenant_scope(scope)
with database.connect() as db:
accounts = [dict(r) for r in db.execute(
f"SELECT id,tenant_id,display_name,external_account_id FROM archive_source_account WHERE {ts.clause()}", ts.params)]
return {"counts": store.stats(scope), "source_accounts": accounts, "watches": store.watches(scope),
"settings": store.settings(scope) if isinstance(scope, str) and scope != "*" else None,
"semantic_configured": VectorIndex().configured}
@app.put("/api/v2/knowledge/settings")
def settings(body: SettingsBody, request: Request, account_id: int | None = None,
principal=Depends(require("knowledge:publish"))):
with errors():
return store.set_enabled(single(principal, account_id), body.enabled, principal.id, client_ip(request))
@app.put("/api/v2/knowledge/watch/{source_id}")
def watch(source_id: str, body: SettingsBody, request: Request, account_id: int | None = None,
principal=Depends(require("knowledge:write", "im:content:read"))):
with errors():
return store.set_watch(single(principal, account_id), source_id, body.enabled, principal.id, client_ip(request))
@app.get('/api/v2/knowledge/models')
def models(account_id: int | None = None, principal=Depends(require('knowledge:write', 'im:content:read'))):
single(principal, account_id)
from knowledge_models import model_choices
return {'items': model_choices(database)}
@app.put('/api/v2/knowledge/jobs/{job_id}/config')
def job_config(job_id: str, body: JobConfigBody, request: Request, account_id: int | None = None,
principal=Depends(require('knowledge:write', 'im:content:read'))):
with errors():
return store.configure_job(single(principal, account_id), job_id, body.model_dump(), principal.id, client_ip(request))
@app.get("/api/v2/knowledge/jobs")
def jobs(account_id: int | None = None, principal=Depends(require("knowledge:read"))):
return {"items": store.jobs(resolve_admin_tenant(principal, account_id))}
@app.post("/api/v2/knowledge/jobs/preview")
def preview_job(body: JobBody, account_id: int | None = None,
principal=Depends(require("knowledge:write", "im:content:read"))):
with errors():
return store.preview_job(single(principal, account_id), body.model_dump())
@app.post("/api/v2/knowledge/jobs")
def create_job(body: JobBody, request: Request, account_id: int | None = None,
principal=Depends(require("knowledge:write", "im:content:read"))):
with errors():
return store.create_job(single(principal, account_id), body.model_dump(), principal.id, client_ip(request))
@app.post("/api/v2/knowledge/jobs/{job_id}/{action}")
def job_action(job_id: str, action: str, request: Request, account_id: int | None = None,
principal=Depends(require("knowledge:write", "im:content:read"))):
with errors():
return store.job_action(single(principal, account_id), job_id, action, principal.id, client_ip(request))
@app.get("/api/v2/knowledge/items")
def items(account_id: int | None = None, status: str = "", query: str = "",
page: int = Query(default=1, ge=1), page_size: int = Query(default=30, ge=1, le=100),
principal=Depends(require("knowledge:read"))):
return store.list_items(resolve_admin_tenant(principal, account_id), status, query, page, page_size)
@app.get("/api/v2/knowledge/items/{item_id}")
def detail(item_id: str, account_id: int | None = None, principal=Depends(require("knowledge:read"))):
with errors():
return store.detail(single(principal, account_id), item_id, sources="im:content:read" in principal.permissions)
@app.put("/api/v2/knowledge/items/{item_id}")
def edit(item_id: str, body: EditBody, request: Request, account_id: int | None = None,
principal=Depends(require("knowledge:write", "im:content:read"))):
with errors():
return store.save(single(principal, account_id), item_id, body.model_dump(), principal.id, client_ip(request))
@app.post("/api/v2/knowledge/items/{item_id}/{action}")
def transition(item_id: str, action: str, body: ActionBody, request: Request,
account_id: int | None = None, principal=Depends(require("knowledge:read"))):
permission = "knowledge:publish" if action in {"publish", "disable"} else (
"knowledge:write" if action in {"restore", "delete"} else "knowledge:review")
if permission not in principal.permissions or "im:content:read" not in principal.permissions:
raise HTTPException(403, "缺少知识操作或来源正文查看权限")
tenant = single(principal, account_id)
with errors():
if action == 'delete':
if not body.confirmed:
raise ValueError('请确认删除已驳回知识,原始聊天归档会保留')
return store.delete_rejected(tenant, item_id, body.revision, principal.id, client_ip(request))
if action == "publish":
return store.publish(tenant, item_id, body.revision, principal.id, client_ip(request))
if action == "restore":
with database.connect() as db:
store._item(db, tenant, item_id)
previous = db.execute("SELECT snapshot_json FROM knowledge_revision WHERE item_id=? AND revision=? "
"ORDER BY created_at DESC LIMIT 1", (item_id, body.restore_revision)).fetchone()
if previous is None:
raise KeyError("历史版本不存在")
values = json.loads(previous[0])
values["revision"] = body.revision
return store.save(tenant, item_id, values, principal.id, client_ip(request))
return store.transition(tenant, item_id, body.revision, action, principal.id, client_ip(request), body.confirmed)
@app.post("/api/v2/knowledge/search")
def search(body: SearchBody, account_id: int | None = None, principal=Depends(require("knowledge:read"))):
return KnowledgeRetriever(store).search(single(principal, account_id), body.query)
@app.get("/api/v2/knowledge/retrievals")
def retrievals(account_id: int | None = None, principal=Depends(require("knowledge:read", "stats:read"))):
scope = tenant_scope(resolve_admin_tenant(principal, account_id))
with database.connect() as db:
rows = db.execute(f"SELECT * FROM knowledge_retrieval_log WHERE {scope.clause()} "
"ORDER BY created_at DESC LIMIT 100", scope.params).fetchall()
return {"items": [{**dict(r), "hits": json.loads(r["hits_json"])} for r in rows]}
@app.get("/api/v2/knowledge/export")
def export(request: Request, account_id: int | None = None,
principal=Depends(require("knowledge:read", "knowledge:export"))):
tenant = single(principal, account_id)
with database.connect() as db:
database._audit(db, principal.id, "knowledge.export", f"tenant={tenant}", client_ip(request))
def stream():
cursor = ""
while True:
with database.connect() as db:
rows = db.execute("SELECT * FROM knowledge_item WHERE tenant_id=? AND status='published' AND id>? "
"AND (valid_until='' OR valid_until>=?) ORDER BY id LIMIT 200",
(tenant, cursor, KnowledgeRetriever._today())).fetchall()
if not rows:
return
cursor = rows[-1]["id"]
rows = [r for r in rows if store._fresh(db, r["id"], tenant)]
for row in rows:
yield json.dumps({"schema_version": "knowledge-v1", **{k: row[k] for k in
("id", "revision", "title", "question", "answer", "conditions", "category", "kind", "valid_until")}},
ensure_ascii=False) + "\n"
return StreamingResponse(stream(), media_type="application/x-ndjson",
headers={"Content-Disposition": 'attachment; filename="knowledge.jsonl"'})
@app.get("/api/v2/knowledge/desktop/summary")
def desktop_summary(account: Any = Depends(current_desktop)):
tenant = str(account["tenant_id"])
counts = store.stats(tenant)
return {"published": counts.get("published", 0), "enabled": bool(store.settings(tenant)["enabled"])}
return store