@cdklabs/cdk-appmod-catalog-blueprints 1.16.1 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.jsii +960 -143
  2. package/lib/document-processing/adapter/queued-s3-adapter.js +1 -1
  3. package/lib/document-processing/agentic-document-processing.js +1 -1
  4. package/lib/document-processing/base-document-processing.js +1 -1
  5. package/lib/document-processing/bedrock-document-processing.d.ts +1 -0
  6. package/lib/document-processing/bedrock-document-processing.js +10 -6
  7. package/lib/document-processing/default-document-processing-config.js +1 -1
  8. package/lib/document-processing/index.d.ts +2 -0
  9. package/lib/document-processing/index.js +3 -1
  10. package/lib/document-processing/localstack-agentic-document-processing.d.ts +20 -0
  11. package/lib/document-processing/localstack-agentic-document-processing.js +66 -0
  12. package/lib/document-processing/localstack-bedrock-document-processing.d.ts +16 -0
  13. package/lib/document-processing/localstack-bedrock-document-processing.js +38 -0
  14. package/lib/document-processing/resources/cleanup/handler.py +16 -1
  15. package/lib/document-processing/resources/default-localstack-invoke/index.py +184 -0
  16. package/lib/document-processing/resources/default-localstack-invoke/provider_runtime.py +251 -0
  17. package/lib/document-processing/resources/default-localstack-invoke/requirements.txt +5 -0
  18. package/lib/document-processing/resources/default-sqs-consumer/index.py +17 -2
  19. package/lib/document-processing/resources/pdf-chunking/handler.py +16 -1
  20. package/lib/document-processing/tests/localstack-agentic-document-processing.test.d.ts +1 -0
  21. package/lib/document-processing/tests/localstack-agentic-document-processing.test.js +78 -0
  22. package/lib/document-processing/tests/localstack-document-processing.test.d.ts +1 -0
  23. package/lib/document-processing/tests/localstack-document-processing.test.js +116 -0
  24. package/lib/framework/agents/base-agent.js +1 -1
  25. package/lib/framework/agents/batch-agent.d.ts +1 -0
  26. package/lib/framework/agents/batch-agent.js +7 -4
  27. package/lib/framework/agents/default-agent-config.js +1 -1
  28. package/lib/framework/agents/index.d.ts +1 -0
  29. package/lib/framework/agents/index.js +2 -1
  30. package/lib/framework/agents/interactive-agent.js +10 -10
  31. package/lib/framework/agents/knowledge-base/base-knowledge-base.js +1 -1
  32. package/lib/framework/agents/knowledge-base/bedrock-knowledge-base.js +1 -1
  33. package/lib/framework/agents/localstack-batch-agent.d.ts +15 -0
  34. package/lib/framework/agents/localstack-batch-agent.js +33 -0
  35. package/lib/framework/agents/resources/default-ollama-agent/batch.py +396 -0
  36. package/lib/framework/agents/resources/default-ollama-agent/models.py +7 -0
  37. package/lib/framework/agents/resources/default-ollama-agent/requirements.txt +9 -0
  38. package/lib/framework/agents/resources/default-ollama-agent/runtime_support.py +237 -0
  39. package/lib/framework/agents/resources/default-ollama-agent/utils.py +77 -0
  40. package/lib/framework/bedrock/bedrock.d.ts +9 -0
  41. package/lib/framework/bedrock/bedrock.js +20 -10
  42. package/lib/framework/custom-resource/default-runtimes.js +1 -1
  43. package/lib/framework/foundation/access-log.js +1 -1
  44. package/lib/framework/foundation/eventbridge-broker.js +1 -1
  45. package/lib/framework/foundation/network.js +1 -1
  46. package/lib/framework/index.d.ts +1 -0
  47. package/lib/framework/index.js +2 -1
  48. package/lib/framework/localstack/index.d.ts +1 -0
  49. package/lib/framework/localstack/index.js +18 -0
  50. package/lib/framework/localstack/localstack-config.d.ts +79 -0
  51. package/lib/framework/localstack/localstack-config.js +49 -0
  52. package/lib/framework/tests/localstack-batch-agent.test.d.ts +1 -0
  53. package/lib/framework/tests/localstack-batch-agent.test.js +67 -0
  54. package/lib/tsconfig.tsbuildinfo +1 -1
  55. package/lib/utilities/data-loader.js +1 -1
  56. package/lib/utilities/lambda-iam-utils.js +1 -1
  57. package/lib/utilities/observability/cloudfront-distribution-observability-property-injector.js +1 -1
  58. package/lib/utilities/observability/cloudwatch-transaction-search.js +1 -1
  59. package/lib/utilities/observability/default-observability-config.js +1 -1
  60. package/lib/utilities/observability/lambda-observability-property-injector.js +1 -1
  61. package/lib/utilities/observability/log-group-data-protection-utils.js +1 -1
  62. package/lib/utilities/observability/powertools-config.js +1 -1
  63. package/lib/utilities/observability/state-machine-observability-property-injector.js +1 -1
  64. package/lib/webapp/frontend-construct.js +1 -1
  65. package/package.json +1 -1
