Merge pull request #1008 from Pythagora-io/more-telemetry

More telemetry
This commit is contained in:
LeonOstrez
2024-06-13 15:26:30 +02:00
committed by GitHub
19 changed files with 295 additions and 96 deletions

View File

@@ -5,8 +5,11 @@ from pydantic import BaseModel, Field
from core.agents.base import BaseAgent from core.agents.base import BaseAgent
from core.agents.convo import AgentConvo from core.agents.convo import AgentConvo
from core.agents.response import AgentResponse from core.agents.response import AgentResponse
from core.db.models import Specification
from core.llm.parser import JSONParser from core.llm.parser import JSONParser
from core.log import get_logger
from core.telemetry import telemetry from core.telemetry import telemetry
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 from core.ui.base import ProjectStage
@@ -15,6 +18,8 @@ WARN_SYSTEM_DEPS = ["docker", "kubernetes", "microservices"]
WARN_FRAMEWORKS = ["next.js", "vue", "vue.js", "svelte", "angular"] WARN_FRAMEWORKS = ["next.js", "vue", "vue.js", "svelte", "angular"]
WARN_FRAMEWORKS_URL = "https://github.com/Pythagora-io/gpt-pilot/wiki/Using-GPT-Pilot-with-frontend-frameworks" WARN_FRAMEWORKS_URL = "https://github.com/Pythagora-io/gpt-pilot/wiki/Using-GPT-Pilot-with-frontend-frameworks"
log = get_logger(__name__)
# FIXME: all the reponse pydantic models should be strict (see config._StrictModel), also check if we # 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 # can disallow adding custom Python attributes to the model
@@ -74,34 +79,34 @@ class Architect(BaseAgent):
async def run(self) -> AgentResponse: async def run(self) -> AgentResponse:
await self.ui.send_project_stage(ProjectStage.ARCHITECTURE) await self.ui.send_project_stage(ProjectStage.ARCHITECTURE)
spec = self.current_state.specification.clone()
if spec.example_project:
self.prepare_example_project(spec)
else:
await self.plan_architecture(spec)
await self.check_system_dependencies(spec)
self.next_state.specification = spec
telemetry.set("template", spec.template)
self.next_state.action = ARCHITECTURE_STEP_NAME
return AgentResponse.done(self)
async def plan_architecture(self, spec: Specification):
await self.send_message("Planning project architecture ...")
llm = self.get_llm() llm = self.get_llm()
convo = AgentConvo(self).template("technologies", templates=PROJECT_TEMPLATES).require_schema(Architecture) convo = AgentConvo(self).template("technologies", templates=PROJECT_TEMPLATES).require_schema(Architecture)
await self.send_message("Planning project architecture ...")
arch: Architecture = await llm(convo, parser=JSONParser(Architecture)) arch: Architecture = await llm(convo, parser=JSONParser(Architecture))
await self.check_compatibility(arch) await self.check_compatibility(arch)
await self.check_system_dependencies(arch.system_dependencies)
spec = self.current_state.specification.clone()
spec.architecture = arch.architecture spec.architecture = arch.architecture
spec.system_dependencies = [d.model_dump() for d in arch.system_dependencies] 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.package_dependencies = [d.model_dump() for d in arch.package_dependencies]
spec.template = arch.template.value if arch.template else None spec.template = arch.template.value if arch.template else None
self.next_state.specification = spec
telemetry.set(
"architecture",
{
"description": spec.architecture,
"system_dependencies": spec.system_dependencies,
"package_dependencies": spec.package_dependencies,
},
)
telemetry.set("template", spec.template)
self.next_state.action = ARCHITECTURE_STEP_NAME
return AgentResponse.done(self)
async def check_compatibility(self, arch: Architecture) -> bool: 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_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] warn_package_deps = [dep.name for dep in arch.package_dependencies if dep.name.lower() in WARN_FRAMEWORKS]
@@ -130,18 +135,50 @@ class Architect(BaseAgent):
# that SpecWriter should catch and allow the user to reword the initial spec. # that SpecWriter should catch and allow the user to reword the initial spec.
return True return True
async def check_system_dependencies(self, deps: list[SystemDependency]): def prepare_example_project(self, spec: Specification):
log.debug(f"Setting architecture for example project: {spec.example_project}")
arch = EXAMPLE_PROJECTS[spec.example_project]["architecture"]
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)
async def check_system_dependencies(self, spec: Specification):
""" """
Check whether the required system dependencies are installed. Check whether the required system dependencies are installed.
This also stores the app architecture telemetry data, including the
information about whether each system dependency is installed.
:param spec: Project specification.
""" """
deps = spec.system_dependencies
for dep in deps: for dep in deps:
status_code, _, _ = await self.process_manager.run_command(dep.test) status_code, _, _ = await self.process_manager.run_command(dep["test"])
dep["installed"] = bool(status_code == 0)
if status_code != 0: if status_code != 0:
if dep.required_locally: if dep["required_locally"]:
remedy = "Please install it before proceeding with your app." remedy = "Please install it before proceeding with your app."
else: else:
remedy = "If you would like to use it locally, please install it before proceeding." remedy = "If you would like to use it locally, please install it before proceeding."
await self.send_message(f"{dep.name} is not available. {remedy}") await self.send_message(f"{dep['name']} is not available. {remedy}")
await self.ask_question(
f"Once you have installed {dep['name']}, please press Continue.",
buttons={"continue": "Continue"},
buttons_only=True,
default="continue",
)
else: else:
await self.send_message(f"{dep.name} is available.") await self.send_message(f"{dep['name']} is available.")
telemetry.set(
"architecture",
{
"description": spec.architecture,
"system_dependencies": deps,
"package_dependencies": spec.package_dependencies,
},
)

