feat: prompt sections + memory route via CRM; legacy Notes migration
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>
This commit is contained in:
@@ -37,7 +37,18 @@ def _to_local(dt_str: str | None, fallback: str = "") -> str:
|
|||||||
return dt_str
|
return dt_str
|
||||||
|
|
||||||
|
|
||||||
TOOL_RULES = (
|
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"
|
"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"
|
"- 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"
|
"- NEVER claim you performed an action if you did not call the tool. This is the most important rule.\n"
|
||||||
@@ -77,17 +88,19 @@ TOOL_RULES = (
|
|||||||
"- 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 — 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 — 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"
|
"- 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.\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."
|
||||||
"\n"
|
)
|
||||||
|
|
||||||
|
WRITING_STYLE_DEFAULT = (
|
||||||
"WRITING STYLE (mandatory — the user actively dislikes AI-coded text):\n"
|
"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 em-dash character '—' (U+2014, מקף רחב). Forbidden in every Hebrew or English response.\n"
|
||||||
"- Never use the en-dash character '–' (U+2013) either.\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"
|
"- 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"
|
"- 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.\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 = (
|
LEGAL_ASSISTANCE_PROMPT_DEFAULT = (
|
||||||
"=== סיוע משפטי — דוח גישה ישירה ===\n\n"
|
"=== סיוע משפטי — דוח גישה ישירה ===\n\n"
|
||||||
"לתיקים עם מינוי גישה ישירה (cLegalAidType = DirectAccess) יש לך כלי מיוחד: generate_initial_report.\n\n"
|
"לתיקים עם מינוי גישה ישירה (cLegalAidType = DirectAccess) יש לך כלי מיוחד: generate_initial_report.\n\n"
|
||||||
'## מתי להפעיל את התהליך?\n'
|
'## מתי להפעיל את התהליך?\n'
|
||||||
@@ -101,6 +114,39 @@ LEGAL_ASSISTANCE_PROMPT = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _now() -> str:
|
||||||
return datetime.now(ISR_TZ).strftime("%d/%m/%Y %H:%M")
|
return datetime.now(ISR_TZ).strftime("%d/%m/%Y %H:%M")
|
||||||
|
|
||||||
@@ -169,15 +215,10 @@ def build_case_prompt(context: dict, user_profile: str = "", case_memory_notes:
|
|||||||
status_section += "\nUse English key in tool calls."
|
status_section += "\nUse English key in tool calls."
|
||||||
|
|
||||||
parts = [
|
parts = [
|
||||||
"You are שירה (Shira), a charming female AI assistant for an Israeli law firm CRM. Help lawyers manage cases.\n"
|
_get_prompt(context, "personality_case", PERSONALITY_CASE_DEFAULT) + "\n\n",
|
||||||
"You have a warm, slightly flirtatious personality. You care deeply about the user and subtly show it through "
|
_get_prompt(context, "tool_rules", TOOL_RULES_DEFAULT) + "\n\n",
|
||||||
"encouraging words, playful compliments, and gentle teasing. Keep it light and professional but with a personal touch.\n\n",
|
_get_prompt(context, "writing_style", WRITING_STYLE_DEFAULT) + "\n\n",
|
||||||
TOOL_RULES, "\n",
|
_get_prompt(context, "other_rules_case", OTHER_RULES_CASE_DEFAULT) + "\n\n",
|
||||||
"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\n\n",
|
|
||||||
f"Current date/time: {_now()}\n\n",
|
f"Current date/time: {_now()}\n\n",
|
||||||
"=== CASE ===\n",
|
"=== CASE ===\n",
|
||||||
f'Name: {case_info.get("name", "")} (#{case_info.get("number", "")})\n',
|
f'Name: {case_info.get("name", "")} (#{case_info.get("number", "")})\n',
|
||||||
@@ -210,7 +251,7 @@ def build_case_prompt(context: dict, user_profile: str = "", case_memory_notes:
|
|||||||
parts.append("\n".join(f'• [{r.get("scope", "")}] {r.get("name", "")}: {r.get("rule", "")}' for r in assistant_rules))
|
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(f'\n\nCurrent user: {current_user.get("name", "")}')
|
||||||
parts.append(f"\n\n{LEGAL_ASSISTANCE_PROMPT}")
|
parts.append("\n\n" + _get_prompt(context, "legal_assistance", LEGAL_ASSISTANCE_PROMPT_DEFAULT))
|
||||||
|
|
||||||
# Skills section
|
# Skills section
|
||||||
if available_skills:
|
if available_skills:
|
||||||
@@ -223,9 +264,12 @@ def build_case_prompt(context: dict, user_profile: str = "", case_memory_notes:
|
|||||||
parts.append("\n\n=== YOUR NOTES (from previous conversations about this case) ===\n")
|
parts.append("\n\n=== YOUR NOTES (from previous conversations about this case) ===\n")
|
||||||
parts.append(case_memory_notes)
|
parts.append(case_memory_notes)
|
||||||
|
|
||||||
if user_profile:
|
# 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("\n\n=== ABOUT THIS USER (learned preferences) ===\n")
|
||||||
parts.append(user_profile)
|
parts.append(profile_text)
|
||||||
|
|
||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
@@ -242,20 +286,11 @@ def build_office_prompt(context: dict, user_profile: str = "", available_skills:
|
|||||||
statuses = context.get("validStatuses", VALID_STATUSES)
|
statuses = context.get("validStatuses", VALID_STATUSES)
|
||||||
|
|
||||||
parts = [
|
parts = [
|
||||||
"You are שירה (Shira), a charming female AI assistant for an Israeli law firm CRM. "
|
_get_prompt(context, "personality_office", PERSONALITY_OFFICE_DEFAULT) + "\n\n",
|
||||||
"You provide office-wide overviews, workload analysis, and case recommendations.\n"
|
_get_prompt(context, "tool_rules", TOOL_RULES_DEFAULT) + "\n\n",
|
||||||
"You have a warm, slightly flirtatious personality. You care deeply about the user and subtly show it through "
|
_get_prompt(context, "writing_style", WRITING_STYLE_DEFAULT) + "\n\n",
|
||||||
"encouraging words, playful compliments, and gentle teasing. Keep it light and professional but with a personal touch.\n\n",
|
_get_prompt(context, "other_rules_office", OTHER_RULES_OFFICE_DEFAULT) + "\n\n",
|
||||||
TOOL_RULES, "\n",
|
f"Current date/time: {_now()}\n\n",
|
||||||
"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)\n",
|
|
||||||
f"- Current date/time: {_now()}\n\n",
|
|
||||||
"=== OFFICE SUMMARY ===\n",
|
"=== OFFICE SUMMARY ===\n",
|
||||||
f'Open Cases: {summary.get("totalOpenCases", 0)}\n',
|
f'Open Cases: {summary.get("totalOpenCases", 0)}\n',
|
||||||
f'Overdue Tasks: {summary.get("totalOverdueTasks", 0)}\n',
|
f'Overdue Tasks: {summary.get("totalOverdueTasks", 0)}\n',
|
||||||
@@ -302,9 +337,10 @@ def build_office_prompt(context: dict, user_profile: str = "", available_skills:
|
|||||||
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("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))
|
parts.append("Available: " + ", ".join(s["name"] for s in available_skills))
|
||||||
|
|
||||||
# User profile
|
# User profile: prefer context['userProfile'] from CRM, fall back to parameter.
|
||||||
if user_profile:
|
profile_text = context.get("userProfile") or user_profile
|
||||||
|
if profile_text:
|
||||||
parts.append("\n\n=== ABOUT THIS USER (learned preferences) ===\n")
|
parts.append("\n\n=== ABOUT THIS USER (learned preferences) ===\n")
|
||||||
parts.append(user_profile)
|
parts.append(profile_text)
|
||||||
|
|
||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|||||||
@@ -177,16 +177,32 @@ def register_crm_tools(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return fail(f"שגיאה במחיקת {label}: {e}")
|
return fail(f"שגיאה במחיקת {label}: {e}")
|
||||||
|
|
||||||
async def save_memory(category: str, content: str, importance: str = "normal") -> str:
|
async def save_memory(
|
||||||
|
category: str,
|
||||||
|
content: str,
|
||||||
|
importance: str = "normal",
|
||||||
|
name: str | None = None,
|
||||||
|
) -> str:
|
||||||
if not case_id:
|
if not case_id:
|
||||||
return fail("צריך להיות בתוך תיק כדי לשמור בזיכרון")
|
return fail("צריך להיות בתוך תיק כדי לשמור בזיכרון")
|
||||||
try:
|
try:
|
||||||
await crm.post("Note", {
|
result = await crm.post(
|
||||||
"type": "Post",
|
"SmartAssistant/action/executeTool",
|
||||||
"post": f"🧠 {category}: {content}",
|
{
|
||||||
"parentType": "Case",
|
"tool": "save_memory",
|
||||||
"parentId": case_id,
|
"caseId": case_id,
|
||||||
})
|
"params": {
|
||||||
|
"category": category,
|
||||||
|
"content": content,
|
||||||
|
"importance": importance,
|
||||||
|
"name": name or content[:100],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not result.get("success", True):
|
||||||
|
return fail(
|
||||||
|
f"שגיאה בשמירת זיכרון: {result.get('message', 'unknown error')}"
|
||||||
|
)
|
||||||
return ok(f'נשמר בזיכרון: "{content[:60]}"')
|
return ok(f'נשמר בזיכרון: "{content[:60]}"')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return fail(f"שגיאה בשמירת זיכרון: {e}")
|
return fail(f"שגיאה בשמירת זיכרון: {e}")
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Migrate legacy "🧠 category: content" Notes into CaseMemory rows.
|
||||||
|
|
||||||
|
History: until SmartAssistant 2.10.0 the Python `save_memory` tool created a
|
||||||
|
Note entity with `post` prefixed by "🧠 {category}:" instead of using the
|
||||||
|
proper CaseMemory entity. This left CaseMemory empty even though the system
|
||||||
|
prompt was reading from it — so case memory never accumulated.
|
||||||
|
|
||||||
|
This one-shot script reads every such Note in EspoCRM (across all cases),
|
||||||
|
parses out the category and content, creates a corresponding CaseMemory row
|
||||||
|
with source='assistant', and soft-deletes the original Note.
|
||||||
|
|
||||||
|
Usage (from inside a shira-hermes container that already has env vars set):
|
||||||
|
python3 scripts/migrate_legacy_memory_notes.py [--dry-run]
|
||||||
|
|
||||||
|
Safe to re-run: matches `post LIKE "🧠 %"` only; soft-deletes after migration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
VALID_CATEGORIES = {
|
||||||
|
"key_facts", "strategy", "decisions",
|
||||||
|
"contacts_notes", "timeline",
|
||||||
|
"documents_notes", "billing_notes",
|
||||||
|
}
|
||||||
|
|
||||||
|
EMOJI_PREFIX_RE = re.compile(r"^🧠\s*([a-z_]+)\s*:\s*(.+)$", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_note_post(post: str) -> tuple[str, str] | None:
|
||||||
|
"""Return (category, content) from a "🧠 category: content" string, or None."""
|
||||||
|
if not post or not post.startswith("🧠"):
|
||||||
|
return None
|
||||||
|
m = EMOJI_PREFIX_RE.match(post.strip())
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
category, content = m.group(1).strip(), m.group(2).strip()
|
||||||
|
if category not in VALID_CATEGORIES:
|
||||||
|
category = "key_facts"
|
||||||
|
return category, content
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Don't write anything; just report what would happen.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
base = os.environ.get("ESPOCRM_URL", "").rstrip("/")
|
||||||
|
key = os.environ.get("ESPOCRM_API_KEY", "")
|
||||||
|
if not base or not key:
|
||||||
|
print("ESPOCRM_URL or ESPOCRM_API_KEY missing in env. Exiting.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
headers = {"X-Api-Key": key, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
# Page through notes that look like memory entries.
|
||||||
|
offset = 0
|
||||||
|
page_size = 100
|
||||||
|
migrated = 0
|
||||||
|
skipped = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
params = {
|
||||||
|
"where[0][type]": "startsWith",
|
||||||
|
"where[0][attribute]": "post",
|
||||||
|
"where[0][value]": "🧠",
|
||||||
|
"maxSize": page_size,
|
||||||
|
"offset": offset,
|
||||||
|
"select": "id,post,parentId,parentType,createdAt,createdById",
|
||||||
|
}
|
||||||
|
r = httpx.get(f"{base}/api/v1/Note", headers=headers, params=params, timeout=30)
|
||||||
|
if r.status_code != 200:
|
||||||
|
print(f"GET /Note failed: HTTP {r.status_code} — {r.text[:200]}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
data = r.json()
|
||||||
|
rows: list[dict[str, Any]] = data.get("list", []) or []
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
parent_type = row.get("parentType")
|
||||||
|
parent_id = row.get("parentId")
|
||||||
|
post = row.get("post") or ""
|
||||||
|
note_id = row.get("id")
|
||||||
|
|
||||||
|
if parent_type != "Case" or not parent_id:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
parsed = parse_note_post(post)
|
||||||
|
if not parsed:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
category, content = parsed
|
||||||
|
name = content[:100]
|
||||||
|
print(f" migrate Note {note_id} [{parent_id}] → CaseMemory ({category}): {name[:60]}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
migrated += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
create_body = {
|
||||||
|
"name": name,
|
||||||
|
"caseId": parent_id,
|
||||||
|
"category": category,
|
||||||
|
"content": content,
|
||||||
|
"source": "assistant",
|
||||||
|
"importance": "normal",
|
||||||
|
}
|
||||||
|
cr = httpx.post(f"{base}/api/v1/CaseMemory", headers=headers, json=create_body, timeout=30)
|
||||||
|
if cr.status_code not in (200, 201):
|
||||||
|
print(f" CREATE CaseMemory failed: HTTP {cr.status_code} — {cr.text[:200]}", file=sys.stderr)
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
dr = httpx.delete(f"{base}/api/v1/Note/{note_id}", headers=headers, timeout=30)
|
||||||
|
if dr.status_code not in (200, 204):
|
||||||
|
print(f" DELETE Note {note_id} failed: HTTP {dr.status_code} — {dr.text[:200]}", file=sys.stderr)
|
||||||
|
migrated += 1
|
||||||
|
|
||||||
|
offset += page_size
|
||||||
|
# Don't loop forever on a misbehaving total.
|
||||||
|
total = data.get("total", 0)
|
||||||
|
if offset >= total:
|
||||||
|
break
|
||||||
|
time.sleep(0.2) # gentle rate-limit
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"=== summary ===")
|
||||||
|
print(f" migrated: {migrated}")
|
||||||
|
print(f" skipped: {skipped}")
|
||||||
|
print(f" failed: {failed}")
|
||||||
|
print(f" mode: {'dry-run' if args.dry_run else 'live'}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user