@@ -0,0 +1,396 @@
1
+ """
2
+ Batch agent Lambda handler for processing documents with AI agents.
3
+
4
+ This module provides the Lambda handler for batch processing of documents
5
+ using Amazon Bedrock agents with tool integration.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ from enum import Enum
12
+ from typing import Any, Dict, Optional
13
+
14
+ from aws_lambda_powertools import Logger, Metrics, Tracer
15
+ from aws_lambda_powertools.metrics import MetricUnit
16
+ from strands import Agent
17
+ from strands_tools import file_read
18
+
19
+ import runtime_support
20
+ from utils import (
21
+ convert_tools_config_into_model,
22
+ create_boto3_client,
23
+ download_and_load_system_prompt,
24
+ download_tools,
25
+ )
26
+
27
+
28
+ class ContentType(str, Enum):
29
+ """
30
+ Document content type enumeration.
31
+
32
+ Defines how document content is provided to the agent.
33
+ """
34
+ FILE = "file" # Document stored in S3 or file system
35
+ DATA = "data" # Document content provided inline
36
+
37
+
38
+ class ContentLocation(str, Enum):
39
+ """
40
+ Document storage location enumeration.
41
+
42
+ Defines where file-based documents are stored.
43
+ """
44
+ S3 = "s3" # Document stored in Amazon S3
45
+
46
+
47
+ class InvokeType(str, Enum):
48
+ """
49
+ Agent invocation type enumeration.
50
+
51
+ Defines the processing mode for the agent.
52
+ """
53
+ BATCH = "batch" # Batch processing (one document at a time)
54
+ INTERACTIVE = "interactive" # Interactive conversation mode (future)
55
+ ATTACH_DIRECTLY = "attach-directly" # Direct invocation (e.g., RAG, API endpoints)
56
+ CLASSIFICATION = "classification" # Document classification step
57
+ PROCESSING = "processing" # Document processing step
58
+ AGGREGATION = "aggregation" # Document aggregation step
59
+
60
+ # Initialize AWS Lambda Powertools
61
+ logger = Logger()
62
+ metrics = Metrics()
63
+ tracer = Tracer()
64
+
65
+ # Initialize AWS clients
66
+ s3_client = create_boto3_client('s3', 'AWS_ENDPOINT_URL_S3')
67
+
68
+ # Load configuration at module initialization
69
+ TOOLS_CONFIG = convert_tools_config_into_model(os.getenv('TOOLS_CONFIG', '{}'))
70
+ AGENT_TOOLS = download_tools(TOOLS_CONFIG)
71
+ SYSTEM_PROMPT = download_and_load_system_prompt(
72
+ os.environ['SYSTEM_PROMPT_S3_BUCKET_NAME'],
73
+ os.environ['SYSTEM_PROMPT_S3_KEY']
74
+ )
75
+
76
+
77
+ def extract_json_from_text(text: str) -> Optional[Dict[str, Any]]:
78
+ """
79
+ Extract JSON object from text containing JSON in code blocks or raw format.
80
+
81
+ Attempts to extract JSON in the following order:
82
+ 1. JSON within ```json code blocks
83
+ 2. Raw JSON objects in the text
84
+
85
+ Args:
86
+ text: Text potentially containing JSON data
87
+
88
+ Returns:
89
+ Parsed JSON dictionary if found, None otherwise
90
+ """
91
+ return runtime_support.extract_json_from_text(text, logger)
92
+
93
+
94
+ def download_document_from_s3(bucket: str, key: str) -> str:
95
+ """
96
+ Download a document from S3 to local /tmp directory.
97
+
98
+ Args:
99
+ bucket: S3 bucket name
100
+ key: S3 object key
101
+
102
+ Returns:
103
+ Local file path where document was downloaded
104
+
105
+ Raises:
106
+ ClientError: If S3 download fails
107
+ """
108
+ filename = key.split('/')[-1]
109
+ local_path = f"/tmp/{filename}"
110
+
111
+ logger.info("Downloading document from S3", extra={
112
+ "bucket": bucket,
113
+ "key": key,
114
+ "local_path": local_path
115
+ })
116
+
117
+ s3_client.download_file(bucket, key, local_path)
118
+ return local_path
119
+
120
+
121
+ def build_agent_prompt(base_prompt: str, event: Dict[str, Any], invoke_type: InvokeType) -> str:
122
+ """
123
+ Build the complete agent prompt from base prompt and event data.
124
+
125
+ Args:
126
+ base_prompt: Base prompt template
127
+ event: Lambda event containing document and classification data
128
+
129
+ Returns:
130
+ Complete prompt with document information and classification
131
+
132
+ Raises:
133
+ ValueError: If content_type is invalid or required fields are missing
134
+ """
135
+ prompt = base_prompt or "Analyze the attached document and verify the information using the provided tools."
136
+
137
+ # Replace classification placeholder if present
138
+ if 'classificationResult' in event:
139
+ classification = event['classificationResult']['documentClassification']
140
+ prompt = prompt.replace("[ACTUAL_CLASSIFICATION]", classification)
141
+ logger.info("Added classification to prompt", extra={"classification": classification})
142
+
143
+ # Validate and process content based on type
144
+ content_type_str = event.get('contentType', '')
145
+ content = event.get('content', {})
146
+
147
+ try:
148
+ content_type = ContentType(content_type_str)
149
+ except ValueError:
150
+ logger.error("Invalid content type", extra={
151
+ "content_type": content_type_str,
152
+ "valid_types": [ct.value for ct in ContentType]
153
+ })
154
+ raise ValueError(
155
+ f"Invalid content_type '{content_type_str}'. "
156
+ f"Must be one of: {', '.join(ct.value for ct in ContentType)}"
157
+ )
158
+
159
+ # Process based on content type
160
+ if content_type == ContentType.FILE:
161
+ location = content.get('location', '')
162
+
163
+ # Validate location
164
+ try:
165
+ location_enum = ContentLocation(location)
166
+ except ValueError:
167
+ logger.error("Invalid content location", extra={
168
+ "location": location,
169
+ "valid_locations": [loc.value for loc in ContentLocation]
170
+ })
171
+ raise ValueError(
172
+ f"Invalid content location '{location}'. "
173
+ f"Must be one of: {', '.join(loc.value for loc in ContentLocation)}"
174
+ )
175
+
176
+ if location_enum == ContentLocation.S3:
177
+ if 'bucket' not in content or 'key' not in content:
178
+ raise ValueError("S3 content must include 'bucket' and 'key' fields")
179
+
180
+ local_path = download_document_from_s3(content['bucket'], content['key'])
181
+ prompt += f" Attached document is located in {local_path}"
182
+ logger.info("Added file location to prompt", extra={"path": local_path})
183
+
184
+ elif content_type == ContentType.DATA:
185
+ if 'data' not in content:
186
+ raise ValueError("Data content must include 'data' field")
187
+
188
+ if invoke_type == InvokeType.BATCH:
189
+ prompt += f" Attached document content are as follows: {content['data']}"
190
+ elif invoke_type == InvokeType.ATTACH_DIRECTLY:
191
+ prompt += f" {content['data']}"
192
+ logger.info("Added inline data to prompt")
193
+
194
+ return prompt
195
+
196
+
197
+ @metrics.log_metrics
198
+ @tracer.capture_lambda_handler
199
+ def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
200
+ """
201
+ Lambda handler for batch agent processing.
202
+
203
+ Processes documents using an AI agent with tool integration.
204
+ Supports both file-based (S3) and inline data inputs.
205
+
206
+ Args:
207
+ event: Lambda event containing:
208
+ - contentType: 'file' or 'data' (see ContentType enum)
209
+ - content: Document location or inline data
210
+ - classificationResult: Optional document classification
211
+ context: Lambda context object
212
+
213
+ Returns:
214
+ Dictionary with 'result' key containing agent response
215
+ (parsed as JSON if EXPECT_JSON is enabled)
216
+
217
+ Raises:
218
+ KeyError: If required environment variables are missing
219
+ ValueError: If event contains invalid content_type or missing required fields
220
+ Exception: If agent invocation fails
221
+ """
222
+ # Load configuration
223
+ runtime_support.configure_provider_runtime()
224
+ model_id = os.getenv("MODEL_ID")
225
+ model = runtime_support.build_agent_model(model_id)
226
+ base_prompt = os.getenv("PROMPT")
227
+ expect_json = bool(os.getenv("EXPECT_JSON", ""))
228
+ output_contract = runtime_support.resolve_output_contract()
229
+ invoke_type_str = os.environ.get("INVOKE_TYPE", InvokeType.BATCH.value)
230
+
231
+ # Validate invoke type
232
+ try:
233
+ invoke_type = InvokeType(invoke_type_str)
234
+ except ValueError:
235
+ logger.warning("Invalid invoke type, using default", extra={
236
+ "invoke_type": invoke_type_str,
237
+ "default": InvokeType.BATCH.value
238
+ })
239
+ invoke_type = InvokeType.BATCH
240
+
241
+ # Add observability metadata
242
+ tracer.put_annotation(key="invoke_type", value=invoke_type.value)
243
+ tracer.put_annotation(key="expect_json", value=expect_json)
244
+ metrics.add_dimension(name="invoke_type", value=invoke_type.value)
245
+ metrics.add_dimension(name="output_contract", value=output_contract)
246
+
247
+ logger.info("Processing batch agent request", extra={
248
+ "model_id": model_id,
249
+ "expect_json": expect_json,
250
+ "output_contract": output_contract,
251
+ "invoke_type": invoke_type.value,
252
+ "content_type": event.get('contentType')
253
+ })
254
+
255
+ try:
256
+ # Build complete prompt (validates content_type and structure)
257
+ prompt = build_agent_prompt(base_prompt, event, invoke_type)
258
+ if runtime_support.debug_agent_payload_enabled():
259
+ logger.info(
260
+ "Agent payload debug",
261
+ extra={
262
+ "system_prompt_preview": runtime_support.truncate_for_debug(SYSTEM_PROMPT),
263
+ "prompt_preview": runtime_support.truncate_for_debug(prompt),
264
+ },
265
+ )
266
+
267
+ # Initialize and invoke agent
268
+ agent = Agent(
269
+ model=model,
270
+ tools=AGENT_TOOLS + [file_read],
271
+ system_prompt=SYSTEM_PROMPT
272
+ )
273
+
274
+ logger.info("Invoking agent", extra={"prompt_length": len(prompt)})
275
+ response = agent(prompt)
276
+
277
+ message_payload = runtime_support.extract_response_message(response)
278
+ if runtime_support.debug_agent_payload_enabled():
279
+ logger.info(
280
+ "Agent response payload debug",
281
+ extra={
282
+ "message_preview": runtime_support.truncate_for_debug(json.dumps(message_payload)),
283
+ },
284
+ )
285
+ if runtime_support.debug_agent_tool_flow_enabled():
286
+ logger.info(
287
+ "Agent tool flow summary",
288
+ extra=runtime_support.summarize_tool_usage_from_message(message_payload),
289
+ )
290
+
291
+ # Extract response text
292
+ response_text = runtime_support.extract_response_text(response)
293
+ logger.info("Agent invocation successful", extra={
294
+ "response_length": len(response_text)
295
+ })
296
+
297
+ # Record success metric
298
+ metrics.add_metric(
299
+ name="SuccessfulAgentInvocation",
300
+ unit=MetricUnit.Count,
301
+ value=1
302
+ )
303
+
304
+ # Parse JSON if requested
305
+ if expect_json:
306
+ result = extract_json_from_text(response_text)
307
+ if result is None or runtime_support.is_empty_assistant_payload(result):
308
+ logger.warning(
309
+ "Expected JSON but final response was empty or non-JSON; requesting final JSON retry",
310
+ extra={
311
+ "initial_response_length": len(response_text),
312
+ "initial_response_preview": response_text[:500],
313
+ },
314
+ )
315
+ retry_response = agent(runtime_support.build_final_json_retry_prompt())
316
+ retry_text = runtime_support.extract_response_text(retry_response)
317
+ retry_message_payload = runtime_support.extract_response_message(retry_response)
318
+ if runtime_support.debug_agent_payload_enabled():
319
+ logger.info(
320
+ "Agent retry payload debug",
321
+ extra={
322
+ "retry_message_preview": runtime_support.truncate_for_debug(json.dumps(retry_message_payload)),
323
+ },
324
+ )
325
+ if runtime_support.debug_agent_tool_flow_enabled():
326
+ logger.info(
327
+ "Agent retry tool flow summary",
328
+ extra=runtime_support.summarize_tool_usage_from_message(retry_message_payload),
329
+ )
330
+ retry_result = extract_json_from_text(retry_text)
331
+
332
+ if retry_result is None or runtime_support.is_empty_assistant_payload(retry_result):
333
+ logger.warning(
334
+ "Retry did not return valid JSON",
335
+ extra={
336
+ "retry_response_length": len(retry_text),
337
+ "retry_response_preview": retry_text[:500],
338
+ },
339
+ )
340
+ result = {
341
+ "raw_response": retry_text,
342
+ "initial_response": response_text,
343
+ }
344
+ else:
345
+ result = retry_result
346
+
347
+ if (
348
+ runtime_support.is_fraud_output_contract_enabled()
349
+ and isinstance(result, dict)
350
+ and not runtime_support.is_complete_fraud_result(result)
351
+ ):
352
+ logger.warning(
353
+ "Result JSON missing required fraud schema fields; forcing a required-tools retry",
354
+ extra={
355
+ "result_preview": runtime_support.truncate_for_debug(json.dumps(result)),
356
+ },
357
+ )
358
+ schema_retry_response = agent(runtime_support.build_required_tools_retry_prompt())
359
+ schema_retry_text = runtime_support.extract_response_text(schema_retry_response)
360
+ schema_retry_result = extract_json_from_text(schema_retry_text)
361
+
362
+ if schema_retry_result is None or not runtime_support.is_complete_fraud_result(schema_retry_result):
363
+ raise ValueError(
364
+ "Agent response did not include required fraud schema after required-tools retry",
365
+ )
366
+
367
+ result = schema_retry_result
368
+ else:
369
+ result = response_text
370
+
371
+ return {"result": result}
372
+
373
+ except ValueError as e:
374
+ # Validation errors (invalid content_type, missing fields, etc.)
375
+ logger.error("Validation error", extra={
376
+ "error": str(e),
377
+ "error_type": "ValidationError"
378
+ })
379
+ metrics.add_metric(
380
+ name="ValidationError",
381
+ unit=MetricUnit.Count,
382
+ value=1
383
+ )
384
+ raise
385
+
386
+ except Exception as e:
387
+ logger.error("Agent invocation failed", extra={
388
+ "error": str(e),
389
+ "error_type": type(e).__name__
390
+ })
391
+ metrics.add_metric(
392
+ name="FailedAgentInvocation",
393
+ unit=MetricUnit.Count,
394
+ value=1
395
+ )
396
+ raise
@@ -0,0 +1,7 @@
1
+ from pydantic import BaseModel
2
+
3
+ class ToolLocationDefinition(BaseModel):
4
+ bucketName: str
5
+ key: str
6
+ isFile: bool
7
+ isZipArchive: bool
@@ -0,0 +1,9 @@
1
+ strands-agents>=1.0.0
2
+ ollama>=0.4.8,<1.0.0
3
+ boto3>=1.0.0
4
+ strands-agents-tools[file_read]
5
+ urllib3>=1.26.0,<2.0.0
6
+ aws-lambda-powertools
7
+ aws-xray-sdk
8
+ pydantic
9
+ aws-opentelemetry-distro
@@ -0,0 +1,237 @@
1
+ import json
2
+ import os
3
+ import re
4
+ from typing import Any, Dict, Optional
5
+
6
+ MODEL_PROVIDER_BEDROCK = 'bedrock'
7
+ MODEL_PROVIDER_OLLAMA = 'ollama'
8
+ OUTPUT_CONTRACT_NONE = 'none'
9
+ OUTPUT_CONTRACT_FRAUD_V1 = 'fraud_v1'
10
+ MAX_DEBUG_PAYLOAD_CHARS = int(os.getenv('DEBUG_AGENT_MAX_CHARS', '4000'))
11
+ REQUIRED_FRAUD_RESULT_KEYS = {'risk_score', 'risk_level', 'findings', 'indicators', 'recommended_actions'}
12
+ REQUIRED_FRAUD_FINDINGS_KEYS = {'metadata_analysis', 'pattern_matches', 'anomalies', 'database_checks'}
13
+
14
+
15
+ def _is_truthy_env(var_name: str) -> bool:
16
+ return os.getenv(var_name, '').strip().lower() in {'1', 'true', 'yes', 'on'}
17
+
18
+
19
+ def debug_agent_payload_enabled() -> bool:
20
+ return _is_truthy_env('DEBUG_AGENT_PAYLOAD')
21
+
22
+
23
+ def debug_agent_tool_flow_enabled() -> bool:
24
+ return _is_truthy_env('DEBUG_AGENT_TOOL_FLOW')
25
+
26
+
27
+ def truncate_for_debug(text: str, max_chars: int = MAX_DEBUG_PAYLOAD_CHARS) -> str:
28
+ if len(text) <= max_chars:
29
+ return text
30
+ return f'{text[:max_chars]}...[truncated]'
31
+
32
+
33
+ def resolve_model_provider() -> str:
34
+ provider = os.getenv('MODEL_PROVIDER', MODEL_PROVIDER_BEDROCK).strip().lower()
35
+ if provider in {MODEL_PROVIDER_BEDROCK, MODEL_PROVIDER_OLLAMA}:
36
+ return provider
37
+ return MODEL_PROVIDER_BEDROCK
38
+
39
+
40
+ def resolve_output_contract() -> str:
41
+ output_contract = os.getenv('AGENT_OUTPUT_CONTRACT', OUTPUT_CONTRACT_NONE).strip().lower()
42
+ if output_contract == OUTPUT_CONTRACT_FRAUD_V1:
43
+ return OUTPUT_CONTRACT_FRAUD_V1
44
+ return OUTPUT_CONTRACT_NONE
45
+
46
+
47
+ def is_fraud_output_contract_enabled() -> bool:
48
+ return resolve_output_contract() == OUTPUT_CONTRACT_FRAUD_V1
49
+
50
+
51
+ def resolve_ollama_model_name(model_id: Optional[str]) -> str:
52
+ raw_model_id = model_id or ''
53
+ explicit_ollama_model = os.getenv('OLLAMA_MODEL_ID')
54
+ if explicit_ollama_model:
55
+ return explicit_ollama_model
56
+
57
+ if raw_model_id.startswith('ollama/'):
58
+ return raw_model_id.split('ollama/', 1)[1]
59
+ if raw_model_id.startswith('ollama.'):
60
+ return raw_model_id.split('ollama.', 1)[1]
61
+ return raw_model_id
62
+
63
+
64
+ def build_agent_model(model_id: Optional[str]) -> Any:
65
+ resolved_provider = resolve_model_provider()
66
+ resolved_model_id = model_id or ''
67
+
68
+ if resolved_provider != MODEL_PROVIDER_OLLAMA:
69
+ return resolved_model_id
70
+
71
+ from strands.models import OllamaModel
72
+
73
+ ollama_host = os.getenv('OLLAMA_BASE_URL') or os.getenv('OLLAMA_HOST')
74
+ ollama_model_name = resolve_ollama_model_name(resolved_model_id)
75
+ if not ollama_model_name:
76
+ raise ValueError('OLLAMA model name is empty. Set MODEL_ID or OLLAMA_MODEL_ID.')
77
+
78
+ return OllamaModel(
79
+ host=ollama_host,
80
+ model_id=ollama_model_name,
81
+ )
82
+
83
+
84
+ def configure_provider_runtime() -> None:
85
+ provider = resolve_model_provider()
86
+ if provider != MODEL_PROVIDER_OLLAMA:
87
+ return
88
+
89
+ ollama_base_url = os.getenv('OLLAMA_BASE_URL')
90
+ if ollama_base_url and not os.getenv('OLLAMA_HOST'):
91
+ os.environ['OLLAMA_HOST'] = ollama_base_url
92
+
93
+
94
+ def extract_json_from_text(text: str, logger) -> Optional[Dict[str, Any]]:
95
+ json_block_pattern = re.compile(r'```json\s*(.*?)\s*```', re.DOTALL | re.IGNORECASE)
96
+ decoder = json.JSONDecoder()
97
+ # Track each JSON candidate with its absolute end index in the full response text.
98
+ # This ensures selection prefers the latest object in the output, not the longest substring.
99
+ candidates: list[tuple[Dict[str, Any], int]] = []
100
+ for json_block_match in json_block_pattern.finditer(text):
101
+ block_text = json_block_match.group(1).strip()
102
+ try:
103
+ parsed = json.loads(block_text)
104
+ if isinstance(parsed, dict):
105
+ candidates.append((parsed, json_block_match.end()))
106
+ except json.JSONDecodeError as error:
107
+ logger.warning('Failed to parse JSON from code block', extra={'error': str(error)})
108
+
109
+ for start_idx, char in enumerate(text):
110
+ if char != '{':
111
+ continue
112
+ try:
113
+ parsed, end_idx = decoder.raw_decode(text[start_idx:])
114
+ except json.JSONDecodeError:
115
+ continue
116
+
117
+ if not isinstance(parsed, dict):
118
+ continue
119
+
120
+ absolute_end_idx = start_idx + end_idx
121
+ candidates.append((parsed, absolute_end_idx))
122
+
123
+ if candidates:
124
+ if not is_fraud_output_contract_enabled():
125
+ return max(candidates, key=lambda candidate: candidate[1])[0]
126
+
127
+ schema_candidates = [
128
+ candidate for candidate in candidates if REQUIRED_FRAUD_RESULT_KEYS.issubset(set(candidate[0].keys()))
129
+ ]
130
+ if schema_candidates:
131
+ return max(schema_candidates, key=lambda candidate: candidate[1])[0]
132
+
133
+ return max(candidates, key=lambda candidate: candidate[1])[0]
134
+
135
+ logger.info('No valid JSON found in text')
136
+ return None
137
+
138
+
139
+ def extract_response_text(response: Any) -> str:
140
+ message = getattr(response, 'message', None)
141
+ if not isinstance(message, dict):
142
+ return str(response)
143
+
144
+ content_blocks = message.get('content')
145
+ if not isinstance(content_blocks, list):
146
+ return json.dumps(message)
147
+
148
+ text_parts = []
149
+ for block in content_blocks:
150
+ if isinstance(block, dict) and isinstance(block.get('text'), str):
151
+ text_parts.append(block['text'])
152
+
153
+ if text_parts:
154
+ return '\n'.join(text_parts)
155
+
156
+ return json.dumps(message)
157
+
158
+
159
+ def extract_response_message(response: Any) -> Dict[str, Any]:
160
+ message = getattr(response, 'message', None)
161
+ if isinstance(message, dict):
162
+ return message
163
+ return {'raw': str(response)}
164
+
165
+
166
+ def summarize_tool_usage_from_message(message: Dict[str, Any]) -> Dict[str, Any]:
167
+ content_blocks = message.get('content', [])
168
+ if not isinstance(content_blocks, list):
169
+ return {'block_count': 0, 'tool_use_count': 0, 'tool_result_count': 0, 'tools': []}
170
+
171
+ tool_names = []
172
+ tool_result_count = 0
173
+ for block in content_blocks:
174
+ if not isinstance(block, dict):
175
+ continue
176
+
177
+ tool_use = block.get('toolUse') or block.get('tool_use')
178
+ if isinstance(tool_use, dict):
179
+ name = tool_use.get('name')
180
+ if isinstance(name, str):
181
+ tool_names.append(name)
182
+
183
+ if block.get('toolResult') is not None or block.get('tool_result') is not None:
184
+ tool_result_count += 1
185
+
186
+ return {
187
+ 'block_count': len(content_blocks),
188
+ 'tool_use_count': len(tool_names),
189
+ 'tool_result_count': tool_result_count,
190
+ 'tools': tool_names,
191
+ }
192
+
193
+
194
+ def is_empty_assistant_payload(payload: Any) -> bool:
195
+ return (
196
+ isinstance(payload, dict)
197
+ and payload.get('role') == 'assistant'
198
+ and isinstance(payload.get('content'), list)
199
+ and len(payload.get('content', [])) == 0
200
+ )
201
+
202
+
203
+ def build_final_json_retry_prompt() -> str:
204
+ if is_fraud_output_contract_enabled():
205
+ return (
206
+ 'Provide the final answer now as a single valid JSON object only. '
207
+ 'Do not call any tools. '
208
+ 'Use exactly these keys: risk_score, risk_level, findings, indicators, recommended_actions.'
209
+ )
210
+
211
+ return (
212
+ 'Provide the final answer now as a single valid JSON object only. '
213
+ 'Do not call any tools.'
214
+ )
215
+
216
+
217
+ def is_complete_fraud_result(result: Dict[str, Any]) -> bool:
218
+ if not isinstance(result, dict):
219
+ return False
220
+ if not REQUIRED_FRAUD_RESULT_KEYS.issubset(result.keys()):
221
+ return False
222
+ findings = result.get('findings')
223
+ if not isinstance(findings, dict):
224
+ return False
225
+ return REQUIRED_FRAUD_FINDINGS_KEYS.issubset(findings.keys())
226
+
227
+
228
+ def build_required_tools_retry_prompt() -> str:
229
+ return (
230
+ 'Your previous response was incomplete. '
231
+ 'You MUST run the full fraud tool sequence in this exact order: '
232
+ '1) extract_document_fields, 2) analyze_metadata, 3) detect_anomalies, '
233
+ '4) match_patterns, 5) lookup_vendor. '
234
+ 'After all tools complete, return ONLY valid JSON with keys: '
235
+ 'risk_score, risk_level, findings, indicators, recommended_actions. '
236
+ 'The findings object MUST include: metadata_analysis, pattern_matches, anomalies, database_checks.'
237
+ )