jinruiyang jinruiy commited on
Commit
4ebe674
·
1 Parent(s): 3eb02b6

Add pagination, auto-save ratings, and fix translation button

Browse files

- Add pagination: 100 results split into 10 per page
- Auto-save ratings on click via /api/rating endpoint
- Fix translation button always showing for AR/CN languages
- Add dotenv support for local development
- Add .env to .gitignore for security

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: jinruiy <jinruiy@users.noreply.github.com>

Files changed (3) hide show
  1. .gitignore +5 -1
  2. backend/api.py +61 -1
  3. frontend/js/app.js +199 -17
.gitignore CHANGED
@@ -23,4 +23,8 @@ Thumbs.db
23
  *.tmp
24
 
25
  # Local data (if any)
26
- *.pkl.local
 
 
 
 
 
23
  *.tmp
24
 
25
  # Local data (if any)
26
+ *.pkl
27
+
28
+ # Environment files
29
+ .env
30
+ .env.local
backend/api.py CHANGED
@@ -14,6 +14,13 @@ from fastapi.staticfiles import StaticFiles
14
  from fastapi.responses import HTMLResponse, FileResponse
15
  from pydantic import BaseModel
16
 
 
 
 
 
 
 
 
17
  from .services import get_knowledge_base, get_retriever, search_knowledge_base, get_stats
18
 
19
  # DeepL API configuration
@@ -60,6 +67,15 @@ class TranslateRequest(BaseModel):
60
  target_lang: str # AR or ZH (DeepL uses ZH for Chinese)
61
 
62
 
 
 
 
 
 
 
 
 
 
63
  # ============================================================
64
  # Translation Cache (file-based, persistent across restarts)
65
  # ============================================================
@@ -180,7 +196,7 @@ async def api_stats():
180
  async def api_search(request: SearchRequest):
181
  """Search the knowledge base"""
182
  try:
