ApiLogicServer 15.4.1__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.
- api_logic_server_cli/add_cust/add_cust.py +6 -2
- api_logic_server_cli/api_logic_server.py +3 -2
- api_logic_server_cli/database/basic_demo.sqlite +0 -0
- api_logic_server_cli/prototypes/base/.github/.copilot-instructions.md +248 -74
- api_logic_server_cli/prototypes/base/docs/training/MCP_Copilot_Integration.md +28 -0
- api_logic_server_cli/prototypes/base/docs/training/OVERVIEW.md +64 -0
- api_logic_server_cli/prototypes/base/docs/training/README.md +140 -0
- api_logic_server_cli/prototypes/base/docs/training/genai_logic_patterns.md +443 -0
- api_logic_server_cli/prototypes/base/docs/training/logic_bank_api.prompt +22 -0
- api_logic_server_cli/prototypes/base/docs/training/logic_bank_patterns.prompt +445 -0
- api_logic_server_cli/prototypes/base/docs/training/probabilistic_logic.prompt +1074 -0
- api_logic_server_cli/prototypes/base/docs/training/probabilistic_logic_guide.md +444 -0
- api_logic_server_cli/prototypes/base/docs/training/probabilistic_template.py +326 -0
- api_logic_server_cli/prototypes/base/logic/logic_discovery/auto_discovery.py +8 -9
- api_logic_server_cli/prototypes/basic_demo/.github/.copilot-instructions.md +333 -121
- api_logic_server_cli/prototypes/basic_demo/.github/welcome.md +15 -1
- api_logic_server_cli/prototypes/basic_demo/customizations/database/db.sqlite +0 -0
- api_logic_server_cli/prototypes/basic_demo/iteration/database/db.sqlite +0 -0
- api_logic_server_cli/prototypes/manager/.github/.copilot-instructions.md +61 -155
- api_logic_server_cli/prototypes/manager/.github/welcome.md +43 -0
- api_logic_server_cli/prototypes/manager/samples/basic_demo_sample/.github/.copilot-instructions.md +502 -76
- {apilogicserver-15.4.1.dist-info → apilogicserver-16.0.0.dist-info}/METADATA +1 -1
- {apilogicserver-15.4.1.dist-info → apilogicserver-16.0.0.dist-info}/RECORD +27 -19
- {apilogicserver-15.4.1.dist-info → apilogicserver-16.0.0.dist-info}/WHEEL +0 -0
- {apilogicserver-15.4.1.dist-info → apilogicserver-16.0.0.dist-info}/entry_points.txt +0 -0
- {apilogicserver-15.4.1.dist-info → apilogicserver-16.0.0.dist-info}/licenses/LICENSE +0 -0
- {apilogicserver-15.4.1.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
|