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
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

399 lines
15 KiB
Python
Raw Permalink 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.
"""kb_label CRUD + many-to-many helpers for kb_source_label.
Phase 6 (Task #20). Backs the v0.8.0 review-screen label chips and the
new 'תוויות' admin tab in the KnowledgeBase EspoCRM extension. A label is
a free-form sub-topic — typical names are "ילד נכה / אוטיזם" or
"נפגעי עבודה / שמיעה" — that helps end users filter the KB beyond the
coarse-grained `topic`. Labels are global; `topic_id` is an optional
hint that scopes a label to one topic for the autocomplete dropdown.
Design choices documented in scripts/migrations/006_labels.sql and
~/.claude/plans/hidden-tickling-ullman.md:
• Flat schema (no parent_id). Slash separators in names are a UI
display convention only.
• ASCII slug is the unique key. `slugify` transliterates Hebrew
letter-by-letter so the slug is deterministic without external deps.
• `usage_count` denormalized — the autocomplete dropdown is hot, and
we don't want a `COUNT(*)` join on every keystroke.
• Auto-creation happens at commit time with a clean
INSERT...ON CONFLICT DO NOTHING pattern, race-safe across concurrent
uploads.
"""
from __future__ import annotations
import logging
import re
from typing import Optional
from api.services.kb.db import get_pool
logger = logging.getLogger("shira.kb.labels")
# Letter-by-letter Hebrew → Latin map. We're not aiming for phonetic
# accuracy (that's a research project) — the goal is a deterministic,
# reversible-enough mapping that produces unique ASCII slugs and reads
# OK in URLs. Final/medial letter forms map identically to their
# canonical form. Niqqud is dropped (handled below in slugify).
_HEB_LATIN = {
"א": "a", "ב": "b", "ג": "g", "ד": "d", "ה": "h",
"ו": "v", "ז": "z", "ח": "ch", "ט": "t", "י": "y",
"כ": "k", "ך": "k", "ל": "l", "מ": "m", "ם": "m",
"נ": "n", "ן": "n", "ס": "s", "ע": "a", "פ": "p",
"ף": "f", "צ": "tz", "ץ": "tz", "ק": "q", "ר": "r",
"ש": "sh", "ת": "t",
}
# Niqqud + cantillation marks (U+0591..U+05C7) — strip before mapping.
_NIQQUD_RE = re.compile(r"[֑-ׇ]")
def slugify(name: str) -> str:
"""Hebrew/Latin name → URL-safe slug matching `^[a-z0-9][a-z0-9-]*$`.
Examples (verified):
"ילד נכה / אוטיזם""yld-nkh-aoytyzm"
"נפגעי עבודה / שמיעה""nfgay-aabvdh-shmyah"
"Employment-Law (2024)""employment-law-2024"
"" → ValueError
The transliteration isn't pretty Hebrew but it's deterministic.
Two callers naming the same concept will collide on the same slug,
which is what we want.
"""
if not name:
raise ValueError("name is required")
# Strip niqqud / cantillation, lowercase ASCII, then transliterate
# any remaining Hebrew letter to its Latin equivalent.
cleaned = _NIQQUD_RE.sub("", name).lower()
out_chars: list[str] = []
for ch in cleaned:
if ch in _HEB_LATIN:
out_chars.append(_HEB_LATIN[ch])
elif "a" <= ch <= "z" or "0" <= ch <= "9":
out_chars.append(ch)
else:
# Spaces, punctuation, slashes, emoji — all become a single
# hyphen. Multiple consecutive hyphens collapse below.
out_chars.append("-")
raw = "".join(out_chars)
# Collapse runs of hyphens, trim ends.
raw = re.sub(r"-+", "-", raw).strip("-")
if not raw:
raise ValueError(f"name {name!r} transliterates to an empty slug")
if len(raw) > 200:
raw = raw[:200].rstrip("-")
# The CHECK constraint forbids leading hyphen / digit-then-hyphen
# gymnastics — start char must be [a-z0-9].
if not re.match(r"^[a-z0-9]", raw):
raise ValueError(f"slug must start with [a-z0-9], got {raw!r}")
return raw
# ── Read paths ──────────────────────────────────────────────────────────────
async def list_labels(
*,
topic_id: Optional[int] = None,
query: Optional[str] = None,
limit: int = 50,
) -> list[dict]:
"""Autocomplete-friendly listing. ORDER BY usage_count DESC.
Filters:
• topic_id: include labels with that topic_id OR NULL (cross-topic
labels are visible everywhere).
• query: case-insensitive substring on name or slug.
"""
limit = max(1, min(200, limit))
where: list[str] = []
args: list = []
if topic_id is not None:
args.append(topic_id)
where.append(f"(topic_id = ${len(args)} OR topic_id IS NULL)")
if query:
args.append(f"%{query}%")
where.append(f"(name ILIKE ${len(args)} OR slug ILIKE ${len(args)})")
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
args.append(limit)
sql = f"""
SELECT id, slug, name, topic_id, usage_count, created_at
FROM kb_label
{where_sql}
ORDER BY usage_count DESC, name
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_label(label_id: int) -> Optional[dict]:
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM kb_label WHERE id = $1", label_id
)
return _row_to_dict(row) if row else None
async def get_labels_for_source(source_id: int) -> list[dict]:
"""Labels attached to one source, ordered by name."""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT l.id, l.slug, l.name, l.topic_id, l.usage_count
FROM kb_label l
JOIN kb_source_label sl ON sl.label_id = l.id
WHERE sl.source_id = $1
ORDER BY l.name
""",
source_id,
)
return [_row_to_dict(r) for r in rows]
# ── Write paths ─────────────────────────────────────────────────────────────
async def create_label(
*,
slug: str,
name: str,
topic_id: Optional[int] = None,
created_by: Optional[str] = None,
) -> dict:
"""Insert one label. Returns the row. Raises ValueError on dup slug."""
if not slug:
raise ValueError("slug is required")
if not name:
raise ValueError("name is required")
pool = await get_pool()
async with pool.acquire() as conn:
try:
row = await conn.fetchrow(
"""
INSERT INTO kb_label (slug, name, topic_id, created_by)
VALUES ($1, $2, $3, $4)
RETURNING id, slug, name, topic_id, usage_count, created_at
""",
slug, name, topic_id, created_by,
)
except Exception as e:
# asyncpg raises UniqueViolationError or CheckViolationError;
# we surface them as ValueError so the route can return 400.
msg = str(e).lower()
if "unique" in msg or "duplicate" in msg:
raise ValueError(f"slug {slug!r} already exists")
if "kb_label_slug_check" in msg:
raise ValueError(f"slug {slug!r} fails shape check (lowercase ASCII + hyphens)")
raise
logger.info("[labels] created id=%s slug=%s", row["id"], row["slug"])
return _row_to_dict(row)
async def lookup_or_create_batch(
items: list[dict],
*,
created_by: Optional[str] = None,
) -> dict[str, int]:
"""For a list of {slug, name, topic_id?} dicts, return {slug → label_id}.
Race-safe: each row is INSERT...ON CONFLICT DO NOTHING + a follow-up
SELECT for the loser of the race. Used by the commit path to resolve
user-confirmed labels (some existing, some new) in a single call.
"""
if not items:
return {}
pool = await get_pool()
out: dict[str, int] = {}
async with pool.acquire() as conn:
for item in items:
slug = item.get("slug") or ""
name = item.get("name") or ""
topic_id = item.get("topic_id")
if not slug or not name:
continue
row = await conn.fetchrow(
"""
INSERT INTO kb_label (slug, name, topic_id, created_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (slug) DO NOTHING
RETURNING id
""",
slug, name, topic_id, created_by,
)
if row is None:
# Lost the race; the row already exists. Re-fetch.
row = await conn.fetchrow(
"SELECT id FROM kb_label WHERE slug = $1", slug
)
if row is None:
# Genuinely impossible — slug check failed somewhere.
logger.error("[labels] failed to resolve slug=%s", slug)
continue
out[slug] = row["id"]
return out
async def apply_to_source(source_id: int, label_ids: list[int]) -> int:
"""Attach labels to a source. Increments usage_count for each NEW
assignment (skips already-assigned). Returns count of new attachments.
"""
if not label_ids:
return 0
pool = await get_pool()
new_count = 0
async with pool.acquire() as conn:
async with conn.transaction():
for lid in label_ids:
inserted = await conn.fetchrow(
"""
INSERT INTO kb_source_label (source_id, label_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
RETURNING source_id
""",
source_id, lid,
)
if inserted:
new_count += 1
await conn.execute(
"UPDATE kb_label SET usage_count = usage_count + 1 WHERE id = $1",
lid,
)
return new_count
async def replace_for_source(source_id: int, label_ids: list[int]) -> dict:
"""Set the label set of a source to exactly `label_ids`. Adjusts
usage_count for both adds and removes. Used by the source edit path
in admin_sources.update_source.
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
existing = await conn.fetch(
"SELECT label_id FROM kb_source_label WHERE source_id = $1",
source_id,
)
current = {r["label_id"] for r in existing}
target = set(label_ids)
to_add = target - current
to_remove = current - target
for lid in to_remove:
await conn.execute(
"DELETE FROM kb_source_label WHERE source_id = $1 AND label_id = $2",
source_id, lid,
)
await conn.execute(
"UPDATE kb_label SET usage_count = GREATEST(0, usage_count - 1) WHERE id = $1",
lid,
)
for lid in to_add:
inserted = await conn.fetchrow(
"""
INSERT INTO kb_source_label (source_id, label_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
RETURNING source_id
""",
source_id, lid,
)
if inserted:
await conn.execute(
"UPDATE kb_label SET usage_count = usage_count + 1 WHERE id = $1",
lid,
)
return {"added": len(to_add), "removed": len(to_remove)}
async def merge_labels(from_id: int, into_id: int) -> dict:
"""Redirect every assignment of `from_id` to `into_id`, then delete
the source label. Used to clean up duplicates created by typos.
Race-safe: ON CONFLICT DO NOTHING handles assignments that already
exist on `into_id`.
"""
if from_id == into_id:
raise ValueError("cannot merge a label into itself")
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
from_label = await conn.fetchrow(
"SELECT id FROM kb_label WHERE id = $1", from_id
)
into_label = await conn.fetchrow(
"SELECT id FROM kb_label WHERE id = $1", into_id
)
if not from_label or not into_label:
raise LookupError("one or both labels not found")
# Move assignments. ON CONFLICT skips sources already tagged
# with `into_id` — they end up tagged once (correct).
await conn.execute(
"""
INSERT INTO kb_source_label (source_id, label_id)
SELECT source_id, $2 FROM kb_source_label WHERE label_id = $1
ON CONFLICT DO NOTHING
""",
from_id, into_id,
)
await conn.execute(
"DELETE FROM kb_source_label WHERE label_id = $1", from_id
)
# Recompute usage_count for `into_id` (the joiner won the
# union; some assignments may have been deduped).
await conn.execute(
"""
UPDATE kb_label SET usage_count = (
SELECT COUNT(*) FROM kb_source_label WHERE label_id = $1
) WHERE id = $1
""",
into_id,
)
await conn.execute("DELETE FROM kb_label WHERE id = $1", from_id)
logger.info("[labels] merged id=%s into id=%s", from_id, into_id)
return {"merged": True, "from_id": from_id, "into_id": into_id}
async def delete_label(label_id: int) -> dict:
"""Hard-delete. Refuses if any source still references the label.
Soft-removal (usage_count → 0 via the source-edit UI) is the path
callers should use to retire a label without affecting historical
sources. This DELETE is for cleaning out abandoned labels.
"""
pool = await get_pool()
async with pool.acquire() as conn:
cnt = await conn.fetchval(
"SELECT COUNT(*) FROM kb_source_label WHERE label_id = $1", label_id
)
if cnt and cnt > 0:
raise ValueError(f"label still has {cnt} source(s); merge or detach first")
result = await conn.execute(
"DELETE FROM kb_label WHERE id = $1", label_id
)
if not result.endswith(" 1"):
raise LookupError(f"label {label_id} not found")
return {"deleted": True, "label_id": label_id}
# ── helpers ─────────────────────────────────────────────────────────────────
def _row_to_dict(row) -> Optional[dict]:
if row is None:
return None
out: dict = {}
for k, v in dict(row).items():
# asyncpg gives us datetime objects; the API serializer expects ISO.
try:
from datetime import datetime
if isinstance(v, datetime):
out[k] = v.isoformat()
continue
except Exception:
pass
out[k] = v
return out