Video_captioner-4_styles / caption_generator.py
sarweshp's picture
Upload 6 files
2c31c5e verified
Raw
History Blame Contribute Delete
12 kB
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