Compare commits
22 Commits
d81c3c37ab
...
docs/hooks
| Author | SHA1 | Date | |
|---|---|---|---|
| 8db3bf6ddd | |||
| a3468d5b2f | |||
| 5f43659b5a | |||
| 86734da210 | |||
| 82ded005a4 | |||
| c7ed1110f8 | |||
| 015e553d06 | |||
| 6bdf9786ac | |||
| d87f9c5a5f | |||
| a0fab1f6de | |||
| d5043100a7 | |||
| 932cc7191c | |||
| d983cfdd3b | |||
| 50649baeed | |||
| a9cd8aeb12 | |||
| 10a63fb9e0 | |||
| f94201c577 | |||
| 026457dac4 | |||
| 75493ce233 | |||
| 3e14cd6798 | |||
| 13a8d9e58f | |||
| 45341a0bc8 |
@@ -1,13 +1,18 @@
|
||||
---
|
||||
name: hermes-curator
|
||||
description: Knowledge Curator (Hermes) — מנתח החלטות סופיות אחרי export, מציע עדכונים ל-skills/lessons. read-only על תוכן, write רק על comments.
|
||||
adapter: hermes_local
|
||||
model: anthropic/claude-sonnet-4-5
|
||||
adapter: deepseek_local
|
||||
model: deepseek-v4-pro
|
||||
profiles:
|
||||
CMP: curator-cmp # רישוי ובניה (תיקים 1xxx)
|
||||
CMPA: curator-cmpa # היטל השבחה + פיצויים (תיקים 8xxx, 9xxx)
|
||||
---
|
||||
|
||||
> **Why DeepSeek**: A/B test 2026-05-05 הראה ש-DeepSeek V4-Pro חזק יותר מ-Sonnet
|
||||
> על דפוסי סגנון/לקסיקון, פי 2-3 מהיר, פי ~20 זול. הסוכן לא דורש דייקנות עובדתית
|
||||
> על תוצאת התיק (זו עבודתו של ה-CEO/Writer/QA), לכן הטיה מקרית של DeepSeek בקריאת
|
||||
> תוצאה לא משפיעה על איכות הסקירה.
|
||||
|
||||
# מנהל ידע — Hermes Knowledge Curator
|
||||
|
||||
## רקע
|
||||
@@ -54,10 +59,11 @@ profiles:
|
||||
|
||||
1. קורא את ה-issue body שב-`{{taskBody}}` — שם התיק + ID של ההחלטה הסופית
|
||||
2. משתמש ב-MCP tools של legal-ai:
|
||||
- `mcp__legal-ai__case_get` — קבלת פרטי תיק
|
||||
- `mcp__legal-ai__document_list` — רשימת מסמכים, איתור ההחלטה הסופית
|
||||
- `mcp__legal-ai__search_decisions` — השוואה לחלטות קודמות
|
||||
- `mcp__legal-ai__case_get` — קבלת פרטי תיק (כולל `expected_outcome` — **הסמכות העובדתית** לתוצאה)
|
||||
- `mcp__legal-ai__case_get_final_text` — הטקסט המלא של ההחלטה הסופית
|
||||
- `mcp__legal-ai__document_list` — רק אם נדרש רשימת מסמכים נוספים של התיק
|
||||
- `mcp__legal-ai__get_style_guide` — דפוסי הסגנון של דפנה
|
||||
- **לא** להשתמש ב-`search_decisions` — השוואה ל-`SKILL.md` ו-`corpus-analysis.md` מספיקה ולא יקרה
|
||||
3. קורא קבצים מקומיים (read-only):
|
||||
- `/home/chaim/legal-ai/skills/decision/SKILL.md`
|
||||
- `/home/chaim/legal-ai/docs/legal-decision-lessons.md`
|
||||
@@ -74,13 +80,30 @@ profiles:
|
||||
|
||||
## פורמט ה-comment
|
||||
|
||||
עברית, ניטרלי. 3-5 ממצאים מובחנים. כל ממצא:
|
||||
עברית, ניטרלי. 3-5 ממצאים מובחנים. **כל ממצא חייב להיות מתויג** באחד מ-4 הסוגים:
|
||||
|
||||
```
|
||||
[סגנון] — מילים, ביטויי מעבר, פתיחות, סיומים
|
||||
[מבנה] — סדר בלוקים, יחסי אורך, מספור
|
||||
[לקסיקון משפטי] — מינוח טכני (מגישי תכנית, ריפוי פגם, וכו')
|
||||
[טבלאי] — דפוסים שמופיעים פעמיים+ ב-corpus
|
||||
```
|
||||
|
||||
לכל ממצא:
|
||||
- **מה ראיתי** — תיאור קצר של הדפוס/הפער
|
||||
- **מה זה אומר** — למה זה חשוב
|
||||
- **הצעה** — איך אפשר להוסיף ל-style guide / lessons (טקסט מוצע מילולי)
|
||||
|
||||
אם אין ממצאים חדשים → לציין במפורש בלי להמציא.
|
||||
|
||||
## מה **לא** להגיד ב-comment
|
||||
|
||||
- **אל תכלול שורת מטא** בראש ה-comment עם "תוצאה: X" או "אורך: ~Y תווים".
|
||||
אתה לא בודק את התיק — אתה בודק את הסגנון. תוצאה מוטעית בראש ה-comment פוגעת באמינות.
|
||||
- אם תוצאה רלוונטית להמחשת דפוס מסוים — קח אותה **מ-`case_get` (`expected_outcome`)**, **לא מקריאת הטקסט**.
|
||||
אם השדה ריק או חסר ב-DB — סמן `[תוצאה: לא מאומתת]` או דלג עליה.
|
||||
- **אל תפרש משפטית** את ההחלטה. דפנה כבר הכריעה. תפקידך זיהוי דפוסים בלבד.
|
||||
|
||||
## מה אני לא עושה
|
||||
|
||||
- **לא מעדכן** קבצים בעצמי (skills/, lessons.py, DB) — רק מציע
|
||||
@@ -93,6 +116,27 @@ profiles:
|
||||
אם MCP server לא נגיש או החלטה לא נמצאת, כתוב comment קצר עם הסיבה
|
||||
ו-status=failed. אל תזייף ממצאים.
|
||||
|
||||
## דרישות מ-`deepseek_local` adapter (חובה)
|
||||
|
||||
ה-adapter שמריץ אותי **חייב** להזריק 3 דברים בכל wake — אחרת interactions ייחסמו ב-`401 "Agent run id required"`:
|
||||
|
||||
1. **env `PAPERCLIP_API_KEY`** — agent's own pcp_ key
|
||||
2. **env `PAPERCLIP_RUN_ID`** — ה-`heartbeat_runs.id` של ה-wake הנוכחי
|
||||
3. **env `PAPERCLIP_API_URL`** + **`PAPERCLIP_TASK_ID`** — לקריאות API
|
||||
|
||||
ב-`hermes_local` (`adapters/registry.ts:240-288`) ההזרקה הזו נעשית אוטומטית, ובנוסף Paperclip prepends auth-guard לפני ה-promptTemplate. ב-`deepseek_local` החדש — לוודא שמיושם.
|
||||
|
||||
ה-promptTemplate **כבר** כולל את ה-header `X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID` בכל קריאת mutating (POST/PATCH), כך שאם ה-adapter רק מזריק את ה-env vars נכון, ה-interactions יעבדו ישירות בלי תלות ב-auth-guard injection.
|
||||
|
||||
### Verification:
|
||||
|
||||
```bash
|
||||
# על תיק חי, אחרי שדפנה לוחצת mark-final, ה-curator יקבל:
|
||||
echo "PAPERCLIP_RUN_ID=$PAPERCLIP_RUN_ID" # חייב להיות UUID חוקי
|
||||
echo "PAPERCLIP_API_KEY=${PAPERCLIP_API_KEY:0:8}..." # חייב להתחיל ב-pcp_
|
||||
echo "PAPERCLIP_API_URL=$PAPERCLIP_API_URL" # חייב להיות http://localhost:3100/api
|
||||
```
|
||||
|
||||
## קונטקסט קבוע (לא לשכוח)
|
||||
|
||||
- היו"ר: עו"ד דפנה תמיר
|
||||
|
||||
11
CLAUDE.md
11
CLAUDE.md
@@ -118,6 +118,8 @@
|
||||
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
|
||||
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
|
||||
├── mcp-server/ ← MCP server + services + tools
|
||||
├── adapters/ ← Paperclip external adapters (ראה למטה)
|
||||
│ └── deepseek-paperclip-adapter/ ← `deepseek_local` (Hermes-pinned ל-DeepSeek profile)
|
||||
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
|
||||
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
|
||||
```
|
||||
@@ -180,6 +182,15 @@
|
||||
- הסקריפט מסנן local skills שלא קיימים ב-CMPA (מציג אזהרה), משתמש ב-API (לא DB ישיר), יוצר revisions, idempotent.
|
||||
- שאלות ה-skill הרשמי של Paperclip — `paperclip` skill תחת `paperclipai/paperclip`.
|
||||
|
||||
### External adapters — `deepseek_local`
|
||||
- מיקום ה-package: [adapters/deepseek-paperclip-adapter/](adapters/deepseek-paperclip-adapter/) (לא ב-`node_modules`).
|
||||
- רישום ב-Paperclip: רשומה ב-`~/.paperclip/adapter-plugins.json` (נטען אוטומטית ב-startup דרך `buildExternalAdapters`). אין צורך בעריכת `node_modules`.
|
||||
- **מה ה-adapter עושה**: spawnל-`hermes chat` עם `HERMES_HOME=/home/chaim/.hermes/profiles/deepseek` כך שה-CLI טוען את `config.yaml` (`base_url=https://api.deepseek.com/v1`, `provider=custom`, `key_env=DEEPSEEK_API_KEY`) ואת `.env` (שמכיל את ה-key).
|
||||
- **מודלים זמינים** (lookup ב-DeepSeek `/v1/models`): `deepseek-v4-pro` (default), `deepseek-v4-flash`. יופיעו כדרופ-דאון ב-UI.
|
||||
- **התקנה מחדש / עדכון**: `curl -X POST -H "Authorization: Bearer pcapi_legal_install_key_2026" -H "Content-Type: application/json" -d '{"packageName":"/home/chaim/legal-ai/adapters/deepseek-paperclip-adapter","isLocalPath":true}' http://localhost:3100/api/adapters/install`. לעדכון hot — `POST /api/adapters/deepseek_local/reload`.
|
||||
- **⚠ Cross-company sync**: `sync_agents_across_companies.py` **מדלג** על סוכנים עם `adapter_type` שונה בין CMP ל-CMPA. כשעוברים סוכן ל-`deepseek_local` חובה להחיל ידנית בשתי החברות לפני sync.
|
||||
- **תוספת adapters עתידיים** (OpenAI ישיר, Anthropic ישיר, וכו'): אותו דפוס. ה-package הראשי חייב לייצא `createServerAdapter()` שמחזיר `{ type, label, models, agentConfigurationDoc, execute, testEnvironment, sessionCodec, listSkills, syncSkills, ... }`. ראה את [adapters/deepseek-paperclip-adapter/dist/index.js](adapters/deepseek-paperclip-adapter/dist/index.js) כתבנית.
|
||||
|
||||
---
|
||||
|
||||
## עקרונות כתיבה קריטיים
|
||||
|
||||
99
adapters/deepseek-paperclip-adapter/dist/index.js
vendored
Normal file
99
adapters/deepseek-paperclip-adapter/dist/index.js
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* DeepSeek (via Hermes) — external Paperclip adapter.
|
||||
*
|
||||
* Loaded by Paperclip's plugin-loader. Contract:
|
||||
* The package's main module must export createServerAdapter() returning
|
||||
* a single ServerAdapterModule object with all fields wired in.
|
||||
*
|
||||
* Runtime: spawns the local `hermes` CLI with HERMES_HOME pinned to a
|
||||
* DeepSeek profile that defines model.base_url=https://api.deepseek.com/v1
|
||||
* and model.key_env=DEEPSEEK_API_KEY.
|
||||
*/
|
||||
|
||||
import {
|
||||
ADAPTER_TYPE,
|
||||
ADAPTER_LABEL,
|
||||
DEEPSEEK_MODELS,
|
||||
DEFAULT_PROFILE_HOME,
|
||||
} from "./shared/constants.js";
|
||||
import { execute } from "./server/execute.js";
|
||||
import { testEnvironment } from "./server/test.js";
|
||||
import { sessionCodec } from "./server/session-codec.js";
|
||||
import { listSkills, syncSkills } from "./server/skills.js";
|
||||
|
||||
const AGENT_CONFIGURATION_DOC = `# DeepSeek (via Hermes) — Agent Configuration
|
||||
|
||||
DeepSeek-pinned variant of the Hermes adapter. Runs the local \`hermes\` CLI
|
||||
with \`HERMES_HOME\` pointed at a DeepSeek profile (\`config.yaml\` declares
|
||||
\`base_url=https://api.deepseek.com/v1\` and \`key_env=DEEPSEEK_API_KEY\`).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Hermes Agent installed (\`pip install hermes-agent\`) — \`hermes --version\` works.
|
||||
- DeepSeek profile dir exists (default: \`/home/chaim/.hermes/profiles/deepseek\`)
|
||||
with \`config.yaml\` + \`.env\` (containing \`DEEPSEEK_API_KEY\`).
|
||||
|
||||
## Core Configuration
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| model | string | \`deepseek-v4-pro\` | DeepSeek model id (\`deepseek-v4-pro\` or \`deepseek-v4-flash\`). |
|
||||
| provider | string | \`custom\` | Hermes provider name. The DeepSeek profile defines \`provider: custom\` so \`custom\` is the right value. |
|
||||
| hermesProfileHome | string | \`/home/chaim/.hermes/profiles/deepseek\` | Absolute path to a Hermes profile dir. Set per-agent if you maintain multiple DeepSeek profiles. |
|
||||
| timeoutSec | number | 1800 | Execution timeout in seconds. |
|
||||
| graceSec | number | 30 | SIGTERM grace period in seconds. |
|
||||
|
||||
## Tools / Workspace
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| toolsets | string | (profile default) | Comma-separated toolsets to enable. |
|
||||
| persistSession | boolean | true | Resume sessions across heartbeats via \`--resume\`. |
|
||||
| worktreeMode | boolean | false | Use git worktree for isolated changes. |
|
||||
| checkpoints | boolean | false | Enable filesystem checkpoints. |
|
||||
|
||||
## Advanced
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| hermesCommand | string | \`hermes\` | Path to the hermes binary. |
|
||||
| verbose | boolean | false | Enable verbose Hermes logs. |
|
||||
| extraArgs | string[] | [] | Extra CLI args appended after standard flags. |
|
||||
| env | object | {} | Extra environment variables passed to Hermes. \`HERMES_HOME\` here overrides \`hermesProfileHome\`. |
|
||||
| promptTemplate | string | (default) | Override the default Paperclip wakeup prompt. |
|
||||
| paperclipApiUrl | string | \`http://127.0.0.1:3100/api\` | Paperclip API URL injected into the prompt template. |
|
||||
|
||||
## Available template variables
|
||||
|
||||
\`{{agentId}}\`, \`{{agentName}}\`, \`{{companyId}}\`, \`{{companyName}}\`,
|
||||
\`{{runId}}\`, \`{{taskId}}\`, \`{{taskTitle}}\`, \`{{taskBody}}\`,
|
||||
\`{{commentId}}\`, \`{{wakeReason}}\`, \`{{projectName}}\`, \`{{paperclipApiUrl}}\`.
|
||||
`;
|
||||
|
||||
export function createServerAdapter() {
|
||||
return {
|
||||
type: ADAPTER_TYPE,
|
||||
label: ADAPTER_LABEL,
|
||||
models: DEEPSEEK_MODELS,
|
||||
agentConfigurationDoc: AGENT_CONFIGURATION_DOC,
|
||||
|
||||
execute,
|
||||
testEnvironment,
|
||||
sessionCodec,
|
||||
listSkills,
|
||||
syncSkills,
|
||||
|
||||
// Capability flags
|
||||
supportsLocalAgentJwt: true,
|
||||
supportsInstructionsBundle: false,
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Also export the loose constants for any caller that wants to inspect
|
||||
// the package without invoking createServerAdapter (e.g., test harnesses).
|
||||
export const type = ADAPTER_TYPE;
|
||||
export const label = ADAPTER_LABEL;
|
||||
export const models = DEEPSEEK_MODELS;
|
||||
export const agentConfigurationDoc = AGENT_CONFIGURATION_DOC;
|
||||
export const defaultProfileHome = DEFAULT_PROFILE_HOME;
|
||||
352
adapters/deepseek-paperclip-adapter/dist/server/execute.js
vendored
Normal file
352
adapters/deepseek-paperclip-adapter/dist/server/execute.js
vendored
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Server-side execution for the DeepSeek-via-Hermes adapter.
|
||||
*
|
||||
* Spawns `hermes chat -q "..." -Q -m <model> --provider custom` with
|
||||
* HERMES_HOME pinned to a DeepSeek-configured profile so the same machine
|
||||
* can run other Hermes-based agents on different providers in parallel.
|
||||
*
|
||||
* The Hermes CLI loads model.base_url, model.key_env (DEEPSEEK_API_KEY),
|
||||
* and toolsets from <HERMES_HOME>/config.yaml + <HERMES_HOME>/.env.
|
||||
*/
|
||||
|
||||
import {
|
||||
runChildProcess,
|
||||
buildPaperclipEnv,
|
||||
renderTemplate,
|
||||
ensureAbsoluteDirectory,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
HERMES_CLI,
|
||||
DEFAULT_PROFILE_HOME,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_PROVIDER,
|
||||
DEFAULT_TIMEOUT_SEC,
|
||||
DEFAULT_GRACE_SEC,
|
||||
SESSION_ID_REGEX,
|
||||
SESSION_ID_REGEX_LEGACY,
|
||||
TOKEN_USAGE_REGEX,
|
||||
COST_REGEX,
|
||||
} from "../shared/constants.js";
|
||||
|
||||
function cfgString(v) {
|
||||
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||
}
|
||||
function cfgNumber(v) {
|
||||
return typeof v === "number" ? v : undefined;
|
||||
}
|
||||
function cfgBoolean(v) {
|
||||
return typeof v === "boolean" ? v : undefined;
|
||||
}
|
||||
function cfgStringArray(v) {
|
||||
return Array.isArray(v) && v.every((i) => typeof i === "string") ? v : undefined;
|
||||
}
|
||||
|
||||
const DEFAULT_PROMPT_TEMPLATE = `You are "{{agentName}}", an AI agent employee in a Paperclip-managed company powered by DeepSeek.
|
||||
|
||||
IMPORTANT: Use the \`terminal\` tool with \`curl\` for ALL Paperclip API calls (web_extract and browser cannot access localhost).
|
||||
|
||||
Your Paperclip identity:
|
||||
Agent ID: {{agentId}}
|
||||
Company ID: {{companyId}}
|
||||
API Base: {{paperclipApiUrl}}
|
||||
|
||||
{{#taskId}}
|
||||
## Assigned Task
|
||||
|
||||
Issue ID: {{taskId}}
|
||||
Title: {{taskTitle}}
|
||||
|
||||
{{taskBody}}
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Work on the task using your tools.
|
||||
2. When done, mark the issue completed:
|
||||
\`curl -s -X PATCH "{{paperclipApiUrl}}/issues/{{taskId}}" -H "Content-Type: application/json" -d '{"status":"done"}'\`
|
||||
3. Post a completion comment summarizing what you did:
|
||||
\`curl -s -X POST "{{paperclipApiUrl}}/issues/{{taskId}}/comments" -H "Content-Type: application/json" -d '{"body":"DONE: <your summary here>"}'\`
|
||||
{{/taskId}}
|
||||
|
||||
{{#commentId}}
|
||||
## Comment on This Issue
|
||||
|
||||
Someone commented. Read it:
|
||||
\`curl -s "{{paperclipApiUrl}}/issues/{{taskId}}/comments/{{commentId}}" | python3 -m json.tool\`
|
||||
Address the comment, POST a reply if needed, then continue working.
|
||||
{{/commentId}}
|
||||
|
||||
{{#noTask}}
|
||||
## Heartbeat Wake — Check for Work
|
||||
|
||||
1. List your open issues:
|
||||
\`curl -s "{{paperclipApiUrl}}/companies/{{companyId}}/issues?assigneeAgentId={{agentId}}"\`
|
||||
2. Pick the highest priority and work on it. When done, follow steps 2-3 above.
|
||||
3. If nothing to do, report briefly what you checked.
|
||||
{{/noTask}}`;
|
||||
|
||||
function buildPrompt(ctx, config) {
|
||||
const template = cfgString(config.promptTemplate) || DEFAULT_PROMPT_TEMPLATE;
|
||||
const taskId = cfgString(ctx.context?.taskId);
|
||||
const taskTitle = cfgString(ctx.context?.taskTitle) || "";
|
||||
const taskBody = cfgString(ctx.context?.taskBody) || "";
|
||||
const commentId = cfgString(ctx.context?.commentId) || "";
|
||||
const wakeReason = cfgString(ctx.context?.wakeReason) || "";
|
||||
const agentName = ctx.agent?.name || "DeepSeek Agent";
|
||||
const companyName = cfgString(ctx.context?.companyName) || "";
|
||||
const projectName = cfgString(ctx.context?.projectName) || "";
|
||||
|
||||
let paperclipApiUrl =
|
||||
cfgString(config.paperclipApiUrl) ||
|
||||
process.env.PAPERCLIP_API_URL ||
|
||||
"http://127.0.0.1:3100/api";
|
||||
if (!paperclipApiUrl.endsWith("/api")) {
|
||||
paperclipApiUrl = paperclipApiUrl.replace(/\/+$/, "") + "/api";
|
||||
}
|
||||
|
||||
const vars = {
|
||||
agentId: ctx.agent?.id || "",
|
||||
agentName,
|
||||
companyId: ctx.agent?.companyId || "",
|
||||
companyName,
|
||||
runId: ctx.runId || "",
|
||||
taskId: taskId || "",
|
||||
taskTitle,
|
||||
taskBody,
|
||||
commentId,
|
||||
wakeReason,
|
||||
projectName,
|
||||
paperclipApiUrl,
|
||||
};
|
||||
|
||||
let rendered = template;
|
||||
rendered = rendered.replace(/\{\{#taskId\}\}([\s\S]*?)\{\{\/taskId\}\}/g, taskId ? "$1" : "");
|
||||
rendered = rendered.replace(/\{\{#noTask\}\}([\s\S]*?)\{\{\/noTask\}\}/g, taskId ? "" : "$1");
|
||||
rendered = rendered.replace(/\{\{#commentId\}\}([\s\S]*?)\{\{\/commentId\}\}/g, commentId ? "$1" : "");
|
||||
return renderTemplate(rendered, vars);
|
||||
}
|
||||
|
||||
function cleanResponse(raw) {
|
||||
return raw
|
||||
.split("\n")
|
||||
.filter((line) => {
|
||||
const t = line.trim();
|
||||
if (!t) return true;
|
||||
if (t.startsWith("[tool]") || t.startsWith("[hermes]") || t.startsWith("[paperclip]") || t.startsWith("[deepseek]")) return false;
|
||||
if (t.startsWith("session_id:")) return false;
|
||||
if (/^\[\d{4}-\d{2}-\d{2}T/.test(t)) return false;
|
||||
if (/^\[done\]\s*┊/.test(t)) return false;
|
||||
if (/^┊\s*[\p{Emoji_Presentation}]/u.test(t) && !/^┊\s*💬/.test(t)) return false;
|
||||
if (/^\p{Emoji_Presentation}\s*(Completed|Running|Error)?\s*$/u.test(t)) return false;
|
||||
return true;
|
||||
})
|
||||
.map((line) => {
|
||||
let t = line.replace(/^[\s]*┊\s*💬\s*/, "").trim();
|
||||
t = t.replace(/^\[done\]\s*/, "").trim();
|
||||
return t;
|
||||
})
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseHermesOutput(stdout, stderr) {
|
||||
const combined = stdout + "\n" + stderr;
|
||||
const result = {};
|
||||
|
||||
const sessionMatch = stdout.match(SESSION_ID_REGEX);
|
||||
if (sessionMatch?.[1]) {
|
||||
result.sessionId = sessionMatch[1];
|
||||
const sessionLineIdx = stdout.lastIndexOf("\nsession_id:");
|
||||
if (sessionLineIdx > 0) {
|
||||
result.response = cleanResponse(stdout.slice(0, sessionLineIdx));
|
||||
}
|
||||
} else {
|
||||
const legacyMatch = combined.match(SESSION_ID_REGEX_LEGACY);
|
||||
if (legacyMatch?.[1]) result.sessionId = legacyMatch[1];
|
||||
const cleaned = cleanResponse(stdout);
|
||||
if (cleaned.length > 0) result.response = cleaned;
|
||||
}
|
||||
|
||||
const usageMatch = combined.match(TOKEN_USAGE_REGEX);
|
||||
if (usageMatch) {
|
||||
result.usage = {
|
||||
inputTokens: parseInt(usageMatch[1], 10) || 0,
|
||||
outputTokens: parseInt(usageMatch[2], 10) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
const costMatch = combined.match(COST_REGEX);
|
||||
if (costMatch?.[1]) result.costUsd = parseFloat(costMatch[1]);
|
||||
|
||||
if (stderr.trim()) {
|
||||
const errorLines = stderr
|
||||
.split("\n")
|
||||
.filter((line) => /error|exception|traceback|failed/i.test(line))
|
||||
.filter((line) => !/INFO|DEBUG|warn/i.test(line));
|
||||
if (errorLines.length > 0) result.errorMessage = errorLines.slice(0, 5).join("\n");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function execute(ctx) {
|
||||
const config = ctx.agent?.adapterConfig ?? {};
|
||||
|
||||
const hermesCmd = cfgString(config.hermesCommand) || HERMES_CLI;
|
||||
const model = cfgString(config.model) || DEFAULT_MODEL;
|
||||
const provider = cfgString(config.provider) || DEFAULT_PROVIDER;
|
||||
const profileHome = cfgString(config.hermesProfileHome) || DEFAULT_PROFILE_HOME;
|
||||
const timeoutSec = cfgNumber(config.timeoutSec) || DEFAULT_TIMEOUT_SEC;
|
||||
const graceSec = cfgNumber(config.graceSec) || DEFAULT_GRACE_SEC;
|
||||
const toolsets = cfgString(config.toolsets) || cfgStringArray(config.enabledToolsets)?.join(",");
|
||||
const extraArgs = cfgStringArray(config.extraArgs);
|
||||
const persistSession = cfgBoolean(config.persistSession) !== false;
|
||||
const worktreeMode = cfgBoolean(config.worktreeMode) === true;
|
||||
const checkpoints = cfgBoolean(config.checkpoints) === true;
|
||||
const useQuiet = cfgBoolean(config.quiet) !== false;
|
||||
|
||||
const prompt = buildPrompt(ctx, config);
|
||||
|
||||
const args = ["chat", "-q", prompt];
|
||||
if (useQuiet) args.push("-Q");
|
||||
if (model) args.push("-m", model);
|
||||
args.push("--provider", provider);
|
||||
if (toolsets) args.push("-t", toolsets);
|
||||
if (worktreeMode) args.push("-w");
|
||||
if (checkpoints) args.push("--checkpoints");
|
||||
if (cfgBoolean(config.verbose) === true) args.push("-v");
|
||||
args.push("--source", "tool");
|
||||
args.push("--yolo");
|
||||
|
||||
const prevSessionId = cfgString(ctx.runtime?.sessionParams?.sessionId);
|
||||
if (persistSession && prevSessionId) args.push("--resume", prevSessionId);
|
||||
if (extraArgs?.length) args.push(...extraArgs);
|
||||
|
||||
// Pin Hermes to the DeepSeek profile by default. The agent can override
|
||||
// by setting adapter_config.hermesProfileHome or adapter_config.env.HERMES_HOME.
|
||||
const env = {
|
||||
...process.env,
|
||||
...buildPaperclipEnv(ctx.agent),
|
||||
HERMES_HOME: profileHome,
|
||||
};
|
||||
if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId;
|
||||
const taskId = cfgString(ctx.context?.taskId);
|
||||
if (taskId) env.PAPERCLIP_TASK_ID = taskId;
|
||||
|
||||
// Parity with hermes_local (paperclip-src/server/src/adapters/registry.ts:267):
|
||||
// inject the per-run agent auth token so the agent can call the Paperclip API.
|
||||
// Without this, every Paperclip API write from the running agent fails with 401.
|
||||
//
|
||||
// Resolve env from the runtime-resolved config (ctx.config.env contains plain
|
||||
// strings — Paperclip's secrets service unwraps {type:"plain"|"secret_ref", ...}
|
||||
// bindings before invocation in services/heartbeat.ts:5433-5437).
|
||||
// Fall back to agent.adapterConfig.env with manual unwrapping for older paths.
|
||||
function unwrapEnvValue(v) {
|
||||
if (typeof v === "string") return v;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||
if (v.type === "plain" && typeof v.value === "string") return v.value;
|
||||
}
|
||||
return undefined; // skip secret_ref / unknown types — let resolver handle them
|
||||
}
|
||||
const resolvedUserEnv =
|
||||
ctx.config && typeof ctx.config === "object" && ctx.config.env && typeof ctx.config.env === "object" && !Array.isArray(ctx.config.env)
|
||||
? ctx.config.env
|
||||
: null;
|
||||
const rawUserEnv =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? config.env
|
||||
: {};
|
||||
// Prefer pre-resolved values from ctx.config.env when available; fall back to
|
||||
// unwrapping raw bindings from agent.adapterConfig.env.
|
||||
const flattenedUserEnv = {};
|
||||
for (const [k, v] of Object.entries(rawUserEnv)) {
|
||||
const resolved = resolvedUserEnv && typeof resolvedUserEnv[k] === "string" ? resolvedUserEnv[k] : unwrapEnvValue(v);
|
||||
if (typeof resolved === "string") flattenedUserEnv[k] = resolved;
|
||||
}
|
||||
const userEnvApiKey = flattenedUserEnv.PAPERCLIP_API_KEY;
|
||||
const explicitApiKey =
|
||||
typeof userEnvApiKey === "string" && userEnvApiKey.trim().length > 0;
|
||||
if (ctx.authToken && !explicitApiKey) env.PAPERCLIP_API_KEY = ctx.authToken;
|
||||
|
||||
// Apply unwrapped user env (may override HERMES_HOME, OPENAI_API_KEY, etc.).
|
||||
Object.assign(env, flattenedUserEnv);
|
||||
|
||||
const cwd = cfgString(config.cwd) || cfgString(ctx.config?.workspaceDir) || ".";
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd);
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[deepseek] Starting Hermes (model=${model}, provider=${provider}, profileHome=${env.HERMES_HOME}, timeout=${timeoutSec}s)\n`,
|
||||
);
|
||||
if (prevSessionId) {
|
||||
await ctx.onLog("stdout", `[deepseek] Resuming session: ${prevSessionId}\n`);
|
||||
}
|
||||
|
||||
// Reclassify benign Hermes stderr lines as stdout so the UI doesn't paint them red.
|
||||
const wrappedOnLog = async (stream, chunk) => {
|
||||
if (stream === "stderr") {
|
||||
const trimmed = chunk.trimEnd();
|
||||
const isBenign =
|
||||
/^\[?\d{4}[-/]\d{2}[-/]\d{2}T/.test(trimmed) ||
|
||||
/^[A-Z]+:\s+(INFO|DEBUG|WARN|WARNING)\b/.test(trimmed) ||
|
||||
/Successfully registered all tools/.test(trimmed) ||
|
||||
/MCP [Ss]erver/.test(trimmed) ||
|
||||
/tool registered successfully/.test(trimmed) ||
|
||||
/Application initialized/.test(trimmed);
|
||||
if (isBenign) return ctx.onLog("stdout", chunk);
|
||||
}
|
||||
return ctx.onLog(stream, chunk);
|
||||
};
|
||||
|
||||
// Forward ctx.onSpawn so Paperclip persists processPid/processGroupId to the
|
||||
// heartbeat_runs row. Without it, the reaper cannot verify the child is alive
|
||||
// (run.processPid is null) and treats the run as orphaned during long quiet
|
||||
// phases (DeepSeek V4-Pro thinking can be silent for 60-90s per turn).
|
||||
const result = await runChildProcess(ctx.runId, hermesCmd, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog: wrappedOnLog,
|
||||
onSpawn: ctx.onSpawn,
|
||||
});
|
||||
|
||||
const parsed = parseHermesOutput(result.stdout || "", result.stderr || "");
|
||||
await ctx.onLog(
|
||||
"stdout",
|
||||
`[deepseek] Exit code: ${result.exitCode ?? "null"}, timed out: ${result.timedOut}\n`,
|
||||
);
|
||||
if (parsed.sessionId) {
|
||||
await ctx.onLog("stdout", `[deepseek] Session: ${parsed.sessionId}\n`);
|
||||
}
|
||||
|
||||
const executionResult = {
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
provider,
|
||||
model,
|
||||
};
|
||||
if (parsed.errorMessage) executionResult.errorMessage = parsed.errorMessage;
|
||||
if (parsed.usage) executionResult.usage = parsed.usage;
|
||||
if (parsed.costUsd !== undefined) executionResult.costUsd = parsed.costUsd;
|
||||
if (parsed.response) executionResult.summary = parsed.response.slice(0, 2000);
|
||||
|
||||
executionResult.resultJson = {
|
||||
result: parsed.response || "",
|
||||
session_id: parsed.sessionId || null,
|
||||
usage: parsed.usage || null,
|
||||
cost_usd: parsed.costUsd ?? null,
|
||||
};
|
||||
|
||||
if (persistSession && parsed.sessionId) {
|
||||
executionResult.sessionParams = { sessionId: parsed.sessionId };
|
||||
executionResult.sessionDisplayId = parsed.sessionId.slice(0, 16);
|
||||
}
|
||||
|
||||
return executionResult;
|
||||
}
|
||||
29
adapters/deepseek-paperclip-adapter/dist/server/session-codec.js
vendored
Normal file
29
adapters/deepseek-paperclip-adapter/dist/server/session-codec.js
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Session codec — Hermes uses a single sessionId for cross-heartbeat continuity
|
||||
* via the --resume CLI flag. Same shape as the Hermes adapter.
|
||||
*/
|
||||
|
||||
function readNonEmptyString(value) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec = {
|
||||
deserialize(raw) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(raw.sessionId) ?? readNonEmptyString(raw.session_id);
|
||||
if (!sessionId) return null;
|
||||
return { sessionId };
|
||||
},
|
||||
serialize(params) {
|
||||
if (!params) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||
if (!sessionId) return null;
|
||||
return { sessionId };
|
||||
},
|
||||
getDisplayId(params) {
|
||||
if (!params) return null;
|
||||
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||
},
|
||||
};
|
||||
171
adapters/deepseek-paperclip-adapter/dist/server/skills.js
vendored
Normal file
171
adapters/deepseek-paperclip-adapter/dist/server/skills.js
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Skill snapshot for the DeepSeek-via-Hermes adapter.
|
||||
*
|
||||
* Hermes manages its own skills under ~/.hermes/skills/ (global; not per-profile).
|
||||
* Paperclip-managed skills declared in adapter config are surfaced as
|
||||
* "company_managed" entries — same behavior as the upstream Hermes adapter.
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { ADAPTER_TYPE } from "../shared/constants.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function asString(value) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function parseSkillFrontmatter(content) {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!match) return {};
|
||||
const fm = {};
|
||||
for (const line of match[1].split("\n")) {
|
||||
const idx = line.indexOf(":");
|
||||
if (idx === -1) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
let val = line.slice(idx + 1).trim();
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
fm[key] = val;
|
||||
}
|
||||
return fm;
|
||||
}
|
||||
|
||||
async function buildSkillEntry(key, skillMdPath, categoryPath) {
|
||||
let description = null;
|
||||
try {
|
||||
const content = await fs.readFile(skillMdPath, "utf8");
|
||||
description = parseSkillFrontmatter(content).description ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {
|
||||
key,
|
||||
runtimeName: key,
|
||||
desired: true,
|
||||
managed: false,
|
||||
state: "installed",
|
||||
origin: "user_installed",
|
||||
originLabel: "Hermes skill",
|
||||
locationLabel: `~/.hermes/skills/${categoryPath}`,
|
||||
readOnly: true,
|
||||
sourcePath: skillMdPath,
|
||||
targetPath: null,
|
||||
detail: description,
|
||||
};
|
||||
}
|
||||
|
||||
async function scanHermesSkills(skillsHome) {
|
||||
const entries = [];
|
||||
try {
|
||||
const cats = await fs.readdir(skillsHome, { withFileTypes: true });
|
||||
for (const cat of cats) {
|
||||
if (!cat.isDirectory()) continue;
|
||||
const catPath = path.join(skillsHome, cat.name);
|
||||
const topSkill = path.join(catPath, "SKILL.md");
|
||||
if (await fs.stat(topSkill).catch(() => null)) {
|
||||
entries.push(await buildSkillEntry(cat.name, topSkill, cat.name));
|
||||
}
|
||||
const items = await fs.readdir(catPath, { withFileTypes: true }).catch(() => []);
|
||||
for (const item of items) {
|
||||
if (!item.isDirectory()) continue;
|
||||
const skillMd = path.join(catPath, item.name, "SKILL.md");
|
||||
if (await fs.stat(skillMd).catch(() => null)) {
|
||||
entries.push(await buildSkillEntry(item.name, skillMd, `${cat.name}/${item.name}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ~/.hermes/skills/ doesn't exist
|
||||
}
|
||||
return entries.sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
async function buildSnapshot(config) {
|
||||
const homedir =
|
||||
asString(config.env?.HOME) ??
|
||||
process.env.HOME ??
|
||||
"/home/chaim";
|
||||
const hermesSkillsHome = path.join(homedir, ".hermes", "skills");
|
||||
|
||||
const paperclipEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, paperclipEntries);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const availableByKey = new Map(paperclipEntries.map((e) => [e.key, e]));
|
||||
|
||||
const hermesSkillEntries = await scanHermesSkills(hermesSkillsHome);
|
||||
const hermesKeys = new Set(hermesSkillEntries.map((e) => e.key));
|
||||
|
||||
const entries = [];
|
||||
const warnings = [];
|
||||
|
||||
for (const entry of paperclipEntries) {
|
||||
const desired = desiredSet.has(entry.key);
|
||||
entries.push({
|
||||
key: entry.key,
|
||||
runtimeName: entry.runtimeName,
|
||||
desired,
|
||||
managed: true,
|
||||
state: desired ? "configured" : "available",
|
||||
origin: entry.required ? "paperclip_required" : "company_managed",
|
||||
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: entry.source,
|
||||
targetPath: null,
|
||||
detail: desired ? "Will be available on the next run via Hermes skill loading." : null,
|
||||
required: Boolean(entry.required),
|
||||
requiredReason: entry.requiredReason ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of hermesSkillEntries) {
|
||||
if (availableByKey.has(entry.key)) continue;
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
for (const desired of desiredSkills) {
|
||||
if (availableByKey.has(desired) || hermesKeys.has(desired)) continue;
|
||||
warnings.push(`Desired skill "${desired}" is not available in Paperclip or Hermes skills.`);
|
||||
entries.push({
|
||||
key: desired,
|
||||
runtimeName: null,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
origin: "external_unknown",
|
||||
originLabel: "External or unavailable",
|
||||
readOnly: false,
|
||||
sourcePath: null,
|
||||
targetPath: null,
|
||||
detail: "Cannot find this skill in Paperclip or ~/.hermes/skills/.",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ADAPTER_TYPE,
|
||||
supported: true,
|
||||
mode: "persistent",
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listSkills(ctx) {
|
||||
return buildSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncSkills(ctx, _desired) {
|
||||
return buildSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export function resolveDesiredSkillNames(config, availableEntries) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
164
adapters/deepseek-paperclip-adapter/dist/server/test.js
vendored
Normal file
164
adapters/deepseek-paperclip-adapter/dist/server/test.js
vendored
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Environment test for the DeepSeek (via Hermes) adapter.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
HERMES_CLI,
|
||||
ADAPTER_TYPE,
|
||||
DEFAULT_PROFILE_HOME,
|
||||
} from "../shared/constants.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function asString(v) {
|
||||
return typeof v === "string" ? v : undefined;
|
||||
}
|
||||
|
||||
async function checkCliInstalled(command) {
|
||||
try {
|
||||
await execFileAsync(command, ["--version"], { timeout: 10_000 });
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err && err.code === "ENOENT") {
|
||||
return {
|
||||
level: "error",
|
||||
message: `Hermes CLI "${command}" not found in PATH`,
|
||||
hint: "Install Hermes Agent: pip install hermes-agent",
|
||||
code: "deepseek_hermes_cli_not_found",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkProfile(profileHome) {
|
||||
try {
|
||||
const stat = await fs.stat(profileHome);
|
||||
if (!stat.isDirectory()) {
|
||||
return {
|
||||
level: "error",
|
||||
message: `Profile path is not a directory: ${profileHome}`,
|
||||
hint: "Create the directory or override hermesProfileHome in adapter config.",
|
||||
code: "deepseek_profile_not_dir",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
level: "error",
|
||||
message: `Hermes profile dir does not exist: ${profileHome}`,
|
||||
hint: "Create the profile dir with config.yaml + .env (DEEPSEEK_API_KEY).",
|
||||
code: "deepseek_profile_missing",
|
||||
};
|
||||
}
|
||||
|
||||
const configPath = path.join(profileHome, "config.yaml");
|
||||
try {
|
||||
await fs.stat(configPath);
|
||||
} catch {
|
||||
return {
|
||||
level: "error",
|
||||
message: `Profile is missing config.yaml: ${configPath}`,
|
||||
hint: "Add config.yaml with model.default + model.base_url + model.key_env.",
|
||||
code: "deepseek_profile_no_config",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
level: "info",
|
||||
message: `Profile resolved: ${profileHome}`,
|
||||
code: "deepseek_profile_ok",
|
||||
};
|
||||
}
|
||||
|
||||
async function checkApiKey(profileHome, configEnv) {
|
||||
// 1. config.env (resolved by Paperclip from secrets)
|
||||
if (configEnv && typeof configEnv === "object" && asString(configEnv.DEEPSEEK_API_KEY)) {
|
||||
return {
|
||||
level: "info",
|
||||
message: "DEEPSEEK_API_KEY found in adapter env config",
|
||||
code: "deepseek_api_key_in_config",
|
||||
};
|
||||
}
|
||||
// 2. Profile-local .env
|
||||
try {
|
||||
const envFile = path.join(profileHome, ".env");
|
||||
const text = await fs.readFile(envFile, "utf-8");
|
||||
if (/^\s*DEEPSEEK_API_KEY=/m.test(text)) {
|
||||
return {
|
||||
level: "info",
|
||||
message: `DEEPSEEK_API_KEY found in ${envFile}`,
|
||||
code: "deepseek_api_key_in_profile",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// 3. Process env
|
||||
if (process.env.DEEPSEEK_API_KEY) {
|
||||
return {
|
||||
level: "info",
|
||||
message: "DEEPSEEK_API_KEY found in Paperclip process env",
|
||||
code: "deepseek_api_key_in_process",
|
||||
};
|
||||
}
|
||||
return {
|
||||
level: "error",
|
||||
message: "DEEPSEEK_API_KEY not found in adapter env, profile .env, or process env",
|
||||
hint: "Add DEEPSEEK_API_KEY to <HERMES_HOME>/.env or to the agent's env secrets.",
|
||||
code: "deepseek_api_key_missing",
|
||||
};
|
||||
}
|
||||
|
||||
export async function testEnvironment(ctx) {
|
||||
const config = ctx.config ?? {};
|
||||
const command = asString(config.hermesCommand) || HERMES_CLI;
|
||||
const profileHome = asString(config.hermesProfileHome) || DEFAULT_PROFILE_HOME;
|
||||
const checks = [];
|
||||
|
||||
const cliCheck = await checkCliInstalled(command);
|
||||
if (cliCheck) {
|
||||
checks.push(cliCheck);
|
||||
if (cliCheck.level === "error") {
|
||||
return {
|
||||
adapterType: ADAPTER_TYPE,
|
||||
status: "fail",
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const profileCheck = await checkProfile(profileHome);
|
||||
checks.push(profileCheck);
|
||||
if (profileCheck.level === "error") {
|
||||
return {
|
||||
adapterType: ADAPTER_TYPE,
|
||||
status: "fail",
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const apiKeyCheck = await checkApiKey(profileHome, config.env);
|
||||
checks.push(apiKeyCheck);
|
||||
|
||||
const model = asString(config.model);
|
||||
checks.push({
|
||||
level: "info",
|
||||
message: model ? `Model: ${model}` : "Using profile default model",
|
||||
code: "deepseek_model",
|
||||
});
|
||||
|
||||
const hasErrors = checks.some((c) => c.level === "error");
|
||||
const hasWarnings = checks.some((c) => c.level === "warn");
|
||||
return {
|
||||
adapterType: ADAPTER_TYPE,
|
||||
status: hasErrors ? "fail" : hasWarnings ? "warn" : "pass",
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
36
adapters/deepseek-paperclip-adapter/dist/shared/constants.js
vendored
Normal file
36
adapters/deepseek-paperclip-adapter/dist/shared/constants.js
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared constants for the DeepSeek (via Hermes) Paperclip adapter.
|
||||
*/
|
||||
|
||||
export const ADAPTER_TYPE = "deepseek_local";
|
||||
export const ADAPTER_LABEL = "DeepSeek (via Hermes)";
|
||||
|
||||
/** Default Hermes CLI binary name. */
|
||||
export const HERMES_CLI = "hermes";
|
||||
|
||||
/** Default profile directory used as HERMES_HOME if the agent does not override it. */
|
||||
export const DEFAULT_PROFILE_HOME = "/home/chaim/.hermes/profiles/deepseek";
|
||||
|
||||
/** Default model — V4-Pro is the strongest DeepSeek model currently exposed. */
|
||||
export const DEFAULT_MODEL = "deepseek-v4-pro";
|
||||
|
||||
/** DeepSeek profiles in this stack use Hermes' "custom" provider (user-defined in profile config.yaml). */
|
||||
export const DEFAULT_PROVIDER = "custom";
|
||||
|
||||
/** Default timeout (seconds) for one CLI invocation. */
|
||||
export const DEFAULT_TIMEOUT_SEC = 1800;
|
||||
|
||||
/** Grace period (seconds) after SIGTERM before SIGKILL. */
|
||||
export const DEFAULT_GRACE_SEC = 30;
|
||||
|
||||
/** Models that DeepSeek's API currently exposes (verified via /v1/models). */
|
||||
export const DEEPSEEK_MODELS = [
|
||||
{ id: "deepseek-v4-pro", label: "DeepSeek V4 Pro" },
|
||||
{ id: "deepseek-v4-flash", label: "DeepSeek V4 Flash" },
|
||||
];
|
||||
|
||||
/** Regex for extracting session_id from quiet-mode Hermes output. */
|
||||
export const SESSION_ID_REGEX = /^session_id:\s*(\S+)/m;
|
||||
export const SESSION_ID_REGEX_LEGACY = /session[_ ](?:id|saved)[:\s]+([a-zA-Z0-9_-]+)/i;
|
||||
export const TOKEN_USAGE_REGEX = /tokens?[:\s]+(\d+)\s*(?:input|in)\b.*?(\d+)\s*(?:output|out)\b/i;
|
||||
export const COST_REGEX = /(?:cost|spent)[:\s]*\$?([\d.]+)/i;
|
||||
25
adapters/deepseek-paperclip-adapter/package-lock.json
generated
Normal file
25
adapters/deepseek-paperclip-adapter/package-lock.json
generated
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "deepseek-paperclip-adapter",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "deepseek-paperclip-adapter",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "^2026.325.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@paperclipai/adapter-utils": {
|
||||
"version": "2026.428.0",
|
||||
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.428.0.tgz",
|
||||
"integrity": "sha512-kGHpE7rhePPCbnG3OwXbNuHZZuI+XyuFgNSiDnrEeiSbkI2c5XHM2WnWDCZ/NGHULfJW3lWhSxGMFoYqiy38vQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
adapters/deepseek-paperclip-adapter/package.json
Normal file
21
adapters/deepseek-paperclip-adapter/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "deepseek-paperclip-adapter",
|
||||
"version": "0.1.0",
|
||||
"description": "Paperclip adapter for DeepSeek (V4-Pro / V4-Flash) — runs Hermes Agent locally pinned to a DeepSeek profile",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "^2026.325.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
21
docs/changelog-2026-05-hooks-jobs.md
Normal file
21
docs/changelog-2026-05-hooks-jobs.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# שינויים — legal-ai backend (2026-05-17)
|
||||
|
||||
## הוספת webhook emitter לסטטוס תיק
|
||||
|
||||
### `web/paperclip_api.py`
|
||||
- נוספה `emit_case_status_webhook()` — fire-and-forget helper שמדווח ל-Paperclip plugin על שינוי סטטוס
|
||||
- שימוש ב-`datetime.now(timezone.utc)` במקום `datetime.utcnow()` המיושן (תואם Python 3.12+)
|
||||
|
||||
### `web/app.py`
|
||||
- `PUT /api/cases/{case_number}` — שולח webhook ב-BackgroundTask כשהסטטוס משתנה
|
||||
- שומר `old_status` לפני העדכון → משווה עם `new_status` → מפעיל webhook רק אם שונה
|
||||
- `GET /api/cases/stale?days=3` — מחזיר תיקים שלא עודכנו N+ ימים (לשימוש `stale-case-reminder` job)
|
||||
- `GET /api/chair-feedback/weekly-summary?days=7` — מסכם פידבק יו"ר לשבוע אחרון (לשימוש `weekly-feedback-analysis` job)
|
||||
|
||||
## שינויים ב-sync script
|
||||
|
||||
### `scripts/sync_agents_across_companies.py`
|
||||
- `--check-instructions`: מדפיס טבלה עם סטטוס הוראות לכל 14 הסוכנים (✅ מעודכן / DRIFT / ⚠ NOT SET)
|
||||
- pre-flight validation לפני `--apply`: אם קובץ הוראות חסר → מבטל בעדינות
|
||||
- מעקב `claude_md_mtime` + `claude_md_last_synced` ב-metadata של הסוכן
|
||||
- alias: `check-agents` ב-`.bashrc`
|
||||
@@ -400,6 +400,54 @@
|
||||
- **~30 תקדמים חיצוניים** ש**דפנה מצטטת באופן עקבי** (ראה precedent-network.md)
|
||||
- **~15 תקדמים אישיים** שלה עצמה — מהווים את הקאנון האישי שלה
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 6.11 לקחים מערר 1200-25 (קרית ענבים, מאי 2026)
|
||||
|
||||
השוואה בין טיוטת הכותב לעריכת דפנה חשפה 7 דפוסי סגנון שלא היו מתועדים:
|
||||
|
||||
### א. סדר בלוקים — תכניות לפני טענות (1xxx)
|
||||
בתיקי רישוי, דפנה מעדיפה שבלוק ט (תכניות חלות) יופיע **לפני** בלוק ז (טענות). הרציונל: הקורא צריך להכיר את המסגרת הנורמטיבית לפני שהוא קורא את טענות הצדדים.
|
||||
|
||||
**סדר נכון ל-1xxx:** ה → ו → **ט** → ו.ב (רקע מורחב) → ז → ח → י → יא → יב
|
||||
|
||||
### ב. תבנית "להלן מתוך" — חובה
|
||||
כל התייחסות למסמך מקור מלווה ב-"להלן מתוך [שם המסמך]:" כ-placeholder לציטוט/צילום. **12 מופעים** בעריכה, **0** בטיוטה. זהו דפוס סגנוני מרכזי שחייב להיות אוטומטי.
|
||||
|
||||
דוגמאות:
|
||||
- "להלן מתוך הוראות התכנית:"
|
||||
- "להלן מתוך פרוטוקול הדיון בוועדה המקומית:"
|
||||
- "להלן מתוך הבקשה להיתר:"
|
||||
- "להלן מתוך מטרת התכנית:"
|
||||
- "להלן מתוך תשריט מצב מוצע:"
|
||||
|
||||
### ג. רקע עובדתי מורחב — ציר זמן מלא
|
||||
בלוק ו חייב לספר את "הסיפור" של התיק: הגשת בקשה → פרסום → מספר התנגדויות → ישיבות ועדה מקומית (תאריך + תוצאה לכל אחת) → החלטה סופית → הגשת ערר. הטיוטה נתנה שורה אחת (90 מילים); דפנה הרחיבה ל-3 ישיבות מפורטות (~420 מילים).
|
||||
|
||||
### ד. ניתוח "גשר תכנוני"
|
||||
כשמבקש שימוש חורג גם מקדם תכנית — דפנה מנתחת: האם השימוש המבוקש **תואם** את התכנון העתידי (→ גשר לגיטימי, כמו בכוכבה תורן)? או **סותר** (→ סטייה כפולה)? מסגרת ניתוח שלמה (249 מילים) שלא הייתה בטיוטה.
|
||||
|
||||
### ה. עיגון כמותי
|
||||
דפנה מוסיפה נתונים מספריים ספציפיים: "4,404.98 מ"ר לכלל היישוב vs 1,425 מ"ר מבוקש — 32%". המספרים מעגנים את ההחלטה במציאות ומקשים על ערעור.
|
||||
|
||||
### ו. כותרות שטוחות (Heading 2 בלבד)
|
||||
דפנה השתמשה ב-Heading 2 לכל הסעיפים, כולל תת-נושאים בדיון. **אין Heading 3**. כל סעיף עומד בפני עצמו.
|
||||
|
||||
### ז. הבחנת תקדימים inline
|
||||
במקום סעיף נפרד "הבחנה מתקדימי העוררת" — ההבחנות מנוסחות inline: "באשר ל-[שם פסק דין]" → מה ההבדל → סיכום. דוגמה: "באשר לבג"ץ 6525/15 עמק שווה... אולם ההבדל מהותי".
|
||||
|
||||
### ביטויי מעבר חדשים (מעריכה 1200-25)
|
||||
| ביטוי | הקשר |
|
||||
|-------|-------|
|
||||
| "עינינו הרואות" | ממצא מתוך מסמך |
|
||||
| "הנה כי כן" | לפיכך (פורמלי) |
|
||||
| "נשוב כאן ונבחין" | חזרה להבחנת תקדים |
|
||||
| "נוסיף ונבהיר" | הוספת הבהרה |
|
||||
| "מסקנת הדברים" | סיכום סעיף |
|
||||
| "משכבר קבענו" | הפניה לקביעה קודמת |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 7. מה עדיין לא ראינו
|
||||
|
||||
@@ -385,3 +385,64 @@ The draft's biggest structural error was adding the "נבאר" doctrinal paragra
|
||||
- [ ] Update voice-fingerprint: add new transition phrases
|
||||
- [ ] Update architecture-by-outcome: add "clean acceptance" archetype
|
||||
- [ ] Fix agent opening punctuation: "ונפרט;" not "נפרט."
|
||||
|
||||
---
|
||||
|
||||
## Lessons from ערר 1200-25 (קרית ענבים — שימוש חורג, דחייה)
|
||||
|
||||
### Source
|
||||
- Our draft: `data/cases/1200-25/exports/טיוטה-v1.docx` (3,181 words)
|
||||
- Daphna's edit: `data/cases/1200-25/exports/עריכה-v1.docx` (4,313 words, +35%)
|
||||
- Date: May 2026
|
||||
|
||||
### What the Edit Changed
|
||||
|
||||
#### 1. Block Order — Plans Before Claims
|
||||
- **Draft:** ה→ו→ז→ח→ט→י→יא→יב (plans after procedures)
|
||||
- **Edit:** ה→ו→**ט**→ו.ב→ז→ח→י→יא→יב (plans BEFORE claims)
|
||||
- **Lesson:** In licensing cases (1xxx), the reader must understand the normative framework (plans) before reading the parties' arguments about those plans. Block ט should precede Block ז. The new order: opening → brief background → **applicable plans** → expanded background (application + committee proceedings) → claims → procedures → discussion.
|
||||
|
||||
#### 2. "להלן מתוך" Document Insertion Pattern
|
||||
- **Draft:** 0 occurrences
|
||||
- **Edit:** 12 occurrences of "להלן מתוך [document name]:"
|
||||
- **Lesson:** Every reference to a source document must be accompanied by "להלן מתוך [שם המסמך]:" as a placeholder for a direct quote/image. This is a MANDATORY pattern, not optional. Examples: "להלן מתוך הוראות התכנית:", "להלן מתוך פרוטוקול הדיון:", "להלן מתוך הבקשה להיתר:"
|
||||
|
||||
#### 3. Expanded Factual Background (Block ו)
|
||||
- **Draft:** ~90 words (3%), one paragraph
|
||||
- **Edit:** ~420 words (10%), covering: (a) the application details, (b) 3 committee meetings with dates and outcomes, (c) the final decision
|
||||
- **Lesson:** Block ו must tell the full "story" of the case: when the application was filed → when it was published → how many objections → when committee meetings were held → what was decided at each meeting → when the appeal was filed. Each meeting should have date + outcome.
|
||||
|
||||
#### 4. Bridge Planning Analysis ("גשר תכנוני")
|
||||
- **Draft:** Not present
|
||||
- **Edit:** 249 words — new analytical framework
|
||||
- **Lesson:** When an applicant for deviation/variance is also promoting a plan for the same land, the decision must analyze: (a) is the pending plan harmonious with the requested use? If yes → the deviation can serve as a "bridge" until the plan is approved (cite כוכבה תורן). If no → the contradiction STRENGTHENS the rejection. The writer must check `search_case_documents` for pending plans and compare them with the requested use.
|
||||
|
||||
#### 5. Competing Plans Analysis
|
||||
- **Draft:** Not present (1,033 words added)
|
||||
- **Edit:** Detailed comparison of the site-specific plan (151-1382787) vs the comprehensive plan (151-1337534)
|
||||
- **Lesson:** When there's a site-specific plan AND a comprehensive plan, the decision must: (a) describe each plan's scope, (b) compare the permitted uses, (c) show quantitative contradictions (e.g., "the comprehensive plan allocates 4,404 m² for ALL commerce in the settlement, while the request alone is for 1,425 m² — 32%"), (d) conclude whether there's harmony or contradiction. This is often the STRONGEST argument in the decision.
|
||||
|
||||
#### 6. Heading Level — Flat Structure
|
||||
- **Draft:** Mixed Heading 2 + Heading 3 (nested subsections)
|
||||
- **Edit:** All Heading 2 (flat structure)
|
||||
- **Lesson:** Each section stands independently. No nesting. In the discussion, each analytical step is a separate Heading 2 section.
|
||||
|
||||
#### 7. Inline Precedent Distinguishing
|
||||
- **Draft:** Separate section "הבחנה מתקדימי העוררת" (Heading 3)
|
||||
- **Edit:** Each precedent distinguished inline with "באשר ל-[case name]" → what's different → conclusion
|
||||
- **Lesson:** Don't create a separate "distinguishing" section. Address each precedent where it naturally comes up in the discussion, using "באשר ל..." as the opener.
|
||||
|
||||
### New Transition Phrases Identified
|
||||
- **"עינינו הרואות"** — introducing a document-based finding ("our eyes see that...")
|
||||
- **"הנה כי כן"** — therefore/accordingly (more formal than "לפיכך")
|
||||
- **"נשוב כאן ונבחין"** — returning to distinguish a case
|
||||
- **"נוסיף ונבהיר"** — adding clarification
|
||||
- **"מסקנת הדברים"** — concluding a subsection
|
||||
- **"משכבר קבענו"** — since we already established
|
||||
|
||||
### Applied To
|
||||
- [x] Update legal-decision-lessons.md with lessons 1-7
|
||||
- [x] Update daphna-voice-fingerprint.md with structural and style findings
|
||||
- [ ] Update block-schema.md: block order for 1xxx cases (ט before ז)
|
||||
- [ ] Update daphna-architecture-by-outcome.md: add "bridge planning" analysis for rejections
|
||||
- [ ] Update writer system prompt: mandatory "להלן מתוך" pattern
|
||||
|
||||
@@ -216,6 +216,22 @@ async def precedent_library_delete(case_law_id: str) -> str:
|
||||
return await plib.precedent_library_delete(case_law_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_link_cases(
|
||||
case_law_id_a: str,
|
||||
case_law_id_b: str,
|
||||
relation_type: str = "same_case_chain",
|
||||
) -> str:
|
||||
"""קישור שתי פסיקות כקשורות (דו-כיווני, idempotent). relation_type: same_case_chain | overruled_by | distinguished."""
|
||||
return await plib.precedent_link_cases(case_law_id_a, case_law_id_b, relation_type)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
|
||||
"""הסרת קישור בין שתי פסיקות (דו-כיווני)."""
|
||||
return await plib.precedent_unlink_cases(case_law_id_a, case_law_id_b)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_extract_halachot(case_law_id: str) -> str:
|
||||
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
|
||||
|
||||
@@ -360,13 +360,9 @@ async def write_block(
|
||||
post_hearing_context=post_hearing_context,
|
||||
)
|
||||
|
||||
# Restructure: sources first, then instructions
|
||||
prompt = (
|
||||
f"## חומרי מקור (מסמכים מלאים — צטט מהם מילה במילה כשאפשר):\n\n"
|
||||
f"{source_context}\n\n"
|
||||
f"---\n\n"
|
||||
f"{formatted_prompt}"
|
||||
)
|
||||
# source_context is already embedded inside formatted_prompt via {source_context} in the
|
||||
# template. Do NOT prepend it again — doing so doubles the prompt size (was 465K chars).
|
||||
prompt = formatted_prompt
|
||||
|
||||
if instructions:
|
||||
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||
@@ -377,6 +373,19 @@ async def write_block(
|
||||
if not dir_doc.get("approved"):
|
||||
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
||||
|
||||
# Guard against context overflow before calling claude -p.
|
||||
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
|
||||
# In practice the CLI has crashed on prompts above ~400K chars, so use
|
||||
# that as a conservative ceiling (well below the token limit).
|
||||
_MAX_PROMPT_CHARS = 400_000
|
||||
if len(prompt) > _MAX_PROMPT_CHARS:
|
||||
raise RuntimeError(
|
||||
f"Prompt too large for {block_id}: {len(prompt):,} chars "
|
||||
f"(limit {_MAX_PROMPT_CHARS:,}). "
|
||||
f"source_context: {len(source_context):,} chars. "
|
||||
f"Reduce documents or call extract_appraiser_facts first."
|
||||
)
|
||||
|
||||
# Call Claude via Claude Code session (no API)
|
||||
model_key = block_cfg["model"]
|
||||
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
||||
@@ -414,16 +423,35 @@ def _build_case_context(case: dict, decision: dict | None) -> str:
|
||||
- תוצאה: {outcome_heb}"""
|
||||
|
||||
|
||||
# Which doc_types are relevant per block.
|
||||
# None → skip source docs entirely (block uses other context, e.g. claims_context)
|
||||
# [] → include all doc types (default for unspecified blocks)
|
||||
# [..] → include only the listed doc_type values
|
||||
_BLOCK_DOC_TYPES: dict[str, list[str] | None] = {
|
||||
"block-he": None, # only case_context needed; no full docs
|
||||
"block-vav": ["appeal", "protocol"], # כתב ערר + פרוטוקול ועדה
|
||||
"block-zayin": None, # claims_context is sufficient
|
||||
"block-chet": ["protocol"], # פרוטוקול + השלמות טיעון
|
||||
"block-tet": ["appraisal"], # שומות בלבד
|
||||
# block-yod, block-yod-alef, block-he etc. default → all docs
|
||||
}
|
||||
|
||||
|
||||
async def _build_source_context(case_id: UUID, block_id: str) -> str:
|
||||
"""Get full document texts for the block.
|
||||
"""Get document texts for the block, filtered by relevance.
|
||||
|
||||
Per Anthropic best practices: send full source documents, not truncated excerpts.
|
||||
Place documents at the TOP of the prompt (before instructions) for 30% better recall.
|
||||
For grounding: instruct Claude to cite word-for-word from these documents.
|
||||
Per-block filtering prevents context overflow on large cases (9+ docs).
|
||||
"""
|
||||
allowed = _BLOCK_DOC_TYPES.get(block_id, []) # [] sentinel = not in map → all docs
|
||||
if allowed is None:
|
||||
return "" # this block doesn't need raw source docs
|
||||
|
||||
docs = await db.list_documents(case_id)
|
||||
context_parts = []
|
||||
for doc in docs:
|
||||
if allowed and doc["doc_type"] not in allowed:
|
||||
continue
|
||||
text = await db.get_document_text(UUID(doc["id"]))
|
||||
if text:
|
||||
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")
|
||||
|
||||
@@ -72,6 +72,9 @@ async def query(
|
||||
"""
|
||||
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
||||
|
||||
if len(full_prompt) > 150_000:
|
||||
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
|
||||
|
||||
cmd = [
|
||||
"claude", "-p",
|
||||
"--output-format", "json",
|
||||
@@ -110,7 +113,8 @@ async def query(
|
||||
|
||||
if proc.returncode != 0:
|
||||
stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error"
|
||||
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}")
|
||||
size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else ""
|
||||
raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}{size_info}")
|
||||
|
||||
stdout = stdout_b.decode("utf-8", errors="replace").strip()
|
||||
if not stdout:
|
||||
|
||||
@@ -700,6 +700,20 @@ CREATE INDEX IF NOT EXISTS idx_case_law_chair ON case_law(chair_name) WHERE chai
|
||||
CREATE INDEX IF NOT EXISTS idx_case_law_district ON case_law(district) WHERE district <> '';
|
||||
"""
|
||||
|
||||
SCHEMA_V11_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS case_law_relations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
case_law_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
|
||||
related_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
|
||||
relation_type TEXT NOT NULL DEFAULT 'same_case_chain',
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(case_law_id, related_id),
|
||||
CHECK (case_law_id <> related_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_clr_a ON case_law_relations(case_law_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_clr_b ON case_law_relations(related_id);
|
||||
"""
|
||||
|
||||
|
||||
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||
async with pool.acquire() as conn:
|
||||
@@ -714,7 +728,8 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||
await conn.execute(SCHEMA_V8_SQL)
|
||||
await conn.execute(SCHEMA_V9_SQL)
|
||||
await conn.execute(SCHEMA_V10_SQL)
|
||||
logger.info("Database schema initialized (v1-v10)")
|
||||
await conn.execute(SCHEMA_V11_SQL)
|
||||
logger.info("Database schema initialized (v1-v11)")
|
||||
|
||||
|
||||
async def init_schema() -> None:
|
||||
@@ -1735,6 +1750,59 @@ async def get_case_law(case_law_id: UUID) -> dict | None:
|
||||
return _row_to_case_law(row) if row else None
|
||||
|
||||
|
||||
async def add_case_law_relation(
|
||||
a_id: UUID, b_id: UUID, relation_type: str = "same_case_chain"
|
||||
) -> None:
|
||||
"""Link two case_law records bidirectionally. Idempotent (ON CONFLICT DO NOTHING)."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.executemany(
|
||||
"""
|
||||
INSERT INTO case_law_relations(case_law_id, related_id, relation_type)
|
||||
VALUES($1, $2, $3)
|
||||
ON CONFLICT (case_law_id, related_id) DO NOTHING
|
||||
""",
|
||||
[(a_id, b_id, relation_type), (b_id, a_id, relation_type)],
|
||||
)
|
||||
|
||||
|
||||
async def remove_case_law_relation(a_id: UUID, b_id: UUID) -> None:
|
||||
"""Remove a bidirectional link between two case_law records."""
|
||||
pool = await get_pool()
|
||||
await pool.execute(
|
||||
"""
|
||||
DELETE FROM case_law_relations
|
||||
WHERE (case_law_id = $1 AND related_id = $2)
|
||||
OR (case_law_id = $2 AND related_id = $1)
|
||||
""",
|
||||
a_id,
|
||||
b_id,
|
||||
)
|
||||
|
||||
|
||||
async def get_case_law_relations(case_law_id: UUID) -> list[dict]:
|
||||
"""Return all case_law records linked to case_law_id, ordered by date asc."""
|
||||
pool = await get_pool()
|
||||
rows = await pool.fetch(
|
||||
"""
|
||||
SELECT cl.*, r.relation_type
|
||||
FROM case_law_relations r
|
||||
JOIN case_law cl ON cl.id = r.related_id
|
||||
WHERE r.case_law_id = $1
|
||||
ORDER BY cl.date ASC NULLS LAST
|
||||
""",
|
||||
case_law_id,
|
||||
)
|
||||
results = []
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
relation_type = d.pop("relation_type")
|
||||
normalized = _row_to_case_law(d)
|
||||
normalized["relation_type"] = relation_type
|
||||
results.append(normalized)
|
||||
return results
|
||||
|
||||
|
||||
async def get_case_law_by_citation(case_number: str) -> dict | None:
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
@@ -1984,9 +2052,19 @@ async def list_external_case_law(
|
||||
offset: int = 0,
|
||||
source_kind: str = "external_upload",
|
||||
) -> list[dict]:
|
||||
"""List chair-uploaded precedents, with simple filters."""
|
||||
"""List chair-uploaded precedents, with simple filters.
|
||||
|
||||
source_kind="all_committees" expands to: source_kind='internal_committee'
|
||||
OR (source_kind='external_upload' AND source_type='appeals_committee').
|
||||
"""
|
||||
pool = await get_pool()
|
||||
conditions = [f"source_kind = '{source_kind}'"]
|
||||
if source_kind == "all_committees":
|
||||
conditions = [
|
||||
"(source_kind = 'internal_committee' OR "
|
||||
"(source_kind = 'external_upload' AND source_type = 'appeals_committee'))"
|
||||
]
|
||||
else:
|
||||
conditions = [f"source_kind = '{source_kind}'"]
|
||||
params: list = []
|
||||
idx = 1
|
||||
if practice_area:
|
||||
@@ -2420,19 +2498,17 @@ async def precedent_library_stats() -> dict:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
total = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM case_law WHERE source_kind = 'external_upload'"
|
||||
"SELECT COUNT(*) FROM case_law"
|
||||
)
|
||||
by_practice = await conn.fetch(
|
||||
"""SELECT practice_area, COUNT(*) AS n
|
||||
FROM case_law
|
||||
WHERE source_kind = 'external_upload'
|
||||
GROUP BY practice_area
|
||||
ORDER BY n DESC"""
|
||||
)
|
||||
by_level = await conn.fetch(
|
||||
"""SELECT precedent_level, COUNT(*) AS n
|
||||
FROM case_law
|
||||
WHERE source_kind = 'external_upload'
|
||||
GROUP BY precedent_level
|
||||
ORDER BY n DESC"""
|
||||
)
|
||||
|
||||
@@ -123,7 +123,7 @@ SUMMARY_STRATEGIES = {
|
||||
|
||||
DISCUSSION_RULES: dict[str, list[str]] = {
|
||||
"universal": [
|
||||
"פרק הדיון = אסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
||||
"פרק הדיון = מאסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
|
||||
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
|
||||
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
|
||||
],
|
||||
|
||||
@@ -438,13 +438,14 @@ async def delete_precedent(case_law_id: UUID | str) -> bool:
|
||||
|
||||
|
||||
async def get_precedent(case_law_id: UUID | str) -> dict | None:
|
||||
"""Get a precedent with its halachot attached."""
|
||||
"""Get a precedent with its halachot and related cases attached."""
|
||||
if isinstance(case_law_id, str):
|
||||
case_law_id = UUID(case_law_id)
|
||||
record = await db.get_case_law(case_law_id)
|
||||
if not record:
|
||||
return None
|
||||
record["halachot"] = await db.list_halachot(case_law_id=case_law_id, limit=500)
|
||||
record["related_cases"] = await db.get_case_law_relations(case_law_id)
|
||||
return record
|
||||
|
||||
|
||||
|
||||
@@ -116,6 +116,54 @@ async def precedent_library_get(case_law_id: str) -> str:
|
||||
return _ok(record)
|
||||
|
||||
|
||||
async def precedent_link_cases(
|
||||
case_law_id_a: str,
|
||||
case_law_id_b: str,
|
||||
relation_type: str = "same_case_chain",
|
||||
) -> str:
|
||||
"""קישור שתי פסיקות כקשורות זו לזו (דו-כיווני). idempotent.
|
||||
|
||||
Args:
|
||||
case_law_id_a: UUID של פסיקה ראשונה.
|
||||
case_law_id_b: UUID של פסיקה שנייה.
|
||||
relation_type: same_case_chain | overruled_by | distinguished
|
||||
"""
|
||||
try:
|
||||
a = UUID(case_law_id_a)
|
||||
b = UUID(case_law_id_b)
|
||||
except ValueError:
|
||||
return _err("case_law_id לא תקין")
|
||||
rec_a = await db.get_case_law(a)
|
||||
rec_b = await db.get_case_law(b)
|
||||
if not rec_a:
|
||||
return _err(f"פסיקה {case_law_id_a} לא נמצאה")
|
||||
if not rec_b:
|
||||
return _err(f"פסיקה {case_law_id_b} לא נמצאה")
|
||||
await db.add_case_law_relation(a, b, relation_type)
|
||||
return _ok({
|
||||
"linked": True,
|
||||
"relation_type": relation_type,
|
||||
"a": {"id": case_law_id_a, "case_number": rec_a.get("case_number"), "court": rec_a.get("court")},
|
||||
"b": {"id": case_law_id_b, "case_number": rec_b.get("case_number"), "court": rec_b.get("court")},
|
||||
})
|
||||
|
||||
|
||||
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
|
||||
"""הסרת קישור בין שתי פסיקות (דו-כיווני).
|
||||
|
||||
Args:
|
||||
case_law_id_a: UUID של פסיקה ראשונה.
|
||||
case_law_id_b: UUID של פסיקה שנייה.
|
||||
"""
|
||||
try:
|
||||
a = UUID(case_law_id_a)
|
||||
b = UUID(case_law_id_b)
|
||||
except ValueError:
|
||||
return _err("case_law_id לא תקין")
|
||||
await db.remove_case_law_relation(a, b)
|
||||
return _ok({"unlinked": True, "a": case_law_id_a, "b": case_law_id_b})
|
||||
|
||||
|
||||
async def precedent_library_delete(case_law_id: str) -> str:
|
||||
"""מחיקת פסיקה מהקורפוס. cascade: chunks + halachot."""
|
||||
try:
|
||||
|
||||
87
scripts/.archive/run_curator_deepseek_test.sh
Executable file
87
scripts/.archive/run_curator_deepseek_test.sh
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-off A/B test runner: runs the Knowledge Curator (Hermes) on CMP-78 using
|
||||
# DeepSeek V4-Pro instead of the default Sonnet 4.5 (via marcus/sonnet gateway).
|
||||
# Compare against CMP-80 which runs with the default config.
|
||||
set -euo pipefail
|
||||
|
||||
PROFILE_HOME="/home/chaim/.hermes/profiles/curator-cmp-deepseek"
|
||||
PAPERCLIP_API_URL="http://localhost:3100/api"
|
||||
# CMP curator agent's Paperclip key (from Infisical: nautilus /legal-ai HERMES_CURATOR_CMP_PAPERCLIP_KEY)
|
||||
PAPERCLIP_API_KEY="pcp_c87edcf306d06fce13fac701bb6d747191d61dba5b51e903"
|
||||
PAPERCLIP_TASK_ID="beb745e5-7195-40c5-9ac0-e9682c2c5184" # CMP-78
|
||||
PAPERCLIP_TASK_KEY="$PAPERCLIP_TASK_ID"
|
||||
PAPERCLIP_TASK_TITLE="[ערר 1130-25] סקירת ידע — Knowledge Curator (DeepSeek A/B test)"
|
||||
PAPERCLIP_RUN_ID="deepseek-ab-$(date +%s)"
|
||||
PAPERCLIP_WAKE_REASON="manual_deepseek_ab_test"
|
||||
|
||||
# Rendered prompt — copy of the curator template with mustache variables resolved
|
||||
# manually for CMP-78. We also add a clear "[ניסוי DeepSeek V4-Pro]" prefix so
|
||||
# the resulting comment is distinguishable from the default-Sonnet run on CMP-80.
|
||||
read -r -d '' PROMPT <<'EOF' || true
|
||||
אתה מנהל ידע (Knowledge Curator) של ועדת הערר. נעור על תיק שדפנה סימנה כסופי.
|
||||
|
||||
תיק: [ערר 1130-25] סקירת ידע — Knowledge Curator
|
||||
issue ID: beb745e5-7195-40c5-9ac0-e9682c2c5184
|
||||
run reason: manual_deepseek_ab_test
|
||||
|
||||
**הקשר חשוב — ניסוי A/B:** זוהי ריצה ידנית באמצעות DeepSeek V4-Pro במקום ה-Sonnet הרגיל. כל ה-comment שתפרסם חייב להתחיל בכותרת `[ניסוי DeepSeek V4-Pro]` כדי שנוכל להבדיל מהריצה המקבילה ב-CMP-80 (שרצה עם Sonnet). אל תעיר סוכנים אחרים. אל תיצור issues חדשים. אל תפתח interaction.
|
||||
|
||||
הוראות:
|
||||
דפנה סימנה את ההחלטה הסופית של תיק 1130-25 כסופית.
|
||||
קובץ סופי: `סופי-1130-25.docx`
|
||||
|
||||
סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.
|
||||
חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, ממוספר.
|
||||
|
||||
# שלבי ביצוע
|
||||
|
||||
## 1. קונטקסט
|
||||
- קרא את MEMORY.md שלך (memory tool) — מה כבר זיהית.
|
||||
- קרא `/home/chaim/legal-ai/skills/decision/SKILL.md` (file tool) — מה כבר תועד.
|
||||
|
||||
## 2. נתונים
|
||||
- `mcp__legal-ai__case_get` עם case_number `1130-25` — מטא-דאטה.
|
||||
- `mcp__legal-ai__case_get_final_text` עם case_number `1130-25` — קרא את הטקסט המלא של ההחלטה הסופית.
|
||||
- אם רלוונטי: `mcp__legal-ai__search_decisions` להשוואה לחלטות קודמות.
|
||||
|
||||
## 3. ניתוח
|
||||
חפש 3-5 דפוסים/פערים. לכל ממצא: מה ראיתי + מה זה אומר + הצעה ניסוחית מדויקת.
|
||||
|
||||
## 4. כתוב comment הממצאים
|
||||
```bash
|
||||
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg b "$BODY" '{body:$b}')" \
|
||||
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/comments"
|
||||
```
|
||||
|
||||
פורמט ה-body:
|
||||
- שורה ראשונה: `[ניסוי DeepSeek V4-Pro]`
|
||||
- אחר כך פסקה אחת מבוא קצרה
|
||||
- אחר כך הממצאים ממוספרים
|
||||
|
||||
## 5. סגור את ה-issue
|
||||
```bash
|
||||
curl -sS -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||
-d '{"status":"done"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
|
||||
```
|
||||
|
||||
# כללים
|
||||
- אל תעדכן קבצים (skills/, lessons.py, DB) בעצמך. רק comment.
|
||||
- אל תיצור issues חדשים.
|
||||
- אל תעיר סוכנים אחרים.
|
||||
- אל תפתח interaction.
|
||||
- בעיה? comment קצר עם הסיבה + סגור (status=done).
|
||||
EOF
|
||||
|
||||
export HERMES_HOME="$PROFILE_HOME"
|
||||
export PAPERCLIP_API_URL PAPERCLIP_API_KEY PAPERCLIP_TASK_ID PAPERCLIP_TASK_KEY \
|
||||
PAPERCLIP_TASK_TITLE PAPERCLIP_RUN_ID PAPERCLIP_WAKE_REASON
|
||||
|
||||
echo "=== DeepSeek V4-Pro Curator A/B test on CMP-78 ==="
|
||||
echo "HERMES_HOME=$HERMES_HOME"
|
||||
echo "TASK_ID=$PAPERCLIP_TASK_ID"
|
||||
echo "RUN_ID=$PAPERCLIP_RUN_ID"
|
||||
echo "Starting Hermes..."
|
||||
echo "---"
|
||||
|
||||
hermes -z "$PROMPT" --yolo chat 2>&1
|
||||
116
scripts/.archive/run_curator_deepseek_test_v2.sh
Executable file
116
scripts/.archive/run_curator_deepseek_test_v2.sh
Executable file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# A/B test runner #2: DeepSeek V4-Pro on CMP-78 — WITH interaction step
|
||||
# (matching the full Sonnet baseline workflow on CMP-80, including ask_user_questions).
|
||||
set -euo pipefail
|
||||
|
||||
PROFILE_HOME="/home/chaim/.hermes/profiles/curator-cmp-deepseek"
|
||||
PAPERCLIP_API_URL="http://localhost:3100/api"
|
||||
PAPERCLIP_API_KEY="pcp_c87edcf306d06fce13fac701bb6d747191d61dba5b51e903"
|
||||
PAPERCLIP_TASK_ID="beb745e5-7195-40c5-9ac0-e9682c2c5184" # CMP-78
|
||||
PAPERCLIP_TASK_KEY="$PAPERCLIP_TASK_ID"
|
||||
PAPERCLIP_TASK_TITLE="[ערר 1130-25] סקירת ידע — DeepSeek V4-Pro test #2 (with interaction)"
|
||||
PAPERCLIP_RUN_ID="deepseek-ab2-$(date +%s)"
|
||||
PAPERCLIP_WAKE_REASON="manual_deepseek_ab_test_v2_with_interaction"
|
||||
|
||||
read -r -d '' PROMPT <<'EOF' || true
|
||||
אתה מנהל ידע (Knowledge Curator) של ועדת הערר. נעור על תיק שדפנה סימנה כסופי.
|
||||
|
||||
תיק: [ערר 1130-25] סקירת ידע — Knowledge Curator
|
||||
issue ID: beb745e5-7195-40c5-9ac0-e9682c2c5184
|
||||
run reason: manual_deepseek_ab_test_v2_with_interaction
|
||||
|
||||
**הקשר חשוב — ניסוי A/B #2:** זוהי ריצה שנייה ידנית באמצעות DeepSeek V4-Pro, הפעם **עם interaction מלא** כדי להשוות הוגנת מול ריצת Sonnet ב-CMP-80. כל הפלטים שתפרסם חייבים להתחיל בכותרת `[ניסוי DeepSeek V4-Pro #2 — עם interaction]`. אל תעיר סוכנים אחרים. אל תיצור issues חדשים.
|
||||
|
||||
הוראות:
|
||||
דפנה סימנה את ההחלטה הסופית של תיק 1130-25 כסופית.
|
||||
קובץ סופי: `סופי-1130-25.docx`
|
||||
|
||||
סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.
|
||||
חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, ממוספר.
|
||||
|
||||
# שלבי ביצוע
|
||||
|
||||
## 1. קונטקסט
|
||||
- קרא את MEMORY.md שלך (memory tool) — מה כבר זיהית.
|
||||
- קרא `/home/chaim/legal-ai/skills/decision/SKILL.md` (file tool) — מה כבר תועד.
|
||||
|
||||
## 2. נתונים
|
||||
- `mcp__legal-ai__case_get` עם case_number `1130-25` — מטא-דאטה.
|
||||
- `mcp__legal-ai__case_get_final_text` עם case_number `1130-25` — קרא את הטקסט המלא של ההחלטה הסופית.
|
||||
|
||||
## 3. ניתוח
|
||||
חפש 3-5 דפוסים/פערים. לכל ממצא: מה ראיתי + מה זה אומר + הצעה ניסוחית מדויקת.
|
||||
|
||||
## 4. כתוב comment הממצאים
|
||||
```bash
|
||||
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg b "$BODY" '{body:$b}')" \
|
||||
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/comments"
|
||||
```
|
||||
|
||||
פורמט ה-body:
|
||||
- שורה ראשונה: `[ניסוי DeepSeek V4-Pro #2 — עם interaction]`
|
||||
- אחר כך פסקה אחת מבוא קצרה
|
||||
- אחר כך הממצאים ממוספרים
|
||||
|
||||
## 5. פתח interaction מסוג ask_user_questions
|
||||
זה השלב שעבד את Sonnet הרבה זמן — בוא נראה כמה זמן יקח לך.
|
||||
|
||||
```bash
|
||||
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/interactions" \
|
||||
-d '{
|
||||
"kind": "ask_user_questions",
|
||||
"idempotencyKey": "curator-deepseek-v2:'"$PAPERCLIP_TASK_ID"':select",
|
||||
"title": "[DeepSeek] איזה ממצאים שווים עדכון?",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {
|
||||
"version": 1,
|
||||
"submitLabel": "אשר בחירה",
|
||||
"questions": [{
|
||||
"id": "findings_to_propose",
|
||||
"prompt": "סמן את הממצאים שאני אכין כהצעת עדכון ל-style guide",
|
||||
"selectionMode": "multi",
|
||||
"options": [
|
||||
{"id":"f1","label":"<מילוי לפי ממצא 1>","description":"<תקציר>"},
|
||||
{"id":"f2","label":"<מילוי לפי ממצא 2>","description":"<תקציר>"}
|
||||
]
|
||||
}]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
מלא את ה-options לפי הממצאים שלך — אופציה אחת לכל ממצא ממוספר.
|
||||
|
||||
## 6. עדכן issue ל-status=in_review (לא done — ממתינים לבחירת חיים)
|
||||
```bash
|
||||
curl -sS -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||
-d '{"status":"in_review"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
|
||||
```
|
||||
|
||||
# כללים
|
||||
- אל תעדכן קבצים (skills/, lessons.py, DB) בעצמך. רק comment + interaction.
|
||||
- אל תיצור issues חדשים.
|
||||
- אל תעיר סוכנים אחרים.
|
||||
- בעיה? comment קצר עם הסיבה + סגור (status=done).
|
||||
EOF
|
||||
|
||||
export HERMES_HOME="$PROFILE_HOME"
|
||||
export PAPERCLIP_API_URL PAPERCLIP_API_KEY PAPERCLIP_TASK_ID PAPERCLIP_TASK_KEY \
|
||||
PAPERCLIP_TASK_TITLE PAPERCLIP_RUN_ID PAPERCLIP_WAKE_REASON
|
||||
|
||||
echo "=== DeepSeek V4-Pro #2 (with interaction) — CMP-78 ==="
|
||||
echo "HERMES_HOME=$HERMES_HOME"
|
||||
echo "TASK_ID=$PAPERCLIP_TASK_ID"
|
||||
echo "RUN_ID=$PAPERCLIP_RUN_ID"
|
||||
echo "Started: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "---"
|
||||
|
||||
START_EPOCH=$(date +%s)
|
||||
hermes -z "$PROMPT" --yolo chat 2>&1
|
||||
END_EPOCH=$(date +%s)
|
||||
DURATION=$((END_EPOCH - START_EPOCH))
|
||||
echo ""
|
||||
echo "=== Run finished ==="
|
||||
echo "Ended: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "Duration: ${DURATION}s ($((DURATION/60))m $((DURATION%60))s)"
|
||||
106
scripts/.archive/run_curator_sonnet_rerun.sh
Executable file
106
scripts/.archive/run_curator_sonnet_rerun.sh
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# A/B test #3: Sonnet 4.5 re-run on CMP-78 — same task as DeepSeek #2 but with Sonnet.
|
||||
# Goal: check if Sonnet is consistent across runs (esp. the case-outcome detection),
|
||||
# given that the original Sonnet baseline on CMP-80 misread the outcome as "דחייה"
|
||||
# while the actual result is "קבלה חלקית".
|
||||
set -euo pipefail
|
||||
|
||||
PROFILE_HOME="/home/chaim/.hermes/profiles/curator-cmp" # default Sonnet profile
|
||||
PAPERCLIP_API_URL="http://localhost:3100/api"
|
||||
PAPERCLIP_API_KEY="pcp_c87edcf306d06fce13fac701bb6d747191d61dba5b51e903"
|
||||
PAPERCLIP_TASK_ID="beb745e5-7195-40c5-9ac0-e9682c2c5184" # CMP-78
|
||||
PAPERCLIP_TASK_KEY="$PAPERCLIP_TASK_ID"
|
||||
PAPERCLIP_TASK_TITLE="[ערר 1130-25] סקירת ידע — Sonnet rerun (consistency check)"
|
||||
PAPERCLIP_RUN_ID="sonnet-rerun-$(date +%s)"
|
||||
PAPERCLIP_WAKE_REASON="manual_sonnet_consistency_rerun"
|
||||
|
||||
read -r -d '' PROMPT <<'EOF' || true
|
||||
אתה מנהל ידע (Knowledge Curator) של ועדת הערר. נעור על תיק שדפנה סימנה כסופי.
|
||||
|
||||
תיק: [ערר 1130-25] סקירת ידע — Knowledge Curator
|
||||
issue ID: beb745e5-7195-40c5-9ac0-e9682c2c5184
|
||||
run reason: manual_sonnet_consistency_rerun
|
||||
|
||||
**הקשר חשוב — ניסוי A/B #3:** זוהי ריצה חוזרת ידנית באמצעות Sonnet 4.5 (אותו מודל שהריץ ב-CMP-80) — בדיקת עקביות. כל הפלטים שתפרסם חייבים להתחיל בכותרת `[ניסוי Sonnet 4.5 — ריצה חוזרת על CMP-78]`. אל תעיר סוכנים אחרים. אל תיצור issues חדשים.
|
||||
|
||||
הוראות:
|
||||
דפנה סימנה את ההחלטה הסופית של תיק 1130-25 כסופית.
|
||||
קובץ סופי: `סופי-1130-25.docx`
|
||||
|
||||
סקור את ההחלטה מול skills/decision/SKILL.md ו-docs/legal-decision-lessons.md.
|
||||
חפש 3-5 דפוסי סגנון/דיון שלא תועדו. כתוב comment בעברית, ניטרלי, ממוספר.
|
||||
|
||||
# שלבי ביצוע
|
||||
|
||||
## 1. קונטקסט
|
||||
- קרא את MEMORY.md שלך (memory tool) — מה כבר זיהית.
|
||||
- קרא `/home/chaim/legal-ai/skills/decision/SKILL.md` (file tool) — מה כבר תועד.
|
||||
|
||||
## 2. נתונים
|
||||
- `mcp__legal-ai__case_get` עם case_number `1130-25` — מטא-דאטה.
|
||||
- `mcp__legal-ai__case_get_final_text` עם case_number `1130-25` — קרא את הטקסט המלא של ההחלטה הסופית.
|
||||
|
||||
**שים לב במיוחד**: זהה במדויק את **תוצאת ההחלטה** (קבלה / קבלה חלקית / דחייה) על סמך הטקסט עצמו, לא על סמך הנחות.
|
||||
|
||||
## 3. ניתוח
|
||||
חפש 3-5 דפוסים/פערים. לכל ממצא: מה ראיתי + מה זה אומר + הצעה ניסוחית מדויקת.
|
||||
|
||||
## 4. כתוב comment הממצאים
|
||||
```bash
|
||||
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg b "$BODY" '{body:$b}')" \
|
||||
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/comments"
|
||||
```
|
||||
|
||||
פורמט ה-body:
|
||||
- שורה ראשונה: `[ניסוי Sonnet 4.5 — ריצה חוזרת על CMP-78]`
|
||||
- שורה שנייה: `**תוצאת ההחלטה הזו: <קבלה / קבלה חלקית / דחייה>** — ציין מפורשות
|
||||
- אחר כך פסקה אחת מבוא קצרה
|
||||
- אחר כך הממצאים ממוספרים
|
||||
|
||||
## 5. פתח interaction מסוג ask_user_questions
|
||||
זהה לפלואו של Sonnet באמת. אם תקבל "Agent run id required" — נסה כמה דרכים, ואם לא הולך, פרסם comment עם רשימת אופציות לבחירה.
|
||||
|
||||
```bash
|
||||
curl -sS -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID/interactions" \
|
||||
-d '{
|
||||
"kind": "ask_user_questions",
|
||||
"idempotencyKey": "curator-sonnet-rerun:'"$PAPERCLIP_TASK_ID"':select",
|
||||
"title": "[Sonnet rerun] איזה ממצאים שווים עדכון?",
|
||||
"continuationPolicy": "wake_assignee",
|
||||
"payload": {"version": 1, "submitLabel": "אשר בחירה",
|
||||
"questions": [{"id": "findings_to_propose", "prompt": "סמן ממצאים", "selectionMode": "multi", "options": []}]}}'
|
||||
```
|
||||
|
||||
## 6. עדכן issue ל-status=in_review
|
||||
```bash
|
||||
curl -sS -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" \
|
||||
-d '{"status":"in_review"}' "$PAPERCLIP_API_URL/issues/$PAPERCLIP_TASK_ID"
|
||||
```
|
||||
|
||||
# כללים
|
||||
- אל תעדכן קבצים בעצמך. רק comment + interaction.
|
||||
- אל תיצור issues חדשים.
|
||||
- אל תעיר סוכנים אחרים.
|
||||
EOF
|
||||
|
||||
export HERMES_HOME="$PROFILE_HOME"
|
||||
export PAPERCLIP_API_URL PAPERCLIP_API_KEY PAPERCLIP_TASK_ID PAPERCLIP_TASK_KEY \
|
||||
PAPERCLIP_TASK_TITLE PAPERCLIP_RUN_ID PAPERCLIP_WAKE_REASON
|
||||
|
||||
echo "=== Sonnet 4.5 rerun (consistency check) — CMP-78 ==="
|
||||
echo "HERMES_HOME=$HERMES_HOME"
|
||||
echo "TASK_ID=$PAPERCLIP_TASK_ID"
|
||||
echo "RUN_ID=$PAPERCLIP_RUN_ID"
|
||||
echo "Started: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "---"
|
||||
|
||||
START_EPOCH=$(date +%s)
|
||||
hermes -z "$PROMPT" --yolo chat 2>&1
|
||||
END_EPOCH=$(date +%s)
|
||||
DURATION=$((END_EPOCH - START_EPOCH))
|
||||
echo ""
|
||||
echo "=== Run finished ==="
|
||||
echo "Ended: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "Duration: ${DURATION}s ($((DURATION/60))m $((DURATION%60))s)"
|
||||
@@ -10,7 +10,7 @@
|
||||
|--------|------|---------|-----------|
|
||||
| `pc.sh` | bash | **wrapper לכל קריאות Paperclip API מסוכנים** — מוסיף Authorization, X-Paperclip-Run-Id (audit trail), Content-Type, base URL. תחביר: `pc.sh <METHOD> <PATH> [BODY_JSON]`. אסור `curl` ישיר ל-`$PAPERCLIP_API_URL`. ראה `HEARTBEAT.md §0`. counterpart ב-Python: `web/paperclip_api.py`. | נקרא ע"י סוכנים |
|
||||
| `sync_missing_agent_skills.py` | python | סקריפט "אל-כשל" להוספת `paperclipSkillSync` ל-`הגהת מסמכים` ו-`מנתח משפטי` שפיספסו את ה-sync ההיסטורי (Gap #28). תומך `--verify`/`--dry-run`/`--apply`. גיבוי אוטומטי ל-`agents-pre-skill-sync-*.sql`. דורש `PAPERCLIP_BOARD_API_KEY` (Infisical /paperclip ב-nautilus env). idempotent. | חד-פעמי (בוצע 2026-05-04). שמור לרפרנס |
|
||||
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** | ידני אחרי כל שינוי |
|
||||
| `sync_agents_across_companies.py` | python | **סנכרון סוכנים מ-CMP (1xxx, master) ל-CMPA (8xxx, mirror)** — Gap #25. משווה adapter_config (model/timeout/instructions/skills/etc), runtime_config (heartbeat), ושדות top-level (budget/metadata/icon/title/role). מסנן אוטומטית local skills שלא קיימים ב-mirror. לוגיקת subset (mirror יכול להחזיק יותר skills כי ה-API מוסיף required runtime skills). תומך `--verify`/`--dry-run`/`--apply [--only NAME]`. גיבוי אוטומטי. דורש `PAPERCLIP_BOARD_API_KEY`. **להריץ אחרי כל שינוי הגדרות ב-CMP.** **⚠ אם `adapter_type` שונה בין CMP ל-CMPA — הסקריפט מדלג על הסוכן עם warning. בעת מעבר adapter (למשל ל-`deepseek_local`) חובה לעדכן ידנית בשתי החברות לפני sync.** | ידני אחרי כל שינוי |
|
||||
| `fix_paperclipai_skills_drift.py` | python | סקריפט חד-פעמי (בוצע 2026-05-04) שניקה drift על `paperclipai/*` skills בין CMP ל-CMPA. הסיר `paperclip-dev` מכל 14 הסוכנים, ודאג ש-`paperclip-converting-plans-to-tasks` קיים רק על CEO ו-analyst. תומך `--apply` (ברירת מחדל: dry-run). דורש `PAPERCLIP_BOARD_API_KEY`. נשמר לרפרנס למקרה שhdrift חוזר. | חד-פעמי (בוצע) |
|
||||
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
||||
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
||||
@@ -54,6 +54,9 @@
|
||||
| `seed-appeals.py` | seeding תיקי ערר ראשוניים ל-DB | MCP: `case_create()` |
|
||||
| `seed-knowledge.py` | seeding לקחים, ביטויי מעבר, פסיקה | MCP: `record_chair_feedback()`, `precedent_attach()` |
|
||||
| `validate-decision.py` | ולידציה מול block-schema | MCP: `validate_decision()` + `qa_validator.py` |
|
||||
| `run_curator_deepseek_test.sh` | A/B test #1 (2026-05-05) — Hermes Curator על CMP-78 דרך DeepSeek V4-Pro ב-`provider:custom`, ללא interaction. תוצאה: 6:33 דק׳, 5 ממצאי סגנון/לקסיקון, פי 3 מהיר מ-Sonnet baseline (CMP-80) ופי ~20 זול. **הסקריפט נקודתי לתיק 1130-25 — לא להריץ שוב** | החלפת Curator לאדפטר DeepSeek מקומי (בתהליך) |
|
||||
| `run_curator_deepseek_test_v2.sh` | A/B test #2 (2026-05-05) — אותו run אבל עם interaction. תוצאה: 9:08 דק׳, 5 ממצאים, היחיד מ-4 הריצות שזיהה תוצאה עובדתית נכונה (קבלה חלקית). interaction נכשל ב-API ("Agent run id required" בריצה ידנית). | החלפת Curator לאדפטר DeepSeek מקומי |
|
||||
| `run_curator_sonnet_rerun.sh` | A/B test #3 (2026-05-05) — ריצה חוזרת של Sonnet 4.5 על אותו CMP-78. תוצאה: 12:52 דק׳ (לעומת 20:13 בריצה המקורית — כי בלי לולאת interaction.json). זיהה תוצאה שגויה ("דחייה") **בעקביות עם הריצה המקורית** — Sonnet עקבי-בטעות, DeepSeek אקראי. | בדיקה חד-פעמית — לא להריץ שוב |
|
||||
|
||||
## סקריפטים שנמחקו (git history בלבד)
|
||||
|
||||
|
||||
@@ -259,6 +259,14 @@ async def apply_diff(mirror_id: str, agent_name: str, diff: dict) -> list[str]:
|
||||
if "runtime_config" in diff:
|
||||
patch_body["runtimeConfig"] = diff["runtime_config"]["to"]
|
||||
|
||||
# Stamp claude_md_mtime + last_synced into metadata
|
||||
mtime = diff.get("_claude_md_mtime")
|
||||
if mtime:
|
||||
current_meta = dict(patch_body.get("metadata") or {})
|
||||
current_meta["claude_md_mtime"] = mtime
|
||||
current_meta["claude_md_last_synced"] = datetime.now(timezone.utc).isoformat()
|
||||
patch_body["metadata"] = current_meta
|
||||
|
||||
if patch_body:
|
||||
status, data = await call_patch(mirror_id, patch_body)
|
||||
if status >= 400:
|
||||
@@ -278,12 +286,73 @@ async def apply_diff(mirror_id: str, agent_name: str, diff: dict) -> list[str]:
|
||||
return errors
|
||||
|
||||
|
||||
def get_claude_md_mtime(adapter_config: dict) -> str | None:
|
||||
"""Return Unix mtime of the agent's instructionsFilePath, or None if file missing."""
|
||||
path = adapter_config.get("instructionsFilePath", "")
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
return str(int(os.path.getmtime(path)))
|
||||
|
||||
|
||||
async def check_instructions(agents: list[dict]) -> bool:
|
||||
"""Print a report of all agents' instruction files. Returns True if all OK."""
|
||||
from datetime import datetime
|
||||
|
||||
all_ok = True
|
||||
print(f"\n{'Agent':<30} {'File':<55} {'Status':<12} {'Size':>7} {'Modified'}")
|
||||
print("-" * 115)
|
||||
|
||||
for agent in agents:
|
||||
name = (agent.get("name") or agent.get("id") or "?")[:29]
|
||||
|
||||
try:
|
||||
adapter_cfg = agent.get("adapter_config") or {}
|
||||
if isinstance(adapter_cfg, str):
|
||||
adapter_cfg = json.loads(adapter_cfg)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
print(f"{name:<30} {'(malformed adapter_config in DB)':<55} {'⚠ ERROR':<12}")
|
||||
continue
|
||||
|
||||
file_path = adapter_cfg.get("instructionsFilePath", "")
|
||||
|
||||
if not file_path:
|
||||
print(f"{name:<30} {'(none)':<55} {'⚠ NOT SET':<12}")
|
||||
continue
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print(f"{name:<30} {file_path[-54:]:<55} {'❌ MISSING':<12}")
|
||||
all_ok = False
|
||||
continue
|
||||
|
||||
stat = os.stat(file_path)
|
||||
size_kb = stat.st_size // 1024
|
||||
mtime = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# Check for drift vs DB metadata
|
||||
try:
|
||||
metadata = agent.get("metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
metadata = json.loads(metadata)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
metadata = {}
|
||||
db_mtime = metadata.get("claude_md_mtime", "")
|
||||
actual_mtime = str(int(stat.st_mtime))
|
||||
drift = " ⚠ DRIFT" if db_mtime and db_mtime != actual_mtime else ""
|
||||
|
||||
print(f"{name:<30} {file_path[-54:]:<55} {'✅ OK':<12} {size_kb:>5}KB {mtime}{drift}")
|
||||
|
||||
print()
|
||||
return all_ok
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
p = argparse.ArgumentParser()
|
||||
g = p.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--verify", action="store_true", help="Show current drift, no changes")
|
||||
g.add_argument("--dry-run", action="store_true", help="Show what would change")
|
||||
g.add_argument("--apply", action="store_true", help="Backup + apply changes")
|
||||
g.add_argument("--check-instructions", action="store_true",
|
||||
help="Scan all agents' instructionsFilePath and report missing/outdated files")
|
||||
p.add_argument("--only", help="Sync only the named agent (e.g., 'עוזר משפטי')")
|
||||
args = p.parse_args()
|
||||
|
||||
@@ -295,6 +364,11 @@ async def main() -> None:
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if args.check_instructions:
|
||||
all_agents = master_agents + mirror_agents
|
||||
all_ok = await check_instructions(all_agents)
|
||||
sys.exit(0 if all_ok else 1)
|
||||
|
||||
mirror_by_name = {a["name"]: a for a in mirror_agents}
|
||||
|
||||
print(f"\n=== Master (CMP, 1xxx): {len(master_agents)} agents ===")
|
||||
@@ -332,6 +406,14 @@ async def main() -> None:
|
||||
return
|
||||
|
||||
# APPLY
|
||||
# Pre-flight: abort if any master agent is missing its instructions file
|
||||
print("🔍 Pre-flight: checking instruction files...")
|
||||
all_ok = await check_instructions(master_agents)
|
||||
if not all_ok:
|
||||
print("❌ Abort: one or more instruction files are missing. Fix before --apply.")
|
||||
sys.exit(1)
|
||||
print("✅ Pre-flight passed.\n")
|
||||
|
||||
print(f"\n=== Backup ===")
|
||||
backup_path = backup_agents_table()
|
||||
print(f" ✓ {backup_path}")
|
||||
@@ -340,6 +422,11 @@ async def main() -> None:
|
||||
all_errors: list[str] = []
|
||||
for master, mirror, diff in plan:
|
||||
print(f"\n → {master['name']} ({mirror['id']})")
|
||||
# Inject mtime into diff so apply_diff can stamp metadata
|
||||
master_ac = master.get("adapter_config") or {}
|
||||
mtime = get_claude_md_mtime(master_ac)
|
||||
if mtime:
|
||||
diff["_claude_md_mtime"] = mtime
|
||||
errors = await apply_diff(mirror["id"], master["name"], diff)
|
||||
if errors:
|
||||
for e in errors:
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { usePrecedent } from "@/lib/api/precedent-library";
|
||||
import { PrecedentEditSheet } from "@/components/precedents/precedent-edit-sheet";
|
||||
import { ExtractedHalachotSection } from "@/components/precedents/extracted-halachot";
|
||||
import { RelatedCasesSection } from "@/components/precedents/link-related-dialog";
|
||||
|
||||
const PRACTICE_AREA_LABELS: Record<string, string> = {
|
||||
rishuy_uvniya: "רישוי ובנייה",
|
||||
@@ -152,6 +153,15 @@ export default function PrecedentDetailPage({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<RelatedCasesSection
|
||||
caseId={id}
|
||||
related={data.related_cases ?? []}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<ExtractedHalachotSection halachot={data.halachot ?? []} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Trash2, Plus, Pencil, Wand2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -151,7 +152,9 @@ function CourtRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => void })
|
||||
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[280px] max-w-[420px] py-3"
|
||||
dir="rtl"
|
||||
>
|
||||
<span dir="auto">{cleanCitation(p.case_number)}</span>
|
||||
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
|
||||
{cleanCitation(p.case_number)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-ink whitespace-normal break-words max-w-[260px] py-3">
|
||||
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
||||
@@ -233,7 +236,9 @@ function CommitteeRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => voi
|
||||
className="font-semibold text-navy text-right whitespace-normal break-words min-w-[200px] max-w-[320px] py-3"
|
||||
dir="rtl"
|
||||
>
|
||||
<span dir="auto">{cleanCitation(p.case_number)}</span>
|
||||
<Link href={`/precedents/${p.id}`} className="hover:underline hover:text-gold-deep" dir="auto">
|
||||
{cleanCitation(p.case_number)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-ink whitespace-normal break-words max-w-[220px] py-3">
|
||||
<div className="font-medium">{cleanCitation(p.case_name)}</div>
|
||||
@@ -308,8 +313,8 @@ export function LibraryListPanel() {
|
||||
limit: 200,
|
||||
};
|
||||
|
||||
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload" });
|
||||
const committee = usePrecedents({ ...sharedFilters, sourceKind: "internal_committee" });
|
||||
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload", sourceType: "court_ruling" });
|
||||
const committee = usePrecedents({ ...sharedFilters, sourceKind: "all_committees" });
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
||||
222
web-ui/src/components/precedents/link-related-dialog.tsx
Normal file
222
web-ui/src/components/precedents/link-related-dialog.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link2, Loader2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
usePrecedents,
|
||||
useLinkRelatedCase,
|
||||
useUnlinkRelatedCase,
|
||||
RelatedCase,
|
||||
} from "@/lib/api/precedent-library";
|
||||
|
||||
const LEVEL_LABELS: Record<string, string> = {
|
||||
"עליון": "עליון",
|
||||
"מנהלי": "מנהלי",
|
||||
"ועדת_ערר_ארצית": "ארצי",
|
||||
"ועדת_ערר_מחוזית": "מחוזי",
|
||||
};
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
"עליון": "bg-red-50 text-red-700 border-red-200",
|
||||
"מנהלי": "bg-orange-50 text-orange-700 border-orange-200",
|
||||
"ועדת_ערר_ארצית": "bg-blue-50 text-blue-700 border-blue-200",
|
||||
"ועדת_ערר_מחוזית": "bg-green-50 text-green-700 border-green-200",
|
||||
};
|
||||
|
||||
// ── Search Dialog ────────────────────────────────────────────────────
|
||||
|
||||
type DialogProps = {
|
||||
caseId: string;
|
||||
currentRelated: RelatedCase[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const { mutateAsync: linkCase, isPending } = useLinkRelatedCase(caseId);
|
||||
const alreadyLinkedIds = new Set([...currentRelated.map((r) => r.id), caseId]);
|
||||
|
||||
const { data, isPending: searching } = usePrecedents(
|
||||
query.length >= 2 ? { search: query, limit: 10 } : {},
|
||||
);
|
||||
|
||||
const candidates = (data?.items ?? []).filter((p) => !alreadyLinkedIds.has(p.id));
|
||||
|
||||
async function handleLink(relatedId: string) {
|
||||
try {
|
||||
await linkCase({ relatedId });
|
||||
toast.success("הפסיקות קושרו");
|
||||
setQuery("");
|
||||
} catch {
|
||||
toast.error("שגיאה בקישור");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg" dir="rtl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-navy">קשר החלטה קשורה</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="חפש לפי מספר תיק, שם, ערכאה..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
dir="rtl"
|
||||
/>
|
||||
|
||||
{query.length >= 2 && (
|
||||
<div className="space-y-1 max-h-72 overflow-y-auto">
|
||||
{searching ? (
|
||||
<div className="flex items-center gap-2 text-ink-muted text-sm py-3">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> מחפש...
|
||||
</div>
|
||||
) : candidates.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm py-3 text-center">לא נמצאו תוצאות</p>
|
||||
) : (
|
||||
candidates.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handleLink(p.id)}
|
||||
disabled={isPending}
|
||||
className="w-full text-right flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule hover:bg-surface/60 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-navy truncate">
|
||||
{p.case_name || p.case_number}
|
||||
</div>
|
||||
<div className="text-[0.72rem] text-ink-muted font-mono" dir="ltr">
|
||||
{p.case_number}
|
||||
</div>
|
||||
</div>
|
||||
{p.precedent_level && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[0.62rem] shrink-0 ${LEVEL_COLORS[p.precedent_level] ?? ""}`}
|
||||
>
|
||||
{LEVEL_LABELS[p.precedent_level] ?? p.precedent_level}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.length > 0 && query.length < 2 && (
|
||||
<p className="text-ink-muted text-xs">הקלד לפחות 2 תווים</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Related Case Card ────────────────────────────────────────────────
|
||||
|
||||
function RelatedCaseCard({ caseId, related }: { caseId: string; related: RelatedCase }) {
|
||||
const { mutateAsync: unlinkCase, isPending } = useUnlinkRelatedCase(caseId);
|
||||
|
||||
async function handleUnlink() {
|
||||
try {
|
||||
await unlinkCase(related.id);
|
||||
toast.success("הקישור הוסר");
|
||||
} catch {
|
||||
toast.error("שגיאה בהסרת הקישור");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule bg-surface">
|
||||
<a
|
||||
href={`/precedents/${related.id}`}
|
||||
className="min-w-0 flex-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="text-sm font-medium text-navy truncate">
|
||||
{related.case_name || related.case_number}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{related.precedent_level && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[0.62rem] ${LEVEL_COLORS[related.precedent_level] ?? ""}`}
|
||||
>
|
||||
{LEVEL_LABELS[related.precedent_level] ?? related.precedent_level}
|
||||
</Badge>
|
||||
)}
|
||||
{related.court && (
|
||||
<span className="text-[0.7rem] text-ink-muted truncate">{related.court}</span>
|
||||
)}
|
||||
{related.date && (
|
||||
<span className="text-[0.7rem] text-ink-muted tabular-nums" dir="ltr">
|
||||
{related.date.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
<button
|
||||
onClick={handleUnlink}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-danger-bg hover:text-danger transition-colors text-ink-muted disabled:opacity-40 shrink-0"
|
||||
title="הסר קישור"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Public section component ─────────────────────────────────────────
|
||||
|
||||
type SectionProps = {
|
||||
caseId: string;
|
||||
related: RelatedCase[];
|
||||
};
|
||||
|
||||
export function RelatedCasesSection({ caseId, related }: SectionProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-navy text-sm font-semibold">
|
||||
החלטות קשורות{related.length > 0 ? ` (${related.length})` : ""}
|
||||
</h3>
|
||||
<Button variant="outline" size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Link2 className="w-3.5 h-3.5 me-1" /> קשר החלטה
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{related.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm">אין החלטות קשורות עדיין</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{related.map((r) => (
|
||||
<RelatedCaseCard key={r.id} caseId={caseId} related={r} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LinkDialog
|
||||
caseId={caseId}
|
||||
currentRelated={related}
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Save, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -65,10 +65,12 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
|
||||
// Hydrate form when the record loads.
|
||||
useEffect(() => {
|
||||
if (!record) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
// React-approved derived-state pattern: sync form whenever a different
|
||||
// record arrives (including after save+refetch). Using setState during
|
||||
// render avoids the one-frame flash that useEffect would produce.
|
||||
const [syncedRecordId, setSyncedRecordId] = useState<string | null>(null);
|
||||
if (record && record.id !== syncedRecordId) {
|
||||
setSyncedRecordId(record.id as string);
|
||||
setForm({
|
||||
citation: record.case_number || "",
|
||||
case_name: record.case_name || "",
|
||||
@@ -84,7 +86,7 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
|
||||
headnote: record.headnote || "",
|
||||
key_quote: (record as { key_quote?: string }).key_quote || "",
|
||||
});
|
||||
}, [record]);
|
||||
}
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -84,9 +84,20 @@ export type Halacha = {
|
||||
precedent_level?: string;
|
||||
};
|
||||
|
||||
export type RelatedCase = {
|
||||
id: string;
|
||||
case_number: string;
|
||||
case_name: string;
|
||||
court: string;
|
||||
precedent_level: string;
|
||||
date: string | null;
|
||||
relation_type: string;
|
||||
};
|
||||
|
||||
export type PrecedentDetail = Precedent & {
|
||||
full_text: string;
|
||||
halachot: Halacha[];
|
||||
related_cases: RelatedCase[];
|
||||
};
|
||||
|
||||
export type SearchHit =
|
||||
@@ -357,6 +368,40 @@ export function useDeletePrecedent() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useLinkRelatedCase(caseId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vars: { relatedId: string; relationType?: string }) =>
|
||||
apiRequest<{ linked: boolean }>(
|
||||
`/api/precedent-library/${encodeURIComponent(caseId)}/relations`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
related_id: vars.relatedId,
|
||||
relation_type: vars.relationType ?? "same_case_chain",
|
||||
},
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnlinkRelatedCase(caseId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (relatedId: string) =>
|
||||
apiRequest<{ unlinked: boolean }>(
|
||||
`/api/precedent-library/${encodeURIComponent(caseId)}/relations/${encodeURIComponent(relatedId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type PrecedentPatch = Partial<{
|
||||
case_name: string;
|
||||
court: string;
|
||||
|
||||
139
web/app.py
139
web/app.py
@@ -20,7 +20,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "
|
||||
|
||||
import zipfile
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from typing import Any, Literal
|
||||
from pydantic import BaseModel
|
||||
@@ -44,7 +44,7 @@ from web.mcp_env_catalog import (
|
||||
normalize_for_compare,
|
||||
)
|
||||
from web.progress_store import ProgressStore
|
||||
from web.paperclip_api import pc_request
|
||||
from web.paperclip_api import emit_case_status_webhook, pc_request
|
||||
from web.paperclip_client import (
|
||||
COMPANIES as PAPERCLIP_COMPANIES,
|
||||
accept_interaction as pc_accept_interaction,
|
||||
@@ -1135,6 +1135,36 @@ async def list_cases(
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/cases/stale")
|
||||
async def api_stale_cases(days: int = 3):
|
||||
"""Return cases that haven't been updated in N days and are not in 'final' or 'new' status."""
|
||||
if days <= 0:
|
||||
return {"cases": [], "total": 0}
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT case_number, title, status,
|
||||
EXTRACT(DAY FROM (now() - updated_at))::int AS days_stale
|
||||
FROM cases
|
||||
WHERE status NOT IN ('final', 'new')
|
||||
AND updated_at < now() - make_interval(days => $1)
|
||||
ORDER BY updated_at ASC -- oldest stale first (longest overdue = highest priority)
|
||||
""",
|
||||
days,
|
||||
)
|
||||
cases = [
|
||||
{
|
||||
"case_number": r["case_number"],
|
||||
"title": r["title"],
|
||||
"status": r["status"],
|
||||
"days_stale": r["days_stale"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"cases": cases, "total": len(cases)}
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/archive")
|
||||
async def api_archive_case(case_number: str):
|
||||
"""Move a case to the archive. Also archives the matching Paperclip project."""
|
||||
@@ -1337,8 +1367,12 @@ async def api_case_get(case_number: str):
|
||||
|
||||
|
||||
@app.put("/api/cases/{case_number}")
|
||||
async def api_case_update(case_number: str, req: CaseUpdateRequest):
|
||||
async def api_case_update(case_number: str, req: CaseUpdateRequest, background_tasks: BackgroundTasks):
|
||||
"""Update case details."""
|
||||
# Capture old status before the update so we can detect changes.
|
||||
existing = await db.get_case_by_number(case_number)
|
||||
old_status = (existing or {}).get("status", "")
|
||||
|
||||
result = await cases_tools.case_update(
|
||||
case_number=case_number,
|
||||
status=req.status,
|
||||
@@ -1351,10 +1385,30 @@ async def api_case_update(case_number: str, req: CaseUpdateRequest):
|
||||
expected_outcome=req.expected_outcome,
|
||||
)
|
||||
try:
|
||||
return json.loads(result)
|
||||
parsed = json.loads(result)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(404, result)
|
||||
|
||||
# Emit webhook when status changes (fire-and-forget via BackgroundTasks).
|
||||
new_status = req.status
|
||||
if new_status and old_status != new_status:
|
||||
prefix = case_number[:1]
|
||||
company_id = (
|
||||
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
|
||||
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
|
||||
else None
|
||||
)
|
||||
background_tasks.add_task(
|
||||
emit_case_status_webhook,
|
||||
case_number=case_number,
|
||||
old_status=old_status,
|
||||
new_status=new_status,
|
||||
company_id=company_id, # None is safe — plugin handles unknown company gracefully
|
||||
)
|
||||
logger.debug("webhook scheduled: case %s %s → %s", case_number, old_status, new_status)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
@app.delete("/api/cases")
|
||||
async def api_case_delete(case_number: str, remove_files: bool = False):
|
||||
@@ -3057,8 +3111,16 @@ async def api_get_methodology(category: str):
|
||||
items = {}
|
||||
for key, default_val in defaults.items():
|
||||
if key in overrides:
|
||||
raw = overrides[key]["rule_value"]
|
||||
# asyncpg returns JSONB as a raw JSON string when no codec is registered.
|
||||
# Parse it back to a Python object so the frontend receives the correct type.
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
items[key] = {
|
||||
"value": overrides[key]["rule_value"],
|
||||
"value": raw,
|
||||
"is_override": True,
|
||||
"updated_at": overrides[key]["created_at"].isoformat() if overrides[key]["created_at"] else None,
|
||||
}
|
||||
@@ -3095,10 +3157,14 @@ async def api_update_methodology(category: str, key: str, req: MethodologyUpdate
|
||||
raise HTTPException(422, "content_checklists value must be a non-empty string")
|
||||
|
||||
pool = await db.get_pool()
|
||||
# json.dumps → text, then PostgreSQL casts text→jsonb.
|
||||
# Passing a Python list directly causes "expected str, got list" in asyncpg;
|
||||
# passing a str with ::jsonb causes double-encoding (stored as JSONB string).
|
||||
# ::text::jsonb bypasses asyncpg's codec and lets PostgreSQL parse the JSON.
|
||||
await pool.execute(
|
||||
"INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
|
||||
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::jsonb) "
|
||||
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::jsonb",
|
||||
"VALUES (gen_random_uuid(), '_global', $1, $2, $3::text::jsonb) "
|
||||
"ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::text::jsonb",
|
||||
category, key, json.dumps(req.value, ensure_ascii=False),
|
||||
)
|
||||
|
||||
@@ -3979,6 +4045,34 @@ async def api_resolve_feedback(feedback_id: str, body: dict):
|
||||
return {"status": "resolved"}
|
||||
|
||||
|
||||
@app.get("/api/chair-feedback/weekly-summary")
|
||||
async def api_chair_feedback_weekly_summary(days: int = 7, limit: int = 100):
|
||||
"""Return chair feedback from the last N days as a text summary for the CEO agent."""
|
||||
if days <= 0:
|
||||
return {"summary": "", "entry_count": 0}
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT cf.feedback_text, c.case_number, c.title
|
||||
FROM chair_feedback cf
|
||||
LEFT JOIN cases c ON c.id = cf.case_id
|
||||
WHERE cf.created_at >= now() - make_interval(days => $1)
|
||||
ORDER BY cf.created_at DESC
|
||||
LIMIT $2
|
||||
""",
|
||||
days,
|
||||
limit,
|
||||
)
|
||||
if not rows:
|
||||
return {"summary": "", "entry_count": 0}
|
||||
lines = [
|
||||
f"- תיק {r['case_number'] or '—'} ({r['title'] or '—'}): {r['feedback_text']}"
|
||||
for r in rows
|
||||
]
|
||||
return {"summary": "\n".join(lines), "entry_count": len(rows)}
|
||||
|
||||
|
||||
# ── Background Processing ─────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -4380,6 +4474,37 @@ async def precedent_library_delete(case_law_id: str):
|
||||
return {"deleted": True, "case_law_id": case_law_id}
|
||||
|
||||
|
||||
class PrecedentRelationRequest(BaseModel):
|
||||
related_id: str
|
||||
relation_type: str = "same_case_chain"
|
||||
|
||||
|
||||
@app.post("/api/precedent-library/{case_law_id}/relations")
|
||||
async def precedent_add_relation(case_law_id: str, req: PrecedentRelationRequest):
|
||||
try:
|
||||
a = UUID(case_law_id)
|
||||
b = UUID(req.related_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "case_law_id לא תקין")
|
||||
if not await db.get_case_law(a):
|
||||
raise HTTPException(404, "פסיקה לא נמצאה")
|
||||
if not await db.get_case_law(b):
|
||||
raise HTTPException(404, f"פסיקה קשורה {req.related_id} לא נמצאה")
|
||||
await db.add_case_law_relation(a, b, req.relation_type)
|
||||
return {"linked": True, "case_law_id": case_law_id, "related_id": req.related_id}
|
||||
|
||||
|
||||
@app.delete("/api/precedent-library/{case_law_id}/relations/{related_id}")
|
||||
async def precedent_remove_relation(case_law_id: str, related_id: str):
|
||||
try:
|
||||
a = UUID(case_law_id)
|
||||
b = UUID(related_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "case_law_id לא תקין")
|
||||
await db.remove_case_law_relation(a, b)
|
||||
return {"unlinked": True, "case_law_id": case_law_id, "related_id": related_id}
|
||||
|
||||
|
||||
# Halacha and metadata extraction are LLM-driven and rely on the local
|
||||
# `claude` CLI via mcp-server/services/claude_session.py — they CANNOT run
|
||||
# from this container (no CLI, no claude.ai session). The endpoints below
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -81,3 +82,35 @@ async def pc_request(
|
||||
if raise_on_error:
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
|
||||
|
||||
async def emit_case_status_webhook(
|
||||
case_number: str,
|
||||
old_status: str,
|
||||
new_status: str,
|
||||
company_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> None:
|
||||
"""Notify the Paperclip plugin that a case status changed.
|
||||
|
||||
Fire-and-forget: logs errors but never raises, so callers aren't blocked.
|
||||
"""
|
||||
try:
|
||||
await pc_request(
|
||||
"POST",
|
||||
"/api/plugins/marcusgroup.legal-ai/webhooks/case-status",
|
||||
json={
|
||||
"caseNumber": case_number,
|
||||
"oldStatus": old_status,
|
||||
"newStatus": new_status,
|
||||
"companyId": company_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
run_id=run_id,
|
||||
timeout=5.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"emit_case_status_webhook failed for case %s (%s → %s): %s",
|
||||
case_number, old_status, new_status, exc,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user