itsulu-blog-publisher/addons/itsulu_blog_publisher/models/blog_schedule.py

484 lines
19 KiB
Python

# -*- coding: utf-8 -*-
"""
itsulu.blog.schedule — Three configurable daily cron slots.
This model owns the full generation orchestration: it calls LLMRouter,
creates the blog.post, sets SEO fields, creates the social copy record,
optionally publishes, and sends the notification email.
One model — three records (morning, afternoon, evening) configured in
data/ir_cron_data.xml. Additional slots can be created manually.
"""
import logging
import time
import base64
from odoo import api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class BlogSchedule(models.Model):
_name = 'itsulu.blog.schedule'
_description = 'Blog Publisher — Schedule Slot'
_order = 'slot asc'
# ------------------------------------------------------------------ #
# Identity #
# ------------------------------------------------------------------ #
name = fields.Char(
string='Slot Name',
required=True,
default='New Schedule Slot',
)
slot = fields.Selection(
selection=[
('morning', 'Morning'),
('afternoon', 'Afternoon'),
('evening', 'Evening'),
],
string='Slot',
required=True,
default='morning',
)
active = fields.Boolean(
string='Active',
default=True,
help='Inactive slots are skipped by the scheduler.',
)
trigger_time = fields.Float(
string='Trigger Time (UTC hours)',
default=8.0,
help='Hour of day in UTC to trigger generation (e.g. 8.0 = 08:00 UTC).',
)
# ------------------------------------------------------------------ #
# Content configuration #
# ------------------------------------------------------------------ #
blog_id = fields.Many2one(
comodel_name='blog.blog',
string='Target Blog',
help='Blog where generated posts will be created. Must be set before activating this slot.',
)
tone = fields.Char(
string='Tone',
default='professional and informative',
help='Writing tone for this slot. E.g. "technical", "beginner-friendly".',
)
auto_publish = fields.Boolean(
string='Auto-Publish',
default=True,
help='Publish the post immediately after generation. '
'If False, post is saved as draft for manual review.',
)
prompt_override = fields.Text(
string='User Prompt Override',
help='Replaces the global user prompt template for this slot only. '
'Leave blank to use the global template from Settings.',
)
# ------------------------------------------------------------------ #
# LLM configuration #
# ------------------------------------------------------------------ #
llm_provider = fields.Selection(
selection=[
('anthropic', 'Anthropic Claude'),
('openai', 'OpenAI ChatGPT'),
('gemini', 'Google Gemini'),
('ollama', 'Ollama / Open WebUI'),
],
string='Text LLM Provider',
required=True,
default='anthropic',
)
llm_model = fields.Char(
string='Text LLM Model',
default='claude-sonnet-4-20250514',
help='Model name passed to the provider API.',
)
image_provider = fields.Selection(
selection=[
('none', 'No Image'),
('openai_dalle', 'OpenAI DALL·E 3'),
('google_imagen', 'Google Imagen'),
('stable_diff', 'Stable Diffusion (Ollama)'),
],
string='Image Provider',
default='none',
required=True,
help='Image generation provider. Independent from the text LLM.',
)
# ------------------------------------------------------------------ #
# Social platform toggles (per slot) #
# ------------------------------------------------------------------ #
platform_twitter = fields.Boolean(string='X/Twitter', default=True)
platform_bluesky = fields.Boolean(string='BlueSky', default=True)
platform_mastodon = fields.Boolean(string='Mastodon', default=True)
platform_linkedin = fields.Boolean(string='LinkedIn', default=True)
# ------------------------------------------------------------------ #
# Notification #
# ------------------------------------------------------------------ #
notification_emails = fields.Char(
string='Notification Email Override',
help='Comma-separated. Overrides the global setting for this slot. '
'Leave blank to use global Settings.',
)
# ------------------------------------------------------------------ #
# Stats (computed from related logs) #
# ------------------------------------------------------------------ #
log_ids = fields.One2many(
comodel_name='itsulu.blog.generation.log',
inverse_name='schedule_id',
string='Generation Logs',
)
last_run = fields.Datetime(
string='Last Run',
compute='_compute_last_run',
store=False,
)
@api.depends('log_ids.create_date')
def _compute_last_run(self):
for rec in self:
last_log = rec.log_ids.sorted('create_date', reverse=True)[:1]
rec.last_run = last_log.create_date if last_log else False
# ------------------------------------------------------------------ #
# Main orchestration #
# ------------------------------------------------------------------ #
def run_generation(self, topic: str = None, trigger_source: str = 'scheduled',
triggered_by=None, auto_publish: bool = None,
platform_overrides: dict = None):
"""
Full generation pipeline for one schedule slot.
Steps:
1. Guard: skip if slot is inactive
2. Resolve topic (argument → queue → LLM-chosen)
3. Create generation log (state='running')
4. Call LLMRouter.generate()
5. Create blog.post with SEO fields and tags
6. Generate cover image (if image_provider != 'none')
7. Create itsulu.blog.post.social
8. Optionally publish the post
9. Update log (state='success')
10. Send notification email
:param topic: Force a specific topic (used by wizard).
:param trigger_source: 'manual', 'website', or 'scheduled'.
:param triggered_by: res.users record of the triggering user.
:param auto_publish: Override slot's auto_publish setting.
:param platform_overrides: dict {platform: bool} to override slot defaults.
:returns: The created blog.post record (or raises UserError on failure).
"""
self.ensure_one()
if not self.active:
_logger.info("BlogSchedule.run_generation: slot '%s' is inactive — skipping", self.name)
return False
_logger.info(
"BlogSchedule.run_generation: slot=%s trigger=%s",
self.slot, trigger_source
)
# Step 2: resolve topic
topic_source = 'manual'
blog_topic_record = None
if not topic:
blog_topic_record = self.env['itsulu.blog.topic'].get_next_topic()
if blog_topic_record:
topic = blog_topic_record.name
if blog_topic_record.notes:
topic = f"{topic}\n\nAdditional context: {blog_topic_record.notes}"
topic_source = 'queue'
else:
# LLM will choose — we pass a prompt that tells it to pick
# a topic relevant to our service portfolio
topic = (
'Choose a timely and relevant topic related to ITSulu\'s services: '
'AI consulting, IT infrastructure, cloud solutions, cybersecurity, '
'digital transformation, or enterprise software. '
'The topic should be useful to IT decision-makers.'
)
topic_source = 'llm'
# Step 3: create generation log
enabled_platforms = self._get_platform_flags(platform_overrides)
effective_auto_publish = auto_publish if auto_publish is not None else self.auto_publish
log = self.env['itsulu.blog.generation.log'].create({
'state': 'running',
'trigger_source': trigger_source,
'schedule_slot': self.slot,
'schedule_id': self.id,
'topic_used': topic[:500] if topic else '',
'topic_source': topic_source,
'triggered_by': triggered_by.id if triggered_by else False,
'llm_provider': self.llm_provider,
'llm_model': self.llm_model,
'image_provider': self.image_provider,
'platform_twitter': enabled_platforms.get('twitter_a', True),
'platform_bluesky': enabled_platforms.get('bluesky_a', True),
'platform_mastodon': enabled_platforms.get('mastodon', True),
'platform_linkedin': enabled_platforms.get('linkedin', True),
})
self.env.flush_all() # persist 'running' log before the API call
start = time.monotonic()
blog_post = None
try:
# Step 4: call LLM
from ..services.llm_router import LLMRouter
router = LLMRouter(
env=self.env,
provider=self.llm_provider,
model=self.llm_model,
)
target_blog = blog_topic_record.blog_id if blog_topic_record and blog_topic_record.blog_id else self.blog_id
llm_response = router.generate(
topic=topic,
blog_name=target_blog.name or 'ITSulu Insights',
tone=(blog_topic_record.tone if blog_topic_record and blog_topic_record.tone else None) or self.tone,
enabled_platforms=enabled_platforms,
user_prompt=self.prompt_override or None,
)
# Step 5: create blog.post
blog_post = self._create_blog_post(
llm_response=llm_response,
blog=target_blog,
auto_publish=effective_auto_publish,
)
# Step 6: generate cover image
if self.image_provider != 'none':
self._attach_cover_image(blog_post=blog_post, llm_response=llm_response)
# Step 7: create social copy
post_url = self._get_post_url(blog_post)
self._create_social_record(
blog_post=blog_post,
llm_response=llm_response,
enabled_platforms=enabled_platforms,
post_url=post_url,
)
# Step 9: update log
elapsed = time.monotonic() - start
log.write({
'state': 'success',
'blog_post_id': blog_post.id,
'tokens_used': llm_response.tokens_used,
'duration_seconds': elapsed,
'raw_llm_response': llm_response.raw_text,
})
# Mark topic as used
if blog_topic_record:
blog_topic_record.mark_used(generation_log=log)
# Step 10: send notification email
log.send_notification_email()
_logger.info(
"BlogSchedule.run_generation: success — post '%s' tokens=%d duration=%.1fs",
blog_post.name, llm_response.tokens_used, elapsed,
)
except Exception as exc:
elapsed = time.monotonic() - start
error_msg = str(exc)
_logger.error(
"BlogSchedule.run_generation: FAILED after %.1fs — %s",
elapsed, error_msg, exc_info=True
)
log.write({
'state': 'error',
'error_message': error_msg,
'duration_seconds': elapsed,
})
self.env.flush_all()
raise
return blog_post
# ------------------------------------------------------------------ #
# Private helpers #
# ------------------------------------------------------------------ #
def _get_platform_flags(self, overrides: dict = None) -> dict:
"""Merge slot defaults with per-run overrides."""
defaults = {
'twitter_a': self.platform_twitter,
'twitter_b': self.platform_twitter,
'bluesky_a': self.platform_bluesky,
'bluesky_b': self.platform_bluesky,
'mastodon': self.platform_mastodon,
'linkedin': self.platform_linkedin,
}
if overrides:
defaults.update(overrides)
return defaults
def _create_blog_post(self, llm_response, blog, auto_publish: bool):
"""Create the blog.post record from the LLM response."""
# Get or create tags
tag_ids = self.env['blog.tag'].get_or_create_tags(llm_response.tags)
vals = {
'name': llm_response.title,
'blog_id': blog.id,
'website_meta_title': llm_response.meta_title,
'website_meta_description': llm_response.meta_description,
'website_meta_keywords': llm_response.meta_keywords,
'tag_ids': [(6, 0, tag_ids.ids)],
'is_published': auto_publish,
'website_published': auto_publish,
}
post = self.env['blog.post'].create(vals)
_logger.info("Created blog.post id=%d name='%s'", post.id, post.name)
return post
def _attach_cover_image(self, blog_post, llm_response):
"""Generate and attach a cover image to the blog post."""
try:
from ..services.image_router import ImageRouter
image_router = ImageRouter(env=self.env, provider=self.image_provider)
img_result = image_router.generate_cover(
title=llm_response.title,
keywords=llm_response.meta_keywords,
)
if img_result:
blog_post.write({'image_cover': img_result.b64_data})
_logger.info("Cover image attached to post id=%d", blog_post.id)
except Exception as exc:
# Image generation failure is non-fatal — log and continue
_logger.warning(
"Cover image generation failed for post id=%d: %s",
blog_post.id, exc
)
def _create_social_record(self, blog_post, llm_response, enabled_platforms: dict,
post_url: str):
"""Create the itsulu.blog.post.social record and substitute the URL."""
social_vals = {
'blog_post_id': blog_post.id,
'twitter_enabled': enabled_platforms.get('twitter_a', True),
'bluesky_enabled': enabled_platforms.get('bluesky_a', True),
'mastodon_enabled': enabled_platforms.get('mastodon', True),
'linkedin_enabled': enabled_platforms.get('linkedin', True),
'twitter_post_a': llm_response.social.twitter_a if enabled_platforms.get('twitter_a') else '',
'twitter_post_b': llm_response.social.twitter_b if enabled_platforms.get('twitter_b') else '',
'bluesky_post_a': llm_response.social.bluesky_a if enabled_platforms.get('bluesky_a') else '',
'bluesky_post_b': llm_response.social.bluesky_b if enabled_platforms.get('bluesky_b') else '',
'mastodon_post': llm_response.social.mastodon if enabled_platforms.get('mastodon') else '',
'linkedin_post': llm_response.social.linkedin if enabled_platforms.get('linkedin') else '',
'sources_referenced': '\n'.join(
f"{s.title}{s.url}" for s in (llm_response.sources or [])
),
}
social = self.env['itsulu.blog.post.social'].create(social_vals)
social.substitute_url(post_url)
return social
def _get_post_url(self, blog_post) -> str:
"""Return the public URL for the blog post."""
base = self.env['ir.config_parameter'].sudo().get_param(
'web.base.url', 'https://itsulu.com'
)
try:
return blog_post.website_url or f"{base}/blog/{blog_post.blog_id.id}/{blog_post.id}"
except Exception:
return f"{base}/blog/{blog_post.id}"
# ------------------------------------------------------------------ #
# Cron entry point (called by ir.cron) #
# ------------------------------------------------------------------ #
@api.model
def _cron_run_all_active_slots(self):
"""
Called by the daily Odoo cron job. Runs all active schedule slots.
Each slot is independent — one failure does not block the others.
"""
slots = self.search([('active', '=', True)])
for slot in slots:
try:
slot.run_generation(trigger_source='scheduled')
except Exception as exc:
_logger.error(
"Cron failed for slot '%s': %s", slot.name, exc, exc_info=True
)
# Continue with the next slot
# ------------------------------------------------------------------ #
# Extend blog.tag with get_or_create_tags helper #
# ------------------------------------------------------------------ #
class BlogTag(models.Model):
_inherit = 'blog.tag'
@api.model
def get_or_create_tags(self, tag_names: list):
"""
Given a list of tag name strings, return a recordset of blog.tag.
Creates tags that do not exist. Case-insensitive matching.
"""
if not tag_names:
return self.browse([])
result_ids = []
for name in tag_names:
name = (name or '').strip()
if not name:
continue
existing = self.search([('name', '=ilike', name)], limit=1)
if existing:
result_ids.append(existing.id)
else:
new_tag = self.create({'name': name})
result_ids.append(new_tag.id)
return self.browse(result_ids)
# ------------------------------------------------------------------ #
# Extend blog.post with itsulu_social_id for email template access #
# ------------------------------------------------------------------ #
class BlogPost(models.Model):
_inherit = 'blog.post'
itsulu_social_id = fields.One2many(
comodel_name='itsulu.blog.post.social',
inverse_name='blog_post_id',
string='Social Media Copy',
limit=1,
)