import type { UserFact, UserFactCategory, UserFactSource, UserFactStatus, UserProfile, UserProfileState, } from "@rakazo/contracts"; import { SENSITIVE_USER_FACT_CATEGORIES } from "@rakazo/contracts"; import type { PrismaClient } from "./client.js"; import { Prisma } from "./client.js"; /** Upper bound on facts one user keeps, so a runaway writer cannot grow the profile without end. */ export const USER_FACT_LIMIT = 500; export class UserFactNotFoundError extends Error { constructor() { super("Fact not found"); this.name = "UserFactNotFoundError"; } } export class InvalidUserFactError extends Error { constructor(message: string) { super(message); this.name = "InvalidUserFactError"; } } export interface NewUserFact { category: UserFactCategory; value: string; source: UserFactSource; confidence?: number; /** Omitted or null: visible in every space. */ spaceIds?: string[] | null; sourceBotId?: string | null; sourceThreadId?: string | null; sourceMessageId?: string | null; } export interface UserFactEdit { id: string; category?: UserFactCategory; value?: string; /** null: visible in every space. */ spaceIds?: string[] | null; } export interface UserProfileUpdate { name?: string; timeZone?: string | null; locale?: string | null; language?: string | null; communicationStyle?: string; summary?: string; addFacts?: NewUserFact[]; editFacts?: UserFactEdit[]; confirmFactIds?: string[]; } /** Outcome for one added fact, in input order. */ export interface AddedUserFact { id: string; status: UserFactStatus; /** False when the statement was already known and the existing fact was kept. */ created: boolean; } export type UserFactForgetTarget = | { factIds: string[] } | { category: UserFactCategory } | { all: true }; const SENSITIVE_CATEGORIES = new Set(SENSITIVE_USER_FACT_CATEGORIES); export function isSensitiveUserFactCategory(category: UserFactCategory): boolean { return SENSITIVE_CATEGORIES.has(category); } /** Comparison key for the same statement written twice: case, spacing, quotes and end punctuation differ. */ export function normalizeUserFactValue(value: string): string { return value .normalize("NFKC") .toLowerCase() .replace(/\s+/g, " ") .replace(/^["'“”„‘’«»\s]+|["'“”„‘’«».!?;:,\s]+$/gu, ""); } const factSelect = { id: true, category: true, value: true, confidence: true, source: true, status: true, sensitive: true, allSpaces: true, sourceBotId: true, sourceThreadId: true, createdAt: true, updatedAt: true, visibleSpaces: { select: { spaceId: true }, orderBy: { spaceId: "asc" } }, } as const; type FactRow = Prisma.UserFactGetPayload<{ select: typeof factSelect }>; type ProfileRow = { timeZone: string | null; locale: string | null; language: string | null; communicationStyle: string; summary: string; consolidatedAt: Date | null; updatedAt: Date; }; function factDto(row: FactRow): UserFact { return { id: row.id, category: row.category as UserFactCategory, value: row.value, confidence: row.confidence, source: row.source as UserFactSource, status: row.status as UserFactStatus, sensitive: row.sensitive, allSpaces: row.allSpaces, spaceIds: row.allSpaces ? [] : row.visibleSpaces.map((space) => space.spaceId), sourceBotId: row.sourceBotId, sourceThreadId: row.sourceThreadId, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } function profileDto(row: ProfileRow | null): UserProfile { return { timeZone: row?.timeZone ?? null, locale: row?.locale ?? null, language: row?.language ?? null, communicationStyle: row?.communicationStyle ?? "", summary: row?.summary ?? "", consolidatedAt: row?.consolidatedAt?.toISOString() ?? null, updatedAt: row?.updatedAt.toISOString() ?? null, }; } /** The user's profile and every fact, pending ones included, newest first. */ export async function getUserProfileState( prisma: PrismaClient, userId: string, ): Promise { const [profile, facts] = await Promise.all([ prisma.userProfile.findUnique({ where: { userId } }), prisma.userFact.findMany({ where: { userId }, orderBy: [{ createdAt: "desc" }, { id: "asc" }], select: factSelect, }), ]); return { profile: profileDto(profile), facts: facts.map(factDto) }; } /** * Serializes one user's profile writes so duplicate checks and the fact limit hold under * concurrency. Every writer of facts takes it inside its transaction. */ export async function lockUserProfile(tx: Prisma.TransactionClient, userId: string): Promise { const key = `user-profile:${userId}`; await tx.$queryRaw(Prisma.sql` SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))::text AS "lock" `); } async function assertUserSpaces( tx: Prisma.TransactionClient, userId: string, spaceIds: string[], ): Promise { const unique = [...new Set(spaceIds)]; if (unique.length === 0) return; const memberships = await tx.spaceMember.count({ where: { userId, spaceId: { in: unique } }, }); if (memberships !== unique.length) throw new InvalidUserFactError("Space not found"); } function visibleSpacesOf(spaceIds: string[] | null | undefined): string[] | null { return spaceIds && spaceIds.length > 0 ? [...new Set(spaceIds)] : null; } async function addFacts( tx: Prisma.TransactionClient, userId: string, facts: NewUserFact[], ): Promise { if (facts.length === 0) return []; const existing = await tx.userFact.findMany({ where: { userId }, select: { id: true, value: true, status: true }, }); const known = new Map(existing.map((fact) => [normalizeUserFactValue(fact.value), fact])); let total = existing.length; const results: AddedUserFact[] = []; for (const fact of facts) { const value = fact.value.trim(); const key = normalizeUserFactValue(value); const match = known.get(key); if (match) { // The user stating a pending fact themselves is consent. if (match.status === "pending" && fact.source === "user") { await tx.userFact.update({ where: { id: match.id }, data: { status: "active", confirmedAt: new Date() }, }); match.status = "active"; } results.push({ id: match.id, status: match.status as UserFactStatus, created: false }); continue; } if (total >= USER_FACT_LIMIT) { throw new InvalidUserFactError(`A profile keeps at most ${USER_FACT_LIMIT} facts`); } const sensitive = isSensitiveUserFactCategory(fact.category); const userAuthored = fact.source === "user"; const status: UserFactStatus = sensitive && !userAuthored ? "pending" : "active"; const spaces = visibleSpacesOf(fact.spaceIds); const created = await tx.userFact.create({ data: { userId, category: fact.category, value, confidence: fact.confidence ?? 1, source: fact.source, status, sensitive, allSpaces: spaces === null, sourceBotId: fact.sourceBotId ?? null, sourceThreadId: fact.sourceThreadId ?? null, sourceMessageId: fact.sourceMessageId ?? null, confirmedAt: userAuthored ? new Date() : null, ...(spaces ? { visibleSpaces: { create: spaces.map((spaceId) => ({ spaceId })) } } : {}), }, select: { id: true, value: true, status: true }, }); known.set(key, created); total += 1; results.push({ id: created.id, status, created: true }); } return results; } async function editFact( tx: Prisma.TransactionClient, userId: string, edit: UserFactEdit, ): Promise { const fact = await tx.userFact.findFirst({ where: { id: edit.id, userId }, select: { id: true }, }); if (!fact) throw new UserFactNotFoundError(); const data: Prisma.UserFactUpdateInput = {}; if (edit.category !== undefined) { data.category = edit.category; data.sensitive = isSensitiveUserFactCategory(edit.category); } if (edit.value !== undefined) { data.value = edit.value.trim(); // The stored embedding described the old wording. data.embedding = []; data.embeddingModel = null; } if (edit.spaceIds !== undefined) { const spaces = visibleSpacesOf(edit.spaceIds); data.allSpaces = spaces === null; data.visibleSpaces = { deleteMany: {}, ...(spaces ? { create: spaces.map((spaceId) => ({ spaceId })) } : {}), }; } await tx.userFact.update({ where: { id: fact.id }, data }); } async function confirmFacts( tx: Prisma.TransactionClient, userId: string, factIds: string[], ): Promise { const unique = [...new Set(factIds)]; if (unique.length === 0) return; const owned = await tx.userFact.count({ where: { userId, id: { in: unique } } }); if (owned !== unique.length) throw new UserFactNotFoundError(); await tx.userFact.updateMany({ where: { userId, id: { in: unique }, status: "pending" }, data: { status: "active", confirmedAt: new Date() }, }); } function profileFields(update: UserProfileUpdate) { const fields = { ...(update.timeZone !== undefined ? { timeZone: update.timeZone } : {}), ...(update.locale !== undefined ? { locale: update.locale } : {}), ...(update.language !== undefined ? { language: update.language } : {}), ...(update.communicationStyle !== undefined ? { communicationStyle: update.communicationStyle } : {}), ...(update.summary !== undefined ? { summary: update.summary } : {}), }; return Object.keys(fields).length > 0 ? fields : null; } /** * Applies one profile change atomically: account name, profile fields, new facts, fact edits * and confirmations. Facts in sensitive categories wait for the user's confirmation unless the * user wrote them, and a statement the profile already holds is not stored twice. */ export async function updateUserProfile( prisma: PrismaClient, userId: string, update: UserProfileUpdate, ): Promise { return prisma.$transaction(async (tx) => { await lockUserProfile(tx, userId); await assertUserSpaces( tx, userId, [...(update.addFacts ?? []), ...(update.editFacts ?? [])].flatMap( (fact) => fact.spaceIds ?? [], ), ); if (update.name !== undefined) { await tx.user.update({ where: { id: userId }, data: { name: update.name } }); } const fields = profileFields(update); if (fields) { await tx.userProfile.upsert({ where: { userId }, create: { userId, ...fields }, update: fields, }); } const added = await addFacts(tx, userId, update.addFacts ?? []); for (const edit of update.editFacts ?? []) await editFact(tx, userId, edit); await confirmFacts(tx, userId, update.confirmFactIds ?? []); return added; }); } /** * Hard-deletes facts. Because the summary may restate them and chats not yet consolidated could * teach them again, forgetting also clears the summary and marks earlier chats as consolidated. * Forgetting everything clears the remaining profile fields too. */ export async function forgetUserFacts( prisma: PrismaClient, userId: string, target: UserFactForgetTarget, ): Promise { return prisma.$transaction(async (tx) => { await lockUserProfile(tx, userId); const where: Prisma.UserFactWhereInput = "factIds" in target ? { userId, id: { in: target.factIds } } : "category" in target ? { userId, category: target.category } : { userId }; const { count } = await tx.userFact.deleteMany({ where }); const everything = "all" in target; if (count > 0 || everything) { const reset = { summary: "", consolidatedAt: new Date(), ...(everything ? { timeZone: null, locale: null, language: null, communicationStyle: "" } : {}), }; await tx.userProfile.upsert({ where: { userId }, create: { userId, ...reset }, update: reset, }); } return count; }); }