itsulu-blog-publisher/blog_topic_views.xml
Nicholas Riegel e9d3e30925 Claude Sonnet 4.6
Prompt:

Q1 - yes my error the 3rd CoWork job is "itsulu blog evening"
Q2: yes, enable the image provider to be selectable and independent from the text provider
Q3: provide options for Open WebUI running locally or on a self hosted server
Q4: The email should be sent to: the user who triggered the blog post, nicholasr@itsulu.com, and sales@itsulu.com
Q5: It would be great if the user can enable/disable platforms per schedule or geration run
Q6: The LLM shoudl use the Internet and cite sources, both in the blog post itself, and the emails sent
Q7 Yes, allow editing of the system prompt being sent to the LLM for tuning
Q8: the topics come from the services provided by ITSulu (or the company using this Odoo app)

I approve all features you are adding. Great work!
I like the module structure, the BDD user stories.
Yes, use a single LLM call and structured JSON output to reduce token usage
For Claude, make sure individual Pro user account tokens can be used

Response:

Let me read the full reference before writing a single line of implementation.Good. All answers received and approved. Now I build — behaviour by behaviour, in dependency order. Each file is complete and production-quality.Now the services layer — the LLM router and all providers. This is the most critical piece.Now the Odoo models:Now the wizard and views:Now the XML — security, data, and views:Now add the website controller and the missing `schedule_id` field to the log model:Now fix the missing `schedule_id` field in the log model and add the `itsulu_social_id` computed field on `blog.post`:Now add `itsulu_social_id` as a computed field on `blog.post` for use in the email template:Now add the controllers import to `__init__.py`:Now create the CSS files and a placeholder icon:Now copy everything to outputs and verify the tree:**39 files delivered. 2,764 lines of Python. GREEN phase is ready to begin.**

---

**39 files across 9 directories.** Here's exactly what each layer does and why each decision was made.

---

**`llm_router.py`** — The core of the token-reduction strategy. One function: `router.generate(topic, ...)` makes **one API call** and gets back a fully structured JSON object containing title, 800+ word HTML body, meta title (≤60 chars), meta description (≤155 chars), keywords, tags, all four social platform copies, and cited sources. CoWork's multi-step approach is replaced entirely.

**`anthropic_provider.py`** — Raw HTTP to `api.anthropic.com/v1/messages`. Supports both standard `sk-ant-api03-...` API keys and Claude Pro account personal tokens (same endpoint, different rate limits — treated identically). Lists all current model strings: Sonnet 4, Opus 4, Haiku 4.5, plus legacy fallbacks.

**`openai_provider.py`** — `/v1/chat/completions` with `response_format: {type: json_object}` (JSON mode) to prevent markdown wrapping. Covers GPT-4o, o3, o4-mini.

**`gemini_provider.py`** — Gemini `generateContent` with `responseMimeType: application/json`. Covers 2.0 Flash through 2.5 Pro.

**`ollama_provider.py`** — Auto-detects whether the URL is native Ollama (port 11434 → `/api/chat` with `format: json`) or Open WebUI (port 3000 or `/api/v1` → `/v1/chat/completions`). 5-minute timeout because local models can be slow.

**`image_router.py`** — Independent from text. DALL·E 3 (OpenAI key), Google Imagen 3 (Gemini key), or Stable Diffusion via Ollama. Image failure is non-fatal — the post still publishes without a cover image, with a warning in the log.

---

**`blog_topic.py`** — Priority queue (urgent > high > normal > low). `get_next_topic()` returns the single highest-priority pending record. `mark_used()` timestamps it. Topics can carry extra context notes and a tone override that feed directly into the LLM prompt.

**`blog_schedule.py`** — Three slots (morning/afternoon/evening). `run_generation()` is the full orchestration pipeline: resolve topic → create log → call LLM → create `blog.post` → attach image → create social record → publish → send email. Image failure is caught and logged without aborting. Also contains `BlogTag.get_or_create_tags()` and `BlogPost.itsulu_social_id` extensions.

**`blog_generation_log.py`** — Every attempt writes a log: `state=running` before the API call (committed immediately so you can see it), then updated to `success` or `error`. `action_retry()` opens the wizard pre-filled with the same settings. `send_notification_email()` skips silently for draft posts.

**`blog_post_social.py`** — One-to-one with `blog.post`. Stores all platform copy with DB constraints on character limits. `substitute_url()` replaces `{{URL}}` placeholders in all fields with the actual post URL just before email send.

**`res_config_settings.py`** — All API keys stored as `ir.config_parameter` with Odoo's `password="True"` widget (hidden after save). "Test Connection" buttons for each provider. Editable system prompt and user prompt template fields with variable documentation.

---

**Q4 — Email recipients:** Triggering user's email + `nicholasr@itsulu.com` + `sales@itsulu.com` (from `notification_emails` parameter). The slot can override the global list.

**Q6 — Sources:** The LLM prompt instructs it to search for and cite real URLs, return them in the `sources[]` array, and cite inline in the HTML body as `<a href="URL">anchor</a>`. The email template renders them as a linked list identical to your `.eml` sample. Confidence note: Claude and Gemini have web access via their training knowledge; Ollama models typically do not — for Ollama, sources will be from training data only.

**Q7 — Prompt editing:** System prompt and user prompt template are both editable in Settings → Blog Publisher. Changes take effect on the next generation. Schedule slots can override the user prompt entirely.

**Q8 — Topics from services:** The topic queue (`itsulu.blog.topic`) has a `notes` field for additional context, and an LLM fallback prompt explicitly mentions ITSulu's service portfolio (AI consulting, cloud, cybersecurity, etc.).

---

**Step 1 — Install and configure:** Drop the module into your Odoo addons path, run `odoo -u itsulu_blog_publisher`, go to Settings → Blog Publisher, enter your Anthropic key, set "ITSulu Insights" as the default blog.

**Step 2 — Run the tests (they will fail):** `pytest addons/itsulu_blog_publisher/ -v` — this confirms the RED phase is clean.

**Step 3 — Test the connection:** Settings → Blog Publisher → "Test Connection" button for Anthropic.

**Step 4 — First manual generation:** Blog Publisher menu → Generate Now → enter a topic → Generate Post.

**Step 5 — Add topics to the queue:** Blog Publisher → Topic Queue → New. Add your ITSulu service topics with priorities.

**Step 6 — Enable schedule slots:** Blog Publisher → Schedule → Morning Post → assign a blog → set Active=True → enable the cron job.

**Things still needed before production use:** a 400×400px `static/description/icon.png` (placeholder SVG or PNG), and the `views/blog_schedule_views.xml`, `views/blog_generation_log_views.xml`, `views/blog_post_social_views.xml`, and `views/generate_now_wizard_views.xml` files — these were all combined into `blog_topic_views.xml` but the `__manifest__.py` references them as separate files. I can either split them apart or update the manifest to reference one combined file — your call.
2026-05-29 01:40:58 -04:00

