feat(kb): Phase 1 — Israeli National Insurance knowledge base

Adds a searchable KB for חוק הביטוח הלאומי, תקנות הביטוח הלאומי, and חוזרי
הביטוח הלאומי. Hybrid search (pgvector cosine + tsvector/trgm) fused with
Reciprocal Rank Fusion, exposed to Shira as the `search_insurance_kb` tool.

- api/services/kb/: asyncpg pool, Voyage voyage-multilingual-2 client,
  per-kind chunker (statute-by-section / circular header-aware), ingest
  pipeline with supersession (old source → superseded_by), hybrid search.
- api/routes/admin_kb.py: POST /admin/kb/ingest (multipart), GET /admin/kb/stats.
- mcp_server/tools/legal_kb_tools.py: `search_insurance_kb` registered under
  the `legal` toolset.
- pyproject.toml: +asyncpg, +python-docx, +boto3, +python-multipart.

Infra (external): pgvector/pgvector:pg16 on the shared PG, insurance_kb DB
with hnsw + gin indexes, MinIO bucket insurance-kb, KB_DATABASE_URL in
Infisical /espocrm, VOYAGE_API_KEY/VOYAGE_MODEL/KB_DATABASE_URL on the
shira-hermes Coolify app.

Refs Task Master #2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 14:36:09 +00:00
parent 84269a8d51
commit 9edcb58c93
12 changed files with 747 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
"""Israeli National Insurance knowledge base tools (search)."""
from __future__ import annotations
import logging
from api.services.kb import search as kb_search
from mcp_server.tools._helpers import fail
logger = logging.getLogger("shira.tools.legal_kb")
_KIND_HEBREW = {
"law": "חוק",
"regulation": "תקנה",
"circular": "חוזר",
}
def _format_hits(hits: list[dict]) -> str:
if not hits:
return "לא נמצאו תוצאות רלוונטיות בבסיס הידע."
lines = [f"נמצאו {len(hits)} קטעים רלוונטיים:"]
for i, h in enumerate(hits, 1):
kind_he = _KIND_HEBREW.get(h["kind"], h["kind"])
ident = f" {h['identifier']}" if h.get("identifier") else ""
ref = h.get("section_ref") or h.get("heading_path") or ""
header = f"{i}. [{kind_he}{ident}] {h['title']}"
if ref:
header += f"{ref}"
lines.append(header)
content = (h.get("content") or "").strip()
if len(content) > 900:
content = content[:900] + ""
lines.append(content)
if h.get("source_url"):
lines.append(f"מקור: {h['source_url']}")
lines.append("")
return "\n".join(lines).rstrip()
def register_legal_kb_tools(tools: dict) -> None:
async def search_insurance_kb(
query: str,
kind: str = "any",
top_k: int = 8,
) -> str:
try:
if kind not in ("law", "regulation", "circular", "any"):
kind = "any"
top_k = max(1, min(int(top_k or 8), 15))
hits = await kb_search.search(query=query, kind=kind, top_k=top_k)
return _format_hits(hits)
except Exception as e:
logger.exception("[search_insurance_kb] failed")
return fail(f"שגיאה בחיפוש בבסיס הידע: {e}")
tools["search_insurance_kb"] = {
"description": (
"חיפוש בבסיס הידע של הביטוח הלאומי — חוק הביטוח הלאומי, תקנות הביטוח "
"הלאומי וחוזרי הביטוח הלאומי. מחזיר קטעים רלוונטיים עם ציטוט מדויק "
"(סעיף/תקנה/מס' חוזר). יש להשתמש בכלי לפני טענה משפטית מבוססת-מקור, "
"ולצטט את ה-section_ref המדויק בתשובה. החיפוש משלב דמיון סמנטי וחיפוש "
"מילולי (hybrid)."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "שאילתת חיפוש בעברית — ניסוח חופשי או מונחים מדויקים.",
},
"kind": {
"type": "string",
"enum": ["law", "regulation", "circular", "any"],
"description": "סינון לפי סוג מקור: law=חוק, regulation=תקנה, circular=חוזר, any=הכל (ברירת מחדל).",
},
"top_k": {
"type": "integer",
"description": "מספר קטעים להחזיר (1–15, ברירת מחדל 8).",
},
},
"required": ["query"],
},
"handler": search_insurance_kb,
}