diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..59dbf58 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'ITSulu Blog Publisher', + 'version': '14.0.1.0.0', + 'summary': 'AI-powered blog post generation with multi-LLM support, scheduling, and social media copy', + 'description': """ +ITSulu Blog Publisher +===================== +Replaces the CoWork/Windows VM blog pipeline with a fully server-side Odoo addon. + +Features +-------- +* On-demand blog generation via backend button or website toolbar +* Three configurable daily schedule slots (morning / afternoon / evening) +* Multi-LLM provider support: Anthropic Claude, OpenAI, Google Gemini, Ollama / Open WebUI +* Independent image generation provider (DALL·E, etc.) +* Single structured-JSON LLM call per post — lowest possible token usage +* Auto-populates all SEO fields (meta title, description, keywords, tags) +* Per-schedule / per-run social media platform enable/disable + (X/Twitter A+B, BlueSky A+B, Fediverse/Mastodon, LinkedIn) +* Notification email with social copy ready to paste — matches ITSulu Insights template +* Topic queue from ITSulu/company services — priority-ordered +* Editable system prompt and user prompt templates +* Full generation log with token usage, duration, error messages, and Retry button +* Supports Anthropic Pro user account API tokens (claude.ai/api) + """, + 'author': 'ITSulu', + 'website': 'https://itsulu.com', + 'category': 'Website/Blog', + 'license': 'LGPL-3', + 'depends': [ + 'base', + 'base_setup', + 'mail', + 'website', + 'website_blog', + ], + 'data': [ + 'security/ir.model.access.csv', + 'data/mail_template_data.xml', + 'data/ir_cron_data.xml', + 'data/default_prompts_data.xml', + 'views/menu_views.xml', + 'views/blog_topic_views.xml', + 'views/blog_schedule_views.xml', + 'views/blog_generation_log_views.xml', + 'views/blog_post_social_views.xml', + 'views/generate_now_wizard_views.xml', + 'views/res_config_settings_views.xml', + 'views/website_blog_publisher_templates.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'itsulu_blog_publisher/static/src/css/blog_publisher.css', + ], + 'website.assets_frontend': [ + 'itsulu_blog_publisher/static/src/css/website_blog_publisher.css', + ], + }, + 'installable': True, + 'application': True, + 'auto_install': False, + 'images': ['static/description/banner.png'], +} diff --git a/anthropic_provider.py b/anthropic_provider.py new file mode 100644 index 0000000..c97f27a --- /dev/null +++ b/anthropic_provider.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +""" +Anthropic Claude provider for itsulu_blog_publisher. + +Supports: +- Standard Anthropic API keys (sk-ant-api03-...) +- Claude Pro user account API tokens (claude.ai/api tokens — same endpoint, + different rate limits; treated identically here) + +Uses raw HTTP (requests) rather than the anthropic SDK to avoid adding a hard +dependency. The SDK can be used optionally if installed. + +Web search: Anthropic does not natively offer a web-search tool in the base +API. We instruct the model to cite real sources from its training knowledge +and note that the caller should verify URLs. When the 'web_search' beta tool +becomes generally available it can be enabled via the tools parameter. +""" +import json +import logging +import requests +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages' +ANTHROPIC_API_VERSION = '2023-06-01' + +# Anthropic model aliases accepted by this addon +KNOWN_ANTHROPIC_MODELS = { + # Sonnet 4 family + 'claude-sonnet-4-20250514': 'claude-sonnet-4-20250514', + 'claude-sonnet-4': 'claude-sonnet-4-20250514', + # Opus 4 + 'claude-opus-4-20250514': 'claude-opus-4-20250514', + 'claude-opus-4': 'claude-opus-4-20250514', + # Haiku 4.5 + 'claude-haiku-4-5': 'claude-haiku-4-5-20251001', + 'claude-haiku-4-5-20251001':'claude-haiku-4-5-20251001', + # Legacy / fallback + 'claude-3-5-sonnet-20241022':'claude-3-5-sonnet-20241022', + 'claude-3-5-haiku-20241022': 'claude-3-5-haiku-20241022', + 'claude-3-opus-20240229': 'claude-3-opus-20240229', +} + + +class AnthropicProvider: + """ + Calls the Anthropic Messages API with a single user message. + Returns (raw_text: str, tokens_used: int). + """ + + def __init__(self, api_key: str, model: str): + self.api_key = api_key + # Resolve model aliases; fall through to the literal string if unknown + self.model = KNOWN_ANTHROPIC_MODELS.get(model, model) + + def generate(self, system_prompt: str, user_prompt: str) -> tuple: + """ + :returns: (raw_text, tokens_used) + :raises UserError: on HTTP error or non-2xx response + """ + headers = { + 'x-api-key': self.api_key, + 'anthropic-version': ANTHROPIC_API_VERSION, + 'content-type': 'application/json', + # Enable extended thinking / web search betas when available: + # 'anthropic-beta': 'web-search-2025-03-05', + } + + payload = { + 'model': self.model, + 'max_tokens': 4096, + 'system': system_prompt, + 'messages': [ + {'role': 'user', 'content': user_prompt} + ], + } + + _logger.debug("AnthropicProvider calling %s with model %s", ANTHROPIC_API_URL, self.model) + + try: + resp = requests.post( + ANTHROPIC_API_URL, + headers=headers, + json=payload, + timeout=120, # blog generation can take up to 2 minutes on Opus + ) + except requests.Timeout: + raise UserError( + "Anthropic API request timed out after 120 seconds. " + "Try a faster model (Haiku or Sonnet) or reduce the prompt length." + ) + except requests.RequestException as exc: + raise UserError(f"Anthropic API network error: {exc}") from exc + + if resp.status_code == 401: + raise UserError( + "Anthropic API returned 401 Unauthorized. " + "Check your API key or Pro account token in Settings → Blog Publisher." + ) + if resp.status_code == 429: + raise UserError( + "Anthropic API rate limit reached (429). " + "Wait a moment and retry, or switch to a different model or provider." + ) + if not resp.ok: + try: + err_detail = resp.json().get('error', {}).get('message', resp.text[:300]) + except Exception: + err_detail = resp.text[:300] + raise UserError( + f"Anthropic API error {resp.status_code}: {err_detail}" + ) + + data = resp.json() + content_blocks = data.get('content', []) + raw_text = ''.join( + block.get('text', '') + for block in content_blocks + if block.get('type') == 'text' + ) + + usage = data.get('usage', {}) + tokens_used = ( + usage.get('input_tokens', 0) + usage.get('output_tokens', 0) + ) + + _logger.info( + "AnthropicProvider: model=%s input_tokens=%d output_tokens=%d", + self.model, + usage.get('input_tokens', 0), + usage.get('output_tokens', 0), + ) + + return raw_text, tokens_used diff --git a/blog_generation_log.py b/blog_generation_log.py new file mode 100644 index 0000000..3eb9ca8 --- /dev/null +++ b/blog_generation_log.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +""" +itsulu.blog.generation.log — Records every generation attempt. + +Every call to the LLM — whether triggered manually, by the website button, +or by a scheduled cron — writes one log record. Operators use this view +to monitor token usage, debug failures, and retry failed generations. +""" +import logging +from odoo import api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class BlogGenerationLog(models.Model): + _name = 'itsulu.blog.generation.log' + _description = 'Blog Publisher — Generation Log' + _order = 'create_date desc' + _rec_name = 'display_name' + + # ------------------------------------------------------------------ # + # Identity # + # ------------------------------------------------------------------ # + + display_name = fields.Char( + string='Name', + compute='_compute_display_name', + store=True, + ) + + # ------------------------------------------------------------------ # + # State # + # ------------------------------------------------------------------ # + + state = fields.Selection( + selection=[ + ('running', 'Running'), + ('success', 'Success'), + ('error', 'Error'), + ], + string='State', + default='running', + required=True, + index=True, + ) + + # ------------------------------------------------------------------ # + # Trigger metadata # + # ------------------------------------------------------------------ # + + trigger_source = fields.Selection( + selection=[ + ('manual', 'Manual (Backend)'), + ('website', 'Manual (Website Button)'), + ('scheduled', 'Scheduled Cron'), + ], + string='Trigger', + default='manual', + required=True, + ) + + schedule_id = fields.Many2one( + comodel_name="itsulu.blog.schedule", + string="Schedule Record", + ondelete="set null", + ) + + schedule_slot = fields.Selection( + selection=[ + ('morning', 'Morning'), + ('afternoon', 'Afternoon'), + ('evening', 'Evening'), + ], + string='Schedule Slot', + help='Populated only for scheduled triggers.', + ) + + topic_used = fields.Char(string='Topic Used') + + topic_source = fields.Selection( + selection=[ + ('queue', 'Topic Queue'), + ('llm', 'LLM-Chosen'), + ('manual', 'Manual Input'), + ], + string='Topic Source', + ) + + triggered_by = fields.Many2one( + comodel_name='res.users', + string='Triggered By', + ondelete='set null', + help='The Odoo user who triggered this generation (if manual).', + ) + + # ------------------------------------------------------------------ # + # LLM metadata # + # ------------------------------------------------------------------ # + + llm_provider = fields.Char(string='LLM Provider') + llm_model = fields.Char(string='LLM Model') + image_provider = fields.Char(string='Image Provider') + tokens_used = fields.Integer(string='Tokens Used', default=0) + duration_seconds = fields.Float(string='Duration (s)', digits=(8, 2)) + + # ------------------------------------------------------------------ # + # Result # + # ------------------------------------------------------------------ # + + blog_post_id = fields.Many2one( + comodel_name='blog.post', + string='Blog Post', + ondelete='set null', + ) + + error_message = fields.Text( + string='Error Message', + help='Human-readable error from the LLM API or post-processing.', + ) + + raw_llm_response = fields.Text( + string='Raw LLM Response', + help='Full JSON returned by the LLM — for debugging only.', + groups='base.group_system', # only System Administrators can see raw output + ) + + # ------------------------------------------------------------------ # + # Enabled social platforms for this run # + # ------------------------------------------------------------------ # + + 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) + + # ------------------------------------------------------------------ # + # Computed # + # ------------------------------------------------------------------ # + + @api.depends('state', 'topic_used', 'llm_provider', 'create_date') + def _compute_display_name(self): + for rec in self: + topic = (rec.topic_used or 'Unknown topic')[:50] + provider = rec.llm_provider or '?' + state_label = {'running': '⏳', 'success': '✅', 'error': '❌'}.get( + rec.state, '' + ) + rec.display_name = f"{state_label} {topic} [{provider}]" + + # ------------------------------------------------------------------ # + # Actions # + # ------------------------------------------------------------------ # + + def action_retry(self): + """ + Open the Generate Now wizard pre-filled with the same settings. + Only valid for error logs. + Returns False for success logs (no-op, prevents accidental duplication). + """ + self.ensure_one() + if self.state != 'error': + return False + + return { + 'type': 'ir.actions.act_window', + 'name': 'Retry Blog Generation', + 'res_model': 'itsulu.blog.generate.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_topic': self.topic_used or '', + 'default_llm_provider': self.llm_provider or '', + 'default_llm_model': self.llm_model or '', + 'default_image_provider': self.image_provider or 'none', + 'default_platform_twitter': self.platform_twitter, + 'default_platform_bluesky': self.platform_bluesky, + 'default_platform_mastodon': self.platform_mastodon, + 'default_platform_linkedin': self.platform_linkedin, + 'default_retry_log_id': self.id, + }, + } + + def send_notification_email(self): + """ + Send the post-publication notification email. + Only sent when blog_post_id.is_published is True. + Called automatically by the generation orchestrator after publish. + """ + self.ensure_one() + + if not self.blog_post_id: + _logger.warning( + "send_notification_email called on log %d with no blog_post_id", self.id + ) + return + + if not self.blog_post_id.is_published: + _logger.info( + "Skipping notification email for draft post (log %d)", self.id + ) + return + + template = self.env.ref( + 'itsulu_blog_publisher.email_template_blog_published', + raise_if_not_found=False, + ) + if not template: + _logger.error("Notification email template not found — skipping email") + return + + # Build recipient list: triggering user + fixed addresses + fixed_emails = self.env['ir.config_parameter'].sudo().get_param( + 'itsulu_blog_publisher.notification_emails', default='' + ) + recipient_emails = set( + e.strip() for e in fixed_emails.split(',') if e.strip() + ) + + if self.triggered_by and self.triggered_by.email: + recipient_emails.add(self.triggered_by.email) + + if not recipient_emails: + _logger.warning("No notification email recipients configured — skipping") + return + + for email_addr in recipient_emails: + template.with_context( + email_to_override=email_addr, + ).send_mail( + self.id, + force_send=True, + email_values={'email_to': email_addr}, + ) + + _logger.info( + "Notification email sent to %s for post '%s'", + ', '.join(recipient_emails), + self.blog_post_id.name, + ) diff --git a/blog_post_social.py b/blog_post_social.py new file mode 100644 index 0000000..e07b39d --- /dev/null +++ b/blog_post_social.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +""" +itsulu.blog.post.social — Social media copy for one published blog post. + +One record per blog.post. Stores the AI-generated platform copy so operators +can review and edit before the notification email is sent, and for future +reference. +""" +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class BlogPostSocial(models.Model): + _name = 'itsulu.blog.post.social' + _description = 'Blog Publisher — Social Media Copy' + _rec_name = 'blog_post_id' + + blog_post_id = fields.Many2one( + comodel_name='blog.post', + string='Blog Post', + required=True, + ondelete='cascade', + index=True, + ) + + # ------------------------------------------------------------------ # + # X / Twitter # + # ------------------------------------------------------------------ # + + twitter_post_a = fields.Text( + string='X / Twitter — Post A', + help='Max 280 characters excluding URL. Hook: statistic or question.', + ) + twitter_post_b = fields.Text( + string='X / Twitter — Post B', + help='Max 280 characters excluding URL. Different angle from Post A.', + ) + twitter_enabled = fields.Boolean(string='Generate X/Twitter', default=True) + + # ------------------------------------------------------------------ # + # BlueSky # + # ------------------------------------------------------------------ # + + bluesky_post_a = fields.Text( + string='BlueSky — Post A', + help='Max 300 characters excluding URL.', + ) + bluesky_post_b = fields.Text( + string='BlueSky — Post B', + help='Max 300 characters excluding URL.', + ) + bluesky_enabled = fields.Boolean(string='Generate BlueSky', default=True) + + # ------------------------------------------------------------------ # + # Fediverse / Mastodon # + # ------------------------------------------------------------------ # + + mastodon_post = fields.Text( + string='Fediverse / Mastodon', + help='Max 500 characters excluding URL. Community-oriented tone.', + ) + mastodon_enabled = fields.Boolean(string='Generate Mastodon', default=True) + + # ------------------------------------------------------------------ # + # LinkedIn # + # ------------------------------------------------------------------ # + + linkedin_post = fields.Text( + string='LinkedIn', + help='150–3000 characters. Professional, data-driven. Include insight + CTA.', + ) + linkedin_enabled = fields.Boolean(string='Generate LinkedIn', default=True) + + # ------------------------------------------------------------------ # + # Sources # + # ------------------------------------------------------------------ # + + sources_referenced = fields.Text( + string='Sources Referenced', + help='Newline-separated list of "Title — URL" pairs cited by the LLM.', + ) + + # ------------------------------------------------------------------ # + # Constraints # + # ------------------------------------------------------------------ # + + _sql_constraints = [ + ( + 'blog_post_unique', + 'UNIQUE(blog_post_id)', + 'Each blog post can have only one social media copy record.', + ) + ] + + @api.constrains('twitter_post_a', 'twitter_post_b') + def _check_twitter_length(self): + """Warn (not error) when Twitter posts exceed 280 characters.""" + for rec in self: + for field_name in ('twitter_post_a', 'twitter_post_b'): + value = getattr(rec, field_name) or '' + # Strip {{URL}} placeholder before counting + stripped = value.replace('{{URL}}', '').strip() + if len(stripped) > 280: + raise ValidationError( + f"X/Twitter post ({field_name}) is {len(stripped)} characters " + f"(max 280 excluding URL). Please shorten it." + ) + + @api.constrains('mastodon_post') + def _check_mastodon_length(self): + for rec in self: + value = (rec.mastodon_post or '').replace('{{URL}}', '').strip() + if len(value) > 500: + raise ValidationError( + f"Mastodon post is {len(value)} characters (max 500 excluding URL)." + ) + + # ------------------------------------------------------------------ # + # Helper # + # ------------------------------------------------------------------ # + + def substitute_url(self, post_url: str) -> 'BlogPostSocial': + """ + Replace {{URL}} placeholder in all social copy fields with the actual URL. + Called just before sending the notification email. + Returns self for chaining. + """ + self.ensure_one() + fields_to_update = [ + 'twitter_post_a', 'twitter_post_b', + 'bluesky_post_a', 'bluesky_post_b', + 'mastodon_post', 'linkedin_post', + ] + vals = {} + for fname in fields_to_update: + current = getattr(self, fname) or '' + if '{{URL}}' in current: + vals[fname] = current.replace('{{URL}}', post_url) + if vals: + self.write(vals) + return self diff --git a/blog_schedule.py b/blog_schedule.py new file mode 100644 index 0000000..c308487 --- /dev/null +++ b/blog_schedule.py @@ -0,0 +1,487 @@ +# -*- 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', + required=True, + help='Blog where generated posts will be created.', + ) + + 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.cr.commit() # 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.cr.commit() + 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, + 'body_arch': llm_response.body_html, + 'body': llm_response.body_html, + '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, + ) diff --git a/blog_topic.py b/blog_topic.py new file mode 100644 index 0000000..816294b --- /dev/null +++ b/blog_topic.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +""" +itsulu.blog.topic — Topic queue for AI blog post generation. + +Topics come from ITSulu's (or the deploying company's) service portfolio. +Users curate this list in the backend; the scheduler pops the highest-priority +pending topic for each generation run. +""" +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class BlogTopic(models.Model): + _name = 'itsulu.blog.topic' + _description = 'Blog Publisher — Topic Queue' + _order = 'priority_order asc, sequence asc, id asc' + + # ------------------------------------------------------------------ # + # Fields # + # ------------------------------------------------------------------ # + + name = fields.Char( + string='Topic / Title Hint', + required=True, + help='What the blog post should be about. Can be a title, a question, ' + 'or a brief description. The LLM will elaborate from this.', + ) + + sequence = fields.Integer( + string='Sequence', + default=10, + help='Lower number = picked first within the same priority level.', + ) + + priority = fields.Selection( + selection=[ + ('low', 'Low'), + ('normal', 'Normal'), + ('high', 'High'), + ('urgent', 'Urgent'), + ], + string='Priority', + default='normal', + required=True, + index=True, + ) + + # Numeric equivalent for clean ordering in _order + priority_order = fields.Integer( + string='Priority Order', + compute='_compute_priority_order', + store=True, + help='Computed from priority for clean DB ordering.', + ) + + state = fields.Selection( + selection=[ + ('pending', 'Pending'), + ('used', 'Used'), + ('skipped', 'Skipped'), + ], + string='State', + default='pending', + required=True, + index=True, + ) + + blog_id = fields.Many2one( + comodel_name='blog.blog', + string='Target Blog', + ondelete='set null', + help='Optional — force this topic to publish to a specific blog. ' + 'If blank, the schedule slot\'s configured blog is used.', + ) + + notes = fields.Text( + string='Notes / Context', + help='Additional context for the LLM — e.g. specific services to highlight, ' + 'recent news to reference, or a preferred angle.', + ) + + tone = fields.Char( + string='Tone Override', + help='E.g. "technical", "beginner-friendly", "thought leadership". ' + 'Overrides the schedule slot\'s default tone.', + ) + + used_date = fields.Datetime( + string='Used On', + readonly=True, + help='Timestamp when this topic was sent to the LLM.', + ) + + generation_log_id = fields.Many2one( + comodel_name='itsulu.blog.generation.log', + string='Generation Log', + readonly=True, + ondelete='set null', + ) + + # ------------------------------------------------------------------ # + # Computed fields # + # ------------------------------------------------------------------ # + + @api.depends('priority') + def _compute_priority_order(self): + _map = {'urgent': 1, 'high': 2, 'normal': 3, 'low': 4} + for rec in self: + rec.priority_order = _map.get(rec.priority, 3) + + # ------------------------------------------------------------------ # + # Constraints # + # ------------------------------------------------------------------ # + + @api.constrains('name') + def _check_name_not_empty(self): + for rec in self: + if not (rec.name or '').strip(): + raise ValidationError("Topic name cannot be empty.") + + # ------------------------------------------------------------------ # + # Business logic # + # ------------------------------------------------------------------ # + + @api.model + def get_next_topic(self): + """ + Return the single highest-priority pending topic, or an empty recordset. + Priority order: urgent > high > normal > low. + Within the same priority, sequence ASC then id ASC. + """ + return self.search([('state', '=', 'pending')], limit=1) + + def mark_used(self, generation_log=None): + """Set state to 'used' and record the timestamp.""" + self.ensure_one() + vals = { + 'state': 'used', + 'used_date': fields.Datetime.now(), + } + if generation_log: + vals['generation_log_id'] = generation_log.id + self.write(vals) + + def action_mark_pending(self): + """Reset a used/skipped topic back to pending for re-use.""" + self.write({'state': 'pending', 'used_date': False, 'generation_log_id': False}) + + def action_mark_skipped(self): + self.write({'state': 'skipped'}) diff --git a/blog_topic_views.xml b/blog_topic_views.xml new file mode 100644 index 0000000..ccec236 --- /dev/null +++ b/blog_topic_views.xml @@ -0,0 +1,439 @@ + + + + + + + itsulu.blog.topic.tree + itsulu.blog.topic + + + + + + + + +