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:
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,9 +71,26 @@ 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] = {}
|
||||||
register_crm_tools(tools_registry, crm, case_id, user_id, context)
|
|
||||||
register_document_tools(tools_registry, crm, case_id, context)
|
# Core CRM/document/legal tools
|
||||||
register_legal_tools(tools_registry, crm, case_id, context)
|
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
|
# Build OpenAI tools format
|
||||||
openai_tools = []
|
openai_tools = []
|
||||||
@@ -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", [])
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 ימים הקרובים — הדגש
|
||||||
|
- הצע פעולות המשך (למשל "האם ליצור משימה?")
|
||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user