feat: add memory extraction LLM tool and prompt
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
You are a memory extraction system for a Discord bot. Given a conversation between a user and the bot, extract any noteworthy information worth remembering for future interactions.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Only extract genuinely NEW information not already in the user's profile.
|
||||||
|
- Be concise — each memory should be one sentence max.
|
||||||
|
- Assign appropriate expiration based on how long the information stays relevant:
|
||||||
|
- "permanent": Stable facts — name, job, hobbies, games they play, personality traits, pets, relationships
|
||||||
|
- "30d": Semi-stable preferences, ongoing situations — "trying to quit Warzone", "grinding for rank 500"
|
||||||
|
- "7d": Temporary situations — "excited about upcoming DLC", "on vacation this week"
|
||||||
|
- "3d": Short-term context — "had a bad day", "playing with friends tonight"
|
||||||
|
- "1d": Momentary state — "drunk right now", "tilted from losses", "in a good mood"
|
||||||
|
- Assign topic tags that would help retrieve this memory later (game names, "personal", "work", "mood", etc.)
|
||||||
|
- Assign importance: "high" for things they'd expect you to remember, "medium" for useful context, "low" for minor color
|
||||||
|
- If you learn a permanent fact about the user, provide a profile_update that incorporates the new fact into their existing profile. Rewrite the ENTIRE profile summary — don't just append. Keep it under 500 characters.
|
||||||
|
- If nothing worth remembering was said, return an empty memories array and null profile_update.
|
||||||
|
- Do NOT store things the bot said — only facts about or from the user.
|
||||||
|
|
||||||
|
Use the extract_memories tool to report your findings.
|
||||||
@@ -203,6 +203,55 @@ CONVERSATION_TOOL = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MEMORY_EXTRACTION_TOOL = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "extract_memories",
|
||||||
|
"description": "Extract noteworthy memories from a conversation for future reference.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"memories": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"memory": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A concise fact or observation worth remembering.",
|
||||||
|
},
|
||||||
|
"topics": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Topic tags for retrieval (e.g., 'gta', 'personal', 'warzone').",
|
||||||
|
},
|
||||||
|
"expiration": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["1d", "3d", "7d", "30d", "permanent"],
|
||||||
|
"description": "How long this memory stays relevant.",
|
||||||
|
},
|
||||||
|
"importance": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["low", "medium", "high"],
|
||||||
|
"description": "How important this memory is for future interactions.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["memory", "topics", "expiration", "importance"],
|
||||||
|
},
|
||||||
|
"description": "Memories to store. Only include genuinely new or noteworthy information.",
|
||||||
|
},
|
||||||
|
"profile_update": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"description": "Full updated profile summary incorporating new permanent facts, or null if no profile changes.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["memories"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
MEMORY_EXTRACTION_PROMPT = (_PROMPTS_DIR / "memory_extraction.txt").read_text(encoding="utf-8")
|
||||||
|
|
||||||
_NO_TEMPERATURE_MODELS = {"gpt-5-nano", "o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini"}
|
_NO_TEMPERATURE_MODELS = {"gpt-5-nano", "o1", "o1-mini", "o1-preview", "o3", "o3-mini", "o4-mini"}
|
||||||
|
|
||||||
|
|
||||||
@@ -626,6 +675,122 @@ class LLMClient:
|
|||||||
self._log_llm("chat", elapsed, False, req_json, error=str(e))
|
self._log_llm("chat", elapsed, False, req_json, error=str(e))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def extract_memories(
|
||||||
|
self,
|
||||||
|
conversation: list[dict[str, str]],
|
||||||
|
username: str,
|
||||||
|
current_profile: str = "",
|
||||||
|
) -> dict | None:
|
||||||
|
"""Extract memories from a conversation for a specific user.
|
||||||
|
|
||||||
|
Returns dict with "memories" list and optional "profile_update", or None on failure.
|
||||||
|
"""
|
||||||
|
# Format conversation as readable lines
|
||||||
|
convo_lines = []
|
||||||
|
for msg in conversation:
|
||||||
|
role = msg.get("role", "")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if role == "assistant":
|
||||||
|
convo_lines.append(f"Bot: {content}")
|
||||||
|
else:
|
||||||
|
convo_lines.append(f"{username}: {content}")
|
||||||
|
convo_text = "\n".join(convo_lines)
|
||||||
|
|
||||||
|
user_content = ""
|
||||||
|
if current_profile:
|
||||||
|
user_content += f"=== CURRENT PROFILE FOR {username} ===\n{current_profile}\n\n"
|
||||||
|
else:
|
||||||
|
user_content += f"=== CURRENT PROFILE FOR {username} ===\n(no profile yet)\n\n"
|
||||||
|
user_content += f"=== CONVERSATION ===\n{convo_text}\n\n"
|
||||||
|
user_content += f"Extract any noteworthy memories from this conversation with {username}."
|
||||||
|
user_content = self._append_no_think(user_content)
|
||||||
|
|
||||||
|
req_json = json.dumps([
|
||||||
|
{"role": "system", "content": MEMORY_EXTRACTION_PROMPT[:500]},
|
||||||
|
{"role": "user", "content": user_content[:500]},
|
||||||
|
], default=str)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
|
||||||
|
async with self._semaphore:
|
||||||
|
try:
|
||||||
|
temp_kwargs = {"temperature": 0.3} if self._supports_temperature else {}
|
||||||
|
response = await self._client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": MEMORY_EXTRACTION_PROMPT},
|
||||||
|
{"role": "user", "content": user_content},
|
||||||
|
],
|
||||||
|
tools=[MEMORY_EXTRACTION_TOOL],
|
||||||
|
tool_choice={"type": "function", "function": {"name": "extract_memories"}},
|
||||||
|
**temp_kwargs,
|
||||||
|
max_completion_tokens=1024,
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = int((time.monotonic() - t0) * 1000)
|
||||||
|
choice = response.choices[0]
|
||||||
|
usage = response.usage
|
||||||
|
|
||||||
|
if choice.message.tool_calls:
|
||||||
|
tool_call = choice.message.tool_calls[0]
|
||||||
|
resp_text = tool_call.function.arguments
|
||||||
|
args = json.loads(resp_text)
|
||||||
|
self._log_llm("memory_extraction", elapsed, True, req_json, resp_text,
|
||||||
|
input_tokens=usage.prompt_tokens if usage else None,
|
||||||
|
output_tokens=usage.completion_tokens if usage else None)
|
||||||
|
return self._validate_memory_result(args)
|
||||||
|
|
||||||
|
logger.warning("No tool call in memory extraction response.")
|
||||||
|
self._log_llm("memory_extraction", elapsed, False, req_json, error="No tool call")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = int((time.monotonic() - t0) * 1000)
|
||||||
|
logger.error("LLM memory extraction error: %s", e)
|
||||||
|
self._log_llm("memory_extraction", elapsed, False, req_json, error=str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_memory_result(result: dict) -> dict:
|
||||||
|
"""Validate and normalize memory extraction result."""
|
||||||
|
valid_expirations = {"1d", "3d", "7d", "30d", "permanent"}
|
||||||
|
valid_importances = {"low", "medium", "high"}
|
||||||
|
|
||||||
|
memories = []
|
||||||
|
for mem in result.get("memories", []):
|
||||||
|
if not isinstance(mem, dict):
|
||||||
|
continue
|
||||||
|
memory_text = str(mem.get("memory", ""))[:500]
|
||||||
|
if not memory_text:
|
||||||
|
continue
|
||||||
|
topics = mem.get("topics", [])
|
||||||
|
if not isinstance(topics, list):
|
||||||
|
topics = []
|
||||||
|
topics = [str(t).lower() for t in topics]
|
||||||
|
|
||||||
|
expiration = str(mem.get("expiration", "7d"))
|
||||||
|
if expiration not in valid_expirations:
|
||||||
|
expiration = "7d"
|
||||||
|
|
||||||
|
importance = str(mem.get("importance", "medium"))
|
||||||
|
if importance not in valid_importances:
|
||||||
|
importance = "medium"
|
||||||
|
|
||||||
|
memories.append({
|
||||||
|
"memory": memory_text,
|
||||||
|
"topics": topics,
|
||||||
|
"expiration": expiration,
|
||||||
|
"importance": importance,
|
||||||
|
})
|
||||||
|
|
||||||
|
profile_update = result.get("profile_update")
|
||||||
|
if profile_update is not None:
|
||||||
|
profile_update = str(profile_update)[:500]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"memories": memories,
|
||||||
|
"profile_update": profile_update,
|
||||||
|
}
|
||||||
|
|
||||||
async def analyze_image(
|
async def analyze_image(
|
||||||
self,
|
self,
|
||||||
image_bytes: bytes,
|
image_bytes: bytes,
|
||||||
|
|||||||
Reference in New Issue
Block a user