-
Notifications
You must be signed in to change notification settings - Fork 0
feat: import Duala and Bassa vocabulary via NTeALan API #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| } | ||
| } | ||
|
|
||
| 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 | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Add connect and read timeouts to A plain ⏱️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| } | ||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 sqlRepository: 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.javaRepository: 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 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 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) { | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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:
Repository: ASSONDJI/afrilingua
Length of output: 650
Propagate fetch failures instead of returning an empty page.
NTeALanImportServicebreaks pagination onentries.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