242 lines
9.7 KiB
Python
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())
|