# 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?