Files
gzth/yuthon_ai_agent/models/ai_rule.py
T
2026-07-02 11:29:18 +08:00

292 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)}