216 lines
6.6 KiB
Python
216 lines
6.6 KiB
Python
import asyncio
|
|
import logging
|
|
import os
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
|
|
import uvicorn
|
|
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
|
|
from fastapi.responses import FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.config import (
|
|
CHUNK_OVERLAP,
|
|
CHUNK_SIZE,
|
|
DEFAULT_TOP_K,
|
|
EMBEDDING_MODEL,
|
|
STATIC_DIR,
|
|
llm_config,
|
|
)
|
|
from app.embeddings import Embedder
|
|
from app.generator import generate
|
|
from app.store import KnowledgeBase
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
)
|
|
logger = logging.getLogger("learn_rag")
|
|
|
|
|
|
class AskRequest(BaseModel):
|
|
question: str = Field(min_length=1, max_length=2000)
|
|
top_k: int = Field(default=DEFAULT_TOP_K, ge=1, le=8)
|
|
|
|
|
|
class DocumentIn(BaseModel):
|
|
title: str = Field(min_length=1, max_length=80)
|
|
content: str = Field(min_length=1, max_length=200_000)
|
|
|
|
|
|
def decode_text(raw: bytes) -> str:
|
|
if len(raw) > 1_000_000:
|
|
raise HTTPException(status_code=413, detail="文件超过 1MB")
|
|
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
|
|
try:
|
|
return raw.decode(encoding)
|
|
except UnicodeDecodeError:
|
|
continue
|
|
raise HTTPException(status_code=400, detail="无法识别文件编码,请使用 UTF-8 或 GB18030")
|
|
|
|
|
|
def clean_document(title: str, content: str) -> tuple[str, str]:
|
|
title = " ".join(title.replace("\x00", "").split())
|
|
content = content.replace("\x00", "").strip()
|
|
if not title:
|
|
raise HTTPException(status_code=400, detail="标题不能为空")
|
|
if len(title) > 80:
|
|
title = title[:80]
|
|
if len(content) < 10:
|
|
raise HTTPException(status_code=400, detail="正文至少需要 10 个字")
|
|
return title, content
|
|
|
|
|
|
def build_runtime() -> KnowledgeBase:
|
|
embedder = Embedder()
|
|
knowledge = KnowledgeBase(embedder)
|
|
knowledge.startup()
|
|
documents, chunks = knowledge.counts()
|
|
logger.info("知识库就绪:%s 篇文档,%s 个切片", documents, chunks)
|
|
return knowledge
|
|
|
|
|
|
async def boot(application: FastAPI) -> None:
|
|
if application.state.booting or application.state.ready:
|
|
return
|
|
application.state.booting = True
|
|
application.state.error = None
|
|
try:
|
|
application.state.kb = await asyncio.to_thread(build_runtime)
|
|
application.state.ready = True
|
|
except Exception as exc:
|
|
logger.exception("初始化失败")
|
|
application.state.error = f"初始化失败:{exc}"
|
|
finally:
|
|
application.state.booting = False
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(application: FastAPI):
|
|
application.state.ready = False
|
|
application.state.booting = False
|
|
application.state.error = None
|
|
application.state.kb = None
|
|
asyncio.create_task(boot(application))
|
|
yield
|
|
|
|
|
|
app = FastAPI(title="知迹", lifespan=lifespan)
|
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
|
|
|
|
def require_kb(request: Request) -> KnowledgeBase:
|
|
if not request.app.state.ready or request.app.state.kb is None:
|
|
detail = request.app.state.error or "嵌入模型还在准备,请稍后再试"
|
|
raise HTTPException(status_code=503, detail=detail)
|
|
return request.app.state.kb
|
|
|
|
|
|
@app.get("/")
|
|
def index() -> FileResponse:
|
|
return FileResponse(STATIC_DIR / "index.html")
|
|
|
|
|
|
@app.get("/api/status")
|
|
def status(request: Request) -> dict:
|
|
knowledge = request.app.state.kb
|
|
documents, chunks = knowledge.counts() if knowledge is not None else (0, 0)
|
|
llm = llm_config()
|
|
return {
|
|
"ready": bool(request.app.state.ready),
|
|
"booting": bool(request.app.state.booting),
|
|
"error": request.app.state.error,
|
|
"embedding_model": EMBEDDING_MODEL,
|
|
"vector_db": "Chroma",
|
|
"chunk_size": CHUNK_SIZE,
|
|
"chunk_overlap": CHUNK_OVERLAP,
|
|
"llm_ready": llm is not None,
|
|
"llm_model": llm["model"] if llm else None,
|
|
"documents": documents,
|
|
"chunks": chunks,
|
|
}
|
|
|
|
|
|
@app.post("/api/boot")
|
|
async def boot_again(request: Request) -> dict:
|
|
if request.app.state.ready:
|
|
return {"ok": True}
|
|
asyncio.create_task(boot(request.app))
|
|
return {"ok": True, "booting": True}
|
|
|
|
|
|
@app.get("/api/documents")
|
|
def list_documents(request: Request) -> dict:
|
|
knowledge = request.app.state.kb
|
|
if knowledge is None:
|
|
return {"documents": []}
|
|
return {"documents": knowledge.list_documents()}
|
|
|
|
|
|
@app.post("/api/documents")
|
|
def create_document(body: DocumentIn, request: Request) -> dict:
|
|
knowledge = require_kb(request)
|
|
title, content = clean_document(body.title, body.content)
|
|
try:
|
|
return knowledge.add(title, content)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
@app.post("/api/documents/upload")
|
|
async def upload_document(request: Request, file: UploadFile = File(...)) -> dict:
|
|
knowledge = require_kb(request)
|
|
raw = await file.read()
|
|
content = decode_text(raw)
|
|
filename = (file.filename or "未命名资料").rsplit("/", 1)[-1]
|
|
title = filename.rsplit(".", 1)[0]
|
|
title, content = clean_document(title, content)
|
|
try:
|
|
return knowledge.add(title, content)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
|
@app.delete("/api/documents/{doc_id}")
|
|
def delete_document(doc_id: str, request: Request) -> dict:
|
|
knowledge = require_kb(request)
|
|
if not knowledge.delete(doc_id):
|
|
raise HTTPException(status_code=404, detail="没有找到这篇资料")
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/api/ask")
|
|
async def ask(body: AskRequest, request: Request) -> dict:
|
|
knowledge = require_kb(request)
|
|
question = body.question.strip()
|
|
if not question:
|
|
raise HTTPException(status_code=400, detail="请输入问题")
|
|
started = time.perf_counter()
|
|
|
|
def run() -> dict:
|
|
chunks = knowledge.search(question, body.top_k)
|
|
logger.info(
|
|
"检索「%s」→ %s",
|
|
question,
|
|
[(chunk["title"], chunk["score"]) for chunk in chunks],
|
|
)
|
|
answer, mode, notice, low_confidence = generate(question, chunks)
|
|
return {
|
|
"question": question,
|
|
"answer": answer,
|
|
"mode": mode,
|
|
"notice": notice,
|
|
"low_confidence": low_confidence,
|
|
"elapsed_ms": int((time.perf_counter() - started) * 1000),
|
|
"chunks": chunks,
|
|
}
|
|
|
|
return await asyncio.to_thread(run)
|
|
|
|
|
|
def main() -> None:
|
|
port = int(os.getenv("PORT", "8000"))
|
|
logger.info("知迹已启动:http://127.0.0.1:%s", port)
|
|
uvicorn.run(app, host="127.0.0.1", port=port, log_level="info")
|