krishna@
7 min read#ai#backend

The AI knows which screen you asked from

A 640-line orchestrator with a branch per screen became six resolvers behind a registry. Adding a surface went from seven files to one file and one line.

share
SR

640 lines and one if-chain

Adding the family surface to Joishi's assistant took a day and a half. Not because family context is hard — it's a list of linked people and their charts — but because the function that assembled context for the model was 640 lines with a branch per screen, and every branch had grown its own opinions.

The shape of the problem: when you ask Joishi a question, it matters enormously which screen you asked from. On the panchanga screen you're asking about today. On a chart you're asking about a birth chart. On milan you're asking about two people at once. Same model, same user, wildly different context. Feeding all of it every time is expensive and makes the answers worse — the model hedges when you give it six topics.

So there's a notion of surface, and the assistant resolves context per surface. Fine idea. The implementation was the problem.

before

src/ai/
├── ai.controller.ts
├── ai.module.ts
├── ai-orchestrator.service.ts      # 640 lines
├── dto/
   ├── ask.dto.ts
   └── surface.enum.ts
├── prompts/
   └── prompt-builder.service.ts   # 410 lines
└── token-budgets.constant.ts

AiOrchestrator injected nine services, because the union of what all five surfaces needed was nine services. The chart branch needed the ephemeris and the dasha calculator. The family branch needed the relationships repository. The milan branch needed both charts and the compatibility scorer. Everything got injected into one class, and every surface paid the construction cost of every other surface's dependencies.

Then there was the accounting. Adding family meant touching seven files: the orchestrator, the surface enum, the prompt builder's preamble switch, token-budgets.constant.ts, the cache-key builder (because family context has a different cacheable prefix), the orchestrator's test file, and the surface-to-feature-flag map. Miss one and you get a surface that half works. I missed the token budget on the first pass and family answers got silently truncated in staging for two days before anyone noticed the model had stopped mentioning anyone's mother.

the port

The fix is unglamorous. One interface, six implementations, one registry.

export interface SurfaceContextResolver {
  readonly surface: Surface;
  /** Hard ceiling for this surface's rendered blocks. Owned here, nowhere else. */
  readonly tokenBudget: number;
  readonly systemInstruction: string;

  resolve(input: SurfaceContextInput): Promise<ResolvedContext>;
}

export type ResolvedContext = {
  /** Ordered highest-priority first. The budget trimmer cuts from the tail. */
  blocks: ContextBlock[];
  cacheable: boolean;
};

ContextBlock is a labelled chunk of rendered text with a priority. SurfaceContextInput carries the subject, the frozen analysis instant, and the locale.

The three things that used to live in three different files — what data this surface needs, how much of it is allowed, and what the model is told about the screen — now live on one object. That's the whole refactor. The rest is plumbing.

after

src/ai/
├── ai.controller.ts
├── ai.module.ts
├── ai-orchestrator.service.ts      # 71 lines
├── context/
   ├── surface-context.port.ts
   ├── surface-context.registry.ts
   ├── budget.ts
   └── resolvers/
       ├── panchanga.resolver.ts
       ├── chart.resolver.ts
       ├── family.resolver.ts
       ├── milan.resolver.ts
       ├── gochar.resolver.ts
       └── general.resolver.ts
├── dto/
   ├── ask.dto.ts
   └── surface.enum.ts
└── prompts/
    └── prompt-builder.service.ts   # 96 lines

The orchestrator no longer knows any surface exists:

@Injectable()
export class AiOrchestrator {
  constructor(
    private readonly registry: SurfaceContextRegistry,
    private readonly cache: ContextCacheService,
    private readonly model: GeminiClient,
  ) {}

  async ask(input: AskInput): Promise<AiStream> {
    const resolver = this.registry.get(input.surface);

    const resolved = await resolver.resolve({
      subjectId: input.subjectId,
      partnerId: input.partnerId,
      instant: input.instant,
      locale: input.locale,
    });

    const blocks = fitToBudget(resolved.blocks, resolver.tokenBudget, input.surface);
    const handle = resolved.cacheable ? await this.cache.acquire(input.userId, blocks) : null;

    return this.model.stream({
      systemInstruction: resolver.systemInstruction,
      cachedContent: handle?.name,
      contents: handle
        ? [userTurn(input.question)]
        : [...renderBlocks(blocks), userTurn(input.question)],
    });
  }
}

