""" 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//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())