nyxcore-systems
7 min read

From Fixed AI to Flexible Wisdom: Enhancing Note Enrichment and Organization

Dive into our latest development sprint where we empowered users with granular control over AI note enrichment and brought much-needed clarity to action points by grouping them by their source notes. Learn about the technical challenges and solutions along the way!

TypeScriptNext.jstRPCPrismaReactLLMAIFull-StackSoftware DevelopmentLessons LearnedState Management

In the fast-evolving landscape of productivity tools, leveraging AI effectively isn't just about having an AI; it's about having the right AI, configured exactly how you need it. Our recent development sprint focused on delivering precisely that: more control over AI-powered note enrichment and a significant improvement in how we organize the insights generated.

We set out to achieve two primary goals:

  1. Empower users with a provider/model/persona chooser for our note enrichment feature, moving beyond a one-size-fits-all approach.
  2. Group generated action points by their source note within the project detail page, providing much-needed context and clarity.

This journey involved a full-stack effort, touching database schema, backend services, API design, and complex frontend state management. Let's break down how we tackled it, including the valuable lessons learned along the way.

Empowering AI Enrichment: Choice and Control

Previously, our note enrichment was a "black box" operation, using a default LLM provider and model. While functional, it lacked the flexibility power users demand. We wanted to allow users to select their preferred AI provider (e.g., OpenAI, Anthropic, Ollama), specific models within those providers, and even custom personas to guide the AI's output.

The Backend Engine for Flexibility

At the heart of this change was extending our core enrichment function: enrichNoteWithWisdom(). This function, residing in src/server/services/note-enrichment.ts, now gracefully accepts providerName, modelOverride, and personaId. This simple yet powerful change unlocked a world of customization.

typescript
// src/server/services/note-enrichment.ts (conceptual snippet)
async function enrichNoteWithWisdom(
  noteContent: string,
  options: {
    providerName?: LLMProviderName;
    modelOverride?: string;
    personaId?: string;
  }
): Promise<EnrichmentResult> {
  // ... logic to dynamically select provider, model, and apply persona
}

Integrating this with our tRPC API was crucial. The enrich procedure in src/server/trpc/routers/projects.ts was updated to accept these new parameters. Crucially, we implemented robust input validation using z.enum(LLM_PROVIDERS) for the provider name. This wasn't just about type safety; it was a critical security measure to prevent arbitrary string inputs from potentially exploiting or misconfiguring our LLM integrations.

We also added a guard for missing FAST_MODELS entries. If a user tries to select a "fast" model for a provider that doesn't have one configured (e.g., a local Ollama setup might not expose a specific model alias we expect), the system now throws a clear, actionable error instead of silently failing or using an unintended default.

Finally, a subtle but important security fix involved scoping persona queries. Personas are either built-in or created by a tenant. We ensured that a user could only select personas relevant to their tenant or the globally available built-ins, preventing cross-tenant data leakage or misconfiguration.

The Frontend Experience: Per-Note State Management

Building the UI for these settings in the NotesTab presented an interesting frontend challenge. Initially, we considered a shared state for enrichment settings across all notes.

Lesson Learned: The Pitfalls of Global State for Local Context Our code review process quickly highlighted a critical flaw: if the settings were shared globally, changing the enrichment provider for one note would inadvertently change it for all other notes being viewed. This would lead to a frustrating and confusing user experience.

The solution was to implement per-note state. We opted for a Map<noteId, EnrichSettings> to store individual enrichment configurations for each note a user is interacting with. This ensures that settings are isolated and persist correctly as users switch between notes. Helper functions like getEnrichSettings() and patchEnrichSettings() were created to manage this Map effectively within our React component, ensuring a smooth and intuitive user experience.

Clarity in Action: Grouping Action Points by Source

Our second major feature aimed to solve a common problem: action points generated from note enrichment often felt disconnected from their origin. A list of action points, no matter how well-formatted, loses significant value without immediate context. Grouping them by the note they originated from was the obvious solution.

