Add Track Changes architecture for draft revisions (CMP + CMPA)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
Fixes critical bug in 1033-25: user-uploaded עריכה-*.docx files were
orphaned on disk while exports kept rebuilding from stale DB blocks.
New architecture:
- User-uploaded DOCX becomes the source of truth (cases.active_draft_path)
- System edits via XML surgery with real Word <w:ins>/<w:del> revisions
- User can Accept/Reject each change from within Word
Components:
- docx_reviser.py: XML surgery for Track Changes (15 tests)
- docx_retrofit.py: retroactive bookmark injection with Hebrew marker
detection + heading heuristic (9 tests)
- docx_exporter.py: emits bookmarks around each of the 12 blocks
- 3 new MCP tools: apply_user_edit, list_bookmarks, revise_draft
- 4 new/updated endpoints: upload (auto-registers active draft),
/exports/revise, /exports/bookmarks, /exports/{filename}/retrofit,
/active-draft
- DB migration: cases.active_draft_path column
- UI: correct banner using real v-numbers, "מקור האמת" badge,
detailed upload toast with bookmarks_added/missing_blocks
- agents: legal-exporter (3 export modes), legal-ceo (stage G for
revision handling), legal-writer (revision mode)
Multi-tenancy:
- Works for both CMP (1xxx cases) and CMPA (8xxx/9xxx cases)
- New revise-draft skill added to both companies
- deploy-track-changes.sh syncs skills CMP ↔ CMPA
- retrofit_case.py: one-off retrofit of existing files
Tests: 34 passing (15 reviser + 9 retrofit + 4 exporter bookmarks + 6 e2e)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
useUploadDraft,
|
||||
useMarkFinal,
|
||||
useDeleteDraft,
|
||||
useActiveDraft,
|
||||
} from "@/lib/api/exports";
|
||||
import {
|
||||
useCaseFeedback,
|
||||
@@ -78,6 +79,7 @@ export function DraftsPanel({
|
||||
const { data: exports, isLoading: exportsLoading } = useExports(caseNumber);
|
||||
const { data: feedbacks, isLoading: feedbackLoading } =
|
||||
useCaseFeedback(caseNumber);
|
||||
const { data: activeDraft } = useActiveDraft(caseNumber);
|
||||
const exportDocx = useExportDocx(caseNumber);
|
||||
const uploadDraft = useUploadDraft(caseNumber);
|
||||
const markFinal = useMarkFinal(caseNumber);
|
||||
@@ -90,25 +92,44 @@ export function DraftsPanel({
|
||||
const isDraftReady = status && DRAFT_READY.includes(status);
|
||||
const openFeedbacks = feedbacks?.filter((f) => !f.resolved) ?? [];
|
||||
|
||||
// Determine draft label based on exports — revised if there are עריכה files or multiple טיוטה versions
|
||||
// Determine draft label based on *actual* v-numbers in filenames (not counts).
|
||||
// "(מתוקנת)" suffix appears when there's at least one עריכה-* file.
|
||||
const draftLabel = (() => {
|
||||
if (!exports?.length) return "טיוטה מוכנה לעיון";
|
||||
const revisions = exports.filter((f) => f.filename.startsWith("עריכה-"));
|
||||
const drafts = exports.filter((f) => f.filename.startsWith("טיוטה-"));
|
||||
if (revisions.length > 0) {
|
||||
const ver = revisions.length + 1;
|
||||
return `טיוטה ${ver} (מתוקנת) מוכנה לעיון`;
|
||||
}
|
||||
if (drafts.length > 1) {
|
||||
return `טיוטה ${drafts.length} מוכנה לעיון`;
|
||||
}
|
||||
return "טיוטה ראשונה מוכנה לעיון";
|
||||
const revisions = exports.filter((f) => f.filename.startsWith("עריכה-"));
|
||||
if (!drafts.length) return "טיוטה מוכנה לעיון";
|
||||
const versions = drafts
|
||||
.map((f) => {
|
||||
const m = f.filename.match(/v(\d+)/);
|
||||
return m ? parseInt(m[1], 10) : 0;
|
||||
})
|
||||
.filter((n) => n > 0);
|
||||
const maxVer = versions.length ? Math.max(...versions) : drafts.length;
|
||||
const suffix = revisions.length > 0 ? " (מתוקנת)" : "";
|
||||
return `טיוטה v${maxVer}${suffix} מוכנה לעיון`;
|
||||
})();
|
||||
|
||||
function handleUpload(file: File) {
|
||||
uploadDraft.mutate(file, {
|
||||
onSuccess: (data) =>
|
||||
toast.success(`הועלה: ${data.filename}`),
|
||||
onSuccess: (data) => {
|
||||
const added = data.bookmarks_added?.length ?? 0;
|
||||
const missing = data.missing_blocks?.length ?? 0;
|
||||
if (data.apply_status === "completed" || data.apply_status === "ok") {
|
||||
if (added > 0) {
|
||||
toast.success(`הועלה: ${data.filename} — זוהו ${added} בלוקים`);
|
||||
} else {
|
||||
toast.success(`הועלה: ${data.filename}`);
|
||||
}
|
||||
if (missing > 0) {
|
||||
toast.warning(
|
||||
`שימו לב: ${missing} בלוקים לא זוהו — ייתכנו בעיות בתיקונים עתידיים`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(`הועלה אך השילוב נכשל: ${data.apply_status ?? "שגיאה"}`);
|
||||
}
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : "שגיאה בהעלאה"),
|
||||
});
|
||||
@@ -164,6 +185,16 @@ export function DraftsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Active-draft badge — the DOCX that is the current source of truth ── */}
|
||||
{activeDraft?.filename && (
|
||||
<div className="flex items-center gap-2 text-xs text-ink-muted">
|
||||
<span>מקור האמת:</span>
|
||||
<Badge variant="outline" className="bg-surface">
|
||||
{activeDraft.filename}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Exports list ── */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
|
||||
@@ -13,10 +13,48 @@ export type ExportFile = {
|
||||
is_final: boolean;
|
||||
};
|
||||
|
||||
export type ActiveDraft = {
|
||||
active_draft_path: string | null;
|
||||
filename: string | null;
|
||||
exists: boolean;
|
||||
};
|
||||
|
||||
export type Revision = {
|
||||
id: string;
|
||||
type: "insert_after" | "insert_before" | "replace" | "delete";
|
||||
anchor_bookmark: string;
|
||||
content?: string;
|
||||
style?: "body" | "heading" | "quote" | "bold";
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type UploadResult = {
|
||||
filename: string;
|
||||
size: number;
|
||||
version: number;
|
||||
active_draft?: string;
|
||||
bookmarks_added?: string[];
|
||||
missing_blocks?: string[];
|
||||
apply_status?: string;
|
||||
};
|
||||
|
||||
export type ReviseResult = {
|
||||
status: string;
|
||||
output_path: string;
|
||||
version: number;
|
||||
applied: number;
|
||||
failed: number;
|
||||
results: { id: string; status: string; error?: string }[];
|
||||
};
|
||||
|
||||
export const exportsKeys = {
|
||||
all: ["exports"] as const,
|
||||
list: (caseNumber: string) =>
|
||||
[...exportsKeys.all, "list", caseNumber] as const,
|
||||
activeDraft: (caseNumber: string) =>
|
||||
[...exportsKeys.all, "active-draft", caseNumber] as const,
|
||||
bookmarks: (caseNumber: string) =>
|
||||
[...exportsKeys.all, "bookmarks", caseNumber] as const,
|
||||
};
|
||||
|
||||
export function useExports(caseNumber: string | undefined) {
|
||||
@@ -48,7 +86,7 @@ export function useExportDocx(caseNumber: string) {
|
||||
export function useUploadDraft(caseNumber: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
mutationFn: async (file: File): Promise<UploadResult> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch(`/api/cases/${caseNumber}/exports/upload`, {
|
||||
@@ -59,14 +97,70 @@ export function useUploadDraft(caseNumber: string) {
|
||||
const err = await res.json().catch(() => ({ detail: "שגיאה בהעלאה" }));
|
||||
throw new Error(err.detail ?? "שגיאה בהעלאה");
|
||||
}
|
||||
return res.json() as Promise<{
|
||||
filename: string;
|
||||
size: number;
|
||||
version: number;
|
||||
}>;
|
||||
return res.json() as Promise<UploadResult>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
|
||||
qc.invalidateQueries({ queryKey: exportsKeys.activeDraft(caseNumber) });
|
||||
qc.invalidateQueries({ queryKey: exportsKeys.bookmarks(caseNumber) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useActiveDraft(caseNumber: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: exportsKeys.activeDraft(caseNumber ?? ""),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<ActiveDraft>(`/api/cases/${caseNumber}/active-draft`, { signal }),
|
||||
enabled: Boolean(caseNumber),
|
||||
staleTime: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBookmarks(caseNumber: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: exportsKeys.bookmarks(caseNumber ?? ""),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<{
|
||||
status: string;
|
||||
active_draft_path?: string;
|
||||
bookmarks?: string[];
|
||||
}>(`/api/cases/${caseNumber}/exports/bookmarks`, { signal }),
|
||||
enabled: Boolean(caseNumber),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useReviseDraft(caseNumber: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: { revisions: Revision[]; author?: string }) =>
|
||||
apiRequest<ReviseResult>(`/api/cases/${caseNumber}/exports/revise`, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
|
||||
qc.invalidateQueries({ queryKey: exportsKeys.activeDraft(caseNumber) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRetrofit(caseNumber: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (filename: string) =>
|
||||
apiRequest<{
|
||||
status: string;
|
||||
active_draft_path: string;
|
||||
bookmarks_added: string[];
|
||||
missing_blocks: string[];
|
||||
}>(`/api/cases/${caseNumber}/exports/${filename}/retrofit`, {
|
||||
method: "POST",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: exportsKeys.activeDraft(caseNumber) });
|
||||
qc.invalidateQueries({ queryKey: exportsKeys.bookmarks(caseNumber) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user