Compare commits
6 Commits
feature/ho
...
docs/hooks
| Author | SHA1 | Date | |
|---|---|---|---|
| 8db3bf6ddd | |||
| a3468d5b2f | |||
| 5f43659b5a | |||
| 86734da210 | |||
| 82ded005a4 | |||
| c7ed1110f8 |
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`
|
||||||
@@ -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:
|
||||||
|
|||||||
58
web/app.py
58
web/app.py
@@ -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."""
|
||||||
@@ -4015,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 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -104,7 +104,7 @@ async def emit_case_status_webhook(
|
|||||||
"oldStatus": old_status,
|
"oldStatus": old_status,
|
||||||
"newStatus": new_status,
|
"newStatus": new_status,
|
||||||
"companyId": company_id,
|
"companyId": company_id,
|
||||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
},
|
},
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
timeout=5.0,
|
timeout=5.0,
|
||||||
|
|||||||
Reference in New Issue
Block a user