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'.
This commit is contained in:
+6
-88
@@ -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
|
||||
|
||||
+95
-28
@@ -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)
|
||||
Reference in New Issue
Block a user