Skip to content

Commit

Permalink
Merge pull request #1038 from Pythagora-io/more-templates
Browse files Browse the repository at this point in the history
Add new fullstack template (react+express) and support template options
  • Loading branch information
LeonOstrez committed Jul 2, 2024
2 parents 53c29de + 85233d4 commit 0923e54
Show file tree
Hide file tree
Showing 96 changed files with 2,344 additions and 287 deletions.
127 changes: 107 additions & 20 deletions core/agents/architect.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from enum import Enum
from typing import Optional

from pydantic import BaseModel, Field
Expand All @@ -9,8 +10,12 @@
from core.llm.parser import JSONParser
from core.log import get_logger
from core.telemetry import telemetry
from core.templates.base import BaseProjectTemplate, NoOptions
from core.templates.example_project import EXAMPLE_PROJECTS
from core.templates.registry import PROJECT_TEMPLATES, ProjectTemplateEnum
from core.templates.registry import (
PROJECT_TEMPLATES,
ProjectTemplateEnum,
)
from core.ui.base import ProjectStage

ARCHITECTURE_STEP_NAME = "Project architecture"
Expand All @@ -21,6 +26,14 @@
log = get_logger(__name__)


class AppType(str, Enum):
WEB = "web-app"
API = "api-service"
MOBILE = "mobile-app"
DESKTOP = "desktop-app"
CLI = "cli-tool"


# FIXME: all the reponse pydantic models should be strict (see config._StrictModel), also check if we
# can disallow adding custom Python attributes to the model
class SystemDependency(BaseModel):
Expand Down Expand Up @@ -54,9 +67,9 @@ class PackageDependency(BaseModel):


