Files
adventure/apps/web/src/lib/services/memory-extraction.ts
Zaine 1e0a4c97b3
Some checks failed
CI / test (push) Has been cancelled
fix the learning
2026-06-26 16:09:04 +01:00

295 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
import type { MemoryCategory, MemorySourceType } from "@adventureos/shared";
import { MEMORY_CATEGORIES, SENSITIVE_MEMORY_CATEGORIES } from "@adventureos/shared";
import { parseAiJson } from "@/lib/ai/parse-json";
import { getAiBehaviorConfig, getAiProviderConfig, generateTextWithFallback, buildSystemPrompt } from "./ai-config";
import { createSuggestion, getMemoryLearningSettings } from "./ai-memory";
const candidateSchema = z.object({
candidates: z
.array(
z.object({
category: z.string(),
title: z.string(),
content: z.string(),
confidence: z.coerce.number(),
sensitivity: z.enum(["normal", "private", "sensitive"]).optional(),
})
)
.max(2),
});
export type MemorySuggestionCandidate = {
category: MemoryCategory;
title: string;
content: string;
confidence: number;
};
type PatternRule = {
category: MemoryCategory;
confidence: number;
patterns: RegExp[];
titlePrefix?: string;
content: (value: string) => string;
};
const FILLER_PREFIX = /^(?:that\s+|to\s+|i\s+|i'm\s+|i am\s+|my\s+)/i;
const RULES: PatternRule[] = [
{
category: "current_goals",
confidence: 0.9,
patterns: [
/^(?:one of my goals|my current goal|my goal|a goal of mine)(?:\s+for\s+.+?)?\s+is\s+(.+)$/i,
/^i want to\s+(.+)$/i,
/^i'm trying to\s+(.+)$/i,
/^i am trying to\s+(.+)$/i,
/^i want to be more\s+(.+)$/i,
/^my current focus is\s+(.+)$/i,
],
content: (value) => `One of the user's current goals is to ${ensureVerbPhrase(value)}.`,
},
{
category: "likes",
confidence: 0.85,
patterns: [/^i like\s+(.+)$/i],
content: (value) => `The user likes ${lowerFirst(value)}.`,
},
{
category: "dislikes",
confidence: 0.85,
patterns: [/^i dislike\s+(.+)$/i, /^i don't like\s+(.+)$/i, /^i do not like\s+(.+)$/i],
content: (value) => `The user dislikes ${lowerFirst(value)}.`,
},
{
category: "ai_tone",
confidence: 0.9,
patterns: [/^i prefer\s+(short explanations|direct explanations|concise explanations|brief explanations|.+\s+explanations)$/i, /^i want the ai to\s+(.+)$/i],
content: (value) =>
/\bexplanations\b/i.test(value)
? `The user prefers ${lowerFirst(value)}.`
: `The user wants the AI to ${ensureVerbPhrase(value)}.`,
},
{
category: "worries",
confidence: 0.8,
patterns: [/^i get worried when\s+(.+)$/i, /^i'm worried about\s+(.+)$/i, /^i am worried about\s+(.+)$/i],
content: (value) => `The user has a worry or concern about ${lowerFirst(value)}.`,
},
{
category: "personal_context",
confidence: 0.75,
patterns: [/^i struggle with\s+(.+)$/i, /^i need help with\s+(.+)$/i],
content: (value) => `The user needs support with ${lowerFirst(value)}.`,
},
{
category: "personal_context",
confidence: 0.95,
patterns: [/^(?:remember that|save this:?|add this to memory:?)(.+)$/i],
titlePrefix: "Remember",
content: (value) => `The user explicitly asked the app to remember that ${stripTrailingPunctuation(value)}.`,
},
];
const LEARNING_HINTS = /\b(learn|learning|examples?|explanations?|teach|lesson)\b/i;
const EXERCISE_HINTS = /\b(exercise|work out|workout|run|gym|walk|train)\b/i;
const UNSPECIFIC_MESSAGES = /^(hello|hi|hey|thanks|thank you|what can you do\??|that's interesting|that is interesting)$/i;
export function extractDeterministicMemoryCandidates(sourceText: string): MemorySuggestionCandidate[] {
const normalized = normalizeInput(sourceText);
if (!normalized || normalized.length < 8 || UNSPECIFIC_MESSAGES.test(normalized)) return [];
for (const rule of RULES) {
for (const pattern of rule.patterns) {
const match = normalized.match(pattern);
const rawValue = match?.[1]?.trim();
if (!rawValue) continue;
const value = cleanValue(rawValue);
if (!isDurableEnough(value)) return [];
const category = refineCategory(rule.category, normalized, value);
return [
{
category,
title: buildTitle(value, rule.titlePrefix),
content: refineContent(rule.content(value), normalized),
confidence: rule.confidence,
},
];
}
}
return [];
}
export async function extractMemoryCandidates(
userId: string,
sourceType: MemorySourceType,
sourceText: string,
sourceRef?: { type: string; id?: string; date?: string }
) {
const settings = await getMemoryLearningSettings(userId);
if (
!settings.learningEnabled ||
!settings.autoSuggestEnabled ||
(sourceType === "chat" && !settings.suggestAfterChat)
) {
return [];
}
const results = [];
const deterministic = extractDeterministicMemoryCandidates(sourceText);
for (const c of deterministic) {
if (!settings.allowedCategories.includes(c.category)) continue;
if (
SENSITIVE_MEMORY_CATEGORIES.includes(c.category) &&
!settings.allowSensitiveCategories
) {
continue;
}
const row = await createSuggestion(userId, {
category: c.category,
title: c.title,
content: c.content,
sourceType,
sourceRef,
confidence: c.confidence,
});
if (row) results.push(row);
}
let behavior: Awaited<ReturnType<typeof getAiBehaviorConfig>>;
let providerConfig: Awaited<ReturnType<typeof getAiProviderConfig>>;
try {
behavior = await getAiBehaviorConfig(userId);
providerConfig = await getAiProviderConfig(userId);
} catch {
return results;
}
if (!behavior.enabled || !providerConfig.enabled) return results;
const prompt = `From this user text, suggest 0-2 personal memory facts the app could remember (only clear patterns, not guesses).
User text:
"""
${sourceText.slice(0, 1500)}
"""
Use only these categories: ${Object.keys(MEMORY_CATEGORIES).join(", ")}.
Return JSON: { "candidates": [{ "category": "likes|motivators|current_goals|reading_preferences|learning_interests|worries|...", "title": "short label", "content": "one sentence fact about the user", "confidence": 0.0-1.0, "sensitivity": "normal|private|sensitive" }] }
If nothing clear, return { "candidates": [] }.`;
try {
const coreSystem = await getTemplateBody(userId);
const res = await generateTextWithFallback(
userId,
{
model: providerConfig.model,
prompt,
system: buildSystemPrompt(behavior, coreSystem),
format: "json",
temperature: 0.3,
maxTokens: 400,
timeoutMs: providerConfig.timeoutMs,
},
behavior
);
const parsed = candidateSchema.safeParse(parseAiJson(res.text));
if (!parsed.success) return results;
for (const c of parsed.data.candidates) {
if (c.confidence < 0.6) continue;
if (!settings.allowedCategories.includes(c.category as MemoryCategory)) continue;
if (
SENSITIVE_MEMORY_CATEGORIES.includes(c.category as MemoryCategory) &&
!settings.allowSensitiveCategories
) {
continue;
}
const row = await createSuggestion(userId, {
category: c.category as MemoryCategory,
title: c.title,
content: c.content,
sourceType,
sourceRef,
confidence: c.confidence,
});
if (row) results.push(row);
}
return results;
} catch {
return results;
}
}
async function getTemplateBody(userId: string) {
const { getTemplateBody: getTpl } = await import("./ai-templates");
return getTpl(userId, "system_core");
}
function normalizeInput(value: string): string {
return value.trim().replace(/[]/g, "'").replace(/\s+/g, " ");
}
function cleanValue(value: string): string {
return stripTrailingPunctuation(value.replace(FILLER_PREFIX, "").trim());
}
function stripTrailingPunctuation(value: string): string {
return value.trim().replace(/[.!?]+$/g, "").trim();
}
function isDurableEnough(value: string): boolean {
const lower = value.toLowerCase();
if (value.length < 4) return false;
if (/^(this|that|it|stuff|things|more|better)$/i.test(lower)) return false;
return true;
}
function refineCategory(category: MemoryCategory, fullText: string, value: string): MemoryCategory {
const combined = `${fullText} ${value}`;
if (category === "likes" && LEARNING_HINTS.test(combined)) return "learning_interests";
if (category === "current_goals" && EXERCISE_HINTS.test(combined)) return "exercise_preferences";
return category;
}
function refineContent(content: string, fullText: string): string {
if (/one of my goals/i.test(fullText) && /next coming weeks|next few weeks/i.test(fullText)) {
return content.replace("current goals is to", "goals for the next few weeks is to");
}
return content;
}
function ensureVerbPhrase(value: string): string {
const cleaned = lowerFirst(stripTrailingPunctuation(value));
if (/^(be|build|wake|read|exercise|work|study|learn|sleep|get|start|stop|finish|practice|focus)\b/i.test(cleaned)) {
return cleaned;
}
return cleaned;
}
function lowerFirst(value: string): string {
return value.charAt(0).toLowerCase() + value.slice(1);
}
function buildTitle(value: string, prefix?: string): string {
const clean = stripTrailingPunctuation(value)
.replace(/\b7\s*a\.?\s*m\.?\b/gi, "7am")
.replace(/\s+/g, " ")
.trim();
const title = clean
.replace(/^(?:to\s+)/i, "")
.split(" ")
.slice(0, 8)
.map((word, index) => {
if (/^\d/.test(word)) return word;
if (index > 0 && /^(at|to|with|for|and|or|the|a|an|of)$/i.test(word)) {
return word.toLowerCase();
}
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
})
.join(" ");
return prefix ? `${prefix}: ${title}` : title;
}