Add thai support in summary

This commit is contained in:
Adrien
2026-04-18 19:55:19 +02:00
parent c7a77af2f4
commit ff97c24a55
7 changed files with 147 additions and 25 deletions
@@ -25,10 +25,12 @@ public class ConceptReportController {
}
@PostMapping
public ResponseEntity<ConceptReportResponse> generate(@PathVariable String id) {
public ResponseEntity<ConceptReportResponse> 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
@@ -60,7 +60,7 @@ public class ConceptReportService {
this.objectMapper = objectMapper;
}
public ConceptReportResponse generateReport(Topic topic) {
public ConceptReportResponse generateReport(Topic topic, String language) {
List<Book> 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<String, String> sectionLabel,
Map<String, String> figureLabel) {
Map<String, String> 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();
}
@@ -26,11 +26,13 @@ public class TopicController {
}
@PostMapping("/{id}/summary")
public ResponseEntity<TopicSummaryResponse> generateSummary(@PathVariable String id) {
public ResponseEntity<TopicSummaryResponse> 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);
}
@@ -59,7 +59,7 @@ public class TopicSummaryService {
this.objectMapper = objectMapper;
}
public TopicSummaryResponse generateSummary(Topic topic) {
public TopicSummaryResponse generateSummary(Topic topic, String language) {
List<Book> 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<SectionEntity> sections,
List<FigureEntity> figures) {
List<FigureEntity> 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();
}
+3 -1
View File
@@ -48,7 +48,7 @@
Read
</router-link>
<button
v-if="book.status === 'READY'"
v-if="book.status === 'READY' && uploadEnabled"
class="btn btn-secondary"
:disabled="enrichRunning"
@click="handleEnrich"
@@ -73,6 +73,7 @@
import { computed, onUnmounted, ref } from 'vue'
import type { Book, EnrichmentProgress } from '@/stores/bookStore'
import { useBookStore } from '@/stores/bookStore'
import { env } from '@/env';
const props = defineProps<{
book: Book
@@ -90,6 +91,7 @@ const enrichFeedback = ref<string | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const enrichRunning = computed(() => enrichProgress.value?.status === 'RUNNING')
const uploadEnabled = env('VITE_UPLOAD_ENABLED') !== 'false'
async function handleEnrich() {
enrichFeedback.value = null
+12 -4
View File
@@ -120,13 +120,17 @@ export const useTopicStore = defineStore('topics', () => {
}
}
async function generateSummary(topicId: string): Promise<TopicSummary | null> {
async function generateSummary(topicId: string, language: 'en' | 'th' = 'en'): Promise<TopicSummary | null> {
summaryLoading.value = true
activeSummaryTopicId.value = topicId
activeSummary.value = null
error.value = null
try {
const response = await api.post<TopicSummary>(`/topics/${topicId}/summary`)
const response = await api.post<TopicSummary>(
`/topics/${topicId}/summary`,
null,
{ params: { language } }
)
activeSummary.value = response.data
return response.data
} catch (err: any) {
@@ -168,12 +172,16 @@ export const useTopicStore = defineStore('topics', () => {
}
}
async function generateConceptReport(topicId: string): Promise<ConceptReport | null> {
async function generateConceptReport(topicId: string, language: 'en' | 'th' = 'en'): Promise<ConceptReport | null> {
conceptReportLoading.value = true
activeConceptReport.value = null
error.value = null
try {
const response = await api.post<ConceptReport>(`/topics/${topicId}/concept-reports`)
const response = await api.post<ConceptReport>(
`/topics/${topicId}/concept-reports`,
null,
{ params: { language } }
)
activeConceptReport.value = response.data
return response.data
} catch (err: any) {
+94 -10
View File
@@ -38,10 +38,28 @@
<div v-if="selectedTopicId && mode === 'summary'" class="history-panel card">
<div class="history-header">
<span class="history-title">Saved summaries</span>
<button class="btn btn-primary btn-sm" :disabled="topicStore.summaryLoading" @click="handleGenerate(selectedTopicId!)">
<span v-if="topicStore.summaryLoading" class="spinner" style="width:14px;height:14px;display:inline-block;vertical-align:middle;margin-right:4px;"></span>
Generate New
</button>
<div class="history-actions">
<div class="lang-toggle" role="group" aria-label="Summary language">
<button
type="button"
class="lang-toggle-btn"
:class="{ 'lang-toggle-btn--active': summaryLanguage === 'en' }"
:disabled="topicStore.summaryLoading"
@click="summaryLanguage = 'en'"
>EN</button>
<button
type="button"
class="lang-toggle-btn"
:class="{ 'lang-toggle-btn--active': summaryLanguage === 'th' }"
:disabled="topicStore.summaryLoading"
@click="summaryLanguage = 'th'"
>TH</button>
</div>
<button class="btn btn-primary btn-sm" :disabled="topicStore.summaryLoading" @click="handleGenerate(selectedTopicId!)">
<span v-if="topicStore.summaryLoading" class="spinner" style="width:14px;height:14px;display:inline-block;vertical-align:middle;margin-right:4px;"></span>
Generate New
</button>
</div>
</div>
<div v-if="topicStore.summaryListLoading" class="history-loading">
@@ -71,10 +89,28 @@
<div v-if="selectedTopicId && mode === 'concept'" class="history-panel card">
<div class="history-header">
<span class="history-title">Saved concept reports</span>
<button class="btn btn-primary btn-sm" :disabled="topicStore.conceptReportLoading" @click="handleGenerateConcept(selectedTopicId!)">
<span v-if="topicStore.conceptReportLoading" class="spinner" style="width:14px;height:14px;display:inline-block;vertical-align:middle;margin-right:4px;"></span>
Generate New
</button>
<div class="history-actions">
<div class="lang-toggle" role="group" aria-label="Report language">
<button
type="button"
class="lang-toggle-btn"
:class="{ 'lang-toggle-btn--active': conceptLanguage === 'en' }"
:disabled="topicStore.conceptReportLoading"
@click="conceptLanguage = 'en'"
>EN</button>
<button
type="button"
class="lang-toggle-btn"
:class="{ 'lang-toggle-btn--active': conceptLanguage === 'th' }"
:disabled="topicStore.conceptReportLoading"
@click="conceptLanguage = 'th'"
>TH</button>
</div>
<button class="btn btn-primary btn-sm" :disabled="topicStore.conceptReportLoading" @click="handleGenerateConcept(selectedTopicId!)">
<span v-if="topicStore.conceptReportLoading" class="spinner" style="width:14px;height:14px;display:inline-block;vertical-align:middle;margin-right:4px;"></span>
Generate New
</button>
</div>
</div>
<div v-if="topicStore.conceptReportListLoading" class="history-loading">
@@ -269,6 +305,8 @@ const showSources = ref(true)
const summaryError = ref<string | null>(null)
const conceptError = ref<string | null>(null)
const isNoBooks = ref(false)
const conceptLanguage = ref<'en' | 'th'>('en')
const summaryLanguage = ref<'en' | 'th'>('en')
const sourcesSection = ref<HTMLElement | null>(null)
const selectedTopicId = ref<string | null>(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;