Compare commits
19 Commits
feat/relat
...
docs/hooks
| Author | SHA1 | Date | |
|---|---|---|---|
| 8db3bf6ddd | |||
| a3468d5b2f | |||
| 5f43659b5a | |||
| 86734da210 | |||
| 82ded005a4 | |||
| c7ed1110f8 | |||
| 015e553d06 | |||
| 6bdf9786ac | |||
| d87f9c5a5f | |||
| a0fab1f6de | |||
| d5043100a7 | |||
| 932cc7191c | |||
| d983cfdd3b | |||
| 50649baeed | |||
| a9cd8aeb12 | |||
| 10a63fb9e0 | |||
| f94201c577 | |||
| 026457dac4 | |||
| 75493ce233 |
21
docs/changelog-2026-05-hooks-jobs.md
Normal file
21
docs/changelog-2026-05-hooks-jobs.md
Normal 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`
|
||||
@@ -360,13 +360,9 @@ async def write_block(
|
||||
post_hearing_context=post_hearing_context,
|
||||
)
|
||||
|
||||
# Restructure: sources first, then instructions
|
||||
prompt = (
|
||||
f"## חומרי מקור (מסמכים מלאים — צטט מהם מילה במילה כשאפשר):\n\n"
|
||||
f"{source_context}\n\n"
|
||||
f"---\n\n"
|
||||
f"{formatted_prompt}"
|
||||
)
|
||||
# source_context is already embedded inside formatted_prompt via {source_context} in the
|
||||
# template. Do NOT prepend it again — doing so doubles the prompt size (was 465K chars).
|
||||
prompt = formatted_prompt
|
||||
|
||||
if instructions:
|
||||
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||
@@ -377,6 +373,19 @@ async def write_block(
|
||||
if not dir_doc.get("approved"):
|
||||
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)
|
||||
model_key = block_cfg["model"]
|
||||
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}"""
|
||||
|
||||
|
||||
# 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:
|
||||
"""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.
|
||||
Place documents at the TOP of the prompt (before instructions) for 30% better recall.
|
||||
For grounding: instruct Claude to cite word-for-word from these documents.
|
||||
Per-block filtering prevents context overflow on large cases (9+ docs).
|
||||
"""
|
||||
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)
|
||||
context_parts = []
|
||||
for doc in docs:
|
||||
if allowed and doc["doc_type"] not in allowed:
|
||||
continue
|
||||
text = await db.get_document_text(UUID(doc["id"]))
|
||||
if text:
|
||||
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")
|
||||
|
||||
@@ -72,6 +72,9 @@ async def query(
|
||||
"""
|
||||
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 = [
|
||||
"claude", "-p",
|
||||
"--output-format", "json",
|
||||
@@ -110,7 +113,8 @@ async def query(
|
||||
|
||||
if proc.returncode != 0:
|
||||
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()
|
||||
if not stdout:
|
||||
|
||||
@@ -2052,8 +2052,18 @@ async def list_external_case_law(
|
||||
offset: int = 0,
|
||||
source_kind: str = "external_upload",
|
||||
) -> 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()
|
||||
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 = []
|
||||
idx = 1
|
||||
@@ -2488,19 +2498,17 @@ async def precedent_library_stats() -> dict:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
total = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM case_law WHERE source_kind = 'external_upload'"
|
||||
"SELECT COUNT(*) FROM case_law"
|
||||
)
|
||||
by_practice = await conn.fetch(
|
||||
"""SELECT practice_area, COUNT(*) AS n
|
||||
FROM case_law
|
||||
WHERE source_kind = 'external_upload'
|
||||
GROUP BY practice_area
|
||||
ORDER BY n DESC"""
|
||||
)
|
||||
by_level = await conn.fetch(
|
||||
"""SELECT precedent_level, COUNT(*) AS n
|
||||
FROM case_law
|
||||
WHERE source_kind = 'external_upload'
|
||||
GROUP BY precedent_level
|
||||
ORDER BY n DESC"""
|
||||
)
|
||||
|
||||
@@ -123,7 +123,7 @@ SUMMARY_STRATEGIES = {
|
||||
|
||||
DISCUSSION_RULES: dict[str, list[str]] = {
|
||||
"universal": [
|
||||
"פרק הדיון = אסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
||||
"פרק הדיון = מאסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
||||
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
|
||||
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
|
||||
],
|
||||
|
||||
@@ -259,6 +259,14 @@ async def apply_diff(mirror_id: str, agent_name: str, diff: dict) -> list[str]:
|
||||
if "runtime_config" in diff:
|
||||
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:
|
||||
status, data = await call_patch(mirror_id, patch_body)
|
||||
if status >= 400:
|
||||
@@ -278,12 +286,73 @@ async def apply_diff(mirror_id: str, agent_name: str, diff: dict) -> list[str]:
|
||||
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:
|
||||
p = argparse.ArgumentParser()
|
||||
g = p.add_mutually_exclusive_group(required=True)
|
||||
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("--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., 'עוזר משפטי')")
|
||||
args = p.parse_args()
|
||||
|
||||
@@ -295,6 +364,11 @@ async def main() -> None:
|
||||
finally:
|
||||
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}
|
||||
|
||||
print(f"\n=== Master (CMP, 1xxx): {len(master_agents)} agents ===")
|
||||
@@ -332,6 +406,14 @@ async def main() -> None:
|
||||
return
|
||||
|
||||
# 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 ===")
|
||||
backup_path = backup_agents_table()
|
||||
print(f" ✓ {backup_path}")
|
||||
@@ -340,6 +422,11 @@ async def main() -> None:
|
||||
all_errors: list[str] = []
|
||||
for master, mirror, diff in plan:
|
||||
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)
|
||||
if errors:
|
||||
for e in errors:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Trash2, Plus, Pencil, Wand2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
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"
|
||||
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 className="text-ink whitespace-normal break-words max-w-[260px] py-3">
|
||||
<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"
|
||||
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 className="text-ink whitespace-normal break-words max-w-[220px] py-3">
|
||||
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
||||
@@ -308,8 +313,8 @@ export function LibraryListPanel() {
|
||||
limit: 200,
|
||||
};
|
||||
|
||||
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload" });
|
||||
const committee = usePrecedents({ ...sharedFilters, sourceKind: "internal_committee" });
|
||||
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload", sourceType: "court_ruling" });
|
||||
const committee = usePrecedents({ ...sharedFilters, sourceKind: "all_committees" });
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Save, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -65,10 +65,12 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
|
||||
// Hydrate form when the record loads.
|
||||
useEffect(() => {
|
||||
if (!record) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
// React-approved derived-state pattern: sync form whenever a different
|
||||
// record arrives (including after save+refetch). Using setState during
|
||||
// render avoids the one-frame flash that useEffect would produce.
|
||||
const [syncedRecordId, setSyncedRecordId] = useState<string | null>(null);
|
||||
if (record && record.id !== syncedRecordId) {
|
||||
setSyncedRecordId(record.id as string);
|
||||
setForm({
|
||||
citation: record.case_number || "",
|
||||
case_name: record.case_name || "",
|
||||
@@ -84,7 +86,7 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
||||
headnote: record.headnote || "",
|
||||
key_quote: (record as { key_quote?: string }).key_quote || "",
|
||||
});
|
||||
}, [record]);
|
||||
}
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
108
web/app.py
108
web/app.py
@@ -20,7 +20,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "
|
||||
|
||||
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 typing import Any, Literal
|
||||
from pydantic import BaseModel
|
||||
@@ -44,7 +44,7 @@ from web.mcp_env_catalog import (
|
||||
normalize_for_compare,
|
||||
)
|
||||
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 (
|
||||
COMPANIES as PAPERCLIP_COMPANIES,
|
||||
accept_interaction as pc_accept_interaction,
|
||||
@@ -1135,6 +1135,36 @@ async def list_cases(
|
||||
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")
|
||||
async def api_archive_case(case_number: str):
|
||||
"""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}")
|
||||
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."""
|
||||
# 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(
|
||||
case_number=case_number,
|
||||
status=req.status,
|
||||
@@ -1351,10 +1385,30 @@ async def api_case_update(case_number: str, req: CaseUpdateRequest):
|
||||
expected_outcome=req.expected_outcome,
|
||||
)
|
||||
try:
|
||||
return json.loads(result)
|
||||
parsed = json.loads(result)
|
||||
except json.JSONDecodeError:
|
||||
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")
|
||||
async def api_case_delete(case_number: str, remove_files: bool = False):
|
||||
@@ -3057,8 +3111,16 @@ async def api_get_methodology(category: str):
|
||||
items = {}
|
||||
for key, default_val in defaults.items():
|
||||
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] = {
|
||||
"value": overrides[key]["rule_value"],
|
||||
"value": raw,
|
||||
"is_override": True,
|
||||
"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")
|
||||
|
||||
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(
|
||||
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
|
||||
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::jsonb) "
|
||||
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $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::text::jsonb",
|
||||
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"}
|
||||
|
||||
|
||||
@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 ─────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -81,3 +82,35 @@ async def pc_request(
|
||||
if raise_on_error:
|
||||
resp.raise_for_status()
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user