View File

@@ -10,6 +10,7 @@ from core.agents.response import AgentResponse, ResponseType
from core.db.models.project_state import TaskStatus from core.db.models.project_state import TaskStatus
from core.llm.parser import JSONParser from core.llm.parser import JSONParser
from core.log import get_logger from core.log import get_logger
from core.telemetry import telemetry
log = get_logger(__name__) log = get_logger(__name__)
@@ -195,6 +196,14 @@ class Developer(BaseAgent):
self.next_state.modified_files = {} self.next_state.modified_files = {}
self.set_next_steps(response, source) self.set_next_steps(response, source)
self.next_state.action = f"Task #{current_task_index + 1} start" self.next_state.action = f"Task #{current_task_index + 1} start"
await telemetry.trace_code_event(
"task-start",
{
"task_index": current_task_index + 1,
"num_tasks": len(self.current_state.tasks),
"num_epics": len(self.current_state.epics),
},
)
return AgentResponse.done(self) return AgentResponse.done(self)
async def get_relevant_files( async def get_relevant_files(

View File

@@ -26,7 +26,7 @@ class SelectedDocsets(BaseModel):
class ExternalDocumentation(BaseAgent): class ExternalDocumentation(BaseAgent):
"""Agent in charge of collecting and storing additional documentation. """Agent in charge of collecting and storing additional documentation.
Docs are per task and are stores in the `tasks` variable in the project state. Docs are per task and are stores in the `docs` variable in the project state.
This agent ensures documentation is collected only once per task. This agent ensures documentation is collected only once per task.
Agent does 2 LLM interactions: Agent does 2 LLM interactions:
@@ -44,7 +44,12 @@ class ExternalDocumentation(BaseAgent):
display_name = "Documentation" display_name = "Documentation"
async def run(self) -> AgentResponse: async def run(self) -> AgentResponse:
available_docsets = await self._get_available_docsets() if self.current_state.specification.example_project:
log.debug("Example project detected, no documentation selected.")
available_docsets = []
else:
available_docsets = await self._get_available_docsets()
selected_docsets = await self._select_docsets(available_docsets) selected_docsets = await self._select_docsets(available_docsets)
await telemetry.trace_code_event("docsets_used", selected_docsets) await telemetry.trace_code_event("docsets_used", selected_docsets)
@@ -153,6 +158,8 @@ class ExternalDocumentation(BaseAgent):
Documentation snippets are stored as a list of dictionaries: Documentation snippets are stored as a list of dictionaries:
{"key": docset-key, "desc": documentation-description, "snippets": list-of-snippets} {"key": docset-key, "desc": documentation-description, "snippets": list-of-snippets}
:param snippets: List of tuples: (docset_key, snippets)
:param available_docsets: List of available docsets from the API.
""" """
docsets_dict = dict(available_docsets) docsets_dict = dict(available_docsets)
@@ -161,4 +168,3 @@ class ExternalDocumentation(BaseAgent):
docs.append({"key": docset_key, "desc": docsets_dict[docset_key], "snippets": snip}) docs.append({"key": docset_key, "desc": docsets_dict[docset_key], "snippets": snip})
self.next_state.docs = docs self.next_state.docs = docs
self.next_state.flag_tasks_as_modified()

View File

@@ -187,7 +187,7 @@ class Orchestrator(BaseAgent):
return Importer(self.state_manager, self.ui) return Importer(self.state_manager, self.ui)
else: else:
# New project: ask the Spec Writer to refine and save the project specification # New project: ask the Spec Writer to refine and save the project specification
return SpecWriter(self.state_manager, self.ui) return SpecWriter(self.state_manager, self.ui, process_manager=self.process_manager)
elif not state.specification.architecture: elif not state.specification.architecture:
# Ask the Architect to design the project architecture and determine dependencies # Ask the Architect to design the project architecture and determine dependencies
return Architect(self.state_manager, self.ui, process_manager=self.process_manager) return Architect(self.state_manager, self.ui, process_manager=self.process_manager)

View File

@@ -3,11 +3,11 @@ from core.agents.convo import AgentConvo
from core.agents.response import AgentResponse from core.agents.response import AgentResponse
from core.db.models import Complexity from core.db.models import Complexity
from core.llm.parser import StringParser from core.llm.parser import StringParser
from core.log import get_logger
from core.telemetry import telemetry from core.telemetry import telemetry
from core.templates.example_project import ( from core.templates.example_project import (
EXAMPLE_PROJECT_ARCHITECTURE, DEFAULT_EXAMPLE_PROJECT,
EXAMPLE_PROJECT_DESCRIPTION, EXAMPLE_PROJECTS,
EXAMPLE_PROJECT_PLAN,
) )
# If the project description is less than this, perform an analysis using LLM # If the project description is less than this, perform an analysis using LLM
@@ -18,6 +18,8 @@ INITIAL_PROJECT_HOWTO_URL = (
) )
SPEC_STEP_NAME = "Create specification" SPEC_STEP_NAME = "Create specification"
log = get_logger(__name__)
class SpecWriter(BaseAgent): class SpecWriter(BaseAgent):
agent_type = "spec-writer" agent_type = "spec-writer"
@@ -41,18 +43,25 @@ class SpecWriter(BaseAgent):
return AgentResponse.import_project(self) return AgentResponse.import_project(self)
if response.button == "example": if response.button == "example":
await self.send_message("Starting example project with description:") await self.prepare_example_project(DEFAULT_EXAMPLE_PROJECT)
await self.send_message(EXAMPLE_PROJECT_DESCRIPTION)
self.prepare_example_project()
return AgentResponse.done(self) return AgentResponse.done(self)
elif response.button == "continue": elif response.button == "continue":
# FIXME: Workaround for the fact that VSCode "continue" button does # FIXME: Workaround for the fact that VSCode "continue" button does
# nothing but repeat the question. We reproduce this bug for bug here. # nothing but repeat the question. We reproduce this bug for bug here.
return AgentResponse.done(self) return AgentResponse.done(self)
spec = response.text spec = response.text.strip()
complexity = await self.check_prompt_complexity(spec) complexity = await self.check_prompt_complexity(spec)
await telemetry.trace_code_event(
"project-description",
{
"initial_prompt": spec,
"complexity": complexity,
},
)
if len(spec) < ANALYZE_THRESHOLD and complexity != Complexity.SIMPLE: if len(spec) < ANALYZE_THRESHOLD and complexity != Complexity.SIMPLE:
spec = await self.analyze_spec(spec) spec = await self.analyze_spec(spec)
spec = await self.review_spec(spec) spec = await self.review_spec(spec)
@@ -73,36 +82,21 @@ class SpecWriter(BaseAgent):
llm_response: str = await llm(convo, temperature=0, parser=StringParser()) llm_response: str = await llm(convo, temperature=0, parser=StringParser())
return llm_response.lower() return llm_response.lower()
def prepare_example_project(self): async def prepare_example_project(self, example_name: str):
example_description = EXAMPLE_PROJECTS[example_name]["description"].strip()
log.debug(f"Starting example project: {example_name}")
await self.send_message(f"Starting example project with description:\n\n{example_description}")
spec = self.current_state.specification.clone() spec = self.current_state.specification.clone()
spec.description = EXAMPLE_PROJECT_DESCRIPTION spec.example_project = example_name
spec.architecture = EXAMPLE_PROJECT_ARCHITECTURE["architecture"] spec.description = example_description
spec.system_dependencies = EXAMPLE_PROJECT_ARCHITECTURE["system_dependencies"] spec.complexity = EXAMPLE_PROJECTS[example_name]["complexity"]
spec.package_dependencies = EXAMPLE_PROJECT_ARCHITECTURE["package_dependencies"]
spec.template = EXAMPLE_PROJECT_ARCHITECTURE["template"]
spec.complexity = Complexity.SIMPLE
telemetry.set("initial_prompt", spec.description.strip())
telemetry.set("is_complex_app", False)
telemetry.set("template", spec.template)
telemetry.set(
"architecture",
{
"architecture": spec.architecture,
"system_dependencies": spec.system_dependencies,
"package_dependencies": spec.package_dependencies,
},
)
self.next_state.specification = spec self.next_state.specification = spec
self.next_state.epics = [ telemetry.set("initial_prompt", spec.description)
{ telemetry.set("example_project", example_name)
"name": "Initial Project", telemetry.set("is_complex_app", spec.complexity != Complexity.SIMPLE)
"description": EXAMPLE_PROJECT_DESCRIPTION,
"completed": False,
"complexity": Complexity.SIMPLE,
}
]
self.next_state.tasks = EXAMPLE_PROJECT_PLAN
async def analyze_spec(self, spec: str) -> str: async def analyze_spec(self, spec: str) -> str:
msg = ( msg = (
@@ -115,6 +109,8 @@ class SpecWriter(BaseAgent):
llm = self.get_llm() llm = self.get_llm()
convo = AgentConvo(self).template("ask_questions").user(spec) convo = AgentConvo(self).template("ask_questions").user(spec)
n_questions = 0
n_answers = 0
while True: while True:
response: str = await llm(convo) response: str = await llm(convo)
@@ -129,12 +125,21 @@ class SpecWriter(BaseAgent):
buttons={"continue": "continue"}, buttons={"continue": "continue"},
) )
if confirm.cancelled or confirm.button == "continue" or confirm.text == "": if confirm.cancelled or confirm.button == "continue" or confirm.text == "":
await self.telemetry.trace_code_event(
"spec-writer-questions",
{
"num_questions": n_questions,
"num_answers": n_answers,
"new_spec": spec,
},
)
return spec return spec
convo.user(confirm.text) convo.user(confirm.text)
else: else:
convo.assistant(response) convo.assistant(response)
n_questions += 1
user_response = await self.ask_question( user_response = await self.ask_question(
response, response,
buttons={"skip": "Skip questions"}, buttons={"skip": "Skip questions"},
@@ -147,6 +152,7 @@ class SpecWriter(BaseAgent):
response: str = await llm(convo) response: str = await llm(convo)
return response return response
n_answers += 1
convo.user(user_response.text) convo.user(user_response.text)
async def review_spec(self, spec: str) -> str: async def review_spec(self, spec: str) -> str:

View File

@@ -1,6 +1,7 @@
from core.agents.base import BaseAgent from core.agents.base import BaseAgent
from core.agents.response import AgentResponse from core.agents.response import AgentResponse
from core.log import get_logger from core.log import get_logger
from core.telemetry import telemetry
log = get_logger(__name__) log = get_logger(__name__)
@@ -25,5 +26,14 @@ class TaskCompleter(BaseAgent):
self.current_state.get_source_index(source), self.current_state.get_source_index(source),
tasks, tasks,
) )
await telemetry.trace_code_event(
"task-end",
{
"task_index": current_task_index1,
"num_tasks": len(self.current_state.tasks),
"num_epics": len(self.current_state.epics),
"num_iterations": len(self.current_state.iterations),
},
)
return AgentResponse.done(self) return AgentResponse.done(self)

View File

@@ -10,6 +10,8 @@ from core.db.models import Complexity
from core.db.models.project_state import TaskStatus from core.db.models.project_state import TaskStatus
from core.llm.parser import JSONParser from core.llm.parser import JSONParser
from core.log import get_logger 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 apply_project_template, get_template_description, get_template_summary
from core.ui.base import ProjectStage, success_source from core.ui.base import ProjectStage, success_source
@@ -41,8 +43,10 @@ class TechLead(BaseAgent):
return await self.update_epic() return await self.update_epic()
if len(self.current_state.epics) == 0: if len(self.current_state.epics) == 0:
self.create_initial_project_epic() if self.current_state.specification.example_project:
# Orchestrator will rerun us to break down the initial project epic self.plan_example_project()
else:
self.create_initial_project_epic()
return AgentResponse.done(self) return AgentResponse.done(self)
await self.ui.send_project_stage(ProjectStage.CODING) await self.ui.send_project_stage(ProjectStage.CODING)
@@ -151,6 +155,13 @@ class TechLead(BaseAgent):
} }
for task in response.plan for task in response.plan
] ]
await telemetry.trace_code_event(
"development-plan",
{
"num_tasks": len(self.current_state.tasks),
"num_epics": len(self.current_state.epics),
},
)
return AgentResponse.done(self) return AgentResponse.done(self)
async def update_epic(self) -> AgentResponse: async def update_epic(self) -> AgentResponse:
@@ -201,3 +212,18 @@ class TechLead(BaseAgent):
] ]
log.debug(f"Updated development plan for {epic['name']}, {len(response.plan)} tasks remaining") log.debug(f"Updated development plan for {epic['name']}, {len(response.plan)} tasks remaining")
return AgentResponse.done(self) return AgentResponse.done(self)
def plan_example_project(self):
example_name = self.current_state.specification.example_project
log.debug(f"Planning example project: {example_name}")
example = EXAMPLE_PROJECTS[example_name]
self.next_state.epics = [
{
"name": "Initial Project",
"description": example["description"],
"completed": False,
"complexity": example["complexity"],
}
]
self.next_state.tasks = example["plan"]

