0d678da25f
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>
159 lines
4.8 KiB
Python
159 lines
4.8 KiB
Python
"""MinIO/S3 helpers for the insurance-kb bucket.
|
|
|
|
Layout in the bucket:
|
|
inbox/{law,regulation,circular,caselaw,tool}/<file>
|
|
processed/{law,regulation,circular,caselaw,tool}/<file>
|
|
failed/{law,regulation,circular,caselaw,tool}/<file>
|
|
|
|
After a successful ingest, the file is moved from inbox/* to processed/*.
|
|
On failure it is moved to failed/*.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from typing import Iterable
|
|
|
|
import boto3
|
|
from botocore.client import Config as BotoConfig
|
|
|
|
logger = logging.getLogger("shira.kb.s3")
|
|
|
|
_KINDS = ("law", "regulation", "circular", "caselaw", "tool")
|
|
|
|
|
|
def _client():
|
|
endpoint = os.environ.get("KB_S3_ENDPOINT")
|
|
access = os.environ.get("KB_S3_ACCESS_KEY")
|
|
secret = os.environ.get("KB_S3_SECRET_KEY")
|
|
region = os.environ.get("KB_S3_REGION", "us-east-1")
|
|
if not (endpoint and access and secret):
|
|
raise RuntimeError("KB_S3_* environment variables are not set")
|
|
return boto3.client(
|
|
"s3",
|
|
endpoint_url=endpoint,
|
|
aws_access_key_id=access,
|
|
aws_secret_access_key=secret,
|
|
region_name=region,
|
|
config=BotoConfig(signature_version="s3v4"),
|
|
)
|
|
|
|
|
|
def _bucket() -> str:
|
|
return os.environ.get("KB_S3_BUCKET", "insurance-kb")
|
|
|
|
|
|
def list_inbox() -> list[dict]:
|
|
"""Return inbox objects across all kinds, excluding folder markers and sidecars.
|
|
|
|
Sidecar files (<name>.meta.json) carry per-document metadata and are
|
|
not standalone documents, so they are filtered out here — the scanner
|
|
fetches them separately when processing their parent.
|
|
"""
|
|
s3 = _client()
|
|
bucket = _bucket()
|
|
items: list[dict] = []
|
|
for kind in _KINDS:
|
|
prefix = f"inbox/{kind}/"
|
|
paginator = s3.get_paginator("list_objects_v2")
|
|
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
|
|
for obj in page.get("Contents", []) or []:
|
|
key = obj["Key"]
|
|
if key.endswith("/"):
|
|
continue
|
|
if key.endswith(".meta.json"):
|
|
continue
|
|
filename = key[len(prefix):]
|
|
if not filename:
|
|
continue
|
|
items.append({
|
|
"key": key,
|
|
"kind": kind,
|
|
"filename": filename,
|
|
"size": obj["Size"],
|
|
})
|
|
return items
|
|
|
|
|
|
def fetch(key: str) -> bytes:
|
|
s3 = _client()
|
|
resp = s3.get_object(Bucket=_bucket(), Key=key)
|
|
return resp["Body"].read()
|
|
|
|
|
|
def move(src_key: str, dst_key: str) -> None:
|
|
s3 = _client()
|
|
bucket = _bucket()
|
|
s3.copy_object(
|
|
Bucket=bucket,
|
|
Key=dst_key,
|
|
CopySource={"Bucket": bucket, "Key": src_key},
|
|
MetadataDirective="COPY",
|
|
)
|
|
s3.delete_object(Bucket=bucket, Key=src_key)
|
|
logger.info("[kb.s3] moved %s -> %s", src_key, dst_key)
|
|
|
|
|
|
def mark_processed(src_key: str, filename: str, kind: str) -> str:
|
|
dst = f"processed/{kind}/{filename}"
|
|
move(src_key, dst)
|
|
# Best-effort: move the sidecar too if it exists.
|
|
sidecar_src = src_key + ".meta.json"
|
|
sidecar_dst = dst + ".meta.json"
|
|
try:
|
|
move(sidecar_src, sidecar_dst)
|
|
except Exception:
|
|
pass
|
|
return dst
|
|
|
|
|
|
def list_failed() -> list[dict]:
|
|
s3 = _client()
|
|
bucket = _bucket()
|
|
items: list[dict] = []
|
|
for kind in _KINDS:
|
|
prefix = f"failed/{kind}/"
|
|
paginator = s3.get_paginator("list_objects_v2")
|
|
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
|
|
for obj in page.get("Contents", []) or []:
|
|
key = obj["Key"]
|
|
if key.endswith("/"):
|
|
continue
|
|
if key.endswith(".meta.json"):
|
|
continue
|
|
filename = key[len(prefix):]
|
|
if not filename:
|
|
continue
|
|
items.append({"key": key, "kind": kind, "filename": filename, "size": obj["Size"]})
|
|
return items
|
|
|
|
|
|
def mark_failed(src_key: str, filename: str, kind: str, reason: str) -> str:
|
|
s3 = _client()
|
|
bucket = _bucket()
|
|
dst = f"failed/{kind}/{filename}"
|
|
# S3 metadata is encoded as HTTP headers, so only ASCII is safe.
|
|
safe_reason = reason.encode("ascii", "ignore").decode("ascii")[:800]
|
|
s3.copy_object(
|
|
Bucket=bucket,
|
|
Key=dst,
|
|
CopySource={"Bucket": bucket, "Key": src_key},
|
|
Metadata={"ingest_error": safe_reason},
|
|
MetadataDirective="REPLACE",
|
|
)
|
|
s3.delete_object(Bucket=bucket, Key=src_key)
|
|
# Best-effort: move the sidecar too.
|
|
sidecar_src = src_key + ".meta.json"
|
|
sidecar_dst = dst + ".meta.json"
|
|
try:
|
|
move(sidecar_src, sidecar_dst)
|
|
except Exception:
|
|
pass
|
|
logger.info("[kb.s3] failed %s -> %s (%s)", src_key, dst, safe_reason[:80])
|
|
return dst
|
|
|
|
|
|
def kinds() -> Iterable[str]:
|
|
return _KINDS
|