This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
shira-hermes/api/services/learner.py
T
chaim 4aad81e11e 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>
2026-04-13 19:33:57 +00:00

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)