8 Commits

Author SHA1 Message Date
0c8d415044 fix(retrieval): scope search_decisions by domain — derive from case, block only on undeterminable case (GAP-12, INV-RET1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:23:41 +00:00
bd6edb8937 merge: FU-6 — code-enforced QA gates (GAP-15/16)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s
export_docx hard-blocks on critical QA failures (gates on stored qa_results, no LLM
re-run); neutral_background severity consistency fix; export HTTP endpoint returns 409
on block (UI shows error, not false success). Verified offline (test_export_qa_gate.py 5/5).
Closes PR #9.
2026-05-30 18:14:40 +00:00
a61495f5ef fix(api): export endpoint returns 409 when QA gate blocks (FU-6 UX — avoid false success toast)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:03:21 +00:00
084b31cd9b fix(qa): enforce critical-QA gate on export + fix neutral_background critical-but-passed (GAP-15/16, INV-QA3/EX3)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:58:50 +00:00
1473bdf3c2 merge: FU-4/GAP-10 corpus-isolation fix
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m39s
Enforce source_kind on halacha_filters (db.py) — closes cross-corpus halacha leak (#56).
Verified by offline regression test (mcp-server/tests/test_precedent_corpus_isolation.py).
2026-05-30 17:53:46 +00:00
f51036bd98 merge: System Spec-set + gap-audit (sub-projects 1+2)
Adds docs/spec/ (14-file living system spec, 11 invariants) + gap-audit (23 findings
→ 8 fix-units) + TaskMaster tasks 59-66. Closes PR #8. Docs/tasks only — no runtime code.
2026-05-30 17:53:46 +00:00
1af689a969 fix(retrieval): enforce source_kind on halacha_filters — close cross-corpus leak (GAP-10, INV-RET1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:46:59 +00:00
80d1c5ff27 tasks(legal-ai): reconcile #56 (cancel→superseded by 62.1) + #57 (link to FU-3)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:43:12 +00:00
9 changed files with 494 additions and 12 deletions

View File

@@ -1967,10 +1967,10 @@
"id": "56", "id": "56",
"title": "[Retrieval finding] halacha_filters לא מסננים source_kind — דליפה חוצת-קורפוסים", "title": "[Retrieval finding] halacha_filters לא מסננים source_kind — דליפה חוצת-קורפוסים",
"description": "התגלה תוך כדי משימה 53. ב-search_precedent_library_semantic וב-search_precedent_library_lexical (db.py): chunk_filters כוללים cl.source_kind=$sk אבל halacha_filters כוללים רק review_status. תוצאה: search_precedent_library(external) מחזיר גם הלכות internal_committee, ו-search_internal_decisions(internal) מחזיר גם הלכות external. אי-עקביות: chunks מסוננים, halachot לא. כרגע זה דווקא מסייע למציאוּת (לכן לא רגרסיה), אבל לא עקבי. דורש החלטת מדיניות: או לסנן halachot גם לפי source_kind (עקבי, אך 'מסתיר' שכבות), או להשאיר מאוחד במכוון + לתעד. אם משאירים מאוחד — לעדכן docstrings של שני הכלים שזה לא 'corpus נפרד'.", "description": "התגלה תוך כדי משימה 53. ב-search_precedent_library_semantic וב-search_precedent_library_lexical (db.py): chunk_filters כוללים cl.source_kind=$sk אבל halacha_filters כוללים רק review_status. תוצאה: search_precedent_library(external) מחזיר גם הלכות internal_committee, ו-search_internal_decisions(internal) מחזיר גם הלכות external. אי-עקביות: chunks מסוננים, halachot לא. כרגע זה דווקא מסייע למציאוּת (לכן לא רגרסיה), אבל לא עקבי. דורש החלטת מדיניות: או לסנן halachot גם לפי source_kind (עקבי, אך 'מסתיר' שכבות), או להשאיר מאוחד במכוון + לתעד. אם משאירים מאוחד — לעדכן docstrings של שני הכלים שזה לא 'corpus נפרד'.",
"status": "pending", "status": "cancelled",
"priority": "low", "priority": "low",
"dependencies": [], "dependencies": [],
"details": "db.py: search_precedent_library_semantic (~שורה הקודמת ל-3311), search_precedent_library_lexical (3346). שתי הפונקציות: halacha_filters=['h.review_status IN ...'] — חסר cl.source_kind. נמצא בעת בדיקת רגרסיה למשימה 53.", "details": "db.py: search_precedent_library_semantic (~שורה הקודמת ל-3311), search_precedent_library_lexical (3346). שתי הפונקציות: halacha_filters=['h.review_status IN ...'] — חסר cl.source_kind. נמצא בעת בדיקת רגרסיה למשימה 53.\n\n[SUPERSEDED 2026-05-30] נבלע ב-FU-4 / תת-משימה 62.1 (GAP-10). נסגר כדי למנוע כפילות-tracker.",
"testStrategy": "לאחר החלטה: אם מסננים — search_precedent_library('...substantive...', external) לא מחזיר case_law_id internal; אם משאירים — docstring מעודכן + טסט מאשר התנהגות מכוונת.", "testStrategy": "לאחר החלטה: אם מסננים — search_precedent_library('...substantive...', external) לא מחזיר case_law_id internal; אם משאירים — docstring מעודכן + טסט מאשר התנהגות מכוונת.",
"subtasks": [], "subtasks": [],
"updatedAt": "2026-05-30T11:09:30.257989+00:00" "updatedAt": "2026-05-30T11:09:30.257989+00:00"
@@ -1984,7 +1984,7 @@
"dependencies": [ "dependencies": [
"55" "55"
], ],
"details": "מקור: case_law.full_text קיים. נתיב: chunker.chunk_document(_hierarchical) → embeddings → החלפת precedent_chunks לתיק. למחוק chunks ישנים של התיק לפני הוספה. אחרי הרצה — ניתן להסיר את פילטר ה->=50 query (אופציונלי). תיקים מושפעים: SELECT DISTINCT case_law_id WHERE length(trim(content))<50.", "details": "מקור: case_law.full_text קיים. נתיב: chunker.chunk_document(_hierarchical) → embeddings → החלפת precedent_chunks לתיק. למחוק chunks ישנים של התיק לפני הוספה. אחרי הרצה — ניתן להסיר את פילטר ה->=50 query (אופציונלי). תיקים מושפעים: SELECT DISTINCT case_law_id WHERE length(trim(content))<50.\n\n[קישור 2026-05-30] קשור ל-FU-3 (task 61, GAP-09 re-index). #57 = מיגרציה חד-פעמית של העבר (re-chunk legacy); FU-3 = ה-invariant הקדמי. נשמרים בנפרד במכוון.",
"testStrategy": "אחרי re-chunk לתיק לדוגמה: 0 chunks<50 לאותו case_law_id; search_internal_decisions עדיין מחזיר את התיק; ספירת chunks סבירה.", "testStrategy": "אחרי re-chunk לתיק לדוגמה: 0 chunks<50 לאותו case_law_id; search_internal_decisions עדיין מחזיר את התיק; ספירת chunks סבירה.",
"subtasks": [], "subtasks": [],
"updatedAt": "2026-05-30T11:19:06.142606+00:00" "updatedAt": "2026-05-30T11:19:06.142606+00:00"

View File

@@ -1509,6 +1509,37 @@ async def get_decision_by_case(case_id: UUID) -> dict | None:
return d return d
async def get_critical_qa_failures(case_id: UUID) -> list[dict]:
"""Return critical-severity failures from the case's latest QA run.
``qa_results`` is cleared+rewritten per ``validate_decision`` run, so the
current rows for a ``case_id`` ARE the latest run. Returns rows where
``severity='critical' AND passed=false``. Callers distinguish "no QA run
yet" (no rows at all) via ``qa_run_exists`` below.
"""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT check_name, severity, passed, errors
FROM qa_results
WHERE case_id = $1 AND severity = 'critical' AND passed = false
ORDER BY check_name""",
case_id,
)
return [dict(r) for r in rows]
async def qa_run_exists(case_id: UUID) -> bool:
"""True if a QA run has ever been recorded for this case (any rows)."""
pool = await get_pool()
async with pool.acquire() as conn:
n = await conn.fetchval(
"SELECT count(*) FROM qa_results WHERE case_id = $1",
case_id,
)
return bool(n)
async def update_decision(decision_id: UUID, **fields) -> None: async def update_decision(decision_id: UUID, **fields) -> None:
if not fields: if not fields:
return return
@@ -3165,7 +3196,10 @@ async def search_precedent_library_semantic(
of halacha review status. of halacha review status.
""" """
pool = await get_pool() pool = await get_pool()
halacha_filters = ["h.review_status IN ('approved', 'published')"] halacha_filters = [
"h.review_status IN ('approved', 'published')",
f"cl.source_kind = '{source_kind}'",
]
chunk_filters = [f"cl.source_kind = '{source_kind}'"] chunk_filters = [f"cl.source_kind = '{source_kind}'"]
h_params: list = [query_embedding, limit] h_params: list = [query_embedding, limit]
c_params: list = [query_embedding, limit] c_params: list = [query_embedding, limit]
@@ -3398,7 +3432,10 @@ async def search_precedent_library_lexical(
return [] return []
pool = await get_pool() pool = await get_pool()
halacha_filters = ["h.review_status IN ('approved', 'published')"] halacha_filters = [
"h.review_status IN ('approved', 'published')",
f"cl.source_kind = '{source_kind}'",
]
chunk_filters = [f"cl.source_kind = '{source_kind}'"] chunk_filters = [f"cl.source_kind = '{source_kind}'"]
# $1 = query, $2 = limit. Filters append starting at $3. # $1 = query, $2 = limit. Filters append starting at $3.
h_params: list = [query, limit] h_params: list = [query, limit]

View File

@@ -67,7 +67,7 @@ def check_neutral_background(blocks: list[dict]) -> dict:
"""בדיקת ניטרליות בלוק הרקע (ו).""" """בדיקת ניטרליות בלוק הרקע (ו)."""
vav = next((b for b in blocks if b["block_id"] == "block-vav"), None) vav = next((b for b in blocks if b["block_id"] == "block-vav"), None)
if not vav or not vav.get("content"): if not vav or not vav.get("content"):
return {"name": "neutral_background", "passed": True, "errors": [], "severity": "critical"} return {"name": "neutral_background", "passed": True, "errors": [], "severity": "warning"}
errors = [] errors = []
lines = vav["content"].split("\n") lines = vav["content"].split("\n")

View File

@@ -399,6 +399,26 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
case_id = UUID(case["id"]) case_id = UUID(case["id"])
# INV-EX3 / INV-QA3: a decision cannot be exported while critical QA gates
# fail (or before QA has been run at all). Gate on the STORED qa_results —
# cheap SELECT, no LLM re-run.
if not await db.qa_run_exists(case_id):
return json.dumps({
"status": "error",
"message": "ייצוא נחסם: בקרת איכות (QA) טרם רצה על התיק. "
"הרץ validate_decision לפני ייצוא.",
}, ensure_ascii=False, indent=2)
critical = await db.get_critical_qa_failures(case_id)
if critical:
gate_names = ", ".join(r["check_name"] for r in critical)
return json.dumps({
"status": "error",
"message": f"ייצוא נחסם: שערי QA קריטיים נכשלו ({gate_names}). "
f"תקן את הליקויים והרץ validate_decision מחדש לפני ייצוא.",
"failed_gates": [r["check_name"] for r in critical],
}, ensure_ascii=False, indent=2)
try: try:
path = await docx_exporter.export_decision(case_id, output_path or None) path = await docx_exporter.export_decision(case_id, output_path or None)
# Register this export as the new source of truth # Register this export as the new source of truth

View File

@@ -7,7 +7,7 @@ import logging
import time import time
from uuid import UUID from uuid import UUID
from legal_mcp.services import db, embeddings, hybrid_search, telemetry from legal_mcp.services import db, embeddings, hybrid_search, practice_area as pa, telemetry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,7 +30,9 @@ async def search_decisions(
appeal_subtype: סוג ערר לסינון (building_permit/betterment_levy/compensation_197) appeal_subtype: סוג ערר לסינון (building_permit/betterment_levy/compensation_197)
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
""" """
# Auto-resolve practice_area from case_number if available # Auto-resolve practice_area from case_number if available (GAP-12 / INV-RET1):
# explicit practice_area wins; otherwise derive from the case so the search is
# scoped to the case's legal domain. Case-less search stays cross-domain.
resolved_case_id: UUID | None = None resolved_case_id: UUID | None = None
if case_number and not practice_area: if case_number and not practice_area:
case = await db.get_case_by_number(case_number) case = await db.get_case_by_number(case_number)
@@ -42,6 +44,22 @@ async def search_decisions(
except (KeyError, ValueError, TypeError): except (KeyError, ValueError, TypeError):
resolved_case_id = None resolved_case_id = None
# Case row had no practice_area — fall back to deriving from the
# case-number prefix (1xxx/8xxx/9xxx). Returns "" for unknown prefixes.
if not practice_area:
practice_area = pa.derive_domain_practice_area(case_number)
# Still undeterminable: a case is present but we cannot scope the
# search to its domain. This is a data anomaly — BLOCK rather than
# silently running a cross-domain search for a specific case.
if not practice_area:
return (
f"שגיאה: לא ניתן לקבוע את התחום המשפטי (practice_area) של תיק "
f"{case_number}. לתיק אין practice_area מוגדר ולא ניתן להסיק אותו "
f"ממספר התיק. זוהי אנומליית נתונים — נא להגדיר את ה-practice_area "
f"של התיק (למשל דרך case_update) לפני הרצת חיפוש מסונן לתיק זה."
)
if not practice_area: if not practice_area:
logger.warning( logger.warning(
"search_decisions called without practice_area filter — " "search_decisions called without practice_area filter — "

View File

@@ -0,0 +1,151 @@
"""Regression tests for FU-6.
GAP-16 (INV-QA consistency): ``check_neutral_background`` must NOT return a
``severity='critical'`` result while ``passed=True``. The empty/missing
block-ו fallback now reports ``severity='warning'`` (consistent with passed).
GAP-15 (INV-EX3 / INV-QA3): ``export_docx`` must refuse to export while
critical QA gates fail OR before any QA run exists. It gates on the STORED
``qa_results`` (cheap SELECT via ``db.get_critical_qa_failures`` /
``db.qa_run_exists``) — it does NOT re-run the LLM validator.
All tests run fully OFFLINE — the pool / db helpers / exporter / git are
monkeypatched. No live Postgres needed.
"""
from __future__ import annotations
import asyncio
import json
import pytest
from legal_mcp.services import db
from legal_mcp.services import qa_validator
from legal_mcp.tools import drafting
# ── GAP-16 ────────────────────────────────────────────────────────
def test_neutral_background_empty_block_is_warning_not_critical() -> None:
"""Empty/missing block-ו → passed=True, so severity must be 'warning'."""
res = qa_validator.check_neutral_background([]) # no block-vav present
assert res["passed"] is True
assert res["severity"] == "warning", (
"a passed result must not carry severity='critical' (GAP-16)"
)
def test_neutral_background_dirty_block_still_critical_path_untouched() -> None:
"""A block-ו with judgment words still fails — fix didn't soften real checks."""
bad_word = qa_validator.VALUE_WORDS[0]
res = qa_validator.check_neutral_background(
[{"block_id": "block-vav", "content": f"הרקע: {bad_word} מאוד"}]
)
assert res["passed"] is False
assert res["errors"], "judgment-word violation should be reported"
# ── GAP-15 ────────────────────────────────────────────────────────
@pytest.fixture()
def patched_export(monkeypatch: pytest.MonkeyPatch) -> dict:
"""Monkeypatch case lookup, exporter, draft-path setter, and git so that
``export_docx`` is isolated to the QA-gate decision. Returns a dict of
call-tracking flags.
"""
calls = {"exported": False, "set_draft": False, "committed": False}
async def _get_case_by_number(case_number: str) -> dict:
return {"id": "00000000-0000-0000-0000-000000000001"}
async def _export_decision(case_id, output_path=None) -> str:
calls["exported"] = True
return "/tmp/decision.docx"
async def _set_active_draft_path(case_id, path) -> None:
calls["set_draft"] = True
def _commit_and_push(case_dir, msg) -> None:
calls["committed"] = True
# find_case_dir is called only on the success path; make it a no-op dir
class _FakeDir:
def exists(self) -> bool:
return False
monkeypatch.setattr(db, "get_case_by_number", _get_case_by_number)
monkeypatch.setattr(drafting.config, "find_case_dir", lambda cn: _FakeDir())
monkeypatch.setattr(drafting.git_sync, "commit_and_push", _commit_and_push)
# docx_exporter / set_active_draft_path are looked up dynamically; patch both
import legal_mcp.services.docx_exporter as docx_exporter
monkeypatch.setattr(docx_exporter, "export_decision", _export_decision)
monkeypatch.setattr(db, "set_active_draft_path", _set_active_draft_path)
return calls
def _run(coro):
return asyncio.run(coro)
def test_export_blocked_when_no_qa_run(
patched_export: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
async def _qa_run_exists(case_id) -> bool:
return False
async def _get_critical(case_id) -> list:
return []
monkeypatch.setattr(db, "qa_run_exists", _qa_run_exists)
monkeypatch.setattr(db, "get_critical_qa_failures", _get_critical)
out = json.loads(_run(drafting.export_docx("8001-24")))
assert out["status"] == "error"
assert "QA" in out["message"] or "validate_decision" in out["message"]
assert patched_export["exported"] is False, "must not call the exporter"
assert patched_export["committed"] is False, "must not git-commit"
def test_export_blocked_when_critical_failures(
patched_export: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
async def _qa_run_exists(case_id) -> bool:
return True
async def _get_critical(case_id) -> list:
return [
{"check_name": "claims_coverage", "severity": "critical",
"passed": False, "errors": []},
{"check_name": "structural_integrity", "severity": "critical",
"passed": False, "errors": []},
]
monkeypatch.setattr(db, "qa_run_exists", _qa_run_exists)
monkeypatch.setattr(db, "get_critical_qa_failures", _get_critical)
out = json.loads(_run(drafting.export_docx("8001-24")))
assert out["status"] == "error"
assert out["failed_gates"] == ["claims_coverage", "structural_integrity"]
assert "claims_coverage" in out["message"]
assert patched_export["exported"] is False, "must not call the exporter"
assert patched_export["committed"] is False, "must not git-commit"
def test_export_proceeds_when_clean(
patched_export: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
async def _qa_run_exists(case_id) -> bool:
return True
async def _get_critical(case_id) -> list:
return []
monkeypatch.setattr(db, "qa_run_exists", _qa_run_exists)
monkeypatch.setattr(db, "get_critical_qa_failures", _get_critical)
out = json.loads(_run(drafting.export_docx("8001-24")))
assert out["status"] == "completed", out
assert out["path"] == "/tmp/decision.docx"
assert patched_export["exported"] is True, "clean QA must allow export"
assert patched_export["set_draft"] is True, "active_draft_path must be set"

View File

@@ -0,0 +1,97 @@
"""Regression test for GAP-10 / INV-RET1: corpus separation enforced on
EVERY precedent-library query path — including the halacha sub-query.
Bug: ``search_precedent_library_semantic`` and
``search_precedent_library_lexical`` filtered the *chunk* sub-query by
``cl.source_kind`` but NOT the *halacha* sub-query. So an external
(``source_kind='external_upload'``) search leaked internal-committee
halachot, and an internal search leaked external-ruling halachot — a
cross-corpus contamination of the rule-level results.
Fix: the same ``cl.source_kind = '<kind>'`` predicate that gates the
chunk query now also gates the halacha query, in BOTH functions.
This test runs fully OFFLINE — it monkeypatches ``db.get_pool`` with a
fake pool that captures every SQL string passed to ``fetch`` instead of
hitting Postgres. It asserts the captured halacha SQL carries the
source_kind predicate identical to the chunk SQL.
"""
from __future__ import annotations
import asyncio
import pytest
from legal_mcp.services import db
class _FakePool:
"""Captures SQL passed to ``fetch``; returns no rows."""
def __init__(self) -> None:
self.queries: list[str] = []
async def fetch(self, sql: str, *args) -> list: # noqa: ANN002
self.queries.append(sql)
return []
def _classify(queries: list[str]) -> tuple[str, str]:
"""Return (halacha_sql, chunk_sql) from the captured queries."""
halacha = next(q for q in queries if "FROM halachot h" in q)
chunk = next(q for q in queries if "FROM precedent_chunks pc" in q)
return halacha, chunk
@pytest.fixture()
def fake_pool(monkeypatch: pytest.MonkeyPatch) -> _FakePool:
pool = _FakePool()
async def _get_pool() -> _FakePool:
return pool
monkeypatch.setattr(db, "get_pool", _get_pool)
return pool
@pytest.mark.parametrize("source_kind", ["external_upload", "internal_committee"])
def test_semantic_halacha_query_is_source_kind_scoped(
fake_pool: _FakePool, source_kind: str
) -> None:
asyncio.run(
db.search_precedent_library_semantic(
query_embedding=[0.0] * 8,
source_kind=source_kind,
include_halachot=True,
limit=5,
)
)
halacha_sql, chunk_sql = _classify(fake_pool.queries)
predicate = f"cl.source_kind = '{source_kind}'"
assert predicate in chunk_sql, "chunk query must be source_kind-scoped (precondition)"
assert predicate in halacha_sql, (
"halacha query MUST carry the same source_kind predicate as the "
"chunk query — otherwise cross-corpus halacha leakage (GAP-10)"
)
@pytest.mark.parametrize("source_kind", ["external_upload", "internal_committee"])
def test_lexical_halacha_query_is_source_kind_scoped(
fake_pool: _FakePool, source_kind: str
) -> None:
asyncio.run(
db.search_precedent_library_lexical(
query="zoning setback",
source_kind=source_kind,
include_halachot=True,
limit=5,
)
)
halacha_sql, chunk_sql = _classify(fake_pool.queries)
predicate = f"cl.source_kind = '{source_kind}'"
assert predicate in chunk_sql, "chunk query must be source_kind-scoped (precondition)"
assert predicate in halacha_sql, (
"halacha query MUST carry the same source_kind predicate as the "
"chunk query — otherwise cross-corpus halacha leakage (GAP-10)"
)

View File

@@ -0,0 +1,144 @@
"""Domain-scope tests for search_decisions (GAP-12 / INV-RET1).
Policy under test (see CLAUDE.md + tools/search.py):
1. explicit practice_area -> used as-is, search runs;
2. case_number + case.practice_area set -> use case value, runs;
3. case_number + empty case.practice_area but derivable prefix (8xxx)
-> derive domain, runs;
4. case_number present but UNDETERMINABLE (case.practice_area empty AND
prefix not 1/8/9) -> BLOCK (return Hebrew error, hybrid search
NEVER called);
5. no case_number, no practice_area -> warn + proceed (runs).
All DB / embedding / hybrid-search / telemetry calls are monkeypatched so
the test runs fully offline with no live Postgres or model.
"""
from __future__ import annotations
import asyncio
import json
from uuid import uuid4
import pytest
from legal_mcp.services import db, embeddings, hybrid_search, telemetry
from legal_mcp.tools import search as search_tool
def _run(coro):
return asyncio.run(coro)
@pytest.fixture()
def patched(monkeypatch: pytest.MonkeyPatch) -> dict:
"""Patch all I/O boundaries. Record what hybrid_search received.
``calls["hybrid"]`` is appended to ONLY when the real search runs, so
asserting ``calls["hybrid"] == []`` proves the search was blocked.
"""
calls: dict = {"hybrid": [], "cases": {}}
async def _embed_query(query: str):
return [0.0] * 8
async def _search_documents_hybrid(**kwargs):
calls["hybrid"].append(kwargs)
# one synthetic hit so the formatting path is exercised
return [
{
"score": 0.9,
"case_number": "X",
"document_title": "doc",
"section_type": "facts",
"page_number": 1,
"content": "hit",
"match_type": "text",
"image_thumbnail_path": None,
}
]
async def _get_case_by_number(case_number: str):
return calls["cases"].get(case_number)
def _log_search_bg(**kwargs):
return None
monkeypatch.setattr(embeddings, "embed_query", _embed_query)
monkeypatch.setattr(
hybrid_search, "search_documents_hybrid", _search_documents_hybrid
)
monkeypatch.setattr(db, "get_case_by_number", _get_case_by_number)
monkeypatch.setattr(telemetry, "log_search_bg", _log_search_bg)
return calls
def test_explicit_practice_area_used(patched: dict) -> None:
out = _run(
search_tool.search_decisions(
query="זכויות בנייה", practice_area="betterment_levy"
)
)
assert len(patched["hybrid"]) == 1
assert patched["hybrid"][0]["practice_area"] == "betterment_levy"
# explicit value must not trigger a case lookup
assert patched["cases"] == {}
# ran -> JSON result, not an error string
assert json.loads(out)[0]["content"] == "hit"
def test_case_practice_area_used(patched: dict) -> None:
patched["cases"]["8126/25"] = {
"id": str(uuid4()),
"practice_area": "betterment_levy",
"appeal_subtype": "betterment_levy",
}
out = _run(
search_tool.search_decisions(query="היטל", case_number="8126/25")
)
assert len(patched["hybrid"]) == 1
assert patched["hybrid"][0]["practice_area"] == "betterment_levy"
assert json.loads(out)[0]["content"] == "hit"
def test_case_empty_practice_area_derived_from_prefix(patched: dict) -> None:
# case row exists but practice_area is empty -> derive from 8xxx prefix
patched["cases"]["8126/25"] = {
"id": str(uuid4()),
"practice_area": "",
"appeal_subtype": "",
}
out = _run(
search_tool.search_decisions(query="היטל", case_number="8126/25")
)
assert len(patched["hybrid"]) == 1
assert patched["hybrid"][0]["practice_area"] == "betterment_levy"
assert json.loads(out)[0]["content"] == "hit"
def test_case_undeterminable_is_blocked(patched: dict) -> None:
# case exists, empty practice_area, and prefix is NOT 1/8/9 -> block
patched["cases"]["7777/25"] = {
"id": str(uuid4()),
"practice_area": "",
"appeal_subtype": "",
}
out = _run(
search_tool.search_decisions(query="משהו", case_number="7777/25")
)
# hybrid search must NOT have been called
assert patched["hybrid"] == []
# returns a Hebrew error string, not JSON
assert out.startswith("שגיאה")
assert "7777/25" in out
with pytest.raises(json.JSONDecodeError):
json.loads(out)
def test_no_case_no_practice_area_proceeds(patched: dict) -> None:
# exploratory / chat search: cross-domain, must NOT be blocked
out = _run(search_tool.search_decisions(query="חיפוש חופשי"))
assert len(patched["hybrid"]) == 1
assert patched["hybrid"][0]["practice_area"] is None
assert patched["cases"] == {}
assert json.loads(out)[0]["content"] == "hit"

View File

@@ -3170,10 +3170,25 @@ async def api_export_docx(case_number: str, background_tasks: BackgroundTasks):
(markdown body + download link) to the linked issue. (markdown body + download link) to the linked issue.
""" """
result = await drafting_tools.export_docx(case_number) result = await drafting_tools.export_docx(case_number)
if isinstance(result, dict):
data = result
else:
try: try:
data = json.loads(result) data = json.loads(result)
except json.JSONDecodeError: except (json.JSONDecodeError, TypeError):
raise HTTPException(500, result) # export_docx can also return a plain (non-JSON) string, e.g.
# "תיק ... לא נמצא." — surface it as a 500 with the raw text.
raise HTTPException(500, str(result))
# FU-6: a QA gate (or another error) can block the export. export_docx
# signals this with status == "error". Returning the existing 200 here
# would let the UI show a false "exported successfully" toast, so we map
# a block to 409 Conflict carrying the Hebrew message + failed_gates.
if isinstance(data, dict) and data.get("status") == "error":
detail = {"message": data.get("message", "ייצוא נחסם.")}
if data.get("failed_gates"):
detail["failed_gates"] = data["failed_gates"]
raise HTTPException(409, detail)
# Notify the Paperclip plugin to attach the final-decision document. # Notify the Paperclip plugin to attach the final-decision document.
docx_filename = ( docx_filename = (