* feat: add comprehensive OpenTelemetry tracing - Add tool execution spans for reflect operations - Add tool call information (names, params) to spans - Change verification scope from 'test' to 'verification' - Add hindsight.reflect_generation span for done() processing - Implement no-op tracer for improved code readability - Update documentation for OTEL configuration - Resolve merge conflicts from rebase * fix: properly serialize Pydantic models in span recording - Add _serialize_for_span() helper to handle Pydantic models - Update all providers to use the helper function - Fixes test failures with 'Object of type X is not JSON serializable' * feat: add Grafana LGTM stack for unified local observability Add Grafana LGTM (Loki, Grafana, Tempo, Mimir) as the recommended local development observability stack. This provides traces, metrics, and logs in a single Docker container instead of separate tools. Changes: - Add scripts/dev/grafana/ with docker-compose and README - Add scripts/dev/start-grafana.sh startup script - Update .env.example to reference Grafana LGTM - Update configuration docs to emphasize Grafana LGTM as primary option - Reorder OTLP backend list to show Grafana LGTM first Benefits: - Single container vs multiple separate tools (Jaeger, SigNoz, etc.) - ~515MB image with full observability stack - Compatible with existing OTLP configuration - Simpler local development setup * chore: remove SigNoz scripts and references Remove SigNoz observability stack in favor of Grafana LGTM as the sole recommended local development tracing solution. Changes: - Delete scripts/dev/signoz/ directory and all SigNoz configurations - Delete scripts/dev/start-signoz.sh startup script - Remove SigNoz references from .env.example - Remove SigNoz from OTLP backends list in configuration docs Grafana LGTM provides the same capabilities (traces, metrics, logs) in a simpler single-container setup. * feat: add consolidation span hierarchy for tracing Add parent-child span structure for consolidation operations: - hindsight.consolidation: Parent span for each memory being processed - hindsight.consolidation_recall: Child span for finding related observations - LLM call span: Automatically created by LLM provider (scope="consolidation") This enables detailed timing breakdown in Grafana Tempo: - Total consolidation time per memory - Time spent in recall - Time spent in LLM call - Time spent executing actions (create/update) All consolidation tests pass (31/31). * feat: add Prometheus metrics and GenAI dashboard to Grafana stack Add comprehensive metrics and dashboarding to the Grafana LGTM stack: Metrics Collection: - Configure Prometheus to scrape Hindsight API /metrics endpoint - Scrape interval: 10 seconds - Targets hindsight-api on host.docker.internal:8888 GenAI Dashboard: - Pre-configured dashboard with 6 panels: - LLM call rate (by provider/model) - LLM call duration (p50/p95 by scope) - Token usage - input tokens/sec by scope - Token usage - output tokens/sec by scope - Operations rate (retain/recall/reflect/consolidation) - Operation duration p95 by operation type Configuration: - Mount prometheus.yml for metrics scraping - Mount dashboards directory for auto-provisioning - Add host.docker.internal mapping for container->host access - Dashboard provisioning with auto-reload every 10s Documentation: - Updated README with metrics viewing instructions - Added PromQL query examples - Documented dashboard access and navigation This provides full observability: traces (Tempo) + metrics (Prometheus/Mimir) + dashboards (Grafana) * refactor: merge Grafana setup into existing monitoring stack Consolidate the separate scripts/dev/grafana/ setup into the existing scripts/dev/monitoring/ stack, using Grafana LGTM (Loki, Grafana, Tempo, Mimir). Changes: - Remove separate scripts/dev/grafana/ directory and start-grafana.sh - Rewrite scripts/dev/monitoring/start.sh to use Docker + Grafana LGTM (was: download native Prometheus/Grafana binaries) - Add docker-compose.yaml for Grafana LGTM container - Add prometheus.yml for scraping Hindsight API metrics - Mount existing dashboards from monitoring/grafana/dashboards/ - Add comprehensive README.md Benefits: - Single unified monitoring command: ./scripts/dev/start-monitoring.sh - Uses existing dashboard files (hindsight-operations, hindsight-llm, hindsight-api-service) - Simpler setup: Docker-based vs downloading/running native binaries - Full observability: traces + metrics + logs + dashboards in one container - Standard ports: Grafana on 3000, OTLP on 4317/4318 Architecture: - Grafana LGTM container (~515MB) provides all components - Dashboards auto-provisioned from monitoring/grafana/dashboards/ - Prometheus scrapes host.docker.internal:8888/metrics - Shared hindsight-network for future service-to-service tracing * fix: run monitoring stack in foreground for easy Ctrl+C stop Change docker-compose from detached (-d) to foreground mode. Users can now stop the stack with Ctrl+C instead of needing to run docker-compose down separately. * fix: remove invalid home dashboard path and obsolete version field - Remove GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH environment variable (was pointing to wrong path causing 'Failed to load home dashboard' error) - Remove obsolete 'version' field from docker-compose.yaml (docker-compose v2+ doesn't require version field) * fix: load Hindsight dashboards in Grafana LGTM Mount Hindsight dashboard JSON files and custom provisioning config to make dashboards visible in Grafana. Changes: - Mount hindsight-operations.json, hindsight-llm.json, hindsight-api-service.json to /otel-lgtm/ - Create grafana-dashboards.yaml with all dashboard providers (default + Hindsight) - Mount custom provisioning config to override LGTM default All 3 Hindsight dashboards now appear in Grafana UI with metrics from Prometheus scraping the Hindsight API /metrics endpoint. * fix: configure Prometheus to scrape Hindsight API metrics Update prometheus.yml to include both OTLP receiver config (from LGTM) and scrape_configs for pulling metrics from Hindsight API. Changes: - Mount prometheus.yml to /otel-lgtm/prometheus.yaml (where LGTM reads it) - Add scrape_configs section to pull from host.docker.internal:8888/metrics - Keep OTLP receiver configuration for trace metrics - Set scrape_interval to 5s Verified: Prometheus now successfully scrapes hindsight_llm_calls_total and other Hindsight metrics. Dashboards now show live data! * feat: add comprehensive tracing for recall and improve reflect/mental_model_refresh spans - Add recall operation tracing with parent-child span hierarchy - Parent: hindsight.recall with attributes (bank_id, query, fact_types, etc.) - Children: recall_embedding, recall_retrieval, recall_fusion, recall_rerank - Fixed context propagation using start_as_current_span() - Improve reflect tracing spans - Remove reflect_generation spans, use reflect instead - Change done() tool processing to hindsight.reflect_tool_call - Fix mental_model_refresh span nesting - Add _skip_span parameter to reflect_async to avoid duplicate hindsight.reflect spans - Mental model refresh now has clean span hierarchy without nested reflect parent - Add comprehensive tracing verification tests - Test span hierarchy and attributes for all operations - Verify parent-child relationships - 5 passing tests covering recall, reflect, consolidation, and mental_model_refresh * refactor: remove redundant is_tracing_enabled() checks - Remove all is_tracing_enabled() conditional checks before tracing calls - NoOpTracer/NoOpSpan handle disabled tracing automatically - Simplify code by always calling tracer methods directly - Fix NoOpTracer.start_as_current_span() to yield NoOpSpan instead of None Changes: - memory_engine.py: Remove 5 is_tracing_enabled checks in recall spans - agent.py: Remove 2 is_tracing_enabled checks in reflect tool spans - tracing.py: Fix NoOpTracer context manager to yield proper NoOpSpan This eliminates ~50 lines of redundant conditional code while maintaining identical behavior. * docs: simplify distributed tracing section in monitoring.md - Make tracing documentation more concise - Focus on span hierarchy and attributes - Remove verbose troubleshooting and performance sections - Keep configuration.md for env vars only
301 lines
10 KiB
Python
301 lines
10 KiB
Python
"""
|
|
Mock LLM provider for testing.
|
|
|
|
This provider allows tests to record LLM calls and return configurable mock responses
|
|
without making actual API calls to external LLM services.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from ..llm_interface import LLMInterface
|
|
from ..response_models import LLMToolCall, LLMToolCallResult, TokenUsage
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MockLLM(LLMInterface):
|
|
"""
|
|
Mock LLM provider for testing.
|
|
|
|
This provider records all calls and returns configurable mock responses,
|
|
enabling tests to verify LLM interactions without making real API calls.
|
|
|
|
Example:
|
|
# Create mock provider
|
|
mock_llm = MockLLM(provider="mock", api_key="", base_url="", model="mock-model")
|
|
|
|
# Set mock response
|
|
mock_llm.set_mock_response({"answer": "test"})
|
|
|
|
# Make calls
|
|
result = await mock_llm.call(
|
|
messages=[{"role": "user", "content": "test"}],
|
|
response_format=MyResponseModel
|
|
)
|
|
|
|
# Verify calls
|
|
calls = mock_llm.get_mock_calls()
|
|
assert len(calls) == 1
|
|
assert calls[0]["scope"] == "memory"
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
provider: str,
|
|
api_key: str,
|
|
base_url: str,
|
|
model: str,
|
|
reasoning_effort: str = "low",
|
|
**kwargs: Any,
|
|
):
|
|
"""
|
|
Initialize mock LLM provider.
|
|
|
|
Args:
|
|
provider: Provider name (should be "mock").
|
|
api_key: Not used for mock provider.
|
|
base_url: Not used for mock provider.
|
|
model: Model name for tracking.
|
|
reasoning_effort: Not used for mock provider.
|
|
**kwargs: Additional parameters (not used).
|
|
"""
|
|
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
|
|
|
|
# Storage for test verification
|
|
self._mock_calls: list[dict] = []
|
|
self._mock_response: Any = None
|
|
self._mock_exception: Exception | None = None
|
|
|
|
async def verify_connection(self) -> None:
|
|
"""
|
|
Verify mock provider (always succeeds).
|
|
|
|
Mock provider doesn't need connection verification since it doesn't
|
|
make real API calls.
|
|
"""
|
|
logger.debug("Mock LLM: connection verification (always succeeds)")
|
|
|
|
async def call(
|
|
self,
|
|
messages: list[dict[str, str]],
|
|
response_format: Any | None = None,
|
|
max_completion_tokens: int | None = None,
|
|
temperature: float | None = None,
|
|
scope: str = "memory",
|
|
max_retries: int = 10,
|
|
initial_backoff: float = 1.0,
|
|
max_backoff: float = 60.0,
|
|
skip_validation: bool = False,
|
|
strict_schema: bool = False,
|
|
return_usage: bool = False,
|
|
) -> Any:
|
|
"""
|
|
Make a mock LLM API call.
|
|
|
|
Records the call for test verification and returns the configured mock response.
|
|
|
|
Args:
|
|
messages: List of message dicts with 'role' and 'content'.
|
|
response_format: Optional Pydantic model for structured output.
|
|
max_completion_tokens: Not used in mock.
|
|
temperature: Not used in mock.
|
|
scope: Scope identifier for tracking.
|
|
max_retries: Not used in mock.
|
|
initial_backoff: Not used in mock.
|
|
max_backoff: Not used in mock.
|
|
skip_validation: Return raw JSON without Pydantic validation.
|
|
strict_schema: Not used in mock.
|
|
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
|
|
|
|
Returns:
|
|
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
|
|
If return_usage=True: Tuple of (result, TokenUsage) with mock token counts.
|
|
"""
|
|
# Record the call for test verification
|
|
call_record = {
|
|
"provider": self.provider,
|
|
"model": self.model,
|
|
"messages": messages,
|
|
"response_format": response_format.__name__
|
|
if response_format and hasattr(response_format, "__name__")
|
|
else str(response_format),
|
|
"scope": scope,
|
|
}
|
|
self._mock_calls.append(call_record)
|
|
logger.debug(f"Mock LLM call recorded: scope={scope}, model={self.model}")
|
|
|
|
# Raise mock exception if configured
|
|
if self._mock_exception is not None:
|
|
raise self._mock_exception
|
|
|
|
# Record trace span (minimal for mock provider)
|
|
from hindsight_api.tracing import get_span_recorder
|
|
|
|
span_recorder = get_span_recorder()
|
|
span_recorder.record_llm_call(
|
|
provider=self.provider,
|
|
model=self.model,
|
|
scope=scope,
|
|
messages=messages,
|
|
response_content="mock response",
|
|
input_tokens=10,
|
|
output_tokens=5,
|
|
duration=0.001, # Mock calls are instant
|
|
finish_reason="stop",
|
|
error=None,
|
|
)
|
|
|
|
# Return mock response
|
|
if self._mock_response is not None:
|
|
result = self._mock_response
|
|
elif response_format is not None:
|
|
# Try to create a minimal valid instance of the response format
|
|
try:
|
|
# For Pydantic models, try to create with minimal valid data
|
|
result = {"mock": True}
|
|
except Exception:
|
|
result = {"mock": True}
|
|
else:
|
|
result = "mock response"
|
|
|
|
if return_usage:
|
|
token_usage = TokenUsage(input_tokens=10, output_tokens=5, total_tokens=15)
|
|
return result, token_usage
|
|
return result
|
|
|
|
async def call_with_tools(
|
|
self,
|
|
messages: list[dict[str, Any]],
|
|
tools: list[dict[str, Any]],
|
|
max_completion_tokens: int | None = None,
|
|
temperature: float | None = None,
|
|
scope: str = "tools",
|
|
max_retries: int = 5,
|
|
initial_backoff: float = 1.0,
|
|
max_backoff: float = 30.0,
|
|
tool_choice: str | dict[str, Any] = "auto",
|
|
) -> LLMToolCallResult:
|
|
"""
|
|
Make a mock LLM API call with tool/function calling support.
|
|
|
|
Records the call for test verification and returns the configured mock response.
|
|
|
|
Args:
|
|
messages: List of message dicts. Can include tool results with role='tool'.
|
|
tools: List of tool definitions in OpenAI format.
|
|
max_completion_tokens: Not used in mock.
|
|
temperature: Not used in mock.
|
|
scope: Scope identifier for tracking.
|
|
max_retries: Not used in mock.
|
|
initial_backoff: Not used in mock.
|
|
max_backoff: Not used in mock.
|
|
tool_choice: Not used in mock.
|
|
|
|
Returns:
|
|
LLMToolCallResult with content and/or tool_calls.
|
|
"""
|
|
# Record the call for test verification
|
|
call_record = {
|
|
"provider": self.provider,
|
|
"model": self.model,
|
|
"messages": messages,
|
|
"tools": [t.get("function", {}).get("name") for t in tools],
|
|
"scope": scope,
|
|
}
|
|
self._mock_calls.append(call_record)
|
|
|
|
# Raise mock exception if configured
|
|
if self._mock_exception is not None:
|
|
raise self._mock_exception
|
|
|
|
# Record OpenTelemetry span
|
|
from hindsight_api.tracing import get_span_recorder
|
|
|
|
span_recorder = get_span_recorder()
|
|
|
|
if self._mock_response is not None:
|
|
if isinstance(self._mock_response, LLMToolCallResult):
|
|
result = self._mock_response
|
|
elif isinstance(self._mock_response, list):
|
|
# Allow setting just tool calls as a list
|
|
result = LLMToolCallResult(
|
|
tool_calls=[
|
|
LLMToolCall(id=f"mock_{i}", name=tc["name"], arguments=tc.get("arguments", {}))
|
|
for i, tc in enumerate(self._mock_response)
|
|
],
|
|
finish_reason="tool_calls",
|
|
)
|
|
else:
|
|
result = LLMToolCallResult(content="mock response", finish_reason="stop")
|
|
else:
|
|
result = LLMToolCallResult(content="mock response", finish_reason="stop")
|
|
|
|
# Record span with mock values
|
|
# Convert LLMToolCall objects to dicts for span recording
|
|
tool_calls_dict = (
|
|
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in result.tool_calls]
|
|
if result.tool_calls
|
|
else None
|
|
)
|
|
span_recorder.record_llm_call(
|
|
provider=self.provider,
|
|
model=self.model,
|
|
scope=scope,
|
|
messages=messages,
|
|
response_content=result.content,
|
|
input_tokens=10, # Mock value
|
|
output_tokens=5, # Mock value
|
|
duration=0.1, # Mock value
|
|
finish_reason=result.finish_reason,
|
|
error=None,
|
|
tool_calls=tool_calls_dict,
|
|
)
|
|
|
|
return result
|
|
|
|
async def cleanup(self) -> None:
|
|
"""Clean up resources (no-op for mock provider)."""
|
|
pass
|
|
|
|
def set_mock_response(self, response: Any) -> None:
|
|
"""
|
|
Set the response to return from mock calls.
|
|
|
|
Args:
|
|
response: The response to return. Can be:
|
|
- A dict/Pydantic model for regular calls
|
|
- An LLMToolCallResult for tool calls
|
|
- A list of tool call dicts for tool calls
|
|
- Any other value to return as-is
|
|
"""
|
|
self._mock_response = response
|
|
|
|
def set_mock_exception(self, exception: Exception) -> None:
|
|
"""
|
|
Set an exception to raise from mock calls.
|
|
|
|
Args:
|
|
exception: The exception to raise on the next call.
|
|
After raising, the exception is cleared.
|
|
"""
|
|
self._mock_exception = exception
|
|
|
|
def get_mock_calls(self) -> list[dict]:
|
|
"""
|
|
Get the list of recorded mock calls.
|
|
|
|
Returns:
|
|
List of call records, each containing:
|
|
- provider: Provider name
|
|
- model: Model name
|
|
- messages: Messages sent
|
|
- response_format/tools: Format or tools used
|
|
- scope: Call scope
|
|
"""
|
|
return self._mock_calls
|
|
|
|
def clear_mock_calls(self) -> None:
|
|
"""Clear the recorded mock calls and any set exception."""
|
|
self._mock_calls = []
|
|
self._mock_exception = None
|