diff --git a/src/youtube_extension/backend/code_generator.py b/src/youtube_extension/backend/code_generator.py index c0a75bca8..328a5c284 100644 --- a/src/youtube_extension/backend/code_generator.py +++ b/src/youtube_extension/backend/code_generator.py @@ -14,14 +14,10 @@ import json import logging import os -import re import tempfile -from datetime import datetime from pathlib import Path -from typing import Any, Dict, Optional -from urllib.parse import urlparse, parse_qs - -from youtube_extension.backend.models.build_plan import BuildPlan +from typing import Any, Optional +from urllib.parse import parse_qs, urlparse logger = logging.getLogger(__name__) @@ -42,7 +38,9 @@ def _extract_video_id(video_url: str) -> Optional[str]: return None -def _build_title(extracted_info: Dict[str, Any], video_analysis: Dict[str, Any], default: str) -> str: +def _build_title( + extracted_info: dict[str, Any], video_analysis: dict[str, Any], default: str +) -> str: """Return a meaningful project title. Priority: @@ -172,7 +170,7 @@ async def generate_project(self, video_analysis: dict[str, Any], project_config: # Include the structured BuildPlan artifact in the result so that # callers (e.g. API endpoints and tests) can inspect it. if build_plan is not None: - result["build_plan"] = build_plan.to_dict() + result["build_plan"] = build_plan logger.info(f"✅ Project generated successfully at {project_path}") return result @@ -326,7 +324,7 @@ async def _generate_vanilla_js_project(self, project_path: Path, video_analysis: f.write(main_js) # Generate styles.css — NOW uses video-derived accent color - styles_css = self._generate_vanilla_styles_css(features, fingerprint) + styles_css = self._generate_vanilla_styles_css(title, features, fingerprint) with open(project_path / "styles.css", "w") as f: f.write(styles_css) @@ -808,7 +806,7 @@ def _generate_vanilla_main_js( return f'''// ────────────────────────────────────────────────── // UVAI Generated JavaScript — {title} // Fingerprint: {fingerprint} -// Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} +// Generated by UVAI // ────────────────────────────────────────────────── {step_block} @@ -862,7 +860,9 @@ def _generate_vanilla_main_js( }} {extra_functions}''' - def _generate_vanilla_styles_css(self, features: list[str], fingerprint: str = "") -> str: + def _generate_vanilla_styles_css( + self, title: str, features: list[str], fingerprint: str = "" + ) -> str: """Generate styles.css for vanilla projects — accent color derived from video.""" accent = self._accent_from_fingerprint(fingerprint) if fingerprint else "hsl(245, 65%, 52%)" @@ -885,7 +885,10 @@ def _generate_vanilla_styles_css(self, features: list[str], fingerprint: str = " } }''' - return f'''/* Generated by UVAI — accent derived from video fingerprint */ + # Sanitize title for safe inclusion in CSS block comments + safe_title = title.replace("*/", "*_/") + + return f'''/* Generated by UVAI for {safe_title} — accent derived from video fingerprint */ :root {{ --accent: {accent}; --accent-dark: {accent_dark}; @@ -1112,7 +1115,8 @@ def _generate_readme(self, title: str, framework: str, video_analysis: dict) -> action = step.get("action", "action") target = step.get("target_file", "") desc = step.get("description", "") - step_num = step.get("step_number", idx + 1) + # Prefer normalized BuildPlan ordering, then legacy step_number, then position. + step_num = step.get("order", step.get("step_number", idx + 1)) detail = f"- Step {step_num}: {action}" if target: detail += f" -> {target}" @@ -1128,7 +1132,7 @@ def _generate_readme(self, title: str, framework: str, video_analysis: dict) -> ## Source Video - **URL**: {video_url} - **Framework**: {framework} -- **Generated**: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} +- **Generated By**: UVAI Watch the original video to follow along with the implementation details. @@ -1207,6 +1211,9 @@ def _build_generation_context(self, video_analysis: dict[str, Any], project_conf metadata = video_analysis.get("metadata") or video_analysis.get("video_data") or {} ai_analysis = video_analysis.get("ai_analysis") or {} build_plan = video_analysis.get("build_plan") or extracted_info.get("build_plan") + # Upstream callers may pass either a Pydantic BuildPlan or a plain dict. + if hasattr(build_plan, "model_dump"): + build_plan = build_plan.model_dump() title = ( project_config.get("title") @@ -1229,9 +1236,22 @@ def _build_generation_context(self, video_analysis: dict[str, Any], project_conf if build_plan: # Use BuildPlan as primary source - this is the preferred path! logger.info(f"✅ Using structured BuildPlan: {build_plan.get('video_title', 'Untitled')}") - features = build_plan.get("features", []) - summary = build_plan.get("summary", "") - key_concepts = build_plan.get("technologies", technologies) + features = ( + self._coerce_to_list(build_plan.get("features")) + or self._coerce_to_list(extracted_info.get("features")) + or self._coerce_to_list(project_config.get("features")) + ) + summary = ( + build_plan.get("summary") + or video_analysis.get("summary") + or ai_analysis.get("Content Summary", "") + ) + key_concepts = ( + self._coerce_to_list(video_analysis.get("key_concepts")) + or self._coerce_to_list(ai_analysis.get("Key Concepts")) + or self._coerce_to_list(build_plan.get("technologies")) + or technologies + ) tutorial_steps = [ f"Step {step.get('order', i + 1)}: {step.get('description', '')}" for i, step in enumerate(build_plan.get("steps", [])[:8]) @@ -1247,11 +1267,12 @@ def _build_generation_context(self, video_analysis: dict[str, Any], project_conf # Fallback to legacy extraction logic logger.warning("⚠️ BuildPlan not found, falling back to legacy extraction") features = self._coerce_to_list(extracted_info.get("features")) - summary = ai_analysis.get("Content Summary", "") - key_concepts = self._coerce_to_list(ai_analysis.get("Key Concepts")) + summary = video_analysis.get("summary") or ai_analysis.get("Content Summary", "") + key_concepts = ( + self._coerce_to_list(video_analysis.get("key_concepts")) + or self._coerce_to_list(ai_analysis.get("Key Concepts")) + ) tutorial_steps = self._coerce_to_list(extracted_info.get("tutorial_steps")) - if not tutorial_steps: - tutorial_steps = self._build_plan_steps_to_list(build_plan) if not tutorial_steps: tutorial_steps = self._derive_tutorial_steps(ai_analysis, video_analysis) @@ -1278,7 +1299,7 @@ def _build_plan_steps_to_list(self, build_plan: dict[str, Any] | None) -> list[s steps = [] for step in build_plan.get("steps", [])[:10]: try: - number = step.get("step_number") or len(steps) + 1 + number = step.get("order") or step.get("step_number") or len(steps) + 1 action = step.get("action") or "action" target = step.get("target_file") or "" description = step.get("description") or "" diff --git a/tests/test_code_generator.py b/tests/test_code_generator.py index 8ce2bfc2c..a93989fcc 100644 --- a/tests/test_code_generator.py +++ b/tests/test_code_generator.py @@ -9,6 +9,54 @@ from youtube_extension.backend.code_generator import ProjectCodeGenerator +def _tempdir_factory(*paths: Path): + remaining = iter(paths) + + def _mkdtemp(prefix: str) -> str: + path = next(remaining) + path.mkdir() + return str(path) + + return _mkdtemp + + +def _build_video_analysis(title: str, video_id: str, summary: str, concepts: list[str]) -> dict: + return { + "metadata": {"title": title, "video_id": video_id}, + "video_data": { + "video_id": video_id, + "video_url": f"https://youtu.be/{video_id}", + }, + "ai_analysis": { + "Content Summary": summary, + "Key Concepts": concepts, + "Related Topics": ["javascript", "html", "css"], + }, + "build_plan": { + "video_id": video_id, + "video_title": title, + "project_type": "web", + "technologies": ["javascript", "html", "css"], + "summary": summary, + "steps": [ + { + "order": 1, + "action": "create_file", + "target_file": "index.html", + "description": "Create the main page shell", + }, + { + "order": 2, + "action": "create_file", + "target_file": "main.js", + "description": "Wire up the interactive tutorial behavior", + }, + ], + }, + "success": True, + } + + def test_generate_project_includes_video_specific_content(monkeypatch, tmp_path) -> None: """Ensure generated assets reflect the source video instead of boilerplate.""" @@ -43,3 +91,77 @@ def test_generate_project_includes_video_specific_content(monkeypatch, tmp_path) assert "React Weather App" in app_text assert "state management" in app_text or "api calls" in app_text + +def test_build_plan_videos_generate_unique_vanilla_assets(monkeypatch, tmp_path) -> None: + """Ensure BuildPlan-backed vanilla generation stays tutorial-specific.""" + + project_one = tmp_path / "project_one" + project_two = tmp_path / "project_two" + monkeypatch.setattr(tempfile, "mkdtemp", _tempdir_factory(project_one, project_two)) + + generator = ProjectCodeGenerator() + project_config = {"type": "web", "features": ["responsive_design"]} + + first = asyncio.run( + generator.generate_project( + _build_video_analysis( + "Build a Todo App", + "todo123", + "Creates a todo list with local storage.", + ["local storage", "dom events"], + ), + project_config, + ) + ) + second = asyncio.run( + generator.generate_project( + _build_video_analysis( + "Build a Weather Dashboard", + "weather456", + "Builds a weather dashboard with API-driven cards.", + ["fetch api", "forecast cards"], + ), + project_config, + ) + ) + + first_path = Path(first["project_path"]) + second_path = Path(second["project_path"]) + + assert (first_path / "main.js").read_text() != (second_path / "main.js").read_text() + assert (first_path / "styles.css").read_text() != (second_path / "styles.css").read_text() + assert first["build_plan"]["steps"][0]["order"] == 1 + + for file_name in ("index.html", "main.js", "styles.css", "README.md"): + assert "Build a Todo App" in (first_path / file_name).read_text() + assert "Build a Weather Dashboard" in (second_path / file_name).read_text() + + +def test_same_build_plan_video_produces_deterministic_vanilla_files(monkeypatch, tmp_path) -> None: + """Ensure repeated generation for the same tutorial is deterministic.""" + + first_project = tmp_path / "deterministic_one" + second_project = tmp_path / "deterministic_two" + monkeypatch.setattr( + tempfile, + "mkdtemp", + _tempdir_factory(first_project, second_project), + ) + + generator = ProjectCodeGenerator() + video_analysis = _build_video_analysis( + "Build a Recipe Finder", + "recipe789", + "Builds a recipe finder with searchable ingredient cards.", + ["search filtering", "ingredient cards"], + ) + project_config = {"type": "web", "features": ["responsive_design"]} + + first = asyncio.run(generator.generate_project(video_analysis, project_config)) + second = asyncio.run(generator.generate_project(video_analysis, project_config)) + + first_path = Path(first["project_path"]) + second_path = Path(second["project_path"]) + + for file_name in ("index.html", "main.js", "styles.css", "README.md"): + assert (first_path / file_name).read_text() == (second_path / file_name).read_text() diff --git a/tests/unit/test_code_generator.py b/tests/unit/test_code_generator.py index 8ce2bfc2c..a93989fcc 100644 --- a/tests/unit/test_code_generator.py +++ b/tests/unit/test_code_generator.py @@ -9,6 +9,54 @@ from youtube_extension.backend.code_generator import ProjectCodeGenerator +def _tempdir_factory(*paths: Path): + remaining = iter(paths) + + def _mkdtemp(prefix: str) -> str: + path = next(remaining) + path.mkdir() + return str(path) + + return _mkdtemp + + +def _build_video_analysis(title: str, video_id: str, summary: str, concepts: list[str]) -> dict: + return { + "metadata": {"title": title, "video_id": video_id}, + "video_data": { + "video_id": video_id, + "video_url": f"https://youtu.be/{video_id}", + }, + "ai_analysis": { + "Content Summary": summary, + "Key Concepts": concepts, + "Related Topics": ["javascript", "html", "css"], + }, + "build_plan": { + "video_id": video_id, + "video_title": title, + "project_type": "web", + "technologies": ["javascript", "html", "css"], + "summary": summary, + "steps": [ + { + "order": 1, + "action": "create_file", + "target_file": "index.html", + "description": "Create the main page shell", + }, + { + "order": 2, + "action": "create_file", + "target_file": "main.js", + "description": "Wire up the interactive tutorial behavior", + }, + ], + }, + "success": True, + } + + def test_generate_project_includes_video_specific_content(monkeypatch, tmp_path) -> None: """Ensure generated assets reflect the source video instead of boilerplate.""" @@ -43,3 +91,77 @@ def test_generate_project_includes_video_specific_content(monkeypatch, tmp_path) assert "React Weather App" in app_text assert "state management" in app_text or "api calls" in app_text + +def test_build_plan_videos_generate_unique_vanilla_assets(monkeypatch, tmp_path) -> None: + """Ensure BuildPlan-backed vanilla generation stays tutorial-specific.""" + + project_one = tmp_path / "project_one" + project_two = tmp_path / "project_two" + monkeypatch.setattr(tempfile, "mkdtemp", _tempdir_factory(project_one, project_two)) + + generator = ProjectCodeGenerator() + project_config = {"type": "web", "features": ["responsive_design"]} + + first = asyncio.run( + generator.generate_project( + _build_video_analysis( + "Build a Todo App", + "todo123", + "Creates a todo list with local storage.", + ["local storage", "dom events"], + ), + project_config, + ) + ) + second = asyncio.run( + generator.generate_project( + _build_video_analysis( + "Build a Weather Dashboard", + "weather456", + "Builds a weather dashboard with API-driven cards.", + ["fetch api", "forecast cards"], + ), + project_config, + ) + ) + + first_path = Path(first["project_path"]) + second_path = Path(second["project_path"]) + + assert (first_path / "main.js").read_text() != (second_path / "main.js").read_text() + assert (first_path / "styles.css").read_text() != (second_path / "styles.css").read_text() + assert first["build_plan"]["steps"][0]["order"] == 1 + + for file_name in ("index.html", "main.js", "styles.css", "README.md"): + assert "Build a Todo App" in (first_path / file_name).read_text() + assert "Build a Weather Dashboard" in (second_path / file_name).read_text() + + +def test_same_build_plan_video_produces_deterministic_vanilla_files(monkeypatch, tmp_path) -> None: + """Ensure repeated generation for the same tutorial is deterministic.""" + + first_project = tmp_path / "deterministic_one" + second_project = tmp_path / "deterministic_two" + monkeypatch.setattr( + tempfile, + "mkdtemp", + _tempdir_factory(first_project, second_project), + ) + + generator = ProjectCodeGenerator() + video_analysis = _build_video_analysis( + "Build a Recipe Finder", + "recipe789", + "Builds a recipe finder with searchable ingredient cards.", + ["search filtering", "ingredient cards"], + ) + project_config = {"type": "web", "features": ["responsive_design"]} + + first = asyncio.run(generator.generate_project(video_analysis, project_config)) + second = asyncio.run(generator.generate_project(video_analysis, project_config)) + + first_path = Path(first["project_path"]) + second_path = Path(second["project_path"]) + + for file_name in ("index.html", "main.js", "styles.css", "README.md"): + assert (first_path / file_name).read_text() == (second_path / file_name).read_text()