diff --git a/yuthon_ai_agent/models/ai_agent.py b/yuthon_ai_agent/models/ai_agent.py index 1ea1974ef..cee5c8336 100644 --- a/yuthon_ai_agent/models/ai_agent.py +++ b/yuthon_ai_agent/models/ai_agent.py @@ -251,31 +251,38 @@ class AiConversation(models.Model): 'name': 'generate_report', 'description': ( '当查询结果数据较多(超过5条)或用户要求查看列表/报表时,' - '生成一个动态报表H5页面链接。用户点击链接可在手机上查看整齐的表格数据。' - '注意:必须先调用 odoo_fields_get 确认字段名称,再调用此工具。' - '【模板使用】如果已有匹配的报表模板,传入 template_id 来使用模板的' - '列配置、视觉风格和参数定义。参数通过 params 传入。' + '生成一个动态报表H5页面链接。用户点击链接可在手机上查看整齐的表格数据。\n' + '【推荐用法】先用 odoo_search_read 查询数据,将返回的 records ' + '数组直接传入 records 参数。这样无需重复指定 model/domain/fields,' + '且用户看到的数据与你的查询结果完全一致。\n' + '【传统用法】如果不传 records,则必须传 model/domain/fields,' + '系统将重新执行查询(数据可能与之前 odoo_search_read 结果不一致)。' ), 'parameters': { 'type': 'object', 'properties': { - 'model': {'type': 'string', 'description': '模型技术名称,如 sale.order'}, - 'domain': {'type': 'array', 'description': "查询条件 domain,如 [['state','=','sale']]", 'items': {}}, - 'fields': {'type': 'array', 'items': {'type': 'string'}, 'description': '要显示的字段列表'}, - 'field_labels': {'type': 'object', 'description': '字段中文标签,如 {"name":"订单号","amount_total":"金额"}'}, 'name': {'type': 'string', 'description': '报表名称,如"本月销售订单"'}, - 'order': {'type': 'string', 'description': '排序,如 "create_date desc"'}, + '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传入,如{"date_from":"2025-01-01","customer":"张三"}', + 'description': '模板参数值,key=参数名, value=参数值。根据模板的param_definitions传入', 'additionalProperties': True, }, }, - 'required': ['model', 'fields', 'name'], + 'required': ['name'], }, }, }, @@ -436,44 +443,63 @@ class AiConversation(models.Model): return None def _execute_generate_report(self, args): - """执行 generate_report 工具(支持 template_id + params + 企微推送)""" + """执行 generate_report 工具(支持 template_id + params + 企微推送 + 预存数据)""" import json as _json - 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': '必须指定显示字段'} - report_name = args.get('name') or '查询报表' - domain = args.get('domain') or [] - field_labels = args.get('field_labels') or {} - order = args.get('order') or 'id desc' - template_id = args.get('template_id') - params = args.get('params') or {} + records = args.get('records') # LLM 已查询到的数据,直接存入避免重复查询 try: vals = { 'name': report_name, - 'model_name': model_name, - 'domain': _json.dumps(domain, ensure_ascii=False), - 'field_list': _json.dumps(field_list, ensure_ascii=False), - 'field_labels': _json.dumps(field_labels, ensure_ascii=False) if field_labels else '', - 'order': order, '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('fields') and template.field_list: - vals['field_list'] = template.field_list 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) @@ -604,12 +630,11 @@ class AiConversation(models.Model): https://域名/yuthon/report/<12位hex>。 LLM 有时会跳过工具调用,直接在文本中编造一个链接, 典型模式:/ai/report/<任意>、/report/ 等。 - 检测到后移除伪造链接并用提示文本替换,避免用户点击无效链接。 + 检测到后,整条回复替换为明确的错误提示,避免误导用户。 """ if not content: return content import re - # 匹配伪造的报表链接模式 hallucinated = re.findall( r'https?://[^\s]*?/(?:ai[-_]?)?report/[^\s]+', content, re.IGNORECASE @@ -619,13 +644,10 @@ class AiConversation(models.Model): 'LLM 编造了报表链接并已拦截 (conversation=%s, uid=%s): %s', self.id, self.env.uid, hallucinated ) - cleaned = content - for url in hallucinated: - cleaned = cleaned.replace(url, '') - # 清理可能残留的空行 - cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip() - cleaned += '\n\n(系统检测到上述回复中包含无效链接,已自动移除。如需要报表,请重新提问。)' - return cleaned + return ( + '抱歉,报表生成出现异常,系统未成功创建报表链接。' + '请重新发送您的问题,系统将重新为您生成报表。' + ) return content def send_message(self, content): @@ -665,15 +687,17 @@ class AiConversation(models.Model): '2. 李四 / 财务部 / 2025-03-15\n' '【报表与推送】当查询结果超过5条,或用户要求"列表/报表/明细"时,' '必须调用 generate_report 工具生成报表链接。\n' + '调用方式:用 odoo_search_read 查到数据后,将返回的 records 数组直接传入 ' + 'generate_report 的 records 参数,只需再填 name 即可。' + '示例:generate_report({"name":"花名册", "records":<上一步search_read的records>})\n' '【严禁编造链接!】你绝对不能自己编造、猜测或拼接报表 URL。' '报表链接只能从 generate_report 工具返回的 url 字段中获取,' '直接用工具返回的 URL,不要做任何修改。' - '如果你自己拼一个 /ai/report/xxx 或任何自创的链接格式,用户点击后会看到"报表不存在"。' - '工具返回的链接格式类似:https://域名/yuthon/report/xxxxxxxxxxxx\n' + '如果你自己拼一个 /ai/report/xxx 或任何自创的链接格式,用户点击后会看到"报表不存在"。\n' '生成报表后系统会自动尝试推送到用户的企微。无论推送是否成功,' '回复时都要说明查询结果并附上工具返回的真实链接,如:\n' '已为您生成报表,共查询到 XX 条记录:\n' - 'https://域名/yuthon/report/xxxxxxxxxxxx\n' + 'https://oa.thtzjt.com/ai/report/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n' '查询结果中的时间已经转换为北京时间(UTC+8),直接展示即可,无需再做时区转换。\n\n' '【重要】你具有记忆能力。当对话中发现用户的新偏好、工作内容、习惯等值得长期记住的信息时,' '主动调用 save_memory 工具保存。下次对话你会自动获取这些记忆。\n' diff --git a/yuthon_ai_agent/models/ai_report.py b/yuthon_ai_agent/models/ai_report.py index 35de7be9c..f788e4508 100644 --- a/yuthon_ai_agent/models/ai_report.py +++ b/yuthon_ai_agent/models/ai_report.py @@ -52,6 +52,9 @@ class AiReport(models.Model): template_id = fields.Many2one('ai.report.template', string='使用模板') params = fields.Text('参数值', default='{}', help='JSON对象,模板参数的实际值。由AI调用generate_report时传入,如{"date_from":"2025-01-01","customer":"张三"}') + report_data = fields.Text('预存数据', + help='当AI已查询到数据后直接传入时,存储JSON格式的记录数据。' + '优先于实时查询,确保用户看到的数据与AI查询结果一致。') @api.model_create_multi def create(self, vals_list): @@ -112,9 +115,19 @@ class AiReport(models.Model): return super().write(vals) def fetch_data(self): - """执行查询并返回数据(增强版:支持模板参数合并、字段回退)""" + """执行查询并返回数据(增强版:支持模板参数合并、字段回退、预存数据)""" self.ensure_one() try: + # 如果报表创建时已预存数据(AI 直接传入的查询结果),直接返回 + if self.report_data: + cached = json.loads(self.report_data) + if isinstance(cached, dict) and cached.get('records'): + return { + 'success': True, + 'records': cached['records'], + 'fields': cached.get('fields', list(cached['records'][0].keys()) if cached['records'] else []), + 'count': cached.get('count', len(cached['records'])), + } model_name = self.model_name if model_name not in self.env: return {'error': f'模型 {model_name} 不存在'}