update
This commit is contained in:
14
fusion_clock_ai/models/__init__.py
Normal file
14
fusion_clock_ai/models/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
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
|
||||
BIN
fusion_clock_ai/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock_ai/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock_ai/models/__pycache__/ai_cache.cpython-312.pyc
Normal file
BIN
fusion_clock_ai/models/__pycache__/ai_cache.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock_ai/models/__pycache__/ai_prompt.cpython-312.pyc
Normal file
BIN
fusion_clock_ai/models/__pycache__/ai_prompt.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock_ai/models/__pycache__/ai_service.cpython-312.pyc
Normal file
BIN
fusion_clock_ai/models/__pycache__/ai_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock_ai/models/__pycache__/ai_usage.cpython-312.pyc
Normal file
BIN
fusion_clock_ai/models/__pycache__/ai_usage.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
22
fusion_clock_ai/models/ai_cache.py
Normal file
22
fusion_clock_ai/models/ai_cache.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
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."""
|
||||
cutoff = datetime.now() - timedelta(hours=24)
|
||||
self.search([('created_at', '<', cutoff)]).unlink()
|
||||
46
fusion_clock_ai/models/ai_conversation.py
Normal file
46
fusion_clock_ai/models/ai_conversation.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
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()
|
||||
31
fusion_clock_ai/models/ai_prompt.py
Normal file
31
fusion_clock_ai/models/ai_prompt.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
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)
|
||||
323
fusion_clock_ai/models/ai_service.py
Normal file
323
fusion_clock_ai/models/ai_service.py
Normal file
@@ -0,0 +1,323 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
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'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Configuration helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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'))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Budget enforcement
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cache helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Usage logging with cost estimation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
MODEL_COST_PER_1K_INPUT = {
|
||||
'gpt-4o': 0.0025,
|
||||
'gpt-4o-mini': 0.00015,
|
||||
'gpt-3.5-turbo': 0.0005,
|
||||
}
|
||||
MODEL_COST_PER_1K_OUTPUT = {
|
||||
'gpt-4o': 0.01,
|
||||
'gpt-4o-mini': 0.0006,
|
||||
'gpt-3.5-turbo': 0.0015,
|
||||
}
|
||||
|
||||
def _log_usage(self, feature, model_name, prompt_tokens, completion_tokens):
|
||||
input_cost = (prompt_tokens / 1000) * self.MODEL_COST_PER_1K_INPUT.get(model_name, 0.001)
|
||||
output_cost = (completion_tokens / 1000) * self.MODEL_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,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Prompt template retrieval
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main chat completion method
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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"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)
|
||||
25
fusion_clock_ai/models/ai_usage.py
Normal file
25
fusion_clock_ai/models/ai_usage.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
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
|
||||
50
fusion_clock_ai/models/clock_correction_ai.py
Normal file
50
fusion_clock_ai/models/clock_correction_ai.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
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)
|
||||
40
fusion_clock_ai/models/clock_report_ai.py
Normal file
40
fusion_clock_ai/models/clock_report_ai.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
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
|
||||
45
fusion_clock_ai/models/hr_attendance_ai.py
Normal file
45
fusion_clock_ai/models/hr_attendance_ai.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
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)
|
||||
58
fusion_clock_ai/models/hr_employee_ai.py
Normal file
58
fusion_clock_ai/models/hr_employee_ai.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
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)
|
||||
98
fusion_clock_ai/models/res_config_settings.py
Normal file
98
fusion_clock_ai/models/res_config_settings.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user