Merge pull request 'feat(case-ui): כרטיס "הפקת מסמכים" עם 3 כפתורי-הפק דטרמיניסטיים (#214)' (#386) from worktree-agent-a72c8793960b71772 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 44s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s

This commit was merged in pull request #386.
This commit is contained in:
2026-06-30 19:56:46 +00:00
5 changed files with 440 additions and 17 deletions

View File

@@ -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<HTMLInputElement>(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.
<div className="flex flex-col gap-6">
{/* ── Banner ── */}
{/* ── Card: document generation (mockup 26) — 3 deterministic "הפק" buttons ── */}
<section className="order-0 rounded-lg border border-rule bg-surface overflow-hidden">
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft bg-parchment">
<Zap className="w-[18px] h-[18px] text-gold-deep shrink-0" />
<h3 className="text-navy text-base font-semibold">הפקת מסמכים</h3>
</div>
<div className="p-4">
<p className="text-xs text-ink-muted leading-relaxed mb-1">
הפקה דטרמיניסטית בלחיצה בלי בקשת-טקסט. כל כפתור מריץ פעולה מדויקת,
והקובץ מופיע בטבלת-הטיוטות / לקריאה מקומית.
</p>
{/* 1 — executive summary of party claims */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft first-of-type:border-t-0">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<ClipboardList className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">הפק סיכום מנהלים</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
תמצית מזוקקת של טענות הצדדים לקראת הדיון מסמך נפרד מהטיוטה.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleGenerateSummary}
disabled={genSummary.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{genSummary.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
"הפק"
)}
</Button>
{summaryReady && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-gold-deep"
onClick={() =>
window.open(
`/api/cases/${caseNumber}/research/party-claims-summary/download`,
"_blank",
)
}
>
<ExternalLink className="w-3.5 h-3.5 me-1" />
פתח
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-gold-deep"
onClick={() =>
window.open(
`/api/cases/${caseNumber}/research/party-claims-summary/export-docx`,
"_blank",
)
}
>
<Download className="w-3.5 h-3.5 me-1" />
DOCX
</Button>
</>
)}
</div>
</div>
</div>
{/* 2 — partial draft (parties' claims, blocks ה–ט) */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<FileText className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">
הפק טיוטה של טענות הצדדים{" "}
<span className="font-medium text-ink-muted text-xs">
(טיוטה חלקית)
</span>
</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
בלוקים הט (רקע, טענות, הליכים, תכניות) עד הדיון, בלי דיון
והכרעה. נשמרת בטבלת-הטיוטות.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleGenerateInterim}
disabled={genInterim.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{genInterim.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
"הפק"
)}
</Button>
{latestInterim && (
<span className="text-[0.7rem] text-ink-muted">
{latestInterim.version
? `v${latestInterim.version} · `
: ""}
{formatDate(latestInterim.created_at)}
</span>
)}
</div>
</div>
</div>
{/* 3 — full decision draft (REUSES useExportDocx; moved from the banner) */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<Files className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">
הפק טיוטת החלטה מלאה
</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
ההחלטה המלאה כל 12 הבלוקים, כולל דיון והכרעה. נקבעת
כמקור-האמת ונשמרת בטבלת-הטיוטות.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleExport}
disabled={exportDocx.isPending || !isDraftReady}
title={!isDraftReady ? "זמין כשיש בלוקים כתובים" : undefined}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{exportDocx.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
<>
<FileOutput className="w-3.5 h-3.5 me-1.5" />
הפק
</>
)}
</Button>
{!isDraftReady && (
<span className="rounded bg-warn-bg px-2 py-0.5 text-[0.65rem] text-warn">
זמין כשיש בלוקים כתובים
</span>
)}
</div>
</div>
</div>
</div>
</section>
{/* ── Banner — draft-ready indicator (export button moved to "הפקת מסמכים") ── */}
{isDraftReady && (
<div className="order-1 flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
<FileText className="w-5 h-5 text-gold-deep shrink-0" />
<span className="text-sm font-medium text-gold-deep">
{draftLabel}
</span>
<div className="me-auto" />
<Button
size="sm"
onClick={handleExport}
disabled={exportDocx.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{exportDocx.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<FileOutput className="w-4 h-4 me-1.5" />
)}
הפק DOCX
</Button>
</div>
)}

View File

@@ -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<PartyClaimsSummary>({
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<GenerateTriggerResult>(
`/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<GenerateTriggerResult>(
`/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.
});
}

View File

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

View File

@@ -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."""

View File

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