diff --git a/TODO.md b/TODO.md index 4d51266..2a85b88 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,7 @@ - Frontend now logs more useful API and debug information than earlier versions. - A local self-test workflow now exists and should be run before container builds or pushes. - A fresh-machine bootstrap was revalidated in a user-local toolchain setup on `2026-03-17`; `go test ./...` and `bash scripts/selftest.sh` now pass in that setup. +- Result modal sizing is now being constrained to the viewport, and modal-only source-summary translation is now part of the active implementation path. ## Current Architecture - `backend/main.go` @@ -225,6 +226,7 @@ - Frontend JavaScript still has no Node-based lint/build step in this environment. - Search cards now separate source snippet from AI reason, but metadata fidelity still depends on source enrichment quality. - Gemini notes are now intended to be Korean, but final output quality still depends on Gemini response consistency. +- Source Summary translation now depends on Google Translate HTTP availability; frontend silently falls back to original summary text if translation fails. - The local self-test script is better than before, but it is still a smoke test, not full integration coverage. ## Current Risks Around Search Quality @@ -547,6 +549,7 @@ - [ ] Reduce `/api/search` latency further without collapsing result count - [ ] Build a repeatable repo-local bootstrap script or documented setup command set for non-root machines so fresh PC setup does not depend on shell history - [ ] Improve Envato / Artgrid preview acquisition reliability so Gemini Vision sees real frames more often +- [ ] Browser-verify the new result modal at multiple viewport heights and confirm translated Source Summary readability on real long descriptions - [ ] Revisit Google Video UX: - current YouTube embed was abandoned due error `153` - current in-app panel is more reliable but less rich than a true embedded watch page @@ -612,6 +615,23 @@ - If behavior in the browser does not match the latest backend/frontend code, the first assumption should be stale frontend assets until proven otherwise ## Recent Change Log +- Date: `2026-03-17` +- What changed: + - Added `POST /api/translate/summary` so the result modal can translate `Source Summary` text to Korean on demand with in-process caching. + - Reworked result modal sizing so the popup stays within the viewport and the summary area scrolls internally instead of stretching the whole dialog off-screen. + - Replaced the false-positive near-deadline warning heuristic with explicit deadline metadata from search collection / enrichment / Gemini stages. + - Added an Artgrid API `403` cooldown guard so repeated clip enrichments stop re-hitting the same blocked JSON endpoint for a while and fall back to HTML parsing directly. + - Added backend tests for timeout-warning gating, summary translation caching, and Artgrid `403` skip behavior. +- Why it changed: + - The provided browser log showed a successful `~55s` search still surfacing `search returned partial results to avoid gateway timeout`, and the user reported that the result popup could overflow the viewport and that untranslated / very long source summaries made the modal feel too large. +- How it was verified: + - `go test ./...` + - `bash scripts/selftest.sh` + - `python3 -m py_compile worker/downloader.py scripts/mock_searxng.py` +- What is still risky or incomplete: + - Full browser-level confirmation of the resized modal and lazy summary translation still needs live UI validation. + - The Artgrid `403` guard reduces repeated waste, but it does not solve the underlying upstream access restriction. + - Date: `2026-03-17` - What changed: - Re-audited the repository structure and `TODO.md` handover state on a fresh machine. diff --git a/backend/handlers/api.go b/backend/handlers/api.go index 4f0731a..8e5ba69 100644 --- a/backend/handlers/api.go +++ b/backend/handlers/api.go @@ -147,6 +147,7 @@ func RegisterRoutes(router *gin.Engine, app *App) { router.POST("/api/download/preview", app.previewDownload) router.POST("/api/upload", app.uploadFile) router.POST("/api/download", app.startDownload) + router.POST("/api/translate/summary", app.translateSummary) router.POST("/api/search", app.searchMedia) } @@ -343,6 +344,28 @@ func (a *App) previewDownload(c *gin.Context) { c.JSON(http.StatusOK, preview) } +func (a *App) translateSummary(c *gin.Context) { + var req struct { + Text string `json:"text"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + req.Text = strings.TrimSpace(req.Text) + if req.Text == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "text is required"}) + return + } + + translated, err := a.GeminiService.TranslateSummaryToKorean(req.Text) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"translatedText": translated}) +} + func (a *App) runDownload(recordID int64, url, start, end, quality, outputPath string) { a.Hub.Broadcast("progress", gin.H{"type": "download", "status": "queued", "progress": 0, "url": url}) a.debug("download command started", gin.H{"url": url, "start": start, "end": end, "quality": quality, "outputPath": outputPath}) @@ -416,7 +439,7 @@ func (a *App) searchMedia(c *gin.Context) { enabledPlatforms := normalizePlatforms(req.Platforms) a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "searching " + selectedPlatformLabel(enabledPlatforms), "progress": 35}) - results, err := a.SearchService.SearchMediaWithDeadline(queryVariants, enabledPlatforms, deadline.Add(-20*time.Second)) + results, searchMeta, err := a.SearchService.SearchMediaWithDeadline(queryVariants, enabledPlatforms, deadline.Add(-20*time.Second)) if err != nil { a.debug("search backend failed", gin.H{"error": err.Error(), "variants": queryVariants, "durationMs": time.Since(started).Milliseconds()}) a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search failed", "progress": 100, "message": err.Error()}) @@ -441,10 +464,12 @@ func (a *App) searchMedia(c *gin.Context) { a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "analyzing all candidate visuals with Gemini Vision", "progress": 75}) recommended, geminiStats, geminiErr := services.EvaluateAllCandidatesWithGeminiWithDeadline(a.GeminiService, req.Query, scored, deadline.Add(-5*time.Second)) a.debug("search gemini evaluation", geminiStats) + supplementalDeadlineLimited := false if services.NeedsSupplementalExploration(recommended) && time.Now().Before(deadline.Add(-10*time.Second)) { a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "Gemini 평가가 약해 추가 후보를 탐색하는 중", "progress": 82}) explorationQueries := buildSupplementalQueries(req.Query, queryVariants) - extraResults, extraErr := a.SearchService.SearchMediaWithDeadline(explorationQueries, enabledPlatforms, deadline.Add(-10*time.Second)) + extraResults, extraMeta, extraErr := a.SearchService.SearchMediaWithDeadline(explorationQueries, enabledPlatforms, deadline.Add(-10*time.Second)) + supplementalDeadlineLimited = extraMeta.PartialDueToDeadline if extraErr == nil && len(extraResults) > 0 { results = mergeSearchResults(results, extraResults) scored = services.RankSearchResults(strings.Join(explorationQueries[:min(len(explorationQueries), 3)], " "), results) @@ -468,6 +493,8 @@ func (a *App) searchMedia(c *gin.Context) { }) } } + } else if services.NeedsSupplementalExploration(recommended) { + supplementalDeadlineLimited = true } if geminiErr != nil && len(recommended) == 0 { warning := geminiErr.Error() @@ -503,13 +530,17 @@ func (a *App) searchMedia(c *gin.Context) { if warning != "" { response["warning"] = warning } - if time.Now().After(deadline.Add(-2*time.Second)) && warning == "" { + if shouldWarnPartialSearch(searchMeta, geminiStats, supplementalDeadlineLimited, warning) { response["warning"] = "search returned partial results to avoid gateway timeout" } a.Hub.Broadcast("progress", gin.H{"type": "search", "status": "search complete", "progress": 100}) c.JSON(http.StatusOK, response) } +func shouldWarnPartialSearch(meta services.SearchExecutionMeta, stats services.GeminiBatchStats, supplementalDeadlineLimited bool, warning string) bool { + return warning == "" && (meta.PartialDueToDeadline || stats.DeadlineLimited || supplementalDeadlineLimited) +} + func normalizeFilename(name string) string { base := strings.ToLower(strings.TrimSpace(name)) ext := filepath.Ext(base) diff --git a/backend/handlers/api_test.go b/backend/handlers/api_test.go new file mode 100644 index 0000000..0eb4be9 --- /dev/null +++ b/backend/handlers/api_test.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "testing" + + "ai-media-hub/backend/services" +) + +func TestShouldWarnPartialSearchDoesNotWarnForCompletedSearch(t *testing.T) { + if shouldWarnPartialSearch(services.SearchExecutionMeta{}, services.GeminiBatchStats{}, false, "") { + t.Fatal("expected no warning when search completed without deadline limits") + } +} + +func TestShouldWarnPartialSearchWarnsWhenDeadlineLimited(t *testing.T) { + if !shouldWarnPartialSearch(services.SearchExecutionMeta{PartialDueToDeadline: true}, services.GeminiBatchStats{}, false, "") { + t.Fatal("expected warning when search collection was deadline limited") + } + if !shouldWarnPartialSearch(services.SearchExecutionMeta{}, services.GeminiBatchStats{DeadlineLimited: true}, false, "") { + t.Fatal("expected warning when gemini stage was deadline limited") + } + if !shouldWarnPartialSearch(services.SearchExecutionMeta{}, services.GeminiBatchStats{}, true, "") { + t.Fatal("expected warning when supplemental exploration was deadline limited") + } +} diff --git a/backend/services/cse.go b/backend/services/cse.go index e7f282a..9f44461 100644 --- a/backend/services/cse.go +++ b/backend/services/cse.go @@ -27,15 +27,16 @@ type SearchResult struct { } type SearchService struct { - BaseURL string - GoogleVideoEngine string - WebEngine string - Client *http.Client - collectors []searchCollector - Debug func(message string, data any) - cacheMu sync.Mutex - searchCache map[string]cachedSearchResults - fetchCache map[string]cachedFetchResult + BaseURL string + GoogleVideoEngine string + WebEngine string + Client *http.Client + collectors []searchCollector + Debug func(message string, data any) + cacheMu sync.Mutex + searchCache map[string]cachedSearchResults + fetchCache map[string]cachedFetchResult + artgridAPIBlockedUntil time.Time } type cachedSearchResults struct { @@ -48,6 +49,10 @@ type cachedFetchResult struct { expiresAt time.Time } +type SearchExecutionMeta struct { + PartialDueToDeadline bool `json:"partialDueToDeadline"` +} + func NewSearchService(baseURL, googleVideoEngine, webEngine string) *SearchService { if googleVideoEngine == "" { googleVideoEngine = "google videos" @@ -70,13 +75,14 @@ func NewSearchService(baseURL, googleVideoEngine, webEngine string) *SearchServi } } -func (s *SearchService) SearchMedia(queries []string, enabledPlatforms map[string]bool) ([]SearchResult, error) { +func (s *SearchService) SearchMedia(queries []string, enabledPlatforms map[string]bool) ([]SearchResult, SearchExecutionMeta, error) { return s.SearchMediaWithDeadline(queries, enabledPlatforms, time.Time{}) } -func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatforms map[string]bool, deadline time.Time) ([]SearchResult, error) { +func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatforms map[string]bool, deadline time.Time) ([]SearchResult, SearchExecutionMeta, error) { + meta := SearchExecutionMeta{} if s.BaseURL == "" { - return nil, fmt.Errorf("searxng base url is not configured") + return nil, meta, fmt.Errorf("searxng base url is not configured") } s.debug("search_service:start", map[string]any{ "queries": queries, @@ -94,6 +100,7 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor runSearchPass := func(bases []string, onlyMissing bool) { for _, base := range bases { if !deadline.IsZero() && time.Now().After(deadline) { + meta.PartialDueToDeadline = true s.debug("search_service:deadline_reached", map[string]any{"stage": "runSearchPass", "base": base}) return } @@ -103,6 +110,7 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor } for _, collector := range s.collectors { if !deadline.IsZero() && time.Now().After(deadline) { + meta.PartialDueToDeadline = true s.debug("search_service:deadline_reached", map[string]any{"stage": "collectorLoop", "collector": collector.Name()}) return } @@ -126,6 +134,7 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor }) for _, searchQuery := range searchQueries { if !deadline.IsZero() && time.Now().After(deadline) { + meta.PartialDueToDeadline = true s.debug("search_service:deadline_reached", map[string]any{"stage": "queryLoop", "collector": collector.Name(), "query": searchQuery}) return } @@ -171,28 +180,33 @@ func (s *SearchService) SearchMediaWithDeadline(queries []string, enabledPlatfor } if len(results) == 0 && lastErr != nil { - return nil, lastErr + return nil, meta, lastErr } sort.SliceStable(results, func(i, j int) bool { return sourceWeight(results[i].Source) > sourceWeight(results[j].Source) }) s.debug("search_service:complete", map[string]any{ - "resultCount": len(results), - "sourceCounts": sourceCounts, - "hadError": lastErr != nil, + "resultCount": len(results), + "sourceCounts": sourceCounts, + "hadError": lastErr != nil, + "partialDueToDeadline": meta.PartialDueToDeadline, }) - return s.EnrichResultsWithDeadline(results, deadline), nil + enriched, enrichMeta := s.EnrichResultsWithDeadline(results, deadline) + meta.PartialDueToDeadline = meta.PartialDueToDeadline || enrichMeta.PartialDueToDeadline + return enriched, meta, nil } func (s *SearchService) EnrichResults(results []SearchResult) []SearchResult { - return s.EnrichResultsWithDeadline(results, time.Time{}) + enriched, _ := s.EnrichResultsWithDeadline(results, time.Time{}) + return enriched } -func (s *SearchService) EnrichResultsWithDeadline(results []SearchResult, deadline time.Time) []SearchResult { +func (s *SearchService) EnrichResultsWithDeadline(results []SearchResult, deadline time.Time) ([]SearchResult, SearchExecutionMeta) { + meta := SearchExecutionMeta{} limit := minInt(len(results), 18) if limit == 0 { - return results + return results, meta } s.debug("search_service:enrich_start", map[string]any{ "total": len(results), @@ -203,12 +217,16 @@ func (s *SearchService) EnrichResultsWithDeadline(results []SearchResult, deadli copy(enriched, results) var wg sync.WaitGroup + var metaMu sync.Mutex sem := make(chan struct{}, 4) for idx := 0; idx < limit; idx++ { wg.Add(1) go func(i int) { defer wg.Done() if !deadline.IsZero() && time.Now().After(deadline) { + metaMu.Lock() + meta.PartialDueToDeadline = true + metaMu.Unlock() return } sem <- struct{}{} @@ -231,7 +249,7 @@ func (s *SearchService) EnrichResultsWithDeadline(results []SearchResult, deadli } wg.Wait() s.debug("search_service:enrich_complete", map[string]any{"limit": limit}) - return enriched + return enriched, meta } func (s *SearchService) enrichResult(result SearchResult) SearchResult { @@ -323,19 +341,32 @@ func (s *SearchService) enrichArtgrid(result SearchResult) SearchResult { s.debug("search_service:enrich_artgrid_start", map[string]any{"link": result.Link, "clipId": clipID}) apiURL := "https://artgrid.io/api/clip/details?clipId=" + clipID - body, err := s.fetchJSONText(apiURL) - if err == nil { - urls := collectURLs(body) - if !hasUsableThumbnail(result.ThumbnailURL) { - result.ThumbnailURL = pickArtgridImageURL(urls, clipID) + var err error + if s.shouldSkipArtgridAPI() { + s.debug("search_service:enrich_artgrid_api_skip", map[string]any{ + "link": result.Link, + "clipId": clipID, + "reason": "cached_403_guard", + }) + } else { + var body string + body, err = s.fetchJSONText(apiURL) + if err == nil { + urls := collectURLs(body) + if !hasUsableThumbnail(result.ThumbnailURL) { + result.ThumbnailURL = pickArtgridImageURL(urls, clipID) + } + if result.PreviewVideoURL == "" { + result.PreviewVideoURL = pickVideoURL(urls) + } } - if result.PreviewVideoURL == "" { - result.PreviewVideoURL = pickVideoURL(urls) + if err != nil { + if strings.Contains(err.Error(), "status 403") { + s.blockArtgridAPI(15 * time.Minute) + } + s.debug("search_service:enrich_artgrid_api_error", map[string]any{"link": result.Link, "clipId": clipID, "error": err.Error()}) } } - if err != nil { - s.debug("search_service:enrich_artgrid_api_error", map[string]any{"link": result.Link, "clipId": clipID, "error": err.Error()}) - } if result.ThumbnailURL == "" || result.PreviewVideoURL == "" { html, err := s.fetchText(result.Link) @@ -540,6 +571,18 @@ func (s *SearchService) setCachedFetchResult(key, body string, ttl time.Duration } } +func (s *SearchService) shouldSkipArtgridAPI() bool { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + return !s.artgridAPIBlockedUntil.IsZero() && time.Now().Before(s.artgridAPIBlockedUntil) +} + +func (s *SearchService) blockArtgridAPI(ttl time.Duration) { + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + s.artgridAPIBlockedUntil = time.Now().Add(ttl) +} + func (s *SearchService) debug(message string, data any) { if s != nil && s.Debug != nil { s.Debug(message, data) diff --git a/backend/services/cse_test.go b/backend/services/cse_test.go index 1b0927f..55bf31b 100644 --- a/backend/services/cse_test.go +++ b/backend/services/cse_test.go @@ -2,7 +2,12 @@ package services import ( "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "net/url" "strings" + "sync/atomic" "testing" "time" ) @@ -176,3 +181,58 @@ func TestSearchServiceFetchCacheRoundTrip(t *testing.T) { t.Fatalf("unexpected cached body: %q", body) } } + +func TestSearchServiceSkipsArtgridAPIAfter403(t *testing.T) { + var apiRequests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/api/clip/details"): + apiRequests.Add(1) + http.Error(w, "forbidden", http.StatusForbidden) + case strings.HasPrefix(r.URL.Path, "/clip/114756/"): + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprintf(w, `Friendly Couple | Stock Video Footage - Artgrid.io`, "114756") + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + service := NewSearchService(server.URL, "", "") + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse test server url: %v", err) + } + service.Client = &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + if clone.URL.Host == "artgrid.io" { + clone.URL.Scheme = serverURL.Scheme + clone.URL.Host = serverURL.Host + clone.Host = serverURL.Host + } + return http.DefaultTransport.RoundTrip(clone) + }), + } + + item := SearchResult{ + Link: "https://artgrid.io/clip/114756/friendly-couple", + Source: "Artgrid", + } + + first := service.enrichArtgrid(item) + second := service.enrichArtgrid(item) + + if apiRequests.Load() != 1 { + t.Fatalf("expected artgrid API to be skipped after first 403, got %d requests", apiRequests.Load()) + } + if first.Title == "" || second.Title == "" { + t.Fatalf("expected HTML fallback enrichment to preserve title, got %#v %#v", first, second) + } +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} diff --git a/backend/services/gemini.go b/backend/services/gemini.go index bb37349..ab7d360 100644 --- a/backend/services/gemini.go +++ b/backend/services/gemini.go @@ -97,6 +97,36 @@ func (g *GeminiService) ExpandQuery(query string) ([]string, error) { return expanded, nil } +func (g *GeminiService) TranslateSummaryToKorean(text string) (string, error) { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return "", nil + } + cacheKey := "summary-ko\n" + trimmed + if cached, ok := g.getCachedTranslation(cacheKey); ok { + g.debug("gemini:summary_translate_cache_hit", map[string]any{"length": len(trimmed)}) + return cached, nil + } + if !looksMostlyASCII(trimmed) { + g.setCachedTranslation(cacheKey, trimmed, 15*time.Minute) + return trimmed, nil + } + + g.debug("gemini:summary_translate_attempt", map[string]any{"length": len(trimmed)}) + translated, err := g.translateViaGoogleToTarget(trimmed, "ko") + if err != nil { + g.debug("gemini:summary_translate_error", map[string]any{"length": len(trimmed), "error": err.Error()}) + return "", err + } + translated = strings.TrimSpace(translated) + if translated == "" { + return "", fmt.Errorf("google translate summary returned empty translation") + } + g.debug("gemini:summary_translate_success", map[string]any{"length": len(trimmed)}) + g.setCachedTranslation(cacheKey, translated, 15*time.Minute) + return translated, nil +} + func (g *GeminiService) TranslateQuery(query string) string { trimmed := strings.TrimSpace(query) if trimmed == "" { @@ -784,11 +814,19 @@ func isOvercompressedTranslation(original, translated string) bool { } func (g *GeminiService) translateViaGoogle(query string) (string, error) { + return g.translateViaGoogleToTarget(query, "en") +} + +func (g *GeminiService) translateViaGoogleToTarget(query, targetLanguage string) (string, error) { baseURL := g.TranslateEndpoint if strings.TrimSpace(baseURL) == "" { baseURL = "https://translate.googleapis.com/translate_a/single" } - endpoint := baseURL + "?client=gtx&sl=auto&tl=en&dt=t&q=" + neturl.QueryEscape(query) + targetLanguage = strings.TrimSpace(targetLanguage) + if targetLanguage == "" { + targetLanguage = "en" + } + endpoint := baseURL + "?client=gtx&sl=auto&tl=" + neturl.QueryEscape(targetLanguage) + "&dt=t&q=" + neturl.QueryEscape(query) resp, err := g.Client.Get(endpoint) if err != nil { return "", err diff --git a/backend/services/gemini_test.go b/backend/services/gemini_test.go index f6fed67..77ab371 100644 --- a/backend/services/gemini_test.go +++ b/backend/services/gemini_test.go @@ -41,6 +41,35 @@ func TestTranslateQueryFallsBackToDictionaryWhenTranslateFails(t *testing.T) { } } +func TestTranslateSummaryToKoreanUsesGoogleAndCaches(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[[["도시에서 웃는 커플","smiling couple in city",null,null,1]],null,"en"]`)) + })) + defer server.Close() + + service := NewGeminiService("") + service.Client = &http.Client{Timeout: 2 * time.Second} + service.TranslateEndpoint = server.URL + + first, err := service.TranslateSummaryToKorean("smiling couple in city") + if err != nil { + t.Fatalf("expected translation to succeed, got error: %v", err) + } + second, err := service.TranslateSummaryToKorean("smiling couple in city") + if err != nil { + t.Fatalf("expected cached translation to succeed, got error: %v", err) + } + if first != "도시에서 웃는 커플" || second != first { + t.Fatalf("unexpected translated summary values: %q %q", first, second) + } + if requests != 1 { + t.Fatalf("expected one upstream translation request due to cache, got %d", requests) + } +} + func TestNormalizeKnownMediaPhrases(t *testing.T) { translated := translateKoreanMediaTerms("사이버 펑크 도시") if translated != "cyberpunk city" { diff --git a/backend/services/ranker.go b/backend/services/ranker.go index 71c3da1..d3e2e20 100644 --- a/backend/services/ranker.go +++ b/backend/services/ranker.go @@ -22,6 +22,7 @@ type GeminiBatchStats struct { SequentialRetried int `json:"sequentialRetried"` RecommendedCount int `json:"recommendedCount"` VisualRejectCount int `json:"visualRejectCount"` + DeadlineLimited bool `json:"deadlineLimited,omitempty"` Errors []string `json:"errors,omitempty"` } @@ -176,6 +177,9 @@ func EvaluateAllCandidatesWithGeminiWithDeadline(service *GeminiService, query s seen := map[string]bool{} for _, batch := range results { if batch.err != nil { + if strings.Contains(batch.err.Error(), "due to deadline") { + stats.DeadlineLimited = true + } if service != nil && service.Debug != nil { service.Debug("ranker:gemini_batch_error", map[string]any{ "batchIndex": batch.index, diff --git a/frontend/app.js b/frontend/app.js index 2067ce9..912c74e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -80,9 +80,11 @@ let cropEnd = 0; let cropMax = 0; let activeThumb = null; let activeResultItem = null; +let activeResultModalSummaryRequest = 0; const activePlatforms = new Set(["envato", "artgrid", "google video"]); const hlsInstances = new WeakMap(); const debugEntries = []; +const summaryTranslationCache = new Map(); const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview"; function proxiedPreviewURL(src) { @@ -484,10 +486,41 @@ function showResultModalGooglePanel(item, message = "") { resultModalFallbackLabel.textContent = item.source || "Preview Fallback"; resultModalGoogleImage.src = hasUsableThumbnail(item.thumbnailUrl) ? item.thumbnailUrl : PREVIEW_PLACEHOLDER; resultModalGoogleImage.alt = item.title || ""; - resultModalGoogleText.textContent = message || item.previewBlockedReason || item.snippet || item.reason || "Open source page or use the primary action."; + resultModalGoogleText.textContent = summarizeReason(message || item.previewBlockedReason || item.snippet || item.reason || "Open source page or use the primary action."); setHidden(resultModalGooglePanel, false, "flex"); } +async function translateSummaryForModal(item, originalText, requestId) { + const trimmed = String(originalText || "").trim(); + if (!trimmed) { + return; + } + if (summaryTranslationCache.has(trimmed)) { + if (activeResultItem?.link === item.link && activeResultModalSummaryRequest === requestId) { + resultModalSnippet.textContent = summaryTranslationCache.get(trimmed); + } + return; + } + try { + const data = await api("/api/translate/summary", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: trimmed }), + }); + const translated = String(data.translatedText || "").trim(); + if (!translated) { + return; + } + summaryTranslationCache.set(trimmed, translated); + if (activeResultItem?.link === item.link && activeResultModalSummaryRequest === requestId) { + resultModalSnippet.textContent = translated; + logEvent("result:modal:summary_translated", { title: item.title, source: item.source }); + } + } catch (error) { + logEvent("result:modal:summary_translate_failed", { title: item.title, source: item.source, message: error.message }); + } +} + function fallbackResultModalMedia(item, reason) { logEvent("result:modal:fallback", { title: item.title, source: item.source, reason, mediaMode: item.mediaMode }); if (item.previewVideoUrl) { @@ -582,10 +615,13 @@ function openResultModal(item) { return; } activeResultItem = item; + activeResultModalSummaryRequest += 1; + const summaryRequestId = activeResultModalSummaryRequest; resultModalTitle.textContent = item.title || "Untitled"; resultModalSource.textContent = item.source || ""; resultModalReason.textContent = summarizeReason(item.reason) || "AI 노트가 없습니다."; - resultModalSnippet.textContent = item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다."; + const originalSummary = item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다."; + resultModalSnippet.textContent = originalSummary; resultModalOpenExternal.href = item.link || "#"; resultModalDownload.classList.toggle("hidden", !item.actionType); resultModalDownload.textContent = item.actionLabel || "Open Source"; @@ -617,6 +653,7 @@ function openResultModal(item) { fallbackResultModalMedia(item, fallbackReason); } showModal(resultModal); + void translateSummaryForModal(item, item.snippet, summaryRequestId); logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link }); } @@ -627,6 +664,7 @@ function closeResultViewer() { if (!resultModal.classList.contains("hidden")) { logEvent("result:modal:close", { title: activeResultItem?.title || "" }); } + activeResultModalSummaryRequest += 1; activeResultItem = null; resetResultModalMedia(); hideModal(resultModal); diff --git a/frontend/index.html b/frontend/index.html index bf35605..ec44610 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -149,8 +149,8 @@ -