Probabilistic Logic
AI Value Computation Pattern
This document describes how to implement probabilistic logic (AI-driven value computation) alongside deterministic rules in LogicBank.
Prerequisites
Read these foundation documents first:
- docs/training/genai_logic_patterns.md - Import patterns, auto-discovery
- docs/training/logic_bank_patterns.prompt - Event signatures, logging, Request Pattern
- docs/training/logic_bank_api.prompt - Deterministic rule APIs
============================================================================= 🤖 AUTOMATIC DETECTION: When to Generate Probabilistic Logic =============================================================================
When you see these patterns in user's natural language prompt, AUTOMATICALLY create complete AI value computation infrastructure:
Trigger Phrases: - "use AI to select/choose..." - "optimize using AI..." - "AI should decide..." - "intelligently select based on..." - "considering [multiple factors] choose..." - "IF [condition] THEN use AI to [select/compute]..."
When triggered, automatically generate: - SysXxxReq model (if not exists) + Alembic migration - logic/logic_discovery/check_credit.py (business logic) - logic/logic_discovery/ai_requests/supplier_selection.py (AI handler) - config/ai_test_context.yaml (test context) - Update ui/admin/admin.yaml
============================================================================= 🚨 CRITICAL FIXES FROM PRODUCTION DEBUGGING (Nov 16-21, 2025) =============================================================================
These errors occurred during demo prep and production testing and MUST be avoided:
ERROR 1: Circular Import - "Session is already flushing"
Applies to main logic/declare_logic.py ONLY, not discovery files.
❌ Problem: Importing LogicBank at module level in MAIN logic/declare_logic.py
# In logic/declare_logic.py (main file)
from logic_bank.logic_bank import Rule # ❌ At module level in main file
✅ Solution for main declare_logic.py: Import inside function
# In logic/declare_logic.py (main file)
from database import models # ✅ At module level
def declare_logic():
from logic_bank.logic_bank import Rule # ✅ Inside function in main file
✅ Discovery files (logic_discovery/*.py) are SAFE with module-level imports:
# In logic/logic_discovery/check_credit.py or supplier_selection.py
from logic_bank.logic_bank import Rule # ✅ Safe in discovery files
from logic_bank.exec_row_logic.logic_row import LogicRow # ✅ Safe in discovery files
from database import models # ✅ Preferred pattern
def declare_logic():
# Rules here
ERROR 2: Auto-Discovery Structure Requirements
⚠️ IMPORTANT: logic/logic_discovery/auto_discovery.py is AUTO-GENERATED by ApiLogicServer - It is ALREADY CORRECT in all new projects (handles recursion + skips init.py) - ❌ DO NOT modify auto_discovery.py - ✅ DO create logic files in proper structure that auto-discovery will find
✅ What auto_discovery.py does (already built-in): - Recursively scans logic_discovery/ and all subdirectories - Finds all .py files except auto_discovery.py and init.py - Imports each file and calls declare_logic() function - Works with nested directories like ai_requests/, validation/, etc.
✅ Your responsibility (what Copilot generates):
logic/logic_discovery/
check_credit.py # Has declare_logic() function
ai_requests/ # Subdirectory
__init__.py # Empty file (makes it a package)
supplier_selection.py # Has declare_logic() function
❌ Common mistake: Putting logic in init.py - auto_discovery.py skips init.py files (by design) - Always create separate .py files with declare_logic() functions
ERROR 3: Path Resolution for YAML Files
❌ Problem: Path(file).parent creates relative path
context_file = config_dir / 'ai_test_context.yaml'
if context_file.exists(): # ❌ May fail on relative paths
✅ Solution: Use .resolve() for absolute paths
current_file = Path(__file__).resolve() # ✅ Absolute path
project_root = current_file.parent.parent.parent.parent
context_file = project_root / 'config' / 'ai_test_context.yaml'
if context_file.exists():
with open(str(context_file), 'r') as f: # ✅ Convert to string
ERROR 4: Missing is_deleted() Check in Early Events (Nov 21, 2025)
❌ Problem: Early events fire on delete, but old_row is None
def set_item_unit_price_from_supplier(row: models.Item, old_row: models.Item, logic_row):
# Process on insert OR when product_id changes
if not (logic_row.is_inserted() or row.product_id != old_row.product_id): # ❌ CRASH on delete
return
✅ Solution: Check is_deleted() FIRST, before accessing old_row
def set_item_unit_price_from_supplier(row: models.Item, old_row: models.Item, logic_row):
from logic.logic_discovery.ai_requests.supplier_selection import get_supplier_selection_from_ai
# Skip on delete (old_row is None) - CHECK THIS FIRST
if logic_row.is_deleted():
return
# Now safe to access old_row
if not (logic_row.is_inserted() or row.product_id != old_row.product_id):
return
Rule: ALL early events that access old_row MUST check is_deleted() first.
ERROR 5: Incomplete Audit Trail - Empty request and Brief reason Fields (Nov 21, 2025)
❌ Problem: Request and reason fields not fully populated with actual data
# In wrapper function
supplier_req.request = f"Select optimal supplier for {product_name}" # ❌ Generic, no context
# In AI handler
row.reason = "Test context selection" # ❌ Missing details
What happens: SysSupplierReq records lack actionable audit trail Impact: Cannot debug AI decisions, no visibility into what candidates were considered Business problem: Compliance, explainability, debugging impossible
✅ Solution: Populate request with FULL context in AI handler (where data exists)
# In AI handler (select_supplier_via_ai) - NOT in wrapper
def select_supplier_via_ai(row: models.SysSupplierReq, old_row, logic_row: LogicRow):
"""
Populate request and reason fields with COMPLETE information:
- request: Full context (product, candidates with prices, world conditions)
- reason: Decision details (selected supplier name, price, full AI explanation)
"""
product = row.product
suppliers = product.ProductSupplierList if product else []
# Build candidate summary for request field
candidate_summary = ', '.join([
f"{s.supplier.name if s.supplier else 'Unknown'}(${s.unit_cost})"
for s in suppliers
])
# TEST CONTEXT case
if test_context:
world = test_context.get('world_conditions', 'normal conditions')
row.request = f"Select supplier for {product.name}: Candidates=[{candidate_summary}], World={world}"
row.reason = f"TEST MODE: Selected {selected_supplier.supplier.name} (${selected_supplier.unit_cost}) - world: {world}"
# AI CALL case
elif api_key:
# Populate BEFORE calling AI
row.request = f"AI Prompt: Product={product.name}, World={world_conditions}, Candidates={len(candidate_data)}: {candidate_summary}"
# After AI responds
supplier_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
row.reason = f"AI: {supplier_name} (${selected_supplier.unit_cost}) - {ai_result.get('reason', 'No reason provided')}"
# FALLBACK case
else:
row.request = f"Select supplier for {product.name}: Candidates=[{candidate_summary}] - NO API KEY"
fallback_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
row.reason = f"Fallback: {fallback_name} (${selected_supplier.unit_cost}) - minimum cost (no API key)"
Key Points:
- ✅ request shows WHAT was asked (product, all candidates, world context)
- ✅ reason shows WHY decision was made (selected supplier details, AI explanation)
- ✅ Both fields populated in AI handler (has access to all data)
- ✅ Includes supplier NAMES and PRICES (not just IDs)
- ✅ Different patterns for test/AI/fallback modes
- ❌ DO NOT populate in wrapper (doesn't have candidate data)
Wrapper function should:
def get_supplier_selection_from_ai(product_id: int, item_id: int, logic_row: LogicRow):
supplier_req_logic_row = logic_row.new_logic_row(models.SysSupplierReq)
supplier_req = supplier_req_logic_row.row
# Set parent context (FK links)
# Note: request/reason populated by AI event handler with actual data
supplier_req.product_id = product_id
supplier_req.item_id = item_id
# Insert triggers AI handler which populates request/reason
supplier_req_logic_row.insert(reason="AI supplier selection request")
# Log results for visibility
logic_row.log(f"AI Request: {supplier_req.request}")
logic_row.log(f"AI Results: supplier_id={supplier_req.chosen_supplier_id}, price={supplier_req.chosen_unit_price}, reason={supplier_req.reason}")
return supplier_req
See docs/training/genai_logic_patterns.md for complete patterns.
============================================================================= ⚡ PATTERN: Early Event with Wrapper Function =============================================================================
The Pattern
When user says "Use AI to Set
- Early event on receiver -
Rule.early_row_event(on_class=models.Item, calling=set_item_unit_price_from_supplier) - Event calls wrapper - Wrapper hides Request Pattern complexity
- Wrapper returns object - Returns populated request object (not scalar)
- Event extracts values -
row.unit_price = req.chosen_unit_price
Fallback Strategy
CRITICAL: AI rules need fallback logic for cases when AI shouldn't/can't run.
Strategy: Reasonable Default → Fail-Fast
- Check for reasonable default: Copy from parent field with matching name
- If no obvious default: Insert
NotImplementedErrorwithTODO_AI_FALLBACKmarker - Never silently fail: Force developer decision at generation time, not runtime
Benefits:
- ✅ Prevents silent production failures
- ✅ Code won't run until developer addresses edge cases
- ✅ Clear markers for what needs attention
- ✅ Works in dev/test, fails explicitly before production
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.
Complete Example
Natural Language
Use AI to Set Item field unit_price by finding the optimal Product Supplier
based on cost, lead time, and world conditions
IF Product has no suppliers, THEN copy from Product.unit_price
Implementation
File: logic/logic_discovery/check_credit.py
"""
Check Credit Use Case - Business Logic Rules
Natural Language Requirements:
1. The Customer's balance is less than the credit limit
2. The Customer's balance is the sum of the Order amount_total where date_shipped is null
3. The Order's amount_total is the sum of the Item amount
4. The Item amount is the quantity * unit_price
5. The Product count suppliers is the sum of the Product Suppliers
6. Use AI to Set Item field unit_price by finding the optimal Product Supplier
based on cost, lead time, and world conditions
version: 3.0
date: November 21, 2025
source: docs/training/probabilistic_logic.prompt
"""
from logic_bank.logic_bank import Rule
from database import models
def declare_logic():
# Other deterministic rules...
Rule.early_row_event(on_class=models.Item, calling=set_item_unit_price_from_supplier)
def set_item_unit_price_from_supplier(row: models.Item, old_row: models.Item, logic_row):
"""
Early event: Sets unit_price using AI if suppliers exist, else uses fallback.
Fires on insert AND when product_id changes (same semantics as copy rule).
"""
from logic.logic_discovery.ai_requests.supplier_selection import get_supplier_selection_from_ai
# Skip on delete (old_row is None) - CRITICAL: Check this FIRST
if logic_row.is_deleted():
return
# Process on insert OR when product_id changes
if not (logic_row.is_inserted() or row.product_id != old_row.product_id):
return
product = row.product
# FALLBACK LOGIC when AI shouldn't/can't run:
# Strategy: Try reasonable default (copy from parent matching field), else fail-fast
if product.count_suppliers == 0:
# Reasonable default: copy from parent.unit_price (matching field name)
if hasattr(product, 'unit_price') and product.unit_price is not None:
logic_row.log(f"No suppliers for {product.name}, using product default price")
row.unit_price = product.unit_price
return
else:
# No obvious fallback - fail-fast with explicit TODO
raise NotImplementedError(
"TODO_AI_FALLBACK: Define fallback for Item.unit_price when no suppliers exist. "
"Options: (1) Use a default constant, (2) Leave NULL if optional, "
"(3) Raise error if required field, (4) Copy from another source"
)
# Product has suppliers - call AI wrapper
logic_row.log(f"Product {product.name} has {product.count_suppliers} suppliers, requesting AI selection")
supplier_req = get_supplier_selection_from_ai(
product_id=row.product_id,
item_id=row.id,
logic_row=logic_row
)
# Extract AI-selected value(s)
row.unit_price = supplier_req.chosen_unit_price
File: logic/logic_discovery/ai_requests/init.py
File: logic/logic_discovery/ai_requests/supplier_selection.py
⚠️ IMPORTANT: There is NO pre-built populate_ai_values() utility in LogicBank.
You must implement the AI selection logic directly as shown below.
"""
AI Supplier Selection - Probabilistic Logic Handler
This module implements AI-driven supplier selection based on cost, lead time,
and world conditions. It uses the Request Pattern for full audit trails.
See: https://apilogicserver.github.io/Docs/Logic-Using-AI/
version: 3.0
date: November 21, 2025
source: docs/training/probabilistic_logic.prompt
"""
from logic_bank.exec_row_logic.logic_row import LogicRow
from logic_bank.logic_bank import Rule
from database import models
from decimal import Decimal
import os
def declare_logic():
"""
Register early event on SysSupplierReq to populate chosen_* fields via AI.
This Request Pattern approach provides full audit trails and separation of concerns.
See: https://apilogicserver.github.io/Docs/Logic/#rule-patterns
"""
Rule.early_row_event(on_class=models.SysSupplierReq, calling=select_supplier_via_ai)
def select_supplier_via_ai(row: models.SysSupplierReq, old_row, logic_row: LogicRow):
"""
Early event (called via insert from wrapper) to populate chosen_* fields via AI.
This AI handler gets called automatically when SysSupplierReq is inserted,
populating AI Results: chosen_supplier_id and chosen_unit_price.
Strategy:
1. Load test context for INPUT conditions (world conditions like "Suez Canal blocked")
2. Always try AI with those conditions
3. If no API key or API fails, use fallback (min cost)
"""
if not logic_row.is_inserted():
return
# Get candidates (suppliers for this product)
product = row.product
suppliers = product.ProductSupplierList if product else []
if not suppliers:
row.request = f"Select supplier for {product.name if product else 'unknown product'} - No suppliers available"
row.reason = "No suppliers exist for this product"
logic_row.log("No suppliers available for AI selection")
row.fallback_used = True
return
# Load test context for world conditions (not for predetermined supplier selection)
from pathlib import Path
import yaml
current_file = Path(__file__).resolve()
project_root = current_file.parent.parent.parent.parent
context_file = project_root / 'config' / 'ai_test_context.yaml'
test_context = {}
if context_file.exists():
with open(str(context_file), 'r') as f:
test_context = yaml.safe_load(f) or {}
world_conditions = test_context.get('world_conditions', 'normal conditions')
selected_supplier = None
# Try AI (check for API key)
if True: # Always try AI unless no key
api_key = os.getenv("APILOGICSERVER_CHATGPT_APIKEY")
if api_key:
try:
# Call OpenAI API with structured prompt
from openai import OpenAI
import json
client = OpenAI(api_key=api_key)
# Build candidate data for prompt - include ALL supplier fields for AI decision
candidate_data = []
for supplier in suppliers:
supplier_obj = supplier.supplier
candidate_data.append({
'supplier_id': supplier.supplier_id,
'supplier_name': supplier_obj.name if supplier_obj else 'Unknown',
'supplier_region': supplier_obj.region if supplier_obj else None,
'supplier_contact': supplier_obj.contact_name if supplier_obj else None,
'supplier_phone': supplier_obj.phone if supplier_obj else None,
'supplier_email': supplier_obj.email if supplier_obj else None,
'unit_cost': float(supplier.unit_cost) if supplier.unit_cost else 0.0,
'lead_time_days': supplier.lead_time_days if hasattr(supplier, 'lead_time_days') else None,
'supplier_part_number': supplier.supplier_part_number if hasattr(supplier, 'supplier_part_number') else None
})
prompt = f"""
You are a supply chain optimization expert. Select the best supplier from the candidates below.
World Conditions: {world_conditions}
Optimization Goal: fastest reliable delivery while keeping costs reasonable
Candidates:
{yaml.dump(candidate_data, default_flow_style=False)}
Respond with ONLY valid JSON in this exact format (no markdown, no code blocks):
{{
"chosen_supplier_id": <id>,
"chosen_unit_price": <price>,
"reason": "<brief explanation>"
}}
"""
# Populate request field with actual prompt summary including key fields
candidate_summary = ', '.join([
f"{c['supplier_name']}(${c['unit_cost']}, {c['supplier_region'] or 'unknown region'}, {c['lead_time_days'] or '?'}days)"
for c in candidate_data
])
row.request = f"Select supplier for {product.name}: Candidates=[{candidate_summary}], World={world_conditions}"
logic_row.log(f"Calling OpenAI API with {len(candidate_data)} candidates, world conditions: {world_conditions}")
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "You are a supply chain expert. Respond with valid JSON only."},
{"role": "user", "content": prompt}
],
temperature=0.7
)
response_text = response.choices[0].message.content.strip()
logic_row.log(f"OpenAI response: {response_text}")
# Parse JSON response
ai_result = json.loads(response_text)
# Find the selected supplier
selected_supplier = next((s for s in suppliers if s.supplier_id == ai_result['chosen_supplier_id']), None)
if selected_supplier:
supplier_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
row.reason = f"Selected {supplier_name} (${selected_supplier.unit_cost}) - {ai_result.get('reason', 'No reason provided')}"
row.fallback_used = False
else:
logic_row.log(f"AI selected invalid supplier_id {ai_result['chosen_supplier_id']}, using fallback")
selected_supplier = min(suppliers, key=lambda s: float(s.unit_cost) if s.unit_cost else 999999.0)
fallback_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
row.reason = f"Fallback: {fallback_name} (${selected_supplier.unit_cost}) - AI returned invalid supplier"
row.fallback_used = True
except Exception as e:
logic_row.log(f"OpenAI API error: {e}, using fallback")
selected_supplier = min(suppliers, key=lambda s: float(s.unit_cost) if s.unit_cost else 999999.0)
fallback_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
candidate_summary = ', '.join([f"{s.supplier.name if s.supplier else 'Unknown'}(${s.unit_cost})" for s in suppliers])
row.request = f"Select supplier for {product.name}: Candidates=[{candidate_summary}] - API ERROR"
row.reason = f"Fallback: {fallback_name} (${selected_supplier.unit_cost}) - API error: {str(e)[:100]}"
row.fallback_used = True
else:
# No API key - use fallback strategy (min cost)
logic_row.log("No API key, using fallback: minimum cost")
selected_supplier = min(suppliers, key=lambda s: float(s.unit_cost) if s.unit_cost else 999999.0)
fallback_name = selected_supplier.supplier.name if selected_supplier.supplier else 'Unknown'
candidate_summary = ', '.join([f"{s.supplier.name if s.supplier else 'Unknown'}(${s.unit_cost})" for s in suppliers])
row.request = f"Select supplier for {product.name}: Candidates=[{candidate_summary}] - NO API KEY"
row.reason = f"Fallback: {fallback_name} (${selected_supplier.unit_cost}) - minimum cost (no API key)"
row.fallback_used = True
# Populate AI results
if selected_supplier:
row.chosen_supplier_id = int(selected_supplier.supplier_id) # Must be int for SQLite FK
row.chosen_unit_price = selected_supplier.unit_cost
logic_row.log(f"Selected supplier {selected_supplier.supplier_id} with price {selected_supplier.unit_cost}")
def get_supplier_selection_from_ai(product_id: int, item_id: int, logic_row: LogicRow) -> models.SysSupplierReq:
"""
Wrapper function called from Item (Receiver) early event.
See: https://apilogicserver.github.io/Docs/Logic-Using-AI/
1. Creates SysSupplierReq and inserts it (triggering AI event that populates chosen_* fields)
2. Returns populated object
This wrapper hides Request Pattern implementation details.
See https://apilogicserver.github.io/Docs/Logic/#rule-patterns.
Returns populated SysSupplierReq object with:
- Standard AI Audit: request, reason, created_on, fallback_used
- Parent Context Links: item_id, product_id
- AI Results: chosen_supplier_id, chosen_unit_price
"""
# 1. Create request row using parent's logic_row
supplier_req_logic_row = logic_row.new_logic_row(models.SysSupplierReq)
supplier_req = supplier_req_logic_row.row
# 2. Set parent context (FK links)
# Note: request/reason fields populated by AI event handler with actual prompt/candidate data
supplier_req.product_id = product_id
supplier_req.item_id = item_id
# 3. Insert triggers early event which populates AI values (chosen_* fields, request, reason)
supplier_req_logic_row.insert(reason="AI supplier selection request")
# 4. Log filled request object for visibility
logic_row.log(f"AI Request: {supplier_req.request}")
logic_row.log(f"AI Results: supplier_id={supplier_req.chosen_supplier_id}, price={supplier_req.chosen_unit_price}, reason={supplier_req.reason}")
# 5. Return populated object (chosen_* fields now set by AI)
return supplier_req
Key Patterns
Key Implementation Points
Test Context Usage:
- Load test context for INPUT conditions (world_conditions like "Suez Canal blocked")
- Test context provides CONDITIONS for AI, NOT predetermined outputs
- File: config/ai_test_context.yaml
- Example: world_conditions: "Suez Canal blocked, use alternate shipping routes"
AI Strategy: - Always try AI if API key exists - Pass world_conditions from test context to AI prompt - AI makes decision based on those conditions
Fallback Strategy:
- When no suppliers: Set fallback_used = True, return early
- When no API key: Use min cost fallback
- When API call fails: Use min cost fallback
Type Handling:
- Foreign keys (IDs): Must be int not Decimal
- Prices: Can be Decimal
- Use float() for comparisons: float(s.unit_cost)
Path Resolution:
- Use Path(__file__).resolve() for absolute paths
- Navigate up from logic/logic_discovery/ai_requests/ to project root
- Then down to config/ai_test_context.yaml
Request Pattern
The wrapper function encapsulates LogicBank's Request Pattern:
# Create using new_logic_row (pass CLASS not instance)
req_logic_row = logic_row.new_logic_row(models.SysXxxReq)
# Access instance via .row property
req = req_logic_row.row
# Set context fields
req.context_id = some_value
# Insert triggers early event handler
req_logic_row.insert(reason="...")
# Return populated object
return req
Request Table Structure
Standard AI Audit (constant for all requests)
id = Column(Integer, primary_key=True)
request = Column(String(2000)) # AI prompt sent
reason = Column(String(500)) # AI reasoning
created_on = Column(DateTime) # Timestamp
fallback_used = Column(Boolean) # Did AI fail?
Parent Context Links (FKs to triggering entities)
AI Results (values selected by AI)
============================================================================= 🚨 REQUEST PATTERN FAILURE MODES (Learned from Production Debugging) =============================================================================
CONTEXT: These are REAL failures that occurred during implementation. Each pattern caused server crashes, test failures, or silent bugs.
FAILURE #1: Formula Returns AI Value Directly
# ❌ WRONG - AI handler never fires
Rule.formula(
derive=models.Item.unit_price,
as_expression=lambda row: get_ai_supplier_price(row)
)
FAILURE #2: Pass Instance to new_logic_row()
# ❌ WRONG - Pass instance instead of class
supplier_req = models.SysSupplierReq()
supplier_req_logic_row = logic_row.new_logic_row(supplier_req)
TypeError: 'SysSupplierReq' object is not callable
Why it fails: new_logic_row() expects a CLASS, not an instance
Fix: Pass the class: logic_row.new_logic_row(models.SysSupplierReq)
FAILURE #3: Access Attributes on LogicRow Instead of .row
# ❌ WRONG - LogicRow doesn't have business attributes
supplier_req_logic_row = logic_row.new_logic_row(models.SysSupplierReq)
item_row.unit_price = supplier_req_logic_row.chosen_unit_price
AttributeError: 'LogicRow' object has no attribute 'chosen_unit_price'
Why it fails: Business attributes are on .row property, not LogicRow wrapper
Fix: Access via .row: supplier_req = supplier_req_logic_row.row
FAILURE #4: Use session.add/flush Directly
# ❌ WRONG - Bypasses LogicBank
supplier_req = models.SysSupplierReq()
logic_row.session.add(supplier_req)
logic_row.session.flush()
FAILURE #5: Forget to Copy Result Back
# ❌ WRONG - AI runs but result not propagated
supplier_req_logic_row.insert(reason="AI supplier selection")
# Missing copy: item_row.unit_price = supplier_req.chosen_unit_price
item_row.unit_price = supplier_req.chosen_unit_price
FAILURE #6: Test Context Checked After API Key
# ❌ WRONG - API key checked first
api_key = os.getenv("APILOGICSERVER_CHATGPT_APIKEY")
if not api_key:
# Apply fallback - tests never reach test context!
return
test_context = _load_test_context(logic_row) # Never reached in tests
✅ CORRECT ORDER (test context first):
# Check test context FIRST (for reproducible testing)
test_context = _load_test_context(logic_row)
if test_context and 'selected_supplier_id' in test_context:
# Use test context
return
# Then check API key
api_key = os.getenv("APILOGICSERVER_CHATGPT_APIKEY")
if not api_key:
# Apply fallback
return
Why: Tests should run consistently without requiring OpenAI API key. Test context is explicitly provided configuration that should override API calls.
============================================================================= 🚨 CRITICAL: Model Relationship Checklist =============================================================================
When adding SysXxxReq audit table, ONLY add relationships where FKs exist:
✅ DO add relationships:
1. Parent models referenced by FKs in SysXxxReq
- Example: product_id FK → Add to Product class
- Example: item_id FK → Add to Item class
- Example: chosen_supplier_id FK → Add to Supplier class
- SysXxxReq model itself (parent relationships)
- Bidirectional:
back_populatesfor standard FKs - Unidirectional:foreign_keys=[...]for non-standard FKs
❌ DO NOT add relationships:
1. Models with no FK to/from SysXxxReq
- Example: ProductSupplier has no FK to SysSupplierReq
- Example: Order has no FK to SysSupplierReq
- Adding relationships without FKs causes NoForeignKeysError
Verification Before Adding Relationship:
# Before adding relationship to Model X, verify:
# 1. Does SysXxxReq have FK to Model X? OR
# 2. Does Model X have FK to SysXxxReq?
# If NO to both → DO NOT add relationship
Common Mistake:
# ❌ WRONG - ProductSupplier has no FK relationship to SysSupplierReq
class ProductSupplier(Base):
SysSupplierReqList : Mapped[List["SysSupplierReq"]] = relationship(...)
# This will cause: NoForeignKeysError at server startup
Correct Pattern:
# ✅ CORRECT - Only add where FK exists
class Product(Base): # Has FK from SysSupplierReq.product_id
SysSupplierReqList : Mapped[List["SysSupplierReq"]] = relationship(back_populates="product")
class Item(Base): # Has FK from SysSupplierReq.item_id
SysSupplierReqList : Mapped[List["SysSupplierReq"]] = relationship(back_populates="item")
# ✅ DO NOT add relationship to Supplier for chosen_supplier_id
# - This is an AI result field (not standard parent-child relationship)
# - Access via SysSupplierReq.chosen_supplier (unidirectional) is sufficient
# - Adding reverse relationship causes NoForeignKeysError
# ✅ DO NOT add relationship to ProductSupplier (no FK exists)
OpenAI API (v1.0.0+)
CRITICAL: Use modern OpenAI API
❌ OLD API (deprecated, will fail):
import openai
openai.api_key = api_key
response = openai.ChatCompletion.create(...) # ❌ Not supported in openai>=1.0.0
✅ NEW API (correct pattern):
from openai import OpenAI
client = OpenAI(api_key=api_key)
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[...]
)
Common Pitfalls
Pass CLASS to new_logic_row, not instance:
# ❌ WRONG
req = models.SysXxxReq()
logic_row.new_logic_row(req) # TypeError
# ✅ CORRECT
logic_row.new_logic_row(models.SysXxxReq)
Access attributes via .row property:
# ❌ WRONG
req_logic_row.product_id = 123 # AttributeError
# ✅ CORRECT
req = req_logic_row.row
req.product_id = 123
Use LogicBank insert, not SQLAlchemy:
# ❌ WRONG
session.add(req)
session.flush() # Bypasses LogicBank
# ✅ CORRECT
req_logic_row.insert(reason="...") # Triggers events
Decimal handling in AI scoring:
# ❌ WRONG - Decimal × float
cost = supplier.unit_cost # Returns Decimal
score = cost * 0.5 # TypeError
# ✅ CORRECT - Convert to float first
cost = float(supplier.unit_cost) if supplier.unit_cost else 999999.0
score = cost * 0.5
File Structure
logic/
logic_discovery/
check_credit.py # Business logic with deterministic rules + AI event
ai_requests/ # AI handlers directory
__init__.py # Python package marker
supplier_selection.py # AI handler + wrapper function
system/
populate_ai_values.py # Reusable introspection utility
Multi-Value Pattern
For cases where multiple values are needed:
def assign_multiple_values(row: models.Order, old_row, logic_row):
"""Extract multiple values from AI request."""
from logic.logic_discovery.ai_requests.supplier_selection import get_supplier_selection_from_ai
if not logic_row.is_inserted():
return
# Call wrapper - returns object
req = get_supplier_selection_from_ai(
product_id=row.product_id,
item_id=row.id,
logic_row=logic_row
)
# Extract multiple values
row.supplier_id = req.chosen_supplier_id
row.unit_price = req.chosen_unit_price
row.lead_time = req.chosen_lead_time
Test Context
Enable reproducible testing via config/ai_test_context.yaml:
Database Model and Alembic Migration Workflow
CRITICAL: Request Pattern requires SysXxxReq audit table in database.
Table Structure Convention
# In database/models.py - Add import at top
import datetime
# Add model class
class SysSupplierReq(Base):
__tablename__ = "sys_supplier_req"
_s_collection_name = 'SysSupplierReq'
# Standard AI Audit
id = Column(Integer, primary_key=True)
request = Column(String(2000)) # Full AI prompt
reason = Column(String(500)) # AI explanation
created_on = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
fallback_used = Column(Boolean, default=False)
# Parent Context Links (FKs to triggering entities)
item_id = Column(Integer, ForeignKey("item.id"), index=True, nullable=True)
product_id = Column(Integer, ForeignKey("product.id"), index=True, nullable=False)
# AI Results (values selected by AI)
chosen_supplier_id = Column(Integer, ForeignKey("supplier.id"))
chosen_unit_price = Column(DECIMAL)
# Relationships (bidirectional)
item : Mapped["Item"] = relationship(back_populates="SysSupplierReqList")
product : Mapped["Product"] = relationship(back_populates="SysSupplierReqList")
chosen_supplier : Mapped["Supplier"] = relationship()
# Add to parent models (Product, Item):
SysSupplierReqList : Mapped[List["SysSupplierReq"]] = relationship(back_populates="product")
Alembic Migration Workflow (Production-Ready)
COPILOT MUST EXECUTE AUTOMATICALLY - No user intervention required:
Step 1: Add model to database/models.py
- Include
import datetimeat top - Add SysXxxReq class with all fields
- Add bidirectional relationships to parent models
Step 2: Generate migration
Creates:database/alembic/versions/xxxxx_add_sys_supplier_req_table.py
Step 3: Clean migration file (CRITICAL - COPILOT MUST DO THIS)
Alembic --autogenerate detects ALL differences between models.py and database.
COPILOT MUST AUTOMATICALLY:
1. Read generated migration file from database/alembic/versions/
2. KEEP: CREATE TABLE sys_supplier_req statement
3. REMOVE: ANY ALTER TABLE operations on existing tables (unrelated changes)
4. SIMPLIFY: downgrade() to just DROP TABLE
5. Save cleaned migration file
Example cleaned migration:
def upgrade():
op.create_table('sys_supplier_req',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('item_id', sa.Integer(), nullable=True),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('chosen_supplier_id', sa.Integer(), nullable=True),
sa.Column('chosen_unit_price', sa.DECIMAL(), nullable=True),
sa.Column('request', sa.String(length=2000), nullable=True),
sa.Column('reason', sa.String(length=500), nullable=True),
sa.Column('created_on', sa.DateTime(), nullable=False),
sa.Column('fallback_used', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['chosen_supplier_id'], ['supplier.id']),
sa.ForeignKeyConstraint(['item_id'], ['item.id']),
sa.ForeignKeyConstraint(['product_id'], ['product.id']),
sa.PrimaryKeyConstraint('id')
)
def downgrade():
op.drop_table('sys_supplier_req')
Step 4: Apply migration
Step 5: Update ui/admin/admin.yaml
Add SysSupplierReq resource with all fields and relationships.
Step 6: User restarts server
To load new model into SQLAlchemy session.
Complete Automated Workflow
When user provides natural language with AI requirement, COPILOT MUST:
- ✅ Check if sys_xxx_req table exists in database (not just models.py)
- ✅ Add model to database/models.py (include datetime import)
- ✅ Add bidirectional relationships to parent models
- ✅ Run:
cd database && alembic revision --autogenerate -m "..." - ✅ AUTOMATICALLY find new migration file in database/alembic/versions/
- ✅ AUTOMATICALLY read migration file
- ✅ AUTOMATICALLY clean it (remove ALTER TABLE, keep CREATE TABLE)
- ✅ AUTOMATICALLY save cleaned migration file
- ✅ Run:
cd database && alembic upgrade head - ✅ Update ui/admin/admin.yaml
- ✅ Generate logic files (check_credit.py, supplier_selection.py)
NO USER INTERVENTION - Copilot handles entire migration workflow.
Common Alembic Issues
Issue: "Target database is not up to date"
Solution: Run cd database && alembic stamp head
Issue: "Table already exists"
Solution: Database has tables but Alembic history is empty. Use alembic stamp head.
Issue: "No config file 'alembic.ini' found" Solution: Must run alembic commands from database/ directory.
Why Alembic (not raw SQL): - ✅ Version controlled (migration files in git) - ✅ Reversible (alembic downgrade) - ✅ Team-friendly (others run same migration) - ✅ Staged deployment (dev → test → prod) - ✅ Audit trail (history of schema changes)
❌ Raw SQL is demo/testing only - NOT production-ready.
Benefits
- Separation of concerns - Event doesn't know Request Pattern details
- Early event - AI executes before other rules
- Wrapper hides complexity - Request Pattern encapsulated
- Returns object - Caller extracts needed values
- Reusable - Multiple events can call same wrapper
- Testable - Can mock wrapper independently
Complete Checklist
When implementing AI logic:
- [ ] Create request table (SysXxxReq) if needed
- [ ] Add fields: Standard AI Audit, Parent Context Links, AI Results
- [ ] Create
logic/logic_discovery/ai_requests/directory - [ ] Create
__init__.pyin ai_requests/ - [ ] Implement AI handler in ai_requests/xxx.py
- [ ] Register early event on SysXxxReq
- [ ] Implement wrapper function (returns object)
- [ ] Call wrapper from receiver event
- [ ] Extract values from returned object
- [ ] Create
config/ai_test_context.yaml - [ ] Update
ui/admin/admin.yaml
For detailed LogicBank patterns, see docs/training/logic_bank_patterns.prompt For deterministic rules, see docs/training/logic_bank_api.prompt