fix the learning
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-06-26 16:09:04 +01:00
parent 4ae37e84dd
commit 1e0a4c97b3
9 changed files with 623 additions and 32 deletions

View File

@@ -9,6 +9,7 @@ import {
} from "./library-context";
import { listMemories, getProfileSummary } from "./ai-memory";
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
import { extractMemoryCandidates } from "./memory-extraction";
export async function listChatSessions(userId: string) {
return db
@@ -57,11 +58,14 @@ export async function sendChatMessage(userId: string, sessionId: string, content
const { session, messages } = sessionData;
await db.insert(aiChatMessages).values({
sessionId,
role: "user",
content: content.trim(),
});
const [userMsg] = await db
.insert(aiChatMessages)
.values({
sessionId,
role: "user",
content: content.trim(),
})
.returning();
const availability = await getAiAvailability(userId);
const ctx = await buildMentorContext(userId, {
@@ -83,7 +87,7 @@ export async function sendChatMessage(userId: string, sessionId: string, content
let reply: string;
let offline = false;
let memoryIdsUsed = ctx.memoryIds;
const memoryIdsUsed = ctx.memoryIds;
if (!availability.canUse || !availability.online) {
offline = true;
@@ -116,12 +120,17 @@ export async function sendChatMessage(userId: string, sessionId: string, content
})
.returning();
const memorySuggestions = await extractMemoryCandidates(userId, "chat", content, {
type: "ai_chat",
id: userMsg.id,
});
await db
.update(aiChatSessions)
.set({ updatedAt: new Date(), title: session.title === "New conversation" ? truncateTitle(content) : session.title })
.where(eq(aiChatSessions.id, sessionId));
return { message: assistantMsg, reply, memoryIdsUsed, offline };
return { message: assistantMsg, reply, memoryIdsUsed, offline, memorySuggestions };
}
function truncateTitle(text: string): string {

View File

@@ -243,20 +243,41 @@ export async function createSuggestion(
confidence?: number;
}
) {
if (!(input.category in MEMORY_CATEGORIES)) {
throw new Error("Invalid memory category");
}
const settings = await getMemoryLearningSettings(userId);
if (!settings.learningEnabled) return null;
const pending = await listSuggestions(userId, "pending");
if (pending.length >= settings.maxPendingSuggestions) return null;
if (pending.some((s) => isSimilarMemoryText(input, s))) return null;
const exactTitle = await db
.select()
.from(aiMemories)
.where(
and(
eq(aiMemories.userId, userId),
eq(aiMemories.category, input.category),
isNull(aiMemories.archivedAt),
ilike(aiMemories.title, input.title)
)
)
.limit(1);
if (exactTitle.length > 0) return null;
const dup = await db
.select()
.from(aiMemories)
.where(
and(
eq(aiMemories.userId, userId),
eq(aiMemories.category, input.category),
isNull(aiMemories.archivedAt),
ilike(aiMemories.content, `%${input.content.slice(0, 40)}%`)
ilike(aiMemories.content, `%${keyPhrase(input.content)}%`)
)
)
.limit(1);
@@ -278,6 +299,40 @@ export async function createSuggestion(
return row;
}
function isSimilarMemoryText(
input: { category: MemoryCategory; title: string; content: string },
existing: { category: string; title: string; content: string }
) {
if (input.category !== existing.category) return false;
const inputTitle = normalizeMemoryText(input.title);
const existingTitle = normalizeMemoryText(existing.title);
const inputContent = normalizeMemoryText(input.content);
const existingContent = normalizeMemoryText(existing.content);
return (
inputTitle === existingTitle ||
inputContent === existingContent ||
inputContent.includes(existingTitle) ||
existingContent.includes(inputTitle) ||
inputContent.includes(keyPhrase(existing.content)) ||
existingContent.includes(keyPhrase(input.content))
);
}
function normalizeMemoryText(value: string) {
return value
.toLowerCase()
.replace(/\b7\s*a\.?\s*m\.?\b/g, "7am")
.replace(/[^a-z0-9]+/g, " ")
.replace(/\b(the|a|an|user|users|the user|to|is|are|be)\b/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function keyPhrase(value: string) {
return normalizeMemoryText(value).split(" ").filter(Boolean).slice(0, 8).join(" ");
}
export async function markMemoriesUsed(userId: string, memoryIds: string[]) {
if (memoryIds.length === 0) return;
await db

View File

@@ -524,6 +524,7 @@ export async function generateChatReply(
const prompt = [
input.history ? `Recent conversation:\n${input.history}\n` : "",
`User: ${input.userMessage}`,
"If the user asks whether you can learn about them, explain that AdventureOS can suggest memories from goals, preferences, routines, worries, and direct remember requests; the user can review, edit, accept, reject, or ignore suggestions before they become saved memory.",
"Respond as the mentor in plain text (no JSON). Keep it concise for a local model.",
]
.filter(Boolean)

View File

@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
extractDeterministicMemoryCandidates,
extractMemoryCandidates,
} from "./memory-extraction";
const createSuggestion = vi.fn();
let learningEnabled = true;
vi.mock("./ai-memory", () => ({
createSuggestion: (...args: unknown[]) => createSuggestion(...args),
getMemoryLearningSettings: vi.fn(async () => ({
learningEnabled,
autoSuggestEnabled: true,
requireApproval: true,
allowedCategories: [
"current_goals",
"likes",
"dislikes",
"learning_interests",
"exercise_preferences",
"ai_tone",
"worries",
],
suggestAfterReflection: true,
suggestAfterChat: true,
maxPendingSuggestions: 10,
minDaysBetweenNudges: 3,
allowSensitiveCategories: false,
})),
}));
vi.mock("./ai-config", () => ({
getAiBehaviorConfig: vi.fn(async () => ({ enabled: true })),
getAiProviderConfig: vi.fn(async () => ({ enabled: true, model: "llama3.1:8b", timeoutMs: 1 })),
generateTextWithFallback: vi.fn(async () => {
throw new Error("AI extractor failed");
}),
buildSystemPrompt: vi.fn(() => "system"),
}));
vi.mock("./ai-templates", () => ({
getTemplateBody: vi.fn(async () => "core"),
}));
describe("extractDeterministicMemoryCandidates", () => {
it("extracts the wake-up goal regression case", () => {
const [candidate] = extractDeterministicMemoryCandidates(
"One of my goals for the next coming weeks is to wake up at 7 am every morning"
);
expect(candidate).toMatchObject({
category: "current_goals",
title: "Wake Up at 7am Every Morning",
confidence: 0.9,
});
expect(candidate.content).toContain("goals for the next few weeks");
expect(candidate.content).toContain("wake up at 7 am every morning");
});
it("extracts learning preferences from likes", () => {
const [candidate] = extractDeterministicMemoryCandidates("I like learning with examples");
expect(candidate).toMatchObject({
category: "learning_interests",
title: "Learning with Examples",
});
});
it("extracts dislikes", () => {
const [candidate] = extractDeterministicMemoryCandidates("I dislike too many tasks at once");
expect(candidate).toMatchObject({
category: "dislikes",
title: "Too Many Tasks at Once",
});
});
it("extracts ordinary worries as pending memory candidates", () => {
const [candidate] = extractDeterministicMemoryCandidates("I get worried when I fall behind");
expect(candidate).toMatchObject({
category: "worries",
title: "Fall Behind",
});
});
it("does not extract vague chatter", () => {
expect(extractDeterministicMemoryCandidates("hello")).toEqual([]);
expect(extractDeterministicMemoryCandidates(" ")).toEqual([]);
expect(extractDeterministicMemoryCandidates("tell me about DNS")).toEqual([]);
});
});
describe("extractMemoryCandidates", () => {
beforeEach(() => {
learningEnabled = true;
createSuggestion.mockReset();
createSuggestion.mockImplementation(async (_userId, input) => ({
id: "suggestion-1",
...input,
status: "pending",
}));
});
it("creates deterministic suggestions even when AI extraction fails", async () => {
const suggestions = await extractMemoryCandidates(
"user-1",
"chat",
"One of my goals for the next coming weeks is to wake up at 7 am every morning",
{ type: "ai_chat", id: "message-1" }
);
expect(createSuggestion).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({
category: "current_goals",
sourceType: "chat",
sourceRef: { type: "ai_chat", id: "message-1" },
})
);
expect(suggestions).toHaveLength(1);
});
it("does not create suggestions when memory learning is disabled", async () => {
learningEnabled = false;
const suggestions = await extractMemoryCandidates(
"user-1",
"chat",
"I like learning with examples"
);
expect(suggestions).toEqual([]);
expect(createSuggestion).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
import type { MemoryCategory, MemorySourceType } from "@adventureos/shared";
import { SENSITIVE_MEMORY_CATEGORIES } 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";
@@ -19,6 +19,111 @@ const candidateSchema = z.object({
.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,
@@ -26,18 +131,52 @@ export async function extractMemoryCandidates(
sourceRef?: { type: string; id?: string; date?: string }
) {
const settings = await getMemoryLearningSettings(userId);
if (!settings.learningEnabled || !settings.autoSuggestEnabled) return [];
if (
!settings.learningEnabled ||
!settings.autoSuggestEnabled ||
(sourceType === "chat" && !settings.suggestAfterChat)
) {
return [];
}
const behavior = await getAiBehaviorConfig(userId);
const providerConfig = await getAiProviderConfig(userId);
if (!behavior.enabled || !providerConfig.enabled) 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)}
"""
Return JSON: { "candidates": [{ "category": "likes|motivators|current_goals|reading_preferences|learning_interests|worries|...", "title": "short label", "content": "one sentence fact", "confidence": 0.0-1.0, "sensitivity": "normal|private|sensitive" }] }
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 {
@@ -57,9 +196,8 @@ If nothing clear, return { "candidates": [] }.`;
);
const parsed = candidateSchema.safeParse(parseAiJson(res.text));
if (!parsed.success) return [];
if (!parsed.success) return results;
const results = [];
for (const c of parsed.data.candidates) {
if (c.confidence < 0.6) continue;
if (!settings.allowedCategories.includes(c.category as MemoryCategory)) continue;
@@ -81,7 +219,7 @@ If nothing clear, return { "candidates": [] }.`;
}
return results;
} catch {
return [];
return results;
}
}
@@ -89,3 +227,68 @@ 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;
}