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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,11 @@ def target_environments():
return store.target_environments()


@app.get("/api/personas")
def personas():
return store.personas()


class PromptTemplateUpdate(BaseModel):
name: str = Field(min_length=1, max_length=120)
content: str = Field(min_length=1, max_length=100_000)
Expand Down Expand Up @@ -367,6 +372,20 @@ class TargetEnvironmentUpdate(BaseModel):
managed_prompt_path: str = ""


class PersonaUpdate(BaseModel):
name: str = Field(min_length=1, max_length=120)
locale: str = Field(min_length=2, max_length=32)
timezone: str = Field(min_length=1, max_length=64)
activity_windows: list[dict] = Field(default_factory=list)
goals: list[str] = Field(min_length=1, max_length=30)
constraints: list[str] = Field(default_factory=list, max_length=30)
context: dict = Field(default_factory=dict)


class PersonaCreate(PersonaUpdate):
id: str = Field(pattern=r"^[a-z][a-z0-9-]{2,63}$")


@app.put("/api/prompt-templates/{template_id}")
def update_prompt_template(template_id: str, values: PromptTemplateUpdate):
return safely(lambda: store.update_prompt_template(template_id, values.model_dump()))
Expand All @@ -392,6 +411,11 @@ def create_target_environment(values: TargetEnvironmentCreate):
return safely(lambda: store.create_target_environment(values.model_dump()))


@app.post("/api/personas")
def create_persona(values: PersonaCreate):
return safely(lambda: store.create_persona(values.model_dump()))


@app.put("/api/execution-environments/{environment_id}")
def update_execution_environment(environment_id: str, values: ExecutionEnvironmentUpdate):
return safely(lambda: store.update_execution_environment(environment_id, values.model_dump()))
Expand All @@ -402,6 +426,11 @@ def update_target_environment(environment_id: str, values: TargetEnvironmentUpda
return safely(lambda: store.update_target_environment(environment_id, values.model_dump()))


@app.put("/api/personas/{persona_id}")
def update_persona(persona_id: str, values: PersonaUpdate):
return safely(lambda: store.update_persona(persona_id, values.model_dump()))


@app.delete("/api/execution-environments/{environment_id}")
def delete_execution_environment(environment_id: str):
return safely(lambda: store.delete_execution_environment(environment_id))
Expand All @@ -412,6 +441,11 @@ def delete_target_environment(environment_id: str):
return safely(lambda: store.delete_target_environment(environment_id))


@app.delete("/api/personas/{persona_id}")
def delete_persona(persona_id: str):
return safely(lambda: store.delete_persona(persona_id))


@app.delete("/api/prompt-templates/{template_id}")
def delete_prompt_template(template_id: str):
return safely(lambda: store.delete_prompt_template(template_id))
Expand Down
96 changes: 95 additions & 1 deletion backend/app/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def _application_data_dir() -> Path:
TARGET_TEST_CASE_SETS = CONFIG / "target-ai-test-case-sets.yaml"
EXECUTION_ENVIRONMENTS = CONFIG / "execution-environments.yaml"
TARGET_ENVIRONMENTS = CONFIG / "target-environments.yaml"
PERSONAS = CONFIG / "personas.yaml"
CYCLE_INTERVENTIONS = CONFIG / "cycle-interventions.yaml"
ISSUE_MANAGEMENT = CONFIG / "issue-management.yaml"
ASSISTANT_MCP_CONFIG = CONFIG / "assistant-mcp.json"
Expand Down Expand Up @@ -149,12 +150,13 @@ def configure_application_data(path: str) -> Path:

global APP_DATA, CONFIG, TARGET_TEST_CASE_SETS, EXECUTION_ENVIRONMENTS, TARGET_ENVIRONMENTS
global CYCLE_INTERVENTIONS, DATA, RUNS, TELEMETRY, SETTINGS, TOOL_TIMES, RUNNERS
global RUNNER_TEMPLATES, QUICK_STARTS, QUICK_START_INSTANCES, TEMPLATE_TRANSLATIONS
global RUNNER_TEMPLATES, QUICK_STARTS, QUICK_START_INSTANCES, TEMPLATE_TRANSLATIONS, PERSONAS
APP_DATA = target
CONFIG = APP_DATA / "config"
TARGET_TEST_CASE_SETS = CONFIG / "target-ai-test-case-sets.yaml"
EXECUTION_ENVIRONMENTS = CONFIG / "execution-environments.yaml"
TARGET_ENVIRONMENTS = CONFIG / "target-environments.yaml"
PERSONAS = CONFIG / "personas.yaml"
CYCLE_INTERVENTIONS = CONFIG / "cycle-interventions.yaml"
DATA = APP_DATA / "data"
RUNS = DATA / "runs"
Expand Down Expand Up @@ -1715,6 +1717,80 @@ def target_test_case_sets(self) -> list[dict[str, Any]]:
else []
)

