Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-llm/src/lexigram/ai/llm/structured/extractor.py: 17%

145 statements  

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

1"""Structured output extraction from LLM responses. 

2 

3:class:`StructuredExtractor` bridges the gap between raw LLM text and 

4typed, validated application objects. It wraps any :class:`LLMClientProtocol`, 

5instructs the model to respond as JSON, parses the response, and 

6validates it against a caller-provided model class. 

7 

8Example:: 

9 

10 from pydantic import BaseModel 

11 from typing import Literal 

12 

13 class Sentiment(BaseModel): 

14 label: Literal["positive", "negative", "neutral"] 

15 confidence: float 

16 reasoning: str 

17 

18 extractor = StructuredExtractor(llm_client) 

19 result = await extractor.extract( 

20 prompt="Analyze the sentiment of: 'Great product!'", 

21 output_model=Sentiment, 

22 ) 

23 if result.is_ok(): 

24 analysis = result.unwrap() 

25 print(analysis.label, analysis.confidence) 

26 else: 

27 error = result.unwrap_err() 

28 # handle ExtractionParseError / ExtractionValidationError / ExtractionMaxRetriesError 

29""" 

30 

31from __future__ import annotations 

32 

33from typing import Any, TypeVar 

34 

35from lexigram.ai.llm.exceptions import ( 

36 ExtractionMaxRetriesError, 

37 ExtractionParseError, 

38 ExtractionValidationError, 

39 LLMError, 

40) 

41from lexigram.ai.llm.structured.parser import ( 

42 build_json_schema, 

43 extract_json_block, 

44 validate_against_model, 

45) 

46from lexigram.contracts.ai.llm import ChatMessage, LLMClientProtocol, Role 

47from lexigram.logging import ( 

48 get_logger, 

49) 

50from lexigram.result import Err, Ok, Result 

51from lexigram.serialization import JSONDecodeError, dumps_str, loads 

52 

53logger = get_logger(__name__) 

54 

55T = TypeVar("T") 

56 

57# --------------------------------------------------------------------------- 

58# System prompt template injected before user content 

59# --------------------------------------------------------------------------- 

60 

61_EXTRACTION_SYSTEM_PROMPT = """\ 

62You are a precise data extraction assistant. 

63 

64Your task is to analyse the user's input and return a **single JSON object** \ 

65that exactly matches the schema provided below. 

66 

67Schema: 

68{schema} 

69 

70Rules: 

71- Output ONLY valid JSON. No markdown, no prose, no code fences. 

72- Every required field must be present. 

73- Do NOT add extra fields not in the schema. 

74- If a value cannot be determined from the input, use a sensible default \ 

75 (empty string, 0, false, etc.) rather than omitting the field. 

76""" 

77 

78 

79class JSONExtractor: 

80 """Extract and parse JSON from LLM responses.""" 

81 

82 @staticmethod 

83 def extract(text: str, multiple: bool = False) -> Any: 

84 """Extract JSON from text.""" 

85 import re 

86 

87 # Try to extract from code blocks first 

88 json_blocks = re.findall(r"```(?:json)?\s*(.*?)```", text, re.DOTALL) 

89 

90 if json_blocks: 

91 if multiple: 

92 results = [] 

93 for block in json_blocks: 

94 try: 

95 results.append(loads(block.strip())) 

96 except (ValueError, TypeError, JSONDecodeError): 

97 continue 

98 if results: 

99 return results 

100 else: 

101 for block in json_blocks: 

102 try: 

103 return loads(block.strip()) 

104 except (ValueError, TypeError, JSONDecodeError): 

105 continue 

106 

107 # Try parsing entire text 

108 try: 

109 result = loads(text.strip()) 

110 return [result] if multiple else result 

111 except (ValueError, TypeError, JSONDecodeError): 

112 pass 

113 

114 # Try to find JSON objects in text using bracket-counting scanner 

115 from lexigram.ai.llm.extraction._json_scanner import extract_json_objects 

116 

117 matches = extract_json_objects(text) 

118 

