4aad81e11e
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>
148 lines
5.5 KiB
Python
148 lines
5.5 KiB
Python
"""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)
|