Skip to content

ai-partner: compressed profile generator #1452

@CraigBuckmaster

Description

@CraigBuckmaster

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.tsCompressedProfile 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:

  1. Baseline: "Studied X chapters total" (always included if > 0)
  2. Recent focus: "Recently focused on [book / genre]" (included if current_focus present)
  3. Scholar affinity: "Engages deeply with [top 2 scholars]" (included if top scholar has ≥ 10 opens)
  4. Tradition lean: "Shows interest in [top tradition] perspectives" (included if top tradition > 30%)
  5. Journey state: "Completed [journey], currently on [active]" (included if any)
  6. 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

  • Migration adds partner_profile_cache table cleanly on a fresh install
  • Migration is idempotent (re-running does nothing harmful)
  • generateProfile() returns prose under 200 tokens for thick-profile fixture
  • generateProfile() returns minimal prose for thin-profile fixture (new user)
  • Cache hit on second call with same signals (no re-computation)
  • Cache miss after signal change (hash differs)
  • force=true bypasses cache
  • getProfileForInspection() returns both prose and raw_signals
  • clearProfile() empties the cache row; next call regenerates
  • Unit tests cover: thick/thin profile, cache hit, cache miss, force regen, template section inclusion rules
  • No any types; strict TypeScript passes
  • Profile is deterministic — same signals produce same prose (critical for hash-based caching)

Out of scope

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions