Compare commits
6 Commits
system-spe
...
fix/fu6-qa
| Author | SHA1 | Date | |
|---|---|---|---|
| a61495f5ef | |||
| 084b31cd9b | |||
| 1473bdf3c2 | |||
| f51036bd98 | |||
| 1af689a969 | |||
| 80d1c5ff27 |
@@ -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"
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
151
mcp-server/tests/test_export_qa_gate.py
Normal file
151
mcp-server/tests/test_export_qa_gate.py
Normal 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"
|
||||||
97
mcp-server/tests/test_precedent_corpus_isolation.py
Normal file
97
mcp-server/tests/test_precedent_corpus_isolation.py
Normal 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)"
|
||||||
|
)
|
||||||
23
web/app.py
23
web/app.py
@@ -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)
|
||||||
try:
|
if isinstance(result, dict):
|
||||||
data = json.loads(result)
|
data = result
|
||||||
except json.JSONDecodeError:
|
else:
|
||||||
raise HTTPException(500, result)
|
try:
|
||||||
|
data = json.loads(result)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
# 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 = (
|
||||||
|
|||||||
Reference in New Issue
Block a user