183
- results = search_knowledge_base(request.query, top_k=5)
184
  return {
185
  "results": results,
186
  "query": request.query,
@@ -235,6 +251,50 @@ async def api_feedback(request: FeedbackRequest, req: Request):
235
  return {"success": False, "error": str(e)}
236
 
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  @app.post("/api/translate")
239
  async def api_translate(request: TranslateRequest):
240
  """Translate texts using DeepL API"""
 
14
  from fastapi.responses import HTMLResponse, FileResponse
15
  from pydantic import BaseModel
16
 
17
+ # Load .env file if present
18
+ try:
19
+ from dotenv import load_dotenv
20
+ load_dotenv(Path(__file__).parent.parent / ".env")
21
+ except ImportError:
22
+ pass # python-dotenv not installed
23
+
24
  from .services import get_knowledge_base, get_retriever, search_knowledge_base, get_stats
25
 
26
  # DeepL API configuration
 
67
  target_lang: str # AR or ZH (DeepL uses ZH for Chinese)
68
 
69
 
70
+ class RatingRequest(BaseModel):
71
+ query: str
72
+ category: str
73
+ entity_id: str
74
+ entity_index: int
75
+ rating_type: str # 'relevance' or 'helpful'
76
+ rating_value: int # 0, 1, or 2 for relevance; 0 or 1 for helpful
77
+
78
+
79
  # ============================================================
80
  # Translation Cache (file-based, persistent across restarts)
81
  # ============================================================
 
196
  async def api_search(request: SearchRequest):
197
  """Search the knowledge base"""
198
  try:
199
+ results = search_knowledge_base(request.query, top_k=100)
200
  return {
201
  "results": results,
202
  "query": request.query,
 
251
  return {"success": False, "error": str(e)}
252
 
253
 
254
+ @app.post("/api/rating")
255
+ async def api_rating(request: RatingRequest, req: Request):
256
+ """Save individual entity rating (auto-save on click)"""
257
+ try:
258
+ # Ensure data directory exists
259
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
260
+
261
+ # Get client IP
262
+ client_ip = req.headers.get("x-forwarded-for", "").split(",")[0].strip()
263
+ if not client_ip:
264
+ client_ip = req.client.host if req.client else "unknown"
265
+
266
+ rating_file = DATA_DIR / "ratings.json"
267
+
268
+ rating = {
269
+ "timestamp": datetime.now().isoformat(),
270
+ "client_ip": client_ip,
271
+ "query": request.query,
272
+ "category": request.category,
273
+ "entity_id": request.entity_id,
274
+ "entity_index": request.entity_index,
275
+ "rating_type": request.rating_type,
276
+ "rating_value": request.rating_value
277
+ }
278
+
279
+ # Load existing ratings
280
+ if rating_file.exists():
281
+ with open(rating_file, "r", encoding="utf-8") as f:
282
+ all_ratings = json.load(f)
283
+ else:
284
+ all_ratings = []
285
+
286
+ all_ratings.append(rating)
287
+
288
+ # Save ratings
289
+ with open(rating_file, "w", encoding="utf-8") as f:
290
+ json.dump(all_ratings, f, ensure_ascii=False, indent=2)
291
+
292
+ return {"success": True, "total": len(all_ratings)}
293
+
294
+ except Exception as e:
295
+ return {"success": False, "error": str(e)}
296
+
297
+
298
  @app.post("/api/translate")
299
  async def api_translate(request: TranslateRequest):
300
  """Translate texts using DeepL API"""
frontend/js/app.js CHANGED
@@ -237,7 +237,10 @@ const state = {
237
  translationAvailable: false,
238
  translatedResults: {}, // { lang: { entityId: { name, summary, facts } } }
239
  showOriginal: false, // Toggle for showing original English
240
- isTranslating: false
 
 
 
241
  };
242
 
243
  // ============================================
@@ -353,6 +356,8 @@ async function handleSearch() {
353
  // Reset translation state for new search
354
  state.translatedResults = {};
355
  state.showOriginal = false;
 
 
356
  updateUIState();
357
 
358
  try {
@@ -512,16 +517,26 @@ function switchToHomeMode() {
512
  // UI RENDERING
513
  // ============================================
514
  function renderResults() {
515
- const results = state.results;
516
  const lang = state.language;
517
- const showTranslateToggle = lang !== 'en' && state.translationAvailable && results.length > 0;
518
- const hasTranslations = state.translatedResults[lang] && Object.keys(state.translatedResults[lang]).length > 0;
 
 
 
 
 
 
 
 
 
 
519
 
520
  if (DOM.resultsCount) {
521
- DOM.resultsCount.textContent = t('topEntities').replace('{count}', results.length);
522
  }
523
 
524
- if (!results.length) {
525
  DOM.resultsContainer.innerHTML = `
526
  <div class="text-center py-12 text-gray-400">
527
  <p class="text-lg">No results found for your query</p>
@@ -530,21 +545,23 @@ function renderResults() {
530
  return;
531
  }
532
 
533
- // Build translation toggle button if applicable
534
- const translationToggle = showTranslateToggle ? `
 
 
535
  <div class="mb-4 flex justify-end">
536
  ${state.isTranslating ? `
537
- <span class="text-[18px] text-amber-600 flex items-center gap-2">
538
  <span class="spinner">⏳</span> ${t('translating')}
539
  </span>
540
- ` : hasTranslations ? `
541
  <button onclick="toggleOriginal()"
542
- class="text-[18px] px-4 py-1.5 rounded border ${state.showOriginal ? 'bg-emerald-900 text-white border-emerald-900' : 'bg-white text-emerald-900 border-emerald-900'} hover:opacity-80 transition">
543
  ${state.showOriginal ? t('showTranslated') : t('showOriginal')}
544
  </button>
545
  ` : `
546
- <button onclick="translateResults()"
547
- class="text-[18px] px-4 py-1.5 rounded border text-white transition"
548
  style="background-color: #b38e3f; border-color: #b38e3f;"
549
  onmouseover="this.style.backgroundColor='#9a7a35'"
550
  onmouseout="this.style.backgroundColor='#b38e3f'">
@@ -554,7 +571,38 @@ function renderResults() {
554
  </div>
555
  ` : '';
556
 
557
- DOM.resultsContainer.innerHTML = translationToggle + results.map((result, index) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558
  // Get translated or original content
559
  const content = getResultContent(result, index);
560
 
@@ -632,9 +680,19 @@ function renderResults() {
632
  </div>
633
  </div>
634
  </div>
635
- `}).join('');
636
  }
637
 
 
 
 
 
 
 
 
 
 
 
638
  function updateUIState() {
639
  if (state.isLoading) {
640
  DOM.searchBtn?.classList.add('loading');
@@ -680,9 +738,9 @@ function selectCategory(categoryId) {
680
  }
681
 
682
  // ============================================
683
- // RATINGS
684
  // ============================================
685
- window.setRating = function(entityIndex, dimension, value) {
686
  if (!state.ratings[entityIndex]) {
687
  state.ratings[entityIndex] = {};
688
  }
@@ -690,6 +748,32 @@ window.setRating = function(entityIndex, dimension, value) {
690
 
691
  // Re-render to update button states
692
  renderResults();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
693
  };
694
 
695
  // ============================================
@@ -848,6 +932,104 @@ async function translateResults() {
848
  }
849
  }
850
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
851
  // Toggle between original and translated
852
  window.toggleOriginal = function() {
853
  state.showOriginal = !state.showOriginal;
 
237
  translationAvailable: false,
238
  translatedResults: {}, // { lang: { entityId: { name, summary, facts } } }
239
  showOriginal: false, // Toggle for showing original English
240
+ isTranslating: false,
241
+ // Pagination state
242
+ currentPage: 1,
243
+ resultsPerPage: 10
244
  };
245
 
246
  // ============================================
 
356
  // Reset translation state for new search
357
  state.translatedResults = {};
358
  state.showOriginal = false;
359
+ // Reset pagination
360
+ state.currentPage = 1;
361
  updateUIState();
362
 
363
  try {
 
517
  // UI RENDERING
518
  // ============================================
519
  function renderResults() {
520
+ const allResults = state.results;
521
  const lang = state.language;
522
+ const totalResults = allResults.length;
523
+ const totalPages = Math.ceil(totalResults / state.resultsPerPage);
524
+
525
+ // Calculate current page items
526
+ const startIndex = (state.currentPage - 1) * state.resultsPerPage;
527
+ const endIndex = Math.min(startIndex + state.resultsPerPage, totalResults);
528
+ const pageResults = allResults.slice(startIndex, endIndex);
529
+
530
+ // Check if current page has translations
531
+ const pageIndices = pageResults.map((_, i) => startIndex + i);
532
+ const hasPageTranslations = state.translatedResults[lang] &&
533
+ pageIndices.some(idx => state.translatedResults[lang][idx]);
534
 
535
  if (DOM.resultsCount) {
536
+ DOM.resultsCount.textContent = `Page ${state.currentPage} of ${totalPages} (${totalResults} total)`;
537
  }
538
 
539
+ if (!allResults.length) {
540
  DOM.resultsContainer.innerHTML = `
541
  <div class="text-center py-12 text-gray-400">
542
  <p class="text-lg">No results found for your query</p>
 
545
  return;
546
  }
547
 
548
+ // Build translation toggle button if applicable (only for current page)
549
+ // Show button for AR/CN even if translation API isn't available (will show message when clicked)
550
+ const showTranslateButton = lang !== 'en' && pageResults.length > 0;
551
+ const translationToggle = showTranslateButton ? `
552
  <div class="mb-4 flex justify-end">
553
  ${state.isTranslating ? `
554
+ <span class="text-[14px] md:text-[18px] text-amber-600 flex items-center gap-2">
555
  <span class="spinner">⏳</span> ${t('translating')}
556
  </span>
557
+ ` : hasPageTranslations ? `
558
  <button onclick="toggleOriginal()"
559
+ class="text-[14px] md:text-[18px] px-4 py-1.5 rounded border ${state.showOriginal ? 'bg-emerald-900 text-white border-emerald-900' : 'bg-white text-emerald-900 border-emerald-900'} hover:opacity-80 transition">
560
  ${state.showOriginal ? t('showTranslated') : t('showOriginal')}
561
  </button>
562
  ` : `
563
+ <button onclick="translateCurrentPage()"
564
+ class="text-[14px] md:text-[18px] px-4 py-1.5 rounded border text-white transition"
565
  style="background-color: #b38e3f; border-color: #b38e3f;"
566
  onmouseover="this.style.backgroundColor='#9a7a35'"
567
  onmouseout="this.style.backgroundColor='#b38e3f'">
 
571
  </div>
572
  ` : '';
573
 
574
+ // Build pagination controls
575
+ const paginationControls = totalPages > 1 ? `
576
+ <div class="flex justify-center items-center gap-2 md:gap-4 my-6">
577
+ <button onclick="goToPage(1)"
578
+ class="px-2 md:px-3 py-1 md:py-2 rounded border text-[12px] md:text-[14px] ${state.currentPage === 1 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-emerald-900 border-emerald-900 hover:bg-emerald-50'}"
579
+ ${state.currentPage === 1 ? 'disabled' : ''}>
580
+ ⏮ First
581
+ </button>
582
+ <button onclick="goToPage(${state.currentPage - 1})"
583
+ class="px-2 md:px-3 py-1 md:py-2 rounded border text-[12px] md:text-[14px] ${state.currentPage === 1 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-emerald-900 border-emerald-900 hover:bg-emerald-50'}"
584
+ ${state.currentPage === 1 ? 'disabled' : ''}>
585
+ ◀ Prev
586
+ </button>
587
+ <span class="px-3 md:px-4 py-1 md:py-2 text-[12px] md:text-[14px] font-medium text-emerald-900">
588
+ ${state.currentPage} / ${totalPages}
589
+ </span>
590
+ <button onclick="goToPage(${state.currentPage + 1})"
591
+ class="px-2 md:px-3 py-1 md:py-2 rounded border text-[12px] md:text-[14px] ${state.currentPage === totalPages ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-emerald-900 border-emerald-900 hover:bg-emerald-50'}"
592
+ ${state.currentPage === totalPages ? 'disabled' : ''}>
593
+ Next ▶
594
+ </button>
595
+ <button onclick="goToPage(${totalPages})"
596
+ class="px-2 md:px-3 py-1 md:py-2 rounded border text-[12px] md:text-[14px] ${state.currentPage === totalPages ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-emerald-900 border-emerald-900 hover:bg-emerald-50'}"
597
+ ${state.currentPage === totalPages ? 'disabled' : ''}>
598
+ Last ⏭
599
+ </button>
600
+ </div>
601
+ ` : '';
602
+
603
+ DOM.resultsContainer.innerHTML = translationToggle + paginationControls + pageResults.map((result, pageIndex) => {
604
+ // Calculate actual index in full results array
605
+ const index = startIndex + pageIndex;
606
  // Get translated or original content
607
  const content = getResultContent(result, index);
608
 
 
680
  </div>
681
  </div>
682
  </div>
683
+ `}).join('') + paginationControls;
684
  }
685
 
686
+ // Pagination navigation
687
+ window.goToPage = function(page) {
688
+ const totalPages = Math.ceil(state.results.length / state.resultsPerPage);
689
+ if (page < 1 || page > totalPages) return;
690
+ state.currentPage = page;
691
+ renderResults();
692
+ // Scroll to top of results
693
+ document.getElementById('results-section')?.scrollIntoView({ behavior: 'smooth' });
694
+ };
695
+
696
  function updateUIState() {
697
  if (state.isLoading) {
698
  DOM.searchBtn?.classList.add('loading');
 
738
  }
739
 
740
  // ============================================
741
+ // RATINGS (auto-save on click)
742
  // ============================================
743
+ window.setRating = async function(entityIndex, dimension, value) {
744
  if (!state.ratings[entityIndex]) {
745
  state.ratings[entityIndex] = {};
746
  }
 
748
 
749
  // Re-render to update button states
750
  renderResults();
751
+
752
+ // Auto-save rating to backend
753
+ try {
754
+ const result = state.results[entityIndex];
755
+ const response = await fetch(`${CONFIG.API_BASE}/rating`, {
756
+ method: 'POST',
757
+ headers: { 'Content-Type': 'application/json' },
758
+ body: JSON.stringify({
759
+ query: state.currentQuery,
760
+ category: state.currentCategory?.id || '',
761
+ entity_id: result?.entity_id || '',
762
+ entity_index: entityIndex,
763
+ rating_type: dimension,
764
+ rating_value: dimension === 'helpful' ? (value ? 1 : 0) : value
765
+ })
766
+ });
767
+
768
+ const data = await response.json();
769
+ if (data.success) {
770
+ // Show subtle confirmation
771
+ showToast('Rating saved ✓', 'success');
772
+ }
773
+ } catch (error) {
774
+ console.error('Failed to save rating:', error);
775
+ // Don't show error to user - rating is still stored locally
776
+ }
777
  };
778
 
779
  // ============================================
 
932
  }
933
  }
934
 
935
+ // Translate only current page items
936
+ window.translateCurrentPage = async function() {
937
+ const lang = state.language;
938
+
939
+ // Only translate for AR or CN
940
+ if (lang === 'en' || state.results.length === 0) {
941
+ return;
942
+ }
943
+
944
+ // Check if translation is available
945
+ if (!state.translationAvailable) {
946
+ showToast(t('translationNotAvailable') + ' (DEEPL_API_KEY not set)', 'warning');
947
+ return;
948
+ }
949
+
950
+ // Calculate current page items
951
+ const startIndex = (state.currentPage - 1) * state.resultsPerPage;
952
+ const endIndex = Math.min(startIndex + state.resultsPerPage, state.results.length);
953
+ const pageResults = state.results.slice(startIndex, endIndex);
954
+
955
+ state.isTranslating = true;
956
+ renderResults(); // Show translating state
957
+
958
+ try {
959
+ // Collect texts from current page only
960
+ const textsToTranslate = [];
961
+ const textMap = []; // Track which text belongs to which result
962
+
963
+ pageResults.forEach((result, pageIndex) => {
964
+ const index = startIndex + pageIndex;
965
+ // Add entity name
966
+ if (result.entity_name) {
967
+ textsToTranslate.push(result.entity_name);
968
+ textMap.push({ index, field: 'entityName' });
969
+ }
970
+ // Add summary
971
+ if (result.summary) {
972
+ textsToTranslate.push(result.summary);
973
+ textMap.push({ index, field: 'summary' });
974
+ }
975
+ // Add facts
976
+ (result.must_answer || []).forEach((fact, factIndex) => {
977
+ textsToTranslate.push(fact);
978
+ textMap.push({ index, field: 'fact', factIndex });
979
+ });
980
+ });
981
+
982
+ if (textsToTranslate.length === 0) {
983
+ state.isTranslating = false;
984
+ return;
985
+ }
986
+
987
+ const response = await fetch(`${CONFIG.API_BASE}/translate`, {
988
+ method: 'POST',
989
+ headers: { 'Content-Type': 'application/json' },
990
+ body: JSON.stringify({
991
+ texts: textsToTranslate,
992
+ target_lang: lang
993
+ })
994
+ });
995
+
996
+ const data = await response.json();
997
+
998
+ if (data.success && data.translations) {
999
+ // Initialize language cache if needed
1000
+ if (!state.translatedResults[lang]) {
1001
+ state.translatedResults[lang] = {};
1002
+ }
1003
+
1004
+ data.translations.forEach((translated, i) => {
1005
+ const { index, field, factIndex } = textMap[i];
1006
+
1007
+ if (!state.translatedResults[lang][index]) {
1008
+ state.translatedResults[lang][index] = {
1009
+ entityName: null,
1010
+ summary: null,
1011
+ facts: []
1012
+ };
1013
+ }
1014
+
1015
+ if (field === 'entityName') {
1016
+ state.translatedResults[lang][index].entityName = translated;
1017
+ } else if (field === 'summary') {
1018
+ state.translatedResults[lang][index].summary = translated;
1019
+ } else if (field === 'fact') {
1020
+ state.translatedResults[lang][index].facts[factIndex] = translated;
1021
+ }
1022
+ });
1023
+ }
1024
+ } catch (error) {
1025
+ console.error('Translation error:', error);
1026
+ showToast(t('translationNotAvailable'), 'warning');
1027
+ } finally {
1028
+ state.isTranslating = false;
1029
+ renderResults();
1030
+ }
1031
+ };
1032
+
1033
  // Toggle between original and translated
1034
  window.toggleOriginal = function() {
1035
  state.showOriginal = !state.showOriginal;