mirror of
https://github.com/hoshikawa2/compass_backoffice.git
synced 2026-07-09 22:04:20 +00:00
first commit
This commit is contained in:
BIN
src/providers/__pycache__/langfuse_provider.cpython-313.pyc
Normal file
BIN
src/providers/__pycache__/langfuse_provider.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/providers/__pycache__/llm_provider.cpython-313.pyc
Normal file
BIN
src/providers/__pycache__/llm_provider.cpython-313.pyc
Normal file
Binary file not shown.
112
src/providers/langfuse_provider.py
Normal file
112
src/providers/langfuse_provider.py
Normal 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
|
||||
216
src/providers/llm_provider.py
Normal file
216
src/providers/llm_provider.py
Normal 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")
|
||||
Reference in New Issue
Block a user