- Add comprehensive K8s test setup guide to CLAUDE.md (section 8) - Document K8s architecture, Docker image requirements, and job execution - Update ARCHITECTURE.md with CI/CD infrastructure details - Fix Dockerfile to use python3 -m pip and proper non-root user handling - Upgrade addon to Odoo 17.0 and update XML view syntax
12 KiB
itsulu_blog_publisher — Architecture & Module Plan
Version: 0.2-RED (K8s test infrastructure live; implementation in progress)
Target: Odoo 17.0 Community
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 modelswebsite— website publishing, URL computationmail— mail.mail for notification emailsbase_setup— res.config.settings extensionbase— 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):
- Header: blog name, date, title, URL
- Post Details: topic area, tags, publication status, blog name
- Social Media Posts (per platform with coloured left-border):
- 🐦 X (Twitter) Post A + Post B
- 🌐 BlueSky Post A + Post B
- 🦣 Fediverse/Mastodon
- News Sources Referenced (if LLM returned sources)
- 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) |
CI/CD & Deployment Infrastructure
Docker Image
- Registry:
registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:latest - Base:
odoo:17.0(non-rootodoouser) - Test tools: pytest, pytest-odoo, pytest-bdd, pytest-cov, pytest-html, requests
- Addon path:
/mnt/extra-addons/itsulu_blog_publisher/ - Build:
docker build --no-cache -t registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:latest .
Kubernetes Test Infrastructure
- Cluster: ITSulu production K8s (172.16.0.1:6443)
- Namespace:
itsulu-testing - Manifests:
kubernetes/itsulu-testing/in infrastructure repo - PostgreSQL 15 pod: persistent, always running, template DB =
odoo_template - Test jobs: ephemeral K8s Jobs, auto-delete after 1 hour
- Secrets:
test-db-info(DB credentials),gitlab-docker-creds(image pull) - ExternalSecrets operator: NOT installed — secrets created manually
Test Status (as of 2026-05-29)
- 64 test cases collected across 7 test files
- 23 failed, 41 errors — infrastructure works; test logic needs fixes
- Root cause: template DB has
odoo_templatebut addon models not yet installed into it - Next: prime template DB with
itsulu_blog_publisherand dependencies installed
Template DB Priming (TODO)
# Connect to test-db pod and prime the template
kubectl exec -it -n itsulu-testing deploy/test-db -- psql -U odoo_test -d postgres -c \
"CREATE DATABASE odoo_template;"
# Then run odoo init inside the pod or a separate init job:
odoo -d odoo_template \
-i base,website,website_blog,mail,itsulu_blog_publisher \
--without-demo=all --stop-after-init \
--db_host=test-db-svc --db_user=odoo_test --db_password=<password>
kubeconfig
- Location:
~/.kube/config - Retrieved from:
ssh nicholasr@172.16.0.80 "sudo cat /etc/kubernetes/admin.conf" - Context:
kubernetes-admin@kubernetes
Open Questions
Q1: Third blog slot name ("itsulu blog afternoon" appears twice — is the third "evening"?) — Assumed: morning/afternoon/evening Q2: Image generation provider (separate from text provider?) — Deferred to Phase 2 Q3: Ollama base URL for your installation — Configurable in Settings Q4: Email recipient(s) — Fixed list in Settings (comma-separated) Q5: Social media platforms — All four always for now Q6: Sources section — LLM prompted to include, optional by model Q7: Prompt template — Editable in backend Settings UI Q8: Topic input — Both: free-text + topic queue