Compare commits
6 Commits
107be235f5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bcf05df26 | |||
| cb4aa703ec | |||
| 4d227a4999 | |||
| f58ddba93e | |||
| 29970ad8b2 | |||
| 85af98cded |
@@ -275,3 +275,40 @@ WARN [plugin] issue.comment.created event missing issueId in payload, skipping
|
||||
- **תוקן בצד שלנו** (PR #2, נפרס דרך `npm run build` + `pm2 restart paperclip` — הפלאגין נטען מ-`/home/chaim/plugin-legal-ai` לפי `package_path`).
|
||||
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
|
||||
- TaskMaster: `legal-ai` #149.
|
||||
|
||||
---
|
||||
|
||||
## 7. `agents.invoke({prompt})` — הפרומפט נבלע; רק `payload.issueId` נמסר
|
||||
|
||||
**התסמין (מנקודת-מבט היו"ר):** כותבים הוראה לסוכן — והוא "מתעלם". ההרצה נראית תקינה
|
||||
לחלוטין: `status=succeeded`, `exit_code=0`, בלי שגיאה, ואפילו עולה כסף. הסוכן מדווח
|
||||
"כל התורים ריקים — אין עבודה".
|
||||
|
||||
**הסיבה:** `ctx.agents.invoke(agentId, companyId, {prompt, reason})` הוא ה-API **המתועד**
|
||||
של ה-Plugin SDK ("*Invoke an agent with a prompt payload*"), ו-`prompt` הוא שדה חובה.
|
||||
Paperclip מקבל אותו, שומר אותו ב-`agent_wakeup_requests.payload.prompt` — **וה-runner קורא
|
||||
רק `payload.issueId`**. הפרומפט לא מגיע למודל. ה-`reason` כן שורד (ל-`context_snapshot.wakeReason`),
|
||||
ולכן זה נראה כאילו משהו נמסר.
|
||||
|
||||
מדידה ב-DB (2026-07-16): **18 מתוך 18** ה-wakeups שאי-פעם נשאו `payload.prompt` לא הגיעו
|
||||
לאף הרצה — 0% מסירה. מול 107,172 wakeups עם `issueId` שעובדים. אימות ישיר: בלוג-ההרצה
|
||||
שנבלעה אין ולו אזכור אחד של מספר-התיק, והמודל פותח ב-*"This heartbeat has no scoped wake payload"*.
|
||||
|
||||
**מה עושים במקום:** נושאים את ההוראה על **issue**, לא ב-payload — issue-ילד שכבר משויך
|
||||
ל-CEO, עם ההוראה ב-`description`, ואז wakeup עם `payload.issueId`. זה בדיוק
|
||||
`open_ceo_run` ב-[`web/paperclip_client.py`](../web/paperclip_client.py), והמסלול היחיד
|
||||
שאומת כמגיע לסוכן.
|
||||
|
||||
**מלכודות שנלוות לזה — אל תיפול בהן שוב:**
|
||||
- **`issues.create` של ה-SDK עם `assigneeAgentId` *לא* מעיר את הסוכן.** ה-REST המקביל
|
||||
(`POST /api/issues/:id/children`) *כן* — הוא פולט wakeup `issue_assigned`. אל תכליל
|
||||
מהתנהגות REST על ה-SDK; זה נבדק ונמצא שונה.
|
||||
- **`ctx.issues.requestWakeup` (ה-primitive הנכון ב-SDK) נכשל מתוך scheduled job** עם
|
||||
`missing, expired, or unknown invocation scope` — למרות ש-capability `issues.wakeup`
|
||||
מוצהר ומותקן. אותה שגיאה מפילה גם `companies.list` / `issues.list` / `issues.listComments`
|
||||
בתוך ה-sweep, ולכן **רשת-הביטחון `route-pending-comments` אינה אמינה**. לא נחקר לעומק —
|
||||
לכן ניתוב-ההערות הועבר ל-backend של legal-ai (REST), שאינו חשוף לבעיה.
|
||||
|
||||
**סטטוס:** נעקף — `POST /api/cases/{case}/agents/comment` ו-`/agents/interaction-response`
|
||||
פותחים הרצת-CEO ישירות (REST) ומסמנים את ההערה כמנותבת, כדי שה-sweep השבור לא יירה
|
||||
הרצת-סרק. TaskMaster `legal-ai` #228.
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CaseHeader } from "@/components/cases/case-header";
|
||||
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||
import { CaseFilesBrowser } from "@/components/cases/case-files-browser";
|
||||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||||
import { PositionsPanel } from "@/components/cases/positions-panel";
|
||||
@@ -243,12 +244,17 @@ export default function CaseDetailPage({
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drafts" className="mt-0">
|
||||
<TabsContent value="drafts" className="mt-0 space-y-4">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<CaseFilesBrowser caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="mt-0">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useSendComment,
|
||||
useSubmitInteraction,
|
||||
useDismissInteraction,
|
||||
useSetIssueStatus,
|
||||
} from "@/lib/api/agents";
|
||||
import type {
|
||||
Interaction,
|
||||
@@ -772,14 +773,34 @@ function IssueGroup({
|
||||
defaultOpen: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const setStatus = useSetIssueStatus(caseNumber);
|
||||
const closed = issue.status === "done" || issue.status === "cancelled";
|
||||
// Show the parent run this issue hangs under (e.g. a "הרצה חדשה" nested under
|
||||
// the live CEO root) so the hierarchy is visible, not flattened.
|
||||
const parentIdentifier = issue.parent_id
|
||||
? issueMap.get(issue.parent_id)
|
||||
: undefined;
|
||||
|
||||
const close = (status: "done" | "cancelled") =>
|
||||
setStatus.mutate(
|
||||
{ issue_id: issue.id, status },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toast.success(
|
||||
status === "done" ? "המשימה סומנה כהושלמה" : "המשימה בוטלה",
|
||||
),
|
||||
onError: () => toast.error("שגיאה בעדכון סטטוס המשימה"),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-b border-rule-soft last:border-b-0">
|
||||
<div className="border-b-2 border-rule last:border-b-0">
|
||||
<div className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50 text-start"
|
||||
className="flex-1 flex items-center gap-2 text-start min-w-0"
|
||||
>
|
||||
<Badge
|
||||
variant={closed ? "secondary" : "default"}
|
||||
@@ -790,13 +811,58 @@ function IssueGroup({
|
||||
<span className="text-[0.78rem] font-semibold text-navy truncate">
|
||||
{shortIssueTitle(issue.title)}
|
||||
</span>
|
||||
{parentIdentifier && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 text-[0.6rem] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||
title={`תת-משימה של ${parentIdentifier}`}
|
||||
>
|
||||
↳ {parentIdentifier}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
|
||||
{issueStatusLabel(issue.status)} · {items.length}
|
||||
</span>
|
||||
</button>
|
||||
{!closed && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => close("done")}
|
||||
disabled={setStatus.isPending}
|
||||
className="h-7 px-2 text-[0.7rem] text-success border-success/40 hover:bg-success-bg"
|
||||
title="סמן את המשימה כהושלמה"
|
||||
>
|
||||
{setStatus.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 me-1" />
|
||||
)}
|
||||
הושלם
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => close("cancelled")}
|
||||
disabled={setStatus.isPending}
|
||||
className="h-7 px-2 text-[0.7rem] text-ink-muted hover:text-danger"
|
||||
title="בטל את המשימה"
|
||||
>
|
||||
בטל
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label={open ? "כווץ" : "הרחב"}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 ms-auto text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
className={`w-4 h-4 text-ink-faint transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="pb-1">
|
||||
{items.map((item) =>
|
||||
@@ -878,6 +944,13 @@ function TargetSelector({
|
||||
const defaultIssue = useMemo(() => pickDefaultTarget(issues), [issues]);
|
||||
const activeIssues = issues.filter((i) => isOpenStatus(i.status));
|
||||
const closedIssues = issues.filter((i) => !isOpenStatus(i.status));
|
||||
// id → identifier, to label a run with the parent it hangs under (↳ CMP-189).
|
||||
const idToIdentifier = useMemo(
|
||||
() => new Map(issues.map((i) => [i.id, i.identifier])),
|
||||
[issues],
|
||||
);
|
||||
const parentTag = (i: PaperclipIssue) =>
|
||||
i.parent_id ? idToIdentifier.get(i.parent_id) : undefined;
|
||||
|
||||
// The issue the pill currently represents (explicit pick, or the auto default).
|
||||
const effectiveIssue =
|
||||
@@ -925,7 +998,31 @@ function TargetSelector({
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="absolute z-20 top-full mt-1 start-0 w-[360px] max-w-[88vw] bg-white border border-rule rounded-xl shadow-lg p-1.5">
|
||||
<div className="absolute z-20 top-full mt-1 start-0 w-[360px] max-w-[88vw] max-h-[62vh] overflow-y-auto overscroll-contain bg-white border border-rule rounded-xl shadow-lg p-1.5">
|
||||
{/* "פתח הרצה חדשה" is the FIRST, most-prominent option so it's always
|
||||
visible without scrolling to the bottom of a long task list. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ kind: "new_run" });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-start bg-gold/10 border border-rule hover:border-gold/60"
|
||||
>
|
||||
<span className="w-6 h-6 rounded-md bg-navy text-white flex items-center justify-center shrink-0">
|
||||
<Plus className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-semibold text-navy">
|
||||
פתח הרצה חדשה
|
||||
</span>
|
||||
<span className="block text-[10px] text-ink-muted">
|
||||
ה-CEO ייצור משימה חדשה ויטפל בהוראה מאפס
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div className="h-px bg-rule-soft my-1.5 mx-1" />
|
||||
|
||||
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
|
||||
משימות פעילות
|
||||
</div>
|
||||
@@ -933,6 +1030,7 @@ function TargetSelector({
|
||||
const selected =
|
||||
(target.kind === "issue" && target.id === i.id) ||
|
||||
(target.kind === "auto" && defaultIssue?.id === i.id);
|
||||
const parent = parentTag(i);
|
||||
return (
|
||||
<button
|
||||
key={i.id}
|
||||
@@ -941,12 +1039,20 @@ function TargetSelector({
|
||||
onChange({ kind: "issue", id: i.id });
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg text-start hover:bg-sand-soft ${selected ? "bg-gold/10" : ""}`}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg text-start hover:bg-sand-soft ${parent ? "ps-6" : ""} ${selected ? "bg-gold/10" : ""}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0" />
|
||||
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||
{i.identifier}
|
||||
</Badge>
|
||||
{parent && (
|
||||
<span
|
||||
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||
title={`תת-משימה של ${parent}`}
|
||||
>
|
||||
↳ {parent}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-ink-soft truncate flex-1">
|
||||
{shortIssueTitle(i.title)}
|
||||
</span>
|
||||
@@ -968,16 +1074,26 @@ function TargetSelector({
|
||||
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
|
||||
משימות סגורות · שליחה אליהן לא תעיר סוכן
|
||||
</div>
|
||||
{closedIssues.map((i) => (
|
||||
{closedIssues.map((i) => {
|
||||
const parent = parentTag(i);
|
||||
return (
|
||||
<div
|
||||
key={i.id}
|
||||
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed"
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed ${parent ? "ps-6" : ""}`}
|
||||
title="משימה סגורה — שליחה אליה לא תעיר סוכן"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-ink-faint shrink-0" />
|
||||
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||
{i.identifier}
|
||||
</Badge>
|
||||
{parent && (
|
||||
<span
|
||||
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||
title={`תת-משימה של ${parent}`}
|
||||
>
|
||||
↳ {parent}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-ink-faint truncate flex-1">
|
||||
{shortIssueTitle(i.title)}
|
||||
</span>
|
||||
@@ -987,31 +1103,10 @@ function TargetSelector({
|
||||
{issueStatusLabel(i.status)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="h-px bg-rule-soft my-1.5 mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ kind: "new_run" });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-start hover:bg-sand-soft"
|
||||
>
|
||||
<span className="w-6 h-6 rounded-md bg-navy text-white flex items-center justify-center shrink-0">
|
||||
<Plus className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-semibold text-navy">
|
||||
פתח הרצה חדשה
|
||||
</span>
|
||||
<span className="block text-[10px] text-ink-muted">
|
||||
ה-CEO ייצור משימה חדשה ויטפל בהוראה מאפס
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
304
web-ui/src/components/cases/case-files-browser.tsx
Normal file
304
web-ui/src/components/cases/case-files-browser.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
useCaseFiles,
|
||||
useCaseFileText,
|
||||
caseFileUrl,
|
||||
type CaseFileFolder,
|
||||
} from "@/lib/api/case-files";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatDateTime } from "@/lib/format-date";
|
||||
import {
|
||||
Folder,
|
||||
FileText,
|
||||
File as FileIcon,
|
||||
Download,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
const FOLDER_LABELS: Record<string, string> = {
|
||||
research: "מחקר וטיוטות",
|
||||
originals: "מסמכי מקור",
|
||||
extracted: "טקסט מחולץ (OCR)",
|
||||
proofread: "לאחר הגהה",
|
||||
drafts: "טיוטות DOCX",
|
||||
backup: "גיבויים",
|
||||
};
|
||||
|
||||
// Extensions the inline viewer can render (everything else → download only).
|
||||
const TEXT_EXT = new Set(["md", "txt", "json", "csv", "log", "markdown"]);
|
||||
|
||||
function fileExt(filename: string): string {
|
||||
return filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : "";
|
||||
}
|
||||
|
||||
function folderLabel(f: CaseFileFolder): string {
|
||||
return FOLDER_LABELS[f.name] ?? f.name;
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const EXT_TONE: Record<string, string> = {
|
||||
md: "bg-teal-100 text-teal-700",
|
||||
markdown: "bg-teal-100 text-teal-700",
|
||||
txt: "bg-blue-100 text-blue-700",
|
||||
json: "bg-blue-100 text-blue-700",
|
||||
pdf: "bg-red-100 text-red-700",
|
||||
docx: "bg-indigo-100 text-indigo-700",
|
||||
doc: "bg-indigo-100 text-indigo-700",
|
||||
};
|
||||
function extTone(ext: string): string {
|
||||
return EXT_TONE[ext] ?? "bg-gray-100 text-gray-600";
|
||||
}
|
||||
|
||||
type Selected = { folderKey: string; filename: string };
|
||||
|
||||
/* ── Component ───────────────────────────────────────────────── */
|
||||
|
||||
export function CaseFilesBrowser({ caseNumber }: { caseNumber: string }) {
|
||||
const { data, isLoading, error } = useCaseFiles(caseNumber);
|
||||
const folders = useMemo(() => data?.folders ?? [], [data?.folders]);
|
||||
|
||||
const totalFiles = folders.reduce((n, f) => n + f.files.length, 0);
|
||||
|
||||
// Default selection derived (not via an effect): a text file in research,
|
||||
// else any text file. The chair's explicit pick overrides it.
|
||||
const defaultSelected = useMemo<Selected | null>(() => {
|
||||
const pick = (
|
||||
pred: (f: CaseFileFolder, name: string) => boolean,
|
||||
): Selected | null => {
|
||||
for (const f of folders)
|
||||
for (const file of f.files)
|
||||
if (pred(f, file.filename))
|
||||
return { folderKey: f.key, filename: file.filename };
|
||||
return null;
|
||||
};
|
||||
return (
|
||||
pick((f, name) => f.name === "research" && TEXT_EXT.has(fileExt(name))) ??
|
||||
pick((_f, name) => TEXT_EXT.has(fileExt(name)))
|
||||
);
|
||||
}, [folders]);
|
||||
|
||||
const [userSelected, setUserSelected] = useState<Selected | null>(null);
|
||||
const selected = userSelected ?? defaultSelected;
|
||||
|
||||
// A folder is open if explicitly toggled; otherwise the folder of the current
|
||||
// selection (or the first non-empty folder) is open by default.
|
||||
const defaultOpenKey =
|
||||
selected?.folderKey ?? folders.find((f) => f.files.length)?.key ?? null;
|
||||
const [openOverrides, setOpenOverrides] = useState<Map<string, boolean>>(
|
||||
new Map(),
|
||||
);
|
||||
const isOpen = (key: string) =>
|
||||
openOverrides.has(key) ? openOverrides.get(key)! : key === defaultOpenKey;
|
||||
|
||||
const selExt = selected ? fileExt(selected.filename) : "";
|
||||
const selIsText = TEXT_EXT.has(selExt);
|
||||
const {
|
||||
data: fileText,
|
||||
isLoading: textLoading,
|
||||
error: textError,
|
||||
} = useCaseFileText(
|
||||
caseNumber,
|
||||
selIsText ? selected?.folderKey : undefined,
|
||||
selIsText ? selected?.filename : undefined,
|
||||
);
|
||||
|
||||
const toggleFolder = (key: string) => {
|
||||
const next = !isOpen(key);
|
||||
setOpenOverrides((prev) => new Map(prev).set(key, next));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-10 text-ink-faint">
|
||||
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||||
<span>טוען קבצים...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-10 text-red-500 text-sm">
|
||||
שגיאה בטעינת קבצי-התיק
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-rule rounded-xl overflow-hidden">
|
||||
{/* header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-rule-soft bg-parchment">
|
||||
<FolderOpen className="w-4 h-4 text-gold-deep" />
|
||||
<h3 className="text-sm font-bold text-navy">קבצי-התיק</h3>
|
||||
<span className="text-[11px] text-ink-muted ms-auto">
|
||||
כל תתי-התיקיות · {totalFiles} קבצים
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-[320px_1fr]">
|
||||
{/* file tree */}
|
||||
<div className="border-b md:border-b-0 md:border-e border-rule-soft max-h-[560px] overflow-y-auto">
|
||||
{folders.map((f) => {
|
||||
const open = isOpen(f.key);
|
||||
return (
|
||||
<div key={f.key} className="border-b border-rule-soft last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFolder(f.key)}
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-2 px-3.5 py-2.5 hover:bg-sand-soft/60 text-start"
|
||||
>
|
||||
<Folder className="w-4 h-4 text-gold-deep shrink-0" />
|
||||
<span className="text-[0.78rem] font-bold text-navy">
|
||||
{folderLabel(f)}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-ink-faint hidden sm:inline">
|
||||
{f.path}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold rounded-full px-2 ms-auto ${f.files.length ? "bg-gray-100 text-ink-muted" : "text-ink-light"}`}
|
||||
>
|
||||
{f.files.length || "ריק"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && f.files.length > 0 && (
|
||||
<div className="pb-1 bg-parchment/50">
|
||||
{f.files.map((file) => {
|
||||
const ext = fileExt(file.filename);
|
||||
const isSel =
|
||||
selected?.folderKey === f.key &&
|
||||
selected?.filename === file.filename;
|
||||
const viewable = TEXT_EXT.has(ext);
|
||||
return (
|
||||
<div
|
||||
key={file.filename}
|
||||
className={`flex items-center gap-2.5 py-1.5 pe-3.5 ps-5 cursor-pointer border-s-2 ${isSel ? "bg-gold-wash border-gold" : "border-transparent hover:bg-sand-soft/60"}`}
|
||||
onClick={() =>
|
||||
viewable
|
||||
? setUserSelected({
|
||||
folderKey: f.key,
|
||||
filename: file.filename,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`w-6 h-6 rounded-md flex items-center justify-center shrink-0 ${extTone(ext)}`}
|
||||
>
|
||||
{viewable ? (
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<FileIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11.5px] font-medium text-ink-soft truncate">
|
||||
{file.filename}
|
||||
</div>
|
||||
<div className="text-[9.5px] text-ink-light">
|
||||
{fmtSize(file.size)} ·{" "}
|
||||
{formatDateTime(file.modified_at * 1000)}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={caseFileUrl(caseNumber, f.key, file.filename)}
|
||||
download={file.filename}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-7 h-7 rounded-md border border-rule bg-surface flex items-center justify-center text-ink-muted hover:text-navy shrink-0"
|
||||
title="הורדה"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{folders.length === 0 && (
|
||||
<div className="text-center py-10 text-ink-faint text-sm">
|
||||
אין קבצים בתיק.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* viewer */}
|
||||
<div className="min-w-0 flex flex-col">
|
||||
{!selected ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center py-16 text-ink-faint text-sm">
|
||||
<FileText className="w-8 h-8 mb-2 opacity-40" />
|
||||
בחר קובץ טקסט מהרשימה לצפייה
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft">
|
||||
<span
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center shrink-0 ${extTone(selExt)}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12.5px] font-bold text-navy truncate">
|
||||
{selected.filename}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={caseFileUrl(
|
||||
caseNumber,
|
||||
selected.folderKey,
|
||||
selected.filename,
|
||||
)}
|
||||
download={selected.filename}
|
||||
className="ms-auto inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-navy bg-parchment border border-rule rounded-md px-3 py-1.5 hover:bg-sand-soft"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
הורדה
|
||||
</a>
|
||||
</div>
|
||||
<div className="px-5 py-4 overflow-y-auto max-h-[520px] bg-[#fdfcf9]">
|
||||
{!selIsText ? (
|
||||
<div className="text-center py-12 text-sm text-ink-muted">
|
||||
קובץ מסוג <Badge variant="outline">{selExt || "?"}</Badge> —
|
||||
לא ניתן לצפייה מוטבעת. הורד כדי לפתוח.
|
||||
</div>
|
||||
) : textLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-ink-faint">
|
||||
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||||
טוען תוכן...
|
||||
</div>
|
||||
) : textError ? (
|
||||
<div className="text-center py-12 text-red-500 text-sm">
|
||||
שגיאה בטעינת תוכן הקובץ
|
||||
</div>
|
||||
) : selExt === "md" || selExt === "markdown" ? (
|
||||
<Markdown content={fileText ?? ""} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-xs text-ink-soft font-mono leading-relaxed">
|
||||
{fileText}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -208,6 +208,25 @@ export function useDismissInteraction(caseNumber: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Manually close a board issue to done/cancelled — the chair tidying the agents
|
||||
* board (e.g. superseded runs left in in_review). Backend does a loop-safe
|
||||
* direct close and issues no wakeup; only the two terminal statuses are allowed. */
|
||||
export function useSetIssueStatus(caseNumber: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vars: { issue_id: string; status: "done" | "cancelled" }) =>
|
||||
apiRequest<{ ok: boolean; id: string; identifier: string; status: string }>(
|
||||
`/api/cases/${caseNumber}/agents/issue-status`,
|
||||
{ method: "POST", body: vars },
|
||||
),
|
||||
onSuccess: () => {
|
||||
if (caseNumber) {
|
||||
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type AgentResetResult = {
|
||||
ok: boolean;
|
||||
reassigned_issues: { id: string; identifier: string }[];
|
||||
|
||||
66
web-ui/src/lib/api/case-files.ts
Normal file
66
web-ui/src/lib/api/case-files.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Case files API — browse & read every content subfolder of a case on disk.
|
||||
*
|
||||
* Surfaces the agent-produced work files (drafts, OCR text, research, originals…)
|
||||
* that previously lived only on the server filesystem. Backed by the pre-existing
|
||||
* `/local-files` endpoints, generalized to all subfolders (path-safe on the
|
||||
* server via `_resolve_case_file`).
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type CaseFile = {
|
||||
filename: string;
|
||||
size: number;
|
||||
/** Unix epoch seconds (float) of last modification. */
|
||||
modified_at: number;
|
||||
};
|
||||
|
||||
export type CaseFileFolder = {
|
||||
/** Opaque folder key for the read endpoint (e.g. "documents__research"). */
|
||||
key: string;
|
||||
/** Leaf folder name (e.g. "research"). */
|
||||
name: string;
|
||||
/** Case-relative path (e.g. "documents/research"). */
|
||||
path: string;
|
||||
files: CaseFile[];
|
||||
};
|
||||
|
||||
export type CaseFilesResponse = { folders: CaseFileFolder[] };
|
||||
|
||||
export function useCaseFiles(caseNumber: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["case-files", caseNumber ?? ""],
|
||||
queryFn: () =>
|
||||
apiRequest<CaseFilesResponse>(`/api/cases/${caseNumber}/local-files`),
|
||||
enabled: !!caseNumber,
|
||||
});
|
||||
}
|
||||
|
||||
/** Direct URL to a single file — use as an <a href> for download/open. */
|
||||
export function caseFileUrl(
|
||||
caseNumber: string,
|
||||
folderKey: string,
|
||||
filename: string,
|
||||
): string {
|
||||
return `/api/cases/${encodeURIComponent(caseNumber)}/local-files/${encodeURIComponent(folderKey)}/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
/** Fetch a text file's contents (md/txt/json) for the inline viewer. */
|
||||
export function useCaseFileText(
|
||||
caseNumber: string | undefined,
|
||||
folderKey: string | undefined,
|
||||
filename: string | undefined,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["case-file-text", caseNumber ?? "", folderKey ?? "", filename ?? ""],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(caseFileUrl(caseNumber!, folderKey!, filename!));
|
||||
if (!res.ok) throw new Error(`שגיאה בטעינת הקובץ (${res.status})`);
|
||||
return res.text();
|
||||
},
|
||||
enabled: !!caseNumber && !!folderKey && !!filename,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -63,10 +63,12 @@ from web.paperclip_client import (
|
||||
cancel_interaction as _cancel_interaction,
|
||||
cancel_run as _cancel_run,
|
||||
escalate_issue as _escalate_issue,
|
||||
mark_comment_routed as _mark_comment_routed,
|
||||
post_comment as _post_comment,
|
||||
reap_stale_interactions as _reap_stale_interactions,
|
||||
reset_agent_session as _reset_agent_session,
|
||||
reset_case_agents as _reset_case_agents,
|
||||
set_issue_status as _set_issue_status,
|
||||
wake_analyst_for_appraiser_facts as _wake_analyst_for_appraiser_facts,
|
||||
wake_analyst_for_argument_aggregation as _wake_analyst_for_argument_aggregation,
|
||||
wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis,
|
||||
@@ -114,6 +116,9 @@ pc_wake_analyst_for_protocol_analysis = instrument(
|
||||
pc_post_comment = instrument(
|
||||
"agent.comment", keys=("issue_id", "company_id"),
|
||||
)(_post_comment)
|
||||
pc_mark_comment_routed = instrument(
|
||||
"agent.comment", keys=("issue_id", "comment_id"),
|
||||
)(_mark_comment_routed)
|
||||
pc_cancel_interaction = instrument(
|
||||
"interaction.cancelled", keys=("issue_id", "interaction_id"),
|
||||
)(_cancel_interaction)
|
||||
@@ -123,6 +128,12 @@ pc_cancel_interaction = instrument(
|
||||
pc_escalate_issue = instrument(
|
||||
"agent.escalated", keys=("issue_id", "severity", "company_id", "reason"),
|
||||
)(_escalate_issue)
|
||||
# Manual chair close of a board issue (done/cancelled) — the loop-safe primitive
|
||||
# behind the agents-board "סמן כהושלם / בטל" actions. Countable per case in the
|
||||
# same telemetry stream as the escalations/wakeups it sits beside.
|
||||
pc_set_issue_status = instrument(
|
||||
"issue.status_set", keys=("issue_id", "status", "company_id"),
|
||||
)(_set_issue_status)
|
||||
pc_reap_stale_interactions = instrument(
|
||||
"interaction.reaped", result_keys=("cancelled",),
|
||||
)(_reap_stale_interactions)
|
||||
@@ -179,6 +190,7 @@ __all__ = [
|
||||
"pc_get_generation_run_status",
|
||||
# comments / interactions
|
||||
"pc_post_comment",
|
||||
"pc_mark_comment_routed",
|
||||
"pc_get_issue_comments",
|
||||
"pc_get_issue_interactions",
|
||||
"pc_accept_interaction",
|
||||
@@ -187,6 +199,7 @@ __all__ = [
|
||||
"pc_cancel_interaction",
|
||||
"pc_reap_stale_interactions",
|
||||
"pc_escalate_issue",
|
||||
"pc_set_issue_status",
|
||||
# agent-run observability + control (live view + smart management)
|
||||
"pc_get_agent_health",
|
||||
"pc_get_recent_escalations",
|
||||
|
||||
197
web/app.py
197
web/app.py
@@ -82,6 +82,7 @@ from web.agent_platform_port import (
|
||||
pc_reset_agent_session,
|
||||
pc_reset_case_agents,
|
||||
pc_respond_to_interaction,
|
||||
pc_set_issue_status,
|
||||
pc_restore_project,
|
||||
pc_wake_analyst_for_appraiser_facts,
|
||||
pc_wake_analyst_for_argument_aggregation,
|
||||
@@ -91,6 +92,7 @@ from web.agent_platform_port import (
|
||||
pc_wake_ceo,
|
||||
pc_wake_ceo_for_action,
|
||||
pc_open_ceo_run,
|
||||
pc_mark_comment_routed,
|
||||
pc_wake_ceo_for_feedback_fold,
|
||||
pc_wake_curator_for_final,
|
||||
pc_wake_for_precedent_extraction,
|
||||
@@ -3069,42 +3071,82 @@ async def api_learn(case_number: str):
|
||||
# ── Local files API — research, drafts, proofread ──
|
||||
|
||||
|
||||
# Directories under a case that are infrastructure/cache, not chair-facing
|
||||
# content. Hidden dirs (``.git``, ``.claude`` …) are skipped separately.
|
||||
_CASE_FILE_EXCLUDE_DIRS = {"thumbnails"}
|
||||
|
||||
|
||||
def _is_content_dir(p: Path) -> bool:
|
||||
return p.is_dir() and not p.name.startswith(".") and p.name not in _CASE_FILE_EXCLUDE_DIRS
|
||||
|
||||
|
||||
def _case_file_folders(case_dir: Path) -> list[tuple[str, Path]]:
|
||||
"""Enumerate every content subdirectory under a case dir as (key, path).
|
||||
|
||||
Covers *all* subfolders (not just research) — recursing one level into
|
||||
``documents/`` — while skipping hidden (``.git``/``.claude``…) and cache
|
||||
dirs. The ``key`` is the case-relative path with ``/`` encoded as ``__`` so
|
||||
it survives a single URL path segment; ``_resolve_case_file`` re-validates it
|
||||
against this same enumeration, so the key set is the serve allowlist (no path
|
||||
traversal).
|
||||
"""
|
||||
folders: list[tuple[str, Path]] = []
|
||||
if not case_dir.exists():
|
||||
return folders
|
||||
for child in sorted(case_dir.iterdir()):
|
||||
if not _is_content_dir(child):
|
||||
continue
|
||||
if child.name == "documents":
|
||||
folders.extend(
|
||||
(f"documents__{sub.name}", sub)
|
||||
for sub in sorted(child.iterdir())
|
||||
if _is_content_dir(sub)
|
||||
)
|
||||
else:
|
||||
folders.append((child.name, child))
|
||||
return folders
|
||||
|
||||
|
||||
def _resolve_case_file(case_dir: Path, folder_key: str, filename: str) -> Path | None:
|
||||
"""Safely resolve (folder_key, filename) to a file inside the case dir.
|
||||
|
||||
Returns None unless ``folder_key`` is one of the enumerated content folders
|
||||
AND ``filename`` resolves to a regular file directly inside it (no traversal).
|
||||
"""
|
||||
base = dict(_case_file_folders(case_dir)).get(folder_key)
|
||||
if base is None:
|
||||
return None
|
||||
target = (base / filename).resolve()
|
||||
if target.parent != base.resolve() or not target.is_file():
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/local-files")
|
||||
async def api_local_files(case_number: str):
|
||||
"""List local files from case subdirectories (research, drafts, proofread)."""
|
||||
"""List local files across ALL content subfolders under a case (not just
|
||||
research) — so the chair can reach any agent-produced file (drafts, OCR text,
|
||||
originals…) from the UI instead of only over SSH. Empty folders are returned
|
||||
too (with an empty ``files`` list) so the UI can show them as such."""
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
result = {}
|
||||
for folder in ("research", "proofread"):
|
||||
folder_path = case_dir / "documents" / folder
|
||||
if folder_path.exists():
|
||||
folders = []
|
||||
for key, path in _case_file_folders(case_dir):
|
||||
files = []
|
||||
for f in sorted(folder_path.iterdir()):
|
||||
for f in sorted(path.iterdir()):
|
||||
if f.is_file() and not f.name.startswith("."):
|
||||
stat = f.stat()
|
||||
files.append({
|
||||
"filename": f.name,
|
||||
"size": stat.st_size,
|
||||
"modified_at": stat.st_mtime,
|
||||
"folder": folder,
|
||||
})
|
||||
if files:
|
||||
result[folder] = files
|
||||
# Drafts are at case level, not under documents
|
||||
drafts_path = case_dir / "drafts"
|
||||
if drafts_path.exists():
|
||||
files = []
|
||||
for f in sorted(drafts_path.iterdir()):
|
||||
if f.is_file() and not f.name.startswith("."):
|
||||
stat = f.stat()
|
||||
files.append({
|
||||
"filename": f.name,
|
||||
"size": stat.st_size,
|
||||
"modified_at": stat.st_mtime,
|
||||
"folder": "drafts",
|
||||
folders.append({
|
||||
"key": key,
|
||||
"name": path.name,
|
||||
"path": str(path.relative_to(case_dir)),
|
||||
"files": files,
|
||||
})
|
||||
if files:
|
||||
result["drafts"] = files
|
||||
return result
|
||||
return {"folders": folders}
|
||||
|
||||
|
||||
async def serve_blob(
|
||||
@@ -3174,17 +3216,32 @@ async def _seal_blob_file(dest: Path, *, bucket=storage.Bucket.DOCUMENTS) -> Non
|
||||
|
||||
@app.get("/api/cases/{case_number}/local-files/{folder}/{filename}")
|
||||
async def api_read_local_file(case_number: str, folder: str, filename: str):
|
||||
"""Read contents of a local case file."""
|
||||
if folder not in ("research", "proofread", "drafts"):
|
||||
raise HTTPException(400, "Invalid folder")
|
||||
"""Serve a single local case file from any content subfolder.
|
||||
|
||||
``folder`` is a key from ``/local-files`` (``documents__research``, ``drafts``…);
|
||||
``_resolve_case_file`` validates it against the folder allowlist and blocks
|
||||
path traversal before the bytes are served.
|
||||
"""
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
if folder == "drafts":
|
||||
path = case_dir / "drafts" / filename
|
||||
else:
|
||||
path = case_dir / "documents" / folder / filename
|
||||
if not path.exists() or not path.is_file():
|
||||
path = _resolve_case_file(case_dir, folder, filename)
|
||||
if path is None:
|
||||
raise HTTPException(404, "קובץ לא נמצא")
|
||||
return await serve_blob(path, media_type="text/plain; charset=utf-8", filename=filename)
|
||||
media_type = _local_file_media_type(filename)
|
||||
return await serve_blob(path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
def _local_file_media_type(filename: str) -> str:
|
||||
"""Best-effort content type for a served case file (text renders inline,
|
||||
binaries download)."""
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
return {
|
||||
"md": "text/markdown; charset=utf-8",
|
||||
"txt": "text/plain; charset=utf-8",
|
||||
"json": "application/json; charset=utf-8",
|
||||
"pdf": "application/pdf",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"doc": "application/msword",
|
||||
}.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
# ── Research analysis (analysis-and-research.md) — parse + edit ────
|
||||
@@ -4605,8 +4662,9 @@ async def api_case_agents(case_number: str):
|
||||
class AgentCommentRequest(BaseModel):
|
||||
body: str
|
||||
issue_id: str | None = None
|
||||
# "פתח הרצה חדשה" — open a fresh CEO-owned run for this instruction instead of
|
||||
# appending to an existing issue (agents-tab target selector). Overrides issue_id.
|
||||
# "פתח הרצה חדשה" — skip the comment entirely and only open a CEO run. Kept for
|
||||
# the agents-tab target selector; the default path below now opens a run too, so
|
||||
# this only differs in *not* recording the instruction on the parent thread.
|
||||
new_run: bool = False
|
||||
|
||||
|
||||
@@ -4650,10 +4708,31 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
||||
|
||||
result = await pc_post_comment(target["id"], target["company_id"], req.body)
|
||||
|
||||
# The comment alone reaches nobody: a case issue awaiting the chair is
|
||||
# `in_review` and owned by a human, so Paperclip cancels any wakeup on it
|
||||
# (`issue_assignee_changed` → "wake the new owner" → the owner is a person).
|
||||
# The plugin sweep that was meant to catch this delivers the instruction as
|
||||
# `payload.prompt`, which the runner drops. So the comment is recorded for the
|
||||
# thread, and the instruction is carried by a CEO-owned child run — the one
|
||||
# path verified to reach the agent (TaskMaster #228; see paperclip-quirks.md).
|
||||
run = await pc_open_ceo_run(case_number, req.body)
|
||||
if run.get("status") != "ok":
|
||||
raise HTTPException(
|
||||
502,
|
||||
f"ההערה נשמרה אך פתיחת ההרצה נכשלה — הסוכן לא יטפל בה: "
|
||||
f"{run.get('reason', 'unknown')}",
|
||||
)
|
||||
# Claim the comment so the sweep does not re-route it: the CEO answers on the
|
||||
# child issue, leaving the parent with no agent reply, which the sweep reads
|
||||
# as "still pending" forever.
|
||||
await pc_mark_comment_routed(target["id"], result.get("comment_id", ""))
|
||||
|
||||
# Echo the resolved target so the UI can show where it landed and flag it
|
||||
# when the chair explicitly targeted a closed issue (whose wakeup is skipped).
|
||||
result["issue_identifier"] = target.get("identifier", "")
|
||||
result["issue_status"] = target.get("status", "")
|
||||
result["run_issue_id"] = run["issue_id"]
|
||||
result["run_issue_identifier"] = run.get("identifier", "")
|
||||
return result
|
||||
|
||||
|
||||
@@ -4670,8 +4749,10 @@ async def api_post_interaction_response(
|
||||
):
|
||||
"""Submit a user's answer to a Paperclip issue-thread interaction.
|
||||
|
||||
Routes to /respond | /accept | /reject based on `action`. Paperclip
|
||||
auto-wakes the issue assignee after a successful submission.
|
||||
Routes to /respond | /accept | /reject based on `action`, then opens a CEO run
|
||||
carrying the answer. Paperclip's own `wake_assignee` cannot deliver it: the
|
||||
issue is owned by the chair while it waits for her, so the "assignee" it wakes
|
||||
is a person and no agent ever runs (TaskMaster #228).
|
||||
"""
|
||||
issues = await pc_get_case_issues(case_number)
|
||||
if not any(i["id"] == req.issue_id for i in issues):
|
||||
@@ -4683,7 +4764,7 @@ async def api_post_interaction_response(
|
||||
"reject": pc_reject_interaction,
|
||||
}
|
||||
try:
|
||||
return await handlers[req.action](
|
||||
result = await handlers[req.action](
|
||||
req.issue_id, req.interaction_id, req.payload,
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
@@ -4692,6 +4773,23 @@ async def api_post_interaction_response(
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"שגיאת Paperclip: {e}")
|
||||
|
||||
answer = json.dumps(req.payload, ensure_ascii=False)
|
||||
run = await pc_open_ceo_run(
|
||||
case_number,
|
||||
f'תשובת היו"ר לשאלה שהוצגה לה ({req.action}):\n\n{answer}\n\n'
|
||||
f"קרא את ה-interaction המקורי על ה-issue והמשך משם.",
|
||||
)
|
||||
if run.get("status") != "ok":
|
||||
raise HTTPException(
|
||||
502,
|
||||
f"התשובה נשמרה אך פתיחת ההרצה נכשלה — הסוכן לא ימשיך: "
|
||||
f"{run.get('reason', 'unknown')}",
|
||||
)
|
||||
if isinstance(result, dict):
|
||||
result["run_issue_id"] = run["issue_id"]
|
||||
result["run_issue_identifier"] = run.get("identifier", "")
|
||||
return result
|
||||
|
||||
|
||||
class InteractionDismissRequest(BaseModel):
|
||||
issue_id: str
|
||||
@@ -4754,6 +4852,29 @@ async def api_escalate_issue(case_number: str, req: EscalateRequest):
|
||||
return result
|
||||
|
||||
|
||||
class IssueStatusRequest(BaseModel):
|
||||
issue_id: str
|
||||
status: Literal["done", "cancelled"]
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/agents/issue-status")
|
||||
async def api_set_issue_status(case_number: str, req: IssueStatusRequest):
|
||||
"""Manually close a board issue to done/cancelled from the chair UI.
|
||||
|
||||
Lets the chair tidy the agents board by hand — superseded "הרצה חדשה" runs
|
||||
left in ``in_review`` had no manual close control and piled up. Only the two
|
||||
terminal statuses are accepted; the loop-safe direct-DB close lives behind the
|
||||
Port (``pc_set_issue_status``) and issues no wakeup.
|
||||
"""
|
||||
issues = await pc_get_case_issues(case_number)
|
||||
if not any(i["id"] == req.issue_id for i in issues):
|
||||
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||
result = await pc_set_issue_status(req.issue_id, req.status)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(400, result.get("error", "עדכון הסטטוס נכשל"))
|
||||
return result
|
||||
|
||||
|
||||
# ── Settings: MCP Server Configuration ────────────────────────────
|
||||
#
|
||||
# Source of truth for legal-ai env vars is Coolify (see memory:
|
||||
|
||||
@@ -327,6 +327,40 @@ async def _create_issue(
|
||||
return issue_id, identifier
|
||||
|
||||
|
||||
async def mark_comment_routed(issue_id: str, comment_id: str) -> dict:
|
||||
"""Claim a chair comment as already routed, so the plugin sweep skips it.
|
||||
|
||||
The plugin's `route-pending-comments` sweep re-routes any user comment newer
|
||||
than the newest agent comment on the same issue. A chair instruction answered
|
||||
on a CEO **child** issue leaves the parent with no agent reply, so the sweep
|
||||
would keep re-routing it forever. Writing the sweep's own marker
|
||||
(`last-routed-comment-id`, plugin state, issue scope) is what tells it the
|
||||
comment is handled — this is the same key `markCommentRouted` sets in
|
||||
`plugin-legal-ai/src/worker.ts`.
|
||||
"""
|
||||
if not comment_id:
|
||||
return {"ok": False, "error": "no_comment_id"}
|
||||
try:
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
await conn.execute(
|
||||
"""INSERT INTO plugin_state (plugin_id, scope_kind, scope_id, namespace, state_key, value_json)
|
||||
VALUES ($1::uuid, 'issue', $2, 'default', 'last-routed-comment-id', $3::jsonb)
|
||||
ON CONFLICT (plugin_id, scope_kind, scope_id, namespace, state_key)
|
||||
DO UPDATE SET value_json = $3::jsonb""",
|
||||
PLUGIN_ID, issue_id, json.dumps(comment_id),
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
return {"ok": True, "issue_id": issue_id, "comment_id": comment_id}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"mark_comment_routed failed for issue %s comment %s: %s",
|
||||
issue_id, comment_id, e,
|
||||
)
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _link_case_to_issue(conn: asyncpg.Connection, issue_id: str, case_number: str) -> None:
|
||||
"""Store the legal-ai case number in plugin state, linked to the issue."""
|
||||
await conn.execute(
|
||||
@@ -655,10 +689,14 @@ async def get_agents_for_case(company_id: str, issue_ids: list[str]) -> list[dic
|
||||
|
||||
|
||||
async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
||||
"""Post a comment on a Paperclip issue.
|
||||
"""Post a comment on a Paperclip issue. Records only — wakes nobody.
|
||||
|
||||
Tries the Board API first (triggers plugin events for CEO routing).
|
||||
Falls back to direct DB insert + CEO wakeup if API fails.
|
||||
Delivering the instruction is the caller's job, via :func:`open_ceo_run`. This
|
||||
function used to also wake the CEO on ``issue_id``, but that wakeup is dead on
|
||||
arrival for the case issues the chair actually comments on: they are
|
||||
`in_review` and owned by her, so Paperclip cancels the run with
|
||||
`issue_assignee_changed` before it starts. Keeping it would leave two parallel
|
||||
delivery paths — one that works and one that silently doesn't (INV-G2).
|
||||
"""
|
||||
# Try Board API first — this triggers the event bus
|
||||
if PAPERCLIP_BOARD_API_KEY:
|
||||
@@ -675,7 +713,6 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
||||
except Exception:
|
||||
logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id)
|
||||
|
||||
# Fallback: direct DB insert + explicit CEO wakeup
|
||||
comment_id = str(uuid.uuid4())
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
@@ -688,23 +725,6 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
# Wake the correct CEO for this company
|
||||
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||
try:
|
||||
await pc_request(
|
||||
"POST",
|
||||
f"/api/agents/{ceo_id}/wakeup",
|
||||
json={
|
||||
"source": "on_demand",
|
||||
"triggerDetail": "manual",
|
||||
"reason": f"user_comment_{issue_id}",
|
||||
"payload": {"issueId": issue_id, "mutation": "comment"},
|
||||
},
|
||||
raise_on_error=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to wake CEO after DB comment on issue %s", issue_id)
|
||||
|
||||
return {"comment_id": comment_id, "issue_id": issue_id, "method": "db_fallback"}
|
||||
|
||||
|
||||
@@ -968,6 +988,70 @@ async def escalate_issue(
|
||||
}
|
||||
|
||||
|
||||
async def set_issue_status(
|
||||
issue_id: str, status: str, company_id: str = "",
|
||||
) -> dict:
|
||||
"""Manually close a case issue to ``done`` or ``cancelled`` from the chair UI.
|
||||
|
||||
The chair needs to tidy the agents board by hand: superseded "הרצה חדשה" runs
|
||||
that were replaced by a later run pile up in ``in_review`` with no way to close
|
||||
them — there was no manual control, so they had to be closed out-of-band. This
|
||||
is the loop-safe primitive behind the board's "סמן כהושלם / בטל" actions.
|
||||
|
||||
Only the two **terminal** statuses are accepted (``CLOSED_ISSUE_STATUSES``) —
|
||||
this tidies the board, it does not drive the workflow (agents own the
|
||||
todo/in_progress/in_review transitions). Direct-DB in one transaction,
|
||||
mirroring :func:`escalate_issue`: bypasses Paperclip's disposition resolver
|
||||
(whose multi-PATCH/``issue.released`` behaviour *causes* the recovery loops),
|
||||
sets the matching close timestamp, clears any agent assignee so no recovery
|
||||
sweep re-acts on it, and records an ``author_type='system'`` audit note (inert
|
||||
w.r.t. the user-comment routing sweep, so it will not wake an agent). No wakeup.
|
||||
"""
|
||||
if status not in CLOSED_ISSUE_STATUSES:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"invalid status {status!r}; expected one of {tuple(CLOSED_ISSUE_STATUSES)}",
|
||||
}
|
||||
|
||||
# status is validated against the frozenset above, so the interpolated column
|
||||
# name is one of exactly two literals — no injection surface.
|
||||
ts_col = "completed_at" if status == "done" else "cancelled_at"
|
||||
note = (
|
||||
"✓ סומן ידנית כהושלם ע\"י היו\"ר"
|
||||
if status == "done"
|
||||
else "✕ בוטל ידנית ע\"י היו\"ר"
|
||||
)
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
async with conn.transaction():
|
||||
row = await conn.fetchrow(
|
||||
f"""UPDATE issues
|
||||
SET status=$1, {ts_col}=now(),
|
||||
assignee_agent_id=null, updated_at=now()
|
||||
WHERE id=$2::uuid
|
||||
RETURNING id, identifier, company_id""",
|
||||
status, issue_id,
|
||||
)
|
||||
if not row:
|
||||
return {"ok": False, "error": f"issue {issue_id} not found"}
|
||||
cid = company_id or str(row["company_id"])
|
||||
await conn.execute(
|
||||
"""INSERT INTO issue_comments (id, company_id, issue_id, body, author_type)
|
||||
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, 'system')""",
|
||||
str(uuid.uuid4()), cid, issue_id, note,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
logger.info("Chair set issue %s → %s", issue_id, status)
|
||||
return {
|
||||
"ok": True,
|
||||
"id": str(row["id"]),
|
||||
"identifier": row["identifier"],
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
async def respond_to_interaction(
|
||||
issue_id: str, interaction_id: str, payload: dict,
|
||||
) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user