Add thai support in summary
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user