assistant/server/api/routes.py

231 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""中心服务端接口。
两组:
- /sync/* 桌面端用,带学校 token:上报草稿、拉取已发布
- /api/* 后台界面用,带 console token(本机开发可不配)
"""
from __future__ import annotations
import asyncio
import os
import time
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
from server import autoreview, review
from server.config import settings
from server.db import database as db
router = APIRouter()
STARTED_AT = time.time()
# ---------- 请求体 ----------
class DraftEntry(BaseModel):
knowledge_id: str
business_id: str = ""
key: str
value: str
category: str = "note"
observations: int = 1
source_task_id: str = ""
# 试用证据:Agent 在真实页面上验证相符 / 不符的次数。字段没声明的话 Pydantic 会直接丢掉
confirmed: int = 0
contradicted: int = 0
status: str = "draft"
class ReportRequest(BaseModel):
school_id: str
entries: list[DraftEntry] = []
class SchoolRequest(BaseModel):
school_id: str
name: str = ""
note: str = ""
class ReviewRequest(BaseModel):
action: str # verify / reject / reset
value: str | None = None
scope: str | None = None # origin / all
by: str = "human"
class BatchReviewRequest(BaseModel):
ids: list[int]
action: str
scope: str | None = None
by: str = "human"
class PresuggestRequest(BaseModel):
ids: list[int] = []
status: str = "draft"
limit: int = 20
class AutoReviewRequest(BaseModel):
dry_run: bool = True
ids: list[int] = []
limit: int | None = None
use_llm: bool = True
# 临时覆盖门槛,不传就用 .env 里的
min_schools: int | None = None
min_observations: int | None = None
require_llm: bool | None = None
auto_reject: bool | None = None
# ---------- 鉴权 ----------
def _school_auth(school_id: str, authorization: str | None) -> None:
token = (authorization or "").removeprefix("Bearer ").strip()
if not db.authenticate(school_id, token):
raise HTTPException(status_code=401, detail="学校标识或上报凭证不正确")
def _console_auth(authorization: str | None) -> None:
"""没配 console_token 时不校验:本机开发用,服务只监听 127.0.0.1。"""
if not settings.console_token:
return
token = (authorization or "").removeprefix("Bearer ").strip()
if token != settings.console_token:
raise HTTPException(status_code=401, detail="后台口令不正确")
# ---------- 健康与概览 ----------
@router.get("/health")
async def health():
return {"status": "ok", "pid": os.getpid()}
@router.get("/api/meta")
async def meta():
return {
"pid": os.getpid(),
"uptime_s": round(time.time() - STARTED_AT),
"db": f"postgresql://{settings.pg_host}:{settings.pg_port}/{settings.pg_database}",
"console_auth": bool(settings.console_token),
"llm_ready": bool(settings.llm_api_key),
"llm_model": settings.llm_model if settings.llm_api_key else "",
}
@router.get("/api/stats")
async def get_stats(authorization: str | None = Header(default=None)):
_console_auth(authorization)
return db.stats()
# ---------- 桌面端同步 ----------
@router.post("/sync/report")
async def report(body: ReportRequest, authorization: str | None = Header(default=None)):
_school_auth(body.school_id, authorization)
result = db.receive_drafts(body.school_id, [e.model_dump() for e in body.entries])
# 开了 auto_on_report 才自动跑智能采纳;默认关,先用后台按钮手动跑几次看准了再打开
if settings.auto_on_report and (result.get("created") or result.get("merged") or result.get("revived")):
asyncio.create_task(autoreview.run(dry_run=False))
return result
@router.get("/sync/published")
async def published(school_id: str, since: int = 0, authorization: str | None = Header(default=None)):
_school_auth(school_id, authorization)
return db.published_for(school_id, since)
# ---------- 学校管理 ----------
@router.get("/api/schools")
async def schools(authorization: str | None = Header(default=None)):
_console_auth(authorization)
return db.list_schools()
@router.post("/api/schools")
async def create_school(body: SchoolRequest, authorization: str | None = Header(default=None)):
"""新建或重置一所学校的上报凭证。明文 token 只在这次响应里出现一次。"""
_console_auth(authorization)
return db.register_school(body.school_id, body.name, body.note)
@router.delete("/api/schools/{school_id}")
async def remove_school(school_id: str, authorization: str | None = Header(default=None)):
_console_auth(authorization)
db.delete_school(school_id)
return {"ok": True}
# ---------- 审核台 ----------
@router.get("/api/entries")
async def entries(
status: str | None = None,
knowledge_id: str | None = None,
business_id: str | None = None,
category: str | None = None,
school_id: str | None = None,
q: str | None = None,
limit: int = 200,
authorization: str | None = Header(default=None),
):
_console_auth(authorization)
return db.list_entries(status, knowledge_id, business_id, category, school_id, q, limit)
@router.post("/api/entries/{entry_id}/review")
async def review_one(entry_id: int, body: ReviewRequest, authorization: str | None = Header(default=None)):
_console_auth(authorization)
row = db.review_entry(entry_id, body.action, body.value, body.scope, body.by)
if not row:
raise HTTPException(status_code=404, detail="条目不存在")
return row
@router.post("/api/entries/batch-review")
async def review_batch(body: BatchReviewRequest, authorization: str | None = Header(default=None)):
_console_auth(authorization)
done = [db.review_entry(i, body.action, None, body.scope, body.by) for i in body.ids]
return {"updated": len([d for d in done if d])}
@router.post("/api/entries/presuggest")
async def presuggest(body: PresuggestRequest, authorization: str | None = Header(default=None)):
"""给一批草稿跑智能预审。没传 ids 就取最新的一批草稿。"""
_console_auth(authorization)
ids = body.ids or [e["id"] for e in db.list_entries(status=body.status, limit=body.limit)]
results = await asyncio.gather(*(review.presuggest(i) for i in ids), return_exceptions=True)
ok = [r for r in results if isinstance(r, dict)]
return {"requested": len(ids), "done": len(ok), "results": ok}
@router.post("/api/entries/auto-review")
async def auto_review(body: AutoReviewRequest, authorization: str | None = Header(default=None)):
"""智能采纳。默认只干跑给出计划,dry_run=false 才真的改状态。"""
_console_auth(authorization)
policy = autoreview.Policy.from_settings()
if body.min_schools is not None:
policy.min_schools = max(1, body.min_schools)
if body.min_observations is not None:
policy.min_observations = max(1, body.min_observations)
if body.require_llm is not None:
policy.require_llm = body.require_llm
if body.auto_reject is not None:
policy.auto_reject = body.auto_reject
return await autoreview.run(
dry_run=body.dry_run, policy=policy, limit=body.limit, ids=body.ids or None, use_llm=body.use_llm
)