Parent epic: #1446 (Amicus — AI Study Partner v1)
Phase: 1 · Size: S
Deterministic on-device generator that reads engagement signals from user.db and produces a ~150-token prose summary ("compressed profile") to send to the AI proxy with each Amicus query. Pure function — no LLM call. User-inspectable.
Files to create
app/src/services/amicus/profile/generator.ts — main generateProfile() function
app/src/services/amicus/profile/signals.ts — queries that pull raw engagement signals from user.db
app/src/services/amicus/profile/templates.ts — prose assembly templates by signal density
app/src/services/amicus/profile/types.ts — CompressedProfile type + related interfaces
app/src/services/amicus/profile/__tests__/generator.test.ts — unit tests with fixture data
Files to modify
app/src/db/userDatabase.ts — add migration creating the partner_profile_cache table (see Migration section)
Conventions to follow
- Migration pattern in
userDatabase.ts: append-only. Add a new entry to the MIGRATIONS array — never modify existing entries (userDatabase.ts line 61 comment).
- Use
getUserDb() from app/src/db/userDatabase.ts to access connection.
- Use
logger from app/src/utils/logger.ts.
Migration (add to MIGRATIONS array in userDatabase.ts)
{
version: /* next available number */,
description: 'Amicus — compressed profile cache',
sql: `
CREATE TABLE IF NOT EXISTS partner_profile_cache (
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row
profile_prose TEXT NOT NULL,
raw_signals_hash TEXT NOT NULL,
generated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`,
}
Singleton pattern (id = 1) — there's only ever one current profile per user. Regenerate in place; never accumulate rows.
Signals extracted from user.db (signals.ts)
All signals are derived from existing tables (reading_progress, user_notes, bookmarks, verse_highlights, etc.) or near-term additions. Profile generator doesn't require any NEW engagement tracking — uses what's already there.
export interface RawSignals {
total_chapters_read: number;
last_30_day_chapters: number;
top_scholars_opened: Array<{ scholar_id: string; open_count: number }>; // top 5
tradition_distribution: Record<string, number>; // e.g. { "Reformed": 0.3, "Jewish": 0.2 }
genre_distribution: Record<string, number>; // e.g. { "epistle": 0.4, "narrative": 0.3 }
completed_journeys: string[];
active_journey: string | null;
recent_chapters: Array<{ book_id: string; chapter_num: number; last_visit: string }>; // last 10
current_focus?: { book_id: string; chapters_in_range: number; days_in_range: number }; // detected streak
}
Each signal is a SQL query in signals.ts. Queries are pure, deterministic, cacheable.
Prose assembly (templates.ts)
Build the summary in sections, each added only if its signal is meaningful:
- Baseline: "Studied X chapters total" (always included if > 0)
- Recent focus: "Recently focused on [book / genre]" (included if
current_focus present)
- Scholar affinity: "Engages deeply with [top 2 scholars]" (included if top scholar has ≥ 10 opens)
- Tradition lean: "Shows interest in [top tradition] perspectives" (included if top tradition > 30%)
- Journey state: "Completed [journey], currently on [active]" (included if any)
- Current position: "Currently reading [book chapter]" (included if
recent_chapters[0] is within last 24h)
Thin profiles (new users) get a deliberately minimal prose like: "New to Companion Study; no established study patterns yet. Currently reading [X]." — this signals to Amicus not to over-personalize.
Target length: 100-200 tokens. Enforce with a final truncation if needed.
Caching & invalidation
export async function generateProfile(force = false): Promise<CompressedProfile>;
- Cache hit: if
raw_signals_hash of current signals matches stored hash AND generated_at is within 7 days → return cached profile_prose
- Cache miss (or
force=true): recompute, update singleton row
raw_signals_hash = SHA256 of JSON-serialized RawSignals (stable serialization, sorted keys)
User-inspectable API
export async function getProfileForInspection(): Promise<{
prose: string; // same as what would be sent to Amicus
generated_at: string;
raw_signals: RawSignals; // for Settings screen to render "what we're sending"
}>;
The Settings screen (#1459) uses this to render the "Show My Amicus Profile" view.
Reset API
export async function clearProfile(): Promise<void>;
Called by "Clear Amicus History" in Settings. Wipes the cache row; profile regenerates on next query.
Acceptance criteria
Out of scope
Parent epic: #1446 (Amicus — AI Study Partner v1)
Phase: 1 · Size: S
Deterministic on-device generator that reads engagement signals from
user.dband produces a ~150-token prose summary ("compressed profile") to send to the AI proxy with each Amicus query. Pure function — no LLM call. User-inspectable.Files to create
app/src/services/amicus/profile/generator.ts— maingenerateProfile()functionapp/src/services/amicus/profile/signals.ts— queries that pull raw engagement signals from user.dbapp/src/services/amicus/profile/templates.ts— prose assembly templates by signal densityapp/src/services/amicus/profile/types.ts—CompressedProfiletype + related interfacesapp/src/services/amicus/profile/__tests__/generator.test.ts— unit tests with fixture dataFiles to modify
app/src/db/userDatabase.ts— add migration creating thepartner_profile_cachetable (see Migration section)Conventions to follow
userDatabase.ts: append-only. Add a new entry to theMIGRATIONSarray — never modify existing entries (userDatabase.ts line 61 comment).getUserDb()fromapp/src/db/userDatabase.tsto access connection.loggerfromapp/src/utils/logger.ts.Migration (add to MIGRATIONS array in userDatabase.ts)
Singleton pattern (id = 1) — there's only ever one current profile per user. Regenerate in place; never accumulate rows.
Signals extracted from user.db (
signals.ts)All signals are derived from existing tables (reading_progress, user_notes, bookmarks, verse_highlights, etc.) or near-term additions. Profile generator doesn't require any NEW engagement tracking — uses what's already there.
Each signal is a SQL query in
signals.ts. Queries are pure, deterministic, cacheable.Prose assembly (
templates.ts)Build the summary in sections, each added only if its signal is meaningful:
current_focuspresent)recent_chapters[0]is within last 24h)Thin profiles (new users) get a deliberately minimal prose like: "New to Companion Study; no established study patterns yet. Currently reading [X]." — this signals to Amicus not to over-personalize.
Target length: 100-200 tokens. Enforce with a final truncation if needed.
Caching & invalidation
raw_signals_hashof current signals matches stored hash ANDgenerated_atis within 7 days → return cachedprofile_proseforce=true): recompute, update singleton rowraw_signals_hash= SHA256 of JSON-serializedRawSignals(stable serialization, sorted keys)User-inspectable API
The Settings screen (#1459) uses this to render the "Show My Amicus Profile" view.
Reset API
Called by "Clear Amicus History" in Settings. Wipes the cache row; profile regenerates on next query.
Acceptance criteria
partner_profile_cachetable cleanly on a fresh installgenerateProfile()returns prose under 200 tokens for thick-profile fixturegenerateProfile()returns minimal prose for thin-profile fixture (new user)force=truebypasses cachegetProfileForInspection()returns both prose and raw_signalsclearProfile()empties the cache row; next call regeneratesanytypes; strict TypeScript passesOut of scope