119 if matches: 

120 if multiple: 

121 results = [] 

122 for match in matches: 

123 try: 

124 results.append(loads(match)) 

125 except (ValueError, TypeError, JSONDecodeError): 

126 continue 

127 if results: 

128 return results 

129 else: 

130 for match in matches: 

131 try: 

132 return loads(match) 

133 except (ValueError, TypeError, JSONDecodeError): 

134 pass 

135 

136 from lexigram.ai.llm.structured.exceptions import ParseError 

137 

138 raise ParseError("Cannot extract JSON from text") 

139 

140 @staticmethod 

141 def extract_array(text: str) -> list[Any]: 

142 """Extract JSON array from text.""" 

143 from lexigram.ai.llm.structured.exceptions import ParseError 

144 

145 result = JSONExtractor.extract(text) 

146 if not isinstance(result, list): 

147 raise ParseError(f"Expected JSON array, got {type(result)}") 

148 return result 

149 

150 

151class StructuredExtractor: 

152 """Extracts validated, typed data from an LLM's text response. 

153 

154 The extractor wraps a :class:`~lexigram.contracts.ai.protocols.LLMClientProtocol` 

155 (any object implementing :meth:`complete`), builds a JSON-focused system 

156 prompt derived from *output_model*'s schema, calls the LLM, and validates 

157 the result. 

158 

159 Failed parse or validation attempts consume retry budget. All outcomes 

160 are surfaced as :class:`~lexigram.result.Result` — the 

161 caller never receives a raw exception. 

162 

163 Args: 

164 client: Any ``LLMClientProtocol`` implementation. 

165 default_model: Optional model name to use when :meth:`extract` is 

166 called without an explicit ``model`` argument. 

167 default_max_retries: Default number of extra attempts after the first 

168 failure. Individual :meth:`extract` calls can 

169 override this. 

170 

171 Example:: 

172 

173 extractor = StructuredExtractor(openai_client, default_model="gpt-4o") 

174 result = await extractor.extract( 

175 prompt="Extract key fields from this invoice: ...", 

176 output_model=Invoice, 

177 ) 

178 """ 

179 

180 def __init__( 

181 self, 

182 client: LLMClientProtocol, 

183 *, 

184 default_model: str | None = None, 

185 default_max_retries: int = 2, 

186 ) -> None: 

187 """Initialise the extractor. 

188 

189 Args: 

190 client: LLM client to use for completions. 

191 default_model: Default model name; forwarded as ``model`` kwarg 

192 to the client when no ``model`` is given in 

193 :meth:`extract`. 

194 default_max_retries: Default retry budget per extraction request. 

195 """ 

196 self._client = client 

197 self._default_model = default_model 

198 self._default_max_retries = default_max_retries 

199 

200 async def extract( 

201 self, 

202 prompt: str | list[Any], 

203 output_model: type[T], 

204 *, 

205 max_retries: int | None = None, 

206 model: str | None = None, 

207 **kwargs: Any, 

208 ) -> Result[ 

209 T, 

210 ExtractionParseError 

211 | ExtractionValidationError 

212 | ExtractionMaxRetriesError 

213 | LLMError, 

214 ]: 

215 """Extract a validated instance of *output_model* from an LLM response. 

216 

217 Args: 

218 prompt: User prompt text, or a list of chat message dicts/objects. 

219 A text string is converted to a single ``user`` message. 

220 output_model: Model class to validate against. Pydantic models, 

221 dataclasses, and classes with a ``model_validate`` 

222 class method are all supported. 

223 max_retries: Override the instance-level retry budget for this 

224 call. ``0`` means a single attempt with no retries. 

225 model: Override the instance-level default model for this call. 

226 **kwargs: Additional keyword arguments forwarded verbatim to the 

227 underlying :meth:`~LLMClientProtocol.complete` call. 

228 

229 Returns: 

230 ``Ok(T)`` — a fully validated *output_model* instance. 

231 ``Err(ExtractionParseError)`` — the last response was not valid JSON. 

232 ``Err(ExtractionValidationError)`` — JSON was valid but did not satisfy 

233 the model's schema. 

234 ``Err(ExtractionMaxRetriesError)`` — all attempts were exhausted. 

235 ``Err(LLMError)`` — the underlying client returned a recoverable 

236 provider/model failure. 

237 """ 

