diff --git a/.env b/.env new file mode 100644 index 0000000..c2ab86e --- /dev/null +++ b/.env @@ -0,0 +1,164 @@ +############################################################################### +# AI AGENT PLATFORM - CONFIGURAÇÃO ÚNICA +# Este arquivo é lido por Pydantic Settings no framework e no backend template. +############################################################################### + +APP_NAME=ai-agent-template +APP_ENV=local +LOG_LEVEL=INFO +API_HOST=0.0.0.0 +API_PORT=8000 +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +############################################################################### +# LLM - OCI Generative AI como provider principal +############################################################################### +# Opções: mock, oci_openai, oci_sdk, openai_compatible +LLM_PROVIDER=oci_openai +LLM_TEMPERATURE=0.2 +LLM_MAX_TOKENS=2048 +LLM_TIMEOUT_SECONDS=120 + +# OCI OpenAI-compatible endpoint +OCI_GENAI_BASE_URL=https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/openai/v1 +OCI_GENAI_MODEL=openai.gpt-4.1 +OCI_GENAI_API_KEY=sk-ph3FgX6iP3fxAQCXb9IpPIDTadkeeYAWntUWhzcWysIM6zsS +OCI_GENAI_PROJECT_OCID= + +# OCI SDK / signer / profiles +OCI_CONFIG_FILE=~/.oci/config +OCI_PROFILE=LATINOAMERICA-Chicago +OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaaexpiw4a7dio64mkfv2t273s2hgdl6mgfvvyv7tycalnjlvpvfl3q +OCI_REGION=us-chicago-1 + +############################################################################### +# Persistência +############################################################################### +# Opções: memory, autonomous, mongodb +SESSION_REPOSITORY_PROVIDER=sqlite +MEMORY_REPOSITORY_PROVIDER=sqlite +CHECKPOINT_REPOSITORY_PROVIDER=sqlite +SQLITE_DB_PATH=./data/agent_framework.db + +# Autonomous Database +ADB_USER=admin +ADB_PASSWORD=Moniquinha1972 +ADB_DSN=oradb23ai_high +ADB_WALLET_LOCATION=/mnt/d/Dropbox/ORACLE/LatinoAmerica/Wallet_ORADB23ai +ADB_WALLET_PASSWORD=Moniquinha1972 +ADB_TABLE_PREFIX=AGENTFW + +# MongoDB - também pode representar Autonomous usando API compatível com Mongo, se habilitada no ambiente +MONGODB_URI=mongodb://mongo:mongopassword@localhost:27017 +MONGODB_DATABASE=agent_platform + +# Redis +REDIS_URL=redis://localhost:6379/0 +ENABLE_REDIS_CACHE=false + +############################################################################### +# RAG / Vector / Graph +############################################################################### +VECTOR_STORE_PROVIDER=memory +GRAPH_STORE_PROVIDER=memory +RAG_TOP_K=5 +EMBEDDING_PROVIDER=mock +OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0 +RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json + +############################################################################### +# Observabilidade +############################################################################### +ENABLE_LANGFUSE=true +LANGFUSE_TRACE_MODE=compact # Opcional: verbose, compact +LANGFUSE_PUBLIC_KEY=pk-lf-2f9da109-5b0f-4c78-b61d-9598ed787eba +LANGFUSE_SECRET_KEY=sk-lf-a4cb0cdd-f2ea-4468-9911-cebeb91ba944 +LANGFUSE_HOST=http://localhost:3005 +ENABLE_OTEL=false +OTEL_EXPORTER_OTLP_ENDPOINT= +OTEL_SERVICE_NAME=ai-agent-template + +############################################################################### +# Analytics / Observer corporativo +############################################################################### +# Quando true, AgentObserver publica eventos IC.*, NOC.* e GRL.* nos providers abaixo. +ENABLE_ANALYTICS=false +# Providers aceitos: oci_streaming,pubsub,noop +ANALYTICS_PROVIDERS=pubsub +# Compatibilidade FIRST/TIM: pode informar AGENT_PUBSUB_TOPIC diretamente. +AGENT_PUBSUB_TOPIC= +GCP_PUBSUB_TOPIC_PATH= +GCP_PROJECT_ID= +GCP_PUBSUB_TOPIC= +GCP_PUBSUB_TIMEOUT_SECONDS=30 +# Credencial GCP segue padrão Google: +# GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json + +############################################################################### +# OCI Streaming +############################################################################### +ENABLE_OCI_STREAMING=false +OCI_STREAM_ENDPOINT= +OCI_STREAM_OCID= +OCI_STREAM_PARTITION_KEY=agent-events + +############################################################################### +# Guardrails, Judges, Supervisor +############################################################################### +ENABLE_INPUT_GUARDRAILS=true +ENABLE_OUTPUT_GUARDRAILS=true +ENABLE_JUDGES=true +ENABLE_SUPERVISOR=true +ENABLE_OUTPUT_SUPERVISOR=true +ENABLE_PARALLEL_GUARDRAILS=true +GUARDRAILS_FAIL_FAST=true +OUTPUT_SUPERVISOR_MAX_RETRIES=3 +GUARDRAILS_CONFIG_PATH=./config/guardrails.yaml +JUDGES_CONFIG_PATH=./config/judges.yaml +PROMPT_POLICY_PATH=./config/prompt_policy.yaml + +############################################################################### +# Gateway de canais +############################################################################### +DEFAULT_CHANNEL=web +ENABLE_VOICE_ADAPTER=true +ENABLE_WHATSAPP_ADAPTER=true +ENABLE_TEXT_ADAPTER=true + +################################################# +# ENTERPRISE ROUTING +################################################# +# Arquivo YAML com intents, keywords, políticas de estado e fallback. +ROUTING_CONFIG_PATH=./config/routing.yaml +# true = usa LLM para classificar quando keywords/estado não resolverem. +# Em produção, costuma ser útil; em desenvolvimento, false evita custo e latência. +ENABLE_LLM_ROUTER=true + +############################################################################### +# MCP / Tools +############################################################################### +ENABLE_MCP_TOOLS=true +MCP_SERVERS_CONFIG_PATH=./agent_template_backend/config/mcp_servers.yaml +TOOLS_CONFIG_PATH=./agent_template_backend/config/tools.yaml +MCP_TOOL_TIMEOUT_SECONDS=30 + +# router = EnterpriseRouter seleciona um agente; supervisor = pode acionar múltiplos agentes +ROUTING_MODE=router + +# Usage/cost accounting +USAGE_REPOSITORY_PROVIDER=sqlite +IDENTITY_CONFIG_PATH=./agent_template_backend/config/identity.yaml +MCP_PARAMETER_MAPPING_PATH=./agent_template_backend/config/mcp_parameter_mapping.yaml + +# ----------------------------------------------------------------------------- +# ConversationSummaryMemory / compressão de contexto conversacional +# ----------------------------------------------------------------------------- +ENABLE_CONVERSATION_SUMMARY_MEMORY=true +MEMORY_CONTEXT_STRATEGY=summary +MEMORY_HISTORY_LIMIT=80 +MEMORY_RECENT_MESSAGES_LIMIT=8 +MEMORY_SUMMARY_TRIGGER_MESSAGES=20 +MEMORY_MAX_SUMMARY_CHARS=6000 +MEMORY_SUMMARY_USE_LLM=true +MEMORY_INJECT_RECENT_MESSAGES=true +MEMORY_INJECT_SUMMARY=true diff --git a/tests/unit/test_telemetry_langfuse_compact.py b/tests/unit/test_telemetry_langfuse_compact.py new file mode 100644 index 0000000..29b7634 --- /dev/null +++ b/tests/unit/test_telemetry_langfuse_compact.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import pytest + +from agent_framework.observability.context import clear_observability_context +from agent_framework.observability.telemetry import Telemetry + + +class Settings: + ENABLE_LANGFUSE = False + ENABLE_OTEL = False + LANGFUSE_TRACE_MODE = "compact" + + +class FakeObservation: + _next_id = 1 + + def __init__(self, kwargs): + self.kwargs = kwargs + self.id = f"obs-{FakeObservation._next_id}" + self.trace_id = "trace-123" + FakeObservation._next_id += 1 + self.updates = [] + self.trace_updates = [] + self.trace_io_updates = [] + + def update(self, **kwargs): + self.updates.append(kwargs) + + def update_trace(self, **kwargs): + self.trace_updates.append(kwargs) + + def set_trace_io(self, **kwargs): + self.trace_io_updates.append(kwargs) + + +class FakeContextManager: + def __init__(self, observation): + self.observation = observation + + def __enter__(self): + return self.observation + + def __exit__(self, exc_type, exc, tb): + return False + + +class FakePropagationContext: + def __init__(self, owner, kwargs): + self.owner = owner + self.kwargs = kwargs + + def __enter__(self): + self.owner.propagations.append(self.kwargs) + + def __exit__(self, exc_type, exc, tb): + return False + + +class FakeLangfuse: + def __init__(self, *, legacy_api: bool = False): + self.observations = [] + self.propagations = [] + self.flush_count = 0 + self.api = FakeApi() if legacy_api else None + + def start_as_current_observation(self, **kwargs): + observation = FakeObservation(kwargs) + self.observations.append(observation) + return FakeContextManager(observation) + + def propagate_attributes(self, **kwargs): + return FakePropagationContext(self, kwargs) + + def flush(self): + self.flush_count += 1 + + +class FakeIngestionResponse: + errors = [] + successes = [] + + +class FakeIngestion: + def __init__(self): + self.batches = [] + + def batch(self, *, batch, metadata=None): + self.batches.append({"batch": batch, "metadata": metadata}) + return FakeIngestionResponse() + + +class FakeApi: + def __init__(self): + self.ingestion = FakeIngestion() + + +def telemetry_with_fake_langfuse(*, legacy_api: bool = False): + FakeObservation._next_id = 1 + telemetry = Telemetry(Settings()) + telemetry.enabled = True + telemetry.langfuse = FakeLangfuse(legacy_api=legacy_api) + return telemetry + + +@pytest.mark.asyncio +async def test_compact_keeps_root_output_and_shows_only_aga_noc_events(): + clear_observability_context() + telemetry = telemetry_with_fake_langfuse() + + async with telemetry.span("agent.gateway_message", session_id="s1", input={"request": "cms"}, _root_span=True) as span: + await telemetry.event("IC.INTERNAL", {"step": "hidden"}, kind="ic") + await telemetry.event("NOC.001", {"step": "visible"}, kind="noc") + await telemetry.event("AGA.010", {"step": "visible"}, kind="ic") + span.set_output({"answer": "ok"}) + + names = [obs.kwargs["name"] for obs in telemetry.langfuse.observations] + assert names == ["agent.gateway_message", "NOC.001", "AGA.010"] + + root = telemetry.langfuse.observations[0] + assert root.updates[-1]["input"] == {"request": "cms"} + assert root.updates[-1]["output"] == {"answer": "ok"} + assert root.trace_io_updates[-1] == {"input": {"request": "cms"}, "output": {"answer": "ok"}} + assert telemetry.langfuse.observations[1].kwargs.get("trace_context") is None + assert telemetry.langfuse.observations[2].kwargs.get("trace_context") is None + assert telemetry.langfuse.propagations[-1]["trace_name"] == "agent.gateway_message" + + aggregated = root.updates[-1]["metadata"]["aggregated_events"] + assert [event["name"] for event in aggregated] == ["IC.INTERNAL", "NOC.001", "AGA.010"] + + +@pytest.mark.asyncio +async def test_compact_generation_records_io_model_parameters_and_usage_details(): + clear_observability_context() + telemetry = telemetry_with_fake_langfuse() + + async with telemetry.span("agent.gateway_message", session_id="s1", input={"request": "cms"}, _root_span=True): + async with telemetry.generation_span( + name="llm.test", + model="test-model", + input=[{"role": "user", "content": "ping"}], + metadata={"profile_name": "test"}, + model_parameters={"temperature": 0.2, "max_tokens": 100}, + ) as generation: + generation.set_output("pong") + generation.set_usage({"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2, "cost_usd": 0.01}) + + generation = telemetry.langfuse.observations[1] + assert generation.kwargs["name"] == "llm.test" + assert generation.kwargs["as_type"] == "generation" + assert generation.kwargs["input"] == [{"role": "user", "content": "ping"}] + assert generation.kwargs["model"] == "test-model" + assert generation.kwargs["model_parameters"] == {"temperature": 0.2, "max_tokens": 100} + assert "usage" not in generation.kwargs + assert "usage_details" not in generation.kwargs + assert generation.updates[-1]["input"] == [{"role": "user", "content": "ping"}] + assert generation.updates[-1]["output"] == "pong" + assert generation.updates[-1]["usage_details"] == {"input": 1, "output": 1} + assert generation.updates[-1]["cost_details"] == {"total": 0.01} + assert generation.kwargs.get("trace_context") is None + + +@pytest.mark.asyncio +async def test_legacy_io_fallback_updates_same_root_and_generation_observations(): + clear_observability_context() + telemetry = telemetry_with_fake_langfuse(legacy_api=True) + + async with telemetry.span("agent.gateway_message", session_id="s1", input={"request": "cms"}, _root_span=True) as root: + async with telemetry.generation_span( + name="llm.test", + model="test-model", + input=[{"role": "user", "content": "ping"}], + model_parameters={"temperature": 0.2}, + ) as generation: + generation.set_output("pong") + generation.set_usage({"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5}) + root.set_output({"answer": "ok"}) + + batches = telemetry.langfuse.api.ingestion.batches + assert [event.type for item in batches for event in item["batch"]] == ["generation-update", "span-update"] + + generation_event = batches[0]["batch"][0] + assert generation_event.body.id == "obs-2" + assert generation_event.body.trace_id == "trace-123" + assert generation_event.body.input == [{"role": "user", "content": "ping"}] + assert generation_event.body.output == "pong" + assert generation_event.body.usage_details == {"input": 2, "output": 3} + + root_event = batches[1]["batch"][0] + assert root_event.body.id == "obs-1" + assert root_event.body.trace_id == "trace-123" + assert root_event.body.input == {"request": "cms"} + assert root_event.body.output == {"answer": "ok"} + assert len(telemetry.langfuse.observations) == 2