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.**
110 lines
3.9 KiB
Python
110 lines
3.9 KiB
Python
# addons/itsulu_blog_publisher/tests/factories.py
|
|
"""
|
|
Test data factory for itsulu_blog_publisher.
|
|
Use this instead of demo data. Never rely on records seeded in another test.
|
|
"""
|
|
|
|
|
|
class BlogPublisherFactory:
|
|
def __init__(self, env):
|
|
self.env = env
|
|
self._counter = 0
|
|
|
|
def _seq(self):
|
|
self._counter += 1
|
|
return self._counter
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Core Odoo blog records #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def blog(self, **kw):
|
|
defaults = {
|
|
'name': f'Test Blog {self._seq()}',
|
|
}
|
|
defaults.update(kw)
|
|
return self.env['blog.blog'].create(defaults)
|
|
|
|
def blog_tag(self, **kw):
|
|
defaults = {
|
|
'name': f'Test Tag {self._seq()}',
|
|
}
|
|
defaults.update(kw)
|
|
return self.env['blog.tag'].create(defaults)
|
|
|
|
def blog_post(self, blog=None, **kw):
|
|
blog = blog or self.blog()
|
|
defaults = {
|
|
'name': f'Test Post {self._seq()}',
|
|
'blog_id': blog.id,
|
|
'website_published': False,
|
|
'is_published': False,
|
|
'body_arch': '<p>Test body content for blog post.</p>',
|
|
'website_meta_title': '',
|
|
'website_meta_description': '',
|
|
'website_meta_keywords': '',
|
|
}
|
|
defaults.update(kw)
|
|
return self.env['blog.post'].create(defaults)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# itsulu_blog_publisher models #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def blog_topic(self, **kw):
|
|
"""itsulu.blog.topic — a planned topic in the queue."""
|
|
defaults = {
|
|
'name': f'Test Topic {self._seq()}',
|
|
'priority': 'normal',
|
|
'state': 'pending',
|
|
'notes': '',
|
|
}
|
|
defaults.update(kw)
|
|
return self.env['itsulu.blog.topic'].create(defaults)
|
|
|
|
def blog_schedule(self, blog=None, **kw):
|
|
"""itsulu.blog.schedule — a cron slot configuration."""
|
|
blog = blog or self.blog()
|
|
defaults = {
|
|
'name': f'Test Schedule {self._seq()}',
|
|
'slot': 'morning',
|
|
'trigger_time': 8.0, # 08:00
|
|
'active': True,
|
|
'blog_id': blog.id,
|
|
'llm_provider': 'anthropic',
|
|
'llm_model': 'claude-sonnet-4-20250514',
|
|
'auto_publish': True,
|
|
}
|
|
defaults.update(kw)
|
|
return self.env['itsulu.blog.schedule'].create(defaults)
|
|
|
|
def generation_log(self, blog_post=None, **kw):
|
|
"""itsulu.blog.generation.log — one attempt record."""
|
|
defaults = {
|
|
'state': 'success',
|
|
'trigger_source': 'manual',
|
|
'llm_provider': 'anthropic',
|
|
'llm_model': 'claude-sonnet-4-20250514',
|
|
'tokens_used': 1500,
|
|
'duration_seconds': 12.0,
|
|
'error_message': '',
|
|
'blog_post_id': blog_post.id if blog_post else False,
|
|
}
|
|
defaults.update(kw)
|
|
return self.env['itsulu.blog.generation.log'].create(defaults)
|
|
|
|
def blog_post_social(self, blog_post=None, **kw):
|
|
"""itsulu.blog.post.social — social media copy for one post."""
|
|
blog_post = blog_post or self.blog_post()
|
|
defaults = {
|
|
'blog_post_id': blog_post.id,
|
|
'twitter_post_a': 'Twitter copy A placeholder.',
|
|
'twitter_post_b': 'Twitter copy B placeholder.',
|
|
'bluesky_post_a': 'BlueSky copy A placeholder.',
|
|
'bluesky_post_b': 'BlueSky copy B placeholder.',
|
|
'mastodon_post': 'Mastodon copy placeholder.',
|
|
'linkedin_post': 'LinkedIn copy placeholder.',
|
|
'sources_referenced': '',
|
|
}
|
|
defaults.update(kw)
|
|
return self.env['itsulu.blog.post.social'].create(defaults)
|