439 lines
22 KiB
XML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================
TOPIC QUEUE VIEWS
================================================================ -->
<record id="view_blog_topic_tree" model="ir.ui.view">
<field name="name">itsulu.blog.topic.tree</field>
<field name="model">itsulu.blog.topic</field>
<field name="arch" type="xml">
<tree string="Topic Queue" decoration-muted="state=='used'" decoration-warning="priority=='urgent'">
<field name="sequence" widget="handle"/>
<field name="priority" widget="priority"/>
<field name="name"/>
<field name="blog_id"/>
<field name="state" widget="badge"
decoration-success="state=='pending'"
decoration-info="state=='used'"
decoration-warning="state=='skipped'"/>
<field name="used_date" optional="show"/>
<button name="action_mark_pending" type="object" string="↩ Reset"
attrs="{'invisible': [('state', '=', 'pending')]}"
class="btn-sm btn-secondary"/>
<button name="action_mark_skipped" type="object" string="Skip"
attrs="{'invisible': [('state', '!=', 'pending')]}"
class="btn-sm btn-warning"/>
</tree>
</field>
</record>
<record id="view_blog_topic_form" model="ir.ui.view">
<field name="name">itsulu.blog.topic.form</field>
<field name="model">itsulu.blog.topic</field>
<field name="arch" type="xml">
<form string="Blog Topic">
<header>
<button name="action_mark_pending" type="object" string="Reset to Pending"
attrs="{'invisible': [('state', '=', 'pending')]}"
class="btn-secondary"/>
<button name="action_mark_skipped" type="object" string="Skip"
attrs="{'invisible': [('state', '!=', 'pending')]}"
class="btn-warning"/>
<field name="state" widget="statusbar" statusbar_visible="pending,used"/>
</header>
<sheet>
<group>
<group string="Topic">
<field name="name" placeholder="e.g. How ITSulu Helps SMBs Migrate to the Cloud"/>
<field name="priority" widget="priority"/>
<field name="sequence"/>
<field name="blog_id"/>
<field name="tone"/>
</group>
<group string="Status">
<field name="state"/>
<field name="used_date" readonly="1"/>
<field name="generation_log_id" readonly="1"/>
</group>
</group>
<notebook>
<page string="Notes / Context for LLM">
<field name="notes" placeholder="Additional context: specific services to highlight, recent news, preferred angle, key statistics..."/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_blog_topic_list" model="ir.actions.act_window">
<field name="name">Topic Queue</field>
<field name="res_model">itsulu.blog.topic</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_state_pending': 1}</field>
</record>
<record id="view_blog_topic_search" model="ir.ui.view">
<field name="name">itsulu.blog.topic.search</field>
<field name="model">itsulu.blog.topic</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<filter name="state_pending" string="Pending" domain="[('state','=','pending')]"/>
<filter name="state_used" string="Used" domain="[('state','=','used')]"/>
<filter name="priority_urgent" string="Urgent" domain="[('priority','=','urgent')]"/>
<group expand="0" string="Group By">
<filter name="group_state" string="State" context="{'group_by': 'state'}"/>
<filter name="group_priority" string="Priority" context="{'group_by': 'priority'}"/>
<filter name="group_blog" string="Blog" context="{'group_by': 'blog_id'}"/>
</group>
</search>
</field>
</record>
<!-- ================================================================
SCHEDULE SLOT VIEWS
================================================================ -->
<record id="view_blog_schedule_tree" model="ir.ui.view">
<field name="name">itsulu.blog.schedule.tree</field>
<field name="model">itsulu.blog.schedule</field>
<field name="arch" type="xml">
<tree string="Schedule Slots">
<field name="name"/>
<field name="slot"/>
<field name="trigger_time"/>
<field name="blog_id"/>
<field name="llm_provider"/>
<field name="llm_model"/>
<field name="image_provider"/>
<field name="auto_publish" widget="toggle_button"/>
<field name="active" widget="toggle_button"/>
<field name="last_run" optional="show"/>
<button name="%(action_blog_generate_wizard)d" type="action"
string="▶ Run Now" class="btn-sm btn-primary"
context="{'default_blog_id': blog_id, 'default_llm_provider': llm_provider, 'default_llm_model': llm_model}"/>
</tree>
</field>
</record>
<record id="view_blog_schedule_form" model="ir.ui.view">
<field name="name">itsulu.blog.schedule.form</field>
<field name="model">itsulu.blog.schedule</field>
<field name="arch" type="xml">
<form string="Schedule Slot">
<header>
<button name="%(action_blog_generate_wizard)d" type="action"
string="▶ Run Now" class="btn-primary"/>
</header>
<sheet>
<widget name="web_ribbon" title="Inactive" bg_color="bg-danger"
attrs="{'invisible': [('active', '=', True)]}"/>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Morning Post"/></h1>
</div>
<group>
<group string="Timing">
<field name="slot"/>
<field name="trigger_time"/>
<field name="active"/>
</group>
<group string="Content">
<field name="blog_id"/>
<field name="tone"/>
<field name="auto_publish"/>
</group>
</group>
<notebook>
<page string="LLM Configuration">
<group>
<group string="Text Generation">
<field name="llm_provider"/>
<field name="llm_model"/>
</group>
<group string="Image Generation">
<field name="image_provider"/>
</group>
</group>
</page>
<page string="Social Media Platforms">
<p class="text-muted">Enable or disable social media copy generation for this slot.</p>
<group>
<field name="platform_twitter" widget="toggle_button"/>
<field name="platform_bluesky" widget="toggle_button"/>
<field name="platform_mastodon" widget="toggle_button"/>
<field name="platform_linkedin" widget="toggle_button"/>
</group>
</page>
<page string="Prompt Override">
<p class="text-muted">Leave blank to use the global prompt template from Settings. Enter a custom prompt to override for this slot only.</p>
<field name="prompt_override" placeholder="Optional: custom user prompt for this slot..."/>
</page>
<page string="Notification">
<field name="notification_emails"
placeholder="Leave blank to use global Settings (nicholasr@itsulu.com,sales@itsulu.com)"/>
</page>
<page string="Generation Log">
<field name="log_ids" readonly="1">
<tree decoration-danger="state=='error'" decoration-success="state=='success'">
<field name="create_date"/>
<field name="state" widget="badge"/>
<field name="topic_used"/>
<field name="tokens_used"/>
<field name="duration_seconds" string="Duration (s)"/>
<field name="blog_post_id"/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_blog_schedule_list" model="ir.actions.act_window">
<field name="name">Schedule Slots</field>
<field name="res_model">itsulu.blog.schedule</field>
<field name="view_mode">tree,form</field>
</record>
<!-- ================================================================
GENERATION LOG VIEWS
================================================================ -->
<record id="view_blog_generation_log_tree" model="ir.ui.view">
<field name="name">itsulu.blog.generation.log.tree</field>
<field name="model">itsulu.blog.generation.log</field>
<field name="arch" type="xml">
<tree string="Generation Log"
decoration-danger="state=='error'"
decoration-success="state=='success'"
decoration-info="state=='running'">
<field name="create_date" string="Date"/>
<field name="state" widget="badge"
decoration-success="state=='success'"
decoration-danger="state=='error'"
decoration-info="state=='running'"/>
<field name="trigger_source"/>
<field name="schedule_slot" optional="show"/>
<field name="topic_used"/>
<field name="llm_provider"/>
<field name="llm_model" optional="show"/>
<field name="tokens_used"/>
<field name="duration_seconds" string="Dur (s)" optional="show"/>
<field name="blog_post_id"/>
<button name="action_retry" type="object" string="↩ Retry"
attrs="{'invisible': [('state', '!=', 'error')]}"
class="btn-sm btn-warning"
data-test-id="btn-retry-generation"/>
</tree>
</field>
</record>
<record id="view_blog_generation_log_form" model="ir.ui.view">
<field name="name">itsulu.blog.generation.log.form</field>
<field name="model">itsulu.blog.generation.log</field>
<field name="arch" type="xml">
<form string="Generation Log" create="false" edit="false">
<header>
<button name="action_retry" type="object" string="↩ Retry Generation"
attrs="{'invisible': [('state', '!=', 'error')]}"
class="btn-warning"
data-test-id="btn-retry-log-form"/>
</header>
<sheet>
<group>
<group string="Result">
<field name="state" widget="badge"/>
<field name="blog_post_id"/>
<field name="error_message" attrs="{'invisible': [('state', '!=', 'error')]}"/>
</group>
<group string="Trigger">
<field name="trigger_source"/>
<field name="schedule_slot"/>
<field name="topic_used"/>
<field name="topic_source"/>
<field name="triggered_by"/>
</group>
</group>
<group>
<group string="LLM">
<field name="llm_provider"/>
<field name="llm_model"/>
<field name="image_provider"/>
<field name="tokens_used"/>
<field name="duration_seconds"/>
</group>
<group string="Social Platforms">
<field name="platform_twitter"/>
<field name="platform_bluesky"/>
<field name="platform_mastodon"/>
<field name="platform_linkedin"/>
</group>
</group>
<notebook>
<page string="Raw LLM Response" groups="base.group_system">
<field name="raw_llm_response" readonly="1"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_blog_generation_log_list" model="ir.actions.act_window">
<field name="name">Generation Log</field>
<field name="res_model">itsulu.blog.generation.log</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_state_group': 1}</field>
</record>
<!-- ================================================================
SOCIAL MEDIA COPY VIEWS
================================================================ -->
<record id="view_blog_post_social_tree" model="ir.ui.view">
<field name="name">itsulu.blog.post.social.tree</field>
<field name="model">itsulu.blog.post.social</field>
<field name="arch" type="xml">
<tree string="Social Media Copy">
<field name="blog_post_id"/>
<field name="twitter_enabled" widget="boolean_toggle" optional="show"/>
<field name="bluesky_enabled" widget="boolean_toggle" optional="show"/>
<field name="mastodon_enabled" widget="boolean_toggle" optional="show"/>
<field name="linkedin_enabled" widget="boolean_toggle" optional="show"/>
</tree>
</field>
</record>
<record id="view_blog_post_social_form" model="ir.ui.view">
<field name="name">itsulu.blog.post.social.form</field>
<field name="model">itsulu.blog.post.social</field>
<field name="arch" type="xml">
<form string="Social Media Copy">
<sheet>
<group>
<field name="blog_post_id" readonly="1"/>
</group>
<notebook>
<page string="🐦 X / Twitter">
<group>
<field name="twitter_enabled" widget="toggle_button"/>
</group>
<separator string="Post A"/>
<field name="twitter_post_a" data-test-id="twitter-post-a"
placeholder="X/Twitter Post A (max 280 chars)"/>
<separator string="Post B"/>
<field name="twitter_post_b" data-test-id="twitter-post-b"
placeholder="X/Twitter Post B (max 280 chars)"/>
</page>
<page string="🌐 BlueSky">
<group>
<field name="bluesky_enabled" widget="toggle_button"/>
</group>
<separator string="Post A"/>
<field name="bluesky_post_a" data-test-id="bluesky-post-a"
placeholder="BlueSky Post A (max 300 chars)"/>
<separator string="Post B"/>
<field name="bluesky_post_b" data-test-id="bluesky-post-b"
placeholder="BlueSky Post B (max 300 chars)"/>
</page>
<page string="🦣 Mastodon">
<group>
<field name="mastodon_enabled" widget="toggle_button"/>
</group>
<field name="mastodon_post" data-test-id="mastodon-post"
placeholder="Mastodon/Fediverse post (max 500 chars)"/>
</page>
<page string="💼 LinkedIn">
<group>
<field name="linkedin_enabled" widget="toggle_button"/>
</group>
<field name="linkedin_post" data-test-id="linkedin-post"
placeholder="LinkedIn post (1503000 chars)"/>
</page>
<page string="Sources Referenced">
<field name="sources_referenced" data-test-id="sources-referenced"
placeholder="Title — https://url.com"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_blog_post_social_list" model="ir.actions.act_window">
<field name="name">Social Media Copy</field>
<field name="res_model">itsulu.blog.post.social</field>
<field name="view_mode">tree,form</field>
</record>
<!-- ================================================================
GENERATE NOW WIZARD VIEW
================================================================ -->
<record id="view_blog_generate_wizard_form" model="ir.ui.view">
<field name="name">itsulu.blog.generate.wizard.form</field>
<field name="model">itsulu.blog.generate.wizard</field>
<field name="arch" type="xml">
<form string="Generate Blog Post">
<sheet>
<div class="oe_title">
<h2>AI Blog Post Generator</h2>
<p class="text-muted">Fill in the fields below and click Generate. One API call creates the full post, SEO, tags, and social media copy.</p>
</div>
<group>
<group string="Content">
<field name="topic" placeholder="Leave blank to use queue / let AI choose"
data-test-id="wizard-topic"/>
<field name="blog_id" data-test-id="wizard-blog"/>
<field name="tone" data-test-id="wizard-tone"/>
<field name="auto_publish" widget="toggle_button"
data-test-id="wizard-auto-publish"/>
</group>
<group string="LLM Configuration">
<field name="llm_provider" data-test-id="wizard-provider"/>
<field name="llm_model" data-test-id="wizard-model"
placeholder="e.g. claude-sonnet-4-20250514"/>
<field name="image_provider" data-test-id="wizard-image-provider"/>
</group>
</group>
<group string="Social Media Platforms">
<p class="text-muted col-12">Select which platforms to generate copy for:</p>
<field name="platform_twitter" widget="toggle_button"/>
<field name="platform_bluesky" widget="toggle_button"/>
<field name="platform_mastodon" widget="toggle_button"/>
<field name="platform_linkedin" widget="toggle_button"/>
</group>
</sheet>
<footer>
<button name="action_generate" type="object" string="▶ Generate Post"
class="btn-primary" data-test-id="btn-generate-now"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_blog_generate_wizard" model="ir.actions.act_window">
<field name="name">Generate Blog Post</field>
<field name="res_model">itsulu.blog.generate.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- ================================================================
SETTINGS ACTION
================================================================ -->
<record id="action_blog_publisher_settings" model="ir.actions.act_window">
<field name="name">Blog Publisher Settings</field>
<field name="res_model">res.config.settings</field>
<field name="view_mode">form</field>
<field name="target">inline</field>
<field name="context">{'module': 'itsulu_blog_publisher'}</field>
</record>
</odoo>