Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-prompt/src/lexigram/ai/prompt/service/loader.py: 26%

86 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 07:19 +0800

1"""Template loaders for PromptService. 

2 

3Three loaders are provided: 

4 

5- :class:`DictPromptLoader` — load from an in-process Python dict (inline 

6 config or app-registered templates). 

7- :class:`DirectoryPromptLoader` — load all YAML/JSON files from a directory. 

8 Each file may contain one or more templates. 

9- The :class:`PromptLoaderProtocol` defines the interface for custom loaders. 

10 

11YAML template format 

12-------------------- 

13A single file may contain multiple templates under a top-level ``templates`` 

14key, or a single template as the top-level document:: 

15 

16 # multi-template file 

17 templates: 

18 - name: wellness_check 

19 version: v1 

20 provider: anthropic 

21 required_variables: [pet_name, species] 

22 optional_defaults: 

23 breed: Unknown 

24 content: | 

25 Assess {pet_name} ({species}, {breed}). 

26 

27 # single-template file (flat) 

28 name: greeting 

29 version: v1 

30 content: Hello, {name}! 

31 

32Dict format 

33----------- 

34Pass a list of dicts, each matching the :class:`~lexigram.ai.prompt.service.models.PromptTemplate` 

35fields:: 

36 

37 loader = DictPromptLoader([ 

38 { 

39 "name": "greeting", 

40 "version": "v1", 

41 "content": "Hello, {name}!", 

42 "required_variables": ["name"], 

43 }, 

44 ]) 

45""" 

46 

47from __future__ import annotations 

48 

49from pathlib import Path 

50from typing import Any, Protocol, runtime_checkable 

51 

52from lexigram.ai.prompt.constants import DEFAULT_RENDER_FORMAT 

53from lexigram.ai.prompt.rendering.engine import RenderFormat 

54from lexigram.ai.prompt.service.models import LLMProvider, PromptTemplate 

55from lexigram.logging import get_logger 

56from lexigram.serialization import JSONDecodeError, loads 

57 

58logger = get_logger(__name__) 

59 

60_SUPPORTED_EXTENSIONS = frozenset({".yaml", ".yml", ".json"}) 

61 

62 

63@runtime_checkable 

64class PromptLoaderProtocol(Protocol): 

65 """Interface for objects that supply :class:`~lexigram.ai.prompt.service.models.PromptTemplate` instances.""" 

66 

67 def load(self) -> list[PromptTemplate]: 

68 """Return all templates from this source. 

69 

70 Raises: 

71 OSError: If a file cannot be read. 

72 ValueError: If a template document is malformed. 

73 """ 

74 ... 

75 

76 

77def _dict_to_template( 

78 data: dict[str, Any], default_format: RenderFormat = DEFAULT_RENDER_FORMAT 

79) -> PromptTemplate: 

80 """Convert a raw dict (from YAML or inline config) to a :class:`PromptTemplate`. 

81 

82 Raises: 

83 ValueError: If ``name``, ``version``, or ``content`` are missing, 

84 or if ``format`` is not a valid 

85 :class:`~lexigram.ai.prompt.rendering.engine.RenderFormat` 

86 value. 

87 """ 

88 missing = [k for k in ("name", "version", "content") if not data.get(k)] 

89 if missing: 

90 raise ValueError( 

91 f"PromptTemplate dict is missing required keys: {missing!r}. Got: {list(data.keys())!r}" 

92 ) 

93 

94 raw_provider = data.get("provider", LLMProvider.GENERIC) 

95 try: 

96 provider = LLMProvider(raw_provider) 

97 except ValueError: 

98 raise ValueError( 

99 f"Unknown provider {raw_provider!r}. " 

100 f"Valid values: {[p.value for p in LLMProvider]!r}" 

101 ) 

102 

103 raw_format = data.get("format", default_format) 

104 try: 

105 render_format = RenderFormat(raw_format) 

106 except ValueError: 

107 raise ValueError( 

108 f"Unknown render format {raw_format!r}. " 

109 f"Valid values: {[f.value for f in RenderFormat]!r}" 

110 ) 

111 

112 required_variables = tuple(data.get("required_variables") or []) 

113 optional_defaults: dict[str, Any] = dict(data.get("optional_defaults") or {}) 

114 

115 return PromptTemplate( 

116 name=str(data["name"]), 

117 version=str(data["version"]), 

118 content=str(data["content"]), 

119 format=render_format, 

120 provider=provider, 

121 required_variables=required_variables, 

122 optional_defaults=optional_defaults, 

123 description=str(data.get("description", "")), 

124 metadata=dict(data.get("metadata") or {}), 

125 ) 

