hendrik/lib/personality.py

242 lines
9.7 KiB
Python

"""
Persona & System Prompt Builder
Arsitektur:
1. Base System Prompt → instruksi inti (tools, RAG, response format)
2. Character → info.yaml (metadata) + instructions.md (personality, policies)
3. Skills → role-specific instructions (programmer, roleplayer, analyst)
Load order: Base → Character → Skills
"""
import re
from dataclasses import dataclass, field
from pathlib import Path
import yaml
import config
# ─── Paths ────────────────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).resolve().parent.parent / "agent"
BASE_PROMPT_PATH = BASE_DIR / "base-system-prompt.md"
ENV_CHARACTERS_DIR = BASE_DIR / "characters"
SKILLS_DIR = BASE_DIR / "skills"
# ─── Personality Configuration ────────────────────────────────────────────────
@dataclass
class PersonalityConfig:
"""Konfigurasi character — hanya metadata, personality traits di instructions.md."""
name: str = "OWL"
codename: str = ""
verbosity: str = "balanced"
catchphrases: list = field(default_factory=list)
disable_reasoning: bool = False
PERSONALITY = PersonalityConfig()
# ─── Markdown Parser ───────────────────────────────────────────────────────────
def _read_markdown_section(filepath: Path) -> str:
"""Baca seluruh isi file markdown, stripping frontmatter jika ada."""
if not filepath.exists():
return ""
content = filepath.read_text(encoding="utf-8")
# Strip leading --- frontmatter blocks
content = re.sub(r'^---\s*\n.*?\n---\s*\n', '', content, flags=re.DOTALL)
# Strip leading # title if present (first line only)
lines = content.strip().splitlines()
if lines and lines[0].startswith('# '):
lines = lines[1:]
return '\n'.join(lines).strip()
# ─── Prompt Builders ───────────────────────────────────────────────────────────
def _build_personality_block(cfg: PersonalityConfig) -> str:
"""Generate personality block — simple identity statement."""
return f"You are {cfg.name}."
def _build_tools_block(tools_definition: list[dict]) -> str:
"""Generate daftar tools dari tools_definition."""
lines = [
"You have access to the following tools:",
"",
]
for i, tool in enumerate(tools_definition, 1):
name = tool["name"]
desc = tool["schema"]["function"]["description"]
lines.append(f"{i}. {name}: {desc}")
return "\n".join(lines)
def _load_character_content(character_name: str) -> str:
"""
Load character content dari instructions.md.
Fallback ke policies.md + style.md jika instructions.md tidak ada.
"""
char_dir = ENV_CHARACTERS_DIR / character_name
if not char_dir.is_dir():
return ""
# Prioritas: instructions.md
instructions_path = char_dir / "instructions.md"
if instructions_path.is_file():
return _read_markdown_section(instructions_path)
# Fallback: policies.md + style.md (format lama)
parts = []
policies_text = _read_markdown_section(char_dir / "policies.md")
if policies_text:
parts.append(policies_text)
style_text = _read_markdown_section(char_dir / "style.md")
if style_text:
parts.append(style_text)
return "\n\n".join(parts)
def _load_skills(skill_names: list[str]) -> str:
"""
Load dan gabungkan skill instructions.
Args:
skill_names: List nama skill aktif, e.g. ["programmer"]
Returns:
Gabungan skill instructions sebagai string.
"""
sections = []
for skill_name in skill_names:
skill_path = SKILLS_DIR / skill_name / "instructions.md"
content = _read_markdown_section(skill_path)
if content:
sections.append(content)
return "\n\n".join(sections)
# ─── Public API ────────────────────────────────────────────────────────────────
def build_system_prompt(
tools_definition: list[dict] | None = None,
skill: str | None = None,
personality: PersonalityConfig | None = None,
character: str | None = None,
skills: list[str] | None = None,
) -> str:
"""
Build system prompt berdasarkan character dan skills.
Load order:
1. Base prompt
2. Personality block (identity statement)
3. Character content (dari instructions.md / policies.md+style.md)
4. Skill instructions (dari skills/<name>/instructions.md)
Args:
tools_definition: Daftar tools (required untuk skill programmer).
skill: Legacy param, diabaikan. Gunakan skills.
personality: PersonalityConfig instance. Default: global PERSONALITY.
character: Nama character directory. Default: dari config AGENT_CHARACTER.
skills: List nama skill aktif. Default: dari config AGENT_SKILLS.
Returns:
String system prompt lengkap.
"""
global PERSONALITY
cfg = personality or PersonalityConfig()
selected_skill = (skill or "").strip().lower()
# Resolve character name
character_name = (character or "").strip().lower()
# ── Load info.yaml dari character directory ──────────────────────────────────
if character_name:
char_dir = ENV_CHARACTERS_DIR / character_name
info_yaml_path = char_dir / "info.yaml"
if info_yaml_path.is_file():
try:
with open(info_yaml_path, "r", encoding="utf-8") as f:
_info_data = yaml.safe_load(f) or {}
if isinstance(_info_data, dict):
if _info_data.get("name"):
cfg.name = _info_data["name"]
if _info_data.get("codename"):
cfg.codename = _info_data["codename"]
elif cfg.name:
cfg.codename = cfg.name
if _info_data.get("verbosity"):
cfg.verbosity = _info_data["verbosity"]
if "disable_reasoning" in _info_data:
cfg.disable_reasoning = bool(_info_data["disable_reasoning"])
# Skill — support YAML list dan comma-separated string
_skill_from_yaml = _info_data.get("skill") or _info_data.get("mode")
if _skill_from_yaml:
if isinstance(_skill_from_yaml, list):
selected_skill = ",".join(str(s).strip().lower() for s in _skill_from_yaml)
else:
selected_skill = str(_skill_from_yaml).strip().lower()
else:
selected_skill = ""
except Exception as e:
print(f"[personality] Warning: gagal load info.yaml untuk '{character_name}': {e}", flush=True)
# Update global PERSONALITY
PERSONALITY = cfg
# Resolve skills list
_valid = ("programmer", "roleplayer", "analyst", "strategist")
if skills is not None:
skills_list = skills
elif selected_skill:
skills_list = [s.strip() for s in selected_skill.split(",") if s.strip() in _valid]
else:
skills_env = config.AGENT_SKILLS.strip()
if skills_env:
skills_list = [s.strip() for s in skills_env.split(",") if s.strip() in _valid]
else:
skills_list = []
# ── 1. Base prompt ──────────────────────────────────────────────────────────
base_prompt = ""
if BASE_PROMPT_PATH.exists():
base_prompt = _read_markdown_section(BASE_PROMPT_PATH)
# ── 2. Personality block ────────────────────────────────────────────────────
personality_block = _build_personality_block(cfg)
# ── 3. Tools block (hanya untuk skill yang butuh tools) ─────────────────────
needs_tools = any(s in ("programmer", "analyst", "roleplayer") for s in skills_list)
tools_block = ""
if needs_tools and tools_definition is not None:
tools_block = _build_tools_block(tools_definition)
# ── 4. Character content ────────────────────────────────────────────────────
character_block = ""
if character_name:
character_block = _load_character_content(character_name)
# ── 5. Skills ────────────────────────────────────────────────────────────────
skills_block = _load_skills(skills_list)
# ── Assemble ─────────────────────────────────────────────────────────────────
sections = [
base_prompt,
personality_block,
tools_block,
character_block,
skills_block,
]
# Filter empty sections dan gabungkan
return "\n\n".join(s for s in sections if s.strip())