4 Commits

Author SHA1 Message Date
4d227a4999 Merge pull request 'fix(routing): הוראת-יו"ר תעבוד בדרך הטבעית — הערה פותחת הרצת-CEO' (#415) from worktree-natural-comment-routing into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m17s
G12 Leak-Guard / leak-guard (push) Successful in 7s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-07-16 12:33:29 +00:00
f58ddba93e fix(routing): הוראת-יו"ר תעבוד בדרך הטבעית — הערה פותחת הרצת-CEO
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 37s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
**התסמין:** היו"ר כותב הוראה על תיק והסוכן "מתעלם". ההרצה מדווחת succeeded/exit 0
ואפילו עולה כסף — ולכן זה שרד חודשים בלי שמישהו שם לב.

**שתי סיבות-שורש בלתי-תלויות, שתיהן מאומתות:**

1. ה-wakeup הישיר על ה-issue של התיק מבוטל תמיד: תיק שממתין ליו"ר הוא `in_review`
   ומשויך *לאדם*, ו-Paperclip עונה `issue_assignee_changed` → "אעיר את הבעלים החדש"
   → הבעלים אדם → אף סוכן לא מתעורר.
2. רשת-הביטחון בפלאגין מוסרת את ההוראה כ-`payload.prompt`, וה-runner קורא רק
   `payload.issueId` — הפרומפט נבלע. 18/18 מדידה ב-DB: 0% מסירה, אי-פעם.

**התיקון:** המסלול הטבעי (הערה בטאב-הסוכנים) מפרסם את ההערה בשרשור כרגיל **וגם**
פותח הרצת-CEO עם ההוראה, דרך `pc_open_ceo_run` — הפרימיטיב היחיד שאומת כמגיע
לסוכן (הוכח היום על 1043-02-26: 3,212→7,697 מילים). אותו תיקון ל-interaction-response,
שסובל מאותה תקלה (החצי השני של #228).

**G2 — הסרת מסלול מקביל:** ה-wakeup הנדון-לביטול הוסר מ-`post_comment`; הוא היה
המסלול השני, זה ששותק. כעת מסלול-מסירה קנוני אחד. `mark_comment_routed` מסמן את
ההערה כמנותבת כדי שה-sweep לא יירה הרצת-סרק על מה שכבר טופל.

**G12 — שער-הפלטפורמה:** המגע החדש (`mark_comment_routed`) נחשף דרך
`agent_platform_port.py` בלבד; leak-guard עובר.

התיקון יושב ב-backend (REST) ולא בפלאגין, ולכן אינו חשוף לבאג ה-invocation-scope
שמפיל את ה-sweep. תועד ב-docs/paperclip-quirks.md §7 עם המלכודות שנתקלנו בהן.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 12:32:20 +00:00
29970ad8b2 Merge pull request 'feat(case-files): פאנל "קבצי-התיק" — גישה מה-UI לכל תתי-התיקיות' (#414) from worktree-case-files-browser into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 42s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-07 17:31:17 +00:00
85af98cded feat(case-files): פאנל "קבצי-התיק" בטאב טיוטות — גישה מה-UI לכל תתי-התיקיות
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 31s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
היו"ר לא יכל לגשת לטיוטה שהסוכן שמר בדיסק (interim-decision-draft.md) אלא ב-SSH.
מימוש מוקאפ X17 מאושר (18m): פאנל דפדפן-קבצים בטאב "טיוטות והערות".

Backend (web/app.py) — הכללה של ה-endpoints הקיימים ל**כל** תת-תיקייה:
- `_case_file_folders` — מונה כל תת-תיקיית-תוכן תחת התיק (מקור/מחולץ/מחקר/הגהה/
  טיוטות/גיבויים), רקורסיה שכבה אחת לתוך documents/, מדלג על מוסתרות (.git/.claude)
  ו-thumbnails. `_resolve_case_file` מאמת folder-key מול הרשימה + חוסם path-traversal.
- `/local-files` מחזיר את כל התיקיות (כולל ריקות, עם files=[]); `/local-files/{folder}/{filename}`
  מגיש מכל תיקייה עם media-type לפי סיומת.

Frontend:
- `lib/api/case-files.ts` — useCaseFiles / useCaseFileText / caseFileUrl.
- `CaseFilesBrowser` — עץ-תיקיות (אקורדיון, ריקות מסומנות "ריק") + viewer מובנה
  ל-md/txt (Markdown הקיים) + הורדה לכל קובץ. נטען בטאב "טיוטות והערות".

אין response_model → אין תלות ב-api:types (מודול טיפוסים בכתב-יד).
Invariants: G1, G2 (אין מסלול-קבצים מקביל — מכליל את הקיים), אבטחת-נתיב.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:30:42 +00:00
7 changed files with 596 additions and 61 deletions

View File

@@ -275,3 +275,40 @@ WARN [plugin] issue.comment.created event missing issueId in payload, skipping
- **תוקן בצד שלנו** (PR #2, נפרס דרך `npm run build` + `pm2 restart paperclip` — הפלאגין נטען מ-`/home/chaim/plugin-legal-ai` לפי `package_path`).
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
- TaskMaster: `legal-ai` #149.
---
## 7. `agents.invoke({prompt})` — הפרומפט נבלע; רק `payload.issueId` נמסר
**התסמין (מנקודת-מבט היו"ר):** כותבים הוראה לסוכן — והוא "מתעלם". ההרצה נראית תקינה
לחלוטין: `status=succeeded`, `exit_code=0`, בלי שגיאה, ואפילו עולה כסף. הסוכן מדווח
"כל התורים ריקים — אין עבודה".
**הסיבה:** `ctx.agents.invoke(agentId, companyId, {prompt, reason})` הוא ה-API **המתועד**
של ה-Plugin SDK ("*Invoke an agent with a prompt payload*"), ו-`prompt` הוא שדה חובה.
Paperclip מקבל אותו, שומר אותו ב-`agent_wakeup_requests.payload.prompt` — **וה-runner קורא
רק `payload.issueId`**. הפרומפט לא מגיע למודל. ה-`reason` כן שורד (ל-`context_snapshot.wakeReason`),
ולכן זה נראה כאילו משהו נמסר.
מדידה ב-DB (2026-07-16): **18 מתוך 18** ה-wakeups שאי-פעם נשאו `payload.prompt` לא הגיעו
לאף הרצה — 0% מסירה. מול 107,172 wakeups עם `issueId` שעובדים. אימות ישיר: בלוג-ההרצה
שנבלעה אין ולו אזכור אחד של מספר-התיק, והמודל פותח ב-*"This heartbeat has no scoped wake payload"*.
**מה עושים במקום:** נושאים את ההוראה על **issue**, לא ב-payload — issue-ילד שכבר משויך
ל-CEO, עם ההוראה ב-`description`, ואז wakeup עם `payload.issueId`. זה בדיוק
`open_ceo_run` ב-[`web/paperclip_client.py`](../web/paperclip_client.py), והמסלול היחיד
שאומת כמגיע לסוכן.
**מלכודות שנלוות לזה — אל תיפול בהן שוב:**
- **`issues.create` של ה-SDK עם `assigneeAgentId` *לא* מעיר את הסוכן.** ה-REST המקביל
(`POST /api/issues/:id/children`) *כן* — הוא פולט wakeup `issue_assigned`. אל תכליל
מהתנהגות REST על ה-SDK; זה נבדק ונמצא שונה.
- **`ctx.issues.requestWakeup` (ה-primitive הנכון ב-SDK) נכשל מתוך scheduled job** עם
`missing, expired, or unknown invocation scope` — למרות ש-capability `issues.wakeup`
מוצהר ומותקן. אותה שגיאה מפילה גם `companies.list` / `issues.list` / `issues.listComments`
בתוך ה-sweep, ולכן **רשת-הביטחון `route-pending-comments` אינה אמינה**. לא נחקר לעומק —
לכן ניתוב-ההערות הועבר ל-backend של legal-ai (REST), שאינו חשוף לבעיה.
**סטטוס:** נעקף — `POST /api/cases/{case}/agents/comment` ו-`/agents/interaction-response`
פותחים הרצת-CEO ישירות (REST) ומסמנים את ההערה כמנותבת, כדי שה-sweep השבור לא יירה
הרצת-סרק. TaskMaster `legal-ai` #228.

View File

@@ -14,6 +14,7 @@ import { CaseHeader } from "@/components/cases/case-header";
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
import { DocumentsPanel } from "@/components/cases/documents-panel";
import { DraftsPanel } from "@/components/cases/drafts-panel";
import { CaseFilesBrowser } from "@/components/cases/case-files-browser";
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
import { PositionsPanel } from "@/components/cases/positions-panel";
@@ -243,12 +244,17 @@ export default function CaseDetailPage({
</Card>
</TabsContent>
<TabsContent value="drafts" className="mt-0">
<TabsContent value="drafts" className="mt-0 space-y-4">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<CaseFilesBrowser caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="agents" className="mt-0">

View 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>
);
}

View 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,
});
}

View File

@@ -63,6 +63,7 @@ from web.paperclip_client import (
cancel_interaction as _cancel_interaction,
cancel_run as _cancel_run,
escalate_issue as _escalate_issue,
mark_comment_routed as _mark_comment_routed,
post_comment as _post_comment,
reap_stale_interactions as _reap_stale_interactions,
reset_agent_session as _reset_agent_session,
@@ -114,6 +115,9 @@ pc_wake_analyst_for_protocol_analysis = instrument(
pc_post_comment = instrument(
"agent.comment", keys=("issue_id", "company_id"),
)(_post_comment)
pc_mark_comment_routed = instrument(
"agent.comment", keys=("issue_id", "comment_id"),
)(_mark_comment_routed)
pc_cancel_interaction = instrument(
"interaction.cancelled", keys=("issue_id", "interaction_id"),
)(_cancel_interaction)
@@ -179,6 +183,7 @@ __all__ = [
"pc_get_generation_run_status",
# comments / interactions
"pc_post_comment",
"pc_mark_comment_routed",
"pc_get_issue_comments",
"pc_get_issue_interactions",
"pc_accept_interaction",

View File

@@ -91,6 +91,7 @@ from web.agent_platform_port import (
pc_wake_ceo,
pc_wake_ceo_for_action,
pc_open_ceo_run,
pc_mark_comment_routed,
pc_wake_ceo_for_feedback_fold,
pc_wake_curator_for_final,
pc_wake_for_precedent_extraction,
@@ -3069,42 +3070,82 @@ async def api_learn(case_number: str):
# ── Local files API — research, drafts, proofread ──
# Directories under a case that are infrastructure/cache, not chair-facing
# content. Hidden dirs (``.git``, ``.claude`` …) are skipped separately.
_CASE_FILE_EXCLUDE_DIRS = {"thumbnails"}
def _is_content_dir(p: Path) -> bool:
return p.is_dir() and not p.name.startswith(".") and p.name not in _CASE_FILE_EXCLUDE_DIRS
def _case_file_folders(case_dir: Path) -> list[tuple[str, Path]]:
"""Enumerate every content subdirectory under a case dir as (key, path).
Covers *all* subfolders (not just research) — recursing one level into
``documents/`` — while skipping hidden (``.git``/``.claude``…) and cache
dirs. The ``key`` is the case-relative path with ``/`` encoded as ``__`` so
it survives a single URL path segment; ``_resolve_case_file`` re-validates it
against this same enumeration, so the key set is the serve allowlist (no path
traversal).
"""
folders: list[tuple[str, Path]] = []
if not case_dir.exists():
return folders
for child in sorted(case_dir.iterdir()):
if not _is_content_dir(child):
continue
if child.name == "documents":
folders.extend(
(f"documents__{sub.name}", sub)
for sub in sorted(child.iterdir())
if _is_content_dir(sub)
)
else:
folders.append((child.name, child))
return folders
def _resolve_case_file(case_dir: Path, folder_key: str, filename: str) -> Path | None:
"""Safely resolve (folder_key, filename) to a file inside the case dir.
Returns None unless ``folder_key`` is one of the enumerated content folders
AND ``filename`` resolves to a regular file directly inside it (no traversal).
"""
base = dict(_case_file_folders(case_dir)).get(folder_key)
if base is None:
return None
target = (base / filename).resolve()
if target.parent != base.resolve() or not target.is_file():
return None
return target
@app.get("/api/cases/{case_number}/local-files")
async def api_local_files(case_number: str):
"""List local files from case subdirectories (research, drafts, proofread)."""
"""List local files across ALL content subfolders under a case (not just
research) — so the chair can reach any agent-produced file (drafts, OCR text,
originals…) from the UI instead of only over SSH. Empty folders are returned
too (with an empty ``files`` list) so the UI can show them as such."""
case_dir = config.find_case_dir(case_number)
result = {}
for folder in ("research", "proofread"):
folder_path = case_dir / "documents" / folder
if folder_path.exists():
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():
folders = []
for key, path in _case_file_folders(case_dir):
files = []
for f in sorted(drafts_path.iterdir()):
for f in sorted(path.iterdir()):
if f.is_file() and not f.name.startswith("."):
stat = f.stat()
files.append({
"filename": f.name,
"size": stat.st_size,
"modified_at": stat.st_mtime,
"folder": "drafts",
})
if files:
result["drafts"] = files
return result
folders.append({
"key": key,
"name": path.name,
"path": str(path.relative_to(case_dir)),
"files": files,
})
return {"folders": folders}
async def serve_blob(
@@ -3174,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}")
async def api_read_local_file(case_number: str, folder: str, filename: str):
"""Read contents of a local case file."""
if folder not in ("research", "proofread", "drafts"):
raise HTTPException(400, "Invalid folder")
"""Serve a single local case file from any content subfolder.
``folder`` is a key from ``/local-files`` (``documents__research``, ``drafts``…);
``_resolve_case_file`` validates it against the folder allowlist and blocks
path traversal before the bytes are served.
"""
case_dir = config.find_case_dir(case_number)
if folder == "drafts":
path = case_dir / "drafts" / filename
else:
path = case_dir / "documents" / folder / filename
if not path.exists() or not path.is_file():
path = _resolve_case_file(case_dir, folder, filename)
if path is None:
raise HTTPException(404, "קובץ לא נמצא")
return await serve_blob(path, media_type="text/plain; charset=utf-8", filename=filename)
media_type = _local_file_media_type(filename)
return await serve_blob(path, media_type=media_type, filename=filename)
def _local_file_media_type(filename: str) -> str:
"""Best-effort content type for a served case file (text renders inline,
binaries download)."""
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
return {
"md": "text/markdown; charset=utf-8",
"txt": "text/plain; charset=utf-8",
"json": "application/json; charset=utf-8",
"pdf": "application/pdf",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"doc": "application/msword",
}.get(ext, "application/octet-stream")
# ── Research analysis (analysis-and-research.md) — parse + edit ────
@@ -4605,8 +4661,9 @@ async def api_case_agents(case_number: str):
class AgentCommentRequest(BaseModel):
body: str
issue_id: str | None = None
# "פתח הרצה חדשה" — open a fresh CEO-owned run for this instruction instead of
# appending to an existing issue (agents-tab target selector). Overrides issue_id.
# "פתח הרצה חדשה" — skip the comment entirely and only open a CEO run. Kept for
# the agents-tab target selector; the default path below now opens a run too, so
# this only differs in *not* recording the instruction on the parent thread.
new_run: bool = False
@@ -4650,10 +4707,31 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
result = await pc_post_comment(target["id"], target["company_id"], req.body)
# The comment alone reaches nobody: a case issue awaiting the chair is
# `in_review` and owned by a human, so Paperclip cancels any wakeup on it
# (`issue_assignee_changed` → "wake the new owner" → the owner is a person).
# The plugin sweep that was meant to catch this delivers the instruction as
# `payload.prompt`, which the runner drops. So the comment is recorded for the
# thread, and the instruction is carried by a CEO-owned child run — the one
# path verified to reach the agent (TaskMaster #228; see paperclip-quirks.md).
run = await pc_open_ceo_run(case_number, req.body)
if run.get("status") != "ok":
raise HTTPException(
502,
f"ההערה נשמרה אך פתיחת ההרצה נכשלה — הסוכן לא יטפל בה: "
f"{run.get('reason', 'unknown')}",
)
# Claim the comment so the sweep does not re-route it: the CEO answers on the
# child issue, leaving the parent with no agent reply, which the sweep reads
# as "still pending" forever.
await pc_mark_comment_routed(target["id"], result.get("comment_id", ""))
# Echo the resolved target so the UI can show where it landed and flag it
# when the chair explicitly targeted a closed issue (whose wakeup is skipped).
result["issue_identifier"] = target.get("identifier", "")
result["issue_status"] = target.get("status", "")
result["run_issue_id"] = run["issue_id"]
result["run_issue_identifier"] = run.get("identifier", "")
return result
@@ -4670,8 +4748,10 @@ async def api_post_interaction_response(
):
"""Submit a user's answer to a Paperclip issue-thread interaction.
Routes to /respond | /accept | /reject based on `action`. Paperclip
auto-wakes the issue assignee after a successful submission.
Routes to /respond | /accept | /reject based on `action`, then opens a CEO run
carrying the answer. Paperclip's own `wake_assignee` cannot deliver it: the
issue is owned by the chair while it waits for her, so the "assignee" it wakes
is a person and no agent ever runs (TaskMaster #228).
"""
issues = await pc_get_case_issues(case_number)
if not any(i["id"] == req.issue_id for i in issues):
@@ -4683,7 +4763,7 @@ async def api_post_interaction_response(
"reject": pc_reject_interaction,
}
try:
return await handlers[req.action](
result = await handlers[req.action](
req.issue_id, req.interaction_id, req.payload,
)
except httpx.HTTPStatusError as e:
@@ -4692,6 +4772,23 @@ async def api_post_interaction_response(
except Exception as e:
raise HTTPException(502, f"שגיאת Paperclip: {e}")
answer = json.dumps(req.payload, ensure_ascii=False)
run = await pc_open_ceo_run(
case_number,
f'תשובת היו"ר לשאלה שהוצגה לה ({req.action}):\n\n{answer}\n\n'
f"קרא את ה-interaction המקורי על ה-issue והמשך משם.",
)
if run.get("status") != "ok":
raise HTTPException(
502,
f"התשובה נשמרה אך פתיחת ההרצה נכשלה — הסוכן לא ימשיך: "
f"{run.get('reason', 'unknown')}",
)
if isinstance(result, dict):
result["run_issue_id"] = run["issue_id"]
result["run_issue_identifier"] = run.get("identifier", "")
return result
class InteractionDismissRequest(BaseModel):
issue_id: str

View File

@@ -327,6 +327,40 @@ async def _create_issue(
return issue_id, identifier
async def mark_comment_routed(issue_id: str, comment_id: str) -> dict:
"""Claim a chair comment as already routed, so the plugin sweep skips it.
The plugin's `route-pending-comments` sweep re-routes any user comment newer
than the newest agent comment on the same issue. A chair instruction answered
on a CEO **child** issue leaves the parent with no agent reply, so the sweep
would keep re-routing it forever. Writing the sweep's own marker
(`last-routed-comment-id`, plugin state, issue scope) is what tells it the
comment is handled — this is the same key `markCommentRouted` sets in
`plugin-legal-ai/src/worker.ts`.
"""
if not comment_id:
return {"ok": False, "error": "no_comment_id"}
try:
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
await conn.execute(
"""INSERT INTO plugin_state (plugin_id, scope_kind, scope_id, namespace, state_key, value_json)
VALUES ($1::uuid, 'issue', $2, 'default', 'last-routed-comment-id', $3::jsonb)
ON CONFLICT (plugin_id, scope_kind, scope_id, namespace, state_key)
DO UPDATE SET value_json = $3::jsonb""",
PLUGIN_ID, issue_id, json.dumps(comment_id),
)
finally:
await conn.close()
return {"ok": True, "issue_id": issue_id, "comment_id": comment_id}
except Exception as e:
logger.warning(
"mark_comment_routed failed for issue %s comment %s: %s",
issue_id, comment_id, e,
)
return {"ok": False, "error": str(e)}
async def _link_case_to_issue(conn: asyncpg.Connection, issue_id: str, case_number: str) -> None:
"""Store the legal-ai case number in plugin state, linked to the issue."""
await conn.execute(
@@ -655,10 +689,14 @@ async def get_agents_for_case(company_id: str, issue_ids: list[str]) -> list[dic
async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
"""Post a comment on a Paperclip issue.
"""Post a comment on a Paperclip issue. Records only — wakes nobody.
Tries the Board API first (triggers plugin events for CEO routing).
Falls back to direct DB insert + CEO wakeup if API fails.
Delivering the instruction is the caller's job, via :func:`open_ceo_run`. This
function used to also wake the CEO on ``issue_id``, but that wakeup is dead on
arrival for the case issues the chair actually comments on: they are
`in_review` and owned by her, so Paperclip cancels the run with
`issue_assignee_changed` before it starts. Keeping it would leave two parallel
delivery paths — one that works and one that silently doesn't (INV-G2).
"""
# Try Board API first — this triggers the event bus
if PAPERCLIP_BOARD_API_KEY:
@@ -675,7 +713,6 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
except Exception:
logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id)
# Fallback: direct DB insert + explicit CEO wakeup
comment_id = str(uuid.uuid4())
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
@@ -688,23 +725,6 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
finally:
await conn.close()
# Wake the correct CEO for this company
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
try:
await pc_request(
"POST",
f"/api/agents/{ceo_id}/wakeup",
json={
"source": "on_demand",
"triggerDetail": "manual",
"reason": f"user_comment_{issue_id}",
"payload": {"issueId": issue_id, "mutation": "comment"},
},
raise_on_error=True,
)
except Exception:
logger.warning("Failed to wake CEO after DB comment on issue %s", issue_id)
return {"comment_id": comment_id, "issue_id": issue_id, "method": "db_fallback"}