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
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package cm.afrilingua.content.client;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import java.util.ArrayList;
import java.util.List;

/**
* Client for the NTeALan collaborative dictionary API
* (https://apis.ntealan.net), used to import Duala and Bassa vocabulary
* per cahier des charges §5.1.
*
* Uses manual JsonNode traversal rather than strict POJO/Jackson binding
* because the API's repeatable fields (translations.equivalent,
* examples.example) inconsistently serialize as either a JSON array or a
* bare object depending on cardinality -- an XML-to-JSON conversion
* artifact confirmed by inspecting real API responses. A strict
* List<Equivalent> binding would throw a JSON mapping exception on every
* entry that happens to have exactly one translation.
*/
@Slf4j
@Component
public class NTeALanClient {

private static final String BASE_URL = "https://apis.ntealan.net/ntealan/dictionaries/articles";

private final RestTemplate restTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();

public NTeALanClient(@Qualifier("externalRestTemplate") RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}

public List<NTeALanWordEntry> fetchArticles(String dictionaryId, int page, int limit) {
String url = String.format("%s/%s?limit=%d&page=%d&sort=ASC", BASE_URL, dictionaryId, limit, page);

String rawResponse;
try {
rawResponse = restTemplate.getForObject(url, String.class);
} catch (Exception ex) {
log.error("Failed to fetch NTeALan dictionary {} page {}", dictionaryId, page, ex);
return List.of();
}

if (rawResponse == null || rawResponse.isBlank()) {
return List.of();
}

try {
JsonNode root = objectMapper.readTree(rawResponse);
JsonNode articles = root.path("articles");

List<NTeALanWordEntry> entries = new ArrayList<>();
for (JsonNode articleWrapper : articles) {
NTeALanWordEntry entry = parseEntry(articleWrapper);
if (entry != null) {
entries.add(entry);
}
}
return entries;
} catch (Exception ex) {
log.error("Failed to parse NTeALan response for dictionary {} page {}", dictionaryId, page, ex);
return List.of();
}
}
Comment on lines +39 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify how NTeALanImportService uses the empty list returned by fetchArticles —
# specifically whether it treats empty as "stop pagination" with no retry on error.
rg -n --type=java -C5 'fetchArticles' services/content-service/src/main/java/cm/afrilingua/content/service/NTeALanImportService.java 2>/dev/null || echo "Import service not found at expected path"
fd NTeALanImportService.java services/content-service/src/main/java

Repository: ASSONDJI/afrilingua

Length of output: 650


Propagate fetch failures instead of returning an empty page. NTeALanImportService breaks pagination on entries.isEmpty(), so swallowing fetch exceptions here turns a transient network error into a silent, truncated import. Let the exception bubble up or return a result that distinguishes “no more data” from “request failed.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@services/content-service/src/main/java/cm/afrilingua/content/client/NTeALanClient.java`
around lines 39 - 70, The fetchArticles method in NTeALanClient is swallowing
request/parse failures by returning List.of(), which makes NTeALanImportService
treat transient errors as end-of-pagination. Update fetchArticles to let
exceptions propagate (or return a failure-aware result) instead of converting
them to an empty list, and keep the empty-list behavior only for the genuine “no
articles” case after a successful response.


private NTeALanWordEntry parseEntry(JsonNode articleWrapper) {
String radical = articleWrapper.path("radical").asText(null);
if (radical == null || radical.isBlank()) {
return null;
}

JsonNode articleBody = articleWrapper.path("article").path("article");
String grammaticalCategory = articleBody.path("type").asText(null);

String translation = extractFirstEquivalent(articleBody.path("translations").path("equivalent"));
if (translation == null || translation.isBlank()) {
return null;
}

return new NTeALanWordEntry(radical.trim(), translation.trim(), grammaticalCategory);
}

/**
* Handles the array-or-bare-object inconsistency: a single translation
* serializes as a bare object instead of a one-element array.
*/
private String extractFirstEquivalent(JsonNode equivalentNode) {
if (equivalentNode.isArray() && !equivalentNode.isEmpty()) {
return equivalentNode.get(0).path("content").asText(null);
}
if (equivalentNode.isObject()) {
return equivalentNode.path("content").asText(null);
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package cm.afrilingua.content.client;

public record NTeALanWordEntry(String word, String translation, String grammaticalCategory) {
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
package cm.afrilingua.content.client;

import cm.afrilingua.content.entity.Word;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;

@Slf4j
@Component
@RequiredArgsConstructor
public class RecommendationClient {

private static final String APPLY_RULE_URL =
"http://RECOMMENDATION-SERVICE/api/recommendations/difficulty/apply-rule";

private final RestTemplate restTemplate;

public RecommendationClient(@Qualifier("loadBalancedRestTemplate") RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}

/**
* Calls recommendation-service to apply the deterministic tone-based
* difficulty rule. Best-effort: any failure (service down, timeout,
Expand Down Expand Up @@ -55,4 +58,4 @@ private record ApplyRuleRequest(String ton1, String ton2) {

private record ApplyRuleResponse(String niveau) {
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,18 @@
@Configuration
public class RestTemplateConfig {

// @LoadBalanced lets this RestTemplate resolve "http://RECOMMENDATION-SERVICE/..."
// via Eureka, the same way Spring Cloud Gateway resolves "lb://RECOMMENDATION-SERVICE".
// Resolves service names (e.g. "RECOMMENDATION-SERVICE") via Eureka.
// Only for calls to other services registered in this project's discovery server.
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
public RestTemplate loadBalancedRestTemplate() {
return new RestTemplate();
}
}

// Plain RestTemplate for real external domains (e.g. apis.ntealan.net),
// which must never go through Eureka's service resolution.
@Bean
public RestTemplate externalRestTemplate() {
return new RestTemplate();
}
Comment on lines +22 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add connect and read timeouts to externalRestTemplate.

A plain new RestTemplate() uses SimpleClientHttpRequestFactory with no connect or read timeout, meaning calls to the NTeALan API can block indefinitely if the external service is slow or unresponsive. Since this bean is specifically for external domains outside your control, this is a real availability risk for the import endpoint.

⏱️ Proposed fix
 `@Bean`
 public RestTemplate externalRestTemplate() {
-    return new RestTemplate();
+    var factory = new SimpleClientHttpRequestFactory();
+    factory.setConnectTimeout(5_000);
+    factory.setReadTimeout(15_000);
+    return new RestTemplate(factory);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public RestTemplate externalRestTemplate() {
return new RestTemplate();
}
public RestTemplate externalRestTemplate() {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5_000);
factory.setReadTimeout(15_000);
return new RestTemplate(factory);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@services/content-service/src/main/java/cm/afrilingua/content/config/RestTemplateConfig.java`
around lines 22 - 24, The externalRestTemplate() bean currently creates a plain
RestTemplate with no network safeguards, which can let NTeALan calls hang
indefinitely; update RestTemplateConfig.externalRestTemplate to configure
connect and read timeouts on the underlying request factory before returning the
bean. Keep the fix localized to externalRestTemplate and ensure the timeouts are
appropriate for calls to external services.

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import cm.afrilingua.content.api.LanguagesApi;
import cm.afrilingua.content.dto.CreateLanguageRequest;
import cm.afrilingua.content.dto.ImportResult;
import cm.afrilingua.content.dto.Language;
import cm.afrilingua.content.service.LanguageService;
import cm.afrilingua.content.service.NTeALanImportService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand All @@ -19,6 +21,7 @@
public class LanguageController implements LanguagesApi {

private final LanguageService languageService;
private final NTeALanImportService nteALanImportService;

@Override
public ResponseEntity<Language> createLanguage(CreateLanguageRequest createLanguageRequest) {
Expand All @@ -35,4 +38,13 @@ public ResponseEntity<Language> getLanguage(UUID languageId) {
public ResponseEntity<List<Language>> listLanguages() {
return ResponseEntity.ok(languageService.listAll());
}

@Override
public ResponseEntity<ImportResult> importFromNtealan(UUID languageId, String dictionaryId) {
NTeALanImportService.ImportResult result = nteALanImportService.importDictionary(languageId, dictionaryId);
return ResponseEntity.ok(new ImportResult()
.imported(result.imported())
.skipped(result.skipped())
.failed(result.failed()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@

public interface WordRepository extends JpaRepository<Word, UUID> {
List<Word> findByLanguageId(UUID languageId);
boolean existsByLanguageIdAndWordIgnoreCase(UUID languageId, String word);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package cm.afrilingua.content.service;

import cm.afrilingua.content.client.NTeALanClient;
import cm.afrilingua.content.client.NTeALanWordEntry;
import cm.afrilingua.content.dto.CreateWordRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class NTeALanImportService {

private static final int PAGE_SIZE = 100;
// Safety net: stops the loop even if the API misbehaves and never
// returns an empty page (e.g. due to an unexpected upstream bug).
private static final int MAX_PAGES = 50;

private final NTeALanClient nteALanClient;
private final WordService wordService;
private final LanguageService languageService;

/**
* Imports every article from a NTeALan dictionary into the given
* language, paginating through the full dictionary. Idempotent: words
* already present for this language (case-insensitive) are skipped
* rather than duplicated, so this method is safe to re-run.
*/
public ImportResult importDictionary(UUID languageId, String dictionaryId) {
// Fail fast with a 404 before starting the batch, rather than
// discovering a bad languageId only after the first word attempt.
languageService.getEntityById(languageId);

int imported = 0;
int skipped = 0;
int failed = 0;

for (int page = 1; page <= MAX_PAGES; page++) {
List<NTeALanWordEntry> entries = nteALanClient.fetchArticles(dictionaryId, page, PAGE_SIZE);
if (entries.isEmpty()) {
break;
}
Comment on lines +42 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Log a warning when the MAX_PAGES safety limit is reached.

If a dictionary has more than 5000 entries (50 × 100), the loop silently stops. The completion log at line 69 would show what looks like a successful full import, masking unimported entries.

🛡️ Proposed fix
     for (int page = 1; page <= MAX_PAGES; page++) {
         List<NTeALanWordEntry> entries = nteALanClient.fetchArticles(dictionaryId, page, PAGE_SIZE);
         if (entries.isEmpty()) {
             break;
         }

+        if (page == MAX_PAGES) {
+            log.warn("Reached MAX_PAGES ({}) for dictionary {} -- there may be unimported entries remaining", MAX_PAGES, dictionaryId);
+        }
+
         for (NTeALanWordEntry entry : entries) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (int page = 1; page <= MAX_PAGES; page++) {
List<NTeALanWordEntry> entries = nteALanClient.fetchArticles(dictionaryId, page, PAGE_SIZE);
if (entries.isEmpty()) {
break;
}
for (int page = 1; page <= MAX_PAGES; page++) {
List<NTeALanWordEntry> entries = nteALanClient.fetchArticles(dictionaryId, page, PAGE_SIZE);
if (entries.isEmpty()) {
break;
}
if (page == MAX_PAGES) {
log.warn("Reached MAX_PAGES ({}) for dictionary {} -- there may be unimported entries remaining", MAX_PAGES, dictionaryId);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@services/content-service/src/main/java/cm/afrilingua/content/service/NTeALanImportService.java`
around lines 42 - 46, The import loop in NTeALanImportService#importDictionary
silently exits when the MAX_PAGES safety limit is hit, which can make a partial
import look complete. Update the logic in importDictionary to detect when the
loop ends because page reached MAX_PAGES rather than entries.isEmpty(), and emit
a warning through the existing logger in that case. Keep the current completion
log, but make sure it clearly distinguishes a full import from a truncated one
so large dictionaries are not misreported as fully imported.


for (NTeALanWordEntry entry : entries) {
try {
if (wordService.wordExists(languageId, entry.word())) {
skipped++;
continue;
}

CreateWordRequest request = new CreateWordRequest()
.word(entry.word())
.translation(entry.translation())
.grammaticalCategory(entry.grammaticalCategory());

wordService.create(languageId, request);
imported++;
} catch (Exception ex) {
log.warn("Failed to import NTeALan word '{}' for dictionary {}", entry.word(), dictionaryId, ex);
failed++;
}
}
Comment on lines +48 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for unique constraints on the Word entity
rg -n -C3 'uniqueConstraints|UniqueConstraint|`@Table`' --type java \
  -g '**/Word.java' \
  services/content-service/src/main/java/cm/afrilingua/content/entity/

# Also check for Flyway/Liquibase migration files referencing the word table
fd -e sql . services/content-service/src/main/resources/ | head -20
rg -n -i 'unique.*word|word.*unique' services/content-service/src/main/resources/ --type sql

Repository: ASSONDJI/afrilingua

Length of output: 1003


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Word entity ==\n'
cat -n services/content-service/src/main/java/cm/afrilingua/content/entity/Word.java

printf '\n== V1 migration ==\n'
cat -n services/content-service/src/main/resources/db/migration/V1__create_languages_and_words_tables.sql

printf '\n== V2 migration ==\n'
cat -n services/content-service/src/main/resources/db/migration/V2__add_tone_fields_to_words.sql

printf '\n== Search for any word-table unique indexes/constraints ==\n'
rg -n -i 'unique|index|constraint' services/content-service/src/main/resources/db/migration/ services/content-service/src/main/java/cm/afrilingua/content/entity/Word.java

Repository: ASSONDJI/afrilingua

Length of output: 3971


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== WordService ==\n'
cat -n services/content-service/src/main/java/cm/afrilingua/content/service/WordService.java

printf '\n== Word repository matches ==\n'
rg -n -C3 'wordExists|IgnoreCase|findBy.*Word|existsBy.*Word|lower\\(|LOWER\\(' services/content-service/src/main/java/cm/afrilingua/content/

printf '\n== Related repository files ==\n'
fd -e java . services/content-service/src/main/java/cm/afrilingua/content/ | rg 'Repository|Service|Word'

Repository: ASSONDJI/afrilingua

Length of output: 5675


Add a database unique constraint for words
services/content-service/src/main/java/cm/afrilingua/content/service/WordService.java checks existence case-insensitively, but words has no unique constraint in services/content-service/src/main/resources/db/migration/V1__create_languages_and_words_tables.sql. A concurrent import or create can still slip in a duplicate between the check and save(). Add a unique constraint/index on (language_id, lower(word)) and treat that violation as a skip.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@services/content-service/src/main/java/cm/afrilingua/content/service/NTeALanImportService.java`
around lines 48 - 66, Add a database-level uniqueness guard for words because
WordService’s existence check can race with concurrent writes. Update the words
schema in the migration to enforce uniqueness per language using a
case-insensitive key on word, then adjust WordService.create and the
NTeALanImportService import flow to catch that duplicate-key path and count it
as skipped instead of failed, using the existing WordService and
NTeALanImportService symbols to locate the change.

}

log.info("NTeALan import complete for dictionary {}: {} imported, {} skipped, {} failed",
dictionaryId, imported, skipped, failed);

return new ImportResult(imported, skipped, failed);
}

public record ImportResult(int imported, int skipped, int failed) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public cm.afrilingua.content.dto.Word create(UUID languageId, CreateWordRequest
/**
* Classification priority:
* 1. An explicit difficultyLevel in the request always wins (manual override).
* 2. If nbSyllabes/tone1/tone2 are all provided, ask recommendation-service.
* 2. If tone1/tone2 are both provided, ask recommendation-service.
* 3. Otherwise, default to BEGINNER — unchanged from the original behavior,
* for languages/words without tonal annotation (e.g. Duala, Bassa).
*/
Expand Down Expand Up @@ -83,6 +83,11 @@ public List<cm.afrilingua.content.dto.Word> listByLanguage(UUID languageId) {
.collect(Collectors.toList());
}

/** Used by NTeALanImportService to skip words already imported, keeping re-runs idempotent. */
public boolean wordExists(UUID languageId, String word) {
return wordRepository.existsByLanguageIdAndWordIgnoreCase(languageId, word);
}

private cm.afrilingua.content.dto.Word toDto(Word word) {
return new cm.afrilingua.content.dto.Word()
.id(word.getId())
Expand All @@ -101,4 +106,4 @@ private cm.afrilingua.content.dto.Word toDto(Word word) {
? cm.afrilingua.content.dto.Word.Tone2Enum.fromValue(word.getTone2())
: null);
}
}
}
Loading
Loading