feat: implement backend knowledge management

This commit is contained in:
2026-10-15 19:24:21 +08:00
parent b6adddee42
commit 6eb1d12580
33 changed files with 1604 additions and 55 deletions
+9 -1
View File
@@ -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=[],
)
+99
View File
@@ -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)
+14 -1
View File
@@ -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"}
+147 -11
View File
@@ -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)
+95
View File
@@ -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)
+8 -1
View File
@@ -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()
+10 -1
View File
@@ -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
View File
@@ -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()
+3
View File
@@ -1 +1,4 @@
from app.models.catalog import AuditEvent, Document, Source
from app.models.knowledge import KnowledgeItem
__all__ = ["AuditEvent", "Document", "KnowledgeItem", "Source"]
+54
View File
@@ -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())
+20 -5
View File
@@ -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)
+124
View File
@@ -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
+3
View File
@@ -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
+39 -11
View File
@@ -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
View File
+120
View File
@@ -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)