diff --git a/.claude/agents/legal-ceo.md b/.claude/agents/legal-ceo.md index 92c3bd0..14c2883 100644 --- a/.claude/agents/legal-ceo.md +++ b/.claude/agents/legal-ceo.md @@ -212,6 +212,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru - אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים - אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה. - אם ה-reason מכיל `weekly-feedback-job` → **דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים. +- אם ה-reason מכיל `feedback_fold_` → **דלג לסעיף "קיפול הערת יו\"ר"**. אל תיגע בתיקים — זו משימת תחזוקת ידע. - אחרת → המשך לשלב A (heartbeat רגיל) ### חילוץ פסיקה אוטומטי @@ -270,6 +271,29 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru **כלל:** אל תגע בתיקים פעילים, אל תעיר סוכנים אחרים, אל תבצע heartbeat רגיל — זו משימת תחזוקה בלבד. +### קיפול הערת יו"ר (feedback_fold) + +**מתי:** `$PAPERCLIP_WAKE_REASON` מכיל `feedback_fold_` + +מופעל כשהיו"ר סימנה הערת פידבק בודדת כ"יושמה" בדף `/feedback`. נוצר issue בפרויקט "ספריית פסיקה" המשויך אליך, ו**תיאור ה-issue מכיל את כל מה שצריך**: טקסט ההערה, הלקח שהופק, הקטגוריה, ויעד הקיפול לפי הקטגוריה. + +**⚠️ MCP startup race** — חל גם כאן (ראה אזהרת חילוץ פסיקה). אם הכלי הראשון מחזיר "No such tool available" — המתן 3 שניות ונסה שוב. + +**מה לעשות:** +1. **קרא את תיאור ה-issue** (`$PAPERCLIP_TASK_ID`) — הוא מכיל את ההערה, הלקח, הקטגוריה, ושדה **"יעד קיפול"**. +2. **rubric ניתוב לפי קטגוריה** (מופיע גם בתיאור ה-issue — זה מקור האמת): + | קטגוריה | קובץ יעד | + |---------|----------| + | `style` | `skills/decision/SKILL.md` | + | `wrong_structure` | `docs/block-schema.md` + `docs/legal-decision-lessons.md` | + | `missing_content` / `factual_error` / `wrong_tone` | `docs/legal-decision-lessons.md` | + | `other` | שיקול דעת — אם זה באג מערכת ולא לקח כתיבה → **אל תוסיף לקובץ**, פתח/עדכן משימת TaskMaster | +3. **קרא את קובץ היעד** והבן מה כבר מתועד שם. +4. **הוסף את הלקח רק אם אינו קיים** (לא כפל). פורמט: משפט עברי ברור + שורת **Rule** באנגלית, בעקבות הסגנון הקיים בקובץ. +5. **סגור את ה-issue** (`status=done`) עם comment קצר בעברית: לאיזה קובץ קופל ומה נוסף (או "כבר קיים — לא נוסף"). + +**כלל:** אל תגע בתיקים פעילים, אל תעיר סוכנים אחרים. משימת תחזוקת ידע בלבד. + ### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה בכל heartbeat **רגיל** (לא comment routing): diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index fd9b4c3..fcc96c9 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -2455,6 +2455,20 @@ async def list_chair_feedback( return [dict(r) for r in rows] +async def get_chair_feedback(feedback_id: UUID) -> dict | None: + """Return a single chair_feedback row by id (with case_number), or None.""" + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """SELECT cf.*, c.case_number, c.appeal_type AS case_appeal_type + FROM chair_feedback cf + LEFT JOIN cases c ON c.id = cf.case_id + WHERE cf.id = $1""", + feedback_id, + ) + return dict(row) if row else None + + async def resolve_chair_feedback( feedback_id: UUID, applied_to: list[str], diff --git a/web-ui/src/app/feedback/page.tsx b/web-ui/src/app/feedback/page.tsx index ce4fc49..f6f3807 100644 --- a/web-ui/src/app/feedback/page.tsx +++ b/web-ui/src/app/feedback/page.tsx @@ -42,9 +42,14 @@ function FeedbackCard({ fb }: { fb: ChairFeedback }) { const onResolve = () => { resolve.mutate( - { feedbackId: fb.id, applied_to: [] }, + { feedbackId: fb.id, applied_to: [], fold: true }, { - onSuccess: () => toast.success("ההערה סומנה כיושמה"), + onSuccess: (res) => + toast.success( + res.fold_queued + ? "סומנה כיושמה — הלקח נשלח ל-CEO לקיפול לקובץ הידע" + : "ההערה סומנה כיושמה", + ), onError: (e) => toast.error(e instanceof Error ? e.message : "שגיאה בסימון"), }, diff --git a/web-ui/src/components/cases/drafts-panel.tsx b/web-ui/src/components/cases/drafts-panel.tsx index 3d36c40..33d143d 100644 --- a/web-ui/src/components/cases/drafts-panel.tsx +++ b/web-ui/src/components/cases/drafts-panel.tsx @@ -153,8 +153,10 @@ export function DraftsPanel({ } function handleResolve(id: string) { + // fold=false: resolving from the per-case panel is bookkeeping only. + // Folding the lesson into the knowledge files is driven from /feedback. resolveMutation.mutate( - { feedbackId: id, applied_to: [] }, + { feedbackId: id, applied_to: [], fold: false }, { onSuccess: () => toast.success("ההערה סומנה כמטופלת"), onError: () => toast.error("שגיאה בעדכון"), diff --git a/web-ui/src/lib/api/feedback.ts b/web-ui/src/lib/api/feedback.ts index a601133..b749b8b 100644 --- a/web-ui/src/lib/api/feedback.ts +++ b/web-ui/src/lib/api/feedback.ts @@ -89,13 +89,17 @@ export function useResolveFeedback() { mutationFn: ({ feedbackId, applied_to, + fold, }: { feedbackId: string; applied_to: string[]; + /** When true (default server-side), wakes the CEO to fold the lesson + * into the right knowledge file. Pass false for pure bookkeeping. */ + fold?: boolean; }) => - apiRequest<{ status: string }>( + apiRequest<{ status: string; fold_queued: boolean }>( `/api/feedback/${feedbackId}/resolve`, - { method: "PATCH", body: { applied_to } }, + { method: "PATCH", body: { applied_to, ...(fold === undefined ? {} : { fold }) } }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: feedbackKeys.all }); diff --git a/web/app.py b/web/app.py index f5511da..8df0601 100644 --- a/web/app.py +++ b/web/app.py @@ -64,6 +64,7 @@ from web.paperclip_client import ( restore_project as pc_restore_project, wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts, wake_ceo_agent as pc_wake_ceo, + wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold, wake_curator_for_final as pc_wake_curator_for_final, wake_for_precedent_extraction as pc_wake_for_precedent_extraction, ) @@ -4972,13 +4973,53 @@ async def api_create_feedback_json(body: dict): @app.patch("/api/feedback/{feedback_id}/resolve") -async def api_resolve_feedback(feedback_id: str, body: dict): - """Mark feedback as resolved.""" +async def api_resolve_feedback( + feedback_id: str, + body: dict, + background_tasks: BackgroundTasks, +): + """Mark feedback as resolved. When ``fold`` is true (default) and the entry + has an extracted lesson, also wake the CEO to fold that lesson into the + right knowledge file (the feedback→agent-knowledge loop). + + The fold is fire-and-forget (BackgroundTask) and best-effort — resolving + never fails because Paperclip is down. Pass ``fold=false`` for pure + bookkeeping resolves (e.g. from the per-case drafts panel) to avoid + spawning a CEO run per click.""" + fid = UUID(feedback_id) + fold = body.get("fold", True) + + fb = await db.get_chair_feedback(fid) + if not fb: + raise HTTPException(404, "הערה לא נמצאה") + await db.resolve_chair_feedback( - feedback_id=UUID(feedback_id), + feedback_id=fid, applied_to=body.get("applied_to", []), ) - return {"status": "resolved"} + + # Guard: only fold a real, lesson-bearing entry, and only when asked. + lesson = (fb.get("lesson_extracted") or "").strip() + fold_queued = False + if fold and lesson: + async def _fold(): + try: + await pc_wake_ceo_for_feedback_fold( + feedback_id=str(fid), + feedback_text=fb.get("feedback_text") or "", + lesson_extracted=lesson, + category=fb.get("category") or "other", + block_id=fb.get("block_id") or "", + case_number=fb.get("case_number") or "", + practice_area=fb.get("case_appeal_type") or "", + ) + except Exception: + logger.exception("feedback-fold wakeup failed (non-fatal) for %s", fid) + + background_tasks.add_task(_fold) + fold_queued = True + + return {"status": "resolved", "fold_queued": fold_queued} @app.get("/api/chair-feedback/weekly-summary") diff --git a/web/paperclip_client.py b/web/paperclip_client.py index 7eb19c8..efb1ee6 100644 --- a/web/paperclip_client.py +++ b/web/paperclip_client.py @@ -907,6 +907,119 @@ async def wake_for_precedent_extraction( return {"ok": False, "error": f"wakeup: {e}", "issue_id": issue_id, "identifier": identifier} +async def wake_ceo_for_feedback_fold( + *, + feedback_id: str, + feedback_text: str, + lesson_extracted: str, + category: str, + block_id: str = "", + case_number: str = "", + practice_area: str = "", +) -> dict: + """Wake the CEO to fold one resolved chair-feedback lesson into the right + knowledge file (the feedback→agent-knowledge loop). + + Mirrors ``wake_for_precedent_extraction``: creates a Paperclip issue under + the library project carrying the lesson + a category→file routing rubric, + then wakes the CEO (reason ``feedback_fold_``). The CEO folds + the lesson per the rubric and closes the issue. + + Best-effort: any failure is logged and returned as ``{"ok": False, ...}`` + so resolving the feedback never fails because Paperclip is down. + """ + if not PAPERCLIP_BOARD_API_KEY: + logger.warning("PAPERCLIP_BOARD_API_KEY not set — skipping feedback-fold wakeup") + return {"ok": False, "skipped": "no_api_key"} + + # decision-lessons.md is shared across companies → either CEO works; route + # by the case's practice area when known, else default to licensing. + company_id = await _get_company_id(practice_area) + ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID) + + # category → target file routing rubric (embedded in the issue so the CEO + # has it even if its prompt is trimmed). Keep in sync with legal-ceo.md. + routing = { + "style": "skills/decision/SKILL.md", + "wrong_structure": "docs/block-schema.md + docs/legal-decision-lessons.md", + "missing_content": "docs/legal-decision-lessons.md", + "factual_error": "docs/legal-decision-lessons.md", + "wrong_tone": "docs/legal-decision-lessons.md", + "other": "שיקול דעת — לרוב באג מערכת → משימת TaskMaster, לא לקח", + } + target = routing.get(category, "docs/legal-decision-lessons.md") + + try: + conn = await asyncpg.connect(PAPERCLIP_DB_URL) + try: + project_id = await _get_or_create_library_project(conn, company_id) + row = await conn.fetchrow( + "UPDATE companies SET issue_counter = issue_counter + 1 " + "WHERE id = $1::uuid RETURNING issue_counter", + company_id, + ) + issue_number = row["issue_counter"] + prefix = "CMP" if company_id == COMPANIES["licensing"] else "CMPA" + identifier = f"{prefix}-{issue_number}" + + issue_id = str(uuid.uuid4()) + short = (feedback_text[:90] + "…") if len(feedback_text) > 90 else feedback_text + description = ( + f"היו\"ר סימנה הערת פידבק כ\"יושמה\" — יש לקפל את הלקח לקובץ הידע הנכון.\n\n" + f"**feedback_id:** `{feedback_id}`\n" + f"**קטגוריה:** {category}\n" + f"**בלוק:** {block_id or '—'}\n" + f"**תיק:** {case_number or '—'}\n\n" + f"**ההערה:**\n{feedback_text}\n\n" + f"**הלקח שהופק:**\n{lesson_extracted or '(אין — הפק לקח מההערה)'}\n\n" + f"---\n\n" + f"**יעד קיפול לפי הקטגוריה:** `{target}`\n\n" + f"**משימה:**\n" + f"1. קרא את קובץ היעד והבן מה כבר מתועד שם.\n" + f"2. הוסף את הלקח **רק אם אינו קיים** (לא כפל). משפט אחד ברור + Rule באנגלית.\n" + f"3. אם הקטגוריה היא `other` והלקח הוא באג מערכת ולא לקח כתיבה — " + f"אל תוסיף לקובץ; פתח/עדכן משימת TaskMaster במקום.\n" + f"4. סמן את ה-issue כ-done ופתח comment קצר: לאיזה קובץ קופל ומה נוסף.\n" + ) + await conn.execute( + """INSERT INTO issues + (id, company_id, project_id, title, description, + status, priority, issue_number, identifier, assignee_agent_id) + VALUES ($1, $2::uuid, $3::uuid, $4, $5, 'todo', 'low', + $6, $7, $8::uuid)""", + issue_id, company_id, project_id, + f"[לקח] קיפול הערת יו\"ר: {short}"[:200], + description, + issue_number, identifier, ceo_id, + ) + finally: + await conn.close() + except Exception as e: + logger.exception("wake_ceo_for_feedback_fold: DB step failed: %s", e) + return {"ok": False, "error": f"db: {e}"} + + payload = { + "source": "automation", + "triggerDetail": "feedback_resolved", + "reason": f"feedback_fold_{identifier}", + "payload": { + "issueId": issue_id, + "mutation": "feedback_fold", + "feedbackId": feedback_id, + }, + } + try: + resp = await pc_request( + "POST", f"/api/agents/{ceo_id}/wakeup", + json=payload, raise_on_error=True, + ) + logger.info("feedback-fold wakeup queued: issue=%s feedback=%s", identifier, feedback_id) + return {"ok": True, "issue_id": issue_id, "identifier": identifier, "wakeup": resp.json()} + except Exception as e: + logger.exception("wake_ceo_for_feedback_fold: wakeup API failed: %s", e) + return {"ok": False, "error": f"wakeup: {e}", "issue_id": issue_id, "identifier": identifier} + + async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "") -> dict: """Wake the CEO agent via Paperclip's wakeup API.