itsulu-blog-publisher/ARCHITECTURE.md
Nicholas Riegel 34647c3742 docs: add Kubernetes test infrastructure documentation
- 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
2026-05-29 18:13:32 -04:00

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 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)

CI/CD & Deployment Infrastructure

Docker Image

  • Registry: registry.gitlab.com/itsulu-odoo/itsulu-blog-publisher:latest
  • Base: odoo:17.0 (non-root odoo user)
  • 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_template but addon models not yet installed into it
  • Next: prime template DB with itsulu_blog_publisher and 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