def personas(self) -> list[dict[str, Any]]:
return yaml.safe_load(PERSONAS.read_text(encoding="utf-8")) if PERSONAS.exists() else []

@staticmethod
def _validated_persona(values: dict[str, Any], persona_id: str) -> dict[str, Any]:
name = str(values.get("name", "")).strip()
locale, timezone = str(values.get("locale", "")).strip(), str(values.get("timezone", "")).strip()
goals, windows, context = (
values.get("goals", []),
values.get("activity_windows", []),
values.get("context", {}),
)
if not name or not locale or not timezone or not isinstance(goals, list) or not goals:
raise ValueError("persona requires a name, locale, timezone, and at least one goal")
if not all(isinstance(goal, str) and goal.strip() for goal in goals):
raise ValueError("persona goals must be non-empty strings")
if not isinstance(windows, list) or not all(isinstance(window, dict) for window in windows):
raise ValueError("persona activity windows must be a list of objects")
if not isinstance(context, dict):
raise ValueError("persona context must be an object")
return {
"id": persona_id,
"name": name,
"locale": locale,
"timezone": timezone,
"activity_windows": windows,
"goals": [goal.strip() for goal in goals],
"constraints": [str(item).strip() for item in values.get("constraints", []) if str(item).strip()],
"context": context,
}

def create_persona(self, values: dict[str, Any]) -> dict[str, Any]:
persona_id = str(values.get("id", "")).strip()
personas = self.personas()
if not persona_id or any(item.get("id") == persona_id for item in personas):
raise ValueError("persona ID is required and must be unique")
persona = self._validated_persona(values, persona_id)
persona["created_at"] = now().isoformat()
personas.append(persona)
temporary = PERSONAS.with_suffix(".tmp")
temporary.write_text(yaml.safe_dump(personas, allow_unicode=True, sort_keys=False), encoding="utf-8")
temporary.replace(PERSONAS)
return persona

def update_persona(self, persona_id: str, values: dict[str, Any]) -> dict[str, Any]:
personas = self.personas()
index = next((i for i, item in enumerate(personas) if item.get("id") == persona_id), None)
if index is None:
raise KeyError(persona_id)
persona = self._validated_persona(values, persona_id)
persona["created_at"] = personas[index].get("created_at", now().isoformat())
personas[index] = persona
temporary = PERSONAS.with_suffix(".tmp")
temporary.write_text(yaml.safe_dump(personas, allow_unicode=True, sort_keys=False), encoding="utf-8")
temporary.replace(PERSONAS)
return persona

def delete_persona(self, persona_id: str) -> None:
personas = self.personas()
if not any(item.get("id") == persona_id for item in personas):
raise KeyError(persona_id)
if any(persona_id in build.get("persona_ids", []) for build in self.builds()):
raise ValueError("persona is used by a build")
temporary = PERSONAS.with_suffix(".tmp")
temporary.write_text(
yaml.safe_dump(
[item for item in personas if item.get("id") != persona_id],
allow_unicode=True,
sort_keys=False,
),
encoding="utf-8",
)
temporary.replace(PERSONAS)

