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 @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) Topic topic = topicRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Topic not found.")); .orElseThrow(() -> new NoSuchElementException("Topic not found."));
return ResponseEntity.ok(conceptReportService.generateReport(topic)); return ResponseEntity.ok(conceptReportService.generateReport(topic, language));
} }
@GetMapping @GetMapping
@@ -60,7 +60,7 @@ public class ConceptReportService {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
public ConceptReportResponse generateReport(Topic topic) { public ConceptReportResponse generateReport(Topic topic, String language) {
List<Book> readyBooks = bookRepository.findAll().stream() List<Book> readyBooks = bookRepository.findAll().stream()
.filter(b -> b.getStatus() == BookStatus.READY) .filter(b -> b.getStatus() == BookStatus.READY)
.toList(); .toList();
@@ -106,7 +106,7 @@ public class ConceptReportService {
if (mf == null || mf.isEmpty()) continue; if (mf == null || mf.isEmpty()) continue;
if (facet == ConceptFacet.OTHER) continue; // skip OTHER bucket in the rendered report 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() String markdown = chatClient.prompt()
.system(SYSTEM_PROMPT) .system(SYSTEM_PROMPT)
.user(prompt) .user(prompt)
@@ -151,7 +151,8 @@ public class ConceptReportService {
private String buildFacetPrompt(Topic topic, ConceptFacet facet, MergedFacet mf, private String buildFacetPrompt(Topic topic, ConceptFacet facet, MergedFacet mf,
Map<String, String> sectionLabel, Map<String, String> sectionLabel,
Map<String, String> figureLabel) { Map<String, String> figureLabel,
String language) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append("CONCEPT: ").append(topic.getName()).append("\n"); sb.append("CONCEPT: ").append(topic.getName()).append("\n");
sb.append("FACET: ").append(facet.displayTitle()).append("\n\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 \"") sb.append("Write the ").append(facet.displayTitle()).append(" section of a concept report on \"")
.append(topic.getName()) .append(topic.getName())
.append("\". Stay strictly within this facet. Use the [S#]/[F#] labels above for citations."); .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(); return sb.toString();
} }
@@ -26,11 +26,13 @@ public class TopicController {
} }
@PostMapping("/{id}/summary") @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) Topic topic = topicRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Topic not found.")); .orElseThrow(() -> new NoSuchElementException("Topic not found."));
TopicSummaryResponse response = topicSummaryService.generateSummary(topic); TopicSummaryResponse response = topicSummaryService.generateSummary(topic, language);
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@@ -59,7 +59,7 @@ public class TopicSummaryService {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
public TopicSummaryResponse generateSummary(Topic topic) { public TopicSummaryResponse generateSummary(Topic topic, String language) {
List<Book> readyBooks = bookRepository.findAll().stream() List<Book> readyBooks = bookRepository.findAll().stream()
.filter(b -> b.getStatus() == BookStatus.READY) .filter(b -> b.getStatus() == BookStatus.READY)
.toList(); .toList();
@@ -82,7 +82,7 @@ public class TopicSummaryService {
log.debug("Topic reports for '{}': {} sections, {} figures retrieved", log.debug("Topic reports for '{}': {} sections, {} figures retrieved",
topic.getName(), allSections.size(), allFigures.size()); topic.getName(), allSections.size(), allFigures.size());
String contextPrompt = buildContextPrompt(question, allSections, allFigures); String contextPrompt = buildContextPrompt(question, allSections, allFigures, language);
String summary = chatClient.prompt() String summary = chatClient.prompt()
.system(SYSTEM_PROMPT) .system(SYSTEM_PROMPT)
.user(contextPrompt) .user(contextPrompt)
@@ -142,7 +142,8 @@ public class TopicSummaryService {
private String buildContextPrompt(String question, private String buildContextPrompt(String question,
List<SectionEntity> sections, List<SectionEntity> sections,
List<FigureEntity> figures) { List<FigureEntity> figures,
String language) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
if (!sections.isEmpty()) { if (!sections.isEmpty()) {
@@ -169,6 +170,17 @@ public class TopicSummaryService {
} }
sb.append("QUESTION:\n").append(question); 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(); return sb.toString();
} }
+3 -1
View File
@@ -48,7 +48,7 @@
Read Read
</router-link> </router-link>
<button <button
v-if="book.status === 'READY'" v-if="book.status === 'READY' && uploadEnabled"
class="btn btn-secondary" class="btn btn-secondary"
:disabled="enrichRunning" :disabled="enrichRunning"
@click="handleEnrich" @click="handleEnrich"
@@ -73,6 +73,7 @@
import { computed, onUnmounted, ref } from 'vue' import { computed, onUnmounted, ref } from 'vue'
import type { Book, EnrichmentProgress } from '@/stores/bookStore' import type { Book, EnrichmentProgress } from '@/stores/bookStore'
import { useBookStore } from '@/stores/bookStore' import { useBookStore } from '@/stores/bookStore'
import { env } from '@/env';
const props = defineProps<{ const props = defineProps<{
book: Book book: Book
@@ -90,6 +91,7 @@ const enrichFeedback = ref<string | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null let pollTimer: ReturnType<typeof setInterval> | null = null
const enrichRunning = computed(() => enrichProgress.value?.status === 'RUNNING') const enrichRunning = computed(() => enrichProgress.value?.status === 'RUNNING')
const uploadEnabled = env('VITE_UPLOAD_ENABLED') !== 'false'
async function handleEnrich() { async function handleEnrich() {
enrichFeedback.value = null 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 summaryLoading.value = true
activeSummaryTopicId.value = topicId activeSummaryTopicId.value = topicId
activeSummary.value = null activeSummary.value = null
error.value = null error.value = null
try { 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 activeSummary.value = response.data
return response.data return response.data
} catch (err: any) { } 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 conceptReportLoading.value = true
activeConceptReport.value = null activeConceptReport.value = null
error.value = null error.value = null
try { 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 activeConceptReport.value = response.data
return response.data return response.data
} catch (err: any) { } catch (err: any) {
+86 -2
View File
@@ -38,11 +38,29 @@
<div v-if="selectedTopicId && mode === 'summary'" class="history-panel card"> <div v-if="selectedTopicId && mode === 'summary'" class="history-panel card">
<div class="history-header"> <div class="history-header">
<span class="history-title">Saved summaries</span> <span class="history-title">Saved summaries</span>
<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!)"> <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> <span v-if="topicStore.summaryLoading" class="spinner" style="width:14px;height:14px;display:inline-block;vertical-align:middle;margin-right:4px;"></span>
Generate New Generate New
</button> </button>
</div> </div>
</div>
<div v-if="topicStore.summaryListLoading" class="history-loading"> <div v-if="topicStore.summaryListLoading" class="history-loading">
<div class="spinner spinner-dark" style="width:20px;height:20px;margin-right:8px;display:inline-block;vertical-align:middle;"></div> <div class="spinner spinner-dark" style="width:20px;height:20px;margin-right:8px;display:inline-block;vertical-align:middle;"></div>
@@ -71,11 +89,29 @@
<div v-if="selectedTopicId && mode === 'concept'" class="history-panel card"> <div v-if="selectedTopicId && mode === 'concept'" class="history-panel card">
<div class="history-header"> <div class="history-header">
<span class="history-title">Saved concept reports</span> <span class="history-title">Saved concept reports</span>
<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!)"> <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> <span v-if="topicStore.conceptReportLoading" class="spinner" style="width:14px;height:14px;display:inline-block;vertical-align:middle;margin-right:4px;"></span>
Generate New Generate New
</button> </button>
</div> </div>
</div>
<div v-if="topicStore.conceptReportListLoading" class="history-loading"> <div v-if="topicStore.conceptReportListLoading" class="history-loading">
<div class="spinner spinner-dark" style="width:20px;height:20px;margin-right:8px;display:inline-block;vertical-align:middle;"></div> <div class="spinner spinner-dark" style="width:20px;height:20px;margin-right:8px;display:inline-block;vertical-align:middle;"></div>
@@ -269,6 +305,8 @@ const showSources = ref(true)
const summaryError = ref<string | null>(null) const summaryError = ref<string | null>(null)
const conceptError = ref<string | null>(null) const conceptError = ref<string | null>(null)
const isNoBooks = ref(false) const isNoBooks = ref(false)
const conceptLanguage = ref<'en' | 'th'>('en')
const summaryLanguage = ref<'en' | 'th'>('en')
const sourcesSection = ref<HTMLElement | null>(null) const sourcesSection = ref<HTMLElement | null>(null)
const selectedTopicId = ref<string | null>(null) const selectedTopicId = ref<string | null>(null)
const mode = ref<'summary' | 'concept'>('summary') const mode = ref<'summary' | 'concept'>('summary')
@@ -401,7 +439,7 @@ async function handleGenerateConcept(topicId: string) {
conceptError.value = null conceptError.value = null
isNoBooks.value = false isNoBooks.value = false
showSources.value = true showSources.value = true
const result = await topicStore.generateConceptReport(topicId) const result = await topicStore.generateConceptReport(topicId, conceptLanguage.value)
if (!result) { if (!result) {
conceptError.value = topicStore.error ?? 'Failed to generate concept report.' conceptError.value = topicStore.error ?? 'Failed to generate concept report.'
isNoBooks.value = isNoBooks.value =
@@ -425,7 +463,7 @@ async function handleGenerate(topicId: string) {
isNoBooks.value = false isNoBooks.value = false
showSources.value = true showSources.value = true
const result = await topicStore.generateSummary(topicId) const result = await topicStore.generateSummary(topicId, summaryLanguage.value)
if (!result) { if (!result) {
summaryError.value = topicStore.error ?? 'Failed to generate summary.' summaryError.value = topicStore.error ?? 'Failed to generate summary.'
isNoBooks.value = isNoBooks.value =
@@ -528,6 +566,52 @@ function formatDateShort(iso: string): string {
margin-bottom: 0.75rem; 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 { .history-title {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;