|
1 | 1 | import re |
| 2 | +from datetime import timedelta |
2 | 3 |
|
3 | 4 | import uuid_utils.compat as uuid |
4 | 5 | from django.db.models import Count, QuerySet |
| 6 | +from django.utils import timezone |
5 | 7 | from langchain_core.messages import HumanMessage |
6 | 8 |
|
7 | 9 | from application.models import Chat, ChatRecord, Application, ApplicationLongTermMemory |
|
10 | 12 | from ops import celery_app |
11 | 13 |
|
12 | 14 | long_term_prompt = ''' |
13 | | -你是一个专业的长期记忆管理助手,负责从对话中提炼并维护用户的结构化长期记忆。 |
| 15 | +你是一个专业的用户长期记忆提炼引擎。你的唯一职责是:从对话中精确识别具有持久价值的用户信息,并与已有记忆进行结构化融合,输出供 AI 助手长期使用的用户画像记忆。 |
14 | 16 |
|
15 | 17 | ## 输入 |
16 | | -
|
17 | 18 | 【已有记忆】: |
18 | 19 | {{existing_memory}} |
19 | 20 |
|
20 | | -【新对话内容】: |
| 21 | +【本轮新增对话】: |
21 | 22 | {{new_conversation}} |
22 | 23 |
|
23 | 24 | --- |
24 | 25 |
|
25 | | -## 任务说明 |
| 26 | +## 提取门槛(必须同时满足,才可提取) |
| 27 | +
|
| 28 | +1. **跨会话复用价值**:这条信息在未来其他对话中仍然适用,而非当次临时需求 |
| 29 | +2. **明确可证**:可从对话原文直接支撑,不得推断、脑补或延伸 |
| 30 | +3. **改善回答质量**:记住这条信息后,AI 的回答会对该用户更准确或更贴合 |
| 31 | +
|
| 32 | +**以下内容禁止提取:** |
| 33 | +- 用户的一次性临时要求(如「这次用表格输出就好」) |
| 34 | +- 用户提问的具体内容本身(问题不是记忆) |
| 35 | +- 无法从对话原文直接证明的推断 |
| 36 | +- 闲聊、问候、感谢等无信息量内容 |
| 37 | +- AI 的回答内容(只提取用户侧信息) |
| 38 | +
|
| 39 | +--- |
| 40 | +
|
| 41 | +## 四类记忆分类与融合规则 |
| 42 | +
|
| 43 | +### 【偏好】交互偏好 |
| 44 | +用户对「AI 如何回应」的稳定期望,需明确声明或在多轮中反复体现才可录入。 |
26 | 45 |
|
27 | | -根据以上输入,生成更新后的长期记忆。遵循以下逻辑: |
28 | | -- 若【已有记忆】为空:仅从【新对话内容】中提炼结构化记忆。 |
29 | | -- 若【已有记忆】不为空:在其基础上进行增量融合——新信息覆盖或补充旧信息,不得删除未被新对话否定的已有记忆。 |
| 46 | +常见维度:回答详略 / 语言风格(正式/口语)/ 输出格式(表格/列表/段落)/ 是否要举例 / 代码风格偏好 / 回复语言 |
| 47 | +
|
| 48 | +融合规则: |
| 49 | +- 同维度出现新偏好 → **覆盖**旧值,条目末标注 `※已更新` |
| 50 | +- 新维度 → 直接追加 |
| 51 | +- 旧偏好无新证据但未被否定 → **保留** |
30 | 52 |
|
31 | 53 | --- |
32 | 54 |
|
33 | | -## 处理规则 |
| 55 | +### 【背景】用户背景 |
| 56 | +用户的客观身份与环境信息,稳定性强,用户未明确更正则不主动变动。 |
34 | 57 |
|
35 | | -严格按以下三类处理,**不得推测、捏造或补全对话中未明确出现的信息**: |
| 58 | +常见维度:职业/角色 / 所在行业 / 技术栈与熟练度 / 使用产品或系统 / 团队规模 / 所在地区 |
36 | 59 |
|
37 | | -### 一、用户偏好 |
38 | | -> 关注用户对"如何回答"的期望与习惯 |
| 60 | +融合规则: |
| 61 | +- 与旧记忆冲突 → **以新对话为准**,标注 `※已更新`,删除旧值 |
| 62 | +- 新增信息 → 追加 |
| 63 | +- 信息模糊无法确认 → 追加时标注 `※待确认` |
39 | 64 |
|
40 | | -常见维度:回答风格、回答长度、语言风格、格式偏好、编程语言、是否需要举例、是否需要解释、输出语言等 |
| 65 | +--- |
41 | 66 |
|
42 | | -- 已有记忆为空:从新对话中提取,无则写「无」 |
43 | | -- 已有记忆不为空:新偏好**覆盖**同维度旧偏好;新维度**追加** |
| 67 | +### 【约定】明确约定 |
| 68 | +用户明确要求 AI 固定遵守的行为规则,须有明确指令性语言支撑,不可自行解读。 |
44 | 69 |
|
45 | | -### 二、关键事实 |
46 | | -> 关注用户客观背景信息 |
| 70 | +常见维度:禁止行为 / 固定执行动作 / 特定触发词响应 / 内容边界 / 输出限制 |
47 | 71 |
|
48 | | -常见维度:职业、行业、技术栈、身份、使用场景、设备环境、地域、项目背景、当前需求等 |
| 72 | +融合规则: |
| 73 | +- 同类新规则 → **覆盖**旧规则,标注 `※已更新` |
| 74 | +- 新增规则 → 追加 |
| 75 | +- 用户明确取消的规则 → **直接删除** |
49 | 76 |
|
50 | | -- 已有记忆为空:从新对话中提取,无则写「无」 |
51 | | -- 已有记忆不为空:新对话中与旧记忆**冲突的事实以新对话为准**;新事实**追加** |
| 77 | +--- |
52 | 78 |
|
53 | | -### 三、规则约定 |
54 | | -> 关注用户明确提出的行为约束或指令规则 |
| 79 | +### 【目标】当前目标 |
| 80 | +用户近期或长期正在推进的具体目标,有助于 AI 主动提供更相关的帮助。 |
55 | 81 |
|
56 | | -常见维度:触发词、执行动作、禁止动作、生效条件、生效时间范围等 |
| 82 | +常见维度:正在进行的项目 / 学习计划 / 待解决的核心问题 / 关键决策 |
57 | 83 |
|
58 | | -- 已有记忆为空:从新对话中提取,无则写「无」 |
59 | | -- 已有记忆不为空:新规则**覆盖**同类旧规则;新规则**追加** |
| 84 | +融合规则: |
| 85 | +- 已明确完成或放弃的目标 → **删除** |
| 86 | +- 新目标 → 追加 |
| 87 | +- 已有目标有进展更新 → **覆盖**旧描述 |
60 | 88 |
|
61 | 89 | --- |
62 | 90 |
|
63 | | -## 输出要求 |
| 91 | +## 输出规范 |
64 | 92 |
|
65 | | -1. **只输出结构化记忆本身**,不得包含任何开场白、解释、总结或额外说明 |
66 | | -2. 每条记忆使用 `- [维度标签]` 开头,标签尽量精准简洁 |
67 | | -3. 某类确实无内容时,必须明确写「无」,不得省略该章节 |
68 | | -4. 输出语言与【新对话内容】保持一致 |
| 93 | +1. **只输出记忆内容本身**,不含任何开头语、解释、总结或分隔说明 |
| 94 | +2. 四个章节**全部输出**,确无内容写「暂无」,不可省略章节 |
| 95 | +3. 每条格式:`- [维度标签] 内容`,标签 2~5 字,精准简洁 |
| 96 | +4. 有变更标记(`※已更新` / `※待确认`)的条目置于各章节**最前** |
| 97 | +5. 每条记忆控制在 **60 字以内**,信息密度优先,超出则拆为两条 |
| 98 | +6. 输出语言与【本轮新增对话】主要语言保持一致 |
| 99 | +
|
| 100 | +--- |
69 | 101 |
|
70 | 102 | ## 输出格式 |
71 | 103 |
|
72 | | -### 一、用户偏好 |
73 | | -- [维度标签] 具体内容 |
74 | | -- [维度标签] 具体内容 |
75 | | -(若无则写:无) |
| 104 | +### 【偏好】交互偏好 |
| 105 | +- [维度标签] 内容 |
| 106 | +(暂无则写:暂无) |
| 107 | +
|
| 108 | +### 【背景】用户背景 |
| 109 | +- [维度标签] 内容 |
| 110 | +(暂无则写:暂无) |
| 111 | +
|
| 112 | +### 【约定】明确约定 |
| 113 | +- [维度标签] 内容 |
| 114 | +(暂无则写:暂无) |
76 | 115 |
|
77 | | -### 二、关键事实 |
78 | | -- [维度标签] 具体内容 |
79 | | -- [维度标签] 具体内容 |
80 | | -(若无则写:无) |
| 116 | +### 【目标】当前目标 |
| 117 | +- [维度标签] 内容 |
| 118 | +(暂无则写:暂无) |
81 | 119 |
|
82 | | -### 三、规则约定 |
83 | | -- [维度标签] 具体内容 |
84 | | -- [维度标签] 具体内容 |
85 | | -(若无则写:无) |
86 | 120 | ''' |
87 | 121 |
|
88 | 122 |
|
@@ -121,20 +155,64 @@ def _get_long_term_config(application, chat_user_id): |
121 | 155 | } |
122 | 156 |
|
123 | 157 |
|
124 | | -def _run_extract(workspace_id, application_id, chat_user_id, config, history_limit): |
| 158 | +def _get_since_time_from_setting(setting: dict): |
125 | 159 | """ |
126 | | - 执行一次长期记忆提取:取最近 history_limit 条对话,调用模型生成/更新记忆。 |
| 160 | + 根据定时设置推算本次应提取的对话起始时间。 |
| 161 | + 返回 datetime(aware),或 None 表示无法推断(回退到 rounds 限制)。 |
127 | 162 | """ |
128 | | - if history_limit <= 0: |
| 163 | + now = timezone.now() |
| 164 | + schedule_type = setting.get("schedule_type") |
| 165 | + |
| 166 | + if schedule_type == "daily": |
| 167 | + return now - timedelta(days=1) |
| 168 | + if schedule_type == "weekly": |
| 169 | + return now - timedelta(weeks=1) |
| 170 | + if schedule_type == "monthly": |
| 171 | + return now - timedelta(days=30) |
| 172 | + if schedule_type == "interval": |
| 173 | + unit = (setting.get("interval_unit") or "").strip() |
| 174 | + try: |
| 175 | + value_i = int(setting.get("interval_value")) |
| 176 | + if value_i <= 0: |
| 177 | + return None |
| 178 | + except Exception: |
| 179 | + return None |
| 180 | + delta_map = { |
| 181 | + "seconds": timedelta(seconds=value_i), |
| 182 | + "minutes": timedelta(minutes=value_i), |
| 183 | + "hours": timedelta(hours=value_i), |
| 184 | + "days": timedelta(days=value_i), |
| 185 | + } |
| 186 | + delta = delta_map.get(unit) |
| 187 | + return now - delta if delta else None |
| 188 | + # cron 等无法从表达式推断间隔,返回 None |
| 189 | + return None |
| 190 | + |
| 191 | + |
| 192 | +def _run_extract(workspace_id, application_id, chat_user_id, config, history_limit=None, since_time=None): |
| 193 | + """ |
| 194 | + 执行一次长期记忆提取。 |
| 195 | + - since_time 不为 None 时:提取该时间点之后产生的对话。 |
| 196 | + - 否则按 history_limit 条数限制。 |
| 197 | + """ |
| 198 | + if since_time is None and (history_limit is None or history_limit <= 0): |
129 | 199 | return |
130 | 200 |
|
131 | | - history_chat_record = list( |
132 | | - QuerySet(ChatRecord).filter( |
| 201 | + qs = ( |
| 202 | + QuerySet(ChatRecord) |
| 203 | + .filter( |
133 | 204 | chat__application_id=application_id, |
134 | 205 | chat__chat_user_id=chat_user_id, |
135 | | - ).order_by('-create_time').only('problem_text', 'answer_text')[:history_limit] |
| 206 | + ) |
| 207 | + .order_by('-create_time') |
| 208 | + .only('problem_text', 'answer_text') |
136 | 209 | ) |
137 | | - if len(history_chat_record) <= 1: |
| 210 | + |
| 211 | + if since_time is not None: |
| 212 | + history_chat_record = list(qs.filter(create_time__gte=since_time)) |
| 213 | + else: |
| 214 | + history_chat_record = list(qs[:history_limit]) |
| 215 | + if len(history_chat_record) == 0: |
138 | 216 | return |
139 | 217 |
|
140 | 218 | chat_model = get_model_instance_by_model_workspace_id( |
@@ -238,9 +316,12 @@ def _execute_scheduled_extract(workspace_id, application_id): |
238 | 316 | continue |
239 | 317 | if config['trigger_type'] != 'SCHEDULED': |
240 | 318 | continue |
241 | | - history_limit = (config['trigger_setting'] or {}).get('rounds', 20) |
| 319 | + setting = config['trigger_setting'] or {} |
| 320 | + since_time = _get_since_time_from_setting(setting) |
| 321 | + history_limit = None if since_time is not None else setting.get('rounds', 20) |
242 | 322 | try: |
243 | | - _run_extract(workspace_id, application_id, chat_user_id, config, history_limit=history_limit) |
| 323 | + _run_extract(workspace_id, application_id, chat_user_id, config, |
| 324 | + history_limit=history_limit, since_time=since_time) |
244 | 325 | except Exception as e: |
245 | 326 | maxkb_logger.warning( |
246 | 327 | f"scheduled extract long_term_memory failed, " |
|
0 commit comments