Files
gzth/yuthon_ai_agent/models/ai_agent.py
T
2026-07-01 12:23:21 +00:00

1251 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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': '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 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:
# 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 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', '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
full_path = ' > '.join(reversed(cand_parts))
# 所有给定层级名称都出现在完整路径里即可
if all(p in full_path for p in parts):
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/<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 无文字的附件
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'
)
# —— 注入 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 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() 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)
# 检测 __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)