first commit

This commit is contained in:
2026-06-13 08:23:21 -03:00
commit 89c23fb0ed
439 changed files with 32801 additions and 0 deletions

Binary file not shown.

View File

@@ -0,0 +1,112 @@
"""
Local Langfuse client wrapper with enriched error handling.
The get_prompt_from_langfuse() method from agent_framework.prompts silently
captures all exceptions without indicating the root cause of the failure.
This module replaces that function with a wrapper that:
- Distinguishes between authentication errors, prompt not found, and network errors.
- Logs the root cause in a structured way.
- Supports retrieval by label (e.g. "production") instead of version "latest".
"""
import langfuse
from typing import Optional, Tuple
from src.core.logging import get_logger
from src.core.config import settings
logger = get_logger(__name__)
def _build_httpx_client():
"""Mirror setup_observer's behavior: honor settings.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE."""
cert_path = settings.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE
if not cert_path:
return None
try:
import httpx
return httpx.Client(verify=cert_path)
except Exception as exc:
logger.warning("Failed to build httpx client for Langfuse provider: %s", exc)
return None
def get_prompt_with_config_from_langfuse(
prompt_name: str,
label: str = "production",
) -> Tuple[Optional[str], dict]:
"""
Fetches a text prompt + its `config` dict from Langfuse.
Langfuse permite anexar um objeto JSON `config` ao lado do corpo do
prompt (editável pela mesma UI). Usamos isso para guardar tuning
parameters que o squad de prompt-engineering pode ajustar sem deploy
(ex.: tamanho de janelas de histórico, thresholds, flags).
Args:
prompt_name: Nome do prompt registrado no Langfuse.
label: Label a recuperar (default: "production").
Returns:
Tupla `(content, config)`. `content=None` quando o prompt não foi
encontrado ou houve erro de rede/auth — nesses casos o caller deve
cair em fallback local. `config` é `{}` quando o prompt não tem
config setado.
"""
try:
client_kwargs = {
"public_key": settings.LANGFUSE_PUBLIC_KEY,
"secret_key": settings.LANGFUSE_SECRET_KEY,
"host": settings.LANGFUSE_BASE_URL,
}
httpx_client = _build_httpx_client()
if httpx_client is not None:
client_kwargs["httpx_client"] = httpx_client
client = langfuse.Langfuse(**client_kwargs)
except Exception as e:
logger.error(
"Failed to initialize the Langfuse client. Check LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and LANGFUSE_BASE_URL. "
f"Error: {e}"
)
return None, {}
try:
prompt_obj = client.get_prompt(name=prompt_name, label=label)
except Exception as e:
logger.error(
f"Unexpected error while fetching prompt '{prompt_name}' from Langfuse: "
f"{type(e).__name__}: {e}"
)
return None, {}
# The Langfuse v3 SDK returns different types depending on the prompt type.
# For text prompts (type="text"), the content is in the .prompt attribute.
if prompt_obj is None:
logger.warning(f"Langfuse returned None for prompt '{prompt_name}'.")
return None, {}
# Try both known SDK attributes to ensure version compatibility.
content = getattr(prompt_obj, "prompt", None) or getattr(prompt_obj, "content", None)
if not content:
logger.warning(
f"Prompt '{prompt_name}' found but has no content. "
f"Available attributes: {[a for a in dir(prompt_obj) if not a.startswith('_')]}"
)
return None, {}
# `config` é opcional no Langfuse. Normalizamos para dict para que o
# caller possa sempre fazer `config.get(...)` sem checar tipo.
raw_config = getattr(prompt_obj, "config", None)
config = raw_config if isinstance(raw_config, dict) else {}
return content, config
def get_prompt_from_langfuse(
prompt_name: str,
label: str = "production",
) -> Optional[str]:
"""Backwards-compatible wrapper que ignora o `config` do prompt."""
content, _config = get_prompt_with_config_from_langfuse(prompt_name, label)
return content

View File

