14 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
5 changed files with 243 additions and 8 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

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

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:
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:

View File

@@ -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 ─────────────────────────────────────────

View File

@@ -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,
)