View File

@@ -182,6 +182,21 @@ class Troubleshooter(IterationPromptMixin, BaseAgent):
return False, False, "" return False, False, ""
if user_response.button == "loop": if user_response.button == "loop":
await telemetry.trace_code_event(
"stuck-in-loop",
{
"clicked": True,
"task_index": self.current_state.tasks.index(self.current_state.current_task) + 1,
"num_tasks": len(self.current_state.tasks),
"num_epics": len(self.current_state.epics),
"num_iterations": len(self.current_state.iterations),
"num_steps": len(self.current_state.steps),
"architecture": {
"system_dependencies": self.current_state.specification.system_dependencies,
"app_dependencies": self.current_state.specification.package_dependencies,
},
},
)
return True, True, "" return True, True, ""
return True, False, user_response.text return True, False, user_response.text

View File

@@ -1 +1,10 @@
Generic single-database configuration. Pythagora uses Alembic for database migrations.
After changing any of the database models, create a new migration:
alembic -c core/db/alembic.ini revision --autogenerate -m "description"
Migrations are applied automatically when the application starts, but can also be
run manually with:
alembic -c core/db/alembic.ini upgrade head

View File

@@ -0,0 +1,34 @@
"""add example project to spec
Revision ID: ff891d366761
Revises: b760f66138c0
Create Date: 2024-06-13 09:38:33.329161
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "ff891d366761"
down_revision: Union[str, None] = "b760f66138c0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("specifications", schema=None) as batch_op:
batch_op.add_column(sa.Column("example_project", sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("specifications", schema=None) as batch_op:
batch_op.drop_column("example_project")
# ### end Alembic commands ###

View File

@@ -31,6 +31,7 @@ class Specification(Base):
package_dependencies: Mapped[list[dict]] = mapped_column(default=list) package_dependencies: Mapped[list[dict]] = mapped_column(default=list)
template: Mapped[Optional[str]] = mapped_column() template: Mapped[Optional[str]] = mapped_column()
complexity: Mapped[str] = mapped_column(server_default=Complexity.HARD) complexity: Mapped[str] = mapped_column(server_default=Complexity.HARD)
example_project: Mapped[Optional[str]] = mapped_column()
# Relationships # Relationships
project_states: Mapped[list["ProjectState"]] = relationship(back_populates="specification", lazy="raise") project_states: Mapped[list["ProjectState"]] = relationship(back_populates="specification", lazy="raise")
@@ -46,6 +47,7 @@ class Specification(Base):
package_dependencies=self.package_dependencies, package_dependencies=self.package_dependencies,
template=self.template, template=self.template,
complexity=self.complexity, complexity=self.complexity,
example_project=self.example_project,
) )
return clone return clone

View File

@@ -77,6 +77,7 @@ class StateManager:
f'with default branch "{branch.name}" (id={branch.id}) ' f'with default branch "{branch.name}" (id={branch.id}) '
f"and initial state id={state.id} (step_index={state.step_index})" f"and initial state id={state.id} (step_index={state.step_index})"
) )
await telemetry.trace_code_event("create-project", {"name": name})
self.current_session = session self.current_session = session
self.current_state = state self.current_state = state

View File

@@ -2,6 +2,7 @@ import sys
import time import time
import traceback import traceback
from copy import deepcopy from copy import deepcopy
from os import getenv
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -72,7 +73,7 @@ class Telemetry:
"python_version": sys.version, "python_version": sys.version,
# GPT Pilot version # GPT Pilot version
"pilot_version": get_version(), "pilot_version": get_version(),
# GPT Pilot Extension version # Pythagora VSCode Extension version
"extension_version": None, "extension_version": None,
# Is extension used # Is extension used
"is_extension": False, "is_extension": False,
@@ -85,6 +86,8 @@ class Telemetry:
"is_complex_app": None, "is_complex_app": None,
# Optional template used for the project # Optional template used for the project
"template": None, "template": None,
# Optional, example project selected by the user
"example_project": None,
# Optional user contact email # Optional user contact email
"user_contact": None, "user_contact": None,
# Unique project ID (app_id) # Unique project ID (app_id)
@@ -320,7 +323,7 @@ class Telemetry:
Note: this method clears all telemetry data after sending it. Note: this method clears all telemetry data after sending it.
""" """
if not self.enabled: if not self.enabled or getenv("DISABLE_TELEMETRY"):
log.debug("Telemetry.send(): telemetry is disabled, not sending data") log.debug("Telemetry.send(): telemetry is disabled, not sending data")
return return
@@ -362,22 +365,26 @@ class Telemetry:
:param name: name of the event :param name: name of the event
:param data: data to send with the event :param data: data to send with the event
""" """
if not self.enabled: if not self.enabled or getenv("DISABLE_TELEMETRY"):
return return
data = deepcopy(data)
for item in ["app_id", "user_contact", "platform", "pilot_version", "model"]:
data[item] = self.data[item]
payload = { payload = {
"pathId": self.telemetry_id, "pathId": self.telemetry_id,
"event": f"trace-{name}", "event": f"trace-{name}",
"data": data, "data": data,
} }
log.debug(f"Sending trace event {name} to {self.endpoint}") log.debug(f"Sending trace event {name} to {self.endpoint}: {repr(payload)}")
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
await client.post(self.endpoint, json=payload) await client.post(self.endpoint, json=payload)
except httpx.RequestError: except httpx.RequestError as e:
pass log.error(f"Failed to send trace event {name}: {e}", exc_info=True)
async def trace_loop(self, name: str, task_with_loop: dict): async def trace_loop(self, name: str, task_with_loop: dict):
payload = deepcopy(self.data) payload = deepcopy(self.data)

View File

@@ -1,3 +1,5 @@
from core.db.models import Complexity
EXAMPLE_PROJECT_DESCRIPTION = """ EXAMPLE_PROJECT_DESCRIPTION = """
The application is a simple ToDo app built using React. Its primary function is to allow users to manage a list of tasks (todos). Each task has a description and a state (open or completed, with the default state being open). The application is frontend-only, with no user sign-up or authentication process. The goal is to provide a straightforward and user-friendly interface for task management. The application is a simple ToDo app built using React. Its primary function is to allow users to manage a list of tasks (todos). Each task has a description and a state (open or completed, with the default state being open). The application is frontend-only, with no user sign-up or authentication process. The goal is to provide a straightforward and user-friendly interface for task management.
@@ -64,3 +66,15 @@ EXAMPLE_PROJECT_PLAN = [
"status": "todo", "status": "todo",
} }
] ]
EXAMPLE_PROJECTS = {
"example-project": {
"description": EXAMPLE_PROJECT_DESCRIPTION,
"architecture": EXAMPLE_PROJECT_ARCHITECTURE,
"complexity": Complexity.SIMPLE,
"plan": EXAMPLE_PROJECT_PLAN,
}
}
DEFAULT_EXAMPLE_PROJECT = "example-project"

View File

@@ -1,30 +1,34 @@
## Telemetry in GPT Pilot ## Telemetry in Pythagora
At GPT Pilot, we are dedicated to improving your experience and the overall quality of our software. To achieve this, we gather anonymous telemetry data which helps us understand how the tool is being used and identify areas for improvement. At Pythagora, we are dedicated to improving your experience and the overall quality of our software. To achieve this, we gather anonymous telemetry data which helps us understand how the tool is being used and identify areas for improvement.
### What We Collect ### What We Collect
The telemetry data we collect includes: The telemetry data we collect includes:
- **Total Runtime**: The total time GPT Pilot was active and running. - **Total Runtime**: The total time Pythagora was active and running.
- **Command Runs**: How many commands were executed during a session. - **Command Runs**: How many commands were executed during a session.
- **Development Steps**: The number of development steps that were performed. - **Development Steps**: The number of development steps that were performed.
- **LLM Requests**: The number of LLM requests made. - **LLM Requests**: The number of LLM requests made.
- **User Inputs**: The number of times you provide input to the tool. - **User Inputs**: The number of times you provide input to the tool.
- **Operating System**: The operating system you are using (and Linux distro if applicable). - **Operating System**: The operating system you are using (and Linux distro if applicable).
- **Python Version**: The version of Python you are using. - **Python Version**: The version of Python you are using.
- **GPT Pilot Version**: The version of GPT Pilot you are using. - **GPT Pilot Version**: The version of Pythagora you are using.
- **LLM Model**: LLM model used for the session. - **LLM Model**: LLM model(s) used for the session.
- **Time**: How long it took to generate a project. - **Time**: How long it took to generate a project.
- **Initial prompt**: App description used to create app (after Specification Writer Agent). - **Initial prompt**: App description used to create app (after Specification Writer Agent).
- **Architecture**: Architecture designed by Pythagora for the app.
- **Documentation**: Pythagora documentation that was used while creating the app.
- **User Email**: User email (if using Pythagora VSCode Extgension, or if explicitly provided when running Pythagora from the command line).
- **Pythagora Tasks/Steps**: Information about the development tasks and steps Pythagora does while coding the app.
All the data points are listed in [pilot.utils.telemetry:Telemetry.clear_data()](../pilot/utils/telemetry.py). All the data points are listed in [core.telemetry:Telemetry.clear_data()](../core/telemetry/__init__.py).
### How We Use This Data ### How We Use This Data
We use this data to: We use this data to:
- Monitor the performance and reliability of GPT Pilot. - Monitor the performance and reliability of Pythagora.
- Understand usage patterns to guide our development and feature prioritization. - Understand usage patterns to guide our development and feature prioritization.
- Identify common workflows and improve the user experience. - Identify common workflows and improve the user experience.
- Ensure the scalability and efficiency of our language model interactions. - Ensure the scalability and efficiency of our language model interactions.
@@ -37,9 +41,9 @@ Your privacy is important to us. The data collected is purely for internal analy
We believe in transparency and control. If you prefer not to send telemetry data, you can opt-out at any time by setting `telemetry.enabled` to `false` in your `~/.gpt-pilot/config.json` configuration file. We believe in transparency and control. If you prefer not to send telemetry data, you can opt-out at any time by setting `telemetry.enabled` to `false` in your `~/.gpt-pilot/config.json` configuration file.
After you update this setting, GPT Pilot will no longer collect telemetry data from your machine. After you update this setting, Pythagora will no longer collect telemetry data from your machine.
### Questions and Feedback ### Questions and Feedback
If you have questions about our telemetry practices or would like to provide feedback, please open an issue in our repository, and we will be happy to engage with you. If you have questions about our telemetry practices or would like to provide feedback, please open an issue in our repository, and we will be happy to engage with you.
Thank you for supporting GPT Pilot and helping us make it better for everyone. Thank you for supporting Pythagora and helping us make it better for everyone.

View File

@@ -8,9 +8,9 @@ from core.state.state_manager import StateManager
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_offline_changes_check_restores_if_workspace_empty(): async def test_offline_changes_check_restores_if_workspace_empty():
sm = Mock(spec=StateManager) sm = AsyncMock(spec=StateManager)
sm.workspace_is_empty.return_value = True sm.workspace_is_empty = Mock(return_value=False)
ui = Mock() ui = AsyncMock()
orca = Orchestrator(state_manager=sm, ui=ui) orca = Orchestrator(state_manager=sm, ui=ui)
await orca.offline_changes_check() await orca.offline_changes_check()
assert sm.restore_files.assert_called_once assert sm.restore_files.assert_called_once
@@ -19,7 +19,8 @@ async def test_offline_changes_check_restores_if_workspace_empty():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_offline_changes_check_imports_changes_from_disk(): async def test_offline_changes_check_imports_changes_from_disk():
sm = AsyncMock() sm = AsyncMock()
sm.workspace_is_empty.return_value = False sm.workspace_is_empty = Mock(return_value=False)
sm.import_files = AsyncMock(return_value=([], []))
ui = AsyncMock() ui = AsyncMock()
ui.ask_question.return_value.button = "yes" ui.ask_question.return_value.button = "yes"
orca = Orchestrator(state_manager=sm, ui=ui) orca = Orchestrator(state_manager=sm, ui=ui)
@@ -31,7 +32,7 @@ async def test_offline_changes_check_imports_changes_from_disk():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_offline_changes_check_restores_changes_from_db(): async def test_offline_changes_check_restores_changes_from_db():
sm = AsyncMock() sm = AsyncMock()
sm.workspace_is_empty.return_value = False sm.workspace_is_empty = Mock(return_value=False)
ui = AsyncMock() ui = AsyncMock()
ui.ask_question.return_value.button = "no" ui.ask_question.return_value.button = "no"
orca = Orchestrator(state_manager=sm, ui=ui) orca = Orchestrator(state_manager=sm, ui=ui)

View File

@@ -1,28 +1,30 @@
from unittest.mock import AsyncMock
import pytest import pytest
from core.agents.response import ResponseType from core.agents.response import ResponseType
from core.agents.spec_writer import SpecWriter from core.agents.spec_writer import SpecWriter
from core.db.models import Complexity from core.db.models import Complexity
from core.telemetry import telemetry
from core.ui.base import UserInput from core.ui.base import UserInput
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_example_project(agentcontext): async def test_start_example_project(agentcontext):
sm, _, ui, _ = agentcontext sm, pm, ui, _ = agentcontext
ui.ask_question.return_value = UserInput(button="example") ui.ask_question.return_value = UserInput(button="example")
pm.run_command = AsyncMock(return_value=(0, "", ""))
sw = SpecWriter(sm, ui) telemetry.start()
sw = SpecWriter(sm, ui, process_manager=pm)
response = await sw.run() response = await sw.run()
assert response.type == ResponseType.DONE assert response.type == ResponseType.DONE
assert sm.current_state.specification.description != "" assert sm.current_state.specification.description != ""
assert sm.current_state.specification.architecture != ""
assert sm.current_state.specification.system_dependencies != []
assert sm.current_state.specification.package_dependencies != []
assert sm.current_state.specification.complexity == Complexity.SIMPLE assert sm.current_state.specification.complexity == Complexity.SIMPLE
assert sm.current_state.epics != [] assert telemetry.data["initial_prompt"] == sm.current_state.specification.description
assert sm.current_state.tasks != []
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -1,6 +1,8 @@
import os
from typing import Callable from typing import Callable
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio import pytest_asyncio
from core.config import DBConfig from core.config import DBConfig
@@ -9,6 +11,11 @@ from core.db.session import SessionManager
from core.state.state_manager import StateManager from core.state.state_manager import StateManager
@pytest.fixture(autouse=True)
def disable_test_telemetry(monkeypatch):
os.environ["DISABLE_TELEMETRY"] = "1"
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def testmanager(): async def testmanager():
""" """

View File

@@ -98,14 +98,15 @@ def test_inc_ignores_unknown_data_field(mock_settings):
assert "unknown_field" not in telemetry.data assert "unknown_field" not in telemetry.data
@patch("core.telemetry.getenv")
@patch("core.telemetry.time") @patch("core.telemetry.time")
@patch("core.telemetry.settings") @patch("core.telemetry.settings")
def test_start_with_telemetry_enabled(mock_settings, mock_time): def test_start_with_telemetry_enabled(mock_settings, mock_time, mock_getenv):
mock_settings.telemetry = MagicMock(id="test-id", endpoint="test-endpoint", enabled=True) mock_settings.telemetry = MagicMock(id="test-id", endpoint="test-endpoint", enabled=True)
mock_time.time.return_value = 1234.0 mock_time.time.return_value = 1234.0
mock_getenv.return_value = None # override DISABLE_TELEMETRY test env var
telemetry = Telemetry() telemetry = Telemetry()
telemetry.start() telemetry.start()
assert telemetry.start_time == 1234.0 assert telemetry.start_time == 1234.0
@@ -134,9 +135,11 @@ def test_stop_calculates_elapsed_time(mock_settings, mock_time):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("core.telemetry.getenv")
@patch("core.telemetry.settings") @patch("core.telemetry.settings")
async def test_send_enabled_and_successful(mock_settings, mock_httpx_post): async def test_send_enabled_and_successful(mock_settings, mock_getenv, mock_httpx_post):
mock_settings.telemetry = MagicMock(id="test-id", endpoint="test-endpoint", enabled=True) mock_settings.telemetry = MagicMock(id="test-id", endpoint="test-endpoint", enabled=True)
mock_getenv.return_value = None # override DISABLE_TELEMETRY test env var
telemetry = Telemetry() telemetry = Telemetry()
with patch.object(telemetry, "calculate_statistics"): with patch.object(telemetry, "calculate_statistics"):
@@ -151,10 +154,12 @@ async def test_send_enabled_and_successful(mock_settings, mock_httpx_post):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("core.telemetry.getenv")
@patch("core.telemetry.settings") @patch("core.telemetry.settings")
async def test_send_enabled_but_post_fails(mock_settings, mock_httpx_post): async def test_send_enabled_but_post_fails(mock_settings, mock_getenv, mock_httpx_post):
mock_settings.telemetry = MagicMock(id="test-id", endpoint="test-endpoint", enabled=True) mock_settings.telemetry = MagicMock(id="test-id", endpoint="test-endpoint", enabled=True)
mock_httpx_post.side_effect = httpx.RequestError("Connection error") mock_httpx_post.side_effect = httpx.RequestError("Connection error")
mock_getenv.return_value = None # override DISABLE_TELEMETRY test env var
telemetry = Telemetry() telemetry = Telemetry()
with patch.object(telemetry, "calculate_statistics"): with patch.object(telemetry, "calculate_statistics"):
@@ -180,9 +185,11 @@ async def test_send_not_enabled(mock_settings, mock_httpx_post):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("core.telemetry.getenv")
@patch("core.telemetry.settings") @patch("core.telemetry.settings")
async def test_send_no_endpoint_configured(mock_settings, mock_httpx_post): async def test_send_no_endpoint_configured(mock_settings, mock_getenv, mock_httpx_post):
mock_settings.telemetry = MagicMock(id="test-id", endpoint=None, enabled=True) mock_settings.telemetry = MagicMock(id="test-id", endpoint=None, enabled=True)
mock_getenv.return_value = None # override DISABLE_TELEMETRY test env var
telemetry = Telemetry() telemetry = Telemetry()
await telemetry.send() await telemetry.send()
@@ -191,9 +198,11 @@ async def test_send_no_endpoint_configured(mock_settings, mock_httpx_post):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("core.telemetry.getenv")
@patch("core.telemetry.settings") @patch("core.telemetry.settings")
async def test_send_clears_counters_after_sending(mock_settings, mock_httpx_post): async def test_send_clears_counters_after_sending(mock_settings, mock_getenv, mock_httpx_post):
mock_settings.telemetry = MagicMock(id="test-id", endpoint="test-endpoint", enabled=True) mock_settings.telemetry = MagicMock(id="test-id", endpoint="test-endpoint", enabled=True)
mock_getenv.return_value = None # override DISABLE_TELEMETRY test env var
telemetry = Telemetry() telemetry = Telemetry()
telemetry.data["model"] = "test-model" telemetry.data["model"] = "test-model"