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 (passed through to ai-gateway)
CLAUDE_MODEL=sonnet CLAUDE_MODEL=sonnet
MAX_OUTPUT_TOKENS=4096 MAX_OUTPUT_TOKENS=4096
MAX_AGENT_ITERATIONS=10
# EspoCRM — for MCP tools # EspoCRM — for MCP tools
ESPOCRM_URL=https://espocrm.dev.marcus-law.co.il ESPOCRM_URL=https://espocrm.dev.marcus-law.co.il
ESPOCRM_API_KEY= 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/ COPY config/ config/
# Install dependencies # 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 # Create persistent data directories
RUN mkdir -p /opt/data/skills /opt/data/memory 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 PORT=3000
ENV SHIRA_DATA_DIR=/opt/data
EXPOSE 3000 EXPOSE 3000
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "3000", "--log-level", "info"] 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 os
import logging import logging
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -16,10 +17,12 @@ logging.basicConfig(
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
) )
logger = logging.getLogger("shira.app")
app = FastAPI( app = FastAPI(
title="shira-hermes", title="shira-hermes",
description="Shira AI Assistant — Hermes Agent backend for EspoCRM SmartAssistant", description="Shira AI Assistant — Hermes Agent backend for EspoCRM SmartAssistant",
version="0.1.0", version="0.2.0",
) )
app.add_middleware( app.add_middleware(
@@ -31,3 +34,17 @@ app.add_middleware(
app.include_router(health_router) app.include_router(health_router)
app.include_router(smart_assistant_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 from __future__ import annotations
import asyncio
import os import os
import logging 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.prompt_builder import build_case_prompt, build_office_prompt
from api.services.agent_runner import AgentRunner 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") logger = logging.getLogger("shira.api")
@@ -64,8 +68,29 @@ async def smart_assistant_chat(request: Request):
if not message and not conversation_history: if not message and not conversation_history:
return {"text": "no message", "actions": [], "conversationId": conversation_id} return {"text": "no message", "actions": [], "conversationId": conversation_id}
# Build system prompt # Extract IDs for memory loading
system_prompt = build_office_prompt(context) if mode == "office" else build_case_prompt(context) 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 # Build messages from conversation history
messages = [] messages = []
@@ -100,9 +125,22 @@ async def smart_assistant_chat(request: Request):
logger.info("[smart-assistant] response: %s...", text[:80]) 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 { return {
"text": text, "text": text,
"actions": [], "actions": [],
"executedActions": [], "executedActions": [],
"conversationId": conversation_id, "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)
+36 -2
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.crm_tools import register_crm_tools
from mcp_server.tools.document_tools import register_document_tools from mcp_server.tools.document_tools import register_document_tools
from mcp_server.tools.legal_tools import register_legal_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") logger = logging.getLogger("shira.agent")
@@ -50,8 +53,17 @@ class AgentRunner:
context: dict, context: dict,
espocrm_url: str, espocrm_url: str,
espocrm_api_key: str, espocrm_api_key: str,
depth: int = 0,
blocked_tools: set[str] | None = None,
allowed_toolsets: list[str] | None = None,
) -> str: ) -> 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 # Build tools from context
crm = EspoCrmClient(espocrm_url, espocrm_api_key) crm = EspoCrmClient(espocrm_url, espocrm_api_key)
@@ -59,10 +71,27 @@ class AgentRunner:
user_id = (context.get("currentUser") or {}).get("id") user_id = (context.get("currentUser") or {}).get("id")
tools_registry: dict[str, dict] = {} tools_registry: dict[str, dict] = {}
# 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) 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) 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) 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 # Build OpenAI tools format
openai_tools = [] openai_tools = []
for name, tool in tools_registry.items(): for name, tool in tools_registry.items():
@@ -78,10 +107,11 @@ class AgentRunner:
# Build messages with system prompt # Build messages with system prompt
openai_messages = [{"role": "system", "content": system_prompt}] openai_messages = [{"role": "system", "content": system_prompt}]
openai_messages.extend(messages) openai_messages.extend(messages)
self._last_messages = openai_messages
# Agentic loop # Agentic loop
for iteration in range(self.max_iterations): 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: try:
response = await self.client.chat.completions.create( response = await self.client.chat.completions.create(
@@ -133,3 +163,7 @@ class AgentRunner:
# Exhausted iterations # Exhausted iterations
logger.warning("[agent] max iterations (%d) reached", self.max_iterations) logger.warning("[agent] max iterations (%d) reached", self.max_iterations)
return "הגעתי למספר המקסימלי של פעולות. נסי לשאול שוב בצורה ממוקדת יותר." 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") 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", {}) case_info = context.get("case", {})
contacts = context.get("contacts", []) contacts = context.get("contacts", [])
open_tasks = context.get("openTasks", []) 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\nCurrent user: {current_user.get("name", "")}')
parts.append(f"\n\n{LEGAL_ASSISTANCE_PROMPT}") 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) 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", {}) summary = context.get("summary", {})
alerts = context.get("alerts", []) alerts = context.get("alerts", [])
cases_by_status = context.get("casesByStatus", {}) 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("\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", "")}')
# 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) 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", "pydantic>=2.10.0",
"mcp>=1.0.0", "mcp>=1.0.0",
"openai>=1.50.0", "openai>=1.50.0",
"pyyaml>=6.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]