Generators

Info

Architectural truth for Generators in the Documents feature. This leaf module acts as the API-side bridge for the asynchronous document generation pipeline, managing data gathering and persistence via CQRS messaging.

Overview

The Documents/Generators module does not execute LLM interactions or long-running computations. Instead, it owns the API side of the generation messaging contracts (coordinated via Wolverine over RabbitMQ) and the handlers that interact with milton-db (via EF Core) and S3 (via the Claim-Check pattern).

The actual computation runs in the MILTON.DocumentGenerator worker service.

Business Logic Intent

Document generation is separated into two logical halves:

  1. API (State & Storage): Owns the SQL database. It gathers context for generation (files, project presets), packages it into a claim-check, and later receives the result to save back to the database.
  2. Worker (Pure Functions): Receives a claim-check key, executes LLM prompts or Python API calls, and writes the output back to a claim-check. It never touches milton-db.

The Generators folder holds the API half of this contract, ensuring that the worker service operates statelessly.

The Claim-Check Pattern

Message brokers (RabbitMQ) struggle with large payloads (source files, generated documents). Therefore, this feature heavily uses the Claim-Check Pattern:

  1. The API gathers large context data (e.g., source code, LLM presets).
  2. It uploads this to an IClaimCheckStore (S3) and gets a string key.
  3. It sends a lightweight message (e.g., TestCaseGenerationRequested) over RabbitMQ containing just the Guid SagaId, Guid BlockId, and the ClaimCheckKey.
  4. The worker reads the S3 object, processes it, writes the result to S3, and replies with a TestCaseGenerationCompleted containing a new ResultClaimCheckKey.

Handlers & Flow

For every block type (e.g., Requirement, Scanner, TestCase), there are two handlers in this module:

  • Prepare[Type]GenerationHandler: Triggered by a command. Gathers database context, creates the claim-check payload, and fires the [Type]GenerationRequested message.
  • Apply[Type]GenerationHandler: Triggered when the worker finishes. Reads the result claim-check, updates milton-db with the new block content, and fires a BlockProcessedEvent.

The Agent (Deep Reasoning) Slice

