nyxcore-systems
7 min read

The Journey to Smarter Notes: Customizing AI Enrichment and Taming Action Items

We just rolled out two significant features: empowering users with custom LLM settings for note enrichment and bringing order to action points by grouping them with their source notes. Dive into the technical challenges and solutions we encountered.

LLMAIPrismatRPCReactNext.jsSoftware ArchitectureNotes AppFrontend DevelopmentBackend Development

Building a robust application often means iterating on user feedback and anticipating future needs. This past session, we tackled two critical areas to make our note-taking application even more powerful: giving users granular control over AI note enrichment and bringing much-needed structure to action items generated from those notes.

It was a sprint, but we've successfully implemented both features, passing all type checks and linting rules. Let's break down the journey, including the inevitable bumps along the way.

Empowering Users: Configurable AI Note Enrichment

Our application uses AI to enrich notes, turning raw thoughts into actionable insights. Previously, this process was a bit of a black box. The AI would do its thing, but users had no say in how it did it. That changes now.

The "Why": Beyond Generic AI

Generic AI output is often good, but rarely perfect. Different tasks require different models, providers, or even specific personas. Imagine wanting a "concise summary" persona for meeting notes versus a "creative brainstorming" persona for ideation. Giving users the choice unlocks a whole new level of utility.

The Implementation: From Backend to Frontend

Backend Foundations

The core logic for enrichment lives in src/server/services/note-enrichment.ts. We extended its enrichNoteWithWisdom() function to accept optional providerName, modelOverride, and personaId. This means our server-side service is now flexible enough to handle user-defined preferences.

typescript
// src/server/services/note-enrichment.ts (simplified)
async function enrichNoteWithWisdom(
  noteId: string,
  tenantId: string,
  options?: {
    providerName?: string;
    modelOverride?: string;
    personaId?: string;
  }
) {
  // ... logic to fetch persona, select LLM based on options ...
}

Next, our tRPC enrich procedure in src/server/trpc/routers/projects.ts was updated to accept these new input parameters. This is where the user's choices from the UI get passed down to the backend service. Crucially, persona queries were scoped to the current tenant or built-in options, preventing any cross-tenant data leakage—a vital security consideration.

Frontend Experience: Per-Note Settings

The user interface for these settings lives within the NotesTab (src/app/(dashboard)/dashboard/projects/[id]/page.tsx). We needed a way for users to configure enrichment for each individual note.

This presented an interesting challenge: how to manage state for settings that apply to a specific note, not globally?

💡 Lesson Learned: Beware of Shared State Pitfalls

The Problem: My initial instinct was to use a single useState hook for the provider, model, and persona across all notes. This felt simpler. However, a quick code review pointed out the obvious flaw: opening the enrichment settings for one note would inadvertently affect the settings displayed (and potentially used) for all other notes on the page. Not ideal for a per-note configuration!

The Fix: We refactored to use a Map<noteId, EnrichSettings> to store the enrichment preferences for each note. This ensures that opening or modifying settings for Note A doesn't interfere with Note B. Helper functions like getEnrichSettings() and patchEnrichSettings() were created to manage this map effectively.

typescript
// src/app/(dashboard)/dashboard/projects/[id]/page.tsx (simplified)
const [enrichSettingsMap, setEnrichSettingsMap] = useState<Map<string, EnrichSettings>>(new Map());

const getEnrichSettings = (noteId: string) => enrichSettingsMap.get(noteId) || DEFAULT_ENRICH_SETTINGS;

const patchEnrichSettings = (noteId: string, updates: Partial<EnrichSettings>) => {
  setEnrichSettingsMap(prev => {
    const newMap = new Map(prev);
    newMap.set(noteId, { ...getEnrichSettings(noteId), ...updates });
    return newMap;
  });
};

The UI now features a collapsible "Enrichment Settings" section for each note, complete with provider buttons, a model grid, and a PersonaPicker component. These settings are then passed directly to the enrichNote.mutate() call.

Bringing Order: Grouping Action Points by Source Note

AI enrichment often generates several action points. As a project grows, these can quickly become a flat, overwhelming list. Our second major feature addresses this by grouping action points based on the note that spawned them.

The "Why": Context is King

An action point like "Research market trends" is far more useful when you know which note it originated from. Was it a competitor analysis, a customer feedback session, or a brainstorming meeting? Grouping by source note provides crucial context and makes managing tasks much more intuitive.

The Implementation: Database to UI

Database Schema Evolution

The first step was to establish the relationship in our database. We added a sourceNoteId foreign key to the ActionPoint model in prisma/schema.prisma, linking it back to ProjectNote. We also added a back-relation actionPoints ActionPoint[] to the ProjectNote model for easy access.

prisma
// prisma/schema.prisma (excerpt)
model ProjectNote {
  id          String        @id @default(cuid())
  // ... other fields
  actionPoints ActionPoint[]
}

