mirror of
https://gitlab.com/itsulu-odoo/itsulu-blog-publisher.git
synced 2026-05-30 23:41:23 +00:00
- 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
317 lines
12 KiB
Markdown
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**
|