itsulu-blog-publisher/ARCHITECTURE.md
Nicholas Riegel a391246ece Claude Sonnet 4.6
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.**
2026-05-29 01:33:30 -04:00

273 lines
10 KiB
Markdown

# itsulu_blog_publisher — Architecture & Module Plan
# Version: 0.1-RED (tests only, no implementation)
# Target: Odoo 14 Community, forward-compatible to v20
## Problem Being Solved
Claude CoWork running on a Windows VM generates 3 blog posts/day.
It is fragile, token-wasteful (no batching, no structured output),
and cannot be scheduled reliably. This addon moves the entire pipeline
server-side into Odoo, eliminating the VM dependency.
---
## Module: itsulu_blog_publisher
### Technical name
`itsulu_blog_publisher`
### Odoo Dependencies
- `website_blog` — blog.blog, blog.post, blog.tag models
- `website` — website publishing, URL computation
- `mail` — mail.mail for notification emails
- `base_setup` — res.config.settings extension
- `base` — ir.config_parameter (encrypted API key storage)
### Python Dependencies (to add to requirements.txt)
- `requests` (already in Odoo standard)
- `anthropic` (Anthropic SDK — or raw HTTP via requests)
- `openai` (OpenAI SDK — or raw HTTP)
- `google-generativeai` (Gemini — or raw HTTP)
- No extra SDK needed for Ollama — it exposes an OpenAI-compatible REST API
---
## File Tree
```
addons/itsulu_blog_publisher/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ ├── blog_topic.py # itsulu.blog.topic
│ ├── blog_schedule.py # itsulu.blog.schedule
│ ├── blog_generation_log.py # itsulu.blog.generation.log
│ ├── blog_post_social.py # itsulu.blog.post.social
│ └── res_config_settings.py # extends res.config.settings
├── services/
│ ├── __init__.py
│ ├── llm_router.py # LLMRouter, LLMResponse dataclass
│ ├── anthropic_provider.py # AnthropicProvider
│ ├── openai_provider.py # OpenAIProvider (also covers Ollama OpenAI-compat)
│ ├── gemini_provider.py # GeminiProvider
│ └── ollama_provider.py # OllamaProvider (native /api/chat)
├── wizards/
│ ├── __init__.py
│ └── generate_now_wizard.py # itsulu.blog.generate.wizard
├── views/
│ ├── blog_topic_views.xml
│ ├── blog_schedule_views.xml
│ ├── blog_generation_log_views.xml
│ ├── blog_post_social_views.xml
│ ├── generate_now_wizard_views.xml
│ ├── res_config_settings_views.xml
│ ├── menu_views.xml
│ └── website_blog_publisher_templates.xml # Website toolbar button + wizard
├── data/
│ ├── ir_cron_data.xml # Three scheduled actions (morning/afternoon/evening)
│ └── mail_template_data.xml # Notification email template
├── 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 # Also covers SEO + notification email
└── features/
├── blog_generation.feature
├── blog_scheduling.feature
├── llm_provider_selection.feature
├── seo_population.feature
└── notification_email.feature
```
---
## Data Models
### itsulu.blog.topic
| Field | Type | Notes |
|---|---|---|
| name | Char | Topic/title hint for LLM |
| priority | Selection | low / normal / high / urgent |
| state | Selection | pending / used / skipped |
| blog_id | Many2one | blog.blog — optional target blog |
| notes | Text | Additional context for LLM prompt |
| used_date | Datetime | When mark_used() was called |
### itsulu.blog.schedule
| Field | Type | Notes |
|---|---|---|
| name | Char | Human label (e.g. "Morning Post") |
| slot | Selection | morning / afternoon / evening |
| trigger_time | Float | Hours (8.0 = 08:00 UTC) |
| active | Boolean | If False, scheduler skips this slot |
| blog_id | Many2one | blog.blog — target blog for this slot |
| llm_provider | Selection | anthropic / openai / gemini / ollama |
| llm_model | Char | e.g. claude-sonnet-4-20250514 |
| auto_publish | Boolean | True = publish immediately |
| prompt_override | Text | Optional custom prompt (overrides default template) |
| notification_emails | Char | Comma-separated; defaults to Settings value |
### itsulu.blog.generation.log
| Field | Type | Notes |
|---|---|---|
| state | Selection | running / success / error |
| trigger_source | Selection | manual / scheduled |
| schedule_slot | Selection | morning / afternoon / evening / False |
| blog_post_id | Many2one | blog.post — None if error |
| llm_provider | Char | Provider used |
| llm_model | Char | Model used |
| tokens_used | Integer | 0 if error before LLM call |
| duration_seconds | Float | Wall-clock time |
| error_message | Text | Human-readable error |
| topic_used | Char | The topic string sent to LLM |
| topic_source | Selection | queue / llm / manual |
| create_date | Datetime | Auto (Odoo standard) |
### itsulu.blog.post.social
| Field | Type | Notes |
|---|---|---|
| blog_post_id | Many2one | blog.post — one-to-one |
| twitter_post_a | Text | ≤ 280 chars exc URL |
| twitter_post_b | Text | ≤ 280 chars exc URL |
| bluesky_post_a | Text | ≤ 300 chars |
| bluesky_post_b | Text | ≤ 300 chars |
| mastodon_post | Text | ≤ 500 chars |
| linkedin_post | Text | 150-3000 chars |
| sources_referenced | Text | Newline-separated URL list |
---
## LLM Router Design
```
LLMRouter(env, provider, model)
└── .generate(prompt, system_prompt=None) → LLMResponse
├── reads API key from ir.config_parameter
├── dispatches to AnthropicProvider / OpenAIProvider / GeminiProvider / OllamaProvider
└── returns LLMResponse(text, tokens_used, title, meta_title,
meta_description, meta_keywords, tags[], social)
LLMResponse is a dataclass or simple class, NOT an Odoo model.
```
The LLM is asked in a **single API call** to return a JSON object containing:
- `title` (plain text)
- `body_html` (full HTML body, min 500 chars)
- `meta_title` (≤60 chars)
- `meta_description` (≤155 chars)
- `meta_keywords` (comma-separated)
- `tags` (list of strings)
- `social.twitter_a`, `social.twitter_b` (≤280 chars each exc URL)
- `social.bluesky_a`, `social.bluesky_b` (≤300 chars each)
- `social.mastodon` (≤500 chars)
- `social.linkedin` (150-3000 chars)
- `sources` (list of {title, url} — optional, model-dependent)
**One call, structured JSON output** = lowest token usage, no back-and-forth.
---
## Generation Orchestration Flow
```
User clicks "Generate Now" (backend wizard or website button)
GenerateNowWizard.action_generate()
├─ 1. Resolve topic (from wizard input, or topic queue, or LLM-chosen)
├─ 2. Build prompt from template (substituting topic, blog name, date, tone)
├─ 3. Instantiate LLMRouter with selected provider + model
├─ 4. log = create generation log with state='running'
├─ 5. Call router.generate(prompt)
│ ├── SUCCESS →
│ │ ├─ 6a. Create blog.post with body_arch, SEO fields, tags
│ │ ├─ 6b. Create itsulu.blog.post.social record
│ │ ├─ 6c. If auto_publish: post.website_published = True
│ │ ├─ 6d. log.state = 'success', log.tokens_used = X
│ │ ├─ 6e. If published: log.send_notification_email()
│ │ └─ 6f. If topic from queue: topic.mark_used()
│ └── FAILURE →
│ ├─ 7a. log.state = 'error', log.error_message = str(exc)
│ └─ 7b. Return — no blog.post created
└─ 8. Return action to open log record (success) or error wizard
```
---
## Settings (res.config.settings additions)
All stored in ir.config_parameter. API keys use Odoo's standard
password widget (not shown in the form after save).
| Setting key | Description |
|---|---|
| itsulu_blog_publisher.anthropic_api_key | Anthropic API key |
| itsulu_blog_publisher.openai_api_key | OpenAI API key |
| itsulu_blog_publisher.gemini_api_key | Google Gemini API key |
| itsulu_blog_publisher.ollama_base_url | Ollama server URL (e.g. http://192.168.1.x:11434) |
| itsulu_blog_publisher.default_provider | Default LLM provider |
| itsulu_blog_publisher.default_model | Default model for the default provider |
| itsulu_blog_publisher.notification_emails | Comma-separated recipient list |
| itsulu_blog_publisher.default_blog_id | Default blog.blog to post to |
| itsulu_blog_publisher.system_prompt | Editable system prompt template |
| itsulu_blog_publisher.user_prompt_template | Editable user prompt with {topic}, {blog_name}, {date} |
| itsulu_blog_publisher.anthropic_image_model | e.g. (future: when image gen is supported) |
---
## Notification Email Format
Matches the uploaded [ITSulu Insights] .eml exactly:
Subject: [{{blog_name}}] Blog Post Published: {{title}} - {{date}}
Body sections (HTML):
1. Header: blog name, date, title, URL
2. Post Details: topic area, tags, publication status, blog name
3. Social Media Posts (per platform with coloured left-border):
- 🐦 X (Twitter) Post A + Post B
- 🌐 BlueSky Post A + Post B
- 🦣 Fediverse/Mastodon
- 💼 LinkedIn
4. News Sources Referenced (if LLM returned sources)
5. Footer: Generated date + "ITSulu Daily Blog Publisher"
---
## SLO Targets
| Metric | Target |
|---|---|
| Single generation (Anthropic Sonnet) | < 30 seconds |
| Token usage per post (structured JSON) | < 2,500 tokens |
| Notification email sent after publish | < 5 seconds |
| Scheduled cron slots per day | 3 (configurable) |
| Log retention | 90 days (configurable, then auto-delete via cron) |
---
## Open Questions (from initial analysis — awaiting your answers)
Q1: Third blog slot name ("itsulu blog afternoon" appears twice is the third "evening"?)
Q2: Image generation provider (separate from text provider?)
Q3: Ollama base URL for your installation
Q4: Email recipient(s) fixed list in Settings or follow triggering user?
Q5: Social media platforms all four always, or per-schedule configurable?
Q6: Sources section should LLM be prompted to include real URLs, or skip?
Q7: Prompt template editable in backend UI, or hardcoded default for now?
Q8: Topic input free-text each time, topic queue, or both?