# -*- coding: utf-8 -*- import json import logging import requests from odoo import models, fields, api, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class AiRule(models.Model): """AI 规则配置 — 控制 AI 回复行为、限制范围、对接技能接口""" _name = 'ai.rule' _description = 'AI 规则' _order = 'priority, id' create_date = fields.Datetime(string='创建时间', readonly=True) write_date = fields.Datetime(string='最后更新', readonly=True) name = fields.Char(string='规则名称', required=True) rule_type = fields.Selection([ ('instruction', '回复指令'), ('restriction', '限制规则'), ('skill', '技能接口'), ('method', '调用模型方法'), ('active_guide', '主动感知'), ], string='规则类型', required=True, default='instruction', help='回复指令:注入到 AI 系统提示词中,控制回复风格和内容。\n' '限制规则:告诉 AI 哪些话题不能回答。\n' '技能接口:对接外部 API,AI 可调用获取专业数据。\n' '调用模型方法:直接调用 Odoo 模型方法(如撤回消息),无需 HTTP 接口。\n' '主动感知:在用户操作(如流程结束)时主动弹出引导消息。' ) active = fields.Boolean(string='启用', default=True) priority = fields.Integer(string='优先级', default=10, help='数字越小优先级越高') # ---- 适用范围 ---- provider_ids = fields.Many2many( 'ai.provider', 'ai_rule_provider_rel', 'rule_id', 'provider_id', string='适用服务商', help='留空则对所有服务商生效', ) group_ids = fields.Many2many( 'res.groups', 'ai_rule_group_rel', 'rule_id', 'group_id', string='适用用户组', help='留空则对所有用户生效', ) # ---- 回复指令 / 限制规则 内容 ---- content = fields.Text( string='规则内容', help='回复指令类型:写给 AI 的系统指令,如"所有回复不超过 200 字"。\n' '限制规则类型:描述不允许回答的话题,如"不得回答薪资相关问题"。', ) # ---- 技能接口配置 ---- skill_name = fields.Char( string='技能名称', help='AI 工具调用时的函数名,如 query_weather, get_stock_price', ) skill_description = fields.Text( string='技能描述', help='告诉 AI 什么时候调用这个技能,如"当用户询问天气时调用"', ) skill_parameters = fields.Text( string='参数定义 (JSON Schema)', help='OpenAI 兼容的 JSON Schema 格式,定义技能需要的参数', default='{\n "type": "object",\n "properties": {},\n "required": []\n}', ) skill_endpoint = fields.Char( string='接口地址', help='技能对接的 API URL,如 https://api.example.com/weather', ) skill_method = fields.Selection([ ('GET', 'GET'), ('POST', 'POST'), ], string='请求方式', default='POST') skill_headers = fields.Text( string='请求头 (JSON)', help='额外请求头,如 {"Authorization": "Bearer xxx"}', default='{}', ) skill_timeout = fields.Integer( string='接口超时(秒)', default=10, ) # ---- 数据写入权限 ---- can_write = fields.Boolean( string='允许修改数据', default=False, help='勾选后,匹配此规则时 AI 可以调用 create/write/unlink 工具修改 Odoo 数据。' '只要有一条适用规则勾选了此项,AI 就拥有写入权限。', ) # ---- 调用模型方法配置 ---- skill_model = fields.Char( string='目标模型', help='方法所在模型的技术名称,如 attendance.message.log。仅 method 类型使用。', ) skill_method_name = fields.Char( string='方法名称', help='要调用的模型方法名,如 action_recall_message。仅 method 类型使用。', ) # ---- 技能接口执行 ---- def execute_skill(self, arguments): """调用技能接口,返回结果字典""" self.ensure_one() if self.rule_type != 'skill' or not self.skill_endpoint: return {'error': '该规则不是技能接口类型或未配置接口地址'} try: headers = json.loads(self.skill_headers or '{}') except json.JSONDecodeError: headers = {} headers.setdefault('Content-Type', 'application/json') timeout = max(self.skill_timeout or 10, 1) try: if self.skill_method == 'GET': resp = requests.get( self.skill_endpoint, params=arguments, headers=headers, timeout=timeout, ) else: resp = requests.post( self.skill_endpoint, json=arguments, headers=headers, timeout=timeout, ) resp.raise_for_status() return resp.json() except requests.exceptions.Timeout: return {'error': f'技能接口超时({timeout}秒)'} except requests.exceptions.RequestException as e: _logger.exception('Skill %s call failed: %s', self.skill_name, e) return {'error': f'技能接口调用失败:{e}'} except (ValueError, json.JSONDecodeError): return {'error': '技能接口返回格式错误(非 JSON)'} def get_tool_schema(self): """将技能规则转换为 OpenAI 兼容的 tool schema(skill 和 method 类型通用)""" self.ensure_one() try: params = json.loads(self.skill_parameters or '{}') except json.JSONDecodeError: params = {'type': 'object', 'properties': {}} return { 'type': 'function', 'function': { 'name': self.skill_name or f'skill_{self.id}', 'description': self.skill_description or self.name, 'parameters': params, }, } def execute_method(self, arguments): """执行模型方法调用(rule_type='method')。搜索目标模型记录,逐条调用指定方法。""" self.ensure_one() if self.rule_type != 'method': return {'error': '该规则不是调用模型方法类型'} if not self.skill_model or self.skill_model not in self.env: return {'error': f'未配置目标模型或模型不存在:{self.skill_model}'} if not self.skill_method_name: return {'error': '未配置方法名称'} Model = self.env[self.skill_model] method = getattr(Model, self.skill_method_name, None) if not method: return {'error': f'模型 {self.skill_model} 中没有方法 {self.skill_method_name}'} try: domain = arguments.get('domain') or [] ids = arguments.get('ids') or [] if ids: records = Model.browse(ids).exists() else: records = Model.search(domain) if not records: return {'success': True, 'message': '没有找到符合条件的记录', 'count': 0} results = [] for rec in records: try: res = method(rec) results.append({'id': rec.id, 'result': str(res) if res else None}) except Exception as e: results.append({'id': rec.id, 'error': str(e)}) return { 'success': True, 'count': len(records), 'results': results, } except Exception as e: _logger.exception('Method rule %s failed: %s', self.skill_name, e) return {'error': f'{type(e).__name__}: {e}'} # ---- 主动感知配置 ---- guide_model = fields.Char( string='触发模型', help='监听哪个模型,如 yuthon.notice', ) guide_field = fields.Char( string='触发字段', help='监听哪个字段变化,如 work_end', ) guide_field_value = fields.Char( string='触发值', help='字段值等于什么时触发,如 True', ) guide_message = fields.Char( string='提醒内容', help='主动弹出时显示的文字,支持变量:{record_name}, {user_name}', ) guide_button_label = fields.Char( string='按钮文字', default='确认执行', ) guide_method_name = fields.Char( string='后台方法名', help='按钮点击后调用的后台方法名,如 notice_send_to_all。后台自动传入 record_id(当前记录ID)', ) guide_method_desc = fields.Char( string='方法说明', ) def _do_test_guide(self): """测试主动感知:模拟触发,创建引导消息(内部方法,由控制器调用)""" self.ensure_one() if self.rule_type != 'active_guide': raise UserError('仅主动感知类型的规则可以测试') # 获取或创建用户对话 conv = self.env['ai.conversation'].search([ ('create_uid', '=', self.env.uid), ], limit=1, order='write_date desc') if not conv: provider = self.env['ai.provider'].search([('active', '=', True)], limit=1) if not provider: raise UserError('请先配置 AI 服务商') conv = self.env['ai.conversation'].create({ 'name': 'AI 对话', 'provider_id': provider.id, }) # 组装测试消息(替换变量为测试值) message = self.guide_message or '【测试】主动感知已触发' message = message.replace('{record_name}', '[测试记录]') message = message.replace('{user_name}', self.env.user.name) message = message.replace('{record_id}', '[测试]') # 组装 action config(按钮点击后的动作) action_config = { 'type': 'execute_tool', 'tool': self.guide_method_name or '', 'params': { 'record_id': 0, 'model': self.guide_model or '', } } # 创建 AI 消息(带 action 按钮) self.env['ai.message'].create({ 'conversation_id': conv.id, 'role': 'assistant', 'content': f'🧪 {message}', 'action': json.dumps({ 'type': 'guide_button', 'label': self.guide_button_label or '确认执行', 'action': action_config, }, ensure_ascii=False), }) return conv.id def action_test_guide(self): """按钮调用:创建测试消息,返回 conv_id 供 JS 打开聊天框""" try: conv_id = self._do_test_guide() return {'conv_id': conv_id, 'message': '测试消息已创建,请查看聊天框'} except UserError as e: return {'error': str(e)}