diff --git a/backend/app/main.py b/backend/app/main.py index d186e1b..2c9ab8b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) @@ -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())) @@ -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())) @@ -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)) @@ -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)) diff --git a/backend/app/store.py b/backend/app/store.py index 359a576..670e37d 100644 --- a/backend/app/store.py +++ b/backend/app/store.py @@ -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" @@ -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" @@ -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() @@ -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"], @@ -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(), @@ -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, @@ -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(), @@ -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), diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index bb490d4..fc141c6 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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() diff --git a/frontend/src/domain/models.ts b/frontend/src/domain/models.ts index b982b6a..5bd0c50 100644 --- a/frontend/src/domain/models.ts +++ b/frontend/src/domain/models.ts @@ -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};browser_executable_path?:string;browser_library_path?:string;environment_variables?:Record;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} } +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;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} } 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 } diff --git a/frontend/src/features/assets/page.tsx b/frontend/src/features/assets/page.tsx index ec58d16..229ad41 100644 --- a/frontend/src/features/assets/page.tsx +++ b/frontend/src/features/assets/page.tsx @@ -15,6 +15,7 @@ import type { Build, ExecutionEnvironment, PromptTemplate, + Persona, RunnerAsset, RunnerTemplate, Settings, @@ -1916,6 +1917,20 @@ function EnvironmentCatalog({ ); } +function PersonaCatalog({ builds, onRefresh }: { builds: Build[]; onRefresh: () => Promise }) { + const [items, setItems] = useState([]), [draft, setDraft] = useState(null), [error, setError] = useState(""); + const load = () => api("/api/personas").then(setItems); + useEffect(() => { void load(); }, []); + const save = () => { + if (!draft) return; + const exists = items.some((item) => item.id === draft.id); + api(exists ? `/api/personas/${draft.id}` : "/api/personas", exists ? "PUT" : "POST", draft) + .then(() => { setDraft(null); setError(""); return Promise.all([load(), onRefresh()]); }) + .catch((value) => setError(value.message)); + }; + return

Reusable user perspectives, goals, constraints, and product-specific context. Runtime identity and memory are never stored here.

{items.map((item) => build.persona_ids?.includes(item.id)).length} onClick={() => setDraft(item)} onDelete={() => api(`/api/personas/${item.id}`, "DELETE").then(() => Promise.all([load(), onRefresh()]).then(() => undefined)).catch((value) => setError(value.message))} />)}{draft && setDraft(null)}>