292 lines
11 KiB
Python
292 lines
11 KiB
Python
# -*- 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)}
|