Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/ISSUES-PHASE2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1500,14 +1500,14 @@ suggestion. The LLM synthesizes news headlines into risk factors that may affect

```java
public interface NewsProvider {
List<NewsItem> getRecentHeadlines(String route, int maxResults);
List<MaritimeNewsArticle> getRecentHeadlines(String route, int maxResults);
}
```

**`NewsItem` DTO:**
**`MaritimeNewsArticle` DTO:**

```java
public class NewsItem {
public class MaritimeNewsArticle {
private String headline;
private String source;
private LocalDate publishedDate;
Expand Down
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@
<artifactId>json-schema-validator</artifactId>
<version>1.0.87</version>
</dependency>
<!-- RSS feed parsing -->
<dependency>
<groupId>com.rometools</groupId>
<artifactId>rome</artifactId>
<version>2.1.0</version>
</dependency>
</dependencies>

<build>
Expand Down
57 changes: 57 additions & 0 deletions src/main/java/com/shipping/freightops/dto/MaritimeNewsArticle.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.shipping.freightops.dto;

import java.time.LocalDate;

/**
* Represents a maritime news article used for shipping risk analysis and freight pricing decisions.
* Contains headline, source, publication date, and summary information relevant to maritime
* operations.
*/
public class MaritimeNewsArticle {
private String headline;
private String source;
private LocalDate publishedDate;
private String summary;

public MaritimeNewsArticle() {}

public MaritimeNewsArticle(
String headline, String source, LocalDate publishedDate, String summary) {
this.headline = headline;
this.source = source;
this.publishedDate = publishedDate;
this.summary = summary;
}

public String getHeadline() {
return headline;
}

public void setHeadline(String headline) {
this.headline = headline;
}

public String getSource() {
return source;
}

public void setSource(String source) {
this.source = source;
}

public LocalDate getPublishedDate() {
return publishedDate;
}

public void setPublishedDate(LocalDate publishedDate) {
this.publishedDate = publishedDate;
}

public String getSummary() {
return summary;
}

public void setSummary(String summary) {
this.summary = summary;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import com.shipping.freightops.enums.ContainerSize;
import com.shipping.freightops.enums.PriceSuggestionConfidence;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public class PriceSuggestionResponse {
private String voyageNumber;
Expand All @@ -17,6 +19,7 @@ public class PriceSuggestionResponse {
private BigDecimal historicalAvgUsd;
private BigDecimal historicalMinUsd;
private BigDecimal historicalMaxUsd;
private List<RiskFactor> riskFactors;

public String getVoyageNumber() {
return voyageNumber;
Expand Down Expand Up @@ -111,6 +114,14 @@ public void setHistoricalMaxUsd(BigDecimal historicalMaxUsd) {
this.historicalMaxUsd = historicalMaxUsd;
}

public List<RiskFactor> getRiskFactors() {
return riskFactors;
}

public void setRiskFactors(List<RiskFactor> riskFactors) {
this.riskFactors = riskFactors;
}

/** Creates a fallback response for no-data or parse-failure scenarios. */
public static PriceSuggestionResponse fallback(
String voyageNumber, String route, ContainerSize containerSize, String reasoning) {
Expand All @@ -126,6 +137,7 @@ public static PriceSuggestionResponse fallback(
response.setHistoricalAvgUsd(null);
response.setHistoricalMinUsd(null);
response.setHistoricalMaxUsd(null);
response.setRiskFactors(new ArrayList<>());
return response;
}
}
41 changes: 41 additions & 0 deletions src/main/java/com/shipping/freightops/dto/RiskFactor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.shipping.freightops.dto;

import com.shipping.freightops.enums.RiskImpact;

public class RiskFactor {
private String factor;
private RiskImpact impact;
private String description;

public RiskFactor() {}

public RiskFactor(String factor, RiskImpact impact, String description) {
this.factor = factor;
this.impact = impact;
this.description = description;
}

public String getFactor() {
return factor;
}

public void setFactor(String factor) {
this.factor = factor;
}

public RiskImpact getImpact() {
return impact;
}

public void setImpact(RiskImpact impact) {
this.impact = impact;
}

public String getDescription() {
return description;
}

public void setDescription(String description) {
this.description = description;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public LocalDateTime getArrivalDate() {
return arrivalTime;
}

public void setArrivalDate(LocalDateTime departureTime) {
public void setArrivalDate(LocalDateTime arrivalTime) {
this.arrivalTime = arrivalTime;
}

Expand Down
8 changes: 8 additions & 0 deletions src/main/java/com/shipping/freightops/enums/RiskImpact.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.shipping.freightops.enums;

/** Represents the potential impact level of a risk factor on shipping pricing. */
public enum RiskImpact {
HIGH,
MEDIUM,
LOW
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.shipping.freightops.news;

import com.shipping.freightops.dto.MaritimeNewsArticle;
import java.util.List;

public interface MaritimeNewsSource {

List<MaritimeNewsArticle> getRecentHeadlines(String route, int maxResults);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.shipping.freightops.news;

import com.shipping.freightops.dto.MaritimeNewsArticle;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;

@Service
public class ShippingNewsAnalyzer {

private static final String ROUTE_SPLIT_REGEX = "[→\\-\\s]+";

private static final Set<String> SHIPPING_KEYWORDS =
Set.of(
"port",
"shipping",
"container",
"vessel",
"freight",
"cargo",
"maritime",
"ocean",
"terminal",
"logistics",
"trade",
"export",
"import",
"suez",
"panama",
"canal",
"strait",
"route",
"disruption");

private final MaritimeNewsSource maritimeNewsSource;

public ShippingNewsAnalyzer(MaritimeNewsSource maritimeNewsSource) {
this.maritimeNewsSource = maritimeNewsSource;
}

public List<MaritimeNewsArticle> getRelevantHeadlines(String route, int maxResults) {
List<MaritimeNewsArticle> allHeadlines =
maritimeNewsSource.getRecentHeadlines(route, maxResults * 2);

if (allHeadlines.isEmpty()) {
return List.of();
}

Set<String> routeKeywords = extractRouteKeywords(route);

return allHeadlines.stream()
.filter(item -> isRelevantToRoute(item, routeKeywords))
.limit(maxResults)
.collect(Collectors.toList());
}

private Set<String> extractRouteKeywords(String route) {
return Arrays.stream(route.split(ROUTE_SPLIT_REGEX))
.map(String::trim)
.map(String::toLowerCase)
.filter(keyword -> !keyword.isEmpty())
.collect(Collectors.toSet());
}

private boolean isRelevantToRoute(MaritimeNewsArticle item, Set<String> routeKeywords) {
String content = (item.getHeadline() + " " + item.getSummary()).toLowerCase();

boolean matchesRouteKeywords =
routeKeywords.stream().anyMatch(keyword -> content.contains(keyword));
boolean matchesShippingKeywords = containsShippingKeywords(content);

return matchesRouteKeywords || matchesShippingKeywords;
}

private boolean containsShippingKeywords(String content) {
return SHIPPING_KEYWORDS.stream().anyMatch(content::contains);
}
}
23 changes: 23 additions & 0 deletions src/main/java/com/shipping/freightops/news/config/NewsConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.shipping.freightops.news.config;

import java.net.http.HttpClient;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;

@Configuration
public class NewsConfig {

@Bean
public RestClient.Builder newsRestClientBuilder(NewsProperties properties) {
var requestFactory =
new JdkClientHttpRequestFactory(
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(properties.getConnectTimeout()))
.build());

return RestClient.builder().requestFactory(requestFactory);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.shipping.freightops.news.config;

import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "app.news")
public class NewsProperties {

private String provider = "static";
private List<String> feeds = List.of();
private int maxHeadlines = 5;
private int connectTimeout = 10;
private int readTimeout = 30;

public String getProvider() {
return provider;
}

public void setProvider(String provider) {
this.provider = provider;
}

public List<String> getFeeds() {
return feeds;
}

public void setFeeds(List<String> feeds) {
this.feeds = feeds;
}

public int getMaxHeadlines() {
return maxHeadlines;
}

public void setMaxHeadlines(int maxHeadlines) {
this.maxHeadlines = maxHeadlines;
}

public int getConnectTimeout() {
return connectTimeout;
}

public void setConnectTimeout(int connectTimeout) {
this.connectTimeout = connectTimeout;
}

public int getReadTimeout() {
return readTimeout;
}

public void setReadTimeout(int readTimeout) {
this.readTimeout = readTimeout;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.shipping.freightops.news.impl;

import com.shipping.freightops.dto.MaritimeNewsArticle;
import com.shipping.freightops.news.MaritimeNewsSource;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

@Component
@ConditionalOnProperty(name = "app.news.provider", havingValue = "noop")
public class NoOpMaritimeNewsSource implements MaritimeNewsSource {

@Override
public List<MaritimeNewsArticle> getRecentHeadlines(String route, int maxResults) {
return List.of();
}
}
Loading
Loading