"""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)