-- allium: 1
-- bDS AI Integration
-- Scope: core (one-shot operations), extension Bucket C (chat + streaming)
-- Distilled from: src/main/engine/ChatEngine.ts, ai/providers.ts,
--   ai/chat.ts, ai/tasks.ts, SecureKeyStore.ts
-- The rewrite models AI access as two configurable OpenAI-compatible
-- endpoints (online + airplane mode) instead of a fixed named-provider set.

use "./post.allium" as post
use "./media.allium" as media

entity AiEndpoint {
    kind: online | airplane
    url: String
    api_key: String?                    -- encrypted via SecureKeyStore; null for local models
    model: String
    -- online: cloud provider (OpenAI, Anthropic-via-proxy, etc.)
    -- airplane: local model (Ollama, LM Studio, etc.)
}

entity AiCatalogProvider {
    id: String
    name: String
    env_keys: Set<String>
    package_ref: String?
    api_url: String?
    doc_url: String?
    updated_at: Timestamp
}

entity AiModel {
    provider: AiCatalogProvider
    model_id: String
    name: String
    family: String?
    supports_attachment: Boolean
    supports_reasoning: Boolean
    supports_tool_calls: Boolean
    supports_structured_output: Boolean
    supports_temperature: Boolean
    knowledge: String?
    release_date: String?
    last_updated_date: String?
    open_weights: Boolean
    input_price: Integer?
    output_price: Integer?
    cache_read_price: Integer?
    cache_write_price: Integer?
    context_window: Integer
    max_input_tokens: Integer
    max_output_tokens: Integer
    interleaved: String?
    status: String?
    updated_at: Timestamp

    -- Relationships
    modalities: AiModelModality with provider = this.provider and model_id = this.model_id

    -- Derived
    input_modalities: modalities where direction = input -> modality
    output_modalities: modalities where direction = output -> modality
}

entity AiModelModality {
    provider: AiCatalogProvider
    model_id: String
    direction: input | output
    modality: text | image | audio | file | tool
}

entity AiCatalogMeta {
    key: String
    value: String
}

surface AiEndpointSurface {
    context endpoint: AiEndpoint

    exposes:
        endpoint.kind
        endpoint.url
        endpoint.api_key when endpoint.api_key != null
        endpoint.model
}

    surface AiModelSurface {
        context catalog_model: AiModel

        exposes:
            catalog_model.provider
            catalog_model.model_id
            catalog_model.name
            catalog_model.family when catalog_model.family != null
            catalog_model.supports_attachment
            catalog_model.supports_reasoning
            catalog_model.supports_tool_calls
            catalog_model.supports_structured_output
            catalog_model.supports_temperature
            catalog_model.knowledge when catalog_model.knowledge != null
            catalog_model.release_date when catalog_model.release_date != null
            catalog_model.last_updated_date when catalog_model.last_updated_date != null
            catalog_model.open_weights
            catalog_model.input_price when catalog_model.input_price != null
            catalog_model.output_price when catalog_model.output_price != null
            catalog_model.cache_read_price when catalog_model.cache_read_price != null
            catalog_model.cache_write_price when catalog_model.cache_write_price != null
            catalog_model.context_window
            catalog_model.max_input_tokens
            catalog_model.max_output_tokens
            catalog_model.interleaved when catalog_model.interleaved != null
            catalog_model.status when catalog_model.status != null
            catalog_model.input_modalities
            catalog_model.output_modalities
            catalog_model.updated_at
    }

entity SecureKeyStore {
    -- Encrypts API keys using the host operating system's secure storage.
    -- Stored in application settings in encrypted form.
    -- No plain-text fallback
}

surface SecureKeyStoreSurface {
    context _: SecureKeyStore
}

entity ChatConversation {
    title: String
    model: String
    created_at: Timestamp
    updated_at: Timestamp

    messages: ChatMessage with conversation = this
}

surface ChatConversationSurface {
    context conversation: ChatConversation

    exposes:
        conversation.title
        conversation.model
        conversation.created_at
        conversation.updated_at
        conversation.messages.count
}

entity ChatMessage {
    conversation: ChatConversation
    role: system | user | assistant | tool
    content: String
    tool_call_id: String?
    tool_calls: String?
    token_usage_input: Integer?
    token_usage_output: Integer?
    cache_read_tokens: Integer?
    cache_write_tokens: Integer?
    created_at: Timestamp
}

