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 4ad569d54c feat(kb-reingest): re-classify metadata via AI on every re-process
Old behavior: clicking "עיבוד מחדש" on a source preserved the existing
metadata (title, identifier, dates, summary, labels) and only re-chunked
+ re-embedded the file. Sources whose original AI classification produced
weak metadata (e.g. title="החלטה" with no summary) had no path to recover
short of deleting and re-uploading.

New behavior: re-process now goes through the same two-phase pipeline as
a fresh upload — classify (AI extracts metadata) → awaiting_review (user
sees suggestions in the batch review screen and edits) → commit (kb_source
metadata is overwritten + chunks are replaced + labels are replaced).

Implementation:
  • start_reingest now returns {job_id, batch_id} and pre-fills metadata_json
    with the existing source's labels + summary so the form has a fallback
    if the classifier times out.
  • The reingest route schedules ingest_jobs.process_classify_stage instead
    of admin_sources.process_reingest_job.
  • commit_job branches on job.source_id: NULL → fresh upload (creates new
    kb_source); set → re-ingest (UPDATE kb_source + replace chunks).
  • New process_reingest_embed_stage replaces the old process_reingest_job;
    it bypasses _EDITABLE_FIELDS so the user can also change kind on
    re-process (e.g. caselaw uploaded as circular).

The browser opens the same batch review screen, so the UX matches uploads
exactly. Re-ingests are single-file batches.

Frontend changes (KnowledgeBase extension v0.9.0) ship in a separate repo.
2026-04-28 17:10:49 +00:00

584 lines
22 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 — kick off the two-phase re-ingest pipeline:
classify → awaiting_review → commit. The
classify stage reuses
ingest_jobs.process_classify_stage so the
user gets fresh AI metadata suggestions in
the same review UI as fresh uploads.
process_reingest_embed_stage — final stage after the user commits:
UPDATE kb_source metadata + replace labels +
re-chunk and re-embed (the original
process_reingest_job logic, minus the
"preserve metadata" rule).
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. As of v0.9.0, hand-edited metadata is
NO LONGER preserved silently — the user reviews AI suggestions and
edits them on the batch review screen before committing, so any
overwrite is explicit. Sources whose AI-extracted metadata was thin
(e.g. title="החלטה" only) get fresh suggestions on every re-process.
"""
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]
) -> dict:
"""Create a kb_ingest_job tied to the existing source_id.
The job enters the same two-phase pipeline as a fresh upload —
classify (AI metadata extraction) → awaiting_review → embed
(re-chunk + UPDATE kb_source) — so the user can review suggestions
on the batch review screen before anything overwrites kb_source.
Returns {"job_id", "batch_id"}; the route schedules
`ingest_jobs.process_classify_stage(job_id)` after this, and the
browser opens `/admin/kb/batch/<batch_id>` to poll the review UI.
"""
import uuid as _uuid
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). The
# batch_id is auto-generated so the existing review UI works
# without changes — re-ingests are single-file batches. Existing
# labels go into metadata_json so the review form can pre-fill
# them as a fallback when the AI classifier hasn't run yet (or
# returns empty).
from api.services.kb import labels as kb_labels # local import: cycle
existing_labels = await kb_labels.get_labels_for_source(source_id)
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"),
"summary": src.get("description"),
"labels": [lbl.get("name") for lbl in existing_labels if lbl.get("name")],
"reingest": True,
}
batch_id = _uuid.uuid4().hex
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, batch_id)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'queued', $7, $8)
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,
batch_id,
)
job_id = row["id"]
logger.info(
"[admin.reingest] queued job_id=%s source_id=%s batch_id=%s",
job_id, source_id, batch_id,
)
return {"job_id": job_id, "batch_id": batch_id}
async def process_reingest_embed_stage(
job_id: int, fields: dict, label_ids: list[int],
) -> None:
"""Final stage of the two-phase re-ingest pipeline.
Called from `ingest_jobs.commit_job` after the user reviewed the AI
suggestions. The job is already in `status='processing',
processing_stage='embedding'` at this point — commit_job set that
transition before scheduling us.
Steps:
1. UPDATE kb_source with the user-confirmed metadata (kind,
title, identifier, source_url, dates, description=summary).
2. Replace the source's labels with `label_ids`.
3. Re-parse the file → chunk → embed → atomic DELETE+INSERT
of kb_chunk rows + checksum update.
4. Mark the job done.
Failures here are real failures (the user already confirmed). They
surface as `status='failed'` so the polling UI lets the user retry.
"""
from api.services.kb import labels as kb_labels # local import: cycle
pool = await get_pool()
async with pool.acquire() as conn:
job_row = await conn.fetchrow(
"SELECT * FROM kb_ingest_job WHERE id = $1", job_id,
)
if not job_row:
logger.warning("[reingest-embed] job %s vanished", 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:
# Step 1: UPDATE kb_source with the user-confirmed metadata.
# commit_job's `fields` dict mirrors JobCommit: kind, title,
# identifier, source_url, published_at, effective_at, summary.
# kb_source.description is the schema name for "summary".
kind = (fields.get("kind") or job["kind"]).strip()
title = (fields.get("title") or "").strip()
if not title:
raise ValueError("title is empty after commit — aborting re-ingest")
await _update_source_metadata_for_reingest(
pool=pool,
source_id=source_id,
kind=kind,
title=title,
identifier=fields.get("identifier"),
source_url=fields.get("source_url"),
published_at=fields.get("published_at"),
effective_at=fields.get("effective_at"),
description=fields.get("summary"),
)
# Step 2: replace labels (empty list clears all).
await kb_labels.replace_for_source(source_id, label_ids or [])
# Step 3: re-chunk + re-embed.
bucket, key = _parse_s3_uri(job["s3_key"])
if not key:
key = job["s3_key"] # bare key, no s3:// prefix
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, 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,
)
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',
processing_stage = NULL,
chunks_created = $2,
completed_at = now()
WHERE id = $1
""",
job_id, len(chunks),
)
logger.info(
"[reingest-embed] done job_id=%s source_id=%s chunks=%d labels=%d",
job_id, source_id, len(chunks), len(label_ids or []),
)
except Exception as exc:
logger.exception("[reingest-embed] failed job_id=%s", job_id)
await _fail_job(pool, job_id, str(exc))
async def _update_source_metadata_for_reingest(
*, pool, source_id: int, kind: str, title: str,
identifier: Optional[str], source_url: Optional[str],
published_at: Optional[str], effective_at: Optional[str],
description: Optional[str],
) -> None:
"""UPDATE kb_source after a re-ingest commit. Bypasses _EDITABLE_FIELDS
so we can also update `kind` (the user explicitly chose it on the
review screen — e.g. caselaw uploaded as circular by mistake).
"""
pub = kb_ingest._coerce_date(published_at) if published_at else None
eff = kb_ingest._coerce_date(effective_at) if effective_at else None
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE kb_source
SET kind = $2,
title = $3,
identifier = $4,
source_url = $5,
published_at = $6,
effective_at = $7,
description = $8
WHERE id = $1
""",
source_id,
kind,
title,
(identifier or None),
(source_url or None),
pub,
eff,
(description or None),
)
# ── 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