From 3ea5d3b536755ae92ebb5f6a1765cf4b800133ff Mon Sep 17 00:00:00 2001 From: Chaim Date: Tue, 30 Jun 2026 19:54:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(case-ui):=20=D7=9B=D7=A8=D7=98=D7=99=D7=A1?= =?UTF-8?q?=20"=D7=94=D7=A4=D7=A7=D7=AA=20=D7=9E=D7=A1=D7=9E=D7=9B=D7=99?= =?UTF-8?q?=D7=9D"=20=D7=A2=D7=9D=203=20=D7=9B=D7=A4=D7=AA=D7=95=D7=A8?= =?UTF-8?q?=D7=99-=D7=94=D7=A4=D7=A7=20=D7=93=D7=98=D7=A8=D7=9E=D7=99?= =?UTF-8?q?=D7=A0=D7=99=D7=A1=D7=98=D7=99=D7=99=D7=9D=20(#214)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit מוסיף כרטיס "הפקת מסמכים" בראש טאב "טיוטות והערות" (mockup 26 — תוספת ל-18h), תוספת לעמוד הקיים, כל הסקשנים נשמרים. - כפתור 1 "הפק סיכום מנהלים" → endpoint חדש POST /generate/party-claims-summary → CEO wakeup סטרוקטורלי action="party_claims_summary" (שלב H2, host-side). Polling: GET /research/party-claims-summary (200=מוכן → פתח/DOCX). - כפתור 2 "הפק טיוטה של טענות הצדדים" → endpoint חדש POST /generate/interim-draft → CEO wakeup action="interim_draft" (שלב H, host-side). Polling: רשימת ה-exports עד שמופיע טיוטת-ביניים-{case}-vN.docx. - כפתור 3 "הפק טיוטת החלטה מלאה" → ה-useExportDocx הקיים (in-container, ללא LLM); הועבר מהבאנר (כפתור "הפק DOCX" הוסר משם — אין כפילות). disabled+title כשאין בלוקים כתובים (isDraftReady). מסלול-עירור יחיד דרך ה-Platform Port (pc_wake_ceo_for_action) → API helper /api/agents/{id}/wakeup, לעולם לא DB insert. Israel-time + cache-invalidation. Invariants: G12 (מגע-Paperclip רק ב-paperclip_client + agent_platform_port; leak-guard ירוק) · G2 (אין מסלול-עירור מקביל — שימוש חוזר ב-pc_request הקיים) · G10 (הפקה מגודרת-יו"ר/CEO) · INV-UI9 (Asia/Jerusalem) · X6 (UI↔API contract). Co-Authored-By: Claude Opus 4.8 (1M context) --- web-ui/src/components/cases/drafts-panel.tsx | 250 +++++++++++++++++-- web-ui/src/lib/api/generate.ts | 92 +++++++ web/agent_platform_port.py | 2 + web/app.py | 46 ++++ web/paperclip_client.py | 67 +++++ 5 files changed, 440 insertions(+), 17 deletions(-) create mode 100644 web-ui/src/lib/api/generate.ts diff --git a/web-ui/src/components/cases/drafts-panel.tsx b/web-ui/src/components/cases/drafts-panel.tsx index a23b73f..d2cd825 100644 --- a/web-ui/src/components/cases/drafts-panel.tsx +++ b/web-ui/src/components/cases/drafts-panel.tsx @@ -25,7 +25,13 @@ import { useUploadFinalDecision, useRunFinalLearning, useRunFinalHalacha, + type ExportFile, } from "@/lib/api/exports"; +import { + usePartyClaimsSummary, + useGeneratePartyClaimsSummary, + useGenerateInterimDraft, +} from "@/lib/api/generate"; import { useCaseFeedback, useCreateFeedback, @@ -55,6 +61,10 @@ import { Stamp, Link2, AlertTriangle, + Zap, + ClipboardList, + Files, + ExternalLink, } from "lucide-react"; /* Statuses at which a draft is considered ready */ @@ -76,6 +86,28 @@ function formatDate(epoch: number): string { return formatDateTime(epoch * 1000); } +/** + * Pick the newest export file whose name starts with `prefix`, preferring the + * highest v-number and falling back to the latest created_at. Used by the + * "הפקת מסמכים" card to surface the most recent טיוטת-ביניים-* file (#214). + */ +function pickLatestVersioned( + files: ExportFile[] | undefined, + prefix: string, +): (ExportFile & { version: number | null }) | null { + const matches = (files ?? []).filter((f) => f.filename.startsWith(prefix)); + if (!matches.length) return null; + const withVer = matches.map((f) => { + const m = f.filename.match(/v(\d+)/); + return { ...f, version: m ? parseInt(m[1], 10) : null }; + }); + withVer.sort((a, b) => { + if (a.version != null && b.version != null) return b.version - a.version; + return b.created_at - a.created_at; + }); + return withVer[0]; +} + /* ── Main component ─────────────────────────────────── */ export function DraftsPanel({ @@ -97,6 +129,10 @@ export function DraftsPanel({ const uploadFinal = useUploadFinalDecision(caseNumber); const runLearning = useRunFinalLearning(caseNumber); const runHalacha = useRunFinalHalacha(caseNumber); + // ── Document generation (#214) — the "הפקת מסמכים" card ── + const { data: summary } = usePartyClaimsSummary(caseNumber); + const genSummary = useGeneratePartyClaimsSummary(caseNumber); + const genInterim = useGenerateInterimDraft(caseNumber); const qc = useQueryClient(); const fileRef = useRef(null); @@ -129,6 +165,38 @@ export function DraftsPanel({ return `טיוטה v${maxVer}${suffix} מוכנה לעיון`; })(); + // ── "הפקת מסמכים" derived state (#214) ── + // Latest interim ("party-claims") partial draft in the exports table — the poll + // target for button 2. The CEO writes it as טיוטת-ביניים-{case}-vN.docx. + const latestInterim = pickLatestVersioned(exports, "טיוטת-ביניים-"); + const summaryReady = Boolean(summary?.markdown); + + function handleGenerateSummary() { + genSummary.mutate(undefined, { + onSuccess: (d) => { + if (d.status === "ok") { + toast.success("הופעלה הפקת סיכום-המנהלים — רצה ברקע (אופוס)"); + } else { + toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`); + } + }, + onError: () => toast.error("שגיאה בהפעלת הפקת סיכום-המנהלים"), + }); + } + + function handleGenerateInterim() { + genInterim.mutate(undefined, { + onSuccess: (d) => { + if (d.status === "ok") { + toast.success("הופעלה הפקת טיוטת טענות-הצדדים — רצה ברקע (אופוס)"); + } else { + toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`); + } + }, + onError: () => toast.error("שגיאה בהפעלת הפקת הטיוטה החלקית"), + }); + } + function handleUpload(file: File) { uploadDraft.mutate(file, { onSuccess: (data) => { @@ -233,30 +301,178 @@ export function DraftsPanel({ } return ( - // flex + per-card `order` so the visual order matches mockup 18h - // (drafts → final+learning → feedback) without reordering the JSX. + // flex + per-card `order` so the visual order matches mockup 26: + // generate-card → banner → active-draft → drafts → final+learning → feedback.
- {/* ── Banner ── */} + {/* ── Card: document generation (mockup 26) — 3 deterministic "הפק" buttons ── */} +
+
+ +

הפקת מסמכים

+
+
+

+ הפקה דטרמיניסטית בלחיצה — בלי בקשת-טקסט. כל כפתור מריץ פעולה מדויקת, + והקובץ מופיע בטבלת-הטיוטות / לקריאה מקומית. +

+ + {/* 1 — executive summary of party claims */} +
+ + + +
+
הפק סיכום מנהלים
+

+ תמצית מזוקקת של טענות הצדדים לקראת הדיון — מסמך נפרד מהטיוטה. +

+
+ + {summaryReady && ( + <> + + + + )} +
+
+
+ + {/* 2 — partial draft (parties' claims, blocks ה–ט) */} +
+ + + +
+
+ הפק טיוטה של טענות הצדדים{" "} + + (טיוטה חלקית) + +
+

+ בלוקים ה–ט (רקע, טענות, הליכים, תכניות) — עד הדיון, בלי דיון + והכרעה. נשמרת בטבלת-הטיוטות. +

+
+ + {latestInterim && ( + + {latestInterim.version + ? `v${latestInterim.version} · ` + : ""} + {formatDate(latestInterim.created_at)} + + )} +
+
+
+ + {/* 3 — full decision draft (REUSES useExportDocx; moved from the banner) */} +
+ + + +
+
+ הפק טיוטת החלטה מלאה +
+

+ ההחלטה המלאה — כל 12 הבלוקים, כולל דיון והכרעה. נקבעת + כמקור-האמת ונשמרת בטבלת-הטיוטות. +

+
+ + {!isDraftReady && ( + + זמין כשיש בלוקים כתובים + + )} +
+
+
+
+
+ + {/* ── Banner — draft-ready indicator (export button moved to "הפקת מסמכים") ── */} {isDraftReady && (
{draftLabel} -
-
)} diff --git a/web-ui/src/lib/api/generate.ts b/web-ui/src/lib/api/generate.ts new file mode 100644 index 0000000..a8ec50f --- /dev/null +++ b/web-ui/src/lib/api/generate.ts @@ -0,0 +1,92 @@ +/** + * Document-generation domain hooks (TaskMaster #214). + * + * The "הפקת מסמכים" card in the case "טיוטות והערות" tab fires two deterministic, + * host-side generation actions through the CEO (structured-action wakeup): + * • party_claims_summary → an executive summary of the parties' claims + * • interim_draft → a partial draft (blocks ה–ט, up to the discussion) + * + * Generation runs on the host (claude_session → claude -p), NOT in the container, + * so the trigger endpoints return 202-accepted and the UI POLLS for completion: + * • summary → GET /api/cases/{n}/research/party-claims-summary (200 = ready) + * • interim draft → the exports list grows a "טיוטת-ביניים-{n}-vN.docx" file + * (polled via useExports — see exports.ts). + * + * The full-decision export ("הפק טיוטת החלטה מלאה") is NOT here — it reuses the + * existing in-container useExportDocx (exports.ts), no LLM, no wakeup. + */ + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ApiError, apiRequest } from "./client"; + +/** Shape returned by the structured-action wakeup endpoints (#214). */ +export type GenerateTriggerResult = { + status: "ok" | "skipped" | "error"; + action?: string; + issue_id?: string; + ceo_id?: string; + reason?: string; + error?: string; +}; + +export const generateKeys = { + all: ["generate"] as const, + partyClaimsSummary: (caseNumber: string) => + [...generateKeys.all, "party-claims-summary", caseNumber] as const, +}; + +/* ── Read: party-claims executive summary (poll target for button 1) ── + 404 (not generated yet) is a normal "not ready" state, not an error — we + coerce it to null so the panel renders the empty/idle state cleanly. */ +export type PartyClaimsSummary = { markdown: string } | null; + +export function usePartyClaimsSummary(caseNumber: string | undefined) { + return useQuery({ + queryKey: generateKeys.partyClaimsSummary(caseNumber ?? ""), + queryFn: async ({ signal }) => { + try { + return await apiRequest<{ markdown: string }>( + `/api/cases/${caseNumber}/research/party-claims-summary`, + { signal }, + ); + } catch (e) { + if (e instanceof ApiError && e.status === 404) return null; + throw e; + } + }, + enabled: Boolean(caseNumber), + staleTime: 5_000, + retry: false, + }); +} + +/* ── Trigger: party-claims executive summary (button 1) ── */ +export function useGeneratePartyClaimsSummary(caseNumber: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => + apiRequest( + `/api/cases/${caseNumber}/generate/party-claims-summary`, + { method: "POST" }, + ), + onSuccess: () => { + // Start polling fresh — the file will appear after the host run completes. + qc.invalidateQueries({ + queryKey: generateKeys.partyClaimsSummary(caseNumber), + }); + }, + }); +} + +/* ── Trigger: interim ("party-claims") partial draft (button 2) ── */ +export function useGenerateInterimDraft(caseNumber: string) { + return useMutation({ + mutationFn: () => + apiRequest( + `/api/cases/${caseNumber}/generate/interim-draft`, + { method: "POST" }, + ), + // The result lands in the exports list (טיוטת-ביניים-*.docx); useExports + // already polls on a 5s interval, so no extra invalidation is needed here. + }); +} diff --git a/web/agent_platform_port.py b/web/agent_platform_port.py index 5f65f05..fded0d2 100644 --- a/web/agent_platform_port.py +++ b/web/agent_platform_port.py @@ -59,6 +59,7 @@ from web.paperclip_client import ( wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts, wake_analyst_for_argument_aggregation as pc_wake_analyst_for_argument_aggregation, wake_ceo_agent as pc_wake_ceo, + wake_ceo_for_action as pc_wake_ceo_for_action, 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, @@ -97,6 +98,7 @@ __all__ = [ "pc_get_agents_for_case", "pc_get_agents", "pc_wake_ceo", + "pc_wake_ceo_for_action", "pc_wake_ceo_for_feedback_fold", "pc_wake_curator_for_final", "pc_wake_for_precedent_extraction", diff --git a/web/app.py b/web/app.py index 9ed306d..aee7189 100644 --- a/web/app.py +++ b/web/app.py @@ -79,6 +79,7 @@ from web.agent_platform_port import ( pc_wake_analyst_for_argument_aggregation, rename_case_project, pc_wake_ceo, + pc_wake_ceo_for_action, pc_wake_ceo_for_feedback_fold, pc_wake_curator_for_final, pc_wake_for_precedent_extraction, @@ -3090,6 +3091,51 @@ async def api_party_claims_summary(case_number: str): return {"markdown": path.read_text(encoding="utf-8")} +# ── Deterministic document generation triggers (TaskMaster #214) ───── +# The "הפקת מסמכים" buttons in the case "טיוטות והערות" tab. Generation for the +# executive summary + interim draft runs HOST-SIDE (claude_session → claude -p, +# not in the container), so these endpoints fire a *structured-action* CEO wakeup +# (the CEO reads payload.action and routes to שלב H2 / שלב H — legal-ceo.md). +# Wakeup goes through the Paperclip API helper (pc_wake_ceo_for_action → the +# platform Port → POST /api/agents/{id}/wakeup), NEVER a direct DB insert. +# Polling for completion uses the existing read endpoints (research/party-claims- +# summary for the summary; the exports list for the טיוטת-ביניים-*.docx file). + + +async def _wake_ceo_action(case_number: str, action: str) -> dict: + """Resolve the case's company + fire a structured-action CEO wakeup (#214).""" + case = await db.get_case_by_number(case_number) + if not case: + raise HTTPException(404, f"תיק {case_number} לא נמצא") + prefix = case_number[:1] + company_id = ( + PAPERCLIP_COMPANIES["licensing"] if prefix == "1" + else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9") + else "" + ) + try: + return await pc_wake_ceo_for_action(case_number, action, company_id=company_id) + except Exception as e: + logger.warning("CEO action %s wakeup failed for %s: %s", action, case_number, e) + return {"status": "error", "error": str(e)} + + +@app.post("/api/cases/{case_number}/generate/party-claims-summary", status_code=202) +async def api_generate_party_claims_summary(case_number: str): + """Fire-and-accept: wake the CEO to generate the party-claims executive summary + (שלב H2 → summarize_party_claims). Runs host-side; poll + GET /api/cases/{n}/research/party-claims-summary for completion.""" + return await _wake_ceo_action(case_number, "party_claims_summary") + + +@app.post("/api/cases/{case_number}/generate/interim-draft", status_code=202) +async def api_generate_interim_draft(case_number: str): + """Fire-and-accept: wake the CEO to generate the partial "party-claims" draft + (שלב H → write_interim_draft + export_interim_draft). Runs host-side; poll the + exports list for the new טיוטת-ביניים-{case}-vN.docx.""" + return await _wake_ceo_action(case_number, "interim_draft") + + @app.get("/api/cases/{case_number}/research/party-claims-summary/download") async def api_party_claims_summary_download(case_number: str): """Download the raw party-claims-summary.md file.""" diff --git a/web/paperclip_client.py b/web/paperclip_client.py index cbe83a8..2467ff5 100644 --- a/web/paperclip_client.py +++ b/web/paperclip_client.py @@ -1195,6 +1195,73 @@ async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "") return result +async def wake_ceo_for_action( + case_number: str, + action: str, + company_id: str = "", +) -> dict: + """Wake the CEO with a deterministic *structured action* on the case's MAIN issue. + + Drives the UI "הפקת מסמכים" buttons (TaskMaster #214): the CEO reads + ``payload.action`` and routes deterministically to its שלב H / שלב H2 + side-quests (legal-ceo.md §"פעולות סטרוקטורליות") — no free-text parsing. + + action="party_claims_summary" → שלב H2 (summarize_party_claims) + action="interim_draft" → שלב H (write_interim_draft + export_interim_draft) + + These are side-quests on the EXISTING ``[ערר {case_number}]`` issue — no child + issue is created (the CEO must not change ``cases.status`` or spawn sub-agents). + Wakeup goes through the Paperclip API (``POST /api/agents/{id}/wakeup``), never a + direct DB insert (CLAUDE.md hard rule). Returns + ``{"status": "ok"|"skipped", ...}``. + """ + if not PAPERCLIP_BOARD_API_KEY: + logger.warning("PAPERCLIP_BOARD_API_KEY not set — skipping CEO action wakeup") + return {"status": "skipped", "reason": "no_api_key"} + + issues = await get_case_issues(case_number) + if not issues: + logger.warning("No Paperclip issues found for case %s — skipping CEO action", case_number) + return {"status": "skipped", "reason": "no_issue"} + + # The main case issue — prefer the in-progress one, else the canonical + # "[ערר {case_number}]" issue, else the oldest. (Same selection spirit as + # wake_curator_for_final, but we never spawn a child — it's a side-quest.) + main_issue = ( + next((i for i in issues if i.get("status") == "in_progress"), None) + or next((i for i in issues if f"[ערר {case_number}]" in (i.get("title") or "")), None) + or issues[0] + ) + main_issue_id = main_issue["id"] + + ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID) + wake_resp = await pc_request( + "POST", + f"/api/agents/{ceo_id}/wakeup", + json={ + "source": "on_demand", + "triggerDetail": "manual", + "reason": f"generate_{action}_{case_number}", + "payload": { + "issueId": main_issue_id, + "action": action, + "case_number": case_number, + }, + }, + raise_on_error=True, + ) + logger.info( + "CEO action wakeup for case %s: action=%s issue=%s ceo=%s status=%s", + case_number, action, main_issue_id, ceo_id, wake_resp.status_code, + ) + return { + "status": "ok", + "action": action, + "issue_id": main_issue_id, + "ceo_id": ceo_id, + } + + def _curator_task_brief(task: str, case_number: str, final_filename: str) -> tuple[str, str]: """Build the (sub-issue title, description) for a staged final-decision task.