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/classifier.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

232 lines
9.8 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""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 1030 file batch doesn't fan out 30 parallel
# Claude calls. Five at a time gives reasonable wall-clock latency
# (~610s 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