feat(feedback): חיבור פידבק יו"ר לסוכנים — סימון "יושם" מקפל לקח לקובץ הידע #58
@@ -212,6 +212,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
||||||
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
||||||
- אם ה-reason מכיל `weekly-feedback-job` → **דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
|
- אם ה-reason מכיל `weekly-feedback-job` → **דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
|
||||||
|
- אם ה-reason מכיל `feedback_fold_` → **דלג לסעיף "קיפול הערת יו\"ר"**. אל תיגע בתיקים — זו משימת תחזוקת ידע.
|
||||||
- אחרת → המשך לשלב A (heartbeat רגיל)
|
- אחרת → המשך לשלב A (heartbeat רגיל)
|
||||||
|
|
||||||
### חילוץ פסיקה אוטומטי
|
### חילוץ פסיקה אוטומטי
|
||||||
@@ -270,6 +271,29 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
|
|
||||||
**כלל:** אל תגע בתיקים פעילים, אל תעיר סוכנים אחרים, אל תבצע heartbeat רגיל — זו משימת תחזוקה בלבד.
|
**כלל:** אל תגע בתיקים פעילים, אל תעיר סוכנים אחרים, אל תבצע heartbeat רגיל — זו משימת תחזוקה בלבד.
|
||||||
|
|
||||||
|
### קיפול הערת יו"ר (feedback_fold)
|
||||||
|
|
||||||
|
**מתי:** `$PAPERCLIP_WAKE_REASON` מכיל `feedback_fold_`
|
||||||
|
|
||||||
|
מופעל כשהיו"ר סימנה הערת פידבק בודדת כ"יושמה" בדף `/feedback`. נוצר issue בפרויקט "ספריית פסיקה" המשויך אליך, ו**תיאור ה-issue מכיל את כל מה שצריך**: טקסט ההערה, הלקח שהופק, הקטגוריה, ויעד הקיפול לפי הקטגוריה.
|
||||||
|
|
||||||
|
**⚠️ MCP startup race** — חל גם כאן (ראה אזהרת חילוץ פסיקה). אם הכלי הראשון מחזיר "No such tool available" — המתן 3 שניות ונסה שוב.
|
||||||
|
|
||||||
|
**מה לעשות:**
|
||||||
|
1. **קרא את תיאור ה-issue** (`$PAPERCLIP_TASK_ID`) — הוא מכיל את ההערה, הלקח, הקטגוריה, ושדה **"יעד קיפול"**.
|
||||||
|
2. **rubric ניתוב לפי קטגוריה** (מופיע גם בתיאור ה-issue — זה מקור האמת):
|
||||||
|
| קטגוריה | קובץ יעד |
|
||||||
|
|---------|----------|
|
||||||
|
| `style` | `skills/decision/SKILL.md` |
|
||||||
|
| `wrong_structure` | `docs/block-schema.md` + `docs/legal-decision-lessons.md` |
|
||||||
|
| `missing_content` / `factual_error` / `wrong_tone` | `docs/legal-decision-lessons.md` |
|
||||||
|
| `other` | שיקול דעת — אם זה באג מערכת ולא לקח כתיבה → **אל תוסיף לקובץ**, פתח/עדכן משימת TaskMaster |
|
||||||
|
3. **קרא את קובץ היעד** והבן מה כבר מתועד שם.
|
||||||
|
4. **הוסף את הלקח רק אם אינו קיים** (לא כפל). פורמט: משפט עברי ברור + שורת **Rule** באנגלית, בעקבות הסגנון הקיים בקובץ.
|
||||||
|
5. **סגור את ה-issue** (`status=done`) עם comment קצר בעברית: לאיזה קובץ קופל ומה נוסף (או "כבר קיים — לא נוסף").
|
||||||
|
|
||||||
|
**כלל:** אל תגע בתיקים פעילים, אל תעיר סוכנים אחרים. משימת תחזוקת ידע בלבד.
|
||||||
|
|
||||||
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
||||||
|
|
||||||
בכל heartbeat **רגיל** (לא comment routing):
|
בכל heartbeat **רגיל** (לא comment routing):
|
||||||
|
|||||||
@@ -2455,6 +2455,20 @@ async def list_chair_feedback(
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_chair_feedback(feedback_id: UUID) -> dict | None:
|
||||||
|
"""Return a single chair_feedback row by id (with case_number), or None."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""SELECT cf.*, c.case_number, c.appeal_type AS case_appeal_type
|
||||||
|
FROM chair_feedback cf
|
||||||
|
LEFT JOIN cases c ON c.id = cf.case_id
|
||||||
|
WHERE cf.id = $1""",
|
||||||
|
feedback_id,
|
||||||
|
)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def resolve_chair_feedback(
|
async def resolve_chair_feedback(
|
||||||
feedback_id: UUID,
|
feedback_id: UUID,
|
||||||
applied_to: list[str],
|
applied_to: list[str],
|
||||||
|
|||||||
@@ -42,9 +42,14 @@ function FeedbackCard({ fb }: { fb: ChairFeedback }) {
|
|||||||
|
|
||||||
const onResolve = () => {
|
const onResolve = () => {
|
||||||
resolve.mutate(
|
resolve.mutate(
|
||||||
{ feedbackId: fb.id, applied_to: [] },
|
{ feedbackId: fb.id, applied_to: [], fold: true },
|
||||||
{
|
{
|
||||||
onSuccess: () => toast.success("ההערה סומנה כיושמה"),
|
onSuccess: (res) =>
|
||||||
|
toast.success(
|
||||||
|
res.fold_queued
|
||||||
|
? "סומנה כיושמה — הלקח נשלח ל-CEO לקיפול לקובץ הידע"
|
||||||
|
: "ההערה סומנה כיושמה",
|
||||||
|
),
|
||||||
onError: (e) =>
|
onError: (e) =>
|
||||||
toast.error(e instanceof Error ? e.message : "שגיאה בסימון"),
|
toast.error(e instanceof Error ? e.message : "שגיאה בסימון"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -153,8 +153,10 @@ export function DraftsPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleResolve(id: string) {
|
function handleResolve(id: string) {
|
||||||
|
// fold=false: resolving from the per-case panel is bookkeeping only.
|
||||||
|
// Folding the lesson into the knowledge files is driven from /feedback.
|
||||||
resolveMutation.mutate(
|
resolveMutation.mutate(
|
||||||
{ feedbackId: id, applied_to: [] },
|
{ feedbackId: id, applied_to: [], fold: false },
|
||||||
{
|
{
|
||||||
onSuccess: () => toast.success("ההערה סומנה כמטופלת"),
|
onSuccess: () => toast.success("ההערה סומנה כמטופלת"),
|
||||||
onError: () => toast.error("שגיאה בעדכון"),
|
onError: () => toast.error("שגיאה בעדכון"),
|
||||||
|
|||||||
@@ -89,13 +89,17 @@ export function useResolveFeedback() {
|
|||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
feedbackId,
|
feedbackId,
|
||||||
applied_to,
|
applied_to,
|
||||||
|
fold,
|
||||||
}: {
|
}: {
|
||||||
feedbackId: string;
|
feedbackId: string;
|
||||||
applied_to: string[];
|
applied_to: string[];
|
||||||
|
/** When true (default server-side), wakes the CEO to fold the lesson
|
||||||
|
* into the right knowledge file. Pass false for pure bookkeeping. */
|
||||||
|
fold?: boolean;
|
||||||
}) =>
|
}) =>
|
||||||
apiRequest<{ status: string }>(
|
apiRequest<{ status: string; fold_queued: boolean }>(
|
||||||
`/api/feedback/${feedbackId}/resolve`,
|
`/api/feedback/${feedbackId}/resolve`,
|
||||||
{ method: "PATCH", body: { applied_to } },
|
{ method: "PATCH", body: { applied_to, ...(fold === undefined ? {} : { fold }) } },
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: feedbackKeys.all });
|
qc.invalidateQueries({ queryKey: feedbackKeys.all });
|
||||||
|
|||||||
49
web/app.py
49
web/app.py
@@ -64,6 +64,7 @@ from web.paperclip_client import (
|
|||||||
restore_project as pc_restore_project,
|
restore_project as pc_restore_project,
|
||||||
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
|
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
|
||||||
wake_ceo_agent as pc_wake_ceo,
|
wake_ceo_agent as pc_wake_ceo,
|
||||||
|
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
|
||||||
wake_curator_for_final as pc_wake_curator_for_final,
|
wake_curator_for_final as pc_wake_curator_for_final,
|
||||||
wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
|
wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
|
||||||
)
|
)
|
||||||
@@ -4972,13 +4973,53 @@ async def api_create_feedback_json(body: dict):
|
|||||||
|
|
||||||
|
|
||||||
@app.patch("/api/feedback/{feedback_id}/resolve")
|
@app.patch("/api/feedback/{feedback_id}/resolve")
|
||||||
async def api_resolve_feedback(feedback_id: str, body: dict):
|
async def api_resolve_feedback(
|
||||||
"""Mark feedback as resolved."""
|
feedback_id: str,
|
||||||
|
body: dict,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
):
|
||||||
|
"""Mark feedback as resolved. When ``fold`` is true (default) and the entry
|
||||||
|
has an extracted lesson, also wake the CEO to fold that lesson into the
|
||||||
|
right knowledge file (the feedback→agent-knowledge loop).
|
||||||
|
|
||||||
|
The fold is fire-and-forget (BackgroundTask) and best-effort — resolving
|
||||||
|
never fails because Paperclip is down. Pass ``fold=false`` for pure
|
||||||
|
bookkeeping resolves (e.g. from the per-case drafts panel) to avoid
|
||||||
|
spawning a CEO run per click."""
|
||||||
|
fid = UUID(feedback_id)
|
||||||
|
fold = body.get("fold", True)
|
||||||
|
|
||||||
|
fb = await db.get_chair_feedback(fid)
|
||||||
|
if not fb:
|
||||||
|
raise HTTPException(404, "הערה לא נמצאה")
|
||||||
|
|
||||||
await db.resolve_chair_feedback(
|
await db.resolve_chair_feedback(
|
||||||
feedback_id=UUID(feedback_id),
|
feedback_id=fid,
|
||||||
applied_to=body.get("applied_to", []),
|
applied_to=body.get("applied_to", []),
|
||||||
)
|
)
|
||||||
return {"status": "resolved"}
|
|
||||||
|
# Guard: only fold a real, lesson-bearing entry, and only when asked.
|
||||||
|
lesson = (fb.get("lesson_extracted") or "").strip()
|
||||||
|
fold_queued = False
|
||||||
|
if fold and lesson:
|
||||||
|
async def _fold():
|
||||||
|
try:
|
||||||
|
await pc_wake_ceo_for_feedback_fold(
|
||||||
|
feedback_id=str(fid),
|
||||||
|
feedback_text=fb.get("feedback_text") or "",
|
||||||
|
lesson_extracted=lesson,
|
||||||
|
category=fb.get("category") or "other",
|
||||||
|
block_id=fb.get("block_id") or "",
|
||||||
|
case_number=fb.get("case_number") or "",
|
||||||
|
practice_area=fb.get("case_appeal_type") or "",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("feedback-fold wakeup failed (non-fatal) for %s", fid)
|
||||||
|
|
||||||
|
background_tasks.add_task(_fold)
|
||||||
|
fold_queued = True
|
||||||
|
|
||||||
|
return {"status": "resolved", "fold_queued": fold_queued}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/chair-feedback/weekly-summary")
|
@app.get("/api/chair-feedback/weekly-summary")
|
||||||
|
|||||||
@@ -907,6 +907,119 @@ async def wake_for_precedent_extraction(
|
|||||||
return {"ok": False, "error": f"wakeup: {e}", "issue_id": issue_id, "identifier": identifier}
|
return {"ok": False, "error": f"wakeup: {e}", "issue_id": issue_id, "identifier": identifier}
|
||||||
|
|
||||||
|
|
||||||
|
async def wake_ceo_for_feedback_fold(
|
||||||
|
*,
|
||||||
|
feedback_id: str,
|
||||||
|
feedback_text: str,
|
||||||
|
lesson_extracted: str,
|
||||||
|
category: str,
|
||||||
|
block_id: str = "",
|
||||||
|
case_number: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Wake the CEO to fold one resolved chair-feedback lesson into the right
|
||||||
|
knowledge file (the feedback→agent-knowledge loop).
|
||||||
|
|
||||||
|
Mirrors ``wake_for_precedent_extraction``: creates a Paperclip issue under
|
||||||
|
the library project carrying the lesson + a category→file routing rubric,
|
||||||
|
then wakes the CEO (reason ``feedback_fold_<identifier>``). The CEO folds
|
||||||
|
the lesson per the rubric and closes the issue.
|
||||||
|
|
||||||
|
Best-effort: any failure is logged and returned as ``{"ok": False, ...}``
|
||||||
|
so resolving the feedback never fails because Paperclip is down.
|
||||||
|
"""
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
logger.warning("PAPERCLIP_BOARD_API_KEY not set — skipping feedback-fold wakeup")
|
||||||
|
return {"ok": False, "skipped": "no_api_key"}
|
||||||
|
|
||||||
|
# decision-lessons.md is shared across companies → either CEO works; route
|
||||||
|
# by the case's practice area when known, else default to licensing.
|
||||||
|
company_id = await _get_company_id(practice_area)
|
||||||
|
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||||
|
|
||||||
|
# category → target file routing rubric (embedded in the issue so the CEO
|
||||||
|
# has it even if its prompt is trimmed). Keep in sync with legal-ceo.md.
|
||||||
|
routing = {
|
||||||
|
"style": "skills/decision/SKILL.md",
|
||||||
|
"wrong_structure": "docs/block-schema.md + docs/legal-decision-lessons.md",
|
||||||
|
"missing_content": "docs/legal-decision-lessons.md",
|
||||||
|
"factual_error": "docs/legal-decision-lessons.md",
|
||||||
|
"wrong_tone": "docs/legal-decision-lessons.md",
|
||||||
|
"other": "שיקול דעת — לרוב באג מערכת → משימת TaskMaster, לא לקח",
|
||||||
|
}
|
||||||
|
target = routing.get(category, "docs/legal-decision-lessons.md")
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
project_id = await _get_or_create_library_project(conn, company_id)
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"UPDATE companies SET issue_counter = issue_counter + 1 "
|
||||||
|
"WHERE id = $1::uuid RETURNING issue_counter",
|
||||||
|
company_id,
|
||||||
|
)
|
||||||
|
issue_number = row["issue_counter"]
|
||||||
|
prefix = "CMP" if company_id == COMPANIES["licensing"] else "CMPA"
|
||||||
|
identifier = f"{prefix}-{issue_number}"
|
||||||
|
|
||||||
|
issue_id = str(uuid.uuid4())
|
||||||
|
short = (feedback_text[:90] + "…") if len(feedback_text) > 90 else feedback_text
|
||||||
|
description = (
|
||||||
|
f"היו\"ר סימנה הערת פידבק כ\"יושמה\" — יש לקפל את הלקח לקובץ הידע הנכון.\n\n"
|
||||||
|
f"**feedback_id:** `{feedback_id}`\n"
|
||||||
|
f"**קטגוריה:** {category}\n"
|
||||||
|
f"**בלוק:** {block_id or '—'}\n"
|
||||||
|
f"**תיק:** {case_number or '—'}\n\n"
|
||||||
|
f"**ההערה:**\n{feedback_text}\n\n"
|
||||||
|
f"**הלקח שהופק:**\n{lesson_extracted or '(אין — הפק לקח מההערה)'}\n\n"
|
||||||
|
f"---\n\n"
|
||||||
|
f"**יעד קיפול לפי הקטגוריה:** `{target}`\n\n"
|
||||||
|
f"**משימה:**\n"
|
||||||
|
f"1. קרא את קובץ היעד והבן מה כבר מתועד שם.\n"
|
||||||
|
f"2. הוסף את הלקח **רק אם אינו קיים** (לא כפל). משפט אחד ברור + Rule באנגלית.\n"
|
||||||
|
f"3. אם הקטגוריה היא `other` והלקח הוא באג מערכת ולא לקח כתיבה — "
|
||||||
|
f"אל תוסיף לקובץ; פתח/עדכן משימת TaskMaster במקום.\n"
|
||||||
|
f"4. סמן את ה-issue כ-done ופתח comment קצר: לאיזה קובץ קופל ומה נוסף.\n"
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO issues
|
||||||
|
(id, company_id, project_id, title, description,
|
||||||
|
status, priority, issue_number, identifier, assignee_agent_id)
|
||||||
|
VALUES ($1, $2::uuid, $3::uuid, $4, $5, 'todo', 'low',
|
||||||
|
$6, $7, $8::uuid)""",
|
||||||
|
issue_id, company_id, project_id,
|
||||||
|
f"[לקח] קיפול הערת יו\"ר: {short}"[:200],
|
||||||
|
description,
|
||||||
|
issue_number, identifier, ceo_id,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("wake_ceo_for_feedback_fold: DB step failed: %s", e)
|
||||||
|
return {"ok": False, "error": f"db: {e}"}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"source": "automation",
|
||||||
|
"triggerDetail": "feedback_resolved",
|
||||||
|
"reason": f"feedback_fold_{identifier}",
|
||||||
|
"payload": {
|
||||||
|
"issueId": issue_id,
|
||||||
|
"mutation": "feedback_fold",
|
||||||
|
"feedbackId": feedback_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = await pc_request(
|
||||||
|
"POST", f"/api/agents/{ceo_id}/wakeup",
|
||||||
|
json=payload, raise_on_error=True,
|
||||||
|
)
|
||||||
|
logger.info("feedback-fold wakeup queued: issue=%s feedback=%s", identifier, feedback_id)
|
||||||
|
return {"ok": True, "issue_id": issue_id, "identifier": identifier, "wakeup": resp.json()}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("wake_ceo_for_feedback_fold: wakeup API failed: %s", e)
|
||||||
|
return {"ok": False, "error": f"wakeup: {e}", "issue_id": issue_id, "identifier": identifier}
|
||||||
|
|
||||||
|
|
||||||
async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "") -> dict:
|
async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "") -> dict:
|
||||||
"""Wake the CEO agent via Paperclip's wakeup API.
|
"""Wake the CEO agent via Paperclip's wakeup API.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user