90 KiB
Fusion Clock AI - Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build a standalone Odoo 19 module (fusion_clock_ai) that adds OpenAI-powered intelligence to the existing fusion_clock attendance system -- natural language queries, narrative reports, anomaly detection, employee chatbot, predictive alerts, and smart configuration.
Architecture: Companion module depending on fusion_clock. All AI calls route through a central service model (fusion.clock.ai.service) that handles API key management, token budgeting, response caching, and prompt templates. Frontend uses OWL chat components in both backend (manager) and portal (employee). The module inherits existing models to inject AI summaries without modifying fusion_clock code.
Tech Stack: Odoo 19, Python 3.12, OpenAI Python SDK (openai), OWL 2 (Odoo Web Library), SCSS, XML views.
Module Structure
fusion_clock_ai/
__init__.py
__manifest__.py
models/
__init__.py
ai_service.py # Core OpenAI wrapper (AbstractModel)
ai_prompt.py # Prompt template storage
ai_conversation.py # Chat history per user
ai_usage.py # Token/cost tracking per day
ai_cache.py # Response cache with TTL
res_config_settings.py # AI-specific settings
hr_employee_ai.py # Inherit hr.employee for AI summary
hr_attendance_ai.py # Inherit hr.attendance for anomaly hooks
clock_report_ai.py # Inherit fusion.clock.report for AI narratives
clock_correction_ai.py # Inherit fusion.clock.correction for AI advisor
controllers/
__init__.py
ai_api.py # Backend JSON-RPC endpoints
portal_ai.py # Portal chatbot endpoints
security/
security.xml # Groups and record rules
ir.model.access.csv # Model access rights
data/
ir_config_parameter_data.xml
ir_cron_data.xml # Weekly AI report, daily anomaly scan
ai_prompt_data.xml # Default prompt templates
views/
res_config_settings_views.xml
ai_conversation_views.xml
ai_usage_views.xml
ai_prompt_views.xml
hr_employee_views.xml # AI Insights tab on employee
clock_report_views.xml # AI narrative on reports
clock_correction_views.xml # AI advisor on corrections
ai_menus.xml
portal_ai_templates.xml # Portal chatbot UI
static/
src/
js/
ai_chat_backend.js # OWL: Manager chat panel
ai_chat_portal.js # Portal: Employee chatbot (Interaction)
xml/
ai_chat_backend.xml # OWL template
scss/
fusion_clock_ai.scss # All AI-specific styles
css/
portal_ai.css # Portal chatbot styles
Phase 1: Foundation + Quick Wins
Task 1: Module Scaffold and Manifest
Files:
- Create:
fusion_clock_ai/__init__.py - Create:
fusion_clock_ai/__manifest__.py - Create:
fusion_clock_ai/models/__init__.py - Create:
fusion_clock_ai/controllers/__init__.py
Step 1: Create module entry point
# fusion_clock_ai/__init__.py
from . import models
from . import controllers
Step 2: Create manifest
# fusion_clock_ai/__manifest__.py
{
'name': 'Fusion Clock AI',
'version': '19.0.1.0.0',
'category': 'Human Resources/Attendances',
'summary': 'AI-Powered Intelligence for Fusion Clock - GPT Reports, Anomaly Detection, Natural Language Queries & Employee Assistant',
'description': """
Fusion Clock AI - Intelligent Attendance Analytics
====================================================
AI enhancement module for Fusion Clock. Requires OpenAI API key.
* **Natural Language Dashboard** - Ask questions about attendance in plain English
* **AI Narrative Reports** - Human-readable weekly/monthly summaries replacing raw numbers
* **Payroll Anomaly Detection** - GPT flags suspicious patterns before payroll approval
* **Employee AI Assistant** - Portal chatbot for hours, leave requests, schedule queries
* **Attendance Coach** - Personalized weekly tips per employee
* **Correction Review Advisor** - AI context for approval/rejection decisions
* **Predictive Understaffing** - Forecast absence likelihood by day
* **Shift Optimization** - Data-driven shift reassignment suggestions
* **Compliance Checks** - Labor law violation alerts
* **Smart Configuration** - Describe policies in English, AI maps to settings
* **Geofence Tuning** - AI suggests radius adjustments from clock-in data
* **Intelligent Incident Logs** - Auto-generated explanations for every activity log
Requires: fusion_clock module and an OpenAI API key.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.io',
'license': 'OPL-1',
'depends': [
'fusion_clock',
'mail',
],
'external_dependencies': {
'python': ['openai'],
},
'data': [
# Security
'security/security.xml',
'security/ir.model.access.csv',
# Data
'data/ir_config_parameter_data.xml',
'data/ir_cron_data.xml',
'data/ai_prompt_data.xml',
# Views
'views/res_config_settings_views.xml',
'views/ai_conversation_views.xml',
'views/ai_usage_views.xml',
'views/ai_prompt_views.xml',
'views/hr_employee_views.xml',
'views/clock_report_views.xml',
'views/clock_correction_views.xml',
'views/ai_menus.xml',
# Portal
'views/portal_ai_templates.xml',
],
'assets': {
'web.assets_frontend': [
'fusion_clock_ai/static/src/css/portal_ai.css',
'fusion_clock_ai/static/src/js/ai_chat_portal.js',
],
'web.assets_backend': [
'fusion_clock_ai/static/src/scss/fusion_clock_ai.scss',
'fusion_clock_ai/static/src/js/ai_chat_backend.js',
'fusion_clock_ai/static/src/xml/ai_chat_backend.xml',
],
},
'installable': True,
'auto_install': False,
'application': False,
}
Step 3: Create models init
# fusion_clock_ai/models/__init__.py
from . import ai_service
from . import ai_prompt
from . import ai_conversation
from . import ai_usage
from . import ai_cache
from . import res_config_settings
from . import hr_employee_ai
from . import hr_attendance_ai
from . import clock_report_ai
from . import clock_correction_ai
Step 4: Create controllers init
# fusion_clock_ai/controllers/__init__.py
from . import ai_api
from . import portal_ai
Task 2: Core AI Service Model
Files:
- Create:
fusion_clock_ai/models/ai_service.py
This is the central brain. Every AI feature calls methods on this model. It handles:
- OpenAI API initialization with the stored API key
- Token counting and budget enforcement
- Response caching (via
fusion.clock.ai.cache) - Usage logging (via
fusion.clock.ai.usage) - System prompt assembly from templates
Step 1: Implement the service
# fusion_clock_ai/models/ai_service.py
import json
import hashlib
import logging
from datetime import datetime, timedelta, date
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
try:
from openai import OpenAI
except ImportError:
OpenAI = None
_logger.warning("openai package not installed. Fusion Clock AI features will be unavailable.")
class FusionClockAIService(models.AbstractModel):
_name = 'fusion.clock.ai.service'
_description = 'Fusion Clock AI Service'
def _get_client(self):
if OpenAI is None:
raise UserError(_("The 'openai' Python package is not installed. Run: pip install openai"))
ICP = self.env['ir.config_parameter'].sudo()
api_key = ICP.get_param('fusion_clock_ai.openai_api_key', '')
if not api_key:
raise UserError(_("OpenAI API key not configured. Go to Fusion Clock > Configuration > AI Settings."))
return OpenAI(api_key=api_key)
def _get_model(self):
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param('fusion_clock_ai.openai_model', 'gpt-4o-mini')
def _get_max_tokens(self):
ICP = self.env['ir.config_parameter'].sudo()
return int(ICP.get_param('fusion_clock_ai.max_response_tokens', '1024'))
def _check_budget(self):
ICP = self.env['ir.config_parameter'].sudo()
monthly_budget = float(ICP.get_param('fusion_clock_ai.monthly_budget_usd', '50.0'))
if monthly_budget <= 0:
return True
today = date.today()
month_start = today.replace(day=1)
usage_records = self.env['fusion.clock.ai.usage'].sudo().search([
('date', '>=', month_start),
('date', '<=', today),
])
total_cost = sum(r.estimated_cost_usd for r in usage_records)
if total_cost >= monthly_budget:
raise UserError(_(
"Monthly AI budget of $%(budget).2f has been reached ($%(spent).2f spent). "
"Increase the budget in AI Settings or wait until next month.",
budget=monthly_budget, spent=total_cost,
))
return True
def _get_cache_key(self, prompt_key, context_hash):
return hashlib.sha256(f"{prompt_key}:{context_hash}".encode()).hexdigest()
def _check_cache(self, cache_key, ttl_minutes=15):
cutoff = datetime.now() - timedelta(minutes=ttl_minutes)
cached = self.env['fusion.clock.ai.cache'].sudo().search([
('cache_key', '=', cache_key),
('created_at', '>=', cutoff),
], limit=1)
if cached:
return cached.response_text
return False
def _store_cache(self, cache_key, response_text, prompt_key=''):
self.env['fusion.clock.ai.cache'].sudo().create({
'cache_key': cache_key,
'prompt_key': prompt_key,
'response_text': response_text,
})
def _log_usage(self, feature, model_name, prompt_tokens, completion_tokens):
cost_per_1k_input = {
'gpt-4o': 0.0025, 'gpt-4o-mini': 0.00015, 'gpt-3.5-turbo': 0.0005,
}
cost_per_1k_output = {
'gpt-4o': 0.01, 'gpt-4o-mini': 0.0006, 'gpt-3.5-turbo': 0.0015,
}
input_cost = (prompt_tokens / 1000) * cost_per_1k_input.get(model_name, 0.001)
output_cost = (completion_tokens / 1000) * cost_per_1k_output.get(model_name, 0.002)
today = date.today()
existing = self.env['fusion.clock.ai.usage'].sudo().search([
('date', '=', today),
('feature', '=', feature),
('model_name', '=', model_name),
], limit=1)
if existing:
existing.write({
'prompt_tokens': existing.prompt_tokens + prompt_tokens,
'completion_tokens': existing.completion_tokens + completion_tokens,
'estimated_cost_usd': existing.estimated_cost_usd + input_cost + output_cost,
'request_count': existing.request_count + 1,
})
else:
self.env['fusion.clock.ai.usage'].sudo().create({
'date': today,
'feature': feature,
'model_name': model_name,
'prompt_tokens': prompt_tokens,
'completion_tokens': completion_tokens,
'estimated_cost_usd': input_cost + output_cost,
'request_count': 1,
})
def _get_system_prompt(self, prompt_key):
prompt = self.env['fusion.clock.ai.prompt'].sudo().search([
('key', '=', prompt_key),
('active', '=', True),
], limit=1)
if not prompt:
return "You are a helpful attendance management assistant."
return prompt.content
def chat_completion(self, messages, feature='general', cache_key=None, cache_ttl=15):
if cache_key:
cached = self._check_cache(cache_key, cache_ttl)
if cached:
return cached
self._check_budget()
client = self._get_client()
model_name = self._get_model()
max_tokens = self._get_max_tokens()
try:
response = client.chat.completions.create(
model=model_name,
messages=messages,
max_tokens=max_tokens,
temperature=0.3,
)
except Exception as e:
_logger.error("OpenAI API error: %s", str(e))
raise UserError(_("AI service error: %s", str(e)))
result = response.choices[0].message.content
usage = response.usage
self._log_usage(feature, model_name, usage.prompt_tokens, usage.completion_tokens)
if cache_key:
self._store_cache(cache_key, result, feature)
return result
# ----------------------------------------------------------------
# Data aggregation helpers -- collect attendance data as structured
# context strings that fit within token limits
# ----------------------------------------------------------------
def _build_employee_context(self, employee, date_from=None, date_to=None):
if not date_from:
date_from = date.today() - timedelta(days=7)
if not date_to:
date_to = date.today()
Attendance = self.env['hr.attendance'].sudo()
attendances = Attendance.search([
('employee_id', '=', employee.id),
('check_in', '>=', datetime.combine(date_from, datetime.min.time())),
('check_in', '<=', datetime.combine(date_to, datetime.max.time())),
], order='check_in asc')
shift = employee.x_fclk_shift_id
shift_info = f"Shift: {shift.name} ({shift.start_time:.1f}-{shift.end_time:.1f})" if shift else "No assigned shift"
lines = [
f"Employee: {employee.name}",
f"Department: {employee.department_id.name or 'N/A'}",
shift_info,
f"On-time streak: {employee.x_fclk_ontime_streak}",
f"Absences this month: {employee.x_fclk_absences_this_month}",
f"Absences this year: {employee.x_fclk_absences_this_year}",
f"Overtime this week: {employee.x_fclk_overtime_this_week:.1f}h",
f"Overtime this month: {employee.x_fclk_overtime_this_month:.1f}h",
f"",
f"Attendance records ({date_from} to {date_to}):",
]
for att in attendances:
check_out_str = att.check_out.strftime('%Y-%m-%d %H:%M') if att.check_out else 'MISSING'
lines.append(
f" {att.check_in.strftime('%Y-%m-%d %H:%M')} -> {check_out_str} | "
f"Net: {att.x_fclk_net_hours:.1f}h | Break: {att.x_fclk_break_minutes:.0f}m | "
f"OT: {att.x_fclk_overtime_hours:.1f}h | Source: {att.x_fclk_clock_source or 'N/A'}"
)
Log = self.env['fusion.clock.activity.log'].sudo()
logs = Log.search([
('employee_id', '=', employee.id),
('log_date', '>=', datetime.combine(date_from, datetime.min.time())),
('log_date', '<=', datetime.combine(date_to, datetime.max.time())),
('log_type', 'in', ['late_clock_in', 'early_clock_out', 'auto_clock_out',
'missed_clock_out', 'absent', 'outside_geofence']),
], order='log_date asc')
if logs:
lines.append("")
lines.append("Incidents:")
for log in logs:
lines.append(f" [{log.log_type}] {log.log_date.strftime('%Y-%m-%d %H:%M')}: {log.description or ''}")
Penalty = self.env['fusion.clock.penalty'].sudo()
penalties = Penalty.search([
('employee_id', '=', employee.id),
('date', '>=', date_from),
('date', '<=', date_to),
])
if penalties:
lines.append("")
lines.append("Penalties:")
for p in penalties:
lines.append(f" [{p.penalty_type}] {p.date}: {p.penalty_minutes:.0f}m deducted")
return "\n".join(lines)
def _build_team_context(self, employees, date_from=None, date_to=None):
if not date_from:
date_from = date.today() - timedelta(days=7)
if not date_to:
date_to = date.today()
lines = [f"Team Attendance Summary ({date_from} to {date_to})", ""]
for emp in employees:
Attendance = self.env['hr.attendance'].sudo()
attendances = Attendance.search([
('employee_id', '=', emp.id),
('check_in', '>=', datetime.combine(date_from, datetime.min.time())),
('check_in', '<=', datetime.combine(date_to, datetime.max.time())),
])
total_net = sum(a.x_fclk_net_hours for a in attendances)
total_ot = sum(a.x_fclk_overtime_hours for a in attendances)
days_worked = len(set(a.check_in.date() for a in attendances))
Penalty = self.env['fusion.clock.penalty'].sudo()
penalty_count = Penalty.search_count([
('employee_id', '=', emp.id),
('date', '>=', date_from),
('date', '<=', date_to),
])
Log = self.env['fusion.clock.activity.log'].sudo()
absence_count = Log.search_count([
('employee_id', '=', emp.id),
('log_type', '=', 'absent'),
('log_date', '>=', datetime.combine(date_from, datetime.min.time())),
('log_date', '<=', datetime.combine(date_to, datetime.max.time())),
])
late_count = Log.search_count([
('employee_id', '=', emp.id),
('log_type', '=', 'late_clock_in'),
('log_date', '>=', datetime.combine(date_from, datetime.min.time())),
('log_date', '<=', datetime.combine(date_to, datetime.max.time())),
])
shift_name = emp.x_fclk_shift_id.name if emp.x_fclk_shift_id else 'No shift'
lines.append(
f"- {emp.name} ({emp.department_id.name or 'N/A'}, {shift_name}): "
f"{days_worked} days, {total_net:.1f}h net, {total_ot:.1f}h OT, "
f"{penalty_count} penalties, {late_count} late, {absence_count} absences, "
f"streak: {emp.x_fclk_ontime_streak}"
)
return "\n".join(lines)
def _build_payroll_context(self, report):
lines = [
f"Payroll Report: {report.name}",
f"Period: {report.date_start} to {report.date_end}",
f"Employee: {report.employee_id.name if report.employee_id else 'Batch (all employees)'}",
f"Total hours: {report.total_hours:.1f}",
f"Net hours: {report.net_hours:.1f}",
f"Total breaks: {report.total_breaks:.0f} minutes",
f"Total penalties: {report.total_penalties}",
f"Days worked: {report.days_worked}",
"",
"Daily breakdown:",
]
for att in report.attendance_ids:
check_out_str = att.check_out.strftime('%H:%M') if att.check_out else 'MISSING'
auto = ' [AUTO-CLOCKED-OUT]' if att.x_fclk_auto_clocked_out else ''
lines.append(
f" {att.check_in.strftime('%Y-%m-%d')} | "
f"{att.check_in.strftime('%H:%M')}-{check_out_str} | "
f"Net: {att.x_fclk_net_hours:.1f}h | OT: {att.x_fclk_overtime_hours:.1f}h{auto}"
)
return "\n".join(lines)
Task 3: Prompt Template Model
Files:
- Create:
fusion_clock_ai/models/ai_prompt.py - Create:
fusion_clock_ai/data/ai_prompt_data.xml
Stores editable prompt templates for each AI feature. Managers can tweak prompts without code changes.
Step 1: Create the model
# fusion_clock_ai/models/ai_prompt.py
from odoo import models, fields
class FusionClockAIPrompt(models.Model):
_name = 'fusion.clock.ai.prompt'
_description = 'AI Prompt Template'
_order = 'key'
key = fields.Char(required=True, index=True)
name = fields.Char(required=True)
content = fields.Text(required=True)
description = fields.Text(help="Explains when this prompt is used and what variables are available.")
active = fields.Boolean(default=True)
feature_category = fields.Selection([
('manager_query', 'Manager Queries'),
('employee_chat', 'Employee Chatbot'),
('report', 'Report Narratives'),
('anomaly', 'Anomaly Detection'),
('coach', 'Attendance Coach'),
('correction', 'Correction Advisor'),
('prediction', 'Predictions'),
('shift', 'Shift Optimization'),
('compliance', 'Compliance'),
('config', 'Configuration'),
('geofence', 'Geofence Tuning'),
('incident', 'Incident Explanation'),
], required=True)
Step 2: Create default prompts data file
<!-- fusion_clock_ai/data/ai_prompt_data.xml -->
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="prompt_manager_query" model="fusion.clock.ai.prompt">
<field name="key">manager_query</field>
<field name="name">Manager Natural Language Query</field>
<field name="feature_category">manager_query</field>
<field name="description">System prompt for the manager chat. The team attendance data will be injected as context.</field>
<field name="content">You are an attendance analytics assistant for a company using Fusion Clock.
You answer questions about employee attendance, overtime, absences, penalties, and schedules.
Rules:
- Answer based ONLY on the provided data. Never invent data.
- If the data doesn't contain the answer, say so clearly.
- Format responses with clear structure: bullet points, tables when comparing employees.
- Use hours and minutes (e.g., "8h 30m"), not decimal hours.
- When mentioning dates, use the format "Mon, Mar 15".
- Be concise but thorough. Highlight concerning patterns.
- If asked about payroll impact, calculate net hours minus penalties.
- Currency amounts are NOT in the data -- never guess pay rates.</field>
</record>
<record id="prompt_employee_chat" model="fusion.clock.ai.prompt">
<field name="key">employee_chat</field>
<field name="name">Employee Clock Assistant</field>
<field name="feature_category">employee_chat</field>
<field name="content">You are a friendly attendance assistant for an employee using Fusion Clock.
You help them understand their hours, schedule, and attendance history.
Rules:
- Only share information about THIS employee. Never reference other employees.
- Be encouraging but honest about attendance issues.
- Format times in 12-hour format (e.g., "9:05 AM").
- If they ask about submitting leave, guide them to use the Leave Request form on the portal.
- If they ask about corrections, guide them to the Correction Request feature.
- Never make up data. If something isn't in the context, say you don't have that information.
- Keep responses concise and friendly.</field>
</record>
<record id="prompt_weekly_narrative" model="fusion.clock.ai.prompt">
<field name="key">weekly_narrative</field>
<field name="name">Weekly Summary Narrative</field>
<field name="feature_category">report</field>
<field name="content">Generate a concise, professional weekly attendance summary for the employee.
Structure:
1. Opening line with overall assessment (strong week / needs attention / concerning patterns)
2. Key metrics in a sentence (total hours, overtime, on-time streak)
3. Notable events (if any: penalties, absences, auto-clock-outs, late arrivals)
4. Positive note or actionable suggestion
Keep it to 3-5 sentences. Professional but warm tone. No bullet points -- write as a short paragraph.</field>
</record>
<record id="prompt_anomaly_detection" model="fusion.clock.ai.prompt">
<field name="key">anomaly_detection</field>
<field name="name">Payroll Anomaly Detection</field>
<field name="feature_category">anomaly</field>
<field name="content">You are a payroll auditor reviewing attendance data before payroll processing.
Flag these anomalies:
- Missing clock-outs without leave requests
- Extremely short shifts (under 2 hours) that might be accidental
- Overtime spikes vs previous periods
- Employees with zero hours but no absence/leave record
- Consecutive auto-clock-outs (employee may not know how to clock out)
- Clock-in/out times that are suspiciously identical day after day (possible buddy punching)
- Weekend/holiday work without overtime classification
For each anomaly found, output:
ANOMALY: [employee name] - [type] - [details] - [recommended action]
If no anomalies found, say "No anomalies detected. Payroll data looks clean."</field>
</record>
<record id="prompt_attendance_coach" model="fusion.clock.ai.prompt">
<field name="key">attendance_coach</field>
<field name="name">Personal Attendance Coach</field>
<field name="feature_category">coach</field>
<field name="content">You are a supportive attendance coach writing a brief personalized tip for an employee.
Based on their recent attendance patterns, write ONE actionable tip (1-2 sentences).
Be specific to their data -- don't give generic advice.
Examples of good tips:
- "You've been arriving 5-10 minutes late on Mondays for 3 weeks. Setting your Monday alarm 15 minutes earlier could protect your 12-day streak."
- "Great consistency this week -- 5 days on time! You're 3 days away from a 20-day streak milestone."
- "You've been auto-clocked-out 3 times this week. Remember to clock out before leaving to avoid needing corrections."
Keep it positive and actionable. Never be punitive.</field>
</record>
<record id="prompt_correction_advisor" model="fusion.clock.ai.prompt">
<field name="key">correction_advisor</field>
<field name="name">Correction Review Advisor</field>
<field name="feature_category">correction</field>
<field name="content">You are an HR advisor reviewing a timesheet correction request.
Provide context to help the manager decide:
1. Is this a one-time request or a recurring pattern?
2. Does the requested time seem reasonable given the employee's typical schedule?
3. Any red flags (e.g., adding hours on a day already marked absent)?
End with: "Recommendation: APPROVE / REVIEW FURTHER / DISCUSS WITH EMPLOYEE"
Be neutral and fact-based. 2-3 sentences max.</field>
</record>
<record id="prompt_understaffing" model="fusion.clock.ai.prompt">
<field name="key">understaffing_prediction</field>
<field name="name">Understaffing Prediction</field>
<field name="feature_category">prediction</field>
<field name="content">Based on historical attendance patterns, predict the likelihood of understaffing for the upcoming week.
Consider:
- Day-of-week absence patterns (e.g., Mondays/Fridays higher)
- Seasonal patterns if data spans multiple months
- Recent absence trends (increasing/decreasing)
- Approved upcoming leaves
For each day of the upcoming week, output:
[Day]: [Risk Level: Low/Medium/High] - [Expected attendance count] / [Total employees] - [Reasoning]
End with one overall recommendation for the manager.</field>
</record>
<record id="prompt_shift_optimization" model="fusion.clock.ai.prompt">
<field name="key">shift_optimization</field>
<field name="name">Shift Optimization</field>
<field name="feature_category">shift</field>
<field name="content">Analyze employee attendance patterns and suggest shift reassignments.
Look for:
- Employees consistently arriving early/late for their assigned shift
- Employees with high penalties who might fit a different shift
- Unbalanced shift coverage (too many on one shift, too few on another)
For each suggestion:
SUGGESTION: Move [employee] from [current shift] to [suggested shift] -- [reasoning based on their clock-in pattern]
Only suggest changes with strong data support. If no changes needed, say so.</field>
</record>
<record id="prompt_compliance" model="fusion.clock.ai.prompt">
<field name="key">compliance_check</field>
<field name="name">Labor Compliance Check</field>
<field name="feature_category">compliance</field>
<field name="content">Review attendance records for potential labor law compliance issues.
Check for:
- Shifts exceeding maximum allowed consecutive hours (flag if > 12h)
- Missing mandatory breaks (shifts > 5h without break deduction)
- Insufficient rest between shifts (less than 8 hours between clock-out and next clock-in)
- Excessive weekly hours (> 48h in a week)
- Minors working outside permitted hours (if age data available)
For each violation:
VIOLATION: [employee] - [type] - [date] - [details] - [regulation reference if known]
If compliant, say "All records are within standard labor compliance thresholds."</field>
</record>
<record id="prompt_smart_config" model="fusion.clock.ai.prompt">
<field name="key">smart_config</field>
<field name="name">Natural Language Configuration</field>
<field name="feature_category">config</field>
<field name="content">You translate natural language policy descriptions into Fusion Clock configuration parameters.
Available parameters:
- fusion_clock.default_clock_in_time (float, 24h format, e.g. 9.0)
- fusion_clock.default_clock_out_time (float, 24h format)
- fusion_clock.default_break_minutes (float, minutes)
- fusion_clock.grace_period_minutes (float)
- fusion_clock.penalty_grace_minutes (float)
- fusion_clock.penalty_deduction_minutes (float)
- fusion_clock.very_late_threshold_minutes (float)
- fusion_clock.max_monthly_absences (integer)
- fusion_clock.daily_overtime_threshold (float, hours)
- fusion_clock.weekly_overtime_threshold (float, hours)
- fusion_clock.max_shift_hours (float, hours)
- fusion_clock.break_threshold_hours (float, hours)
For each change, output JSON:
{"parameter": "key", "value": "new_value", "explanation": "why"}
If the request is ambiguous, ask for clarification instead of guessing.</field>
</record>
<record id="prompt_geofence_tuning" model="fusion.clock.ai.prompt">
<field name="key">geofence_tuning</field>
<field name="name">Geofence Tuning Suggestions</field>
<field name="feature_category">geofence</field>
<field name="content">Analyze clock-in/out distance data for geofenced locations and suggest radius adjustments.
Look for:
- High percentage of clock-ins just outside the radius (radius too tight)
- All clock-ins very close to center (radius could be tightened for security)
- Specific employees consistently outside (individual issue vs location issue)
- Different patterns by time of day
For each location:
[Location Name] (current radius: Xm):
- Clock-ins inside: X%, average distance: Xm
- Clock-ins outside (blocked): X%, average overshoot: Xm
- SUGGESTION: [Keep/Increase to Xm/Decrease to Xm] -- [reasoning]</field>
</record>
<record id="prompt_incident_explanation" model="fusion.clock.ai.prompt">
<field name="key">incident_explanation</field>
<field name="name">Incident Auto-Explanation</field>
<field name="feature_category">incident</field>
<field name="content">Generate a brief, human-readable explanation for an attendance incident.
Given the incident type and context, write 1 sentence explaining what happened and why.
Examples:
- auto_clock_out: "Automatically clocked out at 5:15 PM after the 15-minute grace period expired without a manual clock-out."
- late_clock_in: "Arrived at 9:23 AM, 23 minutes after the 9:00 AM scheduled start. 15-minute penalty applied after 5-minute grace."
- outside_geofence: "Attempted to clock in from 850m away from the Mississauga office (allowed radius: 200m)."
Be factual. Include specific times and numbers from the data.</field>
</record>
<record id="prompt_leave_reason" model="fusion.clock.ai.prompt">
<field name="key">leave_reason_writer</field>
<field name="name">Leave Reason Writer</field>
<field name="feature_category">employee_chat</field>
<field name="content">Rewrite the employee's rough leave/absence explanation into a professional, clear message suitable for their manager.
Rules:
- Keep the original meaning exactly. Do not add or change facts.
- Fix grammar, spelling, and formatting.
- Keep it concise (1-3 sentences).
- Professional but natural tone.
- If the original is already professional, return it unchanged.</field>
</record>
<record id="prompt_log_summary" model="fusion.clock.ai.prompt">
<field name="key">activity_log_summary</field>
<field name="name">Activity Log Summarizer</field>
<field name="feature_category">report</field>
<field name="content">Summarize an employee's activity logs for a given period into a narrative overview.
Structure:
1. Overall pattern (consistent, improving, declining, irregular)
2. Key stats in prose (X clock-ins, Y on-time, Z incidents)
3. Notable patterns (e.g., "Late arrivals concentrated on Mondays")
4. Comparison to previous period if data available
Write 3-5 sentences. Professional, concise, fact-based.</field>
</record>
</odoo>
Task 4: Conversation, Usage, and Cache Models
Files:
- Create:
fusion_clock_ai/models/ai_conversation.py - Create:
fusion_clock_ai/models/ai_usage.py - Create:
fusion_clock_ai/models/ai_cache.py
Step 1: Conversation model (chat history)
# fusion_clock_ai/models/ai_conversation.py
from odoo import models, fields
class FusionClockAIConversation(models.Model):
_name = 'fusion.clock.ai.conversation'
_description = 'AI Chat Conversation'
_order = 'create_date desc'
user_id = fields.Many2one('res.users', required=True, index=True, ondelete='cascade')
conversation_type = fields.Selection([
('manager_query', 'Manager Query'),
('employee_chat', 'Employee Chat'),
('config', 'Configuration'),
], required=True)
message_ids = fields.One2many('fusion.clock.ai.message', 'conversation_id')
title = fields.Char(compute='_compute_title', store=True)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
def _compute_title(self):
for rec in self:
first_msg = rec.message_ids.filtered(lambda m: m.role == 'user')[:1]
if first_msg:
text = first_msg.content[:60]
rec.title = text + ('...' if len(first_msg.content) > 60 else '')
else:
rec.title = f"Conversation #{rec.id}"
class FusionClockAIMessage(models.Model):
_name = 'fusion.clock.ai.message'
_description = 'AI Chat Message'
_order = 'create_date asc'
conversation_id = fields.Many2one('fusion.clock.ai.conversation', required=True,
ondelete='cascade', index=True)
role = fields.Selection([
('system', 'System'),
('user', 'User'),
('assistant', 'Assistant'),
], required=True)
content = fields.Text(required=True)
token_count = fields.Integer()
Step 2: Usage tracking model
# fusion_clock_ai/models/ai_usage.py
from odoo import models, fields
class FusionClockAIUsage(models.Model):
_name = 'fusion.clock.ai.usage'
_description = 'AI Usage Tracking'
_order = 'date desc'
date = fields.Date(required=True, index=True)
feature = fields.Char(required=True, index=True)
model_name = fields.Char(required=True)
prompt_tokens = fields.Integer()
completion_tokens = fields.Integer()
total_tokens = fields.Integer(compute='_compute_total')
estimated_cost_usd = fields.Float(digits=(10, 6))
request_count = fields.Integer(default=1)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
def _compute_total(self):
for rec in self:
rec.total_tokens = rec.prompt_tokens + rec.completion_tokens
Step 3: Response cache model
# fusion_clock_ai/models/ai_cache.py
from odoo import models, fields
class FusionClockAICache(models.Model):
_name = 'fusion.clock.ai.cache'
_description = 'AI Response Cache'
_order = 'created_at desc'
cache_key = fields.Char(required=True, index=True)
prompt_key = fields.Char(index=True)
response_text = fields.Text(required=True)
created_at = fields.Datetime(default=fields.Datetime.now, required=True)
def _gc_expired_cache(self):
"""Cron: delete cache entries older than 24 hours."""
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(hours=24)
self.search([('created_at', '<', cutoff)]).unlink()
Task 5: Settings Model and Views
Files:
- Create:
fusion_clock_ai/models/res_config_settings.py - Create:
fusion_clock_ai/views/res_config_settings_views.xml - Create:
fusion_clock_ai/data/ir_config_parameter_data.xml
Step 1: Settings model
# fusion_clock_ai/models/res_config_settings.py
from odoo import models, fields
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
fclk_ai_openai_api_key = fields.Char(
string='OpenAI API Key',
config_parameter='fusion_clock_ai.openai_api_key',
)
fclk_ai_openai_model = fields.Selection([
('gpt-4o', 'GPT-4o (Best quality, higher cost)'),
('gpt-4o-mini', 'GPT-4o Mini (Good quality, low cost)'),
('gpt-3.5-turbo', 'GPT-3.5 Turbo (Basic, lowest cost)'),
], string='AI Model',
config_parameter='fusion_clock_ai.openai_model',
default='gpt-4o-mini',
)
fclk_ai_max_response_tokens = fields.Integer(
string='Max Response Tokens',
config_parameter='fusion_clock_ai.max_response_tokens',
default=1024,
)
fclk_ai_monthly_budget_usd = fields.Float(
string='Monthly Budget (USD)',
config_parameter='fusion_clock_ai.monthly_budget_usd',
default=50.0,
help="Set to 0 for unlimited. AI requests stop when budget is reached.",
)
fclk_ai_cache_ttl_minutes = fields.Integer(
string='Cache TTL (minutes)',
config_parameter='fusion_clock_ai.cache_ttl_minutes',
default=15,
)
fclk_ai_enable_manager_chat = fields.Boolean(
string='Enable Manager AI Chat',
config_parameter='fusion_clock_ai.enable_manager_chat',
default=True,
)
fclk_ai_enable_employee_chat = fields.Boolean(
string='Enable Employee AI Assistant',
config_parameter='fusion_clock_ai.enable_employee_chat',
default=True,
)
fclk_ai_enable_narrative_reports = fields.Boolean(
string='Enable AI Narrative Reports',
config_parameter='fusion_clock_ai.enable_narrative_reports',
default=True,
)
fclk_ai_enable_anomaly_detection = fields.Boolean(
string='Enable Payroll Anomaly Detection',
config_parameter='fusion_clock_ai.enable_anomaly_detection',
default=True,
)
fclk_ai_enable_coach = fields.Boolean(
string='Enable Attendance Coach',
config_parameter='fusion_clock_ai.enable_coach',
default=True,
)
fclk_ai_enable_correction_advisor = fields.Boolean(
string='Enable Correction Review Advisor',
config_parameter='fusion_clock_ai.enable_correction_advisor',
default=True,
)
fclk_ai_enable_predictions = fields.Boolean(
string='Enable Predictive Alerts',
config_parameter='fusion_clock_ai.enable_predictions',
default=False,
)
fclk_ai_enable_shift_suggestions = fields.Boolean(
string='Enable Shift Optimization',
config_parameter='fusion_clock_ai.enable_shift_suggestions',
default=False,
)
fclk_ai_enable_compliance = fields.Boolean(
string='Enable Compliance Checks',
config_parameter='fusion_clock_ai.enable_compliance',
default=False,
)
fclk_ai_enable_smart_config = fields.Boolean(
string='Enable Natural Language Config',
config_parameter='fusion_clock_ai.enable_smart_config',
default=False,
)
fclk_ai_enable_geofence_tuning = fields.Boolean(
string='Enable Geofence Tuning',
config_parameter='fusion_clock_ai.enable_geofence_tuning',
default=False,
)
fclk_ai_enable_incident_explain = fields.Boolean(
string='Enable Incident Auto-Explain',
config_parameter='fusion_clock_ai.enable_incident_explain',
default=True,
)
Step 2: Settings view (inherits fusion_clock settings)
<!-- fusion_clock_ai/views/res_config_settings_views.xml -->
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_ai" model="ir.ui.view">
<field name="name">res.config.settings.form.fusion.clock.ai</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="fusion_clock.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[hasclass('app_settings_block')]" position="inside">
<h2>AI Settings</h2>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="fclk_ai_openai_api_key"/>
<field name="fclk_ai_openai_api_key" password="True"/>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="fclk_ai_openai_model"/>
<field name="fclk_ai_openai_model"/>
</div>
</div>
</div>
<div class="row mt8 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="fclk_ai_monthly_budget_usd"/>
<field name="fclk_ai_monthly_budget_usd"/>
<div class="text-muted">Set to 0 for unlimited.</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="fclk_ai_max_response_tokens"/>
<field name="fclk_ai_max_response_tokens"/>
</div>
</div>
</div>
<div class="row mt8 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="fclk_ai_cache_ttl_minutes"/>
<field name="fclk_ai_cache_ttl_minutes"/>
</div>
</div>
</div>
<h3 class="mt16">Feature Toggles</h3>
<div class="row mt8 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fclk_ai_enable_manager_chat"/>
</div>
<div class="o_setting_right_pane">
<label for="fclk_ai_enable_manager_chat"/>
<div class="text-muted">Natural language queries for managers</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fclk_ai_enable_employee_chat"/>
</div>
<div class="o_setting_right_pane">
<label for="fclk_ai_enable_employee_chat"/>
<div class="text-muted">AI assistant on employee portal</div>
</div>
</div>
</div>
<div class="row mt8 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fclk_ai_enable_narrative_reports"/>
</div>
<div class="o_setting_right_pane">
<label for="fclk_ai_enable_narrative_reports"/>
<div class="text-muted">AI-written summaries in weekly emails and reports</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fclk_ai_enable_anomaly_detection"/>
</div>
<div class="o_setting_right_pane">
<label for="fclk_ai_enable_anomaly_detection"/>
<div class="text-muted">Flag suspicious patterns before payroll</div>
</div>
</div>
</div>
<div class="row mt8 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fclk_ai_enable_coach"/>
</div>
<div class="o_setting_right_pane">
<label for="fclk_ai_enable_coach"/>
<div class="text-muted">Personalized weekly attendance tips</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fclk_ai_enable_correction_advisor"/>
</div>
<div class="o_setting_right_pane">
<label for="fclk_ai_enable_correction_advisor"/>
<div class="text-muted">AI context on correction approval screens</div>
</div>
</div>
</div>
<div class="row mt8 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fclk_ai_enable_predictions"/>
</div>
<div class="o_setting_right_pane">
<label for="fclk_ai_enable_predictions"/>
<div class="text-muted">Predict understaffing risk by day</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fclk_ai_enable_shift_suggestions"/>
</div>
<div class="o_setting_right_pane">
<label for="fclk_ai_enable_shift_suggestions"/>
<div class="text-muted">Suggest shift reassignments from patterns</div>
</div>
</div>
</div>
<div class="row mt8 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fclk_ai_enable_compliance"/>
</div>
<div class="o_setting_right_pane">
<label for="fclk_ai_enable_compliance"/>
<div class="text-muted">Check for labor law violations</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fclk_ai_enable_smart_config"/>
</div>
<div class="o_setting_right_pane">
<label for="fclk_ai_enable_smart_config"/>
<div class="text-muted">Describe policies in English, AI maps to settings</div>
</div>
</div>
</div>
<div class="row mt8 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fclk_ai_enable_geofence_tuning"/>
</div>
<div class="o_setting_right_pane">
<label for="fclk_ai_enable_geofence_tuning"/>
<div class="text-muted">AI suggests geofence radius adjustments</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fclk_ai_enable_incident_explain"/>
</div>
<div class="o_setting_right_pane">
<label for="fclk_ai_enable_incident_explain"/>
<div class="text-muted">Auto-generate human-readable incident descriptions</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>
Step 3: Default config parameters
<!-- fusion_clock_ai/data/ir_config_parameter_data.xml -->
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="param_openai_model" model="ir.config_parameter">
<field name="key">fusion_clock_ai.openai_model</field>
<field name="value">gpt-4o-mini</field>
</record>
<record id="param_max_tokens" model="ir.config_parameter">
<field name="key">fusion_clock_ai.max_response_tokens</field>
<field name="value">1024</field>
</record>
<record id="param_monthly_budget" model="ir.config_parameter">
<field name="key">fusion_clock_ai.monthly_budget_usd</field>
<field name="value">50.0</field>
</record>
<record id="param_cache_ttl" model="ir.config_parameter">
<field name="key">fusion_clock_ai.cache_ttl_minutes</field>
<field name="value">15</field>
</record>
</odoo>
Task 6: Security (Groups, Access Rights, Record Rules)
Files:
- Create:
fusion_clock_ai/security/security.xml - Create:
fusion_clock_ai/security/ir.model.access.csv
Step 1: Security groups and record rules
<!-- fusion_clock_ai/security/security.xml -->
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- AI conversations: users see own, managers see all -->
<record id="rule_ai_conversation_user" model="ir.rule">
<field name="name">AI Conversation: User sees own</field>
<field name="model_id" ref="model_fusion_clock_ai_conversation"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('fusion_clock.group_fusion_clock_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_ai_conversation_manager" model="ir.rule">
<field name="name">AI Conversation: Manager sees all</field>
<field name="model_id" ref="model_fusion_clock_ai_conversation"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('fusion_clock.group_fusion_clock_manager'))]"/>
</record>
<record id="rule_ai_message_user" model="ir.rule">
<field name="name">AI Message: User sees own conversation messages</field>
<field name="model_id" ref="model_fusion_clock_ai_message"/>
<field name="domain_force">[('conversation_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('fusion_clock.group_fusion_clock_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_ai_message_manager" model="ir.rule">
<field name="name">AI Message: Manager sees all</field>
<field name="model_id" ref="model_fusion_clock_ai_message"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('fusion_clock.group_fusion_clock_manager'))]"/>
</record>
<!-- Usage and cache: manager only -->
<record id="rule_ai_usage_manager" model="ir.rule">
<field name="name">AI Usage: Manager only</field>
<field name="model_id" ref="model_fusion_clock_ai_usage"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('fusion_clock.group_fusion_clock_manager'))]"/>
</record>
<!-- Prompts: manager only -->
<record id="rule_ai_prompt_manager" model="ir.rule">
<field name="name">AI Prompts: Manager only</field>
<field name="model_id" ref="model_fusion_clock_ai_prompt"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('fusion_clock.group_fusion_clock_manager'))]"/>
</record>
<!-- Portal rules -->
<record id="rule_ai_conversation_portal" model="ir.rule">
<field name="name">AI Conversation: Portal sees own</field>
<field name="model_id" ref="model_fusion_clock_ai_conversation"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_ai_message_portal" model="ir.rule">
<field name="name">AI Message: Portal sees own</field>
<field name="model_id" ref="model_fusion_clock_ai_message"/>
<field name="domain_force">[('conversation_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>
Step 2: Access rights CSV
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_ai_prompt_manager,fusion.clock.ai.prompt.manager,model_fusion_clock_ai_prompt,fusion_clock.group_fusion_clock_manager,1,1,1,1
access_ai_conversation_user,fusion.clock.ai.conversation.user,model_fusion_clock_ai_conversation,fusion_clock.group_fusion_clock_user,1,1,1,1
access_ai_conversation_manager,fusion.clock.ai.conversation.manager,model_fusion_clock_ai_conversation,fusion_clock.group_fusion_clock_manager,1,1,1,1
access_ai_message_user,fusion.clock.ai.message.user,model_fusion_clock_ai_message,fusion_clock.group_fusion_clock_user,1,1,1,0
access_ai_message_manager,fusion.clock.ai.message.manager,model_fusion_clock_ai_message,fusion_clock.group_fusion_clock_manager,1,1,1,1
access_ai_usage_manager,fusion.clock.ai.usage.manager,model_fusion_clock_ai_usage,fusion_clock.group_fusion_clock_manager,1,1,1,1
access_ai_cache_manager,fusion.clock.ai.cache.manager,model_fusion_clock_ai_cache,fusion_clock.group_fusion_clock_manager,1,1,1,1
access_ai_conversation_portal,fusion.clock.ai.conversation.portal,model_fusion_clock_ai_conversation,base.group_portal,1,0,1,0
access_ai_message_portal,fusion.clock.ai.message.portal,model_fusion_clock_ai_message,base.group_portal,1,0,1,0
Task 7: Backend Manager Chat (Controller + OWL Component)
Files:
- Create:
fusion_clock_ai/controllers/ai_api.py - Create:
fusion_clock_ai/static/src/js/ai_chat_backend.js - Create:
fusion_clock_ai/static/src/xml/ai_chat_backend.xml - Create:
fusion_clock_ai/static/src/scss/fusion_clock_ai.scss
This is the manager's natural language dashboard. An OWL chat panel (similar to the existing systray FAB) where managers type questions like "Who had the most overtime this week?" and get AI-powered answers.
Step 1: Backend API controller
# fusion_clock_ai/controllers/ai_api.py
import json
import hashlib
import logging
from datetime import datetime, timedelta, date
from odoo import http, fields as odoo_fields, _
from odoo.http import request
_logger = logging.getLogger(__name__)
class FusionClockAIAPI(http.Controller):
def _get_employee(self):
return request.env['hr.employee'].sudo().search([
('user_id', '=', request.env.uid),
], limit=1)
@http.route('/fusion_clock_ai/manager_chat', type='json', auth='user')
def manager_chat(self, message, conversation_id=None, date_from=None, date_to=None):
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'Access denied. Manager role required.'}
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock_ai.enable_manager_chat', 'True') != 'True':
return {'error': 'Manager AI Chat is disabled in settings.'}
AI = request.env['fusion.clock.ai.service'].sudo()
if not date_from:
date_from = (date.today() - timedelta(days=7)).isoformat()
if not date_to:
date_to = date.today().isoformat()
d_from = date.fromisoformat(date_from)
d_to = date.fromisoformat(date_to)
employees = request.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True),
('company_id', '=', request.env.company.id),
])
team_context = AI._build_team_context(employees, d_from, d_to)
Conversation = request.env['fusion.clock.ai.conversation'].sudo()
if conversation_id:
conv = Conversation.browse(int(conversation_id))
if not conv.exists() or conv.user_id.id != user.id:
conv = Conversation.create({
'user_id': user.id,
'conversation_type': 'manager_query',
})
else:
conv = Conversation.create({
'user_id': user.id,
'conversation_type': 'manager_query',
})
Message = request.env['fusion.clock.ai.message'].sudo()
Message.create({
'conversation_id': conv.id,
'role': 'user',
'content': message,
})
system_prompt = AI._get_system_prompt('manager_query')
messages = [
{'role': 'system', 'content': f"{system_prompt}\n\n--- ATTENDANCE DATA ---\n{team_context}"},
]
for msg in conv.message_ids:
messages.append({'role': msg.role, 'content': msg.content})
try:
response = AI.chat_completion(messages, feature='manager_query')
except Exception as e:
return {'error': str(e)}
Message.create({
'conversation_id': conv.id,
'role': 'assistant',
'content': response,
})
return {
'response': response,
'conversation_id': conv.id,
}
@http.route('/fusion_clock_ai/run_analysis', type='json', auth='user')
def run_analysis(self, analysis_type, date_from=None, date_to=None, employee_id=None):
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'Access denied.'}
AI = request.env['fusion.clock.ai.service'].sudo()
if not date_from:
date_from = (date.today() - timedelta(days=7)).isoformat()
if not date_to:
date_to = date.today().isoformat()
d_from = date.fromisoformat(date_from)
d_to = date.fromisoformat(date_to)
prompt_map = {
'anomaly': 'anomaly_detection',
'understaffing': 'understaffing_prediction',
'shift': 'shift_optimization',
'compliance': 'compliance_check',
'geofence': 'geofence_tuning',
}
prompt_key = prompt_map.get(analysis_type)
if not prompt_key:
return {'error': f'Unknown analysis type: {analysis_type}'}
employees = request.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True),
('company_id', '=', request.env.company.id),
])
context_data = AI._build_team_context(employees, d_from, d_to)
if analysis_type == 'geofence':
context_data += "\n\n" + self._build_geofence_context(d_from, d_to)
system_prompt = AI._get_system_prompt(prompt_key)
cache_key = AI._get_cache_key(prompt_key, hashlib.sha256(
f"{d_from}:{d_to}:{request.env.company.id}".encode()
).hexdigest())
try:
response = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': context_data},
],
feature=analysis_type,
cache_key=cache_key,
)
except Exception as e:
return {'error': str(e)}
return {'response': response}
def _build_geofence_context(self, date_from, date_to):
Log = request.env['fusion.clock.activity.log'].sudo()
locations = request.env['fusion.clock.location'].sudo().search([
('active', '=', True),
('company_id', '=', request.env.company.id),
])
lines = ["Geofence Data:"]
for loc in locations:
clock_ins = Log.search([
('location_id', '=', loc.id),
('log_type', '=', 'clock_in'),
('log_date', '>=', datetime.combine(date_from, datetime.min.time())),
('log_date', '<=', datetime.combine(date_to, datetime.max.time())),
])
outside = Log.search([
('location_id', '=', loc.id),
('log_type', '=', 'outside_geofence'),
('log_date', '>=', datetime.combine(date_from, datetime.min.time())),
('log_date', '<=', datetime.combine(date_to, datetime.max.time())),
])
distances = [l.distance for l in clock_ins if l.distance]
avg_dist = sum(distances) / len(distances) if distances else 0
outside_distances = [l.distance for l in outside if l.distance]
avg_overshoot = sum(outside_distances) / len(outside_distances) if outside_distances else 0
lines.append(
f"\n{loc.name} (radius: {loc.radius}m):\n"
f" Successful clock-ins: {len(clock_ins)}, avg distance: {avg_dist:.0f}m\n"
f" Blocked (outside): {len(outside)}, avg overshoot: {avg_overshoot:.0f}m"
)
return "\n".join(lines)
@http.route('/fusion_clock_ai/smart_config', type='json', auth='user')
def smart_config(self, description):
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'Access denied.'}
AI = request.env['fusion.clock.ai.service'].sudo()
system_prompt = AI._get_system_prompt('smart_config')
try:
response = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': description},
],
feature='smart_config',
)
except Exception as e:
return {'error': str(e)}
return {'response': response}
@http.route('/fusion_clock_ai/apply_config', type='json', auth='user')
def apply_config(self, changes):
user = request.env.user
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
return {'error': 'Access denied.'}
ICP = request.env['ir.config_parameter'].sudo()
applied = []
for change in changes:
param = change.get('parameter', '')
value = change.get('value', '')
if param.startswith('fusion_clock.'):
ICP.set_param(param, str(value))
applied.append(param)
return {'applied': applied, 'count': len(applied)}
Step 2: OWL chat component (JS)
/* fusion_clock_ai/static/src/js/ai_chat_backend.js */
/** @odoo-module **/
import { Component, useState, useRef, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { rpc } from "@web/core/network/rpc";
class FusionClockAIChat extends Component {
static template = "fusion_clock_ai.AIChat";
setup() {
this.notification = useService("notification");
this.chatBody = useRef("chatBody");
this.state = useState({
open: false,
messages: [],
input: "",
loading: false,
conversationId: null,
activeTab: "chat",
analysisResult: "",
analysisLoading: false,
});
}
togglePanel() {
this.state.open = !this.state.open;
}
async sendMessage() {
const text = this.state.input.trim();
if (!text || this.state.loading) return;
this.state.messages.push({ role: "user", content: text });
this.state.input = "";
this.state.loading = true;
this._scrollToBottom();
try {
const result = await rpc("/fusion_clock_ai/manager_chat", {
message: text,
conversation_id: this.state.conversationId,
});
if (result.error) {
this.state.messages.push({ role: "assistant", content: `Error: ${result.error}` });
} else {
this.state.messages.push({ role: "assistant", content: result.response });
this.state.conversationId = result.conversation_id;
}
} catch (e) {
this.state.messages.push({ role: "assistant", content: "Failed to reach AI service." });
}
this.state.loading = false;
this._scrollToBottom();
}
async runAnalysis(type) {
this.state.analysisLoading = true;
this.state.analysisResult = "";
try {
const result = await rpc("/fusion_clock_ai/run_analysis", {
analysis_type: type,
});
this.state.analysisResult = result.error || result.response;
} catch {
this.state.analysisResult = "Failed to run analysis.";
}
this.state.analysisLoading = false;
}
onKeyDown(ev) {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();
this.sendMessage();
}
}
newConversation() {
this.state.messages = [];
this.state.conversationId = null;
}
setTab(tab) {
this.state.activeTab = tab;
}
_scrollToBottom() {
requestAnimationFrame(() => {
const el = this.chatBody.el;
if (el) el.scrollTop = el.scrollHeight;
});
}
}
registry.category("actions").add("fusion_clock_ai.AIChat", FusionClockAIChat);
registry.category("systray").add("fusion_clock_ai.AIChatSystray", {
Component: FusionClockAIChat,
isDisplayed: (env) => true,
}, { sequence: 5 });
Step 3: OWL template (XML)
<!-- fusion_clock_ai/static/src/xml/ai_chat_backend.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_clock_ai.AIChat">
<div class="fclk-ai-wrapper">
<!-- FAB Button -->
<button class="fclk-ai-fab" t-on-click="togglePanel"
t-attf-class="fclk-ai-fab #{state.open ? 'fclk-ai-fab--active' : ''}">
<i class="fa fa-magic"/>
</button>
<!-- Panel -->
<div t-if="state.open" class="fclk-ai-panel">
<div class="fclk-ai-panel-header">
<span class="fclk-ai-panel-title">Fusion Clock AI</span>
<div class="fclk-ai-tabs">
<button t-attf-class="fclk-ai-tab #{state.activeTab === 'chat' ? 'active' : ''}"
t-on-click="() => this.setTab('chat')">Chat</button>
<button t-attf-class="fclk-ai-tab #{state.activeTab === 'tools' ? 'active' : ''}"
t-on-click="() => this.setTab('tools')">Tools</button>
</div>
<button class="fclk-ai-close" t-on-click="togglePanel">
<i class="fa fa-times"/>
</button>
</div>
<!-- Chat Tab -->
<div t-if="state.activeTab === 'chat'" class="fclk-ai-chat-body" t-ref="chatBody">
<div t-if="!state.messages.length" class="fclk-ai-empty">
<i class="fa fa-comments-o fclk-ai-empty-icon"/>
<p>Ask me anything about your team's attendance.</p>
<div class="fclk-ai-suggestions">
<button class="fclk-ai-suggestion"
t-on-click="() => { this.state.input = 'Who had the most overtime this week?'; this.sendMessage(); }">
Who had the most overtime?
</button>
<button class="fclk-ai-suggestion"
t-on-click="() => { this.state.input = 'Show me employees with attendance issues'; this.sendMessage(); }">
Attendance issues
</button>
<button class="fclk-ai-suggestion"
t-on-click="() => { this.state.input = 'Summarize this week\\'s attendance'; this.sendMessage(); }">
Weekly summary
</button>
</div>
</div>
<t t-foreach="state.messages" t-as="msg" t-key="msg_index">
<div t-attf-class="fclk-ai-msg fclk-ai-msg--#{msg.role}">
<div class="fclk-ai-msg-avatar">
<i t-if="msg.role === 'user'" class="fa fa-user"/>
<i t-else="" class="fa fa-magic"/>
</div>
<div class="fclk-ai-msg-content">
<t t-out="msg.content"/>
</div>
</div>
</t>
<div t-if="state.loading" class="fclk-ai-msg fclk-ai-msg--assistant">
<div class="fclk-ai-msg-avatar"><i class="fa fa-magic"/></div>
<div class="fclk-ai-msg-content fclk-ai-typing">
<span/><span/><span/>
</div>
</div>
</div>
<!-- Tools Tab -->
<div t-if="state.activeTab === 'tools'" class="fclk-ai-tools-body">
<div class="fclk-ai-tool-grid">
<button class="fclk-ai-tool-btn" t-on-click="() => this.runAnalysis('anomaly')">
<i class="fa fa-exclamation-triangle"/>
<span>Anomaly Scan</span>
</button>
<button class="fclk-ai-tool-btn" t-on-click="() => this.runAnalysis('understaffing')">
<i class="fa fa-users"/>
<span>Staffing Forecast</span>
</button>
<button class="fclk-ai-tool-btn" t-on-click="() => this.runAnalysis('shift')">
<i class="fa fa-exchange"/>
<span>Shift Optimizer</span>
</button>
<button class="fclk-ai-tool-btn" t-on-click="() => this.runAnalysis('compliance')">
<i class="fa fa-gavel"/>
<span>Compliance Check</span>
</button>
<button class="fclk-ai-tool-btn" t-on-click="() => this.runAnalysis('geofence')">
<i class="fa fa-map-marker"/>
<span>Geofence Tuning</span>
</button>
</div>
<div t-if="state.analysisLoading" class="fclk-ai-analysis-loading">
<i class="fa fa-spinner fa-spin"/> Running analysis...
</div>
<div t-if="state.analysisResult" class="fclk-ai-analysis-result">
<pre t-out="state.analysisResult"/>
</div>
</div>
<!-- Chat Input -->
<div t-if="state.activeTab === 'chat'" class="fclk-ai-chat-input">
<button class="fclk-ai-new-chat" t-on-click="newConversation" title="New conversation">
<i class="fa fa-plus"/>
</button>
<textarea t-model="state.input" t-on-keydown="onKeyDown"
placeholder="Ask about attendance..." rows="1"/>
<button class="fclk-ai-send" t-on-click="sendMessage"
t-att-disabled="state.loading || !state.input.trim()">
<i class="fa fa-paper-plane"/>
</button>
</div>
</div>
</div>
</t>
</templates>
Step 4: SCSS styles
The SCSS file should provide a modern chat panel that uses Odoo's CSS variables for theme awareness (dark/light mode). The panel attaches to bottom-right, similar to the existing FAB but for AI. Includes typing animation, message bubbles, suggestion chips, and tool grid. File: fusion_clock_ai/static/src/scss/fusion_clock_ai.scss
Task 8: Portal Employee Chatbot
Files:
- Create:
fusion_clock_ai/controllers/portal_ai.py - Create:
fusion_clock_ai/static/src/js/ai_chat_portal.js - Create:
fusion_clock_ai/static/src/css/portal_ai.css - Create:
fusion_clock_ai/views/portal_ai_templates.xml
The employee chatbot on the /my/clock page. Uses the Interaction pattern (same as existing portal JS). Injects a chat bubble that employees can tap to ask about their hours, request help with leave, or get their attendance coach tip.
Step 1: Portal AI controller
# fusion_clock_ai/controllers/portal_ai.py
import logging
from datetime import datetime, timedelta, date
from odoo import http, _
from odoo.http import request
_logger = logging.getLogger(__name__)
class FusionClockPortalAI(http.Controller):
def _get_employee(self):
return request.env['hr.employee'].sudo().search([
('user_id', '=', request.env.uid),
], limit=1)
@http.route('/fusion_clock_ai/employee_chat', type='json', auth='user')
def employee_chat(self, message, conversation_id=None):
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock_ai.enable_employee_chat', 'True') != 'True':
return {'error': 'AI Assistant is currently disabled.'}
employee = self._get_employee()
if not employee:
return {'error': 'No employee record found.'}
AI = request.env['fusion.clock.ai.service'].sudo()
context_data = AI._build_employee_context(
employee,
date.today() - timedelta(days=30),
date.today(),
)
Conversation = request.env['fusion.clock.ai.conversation'].sudo()
if conversation_id:
conv = Conversation.browse(int(conversation_id))
if not conv.exists() or conv.user_id.id != request.env.uid:
conv = Conversation.create({
'user_id': request.env.uid,
'conversation_type': 'employee_chat',
})
else:
conv = Conversation.create({
'user_id': request.env.uid,
'conversation_type': 'employee_chat',
})
Message = request.env['fusion.clock.ai.message'].sudo()
Message.create({
'conversation_id': conv.id,
'role': 'user',
'content': message,
})
system_prompt = AI._get_system_prompt('employee_chat')
messages = [
{'role': 'system', 'content': f"{system_prompt}\n\n--- YOUR ATTENDANCE DATA ---\n{context_data}"},
]
for msg in conv.message_ids:
messages.append({'role': msg.role, 'content': msg.content})
try:
response = AI.chat_completion(messages, feature='employee_chat')
except Exception as e:
return {'error': str(e)}
Message.create({
'conversation_id': conv.id,
'role': 'assistant',
'content': response,
})
return {
'response': response,
'conversation_id': conv.id,
}
@http.route('/fusion_clock_ai/polish_reason', type='json', auth='user')
def polish_reason(self, rough_text):
AI = request.env['fusion.clock.ai.service'].sudo()
system_prompt = AI._get_system_prompt('leave_reason_writer')
try:
response = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': rough_text},
],
feature='leave_reason_writer',
)
except Exception as e:
return {'error': str(e)}
return {'polished': response}
@http.route('/fusion_clock_ai/my_coach_tip', type='json', auth='user')
def my_coach_tip(self):
employee = self._get_employee()
if not employee:
return {'error': 'No employee record found.'}
AI = request.env['fusion.clock.ai.service'].sudo()
context_data = AI._build_employee_context(
employee,
date.today() - timedelta(days=14),
date.today(),
)
system_prompt = AI._get_system_prompt('attendance_coach')
try:
response = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': context_data},
],
feature='attendance_coach',
)
except Exception as e:
return {'error': str(e)}
return {'tip': response}
Task 9: Model Inheritance for AI Features
Files:
- Create:
fusion_clock_ai/models/hr_employee_ai.py(AI summary on employee profile) - Create:
fusion_clock_ai/models/hr_attendance_ai.py(incident auto-explain hook) - Create:
fusion_clock_ai/models/clock_report_ai.py(AI narrative on reports) - Create:
fusion_clock_ai/models/clock_correction_ai.py(AI advisor on corrections)
Step 1: Employee AI summary
# fusion_clock_ai/models/hr_employee_ai.py
import logging
from datetime import date, timedelta
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class HrEmployeeAI(models.Model):
_inherit = 'hr.employee'
x_fclk_ai_summary = fields.Text(string='AI Attendance Summary', readonly=True)
x_fclk_ai_summary_date = fields.Date(string='Summary Generated', readonly=True)
x_fclk_ai_coach_tip = fields.Text(string='AI Coach Tip', readonly=True)
def action_generate_ai_summary(self):
AI = self.env['fusion.clock.ai.service'].sudo()
for emp in self:
context_data = AI._build_employee_context(
emp, date.today() - timedelta(days=30), date.today()
)
system_prompt = AI._get_system_prompt('activity_log_summary')
try:
summary = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': context_data},
],
feature='log_summary',
)
emp.write({
'x_fclk_ai_summary': summary,
'x_fclk_ai_summary_date': date.today(),
})
except Exception as e:
_logger.warning("Failed to generate AI summary for %s: %s", emp.name, e)
def action_generate_coach_tip(self):
AI = self.env['fusion.clock.ai.service'].sudo()
for emp in self:
context_data = AI._build_employee_context(
emp, date.today() - timedelta(days=14), date.today()
)
system_prompt = AI._get_system_prompt('attendance_coach')
try:
tip = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': context_data},
],
feature='attendance_coach',
)
emp.write({'x_fclk_ai_coach_tip': tip})
except Exception as e:
_logger.warning("Failed to generate coach tip for %s: %s", emp.name, e)
Step 2: Attendance AI hooks (incident auto-explain)
# fusion_clock_ai/models/hr_attendance_ai.py
import logging
from odoo import models, api
_logger = logging.getLogger(__name__)
class HrAttendanceAI(models.Model):
_inherit = 'hr.attendance'
def _ai_explain_incident(self, log_record):
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock_ai.enable_incident_explain', 'True') != 'True':
return
AI = self.env['fusion.clock.ai.service'].sudo()
system_prompt = AI._get_system_prompt('incident_explanation')
context = (
f"Incident type: {log_record.log_type}\n"
f"Employee: {log_record.employee_id.name}\n"
f"Date/Time: {log_record.log_date}\n"
f"Location: {log_record.location_id.name or 'N/A'}\n"
f"Distance: {log_record.distance or 0:.0f}m\n"
f"Current description: {log_record.description or 'None'}\n"
)
shift = log_record.employee_id.x_fclk_shift_id
if shift:
context += f"Shift: {shift.name} ({shift.start_time:.1f}-{shift.end_time:.1f})\n"
try:
explanation = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': context},
],
feature='incident_explain',
)
log_record.sudo().write({'description': explanation})
except Exception as e:
_logger.debug("AI incident explain skipped: %s", e)
Step 3: Report AI narrative
# fusion_clock_ai/models/clock_report_ai.py
import logging
from odoo import models, fields
_logger = logging.getLogger(__name__)
class ClockReportAI(models.Model):
_inherit = 'fusion.clock.report'
x_fclk_ai_narrative = fields.Text(string='AI Narrative', readonly=True)
def action_generate_ai_narrative(self):
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock_ai.enable_narrative_reports', 'True') != 'True':
return
AI = self.env['fusion.clock.ai.service'].sudo()
for report in self:
context_data = AI._build_payroll_context(report)
system_prompt = AI._get_system_prompt('weekly_narrative')
try:
narrative = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': context_data},
],
feature='report_narrative',
)
report.write({'x_fclk_ai_narrative': narrative})
except Exception as e:
_logger.warning("AI narrative failed for report %s: %s", report.name, e)
def action_generate_report(self):
result = super().action_generate_report()
self.action_generate_ai_narrative()
return result
Step 4: Correction AI advisor
# fusion_clock_ai/models/clock_correction_ai.py
import logging
from datetime import date, timedelta
from odoo import models, fields
_logger = logging.getLogger(__name__)
class ClockCorrectionAI(models.Model):
_inherit = 'fusion.clock.correction'
x_fclk_ai_advice = fields.Text(string='AI Review Advice', readonly=True)
def action_get_ai_advice(self):
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock_ai.enable_correction_advisor', 'True') != 'True':
return
AI = self.env['fusion.clock.ai.service'].sudo()
for correction in self:
emp = correction.employee_id
emp_context = AI._build_employee_context(
emp, date.today() - timedelta(days=30), date.today()
)
correction_context = (
f"Correction Request:\n"
f"Employee: {emp.name}\n"
f"Original check-in: {correction.original_check_in}\n"
f"Original check-out: {correction.original_check_out}\n"
f"Requested check-in: {correction.requested_check_in}\n"
f"Requested check-out: {correction.requested_check_out}\n"
f"Reason: {correction.reason}\n"
f"State: {correction.state}\n"
f"\nEmployee history:\n{emp_context}"
)
system_prompt = AI._get_system_prompt('correction_advisor')
try:
advice = AI.chat_completion(
[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': correction_context},
],
feature='correction_advisor',
)
correction.write({'x_fclk_ai_advice': advice})
except Exception as e:
_logger.warning("AI advice failed for correction %s: %s", correction.id, e)
Task 10: Views for AI Features
Files:
- Create:
fusion_clock_ai/views/hr_employee_views.xml - Create:
fusion_clock_ai/views/clock_report_views.xml - Create:
fusion_clock_ai/views/clock_correction_views.xml - Create:
fusion_clock_ai/views/ai_conversation_views.xml - Create:
fusion_clock_ai/views/ai_usage_views.xml - Create:
fusion_clock_ai/views/ai_prompt_views.xml - Create:
fusion_clock_ai/views/ai_menus.xml - Create:
fusion_clock_ai/views/portal_ai_templates.xml
These views add:
- "AI Insights" tab on employee profile with summary, coach tip, and generate buttons
- AI narrative field on payroll reports
- "Get AI Advice" button on correction requests
- List/form views for conversations, usage, and prompt templates
- Menu items under Fusion Clock > AI
- Portal template that injects the chatbot widget onto
/my/clock
Task 11: Cron Jobs
Files:
- Create:
fusion_clock_ai/data/ir_cron_data.xml
<!-- fusion_clock_ai/data/ir_cron_data.xml -->
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Weekly: Generate AI coach tips for all employees -->
<record id="cron_ai_coach_tips" model="ir.cron">
<field name="name">Fusion Clock AI: Weekly Coach Tips</field>
<field name="model_id" ref="hr.model_hr_employee"/>
<field name="state">code</field>
<field name="code">
employees = env['hr.employee'].search([('x_fclk_enable_clock', '=', True)])
employees.action_generate_coach_tip()
</field>
<field name="interval_number">1</field>
<field name="interval_type">weeks</field>
<field name="numbercall">-1</field>
<field name="active" eval="True"/>
</record>
<!-- Daily: Clean expired AI cache -->
<record id="cron_ai_cache_cleanup" model="ir.cron">
<field name="name">Fusion Clock AI: Cache Cleanup</field>
<field name="model_id" ref="model_fusion_clock_ai_cache"/>
<field name="state">code</field>
<field name="code">model._gc_expired_cache()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active" eval="True"/>
</record>
<!-- Daily: Generate AI summaries for all employees -->
<record id="cron_ai_employee_summaries" model="ir.cron">
<field name="name">Fusion Clock AI: Daily Employee Summaries</field>
<field name="model_id" ref="hr.model_hr_employee"/>
<field name="state">code</field>
<field name="code">
employees = env['hr.employee'].search([('x_fclk_enable_clock', '=', True)])
employees.action_generate_ai_summary()
</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active" eval="False"/>
</record>
</odoo>
Summary of All Tasks
| # | Task | Phase | Files |
|---|---|---|---|
| 1 | Module scaffold and manifest | 1 | 4 files |
| 2 | Core AI service model | 1 | 1 file |
| 3 | Prompt template model + defaults | 1 | 2 files |
| 4 | Conversation, usage, cache models | 1 | 3 files |
| 5 | Settings model and views | 1 | 3 files |
| 6 | Security (groups, ACL, rules) | 1 | 2 files |
| 7 | Backend manager chat (controller + OWL) | 1 | 4 files |
| 8 | Portal employee chatbot | 2 | 4 files |
| 9 | Model inheritance (employee, attendance, report, correction) | 2 | 4 files |
| 10 | Views for all AI features | 2 | 8 files |
| 11 | Cron jobs | 3 | 1 file |
Total: ~36 new files, 0 modified files from fusion_clock.
Deployment Notes
- Install the
openaiPython package in the Odoo Docker container:pip install openai - After installing the module, configure the OpenAI API key in Fusion Clock > Configuration > Settings > AI Settings
- Start with
gpt-4o-minimodel for cost efficiency; upgrade togpt-4oif response quality needs improvement - Monitor token usage via Fusion Clock > AI > Usage dashboard
- Default monthly budget is $50 USD -- adjust based on employee count and usage patterns