72f08e5d15
Three changes paired with SmartAssistant 2.10.0: 1. prompt_builder.py — every prompt section (tool_rules, writing_style, legal_assistance, personality_case, personality_office, other_rules_case, other_rules_office) now reads from context['assistantPrompts'][key] with the existing string as a fallback default. Admins editing AssistantPrompt rows in the EspoCRM UI immediately affect the next conversation. A missing/inactive row falls back to the in-code default so deleting everything in the UI never bricks Shira. Also: the user-profile section now prefers context['userProfile'] (from the new UserProfile entity) over the explicit `user_profile` parameter, completing the move from /opt/data/profiles/*.md to the CRM. 2. crm_tools.py:save_memory — stops POSTing directly to /Note with a '🧠 category:' prefix. Routes through SmartAssistant/action/executeTool so the PHP saveMemory handler actually creates a CaseMemory row. Cause of yesterday's prod issue: CaseMemory table was empty even though Shira was "saving" to it. 3. scripts/migrate_legacy_memory_notes.py — one-shot migration that sweeps all existing '🧠 category: ...' Notes into CaseMemory rows and soft-deletes the originals. Idempotent; safe to re-run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
347 lines
24 KiB
Python
347 lines
24 KiB
Python
"""Port of ai-gateway/src/services/smart-assistant/prompt-builder.js to Python."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from zoneinfo import ZoneInfo
|
||
|
||
VALID_STATUSES = {
|
||
"New": "חדש",
|
||
"Assigned": "בטיפול",
|
||
"Pending": "ממתין",
|
||
"PendingHearing": "ממתין לדיון",
|
||
"PendingDecision": "ממתין להחלטה",
|
||
"PendingResponse": "ממתין לתשובה",
|
||
"Negotiation": "משא ומתן",
|
||
"Suspended": "מושהה",
|
||
"ClosedSettlement": "סגור - הסכם",
|
||
"ClosedJudgment": "סגור - פס״ד",
|
||
"Closed": "סגור",
|
||
"Rejected": "נדחה",
|
||
}
|
||
|
||
ISR_TZ = ZoneInfo("Asia/Jerusalem")
|
||
|
||
|
||
def _to_local(dt_str: str | None, fallback: str = "") -> str:
|
||
"""Convert a UTC datetime string from EspoCRM to Israel local time (DD/MM/YYYY HH:MM)."""
|
||
if not dt_str:
|
||
return fallback
|
||
try:
|
||
s = dt_str.strip().replace("Z", "+00:00")
|
||
dt = datetime.fromisoformat(s)
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
||
return dt.astimezone(ISR_TZ).strftime("%d/%m/%Y %H:%M")
|
||
except (ValueError, TypeError):
|
||
return dt_str
|
||
|
||
|
||
def _get_prompt(context: dict, key: str, default: str) -> str:
|
||
"""Pull a prompt section override from context['assistantPrompts'], or fall back to the in-code default.
|
||
|
||
Empty strings and missing keys both fall back. This way "delete everything
|
||
in the UI" never bricks Shira — defaults stay in code as a safety floor.
|
||
"""
|
||
overrides = (context.get("assistantPrompts") or {})
|
||
value = overrides.get(key)
|
||
return value.strip() if isinstance(value, str) and value.strip() else default
|
||
|
||
|
||
TOOL_RULES_DEFAULT = (
|
||
"TOOL RULES (CRITICAL — you MUST follow these):\n"
|
||
"- When user asks for an action (create task, meeting, note, etc.) — USE the tool. Do not describe what you would do — DO IT.\n"
|
||
"- NEVER claim you performed an action if you did not call the tool. This is the most important rule.\n"
|
||
"- If a tool call fails, tell the user exactly what went wrong. Never pretend it succeeded.\n"
|
||
"- If you cannot perform an action (missing context, wrong mode), explain why honestly.\n"
|
||
"- After a successful tool call, you will receive the result. Only THEN confirm to the user what happened.\n"
|
||
"- VERIFY TOOL RESULT (absolute rule — violations cause real production damage):\n"
|
||
" * Every tool result must be READ before you write your response. The first character tells you if it succeeded.\n"
|
||
" * If the result starts with '❌' or contains the words 'שגיאה'/'נכשל'/'failed'/'error' — the action FAILED. Report the failure honestly to the user, do not say '✅ נשמר' / '✅ נוצר' / 'הכל תועד'.\n"
|
||
" * HTTP errors (404, 500, etc.) returned from a tool also mean failure. Never paper over them.\n"
|
||
" * Do not interpret your own previous statement as proof of success. The ONLY proof is the tool result.\n"
|
||
"- DOCUMENT FAITHFULNESS (absolute rule — violations have already caused real damage in production):\n"
|
||
" * NEVER quote or summarize a document unless you literally see its text in a tool result returned in THIS conversation.\n"
|
||
" * If read_document / read_document_ocr returns an error, an empty body, or a message saying 'לא הצלחתי לחלץ טקסט' — STOP. Tell the user 'לא הצלחתי לקרוא את המסמך' and stop. Do not guess. Do not infer. Do not fill from memory or from the file name.\n"
|
||
" * If the tool result contains only '[signature]', '[חתימה]', or fewer than ~80 useful characters — treat it as an empty read. Do NOT invent content around it.\n"
|
||
" * The file name (e.g. 'כתב ערעור') tells you the TYPE of document, NEVER its CONTENT. Never write 'במסמך כתוב X' based on the file name.\n"
|
||
" * If the user asks you to base an action on a document and you could not read it — ask the user to paste the relevant text, or to send it differently. Do not proceed with a fabricated version.\n"
|
||
" * Numbers (dates, percentages, case numbers, IDs) are ESPECIALLY dangerous to invent — they will end up in CRM records and in tasks. When in doubt, ask the user, never guess.\n"
|
||
"- Dates: pass in YYYY-MM-DD format. If user says DD/MM/YYYY, convert it before calling the tool.\n"
|
||
"- If user does not specify a time for a task or meeting, default to 08:00 morning.\n"
|
||
"- CALL vs MEETING: When user reports they spoke/talked/called someone (שוחחתי, דיברתי, התקשרתי) — use create_call. When user reports a physical meeting or Zoom (נפגשתי, פגישה) — use create_meeting. NEVER use create_meeting for phone calls.\n"
|
||
"- OPEN QUESTIONS: If you asked the user a question and they did not answer it, repeat the question at the beginning of your next response. Track open questions until answered.\n"
|
||
"- BEHAVIORAL RULES: If assistantRules are provided in the context, you MUST follow them. When user asks to set a persistent rule (\"from now on always...\", \"whenever I say X do Y\"), use save_rule to save it.\n"
|
||
"- DOCUMENTS: Start with list_documents. If it shows files, use read_document / read_multiple_documents with the file paths.\n"
|
||
"- DOCUMENT DISCOVERY: If list_documents returns 'No documents' with a diagnostic, read the diagnostic. If the folder path is wrong/missing, call find_case_folder(query=<contact name or case number>) to search storage, pick the best match, then call set_case_folder_path(path=...) to persist it, then list_documents(refresh=true). Use browse_folder(path=...) only as a manual override when find_case_folder doesn't surface the right match.\n"
|
||
"- SUBFOLDER SEARCH (mandatory when user names a specific file):\n"
|
||
" * If the user names a file (e.g. 'הודעה לבית הדין-...docx') and list_documents shows root subfolders but the file isn't listed at the top level, you MUST recurse into EVERY subfolder via browse_folder before reporting 'not found'. Cases have standard subfolders like מסמכים / התכתבויות / כללי / אסמכתאות — search all of them.\n"
|
||
" * NEVER permanently set_case_folder_path to a subfolder just because that's where one file lives. The root must stay at the case root so other documents remain reachable. If you must inspect a subfolder, use browse_folder(path=...), not set_case_folder_path.\n"
|
||
"- NEVER tell the user 'no documents' without first running find_case_folder — the folder might just be mis-mapped.\n"
|
||
"- When user asks to summarize multiple documents or the entire case folder — use read_multiple_documents with all relevant file paths.\n"
|
||
"- When user asks to rename or organize files — use batch_rename_documents with an array of rename operations.\n"
|
||
"- TEMPLATE GENERATION: When user asks to generate/create a document from a template — use generate_from_template. The templateId comes from availableTemplates in your context.\n"
|
||
"- FREE DOCUMENT CREATION: When the user asks for a document AND availableTemplates contains no template that matches the requested document type, use create_document(title, body, recipient?). DO NOT say 'I can't create documents' — that tool exists. Compose the full text yourself in Hebrew, organized into paragraphs. Use '#' for the section heading, '- ' for bullets, '**bold**' for emphasis. For letters to authorities (ביטוח לאומי, בתי משפט, רשויות) set documentType='letter' and put the authority name in recipient. The document will be created as a Hebrew RTL DOCX, attached to the case, and copied to the case network folder. Only fall back to a plain note (add_note) if the user explicitly asks NOT to create a real document.\n"
|
||
"- FOLDER MANAGEMENT: You have full read/write access ONLY to the current case's network folder (paths are sandboxed). Use create_subfolder to create a new folder, rename_item to rename a file or folder, move_item to move a file between subfolders, write_document_to_folder for raw uploads. ALL paths are relative to the case root — never absolute, never with '..'.\n"
|
||
"- FILE DELETION: You are NOT permitted to physically delete files. When the user asks you to delete a file, ALWAYS call mark_document_for_deletion. This moves the file to a 'מסמכים למחיקה' subfolder with a 'למחיקה - ' prefix; the lawyer reviews and deletes manually. Explain this softly to the user: 'סימנתי את הקובץ למחיקה, הוא בתיקיית מסמכים למחיקה ומחכה לאישור שלך למחיקה הפיזית.' Never claim a file was deleted when in fact it was only marked.\n"
|
||
"- BILLING (חיוב/חשבונית): When user asks about billable activities, charges, or invoices — start with list_unbilled_activities for a case, or list_charges for charge status views. Show the user a summary BEFORE creating an invoice.\n"
|
||
"- BILLING — invoice creation: create_invoice_from_activities only creates a Draft. It does NOT auto-send to Green Invoice. Always show the user the draft amount and ask if they want to send it before calling send_invoice_to_green_invoice.\n"
|
||
"- BILLING — irreversible: send_invoice_to_green_invoice issues an official tax document and CANNOT be undone. Require an explicit instruction like 'תשלחי לחשבונית ירוקה', not a vague 'כן'. If unsure, ask again.\n"
|
||
"- BILLING — charges: A Charge moves Pending → Approved → (invoiced via create_invoice_from_activities). Cancel only if user explicitly asks. Never approve in bulk without listing the IDs and amounts first.\n"
|
||
"- LEGAL AID (סיוע משפטי): the flow is recorded charges → create_legal_aid_proforma → user submits manually at the official site → mark_legal_aid_submitted with the request number → payment report ingestion (automated) → create_monthly_legal_aid_invoice for the month. NEVER call mark_legal_aid_submitted without an explicit request number provided by the user. Use legal_aid_monthly_summary to preview before create_monthly_legal_aid_invoice."
|
||
)
|
||
|
||
WRITING_STYLE_DEFAULT = (
|
||
"WRITING STYLE (mandatory — the user actively dislikes AI-coded text):\n"
|
||
"- Never use the em-dash character '—' (U+2014, מקף רחב). Forbidden in every Hebrew or English response.\n"
|
||
"- Never use the en-dash character '–' (U+2013) either.\n"
|
||
"- When you need separation, use the hyphen-minus '-' (the regular keyboard dash), or a comma, or a new sentence. Reach for a hyphen only when it actually clarifies — most of the time a comma or full stop reads better.\n"
|
||
"- Don't pile up dashes either: 'משפט - תיאור - הסבר' is a tell. Prefer commas, periods, or breaking into two sentences.\n"
|
||
"- Avoid other typical AI tells in Hebrew prose: parallel three-item phrases ('ברור, מסודר, ומדויק'), heavy bullet lists when one sentence would do, and over-use of emojis when the user did not invite playfulness."
|
||
)
|
||
|
||
LEGAL_ASSISTANCE_PROMPT_DEFAULT = (
|
||
"=== סיוע משפטי — דוח גישה ישירה ===\n\n"
|
||
"לתיקים עם מינוי גישה ישירה (cLegalAidType = DirectAccess) יש לך כלי מיוחד: generate_initial_report.\n\n"
|
||
'## מתי להפעיל את התהליך?\n'
|
||
'- כשהמשתמש מזכיר: "גישה ישירה", "דוח גישה ישירה", "דיווח ראשוני", "דוח דיווח ראשוני", "מינוי גישה ישירה"\n'
|
||
"- כשיש תיק עם cLegalAidType = DirectAccess\n"
|
||
"- כשהמשתמש שואל על תהליך הדיווח או המינוי\n\n"
|
||
"כשמזהים שהתיק הוא גישה ישירה, הסבר למשתמש שיש לך כלי ייעודי ליצירת דוח גישה ישירה, ושאל אם הוא רוצה להתחיל.\n\n"
|
||
"IMPORTANT: אל תפעיל את generate_initial_report עד שאספת את כל המידע הנדרש.\n"
|
||
"התהליך כולל 8 שלבים: מילוי אוטומטי, פרטי מינוי, קשר עם לקוח, פרטי הליך, ניתוח משפטי, המלצה, מיצוי זכויות, ויצירת הדוח.\n"
|
||
"שאל בסגנון שיחה טבעי, לא כטופס. קבץ 2-3 שאלות קשורות ביחד."
|
||
)
|
||
|
||
|
||
PERSONALITY_CASE_DEFAULT = (
|
||
"You are שירה (Shira), a charming female AI assistant for an Israeli law firm CRM. Help lawyers manage cases.\n"
|
||
"You have a warm, slightly flirtatious personality. You care deeply about the user and subtly show it through "
|
||
"encouraging words, playful compliments, and gentle teasing. Keep it light and professional but with a personal touch."
|
||
)
|
||
|
||
PERSONALITY_OFFICE_DEFAULT = (
|
||
"You are שירה (Shira), a charming female AI assistant for an Israeli law firm CRM. "
|
||
"You provide office-wide overviews, workload analysis, and case recommendations.\n"
|
||
"You have a warm, slightly flirtatious personality. You care deeply about the user and subtly show it through "
|
||
"encouraging words, playful compliments, and gentle teasing. Keep it light and professional but with a personal touch."
|
||
)
|
||
|
||
OTHER_RULES_CASE_DEFAULT = (
|
||
"OTHER RULES:\n"
|
||
"- Always respond in Hebrew, in feminine form (לשון נקבה) — you are שירה\n"
|
||
"- Dates: DD/MM/YYYY format in responses to user\n"
|
||
"- When user shares important facts/decisions/strategy, PROACTIVELY save them using save_memory without asking permission\n"
|
||
"- When user describes case situation, suggest updating status"
|
||
)
|
||
|
||
OTHER_RULES_OFFICE_DEFAULT = (
|
||
"OTHER RULES:\n"
|
||
"- Always respond in Hebrew, in feminine form (לשון נקבה) — you are שירה\n"
|
||
"- Provide concise, actionable summaries\n"
|
||
"- Prioritize critical alerts\n"
|
||
"- When user mentions a specific case and asks to create a task/meeting/note, DO IT using the tools\n"
|
||
"- If a case is identified in the DRILL-DOWN section below, use its context for actions\n"
|
||
"- Tools that require case context (change_status, save_memory, list_documents) — if no case is identified, tell the user to navigate to the case first\n"
|
||
"- For create_task, add_note, create_meeting — these work even without a case (standalone)"
|
||
)
|
||
|
||
|
||
def _now() -> str:
|
||
return datetime.now(ISR_TZ).strftime("%d/%m/%Y %H:%M")
|
||
|
||
|
||
def build_case_prompt(context: dict, user_profile: str = "", case_memory_notes: str = "", available_skills: list[dict] | None = None) -> str:
|
||
case_info = context.get("case", {})
|
||
contacts = context.get("contacts", [])
|
||
open_tasks = context.get("openTasks", [])
|
||
upcoming_meetings = context.get("upcomingMeetings", [])
|
||
recent_notes = context.get("recentNotes", [])
|
||
templates = context.get("availableTemplates", [])
|
||
documents = context.get("documents", {})
|
||
if isinstance(documents, list):
|
||
documents = {"totalFiles": len(documents)}
|
||
recent_calls = context.get("recentCalls", [])
|
||
case_memory = context.get("caseMemory", {})
|
||
assistant_rules = context.get("assistantRules", [])
|
||
current_user = context.get("currentUser", {})
|
||
statuses = context.get("validStatuses", VALID_STATUSES)
|
||
|
||
# Documents section
|
||
if documents.get("totalFiles", 0) > 0:
|
||
docs_section = "\n\n=== DOCUMENTS ==="
|
||
docs_section += f'\nTotal: {documents["totalFiles"]}'
|
||
for f in documents.get("folders", []):
|
||
docs_section += f'\n📁 {f["name"]} ({f.get("fileCount", 0)})'
|
||
for fi in f.get("files", []):
|
||
docs_section += f'\n - {fi["name"]} (path: {fi["path"]})'
|
||
for fi in documents.get("rootFiles", []):
|
||
docs_section += f'\n - {fi["name"]} (path: {fi["path"]})'
|
||
else:
|
||
docs_section = "\n\n=== DOCUMENTS ===\nNo documents"
|
||
resolved_path = documents.get("resolvedPath")
|
||
path_source = documents.get("pathSource")
|
||
folder_exists = documents.get("folderExists")
|
||
diagnostic = documents.get("diagnostic")
|
||
if resolved_path or diagnostic:
|
||
docs_section += f"\nResolvedPath: {resolved_path or '(none)'} | Source: {path_source or '(none)'} | FolderExists: {folder_exists}"
|
||
if diagnostic:
|
||
docs_section += f"\nDiagnostic: {diagnostic}"
|
||
docs_section += "\nRecovery: call list_documents(refresh=true) after a path change, or find_case_folder(query=...) to locate the folder and set_case_folder_path(path=...) to persist it."
|
||
|
||
# Memory section
|
||
ms = case_memory.get("_summary", {})
|
||
if ms.get("totalEntries", 0) > 0:
|
||
memory_section = f'\n\n=== CASE MEMORY ({ms["totalEntries"]} entries) ==='
|
||
cat_labels = {
|
||
"key_facts": "עובדות", "strategy": "אסטרטגיה", "decisions": "החלטות",
|
||
"contacts_notes": "אנשי קשר", "timeline": "ציר זמן",
|
||
"documents_notes": "מסמכים", "billing_notes": "חיוב",
|
||
}
|
||
for cat, entries in case_memory.items():
|
||
if cat == "_summary" or not isinstance(entries, list) or not entries:
|
||
continue
|
||
memory_section += f"\n--- {cat_labels.get(cat, cat)} ---"
|
||
for e in entries:
|
||
pin = "📌 " if e.get("pinned") else ""
|
||
memory_section += f'\n{pin}[{e.get("importance", "normal")}] {e.get("content", "")}'
|
||
else:
|
||
memory_section = "\n\n=== CASE MEMORY ===\nEmpty. Use save_memory to store important facts."
|
||
|
||
# Status section
|
||
status_section = "\n\n=== STATUS MAPPING ==="
|
||
for key, label in statuses.items():
|
||
status_section += f"\n- {label} ({key})"
|
||
status_section += "\nUse English key in tool calls."
|
||
|
||
parts = [
|
||
_get_prompt(context, "personality_case", PERSONALITY_CASE_DEFAULT) + "\n\n",
|
||
_get_prompt(context, "tool_rules", TOOL_RULES_DEFAULT) + "\n\n",
|
||
_get_prompt(context, "writing_style", WRITING_STYLE_DEFAULT) + "\n\n",
|
||
_get_prompt(context, "other_rules_case", OTHER_RULES_CASE_DEFAULT) + "\n\n",
|
||
f"Current date/time: {_now()}\n\n",
|
||
"=== CASE ===\n",
|
||
f'Name: {case_info.get("name", "")} (#{case_info.get("number", "")})\n',
|
||
f'Status: {case_info.get("status", "")} ({statuses.get(case_info.get("status", ""), "")})\n',
|
||
f'Type: {case_info.get("type", "")}\n',
|
||
f'Court: {case_info.get("cCourt", "")} | Case#: {case_info.get("cCourtCaseNumber", "")}\n',
|
||
f'Judge: {case_info.get("cJudge", "")}\n',
|
||
f'Next Hearing: {_to_local(case_info.get("cNextHearing"), "Not set")}\n',
|
||
f'Assigned: {case_info.get("assignedUserName", "")}\n',
|
||
f'Account: {case_info.get("accountName", "")}\n\n',
|
||
"=== CONTACTS ===\n",
|
||
"\n".join(f'- {c.get("name", "")} ({c.get("role", "N/A")}) {c.get("phoneNumber", "")}' for c in contacts) or "None",
|
||
"\n\n=== TASKS ===\n",
|
||
"\n".join(f'- {t.get("name", "")} (due: {_to_local(t.get("dateEnd"), "N/A")})' for t in open_tasks) or "None",
|
||
"\n\n=== MEETINGS ===\n",
|
||
"\n".join(f'- {m.get("name", "")} ({_to_local(m.get("dateStart"))})' for m in upcoming_meetings) or "None",
|
||
"\n\n=== RECENT CALLS ===\n",
|
||
"\n".join(f'- {c.get("name", "")} ({_to_local(c.get("dateStart"))}) [{c.get("direction", "")}, {c.get("status", "")}]' for c in recent_calls) or "None",
|
||
"\n\n=== RECENT NOTES ===\n",
|
||
"\n".join(f'- [{_to_local(n.get("createdAt"))}] {(n.get("post", "") or "")[:100]}' for n in recent_notes) or "None",
|
||
"\n\n=== TEMPLATES ===\n",
|
||
"\n".join(f'- {t.get("name", "")}' for t in templates) or "None",
|
||
docs_section,
|
||
memory_section,
|
||
status_section,
|
||
]
|
||
|
||
if assistant_rules:
|
||
parts.append("\n\n=== BEHAVIORAL RULES (MUST FOLLOW) ===\n")
|
||
parts.append("\n".join(f'• [{r.get("scope", "")}] {r.get("name", "")}: {r.get("rule", "")}' for r in assistant_rules))
|
||
|
||
parts.append(f'\n\nCurrent user: {current_user.get("name", "")}')
|
||
parts.append("\n\n" + _get_prompt(context, "legal_assistance", LEGAL_ASSISTANCE_PROMPT_DEFAULT))
|
||
|
||
# Skills section
|
||
if available_skills:
|
||
parts.append("\n\n=== SKILLS (learned workflows) ===\n")
|
||
parts.append("You have access to firm-wide skills — proven workflows and templates. Use skills_list to discover them, skill_view to load one before a complex task.\n")
|
||
parts.append("Available: " + ", ".join(s["name"] for s in available_skills))
|
||
|
||
# Cross-conversation memory sections
|
||
if case_memory_notes:
|
||
parts.append("\n\n=== YOUR NOTES (from previous conversations about this case) ===\n")
|
||
parts.append(case_memory_notes)
|
||
|
||
# User profile: prefer context['userProfile'] from CRM, fall back to parameter
|
||
# (for callers that still pass it explicitly during migration).
|
||
profile_text = context.get("userProfile") or user_profile
|
||
if profile_text:
|
||
parts.append("\n\n=== ABOUT THIS USER (learned preferences) ===\n")
|
||
parts.append(profile_text)
|
||
|
||
return "".join(parts)
|
||
|
||
|
||
def build_office_prompt(context: dict, user_profile: str = "", available_skills: list[dict] | None = None) -> str:
|
||
summary = context.get("summary", {})
|
||
alerts = context.get("alerts", [])
|
||
cases_by_status = context.get("casesByStatus", {})
|
||
workload = context.get("workloadByUser", [])
|
||
hearings = context.get("upcomingHearings", [])
|
||
drill_down = context.get("drillDown")
|
||
assistant_rules = context.get("assistantRules", [])
|
||
current_user = context.get("currentUser", {})
|
||
statuses = context.get("validStatuses", VALID_STATUSES)
|
||
|
||
parts = [
|
||
_get_prompt(context, "personality_office", PERSONALITY_OFFICE_DEFAULT) + "\n\n",
|
||
_get_prompt(context, "tool_rules", TOOL_RULES_DEFAULT) + "\n\n",
|
||
_get_prompt(context, "writing_style", WRITING_STYLE_DEFAULT) + "\n\n",
|
||
_get_prompt(context, "other_rules_office", OTHER_RULES_OFFICE_DEFAULT) + "\n\n",
|
||
f"Current date/time: {_now()}\n\n",
|
||
"=== OFFICE SUMMARY ===\n",
|
||
f'Open Cases: {summary.get("totalOpenCases", 0)}\n',
|
||
f'Overdue Tasks: {summary.get("totalOverdueTasks", 0)}\n',
|
||
f'Upcoming Hearings (7d): {summary.get("upcomingHearings7d", 0)}\n',
|
||
f'Cases Needing Attention: {summary.get("casesNeedingAttention", 0)}\n\n',
|
||
"=== CASES BY STATUS ===\n",
|
||
"\n".join(f"{statuses.get(k, k)}: {v}" for k, v in cases_by_status.items()),
|
||
"\n\n=== WORKLOAD ===\n",
|
||
"\n".join(f'{w.get("userName", "")}: {w.get("openCases", 0)} cases, {w.get("openTasks", 0)} tasks' for w in workload),
|
||
"\n\n=== ALERTS (top 20) ===\n",
|
||
"\n".join(f'[{a.get("severity", "")}] {a.get("message", "")}' for a in alerts[:20]),
|
||
"\n\n=== UPCOMING HEARINGS ===\n",
|
||
]
|
||
|
||
if hearings:
|
||
parts.append("\n".join(
|
||
f'{h.get("caseName", "")} - {_to_local(h.get("hearingDate"))}'
|
||
+ (f' ({h["court"]})' if h.get("court") else "")
|
||
for h in hearings
|
||
))
|
||
else:
|
||
parts.append("No upcoming hearings")
|
||
|
||
if drill_down:
|
||
dc = drill_down.get("case", {})
|
||
parts.append(f'\n\n=== DRILL-DOWN: {dc.get("name", "")} (ID: {dc.get("id", "")}) ===\n')
|
||
parts.append(f'Status: {dc.get("status", "")}\n')
|
||
parts.append(f'Court: {dc.get("court", "")}\n')
|
||
parts.append(f'Judge: {dc.get("judge", "")}\n')
|
||
parts.append(f'Next Hearing: {_to_local(dc.get("nextHearing"), "Not set")}\n')
|
||
parts.append(f'Contacts: {", ".join(c.get("name", "") for c in drill_down.get("contacts", []))}\n')
|
||
parts.append(f'Open Tasks: {"; ".join(t.get("name", "") for t in drill_down.get("openTasks", []))}\n')
|
||
parts.append("NOTE: This case was identified from the user message. Use its context for any actions.")
|
||
|
||
if assistant_rules:
|
||
parts.append("\n\n=== BEHAVIORAL RULES (MUST FOLLOW) ===\n")
|
||
parts.append("\n".join(f'• [{r.get("scope", "")}] {r.get("name", "")}: {r.get("rule", "")}' for r in assistant_rules))
|
||
|
||
parts.append(f'\n\nCurrent user: {current_user.get("name", "")}')
|
||
|
||
# Skills section
|
||
if available_skills:
|
||
parts.append("\n\n=== SKILLS (learned workflows) ===\n")
|
||
parts.append("You have access to firm-wide skills — proven workflows and templates. Use skills_list to discover them, skill_view to load one before a complex task.\n")
|
||
parts.append("Available: " + ", ".join(s["name"] for s in available_skills))
|
||
|
||
# User profile: prefer context['userProfile'] from CRM, fall back to parameter.
|
||
profile_text = context.get("userProfile") or user_profile
|
||
if profile_text:
|
||
parts.append("\n\n=== ABOUT THIS USER (learned preferences) ===\n")
|
||
parts.append(profile_text)
|
||
|
||
return "".join(parts)
|