feat(kb): admin source management endpoints
Backs the Phase 3 sources table in the KnowledgeBase EspoCRM extension
("ניהול" tab). Four new admin endpoints:
GET /admin/kb/sources — list with chunk_count and
last-ingest status (LATERAL
join against kb_ingest_job),
filterable by topic_id + kind
PUT /admin/kb/sources/{id} — patch the user-editable
metadata only (title,
identifier, source_url,
published_at, effective_at);
structural fields (kind,
topic_id, checksum) are not
accepted
DELETE /admin/kb/sources/{id} — hard delete (kb_chunk cascades
via FK; the S3 object at
original_path is best-effort
deleted)
POST /admin/kb/sources/{id}/reingest — queues a kb_ingest_job tied
to the existing source_id;
background task replaces
chunks in-place, preserves
the source row and its
hand-edited metadata, and
updates the checksum
The reingest path differs from ingest_source: ingest_source creates a
new kb_source row and supersedes the previous version. Reingest keeps
the same id (so cached chunk_index references in the browser stay
valid), wipes its kb_chunk rows in a single transaction, and inserts
fresh ones from the same file.
Refs Task Master #14 (espocrm-extensions/KnowledgeBase)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,9 @@ import os
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.services.kb import admin_sources as kb_admin_sources
|
||||
from api.services.kb import ingest as kb_ingest
|
||||
from api.services.kb import ingest_jobs as kb_jobs
|
||||
from api.services.kb import s3 as kb_s3
|
||||
@@ -316,3 +318,72 @@ async def get_ingest_job(request: Request, job_id: int):
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
|
||||
# ── Phase 3: source management (Task #14) ──────────────────────────────────
|
||||
|
||||
class SourceUpdate(BaseModel):
|
||||
"""Whitelist of editable fields. Anything outside this is rejected."""
|
||||
title: str | None = Field(None, max_length=500)
|
||||
identifier: str | None = Field(None, max_length=200)
|
||||
source_url: str | None = Field(None, max_length=1000)
|
||||
published_at: str | None = None
|
||||
effective_at: str | None = None
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
async def list_admin_sources(
|
||||
request: Request,
|
||||
topic_id: int | None = None,
|
||||
kind: str | None = None,
|
||||
limit: int = 200,
|
||||
):
|
||||
_verify_admin(request)
|
||||
if kind and kind not in ("law", "regulation", "circular", "caselaw"):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid kind: {kind}")
|
||||
items = await kb_admin_sources.list_sources(
|
||||
topic_id=topic_id, kind=kind, limit=limit
|
||||
)
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.put("/sources/{source_id}")
|
||||
async def update_admin_source(request: Request, source_id: int, body: SourceUpdate):
|
||||
_verify_admin(request)
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
try:
|
||||
updated = await kb_admin_sources.update_source(source_id, fields)
|
||||
except LookupError:
|
||||
raise HTTPException(status_code=404, detail="Source not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/sources/{source_id}")
|
||||
async def delete_admin_source(request: Request, source_id: int):
|
||||
_verify_admin(request)
|
||||
try:
|
||||
result = await kb_admin_sources.delete_source(source_id)
|
||||
except LookupError:
|
||||
raise HTTPException(status_code=404, detail="Source not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/sources/{source_id}/reingest")
|
||||
async def reingest_admin_source(request: Request, source_id: int):
|
||||
_verify_admin(request)
|
||||
requested_by = (
|
||||
request.headers.get("X-User-Name")
|
||||
or request.headers.get("X-Forwarded-User")
|
||||
or None
|
||||
)
|
||||
try:
|
||||
job_id = await kb_admin_sources.start_reingest(source_id, requested_by)
|
||||
except LookupError:
|
||||
raise HTTPException(status_code=404, detail="Source not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
asyncio.create_task(kb_admin_sources.process_reingest_job(job_id))
|
||||
return {"job_id": job_id, "status": "queued", "source_id": source_id}
|
||||
|
||||
Reference in New Issue
Block a user