Files
roboco/roboco/services/extraction.py
T

509 lines
16 KiB
Python
Raw Normal View History

2025-12-10 02:49:54 +01:00
"""
Message Extraction Service
Extracts structured messages from raw agent LLM output.
Uses pattern matching and optional LLM-based classification to
identify different message types: reasoning, dialogue, decisions,
actions, blockers, and technical content.
Flow:
1. TranscriptionService yields ready buffer
2. ExtractionService analyzes content
3. Produces list of ExtractedMessage objects
4. Messages are stored and broadcast
"""
import re
from typing import Any
from uuid import UUID, uuid4
import structlog
from roboco.models import MessageType
from roboco.models.extraction import (
ExtractionConfig,
ExtractionContext,
ExtractionResult,
)
2025-12-11 01:05:20 +01:00
from roboco.models.message import ExtractedMessage
2025-12-10 02:49:54 +01:00
logger = structlog.get_logger()
2025-12-12 02:45:47 +01:00
# Maximum length for raw excerpt storage
MAX_EXCERPT_LENGTH = 200
2025-12-10 02:49:54 +01:00
2025-12-12 18:08:06 +01:00
2025-12-10 02:49:54 +01:00
# =============================================================================
# EXTRACTION PATTERNS
# =============================================================================
# Patterns for identifying message types
# These are heuristics; can be enhanced with LLM classification
REASONING_PATTERNS = [
r"(?i)^I(?:'m| am) thinking",
r"(?i)^Let me (?:think|consider|analyze)",
r"(?i)^Hmm,? ",
r"(?i)^I need to",
r"(?i)^First,? I(?:'ll| will| should)",
r"(?i)^My approach",
r"(?i)^To solve this",
r"(?i)^The (?:issue|problem|question) (?:is|seems)",
r"(?i)^Looking at",
r"(?i)^Analyzing",
r"(?i)^Considering",
]
DIALOGUE_PATTERNS = [
r"(?i)^Hey,? ",
r"(?i)^Hi,? ",
r"(?i)^@\w+", # Mentions
r"(?i)^Can (?:you|someone)",
r"(?i)^Could (?:you|someone)",
r"(?i)^Would (?:you|someone)",
r"(?i)^I(?:'m| am) asking",
r"(?i)^Question:",
r"(?i)^Does anyone",
r"(?i)^What do you think",
r"(?i)^Thoughts\?",
r"\?$", # Ends with question mark
]
DECISION_PATTERNS = [
r"(?i)^I(?:'ve| have) decided",
r"(?i)^Decision:",
r"(?i)^I(?:'ll| will) go with",
r"(?i)^Let(?:'s| us) use",
r"(?i)^We(?:'ll| will) use",
r"(?i)^The (?:approach|solution|answer) is",
r"(?i)^Choosing",
r"(?i)^Selected:",
r"(?i)^Going with",
r"(?i)^After consideration,? I(?:'ll| will)",
]
ACTION_PATTERNS = [
r"(?i)^Starting",
r"(?i)^Creating",
r"(?i)^Writing",
r"(?i)^Implementing",
r"(?i)^Running",
r"(?i)^Executing",
r"(?i)^Testing",
r"(?i)^Committing",
r"(?i)^Pushing",
r"(?i)^Deploying",
r"(?i)^Task (?:complete|done|finished)",
r"(?i)^Done:",
r"(?i)^Completed:",
r"(?i)^✓",
r"(?i)^✅",
]
BLOCKER_PATTERNS = [
r"(?i)^Blocked:",
r"(?i)^Blocker:",
r"(?i)^I(?:'m| am) blocked",
r"(?i)^Cannot proceed",
r"(?i)^Waiting (?:on|for)",
r"(?i)^Need (?:help|assistance|input)",
r"(?i)^Stuck on",
r"(?i)^Dependency:",
r"(?i)^Missing:",
r"(?i)^Error:",
r"(?i)^Failed:",
r"(?i)^Unable to",
r"(?i)^🚫",
r"(?i)^⛔",
]
TECHNICAL_PATTERNS = [
r"```", # Code blocks
r"(?i)^The (?:function|class|method|variable)",
r"(?i)^This (?:code|implementation|function)",
r"(?i)^Here(?:'s| is) (?:the|how)",
r"(?i)^API:",
r"(?i)^Schema:",
r"(?i)^Endpoint:",
r"(?i)^Response:",
r"(?i)^Request:",
r"^[A-Z][a-zA-Z]+(?:Error|Exception)", # Exception names
]
class ExtractionService:
"""
Service for extracting structured messages from raw agent output.
Uses pattern matching to classify segments into message types.
Can be extended with LLM-based classification for better accuracy.
Usage:
service = ExtractionService()
result = await service.extract(buffer)
for message in result.messages:
await store_message(message)
"""
2026-06-11 18:16:20 +02:00
@staticmethod
def _compile_patterns() -> dict[MessageType, list[re.Pattern]]:
"""Pre-compile the per-message-type regex pattern lists."""
return {
2025-12-10 02:49:54 +01:00
MessageType.REASONING: [re.compile(p) for p in REASONING_PATTERNS],
MessageType.DIALOGUE: [re.compile(p) for p in DIALOGUE_PATTERNS],
MessageType.DECISION: [re.compile(p) for p in DECISION_PATTERNS],
MessageType.ACTION: [re.compile(p) for p in ACTION_PATTERNS],
MessageType.BLOCKER: [re.compile(p) for p in BLOCKER_PATTERNS],
MessageType.TECHNICAL: [re.compile(p) for p in TECHNICAL_PATTERNS],
}
2026-06-11 18:16:20 +02:00
def __init__(self, config: ExtractionConfig | None = None) -> None:
self.config = config or ExtractionConfig()
self.log = logger.bind(component="extraction")
self._compiled_patterns = self._compile_patterns()
2025-12-10 02:49:54 +01:00
self._mention_pattern = re.compile(r"@(\w+)")
2025-12-12 18:08:06 +01:00
async def extract(self, ctx: ExtractionContext) -> ExtractionResult:
2025-12-10 02:49:54 +01:00
"""
Extract messages from raw content.
Args:
2025-12-12 18:08:06 +01:00
ctx: Extraction context with content and metadata
2025-12-10 02:49:54 +01:00
Returns:
ExtractionResult with extracted messages
"""
2025-12-12 18:08:06 +01:00
if len(ctx.content) < self.config.min_content_length:
2025-12-10 02:49:54 +01:00
return ExtractionResult(
messages=[],
2025-12-12 18:08:06 +01:00
raw_content=ctx.content,
agent_id=ctx.agent_id,
channel_id=ctx.channel_id,
session_id=ctx.session_id,
2025-12-10 02:49:54 +01:00
)
# Segment the content
2025-12-12 18:08:06 +01:00
segments = self._segment_content(ctx.content)
2025-12-10 02:49:54 +01:00
messages: list[ExtractedMessage] = []
pattern_matches: dict[str, list[str]] = {}
confidence_scores: dict[UUID, float] = {}
for segment in segments[: self.config.max_segments_per_buffer]:
if not segment.strip():
continue
# Classify segment
msg_type, confidence, matches = self._classify_segment(segment)
# Store pattern matches for debugging
if matches:
pattern_matches[segment[:50]] = matches
# Extract mentions
mentions: list[UUID] = []
if self.config.extract_mentions:
mention_names = self._mention_pattern.findall(segment)
# In production, resolve names to agent UUIDs
# For now, just log them
if mention_names:
self.log.debug("Found mentions", mentions=mention_names)
# Create message
message = ExtractedMessage(
id=uuid4(),
2025-12-12 18:08:06 +01:00
agent_id=ctx.agent_id,
channel_id=ctx.channel_id,
group_id=ctx.group_id,
session_id=ctx.session_id,
2025-12-10 02:49:54 +01:00
type=msg_type,
content=segment.strip(),
content_length=len(segment.strip()),
mentions=mentions,
2025-12-12 18:08:06 +01:00
task_id=ctx.task_id,
2025-12-10 02:49:54 +01:00
confidence=confidence,
2025-12-12 02:45:47 +01:00
raw_excerpt=segment[:MAX_EXCERPT_LENGTH]
if len(segment) > MAX_EXCERPT_LENGTH
else segment,
2025-12-10 02:49:54 +01:00
)
messages.append(message)
confidence_scores[message.id] = confidence
result = ExtractionResult(
messages=messages,
2025-12-12 18:08:06 +01:00
raw_content=ctx.content,
agent_id=ctx.agent_id,
channel_id=ctx.channel_id,
session_id=ctx.session_id,
2025-12-10 02:49:54 +01:00
pattern_matches=pattern_matches,
confidence_scores=confidence_scores,
)
self.log.info(
"Extraction complete",
2025-12-12 18:08:06 +01:00
agent_id=str(ctx.agent_id),
2025-12-10 02:49:54 +01:00
message_count=result.message_count,
types=result.types_extracted,
)
return result
def _segment_content(self, content: str) -> list[str]:
"""
Segment content into logical chunks.
Segmentation strategy:
1. Split on double newlines (paragraphs)
2. Split on code blocks
3. Keep sentences together
"""
segments: list[str] = []
# First, handle code blocks specially
code_block_pattern = re.compile(r"(```[\s\S]*?```)")
parts = code_block_pattern.split(content)
for part in parts:
if part.startswith("```"):
# Code block is its own segment
segments.append(part)
else:
# Split non-code on double newlines
paragraphs = re.split(r"\n\s*\n", part)
for para in paragraphs:
if para.strip():
segments.append(para.strip())
return segments
def _classify_segment(
self,
segment: str,
) -> tuple[MessageType, float, list[str]]:
"""
Classify a segment into a message type.
Returns:
Tuple of (MessageType, confidence, matched_patterns)
"""
# Check each type's patterns
type_scores: dict[MessageType, tuple[int, list[str]]] = {}
for msg_type, patterns in self._compiled_patterns.items():
matches: list[str] = []
for pattern in patterns:
if pattern.search(segment):
matches.append(pattern.pattern)
if matches:
type_scores[msg_type] = (len(matches), matches)
if not type_scores:
# Default to REASONING if no patterns match
return MessageType.REASONING, 0.5, []
# Get highest scoring type
best_type = max(type_scores.keys(), key=lambda t: type_scores[t][0])
match_count, matches = type_scores[best_type]
# Calculate confidence based on match count
total_patterns = len(self._compiled_patterns[best_type])
confidence = min(1.0, (match_count / max(1, total_patterns)) + 0.5)
return best_type, confidence, matches
async def _call_anthropic_with_retry(self, client: Any, prompt: str) -> Any:
"""Call Anthropic messages.create with up to MAX_RATE_LIMIT_RETRIES on 429.
Raises RateLimitError when all retries are exhausted.
"""
import asyncio
import anthropic as anthropic_mod
from roboco.services.exceptions import MAX_RATE_LIMIT_RETRIES, RateLimitError
last_retry_after: float | None = None
for rl_attempt in range(MAX_RATE_LIMIT_RETRIES):
try:
return await client.messages.create(
model="claude-3-haiku-20240307", # Fast, cheap
max_tokens=2000,
messages=[{"role": "user", "content": prompt}],
)
except anthropic_mod.RateLimitError as exc:
try:
header = exc.response.headers.get("retry-after")
last_retry_after = float(header) if header else None
except (AttributeError, TypeError, ValueError):
last_retry_after = None
backoff = (
last_retry_after
if last_retry_after is not None
else float(2**rl_attempt)
)
self.log.warning(
"Anthropic rate limited (429), retrying",
provider="anthropic",
attempt=rl_attempt + 1,
max_retries=MAX_RATE_LIMIT_RETRIES,
backoff_duration=backoff,
)
if rl_attempt < MAX_RATE_LIMIT_RETRIES - 1:
await asyncio.sleep(backoff)
else:
raise RateLimitError(
provider="anthropic", retry_after=last_retry_after
) from exc
raise RateLimitError(provider="anthropic", retry_after=last_retry_after)
2025-12-12 18:08:06 +01:00
async def extract_with_llm(self, ctx: ExtractionContext) -> ExtractionResult:
2025-12-10 02:49:54 +01:00
"""
Extract messages using LLM classification.
This is more accurate but slower and more expensive.
Falls back to pattern matching if LLM unavailable.
2025-12-12 18:08:06 +01:00
Uses TOON format for token-efficient communication.
Retries up to MAX_RATE_LIMIT_RETRIES times on 429/RateLimitError,
respecting the Retry-After header when present.
2025-12-10 02:49:54 +01:00
"""
2025-12-11 01:05:20 +01:00
from anthropic import AsyncAnthropic
from roboco.config import settings
2025-12-12 18:08:06 +01:00
from roboco.llm import ToonAdapter
from roboco.services.exceptions import RateLimitError
2025-12-12 18:08:06 +01:00
toon = ToonAdapter()
2025-12-11 01:05:20 +01:00
try:
client = AsyncAnthropic(api_key=settings.anthropic_api_key)
2025-12-12 18:08:06 +01:00
# Build prompt for LLM classification using TOON
2025-12-11 01:05:20 +01:00
prompt = f"""Analyze this agent output and classify each distinct segment.
Agent output:
2025-12-12 18:08:06 +01:00
{ctx.content}
2025-12-11 01:05:20 +01:00
For each segment, identify:
- type: one of [reasoning, dialogue, decision, action, blocker, technical]
- content: the segment text
- confidence: 0.0 to 1.0
2025-12-12 18:08:06 +01:00
Return as TOON tabular format:
[N,]{{type,content,confidence}}:
reasoning,Analyzing the problem...,0.9
action,Creating file utils.py,0.95
2025-12-11 01:05:20 +01:00
2025-12-12 18:08:06 +01:00
Output only valid TOON, no other text."""
2025-12-11 01:05:20 +01:00
response = await self._call_anthropic_with_retry(client, prompt)
2025-12-11 01:05:20 +01:00
2025-12-12 18:08:06 +01:00
# Parse response using TOON (falls back to JSON)
2025-12-13 05:50:34 +01:00
# Extract text from first TextBlock content
response_text = ""
for block in response.content:
if hasattr(block, "text"):
response_text = block.text
break
2025-12-12 18:08:06 +01:00
segments = toon.decode(response_text)
2025-12-11 01:05:20 +01:00
messages: list[ExtractedMessage] = []
for segment in segments:
2025-12-13 05:50:34 +01:00
if isinstance(segment, dict):
msg_type_str = segment.get("type", "reasoning")
msg_content = segment.get("content", "")
confidence = segment.get("confidence", 0.8)
else:
msg_type_str = "reasoning"
msg_content = str(segment)
confidence = 0.8
2025-12-11 01:05:20 +01:00
msg_type = MessageType(msg_type_str)
messages.append(
ExtractedMessage(
id=uuid4(),
content=msg_content,
2025-12-13 05:50:34 +01:00
content_length=len(msg_content),
type=msg_type,
2025-12-12 18:08:06 +01:00
agent_id=ctx.agent_id,
channel_id=ctx.channel_id,
session_id=ctx.session_id,
group_id=ctx.group_id,
task_id=ctx.task_id,
2025-12-11 01:05:20 +01:00
confidence=confidence,
)
)
return ExtractionResult(
messages=messages,
2025-12-12 18:08:06 +01:00
raw_content=ctx.content,
2025-12-13 05:50:34 +01:00
agent_id=ctx.agent_id,
channel_id=ctx.channel_id,
session_id=ctx.session_id,
2025-12-11 01:05:20 +01:00
)
except RateLimitError:
raise
2025-12-11 01:05:20 +01:00
except Exception as e:
# Fall back to pattern matching
self.log.warning("LLM extraction failed, using patterns", error=str(e))
2025-12-12 18:08:06 +01:00
return await self.extract(ctx)
2025-12-10 02:49:54 +01:00
# =============================================================================
# PIPELINE
# =============================================================================
class ExtractionPipeline:
"""
Complete pipeline from transcription buffer to stored messages.
Combines TranscriptionService and ExtractionService for end-to-end
processing of agent LLM streams.
Usage:
from roboco.services.transcription import TranscriptionService
transcription = TranscriptionService()
pipeline = ExtractionPipeline(transcription)
await pipeline.start()
# Messages are automatically extracted and callbacks invoked
pipeline.on_message(lambda msg: store_message(msg))
"""
def __init__(
self,
extraction_service: ExtractionService | None = None,
) -> None:
self.extraction = extraction_service or ExtractionService()
self._message_callbacks: list[Any] = []
self.log = logger.bind(component="extraction_pipeline")
def on_message(self, callback: Any) -> None:
"""Register a callback for extracted messages."""
self._message_callbacks.append(callback)
2025-12-12 18:08:06 +01:00
async def process_buffer(self, ctx: ExtractionContext) -> ExtractionResult:
2025-12-10 02:49:54 +01:00
"""
Process a buffer and invoke callbacks for each message.
"""
2025-12-12 18:08:06 +01:00
result = await self.extraction.extract(ctx)
2025-12-10 02:49:54 +01:00
# Invoke callbacks for each message
for message in result.messages:
for callback in self._message_callbacks:
try:
await callback(message)
except Exception as e:
self.log.error(
"Message callback error",
error=str(e),
message_id=str(message.id),
)
return result