Compare commits
2 Commits
0a5be942ba
...
107be235f5
| Author | SHA1 | Date | |
|---|---|---|---|
| 107be235f5 | |||
| 9e7d98a47a |
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useMemo } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -34,6 +34,9 @@ import {
|
||||
HelpCircle,
|
||||
ChevronDown,
|
||||
Ban,
|
||||
Target,
|
||||
Plus,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ── 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 ───────────────────────────────────────────────── */
|
||||
|
||||
export function AgentActivityFeed({
|
||||
@@ -826,9 +1135,6 @@ export function AgentActivityFeed({
|
||||
caseNumber: string;
|
||||
}) {
|
||||
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,
|
||||
// and a fresh Map each render would defeat the child cards' memoization).
|
||||
@@ -838,27 +1144,6 @@ export function AgentActivityFeed({
|
||||
return m;
|
||||
}, [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 ──
|
||||
|
||||
if (isLoading) {
|
||||
@@ -939,14 +1224,18 @@ export function AgentActivityFeed({
|
||||
);
|
||||
|
||||
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) */}
|
||||
<AgentRoster agents={data.agents ?? []} />
|
||||
|
||||
{/* pending interactions surfaced to the top — each an accordion, first
|
||||
open (mockup 18i v2 — awaiting you). Excluded from the timeline. */}
|
||||
{/* pending interactions surfaced to the top — each an accordion, all
|
||||
COLLAPSED by default (chair opens to look). Excluded from the timeline. */}
|
||||
{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">
|
||||
<Clock className="w-4 h-4" />
|
||||
ממתין לתשובתך
|
||||
@@ -954,27 +1243,30 @@ export function AgentActivityFeed({
|
||||
{pending.length}
|
||||
</span>
|
||||
</div>
|
||||
{pending.map((i, idx) => (
|
||||
{pending.map((i) => (
|
||||
<InteractionCard
|
||||
key={`pending-${i.id}`}
|
||||
interaction={i}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
defaultOpen={idx === 0}
|
||||
defaultOpen={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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.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} הודעות
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Per-issue accordions */}
|
||||
{/* Per-issue accordions — all COLLAPSED by default, newest group first */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[500px]">
|
||||
{totalFeedItems === 0 ? (
|
||||
hasActiveIssue ? (
|
||||
@@ -989,56 +1281,17 @@ export function AgentActivityFeed({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
issueGroups.map(({ iss, items }, idx) => (
|
||||
issueGroups.map(({ iss, items }) => (
|
||||
<IssueGroup
|
||||
key={iss.id}
|
||||
issue={iss}
|
||||
items={items}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
defaultOpen={
|
||||
idx === 0 ||
|
||||
(iss.status !== "done" && iss.status !== "cancelled")
|
||||
}
|
||||
defaultOpen={false}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,8 @@ export type PaperclipIssue = {
|
||||
completed_at: string | null;
|
||||
created_at: string | null;
|
||||
company_id: string;
|
||||
/** Null for top-level issues (the CEO "start process" issue); set for sub-issues. */
|
||||
parent_id: string | null;
|
||||
};
|
||||
|
||||
export type PaperclipComment = {
|
||||
@@ -151,8 +153,14 @@ export function useAgentActivity(caseNumber: string | undefined) {
|
||||
export function useSendComment(caseNumber: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vars: { body: string; issue_id?: string }) =>
|
||||
apiRequest<{ comment_id: string; issue_id: string; issue_identifier: string }>(
|
||||
mutationFn: (vars: { body: string; issue_id?: string; new_run?: boolean }) =>
|
||||
apiRequest<{
|
||||
comment_id: string | null;
|
||||
issue_id: string;
|
||||
issue_identifier: string;
|
||||
issue_status?: string;
|
||||
new_run?: boolean;
|
||||
}>(
|
||||
`/api/cases/${caseNumber}/agents/comment`,
|
||||
{ method: "POST", body: vars },
|
||||
),
|
||||
|
||||
@@ -72,6 +72,7 @@ from web.paperclip_client import (
|
||||
wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis,
|
||||
wake_ceo_agent as _wake_ceo,
|
||||
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_curator_for_final as _wake_curator_for_final,
|
||||
wake_for_precedent_extraction as _wake_for_precedent_extraction,
|
||||
@@ -89,6 +90,9 @@ pc_wake_ceo = instrument("agent.wakeup", role="ceo")(_wake_ceo)
|
||||
pc_wake_ceo_for_action = instrument(
|
||||
"agent.wakeup", role="ceo", keys=("case_number", "company_id", "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(
|
||||
"agent.wakeup", role="ceo", keys=("feedback_id", "category", "block_id"),
|
||||
)(_wake_ceo_for_feedback_fold)
|
||||
@@ -165,6 +169,7 @@ __all__ = [
|
||||
"pc_get_agents",
|
||||
"pc_wake_ceo",
|
||||
"pc_wake_ceo_for_action",
|
||||
"pc_open_ceo_run",
|
||||
"pc_wake_ceo_for_feedback_fold",
|
||||
"pc_wake_curator_for_final",
|
||||
"pc_wake_for_precedent_extraction",
|
||||
|
||||
30
web/app.py
30
web/app.py
@@ -90,6 +90,7 @@ from web.agent_platform_port import (
|
||||
rename_case_project,
|
||||
pc_wake_ceo,
|
||||
pc_wake_ceo_for_action,
|
||||
pc_open_ceo_run,
|
||||
pc_wake_ceo_for_feedback_fold,
|
||||
pc_wake_curator_for_final,
|
||||
pc_wake_for_precedent_extraction,
|
||||
@@ -4604,15 +4605,38 @@ 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.
|
||||
new_run: bool = False
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/agents/comment")
|
||||
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, routing prefers the live CEO main issue and
|
||||
never a closed (done/cancelled) issue — see ``_pick_default_comment_target``.
|
||||
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)
|
||||
if not issues:
|
||||
raise HTTPException(404, f"לא נמצא פרויקט Paperclip לתיק {case_number}")
|
||||
|
||||
@@ -1739,6 +1739,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).
|
||||
GENERATION_TITLE_SUBSTR = {
|
||||
"party_claims_summary": "סיכום-מנהלים",
|
||||
|
||||
Reference in New Issue
Block a user