301f24c6e4
Labor-law caselaw uploads (4 DOCX, 2026-04-28) timed out at exactly 90s, landing in awaiting_review with empty ai_suggestions. A live retry of the same payload through ai-gateway returned valid metadata in 91.8s — one second over the cap. PDF caselaw can be larger (20K-char cap vs DOCX 6K) and ai-gateway's serialized OAuth session lengthens the tail under batch load, so 180s would not be enough headroom either. 240s keeps the call fail-soft (user can still fill metadata manually) but covers realistic Hebrew legal documents. Override via KB_CLASSIFIER_TIMEOUT_S.
260 lines
12 KiB
Python
260 lines
12 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")
|
||
|
||
# ai-gateway proxies a single Claude OAuth session, so requests
|
||
# effectively serialize at the gateway. Setting concurrency higher
|
||
# than 2 only piles requests in the gateway's queue, where they eat
|
||
# our timeout budget. Two in flight gives a tiny pipelining win if
|
||
# the gateway can overlap socket I/O with Claude latency, otherwise
|
||
# it's no worse than 1.
|
||
_CONCURRENCY = int(os.environ.get("KB_CLASSIFIER_CONCURRENCY", "2"))
|
||
# Empirically the ai-gateway → Claude tool-call round-trip with a ~3KB
|
||
# Hebrew system prompt + 5KB user content runs 18-25s. Long Hebrew
|
||
# caselaw PDFs (full 20K-char first-3-pages cap) push the tail to
|
||
# ~92s on DOCX and noticeably more on dense PDFs; ai-gateway also
|
||
# serializes through one OAuth session, lengthening the tail in a
|
||
# batch. 240s buys headroom without making the user wait forever
|
||
# when something is genuinely stuck.
|
||
_TIMEOUT_S = float(os.environ.get("KB_CLASSIFIER_TIMEOUT_S", "240"))
|
||
_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** — **שדה חובה**: 1 עד 3 תוויות לניווט וסינון במאגר, מופרדות בפסיק.
|
||
החזר מחרוזת אחת (string) — לא רשימה ולא אובייקט. דוגמאות בהמשך מראות בדיוק את הפורמט.
|
||
זהו שדה נפרד מ-subject ואסור להשמיט אותו, גם אם הוא חופף לחלוטין ל-subject.
|
||
הכלל: התווית הראשונה היא תמיד התת-קטגוריה הספציפית (זהה ל-subject ברוב המקרים).
|
||
התוויות הבאות, אם יש, הן רחבות יותר או הקשרים אחרים — שם כלי הערכה, סוג ועדה,
|
||
מספר חוזר וכו' — שיעזרו למצוא את המסמך מזוויות שונות.
|
||
כל תווית בפורמט "קטגוריה / תת-קטגוריה" כשרלוונטי.
|
||
דוגמאות לערך הסופי של השדה (string מופרד בפסיקים):
|
||
• "ילד נכה / אוטיזם, ילד נכה, DSM-5"
|
||
• "ילד נכה / שיתוק מוחין, GMFCS, כלי הערכה"
|
||
• "ילד נכה / תלות בזולת, ועדה רפואית, ילד נכה"
|
||
• "ילד נכה / עיכוב התפתחותי, DQ, פעוטות"
|
||
• "נפגעי עבודה / שמיעה, אודיומטריה"
|
||
|
||
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", "labels"],
|
||
"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": "string",
|
||
"minLength": 1,
|
||
"maxLength": 250,
|
||
"description": (
|
||
"1 to 3 navigation labels separated by commas. "
|
||
"Plain string only — NOT an array, NOT an object. "
|
||
"Example: 'ילד נכה / אוטיזם, ילד נכה, DSM-5'"
|
||
),
|
||
},
|
||
"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 comes as a comma-separated string from the model. Older
|
||
# callers may still hand us a list (defensive — we used to ask for
|
||
# an array and Claude proxies sometimes returned [{}] instead of
|
||
# strings, see the 2026-04-25 incident). Accept both shapes.
|
||
raw_labels = d.get("labels")
|
||
items: list[str] = []
|
||
if isinstance(raw_labels, str):
|
||
items = [s.strip() for s in raw_labels.split(",")]
|
||
elif isinstance(raw_labels, list):
|
||
for raw in raw_labels:
|
||
if isinstance(raw, str):
|
||
items.append(raw.strip())
|
||
elif isinstance(raw, dict):
|
||
# Empty {} from broken proxy round-trip — drop it.
|
||
continue
|
||
clean = [s for s in items if s][:3]
|
||
if clean:
|
||
out["labels"] = clean
|
||
return out
|