237 lines
8.7 KiB
Python
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)
|