diff --git "a/app/services/ai_service.py" "b/app/services/ai_service.py" new file mode 100644--- /dev/null +++ "b/app/services/ai_service.py" @@ -0,0 +1,3233 @@ +from __future__ import annotations + +import json +import logging +import os +import re +import time +from pathlib import Path +from typing import Any, Mapping, Sequence + +import httpx +from dotenv import load_dotenv + +from .file_service import ( + build_preview_file_tree, + finalize_preview_files, +) + + +load_dotenv() + +DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434" +DEFAULT_OLLAMA_MODEL = "qwen2.5-coder" +FAST_PLANNER_TIMEOUT_SECONDS = 22.0 +DEEP_PREVIEW_TIMEOUT_SECONDS = 70.0 +MIN_CUSTOM_PASS_SECONDS = 8.0 +MAX_CUSTOM_FILES = 8 +MAX_CUSTOM_FILE_LINES = 300 +FAST_PLANNER_RESPONSE_BYTES_LIMIT = 48 * 1024 +REQUIRED_PLANNER_KEYS = { + "projectName", + "projectType", + "selectedStack", + "modules", + "packageRequirements", + "installCommands", + "runCommands", + "requiredInputs", + "envVariables", + "customFiles", +} + +logger = logging.getLogger(__name__) + +STACK_FIELDS = ( + "language", + "frontend", + "backend", + "database", + "aiTools", + "deployment", +) +STACK_DEFAULTS = { + "language": "Auto", + "frontend": "Auto", + "backend": "Auto", + "database": "Auto", + "aiTools": "Auto", + "deployment": "Auto", +} +NONE_LIKE = {"", "Auto", "None"} +YES_ANSWERS = {"yes", "y", "true", "1", "required", "needed"} +NO_ANSWERS = {"no", "n", "false", "0", "none", "not needed"} +BOOLEAN_QUESTION_IDS = {"authentication", "admin_dashboard", "email_smtp", "payment_system"} +SUPPORTED_SCOPE_VALUES = {"full-stack", "backend-only", "frontend-only"} +SUPPORTED_LANGUAGE_VALUES = {"Python", "JavaScript", "TypeScript", "Java"} +SUPPORTED_FRONTEND_VALUES = {"React", "Next.js", "Vue", "HTML/CSS/JavaScript", "None"} +SUPPORTED_BACKEND_VALUES = {"FastAPI", "Flask", "Express", "NestJS", "Spring Boot"} +SUPPORTED_DATABASE_VALUES = {"SQLite", "PostgreSQL", "MySQL", "MongoDB", "None"} +SUPPORTED_AI_VALUES = {"None", "Ollama", "OpenAI API", "LangChain"} +SUPPORTED_DEPLOYMENT_VALUES = {"Render", "Railway", "Vercel", "Docker", "None"} +SUPPORTED_COMPLEXITY_VALUES = {"Simple", "Standard", "Advanced"} +SUPPORTED_ROLE_VALUES = { + "Single user / no roles", + "Basic roles (admin + user)", + "Custom roles needed", +} +CANONICAL_ANSWER_ALIASES = { + "project_scope": { + "full stack": "full-stack", + "full-stack": "full-stack", + "fullstack": "full-stack", + "frontend and backend": "full-stack", + "backend only": "backend-only", + "backend-only": "backend-only", + "api only": "backend-only", + "service only": "backend-only", + "frontend only": "frontend-only", + "frontend-only": "frontend-only", + "ui only": "frontend-only", + }, + "language": { + "python": "Python", + "py": "Python", + "javascript": "JavaScript", + "js": "JavaScript", + "typescript": "TypeScript", + "ts": "TypeScript", + "java": "Java", + }, + "frontend_framework": { + "react": "React", + "next": "Next.js", + "nextjs": "Next.js", + "next.js": "Next.js", + "vue": "Vue", + "vanilla": "HTML/CSS/JavaScript", + "html": "HTML/CSS/JavaScript", + "html/css/javascript": "HTML/CSS/JavaScript", + "plain html": "HTML/CSS/JavaScript", + "none": "None", + "no frontend": "None", + }, + "backend_framework": { + "fastapi": "FastAPI", + "flask": "Flask", + "express": "Express", + "node": "Express", + "node.js": "Express", + "nestjs": "NestJS", + "nest": "NestJS", + "spring": "Spring Boot", + "spring boot": "Spring Boot", + "none": "None", + "no backend": "None", + }, + "database": { + "sqlite": "SQLite", + "postgres": "PostgreSQL", + "postgresql": "PostgreSQL", + "postgre sql": "PostgreSQL", + "mysql": "MySQL", + "mongo": "MongoDB", + "mongodb": "MongoDB", + "none": "None", + "no database": "None", + }, + "ai_integration": { + "none": "None", + "no": "None", + "ollama": "Ollama", + "openai": "OpenAI API", + "openai api": "OpenAI API", + "langchain": "LangChain", + }, + "deployment_target": { + "render": "Render", + "railway": "Railway", + "vercel": "Vercel", + "docker": "Docker", + "container": "Docker", + "none": "None", + }, + "complexity_level": { + "simple": "Simple", + "basic": "Simple", + "beginner": "Simple", + "standard": "Standard", + "normal": "Standard", + "default": "Standard", + "advanced": "Advanced", + "complex": "Advanced", + }, + "user_roles": { + "single": "Single user / no roles", + "single user": "Single user / no roles", + "no roles": "Single user / no roles", + "admin and user": "Basic roles (admin + user)", + "admin + user": "Basic roles (admin + user)", + "basic roles": "Basic roles (admin + user)", + "custom roles": "Custom roles needed", + "custom": "Custom roles needed", + "manager roles": "Custom roles needed", + }, +} + +KEYWORD_MAP = { + "language": { + "Python": ("python", "fastapi", "flask", "django"), + "JavaScript": ("javascript", "node", "express"), + "TypeScript": ("typescript", "next.js", "nextjs", "nest", "nestjs"), + "Java": ("java", "spring", "spring boot"), + }, + "frontend": { + "React": ("react",), + "Next.js": ("next.js", "nextjs"), + "Vue": ("vue",), + "HTML/CSS/JavaScript": ("html", "css", "javascript", "vanilla js", "frontend"), + "None": ("api only", "backend only", "cli", "script"), + }, + "backend": { + "FastAPI": ("fastapi",), + "Flask": ("flask",), + "Express": ("express", "node api"), + "NestJS": ("nestjs", "nest js"), + "Spring Boot": ("spring boot", "spring"), + "None": ("static site", "frontend only"), + }, + "database": { + "SQLite": ("sqlite",), + "PostgreSQL": ("postgres", "postgresql"), + "MySQL": ("mysql",), + "MongoDB": ("mongodb", "mongo"), + "None": ("no database", "without database", "in memory"), + }, + "aiTools": { + "Ollama": ("ollama", "local llm", "qwen", "llama"), + "OpenAI API": ("openai", "gpt"), + "LangChain": ("langchain",), + "None": ("no ai", "without ai"), + }, + "deployment": { + "Render": ("render",), + "Railway": ("railway",), + "Vercel": ("vercel",), + "Docker": ("docker", "container"), + "None": ("no deployment",), + }, +} + + +async def generate_project_preview( + idea: str, + selected_stack: dict[str, str] | None = None, + generation_mode: str = "fast", + final_requirements: str = "", +) -> dict[str, Any]: + from .agent_controller import agent_controller + + return await agent_controller.generate_files( + idea, + selected_stack, + generation_mode, + final_requirements, + ) + + +def prepare_preview_for_output(preview: dict[str, Any]) -> dict[str, Any]: + base_idea = ( + str(preview.get("problemStatement") or "").strip() + or str(preview.get("summary") or "").strip() + or str(preview.get("projectName") or "").strip() + or "Generated project" + ) + requested_stack = normalize_stack_selection(preview.get("selectedStack")) + return normalize_preview(preview, base_idea, requested_stack, "fast") + + +async def generate_project_plan( + idea: str, + requested_stack: dict[str, str], + generation_mode: str, + deadline: float, +) -> dict[str, Any]: + timeout_seconds = remaining_time(deadline) + if timeout_seconds <= 0: + raise TimeoutError("Preview time budget was exhausted before generation started.") + + prompt = build_planning_prompt(idea, requested_stack, generation_mode) + planner_payload = await call_ollama_json( + prompt, + timeout_seconds, + enforce_compact_output=(generation_mode == "fast"), + response_kind="planner", + ) + return validate_planner_payload(planner_payload) + + +async def generate_deep_custom_files( + idea: str, + project_name: str, + selected_stack: dict[str, str], + custom_manifest: list[dict[str, str]], + timeout_seconds: float, +) -> list[dict[str, str]]: + prompt = build_custom_files_prompt(idea, project_name, selected_stack, custom_manifest) + payload = await call_ollama_json( + prompt, + timeout_seconds, + enforce_compact_output=False, + response_kind="custom", + ) + files = normalize_files(payload.get("files")) + + allowed_paths = {item["path"] for item in custom_manifest} + trimmed: list[dict[str, str]] = [] + for file_entry in files: + if file_entry["path"] not in allowed_paths: + continue + content = trim_content_lines(file_entry["content"]) + trimmed.append({"path": file_entry["path"], "content": content}) + return trimmed + + +async def call_ollama_json( + prompt: str, + timeout_seconds: float, + *, + enforce_compact_output: bool, + response_kind: str, +) -> dict[str, Any]: + configured_base_url = str(os.getenv("OLLAMA_BASE_URL") or "").strip() + if not configured_base_url: + raise RuntimeError( + "AI generation is unavailable because OLLAMA_BASE_URL is not configured." + ) + + base_url = configured_base_url.rstrip("/") + model = os.getenv("OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL).strip() or DEFAULT_OLLAMA_MODEL + safe_timeout = max(1.0, timeout_seconds) + request_timeout = httpx.Timeout( + connect=min(3.5, safe_timeout), + read=safe_timeout, + write=min(5.0, safe_timeout), + pool=min(5.0, safe_timeout), + ) + + try: + async with httpx.AsyncClient(timeout=request_timeout) as client: + response = await client.post( + f"{base_url}/api/generate", + json={"model": model, "prompt": prompt, "stream": False}, + ) + except httpx.HTTPError as exc: + raise RuntimeError( + f"AI generation is unavailable because Ollama could not be reached at {base_url}." + ) from exc + + if response.status_code >= 400: + detail = response.text.strip() or f"HTTP {response.status_code}" + raise RuntimeError(f"Ollama returned an error: {detail}") + + payload = response.json() + raw_text = str(payload.get("response") or "").strip() + if not raw_text: + raise RuntimeError("Ollama returned an empty response.") + if enforce_compact_output and len(raw_text.encode("utf-8")) > FAST_PLANNER_RESPONSE_BYTES_LIMIT: + raise ValueError( + f"{response_kind.capitalize()} response exceeded the compact output limit for Fast Mode." + ) + + return parse_model_json(raw_text) + + +def build_planning_prompt( + idea: str, + selected_stack: dict[str, str], + generation_mode: str, +) -> str: + stack_summary = "\n".join( + f"- {field}: {selected_stack.get(field, 'Auto')}" for field in STACK_FIELDS + ) + + return f""" +You are Project Agent. + +Return compact JSON only. No markdown. No commentary. + +Goal: +- Plan a 100% runnable starter project. +- Do not generate the entire project. +- The backend will create standard files from templates. + +Rules: +1. Preserve user-specified language, frameworks, tools, and deployment choices. +2. Respect any non-Auto selectedStack values exactly. +3. Choose beginner-friendly defaults only for missing stack categories. +4. For full-stack output, plan both frontend and backend. +5. Do not include README, dependency manifests, setup scripts, run scripts, env files, config boilerplate, or installed libraries in customFiles. +6. customFiles must contain only project-specific business logic files, max {MAX_CUSTOM_FILES}. +7. Each custom file should be worth generating because it contains app-specific logic or UI, not generic boilerplate. +8. Detect required external inputs and return them in requiredInputs. +9. If the project uses auth, database, email, payments, AI providers, or OAuth, include the needed keys in requiredInputs. +10. Do not claim this is a production-finished application. It is a 100% runnable starter project. + +Return this exact top-level shape: +{{ + "projectName": "string", + "projectType": "frontend-only | backend-only | full-stack", + "selectedStack": {{ + "language": "string", + "frontend": "string", + "backend": "string", + "database": "string", + "aiTools": "string", + "deployment": "string" + }}, + "modules": [ + {{ + "name": "string", + "purpose": "string", + "keyFiles": ["string"] + }} + ], + "packageRequirements": ["string"], + "installCommands": ["string"], + "runCommands": ["string"], + "requiredInputs": [ + {{ + "name": "string", + "required": true, + "example": "string", + "whereToAdd": ".env", + "purpose": "string" + }} + ], + "envVariables": [ + {{ + "name": "string", + "value": "string", + "description": "string" + }} + ], + "customFiles": [ + {{ + "path": "relative/path", + "purpose": "why this file is needed" + }} + ] +}} + +Mode: +- generationMode: {generation_mode} +- Fast Mode should keep customFiles compact and rely on backend templates for standard files. +- Deep Mode may propose richer custom business files, but still max {MAX_CUSTOM_FILES}. + +Selected stack: +{stack_summary} + +Project idea: +{idea} +""".strip() + + +def build_custom_files_prompt( + idea: str, + project_name: str, + selected_stack: dict[str, str], + custom_manifest: list[dict[str, str]], +) -> str: + manifest_json = json.dumps(custom_manifest, indent=2) + stack_json = json.dumps(selected_stack, indent=2) + return f""" +You are Project Agent. + +Generate only the custom business-logic files listed below. +Return JSON only in this shape: +{{ + "files": [ + {{ + "path": "relative/path", + "content": "file content" + }} + ] +}} + +Rules: +1. Generate only the listed files. +2. Do not generate standard boilerplate, dependency manifests, docs, setup scripts, or env files. +3. Keep each file under {MAX_CUSTOM_FILE_LINES} lines. +4. Use the selected stack exactly. +5. Produce runnable starter logic, not empty placeholders. + +Project name: {project_name} +Project idea: {idea} +Selected stack: +{stack_json} + +Requested custom files: +{manifest_json} +""".strip() + + +def parse_model_json(raw_text: str) -> dict[str, Any]: + text = strip_markdown_fences(raw_text) + candidate = extract_json_object(text) + if not candidate: + raise ValueError("Could not recover a JSON object from the AI response.") + + parsed = json.loads(candidate) + if not isinstance(parsed, dict): + raise ValueError("AI response JSON was not an object.") + return parsed + + +def strip_markdown_fences(text: str) -> str: + stripped = text.strip() + if stripped.startswith("```"): + stripped = re.sub(r"^```[a-zA-Z0-9_-]*\s*", "", stripped) + stripped = re.sub(r"\s*```$", "", stripped) + return stripped.strip() + + +def extract_json_object(text: str) -> str: + start = text.find("{") + if start == -1: + return "" + + depth = 0 + in_string = False + escape = False + for index in range(start, len(text)): + char = text[index] + if in_string: + if escape: + escape = False + elif char == "\\": + escape = True + elif char == '"': + in_string = False + continue + + if char == '"': + in_string = True + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return text[start : index + 1] + + return "" + + +def build_fallback_preview( + idea: str, + requested_stack: dict[str, str], + generation_mode: str, + reason: str, + generation_context: str | None = None, +) -> dict[str, Any]: + preview = normalize_preview( + {}, + idea, + requested_stack, + generation_mode, + requirements_context=generation_context or idea, + ) + fallback_note = ( + "Fast Mode returned a complete template-based preview because AI generation was unavailable." + if generation_mode == "fast" + else "Deep Mode AI enrichment was unavailable, so a complete template-based preview was returned." + ) + preview["assumptions"] = dedupe_list( + [ + fallback_note, + f"Template fallback preview was generated because the AI planner could not complete in time or returned invalid output: {reason}", + *preview.get("assumptions", []), + ] + ) + return preview + + +def build_generation_context( + idea: str, + final_requirements: str = "", + generation_mode: str = "fast", +) -> str: + cleaned_idea = idea.strip() + cleaned_requirements = final_requirements.strip() + if not cleaned_requirements: + return cleaned_idea + if generation_mode == "fast": + return ( + f"Idea: {cleaned_idea}\n" + "Final requirements:\n" + f"{cleaned_requirements}" + ).strip() + return ( + f"Original idea:\n{cleaned_idea}\n\n" + "Finalized requirements from the agent conversation:\n" + f"{cleaned_requirements}" + ).strip() + + +def analyze_project_idea(idea: str) -> dict[str, Any]: + detected_choices = detect_user_choices(idea) + suggested_stack = resolve_selected_stack( + idea, + normalize_stack_selection({}), + None, + detected_choices, + ) + project_kind = determine_project_kind(suggested_stack, infer_declared_project_type(idea)) + questions = build_agent_questions(idea, suggested_stack, project_kind) + return { + "understanding": build_agent_understanding(idea, suggested_stack, project_kind), + "assumptions": build_agent_analysis_assumptions(suggested_stack, project_kind, questions), + "suggestedStack": suggested_stack, + "stackReasons": build_stack_reasons(suggested_stack, project_kind), + "questions": questions, + "detectedProjectType": project_kind["label"], + "confidence": compute_agent_confidence(idea, detected_choices, questions, project_kind), + } + + +def finalize_agent_requirements( + idea: str, + answers: Mapping[str, Any] | None, + suggested_stack: Mapping[str, Any] | None, +) -> dict[str, Any]: + normalized_answers = normalize_agent_answers(answers) + selected_stack = normalize_stack_selection(suggested_stack) + selected_stack = apply_agent_answers_to_stack(idea, selected_stack, normalized_answers) + project_kind = determine_project_kind(selected_stack, normalized_answers.get("project_scope")) + final_requirements = build_final_requirements_summary( + idea, + normalized_answers, + selected_stack, + project_kind, + ) + assumptions = build_agent_finalize_assumptions( + normalized_answers, + selected_stack, + project_kind, + ) + return { + "finalRequirements": final_requirements, + "selectedStack": selected_stack, + "assumptions": assumptions, + } + + +def infer_declared_project_type(idea: str) -> str: + lowered = idea.lower() + if any(token in lowered for token in ("full stack", "full-stack", "frontend and backend")): + return "full-stack" + if any(token in lowered for token in ("backend only", "api only", "service only")): + return "backend-only" + if any(token in lowered for token in ("frontend only", "landing page only", "static site")): + return "frontend-only" + return "" + + +def build_agent_understanding( + idea: str, + suggested_stack: dict[str, str], + project_kind: dict[str, Any], +) -> str: + description = idea.strip().rstrip(".") + if len(description) > 180: + description = description[:177].rstrip() + "..." + stack_bits: list[str] = [] + if project_kind["hasFrontend"]: + stack_bits.append(suggested_stack["frontend"]) + if project_kind["hasBackend"]: + stack_bits.append(suggested_stack["backend"]) + stack_text = " + ".join(bit for bit in stack_bits if bit not in NONE_LIKE) or suggested_stack["language"] + return ( + f"I understood your idea as a {project_kind['label']} project focused on {description}. " + f"My starting recommendation is {stack_text} with template-generated runtime structure so you can get to a runnable starter quickly." + ) + + +def build_agent_analysis_assumptions( + suggested_stack: dict[str, str], + project_kind: dict[str, Any], + questions: list[dict[str, Any]], +) -> list[str]: + assumptions = [ + "Questions are limited to decisions that change architecture, dependencies, or required files.", + "If you skip the questions, the suggested stack and defaults will be used to generate the project.", + ] + if project_kind["isFullStack"]: + assumptions.append("The app currently looks like a full-stack build, so both frontend and backend are planned by default.") + elif project_kind["hasBackend"]: + assumptions.append("The current recommendation leans backend-first and will keep the frontend optional unless you add it.") + else: + assumptions.append("The current recommendation treats this as a frontend-focused starter unless you add backend needs.") + if questions: + assumptions.append("Each question starts with your own input first. If you leave it blank, the agent will suggest a default and explain the benefit.") + if suggested_stack.get("deployment") == "Render": + assumptions.append("Render is used as a deployment default when no target is mentioned explicitly.") + return dedupe_list(assumptions) + + +def build_stack_reasons( + suggested_stack: dict[str, str], + project_kind: dict[str, Any], +) -> list[str]: + reasons: list[str] = [] + backend = suggested_stack.get("backend", "Auto") + frontend = suggested_stack.get("frontend", "Auto") + database = suggested_stack.get("database", "Auto") + deployment = suggested_stack.get("deployment", "Auto") + ai_tools = suggested_stack.get("aiTools", "Auto") + + if backend == "FastAPI": + reasons.append("FastAPI is a beginner-friendly Python API framework that pairs well with the current runnable starter templates.") + elif backend == "Express": + reasons.append("Express keeps a Node-based backend lightweight and easy to extend for API-first projects.") + elif backend == "Spring Boot": + reasons.append("Spring Boot was chosen because the idea strongly suggests a Java-based backend stack.") + + if frontend == "React": + reasons.append("React with Vite gives a fast local frontend starter and aligns with the existing component templates.") + elif frontend == "HTML/CSS/JavaScript": + reasons.append("A lightweight HTML/CSS/JavaScript frontend keeps the starter simple when a framework was not clearly required.") + elif frontend == "None": + reasons.append("No frontend was selected because the current idea reads like an API, worker, or backend-only service.") + + if database == "SQLite": + reasons.append("SQLite keeps local setup simple for a runnable starter when the app does not need heavier infrastructure yet.") + elif database == "PostgreSQL": + reasons.append("PostgreSQL is a strong default when the app looks multi-user, relational, or production-oriented.") + elif database == "None": + reasons.append("No database was selected because the current idea can start without persistent storage.") + + if ai_tools == "Ollama": + reasons.append("Ollama stays the local-first AI default when the idea explicitly suggests AI features.") + elif ai_tools == "None": + reasons.append("No AI tool was added because the current idea does not require model integration.") + + if deployment == "Render": + reasons.append("Render is the default deployment target because it is simple for web apps and matches the current starter flow.") + elif deployment == "Docker": + reasons.append("Docker is selected because the idea or deployment preference points toward containerized delivery.") + + if project_kind["isFullStack"]: + reasons.append("Both frontend and backend are included because the idea appears to need a user interface and a service layer.") + return dedupe_list(reasons) + + +def build_agent_questions( + idea: str, + suggested_stack: dict[str, str], + project_kind: dict[str, Any], +) -> list[dict[str, Any]]: + lowered = idea.lower() + questions: list[dict[str, Any]] = [] + + def add_question( + question_id: str, + question: str, + question_type: str, + default: str, + reason: str, + options: Sequence[str] | None = None, + ) -> None: + questions.append( + { + "id": question_id, + "question": question, + "type": question_type, + "options": list(options or []), + "default": default, + "reason": reason, + } + ) + + has_explicit_frontend = bool(match_keyword("frontend", lowered)) + has_explicit_backend = bool(match_keyword("backend", lowered)) + + if not infer_declared_project_type(idea) and not (has_explicit_frontend and has_explicit_backend): + add_question( + "project_scope", + "Do you need both a frontend and a backend for this project?", + "choice", + project_kind["label"], + "If you leave this blank, I will suggest the current project scope because it keeps the runnable starter aligned with the folders and runtime scripts your idea appears to need.", + ["full-stack", "backend-only", "frontend-only"], + ) + + if not match_keyword("backend", lowered) and project_kind["hasBackend"]: + add_question( + "backend_framework", + "Which backend framework do you prefer?", + "choice", + suggested_stack["backend"], + f"If you leave this blank, I will suggest {suggested_stack['backend']} because it matches the current starter templates and keeps backend setup straightforward.", + ["FastAPI", "Flask", "Express", "NestJS", "Spring Boot"], + ) + + if not match_keyword("frontend", lowered) and project_kind["hasFrontend"]: + add_question( + "frontend_framework", + "Which frontend framework should the starter use?", + "choice", + suggested_stack["frontend"], + f"If you leave this blank, I will suggest {suggested_stack['frontend']} because it gives the cleanest starter structure for the current UI needs.", + ["React", "Next.js", "Vue", "HTML/CSS/JavaScript", "None"], + ) + + if not match_keyword("language", lowered) and not match_keyword("backend", lowered): + add_question( + "language", + "Do you want to lock in a preferred programming language?", + "choice", + suggested_stack["language"], + f"If you leave this blank, I will suggest {suggested_stack['language']} because it fits the recommended stack and keeps the starter consistent.", + ["Python", "JavaScript", "TypeScript", "Java"], + ) + + if project_kind["hasBackend"] and not match_keyword("database", lowered): + add_question( + "database", + "Which database should the starter prepare for?", + "choice", + suggested_stack["database"], + f"If you leave this blank, I will suggest {suggested_stack['database']} because it gives the best balance of simple setup and starter reliability for this project.", + ["SQLite", "PostgreSQL", "MySQL", "MongoDB", "None"], + ) + + if project_kind["hasBackend"] and not _context_mentions_any(lowered, ("auth", "authentication", "login", "signup", "jwt")): + add_question( + "authentication", + "Do you need authentication in the starter?", + "boolean", + "No", + "If you leave this blank, I will suggest No so the starter stays simpler unless your app clearly needs protected user flows.", + ["Yes", "No"], + ) + + if project_kind["hasBackend"] and not _context_mentions_any(lowered, ("role", "roles", "permissions", "admin", "staff", "manager")): + add_question( + "user_roles", + "Do you need multiple user roles?", + "choice", + "Single user / no roles", + "If you leave this blank, I will suggest a single-user or no-roles setup so the starter stays easy to run before you add permissions logic.", + ["Single user / no roles", "Basic roles (admin + user)", "Custom roles needed"], + ) + + if project_kind["hasFrontend"] and not _context_mentions_any(lowered, ("admin dashboard", "admin panel", "back office")): + add_question( + "admin_dashboard", + "Should the starter include an admin dashboard path?", + "boolean", + "No", + "If you leave this blank, I will suggest No so the starter focuses on the main product flow before adding admin-specific pages.", + ["Yes", "No"], + ) + + if project_kind["hasBackend"] and not _context_mentions_any(lowered, ("email", "smtp", "mail", "newsletter", "verification email", "contact form")): + add_question( + "email_smtp", + "Will the project need email or SMTP support?", + "boolean", + "No", + "If you leave this blank, I will suggest No so the starter avoids extra SMTP setup unless email is part of the core workflow.", + ["Yes", "No"], + ) + + if project_kind["hasBackend"] and not _context_mentions_any(lowered, ("payment", "payments", "stripe", "checkout", "subscription", "billing")): + add_question( + "payment_system", + "Will the project need payment processing?", + "boolean", + "No", + "If you leave this blank, I will suggest No so the starter avoids payment keys and billing setup unless your app clearly needs them.", + ["Yes", "No"], + ) + + if project_kind["hasBackend"] and not _context_mentions_any(lowered, ("api", "integration", "webhook", "third-party", "external service")): + add_question( + "external_apis", + "Do you already know any external APIs or third-party services this project must connect to?", + "text", + "None", + "If you leave this blank, I will assume no required external APIs so the starter stays self-contained and quicker to run locally.", + ) + + if ( + not match_keyword("aiTools", lowered) + and _context_mentions_any( + lowered, + ("ai", "assistant", "agent", "chatbot", "llm", "openai", "ollama", "summar", "recommendation"), + ) + ): + add_question( + "ai_integration", + "Do you want AI integration in the starter?", + "choice", + "None", + "If you leave this blank, I will suggest no AI integration unless the idea clearly needs model tooling and provider keys.", + ["None", "Ollama", "OpenAI API", "LangChain"], + ) + + if not match_keyword("deployment", lowered): + add_question( + "deployment_target", + "Where do you expect to deploy this project first?", + "choice", + suggested_stack["deployment"], + f"If you leave this blank, I will suggest {suggested_stack['deployment']} because it matches the current deployment defaults and generated starter files.", + ["Render", "Railway", "Vercel", "Docker", "None"], + ) + + if len(re.findall(r"[A-Za-z0-9]+", idea)) < 18: + add_question( + "complexity_level", + "How ambitious should the first starter be?", + "choice", + "Standard", + "If you leave this blank, I will suggest Standard so the starter includes useful structure without becoming heavy too early.", + ["Simple", "Standard", "Advanced"], + ) + + return questions + + +def compute_agent_confidence( + idea: str, + detected_choices: list[str], + questions: list[dict[str, Any]], + project_kind: dict[str, Any], +) -> int: + score = 35 + word_count = len(re.findall(r"[A-Za-z0-9]+", idea)) + if word_count >= 12: + score += 15 + elif word_count >= 6: + score += 8 + + score += min(len(detected_choices) * 8, 32) + if infer_declared_project_type(idea): + score += 8 + if project_kind["isFullStack"]: + score += 4 + score -= min(len(questions) * 3, 24) + return max(20, min(95, score)) + + +def normalize_agent_answers(answers: Mapping[str, Any] | None) -> dict[str, str]: + if not isinstance(answers, Mapping): + return {} + normalized: dict[str, str] = {} + for key, value in answers.items(): + question_id = str(key).strip() + normalized[question_id] = normalize_agent_answer_value(question_id, value) + return normalized + + +def apply_agent_answers_to_stack( + idea: str, + selected_stack: dict[str, str], + answers: Mapping[str, str], +) -> dict[str, str]: + resolved = normalize_stack_selection(selected_stack) + scope = normalize_project_scope( + answers.get("project_scope", infer_declared_project_type(idea) or determine_project_kind(resolved)["label"]) + ) + scope_value = scope.strip().lower() + + frontend_choice = supported_answer(answers.get("frontend_framework"), SUPPORTED_FRONTEND_VALUES) + backend_choice = supported_answer(answers.get("backend_framework"), SUPPORTED_BACKEND_VALUES) + language_choice = supported_answer(answers.get("language"), SUPPORTED_LANGUAGE_VALUES) + database_choice = supported_answer(answers.get("database"), SUPPORTED_DATABASE_VALUES) + deployment_choice = supported_answer(answers.get("deployment_target"), SUPPORTED_DEPLOYMENT_VALUES) + ai_choice = supported_answer(answers.get("ai_integration"), SUPPORTED_AI_VALUES) + + if frontend_choice: + resolved["frontend"] = frontend_choice + if backend_choice: + resolved["backend"] = backend_choice + if language_choice: + resolved["language"] = language_choice + if database_choice: + resolved["database"] = database_choice + if deployment_choice: + resolved["deployment"] = deployment_choice + if ai_choice: + resolved["aiTools"] = ai_choice + + if scope_value == "backend-only": + resolved["frontend"] = "None" + if resolved["backend"] in NONE_LIKE: + resolved["backend"] = infer_backend(idea.lower()) + elif scope_value == "frontend-only": + resolved["backend"] = "None" + resolved["database"] = "None" + if resolved["frontend"] in NONE_LIKE: + resolved["frontend"] = infer_frontend(idea.lower(), "None") + else: + if resolved["frontend"] in NONE_LIKE: + resolved["frontend"] = "React" + if resolved["backend"] in NONE_LIKE: + resolved["backend"] = "FastAPI" + if resolved["database"] in {"Auto", ""}: + resolved["database"] = infer_database(idea.lower(), resolved["backend"]) + + if resolved["language"] in {"Auto", ""}: + resolved["language"] = infer_language(idea.lower(), resolved["frontend"], resolved["backend"]) + return resolved + + +def build_final_requirements_summary( + idea: str, + answers: Mapping[str, str], + selected_stack: dict[str, str], + project_kind: dict[str, Any], +) -> str: + lines = [ + f"Original idea: {idea.strip()}", + f"Project scope: {project_kind['label']}", + "Selected stack:", + f"- Language: {selected_stack['language']}", + f"- Frontend: {selected_stack['frontend']}", + f"- Backend: {selected_stack['backend']}", + f"- Database: {selected_stack['database']}", + f"- AI / Tools: {selected_stack['aiTools']}", + f"- Deployment: {selected_stack['deployment']}", + ] + + details: list[str] = [] + if _is_yes_answer(answers.get("authentication")): + details.append("Include authentication-ready structure and required secrets.") + if _is_yes_answer(answers.get("admin_dashboard")): + details.append("Include admin-oriented navigation or management surface in the starter.") + if _is_yes_answer(answers.get("email_smtp")): + details.append("Prepare SMTP/email configuration and service wiring.") + if _is_yes_answer(answers.get("payment_system")): + details.append("Prepare payment provider integration points and required keys.") + if answers.get("user_roles") and answers.get("user_roles") != "Single user / no roles": + details.append(f"Support user access model: {answers['user_roles']}.") + if answers.get("external_apis") and answers.get("external_apis").lower() not in {"", "none", "no"}: + details.append(f"Prepare integration boundaries for: {answers['external_apis']}.") + if answers.get("complexity_level"): + details.append(f"Target complexity level: {answers['complexity_level']}.") + if answers.get("ai_integration") and answers.get("ai_integration") not in {"", "None"}: + details.append(f"Prepare AI integration around: {answers['ai_integration']}.") + details.extend(build_unmapped_preference_notes(answers, selected_stack)) + + if details: + lines.append("Architectural requirements:") + lines.extend(f"- {item}" for item in details) + else: + lines.append("Architectural requirements:") + lines.append("- Use the suggested defaults for unspecified decisions and keep the starter runnable-first.") + + return "\n".join(lines).strip() + + +def build_agent_finalize_assumptions( + answers: Mapping[str, str], + selected_stack: dict[str, str], + project_kind: dict[str, Any], +) -> list[str]: + assumptions = [ + "Final requirements were synthesized from the original idea, your answers, and the selected stack.", + "Suggested defaults were used only where you left a question blank, so generation can continue without extra back-and-forth.", + ] + if project_kind["isFullStack"]: + assumptions.append("Frontend and backend will both be generated in separate folders.") + if _is_yes_answer(answers.get("authentication")): + assumptions.append("Authentication-related secrets and starter environment variables will be included where needed.") + if selected_stack.get("deployment") == "Docker": + assumptions.append("Docker-oriented deployment files will be included where the starter templates support them.") + return dedupe_list(assumptions) + + +def _is_yes_answer(value: str | None) -> bool: + return str(value or "").strip().lower() in YES_ANSWERS + + +def normalize_agent_answer_value(question_id: str, value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + + lowered = text.lower() + if question_id in BOOLEAN_QUESTION_IDS: + if lowered in YES_ANSWERS: + return "Yes" + if lowered in NO_ANSWERS: + return "No" + + if question_id == "project_scope": + return normalize_project_scope(text) + + alias_map = CANONICAL_ANSWER_ALIASES.get(question_id, {}) + if lowered in alias_map: + return alias_map[lowered] + + if question_id == "user_roles": + if "admin" in lowered and "user" in lowered: + return "Basic roles (admin + user)" + if "custom" in lowered or "manager" in lowered or "permission" in lowered: + return "Custom roles needed" + if "single" in lowered or "no role" in lowered: + return "Single user / no roles" + + if question_id == "external_apis" and lowered in NO_ANSWERS: + return "None" + + return text + + +def normalize_project_scope(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + lowered = text.lower() + return CANONICAL_ANSWER_ALIASES["project_scope"].get(lowered, text) + + +def supported_answer(value: str | None, supported_values: set[str]) -> str: + text = str(value or "").strip() + return text if text in supported_values else "" + + +def build_unmapped_preference_notes( + answers: Mapping[str, str], + selected_stack: Mapping[str, str], +) -> list[str]: + notes: list[str] = [] + mappings = [ + ("language", "Language", SUPPORTED_LANGUAGE_VALUES, "language"), + ("frontend_framework", "Frontend framework", SUPPORTED_FRONTEND_VALUES, "frontend"), + ("backend_framework", "Backend framework", SUPPORTED_BACKEND_VALUES, "backend"), + ("database", "Database", SUPPORTED_DATABASE_VALUES, "database"), + ("ai_integration", "AI integration", SUPPORTED_AI_VALUES, "aiTools"), + ("deployment_target", "Deployment target", SUPPORTED_DEPLOYMENT_VALUES, "deployment"), + ] + + for answer_key, label, supported_values, stack_key in mappings: + raw_value = str(answers.get(answer_key, "")).strip() + if not raw_value or raw_value in supported_values: + continue + notes.append( + f"{label} preference noted: {raw_value}. The runnable starter keeps {selected_stack.get(stack_key, 'the suggested default')} as the safe template-backed default for now." + ) + return notes + + +def preview_budget_seconds(generation_mode: str) -> float: + return FAST_PLANNER_TIMEOUT_SECONDS if generation_mode == "fast" else DEEP_PREVIEW_TIMEOUT_SECONDS + + +def validate_planner_payload(payload: Any) -> dict[str, Any]: + if not isinstance(payload, Mapping): + raise ValueError("Planner response was not a JSON object.") + + missing_keys = sorted(REQUIRED_PLANNER_KEYS.difference(payload.keys())) + if missing_keys: + raise ValueError(f"Planner response was partial and missed required keys: {', '.join(missing_keys)}") + + if not isinstance(payload.get("selectedStack"), Mapping): + raise ValueError("Planner response did not include a valid selectedStack object.") + if not isinstance(payload.get("modules"), Sequence) or isinstance(payload.get("modules"), (str, bytes, bytearray)): + raise ValueError("Planner response did not include a valid modules list.") + if not isinstance(payload.get("packageRequirements"), Sequence) or isinstance(payload.get("packageRequirements"), (str, bytes, bytearray)): + raise ValueError("Planner response did not include a valid packageRequirements list.") + if not isinstance(payload.get("installCommands"), Sequence) or isinstance(payload.get("installCommands"), (str, bytes, bytearray)): + raise ValueError("Planner response did not include a valid installCommands list.") + if not isinstance(payload.get("runCommands"), Sequence) or isinstance(payload.get("runCommands"), (str, bytes, bytearray)): + raise ValueError("Planner response did not include a valid runCommands list.") + if not isinstance(payload.get("requiredInputs"), Sequence) or isinstance(payload.get("requiredInputs"), (str, bytes, bytearray)): + raise ValueError("Planner response did not include a valid requiredInputs list.") + if not isinstance(payload.get("envVariables"), Sequence) or isinstance(payload.get("envVariables"), (str, bytes, bytearray)): + raise ValueError("Planner response did not include a valid envVariables list.") + if not isinstance(payload.get("customFiles"), Sequence) or isinstance(payload.get("customFiles"), (str, bytes, bytearray)): + raise ValueError("Planner response did not include a valid customFiles list.") + + normalized_payload = dict(payload) + normalized_payload["customFiles"] = list(payload.get("customFiles", []))[:MAX_CUSTOM_FILES] + return normalized_payload + + +def normalize_preview( + raw_preview: Mapping[str, Any] | None, + idea: str, + requested_stack: dict[str, str] | None = None, + generation_mode: str = "fast", + requirements_context: str | None = None, +) -> dict[str, Any]: + raw = dict(raw_preview or {}) + mode = normalize_generation_mode(generation_mode) + requested = normalize_stack_selection(requested_stack) + planning_context = requirements_context or idea + detected_choices = dedupe_list( + normalize_string_list(raw.get("detectedUserChoices")) or detect_user_choices(idea) + ) + selected_stack = resolve_selected_stack( + idea=idea, + requested_stack=requested, + model_stack=raw.get("selectedStack"), + detected_choices=detected_choices, + ) + project_kind = determine_project_kind(selected_stack, raw.get("projectType")) + project_name = clean_project_name(raw.get("projectName"), idea) + + modules = merge_modules( + normalize_modules(raw.get("modules")), + build_default_modules(selected_stack, project_kind), + ) + required_inputs = merge_required_inputs( + normalize_required_inputs(raw.get("requiredInputs")), + build_required_inputs(planning_context, selected_stack, project_kind, modules), + ) + env_variables = merge_env_variables( + normalize_env_variables(raw.get("envVariables")), + required_inputs_to_env_variables(required_inputs), + ) + package_requirements = dedupe_list( + normalize_string_list(raw.get("packageRequirements")) + + build_package_requirements(selected_stack, project_kind) + ) + install_commands = dedupe_list( + normalize_string_list(raw.get("installCommands")) + + build_install_commands(selected_stack, project_kind) + ) + run_commands = dedupe_list( + normalize_string_list(raw.get("runCommands")) + + build_run_commands(selected_stack, project_kind) + ) + + custom_manifest = normalize_custom_manifest(raw.get("customFiles"), selected_stack, project_kind) + validated_files = finalize_preview_files( + project_name=project_name, + selected_stack=selected_stack, + project_kind=project_kind, + custom_manifest=custom_manifest, + raw_files=raw.get("files"), + ) + + summary = ( + str(raw.get("summary") or "").strip() + or build_summary(project_name, project_kind, selected_stack, mode) + ) + problem_statement = ( + str(raw.get("problemStatement") or "").strip() + or idea.strip() + or f"Build a starter project for {project_name}." + ) + assumptions = dedupe_list( + normalize_string_list(raw.get("assumptions")) + + build_assumptions(selected_stack, project_kind, requested, mode, bool(custom_manifest)) + ) + architecture = dedupe_list( + normalize_string_list(raw.get("architecture")) + + build_architecture(selected_stack, project_kind) + ) + chosen_stack = build_chosen_stack(selected_stack) + + file_tree = build_preview_file_tree(validated_files, include_env_example=bool(env_variables)) + + return { + "projectName": project_name, + "detectedUserChoices": detected_choices, + "selectedStack": selected_stack, + "chosenStack": chosen_stack, + "assumptions": assumptions, + "summary": summary, + "problemStatement": problem_statement, + "architecture": architecture, + "modules": modules, + "packageRequirements": package_requirements, + "installCommands": install_commands, + "runCommands": run_commands, + "requiredInputs": required_inputs, + "envVariables": env_variables, + "fileTree": file_tree, + "files": validated_files, + } + + +def apply_custom_file_overrides( + preview: dict[str, Any], custom_files: list[dict[str, str]] +) -> dict[str, Any]: + merged_files = merge_file_entries(preview.get("files", []), custom_files) + selected_stack = normalize_stack_selection(preview.get("selectedStack")) + project_kind = determine_project_kind(selected_stack) + validated_files = finalize_preview_files( + project_name=str(preview.get("projectName") or "Generated Project"), + selected_stack=selected_stack, + project_kind=project_kind, + raw_files=merged_files, + ) + env_variables = normalize_env_variables(preview.get("envVariables")) + + preview["files"] = validated_files + preview["fileTree"] = build_preview_file_tree( + validated_files, + include_env_example=bool(env_variables), + ) + return preview + + +def normalize_generation_mode(value: Any) -> str: + return "deep" if str(value or "").strip().lower() == "deep" else "fast" + + +def remaining_time(deadline: float) -> float: + return max(0.0, deadline - time.monotonic()) + + +def normalize_stack_selection(selection: Any) -> dict[str, str]: + data = selection if isinstance(selection, Mapping) else {} + normalized: dict[str, str] = {} + for field in STACK_FIELDS: + value = str(data.get(field) or STACK_DEFAULTS[field]).strip() + normalized[field] = value or STACK_DEFAULTS[field] + return normalized + + +def resolve_selected_stack( + idea: str, + requested_stack: dict[str, str], + model_stack: Any, + detected_choices: list[str], +) -> dict[str, str]: + model_selection = normalize_stack_selection(model_stack) + resolved = normalize_stack_selection(requested_stack) + lowered_idea = idea.lower() + + backend = pick_stack_value( + requested_stack.get("backend"), + model_selection.get("backend"), + infer_backend(lowered_idea), + ) + frontend = pick_stack_value( + requested_stack.get("frontend"), + model_selection.get("frontend"), + infer_frontend(lowered_idea, backend), + ) + language = pick_stack_value( + requested_stack.get("language"), + model_selection.get("language"), + infer_language(lowered_idea, frontend, backend), + ) + database = pick_stack_value( + requested_stack.get("database"), + model_selection.get("database"), + infer_database(lowered_idea, backend), + ) + ai_tools = pick_stack_value( + requested_stack.get("aiTools"), + model_selection.get("aiTools"), + infer_ai_tools(lowered_idea), + ) + deployment = pick_stack_value( + requested_stack.get("deployment"), + model_selection.get("deployment"), + infer_deployment(lowered_idea), + ) + + resolved.update( + { + "language": language, + "frontend": frontend, + "backend": backend, + "database": database, + "aiTools": ai_tools, + "deployment": deployment, + } + ) + + if not detected_choices and all(value == "Auto" for value in requested_stack.values()): + resolved["backend"] = resolved["backend"] if resolved["backend"] != "Auto" else "FastAPI" + resolved["frontend"] = resolved["frontend"] if resolved["frontend"] != "Auto" else "React" + resolved["language"] = resolved["language"] if resolved["language"] != "Auto" else "Python" + resolved["database"] = resolved["database"] if resolved["database"] != "Auto" else "SQLite" + resolved["aiTools"] = resolved["aiTools"] if resolved["aiTools"] != "Auto" else "None" + resolved["deployment"] = resolved["deployment"] if resolved["deployment"] != "Auto" else "Render" + + return resolved + + +def pick_stack_value(requested: str | None, suggested: str | None, inferred: str) -> str: + for candidate in (requested, suggested): + if candidate is not None and str(candidate).strip() not in {"", "Auto"}: + return str(candidate).strip() + return inferred + + +def infer_language(idea: str, frontend: str, backend: str) -> str: + detected = match_keyword("language", idea) + if detected: + return detected + if backend in {"FastAPI", "Flask"}: + return "Python" + if backend == "Express": + return "JavaScript" + if backend == "NestJS": + return "TypeScript" + if backend == "Spring Boot": + return "Java" + if frontend in {"React", "Next.js", "Vue", "HTML/CSS/JavaScript"}: + return "JavaScript" + return "Python" + + +def infer_frontend(idea: str, backend: str) -> str: + detected = match_keyword("frontend", idea) + if detected: + return detected + if any(token in idea for token in ("api only", "backend only", "worker", "cli", "automation")): + return "None" + if any(token in idea for token in ("dashboard", "web app", "portal", "browser", "frontend", "website")): + return "React" + if backend in {"FastAPI", "Express", "NestJS", "Spring Boot"}: + return "React" + return "None" + + +def infer_backend(idea: str) -> str: + detected = match_keyword("backend", idea) + if detected: + return detected + if any(token in idea for token in ("frontend only", "static site", "landing page only")): + return "None" + if "spring" in idea or "java" in idea: + return "Spring Boot" + if "node" in idea or "express" in idea: + return "Express" + return "FastAPI" + + +def infer_database(idea: str, backend: str) -> str: + detected = match_keyword("database", idea) + if detected: + return detected + if any(token in idea for token in ("no database", "without database", "in memory")): + return "None" + if backend in {"FastAPI", "Flask"}: + return "SQLite" + if backend in {"Express", "NestJS", "Spring Boot"}: + return "PostgreSQL" + return "None" + + +def infer_ai_tools(idea: str) -> str: + detected = match_keyword("aiTools", idea) + if detected: + return detected + if any(token in idea for token in ("ai", "assistant", "agent", "generator", "chatbot", "llm")): + return "Ollama" + return "None" + + +def infer_deployment(idea: str) -> str: + detected = match_keyword("deployment", idea) + return detected or "Render" + + +def match_keyword(category: str, idea: str) -> str: + for value, keywords in KEYWORD_MAP[category].items(): + if any(keyword in idea for keyword in keywords): + return value + return "" + + +def detect_user_choices(idea: str) -> list[str]: + lowered = idea.lower() + detected: list[str] = [] + labels = { + "language": "Language", + "frontend": "Frontend", + "backend": "Backend", + "database": "Database", + "aiTools": "AI / Tools", + "deployment": "Deployment", + } + for category, mapping in KEYWORD_MAP.items(): + for value, keywords in mapping.items(): + if any(keyword in lowered for keyword in keywords): + detected.append(f"{labels[category]}: {value}") + break + return dedupe_list(detected) + + +def determine_project_kind( + selected_stack: dict[str, str], + declared_type: Any = None, +) -> dict[str, Any]: + declared = str(declared_type or "").strip().lower() + frontend = selected_stack.get("frontend", "None") + backend = selected_stack.get("backend", "None") + has_frontend = frontend not in NONE_LIKE + has_backend = backend not in NONE_LIKE + + if declared == "frontend-only": + has_backend = False + elif declared == "backend-only": + has_frontend = False + elif declared == "full-stack": + has_frontend = True + has_backend = True + + is_full_stack = has_frontend and has_backend + if is_full_stack: + label = "full-stack" + minimum_files = 15 + elif has_backend: + label = "backend-only" + minimum_files = 8 + else: + label = "frontend-only" + minimum_files = 6 + + return { + "hasFrontend": has_frontend, + "hasBackend": has_backend, + "isFullStack": is_full_stack, + "label": label, + "minimumFiles": minimum_files, + } + + +def clean_project_name(raw_name: Any, idea: str) -> str: + if isinstance(raw_name, str) and raw_name.strip(): + return raw_name.strip()[:80] + + words = re.findall(r"[A-Za-z0-9]+", idea) + if not words: + return "Project Agent Output" + return " ".join(word.capitalize() for word in words[:4]) + + +def build_summary( + project_name: str, + project_kind: dict[str, Any], + selected_stack: dict[str, str], + generation_mode: str, +) -> str: + stack_bits = [] + if project_kind["hasFrontend"]: + stack_bits.append(selected_stack["frontend"]) + if project_kind["hasBackend"]: + stack_bits.append(selected_stack["backend"]) + stack_summary = " + ".join(bit for bit in stack_bits if bit not in NONE_LIKE) or selected_stack["language"] + mode_label = "Fast Mode" if generation_mode == "fast" else "Deep Mode" + return ( + f"{project_name} is a 100% runnable starter project built around {stack_summary}. " + f"{mode_label} uses backend templates for all standard project structure so output quality stays complete while generation remains fast." + ) + + +def build_assumptions( + selected_stack: dict[str, str], + project_kind: dict[str, Any], + requested_stack: dict[str, str], + generation_mode: str, + has_custom_manifest: bool, +) -> list[str]: + assumptions: list[str] = [] + if all(value == "Auto" for value in requested_stack.values()): + assumptions.append( + "Some stack choices were inferred automatically to provide a complete beginner-friendly starter." + ) + if generation_mode == "fast": + assumptions.append( + "Fast Mode used backend templates for standard files so project completeness was preserved without waiting for the model to write boilerplate." + ) + else: + assumptions.append( + "Deep Mode keeps the same complete standard structure as Fast Mode and uses extra model time only for project-specific custom files." + ) + if project_kind["isFullStack"]: + assumptions.append("The project is split into frontend and backend folders to keep the full-stack boundary explicit.") + assumptions.append( + "This output is a 100% runnable starter project after `.env` is filled, setup is run, and the run script is started. Business logic can still be customized afterward." + ) + if selected_stack.get("database") == "SQLite": + assumptions.append("SQLite was chosen as a lightweight local-first database default.") + if has_custom_manifest: + assumptions.append("Project-specific custom files were layered on top of the standard stack templates.") + return assumptions + + +def build_architecture( + selected_stack: dict[str, str], + project_kind: dict[str, Any], +) -> list[str]: + architecture: list[str] = [] + if project_kind["hasFrontend"]: + architecture.append( + f"{selected_stack['frontend']} handles the user-facing workflows, starter pages, and client-side integration points." + ) + if project_kind["hasBackend"]: + architecture.append( + f"{selected_stack['backend']} provides the API surface, routing, services, and configuration layer." + ) + if selected_stack.get("database") not in NONE_LIKE: + architecture.append( + f"{selected_stack['database']} is configured as the primary persistence layer through environment-driven settings." + ) + if selected_stack.get("aiTools") not in NONE_LIKE: + architecture.append( + f"{selected_stack['aiTools']} integration is isolated behind service boundaries so model/provider settings can evolve independently." + ) + architecture.append("Setup, run scripts, and dependency manifests are generated server-side for a consistent starter layout.") + return architecture + + +def build_default_modules( + selected_stack: dict[str, str], + project_kind: dict[str, Any], +) -> list[dict[str, Any]]: + modules: list[dict[str, Any]] = [] + if project_kind["hasFrontend"]: + base = "frontend/" if project_kind["isFullStack"] else "" + modules.append( + { + "name": "Frontend Experience", + "purpose": "Provides the main user interface, starter pages, and client-side state or API hooks.", + "keyFiles": [ + f"{base}src/App.jsx" if selected_stack["frontend"] in {"React", "Next.js", "Vue"} else f"{base}index.html", + f"{base}src/pages/HomePage.jsx" if selected_stack["frontend"] in {"React", "Next.js", "Vue"} else f"{base}src/views/home.js", + f"{base}src/services/api.js", + ], + } + ) + if project_kind["hasBackend"]: + base = "backend/" if project_kind["isFullStack"] else "" + if selected_stack["backend"] in {"FastAPI", "Flask"}: + key_files = [f"{base}app/main.py", f"{base}app/routers/items.py", f"{base}app/services/item_service.py"] + elif selected_stack["backend"] in {"Express", "NestJS"}: + key_files = [f"{base}server.js", f"{base}src/routes/items.js", f"{base}src/services/itemService.js"] + else: + key_files = [f"{base}pom.xml", f"{base}src/main/java/com/example/demo/service/AppService.java"] + modules.append( + { + "name": "Backend API", + "purpose": "Provides the routes, services, and integration-ready backend surface.", + "keyFiles": key_files, + } + ) + if selected_stack.get("database") not in NONE_LIKE: + base = "backend/" if project_kind["isFullStack"] else "" + modules.append( + { + "name": "Persistence Layer", + "purpose": "Supplies data models, configuration, and starter persistence wiring.", + "keyFiles": [ + f"{base}app/database.py" if selected_stack["backend"] in {"FastAPI", "Flask"} else f"{base}src/models/itemModel.js" + ], + } + ) + return modules + + +def build_package_requirements( + selected_stack: dict[str, str], + project_kind: dict[str, Any], +) -> list[str]: + packages: list[str] = [] + if project_kind["hasBackend"]: + backend = selected_stack["backend"] + database = selected_stack["database"] + if backend == "FastAPI": + packages.extend( + ["fastapi", "uvicorn[standard]", "pydantic", "pydantic-settings", "sqlalchemy", "python-dotenv"] + ) + if database == "SQLite": + packages.append("aiosqlite") + elif database == "PostgreSQL": + packages.append("psycopg[binary]") + elif backend == "Flask": + packages.extend(["flask", "python-dotenv", "sqlalchemy"]) + elif backend in {"Express", "NestJS"}: + packages.extend(["express", "cors", "dotenv"]) + if database == "PostgreSQL": + packages.append("pg") + elif database == "MongoDB": + packages.append("mongoose") + elif backend == "Spring Boot": + packages.extend( + [ + "org.springframework.boot:spring-boot-starter-web", + "org.springframework.boot:spring-boot-starter-data-jpa", + ] + ) + if project_kind["hasFrontend"]: + frontend = selected_stack["frontend"] + if frontend in {"React", "Next.js", "Vue"}: + packages.extend(["react", "react-dom", "vite"]) + elif frontend == "HTML/CSS/JavaScript": + packages.append("vite") + ai_tools = selected_stack.get("aiTools", "None") + if ai_tools == "Ollama": + packages.append("ollama") + elif ai_tools == "LangChain": + packages.append("langchain") + return dedupe_list(packages) + + +def build_install_commands( + selected_stack: dict[str, str], + project_kind: dict[str, Any], +) -> list[str]: + if project_kind["isFullStack"]: + return ["setup.bat", "./setup.sh"] + if project_kind["hasBackend"] and selected_stack["backend"] == "Spring Boot": + return ["setup.bat", "./setup.sh", "mvn install"] + if project_kind["hasFrontend"] and not project_kind["hasBackend"]: + return ["setup.bat", "./setup.sh", "npm install"] + return ["setup.bat", "./setup.sh"] + + +def build_run_commands( + selected_stack: dict[str, str], + project_kind: dict[str, Any], +) -> list[str]: + commands = ["run.bat", "./run.sh"] + if project_kind["isFullStack"]: + return commands + if project_kind["hasBackend"]: + backend = selected_stack["backend"] + if backend in {"FastAPI", "Flask"}: + commands.append("python -m uvicorn app.main:app --reload") + elif backend in {"Express", "NestJS"}: + commands.append("npm start") + elif backend == "Spring Boot": + commands.append("mvn spring-boot:run") + elif project_kind["hasFrontend"]: + commands.append("npm run dev") + return commands + + +def build_required_inputs( + idea: str, + selected_stack: dict[str, str], + project_kind: dict[str, Any], + modules: list[dict[str, Any]], +) -> list[dict[str, Any]]: + context_parts = [idea] + for module in modules: + context_parts.append(str(module.get("name") or "")) + context_parts.append(str(module.get("purpose") or "")) + context_parts.extend(normalize_string_list(module.get("keyFiles"))) + lowered = " ".join(part.lower() for part in context_parts if part) + + required_inputs: list[dict[str, Any]] = [] + if project_kind["hasBackend"]: + required_inputs.append( + { + "name": "APP_ENV", + "required": False, + "example": "development", + "whereToAdd": ".env", + "purpose": "Application environment name.", + } + ) + + database = selected_stack.get("database") + if database == "SQLite": + required_inputs.append( + { + "name": "DATABASE_URL", + "required": True, + "example": "sqlite:///./app.db", + "whereToAdd": ".env", + "purpose": "SQLite connection string for local development.", + } + ) + elif database in {"PostgreSQL", "MySQL"}: + example = ( + "postgresql://postgres:postgres@localhost:5432/app_db" + if database == "PostgreSQL" + else "mysql://root:password@localhost:3306/app_db" + ) + required_inputs.append( + { + "name": "DATABASE_URL", + "required": True, + "example": example, + "whereToAdd": ".env", + "purpose": f"{database} connection string for the backend database.", + } + ) + elif database == "MongoDB": + required_inputs.append( + { + "name": "MONGODB_URI", + "required": True, + "example": "mongodb://localhost:27017/app_db", + "whereToAdd": ".env", + "purpose": "MongoDB connection string for the backend database.", + } + ) + + if _context_mentions_any(lowered, ("auth", "authentication", "login", "signup", "jwt", "token", "session")): + required_inputs.append( + { + "name": "JWT_SECRET", + "required": True, + "example": "change-me-super-secret", + "whereToAdd": ".env", + "purpose": "Secret used to sign authentication tokens.", + } + ) + + if _context_mentions_any(lowered, ("email", "smtp", "mail", "newsletter", "verification email", "contact form")): + required_inputs.extend( + [ + { + "name": "SMTP_HOST", + "required": True, + "example": "smtp.gmail.com", + "whereToAdd": ".env", + "purpose": "SMTP email server host.", + }, + { + "name": "SMTP_PORT", + "required": True, + "example": "587", + "whereToAdd": ".env", + "purpose": "SMTP email server port.", + }, + { + "name": "SMTP_USER", + "required": True, + "example": "notifications@example.com", + "whereToAdd": ".env", + "purpose": "SMTP account username or sender login.", + }, + { + "name": "SMTP_PASSWORD", + "required": True, + "example": "app-password", + "whereToAdd": ".env", + "purpose": "SMTP account password or app password.", + }, + ] + ) + + if _context_mentions_any(lowered, ("payment", "payments", "stripe", "checkout", "billing", "subscription")): + key_name = "STRIPE_SECRET_KEY" if "stripe" in lowered else "PAYMENT_SECRET_KEY" + required_inputs.append( + { + "name": key_name, + "required": True, + "example": "sk_test_1234567890", + "whereToAdd": ".env", + "purpose": "Secret key for payment provider API requests.", + } + ) + + ai_tools = selected_stack.get("aiTools") + if ai_tools == "Ollama" or _context_mentions_any(lowered, ("ollama", "local llm", "qwen", "llama")): + required_inputs.extend( + [ + { + "name": "OLLAMA_BASE_URL", + "required": True, + "example": "http://localhost:11434", + "whereToAdd": ".env", + "purpose": "Base URL for the local Ollama server.", + }, + { + "name": "OLLAMA_MODEL", + "required": True, + "example": "qwen2.5-coder:latest", + "whereToAdd": ".env", + "purpose": "Ollama model name to use for local inference.", + }, + ] + ) + if ai_tools == "OpenAI API" or _context_mentions_any(lowered, ("openai", "gpt-4", "gpt", "chatgpt")): + required_inputs.append( + { + "name": "OPENAI_API_KEY", + "required": True, + "example": "sk-proj-1234567890", + "whereToAdd": ".env", + "purpose": "API key for hosted OpenAI requests.", + } + ) + + if _context_mentions_any(lowered, ("oauth", "google login", "github login", "social login", "openid")): + required_inputs.extend( + [ + { + "name": "OAUTH_CLIENT_ID", + "required": True, + "example": "your-client-id", + "whereToAdd": ".env", + "purpose": "OAuth client identifier for external sign-in.", + }, + { + "name": "OAUTH_CLIENT_SECRET", + "required": True, + "example": "your-client-secret", + "whereToAdd": ".env", + "purpose": "OAuth client secret for external sign-in.", + }, + ] + ) + + if project_kind["isFullStack"]: + required_inputs.append( + { + "name": "VITE_API_BASE_URL", + "required": False, + "example": "http://localhost:8000", + "whereToAdd": ".env", + "purpose": "Frontend base URL for backend API calls.", + } + ) + + return dedupe_required_inputs(required_inputs) + + +def required_inputs_to_env_variables( + required_inputs: list[dict[str, Any]], +) -> list[dict[str, str]]: + env_vars: list[dict[str, str]] = [] + for item in required_inputs: + name = str(item.get("name") or "").strip() + if not name: + continue + env_vars.append( + { + "name": name, + "value": str(item.get("example") or "").strip(), + "description": str(item.get("purpose") or "").strip(), + } + ) + return env_vars + + +def normalize_custom_manifest( + value: Any, + selected_stack: dict[str, str], + project_kind: dict[str, Any], +) -> list[dict[str, str]]: + manifest: list[dict[str, str]] = [] + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + for item in value: + if len(manifest) >= MAX_CUSTOM_FILES: + break + if isinstance(item, str): + path = clean_relative_path(item) + purpose = "Project-specific custom logic." + elif isinstance(item, Mapping): + path = clean_relative_path(item.get("path")) + purpose = str(item.get("purpose") or item.get("description") or "Project-specific custom logic.").strip() + else: + continue + if not path or is_standard_file_path(path): + continue + manifest.append({"path": path, "purpose": purpose}) + + if manifest: + return dedupe_manifest(manifest) + + defaults: list[dict[str, str]] = [] + if project_kind["isFullStack"]: + if selected_stack["frontend"] not in NONE_LIKE: + defaults.append( + { + "path": "frontend/src/pages/DashboardPage.jsx", + "purpose": "Primary project-specific frontend page for the main user workflow.", + } + ) + if selected_stack["backend"] in {"FastAPI", "Flask"}: + defaults.append( + { + "path": "backend/app/services/domain_service.py", + "purpose": "Project-specific backend domain service with starter business rules.", + } + ) + elif selected_stack["backend"] in {"Express", "NestJS"}: + defaults.append( + { + "path": "backend/src/services/domainService.js", + "purpose": "Project-specific backend domain service with starter business rules.", + } + ) + elif project_kind["hasBackend"]: + if selected_stack["backend"] in {"FastAPI", "Flask"}: + defaults.append( + { + "path": "app/services/domain_service.py", + "purpose": "Project-specific backend domain service with starter business rules.", + } + ) + elif selected_stack["backend"] in {"Express", "NestJS"}: + defaults.append( + { + "path": "src/services/domainService.js", + "purpose": "Project-specific backend domain service with starter business rules.", + } + ) + elif selected_stack["backend"] == "Spring Boot": + defaults.append( + { + "path": "src/main/java/com/example/demo/service/DomainService.java", + "purpose": "Project-specific backend service with starter business rules.", + } + ) + elif project_kind["hasFrontend"]: + if selected_stack["frontend"] in {"React", "Next.js", "Vue"}: + defaults.append( + { + "path": "src/pages/DashboardPage.jsx", + "purpose": "Primary project-specific frontend page for the main workflow.", + } + ) + else: + defaults.append( + { + "path": "src/views/dashboard.js", + "purpose": "Primary project-specific frontend view for the main workflow.", + } + ) + return defaults[:MAX_CUSTOM_FILES] + + +def build_standard_files( + project_name: str, + selected_stack: dict[str, str], + project_kind: dict[str, Any], +) -> list[dict[str, str]]: + files: dict[str, str] = {} + if project_kind["isFullStack"]: + if project_kind["hasFrontend"]: + files.update(build_frontend_files(selected_stack["frontend"], project_name, "frontend")) + if project_kind["hasBackend"]: + files.update(build_backend_files(selected_stack, project_name, "backend")) + files.update(build_root_scripts(selected_stack, project_kind)) + elif project_kind["hasBackend"]: + files.update(build_backend_files(selected_stack, project_name, "")) + files.update(build_root_scripts(selected_stack, project_kind)) + else: + files.update(build_frontend_files(selected_stack["frontend"], project_name, "")) + files.update(build_root_scripts(selected_stack, project_kind)) + return [{"path": path, "content": content} for path, content in files.items()] + + +def build_custom_template_files( + manifest: list[dict[str, str]], + project_name: str, + selected_stack: dict[str, str], + project_kind: dict[str, Any], +) -> list[dict[str, str]]: + files: list[dict[str, str]] = [] + for item in manifest[:MAX_CUSTOM_FILES]: + path = item["path"] + purpose = item["purpose"] + content = build_custom_template_content(path, purpose, project_name, selected_stack, project_kind) + files.append({"path": path, "content": trim_content_lines(content)}) + return files + + +def build_custom_template_content( + path: str, + purpose: str, + project_name: str, + selected_stack: dict[str, str], + project_kind: dict[str, Any], +) -> str: + filename = Path(path).name + stem = Path(path).stem + pretty_name = stem.replace("_", " ").replace("-", " ").title() + extension = Path(path).suffix.lower() + + if extension in {".jsx", ".tsx"}: + if "page" in stem.lower() or "/pages/" in path: + return f"""const cards = [ + {{ + title: "{pretty_name} Overview", + detail: "{purpose}" + }}, + {{ + title: "Starter Workflow", + detail: "Use this page to connect forms, API requests, and user-facing business actions." + }} +]; + +export default function {safe_component_name(stem)}() {{ + return ( +
+

{pretty_name}

+

{purpose}

+ +
+ ); +}} +""" + return f"""export default function {safe_component_name(stem)}() {{ + return ( +
+

{pretty_name}

+

{purpose}

+
+ ); +}} +""" + + if extension == ".py": + if "router" in path or "/routers/" in path: + return f"""from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/") +def read_{safe_python_name(stem)}() -> dict[str, str]: + return {{ + "message": "{purpose}", + "project": "{project_name}", + }} +""" + if "schema" in path or "/schemas/" in path: + return f"""from pydantic import BaseModel + + +class {safe_component_name(stem)}(BaseModel): + name: str + description: str = "{purpose}" +""" + if "model" in path or "/models/" in path: + return f"""from dataclasses import dataclass + + +@dataclass +class {safe_component_name(stem)}: + name: str + status: str = "ready" +""" + return f"""def {safe_python_name(stem)}_summary() -> dict[str, str]: + return {{ + "name": "{pretty_name}", + "purpose": "{purpose}", + "project": "{project_name}", + }} +""" + + if extension in {".js", ".mjs"}: + if "service" in path.lower(): + return f"""export function get{safe_component_name(stem)}Summary() {{ + return {{ + project: "{project_name}", + purpose: "{purpose}" + }}; +}} +""" + if "controller" in path.lower(): + return f"""export function {safe_js_name(stem)}(_req, res) {{ + res.json({{ + project: "{project_name}", + purpose: "{purpose}" + }}); +}} +""" + return f"""export const {safe_js_name(stem)} = {{ + project: "{project_name}", + purpose: "{purpose}" +}}; +""" + + if extension == ".java": + class_name = safe_component_name(stem) + return f"""package com.example.demo.service; + +import org.springframework.stereotype.Service; + +@Service +public class {class_name} {{ + public String summary() {{ + return "{purpose}"; + }} +}} +""" + + return f"# {pretty_name}\n\n{purpose}\n" + + +def ensure_minimum_project_files( + files: list[dict[str, str]], + selected_stack: dict[str, str], + project_kind: dict[str, Any], +) -> list[dict[str, str]]: + merged = {entry["path"]: entry["content"] for entry in files} + for path, content in build_root_scripts(selected_stack, project_kind).items(): + merged.setdefault(path, content) + + if project_kind["isFullStack"]: + for path, content in build_frontend_files(selected_stack["frontend"], "App", "frontend").items(): + merged.setdefault(path, content) + for path, content in build_backend_files(selected_stack, "App", "backend").items(): + merged.setdefault(path, content) + elif project_kind["hasBackend"]: + for path, content in build_backend_files(selected_stack, "App", "").items(): + merged.setdefault(path, content) + else: + for path, content in build_frontend_files(selected_stack["frontend"], "App", "").items(): + merged.setdefault(path, content) + + filler_index = 1 + while len(merged) < project_kind["minimumFiles"]: + filler_path = f"notes/starter-note-{filler_index}.md" + merged.setdefault( + filler_path, + f"# Starter Note {filler_index}\n\nThis file preserves the complete minimum project structure while you continue iterating.\n", + ) + filler_index += 1 + + return [{"path": path, "content": content} for path, content in merged.items()] + + +def build_backend_files( + selected_stack: dict[str, str], + project_name: str, + prefix: str, +) -> dict[str, str]: + backend = selected_stack.get("backend", "FastAPI") + if backend in {"FastAPI", "Flask"}: + return build_fastapi_backend_files(project_name, prefix) + if backend in {"Express", "NestJS"}: + return build_express_backend_files(project_name, prefix) + if backend == "Spring Boot": + return build_spring_backend_files(project_name, prefix) + return build_fastapi_backend_files(project_name, prefix) + + +def build_frontend_files(frontend: str, project_name: str, prefix: str) -> dict[str, str]: + if frontend in {"React", "Next.js", "Vue"}: + return build_react_frontend_files(project_name, prefix) + return build_vanilla_frontend_files(project_name, prefix) + + +def build_fastapi_backend_files(project_name: str, prefix: str) -> dict[str, str]: + app_prefix = prefixed(prefix, "app") + return { + prefixed(prefix, "requirements.txt"): "\n".join( + [ + "fastapi", + "uvicorn[standard]", + "pydantic", + "pydantic-settings", + "sqlalchemy", + "python-dotenv", + "aiosqlite", + "", + ] + ), + prefixed(app_prefix, "__init__.py"): "", + prefixed(app_prefix, "main.py"): f"""from fastapi import FastAPI + +from app.routers import health, items + + +app = FastAPI(title="{project_name} API") +app.include_router(health.router) +app.include_router(items.router, prefix="/api/items", tags=["items"]) + + +@app.get("/") +def read_root() -> dict[str, str]: + return {{"message": "{project_name} backend is running."}} +""", + prefixed(app_prefix, "routers/__init__.py"): "", + prefixed(app_prefix, "routers/health.py"): """from fastapi import APIRouter + +from app.schemas.health import HealthResponse + +router = APIRouter(tags=["health"]) + + +@router.get("/health", response_model=HealthResponse) +def healthcheck() -> HealthResponse: + return HealthResponse(status="ok") +""", + prefixed(app_prefix, "routers/items.py"): """from fastapi import APIRouter + +from app.schemas.item import Item +from app.services.item_service import list_items + +router = APIRouter() + + +@router.get("/", response_model=list[Item]) +def get_items() -> list[Item]: + return list_items() +""", + prefixed(app_prefix, "services/__init__.py"): "", + prefixed(app_prefix, "services/app_service.py"): f"""def get_app_summary() -> str: + return "{project_name} includes routes, services, schemas, and configuration for quick iteration." +""", + prefixed(app_prefix, "services/item_service.py"): """from app.schemas.item import Item + + +def list_items() -> list[Item]: + return [ + Item(id=1, name="Starter task", status="ready"), + Item(id=2, name="Next iteration", status="planned"), + ] +""", + prefixed(app_prefix, "models/__init__.py"): "", + prefixed(app_prefix, "models/base.py"): """from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass +""", + prefixed(app_prefix, "models/item.py"): """from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class ItemModel(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(120)) + status: Mapped[str] = mapped_column(String(40), default="ready") +""", + prefixed(app_prefix, "schemas/__init__.py"): "", + prefixed(app_prefix, "schemas/health.py"): """from pydantic import BaseModel + + +class HealthResponse(BaseModel): + status: str +""", + prefixed(app_prefix, "schemas/item.py"): """from pydantic import BaseModel + + +class Item(BaseModel): + id: int + name: str + status: str +""", + prefixed(app_prefix, "database.py"): """from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.config import settings + +engine = create_engine(settings.database_url, future=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) +""", + prefixed(app_prefix, "config.py"): """from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env") + app_env: str = "development" + database_url: str = "sqlite:///./app.db" + + +settings = Settings() +""", + } + + +def build_express_backend_files(project_name: str, prefix: str) -> dict[str, str]: + return { + prefixed(prefix, "package.json"): json.dumps( + { + "name": project_name.lower().replace(" ", "-"), + "version": "0.1.0", + "private": True, + "type": "module", + "scripts": {"dev": "node --watch server.js", "start": "node server.js"}, + "dependencies": {"cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2"}, + }, + indent=2, + ) + + "\n", + prefixed(prefix, "server.js"): f"""import cors from "cors"; +import dotenv from "dotenv"; +import express from "express"; +import indexRouter from "./src/routes/index.js"; +import itemsRouter from "./src/routes/items.js"; + +dotenv.config(); + +const app = express(); +app.use(cors()); +app.use(express.json()); +app.use("/", indexRouter); +app.use("/api/items", itemsRouter); + +const port = process.env.PORT || 8000; +app.listen(port, () => {{ + console.log("{project_name} API listening on port", port); +}}); +""", + prefixed(prefix, "src/routes/index.js"): """import { Router } from "express"; +import { getStatus } from "../controllers/appController.js"; + +const router = Router(); +router.get("/", getStatus); + +export default router; +""", + prefixed(prefix, "src/routes/items.js"): """import { Router } from "express"; +import { listItems } from "../controllers/itemController.js"; + +const router = Router(); +router.get("/", listItems); + +export default router; +""", + prefixed(prefix, "src/controllers/appController.js"): """export function getStatus(_req, res) { + res.json({ status: "ok" }); +} +""", + prefixed(prefix, "src/controllers/itemController.js"): """import { getItems } from "../services/itemService.js"; + +export function listItems(_req, res) { + res.json(getItems()); +} +""", + prefixed(prefix, "src/services/appService.js"): f"""export function getAppSummary() {{ + return "{project_name} includes routes, controllers, services, and starter configuration."; +}} +""", + prefixed(prefix, "src/services/itemService.js"): """export function getItems() { + return [ + { id: 1, name: "Starter task", status: "ready" }, + { id: 2, name: "Next iteration", status: "planned" } + ]; +} +""", + prefixed(prefix, "src/models/itemModel.js"): """export const itemShape = { + id: "number", + name: "string", + status: "string" +}; +""", + } + + +def build_react_frontend_files(project_name: str, prefix: str) -> dict[str, str]: + return { + prefixed(prefix, "package.json"): json.dumps( + { + "name": project_name.lower().replace(" ", "-") + "-frontend", + "private": True, + "version": "0.1.0", + "type": "module", + "scripts": {"dev": "vite", "build": "vite build", "preview": "vite preview"}, + "dependencies": {"react": "^18.3.1", "react-dom": "^18.3.1"}, + "devDependencies": {"vite": "^5.4.0", "@vitejs/plugin-react": "^4.3.1"}, + }, + indent=2, + ) + + "\n", + prefixed(prefix, "index.html"): """ + + + + + Project Starter + + + +
+ + +""", + prefixed(prefix, "vite.config.js"): """import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173 + } +}); +""", + prefixed(prefix, "src/main.jsx"): """import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + +); +""", + prefixed(prefix, "src/App.jsx"): f"""import AppShell from "./components/AppShell"; +import HomePage from "./pages/HomePage"; + +export default function App() {{ + return ( + + + + ); +}} +""", + prefixed(prefix, "src/components/AppShell.jsx"): """export default function AppShell({ title, children }) { + return ( +
+
+

Generated by Project Agent

+

{title}

+
+
{children}
+
+ ); +} +""", + prefixed(prefix, "src/pages/HomePage.jsx"): """import { getProjectHealth } from "../services/api"; + +export default function HomePage() { + const projectHealth = getProjectHealth(); + + return ( +
+

Starter Overview

+

This frontend is ready for your first feature slice.

+

API health source: {projectHealth}

+
+ ); +} +""", + prefixed(prefix, "src/services/api.js"): """export function getProjectHealth() { + return import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; +} +""", + prefixed(prefix, "src/styles.css"): """:root { + color-scheme: light; + font-family: "Segoe UI", Arial, sans-serif; + background: #f5f7fb; + color: #132238; +} + +body { + margin: 0; +} + +.app-shell { + max-width: 960px; + margin: 0 auto; + padding: 32px 20px 56px; +} + +.hero { + margin-bottom: 24px; +} + +.eyebrow { + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #57657d; +} + +.card { + background: white; + border-radius: 16px; + padding: 24px; + box-shadow: 0 18px 40px rgba(19, 34, 56, 0.08); +} +""", + } + + +def build_vanilla_frontend_files(project_name: str, prefix: str) -> dict[str, str]: + return { + prefixed(prefix, "package.json"): json.dumps( + { + "name": project_name.lower().replace(" ", "-") + "-frontend", + "private": True, + "version": "0.1.0", + "type": "module", + "scripts": {"dev": "vite", "build": "vite build", "preview": "vite preview"}, + "devDependencies": {"vite": "^5.4.0"}, + }, + indent=2, + ) + + "\n", + prefixed(prefix, "index.html"): """ + + + + + Project Starter + + + +
+ + +""", + prefixed(prefix, "vite.config.js"): """import { defineConfig } from "vite"; + +export default defineConfig({ + server: { + port: 5173 + } +}); +""", + prefixed(prefix, "src/main.js"): f"""import {{ renderHomePage }} from "./views/home.js"; +import "./styles.css"; + +document.querySelector("#app").innerHTML = renderHomePage("{project_name}"); +""", + prefixed(prefix, "src/views/home.js"): """export function renderHomePage(title) { + return ` +
+
+

Generated by Project Agent

+

${title}

+

This starter is ready for your first feature slice.

+
+
+ `; +} +""", + prefixed(prefix, "src/services/api.js"): """export function getApiBaseUrl() { + return import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; +} +""", + prefixed(prefix, "src/styles.css"): """body { + margin: 0; + font-family: "Segoe UI", Arial, sans-serif; + background: #f5f7fb; + color: #132238; +} + +.app-shell { + max-width: 960px; + margin: 0 auto; + padding: 32px 20px 56px; +} + +.card { + background: white; + border-radius: 16px; + padding: 24px; + box-shadow: 0 18px 40px rgba(19, 34, 56, 0.08); +} + +.eyebrow { + font-size: 0.75rem; + text-transform: uppercase; + color: #57657d; +} +""", + } + + +def build_spring_backend_files(project_name: str, prefix: str) -> dict[str, str]: + java_base = prefixed(prefix, "src/main/java/com/example/demo") + resources_base = prefixed(prefix, "src/main/resources") + return { + prefixed(prefix, "pom.xml"): """ + 4.0.0 + com.example + demo + 0.0.1-SNAPSHOT + + org.springframework.boot + spring-boot-starter-parent + 3.3.2 + + + 17 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + +""", + prefixed(java_base, "Application.java"): """package com.example.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} +""", + prefixed(java_base, "controller/AppController.java"): """package com.example.demo.controller; + +import com.example.demo.service.AppService; +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class AppController { + private final AppService appService; + + public AppController(AppService appService) { + this.appService = appService; + } + + @GetMapping("/") + public Map status() { + return Map.of("message", appService.status()); + } +} +""", + prefixed(java_base, "service/AppService.java"): f"""package com.example.demo.service; + +import org.springframework.stereotype.Service; + +@Service +public class AppService {{ + public String status() {{ + return "{project_name} backend is running."; + }} +}} +""", + prefixed(java_base, "model/AppModel.java"): """package com.example.demo.model; + +public class AppModel { + private Long id; + private String name; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } +} +""", + prefixed(java_base, "repository/AppRepository.java"): """package com.example.demo.repository; + +import com.example.demo.model.AppModel; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class AppRepository { + public List findAll() { + return List.of(); + } +} +""", + prefixed(resources_base, "application.properties"): """spring.application.name=demo +server.port=8080 +""", + } + + +def build_root_scripts( + selected_stack: dict[str, str], + project_kind: dict[str, Any], +) -> dict[str, str]: + if project_kind["isFullStack"]: + return build_fullstack_scripts(selected_stack) + if project_kind["hasBackend"]: + backend = selected_stack["backend"] + if backend in {"FastAPI", "Flask"}: + return build_python_scripts(".") + if backend in {"Express", "NestJS"}: + return build_node_scripts(".") + if backend == "Spring Boot": + return build_java_scripts(".") + return build_node_scripts(".") + + +def build_fullstack_scripts(selected_stack: dict[str, str]) -> dict[str, str]: + backend_setup = "" + backend_run_windows = "echo No backend runtime configured.\n" + backend_run_unix = 'echo "No backend runtime configured."\n' + + if selected_stack["backend"] in {"FastAPI", "Flask"}: + backend_setup = ( + "if exist backend\\requirements.txt (\n" + " python -m pip install -r backend\\requirements.txt\n" + ")\n" + ) + backend_run_windows = ( + 'start "Backend" cmd /k "cd backend && python -m uvicorn app.main:app --reload"\n' + ) + backend_run_unix = ( + '(cd backend && python -m uvicorn app.main:app --reload) &\n' + ) + elif selected_stack["backend"] in {"Express", "NestJS"}: + backend_setup = 'if exist backend\\package.json (\n pushd backend\n call npm install\n popd\n)\n' + backend_run_windows = 'start "Backend" cmd /k "cd backend && npm run dev"\n' + backend_run_unix = '(cd backend && npm run dev) &\n' + elif selected_stack["backend"] == "Spring Boot": + backend_setup = ( + 'where mvn >nul 2>nul && (\n' + ' pushd backend\n' + ' call mvn install\n' + ' popd\n' + ') || echo Maven not found. Skipping backend install.\n' + ) + backend_run_windows = 'start "Backend" cmd /k "cd backend && mvn spring-boot:run"\n' + backend_run_unix = '(cd backend && mvn spring-boot:run) &\n' + + return { + "setup.bat": f"""@echo off +setlocal +{backend_setup}if exist frontend\\package.json ( + pushd frontend + call npm install + popd +) +echo Setup complete. +""", + "setup.sh": """#!/usr/bin/env bash +set -e +if [ -f backend/requirements.txt ]; then + python -m pip install -r backend/requirements.txt +fi +if [ -f backend/package.json ]; then + (cd backend && npm install) +fi +if [ -f backend/pom.xml ]; then + if command -v mvn >/dev/null 2>&1; then + (cd backend && mvn install) + else + echo "Maven not found. Skipping backend install." + fi +fi +if [ -f frontend/package.json ]; then + (cd frontend && npm install) +fi +echo "Setup complete." +""", + "run.bat": f"""@echo off +setlocal +{backend_run_windows}start "Frontend" cmd /k "cd frontend && npm run dev" +""", + "run.sh": f"""#!/usr/bin/env bash +set -e +{backend_run_unix}(cd frontend && npm run dev) & +wait +""", + } + + +def build_python_scripts(target_dir: str) -> dict[str, str]: + directory_prefix = "" if target_dir in {"", "."} else f"{target_dir}/" + windows_prefix = "" if target_dir in {"", "."} else f"{target_dir}\\" + return { + "setup.bat": f"""@echo off +setlocal +python -m pip install -r {windows_prefix}requirements.txt +echo Setup complete. +""", + "setup.sh": f"""#!/usr/bin/env bash +set -e +python -m pip install -r {directory_prefix}requirements.txt +echo "Setup complete." +""", + "run.bat": f"""@echo off +setlocal +python -m uvicorn app.main:app --reload +""", + "run.sh": f"""#!/usr/bin/env bash +set -e +python -m uvicorn app.main:app --reload +""", + } + + +def build_node_scripts(target_dir: str) -> dict[str, str]: + directory_prefix = "" if target_dir in {"", "."} else f"{target_dir}/" + windows_prefix = "" if target_dir in {"", "."} else f"{target_dir}\\" + return { + "setup.bat": f"""@echo off +setlocal +pushd {windows_prefix or "."} +call npm install +popd +echo Setup complete. +""", + "setup.sh": f"""#!/usr/bin/env bash +set -e +(cd {directory_prefix or "."} && npm install) +echo "Setup complete." +""", + "run.bat": f"""@echo off +setlocal +pushd {windows_prefix or "."} +call npm run dev +popd +""", + "run.sh": f"""#!/usr/bin/env bash +set -e +(cd {directory_prefix or "."} && npm run dev) +""", + } + + +def build_java_scripts(target_dir: str) -> dict[str, str]: + directory_prefix = "" if target_dir in {"", "."} else f"{target_dir}/" + windows_prefix = "" if target_dir in {"", "."} else f"{target_dir}\\" + return { + "setup.bat": f"""@echo off +setlocal +where mvn >nul 2>nul && ( + pushd {windows_prefix or "."} + call mvn install + popd +) || ( + echo Maven not found. Skipping install. +) +""", + "setup.sh": f"""#!/usr/bin/env bash +set -e +if command -v mvn >/dev/null 2>&1; then + (cd {directory_prefix or "."} && mvn install) +else + echo "Maven not found. Skipping install." +fi +""", + "run.bat": f"""@echo off +setlocal +pushd {windows_prefix or "."} +call mvn spring-boot:run +popd +""", + "run.sh": f"""#!/usr/bin/env bash +set -e +(cd {directory_prefix or "."} && mvn spring-boot:run) +""", + } + + +def normalize_string_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + lines = [item.strip(" -*\t") for item in value.splitlines()] + return [line for line in lines if line] + if isinstance(value, Mapping): + return [f"{key}: {str(item).strip()}" for key, item in value.items() if str(item).strip()] + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [str(item).strip() for item in value if str(item).strip()] + text = str(value).strip() + return [text] if text else [] + + +def normalize_modules(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + return [] + modules: list[dict[str, Any]] = [] + for item in value: + if not isinstance(item, Mapping): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + modules.append( + { + "name": name, + "purpose": str(item.get("purpose") or "").strip(), + "keyFiles": normalize_string_list(item.get("keyFiles")), + } + ) + return modules + + +def merge_modules( + primary: list[dict[str, Any]], + secondary: list[dict[str, Any]], +) -> list[dict[str, Any]]: + merged: dict[str, dict[str, Any]] = {} + for collection in (secondary, primary): + for module in collection: + key = module["name"].strip().lower() + current = merged.setdefault( + key, + {"name": module["name"], "purpose": "", "keyFiles": []}, + ) + if module.get("purpose"): + current["purpose"] = module["purpose"] + current["keyFiles"] = dedupe_list(current["keyFiles"] + normalize_string_list(module.get("keyFiles"))) + return list(merged.values()) + + +def normalize_env_variables(value: Any) -> list[dict[str, str]]: + if isinstance(value, Mapping): + value = [{"name": key, "value": str(item), "description": ""} for key, item in value.items()] + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + return [] + env_vars: list[dict[str, str]] = [] + for item in value: + if not isinstance(item, Mapping): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + env_vars.append( + { + "name": name, + "value": str(item.get("value") or "").strip(), + "description": str(item.get("description") or "").strip(), + } + ) + return env_vars + + +def normalize_required_inputs(value: Any) -> list[dict[str, Any]]: + if isinstance(value, Mapping): + value = [value] + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + return [] + + required_inputs: list[dict[str, Any]] = [] + for item in value: + if not isinstance(item, Mapping): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + required_flag = item.get("required") + required_inputs.append( + { + "name": name, + "required": _coerce_required_flag(required_flag), + "example": str(item.get("example") or item.get("value") or "").strip(), + "whereToAdd": str(item.get("whereToAdd") or ".env").strip() or ".env", + "purpose": str(item.get("purpose") or item.get("description") or "").strip(), + } + ) + return dedupe_required_inputs(required_inputs) + + +def merge_required_inputs( + primary: list[dict[str, Any]], + secondary: list[dict[str, Any]], +) -> list[dict[str, Any]]: + merged: dict[str, dict[str, Any]] = {} + for collection in (secondary, primary): + for item in collection: + name = str(item.get("name") or "").strip() + if not name: + continue + merged[name] = { + "name": name, + "required": _coerce_required_flag(item.get("required", True)), + "example": str(item.get("example") or "").strip(), + "whereToAdd": str(item.get("whereToAdd") or ".env").strip() or ".env", + "purpose": str(item.get("purpose") or "").strip(), + } + return list(merged.values()) + + +def merge_env_variables( + primary: list[dict[str, str]], + secondary: list[dict[str, str]], +) -> list[dict[str, str]]: + merged: dict[str, dict[str, str]] = {} + for collection in (secondary, primary): + for item in collection: + merged[item["name"]] = { + "name": item["name"], + "value": item.get("value", ""), + "description": item.get("description", ""), + } + return list(merged.values()) + + +def normalize_files(value: Any) -> list[dict[str, str]]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + return [] + files: list[dict[str, str]] = [] + for item in value: + if not isinstance(item, Mapping): + continue + path = clean_relative_path(item.get("path")) + if not path: + continue + files.append({"path": path, "content": trim_content_lines(str(item.get("content") or ""), allow_long=True)}) + return files + + +def merge_file_entries( + primary: list[dict[str, str]], + secondary: list[dict[str, str]], +) -> list[dict[str, str]]: + merged: dict[str, dict[str, str]] = {} + for file_entry in primary: + merged[file_entry["path"]] = file_entry + for file_entry in secondary: + merged[file_entry["path"]] = file_entry + return list(merged.values()) + + +def build_chosen_stack(selected_stack: dict[str, str]) -> list[str]: + labels = { + "language": "Language", + "frontend": "Frontend", + "backend": "Backend", + "database": "Database", + "aiTools": "AI / Tools", + "deployment": "Deployment", + } + return [ + f"{labels[field]}: {selected_stack[field]}" + for field in STACK_FIELDS + if selected_stack.get(field) not in {"", "Auto"} + ] + + +def clean_relative_path(value: Any) -> str: + path = str(value or "").replace("\\", "/").strip().strip("/") + if not path or path.startswith(".") or ".." in path.split("/"): + return "" + return path + + +def is_standard_file_path(path: str) -> bool: + standard_names = { + "readme.md", + "requirements.txt", + "package.json", + "pom.xml", + ".env.example", + "setup.bat", + "setup.sh", + "run.bat", + "run.sh", + "vite.config.js", + "server.js", + "index.html", + "main.py", + "main.jsx", + "main.js", + "app.jsx", + "application.java", + } + return Path(path).name.lower() in standard_names + + +def dedupe_manifest(items: list[dict[str, str]]) -> list[dict[str, str]]: + seen: set[str] = set() + result: list[dict[str, str]] = [] + for item in items: + path = item["path"] + if path in seen: + continue + seen.add(path) + result.append(item) + return result + + +def dedupe_required_inputs(items: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + seen: set[str] = set() + result: list[dict[str, Any]] = [] + for item in items: + name = str(item.get("name") or "").strip() + if not name: + continue + key = name.lower() + if key in seen: + continue + seen.add(key) + result.append( + { + "name": name, + "required": _coerce_required_flag(item.get("required", True)), + "example": str(item.get("example") or "").strip(), + "whereToAdd": str(item.get("whereToAdd") or ".env").strip() or ".env", + "purpose": str(item.get("purpose") or "").strip(), + } + ) + return result + + +def trim_content_lines(content: str, allow_long: bool = False) -> str: + if allow_long: + return content + lines = content.splitlines() + if len(lines) <= MAX_CUSTOM_FILE_LINES: + return content + return "\n".join(lines[:MAX_CUSTOM_FILE_LINES]).rstrip() + "\n" + + +def safe_component_name(value: str) -> str: + parts = re.findall(r"[A-Za-z0-9]+", value) + return "".join(part.capitalize() for part in parts) or "GeneratedComponent" + + +def safe_python_name(value: str) -> str: + cleaned = re.sub(r"[^a-zA-Z0-9_]+", "_", value).strip("_").lower() + return cleaned or "generated_item" + + +def safe_js_name(value: str) -> str: + parts = re.findall(r"[A-Za-z0-9]+", value) + if not parts: + return "generatedItem" + head = parts[0].lower() + tail = "".join(part.capitalize() for part in parts[1:]) + return head + tail + + +def prefixed(prefix: str, path: str) -> str: + base = Path(prefix) if prefix else Path() + return (base / path).as_posix() + + +def dedupe_list(items: Sequence[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for item in items: + cleaned = str(item).strip() + if not cleaned: + continue + key = cleaned.lower() + if key in seen: + continue + seen.add(key) + result.append(cleaned) + return result + + +def _context_mentions_any(lowered_text: str, keywords: Sequence[str]) -> bool: + return any(keyword in lowered_text for keyword in keywords) + + +def _coerce_required_flag(value: Any) -> bool: + if isinstance(value, bool): + return value + if value is None: + return True + if isinstance(value, str): + return value.strip().lower() not in {"false", "0", "no", "optional"} + return bool(value)