model ActionPoint {
  id           String      @id @default(cuid())
  // ... other fields
  sourceNoteId String?     @map("source_note_id")
  sourceNote   ProjectNote? @relation(fields: [sourceNoteId], references: [id])

  @@index([sourceNoteId])
}

After modifying the schema, npm run db:push && npm run db:generate ensured our database and Prisma client were perfectly in sync.

Backend Integration

When action points are created during the enrichment process, our applyEnrichment tRPC procedure (also in src/server/trpc/routers/projects.ts) now passes sourceNoteId: input.noteId to ensure each new action point is correctly linked.

To support the grouped view, the list procedure in src/server/trpc/routers/action-points.ts was updated to include the sourceNote relation, specifically selecting its id and title. This allows the frontend to display meaningful headers.

typescript
// src/server/trpc/routers/action-points.ts (simplified)
export const actionPointsRouter = router({
  list: protectedProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      return ctx.db.actionPoint.findMany({
        where: { projectId: input.projectId, project: { tenantId: ctx.session.user.tenantId } },
        include: {
          sourceNote: {
            select: { id: true, title: true } // Crucial for grouping
          }
        },
        orderBy: { createdAt: 'desc' },
      });
    }),
});

Frontend Presentation: ActionPointGroups

The ActionPointGroups component in the ActionPointsTab is where the magic happens visually. It takes the list of action points and intelligently groups them by their sourceNoteId.

Each group gets a collapsible header displaying the note's title and a count badge. We also added a special "Manual / Other" section for action points that don't have a sourceNoteId (e.g., those created manually). If no groups exist, it gracefully falls back to a flat list.

💡 Lesson Learned: The Elusive React key Prop

The Problem: During development of ActionPointGroups, I initially tried setting the React key prop inside a renderItem helper function, which was then called within a .map() loop. For example: {items.map((ap) => renderItem(ap))} where renderItem returns <Card key={ap.id}>...</Card>. React was complaining about missing keys, or keys not being stable.

The Fix: React requires the key prop to be on the element directly returned by the .map() callback. Moving the key to the call site resolved the issue: {items.map((ap) => <div key={ap.id}>{renderItem(ap)}</div>)}. This subtle but critical detail is a common source of confusion for React developers, and it's a good reminder to always place keys correctly at the root of mapped elements.

Looking Ahead: Refining and Enhancing

While these features are fully implemented and ready, development is an ongoing process. Here are a few immediate next steps and considerations for further refinement:

  1. Input Validation: Strengthen provider input validation in the enrich procedure using z.enum(LLM_PROVIDERS) instead of a free-form z.string(). This prevents unexpected values and improves type safety.
  2. Performance Optimization: Introduce useMemo to the grouping logic within the ActionPointGroups component to prevent unnecessary re-calculations on every render.
  3. LLM Configuration: Add ollama to our FAST_MODELS constant to ensure it's properly recognized and utilized.
  4. Security Scope: Enhance the applyEnrichment procedure's projectNote.update where clause with a tenant/project scope check. Currently, it only filters by id, which could theoretically be exploited if not careful.
  5. End-to-End Testing: A thorough E2E test suite will confirm everything works as expected: expanding a note, configuring enrichment, applying it, verifying sourceNoteId population, and checking the grouped view in the Actions tab.

This session was a significant step forward in making our note-taking application more intelligent, more organized, and ultimately, more useful. We're excited to see how users leverage these new capabilities!


json
{
  "thingsDone": [
    "Added sourceNoteId FK to ActionPoint model and back-relation to ProjectNote",
    "Extended enrichNoteWithWisdom() to accept provider, model, persona options",
    "Scoped persona query to tenant OR built-in options",
    "Updated tRPC enrich procedure to pass provider, model, persona input",
    "Updated tRPC applyEnrichment to pass sourceNoteId when creating action points",
    "Updated tRPC action-points list to include sourceNote relation",
    "Built per-note enrichment settings UI with provider buttons, model grid, PersonaPicker",
    "Implemented per-note state for enrichment settings using Map<noteId, EnrichSettings>",
    "Built ActionPointGroups component to group action points by source note",
    "Added 'Manual / Other' section for ungrouped action points",
    "Implemented flat list fallback for action points when no groups exist",
    "Ensured proper React key placement on mapped elements"
  ],
  "pains": [
    "Attempted shared enrichment settings state, leading to cross-note side effects",
    "Incorrect placement of React key prop inside helper function for mapped items"
  ],
  "successes": [
    "Refactored to per-note state with Map for enrichment settings",
    "Correctly moved React key prop to the direct call site of .map()",
    "Successfully integrated database schema changes with Prisma",
    "Implemented flexible AI enrichment configuration",
    "Created intuitive grouped view for action points"
  ],
  "techStack": [
    "Next.js",
    "React",
    "tRPC",
    "Prisma",
    "PostgreSQL",
    "TypeScript",
    "LLM APIs"
  ]
}