File size: 11,992 Bytes
2c31c5e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
import json
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Dict, List, Any

from openai import OpenAI

from caption_judge import evaluate_caption, refine_caption, select_best_caption

# Keys here MUST match the style strings that show up in tasks.json
# ("formal", "sarcastic", "humorous_tech", "humorous_non_tech").
STYLE_PROMPTS = {
    "formal": (
        "Based on the scene details below, write a formal, professional caption. "
        "Use objective language, avoid slang, and highlight the key activities and setting. "
        "Keep it to one concise sentence."
    ),
    "sarcastic": (
        "Using the objects and actions described, craft a sarcastic, witty caption. "
        "Use irony or dry humor to comment on the scene's activities. Keep it short and punchy."
    ),
    "humorous_tech": (
        "Create a playful, tech-flavored caption about this scene. Use tech metaphors, jargon, "
        "or analogies (e.g., buffering, algorithm, CPU, rendering) that connect to the objects "
        "and actions. Make it clever but still understandable."
    ),
    "humorous_non_tech": (
        "Write a funny, everyday caption using relatable analogies, puns, or observational humor. "
        "Reference the specific objects and actions in a way anyone would find amusing, even "
        "without technical knowledge."
    ),
}


def format_scene_data(data: Dict[str, Any]) -> str:
    """Format the scene JSON into a readable block for the prompt."""
    return (
        f"Scene Description: {data.get('scene', 'Not specified')}\n"
        f"Objects in Scene: {', '.join(data.get('objects', []) or [])}\n"
        f"Actions Occurring: {', '.join(data.get('actions', []) or [])}\n"
        f"Overall Mood: {data.get('mood', 'Not specified')}\n"
        f"Video Summary: {data.get('summary', 'Not specified')}\n"
        f"Audio/Transcript: {data.get('audio_transcript', 'Not specified')}"
    )


def _extract_caption_json(raw_text: str) -> str:
    """
    The model is asked to return {"caption": "..."} as JSON.
    This pulls the caption string out, tolerating code fences / stray text.
    """
    text = raw_text.strip()
    if text.startswith("```json"):
        text = text[7:]
    if text.startswith("```"):
        text = text[3:]
    if text.endswith("```"):
        text = text[:-3]
    text = text.strip()

    try:
        data = json.loads(text)
    except json.JSONDecodeError:
        match = re.search(r'\{.*\}', text, re.DOTALL)
        if not match:
            raise
        data = json.loads(match.group())

    if not isinstance(data, dict) or "caption" not in data:
        raise ValueError("JSON response missing 'caption' key")

    caption = str(data["caption"]).strip()
    if not caption:
        raise ValueError("Empty caption in JSON response")
    return caption


def generate_caption(
    style: str,
    scene_text: str,
    client: OpenAI,
    model: str,
    max_retries: int = 7,
) -> str:
    """Generate a single caption in the requested style, requesting strict JSON output."""

    system_prompt = (
        "You are a creative video caption writer. Produce captions that match the requested "
        "tone while staying true to the given scene details. "
        "Maintain the length of written caption between 25 to 60 words. "
        'Respond with ONLY a raw JSON object of the form {"caption": "your caption here"}. '
        "No markdown, no code fences, no extra commentary."
        
    )

    user_prompt = f"""{STYLE_PROMPTS[style]}

Scene details:
{scene_text}

Respond with JSON: {{"caption": "..."}}"""

    temp = 0.8 if style == "formal" else 0.7

    last_error = None
    for attempt in range(1, max_retries + 1):
        try:
            response = client.chat.completions.create(
                model=model,
                messages=[
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": user_prompt},
                ],
                temperature=temp,
                max_tokens=2000,
                timeout=30,
            )

            if response and response.choices:
                message = response.choices[0].message
                content = getattr(message, "content", None) or getattr(message, "text", None)
                if content and content.strip():
                    return _extract_caption_json(content)
            print(response)
            print(f"[caption:{style}] attempt {attempt}/{max_retries}: empty/invalid response")

        except Exception as e:
            last_error = e
            print(f"[caption:{style}] attempt {attempt}/{max_retries} error: {e}")

        if attempt < max_retries:
            time.sleep(min(0.1 * attempt, 10))

    print(f"[caption:{style}] all {max_retries} attempts failed. Last error: {last_error}")
    return f"[Fallback] {style.replace('_', ' ').title()} caption for the described scene."


