feat: implement backend knowledge management
This commit is contained in:
@@ -1,7 +1,15 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.schemas.chat import ChatRequest, ChatResponse
|
||||
|
||||
router = APIRouter(tags=["chat"])
|
||||
|
||||
|
||||
@router.post("/chat", response_model=ChatResponse)
|
||||
def chat(payload: ChatRequest) -> ChatResponse:
|
||||
# TODO: retrieval -> context assembly -> LLM -> evidence/source checks
|
||||
return ChatResponse(answer=f"已收到问题:{payload.question}。当前为基础壳子,尚未接入知识检索或大语言模型。", status="stub", sources=[])
|
||||
return ChatResponse(
|
||||
answer=f"已收到问题:{payload.question}。当前为基础壳子,尚未接入知识检索或大语言模型。",
|
||||
status="stub",
|
||||
sources=[],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models import Document, KnowledgeItem, Source
|
||||
from app.schemas.catalog import AuditRead, DocumentCreate, DocumentRead, DocumentUpdate, Page
|
||||
from app.services.catalog import (
|
||||
ensure_version,
|
||||
get_item,
|
||||
history,
|
||||
invalidate_knowledge,
|
||||
page_result,
|
||||
record_event,
|
||||
update_values,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||
|
||||
|
||||
@router.get("", response_model=Page[DocumentRead])
|
||||
def list_documents(
|
||||
q: str = Query("", max_length=200),
|
||||
source_id: int | None = Query(None, gt=0),
|
||||
category: str | None = Query(None, max_length=80),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
statement = select(Document).where(Document.is_deleted.is_(False))
|
||||
if source_id is not None:
|
||||
statement = statement.where(Document.source_id == source_id)
|
||||
if category is not None:
|
||||
statement = statement.where(Document.category == category)
|
||||
if q:
|
||||
statement = statement.where(
|
||||
or_(
|
||||
Document.title.icontains(q, autoescape=True),
|
||||
Document.content.icontains(q, autoescape=True),
|
||||
)
|
||||
)
|
||||
return page_result(db, statement.order_by(Document.id.desc()), page, page_size)
|
||||
|
||||
|
||||
@router.post("", response_model=DocumentRead, status_code=201)
|
||||
def create_document(payload: DocumentCreate, db: Session = Depends(get_db)):
|
||||
get_item(db, Source, payload.source_id, lock=True)
|
||||
item = Document(**payload.model_dump())
|
||||
db.add(item)
|
||||
record_event(db, item, "create")
|
||||
db.commit()
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/{item_id}", response_model=DocumentRead)
|
||||
def read_document(item_id: int, db: Session = Depends(get_db)):
|
||||
return get_item(db, Document, item_id)
|
||||
|
||||
|
||||
@router.patch("/{item_id}", response_model=DocumentRead)
|
||||
def update_document(item_id: int, payload: DocumentUpdate, db: Session = Depends(get_db)):
|
||||
item = get_item(db, Document, item_id, lock=True)
|
||||
ensure_version(item, payload.expected_version)
|
||||
update_values(item, payload)
|
||||
record_event(db, item, "update")
|
||||
invalidate_knowledge(db, KnowledgeItem.document_id == item.id)
|
||||
db.commit()
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{item_id}", status_code=204)
|
||||
def delete_document(
|
||||
item_id: int, expected_version: int = Query(..., ge=1), db: Session = Depends(get_db)
|
||||
):
|
||||
item = get_item(db, Document, item_id, lock=True)
|
||||
ensure_version(item, expected_version)
|
||||
if (
|
||||
db.scalar(
|
||||
select(KnowledgeItem.id)
|
||||
.where(KnowledgeItem.document_id == item_id, KnowledgeItem.is_deleted.is_(False))
|
||||
.limit(1)
|
||||
)
|
||||
is not None
|
||||
):
|
||||
raise HTTPException(409, "文档仍被知识条目引用,不能删除")
|
||||
item.is_deleted = True
|
||||
record_event(db, item, "delete")
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/{item_id}/history", response_model=Page[AuditRead])
|
||||
def document_history(
|
||||
item_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return history(db, Document, item_id, page, page_size)
|
||||
@@ -1,5 +1,18 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "service": "nalati-rag-api"}
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
def ready(db: Session = Depends(get_db)):
|
||||
db.execute(text("SELECT 1"))
|
||||
return {"status": "ok", "database": "connected"}
|
||||
|
||||
@@ -1,15 +1,151 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.knowledge import KnowledgeItem
|
||||
from app.schemas.knowledge import KnowledgeCreate, KnowledgeRead
|
||||
router = APIRouter(tags=["knowledge"])
|
||||
@router.get("/knowledge", response_model=list[KnowledgeRead])
|
||||
def list_knowledge(db: Session = Depends(get_db)):
|
||||
return list(db.scalars(select(KnowledgeItem).order_by(KnowledgeItem.id.desc())).all())
|
||||
@router.post("/knowledge", response_model=KnowledgeRead, status_code=201)
|
||||
from app.models import KnowledgeItem
|
||||
from app.models.catalog import utc_now
|
||||
from app.schemas.catalog import AuditRead, Page, Status
|
||||
from app.schemas.knowledge import KnowledgeCreate, KnowledgeRead, KnowledgeReview, KnowledgeUpdate
|
||||
from app.services.catalog import (
|
||||
ensure_version,
|
||||
get_item,
|
||||
history,
|
||||
page_result,
|
||||
record_event,
|
||||
reset_review,
|
||||
resolve_knowledge_links,
|
||||
update_values,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
|
||||
|
||||
|
||||
def filtered_query(q="", category=None, verification_status=None, source_id=None, document_id=None):
|
||||
statement = select(KnowledgeItem).where(KnowledgeItem.is_deleted.is_(False))
|
||||
for column, value in (
|
||||
(KnowledgeItem.category, category),
|
||||
(KnowledgeItem.verification_status, verification_status),
|
||||
(KnowledgeItem.source_id, source_id),
|
||||
(KnowledgeItem.document_id, document_id),
|
||||
):
|
||||
if value is not None:
|
||||
statement = statement.where(column == value)
|
||||
if q:
|
||||
statement = statement.where(
|
||||
or_(
|
||||
KnowledgeItem.title.icontains(q, autoescape=True),
|
||||
KnowledgeItem.content.icontains(q, autoescape=True),
|
||||
)
|
||||
)
|
||||
return statement.order_by(KnowledgeItem.id.desc())
|
||||
|
||||
|
||||
@router.get("", response_model=list[KnowledgeRead])
|
||||
def list_knowledge(
|
||||
q: str = Query("", max_length=200),
|
||||
category: str | None = Query(None, max_length=80),
|
||||
verification_status: Status | None = None,
|
||||
limit: int = Query(100, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""保留数组响应;完整分页与关联筛选使用 /knowledge/page。"""
|
||||
return list(
|
||||
db.scalars(filtered_query(q, category, verification_status).offset(offset).limit(limit))
|
||||
)
|
||||
|
||||
|
||||
@router.get("/page", response_model=Page[KnowledgeRead])
|
||||
def paged_knowledge(
|
||||
q: str = Query("", max_length=200),
|
||||
category: str | None = Query(None, max_length=80),
|
||||
verification_status: Status | None = None,
|
||||
source_id: int | None = Query(None, gt=0),
|
||||
document_id: int | None = Query(None, gt=0),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return page_result(
|
||||
db,
|
||||
filtered_query(q, category, verification_status, source_id, document_id),
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=KnowledgeRead, status_code=201)
|
||||
def create_knowledge(payload: KnowledgeCreate, db: Session = Depends(get_db)):
|
||||
item = KnowledgeItem(title=payload.title, category=payload.category, content=payload.content, source_url=str(payload.source_url) if payload.source_url else None, verification_status="pending")
|
||||
db.add(item); db.commit(); db.refresh(item)
|
||||
item = KnowledgeItem(
|
||||
**payload.model_dump(exclude={"source_url"}),
|
||||
source_url=str(payload.source_url) if payload.source_url else None,
|
||||
verification_status="pending",
|
||||
)
|
||||
resolve_knowledge_links(db, item)
|
||||
db.add(item)
|
||||
record_event(db, item, "create")
|
||||
db.commit()
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/{item_id}", response_model=KnowledgeRead)
|
||||
def read_knowledge(item_id: int, db: Session = Depends(get_db)):
|
||||
return get_item(db, KnowledgeItem, item_id)
|
||||
|
||||
|
||||
@router.patch("/{item_id}", response_model=KnowledgeRead)
|
||||
def update_knowledge(item_id: int, payload: KnowledgeUpdate, db: Session = Depends(get_db)):
|
||||
item = get_item(db, KnowledgeItem, item_id)
|
||||
ensure_version(item, payload.expected_version)
|
||||
update_values(item, payload)
|
||||
if payload.document_id is not None and "source_id" not in payload.model_fields_set:
|
||||
item.source_id = None
|
||||
if {
|
||||
"source_id",
|
||||
"document_id",
|
||||
} & payload.model_fields_set and "source_url" not in payload.model_fields_set:
|
||||
item.source_url = None
|
||||
resolve_knowledge_links(db, item)
|
||||
reset_review(item)
|
||||
record_event(db, item, "update")
|
||||
db.commit()
|
||||
return item
|
||||
|
||||
|
||||
@router.post("/{item_id}/review", response_model=KnowledgeRead)
|
||||
def review_knowledge(item_id: int, payload: KnowledgeReview, db: Session = Depends(get_db)):
|
||||
item = get_item(db, KnowledgeItem, item_id)
|
||||
ensure_version(item, payload.expected_version)
|
||||
resolve_knowledge_links(db, item)
|
||||
if payload.verification_status == "verified" and (not item.source_url or not item.collected_at):
|
||||
raise HTTPException(422, "核验通过前必须补全来源网址和真实采集时间")
|
||||
item.verification_status = payload.verification_status
|
||||
item.reviewer = payload.reviewer
|
||||
item.reviewed_at = utc_now()
|
||||
item.verification_notes = payload.verification_notes
|
||||
record_event(db, item, "review", actor=payload.reviewer, notes=payload.verification_notes)
|
||||
db.commit()
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{item_id}", status_code=204)
|
||||
def delete_knowledge(
|
||||
item_id: int, expected_version: int = Query(..., ge=1), db: Session = Depends(get_db)
|
||||
):
|
||||
item = get_item(db, KnowledgeItem, item_id)
|
||||
ensure_version(item, expected_version)
|
||||
item.is_deleted = True
|
||||
record_event(db, item, "delete")
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/{item_id}/history", response_model=Page[AuditRead])
|
||||
def knowledge_history(
|
||||
item_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return history(db, KnowledgeItem, item_id, page, page_size)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models import Document, KnowledgeItem, Source
|
||||
from app.schemas.catalog import AuditRead, Page, SourceCreate, SourceRead, SourceUpdate
|
||||
from app.services.catalog import (
|
||||
ensure_version,
|
||||
get_item,
|
||||
history,
|
||||
invalidate_knowledge,
|
||||
page_result,
|
||||
record_event,
|
||||
update_values,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/sources", tags=["sources"])
|
||||
|
||||
|
||||
@router.get("", response_model=Page[SourceRead])
|
||||
def list_sources(
|
||||
q: str = Query("", max_length=200),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
statement = select(Source).where(Source.is_deleted.is_(False))
|
||||
if q:
|
||||
statement = statement.where(
|
||||
or_(
|
||||
Source.name.icontains(q, autoescape=True),
|
||||
Source.notes.icontains(q, autoescape=True),
|
||||
)
|
||||
)
|
||||
return page_result(db, statement.order_by(Source.id.desc()), page, page_size)
|
||||
|
||||
|
||||
@router.post("", response_model=SourceRead, status_code=201)
|
||||
def create_source(payload: SourceCreate, db: Session = Depends(get_db)):
|
||||
item = Source(**payload.model_dump(mode="json"))
|
||||
db.add(item)
|
||||
record_event(db, item, "create")
|
||||
db.commit()
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/{item_id}", response_model=SourceRead)
|
||||
def read_source(item_id: int, db: Session = Depends(get_db)):
|
||||
return get_item(db, Source, item_id)
|
||||
|
||||
|
||||
@router.patch("/{item_id}", response_model=SourceRead)
|
||||
def update_source(item_id: int, payload: SourceUpdate, db: Session = Depends(get_db)):
|
||||
item = get_item(db, Source, item_id, lock=True)
|
||||
ensure_version(item, payload.expected_version)
|
||||
update_values(item, payload)
|
||||
record_event(db, item, "update")
|
||||
invalidate_knowledge(
|
||||
db, KnowledgeItem.source_id == item.id, source_url=item.source_url, update_url=True
|
||||
)
|
||||
db.commit()
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{item_id}", status_code=204)
|
||||
def delete_source(
|
||||
item_id: int, expected_version: int = Query(..., ge=1), db: Session = Depends(get_db)
|
||||
):
|
||||
item = get_item(db, Source, item_id, lock=True)
|
||||
ensure_version(item, expected_version)
|
||||
for model in (Document, KnowledgeItem):
|
||||
if (
|
||||
db.scalar(
|
||||
select(model.id)
|
||||
.where(model.source_id == item_id, model.is_deleted.is_(False))
|
||||
.limit(1)
|
||||
)
|
||||
is not None
|
||||
):
|
||||
raise HTTPException(409, "来源仍被文档或知识条目引用,不能删除")
|
||||
item.is_deleted = True
|
||||
record_event(db, item, "delete")
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/{item_id}/history", response_model=Page[AuditRead])
|
||||
def source_history(
|
||||
item_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return history(db, Source, item_id, page, page_size)
|
||||
@@ -1,14 +1,21 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "Nalati Tourism QA API"
|
||||
app_env: str = "development"
|
||||
api_prefix: str = "/api"
|
||||
database_url: str = "postgresql+psycopg://nalati:change-this-password@127.0.0.1:5432/nalati_rag"
|
||||
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=Path(__file__).resolve().parents[2] / ".env", extra="ignore"
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [x.strip() for x in self.cors_origins.split(",") if x.strip()]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url, pool_pre_ping=True, connect_args={"connect_timeout": 5}
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
+57
-18
@@ -1,25 +1,64 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
|
||||
from app.api.routes import chat, documents, health, knowledge, sources
|
||||
from app.core.config import settings
|
||||
from app.db.session import Base, engine
|
||||
from app.models import KnowledgeItem # noqa: F401
|
||||
from app.api.routes import health, chat, knowledge
|
||||
from app.db.session import engine
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Convenience for local bootstrap. Use Alembic migrations before production.
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield
|
||||
|
||||
app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=settings.cors_origin_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||
app.include_router(health.router, prefix=settings.api_prefix)
|
||||
app.include_router(chat.router, prefix=settings.api_prefix)
|
||||
app.include_router(knowledge.router, prefix=settings.api_prefix)
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "Nalati Tourism QA API", "docs": "/docs"}
|
||||
def create_app(db_engine=engine) -> FastAPI:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
try:
|
||||
with db_engine.connect() as connection:
|
||||
revision = connection.scalar(text("SELECT version_num FROM alembic_version"))
|
||||
if revision != "0002_catalog":
|
||||
raise RuntimeError(
|
||||
"数据库版本不匹配,请先在 backend 目录执行 alembic upgrade head"
|
||||
)
|
||||
except ProgrammingError as exc:
|
||||
raise RuntimeError(
|
||||
"数据库尚未初始化,请先在 backend 目录执行 alembic upgrade head"
|
||||
) from exc
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
db_engine.dispose()
|
||||
|
||||
app = FastAPI(title=settings.app_name, version="0.2.0", lifespan=lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
for router in (health.router, chat.router, knowledge.router, sources.router, documents.router):
|
||||
app.include_router(router, prefix=settings.api_prefix)
|
||||
|
||||
@app.exception_handler(StaleDataError)
|
||||
async def stale_data_handler(request, exc):
|
||||
return JSONResponse(status_code=409, content={"detail": "记录已更新,请刷新后重试"})
|
||||
|
||||
@app.exception_handler(IntegrityError)
|
||||
async def integrity_handler(request, exc):
|
||||
return JSONResponse(status_code=409, content={"detail": "数据关联或约束冲突"})
|
||||
|
||||
@app.exception_handler(OperationalError)
|
||||
async def database_unavailable_handler(request, exc):
|
||||
return JSONResponse(status_code=503, content={"detail": "数据库暂不可用"})
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "Nalati Tourism QA API", "docs": "/docs"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
from app.models.catalog import AuditEvent, Document, Source
|
||||
from app.models.knowledge import KnowledgeItem
|
||||
|
||||
__all__ = ["AuditEvent", "Document", "KnowledgeItem", "Source"]
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, declared_attr, mapped_column
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Versioned:
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utc_now, onupdate=utc_now
|
||||
)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
@declared_attr.directive
|
||||
def __mapper_args__(cls):
|
||||
return {"version_id_col": cls.version}
|
||||
|
||||
|
||||
class Source(Versioned, Base):
|
||||
__tablename__ = "sources"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(250), nullable=False)
|
||||
source_url: Mapped[str | None] = mapped_column(String(1000))
|
||||
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
|
||||
|
||||
class Document(Versioned, Base):
|
||||
__tablename__ = "documents"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
source_id: Mapped[int] = mapped_column(ForeignKey("sources.id"), index=True)
|
||||
title: Mapped[str] = mapped_column(String(250), nullable=False)
|
||||
category: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
collected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
|
||||
|
||||
class AuditEvent(Base):
|
||||
__tablename__ = "audit_events"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
entity_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
entity_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
action: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
actor: Mapped[str | None] = mapped_column(String(100))
|
||||
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
snapshot: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -1,14 +1,29 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.db.session import Base
|
||||
|
||||
class KnowledgeItem(Base):
|
||||
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.session import Base
|
||||
from app.models.catalog import Versioned
|
||||
|
||||
|
||||
class KnowledgeItem(Versioned, Base):
|
||||
__tablename__ = "knowledge_items"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"verification_status IN ('pending', 'verified', 'rejected')",
|
||||
name="ck_knowledge_verification_status",
|
||||
),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(250), nullable=False, index=True)
|
||||
category: Mapped[str] = mapped_column(String(80), nullable=False, default="未分类")
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
verification_status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
source_id: Mapped[int | None] = mapped_column(ForeignKey("sources.id"), index=True)
|
||||
document_id: Mapped[int | None] = mapped_column(ForeignKey("documents.id"), index=True)
|
||||
collected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
reviewer: Mapped[str | None] = mapped_column(String(100))
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
verification_notes: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Generic, Literal, TypeVar
|
||||
|
||||
from pydantic import (
|
||||
AwareDatetime,
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
HttpUrl,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
Title = Annotated[str, Field(min_length=1, max_length=250)]
|
||||
Category = Annotated[str, Field(min_length=1, max_length=80)]
|
||||
Body = Annotated[str, Field(min_length=1, max_length=100_000)]
|
||||
Notes = Annotated[str, Field(max_length=10_000)]
|
||||
Status = Literal["pending", "verified", "rejected"]
|
||||
PositiveId = Annotated[int, Field(gt=0)]
|
||||
|
||||
|
||||
class Input(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
@field_validator("source_url", mode="before", check_fields=False)
|
||||
@classmethod
|
||||
def empty_url_is_absent(cls, value):
|
||||
return None if isinstance(value, str) and not value.strip() else value
|
||||
|
||||
|
||||
class Patch(Input):
|
||||
expected_version: PositiveId
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_changes(self):
|
||||
if not self.model_fields_set - {"expected_version"}:
|
||||
raise ValueError("至少提供一个需要更新的字段")
|
||||
return self
|
||||
|
||||
@field_validator("*", mode="before")
|
||||
@classmethod
|
||||
def disallow_required_null(cls, value, info):
|
||||
if (
|
||||
info.field_name
|
||||
in {"name", "title", "category", "content", "notes", "collected_at", "expected_version"}
|
||||
and value is None
|
||||
):
|
||||
raise ValueError("该字段不能为 null")
|
||||
return value
|
||||
|
||||
|
||||
class Read(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
version: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
is_deleted: bool
|
||||
|
||||
|
||||
class SourceCreate(Input):
|
||||
name: Title
|
||||
source_url: HttpUrl | None = Field(default=None, max_length=1000)
|
||||
notes: Notes = ""
|
||||
|
||||
|
||||
class SourceUpdate(Patch):
|
||||
name: Title | None = None
|
||||
source_url: HttpUrl | None = Field(default=None, max_length=1000)
|
||||
notes: Notes | None = None
|
||||
|
||||
|
||||
class SourceRead(Read):
|
||||
name: str
|
||||
source_url: str | None
|
||||
notes: str
|
||||
|
||||
|
||||
class DocumentCreate(Input):
|
||||
source_id: PositiveId
|
||||
title: Title
|
||||
category: Category = "未分类"
|
||||
content: Body
|
||||
collected_at: AwareDatetime
|
||||
notes: Notes = ""
|
||||
|
||||
|
||||
class DocumentUpdate(Patch):
|
||||
title: Title | None = None
|
||||
category: Category | None = None
|
||||
content: Body | None = None
|
||||
collected_at: AwareDatetime | None = None
|
||||
notes: Notes | None = None
|
||||
|
||||
|
||||
class DocumentRead(Read):
|
||||
source_id: int
|
||||
title: str
|
||||
category: str
|
||||
content: str
|
||||
collected_at: datetime
|
||||
notes: str
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Page(BaseModel, Generic[T]):
|
||||
items: list[T]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class AuditRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
entity_type: str
|
||||
entity_id: int
|
||||
action: str
|
||||
actor: str | None
|
||||
notes: str
|
||||
snapshot: dict
|
||||
created_at: datetime
|
||||
@@ -1,12 +1,15 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
question: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
|
||||
class SourcePreview(BaseModel):
|
||||
title: str
|
||||
source_url: str | None = None
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
answer: str
|
||||
status: str
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, ConfigDict, Field, HttpUrl
|
||||
|
||||
class KnowledgeCreate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=250)
|
||||
category: str = Field(default="未分类", max_length=80)
|
||||
content: str = Field(min_length=1)
|
||||
source_url: HttpUrl | None = None
|
||||
from pydantic import AwareDatetime, Field, HttpUrl
|
||||
|
||||
class KnowledgeRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
from app.schemas.catalog import Body, Category, Input, Patch, PositiveId, Read, Status, Title
|
||||
|
||||
|
||||
class KnowledgeCreate(Input):
|
||||
title: Title
|
||||
category: Category = "未分类"
|
||||
content: Body
|
||||
source_url: HttpUrl | None = Field(default=None, max_length=1000)
|
||||
source_id: PositiveId | None = None
|
||||
document_id: PositiveId | None = None
|
||||
collected_at: AwareDatetime | None = None
|
||||
|
||||
|
||||
class KnowledgeUpdate(Patch):
|
||||
title: Title | None = None
|
||||
category: Category | None = None
|
||||
content: Body | None = None
|
||||
source_url: HttpUrl | None = Field(default=None, max_length=1000)
|
||||
source_id: PositiveId | None = None
|
||||
document_id: PositiveId | None = None
|
||||
collected_at: AwareDatetime | None = None
|
||||
|
||||
|
||||
class KnowledgeReview(Input):
|
||||
expected_version: PositiveId
|
||||
verification_status: Status
|
||||
reviewer: str = Field(min_length=1, max_length=100)
|
||||
verification_notes: str = Field(min_length=1, max_length=10_000)
|
||||
|
||||
|
||||
class KnowledgeRead(Read):
|
||||
title: str
|
||||
category: str
|
||||
content: str
|
||||
source_url: str | None
|
||||
verification_status: str
|
||||
created_at: datetime
|
||||
verification_status: Status
|
||||
source_id: int | None
|
||||
document_id: int | None
|
||||
collected_at: datetime | None
|
||||
reviewer: str | None
|
||||
reviewed_at: datetime | None
|
||||
verification_notes: str | None
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
from fastapi import HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from sqlalchemy import func, inspect, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AuditEvent, Document, KnowledgeItem, Source
|
||||
from app.models.catalog import utc_now
|
||||
|
||||
|
||||
def get_item(db: Session, model, item_id: int, *, include_deleted=False, lock=False):
|
||||
if lock:
|
||||
item = db.scalar(
|
||||
select(model)
|
||||
.where(model.id == item_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
else:
|
||||
item = db.get(model, item_id)
|
||||
if item is None or (item.is_deleted and not include_deleted):
|
||||
raise HTTPException(404, "记录不存在")
|
||||
return item
|
||||
|
||||
|
||||
def ensure_version(item, expected_version: int):
|
||||
if item.version != expected_version:
|
||||
raise HTTPException(409, "记录已更新,请刷新后重试")
|
||||
|
||||
|
||||
def values(item) -> dict:
|
||||
return jsonable_encoder(
|
||||
{column.key: getattr(item, column.key) for column in inspect(type(item)).columns}
|
||||
)
|
||||
|
||||
|
||||
def record_event(db: Session, item, action: str, *, actor=None, notes=""):
|
||||
db.flush()
|
||||
snapshot = {"item": values(item)}
|
||||
if isinstance(item, KnowledgeItem):
|
||||
source = db.get(Source, item.source_id) if item.source_id else None
|
||||
document = db.get(Document, item.document_id) if item.document_id else None
|
||||
snapshot["source"] = values(source) if source else None
|
||||
snapshot["document"] = values(document) if document else None
|
||||
db.add(
|
||||
AuditEvent(
|
||||
entity_type=item.__tablename__,
|
||||
entity_id=item.id,
|
||||
action=action,
|
||||
actor=actor,
|
||||
notes=notes,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_values(item, payload):
|
||||
data = payload.model_dump(mode="json", exclude_unset=True, exclude={"expected_version"})
|
||||
data.update(
|
||||
{
|
||||
key: value
|
||||
for key, value in payload.model_dump(exclude_unset=True).items()
|
||||
if key.endswith("_at")
|
||||
}
|
||||
)
|
||||
for name, value in data.items():
|
||||
setattr(item, name, value)
|
||||
item.updated_at = utc_now()
|
||||
|
||||
|
||||
def reset_review(item: KnowledgeItem):
|
||||
item.verification_status = "pending"
|
||||
item.reviewer = None
|
||||
item.reviewed_at = None
|
||||
item.verification_notes = None
|
||||
item.updated_at = utc_now()
|
||||
|
||||
|
||||
def invalidate_knowledge(db: Session, condition, *, source_url=None, update_url=False):
|
||||
for item in db.scalars(
|
||||
select(KnowledgeItem).where(condition, KnowledgeItem.is_deleted.is_(False))
|
||||
):
|
||||
reset_review(item)
|
||||
if update_url:
|
||||
item.source_url = source_url
|
||||
record_event(db, item, "invalidate", notes="来源或文档已变更,需要重新核验")
|
||||
|
||||
|
||||
def resolve_knowledge_links(db: Session, item: KnowledgeItem):
|
||||
# 先锁依据记录再保存条目,避免引用与父记录修改/删除同时发生。
|
||||
with db.no_autoflush:
|
||||
if item.document_id is not None:
|
||||
document = get_item(db, Document, item.document_id, lock=True)
|
||||
if item.source_id is not None and item.source_id != document.source_id:
|
||||
raise HTTPException(422, "文档与来源不一致")
|
||||
item.source_id = document.source_id
|
||||
if item.source_id is not None:
|
||||
source = get_item(db, Source, item.source_id, lock=True)
|
||||
if item.source_url and item.source_url != source.source_url:
|
||||
raise HTTPException(422, "来源网址与登记来源不一致")
|
||||
item.source_url = source.source_url
|
||||
|
||||
|
||||
def page_result(db: Session, statement, page: int, page_size: int):
|
||||
total = db.scalar(select(func.count()).select_from(statement.order_by(None).subquery()))
|
||||
return {
|
||||
"items": list(db.scalars(statement.offset((page - 1) * page_size).limit(page_size))),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
def history(db: Session, model, item_id: int, page: int, page_size: int):
|
||||
get_item(db, model, item_id, include_deleted=True)
|
||||
statement = (
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.entity_type == model.__tablename__, AuditEvent.entity_id == item_id)
|
||||
.order_by(AuditEvent.id.desc())
|
||||
)
|
||||
return page_result(db, statement, page, page_size)
|
||||
Reference in New Issue
Block a user