surface ChatMessageSurface {
    context message: ChatMessage

    exposes:
        message.conversation
        message.role
        message.content
        message.tool_call_id when message.tool_call_id != null
        message.tool_calls when message.tool_calls != null
        message.token_usage_input when message.token_usage_input != null
        message.token_usage_output when message.token_usage_output != null
        message.cache_read_tokens when message.cache_read_tokens != null
        message.cache_write_tokens when message.cache_write_tokens != null
        message.created_at
}

surface AiConfigurationSurface {
    facing _: AiOperator

    provides:
        SetAiEndpointRequested(kind, url, api_key, model)
        RemoveAiEndpointRequested(kind)
        RefreshModelCatalogRequested(source)
}

surface OneShotAiSurface {
    facing _: AiOperator

    provides:
        AnalyzeTaxonomyRequested(post)
        AnalyzeImageRequested(media)
        AnalyzePostRequested(post)
        DetectLanguageRequested(text)
        TranslatePostRequested(post, target_language)
        TranslateMediaRequested(media, target_language)
}

surface AiChatSurface {
    facing _: ChatOperator

    provides:
        StartChatRequested(model)
        SendChatMessageRequested(conversation, content)
        CancelChatRequested(conversation)
}

config {
    model_catalog_ttl: Duration = 5.minutes
    chat_max_tool_rounds: Integer = 10
    default_max_output_tokens: Integer = 16384
}

rule SetAiEndpoint {
    when: SetAiEndpointRequested(kind, url, api_key, model)
    ensures:
        let endpoint = AiEndpoint.created(
            kind: kind,
            url: url,
            api_key: api_key,
            model: model
        )
        endpoint.kind = kind
}

rule RemoveAiEndpoint {
    when: RemoveAiEndpointRequested(kind)
    for endpoint in AiEndpoints where endpoint.kind = kind:
        ensures: not exists endpoint
}

-- One-shot AI tasks (core scope, no streaming)
-- All use OpenAI Chat Completions wire format.
-- Endpoint routing: see AirplaneModeGating invariant below.
-- When no endpoint configured for current mode: disable AI, show toast.

rule AnalyzeTaxonomy {
    when: AnalyzeTaxonomyRequested(post)
    requires: active_endpoint_configured
    -- Suggests tags and categories for a post
    ensures: TaxonomySuggestion(tags, categories)
}

rule AnalyzeImage {
    when: AnalyzeImageRequested(media)
    requires: active_endpoint_configured
    requires: is_image(media.mime_type)
    -- Vision model generates alt text and caption
    ensures: ImageAnalysisResult(alt, caption)
}

rule AnalyzePost {
    when: AnalyzePostRequested(post)
    requires: active_endpoint_configured
    -- Generates title, excerpt, slug suggestions
    ensures: PostAnalysisResult(title, excerpt, slug)
}

rule DetectLanguage {
    when: DetectLanguageRequested(text)
    requires: active_endpoint_configured
    ensures: LanguageDetectionResult(language_code)
}

rule TranslatePost {
    when: TranslatePostRequested(post, target_language)
    requires: active_endpoint_configured
    -- Translates title, excerpt, content to target language
    ensures: TranslationResult(title, excerpt, content)
}

rule TranslateMedia {
    when: TranslateMediaRequested(media, target_language)
    requires: active_endpoint_configured
    -- Translates title, alt, caption to target language
    ensures: MediaTranslationResult(title, alt, caption)
}

-- Chat (extension Bucket C scope, with streaming and tool use)

rule StartChat {
    when: StartChatRequested(model)
    ensures: ChatConversation.created(
        title: generated_chat_title(model),
        model: model,
        created_at: now,
        updated_at: now
    )
}

rule SendChatMessage {
    when: SendChatMessageRequested(conversation, content)
    requires: active_endpoint_configured
    ensures: ChatMessage.created(
        conversation: conversation,
        role: user,
        content: content,
        token_usage_input: null,
        token_usage_output: null,
        created_at: now
    )
    ensures: conversation.updated_at = now
    ensures: AiStreamingResponse(conversation)
        -- Streaming response with bounded tool-call loop.
        -- Blog data tools for post/media querying and mutation during chat.
        -- Render tools may emit structured chart/table/form payloads.
        -- Token usage tracking includes input, output, cache read, cache write.
}

rule CancelChat {
    when: CancelChatRequested(conversation)
    ensures: AiStreamingResponseCancelled(conversation)
}

-- Model catalog

rule RefreshModelCatalog {
    when: RefreshModelCatalogRequested(source)
    -- Refreshes advisory provider/model metadata used for capability checks,
    -- default token budgeting, and model selection UX.
    -- Uses conditional GET with ETag where supported.
    ensures: ModelCatalogUpdated()
}