def generate_and_judge_caption(
    style: str,
    scene_text: str,
    gen_client: OpenAI,
    gen_model: str,
    judge_client: OpenAI,
    judge_model: str,
    refine_client: OpenAI,
    refine_model: str,
    max_retries: int = 7,
    enable_judge: bool = True,
    max_refine_iterations: int = 2,
) -> Dict[str, Any]:
    """
    Implements:

        Generate Caption -> Judge -> PASS -> Return
                               |
                              FAIL
                               |
                               v
                            Refiner (sees every prior attempt + feedback)
                               |
                               v
                          Judge again
                               |
                               v
                    iterations exhausted?
                       No -> Refine again
                       Yes -> Best Caption Selector -> Final Caption

    The judge gives a single PASS/FAIL verdict (see caption_judge.py for why: four
    separate numeric scores added variance without changing the decision). On FAIL,
    the refiner is shown the FULL history of attempts and their feedback -- not just
    the latest one -- so it can't oscillate between reintroducing the same couple of
    mistakes. If no attempt passes before the refinement budget runs out, a dedicated
    selector call picks the strongest candidate among ALL attempts made (the last
    attempt is not assumed to be the best one -- refining to fix one problem can
    introduce another).

    gen_client/gen_model, judge_client/judge_model, and refine_client/refine_model
    are independent so each stage can use a different model.

    Returns: {"caption": str, "judged": bool, "passed": bool | None,
              "refine_iterations": int, "attempts": [{"caption": str, "feedback": str}, ...]}
    """
    caption = generate_caption(style, scene_text, gen_client, gen_model, max_retries)

    result: Dict[str, Any] = {
        "caption": caption,
        "judged": False,
        "passed": None,
        "refine_iterations": 0,
        "attempts": [],
    }

    if not enable_judge:
        return result

    attempts: List[Dict[str, str]] = []
    current_caption = caption

    for iteration in range(max_refine_iterations + 1):
        verdict = evaluate_caption(style, scene_text, current_caption, judge_client, judge_model, max_retries)
        result["judged"] = True
        attempts.append({"caption": current_caption, "feedback": verdict["feedback"]})

        if verdict["passed"]:
            result["caption"] = current_caption
            result["passed"] = True
            result["refine_iterations"] = iteration
            result["attempts"] = attempts
            return result

        if iteration >= max_refine_iterations:
            break  # refinement budget exhausted, nothing has passed yet

        print(f"[judge:{style}] attempt {iteration + 1} FAILED, refining "
              f"({iteration + 1}/{max_refine_iterations}). feedback={verdict['feedback']!r}")
        current_caption = refine_caption(
            style,
            STYLE_PROMPTS[style],
            scene_text,
            attempts,
            refine_client,
            refine_model,
            max_retries,
        )

    # Budget exhausted with no passing attempt: don't just keep the last one --
    # ask the judge to pick the strongest candidate out of everything tried.
    print(f"[judge:{style}] exhausted refine budget with no pass, selecting best of "
          f"{len(attempts)} attempts.")
    best_caption = select_best_caption(style, scene_text, attempts, judge_client, judge_model, max_retries)
    result["caption"] = best_caption
    result["passed"] = False
    result["refine_iterations"] = max_refine_iterations
    result["attempts"] = attempts
    return result


def generate_all_captions(
    json_data: Dict[str, Any],
    client: OpenAI,
    model: str,
    styles: List[str],
    max_retries: int = 7,
    judge_client: OpenAI = None,
    judge_model: str = None,
    refine_client: OpenAI = None,
    refine_model: str = None,
    enable_judge: bool = False,
    max_refine_iterations: int = 2,
    include_judge_metadata: bool = False,
) -> Dict[str, Any]:
    """Generate captions for all requested styles concurrently, optionally passing
    each one through an LLM judge + refinement loop before it's accepted.

    Each style is an independent network call to the model, so they are
    fired off in parallel threads instead of sequentially. This turns
    (N styles * per-call latency) into roughly one call's worth of wall time.

    When enable_judge=True, judge_client/judge_model and refine_client/refine_model
    default to `client`/`model` (the caption-generation ones) if not supplied, but
    can be set independently to use different models for drafting vs. judging vs.
    refining.

    Returns {style: caption_str, ...} normally, or, if include_judge_metadata=True,
    {style: {"caption": str, "passed": bool, "refine_iterations": int, ...}, ...}.
    """
    scene_text = format_scene_data(json_data)
    valid_styles = []
    for style in styles:
        if style not in STYLE_PROMPTS:
            print(f"[caption] unknown style '{style}', skipping")
            continue
        valid_styles.append(style)

    captions: Dict[str, Any] = {}
    if not valid_styles:
        return captions

    j_client = judge_client or client
    j_model = judge_model or model
    r_client = refine_client or client
    r_model = refine_model or model

    with ThreadPoolExecutor(max_workers=len(valid_styles)) as executor:
        future_to_style = {
            executor.submit(
                generate_and_judge_caption,
                style,
                scene_text,
                client,
                model,
                j_client,
                j_model,
                r_client,
                r_model,
                max_retries,
                enable_judge,
                max_refine_iterations,
            ): style
            for style in valid_styles
        }
        for future in as_completed(future_to_style):
            style = future_to_style[future]
            try:
                outcome = future.result()
                captions[style] = outcome if include_judge_metadata else outcome["caption"]
            except Exception as e:
                # generate_caption/generate_and_judge_caption already retry
                # internally and return a fallback string on failure, so this
                # is a last-resort guard.
                print(f"[caption:{style}] unexpected error in thread: {e}")
                fallback = f"[Fallback] {style.replace('_', ' ').title()} caption for the described scene."
                captions[style] = (
                    {"caption": fallback, "judged": False, "passed": None, "refine_iterations": 0, "attempts": []}
                    if include_judge_metadata
                    else fallback
                )

    return captions