Prompt: We are currently using Claude CoWork to generate 3 blog posts per day "itsulu daily blog" "itsulu blog afternoon" "itsulu blog afternoon". Claude CoWork is also running inside a Windows VM on a laptop computer. CoWork encounters more errors and it seems to use more tokens than it should. None of this is ideal. The goal is to reduce compute and token usage. To do this, It is best if we have the ITSulu Odoo server perform nearly all of the actions necessary to post a new blog post on demand or on a schedule. We will be building an Odoo app that has the following features: * backend and Website button to push to, on demand create a blog post * UI and views in the backend and website to schedule the creation of blog posts * user options to automatically publish the post or leave it in draft form * optionally use API calls to Athropic Claude APIs so Claude can generate the blog text and images. Allow use of specific models (Opus, Sonnet, Haiku, Adaptive, Extended) * optionally use API calls to any LLM model, including other frontier models (Google Gemini, OpenAI ChatGPT), and also open models using ollama / Open WebUI (Ministral, Gemma, etc) * upload blog text and images from the AI LLM response to a blog post at ITSulu Odoo * make sure the SEO is entered fully and correctly * send a single email with suggested social media post, similar to the "[ITSulu Insights] Blog Post Published_ Prompt Governance & AI Scaling - May 26, 2026.eml" file uploaded * as features are added, create both back end UI/views and Website UI Make sure Gerkin style BDD stories and failing test code is included. Ask any questions for clarification. If there is a missing feature, add it. Response: Let me read the reference files and the uploaded email before planning.Good — I have everything I need. Here is my full analysis, the clarifying questions, the feature additions I'm proposing, and the complete BDD story set with failing test stubs. **Please read and approve (or correct) the tests before I write a single line of implementation.** --- **Current pain:** CoWork on a Windows VM drives the blog pipeline. It's fragile, token-wasteful, and has no scheduling. You want to move that entire pipeline server-side into an Odoo 14 addon (forward-compatible to v20). **Target state:** A new addon `itsulu_blog_publisher` that: 1. Lets a user trigger or schedule AI blog-post generation from the Odoo backend or website. 2. Calls any LLM (Anthropic, OpenAI, Gemini, Ollama) to generate title, body HTML, cover image, SEO fields, and social media copy. 3. Creates the `blog.post` record in Odoo, optionally publishes it, sets all SEO fields. 4. Sends one notification email matching the format in the uploaded `.eml`. --- **Q1 — Blog targets:** The email shows "ITSulu Insights" as the blog. The CoWork runs three jobs: "itsulu daily blog", "itsulu blog afternoon", "itsulu blog afternoon" (the third seems like a duplicate name — is the third actually a different blog, e.g. "itsulu blog evening"?). Should each schedule slot map to a specific Odoo Blog (`blog.blog` record), or should the user pick the blog each time? **Q2 — LLM image generation:** Anthropic Claude does not natively generate images (it uses text only). For images you'd need a separate provider: DALL·E 3 (OpenAI), Imagen (Google), or Stable Diffusion (Ollama). Should the image provider be selectable independently from the text provider, or do you want a simpler rule (e.g. "if using OpenAI for text, use DALL·E for image; otherwise skip image")? **Q3 — Ollama/Open WebUI endpoint:** Ollama runs locally or on a self-hosted server. What is the base URL of your Ollama instance (e.g. `http://192.168.x.x:11434`)? This goes in the addon's Settings page, not hardcoded. **Q4 — Email recipient(s):** The sample email goes from `nicholasr@itsulu.com` to `nicholasr@itsulu.com`. Should the notification email always go to a configured list of addresses (set in Settings), or should it follow the Odoo user who triggered the generation? **Q5 — Social media platforms:** The sample email has X/Twitter (A+B), BlueSky (A+B), Fediverse/Mastodon, and LinkedIn. Should all four always be generated, or should the user be able to enable/disable platforms per schedule or per generation run? **Q6 — "Sources referenced" section:** The current email lists external URLs (Forrester, Gartner, etc.). Does the LLM prompt currently include web search, or are those sources hallucinated/inserted by CoWork? Should the new addon ask the LLM to include cited sources, or skip that section? **Q7 — Prompt template management:** Do you want a backend UI to edit the system prompt / user prompt template that gets sent to the LLM (so non-developers can tune it), or is a hardcoded default in the addon config sufficient for now? **Q8 — Topic input:** How does the current CoWork decide what topic to write about? Is it a fixed prompt ("write a blog post about AI trends"), does it pull a topic from a queue, or does it use web search? Should the new addon support a topic queue (list of planned topics) or accept a free-text prompt each time? --- These are gaps I see that the current email and workflow imply. I'll add them unless you say no: **A — LLM Provider Settings page** (backend): API keys, model selections, Ollama URL, default provider — all stored encrypted in `ir.config_parameter`, never in YAML or code. **B — Prompt Template management**: Editable system prompt + user prompt template stored in the DB. Variables like `{topic}`, `{blog_name}`, `{date}`, `{tone}` are substituted at runtime. **C — Topic Queue**: A simple `itsulu.blog.topic` model — list of planned topics with priority. The scheduler picks the highest-priority unused topic. Users can add topics in the backend. **D — Generation Log / History**: Every generation attempt (success or failure) is logged with: provider used, tokens consumed (if API returns it), duration, error message if any. This gives you the usage visibility you need to replace CoWork. **E — Retry / Error handling**: If the LLM call fails, the record stays in `draft` state with an error message. A "Retry" button is shown. No silent failures. **F — Social media copy stored on the post**: The generated X/BlueSky/LinkedIn/Mastodon copy is stored on the `blog.post` (via a related model) and shown in the backend form, so you can edit it before or after sending the email. **G — Website "Generate Now" button**: Visible to website admins on `/blog` and on the blog post listing page — a single-click "Generate New Post" that opens a small wizard (topic, blog, provider, publish immediately?). **H — Scheduled Action (cron)**: Three cron slots configurable in Settings (morning, afternoon, evening). Each slot has: enabled toggle, time, target blog, LLM provider override, auto-publish toggle. --- ``` addons/itsulu_blog_publisher/ ├── __init__.py ├── __manifest__.py ├── models/ │ ├── __init__.py │ ├── blog_publisher_config.py # res.config.settings extension │ ├── blog_topic.py # itsulu.blog.topic │ ├── blog_generation_log.py # itsulu.blog.generation.log │ ├── blog_schedule.py # itsulu.blog.schedule (cron config) │ └── blog_post_social.py # itsulu.blog.post.social (social copy) ├── services/ │ ├── __init__.py │ ├── llm_router.py # picks provider, calls API │ ├── anthropic_provider.py │ ├── openai_provider.py │ ├── gemini_provider.py │ └── ollama_provider.py ├── wizards/ │ └── generate_now_wizard.py ├── views/ │ ├── blog_topic_views.xml │ ├── blog_generation_log_views.xml │ ├── blog_schedule_views.xml │ ├── blog_post_social_views.xml │ ├── res_config_settings_views.xml │ └── website_blog_publisher_templates.xml ├── data/ │ └── ir_cron_data.xml ├── security/ │ └── ir.model.access.csv ├── tests/ │ ├── __init__.py │ ├── factories.py │ ├── test_llm_router.py │ ├── test_blog_topic.py │ ├── test_blog_generation_log.py │ ├── test_blog_schedule.py │ └── test_blog_post_social.py └── features/ ├── blog_generation.feature ├── blog_scheduling.feature ├── llm_provider_selection.feature ├── seo_population.feature └── notification_email.feature ``` --- These are the stories. **Read these first.** Then I'll show you the pytest-bdd step definitions. --- ```gherkin Feature: On-demand AI blog post generation As an ITSulu content admin I want to trigger blog post generation with a single button click So that I no longer need CoWork running on a Windows VM Background: Given the Anthropic API key is configured in Settings And the blog "ITSulu Insights" exists in Odoo Scenario: Generate and auto-publish a blog post from the backend Given I am on the Blog Publisher backend form And I enter topic "Prompt Governance in Enterprise AI" And I select provider "anthropic" and model "claude-sonnet-4-20250514" And I set auto-publish to True When I click "Generate Now" Then a blog.post record is created with a non-empty title And the blog.post body_arch contains at least 500 characters of HTML And the blog.post is_published is True And the SEO fields website_meta_title and website_meta_description are populated And a generation log entry exists with state "success" Scenario: Generate a blog post and leave it as draft Given I am on the Blog Publisher backend form And I enter topic "Open Source LLMs for Business" And I set auto-publish to False When I click "Generate Now" Then a blog.post record is created And the blog.post is_published is False Scenario: LLM API call fails gracefully Given the Anthropic API key is invalid And I enter topic "Any topic" When I click "Generate Now" Then no blog.post record is created And a generation log entry exists with state "error" And the log contains a human-readable error message And a "Retry" button is visible on the log record Scenario: Generate blog post from the Website frontend button Given I am logged in as website admin on /blog When I click "Generate New Post" in the website toolbar And I fill the wizard with topic "AI in Manufacturing" and click Generate Then I am redirected to the new blog post URL And the post title is visible on the page ``` --- ```gherkin Feature: Scheduled blog post generation As an ITSulu content admin I want to schedule three blog posts per day automatically So that content is published without manual intervention Background: Given three schedule slots are configured: morning 08:00, afternoon 13:00, evening 18:00 And each slot has a target blog and LLM provider assigned Scenario: Morning schedule slot triggers generation at configured time Given the morning schedule slot is enabled And the current time matches the morning slot trigger time When the Odoo scheduled action runs Then a blog.post is created in the morning slot's target blog And the generation log shows the slot "morning" as the trigger source Scenario: Disabled schedule slot is skipped Given the afternoon schedule slot is disabled When the Odoo scheduled action runs at the afternoon trigger time Then no blog.post is created for the afternoon slot And the generation log has no entry for the afternoon slot at that time Scenario: Schedule slot picks next topic from the topic queue Given the topic queue contains ["AI Ethics", "Cloud Migration", "DevOps Trends"] And "AI Ethics" has the highest priority When the morning schedule slot triggers generation Then the generated blog.post title relates to "AI Ethics" And the "AI Ethics" topic is marked as used Scenario: Schedule slot falls back to AI-chosen topic when queue is empty Given the topic queue is empty When the morning schedule slot triggers generation Then a blog.post is still created And the generation log notes "topic chosen by LLM" ``` --- ```gherkin Feature: Multi-provider LLM routing As an ITSulu admin I want to choose which LLM provider generates each blog post So that I can control cost, quality, and availability Scenario: Anthropic provider generates blog content Given provider is "anthropic" and model is "claude-sonnet-4-20250514" When the LLM router is called with a prompt Then the router calls the Anthropic /v1/messages endpoint And returns a non-empty string response Scenario: OpenAI provider generates blog content Given provider is "openai" and model is "gpt-4o" When the LLM router is called with a prompt Then the router calls the OpenAI /v1/chat/completions endpoint Scenario: Gemini provider generates blog content Given provider is "gemini" and model is "gemini-2.0-flash" When the LLM router is called with a prompt Then the router calls the Google Gemini API endpoint Scenario: Ollama provider generates blog content using local model Given provider is "ollama" and model is "mistral" And the Ollama base URL is "http://localhost:11434" When the LLM router is called with a prompt Then the router calls http://localhost:11434/api/chat And the response is a non-empty string Scenario: Unknown provider raises configuration error Given provider is "unknown_provider" When the LLM router is called Then a UserError is raised with message containing "provider not configured" Scenario: Token usage is recorded in generation log Given provider is "anthropic" When generation completes successfully Then the generation log record contains tokens_used > 0 ``` --- ```gherkin Feature: SEO fields populated on every generated blog post As an ITSulu content admin I want all SEO fields filled automatically So that every post is search-engine ready at publication Scenario: All SEO fields are populated after generation Given a blog post has been generated successfully Then blog.post.website_meta_title is not empty And blog.post.website_meta_title has length <= 60 characters And blog.post.website_meta_description is not empty And blog.post.website_meta_description has length <= 155 characters And blog.post.website_meta_keywords is not empty And blog.post.tag_ids contains at least 2 tags Scenario: SEO title does not duplicate the post title verbatim Given a blog post has been generated with title "AI Trends 2026" Then website_meta_title is not exactly "AI Trends 2026" And website_meta_title contains the core topic keywords Scenario: Tags are created if they do not already exist Given the LLM returns tags ["Enterprise AI", "Prompt Engineering", "New Tag XYZ"] When the blog post is saved Then blog.tag records exist for all three tags And the new tag "New Tag XYZ" is created automatically ``` --- ```gherkin Feature: Post-generation notification email As an ITSulu content admin I want to receive one email after each successful blog post publication So that I have the social media copy ready to paste Background: Given the notification email recipient is "nicholasr@itsulu.com" Scenario: Notification email is sent after successful auto-publish Given a blog post was generated and auto-published When the generation completes Then exactly one email is sent to "nicholasr@itsulu.com" And the email subject matches "[ITSulu Insights] Blog Post Published: {title} - {date}" And the email body contains the blog post URL And the email body contains X/Twitter Post A and Post B And the email body contains BlueSky Post A and Post B And the email body contains a Fediverse/Mastodon post And the email body contains a LinkedIn post And the email body contains a "News Sources Referenced" section Scenario: Notification email is NOT sent for draft posts Given a blog post was generated with auto-publish False When the generation completes Then no email is sent Scenario: Notification email is sent to all configured recipients Given recipients are ["alice@itsulu.com", "bob@itsulu.com"] When a blog post is generated and published Then exactly one email is sent to each recipient Scenario: Email social copy uses platform-appropriate character limits Given a blog post was generated and published Then the X/Twitter posts are each <= 280 characters (excluding URL) And the LinkedIn post is >= 150 characters And the Mastodon post is <= 500 characters ``` --- These are the **RED** tests. They will all fail because the module does not exist yet. ---Now let me create the full test files as artifacts.Here is a summary of everything above — please read carefully before approving. --- **9 files, zero implementation.** Every test will fail with `ImportError` or `ModuleNotFoundError` until we build the module. That's exactly correct. --- | Feature | Scenarios | |---|---| | On-demand blog generation | Generate + publish; generate draft; API failure + retry | | Scheduled generation | Active slot runs; inactive slot skips; topic queue; empty queue fallback | | LLM provider routing | Anthropic, OpenAI, Gemini, Ollama, unknown provider, token logging | | SEO population | All fields set; title not verbatim copy; new tags auto-created | | Notification email | Format matches your .eml exactly; no email for drafts; character limits per platform | --- `TestLLMRouterProviderDispatch` — 5 tests: each provider dispatches correctly, unknown provider raises `UserError`, missing API key raises `UserError` `TestLLMRouterTokenLogging` — 1 test: `tokens_used` comes back from the router `TestBlogTopicQueueManagement` — 7 tests: priority ordering, empty queue returns False, mark_used, blog assignment `TestBlogGenerationLog*` — 6 + 2 tests: success/error metadata, retry action exists on error logs, no retry on success `TestBlogSchedule*` — 3 + 7 tests: slot config, run creates post + log, inactive slot is no-op, queue integration, auto-publish flag `TestSEOPopulation` + `TestBlogPostSocial` + `TestNotificationEmail` — 12 tests: all SEO fields, character limits, email format, draft suppresses email --- **Single LLM call, structured JSON output.** One API call returns everything — title, body HTML, SEO fields, tags, and all four social platform copies — as a JSON object. This is why token usage will drop dramatically vs. CoWork's multi-step approach. --- I've listed them in `ARCHITECTURE.md`. The most important blocking ones are: - **Q1** — Is the third daily slot "evening"? (affects model and cron data) - **Q2** — Image generation: separate provider, or skip for now? - **Q4** — Email recipients: fixed settings list, or per-triggering-user? - **Q8** — Topic input: queue + free-text both, or one of those? **Once you say "looks good" on the tests, I write `__manifest__.py`, the models, the services, the views, and the cron data — in that order, one behaviour at a time.**
362 lines
14 KiB
Python
362 lines
14 KiB
Python
# addons/itsulu_blog_publisher/tests/test_bdd_steps.py
|
|
"""
|
|
pytest-bdd step definitions for itsulu_blog_publisher feature files.
|
|
Links: blog_generation.feature, blog_scheduling.feature,
|
|
llm_provider_selection.feature, seo_population.feature,
|
|
notification_email.feature
|
|
|
|
RED PHASE — all scenarios FAIL until implementation exists.
|
|
"""
|
|
import pytest
|
|
from unittest.mock import patch, MagicMock
|
|
from pytest_bdd import scenarios, given, when, then, parsers
|
|
|
|
# Link all feature files
|
|
scenarios('../features/blog_generation.feature')
|
|
scenarios('../features/blog_scheduling.feature')
|
|
scenarios('../features/llm_provider_selection.feature')
|
|
scenarios('../features/seo_population.feature')
|
|
scenarios('../features/notification_email.feature')
|
|
|
|
|
|
# ================================================================= #
|
|
# Shared fixtures #
|
|
# ================================================================= #
|
|
|
|
@pytest.fixture
|
|
def ctx():
|
|
"""Mutable context bag shared between steps in one scenario."""
|
|
return {}
|
|
|
|
|
|
@pytest.fixture
|
|
def odoo_env(request):
|
|
"""Provide the Odoo environment from the TransactionCase if available,
|
|
or from the pytest-odoo plugin's odoo_env fixture."""
|
|
return request.getfixturevalue('odoo_env')
|
|
|
|
|
|
# ================================================================= #
|
|
# Feature: blog_generation — On-Demand Blog Generation #
|
|
# ================================================================= #
|
|
|
|
@given('the Anthropic API key is configured in Settings')
|
|
def given_anthropic_api_key_configured(odoo_env):
|
|
odoo_env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.anthropic_api_key', 'sk-ant-test-key'
|
|
)
|
|
|
|
|
|
@given(parsers.parse('the blog "{blog_name}" exists in Odoo'), target_fixture='blog')
|
|
def given_blog_exists(odoo_env, blog_name):
|
|
blog = odoo_env['blog.blog'].search([('name', '=', blog_name)], limit=1)
|
|
if not blog:
|
|
blog = odoo_env['blog.blog'].create({'name': blog_name})
|
|
return blog
|
|
|
|
|
|
@given('I am on the Blog Publisher backend form')
|
|
def given_on_backend_form(ctx):
|
|
# In a unit/integration context this is a no-op; Playwright covers UI navigation
|
|
ctx['location'] = 'backend_form'
|
|
|
|
|
|
@given(parsers.parse('I enter topic "{topic}"'))
|
|
def given_enter_topic(ctx, topic):
|
|
ctx['topic'] = topic
|
|
|
|
|
|
@given(parsers.parse('I select provider "{provider}" and model "{model}"'))
|
|
def given_select_provider_model(ctx, provider, model):
|
|
ctx['provider'] = provider
|
|
ctx['model'] = model
|
|
|
|
|
|
@given(parsers.parse('I set auto-publish to {auto_publish}'))
|
|
def given_set_auto_publish(ctx, auto_publish):
|
|
ctx['auto_publish'] = auto_publish.strip().lower() in ('true', 'yes', '1')
|
|
|
|
|
|
@given('the Anthropic API key is invalid')
|
|
def given_invalid_api_key(odoo_env):
|
|
odoo_env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.anthropic_api_key', 'INVALID'
|
|
)
|
|
|
|
|
|
def _make_mock_llm_response(topic='AI Trends'):
|
|
resp = MagicMock()
|
|
resp.text = f'<h1>{topic}</h1>' + '<p>' + ('Content. ' * 60) + '</p>'
|
|
resp.tokens_used = 850
|
|
resp.title = topic
|
|
resp.meta_title = f'{topic} — Enterprise Guide 2026'[:60]
|
|
resp.meta_description = f'A comprehensive guide to {topic} for enterprise leaders.'[:155]
|
|
resp.meta_keywords = 'AI, enterprise, trends'
|
|
resp.tags = ['Enterprise AI', 'AI Trends']
|
|
resp.social = MagicMock(
|
|
twitter_a=f'Read our post on {topic}: https://itsulu.com/blog/test',
|
|
twitter_b=f'More on {topic}: https://itsulu.com/blog/test',
|
|
bluesky_a=f'BlueSky: {topic} post A',
|
|
bluesky_b=f'BlueSky: {topic} post B',
|
|
mastodon=f'Mastodon post about {topic}.',
|
|
linkedin=f'LinkedIn post about {topic}. ' * 5,
|
|
)
|
|
return resp
|
|
|
|
|
|
@when('I click "Generate Now"')
|
|
def when_click_generate_now(odoo_env, ctx):
|
|
"""
|
|
Calls the wizard/action that orchestrates blog generation.
|
|
The actual model method is action_generate_now on itsulu.blog.schedule
|
|
or a wizard model. We mock the LLM call.
|
|
"""
|
|
from odoo.addons.itsulu_blog_publisher.wizards.generate_now_wizard import GenerateNowWizard
|
|
|
|
mock_resp = _make_mock_llm_response(ctx.get('topic', 'AI'))
|
|
with patch(
|
|
'odoo.addons.itsulu_blog_publisher.services.llm_router.LLMRouter.generate',
|
|
return_value=mock_resp,
|
|
):
|
|
wizard = odoo_env['itsulu.blog.generate.wizard'].create({
|
|
'topic': ctx.get('topic', ''),
|
|
'llm_provider': ctx.get('provider', 'anthropic'),
|
|
'llm_model': ctx.get('model', 'claude-sonnet-4-20250514'),
|
|
'auto_publish': ctx.get('auto_publish', True),
|
|
})
|
|
try:
|
|
result = wizard.action_generate()
|
|
ctx['result'] = result
|
|
ctx['error'] = None
|
|
except Exception as exc:
|
|
ctx['result'] = None
|
|
ctx['error'] = exc
|
|
|
|
|
|
@then('a blog.post record is created with a non-empty title')
|
|
def then_blog_post_created_with_title(odoo_env, ctx):
|
|
post = odoo_env['blog.post'].search([], order='id desc', limit=1)
|
|
assert post, "No blog.post record was created"
|
|
assert post.name, "blog.post.name must not be empty"
|
|
ctx['post'] = post
|
|
|
|
|
|
@then(parsers.parse('the blog.post body_arch contains at least {min_chars:d} characters of HTML'))
|
|
def then_body_arch_length(odoo_env, ctx, min_chars):
|
|
post = ctx.get('post') or odoo_env['blog.post'].search([], order='id desc', limit=1)
|
|
assert len(post.body_arch or '') >= min_chars, (
|
|
f"blog.post body_arch has {len(post.body_arch or '')} chars, need >= {min_chars}"
|
|
)
|
|
|
|
|
|
@then(parsers.parse('the blog.post is_published is {expected}'))
|
|
def then_blog_post_published_state(odoo_env, ctx, expected):
|
|
post = ctx.get('post') or odoo_env['blog.post'].search([], order='id desc', limit=1)
|
|
expected_bool = expected.strip().lower() in ('true', 'yes')
|
|
assert post.is_published == expected_bool, (
|
|
f"Expected is_published={expected_bool}, got {post.is_published}"
|
|
)
|
|
|
|
|
|
@then('the SEO fields website_meta_title and website_meta_description are populated')
|
|
def then_seo_fields_populated(odoo_env, ctx):
|
|
post = ctx.get('post') or odoo_env['blog.post'].search([], order='id desc', limit=1)
|
|
assert post.website_meta_title, "website_meta_title must not be empty"
|
|
assert post.website_meta_description, "website_meta_description must not be empty"
|
|
|
|
|
|
@then(parsers.parse('a generation log entry exists with state "{state}"'))
|
|
def then_generation_log_exists(odoo_env, state):
|
|
log = odoo_env['itsulu.blog.generation.log'].search(
|
|
[('state', '=', state)], order='id desc', limit=1
|
|
)
|
|
assert log, f"No generation log with state='{state}' found"
|
|
|
|
|
|
@then('no blog.post record is created')
|
|
def then_no_blog_post_created(odoo_env, ctx):
|
|
# If there was an error, result should be None
|
|
assert ctx.get('error') is not None or ctx.get('result') is None, (
|
|
"A blog.post record was created but an error was expected"
|
|
)
|
|
|
|
|
|
@then('the log contains a human-readable error message')
|
|
def then_log_has_error_message(odoo_env):
|
|
log = odoo_env['itsulu.blog.generation.log'].search(
|
|
[('state', '=', 'error')], order='id desc', limit=1
|
|
)
|
|
assert log.error_message, "Error log must have a non-empty error_message"
|
|
assert len(log.error_message) > 10, "Error message must be human-readable (>10 chars)"
|
|
|
|
|
|
@then('a "Retry" button is visible on the log record')
|
|
def then_retry_button_exists(odoo_env):
|
|
log = odoo_env['itsulu.blog.generation.log'].search(
|
|
[('state', '=', 'error')], order='id desc', limit=1
|
|
)
|
|
assert hasattr(log, 'action_retry'), "Log model must have action_retry() method"
|
|
|
|
|
|
# ================================================================= #
|
|
# Feature: llm_provider_selection #
|
|
# ================================================================= #
|
|
|
|
@given(parsers.parse('provider is "{provider}" and model is "{model}"'))
|
|
def given_provider_and_model(ctx, provider, model):
|
|
ctx['provider'] = provider
|
|
ctx['model'] = model
|
|
|
|
|
|
@given(parsers.parse('the Ollama base URL is "{url}"'))
|
|
def given_ollama_base_url(odoo_env, url):
|
|
odoo_env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.ollama_base_url', url
|
|
)
|
|
|
|
|
|
@when('the LLM router is called with a prompt')
|
|
def when_llm_router_called(odoo_env, ctx):
|
|
from odoo.addons.itsulu_blog_publisher.services.llm_router import LLMRouter
|
|
mock_resp = MagicMock(text='<p>Generated</p>', tokens_used=500)
|
|
|
|
provider_map = {
|
|
'anthropic': 'odoo.addons.itsulu_blog_publisher.services.anthropic_provider.AnthropicProvider.generate',
|
|
'openai': 'odoo.addons.itsulu_blog_publisher.services.openai_provider.OpenAIProvider.generate',
|
|
'gemini': 'odoo.addons.itsulu_blog_publisher.services.gemini_provider.GeminiProvider.generate',
|
|
'ollama': 'odoo.addons.itsulu_blog_publisher.services.ollama_provider.OllamaProvider.generate',
|
|
}
|
|
provider = ctx.get('provider', 'anthropic')
|
|
patch_target = provider_map.get(provider)
|
|
|
|
try:
|
|
if patch_target:
|
|
with patch(patch_target, return_value=mock_resp) as mock_gen:
|
|
router = LLMRouter(odoo_env, provider=provider, model=ctx.get('model', ''))
|
|
result = router.generate(prompt='Write a blog post')
|
|
ctx['result'] = result
|
|
ctx['mock_generate'] = mock_gen
|
|
else:
|
|
router = LLMRouter(odoo_env, provider=provider, model=ctx.get('model', ''))
|
|
ctx['result'] = router.generate(prompt='Write a blog post')
|
|
ctx['error'] = None
|
|
except Exception as exc:
|
|
ctx['result'] = None
|
|
ctx['error'] = exc
|
|
|
|
|
|
@then(parsers.parse('the router calls the Anthropic /v1/messages endpoint'))
|
|
def then_router_called_anthropic(ctx):
|
|
assert ctx.get('mock_generate') and ctx['mock_generate'].called
|
|
|
|
|
|
@then(parsers.parse('the router calls the OpenAI /v1/chat/completions endpoint'))
|
|
def then_router_called_openai(ctx):
|
|
assert ctx.get('mock_generate') and ctx['mock_generate'].called
|
|
|
|
|
|
@then(parsers.parse('the router calls the Google Gemini API endpoint'))
|
|
def then_router_called_gemini(ctx):
|
|
assert ctx.get('mock_generate') and ctx['mock_generate'].called
|
|
|
|
|
|
@then(parsers.parse('the router calls http://localhost:11434/api/chat'))
|
|
def then_router_called_ollama(ctx):
|
|
assert ctx.get('mock_generate') and ctx['mock_generate'].called
|
|
|
|
|
|
@then('returns a non-empty string response')
|
|
def then_non_empty_response(ctx):
|
|
result = ctx.get('result')
|
|
assert result and result.text, "LLM router must return a non-empty text response"
|
|
|
|
|
|
@then(parsers.parse('a UserError is raised with message containing "{msg_fragment}"'))
|
|
def then_user_error_raised(ctx, msg_fragment):
|
|
from odoo.exceptions import UserError
|
|
error = ctx.get('error')
|
|
assert error is not None, "Expected a UserError but no error was raised"
|
|
assert isinstance(error, UserError), f"Expected UserError, got {type(error)}"
|
|
assert msg_fragment.lower() in str(error).lower(), (
|
|
f"UserError message '{error}' does not contain '{msg_fragment}'"
|
|
)
|
|
|
|
|
|
@then(parsers.parse('the generation log record contains tokens_used > {min_tokens:d}'))
|
|
def then_tokens_used_recorded(odoo_env, min_tokens):
|
|
log = odoo_env['itsulu.blog.generation.log'].search(
|
|
[('state', '=', 'success')], order='id desc', limit=1
|
|
)
|
|
assert log, "No success log found"
|
|
assert log.tokens_used > min_tokens, (
|
|
f"tokens_used={log.tokens_used} must be > {min_tokens}"
|
|
)
|
|
|
|
|
|
# ================================================================= #
|
|
# Feature: notification_email #
|
|
# ================================================================= #
|
|
|
|
@given(parsers.parse('the notification email recipient is "{email}"'))
|
|
def given_notification_recipient(odoo_env, email):
|
|
odoo_env['ir.config_parameter'].sudo().set_param(
|
|
'itsulu_blog_publisher.notification_emails', email
|
|
)
|
|
|
|
|
|
@given('a blog post was generated and auto-published')
|
|
def given_published_blog_post(odoo_env, ctx):
|
|
blog = odoo_env['blog.blog'].search([('name', '=', 'ITSulu Insights')], limit=1)
|
|
if not blog:
|
|
blog = odoo_env['blog.blog'].create({'name': 'ITSulu Insights'})
|
|
post = odoo_env['blog.post'].create({
|
|
'name': 'Prompt Governance & AI Scaling',
|
|
'blog_id': blog.id,
|
|
'is_published': True,
|
|
'body_arch': '<p>Content</p>',
|
|
'website_meta_title': 'AI Governance 2026',
|
|
'website_meta_description': 'Learn AI governance frameworks.',
|
|
})
|
|
odoo_env['itsulu.blog.post.social'].create({
|
|
'blog_post_id': post.id,
|
|
'twitter_post_a': 'Twitter A',
|
|
'twitter_post_b': 'Twitter B',
|
|
'bluesky_post_a': 'BlueSky A',
|
|
'bluesky_post_b': 'BlueSky B',
|
|
'mastodon_post': 'Mastodon post',
|
|
'linkedin_post': 'LinkedIn post about AI governance frameworks in enterprise.',
|
|
})
|
|
log = odoo_env['itsulu.blog.generation.log'].create({
|
|
'blog_post_id': post.id,
|
|
'state': 'success',
|
|
'trigger_source': 'manual',
|
|
'llm_provider': 'anthropic',
|
|
'llm_model': 'claude-sonnet-4-20250514',
|
|
'tokens_used': 900,
|
|
'duration_seconds': 10.5,
|
|
})
|
|
ctx['post'] = post
|
|
ctx['log'] = log
|
|
|
|
|
|
@when('the generation completes')
|
|
def when_generation_completes(odoo_env, ctx):
|
|
log = ctx.get('log')
|
|
if log:
|
|
log.send_notification_email()
|
|
|
|
|
|
@then(parsers.parse('exactly one email is sent to "{email}"'))
|
|
def then_exactly_one_email_sent(odoo_env, email):
|
|
mails = odoo_env['mail.mail'].search([('email_to', 'ilike', email)], order='id desc')
|
|
assert len(mails) == 1, f"Expected 1 email to {email}, found {len(mails)}"
|
|
|
|
|
|
@then(parsers.parse('the email subject matches "{subject_pattern}"'))
|
|
def then_email_subject_matches(odoo_env, subject_pattern):
|
|
mail = odoo_env['mail.mail'].search([], order='id desc', limit=1)
|
|
# Replace template vars with loose matching
|
|
core = subject_pattern.split('{')[0].strip()
|
|
assert core in (mail.subject or ''), (
|
|
f"Subject '{mail.subject}' does not start with '{core}'"
|
|
)
|