This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
shira-hermes/api/services/kb/admin_sources.py
T
chaim 0d678da25f feat(kb): backend for v0.8.0 — tool kind + labels + AI classifier + bulk upload
Phase 6 of the multi-topic KB refactor (backend half). Subsumes the
original Task #19 design discussion (subject vs labels). EspoCRM
extension UI ships separately.

Migrations (4 new, all idempotent, transaction-wrapped):
  004_kind_tool             — adds 'tool' to kb_source/kb_ingest_job CHECK
  005_source_description    — kb_source.description + ai_classified_at
  006_labels                — kb_label + kb_source_label (M:N), GRANTs
  007_ingest_classifier     — awaiting_review status + processing_stage
                              + ai_suggestions JSONB + batch_id

Two-phase ingest (api/services/kb/ingest_jobs.py):
  queued → processing(stage=classifying)
         → awaiting_review                  ← user reviews AI output
         → processing(stage=embedding)      ← after user commits
         → done | failed

  process_classify_stage() pulls the file, runs parse_first_pages
  (3-page text extract), calls classifier.classify (Claude tool-call
  via ai-gateway, 45s timeout, 5-concurrent semaphore, fail-soft on
  any error → empty suggestions), writes ai_suggestions JSONB,
  transitions to awaiting_review.

  commit_job() resolves user-confirmed labels (existing by slug,
  new ones via slugify+ON CONFLICT DO NOTHING), transitions to
  processing(embedding), schedules process_embed_stage.

  process_embed_stage() runs the legacy ingest_source path with
  user-edited metadata + summary as kb_source.description, then
  apply_to_source for labels.

  discard_job() removes a file from a batch (status=failed,
  S3 deleted).

  list_jobs_by_batch() — single-roundtrip review-screen load.

Labels (NEW api/services/kb/labels.py):
  Hebrew → ASCII slugify (letter-by-letter map, no external dep);
  CRUD; lookup_or_create_batch (race-safe); apply_to_source +
  replace_for_source maintain usage_count; merge race-safely
  (UPDATE assignments + ON CONFLICT DO NOTHING).

Classifier (NEW api/services/kb/classifier.py):
  AsyncOpenAI to ai-gateway, Sonnet, OpenAI-style tool calling for
  guaranteed JSON shape, Hebrew system prompt with 5 kind values,
  citation format examples, "do not invent" rule, summary length
  bounds 80-200 chars.

Routes (api/routes/admin_kb.py — 8 new on top of existing 11):
  POST   /admin/kb/upload-batch                  (multi-file, AI classify)
  GET    /admin/kb/batch/{batch_id}              (review-screen load)
  POST   /admin/kb/jobs/{id}/commit              (user confirms metadata)
  POST   /admin/kb/jobs/{id}/discard             (user removes from batch)
  GET    /admin/kb/labels                        (typeahead)
  POST   /admin/kb/labels                        (explicit create)
  POST   /admin/kb/labels/{id}/merge             (admin cleanup)
  DELETE /admin/kb/labels/{id}                   (only if usage=0)

Public /kb/search gains optional label_id filter.
admin_sources.list_sources LEFT JOINs labels into each row's
output. update_source accepts label_ids to replace the set.

End-to-end smoke test passed: synthetic Hebrew circular →
upload-batch → background classify → awaiting_review with kind,
title, subject, summary, identifier, published_at all populated
correctly.

Refs Task Master #20 (espocrm-extensions/KnowledgeBase v0.8.0)
Subsumes: Task #19 (labels for sub-topics)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 18:42:15 +00:00

