feat: persist reviewed chunks and index publication versions
This commit is contained in:
@@ -15,8 +15,13 @@ from app.services.catalog import (
|
||||
record_event,
|
||||
update_values,
|
||||
)
|
||||
from app.services.indexing import catalog_write_guard
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["documents"], dependencies=[Depends(require_admin)])
|
||||
router = APIRouter(
|
||||
prefix="/documents",
|
||||
tags=["documents"],
|
||||
dependencies=[Depends(require_admin), Depends(catalog_write_guard)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=Page[DocumentRead])
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.indexing import Chunk, IndexPublication, IndexVersion
|
||||
from app.schemas.catalog import Page
|
||||
from app.schemas.indexing import (
|
||||
ActiveIndex,
|
||||
BuildIndex,
|
||||
ChunkRead,
|
||||
IndexRead,
|
||||
PublicationRead,
|
||||
PublishIndex,
|
||||
)
|
||||
from app.services.auth import CurrentSession, require_admin
|
||||
from app.services.catalog import page_result
|
||||
from app.services.indexing import active_index, build_index, publish_index, version_or_404
|
||||
|
||||
router = APIRouter(prefix="/indexes", tags=["indexes"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
@router.post("", response_model=IndexRead, status_code=201)
|
||||
def build(
|
||||
payload: BuildIndex,
|
||||
current: CurrentSession = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return build_index(db, payload, current.user.id)
|
||||
|
||||
|
||||
@router.get("", response_model=Page[IndexRead])
|
||||
def list_versions(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return page_result(
|
||||
db,
|
||||
select(IndexVersion).order_by(IndexVersion.created_at.desc(), IndexVersion.id),
|
||||
page,
|
||||
page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/active", response_model=ActiveIndex)
|
||||
def active(db: Session = Depends(get_db)):
|
||||
return active_index(db)
|
||||
|
||||
|
||||
@router.get("/publications", response_model=Page[PublicationRead])
|
||||
def publications(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return page_result(
|
||||
db, select(IndexPublication).order_by(IndexPublication.generation.desc()), page, page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{version_id}", response_model=IndexRead)
|
||||
def read_version(version_id: UUID, db: Session = Depends(get_db)):
|
||||
return version_or_404(db, str(version_id))
|
||||
|
||||
|
||||
@router.post("/{version_id}/publish", response_model=ActiveIndex)
|
||||
def publish(
|
||||
version_id: UUID,
|
||||
payload: PublishIndex,
|
||||
current: CurrentSession = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return publish_index(db, version_id, payload.expected_generation, current.user.id)
|
||||
|
||||
|
||||
@router.get("/{version_id}/chunks", response_model=Page[ChunkRead])
|
||||
def chunks(
|
||||
version_id: UUID,
|
||||
knowledge_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),
|
||||
):
|
||||
version_or_404(db, str(version_id))
|
||||
statement = select(Chunk).where(Chunk.index_version_id == str(version_id))
|
||||
if knowledge_id is not None:
|
||||
statement = statement.where(Chunk.knowledge_id == knowledge_id)
|
||||
return page_result(db, statement.order_by(Chunk.knowledge_id, Chunk.ordinal), page, page_size)
|
||||
@@ -18,8 +18,13 @@ from app.services.catalog import (
|
||||
resolve_knowledge_links,
|
||||
update_values,
|
||||
)
|
||||
from app.services.indexing import catalog_write_guard
|
||||
|
||||
router = APIRouter(prefix="/knowledge", tags=["knowledge"], dependencies=[Depends(require_admin)])
|
||||
router = APIRouter(
|
||||
prefix="/knowledge",
|
||||
tags=["knowledge"],
|
||||
dependencies=[Depends(require_admin), Depends(catalog_write_guard)],
|
||||
)
|
||||
|
||||
|
||||
def filtered_query(q="", category=None, verification_status=None, source_id=None, document_id=None):
|
||||
|
||||
@@ -15,8 +15,13 @@ from app.services.catalog import (
|
||||
record_event,
|
||||
update_values,
|
||||
)
|
||||
from app.services.indexing import catalog_write_guard
|
||||
|
||||
router = APIRouter(prefix="/sources", tags=["sources"], dependencies=[Depends(require_admin)])
|
||||
router = APIRouter(
|
||||
prefix="/sources",
|
||||
tags=["sources"],
|
||||
dependencies=[Depends(require_admin), Depends(catalog_write_guard)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=Page[SourceRead])
|
||||
|
||||
+4
-3
@@ -8,7 +8,7 @@ from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
|
||||
from app.api.routes import auth, chat, documents, health, knowledge, retrieval, sources
|
||||
from app.api.routes import auth, chat, documents, health, indexes, knowledge, retrieval, sources
|
||||
from app.core.config import settings
|
||||
from app.db.session import engine
|
||||
from app.services.auth import dummy_hash
|
||||
@@ -21,7 +21,7 @@ def create_app(db_engine=engine) -> FastAPI:
|
||||
try:
|
||||
with db_engine.connect() as connection:
|
||||
revision = connection.scalar(text("SELECT version_num FROM alembic_version"))
|
||||
if revision != "0004_auth_sessions":
|
||||
if revision != "0005_chunk_indexes":
|
||||
raise RuntimeError(
|
||||
"数据库版本不匹配,请先在 backend 目录执行 alembic upgrade head"
|
||||
)
|
||||
@@ -36,7 +36,7 @@ def create_app(db_engine=engine) -> FastAPI:
|
||||
finally:
|
||||
db_engine.dispose()
|
||||
|
||||
app = FastAPI(title=settings.app_name, version="0.4.0", lifespan=lifespan)
|
||||
app = FastAPI(title=settings.app_name, version="0.5.0", lifespan=lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
@@ -47,6 +47,7 @@ def create_app(db_engine=engine) -> FastAPI:
|
||||
for router in (
|
||||
health.router,
|
||||
auth.router,
|
||||
indexes.router,
|
||||
chat.router,
|
||||
knowledge.router,
|
||||
sources.router,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from app.models.auth import AuthRateLimit, AuthSession, User
|
||||
from app.models.catalog import AuditEvent, Document, Source
|
||||
from app.models.indexing import Chunk, IndexPublication, IndexState, IndexVersion
|
||||
from app.models.knowledge import KnowledgeItem
|
||||
from app.models.retrieval import RetrievalRun
|
||||
|
||||
@@ -6,10 +8,13 @@ __all__ = [
|
||||
"AuditEvent",
|
||||
"AuthRateLimit",
|
||||
"AuthSession",
|
||||
"Chunk",
|
||||
"Document",
|
||||
"IndexPublication",
|
||||
"IndexState",
|
||||
"IndexVersion",
|
||||
"KnowledgeItem",
|
||||
"RetrievalRun",
|
||||
"Source",
|
||||
"User",
|
||||
]
|
||||
from app.models.auth import AuthRateLimit, AuthSession, User
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Immutable chunk snapshots and atomic publication metadata (no vector index yet)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class IndexVersion(Base):
|
||||
__tablename__ = "index_versions"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('ready', 'published', 'superseded')", name="ck_index_version_status"
|
||||
),
|
||||
)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ready")
|
||||
config: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
corpus_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
catalog_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
item_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
chunk_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class IndexState(Base):
|
||||
__tablename__ = "index_state"
|
||||
__table_args__ = (CheckConstraint("id = 1", name="ck_index_state_singleton"),)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
active_version_id: Mapped[str | None] = mapped_column(ForeignKey("index_versions.id"))
|
||||
generation: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
catalog_revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
class Chunk(Base):
|
||||
__tablename__ = "chunks"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("index_version_id", "knowledge_id", "ordinal", name="uq_chunk_position"),
|
||||
CheckConstraint("start_offset >= 0 AND end_offset > start_offset", name="ck_chunk_offsets"),
|
||||
)
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
index_version_id: Mapped[str] = mapped_column(ForeignKey("index_versions.id"), index=True)
|
||||
knowledge_id: Mapped[int] = mapped_column(ForeignKey("knowledge_items.id"), nullable=False)
|
||||
ordinal: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
start_offset: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
end_offset: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
text_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
provenance: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
|
||||
|
||||
class IndexPublication(Base):
|
||||
__tablename__ = "index_publications"
|
||||
generation: Mapped[int] = mapped_column(primary_key=True)
|
||||
previous_version_id: Mapped[str | None] = mapped_column(ForeignKey("index_versions.id"))
|
||||
version_id: Mapped[str] = mapped_column(ForeignKey("index_versions.id"), nullable=False)
|
||||
actor_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
published_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.schemas.catalog import Input
|
||||
|
||||
|
||||
class BuildIndex(Input):
|
||||
chunk_size: int = Field(500, ge=100, le=2000)
|
||||
overlap: int = Field(80, ge=0, le=1000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def bounded_overlap(self):
|
||||
if self.overlap > self.chunk_size // 2:
|
||||
raise ValueError("重叠不得超过切分长度的一半")
|
||||
return self
|
||||
|
||||
|
||||
class PublishIndex(Input):
|
||||
expected_generation: int = Field(ge=0)
|
||||
|
||||
|
||||
class IndexConfig(BuildIndex):
|
||||
splitter: Literal["unicode-window-v1"]
|
||||
|
||||
|
||||
class ChunkProvenance(BaseModel):
|
||||
knowledge_id: int
|
||||
knowledge_version: int
|
||||
document_id: int | None
|
||||
document_version: int | None
|
||||
source_id: int | None
|
||||
source_version: int | None
|
||||
source_name: str | None
|
||||
source_url: str
|
||||
title: str
|
||||
category: str
|
||||
|
||||
|
||||
class TimestampRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_validator("created_at", "published_at", check_fields=False)
|
||||
@classmethod
|
||||
def utc_timestamp(cls, value):
|
||||
return value.astimezone(timezone.utc) if value is not None else None
|
||||
|
||||
|
||||
class IndexRead(TimestampRead):
|
||||
id: UUID
|
||||
status: Literal["ready", "published", "superseded"]
|
||||
config: IndexConfig
|
||||
corpus_fingerprint: str
|
||||
catalog_revision: int
|
||||
item_count: int
|
||||
chunk_count: int
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
published_at: datetime | None
|
||||
|
||||
|
||||
class ActiveIndex(BaseModel):
|
||||
generation: int
|
||||
catalog_revision: int
|
||||
is_current: bool
|
||||
active_version: IndexRead | None
|
||||
|
||||
|
||||
class ChunkRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
index_version_id: UUID
|
||||
knowledge_id: int
|
||||
ordinal: int
|
||||
start_offset: int
|
||||
end_offset: int
|
||||
text: str
|
||||
text_sha256: str
|
||||
provenance: ChunkProvenance
|
||||
|
||||
|
||||
class PublicationRead(TimestampRead):
|
||||
generation: int
|
||||
previous_version_id: UUID | None
|
||||
version_id: UUID
|
||||
actor_id: int
|
||||
published_at: datetime
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Bounded chunk snapshots and serialized, optimistic publication."""
|
||||
|
||||
import hashlib
|
||||
from uuid import UUID, uuid4, uuid5
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.catalog import utc_now
|
||||
from app.models.indexing import Chunk, IndexPublication, IndexState, IndexVersion
|
||||
from app.schemas.indexing import ActiveIndex, BuildIndex, IndexRead
|
||||
from app.services.retrieval import corpus_fingerprint, load_corpus
|
||||
|
||||
MAX_CHUNKS = 20_000
|
||||
|
||||
|
||||
def locked_state(db: Session) -> IndexState:
|
||||
state = db.scalar(
|
||||
select(IndexState)
|
||||
.where(IndexState.id == 1)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
if state is None:
|
||||
raise HTTPException(503, "索引状态尚未初始化,请检查数据库迁移")
|
||||
return state
|
||||
|
||||
|
||||
def catalog_write_guard(request: Request, db: Session = Depends(get_db)):
|
||||
if request.method in {"POST", "PATCH", "PUT", "DELETE"}:
|
||||
# Always before catalog/parent row locks; failures roll back this revision as well.
|
||||
state = locked_state(db)
|
||||
state.catalog_revision += 1
|
||||
|
||||
|
||||
def version_or_404(db: Session, version_id: str) -> IndexVersion:
|
||||
version = db.get(IndexVersion, version_id)
|
||||
if version is None:
|
||||
raise HTTPException(404, "索引版本不存在")
|
||||
return version
|
||||
|
||||
|
||||
def split_text(content: str, size: int, overlap: int):
|
||||
"""Offsets are original Python Unicode character offsets, end-exclusive."""
|
||||
for start in range(0, len(content), size - overlap):
|
||||
end = min(len(content), start + size)
|
||||
yield start, end, content[start:end]
|
||||
if end == len(content):
|
||||
break
|
||||
|
||||
|
||||
def build_index(db: Session, payload: BuildIndex, actor_id: int) -> IndexRead:
|
||||
state = locked_state(db)
|
||||
corpus = load_corpus(db, None)
|
||||
version_id = uuid4()
|
||||
chunks = []
|
||||
for item in corpus:
|
||||
provenance = {key: value for key, value in item.items() if key != "content"}
|
||||
for ordinal, (start, end, content) in enumerate(
|
||||
split_text(item["content"], payload.chunk_size, payload.overlap)
|
||||
):
|
||||
if len(chunks) >= MAX_CHUNKS:
|
||||
raise HTTPException(503, "切分数量超过本阶段容量,请增加切分长度或减少重叠")
|
||||
chunks.append(
|
||||
Chunk(
|
||||
id=str(uuid5(version_id, f"{item['knowledge_id']}:{ordinal}")),
|
||||
index_version_id=str(version_id),
|
||||
knowledge_id=item["knowledge_id"],
|
||||
ordinal=ordinal,
|
||||
start_offset=start,
|
||||
end_offset=end,
|
||||
text=content,
|
||||
text_sha256=hashlib.sha256(content.encode()).hexdigest(),
|
||||
provenance=provenance,
|
||||
)
|
||||
)
|
||||
version = IndexVersion(
|
||||
id=str(version_id),
|
||||
status="ready",
|
||||
config={"splitter": "unicode-window-v1", **payload.model_dump()},
|
||||
corpus_fingerprint=corpus_fingerprint(corpus),
|
||||
catalog_revision=state.catalog_revision,
|
||||
item_count=len(corpus),
|
||||
chunk_count=len(chunks),
|
||||
created_by=actor_id,
|
||||
)
|
||||
db.add(version)
|
||||
db.flush()
|
||||
db.add_all(chunks)
|
||||
result = IndexRead.model_validate(version)
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
|
||||
def active_index(db: Session, state: IndexState | None = None) -> ActiveIndex:
|
||||
state = state or locked_state(db)
|
||||
version = db.get(IndexVersion, state.active_version_id) if state.active_version_id else None
|
||||
current = bool(version and version.catalog_revision == state.catalog_revision)
|
||||
if current:
|
||||
current = version.corpus_fingerprint == corpus_fingerprint(load_corpus(db, None))
|
||||
return ActiveIndex(
|
||||
generation=state.generation,
|
||||
catalog_revision=state.catalog_revision,
|
||||
is_current=current,
|
||||
active_version=IndexRead.model_validate(version) if version else None,
|
||||
)
|
||||
|
||||
|
||||
def publish_index(db: Session, version_id: UUID, expected_generation: int, actor_id: int):
|
||||
state = locked_state(db)
|
||||
if state.generation != expected_generation:
|
||||
raise HTTPException(409, "已发布版本发生变化,请刷新后重试")
|
||||
version = version_or_404(db, str(version_id))
|
||||
if (
|
||||
version.catalog_revision != state.catalog_revision
|
||||
or version.corpus_fingerprint != corpus_fingerprint(load_corpus(db, None))
|
||||
):
|
||||
raise HTTPException(409, "资料或审核依据已变化,请重新构建后发布")
|
||||
if state.active_version_id == version.id:
|
||||
return active_index(db, state)
|
||||
previous = state.active_version_id
|
||||
if previous:
|
||||
db.get(IndexVersion, previous).status = "superseded"
|
||||
state.active_version_id = version.id
|
||||
state.generation += 1
|
||||
version.status = "published"
|
||||
version.published_at = utc_now()
|
||||
db.add(
|
||||
IndexPublication(
|
||||
generation=state.generation,
|
||||
previous_version_id=previous,
|
||||
version_id=version.id,
|
||||
actor_id=actor_id,
|
||||
published_at=version.published_at,
|
||||
)
|
||||
)
|
||||
# Freeze the response before commit expires ORM objects and releases the lock.
|
||||
result = ActiveIndex(
|
||||
generation=expected_generation + 1,
|
||||
catalog_revision=version.catalog_revision,
|
||||
is_current=True,
|
||||
active_version=IndexRead.model_validate(version),
|
||||
)
|
||||
db.commit()
|
||||
return result
|
||||
@@ -151,15 +151,19 @@ def load_corpus(db: Session, category: str | None) -> list[dict]:
|
||||
return corpus
|
||||
|
||||
|
||||
def corpus_fingerprint(corpus: list[dict]) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(corpus, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def retrieve(db: Session, payload: RetrievalRequest) -> RetrievalResponse:
|
||||
started = perf_counter()
|
||||
terms = list(dict.fromkeys(tokenize(payload.query)))
|
||||
if not terms:
|
||||
raise HTTPException(422, "问题中没有可检索词,请补充具体关键词")
|
||||
corpus = load_corpus(db, payload.category)
|
||||
fingerprint = hashlib.sha256(
|
||||
json.dumps(corpus, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
fingerprint = corpus_fingerprint(corpus)
|
||||
counters = [
|
||||
Counter(tokenize(item["title"]) * CONFIG.title_weight + tokenize(item["content"]))
|
||||
for item in corpus
|
||||
|
||||
Reference in New Issue
Block a user