📝 Generation
Purpose: This guide provides step-by-step instructions for implementing the Text Generation capability.
Dependencies:
- Requires Foundation to be completed first (Phase 1 infrastructure)
- Recommended: Rewriting completed first (anchor primitive)
- References Text Operations Solution Architecture for design context
Note: Text Generation is an extension of rewriting. It should not ship alone — it ships as an extension of the rewriting anchor primitive.
Related Documents:
- Text Operations Epic - Capability definition
Note: This guide is specific to Text Operations (TextOps) implementation. Once Text Operations is fully implemented and validated, this entire text-intelligence/ folder will be archived.
Overview
What This Guide Covers
Text Generation provides the capability to generate original text content from prompts. This is an extension of Text Rewriting (the anchor primitive). Generation should not ship alone — it ships as an extension of rewriting.
What's Included:
- Service interface and implementation
- Creative/balanced ChatModel beans
- DTOs (request/response)
- Controller endpoints
- Basic validation and error handling
What's NOT Included:
- ❌ Text rewriting (see Rewriting)
- ❌ Usage limits and quotas (see Usage Tracking)
- ❌ Product-specific UI or presets (fork products add these)
Prerequisites
- Phase 1 Foundation completed (ChatModel bean configured, Ollama running)
- Understanding of Spring Boot service patterns
- Familiarity with LangChain4j ChatModel API
- Recommended: Text Rewriting implementation completed (anchor primitive)
Implementation Steps
Step 1: Extend AiConstants
File: server/src/main/java/com/saas/springular/common/ai/constants/AiConstants.java
Add constants:
public final class AiConstants {
// ... existing constants ...
// Text Operations constants
public static final double CREATIVE_TEMPERATURE = 0.9;
public static final double BALANCED_TEMPERATURE = 0.7;
public static final double FACTUAL_TEMPERATURE = 0.3;
public static final int MAX_PROMPT_LENGTH = 4000;
}
Step 2: Extend AIConfiguration (Add Creative ChatModel)
File: server/src/main/java/com/saas/springular/common/ai/config/AIConfiguration.java
Add beans:
@Configuration
@RequiredArgsConstructor
public class AIConfiguration {
// ... existing chatModel bean (default, used as balanced) ...
@Bean
@Qualifier("creative")
public ChatLanguageModel creativeChatModel() {
return OllamaChatModel.builder()
.baseUrl(baseUrl)
.modelName(chatModelName)
.timeout(Duration.ofMillis(timeoutMs))
.temperature(AiConstants.CREATIVE_TEMPERATURE)
.build();
}
}
Step 3: Create DTOs
File: server/src/main/java/com/saas/springular/common/ai/model/TextGenerationRequest.java
Key Points:
- ✅ Use Jakarta Validation annotations
- ✅ Reference constants for size limits
- ✅ Provide clear validation messages
Create request DTO:
package com.saas.springular.common.ai.model;
import com.saas.springular.common.ai.constants.AiConstants;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record TextGenerationRequest(
@NotBlank(message = "Prompt is required")
@Size(max = AiConstants.MAX_PROMPT_LENGTH, message = "Prompt must not exceed 4000 characters")
String prompt,
@Size(max = 50, message = "Tone must not exceed 50 characters")
String tone // Optional: "creative" | "balanced" | "factual", defaults to "balanced"
) {}
Validation Notes:
@NotBlank: Rejects null, empty, or whitespace-only strings (automatically trims)@Size: Enforces maximum length (4000 characters for prompt, 50 for tone)- Validation errors are automatically handled by Spring's
@ControllerAdvice(existingExceptionResponseHandler) - Response includes field-level error messages in standard format
File: server/src/main/java/com/saas/springular/common/ai/model/TextGenerationResponse.java
Create response DTO:
package com.saas.springular.common.ai.model;
public record TextGenerationResponse(
String text,
String model,
String tone
) {}
Step 4: Create Service Interface
File: server/src/main/java/com/saas/springular/common/ai/service/TextIntelligenceService.java
Create interface:
package com.saas.springular.common.ai.service;
public interface TextIntelligenceService {
/**
* Generate text from a prompt.
*
* @param prompt The input prompt
* @param tone Optional tone: "creative", "balanced", or "factual". Defaults to "balanced"
* @return Generated text
*/
String generateText(String prompt, String tone);
}
Step 5: Implement Service
File: server/src/main/java/com/saas/springular/common/ai/service/impl/TextIntelligenceServiceImpl.java
Key Points:
- ✅ Handle provider-specific exceptions (if available)
- ✅ Add structured logging
- ✅ Log error categories (not prompt content)
- ✅ Throw
RuntimeException(integrates with existing@ControllerAdvice) - ✅ Stateless implementation (no mutable instance variables)
Create implementation:
package com.saas.springular.common.ai.service.impl;
import com.saas.springular.common.ai.service.TextIntelligenceService;
import dev.langchain4j.model.chat.ChatLanguageModel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class TextIntelligenceServiceImpl implements TextIntelligenceService {
private final ChatLanguageModel chatModel; // Default (balanced)
@Qualifier("creative")
private final ChatLanguageModel creativeChatModel;
@Override
public String generateText(String prompt, String tone) {
long startTime = System.currentTimeMillis();
ChatLanguageModel model = selectModel(tone);
String modelName = getModelName(model);
try {
String result = model.chat(prompt);
long latency = System.currentTimeMillis() - startTime;
log.info("Text generation completed: operation=text-generation, model={}, promptLength={}, latency={}ms, success=true",
modelName, prompt.length(), latency);
return result;
} catch (Exception e) {
long latency = System.currentTimeMillis() - startTime;
String errorCategory = categorizeError(e);
log.error("Text generation failed: operation=text-generation, model={}, promptLength={}, latency={}ms, success=false, errorCategory={}",
modelName, prompt.length(), latency, errorCategory, e);
throw new RuntimeException("Text generation failed", e);
}
}
private ChatLanguageModel selectModel(String tone) {
if (tone == null || tone.isEmpty() || "balanced".equalsIgnoreCase(tone)) {
return chatModel;
} else if ("creative".equalsIgnoreCase(tone)) {
return creativeChatModel;
} else {
return chatModel; // Default to balanced for unknown tones
}
}
private String getModelName(ChatLanguageModel model) {
// Extract model name from configuration (simplified for example)
// In real implementation, could extract from bean name or config
return "ollama-llama2:7b";
}
private String categorizeError(Exception e) {
// Categorize error for structured logging
String exceptionName = e.getClass().getSimpleName();
if (exceptionName.contains("Timeout")) {
return "timeout";
} else if (exceptionName.contains("Invocation")) {
return "provider-failure";
} else {
return "unknown";
}
}
}
Error Handling Notes:
- Catches all exceptions and categorizes them for structured logging
- Logs error category, latency, and operation details (not prompt content at INFO level)
- Never logs prompt content at INFO level (may contain PII or sensitive data)
- Throws
RuntimeExceptionwhich is automatically handled by existingExceptionResponseHandler - Error categories: "timeout", "provider-failure", "unknown"
Structured Logging:
- Required fields:
operation,model,promptLength,latency,success,errorCategory - Format: key=value pairs for easy parsing and filtering
- Prompt content excluded from INFO logs (only length logged)
Step 6: Add Controller Endpoint
File: server/src/main/java/com/saas/springular/common/ai/controller/AIController.java
Add endpoint:
package com.saas.springular.common.ai.controller;
import com.saas.springular.common.ai.model.TextGenerationRequest;
import com.saas.springular.common.ai.model.TextGenerationResponse;
import com.saas.springular.common.ai.service.TextIntelligenceService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/ai/text")
@RequiredArgsConstructor
public class AIController {
private final TextIntelligenceService textIntelligenceService;
@PostMapping("/generate")
public ResponseEntity<TextGenerationResponse> generateText(
@Valid @RequestBody TextGenerationRequest request) {
String generatedText = textIntelligenceService.generateText(
request.prompt(),
request.tone()
);
return ResponseEntity.ok(new TextGenerationResponse(
generatedText,
"ollama", // Model identifier
request.tone() != null ? request.tone() : "balanced"
));
}
}
Production Readiness
Input Validation
- ✅ DTOs use Jakarta Validation annotations (
@NotBlank,@Size) - ✅ Size limits enforced (
MAX_PROMPT_LENGTH= 4000 characters) - ✅ Required fields validated automatically
- ✅ Validation errors handled by existing
@ControllerAdvice(ExceptionResponseHandler) - ✅ Error responses follow standard format
Error Handling
- ✅ Provider errors caught and categorized (timeout, provider-failure, unknown)
- ✅ User-friendly error messages returned
- ✅ Errors logged with structured format
- ✅ Errors automatically handled by existing
ExceptionResponseHandler
Logging
- ✅ Structured logging with required fields:
operation: Operation type (e.g., "text-generation")model: Model identifierpromptLength: Length of input promptlatency: Request duration in millisecondssuccess: Boolean (true/false)errorCategory: Error category if failed
- ✅ Prompt content NOT logged at INFO level (only length logged)
- ✅ Error categories logged for debugging
Token Tracking (Preparation)
Token tracking hooks are prepared but not fully implemented (see Usage Tracking guide):
// In service implementation (future)
// Token tracking will be added in Usage Tracking guide
// Interface preparation: record usage when tokens are available from provider
Full token tracking implementation is in the Usage Tracking guide.
Testing
Unit Tests
File: server/src/test/java/com/saas/springular/common/ai/service/impl/TextIntelligenceServiceImplTest.java
Create test:
package com.saas.springular.common.ai.service.impl;
import com.saas.springular.common.ai.service.TextIntelligenceService;
import dev.langchain4j.model.chat.ChatLanguageModel;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TextIntelligenceServiceImplTest {
@Mock
private ChatLanguageModel chatModel;
@Mock
private ChatLanguageModel creativeChatModel;
@InjectMocks
private TextIntelligenceServiceImpl service;
@Test
void generatesTextWithBalancedTone() {
// arrange
String prompt = "Write a short greeting";
String expectedResponse = "Hello!";
when(chatModel.chat(prompt)).thenReturn(expectedResponse);
// act
String result = service.generateText(prompt, "balanced");
// assert
assertThat(result).isEqualTo(expectedResponse);
}
@Test
void generatesTextWithCreativeTone() {
// arrange
String prompt = "Write a creative greeting";
String expectedResponse = "Greetings, fellow traveler!";
when(creativeChatModel.chat(prompt)).thenReturn(expectedResponse);
// act
String result = service.generateText(prompt, "creative");
// assert
assertThat(result).isEqualTo(expectedResponse);
}
@Test
void defaultsToBalancedWhenToneIsNull() {
// arrange
String prompt = "Write a greeting";
String expectedResponse = "Hello";
when(chatModel.chat(prompt)).thenReturn(expectedResponse);
// act
String result = service.generateText(prompt, null);
// assert
assertThat(result).isEqualTo(expectedResponse);
}
@Test
void handlesExceptionGracefully() {
// arrange
String prompt = "Test prompt";
when(chatModel.chat(anyString())).thenThrow(new RuntimeException("Provider error"));
// act & assert
assertThatThrownBy(() -> service.generateText(prompt, "balanced"))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("Text generation failed");
}
}
Integration Tests
File: server/src/test/java/com/saas/springular/common/ai/controller/AIControllerTest.java
Create integration test:
package com.saas.springular.common.ai.controller;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
class AIControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
@Disabled("Requires Ollama running")
void generatesText() throws Exception {
// arrange
String requestBody = """
{
"prompt": "Say hello in one word",
"tone": "balanced"
}
""";
// act & assert
mockMvc.perform(post("/api/ai/text/generate")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.text").exists())
.andExpect(jsonPath("$.model").value("ollama"))
.andExpect(jsonPath("$.tone").value("balanced"));
}
@Test
void rejectsInvalidRequest() throws Exception {
// arrange - missing required prompt field
String requestBody = """
{
"tone": "balanced"
}
""";
// act & assert
mockMvc.perform(post("/api/ai/text/generate")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isBadRequest());
}
@Test
void rejectsPromptExceedingMaxLength() throws Exception {
// arrange - prompt exceeding MAX_PROMPT_LENGTH
String longPrompt = "a".repeat(4001);
String requestBody = String.format("""
{
"prompt": "%s",
"tone": "balanced"
}
""", longPrompt);
// act & assert
mockMvc.perform(post("/api/ai/text/generate")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isBadRequest());
}
}
Integration Test Notes:
- Tests end-to-end flow (HTTP → Controller → Service → Model)
- Validation error tests verify Jakarta Validation works
- Real provider test is disabled by default (requires Ollama)
- Use
@Disabledfor tests requiring external services
Validation
Manual Testing
- Start Ollama: Ensure Ollama service is running (
docker-compose up ollama) - Download Model: If needed, pull model:
ollama pull llama2:7b - Start Application: Start Spring Boot application
- Test Endpoint: Use curl or Postman:
curl -X POST http://localhost:8080/api/ai/text/generate \
-H "Content-Type: application/json" \
-d '{
"prompt": "Write a short greeting",
"tone": "creative"
}'
Expected Response:
{
"text": "Greetings, fellow traveler!",
"model": "ollama",
"tone": "creative"
}
Time Estimate
Total Time: 2-3 hours
Breakdown:
- Constants extension: 10 minutes
- Configuration extension: 20 minutes
- DTOs: 20 minutes
- Service interface: 15 minutes
- Service implementation: 30 minutes
- Controller: 20 minutes
- Unit tests: 30 minutes
- Integration tests: 30 minutes
- Manual testing: 20 minutes
Next Steps
After Text Generation is complete:
- Usage Tracking: See Usage Tracking
Note: If you haven't implemented Text Rewriting yet, consider implementing it first as it is the anchor primitive.
Troubleshooting
Issue: ChatModel bean not found for qualifier
Solution: Verify @Qualifier annotations match bean definitions in AIConfiguration.
Issue: Text generation returns empty or error
Solution: Check Ollama is running, model is downloaded, and logs for detailed error messages.
Issue: Validation errors on request
Solution: Verify request DTO validation annotations and request body format.