mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
509 lines
16 KiB
Python
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
|