231 lines
7.2 KiB
Python
231 lines
7.2 KiB
Python
"""中心服务端接口。
|
||
|
||
两组:
|
||
- /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
|
||
)
|