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) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 19:33:57 +00:00
parent 4c491ce45a
commit 4aad81e11e
15 changed files with 1022 additions and 13 deletions
+4
View File
@@ -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
+7 -3
View File
@@ -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"]
+18 -1
View File
@@ -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)
+40 -2
View File
@@ -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)
+39 -5
View File
@@ -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", [])
+130
View File
@@ -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,
}
+147
View File
@@ -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)
+227
View File
@@ -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,
}
+29 -2
View File
@@ -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)
+139
View File
@@ -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,
}
+58
View File
@@ -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 — הראה מועדים לכל התיקים
@@ -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
@@ -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
+53
View File
@@ -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 ימים הקרובים — הדגש
- הצע פעולות המשך (למשל "האם ליצור משימה?")
+1
View File
@@ -10,6 +10,7 @@ dependencies = [
"pydantic>=2.10.0",
"mcp>=1.0.0",
"openai>=1.50.0",
"pyyaml>=6.0",
]
[project.optional-dependencies]