21 Commits

Author SHA1 Message Date
8db3bf6ddd docs: add changelog for hooks/jobs/sync improvements (2026-05-17) 2026-05-17 10:24:42 +00:00
a3468d5b2f fix: use timezone-aware datetime in webhook timestamp
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m17s
Replace deprecated datetime.utcnow() with datetime.now(timezone.utc)
to avoid Python 3.12+ DeprecationWarning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 10:15:52 +00:00
5f43659b5a fix: add defensive JSON parsing in check_instructions 2026-05-16 17:53:42 +00:00
86734da210 feat: add --check-instructions, pre-flight validation, and mtime tracking to sync script
- P3-T1: --check-instructions flag + check_instructions() prints a table of all
  agents' instructionsFilePath with status ( OK /  MISSING / ⚠ NOT SET),
  size, mtime, and ⚠ DRIFT when file has changed since last sync
- P3-T2: --apply now runs a pre-flight check on master agents and aborts if any
  instruction file is missing, before touching the DB or calling any API
- P3-T3: get_claude_md_mtime() helper; --apply stamps claude_md_mtime and
  claude_md_last_synced into each mirror agent's metadata via the PATCH call
- P3-T4: alias check-agents added to ~/.bashrc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 17:51:34 +00:00
82ded005a4 fix: add days>0 guard and limit param to stale/feedback endpoints
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
2026-05-16 17:38:34 +00:00
c7ed1110f8 feat: add /api/cases/stale and /api/chair-feedback/weekly-summary endpoints
GET /api/cases/stale?days=N — returns cases not updated in N days (default 3)
  that are not in 'final' or 'new' status, with days_stale count.
GET /api/chair-feedback/weekly-summary?days=N — returns chair feedback from
  the last N days (default 7) as a Hebrew bullet-list summary for CEO agent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 17:36:12 +00:00
