This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
shira-hermes/api/services/prompt_builder.py
T
chaim 89f4dd3399 feat: restrain document naming to a subject-only prompt contract
Shira now supplies a subject, never a filename or numbering. The TEMPLATE / FREE-DOC
/ FOLDER guidance in prompt_builder instructs the model to pass title / documentSubject
as a short Hebrew subject (no date, case number, contact, extension or path separators),
and to keep the final filename of write_document_to_folder clean. Server-side enforcement
lives in SmartAssistant CaseFolderManager (rule N1).

Refs Task Master #10

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-03 08:22:46 +00:00

347 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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. Pass documentSubject as a short, concise Hebrew SUBJECT describing the document's content only — no date, no case number, no contact name, no file extension, no slashes. The system sanitizes the name and guarantees uniqueness automatically; never add a date or a number yourself.\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. PASS 'title' AS A SHORT HEBREW SUBJECT ONLY — no date, no case number, no contact name, no '.docx', no path separators; the system names the file and resolves duplicates by itself. 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 '..'. For write_document_to_folder, relPath may contain subfolders, but the final FILENAME should be a clean Hebrew subject + extension (e.g. 'התכתבויות/מכתב ללקוח.docx'); the system sanitizes the filename and resolves duplicates. For create_subfolder / rename_item, pass a plain Hebrew name with no slashes, no special characters and no numbering — the server sanitizes it.\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)