44import re
55import time
66from dataclasses import dataclass
7+ from functools import lru_cache
8+ from pathlib import Path
79from typing import Any
810
911import httpx
12+ import markdown # type: ignore[import-untyped]
1013from django .conf import settings
1114
1215JSON_OBJECT_PATTERN = re .compile (r"\{.*\}" , re .DOTALL )
@@ -19,6 +22,17 @@ class OpenRouterJSONResponse:
1922 latency_ms : int
2023
2124
25+ @dataclass (slots = True )
26+ class SkillDefinition :
27+ """Represents one Claude-style skill markdown document."""
28+
29+ name : str
30+ input_fields : tuple [str , ...]
31+ output_fields : tuple [str , ...]
32+ instructions_markdown : str
33+ instructions_html : str
34+
35+
2236def openrouter_chat_json (
2337 * , model : str , system_prompt : str , user_prompt : str
2438) -> OpenRouterJSONResponse :
@@ -62,6 +76,42 @@ def openrouter_chat_json(
6276 )
6377
6478
79+ @lru_cache (maxsize = 16 )
80+ def get_skill_definition (skill_name : str ) -> SkillDefinition :
81+ """Load a skill definition from the repository skill markdown directory."""
82+
83+ skill_path = Path (__file__ ).resolve ().parent .parent / "skills" / skill_name / "SKILL.md"
84+ raw_text = skill_path .read_text (encoding = "utf-8" )
85+ frontmatter , body = _split_frontmatter (raw_text )
86+ name = frontmatter .get ("name" , skill_name ).strip () or skill_name
87+ input_fields = _csv_field_list (frontmatter .get ("input" , "" ))
88+ output_fields = _csv_field_list (frontmatter .get ("output" , "" ))
89+ instructions_markdown = body .strip ()
90+ return SkillDefinition (
91+ name = name ,
92+ input_fields = input_fields ,
93+ output_fields = output_fields ,
94+ instructions_markdown = instructions_markdown ,
95+ instructions_html = markdown .markdown (instructions_markdown ),
96+ )
97+
98+
99+ def build_skill_user_prompt (skill_name : str , inputs : dict [str , Any ]) -> str :
100+ """Render a consistent user prompt from a skill's declared input fields."""
101+
102+ skill = get_skill_definition (skill_name )
103+ sections = []
104+ for field_name in skill .input_fields :
105+ value = inputs .get (field_name , "" )
106+ sections .append (f"{ field_name } :\n { _stringify_skill_input (value )} " )
107+ if skill .output_fields :
108+ sections .append (
109+ "Return only a JSON object with these fields: "
110+ + ", " .join (skill .output_fields )
111+ )
112+ return "\n \n " .join (sections )
113+
114+
65115def _extract_json_object (message_content : str ) -> dict [str , Any ]:
66116 try :
67117 payload = json .loads (message_content )
@@ -73,3 +123,35 @@ def _extract_json_object(message_content: str) -> dict[str, Any]:
73123 if not isinstance (payload , dict ):
74124 raise ValueError ("Model response JSON must be an object." )
75125 return payload
126+
127+
128+ def _split_frontmatter (raw_text : str ) -> tuple [dict [str , str ], str ]:
129+ """Split a skill markdown document into simple frontmatter and body."""
130+
131+ if not raw_text .startswith ("---\n " ):
132+ return {}, raw_text
133+ _ , _ , remainder = raw_text .partition ("\n " )
134+ frontmatter_block , separator , body = remainder .partition ("\n ---\n " )
135+ if not separator :
136+ return {}, raw_text
137+ frontmatter : dict [str , str ] = {}
138+ for line in frontmatter_block .splitlines ():
139+ if not line .strip () or ":" not in line :
140+ continue
141+ key , value = line .split (":" , 1 )
142+ frontmatter [key .strip ()] = value .strip ()
143+ return frontmatter , body
144+
145+
146+ def _csv_field_list (raw_value : str ) -> tuple [str , ...]:
147+ """Parse a comma-separated frontmatter field list."""
148+
149+ return tuple (part .strip () for part in raw_value .split ("," ) if part .strip ())
150+
151+
152+ def _stringify_skill_input (value : Any ) -> str :
153+ """Serialize skill input values into prompt-safe text."""
154+
155+ if isinstance (value , (dict , list , tuple )):
156+ return json .dumps (value , ensure_ascii = True , indent = 2 , sort_keys = True )
157+ return str (value )
0 commit comments