Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 43 additions & 22 deletions src/youtube_extension/backend/code_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -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%)"
Expand All @@ -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};
Expand Down Expand Up @@ -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}"
Expand All @@ -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.

Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using hasattr() for Pydantic BaseModel detection is less safe and less explicit than using isinstance() type checking

Fix on Vercel

if hasattr(build_plan, "model_dump"):
build_plan = build_plan.model_dump()

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

build_plan.model_dump() may leave StepAction enum instances inside the resulting dict (Pydantic v2 default "python" mode). Downstream code formats step["action"] into README strings and may emit StepAction.CREATE_FILE instead of create_file, and it can also complicate JSON serialization. Consider using model_dump(mode="json") (or otherwise normalizing step actions to plain strings) when converting BuildPlan to a dict.

Suggested change
build_plan = build_plan.model_dump()
build_plan = build_plan.model_dump(mode="json")

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
build_plan = build_plan.model_dump()
build_plan = build_plan.model_dump(mode="json")

Pydantic model_dump() preserves enum instances instead of converting them to JSON-serializable strings

Fix on Vercel


title = (
project_config.get("title")
Expand All @@ -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])
Expand All @@ -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)

Expand All @@ -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 ""
Expand Down
122 changes: 122 additions & 0 deletions tests/test_code_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment on lines +12 to +21

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test module appears to duplicate the same helper functions and test cases found in tests/unit/test_code_generator.py. Keeping both copies will run the same assertions twice and increases maintenance overhead. Consider consolidating these tests into a single location (or factoring shared helpers into fixtures) and deleting the duplicate file/tests.

Copilot uses AI. Check for mistakes.

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."""

Expand Down Expand Up @@ -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()
Loading
Loading