238 retries = max_retries if max_retries is not None else self._default_max_retries 

239 if retries < 0: 

240 raise ValueError("max_retries must be greater than or equal to 0") 

241 effective_model = model or self._default_model 

242 

243 schema = build_json_schema(output_model) 

244 schema_str = dumps_str(schema, indent=2) 

245 system_content = _EXTRACTION_SYSTEM_PROMPT.format(schema=schema_str) 

246 

247 messages = self._build_messages(prompt, system_content) 

248 if effective_model is not None: 

249 kwargs.setdefault("model", effective_model) 

250 

251 last_error: ExtractionParseError | ExtractionValidationError | None = None 

252 

253 for attempt in range(retries + 1): 

254 if attempt > 0: 

255 logger.debug( 

256 "extraction_retry", 

257 attempt=attempt, 

258 model=effective_model, 

259 output_model=output_model.__name__, 

260 ) 

261 

262 completion_result = await self._client.complete(messages, **kwargs) 

263 if completion_result.is_err(): 

264 return Err(completion_result.unwrap_err()) # type: ignore[arg-type] 

265 

266 completion = completion_result.unwrap() 

267 raw_text = _get_content(completion) 

268 

269 # --- Parse --- 

270 try: 

271 parsed = extract_json_block(raw_text) 

272 except ValueError as exc: 

273 last_error = ExtractionParseError( 

274 f"Failed to parse JSON from LLM response (attempt {attempt + 1}): {exc}" 

275 ) 

276 logger.debug( 

277 "extraction_parse_failed", 

278 attempt=attempt + 1, 

279 error=str(exc), 

280 raw_text=raw_text[:200], 

281 ) 

282 continue 

283 

284 # --- Validate --- 

285 try: 

286 instance = validate_against_model(parsed, output_model) 

287 except (TypeError, ValueError) as exc: 

288 last_error = ExtractionValidationError( 

289 f"Model validation failed (attempt {attempt + 1}): {exc}" 

290 ) 

291 logger.debug( 

292 "extraction_validation_failed", 

293 attempt=attempt + 1, 

294 error=str(exc), 

295 ) 

296 continue 

297 

298 logger.debug( 

299 "extraction_succeeded", 

300 attempt=attempt + 1, 

301 output_model=output_model.__name__, 

302 ) 

303 return Ok(instance) 

304 

305 # All attempts exhausted 

306 if last_error is not None: 

307 return Err( 

308 ExtractionMaxRetriesError( 

309 f"Extraction failed after {retries + 1} attempt(s). " 

310 f"Last error: {last_error}" 

311 ) 

312 ) 

313 

314 # Should not be reached 

315 return Err( 

316 ExtractionMaxRetriesError("Extraction failed with no recorded error.") 

317 ) 

318 

319 # ------------------------------------------------------------------ 

320 # Helpers 

321 # ------------------------------------------------------------------ 

322 

323 @staticmethod 

324 def _build_messages( 

325 prompt: str | list[Any], 

326 system_content: str, 

327 ) -> list[ChatMessage]: 

328 """Build a message list with the extraction system prompt prepended. 

329 

330 Args: 

331 prompt: User prompt string or pre-built message list. 

332 system_content: The JSON-extraction system prompt. 

333 

334 Returns: 

335 List of ``ChatMessage`` objects. 

336 """ 

337 if isinstance(prompt, str): 

338 return [ 

339 ChatMessage(role=Role.SYSTEM, content=system_content), 

340 ChatMessage(role=Role.USER, content=prompt), 

341 ] 

342 

343 messages: list[ChatMessage] = [] 

344 has_system = False 

345 for msg in prompt: 

