Compare commits
10 Commits
7d1fc49e35
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d227a4999 | |||
| f58ddba93e | |||
| 29970ad8b2 | |||
| 85af98cded | |||
| 107be235f5 | |||
| 9e7d98a47a | |||
| 0a5be942ba | |||
| 67d5835eb8 | |||
| 1d8c4e29c8 | |||
| 943696efbd |
@@ -101,6 +101,8 @@ ATTACHMENTS=$(echo "$CONTEXT" | jq '.attachments')
|
|||||||
```
|
```
|
||||||
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי.
|
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי.
|
||||||
|
|
||||||
|
**1.5ד. המשך-עבודה על תיק — קרא predecessor תחילה (seance, #220).** אם אתה מתעורר-מחדש על תיק שכבר עבדת עליו (continuation — issue פתוח עם היסטוריית-ריצות), **לפני** שתגלה-מחדש את ההקשר מאפס, קרא את הכלי `legal_predecessor_context(case_number=<מספר-התיק>)`. הוא מחזיר את מסקנות הריצות-הקודמות על התיק (ה-`summary` שכל heartbeat השאיר, newest-first) — כך תמשיך מנקודת-העצירה במקום לחזור על עבודה שכבר בוצעה (מונע את ה-*blind heartbeat*). אם אין ריצות-קודמות עם מסקנות — המשך רגיל.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**
|
## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**
|
||||||
|
|||||||
@@ -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`).
|
- **תוקן בצד שלנו** (PR #2, נפרס דרך `npm run build` + `pm2 restart paperclip` — הפלאגין נטען מ-`/home/chaim/plugin-legal-ai` לפי `package_path`).
|
||||||
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
|
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
|
||||||
- TaskMaster: `legal-ai` #149.
|
- 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 { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
||||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||||
import { DraftsPanel } from "@/components/cases/drafts-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 { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||||||
import { PositionsPanel } from "@/components/cases/positions-panel";
|
import { PositionsPanel } from "@/components/cases/positions-panel";
|
||||||
@@ -243,12 +244,17 @@ export default function CaseDetailPage({
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="drafts" className="mt-0">
|
<TabsContent value="drafts" className="mt-0 space-y-4">
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-6 py-5">
|
<CardContent className="px-6 py-5">
|
||||||
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<CaseFilesBrowser caseNumber={caseNumber} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="agents" className="mt-0">
|
<TabsContent value="agents" className="mt-0">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, useEffect, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -34,6 +34,9 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Ban,
|
Ban,
|
||||||
|
Target,
|
||||||
|
Plus,
|
||||||
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/* ── Role → color mapping ────────────────────────────────────── */
|
/* ── Role → color mapping ────────────────────────────────────── */
|
||||||
@@ -818,6 +821,312 @@ function IssueGroup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Compose: directive box + target selector (top of tab) ────── */
|
||||||
|
|
||||||
|
const isOpenStatus = (s: string) => s !== "done" && s !== "cancelled";
|
||||||
|
|
||||||
|
/** Client mirror of the server's pick_default_comment_target
|
||||||
|
* (web/paperclip_client.py): prefer the newest OPEN top-level issue (the live
|
||||||
|
* CEO main issue), then any open issue, then any top-level, else newest.
|
||||||
|
* `issues` arrives oldest→newest, so the last match is the newest. */
|
||||||
|
function pickDefaultTarget(issues: PaperclipIssue[]): PaperclipIssue | null {
|
||||||
|
if (!issues.length) return null;
|
||||||
|
const isTop = (i: PaperclipIssue) => i.parent_id == null;
|
||||||
|
const preds: ((i: PaperclipIssue) => boolean)[] = [
|
||||||
|
(i) => isOpenStatus(i.status) && isTop(i),
|
||||||
|
(i) => isOpenStatus(i.status),
|
||||||
|
isTop,
|
||||||
|
];
|
||||||
|
for (const p of preds) {
|
||||||
|
const m = issues.filter(p);
|
||||||
|
if (m.length) return m[m.length - 1];
|
||||||
|
}
|
||||||
|
return issues[issues.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_TONE: Record<string, string> = {
|
||||||
|
in_progress: "bg-emerald-100 text-emerald-700",
|
||||||
|
in_review: "bg-amber-100 text-amber-700",
|
||||||
|
todo: "bg-emerald-100 text-emerald-700",
|
||||||
|
backlog: "bg-emerald-100 text-emerald-700",
|
||||||
|
blocked: "bg-red-100 text-red-700",
|
||||||
|
done: "bg-gray-100 text-gray-500",
|
||||||
|
cancelled: "bg-red-50 text-red-600",
|
||||||
|
};
|
||||||
|
function statusTone(s: string) {
|
||||||
|
return STATUS_TONE[s] ?? "bg-gray-100 text-gray-600";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a chair instruction is routed. `auto` lets the server pick the live
|
||||||
|
* CEO main issue; `issue` targets an explicit one; `new_run` opens a fresh
|
||||||
|
* CEO-owned run (bypasses the human-owned-issue cancellation). */
|
||||||
|
type Target =
|
||||||
|
| { kind: "auto" }
|
||||||
|
| { kind: "issue"; id: string }
|
||||||
|
| { kind: "new_run" };
|
||||||
|
|
||||||
|
function TargetSelector({
|
||||||
|
issues,
|
||||||
|
target,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
issues: PaperclipIssue[];
|
||||||
|
target: Target;
|
||||||
|
onChange: (t: Target) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const defaultIssue = useMemo(() => pickDefaultTarget(issues), [issues]);
|
||||||
|
const activeIssues = issues.filter((i) => isOpenStatus(i.status));
|
||||||
|
const closedIssues = issues.filter((i) => !isOpenStatus(i.status));
|
||||||
|
|
||||||
|
// The issue the pill currently represents (explicit pick, or the auto default).
|
||||||
|
const effectiveIssue =
|
||||||
|
target.kind === "issue"
|
||||||
|
? issues.find((i) => i.id === target.id) ?? null
|
||||||
|
: target.kind === "auto"
|
||||||
|
? defaultIssue
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex items-center gap-2 bg-sand-soft/60 border border-rule rounded-lg px-2.5 py-1.5 hover:border-gold/60 min-w-0 max-w-full"
|
||||||
|
>
|
||||||
|
<Target className="w-3.5 h-3.5 text-gold-deep shrink-0" />
|
||||||
|
{target.kind === "new_run" ? (
|
||||||
|
<span className="text-xs font-semibold text-navy">פתח הרצה חדשה</span>
|
||||||
|
) : effectiveIssue ? (
|
||||||
|
<>
|
||||||
|
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||||
|
{effectiveIssue.identifier}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs font-medium text-navy truncate">
|
||||||
|
{shortIssueTitle(effectiveIssue.title)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(effectiveIssue.status)}`}
|
||||||
|
>
|
||||||
|
{issueStatusLabel(effectiveIssue.status)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-ink-muted">בחר יעד</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="w-3.5 h-3.5 text-ink-faint shrink-0 ms-1" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
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="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
|
||||||
|
משימות פעילות
|
||||||
|
</div>
|
||||||
|
{activeIssues.map((i) => {
|
||||||
|
const selected =
|
||||||
|
(target.kind === "issue" && target.id === i.id) ||
|
||||||
|
(target.kind === "auto" && defaultIssue?.id === i.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
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" : ""}`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<span className="text-xs text-ink-soft truncate flex-1">
|
||||||
|
{shortIssueTitle(i.title)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
|
||||||
|
>
|
||||||
|
{issueStatusLabel(i.status)}
|
||||||
|
</span>
|
||||||
|
{selected && (
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-gold-deep shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{closedIssues.length > 0 && (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
{closedIssues.map((i) => (
|
||||||
|
<div
|
||||||
|
key={i.id}
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed"
|
||||||
|
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>
|
||||||
|
<span className="text-xs text-ink-faint truncate flex-1">
|
||||||
|
{shortIssueTitle(i.title)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Composer({
|
||||||
|
caseNumber,
|
||||||
|
issues,
|
||||||
|
}: {
|
||||||
|
caseNumber: string;
|
||||||
|
issues: PaperclipIssue[];
|
||||||
|
}) {
|
||||||
|
const sendComment = useSendComment(caseNumber);
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [target, setTarget] = useState<Target>({ kind: "auto" });
|
||||||
|
|
||||||
|
const selectedIssue =
|
||||||
|
target.kind === "issue" ? issues.find((i) => i.id === target.id) ?? null : null;
|
||||||
|
const closedTarget = !!selectedIssue && !isOpenStatus(selectedIssue.status);
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!body.trim() || closedTarget) return;
|
||||||
|
const vars =
|
||||||
|
target.kind === "new_run"
|
||||||
|
? { body: body.trim(), new_run: true }
|
||||||
|
: target.kind === "issue"
|
||||||
|
? { body: body.trim(), issue_id: target.id }
|
||||||
|
: { body: body.trim() };
|
||||||
|
sendComment.mutate(vars, {
|
||||||
|
onSuccess: (res) => {
|
||||||
|
setBody("");
|
||||||
|
setTarget({ kind: "auto" });
|
||||||
|
toast.success(
|
||||||
|
res.new_run
|
||||||
|
? `נפתחה הרצה חדשה — ${res.issue_identifier}`
|
||||||
|
: `נשלח ל-${res.issue_identifier}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: () => toast.error("שגיאה בשליחת ההודעה"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-rule rounded-xl shadow-sm p-3.5">
|
||||||
|
<div className="flex items-center gap-2 mb-2.5">
|
||||||
|
<MessageSquare className="w-4 h-4 text-gold-deep" />
|
||||||
|
<h3 className="text-sm font-bold text-navy">הוראה לסוכנים</h3>
|
||||||
|
<span className="text-[11px] text-ink-faint ms-auto">
|
||||||
|
Ctrl+Enter לשליחה
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span className="text-[11px] font-semibold text-ink-muted shrink-0">
|
||||||
|
שליחה אל:
|
||||||
|
</span>
|
||||||
|
<TargetSelector issues={issues} target={target} onChange={setTarget} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{closedTarget && (
|
||||||
|
<div className="flex items-start gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2 mb-2 text-[11.5px] text-red-700 leading-snug">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<b className="font-bold">יעד סגור.</b> שליחה למשימה סגורה לא תעיר סוכן —
|
||||||
|
ההוראה לא תטופל. בחר משימה פעילה או{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="font-bold underline"
|
||||||
|
onClick={() => setTarget({ kind: "new_run" })}
|
||||||
|
>
|
||||||
|
פתח הרצה חדשה
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="כתוב הוראה לסוכנים..."
|
||||||
|
aria-label="הוראה לסוכנים"
|
||||||
|
className="min-h-[60px] resize-none text-sm"
|
||||||
|
dir="rtl"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center mt-2">
|
||||||
|
<span className="text-[11px] text-ink-faint">
|
||||||
|
ההודעה תנותב דרך סוכן ה-CEO · היעד ניתן לשינוי למעלה
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="ms-auto"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!body.trim() || closedTarget || sendComment.isPending}
|
||||||
|
>
|
||||||
|
{sendComment.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-4 h-4 me-1" />
|
||||||
|
)}
|
||||||
|
שלח
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Main Feed ───────────────────────────────────────────────── */
|
/* ── Main Feed ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function AgentActivityFeed({
|
export function AgentActivityFeed({
|
||||||
@@ -826,9 +1135,6 @@ export function AgentActivityFeed({
|
|||||||
caseNumber: string;
|
caseNumber: string;
|
||||||
}) {
|
}) {
|
||||||
const { data, isLoading, error } = useAgentActivity(caseNumber);
|
const { data, isLoading, error } = useAgentActivity(caseNumber);
|
||||||
const sendComment = useSendComment(caseNumber);
|
|
||||||
const [body, setBody] = useState("");
|
|
||||||
const endRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Build issue_id → identifier map (memoized — the feed refetches every 10s,
|
// Build issue_id → identifier map (memoized — the feed refetches every 10s,
|
||||||
// and a fresh Map each render would defeat the child cards' memoization).
|
// and a fresh Map each render would defeat the child cards' memoization).
|
||||||
@@ -838,27 +1144,6 @@ export function AgentActivityFeed({
|
|||||||
return m;
|
return m;
|
||||||
}, [data?.issues]);
|
}, [data?.issues]);
|
||||||
|
|
||||||
// Auto-scroll on new comments or interactions
|
|
||||||
const commentCount = data?.comments?.length ?? 0;
|
|
||||||
const interactionCount = data?.interactions?.length ?? 0;
|
|
||||||
useEffect(() => {
|
|
||||||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}, [commentCount, interactionCount]);
|
|
||||||
|
|
||||||
const handleSend = () => {
|
|
||||||
if (!body.trim()) return;
|
|
||||||
sendComment.mutate(
|
|
||||||
{ body: body.trim() },
|
|
||||||
{
|
|
||||||
onSuccess: (res) => {
|
|
||||||
setBody("");
|
|
||||||
toast.success(`נשלח ל-${res.issue_identifier}`);
|
|
||||||
},
|
|
||||||
onError: () => toast.error("שגיאה בשליחת ההודעה"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Empty / loading states ──
|
// ── Empty / loading states ──
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -939,14 +1224,18 @@ export function AgentActivityFeed({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full gap-3">
|
||||||
|
{/* directive box — moved to the TOP, above the activity (most available,
|
||||||
|
no scrolling). Carries the target selector. */}
|
||||||
|
<Composer caseNumber={caseNumber} issues={data.issues} />
|
||||||
|
|
||||||
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
|
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
|
||||||
<AgentRoster agents={data.agents ?? []} />
|
<AgentRoster agents={data.agents ?? []} />
|
||||||
|
|
||||||
{/* pending interactions surfaced to the top — each an accordion, first
|
{/* pending interactions surfaced to the top — each an accordion, all
|
||||||
open (mockup 18i v2 — awaiting you). Excluded from the timeline. */}
|
COLLAPSED by default (chair opens to look). Excluded from the timeline. */}
|
||||||
{pending.length > 0 && (
|
{pending.length > 0 && (
|
||||||
<div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2">
|
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2">
|
||||||
<div className="flex items-center gap-2 text-[0.82rem] font-bold text-amber-800">
|
<div className="flex items-center gap-2 text-[0.82rem] font-bold text-amber-800">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
ממתין לתשובתך
|
ממתין לתשובתך
|
||||||
@@ -954,27 +1243,30 @@ export function AgentActivityFeed({
|
|||||||
{pending.length}
|
{pending.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{pending.map((i, idx) => (
|
{pending.map((i) => (
|
||||||
<InteractionCard
|
<InteractionCard
|
||||||
key={`pending-${i.id}`}
|
key={`pending-${i.id}`}
|
||||||
interaction={i}
|
interaction={i}
|
||||||
caseNumber={caseNumber}
|
caseNumber={caseNumber}
|
||||||
issueMap={issueMap}
|
issueMap={issueMap}
|
||||||
defaultOpen={idx === 0}
|
defaultOpen={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Timeline header */}
|
{/* Timeline header */}
|
||||||
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-1">
|
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule">
|
||||||
<span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
|
<span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
|
||||||
<span className="text-[0.72rem] text-ink-muted">
|
<span className="text-[0.68rem] font-semibold text-gold-deep bg-gold/10 rounded-full px-2 py-0.5">
|
||||||
|
החדש למעלה
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.72rem] text-ink-muted ms-auto">
|
||||||
{data.issues.length} משימות · {totalFeedItems} הודעות
|
{data.issues.length} משימות · {totalFeedItems} הודעות
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Per-issue accordions */}
|
{/* Per-issue accordions — all COLLAPSED by default, newest group first */}
|
||||||
<div className="flex-1 overflow-y-auto max-h-[500px]">
|
<div className="flex-1 overflow-y-auto max-h-[500px]">
|
||||||
{totalFeedItems === 0 ? (
|
{totalFeedItems === 0 ? (
|
||||||
hasActiveIssue ? (
|
hasActiveIssue ? (
|
||||||
@@ -989,56 +1281,17 @@ export function AgentActivityFeed({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
issueGroups.map(({ iss, items }, idx) => (
|
issueGroups.map(({ iss, items }) => (
|
||||||
<IssueGroup
|
<IssueGroup
|
||||||
key={iss.id}
|
key={iss.id}
|
||||||
issue={iss}
|
issue={iss}
|
||||||
items={items}
|
items={items}
|
||||||
caseNumber={caseNumber}
|
caseNumber={caseNumber}
|
||||||
issueMap={issueMap}
|
issueMap={issueMap}
|
||||||
defaultOpen={
|
defaultOpen={false}
|
||||||
idx === 0 ||
|
|
||||||
(iss.status !== "done" && iss.status !== "cancelled")
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
<div ref={endRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comment input */}
|
|
||||||
<div className="border-t border-rule pt-3 mt-3 space-y-2">
|
|
||||||
<Textarea
|
|
||||||
value={body}
|
|
||||||
onChange={(e) => setBody(e.target.value)}
|
|
||||||
placeholder="כתוב הוראה לסוכנים..."
|
|
||||||
aria-label="הוראה לסוכנים"
|
|
||||||
className="min-h-[60px] resize-none text-sm"
|
|
||||||
dir="rtl"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-[11px] text-ink-faint">
|
|
||||||
ההודעה תנותב דרך סוכן ה-CEO · Ctrl+Enter לשליחה
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={!body.trim() || sendComment.isPending}
|
|
||||||
>
|
|
||||||
{sendComment.isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send className="w-4 h-4 me-1" />
|
|
||||||
)}
|
|
||||||
שלח
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ export type PaperclipIssue = {
|
|||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
company_id: string;
|
company_id: string;
|
||||||
|
/** Null for top-level issues (the CEO "start process" issue); set for sub-issues. */
|
||||||
|
parent_id: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaperclipComment = {
|
export type PaperclipComment = {
|
||||||
@@ -151,8 +153,14 @@ export function useAgentActivity(caseNumber: string | undefined) {
|
|||||||
export function useSendComment(caseNumber: string | undefined) {
|
export function useSendComment(caseNumber: string | undefined) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (vars: { body: string; issue_id?: string }) =>
|
mutationFn: (vars: { body: string; issue_id?: string; new_run?: boolean }) =>
|
||||||
apiRequest<{ comment_id: string; issue_id: string; issue_identifier: string }>(
|
apiRequest<{
|
||||||
|
comment_id: string | null;
|
||||||
|
issue_id: string;
|
||||||
|
issue_identifier: string;
|
||||||
|
issue_status?: string;
|
||||||
|
new_run?: boolean;
|
||||||
|
}>(
|
||||||
`/api/cases/${caseNumber}/agents/comment`,
|
`/api/cases/${caseNumber}/agents/comment`,
|
||||||
{ method: "POST", body: vars },
|
{ method: "POST", body: vars },
|
||||||
),
|
),
|
||||||
|
|||||||
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ from web.paperclip_client import (
|
|||||||
get_agents_for_case as pc_get_agents_for_case,
|
get_agents_for_case as pc_get_agents_for_case,
|
||||||
get_agents_for_company as pc_get_agents,
|
get_agents_for_company as pc_get_agents,
|
||||||
get_case_issues as pc_get_case_issues,
|
get_case_issues as pc_get_case_issues,
|
||||||
|
pick_default_comment_target as pc_pick_default_comment_target,
|
||||||
get_issue_comments as pc_get_issue_comments,
|
get_issue_comments as pc_get_issue_comments,
|
||||||
get_issue_interactions as pc_get_issue_interactions,
|
get_issue_interactions as pc_get_issue_interactions,
|
||||||
get_project_url,
|
get_project_url,
|
||||||
@@ -62,6 +63,7 @@ from web.paperclip_client import (
|
|||||||
cancel_interaction as _cancel_interaction,
|
cancel_interaction as _cancel_interaction,
|
||||||
cancel_run as _cancel_run,
|
cancel_run as _cancel_run,
|
||||||
escalate_issue as _escalate_issue,
|
escalate_issue as _escalate_issue,
|
||||||
|
mark_comment_routed as _mark_comment_routed,
|
||||||
post_comment as _post_comment,
|
post_comment as _post_comment,
|
||||||
reap_stale_interactions as _reap_stale_interactions,
|
reap_stale_interactions as _reap_stale_interactions,
|
||||||
reset_agent_session as _reset_agent_session,
|
reset_agent_session as _reset_agent_session,
|
||||||
@@ -71,6 +73,7 @@ from web.paperclip_client import (
|
|||||||
wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis,
|
wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis,
|
||||||
wake_ceo_agent as _wake_ceo,
|
wake_ceo_agent as _wake_ceo,
|
||||||
wake_ceo_for_action as _wake_ceo_for_action,
|
wake_ceo_for_action as _wake_ceo_for_action,
|
||||||
|
open_ceo_run as _open_ceo_run,
|
||||||
wake_ceo_for_feedback_fold as _wake_ceo_for_feedback_fold,
|
wake_ceo_for_feedback_fold as _wake_ceo_for_feedback_fold,
|
||||||
wake_curator_for_final as _wake_curator_for_final,
|
wake_curator_for_final as _wake_curator_for_final,
|
||||||
wake_for_precedent_extraction as _wake_for_precedent_extraction,
|
wake_for_precedent_extraction as _wake_for_precedent_extraction,
|
||||||
@@ -88,6 +91,9 @@ pc_wake_ceo = instrument("agent.wakeup", role="ceo")(_wake_ceo)
|
|||||||
pc_wake_ceo_for_action = instrument(
|
pc_wake_ceo_for_action = instrument(
|
||||||
"agent.wakeup", role="ceo", keys=("case_number", "company_id", "action"),
|
"agent.wakeup", role="ceo", keys=("case_number", "company_id", "action"),
|
||||||
)(_wake_ceo_for_action)
|
)(_wake_ceo_for_action)
|
||||||
|
pc_open_ceo_run = instrument(
|
||||||
|
"agent.wakeup", role="ceo", keys=("case_number", "company_id"),
|
||||||
|
)(_open_ceo_run)
|
||||||
pc_wake_ceo_for_feedback_fold = instrument(
|
pc_wake_ceo_for_feedback_fold = instrument(
|
||||||
"agent.wakeup", role="ceo", keys=("feedback_id", "category", "block_id"),
|
"agent.wakeup", role="ceo", keys=("feedback_id", "category", "block_id"),
|
||||||
)(_wake_ceo_for_feedback_fold)
|
)(_wake_ceo_for_feedback_fold)
|
||||||
@@ -109,6 +115,9 @@ pc_wake_analyst_for_protocol_analysis = instrument(
|
|||||||
pc_post_comment = instrument(
|
pc_post_comment = instrument(
|
||||||
"agent.comment", keys=("issue_id", "company_id"),
|
"agent.comment", keys=("issue_id", "company_id"),
|
||||||
)(_post_comment)
|
)(_post_comment)
|
||||||
|
pc_mark_comment_routed = instrument(
|
||||||
|
"agent.comment", keys=("issue_id", "comment_id"),
|
||||||
|
)(_mark_comment_routed)
|
||||||
pc_cancel_interaction = instrument(
|
pc_cancel_interaction = instrument(
|
||||||
"interaction.cancelled", keys=("issue_id", "interaction_id"),
|
"interaction.cancelled", keys=("issue_id", "interaction_id"),
|
||||||
)(_cancel_interaction)
|
)(_cancel_interaction)
|
||||||
@@ -158,11 +167,13 @@ __all__ = [
|
|||||||
# issues / workflow
|
# issues / workflow
|
||||||
"pc_create_workflow_issue",
|
"pc_create_workflow_issue",
|
||||||
"pc_get_case_issues",
|
"pc_get_case_issues",
|
||||||
|
"pc_pick_default_comment_target",
|
||||||
# agents / wakeups
|
# agents / wakeups
|
||||||
"pc_get_agents_for_case",
|
"pc_get_agents_for_case",
|
||||||
"pc_get_agents",
|
"pc_get_agents",
|
||||||
"pc_wake_ceo",
|
"pc_wake_ceo",
|
||||||
"pc_wake_ceo_for_action",
|
"pc_wake_ceo_for_action",
|
||||||
|
"pc_open_ceo_run",
|
||||||
"pc_wake_ceo_for_feedback_fold",
|
"pc_wake_ceo_for_feedback_fold",
|
||||||
"pc_wake_curator_for_final",
|
"pc_wake_curator_for_final",
|
||||||
"pc_wake_for_precedent_extraction",
|
"pc_wake_for_precedent_extraction",
|
||||||
@@ -172,6 +183,7 @@ __all__ = [
|
|||||||
"pc_get_generation_run_status",
|
"pc_get_generation_run_status",
|
||||||
# comments / interactions
|
# comments / interactions
|
||||||
"pc_post_comment",
|
"pc_post_comment",
|
||||||
|
"pc_mark_comment_routed",
|
||||||
"pc_get_issue_comments",
|
"pc_get_issue_comments",
|
||||||
"pc_get_issue_interactions",
|
"pc_get_issue_interactions",
|
||||||
"pc_accept_interaction",
|
"pc_accept_interaction",
|
||||||
|
|||||||
209
web/app.py
209
web/app.py
@@ -69,6 +69,7 @@ from web.agent_platform_port import (
|
|||||||
pc_get_agents,
|
pc_get_agents,
|
||||||
pc_get_agents_for_case,
|
pc_get_agents_for_case,
|
||||||
pc_get_case_issues,
|
pc_get_case_issues,
|
||||||
|
pc_pick_default_comment_target,
|
||||||
pc_get_issue_comments,
|
pc_get_issue_comments,
|
||||||
pc_get_issue_interactions,
|
pc_get_issue_interactions,
|
||||||
pc_get_run_events,
|
pc_get_run_events,
|
||||||
@@ -89,6 +90,8 @@ from web.agent_platform_port import (
|
|||||||
rename_case_project,
|
rename_case_project,
|
||||||
pc_wake_ceo,
|
pc_wake_ceo,
|
||||||
pc_wake_ceo_for_action,
|
pc_wake_ceo_for_action,
|
||||||
|
pc_open_ceo_run,
|
||||||
|
pc_mark_comment_routed,
|
||||||
pc_wake_ceo_for_feedback_fold,
|
pc_wake_ceo_for_feedback_fold,
|
||||||
pc_wake_curator_for_final,
|
pc_wake_curator_for_final,
|
||||||
pc_wake_for_precedent_extraction,
|
pc_wake_for_precedent_extraction,
|
||||||
@@ -3067,42 +3070,82 @@ async def api_learn(case_number: str):
|
|||||||
# ── Local files API — research, drafts, proofread ──
|
# ── 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")
|
@app.get("/api/cases/{case_number}/local-files")
|
||||||
async def api_local_files(case_number: str):
|
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)
|
case_dir = config.find_case_dir(case_number)
|
||||||
result = {}
|
folders = []
|
||||||
for folder in ("research", "proofread"):
|
for key, path in _case_file_folders(case_dir):
|
||||||
folder_path = case_dir / "documents" / folder
|
|
||||||
if folder_path.exists():
|
|
||||||
files = []
|
|
||||||
for f in sorted(folder_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 = []
|
files = []
|
||||||
for f in sorted(drafts_path.iterdir()):
|
for f in sorted(path.iterdir()):
|
||||||
if f.is_file() and not f.name.startswith("."):
|
if f.is_file() and not f.name.startswith("."):
|
||||||
stat = f.stat()
|
stat = f.stat()
|
||||||
files.append({
|
files.append({
|
||||||
"filename": f.name,
|
"filename": f.name,
|
||||||
"size": stat.st_size,
|
"size": stat.st_size,
|
||||||
"modified_at": stat.st_mtime,
|
"modified_at": stat.st_mtime,
|
||||||
"folder": "drafts",
|
|
||||||
})
|
})
|
||||||
if files:
|
folders.append({
|
||||||
result["drafts"] = files
|
"key": key,
|
||||||
return result
|
"name": path.name,
|
||||||
|
"path": str(path.relative_to(case_dir)),
|
||||||
|
"files": files,
|
||||||
|
})
|
||||||
|
return {"folders": folders}
|
||||||
|
|
||||||
|
|
||||||
async def serve_blob(
|
async def serve_blob(
|
||||||
@@ -3172,17 +3215,32 @@ async def _seal_blob_file(dest: Path, *, bucket=storage.Bucket.DOCUMENTS) -> Non
|
|||||||
|
|
||||||
@app.get("/api/cases/{case_number}/local-files/{folder}/{filename}")
|
@app.get("/api/cases/{case_number}/local-files/{folder}/{filename}")
|
||||||
async def api_read_local_file(case_number: str, folder: str, filename: str):
|
async def api_read_local_file(case_number: str, folder: str, filename: str):
|
||||||
"""Read contents of a local case file."""
|
"""Serve a single local case file from any content subfolder.
|
||||||
if folder not in ("research", "proofread", "drafts"):
|
|
||||||
raise HTTPException(400, "Invalid folder")
|
``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)
|
case_dir = config.find_case_dir(case_number)
|
||||||
if folder == "drafts":
|
path = _resolve_case_file(case_dir, folder, filename)
|
||||||
path = case_dir / "drafts" / filename
|
if path is None:
|
||||||
else:
|
|
||||||
path = case_dir / "documents" / folder / filename
|
|
||||||
if not path.exists() or not path.is_file():
|
|
||||||
raise HTTPException(404, "קובץ לא נמצא")
|
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 ────
|
# ── Research analysis (analysis-and-research.md) — parse + edit ────
|
||||||
@@ -4603,14 +4661,39 @@ async def api_case_agents(case_number: str):
|
|||||||
class AgentCommentRequest(BaseModel):
|
class AgentCommentRequest(BaseModel):
|
||||||
body: str
|
body: str
|
||||||
issue_id: str | None = None
|
issue_id: str | None = None
|
||||||
|
# "פתח הרצה חדשה" — 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
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/cases/{case_number}/agents/comment")
|
@app.post("/api/cases/{case_number}/agents/comment")
|
||||||
async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
||||||
"""Post a comment on a Paperclip issue linked to a case.
|
"""Post a chair instruction to a Paperclip issue linked to a case.
|
||||||
|
|
||||||
If issue_id is omitted, the most recent non-done issue is used.
|
Routing (agents-tab target selector):
|
||||||
|
- ``new_run=True`` → open a fresh CEO-owned run (child issue assigned to the
|
||||||
|
CEO + wakeup), sidestepping the ``issue_assignee_changed`` cancellation on
|
||||||
|
a human-owned parent. Overrides ``issue_id``.
|
||||||
|
- ``issue_id`` given → post to that issue (explicit chair choice).
|
||||||
|
- neither → prefer the live CEO main issue, never a closed one
|
||||||
|
(``pick_default_comment_target``).
|
||||||
"""
|
"""
|
||||||
|
if req.new_run:
|
||||||
|
result = await pc_open_ceo_run(case_number, req.body)
|
||||||
|
if result.get("status") != "ok":
|
||||||
|
raise HTTPException(
|
||||||
|
502,
|
||||||
|
f"פתיחת הרצה חדשה נכשלה: {result.get('reason', 'unknown')}",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"comment_id": None,
|
||||||
|
"issue_id": result["issue_id"],
|
||||||
|
"issue_identifier": result.get("identifier", ""),
|
||||||
|
"issue_status": "in_progress",
|
||||||
|
"new_run": True,
|
||||||
|
}
|
||||||
|
|
||||||
issues = await pc_get_case_issues(case_number)
|
issues = await pc_get_case_issues(case_number)
|
||||||
if not issues:
|
if not issues:
|
||||||
raise HTTPException(404, f"לא נמצא פרויקט Paperclip לתיק {case_number}")
|
raise HTTPException(404, f"לא נמצא פרויקט Paperclip לתיק {case_number}")
|
||||||
@@ -4620,14 +4703,35 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
|||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||||
else:
|
else:
|
||||||
# Pick the most recent non-done issue, or the last one
|
target = pc_pick_default_comment_target(issues)
|
||||||
active = [i for i in issues if i["status"] != "done"]
|
|
||||||
target = active[-1] if active else issues[-1]
|
|
||||||
|
|
||||||
result = await pc_post_comment(target["id"], target["company_id"], req.body)
|
result = await pc_post_comment(target["id"], target["company_id"], req.body)
|
||||||
|
|
||||||
# Find the identifier for the response
|
# 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_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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -4644,8 +4748,10 @@ async def api_post_interaction_response(
|
|||||||
):
|
):
|
||||||
"""Submit a user's answer to a Paperclip issue-thread interaction.
|
"""Submit a user's answer to a Paperclip issue-thread interaction.
|
||||||
|
|
||||||
Routes to /respond | /accept | /reject based on `action`. Paperclip
|
Routes to /respond | /accept | /reject based on `action`, then opens a CEO run
|
||||||
auto-wakes the issue assignee after a successful submission.
|
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)
|
issues = await pc_get_case_issues(case_number)
|
||||||
if not any(i["id"] == req.issue_id for i in issues):
|
if not any(i["id"] == req.issue_id for i in issues):
|
||||||
@@ -4657,7 +4763,7 @@ async def api_post_interaction_response(
|
|||||||
"reject": pc_reject_interaction,
|
"reject": pc_reject_interaction,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
return await handlers[req.action](
|
result = await handlers[req.action](
|
||||||
req.issue_id, req.interaction_id, req.payload,
|
req.issue_id, req.interaction_id, req.payload,
|
||||||
)
|
)
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
@@ -4666,6 +4772,23 @@ async def api_post_interaction_response(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(502, f"שגיאת Paperclip: {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):
|
class InteractionDismissRequest(BaseModel):
|
||||||
issue_id: str
|
issue_id: str
|
||||||
|
|||||||
@@ -327,6 +327,40 @@ async def _create_issue(
|
|||||||
return issue_id, identifier
|
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:
|
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."""
|
"""Store the legal-ai case number in plugin state, linked to the issue."""
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
@@ -484,7 +518,8 @@ async def get_case_issues(case_number: str) -> list[dict]:
|
|||||||
"""SELECT DISTINCT ON (i.id)
|
"""SELECT DISTINCT ON (i.id)
|
||||||
i.id, i.title, i.status, i.identifier, i.priority,
|
i.id, i.title, i.status, i.identifier, i.priority,
|
||||||
i.assignee_agent_id, a.name AS assignee_name,
|
i.assignee_agent_id, a.name AS assignee_name,
|
||||||
i.started_at, i.completed_at, i.created_at, i.company_id
|
i.started_at, i.completed_at, i.created_at, i.company_id,
|
||||||
|
i.parent_id
|
||||||
FROM issues i
|
FROM issues i
|
||||||
LEFT JOIN agents a ON i.assignee_agent_id = a.id
|
LEFT JOIN agents a ON i.assignee_agent_id = a.id
|
||||||
LEFT JOIN plugin_state ps ON ps.scope_id = i.id::text
|
LEFT JOIN plugin_state ps ON ps.scope_id = i.id::text
|
||||||
@@ -510,6 +545,7 @@ async def get_case_issues(case_number: str) -> list[dict]:
|
|||||||
"completed_at": r["completed_at"].isoformat() if r["completed_at"] else None,
|
"completed_at": r["completed_at"].isoformat() if r["completed_at"] else None,
|
||||||
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||||
"company_id": str(r["company_id"]),
|
"company_id": str(r["company_id"]),
|
||||||
|
"parent_id": str(r["parent_id"]) if r["parent_id"] else None,
|
||||||
}
|
}
|
||||||
for r in sorted_rows
|
for r in sorted_rows
|
||||||
]
|
]
|
||||||
@@ -517,6 +553,43 @@ async def get_case_issues(case_number: str) -> list[dict]:
|
|||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# A comment posted to a closed issue never wakes an agent — Paperclip skips the
|
||||||
|
# wakeup for done/cancelled issues, so the chair's instruction is silently
|
||||||
|
# swallowed. The default-target picker MUST therefore avoid these statuses.
|
||||||
|
CLOSED_ISSUE_STATUSES = frozenset({"done", "cancelled"})
|
||||||
|
|
||||||
|
|
||||||
|
def pick_default_comment_target(issues: list[dict]) -> dict:
|
||||||
|
"""Choose which issue a chair comment routes to when none is specified.
|
||||||
|
|
||||||
|
``issues`` is ordered oldest→newest (see :func:`get_case_issues`). Comment
|
||||||
|
routing is meant to reach the CEO, whose live "התחל תהליך ניסוח" issue is
|
||||||
|
top-level (``parent_id is None``) and open. Preference order:
|
||||||
|
1. newest OPEN top-level issue — the live CEO main issue;
|
||||||
|
2. newest OPEN issue of any depth — a live sub-issue if no main is open;
|
||||||
|
3. newest top-level issue even if closed — better than a closed child;
|
||||||
|
4. newest issue overall — last resort.
|
||||||
|
Excluding closed statuses is the fix for the recurring bug where the newest
|
||||||
|
non-done issue was a *cancelled* child, so the wakeup was skipped and the
|
||||||
|
instruction vanished. ``issues`` must be non-empty.
|
||||||
|
"""
|
||||||
|
def is_open(i: dict) -> bool:
|
||||||
|
return i["status"] not in CLOSED_ISSUE_STATUSES
|
||||||
|
|
||||||
|
def is_top(i: dict) -> bool:
|
||||||
|
return i.get("parent_id") is None
|
||||||
|
|
||||||
|
for pred in (
|
||||||
|
lambda i: is_open(i) and is_top(i),
|
||||||
|
is_open,
|
||||||
|
is_top,
|
||||||
|
):
|
||||||
|
matches = [i for i in issues if pred(i)]
|
||||||
|
if matches:
|
||||||
|
return matches[-1]
|
||||||
|
return issues[-1]
|
||||||
|
|
||||||
|
|
||||||
async def get_issue_comments(issue_ids: list[str]) -> list[dict]:
|
async def get_issue_comments(issue_ids: list[str]) -> list[dict]:
|
||||||
"""Get all comments on a list of Paperclip issues, with agent metadata."""
|
"""Get all comments on a list of Paperclip issues, with agent metadata."""
|
||||||
if not issue_ids:
|
if not issue_ids:
|
||||||
@@ -616,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:
|
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).
|
Delivering the instruction is the caller's job, via :func:`open_ceo_run`. This
|
||||||
Falls back to direct DB insert + CEO wakeup if API fails.
|
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
|
# Try Board API first — this triggers the event bus
|
||||||
if PAPERCLIP_BOARD_API_KEY:
|
if PAPERCLIP_BOARD_API_KEY:
|
||||||
@@ -636,7 +713,6 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id)
|
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())
|
comment_id = str(uuid.uuid4())
|
||||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
try:
|
try:
|
||||||
@@ -649,23 +725,6 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
|||||||
finally:
|
finally:
|
||||||
await conn.close()
|
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"}
|
return {"comment_id": comment_id, "issue_id": issue_id, "method": "db_fallback"}
|
||||||
|
|
||||||
|
|
||||||
@@ -1700,6 +1759,93 @@ async def wake_ceo_for_action(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def open_ceo_run(
|
||||||
|
case_number: str,
|
||||||
|
instruction: str,
|
||||||
|
company_id: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Open a FRESH CEO-owned run carrying a free-form chair instruction.
|
||||||
|
|
||||||
|
Drives the "פתח הרצה חדשה" option of the agents-tab target selector: instead
|
||||||
|
of appending the instruction to an existing (possibly human-owned or closed)
|
||||||
|
issue, a **child issue assigned to the CEO** is created under the case's main
|
||||||
|
issue and the CEO is woken on it. This is the same CEO-child primitive as
|
||||||
|
:func:`wake_ceo_for_action` (#227) — it sidesteps the ``issue_assignee_changed``
|
||||||
|
cancellation that fires when an agent is woken on a human-owned parent (the
|
||||||
|
exact silent failure the chair hit on 1027-04-26; see TaskMaster #228).
|
||||||
|
|
||||||
|
Wakeup goes through the Paperclip API, never a direct DB insert. Returns
|
||||||
|
``{"status":"ok", "issue_id"(=child), "identifier", ...}`` or
|
||||||
|
``{"status":"skipped", "reason": ...}``.
|
||||||
|
"""
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
logger.warning("PAPERCLIP_BOARD_API_KEY not set — skipping CEO new-run")
|
||||||
|
return {"status": "skipped", "reason": "no_api_key"}
|
||||||
|
|
||||||
|
issues = await get_case_issues(case_number)
|
||||||
|
if not issues:
|
||||||
|
logger.warning("No Paperclip issues for case %s — skipping CEO new-run", case_number)
|
||||||
|
return {"status": "skipped", "reason": "no_issue"}
|
||||||
|
|
||||||
|
# Parent the new run under the live main issue (never a closed child).
|
||||||
|
main_issue = pick_default_comment_target(issues)
|
||||||
|
main_issue_id = main_issue["id"]
|
||||||
|
if not company_id:
|
||||||
|
company_id = main_issue.get("company_id", "")
|
||||||
|
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||||
|
|
||||||
|
title = f'[ערר {case_number}] הוראת יו"ר — הרצה חדשה'
|
||||||
|
description = (
|
||||||
|
'הוראת יו"ר שנשלחה כ"הרצה חדשה" מטאב הסוכנים. קרא את מצב התיק, טפל בהוראה '
|
||||||
|
"בסגנון דפנה, ומסור disposition תקין בסיום.\n\n---\n\n" + instruction
|
||||||
|
)
|
||||||
|
child_resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/issues/{main_issue_id}/children",
|
||||||
|
json={
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"status": "in_progress",
|
||||||
|
"priority": "medium",
|
||||||
|
"assigneeAgentId": ceo_id,
|
||||||
|
},
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
sub_issue = child_resp.json()
|
||||||
|
sub_issue_id = sub_issue["id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
await _link_case_to_issue(conn, sub_issue_id, case_number)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("plugin_state link failed for new-run sub_issue=%s: %s", sub_issue_id, e)
|
||||||
|
|
||||||
|
await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/agents/{ceo_id}/wakeup",
|
||||||
|
json={
|
||||||
|
"source": "on_demand",
|
||||||
|
"triggerDetail": "manual",
|
||||||
|
"reason": f"chair_new_run_{case_number}",
|
||||||
|
"payload": {"issueId": sub_issue_id, "case_number": case_number},
|
||||||
|
},
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"CEO new-run for case %s: child_issue=%s ceo=%s", case_number, sub_issue_id, ceo_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"issue_id": sub_issue_id,
|
||||||
|
"identifier": sub_issue.get("identifier", ""),
|
||||||
|
"main_issue_id": main_issue_id,
|
||||||
|
"ceo_id": ceo_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Substring of the generation child-issue title per action (see _ceo_action_brief).
|
# Substring of the generation child-issue title per action (see _ceo_action_brief).
|
||||||
GENERATION_TITLE_SUBSTR = {
|
GENERATION_TITLE_SUBSTR = {
|
||||||
"party_claims_summary": "סיכום-מנהלים",
|
"party_claims_summary": "סיכום-מנהלים",
|
||||||
|
|||||||
78
web/tests/test_comment_target.py
Normal file
78
web/tests/test_comment_target.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Tests for pick_default_comment_target — chair-comment routing.
|
||||||
|
|
||||||
|
Regression guard for the recurring bug where a chair instruction typed into the
|
||||||
|
"כתוב הוראה לסוכנים..." box was routed to a *cancelled* child issue, so
|
||||||
|
Paperclip skipped the wakeup and the instruction was silently swallowed.
|
||||||
|
|
||||||
|
The picker must never default to a closed (done/cancelled) issue and must prefer
|
||||||
|
the live top-level CEO issue. Pure function — no DB, no FastAPI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("PAPERCLIP_DB_URL", "postgres://x:x@127.0.0.1:54329/paperclip")
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # web/
|
||||||
|
|
||||||
|
pc = pytest.importorskip("paperclip_client", reason="web deps unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
def _iss(id_, status, parent_id=None):
|
||||||
|
return {"id": id_, "status": status, "parent_id": parent_id, "identifier": id_}
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefers_open_top_level_over_newer_cancelled_child():
|
||||||
|
"""The exact 1027-04-26 scenario: newest non-done issue is a cancelled child;
|
||||||
|
routing must still land on the live top-level CEO issue."""
|
||||||
|
issues = [
|
||||||
|
_iss("main-old", "done"), # oldest top-level, closed
|
||||||
|
_iss("main-live", "in_review"), # live CEO main issue
|
||||||
|
_iss("child-cancelled", "cancelled", "main-live"), # newest non-done → the trap
|
||||||
|
_iss("child-done", "done", "main-live"),
|
||||||
|
]
|
||||||
|
assert pc.pick_default_comment_target(issues)["id"] == "main-live"
|
||||||
|
|
||||||
|
|
||||||
|
def test_never_returns_cancelled_when_an_open_issue_exists():
|
||||||
|
issues = [
|
||||||
|
_iss("a", "done"),
|
||||||
|
_iss("b", "in_progress", "a"),
|
||||||
|
_iss("c", "cancelled", "a"),
|
||||||
|
]
|
||||||
|
assert pc.pick_default_comment_target(issues)["status"] != "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_falls_back_to_open_child_when_no_open_top_level():
|
||||||
|
issues = [
|
||||||
|
_iss("main", "done"),
|
||||||
|
_iss("child-live", "in_progress", "main"),
|
||||||
|
]
|
||||||
|
assert pc.pick_default_comment_target(issues)["id"] == "child-live"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefers_top_level_when_everything_closed():
|
||||||
|
"""No open issue anywhere — pick the newest top-level (better than a child)."""
|
||||||
|
issues = [
|
||||||
|
_iss("main-1", "done"),
|
||||||
|
_iss("main-2", "cancelled"),
|
||||||
|
_iss("child", "done", "main-2"),
|
||||||
|
]
|
||||||
|
assert pc.pick_default_comment_target(issues)["id"] == "main-2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_issue():
|
||||||
|
issues = [_iss("only", "cancelled")]
|
||||||
|
assert pc.pick_default_comment_target(issues)["id"] == "only"
|
||||||
|
|
||||||
|
|
||||||
|
def test_picks_newest_open_top_level():
|
||||||
|
issues = [
|
||||||
|
_iss("main-1", "in_review"),
|
||||||
|
_iss("main-2", "in_review"),
|
||||||
|
]
|
||||||
|
assert pc.pick_default_comment_target(issues)["id"] == "main-2"
|
||||||
Reference in New Issue
Block a user