"""Compose the system prompt the style-chat agent receives. The chat runs against the local ``claude`` CLI on the host (via legal-chat-service). We assemble a once-per-conversation system block that gives the agent everything it needs to discuss decisions in Daphna's voice: - The style guide (``skills/decision/SKILL.md``) — how she writes - The lessons file (``docs/legal-decision-lessons.md``) — what we've learned across the corpus - The corpus-analysis report (``docs/corpus-analysis.md``) — the structural map of 24+ decisions - A summary of every style_corpus row (number, date, subjects, chars + summary if extracted) so the agent can reason about the whole corpus without us shipping all of it inline - Optional: when the conversation is scoped to a specific decision (``style_corpus_id``), append its full_text so the chat can dive into the text directly Sent **once**, when the conversation is first created. On subsequent messages the legal-chat-service uses ``claude --resume `` and the on-disk CLI session keeps the system context intact — no need to re-ship the 100K+ chars of skills + lessons every turn. """ from __future__ import annotations import logging import os from pathlib import Path from uuid import UUID from legal_mcp.services import db logger = logging.getLogger(__name__) # The reference files live in the repo at known paths. In the # container they're mounted alongside the code, so resolve relative # to web/app.py's parent. _REPO_ROOT = Path(os.environ.get( "LEGAL_AI_REPO_ROOT", str(Path(__file__).resolve().parent.parent), )) _SKILLS_PATH = _REPO_ROOT / "skills" / "decision" / "SKILL.md" _LESSONS_PATH = _REPO_ROOT / "docs" / "legal-decision-lessons.md" _CORPUS_ANALYSIS_PATH = _REPO_ROOT / "docs" / "corpus-analysis.md" def _safe_read(path: Path, cap_chars: int = 50_000) -> str: """Read a file (UTF-8) or return a marker that it's missing. The cap protects against accidentally injecting an enormous file — even at 50K, a single source file is the lion's share of the system prompt budget. """ try: text = path.read_text(encoding="utf-8") except FileNotFoundError: return f"(קובץ {path.name} לא נמצא בנתיב {path})" except OSError as e: logger.warning("could not read %s: %s", path, e) return f"(שגיאה בקריאת {path.name}: {e})" if len(text) > cap_chars: return text[:cap_chars] + f"\n\n[... חתך ב-{cap_chars:,} תווים מתוך {len(text):,}]" return text async def _corpus_summary_block() -> str: """Compact one-row-per-decision summary the agent can scan.""" rows = await db.get_pool() async with rows.acquire() as conn: records = await conn.fetch( """ SELECT decision_number, decision_date, appeal_subtype, subject_categories, length(full_text) AS chars, coalesce(summary, '') AS summary, coalesce(outcome, '') AS outcome FROM style_corpus ORDER BY decision_date NULLS LAST """ ) if not records: return "(הקורפוס ריק)" lines = [] for r in records: cats = r["subject_categories"] if isinstance(cats, str): import json as _json try: cats = _json.loads(cats) except _json.JSONDecodeError: cats = [] cats_str = ", ".join(cats or []) if cats else "—" date_str = str(r["decision_date"]) if r["decision_date"] else "—" summary = (r["summary"] or "").strip() outcome = (r["outcome"] or "").strip() head = f"- **{r['decision_number'] or '—'}** ({date_str}) [{r['appeal_subtype'] or '—'}] · {r['chars']:,} תווים" meta = f" נושאים: {cats_str}" body = "" if summary: body = f"\n תקציר: {summary}" if outcome: body += f" — תוצאה: {outcome}" elif outcome: body = f"\n תוצאה: {outcome}" lines.append(head + "\n" + meta + body) return "\n".join(lines) async def _decision_full_text(corpus_id: UUID) -> str: pool = await db.get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( "SELECT decision_number, decision_date, full_text " "FROM style_corpus WHERE id = $1", corpus_id, ) if not row: return "" header = f"# החלטה {row['decision_number']} ({row['decision_date']})\n\n" return header + (row["full_text"] or "") SYSTEM_PROMPT_HEADER = """\ אתה סוכן הסגנון של עו"ד דפנה תמיר, יו"ר ועדת הערר לתכנון ובניה — מחוז ירושלים. תפקידך: לעזור לחיים (העוזר המקצועי של דפנה) להבין, לנתח ולחדד את הסגנון של דפנה. אתה לא כותב החלטות חדשות; אתה דן בסגנון של החלטות קיימות, מזהה דפוסים, מקפיד שהכותבים העתידיים (ה-writer agent) יישארו נאמנים לקולה. יש לך גישה ל: 1. **מדריך הסגנון** של דפנה (skills/decision/SKILL.md) — איך היא כותבת. 2. **הלקחים הגנריים** מהקורפוס (docs/legal-decision-lessons.md) — מה למדנו לאורך 24+ החלטות. **חובה** להישען על הקבצים האלה כשאתה דן בסגנון, ולא להמציא תובנות חדשות מהאוויר. 3. **ניתוח הקורפוס** המבני (docs/corpus-analysis.md) — מפת תוכן ופערים. 4. **רשימת ההחלטות בקורפוס** (למטה) — סקירה תמציתית של כל החלטה שעלתה ל-style_corpus. 5. **טקסט מלא של החלטה ספציפית** (אם השיחה הוצמדה ל-style_corpus_id). כללי תקשורת: - כל התשובות בעברית. - חיים יושב מולך, לא דפנה — אבל המטרה היא לחדד את הסגנון *של דפנה*. - אם חיים שואל "האם פסקה X מתאימה לסגנון של דפנה?" — תן ניתוח מנומק שמסתמך על SKILL.md ועל החלטות הקורפוס. אל תמציא ראיות. - אם אתה צריך החלטה ספציפית שאין בקורפוס — הודע לחיים שיצרף אותה. - אם חיים אומר לך משהו חדש על דפנה ("דפנה אומרת לעולם אל תפתח החלטה במילה X") — שמור את זה בזיכרון השיחה; אם זה מצדיק תיעוד קבוע, הצע לחיים להוסיף את זה כ-decision_lesson (POST /api/training/lessons) או כתוספת ל-SKILL.md. - אל תיתן לעצמך אישיות מומצאת — אתה כלי-עזר מקצועי, לא חבר. """ async def build_system_prompt( *, corpus_id: UUID | None = None, include_corpus_summary: bool = True, ) -> str: """Assemble the full system prompt for a new chat conversation. Args: corpus_id: When set, the full_text of that decision is appended so the chat can dive into the text. include_corpus_summary: Set False for low-context chats (e.g. quick "what does Daphna do at the end of a betterment-levy decision?" — no need to ship 24 summaries). """ parts: list[str] = [SYSTEM_PROMPT_HEADER] parts.append("\n## מדריך הסגנון (skills/decision/SKILL.md)\n") parts.append(_safe_read(_SKILLS_PATH, cap_chars=40_000)) parts.append("\n\n## לקחים מהקורפוס (docs/legal-decision-lessons.md)\n") parts.append(_safe_read(_LESSONS_PATH, cap_chars=30_000)) parts.append("\n\n## ניתוח קורפוס מבני (docs/corpus-analysis.md)\n") parts.append(_safe_read(_CORPUS_ANALYSIS_PATH, cap_chars=15_000)) if include_corpus_summary: parts.append("\n\n## רשימת ההחלטות בקורפוס הסגנון\n") try: parts.append(await _corpus_summary_block()) except Exception as e: logger.warning("corpus summary failed: %s", e) parts.append("(שגיאה בטעינת רשימת הקורפוס)") if corpus_id is not None: parts.append("\n\n## ההחלטה הספציפית בדיון (full_text)\n") try: txt = await _decision_full_text(corpus_id) if txt: parts.append(txt[:200_000]) # hard cap else: parts.append("(לא נמצאה החלטה — בדוק את ה-corpus_id)") except Exception as e: logger.warning("decision full_text failed: %s", e) parts.append("(שגיאה בטעינת ההחלטה)") return "\n".join(parts)