""" Persona & System Prompt Builder Arsitektur: 1. Base System Prompt → instruksi inti (tools, RAG, response format) 2. Env Character → persona (identity, communication style, description) → policies (git policy, safety rules) 3. Skills → role-specific instructions (programmer, roleplayer, analyst) Load order: Base → Character → Policies → Skills """ import os import re from dataclasses import dataclass, field from pathlib import Path # ─── 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" # ─── Mode / Skill ────────────────────────────────────────────────────────────── MODE = os.getenv("PERSONA_MODE", default="programmer").strip().lower() # ─── Personality Configuration ──────────────────────────────────────────────── @dataclass class PersonalityConfig: """Konfigurasi personality AI yang berlaku lintas mode.""" name: str = "OWL" age: str = "" gender: str = "" tone: str = "casual" verbosity: str = "balanced" humor_level: str = "light" language: str = "id" mood: str = "cheerful" catchphrases: list = field(default_factory=list) def _load_personality_from_env() -> PersonalityConfig: """Baca personality config dari environment variables.""" raw_catchphrases = os.getenv("PERSONA_CATCHPHRASES", default="").strip() catchphrases = [c.strip() for c in raw_catchphrases.split(",") if c.strip()] if raw_catchphrases else [] return PersonalityConfig( name=os.getenv("PERSONA_NAME", default="OWL").strip() or "OWL", age=os.getenv("PERSONA_AGE", default="").strip(), gender=os.getenv("PERSONA_GENDER", default="").strip(), tone=os.getenv("PERSONA_TONE", default="casual").strip().lower() or "casual", verbosity=os.getenv("PERSONA_VERBOSITY", default="balanced").strip().lower() or "balanced", humor_level=os.getenv("PERSONA_HUMOR", default="light").strip().lower() or "light", language=os.getenv("PERSONA_LANGUAGE", default="id").strip().lower() or "id", mood=os.getenv("PERSONA_MOOD", default="cheerful").strip().lower() or "cheerful", catchphrases=catchphrases, ) PERSONALITY = _load_personality_from_env() # ─── Markdown Parser ─────────────────────────────────────────────────────────── def _parse_simple_kv(filepath: Path) -> dict: """ Parse file markdown dengan format sederhana: # Section - **Key:** Value - Key: Value Returns dict { key: value }. """ result = {} if not filepath.exists(): return result for line in filepath.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#"): continue # Format: "- **Key:** Value" atau "- Key: Value" cleaned = re.sub(r'^-\s*', '', line) cleaned = re.sub(r'\*\*', '', cleaned) if ':' in cleaned: key, value = cleaned.split(':', 1) result[key.strip().lower()] = value.strip() return result 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 deskripsi personality dari config.""" parts = [f"You are {cfg.name}."] if cfg.age: parts.append(f"Your persona age is {cfg.age} years old.") if cfg.gender: parts.append(f"Your persona gender is {cfg.gender}.") tone_map = { "casual": "You speak in a casual, relaxed manner — like chatting with a friend.", "formal": "You speak formally and professionally, using polite language.", "playful": "You are playful and cheerful, making conversations fun and lighthearted.", "warm": "You are warm and friendly, making people feel comfortable and welcomed.", } parts.append(tone_map.get(cfg.tone, tone_map["casual"])) verbosity_map = { "concise": "Keep your answers short and to the point.", "balanced": "Provide balanced answers — not too brief, not too long.", "detailed": "Give thorough, detailed answers with explanations.", } parts.append(verbosity_map.get(cfg.verbosity, verbosity_map["balanced"])) humor_map = { "none": "Stay serious and avoid jokes.", "light": "Occasionally sprinkle in light humor when appropriate.", "witty": "Be witty and humorous — jokes, puns, and playful banter are welcome.", } parts.append(humor_map.get(cfg.humor_level, humor_map["light"])) if cfg.language == "id": parts.append("Always respond in Indonesian (Bahasa Indonesia).") elif cfg.language == "en": parts.append("Always respond in English.") else: parts.append("Respond in the same language the user uses.") mood_map = { "cheerful": "Your overall mood is cheerful and positive.", "calm": "Your overall mood is calm and soothing.", "energetic": "Your overall mood is energetic and enthusiastic.", "sarcastic": "Your overall mood is sarcastic and dry-humored.", } parts.append(mood_map.get(cfg.mood, mood_map["cheerful"])) if cfg.catchphrases: phrases = ", ".join(f'"{p}"' for p in cfg.catchphrases) parts.append(f"You sometimes use these catchphrases: {phrases}.") return "\n".join(parts) 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}") lines.append("") lines.append( "Use tools by returning tool calls when needed. After receiving tool " "results, continue your reasoning. When you have the final answer, " "return it as plain text without tool calls." ) return "\n".join(lines) def _load_env_character(character_name: str) -> tuple[str, str]: """ Load character description dan policies dari directory env-characters. Returns: (character_block, policies_text) """ char_dir = ENV_CHARACTERS_DIR / character_name if not char_dir.is_dir(): return "", "" # Read character.md — derive personality from config (already loaded) character_block = "" # personality block tetap dari PersonalityConfig # Read policies.md policies_text = _read_markdown_section(char_dir / "policies.md") return character_block, policies_text 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, mode: str | None = None, personality: PersonalityConfig | None = None, character: str | None = None, skills: list[str] | None = None, ) -> str: """ Build system prompt berdasarkan mode, character, dan skills. Load order: 1. Base prompt 2. Personality block (dari config) 3. Policies (dari env-characters//policies.md) 4. Skill instructions (dari skills//instructions.md) Args: tools_definition: Daftar tools (required untuk skill programmer). mode: "programmer" atau "roleplayer". Default: dari env PERSONA_MODE. personality: PersonalityConfig instance. Default: global PERSONALITY. character: Nama env character. Default: dari env AGENT_CHARACTER. skills: List nama skill aktif. Default: derives from mode atau env AGENT_SKILLS. Returns: String system prompt lengkap. """ selected_mode = (mode or MODE).strip().lower() cfg = personality or PERSONALITY # Resolve character name character_name = (character or os.getenv("AGENT_CHARACTER", default="")).strip().lower() # Resolve skills list if skills is None: skills_env = os.getenv("AGENT_SKILLS", default="").strip() if skills_env: skills_list = [s.strip() for s in skills_env.split(",") if s.strip()] else: # Derive from mode untuk backward compatibility skills_list = [selected_mode] if selected_mode in ("programmer", "roleplayer") else [] else: skills_list = skills # ── 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") for s in skills_list) tools_block = "" if needs_tools and tools_definition is not None: tools_block = _build_tools_block(tools_definition) # ── 4. Policies ────────────────────────────────────────────────────────────── policies_block = "" if character_name: _, policies_block = _load_env_character(character_name) # ── 5. Skills ──────────────────────────────────────────────────────────────── skills_block = _load_skills(skills_list) # ── Assemble ───────────────────────────────────────────────────────────────── sections = [ base_prompt, personality_block, tools_block, policies_block, skills_block, ] # Filter empty sections dan gabungkan return "\n\n".join(s for s in sections if s.strip())