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 f03721e801 feat(kb): multi-topic foundation — kb_topic table, topic-scoped endpoints
Phase 1 of the multi-domain refactor. Adds a kb_topic table seeded with
the existing 'ביטוח לאומי' domain (id=1) and pins all 6 existing
kb_source rows to it, so search/ask can be scoped per domain without
disturbing the current single-topic UX.

- scripts/migrations/001_topics.sql: idempotent migration. Creates
  kb_topic, seeds national-insurance with the system_prompt_addendum
  copied verbatim from kb_public.py's hardcoded text, adds nullable
  kb_source.topic_id, backfills it to 1, then SET NOT NULL. GRANTs to
  shira_kb mirror its existing kb_source privileges. Wrapped in BEGIN
  /COMMIT with a sanity-check that raises if the backfill is incomplete.
- api/services/kb/topics.py: list_topics / get_topic / resolve_topic_id.
  resolve_topic_id raises ValueError on unknown ids so the route layer
  can return 400 instead of silently mixing domains.
- api/services/kb/search.py: search() + _retrieve_rrf accept topic_id;
  the SQL adds AND s.topic_id = $N when present. _expand_query is
  parametrized by topic name (was hardcoded "הביטוח הלאומי").
- api/routes/kb_public.py: new GET /kb/topics. /kb/search /kb/sources
  /kb/ask /kb/ask/stream all accept topic_id and call resolve_topic_id.
  _build_ask_runner_context loads system_prompt_addendum from the DB
  instead of the hardcoded insurance string.
- mcp_server/tools/legal_kb_tools.py: tool renamed
  search_insurance_kb → search_legal_kb, takes topic_id at registration
  so each conversation is pinned to its caller-chosen domain. Tool
  description interpolates the topic name.
- api/services/agent_runner.py: kb_topic_id + kb_topic_name flow
  through to register_legal_kb_tools. Hebrew progress labels updated.

Refs Task Master #12 (espocrm-extensions/KnowledgeBase).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:15:19 +00:00

144 lines
5.7 KiB
Python

"""Legal knowledge base tool — topic-scoped semantic + lexical search.
Renamed from search_insurance_kb to search_legal_kb in Phase 1 of the
multi-topic refactor (Task Master KnowledgeBase #12). The handler now
takes a topic_id at registration time so each conversation is scoped to
its caller-chosen domain.
"""
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 ""
header = f"{i}. [{kind_he}{ident}] {h['title']}"
# Prefer the full hierarchical path when present; fall back to section_ref.
path = h.get("heading_path") or h.get("section_ref") or ""
if path:
header += f"{path}"
# Date annotation for time-sensitive citations.
pub = h.get("published_at")
if pub:
header += f" (פורסם {pub})"
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,
sources_used: list[dict] | None = None,
topic_id: int | None = None,
topic_name: str | None = None,
) -> None:
"""Register the search_legal_kb tool.
topic_id pins retrieval to a single domain (kb_source.topic_id). When
None, the underlying search falls back to the lowest active topic —
the route layer (kb_public.py) normally resolves a concrete id before
this is called, so None should be a transitional case only.
topic_name is interpolated into the tool description so the LLM sees
a domain-specific description (e.g. "ביטוח לאומי" vs "דיני עבודה").
If `sources_used` is provided, each hit's (source_id, page_number,
section_ref, title, kind, original_path) is appended — deduplicated
by (source_id, page_number) — so the caller can build a "view PDF"
link list alongside the agent's text answer.
"""
domain_text = topic_name or "המשפט"
async def search_legal_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,
topic_id=topic_id,
topic_name=topic_name,
)
if sources_used is not None:
seen = {(s.get("source_id"), s.get("page_number")) for s in sources_used}
for h in hits:
key = (h.get("source_id"), h.get("page_number"))
if h.get("source_id") and key not in seen:
sources_used.append({
"source_id": h["source_id"],
"title": h.get("title"),
"kind": h.get("kind"),
"identifier": h.get("identifier"),
"heading_path": h.get("heading_path"),
"section_ref": h.get("section_ref"),
"page_number": h.get("page_number"),
"original_path": h.get("original_path"),
})
seen.add(key)
return _format_hits(hits)
except Exception as e:
logger.exception("[search_legal_kb] failed (topic_id=%s)", topic_id)
return fail(f"שגיאה בחיפוש בבסיס הידע: {e}")
tools["search_legal_kb"] = {
"description": (
f"חיפוש בבסיס הידע המשפטי בנושא {domain_text} — חוקים, תקנות וחוזרים. "
"מחזיר קטעים רלוונטיים עם ציטוט מדויק (סעיף/תקנה/מס' חוזר). "
"יש להשתמש בכלי לפני טענה משפטית מבוססת-מקור, ולצטט את ה-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_legal_kb,
}