1003 lines
46 KiB
Python
1003 lines
46 KiB
Python
# -*- 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):
|
||
"""返回 OpenAI 兼容的工具列表(JSON Schema)。"""
|
||
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': '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 self.provider_id.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):
|
||
"""执行单个工具调用,返回可 JSON 序列化的结果字典。
|
||
所有 ORM 操作都走当前用户权限(self.env),自动遵循 ir.rule / ir.model.access。
|
||
"""
|
||
try:
|
||
# 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 self.provider_id.allow_write:
|
||
return {'error': '当前 Provider 未启用写入权限'}
|
||
rec = Model.create(args.get('values') or {})
|
||
return {'success': True, 'id': rec.id, 'display_name': rec.display_name}
|
||
|
||
if name == 'odoo_write':
|
||
if not self.provider_id.allow_write:
|
||
return {'error': '当前 Provider 未启用写入权限'}
|
||
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 self.provider_id.allow_write:
|
||
return {'error': '当前 Provider 未启用写入权限'}
|
||
ids = args.get('ids') or []
|
||
Model.browse(ids).unlink()
|
||
return {'success': True, 'deleted_ids': ids}
|
||
|
||
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', '=', 'skill'),
|
||
('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'),
|
||
('skill_name', '=', name),
|
||
('active', '=', True),
|
||
('provider_ids', '=', False),
|
||
], limit=1)
|
||
if skill_rule:
|
||
return skill_rule.execute_skill(args)
|
||
return None
|
||
|
||
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/<access_token>。
|
||
LLM 有时会跳过工具调用,直接在文本中编造一个链接,
|
||
典型模式:/report/<uuid> 等。
|
||
检测到后,整条回复替换为明确的错误提示,避免误导用户。
|
||
|
||
: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 服务未配置或已停用。'}
|
||
|
||
# —— 附件 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 无文字的附件
|
||
for att in attachments:
|
||
att_text = att.ai_ocr_content or ''
|
||
if att_text:
|
||
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 存原始用户文本,ocr_content 存 OCR 结果,
|
||
# full_content 带 OCR 的版本发给 AI,前端通过 attachments 字段渲染附件预览)
|
||
msg_vals = {
|
||
'conversation_id': self.id,
|
||
'role': 'user',
|
||
'content': content, # 存干净的用户原文,不含 OCR 前缀
|
||
}
|
||
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'
|
||
)
|
||
|
||
# —— 注入 AI 规则 ——
|
||
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)
|
||
|
||
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() 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 = ''
|
||
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)
|
||
# 收集合法报表 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:
|
||
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 '')
|
||
|
||
return final_content
|
||
|
||
|
||
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)
|