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>
This commit is contained in:
2026-04-25 14:15:19 +00:00
parent 76fd77f904
commit f03721e801
6 changed files with 296 additions and 48 deletions
+39 -13
View File
@@ -1,4 +1,10 @@
"""Israeli National Insurance knowledge base tools (search)."""
"""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
@@ -47,15 +53,30 @@ def _format_hits(hits: list[dict]) -> str:
return "\n".join(lines).rstrip()
def register_legal_kb_tools(tools: dict, sources_used: list[dict] | None = None) -> None:
"""Register the search_insurance_kb tool.
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.
"""
async def search_insurance_kb(
domain_text = topic_name or "המשפט"
async def search_legal_kb(
query: str,
kind: str = "any",
top_k: int = 8,
@@ -64,7 +85,13 @@ def register_legal_kb_tools(tools: dict, sources_used: list[dict] | None = None)
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)
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:
@@ -83,16 +110,15 @@ def register_legal_kb_tools(tools: dict, sources_used: list[dict] | None = None)
seen.add(key)
return _format_hits(hits)
except Exception as e:
logger.exception("[search_insurance_kb] failed")
logger.exception("[search_legal_kb] failed (topic_id=%s)", topic_id)
return fail(f"שגיאה בחיפוש בבסיס הידע: {e}")
tools["search_insurance_kb"] = {
tools["search_legal_kb"] = {
"description": (
"חיפוש בבסיס הידע של הביטוח הלאומי — חוק הביטוח הלאומי, תקנות הביטוח "
"הלאומי וחוזרי הביטוח הלאומי. מחזיר קטעים רלוונטיים עם ציטוט מדויק "
"(סעיף/תקנה/מס' חוזר). יש להשתמש בכלי לפני טענה משפטית מבוססת-מקור, "
"ולצטט את ה-section_ref המדויק בתשובה. החיפוש משלב דמיון סמנטי וחיפוש "
"מילולי (hybrid)."
f"חיפוש בבסיס הידע המשפטי בנושא {domain_text} — חוקים, תקנות וחוזרים. "
"מחזיר קטעים רלוונטיים עם ציטוט מדויק (סעיף/תקנה/מס' חוזר). "
"יש להשתמש בכלי לפני טענה משפטית מבוססת-מקור, ולצטט את ה-section_ref "
"המדויק בתשובה. החיפוש משלב דמיון סמנטי וחיפוש מילולי (hybrid)."
),
"parameters": {
"type": "object",
@@ -113,5 +139,5 @@ def register_legal_kb_tools(tools: dict, sources_used: list[dict] | None = None)
},
"required": ["query"],
},
"handler": search_insurance_kb,
"handler": search_legal_kb,
}