453 lines
23 KiB
Python
453 lines
23 KiB
Python
"""Tenant-scoped knowledge management routes."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
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)
|
|
model_concurrency: int = Field(default=4, ge=1, le=16)
|
|
model_filter: bool = True
|
|
# 0 = 不限
|
|
max_messages: int = Field(default=10000, ge=0, le=100000000)
|
|
max_model_calls: int = Field(default=100, ge=0, le=10000000)
|
|
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)
|
|
model_concurrency: int = Field(default=4, ge=1, le=16)
|
|
model_batch: int | None = Field(default=None, ge=1, le=20)
|
|
model_filter: bool = True
|
|
max_model_calls: int = Field(default=100, ge=0, le=10000000)
|
|
|
|
|
|
class RunScopeBody(BaseModel):
|
|
"""整理范围:不选账号 = 全部授权账号。"""
|
|
account_ids: list[int] = Field(default_factory=list, max_length=500)
|
|
date_from: str = ""
|
|
date_to: str = ""
|
|
|
|
|
|
class RunBody(RunScopeBody):
|
|
engine: Literal["rules", "model"] = "model"
|
|
model_provider_id: str = Field(default="", max_length=128)
|
|
model_timeout_seconds: int = Field(default=90, ge=30, le=180)
|
|
model_concurrency: int = Field(default=4, ge=1, le=16)
|
|
model_batch: int = Field(default=8, ge=1, le=20)
|
|
model_filter: bool = True
|
|
max_model_calls: int = Field(default=0, ge=0, le=10000000)
|
|
staff_confirmed: bool = False
|
|
auto_watch: bool = False
|
|
|
|
|
|
class RunConfigBody(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)
|
|
model_concurrency: int = Field(default=4, ge=1, le=16)
|
|
model_batch: int = Field(default=8, ge=1, le=20)
|
|
model_filter: bool = True
|
|
max_model_calls: int = Field(default=0, ge=0, le=10000000)
|
|
|
|
|
|
class KnowledgeConfigBody(BaseModel):
|
|
default_engine: Literal["rules", "model"] = "model"
|
|
default_model_provider_id: str = Field(default="", max_length=128)
|
|
model_concurrency: int = Field(default=4, ge=1, le=16)
|
|
model_batch: int = Field(default=8, ge=1, le=20)
|
|
model_filter: bool = True
|
|
incremental_engine: Literal["rules", "model"] = "rules"
|
|
|
|
|
|
class ModelTestBody(BaseModel):
|
|
model_provider_id: str = Field(default="", max_length=128)
|
|
model_timeout_seconds: int = Field(default=90, ge=30, le=180)
|
|
|
|
|
|
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
|
|
|
|
|
|
# 单条知识的操作 → 对应按钮的权限码。都要能看来源聊天原文(审核的证据)。
|
|
ITEM_ACTIONS = {
|
|
"approve": "knowledge:review",
|
|
"reject": "knowledge:reject",
|
|
"publish": "knowledge:publish",
|
|
"disable": "knowledge:disable",
|
|
"restore": "knowledge:restore",
|
|
"delete": "knowledge:delete",
|
|
}
|
|
|
|
|
|
def register_knowledge_routes(app, database, require, client_ip, resolve_admin_tenant, current_desktop,
|
|
require_any=None):
|
|
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
|
|
|
|
def item_tenant(principal, account_id, item_id):
|
|
"""单条知识归哪个账号:选了具体账号就是它;「全部账号」下按知识自己的账号,并且必须在授权范围内。
|
|
不在范围内一律报"不存在",不透露别的账号有哪些知识。"""
|
|
scope = resolve_admin_tenant(principal, account_id)
|
|
if isinstance(scope, str) and scope != "*":
|
|
return scope
|
|
with database.connect() as db:
|
|
row = db.execute("SELECT tenant_id FROM knowledge_item WHERE id=?", (item_id,)).fetchone()
|
|
allowed = tenant_scope(scope)
|
|
if row is None or not (allowed.everything or row[0] in allowed.tenants):
|
|
raise HTTPException(404, "知识不存在或不在你的授权范围内")
|
|
return row[0]
|
|
|
|
from knowledge_review import KnowledgeReviewStore
|
|
reviews = KnowledgeReviewStore(store)
|
|
|
|
def run_tenants(principal, account_ids):
|
|
"""整理范围 → 租户列表。不选账号 = 授权范围内全部有聊天归档的账号。"""
|
|
if not account_ids:
|
|
return store.scope_tenants(resolve_admin_tenant(principal, -1)), "全部账号"
|
|
tenants = []
|
|
for account_id in dict.fromkeys(account_ids):
|
|
tenant = resolve_admin_tenant(principal, int(account_id))
|
|
if isinstance(tenant, str) and tenant != "*":
|
|
tenants.append(tenant)
|
|
return tenants, f"{len(tenants)} 个账号"
|
|
|
|
# ── 整理批次:一次整理多个账号、全部聊天 ─────────────────────────────────
|
|
@app.post('/api/v2/knowledge/runs/preview')
|
|
def preview_run(body: RunScopeBody, principal=Depends(require('knowledge:run', 'im:content:read'))):
|
|
with errors():
|
|
tenants, label = run_tenants(principal, body.account_ids)
|
|
return {**store.preview_run(tenants, body.model_dump()), 'scope_label': label}
|
|
|
|
@app.post('/api/v2/knowledge/runs')
|
|
def create_run(body: RunBody, request: Request, principal=Depends(require('knowledge:run', 'im:content:read'))):
|
|
if body.auto_watch and 'knowledge:watch' not in principal.permissions:
|
|
raise HTTPException(403, '缺少权限:knowledge:watch') # 「新聊天也自动整理」是另一个按钮
|
|
with errors():
|
|
tenants, label = run_tenants(principal, body.account_ids)
|
|
values = body.model_dump(exclude={'account_ids'})
|
|
return store.create_run(tenants, values, principal.id, client_ip(request), scope_label=label)
|
|
|
|
@app.get('/api/v2/knowledge/runs')
|
|
def runs(principal=Depends(require('knowledge:read'))):
|
|
scope = resolve_admin_tenant(principal, -1)
|
|
return {**store.runs(scope), 'auto': store.auto_summary(scope)}
|
|
|
|
@app.put('/api/v2/knowledge/runs/{run_id}/config')
|
|
def configure_run(run_id: str, body: RunConfigBody, request: Request,
|
|
principal=Depends(require('knowledge:run:control', 'im:content:read'))):
|
|
with errors():
|
|
return store.configure_run(resolve_admin_tenant(principal, -1), run_id, body.model_dump(),
|
|
principal.id, client_ip(request))
|
|
|
|
@app.post('/api/v2/knowledge/runs/{run_id}/{action}')
|
|
def run_action(run_id: str, action: str, request: Request,
|
|
principal=Depends(require('knowledge:run:control', 'im:content:read'))):
|
|
with errors():
|
|
return store.run_action(resolve_admin_tenant(principal, -1), run_id, action, principal.id, client_ip(request))
|
|
|
|
@app.get('/api/v2/knowledge/config')
|
|
def get_config(principal=Depends(require('knowledge:read'))):
|
|
return store.config()
|
|
|
|
@app.put('/api/v2/knowledge/config')
|
|
def put_config(body: KnowledgeConfigBody, request: Request,
|
|
principal=Depends(require('knowledge:settings'))):
|
|
with errors():
|
|
return store.save_config(body.model_dump(), principal.id, client_ip(request))
|
|
|
|
@app.post('/api/v2/knowledge/models/test')
|
|
async def test_model(body: ModelTestBody, principal=Depends(require_any('knowledge:run', 'knowledge:settings'))):
|
|
"""用一条虚构问答试一次整理,确认模型能连通、能按格式输出。不读任何真实聊天。"""
|
|
from knowledge_model_output import ModelOutputError, normalize_output
|
|
from knowledge_models import SAMPLE, extract, resolve_model, safe_model_error
|
|
from model_usage import UsageRecorder
|
|
options = {'model_provider_id': body.model_provider_id, 'model_timeout_seconds': body.model_timeout_seconds}
|
|
try:
|
|
outlet = await asyncio.to_thread(resolve_model, database, options)
|
|
except ValueError as exc:
|
|
raise HTTPException(400, str(exc)) from exc
|
|
outlet.config = {**outlet.config, 'timeout_ms': body.model_timeout_seconds * 1000}
|
|
candidate = {**SAMPLE, 'title': SAMPLE['question'], 'conditions': '', 'category': '待分类',
|
|
'kind': 'qa', 'flags': [], 'sources': []}
|
|
started = time.monotonic()
|
|
async with UsageRecorder(database, tenant_id='default', task_id='knowledge-model-test',
|
|
purpose='knowledge') as recorder:
|
|
async with httpx.AsyncClient() as client:
|
|
result, prompt_chars = await extract(client, outlet, candidate, deadline=body.model_timeout_seconds,
|
|
allow_discard=False, usage_recorder=recorder)
|
|
reply = {'model_provider_id': outlet.id, 'model_name': outlet.name,
|
|
'latency_ms': int((time.monotonic() - started) * 1000)}
|
|
if result.get('error'):
|
|
return {**reply, 'ok': False, 'error': safe_model_error(result['error'])}
|
|
try:
|
|
draft, _, _ = normalize_output(candidate, result.get('text'), prompt_chars)
|
|
except ModelOutputError as exc:
|
|
return {**reply, 'ok': False, 'error': f'模型能连通,但输出不符合整理格式:{exc}'}
|
|
return {**reply, 'ok': True, 'sample': SAMPLE,
|
|
'draft': {key: draft[key] for key in ('title', 'question', 'answer', 'conditions', 'category', 'kind')}}
|
|
|
|
@app.post('/api/v2/knowledge/review-tasks/preview')
|
|
def preview_reviews(request: Request, account_id: int = -1,
|
|
principal=Depends(require('knowledge:read', 'knowledge:review:auto', '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:auto', '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:auto', '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:auto', '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:auto', '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:enable"))):
|
|
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:watch", "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_any('knowledge:run', 'knowledge:run:control', 'knowledge:settings'))):
|
|
# 模型清单是全局的,「全部账号」下也能选
|
|
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:run:control', '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, include_auto: bool = False, principal=Depends(require("knowledge:read"))):
|
|
return {"items": store.jobs(resolve_admin_tenant(principal, account_id), include_auto=include_auto)}
|
|
|
|
@app.post("/api/v2/knowledge/jobs/preview")
|
|
def preview_job(body: JobBody, account_id: int | None = None,
|
|
principal=Depends(require("knowledge:run", "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:run", "im:content:read"))):
|
|
if body.auto_watch and 'knowledge:watch' not in principal.permissions:
|
|
raise HTTPException(403, '缺少权限:knowledge:watch')
|
|
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:run:control", "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"))):
|
|
result = store.list_items(resolve_admin_tenant(principal, account_id), status, query, page, page_size)
|
|
# 「全部账号」下列表混着多个账号的知识:每条带上所属账号,点开就能直接看、直接审
|
|
with database.connect() as db:
|
|
labels = store._tenant_labels(db, {item["tenant_id"] for item in result["items"]})
|
|
for item in result["items"]:
|
|
item.update(account_id=labels[item["tenant_id"]]["account_id"], account_label=labels[item["tenant_id"]]["label"])
|
|
return result
|
|
|
|
@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():
|
|
tenant = item_tenant(principal, account_id, item_id)
|
|
item = store.detail(tenant, item_id, sources="im:content:read" in principal.permissions)
|
|
with database.connect() as db:
|
|
label = store._tenant_labels(db, [tenant])[tenant]
|
|
return {**item, "account_id": label["account_id"], "account_label": label["label"]}
|
|
|
|
@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(item_tenant(principal, account_id, item_id), item_id, body.model_dump(), principal.id,
|
|
client_ip(request))
|
|
|
|
for code in ITEM_ACTIONS.values():
|
|
require(code) # 只为把码登记进权限菜单;真正的判断在 transition 里按操作做
|
|
|
|
@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 = ITEM_ACTIONS.get(action)
|
|
if permission is None:
|
|
raise HTTPException(400, "未知操作")
|
|
missing = [code for code in (permission, "im:content:read") if code not in principal.permissions]
|
|
if missing:
|
|
raise HTTPException(403, f"缺少权限:{'、'.join(missing)}")
|
|
tenant = item_tenant(principal, account_id, item_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:search"))):
|
|
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
|