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

243 lines
8.9 KiB
Python

"""Hybrid search: vector + full-text, fused with Reciprocal Rank Fusion,
and finally reranked with a Voyage cross-encoder for precision.
`search()` supports an `expand=True` mode that asks the LLM for query
variants, retrieves candidates for each, merges them, and reranks the
union against the original query. Costs one extra LLM call + one Voyage
embed per variant, but recovers documents that don't share the user's
exact phrasing — e.g. ספר הליקויים when the user types "מהי תקנה 37".
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Literal
from openai import AsyncOpenAI
from api.services.kb import voyage
from api.services.kb.db import get_pool
logger = logging.getLogger("shira.kb.search")
_RRF_K = 60 # standard RRF constant
_CANDIDATES_PER_SIDE = 30
# Number of fused RRF candidates fed to the reranker. Higher = better
# recall before the final cut; capped at ~30 to keep latency reasonable.
_RERANK_POOL = 30
# How many alternative phrasings to ask the LLM to generate when expand=True.
_EXPANSION_VARIANTS = 3
_EXPANSION_TIMEOUT_S = 12.0
async def _retrieve_rrf(
query: str,
kind: Literal["law", "regulation", "circular", "caselaw", "tool", "any"],
topic_id: int | None = None,
label_id: int | None = None,
) -> list[dict]:
"""Run vector + lexical retrieval, fuse with RRF. No rerank, no top_k cut.
Each candidate dict carries `chunk_id` (kb_chunk.id) so callers can
deduplicate when merging results from multiple queries.
topic_id, when provided, restricts retrieval to a single domain
(kb_source.topic_id). When None, all topics are searched — kept for
transitional callers; new code should always pass a resolved id.
label_id, when provided, further restricts retrieval to sources
tagged with that label (kb_source_label).
"""
[vec] = await voyage.embed([query], input_type="query")
filters: list[str] = []
params: list = [vec, query, _CANDIDATES_PER_SIDE]
if kind != "any":
params.append(kind)
filters.append(f"AND s.kind = ${len(params)}")
if topic_id is not None:
params.append(topic_id)
filters.append(f"AND s.topic_id = ${len(params)}")
if label_id is not None:
params.append(label_id)
filters.append(
f"AND EXISTS (SELECT 1 FROM kb_source_label sl "
f"WHERE sl.source_id = s.id AND sl.label_id = ${len(params)})"
)
extra = "\n ".join(filters)
sql = f"""
WITH vec AS (
SELECT c.id, ROW_NUMBER() OVER (ORDER BY c.embedding <=> $1::vector) AS rank
FROM kb_chunk c
JOIN kb_source s ON s.id = c.source_id
WHERE c.embedding IS NOT NULL
AND s.superseded_by IS NULL
{extra}
ORDER BY c.embedding <=> $1::vector
LIMIT $3
),
lex AS (
SELECT c.id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(c.content_tsv, q) DESC) AS rank
FROM kb_chunk c
JOIN kb_source s ON s.id = c.source_id,
websearch_to_tsquery('simple', $2) AS q
WHERE c.content_tsv @@ q
AND s.superseded_by IS NULL
{extra}
ORDER BY ts_rank_cd(c.content_tsv, q) DESC
LIMIT $3
),
fused AS (
SELECT id, SUM(score) AS score FROM (
SELECT id, 1.0 / ({_RRF_K} + rank) AS score FROM vec
UNION ALL
SELECT id, 1.0 / ({_RRF_K} + rank) AS score FROM lex
) u GROUP BY id
)
SELECT
c.id AS chunk_id, c.chunk_index,
s.id AS source_id, s.kind, s.title, s.identifier, s.source_url,
s.published_at, s.effective_at, s.original_path,
c.heading_path, c.section_ref, c.content, c.page_number,
f.score
FROM fused f
JOIN kb_chunk c ON c.id = f.id
JOIN kb_source s ON s.id = c.source_id
ORDER BY f.score DESC
LIMIT {_RERANK_POOL}
"""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(sql, *params)
return [dict(r) for r in rows]
_EXPANSION_PROMPT = (
"אתה עוזר חיפוש בבסיס ידע משפטי בנושא {topic_name}. "
"המשתמש שואל שאלה. החזר עד {n} ניסוחים חלופיים שיעזרו לאחזר "
"את אותו מידע ממסמכים משפטיים בעברית — תרגומים מסגנון שאלתי "
"לסגנון מסמך, מילים נרדפות (למשל 'תקנה 37''בדיקה מחדש', "
"'נכות''נכה'/'דרגת נכות'/'מוגבלות'), והוספת מונחים רפואיים/משפטיים "
"שלא מופיעים במפורש בשאלה. החזר ניסוח אחד לשורה, ללא מספור או הקדמה. "
"אל תחזור על הניסוח המקורי."
)
async def _expand_query(query: str, topic_name: str | None = None) -> list[str]:
"""Ask the LLM for alternative phrasings. Empty list on failure (caller
just runs the original query)."""
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:
return []
client = AsyncOpenAI(base_url=f"{base}/v1", api_key=api_key)
prompt = _EXPANSION_PROMPT.format(
n=_EXPANSION_VARIANTS,
topic_name=topic_name or "כללי",
)
try:
resp = await asyncio.wait_for(
client.chat.completions.create(
model=os.environ.get("CLAUDE_MODEL", "sonnet"),
messages=[
{"role": "system", "content": prompt},
{"role": "user", "content": query},
],
max_tokens=256,
temperature=0.3,
),
timeout=_EXPANSION_TIMEOUT_S,
)
except (asyncio.TimeoutError, Exception) as e:
logger.warning("[kb.search] query expansion failed: %s", e)
return []
text = (resp.choices[0].message.content or "").strip()
variants: list[str] = []
for line in text.splitlines():
v = line.strip(" -•·*\t").strip()
if not v or v == query or v in variants:
continue
variants.append(v)
if len(variants) >= _EXPANSION_VARIANTS:
break
return variants
async def _rerank_or_truncate(
query: str, candidates: list[dict], top_k: int,
) -> list[dict]:
"""Cross-encoder rerank against `query`; fall back to RRF order on error."""
rerank_disabled = os.environ.get("KB_RERANK_DISABLED", "").lower() in ("1", "true")
if rerank_disabled or len(candidates) <= top_k:
return candidates[:top_k]
texts = [c["content"] for c in candidates]
try:
ranked = await voyage.rerank(query=query, documents=texts, top_k=top_k)
except voyage.VoyageError as e:
logger.warning("[kb.search] rerank failed, falling back to RRF: %s", e)
return candidates[:top_k]
out: list[dict] = []
for r in ranked:
i = r["index"]
if 0 <= i < len(candidates):
out.append({**candidates[i], "rerank_score": r["relevance_score"]})
logger.info("[kb.search] rerank kept top %d / %d", len(out), len(candidates))
return out
async def search(
query: str,
kind: Literal["law", "regulation", "circular", "caselaw", "tool", "any"] = "any",
top_k: int = 8,
expand: bool = False,
topic_id: int | None = None,
topic_name: str | None = None,
label_id: int | None = None,
) -> list[dict]:
query = (query or "").strip()
if not query:
return []
if not expand:
candidates = await _retrieve_rrf(query, kind, topic_id=topic_id, label_id=label_id)
logger.info(
"[kb.search] query=%r kind=%s topic_id=%s label_id=%s RRF_candidates=%d",
query[:80], kind, topic_id, label_id, len(candidates),
)
if not candidates:
return []
return await _rerank_or_truncate(query, candidates, top_k)
# Multi-query expansion: variants run in parallel with the original.
variants = await _expand_query(query, topic_name=topic_name)
queries = [query] + variants
logger.info("[kb.search] expand=true topic_id=%s label_id=%s variants=%d",
topic_id, label_id, len(variants))
results = await asyncio.gather(
*[_retrieve_rrf(q, kind, topic_id=topic_id, label_id=label_id) for q in queries],
return_exceptions=True,
)
merged: dict[int, dict] = {}
for r in results:
if isinstance(r, Exception):
logger.warning("[kb.search] sub-retrieval failed: %s", r)
continue
for c in r:
cid = c["chunk_id"]
if cid not in merged or c["score"] > merged[cid]["score"]:
merged[cid] = c
candidates = sorted(merged.values(), key=lambda x: -x["score"])[:_RERANK_POOL]
logger.info(
"[kb.search] merged candidates=%d (from %d queries)",
len(candidates), len(queries),
)
if not candidates:
return []
return await _rerank_or_truncate(query, candidates, top_k)