Skip to content

Commit 7146430

Browse files
Feature/aip 002 AI price suggestion for voyage pricing (#56)
* AIP-002 (#48) add repository queries for voyage price suggestion * AIP-002 (#48) add PriceSuggestionResponse DTO, PriceSuggestionConfidence enum, CountryRegionMapper * AIP-002 (#48) add completeWithSchema for structured AI output in OpenAI, Claude, NoOp clients * AIP-002 (#48) add price suggestion JSON schema and PriceSuggestionSchemaBuilder * AIP-002 (#48) add PriceSuggestionService and GET /voyages/{id}/price-suggestion endpoint * AIP-002 (#48) add VoyagePriceSuggestionControllerTest and PriceSuggestionSchemaTest * AIP-002 (#48) marge commit * AIP-002 (#48) fix stash conflicts * AIP-002 (#48) Fix formatting issues in FreightOrderRepository.java * AIP-002 (#48) Style fix and resolve voyage_prices FK violation * AIP-002 (#48) add unit tests for VoyagePriceRepositoryTest * AIP-002 (#48) Extracting the prompt to another file * AIP-002 (#48) Style fixing * AIP-002 (#48) fix wrong HTTP status * AIP-002 (#48) move price suggestion prompt to classpath resource file * AIP-002 (#48) change countByVoyageIds return type to List<Object[]> to avoid runtime error * AIP-002 (#48) fix format
1 parent 25bc9aa commit 7146430

20 files changed

Lines changed: 1345 additions & 18 deletions

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@
9595
<artifactId>pdfbox</artifactId>
9696
<version>3.0.5</version>
9797
</dependency>
98+
<!-- JSON Schema validation for price suggestion LLM output -->
99+
<dependency>
100+
<groupId>com.networknt</groupId>
101+
<artifactId>json-schema-validator</artifactId>
102+
<version>1.0.87</version>
103+
</dependency>
98104
</dependencies>
99105

100106
<build>

src/main/java/com/shipping/freightops/ai/AiClient.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,9 @@
22

33
public interface AiClient {
44
String complete(String systemPrompt, String userPrompt);
5+
6+
/**
7+
* Same as complete(), but enforces a JSON Schema on the output. Returns the raw JSON response.
8+
*/
9+
String completeWithSchema(String systemPrompt, String userPrompt, String jsonSchema);
510
}

src/main/java/com/shipping/freightops/ai/impl/ClaudeAiClient.java

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.shipping.freightops.ai.impl;
22

3+
import com.fasterxml.jackson.core.type.TypeReference;
4+
import com.fasterxml.jackson.databind.JsonNode;
35
import com.fasterxml.jackson.databind.ObjectMapper;
46
import com.shipping.freightops.ai.AiClient;
57
import com.shipping.freightops.ai.config.AiProperties;
@@ -15,6 +17,23 @@
1517
@Component
1618
@ConditionalOnProperty(name = "app.ai.provider", havingValue = "claude")
1719
public class ClaudeAiClient implements AiClient {
20+
21+
private static final String CLAUDE_MESSAGES_PATH = "/v1/messages";
22+
private static final String JSON_SCHEMA_TYPE = "json_schema";
23+
private static final String OUTPUT_CONFIG_KEY = "output_config";
24+
private static final String FORMAT_KEY = "format";
25+
private static final String SCHEMA_KEY = "schema";
26+
private static final String CONTENT_KEY = "content";
27+
private static final String TYPE_KEY = "type";
28+
private static final String TEXT_KEY = "text";
29+
private static final String TEXT_BLOCK_TYPE = "text";
30+
private static final String MODEL_KEY = "model";
31+
private static final String MAX_TOKENS_KEY = "max_tokens";
32+
private static final String SYSTEM_KEY = "system";
33+
private static final String MESSAGES_KEY = "messages";
34+
private static final String ROLE_KEY = "role";
35+
private static final String USER_ROLE = "user";
36+
1837
private final RestClient restClient;
1938
private final AiProperties aiProperties;
2039
private final ObjectMapper objectMapper;
@@ -36,19 +55,73 @@ public ClaudeAiClient(
3655
public String complete(String systemPrompt, String userPrompt) {
3756
Map<String, Object> request =
3857
Map.of(
39-
"model", aiProperties.getModel(),
40-
"max_tokens", aiProperties.getMaxTokens(),
41-
"system", systemPrompt,
42-
"messages", List.of(Map.of("role", "user", "content", userPrompt)));
58+
MODEL_KEY, aiProperties.getModel(),
59+
MAX_TOKENS_KEY, aiProperties.getMaxTokens(),
60+
SYSTEM_KEY, systemPrompt,
61+
MESSAGES_KEY, List.of(Map.of(ROLE_KEY, USER_ROLE, CONTENT_KEY, userPrompt)));
4362

4463
String response =
45-
restClient.post().uri("/v1/messages").body(request).retrieve().body(String.class);
64+
restClient.post().uri(CLAUDE_MESSAGES_PATH).body(request).retrieve().body(String.class);
4665

4766
logUsage(response);
4867

4968
return response;
5069
}
5170

71+
@Override
72+
public String completeWithSchema(String systemPrompt, String userPrompt, String jsonSchema) {
73+
try {
74+
Map<String, Object> outputConfig = buildOutputConfig(jsonSchema);
75+
Map<String, Object> request = buildStructuredRequest(systemPrompt, userPrompt, outputConfig);
76+
String rawResponse =
77+
restClient.post().uri(CLAUDE_MESSAGES_PATH).body(request).retrieve().body(String.class);
78+
79+
logUsage(rawResponse);
80+
81+
return extractTextFromResponse(rawResponse);
82+
} catch (Exception e) {
83+
log.warn("Failed to extract content from Claude response", e);
84+
throw new RuntimeException("Claude structured output failed", e);
85+
}
86+
}
87+
88+
private Map<String, Object> buildOutputConfig(String jsonSchema) throws Exception {
89+
Map<String, Object> schemaMap =
90+
objectMapper.readValue(jsonSchema, new TypeReference<Map<String, Object>>() {});
91+
return Map.of(FORMAT_KEY, Map.of(TYPE_KEY, JSON_SCHEMA_TYPE, SCHEMA_KEY, schemaMap));
92+
}
93+
94+
private Map<String, Object> buildStructuredRequest(
95+
String systemPrompt, String userPrompt, Map<String, Object> outputConfig) {
96+
return Map.of(
97+
MODEL_KEY, aiProperties.getModel(),
98+
MAX_TOKENS_KEY, aiProperties.getMaxTokens(),
99+
SYSTEM_KEY, systemPrompt,
100+
MESSAGES_KEY, List.of(Map.of(ROLE_KEY, USER_ROLE, CONTENT_KEY, userPrompt)),
101+
OUTPUT_CONFIG_KEY, outputConfig);
102+
}
103+
104+
private String extractTextFromResponse(String rawResponse) throws Exception {
105+
JsonNode root = objectMapper.readTree(rawResponse);
106+
JsonNode content = root.get(CONTENT_KEY);
107+
108+
if (content == null || !content.isArray() || content.isEmpty()) {
109+
return rawResponse;
110+
}
111+
112+
JsonNode firstBlock = content.get(0);
113+
if (firstBlock == null || !TEXT_BLOCK_TYPE.equals(firstBlock.path(TYPE_KEY).asText(null))) {
114+
return rawResponse;
115+
}
116+
117+
JsonNode text = firstBlock.get(TEXT_KEY);
118+
if (text != null && text.isTextual()) {
119+
return text.asText();
120+
}
121+
122+
return rawResponse;
123+
}
124+
52125
private void logUsage(String rawResponse) {
53126
try {
54127
ClaudeResponse parsed = objectMapper.readValue(rawResponse, ClaudeResponse.class);

src/main/java/com/shipping/freightops/ai/impl/NoOpAiClient.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,26 @@
88
@ConditionalOnProperty(name = "app.ai.provider", havingValue = "noop", matchIfMissing = true)
99
public class NoOpAiClient implements AiClient {
1010

11+
private static final String SCHEMA_COMPLIANT_MOCK =
12+
"""
13+
{
14+
"suggestedPriceLowUsd": 1000.00,
15+
"suggestedPriceHighUsd": 1200.00,
16+
"confidence": "MEDIUM",
17+
"reasoning": "Mock response from NoOpAiClient. No real AI analysis performed.",
18+
"dataPoints": 0
19+
}
20+
""";
21+
1122
@Override
1223
public String complete(String systemPrompt, String userPrompt) {
1324
return "{\"welcome\":\"This is a mock AI response of the Noop implementation!\", \"mock\":true, \"prompt\":\""
1425
+ userPrompt
1526
+ "\"}";
1627
}
28+
29+
@Override
30+
public String completeWithSchema(String systemPrompt, String userPrompt, String jsonSchema) {
31+
return SCHEMA_COMPLIANT_MOCK;
32+
}
1733
}

src/main/java/com/shipping/freightops/ai/impl/OpenAiClient.java

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.shipping.freightops.ai.impl;
22

3+
import com.fasterxml.jackson.core.type.TypeReference;
4+
import com.fasterxml.jackson.databind.JsonNode;
35
import com.fasterxml.jackson.databind.ObjectMapper;
46
import com.shipping.freightops.ai.AiClient;
57
import com.shipping.freightops.ai.config.AiProperties;
@@ -16,6 +18,23 @@
1618
@ConditionalOnProperty(name = "app.ai.provider", havingValue = "openai")
1719
public class OpenAiClient implements AiClient {
1820

21+
private static final String OPENAI_CHAT_PATH = "/v1/chat/completions";
22+
private static final String JSON_SCHEMA_TYPE = "json_schema";
23+
private static final String RESPONSE_FORMAT_KEY = "response_format";
24+
private static final String SCHEMA_NAME = "structured_output";
25+
private static final String SCHEMA_KEY = "schema";
26+
private static final String NAME_KEY = "name";
27+
private static final String STRICT_KEY = "strict";
28+
private static final String CHOICES_KEY = "choices";
29+
private static final String MESSAGE_KEY = "message";
30+
private static final String CONTENT_KEY = "content";
31+
private static final String MODEL_KEY = "model";
32+
private static final String MESSAGES_KEY = "messages";
33+
private static final String ROLE_KEY = "role";
34+
private static final String SYSTEM_ROLE = "system";
35+
private static final String USER_ROLE = "user";
36+
private static final String MAX_COMPLETION_TOKENS_KEY = "max_completion_tokens";
37+
1938
private final RestClient restClient;
2039
private final AiProperties aiProperties;
2140
private final ObjectMapper objectMapper;
@@ -33,21 +52,80 @@ public OpenAiClient(
3352
public String complete(String systemPrompt, String userPrompt) {
3453
Map<String, Object> request =
3554
Map.of(
36-
"model", aiProperties.getModel(),
37-
"messages",
55+
MODEL_KEY, aiProperties.getModel(),
56+
MESSAGES_KEY,
3857
List.of(
39-
Map.of("role", "system", "content", systemPrompt),
40-
Map.of("role", "user", "content", userPrompt)),
41-
"max_completion_tokens", aiProperties.getMaxTokens());
58+
Map.of(ROLE_KEY, SYSTEM_ROLE, CONTENT_KEY, systemPrompt),
59+
Map.of(ROLE_KEY, USER_ROLE, CONTENT_KEY, userPrompt)),
60+
MAX_COMPLETION_TOKENS_KEY, aiProperties.getMaxTokens());
4261

4362
String response =
44-
restClient.post().uri("/chat/completions").body(request).retrieve().body(String.class);
63+
restClient.post().uri(OPENAI_CHAT_PATH).body(request).retrieve().body(String.class);
4564

4665
logUsage(response);
4766

4867
return response;
4968
}
5069

70+
@Override
71+
public String completeWithSchema(String systemPrompt, String userPrompt, String jsonSchema) {
72+
try {
73+
Map<String, Object> responseFormat = buildResponseFormat(jsonSchema);
74+
Map<String, Object> request =
75+
buildStructuredRequest(systemPrompt, userPrompt, responseFormat);
76+
String rawResponse =
77+
restClient.post().uri(OPENAI_CHAT_PATH).body(request).retrieve().body(String.class);
78+
79+
logUsage(rawResponse);
80+
81+
return extractTextFromResponse(rawResponse);
82+
} catch (Exception e) {
83+
log.warn("Failed to extract content from OpenAI response", e);
84+
throw new RuntimeException("OpenAI structured output failed", e);
85+
}
86+
}
87+
88+
private Map<String, Object> buildResponseFormat(String jsonSchema) throws Exception {
89+
Map<String, Object> schemaMap =
90+
objectMapper.readValue(jsonSchema, new TypeReference<Map<String, Object>>() {});
91+
Map<String, Object> jsonSchemaConfig =
92+
Map.of(NAME_KEY, SCHEMA_NAME, STRICT_KEY, true, SCHEMA_KEY, schemaMap);
93+
return Map.of("type", JSON_SCHEMA_TYPE, "json_schema", jsonSchemaConfig);
94+
}
95+
96+
private Map<String, Object> buildStructuredRequest(
97+
String systemPrompt, String userPrompt, Map<String, Object> responseFormat) {
98+
return Map.of(
99+
MODEL_KEY, aiProperties.getModel(),
100+
MESSAGES_KEY,
101+
List.of(
102+
Map.of(ROLE_KEY, SYSTEM_ROLE, CONTENT_KEY, systemPrompt),
103+
Map.of(ROLE_KEY, USER_ROLE, CONTENT_KEY, userPrompt)),
104+
MAX_COMPLETION_TOKENS_KEY, aiProperties.getMaxTokens(),
105+
RESPONSE_FORMAT_KEY, responseFormat);
106+
}
107+
108+
private String extractTextFromResponse(String rawResponse) throws Exception {
109+
JsonNode root = objectMapper.readTree(rawResponse);
110+
JsonNode choices = root.get(CHOICES_KEY);
111+
112+
if (choices == null || !choices.isArray() || choices.isEmpty()) {
113+
return rawResponse;
114+
}
115+
116+
JsonNode message = choices.get(0).get(MESSAGE_KEY);
117+
if (message == null) {
118+
return rawResponse;
119+
}
120+
121+
JsonNode content = message.get(CONTENT_KEY);
122+
if (content != null && content.isTextual()) {
123+
return content.asText();
124+
}
125+
126+
return rawResponse;
127+
}
128+
51129
private void logUsage(String rawResponse) {
52130
try {
53131
OpenAiResponse parsed = objectMapper.readValue(rawResponse, OpenAiResponse.class);

src/main/java/com/shipping/freightops/controller/VoyageController.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
import com.shipping.freightops.entity.Voyage;
66
import com.shipping.freightops.entity.VoyageCost;
77
import com.shipping.freightops.entity.VoyagePrice;
8+
import com.shipping.freightops.enums.ContainerSize;
89
import com.shipping.freightops.enums.VoyageStatus;
910
import com.shipping.freightops.service.FreightOrderService;
11+
import com.shipping.freightops.service.PriceSuggestionService;
1012
import com.shipping.freightops.service.VoyageService;
1113
import io.swagger.v3.oas.annotations.Operation;
1214
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -26,10 +28,15 @@
2628
public class VoyageController {
2729
private final VoyageService voyageService;
2830
private final FreightOrderService freightOrderService;
31+
private final PriceSuggestionService priceSuggestionService;
2932

30-
public VoyageController(VoyageService voyageService, FreightOrderService freightOrderService) {
33+
public VoyageController(
34+
VoyageService voyageService,
35+
FreightOrderService freightOrderService,
36+
PriceSuggestionService priceSuggestionService) {
3137
this.voyageService = voyageService;
3238
this.freightOrderService = freightOrderService;
39+
this.priceSuggestionService = priceSuggestionService;
3340
}
3441

3542
@Operation(summary = "Get all voyages")
@@ -169,6 +176,17 @@ public ResponseEntity<FinancialSummaryResponse> getFinancialSummary(@PathVariabl
169176
return ResponseEntity.ok(voyageService.getFinancialSummary(voyageId));
170177
}
171178

179+
@Operation(summary = "Get AI-suggested price range for a voyage and container size")
180+
@ApiResponses({
181+
@ApiResponse(responseCode = "200", description = "Price suggestion retrieved"),
182+
@ApiResponse(responseCode = "404", description = "Voyage not found")
183+
})
184+
@GetMapping("/{voyageId}/price-suggestion")
185+
public ResponseEntity<PriceSuggestionResponse> getPriceSuggestion(
186+
@PathVariable Long voyageId, @RequestParam ContainerSize containerSize) {
187+
return ResponseEntity.ok(priceSuggestionService.getPriceSuggestion(voyageId, containerSize));
188+
}
189+
172190
@Operation(summary = "Get load summary for a voyage")
173191
@ApiResponses({
174192
@ApiResponse(responseCode = "200", description = "Voyage load retrieved"),

0 commit comments

Comments
 (0)