# -*- coding: utf-8 -*- from odoo import models, fields, api class AiUserProfile(models.Model): """用户 AI 画像 — 每个用户一个,存储 AI 对该用户的认知""" _name = 'ai.user.profile' _description = '用户 AI 画像' _rec_name = 'user_id' user_id = fields.Many2one( 'res.users', string='用户', required=True, ondelete='cascade', index=True, default=lambda self: self.env.uid, ) employee_id = fields.Many2one( 'hr.employee', string='关联员工', help='关联员工后自动带出职位、部门等信息', ) # 基础信息(用户可手动维护,AI 也可通过工具更新) nickname = fields.Char( string='称呼', help='AI 如何称呼你,如"彭哥"、"张总"' ) role = fields.Char( string='职位/角色', help='如:项目经理、产品总监、运维工程师' ) department = fields.Char( string='部门', help='所属部门或团队' ) work_scope = fields.Text( string='工作职责', help='简述你主要负责的工作内容,帮助 AI 更好理解你的需求' ) style_preference = fields.Selection([ ('concise', '简洁直接'), ('detailed', '详细深入'), ('casual', '轻松口语'), ('formal', '正式商务'), ], string='沟通风格', default='concise', help='AI 回复时的风格偏好' ) custom_instruction = fields.Text( string='自定义指令', help='给 AI 的额外指令,如"回答不要超过 200 字"、"优先用表格呈现数据"等' ) # 记忆 memory_ids = fields.One2many( 'ai.user.memory', 'profile_id', string='AI 记忆' ) memory_count = fields.Integer( string='记忆条数', compute='_compute_memory_count' ) # Token 统计 total_tokens_used = fields.Integer( string='总 Token', compute='_compute_token_stats' ) token_call_count = fields.Integer( string='调用次数', compute='_compute_token_stats' ) # 对话统计 conversation_count = fields.Integer( string='对话数', compute='_compute_conversation_count' ) _sql_constraints = [ ('user_unique', 'UNIQUE(user_id)', '每个用户只能有一个 AI 画像'), ] @api.onchange('employee_id') def _onchange_employee_id(self): """选择员工后自动填充称呼、职位、部门""" if self.employee_id: emp = self.employee_id if not self.nickname or self.nickname == self.user_id.name: self.nickname = emp.name self.role = emp.job_title or (emp.job_id.name if emp.job_id else '') or self.role self.department = emp.department_id.name if emp.department_id else self.department @api.onchange('user_id') def _onchange_user_id(self): """切换用户后自动关联对应员工""" if self.user_id: emp = self.env['hr.employee'].search([ ('user_id', '=', self.user_id.id) ], limit=1) if emp: self.employee_id = emp.id @api.depends('memory_ids') def _compute_memory_count(self): for rec in self: rec.memory_count = len(rec.memory_ids) def _compute_token_stats(self): """统计该用户的 Token 总量和调用次数""" for rec in self: usage_data = self.env['ai.token.usage'].sudo().read_group( [('user_id', '=', rec.user_id.id)], ['total_tokens:sum'], [], ) rec.total_tokens_used = usage_data[0]['total_tokens'] if usage_data else 0 rec.token_call_count = self.env['ai.token.usage'].sudo().search_count( [('user_id', '=', rec.user_id.id)] ) def _compute_conversation_count(self): """统计该用户的对话数""" for rec in self: rec.conversation_count = self.env['ai.conversation'].search_count( [('create_uid', '=', rec.user_id.id)] ) def action_view_token_usage(self): """打开当前用户的 Token 用量明细""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': f'{self.user_id.name} - Token 用量', 'res_model': 'ai.token.usage', 'view_mode': 'tree', 'domain': [('user_id', '=', self.user_id.id)], 'context': {'search_default_filter_month': 1}, } def action_view_conversations(self): """打开当前用户的对话列表""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': f'{self.user_id.name} - 对话', 'res_model': 'ai.conversation', 'view_mode': 'tree,form', 'domain': [('create_uid', '=', self.user_id.id)], } def action_start_chat(self): """发起新对话 → 跳转到聊天界面""" self.ensure_one() # 获取或创建个人对话 conversation = self.env['ai.conversation'].search([ ('create_uid', '=', self.env.uid), ], limit=1, order='create_date desc') if not conversation: provider = self.env['ai.provider'].search([('active', '=', True)], limit=1) conversation = self.env['ai.conversation'].create({ 'name': '新对话', 'provider_id': provider.id if provider else False, }) return conversation.open_chat() @api.model def get_or_create(self, user_id=None): """获取或创建当前用户的 AI 画像""" uid = user_id or self.env.uid profile = self.search([('user_id', '=', uid)], limit=1) if not profile: user = self.env['res.users'].browse(uid) # 自动查找关联员工 emp = self.env['hr.employee'].search([('user_id', '=', uid)], limit=1) vals = { 'user_id': uid, 'nickname': emp.name if emp else user.name, 'role': (emp.job_title or (emp.job_id.name if emp and emp.job_id else '')) or user.job_title or '', 'department': emp.department_id.name if emp and emp.department_id else '', } if emp: vals['employee_id'] = emp.id profile = self.create(vals) return profile def get_system_context(self): """生成注入到 system prompt 的用户上下文段落""" self.ensure_one() parts = [] if self.nickname: parts.append(f'用户称呼:{self.nickname}') if self.role: parts.append(f'职位:{self.role}') if self.department: parts.append(f'部门:{self.department}') if self.work_scope: parts.append(f'工作职责:{self.work_scope}') if self.style_preference: style_map = { 'concise': '请简洁直接回答,不要冗余', 'detailed': '请详细深入回答,提供充分背景', 'casual': '请用轻松口语化风格回答', 'formal': '请用正式商务风格回答', } parts.append(f'沟通风格要求:{style_map.get(self.style_preference, "")}') if self.custom_instruction: parts.append(f'用户自定义指令:{self.custom_instruction}') # 加入最近的活跃记忆(最多 20 条) memories = self.memory_ids.filtered('active').sorted('write_date', reverse=True)[:20] if memories: mem_lines = [] for m in memories: cat_label = dict(m._fields['category'].selection).get(m.category, '') mem_lines.append(f' [{cat_label}] {m.content}') parts.append('关于此用户的记忆:\n' + '\n'.join(mem_lines)) return '\n'.join(parts) if parts else '' class AiUserMemory(models.Model): """AI 对用户的长期记忆""" _name = 'ai.user.memory' _description = 'AI 用户记忆' _order = 'write_date desc' profile_id = fields.Many2one( 'ai.user.profile', string='用户画像', required=True, ondelete='cascade', index=True, ) category = fields.Selection([ ('habit', '习惯偏好'), ('work', '工作内容'), ('knowledge', '专业知识'), ('style', '沟通风格'), ('personal', '个人信息'), ('other', '其他'), ], string='类别', default='other', required=True) content = fields.Text(string='记忆内容', required=True) source = fields.Selection([ ('auto', 'AI 自动提取'), ('manual', '用户手动添加'), ], string='来源', default='manual') active = fields.Boolean(string='有效', default=True)