ApiLogicServer 15.4.3__py3-none-any.whl → 16.0.0__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 (26) 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 +228 -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 +22 -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 +1074 -0
  11. api_logic_server_cli/prototypes/base/docs/training/probabilistic_logic_guide.md +444 -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 +326 -142
  15. api_logic_server_cli/prototypes/basic_demo/.github/welcome.md +15 -1
  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/samples/basic_demo_sample/.github/.copilot-instructions.md +502 -76
  21. {apilogicserver-15.4.3.dist-info → apilogicserver-16.0.0.dist-info}/METADATA +1 -1
  22. {apilogicserver-15.4.3.dist-info → apilogicserver-16.0.0.dist-info}/RECORD +26 -18
  23. {apilogicserver-15.4.3.dist-info → apilogicserver-16.0.0.dist-info}/WHEEL +0 -0
  24. {apilogicserver-15.4.3.dist-info → apilogicserver-16.0.0.dist-info}/entry_points.txt +0 -0
  25. {apilogicserver-15.4.3.dist-info → apilogicserver-16.0.0.dist-info}/licenses/LICENSE +0 -0
  26. {apilogicserver-15.4.3.dist-info → apilogicserver-16.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1074 @@
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 - proper initialization of ai_request data
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. Check for test context first (for reproducible testing)
403
+ 2. If no test context and API key exists, call OpenAI
404
+ 3. If no API key, 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
+ # Check for test context first (BEFORE API key check)
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
+ selected_supplier = None
429
+
430
+ if context_file.exists():
431
+ with open(str(context_file), 'r') as f:
432
+ test_context = yaml.safe_load(f)
433
+ if test_context and 'selected_supplier_id' in test_context:
434
+ supplier_id = test_context['selected_supplier_id']
435
+ selected_supplier = next((s for s in suppliers if s.supplier_id == supplier_id), None)
436
+ if selected_supplier:
437
+ # Build candidate summary for request field
438
+ candidate_summary = ', '.join([f"{s.supplier.name if s.supplier else 'Unknown'}(${s.unit_cost})" for s in suppliers])
439
+ world = test_context.get('world_conditions', 'normal conditions')
440
+ row.request = f"Select supplier for {product.name}: Candidates=[{candidate_summary}], World={world}"
441
+ row.reason = f"TEST MODE: Selected {selected_supplier.supplier.name if selected_supplier.supplier else 'supplier'} (${selected_supplier.unit_cost}) - world: {world}"
442
+ logic_row.log(f"Using test context: supplier {supplier_id}")
443
+ row.fallback_used = False
444
+
445
+ # If no test context, try AI (check for API key)
446
+ if not selected_supplier:
447
+ api_key = os.getenv("APILOGICSERVER_CHATGPT_APIKEY")
448
+ if api_key:
449
+ try:
450
+ # Call OpenAI API with structured prompt
451
+ from openai import OpenAI
452
+ import json
453
+
454
+ client = OpenAI(api_key=api_key)
455
+
456
+ # Build candidate data for prompt
457
+ candidate_data = []
458
+ for supplier in suppliers:
459
+ candidate_data.append({
460
+ 'supplier_id': supplier.supplier_id,
461
+ 'supplier_name': supplier.supplier.name if supplier.supplier else 'Unknown',
462
+ 'unit_cost': float(supplier.unit_cost) if supplier.unit_cost else 0.0,
463
+ 'lead_time_days': supplier.lead_time_days if hasattr(supplier, 'lead_time_days') else None
464
+ })
465
+
466
+ world_conditions = test_context.get('world_conditions', 'normal conditions') if 'test_context' in locals() else 'normal conditions'
467
+
468
+ prompt = f"""
469
+ You are a supply chain optimization expert. Select the best supplier from the candidates below.
470
+
471
+ World Conditions: {world_conditions}
472
+
473
+ Optimization Goal: fastest reliable delivery while keeping costs reasonable
474
+
475
+ Candidates:
476
+ {yaml.dump(candidate_data, default_flow_style=False)}
477
+
478
+ Respond with ONLY valid JSON in this exact format (no markdown, no code blocks):
479
+ {{
480
+ "chosen_supplier_id": <id>,
481
+ "chosen_unit_price": <price>,
482
+ "reason": "<brief explanation>"
483
+ }}
484
+ """
485
+
486
+ # Populate request field with actual prompt summary
487
+ candidate_list = ', '.join([c['supplier_name'] + '($' + str(c['unit_cost']) + ')' for c in candidate_data])
488
+ row.request = f"AI Prompt: Product={product.name}, World={world_conditions}, Candidates={len(candidate_data)}: {candidate_list}"
489
+
490
+ logic_row.log(f"Calling OpenAI API with {len(candidate_data)} candidates")
491
+
492
+ response = client.chat.completions.create(
493
+ model="gpt-4o-2024-08-06",
494
+ messages=[
495
+ {"role": "system", "content": "You are a supply chain expert. Respond with valid JSON only."},
496
+ {"role": "user", "content": prompt}
497
+ ],
498
+ temperature=0.7
499
+ )
500
+
501
+ response_text = response.choices[0].message.content.strip()
502
+ logic_row.log(f"OpenAI response: {response_text}")
503
+
504
+ # Parse JSON response
505
+ ai_result = json.loads(response_text)
506
+
507
+ # Find the selected supplier
508
+ selected_supplier = next((s for s in suppliers if s.supplier_id == ai_result['chosen_supplier_id']), None)
509
+ if selected_supplier:
510
+ supplier_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
511
+ row.reason = f"AI: {supplier_name} (${selected_supplier.unit_cost}) - {ai_result.get('reason', 'No reason provided')}"
512
+ row.fallback_used = False
513
+ else:
514
+ logic_row.log(f"AI selected invalid supplier_id {ai_result['chosen_supplier_id']}, using fallback")
515
+ selected_supplier = min(suppliers, key=lambda s: float(s.unit_cost) if s.unit_cost else 999999.0)
516
+ fallback_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
517
+ row.reason = f"Fallback: {fallback_name} (${selected_supplier.unit_cost}) - AI returned invalid supplier"
518
+ row.fallback_used = True
519
+
520
+ except Exception as e:
521
+ logic_row.log(f"OpenAI API error: {e}, using fallback")
522
+ selected_supplier = min(suppliers, key=lambda s: float(s.unit_cost) if s.unit_cost else 999999.0)
523
+ fallback_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
524
+ candidate_summary = ', '.join([f"{s.supplier.name if s.supplier else 'Unknown'}(${s.unit_cost})" for s in suppliers])
525
+ row.request = f"Select supplier for {product.name}: Candidates=[{candidate_summary}] - API ERROR"
526
+ row.reason = f"Fallback: {fallback_name} (${selected_supplier.unit_cost}) - API error: {str(e)[:100]}"
527
+ row.fallback_used = True
528
+ else:
529
+ # No API key - use fallback strategy (min cost)
530
+ logic_row.log("No API key, using fallback: minimum cost")
531
+ selected_supplier = min(suppliers, key=lambda s: float(s.unit_cost) if s.unit_cost else 999999.0)
532
+ fallback_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
533
+ candidate_summary = ', '.join([f"{s.supplier.name if s.supplier else 'Unknown'}(${s.unit_cost})" for s in suppliers])
534
+ row.request = f"Select supplier for {product.name}: Candidates=[{candidate_summary}] - NO API KEY"
535
+ row.reason = f"Fallback: {fallback_name} (${selected_supplier.unit_cost}) - minimum cost (no API key)"
536
+ row.fallback_used = True
537
+
538
+ # Populate AI results
539
+ if selected_supplier:
540
+ row.chosen_supplier_id = int(selected_supplier.supplier_id) # Must be int for SQLite FK
541
+ row.chosen_unit_price = selected_supplier.unit_cost
542
+ logic_row.log(f"Selected supplier {selected_supplier.supplier_id} with price {selected_supplier.unit_cost}")
543
+
544
+ def get_supplier_selection_from_ai(product_id: int, item_id: int, logic_row: LogicRow) -> models.SysSupplierReq:
545
+ """
546
+ Wrapper function called from Item (Receiver) early event.
547
+
548
+ See: https://apilogicserver.github.io/Docs/Logic-Using-AI/
549
+
550
+ 1. Creates SysSupplierReq and inserts it (triggering AI event that populates chosen_* fields)
551
+ 2. Returns populated object
552
+
553
+ This wrapper hides Request Pattern implementation details.
554
+ See https://apilogicserver.github.io/Docs/Logic/#rule-patterns.
555
+
556
+ Returns populated SysSupplierReq object with:
557
+ - Standard AI Audit: request, reason, created_on, fallback_used
558
+ - Parent Context Links: item_id, product_id
559
+ - AI Results: chosen_supplier_id, chosen_unit_price
560
+ """
561
+ # 1. Create request row using parent's logic_row
562
+ supplier_req_logic_row = logic_row.new_logic_row(models.SysSupplierReq)
563
+ supplier_req = supplier_req_logic_row.row
564
+
565
+ # 2. Set parent context (FK links)
566
+ # Note: request/reason fields populated by AI event handler with actual prompt/candidate data
567
+ supplier_req.product_id = product_id
568
+ supplier_req.item_id = item_id
569
+
570
+ # 3. Insert triggers early event which populates AI values (chosen_* fields, request, reason)
571
+ supplier_req_logic_row.insert(reason="AI supplier selection request")
572
+
573
+ # 4. Log filled request object for visibility
574
+ logic_row.log(f"AI Request: {supplier_req.request}")
575
+ logic_row.log(f"AI Results: supplier_id={supplier_req.chosen_supplier_id}, price={supplier_req.chosen_unit_price}, reason={supplier_req.reason}")
576
+
577
+ # 5. Return populated object (chosen_* fields now set by AI)
578
+ return supplier_req
579
+ ```
580
+
581
+ ## Key Patterns
582
+
583
+ ### Key Implementation Points
584
+
585
+ **Test Context Priority:**
586
+ - ALWAYS check test context BEFORE API key
587
+ - Enables reproducible testing without API calls
588
+ - File: `config/ai_test_context.yaml`
589
+
590
+ **Fallback Strategy:**
591
+ - When no suppliers: Set `fallback_used = True`, return early
592
+ - When no test context and no API key: Use min cost
593
+ - When no test context but have API key: Call API (or fallback for demo)
594
+
595
+ **Type Handling:**
596
+ - Foreign keys (IDs): Must be `int` not `Decimal`
597
+ - Prices: Can be `Decimal`
598
+ - Use `float()` for comparisons: `float(s.unit_cost)`
599
+
600
+ **Path Resolution:**
601
+ - Use `Path(__file__).resolve()` for absolute paths
602
+ - Navigate up from `logic/logic_discovery/ai_requests/` to project root
603
+ - Then down to `config/ai_test_context.yaml`
604
+
605
+ ### Request Pattern
606
+
607
+ The wrapper function encapsulates LogicBank's Request Pattern:
608
+
609
+ ```python
610
+ # Create using new_logic_row (pass CLASS not instance)
611
+ req_logic_row = logic_row.new_logic_row(models.SysXxxReq)
612
+
613
+ # Access instance via .row property
614
+ req = req_logic_row.row
615
+
616
+ # Set context fields
617
+ req.context_id = some_value
618
+
619
+ # Insert triggers early event handler
620
+ req_logic_row.insert(reason="...")
621
+
622
+ # Return populated object
623
+ return req
624
+ ```
625
+
626
+ ### Request Table Structure
627
+
628
+ **Standard AI Audit (constant for all requests)**
629
+ ```python
630
+ id = Column(Integer, primary_key=True)
631
+ request = Column(String(2000)) # AI prompt sent
632
+ reason = Column(String(500)) # AI reasoning
633
+ created_on = Column(DateTime) # Timestamp
634
+ fallback_used = Column(Boolean) # Did AI fail?
635
+ ```
636
+
637
+ **Parent Context Links (FKs to triggering entities)**
638
+ ```python
639
+ item_id = Column(ForeignKey('item.id'))
640
+ product_id = Column(ForeignKey('product.id'))
641
+ ```
642
+
643
+ **AI Results (values selected by AI)**
644
+ ```python
645
+ chosen_supplier_id = Column(ForeignKey('supplier.id'))
646
+ chosen_unit_price = Column(DECIMAL)
647
+ ```
648
+
649
+ =============================================================================
650
+ 🚨 REQUEST PATTERN FAILURE MODES (Learned from Production Debugging)
651
+ =============================================================================
652
+
653
+ **CONTEXT**: These are REAL failures that occurred during implementation. Each pattern caused server crashes, test failures, or silent bugs.
654
+
655
+ **FAILURE #1: Formula Returns AI Value Directly**
656
+ ```python
657
+ # ❌ WRONG - AI handler never fires
658
+ Rule.formula(
659
+ derive=models.Item.unit_price,
660
+ as_expression=lambda row: get_ai_supplier_price(row)
661
+ )
662
+ ```
663
+ **What happens**: Formula executes but AI handler never fires, no audit trail created.
664
+ **Error**: Silent failure - no SysSupplierReq records, unit_price has wrong value
665
+ **Why it fails**: Formula should PRESERVE value, not COMPUTE it via AI
666
+ **Fix**: Use early event pattern (see above)
667
+
668
+ **FAILURE #2: Pass Instance to new_logic_row()**
669
+ ```python
670
+ # ❌ WRONG - Pass instance instead of class
671
+ supplier_req = models.SysSupplierReq()
672
+ supplier_req_logic_row = logic_row.new_logic_row(supplier_req)
673
+ ```
674
+ **What happens**: Python tries to call the instance as a function
675
+ **Error**: `TypeError: 'SysSupplierReq' object is not callable`
676
+ **Why it fails**: new_logic_row() expects a CLASS, not an instance
677
+ **Fix**: Pass the class: `logic_row.new_logic_row(models.SysSupplierReq)`
678
+
679
+ **FAILURE #3: Access Attributes on LogicRow Instead of .row**
680
+ ```python
681
+ # ❌ WRONG - LogicRow doesn't have business attributes
682
+ supplier_req_logic_row = logic_row.new_logic_row(models.SysSupplierReq)
683
+ item_row.unit_price = supplier_req_logic_row.chosen_unit_price
684
+ ```
685
+ **What happens**: LogicRow is a wrapper, not the business object
686
+ **Error**: `AttributeError: 'LogicRow' object has no attribute 'chosen_unit_price'`
687
+ **Why it fails**: Business attributes are on .row property, not LogicRow wrapper
688
+ **Fix**: Access via .row: `supplier_req = supplier_req_logic_row.row`
689
+
690
+ **FAILURE #4: Use session.add/flush Directly**
691
+ ```python
692
+ # ❌ WRONG - Bypasses LogicBank
693
+ supplier_req = models.SysSupplierReq()
694
+ logic_row.session.add(supplier_req)
695
+ logic_row.session.flush()
696
+ ```
697
+ **What happens**: Object added to database but LogicBank events never fire
698
+ **Error**: Silent failure - AI handler never executes, no AI selection
699
+ **Why it fails**: Direct SQLAlchemy calls bypass LogicBank event chain
700
+ **Fix**: Use logic_row.new_logic_row() + explicit .insert()
701
+
702
+ **FAILURE #5: Forget to Copy Result Back**
703
+ ```python
704
+ # ❌ WRONG - AI runs but result not propagated
705
+ supplier_req_logic_row.insert(reason="AI supplier selection")
706
+ # Missing copy: item_row.unit_price = supplier_req.chosen_unit_price
707
+ ```
708
+ **What happens**: SysSupplierReq populated correctly but Item.unit_price unset
709
+ **Error**: Silent failure - AI works but business logic breaks (unit_price = None)
710
+ **Why it fails**: No automatic propagation between tables
711
+ **Fix**: Explicitly copy: `item_row.unit_price = supplier_req.chosen_unit_price`
712
+
713
+ **FAILURE #6: Test Context Checked After API Key**
714
+ ```python
715
+ # ❌ WRONG - API key checked first
716
+ api_key = os.getenv("APILOGICSERVER_CHATGPT_APIKEY")
717
+ if not api_key:
718
+ # Apply fallback - tests never reach test context!
719
+ return
720
+
721
+ test_context = _load_test_context(logic_row) # Never reached in tests
722
+ ```
723
+ **What happens**: Tests use fallback logic instead of test context
724
+ **Error**: Non-deterministic tests, "fallback_used" flag set incorrectly
725
+ **Why it fails**: Test context should override API key check
726
+ **Fix**: Check test context FIRST, then API key
727
+
728
+ ✅ **CORRECT ORDER** (test context first):
729
+ ```python
730
+ # Check test context FIRST (for reproducible testing)
731
+ test_context = _load_test_context(logic_row)
732
+ if test_context and 'selected_supplier_id' in test_context:
733
+ # Use test context
734
+ return
735
+
736
+ # Then check API key
737
+ api_key = os.getenv("APILOGICSERVER_CHATGPT_APIKEY")
738
+ if not api_key:
739
+ # Apply fallback
740
+ return
741
+ ```
742
+
743
+ Why: Tests should run consistently without requiring OpenAI API key. Test context is explicitly provided configuration that should override API calls.
744
+
745
+ =============================================================================
746
+ 🚨 CRITICAL: Model Relationship Checklist
747
+ =============================================================================
748
+
749
+ When adding SysXxxReq audit table, **ONLY add relationships where FKs exist:**
750
+
751
+ ✅ **DO add relationships:**
752
+ 1. Parent models referenced by FKs in SysXxxReq
753
+ - Example: `product_id` FK → Add to `Product` class
754
+ - Example: `item_id` FK → Add to `Item` class
755
+ - Example: `chosen_supplier_id` FK → Add to `Supplier` class
756
+
757
+ 2. SysXxxReq model itself (parent relationships)
758
+ - Bidirectional: `back_populates` for standard FKs
759
+ - Unidirectional: `foreign_keys=[...]` for non-standard FKs
760
+
761
+ ❌ **DO NOT add relationships:**
762
+ 1. Models with no FK to/from SysXxxReq
763
+ - Example: `ProductSupplier` has no FK to `SysSupplierReq`
764
+ - Example: `Order` has no FK to `SysSupplierReq`
765
+ - **Adding relationships without FKs causes NoForeignKeysError**
766
+
767
+ **Verification Before Adding Relationship:**
768
+ ```python
769
+ # Before adding relationship to Model X, verify:
770
+ # 1. Does SysXxxReq have FK to Model X? OR
771
+ # 2. Does Model X have FK to SysXxxReq?
772
+ # If NO to both → DO NOT add relationship
773
+ ```
774
+
775
+ **Common Mistake:**
776
+ ```python
777
+ # ❌ WRONG - ProductSupplier has no FK relationship to SysSupplierReq
778
+ class ProductSupplier(Base):
779
+ SysSupplierReqList : Mapped[List["SysSupplierReq"]] = relationship(...)
780
+ # This will cause: NoForeignKeysError at server startup
781
+ ```
782
+
783
+ **Correct Pattern:**
784
+ ```python
785
+ # ✅ CORRECT - Only add where FK exists
786
+ class Product(Base): # Has FK from SysSupplierReq.product_id
787
+ SysSupplierReqList : Mapped[List["SysSupplierReq"]] = relationship(back_populates="product")
788
+
789
+ class Item(Base): # Has FK from SysSupplierReq.item_id
790
+ SysSupplierReqList : Mapped[List["SysSupplierReq"]] = relationship(back_populates="item")
791
+
792
+ class Supplier(Base): # Has FK from SysSupplierReq.chosen_supplier_id
793
+ SysSupplierReqList : Mapped[List["SysSupplierReq"]] = relationship(foreign_keys="SysSupplierReq.chosen_supplier_id")
794
+
795
+ # ✅ DO NOT add relationship to ProductSupplier (no FK exists)
796
+ ```
797
+
798
+ ### OpenAI API (v1.0.0+)
799
+
800
+ **CRITICAL: Use modern OpenAI API**
801
+
802
+ ❌ OLD API (deprecated, will fail):
803
+ ```python
804
+ import openai
805
+ openai.api_key = api_key
806
+ response = openai.ChatCompletion.create(...) # ❌ Not supported in openai>=1.0.0
807
+ ```
808
+
809
+ ✅ NEW API (correct pattern):
810
+ ```python
811
+ from openai import OpenAI
812
+
813
+ client = OpenAI(api_key=api_key)
814
+ response = client.chat.completions.create(
815
+ model="gpt-4o-2024-08-06",
816
+ messages=[...]
817
+ )
818
+ ```
819
+
820
+ ### Common Pitfalls
821
+
822
+ **Pass CLASS to new_logic_row, not instance:**
823
+ ```python
824
+ # ❌ WRONG
825
+ req = models.SysXxxReq()
826
+ logic_row.new_logic_row(req) # TypeError
827
+
828
+ # ✅ CORRECT
829
+ logic_row.new_logic_row(models.SysXxxReq)
830
+ ```
831
+
832
+ **Access attributes via .row property:**
833
+ ```python
834
+ # ❌ WRONG
835
+ req_logic_row.product_id = 123 # AttributeError
836
+
837
+ # ✅ CORRECT
838
+ req = req_logic_row.row
839
+ req.product_id = 123
840
+ ```
841
+
842
+ **Use LogicBank insert, not SQLAlchemy:**
843
+ ```python
844
+ # ❌ WRONG
845
+ session.add(req)
846
+ session.flush() # Bypasses LogicBank
847
+
848
+ # ✅ CORRECT
849
+ req_logic_row.insert(reason="...") # Triggers events
850
+ ```
851
+
852
+ **Decimal handling in AI scoring:**
853
+ ```python
854
+ # ❌ WRONG - Decimal × float
855
+ cost = supplier.unit_cost # Returns Decimal
856
+ score = cost * 0.5 # TypeError
857
+
858
+ # ✅ CORRECT - Convert to float first
859
+ cost = float(supplier.unit_cost) if supplier.unit_cost else 999999.0
860
+ score = cost * 0.5
861
+ ```
862
+
863
+ ## File Structure
864
+
865
+ ```
866
+ logic/
867
+ logic_discovery/
868
+ check_credit.py # Business logic with deterministic rules + AI event
869
+ ai_requests/ # AI handlers directory
870
+ __init__.py # Python package marker
871
+ supplier_selection.py # AI handler + wrapper function
872
+ system/
873
+ populate_ai_values.py # Reusable introspection utility
874
+ ```
875
+
876
+ ## Multi-Value Pattern
877
+
878
+ For cases where multiple values are needed:
879
+
880
+ ```python
881
+ def assign_multiple_values(row: models.Order, old_row, logic_row):
882
+ """Extract multiple values from AI request."""
883
+ from logic.logic_discovery.ai_requests.supplier_selection import get_supplier_selection_from_ai
884
+
885
+ if not logic_row.is_inserted():
886
+ return
887
+
888
+ # Call wrapper - returns object
889
+ req = get_supplier_selection_from_ai(
890
+ product_id=row.product_id,
891
+ item_id=row.id,
892
+ logic_row=logic_row
893
+ )
894
+
895
+ # Extract multiple values
896
+ row.supplier_id = req.chosen_supplier_id
897
+ row.unit_price = req.chosen_unit_price
898
+ row.lead_time = req.chosen_lead_time
899
+ ```
900
+
901
+ ## Test Context
902
+
903
+ Enable reproducible testing via `config/ai_test_context.yaml`:
904
+
905
+ ```yaml
906
+ world_conditions: 'ship aground in Suez Canal'
907
+ selected_supplier_id: 2
908
+ ```
909
+
910
+ ## Database Model and Alembic Migration Workflow
911
+
912
+ **CRITICAL**: Request Pattern requires SysXxxReq audit table in database.
913
+
914
+ ### Table Structure Convention
915
+
916
+ ```python
917
+ # In database/models.py - Add import at top
918
+ import datetime
919
+
920
+ # Add model class
921
+ class SysSupplierReq(Base):
922
+ __tablename__ = "sys_supplier_req"
923
+ _s_collection_name = 'SysSupplierReq'
924
+
925
+ # Standard AI Audit
926
+ id = Column(Integer, primary_key=True)
927
+ request = Column(String(2000)) # Full AI prompt
928
+ reason = Column(String(500)) # AI explanation
929
+ created_on = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
930
+ fallback_used = Column(Boolean, default=False)
931
+
932
+ # Parent Context Links (FKs to triggering entities)
933
+ item_id = Column(Integer, ForeignKey("item.id"), index=True, nullable=True)
934
+ product_id = Column(Integer, ForeignKey("product.id"), index=True, nullable=False)
935
+
936
+ # AI Results (values selected by AI)
937
+ chosen_supplier_id = Column(Integer, ForeignKey("supplier.id"))
938
+ chosen_unit_price = Column(DECIMAL)
939
+
940
+ # Relationships (bidirectional)
941
+ item : Mapped["Item"] = relationship(back_populates="SysSupplierReqList")
942
+ product : Mapped["Product"] = relationship(back_populates="SysSupplierReqList")
943
+ chosen_supplier : Mapped["Supplier"] = relationship()
944
+
945
+ # Add to parent models (Product, Item):
946
+ SysSupplierReqList : Mapped[List["SysSupplierReq"]] = relationship(back_populates="product")
947
+ ```
948
+
949
+ ### Alembic Migration Workflow (Production-Ready)
950
+
951
+ **COPILOT MUST EXECUTE AUTOMATICALLY** - No user intervention required:
952
+
953
+ #### Step 1: Add model to database/models.py
954
+ - Include `import datetime` at top
955
+ - Add SysXxxReq class with all fields
956
+ - Add bidirectional relationships to parent models
957
+
958
+ #### Step 2: Generate migration
959
+ ```bash
960
+ cd database && alembic revision --autogenerate -m "Add sys_supplier_req table"
961
+ ```
962
+ Creates: `database/alembic/versions/xxxxx_add_sys_supplier_req_table.py`
963
+
964
+ #### Step 3: Clean migration file (CRITICAL - COPILOT MUST DO THIS)
965
+ Alembic --autogenerate detects ALL differences between models.py and database.
966
+
967
+ **COPILOT MUST AUTOMATICALLY:**
968
+ 1. Read generated migration file from `database/alembic/versions/`
969
+ 2. **KEEP**: CREATE TABLE sys_supplier_req statement
970
+ 3. **REMOVE**: ANY ALTER TABLE operations on existing tables (unrelated changes)
971
+ 4. **SIMPLIFY**: downgrade() to just DROP TABLE
972
+ 5. Save cleaned migration file
973
+
974
+ Example cleaned migration:
975
+ ```python
976
+ def upgrade():
977
+ op.create_table('sys_supplier_req',
978
+ sa.Column('id', sa.Integer(), nullable=False),
979
+ sa.Column('item_id', sa.Integer(), nullable=True),
980
+ sa.Column('product_id', sa.Integer(), nullable=False),
981
+ sa.Column('chosen_supplier_id', sa.Integer(), nullable=True),
982
+ sa.Column('chosen_unit_price', sa.DECIMAL(), nullable=True),
983
+ sa.Column('request', sa.String(length=2000), nullable=True),
984
+ sa.Column('reason', sa.String(length=500), nullable=True),
985
+ sa.Column('created_on', sa.DateTime(), nullable=False),
986
+ sa.Column('fallback_used', sa.Boolean(), nullable=True),
987
+ sa.ForeignKeyConstraint(['chosen_supplier_id'], ['supplier.id']),
988
+ sa.ForeignKeyConstraint(['item_id'], ['item.id']),
989
+ sa.ForeignKeyConstraint(['product_id'], ['product.id']),
990
+ sa.PrimaryKeyConstraint('id')
991
+ )
992
+
993
+ def downgrade():
994
+ op.drop_table('sys_supplier_req')
995
+ ```
996
+
997
+ #### Step 4: Apply migration
998
+ ```bash
999
+ cd database && alembic upgrade head
1000
+ ```
1001
+
1002
+ #### Step 5: Update ui/admin/admin.yaml
1003
+ Add SysSupplierReq resource with all fields and relationships.
1004
+
1005
+ #### Step 6: User restarts server
1006
+ To load new model into SQLAlchemy session.
1007
+
1008
+ ### Complete Automated Workflow
1009
+
1010
+ When user provides natural language with AI requirement, COPILOT MUST:
1011
+
1012
+ 1. ✅ Check if sys_xxx_req table exists in database (not just models.py)
1013
+ 2. ✅ Add model to database/models.py (include datetime import)
1014
+ 3. ✅ Add bidirectional relationships to parent models
1015
+ 4. ✅ Run: `cd database && alembic revision --autogenerate -m "..."`
1016
+ 5. ✅ **AUTOMATICALLY** find new migration file in database/alembic/versions/
1017
+ 6. ✅ **AUTOMATICALLY** read migration file
1018
+ 7. ✅ **AUTOMATICALLY** clean it (remove ALTER TABLE, keep CREATE TABLE)
1019
+ 8. ✅ **AUTOMATICALLY** save cleaned migration file
1020
+ 9. ✅ Run: `cd database && alembic upgrade head`
1021
+ 10. ✅ Update ui/admin/admin.yaml
1022
+ 11. ✅ Generate logic files (check_credit.py, supplier_selection.py)
1023
+
1024
+ **NO USER INTERVENTION** - Copilot handles entire migration workflow.
1025
+
1026
+ ### Common Alembic Issues
1027
+
1028
+ **Issue**: "Target database is not up to date"
1029
+ **Solution**: Run `cd database && alembic stamp head`
1030
+
1031
+ **Issue**: "Table already exists"
1032
+ **Solution**: Database has tables but Alembic history is empty. Use `alembic stamp head`.
1033
+
1034
+ **Issue**: "No config file 'alembic.ini' found"
1035
+ **Solution**: Must run alembic commands from database/ directory.
1036
+
1037
+ **Why Alembic (not raw SQL)**:
1038
+ - ✅ Version controlled (migration files in git)
1039
+ - ✅ Reversible (alembic downgrade)
1040
+ - ✅ Team-friendly (others run same migration)
1041
+ - ✅ Staged deployment (dev → test → prod)
1042
+ - ✅ Audit trail (history of schema changes)
1043
+
1044
+ ❌ Raw SQL is demo/testing only - NOT production-ready.
1045
+
1046
+ ## Benefits
1047
+
1048
+ - **Separation of concerns** - Event doesn't know Request Pattern details
1049
+ - **Early event** - AI executes before other rules
1050
+ - **Wrapper hides complexity** - Request Pattern encapsulated
1051
+ - **Returns object** - Caller extracts needed values
1052
+ - **Reusable** - Multiple events can call same wrapper
1053
+ - **Testable** - Can mock wrapper independently
1054
+
1055
+ ## Complete Checklist
1056
+
1057
+ When implementing AI logic:
1058
+
1059
+ - [ ] Create request table (SysXxxReq) if needed
1060
+ - [ ] Add fields: Standard AI Audit, Parent Context Links, AI Results
1061
+ - [ ] Create `logic/logic_discovery/ai_requests/` directory
1062
+ - [ ] Create `__init__.py` in ai_requests/
1063
+ - [ ] Implement AI handler in ai_requests/xxx.py
1064
+ - [ ] Register early event on SysXxxReq
1065
+ - [ ] Implement wrapper function (returns object)
1066
+ - [ ] Call wrapper from receiver event
1067
+ - [ ] Extract values from returned object
1068
+ - [ ] Create `config/ai_test_context.yaml`
1069
+ - [ ] Update `ui/admin/admin.yaml`
1070
+
1071
+ ---
1072
+
1073
+ For detailed LogicBank patterns, see docs/training/logic_bank_patterns.prompt
1074
+ For deterministic rules, see docs/training/logic_bank_api.prompt