Compare commits
5 Commits
f5d76fc3ec
...
b9001b7aac
| Author | SHA1 | Date | |
|---|---|---|---|
| b9001b7aac | |||
| 40a2f817fd | |||
| 9a33ecc6b5 | |||
| 770aea0f57 | |||
| acfad750ab |
@@ -655,75 +655,27 @@
|
|||||||
- If behavior in the browser does not match the latest backend/frontend code, the first assumption should be stale frontend assets until proven otherwise
|
- 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
|
## Recent Change Log
|
||||||
- Date: `2026-03-17`
|
- Date: `2026-03-18`
|
||||||
- What changed:
|
- What changed:
|
||||||
- Switched the primary multi-candidate Gemini Vision response format away from JSON and toward a compact line-based text protocol:
|
- Reverted the recent Gemini output-format experimentation commits so the repo returns to the last simpler known-good search/Gemini state anchored by `91ee375`.
|
||||||
- `index|verdict|assessment|recommended|reason_ko|search_hint`
|
- Reverted commits:
|
||||||
- Kept the older JSON parser only as a fallback path instead of the primary success path.
|
- `f5d76fc` `Replace gemini batch JSON protocol`
|
||||||
- Reduced Gemini Vision output-token budgets again to better match the new shorter line-based format.
|
- `b6a217c` `Harden single-candidate gemini recovery`
|
||||||
- Added unit coverage for the new pipe-delimited Gemini batch parser.
|
- `3be7971` `Reduce gemini partial batch noise`
|
||||||
|
- `513199f` `Harden gemini vision JSON recovery`
|
||||||
|
- The effective runtime baseline after the rollback is now the `91ee375` state plus the newer Windows 11 PowerShell workflow work.
|
||||||
- Why it changed:
|
- Why it changed:
|
||||||
- The user-provided log `ai-media-hub-2026-03-17T08-38-47-661Z.log` still showed all Gemini batches failing with JSON output truncated almost immediately:
|
- Repeated Gemini output-format changes were still producing real user-facing failures across multiple logs, including:
|
||||||
- `"{\"recommend"`
|
- `gemini vision failed for all batches`
|
||||||
- `"{\"recommendations\":[{\""`
|
- `gemini vision partially failed on 4 of 6 batches`
|
||||||
- `"{\"recommendations\":[{\"index"`
|
- extremely short truncated payloads such as `"{\"recommend"` and `"{\"recommendations\":[{\"index"`
|
||||||
- At that point the right fix was no longer “more JSON hardening”, but removing JSON as the primary batch transport format so completed lines can be recovered even when the tail of the response is cut off.
|
- The user explicitly requested a rollback to a commit state that worked normally instead of continuing to stack more Gemini parsing experiments.
|
||||||
- How it was verified:
|
- How it was verified:
|
||||||
- `pwsh -NoProfile -File scripts/selftest.ps1`
|
- `pwsh -NoProfile -File scripts/selftest.ps1`
|
||||||
- added Go tests for line-based Gemini batch parsing
|
- local git history inspection confirmed the rollback point as the pre-experiment Gemini baseline `91ee375`
|
||||||
- What is still risky or incomplete:
|
- What is still risky or incomplete:
|
||||||
- If Gemini returns text that does not follow either the pipe-delimited format or the JSON fallback shape, parsing can still fail.
|
- This rollback intentionally gives up the later Gemini parsing experiments, so the codebase no longer contains those attempted mitigations.
|
||||||
- The model prompt is now stricter and shorter, which improves reliability, but it can make reasons more terse than before.
|
- If the original upstream Gemini truncation issue already existed before those follow-up commits, it can still reappear and would need a cleaner redesign from the reverted baseline rather than more incremental patching on top of the discarded branch.
|
||||||
|
|
||||||
- Date: `2026-03-17`
|
|
||||||
- What changed:
|
|
||||||
- Added a dedicated single-candidate Gemini recovery path that no longer asks for JSON and instead parses a tiny plain-text key/value response.
|
|
||||||
- Kept multi-candidate Gemini Vision on compact JSON, but changed sequential recovery to use the shorter plain-text format automatically through the existing `Recommend(..., []SearchResult{item})` path.
|
|
||||||
- Added unit coverage for the single-candidate plain-text parser.
|
|
||||||
- Why it changed:
|
|
||||||
- The user-provided log `ai-media-hub-2026-03-17T08-20-31-074Z.log` showed even more severe truncation:
|
|
||||||
- `"{\"recommendations\":[{\"index\":"`
|
|
||||||
- `"{\"recommendations"`
|
|
||||||
- `"{\"recommend"`
|
|
||||||
- The same log showed `sequentialRetried: 0`, which means the old single-candidate recovery path was still too verbose and was not successfully rescuing failed batches.
|
|
||||||
- How it was verified:
|
|
||||||
- `pwsh -NoProfile -File scripts/selftest.ps1`
|
|
||||||
- added Go tests for single-candidate Gemini plain-text parsing
|
|
||||||
- What is still risky or incomplete:
|
|
||||||
- If Gemini returns malformed plain text that omits the required `verdict:` line, even the single-candidate recovery path can still fail.
|
|
||||||
- This improves recovery robustness, but total Gemini latency can still rise when many batch failures fall back to candidate-by-candidate evaluation.
|
|
||||||
|
|
||||||
- Date: `2026-03-17`
|
|
||||||
- What changed:
|
|
||||||
- Added adaptive Gemini Vision output-token sizing so smaller candidate batches, especially single-candidate sequential recovery calls, now request much shorter responses.
|
|
||||||
- Added a dedicated shorter single-candidate Gemini Vision instruction path for sequential recovery after batch failure.
|
|
||||||
- Stopped counting a batch as a strong user-facing partial failure when sequential recovery still salvages recommendations from that batch.
|
|
||||||
- Added unit coverage for the adaptive Gemini Vision token budget helper.
|
|
||||||
- Why it changed:
|
|
||||||
- The user-provided log `ai-media-hub-2026-03-17T07-55-17-127Z.log` still showed `gemini vision partially failed on 4 of 6 batches`.
|
|
||||||
- The same log also showed `sequentialRetried: 0`, which means the fallback single-candidate reevaluation path was still not recovering those truncated JSON batches well enough.
|
|
||||||
- How it was verified:
|
|
||||||
- `pwsh -NoProfile -File scripts/selftest.ps1`
|
|
||||||
- added Go tests for adaptive Gemini token sizing
|
|
||||||
- What is still risky or incomplete:
|
|
||||||
- This reduces partial-failure pressure further, but extremely short or malformed Gemini outputs can still fail before one complete recommendation object is emitted.
|
|
||||||
- Smaller recovery responses improve reliability, but repeated sequential recovery can still add latency on difficult searches.
|
|
||||||
|
|
||||||
- Date: `2026-03-17`
|
|
||||||
- What changed:
|
|
||||||
- Reduced Gemini Vision batch size from `6` to `4` so each model response carries fewer recommendation objects and is less likely to be truncated mid-JSON.
|
|
||||||
- Tightened the Gemini Vision prompt to ask for shorter Korean reasons and compact JSON-only output.
|
|
||||||
- Lowered Gemini Vision `maxOutputTokens` and added partial JSON recovery so already-complete recommendation objects can still be salvaged when the model output is cut off before the final closing braces.
|
|
||||||
- Added unit coverage for truncated Gemini Vision JSON recovery.
|
|
||||||
- Why it changed:
|
|
||||||
- The user-provided log `ai-media-hub-2026-03-17T07-29-44-949Z.log` showed that visuals were prepared successfully, but every batch failed with `gemini vision JSON extraction failed: no complete JSON object found ...`.
|
|
||||||
- The failure pattern indicates output truncation rather than missing thumbnails or preview frames.
|
|
||||||
- How it was verified:
|
|
||||||
- `pwsh -NoProfile -File scripts/selftest.ps1`
|
|
||||||
- added Go tests for partial Gemini JSON recovery behavior
|
|
||||||
- What is still risky or incomplete:
|
|
||||||
- If Gemini returns severely malformed output before even one full recommendation object closes, the parser still cannot recover useful results from that batch.
|
|
||||||
- Smaller batch size improves reliability but can increase total Gemini round trips and latency on some searches.
|
|
||||||
|
|
||||||
- Date: `2026-03-17`
|
- Date: `2026-03-17`
|
||||||
- What changed:
|
- What changed:
|
||||||
|
|||||||
+32
-345
@@ -13,7 +13,6 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -246,9 +245,6 @@ func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AI
|
|||||||
if len(candidates) == 0 {
|
if len(candidates) == 0 {
|
||||||
return []AIRecommendation{}, nil
|
return []AIRecommendation{}, nil
|
||||||
}
|
}
|
||||||
if len(candidates) == 1 {
|
|
||||||
return g.recommendSingleCandidate(query, candidates[0])
|
|
||||||
}
|
|
||||||
g.debug("gemini:vision_start", map[string]any{
|
g.debug("gemini:vision_start", map[string]any{
|
||||||
"query": query,
|
"query": query,
|
||||||
"candidateCount": len(candidates),
|
"candidateCount": len(candidates),
|
||||||
@@ -257,7 +253,22 @@ func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AI
|
|||||||
type geminiPart map[string]any
|
type geminiPart map[string]any
|
||||||
parts := []geminiPart{
|
parts := []geminiPart{
|
||||||
{
|
{
|
||||||
"text": buildGeminiVisionInstruction(query, len(candidates)),
|
"text": `You are a professional video editor. Analyze whether each provided visual is suitable as a usable scene or shot for the user's requested keyword. Return JSON only in this shape:
|
||||||
|
{"recommendations":[{"index":0,"verdict":"Yes","reason":"short reason","recommended":true,"assessment":"positive","searchHint":"short english hint"}]}
|
||||||
|
Return one entry for every analyzed candidate. Use Korean for every reason. Keep reasons concise but specific enough to explain usefulness.
|
||||||
|
Set verdict to "Yes" or "No" for every candidate. "Yes" means the scene is usable and relevant for editing against the user's keyword. "No" means it is not suitable or not relevant enough.
|
||||||
|
Set recommended=true only when verdict is "Yes". Set recommended=false when verdict is "No".
|
||||||
|
Set assessment to one of: positive, unclear, irrelevant, inappropriate.
|
||||||
|
- positive: directly usable and relevant to the query
|
||||||
|
- unclear: visually ambiguous, weak, or not confident enough
|
||||||
|
- irrelevant: visibly unrelated to the query intent
|
||||||
|
- inappropriate: low-quality, spammy, misleading, meme-like, or otherwise unsuitable for professional editing
|
||||||
|
When assessment is not positive, provide searchHint as a short English stock-footage search phrase that could help find better candidates. Keep it under 8 words.
|
||||||
|
When assessment is positive, searchHint may be empty.
|
||||||
|
Prefer cinematic b-roll, stock footage, editorial footage, clean composition, usable establishing shots, and professional media thumbnails.
|
||||||
|
Avoid clickbait faces, exaggerated expressions, meme aesthetics, low-information thumbnails, sensational text overlays, or gossip-style imagery.
|
||||||
|
Favor scenes that look directly useful for professional editing, sequencing, establishing, cutaway, or mood-building usage.
|
||||||
|
User query: ` + query,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +298,6 @@ func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AI
|
|||||||
"query": query,
|
"query": query,
|
||||||
"visualCount": visualCount,
|
"visualCount": visualCount,
|
||||||
"maxImages": maxImages,
|
"maxImages": maxImages,
|
||||||
"maxOutputTokens": geminiVisionMaxOutputTokens(visualCount),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
@@ -295,9 +305,8 @@ func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AI
|
|||||||
{"parts": parts},
|
{"parts": parts},
|
||||||
},
|
},
|
||||||
"generationConfig": map[string]any{
|
"generationConfig": map[string]any{
|
||||||
"responseMimeType": "text/plain",
|
"responseMimeType": "application/json",
|
||||||
"temperature": 0.1,
|
"maxOutputTokens": 1400,
|
||||||
"maxOutputTokens": geminiVisionMaxOutputTokens(visualCount),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,17 +339,23 @@ func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AI
|
|||||||
return nil, fmt.Errorf("gemini vision returned no candidates")
|
return nil, fmt.Errorf("gemini vision returned no candidates")
|
||||||
}
|
}
|
||||||
|
|
||||||
rawText := payload.Candidates[0].Content.Parts[0].Text
|
jsonText, err := extractJSONObject(payload.Candidates[0].Content.Parts[0].Text)
|
||||||
parsed, recoveredPartial, err := parseGeminiVisionRecommendations(rawText)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("gemini vision JSON extraction failed: %w", err)
|
return nil, fmt.Errorf("gemini vision JSON extraction failed: %w", err)
|
||||||
}
|
}
|
||||||
if recoveredPartial {
|
|
||||||
g.debug("gemini:vision_partial_json_recovered", map[string]any{
|
var parsed struct {
|
||||||
"query": query,
|
Recommendations []struct {
|
||||||
"candidateCount": len(candidates),
|
Index int `json:"index"`
|
||||||
"recommendationCount": len(parsed.Recommendations),
|
Verdict string `json:"verdict"`
|
||||||
})
|
Reason string `json:"reason"`
|
||||||
|
Recommended bool `json:"recommended"`
|
||||||
|
Assessment string `json:"assessment"`
|
||||||
|
SearchHint string `json:"searchHint"`
|
||||||
|
} `json:"recommendations"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("gemini vision JSON parse failed: %w; raw=%q", err, truncateForError(payload.Candidates[0].Content.Parts[0].Text, 200))
|
||||||
}
|
}
|
||||||
|
|
||||||
recommendations := make([]AIRecommendation, 0, len(parsed.Recommendations))
|
recommendations := make([]AIRecommendation, 0, len(parsed.Recommendations))
|
||||||
@@ -372,334 +387,6 @@ func (g *GeminiService) Recommend(query string, candidates []SearchResult) ([]AI
|
|||||||
return recommendations, nil
|
return recommendations, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GeminiService) recommendSingleCandidate(query string, candidate SearchResult) ([]AIRecommendation, error) {
|
|
||||||
g.debug("gemini:vision_start", map[string]any{
|
|
||||||
"query": query,
|
|
||||||
"candidateCount": 1,
|
|
||||||
"mode": "single_candidate_recovery",
|
|
||||||
})
|
|
||||||
|
|
||||||
img, mimeType, err := g.fetchCandidateVisualInlineData(candidate)
|
|
||||||
if err != nil {
|
|
||||||
g.debug("gemini:vision_candidate_visual_error", map[string]any{
|
|
||||||
"index": 0,
|
|
||||||
"link": candidate.Link,
|
|
||||||
"source": candidate.Source,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
g.debug("gemini:vision_visuals_prepared", map[string]any{
|
|
||||||
"query": query,
|
|
||||||
"visualCount": 1,
|
|
||||||
"maxImages": 1,
|
|
||||||
"maxOutputTokens": 120,
|
|
||||||
"mode": "single_candidate_recovery",
|
|
||||||
})
|
|
||||||
|
|
||||||
body := map[string]any{
|
|
||||||
"contents": []map[string]any{
|
|
||||||
{
|
|
||||||
"parts": []map[string]any{
|
|
||||||
{
|
|
||||||
"text": `You are a professional video editor. Analyze the single provided visual for the user's keyword.
|
|
||||||
Return plain text only with exactly these 5 lines:
|
|
||||||
verdict: Yes or No
|
|
||||||
assessment: positive or unclear or irrelevant or inappropriate
|
|
||||||
recommended: true or false
|
|
||||||
reason_ko: very short Korean reason
|
|
||||||
search_hint: short English stock-footage hint or empty
|
|
||||||
No JSON. No markdown. No extra text.
|
|
||||||
User query: ` + query,
|
|
||||||
},
|
|
||||||
{"text": fmt.Sprintf("Candidate 0: title=%s source=%s link=%s", candidate.Title, candidate.Source, candidate.Link)},
|
|
||||||
{"inlineData": map[string]string{"mimeType": mimeType, "data": img}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"generationConfig": map[string]any{
|
|
||||||
"responseMimeType": "text/plain",
|
|
||||||
"temperature": 0.1,
|
|
||||||
"maxOutputTokens": 120,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
rawText, err := g.generateText(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rec, err := parseSingleCandidateVisionText(rawText)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("gemini single-candidate parse failed: %w; raw=%q", err, truncateForError(rawText, 200))
|
|
||||||
}
|
|
||||||
|
|
||||||
recommended := rec.Recommended || strings.EqualFold(strings.TrimSpace(rec.Verdict), "yes")
|
|
||||||
assessment := normalizeAssessment(rec.Assessment, recommended)
|
|
||||||
result := AIRecommendation{
|
|
||||||
Title: candidate.Title,
|
|
||||||
Link: candidate.Link,
|
|
||||||
Snippet: candidate.Snippet,
|
|
||||||
ThumbnailURL: candidate.ThumbnailURL,
|
|
||||||
PreviewVideoURL: candidate.PreviewVideoURL,
|
|
||||||
Source: candidate.Source,
|
|
||||||
Reason: normalizeKoreanReason(rec.Reason),
|
|
||||||
Recommended: recommended,
|
|
||||||
Assessment: assessment,
|
|
||||||
SearchHint: normalizeSearchHint(rec.SearchHint),
|
|
||||||
}
|
|
||||||
|
|
||||||
g.debug("gemini:vision_complete", map[string]any{
|
|
||||||
"query": query,
|
|
||||||
"recommendationCount": 1,
|
|
||||||
"mode": "single_candidate_recovery",
|
|
||||||
})
|
|
||||||
return []AIRecommendation{result}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildGeminiVisionInstruction(query string, _ int) string {
|
|
||||||
return `You are a professional video editor. Analyze whether each provided visual is suitable as a usable scene or shot for the user's requested keyword.
|
|
||||||
Return plain text only.
|
|
||||||
Return exactly one line per analyzed candidate in this exact format:
|
|
||||||
index|verdict|assessment|recommended|reason_ko|search_hint
|
|
||||||
Rules:
|
|
||||||
- index: integer candidate index
|
|
||||||
- verdict: Yes or No
|
|
||||||
- assessment: positive or unclear or irrelevant or inappropriate
|
|
||||||
- recommended: true or false
|
|
||||||
- reason_ko: very short Korean reason without line breaks and without |
|
|
||||||
- search_hint: short English stock-footage phrase or empty, without |
|
|
||||||
Do not include markdown fences, JSON, bullets, numbering, or any other text.
|
|
||||||
Prefer cinematic b-roll, stock footage, editorial footage, clean composition, usable establishing shots, and professional media thumbnails.
|
|
||||||
Avoid clickbait faces, exaggerated expressions, meme aesthetics, low-information thumbnails, sensational text overlays, or gossip-style imagery.
|
|
||||||
User query: ` + query
|
|
||||||
}
|
|
||||||
|
|
||||||
func geminiVisionMaxOutputTokens(candidateCount int) int {
|
|
||||||
switch {
|
|
||||||
case candidateCount <= 1:
|
|
||||||
return 120
|
|
||||||
case candidateCount == 2:
|
|
||||||
return 180
|
|
||||||
case candidateCount == 3:
|
|
||||||
return 240
|
|
||||||
case candidateCount == 4:
|
|
||||||
return 300
|
|
||||||
default:
|
|
||||||
return 360
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type geminiVisionParsedPayload struct {
|
|
||||||
Recommendations []struct {
|
|
||||||
Index int `json:"index"`
|
|
||||||
Verdict string `json:"verdict"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
Recommended bool `json:"recommended"`
|
|
||||||
Assessment string `json:"assessment"`
|
|
||||||
SearchHint string `json:"searchHint"`
|
|
||||||
} `json:"recommendations"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseGeminiVisionRecommendations(raw string) (geminiVisionParsedPayload, bool, error) {
|
|
||||||
if parsed, ok := parseGeminiVisionLines(raw); ok {
|
|
||||||
return parsed, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonText, err := extractJSONObject(raw)
|
|
||||||
if err == nil {
|
|
||||||
var parsed geminiVisionParsedPayload
|
|
||||||
if unmarshalErr := json.Unmarshal([]byte(jsonText), &parsed); unmarshalErr != nil {
|
|
||||||
return geminiVisionParsedPayload{}, false, fmt.Errorf("json parse failed: %w; raw=%q", unmarshalErr, truncateForError(raw, 200))
|
|
||||||
}
|
|
||||||
return parsed, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
objects := extractCompleteRecommendationObjects(raw)
|
|
||||||
if len(objects) == 0 {
|
|
||||||
return geminiVisionParsedPayload{}, false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed := geminiVisionParsedPayload{
|
|
||||||
Recommendations: make([]struct {
|
|
||||||
Index int `json:"index"`
|
|
||||||
Verdict string `json:"verdict"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
Recommended bool `json:"recommended"`
|
|
||||||
Assessment string `json:"assessment"`
|
|
||||||
SearchHint string `json:"searchHint"`
|
|
||||||
}, 0, len(objects)),
|
|
||||||
}
|
|
||||||
for _, objectText := range objects {
|
|
||||||
var item struct {
|
|
||||||
Index int `json:"index"`
|
|
||||||
Verdict string `json:"verdict"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
Recommended bool `json:"recommended"`
|
|
||||||
Assessment string `json:"assessment"`
|
|
||||||
SearchHint string `json:"searchHint"`
|
|
||||||
}
|
|
||||||
if unmarshalErr := json.Unmarshal([]byte(objectText), &item); unmarshalErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parsed.Recommendations = append(parsed.Recommendations, item)
|
|
||||||
}
|
|
||||||
if len(parsed.Recommendations) == 0 {
|
|
||||||
return geminiVisionParsedPayload{}, false, err
|
|
||||||
}
|
|
||||||
return parsed, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseGeminiVisionLines(raw string) (geminiVisionParsedPayload, bool) {
|
|
||||||
lines := strings.Split(strings.ReplaceAll(strings.TrimSpace(raw), "\r\n", "\n"), "\n")
|
|
||||||
parsed := geminiVisionParsedPayload{
|
|
||||||
Recommendations: make([]struct {
|
|
||||||
Index int `json:"index"`
|
|
||||||
Verdict string `json:"verdict"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
Recommended bool `json:"recommended"`
|
|
||||||
Assessment string `json:"assessment"`
|
|
||||||
SearchHint string `json:"searchHint"`
|
|
||||||
}, 0, len(lines)),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range lines {
|
|
||||||
trimmed := strings.TrimSpace(strings.Trim(line, "`"))
|
|
||||||
if trimmed == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(trimmed, "|", 6)
|
|
||||||
if len(parts) != 6 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
index, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parsed.Recommendations = append(parsed.Recommendations, struct {
|
|
||||||
Index int `json:"index"`
|
|
||||||
Verdict string `json:"verdict"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
Recommended bool `json:"recommended"`
|
|
||||||
Assessment string `json:"assessment"`
|
|
||||||
SearchHint string `json:"searchHint"`
|
|
||||||
}{
|
|
||||||
Index: index,
|
|
||||||
Verdict: strings.TrimSpace(parts[1]),
|
|
||||||
Assessment: strings.TrimSpace(parts[2]),
|
|
||||||
Recommended: strings.EqualFold(strings.TrimSpace(parts[3]), "true") || strings.EqualFold(strings.TrimSpace(parts[3]), "yes"),
|
|
||||||
Reason: strings.TrimSpace(parts[4]),
|
|
||||||
SearchHint: strings.TrimSpace(parts[5]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return parsed, len(parsed.Recommendations) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
type singleCandidateVisionResponse struct {
|
|
||||||
Verdict string
|
|
||||||
Assessment string
|
|
||||||
Recommended bool
|
|
||||||
Reason string
|
|
||||||
SearchHint string
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSingleCandidateVisionText(raw string) (singleCandidateVisionResponse, error) {
|
|
||||||
lines := strings.Split(strings.ReplaceAll(strings.TrimSpace(raw), "\r\n", "\n"), "\n")
|
|
||||||
result := singleCandidateVisionResponse{}
|
|
||||||
for _, line := range lines {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if trimmed == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(trimmed, ":", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.ToLower(strings.TrimSpace(parts[0]))
|
|
||||||
value := strings.TrimSpace(parts[1])
|
|
||||||
switch key {
|
|
||||||
case "verdict":
|
|
||||||
result.Verdict = value
|
|
||||||
case "assessment":
|
|
||||||
result.Assessment = value
|
|
||||||
case "recommended":
|
|
||||||
result.Recommended = strings.EqualFold(value, "true") || strings.EqualFold(value, "yes")
|
|
||||||
case "reason_ko":
|
|
||||||
result.Reason = value
|
|
||||||
case "search_hint":
|
|
||||||
result.SearchHint = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(result.Verdict) == "" {
|
|
||||||
return singleCandidateVisionResponse{}, fmt.Errorf("missing verdict line")
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractCompleteRecommendationObjects(text string) []string {
|
|
||||||
cleaned := strings.TrimSpace(text)
|
|
||||||
cleaned = strings.TrimPrefix(cleaned, "```json")
|
|
||||||
cleaned = strings.TrimPrefix(cleaned, "```")
|
|
||||||
cleaned = strings.TrimSuffix(cleaned, "```")
|
|
||||||
cleaned = strings.TrimSpace(cleaned)
|
|
||||||
|
|
||||||
recommendationsIndex := strings.Index(cleaned, `"recommendations"`)
|
|
||||||
if recommendationsIndex == -1 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
arrayStart := strings.Index(cleaned[recommendationsIndex:], "[")
|
|
||||||
if arrayStart == -1 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
arrayStart += recommendationsIndex
|
|
||||||
|
|
||||||
objects := make([]string, 0, 4)
|
|
||||||
inString := false
|
|
||||||
escaped := false
|
|
||||||
objectDepth := 0
|
|
||||||
objectStart := -1
|
|
||||||
|
|
||||||
for idx := arrayStart + 1; idx < len(cleaned); idx++ {
|
|
||||||
ch := cleaned[idx]
|
|
||||||
if escaped {
|
|
||||||
escaped = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ch == '\\' && inString {
|
|
||||||
escaped = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ch == '"' {
|
|
||||||
inString = !inString
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if inString {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch ch {
|
|
||||||
case '{':
|
|
||||||
if objectDepth == 0 {
|
|
||||||
objectStart = idx
|
|
||||||
}
|
|
||||||
objectDepth++
|
|
||||||
case '}':
|
|
||||||
if objectDepth == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
objectDepth--
|
|
||||||
if objectDepth == 0 && objectStart >= 0 {
|
|
||||||
objects = append(objects, cleaned[objectStart:idx+1])
|
|
||||||
objectStart = -1
|
|
||||||
}
|
|
||||||
case ']':
|
|
||||||
if objectDepth == 0 {
|
|
||||||
return objects
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return objects
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *GeminiService) BuildSupplementalQueries(query string, existing []string, reviewed []AIRecommendation) ([]string, error) {
|
func (g *GeminiService) BuildSupplementalQueries(query string, existing []string, reviewed []AIRecommendation) ([]string, error) {
|
||||||
baseExisting := make([]string, 0, len(existing))
|
baseExisting := make([]string, 0, len(existing))
|
||||||
for _, item := range existing {
|
for _, item := range existing {
|
||||||
|
|||||||
@@ -228,68 +228,3 @@ func TestFilterHardGeminiErrorsIgnoresLowValueVisualFailures(t *testing.T) {
|
|||||||
t.Fatalf("unexpected filtered errors: %#v", filtered)
|
t.Fatalf("unexpected filtered errors: %#v", filtered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseGeminiVisionRecommendationsRecoversCompleteObjectsFromTruncatedJSON(t *testing.T) {
|
|
||||||
raw := "{\n" +
|
|
||||||
" \"recommendations\": [\n" +
|
|
||||||
" {\"index\":0,\"verdict\":\"Yes\",\"reason\":\"적합\",\"recommended\":true,\"assessment\":\"positive\",\"searchHint\":\"\"},\n" +
|
|
||||||
" {\"index\":1,\"verdict\":\"No\",\"reason\":\"부적합\",\"recommended\":false,\"assessment\":\"irrelevant\",\"searchHint\":\"night city b-roll\"},\n" +
|
|
||||||
" {\"index\":2,\"verdict\":\"Yes\",\"reason\":\"잘림"
|
|
||||||
|
|
||||||
parsed, recoveredPartial, err := parseGeminiVisionRecommendations(raw)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected partial recovery, got error: %v", err)
|
|
||||||
}
|
|
||||||
if !recoveredPartial {
|
|
||||||
t.Fatal("expected partial recovery flag to be true")
|
|
||||||
}
|
|
||||||
if len(parsed.Recommendations) != 2 {
|
|
||||||
t.Fatalf("expected 2 recovered recommendation objects, got %#v", parsed.Recommendations)
|
|
||||||
}
|
|
||||||
if parsed.Recommendations[0].Index != 0 || parsed.Recommendations[1].Index != 1 {
|
|
||||||
t.Fatalf("unexpected recovered recommendations: %#v", parsed.Recommendations)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractCompleteRecommendationObjectsReturnsNilWhenArrayMissing(t *testing.T) {
|
|
||||||
if got := extractCompleteRecommendationObjects(`{"message":"no recommendations here"}`); len(got) != 0 {
|
|
||||||
t.Fatalf("expected no objects, got %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGeminiVisionMaxOutputTokensShrinksForSingleCandidate(t *testing.T) {
|
|
||||||
if got := geminiVisionMaxOutputTokens(1); got != 120 {
|
|
||||||
t.Fatalf("expected 120 tokens for single candidate, got %d", got)
|
|
||||||
}
|
|
||||||
if got := geminiVisionMaxOutputTokens(4); got != 300 {
|
|
||||||
t.Fatalf("expected 300 tokens for four candidates, got %d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSingleCandidateVisionTextParsesKeyValueResponse(t *testing.T) {
|
|
||||||
raw := "verdict: Yes\nassessment: positive\nrecommended: true\nreason_ko: 적합한 도시 야경\nsearch_hint: "
|
|
||||||
parsed, err := parseSingleCandidateVisionText(raw)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected parse success, got %v", err)
|
|
||||||
}
|
|
||||||
if parsed.Verdict != "Yes" || parsed.Assessment != "positive" || !parsed.Recommended {
|
|
||||||
t.Fatalf("unexpected parsed result: %#v", parsed)
|
|
||||||
}
|
|
||||||
if parsed.Reason != "적합한 도시 야경" {
|
|
||||||
t.Fatalf("unexpected reason: %#v", parsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseGeminiVisionLinesParsesPipeDelimitedRows(t *testing.T) {
|
|
||||||
raw := "0|Yes|positive|true|적합한 네온 도시|\n1|No|irrelevant|false|관련성 낮음|night city skyline"
|
|
||||||
parsed, ok := parseGeminiVisionLines(raw)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected pipe-delimited parser to succeed")
|
|
||||||
}
|
|
||||||
if len(parsed.Recommendations) != 2 {
|
|
||||||
t.Fatalf("unexpected parsed recommendations: %#v", parsed.Recommendations)
|
|
||||||
}
|
|
||||||
if parsed.Recommendations[0].Index != 0 || parsed.Recommendations[1].Index != 1 {
|
|
||||||
t.Fatalf("unexpected parsed indices: %#v", parsed.Recommendations)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ func EvaluateAllCandidatesWithGemini(service *GeminiService, query string, ranke
|
|||||||
}
|
}
|
||||||
|
|
||||||
func EvaluateAllCandidatesWithGeminiWithDeadline(service *GeminiService, query string, ranked []SearchResult, deadline time.Time) ([]AIRecommendation, GeminiBatchStats, error) {
|
func EvaluateAllCandidatesWithGeminiWithDeadline(service *GeminiService, query string, ranked []SearchResult, deadline time.Time) ([]AIRecommendation, GeminiBatchStats, error) {
|
||||||
const chunkSize = 4
|
const chunkSize = 6
|
||||||
const maxConcurrentBatches = 2
|
const maxConcurrentBatches = 2
|
||||||
if service == nil {
|
if service == nil {
|
||||||
return nil, GeminiBatchStats{}, fmt.Errorf("gemini service is not configured")
|
return nil, GeminiBatchStats{}, fmt.Errorf("gemini service is not configured")
|
||||||
@@ -198,6 +198,14 @@ func EvaluateAllCandidatesWithGeminiWithDeadline(service *GeminiService, query s
|
|||||||
seen[item.Link] = true
|
seen[item.Link] = true
|
||||||
merged = append(merged, item)
|
merged = append(merged, item)
|
||||||
}
|
}
|
||||||
|
if len(hardErrs) > 0 {
|
||||||
|
stats.Failed++
|
||||||
|
for _, recoveredErr := range hardErrs {
|
||||||
|
if len(stats.Errors) < 5 {
|
||||||
|
stats.Errors = append(stats.Errors, recoveredErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(hardErrs) == 0 {
|
if len(hardErrs) == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user