diff --git a/yuthon_ai_agent/models/ai_agent.py b/yuthon_ai_agent/models/ai_agent.py index 98b981611..29158178b 100644 --- a/yuthon_ai_agent/models/ai_agent.py +++ b/yuthon_ai_agent/models/ai_agent.py @@ -466,26 +466,29 @@ class AiConversation(models.Model): return {'error': f'{type(e).__name__}: {e}'} def _execute_skill_tool(self, name, args): - """尝试执行技能规则工具,找不到返回 None""" + """尝试执行技能/方法规则工具,找不到返回 None""" provider = self.provider_id if not provider: return None - skill_rule = self.env['ai.rule'].sudo().search([ - ('rule_type', '=', 'skill'), + # 先查 skill 类型 + rule = self.env['ai.rule'].sudo().search([ + ('rule_type', 'in', ['skill', 'method']), ('skill_name', '=', name), ('active', '=', True), ('provider_ids', 'in', [provider.id]), ], limit=1) - if not skill_rule: - # 也搜索不限服务商的全局技能 - skill_rule = self.env['ai.rule'].sudo().search([ - ('rule_type', '=', 'skill'), + if not rule: + # 也搜索不限服务商的全局规则 + rule = self.env['ai.rule'].sudo().search([ + ('rule_type', 'in', ['skill', 'method']), ('skill_name', '=', name), ('active', '=', True), ('provider_ids', '=', False), ], limit=1) - if skill_rule: - return skill_rule.execute_skill(args) + if rule: + if rule.rule_type == 'method': + return rule.execute_method(args) + return rule.execute_skill(args) return None def _execute_generate_report(self, args): @@ -875,7 +878,7 @@ class AiConversation(models.Model): applicable_rules = provider.get_applicable_rules() instruction_rules = applicable_rules.filtered(lambda r: r.rule_type == 'instruction') restriction_rules = applicable_rules.filtered(lambda r: r.rule_type == 'restriction') - skill_rules = applicable_rules.filtered(lambda r: r.rule_type == 'skill' and r.skill_name) + skill_rules = applicable_rules.filtered(lambda r: r.rule_type in ('skill', 'method') and r.skill_name) if instruction_rules: system_msg += '\n--- 回复指令(必须遵守)---\n' diff --git a/yuthon_ai_agent/models/ai_rule.py b/yuthon_ai_agent/models/ai_rule.py index 7b34cdcb8..909a395d6 100644 --- a/yuthon_ai_agent/models/ai_rule.py +++ b/yuthon_ai_agent/models/ai_rule.py @@ -23,10 +23,12 @@ class AiRule(models.Model): ('instruction', '回复指令'), ('restriction', '限制规则'), ('skill', '技能接口'), + ('method', '调用模型方法'), ], string='规则类型', required=True, default='instruction', help='回复指令:注入到 AI 系统提示词中,控制回复风格和内容。\n' '限制规则:告诉 AI 哪些话题不能回答。\n' - '技能接口:对接外部 API,AI 可调用获取专业数据。' + '技能接口:对接外部 API,AI 可调用获取专业数据。\n' + '调用模型方法:直接调用 Odoo 模型方法(如撤回消息),无需 HTTP 接口。' ) active = fields.Boolean(string='启用', default=True) priority = fields.Integer(string='优先级', default=10, help='数字越小优先级越高') @@ -81,6 +83,16 @@ class AiRule(models.Model): string='接口超时(秒)', default=10, ) + # ---- 调用模型方法配置 ---- + 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): """调用技能接口,返回结果字典""" @@ -121,7 +133,7 @@ class AiRule(models.Model): return {'error': '技能接口返回格式错误(非 JSON)'} def get_tool_schema(self): - """将技能规则转换为 OpenAI 兼容的 tool schema""" + """将技能规则转换为 OpenAI 兼容的 tool schema(skill 和 method 类型通用)""" self.ensure_one() try: params = json.loads(self.skill_parameters or '{}') @@ -135,3 +147,48 @@ class AiRule(models.Model): '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}'} + diff --git a/yuthon_ai_agent/views/ai_rule_views.xml b/yuthon_ai_agent/views/ai_rule_views.xml index ce4a48397..ee3c0ce3b 100644 --- a/yuthon_ai_agent/views/ai_rule_views.xml +++ b/yuthon_ai_agent/views/ai_rule_views.xml @@ -13,7 +13,8 @@ + decoration-success="rule_type == 'skill'" + decoration-danger="rule_type == 'method'"/> @@ -58,7 +59,7 @@ + invisible="rule_type in ('skill', 'method')"> @@ -88,6 +89,28 @@ placeholder='{"Authorization": "Bearer xxx"}'/> + + + + + + + + + + + + + + + + @@ -110,6 +133,8 @@ domain="[('rule_type','=','restriction')]"/> + diff --git a/yuthon_hr_attendance/models/yuthon_vacation_reports.py b/yuthon_hr_attendance/models/yuthon_vacation_reports.py index 03b6e37c5..f780afb1a 100644 --- a/yuthon_hr_attendance/models/yuthon_vacation_reports.py +++ b/yuthon_hr_attendance/models/yuthon_vacation_reports.py @@ -3556,6 +3556,7 @@ class VacationOtherLine(models.Model): class AttendanceMessageLog(models.Model): _name = 'attendance.message.log' _description = '消息提醒日志' + _order = 'update_date desc' vacation_id = fields.Many2one('yuthon.vacation.reports', string='关联假期额度记录') employee_id = fields.Many2one('hr.employee', string='员工') @@ -3568,6 +3569,34 @@ class AttendanceMessageLog(models.Model): notice_type = fields.Selection([('attendance', '出勤'), ('schedule', '日程'), ('announcement', '公告'), ('attendance_confirmation', '考勤确认单'), ('salary_slip', '薪资条'), ('process', '流程'), ('other', '其他')], string='类型') message_url = fields.Char(string="url") + msg_id = fields.Char(string='消息ID') + category = fields.Char(string='消息类别') + + def action_recall_message(self): + """撤回企微消息(调用企微back_message接口)""" + self.ensure_one() + if not self.msg_id: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': '撤回失败', + 'message': '消息ID为空,无法撤回', + 'type': 'warning', + } + } + category = self.category or 'att' + self.env['wecom.apps'].back_message(self.msg_id, category=category) + self.msg_id = '已撤回' + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': '撤回成功', + 'message': f'消息已撤回(msg_id={self.msg_id})', + 'type': 'success', + } + } @api.depends('employee_id') def _compute_shift_id(self): diff --git a/yuthon_hr_attendance/views/attendance_message_log.xml b/yuthon_hr_attendance/views/attendance_message_log.xml index 68ebe5675..2b4269b1a 100644 --- a/yuthon_hr_attendance/views/attendance_message_log.xml +++ b/yuthon_hr_attendance/views/attendance_message_log.xml @@ -4,6 +4,7 @@ attendance.message.log +