quantumbit commited on
Commit
1101e2c
·
verified ·
1 Parent(s): 3810d8e

Update inference.py

Browse files
Files changed (1) hide show
  1. inference.py +351 -351
inference.py CHANGED
@@ -1,351 +1,351 @@
1
- """
2
- Inference Processor - Handles VLM extraction, validation, and result formatting
3
- """
4
-
5
- import torch
6
- import time
7
- import json
8
- import codecs
9
- import re
10
- from PIL import Image
11
- from qwen_vl_utils import process_vision_info
12
- from typing import Dict, Tuple
13
-
14
- from config import (
15
- MAX_IMAGE_SIZE,
16
- HP_VALID_RANGE,
17
- ASSET_COST_VALID_RANGE,
18
- COST_PER_GPU_HOUR
19
- )
20
- from model_manager import model_manager
21
-
22
-
23
- EXTRACTION_PROMPT = """
24
- You are an expert at reading noisy, handwritten Indian invoices and quotations.
25
-
26
- Your task is to extract text EXACTLY as it appears in the image.
27
- Do NOT translate, summarize, normalize, or rewrite any text.
28
- Preserve the original language (Hindi, Marathi, Kannada, English, etc.).
29
-
30
- Carefully read the image and extract the following fields.
31
-
32
- Return ONLY valid JSON in this format:
33
-
34
- {
35
- "dealer_name": string,
36
- "model_name": string,
37
- "horse_power": number,
38
- "asset_cost": number
39
- }
40
-
41
- Critical rules:
42
- - Dealer name must be copied exactly from the image in the original language and spelling.
43
- - Model name must be copied exactly from the image without translation.
44
- - Do NOT convert regional language text into English.
45
- - Do NOT expand abbreviations or correct spelling.
46
- - Only numbers may be normalized.
47
-
48
- Extraction hints:
49
- - Asset cost is the total amount, usually the largest number on the page, the total amount after TAX, final price or final cost.
50
- - Dealer name is usually at the top header or company name.
51
- - Model name often appears near words like Model, Tractor, Variant.
52
- - Horse power must come ONLY from explicit HP text, never from model numbers.
53
- - Horse power may appear as "HP", handwritten like "49 HP", "63hp", "HP-30".
54
- - Remove commas and currency symbols from numbers only.
55
- - If handwriting is unclear, make your best reasonable interpretation of the characters — but preserve language.
56
-
57
- Output rules:
58
- - Output ONLY valid JSON.
59
- - Do NOT include markdown, explanations, or extra text.
60
- """
61
-
62
-
63
- class InferenceProcessor:
64
- """Handles VLM inference, validation, and result processing"""
65
-
66
- @staticmethod
67
- def preprocess_image(image_path: str) -> Image.Image:
68
- """Load and resize image if needed"""
69
- image = Image.open(image_path).convert("RGB")
70
-
71
- # Resize if too large
72
- if max(image.size) > MAX_IMAGE_SIZE:
73
- ratio = MAX_IMAGE_SIZE / max(image.size)
74
- new_size = (int(image.size[0] * ratio), int(image.size[1] * ratio))
75
- image = image.resize(new_size, Image.LANCZOS)
76
- print(f"🔄 Image resized to {new_size}")
77
-
78
- return image
79
-
80
- @staticmethod
81
- def run_vlm_extraction(image: Image.Image) -> Tuple[str, float]:
82
- """Run VLM model to extract invoice fields"""
83
- if not model_manager.is_loaded():
84
- raise RuntimeError("Models not loaded")
85
-
86
- model = model_manager.vlm_model
87
- processor = model_manager.processor
88
-
89
- messages = [
90
- {
91
- "role": "user",
92
- "content": [
93
- {"type": "image", "image": image},
94
- {"type": "text", "text": EXTRACTION_PROMPT}
95
- ]
96
- }
97
- ]
98
-
99
- # Apply chat template
100
- text = processor.apply_chat_template(
101
- messages,
102
- tokenize=False,
103
- add_generation_prompt=True
104
- )
105
-
106
- # Process vision input
107
- image_inputs, video_inputs = process_vision_info(messages)
108
- inputs = processor(
109
- text=[text],
110
- images=image_inputs,
111
- videos=video_inputs,
112
- padding=True,
113
- return_tensors="pt",
114
- )
115
- inputs = inputs.to("cuda")
116
-
117
- start = time.time()
118
-
119
- # Generate
120
- generated_ids = model.generate(**inputs, max_new_tokens=256)
121
-
122
- latency = time.time() - start
123
-
124
- # Decode output
125
- generated_ids_trimmed = [
126
- out_ids[len(in_ids):] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
127
- ]
128
- output_text = processor.batch_decode(
129
- generated_ids_trimmed,
130
- skip_special_tokens=True,
131
- clean_up_tokenization_spaces=False
132
- )
133
-
134
- output_text = output_text[0] if isinstance(output_text, list) else output_text
135
-
136
- # Clean up GPU memory
137
- del inputs, generated_ids, generated_ids_trimmed
138
- if torch.cuda.is_available():
139
- torch.cuda.empty_cache()
140
-
141
- return output_text, latency
142
-
143
- @staticmethod
144
- def extract_json_from_output(text: str) -> Dict:
145
- """Extract JSON from model output"""
146
- # Handle single/double backticks
147
- if text.count('```') in [1, 2]:
148
- data = text.split('```')[1]
149
- if data.startswith('json'):
150
- data = data[4:]
151
- try:
152
- return json.loads(codecs.decode(data, "unicode-escape"))
153
- except:
154
- pass
155
-
156
- # Try markdown code blocks
157
- markdown_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', text, re.DOTALL)
158
- if markdown_match:
159
- try:
160
- return json.loads(markdown_match.group(1))
161
- except json.JSONDecodeError:
162
- pass
163
-
164
- # Find JSON blocks
165
- json_matches = re.finditer(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', text, re.DOTALL)
166
-
167
- for match in json_matches:
168
- json_str = match.group(0)
169
- try:
170
- parsed = json.loads(json_str)
171
- # Verify expected keys
172
- if all(key in parsed for key in ["dealer_name", "model_name", "horse_power", "asset_cost"]):
173
- return parsed
174
- except json.JSONDecodeError:
175
- continue
176
-
177
- # Fallback
178
- return {
179
- "dealer_name": None,
180
- "model_name": None,
181
- "horse_power": None,
182
- "asset_cost": None
183
- }
184
-
185
- @staticmethod
186
- def clean_text(text) -> str:
187
- """Clean text field"""
188
- if not text:
189
- return None
190
- text = str(text).strip()
191
- text = re.sub(r"\s+", " ", text)
192
- return text if len(text) > 1 else None
193
-
194
- @staticmethod
195
- def clean_number(num):
196
- """Clean number field"""
197
- try:
198
- if num is None:
199
- return None
200
- return int(float(num))
201
- except:
202
- return None
203
-
204
- @staticmethod
205
- def fix_horse_power(vlm_hp, model_name) -> Tuple:
206
- """Fix common HP extraction mistakes"""
207
- # Accept if in valid range
208
- if vlm_hp is not None and HP_VALID_RANGE[0] <= vlm_hp <= HP_VALID_RANGE[1]:
209
- return vlm_hp, 1.0
210
-
211
- # Try extracting from model name
212
- if model_name:
213
- match = re.search(r"HP[- ]?(\d+)", model_name, re.I)
214
- if match:
215
- hp = int(match.group(1))
216
- if HP_VALID_RANGE[0] <= hp <= HP_VALID_RANGE[1]:
217
- return hp, 0.8
218
-
219
- return None, 0.2
220
-
221
- @staticmethod
222
- def validate_asset_cost(cost) -> Tuple:
223
- """Validate asset cost"""
224
- if cost is None:
225
- return None, 0.2
226
-
227
- cost = InferenceProcessor.clean_number(cost)
228
-
229
- if ASSET_COST_VALID_RANGE[0] <= cost <= ASSET_COST_VALID_RANGE[1]:
230
- return cost, 1.0
231
-
232
- return None, 0.3
233
-
234
- @staticmethod
235
- def validate_text_field(text) -> Tuple:
236
- """Validate text fields"""
237
- text = InferenceProcessor.clean_text(text)
238
- if not text or len(text) < 3:
239
- return None, 0.3
240
- return text, 1.0
241
-
242
- @staticmethod
243
- def validate_prediction(raw_json: Dict) -> Tuple[Dict, float, list]:
244
- """Validate and fix extracted fields"""
245
- warnings = []
246
- confidences = []
247
-
248
- # Dealer
249
- dealer, dealer_conf = InferenceProcessor.validate_text_field(raw_json.get("dealer_name"))
250
- if dealer is None:
251
- warnings.append("Dealer name invalid")
252
- confidences.append(dealer_conf)
253
-
254
- # Model
255
- model_name, model_conf = InferenceProcessor.validate_text_field(raw_json.get("model_name"))
256
- if model_name is None:
257
- warnings.append("Model name invalid")
258
- confidences.append(model_conf)
259
-
260
- # Horse Power
261
- hp_raw = InferenceProcessor.clean_number(raw_json.get("horse_power"))
262
- hp, hp_conf = InferenceProcessor.fix_horse_power(hp_raw, model_name)
263
- if hp is None:
264
- warnings.append("Horse power invalid")
265
- confidences.append(hp_conf)
266
-
267
- # Asset Cost
268
- cost_raw = InferenceProcessor.clean_number(raw_json.get("asset_cost"))
269
- cost, cost_conf = InferenceProcessor.validate_asset_cost(cost_raw)
270
- if cost is None:
271
- warnings.append("Asset cost invalid")
272
- confidences.append(cost_conf)
273
-
274
- # Overall field confidence
275
- field_confidence = round(sum(confidences) / len(confidences), 3)
276
-
277
- validated = {
278
- "dealer_name": dealer,
279
- "model_name": model_name,
280
- "horse_power": hp,
281
- "asset_cost": cost
282
- }
283
-
284
- return validated, field_confidence, warnings
285
-
286
- @staticmethod
287
- def process_invoice(image_path: str, doc_id: str = None) -> Dict:
288
- """
289
- Complete invoice processing pipeline
290
-
291
- Args:
292
- image_path: Path to invoice image
293
- doc_id: Document identifier (optional)
294
-
295
- Returns:
296
- dict: Complete JSON output with all fields
297
- """
298
- total_start = time.time()
299
-
300
- # Generate doc_id if not provided
301
- if doc_id is None:
302
- import os
303
- doc_id = os.path.splitext(os.path.basename(image_path))[0]
304
-
305
- # Step 1: Preprocess image
306
- image = InferenceProcessor.preprocess_image(image_path)
307
-
308
- # Step 2: YOLO Detection
309
- signature_info, stamp_info, signature_conf, stamp_conf = model_manager.detect_sign_stamp(image_path)
310
-
311
- # Step 3: VLM Extraction
312
- vlm_output, vlm_latency = InferenceProcessor.run_vlm_extraction(image)
313
-
314
- # Clean up image
315
- image.close()
316
- del image
317
-
318
- # Step 4: Parse JSON
319
- raw_json = InferenceProcessor.extract_json_from_output(vlm_output)
320
-
321
- # Step 5: Validate and fix
322
- validated_fields, field_confidence, warnings = InferenceProcessor.validate_prediction(raw_json)
323
-
324
- # Add signature and stamp
325
- validated_fields["signature"] = signature_info
326
- validated_fields["stamp"] = stamp_info
327
-
328
- # Calculate overall confidence
329
- confidences = [field_confidence]
330
- if signature_info["present"]:
331
- confidences.append(signature_conf)
332
- if stamp_info["present"]:
333
- confidences.append(stamp_conf)
334
-
335
- overall_confidence = round(sum(confidences) / len(confidences), 3)
336
-
337
- # Calculate time and cost
338
- total_time = time.time() - total_start
339
- cost_estimate = (COST_PER_GPU_HOUR * total_time) / 3600
340
-
341
- # Build result
342
- result = {
343
- "doc_id": doc_id,
344
- "fields": validated_fields,
345
- "confidence": overall_confidence,
346
- "processing_time_sec": round(total_time, 2),
347
- "cost_estimate_usd": round(cost_estimate, 6),
348
- "warnings": warnings if warnings else None
349
- }
350
-
351
- return result
 
1
+ """
2
+ Inference Processor - Handles VLM extraction, validation, and result formatting
3
+ """
4
+
5
+ import torch
6
+ import time
7
+ import json
8
+ import codecs
9
+ import re
10
+ from PIL import Image
11
+ from qwen_vl_utils import process_vision_info
12
+ from typing import Dict, Tuple
13
+
14
+ from config import (
15
+ MAX_IMAGE_SIZE,
16
+ HP_VALID_RANGE,
17
+ ASSET_COST_VALID_RANGE,
18
+ COST_PER_GPU_HOUR
19
+ )
20
+ from model_manager import model_manager
21
+
22
+
23
+ EXTRACTION_PROMPT = """
24
+ You are an expert at reading noisy, handwritten Indian invoices and quotations.
25
+
26
+ Your task is to extract text EXACTLY as it appears in the image.
27
+ Do NOT translate, summarize, normalize, or rewrite any text.
28
+ Preserve the original language (Hindi, Marathi, Kannada, English, etc.).
29
+
30
+ Carefully read the image and extract the following fields.
31
+
32
+ Return ONLY valid JSON in this format:
33
+
34
+ {
35
+ "dealer_name": string,
36
+ "model_name": string,
37
+ "horse_power": number,
38
+ "asset_cost": number
39
+ }
40
+
41
+ Critical rules:
42
+ - Dealer name must be copied exactly from the image in the original language and spelling.
43
+ - Model name must be copied exactly from the image without translation.
44
+ - Do NOT convert regional language text into English.
45
+ - Do NOT expand abbreviations or correct spelling.
46
+ - Only numbers may be normalized.
47
+
48
+ Extraction hints:
49
+ - Asset cost is the total amount, usually the largest number on the page, the total amount after TAX, final price or final cost.
50
+ - Dealer name is usually at the top header or company name.
51
+ - Model name often appears near words like Model, Tractor, Variant.
52
+ - Horse power must come ONLY from explicit HP text, never from model numbers.
53
+ - Horse power may appear as "HP", handwritten like "49 HP", "63hp", "HP-30".
54
+ - Remove commas and currency symbols from numbers only.
55
+ - If handwriting is unclear, make your best reasonable interpretation of the characters — but preserve language.
56
+
57
+ Output rules:
58
+ - Output ONLY valid JSON.
59
+ - Do NOT include markdown, explanations, or extra text.
60
+ """
61
+
62
+
63
+ class InferenceProcessor:
64
+ """Handles VLM inference, validation, and result processing"""
65
+
66
+ @staticmethod
67
+ def preprocess_image(image_path: str) -> Image.Image:
68
+ """Load and resize image if needed"""
69
+ image = Image.open(image_path).convert("RGB")
70
+
71
+ # Resize if too large
72
+ if max(image.size) > MAX_IMAGE_SIZE:
73
+ ratio = MAX_IMAGE_SIZE / max(image.size)
74
+ new_size = (int(image.size[0] * ratio), int(image.size[1] * ratio))
75
+ image = image.resize(new_size, Image.LANCZOS)
76
+ print(f"🔄 Image resized to {new_size}")
77
+
78
+ return image
79
+
80
+ @staticmethod
81
+ def run_vlm_extraction(image: Image.Image) -> Tuple[str, float]:
82
+ """Run VLM model to extract invoice fields"""
83
+ if not model_manager.is_loaded():
84
+ raise RuntimeError("Models not loaded")
85
+
86
+ model = model_manager.vlm_model
87
+ processor = model_manager.processor
88
+
89
+ messages = [
90
+ {
91
+ "role": "user",
92
+ "content": [
93
+ {"type": "image", "image": image},
94
+ {"type": "text", "text": EXTRACTION_PROMPT}
95
+ ]
96
+ }
97
+ ]
98
+
99
+ # Apply chat template
100
+ text = processor.apply_chat_template(
101
+ messages,
102
+ tokenize=False,
103
+ add_generation_prompt=True
104
+ )
105
+
106
+ # Process vision input
107
+ image_inputs, video_inputs = process_vision_info(messages)
108
+ inputs = processor(
109
+ text=[text],
110
+ images=image_inputs,
111
+ videos=video_inputs,
112
+ padding=True,
113
+ return_tensors="pt",
114
+ )
115
+ inputs = inputs.to("cuda")
116
+
117
+ start = time.time()
118
+
119
+ # Generate
120
+ generated_ids = model.generate(**inputs, max_new_tokens=256)
121
+
122
+ latency = time.time() - start
123
+
124
+ # Decode output
125
+ generated_ids_trimmed = [
126
+ out_ids[len(in_ids):] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
127
+ ]
128
+ output_text = processor.batch_decode(
129
+ generated_ids_trimmed,
130
+ skip_special_tokens=True,
131
+ clean_up_tokenization_spaces=False
132
+ )
133
+
134
+ output_text = output_text[0] if isinstance(output_text, list) else output_text
135
+
136
+ # Clean up GPU memory
137
+ del inputs, generated_ids, generated_ids_trimmed
138
+ if torch.cuda.is_available():
139
+ torch.cuda.empty_cache()
140
+
141
+ return output_text, latency
142
+
143
+ @staticmethod
144
+ def extract_json_from_output(text: str) -> Dict:
145
+ """Extract JSON from model output"""
146
+ # Handle single/double backticks
147
+ if text.count('```') in [1, 2]:
148
+ data = text.split('```')[1]
149
+ if data.startswith('json'):
150
+ data = data[4:]
151
+ try:
152
+ return json.loads(data.strip())
153
+ except:
154
+ pass
155
+
156
+ # Try markdown code blocks
157
+ markdown_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', text, re.DOTALL)
158
+ if markdown_match:
159
+ try:
160
+ return json.loads(markdown_match.group(1))
161
+ except json.JSONDecodeError:
162
+ pass
163
+
164
+ # Find JSON blocks
165
+ json_matches = re.finditer(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', text, re.DOTALL)
166
+
167
+ for match in json_matches:
168
+ json_str = match.group(0)
169
+ try:
170
+ parsed = json.loads(json_str)
171
+ # Verify expected keys
172
+ if all(key in parsed for key in ["dealer_name", "model_name", "horse_power", "asset_cost"]):
173
+ return parsed
174
+ except json.JSONDecodeError:
175
+ continue
176
+
177
+ # Fallback
178
+ return {
179
+ "dealer_name": None,
180
+ "model_name": None,
181
+ "horse_power": None,
182
+ "asset_cost": None
183
+ }
184
+
185
+ @staticmethod
186
+ def clean_text(text) -> str:
187
+ """Clean text field"""
188
+ if not text:
189
+ return None
190
+ text = str(text).strip()
191
+ text = re.sub(r"\s+", " ", text)
192
+ return text if len(text) > 1 else None
193
+
194
+ @staticmethod
195
+ def clean_number(num):
196
+ """Clean number field"""
197
+ try:
198
+ if num is None:
199
+ return None
200
+ return int(float(num))
201
+ except:
202
+ return None
203
+
204
+ @staticmethod
205
+ def fix_horse_power(vlm_hp, model_name) -> Tuple:
206
+ """Fix common HP extraction mistakes"""
207
+ # Accept if in valid range
208
+ if vlm_hp is not None and HP_VALID_RANGE[0] <= vlm_hp <= HP_VALID_RANGE[1]:
209
+ return vlm_hp, 1.0
210
+
211
+ # Try extracting from model name
212
+ if model_name:
213
+ match = re.search(r"HP[- ]?(\d+)", model_name, re.I)
214
+ if match:
215
+ hp = int(match.group(1))
216
+ if HP_VALID_RANGE[0] <= hp <= HP_VALID_RANGE[1]:
217
+ return hp, 0.8
218
+
219
+ return None, 0.2
220
+
221
+ @staticmethod
222
+ def validate_asset_cost(cost) -> Tuple:
223
+ """Validate asset cost"""
224
+ if cost is None:
225
+ return None, 0.2
226
+
227
+ cost = InferenceProcessor.clean_number(cost)
228
+
229
+ if ASSET_COST_VALID_RANGE[0] <= cost <= ASSET_COST_VALID_RANGE[1]:
230
+ return cost, 1.0
231
+
232
+ return None, 0.3
233
+
234
+ @staticmethod
235
+ def validate_text_field(text) -> Tuple:
236
+ """Validate text fields"""
237
+ text = InferenceProcessor.clean_text(text)
238
+ if not text or len(text) < 3:
239
+ return None, 0.3
240
+ return text, 1.0
241
+
242
+ @staticmethod
243
+ def validate_prediction(raw_json: Dict) -> Tuple[Dict, float, list]:
244
+ """Validate and fix extracted fields"""
245
+ warnings = []
246
+ confidences = []
247
+
248
+ # Dealer
249
+ dealer, dealer_conf = InferenceProcessor.validate_text_field(raw_json.get("dealer_name"))
250
+ if dealer is None:
251
+ warnings.append("Dealer name invalid")
252
+ confidences.append(dealer_conf)
253
+
254
+ # Model
255
+ model_name, model_conf = InferenceProcessor.validate_text_field(raw_json.get("model_name"))
256
+ if model_name is None:
257
+ warnings.append("Model name invalid")
258
+ confidences.append(model_conf)
259
+
260
+ # Horse Power
261
+ hp_raw = InferenceProcessor.clean_number(raw_json.get("horse_power"))
262
+ hp, hp_conf = InferenceProcessor.fix_horse_power(hp_raw, model_name)
263
+ if hp is None:
264
+ warnings.append("Horse power invalid")
265
+ confidences.append(hp_conf)
266
+
267
+ # Asset Cost
268
+ cost_raw = InferenceProcessor.clean_number(raw_json.get("asset_cost"))
269
+ cost, cost_conf = InferenceProcessor.validate_asset_cost(cost_raw)
270
+ if cost is None:
271
+ warnings.append("Asset cost invalid")
272
+ confidences.append(cost_conf)
273
+
274
+ # Overall field confidence
275
+ field_confidence = round(sum(confidences) / len(confidences), 3)
276
+
277
+ validated = {
278
+ "dealer_name": dealer,
279
+ "model_name": model_name,
280
+ "horse_power": hp,
281
+ "asset_cost": cost
282
+ }
283
+
284
+ return validated, field_confidence, warnings
285
+
286
+ @staticmethod
287
+ def process_invoice(image_path: str, doc_id: str = None) -> Dict:
288
+ """
289
+ Complete invoice processing pipeline
290
+
291
+ Args:
292
+ image_path: Path to invoice image
293
+ doc_id: Document identifier (optional)
294
+
295
+ Returns:
296
+ dict: Complete JSON output with all fields
297
+ """
298
+ total_start = time.time()
299
+
300
+ # Generate doc_id if not provided
301
+ if doc_id is None:
302
+ import os
303
+ doc_id = os.path.splitext(os.path.basename(image_path))[0]
304
+
305
+ # Step 1: Preprocess image
306
+ image = InferenceProcessor.preprocess_image(image_path)
307
+
308
+ # Step 2: YOLO Detection
309
+ signature_info, stamp_info, signature_conf, stamp_conf = model_manager.detect_sign_stamp(image_path)
310
+
311
+ # Step 3: VLM Extraction
312
+ vlm_output, vlm_latency = InferenceProcessor.run_vlm_extraction(image)
313
+
314
+ # Clean up image
315
+ image.close()
316
+ del image
317
+
318
+ # Step 4: Parse JSON
319
+ raw_json = InferenceProcessor.extract_json_from_output(vlm_output)
320
+
321
+ # Step 5: Validate and fix
322
+ validated_fields, field_confidence, warnings = InferenceProcessor.validate_prediction(raw_json)
323
+
324
+ # Add signature and stamp
325
+ validated_fields["signature"] = signature_info
326
+ validated_fields["stamp"] = stamp_info
327
+
328
+ # Calculate overall confidence
329
+ confidences = [field_confidence]
330
+ if signature_info["present"]:
331
+ confidences.append(signature_conf)
332
+ if stamp_info["present"]:
333
+ confidences.append(stamp_conf)
334
+
335
+ overall_confidence = round(sum(confidences) / len(confidences), 3)
336
+
337
+ # Calculate time and cost
338
+ total_time = time.time() - total_start
339
+ cost_estimate = (COST_PER_GPU_HOUR * total_time) / 3600
340
+
341
+ # Build result
342
+ result = {
343
+ "doc_id": doc_id,
344
+ "fields": validated_fields,
345
+ "confidence": overall_confidence,
346
+ "processing_time_sec": round(total_time, 2),
347
+ "cost_estimate_usd": round(cost_estimate, 6),
348
+ "warnings": warnings if warnings else None
349
+ }
350
+
351
+ return result