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)
|
||||
Reference in New Issue
Block a user