From 4aad81e11e9e3b6dab3dafc17b5dcd63dfac6fc0 Mon Sep 17 00:00:00 2001 From: Chaim Date: Mon, 13 Apr 2026 19:33:57 +0000 Subject: [PATCH] feat: add skills, memory, delegation and self-learning (Phase 3) Add three Hermes-inspired capabilities to Shira: - Skills system: firm-wide workflow templates (list/view/manage tools), 4 pre-seeded skills (case summary, hearing prep, direct access report, deadline tracker), auto-learning via post-conversation meta-prompt - Cross-conversation memory: per-lawyer profiles and per-case memory stored as bounded markdown files, injected into system prompts - Sub-agent delegation: spawn up to 3 child AgentRunner instances for parallel document analysis and complex multi-step tasks New files: skills.py, memory.py, delegate.py, learner.py, 4 SKILL.md Updated: agent_runner (6 new tools, depth/blocked_tools support), prompt_builder (skills/memory injection), route (memory loading, background learning), main.py (data dirs), Dockerfile (pyyaml, skills) Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 4 + Dockerfile | 10 +- api/main.py | 19 +- api/routes/smart_assistant.py | 42 +++- api/services/agent_runner.py | 44 +++- api/services/delegate.py | 130 ++++++++++ api/services/learner.py | 147 ++++++++++++ api/services/memory.py | 227 ++++++++++++++++++ api/services/prompt_builder.py | 31 ++- api/services/skills.py | 139 +++++++++++ config/skills/deadline-tracker/SKILL.md | 58 +++++ .../skills/direct-access-report-flow/SKILL.md | 78 ++++++ config/skills/hearing-preparation/SKILL.md | 52 ++++ config/skills/legal-case-summary/SKILL.md | 53 ++++ pyproject.toml | 1 + 15 files changed, 1022 insertions(+), 13 deletions(-) create mode 100644 api/services/delegate.py create mode 100644 api/services/learner.py create mode 100644 api/services/memory.py create mode 100644 api/services/skills.py create mode 100644 config/skills/deadline-tracker/SKILL.md create mode 100644 config/skills/direct-access-report-flow/SKILL.md create mode 100644 config/skills/hearing-preparation/SKILL.md create mode 100644 config/skills/legal-case-summary/SKILL.md diff --git a/.env.example b/.env.example index e120572..b46a833 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,11 @@ AI_GATEWAY_API_KEY= # Claude model (passed through to ai-gateway) CLAUDE_MODEL=sonnet MAX_OUTPUT_TOKENS=4096 +MAX_AGENT_ITERATIONS=10 # EspoCRM — for MCP tools ESPOCRM_URL=https://espocrm.dev.marcus-law.co.il ESPOCRM_API_KEY= + +# Data directory — persistent volume for skills, memory, profiles +SHIRA_DATA_DIR=/opt/data diff --git a/Dockerfile b/Dockerfile index ef07135..5cd7e5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,12 +11,16 @@ COPY mcp_server/ mcp_server/ COPY config/ config/ # Install dependencies -RUN pip install --no-cache-dir fastapi "uvicorn[standard]" httpx pydantic openai +RUN pip install --no-cache-dir fastapi "uvicorn[standard]" httpx pydantic openai pyyaml -# Create data directory for skills/memory -RUN mkdir -p /opt/data/skills /opt/data/memory +# Create persistent data directories +RUN mkdir -p /opt/data/skills /opt/data/profiles /opt/data/cases + +# Copy pre-seeded skills (will be overridden by persistent volume if mounted) +COPY config/skills/ /opt/data/skills/ ENV PORT=3000 +ENV SHIRA_DATA_DIR=/opt/data EXPOSE 3000 CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "3000", "--log-level", "info"] diff --git a/api/main.py b/api/main.py index 637de6d..218f97b 100644 --- a/api/main.py +++ b/api/main.py @@ -4,6 +4,7 @@ from __future__ import annotations import os import logging +from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -16,10 +17,12 @@ logging.basicConfig( format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", ) +logger = logging.getLogger("shira.app") + app = FastAPI( title="shira-hermes", description="Shira AI Assistant — Hermes Agent backend for EspoCRM SmartAssistant", - version="0.1.0", + version="0.2.0", ) app.add_middleware( @@ -31,3 +34,17 @@ app.add_middleware( app.include_router(health_router) app.include_router(smart_assistant_router) + + +@app.on_event("startup") +async def ensure_data_dirs(): + """Create persistent data directories if they don't exist.""" + data_dir = os.environ.get("SHIRA_DATA_DIR", "/opt/data") + dirs = [ + f"{data_dir}/skills", + f"{data_dir}/profiles", + f"{data_dir}/cases", + ] + for d in dirs: + Path(d).mkdir(parents=True, exist_ok=True) + logger.info("Data directories ready at %s", data_dir) diff --git a/api/routes/smart_assistant.py b/api/routes/smart_assistant.py index 29f0201..f65aa3a 100644 --- a/api/routes/smart_assistant.py +++ b/api/routes/smart_assistant.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import os import logging @@ -10,6 +11,9 @@ from pydantic import BaseModel from api.services.prompt_builder import build_case_prompt, build_office_prompt from api.services.agent_runner import AgentRunner +from api.services.skills import list_skills +from api.services.memory import load_user_profile, load_case_memory +from api.services.learner import extract_skills logger = logging.getLogger("shira.api") @@ -64,8 +68,29 @@ async def smart_assistant_chat(request: Request): if not message and not conversation_history: return {"text": "no message", "actions": [], "conversationId": conversation_id} - # Build system prompt - system_prompt = build_office_prompt(context) if mode == "office" else build_case_prompt(context) + # Extract IDs for memory loading + case_id = (context.get("case") or {}).get("id") or (context.get("drillDown", {}).get("case", {}).get("id")) + user_id = (context.get("currentUser") or {}).get("id") + + # Load cross-conversation memory and skills + user_profile = load_user_profile(user_id) if user_id else "" + case_memory_notes = load_case_memory(case_id) if case_id else "" + available_skills = list_skills() + + # Build system prompt with memory and skills injected + if mode == "office": + system_prompt = build_office_prompt( + context, + user_profile=user_profile, + available_skills=available_skills, + ) + else: + system_prompt = build_case_prompt( + context, + user_profile=user_profile, + case_memory_notes=case_memory_notes, + available_skills=available_skills, + ) # Build messages from conversation history messages = [] @@ -100,9 +125,22 @@ async def smart_assistant_chat(request: Request): logger.info("[smart-assistant] response: %s...", text[:80]) + # Trigger self-learning in the background (non-blocking) + conversation_messages = runner.get_messages() + if conversation_messages: + asyncio.create_task(_learn_in_background(conversation_messages)) + return { "text": text, "actions": [], "executedActions": [], "conversationId": conversation_id, } + + +async def _learn_in_background(messages: list[dict]) -> None: + """Run skill extraction in background — errors are swallowed and logged.""" + try: + await extract_skills(messages) + except Exception as e: + logger.error("[learner] background learning failed: %s", e) diff --git a/api/services/agent_runner.py b/api/services/agent_runner.py index 37c78a9..5b27ccc 100644 --- a/api/services/agent_runner.py +++ b/api/services/agent_runner.py @@ -12,6 +12,9 @@ from mcp_server.espocrm_client import EspoCrmClient from mcp_server.tools.crm_tools import register_crm_tools from mcp_server.tools.document_tools import register_document_tools from mcp_server.tools.legal_tools import register_legal_tools +from api.services.skills import register_skill_tools +from api.services.memory import register_memory_tools +from api.services.delegate import register_delegate_tool logger = logging.getLogger("shira.agent") @@ -50,8 +53,17 @@ class AgentRunner: context: dict, espocrm_url: str, espocrm_api_key: str, + depth: int = 0, + blocked_tools: set[str] | None = None, + allowed_toolsets: list[str] | None = None, ) -> str: - """Run a conversation with tool-calling loop. Returns the final text response.""" + """Run a conversation with tool-calling loop. Returns the final text response. + + Args: + depth: Current delegation depth. 0 = main conversation, 1 = child agent. + blocked_tools: Tool names to exclude (used by delegation to prevent recursion). + allowed_toolsets: If set, only register tools from these categories (crm, documents, legal). + """ # Build tools from context crm = EspoCrmClient(espocrm_url, espocrm_api_key) @@ -59,9 +71,26 @@ class AgentRunner: user_id = (context.get("currentUser") or {}).get("id") tools_registry: dict[str, dict] = {} - register_crm_tools(tools_registry, crm, case_id, user_id, context) - register_document_tools(tools_registry, crm, case_id, context) - register_legal_tools(tools_registry, crm, case_id, context) + + # Core CRM/document/legal tools + should_register_all = allowed_toolsets is None + if should_register_all or "crm" in (allowed_toolsets or []): + register_crm_tools(tools_registry, crm, case_id, user_id, context) + if should_register_all or "documents" in (allowed_toolsets or []): + register_document_tools(tools_registry, crm, case_id, context) + if should_register_all or "legal" in (allowed_toolsets or []): + register_legal_tools(tools_registry, crm, case_id, context) + + # Phase 3 tools (only for main conversation, not child agents) + if depth == 0: + register_skill_tools(tools_registry) + register_memory_tools(tools_registry, case_id, user_id) + register_delegate_tool(tools_registry, context, espocrm_url, espocrm_api_key) + + # Remove blocked tools (for delegation safety) + if blocked_tools: + for name in blocked_tools: + tools_registry.pop(name, None) # Build OpenAI tools format openai_tools = [] @@ -78,10 +107,11 @@ class AgentRunner: # Build messages with system prompt openai_messages = [{"role": "system", "content": system_prompt}] openai_messages.extend(messages) + self._last_messages = openai_messages # Agentic loop for iteration in range(self.max_iterations): - logger.info("[agent] iteration %d, messages=%d", iteration + 1, len(openai_messages)) + logger.info("[agent] depth=%d iteration %d, messages=%d", depth, iteration + 1, len(openai_messages)) try: response = await self.client.chat.completions.create( @@ -133,3 +163,7 @@ class AgentRunner: # Exhausted iterations logger.warning("[agent] max iterations (%d) reached", self.max_iterations) return "הגעתי למספר המקסימלי של פעולות. נסי לשאול שוב בצורה ממוקדת יותר." + + def get_messages(self) -> list[dict]: + """Return the conversation messages from the last run (for post-conversation learning).""" + return getattr(self, "_last_messages", []) diff --git a/api/services/delegate.py b/api/services/delegate.py new file mode 100644 index 0000000..a070fec --- /dev/null +++ b/api/services/delegate.py @@ -0,0 +1,130 @@ +"""Delegation engine — spawn child AgentRunner instances for complex parallel tasks.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os + +logger = logging.getLogger("shira.delegate") + +MAX_CONCURRENT_CHILDREN = 3 +BLOCKED_TOOLS = {"delegate_task", "update_user_profile", "update_case_memory", "skill_manage"} + + +async def run_child_task( + goal: str, + context: dict, + espocrm_url: str, + espocrm_api_key: str, + allowed_toolsets: list[str] | None = None, +) -> str: + """Run a single child agent conversation with a focused goal.""" + from api.services.agent_runner import AgentRunner + + runner = AgentRunner( + ai_gateway_url=os.environ.get("AI_GATEWAY_URL", "http://localhost:3000"), + ai_gateway_api_key=os.environ.get("AI_GATEWAY_API_KEY", ""), + model=os.environ.get("CLAUDE_MODEL", "sonnet"), + max_tokens=int(os.environ.get("MAX_OUTPUT_TOKENS", "4096")), + max_iterations=8, + ) + + system_prompt = ( + "You are a focused sub-agent working on a specific delegated task.\n" + "Complete the task thoroughly and return a clear, concise result.\n" + "Always respond in Hebrew.\n\n" + f"YOUR TASK:\n{goal}\n" + ) + + messages = [{"role": "user", "content": goal}] + + try: + result = await runner.run( + system_prompt=system_prompt, + messages=messages, + context=context, + espocrm_url=espocrm_url, + espocrm_api_key=espocrm_api_key, + depth=1, + blocked_tools=BLOCKED_TOOLS, + allowed_toolsets=allowed_toolsets, + ) + return result + except Exception as e: + logger.error("Child task failed: %s — %s", goal[:50], e) + return f"❌ משימה נכשלה: {e}" + + +async def delegate_tasks( + tasks: list[dict], + context: dict, + espocrm_url: str, + espocrm_api_key: str, +) -> str: + """Run multiple child tasks concurrently. Returns combined results.""" + if len(tasks) > MAX_CONCURRENT_CHILDREN: + tasks = tasks[:MAX_CONCURRENT_CHILDREN] + logger.warning("Truncated to %d concurrent children", MAX_CONCURRENT_CHILDREN) + + coros = [ + run_child_task( + goal=t["goal"], + context=context, + espocrm_url=espocrm_url, + espocrm_api_key=espocrm_api_key, + allowed_toolsets=t.get("toolsets"), + ) + for t in tasks + ] + + results = await asyncio.gather(*coros, return_exceptions=True) + + parts = [] + for i, (task, result) in enumerate(zip(tasks, results), 1): + if isinstance(result, Exception): + parts.append(f"### משימה {i}: {task['goal']}\n❌ שגיאה: {result}") + else: + parts.append(f"### משימה {i}: {task['goal']}\n{result}") + + return "\n\n".join(parts) + + +def register_delegate_tool( + tools: dict, + context: dict, + espocrm_url: str, + espocrm_api_key: str, +) -> None: + """Register the delegate_task tool.""" + + async def delegate_task(goal: str, context_extra: str = "", toolsets: str = "") -> str: + """Delegate a single complex task to a focused sub-agent.""" + allowed = toolsets.split(",") if toolsets else None + result = await run_child_task( + goal=f"{goal}\n\n{context_extra}" if context_extra else goal, + context=context, + espocrm_url=espocrm_url, + espocrm_api_key=espocrm_api_key, + allowed_toolsets=allowed, + ) + return result + + tools["delegate_task"] = { + "description": ( + "Delegate a complex task to a focused sub-agent that runs independently. " + "Use for: parallel document analysis, multi-step research, report preparation. " + "The sub-agent has access to CRM and document tools but cannot delegate further or modify memory/skills." + ), + "parameters": { + "type": "object", + "properties": { + "goal": {"type": "string", "description": "Clear description of the task for the sub-agent"}, + "context_extra": {"type": "string", "description": "Additional context to help the sub-agent"}, + "toolsets": {"type": "string", "description": "Comma-separated tool categories to allow (crm,documents,legal). Empty = all."}, + }, + "required": ["goal"], + }, + "handler": delegate_task, + } diff --git a/api/services/learner.py b/api/services/learner.py new file mode 100644 index 0000000..1838826 --- /dev/null +++ b/api/services/learner.py @@ -0,0 +1,147 @@ +"""Self-learning module — post-conversation skill extraction via meta-prompt.""" + +from __future__ import annotations + +import json +import logging +import os + +from openai import AsyncOpenAI + +from api.services.skills import list_skills, view_skill, save_skill + +logger = logging.getLogger("shira.learner") + +# Minimum thresholds to trigger learning +MIN_TOOL_CALLS = 3 +MIN_TURNS = 4 + +EXTRACTION_PROMPT = """\ +אתה מנתח שיחות ומזהה תבניות עבודה (workflows) שניתן להפוך ל-skills לשימוש חוזר. + +נתח את השיחה הבאה בין עורך דין לעוזרת AI משפטית. חפש: +- רצף פעולות שחוזר על עצמו או שנראה כתבנית כללית +- workflow מורכב שעורך דין אחר יכול להשתמש בו +- תהליך שכדאי לתעד כדי שהעוזרת תבצע אותו טוב יותר בפעם הבאה + +Skills קיימים (אל תיצור כפילויות): +{existing_skills} + +אם מצאת workflow חדש שראוי להיות skill, החזר JSON: +{{"create": true, "name": "skill-name-in-english", "description": "תיאור בעברית", "content": "תוכן ה-SKILL.md המלא כולל frontmatter"}} + +אם מצאת שיפור ל-skill קיים, החזר: +{{"update": true, "name": "existing-skill-name", "description": "תיאור מעודכן", "content": "תוכן SKILL.md מעודכן"}} + +אם אין מה ללמוד, החזר: +{{"create": false}} + +חשוב: החזר רק JSON תקין, בלי טקסט נוסף. +""" + + +def _should_learn(messages: list[dict]) -> bool: + """Check if conversation meets learning thresholds.""" + tool_calls = sum(1 for m in messages if m.get("role") == "tool") + turns = sum(1 for m in messages if m.get("role") in ("user", "assistant")) + return tool_calls >= MIN_TOOL_CALLS and turns >= MIN_TURNS + + +def _summarize_conversation(messages: list[dict]) -> str: + """Create a condensed version of the conversation for the extraction prompt.""" + parts = [] + for m in messages: + role = m.get("role", "") + content = m.get("content", "") + if role == "system": + continue + if role == "tool": + tool_id = m.get("tool_call_id", "") + parts.append(f"[Tool Result {tool_id}]: {content[:200]}") + elif role == "assistant": + # Include tool calls info if present + tool_calls = m.get("tool_calls", []) + if tool_calls: + for tc in tool_calls: + fn = tc.get("function", {}) + parts.append(f"[Assistant calls {fn.get('name', '?')}({fn.get('arguments', '')[:100]})]") + elif content: + parts.append(f"[Assistant]: {content[:300]}") + elif role == "user": + parts.append(f"[User]: {content[:200]}") + + return "\n".join(parts) + + +async def extract_skills(messages: list[dict]) -> None: + """Analyze a completed conversation and extract skills if applicable. + + This runs as a background task — errors are logged but never raised. + """ + if not _should_learn(messages): + logger.debug("Conversation too short for learning (skipped)") + return + + try: + existing = list_skills() + existing_summary = "\n".join( + f'- {s["name"]}: {s["description"]}' for s in existing + ) or "אין skills קיימים." + + conversation_text = _summarize_conversation(messages) + prompt = EXTRACTION_PROMPT.format(existing_skills=existing_summary) + + client = AsyncOpenAI( + base_url=os.environ.get("AI_GATEWAY_URL", "http://localhost:3000").rstrip("/") + "/v1", + api_key=os.environ.get("AI_GATEWAY_API_KEY", ""), + ) + + response = await client.chat.completions.create( + model=os.environ.get("CLAUDE_MODEL", "sonnet"), + messages=[ + {"role": "system", "content": prompt}, + {"role": "user", "content": f"השיחה לניתוח:\n\n{conversation_text}"}, + ], + max_tokens=2048, + ) + + result_text = (response.choices[0].message.content or "").strip() + + # Parse JSON — handle possible markdown wrapping + if result_text.startswith("```"): + result_text = result_text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() + + result = json.loads(result_text) + + if result.get("create"): + name = result["name"] + existing_content = view_skill(name) + if existing_content: + logger.info("Skill '%s' already exists, skipping create", name) + return + content = result.get("content", "") + if not content: + # Build content from description + content = ( + f"---\nname: {name}\n" + f"description: {result.get('description', '')}\n" + f"version: 1.0.0\n---\n\n" + f"{result.get('description', '')}\n" + ) + save_skill(name, content) + logger.info("Self-learning: created skill '%s'", name) + + elif result.get("update"): + name = result["name"] + content = result.get("content", "") + if content: + save_skill(name, content) + logger.info("Self-learning: updated skill '%s'", name) + + else: + logger.debug("Self-learning: no new skill identified") + + except json.JSONDecodeError as e: + logger.warning("Self-learning: failed to parse extraction result: %s", e) + except Exception as e: + logger.error("Self-learning error: %s", e) diff --git a/api/services/memory.py b/api/services/memory.py new file mode 100644 index 0000000..2beabf0 --- /dev/null +++ b/api/services/memory.py @@ -0,0 +1,227 @@ +"""Per-lawyer and per-case memory store — bounded markdown files with atomic writes.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import tempfile +from pathlib import Path + +logger = logging.getLogger("shira.memory") + +DATA_DIR = os.environ.get("SHIRA_DATA_DIR", "/opt/data") +PROFILES_DIR = f"{DATA_DIR}/profiles" +CASES_DIR = f"{DATA_DIR}/cases" + +PROFILE_MAX_CHARS = 1500 +CASE_MEMORY_MAX_CHARS = 2500 +ENTRY_DELIMITER = "\n§\n" + +# Per-path locks for thread-safe writes +_locks: dict[str, asyncio.Lock] = {} + + +def _get_lock(path: str) -> asyncio.Lock: + if path not in _locks: + _locks[path] = asyncio.Lock() + return _locks[path] + + +def _read_file(path: str) -> str: + p = Path(path) + if not p.exists(): + return "" + return p.read_text(encoding="utf-8") + + +def _write_file_atomic(path: str, content: str) -> None: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(p.parent), suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + os.rename(tmp, str(p)) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def _entries(content: str) -> list[str]: + if not content.strip(): + return [] + return [e.strip() for e in content.split(ENTRY_DELIMITER) if e.strip()] + + +def _join_entries(entries: list[str]) -> str: + return ENTRY_DELIMITER.join(entries) + + +def _truncate(content: str, max_chars: int) -> str: + """Remove oldest entries (from the top) until content fits.""" + if len(content) <= max_chars: + return content + entries = _entries(content) + while entries and len(_join_entries(entries)) > max_chars: + entries.pop(0) + return _join_entries(entries) + + +# ---------- Public API ---------- + +def load_user_profile(user_id: str) -> str: + """Load the per-lawyer profile. Returns empty string if none exists.""" + return _read_file(f"{PROFILES_DIR}/{user_id}.md") + + +def load_case_memory(case_id: str) -> str: + """Load the per-case memory. Returns empty string if none exists.""" + return _read_file(f"{CASES_DIR}/{case_id}/memory.md") + + +async def update_user_profile(user_id: str, action: str, content: str, match: str = "") -> str: + """Add, replace, or remove an entry in the lawyer's profile.""" + path = f"{PROFILES_DIR}/{user_id}.md" + async with _get_lock(path): + current = _read_file(path) + entries = _entries(current) + + if action == "add": + entries.append(content) + result = _truncate(_join_entries(entries), PROFILE_MAX_CHARS) + _write_file_atomic(path, result) + return f'✅ נוסף לפרופיל: "{content[:60]}"' + + elif action == "replace": + if not match: + return "❌ צריך לספק match כדי למצוא את הרשומה להחלפה." + replaced = False + for i, e in enumerate(entries): + if match in e: + entries[i] = content + replaced = True + break + if not replaced: + return f'❌ לא נמצאה רשומה עם "{match}"' + result = _truncate(_join_entries(entries), PROFILE_MAX_CHARS) + _write_file_atomic(path, result) + return f'✅ רשומה עודכנה בפרופיל.' + + elif action == "remove": + if not match: + return "❌ צריך לספק match כדי למצוא את הרשומה למחיקה." + original_len = len(entries) + entries = [e for e in entries if match not in e] + if len(entries) == original_len: + return f'❌ לא נמצאה רשומה עם "{match}"' + _write_file_atomic(path, _join_entries(entries)) + return "✅ רשומה הוסרה מהפרופיל." + + elif action == "read": + return current if current else "הפרופיל ריק." + + return f"❌ פעולה לא מוכרת: {action}" + + +async def update_case_memory(case_id: str, action: str, content: str, match: str = "") -> str: + """Add, replace, or remove an entry in the case memory.""" + path = f"{CASES_DIR}/{case_id}/memory.md" + async with _get_lock(path): + current = _read_file(path) + entries = _entries(current) + + if action == "add": + entries.append(content) + result = _truncate(_join_entries(entries), CASE_MEMORY_MAX_CHARS) + _write_file_atomic(path, result) + return f'✅ נשמר בזיכרון התיק: "{content[:60]}"' + + elif action == "replace": + if not match: + return "❌ צריך לספק match כדי למצוא את הרשומה להחלפה." + replaced = False + for i, e in enumerate(entries): + if match in e: + entries[i] = content + replaced = True + break + if not replaced: + return f'❌ לא נמצאה רשומה עם "{match}"' + result = _truncate(_join_entries(entries), CASE_MEMORY_MAX_CHARS) + _write_file_atomic(path, result) + return "✅ רשומה עודכנה בזיכרון התיק." + + elif action == "remove": + if not match: + return "❌ צריך לספק match כדי למצוא את הרשומה למחיקה." + original_len = len(entries) + entries = [e for e in entries if match not in e] + if len(entries) == original_len: + return f'❌ לא נמצאה רשומה עם "{match}"' + _write_file_atomic(path, _join_entries(entries)) + return "✅ רשומה הוסרה מזיכרון התיק." + + elif action == "read": + return current if current else "זיכרון התיק ריק." + + return f"❌ פעולה לא מוכרת: {action}" + + +def register_memory_tools(tools: dict, case_id: str | None, user_id: str | None) -> None: + """Register memory tools into the tools dict.""" + + async def _update_user_profile(action: str, content: str = "", match: str = "") -> str: + if not user_id: + return "❌ לא ניתן לזהות את המשתמש." + return await update_user_profile(user_id, action, content, match) + + async def _update_case_memory(action: str, content: str = "", match: str = "") -> str: + if not case_id: + return "❌ צריך להיות בתוך תיק כדי לעדכן את הזיכרון." + return await update_case_memory(case_id, action, content, match) + + tools["update_user_profile"] = { + "description": ( + "Manage the lawyer's personal profile — preferences, communication style, common case types. " + "This persists across conversations. Use proactively when you learn about the user's preferences." + ), + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["add", "replace", "remove", "read"], + "description": "add=append entry, replace=find by match and replace, remove=find by match and delete, read=show current profile", + }, + "content": {"type": "string", "description": "The content to add or replace with"}, + "match": {"type": "string", "description": "Substring to match when replacing or removing"}, + }, + "required": ["action"], + }, + "handler": _update_user_profile, + } + + tools["update_case_memory"] = { + "description": ( + "Manage the case's persistent memory — key facts, strategy, decisions, timeline. " + "This persists across conversations about this case. Use alongside save_memory (which writes to CRM notes)." + ), + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["add", "replace", "remove", "read"], + "description": "add=append entry, replace=find by match and replace, remove=find by match and delete, read=show current memory", + }, + "content": {"type": "string", "description": "The content to add or replace with"}, + "match": {"type": "string", "description": "Substring to match when replacing or removing"}, + }, + "required": ["action"], + }, + "handler": _update_case_memory, + } diff --git a/api/services/prompt_builder.py b/api/services/prompt_builder.py index 8dbd850..03d09f7 100644 --- a/api/services/prompt_builder.py +++ b/api/services/prompt_builder.py @@ -57,7 +57,7 @@ def _now() -> str: return datetime.now(ISR_TZ).strftime("%d/%m/%Y %H:%M") -def build_case_prompt(context: dict) -> str: +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", []) @@ -155,10 +155,25 @@ def build_case_prompt(context: dict) -> str: parts.append(f'\n\nCurrent user: {current_user.get("name", "")}') parts.append(f"\n\n{LEGAL_ASSISTANCE_PROMPT}") + # 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) + + if user_profile: + parts.append("\n\n=== ABOUT THIS USER (learned preferences) ===\n") + parts.append(user_profile) + return "".join(parts) -def build_office_prompt(context: dict) -> str: +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", {}) @@ -223,4 +238,16 @@ def build_office_prompt(context: dict) -> str: 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 + if user_profile: + parts.append("\n\n=== ABOUT THIS USER (learned preferences) ===\n") + parts.append(user_profile) + return "".join(parts) diff --git a/api/services/skills.py b/api/services/skills.py new file mode 100644 index 0000000..ce4f3d0 --- /dev/null +++ b/api/services/skills.py @@ -0,0 +1,139 @@ +"""Skills engine — list, view, manage, and register skill tools.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +import yaml + +logger = logging.getLogger("shira.skills") + +SKILLS_DIR = os.environ.get("SHIRA_DATA_DIR", "/opt/data") + "/skills" + + +def _parse_frontmatter(text: str) -> tuple[dict, str]: + """Parse YAML frontmatter from a SKILL.md file. Returns (meta, body).""" + if not text.startswith("---"): + return {}, text + end = text.find("---", 3) + if end == -1: + return {}, text + try: + meta = yaml.safe_load(text[3:end]) or {} + except yaml.YAMLError: + meta = {} + body = text[end + 3:].lstrip("\n") + return meta, body + + +def list_skills() -> list[dict]: + """Scan the skills directory and return metadata for each skill.""" + skills_path = Path(SKILLS_DIR) + if not skills_path.exists(): + return [] + + result = [] + for entry in sorted(skills_path.iterdir()): + skill_file = entry / "SKILL.md" if entry.is_dir() else None + if not skill_file or not skill_file.exists(): + continue + try: + meta, _ = _parse_frontmatter(skill_file.read_text(encoding="utf-8")) + result.append({ + "name": meta.get("name", entry.name), + "description": meta.get("description", ""), + "version": meta.get("version", "1.0.0"), + }) + except Exception as e: + logger.warning("Failed to parse skill %s: %s", entry.name, e) + return result + + +def view_skill(skill_name: str) -> str | None: + """Load the full SKILL.md content for a given skill name.""" + skill_file = Path(SKILLS_DIR) / skill_name / "SKILL.md" + if not skill_file.exists(): + return None + return skill_file.read_text(encoding="utf-8") + + +def save_skill(skill_name: str, content: str) -> None: + """Create or overwrite a skill file.""" + skill_dir = Path(SKILLS_DIR) / skill_name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text(content, encoding="utf-8") + logger.info("Saved skill: %s", skill_name) + + +def register_skill_tools(tools: dict) -> None: + """Register skill-related tools into the tools dict.""" + + async def skills_list() -> str: + import json + skills = list_skills() + if not skills: + return "אין skills זמינים כרגע." + lines = ["Skills זמינים:"] + for s in skills: + lines.append(f'- **{s["name"]}**: {s["description"]}') + return "\n".join(lines) + + async def skill_view(skill_name: str) -> str: + content = view_skill(skill_name) + if content is None: + return f'❌ Skill "{skill_name}" לא נמצא.' + return content + + async def skill_manage(action: str, skill_name: str, content: str = "") -> str: + if action == "create": + if not content: + return "❌ צריך לספק תוכן ל-skill חדש." + existing = view_skill(skill_name) + if existing: + return f'❌ Skill "{skill_name}" כבר קיים. השתמש ב-action "update" כדי לעדכן.' + save_skill(skill_name, content) + return f'✅ Skill "{skill_name}" נוצר בהצלחה.' + elif action == "update": + existing = view_skill(skill_name) + if not existing: + return f'❌ Skill "{skill_name}" לא נמצא. השתמש ב-action "create" כדי ליצור.' + if not content: + return "❌ צריך לספק תוכן מעודכן." + save_skill(skill_name, content) + return f'✅ Skill "{skill_name}" עודכן בהצלחה.' + else: + return f'❌ פעולה לא מוכרת: {action}. השתמש ב-"create" או "update".' + + tools["skills_list"] = { + "description": "List all available skills (learned workflows and templates). Returns skill names and descriptions.", + "parameters": {"type": "object", "properties": {}, "required": []}, + "handler": skills_list, + } + + tools["skill_view"] = { + "description": "View the full content of a specific skill. Use after skills_list to load a relevant workflow before a complex task.", + "parameters": { + "type": "object", + "properties": { + "skill_name": {"type": "string", "description": "The skill name (from skills_list)"}, + }, + "required": ["skill_name"], + }, + "handler": skill_view, + } + + tools["skill_manage"] = { + "description": "Create or update a skill. Use when you discover a reusable workflow pattern during a conversation.", + "parameters": { + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["create", "update"], "description": "Create new or update existing"}, + "skill_name": {"type": "string", "description": "Skill name (lowercase-dashes)"}, + "content": {"type": "string", "description": "Full skill content in markdown with YAML frontmatter"}, + }, + "required": ["action", "skill_name", "content"], + }, + "handler": skill_manage, + } diff --git a/config/skills/deadline-tracker/SKILL.md b/config/skills/deadline-tracker/SKILL.md new file mode 100644 index 0000000..e580111 --- /dev/null +++ b/config/skills/deadline-tracker/SKILL.md @@ -0,0 +1,58 @@ +--- +name: deadline-tracker +description: סריקת מועדים קריטיים והתראות פרואקטיביות +version: 1.0.0 +--- + +# מעקב מועדים + +## מתי להשתמש +- כשעורך דין שואל "מה יש לי השבוע?" / "מה דחוף?" +- כשמזהים משימות באיחור בקונטקסט +- כשיש דיון ב-7 ימים הקרובים +- במצב office — כשמבקשים סקירת מועדים + +## תהליך + +1. **סרוק את הקונטקסט** לכל דבר עם תאריך: + - `openTasks` — בדוק `dateEnd` מול התאריך הנוכחי + - `upcomingMeetings` — בדוק `dateStart` + - `cNextHearing` — מועד הדיון הבא + - `cFilingDeadline` — מועד הגשה (גישה ישירה) + +2. **סווג לפי דחיפות:** + - 🔴 **עבר מועד** — משימות שה-dateEnd עבר + - 🟠 **היום / מחר** — פעולות ל-24 שעות הקרובות + - 🟡 **השבוע** — ב-7 ימים הקרובים + - 🟢 **בהמשך** — מעבר ל-7 ימים + +3. **הצג בפורמט:** + +``` +📅 מעקב מועדים — [תאריך היום] + +🔴 עבר מועד: +- [משימה] — היה צריך ב-[תאריך] (לפני [X] ימים) + +🟠 היום / מחר: +- [פגישה/דיון] — [תאריך ושעה] +- [משימה] — מועד: [תאריך] + +🟡 השבוע: +- [דיון] — [תאריך] ב-[בית משפט] +- [משימה] — מועד: [תאריך] + +🟢 בהמשך: +- [פגישה] — [תאריך] +``` + +4. **הצע פעולות:** + - למשימות באיחור: "האם לעדכן את המועד?" + - לדיון קרוב: "האם להתחיל הכנה?" (skill: hearing-preparation) + - למועד הגשה קרוב: "האם יש מה להכין?" + +## כללים +- תמיד ציין כמה ימים נשארו / עברו +- סדר לפי דחיפות (עבר מועד → היום → השבוע) +- אם אין כלום דחוף — ציין "אין מועדים דחופים" +- שלב עם office mode — הראה מועדים לכל התיקים diff --git a/config/skills/direct-access-report-flow/SKILL.md b/config/skills/direct-access-report-flow/SKILL.md new file mode 100644 index 0000000..21ef290 --- /dev/null +++ b/config/skills/direct-access-report-flow/SKILL.md @@ -0,0 +1,78 @@ +--- +name: direct-access-report-flow +description: תהליך שלב-אחר-שלב לאיסוף מידע ויצירת דוח גישה ישירה +version: 1.0.0 +--- + +# דוח גישה ישירה — תהליך איסוף מידע + +## מתי להשתמש +כשעורך דין מבקש ליצור דוח גישה ישירה / דוח דיווח ראשוני, או כשהתיק מסוג DirectAccess. + +## 8 שלבי התהליך + +### שלב 1: מילוי אוטומטי +שלוף מהקונטקסט של התיק: +- `cAppointmentDate` → תאריך מינוי +- `cLegalAidType` → סוג סיוע +- `cUrgencyLevel` → רמת דחיפות +- `cFilingDeadline` → מועד הגשה + +### שלב 2: פרטי מינוי +שאל על: +- תאריך מינוי (אם לא קיים) +- סוג סיוע משפטי (ייצוג/ייעוץ/הגשת כתב טענות) +- רמת דחיפות +- מועד אחרון להגשה + +### שלב 3: קשר עם לקוח +שאל (קבץ 2-3 שאלות): +- האם נוצר קשר עם הלקוח? +- תאריך יצירת קשר ראשון +- אם היה עיכוב — מה הסיבה? +- תאריך פגישה +- אם היה עיכוב בפגישה — מה הסיבה? + +### שלב 4: פרטי הליך +שאל: +- מספר הליך +- בית משפט (בחירה מ-6 אפשרויות) +- נושא ההליך +- תמצית התביעה +- טענות ההגנה + +### שלב 5: ניתוח משפטי (12 שאלות) +שאל בקבוצות של 2-3: +- q1: עיון במסמכים — האם עיינת? מה מצאת? +- q2: נימוקים — האם עיינת בנימוקי הערעור? +- q3: הרכב — האם יש התאמה/אי-התאמה בהרכב? +- q4: תלונות — האם הוגשו תלונות? +- q5: שימוע — האם היה שימוע כדין? +- q6: בדיקות — האם בוצעו בדיקות קליניות? +- q7: שיקום — האם בוצע שיקום? +- q8: ציון — מה הציון שנקבע? +- q9: קביעה — האם הקביעה סבירה? +- q10: ייחודי — האם יש היבט ייחודי? +- q11: תקדימים — פסיקה רלוונטית +- q12: סיכום — הערכה כללית + +### שלב 6: המלצה +שאל: +- סוג המלצה (9 אפשרויות) +- פירוט ההמלצה + +### שלב 7: מיצוי זכויות +שאל: +- האם נדרש סיוע נוסף? +- פירוט +- דחיפות + +### שלב 8: יצירת הדוח +כשכל המידע נאסף — הפעל generate_initial_report עם כל הפרמטרים. + +## כללים חשובים +- **לא** להפעיל את generate_initial_report עד שכל המידע נאסף +- שאל בסגנון שיחה טבעי, לא כטופס +- קבץ 2-3 שאלות קשורות ביחד +- אם המשתמש לא יודע תשובה — דלג ועבור הלאה +- שמור התקדמות בזיכרון עם save_memory diff --git a/config/skills/hearing-preparation/SKILL.md b/config/skills/hearing-preparation/SKILL.md new file mode 100644 index 0000000..13e15d4 --- /dev/null +++ b/config/skills/hearing-preparation/SKILL.md @@ -0,0 +1,52 @@ +--- +name: hearing-preparation +description: הכנה לדיון בבית משפט — צ'קליסט ותזכורות +version: 1.0.0 +--- + +# הכנה לדיון בבית משפט + +## מתי להשתמש +כשעורך דין מבקש להתכונן לדיון, שואל "מה צריך להכין", או כשיש דיון בשבוע הקרוב. + +## תהליך + +1. **בדיקת פרטי הדיון** — תאריך, בית משפט, שופט, נושא +2. **סריקת מסמכים** — השתמש ב-list_documents ובדוק אילו מסמכים רלוונטיים +3. **בדיקת משימות פתוחות** — האם יש משימות הכנה שטרם הושלמו +4. **עובדות מפתח** — שלוף מזיכרון התיק (strategy, key_facts, decisions) +5. **יצירת צ'קליסט** — הצע רשימת הכנה + +## צ'קליסט הכנה לדיון + +``` +✅ הכנה לדיון — [תאריך] — [בית משפט] + +📑 מסמכים: +☐ כתבי טענות מעודכנים +☐ פרוטוקולים מדיונים קודמים +☐ ראיות / נספחים +☐ חוות דעת מומחה (אם רלוונטי) + +📋 הכנה מקצועית: +☐ סיכום טענות עיקריות +☐ רשימת תקדימים רלוונטיים +☐ רשימת עדים (אם רלוונטי) +☐ חישובי סכומים (אם רלוונטי) + +📞 תיאום: +☐ אישור מועד עם לקוח +☐ תיאום עם עדים +☐ עדכון צד שכנגד (אם נדרש) + +📦 ליום הדיון: +☐ עותקים לשופט ולצדדים +☐ תעודת זהות הלקוח +☐ ייפוי כוח (אם נדרש) +``` + +## כללים +- אם חסרים מסמכים קריטיים — הדגש ב-⚠️ +- הצע ליצור משימות לכל פריט חסר +- אם הדיון בעוד פחות מ-3 ימים — סמן דחיפות +- שמור עובדות מפתח בזיכרון עם save_memory diff --git a/config/skills/legal-case-summary/SKILL.md b/config/skills/legal-case-summary/SKILL.md new file mode 100644 index 0000000..2aea9ff --- /dev/null +++ b/config/skills/legal-case-summary/SKILL.md @@ -0,0 +1,53 @@ +--- +name: legal-case-summary +description: יצירת סיכום תיק משפטי מקיף עם כל הפרטים הרלוונטיים +version: 1.0.0 +--- + +# סיכום תיק משפטי + +## מתי להשתמש +כשעורך דין מבקש סיכום תיק, סקירה כללית, או "מה המצב בתיק". + +## תהליך + +1. **איסוף מידע** — השתמש ב-query_info עם query_type "general" כדי לקבל את כל פרטי התיק +2. **סריקת משימות** — בדוק openTasks ו-overdue +3. **סריקת פגישות** — בדוק upcomingMeetings ו-recentMeetings +4. **מסמכים** — אם יש מסמכים רלוונטיים, ציין אותם +5. **זיכרון** — כלול עובדות מפתח מ-caseMemory + +## מבנה הסיכום + +``` +📋 סיכום תיק: [שם התיק] (#[מספר]) + +📊 סטטוס: [סטטוס בעברית] +⚖️ בית משפט: [שם] | שופט: [שם] +📅 דיון הבא: [תאריך] + +👥 אנשי קשר: +- [שם] ([תפקיד]) — [טלפון] + +📌 עובדות מפתח: +- [מזיכרון התיק] + +📋 משימות פתוחות ([מספר]): +- [רשימה] + +📅 פגישות קרובות: +- [רשימה] + +📝 הערות אחרונות: +- [רשימה] + +⚠️ דגשים: +- [משימות באיחור, מועדים קרובים, פעולות נדרשות] +``` + +## כללים +- תמיד בעברית +- תאריכים בפורמט DD/MM/YYYY +- סמן משימות באיחור ב-⚠️ +- אם יש מועד דיון ב-7 ימים הקרובים — הדגש +- הצע פעולות המשך (למשל "האם ליצור משימה?") diff --git a/pyproject.toml b/pyproject.toml index 06a3ccc..a15daa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "pydantic>=2.10.0", "mcp>=1.0.0", "openai>=1.50.0", + "pyyaml>=6.0", ] [project.optional-dependencies]