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:
2026-04-25 17:19:51 +00:00
parent 16eeeff7b6
commit 17f93b5f3e
2 changed files with 502 additions and 0 deletions
+71
View File
@@ -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}