485 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Source management for the KB admin panel (Phase 3 / Task #14).
Today the only way to clean up or correct a source is `DELETE FROM
kb_source WHERE id=...` on the shared PG plus an mc rm in MinIO. This
module backs the new sources table in the EspoCRM "ניהול" tab:
list_sources — table view with chunk count and last-ingest status
update_source — patch the user-editable metadata
(title, identifier, dates, source_url)
delete_source — hard delete (cascades chunks; drops S3 object)
start_reingest — re-parse the existing file in place; preserves
source_id and metadata, replaces chunks
process_reingest_job — async background worker that does the actual
parse→chunk→embed and swaps chunks in a single
transaction (UI polls the same /admin/kb/jobs
endpoint as upload jobs)
The reingest path differs from ingest_source: ingest_source creates a
NEW kb_source row and supersedes the previous version. Reingest keeps
the same row id (so cached _lastSearch chunk_index references in the
browser still point at a valid source), wipes its chunks, and inserts
fresh ones from the same file. Hand-edited metadata is preserved.
"""
from __future__ import annotations
import datetime as _dt
import json
import logging
import re
from typing import Optional
from api.services.kb import ingest as kb_ingest
from api.services.kb import s3 as kb_s3
from api.services.kb import voyage
from api.services.kb.chunker import chunk as chunk_text
from api.services.kb.db import get_pool
logger = logging.getLogger("shira.kb.admin_sources")
# Whitelist of fields the user can edit through PUT /admin/kb/sources/{id}.
# Anything else (kind, topic_id, checksum, source_id, ...) is structural
# and must not be flipped from a metadata edit.
_EDITABLE_FIELDS = ("title", "identifier", "source_url", "published_at",
"effective_at", "description")
# ── Listing ─────────────────────────────────────────────────────────────────
async def list_sources(
*,
topic_id: Optional[int] = None,
kind: Optional[str] = None,
label_id: Optional[int] = None,
limit: int = 200,
) -> list[dict]:
"""Source rows enriched with chunk_count, last_ingest_status, and labels.
The last-ingest column is a LATERAL join against kb_ingest_job — picks
the most recent job row for the source (regardless of status), so the
UI can flag sources whose last re-ingest failed.
Labels are aggregated as a JSON array via a correlated subquery —
keeps the row count clean (no N×labels Cartesian product).
"""
limit = max(1, min(500, limit))
where: list[str] = ["s.superseded_by IS NULL"]
args: list = []
if topic_id is not None:
args.append(topic_id)
where.append(f"s.topic_id = ${len(args)}")
if kind:
args.append(kind)
where.append(f"s.kind = ${len(args)}")
if label_id is not None:
args.append(label_id)
where.append(f"EXISTS (SELECT 1 FROM kb_source_label sl "
f"WHERE sl.source_id = s.id AND sl.label_id = ${len(args)})")
args.append(limit)
sql = f"""
SELECT
s.id,
s.kind,
s.title,
s.identifier,
s.published_at,
s.effective_at,
s.source_url,
s.original_path,
s.checksum,
s.topic_id,
s.description,
s.ai_classified_at,
s.created_at,
s.updated_at,
(SELECT COUNT(*) FROM kb_chunk c WHERE c.source_id = s.id) AS chunk_count,
(
SELECT json_build_object(
'job_id', j.id,
'status', j.status,
'created_at', j.created_at,
'completed_at', j.completed_at,
'error_message', j.error_message
)
FROM kb_ingest_job j
WHERE j.source_id = s.id
ORDER BY j.created_at DESC
LIMIT 1
) AS last_ingest,
COALESCE(
(
SELECT json_agg(json_build_object(
'id', l.id, 'slug', l.slug, 'name', l.name,
'topic_id', l.topic_id
) ORDER BY l.name)
FROM kb_label l
JOIN kb_source_label sl ON sl.label_id = l.id
WHERE sl.source_id = s.id
),
'[]'::json
) AS labels
FROM kb_source s
WHERE {' AND '.join(where)}
ORDER BY s.kind, s.title
LIMIT ${len(args)}
"""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(sql, *args)
return [_row_to_dict(r) for r in rows]
async def get_source(source_id: int) -> Optional[dict]:
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM kb_source WHERE id = $1",
source_id,
)
return _row_to_dict(row) if row else None
# ── Editing ─────────────────────────────────────────────────────────────────
async def update_source(
source_id: int,
fields: dict,
*,
label_ids: Optional[list[int]] = None,
) -> dict:
"""UPDATE only whitelisted columns. Returns the post-update row.
If `label_ids` is provided (not None), the source's label set is
REPLACED to exactly those ids — usage_count adjusted both ways.
Pass an empty list to clear all labels; pass None to leave labels
untouched.
"""
from api.services.kb import labels as kb_labels # local import: cycle
clean: dict = {}
for k in _EDITABLE_FIELDS:
if k not in fields:
continue
v = fields[k]
if k in ("published_at", "effective_at"):
clean[k] = kb_ingest._coerce_date(v)
elif v is None or v == "":
clean[k] = None
else:
clean[k] = str(v).strip()
if "title" in clean and not clean["title"]:
raise ValueError("title cannot be empty")
pool = await get_pool()
if clean:
set_pieces = []
args: list = [source_id]
for k, v in clean.items():
args.append(v)
set_pieces.append(f"{k} = ${len(args)}")
sql = f"""
UPDATE kb_source
SET {', '.join(set_pieces)}
WHERE id = $1
RETURNING id
"""
async with pool.acquire() as conn:
updated = await conn.fetchrow(sql, *args)
if not updated:
raise LookupError(f"source {source_id} not found")
else:
# Caller passed nothing editable; verify the source still exists
# so we can return a sensible 404 vs blindly returning empty.
async with pool.acquire() as conn:
existing = await conn.fetchrow(
"SELECT id FROM kb_source WHERE id = $1", source_id
)
if not existing:
raise LookupError(f"source {source_id} not found")
if label_ids is not None:
await kb_labels.replace_for_source(source_id, label_ids)
# Re-read with labels via list_sources([id])-style query — reuse
# get_source() for simplicity, then fetch labels separately.
row = await get_source(source_id)
if row is not None:
row["labels"] = await kb_labels.get_labels_for_source(source_id)
return row
# ── Deletion ────────────────────────────────────────────────────────────────
async def delete_source(source_id: int) -> dict:
"""Hard-delete a source. kb_chunk rows cascade via FK; the underlying
S3 object is deleted on a best-effort basis (a stray file is harmless,
but cluttered).
"""
src = await get_source(source_id)
if not src:
raise LookupError(f"source {source_id} not found")
pool = await get_pool()
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM kb_source WHERE id = $1", source_id
)
deleted = result.endswith(" 1")
# Best-effort S3 cleanup — never fail the whole delete on a stray
# object that might already be gone.
s3_deleted = False
bucket, key = _parse_s3_uri(src.get("original_path") or "")
if key:
try:
kb_s3._client().delete_object(Bucket=bucket or kb_s3._bucket(), Key=key)
s3_deleted = True
except Exception as exc:
logger.warning("[admin.delete] S3 delete failed for %s: %s", key, exc)
return {"deleted": deleted, "source_id": source_id, "s3_deleted": s3_deleted}
# ── Re-ingest ───────────────────────────────────────────────────────────────
async def start_reingest(source_id: int, requested_by_user: Optional[str]) -> int:
"""Create a kb_ingest_job tied to the existing source_id.
The actual work is in `process_reingest_job`, scheduled via
`asyncio.create_task` from the route. Returns the new job_id so the
browser can poll using the same /admin/kb/jobs/{id} endpoint as
upload jobs.
"""
src = await get_source(source_id)
if not src:
raise LookupError(f"source {source_id} not found")
if not src.get("original_path"):
raise ValueError(
f"source {source_id} has no original_path — re-ingest needs the original file"
)
# Pre-flight: confirm the file actually exists in S3 before queuing
# the job. Saves the user from waiting 30s on a doomed job.
bucket, key = _parse_s3_uri(src["original_path"])
if not key:
raise ValueError(f"unparseable original_path: {src['original_path']}")
try:
kb_s3._client().head_object(Bucket=bucket or kb_s3._bucket(), Key=key)
except Exception as exc:
raise ValueError(f"original file unavailable: {exc}")
# Reuse the kb_ingest_job table; mark this row as a re-ingest by
# pre-filling source_id (uploads start with source_id NULL).
metadata = {
"title": src.get("title"),
"identifier": src.get("identifier"),
"published_at": _date_to_iso(src.get("published_at")),
"effective_at": _date_to_iso(src.get("effective_at")),
"source_url": src.get("source_url"),
"reingest": True,
}
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO kb_ingest_job
(source_id, original_filename, s3_key, kind, topic_id,
metadata_json, status, requested_by_user)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'queued', $7)
RETURNING id
""",
source_id,
src.get("title") or _filename_from_key(key),
key,
src["kind"],
src["topic_id"],
json.dumps(metadata, ensure_ascii=False),
requested_by_user,
)
job_id = row["id"]
logger.info("[admin.reingest] queued job_id=%s source_id=%s", job_id, source_id)
return job_id
async def process_reingest_job(job_id: int) -> None:
"""Background worker for re-ingest. Replaces chunks in-place; preserves
the kb_source row and its metadata.
Errors are written to the job row (status='failed', error_message=...)
so the polling UI never gets stuck on 'processing'.
"""
pool = await get_pool()
async with pool.acquire() as conn:
job_row = await conn.fetchrow(
"""
UPDATE kb_ingest_job
SET status = 'processing', started_at = now()
WHERE id = $1 AND status = 'queued'
RETURNING *
""",
job_id,
)
if not job_row:
logger.warning("[reingest] job %s not in queued state, skipping", job_id)
return
job = _row_to_dict(job_row)
source_id = job["source_id"]
if source_id is None:
await _fail_job(pool, job_id, "re-ingest job has no source_id")
return
try:
bucket, key = _parse_s3_uri(job["s3_key"]) # s3_key here is just the key
# If s3_key is a bare key (no s3:// prefix), _parse_s3_uri returns
# ("", "") — fall back to the bare key.
if not key:
key = job["s3_key"]
data = kb_s3._client().get_object(
Bucket=bucket or kb_s3._bucket(),
Key=key,
)["Body"].read()
if not data:
raise kb_ingest.IngestError(f"empty file at {key}")
text = kb_ingest.parse(data, job.get("original_filename") or key)
if not text:
raise kb_ingest.IngestError("no text extracted")
chunks = chunk_text(text, job["kind"])
if not chunks:
raise kb_ingest.IngestError("chunker produced zero chunks")
# Embed first (slow, network-bound) so the actual DB swap is fast.
texts = [c["content"] for c in chunks]
vectors = await voyage.embed(texts, input_type="document")
import hashlib
new_checksum = hashlib.sha256(text.encode("utf-8")).hexdigest()
async with pool.acquire() as conn:
async with conn.transaction():
# Drop existing chunks and reinsert in one tx so we never
# leave the source partially-chunked.
await conn.execute(
"DELETE FROM kb_chunk WHERE source_id = $1", source_id
)
rows = [
(
source_id,
c["chunk_index"],
c["heading_path"],
c["section_ref"],
c["content"],
vectors[i],
c["token_count"],
c.get("page_number"),
)
for i, c in enumerate(chunks)
]
await conn.executemany(
"""
INSERT INTO kb_chunk
(source_id, chunk_index, heading_path, section_ref,
content, embedding, token_count, page_number)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
""",
rows,
)
# Update only the structural fields that depend on the file
# (checksum). Hand-edited metadata stays as-is.
await conn.execute(
"UPDATE kb_source SET checksum = $2 WHERE id = $1",
source_id, new_checksum,
)
await conn.execute(
"""
UPDATE kb_ingest_job
SET status = 'done',
chunks_created = $2,
completed_at = now()
WHERE id = $1
""",
job_id, len(chunks),
)
logger.info(
"[reingest] done job_id=%s source_id=%s chunks=%d",
job_id, source_id, len(chunks),
)
except Exception as exc:
logger.exception("[reingest] failed job_id=%s", job_id)
await _fail_job(pool, job_id, str(exc))
# ── helpers ─────────────────────────────────────────────────────────────────
async def _fail_job(pool, job_id: int, msg: str) -> None:
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE kb_ingest_job
SET status = 'failed',
error_message = $2,
completed_at = now()
WHERE id = $1
""",
job_id, msg[:4000],
)
def _row_to_dict(row) -> Optional[dict]:
if row is None:
return None
out: dict = {}
for k, v in dict(row).items():
if isinstance(v, _dt.datetime):
out[k] = v.isoformat()
elif isinstance(v, _dt.date):
out[k] = v.isoformat()
elif k == "metadata_json" and isinstance(v, str):
try:
out[k] = json.loads(v)
except Exception:
out[k] = {}
elif k == "last_ingest" and isinstance(v, str):
try:
out[k] = json.loads(v)
except Exception:
out[k] = None
elif k == "labels" and isinstance(v, str):
try:
out[k] = json.loads(v)
except Exception:
out[k] = []
else:
out[k] = v
return out
_S3_URI = re.compile(r"^s3://([^/]+)/(.+)$")
def _parse_s3_uri(uri: str) -> tuple[str, str]:
"""('bucket', 'key/path') or ('', '') if not an s3:// URI."""
if not uri:
return ("", "")
m = _S3_URI.match(uri)
if not m:
return ("", "")
return (m.group(1), m.group(2))
def _filename_from_key(key: str) -> str:
return key.rsplit("/", 1)[-1] if key else "source"
def _date_to_iso(value) -> Optional[str]:
if value is None:
return None
if isinstance(value, str):
return value[:10] if value else None
if isinstance(value, _dt.date):
return value.isoformat()
return None