@staticmethod
def _validated_target_test_case_set(values: dict[str, Any], set_id: str) -> dict[str, Any]:
name, description = str(values.get("name", "")).strip(), str(values.get("description", "")).strip()
Expand Down Expand Up @@ -2031,6 +2107,12 @@ def create_build(self, values: dict[str, Any]) -> dict[str, Any]:
raise ValueError("AI model profile does not exist")
if not any(item.get("id") == values.get("test_case_set_id") for item in self.target_test_case_sets()):
raise ValueError("target-AI test case set does not exist")
persona_ids = [str(item) for item in values.get("persona_ids", [])]
if len(set(persona_ids)) != len(persona_ids) or any(
not any(persona.get("id") == persona_id for persona in self.personas())
for persona_id in persona_ids
):
raise ValueError("persona selection contains an unknown or duplicate persona")
build = {
"id": build_id,
"name": values["name"],
Expand All @@ -2051,6 +2133,7 @@ def create_build(self, values: dict[str, Any]) -> dict[str, Any]:
"model_profile_name": values.get("model_profile_name", "Default"),
"task_instruction": values.get("task_instruction", ""),
"test_case_set_id": values["test_case_set_id"],
"persona_ids": persona_ids,
"browser_base_url": str(target_environment.get("browser_base_url", "")).strip(),
"browser_executable_path": str(execution_environment.get("browser_executable_path", "")).strip(),
"browser_library_path": str(execution_environment.get("browser_library_path", "")).strip(),
Expand Down Expand Up @@ -2117,6 +2200,12 @@ def update_build(self, build_id: str, values: dict[str, Any]) -> dict[str, Any]:
raise ValueError("AI model profile does not exist")
if not any(item.get("id") == values.get("test_case_set_id") for item in self.target_test_case_sets()):
raise ValueError("target-AI test case set does not exist")
persona_ids = [str(item) for item in values.get("persona_ids", [])]
if len(set(persona_ids)) != len(persona_ids) or any(
not any(persona.get("id") == persona_id for persona in self.personas())
for persona_id in persona_ids
):
raise ValueError("persona selection contains an unknown or duplicate persona")
existing = builds[index]
build = {
"id": build_id,
Expand All @@ -2138,6 +2227,7 @@ def update_build(self, build_id: str, values: dict[str, Any]) -> dict[str, Any]:
"model_profile_name": values.get("model_profile_name", "Default"),
"task_instruction": existing.get("task_instruction", ""),
"test_case_set_id": values["test_case_set_id"],
"persona_ids": persona_ids,
"browser_base_url": str(target_environment.get("browser_base_url", "")).strip(),
"browser_executable_path": str(execution_environment.get("browser_executable_path", "")).strip(),
"browser_library_path": str(execution_environment.get("browser_library_path", "")).strip(),
Expand Down Expand Up @@ -3623,6 +3713,10 @@ def _execute(self, run_id: str) -> None:
None,
)
resources["test_cases"] = selected.get("cases", []) if selected else build.get("test_cases", [])
selected_personas = set(build.get("persona_ids", []))
resources["personas"] = [
persona for persona in self.personas() if persona.get("id") in selected_personas
]
profile_name = str(build.get("model_profile_name", ""))
resources["model_profile"] = next(
(profile for profile in self.profiles() if profile.get("profile_name") == profile_name),
Expand Down
19 changes: 19 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1939,6 +1939,25 @@ def test_target_test_case_sets_are_managed_as_assets(tmp_path, monkeypatch):
assert updated["name"] == "Updated target tests"


def test_personas_are_managed_as_reusable_assets(tmp_path, monkeypatch):
monkeypatch.setattr(store_module, "PERSONAS", tmp_path / "personas.yaml")
store = store_module.ConsoleStore()
values = {
"id": "careful-investor",
"name": "Careful investor",
"locale": "en-US",
"timezone": "America/New_York",
"activity_windows": [{"days": ["mon"], "start": "08:00", "end": "18:00"}],
"goals": ["Understand the portfolio safely."],
"constraints": ["Never place a real order."],
"context": {"plan": "free"},
}
created = store.create_persona(values)
assert created["context"] == {"plan": "free"}
updated = store.update_persona("careful-investor", {**values, "name": "Cautious investor"})
assert updated["name"] == "Cautious investor"


def test_proposal_history_is_derived_from_evaluation_run_results(tmp_path, monkeypatch):
monkeypatch.setattr(store_module, "RUNS", tmp_path / "runs")
store = store_module.ConsoleStore()
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/domain/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ export type TestCase = { id:string; name:string; prompt:string; acceptance:strin
export type TargetTestCaseSet = { id:string; name:string; description:string; cases:TestCase[]; created_at?:string }
export type ExecutionEnvironment = {id:string;name:string;executor:{type:'local'|'remote-http';endpoint?:string;method?:'GET'|'POST'|'PUT';timeout_seconds?:number;headers?:Record<string,string>};browser_executable_path?:string;browser_library_path?:string;environment_variables?:Record<string,string>;created_at?:string}
export type TargetEnvironment = {id:string;name:string;repository:string;browser_base_url?:string;managed_prompt_path?:string;created_at?:string}
export type Build = { id:string; name:string; enabled:boolean; starred?:boolean; runner_id:string;runner_version?:number|null; execution_environment_id?:string;target_environment_id?:string; repository:string; repository_name?:string; repository_is_git?:boolean; repository_error?:string; purpose:string;manager_template_id?:string;model_profile_name?:string;test_case_set_id?:string;test_cases?:TestCase[];browser_base_url?:string;browser_executable_path?:string;browser_library_path?:string;timezone:string;repeat_interval_minutes:number;run_limit:number;cadence_mode?:'after_completion'|'fixed';overrun_policy?:'wait'|'interrupt_eval';schedule_enabled?:boolean;schedule_weekdays?:number[];schedule_start_time?:string;schedule_end_time?:string;iteration_strategy?:'linear'|'score_select';candidates_per_iteration?:number;approval_score:number;require_human_approval_before_apply?:boolean;created_at?:string;last_run_at?:string;executor?:{type?:'local'|'remote-http';endpoint?:string;method?:'GET'|'POST'|'PUT';timeout_seconds?:number;headers?:Record<string,string>} }
export type Persona = {id:string;name:string;locale:string;timezone:string;activity_windows:{days?:string[];start?:string;end?:string}[];goals:string[];constraints:string[];context:Record<string,unknown>;created_at?:string}
export type Build = { id:string; name:string; enabled:boolean; starred?:boolean; runner_id:string;runner_version?:number|null;persona_ids?:string[]; execution_environment_id?:string;target_environment_id?:string; repository:string; repository_name?:string; repository_is_git?:boolean; repository_error?:string; purpose:string;manager_template_id?:string;model_profile_name?:string;test_case_set_id?:string;test_cases?:TestCase[];browser_base_url?:string;browser_executable_path?:string;browser_library_path?:string;timezone:string;repeat_interval_minutes:number;run_limit:number;cadence_mode?:'after_completion'|'fixed';overrun_policy?:'wait'|'interrupt_eval';schedule_enabled?:boolean;schedule_weekdays?:number[];schedule_start_time?:string;schedule_end_time?:string;iteration_strategy?:'linear'|'score_select';candidates_per_iteration?:number;approval_score:number;require_human_approval_before_apply?:boolean;created_at?:string;last_run_at?:string;executor?:{type?:'local'|'remote-http';endpoint?:string;method?:'GET'|'POST'|'PUT';timeout_seconds?:number;headers?:Record<string,string>} }
export type RunnerState = { name:string; scope:'build'|'runner'; runner_id?:string; updated_at?:string; run_id?:string; iteration?:number; value:unknown }
export type PromptTemplate = { id:string; name:string; version:number; content:string; versions?:{version:number;content:string}[];created_at?:string }
export type SavedDataFile = { label?:string; filename:string; path:string; relative_path?:string; sha256?:string; size?:number; content_type?:string }
Expand Down
Loading
Loading