Files
roboco/roboco/services/extraction.py
T
303c2db289 Fix: rate limit real probe (#110)
* fix(rate-limit): real provider liveness probe instead of time-based stub

The rate-limit recovery sweeper cleared a provider and resumed parked agents
purely on elapsed time — _do_probe was a stub that always returned True once
the retry_after window passed, so it never confirmed the provider had actually
stopped rate-limiting us. Under a sustained limit that resumes agents straight
into another 429, re-parking them: avoidable churn.

Make the probe real. _do_probe now issues a free, unmetered liveness call —
Anthropic GET /v1/models or Ollama GET /api/tags — and treats any non-429
response as the limit having lifted. A 429 keeps the provider parked; a
network error keeps it parked too (retry next sweep). When the provider can't
be probed (no API key, or an unrecognized provider), it falls back to the
prior time-expiry optimism rather than stranding agents. _probe_target keeps
URL/header resolution separate and testable, and _do_probe stays a
monkeypatchable boundary so the existing sweep tests are unaffected.

Also drop two acceptance-criteria-number labels from comments in this file.

* chore(rate-limit): clear merged gate debt in rate-limit tests + deps lint

The rate-limit PR landed with ruff violations the full gate flags but the
authors' runs missed: test_rate_limit_sweep.py was unformatted, and
test_rate_limit_tracker.py had unsorted/unused imports and magic-value
comparisons. Format the sweep test, drop the dead imports, and bind the
magic comparison values to locals. Also strip acceptance-criteria-number
labels from comments/docstrings across the three rate-limit test files
(leaving genuine acceptance_criteria=[...] test data untouched), and add
api/deps.py to the PLC0415 per-file-ignore — it is the DI wiring hub and
defers a couple of service imports to call time to avoid import cycles,
the same rationale already applied to api/routes, runtime, and services.

* fix(rate-limit): resolve redis type errors in RateLimitStateTracker

A cold mypy run (the gate's true state — prior passes were warm-cache only)
flagged four redis-typing errors in rate_limit_tracker.py that the merge
missed: three unused type:ignore[type-arg] on redis.Redis, and an
aclose() the bundled redis type stub doesn't expose.

Drop the now-unused ignores, and close the scan client via
'async with redis.from_url(...) as r:' instead of a finally-block
aclose(). The context manager closes the client on exit using the modern
redis.asyncio API — no deprecated close(), no stub-missing aclose(), no
suppression. Extend the test's redis mock to model the async
context-manager protocol so it returns itself on enter.

* test(prompter): pass route='main_pm' in the product main-PM routing test

Pre-existing master failure, unrelated to the rate-limit work. The test is
named ...product_routes_to_main_pm and asserts team=MAIN_PM, but called
confirm_live_draft without a route, so it got the 'board' default — which
assigns the Product Owner and yields team=BOARD by design (the board-review
path keeps the root at team=board until the CEO approves). The Main-PM path
is selected with route='main_pm', exactly as the sibling
...main_pm_route_assigns_main_pm test does. Add the missing kwarg so the test
verifies the path it names; behaviour under test is unchanged.

* Updated uv.lock

* refactor(complexity): bring all rank-C blocks under the xenon B ceiling

The full quality gate's xenon step (--max-absolute B --max-modules A
--max-average A) failed on eight rank-C blocks plus the extraction module
average — debt the rate-limit and token-analytics merges deferred. Reduce
each by extracting cohesive helpers, behaviour unchanged:

- orchestrator._probe_one_provider: split into _too_early_to_probe,
  _on_probe_success, _on_probe_failure, _parked_agents_for.
- rate_limit_tracker.list_rate_limited_providers: extract _read_rate_limited_entry
  and a _decode helper.
- trigger_filter.decide_spawn: extract _stale_trigger_decision (drops the
  PLR0911 suppression too).
- ollama_embedder (embed_query, _embed_batch_sync, aembed_query,
  _embed_batch_async): share _rl_backoff / _map_embed_error / _log_429 /
  _sleep_connect_retry / _asleep_connect_retry; remove a dead post-loop guard
  in aembed_query.
- mentor._synthesize_answer: extract _select_system_prompt and
  _answer_from_response.
- indexes/base.ask: extract the 429-retried LLM call into _ask_llm.
- extraction.__init__: extract _compile_patterns so the module average
  lands at rank A.

xenon now exits 0; rate-limit, optimal_brain, extraction, and events suites
all green.

* chore(deps): drop obsolete types-redis stub; honor redis 8.0 inline types

types-redis 4.6 (typed for redis 4.x) shadowed redis 8.0's own inline types,
which both masked real annotation mismatches in stream_bus.py and forced
awkward workarounds elsewhere. The stale stub is why the mypy gate only ever
passed warm-cached: a cold run under the wrong stub disagreed with the code.

Remove types-redis (and its orphaned transitive stubs) so mypy uses redis's
shipped types. That surfaces that xreadgroup/xclaim return bytes-keyed records
while _handle_message is annotated str — the code already decodes bytes
defensively, so this is an annotation gap, not a runtime bug. Make the types
honest: cast each result to its concrete shape and decode the stream name and
message id to str at the dispatch boundary via a _to_str helper.

mypy roboco/ is now clean cold (247 files) against redis's real types; events
suite green.

* Updated uv.lock

* fix(workspace): install the dev extra so agents can run make quality

Agent workspaces were set up with plain `uv sync`, which installs only the
project's default dependency group (pytest) — not the `dev` *extra* where the
gate tools live (ruff, mypy, xenon, radon, vulture, bandit, deptry). So an
agent's .venv had pytest but no linters, and `make quality` died immediately
on `ruff: command not found`. Agents literally could not lint, type-check, or
complexity-check their own work, which is how format/mypy/xenon debt merged
unseen. Sync the `dev` extra (`uv sync --extra dev`) so the workspace gets the
full toolchain the setup's own docstring already promised.

* fix(panel): rate-limit endpoint shape + websocket path

Two panel-facing breakages from the rate-limit rework:

- GET /api/system/rate-limits returned a raw list, but the panel store reads
  response.entries — so `r.entries is not iterable` crashed the banner sync on
  page load. Return the panel's contract: a { entries: [...] } envelope whose
  items are camelCase {provider, affectedAgents, hitAt, resumeAt,
  retryAfterSeconds}, derived from the raw Redis state (resumeAt = hitAt +
  retryAfter).
- The rate-limit websocket hook passed "/ws/system" while getWebSocketUrl()
  already supplies the "/ws" base, producing the doubled "/ws/ws/system" URL.
  Pass "/system" to match the agents/channels/notifications hooks.

Note: the backend /ws/system endpoint itself does not yet exist (the rework
shipped the panel hook only); the REST fix keeps the banner correct on load
and reconnect until that endpoint is built.

* test(workspace): assert uv sync installs the dev extra

Follow the workspace setup change: the dependency-install command is now
`uv sync --extra dev` so the agent workspace gets the lint/type/complexity
toolchain. Update the three assertions that pinned the old `uv sync`.

* feat(ws): add /ws/system stream and bridge rate-limit events to the panel

The rate-limit rework shipped the panel's websocket hook but no backend: there
was no /ws/system endpoint and nothing forwarded RATE_LIMIT_HIT/LIFTED to a
socket, so the banner got no live updates.

Build the missing half:
- ConnectionManager grows a system-wide connection set with connect_system /
  broadcast_system, and disconnect() now clears it.
- A /ws/system websocket endpoint (operator stream, no per-agent keying) with
  the same connected + ping/pong lifecycle as the other streams.
- websocket_bridge subscribes RATE_LIMIT_HIT/LIFTED and forwards each to
  broadcast_system tagged with the type the panel switches on. Both events
  ride the same StreamEventBus singleton, and the subscriptions register
  before start_listening(), so the consumer reads their streams.

Pairs with the panel hook now passing '/system' (getWebSocketUrl supplies the
'/ws' base). Covered by handler, manager, and endpoint-lifecycle tests.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-11 18:16:20 +02:00

509 lines
16 KiB
Python

"""
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,
)
from roboco.models.message import ExtractedMessage
logger = structlog.get_logger()
# Maximum length for raw excerpt storage
MAX_EXCERPT_LENGTH = 200
# =============================================================================
# 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)
"""
@staticmethod
def _compile_patterns() -> dict[MessageType, list[re.Pattern]]:
"""Pre-compile the per-message-type regex pattern lists."""
return {
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],
}
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()
self._mention_pattern = re.compile(r"@(\w+)")
async def extract(self, ctx: ExtractionContext) -> ExtractionResult:
"""
Extract messages from raw content.
Args:
ctx: Extraction context with content and metadata
Returns:
ExtractionResult with extracted messages
"""
if len(ctx.content) < self.config.min_content_length:
return ExtractionResult(
messages=[],
raw_content=ctx.content,
agent_id=ctx.agent_id,
channel_id=ctx.channel_id,
session_id=ctx.session_id,
)
# Segment the content
segments = self._segment_content(ctx.content)
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(),
agent_id=ctx.agent_id,
channel_id=ctx.channel_id,
group_id=ctx.group_id,
session_id=ctx.session_id,
type=msg_type,
content=segment.strip(),
content_length=len(segment.strip()),
mentions=mentions,
task_id=ctx.task_id,
confidence=confidence,
raw_excerpt=segment[:MAX_EXCERPT_LENGTH]
if len(segment) > MAX_EXCERPT_LENGTH
else segment,
)
messages.append(message)
confidence_scores[message.id] = confidence
result = ExtractionResult(
messages=messages,
raw_content=ctx.content,
agent_id=ctx.agent_id,
channel_id=ctx.channel_id,
session_id=ctx.session_id,
pattern_matches=pattern_matches,
confidence_scores=confidence_scores,
)
self.log.info(
"Extraction complete",
agent_id=str(ctx.agent_id),
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)
async def extract_with_llm(self, ctx: ExtractionContext) -> ExtractionResult:
"""
Extract messages using LLM classification.
This is more accurate but slower and more expensive.
Falls back to pattern matching if LLM unavailable.
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.
"""
from anthropic import AsyncAnthropic
from roboco.config import settings
from roboco.llm import ToonAdapter
from roboco.services.exceptions import RateLimitError
toon = ToonAdapter()
try:
client = AsyncAnthropic(api_key=settings.anthropic_api_key)
# Build prompt for LLM classification using TOON
prompt = f"""Analyze this agent output and classify each distinct segment.
Agent output:
{ctx.content}
For each segment, identify:
- type: one of [reasoning, dialogue, decision, action, blocker, technical]
- content: the segment text
- confidence: 0.0 to 1.0
Return as TOON tabular format:
[N,]{{type,content,confidence}}:
reasoning,Analyzing the problem...,0.9
action,Creating file utils.py,0.95
Output only valid TOON, no other text."""
response = await self._call_anthropic_with_retry(client, prompt)
# Parse response using TOON (falls back to JSON)
# Extract text from first TextBlock content
response_text = ""
for block in response.content:
if hasattr(block, "text"):
response_text = block.text
break
segments = toon.decode(response_text)
messages: list[ExtractedMessage] = []
for segment in segments:
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
msg_type = MessageType(msg_type_str)
messages.append(
ExtractedMessage(
id=uuid4(),
content=msg_content,
content_length=len(msg_content),
type=msg_type,
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,
confidence=confidence,
)
)
return ExtractionResult(
messages=messages,
raw_content=ctx.content,
agent_id=ctx.agent_id,
channel_id=ctx.channel_id,
session_id=ctx.session_id,
)
except RateLimitError:
raise
except Exception as e:
# Fall back to pattern matching
self.log.warning("LLM extraction failed, using patterns", error=str(e))
return await self.extract(ctx)
# =============================================================================
# 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)
async def process_buffer(self, ctx: ExtractionContext) -> ExtractionResult:
"""
Process a buffer and invoke callbacks for each message.
"""
result = await self.extraction.extract(ctx)
# 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