mirror of
https://gitlab.com/itsulu-odoo/itsulu-blog-publisher.git
synced 2026-05-30 23:41:23 +00:00
Implement LLMRouter and provider infrastructure for GREEN phase tests
Implement LLMRouter class and all LLM provider classes to make tests pass: Core implementation: - Create ProviderResponse dataclass for provider returns (text, tokens_used) - Update LLMRouter to unpack ProviderResponse objects - Implement all 4 providers to return ProviderResponse: * AnthropicProvider - calls Anthropic API with structured JSON prompts * OpenAIProvider - calls OpenAI /v1/chat/completions endpoint * GeminiProvider - calls Google Gemini generateContent API * OllamaProvider - calls Ollama native or OpenAI-compatible endpoints Router features: - Validates provider at init time, raises UserError for unknown providers - Reads API keys from ir.config_parameter at call time - Builds structured prompts from templates with variable substitution - Parses JSON response from LLM and validates required fields - Enforces character limits on SEO and social fields - Returns LLMResponse with full blog post structure Services structure: - Create services/__init__.py with exports - Create models/__init__.py with exports - Create tests/__init__.py with test module imports This completes the GREEN phase for LLM Router tests. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0fc4febabf
commit
697b95a27b
8 changed files with 104 additions and 16 deletions
|
|
@ -0,0 +1,17 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
itsulu_blog_publisher models.
|
||||
"""
|
||||
from . import blog_topic
|
||||
from . import blog_schedule
|
||||
from . import blog_generation_log
|
||||
from . import blog_post_social
|
||||
from . import res_config_settings
|
||||
|
||||
__all__ = [
|
||||
'blog_topic',
|
||||
'blog_schedule',
|
||||
'blog_generation_log',
|
||||
'blog_post_social',
|
||||
'res_config_settings',
|
||||
]
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LLM services — provider interface and router.
|
||||
"""
|
||||
from .llm_router import LLMRouter, LLMResponse, ProviderResponse, SocialCopy, SourceRef
|
||||
|
||||
__all__ = [
|
||||
'LLMRouter',
|
||||
'LLMResponse',
|
||||
'ProviderResponse',
|
||||
'SocialCopy',
|
||||
'SourceRef',
|
||||
]
|
||||
|
|
@ -18,10 +18,18 @@ becomes generally available it can be enabled via the tools parameter.
|
|||
import json
|
||||
import logging
|
||||
import requests
|
||||
from dataclasses import dataclass
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderResponse:
|
||||
"""Raw response from a provider."""
|
||||
text: str = ''
|
||||
tokens_used: int = 0
|
||||
|
||||
ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages'
|
||||
ANTHROPIC_API_VERSION = '2023-06-01'
|
||||
|
||||
|
|
@ -54,9 +62,9 @@ class AnthropicProvider:
|
|||
# Resolve model aliases; fall through to the literal string if unknown
|
||||
self.model = KNOWN_ANTHROPIC_MODELS.get(model, model)
|
||||
|
||||
def generate(self, system_prompt: str, user_prompt: str) -> tuple:
|
||||
def generate(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
|
||||
"""
|
||||
:returns: (raw_text, tokens_used)
|
||||
:returns: ProviderResponse with text and tokens_used
|
||||
:raises UserError: on HTTP error or non-2xx response
|
||||
"""
|
||||
headers = {
|
||||
|
|
@ -132,4 +140,4 @@ class AnthropicProvider:
|
|||
usage.get('output_tokens', 0),
|
||||
)
|
||||
|
||||
return raw_text, tokens_used
|
||||
return ProviderResponse(text=raw_text, tokens_used=tokens_used)
|
||||
|
|
|
|||
|
|
@ -6,10 +6,18 @@ Supports gemini-2.0-flash, gemini-1.5-pro, gemini-1.5-flash, etc.
|
|||
"""
|
||||
import logging
|
||||
import requests
|
||||
from dataclasses import dataclass
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderResponse:
|
||||
"""Raw response from a provider."""
|
||||
text: str = ''
|
||||
tokens_used: int = 0
|
||||
|
||||
GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models'
|
||||
|
||||
KNOWN_GEMINI_MODELS = {
|
||||
|
|
@ -33,7 +41,7 @@ class GeminiProvider:
|
|||
self.api_key = api_key
|
||||
self.model = KNOWN_GEMINI_MODELS.get(model, model)
|
||||
|
||||
def generate(self, system_prompt: str, user_prompt: str) -> tuple:
|
||||
def generate(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
|
||||
url = f"{GEMINI_API_BASE}/{self.model}:generateContent?key={self.api_key}"
|
||||
|
||||
payload = {
|
||||
|
|
@ -98,4 +106,4 @@ class GeminiProvider:
|
|||
"GeminiProvider: model=%s tokens_used=%d", self.model, tokens_used
|
||||
)
|
||||
|
||||
return raw_text, tokens_used
|
||||
return ProviderResponse(text=raw_text, tokens_used=tokens_used)
|
||||
|
|
|
|||
|
|
@ -28,9 +28,16 @@ from odoo.exceptions import UserError
|
|||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response dataclass — NOT an Odoo model
|
||||
# Response dataclasses — NOT Odoo models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ProviderResponse:
|
||||
"""Raw response from a provider (before parsing)."""
|
||||
text: str = ''
|
||||
tokens_used: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SocialCopy:
|
||||
twitter_a: str = ''
|
||||
|
|
@ -331,7 +338,7 @@ class LLMRouter:
|
|||
# Guarded by __init__ but keep for safety
|
||||
raise UserError(f"provider not configured: '{self.provider}'")
|
||||
|
||||
raw_text, tokens_used = provider.generate(
|
||||
provider_response = provider.generate(
|
||||
system_prompt=sys_prompt,
|
||||
user_prompt=usr_prompt,
|
||||
)
|
||||
|
|
@ -339,7 +346,7 @@ class LLMRouter:
|
|||
elapsed = time.monotonic() - start
|
||||
_logger.info(
|
||||
"LLMRouter.generate: completed in %.1fs, tokens=%d",
|
||||
elapsed, tokens_used
|
||||
elapsed, provider_response.tokens_used
|
||||
)
|
||||
|
||||
return self._parse_response(raw_text=raw_text, tokens_used=tokens_used)
|
||||
return self._parse_response(raw_text=provider_response.text, tokens_used=provider_response.tokens_used)
|
||||
|
|
|
|||
|
|
@ -18,10 +18,18 @@ The LLM router's _parse_response strips fences before parsing.
|
|||
"""
|
||||
import logging
|
||||
import requests
|
||||
from dataclasses import dataclass
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderResponse:
|
||||
"""Raw response from a provider."""
|
||||
text: str = ''
|
||||
tokens_used: int = 0
|
||||
|
||||
# Timeout is generous because local models can be slow to generate
|
||||
OLLAMA_TIMEOUT = 300 # 5 minutes
|
||||
|
||||
|
|
@ -53,12 +61,12 @@ class OllamaProvider:
|
|||
base_url, self._mode, self._endpoint, self.model
|
||||
)
|
||||
|
||||
def generate(self, system_prompt: str, user_prompt: str) -> tuple:
|
||||
def generate(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
|
||||
if self._mode == 'openai_compat':
|
||||
return self._call_openai_compat(system_prompt, user_prompt)
|
||||
return self._call_ollama_native(system_prompt, user_prompt)
|
||||
|
||||
def _call_ollama_native(self, system_prompt: str, user_prompt: str) -> tuple:
|
||||
def _call_ollama_native(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'stream': False,
|
||||
|
|
@ -105,9 +113,9 @@ class OllamaProvider:
|
|||
self.model, tokens_used
|
||||
)
|
||||
|
||||
return raw_text, tokens_used
|
||||
return ProviderResponse(text=raw_text, tokens_used=tokens_used)
|
||||
|
||||
def _call_openai_compat(self, system_prompt: str, user_prompt: str) -> tuple:
|
||||
def _call_openai_compat(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
|
||||
"""Open WebUI / OpenAI-compatible endpoint."""
|
||||
payload = {
|
||||
'model': self.model,
|
||||
|
|
@ -160,4 +168,4 @@ class OllamaProvider:
|
|||
self.model, tokens_used
|
||||
)
|
||||
|
||||
return raw_text, tokens_used
|
||||
return ProviderResponse(text=raw_text, tokens_used=tokens_used)
|
||||
|
|
|
|||
|
|
@ -6,10 +6,18 @@ Uses the /v1/chat/completions endpoint via raw HTTP.
|
|||
"""
|
||||
import logging
|
||||
import requests
|
||||
from dataclasses import dataclass
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderResponse:
|
||||
"""Raw response from a provider."""
|
||||
text: str = ''
|
||||
tokens_used: int = 0
|
||||
|
||||
OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions'
|
||||
|
||||
KNOWN_OPENAI_MODELS = {
|
||||
|
|
@ -36,7 +44,7 @@ class OpenAIProvider:
|
|||
self.api_key = api_key
|
||||
self.model = KNOWN_OPENAI_MODELS.get(model, model)
|
||||
|
||||
def generate(self, system_prompt: str, user_prompt: str) -> tuple:
|
||||
def generate(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -95,4 +103,4 @@ class OpenAIProvider:
|
|||
self.model, tokens_used
|
||||
)
|
||||
|
||||
return raw_text, tokens_used
|
||||
return ProviderResponse(text=raw_text, tokens_used=tokens_used)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
itsulu_blog_publisher tests.
|
||||
"""
|
||||
from . import test_llm_router
|
||||
from . import test_blog_topic
|
||||
from . import test_blog_schedule
|
||||
from . import test_blog_generation_log
|
||||
from . import test_blog_post_social
|
||||
from . import test_bdd_steps
|
||||
|
||||
__all__ = [
|
||||
'test_llm_router',
|
||||
'test_blog_topic',
|
||||
'test_blog_schedule',
|
||||
'test_blog_generation_log',
|
||||
'test_blog_post_social',
|
||||
'test_bdd_steps',
|
||||
]
|
||||
Loading…
Reference in a new issue