346 role = ( 

347 msg.get("role") if isinstance(msg, dict) else getattr(msg, "role", None) 

348 ) 

349 content = ( 

350 msg.get("content", "") 

351 if isinstance(msg, dict) 

352 else getattr(msg, "content", "") 

353 ) 

354 if role == "system": 

355 has_system = True 

356 combined = f"{content}\n\n---\n\n{system_content}" 

357 messages.append(ChatMessage(role=Role.SYSTEM, content=combined)) 

358 elif isinstance(content, list): 

359 messages.append(ChatMessage(role=str(role), content=content)) 

360 else: 

361 messages.append(ChatMessage(role=str(role), content=str(content))) 

362 

363 if not has_system: 

364 messages.insert(0, ChatMessage(role=Role.SYSTEM, content=system_content)) 

365 

366 return messages 

367 

368 

369def _get_content(completion: Any) -> str: 

370 """Extract text content from a Completion-like object. 

371 

372 Accepts: 

373 - Objects with a ``.content`` attribute (``lexigram-ai-llm`` Completion). 

374 - Plain strings. 

375 - Dicts with a ``content`` key. 

376 

377 Args: 

378 completion: Completion object or raw value. 

379 

380 Returns: 

381 The text content as a string. 

382 

383 Raises: 

384 ValueError: When content cannot be found. 

385 """ 

386 if isinstance(completion, str): 

387 return completion 

388 if isinstance(completion, dict): 

389 content = completion.get("content") or completion.get("text", "") 

390 return str(content) 

391 content = getattr(completion, "content", None) 

392 if content is not None: 

393 return str(content) 

394 raise ValueError( 

395 f"Cannot extract text content from completion of type {type(completion).__name__!r}" 

396 ) 

397 

398 

399class StructuredOutputExtractor: 

400 """Utility class that exposes the bracket-counting JSON block extractor. 

401 

402 A lightweight, argument-free companion to :class:`StructuredExtractor` 

403 designed for direct use and unit-testing of the JSON extraction step in 

404 isolation. The extraction logic itself lives in 

405 :func:`~lexigram.ai.llm.structured.parser._extract_first_json_block`; 

406 this class adds markdown-fence stripping on top and provides a stable 

407 surface for callers that only need the raw JSON string (not a parsed 

408 Python value). 

409 

410 Example:: 

411 

412 extractor = StructuredOutputExtractor() 

413 raw = extractor._extract_json_block(llm_response_text) 

414 if raw is not None: 

415 data = json.loads(raw) 

416 """ 

417 

418 def _extract_json_block(self, text: str) -> str | None: 

419 """Extract the first complete JSON object or array from *text*. 

420 

421 Uses bracket counting (via 

422 :func:`~lexigram.ai.llm.structured.parser._extract_first_json_block`) 

423 which correctly handles: 

424 

425 - Nested objects and arrays. 

426 - Multiple JSON objects in the same string (returns the first valid one). 

427 - Braces / brackets inside quoted string values. 

428 - Markdown code fences (````json … ````). 

429 

430 Args: 

431 text: Raw text that may contain a JSON object or array. 

432 

433 Returns: 

434 The first complete, parseable JSON block as a raw string, or 

435 ``None`` when no valid JSON block is found. 

436 """ 

437 from lexigram.ai.llm.structured.parser import _extract_first_json_block 

438 

439 # Strip markdown code fences before scanning so the bracket counter 

440 # is not confused by the fence syntax. 

441 stripped = text.strip() 

442 if stripped.startswith("```"): 

443 lines = stripped.split("\n") 

444 # Drop the opening fence line (```json or ```) … 

445 inner_lines = lines[1:] 

446 # … and the closing ``` if present. 

447 if inner_lines and inner_lines[-1].strip() == "```": 

448 inner_lines = inner_lines[:-1] 

449 stripped = "\n".join(inner_lines) 

450 

451 return _extract_first_json_block(stripped) 

452 

453 

454# Backward-compat alias 

455__all__ = ["JSONExtractor", "StructuredExtractor", "StructuredOutputExtractor"]