# -*- coding: utf-8 -*- import json import logging import os import re import ast from datetime import datetime, timezone from urllib.parse import quote import requests from odoo import http from odoo.exceptions import AccessError from odoo.http import request from odoo.osv import expression try: from zoneinfo import ZoneInfo except Exception: ZoneInfo = None _logger = logging.getLogger(__name__) class AIAgentController(http.Controller): _MAX_CONTRACT_CONTEXT_CHARS = 40000 _MAX_PROJECT_CONTEXT_CHARS = 30000 _TOOL_MAX_ROWS = 100 _OPEN_TABLE_ACTION_XMLIDS = { 'dss.contracts': 'DigitalSignage.action_dss_main_contracts', 'dss.projects': 'DigitalSignage.action_dss_view_tree', } _OPEN_TABLE_MENU_XMLIDS = { 'dss.contracts': 'DigitalSignage.menu_dss_contracts_details', 'dss.projects': 'DigitalSignage.menu_dss_recherche_project_details', } def _log_params(self, event_name, params): """Centralized structured logging with clipping to keep log lines readable.""" try: serialized = json.dumps(params or {}, ensure_ascii=False, default=str) except Exception: serialized = str(params) _logger.info('AI Agent %s | params=%s', event_name, self._clip_text(serialized, 3000)) def _save_history_entry(self, role, content): text = (content or '').strip() if not text: return request.env['dss.ai.chat.history'].sudo().create({ 'user_id': request.env.user.id, 'role': role, 'content': text, }) def _save_exchange(self, cleaned_messages, assistant_reply): last_user_prompt = self._last_user_query(cleaned_messages) if last_user_prompt: self._save_history_entry('user', last_user_prompt) if assistant_reply: self._save_history_entry('assistant', assistant_reply) @http.route('/digitalsignage/ai_agent/prompts', type='json', auth='user') def ai_agent_prompts(self, limit=20): safe_limit = self._safe_int(limit, default=20, min_value=1, max_value=100) rows = request.env['dss.ai.chat.history'].sudo().search_read( [('user_id', '=', request.env.user.id), ('role', '=', 'user')], ['content'], limit=200, order='create_date desc', ) prompts = [] seen = set() for row in rows: text = (row.get('content') or '').strip() if not text: continue if text in seen: continue seen.add(text) prompts.append(text) if len(prompts) >= safe_limit: break return {'prompts': prompts} @http.route('/digitalsignage/ai_agent/open_contract', type='json', auth='user') def ai_agent_open_contract(self, contract_auto_id='', record_id=None): model_name = 'dss.contracts' contract_auto_id = (contract_auto_id or '').strip() record_id = self._safe_int(record_id or 0, default=0, min_value=0) is_project_candidate = bool( contract_auto_id and contract_auto_id.isdigit() and ( contract_auto_id.endswith('00') or len(contract_auto_id) == 3 ) ) self._log_params('navbar.open_contract.request', { 'user_id': request.env.user.id, 'contract_auto_id': contract_auto_id, 'record_id': record_id, 'is_project_candidate': is_project_candidate, }) # If the input looks like a project number, first try an exact lookup on dss.projects.projectid. if is_project_candidate: project_number = self._safe_int(contract_auto_id, default=0, min_value=0) if project_number: try: project_record = request.env['dss.projects'].search([ ('projectid', '=', project_number), ], limit=1) except AccessError: return { 'error': 'Kein Zugriff auf dss.projects.', 'found': False, 'message': 'Kein Zugriff auf Projektdaten.', } if project_record: open_table = self._build_open_project_kanban_payload(project_record) return { 'found': True, 'mode': 'project_contract_kanban', 'record_id': int(project_record.id), 'open_table': open_table, 'message': 'Projekt-Vertragskanban ueber ProjectID geoeffnet.', } model = request.env[model_name] record = None try: if record_id > 0: record = model.browse(record_id) if not record.exists(): record = None if not record and contract_auto_id: record = model.search( ['|', ('contract_auto_id', '=', contract_auto_id), ('contract_id', '=', contract_auto_id)], limit=1, ) except AccessError: return { 'error': 'Kein Zugriff auf dss.contracts.', 'found': False, 'message': 'Kein Zugriff auf Vertragsdaten.', } # Important: contract lookup has priority, even if number ends with 00. if record: open_table = self._build_open_record_payload( model_name, int(record.id), domain=[('id', '=', int(record.id))], open_label='Vertrag im Formular oeffnen', ) self._log_params('navbar.open_contract.response', { 'user_id': request.env.user.id, 'record_id': int(record.id), 'mode': 'contract_form', }) return { 'found': True, 'mode': 'contract_form', 'record_id': int(record.id), 'open_table': open_table, 'message': 'Vertrag geoeffnet.', } return { 'found': False, 'contract_auto_id': contract_auto_id, 'message': 'Keine passenden Vertraege/Projekte fuer "%s" gefunden.' % contract_auto_id, } @http.route('/digitalsignage/ai_agent/history', type='json', auth='user') def ai_agent_history(self, limit=30): safe_limit = self._safe_int(limit, default=30, min_value=1, max_value=200) rows = request.env['dss.ai.chat.history'].sudo().search_read( [('user_id', '=', request.env.user.id)], ['role', 'content', 'create_date'], limit=safe_limit, order='create_date desc', ) rows.reverse() return {'items': rows} def _clip_text(self, text, max_chars): if not text: return "" if len(text) <= max_chars: return text return "%s\n... (Kontext gekuerzt, Zeichenlimit erreicht)" % text[:max_chars] def _find_action_for_model(self, model_name): if not model_name: return {'action_id': None, 'menu_id': None, 'view_type': 'tree'} try: action_model = request.env['ir.actions.act_window'].sudo() action = None menu = None preferred_xmlid = self._OPEN_TABLE_ACTION_XMLIDS.get(model_name) if preferred_xmlid: action = request.env.ref(preferred_xmlid, raise_if_not_found=False) preferred_menu_xmlid = self._OPEN_TABLE_MENU_XMLIDS.get(model_name) if preferred_menu_xmlid: menu = request.env.ref(preferred_menu_xmlid, raise_if_not_found=False) # Prefer actions that can open a list/tree view. if not action: action = action_model.search( [('res_model', '=', model_name), ('view_mode', 'ilike', 'tree')], order='id desc', limit=1, ) if not action: action = action_model.search( [('res_model', '=', model_name), ('view_mode', 'ilike', 'list')], order='id desc', limit=1, ) if not action: action = action_model.search( [('res_model', '=', model_name)], order='id desc', limit=1, ) menu_id = menu.id if menu else None if action and not menu_id: menu = request.env['ir.ui.menu'].sudo().search( [('action', '=', 'ir.actions.act_window,%s' % action.id)], order='id asc', limit=1, ) menu_id = menu.id if menu else None return { 'action_id': action.id if action else None, 'menu_id': menu_id, 'view_type': 'tree', } except Exception: return {'action_id': None, 'menu_id': None, 'view_type': 'tree'} def _build_open_table_url(self, model_name, action_id, menu_id, view_type, domain): if not model_name: return '/web' domain_str = quote(json.dumps(domain or [], ensure_ascii=False)) parts = [] company_ids = request.env.context.get('allowed_company_ids') or [request.env.company.id] if action_id: parts.append('action=%s' % action_id) if menu_id: parts.append('menu_id=%s' % menu_id) if company_ids: parts.append('cids=%s' % ','.join([str(cid) for cid in company_ids])) parts.append('model=%s' % quote(str(model_name))) parts.append('view_type=%s' % quote(str(view_type or 'tree'))) parts.append('domain=%s' % domain_str) return '/web#' + '&'.join(parts) def _build_open_table_action(self, model_name, domain): if not model_name: return None if not isinstance(domain, list): domain = [] domain = self._normalize_domain(domain) action_info = self._find_action_for_model(model_name) action_id = action_info.get('action_id') action_name = None if action_id: try: action = request.env['ir.actions.act_window'].sudo().browse(action_id) action_name = action.name or None except Exception: action_name = None action_context = { 'ai_open_table_domain': domain, } action_context.update(self._domain_to_search_defaults(domain)) return { 'type': 'ir.actions.act_window', 'name': action_name or model_name, 'res_model': model_name, 'view_mode': 'tree,form', 'views': [[False, 'tree'], [False, 'form']], 'domain': domain, 'target': 'current', 'context': action_context, } def _build_open_table_payload(self, model_name, domain, returned=0, total=0, record_ids=None): if not model_name: return None if not isinstance(domain, list): domain = [] domain = self._normalize_domain(domain) record_ids = [rid for rid in (record_ids or []) if isinstance(rid, int)] action_info = self._find_action_for_model(model_name) action_id = action_info.get('action_id') menu_id = action_info.get('menu_id') view_type = action_info.get('view_type') or 'tree' return { 'model': model_name, 'action_id': action_id, 'menu_id': menu_id, 'view_type': view_type, 'domain': domain, 'record_ids': record_ids, 'returned': returned, 'total': total, 'open_action': self._build_open_table_action(model_name, domain), 'url': self._build_open_table_url(model_name, action_id, menu_id, view_type, domain), } def _parse_domain_input(self, raw_domain): if isinstance(raw_domain, tuple): return [raw_domain] if isinstance(raw_domain, dict): # Accept wrapper formats like {"domain": [...]}, {"filters": [...]}, {"conditions": [...]}. for key in ('domain', 'filters', 'conditions'): if key in raw_domain: return self._parse_domain_input(raw_domain.get(key)) # Accept single-clause dict format: {"field": "x", "operator": "=", "value": 1} field_name = raw_domain.get('field') operator = raw_domain.get('operator', '=') if field_name: return [(field_name, operator, raw_domain.get('value'))] return [] if isinstance(raw_domain, list): # Also accept list of clause-dicts. converted = [] has_dict_clause = False for item in raw_domain: if isinstance(item, dict): has_dict_clause = True field_name = item.get('field') operator = item.get('operator', '=') if field_name: converted.append((field_name, operator, item.get('value'))) continue converted.append(item) if has_dict_clause: return converted return raw_domain if not isinstance(raw_domain, str) or not raw_domain.strip(): return [] text = raw_domain.strip() # Prefer JSON first because booleans/null are represented as true/false/null. try: parsed_json = json.loads(text) parsed = self._parse_domain_input(parsed_json) if isinstance(parsed, list): return parsed except Exception: pass # Fallback for python-literal style domains. try: parsed_literal = ast.literal_eval(text) parsed = self._parse_domain_input(parsed_literal) if isinstance(parsed, list): return parsed except Exception: pass _logger.warning('Invalid domain input: %s', raw_domain) return [] def _open_table_priority(self, function_name): priorities = { 'open_contract_form': 110, 'get_all_contracts': 100, 'search_all_records': 90, 'get_model_records': 80, 'get_projects': 70, } return priorities.get(function_name or '', 0) def _build_open_record_action(self, model_name, record_id, view_mode='form'): if not model_name or not isinstance(record_id, int) or record_id <= 0: return None action_info = self._find_action_for_model(model_name) action_id = action_info.get('action_id') action_name = None if action_id: try: action = request.env['ir.actions.act_window'].sudo().browse(action_id) action_name = action.name or None except Exception: action_name = None return { 'type': 'ir.actions.act_window', 'name': action_name or model_name, 'res_model': model_name, 'res_id': record_id, 'view_mode': view_mode or 'form', 'views': [[False, 'form']], 'target': 'current', } def _build_open_record_url(self, model_name, record_id, action_id=None, menu_id=None, view_type='form'): if not model_name or not isinstance(record_id, int) or record_id <= 0: return '/web' parts = [] company_ids = request.env.context.get('allowed_company_ids') or [request.env.company.id] if action_id: parts.append('action=%s' % action_id) if menu_id: parts.append('menu_id=%s' % menu_id) if company_ids: parts.append('cids=%s' % ','.join([str(cid) for cid in company_ids])) parts.append('model=%s' % quote(str(model_name))) parts.append('id=%s' % record_id) parts.append('view_type=%s' % quote(str(view_type or 'form'))) return '/web#' + '&'.join(parts) def _build_open_record_payload(self, model_name, record_id, domain=None, open_label='Datensatz oeffnen'): if not model_name or not isinstance(record_id, int) or record_id <= 0: return None action_info = self._find_action_for_model(model_name) action_id = action_info.get('action_id') menu_id = action_info.get('menu_id') return { 'model': model_name, 'action_id': action_id, 'menu_id': menu_id, 'view_type': 'form', 'domain': self._normalize_domain(domain or [('id', '=', record_id)]), 'record_ids': [record_id], 'returned': 1, 'total': 1, 'auto_open': True, 'open_label': open_label, 'open_action': self._build_open_record_action(model_name, record_id, view_mode='form'), 'url': self._build_open_record_url(model_name, record_id, action_id=action_id, menu_id=menu_id, view_type='form'), } def _build_open_project_kanban_payload(self, project_record): if not project_record or not project_record.exists(): return None model_name = 'dss.contracts' action_info = self._find_action_for_model(model_name) action = request.env.ref('DigitalSignage.action_dss_project_contracts', raise_if_not_found=False) action_id = action.id if action else action_info.get('action_id') menu_id = action_info.get('menu_id') domain = [('project', '=', int(project_record.id))] return { 'model': model_name, 'action_id': action_id, 'menu_id': menu_id, 'view_type': 'kanban', 'domain': domain, 'record_ids': [], 'returned': 1, 'total': 1, 'auto_open': True, 'open_label': 'Projekt-Vertraege in Kanban oeffnen', 'open_action': { 'type': 'ir.actions.act_window', 'name': (action.name if action else None) or model_name, 'res_model': model_name, 'view_mode': 'kanban,form,tree,calendar,activity', 'views': [[False, 'kanban'], [False, 'form'], [False, 'tree']], 'domain': domain, 'target': 'current', 'context': { 'ai_open_table_domain': domain, 'default_project': int(project_record.id), 'default_project_id': int(project_record.id), 'show_project_update': True, }, }, 'url': self._build_open_table_url(model_name, action_id, menu_id, 'kanban', domain), } def _normalize_domain(self, domain): if not isinstance(domain, list): return [] try: return expression.normalize_domain(domain) except Exception: _logger.warning('Could not normalize domain, using raw domain: %s', domain) return domain def _domain_to_search_defaults(self, domain): """Best effort for visible UI filters via search_default_* context keys.""" defaults = {} if not isinstance(domain, list): return defaults for token in domain: if not isinstance(token, (list, tuple)) or len(token) != 3: continue field_name, operator, value = token if operator != '=': continue if not isinstance(field_name, str): continue if not field_name.replace('_', '').isalnum(): continue defaults['search_default_%s' % field_name] = value return defaults def _extract_api_error_message(self, response): try: payload = response.json() or {} error = payload.get('error') or {} message = error.get('message') or '' if message: return message except Exception: pass return response.text or '' def _parse_allowed_models(self, settings): raw_models = (settings.ai_agent_callback_models or '').strip() if settings else '' if not raw_models: return [] return [m.strip() for m in raw_models.split(',') if m.strip()] def _parse_allowed_fields(self, settings): raw = (settings.ai_agent_callback_fields_json or '').strip() if settings else '' if not raw: return {} try: parsed = json.loads(raw) return parsed if isinstance(parsed, dict) else {} except Exception: _logger.warning('Invalid JSON in ai_agent_callback_fields_json') return {} def _parse_allowed_aliases(self, settings): raw = (settings.ai_agent_callback_aliases_json or '').strip() if settings else '' if not raw: return {} try: parsed = json.loads(raw) return parsed if isinstance(parsed, dict) else {} except Exception: _logger.warning('Invalid JSON in ai_agent_callback_aliases_json') return {} def _parse_allowed_write_fields(self, settings): raw = (settings.ai_agent_callback_write_fields or '').strip() if settings else '' if not raw: return {} allowed = {} for item in re.split(r'[\n,;]+', raw): entry = (item or '').strip() if not entry or '.' not in entry: continue model_name, field_name = entry.rsplit('.', 1) model_name = model_name.strip() field_name = field_name.strip() if not model_name or not field_name: continue allowed.setdefault(model_name, set()).add(field_name) return allowed def _sanitize_search_text(self, value): text = (value or '').strip() # Keep search text compact to avoid overly broad / expensive queries. return re.sub(r'\s+', ' ', text)[:120] def _normalize_contract_search_field(self, settings, model_name, field_name): raw_original = (field_name or '').strip() raw = raw_original.lower() allowed_fields_map = self._parse_allowed_fields(settings) configured_fields = allowed_fields_map.get(model_name) or [] allowed_fields = [f for f in configured_fields if isinstance(f, str) and f] allowed_set = set(allowed_fields) if raw in allowed_fields: return raw aliases_map = self._parse_allowed_aliases(settings) model_aliases = aliases_map.get(model_name) if isinstance(aliases_map, dict) else {} if not isinstance(model_aliases, dict): model_aliases = {} normalized_aliases = {} for alias_key, field_value in model_aliases.items(): alias_key_text = str(alias_key or '').strip().lower() field_text = str(field_value or '').strip() if alias_key_text and field_text: normalized_aliases[alias_key_text] = field_text target_field = normalized_aliases.get(raw, '') if target_field in allowed_set: return target_field return '' def _to_boolean_search_value(self, value): raw = (value or '').strip().lower() if raw in ('1', 'true', 't', 'yes', 'y', 'ja', 'j', 'on'): return True if raw in ('0', 'false', 'f', 'no', 'n', 'nein', 'off'): return False return None def _split_field_and_value(self, settings, model_name, search_text): text = self._sanitize_search_text(search_text) for separator in (':', '='): if separator in text: left, right = text.split(separator, 1) field_name = self._normalize_contract_search_field(settings, model_name, left) value = self._sanitize_search_text(right) if field_name and value: return field_name, value return '', text def _tool_search_all_records(self, settings, arguments): """Durchsucht ein freigegebenes Modell nach einem Filterstring und gibt alle gefundenen Datensaetze (paginiert) mit allen freigegebenen Feldern zurueck.""" model_name = ((arguments or {}).get('model_name') or '').strip() search_text = self._sanitize_search_text((arguments or {}).get('search_text') or '') limit = self._safe_int((arguments or {}).get('limit', 50), default=50, min_value=1, max_value=self._TOOL_MAX_ROWS) offset = self._safe_int((arguments or {}).get('offset', 0), default=0, min_value=0) search_fields_arg = (arguments or {}).get('search_fields') or [] self._log_params('tool.search_all_records.request', { 'user_id': request.env.user.id, 'model_name': model_name, 'search_text': search_text, 'search_fields_arg': search_fields_arg, 'limit': limit, 'offset': offset, }) allowed_models = self._parse_allowed_models(settings) allowed_fields_map = self._parse_allowed_fields(settings) if model_name not in allowed_models: return {'error': 'Modell ist nicht fuer Callback freigegeben: %s' % model_name} allowed_fields = allowed_fields_map.get(model_name) or [] if not isinstance(allowed_fields, list) or not allowed_fields: return {'error': 'Keine freigegebenen Felder fuer Modell %s konfiguriert.' % model_name} # Determine which fields to search in. if search_fields_arg and isinstance(search_fields_arg, list): search_target_fields = [f for f in search_fields_arg if f in allowed_fields] else: search_target_fields = allowed_fields[:] if not search_target_fields: search_target_fields = allowed_fields[:] domain = [] if search_text: if len(search_target_fields) == 1: domain = [(search_target_fields[0], 'ilike', search_text)] else: domain = [] for idx, fname in enumerate(search_target_fields): if idx < len(search_target_fields) - 1: domain.append('|') domain.append((fname, 'ilike', search_text)) total = request.env[model_name].search_count(domain) rows = request.env[model_name].search_read( domain, allowed_fields, limit=limit, offset=offset, order='write_date desc', ) self._log_params('tool.search_all_records.response', { 'user_id': request.env.user.id, 'model_name': model_name, 'total': total, 'returned': len(rows), 'offset': offset, 'limit': limit, 'has_more': (offset + len(rows)) < total, }) return { 'model': model_name, 'domain': domain, 'search_text': search_text, 'search_fields': search_target_fields, 'total': total, 'returned': len(rows), 'offset': offset, 'limit': limit, 'has_more': (offset + len(rows)) < total, 'next_offset': offset + len(rows), 'records': rows, } def _tool_get_model_records(self, settings, arguments): model_name = ((arguments or {}).get('model_name') or '').strip() limit = self._safe_int((arguments or {}).get('limit', 25), default=25, min_value=1, max_value=self._TOOL_MAX_ROWS) offset = self._safe_int((arguments or {}).get('offset', 0), default=0, min_value=0) search_text = self._sanitize_search_text((arguments or {}).get('search_text') or '') requested_fields = (arguments or {}).get('fields') or [] self._log_params('tool.get_model_records.request', { 'user_id': request.env.user.id, 'model_name': model_name, 'search_text': search_text, 'requested_fields': requested_fields, 'limit': limit, 'offset': offset, }) allowed_models = self._parse_allowed_models(settings) allowed_fields_map = self._parse_allowed_fields(settings) if model_name not in allowed_models: return {'error': 'Modell ist nicht fuer Callback freigegeben: %s' % model_name} allowed_fields = allowed_fields_map.get(model_name) or [] if not isinstance(allowed_fields, list): allowed_fields = [] if not allowed_fields: return {'error': 'Keine freigegebenen Felder fuer Modell %s konfiguriert.' % model_name} if requested_fields and isinstance(requested_fields, list): fields_to_read = [f for f in requested_fields if f in allowed_fields] if not fields_to_read: fields_to_read = allowed_fields[:] else: fields_to_read = allowed_fields[:] domain = [] if search_text: # Build OR domain over char/text-like configured fields. or_fields = [f for f in allowed_fields if isinstance(f, str)] if or_fields: if len(or_fields) == 1: domain = [(or_fields[0], 'ilike', search_text)] else: domain = [] for idx, field_name in enumerate(or_fields): if idx < len(or_fields) - 1: domain.append('|') domain.append((field_name, 'ilike', search_text)) total = request.env[model_name].search_count(domain) rows = request.env[model_name].search_read( domain, fields_to_read, limit=limit, offset=offset, order='write_date desc', ) self._log_params('tool.get_model_records.response', { 'user_id': request.env.user.id, 'model_name': model_name, 'fields_to_read': fields_to_read, 'total': total, 'returned': len(rows), 'offset': offset, 'limit': limit, 'has_more': (offset + len(rows)) < total, }) return { 'model': model_name, 'domain': domain, 'fields': fields_to_read, 'total': total, 'returned': len(rows), 'offset': offset, 'limit': limit, 'has_more': (offset + len(rows)) < total, 'next_offset': offset + len(rows), 'records': rows, } def _tool_get_all_contracts(self, settings, arguments): if not isinstance(arguments, dict): self._log_params('tool.get_all_contracts.invalid_arguments', { 'user_id': request.env.user.id, 'type': type(arguments).__name__, 'value': arguments, }) arguments = {} model_name = 'dss.contracts' raw_domain = arguments.get('domain') if raw_domain is None: raw_domain = arguments.get('filters') if raw_domain is None: raw_domain = arguments.get('conditions') raw_search_field = arguments.get('search_field') raw_search_value = arguments.get('search_value') or arguments.get('search_text') requested_fields = arguments.get('fields') or [] limit = self._safe_int(arguments.get('limit', 25), default=25, min_value=1, max_value=self._TOOL_MAX_ROWS) offset = self._safe_int(arguments.get('offset', 0), default=0, min_value=0) self._log_params('tool.get_all_contracts.request', { 'user_id': request.env.user.id, 'domain': raw_domain, 'search_field': raw_search_field, 'search_value': raw_search_value, 'requested_fields': requested_fields, 'limit': limit, 'offset': offset, }) default_fields_to_read = [ 'contract_auto_id', 'contract_id', 'contract_name', 'contract_auto_name', 'client_short_company', 'client_short_vorname', 'client_short_name', 'contract_state', 'project', 'vertragssumme', ] allowed_fields_map = self._parse_allowed_fields(settings) configured_fields = allowed_fields_map.get(model_name) or [] allowed_fields = [f for f in configured_fields if isinstance(f, str) and f] if requested_fields and isinstance(requested_fields, list): fields_to_read = [f for f in requested_fields if f in allowed_fields] if not fields_to_read: return { 'error': 'Keine der angeforderten Felder ist fuer %s freigegeben.' % model_name, 'allowed_fields': allowed_fields, } else: fields_to_read = default_fields_to_read domain = self._normalize_domain(self._parse_domain_input(raw_domain)) # Fallback: build a domain from search_field/search_text style inputs when no domain was passed. if not domain: normalized_search_field = self._normalize_contract_search_field(settings, model_name, raw_search_field) search_text = self._sanitize_search_text(raw_search_value) if search_text: if normalized_search_field: boolean_value = self._to_boolean_search_value(search_text) if boolean_value is not None: domain = [(normalized_search_field, '=', boolean_value)] else: domain = [(normalized_search_field, 'ilike', search_text)] else: split_field, split_value = self._split_field_and_value(settings, model_name, search_text) if split_field and split_value: boolean_value = self._to_boolean_search_value(split_value) if boolean_value is not None: domain = [(split_field, '=', boolean_value)] else: domain = [(split_field, 'ilike', split_value)] if not domain and search_text: allowed_fields_map = self._parse_allowed_fields(settings) configured_fields = allowed_fields_map.get(model_name) or [] search_fields = [f for f in configured_fields if isinstance(f, str) and f] if search_fields: if len(search_fields) == 1: domain = [(search_fields[0], 'ilike', search_text)] else: domain = [] for idx, field_name in enumerate(search_fields): if idx < len(search_fields) - 1: domain.append('|') domain.append((field_name, 'ilike', search_text)) domain = self._normalize_domain(domain) self._log_params('tool.get_all_contracts.domain_effective', { 'user_id': request.env.user.id, 'domain': domain, }) records = request.env[model_name].search( domain, offset=offset, limit=limit if limit > 0 else None, order='write_date desc', ) total = request.env[model_name].search_count(domain) rows = records.read(fields_to_read) record_ids = records.ids contracts = [] for row in rows: state = row.get('contract_state') project = row.get('project') contracts.append({ 'contract_auto_id': row.get('contract_auto_id') or row.get('contract_id') or '', 'contract_name': row.get('contract_name') or row.get('contract_auto_name') or '', 'customer': row.get('client_short_company') or ' '.join( filter(None, [row.get('client_short_vorname'), row.get('client_short_name')]) ).strip(), 'state': state[1] if isinstance(state, (list, tuple)) and len(state) > 1 else '', 'project': project[1] if isinstance(project, (list, tuple)) and len(project) > 1 else '', 'amount': row.get('vertragssumme') if row.get('vertragssumme') not in (None, False) else None, }) self._log_params('tool.get_all_contracts.response', { 'user_id': request.env.user.id, 'fields_to_read': fields_to_read, 'total': total, 'returned': len(contracts), 'offset': offset, 'limit': limit, 'has_more': (offset + len(contracts)) < total, }) if requested_fields and isinstance(requested_fields, list): return { 'model': model_name, 'domain': domain, 'record_ids': record_ids, 'fields': fields_to_read, 'total': total, 'returned': len(rows), 'offset': offset, 'limit': limit, 'has_more': (offset + len(rows)) < total, 'next_offset': offset + len(rows), 'records': rows, } return { 'model': model_name, 'domain': domain, 'record_ids': record_ids, 'fields': fields_to_read, 'total': total, 'returned': len(contracts), 'offset': offset, 'limit': limit, 'has_more': (offset + len(contracts)) < total, 'next_offset': offset + len(contracts), 'contracts': contracts, } def _tool_get_contract_by_number(self, arguments): number = ((arguments or {}).get('contract_auto_id') or '').strip() self._log_params('tool.get_contract_by_number.request', { 'user_id': request.env.user.id, 'contract_auto_id': number, }) if not number: return {'error': 'contract_auto_id ist erforderlich.'} rows = request.env['dss.contracts'].search_read( ['|', ('contract_auto_id', '=', number), ('contract_id', '=', number)], [ 'contract_auto_id', 'contract_id', 'contract_name', 'contract_auto_name', 'client_short_company', 'client_short_vorname', 'client_short_name', 'contract_state', 'project', 'vertragssumme', 'remark', 'contract_remark' ], limit=1, ) if not rows: self._log_params('tool.get_contract_by_number.response', { 'user_id': request.env.user.id, 'contract_auto_id': number, 'found': False, }) return {'found': False, 'contract_auto_id': number} row = rows[0] state = row.get('contract_state') project = row.get('project') self._log_params('tool.get_contract_by_number.response', { 'user_id': request.env.user.id, 'contract_auto_id': number, 'found': True, }) return { 'found': True, 'contract': { 'contract_auto_id': row.get('contract_auto_id') or row.get('contract_id') or '', 'contract_name': row.get('contract_name') or row.get('contract_auto_name') or '', 'customer': row.get('client_short_company') or ' '.join( filter(None, [row.get('client_short_vorname'), row.get('client_short_name')]) ).strip(), 'state': state[1] if isinstance(state, (list, tuple)) and len(state) > 1 else '', 'project': project[1] if isinstance(project, (list, tuple)) and len(project) > 1 else '', 'amount': row.get('vertragssumme') if row.get('vertragssumme') not in (None, False) else None, 'remark': (row.get('remark') or '')[:2000], 'contract_remark': (row.get('contract_remark') or '')[:2000], }, } def _tool_open_contract_form(self, arguments): if not isinstance(arguments, dict): arguments = {} model_name = 'dss.contracts' record_id = self._safe_int(arguments.get('record_id', 0), default=0, min_value=0) contract_auto_id = ((arguments or {}).get('contract_auto_id') or '').strip() self._log_params('tool.open_contract_form.request', { 'user_id': request.env.user.id, 'record_id': record_id, 'contract_auto_id': contract_auto_id, }) model = request.env[model_name] record = None if record_id > 0: record = model.browse(record_id) if not record.exists(): return {'error': 'Vertrag mit ID %s wurde nicht gefunden.' % record_id} elif contract_auto_id: domain = ['|', ('contract_auto_id', '=', contract_auto_id), ('contract_id', '=', contract_auto_id)] record = model.search(domain, limit=1) if not record: return {'error': 'Vertrag %s wurde nicht gefunden.' % contract_auto_id} else: return {'error': 'record_id oder contract_auto_id ist erforderlich.'} open_table = self._build_open_record_payload( model_name, int(record.id), domain=[('id', '=', int(record.id))], open_label='Vertrag im Formular oeffnen', ) result = { 'opened': True, 'model': model_name, 'record_id': int(record.id), 'record_ids': [int(record.id)], 'domain': [('id', '=', int(record.id))], 'returned': 1, 'total': 1, 'open_table': open_table, } self._log_params('tool.open_contract_form.response', { 'user_id': request.env.user.id, 'record_id': int(record.id), 'has_open_table': bool(open_table), }) return result def _tool_get_projects(self, arguments): search_text = (arguments or {}).get('search_text') or '' limit = self._safe_int((arguments or {}).get('limit', 25), default=25, min_value=1, max_value=self._TOOL_MAX_ROWS) offset = self._safe_int((arguments or {}).get('offset', 0), default=0, min_value=0) self._log_params('tool.get_projects.request', { 'user_id': request.env.user.id, 'search_text': search_text, 'limit': limit, 'offset': offset, }) domain = [] if search_text: domain = [ '|', '|', ('projektname', 'ilike', search_text), ('name', 'ilike', search_text), ('projectid', 'ilike', search_text), ] total = request.env['dss.projects'].search_count(domain) rows = request.env['dss.projects'].search_read( domain, ['projectid', 'projektname', 'name', 'standort_strasse', 'standort_plz', 'standort_ort', 'maps_visitors', 'aktstatus'], limit=limit, offset=offset, order='write_date desc', ) projects = [] for row in rows: status = row.get('aktstatus') projects.append({ 'projectid': row.get('projectid') or '', 'projektname': row.get('projektname') or row.get('name') or '', 'location': ' '.join(filter(None, [row.get('standort_strasse'), row.get('standort_plz'), row.get('standort_ort')])), 'visitors': row.get('maps_visitors') if row.get('maps_visitors') not in (None, False) else None, 'status': status[1] if isinstance(status, (list, tuple)) and len(status) > 1 else '', }) self._log_params('tool.get_projects.response', { 'user_id': request.env.user.id, 'total': total, 'returned': len(projects), 'offset': offset, 'limit': limit, 'has_more': (offset + len(projects)) < total, }) return { 'model': 'dss.projects', 'domain': domain, 'total': total, 'returned': len(projects), 'offset': offset, 'limit': limit, 'has_more': (offset + len(projects)) < total, 'next_offset': offset + len(projects), 'projects': projects, } def _validate_write_values(self, settings, model_name, values): if not model_name: return {'error': 'model_name ist erforderlich.'} if not isinstance(values, dict) or not values: return {'error': 'values muss ein nicht-leeres Objekt sein.'} allowed_write_fields = self._parse_allowed_write_fields(settings) allowed_fields = allowed_write_fields.get(model_name) or set() if not allowed_fields: return {'error': 'Keine freigegebenen Schreibfelder fuer Modell %s konfiguriert.' % model_name} disallowed_fields = sorted([field_name for field_name in values.keys() if field_name not in allowed_fields]) if disallowed_fields: return { 'error': 'Nicht freigegebene Schreibfelder fuer %s: %s' % (model_name, ', '.join(disallowed_fields)), 'allowed_fields': sorted(list(allowed_fields)), } model = request.env[model_name] existing_fields = getattr(model, '_fields', {}) or {} invalid_fields = sorted([field_name for field_name in values.keys() if field_name not in existing_fields]) if invalid_fields: return { 'error': 'Unbekannte Felder fuer %s: %s' % (model_name, ', '.join(invalid_fields)), } return {'model': model} def _tool_create_model_record(self, settings, arguments): if not isinstance(arguments, dict): arguments = {} model_name = ((arguments or {}).get('model_name') or '').strip() values = (arguments or {}).get('values') or {} self._log_params('tool.create_model_record.request', { 'user_id': request.env.user.id, 'model_name': model_name, 'value_keys': sorted(list(values.keys())) if isinstance(values, dict) else [], }) validation = self._validate_write_values(settings, model_name, values) if validation.get('error'): return validation model = validation['model'] record = model.create(values) result = { 'created': True, 'model': model_name, 'record_id': record.id, 'display_name': getattr(record, 'display_name', str(record.id)), 'written_fields': sorted(list(values.keys())), } self._log_params('tool.create_model_record.response', { 'user_id': request.env.user.id, 'model_name': model_name, 'record_id': record.id, 'written_fields': result['written_fields'], }) return result def _tool_update_model_record(self, settings, arguments): if not isinstance(arguments, dict): arguments = {} model_name = ((arguments or {}).get('model_name') or '').strip() record_id = self._safe_int((arguments or {}).get('record_id'), default=0, min_value=0) values = (arguments or {}).get('values') or {} self._log_params('tool.update_model_record.request', { 'user_id': request.env.user.id, 'model_name': model_name, 'record_id': record_id, 'value_keys': sorted(list(values.keys())) if isinstance(values, dict) else [], }) if record_id <= 0: return {'error': 'record_id ist erforderlich.'} validation = self._validate_write_values(settings, model_name, values) if validation.get('error'): return validation model = validation['model'] record = model.browse(record_id) if not record.exists(): return { 'error': 'Datensatz %s in %s wurde nicht gefunden.' % (record_id, model_name), } record.write(values) result = { 'updated': True, 'model': model_name, 'record_id': record.id, 'display_name': getattr(record, 'display_name', str(record.id)), 'written_fields': sorted(list(values.keys())), } self._log_params('tool.update_model_record.response', { 'user_id': request.env.user.id, 'model_name': model_name, 'record_id': record.id, 'written_fields': result['written_fields'], }) return result def _tool_get_system_datetime(self, arguments): if not isinstance(arguments, dict): arguments = {} requested_timezone = (arguments.get('timezone') or '').strip() user_timezone = (request.env.user.tz or '').strip() or 'UTC' timezone_name = requested_timezone or user_timezone self._log_params('tool.get_system_datetime.request', { 'user_id': request.env.user.id, 'requested_timezone': requested_timezone, 'user_timezone': user_timezone, }) tz_warning = '' tzinfo = timezone.utc effective_timezone = 'UTC' if ZoneInfo: try: tzinfo = ZoneInfo(timezone_name) effective_timezone = timezone_name except Exception: tz_warning = 'Ungueltige Zeitzone: %s. Fallback auf UTC.' % timezone_name tzinfo = timezone.utc effective_timezone = 'UTC' else: if timezone_name and timezone_name != 'UTC': tz_warning = 'ZoneInfo nicht verfuegbar. Fallback auf UTC.' now_utc = datetime.now(timezone.utc) now_local = now_utc.astimezone(tzinfo) weekday_names_de = { 1: 'Montag', 2: 'Dienstag', 3: 'Mittwoch', 4: 'Donnerstag', 5: 'Freitag', 6: 'Samstag', 7: 'Sonntag', } result = { 'timezone': effective_timezone, 'iso_datetime': now_local.isoformat(), 'date': now_local.strftime('%Y-%m-%d'), 'time': now_local.strftime('%H:%M:%S'), 'year': now_local.year, 'month': now_local.month, 'day': now_local.day, 'weekday_number_iso': now_local.isoweekday(), 'weekday_name': weekday_names_de.get(now_local.isoweekday(), ''), 'week_number_iso': int(now_local.strftime('%V')), 'timestamp_unix': int(now_local.timestamp()), 'utc_iso_datetime': now_utc.isoformat(), } if tz_warning: result['warning'] = tz_warning self._log_params('tool.get_system_datetime.response', { 'user_id': request.env.user.id, 'timezone': result.get('timezone'), 'iso_datetime': result.get('iso_datetime'), 'warning': result.get('warning', ''), }) return result def _build_openai_tools(self): self._log_params('tools.build_openai_tools', { 'tool_names': [ 'open_contract_form', 'get_all_contracts', 'get_contract_by_number', 'get_projects', 'create_model_record', 'update_model_record', 'get_system_datetime', 'search_all_records', 'get_model_records', ] }) return [ { 'type': 'function', 'function': { 'name': 'open_contract_form', 'description': 'Oeffnet einen spezifischen Vertrag in der Formularansicht.', 'parameters': { 'type': 'object', 'properties': { 'record_id': { 'type': 'integer', 'description': 'Interne Datensatz-ID von dss.contracts.', 'minimum': 1, }, 'contract_auto_id': { 'type': 'string', 'description': 'Vertragsnummer (contract_auto_id oder contract_id) als Alternative zu record_id.', }, }, 'additionalProperties': False, }, }, }, { 'type': 'function', 'function': { 'name': 'get_all_contracts', 'description': 'Sucht Vertraege in dss.contracts (paginiert).', 'parameters': { 'type': 'object', 'properties': { 'domain': { 'type': 'array', 'items': {}, 'description': 'Optionaler Odoo-Domain-Array, z.B. [["contract_name", "ilike", "Test"], ["contract_auto_extend", "=", false]].', }, 'limit': { 'type': 'integer', 'description': 'Maximale Anzahl Datensaetze pro Seite.', 'minimum': 1, }, 'offset': { 'type': 'integer', 'description': 'Startposition fuer Paging.', 'minimum': 0, }, 'fields': { 'type': 'array', 'items': {'type': 'string'}, 'description': 'Optional: Rueckgabefelder fuer dss.contracts. Es sind nur freigegebene Felder aus den Einstellungen erlaubt.', }, }, 'additionalProperties': False, }, }, }, { 'type': 'function', 'function': { 'name': 'get_contract_by_number', 'description': 'Liefert Vertragsdetails zu einer Vertragsnummer.', 'parameters': { 'type': 'object', 'properties': { 'contract_auto_id': { 'type': 'string', 'description': 'Vertragsnummer (contract_auto_id oder contract_id).', }, }, 'required': ['contract_auto_id'], 'additionalProperties': False, }, }, }, { 'type': 'function', 'function': { 'name': 'get_all_projects', 'description': 'Sucht Daten aus Projekte in dss.projects (paginiert).', 'parameters': { 'type': 'object', 'properties': { 'search_text': { 'type': 'string', 'description': 'Optionaler Filter fuer Projektname oder Projekt-ID.', }, 'limit': { 'type': 'integer', 'description': 'Maximale Anzahl Datensaetze pro Seite.', 'minimum': 1, }, 'offset': { 'type': 'integer', 'description': 'Startposition fuer Paging.', 'minimum': 0, }, }, 'additionalProperties': False, }, }, }, { 'type': 'function', 'function': { 'name': 'create_model_record', 'description': 'Legt einen Datensatz in einem Odoo-Modell an. Es duerfen nur exakt in den Einstellungen freigegebene Schreibfelder im Muster model.field gesetzt werden.', 'parameters': { 'type': 'object', 'properties': { 'model_name': { 'type': 'string', 'description': 'Name des Odoo-Modells, in dem ein Datensatz angelegt werden soll.', }, 'values': { 'type': 'object', 'description': 'Feldwerte fuer den neuen Datensatz. Nur exakt freigegebene Felder des Modells sind erlaubt.', 'additionalProperties': True, }, }, 'required': ['model_name', 'values'], 'additionalProperties': False, }, }, }, { 'type': 'function', 'function': { 'name': 'update_model_record', 'description': 'Aendert einen vorhandenen Datensatz in einem Odoo-Modell. Es duerfen nur exakt in den Einstellungen freigegebene Schreibfelder im Muster model.field gesetzt werden.', 'parameters': { 'type': 'object', 'properties': { 'model_name': { 'type': 'string', 'description': 'Name des Odoo-Modells, in dem ein Datensatz geaendert werden soll.', }, 'record_id': { 'type': 'integer', 'description': 'ID des vorhandenen Datensatzes.', 'minimum': 1, }, 'values': { 'type': 'object', 'description': 'Zu aendernde Feldwerte. Nur exakt freigegebene Felder des Modells sind erlaubt.', 'additionalProperties': True, }, }, 'required': ['model_name', 'record_id', 'values'], 'additionalProperties': False, }, }, }, { 'type': 'function', 'function': { 'name': 'get_system_datetime', 'description': 'Liefert aktuelle Systemzeitparameter wie Datum, Uhrzeit, Wochentag, Jahr und ISO-Kalenderwoche.', 'parameters': { 'type': 'object', 'properties': { 'timezone': { 'type': 'string', 'description': 'Optionale IANA-Zeitzone (z.B. Europe/Berlin). Ohne Wert wird die Benutzer-Zeitzone verwendet.', }, }, 'additionalProperties': False, }, }, }, { 'type': 'function', 'function': { 'name': 'search_all_records', 'description': ( 'Durchsucht ein freigegebenes Odoo-Modell nach einem Filterstring und liefert ' 'alle gefundenen Datensaetze mit allen freigegebenen Feldern (paginiert). ' 'Nutze diese Funktion fuer breite Suchen, z.B. alle Vertraege eines Kunden.' ), 'parameters': { 'type': 'object', 'properties': { 'model_name': { 'type': 'string', 'description': 'Name des freigegebenen Odoo-Modells, z.B. dss.contracts.', }, 'search_text': { 'type': 'string', 'description': 'Pflichtfeld: Suchbegriff fuer ilike-Filter ueber alle freigegebenen Felder.', }, 'search_fields': { 'type': 'array', 'items': {'type': 'string'}, 'description': 'Optional: Einschraenkung auf bestimmte Felder fuer die Suche.', }, 'limit': { 'type': 'integer', 'description': 'Max. Datensaetze pro Seite (Standard 50).', 'minimum': 1, }, 'offset': { 'type': 'integer', 'description': 'Startposition fuer Paging.', 'minimum': 0, }, }, 'required': ['model_name', 'search_text'], 'additionalProperties': False, }, }, }, { 'type': 'function', 'function': { 'name': 'get_model_records', 'description': 'Generischer Callback fuer freigegebene Modelle/Felder laut Einstellungen.', 'parameters': { 'type': 'object', 'properties': { 'model_name': { 'type': 'string', 'description': 'Name des freigegebenen Odoo-Modells, z.B. dss.contracts.', }, 'fields': { 'type': 'array', 'items': {'type': 'string'}, 'description': 'Optional: Teilmenge der freigegebenen Felder.', }, 'search_text': { 'type': 'string', 'description': 'Optionaler Suchtext fuer ilike-Filter.', }, 'limit': { 'type': 'integer', 'description': 'Maximale Anzahl Datensaetze pro Seite.', 'minimum': 1, }, 'offset': { 'type': 'integer', 'description': 'Startposition fuer Paging.', 'minimum': 0, }, }, 'required': ['model_name'], 'additionalProperties': False, }, }, } ] def _safe_int(self, value, default=0, min_value=None, max_value=None): try: int_value = int(value) except Exception: int_value = default if min_value is not None: int_value = max(min_value, int_value) if max_value is not None: int_value = min(max_value, int_value) return int_value def _last_user_query(self, cleaned_messages): for item in reversed(cleaned_messages): if item.get('role') == 'user' and item.get('content'): return item['content'] return '' def _row_to_field_value_string(self, row): parts = [] for key, value in (row or {}).items(): if isinstance(value, (list, tuple)): value_text = '|'.join([str(v) for v in value]) elif value is None: value_text = '' else: value_text = str(value) parts.append('"%s":"%s"' % (str(key), value_text.replace('"', "'"))) return ', '.join(parts) def _load_contract_rows(self, settings): if not settings or not settings.ai_agent_contracts_access: return [] limit = self._safe_int(settings.ai_agent_contracts_limit or 0, default=0, min_value=0) fields_to_read = [ 'contract_auto_id', 'contract_id', 'contract_name', 'contract_auto_name', 'client_short_company', 'client_short_vorname', 'client_short_name', 'contract_state', 'project', 'vertragssumme', ] return request.env['dss.contracts'].search_read( [], fields_to_read, limit=limit if limit > 0 else None, order='write_date desc', ) def _load_project_rows(self, settings): if not settings or not settings.ai_agent_projects_access: return [] limit = self._safe_int(settings.ai_agent_projects_limit or 0, default=0, min_value=0) fields_to_read = [ 'projectid', 'projektname', 'name', 'standort_ort', 'standort_plz', 'standort_strasse', 'maps_visitors', 'aktstatus', ] return request.env['dss.projects'].search_read( [], fields_to_read, limit=limit if limit > 0 else None, order='write_date desc', ) def _build_rag_documents(self, settings): docs = [] try: contract_rows = self._load_contract_rows(settings) self._log_params('rag.build_documents.contract_rows', { 'count': len(contract_rows), 'contracts_access': bool(settings and settings.ai_agent_contracts_access), }) for row in contract_rows: contract_no = row.get('contract_auto_id') or row.get('contract_id') or '-' contract_name = row.get('contract_name') or row.get('contract_auto_name') or '-' customer = row.get('client_short_company') or ' '.join( filter(None, [row.get('client_short_vorname'), row.get('client_short_name')]) ).strip() or '-' state = row.get('contract_state') state_name = state[1] if isinstance(state, (list, tuple)) and len(state) > 1 else '-' project = row.get('project') project_name = project[1] if isinstance(project, (list, tuple)) and len(project) > 1 else '-' amount = row.get('vertragssumme') amount_text = str(amount) if amount not in (None, False) else '-' full_field_value_string = self._row_to_field_value_string(row) docs.append({ 'content': 'Vertrag %s | %s | Kunde: %s | Status: %s | Projekt: %s | Summe: %s | RAW : %s' % ( contract_no, contract_name, customer, state_name, project_name, amount_text, full_field_value_string, ), 'metadata': { 'type': 'contract', 'id': contract_no, 'name': contract_name, 'fields': full_field_value_string, }, }) project_rows = self._load_project_rows(settings) self._log_params('rag.build_documents.project_rows', { 'count': len(project_rows), 'projects_access': bool(settings and settings.ai_agent_projects_access), }) for row in project_rows: project_no = row.get('projectid') or '-' project_name = row.get('projektname') or row.get('name') or '-' location_parts = [ row.get('standort_strasse') or '', row.get('standort_plz') or '', row.get('standort_ort') or '', ] location = ' '.join([p for p in location_parts if p]).strip() or '-' visitors = row.get('maps_visitors') if row.get('maps_visitors') not in (None, False) else '-' status = row.get('aktstatus') status_name = status[1] if isinstance(status, (list, tuple)) and len(status) > 1 else '-' full_field_value_string = self._row_to_field_value_string(row) docs.append({ 'content': 'Projekt %s | %s | Ort: %s | Besucher: %s | Status: %s' % ( project_no, project_name, location, visitors, status_name, ), 'metadata': { 'type': 'project', 'id': project_no, 'name': project_name, 'fields': full_field_value_string, }, }) except AccessError: return [], 'Hinweis: Fuer Teile des RAG-Kontexts fehlen Berechtigungen.' except Exception as exc: _logger.exception('Could not build RAG documents: %s', exc) return [], 'Hinweis: RAG-Dokumente konnten nicht geladen werden.' self._log_params('rag.build_documents.result', {'doc_count': len(docs)}) return docs, '' def _build_rag_context(self, settings, cleaned_messages): if not settings or not settings.ai_agent_rag_enabled: self._log_params('rag.build_context.skipped', { 'enabled': bool(settings and settings.ai_agent_rag_enabled), }) return '', '' query = self._last_user_query(cleaned_messages) if not query: self._log_params('rag.build_context.no_query', {'cleaned_message_count': len(cleaned_messages or [])}) return '', '' docs, warning = self._build_rag_documents(settings) if not docs: self._log_params('rag.build_context.no_docs', {'warning': warning}) return '', warning try: from langchain_community.retrievers import BM25Retriever from langchain_core.documents import Document except Exception: return '', 'Hinweis: LangChain ist nicht installiert (benoetigt: langchain-community, langchain-core).' try: lc_docs = [Document(page_content=d['content'], metadata=d['metadata']) for d in docs] retriever = BM25Retriever.from_documents(lc_docs) retriever.k = self._safe_int(settings.ai_agent_rag_top_k or 8, default=8, min_value=1, max_value=50) self._log_params('rag.build_context.retrieve', { 'query': query, 'doc_count': len(lc_docs), 'top_k': retriever.k, }) results = retriever.invoke(query) except Exception as exc: _logger.exception('RAG retrieval failed: %s', exc) return '', 'Hinweis: RAG-Retrieval fehlgeschlagen (pruefe optionales Paket rank-bm25).' if not results: self._log_params('rag.build_context.empty_results', {'query': query}) return 'RAG-Kontext: Keine passenden Treffer gefunden.', warning lines = ['RAG-Treffer fuer Anfrage: %s' % query] for idx, item in enumerate(results, start=1): metadata = item.metadata or {} doc_type = metadata.get('type', '-') doc_id = metadata.get('id', '-') lines.append('%s. [%s:%s] %s' % (idx, doc_type, doc_id, item.page_content)) rag_text = self._clip_text('\n'.join(lines), self._MAX_CONTRACT_CONTEXT_CHARS) self._log_params('rag.build_context.result', { 'query': query, 'result_count': len(results), 'rag_text_len': len(rag_text or ''), }) return rag_text, warning def _call_chat_api(self, base_url, api_key, model, payload_messages, temperature, timeout_seconds, tools=None): self._log_params('api.call_chat_api.request', { 'base_url': base_url, 'model': model, 'message_count': len(payload_messages or []), 'temperature': temperature if temperature is not None else 0.3, 'timeout_seconds': timeout_seconds, 'tools_count': len(tools or []), }) payload = { 'model': model, 'messages': payload_messages, 'temperature': temperature if temperature is not None else 0.3, } if tools: payload['tools'] = tools response = requests.post( '%s/chat/completions' % base_url, headers={ 'Authorization': 'Bearer %s' % api_key, 'Content-Type': 'application/json', }, json=payload, timeout=timeout_seconds, ) self._log_params('api.call_chat_api.response', { 'status_code': response.status_code, 'content_length': len(response.text or ''), }) return response def _build_contracts_context(self, settings): if not settings or not settings.ai_agent_contracts_access: return "" limit = settings.ai_agent_contracts_limit or 0 try: limit = int(limit) except Exception: limit = 0 if limit < 0: limit = 0 fields_to_read = [ 'contract_auto_id', 'contract_id', 'contract_name', 'contract_auto_name', 'client_short_company', 'client_short_vorname', 'client_short_name', 'contract_state', 'project', 'vertragssumme', ] try: total = request.env['dss.contracts'].search_count([]) rows = request.env['dss.contracts'].search_read( [], fields_to_read, limit=limit if limit > 0 else None, order='write_date desc', ) except AccessError: return "Hinweis: Der aktuelle Benutzer hat keinen Zugriff auf dss.contracts." except Exception as exc: _logger.exception('Could not build contracts context: %s', exc) return "Hinweis: Vertragskontext konnte nicht geladen werden." if not rows: return "Vertragskontext: Keine Vertraege gefunden." lines = [ "Vertragskontext (dss.contracts): %s Datensaetze (geliefert: %s)" % (total, len(rows)) ] for row in rows: contract_no = row.get('contract_auto_id') or row.get('contract_id') or '-' contract_name = row.get('contract_name') or row.get('contract_auto_name') or '-' customer = row.get('client_short_company') or ' '.join( filter(None, [row.get('client_short_vorname'), row.get('client_short_name')]) ).strip() or '-' state = row.get('contract_state') state_name = state[1] if isinstance(state, (list, tuple)) and len(state) > 1 else '-' project = row.get('project') project_name = project[1] if isinstance(project, (list, tuple)) and len(project) > 1 else '-' amount = row.get('vertragssumme') amount_text = str(amount) if amount not in (None, False) else '-' lines.append( "- %s | %s | Kunde: %s | Status: %s | Projekt: %s | Summe: %s" % (contract_no, contract_name, customer, state_name, project_name, amount_text) ) return self._clip_text("\n".join(lines), self._MAX_CONTRACT_CONTEXT_CHARS) def _build_projects_context(self, settings): if not settings or not settings.ai_agent_projects_access: return "" limit = settings.ai_agent_projects_limit or 0 try: limit = int(limit) except Exception: limit = 0 if limit < 0: limit = 0 fields_to_read = [ 'projectid', 'projektname', 'name', 'standort_ort', 'standort_plz', 'standort_strasse', 'maps_visitors', 'aktstatus', ] try: total = request.env['dss.projects'].search_count([]) rows = request.env['dss.projects'].search_read( [], fields_to_read, limit=limit if limit > 0 else None, order='write_date desc', ) except AccessError: return "Hinweis: Der aktuelle Benutzer hat keinen Zugriff auf dss.projects." except Exception as exc: _logger.exception('Could not build projects context: %s', exc) return "Hinweis: Projektkontext konnte nicht geladen werden." if not rows: return "Projektkontext: Keine Projekte gefunden." lines = [ "Projektkontext (dss.projects): %s Datensaetze (geliefert: %s)" % (total, len(rows)) ] for row in rows: project_no = row.get('projectid') or '-' project_name = row.get('projektname') or row.get('name') or '-' location_parts = [ row.get('standort_strasse') or '', row.get('standort_plz') or '', row.get('standort_ort') or '', ] location = " ".join([p for p in location_parts if p]).strip() or '-' visitors = row.get('maps_visitors') if row.get('maps_visitors') not in (None, False) else '-' status = row.get('aktstatus') status_name = status[1] if isinstance(status, (list, tuple)) and len(status) > 1 else '-' lines.append( "- %s | %s | Ort: %s | Besucher: %s | Status: %s" % (project_no, project_name, location, visitors, status_name) ) return self._clip_text("\n".join(lines), self._MAX_PROJECT_CONTEXT_CHARS) @http.route('/digitalsignage/ai_agent/chat', type='json', auth='user') def ai_agent_chat(self, messages=None): """Proxy chat requests from the backend UI to an OpenAI-compatible API.""" settings = request.env['dss.settings'].sudo().search([], limit=1) icp = request.env['ir.config_parameter'].sudo() enabled = settings.ai_agent_enabled if settings else True api_key = (settings.ai_agent_api_key if settings else False) or icp.get_param('digitalsignage_ai.api_key') or os.getenv('OPENAI_API_KEY', '') base_url = ((settings.ai_agent_base_url if settings else False) or icp.get_param('digitalsignage_ai.base_url') or 'https://api.openai.com/v1').rstrip('/') model = (settings.ai_agent_model if settings else False) or icp.get_param('digitalsignage_ai.model') or 'gpt-4o-mini' system_prompt = ( (settings.ai_agent_system_prompt if settings else False) or icp.get_param('digitalsignage_ai.system_prompt') or 'Du bist ein hilfreicher virtueller Mitarbeiter im Odoo Backend.' ) timeout_value = str( (settings.ai_agent_timeout_seconds if settings and settings.ai_agent_timeout_seconds else False) or icp.get_param('digitalsignage_ai.timeout_seconds') or '60' ) temperature = ( settings.ai_agent_temperature if settings and settings.ai_agent_temperature is not False else None ) max_context_messages = ( settings.ai_agent_max_context_messages if settings and settings.ai_agent_max_context_messages else 20 ) if not enabled: return { 'reply': 'Der AI Agent ist in den DigitalSignage Einstellungen deaktiviert.' } if not api_key: return { 'reply': 'OpenAI API Key fehlt. Bitte setze digitalsignage_ai.api_key in den Systemparametern.' } timeout_seconds = 60 try: timeout_seconds = max(5, int(timeout_value)) except Exception: timeout_seconds = 60 safe_messages = messages if isinstance(messages, list) else [] self._log_params('chat.request.received', { 'user_id': request.env.user.id, 'enabled': bool(enabled), 'base_url': base_url, 'model': model, 'temperature': temperature, 'timeout_seconds': timeout_seconds, 'max_context_messages': max_context_messages, 'incoming_messages': len(safe_messages), }) cleaned_messages = [] for item in safe_messages[-max_context_messages:]: if not isinstance(item, dict): continue role = item.get('role') content = item.get('content') if role not in ('user', 'assistant'): continue if not isinstance(content, str): continue content = content.strip() if not content: continue cleaned_messages.append({'role': role, 'content': content}) # Keep only the latest user prompt for model calls. latest_user_prompt = self._last_user_query(cleaned_messages) cleaned_messages = [{'role': 'user', 'content': latest_user_prompt}] if latest_user_prompt else [] self._log_params('chat.request.cleaned', { 'user_id': request.env.user.id, 'cleaned_messages': len(cleaned_messages), 'latest_user_prompt_len': len(latest_user_prompt or ''), }) callback_prompt = (settings.ai_agent_callback_prompt if settings else '') or '' payload_messages = [{ 'role': 'system', 'content': ( system_prompt + '\n\nArbeite tool-first: Hole Businessdaten nur bei Bedarf ueber Funktionen. ' 'Sende keine grossen Datenmengen im Prompt. Nutze Paging (limit/offset).' + ('\n\n' + callback_prompt if callback_prompt else '') ), }] response_suffix = '' payload_messages += cleaned_messages tools = self._build_openai_tools() self._log_params('chat.request.payload', { 'user_id': request.env.user.id, 'payload_messages': len(payload_messages), 'tools_count': len(tools), }) try: open_table = None open_table_priority = -1 response = self._call_chat_api( base_url, api_key, model, payload_messages, temperature, timeout_seconds, tools=tools, ) if response.status_code >= 400: api_error_message = self._extract_api_error_message(response) _logger.warning('AI Agent API error %s: %s', response.status_code, api_error_message) # Retry once with reduced payload when context becomes too large. if response.status_code == 400 and payload_messages: reduced_messages = [{'role': 'system', 'content': system_prompt}] + cleaned_messages[-6:] retry_response = self._call_chat_api( base_url, api_key, model, reduced_messages, temperature, timeout_seconds, tools=tools, ) if retry_response.status_code < 400: retry_data = retry_response.json() or {} retry_choices = retry_data.get('choices') or [] if retry_choices and retry_choices[0].get('message') and retry_choices[0]['message'].get('content'): return { 'reply': "%s\n\n(Hinweis: Der Datenkontext war zu gross und wurde fuer diese Antwort reduziert.)" % retry_choices[0]['message']['content'].strip() } if api_error_message: return {'reply': 'AI-API Fehler (%s): %s' % (response.status_code, api_error_message)} return {'reply': 'AI-API Fehler (%s). Bitte Konfiguration oder Zugang pruefen.' % response.status_code} data = response.json() or {} choices = data.get('choices') or [] if choices and choices[0].get('message'): assistant_message = choices[0]['message'] self._log_params('chat.response.first', { 'user_id': request.env.user.id, 'tool_calls_count': len(assistant_message.get('tool_calls') or []), 'assistant_content_len': len((assistant_message.get('content') or '')), }) current_message = assistant_message tool_round = 0 max_tool_rounds = 4 while True: tool_calls = current_message.get('tool_calls') or [] if not tool_calls: break payload_messages.append({ 'role': 'assistant', 'content': current_message.get('content') or '', 'tool_calls': tool_calls, }) for tool_call in tool_calls: function_info = tool_call.get('function') or {} function_name = function_info.get('name') raw_arguments = function_info.get('arguments') or '{}' try: arguments = json.loads(raw_arguments) except Exception: arguments = {} # Some providers return double-encoded JSON arguments. if isinstance(arguments, str): try: arguments = json.loads(arguments) except Exception: arguments = {} if not isinstance(arguments, dict): self._log_params('chat.tool_call.arguments.invalid_type', { 'user_id': request.env.user.id, 'tool_call_id': tool_call.get('id'), 'function_name': function_name, 'arguments_type': type(arguments).__name__, }) arguments = {} self._log_params('chat.tool_call.dispatch', { 'user_id': request.env.user.id, 'tool_call_id': tool_call.get('id'), 'function_name': function_name, 'arguments': arguments, }) if function_name == 'get_all_contracts': try: tool_result = self._tool_get_all_contracts(settings, arguments) except AccessError: tool_result = {'error': 'Kein Zugriff auf dss.contracts.'} except Exception as exc: _logger.exception('Tool get_all_contracts failed: %s', exc) tool_result = {'error': 'Fehler beim Lesen von dss.contracts.'} elif function_name == 'open_contract_form': try: tool_result = self._tool_open_contract_form(arguments) except AccessError: tool_result = {'error': 'Kein Zugriff auf dss.contracts.'} except Exception as exc: _logger.exception('Tool open_contract_form failed: %s', exc) tool_result = {'error': 'Fehler beim Oeffnen des Vertragsformulars.'} elif function_name == 'get_contract_by_number': try: tool_result = self._tool_get_contract_by_number(arguments) except AccessError: tool_result = {'error': 'Kein Zugriff auf dss.contracts.'} except Exception as exc: _logger.exception('Tool get_contract_by_number failed: %s', exc) tool_result = {'error': 'Fehler beim Laden von Vertragsdetails.'} elif function_name == 'get_projects': try: tool_result = self._tool_get_projects(arguments) except AccessError: tool_result = {'error': 'Kein Zugriff auf dss.projects.'} except Exception as exc: _logger.exception('Tool get_projects failed: %s', exc) tool_result = {'error': 'Fehler beim Lesen von dss.projects.'} elif function_name == 'get_system_datetime': try: tool_result = self._tool_get_system_datetime(arguments) except Exception as exc: _logger.exception('Tool get_system_datetime failed: %s', exc) tool_result = {'error': 'Fehler beim Ermitteln von Datum/Uhrzeit.'} elif function_name == 'create_model_record': try: tool_result = self._tool_create_model_record(settings, arguments) except AccessError: tool_result = {'error': 'Kein Zugriff auf das angefragte Modell zum Anlegen.'} except Exception as exc: _logger.exception('Tool create_model_record failed: %s', exc) tool_result = {'error': 'Fehler beim Anlegen des Datensatzes.'} elif function_name == 'update_model_record': try: tool_result = self._tool_update_model_record(settings, arguments) except AccessError: tool_result = {'error': 'Kein Zugriff auf das angefragte Modell zum Aendern.'} except Exception as exc: _logger.exception('Tool update_model_record failed: %s', exc) tool_result = {'error': 'Fehler beim Aendern des Datensatzes.'} elif function_name == 'search_all_records': try: tool_result = self._tool_search_all_records(settings, arguments) except AccessError: tool_result = {'error': 'Kein Zugriff auf das angefragte Modell.'} except Exception as exc: _logger.exception('Tool search_all_records failed: %s', exc) tool_result = {'error': 'Fehler bei search_all_records.'} elif function_name == 'get_model_records': try: tool_result = self._tool_get_model_records(settings, arguments) except AccessError: tool_result = {'error': 'Kein Zugriff auf das angefragte Modell.'} except Exception as exc: _logger.exception('Tool get_model_records failed: %s', exc) tool_result = {'error': 'Fehler beim generischen Modellabruf.'} else: tool_result = {'error': 'Unbekannte Funktion: %s' % function_name} payload_messages.append({ 'role': 'tool', 'tool_call_id': tool_call.get('id'), 'content': json.dumps(tool_result, ensure_ascii=False), }) self._log_params('chat.tool_call.result', { 'user_id': request.env.user.id, 'tool_call_id': tool_call.get('id'), 'function_name': function_name, 'result_keys': sorted(list(tool_result.keys())) if isinstance(tool_result, dict) else [], }) if isinstance(tool_result, dict): explicit_open_table = tool_result.get('open_table') explicit_open_table_applied = False if isinstance(explicit_open_table, dict): candidate_priority = self._open_table_priority(function_name) if candidate_priority >= open_table_priority: open_table = explicit_open_table open_table_priority = candidate_priority explicit_open_table_applied = True result_model = tool_result.get('model') result_domain = tool_result.get('domain') result_returned = self._safe_int(tool_result.get('returned', 0), default=0, min_value=0) result_total = self._safe_int(tool_result.get('total', 0), default=0, min_value=0) result_record_ids = tool_result.get('record_ids') or [] if (not explicit_open_table_applied) and result_model and isinstance(result_domain, list) and max(result_returned, result_total) > 0: candidate_priority = self._open_table_priority(function_name) if candidate_priority >= open_table_priority: open_table = self._build_open_table_payload( result_model, result_domain, returned=result_returned, total=result_total, record_ids=result_record_ids, ) open_table_priority = candidate_priority tool_round += 1 if tool_round >= max_tool_rounds: final_reply = 'Zu viele aufeinanderfolgende Tool-Aufrufe. Bitte die Anfrage vereinfachen.' self._log_params('chat.tool_call.max_rounds', { 'user_id': request.env.user.id, 'tool_rounds': tool_round, }) self._save_exchange(cleaned_messages, final_reply) if open_table: return {'reply': final_reply, 'open_table': open_table} return {'reply': final_reply} followup_response = self._call_chat_api( base_url, api_key, model, payload_messages, temperature, timeout_seconds, tools=tools, ) if followup_response.status_code >= 400: api_error_message = self._extract_api_error_message(followup_response) self._log_params('chat.followup.error', { 'user_id': request.env.user.id, 'status_code': followup_response.status_code, 'api_error_message': api_error_message, }) return {'reply': 'AI-API Fehler (%s): %s' % (followup_response.status_code, api_error_message)} followup_data = followup_response.json() or {} followup_choices = followup_data.get('choices') or [] if not followup_choices or not followup_choices[0].get('message'): final_reply = 'Die AI hat keine verwertbare Antwort geliefert.' self._log_params('chat.followup.empty_reply', { 'user_id': request.env.user.id, 'tool_rounds': tool_round + 1, }) self._save_exchange(cleaned_messages, final_reply) if open_table: return {'reply': final_reply, 'open_table': open_table} return {'reply': final_reply} current_message = followup_choices[0]['message'] final_reply = (current_message.get('content') or '').strip() + response_suffix if not final_reply: final_reply = 'Die AI hat keine verwertbare Antwort geliefert.' self._log_params('chat.followup.success', { 'user_id': request.env.user.id, 'reply_len': len(final_reply or ''), 'tool_rounds': tool_round, }) self._save_exchange(cleaned_messages, final_reply) if open_table: return {'reply': final_reply, 'open_table': open_table} return {'reply': final_reply} final_reply = 'Die AI hat keine verwertbare Antwort geliefert.' self._log_params('chat.empty_reply', { 'user_id': request.env.user.id, 'choices_count': len(choices), }) self._save_exchange(cleaned_messages, final_reply) return {'reply': final_reply} except requests.exceptions.RequestException as exc: _logger.exception('AI Agent request failed: %s', exc) final_reply = 'Verbindung zur AI fehlgeschlagen. Bitte spaeter erneut versuchen.' self._save_exchange(cleaned_messages, final_reply) return {'reply': final_reply} except Exception as exc: _logger.exception('Unexpected AI Agent error: %s', exc) final_reply = 'Unerwarteter Fehler im AI-Agenten.' self._save_exchange(cleaned_messages, final_reply) return {'reply': final_reply}