Files
gzth/yuthon_ai_agent/models/ai_user_profile.py
T
2026-05-28 12:15:39 +08:00

237 lines
8.7 KiB
Python

# -*- 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)