# -*- coding: utf-8 -*- import json import logging from datetime import datetime import pytz import requests from odoo import models, fields, api from odoo.exceptions import AccessError _logger = logging.getLogger(__name__) # 单次对话最多允许的工具调用轮次(防止死循环) MAX_TOOL_ITERATIONS = 12 # search_read 默认/最大返回条数 DEFAULT_LIMIT = 20 MAX_LIMIT = 200 class AiProvider(models.Model): """AI 服务配置""" _name = 'ai.provider' _description = 'AI 服务商' create_date = fields.Datetime(string='创建时间', readonly=True) write_date = fields.Datetime(string='最后更新', readonly=True) name = fields.Char(string='名称', required=True) provider_type = fields.Selection([ ('openai', 'OpenAI'), ('azure', 'Azure OpenAI'), ('local', '本地模型 (Ollama/LocalAI)'), ('custom', '自定义'), ], string='类型', default='openai', required=True) api_base = fields.Char( string='API 地址', default='https://api.openai.com/v1', help='API Base URL,包含 /v1' ) api_key = fields.Char(string='API Key') model_name = fields.Char( string='模型名称', default='gpt-4o-mini', help='如 gpt-4o-mini, qwen-plus, llama3 等' ) max_tokens = fields.Integer(string='最大 Token', default=4096) temperature = fields.Float(string='温度', default=0.7) timeout = fields.Integer( string='超时(秒)', default=60, help='AI 单次请求的最大等待秒数(读取超时),超过后返回超时错误。连接超时固定 10 秒。默认 60 秒。' ) active = fields.Boolean(string='启用', default=True) enable_tools = fields.Boolean( string='启用工具调用', default=True, help='开启后 AI 可以查询/创建/修改 Odoo 数据(Function Calling)。要求模型支持 tools 参数(如 gpt-4o, qwen-plus, deepseek-chat 等)。' ) allow_write = fields.Boolean( string='允许写入', default=False, help='允许 AI 调用 create/write/unlink 工具。建议仅在受信任环境开启。' ) # OCR 配置 ocr_enabled = fields.Boolean( string='启用 OCR', default=False, help='开启后聊天支持上传图片/PDF,自动调用腾讯云 OCR 提取文字后再发给 AI' ) ocr_secret_id = fields.Char( string='OCR SecretId', help='腾讯云 API 密钥 SecretId,在 https://console.cloud.tencent.com/cam/capi 获取' ) ocr_secret_key = fields.Char( string='OCR SecretKey', help='腾讯云 API 密钥 SecretKey' ) ocr_region = fields.Char( string='OCR 地域', default='ap-guangzhou', help='腾讯云 OCR 服务地域,如 ap-guangzhou, ap-shanghai, ap-beijing' ) # 规则配置 rule_ids = fields.Many2many( 'ai.rule', 'ai_rule_provider_rel', 'provider_id', 'rule_id', string='关联规则', help='为该服务商绑定的 AI 规则(回复指令、限制规则、技能接口)', ) # Token 统计(关联到本服务商) provider_total_tokens = fields.Integer( string='总 Token', compute='_compute_provider_token_stats' ) provider_call_count = fields.Integer( string='调用次数', compute='_compute_provider_token_stats' ) def _compute_provider_token_stats(self): """统计该服务商的 Token 总量和调用次数""" for rec in self: usage_data = self.env['ai.token.usage'].sudo().read_group( [('provider_id', '=', rec.id)], ['total_tokens:sum'], [], ) rec.provider_total_tokens = usage_data[0]['total_tokens'] if usage_data else 0 rec.provider_call_count = self.env['ai.token.usage'].sudo().search_count( [('provider_id', '=', rec.id)] ) def action_view_provider_token_usage(self): """打开该服务商的 Token 用量明细""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': f'{self.name} - Token 用量', 'res_model': 'ai.token.usage', 'view_mode': 'tree', 'domain': [('provider_id', '=', self.id)], 'context': {'search_default_group_user': 1}, } def get_applicable_rules(self, user=None): """获取对当前用户生效的规则(按优先级排序)""" self.ensure_one() user = user or self.env.user rules = self.rule_ids.filtered(lambda r: r.active) # 过滤用户组:规则未配用户组 = 对所有人生效,配了则检查用户是否在组中 applicable = rules.filtered( lambda r: not r.group_ids or (r.group_ids & user.groups_id) ) return applicable.sorted('priority') class AiConversation(models.Model): """对话会话""" _name = 'ai.conversation' _description = 'AI 对话' _order = 'create_date desc' create_date = fields.Datetime(string='创建时间', readonly=True) write_date = fields.Datetime(string='最后更新', readonly=True) name = fields.Char(string='标题', required=True, default='新对话') provider_id = fields.Many2one( 'ai.provider', string='AI 服务', required=True, default=lambda self: self.env['ai.provider'].search([('active', '=', True)], limit=1) ) message_ids = fields.One2many( 'ai.message', 'conversation_id', string='消息' ) message_count = fields.Integer( string='消息数', compute='_compute_message_count', store=True ) create_uid = fields.Many2one('res.users', string='用户', default=lambda self: self.env.uid) # 从 provider 映射过来的可读字段,方便在对话页直观查看当前助手配置 provider_model_name = fields.Char(related='provider_id.model_name', string='模型名称', readonly=True) provider_timeout = fields.Integer(related='provider_id.timeout', string='超时(秒)', readonly=True) provider_enable_tools = fields.Boolean(related='provider_id.enable_tools', string='工具调用', readonly=True) provider_allow_write = fields.Boolean(related='provider_id.allow_write', string='允许写入', readonly=True) @api.depends('message_ids') def _compute_message_count(self): for rec in self: rec.message_count = len(rec.message_ids) def action_clear(self): """清空对话消息""" self.ensure_one() self.message_ids.unlink() def open_chat(self): """打开聊天界面""" self.ensure_one() return { 'type': 'ir.actions.client', 'tag': 'yuthon_ai_agent.chat', 'name': self.name, 'params': {'res_id': self.id}, } # --------------------------------------------------------- # Function Calling: 工具定义 & 执行 # --------------------------------------------------------- def _get_tools_schema(self, allow_write=False): """返回 OpenAI 兼容的工具列表(JSON Schema)。 :param allow_write: 是否允许写入操作(由适用规则的 can_write 决定) """ tools = [ { 'type': 'function', 'function': { 'name': 'odoo_search_read', 'description': '搜索并读取 Odoo 数据库记录。常见模型:res.partner(联系人)、product.product(产品)、sale.order(销售订单)、account.move(发票)、stock.picking(出入库)、hr.employee(员工)、res.users(用户)。', 'parameters': { 'type': 'object', 'properties': { 'model': {'type': 'string', 'description': '模型技术名称,如 res.partner'}, 'domain': {'type': 'array', 'description': "Odoo domain,如 [['name','ilike','张三']],默认 []", 'items': {}}, 'fields': {'type': 'array', 'items': {'type': 'string'}, 'description': '要读取的字段列表,留空则只读 id/display_name'}, 'limit': {'type': 'integer', 'description': f'返回条数,默认 {DEFAULT_LIMIT},最大 {MAX_LIMIT}'}, 'order': {'type': 'string', 'description': '排序,如 "create_date desc"'}, }, 'required': ['model'], }, }, }, { 'type': 'function', 'function': { 'name': 'odoo_fields_get', 'description': '获取模型字段定义(类型、label、关系字段指向的模型)。当不清楚字段名时先调用此工具。', 'parameters': { 'type': 'object', 'properties': { 'model': {'type': 'string'}, 'allfields': {'type': 'array', 'items': {'type': 'string'}, 'description': '可选:只返回指定字段'}, }, 'required': ['model'], }, }, }, { 'type': 'function', 'function': { 'name': 'odoo_search_count', 'description': '统计满足条件的记录数。', 'parameters': { 'type': 'object', 'properties': { 'model': {'type': 'string'}, 'domain': {'type': 'array', 'items': {}}, }, 'required': ['model'], }, }, }, { 'type': 'function', 'function': { 'name': 'navigate_to', 'description': ( '引导用户跳转到 Odoo 指定页面(菜单/列表/表单/看板)。\n' '当用户说"打开XX页面"/"帮我打开XX"/"跳转到XX"/"去XX"/"查看XX列表"时调用。\n' '调用后聊天框会显示一个可点击的操作按钮,用户点击即可跳转。\n' '【精确跳转】如果用户说出了菜单层级(如"会议日程下的日程管理"),' '请使用 menu_path 参数,格式为"父菜单/子菜单";' '如果只是模糊说"日程管理",请使用 menu_name 参数。' ), 'parameters': { 'type': 'object', 'properties': { 'action_type': { 'type': 'string', 'enum': ['menu', 'list', 'form', 'kanban'], 'description': '跳转类型:menu=菜单, list=列表视图, form=表单详情, kanban=看板视图', }, 'model': { 'type': 'string', 'description': '目标模型技术名称,如 sale.order, res.partner, account.move。list/form/kanban 类型必填', }, 'res_id': { 'type': 'integer', 'description': '记录 ID,仅 action_type=form 时需要', }, 'name': { 'type': 'string', 'description': '操作按钮显示的文案,如"打开销售订单列表"、"查看张三的详情"', }, 'domain': { 'type': 'array', 'description': '可选,列表视图的筛选条件,如 [["state","=","draft"]]', 'items': {}, }, 'menu_path': { 'type': 'string', 'description': '【精确跳转】菜单层级路径,如"会议日程/日程管理"、"销售/销售订单/报价单"', }, 'menu_name': { 'type': 'string', 'description': '【模糊匹配】目标菜单名称,如"日程管理"、"销售订单"', }, 'menu_xmlid': { 'type': 'string', 'description': '目标菜单的完整 XML ID(如 meeting_schedule.menu_schedule)' }, }, 'required': ['action_type'], }, }, }, { 'type': 'function', 'function': { 'name': 'save_memory', 'description': ( '将用户的偏好、习惯、工作内容等信息保存为长期记忆。' '当对话中发现值得记住的用户信息时主动调用。' '分类:habit(习惯偏好)、work(工作内容)、knowledge(专业知识)、' 'style(沟通风格)、personal(个人信息)、other(其他)。' ), 'parameters': { 'type': 'object', 'properties': { 'category': { 'type': 'string', 'enum': ['habit', 'work', 'knowledge', 'style', 'personal', 'other'], 'description': '记忆类别', }, 'content': { 'type': 'string', 'description': '要记住的内容,简洁一句话概括', }, }, 'required': ['category', 'content'], }, }, }, { 'type': 'function', 'function': { 'name': 'generate_report', 'description': ( '当查询结果数据较多(超过5条)或用户要求查看列表/报表时,' '生成一个动态报表H5页面链接。用户点击链接可在手机上查看整齐的表格数据。\n' '【推荐用法】先用 odoo_search_read 查询数据,将返回的 records ' '数组直接传入 records 参数。这样无需重复指定 model/domain/fields,' '且用户看到的数据与你的查询结果完全一致。\n' '【传统用法】如果不传 records,则必须传 model/domain/fields,' '系统将重新执行查询(数据可能与之前 odoo_search_read 结果不一致)。' ), 'parameters': { 'type': 'object', 'properties': { 'name': {'type': 'string', 'description': '报表名称,如"本月销售订单"'}, 'records': { 'type': 'array', 'items': {'type': 'object'}, 'description': '【推荐】odoo_search_read 返回的 records 数组,直接传入即可。传入后无需指定 model/domain/fields。', }, 'model': {'type': 'string', 'description': '模型技术名称,如 sale.order。传入 records 时可省略'}, 'domain': {'type': 'array', 'description': "查询条件 domain。传入 records 时可省略", 'items': {}}, 'fields': {'type': 'array', 'items': {'type': 'string'}, 'description': '要显示的字段列表。传入 records 时可省略'}, 'field_labels': {'type': 'object', 'description': '字段中文标签,如 {"name":"订单号","amount_total":"金额"}'}, 'order': {'type': 'string', 'description': '排序,如 "create_date desc"。传入 records 时可省略'}, 'template_id': { 'type': 'integer', 'description': '报表模板ID,使用模板的列配置和参数定义。可从"可用报表模板"列表中获取', }, 'params': { 'type': 'object', 'description': '模板参数值,key=参数名, value=参数值。根据模板的param_definitions传入', 'additionalProperties': True, }, }, 'required': ['name'], }, }, }, { 'type': 'function', 'function': { 'name': 'get_report_link', 'description': ( '获取报表模板对应的固定报表链接(不需要重新生成)。\n' '当用户需要某个常规报表时,优先使用此工具而不是 generate_report。\n' '如果模板有固定链接(后台已预生成),直接返回链接;' '如果没有固定链接,返回 null 让你知道需要调用 generate_report。' ), 'parameters': { 'type': 'object', 'properties': { 'template_id': { 'type': 'integer', 'description': '报表模板ID,从"可用报表模板"列表中获取', }, }, 'required': ['template_id'], }, }, }, ] if allow_write: tools += [ { 'type': 'function', 'function': { 'name': 'odoo_create', 'description': '在 Odoo 中创建一条记录,返回新记录 id 与 display_name。', 'parameters': { 'type': 'object', 'properties': { 'model': {'type': 'string'}, 'values': {'type': 'object', 'description': '字段名 -> 值。Many2one 字段传 id;Many2many/One2many 用 [(6,0,[ids])] 等命令格式'}, }, 'required': ['model', 'values'], }, }, }, { 'type': 'function', 'function': { 'name': 'odoo_write', 'description': '更新已有记录。', 'parameters': { 'type': 'object', 'properties': { 'model': {'type': 'string'}, 'ids': {'type': 'array', 'items': {'type': 'integer'}}, 'values': {'type': 'object'}, }, 'required': ['model', 'ids', 'values'], }, }, }, { 'type': 'function', 'function': { 'name': 'odoo_unlink', 'description': '删除记录(请谨慎,先与用户确认)。', 'parameters': { 'type': 'object', 'properties': { 'model': {'type': 'string'}, 'ids': {'type': 'array', 'items': {'type': 'integer'}}, }, 'required': ['model', 'ids'], }, }, }, ] return tools def _execute_tool(self, name, args, allow_write=False): """执行单个工具调用,返回可 JSON 序列化的结果字典。 所有 ORM 操作都走当前用户权限(self.env),自动遵循 ir.rule / ir.model.access。 :param allow_write: 是否允许写入操作(由适用规则的 can_write 决定) """ try: # navigate_to 工具 — 返回 action 标记,中断 tool_calls 循环 if name == 'navigate_to': return self._execute_navigate_to(args) # save_memory 工具单独处理(不需要 model 参数) if name == 'save_memory': profile = self.env['ai.user.profile'].get_or_create() category = args.get('category', 'other') content = args.get('content', '') if not content: return {'error': '记忆内容不能为空'} # 检查是否已有相似记忆,避免重复 existing = self.env['ai.user.memory'].search([ ('profile_id', '=', profile.id), ('content', '=', content), ('active', '=', True), ], limit=1) if existing: return {'success': True, 'message': '该记忆已存在,跳过'} self.env['ai.user.memory'].create({ 'profile_id': profile.id, 'category': category, 'content': content, 'source': 'auto', }) return {'success': True, 'message': f'已记住:{content}'} # generate_report 工具单独处理 if name == 'generate_report': return self._execute_generate_report(args) # get_report_link:获取模板的固定报表链接(无需重新生成) if name == 'get_report_link': return self._execute_get_report_link(args) model_name = args.get('model') if not model_name or model_name not in self.env: return {'error': f'未知模型:{model_name}'} Model = self.env[model_name] if name == 'odoo_search_read': domain = args.get('domain') or [] fields_ = args.get('fields') or [] limit = min(int(args.get('limit') or DEFAULT_LIMIT), MAX_LIMIT) order = args.get('order') or None records = Model.search_read(domain, fields_, limit=limit, order=order) # 将 UTC 时间转换为用户时区 records = self._convert_records_timezone(records, model_name, fields_) return {'success': True, 'count': len(records), 'records': records} if name == 'odoo_search_count': domain = args.get('domain') or [] return {'success': True, 'count': Model.search_count(domain)} if name == 'odoo_fields_get': allfields = args.get('allfields') or [] fg = Model.fields_get(allfields, attributes=['type', 'string', 'relation', 'required', 'readonly', 'selection']) return {'success': True, 'fields': fg} if name == 'odoo_create': if not allow_write: return {'error': '当前匹配的规则未启用写入权限,AI 无法修改数据。' '如需开启,请在【规则配置】中勾选对应规则的"允许修改数据"。'} rec = Model.create(args.get('values') or {}) return {'success': True, 'id': rec.id, 'display_name': rec.display_name} if name == 'odoo_write': if not allow_write: return {'error': '当前匹配的规则未启用写入权限,AI 无法修改数据。' '如需开启,请在【规则配置】中勾选对应规则的"允许修改数据"。'} ids = args.get('ids') or [] Model.browse(ids).write(args.get('values') or {}) return {'success': True, 'updated_ids': ids} if name == 'odoo_unlink': if not allow_write: return {'error': '当前匹配的规则未启用写入权限,AI 无法修改数据。' '如需开启,请在【规则配置】中勾选对应规则的"允许修改数据"。'} ids = args.get('ids') or [] Model.browse(ids).unlink() return {'success': True, 'deleted_ids': ids} # 自定义模型方法调用(主动感知按钮触发) # action_config 格式: {"type": "execute_tool", "tool": "方法名", "params": {"record_id": 123, "model": "yuthon.notice"}} if model_name and args.get('record_id'): record = self.env[model_name].browse(args['record_id']) if not record.exists(): return {'error': f'记录不存在:{model_name}, id={args["record_id"]}'} method = getattr(record, name, None) if not method or not callable(method): return {'error': f'模型 {model_name} 没有可调用的方法:{name}'} result = method() return {'success': True, 'result': str(result) if result is not None else '执行成功'} return {'error': f'未知工具:{name}'} except Exception as e: _logger.exception('Tool %s failed: %s', name, e) return {'error': f'{type(e).__name__}: {e}'} def _execute_skill_tool(self, name, args): """尝试执行技能/方法规则工具,找不到返回 None""" provider = self.provider_id if not provider: return None # 先查 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 rule: # 也搜索不限服务商的全局规则 rule = self.env['ai.rule'].sudo().search([ ('rule_type', 'in', ['skill', 'method']), ('skill_name', '=', name), ('active', '=', True), ('provider_ids', '=', False), ], limit=1) if rule: if rule.rule_type == 'method': return rule.execute_method(args) return rule.execute_skill(args) return None def _get_menu_action_by_path_or_name(self, menu_path=None, menu_name=None, menu_xmlid=None): """根据菜单路径/名称/xmlid 查找对应的 ir.ui.menu 及其 action,返回 (action_dict, menu_path_str)。 匹配优先级: 1. menu_xmlid 精确匹配 2. menu_path 按层级匹配,如 "会议日程/日程管理" 3. menu_name 模糊匹配 """ Menu = self.env['ir.ui.menu'].sudo() menu = None path_str = menu_name or menu_path or '' # 1) 按 xmlid 精确匹配 if menu_xmlid: try: menu = self.env.ref(menu_xmlid, raise_if_not_found=False) if menu and menu._name == 'ir.ui.menu': path_parts = [] m = menu while m: path_parts.append(m.name) m = m.parent_id path_str = ' > '.join(reversed(path_parts)) except Exception as e: _logger.warning('[AI-NAV] 解析 menu_xmlid %s 失败: %s', menu_xmlid, e) menu = None # 2) 按 menu_path 层级匹配 if not menu and menu_path: parts = [p.strip() for p in menu_path.replace('>', '/').split('/') if p.strip()] if parts: target_name = parts[-1] candidates = Menu.search([('name', 'ilike', target_name)]) for cand in candidates: cand_parts = [] m = cand while m: cand_parts.append(m.name) m = m.parent_id cand_parts.reverse() # 从根到叶子 full_path = ' > '.join(cand_parts) # 精确匹配:每个给定层级必须完整匹配路径中的对应组件 if len(cand_parts) >= len(parts): # 从叶子向上匹配给定层级的最后 N 个组件 match = True for i, p in enumerate(parts): # 匹配路径末尾的对应组件(从叶子向上数) path_idx = len(cand_parts) - len(parts) + i if path_idx < 0 or cand_parts[path_idx] != p: match = False break if match: menu = cand path_str = full_path break # 3) 按 menu_name 模糊匹配 if not menu and menu_name: # 优先匹配叶子菜单,再按 sequence/id 排序取第一条 menu = Menu.search([('name', 'ilike', menu_name)], limit=1, order='sequence,id') if menu: cand_parts = [] m = menu while m: cand_parts.append(m.name) m = m.parent_id path_str = ' > '.join(reversed(cand_parts)) if not menu or not menu.action: return None, path_str # 读取 action 字典并清理前端不需要的字段,只保留 doAction 必需字段 action_ref = menu.action raw_action = action_ref.sudo().read()[0] keep_keys = { 'id', 'name', 'type', 'target', 'res_model', 'res_id', 'view_mode', 'views', 'domain', 'context', 'search_view_id', 'limit', 'binding_type', } action = {k: raw_action[k] for k in keep_keys if k in raw_action and raw_action[k] not in (None, False, [])} # 处理 context 字符串 if 'context' in action and isinstance(action['context'], str): try: from ast import literal_eval action['context'] = literal_eval(action['context']) except Exception: action['context'] = {} action['type'] = action_ref._name return action, path_str def _execute_navigate_to(self, args): """构建 Odoo action 字典,供前端调用 doAction 跳转。 返回 {'__action__': True, 'action': {...}, 'message': '...'}, send_message 检测到 __action__ 后会中断 tool_calls 循环。""" action_type = args.get('action_type', 'list') model = args.get('model', '') res_id = args.get('res_id') name = args.get('name', '') domain = args.get('domain') or [] menu_path = args.get('menu_path', '') menu_name = args.get('menu_name', '') menu_xmlid = args.get('menu_xmlid', '') action = None menu_path_str = '' # 优先按菜单路径/名称/xmlid 精确解析 if menu_path or menu_name or menu_xmlid: action, menu_path_str = self._get_menu_action_by_path_or_name( menu_path=menu_path, menu_name=menu_name, menu_xmlid=menu_xmlid ) if not action: # 如果是按菜单路径没找到,不要直接失败,降级到按 model 匹配 _logger.warning( '[AI-NAV] 未找到菜单 path=%s name=%s xmlid=%s,降级到 model=%s', menu_path, menu_name, menu_xmlid, model ) # 按 model 构建通用 action(兜底) if not action: action = { 'type': 'ir.actions.act_window', 'name': name or (model and f'{model} 列表'), 'target': 'current', } if action_type == 'menu': # menu 类型但没提供 menu_xmlid 且没有按路径匹配到 if not model: return {'error': 'menu 类型需要提供 menu_xmlid/menu_path,或提供 model 作为兜底'} action['res_model'] = model action['view_mode'] = 'list,kanban,form' if domain: action['domain'] = domain elif action_type == 'kanban': if not model: return {'error': 'kanban 类型需要提供 model 参数'} action['res_model'] = model action['view_mode'] = 'kanban,list,form' if domain: action['domain'] = domain elif action_type == 'form': if not model or not res_id: return {'error': 'form 类型需要提供 model 和 res_id 参数'} action['res_model'] = model action['res_id'] = res_id action['view_mode'] = 'form' action['views'] = [(False, 'form')] else: # list(默认) if not model: return {'error': 'list 类型需要提供 model 参数'} action['res_model'] = model action['view_mode'] = 'list,kanban,form' if domain: action['domain'] = domain # 获取模型的显示名称,用于默认 name if not action.get('name') and model and model in self.env: model_desc = self.env['ir.model'].sudo().search( [('model', '=', model)], limit=1) if model_desc: action['name'] = model_desc.name # 按钮文案:优先真实菜单名(action.name),不用 LLM 传的 name(LLM 可能自己编一个) btn_label = action.get('name') or name or '打开页面' msg = btn_label if menu_path_str and menu_path_str not in btn_label: msg = f'{btn_label}\n菜单路径:{menu_path_str}' return { '__action__': True, 'action': action, 'message': msg, } def _execute_generate_report(self, args): """执行 generate_report 工具(支持 template_id + params + 企微推送 + 预存数据)""" import json as _json report_name = args.get('name') or '查询报表' records = args.get('records') # LLM 已查询到的数据,直接存入避免重复查询 try: vals = { 'name': report_name, 'create_uid': self.env.uid, } template_id = args.get('template_id') params = args.get('params') or {} field_labels = args.get('field_labels') or {} # 优先使用预存数据(LLM 已通过 odoo_search_read 查询的结果) if records: if not isinstance(records, list) or len(records) == 0: return {'error': 'records 参数必须是非空数组'} vals['report_data'] = _json.dumps({ 'records': records, 'count': len(records), 'fields': list(records[0].keys()) if records else [], }, ensure_ascii=False, default=str) vals['model_name'] = args.get('model') or 'unknown' vals['field_list'] = '[]' if field_labels: vals['field_labels'] = _json.dumps(field_labels, ensure_ascii=False) else: # 传统模式:指定查询参数,服务端实时查询 model_name = args.get('model') if not model_name or model_name not in self.env: return {'error': f'未知模型:{model_name}'} field_list = args.get('fields') or [] if not field_list: return {'error': '必须指定显示字段'} domain = args.get('domain') or [] order = args.get('order') or 'id desc' vals['model_name'] = model_name vals['domain'] = _json.dumps(domain, ensure_ascii=False) vals['field_list'] = _json.dumps(field_list, ensure_ascii=False) vals['field_labels'] = _json.dumps(field_labels, ensure_ascii=False) if field_labels else '' vals['order'] = order # 模板配置(records 和传统模式都可用) if template_id: template = self.env['ai.report.template'].sudo().browse(template_id) if template.exists(): vals['template_id'] = template_id if not args.get('field_labels') and template.field_labels: vals['field_labels'] = template.field_labels if not records and not args.get('fields') and template.field_list: vals['field_list'] = template.field_list if params: vals['params'] = _json.dumps(params, ensure_ascii=False) report = self.env['ai.report'].sudo().create(vals) url = report.get_report_url() result = { 'success': True, 'url': url, 'message': f'已生成报表链接: {url}', } return result except Exception as e: _logger.exception('generate_report failed: %s', e) return { 'success': False, 'error': f'报表生成失败:{str(e)}', 'message': f'报表生成失败,请稍后重试或联系管理员检查服务端日志。' } def _execute_get_report_link(self, args): """获取模板的固定报表链接(不重新生成)""" template_id = args.get('template_id') if not template_id: return {'success': False, 'message': '请提供 template_id'} template = self.env['ai.report.template'].sudo().browse(template_id) if not template.exists(): return {'success': False, 'message': f'模板 {template_id} 不存在'} if template.fixed_report_id and template.fixed_report_id.active: return { 'success': True, 'url': template.fixed_report_url, 'template_name': template.name, 'message': f'已获取"{template.name}"的固定报表链接', } return {'success': False, 'message': f'模板"{template.name}"尚未生成固定链接,请使用 generate_report 临时生成'} def _call_chat_completion(self, provider, messages, tools=None, retry=2): """封装一次 HTTP 调用,返回 (assistant_message_dict, error_str)。 同时记录 Token 使用量。支持自动重试瞬态网络错误。 """ payload = { 'model': provider.model_name, 'messages': messages, 'max_tokens': provider.max_tokens, 'temperature': provider.temperature, } if tools: payload['tools'] = tools payload['tool_choice'] = 'auto' read_timeout = max(provider.timeout or 60, 10) last_error = None for attempt in range(retry + 1): try: resp = requests.post( f"{provider.api_base}/chat/completions", headers={ 'Authorization': f'Bearer {provider.api_key}', 'Content-Type': 'application/json', }, json=payload, timeout=(10, read_timeout), # (connect_timeout, read_timeout) ) if not resp.ok: _logger.error( "AI API 返回 %s: %s", resp.status_code, resp.text[:500] ) resp.raise_for_status() data = resp.json() # 记录 Token 使用量 usage = data.get('usage') or {} if usage: self.env['ai.token.usage'].sudo().create({ 'user_id': self.env.uid, 'conversation_id': self.id, 'provider_id': provider.id, 'model_name': provider.model_name, 'prompt_tokens': usage.get('prompt_tokens', 0), 'completion_tokens': usage.get('completion_tokens', 0), 'total_tokens': usage.get('total_tokens', 0), }) return data['choices'][0]['message'], None except requests.exceptions.Timeout: last_error = f'AI 响应超时(超过 {read_timeout} 秒)' if attempt < retry: _logger.warning( '%s,第 %s/%s 次重试...', last_error, attempt + 1, retry ) continue return None, f'错误:{last_error},已重试 {retry} 次仍失败。请在服务商配置中调高超时时间或检查网络。' except requests.exceptions.ConnectionError as e: last_error = f'无法连接到 AI 服务 ({e})' if attempt < retry: _logger.warning( '%s,第 %s/%s 次重试...', last_error, attempt + 1, retry ) continue return None, f'错误:{last_error},已重试 {retry} 次仍失败。请检查服务器能否访问外网 API。' except requests.exceptions.RequestException as e: return None, f'错误:API 调用失败 — {e}' except (KeyError, IndexError, ValueError) as e: return None, f'错误:解析 AI 响应失败 — {e}' return None, f'错误:{last_error}' def _convert_records_timezone(self, records, model_name, fields_list): """将查询结果中的 datetime 字段从 UTC 转换为用户时区""" if not records: return records # 获取用户时区 user_tz_name = self.env.user.tz or 'Asia/Shanghai' try: user_tz = pytz.timezone(user_tz_name) except pytz.exceptions.UnknownTimeZoneError: user_tz = pytz.timezone('Asia/Shanghai') utc_tz = pytz.UTC # 获取模型的 datetime 字段列表 try: model_fields = self.env[model_name].fields_get(attributes=['type']) except Exception: return records datetime_fields = set() for fname, finfo in model_fields.items(): if finfo.get('type') == 'datetime': datetime_fields.add(fname) # 如果指定了fields_list,只处理请求的字段 if fields_list: datetime_fields = datetime_fields & set(fields_list) if not datetime_fields: return records # 转换每条记录中的 datetime 字段 for record in records: for fname in datetime_fields: val = record.get(fname) if not val: continue try: if isinstance(val, str): dt_utc = datetime.strptime(val, '%Y-%m-%d %H:%M:%S') elif isinstance(val, datetime): dt_utc = val else: continue dt_utc = utc_tz.localize(dt_utc) dt_local = dt_utc.astimezone(user_tz) record[fname] = dt_local.strftime('%Y-%m-%d %H:%M:%S') except (ValueError, TypeError): continue return records # --------------------------------------------------------- # 主入口 # --------------------------------------------------------- def _check_hallucinated_url(self, content, legit_urls=None): """检测并移除 LLM 响应中编造的报表链接。 真正的报表链接由 generate_report 工具返回,格式为 https://域名/ai/report/。 LLM 有时会跳过工具调用,直接在文本中编造一个链接, 典型模式:/report/ 等。 检测到后,整条回复替换为明确的错误提示,避免误导用户。 :param legit_urls: 本次对话中通过 generate_report / get_report_link 工具合法生成的 URL 列表,白名单内的 URL 不拦截。 """ if not content: return content import re legit_urls = legit_urls or [] # 找出所有看起来像报表链接的 URL all_report_urls = re.findall( r'https?://[^\s]*?/(?:ai[-_]?)?report/[^\s]+', content, re.IGNORECASE ) if not all_report_urls: return content # 过滤掉白名单中的合法 URL,剩下的就是编造的 hallucinated = [u for u in all_report_urls if u not in legit_urls] if hallucinated: _logger.warning( 'LLM 编造了报表链接并已拦截 (conversation=%s, uid=%s): %s', self.id, self.env.uid, hallucinated ) return ( '抱歉,报表生成出现异常,系统未成功创建报表链接。' '请重新发送您的问题,系统将重新为您生成报表。' ) return content def send_message(self, content, attachment_ids=None): """发送用户消息并获取 AI 回复(支持工具调用)。 :param content: 用户输入的文本 :param attachment_ids: 可选,附件 ID 列表。附件会先经 OCR 提取文字, 拼入消息首部再发送给 AI。 """ self.ensure_one() # 权限校验 if not self.env.user.has_group('yuthon_ai_agent.group_ai_agent_user'): return {'error': '您没有使用 AI 助手的权限,请联系管理员开通。'} provider = self.provider_id if not provider or not provider.active: return {'error': 'AI 服务未配置或已停用。'} # 计算写入权限:适用规则中任一条勾选 can_write 即允许写入 applicable_rules = provider.get_applicable_rules() allow_write = any(r.can_write for r in applicable_rules) # —— 附件 OCR 预处理 —— ocr_result = '' attachment_note = '' msg_attachment_ids = [] no_text_count = 0 # 无文字附件数量 if attachment_ids: attachments = self.env['documents.document'].sudo().browse(attachment_ids).exists() if attachments: msg_attachment_ids = [(4, a.id) for a in attachments] ocr_parts = [] no_text_names = [] # OCR 无文字的附件 MAX_ATTACH_CHARS = 8000 # 单附件内容上限,超过截断防止 AI 超时 for att in attachments: att_text = att.ai_ocr_content or '' if att_text: truncated = False if len(att_text) > MAX_ATTACH_CHARS: att_text = att_text[:MAX_ATTACH_CHARS] + '\n\n...(内容过长已截断,完整数据请在后台"附件导入记录"中查看)' truncated = True _logger.warning('[AI-MSG] 附件 %s 内容过长(%d字符),已截断至 %d', att.name, len(att.ai_ocr_content), MAX_ATTACH_CHARS) ocr_parts.append( f'【附件:{att.name}】\n' f'{att_text}\n' f'--- 附件内容结束 ---' ) else: no_text_names.append(att.name) if ocr_parts: ocr_result = '\n\n'.join(ocr_parts) + '\n\n' # 有附件但 OCR 无文字 → 告知 AI 它是文本模型,无法“看”图片 if no_text_names: no_text_count = len(no_text_names) file_list = '、'.join(no_text_names) attachment_note = ( f'[系统提示:用户上传了附件({file_list}),' f'但 OCR 未能从中提取到文字。' f'你是一个纯文本模型,不具备图像识别/视觉能力,无法查看图片内容。' f'请告知用户你只能识别图片中的印刷文字(通过 OCR),' f'无法辨认物体、人物、场景、颜色等图像信息。' f'如果用户问的是图片里有什么物体/人物/场景,请友好说明这一限制。]\n\n' ) # 构建最终发送给 AI 的用户消息内容 full_content = attachment_note + ocr_result + content if (ocr_result or attachment_note) else content _logger.info( '[AI-MSG] send_message | attachments=%d | ocr_chars=%d | no_text=%d | ' 'full_content_head=%s', len(msg_attachment_ids) if msg_attachment_ids else 0, len(ocr_result) if ocr_result else 0, no_text_count, (full_content or '')[:300], ) # 1. 保存用户消息(content 存 full_content 即 OCR+原文,前端 renderContent 会自动 strip 附件标记) msg_vals = { 'conversation_id': self.id, 'role': 'user', 'content': full_content, # OCR 拼好的完整内容,发给 AI 的就是这个 } if msg_attachment_ids: msg_vals['attachment_ids'] = msg_attachment_ids msg_vals['ocr_content'] = ocr_result self.env['ai.message'].create(msg_vals) # 2. 构建发送给模型的 messages # 获取用户画像 profile = self.env['ai.user.profile'].get_or_create() user_context = profile.get_system_context() system_msg = ( '你是一个 Odoo 17 ERP 系统的智能助手,可以调用工具直接查询/操作数据库。\n' '常见模型:res.partner(联系人)、product.product/product.template(产品)、' 'sale.order(销售订单)、purchase.order(采购单)、account.move(发票/账单)、' 'stock.picking(出入库)、hr.employee(员工)、res.users(用户)、res.company(公司)。\n' '当用户提到"查/搜/统计/创建/修改/删除"等数据相关需求时,必须主动调用相应工具,' '不要凭空编造结果。不确定字段名时先调用 odoo_fields_get。\n' '所有回答使用中文,简洁清晰。\n' '【格式要求】回复不要使用 Markdown 语法(禁止使用 | 表格、** 加粗、# 标题等)。\n' '少量数据(≤5条)直接用编号列表展示,如:\n' '1. 张三 / 研发部 / 2025-03-01\n' '2. 李四 / 财务部 / 2025-03-15\n' '【报表与推送】当用户要求查看列表/报表/明细时,' '只生成一个链接即可,不要生成多个链接或过多说明。\n' '优先调用 get_report_link 工具:\n' ' 1) 从"可用报表模板"中找到匹配的 template_id\n' ' 2) 调用 get_report_link({"template_id": XX}) 获取固定链接\n' ' 3) 如果返回 success=true,直接把 url 给用户(无需重新生成)\n' ' 4) 如果返回 success=false(模板未生成固定链接),再调用 generate_report 临时生成\n' '【get_report_link 返回值】success=true 时,url 字段是固定链接,直接展示给用户。\n' '【generate_report 推荐用法】先用 odoo_search_read 查询数据,将返回的 records ' '数组直接传入 records 参数。这样无需重复指定 model/domain/fields,' '且用户看到的数据与你的查询结果完全一致。\n' '【严禁编造链接!】你绝对不能自己编造、猜测或拼接报表 URL。' '报表链接只能从 get_report_link 或 generate_report 工具返回的 url 字段中获取,' '直接用工具返回的 URL,不要做任何修改。' '如果你自己拼一个 /ai/report/xxx 或任何自创的链接格式,用户点击后会看到"报表不存在"。\n' '生成报表后系统会自动尝试推送到用户的企微。回复时保持简洁,' '只发送一条包含链接的简短消息即可,不要添加过多说明。如:\n' 'https://oa.thtzjt.com/ai/report/xxxxxxxxxxxxxxxxxxxxxxxx\n' '查询结果中的时间已经转换为北京时间(UTC+8),直接展示即可,无需再做时区转换。\n\n' '【重要】你具有记忆能力。当对话中发现用户的新偏好、工作内容、习惯等值得长期记住的信息时,' '主动调用 save_memory 工具保存。下次对话你会自动获取这些记忆。\n' '称呼用户时使用其设定的称呼,不要说"您"、"亲"等通用称谓。\n' '【页面跳转】当用户要求"打开XX页面"/"跳转到XX"/"帮我看XX列表"/"打开XX的详情"时,' '调用 navigate_to 工具。调用前:如果用户说出了菜单层级,请使用 menu_path(如"会议日程/日程管理");' '如果只说名称,请使用 menu_name。调用后回复里请列出完整菜单路径,如:' '「菜单路径:会议日程 > 日程管理」。' '对话窗口会显示操作按钮,用户点击即可跳转。\n' f'【数据写入权限】{"允许创建/修改/删除 Odoo 数据。" if allow_write else "当前仅允许查询数据,不允许修改数据(创建/写入/删除操作已被禁用)。"}\n' ) # —— 注入 AI 规则 —— 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 in ('skill', 'method') and r.skill_name) if instruction_rules: system_msg += '\n--- 回复指令(必须遵守)---\n' for rule in instruction_rules: system_msg += f'• {rule.name}:{rule.content}\n' if restriction_rules: system_msg += '\n--- 限制规则(禁止回答以下话题)---\n' for rule in restriction_rules: system_msg += f'• {rule.name}:{rule.content}\n' system_msg += '如果用户询问以上禁止话题,礼貌拒绝并说明无法回答。\n' # —— 注入操作手册 —— manual_content = self.env['ai.manual'].get_all_manual_content() if manual_content: system_msg += '\n--- 操作手册(用户询问操作方法时参考)---\n' system_msg += manual_content + '\n' system_msg += '当用户询问如何操作某个功能时,优先参考以上手册内容进行指导。\n' # —— 注入报表模板 —— templates_info = self.env['ai.report.template'].get_templates_for_ai() if templates_info: system_msg += '\n--- 可用报表模板(调用 get_report_link 或 generate_report 时可参考)---\n' system_msg += templates_info + '\n' system_msg += '当用户查询相关数据时,优先调用 get_report_link(template_id) 获取固定链接;' '如无固定链接,再使用 generate_report 配合模板生成报表。\n' if user_context: system_msg += f'\n--- 当前用户信息 ---\n{user_context}\n' # 构建 messages 列表 messages = [{'role': 'system', 'content': system_msg}] for msg in self.message_ids: if msg.role in ('user', 'assistant') and msg.content: messages.append({'role': msg.role, 'content': msg.content}) tools = self._get_tools_schema(allow_write=allow_write) if provider.enable_tools else None # 把技能规则注册为额外的工具 if skill_rules: if tools is None: tools = [] for rule in skill_rules: tools.append(rule.get_tool_schema()) # 3. 多轮 tool_calls 循环 final_content = '' final_action = None # 导航 action(navigate_to 工具返回) legit_report_urls = [] # 收集本次对话中合法生成的报表 URL for _ in range(MAX_TOOL_ITERATIONS): assistant_msg, err = self._call_chat_completion(provider, messages, tools) if err: final_content = err break tool_calls = assistant_msg.get('tool_calls') if not tool_calls: final_content = assistant_msg.get('content') or '' break # 把 assistant 的工具调用消息追加到上下文 messages.append({ 'role': 'assistant', 'content': assistant_msg.get('content') or '', 'tool_calls': tool_calls, }) # 依次执行工具,写回 role=tool 消息 for tc in tool_calls: fn = tc.get('function', {}) fname = fn.get('name', '') try: fargs = json.loads(fn.get('arguments') or '{}') except json.JSONDecodeError: fargs = {} # 先尝试技能接口工具,找不到再走内置工具 result = self._execute_skill_tool(fname, fargs) if result is None: result = self._execute_tool(fname, fargs, allow_write=allow_write) # 检测 __action__ 标记(navigate_to 工具返回) if isinstance(result, dict) and result.get('__action__'): final_action = result.get('action') final_content = result.get('message', '') break # 中断 tool_calls 遍历 # 收集合法报表 URL(generate_report / get_report_link 返回的) if isinstance(result, dict) and result.get('success') and result.get('url'): legit_report_urls.append(result['url']) messages.append({ 'role': 'tool', 'tool_call_id': tc.get('id'), 'content': json.dumps(result, ensure_ascii=False, default=str), }) else: # 正常完成本轮 tool_calls,继续下一轮 continue # 被 __action__ break 了,也跳出外层循环 break else: final_content = final_content or '错误:工具调用次数超限,请重新提问。' # 检测 LLM 是否编造了报表链接(未通过 generate_report 工具生成) final_content = self._check_hallucinated_url(final_content, legit_report_urls) # 4. 保存最终 AI 回复 self.env['ai.message'].create({ 'conversation_id': self.id, 'role': 'assistant', 'content': final_content, }) # 5. 首次发送自动更新标题 if self.name == '新对话' and len(self.message_ids) >= 2: self.name = content[:30] + ('...' if len(content) > 30 else '') result = {'content': final_content} if final_action: result['action'] = final_action return result class AiMessage(models.Model): """对话消息""" _name = 'ai.message' _description = 'AI 消息' _order = 'create_date' conversation_id = fields.Many2one( 'ai.conversation', string='对话', required=True, ondelete='cascade' ) role = fields.Selection([ ('user', '用户'), ('assistant', '助手'), ('system', '系统'), ], string='角色', required=True) content = fields.Text(string='内容', required=True) attachment_ids = fields.Many2many( 'documents.document', 'ai_message_doc_attachment_rel', 'message_id', 'document_id', string='附件' ) ocr_content = fields.Text( string='OCR 识别结果', help='附件图片/PDF 经 OCR 提取的文本内容' ) create_uid = fields.Many2one('res.users', string='发送者', default=lambda self: self.env.uid) action = fields.Text( string='操作配置', help='JSON格式,存储按钮配置,如 {"type": "guide_button", "label": "确认", "action": {...}}', )