diff --git a/addons/itsulu_blog_publisher/models/__init__.py b/addons/itsulu_blog_publisher/models/__init__.py index e69de29..9b83b3b 100644 --- a/addons/itsulu_blog_publisher/models/__init__.py +++ b/addons/itsulu_blog_publisher/models/__init__.py @@ -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', +] diff --git a/addons/itsulu_blog_publisher/services/__init__.py b/addons/itsulu_blog_publisher/services/__init__.py index e69de29..1dd2efa 100644 --- a/addons/itsulu_blog_publisher/services/__init__.py +++ b/addons/itsulu_blog_publisher/services/__init__.py @@ -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', +] diff --git a/addons/itsulu_blog_publisher/services/anthropic_provider.py b/addons/itsulu_blog_publisher/services/anthropic_provider.py index c97f27a..e67920f 100644 --- a/addons/itsulu_blog_publisher/services/anthropic_provider.py +++ b/addons/itsulu_blog_publisher/services/anthropic_provider.py @@ -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) diff --git a/addons/itsulu_blog_publisher/services/gemini_provider.py b/addons/itsulu_blog_publisher/services/gemini_provider.py index 89caaf0..fc2774d 100644 --- a/addons/itsulu_blog_publisher/services/gemini_provider.py +++ b/addons/itsulu_blog_publisher/services/gemini_provider.py @@ -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) diff --git a/addons/itsulu_blog_publisher/services/llm_router.py b/addons/itsulu_blog_publisher/services/llm_router.py index 2f1851e..ffc9abb 100644 --- a/addons/itsulu_blog_publisher/services/llm_router.py +++ b/addons/itsulu_blog_publisher/services/llm_router.py @@ -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) diff --git a/addons/itsulu_blog_publisher/services/ollama_provider.py b/addons/itsulu_blog_publisher/services/ollama_provider.py index 63a3725..43223ec 100644 --- a/addons/itsulu_blog_publisher/services/ollama_provider.py +++ b/addons/itsulu_blog_publisher/services/ollama_provider.py @@ -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) diff --git a/addons/itsulu_blog_publisher/services/openai_provider.py b/addons/itsulu_blog_publisher/services/openai_provider.py index 1f16763..32e4525 100644 --- a/addons/itsulu_blog_publisher/services/openai_provider.py +++ b/addons/itsulu_blog_publisher/services/openai_provider.py @@ -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) diff --git a/addons/itsulu_blog_publisher/tests/__init__.py b/addons/itsulu_blog_publisher/tests/__init__.py index e69de29..3036546 100644 --- a/addons/itsulu_blog_publisher/tests/__init__.py +++ b/addons/itsulu_blog_publisher/tests/__init__.py @@ -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', +]