@@ -0,0 +1,216 @@
"""
Backoffice LLM provider adapter.
This module uses this framework version's real LLM entrypoint:
agent_framework.llm.providers.create_llm
The backoffice domain still needs the historical objects
``classification_llm``, ``classification_large_llm`` and ``tais_kb_llm`` with
attributes such as ``eligibleModel_name`` because the original nodes reference
those attributes for telemetry. They are lightweight descriptors; execution is
performed by the framework provider selected by ``LLM_PROVIDER``.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
import threading
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional
from agent_framework.config.settings import settings as fw_settings
from agent_framework.llm.providers import create_llm
from src.core.config import settings
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class BackofficeLLMDescriptor:
eligibleModel_name: str
temperature: float
max_tokens: int
top_p: float = 1.0
top_k: float = 0.0
@dataclass
class LLMResponse:
content: str
model: str = ""
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
finish_reason: str = ""
latency_ms: float = 0.0
parsed_json: Optional[Any] = None
@property
def usage(self) -> Dict[str, int]:
return {
"input": self.prompt_tokens,
"output": self.completion_tokens,
"total": self.total_tokens,
}
class _SettingsProxy:
"""Proxy over framework settings with per-call model parameters."""
def __init__(self, base: Any, llm: BackofficeLLMDescriptor):
self._base = base
self.LLM_PROVIDER = getattr(settings, "LLM_PROVIDER", None) or getattr(base, "LLM_PROVIDER", "oci_openai")
# The framework supports oci_openai, oci_sdk, openai_compatible and mock.
# If a legacy env uses "oci", route it to the immediately usable OCI
# OpenAI-compatible implementation.
if self.LLM_PROVIDER == "oci":
self.LLM_PROVIDER = "oci_openai"
self.OCI_GENAI_MODEL = str(llm.eligibleModel_name)
self.LLM_TEMPERATURE = float(llm.temperature)
self.LLM_MAX_TOKENS = int(llm.max_tokens)
def __getattr__(self, name: str) -> Any:
return getattr(self._base, name)
LLM_ENDPOINT: str = getattr(fw_settings, "OCI_GENAI_BASE_URL", "")
classification_llm = BackofficeLLMDescriptor(
eligibleModel_name=settings.CLASSIFICATION_LLM_MODEL,
temperature=settings.CLASSIFICATION_LLM_TEMPERATURE,
max_tokens=settings.CLASSIFICATION_LLM_MAX_TOKENS,
top_p=settings.CLASSIFICATION_LLM_TOP_P,
top_k=settings.CLASSIFICATION_LLM_TOP_K,
)
classification_large_llm = BackofficeLLMDescriptor(
eligibleModel_name=settings.CLASSIFICATION_LARGE_LLM_MODEL,
temperature=settings.CLASSIFICATION_LARGE_LLM_TEMPERATURE,
max_tokens=settings.CLASSIFICATION_LARGE_LLM_MAX_TOKENS,
top_p=settings.CLASSIFICATION_LARGE_LLM_TOP_P,
top_k=settings.CLASSIFICATION_LARGE_LLM_TOP_K,
)
tais_kb_llm = BackofficeLLMDescriptor(
eligibleModel_name=settings.TAIS_KB_LLM_MODEL,
temperature=settings.TAIS_KB_LLM_TEMPERATURE,
max_tokens=settings.TAIS_KB_LLM_MAX_TOKENS,
top_p=settings.TAIS_KB_LLM_TOP_P,
top_k=settings.TAIS_KB_LLM_TOP_K,
)
def _run_coro_sync(coro):
"""Run async framework providers from the original sync node helpers.
Some original backoffice nodes are async but call this helper synchronously.
If there is an active event loop, run the coroutine in a short-lived thread
with its own loop to avoid ``asyncio.run() cannot be called`` errors.
"""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
result: dict[str, Any] = {}
def runner() -> None:
try:
result["value"] = asyncio.run(coro)
except BaseException as exc: # noqa: BLE001
result["error"] = exc
t = threading.Thread(target=runner, daemon=True)
t.start()
t.join()
if "error" in result:
raise result["error"]
return result.get("value")
def _extract_json_from_content(content: str) -> Any:
stripped = content.strip()
fence_match = re.search(r"```(?:json)?\s*(.*?)\s*```", stripped, re.DOTALL)
if fence_match:
stripped = fence_match.group(1).strip()
return json.loads(stripped)
def _chat_llm_single_attempt(llm: BackofficeLLMDescriptor, prompt: str) -> LLMResponse:
started = time.time()
runtime_settings = _SettingsProxy(fw_settings, llm)
provider = create_llm(runtime_settings)
messages = [{"role": "user", "content": prompt}]
content = _run_coro_sync(provider.ainvoke(messages)) or ""
latency_ms = round((time.time() - started) * 1000, 2)
# Token counts are collected inside the framework provider when available.
# This compatibility response keeps domain code stable even when the provider
# only returns text.
estimated_prompt = max(1, len(prompt) // 4)
estimated_completion = max(1, len(content) // 4)
return LLMResponse(
content=str(content),
model=str(llm.eligibleModel_name),
prompt_tokens=estimated_prompt,
completion_tokens=estimated_completion,
total_tokens=estimated_prompt + estimated_completion,
finish_reason="stop",
latency_ms=latency_ms,
)
def chat_llm_with_usage(
llm: BackofficeLLMDescriptor,
prompt: str,
expect_json: bool = False,
json_max_attempts: int = 3,
) -> LLMResponse:
if not expect_json:
return _chat_llm_single_attempt(llm, prompt)
current_prompt = prompt
last_bad_response: Optional[str] = None
last_parse_err: Optional[json.JSONDecodeError] = None
for attempt in range(json_max_attempts):
if attempt > 0 and last_bad_response is not None:
current_prompt = (
f"{prompt}\n\n"
f"[TENTATIVA ANTERIOR FALHOU — tentativa {attempt + 1}/{json_max_attempts}]\n"
f"A resposta anterior NÃO pôde ser parseada como JSON válido.\n"
f"Erro do parser: {last_parse_err}\n"
f"Resposta anterior (NÃO repita esse formato/erro):\n"
f"<<<\n{last_bad_response}\n>>>\n"
f"Responda APENAS com JSON válido, sem texto extra antes ou depois."
)
response = _chat_llm_single_attempt(llm, current_prompt)
try:
response.parsed_json = _extract_json_from_content(response.content)
return response
except json.JSONDecodeError as parse_err:
last_bad_response = response.content
last_parse_err = parse_err
if attempt == json_max_attempts - 1:
logger.error(
"LLM JSON parsing failed after %d attempts. Last error: %s | last_content=%.500r",
json_max_attempts,
parse_err,
response.content,
)
raise
logger.warning(
"LLM returned invalid JSON (attempt %d/%d): %s. Retrying with enriched prompt.",
attempt + 1,
json_max_attempts,
parse_err,
)
raise RuntimeError("chat_llm_with_usage: fim de loop inesperado")