ApiLogicServer 15.4.3__py3-none-any.whl → 16.0.2__py3-none-any.whl

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 (27) hide show
  1. api_logic_server_cli/add_cust/add_cust.py +6 -2
  2. api_logic_server_cli/api_logic_server.py +2 -1
  3. api_logic_server_cli/database/basic_demo.sqlite +0 -0
  4. api_logic_server_cli/prototypes/base/.github/.copilot-instructions.md +229 -76
  5. api_logic_server_cli/prototypes/base/docs/training/OVERVIEW.md +64 -0
  6. api_logic_server_cli/prototypes/base/docs/training/README.md +140 -0
  7. api_logic_server_cli/prototypes/base/docs/training/genai_logic_patterns.md +443 -0
  8. api_logic_server_cli/prototypes/base/docs/training/logic_bank_api.prompt +23 -0
  9. api_logic_server_cli/prototypes/base/docs/training/logic_bank_patterns.prompt +445 -0
  10. api_logic_server_cli/prototypes/base/docs/training/probabilistic_logic.prompt +1081 -0
  11. api_logic_server_cli/prototypes/base/docs/training/probabilistic_logic_guide.md +483 -0
  12. api_logic_server_cli/prototypes/base/docs/training/probabilistic_template.py +326 -0
  13. api_logic_server_cli/prototypes/base/logic/logic_discovery/auto_discovery.py +8 -9
  14. api_logic_server_cli/prototypes/basic_demo/.github/.copilot-instructions.md +327 -141
  15. api_logic_server_cli/prototypes/basic_demo/.github/welcome.md +21 -7
  16. api_logic_server_cli/prototypes/basic_demo/customizations/database/db.sqlite +0 -0
  17. api_logic_server_cli/prototypes/basic_demo/iteration/database/db.sqlite +0 -0
  18. api_logic_server_cli/prototypes/manager/.github/.copilot-instructions.md +61 -155
  19. api_logic_server_cli/prototypes/manager/.github/welcome.md +43 -0
  20. api_logic_server_cli/prototypes/manager/.vscode/settings.json +1 -0
  21. api_logic_server_cli/prototypes/manager/samples/basic_demo_sample/.github/.copilot-instructions.md +502 -76
  22. {apilogicserver-15.4.3.dist-info → apilogicserver-16.0.2.dist-info}/METADATA +1 -1
  23. {apilogicserver-15.4.3.dist-info → apilogicserver-16.0.2.dist-info}/RECORD +27 -19
  24. {apilogicserver-15.4.3.dist-info → apilogicserver-16.0.2.dist-info}/WHEEL +0 -0
  25. {apilogicserver-15.4.3.dist-info → apilogicserver-16.0.2.dist-info}/entry_points.txt +0 -0
  26. {apilogicserver-15.4.3.dist-info → apilogicserver-16.0.2.dist-info}/licenses/LICENSE +0 -0
  27. {apilogicserver-15.4.3.dist-info → apilogicserver-16.0.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1081 @@
1
+ ---
2
+ title: LogicBank Probabilistic Rules API (AI Value Computation)
3
+ description: Training document for translating natural language into probabilistic value computation rules
4
+ source: Generic training for ApiLogicServer projects with probabilistic rules
5
+ usage: AI assistants read this to generate probabilistic + deterministic rules implementations
6
+ version: 3.1
7
+ date: November 21, 2025
8
+ prerequisites:
9
+ - docs/training/genai_logic_patterns.md (CRITICAL import patterns, auto-discovery)
10
+ - docs/training/logic_bank_patterns.prompt (event signatures, logging, request pattern)
11
+ - docs/training/logic_bank_api.prompt (deterministic rule APIs)
12
+ ---
13
+
14
+ # AI Value Computation Pattern
15
+
16
+ This document describes how to implement probabilistic logic (AI-driven value computation) alongside deterministic rules in LogicBank.
17
+
18
+ ## Prerequisites
19
+
20
+ Read these foundation documents first:
21
+
22
+ 1. **docs/training/genai_logic_patterns.md** - Import patterns, auto-discovery
23
+ 2. **docs/training/logic_bank_patterns.prompt** - Event signatures, logging, Request Pattern
24
+ 3. **docs/training/logic_bank_api.prompt** - Deterministic rule APIs
25
+
26
+ =============================================================================
27
+ 🤖 AUTOMATIC DETECTION: When to Generate Probabilistic Logic
28
+ =============================================================================
29
+
30
+ When you see these patterns in user's natural language prompt, AUTOMATICALLY create complete AI value computation infrastructure:
31
+
32
+ **Trigger Phrases:**
33
+ - "use AI to select/choose..."
34
+ - "optimize using AI..."
35
+ - "AI should decide..."
36
+ - "intelligently select based on..."
37
+ - "considering [multiple factors] choose..."
38
+ - "IF [condition] THEN use AI to [select/compute]..."
39
+
40
+ When triggered, automatically generate:
41
+ - SysXxxReq model (if not exists) + Alembic migration
42
+ - logic/logic_discovery/check_credit.py (business logic)
43
+ - logic/logic_discovery/ai_requests/supplier_selection.py (AI handler)
44
+ - config/ai_test_context.yaml (test context)
45
+ - Update ui/admin/admin.yaml
46
+
47
+ =============================================================================
48
+ 🚨 CRITICAL FIXES FROM PRODUCTION DEBUGGING (Nov 16-21, 2025)
49
+ =============================================================================
50
+
51
+ These errors occurred during demo prep and production testing and MUST be avoided:
52
+
53
+ **ERROR 1: Circular Import - "Session is already flushing"**
54
+
55
+ Applies to main logic/declare_logic.py ONLY, not discovery files.
56
+
57
+ ❌ Problem: Importing LogicBank at module level in MAIN logic/declare_logic.py
58
+ ```python
59
+ # In logic/declare_logic.py (main file)
60
+ from logic_bank.logic_bank import Rule # ❌ At module level in main file
61
+ ```
62
+
63
+ ✅ Solution for main declare_logic.py: Import inside function
64
+ ```python
65
+ # In logic/declare_logic.py (main file)
66
+ from database import models # ✅ At module level
67
+
68
+ def declare_logic():
69
+ from logic_bank.logic_bank import Rule # ✅ Inside function in main file
70
+ ```
71
+
72
+ ✅ Discovery files (logic_discovery/*.py) are SAFE with module-level imports:
73
+ ```python
74
+ # In logic/logic_discovery/check_credit.py or supplier_selection.py
75
+ from logic_bank.logic_bank import Rule # ✅ Safe in discovery files
76
+ from logic_bank.exec_row_logic.logic_row import LogicRow # ✅ Safe in discovery files
77
+ from database import models # ✅ Preferred pattern
78
+
79
+ def declare_logic():
80
+ # Rules here
81
+ ```
82
+
83
+ **ERROR 2: Auto-Discovery Structure Requirements**
84
+
85
+ ⚠️ **IMPORTANT**: logic/logic_discovery/auto_discovery.py is AUTO-GENERATED by ApiLogicServer
86
+ - It is ALREADY CORRECT in all new projects (handles recursion + skips __init__.py)
87
+ - ❌ DO NOT modify auto_discovery.py
88
+ - ✅ DO create logic files in proper structure that auto-discovery will find
89
+
90
+ ✅ What auto_discovery.py does (already built-in):
91
+ - Recursively scans logic_discovery/ and all subdirectories
92
+ - Finds all .py files except auto_discovery.py and __init__.py
93
+ - Imports each file and calls declare_logic() function
94
+ - Works with nested directories like ai_requests/, validation/, etc.
95
+
96
+ ✅ Your responsibility (what Copilot generates):
97
+ ```
98
+ logic/logic_discovery/
99
+ check_credit.py # Has declare_logic() function
100
+ ai_requests/ # Subdirectory
101
+ __init__.py # Empty file (makes it a package)
102
+ supplier_selection.py # Has declare_logic() function
103
+ ```
104
+
105
+ ❌ Common mistake: Putting logic in __init__.py
106
+ - auto_discovery.py skips __init__.py files (by design)
107
+ - Always create separate .py files with declare_logic() functions
108
+
109
+ **ERROR 3: Path Resolution for YAML Files**
110
+
111
+ ❌ Problem: Path(__file__).parent creates relative path
112
+ ```python
113
+ context_file = config_dir / 'ai_test_context.yaml'
114
+ if context_file.exists(): # ❌ May fail on relative paths
115
+ ```
116
+
117
+ ✅ Solution: Use .resolve() for absolute paths
118
+ ```python
119
+ current_file = Path(__file__).resolve() # ✅ Absolute path
120
+ project_root = current_file.parent.parent.parent.parent
121
+ context_file = project_root / 'config' / 'ai_test_context.yaml'
122
+ if context_file.exists():
123
+ with open(str(context_file), 'r') as f: # ✅ Convert to string
124
+ ```
125
+
126
+ **ERROR 4: Missing `is_deleted()` Check in Early Events (Nov 21, 2025)**
127
+
128
+ ❌ Problem: Early events fire on delete, but `old_row` is None
129
+ ```python
130
+ def set_item_unit_price_from_supplier(row: models.Item, old_row: models.Item, logic_row):
131
+ # Process on insert OR when product_id changes
132
+ if not (logic_row.is_inserted() or row.product_id != old_row.product_id): # ❌ CRASH on delete
133
+ return
134
+ ```
135
+
136
+ ✅ Solution: Check `is_deleted()` FIRST, before accessing `old_row`
137
+ ```python
138
+ def set_item_unit_price_from_supplier(row: models.Item, old_row: models.Item, logic_row):
139
+ from logic.logic_discovery.ai_requests.supplier_selection import get_supplier_selection_from_ai
140
+
141
+ # Skip on delete (old_row is None) - CHECK THIS FIRST
142
+ if logic_row.is_deleted():
143
+ return
144
+
145
+ # Now safe to access old_row
146
+ if not (logic_row.is_inserted() or row.product_id != old_row.product_id):
147
+ return
148
+ ```
149
+
150
+ **Rule**: ALL early events that access `old_row` MUST check `is_deleted()` first.
151
+
152
+ **ERROR 5: Incomplete Audit Trail - Empty `request` and Brief `reason` Fields (Nov 21, 2025)**
153
+
154
+ ❌ Problem: Request and reason fields not fully populated with actual data
155
+ ```python
156
+ # In wrapper function
157
+ supplier_req.request = f"Select optimal supplier for {product_name}" # ❌ Generic, no context
158
+ # In AI handler
159
+ row.reason = "Test context selection" # ❌ Missing details
160
+ ```
161
+
162
+ **What happens**: SysSupplierReq records lack actionable audit trail
163
+ **Impact**: Cannot debug AI decisions, no visibility into what candidates were considered
164
+ **Business problem**: Compliance, explainability, debugging impossible
165
+
166
+ ✅ Solution: Populate `request` with FULL context in AI handler (where data exists)
167
+ ```python
168
+ # In AI handler (select_supplier_via_ai) - NOT in wrapper
169
+ def select_supplier_via_ai(row: models.SysSupplierReq, old_row, logic_row: LogicRow):
170
+ """
171
+ Populate request and reason fields with COMPLETE information:
172
+ - request: Full context (product, candidates with prices, world conditions)
173
+ - reason: Decision details (selected supplier name, price, full AI explanation)
174
+ """
175
+ product = row.product
176
+ suppliers = product.ProductSupplierList if product else []
177
+
178
+ # Build candidate summary for request field
179
+ candidate_summary = ', '.join([
180
+ f"{s.supplier.name if s.supplier else 'Unknown'}(${s.unit_cost})"
181
+ for s in suppliers
182
+ ])
183
+
184
+ # TEST CONTEXT case
185
+ if test_context:
186
+ world = test_context.get('world_conditions', 'normal conditions')
187
+ row.request = f"Select supplier for {product.name}: Candidates=[{candidate_summary}], World={world}"
188
+ row.reason = f"TEST MODE: Selected {selected_supplier.supplier.name} (${selected_supplier.unit_cost}) - world: {world}"
189
+
190
+ # AI CALL case
191
+ elif api_key:
192
+ # Populate BEFORE calling AI
193
+ row.request = f"AI Prompt: Product={product.name}, World={world_conditions}, Candidates={len(candidate_data)}: {candidate_summary}"
194
+
195
+ # After AI responds
196
+ supplier_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
197
+ row.reason = f"AI: {supplier_name} (${selected_supplier.unit_cost}) - {ai_result.get('reason', 'No reason provided')}"
198
+
199
+ # FALLBACK case
200
+ else:
201
+ row.request = f"Select supplier for {product.name}: Candidates=[{candidate_summary}] - NO API KEY"
202
+ fallback_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
203
+ row.reason = f"Fallback: {fallback_name} (${selected_supplier.unit_cost}) - minimum cost (no API key)"
204
+ ```
205
+
206
+ **Key Points:**
207
+ - ✅ `request` shows WHAT was asked (product, all candidates, world context)
208
+ - ✅ `reason` shows WHY decision was made (selected supplier details, AI explanation)
209
+ - ✅ Both fields populated in AI handler (has access to all data)
210
+ - ✅ Includes supplier NAMES and PRICES (not just IDs)
211
+ - ✅ Different patterns for test/AI/fallback modes
212
+ - ❌ DO NOT populate in wrapper (doesn't have candidate data)
213
+
214
+ **Wrapper function should:**
215
+ ```python
216
+ def get_supplier_selection_from_ai(product_id: int, item_id: int, logic_row: LogicRow):
217
+ supplier_req_logic_row = logic_row.new_logic_row(models.SysSupplierReq)
218
+ supplier_req = supplier_req_logic_row.row
219
+
220
+ # Set parent context (FK links)
221
+ # Note: request/reason populated by AI event handler with actual data
222
+ supplier_req.product_id = product_id
223
+ supplier_req.item_id = item_id
224
+
225
+ # Insert triggers AI handler which populates request/reason
226
+ supplier_req_logic_row.insert(reason="AI supplier selection request")
227
+
228
+ # Log results for visibility
229
+ logic_row.log(f"AI Request: {supplier_req.request}")
230
+ logic_row.log(f"AI Results: supplier_id={supplier_req.chosen_supplier_id}, price={supplier_req.chosen_unit_price}, reason={supplier_req.reason}")
231
+
232
+ return supplier_req
233
+ ```
234
+
235
+ See docs/training/genai_logic_patterns.md for complete patterns.
236
+
237
+ =============================================================================
238
+ ⚡ PATTERN: Early Event with Wrapper Function
239
+ =============================================================================
240
+
241
+ ## The Pattern
242
+
243
+ When user says "Use AI to Set <Receiver> field by finding optimal <Provider>":
244
+
245
+ 1. **Early event on receiver** - `Rule.early_row_event(on_class=models.Item, calling=set_item_unit_price_from_supplier)`
246
+ 2. **Event calls wrapper** - Wrapper hides Request Pattern complexity
247
+ 3. **Wrapper returns object** - Returns populated request object (not scalar)
248
+ 4. **Event extracts values** - `row.unit_price = req.chosen_unit_price`
249
+
250
+ ## Fallback Strategy
251
+
252
+ **CRITICAL:** AI rules need fallback logic for cases when AI shouldn't/can't run.
253
+
254
+ **Strategy: Reasonable Default → Fail-Fast**
255
+
256
+ 1. **Check for reasonable default**: Copy from parent field with matching name
257
+ 2. **If no obvious default**: Insert `NotImplementedError` with `TODO_AI_FALLBACK` marker
258
+ 3. **Never silently fail**: Force developer decision at generation time, not runtime
259
+
260
+ **Benefits:**
261
+ - ✅ Prevents silent production failures
262
+ - ✅ Code won't run until developer addresses edge cases
263
+ - ✅ Clear markers for what needs attention
264
+ - ✅ Works in dev/test, fails explicitly before production
265
+
266
+ **For multi-value AI results**: Apply per-field fallback strategy. Common: copy from parent matching field names. For fields with no obvious fallback, use `TODO_AI_FALLBACK`.
267
+
268
+ ## Complete Example
269
+
270
+ ### Natural Language
271
+
272
+ ```
273
+ Use AI to Set Item field unit_price by finding the optimal Product Supplier
274
+ based on cost, lead time, and world conditions
275
+
276
+ IF Product has no suppliers, THEN copy from Product.unit_price
277
+ ```
278
+
279
+ ### Implementation
280
+
281
+ **File: logic/logic_discovery/check_credit.py**
282
+
283
+ ```python
284
+ """
285
+ Check Credit Use Case - Business Logic Rules
286
+
287
+ Natural Language Requirements:
288
+ 1. The Customer's balance is less than the credit limit
289
+ 2. The Customer's balance is the sum of the Order amount_total where date_shipped is null
290
+ 3. The Order's amount_total is the sum of the Item amount
291
+ 4. The Item amount is the quantity * unit_price
292
+ 5. The Product count suppliers is the sum of the Product Suppliers
293
+ 6. Use AI to Set Item field unit_price by finding the optimal Product Supplier
294
+ based on cost, lead time, and world conditions
295
+
296
+ version: 3.0
297
+ date: November 21, 2025
298
+ source: docs/training/probabilistic_logic.prompt
299
+ """
300
+
301
+ from logic_bank.logic_bank import Rule
302
+ from database import models
303
+
304
+ def declare_logic():
305
+ # Other deterministic rules...
306
+ Rule.early_row_event(on_class=models.Item, calling=set_item_unit_price_from_supplier)
307
+
308
+ def set_item_unit_price_from_supplier(row: models.Item, old_row: models.Item, logic_row):
309
+ """
310
+ Early event: Sets unit_price using AI if suppliers exist, else uses fallback.
311
+
312
+ Fires on insert AND when product_id changes (same semantics as copy rule).
313
+ """
314
+ from logic.logic_discovery.ai_requests.supplier_selection import get_supplier_selection_from_ai
315
+
316
+ # Skip on delete (old_row is None) - CRITICAL: Check this FIRST
317
+ if logic_row.is_deleted():
318
+ return
319
+
320
+ # Process on insert OR when product_id changes
321
+ if not (logic_row.is_inserted() or row.product_id != old_row.product_id):
322
+ return
323
+
324
+ product = row.product
325
+
326
+ # FALLBACK LOGIC when AI shouldn't/can't run:
327
+ # Strategy: Try reasonable default (copy from parent matching field), else fail-fast
328
+ if product.count_suppliers == 0:
329
+ # Reasonable default: copy from parent.unit_price (matching field name)
330
+ if hasattr(product, 'unit_price') and product.unit_price is not None:
331
+ logic_row.log(f"No suppliers for {product.name}, using product default price")
332
+ row.unit_price = product.unit_price
333
+ return
334
+ else:
335
+ # No obvious fallback - fail-fast with explicit TODO
336
+ raise NotImplementedError(
337
+ "TODO_AI_FALLBACK: Define fallback for Item.unit_price when no suppliers exist. "
338
+ "Options: (1) Use a default constant, (2) Leave NULL if optional, "
339
+ "(3) Raise error if required field, (4) Copy from another source"
340
+ )
341
+
342
+ # Product has suppliers - call AI wrapper
343
+ logic_row.log(f"Product {product.name} has {product.count_suppliers} suppliers, requesting AI selection")
344
+ supplier_req = get_supplier_selection_from_ai(
345
+ product_id=row.product_id,
346
+ item_id=row.id,
347
+ logic_row=logic_row
348
+ )
349
+
350
+ # Extract AI-selected value(s)
351
+ row.unit_price = supplier_req.chosen_unit_price
352
+ ```
353
+
354
+ **File: logic/logic_discovery/ai_requests/__init__.py**
355
+
356
+ ```python
357
+ # Empty file - makes this a Python package
358
+ ```
359
+
360
+ **File: logic/logic_discovery/ai_requests/supplier_selection.py**
361
+
362
+ ⚠️ **IMPORTANT:** There is NO pre-built `populate_ai_values()` utility in LogicBank.
363
+ You must implement the AI selection logic directly as shown below.
364
+
365
+ ```python
366
+ """
367
+ AI Supplier Selection - Probabilistic Logic Handler
368
+
369
+ This module implements AI-driven supplier selection based on cost, lead time,
370
+ and world conditions. It uses the Request Pattern for full audit trails.
371
+
372
+ See: https://apilogicserver.github.io/Docs/Logic-Using-AI/
373
+
374
+ version: 3.0
375
+ date: November 21, 2025
376
+ source: docs/training/probabilistic_logic.prompt
377
+ """
378
+
379
+ from logic_bank.exec_row_logic.logic_row import LogicRow
380
+ from logic_bank.logic_bank import Rule
381
+ from database import models
382
+ from decimal import Decimal
383
+ import os
384
+
385
+ def declare_logic():
386
+ """
387
+ Register early event on SysSupplierReq to populate chosen_* fields via AI.
388
+
389
+ This Request Pattern approach provides full audit trails and separation of concerns.
390
+ See: https://apilogicserver.github.io/Docs/Logic/#rule-patterns
391
+ """
392
+ Rule.early_row_event(on_class=models.SysSupplierReq, calling=select_supplier_via_ai)
393
+
394
+ def select_supplier_via_ai(row: models.SysSupplierReq, old_row, logic_row: LogicRow):
395
+ """
396
+ Early event (called via insert from wrapper) to populate chosen_* fields via AI.
397
+
398
+ This AI handler gets called automatically when SysSupplierReq is inserted,
399
+ populating AI Results: chosen_supplier_id and chosen_unit_price.
400
+
401
+ Strategy:
402
+ 1. Load test context for INPUT conditions (world conditions like "Suez Canal blocked")
403
+ 2. Always try AI with those conditions
404
+ 3. If no API key or API fails, use fallback (min cost)
405
+ """
406
+ if not logic_row.is_inserted():
407
+ return
408
+
409
+ # Get candidates (suppliers for this product)
410
+ product = row.product
411
+ suppliers = product.ProductSupplierList if product else []
412
+
413
+ if not suppliers:
414
+ row.request = f"Select supplier for {product.name if product else 'unknown product'} - No suppliers available"
415
+ row.reason = "No suppliers exist for this product"
416
+ logic_row.log("No suppliers available for AI selection")
417
+ row.fallback_used = True
418
+ return
419
+
420
+ # Load test context for world conditions (not for predetermined supplier selection)
421
+ from pathlib import Path
422
+ import yaml
423
+
424
+ current_file = Path(__file__).resolve()
425
+ project_root = current_file.parent.parent.parent.parent
426
+ context_file = project_root / 'config' / 'ai_test_context.yaml'
427
+
428
+ test_context = {}
429
+ if context_file.exists():
430
+ with open(str(context_file), 'r') as f:
431
+ test_context = yaml.safe_load(f) or {}
432
+
433
+ world_conditions = test_context.get('world_conditions', 'normal conditions')
434
+
435
+ selected_supplier = None
436
+
437
+ # Try AI (check for API key)
438
+ if True: # Always try AI unless no key
439
+ api_key = os.getenv("APILOGICSERVER_CHATGPT_APIKEY")
440
+ if api_key:
441
+ try:
442
+ # Call OpenAI API with structured prompt
443
+ from openai import OpenAI
444
+ import json
445
+
446
+ client = OpenAI(api_key=api_key)
447
+
448
+ # Build candidate data for prompt - include ALL supplier fields for AI decision
449
+ candidate_data = []
450
+ for supplier in suppliers:
451
+ supplier_obj = supplier.supplier
452
+ candidate_data.append({
453
+ 'supplier_id': supplier.supplier_id,
454
+ 'supplier_name': supplier_obj.name if supplier_obj else 'Unknown',
455
+ 'supplier_region': supplier_obj.region if supplier_obj else None,
456
+ 'supplier_contact': supplier_obj.contact_name if supplier_obj else None,
457
+ 'supplier_phone': supplier_obj.phone if supplier_obj else None,
458
+ 'supplier_email': supplier_obj.email if supplier_obj else None,
459
+ 'unit_cost': float(supplier.unit_cost) if supplier.unit_cost else 0.0,
460
+ 'lead_time_days': supplier.lead_time_days if hasattr(supplier, 'lead_time_days') else None,
461
+ 'supplier_part_number': supplier.supplier_part_number if hasattr(supplier, 'supplier_part_number') else None
462
+ })
463
+
464
+ prompt = f"""
465
+ You are a supply chain optimization expert. Select the best supplier from the candidates below.
466
+
467
+ World Conditions: {world_conditions}
468
+
469
+ Optimization Goal: fastest reliable delivery while keeping costs reasonable
470
+
471
+ Candidates:
472
+ {yaml.dump(candidate_data, default_flow_style=False)}
473
+
474
+ Respond with ONLY valid JSON in this exact format (no markdown, no code blocks):
475
+ {{
476
+ "chosen_supplier_id": <id>,
477
+ "chosen_unit_price": <price>,
478
+ "reason": "<brief explanation>"
479
+ }}
480
+ """
481
+
482
+ # Populate request field with actual prompt summary including key fields
483
+ candidate_summary = ', '.join([
484
+ f"{c['supplier_name']}(${c['unit_cost']}, {c['supplier_region'] or 'unknown region'}, {c['lead_time_days'] or '?'}days)"
485
+ for c in candidate_data
486
+ ])
487
+ row.request = f"Select supplier for {product.name}: Candidates=[{candidate_summary}], World={world_conditions}"
488
+
489
+ logic_row.log(f"Calling OpenAI API with {len(candidate_data)} candidates, world conditions: {world_conditions}")
490
+
491
+ response = client.chat.completions.create(
492
+ model="gpt-4o-2024-08-06",
493
+ messages=[
494
+ {"role": "system", "content": "You are a supply chain expert. Respond with valid JSON only."},
495
+ {"role": "user", "content": prompt}
496
+ ],
497
+ temperature=0.7
498
+ )
499
+
500
+ response_text = response.choices[0].message.content.strip()
501
+ logic_row.log(f"OpenAI response: {response_text}")
502
+
503
+ # Parse JSON response
504
+ ai_result = json.loads(response_text)
505
+
506
+ # Find the selected supplier
507
+ selected_supplier = next((s for s in suppliers if s.supplier_id == ai_result['chosen_supplier_id']), None)
508
+ if selected_supplier:
509
+ supplier_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
510
+ row.reason = f"Selected {supplier_name} (${selected_supplier.unit_cost}) - {ai_result.get('reason', 'No reason provided')}"
511
+ row.fallback_used = False
512
+ else:
513
+ logic_row.log(f"AI selected invalid supplier_id {ai_result['chosen_supplier_id']}, using fallback")
514
+ selected_supplier = min(suppliers, key=lambda s: float(s.unit_cost) if s.unit_cost else 999999.0)
515
+ fallback_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
516
+ row.reason = f"Fallback: {fallback_name} (${selected_supplier.unit_cost}) - AI returned invalid supplier"
517
+ row.fallback_used = True
518
+
519
+ except Exception as e:
520
+ logic_row.log(f"OpenAI API error: {e}, using fallback")
521
+ selected_supplier = min(suppliers, key=lambda s: float(s.unit_cost) if s.unit_cost else 999999.0)
522
+ fallback_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
523
+ candidate_summary = ', '.join([f"{s.supplier.name if s.supplier else 'Unknown'}(${s.unit_cost})" for s in suppliers])
524
+ row.request = f"Select supplier for {product.name}: Candidates=[{candidate_summary}] - API ERROR"
525
+ row.reason = f"Fallback: {fallback_name} (${selected_supplier.unit_cost}) - API error: {str(e)[:100]}"
526
+ row.fallback_used = True
527
+ else:
528
+ # No API key - use fallback strategy (min cost)
529
+ logic_row.log("No API key, using fallback: minimum cost")
530
+ selected_supplier = min(suppliers, key=lambda s: float(s.unit_cost) if s.unit_cost else 999999.0)
531
+ fallback_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
532
+ candidate_summary = ', '.join([f"{s.supplier.name if s.supplier else 'Unknown'}(${s.unit_cost})" for s in suppliers])
533
+ row.request = f"Select supplier for {product.name}: Candidates=[{candidate_summary}] - NO API KEY"
534
+ row.reason = f"Fallback: {fallback_name} (${selected_supplier.unit_cost}) - minimum cost (no API key)"
535
+ row.fallback_used = True
536
+
537
+ # Populate AI results
538
+ if selected_supplier:
539
+ row.chosen_supplier_id = int(selected_supplier.supplier_id) # Must be int for SQLite FK
540
+ row.chosen_unit_price = selected_supplier.unit_cost
541
+ logic_row.log(f"Selected supplier {selected_supplier.supplier_id} with price {selected_supplier.unit_cost}")
542
+
543
+ def get_supplier_selection_from_ai(product_id: int, item_id: int, logic_row: LogicRow) -> models.SysSupplierReq:
544
+ """
545
+ Wrapper function called from Item (Receiver) early event.
546
+
547
+ See: https://apilogicserver.github.io/Docs/Logic-Using-AI/
548
+
549
+ 1. Creates SysSupplierReq and inserts it (triggering AI event that populates chosen_* fields)
550
+ 2. Returns populated object
551
+
552
+ This wrapper hides Request Pattern implementation details.
553
+ See https://apilogicserver.github.io/Docs/Logic/#rule-patterns.
554
+
555
+ Returns populated SysSupplierReq object with:
556
+ - Standard AI Audit: request, reason, created_on, fallback_used
557
+ - Parent Context Links: item_id, product_id
558
+ - AI Results: chosen_supplier_id, chosen_unit_price
559
+ """
560
+ # 1. Create request row using parent's logic_row
561
+ supplier_req_logic_row = logic_row.new_logic_row(models.SysSupplierReq)
562
+ supplier_req = supplier_req_logic_row.row
563
+
564
+ # 2. Set parent context (FK links)
565
+ # Note: request/reason fields populated by AI event handler with actual prompt/candidate data
566
+ supplier_req.product_id = product_id
567
+ supplier_req.item_id = item_id
568
+
569
+ # 3. Insert triggers early event which populates AI values (chosen_* fields, request, reason)
570
+ supplier_req_logic_row.insert(reason="AI supplier selection request")
571
+
572
+ # 4. Log filled request object for visibility
573
+ logic_row.log(f"AI Request: {supplier_req.request}")
574
+ logic_row.log(f"AI Results: supplier_id={supplier_req.chosen_supplier_id}, price={supplier_req.chosen_unit_price}, reason={supplier_req.reason}")
575
+
576
+ # 5. Return populated object (chosen_* fields now set by AI)
577
+ return supplier_req
578
+ ```
579
+
580
+ ## Key Patterns
581
+
582
+ ### Key Implementation Points
583
+
584
+ **Test Context Usage:**
585
+ - Load test context for INPUT conditions (world_conditions like "Suez Canal blocked")
586
+ - Test context provides CONDITIONS for AI, NOT predetermined outputs
587
+ - File: `config/ai_test_context.yaml`
588
+ - Example: `world_conditions: "Suez Canal blocked, use alternate shipping routes"`
589
+
590
+ **AI Strategy:**
591
+ - Always try AI if API key exists
592
+ - Pass world_conditions from test context to AI prompt
593
+ - AI makes decision based on those conditions
594
+
595
+ **Fallback Strategy:**
596
+ - When no suppliers: Set `fallback_used = True`, return early
597
+ - When no API key: Use min cost fallback
598
+ - When API call fails: Use min cost fallback
599
+
600
+ **Type Handling:**
601
+ - Foreign keys (IDs): Must be `int` not `Decimal`
602
+ - Prices: Can be `Decimal`
603
+ - Use `float()` for comparisons: `float(s.unit_cost)`
604
+
605
+ **Path Resolution:**
606
+ - Use `Path(__file__).resolve()` for absolute paths
607
+ - Navigate up from `logic/logic_discovery/ai_requests/` to project root
608
+ - Then down to `config/ai_test_context.yaml`
609
+
610
+ ### Request Pattern
611
+
612
+ The wrapper function encapsulates LogicBank's Request Pattern:
613
+
614
+ ```python
615
+ # Create using new_logic_row (pass CLASS not instance)
616
+ req_logic_row = logic_row.new_logic_row(models.SysXxxReq)
617
+
618
+ # Access instance via .row property
619
+ req = req_logic_row.row
620
+
621
+ # Set context fields
622
+ req.context_id = some_value
623
+
624
+ # Insert triggers early event handler
625
+ req_logic_row.insert(reason="...")
626
+
627
+ # Return populated object
628
+ return req
629
+ ```
630
+
631
+ ### Request Table Structure
632
+
633
+ **Standard AI Audit (constant for all requests)**
634
+ ```python
635
+ id = Column(Integer, primary_key=True)
636
+ request = Column(String(2000)) # AI prompt sent
637
+ reason = Column(String(500)) # AI reasoning
638
+ created_on = Column(DateTime) # Timestamp
639
+ fallback_used = Column(Boolean) # Did AI fail?
640
+ ```
641
+
642
+ **Parent Context Links (FKs to triggering entities)**
643
+ ```python
644
+ item_id = Column(ForeignKey('item.id'))
645
+ product_id = Column(ForeignKey('product.id'))
646
+ ```
647
+
648
+ **AI Results (values selected by AI)**
649
+ ```python
650
+ chosen_supplier_id = Column(ForeignKey('supplier.id'))
651
+ chosen_unit_price = Column(DECIMAL)
652
+ ```
653
+
654
+ =============================================================================
655
+ 🚨 REQUEST PATTERN FAILURE MODES (Learned from Production Debugging)
656
+ =============================================================================
657
+
658
+ **CONTEXT**: These are REAL failures that occurred during implementation. Each pattern caused server crashes, test failures, or silent bugs.
659
+
660
+ **FAILURE #1: Formula Returns AI Value Directly**
661
+ ```python
662
+ # ❌ WRONG - AI handler never fires
663
+ Rule.formula(
664
+ derive=models.Item.unit_price,
665
+ as_expression=lambda row: get_ai_supplier_price(row)
666
+ )
667
+ ```
668
+ **What happens**: Formula executes but AI handler never fires, no audit trail created.
669
+ **Error**: Silent failure - no SysSupplierReq records, unit_price has wrong value
670
+ **Why it fails**: Formula should PRESERVE value, not COMPUTE it via AI
671
+ **Fix**: Use early event pattern (see above)
672
+
673
+ **FAILURE #2: Pass Instance to new_logic_row()**
674
+ ```python
675
+ # ❌ WRONG - Pass instance instead of class
676
+ supplier_req = models.SysSupplierReq()
677
+ supplier_req_logic_row = logic_row.new_logic_row(supplier_req)
678
+ ```
679
+ **What happens**: Python tries to call the instance as a function
680
+ **Error**: `TypeError: 'SysSupplierReq' object is not callable`
681
+ **Why it fails**: new_logic_row() expects a CLASS, not an instance
682
+ **Fix**: Pass the class: `logic_row.new_logic_row(models.SysSupplierReq)`
683
+
684
+ **FAILURE #3: Access Attributes on LogicRow Instead of .row**
685
+ ```python
686
+ # ❌ WRONG - LogicRow doesn't have business attributes
687
+ supplier_req_logic_row = logic_row.new_logic_row(models.SysSupplierReq)
688
+ item_row.unit_price = supplier_req_logic_row.chosen_unit_price
689
+ ```
690
+ **What happens**: LogicRow is a wrapper, not the business object
691
+ **Error**: `AttributeError: 'LogicRow' object has no attribute 'chosen_unit_price'`
692
+ **Why it fails**: Business attributes are on .row property, not LogicRow wrapper
693
+ **Fix**: Access via .row: `supplier_req = supplier_req_logic_row.row`
694
+
695
+ **FAILURE #4: Use session.add/flush Directly**
696
+ ```python
697
+ # ❌ WRONG - Bypasses LogicBank
698
+ supplier_req = models.SysSupplierReq()
699
+ logic_row.session.add(supplier_req)
700
+ logic_row.session.flush()
701
+ ```
702
+ **What happens**: Object added to database but LogicBank events never fire
703
+ **Error**: Silent failure - AI handler never executes, no AI selection
704
+ **Why it fails**: Direct SQLAlchemy calls bypass LogicBank event chain
705
+ **Fix**: Use logic_row.new_logic_row() + explicit .insert()
706
+
707
+ **FAILURE #5: Forget to Copy Result Back**
708
+ ```python
709
+ # ❌ WRONG - AI runs but result not propagated
710
+ supplier_req_logic_row.insert(reason="AI supplier selection")
711
+ # Missing copy: item_row.unit_price = supplier_req.chosen_unit_price
712
+ ```
713
+ **What happens**: SysSupplierReq populated correctly but Item.unit_price unset
714
+ **Error**: Silent failure - AI works but business logic breaks (unit_price = None)
715
+ **Why it fails**: No automatic propagation between tables
716
+ **Fix**: Explicitly copy: `item_row.unit_price = supplier_req.chosen_unit_price`
717
+
718
+ **FAILURE #6: Test Context Checked After API Key**
719
+ ```python
720
+ # ❌ WRONG - API key checked first
721
+ api_key = os.getenv("APILOGICSERVER_CHATGPT_APIKEY")
722
+ if not api_key:
723
+ # Apply fallback - tests never reach test context!
724
+ return
725
+
726
+ test_context = _load_test_context(logic_row) # Never reached in tests
727
+ ```
728
+ **What happens**: Tests use fallback logic instead of test context
729
+ **Error**: Non-deterministic tests, "fallback_used" flag set incorrectly
730
+ **Why it fails**: Test context should override API key check
731
+ **Fix**: Check test context FIRST, then API key
732
+
733
+ ✅ **CORRECT ORDER** (test context first):
734
+ ```python
735
+ # Check test context FIRST (for reproducible testing)
736
+ test_context = _load_test_context(logic_row)
737
+ if test_context and 'selected_supplier_id' in test_context:
738
+ # Use test context
739
+ return
740
+
741
+ # Then check API key
742
+ api_key = os.getenv("APILOGICSERVER_CHATGPT_APIKEY")
743
+ if not api_key:
744
+ # Apply fallback
745
+ return
746
+ ```
747
+
748
+ Why: Tests should run consistently without requiring OpenAI API key. Test context is explicitly provided configuration that should override API calls.
749
+
750
+ =============================================================================
751
+ 🚨 CRITICAL: Model Relationship Checklist
752
+ =============================================================================
753
+
754
+ When adding SysXxxReq audit table, **ONLY add relationships where FKs exist:**
755
+
756
+ ✅ **DO add relationships:**
757
+ 1. Parent models referenced by FKs in SysXxxReq
758
+ - Example: `product_id` FK → Add to `Product` class
759
+ - Example: `item_id` FK → Add to `Item` class
760
+ - Example: `chosen_supplier_id` FK → Add to `Supplier` class
761
+
762
+ 2. SysXxxReq model itself (parent relationships)
763
+ - Bidirectional: `back_populates` for standard FKs
764
+ - Unidirectional: `foreign_keys=[...]` for non-standard FKs
765
+
766
+ ❌ **DO NOT add relationships:**
767
+ 1. Models with no FK to/from SysXxxReq
768
+ - Example: `ProductSupplier` has no FK to `SysSupplierReq`
769
+ - Example: `Order` has no FK to `SysSupplierReq`
770
+ - **Adding relationships without FKs causes NoForeignKeysError**
771
+
772
+ **Verification Before Adding Relationship:**
773
+ ```python
774
+ # Before adding relationship to Model X, verify:
775
+ # 1. Does SysXxxReq have FK to Model X? OR
776
+ # 2. Does Model X have FK to SysXxxReq?
777
+ # If NO to both → DO NOT add relationship
778
+ ```
779
+
780
+ **Common Mistake:**
781
+ ```python
782
+ # ❌ WRONG - ProductSupplier has no FK relationship to SysSupplierReq
783
+ class ProductSupplier(Base):
784
+ SysSupplierReqList : Mapped[List["SysSupplierReq"]] = relationship(...)
785
+ # This will cause: NoForeignKeysError at server startup
786
+ ```
787
+
788
+ **Correct Pattern:**
789
+ ```python
790
+ # ✅ CORRECT - Only add where FK exists
791
+ class Product(Base): # Has FK from SysSupplierReq.product_id
792
+ SysSupplierReqList : Mapped[List["SysSupplierReq"]] = relationship(back_populates="product")
793
+
794
+ class Item(Base): # Has FK from SysSupplierReq.item_id
795
+ SysSupplierReqList : Mapped[List["SysSupplierReq"]] = relationship(back_populates="item")
796
+
797
+ # ✅ DO NOT add relationship to Supplier for chosen_supplier_id
798
+ # - This is an AI result field (not standard parent-child relationship)
799
+ # - Access via SysSupplierReq.chosen_supplier (unidirectional) is sufficient
800
+ # - Adding reverse relationship causes NoForeignKeysError
801
+
802
+ # ✅ DO NOT add relationship to ProductSupplier (no FK exists)
803
+ ```
804
+
805
+ ### OpenAI API (v1.0.0+)
806
+
807
+ **CRITICAL: Use modern OpenAI API**
808
+
809
+ ❌ OLD API (deprecated, will fail):
810
+ ```python
811
+ import openai
812
+ openai.api_key = api_key
813
+ response = openai.ChatCompletion.create(...) # ❌ Not supported in openai>=1.0.0
814
+ ```
815
+
816
+ ✅ NEW API (correct pattern):
817
+ ```python
818
+ from openai import OpenAI
819
+
820
+ client = OpenAI(api_key=api_key)
821
+ response = client.chat.completions.create(
822
+ model="gpt-4o-2024-08-06",
823
+ messages=[...]
824
+ )
825
+ ```
826
+
827
+ ### Common Pitfalls
828
+
829
+ **Pass CLASS to new_logic_row, not instance:**
830
+ ```python
831
+ # ❌ WRONG
832
+ req = models.SysXxxReq()
833
+ logic_row.new_logic_row(req) # TypeError
834
+
835
+ # ✅ CORRECT
836
+ logic_row.new_logic_row(models.SysXxxReq)
837
+ ```
838
+
839
+ **Access attributes via .row property:**
840
+ ```python
841
+ # ❌ WRONG
842
+ req_logic_row.product_id = 123 # AttributeError
843
+
844
+ # ✅ CORRECT
845
+ req = req_logic_row.row
846
+ req.product_id = 123
847
+ ```
848
+
849
+ **Use LogicBank insert, not SQLAlchemy:**
850
+ ```python
851
+ # ❌ WRONG
852
+ session.add(req)
853
+ session.flush() # Bypasses LogicBank
854
+
855
+ # ✅ CORRECT
856
+ req_logic_row.insert(reason="...") # Triggers events
857
+ ```
858
+
859
+ **Decimal handling in AI scoring:**
860
+ ```python
861
+ # ❌ WRONG - Decimal × float
862
+ cost = supplier.unit_cost # Returns Decimal
863
+ score = cost * 0.5 # TypeError
864
+
865
+ # ✅ CORRECT - Convert to float first
866
+ cost = float(supplier.unit_cost) if supplier.unit_cost else 999999.0
867
+ score = cost * 0.5
868
+ ```
869
+
870
+ ## File Structure
871
+
872
+ ```
873
+ logic/
874
+ logic_discovery/
875
+ check_credit.py # Business logic with deterministic rules + AI event
876
+ ai_requests/ # AI handlers directory
877
+ __init__.py # Python package marker
878
+ supplier_selection.py # AI handler + wrapper function
879
+ system/
880
+ populate_ai_values.py # Reusable introspection utility
881
+ ```
882
+
883
+ ## Multi-Value Pattern
884
+
885
+ For cases where multiple values are needed:
886
+
887
+ ```python
888
+ def assign_multiple_values(row: models.Order, old_row, logic_row):
889
+ """Extract multiple values from AI request."""
890
+ from logic.logic_discovery.ai_requests.supplier_selection import get_supplier_selection_from_ai
891
+
892
+ if not logic_row.is_inserted():
893
+ return
894
+
895
+ # Call wrapper - returns object
896
+ req = get_supplier_selection_from_ai(
897
+ product_id=row.product_id,
898
+ item_id=row.id,
899
+ logic_row=logic_row
900
+ )
901
+
902
+ # Extract multiple values
903
+ row.supplier_id = req.chosen_supplier_id
904
+ row.unit_price = req.chosen_unit_price
905
+ row.lead_time = req.chosen_lead_time
906
+ ```
907
+
908
+ ## Test Context
909
+
910
+ Enable reproducible testing via `config/ai_test_context.yaml`:
911
+
912
+ ```yaml
913
+ world_conditions: 'ship aground in Suez Canal'
914
+ selected_supplier_id: 2
915
+ ```
916
+
917
+ ## Database Model and Alembic Migration Workflow
918
+
919
+ **CRITICAL**: Request Pattern requires SysXxxReq audit table in database.
920
+
921
+ ### Table Structure Convention
922
+
923
+ ```python
924
+ # In database/models.py - Add import at top
925
+ import datetime
926
+
927
+ # Add model class
928
+ class SysSupplierReq(Base):
929
+ __tablename__ = "sys_supplier_req"
930
+ _s_collection_name = 'SysSupplierReq'
931
+
932
+ # Standard AI Audit
933
+ id = Column(Integer, primary_key=True)
934
+ request = Column(String(2000)) # Full AI prompt
935
+ reason = Column(String(500)) # AI explanation
936
+ created_on = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
937
+ fallback_used = Column(Boolean, default=False)
938
+
939
+ # Parent Context Links (FKs to triggering entities)
940
+ item_id = Column(Integer, ForeignKey("item.id"), index=True, nullable=True)
941
+ product_id = Column(Integer, ForeignKey("product.id"), index=True, nullable=False)
942
+
943
+ # AI Results (values selected by AI)
944
+ chosen_supplier_id = Column(Integer, ForeignKey("supplier.id"))
945
+ chosen_unit_price = Column(DECIMAL)
946
+
947
+ # Relationships (bidirectional)
948
+ item : Mapped["Item"] = relationship(back_populates="SysSupplierReqList")
949
+ product : Mapped["Product"] = relationship(back_populates="SysSupplierReqList")
950
+ chosen_supplier : Mapped["Supplier"] = relationship()
951
+
952
+ # Add to parent models (Product, Item):
953
+ SysSupplierReqList : Mapped[List["SysSupplierReq"]] = relationship(back_populates="product")
954
+ ```
955
+
956
+ ### Alembic Migration Workflow (Production-Ready)
957
+
958
+ **COPILOT MUST EXECUTE AUTOMATICALLY** - No user intervention required:
959
+
960
+ #### Step 1: Add model to database/models.py
961
+ - Include `import datetime` at top
962
+ - Add SysXxxReq class with all fields
963
+ - Add bidirectional relationships to parent models
964
+
965
+ #### Step 2: Generate migration
966
+ ```bash
967
+ cd database && alembic revision --autogenerate -m "Add sys_supplier_req table"
968
+ ```
969
+ Creates: `database/alembic/versions/xxxxx_add_sys_supplier_req_table.py`
970
+
971
+ #### Step 3: Clean migration file (CRITICAL - COPILOT MUST DO THIS)
972
+ Alembic --autogenerate detects ALL differences between models.py and database.
973
+
974
+ **COPILOT MUST AUTOMATICALLY:**
975
+ 1. Read generated migration file from `database/alembic/versions/`
976
+ 2. **KEEP**: CREATE TABLE sys_supplier_req statement
977
+ 3. **REMOVE**: ANY ALTER TABLE operations on existing tables (unrelated changes)
978
+ 4. **SIMPLIFY**: downgrade() to just DROP TABLE
979
+ 5. Save cleaned migration file
980
+
981
+ Example cleaned migration:
982
+ ```python
983
+ def upgrade():
984
+ op.create_table('sys_supplier_req',
985
+ sa.Column('id', sa.Integer(), nullable=False),
986
+ sa.Column('item_id', sa.Integer(), nullable=True),
987
+ sa.Column('product_id', sa.Integer(), nullable=False),
988
+ sa.Column('chosen_supplier_id', sa.Integer(), nullable=True),
989
+ sa.Column('chosen_unit_price', sa.DECIMAL(), nullable=True),
990
+ sa.Column('request', sa.String(length=2000), nullable=True),
991
+ sa.Column('reason', sa.String(length=500), nullable=True),
992
+ sa.Column('created_on', sa.DateTime(), nullable=False),
993
+ sa.Column('fallback_used', sa.Boolean(), nullable=True),
994
+ sa.ForeignKeyConstraint(['chosen_supplier_id'], ['supplier.id']),
995
+ sa.ForeignKeyConstraint(['item_id'], ['item.id']),
996
+ sa.ForeignKeyConstraint(['product_id'], ['product.id']),
997
+ sa.PrimaryKeyConstraint('id')
998
+ )
999
+
1000
+ def downgrade():
1001
+ op.drop_table('sys_supplier_req')
1002
+ ```
1003
+
1004
+ #### Step 4: Apply migration
1005
+ ```bash
1006
+ cd database && alembic upgrade head
1007
+ ```
1008
+
1009
+ #### Step 5: Update ui/admin/admin.yaml
1010
+ Add SysSupplierReq resource with all fields and relationships.
1011
+
1012
+ #### Step 6: User restarts server
1013
+ To load new model into SQLAlchemy session.
1014
+
1015
+ ### Complete Automated Workflow
1016
+
1017
+ When user provides natural language with AI requirement, COPILOT MUST:
1018
+
1019
+ 1. ✅ Check if sys_xxx_req table exists in database (not just models.py)
1020
+ 2. ✅ Add model to database/models.py (include datetime import)
1021
+ 3. ✅ Add bidirectional relationships to parent models
1022
+ 4. ✅ Run: `cd database && alembic revision --autogenerate -m "..."`
1023
+ 5. ✅ **AUTOMATICALLY** find new migration file in database/alembic/versions/
1024
+ 6. ✅ **AUTOMATICALLY** read migration file
1025
+ 7. ✅ **AUTOMATICALLY** clean it (remove ALTER TABLE, keep CREATE TABLE)
1026
+ 8. ✅ **AUTOMATICALLY** save cleaned migration file
1027
+ 9. ✅ Run: `cd database && alembic upgrade head`
1028
+ 10. ✅ Update ui/admin/admin.yaml
1029
+ 11. ✅ Generate logic files (check_credit.py, supplier_selection.py)
1030
+
1031
+ **NO USER INTERVENTION** - Copilot handles entire migration workflow.
1032
+
1033
+ ### Common Alembic Issues
1034
+
1035
+ **Issue**: "Target database is not up to date"
1036
+ **Solution**: Run `cd database && alembic stamp head`
1037
+
1038
+ **Issue**: "Table already exists"
1039
+ **Solution**: Database has tables but Alembic history is empty. Use `alembic stamp head`.
1040
+
1041
+ **Issue**: "No config file 'alembic.ini' found"
1042
+ **Solution**: Must run alembic commands from database/ directory.
1043
+
1044
+ **Why Alembic (not raw SQL)**:
1045
+ - ✅ Version controlled (migration files in git)
1046
+ - ✅ Reversible (alembic downgrade)
1047
+ - ✅ Team-friendly (others run same migration)
1048
+ - ✅ Staged deployment (dev → test → prod)
1049
+ - ✅ Audit trail (history of schema changes)
1050
+
1051
+ ❌ Raw SQL is demo/testing only - NOT production-ready.
1052
+
1053
+ ## Benefits
1054
+
1055
+ - **Separation of concerns** - Event doesn't know Request Pattern details
1056
+ - **Early event** - AI executes before other rules
1057
+ - **Wrapper hides complexity** - Request Pattern encapsulated
1058
+ - **Returns object** - Caller extracts needed values
1059
+ - **Reusable** - Multiple events can call same wrapper
1060
+ - **Testable** - Can mock wrapper independently
1061
+
1062
+ ## Complete Checklist
1063
+
1064
+ When implementing AI logic:
1065
+
1066
+ - [ ] Create request table (SysXxxReq) if needed
1067
+ - [ ] Add fields: Standard AI Audit, Parent Context Links, AI Results
1068
+ - [ ] Create `logic/logic_discovery/ai_requests/` directory
1069
+ - [ ] Create `__init__.py` in ai_requests/
1070
+ - [ ] Implement AI handler in ai_requests/xxx.py
1071
+ - [ ] Register early event on SysXxxReq
1072
+ - [ ] Implement wrapper function (returns object)
1073
+ - [ ] Call wrapper from receiver event
1074
+ - [ ] Extract values from returned object
1075
+ - [ ] Create `config/ai_test_context.yaml`
1076
+ - [ ] Update `ui/admin/admin.yaml`
1077
+
1078
+ ---
1079
+
1080
+ For detailed LogicBank patterns, see docs/training/logic_bank_patterns.prompt
1081
+ For deterministic rules, see docs/training/logic_bank_api.prompt