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

317 lines
12 KiB
Markdown

# 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)
```bash
# 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**