1	from __future__ import annotations
2
3	import json
4	from dataclasses import asdict, dataclass, field
5	from html import escape
6	from pathlib import Path
7	from typing import Any
8
9	from core.listings.models import ListingSnapshot
10	from core.listings.review import ApprovedListingReel
11	from core.models import MediaProbe
12
13	PROFILE_NAME = "listing_reel_instagram"
14	SCORECARD_VERSION = "2026-07-11.v1"
15	PROFILE_WEIGHTS = {
16	    "format_and_delivery": 15,
17	    "visual_composition": 25,
18	    "motion_and_transitions": 15,
19	    "narration_scene_alignment": 20,
20	    "caption_alignment_readability": 10,
21	    "branding_outro_quality": 10,
22	    "technical_polish": 5,
23	}
24
25
26	@dataclass(slots=True)
27	class ScoreIssue:
28	    code: str
29	    severity: str
30	    message: str
31	    evidence: dict[str, Any] = field(default_factory=dict)
32
33
34	@dataclass(slots=True)
35	class ScoreReport:
36	    profile: str
37	    version: str
38	    phase: str
39	    overall_score: int
40	    subscores: dict[str, int]
41	    hard_gate_warnings: list[dict[str, Any]]
42	    top_issues: list[dict[str, Any]]
43	    deterministic_checks: dict[str, Any]
44	    model_assisted_checks: dict[str, Any]
45	    recommendation: str
46	    confidence_note: str
47	    evidence: dict[str, Any]
48	    remote_review_url: str | None = None
49
50	    def to_dict(self) -> dict[str, Any]:
51	        return {
52	            "profile": self.profile,
53	            "version": self.version,
54	            "phase": self.phase,
55	            "overall_score": self.overall_score,
56	            "subscores": dict(self.subscores),
57	            "hard_gate_warnings": list(self.hard_gate_warnings),
58	            "top_issues": list(self.top_issues),
59	            "deterministic_checks": dict(self.deterministic_checks),
60	            "model_assisted_checks": dict(self.model_assisted_checks),
61	            "recommendation": self.recommendation,
62	            "confidence_note": self.confidence_note,
63	            "evidence": dict(self.evidence),
64	            "remote_review_url": self.remote_review_url,
65	        }
66
67
68	def score_pre_render(
69	    snapshot: ListingSnapshot,
70	    approved: ApprovedListingReel,
71	    *,
72	    width: int,
73	    height: int,
74	    fps: int,
75	) -> ScoreReport:
76	    warnings: list[ScoreIssue] = []
77	    issues: list[ScoreIssue] = []
78	    contact = approved.outro
79	    profile_photo_path = str(contact.get("profile_photo_path") or "").strip()
80	    has_profile_photo = bool(profile_photo_path)
81	    if not has_profile_photo:
82	        warnings.append(
83	            ScoreIssue(
84	                code="missing_profile_photo",
85	                severity="warning",
86	                message="Agent-card outro does not include a profile photo.",
87	                evidence={"field": "outro.profile_photo_path"},
88	            )
89	        )
90	    if len(approved.photos) < 3:
91	        issues.append(
92	            ScoreIssue(
93	                code="low_photo_count",
94	                severity="warning",
95	                message="Low photo count may reduce full-bleed variety and scene support.",
96	                evidence={"photo_count": len(approved.photos)},
97	            )
98	        )
99	    portrait = height > width
100	    if not portrait:
101	        warnings.append(
102	            ScoreIssue(
103	                code="non_portrait_layout",
104	                severity="warning",
105	                message="Configured reel output is not portrait.",
106	                evidence={"width": width, "height": height},
107	            )
108	        )
109
110	    subscores = {
111	        "format_and_delivery": _clamp(100 if portrait else 40),
112	        "visual_composition": _clamp(65 + min(25, len(approved.photos) * 6) + (5 if snapshot.cover_photo_index == 0 else 0)),
113	        "motion_and_transitions": _clamp(85 if fps >= 60 else 70),
114	        "narration_scene_alignment": _pre_alignment_score(snapshot, approved),
115	        "caption_alignment_readability": 75,
116	        "branding_outro_quality": _clamp(90 if has_profile_photo else 62),
117	        "technical_polish": 90,
118	    }
119	    if warnings:
120	        subscores["branding_outro_quality"] = min(subscores["branding_outro_quality"], 62)
121	    if len(approved.photos) < 3:
122	        subscores["visual_composition"] = min(subscores["visual_composition"], 68)
123
124	    return _build_report(
125	        phase="script_review",
126	        subscores=subscores,
127	        warnings=warnings,
128	        issues=issues,
129	        deterministic_checks={
130	            "portrait_expected": True,
131	            "configured_width": width,
132	            "configured_height": height,
133	            "configured_fps": fps,
134	            "photo_count": len(approved.photos),
135	            "has_profile_photo": has_profile_photo,
136	            "target_duration_seconds": approved.target_duration_seconds,
137	        },
138	        model_assisted_checks=_pending_model_assisted(),
139	        evidence={
140	            "cover_photo_path": approved.cover_photo.relative_path,
141	            "photo_order": [photo.relative_path for photo in approved.photos],
142	        },
143	    )
144
145
146	def score_post_render(
147	    snapshot: ListingSnapshot,
148	    approved: ApprovedListingReel,
149	    *,
150	    probe: MediaProbe,
151	    caption_overlay_count: int,
152	    has_profile_photo: bool,
153	) -> ScoreReport:
154	    warnings: list[ScoreIssue] = []
155	    issues: list[ScoreIssue] = []
156	    portrait = (probe.height or 0) > (probe.width or 0)
157	    if not portrait:
158	        warnings.append(
159	            ScoreIssue(
160	                code="artifact_not_portrait",
161	                severity="warning",
162	                message="Rendered artifact is not portrait.",
163	                evidence={"width": probe.width, "height": probe.height},
164	            )
165	        )
166	    if not probe.has_audio:
167	        warnings.append(
168	            ScoreIssue(
169	                code="missing_audio_stream",
170	                severity="warning",
171	                message="Rendered artifact is missing an audio stream.",
172	            )
173	        )
174	    if probe.duration_seconds < 20 or probe.duration_seconds > 45:
175	        issues.append(
176	            ScoreIssue(
177	                code="duration_out_of_band",
178	                severity="warning",
179	                message="Rendered reel duration falls outside the preferred 20–45 second range.",
180	                evidence={"duration_seconds": probe.duration_seconds},
181	            )
182	        )
183	    if not has_profile_photo:
184	        warnings.append(
185	            ScoreIssue(
186	                code="missing_profile_photo",
187	                severity="warning",
188	                message="Rendered reel relies on an outro without a configured profile photo.",
189	            )
190	        )
191	    duration_delta = abs(probe.duration_seconds - approved.target_duration_seconds)
192	    format_score = 100
193	    if not portrait:
194	        format_score -= 45
195	    if duration_delta > 5:
196	        format_score -= min(25, int(duration_delta * 2))
197	    elif duration_delta > 2:
198	        format_score -= 10
199
200	    subscores = {
201	        "format_and_delivery": _clamp(format_score),
202	        "visual_composition": _clamp(72 + min(18, len(approved.photos) * 4)),
203	        "motion_and_transitions": _clamp(88 if (probe.frame_rate or 0) >= 59 else 72),
204	        "narration_scene_alignment": _post_alignment_score(snapshot, approved),
205	        "caption_alignment_readability": _clamp(84 if caption_overlay_count else 50),
206	        "branding_outro_quality": _clamp(92 if has_profile_photo else 62),
207	        "technical_polish": _technical_score(probe),
208	    }
209	    if not probe.has_audio:
210	        subscores["technical_polish"] = min(subscores["technical_polish"], 35)
211	        subscores["caption_alignment_readability"] = min(subscores["caption_alignment_readability"], 60)
212
213	    return _build_report(
214	        phase="video_review",
215	        subscores=subscores,
216	        warnings=warnings,
217	        issues=issues,
218	        deterministic_checks={
219	            "artifact_width": probe.width,
220	            "artifact_height": probe.height,
221	            "artifact_duration_seconds": probe.duration_seconds,
222	            "artifact_frame_rate": probe.frame_rate,
223	            "artifact_video_codec": probe.video_codec,
224	            "artifact_audio_codec": probe.audio_codec,
225	            "artifact_has_audio": probe.has_audio,
226	            "caption_overlay_count": caption_overlay_count,
227	            "has_profile_photo": has_profile_photo,
228	        },
229	        model_assisted_checks=_pending_model_assisted(),
230	        evidence={"target_duration_seconds": approved.target_duration_seconds},
231	    )
232
233
234	def write_score_report(path: str | Path, report: ScoreReport) -> Path:
235	    target = Path(path)
236	    target.parent.mkdir(parents=True, exist_ok=True)
237	    target.write_text(json.dumps(report.to_dict(), indent=2), encoding="utf-8")
238	    return target
239
240
241	def build_review_page(
242	    path: str | Path,
243	    *,
244	    title: str,
245	    video_filename: str,
246	    cover_filename: str = "",
247	    pre_report: ScoreReport | None = None,
248	    post_report: ScoreReport | None = None,
249	    caption_filename: str = "caption.txt",
250	    metadata_filename: str = "metadata.json",
251	) -> Path:
252	    target = Path(path)
253	    target.parent.mkdir(parents=True, exist_ok=True)
254	    pre_html = _report_html(pre_report, "Script Review") if pre_report else ""
255	    post_html = _report_html(post_report, "Video Review") if post_report else ""
256	    cover_html = (
257	        f''
258	        if cover_filename
259	        else ""
260	    )
261	    html = f"""<!doctype html>
262
263
264	  <meta charset="utf-8" />
265	  <meta name="viewport" content="width=device-width,initial-scale=1" />
266	  <title>{escape(title)} — Reel Review</title>
267
279
280
281
282
{escape(title)}

283
Private operator review surface for the rendered reel and QA scorecards.

284

285	    <video controls playsinline preload="metadata" src="{escape(video_filename)}"></video>
286

287

288
Artifacts

289
Open video · Open caption · Open metadata

290	    {cover_html}
291

292	  {pre_html}
293	  {post_html}
294
295
296
297	"""
298	    target.write_text(html, encoding="utf-8")
299	    return target
300
301
302	def review_url_for(base_url: str, relative_review_path: str) -> str:
303	    return base_url.rstrip("/") + "/" + relative_review_path.strip("/")
304
305
306	def _build_report(
307	    *,
308	    phase: str,
309	    subscores: dict[str, int],
310	    warnings: list[ScoreIssue],
311	    issues: list[ScoreIssue],
312	    deterministic_checks: dict[str, Any],
313	    model_assisted_checks: dict[str, Any],
314	    evidence: dict[str, Any],
315	) -> ScoreReport:
316	    overall = _weighted_average(subscores)
317	    top_issues = sorted([*warnings, *issues], key=_issue_rank)[:3]
318	    recommendation = "publishable" if overall >= 85 and not warnings else "revise" if overall >= 65 else "reject"
319	    return ScoreReport(
320	        profile=PROFILE_NAME,
321	        version=SCORECARD_VERSION,
322	        phase=phase,
323	        overall_score=overall,
324	        subscores=subscores,
325	        hard_gate_warnings=[asdict(item) for item in warnings],
326	        top_issues=[asdict(item) for item in top_issues],
327	        deterministic_checks=deterministic_checks,
328	        model_assisted_checks=model_assisted_checks,
329	        recommendation=recommendation,
330	        confidence_note="V1 uses deterministic checks and simple heuristics. Model-assisted judgments are reserved but not yet active.",
331	        evidence=evidence,
332	    )
333
334
335	def _weighted_average(subscores: dict[str, int]) -> int:
336	    total = sum(PROFILE_WEIGHTS.values())
337	    weighted = sum(subscores[key] * PROFILE_WEIGHTS[key] for key in PROFILE_WEIGHTS)
338	    return round(weighted / total)
339
340
341	def _issue_rank(issue: ScoreIssue) -> tuple[int, str]:
342	    severity = {"error": 0, "warning": 1, "info": 2}.get(issue.severity, 3)
343	    return (severity, issue.code)
344
345
346	def _pending_model_assisted() -> dict[str, Any]:
347	    return {
348	        "status": "pending_future_phase",
349	        "checks": [
350	            "scene_claim_visibility",
351	            "full_bleed_vs_sparse_judgment",
352	            "transition_smoothness_judgment",
353	            "caption_interference_judgment",
354	        ],
355	    }
356
357
358	def _pre_alignment_score(snapshot: ListingSnapshot, approved: ApprovedListingReel) -> int:
359	    text = approved.narration.lower()
360	    score = 78
361	    if snapshot.beds is not None and "bed" in text:
362	        score += 6
363	    if snapshot.baths is not None and "bath" in text:
364	        score += 6
365	    if snapshot.square_feet is not None and "square feet" in text:
366	        score += 4
367	    return _clamp(score)
368
369
370	def _post_alignment_score(snapshot: ListingSnapshot, approved: ApprovedListingReel) -> int:
371	    score = _pre_alignment_score(snapshot, approved)
372	    if len(approved.photos) >= 4:
373	        score += 4
374	    return _clamp(score)
375
376
377	def _technical_score(probe: MediaProbe) -> int:
378	    score = 92
379	    if probe.video_codec != "h264":
380	        score -= 10
381	    if not probe.has_audio:
382	        score -= 50
383	    if probe.audio_codec not in {None, "aac"}:
384	        score -= 8
385	    return _clamp(score)
386
387
388	def _clamp(value: int) -> int:
389	    return max(0, min(100, int(value)))
390
391
392	def _report_html(report: ScoreReport, heading: str) -> str:
393	    subs = "".join(
394	        f"
{escape(name.replace('_', ' ').title())}	{score}
"
395	        for name, score in report.subscores.items()
396	    )
397	    issues = "".join(f"
{escape(item['message'])}
" for item in report.top_issues) or "
No major issues detected.
"
398	    return f"""
399

400
{escape(heading)}

401
{report.overall_score}

402
Recommendation: {escape(report.recommendation)}

403
{subs}

404
Top issues

405
{issues}

406

407	"""
408

from __future__ import annotations

import json
from dataclasses import asdict, dataclass, field
from html import escape
from pathlib import Path
from typing import Any

from core.listings.models import ListingSnapshot
from core.listings.review import ApprovedListingReel
from core.models import MediaProbe

PROFILE_NAME = "listing_reel_instagram"
SCORECARD_VERSION = "2026-07-11.v1"
PROFILE_WEIGHTS = {
    "format_and_delivery": 15,
    "visual_composition": 25,
    "motion_and_transitions": 15,
    "narration_scene_alignment": 20,
    "caption_alignment_readability": 10,
    "branding_outro_quality": 10,
    "technical_polish": 5,
}

@dataclass(slots=True)
class ScoreIssue:
    code: str
    severity: str
    message: str
    evidence: dict[str, Any] = field(default_factory=dict)

@dataclass(slots=True)
class ScoreReport:
    profile: str
    version: str
    phase: str
    overall_score: int
    subscores: dict[str, int]
    hard_gate_warnings: list[dict[str, Any]]
    top_issues: list[dict[str, Any]]
    deterministic_checks: dict[str, Any]
    model_assisted_checks: dict[str, Any]
    recommendation: str
    confidence_note: str
    evidence: dict[str, Any]
    remote_review_url: str | None = None

    def to_dict(self) -> dict[str, Any]:
        return {
            "profile": self.profile,
            "version": self.version,
            "phase": self.phase,
            "overall_score": self.overall_score,
            "subscores": dict(self.subscores),
            "hard_gate_warnings": list(self.hard_gate_warnings),
            "top_issues": list(self.top_issues),
            "deterministic_checks": dict(self.deterministic_checks),
            "model_assisted_checks": dict(self.model_assisted_checks),
            "recommendation": self.recommendation,
            "confidence_note": self.confidence_note,
            "evidence": dict(self.evidence),
            "remote_review_url": self.remote_review_url,
        }

def score_pre_render(
    snapshot: ListingSnapshot,
    approved: ApprovedListingReel,
    *,
    width: int,
    height: int,
    fps: int,
) -> ScoreReport:
    warnings: list[ScoreIssue] = []
    issues: list[ScoreIssue] = []
    contact = approved.outro
    profile_photo_path = str(contact.get("profile_photo_path") or "").strip()
    has_profile_photo = bool(profile_photo_path)
    if not has_profile_photo:
        warnings.append(
            ScoreIssue(
                code="missing_profile_photo",
                severity="warning",
                message="Agent-card outro does not include a profile photo.",
                evidence={"field": "outro.profile_photo_path"},
            )
        )
    if len(approved.photos) < 3:
        issues.append(
            ScoreIssue(
                code="low_photo_count",
                severity="warning",
                message="Low photo count may reduce full-bleed variety and scene support.",
                evidence={"photo_count": len(approved.photos)},
            )
        )
    portrait = height > width
    if not portrait:
        warnings.append(
            ScoreIssue(
                code="non_portrait_layout",
                severity="warning",
                message="Configured reel output is not portrait.",
                evidence={"width": width, "height": height},
            )
        )

    subscores = {
        "format_and_delivery": _clamp(100 if portrait else 40),
        "visual_composition": _clamp(65 + min(25, len(approved.photos) * 6) + (5 if snapshot.cover_photo_index == 0 else 0)),
        "motion_and_transitions": _clamp(85 if fps >= 60 else 70),
        "narration_scene_alignment": _pre_alignment_score(snapshot, approved),
        "caption_alignment_readability": 75,
        "branding_outro_quality": _clamp(90 if has_profile_photo else 62),
        "technical_polish": 90,
    }
    if warnings:
        subscores["branding_outro_quality"] = min(subscores["branding_outro_quality"], 62)
    if len(approved.photos) < 3:
        subscores["visual_composition"] = min(subscores["visual_composition"], 68)

    return _build_report(
        phase="script_review",
        subscores=subscores,
        warnings=warnings,
        issues=issues,
        deterministic_checks={
            "portrait_expected": True,
            "configured_width": width,
            "configured_height": height,
            "configured_fps": fps,
            "photo_count": len(approved.photos),
            "has_profile_photo": has_profile_photo,
            "target_duration_seconds": approved.target_duration_seconds,
        },
        model_assisted_checks=_pending_model_assisted(),
        evidence={
            "cover_photo_path": approved.cover_photo.relative_path,
            "photo_order": [photo.relative_path for photo in approved.photos],
        },
    )

def score_post_render(
    snapshot: ListingSnapshot,
    approved: ApprovedListingReel,
    *,
    probe: MediaProbe,
    caption_overlay_count: int,
    has_profile_photo: bool,
) -> ScoreReport:
    warnings: list[ScoreIssue] = []
    issues: list[ScoreIssue] = []
    portrait = (probe.height or 0) > (probe.width or 0)
    if not portrait:
        warnings.append(
            ScoreIssue(
                code="artifact_not_portrait",
                severity="warning",
                message="Rendered artifact is not portrait.",
                evidence={"width": probe.width, "height": probe.height},
            )
        )
    if not probe.has_audio:
        warnings.append(
            ScoreIssue(
                code="missing_audio_stream",
                severity="warning",
                message="Rendered artifact is missing an audio stream.",
            )
        )
    if probe.duration_seconds < 20 or probe.duration_seconds > 45:
        issues.append(
            ScoreIssue(
                code="duration_out_of_band",
                severity="warning",
                message="Rendered reel duration falls outside the preferred 20–45 second range.",
                evidence={"duration_seconds": probe.duration_seconds},
            )
        )
    if not has_profile_photo:
        warnings.append(
            ScoreIssue(
                code="missing_profile_photo",
                severity="warning",
                message="Rendered reel relies on an outro without a configured profile photo.",
            )
        )
    duration_delta = abs(probe.duration_seconds - approved.target_duration_seconds)
    format_score = 100
    if not portrait:
        format_score -= 45
    if duration_delta > 5:
        format_score -= min(25, int(duration_delta * 2))
    elif duration_delta > 2:
        format_score -= 10

    subscores = {
        "format_and_delivery": _clamp(format_score),
        "visual_composition": _clamp(72 + min(18, len(approved.photos) * 4)),
        "motion_and_transitions": _clamp(88 if (probe.frame_rate or 0) >= 59 else 72),
        "narration_scene_alignment": _post_alignment_score(snapshot, approved),
        "caption_alignment_readability": _clamp(84 if caption_overlay_count else 50),
        "branding_outro_quality": _clamp(92 if has_profile_photo else 62),
        "technical_polish": _technical_score(probe),
    }
    if not probe.has_audio:
        subscores["technical_polish"] = min(subscores["technical_polish"], 35)
        subscores["caption_alignment_readability"] = min(subscores["caption_alignment_readability"], 60)

    return _build_report(
        phase="video_review",
        subscores=subscores,
        warnings=warnings,
        issues=issues,
        deterministic_checks={
            "artifact_width": probe.width,
            "artifact_height": probe.height,
            "artifact_duration_seconds": probe.duration_seconds,
            "artifact_frame_rate": probe.frame_rate,
            "artifact_video_codec": probe.video_codec,
            "artifact_audio_codec": probe.audio_codec,
            "artifact_has_audio": probe.has_audio,
            "caption_overlay_count": caption_overlay_count,
            "has_profile_photo": has_profile_photo,
        },
        model_assisted_checks=_pending_model_assisted(),
        evidence={"target_duration_seconds": approved.target_duration_seconds},
    )

def write_score_report(path: str | Path, report: ScoreReport) -> Path:
    target = Path(path)
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(json.dumps(report.to_dict(), indent=2), encoding="utf-8")
    return target

def build_review_page(
    path: str | Path,
    *,
    title: str,
    video_filename: str,
    cover_filename: str = "",
    pre_report: ScoreReport | None = None,
    post_report: ScoreReport | None = None,
    caption_filename: str = "caption.txt",
    metadata_filename: str = "metadata.json",
) -> Path:
    target = Path(path)
    target.parent.mkdir(parents=True, exist_ok=True)
    pre_html = _report_html(pre_report, "Script Review") if pre_report else ""
    post_html = _report_html(post_report, "Video Review") if post_report else ""
    cover_html = (
        f''
        if cover_filename
        else ""
    )
    html = f"""<!doctype html>

  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>{escape(title)} — Reel Review</title>

{escape(title)}

Private operator review surface for the rendered reel and QA scorecards.

    <video controls playsinline preload="metadata" src="{escape(video_filename)}"></video>

Artifacts

Open video · Open caption · Open metadata

    {cover_html}

  {pre_html}
  {post_html}

"""
    target.write_text(html, encoding="utf-8")
    return target

def review_url_for(base_url: str, relative_review_path: str) -> str:
    return base_url.rstrip("/") + "/" + relative_review_path.strip("/")

def _build_report(
    *,
    phase: str,
    subscores: dict[str, int],
    warnings: list[ScoreIssue],
    issues: list[ScoreIssue],
    deterministic_checks: dict[str, Any],
    model_assisted_checks: dict[str, Any],
    evidence: dict[str, Any],
) -> ScoreReport:
    overall = _weighted_average(subscores)
    top_issues = sorted([*warnings, *issues], key=_issue_rank)[:3]
    recommendation = "publishable" if overall >= 85 and not warnings else "revise" if overall >= 65 else "reject"
    return ScoreReport(
        profile=PROFILE_NAME,
        version=SCORECARD_VERSION,
        phase=phase,
        overall_score=overall,
        subscores=subscores,
        hard_gate_warnings=[asdict(item) for item in warnings],
        top_issues=[asdict(item) for item in top_issues],
        deterministic_checks=deterministic_checks,
        model_assisted_checks=model_assisted_checks,
        recommendation=recommendation,
        confidence_note="V1 uses deterministic checks and simple heuristics. Model-assisted judgments are reserved but not yet active.",
        evidence=evidence,
    )

def _weighted_average(subscores: dict[str, int]) -> int:
    total = sum(PROFILE_WEIGHTS.values())
    weighted = sum(subscores[key] * PROFILE_WEIGHTS[key] for key in PROFILE_WEIGHTS)
    return round(weighted / total)

def _issue_rank(issue: ScoreIssue) -> tuple[int, str]:
    severity = {"error": 0, "warning": 1, "info": 2}.get(issue.severity, 3)
    return (severity, issue.code)

def _pending_model_assisted() -> dict[str, Any]:
    return {
        "status": "pending_future_phase",
        "checks": [
            "scene_claim_visibility",
            "full_bleed_vs_sparse_judgment",
            "transition_smoothness_judgment",
            "caption_interference_judgment",
        ],
    }

def _pre_alignment_score(snapshot: ListingSnapshot, approved: ApprovedListingReel) -> int:
    text = approved.narration.lower()
    score = 78
    if snapshot.beds is not None and "bed" in text:
        score += 6
    if snapshot.baths is not None and "bath" in text:
        score += 6
    if snapshot.square_feet is not None and "square feet" in text:
        score += 4
    return _clamp(score)

def _post_alignment_score(snapshot: ListingSnapshot, approved: ApprovedListingReel) -> int:
    score = _pre_alignment_score(snapshot, approved)
    if len(approved.photos) >= 4:
        score += 4
    return _clamp(score)

def _technical_score(probe: MediaProbe) -> int:
    score = 92
    if probe.video_codec != "h264":
        score -= 10
    if not probe.has_audio:
        score -= 50
    if probe.audio_codec not in {None, "aac"}:
        score -= 8
    return _clamp(score)

def _clamp(value: int) -> int:
    return max(0, min(100, int(value)))

def _report_html(report: ScoreReport, heading: str) -> str:
    subs = "".join(
        f"
{escape(name.replace('_', ' ').title())}	{score}
"
        for name, score in report.subscores.items()
    )
    issues = "".join(f"
{escape(item['message'])}
" for item in report.top_issues) or "
No major issues detected.
"
    return f"""

{escape(heading)}

{report.overall_score}

Recommendation: {escape(report.recommendation)}

{subs}

Top issues

{issues}

"""


Ingest Claude blog post on marketing operations automation


Now let me read the `listing_reel_modules.py` to understand the render and video review modules.


1	from __future__ import annotations
2
3	import json
4	from pathlib import Path
5	from typing import Any
6
7	from core.listings.manifest import listing_snapshot_from_dict, load_listing_source, source_policy_for
8	from core.listings.models import AgentProfile
9	from core.listings.review import build_review_bundle, parse_approved_review, snapshot_digest
10	from core.listings.scorecard import (
11	    PROFILE_NAME,
12	    build_review_page,
13	    review_url_for,
14	    score_post_render,
15	    score_pre_render,
16	    write_score_report,
17	)
18	from core.media.ffmpeg_utils import probe_media
19	from core.media.listing_graphics import create_contact_outro, create_listing_cover
20	from core.media.photo_reel import build_photo_reel_command, run_photo_reel
21	from core.workflows.runner import WorkflowContext
22
23
24	class ListingIntakeModule:
25	    def run(self, context: WorkflowContext, params: dict) -> dict:
26	        listing_url = str(context.data.get("listing_url") or "")
27	        manifest_path = context.data.get("listing_manifest_path")
28	        default_root = (
29	            str(Path(str(manifest_path)).parent)
30	            if manifest_path
31	            else str(context.workspace / "listing_media")
32	        )
33	        media_root = str(context.data.get("listing_media_root") or default_root)
34	        fetcher = None
35	        if not manifest_path:
36	            from core.listings.providers.registry import build_authorized_fetcher
37
38	            Path(media_root).mkdir(parents=True, exist_ok=True)
39	            fetcher = build_authorized_fetcher(
40	                media_root=media_root,
41	                demo=bool(context.data.get("listing_demo_intake")),
42	                rights_confirmed=bool(context.data.get("listing_rights_confirmed")),
43	            )
44	        snapshot = load_listing_source(
45	            listing_url,
46	            manifest_path=manifest_path,
47	            media_root=media_root,
48	            fetcher=fetcher,
49	        )
50	        snapshot_path = context.workspace / "listing_snapshot.json"
51	        snapshot_path.write_text(json.dumps(snapshot.to_dict(), indent=2), encoding="utf-8")
52	        return {
53	            "listing_snapshot_path": str(snapshot_path),
54	            "listing_snapshot_sha256": snapshot_digest(snapshot),
55	            "listing_media_root": media_root,
56	            "listing_source_policy": source_policy_for(listing_url).value,
57	            "listing_source_provider": snapshot.source_provider,
58	        }
59
60
61	class ListingReviewDraftModule:
62	    def run(self, context: WorkflowContext, params: dict) -> dict:
63	        snapshot = _load_snapshot(context)
64	        agent = AgentProfile.from_mapping(
65	            {
66	                "name": context.data.get("agent_name"),
67	                "instagram_handle": context.data.get("agent_instagram_handle"),
68	                "phone": context.data.get("agent_phone"),
69	                "email": context.data.get("agent_email"),
70	                "website": context.data.get("agent_website"),
71	                "voice_owner": context.data.get("voice_owner"),
72	                "profile_photo_path": context.data.get("agent_profile_photo"),
73	            }
74	        )
75	        bundle = build_review_bundle(
76	            snapshot,
77	            agent,
78	            target_duration_seconds=float(
79	                context.data.get("target_duration_seconds")
80	                or params.get("target_duration_seconds", 30)
81	            ),
82	        )
83	        draft = json.dumps(bundle, indent=2)
84	        draft_path = context.workspace / "listing_review_draft.json"
85	        draft_path.write_text(draft, encoding="utf-8")
86	        return {
87	            "generated_script": draft,
88	            "listing_review_draft_path": str(draft_path),
89	        }
90
91
92	class ListingReviewApprovalModule:
93	    def run(self, context: WorkflowContext, params: dict) -> dict:
94	        snapshot = _load_snapshot(context)
95	        approved = parse_approved_review(
96	            str(context.data.get("approved_script") or ""),
97	            snapshot,
98	            media_root=context.data["listing_media_root"],
99	        )
100	        approved_path = context.workspace / "approved_listing_reel.normalized.json"
101	        approved_payload = {
102	            "narration": approved.narration,
103	            "instagram_caption": approved.instagram_caption,
104	            "photo_order": [photo.relative_path for photo in approved.photos],
105	            "cover_photo_path": approved.cover_photo.relative_path,
106	            "outro": approved.outro,
107	            "target_duration_seconds": approved.target_duration_seconds,
108	        }
109	        approved_path.write_text(json.dumps(approved_payload, indent=2), encoding="utf-8")
110	        return {
111	            "approved_narration": approved.narration,
112	            "approved_caption": approved.instagram_caption,
113	            "approved_photo_paths": [str(photo.path) for photo in approved.photos],
114	            "approved_cover_photo_path": str(approved.cover_photo.path),
115	            "approved_outro": approved.outro,
116	            "target_duration_seconds": approved.target_duration_seconds,
117	            "approved_listing_reel_path": str(approved_path),
118	        }
119
120
121	class ListingGraphicsModule:
122	    def run(self, context: WorkflowContext, params: dict) -> dict:
123	        snapshot = _load_snapshot(context)
124	        cover_source = Path(context.data["approved_cover_photo_path"])
125	        cover_photo = next((photo for photo in snapshot.photos if photo.path == cover_source), None)
126	        if cover_photo is None:
127	            raise ValueError("Approved cover photo is not part of the listing snapshot")
128	        fact_parts = []
129	        if snapshot.beds is not None:
130	            fact_parts.append(f"{snapshot.beds:g} bed")
131	        if snapshot.baths is not None:
132	            fact_parts.append(f"{snapshot.baths:g} bath")
133	        if snapshot.square_feet is not None:
134	            fact_parts.append(f"{snapshot.square_feet:,} sq ft")
135	        locality = ", ".join(part for part in (snapshot.city, snapshot.state) if part)
136	        title = snapshot.address or snapshot.display_location or "Featured property"
137	        subtitle_parts = ([] if title == locality else ([locality] if locality else [])) + fact_parts
138	        subtitle = " · ".join(subtitle_parts)
139	        price = f"${snapshot.price:,}" if snapshot.price is not None else ""
140	        cover_path = create_listing_cover(
141	            cover_source,
142	            context.workspace / "cover.jpg",
143	            title=title,
144	            subtitle=subtitle,
145	            price=price,
146	            focal_point=cover_photo.focal_point,
147	        )
148
149	        outro = dict(context.data.get("approved_outro") or {})
150	        outro_path = create_contact_outro(
151	            context.workspace / "outro.png",
152	            agent_name=str(outro.get("agent_name") or ""),
153	            cta=str(outro.get("cta") or "Schedule a private tour"),
154	            instagram_handle=str(outro.get("instagram_handle") or ""),
155	            phone=str(outro.get("phone") or ""),
156	            email=str(outro.get("email") or ""),
157	            website=str(outro.get("website") or ""),
158	            profile_photo_path=str(outro.get("profile_photo_path") or ""),
159	        )
160	        return {"cover_image_path": str(cover_path), "outro_image_path": str(outro_path)}
161
162
163	class ListingScriptReviewModule:
164	    def run(self, context: WorkflowContext, params: dict) -> dict:
165	        snapshot = _load_snapshot(context)
166	        approved = _approved_reel(context)
167	        width = int(params.get("width", 1080))
168	        height = int(params.get("height", 1920))
169	        fps = int(params.get("fps", 60))
170	        report = score_pre_render(snapshot, approved, width=width, height=height, fps=fps)
171	        qa_dir = context.workspace / "qa"
172	        report_path = write_score_report(qa_dir / "script_review.json", report)
173	        return {
174	            "reel_profile": PROFILE_NAME,
175	            "script_review_path": str(report_path),
176	            "script_review": report.to_dict(),
177	            "pre_render_score_path": str(report_path),
178	            "pre_render_score": report.to_dict(),
179	        }
180
181
182	class ListingPhotoReelRenderModule:
183	    def run(self, context: WorkflowContext, params: dict) -> dict:
184	        _load_snapshot(context)  # Re-verifies approved media hashes immediately before render.
185	        narration_duration = float(
186	            context.data.get("narration_duration_seconds")
187	            or context.data.get("target_duration_seconds")
188	            or 30
189	        )
190	        outro_duration = float(params.get("outro_duration_seconds", 4.5))
191	        target_duration = float(context.data.get("target_duration_seconds") or 30)
192	        total_duration = min(
193	            45.0,
194	            max(20.0, target_duration, narration_duration + outro_duration),
195	        )
196	        output_path = context.workspace / "final_video.mp4"
197	        command = build_photo_reel_command(
198	            photo_paths=context.data.get("approved_photo_paths") or [],
199	            narration_audio=Path(context.data["narration_path"]),
200	            outro_image=Path(context.data["outro_image_path"]),
201	            caption_overlays=context.data.get("caption_overlays") or [],
202	            output_video=output_path,
203	            total_duration_seconds=total_duration,
204	            outro_duration_seconds=outro_duration,
205	            width=int(params.get("width", 1080)),
206	            height=int(params.get("height", 1920)),
207	            fps=int(params.get("fps", 60)),
208	        )
209	        command_path = context.workspace / "render_command.txt"
210	        command_path.write_text(" ".join(command), encoding="utf-8")
211	        result = {
212	            "output_video": str(output_path),
213	            "render_command": command,
214	            "render_command_path": str(command_path),
215	            "render_executed": False,
216	            "output_duration_seconds": total_duration,
217	        }
218	        if bool(context.data.get("execute_render", False)):
219	            run_photo_reel(command)
220	            probe = probe_media(output_path)
221	            result.update(
222	                {
223	                    "render_executed": True,
224	                    "rendered_duration_seconds": probe.duration_seconds,
225	                    "rendered_width": probe.width,
226	                    "rendered_height": probe.height,
227	                    "rendered_frame_rate": probe.frame_rate,
228	                    "rendered_video_codec": probe.video_codec,
229	                    "rendered_audio_codec": probe.audio_codec,
230	                }
231	            )
232	        return result
233
234
235	class ListingVideoReviewModule:
236	    def run(self, context: WorkflowContext, params: dict) -> dict:
237	        snapshot = _load_snapshot(context)
238	        approved = _approved_reel(context)
239	        output_video = Path(context.data["output_video"])
240	        probe = probe_media(output_video)
241	        has_profile_photo = bool(str((context.data.get("approved_outro") or {}).get("profile_photo_path") or "").strip())
242	        report = score_post_render(
243	            snapshot,
244	            approved,
245	            probe=probe,
246	            caption_overlay_count=len(context.data.get("caption_overlays") or []),
247	            has_profile_photo=has_profile_photo,
248	        )
249	        qa_dir = context.workspace / "qa"
250	        report_path = write_score_report(qa_dir / "video_review.json", report)
251	        return {
252	            "video_review_path": str(report_path),
253	            "video_review": report.to_dict(),
254	            "post_render_score_path": str(report_path),
255	            "post_render_score": report.to_dict(),
256	        }
257
258
259	class ListingReviewArtifactsModule:
260	    def run(self, context: WorkflowContext, params: dict) -> dict:
261	        snapshot = _load_snapshot(context)
262	        publish_dir = context.workspace / "publish"
263	        publish_dir.mkdir(parents=True, exist_ok=True)
264	        review_base_url = str(context.data.get("review_base_url") or "").strip()
265	        pre_report = context.data.get("script_review") or context.data.get("pre_render_score") or {}
266	        post_report = context.data.get("video_review") or context.data.get("post_render_score") or {}
267
268	        pre_report_path = _copy_optional(
269	            Path(str(context.data.get("script_review_path") or context.data.get("pre_render_score_path") or "")),
270	            publish_dir / "script_review.json",
271	        )
272	        post_report_path = _copy_optional(
273	            Path(str(context.data.get("video_review_path") or context.data.get("post_render_score_path") or "")),
274	            publish_dir / "video_review.json",
275	        )
276
277	        review_page = build_review_page(
278	            publish_dir / "review.html",
279	            title=str(snapshot.address or context.data.get("agent_name") or "Listing Reel Review"),
280	            video_filename="final_video.mp4",
281	            cover_filename=Path(str(context.data.get("cover_image_path") or "")).name if context.data.get("cover_image_path") else "",
282	            pre_report=_report_from_dict(pre_report) if pre_report else None,
283	            post_report=_report_from_dict(post_report) if post_report else None,
284	        )
285	        review_url = None
286	        if review_base_url:
287	            relative = review_page.relative_to(context.workspace).as_posix()
288	            review_url = review_url_for(review_base_url, relative)
289	        return {
290	            "review_page_path": str(review_page),
291	            "review_page_url": review_url,
292	            "script_review_publish_path": str(pre_report_path) if pre_report_path else None,
293	            "video_review_publish_path": str(post_report_path) if post_report_path else None,
294	            "pre_render_score_publish_path": str(pre_report_path) if pre_report_path else None,
295	            "post_render_score_publish_path": str(post_report_path) if post_report_path else None,
296	        }
297
298
299	ListingPreRenderScoreModule = ListingScriptReviewModule
300	ListingPostRenderScoreModule = ListingVideoReviewModule
301
302
303	def _load_snapshot(context: WorkflowContext):
304	    raw = json.loads(Path(context.data["listing_snapshot_path"]).read_text(encoding="utf-8"))
305	    return listing_snapshot_from_dict(raw, media_root=context.data["listing_media_root"], verify_media=True)
306
307
308	def _approved_reel(context: WorkflowContext):
309	    from core.listings.review import ApprovedListingReel
310
311	    snapshot = _load_snapshot(context)
312	    by_path = {photo.relative_path: photo for photo in snapshot.photos}
313	    approved_path = Path(context.data["approved_listing_reel_path"])
314	    raw = json.loads(approved_path.read_text(encoding="utf-8"))
315	    photo_order = [by_path[path] for path in raw.get("photo_order", []) if path in by_path]
316	    cover_photo = by_path[str(raw.get("cover_photo_path") or snapshot.cover_photo.relative_path)]
317	    return ApprovedListingReel(
318	        narration=str(raw.get("narration") or ""),
319	        instagram_caption=str(raw.get("instagram_caption") or ""),
320	        photos=tuple(photo_order),
321	        cover_photo=cover_photo,
322	        outro=dict(raw.get("outro") or {}),
323	        target_duration_seconds=float(raw.get("target_duration_seconds") or 30.0),
324	    )
325
326
327	def _copy_optional(source: Path, target: Path) -> Path | None:
328	    if not source or str(source) == "." or not source.exists():
329	        return None
330	    target.write_bytes(source.read_bytes())
331	    return target
332
333
334	def _report_from_dict(raw: dict) -> Any:
335	    from core.listings.scorecard import ScoreReport
336
337	    return ScoreReport(
338	        profile=str(raw.get("profile") or PROFILE_NAME),
339	        version=str(raw.get("version") or ""),
340	        phase=str(raw.get("phase") or ""),
341	        overall_score=int(raw.get("overall_score") or 0),
342	        subscores=dict(raw.get("subscores") or {}),
343	        hard_gate_warnings=list(raw.get("hard_gate_warnings") or []),
344	        top_issues=list(raw.get("top_issues") or []),
345	        deterministic_checks=dict(raw.get("deterministic_checks") or {}),
346	        model_assisted_checks=dict(raw.get("model_assisted_checks") or {}),
347	        recommendation=str(raw.get("recommendation") or ""),
348	        confidence_note=str(raw.get("confidence_note") or ""),
349	        evidence=dict(raw.get("evidence") or {}),
350	        remote_review_url=raw.get("remote_review_url"),
351	    )
352

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from core.listings.manifest import listing_snapshot_from_dict, load_listing_source, source_policy_for
from core.listings.models import AgentProfile
from core.listings.review import build_review_bundle, parse_approved_review, snapshot_digest
from core.listings.scorecard import (
    PROFILE_NAME,
    build_review_page,
    review_url_for,
    score_post_render,
    score_pre_render,
    write_score_report,
)
from core.media.ffmpeg_utils import probe_media
from core.media.listing_graphics import create_contact_outro, create_listing_cover
from core.media.photo_reel import build_photo_reel_command, run_photo_reel
from core.workflows.runner import WorkflowContext

class ListingIntakeModule:
    def run(self, context: WorkflowContext, params: dict) -> dict:
        listing_url = str(context.data.get("listing_url") or "")
        manifest_path = context.data.get("listing_manifest_path")
        default_root = (
            str(Path(str(manifest_path)).parent)
            if manifest_path
            else str(context.workspace / "listing_media")
        )
        media_root = str(context.data.get("listing_media_root") or default_root)
        fetcher = None
        if not manifest_path:
            from core.listings.providers.registry import build_authorized_fetcher

            Path(media_root).mkdir(parents=True, exist_ok=True)
            fetcher = build_authorized_fetcher(
                media_root=media_root,
                demo=bool(context.data.get("listing_demo_intake")),
                rights_confirmed=bool(context.data.get("listing_rights_confirmed")),
            )
        snapshot = load_listing_source(
            listing_url,
            manifest_path=manifest_path,
            media_root=media_root,
            fetcher=fetcher,
        )
        snapshot_path = context.workspace / "listing_snapshot.json"
        snapshot_path.write_text(json.dumps(snapshot.to_dict(), indent=2), encoding="utf-8")
        return {
            "listing_snapshot_path": str(snapshot_path),
            "listing_snapshot_sha256": snapshot_digest(snapshot),
            "listing_media_root": media_root,
            "listing_source_policy": source_policy_for(listing_url).value,
            "listing_source_provider": snapshot.source_provider,
        }

class ListingReviewDraftModule:
    def run(self, context: WorkflowContext, params: dict) -> dict:
        snapshot = _load_snapshot(context)
        agent = AgentProfile.from_mapping(
            {
                "name": context.data.get("agent_name"),
                "instagram_handle": context.data.get("agent_instagram_handle"),
                "phone": context.data.get("agent_phone"),
                "email": context.data.get("agent_email"),
                "website": context.data.get("agent_website"),
                "voice_owner": context.data.get("voice_owner"),
                "profile_photo_path": context.data.get("agent_profile_photo"),
            }
        )
        bundle = build_review_bundle(
            snapshot,
            agent,
            target_duration_seconds=float(
                context.data.get("target_duration_seconds")
                or params.get("target_duration_seconds", 30)
            ),
        )
        draft = json.dumps(bundle, indent=2)
        draft_path = context.workspace / "listing_review_draft.json"
        draft_path.write_text(draft, encoding="utf-8")
        return {
            "generated_script": draft,
            "listing_review_draft_path": str(draft_path),
        }

class ListingReviewApprovalModule:
    def run(self, context: WorkflowContext, params: dict) -> dict:
        snapshot = _load_snapshot(context)
        approved = parse_approved_review(
            str(context.data.get("approved_script") or ""),
            snapshot,
            media_root=context.data["listing_media_root"],
        )
        approved_path = context.workspace / "approved_listing_reel.normalized.json"
        approved_payload = {
            "narration": approved.narration,
            "instagram_caption": approved.instagram_caption,
            "photo_order": [photo.relative_path for photo in approved.photos],
            "cover_photo_path": approved.cover_photo.relative_path,
            "outro": approved.outro,
            "target_duration_seconds": approved.target_duration_seconds,
        }
        approved_path.write_text(json.dumps(approved_payload, indent=2), encoding="utf-8")
        return {
            "approved_narration": approved.narration,
            "approved_caption": approved.instagram_caption,
            "approved_photo_paths": [str(photo.path) for photo in approved.photos],
            "approved_cover_photo_path": str(approved.cover_photo.path),
            "approved_outro": approved.outro,
            "target_duration_seconds": approved.target_duration_seconds,
            "approved_listing_reel_path": str(approved_path),
        }

class ListingGraphicsModule:
    def run(self, context: WorkflowContext, params: dict) -> dict:
        snapshot = _load_snapshot(context)
        cover_source = Path(context.data["approved_cover_photo_path"])
        cover_photo = next((photo for photo in snapshot.photos if photo.path == cover_source), None)
        if cover_photo is None:
            raise ValueError("Approved cover photo is not part of the listing snapshot")
        fact_parts = []
        if snapshot.beds is not None:
            fact_parts.append(f"{snapshot.beds:g} bed")
        if snapshot.baths is not None:
            fact_parts.append(f"{snapshot.baths:g} bath")
        if snapshot.square_feet is not None:
            fact_parts.append(f"{snapshot.square_feet:,} sq ft")
        locality = ", ".join(part for part in (snapshot.city, snapshot.state) if part)
        title = snapshot.address or snapshot.display_location or "Featured property"
        subtitle_parts = ([] if title == locality else ([locality] if locality else [])) + fact_parts
        subtitle = " · ".join(subtitle_parts)
        price = f"${snapshot.price:,}" if snapshot.price is not None else ""
        cover_path = create_listing_cover(
            cover_source,
            context.workspace / "cover.jpg",
            title=title,
            subtitle=subtitle,
            price=price,
            focal_point=cover_photo.focal_point,
        )

        outro = dict(context.data.get("approved_outro") or {})
        outro_path = create_contact_outro(
            context.workspace / "outro.png",
            agent_name=str(outro.get("agent_name") or ""),
            cta=str(outro.get("cta") or "Schedule a private tour"),
            instagram_handle=str(outro.get("instagram_handle") or ""),
            phone=str(outro.get("phone") or ""),
            email=str(outro.get("email") or ""),
            website=str(outro.get("website") or ""),
            profile_photo_path=str(outro.get("profile_photo_path") or ""),
        )
        return {"cover_image_path": str(cover_path), "outro_image_path": str(outro_path)}

class ListingScriptReviewModule:
    def run(self, context: WorkflowContext, params: dict) -> dict:
        snapshot = _load_snapshot(context)
        approved = _approved_reel(context)
        width = int(params.get("width", 1080))
        height = int(params.get("height", 1920))
        fps = int(params.get("fps", 60))
        report = score_pre_render(snapshot, approved, width=width, height=height, fps=fps)
        qa_dir = context.workspace / "qa"
        report_path = write_score_report(qa_dir / "script_review.json", report)
        return {
            "reel_profile": PROFILE_NAME,
            "script_review_path": str(report_path),
            "script_review": report.to_dict(),
            "pre_render_score_path": str(report_path),
            "pre_render_score": report.to_dict(),
        }

class ListingPhotoReelRenderModule:
    def run(self, context: WorkflowContext, params: dict) -> dict:
        _load_snapshot(context)  # Re-verifies approved media hashes immediately before render.
        narration_duration = float(
            context.data.get("narration_duration_seconds")
            or context.data.get("target_duration_seconds")
            or 30
        )
        outro_duration = float(params.get("outro_duration_seconds", 4.5))
        target_duration = float(context.data.get("target_duration_seconds") or 30)
        total_duration = min(
            45.0,
            max(20.0, target_duration, narration_duration + outro_duration),
        )
        output_path = context.workspace / "final_video.mp4"
        command = build_photo_reel_command(
            photo_paths=context.data.get("approved_photo_paths") or [],
            narration_audio=Path(context.data["narration_path"]),
            outro_image=Path(context.data["outro_image_path"]),
            caption_overlays=context.data.get("caption_overlays") or [],
            output_video=output_path,
            total_duration_seconds=total_duration,
            outro_duration_seconds=outro_duration,
            width=int(params.get("width", 1080)),
            height=int(params.get("height", 1920)),
            fps=int(params.get("fps", 60)),
        )
        command_path = context.workspace / "render_command.txt"
        command_path.write_text(" ".join(command), encoding="utf-8")
        result = {
            "output_video": str(output_path),
            "render_command": command,
            "render_command_path": str(command_path),
            "render_executed": False,
            "output_duration_seconds": total_duration,
        }
        if bool(context.data.get("execute_render", False)):
            run_photo_reel(command)
            probe = probe_media(output_path)
            result.update(
                {
                    "render_executed": True,
                    "rendered_duration_seconds": probe.duration_seconds,
                    "rendered_width": probe.width,
                    "rendered_height": probe.height,
                    "rendered_frame_rate": probe.frame_rate,
                    "rendered_video_codec": probe.video_codec,
                    "rendered_audio_codec": probe.audio_codec,
                }
            )
        return result

class ListingVideoReviewModule:
    def run(self, context: WorkflowContext, params: dict) -> dict:
        snapshot = _load_snapshot(context)
        approved = _approved_reel(context)
        output_video = Path(context.data["output_video"])
        probe = probe_media(output_video)
        has_profile_photo = bool(str((context.data.get("approved_outro") or {}).get("profile_photo_path") or "").strip())
        report = score_post_render(
            snapshot,
            approved,
            probe=probe,
            caption_overlay_count=len(context.data.get("caption_overlays") or []),
            has_profile_photo=has_profile_photo,
        )
        qa_dir = context.workspace / "qa"
        report_path = write_score_report(qa_dir / "video_review.json", report)
        return {
            "video_review_path": str(report_path),
            "video_review": report.to_dict(),
            "post_render_score_path": str(report_path),
            "post_render_score": report.to_dict(),
        }

class ListingReviewArtifactsModule:
    def run(self, context: WorkflowContext, params: dict) -> dict:
        snapshot = _load_snapshot(context)
        publish_dir = context.workspace / "publish"
        publish_dir.mkdir(parents=True, exist_ok=True)
        review_base_url = str(context.data.get("review_base_url") or "").strip()
        pre_report = context.data.get("script_review") or context.data.get("pre_render_score") or {}
        post_report = context.data.get("video_review") or context.data.get("post_render_score") or {}

        pre_report_path = _copy_optional(
            Path(str(context.data.get("script_review_path") or context.data.get("pre_render_score_path") or "")),
            publish_dir / "script_review.json",
        )
        post_report_path = _copy_optional(
            Path(str(context.data.get("video_review_path") or context.data.get("post_render_score_path") or "")),
            publish_dir / "video_review.json",
        )

        review_page = build_review_page(
            publish_dir / "review.html",
            title=str(snapshot.address or context.data.get("agent_name") or "Listing Reel Review"),
            video_filename="final_video.mp4",
            cover_filename=Path(str(context.data.get("cover_image_path") or "")).name if context.data.get("cover_image_path") else "",
            pre_report=_report_from_dict(pre_report) if pre_report else None,
            post_report=_report_from_dict(post_report) if post_report else None,
        )
        review_url = None
        if review_base_url:
            relative = review_page.relative_to(context.workspace).as_posix()
            review_url = review_url_for(review_base_url, relative)
        return {
            "review_page_path": str(review_page),
            "review_page_url": review_url,
            "script_review_publish_path": str(pre_report_path) if pre_report_path else None,
            "video_review_publish_path": str(post_report_path) if post_report_path else None,
            "pre_render_score_publish_path": str(pre_report_path) if pre_report_path else None,
            "post_render_score_publish_path": str(post_report_path) if post_report_path else None,
        }

ListingPreRenderScoreModule = ListingScriptReviewModule
ListingPostRenderScoreModule = ListingVideoReviewModule

def _load_snapshot(context: WorkflowContext):
    raw = json.loads(Path(context.data["listing_snapshot_path"]).read_text(encoding="utf-8"))
    return listing_snapshot_from_dict(raw, media_root=context.data["listing_media_root"], verify_media=True)

def _approved_reel(context: WorkflowContext):
    from core.listings.review import ApprovedListingReel

    snapshot = _load_snapshot(context)
    by_path = {photo.relative_path: photo for photo in snapshot.photos}
    approved_path = Path(context.data["approved_listing_reel_path"])
    raw = json.loads(approved_path.read_text(encoding="utf-8"))
    photo_order = [by_path[path] for path in raw.get("photo_order", []) if path in by_path]
    cover_photo = by_path[str(raw.get("cover_photo_path") or snapshot.cover_photo.relative_path)]
    return ApprovedListingReel(
        narration=str(raw.get("narration") or ""),
        instagram_caption=str(raw.get("instagram_caption") or ""),
        photos=tuple(photo_order),
        cover_photo=cover_photo,
        outro=dict(raw.get("outro") or {}),
        target_duration_seconds=float(raw.get("target_duration_seconds") or 30.0),
    )

def _copy_optional(source: Path, target: Path) -> Path | None:
    if not source or str(source) == "." or not source.exists():
        return None
    target.write_bytes(source.read_bytes())
    return target

def _report_from_dict(raw: dict) -> Any:
    from core.listings.scorecard import ScoreReport

    return ScoreReport(
        profile=str(raw.get("profile") or PROFILE_NAME),
        version=str(raw.get("version") or ""),
        phase=str(raw.get("phase") or ""),
        overall_score=int(raw.get("overall_score") or 0),
        subscores=dict(raw.get("subscores") or {}),
        hard_gate_warnings=list(raw.get("hard_gate_warnings") or []),
        top_issues=list(raw.get("top_issues") or []),
        deterministic_checks=dict(raw.get("deterministic_checks") or {}),
        model_assisted_checks=dict(raw.get("model_assisted_checks") or {}),
        recommendation=str(raw.get("recommendation") or ""),
        confidence_note=str(raw.get("confidence_note") or ""),
        evidence=dict(raw.get("evidence") or {}),
        remote_review_url=raw.get("remote_review_url"),
    )


Now I have a complete picture. Let me figure out the best implementation.

**Current state:**
- `render_reel` (ListingPhotoReelRenderModule): builds ffmpeg command, runs it if `execute_render=True`, stores `output_video`, `render_command`, etc.
- `video_review` (ListingVideoReviewModule): reads `output_video`, probes it, runs `score_post_render()`, stores `video_review` dict with `recommendation` field

**What I need to implement:**
A QA retry loop that:
1. Checks `video_review.recommendation`
2. If the recommendation isn't "publishable" and we haven't exhausted retries, re-run the stored render command and re-score the output
3. Some issues like missing audio might be transient ffmpeg problems that could resolve on retry, while others like incorrect layout are deterministic and won't change
4. The retry logic should re-render and re-score with clear logging of what improved, surfacing deterministic failures rather than looping indefinitely

I'm building a `ListingQARetryModule` that reads the video review from context and checks if the recommendation warrants a retry attempt.

Implementing the retry loop... Writing the retry loop... Finishing the return statement, then handling the case where all retries are exhausted by raising an error with the final score and recommendation details. I'm also noticing a potential issue with how `run_photo_reel` behaves when `execute_render` is False — the render command gets built but never actually executed, which could cause problems downstream.

Looking at the module flow, the `video_review` module calls `probe_media` on the output video, which assumes the render was already executed. So the retry logic can safely assume the same thing. The QA retry would only be needed if the render actually ran, and if `execute_render` is False, the video wouldn't exist anyway, causing the review to fail earlier in the pipeline.

For the retry module's output, I need to update `context.data` with the new scores and the path to the retry score file, since downstream modules like `ListingReviewArtifactsModule` read from `video_review_path` and copy it to the publish directory. The retry module acts as a no-op if the initial review shows "publishable", but kicks in when the recommendation is "revise" or "reject".

Now I'm sketching out the test cases: one for when no retry is needed, one for successful retries at various attempt counts, and one for when all retries are exhausted. Let me check the existing test patterns to match the conventions.


Bash

ls /workspace/extra/projects/stagehand/tests/ && echo "---" && ls /workspace/extra/projects/stagehand/tests/test_listing_reel*.py 2>/dev/null || echo "none"

List test files


__init__.py
__pycache__
fixtures
test_agent_instruction_symmetry.py
test_cli.py
test_compass_provider.py
test_config.py
test_config_settings.py
test_coqui_tts.py
test_db_models.py
test_delivery_naming.py
test_hyperframes_narration.py
test_hyperframes_review.py
test_instagram_package.py
test_integrations.py
test_listing_graphics.py
test_listing_manifest.py
test_listing_reel_workflow.py
test_listing_review.py
test_live_integrations.py
test_local_input.py
test_local_tts.py
test_media.py
test_photo_reel.py
test_reel_scorecard.py
test_review_server.py
test_workflow_runner.py
---
/workspace/extra/projects/stagehand/tests/test_listing_reel_workflow.py


1	from __future__ import annotations
2
3	import json
4	import shutil
5	import tempfile
6	import unittest
7	from pathlib import Path
8	from unittest.mock import patch
9
10	from PIL import Image
11
12	from core.models import ApprovalStatus, JobStatus
13	from core.workflows.config import WorkflowConfig, WorkflowStep
14	from core.workflows.listing_reel_modules import (
15	    ListingIntakeModule,
16	    ListingReviewApprovalModule,
17	    ListingReviewDraftModule,
18	)
19	from core.workflows.runner import ApprovalGate, WorkflowRunner
20	from core.workflows.store import JsonJobStore
21	from core.workflows.config import load_workflow_config
22	from core.workflows.modules import default_modules
23
24	LISTING_URL = "https://www.zillow.com/homedetails/8536-Birmingham-Dr-Austin-TX-78748/29505949_zpid/"
25
26
27	def _write_fixture(root: Path) -> Path:
28	    photos = root / "photos"
29	    photos.mkdir()
30	    (photos / "front.ppm").write_text("P3\n1 1\n255\n210 200 190\n", encoding="ascii")
31	    manifest = root / "listing.json"
32	    manifest.write_text(
33	        json.dumps(
34	            {
35	                "version": 1,
36	                "source_url": LISTING_URL,
37	                "rights_confirmed": True,
38	                "attribution": "Listing agent supplied media.",
39	                "address": "8536 Birmingham Dr",
40	                "city": "Austin",
41	                "state": "TX",
42	                "price": 475000,
43	                "beds": 3,
44	                "baths": 2,
45	                "description": "Bright living spaces.",
46	                "photos": [{"path": "photos/front.ppm", "label": "Front"}],
47	                "cover_photo_index": 0,
48	            }
49	        ),
50	        encoding="utf-8",
51	    )
52	    return manifest
53
54
55	class ListingReelApprovalScenarioTests(unittest.TestCase):
56	    def test_workflow_pauses_before_expensive_work_then_parses_approved_bundle(self) -> None:
57	        config = WorkflowConfig(
58	            name="listing_reel_review_test",
59	            description="",
60	            steps=[
61	                WorkflowStep("intake", "listing_intake", {}),
62	                WorkflowStep("draft", "listing_review_draft", {"target_duration_seconds": 30}),
63	                WorkflowStep("approve", "approval_gate", {"approval_artifact": "approved_listing_reel.json"}),
64	                WorkflowStep("validate", "listing_review_approval", {}),
65	            ],
66	        )
67	        with tempfile.TemporaryDirectory() as tmp:
68	            root = Path(tmp)
69	            manifest = _write_fixture(root)
70	            store = JsonJobStore(root / "jobs")
71	            runner = WorkflowRunner(
72	                config,
73	                {
74	                    "listing_intake": ListingIntakeModule(),
75	                    "listing_review_draft": ListingReviewDraftModule(),
76	                    "approval_gate": ApprovalGate(),
77	                    "listing_review_approval": ListingReviewApprovalModule(),
78	                },
79	                store,
80	                output_root=root / "output",
81	            )
82
83	            job = runner.start(
84	                "richard-fowler",
85	                {
86	                    "listing_url": LISTING_URL,
87	                    "listing_manifest_path": str(manifest),
88	                    "listing_media_root": str(root),
89	                    "agent_name": "Richard D Fowler",
90	                    "agent_instagram_handle": "@richardfowlerrealtor",
91	                    "agent_website": "https://richardfowlerrealtor.com",
92	                    "voice_owner": "Richard D Fowler",
93	                },
94	            )
95
96	            self.assertEqual(job.status, JobStatus.WAITING_FOR_APPROVAL)
97	            self.assertEqual(job.approval_status, ApprovalStatus.PENDING)
98	            self.assertNotIn("approved_narration", job.output_params)
99	            self.assertNotIn("render_command", job.output_params)
100
101	            approval_path = Path(job.output_params["approval_artifact"])
102	            approved_payload = json.loads(approval_path.read_text(encoding="utf-8"))
103	            approved_payload["narration"] = "Approved narration."
104	            approved_payload["instagram_caption"] = "Approved caption."
105	            approval_path.write_text(json.dumps(approved_payload, indent=2), encoding="utf-8")
106
107	            completed = runner.approve(job.id, approval_path)
108
109	        self.assertEqual(completed.status, JobStatus.COMPLETED)
110	        self.assertEqual(completed.output_params["approved_narration"], "Approved narration.")
111	        self.assertEqual(completed.output_params["approved_caption"], "Approved caption.")
112	        self.assertTrue(completed.output_params["approved_photo_paths"])
113
114	    def test_full_workflow_resumes_from_one_approval_into_instagram_package(self) -> None:
115	        with tempfile.TemporaryDirectory() as tmp:
116	            root = Path(tmp)
117	            manifest = _write_fixture(root)
118	            profile = root / "profile.png"
119	            Image.new("RGB", (64, 64), (180, 150, 140)).save(profile)
120	            runner = WorkflowRunner(
121	                load_workflow_config("workflows/listing_reel/config.yaml"),
122	                default_modules(),
123	                JsonJobStore(root / "jobs"),
124	                output_root=root / "output",
125	            )
126	            job = runner.start(
127	                "richard-fowler",
128	                {
129	                    "listing_url": LISTING_URL,
130	                    "listing_manifest_path": str(manifest),
131	                    "listing_media_root": str(root),
132	                    "agent_name": "Richard D Fowler",
133	                    "agent_instagram_handle": "@richardfowlerrealtor",
134	                    "agent_website": "https://richardfowlerrealtor.com",
135	                    "instagram_handle": "@richardfowlerrealtor",
136	                    "instagram_hashtags": "#realestate #austinrealestate",
137	                    "voice_owner": "Richard D Fowler",
138	                    "agent_profile_photo": str(profile),
139	                    "review_base_url": "http://stagehand.tailnet.ts.net:8421",
140	                    "local_tts_provider": "silent",
141	                    "execute_render": True,
142	                    "target_duration_seconds": 20,
143	                },
144	            )
145	            approval_path = Path(job.output_params["approval_artifact"])
146	            approved = json.loads(approval_path.read_text(encoding="utf-8"))
147	            approved["narration"] = "A concise approved property narration."
148	            approved["instagram_caption"] = "An approved caption. #realestate"
149	            approval_path.write_text(json.dumps(approved, indent=2), encoding="utf-8")
150
151	            def fake_render(command: list[str]) -> None:
152	                shutil.copyfile("tests/fixtures/short_walkthrough.mp4", Path(command[-1]))
153
154	            with patch(
155	                "core.workflows.listing_reel_modules.run_photo_reel",
156	                side_effect=fake_render,
157	            ):
158	                completed = runner.approve(job.id, approval_path)
159
160	            self.assertEqual(completed.status, JobStatus.COMPLETED)
161	            expected_artifacts = (
162	                "narration_path",
163	                "captions_path",
164	                "outro_image_path",
165	                "cover_image_path",
166	                "render_command_path",
167	                "script_review_path",
168	                "video_review_path",
169	                "pre_render_score_path",
170	                "post_render_score_path",
171	                "publish_video_path",
172	                "caption_path",
173	                "metadata_path",
174	                "listing_snapshot_package_path",
175	                "review_page_path",
176	            )
177	            for name in expected_artifacts:
178	                self.assertTrue(Path(completed.output_params[name]).exists(), name)
179	            self.assertEqual(completed.output_params["voice_provider"], "local_stub_silent_audio")
180	            command = completed.output_params["render_command"]
181	            self.assertEqual(command[command.index("-t") + 1], "20.000")
182	            self.assertIn(
183	                "approved caption",
184	                Path(completed.output_params["caption_path"]).read_text(encoding="utf-8").lower(),
185	            )
186	            review_html = Path(completed.output_params["review_page_path"]).read_text(encoding="utf-8")
187	            self.assertIn("Private operator review surface", review_html)
188	            self.assertIn("final_video.mp4", review_html)
189	            metadata = json.loads(Path(completed.output_params["metadata_path"]).read_text(encoding="utf-8"))
190	            self.assertTrue(metadata["review_url"].endswith("/publish/review.html"))
191
192
193	if __name__ == "__main__":
194	    unittest.main()
195

from __future__ import annotations

import json
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

from PIL import Image

from core.models import ApprovalStatus, JobStatus
from core.workflows.config import WorkflowConfig, WorkflowStep
from core.workflows.listing_reel_modules import (
    ListingIntakeModule,
    ListingReviewApprovalModule,
    ListingReviewDraftModule,
)
from core.workflows.runner import ApprovalGate, WorkflowRunner
from core.workflows.store import JsonJobStore
from core.workflows.config import load_workflow_config
from core.workflows.modules import default_modules

LISTING_URL = "https://www.zillow.com/homedetails/8536-Birmingham-Dr-Austin-TX-78748/29505949_zpid/"

def _write_fixture(root: Path) -> Path:
    photos = root / "photos"
    photos.mkdir()
    (photos / "front.ppm").write_text("P3\n1 1\n255\n210 200 190\n", encoding="ascii")
    manifest = root / "listing.json"
    manifest.write_text(
        json.dumps(
            {
                "version": 1,
                "source_url": LISTING_URL,
                "rights_confirmed": True,
                "attribution": "Listing agent supplied media.",
                "address": "8536 Birmingham Dr",
                "city": "Austin",
                "state": "TX",
                "price": 475000,
                "beds": 3,
                "baths": 2,
                "description": "Bright living spaces.",
                "photos": [{"path": "photos/front.ppm", "label": "Front"}],
                "cover_photo_index": 0,
            }
        ),
        encoding="utf-8",
    )
    return manifest

class ListingReelApprovalScenarioTests(unittest.TestCase):
    def test_workflow_pauses_before_expensive_work_then_parses_approved_bundle(self) -> None:
        config = WorkflowConfig(
            name="listing_reel_review_test",
            description="",
            steps=[
                WorkflowStep("intake", "listing_intake", {}),
                WorkflowStep("draft", "listing_review_draft", {"target_duration_seconds": 30}),
                WorkflowStep("approve", "approval_gate", {"approval_artifact": "approved_listing_reel.json"}),
                WorkflowStep("validate", "listing_review_approval", {}),
            ],
        )
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            manifest = _write_fixture(root)
            store = JsonJobStore(root / "jobs")
            runner = WorkflowRunner(
                config,
                {
                    "listing_intake": ListingIntakeModule(),
                    "listing_review_draft": ListingReviewDraftModule(),
                    "approval_gate": ApprovalGate(),
                    "listing_review_approval": ListingReviewApprovalModule(),
                },
                store,
                output_root=root / "output",
            )

            job = runner.start(
                "richard-fowler",
                {
                    "listing_url": LISTING_URL,
                    "listing_manifest_path": str(manifest),
                    "listing_media_root": str(root),
                    "agent_name": "Richard D Fowler",
                    "agent_instagram_handle": "@richardfowlerrealtor",
                    "agent_website": "https://richardfowlerrealtor.com",
                    "voice_owner": "Richard D Fowler",
                },
            )

            self.assertEqual(job.status, JobStatus.WAITING_FOR_APPROVAL)
            self.assertEqual(job.approval_status, ApprovalStatus.PENDING)
            self.assertNotIn("approved_narration", job.output_params)
            self.assertNotIn("render_command", job.output_params)

            approval_path = Path(job.output_params["approval_artifact"])
            approved_payload = json.loads(approval_path.read_text(encoding="utf-8"))
            approved_payload["narration"] = "Approved narration."
            approved_payload["instagram_caption"] = "Approved caption."
            approval_path.write_text(json.dumps(approved_payload, indent=2), encoding="utf-8")

            completed = runner.approve(job.id, approval_path)

        self.assertEqual(completed.status, JobStatus.COMPLETED)
        self.assertEqual(completed.output_params["approved_narration"], "Approved narration.")
        self.assertEqual(completed.output_params["approved_caption"], "Approved caption.")
        self.assertTrue(completed.output_params["approved_photo_paths"])

    def test_full_workflow_resumes_from_one_approval_into_instagram_package(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            manifest = _write_fixture(root)
            profile = root / "profile.png"
            Image.new("RGB", (64, 64), (180, 150, 140)).save(profile)
            runner = WorkflowRunner(
                load_workflow_config("workflows/listing_reel/config.yaml"),
                default_modules(),
                JsonJobStore(root / "jobs"),
                output_root=root / "output",
            )
            job = runner.start(
                "richard-fowler",
                {
                    "listing_url": LISTING_URL,
                    "listing_manifest_path": str(manifest),
                    "listing_media_root": str(root),
                    "agent_name": "Richard D Fowler",
                    "agent_instagram_handle": "@richardfowlerrealtor",
                    "agent_website": "https://richardfowlerrealtor.com",
                    "instagram_handle": "@richardfowlerrealtor",
                    "instagram_hashtags": "#realestate #austinrealestate",
                    "voice_owner": "Richard D Fowler",
                    "agent_profile_photo": str(profile),
                    "review_base_url": "http://stagehand.tailnet.ts.net:8421",
                    "local_tts_provider": "silent",
                    "execute_render": True,
                    "target_duration_seconds": 20,
                },
            )
            approval_path = Path(job.output_params["approval_artifact"])
            approved = json.loads(approval_path.read_text(encoding="utf-8"))
            approved["narration"] = "A concise approved property narration."
            approved["instagram_caption"] = "An approved caption. #realestate"
            approval_path.write_text(json.dumps(approved, indent=2), encoding="utf-8")

            def fake_render(command: list[str]) -> None:
                shutil.copyfile("tests/fixtures/short_walkthrough.mp4", Path(command[-1]))

            with patch(
                "core.workflows.listing_reel_modules.run_photo_reel",
                side_effect=fake_render,
            ):
                completed = runner.approve(job.id, approval_path)

            self.assertEqual(completed.status, JobStatus.COMPLETED)
            expected_artifacts = (
                "narration_path",
                "captions_path",
                "outro_image_path",
                "cover_image_path",
                "render_command_path",
                "script_review_path",
                "video_review_path",
                "pre_render_score_path",
                "post_render_score_path",
                "publish_video_path",
                "caption_path",
                "metadata_path",
                "listing_snapshot_package_path",
                "review_page_path",
            )
            for name in expected_artifacts:
                self.assertTrue(Path(completed.output_params[name]).exists(), name)
            self.assertEqual(completed.output_params["voice_provider"], "local_stub_silent_audio")
            command = completed.output_params["render_command"]
            self.assertEqual(command[command.index("-t") + 1], "20.000")
            self.assertIn(
                "approved caption",
                Path(completed.output_params["caption_path"]).read_text(encoding="utf-8").lower(),
            )
            review_html = Path(completed.output_params["review_page_path"]).read_text(encoding="utf-8")
            self.assertIn("Private operator review surface", review_html)
            self.assertIn("final_video.mp4", review_html)
            metadata = json.loads(Path(completed.output_params["metadata_path"]).read_text(encoding="utf-8"))
            self.assertTrue(metadata["review_url"].endswith("/publish/review.html"))

if __name__ == "__main__":
    unittest.main()


1	from __future__ import annotations
2
3	import json
4	import tempfile
5	import unittest
6	from pathlib import Path
7
8	from core.listings.manifest import load_listing_manifest
9	from core.listings.models import AgentProfile
10	from core.listings.review import build_review_bundle, parse_approved_review
11	from core.listings.scorecard import build_review_page, score_post_render, score_pre_render, write_score_report
12	from core.models import MediaProbe
13
14	LISTING_URL = "https://example.com/listings/1002-horne"
15
16
17	def _snapshot(root: Path):
18	    photos = root / "photos"
19	    photos.mkdir(parents=True)
20	    (photos / "front.ppm").write_text("P3\n2 1\n255\n200 210 220 180 190 200\n", encoding="ascii")
21	    (photos / "living.ppm").write_text("P3\n2 1\n255\n240 235 225 230 225 215\n", encoding="ascii")
22	    (photos / "patio.ppm").write_text("P3\n2 1\n255\n180 205 190 150 175 160\n", encoding="ascii")
23	    manifest = root / "listing.json"
24	    manifest.write_text(
25	        json.dumps(
26	            {
27	                "version": 1,
28	                "source_url": LISTING_URL,
29	                "source_provider": "operator_manifest",
30	                "rights_confirmed": True,
31	                "attribution": "Photos supplied by listing agent.",
32	                "address": "1002 Horne Dr",
33	                "city": "Cedar Park",
34	                "state": "TX",
35	                "postal_code": "78613",
36	                "price": 692500,
37	                "beds": 5,
38	                "baths": 2.5,
39	                "square_feet": 2684,
40	                "description": "A bright Cedar Park home with flexible living spaces.",
41	                "photos": [
42	                    {"path": "photos/front.ppm", "label": "Front exterior"},
43	                    {"path": "photos/living.ppm", "label": "Living room"},
44	                    {"path": "photos/patio.ppm", "label": "Patio"},
45	                ],
46	                "cover_photo_index": 0,
47	            }
48	        ),
49	        encoding="utf-8",
50	    )
51	    return load_listing_manifest(manifest, listing_url=LISTING_URL, media_root=root)
52
53
54	class ReelScorecardTests(unittest.TestCase):
55	    def test_pre_render_score_flags_missing_profile_photo(self) -> None:
56	        with tempfile.TemporaryDirectory() as tmp:
57	            root = Path(tmp)
58	            snapshot = _snapshot(root)
59	            profile = AgentProfile.from_mapping(
60	                {

from __future__ import annotations

import json
import tempfile
import unittest
from pathlib import Path

from core.listings.manifest import load_listing_manifest
from core.listings.models import AgentProfile
from core.listings.review import build_review_bundle, parse_approved_review
from core.listings.scorecard import build_review_page, score_post_render, score_pre_render, write_score_report
from core.models import MediaProbe

LISTING_URL = "https://example.com/listings/1002-horne"

def _snapshot(root: Path):
    photos = root / "photos"
    photos.mkdir(parents=True)
    (photos / "front.ppm").write_text("P3\n2 1\n255\n200 210 220 180 190 200\n", encoding="ascii")
    (photos / "living.ppm").write_text("P3\n2 1\n255\n240 235 225 230 225 215\n", encoding="ascii")
    (photos / "patio.ppm").write_text("P3\n2 1\n255\n180 205 190 150 175 160\n", encoding="ascii")
    manifest = root / "listing.json"
    manifest.write_text(
        json.dumps(
            {
                "version": 1,
                "source_url": LISTING_URL,
                "source_provider": "operator_manifest",
                "rights_confirmed": True,
                "attribution": "Photos supplied by listing agent.",
                "address": "1002 Horne Dr",
                "city": "Cedar Park",
                "state": "TX",
                "postal_code": "78613",
                "price": 692500,
                "beds": 5,
                "baths": 2.5,
                "square_feet": 2684,
                "description": "A bright Cedar Park home with flexible living spaces.",
                "photos": [
                    {"path": "photos/front.ppm", "label": "Front exterior"},
                    {"path": "photos/living.ppm", "label": "Living room"},
                    {"path": "photos/patio.ppm", "label": "Patio"},
                ],
                "cover_photo_index": 0,
            }
        ),
        encoding="utf-8",
    )
    return load_listing_manifest(manifest, listing_url=LISTING_URL, media_root=root)

class ReelScorecardTests(unittest.TestCase):
    def test_pre_render_score_flags_missing_profile_photo(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            snapshot = _snapshot(root)
            profile = AgentProfile.from_mapping(
                {


Now I have a good picture of the code. Let me check `default_modules()` to understand what modules are registered and how I'll add my new one.


grep -n "default_modules\|listing_video_review\|listing_photo_reel\|qa_retry" /workspace/extra/projects/stagehand/core/workflows/modules.py | head -40

Find module registry


404:def default_modules(tts_client: Any = None) -> dict:
432:        "listing_photo_reel": ListingPhotoReelRenderModule(),
433:        "listing_video_review": ListingVideoReviewModule(),


395	            "listing_snapshot_package_path": (
396	                str(listing_snapshot_package_path) if listing_snapshot_package_path else None
397	            ),
398	            "review_page_path": str(context.data.get("review_page_path") or "") or None,
399	            "review_page_url": str(context.data.get("review_page_url") or "") or None,
400	            "publish_method": "manual_handoff",
401	        }
402
403
404	def default_modules(tts_client: Any = None) -> dict:
405	    from core.workflows.runner import ApprovalGate
406	    from core.workflows.listing_reel_modules import (
407	        ListingGraphicsModule,
408	        ListingIntakeModule,
409	        ListingPhotoReelRenderModule,
410	        ListingReviewArtifactsModule,
411	        ListingReviewApprovalModule,
412	        ListingReviewDraftModule,
413	        ListingScriptReviewModule,
414	        ListingVideoReviewModule,
415	    )
416
417	    return {
418	        "transcription": LocalTranscriptionModule(),
419	        "llm_script": LocalScriptModule(),
420	        "approval_gate": ApprovalGate(),
421	        "voice_synthesis": LocalVoiceModule(tts_client),
422	        "captions": LocalCaptionModule(),
423	        "video_format": LocalRenderModule(),
424	        "instagram_caption": InstagramCaptionModule(),
425	        "instagram_package": InstagramPackageModule(),
426	        "listing_intake": ListingIntakeModule(),
427	        "listing_review_draft": ListingReviewDraftModule(),
428	        "listing_review_approval": ListingReviewApprovalModule(),
429	        "listing_script_review": ListingScriptReviewModule(),
430	        "listing_pre_render_score": ListingScriptReviewModule(),
431	        "listing_graphics": ListingGraphicsModule(),
432	        "listing_photo_reel": ListingPhotoReelRenderModule(),
433	        "listing_video_review": ListingVideoReviewModule(),
434	        "listing_post_render_score": ListingVideoReviewModule(),
435	        "listing_review_artifacts": ListingReviewArtifactsModule(),
436	    }
437
438
439	# ── Audio extraction ──────────────────────────────────────────────────────────
440
441
442	def _audio_path_for_transcription(input_path: str | Path, workspace: Path) -> str | Path:
443	    """Return a path suitable for the transcription API.
444
445	    If the input is a known video format and the file exists, extract the audio
446	    track to workspace as an m4a. This keeps the payload small and avoids API
447	    rejections for container formats the API may not accept (e.g. MOV).
448	    Falls through unchanged when the file doesn't exist (test/offline mode) or
449	    is already an audio format.

            "listing_snapshot_package_path": (
                str(listing_snapshot_package_path) if listing_snapshot_package_path else None
            ),
            "review_page_path": str(context.data.get("review_page_path") or "") or None,
            "review_page_url": str(context.data.get("review_page_url") or "") or None,
            "publish_method": "manual_handoff",
        }

def default_modules(tts_client: Any = None) -> dict:
    from core.workflows.runner import ApprovalGate
    from core.workflows.listing_reel_modules import (
        ListingGraphicsModule,
        ListingIntakeModule,
        ListingPhotoReelRenderModule,
        ListingReviewArtifactsModule,
        ListingReviewApprovalModule,
        ListingReviewDraftModule,
        ListingScriptReviewModule,
        ListingVideoReviewModule,
    )

    return {
        "transcription": LocalTranscriptionModule(),
        "llm_script": LocalScriptModule(),
        "approval_gate": ApprovalGate(),
        "voice_synthesis": LocalVoiceModule(tts_client),
        "captions": LocalCaptionModule(),
        "video_format": LocalRenderModule(),
        "instagram_caption": InstagramCaptionModule(),
        "instagram_package": InstagramPackageModule(),
        "listing_intake": ListingIntakeModule(),
        "listing_review_draft": ListingReviewDraftModule(),
        "listing_review_approval": ListingReviewApprovalModule(),
        "listing_script_review": ListingScriptReviewModule(),
        "listing_pre_render_score": ListingScriptReviewModule(),
        "listing_graphics": ListingGraphicsModule(),
        "listing_photo_reel": ListingPhotoReelRenderModule(),
        "listing_video_review": ListingVideoReviewModule(),
        "listing_post_render_score": ListingVideoReviewModule(),
        "listing_review_artifacts": ListingReviewArtifactsModule(),
    }

# ── Audio extraction ──────────────────────────────────────────────────────────

def _audio_path_for_transcription(input_path: str | Path, workspace: Path) -> str | Path:
    """Return a path suitable for the transcription API.

    If the input is a known video format and the file exists, extract the audio
    track to workspace as an m4a. This keeps the payload small and avoids API
    rejections for container formats the API may not accept (e.g. MOV).
    Falls through unchanged when the file doesn't exist (test/offline mode) or
    is already an audio format.


Now I have a complete picture. Let me implement the `ListingQARetryModule`.

The implementation plan:
1. Add `ListingQARetryModule` to `listing_reel_modules.py`
2. Register it as `listing_qa_retry` in `modules.py` `default_modules()`
3. Add the step to `workflows/listing_reel/config.yaml` between `video_review` and `build_review_artifacts`
4. Write tests (RED first, then GREEN)

The module should:
- If `post_render_score.recommendation == "publishable"`: return `{"qa_retry_attempts": 0, "qa_retry_needed": False}` immediately
- Otherwise, loop through up to `max_retries` (default 2) to re-render the video, re-probe it, and re-score it, saving each attempt's results to a separate JSON file, and return early if the score becomes publishable or raise an error if all retries are exhausted
- Only attempt retries if `render_executed` is True, since we shouldn't re-render in dry-run mode
- The render command is stored in `context.data["render_command"]` as a list of strings regardless of execution mode

Now I'm planning the test strategy using TDD — I'll need to mock `run_photo_reel`, `probe_media`, and the snapshot loading functions to avoid actual ffmpeg calls and file dependencies, then set up a `WorkflowContext` with pre-populated data for the retry module tests.

I should check what `MediaProbe` looks like to know what properties to mock.


grep -n "class MediaProbe" /workspace/extra/projects/stagehand/core/models.py

Find MediaProbe definition


105:class MediaProbe:


105	class MediaProbe:
106	    path: Path
107	    duration_seconds: float
108	    width: int | None
109	    height: int | None
110	    has_audio: bool
111	    frame_rate: float | None = None
112	    video_codec: str | None = None
113	    audio_codec: str | None = None
114	    pixel_format: str | None = None
115	    color_range: str | None = None
116

class MediaProbe:
    path: Path
    duration_seconds: float
    width: int | None
    height: int | None
    has_audio: bool
    frame_rate: float | None = None
    video_codec: str | None = None
    audio_codec: str | None = None
    pixel_format: str | None = None
    color_range: str | None = None


<context timezone="America/Chicago" />
<message id="2974" from="telegram-mg-17796" sender="James Lopez" time="Jul 20, 2026, 9:48 PM">Would stagehand then be an “agent”? Would adding the graph make it an agent? If not, what would? I’m still trying to get a concrete example of an agent for me to really grasp the concept intuitively</message>


Now I have everything I need. Let me write the implementation.

**The plan:**

1. `ListingQARetryModule` in `listing_reel_modules.py`:
   - Reads `post_render_score` from context
   - If publishable: no-op
   - If not publishable AND `render_executed` is True: retry loop
   - If not publishable AND `render_executed` is False: also no-op (skip retry for non-executed renders)
   - Each retry: re-render, re-probe, re-score
   - On success: update context with new score
   - On exhaustion: raise ValueError

2. Register in `default_modules()` as `"listing_qa_retry"`

3. Add the retry step to the config between `video_review` and `build_review_artifacts` with a max retries parameter

4. Write tests for the module, either in the existing test file or a new dedicated one

I need to make sure the module handles the case where the video file doesn't exist since `video_review` already handles the media probing.

Looking at the video review module, it calls `probe_media` on the output video, so if the file doesn't exist it would fail there first. This means the `qa_retry` step will only execute when the video has actually been rendered, so I don't need to add extra file existence checks.

I'm defining the retry module class that will check if the post-render score meets the publishable threshold, and if not, re-render and re-score up to a maximum number of attempts before raising an error if all retries are exhausted. Checking if the render command exists, then loading the snapshot and approved reel data before entering a retry loop where I execute the render, probe the output video, and score the post-render results.

Writing QA retry logic... I see that `score_post_render` is actually deterministic based on the `MediaProbe` values, so I can control whether the recommendation is "publishable", "revise", or "reject" by setting up the probe output appropriately. The recommendation logic checks if the overall score is at least 85 with no warnings for "publishable", at least 65 for "revise", and below that for "reject". Warnings can come from portrait orientation, audio presence, duration constraints, or profile photo availability, and these feed into the subscores that determine the overall rating. So with no profile photo, the branding score drops to 62. For a failing case, I'd remove the audio stream entirely, which tanks the technical polish below 42. For a passing case, I need portrait orientation, audio present, a 25-second duration, 60fps, h264 video codec, aac audio, and a profile photo. Now I'm calculating whether this configuration hits the 85+ threshold—starting with format and delivery at 100 for portrait, visual composition around 84 with a few photos, and motion and transitions at 88 for the high frame rate.

Plugging in the weighted scores across all categories—narration alignment around 78-94, captions at 84, branding at 92 with the profile photo, and technical polish at 92—I get a total of 87 after applying the weights, which clears the 85 minimum.

For the test, I need to mock `probe_media` to return different results on successive calls so the first render fails and the second passes. I'll also need to mock `_load_snapshot` and `_approved_reel` since creating actual fixture files is more complex than just stubbing them out.

Since these are module-level functions, I'll patch them at the module path where they're used. I'm also thinking through what `WorkflowContext` needs — a job, workspace path, and data dict with the post-render score and other fields. Looking at how the `WorkflowRunner` instantiates Jobs, I can create a minimal Job directly for my retry module tests instead of going through the full workflow setup.