diff --git a/backend/src/main/java/com/aiteacher/concept/ConceptReportController.java b/backend/src/main/java/com/aiteacher/concept/ConceptReportController.java index 83aa0cb..4b9599d 100644 --- a/backend/src/main/java/com/aiteacher/concept/ConceptReportController.java +++ b/backend/src/main/java/com/aiteacher/concept/ConceptReportController.java @@ -25,10 +25,12 @@ public class ConceptReportController { } @PostMapping - public ResponseEntity generate(@PathVariable String id) { + public ResponseEntity generate( + @PathVariable String id, + @RequestParam(defaultValue = "en") String language) { Topic topic = topicRepository.findById(id) .orElseThrow(() -> new NoSuchElementException("Topic not found.")); - return ResponseEntity.ok(conceptReportService.generateReport(topic)); + return ResponseEntity.ok(conceptReportService.generateReport(topic, language)); } @GetMapping diff --git a/backend/src/main/java/com/aiteacher/concept/ConceptReportService.java b/backend/src/main/java/com/aiteacher/concept/ConceptReportService.java index 16ba3f6..c0b3a24 100644 --- a/backend/src/main/java/com/aiteacher/concept/ConceptReportService.java +++ b/backend/src/main/java/com/aiteacher/concept/ConceptReportService.java @@ -60,7 +60,7 @@ public class ConceptReportService { this.objectMapper = objectMapper; } - public ConceptReportResponse generateReport(Topic topic) { + public ConceptReportResponse generateReport(Topic topic, String language) { List readyBooks = bookRepository.findAll().stream() .filter(b -> b.getStatus() == BookStatus.READY) .toList(); @@ -106,7 +106,7 @@ public class ConceptReportService { if (mf == null || mf.isEmpty()) continue; if (facet == ConceptFacet.OTHER) continue; // skip OTHER bucket in the rendered report - String prompt = buildFacetPrompt(topic, facet, mf, sectionLabel, figureLabel); + String prompt = buildFacetPrompt(topic, facet, mf, sectionLabel, figureLabel, language); String markdown = chatClient.prompt() .system(SYSTEM_PROMPT) .user(prompt) @@ -151,7 +151,8 @@ public class ConceptReportService { private String buildFacetPrompt(Topic topic, ConceptFacet facet, MergedFacet mf, Map sectionLabel, - Map figureLabel) { + Map figureLabel, + String language) { StringBuilder sb = new StringBuilder(); sb.append("CONCEPT: ").append(topic.getName()).append("\n"); sb.append("FACET: ").append(facet.displayTitle()).append("\n\n"); @@ -181,6 +182,17 @@ public class ConceptReportService { sb.append("Write the ").append(facet.displayTitle()).append(" section of a concept report on \"") .append(topic.getName()) .append("\". Stay strictly within this facet. Use the [S#]/[F#] labels above for citations."); + + if ("th".equalsIgnoreCase(language)) { + sb.append("\n\nIMPORTANT: Write the narrative in Thai. ") + .append("Keep all medical, anatomical, surgical, pharmacological, and clinical ") + .append("terminology in English (e.g., cerebellopontine angle, glioblastoma, craniotomy, ") + .append("dexamethasone). Do NOT translate disease names, anatomical structures, drug names, ") + .append("procedures, eponyms, or imaging modalities. Translate only connective prose, ") + .append("explanations, and general descriptions. Citation labels [S#]/[F#] stay unchanged. ") + .append("The sentinel string for insufficient context must remain exactly: ") + .append("\"The uploaded books do not contain sufficient information on this aspect.\""); + } return sb.toString(); } diff --git a/backend/src/main/java/com/aiteacher/topic/TopicController.java b/backend/src/main/java/com/aiteacher/topic/TopicController.java index 7d93672..10ddc14 100644 --- a/backend/src/main/java/com/aiteacher/topic/TopicController.java +++ b/backend/src/main/java/com/aiteacher/topic/TopicController.java @@ -26,11 +26,13 @@ public class TopicController { } @PostMapping("/{id}/summary") - public ResponseEntity generateSummary(@PathVariable String id) { + public ResponseEntity generateSummary( + @PathVariable String id, + @RequestParam(defaultValue = "en") String language) { Topic topic = topicRepository.findById(id) .orElseThrow(() -> new NoSuchElementException("Topic not found.")); - TopicSummaryResponse response = topicSummaryService.generateSummary(topic); + TopicSummaryResponse response = topicSummaryService.generateSummary(topic, language); return ResponseEntity.ok(response); } diff --git a/backend/src/main/java/com/aiteacher/topic/TopicSummaryService.java b/backend/src/main/java/com/aiteacher/topic/TopicSummaryService.java index 3b3ca37..37a0fee 100644 --- a/backend/src/main/java/com/aiteacher/topic/TopicSummaryService.java +++ b/backend/src/main/java/com/aiteacher/topic/TopicSummaryService.java @@ -59,7 +59,7 @@ public class TopicSummaryService { this.objectMapper = objectMapper; } - public TopicSummaryResponse generateSummary(Topic topic) { + public TopicSummaryResponse generateSummary(Topic topic, String language) { List readyBooks = bookRepository.findAll().stream() .filter(b -> b.getStatus() == BookStatus.READY) .toList(); @@ -82,7 +82,7 @@ public class TopicSummaryService { log.debug("Topic reports for '{}': {} sections, {} figures retrieved", topic.getName(), allSections.size(), allFigures.size()); - String contextPrompt = buildContextPrompt(question, allSections, allFigures); + String contextPrompt = buildContextPrompt(question, allSections, allFigures, language); String summary = chatClient.prompt() .system(SYSTEM_PROMPT) .user(contextPrompt) @@ -142,7 +142,8 @@ public class TopicSummaryService { private String buildContextPrompt(String question, List sections, - List figures) { + List figures, + String language) { StringBuilder sb = new StringBuilder(); if (!sections.isEmpty()) { @@ -169,6 +170,17 @@ public class TopicSummaryService { } sb.append("QUESTION:\n").append(question); + + if ("th".equalsIgnoreCase(language)) { + sb.append("\n\nIMPORTANT: Write the narrative in Thai. ") + .append("Keep all medical, anatomical, surgical, pharmacological, and clinical ") + .append("terminology in English (e.g., cerebellopontine angle, glioblastoma, craniotomy, ") + .append("dexamethasone). Do NOT translate disease names, anatomical structures, drug names, ") + .append("procedures, eponyms, or imaging modalities. Translate only connective prose, ") + .append("explanations, and general descriptions. Citation labels [S#]/[F#] stay unchanged. ") + .append("The sentinel string for insufficient context must remain exactly: ") + .append("\"The uploaded books do not contain sufficient information on this topic.\""); + } return sb.toString(); } diff --git a/frontend/src/components/BookCard.vue b/frontend/src/components/BookCard.vue index 879aefa..3586105 100644 --- a/frontend/src/components/BookCard.vue +++ b/frontend/src/components/BookCard.vue @@ -48,7 +48,7 @@ Read +
+
+ + +
+ +
@@ -71,10 +89,28 @@
Saved concept reports - +
+
+ + +
+ +
@@ -269,6 +305,8 @@ const showSources = ref(true) const summaryError = ref(null) const conceptError = ref(null) const isNoBooks = ref(false) +const conceptLanguage = ref<'en' | 'th'>('en') +const summaryLanguage = ref<'en' | 'th'>('en') const sourcesSection = ref(null) const selectedTopicId = ref(null) const mode = ref<'summary' | 'concept'>('summary') @@ -401,7 +439,7 @@ async function handleGenerateConcept(topicId: string) { conceptError.value = null isNoBooks.value = false showSources.value = true - const result = await topicStore.generateConceptReport(topicId) + const result = await topicStore.generateConceptReport(topicId, conceptLanguage.value) if (!result) { conceptError.value = topicStore.error ?? 'Failed to generate concept report.' isNoBooks.value = @@ -425,7 +463,7 @@ async function handleGenerate(topicId: string) { isNoBooks.value = false showSources.value = true - const result = await topicStore.generateSummary(topicId) + const result = await topicStore.generateSummary(topicId, summaryLanguage.value) if (!result) { summaryError.value = topicStore.error ?? 'Failed to generate summary.' isNoBooks.value = @@ -528,6 +566,52 @@ function formatDateShort(iso: string): string { margin-bottom: 0.75rem; } +.history-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.lang-toggle { + display: inline-flex; + border: 1px solid var(--border-color, #d0d7de); + border-radius: 6px; + overflow: hidden; +} + +.lang-toggle-btn { + padding: 0.25rem 0.55rem; + font-size: 0.75rem; + font-weight: 600; + background: transparent; + color: var(--text-secondary, #57606a); + border: none; + cursor: pointer; + line-height: 1; +} + +.lang-toggle-btn:not(:last-child) { + border-right: 1px solid var(--border-color, #d0d7de); +} + +.lang-toggle-btn:hover:not(:disabled) { + background: var(--hover-bg, #f3f4f6); +} + +.lang-toggle-btn--active { + background: var(--primary-color, #0969da); + color: #fff; +} + +.lang-toggle-btn--active:hover:not(:disabled) { + background: var(--primary-color, #0969da); +} + +.lang-toggle-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .history-title { font-size: 0.875rem; font-weight: 600;