015e553d06 fix: add debug log and null company_id comment to webhook scheduling
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 4m16s
2026-05-16 17:13:07 +00:00
6bdf9786ac feat: emit case-status webhook on status change in PUT /api/cases/:case 2026-05-16 17:10:30 +00:00
d87f9c5a5f fix: include case details in webhook failure warning log 2026-05-16 17:08:33 +00:00
a0fab1f6de feat: add emit_case_status_webhook helper 2026-05-16 17:06:37 +00:00
d5043100a7 fix: json.loads JSONB overrides on GET — asyncpg has no codec registered
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
asyncpg returns JSONB columns as raw JSON strings when no type codec is
configured (only pgvector is registered in _init_connection). The stored
value is a correct JSONB array (jsonb_typeof=array confirmed), but
asyncpg decodes it as str. Parse it explicitly in the GET handler so
the frontend receives the correct Python list/dict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:54:44 +00:00
932cc7191c fix: use ::text::jsonb to store methodology overrides correctly
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
asyncpg cannot encode a Python list as JSONB directly (expects str).
Passing str with ::jsonb causes double-encoding (stored as JSONB string).
Solution: json.dumps() the value → pass as text → PostgreSQL parses
with ::text::jsonb cast, storing it as the correct JSONB array/object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:38:05 +00:00
d983cfdd3b Merge pull request 'fix: prevent JSONB double-encoding on methodology save' (#6) from fix/methodology-jsonb-double-encoding into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m39s
2026-05-10 18:34:03 +00:00
50649baeed fix: prevent JSONB double-encoding on methodology save
Pass req.value directly to asyncpg instead of json.dumps(req.value).
When a Python string was passed with ::jsonb, asyncpg encoded it as a
JSONB string (not an array), causing the frontend spread operator to
split it into individual characters — one textarea per character.

Also fix typo in DISCUSSION_RULES default: "אסה" → "מאסה".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:30:49 +00:00
a9cd8aeb12 fix: prevent write_interim_draft context overflow (465K → ≤300K chars)
Two bugs caused all 5 interim blocks to fail with "Claude CLI failed
(exit 1): unknown error":

1. source_context was embedded BOTH inside the prompt template (via
   {source_context}) AND prepended again in write_block — doubling every
   block's context size (232K chars × 2 = 465K chars).

2. _build_source_context loaded all 9 case documents for every block
   regardless of relevance.

Fixes:
- Remove the duplicate source_context prepend in write_block; the
  template already contains it via {source_context}
- Add per-block document filtering (_BLOCK_DOC_TYPES): block-he/zayin →
  empty, block-chet → protocol only, block-tet → appraisals only
- Add 400K char guard before calling claude -p with a descriptive error
  (vs opaque "exit 1: unknown error")
- Add prompt-size warning and size info in claude_session error messages

Result: block-he 0 chars, block-zayin 0 chars, block-vav ~172K,
block-chet ~45K, block-tet ~300K (all under 400K limit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 10:49:47 +00:00
10a63fb9e0 fix(precedents): separate court rulings from committee decisions correctly
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m37s
- DB: add 'all_committees' virtual source_kind covering internal_committee
  + external_upload appeals_committee rows in one query
- DB: stats now count all case_law rows (not just external_upload),
  fixing the precedents_total that excluded 44 internal-committee records
- UI: courts table filters to source_type=court_ruling only;
  committees table uses the new all_committees query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:59:30 +00:00
f94201c577 feat(precedents): make citation link to detail page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 34s
Both CourtRow and CommitteeRow citation cells are now Next.js Links
→ /precedents/{id}, letting users navigate directly from the list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:01:26 +00:00
026457dac4 fix(precedent-edit): sync form from record without useEffect flash
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 36s
Replace useEffect-based form hydration with React's approved derived-state
pattern (setState-during-render). This eliminates the one-frame flash where
the precedent_level Select showed "—" before useEffect fired, and fixes
cases where the same record reference returned from TanStack cache caused
useEffect to not re-run after save+invalidate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:35:04 +00:00
75493ce233 Merge pull request 'feat: link related precedents across court instances (SCHEMA_V11)' (#4) from feat/related-precedents-v11 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m41s
Reviewed-on: #4
2026-05-10 07:54:37 +00:00
3e14cd6798 feat: link related precedents across court instances (SCHEMA_V11)
Add ability to mark case_law records as related (e.g. same appeal
through ועדת ערר → מנהלי → עליון):
- DB: case_law_relations join table (bidirectional, V11 migration)
- DB CRUD: add/remove/get_case_law_relations
- Service: get_precedent() now returns related_cases[]
- MCP: precedent_link_cases + precedent_unlink_cases tools
- REST: POST/DELETE /api/precedent-library/{id}/relations
- UI: RelatedCasesSection on detail page with search dialog and unlink

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 07:52:29 +00:00
13a8d9e58f Merge pull request 'feat(curator): switch Hermes Curator to DeepSeek V4-Pro via deepseek_local adapter' (#3) from feat/deepseek-curator-adapter into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m53s
2026-05-10 06:21:28 +00:00
16 changed files with 759 additions and 36 deletions

View File

@@ -0,0 +1,21 @@
# שינויים — legal-ai backend (2026-05-17)
## הוספת webhook emitter לסטטוס תיק
### `web/paperclip_api.py`
- נוספה `emit_case_status_webhook()` — fire-and-forget helper שמדווח ל-Paperclip plugin על שינוי סטטוס
- שימוש ב-`datetime.now(timezone.utc)` במקום `datetime.utcnow()` המיושן (תואם Python 3.12+)
### `web/app.py`
- `PUT /api/cases/{case_number}` — שולח webhook ב-BackgroundTask כשהסטטוס משתנה
- שומר `old_status` לפני העדכון → משווה עם `new_status` → מפעיל webhook רק אם שונה
- `GET /api/cases/stale?days=3` — מחזיר תיקים שלא עודכנו N+ ימים (לשימוש `stale-case-reminder` job)
- `GET /api/chair-feedback/weekly-summary?days=7` — מסכם פידבק יו"ר לשבוע אחרון (לשימוש `weekly-feedback-analysis` job)
## שינויים ב-sync script
### `scripts/sync_agents_across_companies.py`
- `--check-instructions`: מדפיס טבלה עם סטטוס הוראות לכל 14 הסוכנים (✅ מעודכן / DRIFT / ⚠ NOT SET)
- pre-flight validation לפני `--apply`: אם קובץ הוראות חסר → מבטל בעדינות
- מעקב `claude_md_mtime` + `claude_md_last_synced` ב-metadata של הסוכן
- alias: `check-agents` ב-`.bashrc`

View File

@@ -216,6 +216,22 @@ async def precedent_library_delete(case_law_id: str) -> str:
return await plib.precedent_library_delete(case_law_id) return await plib.precedent_library_delete(case_law_id)
@mcp.tool()
async def precedent_link_cases(
case_law_id_a: str,
case_law_id_b: str,
relation_type: str = "same_case_chain",
) -> str:
"""קישור שתי פסיקות כקשורות (דו-כיווני, idempotent). relation_type: same_case_chain | overruled_by | distinguished."""
return await plib.precedent_link_cases(case_law_id_a, case_law_id_b, relation_type)
@mcp.tool()
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
"""הסרת קישור בין שתי פסיקות (דו-כיווני)."""
return await plib.precedent_unlink_cases(case_law_id_a, case_law_id_b)
@mcp.tool() @mcp.tool()
async def precedent_extract_halachot(case_law_id: str) -> str: async def precedent_extract_halachot(case_law_id: str) -> str:
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review.""" """הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""

View File

@@ -360,13 +360,9 @@ async def write_block(
post_hearing_context=post_hearing_context, post_hearing_context=post_hearing_context,
) )
# Restructure: sources first, then instructions # source_context is already embedded inside formatted_prompt via {source_context} in the
prompt = ( # template. Do NOT prepend it again — doing so doubles the prompt size (was 465K chars).
f"## חומרי מקור (מסמכים מלאים — צטט מהם מילה במילה כשאפשר):\n\n" prompt = formatted_prompt
f"{source_context}\n\n"
f"---\n\n"
f"{formatted_prompt}"
)
if instructions: if instructions:
prompt += f"\n\n## הנחיות נוספות:\n{instructions}" prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
@@ -377,6 +373,19 @@ async def write_block(
if not dir_doc.get("approved"): if not dir_doc.get("approved"):
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.") raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
# Guard against context overflow before calling claude -p.
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
# In practice the CLI has crashed on prompts above ~400K chars, so use
# that as a conservative ceiling (well below the token limit).
_MAX_PROMPT_CHARS = 400_000
if len(prompt) > _MAX_PROMPT_CHARS:
raise RuntimeError(
f"Prompt too large for {block_id}: {len(prompt):,} chars "
f"(limit {_MAX_PROMPT_CHARS:,}). "
f"source_context: {len(source_context):,} chars. "
f"Reduce documents or call extract_appraiser_facts first."
)
# Call Claude via Claude Code session (no API) # Call Claude via Claude Code session (no API)
model_key = block_cfg["model"] model_key = block_cfg["model"]
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
@@ -414,16 +423,35 @@ def _build_case_context(case: dict, decision: dict | None) -> str:
- תוצאה: {outcome_heb}""" - תוצאה: {outcome_heb}"""
# Which doc_types are relevant per block.
# None → skip source docs entirely (block uses other context, e.g. claims_context)
# [] → include all doc types (default for unspecified blocks)
# [..] → include only the listed doc_type values
_BLOCK_DOC_TYPES: dict[str, list[str] | None] = {
"block-he": None, # only case_context needed; no full docs
"block-vav": ["appeal", "protocol"], # כתב ערר + פרוטוקול ועדה
"block-zayin": None, # claims_context is sufficient
"block-chet": ["protocol"], # פרוטוקול + השלמות טיעון
"block-tet": ["appraisal"], # שומות בלבד
# block-yod, block-yod-alef, block-he etc. default → all docs
}
async def _build_source_context(case_id: UUID, block_id: str) -> str: async def _build_source_context(case_id: UUID, block_id: str) -> str:
"""Get full document texts for the block. """Get document texts for the block, filtered by relevance.
Per Anthropic best practices: send full source documents, not truncated excerpts. Per Anthropic best practices: send full source documents, not truncated excerpts.
Place documents at the TOP of the prompt (before instructions) for 30% better recall. Per-block filtering prevents context overflow on large cases (9+ docs).
For grounding: instruct Claude to cite word-for-word from these documents.
""" """
allowed = _BLOCK_DOC_TYPES.get(block_id, []) # [] sentinel = not in map → all docs
if allowed is None:
return "" # this block doesn't need raw source docs
docs = await db.list_documents(case_id) docs = await db.list_documents(case_id)
context_parts = [] context_parts = []
for doc in docs: for doc in docs:
if allowed and doc["doc_type"] not in allowed:
continue
text = await db.get_document_text(UUID(doc["id"])) text = await db.get_document_text(UUID(doc["id"]))
if text: if text:
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}") context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")

View File

@@ -72,6 +72,9 @@ async def query(
""" """
full_prompt = f"{system}\n\n{prompt}" if system else prompt full_prompt = f"{system}\n\n{prompt}" if system else prompt
if len(full_prompt) > 150_000:
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
cmd = [ cmd = [
"claude", "-p", "claude", "-p",
"--output-format", "json", "--output-format", "json",
@@ -110,7 +113,8 @@ async def query(
if proc.returncode != 0: if proc.returncode != 0:
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error" stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}") size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}{size_info}")
stdout = stdout_b.decode("utf-8", errors="replace").strip() stdout = stdout_b.decode("utf-8", errors="replace").strip()
if not stdout: if not stdout:

View File

@@ -700,6 +700,20 @@ CREATE INDEX IF NOT EXISTS idx_case_law_chair ON case_law(chair_name) WHERE chai
CREATE INDEX IF NOT EXISTS idx_case_law_district ON case_law(district) WHERE district <> ''; CREATE INDEX IF NOT EXISTS idx_case_law_district ON case_law(district) WHERE district <> '';
""" """
SCHEMA_V11_SQL = """
CREATE TABLE IF NOT EXISTS case_law_relations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_law_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
related_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
relation_type TEXT NOT NULL DEFAULT 'same_case_chain',
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(case_law_id, related_id),
CHECK (case_law_id <> related_id)
);
CREATE INDEX IF NOT EXISTS idx_clr_a ON case_law_relations(case_law_id);
CREATE INDEX IF NOT EXISTS idx_clr_b ON case_law_relations(related_id);
"""
async def _run_schema_migrations(pool: asyncpg.Pool) -> None: async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
async with pool.acquire() as conn: async with pool.acquire() as conn:
@@ -714,7 +728,8 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
await conn.execute(SCHEMA_V8_SQL) await conn.execute(SCHEMA_V8_SQL)
await conn.execute(SCHEMA_V9_SQL) await conn.execute(SCHEMA_V9_SQL)
await conn.execute(SCHEMA_V10_SQL) await conn.execute(SCHEMA_V10_SQL)
logger.info("Database schema initialized (v1-v10)") await conn.execute(SCHEMA_V11_SQL)
logger.info("Database schema initialized (v1-v11)")
async def init_schema() -> None: async def init_schema() -> None:
@@ -1735,6 +1750,59 @@ async def get_case_law(case_law_id: UUID) -> dict | None:
return _row_to_case_law(row) if row else None return _row_to_case_law(row) if row else None
async def add_case_law_relation(
a_id: UUID, b_id: UUID, relation_type: str = "same_case_chain"
) -> None:
"""Link two case_law records bidirectionally. Idempotent (ON CONFLICT DO NOTHING)."""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.executemany(
"""
INSERT INTO case_law_relations(case_law_id, related_id, relation_type)
VALUES($1, $2, $3)
ON CONFLICT (case_law_id, related_id) DO NOTHING
""",
[(a_id, b_id, relation_type), (b_id, a_id, relation_type)],
)
async def remove_case_law_relation(a_id: UUID, b_id: UUID) -> None:
"""Remove a bidirectional link between two case_law records."""
pool = await get_pool()
await pool.execute(
"""
DELETE FROM case_law_relations
WHERE (case_law_id = $1 AND related_id = $2)
OR (case_law_id = $2 AND related_id = $1)
""",
a_id,
b_id,
)
async def get_case_law_relations(case_law_id: UUID) -> list[dict]:
"""Return all case_law records linked to case_law_id, ordered by date asc."""
pool = await get_pool()
rows = await pool.fetch(
"""
SELECT cl.*, r.relation_type
FROM case_law_relations r
JOIN case_law cl ON cl.id = r.related_id
WHERE r.case_law_id = $1
ORDER BY cl.date ASC NULLS LAST
""",
case_law_id,
)
results = []
for row in rows:
d = dict(row)
relation_type = d.pop("relation_type")
normalized = _row_to_case_law(d)
normalized["relation_type"] = relation_type
results.append(normalized)
return results
async def get_case_law_by_citation(case_number: str) -> dict | None: async def get_case_law_by_citation(case_number: str) -> dict | None:
pool = await get_pool() pool = await get_pool()
row = await pool.fetchrow( row = await pool.fetchrow(
@@ -1984,9 +2052,19 @@ async def list_external_case_law(
offset: int = 0, offset: int = 0,
source_kind: str = "external_upload", source_kind: str = "external_upload",
) -> list[dict]: ) -> list[dict]:
"""List chair-uploaded precedents, with simple filters.""" """List chair-uploaded precedents, with simple filters.
source_kind="all_committees" expands to: source_kind='internal_committee'
OR (source_kind='external_upload' AND source_type='appeals_committee').
"""
pool = await get_pool() pool = await get_pool()
conditions = [f"source_kind = '{source_kind}'"] if source_kind == "all_committees":
conditions = [
"(source_kind = 'internal_committee' OR "
"(source_kind = 'external_upload' AND source_type = 'appeals_committee'))"
]
else:
conditions = [f"source_kind = '{source_kind}'"]
params: list = [] params: list = []
idx = 1 idx = 1
if practice_area: if practice_area:
@@ -2420,19 +2498,17 @@ async def precedent_library_stats() -> dict:
pool = await get_pool() pool = await get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
total = await conn.fetchval( total = await conn.fetchval(
"SELECT COUNT(*) FROM case_law WHERE source_kind = 'external_upload'" "SELECT COUNT(*) FROM case_law"
) )
by_practice = await conn.fetch( by_practice = await conn.fetch(
"""SELECT practice_area, COUNT(*) AS n """SELECT practice_area, COUNT(*) AS n
FROM case_law FROM case_law
WHERE source_kind = 'external_upload'
GROUP BY practice_area GROUP BY practice_area
ORDER BY n DESC""" ORDER BY n DESC"""
) )
by_level = await conn.fetch( by_level = await conn.fetch(
"""SELECT precedent_level, COUNT(*) AS n """SELECT precedent_level, COUNT(*) AS n
FROM case_law FROM case_law
WHERE source_kind = 'external_upload'
GROUP BY precedent_level GROUP BY precedent_level
ORDER BY n DESC""" ORDER BY n DESC"""
) )

View File

@@ -123,7 +123,7 @@ SUMMARY_STRATEGIES = {
DISCUSSION_RULES: dict[str, list[str]] = { DISCUSSION_RULES: dict[str, list[str]] = {
"universal": [ "universal": [
"פרק הדיון = אסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.", "פרק הדיון = מאסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).", "חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.", "טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
], ],

View File

@@ -438,13 +438,14 @@ async def delete_precedent(case_law_id: UUID | str) -> bool:
async def get_precedent(case_law_id: UUID | str) -> dict | None: async def get_precedent(case_law_id: UUID | str) -> dict | None:
"""Get a precedent with its halachot attached.""" """Get a precedent with its halachot and related cases attached."""
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)
record = await db.get_case_law(case_law_id) record = await db.get_case_law(case_law_id)
if not record: if not record:
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)
return record return record

View File

@@ -116,6 +116,54 @@ async def precedent_library_get(case_law_id: str) -> str:
return _ok(record) return _ok(record)
async def precedent_link_cases(
case_law_id_a: str,
case_law_id_b: str,
relation_type: str = "same_case_chain",
) -> str:
"""קישור שתי פסיקות כקשורות זו לזו (דו-כיווני). idempotent.
Args:
case_law_id_a: UUID של פסיקה ראשונה.
case_law_id_b: UUID של פסיקה שנייה.
relation_type: same_case_chain | overruled_by | distinguished
"""
try:
a = UUID(case_law_id_a)
b = UUID(case_law_id_b)
except ValueError:
return _err("case_law_id לא תקין")
rec_a = await db.get_case_law(a)
rec_b = await db.get_case_law(b)
if not rec_a:
return _err(f"פסיקה {case_law_id_a} לא נמצאה")
if not rec_b:
return _err(f"פסיקה {case_law_id_b} לא נמצאה")
await db.add_case_law_relation(a, b, relation_type)
return _ok({
"linked": True,
"relation_type": relation_type,
"a": {"id": case_law_id_a, "case_number": rec_a.get("case_number"), "court": rec_a.get("court")},
"b": {"id": case_law_id_b, "case_number": rec_b.get("case_number"), "court": rec_b.get("court")},
})
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
"""הסרת קישור בין שתי פסיקות (דו-כיווני).
Args:
case_law_id_a: UUID של פסיקה ראשונה.
case_law_id_b: UUID של פסיקה שנייה.
"""
try:
a = UUID(case_law_id_a)
b = UUID(case_law_id_b)
except ValueError:
return _err("case_law_id לא תקין")
await db.remove_case_law_relation(a, b)
return _ok({"unlinked": True, "a": case_law_id_a, "b": case_law_id_b})
async def precedent_library_delete(case_law_id: str) -> str: async def precedent_library_delete(case_law_id: str) -> str:
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot.""" """מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
try: try:

View File

@@ -259,6 +259,14 @@ async def apply_diff(mirror_id: str, agent_name: str, diff: dict) -> list[str]:
if "runtime_config" in diff: if "runtime_config" in diff:
patch_body["runtimeConfig"] = diff["runtime_config"]["to"] patch_body["runtimeConfig"] = diff["runtime_config"]["to"]
# Stamp claude_md_mtime + last_synced into metadata
mtime = diff.get("_claude_md_mtime")
if mtime:
current_meta = dict(patch_body.get("metadata") or {})
current_meta["claude_md_mtime"] = mtime
current_meta["claude_md_last_synced"] = datetime.now(timezone.utc).isoformat()
patch_body["metadata"] = current_meta
if patch_body: if patch_body:
status, data = await call_patch(mirror_id, patch_body) status, data = await call_patch(mirror_id, patch_body)
if status >= 400: if status >= 400:
@@ -278,12 +286,73 @@ async def apply_diff(mirror_id: str, agent_name: str, diff: dict) -> list[str]:
return errors return errors
def get_claude_md_mtime(adapter_config: dict) -> str | None:
"""Return Unix mtime of the agent's instructionsFilePath, or None if file missing."""
path = adapter_config.get("instructionsFilePath", "")
if not path or not os.path.exists(path):
return None
return str(int(os.path.getmtime(path)))
async def check_instructions(agents: list[dict]) -> bool:
"""Print a report of all agents' instruction files. Returns True if all OK."""
from datetime import datetime
all_ok = True
print(f"\n{'Agent':<30} {'File':<55} {'Status':<12} {'Size':>7} {'Modified'}")
print("-" * 115)
for agent in agents:
name = (agent.get("name") or agent.get("id") or "?")[:29]
try:
adapter_cfg = agent.get("adapter_config") or {}
if isinstance(adapter_cfg, str):
adapter_cfg = json.loads(adapter_cfg)
except (json.JSONDecodeError, TypeError):
print(f"{name:<30} {'(malformed adapter_config in DB)':<55} {'⚠ ERROR':<12}")
continue
file_path = adapter_cfg.get("instructionsFilePath", "")
if not file_path:
print(f"{name:<30} {'(none)':<55} {'⚠ NOT SET':<12}")
continue
if not os.path.exists(file_path):
print(f"{name:<30} {file_path[-54:]:<55} {'❌ MISSING':<12}")
all_ok = False
continue
stat = os.stat(file_path)
size_kb = stat.st_size // 1024
mtime = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
# Check for drift vs DB metadata
try:
metadata = agent.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
except (json.JSONDecodeError, TypeError):
metadata = {}
db_mtime = metadata.get("claude_md_mtime", "")
actual_mtime = str(int(stat.st_mtime))
drift = " ⚠ DRIFT" if db_mtime and db_mtime != actual_mtime else ""
print(f"{name:<30} {file_path[-54:]:<55} {'✅ OK':<12} {size_kb:>5}KB {mtime}{drift}")
print()
return all_ok
async def main() -> None: async def main() -> None:
p = argparse.ArgumentParser() p = argparse.ArgumentParser()
g = p.add_mutually_exclusive_group(required=True) g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--verify", action="store_true", help="Show current drift, no changes") g.add_argument("--verify", action="store_true", help="Show current drift, no changes")
g.add_argument("--dry-run", action="store_true", help="Show what would change") g.add_argument("--dry-run", action="store_true", help="Show what would change")
g.add_argument("--apply", action="store_true", help="Backup + apply changes") g.add_argument("--apply", action="store_true", help="Backup + apply changes")
g.add_argument("--check-instructions", action="store_true",
help="Scan all agents' instructionsFilePath and report missing/outdated files")
p.add_argument("--only", help="Sync only the named agent (e.g., 'עוזר משפטי')") p.add_argument("--only", help="Sync only the named agent (e.g., 'עוזר משפטי')")
args = p.parse_args() args = p.parse_args()
@@ -295,6 +364,11 @@ async def main() -> None:
finally: finally:
await conn.close() await conn.close()
if args.check_instructions:
all_agents = master_agents + mirror_agents
all_ok = await check_instructions(all_agents)
sys.exit(0 if all_ok else 1)
mirror_by_name = {a["name"]: a for a in mirror_agents} mirror_by_name = {a["name"]: a for a in mirror_agents}
print(f"\n=== Master (CMP, 1xxx): {len(master_agents)} agents ===") print(f"\n=== Master (CMP, 1xxx): {len(master_agents)} agents ===")
@@ -332,6 +406,14 @@ async def main() -> None:
return return
# APPLY # APPLY
# Pre-flight: abort if any master agent is missing its instructions file
print("🔍 Pre-flight: checking instruction files...")
all_ok = await check_instructions(master_agents)
if not all_ok:
print("❌ Abort: one or more instruction files are missing. Fix before --apply.")
sys.exit(1)
print("✅ Pre-flight passed.\n")
print(f"\n=== Backup ===") print(f"\n=== Backup ===")
backup_path = backup_agents_table() backup_path = backup_agents_table()
print(f"{backup_path}") print(f"{backup_path}")
@@ -340,6 +422,11 @@ async def main() -> None:
all_errors: list[str] = [] all_errors: list[str] = []
for master, mirror, diff in plan: for master, mirror, diff in plan:
print(f"\n{master['name']} ({mirror['id']})") print(f"\n{master['name']} ({mirror['id']})")
# Inject mtime into diff so apply_diff can stamp metadata
master_ac = master.get("adapter_config") or {}
mtime = get_claude_md_mtime(master_ac)
if mtime:
diff["_claude_md_mtime"] = mtime
errors = await apply_diff(mirror["id"], master["name"], diff) errors = await apply_diff(mirror["id"], master["name"], diff)
if errors: if errors:
for e in errors: for e in errors:

View File

@@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { usePrecedent } from "@/lib/api/precedent-library"; import { usePrecedent } from "@/lib/api/precedent-library";
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet"; import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot"; import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
const PRACTICE_AREA_LABELS: Record<string, string> = { const PRACTICE_AREA_LABELS: Record<string, string> = {
rishuy_uvniya: "רישוי ובנייה", rishuy_uvniya: "רישוי ובנייה",
@@ -152,6 +153,15 @@ export default function PrecedentDetailPage({
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<RelatedCasesSection
caseId={id}
related={data.related_cases ?? []}
/>
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm"> <Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5"> <CardContent className="px-6 py-5">
<ExtractedHalachotSection halachot={data.halachot ?? []} /> <ExtractedHalachotSection halachot={data.halachot ?? []} />

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link";
import { Trash2, Plus, Pencil, Wand2 } from "lucide-react"; import { Trash2, Plus, Pencil, Wand2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -151,7 +152,9 @@ function CourtRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void })
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3" className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3"
dir="rtl" dir="rtl"
> >
<span dir="auto">{cleanCitation(p.case_number)}</span> <Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
{cleanCitation(p.case_number)}
</Link>
</TableCell> </TableCell>
<TableCell className="text-ink whitespace-normal break-words max-w-[260px] py-3"> <TableCell className="text-ink whitespace-normal break-words max-w-[260px] py-3">
<div className="font-medium">{cleanCitation(p.case_name)}</div> <div className="font-medium">{cleanCitation(p.case_name)}</div>
@@ -233,7 +236,9 @@ function CommitteeRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => voi
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[200px] max-w-[320px] py-3" className="font-semibold text-navy text-right whitespace-normal break-words min-w-[200px] max-w-[320px] py-3"
dir="rtl" dir="rtl"
> >
<span dir="auto">{cleanCitation(p.case_number)}</span> <Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
{cleanCitation(p.case_number)}
</Link>
</TableCell> </TableCell>
<TableCell className="text-ink whitespace-normal break-words max-w-[220px] py-3"> <TableCell className="text-ink whitespace-normal break-words max-w-[220px] py-3">
<div className="font-medium">{cleanCitation(p.case_name)}</div> <div className="font-medium">{cleanCitation(p.case_name)}</div>
@@ -308,8 +313,8 @@ export function LibraryListPanel() {
limit: 200, limit: 200,
}; };
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload" }); const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload", sourceType: "court_ruling" });
const committee = usePrecedents({ ...sharedFilters, sourceKind: "internal_committee" }); const committee = usePrecedents({ ...sharedFilters, sourceKind: "all_committees" });
return ( return (
<div className="space-y-8"> <div className="space-y-8">

View File

@@ -0,0 +1,222 @@
"use client";
import { useState } from "react";
import { Link2, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
usePrecedents,
useLinkRelatedCase,
useUnlinkRelatedCase,
RelatedCase,
} from "@/lib/api/precedent-library";
const LEVEL_LABELS: Record<string, string> = {
"עליון": "עליון",
"מנהלי": "מנהלי",
"ועדת_ערר_ארצית": "ארצי",
"ועדת_ערר_מחוזית": "מחוזי",
};
const LEVEL_COLORS: Record<string, string> = {
"עליון": "bg-red-50 text-red-700 border-red-200",
"מנהלי": "bg-orange-50 text-orange-700 border-orange-200",
"ועדת_ערר_ארצית": "bg-blue-50 text-blue-700 border-blue-200",
"ועדת_ערר_מחוזית": "bg-green-50 text-green-700 border-green-200",
};
// ── Search Dialog ────────────────────────────────────────────────────
type DialogProps = {
caseId: string;
currentRelated: RelatedCase[];
open: boolean;
onOpenChange: (open: boolean) => void;
};
function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps) {
const [query, setQuery] = useState("");
const { mutateAsync: linkCase, isPending } = useLinkRelatedCase(caseId);
const alreadyLinkedIds = new Set([...currentRelated.map((r) => r.id), caseId]);
const { data, isPending: searching } = usePrecedents(
query.length >= 2 ? { search: query, limit: 10 } : {},
);
const candidates = (data?.items ?? []).filter((p) => !alreadyLinkedIds.has(p.id));
async function handleLink(relatedId: string) {
try {
await linkCase({ relatedId });
toast.success("הפסיקות קושרו");
setQuery("");
} catch {
toast.error("שגיאה בקישור");
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg" dir="rtl">
<DialogHeader>
<DialogTitle className="text-navy">קשר החלטה קשורה</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Input
placeholder="חפש לפי מספר תיק, שם, ערכאה..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
dir="rtl"
/>
{query.length >= 2 && (
<div className="space-y-1 max-h-72 overflow-y-auto">
{searching ? (
<div className="flex items-center gap-2 text-ink-muted text-sm py-3">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> מחפש...
</div>
) : candidates.length === 0 ? (
<p className="text-ink-muted text-sm py-3 text-center">לא נמצאו תוצאות</p>
) : (
candidates.map((p) => (
<button
key={p.id}
onClick={() => handleLink(p.id)}
disabled={isPending}
className="w-full text-right flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule hover:bg-surface/60 transition-colors disabled:opacity-50"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-navy truncate">
{p.case_name || p.case_number}
</div>
<div className="text-[0.72rem] text-ink-muted font-mono" dir="ltr">
{p.case_number}
</div>
</div>
{p.precedent_level && (
<Badge
variant="outline"
className={`text-[0.62rem] shrink-0 ${LEVEL_COLORS[p.precedent_level] ?? ""}`}
>
{LEVEL_LABELS[p.precedent_level] ?? p.precedent_level}
</Badge>
)}
</button>
))
)}
</div>
)}
{query.length > 0 && query.length < 2 && (
<p className="text-ink-muted text-xs">הקלד לפחות 2 תווים</p>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ── Related Case Card ────────────────────────────────────────────────
function RelatedCaseCard({ caseId, related }: { caseId: string; related: RelatedCase }) {
const { mutateAsync: unlinkCase, isPending } = useUnlinkRelatedCase(caseId);
async function handleUnlink() {
try {
await unlinkCase(related.id);
toast.success("הקישור הוסר");
} catch {
toast.error("שגיאה בהסרת הקישור");
}
}
return (
<div className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule bg-surface">
<a
href={`/precedents/${related.id}`}
className="min-w-0 flex-1 hover:opacity-80 transition-opacity"
>
<div className="text-sm font-medium text-navy truncate">
{related.case_name || related.case_number}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{related.precedent_level && (
<Badge
variant="outline"
className={`text-[0.62rem] ${LEVEL_COLORS[related.precedent_level] ?? ""}`}
>
{LEVEL_LABELS[related.precedent_level] ?? related.precedent_level}
</Badge>
)}
{related.court && (
<span className="text-[0.7rem] text-ink-muted truncate">{related.court}</span>
)}
{related.date && (
<span className="text-[0.7rem] text-ink-muted tabular-nums" dir="ltr">
{related.date.slice(0, 10)}
</span>
)}
</div>
</a>
<button
onClick={handleUnlink}
disabled={isPending}
className="p-1 rounded hover:bg-danger-bg hover:text-danger transition-colors text-ink-muted disabled:opacity-40 shrink-0"
title="הסר קישור"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
// ── Public section component ─────────────────────────────────────────
type SectionProps = {
caseId: string;
related: RelatedCase[];
};
export function RelatedCasesSection({ caseId, related }: SectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-navy text-sm font-semibold">
החלטות קשורות{related.length > 0 ? ` (${related.length})` : ""}
</h3>
<Button variant="outline" size="sm" onClick={() => setDialogOpen(true)}>
<Link2 className="w-3.5 h-3.5 me-1" /> קשר החלטה
</Button>
</div>
{related.length === 0 ? (
<p className="text-ink-muted text-sm">אין החלטות קשורות עדיין</p>
) : (
<div className="space-y-1.5">
{related.map((r) => (
<RelatedCaseCard key={r.id} caseId={caseId} related={r} />
))}
</div>
)}
<LinkDialog
caseId={caseId}
currentRelated={related}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { Save, Sparkles } from "lucide-react"; import { Save, Sparkles } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -65,10 +65,12 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
const [form, setForm] = useState<FormState>(EMPTY); const [form, setForm] = useState<FormState>(EMPTY);
// Hydrate form when the record loads. // React-approved derived-state pattern: sync form whenever a different
useEffect(() => { // record arrives (including after save+refetch). Using setState during
if (!record) return; // render avoids the one-frame flash that useEffect would produce.
// eslint-disable-next-line react-hooks/set-state-in-effect const [syncedRecordId, setSyncedRecordId] = useState<string | null>(null);
if (record && record.id !== syncedRecordId) {
setSyncedRecordId(record.id as string);
setForm({ setForm({
citation: record.case_number || "", citation: record.case_number || "",
case_name: record.case_name || "", case_name: record.case_name || "",
@@ -84,7 +86,7 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
headnote: record.headnote || "", headnote: record.headnote || "",
key_quote: (record as { key_quote?: string }).key_quote || "", key_quote: (record as { key_quote?: string }).key_quote || "",
}); });
}, [record]); }
const onSubmit = async (e: React.FormEvent) => { const onSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();

View File

@@ -84,9 +84,20 @@ export type Halacha = {
precedent_level?: string; precedent_level?: string;
}; };
export type RelatedCase = {
id: string;
case_number: string;
case_name: string;
court: string;
precedent_level: string;
date: string | null;
relation_type: string;
};
export type PrecedentDetail = Precedent & { export type PrecedentDetail = Precedent & {
full_text: string; full_text: string;
halachot: Halacha[]; halachot: Halacha[];
related_cases: RelatedCase[];
}; };
export type SearchHit = export type SearchHit =
@@ -357,6 +368,40 @@ export function useDeletePrecedent() {
}); });
} }
export function useLinkRelatedCase(caseId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { relatedId: string; relationType?: string }) =>
apiRequest<{ linked: boolean }>(
`/api/precedent-library/${encodeURIComponent(caseId)}/relations`,
{
method: "POST",
body: {
related_id: vars.relatedId,
relation_type: vars.relationType ?? "same_case_chain",
},
},
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
},
});
}
export function useUnlinkRelatedCase(caseId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (relatedId: string) =>
apiRequest<{ unlinked: boolean }>(
`/api/precedent-library/${encodeURIComponent(caseId)}/relations/${encodeURIComponent(relatedId)}`,
{ method: "DELETE" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
},
});
}
export type PrecedentPatch = Partial<{ export type PrecedentPatch = Partial<{
case_name: string; case_name: string;
court: string; court: string;

View File

@@ -20,7 +20,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "
import zipfile import zipfile
from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel from pydantic import BaseModel
@@ -44,7 +44,7 @@ from web.mcp_env_catalog import (
normalize_for_compare, normalize_for_compare,
) )
from web.progress_store import ProgressStore from web.progress_store import ProgressStore
from web.paperclip_api import pc_request from web.paperclip_api import emit_case_status_webhook, pc_request
from web.paperclip_client import ( from web.paperclip_client import (
COMPANIES as PAPERCLIP_COMPANIES, COMPANIES as PAPERCLIP_COMPANIES,
accept_interaction as pc_accept_interaction, accept_interaction as pc_accept_interaction,
@@ -1135,6 +1135,36 @@ async def list_cases(
return result return result
@app.get("/api/cases/stale")
async def api_stale_cases(days: int = 3):
"""Return cases that haven't been updated in N days and are not in 'final' or 'new' status."""
if days <= 0:
return {"cases": [], "total": 0}
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT case_number, title, status,
EXTRACT(DAY FROM (now() - updated_at))::int AS days_stale
FROM cases
WHERE status NOT IN ('final', 'new')
AND updated_at < now() - make_interval(days => $1)
ORDER BY updated_at ASC -- oldest stale first (longest overdue = highest priority)
""",
days,
)
cases = [
{
"case_number": r["case_number"],
"title": r["title"],
"status": r["status"],
"days_stale": r["days_stale"],
}
for r in rows
]
return {"cases": cases, "total": len(cases)}
@app.post("/api/cases/{case_number}/archive") @app.post("/api/cases/{case_number}/archive")
async def api_archive_case(case_number: str): async def api_archive_case(case_number: str):
"""Move a case to the archive. Also archives the matching Paperclip project.""" """Move a case to the archive. Also archives the matching Paperclip project."""
@@ -1337,8 +1367,12 @@ async def api_case_get(case_number: str):
@app.put("/api/cases/{case_number}") @app.put("/api/cases/{case_number}")
async def api_case_update(case_number: str, req: CaseUpdateRequest): async def api_case_update(case_number: str, req: CaseUpdateRequest, background_tasks: BackgroundTasks):
"""Update case details.""" """Update case details."""
# Capture old status before the update so we can detect changes.
existing = await db.get_case_by_number(case_number)
old_status = (existing or {}).get("status", "")
result = await cases_tools.case_update( result = await cases_tools.case_update(
case_number=case_number, case_number=case_number,
status=req.status, status=req.status,
@@ -1351,10 +1385,30 @@ async def api_case_update(case_number: str, req: CaseUpdateRequest):
expected_outcome=req.expected_outcome, expected_outcome=req.expected_outcome,
) )
try: try:
return json.loads(result) parsed = json.loads(result)
except json.JSONDecodeError: except json.JSONDecodeError:
raise HTTPException(404, result) raise HTTPException(404, result)
# Emit webhook when status changes (fire-and-forget via BackgroundTasks).
new_status = req.status
if new_status and old_status != new_status:
prefix = case_number[:1]
company_id = (
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
else None
)
background_tasks.add_task(
emit_case_status_webhook,
case_number=case_number,
old_status=old_status,
new_status=new_status,
company_id=company_id, # None is safe — plugin handles unknown company gracefully
)
logger.debug("webhook scheduled: case %s %s%s", case_number, old_status, new_status)
return parsed
@app.delete("/api/cases") @app.delete("/api/cases")
async def api_case_delete(case_number: str, remove_files: bool = False): async def api_case_delete(case_number: str, remove_files: bool = False):
@@ -3057,8 +3111,16 @@ async def api_get_methodology(category: str):
items = {} items = {}
for key, default_val in defaults.items(): for key, default_val in defaults.items():
if key in overrides: if key in overrides:
raw = overrides[key]["rule_value"]
# asyncpg returns JSONB as a raw JSON string when no codec is registered.
# Parse it back to a Python object so the frontend receives the correct type.
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (json.JSONDecodeError, TypeError):
pass
items[key] = { items[key] = {
"value": overrides[key]["rule_value"], "value": raw,
"is_override": True, "is_override": True,
"updated_at": overrides[key]["created_at"].isoformat() if overrides[key]["created_at"] else None, "updated_at": overrides[key]["created_at"].isoformat() if overrides[key]["created_at"] else None,
} }
@@ -3095,10 +3157,14 @@ async def api_update_methodology(category: str, key: str, req: MethodologyUpdate
raise HTTPException(422, "content_checklists value must be a non-empty string") raise HTTPException(422, "content_checklists value must be a non-empty string")
pool = await db.get_pool() pool = await db.get_pool()
# json.dumps → text, then PostgreSQL casts text→jsonb.
# Passing a Python list directly causes "expected str, got list" in asyncpg;
# passing a str with ::jsonb causes double-encoding (stored as JSONB string).
# ::text::jsonb bypasses asyncpg's codec and lets PostgreSQL parse the JSON.
await pool.execute( await pool.execute(
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) " "INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::jsonb) " "VALUES (gen_random_uuid(), '_global', $1, $2, $3::text::jsonb) "
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::jsonb", "ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::text::jsonb",
category, key, json.dumps(req.value, ensure_ascii=False), category, key, json.dumps(req.value, ensure_ascii=False),
) )
@@ -3979,6 +4045,34 @@ async def api_resolve_feedback(feedback_id: str, body: dict):
return {"status": "resolved"} return {"status": "resolved"}
@app.get("/api/chair-feedback/weekly-summary")
async def api_chair_feedback_weekly_summary(days: int = 7, limit: int = 100):
"""Return chair feedback from the last N days as a text summary for the CEO agent."""
if days <= 0:
return {"summary": "", "entry_count": 0}
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT cf.feedback_text, c.case_number, c.title
FROM chair_feedback cf
LEFT JOIN cases c ON c.id = cf.case_id
WHERE cf.created_at >= now() - make_interval(days => $1)
ORDER BY cf.created_at DESC
LIMIT $2
""",
days,
limit,
)
if not rows:
return {"summary": "", "entry_count": 0}
lines = [
f"- תיק {r['case_number'] or ''} ({r['title'] or ''}): {r['feedback_text']}"
for r in rows
]
return {"summary": "\n".join(lines), "entry_count": len(rows)}
# ── Background Processing ───────────────────────────────────────── # ── Background Processing ─────────────────────────────────────────
@@ -4380,6 +4474,37 @@ async def precedent_library_delete(case_law_id: str):
return {"deleted": True, "case_law_id": case_law_id} return {"deleted": True, "case_law_id": case_law_id}
class PrecedentRelationRequest(BaseModel):
related_id: str
relation_type: str = "same_case_chain"
@app.post("/api/precedent-library/{case_law_id}/relations")
async def precedent_add_relation(case_law_id: str, req: PrecedentRelationRequest):
try:
a = UUID(case_law_id)
b = UUID(req.related_id)
except ValueError:
raise HTTPException(400, "case_law_id לא תקין")
if not await db.get_case_law(a):
raise HTTPException(404, "פסיקה לא נמצאה")
if not await db.get_case_law(b):
raise HTTPException(404, f"פסיקה קשורה {req.related_id} לא נמצאה")
await db.add_case_law_relation(a, b, req.relation_type)
return {"linked": True, "case_law_id": case_law_id, "related_id": req.related_id}
@app.delete("/api/precedent-library/{case_law_id}/relations/{related_id}")
async def precedent_remove_relation(case_law_id: str, related_id: str):
try:
a = UUID(case_law_id)
b = UUID(related_id)
except ValueError:
raise HTTPException(400, "case_law_id לא תקין")
await db.remove_case_law_relation(a, b)
return {"unlinked": True, "case_law_id": case_law_id, "related_id": related_id}
# Halacha and metadata extraction are LLM-driven and rely on the local # Halacha and metadata extraction are LLM-driven and rely on the local
# `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run # `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run
# from this container (no CLI, no claude.ai session). The endpoints below # from this container (no CLI, no claude.ai session). The endpoints below

View File

@@ -19,6 +19,7 @@ from __future__ import annotations
import logging import logging
import os import os
from datetime import datetime, timezone
from typing import Any from typing import Any
import httpx import httpx
@@ -81,3 +82,35 @@ async def pc_request(
if raise_on_error: if raise_on_error:
resp.raise_for_status() resp.raise_for_status()
return resp return resp
async def emit_case_status_webhook(
case_number: str,
old_status: str,
new_status: str,
company_id: str | None = None,
run_id: str | None = None,
) -> None:
"""Notify the Paperclip plugin that a case status changed.
Fire-and-forget: logs errors but never raises, so callers aren't blocked.
"""
try:
await pc_request(
"POST",
"/api/plugins/marcusgroup.legal-ai/webhooks/case-status",
json={
"caseNumber": case_number,
"oldStatus": old_status,
"newStatus": new_status,
"companyId": company_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
run_id=run_id,
timeout=5.0,
)
except Exception as exc:
logger.warning(
"emit_case_status_webhook failed for case %s (%s%s): %s",
case_number, old_status, new_status, exc,
)