invariant AirplaneModeGating {
    -- Endpoint routing based on airplane (offline) mode:
    --   airplane_mode = true  -> use airplane endpoint (local model)
    --   airplane_mode = false -> use online endpoint (cloud provider)
    -- active_endpoint_configured = true iff the endpoint for the
    --   current mode has a non-empty url (and api_key for online).
    -- When active endpoint is not configured: AI is unavailable,
    --   show toast "AI unavailable — configure {online|airplane} endpoint in Settings"
}

invariant AirplaneModeModelSwap {
    -- In airplane mode, cloud models are never contacted.
    -- Chat uses the configured offline chat model when needed.
    -- Image analysis uses the configured offline vision-capable model when needed.
    -- If no suitable offline model is configured, the operation fails with
    --   actionable guidance instead of silently falling back to the online endpoint.
}

invariant TwoEndpointModel {
    -- Two configurable OpenAI-compatible endpoints:
    --   online: for cloud providers (requires API key)
    --   airplane: for local models (no API key required)
    -- Both use the OpenAI Chat Completions wire format.
    -- Endpoint selection is configurable rather than tied to hard-coded providers.
}

invariant AdvisoryModelCatalog {
    -- Model metadata is stored separately from runtime endpoint configuration.
    -- It supplies capability hints such as context window, tool-call support,
    -- structured output support, vision/input modalities, and pricing metadata.
    -- The catalog remains usable offline after the last successful refresh.
}

invariant ConditionalCatalogRefresh {
    -- Model catalog refresh uses conditional HTTP requests when possible.
    -- The latest ETag and fetch timestamp are persisted in AiCatalogMeta.
    -- A 304 response updates freshness metadata without rewriting model rows.
    exists meta in AiCatalogMeta where meta.key = "etag" or meta.key = "last_fetched_at"
}

invariant ProviderDetection {
    -- Runtime provider selection may be inferred from model identifiers,
    -- local-endpoint registration, or explicit endpoint configuration.
    -- The system does not rely on a single hard-coded provider list for routing.
}

invariant VisionCapabilityGate {
    -- AnalyzeImage only runs against models that accept image input.
    -- Local/offline models must advertise or be configured with image capability
    -- before the runtime sends multimodal requests to them.
}

invariant ChatContextTruncation {
    -- Chat requests are trimmed to fit within the selected model's context window.
    -- Oldest user/assistant pairs are dropped first.
    -- The system prompt, tool schema budget, and output-token reserve are preserved.
}

invariant BoundedToolLoop {
    -- Chat tool execution is bounded by config.chat_max_tool_rounds.
    -- Tool-capable models may call blog-domain tools and render tools.
    -- Non-tool-capable models skip tool exposure entirely.
}

invariant TokenUsageAccounting {
    -- Chat turn accounting tracks input, output, cache-read, and cache-write tokens.
    -- Usage is reported per turn and accumulated per conversation.
    -- Cache token accounting is surfaced when the underlying provider reports it.
}

invariant ChatCancellation {
    -- Each in-flight chat turn can be aborted independently.
    -- Cancellation stops streaming and tool execution for that request only.
}

invariant StructuredRenderTools {
    -- Chat may emit structured render payloads via tool calls prefixed "render_".
    -- 9 inline surface types: card, chart, form, list, metric, mindmap, table, tabs, text, json.
    -- Render tool names: render_card, render_chart, render_form, render_list,
    --   render_metric, render_mindmap, render_table, render_tabs.
    -- Unrecognized render tool names render as JSON (raw dump).
    -- Tab content may nest any surface type including plain text.
    -- These payloads are data contracts, not arbitrary HTML strings.
    -- Surface data contracts are defined in editor_chat.allium InlineSurface value.
}

invariant BlogStatsPromptAugmentation {
    -- The base system prompt may be augmented with current blog statistics
    -- such as post counts, media counts, tag/category totals, and date ranges
    -- so long as the augmentation reflects current project state.
}

invariant AiSpecPartitioning {
    -- This file covers two distinct but related AI contracts:
    --   1. Core one-shot operations (taxonomy, vision, translation, language detection)
    --   2. Extension chat/model-catalog behaviour
    -- Both share the same endpoint routing and airplane-mode gating rules.
}

invariant SecureKeyStorage {
    -- API keys are never stored in plain text
    -- Always encrypted via host secure storage before persistence
}
