# OpenAI Integration Pattern

## Overview

This document describes the pattern used for integrating OpenAI's API in the BuyerKiosk application, specifically for the AI Smart Scheduling feature (Spec 026).

## Architecture

```
┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│  Controller     │────▶│  AiGenerateJob   │────▶│  OpenAIClient   │
│  (API Layer)    │     │  (TaskEngine)    │     │  (HTTP Client)  │
└─────────────────┘     └──────────────────┘     └─────────────────┘
         │                      │                        │
         │                      │                        │
         ▼                      ▼                        ▼
    Dispatches             Processes               Calls OpenAI API
    Async Job              with retry              with Structured Outputs
```

## Key Components

### 1. OpenAIClient (`src/BuyerKiosk/Scheduling/AiScheduling/Services/OpenAIClient.php`)

The OpenAI client handles all communication with OpenAI's API:

```php
class OpenAIClient
{
    public const DEFAULT_TIMEOUT = 60;
    public const MODEL_FALLBACK_CHAIN = ['gpt-5-mini', 'gpt-5', 'gpt-4o-mini'];

    public function chat(
        array $messages,
        array $responseSchema,
        int $timeout = self::DEFAULT_TIMEOUT
    ): OpenAIResponse;
}
```

**Features:**
- Model fallback chain (tries multiple models)
- Structured Outputs (JSON Schema validation)
- Timeout configuration
- Error handling with typed exceptions

### 2. AiPromptBuilder (`src/BuyerKiosk/Scheduling/AiScheduling/Services/AiPromptBuilder.php`)

Builds the prompt and schema for OpenAI:

```php
class AiPromptBuilder
{
    public const RESPONSE_SCHEMA = [
        'name' => 'schedule_assignments',
        'strict' => true,
        'schema' => [
            'type' => 'object',
            'properties' => [
                'assignments' => [...],
                'summary' => [...]
            ],
            'required' => ['assignments', 'summary'],
            'additionalProperties' => false,
        ],
    ];

    public function buildSystemPrompt(array $priorities, ?string $customInstructions): string;
    public function buildUserPrompt(array $shifts, array $employees): string;
}
```

### 3. AiGenerateJob (`src/BuyerKiosk/Scheduling/AiScheduling/Jobs/AiGenerateJob.php`)

TaskEngine job that processes AI generation asynchronously:

```php
class AiGenerateJob implements JobInterface
{
    public function getQueue(): string => 'default';
    public function getMaxAttempts(): int => 2;
    public function getRetryDelay(): int => 30;

    public function execute(array $params): void;
}
```

## Structured Outputs Pattern

We use OpenAI's Structured Outputs feature for reliable JSON responses.

### Schema Definition

```php
$schema = [
    'name' => 'schema_name',      // Required by OpenAI
    'strict' => true,              // Enforce schema compliance
    'schema' => [
        'type' => 'object',
        'properties' => [...],
        'required' => [...],
        'additionalProperties' => false,  // Required for strict mode
    ],
];
```

### Important Notes

1. **All properties must have `additionalProperties: false`** in strict mode
2. **Schema name is required** in the wrapper object
3. **All fields in schema must be defined** - no dynamic properties

## Temperature Handling

Different OpenAI models have different temperature support:

```php
private const MODELS_SUPPORTING_TEMPERATURE = [
    'gpt-4o',
    'gpt-4o-mini',
    'gpt-4-turbo',
    'gpt-4',
    'gpt-3.5-turbo',
];

// Reasoning models (o1, o3, gpt-5 series) don't support temperature
private function modelSupportsTemperature(string $model): bool
{
    foreach (self::MODELS_SUPPORTING_TEMPERATURE as $supportedModel) {
        if (str_starts_with($model, $supportedModel)) {
            return true;
        }
    }
    return false;
}
```

## Error Handling

### OpenAIException Types

```php
class OpenAIException extends Exception
{
    public static function missingApiKey(): self;
    public static function timeout(string $model, int $timeout): self;
    public static function modelNotFound(string $model): self;
    public static function rateLimited(string $model, ?int $retryAfter): self;
    public static function apiError(int $httpCode, string $model, string $message): self;
    public static function allModelsExhausted(array $triedModels): self;

    public function isModelNotFound(): bool;
    public function isRateLimited(): bool;
    public function isTimeout(): bool;
}
```

### Retry Logic

The job system handles retries:

```php
// In job execution
try {
    $response = $openAIClient->chat($messages, $schema);
} catch (OpenAIException $e) {
    if ($e->isRateLimited()) {
        // Re-throw to trigger retry with delay
        throw $e;
    }
    if ($e->isTimeout()) {
        // Re-throw to trigger retry
        throw $e;
    }
    // Other errors fail immediately
    $this->handleFailure($e);
}
```

## Security Considerations

### API Key Management

```php
// API key loaded from environment - NEVER exposed to client
$apiKey = getenv('OPENAI_API_KEY');

// Private property, no public getter
private ?string $apiKey;

// Key only used in server-side HTTP requests
'Authorization: Bearer ' . $this->getApiKey()
```

### Input Validation

```php
// Validate custom instructions length
if (strlen($customInstructions) > 500) {
    throw new ValidationException('Instructions too long');
}

// Sanitize user input before including in prompts
$safeInstructions = htmlspecialchars($customInstructions, ENT_QUOTES, 'UTF-8');
```

## Testing

### Unit Tests

```php
// Mock OpenAI responses for unit tests
$mockResponse = new OpenAIResponse(
    content: ['assignments' => [...], 'summary' => [...]],
    model: 'gpt-4o-mini',
    promptTokens: 500,
    completionTokens: 200,
    durationMs: 5000
);
```

### Integration Tests

```php
// Skip live tests when API key not configured
if (!getenv('OPENAI_API_KEY')) {
    $this->markTestSkipped('OPENAI_API_KEY not configured');
}
```

## Configuration

### Environment Variables

```bash
# Required
OPENAI_API_KEY=sk-proj-...

# Optional
OPENAI_MODEL=gpt-4o-mini           # Override default model
AI_SCHEDULE_MAX_RUNS_PER_WEEK=5    # Rate limit per store
```

### Model Selection

The system uses a fallback chain:

1. `gpt-5-mini` (primary - newest model)
2. `gpt-5` (fallback)
3. `gpt-4o-mini` (final fallback - widely available)

If configured via `OPENAI_MODEL`, that model is tried first.

## Best Practices

1. **Always use Structured Outputs** - Ensures consistent response format
2. **Handle model fallback** - New models may not be available in all regions
3. **Set reasonable timeouts** - 60 seconds allows for complex generations
4. **Log token usage** - Monitor costs and optimize prompts
5. **Validate responses** - Even with schema, validate business logic
6. **Use async processing** - Don't block HTTP requests on AI calls

## Related Files

- `src/BuyerKiosk/Scheduling/AiScheduling/Services/OpenAIClient.php`
- `src/BuyerKiosk/Scheduling/AiScheduling/Services/OpenAIResponse.php`
- `src/BuyerKiosk/Scheduling/AiScheduling/Services/OpenAIException.php`
- `src/BuyerKiosk/Scheduling/AiScheduling/Services/AiPromptBuilder.php`
- `src/BuyerKiosk/Scheduling/AiScheduling/Jobs/AiGenerateJob.php`
- `tests/Unit/Scheduling/AiScheduling/Services/OpenAIClientTest.php`
- `tests/Integration/Scheduling/AiScheduling/OpenAIIntegrationTest.php`
