2329 lines
98 KiB
Python
Executable File
2329 lines
98 KiB
Python
Executable File
# -*- 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}
|