272 lines
14 KiB
Python
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
|