3f14cbbf7e
CI / lint (pull_request) Successful in 40s
CI / security (pull_request) Successful in 32s
CI / quality (pull_request) Successful in 42s
CI / typecheck (pull_request) Successful in 1m1s
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 14s
CI / unit_tests (pull_request) Successful in 3m58s
CI / integration_tests (pull_request) Successful in 4m34s
CI / docker (pull_request) Successful in 1m6s
CI / coverage (pull_request) Successful in 6m21s
CI / quality (push) Successful in 16s
CI / lint (push) Successful in 21s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 35s
CI / benchmark-regression (push) Has been skipped
CI / build (push) Successful in 39s
CI / unit_tests (push) Successful in 2m38s
CI / integration_tests (push) Successful in 3m2s
CI / docker (push) Successful in 40s
CI / coverage (push) Successful in 4m9s
CI / benchmark-publish (push) Successful in 14m42s
CI / benchmark-regression (pull_request) Successful in 26m29s
Add LLMTrace Pydantic v2 domain model with all required fields (trace_id, plan_id, decision_id, actor, provider, model, prompt_tokens, completion_tokens, cost_usd, latency_ms, tool_calls, context_hash, streaming, retry_count, error). Define 14 OperationalMetricKey values (PLAN_DURATION_MS, PLAN_TOTAL_COST_USD, PLAN_DECISION_COUNT, ACTOR_INVOCATION_COUNT, ACTOR_LATENCY_MS, TOOL_INVOCATION_COUNT, TOOL_ERROR_RATE, CONTEXT_BUILD_TIME_MS, CONTEXT_TOKEN_COUNT, LLM_CALL_COUNT, LLM_TOTAL_TOKENS, LLM_TOTAL_COST_USD, LLM_AVG_LATENCY_MS, SUBPLAN_COUNT) with MetricEntry model and MetricCollector. Add llm_traces database table (LLMTraceModel) with LLMTraceRepository for persistence. TraceService provides recording, querying, metric computation, plan lifecycle hooks, and optional LangSmith forwarding when LANGCHAIN_TRACING_V2=true. Wired into DI container as trace_service. Includes 28 Behave BDD scenarios, 6 Robot Framework smoke tests, 3 ASV benchmark suites, and reference documentation. Fix pre-existing cli_core server_mode test flake by mocking resolve_server_mode in test steps to avoid stale config file interference. Closes #500
215 lines
6.4 KiB
Python
215 lines
6.4 KiB
Python
"""Helper script for LLM trace Robot Framework tests.
|
|
|
|
Usage:
|
|
python helper_llm_trace.py record-and-retrieve
|
|
python helper_llm_trace.py list-by-plan
|
|
python helper_llm_trace.py compute-metrics
|
|
python helper_llm_trace.py metric-key-count
|
|
python helper_llm_trace.py validation
|
|
python helper_llm_trace.py lifecycle-hooks
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
# Ensure source tree is importable — force the local checkout to win
|
|
# over any inherited PYTHONPATH (e.g. /app/src in CI containers).
|
|
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
|
sys.path.insert(0, _SRC)
|
|
|
|
from cleveragents.application.services.trace_service import TraceService # noqa: E402
|
|
from cleveragents.domain.models.observability.llm_trace import LLMTrace # noqa: E402
|
|
from cleveragents.domain.models.observability.metrics import ( # noqa: E402
|
|
OperationalMetricKey,
|
|
)
|
|
|
|
VALID_ULID_1 = "01HXAAAAAAAAAAAAAAAAAAAAAA"
|
|
VALID_ULID_2 = "01HXBBBBBBBBBBBBBBBBBBBBBB"
|
|
VALID_ULID_3 = "01HXCCCCCCCCCCCCCCCCCCCCCC"
|
|
VALID_PLAN_ID = "01HX0000000000PPPPPPPPPPPP"
|
|
|
|
|
|
class InMemoryRepo:
|
|
"""Minimal in-memory repo for testing."""
|
|
|
|
def __init__(self) -> None:
|
|
self._store: dict[str, LLMTrace] = {}
|
|
|
|
def save(self, trace: LLMTrace) -> None:
|
|
self._store[trace.trace_id] = trace
|
|
|
|
def get(self, trace_id: str) -> LLMTrace | None:
|
|
return self._store.get(trace_id)
|
|
|
|
def list_by_plan(self, plan_id: str) -> list[LLMTrace]:
|
|
return [t for t in self._store.values() if t.plan_id == plan_id]
|
|
|
|
def list_by_decision(self, decision_id: str) -> list[LLMTrace]:
|
|
return [t for t in self._store.values() if t.decision_id == decision_id]
|
|
|
|
|
|
def _make_trace(trace_id: str = VALID_ULID_1, **kwargs: Any) -> LLMTrace:
|
|
defaults: dict[str, Any] = {
|
|
"trace_id": trace_id,
|
|
"plan_id": VALID_PLAN_ID,
|
|
"actor": "planner",
|
|
"provider": "openai",
|
|
"model": "gpt-4o",
|
|
"prompt_tokens": 500,
|
|
"completion_tokens": 200,
|
|
"cost_usd": 0.01,
|
|
"latency_ms": 350.0,
|
|
}
|
|
defaults.update(kwargs)
|
|
return LLMTrace(**defaults)
|
|
|
|
|
|
def _make_service() -> TraceService:
|
|
repo = InMemoryRepo()
|
|
return TraceService(settings=MagicMock(), repository=repo) # type: ignore[arg-type]
|
|
|
|
|
|
def cmd_record_and_retrieve() -> None:
|
|
"""Record a trace and retrieve by ID."""
|
|
svc = _make_service()
|
|
trace = _make_trace()
|
|
svc.record_trace(trace)
|
|
result = svc.get_trace(trace.trace_id)
|
|
if result is None:
|
|
print("FAIL: trace not found after recording")
|
|
sys.exit(1)
|
|
if result.trace_id != trace.trace_id:
|
|
print(f"FAIL: trace_id mismatch {result.trace_id} != {trace.trace_id}")
|
|
sys.exit(1)
|
|
print("llm-trace-record-ok")
|
|
|
|
|
|
def cmd_list_by_plan() -> None:
|
|
"""Record multiple traces and list by plan."""
|
|
svc = _make_service()
|
|
for uid in (VALID_ULID_1, VALID_ULID_2, VALID_ULID_3):
|
|
svc.record_trace(_make_trace(trace_id=uid))
|
|
results = svc.get_traces(VALID_PLAN_ID)
|
|
if len(results) != 3:
|
|
print(f"FAIL: expected 3 traces, got {len(results)}")
|
|
sys.exit(1)
|
|
print("llm-trace-list-plan-ok")
|
|
|
|
|
|
def cmd_compute_metrics() -> None:
|
|
"""Compute metrics from traces."""
|
|
svc = _make_service()
|
|
for uid in (VALID_ULID_1, VALID_ULID_2, VALID_ULID_3):
|
|
svc.record_trace(_make_trace(trace_id=uid))
|
|
metrics = svc.compute_metrics(VALID_PLAN_ID)
|
|
if len(metrics) != 4:
|
|
print(f"FAIL: expected 4 metrics, got {len(metrics)}")
|
|
sys.exit(1)
|
|
keys = {m.key for m in metrics}
|
|
expected = {
|
|
OperationalMetricKey.LLM_CALL_COUNT,
|
|
OperationalMetricKey.LLM_TOTAL_TOKENS,
|
|
OperationalMetricKey.LLM_TOTAL_COST_USD,
|
|
OperationalMetricKey.LLM_AVG_LATENCY_MS,
|
|
}
|
|
if keys != expected:
|
|
print(f"FAIL: unexpected metric keys {keys}")
|
|
sys.exit(1)
|
|
print("llm-trace-metrics-ok")
|
|
|
|
|
|
def cmd_metric_key_count() -> None:
|
|
"""Verify all 14 metric keys exist."""
|
|
if len(OperationalMetricKey) != 14:
|
|
print(f"FAIL: expected 14 keys, got {len(OperationalMetricKey)}")
|
|
sys.exit(1)
|
|
print("llm-trace-metric-keys-ok")
|
|
|
|
|
|
def cmd_validation() -> None:
|
|
"""Verify model validation rejects bad inputs."""
|
|
from pydantic import ValidationError
|
|
|
|
# Invalid trace_id
|
|
try:
|
|
_make_trace(trace_id="INVALID")
|
|
print("FAIL: invalid trace_id accepted")
|
|
sys.exit(1)
|
|
except ValidationError:
|
|
pass
|
|
|
|
# Negative prompt tokens
|
|
try:
|
|
_make_trace(prompt_tokens=-1)
|
|
print("FAIL: negative prompt_tokens accepted")
|
|
sys.exit(1)
|
|
except ValidationError:
|
|
pass
|
|
|
|
# Negative cost
|
|
try:
|
|
_make_trace(cost_usd=-0.5)
|
|
print("FAIL: negative cost_usd accepted")
|
|
sys.exit(1)
|
|
except ValidationError:
|
|
pass
|
|
|
|
print("llm-trace-validation-ok")
|
|
|
|
|
|
def cmd_lifecycle_hooks() -> None:
|
|
"""Verify lifecycle hooks produce correct metrics."""
|
|
svc = _make_service()
|
|
|
|
# on_plan_start
|
|
entry = svc.on_plan_start(VALID_PLAN_ID)
|
|
if entry is None or entry.key != OperationalMetricKey.PLAN_DECISION_COUNT:
|
|
print("FAIL: on_plan_start incorrect")
|
|
sys.exit(1)
|
|
|
|
# on_actor_invocation
|
|
entries = svc.on_actor_invocation(VALID_PLAN_ID, "planner", 250.0)
|
|
keys = {m.key for m in entries}
|
|
if OperationalMetricKey.ACTOR_INVOCATION_COUNT not in keys:
|
|
print("FAIL: on_actor_invocation missing ACTOR_INVOCATION_COUNT")
|
|
sys.exit(1)
|
|
|
|
# on_tool_execution
|
|
entries = svc.on_tool_execution(VALID_PLAN_ID, "file_write", errored=True)
|
|
keys = {m.key for m in entries}
|
|
if OperationalMetricKey.TOOL_ERROR_RATE not in keys:
|
|
print("FAIL: on_tool_execution missing TOOL_ERROR_RATE")
|
|
sys.exit(1)
|
|
|
|
print("llm-trace-hooks-ok")
|
|
|
|
|
|
def main() -> None:
|
|
"""Dispatch subcommand."""
|
|
if len(sys.argv) < 2:
|
|
print("Usage: helper_llm_trace.py <command>")
|
|
sys.exit(1)
|
|
|
|
cmd = sys.argv[1]
|
|
commands = {
|
|
"record-and-retrieve": cmd_record_and_retrieve,
|
|
"list-by-plan": cmd_list_by_plan,
|
|
"compute-metrics": cmd_compute_metrics,
|
|
"metric-key-count": cmd_metric_key_count,
|
|
"validation": cmd_validation,
|
|
"lifecycle-hooks": cmd_lifecycle_hooks,
|
|
}
|
|
fn = commands.get(cmd)
|
|
if fn is None:
|
|
print(f"Unknown command: {cmd}")
|
|
sys.exit(1)
|
|
fn()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|