Alongside the per-type single-turn slices there is one type-agnostic slice for blocks that need multi-step reasoning (issue #76). It follows exactly the same Prepare/Apply shape:

  • PrepareAgentSessionHandler: resolves the block’s scope (tenant, project, repository name), derives the natural-language goal from the block’s type and its place in the document, gathers the decrypted LLM presets, and claim-checks an AgentSessionPayload. Unlike the single-turn producers it deliberately does not pre-build a source-context blob — the agent fetches what it needs through tools across multiple turns, so it receives the repo scope instead. The turn budget comes from AiAgent:MaxTurns (default 10, clamped to 50).
  • ApplyAgentSessionHandler: reads the AgentSessionResult, writes Content to the block, marks it Completed, notifies clients, and reports a BlockProcessedEvent. Every failure path returns a failed BlockProcessedEvent rather than throwing, so one stuck block cannot stall the document.

Routing is decided at instantiation time, not by the saga: InstantiateDocumentHandler reads the deepReasoning flag from each block’s PropertiesJson (see BlockGenerationMode) and ships the result as ProcessableBlockRef.Mode ("Agent" or null). The saga is DB-free and cannot look this up itself.

Sequence: The Generation Round-Trip

sequenceDiagram
    participant API as API (milton-db)
    participant S3 as S3 (Claim-Check)
    participant Bus as Wolverine
    participant Worker as DocGen Worker

    note over API: Prepare Phase
    API->>API: Load Block & Presets from DB
    API->>S3: PutAsync(Payload)
    S3-->>API: returns InputKey
    API->>Bus: Publish(TypeGenerationRequested, InputKey)
    
    note over Worker: Execution Phase
    Bus->>Worker: Consume(TypeGenerationRequested)
    Worker->>S3: GetAsync(InputKey)
    Worker->>Worker: Run LLM / Processor
    Worker->>S3: PutAsync(Result)
    S3-->>Worker: returns ResultKey
    Worker->>Bus: Publish(TypeGenerationCompleted, ResultKey)
    
    note over API: Apply Phase
    Bus->>API: Consume(TypeGenerationCompleted)
    API->>S3: GetAsync(ResultKey)
    API->>API: Update milton-db (EF Core)
    API->>Bus: Publish(BlockProcessedEvent)

Messaging Contracts

This module defines API-side mirrors of the message contracts. To enable cross-service routing without a shared assembly, the [MessageIdentity("...")] attributes strictly match the worker’s attributes.

  • Commands (Triggered locally): PrepareRequirementGenerationCommand, PrepareScannerGenerationCommand, PrepareTestCaseGenerationCommand, PrepareAgentSessionCommand
  • Outgoing Events (Sent to Worker): RequirementGenerationRequested, ScannerGenerationRequested, TestCaseGenerationRequested, AgentSessionRequested
  • Incoming Events (Received from Worker): RequirementGenerationCompleted, ScannerGenerationCompleted, TestCaseGenerationCompleted, AgentSessionCompletedEvent
  • Orchestration Events: BlockProcessedEvent, DocumentCreatedEvent, GeneratePdfCommand

RabbitMQ queues the API listens on for this pipeline: requirement-prep / requirement-results, scanner-prep / scanner-results, testcase-prep / testcase-results, agent-prep / agent-results. Everything the API sends back into the pipeline goes to document-processing.

Testing the Round-Trip

The two-host round-trip integration test (MILTON.Tests/RequirementRoundTripTests.cs) starts the API and the DocGen worker as separate in-process Wolverine hosts over a real RabbitMQ broker. Both hosts must register every dependency the code-generated handlers take as parameters — including optional ones. Wolverine’s code generator resolves all handler parameters from the container regardless of whether they are optional, so a handler like PrepareRequirementGenerationHandler (which accepts IVaultSecretProvider? vaultProvider = null) fails at codegen time with UnResolvableVariableException if IVaultSecretProvider is not registered.

Mirror production (API Program.cs and DocGen Program.cs both call AddVaultSecretProvider(...)) by registering the in-memory mock on both hosts:

svc.AddSingleton<IVaultSecretProvider>(new MockVaultSecretProvider());

The Vault is only consulted when a preset actually contains an API key, so the mock is a safe no-op for most test data. Because the Apply handlers now also invalidate the HybridCache, the API-side round-trip host must register it too (L1-only, mirroring the API without a Redis connection string):

svc.AddHybridCache();

Caching Invalidation

ApplyRequirementGenerationHandler, ApplyScannerGenerationHandler, and ApplyTestCaseGenerationHandler remove the doc:{tenantId}:{block.Section.DocumentId} and project-docs:{tenantId}:{block.Section.Document.ProjectId} HybridCache tags after their SaveChangesAsync — generated content is part of the cached DocumentResponse and the project-documents summary. Tenant resolution follows the Prepare handlers’ pattern (tenantService.GetCurrentTenant() inside the message handler; there is no HTTP context). InstantiateDocumentHandler similarly evicts the new document’s tag, config:{tenantId}:{projectId} (materialization depends on the project’s repo config), and project-docs:{tenantId}:{projectId}.

Presets Caching (safe — raw encrypted payload only)

GenerationGather.LoadDecryptedPresetsAsync is called on every block-prepare and previously read ProjectConfigs.LlmPresetsJson from the DB each time. When the Prepare handlers pass a HybridCache + ITenantService, only the RAW LlmPresetsJson string is cached under presets:{tenantId}:{projectId} (5-minute TTL), tagged with config:{tenantId}:{projectId} so the existing UpdateProjectConfigHandler invalidation already evicts it (no new invalidation point). A missing/blank payload is cached as an empty sentinel (never a null value); the caller still receives null.

HARD RULE: deserialization + decryption + Vault substitution ALWAYS run after the cache read, on the cached raw string, exactly as before — a decrypted API key or a decrypted LlmPresetConfig object never enters the cache. Without a cache/tenant (e.g. direct unit calls) the method behaves byte-identically to the original uncached path. The Prepare handlers pass the cache through as optional parameters (Wolverine resolves them from DI when registered, as with IVaultSecretProvider).

0 items under this folder.