openher / persona /loader.py
kellyxiaowei's picture
Deploy OpenHer Gradio Space — gemma-4-E4B served on Modal
dff25f7 verified
Raw
History Blame Contribute Delete
9.33 kB
"""
PersonaLoader — Parse SOUL.md files and manage character definitions.
Each persona is a directory containing a SOUL.md with YAML frontmatter
(name, age, mbti, tags, voice config, image config) and markdown body
(personality description, speaking style, background story, behavioral rules).
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import frontmatter
import yaml
@dataclass
class VoiceConfig:
"""Voice description for a persona (provider-agnostic).
Provider-specific settings (voice_preset, model) are in api.yaml → tts.voice_map.
"""
description: Optional[str] = None # Natural language voice description
@dataclass
class Persona:
"""Loaded persona with all configuration and content."""
# Identity
name: str
persona_id: str # Directory name, used as unique ID
name_zh: Optional[str] = None # Chinese display name (for Chinese personas)
age: Optional[int] = None
gender: str = "female"
lang: str = "zh" # Prompt label language: 'zh' or 'en'
mbti: Optional[str] = None
tags: list[str] = field(default_factory=list)
tags_zh: list[str] = field(default_factory=list)
# Configs
voice: VoiceConfig = field(default_factory=VoiceConfig)
# Display layer
bio: dict = field(default_factory=dict) # {"en": ..., "zh": ...}
# Content sections (from markdown body, legacy)
personality: str = "" # 性格描述
speaking_style: str = "" # 说话风格
background: str = "" # 背景故事
behavioral_rules: str = "" # 行为规则
raw_content: str = "" # Full markdown body (fallback)
# Engine seed
drive_baseline: dict = field(default_factory=dict) # genome_seed.drive_baseline
engine_params: dict = field(default_factory=dict) # genome_seed.engine_params (per-persona tuning)
signal_overrides: dict = field(default_factory=dict) # genome_seed.signal_buckets (per-persona desc overrides)
# Source
base_dir: str = "" # Absolute path to persona directory
def build_system_prompt_section(self) -> str:
"""Build the persona section for system prompt injection."""
parts = [f"# 你的身份:{self.name}"]
if self.age:
parts.append(f"- 年龄:{self.age}岁")
if self.gender:
parts.append(f"- 性别:{self.gender}")
if self.mbti:
parts.append(f"- MBTI:{self.mbti}")
_display_tags = self.tags_zh if self.tags_zh else self.tags
if _display_tags:
parts.append(f"- 特点:{'、'.join(_display_tags)}")
if self.personality:
parts.append(f"\n## 性格\n{self.personality}")
if self.speaking_style:
parts.append(f"\n## 说话风格\n{self.speaking_style}")
if self.background:
parts.append(f"\n## 背景故事\n{self.background}")
if self.behavioral_rules:
parts.append(f"\n## 行为规则\n{self.behavioral_rules}")
# If no structured sections, use raw content
if not any([self.personality, self.speaking_style, self.background]):
if self.raw_content:
parts.append(f"\n{self.raw_content}")
return "\n".join(parts)
class PersonaLoader:
"""Load and manage persona definitions from SOUL.md files."""
PERSONA_FILENAME = "SOUL.md"
# Known H2 sections in SOUL.md body
SECTION_MAPPING = {
"性格": "personality",
"personality": "personality",
"说话风格": "speaking_style",
"speaking style": "speaking_style",
"背景故事": "background",
"background": "background",
"背景": "background",
"行为规则": "behavioral_rules",
"behavioral rules": "behavioral_rules",
"rules": "behavioral_rules",
}
def __init__(self, personas_dir: str):
"""
Args:
personas_dir: Root directory containing persona subdirectories.
Each subdirectory should contain a SOUL.md.
"""
self.personas_dir = Path(personas_dir)
self._cache: dict[str, Persona] = {}
def load_all(self) -> dict[str, Persona]:
"""Load all personas from the personas directory."""
self._cache.clear()
if not self.personas_dir.exists():
return {}
for entry in sorted(self.personas_dir.iterdir()):
if entry.is_dir():
persona_file = entry / self.PERSONA_FILENAME
if persona_file.exists():
try:
persona = self._load_one(entry)
self._cache[persona.persona_id] = persona
except Exception as e:
print(f"[persona] Failed to load {entry.name}: {e}")
return self._cache
def get(self, persona_id: str) -> Optional[Persona]:
"""Get a loaded persona by ID."""
if not self._cache:
self.load_all()
return self._cache.get(persona_id)
def list_ids(self) -> list[str]:
"""List all available persona IDs."""
if not self._cache:
self.load_all()
return list(self._cache.keys())
def reload(self, persona_id: str) -> Optional[Persona]:
"""Reload a specific persona from disk."""
persona_dir = self.personas_dir / persona_id
if not (persona_dir / self.PERSONA_FILENAME).exists():
return None
persona = self._load_one(persona_dir)
self._cache[persona.persona_id] = persona
return persona
def _load_one(self, persona_dir: Path) -> Persona:
"""Load a single persona from its directory."""
persona_file = persona_dir / self.PERSONA_FILENAME
post = frontmatter.load(str(persona_file))
# Parse frontmatter
meta = post.metadata
persona_id = persona_dir.name
# Voice + Image config: read from SHELL.md (external modality config)
# Falls back to SOUL.md frontmatter for backward compatibility
shell_file = persona_dir / "SHELL.md"
if shell_file.exists():
shell_post = frontmatter.load(str(shell_file))
shell_meta = shell_post.metadata
else:
shell_meta = meta # fallback: read from SOUL.md
# Voice config (provider-agnostic, only description)
voice_meta = shell_meta.get("voice", {})
if isinstance(voice_meta, str):
voice_meta = {"description": voice_meta}
voice = VoiceConfig(
description=voice_meta.get("description"),
)
# Parse body sections
sections = self._parse_sections(post.content)
# Genome seed (engine layer)
genome_seed = meta.get("genome_seed", {})
drive_baseline = genome_seed.get("drive_baseline", {}) if isinstance(genome_seed, dict) else {}
# Bio (display layer, may be dict or string)
bio_raw = meta.get("bio", {})
bio = bio_raw if isinstance(bio_raw, dict) else {"en": str(bio_raw)}
persona = Persona(
name=meta.get("name", persona_id),
persona_id=persona_id,
name_zh=meta.get("name_zh"),
age=meta.get("age"),
gender=meta.get("gender", "female"),
lang=meta.get("lang", "zh"),
mbti=meta.get("mbti"),
tags=meta.get("tags", {}).get("en", []) if isinstance(meta.get("tags"), dict) else meta.get("tags", []),
tags_zh=meta.get("tags", {}).get("zh", []) if isinstance(meta.get("tags"), dict) else [],
voice=voice,
bio=bio,
personality=sections.get("personality", ""),
speaking_style=sections.get("speaking_style", ""),
background=sections.get("background", ""),
behavioral_rules=sections.get("behavioral_rules", ""),
raw_content=post.content,
base_dir=str(persona_dir),
drive_baseline=drive_baseline,
engine_params=genome_seed.get("engine_params", {}) if isinstance(genome_seed, dict) else {},
signal_overrides=genome_seed.get("signal_buckets", {}) if isinstance(genome_seed, dict) else {},
)
return persona
def _parse_sections(self, content: str) -> dict[str, str]:
"""Parse markdown H2 sections into a dict."""
sections: dict[str, str] = {}
current_key: Optional[str] = None
current_lines: list[str] = []
for line in content.split("\n"):
if line.startswith("## "):
# Save previous section
if current_key:
sections[current_key] = "\n".join(current_lines).strip()
# Start new section
heading = line[3:].strip().lower()
current_key = self.SECTION_MAPPING.get(heading)
current_lines = []
elif current_key is not None:
current_lines.append(line)
# Save last section
if current_key:
sections[current_key] = "\n".join(current_lines).strip()
return sections