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>
232 lines
9.8 KiB
Python
232 lines
9.8 KiB
Python
"""AI metadata extraction for uploaded KB documents.
|
||
|
||
Phase 6 (Task #20). When a user uploads a PDF/DOCX/TXT through the
|
||
batch UI, the background classifier reads the first ~3 pages of text,
|
||
calls Claude (via the existing ai-gateway proxy), and returns a draft
|
||
metadata payload — kind, title, identifier, subject, labels, dates,
|
||
and a short summary. The user reviews these on the new bulk-upload
|
||
review screen and edits before committing.
|
||
|
||
Design notes (see ~/.claude/plans/hidden-tickling-ullman.md):
|
||
• Tool-calling guarantees JSON shape — no prompt-engineering
|
||
fragility around "please return only JSON".
|
||
• Sonnet model (env CLAUDE_MODEL). Hebrew legal text is hard;
|
||
Haiku gets citations wrong too often.
|
||
• 15s timeout, semaphore-bounded to 5 concurrent calls so a 30-file
|
||
drop doesn't trip ai-gateway rate limits.
|
||
• Fail-soft: any error → empty dict. Caller transitions the job to
|
||
`awaiting_review` with `ai_suggestions = {}`, and the UI shows
|
||
"אין הצעות AI — מלא ידנית".
|
||
|
||
The classifier is an aid, not a gate. Wrong suggestions are an
|
||
annoyance; the user always confirms before chunks land in the index.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
from typing import Optional
|
||
|
||
from openai import AsyncOpenAI
|
||
|
||
logger = logging.getLogger("shira.kb.classifier")
|
||
|
||
# Bound concurrency so a 10–30 file batch doesn't fan out 30 parallel
|
||
# Claude calls. Five at a time gives reasonable wall-clock latency
|
||
# (~6–10s for 30 files) without overwhelming ai-gateway.
|
||
_CONCURRENCY = int(os.environ.get("KB_CLASSIFIER_CONCURRENCY", "5"))
|
||
# Empirically the ai-gateway → Claude tool-call round-trip with a ~3KB
|
||
# Hebrew system prompt + 5KB user content runs 18-25s. Pad to 45s so
|
||
# we don't fail-soft on the long-tail latency.
|
||
_TIMEOUT_S = float(os.environ.get("KB_CLASSIFIER_TIMEOUT_S", "45"))
|
||
_MAX_INPUT_CHARS = 20_000 # ~3 pages of Hebrew legal text
|
||
|
||
_semaphore = asyncio.Semaphore(_CONCURRENCY)
|
||
|
||
|
||
_SYSTEM_PROMPT = """אתה מסווג מסמכים משפטיים בעברית עבור מאגר ידע משפטי.
|
||
תקבל את הטקסט של 3 העמודים הראשונים של מסמך, ועליך לחלץ ממנו מטא-דאטה.
|
||
|
||
הסיווג חייב לכבד את החוקים הבאים:
|
||
|
||
1. **kind** — אחד מהבאים בלבד:
|
||
• law — חוק (הצעת חוק, ספר חוקים, תיקון לחוק)
|
||
• regulation — תקנות
|
||
• circular — חוזר רשות / משרד / מוסד (חוזר נכות, חוזר מנכ"ל, חוזר לשכה רפואית)
|
||
• caselaw — פסק דין / החלטה. כותרת מתחילה לרוב ב-"בבית המשפט", "ע"א", "בג"ץ", "ב"ל",
|
||
"תיק", או שם תיק עם מספר.
|
||
• tool — כלי הערכה / מאמר / מדריך אקדמי. אינו מסמך משפטי פורמלי.
|
||
דוגמאות: GMFCS, MACS, CARS, Mini-MACS, מדריך הערכת תפקוד מאוניברסיטה.
|
||
|
||
2. **title** — כותרת המסמך כפי שהיא מופיעה בעמוד הראשון. אל תוסיף, אל תקצר.
|
||
ללא מרכאות סוגרות.
|
||
|
||
3. **identifier** — זיהוי קצר ומדויק. דוגמאות לפי kind:
|
||
• law/regulation: "חוק הביטוח הלאומי, התשנ\"ה–1995", "תקנה 37(א)"
|
||
• circular: "חוזר נכות 1990/02", "חוזר מנכ\"ל 14/2018"
|
||
• caselaw: "ע\"א 1234/24", "בג\"ץ 5678/22", "ב\"ל 9876-12-23"
|
||
• tool: שם הגוף המוציא ("CanChild — McMaster University")
|
||
אם אין זיהוי ברור — null. אל תמציא.
|
||
|
||
4. **subject** — תיאור תת-נושא קצר (≤80 תווים). למשל: "ילד נכה / אוטיזם",
|
||
"ילד נכה / שיתוק מוחין", "נפגעי עבודה / מחלת מקצוע".
|
||
|
||
5. **labels** — עד 3 תוויות לסיווג מעמיק. כל תווית בפורמט "קטגוריה / תת-קטגוריה" אם ניתן.
|
||
דוגמאות:
|
||
• "ילד נכה / אוטיזם"
|
||
• "ילד נכה / שיתוק מוחין"
|
||
• "ילד נכה / תלות בזולת"
|
||
• "ילד נכה / עיכוב התפתחותי"
|
||
• "ילד נכה / מומים בגפיים"
|
||
• "נפגעי עבודה / שמיעה"
|
||
|
||
6. **published_at / effective_at** — בפורמט YYYY-MM-DD בלבד.
|
||
אם רק שנה ידועה — YYYY-01-01. אם לא ידוע — null.
|
||
effective_at הוא תאריך התחילה לתוקף (אם מצוין במפורש בנפרד מתאריך הפרסום).
|
||
|
||
7. **summary** — משפט אחד באורך 80–200 תווים שמסכם על מה המסמך. ללא קלישאות
|
||
("מסמך חשוב"...). תאר את התוכן הספציפי בקצרה.
|
||
|
||
**אסור: לא להמציא נתונים שלא מופיעים בטקסט. עדיף null על ניחוש.**
|
||
"""
|
||
|
||
|
||
_TOOL_SCHEMA = {
|
||
"type": "function",
|
||
"function": {
|
||
"name": "extract_kb_metadata",
|
||
"description": "Return extracted metadata for the document.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"required": ["kind", "title", "summary"],
|
||
"properties": {
|
||
"kind": {
|
||
"type": "string",
|
||
"enum": ["law", "regulation", "circular", "caselaw", "tool"],
|
||
},
|
||
"title": {"type": "string", "maxLength": 500},
|
||
"identifier": {"type": ["string", "null"], "maxLength": 200},
|
||
"subject": {"type": ["string", "null"], "maxLength": 80},
|
||
"labels": {
|
||
"type": "array",
|
||
"maxItems": 3,
|
||
"items": {"type": "string", "maxLength": 80},
|
||
},
|
||
"published_at": {
|
||
"type": ["string", "null"],
|
||
"pattern": r"^\d{4}-\d{2}-\d{2}$",
|
||
},
|
||
"effective_at": {
|
||
"type": ["string", "null"],
|
||
"pattern": r"^\d{4}-\d{2}-\d{2}$",
|
||
},
|
||
"summary": {"type": "string", "minLength": 40, "maxLength": 250},
|
||
},
|
||
"additionalProperties": False,
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
async def classify(
|
||
*,
|
||
text: str,
|
||
filename: str,
|
||
topic_name: Optional[str] = None,
|
||
) -> dict:
|
||
"""Best-effort extraction. Returns {} on any error.
|
||
|
||
`text` is the first ~3 pages of the document (caller decides — we
|
||
truncate to _MAX_INPUT_CHARS just in case). `topic_name` lets the
|
||
LLM bias label suggestions toward the active legal domain
|
||
("ביטוח לאומי" vs "דיני עבודה").
|
||
"""
|
||
if not text or not text.strip():
|
||
logger.info("[classifier] empty text for %s — skipping", filename)
|
||
return {}
|
||
|
||
base = (os.environ.get("AI_GATEWAY_URL") or "http://localhost:3000").rstrip("/")
|
||
api_key = os.environ.get("AI_GATEWAY_API_KEY", "")
|
||
if not api_key:
|
||
logger.warning("[classifier] AI_GATEWAY_API_KEY not set — skipping")
|
||
return {}
|
||
|
||
truncated = text[:_MAX_INPUT_CHARS]
|
||
user_msg = (
|
||
f"topic_hint: {topic_name or 'כללי'}\n"
|
||
f"filename: {filename}\n\n"
|
||
f"--- מסמך ---\n{truncated}\n--- סוף ---"
|
||
)
|
||
|
||
client = AsyncOpenAI(base_url=f"{base}/v1", api_key=api_key)
|
||
async with _semaphore:
|
||
try:
|
||
resp = await asyncio.wait_for(
|
||
client.chat.completions.create(
|
||
model=os.environ.get("CLAUDE_MODEL", "sonnet"),
|
||
messages=[
|
||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||
{"role": "user", "content": user_msg},
|
||
],
|
||
tools=[_TOOL_SCHEMA],
|
||
tool_choice={
|
||
"type": "function",
|
||
"function": {"name": "extract_kb_metadata"},
|
||
},
|
||
max_tokens=600,
|
||
temperature=0.1,
|
||
),
|
||
timeout=_TIMEOUT_S,
|
||
)
|
||
except asyncio.TimeoutError:
|
||
logger.warning("[classifier] timeout on %s after %ss", filename, _TIMEOUT_S)
|
||
return {}
|
||
except Exception as e:
|
||
logger.warning("[classifier] error on %s: %s", filename, e)
|
||
return {}
|
||
|
||
# Extract the tool call. We forced the model to call exactly one tool;
|
||
# if it didn't, we got nothing useful.
|
||
try:
|
||
choice = resp.choices[0]
|
||
tool_calls = choice.message.tool_calls or []
|
||
if not tool_calls:
|
||
logger.warning("[classifier] no tool call returned for %s", filename)
|
||
return {}
|
||
args_str = tool_calls[0].function.arguments
|
||
parsed = json.loads(args_str)
|
||
except (KeyError, IndexError, AttributeError, json.JSONDecodeError) as e:
|
||
logger.warning("[classifier] failed to parse tool call for %s: %s", filename, e)
|
||
return {}
|
||
|
||
return _normalize(parsed)
|
||
|
||
|
||
def _normalize(d: dict) -> dict:
|
||
"""Coerce types and drop empty values from the LLM output."""
|
||
out: dict = {}
|
||
if isinstance(d.get("kind"), str) and d["kind"] in (
|
||
"law", "regulation", "circular", "caselaw", "tool"
|
||
):
|
||
out["kind"] = d["kind"]
|
||
for key in ("title", "identifier", "subject", "summary"):
|
||
v = d.get(key)
|
||
if isinstance(v, str) and v.strip():
|
||
out[key] = v.strip()
|
||
for key in ("published_at", "effective_at"):
|
||
v = d.get(key)
|
||
if isinstance(v, str) and len(v) == 10 and v[4] == "-" and v[7] == "-":
|
||
out[key] = v
|
||
labels = d.get("labels") or []
|
||
if isinstance(labels, list):
|
||
clean: list[str] = []
|
||
for raw in labels[:3]:
|
||
if isinstance(raw, str) and raw.strip():
|
||
clean.append(raw.strip())
|
||
if clean:
|
||
out["labels"] = clean
|
||
return out
|