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() 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 — CompressedProfile type + related interfacesapp/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.
exportinterfaceRawSignals{total_chapters_read: number;last_30_day_chapters: number;top_scholars_opened: Array<{scholar_id: string;open_count: number}>;// top 5tradition_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 10current_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
exportasyncfunctiongenerateProfile(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
exportasyncfunctiongetProfileForInspection(): Promise<{prose: string;// same as what would be sent to Amicusgenerated_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
exportasyncfunctionclearProfile(): 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