Tolerate Gemini image expansion drift
build-push / docker (push) Successful in 4m23s

This commit is contained in:
GHStaK
2026-03-24 16:29:36 +09:00
parent 1fb9919ec3
commit 3c6df2e777
6 changed files with 68 additions and 49 deletions
+14
View File
@@ -268,6 +268,20 @@
- backend debug broadcasts
## Recent Change Log
- Date: `2026-03-24`
- What changed:
- Relaxed Gemini image-query expansion parsing so loose plain-text numbered lists can still be accepted when the model prepends explanatory text instead of returning a clean JSON object.
- Removed the GIPHY image-mode search meta box from the frontend so the image UI stays visually simpler.
- Stopped surfacing the Gemini image-expansion fallback warning directly in the image-search UI when the backend can still continue with usable fallback queries.
- Why it changed:
- Real log review showed Gemini image expansion sometimes returned text like `Here is the JSON requested`, which triggered fallback even though the model output still contained useful query candidates, and the extra meta box was not adding enough value to justify the space it consumed.
- How it was verified:
- log review of `ai-media-hub-2026-03-24T07-25-42-827Z.log`
- `node --check frontend/app.js`
- What is still risky or incomplete:
- This improves tolerance for one common Gemini formatting deviation, but fully free-form model output can still fall back if it does not contain recoverable query lines.
- Go tests still could not be rerun in this environment because `go` is currently unavailable here.
- Date: `2026-03-24`
- What changed:
- Removed the redundant `GIPHY Download Dir` variable field from the Unraid template and kept the dedicated `GIPHY Downloads` path mapping as the single user-facing download-path control.
+33
View File
@@ -147,6 +147,11 @@ func (g *GeminiService) ExpandImageQueries(query string) ([]string, error) {
}
jsonText, err := extractJSONObject(rawText)
if err != nil {
if looseQueries := parseLooseImageExpansionLines(rawText); len(looseQueries) == 5 {
g.setCachedExpansion(cacheKey, looseQueries, 15*time.Minute)
g.debug("gemini:image_expand_success", map[string]any{"query": trimmed, "queries": looseQueries, "mode": "loose_text"})
return looseQueries, nil
}
g.debug("gemini:image_expand_parse_error", map[string]any{"query": trimmed, "error": err.Error()})
g.setCachedExpansion(cacheKey, fallback, 15*time.Minute)
return fallback, err
@@ -801,6 +806,34 @@ func truncateForError(text string, limit int) string {
return trimmed[:limit] + "..."
}
func parseLooseImageExpansionLines(text string) []string {
candidates := make([]string, 0, 8)
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
trimmed = strings.TrimPrefix(trimmed, "- ")
trimmed = strings.TrimPrefix(trimmed, "* ")
trimmed = strings.TrimPrefix(trimmed, "1. ")
trimmed = strings.TrimPrefix(trimmed, "2. ")
trimmed = strings.TrimPrefix(trimmed, "3. ")
trimmed = strings.TrimPrefix(trimmed, "4. ")
trimmed = strings.TrimPrefix(trimmed, "5. ")
trimmed = strings.TrimSpace(strings.Trim(trimmed, "\"'`"))
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "here is") || strings.HasPrefix(lower, "json") || strings.HasPrefix(lower, "output") {
continue
}
candidates = append(candidates, trimmed)
}
queries := normalizeImageExpansionQueries(candidates)
if len(queries) < 5 {
return nil
}
return queries[:5]
}
func normalizeKoreanReason(reason string) string {
trimmed := strings.TrimSpace(reason)
if trimmed == "" {
+20
View File
@@ -145,6 +145,26 @@ func TestExpandImageQueriesFallsBackWhenGeminiFails(t *testing.T) {
}
}
func TestExpandImageQueriesAcceptsLoosePlainTextList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"Here is the JSON requested\n1. cute cat\n2. cute cat gif\n3. cat reaction gif\n4. cat meme gif\n5. animated cat sticker"}]}}]}`))
}))
defer server.Close()
service := NewGeminiService("dummy-key", "gemini-2.5-flash")
service.Client = &http.Client{Timeout: 2 * time.Second}
service.GenerateEndpoint = server.URL
queries, err := service.ExpandImageQueries("고양이")
if err != nil {
t.Fatalf("expected loose plain-text list to be accepted, got %v", err)
}
if len(queries) != 5 || queries[0] != "cute cat" || queries[4] != "animated cat sticker" {
t.Fatalf("unexpected loose parsed queries: %#v", queries)
}
}
func TestSelectUnevaluatedCandidatesSkipsReviewedLinks(t *testing.T) {
ranked := []SearchResult{
{Link: "https://a.example"},
-1
View File
@@ -168,7 +168,6 @@ func (s *GiphyService) SearchImages(query string, requestedMax int) (GiphySearch
expandedQueries, expansionErr := s.expandQueries(response.OriginalQuery)
response.ExpandedQueries = expandedQueries
if expansionErr != nil {
response.Warning = "Query expansion failed, using fallback search terms."
s.debug("giphy:query_expansion_fallback", map[string]any{
"query": response.OriginalQuery,
"queries": expandedQueries,
+1 -35
View File
@@ -13,10 +13,6 @@ const mediaTypeToggles = Array.from(document.querySelectorAll("[data-media-type-
const platformToggles = Array.from(document.querySelectorAll("[data-platform-toggle]"));
const imageSearchSandbox = document.getElementById("imageSearchSandbox");
const imagePromptChips = Array.from(document.querySelectorAll("[data-image-prompt]"));
const giphyMetaPanel = document.getElementById("giphyMetaPanel");
const giphyOriginalQuery = document.getElementById("giphyOriginalQuery");
const giphyResultCount = document.getElementById("giphyResultCount");
const giphyExpandedQueries = document.getElementById("giphyExpandedQueries");
const dropzone = document.getElementById("dropzone");
const fileInput = document.getElementById("fileInput");
const uploadResult = document.getElementById("uploadResult");
@@ -103,7 +99,6 @@ const resultPreviewInflight = new Map();
let cardSummaryObserver = null;
let activeMediaType = "video";
const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview";
let activeImageSearchResponse = null;
function proxiedPreviewURL(src) {
if (!src) {
@@ -310,31 +305,6 @@ function renderImageEmptyState(message) {
searchResults.innerHTML = `<div class="rounded-3xl border border-white/10 bg-black/30 p-5 text-sm text-zinc-400">${message}</div>`;
}
function renderExpandedQueries(queries = []) {
giphyExpandedQueries.innerHTML = "";
for (const item of queries) {
const chip = document.createElement("span");
chip.className = "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] uppercase tracking-[0.18em] text-zinc-300";
chip.textContent = item;
giphyExpandedQueries.appendChild(chip);
}
}
function updateImageSearchMeta(data = null) {
activeImageSearchResponse = data;
const visible = Boolean(data);
setHidden(giphyMetaPanel, !visible, "block");
if (!visible) {
giphyOriginalQuery.textContent = "Original query: -";
giphyResultCount.textContent = "0 results";
giphyExpandedQueries.innerHTML = "";
return;
}
giphyOriginalQuery.textContent = `Original query: ${data.originalQuery || "-"}`;
giphyResultCount.textContent = `${Number(data.total || 0)} results`;
renderExpandedQueries(data.expandedQueries || []);
}
function renderImageResults(items = []) {
searchResults.innerHTML = "";
searchResults.classList.remove("xl:grid-cols-3");
@@ -362,7 +332,6 @@ function applyMediaTypeUI() {
const isImageMode = activeMediaType === "image";
syncMediaTypeButtons();
setHidden(imageSearchSandbox, !isImageMode, "block");
setHidden(giphyMetaPanel, true, "block");
setHidden(queryVariants, true, "");
showWarning("");
searchResultsViewport.classList.toggle("image-results-scroll", isImageMode);
@@ -376,7 +345,6 @@ function applyMediaTypeUI() {
button.classList.toggle("hidden", isImageMode);
}
if (isImageMode) {
updateImageSearchMeta(null);
setStatus("giphy image mode", 0);
renderImageEmptyState("GIPHY 검색어를 입력하면 여기에 최대 100개의 이미지/GIF 결과가 표시됩니다.");
} else {
@@ -930,13 +898,11 @@ searchForm.addEventListener("submit", async (event) => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: searchQuery.value, maxResults: 100 }),
});
updateImageSearchMeta(data);
renderImageResults(data.items || []);
showWarning(data.warning || "");
showWarning("");
logEvent("image-search:completed", { query: data.originalQuery || searchQuery.value, total: data.total || 0, expandedQueries: data.expandedQueries || [] });
setStatus("giphy search complete", 100);
} catch (error) {
updateImageSearchMeta(null);
renderImageEmptyState("GIPHY 검색 결과를 불러오지 못했습니다.");
showWarning(error.message);
setStatus("giphy search failed", 100);
-13
View File
@@ -69,19 +69,6 @@
<button type="button" class="image-prompt-chip rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-200" data-image-prompt="minimal product mockup">Product Mockup</button>
</div>
</div>
<div id="giphyMetaPanel" class="mt-4 hidden rounded-2xl border border-white/10 bg-black/30 p-4">
<div class="grid gap-3 lg:grid-cols-[1.4fr_1fr]">
<div class="space-y-2">
<p class="text-xs uppercase tracking-[0.22em] text-zinc-500">Search Meta</p>
<p id="giphyOriginalQuery" class="text-sm text-zinc-200">Original query: -</p>
<p id="giphyResultCount" class="text-sm text-zinc-400">0 results</p>
</div>
<div class="space-y-2">
<p class="text-xs uppercase tracking-[0.22em] text-zinc-500">Expanded Queries</p>
<div id="giphyExpandedQueries" class="flex flex-wrap gap-2"></div>
</div>
</div>
</div>
</div>
<div id="searchWarning" class="mt-3 hidden rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200"></div>
<div id="queryVariants" class="hidden"></div>