126 

127 

128def _parse_file_content(path: Path, text: str) -> list[dict[str, Any]]: 

129 """Parse YAML or JSON text into a list of raw template dicts.""" 

130 if path.suffix.lower() == ".json": 

131 data = loads(text) 

132 else: 

133 try: 

134 import yaml 

135 except ImportError as exc: 

136 raise ImportError( 

137 f"PyYAML is required to load YAML prompt templates. " 

138 f"Install it: pip install pyyaml. File: {path}" 

139 ) from exc 

140 data = yaml.safe_load(text) 

141 

142 if data is None: 

143 return [] 

144 

145 if isinstance(data, list): 

146 return [d for d in data if isinstance(d, dict)] 

147 

148 if isinstance(data, dict): 

149 if "templates" in data and isinstance(data["templates"], list): 

150 return [d for d in data["templates"] if isinstance(d, dict)] 

151 # Single flat template document 

152 return [data] 

153 

154 raise ValueError(f"Unexpected YAML/JSON root type in {path}: {type(data).__name__}") 

155 

156 

157class DictPromptLoader: 

158 """Load templates from an in-process Python list of dicts. 

159 

160 Args: 

161 templates: List of raw template dicts (see module docstring for schema). 

162 default_format: Format applied to dicts that don't declare ``format``. 

163 """ 

164 

165 def __init__( 

166 self, 

167 templates: list[dict[str, Any]], 

168 default_format: RenderFormat = DEFAULT_RENDER_FORMAT, 

169 ) -> None: 

170 self._templates = templates 

171 self._default_format = default_format 

172 

173 def load(self) -> list[PromptTemplate]: 

174 """Parse and return all templates. 

175 

176 Raises: 

177 ValueError: If any dict is malformed. 

178 """ 

179 result: list[PromptTemplate] = [] 

180 for i, raw in enumerate(self._templates): 

181 try: 

182 result.append(_dict_to_template(raw, self._default_format)) 

183 except ValueError as exc: 

184 raise ValueError(f"Invalid template at index {i}: {exc}") from exc 

185 return result 

186 

187 

188class DirectoryPromptLoader: 

189 """Load templates from all YAML/JSON files in a directory. 

190 

191 Files are processed in sorted order. Files starting with ``.`` or ``_`` 

192 are skipped. Files that fail to parse emit a warning and are skipped 

193 (non-fatal), matching the behaviour of 

194 :class:`~lexigram.config.lib.sources.DirectoryConfigSource`. 

195 

196 Args: 

197 directory: Path to the directory containing template files. 

198 default_format: Format applied to files that don't declare ``format``. 

199 """ 

200 

201 def __init__( 

202 self, 

203 directory: str | Path, 

204 default_format: RenderFormat = DEFAULT_RENDER_FORMAT, 

205 ) -> None: 

206 self._directory = Path(directory) 

207 self._default_format = default_format 

208 

209 def load(self) -> list[PromptTemplate]: 

210 """Scan the directory and return all parsed templates. 

211 

212 Raises: 

213 OSError: If the directory cannot be read. 

214 """ 

215 if not self._directory.is_dir(): 

216 logger.warning( 

217 "prompt_loader_directory_not_found", 

218 directory=str(self._directory), 

219 ) 

220 return [] 

221 

222 result: list[PromptTemplate] = [] 

223 for path in sorted(self._directory.iterdir()): 

224 if path.suffix.lower() not in _SUPPORTED_EXTENSIONS: 

225 continue 

226 if path.name.startswith((".", "_")): 

227 continue 

228 

229 try: 

230 text = path.read_text(encoding="utf-8") 

231 raw_dicts = _parse_file_content(path, text) 

232 for raw in raw_dicts: 

233 try: 

234 result.append(_dict_to_template(raw, self._default_format)) 

235 except ValueError as exc: 

236 logger.warning( 

237 "prompt_loader_template_parse_error", 

238 file=str(path), 

239 error=str(exc), 

240 ) 

241 except (OSError, JSONDecodeError, ValueError) as exc: 

242 logger.warning( 

243 "prompt_loader_file_error", 

244 file=str(path), 

245 error=str(exc), 

246 ) 

247 

248 logger.debug( 

249 "prompt_loader_directory_loaded", 

250 directory=str(self._directory), 

251 count=len(result), 

252 ) 

253 return result 

254 

255 

256__all__ = [ 

257 "DictPromptLoader", 

258 "DirectoryPromptLoader", 

259 "PromptLoaderProtocol", 

260]