305 lines
12 KiB
Python
305 lines
12 KiB
Python
"""
|
|
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/<name>/policies.md)
|
|
4. Skill instructions (dari skills/<name>/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())
|