Nine injected services became three. Each resolver injects only what it needs, and NestJS constructs them lazily per module.

why a registry and not a switch

The obvious counter-argument is that a switch over a union type is exhaustively checked by the compiler, so a missing case is a build error, not a runtime surprise. That's true, and it's a real point. It isn't the reason.

The reason is dependencies. A switch statement lives inside a class, and that class has one constructor. If the milan branch needs the compatibility scorer, the class needs the compatibility scorer, and now every request to every surface drags that graph along. You cannot express "this branch has these dependencies" in a switch. You can express it trivially in a class per branch.

The second reason is people. Six of us work on this service. A central switch is a file every surface change has to edit, which means every surface change is a potential merge conflict on the same forty lines. A registry entry is one line, and the interesting code is in a file that didn't exist before.

The registry itself refuses to boot if a surface has no resolver:

@Injectable()
export class SurfaceContextRegistry implements OnModuleInit {
  private readonly bySurface = new Map<Surface, SurfaceContextResolver>();

  constructor(
    @Inject(SURFACE_CONTEXT_RESOLVER)
    private readonly resolvers: SurfaceContextResolver[],
  ) {}

  onModuleInit(): void {
    for (const resolver of this.resolvers) {
      if (this.bySurface.has(resolver.surface)) {
        throw new Error(`duplicate context resolver for surface "${resolver.surface}"`);
      }
      this.bySurface.set(resolver.surface, resolver);
    }

    const missing = ALL_SURFACES.filter((s) => !this.bySurface.has(s));
    if (missing.length > 0) {
      throw new Error(`no context resolver registered for: ${missing.join(', ')}`);
    }
  }

  get(surface: Surface): SurfaceContextResolver {
    const resolver = this.bySurface.get(surface);
    if (!resolver) throw new InternalServerErrorException(`unregistered surface: ${surface}`);
    return resolver;
  }
}

A pod that can't serve every surface doesn't join the pool. Same instinct as env validation: fail at boot, loudly, where it's cheap.

keeping resolvers honest

Owning your own token budget only works if something checks. fitToBudget trims from the tail of the block list and increments ai_context_trim_total{surface} when it does, so a resolver that habitually overruns shows up in a graph rather than in a bad answer.

And every registered resolver runs against a fixture subject in CI:

describe.each(ALL_SURFACES)('%s resolver', (surface) => {
  it('fits its own budget for the fixture subject', async () => {
    const resolver = registry.get(surface);
    const resolved = await resolver.resolve(FIXTURE_INPUT);
    const tokens = await countTokens(renderBlocks(resolved.blocks));

    expect(tokens).toBeLessThanOrEqual(resolver.tokenBudget);
  });
});

The fixture subject has a full chart, four linked family members, and a partner, so it's close to the worst case. This test has failed on me twice, both times because I added a block and forgot the budget was a number I'd chosen months earlier for a smaller block set. Which is exactly the failure the old design shipped to staging silently.

where it leaks

SurfaceContextInput has an optional partnerId. Five of the six resolvers ignore it. Only milan reads it, because compatibility needs two charts, and the port is otherwise built around the idea of one subject.

I tried to fix this. The port became generic over its input type, the registry became a Map<Surface, SurfaceContextResolver<any>> because the map can't be heterogeneous without a lookup table of surface-to-input types, and the code review comment I got was "I can't tell what this does anymore." Fair. I reverted it the same afternoon.

So there's a field on a shared interface that means nothing to most implementations. It's the honest cost of one abstraction covering six things that aren't quite the same shape. I don't have a good answer for it. If a second two-subject surface ever appears, I'll probably split the port in two rather than keep generalising.

There's a softer leak too. Resolvers declare cacheable, but the cache key is built by the orchestrator from the rendered blocks. A resolver that reads mutable data it doesn't put in a block can produce a stale cache hit. That's enforced by a rule in a review checklist, not by a type. Rules lose to types eventually.

what it cost

Four days, including the generic detour I threw away.

The number that made it worth it: gochar was the sixth surface, added after the refactor. One resolver file, one registry line, one budget constant on that same file. The orchestrator diff was zero lines. It took an afternoon.

Family, the fifth surface, took a day and a half and touched seven files. Same team, same complexity of data, six weeks apart.


by Krishna Adhikari · Jun 28, 2026
share
// related.transmissions

Keep reading.