From d7872bea5353634c857869e96689ecca8d221dec Mon Sep 17 00:00:00 2001 From: Ed_ Date: Fri, 26 Jun 2026 07:22:18 -0400 Subject: [PATCH] refactor(personas): move Persona dataclass from models.py to personas.py Per spec FR4 + Phase 3.4: Persona dataclass + properties (provider/model/ temperature/top_p/max_output_tokens) + to_dict/from_dict move from src/models.py into src/personas.py (which already has the PersonaManager ops layer). Re-export at top of models.py preserves 'from src.models import Persona'. --- src/models.py | 94 +++--------------------------------- src/personas.py | 123 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 101 insertions(+), 116 deletions(-) diff --git a/src/models.py b/src/models.py index 45f98e6e..60e99db7 100644 --- a/src/models.py +++ b/src/models.py @@ -76,6 +76,8 @@ from src.mma import ( TrackState, WorkerContext, ) +# Backward-compat re-export for Persona (Phase 3.4 -> src/personas.py). +from src.personas import Persona # Alias the old `Metadata` dataclass name to TrackMetadata so existing # `from src.models import Metadata` keeps resolving to the dataclass. Metadata = TrackMetadata # noqa: F401 — legacy class name re-export @@ -530,96 +532,12 @@ class ExternalEditorConfig: EMPTY_TEXT_EDITOR_CONFIG: TextEditorConfig = TextEditorConfig() - #region: Persona +# Persona dataclass moved to src/personas.py in module_taxonomy_refactor_20260627 Phase 3.4. +# PersonaManager (the ops layer) is also there. Re-export at the top of this module +# preserves backward-compat 'from src.models import Persona'. -@dataclass -class Persona: - name: str - preferred_models: list[Metadata] = field(default_factory=list) - system_prompt: str = '' - tool_preset: Optional[str] = None - bias_profile: Optional[str] = None - context_preset: Optional[str] = None - aggregation_strategy: Optional[str] = None - - @property - def provider(self) -> str: - if not self.preferred_models: return "" - return self.preferred_models[0].get("provider") or "" - - @property - def model(self) -> str: - if not self.preferred_models: return "" - return self.preferred_models[0].get("model") or "" - - @property - def temperature(self) -> float: - if not self.preferred_models: return 0.0 - return float(self.preferred_models[0].get("temperature") or 0.0) - - @property - def top_p(self) -> float: - if not self.preferred_models: return 1.0 - return float(self.preferred_models[0].get("top_p") or 1.0) - - @property - def max_output_tokens(self) -> int: - if not self.preferred_models: return 0 - return int(self.preferred_models[0].get("max_output_tokens") or 0) - - def to_dict(self) -> Metadata: - """ - [C: src/personas.py:PersonaManager.save_persona, src/presets.py:PresetManager.save_preset, src/project_manager.py:save_project, src/project_manager.py:save_track_state, src/tool_presets.py:ToolPresetManager.save_bias_profile, src/tool_presets.py:ToolPresetManager.save_preset, src/workspace_manager.py:WorkspaceManager.save_profile, tests/test_bias_models.py:test_bias_profile_model, tests/test_bias_models.py:test_tool_model, tests/test_bias_models.py:test_tool_preset_extension, tests/test_context_presets_models.py:test_context_preset_serialization, tests/test_context_presets_models.py:test_file_view_preset_serialization, tests/test_custom_slices_annotations.py:test_file_item_custom_slices_round_trip_annotations, tests/test_custom_slices_annotations.py:test_file_item_custom_slices_serialization_with_annotations, tests/test_event_serialization.py:test_user_request_event_serialization, tests/test_external_editor.py:TestExternalEditorConfig.test_to_dict, tests/test_external_editor.py:TestTextEditorConfig.test_to_dict, tests/test_file_item_model.py:test_file_item_to_dict, tests/test_gui_events_v2.py:test_user_request_event_payload, tests/test_history_manager.py:TestHistoryManager.test_snapshot_roundtrip, tests/test_mcp_config.py:test_mcp_configuration_to_from_dict, tests/test_mcp_config.py:test_mcp_server_config_to_from_dict, tests/test_per_ticket_model.py:test_model_override_serialization, tests/test_persona_id.py:test_ticket_persona_id_serialization, tests/test_persona_models.py:test_persona_defaults, tests/test_persona_models.py:test_persona_serialization, tests/test_slice_editor_behavior.py:test_add_slice_with_annotations, tests/test_thinking_gui.py:test_thinking_segment_model_compatibility, tests/test_ticket_queue.py:test_ticket_to_dict_priority, tests/test_tiered_aggregation.py:test_persona_aggregation_strategy, tests/test_track_state_schema.py:test_track_state_to_dict, tests/test_track_state_schema.py:test_track_state_to_dict_with_none, tests/test_ui_summary_only_removal.py:test_file_item_serialization_with_flags] - """ - res = {"system_prompt": self.system_prompt} - if self.preferred_models: - processed = [] - for m in self.preferred_models: - if isinstance(m, str): - processed.append({"model": m}) - else: - processed.append(m) - res["preferred_models"] = processed - if self.tool_preset is not None: res["tool_preset"] = self.tool_preset - if self.bias_profile is not None: res["bias_profile"] = self.bias_profile - if self.context_preset is not None: res["context_preset"] = self.context_preset - if self.aggregation_strategy is not None: res["aggregation_strategy"] = self.aggregation_strategy - return res - - @classmethod - def from_dict(cls, name: str, data: Metadata) -> "Persona": - """ - [C: src/personas.py:PersonaManager.load_all, src/presets.py:PresetManager.load_all, src/project_manager.py:load_project, src/project_manager.py:load_track_state, src/tool_presets.py:ToolPresetManager.load_all_bias_profiles, src/tool_presets.py:ToolPresetManager.load_all_presets, src/workspace_manager.py:WorkspaceManager.load_all_profiles, tests/test_bias_models.py:test_bias_profile_model, tests/test_bias_models.py:test_tool_model, tests/test_bias_models.py:test_tool_preset_extension, tests/test_context_presets_models.py:test_context_preset_from_dict_legacy, tests/test_context_presets_models.py:test_context_preset_serialization, tests/test_context_presets_models.py:test_file_view_preset_serialization, tests/test_custom_slices_annotations.py:test_file_item_custom_slices_deserialization_with_annotations, tests/test_custom_slices_annotations.py:test_file_item_custom_slices_round_trip_annotations, tests/test_external_editor.py:TestExternalEditorConfig.test_from_dict_with_dict_editors, tests/test_external_editor.py:TestExternalEditorConfig.test_from_dict_with_string_editors, tests/test_external_editor.py:TestTextEditorConfig.test_from_dict_with_diff_args, tests/test_external_editor.py:TestTextEditorConfig.test_from_dict_without_diff_args, tests/test_file_item_model.py:test_file_item_from_dict, tests/test_file_item_model.py:test_file_item_from_dict_defaults, tests/test_history_manager.py:TestHistoryManager.test_snapshot_roundtrip, tests/test_mcp_config.py:test_mcp_configuration_to_from_dict, tests/test_mcp_config.py:test_mcp_server_config_to_from_dict, tests/test_per_ticket_model.py:test_model_override_default_on_deserialize, tests/test_per_ticket_model.py:test_model_override_deserialization, tests/test_persona_id.py:test_ticket_persona_id_deserialization, tests/test_persona_models.py:test_persona_defaults, tests/test_persona_models.py:test_persona_deserialization, tests/test_project_serialization.py:TestProjectSerialization.test_backward_compatibility_strings, tests/test_slice_editor_behavior.py:test_add_slice_with_annotations, tests/test_ticket_queue.py:test_ticket_from_dict_default_priority, tests/test_ticket_queue.py:test_ticket_from_dict_priority, tests/test_tiered_aggregation.py:test_persona_aggregation_strategy, tests/test_track_state_schema.py:test_track_state_from_dict, tests/test_track_state_schema.py:test_track_state_from_dict_empty_and_missing, tests/test_ui_summary_only_removal.py:test_file_item_serialization_with_flags] - """ - raw_models = data.get("preferred_models", []) - parsed_models = [] - for m in raw_models: - if isinstance(m, str): - parsed_models.append({"model": m}) - else: - parsed_models.append(m) - legacy = {} - for k in ["provider", "model", "temperature", "top_p", "max_output_tokens"]: - if data.get(k) is not None: - legacy[k] = data[k] - if legacy: - if not parsed_models: - parsed_models.append(legacy) - else: - for k, v in legacy.items(): - if k not in parsed_models[0] or parsed_models[0][k] is None: - parsed_models[0][k] = v - return cls( - name = name, - preferred_models = parsed_models, - system_prompt = data.get("system_prompt", ""), - tool_preset = data.get("tool_preset"), - bias_profile = data.get("bias_profile"), - context_preset = data.get("context_preset"), - aggregation_strategy = data.get("aggregation_strategy"), - ) - +#region: Workspace #region: Workspace @dataclass diff --git a/src/personas.py b/src/personas.py index 574c8d40..2b7335d8 100644 --- a/src/personas.py +++ b/src/personas.py @@ -1,11 +1,103 @@ +"""Personas module: Persona dataclass + PersonaManager CRUD. + +Per module_taxonomy_refactor_20260627 Phase 3.4, the Persona dataclass +moved from src/models.py into this module. PersonaManager (the ops layer +that loads/saves Persona instances to TOML) was already here. +""" +from __future__ import annotations + import tomllib import tomli_w -from pathlib import Path -from typing import Dict, Any, Optional +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Any, Optional -from src.models import Persona from src import paths +from src.type_aliases import Metadata + + +@dataclass +class Persona: + name: str + preferred_models: list[Metadata] = field(default_factory=list) + system_prompt: str = '' + tool_preset: Optional[str] = None + bias_profile: Optional[str] = None + context_preset: Optional[str] = None + aggregation_strategy: Optional[str] = None + + @property + def provider(self) -> str: + if not self.preferred_models: return "" + return self.preferred_models[0].get("provider") or "" + + @property + def model(self) -> str: + if not self.preferred_models: return "" + return self.preferred_models[0].get("model") or "" + + @property + def temperature(self) -> float: + if not self.preferred_models: return 0.0 + return float(self.preferred_models[0].get("temperature") or 0.0) + + @property + def top_p(self) -> float: + if not self.preferred_models: return 1.0 + return float(self.preferred_models[0].get("top_p") or 1.0) + + @property + def max_output_tokens(self) -> int: + if not self.preferred_models: return 0 + return int(self.preferred_models[0].get("max_output_tokens") or 0) + + def to_dict(self) -> Metadata: + res = {"system_prompt": self.system_prompt} + if self.preferred_models: + processed = [] + for m in self.preferred_models: + if isinstance(m, str): + processed.append({"model": m}) + else: + processed.append(m) + res["preferred_models"] = processed + if self.tool_preset is not None: res["tool_preset"] = self.tool_preset + if self.bias_profile is not None: res["bias_profile"] = self.bias_profile + if self.context_preset is not None: res["context_preset"] = self.context_preset + if self.aggregation_strategy is not None: res["aggregation_strategy"] = self.aggregation_strategy + return res + + @classmethod + def from_dict(cls, name: str, data: Metadata) -> "Persona": + raw_models = data.get("preferred_models", []) + parsed_models = [] + for m in raw_models: + if isinstance(m, str): + parsed_models.append({"model": m}) + else: + parsed_models.append(m) + legacy = {} + for k in ["provider", "model", "temperature", "top_p", "max_output_tokens"]: + if data.get(k) is not None: + legacy[k] = data[k] + if legacy: + if not parsed_models: + parsed_models.append(legacy) + else: + for k, v in legacy.items(): + if k not in parsed_models[0] or parsed_models[0][k] is None: + parsed_models[0][k] = v + return cls( + name = name, + preferred_models = parsed_models, + system_prompt = data.get("system_prompt", ""), + tool_preset = data.get("tool_preset"), + bias_profile = data.get("bias_profile"), + context_preset = data.get("context_preset"), + aggregation_strategy = data.get("aggregation_strategy"), + ) + class PersonaManager: """Manages Persona profiles across global and project-specific files.""" @@ -14,9 +106,6 @@ class PersonaManager: self.project_root = project_root def _get_path(self, scope: str) -> Path: - """ - [C: src/tool_presets.py:ToolPresetManager.delete_bias_profile, src/tool_presets.py:ToolPresetManager.delete_preset, src/tool_presets.py:ToolPresetManager.save_bias_profile, src/tool_presets.py:ToolPresetManager.save_preset, src/workspace_manager.py:WorkspaceManager.delete_profile, src/workspace_manager.py:WorkspaceManager.save_profile] - """ if scope == "global": return paths.get_global_personas_path() elif scope == "project": @@ -27,34 +116,23 @@ class PersonaManager: raise ValueError("Invalid scope, must be 'global' or 'project'") def load_all(self) -> Dict[str, Persona]: - """ - Merges global and project personas into a single dictionary. - [C: tests/test_persona_manager.py:test_delete_persona, tests/test_persona_manager.py:test_load_all_merged, tests/test_persona_manager.py:test_save_persona, tests/test_preset_manager.py:test_delete_preset, tests/test_preset_manager.py:test_load_all_merged, tests/test_preset_manager.py:test_save_preset_global, tests/test_preset_manager.py:test_save_preset_project, tests/test_presets.py:TestPresetManager.test_delete_preset, tests/test_presets.py:TestPresetManager.test_project_overwrites_global, tests/test_presets.py:TestPresetManager.test_save_and_load_global, tests/test_presets.py:TestPresetManager.test_save_and_load_project] - """ personas = {} - global_path = paths.get_global_personas_path() global_data = self._load_file(global_path) for name, data in global_data.get("personas", {}).items(): personas[name] = Persona.from_dict(name, data) - if self.project_root: project_path = paths.get_project_personas_path(self.project_root) project_data = self._load_file(project_path) for name, data in project_data.get("personas", {}).items(): personas[name] = Persona.from_dict(name, data) - return personas def save_persona(self, persona: Persona, scope: str = "project") -> None: - """ - [C: tests/test_persona_manager.py:test_save_persona] - """ path = self._get_path(scope) data = self._load_file(path) if "personas" not in data: data["personas"] = {} - data["personas"][persona.name] = persona.to_dict() self._save_file(path, data) @@ -65,18 +143,13 @@ class PersonaManager: project_data = self._load_file(project_path) if name in project_data.get("personas", {}): return "project" - global_path = paths.get_global_personas_path() global_data = self._load_file(global_path) if name in global_data.get("personas", {}): return "global" - return "project" def delete_persona(self, name: str, scope: str = "project") -> None: - """ - [C: tests/test_persona_manager.py:test_delete_persona] - """ path = self._get_path(scope) data = self._load_file(path) if "personas" in data and name in data["personas"]: @@ -84,9 +157,6 @@ class PersonaManager: self._save_file(path, data) def _load_file(self, path: Path) -> Dict[str, Any]: - """ - [C: src/presets.py:PresetManager.delete_preset, src/presets.py:PresetManager.get_preset_scope, src/presets.py:PresetManager.load_all, src/presets.py:PresetManager.save_preset, src/workspace_manager.py:WorkspaceManager.delete_profile, src/workspace_manager.py:WorkspaceManager.load_all_profiles, src/workspace_manager.py:WorkspaceManager.save_profile] - """ if not path.exists(): return {} try: @@ -96,9 +166,6 @@ class PersonaManager: return {} def _save_file(self, path: Path, data: Dict[str, Any]) -> None: - """ - [C: src/presets.py:PresetManager.delete_preset, src/presets.py:PresetManager.save_preset, src/workspace_manager.py:WorkspaceManager.delete_profile, src/workspace_manager.py:WorkspaceManager.save_profile] - """ path.parent.mkdir(parents=True, exist_ok=True) with open(path, "wb") as f: tomli_w.dump(data, f) \ No newline at end of file