295 lines
9.5 KiB
TypeScript
295 lines
9.5 KiB
TypeScript
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;
|
||
}
|