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.
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.
// 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.
// 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/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.
// 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:
- Input Validation: Strengthen
providerinput validation in theenrichprocedure usingz.enum(LLM_PROVIDERS)instead of a free-formz.string(). This prevents unexpected values and improves type safety. - Performance Optimization: Introduce
useMemoto the grouping logic within theActionPointGroupscomponent to prevent unnecessary re-calculations on every render. - LLM Configuration: Add
ollamato ourFAST_MODELSconstant to ensure it's properly recognized and utilized. - Security Scope: Enhance the
applyEnrichmentprocedure'sprojectNote.updatewhere clause with a tenant/project scope check. Currently, it only filters byid, which could theoretically be exploited if not careful. - End-to-End Testing: A thorough E2E test suite will confirm everything works as expected: expanding a note, configuring enrichment, applying it, verifying
sourceNoteIdpopulation, 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!
{
"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"
]
}