claude-os/app/core/learning_jobs.py
2025-10-27 17:41:44 -05:00

225 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""
Claude OS Learning Jobs
RQ jobs for real-time knowledge capture and MCP updates
"""
import json
import time
from pathlib import Path
from typing import Dict, Optional
import urllib.request
import urllib.error
from app.core.conversation_watcher import ConversationWatcher
from app.core.redis_config import RedisConfig
def process_learning_detection(project_id: int, detection: Dict) -> Dict:
"""
Process a detected learning opportunity
- Store in Redis
- Prompt user
- Update knowledge base if confirmed
"""
print(f"\n🔍 Processing detection: {detection['trigger']}")
print(f" Text: {detection['text']}")
# Store prompt in Redis with TTL (10 minutes for user response)
RedisConfig.set_confirmation(project_id, detection["id"], False)
# Queue user prompt
prompt_job_id = RedisConfig.queue_prompt_job(project_id, detection)
print(f" ⏳ Waiting for user confirmation (job: {prompt_job_id})")
# Wait for user confirmation (up to 10 minutes)
confirmed = wait_for_confirmation(project_id, detection["id"], timeout=600)
if confirmed:
print(f" ✅ User confirmed! Updating knowledge base...")
ingest_job_id = RedisConfig.queue_ingest_job(project_id, str(Path.cwd()))
return {
"status": "confirmed",
"ingest_job_id": ingest_job_id,
"detection_id": detection["id"],
}
else:
print(f" ⏭️ User skipped or timed out")
return {"status": "skipped", "detection_id": detection["id"]}
def prompt_user_for_confirmation(project_id: int, detection: Dict) -> Dict:
"""
Prompt user to confirm if we should remember this
In real Claude Code CLI, this would show an interactive prompt
"""
print(f"\n🔔 KNOWLEDGE UPDATE DETECTED:")
print(f" Trigger: {detection['trigger']}")
print(f" Confidence: {detection['confidence']:.0%}")
print(f" Text: {detection['text']}")
print(f" Context: {detection.get('context', '')}\n")
# In a real CLI, this would be interactive
# For now, we'll use environment variable or Redis for response
# In production, Claude Code CLI would set the confirmation
print(f" Waiting for confirmation (check Redis key)...")
print(f" Key: claude_os:prompt:{project_id}:{detection['id']}:confirmed")
return {
"status": "prompted",
"detection_id": detection["id"],
"trigger": detection["trigger"],
}
def ingest_to_mcp(project_id: int, project_path: str) -> Dict:
"""
Ingest the learned insights file to project_profile MCP
"""
try:
watcher = ConversationWatcher(project_id, project_path)
insights_file = (
Path(project_path) / ".claude-os" / "project-profile" / "LEARNED_INSIGHTS.md"
)
if not insights_file.exists():
print(f"⚠️ No insights file found at {insights_file}")
return {"status": "error", "message": "insights_file_not_found"}
# Read the insights file
with open(insights_file, "r") as f:
content = f.read()
# Prepare ingest payload
payload = {
"filename": "LEARNED_INSIGHTS.md",
"content": content,
"mcp_type": "project_profile",
"metadata": {
"type": "learned_insights",
"auto_generated": True,
"learning_system": "real_time_watcher",
},
}
# Post to Claude OS API
api_url = "http://localhost:8051" # Should be configurable
ingest_url = f"{api_url}/api/projects/{project_id}/ingest-document"
payload_json = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
ingest_url,
data=payload_json,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as response:
if response.status in [200, 201]:
print(f" ✅ Ingested to project_profile MCP")
return {
"status": "ingested",
"project_id": project_id,
"file": str(insights_file),
}
else:
return {
"status": "error",
"message": f"API returned {response.status}",
}
except Exception as e:
print(f"⚠️ Error ingesting to MCP: {e}")
return {"status": "error", "message": str(e)}
def handle_conversation_message(project_id: int, message: Dict) -> None:
"""
Process an incoming conversation message
- Detect triggers
- Queue learning jobs for high-confidence detections
"""
try:
text = message.get("text", "")
role = message.get("role", "user")
# Only learn from user messages (not Claude responses)
if role != "user":
return
watcher = ConversationWatcher(project_id, ".")
detections = watcher.detect_triggers(text)
print(f"\n🔍 Analyzing message from {role}...")
if not detections:
print(f" No learning opportunities detected")
return
print(f" 🎯 Found {len(detections)} potential learning opportunities:")
for detection in detections:
if watcher.should_prompt_user(detection):
print(
f" • {detection['trigger']}: {detection['text'][:50]}... (confidence: {detection['confidence']:.0%})"
)
# Queue the learning job
job_id = RedisConfig.queue_learning_job(project_id, detection)
print(f" → Queued (job: {job_id})")
except Exception as e:
print(f"⚠️ Error processing message: {e}")
def wait_for_confirmation(
project_id: int, detection_id: str, timeout: int = 600
) -> Optional[bool]:
"""
Wait for user confirmation with timeout
Checks Redis every second for confirmation
"""
start_time = time.time()
while time.time() - start_time < timeout:
confirmed = RedisConfig.get_confirmation(project_id, detection_id)
if confirmed is not None:
return confirmed
# Check every second
time.sleep(1)
# Timeout - return False
return False
def subscribe_to_conversations(project_id: int) -> None:
"""
Subscribe to conversation channel and process messages
Runs continuously, processing each message as it arrives
"""
print(f"\n🎧 Subscribing to conversations for project {project_id}...")
pubsub = RedisConfig.subscribe_to_conversation(project_id)
try:
for message in pubsub.listen():
if message["type"] == "message":
try:
data = json.loads(message["data"])
handle_conversation_message(project_id, data)
except json.JSONDecodeError:
pass # Invalid JSON, skip
except KeyboardInterrupt:
print("\n👋 Unsubscribing from conversations...")
pubsub.close()
if __name__ == "__main__":
# Testing
import sys
if len(sys.argv) > 1:
project_id = int(sys.argv[1])
subscribe_to_conversations(project_id)