class Architecture(BaseModel):
architecture: str = Field(
None,
description="General description of the app architecture.",
app_type: AppType = Field(
AppType.WEB,
description="Type of the app to build.",
)
system_dependencies: list[SystemDependency] = Field(
None,
Expand All @@ -66,9 +79,16 @@ class Architecture(BaseModel):
None,
description="List of framework/language-specific packages used by the app.",
)


class TemplateSelection(BaseModel):
architecture: str = Field(
None,
description="General description of the app architecture.",
)
template: Optional[ProjectTemplateEnum] = Field(
None,
description="Project template to use for the app, if any (optional, can be null).",
description="Project template to use for the app, or null if no template is a good fit.",
)


Expand All @@ -89,31 +109,78 @@ async def run(self) -> AgentResponse:
await self.check_system_dependencies(spec)

self.next_state.specification = spec
telemetry.set("template", spec.template)
telemetry.set("templates", spec.templates)
self.next_state.action = ARCHITECTURE_STEP_NAME
return AgentResponse.done(self)

async def select_templates(self, spec: Specification) -> dict[str, BaseProjectTemplate]:
"""
Select project template(s) to use based on the project description.
Although the Pythagora database models support multiple projects, this
function will achoose at most one project template, as we currently don't
have templates that could be used together in a single project.
:param spec: Project specification.
:return: Dictionary of selected project templates.
"""
await self.send_message("Selecting starter templates ...")

llm = self.get_llm()
convo = (
AgentConvo(self)
.template(
"select_templates",
templates=PROJECT_TEMPLATES,
)
.require_schema(TemplateSelection)
)
tpl: TemplateSelection = await llm(convo, parser=JSONParser(TemplateSelection))
templates = {}
if tpl.template:
template_class = PROJECT_TEMPLATES.get(tpl.template)
if template_class:
options = await self.configure_template(spec, template_class)
templates[tpl.template] = template_class(
options,
self.state_manager,
self.process_manager,
)

return tpl.architecture, templates

async def plan_architecture(self, spec: Specification):
await self.send_message("Planning project architecture ...")
architecture_description, templates = await self.select_templates(spec)

await self.send_message("Picking technologies to use ...")

llm = self.get_llm()
convo = AgentConvo(self).template("technologies", templates=PROJECT_TEMPLATES).require_schema(Architecture)
convo = (
AgentConvo(self)
.template(
"technologies",
templates=templates,
architecture=architecture_description,
)
.require_schema(Architecture)
)
arch: Architecture = await llm(convo, parser=JSONParser(Architecture))

await self.check_compatibility(arch)

spec.architecture = arch.architecture
spec.architecture = architecture_description
spec.templates = {t.name: t.options_dict for t in templates.values()}
spec.system_dependencies = [d.model_dump() for d in arch.system_dependencies]
spec.package_dependencies = [d.model_dump() for d in arch.package_dependencies]
spec.template = arch.template.value if arch.template else None

async def check_compatibility(self, arch: Architecture) -> bool:
warn_system_deps = [dep.name for dep in arch.system_dependencies if dep.name.lower() in WARN_SYSTEM_DEPS]
warn_package_deps = [dep.name for dep in arch.package_dependencies if dep.name.lower() in WARN_FRAMEWORKS]

if warn_system_deps:
await self.ask_question(
f"Warning: GPT Pilot doesn't officially support {', '.join(warn_system_deps)}. "
f"Warning: Pythagora doesn't officially support {', '.join(warn_system_deps)}. "
f"You can try to use {'it' if len(warn_system_deps) == 1 else 'them'}, but you may run into problems.",
buttons={"continue": "Continue"},
buttons_only=True,
Expand All @@ -122,7 +189,7 @@ async def check_compatibility(self, arch: Architecture) -> bool:

if warn_package_deps:
await self.ask_question(
f"Warning: GPT Pilot works best with vanilla JavaScript. "
f"Warning: Pythagora works best with vanilla JavaScript. "
f"You can try try to use {', '.join(warn_package_deps)}, but you may run into problems. "
f"Visit {WARN_FRAMEWORKS_URL} for more information.",
buttons={"continue": "Continue"},
Expand All @@ -142,8 +209,8 @@ def prepare_example_project(self, spec: Specification):
spec.architecture = arch["architecture"]
spec.system_dependencies = arch["system_dependencies"]
spec.package_dependencies = arch["package_dependencies"]
spec.template = arch["template"]
telemetry.set("template", spec.template)
spec.templates = arch["templates"]
telemetry.set("templates", spec.templates)

async def check_system_dependencies(self, spec: Specification):
"""
Expand All @@ -157,6 +224,7 @@ async def check_system_dependencies(self, spec: Specification):
deps = spec.system_dependencies

for dep in deps:
await self.send_message(f"Checking if {dep['name']} is available ...")
status_code, _, _ = await self.process_manager.run_command(dep["test"])
dep["installed"] = bool(status_code == 0)
if status_code != 0:
Expand All @@ -174,11 +242,30 @@ async def check_system_dependencies(self, spec: Specification):
else:
await self.send_message(f"✅ {dep['name']} is available.")

telemetry.set(
"architecture",
{
"description": spec.architecture,
"system_dependencies": deps,
"package_dependencies": spec.package_dependencies,
},
async def configure_template(self, spec: Specification, template_class: BaseProjectTemplate) -> BaseModel:
"""
Ask the LLM to configure the template options.
Based on the project description, the LLM should pick the options that
make the most sense. If template has no options, the method is a no-op
and returns an empty options model.
:param spec: Project specification.
:param template_class: Template that needs to be configured.
:return: Configured options model.
"""
if template_class.options_class is NoOptions:
# If template has no options, no need to ask LLM for anything
return NoOptions()

llm = self.get_llm()
convo = (
AgentConvo(self)
.template(
"configure_template",
project_description=spec.description,
project_template=template_class,
)
.require_schema(template_class.options_class)
)
return await llm(convo, parser=JSONParser(template_class.options_class))
18 changes: 16 additions & 2 deletions core/agents/convo.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from copy import deepcopy
from typing import TYPE_CHECKING, Optional

import jsonref
from pydantic import BaseModel

from core.config import get_config
Expand Down Expand Up @@ -88,6 +89,19 @@ def fork(self) -> "AgentConvo":
return child

def require_schema(self, model: BaseModel) -> "AgentConvo":
schema_txt = json.dumps(model.model_json_schema())
self.user(f"IMPORTANT: Your response MUST conform to this JSON schema:\n```\n{schema_txt}\n```")
def remove_defs(d):
if isinstance(d, dict):
return {k: remove_defs(v) for k, v in d.items() if k != "$defs"}
elif isinstance(d, list):
return [remove_defs(v) for v in d]
else:
return d

# We want to make the schema as simple as possible to avoid confusing the LLM,
# so we remove (dereference) all the refs we can and show the "final" schema version.
schema_txt = json.dumps(remove_defs(jsonref.loads(json.dumps(model.model_json_schema()))))
self.user(
f"IMPORTANT: Your response MUST conform to this JSON schema:\n```\n{schema_txt}\n```."
f"YOU MUST NEVER add any additional fields to your response, and NEVER add additional preamble like 'Here is your JSON'."
)
return self
1 change: 1 addition & 0 deletions core/agents/error_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ async def handle_command_error(self, message: str, details: dict) -> AgentRespon
{
"id": uuid4().hex,
"user_feedback": f"Error running command: {cmd}",
"user_feedback_qa": None,
"description": llm_response,
"alternative_solutions": [],
"attempts": 1,
Expand Down
4 changes: 2 additions & 2 deletions core/agents/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,9 @@ def create_agent(self, prev_response: Optional[AgentResponse]) -> BaseAgent:
elif (
not state.epics
or not self.current_state.unfinished_tasks
or (state.specification.template and not state.files)
or (state.specification.templates and not state.files)
):
# Ask the Tech Lead to break down the initial project or feature into tasks and apply project template
# Ask the Tech Lead to break down the initial project or feature into tasks and apply project templates
return TechLead(self.state_manager, self.ui, process_manager=self.process_manager)

if state.current_task and state.docs is None and state.specification.complexity != Complexity.SIMPLE:
Expand Down
58 changes: 36 additions & 22 deletions core/agents/tech_lead.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from typing import Optional
from uuid import uuid4

from pydantic import BaseModel, Field
Expand All @@ -12,7 +11,7 @@
from core.log import get_logger
from core.telemetry import telemetry
from core.templates.example_project import EXAMPLE_PROJECTS
from core.templates.registry import apply_project_template, get_template_description, get_template_summary
from core.templates.registry import PROJECT_TEMPLATES
from core.ui.base import ProjectStage, success_source

log = get_logger(__name__)
Expand Down Expand Up @@ -51,9 +50,9 @@ async def run(self) -> AgentResponse:

await self.ui.send_project_stage(ProjectStage.CODING)

if self.current_state.specification.template and not self.current_state.files:
await self.apply_project_template()
self.next_state.action = "Apply project template"
if self.current_state.specification.templates and not self.current_state.files:
await self.apply_project_templates()
self.next_state.action = "Apply project templates"
return AgentResponse.done(self)

if self.current_state.current_epic:
Expand All @@ -77,25 +76,39 @@ def create_initial_project_epic(self):
}
]

async def apply_project_template(self) -> Optional[str]:
async def apply_project_templates(self):
state = self.current_state
summaries = []

# Only do this for the initial project and if the templates are specified
if len(state.epics) != 1 or not state.specification.templates:
return

for template_name, template_options in state.specification.templates.items():
template_class = PROJECT_TEMPLATES.get(template_name)
if not template_class:
log.error(f"Project template not found: {template_name}")
continue

template = template_class(
template_options,
self.state_manager,
self.process_manager,
)

# Only do this for the initial project and if the template is specified
if len(state.epics) != 1 or not state.specification.template:
return None

description = get_template_description(state.specification.template)
log.info(f"Applying project template: {state.specification.template}")
await self.send_message(f"Applying project template {description} ...")
summary = await apply_project_template(
self.current_state.specification.template,
self.state_manager,
self.process_manager,
)
# Saving template files will fill this in and we want it clear for the
# first task.
description = template.description
log.info(f"Applying project template: {template.name}")
await self.send_message(f"Applying project template {description} ...")
summary = await template.apply()
summaries.append(summary)

# Saving template files will fill this in and we want it clear for the first task.
self.next_state.relevant_files = None
return summary

if summaries:
spec = self.current_state.specification.clone()
spec.description += "\n\n" + "\n\n".join(summaries)
self.next_state.specification = spec

async def ask_for_new_feature(self) -> AgentResponse:
if len(self.current_state.epics) > 2:
Expand Down Expand Up @@ -140,7 +153,8 @@ async def plan_epic(self, epic) -> AgentResponse:
"plan",
epic=epic,
task_type=self.current_state.current_epic.get("source", "app"),
existing_summary=get_template_summary(self.current_state.specification.template),
# FIXME: we're injecting summaries to initial description
existing_summary=None,
)
.require_schema(DevelopmentPlan)
)
Expand Down
2 changes: 2 additions & 0 deletions core/cli/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ def parse_arguments() -> Namespace:
--import-v0: Import data from a v0 (gpt-pilot) database with the given path
--email: User's email address, if provided
--extension-version: Version of the VSCode extension, if used
--no-check: Disable initial LLM API check
:return: Parsed arguments object.
"""
version = get_version()
Expand Down Expand Up @@ -134,6 +135,7 @@ def parse_arguments() -> Namespace:
)
parser.add_argument("--email", help="User's email address", required=False)
parser.add_argument("--extension-version", help="Version of the VSCode extension", required=False)
parser.add_argument("--no-check", help="Disable initial LLM API check", action="store_true")
return parser.parse_args()


Expand Down
Loading

0 comments on commit 0923e54

Please sign in to comment.