Add content checklists for block-yod and chair feedback system
Addresses Dafna's observation that licensing decisions lack comprehensive planning discussion. Systematic corpus analysis of all 24 training decisions revealed the system learned writing style but not substantive content. Changes: - Corpus analysis of all 24 decisions (docs/corpus-analysis.md) - 5 content checklists by appeal subtype injected into block-yod prompt - chair_feedback DB table + API endpoints + MCP tools - Feedback management page in Next.js UI (/feedback) - Navigation updated with "הערות יו״ר" link Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
240
docs/corpus-analysis.md
Normal file
240
docs/corpus-analysis.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# ניתוח שיטתי של קורפוס ההחלטות — מפת תוכן
|
||||||
|
|
||||||
|
> נוצר: 2026-04-12
|
||||||
|
> מקור: ניתוח 24 החלטות מתוך `/data/training/proofread/`
|
||||||
|
> מטרה: לחלץ דפוסי תוכן בפרק הדיון וההכרעה לפי סוג תיק
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. סקירה כללית של הקורפוס
|
||||||
|
|
||||||
|
### הרכב הקורפוס
|
||||||
|
| סוג | כמות | החלטות |
|
||||||
|
|-----|------|--------|
|
||||||
|
| רישוי ובנייה (1xxx) | 22 | כל ההחלטות מלבד גבאי ובית הכרם |
|
||||||
|
| תמ"א 38 | 1 | בית הכרם 1126+1141 |
|
||||||
|
| סמכות/סף בלבד | 1 | גבאי 1105 |
|
||||||
|
| **היטל השבחה (8xxx)** | **0** | **פער קריטי — אין אף החלטה בקורפוס** |
|
||||||
|
|
||||||
|
### תוצאות
|
||||||
|
| תוצאה | כמות | דוגמאות |
|
||||||
|
|-------|------|---------|
|
||||||
|
| דחייה | 12 | עמית, פרומר, זעיתר, בית שמש, אנשין (חניה), אהרן, שטרית, גבאי, ירושלים שקופה, לבנון, יפה |
|
||||||
|
| קבלה | 5 | טלי-אביב, הראל 1043+1054, הראל 1071+1077, מינץ, לוי |
|
||||||
|
| קבלה חלקית | 4 | אמיתי, בר-און, אנשין (1096), בית הכרם, אואקנין |
|
||||||
|
|
||||||
|
### אורך פרק הדיון
|
||||||
|
- טווח: 465 — 12,000 מילים
|
||||||
|
- ממוצע: ~5,000 מילים
|
||||||
|
- הקצר ביותר: גבאי (465, סמכות בלבד)
|
||||||
|
- הארוך ביותר: תורן/1015 (~11,000, שימוש חורג), מינץ/1071 (~12,000, סבב שני)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. נושאים שנמצאו בפרקי הדיון — מפת תוכן מלאה
|
||||||
|
|
||||||
|
### 2.1 נושאים תכנוניים (Planning Content)
|
||||||
|
|
||||||
|
| נושא | מופיע ב-X החלטות | עומק טיפוסי | דוגמאות בולטות |
|
||||||
|
|------|-----------------|-------------|----------------|
|
||||||
|
| **ניתוח הוראות תכנית** (ציטוט ישיר, פרשנות) | 18/24 | 3-15 סעיפים | פרומר (MI/200), לבנון (Hal/435), בית הכרם (10038, 16000) |
|
||||||
|
| **חניה** (חישוב, נספח תנועה, חלופות) | 8/24 | 5-15 סעיפים | אנשין-1096 (הרחבה), בית הכרם (הרחבה), אנשין-1109, לוי |
|
||||||
|
| **קווי בניין ומרווחים** | 7/24 | 3-10 סעיפים | אמיתי, אואקנין, שטרית, בר-און |
|
||||||
|
| **גובה/קומות** | 4/24 | 3-6 סעיפים | לבנון (הרחבה), בר-און, לוי |
|
||||||
|
| **סביבה ואופי שכונה** | 6/24 | 2-5 סעיפים | זעיתר (טיפולוגיה), פרומר (חקלאי), בית הכרם |
|
||||||
|
| **שימושים מותרים/שימוש חורג** | 2/24 | 5-20 סעיפים | תורן (הרחבה חריגה), יפה |
|
||||||
|
| **שימור** | 2/24 | 2-5 סעיפים | בית הכרם, בר-און (עצים) |
|
||||||
|
| **טופוגרפיה/טיפולוגיה** | 2/24 | 3-8 סעיפים | זעיתר (הרחבה), לבנון |
|
||||||
|
| **תכנית אב כמסגרת** | 2/24 | 2-3 סעיפים | בית הכרם (16000), תורן (צור הדסה) |
|
||||||
|
| **אינטרס ציבורי (חיזוק/התחדשות)** | 2/24 | 3-8 סעיפים | בית הכרם (תמ"א 38), מינץ |
|
||||||
|
| **היררכיית תכניות** (ארצית→מחוזית→מקומית) | 3/24 | 5-12 סעיפים | פרומר (הרחבה), לבנון, תורן |
|
||||||
|
| **נספח בינוי** (ניתוח פרטני) | 5/24 | 3-8 סעיפים | לבנון, לוי, מינץ, בר-און, בית שמש |
|
||||||
|
| **פגיעה בשכנים** (צל, פרטיות, רעש) | 5/24 | 2-5 סעיפים | אמיתי, שטרית, בית הכרם, אואקנין, זעיתר |
|
||||||
|
| **עצים/נוף** | 3/24 | 1-3 סעיפים | בר-און, בית שמש, בית הכרם |
|
||||||
|
|
||||||
|
### 2.2 נושאים משפטיים (Legal Content)
|
||||||
|
|
||||||
|
| נושא | מופיע ב-X החלטות | עומק טיפוסי |
|
||||||
|
|------|-----------------|-------------|
|
||||||
|
| **סמכות/זכות ערר** (ס' 152, 12ב) | 10/24 | 3-12 סעיפים |
|
||||||
|
| **הלכת שפר** (ערר על היתר תואם תכנית) | 8/24 | 2-5 סעיפים |
|
||||||
|
| **תימוכין קנייניים** (property feasibility) | 6/24 | 5-15 סעיפים |
|
||||||
|
| **סטייה ניכרת** (תקנה 2) | 5/24 | 3-8 סעיפים |
|
||||||
|
| **שיהוי** (delay/laches) | 5/24 | 2-5 סעיפים |
|
||||||
|
| **מידתיות** (proportionality) | 4/24 | 2-5 סעיפים |
|
||||||
|
| **עבריינות בנייה** (building violations) | 6/24 | 2-5 סעיפים |
|
||||||
|
| **שיקול דעת הוועדה המקומית** | 8/24 | 2-5 סעיפים |
|
||||||
|
| **קניין vs. תכנון** (הפרדת סמכויות) | 7/24 | 3-10 סעיפים |
|
||||||
|
| **הכשרת בנייה קיימת** (regularization) | 3/24 | 5-12 סעיפים |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. דפוסי "דיון תכנוני" שזוהו
|
||||||
|
|
||||||
|
### 3.1 מתי דפנה מקיימת דיון תכנוני מקיף?
|
||||||
|
|
||||||
|
**תמיד** כאשר:
|
||||||
|
- הערר עוסק בהתאמה לתכנית (ייעוד, שימוש, גובה, בנייה)
|
||||||
|
- יש שאלה של סטייה מהוראות תכנית
|
||||||
|
- הנושא הוא חניה/תשתיות
|
||||||
|
- התיק מערב תמ"א 38 או התחדשות עירונית
|
||||||
|
|
||||||
|
**לעולם לא** כאשר:
|
||||||
|
- התיק הוא סף/סמכות בלבד (גבאי)
|
||||||
|
- השאלה היא קניינית טהורה (טלי-אביב/1043+1054, בית שמש/1180+1181)
|
||||||
|
|
||||||
|
**עומק משתנה** כאשר:
|
||||||
|
- יש מספר נושאים שחלקם תכנוניים — הדיון התכנוני מוגבל לנושאים הרלוונטיים
|
||||||
|
|
||||||
|
### 3.2 איך דפנה בונה דיון תכנוני — הדפוס
|
||||||
|
|
||||||
|
**שלב 1: הקשר תכנוני רחב (2-8 סעיפים)**
|
||||||
|
- תכניות חלות ברמה הרלוונטית (מקומית, מחוזית, ארצית)
|
||||||
|
- ייעוד הקרקע, שימושים מותרים
|
||||||
|
- אופי הסביבה, מרקם בנוי
|
||||||
|
- *דוגמה*: פרומר — 12 סעיפים על MI/200, TAMA 35, TAMAM 30/1
|
||||||
|
|
||||||
|
**שלב 2: ציטוט ישיר מהוראות תכנית (3-15 סעיפים)**
|
||||||
|
- בלוקים ארוכים (200-600 מילים) של הוראות תכנית
|
||||||
|
- הדגשות בולד על המילים הרלוונטיות
|
||||||
|
- "הדגשת הח"מ" / "הדגשת הח.מ."
|
||||||
|
- *דוגמה*: בית הכרם — 400+ מילים מהוראות חניה של תכנית 5166ב
|
||||||
|
|
||||||
|
**שלב 3: יישום על המקרה הספציפי (3-8 סעיפים)**
|
||||||
|
- הוראה → עובדה → מסקנה
|
||||||
|
- "הנה מה שאומרת התכנית, הנה מה שקורה בפועל, הנה המסקנה"
|
||||||
|
- *דוגמה*: לבנון — השוואת חתכים של נספח בינוי עם הבקשה
|
||||||
|
|
||||||
|
**שלב 4: מסקנה תכנונית (1-3 סעיפים)**
|
||||||
|
- האם הבקשה תואמת/סוטה
|
||||||
|
- האם הסטייה מוצדקת
|
||||||
|
- מה צריך לתקן
|
||||||
|
|
||||||
|
### 3.3 הדפוס של "דיון תכנוני" לפי סוג נושא
|
||||||
|
|
||||||
|
| נושא | סדר ניתוח טיפוסי | רמת עומק |
|
||||||
|
|------|-----------------|----------|
|
||||||
|
| **חניה** | הוראות תכנית → תקנות חניה → נספח תנועה → חישוב → חלופות (קרן חניה, חפיפה, תחבורה ציבורית) | עמוק מאוד (8-15 סעיפים) |
|
||||||
|
| **קווי בניין** | הוראת תכנית → סטייה ניכרת? (תקנה 2(19)) → מידתיות → פגיעה בשכנים | בינוני-עמוק (5-10 סעיפים) |
|
||||||
|
| **גובה** | הוראת תכנית → נספח בינוי → מטרת ההגבלה → סטייה ניכרת? | בינוני (4-8 סעיפים) |
|
||||||
|
| **ייעוד/שימוש** | פרשנות תכנית → היררכיית תכניות → פרשנות מהותית → יישום | עמוק מאוד (10-20 סעיפים) |
|
||||||
|
| **שכנות** | עובדות (סיור) → השפעה (צל, פרטיות, רעש) → מידתיות | בינוני (3-6 סעיפים) |
|
||||||
|
| **סביבה** | תכנית אב → אופי שכונה → מרקם → השתלבות | בינוני (3-5 סעיפים) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. דפוסים חוצי-החלטות
|
||||||
|
|
||||||
|
### 4.1 מבנה הדיון — סדר הנושאים
|
||||||
|
1. **שאלות סף** (אם יש) — סמכות, זכות ערר, שיהוי
|
||||||
|
2. **הקשר תכנוני רחב** — תכניות, ייעוד, סביבה
|
||||||
|
3. **ניתוח ענייני** — נושא אחר נושא, כל אחד ב-CREAC
|
||||||
|
4. **מענה לטענות ספציפיות** — עובר על כל טענה מבלוק ז
|
||||||
|
5. **מסקנה** — תוצאה + הוראות אופרטיביות
|
||||||
|
|
||||||
|
### 4.2 כמה תכנון יש בכל החלטה?
|
||||||
|
|
||||||
|
| דרגה | תיאור | החלטות |
|
||||||
|
|------|-------|--------|
|
||||||
|
| **כבד** (>50% תכנון) | הדיון הוא בעיקר תכנוני | פרומר, זעיתר, בית הכרם, תורן, לבנון |
|
||||||
|
| **מאוזן** (30-50%) | שילוב תכנון + משפט | עמית, אמיתי, בר-און, אנשין-1096, אואקנין, לוי, שטרית |
|
||||||
|
| **קל** (<30%) | בעיקר משפטי, תכנון מינימלי | בית שמש, אנשין-1109, יפה |
|
||||||
|
| **אין** (0%) | רק משפטי/סמכות | טלי-אביב, הראל 1043+1054, גבאי, ירושלים שקופה |
|
||||||
|
|
||||||
|
### 4.3 פסיקה חוזרת (Recurring Case Law)
|
||||||
|
| פסיקה | נושא | מופיעה ב-X החלטות |
|
||||||
|
|-------|------|-------------------|
|
||||||
|
| הלכת שפר (עע"מ 317/10) | ערר על היתר תואם תכנית | 8 |
|
||||||
|
| הלכת עייזן (בג"ץ 1578/90) | תימוכין קנייניים | 6 |
|
||||||
|
| הלכת בן-יקר-גת | סטייה ניכרת | 4 |
|
||||||
|
| ערר אדלר 1181/22 | שיקול דעת תמ"א 38 | 2 |
|
||||||
|
| עע"מ 3975/22 קרן נכסים | קניין vs. תכנון | 4 |
|
||||||
|
|
||||||
|
### 4.4 טכניקות ניתוח ייחודיות
|
||||||
|
1. **פרשנות הרמונית** — כשיש מספר תכניות, דפנה מפרשת אותן ביחד (תורן)
|
||||||
|
2. **בדיקת תקדימים עובדתית** — הוועדה בדקה בעצמה 3 נכסים שנטענו כתקדים (לבנון)
|
||||||
|
3. **ציטוט מהחלטה מרכזת** — במקום לצטט 7 פס"ד, מצטטת אחד שריכז את כולם
|
||||||
|
4. **מבחן "המגרש הריק"** — להכשרת בנייה קיימת (אמיתי)
|
||||||
|
5. **מיפוי מתחים** — רשימת 3-6 מתחים לפני הניתוח (בית הכרם)
|
||||||
|
6. **"למעלה מן הצורך"** — דיון obiter אחרי הכרעה בסף (עמית, בית שמש)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. פערים שזוהו
|
||||||
|
|
||||||
|
### 5.1 פער קריטי: אין החלטות היטל השבחה בקורפוס
|
||||||
|
למרות שהמערכת מגדירה 3 סוגי עררים (רישוי, היטל השבחה, פיצויים) — **כל 24 ההחלטות הן רישוי ובנייה**. אין לנו אף מודל לכתיבת החלטה בהיטל השבחה.
|
||||||
|
|
||||||
|
### 5.2 פער: לא כל נושא תכנוני מכוסה
|
||||||
|
נושאים שמופיעים רק בהחלטה אחת-שתיים:
|
||||||
|
- שימור → רק בית הכרם
|
||||||
|
- תמ"א 38 → רק בית הכרם
|
||||||
|
- שימוש חורג → רק תורן
|
||||||
|
- טיפולוגיה/טופוגרפיה → רק זעיתר
|
||||||
|
- תכנית אב כמסגרת → רק בית הכרם + תורן
|
||||||
|
|
||||||
|
### 5.3 פער: הפרומפט הנוכחי לא מכיל "צ'קליסט תוכן"
|
||||||
|
הפרומפט של block-yod (שורות 198-234 ב-block_writer.py) אומר:
|
||||||
|
- ✅ CREAC methodology
|
||||||
|
- ✅ ענה על כל טענה
|
||||||
|
- ✅ צטט פסיקה
|
||||||
|
- ❌ **אין**: "בתיק רישוי, כסה את הנושאים התכנוניים הרלוונטיים"
|
||||||
|
- ❌ **אין**: צ'קליסט תוכן לפי סוג ערר
|
||||||
|
- ❌ **אין**: "הקשר תכנוני רחב" כמרכיב חובה
|
||||||
|
|
||||||
|
### 5.4 פער: הבחנה לא מספיקה בין תת-סוגי רישוי
|
||||||
|
תיקי רישוי שונים מאוד זה מזה:
|
||||||
|
- **סמכות/סף** — דיון משפטי טהור, אין צורך בתכנון
|
||||||
|
- **קנייני** — תימוכין קנייניים, אין צורך בתכנון
|
||||||
|
- **תכנוני מובהק** — ייעוד, חניה, גובה — דיון תכנוני מקיף
|
||||||
|
- **שימוש חורג** — פרשנות תכניות, דיון תכנוני עמוק
|
||||||
|
- **הקלה** — מידתיות + תכנון
|
||||||
|
- **תמ"א 38** — איזון אינטרסים + תכנון
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. המלצות לשלב הבא
|
||||||
|
|
||||||
|
### 6.1 צ'קליסט תוכן מוצע לערר רישוי ובנייה
|
||||||
|
```
|
||||||
|
בהתאם לנושא הערר, הדיון צריך לכלול:
|
||||||
|
|
||||||
|
□ הקשר תכנוני רחב (תמיד כשהערר מגיע למריט):
|
||||||
|
- תכניות חלות (מקומית, מחוזית, ארצית — לפי הצורך)
|
||||||
|
- ייעוד הקרקע
|
||||||
|
- אופי הסביבה
|
||||||
|
|
||||||
|
□ ניתוח הוראות תכנית (כשיש שאלה של התאמה/סטייה):
|
||||||
|
- ציטוט ישיר מהוראות רלוונטיות
|
||||||
|
- פרשנות — תכלית ההוראה
|
||||||
|
- יישום על המקרה
|
||||||
|
|
||||||
|
□ חניה (כשרלוונטי):
|
||||||
|
- הוראות תכנית + נספח תנועה
|
||||||
|
- חישוב מקומות נדרשים vs. מסופקים
|
||||||
|
- חלופות (קרן חניה, חפיפה, תח"צ)
|
||||||
|
|
||||||
|
□ שכנות/פגיעה (כשרלוונטי):
|
||||||
|
- ממצאי סיור
|
||||||
|
- צל, פרטיות, רעש, נוף
|
||||||
|
- מידתיות
|
||||||
|
|
||||||
|
□ קווי בניין (כשרלוונטי):
|
||||||
|
- הוראת תכנית
|
||||||
|
- סטייה ניכרת — תקנה 2(19)
|
||||||
|
- הצדקה/מידתיות
|
||||||
|
|
||||||
|
□ גובה/קומות (כשרלוונטי):
|
||||||
|
- הוראת תכנית + נספח בינוי
|
||||||
|
- מטרת ההגבלה
|
||||||
|
- סטייה ניכרת — תקנה 2(10)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 הבחנה בין תת-סוגים
|
||||||
|
הפרומפט צריך לזהות את סוג הערר ולהתאים את הצ'קליסט:
|
||||||
|
- **ערר סמכות/סף** → ללא דיון תכנוני
|
||||||
|
- **ערר קנייני** → דיון משפטי, ללא תכנון
|
||||||
|
- **ערר מהותי** → דיון תכנוני מקיף + משפטי
|
||||||
|
|
||||||
|
### 6.3 צורך דחוף: החלטות היטל השבחה
|
||||||
|
צריך להוסיף לקורפוס לפחות 5-10 החלטות של היטל השבחה לפני שהמערכת יכולה לכתוב החלטות בתחום הזה.
|
||||||
@@ -346,6 +346,29 @@ async def ingest_final_version(
|
|||||||
return await workflow.ingest_final_version(case_number, file_path, final_text)
|
return await workflow.ingest_final_version(case_number, file_path, final_text)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def record_chair_feedback(
|
||||||
|
case_number: str,
|
||||||
|
feedback_text: str,
|
||||||
|
block_id: str = "block-yod",
|
||||||
|
category: str = "missing_content",
|
||||||
|
lesson_extracted: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""תיעוד הערת יו"ר (דפנה) על טיוטת החלטה — חסר, שגיאה, סגנון."""
|
||||||
|
return await workflow.record_chair_feedback(
|
||||||
|
case_number, feedback_text, block_id, category, lesson_extracted,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def list_chair_feedback(
|
||||||
|
case_number: str = "",
|
||||||
|
category: str = "",
|
||||||
|
unresolved_only: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""הצגת הערות יו"ר שתועדו — אפשר לסנן לפי תיק, קטגוריה, מטופלות."""
|
||||||
|
return await workflow.list_chair_feedback(case_number, category, unresolved_only)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
mcp.run(transport="stdio")
|
mcp.run(transport="stdio")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import db, embeddings, claude_session
|
from legal_mcp.services import db, embeddings, claude_session
|
||||||
|
from legal_mcp.services.lessons import get_content_checklist
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -215,6 +216,8 @@ BLOCK_PROMPTS = {
|
|||||||
- **ללא כותרות משנה** (חריג: נושאים נפרדים לחלוטין)
|
- **ללא כותרות משנה** (חריג: נושאים נפרדים לחלוטין)
|
||||||
- מספור רציף
|
- מספור רציף
|
||||||
|
|
||||||
|
{content_checklist}
|
||||||
|
|
||||||
## כיוון מאושר (חובה):
|
## כיוון מאושר (חובה):
|
||||||
{direction_context}
|
{direction_context}
|
||||||
|
|
||||||
@@ -310,6 +313,15 @@ async def write_block(
|
|||||||
outcome = (decision or {}).get("outcome", "rejected")
|
outcome = (decision or {}).get("outcome", "rejected")
|
||||||
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
||||||
|
|
||||||
|
# Content checklist — tells block-yod WHAT topics to cover
|
||||||
|
content_checklist = ""
|
||||||
|
if block_id == "block-yod":
|
||||||
|
content_checklist = get_content_checklist(
|
||||||
|
appeal_type=case.get("appeal_type", ""),
|
||||||
|
subject=case.get("subject", ""),
|
||||||
|
subject_categories=case.get("subject_categories", []),
|
||||||
|
)
|
||||||
|
|
||||||
# Format prompt — per Anthropic long-context best practices:
|
# Format prompt — per Anthropic long-context best practices:
|
||||||
# Place source documents FIRST (top of prompt), instructions LAST.
|
# Place source documents FIRST (top of prompt), instructions LAST.
|
||||||
# "Queries at the end can improve response quality by up to 30%"
|
# "Queries at the end can improve response quality by up to 30%"
|
||||||
@@ -323,6 +335,7 @@ async def write_block(
|
|||||||
style_context=style_context,
|
style_context=style_context,
|
||||||
discussion_context=discussion_context,
|
discussion_context=discussion_context,
|
||||||
structure_guidance=structure_guidance,
|
structure_guidance=structure_guidance,
|
||||||
|
content_checklist=content_checklist,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Restructure: sources first, then instructions
|
# Restructure: sources first, then instructions
|
||||||
|
|||||||
@@ -358,6 +358,22 @@ CREATE TABLE IF NOT EXISTS case_law_embeddings (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════
|
||||||
|
-- Chair Feedback (הערות דפנה על טיוטות)
|
||||||
|
-- ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chair_feedback (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
case_id UUID REFERENCES cases(id) ON DELETE SET NULL,
|
||||||
|
block_id TEXT DEFAULT '', -- block-yod, block-vav, etc.
|
||||||
|
feedback_text TEXT NOT NULL, -- ההערה של דפנה
|
||||||
|
category TEXT DEFAULT 'other', -- missing_content/wrong_tone/wrong_structure/factual_error/style/other
|
||||||
|
lesson_extracted TEXT DEFAULT '', -- הלקח שהופק
|
||||||
|
applied_to TEXT[] DEFAULT '{}', -- לאילו קבצים/כללים הלקח יושם
|
||||||
|
resolved BOOLEAN DEFAULT FALSE, -- האם הלקח יושם
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
-- ═══════════════════════════════════════════════════════════════════
|
-- ═══════════════════════════════════════════════════════════════════
|
||||||
-- Indexes
|
-- Indexes
|
||||||
-- ═══════════════════════════════════════════════════════════════════
|
-- ═══════════════════════════════════════════════════════════════════
|
||||||
@@ -986,3 +1002,72 @@ async def search_precedents(
|
|||||||
|
|
||||||
results.sort(key=lambda x: x["score"], reverse=True)
|
results.sort(key=lambda x: x["score"], reverse=True)
|
||||||
return results[:limit]
|
return results[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Chair feedback ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def record_chair_feedback(
|
||||||
|
case_id: UUID | None,
|
||||||
|
block_id: str,
|
||||||
|
feedback_text: str,
|
||||||
|
category: str = "other",
|
||||||
|
lesson_extracted: str = "",
|
||||||
|
) -> UUID:
|
||||||
|
"""Record feedback from the chair (Dafna) on a draft block."""
|
||||||
|
pool = await get_pool()
|
||||||
|
feedback_id = uuid4()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO chair_feedback
|
||||||
|
(id, case_id, block_id, feedback_text, category, lesson_extracted)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)""",
|
||||||
|
feedback_id, case_id, block_id, feedback_text, category,
|
||||||
|
lesson_extracted,
|
||||||
|
)
|
||||||
|
return feedback_id
|
||||||
|
|
||||||
|
|
||||||
|
async def list_chair_feedback(
|
||||||
|
case_id: UUID | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
unresolved_only: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List chair feedback, optionally filtered."""
|
||||||
|
pool = await get_pool()
|
||||||
|
conditions = []
|
||||||
|
params: list = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
if case_id:
|
||||||
|
conditions.append(f"case_id = ${idx}")
|
||||||
|
params.append(case_id)
|
||||||
|
idx += 1
|
||||||
|
if category:
|
||||||
|
conditions.append(f"category = ${idx}")
|
||||||
|
params.append(category)
|
||||||
|
idx += 1
|
||||||
|
if unresolved_only:
|
||||||
|
conditions.append("resolved = FALSE")
|
||||||
|
|
||||||
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
f"SELECT * FROM chair_feedback {where} ORDER BY created_at DESC",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_chair_feedback(
|
||||||
|
feedback_id: UUID,
|
||||||
|
applied_to: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Mark feedback as resolved and record where it was applied."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""UPDATE chair_feedback
|
||||||
|
SET resolved = TRUE, applied_to = $2
|
||||||
|
WHERE id = $1""",
|
||||||
|
feedback_id, applied_to,
|
||||||
|
)
|
||||||
|
|||||||
@@ -329,3 +329,193 @@ def format_ratios_comment(outcome: str, section: str) -> str:
|
|||||||
lo, hi = ratios[section]
|
lo, hi = ratios[section]
|
||||||
return f"יעד: {lo}-{hi}% מסך ההחלטה"
|
return f"יעד: {lo}-{hi}% מסך ההחלטה"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Content checklists by appeal subtype ──────────────────────────
|
||||||
|
# Based on systematic analysis of 24 decisions from Dafna's corpus.
|
||||||
|
# See: docs/corpus-analysis.md
|
||||||
|
|
||||||
|
CONTENT_CHECKLISTS: dict[str, str] = {
|
||||||
|
"licensing_substantive": """## צ'קליסט תוכן — ערר רישוי מהותי (חובה)
|
||||||
|
הדיון חייב לכלול את הנושאים הרלוונטיים מהרשימה הבאה.
|
||||||
|
**אל תדלג על נושא שרלוונטי לתיק — בדוק כל סעיף.**
|
||||||
|
|
||||||
|
### א. הקשר תכנוני רחב (חובה בכל ערר מהותי)
|
||||||
|
- תכניות חלות — ציין את התכניות הרלוונטיות ברמה מקומית, מחוזית וארצית (לפי הצורך)
|
||||||
|
- ייעוד הקרקע — מה הייעוד בתכנית? מה השימושים המותרים?
|
||||||
|
- אופי הסביבה — מרקם בנוי, צפיפות, אופי שכונה/ישוב
|
||||||
|
- *דוגמה*: בערר פרומר — 12 סעיפים על MI/200, תמ"א 35, תמ"מ 30/1
|
||||||
|
|
||||||
|
### ב. ניתוח הוראות תכנית (כשיש שאלה של התאמה/סטייה)
|
||||||
|
- ציטוט ישיר מהוראות התכנית הרלוונטיות (200-600 מילים לכל ציטוט)
|
||||||
|
- פרשנות — מה תכלית ההוראה?
|
||||||
|
- יישום — האם הבקשה תואמת או סוטה?
|
||||||
|
- *דוגמה*: בערר לבנון — ניתוח חתכים של נספח בינוי מול הבקשה
|
||||||
|
|
||||||
|
### ג. חניה (כשרלוונטי — מופיע ב-8 מתוך 24 החלטות)
|
||||||
|
- הוראות תכנית + נספח תנועה (ציטוט ישיר)
|
||||||
|
- חישוב מקומות חניה נדרשים vs. מסופקים
|
||||||
|
- חלופות: קרן חניה, חפיפת שימושים, קרבה לתח"צ
|
||||||
|
- *דוגמה*: בערר בית הכרם — 8 סעיפים, 400+ מילים מהוראות תכנית 5166ב
|
||||||
|
|
||||||
|
### ד. קווי בניין ומרווחים (כשרלוונטי)
|
||||||
|
- הוראת תכנית על מרווחים
|
||||||
|
- סטייה ניכרת? — תקנה 2(19) / הלכת בן-יקר-גת
|
||||||
|
- הצדקה + מידתיות — פגיעה בשכנים?
|
||||||
|
|
||||||
|
### ה. גובה וקומות (כשרלוונטי)
|
||||||
|
- הוראת תכנית + נספח בינוי (חתכים)
|
||||||
|
- מטרת ההגבלה — למה יש הגבלת גובה כאן?
|
||||||
|
- סטייה ניכרת — תקנה 2(10) / 2(8)
|
||||||
|
|
||||||
|
### ו. פגיעה בשכנים (כשרלוונטי)
|
||||||
|
- ממצאי סיור באתר
|
||||||
|
- השפעה: צל, פרטיות, רעש, נוף
|
||||||
|
- מידתיות — האם הפגיעה סבירה?
|
||||||
|
|
||||||
|
### ז. שימוש חורג (כשרלוונטי)
|
||||||
|
- מה השימוש המותר בתכנית? מה השימוש המבוקש?
|
||||||
|
- "מבחן ההתאמה" — האם השימוש מתאים למיקום?
|
||||||
|
- תנאים ומגבלות
|
||||||
|
""",
|
||||||
|
|
||||||
|
"licensing_threshold": """## צ'קליסט תוכן — ערר רישוי סף/סמכות
|
||||||
|
הערר עוסק בשאלות סף — אין צורך בדיון תכנוני מקיף.
|
||||||
|
|
||||||
|
### א. שאלת הסמכות
|
||||||
|
- סעיפי חוק רלוונטיים (ס' 12ב, 152, וכו')
|
||||||
|
- פסיקה על גבולות הסמכות
|
||||||
|
|
||||||
|
### ב. זכות ערר
|
||||||
|
- מי רשאי לערור? באיזה מסלול?
|
||||||
|
- הלכת שפר (עע"מ 317/10) — כשרלוונטית
|
||||||
|
|
||||||
|
### ג. שיהוי (אם רלוונטי)
|
||||||
|
""",
|
||||||
|
|
||||||
|
"licensing_property": """## צ'קליסט תוכן — ערר רישוי קנייני
|
||||||
|
הערר עוסק בעיקר בשאלת תימוכין קנייניים — דיון משפטי.
|
||||||
|
|
||||||
|
### א. מסגרת נורמטיבית
|
||||||
|
- הלכת עייזן, בני אליעזר, רוזן — "היתכנות קניינית"
|
||||||
|
- ס' 71ב לחוק המקרקעין
|
||||||
|
|
||||||
|
### ב. בחינת הראיות
|
||||||
|
- הסכמות, רישום, היסטוריית בנייה
|
||||||
|
- חלוקה דה-פקטו ארוכת שנים
|
||||||
|
|
||||||
|
### ג. הפרדה בין קניין לתכנון
|
||||||
|
- גוף תכנוני אינו מכריע בסכסוכי קניין
|
||||||
|
- "היתכנות קניינית" ≠ הוכחת בעלות
|
||||||
|
|
||||||
|
### ד. שאלות תכנוניות (אם רלוונטיות)
|
||||||
|
- אם הערר עולה גם שאלות תכנוניות — דון בהן בנפרד
|
||||||
|
""",
|
||||||
|
|
||||||
|
"tama38": """## צ'קליסט תוכן — ערר תמ"א 38
|
||||||
|
הדיון חייב לאזן בין אינטרס ציבורי לפגיעה בשכנים.
|
||||||
|
|
||||||
|
### א. אינטרס ציבורי — חיזוק/התחדשות
|
||||||
|
- עוצמת האינטרס — בניין גדול vs. בית בודד
|
||||||
|
- "בית בודד" מחליש את אינטרס החיזוק
|
||||||
|
- תרומה לרקמה העירונית
|
||||||
|
|
||||||
|
### ב. תכנית אב / מדיניות אזורית
|
||||||
|
- האם יש תכנית אב? מדיניות 16000?
|
||||||
|
- התאמה לראיה כללית vs. אד-הוק
|
||||||
|
|
||||||
|
### ג. ניתוח השוואתי
|
||||||
|
- זכויות לפי תכנית קיימת vs. מבוקש לפי תמ"א 38
|
||||||
|
- שטחים, קומות, קווי בניין — טבלת השוואה
|
||||||
|
|
||||||
|
### ד. שימור (כשרלוונטי)
|
||||||
|
- חוות דעת אגף שימור
|
||||||
|
- השפעה על מיקום/צורת הבניין
|
||||||
|
|
||||||
|
### ה. חניה (כמעט תמיד רלוונטי)
|
||||||
|
- הוראות תכנית + ס' 17 לתמ"א 38
|
||||||
|
- פטורים — קרבה לתח"צ, קרן חניה, תכנית אב
|
||||||
|
- ניתוח מפורט של חלופות
|
||||||
|
|
||||||
|
### ו. פגיעה בשכנים
|
||||||
|
- ממצאי סיור
|
||||||
|
- צל, פרטיות, קרבה
|
||||||
|
- מידתיות — מה הפגיעה ביחס לתועלת?
|
||||||
|
|
||||||
|
### ז. מטרדי בנייה
|
||||||
|
- "מטרד בנייה אינו עילה לסירוב" — אך תנאים נדרשים
|
||||||
|
- תכנית ארגון אתר
|
||||||
|
""",
|
||||||
|
|
||||||
|
"betterment_levy": """## צ'קליסט תוכן — ערר היטל השבחה
|
||||||
|
⚠️ שים לב: אין עדיין החלטות היטל השבחה בקורפוס האימון.
|
||||||
|
הצ'קליסט הזה מבוסס על ידע כללי — לא על ניתוח ספציפי של סגנון דפנה.
|
||||||
|
|
||||||
|
### א. המסגרת הנורמטיבית
|
||||||
|
- התוספת השלישית לחוק התכנון והבנייה
|
||||||
|
- אירוע מס — מה יצר את ההשבחה?
|
||||||
|
|
||||||
|
### ב. שומה
|
||||||
|
- שיטת השומה (שומה מכרעת / שמאי מייעץ)
|
||||||
|
- מועד הקובע
|
||||||
|
- זכויות בנייה — לפני ואחרי
|
||||||
|
|
||||||
|
### ג. שאלות משפטיות
|
||||||
|
- פטורים (ס' 19)
|
||||||
|
- מועדי תשלום
|
||||||
|
- שיערוך
|
||||||
|
|
||||||
|
### ד. ניתוח שמאי
|
||||||
|
- האם השומה תקינה?
|
||||||
|
- פערים בין השומות
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_content_checklist(
|
||||||
|
appeal_type: str = "",
|
||||||
|
subject: str = "",
|
||||||
|
subject_categories: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Return the appropriate content checklist based on case characteristics.
|
||||||
|
|
||||||
|
Determines the subtype from case metadata:
|
||||||
|
- TAMA 38 cases → tama38 checklist
|
||||||
|
- Betterment levy (8xxx) → betterment_levy checklist
|
||||||
|
- Property-only cases → licensing_property checklist
|
||||||
|
- Threshold/jurisdiction cases → licensing_threshold checklist
|
||||||
|
- All other licensing → licensing_substantive checklist
|
||||||
|
"""
|
||||||
|
cats = subject_categories or []
|
||||||
|
subject_lower = subject.lower() if subject else ""
|
||||||
|
appeal_lower = appeal_type.lower() if appeal_type else ""
|
||||||
|
|
||||||
|
# TAMA 38
|
||||||
|
if any(
|
||||||
|
kw in subject_lower
|
||||||
|
for kw in ["תמ\"א 38", "תמא 38", "תמ\"א38", "חיזוק", "tama"]
|
||||||
|
) or "תמ\"א 38" in cats:
|
||||||
|
return CONTENT_CHECKLISTS["tama38"]
|
||||||
|
|
||||||
|
# Betterment levy
|
||||||
|
if "היטל השבחה" in appeal_lower or "betterment" in appeal_lower or any(
|
||||||
|
"היטל" in c for c in cats
|
||||||
|
):
|
||||||
|
return CONTENT_CHECKLISTS["betterment_levy"]
|
||||||
|
|
||||||
|
# Property-focused (תימוכין קנייניים)
|
||||||
|
if any(
|
||||||
|
kw in subject_lower
|
||||||
|
for kw in ["תימוכין", "קנייני", "בעלות", "הסכמת דיירים"]
|
||||||
|
):
|
||||||
|
return CONTENT_CHECKLISTS["licensing_property"]
|
||||||
|
|
||||||
|
# Threshold/jurisdiction
|
||||||
|
if any(
|
||||||
|
kw in subject_lower
|
||||||
|
for kw in ["סמכות", "סף", "סילוק על הסף", "זכות ערר"]
|
||||||
|
):
|
||||||
|
return CONTENT_CHECKLISTS["licensing_threshold"]
|
||||||
|
|
||||||
|
# Default: substantive licensing
|
||||||
|
return CONTENT_CHECKLISTS["licensing_substantive"]
|
||||||
|
|||||||
@@ -318,3 +318,97 @@ async def ingest_final_version(
|
|||||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
|
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Chair feedback tools ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def record_chair_feedback(
|
||||||
|
case_number: str,
|
||||||
|
feedback_text: str,
|
||||||
|
block_id: str = "block-yod",
|
||||||
|
category: str = "missing_content",
|
||||||
|
lesson_extracted: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""תיעוד הערת יו"ר (דפנה) על טיוטת החלטה.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: מספר תיק הערר
|
||||||
|
feedback_text: ההערה של דפנה (מה חסר, מה לא נכון, מה צריך לשנות)
|
||||||
|
block_id: הבלוק שההערה מתייחסת אליו (ברירת מחדל: block-yod)
|
||||||
|
category: קטגוריה — missing_content/wrong_tone/wrong_structure/factual_error/style/other
|
||||||
|
lesson_extracted: הלקח שהופק מההערה (אם ברור כבר)
|
||||||
|
"""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
case_id = UUID(case["id"]) if case else None
|
||||||
|
|
||||||
|
valid_categories = [
|
||||||
|
"missing_content", "wrong_tone", "wrong_structure",
|
||||||
|
"factual_error", "style", "other",
|
||||||
|
]
|
||||||
|
if category not in valid_categories:
|
||||||
|
return f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}"
|
||||||
|
|
||||||
|
feedback_id = await db.record_chair_feedback(
|
||||||
|
case_id=case_id,
|
||||||
|
block_id=block_id,
|
||||||
|
feedback_text=feedback_text,
|
||||||
|
category=category,
|
||||||
|
lesson_extracted=lesson_extracted,
|
||||||
|
)
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"status": "ok",
|
||||||
|
"feedback_id": str(feedback_id),
|
||||||
|
"message": f"הערה נרשמה בהצלחה. קטגוריה: {category}.",
|
||||||
|
"next_steps": [
|
||||||
|
"כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback",
|
||||||
|
"כדי לסמן כמטופל: resolve_chair_feedback",
|
||||||
|
],
|
||||||
|
}, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_chair_feedback(
|
||||||
|
case_number: str = "",
|
||||||
|
category: str = "",
|
||||||
|
unresolved_only: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""הצגת הערות יו"ר שתועדו, עם אפשרות סינון.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: סינון לפי תיק (אם ריק — כל ההערות)
|
||||||
|
category: סינון לפי קטגוריה
|
||||||
|
unresolved_only: האם להציג רק הערות שלא טופלו (ברירת מחדל: כן)
|
||||||
|
"""
|
||||||
|
case_id = None
|
||||||
|
if case_number:
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if case:
|
||||||
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
|
feedbacks = await db.list_chair_feedback(
|
||||||
|
case_id=case_id,
|
||||||
|
category=category or None,
|
||||||
|
unresolved_only=unresolved_only,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not feedbacks:
|
||||||
|
return "אין הערות שמתאימות לסינון."
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for fb in feedbacks:
|
||||||
|
items.append({
|
||||||
|
"id": str(fb["id"]),
|
||||||
|
"case_id": str(fb["case_id"]) if fb["case_id"] else None,
|
||||||
|
"block_id": fb["block_id"],
|
||||||
|
"category": fb["category"],
|
||||||
|
"feedback": fb["feedback_text"],
|
||||||
|
"lesson": fb["lesson_extracted"],
|
||||||
|
"resolved": fb["resolved"],
|
||||||
|
"date": fb["created_at"].isoformat() if fb.get("created_at") else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"total": len(items),
|
||||||
|
"feedbacks": items,
|
||||||
|
}, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|||||||
329
web-ui/src/app/feedback/page.tsx
Normal file
329
web-ui/src/app/feedback/page.tsx
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
useFeedbackList,
|
||||||
|
useCreateFeedback,
|
||||||
|
useResolveFeedback,
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
BLOCK_LABELS,
|
||||||
|
type FeedbackCategory,
|
||||||
|
} from "@/lib/api/feedback";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<FeedbackCategory, string> = {
|
||||||
|
missing_content: "bg-amber-100 text-amber-800 border-amber-200",
|
||||||
|
wrong_tone: "bg-purple-100 text-purple-800 border-purple-200",
|
||||||
|
wrong_structure: "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
|
factual_error: "bg-red-100 text-red-800 border-red-200",
|
||||||
|
style: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||||
|
other: "bg-gray-100 text-gray-800 border-gray-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FeedbackPage() {
|
||||||
|
const [showResolved, setShowResolved] = useState(false);
|
||||||
|
const [filterCategory, setFilterCategory] = useState<string>("");
|
||||||
|
|
||||||
|
const { data: feedbacks, isLoading } = useFeedbackList({
|
||||||
|
category: filterCategory || undefined,
|
||||||
|
unresolved_only: !showResolved,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveMutation = useResolveFeedback();
|
||||||
|
|
||||||
|
function handleResolve(id: string) {
|
||||||
|
resolveMutation.mutate(
|
||||||
|
{ feedbackId: id, applied_to: [] },
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.success("ההערה סומנה כמטופלת"),
|
||||||
|
onError: () => toast.error("שגיאה בעדכון"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<section className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||||
|
<Link href="/" className="hover:text-gold-deep">
|
||||||
|
בית
|
||||||
|
</Link>
|
||||||
|
<span aria-hidden> · </span>
|
||||||
|
<span className="text-navy">הערות יו״ר</span>
|
||||||
|
</nav>
|
||||||
|
<h1 className="text-navy mb-0">הערות יו״ר על טיוטות</h1>
|
||||||
|
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||||
|
תיעוד הערות דפנה על טיוטות החלטות. כל הערה מנותחת ומשפיעה על שיפור
|
||||||
|
כתיבת ההחלטות העתידיות.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<NewFeedbackDialog />
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filterCategory}
|
||||||
|
onChange={(e) => setFilterCategory(e.target.value)}
|
||||||
|
className="rounded-md border border-rule bg-surface px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">כל הקטגוריות</option>
|
||||||
|
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-ink-muted cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showResolved}
|
||||||
|
onChange={(e) => setShowResolved(e.target.checked)}
|
||||||
|
className="rounded border-rule"
|
||||||
|
/>
|
||||||
|
הצג גם מטופלות
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{feedbacks && (
|
||||||
|
<span className="text-sm text-ink-muted me-auto">
|
||||||
|
{feedbacks.length} הערות
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback list */}
|
||||||
|
{isLoading ? (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-8 text-center text-ink-muted">
|
||||||
|
טוען...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : !feedbacks?.length ? (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-8 text-center text-ink-muted">
|
||||||
|
אין הערות{!showResolved ? " פתוחות" : ""}
|
||||||
|
{filterCategory ? ` בקטגוריה ${CATEGORY_LABELS[filterCategory as FeedbackCategory]}` : ""}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{feedbacks.map((fb) => (
|
||||||
|
<Card
|
||||||
|
key={fb.id}
|
||||||
|
className={`bg-surface border-rule shadow-sm ${fb.resolved ? "opacity-60" : ""}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="border-b pb-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge
|
||||||
|
className={`text-[0.7rem] border ${CATEGORY_COLORS[fb.category]}`}
|
||||||
|
>
|
||||||
|
{CATEGORY_LABELS[fb.category]}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-[0.7rem]">
|
||||||
|
{BLOCK_LABELS[fb.block_id] ?? fb.block_id}
|
||||||
|
</Badge>
|
||||||
|
{fb.case_number && (
|
||||||
|
<Link
|
||||||
|
href={`/cases/${fb.case_number}`}
|
||||||
|
className="text-[0.7rem] text-gold-deep hover:underline"
|
||||||
|
>
|
||||||
|
תיק {fb.case_number}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{fb.resolved && (
|
||||||
|
<Badge className="bg-emerald-100 text-emerald-700 text-[0.7rem] border border-emerald-200">
|
||||||
|
טופל
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-[0.7rem] text-ink-muted me-auto">
|
||||||
|
{fb.created_at
|
||||||
|
? new Date(fb.created_at).toLocaleDateString("he-IL")
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-6 py-4 space-y-3">
|
||||||
|
<p className="text-sm leading-relaxed">{fb.feedback_text}</p>
|
||||||
|
|
||||||
|
{fb.lesson_extracted && (
|
||||||
|
<div className="bg-gold/5 border border-gold/20 rounded-md px-4 py-3">
|
||||||
|
<p className="text-[0.7rem] font-semibold text-gold-deep mb-1">
|
||||||
|
לקח שהופק:
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-ink-muted leading-relaxed">
|
||||||
|
{fb.lesson_extracted}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fb.resolved && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleResolve(fb.id)}
|
||||||
|
disabled={resolveMutation.isPending}
|
||||||
|
>
|
||||||
|
סמן כמטופל
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── New feedback dialog ─────────────────────────────────── */
|
||||||
|
|
||||||
|
function NewFeedbackDialog() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const createMutation = useCreateFeedback();
|
||||||
|
|
||||||
|
const [caseNumber, setCaseNumber] = useState("");
|
||||||
|
const [blockId, setBlockId] = useState("block-yod");
|
||||||
|
const [category, setCategory] = useState<FeedbackCategory>("missing_content");
|
||||||
|
const [feedbackText, setFeedbackText] = useState("");
|
||||||
|
const [lesson, setLesson] = useState("");
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!feedbackText.trim()) return;
|
||||||
|
|
||||||
|
createMutation.mutate(
|
||||||
|
{
|
||||||
|
case_number: caseNumber || undefined,
|
||||||
|
block_id: blockId,
|
||||||
|
feedback_text: feedbackText,
|
||||||
|
category,
|
||||||
|
lesson_extracted: lesson || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("ההערה נרשמה בהצלחה");
|
||||||
|
setOpen(false);
|
||||||
|
setCaseNumber("");
|
||||||
|
setFeedbackText("");
|
||||||
|
setLesson("");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("שגיאה ברישום ההערה"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>+ הערה חדשה</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-lg" dir="rtl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>רישום הערת יו״ר</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fb-case">מספר תיק (אופציונלי)</Label>
|
||||||
|
<Input
|
||||||
|
id="fb-case"
|
||||||
|
value={caseNumber}
|
||||||
|
onChange={(e) => setCaseNumber(e.target.value)}
|
||||||
|
placeholder="1130-25"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fb-block">בלוק</Label>
|
||||||
|
<select
|
||||||
|
id="fb-block"
|
||||||
|
value={blockId}
|
||||||
|
onChange={(e) => setBlockId(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{Object.entries(BLOCK_LABELS).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fb-category">קטגוריה</Label>
|
||||||
|
<select
|
||||||
|
id="fb-category"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value as FeedbackCategory)}
|
||||||
|
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fb-text">ההערה</Label>
|
||||||
|
<Textarea
|
||||||
|
id="fb-text"
|
||||||
|
value={feedbackText}
|
||||||
|
onChange={(e) => setFeedbackText(e.target.value)}
|
||||||
|
placeholder="מה דפנה אמרה? מה חסר, מה לא נכון, מה צריך לשנות..."
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="fb-lesson">לקח שהופק (אופציונלי)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="fb-lesson"
|
||||||
|
value={lesson}
|
||||||
|
onChange={(e) => setLesson(e.target.value)}
|
||||||
|
placeholder="מה למדנו מההערה? מה צריך לשנות במערכת?"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
ביטול
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? "שומר..." : "שמור הערה"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ href: "/", label: "בית" },
|
{ href: "/", label: "בית" },
|
||||||
{ href: "/cases/new", label: "תיק חדש" },
|
{ href: "/cases/new", label: "תיק חדש" },
|
||||||
{ href: "/training", label: "אימון סגנון" },
|
{ href: "/training", label: "אימון סגנון" },
|
||||||
|
{ href: "/feedback", label: "הערות יו״ר" },
|
||||||
{ href: "/skills", label: "מיומנויות" },
|
{ href: "/skills", label: "מיומנויות" },
|
||||||
{ href: "/diagnostics", label: "אבחון" },
|
{ href: "/diagnostics", label: "אבחון" },
|
||||||
];
|
];
|
||||||
|
|||||||
112
web-ui/src/lib/api/feedback.ts
Normal file
112
web-ui/src/lib/api/feedback.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Chair feedback hooks — recording and managing Dafna's feedback on drafts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
|
||||||
|
export type FeedbackCategory =
|
||||||
|
| "missing_content"
|
||||||
|
| "wrong_tone"
|
||||||
|
| "wrong_structure"
|
||||||
|
| "factual_error"
|
||||||
|
| "style"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export type ChairFeedback = {
|
||||||
|
id: string;
|
||||||
|
case_id: string | null;
|
||||||
|
case_number: string;
|
||||||
|
block_id: string;
|
||||||
|
category: FeedbackCategory;
|
||||||
|
feedback_text: string;
|
||||||
|
lesson_extracted: string;
|
||||||
|
resolved: boolean;
|
||||||
|
applied_to: string[];
|
||||||
|
created_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateFeedbackInput = {
|
||||||
|
case_number?: string;
|
||||||
|
block_id?: string;
|
||||||
|
feedback_text: string;
|
||||||
|
category?: FeedbackCategory;
|
||||||
|
lesson_extracted?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const feedbackKeys = {
|
||||||
|
all: ["feedback"] as const,
|
||||||
|
list: (filters: { category?: string; unresolved_only?: boolean }) =>
|
||||||
|
[...feedbackKeys.all, "list", filters] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useFeedbackList(filters: {
|
||||||
|
category?: string;
|
||||||
|
unresolved_only?: boolean;
|
||||||
|
} = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.category) params.set("category", filters.category);
|
||||||
|
if (filters.unresolved_only) params.set("unresolved_only", "true");
|
||||||
|
const qs = params.toString();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: feedbackKeys.list(filters),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<ChairFeedback[]>(`/api/feedback${qs ? `?${qs}` : ""}`, { signal }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateFeedback() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateFeedbackInput) =>
|
||||||
|
apiRequest<{ id: string; status: string }>("/api/feedback/json", {
|
||||||
|
method: "POST",
|
||||||
|
body: data,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: feedbackKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResolveFeedback() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
feedbackId,
|
||||||
|
applied_to,
|
||||||
|
}: {
|
||||||
|
feedbackId: string;
|
||||||
|
applied_to: string[];
|
||||||
|
}) =>
|
||||||
|
apiRequest<{ status: string }>(
|
||||||
|
`/api/feedback/${feedbackId}/resolve`,
|
||||||
|
{ method: "PATCH", body: { applied_to } },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: feedbackKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hebrew labels for feedback categories */
|
||||||
|
export const CATEGORY_LABELS: Record<FeedbackCategory, string> = {
|
||||||
|
missing_content: "תוכן חסר",
|
||||||
|
wrong_tone: "טון שגוי",
|
||||||
|
wrong_structure: "מבנה שגוי",
|
||||||
|
factual_error: "שגיאה עובדתית",
|
||||||
|
style: "סגנון",
|
||||||
|
other: "אחר",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Block ID labels */
|
||||||
|
export const BLOCK_LABELS: Record<string, string> = {
|
||||||
|
"block-he": "ה — פתיחה",
|
||||||
|
"block-vav": "ו — רקע עובדתי",
|
||||||
|
"block-zayin": "ז — טענות הצדדים",
|
||||||
|
"block-chet": "ח — הליכים",
|
||||||
|
"block-tet": "ט — תכניות חלות",
|
||||||
|
"block-yod": "י — דיון והכרעה",
|
||||||
|
"block-yod-alef": "יא — סיכום",
|
||||||
|
};
|
||||||
127
web/app.py
127
web/app.py
@@ -2302,6 +2302,133 @@ async def api_reprocess_document(case_number: str, doc_id: str):
|
|||||||
return {"status": "reprocessing"}
|
return {"status": "reprocessing"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Chair feedback endpoints ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/feedback")
|
||||||
|
async def api_list_feedback(
|
||||||
|
case_number: str = "",
|
||||||
|
category: str = "",
|
||||||
|
unresolved_only: bool = False,
|
||||||
|
):
|
||||||
|
"""List chair feedback, optionally filtered by case/category."""
|
||||||
|
case_id = None
|
||||||
|
if case_number:
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if case:
|
||||||
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
|
feedbacks = await db.list_chair_feedback(
|
||||||
|
case_id=case_id,
|
||||||
|
category=category or None,
|
||||||
|
unresolved_only=unresolved_only,
|
||||||
|
)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
# Build case_number lookup
|
||||||
|
case_numbers: dict[str, str] = {}
|
||||||
|
pool = await db.get_pool()
|
||||||
|
for fb in feedbacks:
|
||||||
|
cid = fb.get("case_id")
|
||||||
|
cn = ""
|
||||||
|
if cid and str(cid) not in case_numbers:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT case_number, title FROM cases WHERE id = $1", cid,
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
case_numbers[str(cid)] = row["case_number"]
|
||||||
|
if cid:
|
||||||
|
cn = case_numbers.get(str(cid), "")
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"id": str(fb["id"]),
|
||||||
|
"case_id": str(fb["case_id"]) if fb["case_id"] else None,
|
||||||
|
"case_number": cn,
|
||||||
|
"block_id": fb["block_id"],
|
||||||
|
"category": fb["category"],
|
||||||
|
"feedback_text": fb["feedback_text"],
|
||||||
|
"lesson_extracted": fb["lesson_extracted"],
|
||||||
|
"resolved": fb["resolved"],
|
||||||
|
"applied_to": fb.get("applied_to", []),
|
||||||
|
"created_at": fb["created_at"].isoformat() if fb.get("created_at") else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/feedback")
|
||||||
|
async def api_create_feedback(
|
||||||
|
case_number: str = Form(""),
|
||||||
|
block_id: str = Form("block-yod"),
|
||||||
|
feedback_text: str = Form(...),
|
||||||
|
category: str = Form("missing_content"),
|
||||||
|
lesson_extracted: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Record a new chair feedback entry."""
|
||||||
|
case_id = None
|
||||||
|
if case_number:
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if case:
|
||||||
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
|
valid_categories = [
|
||||||
|
"missing_content", "wrong_tone", "wrong_structure",
|
||||||
|
"factual_error", "style", "other",
|
||||||
|
]
|
||||||
|
if category not in valid_categories:
|
||||||
|
raise HTTPException(400, f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}")
|
||||||
|
|
||||||
|
feedback_id = await db.record_chair_feedback(
|
||||||
|
case_id=case_id,
|
||||||
|
block_id=block_id,
|
||||||
|
feedback_text=feedback_text,
|
||||||
|
category=category,
|
||||||
|
lesson_extracted=lesson_extracted,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"id": str(feedback_id), "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/feedback/json")
|
||||||
|
async def api_create_feedback_json(body: dict):
|
||||||
|
"""Record a new chair feedback entry (JSON body)."""
|
||||||
|
case_number = body.get("case_number", "")
|
||||||
|
case_id = None
|
||||||
|
if case_number:
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if case:
|
||||||
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
|
valid_categories = [
|
||||||
|
"missing_content", "wrong_tone", "wrong_structure",
|
||||||
|
"factual_error", "style", "other",
|
||||||
|
]
|
||||||
|
category = body.get("category", "missing_content")
|
||||||
|
if category not in valid_categories:
|
||||||
|
raise HTTPException(400, f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}")
|
||||||
|
|
||||||
|
feedback_id = await db.record_chair_feedback(
|
||||||
|
case_id=case_id,
|
||||||
|
block_id=body.get("block_id", "block-yod"),
|
||||||
|
feedback_text=body.get("feedback_text", ""),
|
||||||
|
category=category,
|
||||||
|
lesson_extracted=body.get("lesson_extracted", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"id": str(feedback_id), "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/feedback/{feedback_id}/resolve")
|
||||||
|
async def api_resolve_feedback(feedback_id: str, body: dict):
|
||||||
|
"""Mark feedback as resolved."""
|
||||||
|
await db.resolve_chair_feedback(
|
||||||
|
feedback_id=UUID(feedback_id),
|
||||||
|
applied_to=body.get("applied_to", []),
|
||||||
|
)
|
||||||
|
return {"status": "resolved"}
|
||||||
|
|
||||||
|
|
||||||
# ── Background Processing ─────────────────────────────────────────
|
# ── Background Processing ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user