LLM Prompt Architecture (RubyLLM)
This document describes how LLM calls are structured in the application.
The single approach
All LLM access goes through RubyLLM (#499). There is no other LLM client:
BasePrompt(app/prompts/) is the standard entry point: YAML prompt files inconfig/prompts/,Schematist::Schemaclasses inapp/schemas/, value objects inapp/values/.BaseLLMService(app/services/base_llm_service.rb) is a thinRubyLLM.chatwrapper used by three older services; folding them intoBasePromptclasses is deferred (see #499).- Embeddings use
RubyLLM.embeddirectly (Sentences::EmbedService). - FakeAI (
docs/technical/fake_ai.md) fakes all three entry points in development, and Mocha stubs cover them in tests;LIVE_MODE=trueswitches either to the real APIs.
Goals
- Single-responsibility classes
- Clear separation between:
- Prompt definition + LLM concerns
- Domain orchestration and DB updates
- Local prompt files so the full context is visible in-editor (e.g. Cursor)
- Easy extension to tools, RAG, streaming, etc.
- Resilient error handling with automatic retries for transient failures
Layers
1. BasePrompt (app/prompts/base_prompt.rb)
BasePrompt centralizes all cross-cutting concerns:
- Loads prompt text from YAML (
config/prompts/...) - Interpolates variables (e.g.
,) - Builds chat messages (
systemanduser) - Selects the model (with optional override)
- Attaches a RubyLLM
Schema(optional) - Converts the response into a value object
Subclasses override small, focused methods:
prompt_file_path– where the YAML livesschema_class– Schematist::Schema subclass (optional)value_class– Value object class (optional)model_name– default model for this prompt
2. Prompt YAML files (config/prompts/...)
Each prompt lives in its own YAML file, e.g.:
config/prompts/resources/verbs/common_classifier_prompt.ymlconfig/prompts/resources/verbs/verbals_generator_prompt.ymlconfig/prompts/resources/verbs/translations_generator_prompt.ymlconfig/prompts/resources/verbs/conjugations_generator_prompt.yml
Structure:
system: |-
System message with .
user: |-
User message with .
The YAML is the canonical source of prompt text:
- Easy to edit
- Fully visible to the editor (Cursor)
- Simple to version in Git
3. Schemas (app/schemas/…)
We use Schematist::Schema (the schematist gem, formerly ruby_llm-schema) to describe the expected JSON shape from the LLM:
Resources::Verbs::CommonClassifierSchemaResources::Verbs::VerbalsSchemaResources::Verbs::TranslationsSchemaResources::Verbs::ConjugationsGeneratorSchema
These schemas are passed to RubyLLM, so the model is instructed to output well-structured JSON.
4. Value objects (app/values/…)
Each schema has a corresponding value object:
CommonClassifierValueVerbalsValueTranslationsValueConjugationsGeneratorValue
They encapsulate:
- Small accessors (common, confidence, reasons, etc.)
- Error helpers (error?, valid?)
- Normalization (to_h for verbals)
The BasePrompt converts response.content into the appropriate value object.
5. Prompt subclasses (app/prompts/resources/verbs/…)
Examples:
Resources::Verbs::CommonClassifierPromptResources::Verbs::VerbalsGeneratorPromptResources::Verbs::TranslationsGeneratorPromptResources::Verbs::ConjugationsGeneratorPrompt
Each prompt subclass:
- Points to its YAML file
- Declares its schema + value class
- Declares the model to use
They do not contain any domain logic (no DB updates, no branching for “common vs uncommon”). They only describe how to talk to the LLM for a specific task.
6. Service Classes (app/services/resources/verbs/…)
Each prompt has a corresponding service class that handles the business logic:
Resources::Verbs::CommonClassifierService– calls CommonClassifierPrompt and updates verb_lemma.commonResources::Verbs::VerbalsGeneratorService– calls VerbalsGeneratorPrompt and creates Verbal recordsResources::Verbs::TranslationsGeneratorService– calls TranslationsGeneratorPrompt and creates/updates VerbLemmaTranslation recordsResources::Verbs::ConjugationsGeneratorService– calls ConjugationsGeneratorPrompt and creates Conjugation records, enqueues audio job
Services handle:
- Validation of input (verb_lemma must be persisted, have language, etc.)
- Calling the appropriate prompt
- Persisting results to the database
- Returning result hashes with
:status(:successor:error) and relevant data
7. Jobs (app/jobs/resources/verbs/…)
Each verb validation service has a corresponding background job (queue: llm):
Resources::Verbs::ValidateCommonJobResources::Verbs::ValidateVerbalsJobResources::Verbs::ValidateTranslationsJobResources::Verbs::ValidateConjugationsJob
Each job loads the VerbLemma, calls its service, and sets the matching
*_validated flag on success. Resources::VerbsController#show enqueues
them lazily for unvalidated verbs; the admin VerbLemmas validate action
runs the services synchronously. See docs/features/verbs/verb_validator.md.
The jobs do not contain any LLM call logic or persistence logic – they only coordinate the services.
8. Streaming
In this design:
Schema-based prompts are non-streaming for simplicity and reliability.
The BasePrompt explicitly forbids streaming? == true when a schema_class is provided.
In a future iteration, streaming can be enabled for:
- Prompts with no schema
- Chat-style responses where streaming is useful (e.g. long explanations in the UI)
9. Why this design works well
Single responsibility:
- Prompt classes deal only with LLM communication.
- Service classes deal with business logic and persistence.
- Orchestrators deal only with coordination.
Testable:
- Prompts can be unit-tested by stubbing RubyLLM.
- Services can be tested by stubbing prompts.
- Orchestrators can be tested by stubbing services.
Editor-friendly:
- Prompt text lives in YAML files, visible to the IDE.
Extensible:
- New prompts are just new subclasses + YAML files + schemas/values.
- New services follow the same pattern.
- Tools and RAG can be added by extending BasePrompt.