---
title: LogicBank Probabilistic Rules API (AI Value Computation)
description: Training document for translating natural language into probabilistic value computation rules
source: Generic training for ApiLogicServer projects with probabilistic rules
usage: AI assistants read this to generate probabilistic + deterministic rules implementations
version: 3.1
date: November 21, 2025
prerequisites:
  - docs/training/genai_logic_patterns.md (CRITICAL import patterns, auto-discovery)
  - docs/training/logic_bank_patterns.prompt (event signatures, logging, request pattern)
  - docs/training/logic_bank_api.prompt (deterministic rule APIs)
---

# 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:

1. **docs/training/genai_logic_patterns.md** - Import patterns, auto-discovery
2. **docs/training/logic_bank_patterns.prompt** - Event signatures, logging, Request Pattern
3. **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
```python
# 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
```python
# 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:
```python
# 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
```python
context_file = config_dir / 'ai_test_context.yaml'
if context_file.exists():  # ❌ May fail on relative paths
```

✅ Solution: Use .resolve() for absolute paths
```python
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
```python
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`
```python
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
```python
# 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)
```python
# 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:**
```python
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 <Receiver> field by finding optimal <Provider>":

1. **Early event on receiver** - `Rule.early_row_event(on_class=models.Item, calling=set_item_unit_price_from_supplier)`
2. **Event calls wrapper** - Wrapper hides Request Pattern complexity
3. **Wrapper returns object** - Returns populated request object (not scalar)
4. **Event extracts values** - `row.unit_price = req.chosen_unit_price`

**⚠️ CRITICAL: use `early_row_event` — `unit_price` is consumed by `Rule.formula(Item.amount)`**

`Rule.formula` runs *after* events. Wrong event type → formula uses old/null price → wrong amounts, wrong balance, wrong credit check — silently. See PATTERN 8 in `docs/training/logic_bank_patterns.md`.

## Fallback Strategy

**CRITICAL:** AI rules need fallback logic for cases when AI shouldn't/can't run.

**Strategy: Reasonable Default → Fail-Fast**

1. **Check for reasonable default**: Copy from parent field with matching name
2. **If no obvious default**: Insert `NotImplementedError` with `TODO_AI_FALLBACK` marker
3. **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**

```python
"""
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**

```python
# Empty file - makes this a Python package
```

**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.

```python
"""
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:

```python
# 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)**
```python
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)**
```python
item_id = Column(ForeignKey('item.id'))
product_id = Column(ForeignKey('product.id'))
```

**AI Results (values selected by AI)**
```python
chosen_supplier_id = Column(ForeignKey('supplier.id'))
chosen_unit_price = Column(DECIMAL)
```

=============================================================================
🚨 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**
```python
# ❌ WRONG - AI handler never fires
Rule.formula(
    derive=models.Item.unit_price,
    as_expression=lambda row: get_ai_supplier_price(row)
)
```
**What happens**: Formula executes but AI handler never fires, no audit trail created.
**Error**: Silent failure - no SysSupplierReq records, unit_price has wrong value
**Why it fails**: Formula should PRESERVE value, not COMPUTE it via AI
**Fix**: Use early event pattern (see above)

**FAILURE #2: Pass Instance to new_logic_row()**
```python
# ❌ WRONG - Pass instance instead of class
supplier_req = models.SysSupplierReq()
supplier_req_logic_row = logic_row.new_logic_row(supplier_req)
```
**What happens**: Python tries to call the instance as a function
**Error**: `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**
```python
# ❌ 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
```
**What happens**: LogicRow is a wrapper, not the business object
**Error**: `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**
```python
# ❌ WRONG - Bypasses LogicBank
supplier_req = models.SysSupplierReq()
logic_row.session.add(supplier_req)
logic_row.session.flush()
```
**What happens**: Object added to database but LogicBank events never fire
**Error**: Silent failure - AI handler never executes, no AI selection
**Why it fails**: Direct SQLAlchemy calls bypass LogicBank event chain
**Fix**: Use logic_row.new_logic_row() + explicit .insert()

**FAILURE #5: Forget to Copy Result Back**
```python
# ❌ 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
```
**What happens**: SysSupplierReq populated correctly but Item.unit_price unset
**Error**: Silent failure - AI works but business logic breaks (unit_price = None)
**Why it fails**: No automatic propagation between tables
**Fix**: Explicitly copy: `item_row.unit_price = supplier_req.chosen_unit_price`

**FAILURE #6: Test Context Checked After API Key**
```python
# ❌ 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
```
**What happens**: Tests use fallback logic instead of test context
**Error**: Non-deterministic tests, "fallback_used" flag set incorrectly
**Why it fails**: Test context should override API key check
**Fix**: Check test context FIRST, then API key

✅ **CORRECT ORDER** (test context first):
```python
# 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

2. SysXxxReq model itself (parent relationships)
   - Bidirectional: `back_populates` for 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:**
```python
# 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:**
```python
# ❌ 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:**
```python
# ✅ 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):
```python
import openai
openai.api_key = api_key
response = openai.ChatCompletion.create(...)  # ❌ Not supported in openai>=1.0.0
```

✅ NEW API (correct pattern):
```python
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:**
```python
# ❌ WRONG
req = models.SysXxxReq()
logic_row.new_logic_row(req)  # TypeError

# ✅ CORRECT
logic_row.new_logic_row(models.SysXxxReq)
```

**Access attributes via .row property:**
```python
# ❌ WRONG
req_logic_row.product_id = 123  # AttributeError

# ✅ CORRECT
req = req_logic_row.row
req.product_id = 123
```

**Use LogicBank insert, not SQLAlchemy:**
```python
# ❌ WRONG
session.add(req)
session.flush()  # Bypasses LogicBank

# ✅ CORRECT
req_logic_row.insert(reason="...")  # Triggers events
```

**Decimal handling in AI scoring:**
```python
# ❌ 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:

```python
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`:

```yaml
world_conditions: 'ship aground in Suez Canal'
selected_supplier_id: 2
```

## Database Model and Alembic Migration Workflow

**CRITICAL**: Request Pattern requires SysXxxReq audit table in database.

### Table Structure Convention

```python
# 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 datetime` at top
- Add SysXxxReq class with all fields
- Add bidirectional relationships to parent models

#### Step 2: Generate migration
```bash
cd database && alembic revision --autogenerate -m "Add sys_supplier_req table"
```
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:
```python
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
```bash
cd database && alembic upgrade head
```

#### 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:

1. ✅ Check if sys_xxx_req table exists in database (not just models.py)
2. ✅ Add model to database/models.py (include datetime import)
3. ✅ Add bidirectional relationships to parent models
4. ✅ Run: `cd database && alembic revision --autogenerate -m "..."`
5. ✅ **AUTOMATICALLY** find new migration file in database/alembic/versions/
6. ✅ **AUTOMATICALLY** read migration file
7. ✅ **AUTOMATICALLY** clean it (remove ALTER TABLE, keep CREATE TABLE)
8. ✅ **AUTOMATICALLY** save cleaned migration file
9. ✅ Run: `cd database && alembic upgrade head`
10. ✅ Update ui/admin/admin.yaml
11. ✅ 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__.py` in 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