Database Foundations: The sourceNoteId

The first step was a fundamental change to our database schema. We added a sourceNoteId foreign key to the ActionPoint model in prisma/schema.prisma. This nullable UUID field links each action point back to its ProjectNote.

prisma
// prisma/schema.prisma (conceptual snippet)
model ActionPoint {
  id          String    @id @default(uuid())
  // ... other fields
  sourceNoteId String?   @map("source_note_id")
  sourceNote  ProjectNote? @relation("ActionPointsOnProjectNote", fields: [sourceNoteId], references: [id])
}

model ProjectNote {
  id          String    @id @default(uuid())
  // ... other fields
  actionPoints ActionPoint[] @relation("ActionPointsOnProjectNote")
}

This simple addition enabled the relational data we needed for grouping. The actionPoints ActionPoint[] back-relation on ProjectNote completed the picture, allowing us to easily query all action points associated with a specific note.

Backend Support and Security

With the database schema updated, we needed to ensure our backend could leverage this new relationship. The list procedure in src/server/trpc/routers/action-points.ts was updated to include the sourceNote relation, meaning when action points are fetched, their originating note's details can be retrieved in the same query.

A critical security enhancement was also added to the applyEnrichment procedure. Before updating any ActionPoint records, we now perform an explicit ownership check.

Lesson Learned: Prisma update Limitations and Robust Ownership Checks Initially, we attempted to use Prisma's update method with a compound where clause (e.g., where: { id, tenantId, projectId }). However, Prisma's update (and delete) operations primarily target records by @id or @@unique fields in the where clause for direct updates. While it can handle more complex filters, a direct compound where that also includes tenant and project IDs for a non-unique field isn't its primary design for updating a specific record.

Our workaround, and a more robust security pattern, was to first perform a findFirst query to explicitly verify that the ActionPoint belongs to the current tenant and project. Only if this ownership check passes successfully do we proceed with the update operation. This adds an explicit layer of security, preventing unauthorized modifications even if an id were somehow guessed or tampered with.

typescript
// src/server/trpc/routers/projects.ts (conceptual snippet for applyEnrichment)
const existingActionPoint = await ctx.prisma.actionPoint.findFirst({
  where: {
    id: input.actionPointId,
    project: {
      id: input.projectId,
      tenantId: ctx.session.user.tenantId,
    },
  },
});

if (!existingActionPoint) {
  throw new TRPCError({ code: 'NOT_FOUND', message: 'Action point not found or unauthorized.' });
}

// Proceed with update now that ownership is confirmed
await ctx.prisma.actionPoint.update({
  where: { id: input.actionPointId },
  data: { /* ... */ },
});

Frontend Presentation: The ActionPointGroups Component

Finally, on the frontend, we built the ActionPointGroups component. This component takes the list of action points, processes them, and groups them by their sourceNote. It features collapsible headers for each note group, allowing users to quickly navigate and focus on relevant action points. To ensure performance, especially with potentially many action points, we memoized the grouping logic, preventing unnecessary re-calculations on re-renders.

A small but crucial detail was fixing React key placement on several .map() call sites. Correct key usage is fundamental for React's reconciliation algorithm, preventing rendering glitches and performance issues in dynamic lists.

Reflections and Next Steps

This sprint was a significant step forward in making our AI features more powerful, flexible, and user-friendly, while also drastically improving the organization of generated insights. We moved from a generic AI experience to one that puts choice and context directly into the user's hands.

Our immediate next steps involve:

  • End-to-End Verification: Thoroughly testing the entire flow from configuring enrichment settings, generating action points, to seeing them correctly grouped in the Actions tab.
  • Refactoring Considerations: As the NotesTab grows, we'll keep an eye on extracting the NoteEnrichmentPanel into a standalone component to maintain code readability and modularity.

The journey of building robust, user-centric features is always filled with interesting technical challenges. By embracing lessons learned, focusing on security, and prioritizing user experience, we continue to evolve our platform to be more intelligent and intuitive.


json