报表
This commit is contained in:
@@ -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/<uuid> 等。
|
||||
检测到后移除伪造链接并用提示文本替换,避免用户点击无效链接。
|
||||
检测到后,整条回复替换为明确的错误提示,避免误导用户。
|
||||
"""
|
||||
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'
|
||||
|
||||
@@ -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} 不存在'}
|
||||
|
||||
Reference in New Issue
Block a user