Compare commits
43 Commits
worktree-c
...
c9970a5955
| Author | SHA1 | Date | |
|---|---|---|---|
| c9970a5955 | |||
| 38234d9b4f | |||
| a0b158b2c8 | |||
| a3df05e067 | |||
| a3e612f22b | |||
| 5108c854cf | |||
| d7855f6284 | |||
| 80809ca406 | |||
| abe4c53df1 | |||
| 5ede8a9653 | |||
| 33c10e4147 | |||
| 81050181d7 | |||
| 00c8083cc9 | |||
| 21ff52aff9 | |||
| cc8fd0b853 | |||
| 70f93c3bd4 | |||
| 4f45fa416b | |||
| ccc5a73bc8 | |||
| b8c49a1269 | |||
| b0bcdbeeef | |||
| 2c515966c5 | |||
| 91c521922f | |||
| ad29f6033f | |||
| 9d4960f28f | |||
| 38b3ffc587 | |||
| 2ae68c5896 | |||
| dd0312e457 | |||
| eaf80dbe9a | |||
| f63bd4df0f | |||
| 25b41af6a3 | |||
| 979ec17a45 | |||
| eb0182ecf8 | |||
| a401197204 | |||
| 688de7f842 | |||
| a18ed8ffb7 | |||
| 086913ce8d | |||
| f7bf437f67 | |||
| 473c54fc43 | |||
| 92f0c7d208 | |||
| 422d79f9e8 | |||
| fb32c278e6 | |||
| 9e0f483e83 | |||
| 5a69980adf |
@@ -166,7 +166,7 @@ tools:
|
|||||||
10. **הכנה ל-CREAC** — לכל סוגיה רשום:
|
10. **הכנה ל-CREAC** — לכל סוגיה רשום:
|
||||||
- כלל (Rule): הכלל המשפטי/תכנוני שיעמוד בבסיס הדיון
|
- כלל (Rule): הכלל המשפטי/תכנוני שיעמוד בבסיס הדיון
|
||||||
- עובדות מפתח (Facts): העובדות שיופיעו בשלב היישום
|
- עובדות מפתח (Facts): העובדות שיופיעו בשלב היישום
|
||||||
- תקדים מבהיר (אם נדרש): רק אם הכלל דורש הבהרה
|
- תקדים מבהיר (אם נדרש): רק אם הכלל דורש הבהרה. **שקלל סמכות (X11):** כל תוצאת `search_precedent_library` נושאת `cited_by`/`verified`/`cite_count` — כמה ועדות-ערר אימצו את התקדים. העדף תקדים עם `cited_by.positive` גבוה, וסַמֵּן אותו ל-writer במפורש: *"אומץ ב-N החלטות ועדת-ערר"* (כדי שיבסס סמכות). תקדים עם `cited_by.negative>0` (אובחן/בוטל) — סמן זהירות, אל תציגו כ-good-law.
|
||||||
11. **שאלות משפטיות** — 1-3 שאלות לפי הצורך (ראה שלב 4)
|
11. **שאלות משפטיות** — 1-3 שאלות לפי הצורך (ראה שלב 4)
|
||||||
12. **עמדת ועדת הערר** — שדה ריק שיו"ר הוועדה ימלא ידנית. **חובה להוסיף לכל סוגיה!** עמדה זו תשמש כהנחיה מחייבת לסוכן הכתיבה.
|
12. **עמדת ועדת הערר** — שדה ריק שיו"ר הוועדה ימלא ידנית. **חובה להוסיף לכל סוגיה!** עמדה זו תשמש כהנחיה מחייבת לסוכן הכתיבה.
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,15 @@ search_internal_decisions(
|
|||||||
|
|
||||||
**מינימום:** queries לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו.
|
**מינימום:** queries לקורפוס הסמכותי = מספר סוגיות מרכזיות שזוהו.
|
||||||
|
|
||||||
|
#### 2ב.4ב — שקלול סמכות לפי טיפול-שיפוטי-מצטבר (X11) ⚠️
|
||||||
|
|
||||||
|
כל תוצאה מ-`search_precedent_library` נושאת שדה **`cited_by`** + `verified`/`cite_count` — **כמה החלטות ועדת-ערר אחרות הסתמכו על אותו תקדים ואיך טיפלו בו**. זהו אות-סמכות אנושי-מצטבר (לא ניחוש-מודל), וחובה לשקלל אותו:
|
||||||
|
|
||||||
|
- **`cited_by.positive` גבוה (אומץ/הוסבר ע"י הרבה ועדות)** → תקדים-עוגן. **טַען את הסמכות במפורש** בפלט-המחקר: *"הלכת [שם] (עע"מ X/YY) — אומצה ב-N החלטות ועדת-ערר"*, כדי שה-writer יוכל לבסס עליה. בין שני תקדימים שקולים-תוכן — העדף את בעל ה-`positive` הגבוה.
|
||||||
|
- **`cited_by.negative` > 0 (אובחן/בוקר/בוטל)** → **דגל זהירות**. אל תציג תקדים שבוטל (`overruled`) כ"good law"; ציין את האבחנה. תקדים שאובחן שוב-ושוב אינו ראיה חזקה לסוגיה — בדוק את ההקשר.
|
||||||
|
- **`unclassified`** → הטיפול טרם סווג; התייחס ל-`cite_count` הגולמי בלבד.
|
||||||
|
- האות הזה **מחדד תיעדוף, לא מחליף קריאה** — עדיין קרא את ההלכה ואת ההקשר (INV-COR3/COR5: סמכות לסוגיה הספציפית, לא לפסק כולו).
|
||||||
|
|
||||||
#### 2ב.4א — איתור החלטה ספציפית לפי שם — פרוטוקול לפני "לא בקורפוס" ⚠️
|
#### 2ב.4א — איתור החלטה ספציפית לפי שם — פרוטוקול לפני "לא בקורפוס" ⚠️
|
||||||
|
|
||||||
שם תיק לבדו (למשל `"אגסי"`) **אינו מפתח חיפוש אמין**. ההטמעה הסמנטית והאינדקס הלקסיקלי בנויים על תוכן ההלכה/הפסקה — כך ששאילתת-שם עלולה להחזיר דווקא החלטות ש**מצטטות** את התיק, ולא את התיק עצמו. לפני שמכריזים שהחלטה אינה בקורפוס:
|
שם תיק לבדו (למשל `"אגסי"`) **אינו מפתח חיפוש אמין**. ההטמעה הסמנטית והאינדקס הלקסיקלי בנויים על תוכן ההלכה/הפסקה — כך ששאילתת-שם עלולה להחזיר דווקא החלטות ש**מצטטות** את התיק, ולא את התיק עצמו. לפני שמכריזים שהחלטה אינה בקורפוס:
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ case_update(case_number, status="drafted")
|
|||||||
### שלב ג: לכל סוגיה — מבנה סילוגיסטי (CREAC) בקול דפנה
|
### שלב ג: לכל סוגיה — מבנה סילוגיסטי (CREAC) בקול דפנה
|
||||||
1. **מסקנה** — פתח בתשובה (בקול "אנחנו" — ראה טבלה למטה)
|
1. **מסקנה** — פתח בתשובה (בקול "אנחנו" — ראה טבלה למטה)
|
||||||
2. **כלל** — ציטוט סעיף החוק במלואו (לא תמצית). אם רלוונטי — סעיפי משנה כולם.
|
2. **כלל** — ציטוט סעיף החוק במלואו (לא תמצית). אם רלוונטי — סעיפי משנה כולם.
|
||||||
3. **הרחבה** — תקדים רלוונטי אחד **בציטוט מלא** (לא תמצית). דפנה תמיד מצטטת בני 4-15 שורות עם הפניה `(פורסם בנבו)`.
|
3. **הרחבה** — תקדים רלוונטי אחד **בציטוט מלא** (לא תמצית). דפנה תמיד מצטטת בני 4-15 שורות עם הפניה `(פורסם בנבו)`. **כשהמנתח סימן תקדים כ"אומץ ב-N החלטות ועדת-ערר" (אות-סמכות X11)** — חזק את ההסתמכות בלשון-סמכות ("הלכה מושרשת שאומצה בשורת החלטות"); זהו טיפול-שיפוטי-מצטבר אמיתי, לא ניחוש. **אל** תמציא מספר בעצמך — השתמש רק במה שהמנתח מסר (INV-AH, read-only מהמנתח).
|
||||||
4. **יישום** — החל את הכלל על העובדות. הפרד ממצא עובדתי ממסקנה משפטית. השתמש בנתונים (מספרים, מידות, אחוזים).
|
4. **יישום** — החל את הכלל על העובדות. הפרד ממצא עובדתי ממסקנה משפטית. השתמש בנתונים (מספרים, מידות, אחוזים).
|
||||||
5. **אישור-לפני-דחייה (חובה)** — הצג את הטענה הטובה ביותר של הצד המפסיד: **"אכן [נקודה תקפה]... אולם [למה לא מכריע]"**. השימוש ב-"אכן" (לא "אמנם") הוא הסטנדרט.
|
5. **אישור-לפני-דחייה (חובה)** — הצג את הטענה הטובה ביותר של הצד המפסיד: **"אכן [נקודה תקפה]... אולם [למה לא מכריע]"**. השימוש ב-"אכן" (לא "אמנם") הוא הסטנדרט.
|
||||||
6. **למעלה מן הצורך** (חובה לטענות מרכזיות) — "גם אם היינו מקבלים את פרשנות העורר... התוצאה הייתה זהה". סוגר חלון לערעור.
|
6. **למעלה מן הצורך** (חובה לטענות מרכזיות) — "גם אם היינו מקבלים את פרשנות העורר... התוצאה הייתה זהה". סוגר חלון לערעור.
|
||||||
|
|||||||
@@ -35,14 +35,14 @@
|
|||||||
| `case_number` (citation) | CHAIR (חובה) | מפתח idempotency |
|
| `case_number` (citation) | CHAIR (חובה) | מפתח idempotency |
|
||||||
| `full_text`, `extraction_status`, `source_kind` | DETERMINISTIC | — |
|
| `full_text`, `extraction_status`, `source_kind` | DETERMINISTIC | — |
|
||||||
| `case_name`, `court`, `date`, `headnote`, `summary`, `key_quote`, `subject_tags`, `appeal_subtype`, `precedent_level`, `source_type`, `citation_formatted` | CHAIR או OPUS | Opus ממלא רק אם ריק |
|
| `case_name`, `court`, `date`, `headnote`, `summary`, `key_quote`, `subject_tags`, `appeal_subtype`, `precedent_level`, `source_type`, `citation_formatted` | CHAIR או OPUS | Opus ממלא רק אם ריק |
|
||||||
| `is_binding` | CHAIR (default true) | קובע prompt-הלכה |
|
| `is_binding` | CHAIR (default true) **אך** DETERMINISTIC-false ל-`source_type=appeals_committee` | קובע prompt-הלכה. סמכות מבנית ([INV-DM7](02-data-model.md#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד)): מקור-ועדה הוא תמיד persuasive — נכפה ל-false ב-`create_external_case_law` ונאכף ע"י `case_law_committee_not_binding_check` |
|
||||||
| chunks (`content`/`section_type`/`page_number`) | DETERMINISTIC | — |
|
| chunks (`content`/`section_type`/`page_number`) | DETERMINISTIC | — |
|
||||||
| `embedding` (chunks) | Voyage (לא-LLM-reasoning) | ⚠ לא-GENERATED ([gap-audit GAP-09](gap-audit.md)) |
|
| `embedding` (chunks) | Voyage (לא-LLM-reasoning) | ⚠ לא-GENERATED ([gap-audit GAP-09](gap-audit.md)) |
|
||||||
| כל `halachot` | OPUS | נכנס pending_review |
|
| כל `halachot` | OPUS | נכנס pending_review |
|
||||||
|
|
||||||
### 2ב. החלטה פנימית (`case_law`, source_kind=`internal_committee`)
|
### 2ב. החלטה פנימית (`case_law`, source_kind=`internal_committee`)
|
||||||
כמו 2א, ובנוסף: `case_number` **חובה**; `chair_name`/`district`/`proceeding_type` — CHAIR או OPUS או DERIVED;
|
כמו 2א, ובנוסף: `case_number` **חובה**; `chair_name`/`district`/`proceeding_type` — CHAIR או OPUS או DERIVED;
|
||||||
`source_type` = `appeals_committee` (DETERMINISTIC קבוע). placeholder `"(טרם חולץ)"` מסומן ל-chair_name/district
|
`source_type` = `appeals_committee` (DETERMINISTIC קבוע); `is_binding` = `false` (DETERMINISTIC קבוע — נכפה ב-`create_internal_committee_decision`, [INV-DM7](02-data-model.md#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד): החלטת-ועדה אינה מחייבת ועדה אחרת ולא את עצמה — תמיד persuasive). placeholder `"(טרם חולץ)"` מסומן ל-chair_name/district
|
||||||
ריקים ומטופל כריק ע"י ה-extractor.
|
ריקים ומטופל כריק ע"י ה-extractor.
|
||||||
|
|
||||||
### 2ג. מסמך-תיק (`documents`)
|
### 2ג. מסמך-תיק (`documents`)
|
||||||
|
|||||||
@@ -418,6 +418,12 @@ async def digest_process_pending(limit: int = 20) -> str:
|
|||||||
return await digest_tools.digest_process_pending(_clamp_limit(limit))
|
return await digest_tools.digest_process_pending(_clamp_limit(limit))
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def digest_radar(case_number: str, limit: int = 5, min_score: float = 0.45) -> str:
|
||||||
|
"""רדאר-יומונים הקשרי-לתיק (X12) — מחזיר יומונים לא-מקושרים (פס"ד שעוד אין לנו) שהנושא שלהם קרוב סמנטית לתיק, כדי שלא ייפול פס"ד רלוונטי בזמן ההכרעה. כל ליד מצביע על מראה-המקום של הפס"ד המקורי + סטטוס-הפער + פעולה מוצעת. ⚠️ radar בלבד — היומון אינו מצוטט (INV-DIG1)."""
|
||||||
|
return await digest_tools.digest_radar(case_number, _clamp_limit(limit), min_score)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def halacha_review(
|
async def halacha_review(
|
||||||
halacha_id: str,
|
halacha_id: str,
|
||||||
|
|||||||
134
mcp-server/src/legal_mcp/services/case_citation_verification.py
Normal file
134
mcp-server/src/legal_mcp/services/case_citation_verification.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""Citation-verification view (X11 Phase 2 / #154) — the chair's "אימות פסיקה" tab.
|
||||||
|
|
||||||
|
Assembles, per legal ARGUMENT of a case, the supporting precedents the chair should
|
||||||
|
verify before the writer cites them:
|
||||||
|
|
||||||
|
• in-corpus suggestions — per-issue semantic retrieval over the authoritative
|
||||||
|
precedent library (``search_library``), each carrying the cumulative authority
|
||||||
|
signal (``cited_by``: followed/distinguished — db.citation_authority, X11).
|
||||||
|
• attached/verified state — any ``case_precedents`` row already attached to the
|
||||||
|
argument (verified flag + chair_note), merged onto the matching suggestion.
|
||||||
|
• radar — UNLINKED digests relevant to the same issue (rulings we don't hold yet),
|
||||||
|
from ``case_digest_radar`` grouped by matched issue.
|
||||||
|
|
||||||
|
Pure read/assembly — never writes, never cites (INV-DIG1/INV-AH). The chair verifies
|
||||||
|
through ``db.set_case_precedent_verified`` / attach; the writer consumes only verified
|
||||||
|
rows. Reuses the one corpus search + the one authority query + the one radar (G2).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import (
|
||||||
|
argument_aggregator,
|
||||||
|
db,
|
||||||
|
digest_library,
|
||||||
|
precedent_library,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SUGGEST_PER_ISSUE = 4
|
||||||
|
_SUGGEST_FLOOR = 0.45
|
||||||
|
|
||||||
|
|
||||||
|
async def build_view(case_number: str) -> dict:
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
return {"status": "case_not_found", "case_number": case_number, "arguments": []}
|
||||||
|
case_id = case["id"]
|
||||||
|
if isinstance(case_id, str):
|
||||||
|
case_id = UUID(case_id)
|
||||||
|
ctx = " ".join(x for x in [case.get("title") or "", case.get("appeal_subtype") or ""] if x).strip()
|
||||||
|
|
||||||
|
args = await argument_aggregator.get_legal_arguments(case_id)
|
||||||
|
|
||||||
|
# Attached precedents already on the case → grouped by argument_id, keyed by
|
||||||
|
# the resolved corpus ruling so we can merge verify-state onto a suggestion.
|
||||||
|
attached = await db.list_case_precedents(case_id)
|
||||||
|
attached_by_arg: dict[str, dict[str, dict]] = {}
|
||||||
|
for p in attached:
|
||||||
|
aid = str(p.get("argument_id") or "")
|
||||||
|
clid = str(p.get("case_law_id") or "")
|
||||||
|
if aid and clid:
|
||||||
|
attached_by_arg.setdefault(aid, {})[clid] = p
|
||||||
|
|
||||||
|
# Radar (unlinked digests) once, grouped by the issue label it matched.
|
||||||
|
radar_by_issue: dict[str, list[dict]] = {}
|
||||||
|
try:
|
||||||
|
radar = await digest_library.case_digest_radar(case_number, limit=12, min_score=0.42)
|
||||||
|
for lead in radar.get("leads", []):
|
||||||
|
for label in (lead.get("matched_issues") or ["—"]):
|
||||||
|
radar_by_issue.setdefault(label, []).append(lead)
|
||||||
|
except Exception as e: # noqa: BLE001 — radar is best-effort
|
||||||
|
logger.warning("citation_verification radar failed for %s: %s", case_number, e)
|
||||||
|
|
||||||
|
out_args: list[dict] = []
|
||||||
|
n_verified = 0
|
||||||
|
for a in args:
|
||||||
|
aid = str(a["id"])
|
||||||
|
title = (a.get("argument_title") or "").strip()
|
||||||
|
topic = (a.get("legal_topic") or "").strip()
|
||||||
|
query = f"{ctx} {title}. {topic}".strip()
|
||||||
|
|
||||||
|
hits = []
|
||||||
|
try:
|
||||||
|
hits = await precedent_library.search_library(
|
||||||
|
query=query, limit=_SUGGEST_PER_ISSUE, include_halachot=True)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("citation_verification search failed (%s): %s", title[:30], e)
|
||||||
|
|
||||||
|
# Resolve the authority breakdown for the hit set in one batched query.
|
||||||
|
clids = [UUID(str(h["case_law_id"])) for h in hits
|
||||||
|
if h.get("case_law_id") and float(h.get("score", 0) or 0) >= _SUGGEST_FLOOR]
|
||||||
|
authority = await db.citation_authority(clids) if clids else {}
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
supporting: list[dict] = []
|
||||||
|
for h in hits:
|
||||||
|
clid = str(h.get("case_law_id") or "")
|
||||||
|
if not clid or clid in seen:
|
||||||
|
continue
|
||||||
|
if float(h.get("score", 0) or 0) < _SUGGEST_FLOOR:
|
||||||
|
continue
|
||||||
|
seen.add(clid)
|
||||||
|
att = attached_by_arg.get(aid, {}).get(clid)
|
||||||
|
if att and att.get("verified"):
|
||||||
|
n_verified += 1
|
||||||
|
supporting.append({
|
||||||
|
"case_law_id": clid,
|
||||||
|
"case_number": h.get("case_number") or "",
|
||||||
|
"case_name": h.get("case_name") or "",
|
||||||
|
"quote": h.get("supporting_quote") or h.get("rule_statement") or "",
|
||||||
|
"score": round(float(h.get("score", 0) or 0), 3),
|
||||||
|
"cited_by": authority.get(clid, {"total": 0, "positive": 0,
|
||||||
|
"negative": 0, "unclassified": 0,
|
||||||
|
"by_treatment": {}}),
|
||||||
|
"attached_id": str(att["id"]) if att else None,
|
||||||
|
"verified": bool(att.get("verified")) if att else False,
|
||||||
|
"chair_note": (att.get("chair_note") or "") if att else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
out_args.append({
|
||||||
|
"argument_id": aid,
|
||||||
|
"title": title,
|
||||||
|
"legal_topic": topic,
|
||||||
|
"priority": a.get("priority") or "",
|
||||||
|
"party": a.get("party") or "",
|
||||||
|
"supporting": supporting,
|
||||||
|
"radar": radar_by_issue.get(title, []),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"case_number": case_number,
|
||||||
|
"arguments": out_args,
|
||||||
|
"summary": {
|
||||||
|
"arguments_total": len(out_args),
|
||||||
|
"arguments_with_support": sum(1 for x in out_args if x["supporting"]),
|
||||||
|
"verified": n_verified,
|
||||||
|
"radar_leads": sum(len(x["radar"]) for x in out_args),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -236,7 +236,18 @@ async def extract_and_store(case_law_id: UUID) -> dict:
|
|||||||
if not row:
|
if not row:
|
||||||
return {"extracted": 0, "linked": 0, "new": 0, "skipped": 0, "error": "not_found"}
|
return {"extracted": 0, "linked": 0, "new": 0, "skipped": 0, "error": "not_found"}
|
||||||
|
|
||||||
text = row["full_text"] or ""
|
# A citation counts as the DECIDING body's reliance ONLY when it appears in
|
||||||
|
# the discussion/ruling sections — never where a party cites a ruling in its
|
||||||
|
# own arguments (block ז). Reuse the halacha extractor's section selection
|
||||||
|
# (G2: a single definition of "reasoning sections" — legal_analysis/ruling/
|
||||||
|
# conclusion + the discussion-anchor, excluding facts/claims/intro) so a
|
||||||
|
# ruling cited only in appellant_claims/parties_claims is never attributed to
|
||||||
|
# the chair. Fall back to full_text only for un-chunked rows.
|
||||||
|
from legal_mcp.services import halacha_extractor as _hx
|
||||||
|
disc_chunks, _used_fallback = await _hx._select_extractable_chunks(case_law_id)
|
||||||
|
text = "\n".join((c.get("content") or "") for c in disc_chunks).strip()
|
||||||
|
if not text:
|
||||||
|
text = row["full_text"] or ""
|
||||||
own_norm = _normalize_case_number(row["case_number"] or "")
|
own_norm = _normalize_case_number(row["case_number"] or "")
|
||||||
|
|
||||||
extracted = 0
|
extracted = 0
|
||||||
@@ -388,12 +399,16 @@ async def list_citations_to_case_law(case_law_id: UUID) -> list[dict]:
|
|||||||
pic.cited_case_number,
|
pic.cited_case_number,
|
||||||
pic.match_context,
|
pic.match_context,
|
||||||
pic.match_pattern,
|
pic.match_pattern,
|
||||||
|
pic.treatment,
|
||||||
pic.confidence::float AS confidence,
|
pic.confidence::float AS confidence,
|
||||||
pic.created_at,
|
pic.created_at,
|
||||||
cl.case_number AS source_case_number,
|
cl.case_number AS source_case_number,
|
||||||
cl.case_name AS source_case_name,
|
cl.case_name AS source_case_name,
|
||||||
cl.chair_name AS source_chair_name,
|
cl.chair_name AS source_chair_name,
|
||||||
cl.district AS source_district
|
cl.district AS source_district,
|
||||||
|
cl.precedent_level AS source_precedent_level,
|
||||||
|
cl.court AS source_court,
|
||||||
|
cl.date AS source_date
|
||||||
FROM precedent_internal_citations pic
|
FROM precedent_internal_citations pic
|
||||||
JOIN case_law cl ON cl.id = pic.source_case_law_id
|
JOIN case_law cl ON cl.id = pic.source_case_law_id
|
||||||
WHERE pic.cited_case_law_id = $1
|
WHERE pic.cited_case_law_id = $1
|
||||||
@@ -432,3 +447,42 @@ async def get_cited_case_law_ids(source_case_law_ids: list[UUID]) -> dict[str, l
|
|||||||
for r in rows:
|
for r in rows:
|
||||||
out.setdefault(r["source_id"], []).append(r["cited_id"])
|
out.setdefault(r["source_id"], []).append(r["cited_id"])
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def relink_orphan_citations(case_law_id: "UUID | None" = None) -> int:
|
||||||
|
"""Back-fill ``cited_case_law_id`` on orphan citation edges that now resolve.
|
||||||
|
|
||||||
|
Citations are resolved to a corpus row only at extraction time (see
|
||||||
|
``extract_and_store``). When a cited ruling is uploaded LATER, its existing
|
||||||
|
orphan edges (``cited_case_law_id IS NULL``) are never re-resolved, so the
|
||||||
|
citation graph under-counts real connectivity. Call on every precedent
|
||||||
|
upload (``case_law_id`` set → link only edges that resolve to that row) or as
|
||||||
|
a one-off sweep (``case_law_id=None`` → re-resolve every orphan). Reuses the
|
||||||
|
canonical resolver ``_resolve_case_law_id`` (no parallel matching logic, G2);
|
||||||
|
idempotent — a second run links nothing new.
|
||||||
|
"""
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
orphans = await conn.fetch(
|
||||||
|
"SELECT DISTINCT cited_case_number FROM precedent_internal_citations "
|
||||||
|
"WHERE cited_case_law_id IS NULL AND coalesce(cited_case_number, '') <> ''"
|
||||||
|
)
|
||||||
|
linked = 0
|
||||||
|
for r in orphans:
|
||||||
|
cited = await _resolve_case_law_id(_normalize_case_number(r["cited_case_number"]))
|
||||||
|
if cited is None:
|
||||||
|
continue
|
||||||
|
if case_law_id is not None and cited != case_law_id:
|
||||||
|
continue
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
res = await conn.execute(
|
||||||
|
"UPDATE precedent_internal_citations "
|
||||||
|
"SET cited_case_law_id = $1, confidence = GREATEST(confidence, 0.90) "
|
||||||
|
"WHERE cited_case_law_id IS NULL AND cited_case_number = $2",
|
||||||
|
cited, r["cited_case_number"],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
linked += int(res.split()[-1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
return linked
|
||||||
|
|||||||
@@ -1697,6 +1697,57 @@ CREATE INDEX IF NOT EXISTS idx_hcc_canonical
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_V42_SQL = """
|
||||||
|
-- INV-DM7: authority (binding/persuasive) is STRUCTURAL for committee sources.
|
||||||
|
-- An appeals-committee decision (source_kind='internal_committee', or any row
|
||||||
|
-- with source_type='appeals_committee') is persuasive, NEVER binding — a
|
||||||
|
-- committee's ruling does not bind another committee, nor itself. is_binding
|
||||||
|
-- stays a stored column (it selects the halacha-extraction prompt), but for
|
||||||
|
-- committee sources it is a faithful cache of the derived value, not a chair
|
||||||
|
-- guess. Mirrors 02-data-model.md §INV-DM7 / X8-field-provenance.md.
|
||||||
|
-- Step 1 — normalize legacy rows at the source (G1), idempotent.
|
||||||
|
UPDATE case_law SET is_binding = FALSE
|
||||||
|
WHERE is_binding IS TRUE
|
||||||
|
AND (source_kind = 'internal_committee' OR source_type = 'appeals_committee');
|
||||||
|
-- Step 2 — constrain so a binding committee row can never be written again.
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE case_law ADD CONSTRAINT case_law_committee_not_binding_check
|
||||||
|
CHECK (NOT ((source_kind = 'internal_committee'
|
||||||
|
OR source_type = 'appeals_committee') AND is_binding));
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_V43_SQL = """
|
||||||
|
-- חיפה (Haifa) is a distinct planning district, separate from הצפון (North).
|
||||||
|
-- A bug in _district_from_court mapped "חיפה" -> "צפון", and the service-layer
|
||||||
|
-- _VALID_DISTRICTS omitted "חיפה" entirely, so Haifa committee decisions were
|
||||||
|
-- mis-filed under צפון. Both fixed in internal_decisions.py; this reclassifies
|
||||||
|
-- the legacy rows at the source (G1) by their court text. Idempotent.
|
||||||
|
UPDATE case_law SET district = 'חיפה'
|
||||||
|
WHERE source_kind = 'internal_committee'
|
||||||
|
AND district = 'צפון'
|
||||||
|
AND court ILIKE '%חיפה%';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_V44_SQL = """
|
||||||
|
-- Citation-verification panel (#154): the chair verifies each corpus precedent
|
||||||
|
-- against the specific legal ARGUMENT before the writer may cite it (INV-AH gate).
|
||||||
|
-- case_precedents gains: the argument it supports, the resolved case_law row
|
||||||
|
-- (for the cited_by authority signal + dedup), a verified flag (the gate), and a
|
||||||
|
-- verification timestamp. chair_note already exists. All nullable so legacy
|
||||||
|
-- section-scoped rows stay valid.
|
||||||
|
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS argument_id UUID
|
||||||
|
REFERENCES legal_arguments(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS case_law_id UUID
|
||||||
|
REFERENCES case_law(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS verified BOOLEAN DEFAULT false;
|
||||||
|
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS verified_at TIMESTAMPTZ;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_case_precedents_argument ON case_precedents(argument_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Stable, arbitrary key for the session-level advisory lock that serialises
|
# Stable, arbitrary key for the session-level advisory lock that serialises
|
||||||
# schema DDL across processes. Every short-lived process (cron drains, services)
|
# schema DDL across processes. Every short-lived process (cron drains, services)
|
||||||
# re-runs the idempotent migrations on startup; without this lock two processes
|
# re-runs the idempotent migrations on startup; without this lock two processes
|
||||||
@@ -1714,7 +1765,7 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
|||||||
await _apply_schema_ddl(conn)
|
await _apply_schema_ddl(conn)
|
||||||
finally:
|
finally:
|
||||||
await conn.execute("SELECT pg_advisory_unlock($1)", _MIGRATION_LOCK_KEY)
|
await conn.execute("SELECT pg_advisory_unlock($1)", _MIGRATION_LOCK_KEY)
|
||||||
logger.info("Database schema initialized (v1-v41)")
|
logger.info("Database schema initialized (v1-v43)")
|
||||||
|
|
||||||
|
|
||||||
async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
||||||
@@ -1760,6 +1811,9 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
|||||||
await conn.execute(SCHEMA_V39_SQL)
|
await conn.execute(SCHEMA_V39_SQL)
|
||||||
await conn.execute(SCHEMA_V40_SQL)
|
await conn.execute(SCHEMA_V40_SQL)
|
||||||
await conn.execute(SCHEMA_V41_SQL)
|
await conn.execute(SCHEMA_V41_SQL)
|
||||||
|
await conn.execute(SCHEMA_V42_SQL)
|
||||||
|
await conn.execute(SCHEMA_V43_SQL)
|
||||||
|
await conn.execute(SCHEMA_V44_SQL)
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -3295,28 +3349,58 @@ async def create_case_precedent(
|
|||||||
chair_note: str = "",
|
chair_note: str = "",
|
||||||
pdf_document_id: UUID | None = None,
|
pdf_document_id: UUID | None = None,
|
||||||
practice_area: str | None = None,
|
practice_area: str | None = None,
|
||||||
|
argument_id: UUID | None = None,
|
||||||
|
case_law_id: UUID | None = None,
|
||||||
|
verified: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Insert a new precedent attached to a case."""
|
"""Insert a new precedent attached to a case.
|
||||||
|
|
||||||
|
``argument_id``/``case_law_id``/``verified`` (X11 #154) link the attachment to
|
||||||
|
the specific legal argument it supports and the corpus ruling, and mark whether
|
||||||
|
the chair verified it (the INV-AH gate the writer respects)."""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
row = await pool.fetchrow(
|
row = await pool.fetchrow(
|
||||||
"""
|
"""
|
||||||
INSERT INTO case_precedents
|
INSERT INTO case_precedents
|
||||||
(case_id, section_id, quote, citation, chair_note, pdf_document_id, practice_area)
|
(case_id, section_id, quote, citation, chair_note, pdf_document_id,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
practice_area, argument_id, case_law_id, verified, verified_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||||
|
CASE WHEN $10 THEN now() ELSE NULL END)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
case_id, section_id, quote, citation, chair_note, pdf_document_id, practice_area,
|
case_id, section_id, quote, citation, chair_note, pdf_document_id,
|
||||||
|
practice_area, argument_id, case_law_id, verified,
|
||||||
)
|
)
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_case_precedent_verified(
|
||||||
|
precedent_id: UUID, verified: bool, chair_note: str | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Toggle the chair-verification gate on an attached precedent (#154), optionally
|
||||||
|
updating the chair note. Sets verified_at when verifying, clears it when un-verifying."""
|
||||||
|
pool = await get_pool()
|
||||||
|
sets = ["verified = $2", "verified_at = CASE WHEN $2 THEN now() ELSE NULL END",
|
||||||
|
"updated_at = now()"]
|
||||||
|
params: list = [precedent_id, verified]
|
||||||
|
if chair_note is not None:
|
||||||
|
sets.append(f"chair_note = ${len(params) + 1}")
|
||||||
|
params.append(chair_note)
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
f"UPDATE case_precedents SET {', '.join(sets)} WHERE id = $1 RETURNING *",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def list_case_precedents(case_id: UUID) -> list[dict]:
|
async def list_case_precedents(case_id: UUID) -> list[dict]:
|
||||||
"""List all precedents attached to a case, ordered by section then creation time."""
|
"""List all precedents attached to a case, ordered by section then creation time."""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
rows = await pool.fetch(
|
rows = await pool.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT id, case_id, section_id, quote, citation, chair_note,
|
SELECT id, case_id, section_id, quote, citation, chair_note,
|
||||||
pdf_document_id, practice_area, created_at, updated_at
|
pdf_document_id, practice_area, argument_id, case_law_id,
|
||||||
|
verified, verified_at, created_at, updated_at
|
||||||
FROM case_precedents
|
FROM case_precedents
|
||||||
WHERE case_id = $1
|
WHERE case_id = $1
|
||||||
ORDER BY section_id NULLS LAST, created_at
|
ORDER BY section_id NULLS LAST, created_at
|
||||||
@@ -4253,6 +4337,34 @@ async def case_number_collides(case_number: str, exclude_id: UUID) -> bool:
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
# Subject-tag convention for case_law topic hubs (powers /graph topic nodes).
|
||||||
|
# A simple multi-word Hebrew phrase is stored with underscores between words
|
||||||
|
# ("היטל_השבחה") — the documented extraction contract (precedent_library tool
|
||||||
|
# examples: קווי_בניין, מועד_קביעת_שומה) that ~99% of the corpus already follows.
|
||||||
|
# The same phrase arriving with spaces ("היטל השבחה") is the same topic in
|
||||||
|
# disguise and spawns a duplicate graph hub. Normalize at the single case_law
|
||||||
|
# write chokepoint (G1) so no path can re-introduce the split. Tags carrying
|
||||||
|
# punctuation/digits/dashes (e.g. "פטור מותנה — סעיף 19(ג)") are left untouched —
|
||||||
|
# the convention covers only plain Hebrew word phrases.
|
||||||
|
_PURE_HEBREW_WORDS = re.compile(r"^[א-ת]+(?: [א-ת]+)+$")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_subject_tags(tags) -> list[str]:
|
||||||
|
"""Strip, apply the underscore convention to plain Hebrew phrases, dedup."""
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in tags or []:
|
||||||
|
t = str(raw).strip()
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
if _PURE_HEBREW_WORDS.match(t):
|
||||||
|
t = t.replace(" ", "_")
|
||||||
|
if t not in seen:
|
||||||
|
seen.add(t)
|
||||||
|
out.append(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def create_external_case_law(
|
async def create_external_case_law(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
case_name: str,
|
case_name: str,
|
||||||
@@ -4270,15 +4382,27 @@ async def create_external_case_law(
|
|||||||
precedent_level: str = "",
|
precedent_level: str = "",
|
||||||
is_binding: bool = True,
|
is_binding: bool = True,
|
||||||
document_id: UUID | None = None,
|
document_id: UUID | None = None,
|
||||||
|
citation_formatted: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Insert a chair-uploaded external precedent into case_law.
|
"""Insert a chair-uploaded external precedent into case_law.
|
||||||
|
|
||||||
If a row with this ``case_number`` already exists with
|
If a row with this ``case_number`` already exists with
|
||||||
source_kind='cited_only' (auto-discovered), promote it to
|
source_kind='cited_only' (auto-discovered), promote it to
|
||||||
source_kind='external_upload' and fill in the missing fields.
|
source_kind='external_upload' and fill in the missing fields.
|
||||||
|
|
||||||
|
``citation_formatted`` seeds the מראה-מקום from the chair's typed upload-form
|
||||||
|
value so the field is NEVER blank between upload and metadata extraction (the
|
||||||
|
inline enrichment later UPGRADES it to the canonical derived form via
|
||||||
|
``apply_to_record(force_citation=True)``). On a cited_only→external promotion
|
||||||
|
an existing non-empty value (a prior chair edit) is preserved.
|
||||||
"""
|
"""
|
||||||
|
# INV-DM7: an appeals-committee source is persuasive even when uploaded via
|
||||||
|
# the external path — coerce to non-binding so it matches the committee
|
||||||
|
# invariant and case_law_committee_not_binding_check (G1).
|
||||||
|
if source_type == "appeals_committee":
|
||||||
|
is_binding = False
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
|
tags_json = json.dumps(_normalize_subject_tags(subject_tags), ensure_ascii=False)
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
# Atomic upsert on the V15 partial unique index
|
# Atomic upsert on the V15 partial unique index
|
||||||
# uq_case_law_external_number (case_number) WHERE source_kind <> 'internal_committee'.
|
# uq_case_law_external_number (case_number) WHERE source_kind <> 'internal_committee'.
|
||||||
@@ -4295,11 +4419,12 @@ async def create_external_case_law(
|
|||||||
summary, key_quote, full_text, source_url,
|
summary, key_quote, full_text, source_url,
|
||||||
source_kind, document_id, extraction_status,
|
source_kind, document_id, extraction_status,
|
||||||
halacha_extraction_status, practice_area, appeal_subtype,
|
halacha_extraction_status, practice_area, appeal_subtype,
|
||||||
headnote, source_type, precedent_level, is_binding, content_hash
|
headnote, source_type, precedent_level, is_binding, content_hash,
|
||||||
|
citation_formatted
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||||
'external_upload', $10, 'processing', 'pending',
|
'external_upload', $10, 'processing', 'pending',
|
||||||
$11, $12, $13, $14, $15, $16, $17
|
$11, $12, $13, $14, $15, $16, $17, $18
|
||||||
)
|
)
|
||||||
ON CONFLICT (case_number) WHERE source_kind <> 'internal_committee'
|
ON CONFLICT (case_number) WHERE source_kind <> 'internal_committee'
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
@@ -4321,14 +4446,16 @@ async def create_external_case_law(
|
|||||||
source_kind = 'external_upload',
|
source_kind = 'external_upload',
|
||||||
extraction_status = 'processing',
|
extraction_status = 'processing',
|
||||||
halacha_extraction_status = 'pending',
|
halacha_extraction_status = 'pending',
|
||||||
content_hash = EXCLUDED.content_hash
|
content_hash = EXCLUDED.content_hash,
|
||||||
|
citation_formatted = COALESCE(
|
||||||
|
NULLIF(case_law.citation_formatted, ''), EXCLUDED.citation_formatted)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
case_number, case_name, court, decision_date, tags_json,
|
case_number, case_name, court, decision_date, tags_json,
|
||||||
summary, key_quote, full_text, source_url,
|
summary, key_quote, full_text, source_url,
|
||||||
document_id, practice_area, appeal_subtype, headnote,
|
document_id, practice_area, appeal_subtype, headnote,
|
||||||
source_type, precedent_level, is_binding,
|
source_type, precedent_level, is_binding,
|
||||||
_content_hash(full_text),
|
_content_hash(full_text), (citation_formatted or "").strip(),
|
||||||
)
|
)
|
||||||
return _row_to_case_law(row)
|
return _row_to_case_law(row)
|
||||||
|
|
||||||
@@ -4345,7 +4472,7 @@ async def create_internal_committee_decision(
|
|||||||
appeal_subtype: str = "",
|
appeal_subtype: str = "",
|
||||||
subject_tags: list[str] | None = None,
|
subject_tags: list[str] | None = None,
|
||||||
summary: str = "",
|
summary: str = "",
|
||||||
is_binding: bool = True,
|
is_binding: bool = False,
|
||||||
document_id: UUID | None = None,
|
document_id: UUID | None = None,
|
||||||
proceeding_type: str = "ערר",
|
proceeding_type: str = "ערר",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -4355,9 +4482,13 @@ async def create_internal_committee_decision(
|
|||||||
exist as both 'ערר' and 'בל"מ' (an extension-of-time request can be
|
exist as both 'ערר' and 'בל"מ' (an extension-of-time request can be
|
||||||
filed against an existing appeal with the same number).
|
filed against an existing appeal with the same number).
|
||||||
"""
|
"""
|
||||||
|
# INV-DM7: authority is structural for this source — an appeals-committee
|
||||||
|
# decision is persuasive, never binding. Coerce regardless of caller so the
|
||||||
|
# value can never violate case_law_committee_not_binding_check (G1).
|
||||||
|
is_binding = False
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
case_number = _canonical_case_number(case_number)
|
case_number = _canonical_case_number(case_number)
|
||||||
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
|
tags_json = json.dumps(_normalize_subject_tags(subject_tags), ensure_ascii=False)
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
# Atomic upsert on V15 partial unique index
|
# Atomic upsert on V15 partial unique index
|
||||||
# uq_case_law_internal_number_proc (case_number, proceeding_type)
|
# uq_case_law_internal_number_proc (case_number, proceeding_type)
|
||||||
@@ -4498,6 +4629,12 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
|
|||||||
"proceeding_type", "citation_formatted", "parties",
|
"proceeding_type", "citation_formatted", "parties",
|
||||||
}
|
}
|
||||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||||
|
# INV-DM7: reclassifying a row to an appeals-committee source makes it
|
||||||
|
# persuasive — coerce is_binding in the same patch so the UPDATE can't
|
||||||
|
# violate case_law_committee_not_binding_check (e.g. the Gemini metadata
|
||||||
|
# extractor relabels an external court_ruling as appeals_committee).
|
||||||
|
if updates.get("source_type") == "appeals_committee":
|
||||||
|
updates["is_binding"] = False
|
||||||
if not updates:
|
if not updates:
|
||||||
return await get_case_law(case_law_id)
|
return await get_case_law(case_law_id)
|
||||||
|
|
||||||
@@ -4506,12 +4643,21 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
|
|||||||
params: list = [case_law_id]
|
params: list = [case_law_id]
|
||||||
for i, (k, v) in enumerate(updates.items(), start=2):
|
for i, (k, v) in enumerate(updates.items(), start=2):
|
||||||
if k == "subject_tags":
|
if k == "subject_tags":
|
||||||
v = json.dumps(v or [], ensure_ascii=False)
|
v = json.dumps(_normalize_subject_tags(v), ensure_ascii=False)
|
||||||
set_parts.append(f"{k} = ${i}")
|
set_parts.append(f"{k} = ${i}")
|
||||||
params.append(v)
|
params.append(v)
|
||||||
sql = f"UPDATE case_law SET {', '.join(set_parts)} WHERE id = $1 RETURNING *"
|
sql = f"UPDATE case_law SET {', '.join(set_parts)} WHERE id = $1 RETURNING *"
|
||||||
row = await pool.fetchrow(sql, *params)
|
row = await pool.fetchrow(sql, *params)
|
||||||
return _row_to_case_law(row) if row else None
|
if row is None:
|
||||||
|
return None
|
||||||
|
# `searchable` is a DERIVED completeness flag (INV-DM1). It is computed at
|
||||||
|
# ingest end and after the Gemini metadata extractor — but internal-committee
|
||||||
|
# decisions skip Gemini, so when their summary/tags/metadata are filled later
|
||||||
|
# (manual edit, deterministic enrichment) the flag would otherwise go stale
|
||||||
|
# and the row stays invisible to RAG. Recompute at this write so the derived
|
||||||
|
# value never drifts from the content (G1: normalize at the source).
|
||||||
|
await recompute_searchable(case_law_id)
|
||||||
|
return await get_case_law(case_law_id)
|
||||||
|
|
||||||
|
|
||||||
async def set_case_law_extraction_status(case_law_id: UUID, status: str) -> None:
|
async def set_case_law_extraction_status(case_law_id: UUID, status: str) -> None:
|
||||||
@@ -4920,14 +5066,24 @@ async def search_digests_semantic(
|
|||||||
subject_tag: str = "",
|
subject_tag: str = "",
|
||||||
concept_tag: str = "",
|
concept_tag: str = "",
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
|
linked_only: bool | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Pure-semantic search over the digests radar (X12). Single vector per row
|
"""Pure-semantic search over the digests radar (X12). Single vector per row
|
||||||
(no chunks/halachot), so no RRF here — see X12 §6. Joins the linked ruling's
|
(no chunks/halachot), so no RRF here — see X12 §6. Joins the linked ruling's
|
||||||
citation when present so the researcher sees the pointer target directly."""
|
citation when present so the researcher sees the pointer target directly.
|
||||||
|
|
||||||
|
``linked_only``: None = all digests (default); False = only UNLINKED digests
|
||||||
|
(``linked_case_law_id IS NULL`` — rulings we don't hold yet, the case radar's
|
||||||
|
target set); True = only linked digests.
|
||||||
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
conditions = ["d.embedding IS NOT NULL"]
|
conditions = ["d.embedding IS NOT NULL"]
|
||||||
params: list = [query_embedding, limit]
|
params: list = [query_embedding, limit]
|
||||||
idx = 3
|
idx = 3
|
||||||
|
if linked_only is True:
|
||||||
|
conditions.append("d.linked_case_law_id IS NOT NULL")
|
||||||
|
elif linked_only is False:
|
||||||
|
conditions.append("d.linked_case_law_id IS NULL")
|
||||||
if practice_area:
|
if practice_area:
|
||||||
conditions.append(f"d.practice_area = ${idx}")
|
conditions.append(f"d.practice_area = ${idx}")
|
||||||
params.append(practice_area)
|
params.append(practice_area)
|
||||||
@@ -6438,8 +6594,13 @@ async def refresh_verified_layer() -> dict:
|
|||||||
"""Recompute the verified/cite_count layer from chair citations (#153).
|
"""Recompute the verified/cite_count layer from chair citations (#153).
|
||||||
|
|
||||||
'verified' = the principle's SOURCE precedent was cited by a chair (any
|
'verified' = the principle's SOURCE precedent was cited by a chair (any
|
||||||
committee decision). 'cite_count' = # distinct chair decisions citing it. This
|
committee decision) WITHOUT a negative treatment. 'cite_count' = # distinct
|
||||||
is the ONLY trust signal — never human review. Idempotent (full recompute).
|
chair decisions citing it whose treatment is NOT negative (X11 §4 / INV-COR2:
|
||||||
|
a precedent *distinguished*/*criticized*/*questioned*/*overruled* must never
|
||||||
|
gain authority from those citations). Unclassified edges (treatment='') count
|
||||||
|
as neutral-positive until ``classify_citation_treatments.py`` labels them, so
|
||||||
|
the signal degrades gracefully before classification has run. This is the ONLY
|
||||||
|
trust signal — never human review. Idempotent (full recompute).
|
||||||
Returns {verified_principles, verified_precedents}.
|
Returns {verified_principles, verified_precedents}.
|
||||||
"""
|
"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
@@ -6456,6 +6617,8 @@ async def refresh_verified_layer() -> dict:
|
|||||||
" JOIN case_law src ON src.id = pic.source_case_law_id "
|
" JOIN case_law src ON src.id = pic.source_case_law_id "
|
||||||
" WHERE src.source_kind='internal_committee' "
|
" WHERE src.source_kind='internal_committee' "
|
||||||
" AND pic.cited_case_law_id IS NOT NULL "
|
" AND pic.cited_case_law_id IS NOT NULL "
|
||||||
|
" AND coalesce(pic.treatment,'') NOT IN "
|
||||||
|
" ('distinguished','criticized','questioned','overruled') "
|
||||||
" GROUP BY pic.cited_case_law_id) "
|
" GROUP BY pic.cited_case_law_id) "
|
||||||
"UPDATE halachot h SET verified=true, cite_count=cc.n, updated_at=now() "
|
"UPDATE halachot h SET verified=true, cite_count=cc.n, updated_at=now() "
|
||||||
"FROM cc WHERE h.case_law_id = cc.id")
|
"FROM cc WHERE h.case_law_id = cc.id")
|
||||||
@@ -6466,6 +6629,55 @@ async def refresh_verified_layer() -> dict:
|
|||||||
return {"verified_principles": row["vp"], "verified_precedents": row["vc"]}
|
return {"verified_principles": row["vp"], "verified_precedents": row["vc"]}
|
||||||
|
|
||||||
|
|
||||||
|
# X11 §4 treatment buckets (mirrors corroboration.TREATMENT_POSITIVE/NEGATIVE) —
|
||||||
|
# kept here so the SQL layer can label a breakdown without importing the service.
|
||||||
|
_TREATMENT_POSITIVE = ("followed", "explained")
|
||||||
|
_TREATMENT_NEGATIVE = ("distinguished", "criticized", "questioned", "overruled")
|
||||||
|
|
||||||
|
|
||||||
|
async def citation_authority(case_law_ids: list["UUID"]) -> dict[str, dict]:
|
||||||
|
"""Per-precedent incoming-citation breakdown by treatment (X11 Phase 2, #154).
|
||||||
|
|
||||||
|
For each precedent id → how many DISTINCT committee decisions cite it, split
|
||||||
|
into positive (followed/explained), negative (distinguished/criticized/
|
||||||
|
questioned/overruled) and unclassified (treatment not yet labelled). This is the
|
||||||
|
'cited_by N (X אומצו, Y אובחנו)' authority signal surfaced to research agents so
|
||||||
|
they can argue authority — and avoid leaning on a precedent that was repeatedly
|
||||||
|
distinguished/overruled. Counts distinct sources; a source with no treatment yet
|
||||||
|
falls in 'unclassified'. Returns {} for ids with no incoming committee citations.
|
||||||
|
"""
|
||||||
|
if not case_law_ids:
|
||||||
|
return {}
|
||||||
|
pool = await get_pool()
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT pic.cited_case_law_id::text AS id, "
|
||||||
|
" coalesce(NULLIF(pic.treatment, ''), 'unclassified') AS t, "
|
||||||
|
" count(DISTINCT pic.source_case_law_id) AS n "
|
||||||
|
"FROM precedent_internal_citations pic "
|
||||||
|
"JOIN case_law src ON src.id = pic.source_case_law_id "
|
||||||
|
"WHERE src.source_kind = 'internal_committee' "
|
||||||
|
" AND pic.cited_case_law_id = ANY($1::uuid[]) "
|
||||||
|
"GROUP BY 1, 2",
|
||||||
|
case_law_ids,
|
||||||
|
)
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
for r in rows:
|
||||||
|
d = out.setdefault(r["id"], {
|
||||||
|
"total": 0, "positive": 0, "negative": 0, "unclassified": 0,
|
||||||
|
"by_treatment": {},
|
||||||
|
})
|
||||||
|
t, n = r["t"], int(r["n"])
|
||||||
|
d["by_treatment"][t] = d["by_treatment"].get(t, 0) + n
|
||||||
|
d["total"] += n
|
||||||
|
if t in _TREATMENT_POSITIVE:
|
||||||
|
d["positive"] += n
|
||||||
|
elif t in _TREATMENT_NEGATIVE:
|
||||||
|
d["negative"] += n
|
||||||
|
else:
|
||||||
|
d["unclassified"] += n
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def list_canonical_instances(canonical_id: "UUID") -> list[dict]:
|
async def list_canonical_instances(canonical_id: "UUID") -> list[dict]:
|
||||||
"""List all halachot (instances) sharing a canonical_id — used by the UI accordion."""
|
"""List all halachot (instances) sharing a canonical_id — used by the UI accordion."""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
@@ -8030,7 +8242,33 @@ _MP_PROVENANCE_COLS = """,
|
|||||||
WHERE pic.cited_case_law_id = mp.linked_case_law_id
|
WHERE pic.cited_case_law_id = mp.linked_case_law_id
|
||||||
AND COALESCE(src.case_number, '') <> ''
|
AND COALESCE(src.case_number, '') <> ''
|
||||||
) AS cited_by_precedents,
|
) AS cited_by_precedents,
|
||||||
substring(mp.notes from 'מס''?\\s*([0-9]+)') AS yomon_number"""
|
substring(mp.notes from 'מס''?\\s*([0-9]+)') AS yomon_number,
|
||||||
|
-- Bridge to the corpus citation graph (G2: read-time, no stored
|
||||||
|
-- duplication). For an OPEN gap (no linked_case_law_id yet) find
|
||||||
|
-- which committee DECISIONS cite this ruling, matched on the
|
||||||
|
-- normalized docket number (same normalization the relinker uses:
|
||||||
|
-- strip to digits/dashes, slash->dash). Surfaces "צוטט ע\"י <יו\"ר>"
|
||||||
|
-- + the deciding case number in the UI.
|
||||||
|
(SELECT array_agg(DISTINCT src.chair_name ORDER BY src.chair_name)
|
||||||
|
FROM precedent_internal_citations picc
|
||||||
|
JOIN case_law src ON src.id = picc.source_case_law_id
|
||||||
|
AND src.source_kind = 'internal_committee'
|
||||||
|
WHERE split_part(mp.citation_norm, '|', 2) <> ''
|
||||||
|
AND regexp_replace(replace(picc.cited_case_number, '/', '-'),
|
||||||
|
'[^0-9-]', '', 'g')
|
||||||
|
= split_part(mp.citation_norm, '|', 2)
|
||||||
|
AND COALESCE(src.chair_name, '') <> ''
|
||||||
|
) AS cited_by_chairs,
|
||||||
|
(SELECT array_agg(DISTINCT src.case_number ORDER BY src.case_number)
|
||||||
|
FROM precedent_internal_citations picd
|
||||||
|
JOIN case_law src ON src.id = picd.source_case_law_id
|
||||||
|
AND src.source_kind = 'internal_committee'
|
||||||
|
WHERE split_part(mp.citation_norm, '|', 2) <> ''
|
||||||
|
AND regexp_replace(replace(picd.cited_case_number, '/', '-'),
|
||||||
|
'[^0-9-]', '', 'g')
|
||||||
|
= split_part(mp.citation_norm, '|', 2)
|
||||||
|
AND COALESCE(src.case_number, '') <> ''
|
||||||
|
) AS cited_by_decisions"""
|
||||||
|
|
||||||
|
|
||||||
async def list_missing_precedents(
|
async def list_missing_precedents(
|
||||||
|
|||||||
@@ -381,6 +381,119 @@ async def link_digest(digest_id: UUID | str, case_law_id: UUID | str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _radar_enrich(h: dict, score: float, matched_issues: list[str]) -> dict:
|
||||||
|
"""Shape one radar hit into a chair lead: gap status + suggested action +
|
||||||
|
which case ISSUE(s) it answers. The action points at the underlying RULING,
|
||||||
|
never the digest (INV-DIG1)."""
|
||||||
|
cit = (h.get("underlying_citation") or "").strip()
|
||||||
|
gap = await db.find_missing_precedent_by_citation(cit) if cit else None
|
||||||
|
in_corpus = await db.find_case_law_by_citation_fuzzy(cit) if cit else None
|
||||||
|
if in_corpus:
|
||||||
|
action = "available_link" # ruling actually IS in the corpus → just link the digest
|
||||||
|
elif gap and (gap.get("status") in ("uploaded", "closed")):
|
||||||
|
action = "fetched" # already obtained
|
||||||
|
elif gap:
|
||||||
|
action = "gap_open" # flagged as missing — can request a fetch
|
||||||
|
else:
|
||||||
|
action = "new_lead" # not even flagged yet — the highest-value alert
|
||||||
|
return {
|
||||||
|
"digest_id": str(h["id"]),
|
||||||
|
"yomon_number": h.get("yomon_number"),
|
||||||
|
"headline": h.get("headline_holding") or h.get("summary") or "",
|
||||||
|
"underlying_citation": cit,
|
||||||
|
"underlying_court": h.get("underlying_court") or "",
|
||||||
|
"score": round(score, 3),
|
||||||
|
"matched_issues": matched_issues,
|
||||||
|
"missing_precedent_id": str(gap["id"]) if gap else None,
|
||||||
|
"missing_precedent_status": gap.get("status") if gap else None,
|
||||||
|
"action": action,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def case_digest_radar(
|
||||||
|
case_number: str,
|
||||||
|
limit: int = 5,
|
||||||
|
min_score: float = 0.45,
|
||||||
|
) -> dict:
|
||||||
|
"""Case-contextual digest radar (X12) — the chair-facing "שים לב" lead.
|
||||||
|
|
||||||
|
Surfaces UNLINKED digests (``linked_case_law_id IS NULL`` — rulings we don't hold
|
||||||
|
yet) whose topic is semantically close to THIS case, so a relevant ruling we only
|
||||||
|
know about via a digest doesn't fall through the cracks while the case is decided.
|
||||||
|
|
||||||
|
Query calibration: prefer the analyst's DISTILLED legal arguments (one crisp issue
|
||||||
|
per row — ``argument_title`` + ``legal_topic``) over the dozens of raw claims. Each
|
||||||
|
issue is searched separately and the leads are MERGED, so every lead is attributed
|
||||||
|
to the case issue(s) it answers (``matched_issues``) — far higher precision than
|
||||||
|
one blended query of noisy claims. Falls back to raw claims pre-aggregation
|
||||||
|
(``source`` reports which path ran). Each lead carries the underlying ruling's gap
|
||||||
|
status + a suggested action.
|
||||||
|
|
||||||
|
INV-DIG1: this is RADAR — the digest is never cited; the lead points at the
|
||||||
|
underlying *ruling* (fetch / upload / link), never the digest itself. Read-only.
|
||||||
|
"""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
return {"status": "case_not_found", "case_number": case_number, "leads": [], "count": 0}
|
||||||
|
case_id = case["id"]
|
||||||
|
if isinstance(case_id, str):
|
||||||
|
case_id = UUID(case_id)
|
||||||
|
ctx = " ".join(x for x in [case.get("title") or "", case.get("appeal_subtype") or ""] if x).strip()
|
||||||
|
|
||||||
|
# Preferred source: the analyst's distilled CREAC issues (one per legal_argument).
|
||||||
|
issues: list[tuple[str, str]] = [] # (label, embed_text)
|
||||||
|
try:
|
||||||
|
from legal_mcp.services import argument_aggregator
|
||||||
|
for a in await argument_aggregator.get_legal_arguments(case_id):
|
||||||
|
label = (a.get("argument_title") or a.get("legal_topic") or "").strip()
|
||||||
|
topic = (a.get("legal_topic") or "").strip()
|
||||||
|
body = f"{label}. {topic}".strip(". ").strip()
|
||||||
|
if body:
|
||||||
|
issues.append((label, f"{ctx} {body}".strip()))
|
||||||
|
except Exception as e: # noqa: BLE001 — arguments are optional; fall back to claims
|
||||||
|
logger.warning("case_digest_radar: get_legal_arguments failed for %s: %s", case_number, e)
|
||||||
|
|
||||||
|
merged: dict[str, dict] = {} # digest_id -> {"hit", "best", "issues": set}
|
||||||
|
if issues:
|
||||||
|
source = "legal_arguments"
|
||||||
|
issues = issues[:25] # already distilled — bound the per-issue search fan-out
|
||||||
|
vecs = await embeddings.embed_texts([t for _, t in issues], input_type="query")
|
||||||
|
for (label, _), vec in zip(issues, vecs):
|
||||||
|
for h in await db.search_digests_semantic(vec, limit=6, linked_only=False):
|
||||||
|
s = float(h.get("score", 0) or 0)
|
||||||
|
if s < min_score:
|
||||||
|
continue
|
||||||
|
m = merged.setdefault(str(h["id"]), {"hit": h, "best": s, "issues": set()})
|
||||||
|
m["best"] = max(m["best"], s)
|
||||||
|
if label:
|
||||||
|
m["issues"].add(label)
|
||||||
|
else:
|
||||||
|
# Fallback: blended query from raw claims (pre-aggregation), one search.
|
||||||
|
source = "claims"
|
||||||
|
parts = [ctx]
|
||||||
|
try:
|
||||||
|
claims = await db.get_claims(case_id)
|
||||||
|
parts += [(c.get("claim_text") or "") for c in claims[:20]]
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("case_digest_radar: get_claims failed for %s: %s", case_number, e)
|
||||||
|
query = " ".join(p for p in parts if p).strip()
|
||||||
|
if not query:
|
||||||
|
return {"status": "no_topic", "case_number": case_number,
|
||||||
|
"source": source, "leads": [], "count": 0}
|
||||||
|
vec = (await embeddings.embed_texts([query], input_type="query"))[0]
|
||||||
|
for h in await db.search_digests_semantic(vec, limit=max(limit * 4, 20), linked_only=False):
|
||||||
|
s = float(h.get("score", 0) or 0)
|
||||||
|
if s < min_score:
|
||||||
|
continue
|
||||||
|
merged.setdefault(str(h["id"]), {"hit": h, "best": s, "issues": set()})
|
||||||
|
|
||||||
|
ordered = sorted(merged.values(), key=lambda m: -m["best"])[:max(1, limit)]
|
||||||
|
leads = [await _radar_enrich(m["hit"], m["best"], sorted(m["issues"])) for m in ordered]
|
||||||
|
return {"status": "ok", "case_number": case_number, "source": source,
|
||||||
|
"issues_used": [lbl for lbl, _ in issues] if source == "legal_arguments" else None,
|
||||||
|
"leads": leads, "count": len(leads)}
|
||||||
|
|
||||||
|
|
||||||
async def relink_digest(digest_id: UUID | str) -> dict:
|
async def relink_digest(digest_id: UUID | str) -> dict:
|
||||||
"""Re-run autolink for an unlinked digest. No-op if already linked / no match."""
|
"""Re-run autolink for an unlinked digest. No-op if already linked / no match."""
|
||||||
digest = await db.get_digest(digest_id)
|
digest = await db.get_digest(digest_id)
|
||||||
|
|||||||
@@ -233,6 +233,17 @@ async def ingest_document(
|
|||||||
await db.request_metadata_extraction(case_law_id)
|
await db.request_metadata_extraction(case_law_id)
|
||||||
await db.request_halacha_extraction(case_law_id)
|
await db.request_halacha_extraction(case_law_id)
|
||||||
await db.recompute_searchable(case_law_id)
|
await db.recompute_searchable(case_law_id)
|
||||||
|
# Citations resolve to a corpus row only at extraction time. A ruling
|
||||||
|
# uploaded AFTER it was first cited leaves orphan edges (NULL link),
|
||||||
|
# so re-link them to this freshly-ingested row now — the citation
|
||||||
|
# graph self-heals instead of permanently under-counting (G1). Non-fatal.
|
||||||
|
try:
|
||||||
|
from legal_mcp.services import citation_extractor as _ce
|
||||||
|
relinked = await _ce.relink_orphan_citations(case_law_id)
|
||||||
|
if relinked:
|
||||||
|
logger.info("relinked %d orphan citation(s) -> %s", relinked, case_law_id)
|
||||||
|
except Exception as e: # noqa: BLE001 — relink is best-effort
|
||||||
|
logger.warning("citation relink failed (non-fatal): %s", e)
|
||||||
|
|
||||||
await progress("completed", 100,
|
await progress("completed", 100,
|
||||||
f"נקלט: {stored_chunks} chunks. חילוץ הלכות ומטא-דאטה ממתינים בתור.")
|
f"נקלט: {stored_chunks} chunks. חילוץ הלכות ומטא-דאטה ממתינים בתור.")
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ logger = logging.getLogger(__name__)
|
|||||||
INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions"
|
INTERNAL_DECISIONS_DIR = Path(config.DATA_DIR) / "internal-decisions"
|
||||||
|
|
||||||
_VALID_PRACTICE_AREAS = frozenset({"", "rishuy_uvniya", "betterment_levy", "compensation_197"})
|
_VALID_PRACTICE_AREAS = frozenset({"", "rishuy_uvniya", "betterment_levy", "compensation_197"})
|
||||||
_VALID_DISTRICTS = frozenset({"", "ירושלים", "מרכז", "תל אביב", "צפון", "דרום", "ארצי"})
|
_VALID_DISTRICTS = frozenset({"", "ירושלים", "מרכז", "תל אביב", "חיפה", "צפון", "דרום", "ארצי"})
|
||||||
|
|
||||||
_COURT_TO_DISTRICT = [
|
_COURT_TO_DISTRICT = [
|
||||||
("ירושלים", "ירושלים"),
|
("ירושלים", "ירושלים"),
|
||||||
("תל אביב", "תל אביב"),
|
("תל אביב", "תל אביב"),
|
||||||
('ת"א', "תל אביב"),
|
('ת"א', "תל אביב"),
|
||||||
("מרכז", "מרכז"),
|
("מרכז", "מרכז"),
|
||||||
("חיפה", "צפון"),
|
("חיפה", "חיפה"),
|
||||||
("צפון", "צפון"),
|
("צפון", "צפון"),
|
||||||
("דרום", "דרום"),
|
("דרום", "דרום"),
|
||||||
("ארצי", "ארצי"),
|
("ארצי", "ארצי"),
|
||||||
@@ -77,7 +77,7 @@ async def _create_internal_record(**kw) -> dict:
|
|||||||
appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
|
appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
|
||||||
subject_tags=list(kw.get("subject_tags") or []),
|
subject_tags=list(kw.get("subject_tags") or []),
|
||||||
summary=(kw.get("summary") or "").strip(),
|
summary=(kw.get("summary") or "").strip(),
|
||||||
is_binding=kw.get("is_binding", True),
|
is_binding=kw.get("is_binding", False), # INV-DM7: committee = persuasive
|
||||||
document_id=kw.get("document_id"),
|
document_id=kw.get("document_id"),
|
||||||
proceeding_type=kw.get("proceeding_type") or "ערר",
|
proceeding_type=kw.get("proceeding_type") or "ערר",
|
||||||
)
|
)
|
||||||
@@ -108,7 +108,7 @@ async def ingest_internal_decision(
|
|||||||
appeal_subtype: str = "",
|
appeal_subtype: str = "",
|
||||||
subject_tags: list[str] | None = None,
|
subject_tags: list[str] | None = None,
|
||||||
summary: str = "",
|
summary: str = "",
|
||||||
is_binding: bool = True,
|
is_binding: bool = False, # INV-DM7: committee sources are persuasive
|
||||||
file_path: str | Path | None = None,
|
file_path: str | Path | None = None,
|
||||||
text: str | None = None,
|
text: str | None = None,
|
||||||
document_id: UUID | None = None,
|
document_id: UUID | None = None,
|
||||||
@@ -229,6 +229,7 @@ async def migrate_from_external_corpus(dry_run: bool = False) -> dict:
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""UPDATE case_law
|
"""UPDATE case_law
|
||||||
SET source_kind = 'internal_committee',
|
SET source_kind = 'internal_committee',
|
||||||
|
is_binding = FALSE, -- INV-DM7: committee = persuasive
|
||||||
district = CASE WHEN $2 <> '' THEN $2 ELSE district END
|
district = CASE WHEN $2 <> '' THEN $2 ELSE district END
|
||||||
WHERE id = $1""",
|
WHERE id = $1""",
|
||||||
row["id"], district,
|
row["id"], district,
|
||||||
|
|||||||
@@ -68,7 +68,11 @@ def _external_staging_subdir(inputs: dict) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def _create_external_record(**kw) -> dict:
|
async def _create_external_record(**kw) -> dict:
|
||||||
"""Adapter: maps canonical inputs (citation) to create_external_case_law(case_number)."""
|
"""Adapter: maps canonical inputs (citation) to create_external_case_law(case_number).
|
||||||
|
|
||||||
|
The chair's typed citation seeds ``citation_formatted`` so the מראה-מקום is never
|
||||||
|
blank before metadata extraction; the inline enrichment upgrades it to the
|
||||||
|
canonical form (see ``ingest_precedent``)."""
|
||||||
return await db.create_external_case_law(
|
return await db.create_external_case_law(
|
||||||
case_number=kw["citation"].strip(),
|
case_number=kw["citation"].strip(),
|
||||||
case_name=kw["case_name"],
|
case_name=kw["case_name"],
|
||||||
@@ -84,6 +88,7 @@ async def _create_external_record(**kw) -> dict:
|
|||||||
precedent_level=kw.get("precedent_level", ""),
|
precedent_level=kw.get("precedent_level", ""),
|
||||||
is_binding=kw.get("is_binding", True),
|
is_binding=kw.get("is_binding", True),
|
||||||
document_id=kw.get("document_id"),
|
document_id=kw.get("document_id"),
|
||||||
|
citation_formatted=kw["citation"].strip(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -126,10 +131,28 @@ async def ingest_precedent(
|
|||||||
"appeal_subtype": appeal_subtype, "subject_tags": subject_tags,
|
"appeal_subtype": appeal_subtype, "subject_tags": subject_tags,
|
||||||
"is_binding": is_binding, "headnote": headnote, "summary": summary,
|
"is_binding": is_binding, "headnote": headnote, "summary": summary,
|
||||||
}
|
}
|
||||||
return await ingest.ingest_document(
|
result = await ingest.ingest_document(
|
||||||
_EXTERNAL_SPEC, inputs=inputs, file_path=file_path,
|
_EXTERNAL_SPEC, inputs=inputs, file_path=file_path,
|
||||||
document_id=document_id, progress=progress,
|
document_id=document_id, progress=progress,
|
||||||
)
|
)
|
||||||
|
# Inline metadata enrichment (Gemini, in-container — gemini_session is direct
|
||||||
|
# REST, no local CLI). UPGRADES the seeded provisional citation_formatted to the
|
||||||
|
# canonical derived form (parties + reporter + date) so the מראה-מקום is correct
|
||||||
|
# the moment the chair opens /precedents/[id] — not deferred to the local drainer.
|
||||||
|
# force_citation=True overwrites the seed only; chair edits aren't reachable yet
|
||||||
|
# (row just created). Best-effort: on no key / API failure the seed remains and
|
||||||
|
# the queued metadata drain (request_metadata_extraction, already set) is the
|
||||||
|
# fallback. Halacha extraction is NOT touched here — it stays local (claude_session).
|
||||||
|
cid = result.get("case_law_id") if isinstance(result, dict) else None
|
||||||
|
if cid:
|
||||||
|
try:
|
||||||
|
from legal_mcp.services import precedent_metadata_extractor as _pme
|
||||||
|
r = await _pme.extract_and_apply(UUID(str(cid)), force_citation=True)
|
||||||
|
if r.get("status") == "completed":
|
||||||
|
await db.set_case_law_metadata_status(UUID(str(cid)), "completed")
|
||||||
|
except Exception as e: # noqa: BLE001 — enrichment is best-effort; drainer is fallback
|
||||||
|
logger.warning("inline metadata enrichment failed for %s (drainer will retry): %s", cid, e)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def reextract_halachot(
|
async def reextract_halachot(
|
||||||
@@ -411,6 +434,37 @@ async def get_precedent(case_law_id: UUID | str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
|
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
|
||||||
record["related_cases"] = await db.get_case_law_relations(case_law_id)
|
record["related_cases"] = await db.get_case_law_relations(case_law_id)
|
||||||
|
# Auto-detected citation graph: which decisions cite THIS ruling (incoming
|
||||||
|
# edges in precedent_internal_citations). Reuses the canonical query (G2) —
|
||||||
|
# no parallel resolution path. Shaped to match the front-end RelatedCase row
|
||||||
|
# so the existing "ציטוטים מקושרים" panel renders them with no visual change.
|
||||||
|
from legal_mcp.services import citation_extractor as _ce
|
||||||
|
raw = await _ce.list_citations_to_case_law(case_law_id)
|
||||||
|
record["incoming_citations"] = [
|
||||||
|
{
|
||||||
|
"id": r["source_case_law_id"],
|
||||||
|
"case_number": r.get("source_case_number") or r.get("cited_case_number") or "",
|
||||||
|
"case_name": r.get("source_case_name") or "",
|
||||||
|
"court": r.get("source_court") or "",
|
||||||
|
"precedent_level": r.get("source_precedent_level") or "",
|
||||||
|
"chair_name": r.get("source_chair_name") or "",
|
||||||
|
"date": (
|
||||||
|
r["source_date"].isoformat()
|
||||||
|
if r.get("source_date") is not None else None
|
||||||
|
),
|
||||||
|
"treatment": r.get("treatment") or "",
|
||||||
|
"confidence": r.get("confidence"),
|
||||||
|
}
|
||||||
|
for r in raw
|
||||||
|
]
|
||||||
|
# Authority signal (X11 Phase 2, #154): how the citing committee decisions
|
||||||
|
# TREATED this ruling (followed/distinguished/…) — surfaced so the chair (and
|
||||||
|
# research agents via the tool output) can argue authority and avoid leaning on
|
||||||
|
# a repeatedly-distinguished precedent.
|
||||||
|
record["cited_by"] = (await db.citation_authority([case_law_id])).get(
|
||||||
|
str(case_law_id),
|
||||||
|
{"total": 0, "positive": 0, "negative": 0, "unclassified": 0, "by_treatment": {}},
|
||||||
|
)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ async def apply_to_record(
|
|||||||
case_law_id: UUID | str,
|
case_law_id: UUID | str,
|
||||||
suggested: dict,
|
suggested: dict,
|
||||||
overwrite_case_number: bool = False,
|
overwrite_case_number: bool = False,
|
||||||
|
force_citation: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Merge suggested metadata into the case_law row, filling ONLY empty fields.
|
"""Merge suggested metadata into the case_law row, filling ONLY empty fields.
|
||||||
|
|
||||||
@@ -274,6 +275,13 @@ async def apply_to_record(
|
|||||||
|
|
||||||
overwrite_case_number: when True, update case_number from case_number_clean
|
overwrite_case_number: when True, update case_number from case_number_clean
|
||||||
even if the field already has a value (used for one-time migration enrichment).
|
even if the field already has a value (used for one-time migration enrichment).
|
||||||
|
|
||||||
|
force_citation: when True, (re)assemble citation_formatted even if the field
|
||||||
|
is non-empty — used by the at-upload inline enrichment to UPGRADE the seeded
|
||||||
|
provisional citation (the raw chair input) to the canonical derived form. The
|
||||||
|
write still happens only when the deterministic assembly SUCCEEDS (a missing
|
||||||
|
component → no write → the seed is preserved). The drainer keeps the default
|
||||||
|
(False) so a chair's manual edit in /precedents/[id] is never clobbered.
|
||||||
"""
|
"""
|
||||||
if isinstance(case_law_id, str):
|
if isinstance(case_law_id, str):
|
||||||
case_law_id = UUID(case_law_id)
|
case_law_id = UUID(case_law_id)
|
||||||
@@ -482,7 +490,7 @@ async def apply_to_record(
|
|||||||
# source_type/district/proceeding_type/parties). Only fill when empty so chair
|
# source_type/district/proceeding_type/parties). Only fill when empty so chair
|
||||||
# edits in /precedents/[id] are preserved; abstains (no write) when a component
|
# edits in /precedents/[id] are preserved; abstains (no write) when a component
|
||||||
# is missing.
|
# is missing.
|
||||||
if not (record.get("citation_formatted") or "").strip():
|
if force_citation or not (record.get("citation_formatted") or "").strip():
|
||||||
eff = {**record, **fields_to_update}
|
eff = {**record, **fields_to_update}
|
||||||
eff_parties = (
|
eff_parties = (
|
||||||
fields_to_update.get("parties") or record.get("parties") or ""
|
fields_to_update.get("parties") or record.get("parties") or ""
|
||||||
@@ -505,6 +513,7 @@ async def apply_to_record(
|
|||||||
async def extract_and_apply(
|
async def extract_and_apply(
|
||||||
case_law_id: UUID | str,
|
case_law_id: UUID | str,
|
||||||
overwrite_case_number: bool = False,
|
overwrite_case_number: bool = False,
|
||||||
|
force_citation: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Convenience wrapper: extract → merge into row → return summary."""
|
"""Convenience wrapper: extract → merge into row → return summary."""
|
||||||
suggested = await extract_metadata(case_law_id)
|
suggested = await extract_metadata(case_law_id)
|
||||||
@@ -523,7 +532,11 @@ async def extract_and_apply(
|
|||||||
"status": "extraction_failed" if has_text else "no_metadata",
|
"status": "extraction_failed" if has_text else "no_metadata",
|
||||||
"fields": [],
|
"fields": [],
|
||||||
}
|
}
|
||||||
result = await apply_to_record(case_law_id, suggested, overwrite_case_number=overwrite_case_number)
|
result = await apply_to_record(
|
||||||
|
case_law_id, suggested,
|
||||||
|
overwrite_case_number=overwrite_case_number,
|
||||||
|
force_citation=force_citation,
|
||||||
|
)
|
||||||
if result["updated"]:
|
if result["updated"]:
|
||||||
await db.recompute_searchable(case_law_id)
|
await db.recompute_searchable(case_law_id)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -170,3 +170,30 @@ async def digest_process_pending(limit: int = 20) -> str:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _err(str(e))
|
return _err(str(e))
|
||||||
return _ok(result)
|
return _ok(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def digest_radar(case_number: str, limit: int = 5, min_score: float = 0.45) -> str:
|
||||||
|
"""רדאר-יומונים הקשרי-לתיק (X12) — "שים לב" ליו"ר.
|
||||||
|
|
||||||
|
מחזיר יומונים **לא-מקושרים** (פס"ד שעוד אין לנו בקורפוס) שהנושא שלהם קרוב
|
||||||
|
סמנטית לתיק הזה — כדי שפס"ד רלוונטי שמוכר רק דרך יומון לא ייפול בין הכיסאות
|
||||||
|
בזמן הכרעת התיק. נושא-התיק נבנה מ-title + appeal_subtype + טענות-הצדדים, ומותאם
|
||||||
|
מול יומונים לא-מקושרים. כל ליד נושא את מראה-המקום של הפס"ד המקורי, סטטוס-הפער
|
||||||
|
(new_lead / gap_open / fetched / available_link) ופעולה מוצעת.
|
||||||
|
|
||||||
|
INV-DIG1: זהו radar — היומון לעולם אינו מצוטט; הליד מצביע על **הפס"ד** (להזמין
|
||||||
|
משיכה / להעלות / לקשר), לא על היומון. read-only.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: מספר התיק (למשל "8124-09-24").
|
||||||
|
limit: מספר לידים מקסימלי.
|
||||||
|
min_score: סף-דמיון סמנטי (0-1) לסינון רעש.
|
||||||
|
"""
|
||||||
|
if not case_number.strip():
|
||||||
|
return _err("case_number חובה")
|
||||||
|
try:
|
||||||
|
result = await digest_library.case_digest_radar(
|
||||||
|
case_number.strip(), limit=max(1, int(limit)), min_score=float(min_score))
|
||||||
|
except Exception as e:
|
||||||
|
return _err(str(e))
|
||||||
|
return _ok(result)
|
||||||
|
|||||||
@@ -300,6 +300,20 @@ async def search_precedent_library(
|
|||||||
limit=limit,
|
limit=limit,
|
||||||
include_halachot=include_halachot,
|
include_halachot=include_halachot,
|
||||||
)
|
)
|
||||||
|
# X11 Phase 2 (#154): attach the incoming-citation authority breakdown so the
|
||||||
|
# research agent can WEIGH and ARGUE authority ("הלכה שאומצה ב-N החלטות ועדת-ערר")
|
||||||
|
# — and steer clear of a precedent that committees repeatedly distinguished /
|
||||||
|
# overruled. Batched: one query for the whole result page.
|
||||||
|
try:
|
||||||
|
ids = {str(r.get("case_law_id")) for r in results if r.get("case_law_id")}
|
||||||
|
if ids:
|
||||||
|
auth = await db.citation_authority([UUID(i) for i in ids])
|
||||||
|
for r in results:
|
||||||
|
cb = auth.get(str(r.get("case_law_id")))
|
||||||
|
if cb:
|
||||||
|
r["cited_by"] = cb
|
||||||
|
except Exception: # noqa: BLE001 — authority is an additive signal; never break search
|
||||||
|
pass
|
||||||
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
elapsed_ms = int((time.perf_counter() - t0) * 1000)
|
||||||
telemetry.log_search_bg(
|
telemetry.log_search_bg(
|
||||||
search_type="precedent_library",
|
search_type="precedent_library",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים |
|
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים |
|
||||||
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — `--apply` מדלג על הסוכן; `--verify` מדווח אותו רם כ-DRIFT.** בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות. **`--verify` יוצא exit≠0 על כל drift** (needs-sync / adapter-mismatch / missing-in-mirror) — שמיש כ-gate ל-cron/CI (GAP-21/FU-8a). | ידני אחרי כל שינוי |
|
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — `--apply` מדלג על הסוכן; `--verify` מדווח אותו רם כ-DRIFT.** בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות. **`--verify` יוצא exit≠0 על כל drift** (needs-sync / adapter-mismatch / missing-in-mirror) — שמיש כ-gate ל-cron/CI (GAP-21/FU-8a). | ידני אחרי כל שינוי |
|
||||||
| `fix_paperclipai_skills_drift.py` | python | סקריפט חד-פעמי (בוצע 2026-05-04) שניקה drift על `paperclipai/*` skills בין CMP ל-CMPA. הסיר `paperclip-dev` מכל 14 הסוכנים, ודאג ש-`paperclip-converting-plans-to-tasks` קיים רק על CEO ו-analyst. תומך `--apply` (ברירת מחדל: dry-run). דורש `PAPERCLIP_BOARD_API_KEY`. נשמר לרפרנס למקרה שhdrift חוזר. | חד-פעמי (בוצע) |
|
| `fix_paperclipai_skills_drift.py` | python | סקריפט חד-פעמי (בוצע 2026-05-04) שניקה drift על `paperclipai/*` skills בין CMP ל-CMPA. הסיר `paperclip-dev` מכל 14 הסוכנים, ודאג ש-`paperclip-converting-plans-to-tasks` קיים רק על CEO ו-analyst. תומך `--apply` (ברירת מחדל: dry-run). דורש `PAPERCLIP_BOARD_API_KEY`. נשמר לרפרנס למקרה שhdrift חוזר. | חד-פעמי (בוצע) |
|
||||||
|
| `classify_citation_treatments.py` | python | **סיווג-טיפול לקצוות-ציטוט (X11 Phase 2, #154)** — לכל קצה ב-`precedent_internal_citations` (החלטת-ועדה מצטטת תקדים) מסווג את ה-`treatment` מתוך ה-`match_context` דרך `corroboration.classify_treatment` (Opus 4.8 @ xhigh, claude_session **מקומי** — לא בקונטיינר): followed/explained=חיובי, distinguished/criticized/questioned/overruled=שלילי. ממלא `precedent_internal_citations.treatment` כך ש-`refresh_verified_layer` לא יספור ציטוט שלילי כסמכות (INV-COR2) ו-`db.citation_authority` יציג פירוק לסוכנים. אידמפוטנטי (מדלג על מסווגים). `--apply`/`--limit N`/`--case-law-id UUID`. **אחרי `--apply` הרץ `build_verified_layer.py`.** דורש `HOME=/home/chaim`. | ידני / אחרי גלי-ציטוט חדשים |
|
||||||
| `adapter_profiles.py` | python (module) | **רישום-פרופילי-אדפטר** — מקור-אמת יחיד ל-3 צירי-הכשל של מעבר-אדפטר: provider/default_model, instructions_mode (`file_path` בטוח-frontmatter מול `content_arg` ששובר `---`), ו-tool_config (`gemini_global` excludeTools / `frontmatter` / `hermes` / `codex_home`). כולל `codex_local` עם משפחת מודלי OpenAI/Codex (`gpt-*`, `o3*`, `o4*`, `codex-*`). מיובא ע"י `migrate_agent_adapter.py`. הוספת אדפטר עתידי = רשומה אחת. לא מורץ ישירות. | תשתית |
|
| `adapter_profiles.py` | python (module) | **רישום-פרופילי-אדפטר** — מקור-אמת יחיד ל-3 צירי-הכשל של מעבר-אדפטר: provider/default_model, instructions_mode (`file_path` בטוח-frontmatter מול `content_arg` ששובר `---`), ו-tool_config (`gemini_global` excludeTools / `frontmatter` / `hermes` / `codex_home`). כולל `codex_local` עם משפחת מודלי OpenAI/Codex (`gpt-*`, `o3*`, `o4*`, `codex-*`). מיובא ע"י `migrate_agent_adapter.py`. הוספת אדפטר עתידי = רשומה אחת. לא מורץ ישירות. | תשתית |
|
||||||
| `migrate_agent_adapter.py` | python | **מעבר-אדפטר בטוח לכל סוכן ← כל אדפטר, בשתי החברות יחד (INV-MC1)**. מיישב model↔provider, גורס frontmatter לעותק `.generated/<name>.nofm.md` ל-content_arg adapters (אחרת קריסת `gemini --prompt`/`hermes -q` על `---`), ומשחרר excludeTools גלובלי של gemini (`--relax-tools`). `--check` (preflight בלבד, exit≠0 על שגיאה — שער FU-8a) / `--apply` / `--revert` (שחזור מדויק מ-sidecar `data/adapter-migration-state.json`) / `--verify` (מסמן מצב לא-תואם/א-סימטרי, exit≠0). `--agent "<שם>"\|all --to <adapter> [--model X] [--relax-tools]`. PATCH דרך `/api/agents/{id}` (לא DB). דורש `PAPERCLIP_BOARD_API_KEY`. הרץ עם `mcp-server/.venv/bin/python`. **fallback-חירום כשנגמרים טוקני-Claude; החזר ל-claude_local כשחוזרים.** | ידני לפי צורך |
|
| `migrate_agent_adapter.py` | python | **מעבר-אדפטר בטוח לכל סוכן ← כל אדפטר, בשתי החברות יחד (INV-MC1)**. מיישב model↔provider, גורס frontmatter לעותק `.generated/<name>.nofm.md` ל-content_arg adapters (אחרת קריסת `gemini --prompt`/`hermes -q` על `---`), ומשחרר excludeTools גלובלי של gemini (`--relax-tools`). `--check` (preflight בלבד, exit≠0 על שגיאה — שער FU-8a) / `--apply` / `--revert` (שחזור מדויק מ-sidecar `data/adapter-migration-state.json`) / `--verify` (מסמן מצב לא-תואם/א-סימטרי, exit≠0). `--agent "<שם>"\|all --to <adapter> [--model X] [--relax-tools]`. PATCH דרך `/api/agents/{id}` (לא DB). דורש `PAPERCLIP_BOARD_API_KEY`. הרץ עם `mcp-server/.venv/bin/python`. **fallback-חירום כשנגמרים טוקני-Claude; החזר ל-claude_local כשחוזרים.** | ידני לפי צורך |
|
||||||
|
|
||||||
|
|||||||
99
scripts/classify_citation_treatments.py
Normal file
99
scripts/classify_citation_treatments.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""Classify the TREATMENT of each internal citation edge (X11 Phase 2, #154).
|
||||||
|
|
||||||
|
Each row in ``precedent_internal_citations`` records that a committee decision
|
||||||
|
cited a precedent, with the surrounding ``match_context``. Until now the edge's
|
||||||
|
``treatment`` column was empty, so the verified/authority layer counted every
|
||||||
|
citation as if it were positive — a precedent *distinguished* N times got the
|
||||||
|
same authority boost as one *followed* N times (an INV-COR2 violation).
|
||||||
|
|
||||||
|
This script fills ``treatment`` per edge by classifying the context with
|
||||||
|
``corroboration.classify_treatment`` (Opus 4.8 @ xhigh via the local
|
||||||
|
claude_session bridge — LOCAL ONLY, the claude CLI is not in the container):
|
||||||
|
|
||||||
|
followed | explained → positive (counts toward authority)
|
||||||
|
distinguished | criticized |
|
||||||
|
questioned | overruled → negative (never counts; overruled = demote)
|
||||||
|
|
||||||
|
Scope: only LINKED edges (``cited_case_law_id IS NOT NULL``) with an empty
|
||||||
|
``treatment`` and a non-empty ``match_context``. Idempotent — a second run skips
|
||||||
|
rows already classified. After applying, run ``scripts/build_verified_layer.py``
|
||||||
|
(or ``db.refresh_verified_layer``) so the treatment-aware count takes effect.
|
||||||
|
|
||||||
|
Run (dry-run, default — classifies and PRINTS, writes nothing):
|
||||||
|
HOME=/home/chaim mcp-server/.venv/bin/python scripts/classify_citation_treatments.py
|
||||||
|
Apply:
|
||||||
|
HOME=/home/chaim mcp-server/.venv/bin/python scripts/classify_citation_treatments.py --apply
|
||||||
|
Options:
|
||||||
|
--limit N classify at most N edges (smoke test)
|
||||||
|
--case-law-id UUID restrict to citations TO this one precedent
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||||
|
|
||||||
|
from legal_mcp.services import corroboration, db # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
async def _pending(limit: int | None, case_law_id: str | None) -> list[dict]:
|
||||||
|
pool = await db.get_pool()
|
||||||
|
where = ["cited_case_law_id IS NOT NULL", "coalesce(treatment,'') = ''",
|
||||||
|
"coalesce(match_context,'') <> ''"]
|
||||||
|
params: list = []
|
||||||
|
if case_law_id:
|
||||||
|
params.append(UUID(case_law_id))
|
||||||
|
where.append(f"cited_case_law_id = ${len(params)}")
|
||||||
|
sql = (f"SELECT id, cited_case_number, match_context "
|
||||||
|
f"FROM precedent_internal_citations WHERE {' AND '.join(where)} "
|
||||||
|
f"ORDER BY created_at")
|
||||||
|
if limit:
|
||||||
|
sql += f" LIMIT {int(limit)}"
|
||||||
|
rows = await pool.fetch(sql, *params)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--apply", action="store_true", help="write changes (default: dry-run)")
|
||||||
|
ap.add_argument("--limit", type=int, default=None)
|
||||||
|
ap.add_argument("--case-law-id", type=str, default=None)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
rows = await _pending(args.limit, args.case_law_id)
|
||||||
|
print(f"קצוות לא-מסווגים לעיבוד: {len(rows)}\n")
|
||||||
|
|
||||||
|
pool = await db.get_pool()
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
errors = 0
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
t = await corroboration.classify_treatment(
|
||||||
|
r["cited_case_number"] or "", r["match_context"] or "")
|
||||||
|
except Exception as e: # noqa: BLE001 — one bad row must not abort the batch
|
||||||
|
errors += 1
|
||||||
|
print(f" ✗ [error] {r['cited_case_number']}: {type(e).__name__}: {e}")
|
||||||
|
continue
|
||||||
|
counts[t] = counts.get(t, 0) + 1
|
||||||
|
sign = "+" if corroboration.is_positive(t) else ("-" if corroboration.is_negative(t) else "·")
|
||||||
|
print(f" {sign} {t:<14} {r['cited_case_number']}")
|
||||||
|
if args.apply:
|
||||||
|
await pool.execute(
|
||||||
|
"UPDATE precedent_internal_citations SET treatment = $2 WHERE id = $1",
|
||||||
|
r["id"], t,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\nסיכום טיפול: {counts} שגיאות={errors}"
|
||||||
|
+ ("" if args.apply else " (dry-run — לא נכתב)"))
|
||||||
|
if args.apply:
|
||||||
|
print("הרץ עכשיו: scripts/build_verified_layer.py (או db.refresh_verified_layer) "
|
||||||
|
"כדי שהספירה מודעת-הטיפול תיכנס לתוקף.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { SubsectionCard } from "@/components/compose/subsection-card";
|
import { SubsectionCard } from "@/components/compose/subsection-card";
|
||||||
import { PrecedentsSection } from "@/components/compose/precedents-section";
|
import { PrecedentsSection } from "@/components/compose/precedents-section";
|
||||||
|
import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
|
||||||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||||
import { Markdown } from "@/components/ui/markdown";
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -236,6 +237,12 @@ export default function ComposePage({
|
|||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
<span className="text-navy">עורך החלטה</span>
|
<span className="text-navy">עורך החלטה</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
<Link
|
||||||
|
href={`/cases/${caseNumber}`}
|
||||||
|
className="inline-flex items-center gap-1.5 mb-3 rounded-md border border-rule bg-surface px-3 py-1.5 text-[0.82rem] font-semibold text-gold-deep hover:bg-gold-wash hover:text-navy transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden>→</span> חזרה לדף התיק
|
||||||
|
</Link>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<h1 className="text-navy text-2xl font-bold mb-0">ערר {caseNumber}</h1>
|
<h1 className="text-navy text-2xl font-bold mb-0">ערר {caseNumber}</h1>
|
||||||
<StatusChip status={caseQuery.data?.status} />
|
<StatusChip status={caseQuery.data?.status} />
|
||||||
@@ -270,6 +277,7 @@ export default function ComposePage({
|
|||||||
<TabsList className="bg-rule-soft/60">
|
<TabsList className="bg-rule-soft/60">
|
||||||
<TabsTrigger value="blocks">עורך הבלוקים</TabsTrigger>
|
<TabsTrigger value="blocks">עורך הבלוקים</TabsTrigger>
|
||||||
<TabsTrigger value="positions">עמדות וטענות</TabsTrigger>
|
<TabsTrigger value="positions">עמדות וטענות</TabsTrigger>
|
||||||
|
<TabsTrigger value="verify">אימות פסיקה</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Tab 1 — the 12-block decision editor (reused DecisionBlocksPanel) */}
|
{/* Tab 1 — the 12-block decision editor (reused DecisionBlocksPanel) */}
|
||||||
@@ -277,6 +285,11 @@ export default function ComposePage({
|
|||||||
<DecisionBlocksPanel caseNumber={caseNumber} />
|
<DecisionBlocksPanel caseNumber={caseNumber} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Tab 3 — citation verification: per-argument support + verify gate (#154) */}
|
||||||
|
<TabsContent value="verify" className="mt-5">
|
||||||
|
<CitationVerificationPanel caseNumber={caseNumber} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* Tab 2 — chair positions on the analyst's threshold-claims + issues */}
|
{/* Tab 2 — chair positions on the analyst's threshold-claims + issues */}
|
||||||
<TabsContent value="positions" className="mt-5">
|
<TabsContent value="positions" className="mt-5">
|
||||||
{analysis.isPending ? (
|
{analysis.isPending ? (
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ export default function CaseDetailPage({
|
|||||||
<>
|
<>
|
||||||
{data && <CaseEditDialog data={data} />}
|
{data && <CaseEditDialog data={data} />}
|
||||||
<UploadSheet caseNumber={caseNumber} />
|
<UploadSheet caseNumber={caseNumber} />
|
||||||
|
<Button asChild className="bg-gold text-white hover:bg-gold-deep border-transparent">
|
||||||
|
<Link href={`/cases/${caseNumber}/compose`}>פתח עורך החלטה</Link>
|
||||||
|
</Button>
|
||||||
{canStartWorkflow && (
|
{canStartWorkflow && (
|
||||||
<Button
|
<Button
|
||||||
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
|
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
|
||||||
@@ -135,16 +138,7 @@ export default function CaseDetailPage({
|
|||||||
<DocumentsPanel data={data} />
|
<DocumentsPanel data={data} />
|
||||||
|
|
||||||
<AgentActivityPreview caseNumber={caseNumber} />
|
<AgentActivityPreview caseNumber={caseNumber} />
|
||||||
|
{/* decision-editor CTA moved to the band actions (visible on all tabs) */}
|
||||||
{/* gold CTA — open the decision editor */}
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
className="w-full bg-gold text-white hover:bg-gold-deep border-transparent py-6 text-base font-semibold"
|
|
||||||
>
|
|
||||||
<Link href={`/cases/${caseNumber}/compose`}>
|
|
||||||
פתח עורך החלטה →
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="arguments" className="mt-0">
|
<TabsContent value="arguments" className="mt-0">
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ import { MissingPrecedentsTable } from "@/components/missing-precedents/missing-
|
|||||||
|
|
||||||
type StatusFilter = MissingPrecedentStatus | "all";
|
type StatusFilter = MissingPrecedentStatus | "all";
|
||||||
|
|
||||||
|
// Only the two states the chair acts on: open gaps to fill, and closed gaps for
|
||||||
|
// reference. "הועלה" (transient) and "לא-רלוונטי" were dropped from the filter,
|
||||||
|
// and "הכל" with them (chair's request, design 09).
|
||||||
const STATUS_CHIPS: { value: StatusFilter; label: string }[] = [
|
const STATUS_CHIPS: { value: StatusFilter; label: string }[] = [
|
||||||
{ value: "open", label: "פתוח" },
|
{ value: "open", label: "פתוח" },
|
||||||
{ value: "uploaded", label: "הועלה" },
|
|
||||||
{ value: "closed", label: "נסגר" },
|
{ value: "closed", label: "נסגר" },
|
||||||
{ value: "irrelevant", label: "לא-רלוונטי" },
|
|
||||||
{ value: "all", label: "הכל" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function MissingPrecedentsPage() {
|
export default function MissingPrecedentsPage() {
|
||||||
@@ -145,15 +145,13 @@ export default function MissingPrecedentsPage() {
|
|||||||
<div className="rounded-lg border border-rule bg-parchment px-5 py-3.5 text-[0.82rem] text-ink-muted leading-7">
|
<div className="rounded-lg border border-rule bg-parchment px-5 py-3.5 text-[0.82rem] text-ink-muted leading-7">
|
||||||
<b className="text-ink-soft">מחזור-חיים:</b>{" "}
|
<b className="text-ink-soft">מחזור-חיים:</b>{" "}
|
||||||
<LifecycleChip tone="open">פתוח</LifecycleChip> →{" "}
|
<LifecycleChip tone="open">פתוח</LifecycleChip> →{" "}
|
||||||
<LifecycleChip tone="up">הועלה</LifecycleChip> →{" "}
|
|
||||||
<LifecycleChip tone="closed">נסגר</LifecycleChip>. פריט נפתח אוטומטית
|
<LifecycleChip tone="closed">נסגר</LifecycleChip>. פריט נפתח אוטומטית
|
||||||
בעת חילוץ ציטוט שאין לו תקדים בקורפוס; בהעלאת פסק-הדין הוא מקושר לרשומת
|
בעת חילוץ ציטוט שאין לו תקדים בקורפוס; בהעלאת פסק-הדין הוא מקושר לרשומת
|
||||||
הפסיקה דרך{" "}
|
הפסיקה דרך{" "}
|
||||||
<code className="rounded border border-rule bg-surface px-1.5 py-0.5 text-[0.75rem] text-gold-deep" dir="ltr">
|
<code className="rounded border border-rule bg-surface px-1.5 py-0.5 text-[0.75rem] text-gold-deep" dir="ltr">
|
||||||
linked_case_law_id
|
linked_case_law_id
|
||||||
</code>{" "}
|
</code>{" "}
|
||||||
ונסגר. פריט שאינו רלוונטי מסומן{" "}
|
ונסגר.
|
||||||
<LifecycleChip tone="na">לא-רלוונטי</LifecycleChip> מבלי שתידרש העלאה.
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -673,11 +672,11 @@ function RunLogDialog({ run, onClose }: { run: AgentRun | null; onClose: () => v
|
|||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="text-sm text-destructive">שגיאה בטעינת הלוג: {String(error)}</p>
|
<p className="text-sm text-destructive">שגיאה בטעינת הלוג: {String(error)}</p>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-[60vh] rounded-md border border-rule-soft bg-rule-soft/20 p-3">
|
<div className="h-[60vh] w-full overflow-y-auto overflow-x-hidden rounded-md border border-rule-soft bg-rule-soft/20 p-3">
|
||||||
<pre dir="ltr" className="text-[0.72rem] leading-relaxed whitespace-pre-wrap break-words text-navy text-start">
|
<pre dir="ltr" className="w-full min-w-0 max-w-full overflow-x-hidden text-[0.72rem] leading-relaxed whitespace-pre-wrap break-all text-navy text-start">
|
||||||
{text || "אין פלט עדיין."}
|
{text || "אין פלט עדיין."}
|
||||||
</pre>
|
</pre>
|
||||||
</ScrollArea>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -69,8 +69,10 @@ export default function HomePage() {
|
|||||||
{/* KPI row — mockup 04 .kpis (4-up, gold-washed "ממתינים לאישור") */}
|
{/* KPI row — mockup 04 .kpis (4-up, gold-washed "ממתינים לאישור") */}
|
||||||
<KPICards cases={data} loading={isPending} />
|
<KPICards cases={data} loading={isPending} />
|
||||||
|
|
||||||
{/* two-column body — main flow + narrow gold gate rail (mockup 04 .cols) */}
|
{/* two-column body — main flow + narrow gold gate rail (mockup 04 .cols).
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
Rail trimmed 360→280 so the cases table gets the width it needs and
|
||||||
|
no longer needs a horizontal scrollbar (chair request). */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
|
||||||
<div className="space-y-6 min-w-0">
|
<div className="space-y-6 min-w-0">
|
||||||
{/* "תיקים לפי סטטוס" (פסים אופקיים) הוסר — פיזור-הסטטוסים מוצג
|
{/* "תיקים לפי סטטוס" (פסים אופקיים) הוסר — פיזור-הסטטוסים מוצג
|
||||||
בדונאט "פיזור סטטוסים" בטור-הצד (#1). */}
|
בדונאט "פיזור סטטוסים" בטור-הצד (#1). */}
|
||||||
|
|||||||
@@ -236,7 +236,11 @@ export default function PrecedentDetailPage({
|
|||||||
|
|
||||||
{/* side rail — citations + corroboration */}
|
{/* side rail — citations + corroboration */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<RelatedCasesSection caseId={id} related={data.related_cases ?? []} />
|
<RelatedCasesSection
|
||||||
|
caseId={id}
|
||||||
|
related={data.related_cases ?? []}
|
||||||
|
incoming={data.incoming_citations ?? []}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,8 +63,10 @@ export function CaseHeader({
|
|||||||
<span className="text-navy tabular-nums">{data?.case_number ?? "…"}</span>
|
<span className="text-navy tabular-nums">{data?.case_number ?? "…"}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-6 flex-wrap">
|
{/* title block + metadata on one row — no wrap, so the metadata dl stays
|
||||||
<div className="min-w-0">
|
parallel to the H1 and a long parties line can't push it below. */}
|
||||||
|
<div className="flex items-start justify-between gap-6">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
{/* title row — H1 + status/type/blam chips inline (mockup .band h1) */}
|
{/* title row — H1 + status/type/blam chips inline (mockup .band h1) */}
|
||||||
<h1 className="text-navy text-[1.7rem] font-bold leading-tight flex items-center gap-3 flex-wrap mb-0">
|
<h1 className="text-navy text-[1.7rem] font-bold leading-tight flex items-center gap-3 flex-wrap mb-0">
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
@@ -107,11 +109,15 @@ export function CaseHeader({
|
|||||||
{data.title}
|
{data.title}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{/* parties line (mockup .parties) */}
|
{/* parties line (mockup .parties) — clamped to 2 lines so a long list
|
||||||
|
of appellants/respondents doesn't grow the band; full text on hover. */}
|
||||||
{parties ? (
|
{parties ? (
|
||||||
<p className="text-ink-soft text-sm mt-1.5">{parties}</p>
|
<p className="text-ink-soft text-sm mt-1.5 max-w-3xl line-clamp-2" title={parties}>
|
||||||
|
{parties}
|
||||||
|
</p>
|
||||||
) : data?.subject ? (
|
) : data?.subject ? (
|
||||||
<p className="text-ink-soft text-sm mt-1.5 max-w-3xl leading-relaxed">
|
<p className="text-ink-soft text-sm mt-1.5 max-w-3xl leading-relaxed line-clamp-2"
|
||||||
|
title={data.subject}>
|
||||||
{data.subject}
|
{data.subject}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const columns: ColumnDef<Case>[] = [
|
|||||||
accessorKey: "title",
|
accessorKey: "title",
|
||||||
header: "כותרת",
|
header: "כותרת",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="text-ink max-w-[420px] truncate flex items-center gap-2" title={row.original.title}>
|
<div className="text-ink max-w-[420px] min-w-0 truncate flex items-center gap-2" title={row.original.title}>
|
||||||
{(row.original.proceeding_type === 'בל"מ' || isBlamSubtype(row.original.appeal_subtype)) && (
|
{(row.original.proceeding_type === 'בל"מ' || isBlamSubtype(row.original.appeal_subtype)) && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -190,12 +190,27 @@ export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) {
|
|||||||
|
|
||||||
const handleAggregate = (force: boolean) => {
|
const handleAggregate = (force: boolean) => {
|
||||||
aggregate.mutate(force, {
|
aggregate.mutate(force, {
|
||||||
onSuccess: () => {
|
onSuccess: (result) => {
|
||||||
toast.success(
|
switch (result.status) {
|
||||||
force
|
case "queued":
|
||||||
? "הופעלה חזרה חישוב טיעונים (force). יסתיים תוך דקה."
|
toast.success(
|
||||||
: "הופעל חישוב טיעונים. רענן בעוד דקה.",
|
force
|
||||||
);
|
? "נשלח לאנליטיקאי לחישוב מחדש. התוצאה תופיע תוך כמה דקות — רענן את הדף."
|
||||||
|
: "נשלח לאנליטיקאי. החישוב רץ ברקע; התוצאה תופיע תוך כמה דקות — רענן את הדף.",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "no_claims":
|
||||||
|
case "exists":
|
||||||
|
toast.info(result.message);
|
||||||
|
break;
|
||||||
|
case "skipped":
|
||||||
|
toast.warning(
|
||||||
|
`לא ניתן להפעיל אוטומטית (${result.reason}). הרץ ידנית מ-Claude Code: mcp__legal-ai__aggregate_claims_to_arguments`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
toast.success("הבקשה נשלחה.");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`),
|
onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`),
|
||||||
});
|
});
|
||||||
|
|||||||
261
web-ui/src/components/compose/citation-verification-panel.tsx
Normal file
261
web-ui/src/components/compose/citation-verification-panel.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "אימות פסיקה" tab (X11 Phase 2 / #154) — per legal argument, the in-corpus
|
||||||
|
* supporting precedents with the cumulative authority signal (cited_by), a verify
|
||||||
|
* gate + chair note, and per-issue radar (unlinked digests). The writer cites only
|
||||||
|
* verified quotes (INV-AH); the digest is never cited (INV-DIG1, radar only).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
useCitationVerification,
|
||||||
|
useVerifyCitation,
|
||||||
|
type ArgumentBlock,
|
||||||
|
type SupportingPrecedent,
|
||||||
|
type RadarLead,
|
||||||
|
} from "@/lib/api/citation-verification";
|
||||||
|
|
||||||
|
const PRIORITY_LABEL: Record<string, string> = {
|
||||||
|
threshold: "סף",
|
||||||
|
substantive: "מהותית",
|
||||||
|
procedural: "פרוצדורלית",
|
||||||
|
relief: "סעד",
|
||||||
|
};
|
||||||
|
|
||||||
|
const RADAR_LABEL: Record<string, string> = {
|
||||||
|
new_lead: "ליד חדש",
|
||||||
|
gap_open: "בתור-חסרים",
|
||||||
|
fetched: "נמשך",
|
||||||
|
available_link: "בקורפוס — לקשר",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CitationVerificationPanel({ caseNumber }: { caseNumber: string }) {
|
||||||
|
const { data, isPending, isError } = useCitationVerification(caseNumber);
|
||||||
|
const verify = useVerifyCitation(caseNumber);
|
||||||
|
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5 space-y-3">
|
||||||
|
<Skeleton className="h-6 w-64" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isError || !data || data.status !== "ok") {
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-8 text-center text-ink-muted">
|
||||||
|
לא ניתן לטעון את אימות-הפסיקה כעת.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data.arguments.length) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-12 text-center space-y-2">
|
||||||
|
<div className="text-gold text-3xl" aria-hidden>❦</div>
|
||||||
|
<p className="text-navy font-semibold mb-0">אין עדיין סוגיות מזוקקות לתיק</p>
|
||||||
|
<p className="text-ink-muted text-sm">
|
||||||
|
הרץ את ניתוח-הטענות (aggregate) כדי שהמערכת תזהה סוגיות ותתאים להן פסיקה.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runVerify(a: ArgumentBlock, s: SupportingPrecedent, verified: boolean) {
|
||||||
|
verify.mutate(
|
||||||
|
{
|
||||||
|
argument_id: a.argument_id,
|
||||||
|
case_law_id: s.case_law_id,
|
||||||
|
quote: s.quote,
|
||||||
|
citation: s.case_number || s.case_name,
|
||||||
|
chair_note: notes[s.case_law_id] ?? s.chair_note,
|
||||||
|
verified,
|
||||||
|
attached_id: s.attached_id ?? "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.success(verified ? "הציטוט אומת" : "סומן כלא-רלוונטי"),
|
||||||
|
onError: (e: Error) => toast.error(`שגיאה: ${e.message}`),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveNote(a: ArgumentBlock, s: SupportingPrecedent) {
|
||||||
|
const note = notes[s.case_law_id];
|
||||||
|
if (note === undefined || note === s.chair_note) return;
|
||||||
|
verify.mutate(
|
||||||
|
{
|
||||||
|
argument_id: a.argument_id,
|
||||||
|
case_law_id: s.case_law_id,
|
||||||
|
quote: s.quote,
|
||||||
|
citation: s.case_number || s.case_name,
|
||||||
|
chair_note: note,
|
||||||
|
verified: s.verified,
|
||||||
|
attached_id: s.attached_id ?? "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.success("הערת-יו״ר נשמרה"),
|
||||||
|
onError: (e: Error) => toast.error(`שגיאה: ${e.message}`),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sum = data.summary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* summary */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-ink-soft bg-parchment border border-rule rounded-lg px-4 py-2.5">
|
||||||
|
<span>סוגיות עם פסיקה: <b className="text-navy tabular-nums">{sum.arguments_with_support}/{sum.arguments_total}</b></span>
|
||||||
|
<span>אומתו: <b className="text-success tabular-nums">{sum.verified}</b></span>
|
||||||
|
<span>לידֵי רדאר: <b className="text-warn tabular-nums">{sum.radar_leads}</b></span>
|
||||||
|
<span className="text-ink-muted text-[0.78rem] ms-auto">ה-writer מצטט רק מאומתים (INV-AH) · היומון אינו מצוטט (INV-DIG1)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.arguments.map((a) => (
|
||||||
|
<Card key={a.argument_id} className="bg-surface border-rule shadow-sm overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* issue header */}
|
||||||
|
<div className="flex items-start gap-3 px-5 py-3.5 border-b border-rule-soft bg-parchment/60">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-navy font-bold text-[0.98rem]">{a.title}</div>
|
||||||
|
<div className="text-ink-muted text-xs mt-0.5">
|
||||||
|
סוגיה{a.legal_topic ? ` · ${a.legal_topic}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{a.priority && (
|
||||||
|
<span className="shrink-0 rounded-full bg-gold-wash text-gold-deep text-[0.68rem] font-semibold px-2.5 py-0.5">
|
||||||
|
{PRIORITY_LABEL[a.priority] ?? a.priority}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* supporting precedents */}
|
||||||
|
<div className="px-5 py-3">
|
||||||
|
<div className="text-[0.72rem] font-bold text-ink-muted mb-1">פסיקה תומכת בקורפוס</div>
|
||||||
|
{a.supporting.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm py-2">לא נמצאה פסיקה תומכת בקורפוס לסוגיה זו.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="list-none p-0 m-0 divide-y divide-rule-soft">
|
||||||
|
{a.supporting.map((s) => (
|
||||||
|
<li key={s.case_law_id} className="py-3 first:pt-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<a
|
||||||
|
href={`/precedents/${s.case_law_id}`}
|
||||||
|
className="font-bold text-gold-deep text-[0.86rem] hover:text-navy"
|
||||||
|
>
|
||||||
|
{s.case_number || s.case_name}
|
||||||
|
</a>
|
||||||
|
{s.cited_by.positive > 0 && (
|
||||||
|
<span className="rounded-full bg-success-bg text-success text-[0.68rem] font-semibold px-2 py-0.5">
|
||||||
|
אומץ ×{s.cited_by.positive}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.cited_by.negative > 0 && (
|
||||||
|
<span className="rounded-full bg-danger-bg text-danger text-[0.68rem] font-semibold px-2 py-0.5">
|
||||||
|
אובחן ×{s.cited_by.negative}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{s.quote && (
|
||||||
|
<blockquote className="border-s-[3px] border-gold bg-gold-wash text-ink-soft text-sm leading-7 rounded-e-md px-3.5 py-2 my-2 mx-0">
|
||||||
|
{s.quote}
|
||||||
|
</blockquote>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{s.verified ? (
|
||||||
|
<>
|
||||||
|
<span className="text-success text-xs font-semibold">✓ אומת ע״י היו״ר</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={verify.isPending}
|
||||||
|
onClick={() => runVerify(a, s, false)}
|
||||||
|
className="text-xs text-ink-muted hover:text-danger border border-rule rounded-md px-2.5 py-1 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
בטל אימות
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={verify.isPending}
|
||||||
|
onClick={() => runVerify(a, s, true)}
|
||||||
|
className="text-xs font-semibold text-success bg-success-bg border border-success/30 rounded-md px-3 py-1 hover:brightness-95 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
✓ מאמת
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={verify.isPending}
|
||||||
|
onClick={() => runVerify(a, s, false)}
|
||||||
|
className="text-xs font-semibold text-danger border border-rule rounded-md px-3 py-1 hover:bg-danger-bg disabled:opacity-40"
|
||||||
|
>
|
||||||
|
✗ לא רלוונטי
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{s.cited_by.negative > 0 && !s.verified && (
|
||||||
|
<span className="text-warn text-[0.72rem] ms-1">⚠ אובחן — בדוק הקשר</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* chair note */}
|
||||||
|
<div className="mt-2 flex items-start gap-2">
|
||||||
|
<label className="text-[0.72rem] font-bold text-gold-deep whitespace-nowrap pt-1.5">
|
||||||
|
הערת יו״ר:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={s.chair_note}
|
||||||
|
placeholder="הוסף הערה לציטוט — מדוע תומך / הסתייגות / איך לשלב…"
|
||||||
|
onChange={(e) => setNotes((n) => ({ ...n, [s.case_law_id]: e.target.value }))}
|
||||||
|
onBlur={() => saveNote(a, s)}
|
||||||
|
className="flex-1 text-[0.8rem] text-ink-soft bg-parchment border border-dashed border-rule rounded-md px-2.5 py-1.5 focus:outline-none focus:border-gold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* radar — unlinked digests for this issue (INV-DIG1: pointer, not citation) */}
|
||||||
|
<RadarStrip radar={a.radar} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadarStrip({ radar }: { radar: RadarLead[] }) {
|
||||||
|
if (!radar.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="mx-5 mb-4 rounded-lg border border-dashed border-gold bg-parchment px-4 py-2.5">
|
||||||
|
<div className="text-[0.72rem] font-bold text-gold-deep mb-1">
|
||||||
|
📡 רדאר — פסיקה שאין לנו בקורפוס (מצביע, לא מצוטט)
|
||||||
|
</div>
|
||||||
|
<ul className="list-none p-0 m-0 space-y-1">
|
||||||
|
{radar.map((r) => (
|
||||||
|
<li key={r.digest_id} className="flex items-center gap-2 flex-wrap text-[0.8rem]">
|
||||||
|
<span className="font-semibold text-navy">{r.underlying_citation || "(אין מראה-מקום)"}</span>
|
||||||
|
<span className="rounded-full bg-warn-bg text-warn text-[0.64rem] font-semibold px-2 py-0.5">
|
||||||
|
{RADAR_LABEL[r.action] ?? r.action}
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-muted truncate">{r.headline}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,7 +48,14 @@ export type GraphControls = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ALL = "__all__";
|
const ALL = "__all__";
|
||||||
const YEARS = Array.from({ length: 2026 - 1994 + 1 }, (_, i) => 2026 - i);
|
// Floor covers the oldest dated precedent in the corpus (currently ע"א 725/81 →
|
||||||
|
// 1982); ceiling tracks the current year so the range never ages out.
|
||||||
|
const YEAR_FLOOR = 1980;
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear();
|
||||||
|
const YEARS = Array.from(
|
||||||
|
{ length: CURRENT_YEAR - YEAR_FLOOR + 1 },
|
||||||
|
(_, i) => CURRENT_YEAR - i,
|
||||||
|
);
|
||||||
|
|
||||||
const COLOR_BY: { value: ColorBy; label: string }[] = [
|
const COLOR_BY: { value: ColorBy; label: string }[] = [
|
||||||
{ value: "type", label: "סוג נקודה" },
|
{ value: "type", label: "סוג נקודה" },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Trash2, Upload, Pencil, ExternalLink } from "lucide-react";
|
import { Trash2, Upload, Pencil, ExternalLink, ChevronDown } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +63,24 @@ function SourceChip({ party }: { party: CitedByParty | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COLS = 7;
|
||||||
|
|
||||||
|
function TableHeaderRow() {
|
||||||
|
return (
|
||||||
|
<TableHeader className="bg-parchment">
|
||||||
|
<TableRow className="border-rule hover:bg-transparent">
|
||||||
|
<TableHead className="text-ink-muted text-right font-medium text-xs">פסיקה</TableHead>
|
||||||
|
<TableHead className="text-ink-muted text-right font-medium text-xs">נושא</TableHead>
|
||||||
|
<TableHead className="text-ink-muted text-right font-medium text-xs">תיק</TableHead>
|
||||||
|
<TableHead className="text-ink-muted text-right font-medium text-xs">צוטט ע״י</TableHead>
|
||||||
|
<TableHead className="text-ink-muted text-right font-medium text-xs">סטטוס</TableHead>
|
||||||
|
<TableHead className="text-ink-muted text-right font-medium text-xs">נוצר</TableHead>
|
||||||
|
<TableHead className="text-navy" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TableSkeleton({ cols }: { cols: number }) {
|
function TableSkeleton({ cols }: { cols: number }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -79,6 +97,38 @@ function TableSkeleton({ cols }: { cols: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accordion section meta — groups rows by discovery source (chair's request).
|
||||||
|
* "צוטט ע״י דפנה" (corpus-decision reliance) is the high-value group and opens
|
||||||
|
* by default; the noisy יומון group and the misc "אחר" group start collapsed. */
|
||||||
|
type SectionKey = "chair" | "digest" | "other";
|
||||||
|
const SECTION_META: Record<
|
||||||
|
SectionKey,
|
||||||
|
{ label: string; title: string; desc: string; chip: string; defaultOpen: boolean }
|
||||||
|
> = {
|
||||||
|
chair: {
|
||||||
|
label: "צוטט ע״י דפנה",
|
||||||
|
title: "פסיקה שהוועדה נסמכת עליה",
|
||||||
|
desc: "מצוטטת בהחלטות-הקורפוס (גרף-הציטוטים)",
|
||||||
|
chip: "bg-success-bg text-success",
|
||||||
|
defaultOpen: true,
|
||||||
|
},
|
||||||
|
digest: {
|
||||||
|
label: "יומון",
|
||||||
|
title: "זוהתה ביומון יומי",
|
||||||
|
desc: "מצביע, טרם צוטט בהחלטה",
|
||||||
|
chip: "bg-teal-bg text-teal",
|
||||||
|
defaultOpen: false,
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
label: "אחר",
|
||||||
|
title: "כתב-טענות / ידני",
|
||||||
|
desc: "צוטט בערר חי או נרשם ידנית",
|
||||||
|
chip: "bg-info-bg text-info",
|
||||||
|
defaultOpen: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const SECTION_ORDER: SectionKey[] = ["chair", "digest", "other"];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
status?: MissingPrecedentStatus | "";
|
status?: MissingPrecedentStatus | "";
|
||||||
q?: string;
|
q?: string;
|
||||||
@@ -91,7 +141,11 @@ export function MissingPrecedentsTable({ status, q, legalTopic }: Props) {
|
|||||||
status: status === "" ? undefined : status,
|
status: status === "" ? undefined : status,
|
||||||
q,
|
q,
|
||||||
legalTopic,
|
legalTopic,
|
||||||
limit: 200,
|
// Load the full set so the accordion section counts (chair/digest/other)
|
||||||
|
// reflect the real totals — at limit:200 the page showed only the first
|
||||||
|
// page (e.g. 16 of 106 committee rows) while the header showed the true
|
||||||
|
// count, so the sections didn't sum to it. Backend caps at 2000.
|
||||||
|
limit: 1000,
|
||||||
});
|
});
|
||||||
const del = useDeleteMissingPrecedent();
|
const del = useDeleteMissingPrecedent();
|
||||||
|
|
||||||
@@ -108,6 +162,196 @@ export function MissingPrecedentsTable({ status, q, legalTopic }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Partition the current result set by discovery source so each accordion
|
||||||
|
* section renders only its own rows. A row "cited by the committee" (has a
|
||||||
|
* resolved chair from the citation-graph bridge) wins over its raw
|
||||||
|
* discovery_source — that's the group the chair cares about. */
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const g: Record<SectionKey, MissingPrecedent[]> = { chair: [], digest: [], other: [] };
|
||||||
|
for (const mp of data?.items ?? []) {
|
||||||
|
if (mp.cited_by_chairs?.length) g.chair.push(mp);
|
||||||
|
else if (mp.discovery_source === "digest") g.digest.push(mp);
|
||||||
|
else g.other.push(mp);
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const renderRow = (mp: MissingPrecedent) => (
|
||||||
|
<TableRow
|
||||||
|
key={mp.id}
|
||||||
|
className="border-rule hover:bg-rule-soft/30 cursor-pointer"
|
||||||
|
onClick={() => setOpenId(mp.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="max-w-[440px]">
|
||||||
|
{/* Single line when there's no distinct case_name — the old two-line
|
||||||
|
layout repeated the citation (name fell back to a truncation of the
|
||||||
|
same citation). Show the name row only when it adds information. */}
|
||||||
|
{mp.case_name && mp.case_name.trim() ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-navy font-semibold truncate">
|
||||||
|
{mp.case_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted truncate" dir="rtl">
|
||||||
|
{mp.citation}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-navy font-semibold truncate" dir="rtl">
|
||||||
|
{mp.citation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm text-ink">{mp.legal_topic || "—"}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{mp.cited_by_decisions?.length ? (
|
||||||
|
/* committee decision(s) that cite this missing ruling
|
||||||
|
(corpus citation-graph bridge) */
|
||||||
|
<span className="inline-flex items-baseline gap-1.5">
|
||||||
|
<span className="text-sm text-navy font-semibold tabular-nums" dir="ltr">
|
||||||
|
{mp.cited_by_decisions[0]}
|
||||||
|
</span>
|
||||||
|
{mp.cited_by_decisions.length > 1 ? (
|
||||||
|
<span className="text-[0.7rem] text-ink-muted">
|
||||||
|
+{mp.cited_by_decisions.length - 1}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : mp.cited_in_case_number ? (
|
||||||
|
<Link
|
||||||
|
href={`/cases/${encodeURIComponent(mp.cited_in_case_number)}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-sm text-navy hover:text-gold-deep inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{mp.cited_in_case_number}
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-ink-muted text-sm">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-ink">
|
||||||
|
{mp.cited_by_chairs?.length ? (
|
||||||
|
/* cited by a committee DECISION in the corpus → show the chair who
|
||||||
|
relied on it (e.g. דפנה תמיר). Bridge from
|
||||||
|
precedent_internal_citations; takes priority over the generic
|
||||||
|
discovery-source chips. */
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full whitespace-nowrap bg-success-bg text-success border-transparent"
|
||||||
|
>
|
||||||
|
{mp.cited_by_chairs[0]}
|
||||||
|
</Badge>
|
||||||
|
{mp.cited_by_chairs.length > 1 ? (
|
||||||
|
<div className="text-[0.7rem] text-ink-muted mt-1">
|
||||||
|
+{mp.cited_by_chairs.length - 1} יו״ר
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : mp.discovery_source === "cited_only" ? (
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full whitespace-nowrap bg-plum-bg text-plum border-transparent"
|
||||||
|
>
|
||||||
|
פסיקה בקורפוס
|
||||||
|
</Badge>
|
||||||
|
{mp.cited_by_precedents?.length ? (
|
||||||
|
<div className="text-[0.7rem] text-ink-muted truncate max-w-[180px] mt-1">
|
||||||
|
מצוטט ע״י: {mp.cited_by_precedents.join(", ")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : mp.discovery_source === "digest" ? (
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full whitespace-nowrap bg-teal-bg text-teal border-transparent"
|
||||||
|
>
|
||||||
|
יומון
|
||||||
|
</Badge>
|
||||||
|
{mp.yomon_number ? (
|
||||||
|
<div className="text-[0.7rem] text-ink-muted mt-1">
|
||||||
|
מס׳ {mp.yomon_number}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SourceChip party={mp.cited_by_party} />
|
||||||
|
{mp.cited_by_party_name ? (
|
||||||
|
<div className="text-[0.7rem] text-ink-muted truncate max-w-[160px] mt-1">
|
||||||
|
{mp.cited_by_party_name}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={mp.status} />
|
||||||
|
{mp.linked_case_law_number ? (
|
||||||
|
<div className="text-[0.7rem] text-success mt-1">
|
||||||
|
↳ {mp.linked_case_law_name || mp.linked_case_law_number}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-[0.78rem] text-ink-muted">
|
||||||
|
{formatDate(mp.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-end">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{mp.status === "open" ? (
|
||||||
|
/* gold "העלה והשלם" CTA (mockup 09 `.btn`) */
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpenId(mp.id);
|
||||||
|
}}
|
||||||
|
className="h-7 bg-gold text-white hover:bg-gold-deep border-transparent text-[0.78rem] font-semibold"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5 me-1" />
|
||||||
|
העלה והשלם
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
/* passive "done" label for non-open rows */
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-md bg-rule-soft text-ink-muted text-[0.78rem] font-medium px-2.5 py-1">
|
||||||
|
{mp.status === "closed" ? "קושר" : STATUS_LABELS[mp.status]} ✓
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{mp.status !== "open" ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpenId(mp.id);
|
||||||
|
}}
|
||||||
|
title="פרטים"
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(mp);
|
||||||
|
}}
|
||||||
|
disabled={del.isPending}
|
||||||
|
className="text-danger hover:text-danger"
|
||||||
|
title="מחיקה"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
|
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
|
||||||
@@ -116,168 +360,61 @@ export function MissingPrecedentsTable({ status, q, legalTopic }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (isPending) {
|
||||||
<>
|
return (
|
||||||
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="bg-parchment">
|
<TableHeaderRow />
|
||||||
<TableRow className="border-rule hover:bg-transparent">
|
|
||||||
<TableHead className="text-ink-muted text-right font-medium text-xs">פסיקה</TableHead>
|
|
||||||
<TableHead className="text-ink-muted text-right font-medium text-xs">נושא</TableHead>
|
|
||||||
<TableHead className="text-ink-muted text-right font-medium text-xs">תיק</TableHead>
|
|
||||||
<TableHead className="text-ink-muted text-right font-medium text-xs">צוטט ע״י</TableHead>
|
|
||||||
<TableHead className="text-ink-muted text-right font-medium text-xs">סטטוס</TableHead>
|
|
||||||
<TableHead className="text-ink-muted text-right font-medium text-xs">נוצר</TableHead>
|
|
||||||
<TableHead className="text-navy" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isPending ? (
|
<TableSkeleton cols={COLS} />
|
||||||
<TableSkeleton cols={7} />
|
|
||||||
) : !data?.items.length ? (
|
|
||||||
<TableRow className="border-rule">
|
|
||||||
<TableCell colSpan={7} className="text-center text-ink-muted py-8">
|
|
||||||
אין פסיקות חסרות בקריטריונים הנוכחיים.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
data.items.map((mp) => (
|
|
||||||
<TableRow
|
|
||||||
key={mp.id}
|
|
||||||
className="border-rule hover:bg-rule-soft/30 cursor-pointer"
|
|
||||||
onClick={() => setOpenId(mp.id)}
|
|
||||||
>
|
|
||||||
<TableCell className="max-w-[440px]">
|
|
||||||
<div className="text-sm text-navy font-semibold truncate">
|
|
||||||
{mp.case_name || mp.citation.split(" ").slice(0, 6).join(" ")}
|
|
||||||
</div>
|
|
||||||
<div className="text-[0.72rem] text-ink-muted truncate" dir="rtl">
|
|
||||||
{mp.citation}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-sm text-ink">{mp.legal_topic || "—"}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{mp.cited_in_case_number ? (
|
|
||||||
<Link
|
|
||||||
href={`/cases/${encodeURIComponent(mp.cited_in_case_number)}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="text-sm text-navy hover:text-gold-deep inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{mp.cited_in_case_number}
|
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className="text-ink-muted text-sm">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-ink">
|
|
||||||
{mp.discovery_source === "cited_only" ? (
|
|
||||||
<>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-full whitespace-nowrap bg-plum-bg text-plum border-transparent"
|
|
||||||
>
|
|
||||||
פסיקה בקורפוס
|
|
||||||
</Badge>
|
|
||||||
{mp.cited_by_precedents?.length ? (
|
|
||||||
<div className="text-[0.7rem] text-ink-muted truncate max-w-[180px] mt-1">
|
|
||||||
מצוטט ע״י: {mp.cited_by_precedents.join(", ")}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : mp.discovery_source === "digest" ? (
|
|
||||||
<>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-full whitespace-nowrap bg-teal-bg text-teal border-transparent"
|
|
||||||
>
|
|
||||||
יומון
|
|
||||||
</Badge>
|
|
||||||
{mp.yomon_number ? (
|
|
||||||
<div className="text-[0.7rem] text-ink-muted mt-1">
|
|
||||||
מס׳ {mp.yomon_number}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<SourceChip party={mp.cited_by_party} />
|
|
||||||
{mp.cited_by_party_name ? (
|
|
||||||
<div className="text-[0.7rem] text-ink-muted truncate max-w-[160px] mt-1">
|
|
||||||
{mp.cited_by_party_name}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusBadge status={mp.status} />
|
|
||||||
{mp.linked_case_law_number ? (
|
|
||||||
<div className="text-[0.7rem] text-success mt-1">
|
|
||||||
↳ {mp.linked_case_law_name || mp.linked_case_law_number}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-[0.78rem] text-ink-muted">
|
|
||||||
{formatDate(mp.created_at)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-end">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
{mp.status === "open" ? (
|
|
||||||
/* gold "העלה והשלם" CTA (mockup 09 `.btn`) */
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setOpenId(mp.id);
|
|
||||||
}}
|
|
||||||
className="h-7 bg-gold text-white hover:bg-gold-deep border-transparent text-[0.78rem] font-semibold"
|
|
||||||
>
|
|
||||||
<Upload className="w-3.5 h-3.5 me-1" />
|
|
||||||
העלה והשלם
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
/* passive "done" label for non-open rows */
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-md bg-rule-soft text-ink-muted text-[0.78rem] font-medium px-2.5 py-1">
|
|
||||||
{mp.status === "closed" ? "קושר" : STATUS_LABELS[mp.status]} ✓
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{mp.status !== "open" ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setOpenId(mp.id);
|
|
||||||
}}
|
|
||||||
title="פרטים"
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDelete(mp);
|
|
||||||
}}
|
|
||||||
disabled={del.isPending}
|
|
||||||
className="text-danger hover:text-danger"
|
|
||||||
title="מחיקה"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data?.items.length) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-rule bg-surface shadow-sm px-6 py-10 text-center text-ink-muted text-sm">
|
||||||
|
אין פסיקות חסרות בקריטריונים הנוכחיים.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3.5">
|
||||||
|
{SECTION_ORDER.map((key) => {
|
||||||
|
const items = groups[key];
|
||||||
|
if (!items.length) return null;
|
||||||
|
const meta = SECTION_META[key];
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
key={key}
|
||||||
|
open={meta.defaultOpen}
|
||||||
|
className="group rounded-lg border border-rule bg-surface shadow-sm overflow-hidden"
|
||||||
|
>
|
||||||
|
<summary className="flex items-center gap-3 px-4 py-3.5 bg-parchment cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
|
||||||
|
<ChevronDown className="w-4 h-4 text-ink-muted transition-transform -rotate-90 group-open:rotate-0 shrink-0" />
|
||||||
|
<span className={`rounded-full px-2.5 py-0.5 text-[0.72rem] font-semibold whitespace-nowrap ${meta.chip}`}>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-navy text-sm">{meta.title}</span>
|
||||||
|
<span className="hidden sm:inline text-ink-muted text-[0.78rem]">
|
||||||
|
— {meta.desc}
|
||||||
|
</span>
|
||||||
|
<span className="ms-auto tabular-nums text-[0.8rem] text-ink-soft bg-surface border border-rule rounded-full px-2.5 py-0.5 font-semibold">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<Table>
|
||||||
|
<TableHeaderRow />
|
||||||
|
<TableBody>{items.map(renderRow)}</TableBody>
|
||||||
|
</Table>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<MissingPrecedentDetailDrawer
|
<MissingPrecedentDetailDrawer
|
||||||
id={openId}
|
id={openId}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
useLinkRelatedCase,
|
useLinkRelatedCase,
|
||||||
useUnlinkRelatedCase,
|
useUnlinkRelatedCase,
|
||||||
RelatedCase,
|
RelatedCase,
|
||||||
|
IncomingCitation,
|
||||||
} from "@/lib/api/precedent-library";
|
} from "@/lib/api/precedent-library";
|
||||||
|
|
||||||
const LEVEL_LABELS: Record<string, string> = {
|
const LEVEL_LABELS: Record<string, string> = {
|
||||||
@@ -133,19 +134,28 @@ function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps)
|
|||||||
type SectionProps = {
|
type SectionProps = {
|
||||||
caseId: string;
|
caseId: string;
|
||||||
related: RelatedCase[];
|
related: RelatedCase[];
|
||||||
|
/** Decisions that cite THIS ruling — auto-detected from the citation graph
|
||||||
|
* (read-only). Optional so existing call-sites stay valid. */
|
||||||
|
incoming?: IncomingCitation[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Rail-styled citations card (mockup 08 side rail). Renders linked related
|
/* Rail-styled citations card (mockup 08 side rail). Renders linked related
|
||||||
* decisions as a navy-headed card with arrow-prefixed rows; keeps the full
|
* decisions as a navy-headed card with arrow-prefixed rows; keeps the full
|
||||||
* link/unlink logic. Used in the precedent-detail side rail. */
|
* link/unlink logic. Auto-detected incoming citations (decisions that cite this
|
||||||
export function RelatedCasesSection({ caseId, related }: SectionProps) {
|
* ruling) render in the SAME row style, read-only (no unlink). Used in the
|
||||||
|
* precedent-detail side rail. */
|
||||||
|
export function RelatedCasesSection({ caseId, related, incoming = [] }: SectionProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
// De-dupe: a decision already linked manually shouldn't appear twice.
|
||||||
|
const manualIds = new Set(related.map((r) => r.id));
|
||||||
|
const autoCites = incoming.filter((c) => !manualIds.has(c.id));
|
||||||
|
const total = related.length + autoCites.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-rule bg-surface shadow-sm px-4 py-3.5 space-y-2.5">
|
<div className="rounded-lg border border-rule bg-surface shadow-sm px-4 py-3.5 space-y-2.5">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h3 className="text-navy text-[0.92rem] font-semibold m-0">
|
<h3 className="text-navy text-[0.92rem] font-semibold m-0">
|
||||||
ציטוטים מקושרים{related.length > 0 ? ` (${related.length})` : ""}
|
ציטוטים מקושרים{total > 0 ? ` (${total})` : ""}
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -157,10 +167,46 @@ export function RelatedCasesSection({ caseId, related }: SectionProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{related.length === 0 ? (
|
{total === 0 ? (
|
||||||
<p className="text-ink-muted text-[0.82rem] m-0">אין החלטות קשורות עדיין</p>
|
<p className="text-ink-muted text-[0.82rem] m-0">אין החלטות קשורות עדיין</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="list-none p-0 m-0">
|
<ul className="list-none p-0 m-0">
|
||||||
|
{autoCites.map((c) => (
|
||||||
|
<li
|
||||||
|
key={c.id}
|
||||||
|
className="flex items-start gap-2 py-2 border-b border-rule-soft last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="text-gold font-bold leading-6 shrink-0" aria-hidden>←</span>
|
||||||
|
<a
|
||||||
|
href={`/precedents/${c.id}`}
|
||||||
|
className="min-w-0 flex-1 group hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<div className="text-[0.82rem] text-ink-soft leading-5 group-hover:text-navy">
|
||||||
|
{c.case_name || c.case_number}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||||
|
{c.precedent_level && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[0.6rem] ${LEVEL_COLORS[c.precedent_level] ?? ""}`}
|
||||||
|
>
|
||||||
|
{LEVEL_LABELS[c.precedent_level] ?? c.precedent_level}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{(c.chair_name || c.court) && (
|
||||||
|
<span className="text-[0.68rem] text-ink-muted truncate">
|
||||||
|
{c.chair_name || c.court}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{c.date && (
|
||||||
|
<span className="text-[0.68rem] text-ink-muted tabular-nums" dir="ltr">
|
||||||
|
{c.date.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
{related.map((r) => (
|
{related.map((r) => (
|
||||||
<li
|
<li
|
||||||
key={r.id}
|
key={r.id}
|
||||||
|
|||||||
103
web-ui/src/lib/api/citation-verification.ts
Normal file
103
web-ui/src/lib/api/citation-verification.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Citation-verification domain (X11 Phase 2 / #154) — the compose "אימות פסיקה" tab.
|
||||||
|
*
|
||||||
|
* Per legal argument: in-corpus supporting precedents with the cumulative authority
|
||||||
|
* signal (cited_by — followed/distinguished), their verify state + chair note, and
|
||||||
|
* per-issue radar (unlinked digests). The chair verifies before the writer cites
|
||||||
|
* (INV-AH). Backed by GET/POST /api/cases/{n}/citation-verification[/verify].
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
|
||||||
|
export type CitedBy = {
|
||||||
|
total: number;
|
||||||
|
positive: number;
|
||||||
|
negative: number;
|
||||||
|
unclassified: number;
|
||||||
|
by_treatment: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SupportingPrecedent = {
|
||||||
|
case_law_id: string;
|
||||||
|
case_number: string;
|
||||||
|
case_name: string;
|
||||||
|
quote: string;
|
||||||
|
score: number;
|
||||||
|
cited_by: CitedBy;
|
||||||
|
attached_id: string | null;
|
||||||
|
verified: boolean;
|
||||||
|
chair_note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RadarLead = {
|
||||||
|
digest_id: string;
|
||||||
|
yomon_number?: string | number | null;
|
||||||
|
headline: string;
|
||||||
|
underlying_citation: string;
|
||||||
|
underlying_court: string;
|
||||||
|
score: number;
|
||||||
|
missing_precedent_id: string | null;
|
||||||
|
missing_precedent_status: string | null;
|
||||||
|
action: "new_lead" | "gap_open" | "fetched" | "available_link" | string;
|
||||||
|
matched_issues?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArgumentBlock = {
|
||||||
|
argument_id: string;
|
||||||
|
title: string;
|
||||||
|
legal_topic: string;
|
||||||
|
priority: string;
|
||||||
|
party: string;
|
||||||
|
supporting: SupportingPrecedent[];
|
||||||
|
radar: RadarLead[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CitationVerificationView = {
|
||||||
|
status: string;
|
||||||
|
case_number: string;
|
||||||
|
arguments: ArgumentBlock[];
|
||||||
|
summary: {
|
||||||
|
arguments_total: number;
|
||||||
|
arguments_with_support: number;
|
||||||
|
verified: number;
|
||||||
|
radar_leads: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VerifyCitationBody = {
|
||||||
|
argument_id: string;
|
||||||
|
case_law_id?: string;
|
||||||
|
quote?: string;
|
||||||
|
citation?: string;
|
||||||
|
chair_note?: string | null;
|
||||||
|
verified: boolean;
|
||||||
|
attached_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCitationVerification(caseNumber: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["citation-verification", caseNumber ?? ""],
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<CitationVerificationView>(
|
||||||
|
`/api/cases/${caseNumber}/citation-verification`,
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
enabled: Boolean(caseNumber),
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVerifyCitation(caseNumber: string | undefined) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: VerifyCitationBody) =>
|
||||||
|
apiRequest(`/api/cases/${caseNumber}/citation-verification/verify`, {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["citation-verification", caseNumber ?? ""] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -71,12 +71,30 @@ export function useLegalArguments(caseNumber: string | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AggregateArgumentsResult = {
|
/**
|
||||||
status: "started" | string;
|
* The aggregation runs on the legal-analyst agent (host-side, where the
|
||||||
case_number: string;
|
* `claude` CLI lives) — NOT inline in the FastAPI container. The endpoint
|
||||||
force: boolean;
|
* either delegates via a Paperclip wakeup (`queued`) or short-circuits on a
|
||||||
message: string;
|
* cheap in-container DB pre-check (`no_claims` / `exists`). `skipped` means
|
||||||
};
|
* no analyst route was available; the chair can run the MCP tool manually.
|
||||||
|
*/
|
||||||
|
export type AggregateArgumentsResult =
|
||||||
|
| {
|
||||||
|
status: "queued";
|
||||||
|
sub_issue_id: string;
|
||||||
|
analyst_id: string;
|
||||||
|
main_issue_id: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "no_claims" | "exists";
|
||||||
|
total: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "skipped";
|
||||||
|
reason: "no_api_key" | "no_analyst" | "no_issue" | string;
|
||||||
|
company_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function useAggregateArguments(caseNumber: string | undefined) {
|
export function useAggregateArguments(caseNumber: string | undefined) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ export type MissingPrecedent = {
|
|||||||
discovery_source: string | null; // manual | cited_only | digest | court_fetch
|
discovery_source: string | null; // manual | cited_only | digest | court_fetch
|
||||||
cited_by_precedents: string[] | null; // corpus precedents citing a cited_only stub
|
cited_by_precedents: string[] | null; // corpus precedents citing a cited_only stub
|
||||||
yomon_number: string | null; // digest gap's source yomon number
|
yomon_number: string | null; // digest gap's source yomon number
|
||||||
|
// Bridge to the corpus citation graph: committee decisions (and their chairs)
|
||||||
|
// that cite this still-missing ruling — fills "צוטט ע״י <יו״ר>" + "תיק".
|
||||||
|
cited_by_chairs: string[] | null;
|
||||||
|
cited_by_decisions: string[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MissingPrecedentListResponse = {
|
export type MissingPrecedentListResponse = {
|
||||||
|
|||||||
@@ -143,10 +143,24 @@ export type RelatedCase = {
|
|||||||
relation_type: string;
|
relation_type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A decision that cites THIS ruling — auto-detected from the citation graph
|
||||||
|
* (precedent_internal_citations incoming edges). Read-only (no unlink). */
|
||||||
|
export type IncomingCitation = {
|
||||||
|
id: string;
|
||||||
|
case_number: string;
|
||||||
|
case_name: string;
|
||||||
|
court: string;
|
||||||
|
precedent_level: string;
|
||||||
|
chair_name: string;
|
||||||
|
date: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type PrecedentDetail = Precedent & {
|
export type PrecedentDetail = Precedent & {
|
||||||
full_text: string;
|
full_text: string;
|
||||||
halachot: Halacha[];
|
halachot: Halacha[];
|
||||||
related_cases: RelatedCase[];
|
related_cases: RelatedCase[];
|
||||||
|
incoming_citations: IncomingCitation[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SearchHit =
|
export type SearchHit =
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ from web.paperclip_client import (
|
|||||||
restore_project as pc_restore_project,
|
restore_project as pc_restore_project,
|
||||||
update_project_name as pc_update_project_name,
|
update_project_name as pc_update_project_name,
|
||||||
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
|
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
|
||||||
|
wake_analyst_for_argument_aggregation as pc_wake_analyst_for_argument_aggregation,
|
||||||
wake_ceo_agent as pc_wake_ceo,
|
wake_ceo_agent as pc_wake_ceo,
|
||||||
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
|
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
|
||||||
wake_curator_for_final as pc_wake_curator_for_final,
|
wake_curator_for_final as pc_wake_curator_for_final,
|
||||||
@@ -99,6 +100,7 @@ __all__ = [
|
|||||||
"pc_wake_curator_for_final",
|
"pc_wake_curator_for_final",
|
||||||
"pc_wake_for_precedent_extraction",
|
"pc_wake_for_precedent_extraction",
|
||||||
"pc_wake_analyst_for_appraiser_facts",
|
"pc_wake_analyst_for_appraiser_facts",
|
||||||
|
"pc_wake_analyst_for_argument_aggregation",
|
||||||
# comments / interactions
|
# comments / interactions
|
||||||
"pc_post_comment",
|
"pc_post_comment",
|
||||||
"pc_get_issue_comments",
|
"pc_get_issue_comments",
|
||||||
|
|||||||
165
web/app.py
165
web/app.py
@@ -75,6 +75,7 @@ from web.agent_platform_port import (
|
|||||||
pc_respond_to_interaction,
|
pc_respond_to_interaction,
|
||||||
pc_restore_project,
|
pc_restore_project,
|
||||||
pc_wake_analyst_for_appraiser_facts,
|
pc_wake_analyst_for_appraiser_facts,
|
||||||
|
pc_wake_analyst_for_argument_aggregation,
|
||||||
rename_case_project,
|
rename_case_project,
|
||||||
pc_wake_ceo,
|
pc_wake_ceo,
|
||||||
pc_wake_ceo_for_feedback_fold,
|
pc_wake_ceo_for_feedback_fold,
|
||||||
@@ -2454,41 +2455,78 @@ async def api_get_claims(case_number: str):
|
|||||||
# the FastAPI container it short-circuits with status="llm_unavailable".
|
# the FastAPI container it short-circuits with status="llm_unavailable".
|
||||||
|
|
||||||
@app.post("/api/cases/{case_number}/aggregate-arguments")
|
@app.post("/api/cases/{case_number}/aggregate-arguments")
|
||||||
async def api_aggregate_arguments(
|
async def api_aggregate_arguments(case_number: str, force: bool = False):
|
||||||
case_number: str,
|
"""Queue claim→argument aggregation by waking the legal-analyst agent.
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
force: bool = False,
|
|
||||||
):
|
|
||||||
"""Aggregate raw claims into distinct legal arguments via Claude.
|
|
||||||
|
|
||||||
Runs as a BackgroundTask because the LLM pass can take 30-90 seconds.
|
The aggregation itself calls `claude_session.query_json()`, which shells
|
||||||
|
out to the local `claude` CLI — present on the agent host, **absent in
|
||||||
|
this FastAPI container**. Running it inline as a BackgroundTask (the old
|
||||||
|
behaviour) silently produced nothing, and on `force` it destructively
|
||||||
|
deleted the existing arguments *before* the doomed LLM call. So we
|
||||||
|
delegate to the analyst exactly like `extract-appraiser-facts`: create a
|
||||||
|
child Paperclip issue, assign it to the company's analyst, and trigger a
|
||||||
|
wakeup. The analyst runs the MCP tool locally and posts results.
|
||||||
|
|
||||||
|
Cheap in-container pre-checks (DB only, no LLM) short-circuit before any
|
||||||
|
agent is spun up:
|
||||||
|
- `no_claims` — there are no raw claims to aggregate yet.
|
||||||
|
- `exists` — arguments already computed and `force` is False.
|
||||||
|
|
||||||
|
Response shape:
|
||||||
|
{"status": "queued", "sub_issue_id", "analyst_id", "main_issue_id"}
|
||||||
|
or {"status": "no_claims"|"exists", "total", "message"}
|
||||||
|
or {"status": "skipped", "reason": "no_api_key"|"no_analyst"|"no_issue"}
|
||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
|
||||||
async def _run() -> None:
|
case_id = UUID(case["id"])
|
||||||
try:
|
pool = await db.get_pool()
|
||||||
from legal_mcp.services import argument_aggregator
|
async with pool.acquire() as conn:
|
||||||
result = await argument_aggregator.aggregate_claims_to_arguments(
|
claim_count = await conn.fetchval(
|
||||||
UUID(case["id"]), force=force,
|
"SELECT COUNT(*) FROM claims WHERE case_id = $1", case_id,
|
||||||
)
|
)
|
||||||
logger.info(
|
existing_args = await conn.fetchval(
|
||||||
"aggregate_arguments[%s] finished: %s",
|
"SELECT COUNT(*) FROM legal_arguments WHERE case_id = $1", case_id,
|
||||||
case_number, result,
|
)
|
||||||
)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
logger.exception(
|
|
||||||
"aggregate_arguments[%s] failed: %s", case_number, e,
|
|
||||||
)
|
|
||||||
|
|
||||||
background_tasks.add_task(_run)
|
if not claim_count:
|
||||||
return {
|
return {
|
||||||
"status": "started",
|
"status": "no_claims",
|
||||||
"case_number": case_number,
|
"total": 0,
|
||||||
"force": force,
|
"message": (
|
||||||
"message": "Aggregation started in background. Poll /legal-arguments for results.",
|
"אין טענות גולמיות בתיק. הרץ קודם חילוץ טענות (extract_claims) "
|
||||||
}
|
"ואז חשב טיעונים."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if existing_args and not force:
|
||||||
|
return {
|
||||||
|
"status": "exists",
|
||||||
|
"total": existing_args,
|
||||||
|
"message": (
|
||||||
|
f"כבר קיימים {existing_args} טיעונים מאוגדים. השתמש בכפתור "
|
||||||
|
"החישוב-מחדש כדי לחשב מחדש (מוחק ובונה מחדש)."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Route to the analyst of the correct company by case-number prefix.
|
||||||
|
prefix = case_number[:1]
|
||||||
|
company_id = (
|
||||||
|
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
|
||||||
|
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await pc_wake_analyst_for_argument_aggregation(
|
||||||
|
case_number, company_id=company_id, force=force,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("analyst wakeup failed for argument aggregation %s", case_number)
|
||||||
|
raise HTTPException(500, f"לא ניתן לשלוח לאנליטיקאי: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/cases/{case_number}/legal-arguments")
|
@app.get("/api/cases/{case_number}/legal-arguments")
|
||||||
@@ -3155,6 +3193,62 @@ async def api_precedent_list(case_number: str):
|
|||||||
return envelope_unwrap(parsed)
|
return envelope_unwrap(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Citation-verification panel (X11 Phase 2 / #154) — "אימות פסיקה" tab ──
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cases/{case_number}/citation-verification")
|
||||||
|
async def api_citation_verification(case_number: str):
|
||||||
|
"""Per legal-argument: supporting corpus precedents (with cited_by authority),
|
||||||
|
their verify state + chair note, and per-issue radar (unlinked digests). Powers
|
||||||
|
the compose 'אימות פסיקה' tab — the chair verifies before the writer cites."""
|
||||||
|
from legal_mcp.services import case_citation_verification as ccv
|
||||||
|
view = await ccv.build_view(case_number)
|
||||||
|
if view.get("status") == "case_not_found":
|
||||||
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
class CitationVerifyRequest(BaseModel):
|
||||||
|
argument_id: str
|
||||||
|
case_law_id: str = ""
|
||||||
|
quote: str = ""
|
||||||
|
citation: str = ""
|
||||||
|
chair_note: str | None = None
|
||||||
|
verified: bool = True
|
||||||
|
attached_id: str = "" # set when the precedent row already exists
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/citation-verification/verify")
|
||||||
|
async def api_citation_verify(case_number: str, req: CitationVerifyRequest):
|
||||||
|
"""Verify / un-verify a precedent for a specific argument (the INV-AH gate the
|
||||||
|
writer respects). Upsert: PATCH an existing attachment, else attach a new one
|
||||||
|
linked to the argument + corpus ruling and mark it."""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
try:
|
||||||
|
if req.attached_id:
|
||||||
|
row = await db.set_case_precedent_verified(
|
||||||
|
UUID(req.attached_id), req.verified, req.chair_note)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "שיוך-פסיקה לא נמצא")
|
||||||
|
return row
|
||||||
|
if not req.quote.strip() or not req.citation.strip():
|
||||||
|
raise HTTPException(400, "quote ו-citation חובה לצירוף חדש")
|
||||||
|
cid = case["id"]
|
||||||
|
row = await db.create_case_precedent(
|
||||||
|
case_id=UUID(cid) if isinstance(cid, str) else cid,
|
||||||
|
quote=req.quote, citation=req.citation,
|
||||||
|
chair_note=req.chair_note or "",
|
||||||
|
argument_id=UUID(req.argument_id) if req.argument_id else None,
|
||||||
|
case_law_id=UUID(req.case_law_id) if req.case_law_id else None,
|
||||||
|
verified=req.verified,
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, "מזהה לא תקין")
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/precedents/{precedent_id}")
|
@app.delete("/api/precedents/{precedent_id}")
|
||||||
async def api_precedent_delete(precedent_id: str):
|
async def api_precedent_delete(precedent_id: str):
|
||||||
"""Delete a precedent attachment. The archived PDF (if any) stays
|
"""Delete a precedent attachment. The archived PDF (if any) stays
|
||||||
@@ -6444,6 +6538,17 @@ class DigestLinkRequest(BaseModel):
|
|||||||
case_law_id: str
|
case_law_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cases/{case_number}/digest-radar")
|
||||||
|
async def api_case_digest_radar(case_number: str, limit: int = 5, min_score: float = 0.45):
|
||||||
|
"""Case-contextual digest radar (X12) — UNLINKED digests whose topic is close to
|
||||||
|
this case (rulings we don't hold yet). Powers the case-page "📡 רדאר יומונים" lead
|
||||||
|
so a relevant ruling known only via a digest doesn't fall through the cracks while
|
||||||
|
the case is decided. INV-DIG1: points at the underlying ruling, never cites the
|
||||||
|
digest."""
|
||||||
|
return await digest_service.case_digest_radar(
|
||||||
|
case_number, limit=max(1, min(int(limit), 20)), min_score=float(min_score))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/digests/upload")
|
@app.post("/api/digests/upload")
|
||||||
async def digest_upload(
|
async def digest_upload(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -7141,7 +7246,7 @@ async def internal_decisions_upload(
|
|||||||
practice_area: str = Form(""),
|
practice_area: str = Form(""),
|
||||||
appeal_subtype: str = Form(""),
|
appeal_subtype: str = Form(""),
|
||||||
subject_tags: str = Form("[]"),
|
subject_tags: str = Form("[]"),
|
||||||
is_binding: bool = Form(True),
|
is_binding: bool = Form(False), # INV-DM7: committee = persuasive (coerced at db too)
|
||||||
summary: str = Form(""),
|
summary: str = Form(""),
|
||||||
):
|
):
|
||||||
"""Upload a planning appeals-committee decision to the internal corpus.
|
"""Upload a planning appeals-committee decision to the internal corpus.
|
||||||
@@ -7877,7 +7982,7 @@ async def missing_precedents_list(
|
|||||||
case_id=case_uuid,
|
case_id=case_uuid,
|
||||||
legal_topic=legal_topic.strip() or None,
|
legal_topic=legal_topic.strip() or None,
|
||||||
q=q.strip() or None,
|
q=q.strip() or None,
|
||||||
limit=max(1, min(int(limit), 500)),
|
limit=max(1, min(int(limit), 2000)),
|
||||||
offset=max(0, int(offset)),
|
offset=max(0, int(offset)),
|
||||||
)
|
)
|
||||||
# Counters useful for the sidebar badge.
|
# Counters useful for the sidebar badge.
|
||||||
|
|||||||
@@ -1371,3 +1371,121 @@ async def wake_analyst_for_appraiser_facts(
|
|||||||
"analyst_id": analyst_id,
|
"analyst_id": analyst_id,
|
||||||
"main_issue_id": main_issue_id,
|
"main_issue_id": main_issue_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def wake_analyst_for_argument_aggregation(
|
||||||
|
case_number: str,
|
||||||
|
company_id: str,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Wake the legal-analyst to aggregate raw claims into legal arguments.
|
||||||
|
|
||||||
|
Triggered by the chair clicking "חשב טיעונים" in the case page. The
|
||||||
|
FastAPI container cannot run `aggregate_claims_to_arguments` directly —
|
||||||
|
the aggregator calls `claude_session.query_json()`, which only works
|
||||||
|
where the local `claude` CLI is present (the MCP server / agent runner
|
||||||
|
on the host), **not in this container**. So instead of an in-container
|
||||||
|
BackgroundTask (which fails silently and, on `force`, destructively
|
||||||
|
deletes the existing arguments before the doomed LLM call), we create a
|
||||||
|
child issue under the case's main Paperclip issue, assign it to the
|
||||||
|
analyst of the correct company, and trigger a wakeup. The analyst's
|
||||||
|
HEARTBEAT picks up the issue, runs the MCP tool locally, and reports
|
||||||
|
back via a comment.
|
||||||
|
|
||||||
|
Mirrors ``wake_analyst_for_appraiser_facts`` — same delegation shape.
|
||||||
|
|
||||||
|
Returns a dict shaped for the FastAPI endpoint to serialize as-is:
|
||||||
|
{"status": "queued", "sub_issue_id", "analyst_id", "main_issue_id"}
|
||||||
|
or {"status": "skipped", "reason": "..."} for non-fatal early outs.
|
||||||
|
"""
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
logger.warning(
|
||||||
|
"PAPERCLIP_BOARD_API_KEY not set — cannot queue analyst wakeup "
|
||||||
|
"for argument aggregation on %s",
|
||||||
|
case_number,
|
||||||
|
)
|
||||||
|
return {"status": "skipped", "reason": "no_api_key"}
|
||||||
|
|
||||||
|
analyst_id = ANALYST_AGENTS.get(company_id)
|
||||||
|
if not analyst_id:
|
||||||
|
logger.info("No analyst configured for company %s — skipping", company_id)
|
||||||
|
return {"status": "skipped", "reason": "no_analyst", "company_id": company_id}
|
||||||
|
|
||||||
|
issues = await get_case_issues(case_number)
|
||||||
|
if not issues:
|
||||||
|
logger.warning(
|
||||||
|
"No Paperclip issues found for case %s — cannot queue analyst", case_number,
|
||||||
|
)
|
||||||
|
return {"status": "skipped", "reason": "no_issue"}
|
||||||
|
|
||||||
|
main_issue = next((i for i in issues if i.get("status") == "in_progress"), None) or issues[0]
|
||||||
|
main_issue_id = main_issue["id"]
|
||||||
|
|
||||||
|
force_clause = ", force=True" if force else ""
|
||||||
|
rerun_note = (
|
||||||
|
"זהו חישוב-מחדש (force) — הטיעונים הקיימים יימחקו ויחושבו מחדש.\n\n"
|
||||||
|
if force else ""
|
||||||
|
)
|
||||||
|
description = (
|
||||||
|
f"חיים ביקש חישוב טיעונים משפטיים בתיק {case_number}.\n\n"
|
||||||
|
f"{rerun_note}"
|
||||||
|
f"הרץ `mcp__legal-ai__aggregate_claims_to_arguments(case_number=\"{case_number}\"{force_clause})` "
|
||||||
|
f"וכתוב comment בעברית עם תוצאת האיגוד — כמה טיעונים מובחנים נוצרו לכל צד "
|
||||||
|
f"(עוררים / משיבים / ועדה / מבקשי-היתר). אם אין טענות גולמיות בתיק, דווח "
|
||||||
|
f"ב-comment שצריך להריץ קודם חילוץ טענות (`extract_claims`) וסגור את ה-issue כ-blocked."
|
||||||
|
)
|
||||||
|
child_resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/issues/{main_issue_id}/children",
|
||||||
|
json={
|
||||||
|
"title": f"[ערר {case_number}] חישוב טיעונים משפטיים",
|
||||||
|
"description": description,
|
||||||
|
"status": "in_progress",
|
||||||
|
# Paperclip ISSUE_PRIORITIES = critical|high|medium|low — "normal"
|
||||||
|
# is NOT a valid enum value (Zod 400 → surfaces as 500).
|
||||||
|
"priority": "medium",
|
||||||
|
"assigneeAgentId": analyst_id,
|
||||||
|
},
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
sub_issue = child_resp.json()
|
||||||
|
sub_issue_id = sub_issue["id"]
|
||||||
|
|
||||||
|
# Tag plugin_state so the case page surfaces this sub-issue too.
|
||||||
|
try:
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
await _link_case_to_issue(conn, sub_issue_id, case_number)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("plugin_state link failed for sub_issue=%s: %s", sub_issue_id, e)
|
||||||
|
|
||||||
|
wake_resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/agents/{analyst_id}/wakeup",
|
||||||
|
json={
|
||||||
|
"source": "on_demand",
|
||||||
|
"triggerDetail": "manual",
|
||||||
|
"reason": f"aggregate_arguments_{case_number}",
|
||||||
|
# "assignment" is the generic mutation the HEARTBEAT recognises;
|
||||||
|
# task intent lives in the child-issue description, not the payload.
|
||||||
|
"payload": {
|
||||||
|
"issueId": sub_issue_id,
|
||||||
|
"mutation": "assignment",
|
||||||
|
"caseNumber": case_number,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Analyst wakeup for argument aggregation on case %s: sub_issue=%s "
|
||||||
|
"analyst=%s force=%s wake=%s",
|
||||||
|
case_number, sub_issue_id, analyst_id, force, wake_resp.status_code,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "queued",
|
||||||
|
"sub_issue_id": sub_issue_id,
|
||||||
|
"analyst_id": analyst_id,
|
||||||
|
"main_issue_id": main_issue_id,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user