-
Notifications
You must be signed in to change notification settings - Fork 1
Restore tutorial-specific code generation for BuildPlan and legacy paths #149
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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() | ||||||
|
||||||
| build_plan = build_plan.model_dump() | |
| build_plan = build_plan.model_dump(mode="json") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
|
|
||
| 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() | ||
There was a problem hiding this comment.
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