diff --git a/migrations/7.0.0_billion_scale_redesign/down.sql b/migrations/7.0.0_billion_scale_redesign/down.sql new file mode 100644 index 000000000..0762b04d1 --- /dev/null +++ b/migrations/7.0.0_billion_scale_redesign/down.sql @@ -0,0 +1,9 @@ +-- Migration: 7.0.0 Billion Scale Redesign - ROLLBACK +-- Description: Drops the gb schema and all its objects +-- WARNING: This is a DESTRUCTIVE operation - all data will be lost + +-- Drop the entire schema (CASCADE drops all objects within) +DROP SCHEMA IF EXISTS gb CASCADE; + +-- Note: This migration completely removes the v7 schema. +-- To restore previous schema, run migrations 6.x.x in order. diff --git a/migrations/7.0.0_billion_scale_redesign/up.sql b/migrations/7.0.0_billion_scale_redesign/up.sql new file mode 100644 index 000000000..b59d7ac48 --- /dev/null +++ b/migrations/7.0.0_billion_scale_redesign/up.sql @@ -0,0 +1,1124 @@ +-- Migration: 7.0.0 Billion Scale Redesign +-- Description: Complete database redesign for billion-user scale +-- Features: +-- - PostgreSQL ENUMs instead of VARCHAR for domain values +-- - Sharding support with shard_key (region/tenant based) +-- - Optimized indexes for high-throughput queries +-- - Partitioning-ready table structures +-- - No TEXT columns for domain values - all use SMALLINT enums +-- +-- IMPORTANT: This is a DESTRUCTIVE migration - drops all existing tables +-- Only run on fresh installations or after full data export + +-- ============================================================================ +-- CLEANUP: Drop all existing objects +-- ============================================================================ +DROP SCHEMA IF EXISTS gb CASCADE; +CREATE SCHEMA gb; +SET search_path TO gb, public; + +-- ============================================================================ +-- SHARDING INFRASTRUCTURE +-- ============================================================================ + +-- Shard configuration table (exists in each shard, contains global shard map) +CREATE TABLE gb.shard_config ( + shard_id SMALLINT PRIMARY KEY, + region_code CHAR(3) NOT NULL, -- ISO 3166-1 alpha-3: USA, BRA, DEU, etc. + datacenter VARCHAR(32) NOT NULL, -- e.g., 'us-east-1', 'eu-west-1' + connection_string TEXT NOT NULL, -- Encrypted connection string + is_primary BOOLEAN DEFAULT false, + is_active BOOLEAN DEFAULT true, + min_tenant_id BIGINT NOT NULL, + max_tenant_id BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Tenant to shard mapping (replicated across all shards for routing) +CREATE TABLE gb.tenant_shard_map ( + tenant_id BIGINT PRIMARY KEY, + shard_id SMALLINT NOT NULL REFERENCES gb.shard_config(shard_id), + region_code CHAR(3) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX idx_tenant_shard_region ON gb.tenant_shard_map(region_code, shard_id); + +-- ============================================================================ +-- ENUM TYPES - All domain values as PostgreSQL ENUMs (stored as integers internally) +-- ============================================================================ + +-- Core enums +CREATE TYPE gb.channel_type AS ENUM ( + 'web', 'whatsapp', 'telegram', 'msteams', 'slack', 'email', 'sms', 'voice', 'instagram', 'api' +); + +CREATE TYPE gb.message_role AS ENUM ( + 'user', 'assistant', 'system', 'tool', 'episodic', 'compact' +); + +CREATE TYPE gb.message_type AS ENUM ( + 'text', 'image', 'audio', 'video', 'document', 'location', 'contact', 'sticker', 'reaction' +); + +CREATE TYPE gb.llm_provider AS ENUM ( + 'openai', 'anthropic', 'azure_openai', 'azure_claude', 'google', 'local', 'ollama', 'groq', 'mistral', 'cohere' +); + +CREATE TYPE gb.context_provider AS ENUM ( + 'qdrant', 'pinecone', 'weaviate', 'milvus', 'pgvector', 'elasticsearch', 'none' +); + +-- Task/workflow enums +CREATE TYPE gb.task_status AS ENUM ( + 'pending', 'ready', 'running', 'paused', 'waiting_approval', 'completed', 'failed', 'cancelled' +); + +CREATE TYPE gb.task_priority AS ENUM ( + 'low', 'normal', 'high', 'urgent', 'critical' +); + +CREATE TYPE gb.execution_mode AS ENUM ( + 'autonomous', 'supervised', 'manual' +); + +CREATE TYPE gb.risk_level AS ENUM ( + 'none', 'low', 'medium', 'high', 'critical' +); + +CREATE TYPE gb.approval_status AS ENUM ( + 'pending', 'approved', 'rejected', 'expired', 'skipped' +); + +CREATE TYPE gb.approval_decision AS ENUM ( + 'approve', 'reject', 'skip' +); + +-- Intent/AI enums +CREATE TYPE gb.intent_type AS ENUM ( + 'app_create', 'todo', 'monitor', 'action', 'schedule', 'goal', 'tool', 'query', 'unknown' +); + +CREATE TYPE gb.plan_status AS ENUM ( + 'pending', 'approved', 'rejected', 'executing', 'completed', 'failed' +); + +CREATE TYPE gb.safety_outcome AS ENUM ( + 'allowed', 'blocked', 'warning', 'error' +); + +CREATE TYPE gb.designer_change_type AS ENUM ( + 'style', 'html', 'database', 'tool', 'scheduler', 'config', 'multiple', 'unknown' +); + +-- Memory enums +CREATE TYPE gb.memory_type AS ENUM ( + 'short', 'long', 'episodic', 'semantic', 'procedural' +); + +-- Calendar/scheduling enums +CREATE TYPE gb.recurrence_pattern AS ENUM ( + 'once', 'daily', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly', 'custom' +); + +CREATE TYPE gb.booking_status AS ENUM ( + 'pending', 'confirmed', 'cancelled', 'completed', 'no_show' +); + +CREATE TYPE gb.resource_type AS ENUM ( + 'room', 'equipment', 'vehicle', 'person', 'virtual', 'other' +); + +-- Permission enums +CREATE TYPE gb.permission_level AS ENUM ( + 'none', 'read', 'write', 'admin', 'owner' +); + +CREATE TYPE gb.sync_status AS ENUM ( + 'synced', 'pending', 'conflict', 'error', 'deleted' +); + +-- Email enums +CREATE TYPE gb.email_status AS ENUM ( + 'draft', 'queued', 'sending', 'sent', 'delivered', 'bounced', 'failed', 'cancelled' +); + +CREATE TYPE gb.responder_type AS ENUM ( + 'out_of_office', 'vacation', 'custom', 'auto_reply' +); + +-- Meeting enums +CREATE TYPE gb.participant_status AS ENUM ( + 'invited', 'accepted', 'declined', 'tentative', 'waiting', 'admitted', 'left', 'kicked' +); + +CREATE TYPE gb.background_type AS ENUM ( + 'none', 'blur', 'image', 'video' +); + +CREATE TYPE gb.poll_type AS ENUM ( + 'single', 'multiple', 'ranked', 'open' +); + +-- Test enums +CREATE TYPE gb.test_status AS ENUM ( + 'pending', 'running', 'passed', 'failed', 'skipped', 'error', 'timeout' +); + +CREATE TYPE gb.test_account_type AS ENUM ( + 'sender', 'receiver', 'bot', 'admin', 'observer' +); + +-- ============================================================================ +-- CORE TABLES - Tenant-aware with shard_key +-- ============================================================================ + +-- Tenants (organizations/companies) +CREATE TABLE gb.tenants ( + id BIGSERIAL PRIMARY KEY, + shard_id SMALLINT NOT NULL, + external_id UUID DEFAULT gen_random_uuid() UNIQUE, + name VARCHAR(255) NOT NULL, + slug VARCHAR(128) NOT NULL UNIQUE, + region_code CHAR(3) NOT NULL DEFAULT 'USA', + plan_tier SMALLINT NOT NULL DEFAULT 0, -- 0=free, 1=starter, 2=pro, 3=enterprise + settings JSONB DEFAULT '{}'::jsonb, + limits JSONB DEFAULT '{"users": 5, "bots": 1, "storage_gb": 1}'::jsonb, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX idx_tenants_shard ON gb.tenants(shard_id); +CREATE INDEX idx_tenants_region ON gb.tenants(region_code); +CREATE INDEX idx_tenants_active ON gb.tenants(is_active) WHERE is_active; + +-- Users +CREATE TABLE gb.users ( + id BIGSERIAL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + external_id UUID DEFAULT gen_random_uuid(), + username VARCHAR(128) NOT NULL, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255), + phone_number VARCHAR(32), + display_name VARCHAR(255), + avatar_url VARCHAR(512), + locale CHAR(5) DEFAULT 'en-US', + timezone VARCHAR(64) DEFAULT 'UTC', + is_active BOOLEAN DEFAULT true, + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id), + CONSTRAINT uq_users_tenant_email UNIQUE (tenant_id, email), + CONSTRAINT uq_users_tenant_username UNIQUE (tenant_id, username) +); +CREATE INDEX idx_users_tenant ON gb.users(tenant_id); +CREATE INDEX idx_users_external ON gb.users(external_id); +CREATE INDEX idx_users_email ON gb.users(email); + +-- Bots +CREATE TABLE gb.bots ( + id BIGSERIAL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + external_id UUID DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + llm_provider gb.llm_provider NOT NULL DEFAULT 'openai', + llm_config JSONB DEFAULT '{}'::jsonb, + context_provider gb.context_provider NOT NULL DEFAULT 'qdrant', + context_config JSONB DEFAULT '{}'::jsonb, + system_prompt TEXT, + personality JSONB DEFAULT '{}'::jsonb, + capabilities JSONB DEFAULT '[]'::jsonb, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id), + CONSTRAINT uq_bots_tenant_name UNIQUE (tenant_id, name) +); +CREATE INDEX idx_bots_tenant ON gb.bots(tenant_id); +CREATE INDEX idx_bots_external ON gb.bots(external_id); +CREATE INDEX idx_bots_active ON gb.bots(tenant_id, is_active) WHERE is_active; + +-- Bot Channels +CREATE TABLE gb.bot_channels ( + id BIGSERIAL, + bot_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + channel_type gb.channel_type NOT NULL, + channel_identifier VARCHAR(255), -- phone number, email, webhook id, etc. + config JSONB DEFAULT '{}'::jsonb, + credentials_vault_path VARCHAR(512), -- Reference to Vault secret + is_active BOOLEAN DEFAULT true, + last_activity_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id), + CONSTRAINT uq_bot_channel UNIQUE (bot_id, channel_type, channel_identifier) +); +CREATE INDEX idx_bot_channels_bot ON gb.bot_channels(bot_id); +CREATE INDEX idx_bot_channels_type ON gb.bot_channels(channel_type); + +-- ============================================================================ +-- SESSION AND MESSAGE TABLES - High volume, partition-ready +-- ============================================================================ + +-- User Sessions (partitioned by created_at for time-series queries) +CREATE TABLE gb.sessions ( + id BIGSERIAL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + user_id BIGINT NOT NULL, + bot_id BIGINT NOT NULL, + external_id UUID DEFAULT gen_random_uuid(), + channel_type gb.channel_type NOT NULL DEFAULT 'web', + title VARCHAR(512) DEFAULT 'New Conversation', + context_data JSONB DEFAULT '{}'::jsonb, + current_tool VARCHAR(255), + message_count INT DEFAULT 0, + total_tokens INT DEFAULT 0, + last_activity_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id, created_at) +) PARTITION BY RANGE (created_at); + +-- Create partitions for sessions (monthly) +CREATE TABLE gb.sessions_y2024m01 PARTITION OF gb.sessions + FOR VALUES FROM ('2024-01-01') TO ('2024-02-01'); +CREATE TABLE gb.sessions_y2024m02 PARTITION OF gb.sessions + FOR VALUES FROM ('2024-02-01') TO ('2024-03-01'); +CREATE TABLE gb.sessions_y2024m03 PARTITION OF gb.sessions + FOR VALUES FROM ('2024-03-01') TO ('2024-04-01'); +CREATE TABLE gb.sessions_y2024m04 PARTITION OF gb.sessions + FOR VALUES FROM ('2024-04-01') TO ('2024-05-01'); +CREATE TABLE gb.sessions_y2024m05 PARTITION OF gb.sessions + FOR VALUES FROM ('2024-05-01') TO ('2024-06-01'); +CREATE TABLE gb.sessions_y2024m06 PARTITION OF gb.sessions + FOR VALUES FROM ('2024-06-01') TO ('2024-07-01'); +CREATE TABLE gb.sessions_y2024m07 PARTITION OF gb.sessions + FOR VALUES FROM ('2024-07-01') TO ('2024-08-01'); +CREATE TABLE gb.sessions_y2024m08 PARTITION OF gb.sessions + FOR VALUES FROM ('2024-08-01') TO ('2024-09-01'); +CREATE TABLE gb.sessions_y2024m09 PARTITION OF gb.sessions + FOR VALUES FROM ('2024-09-01') TO ('2024-10-01'); +CREATE TABLE gb.sessions_y2024m10 PARTITION OF gb.sessions + FOR VALUES FROM ('2024-10-01') TO ('2024-11-01'); +CREATE TABLE gb.sessions_y2024m11 PARTITION OF gb.sessions + FOR VALUES FROM ('2024-11-01') TO ('2024-12-01'); +CREATE TABLE gb.sessions_y2024m12 PARTITION OF gb.sessions + FOR VALUES FROM ('2024-12-01') TO ('2025-01-01'); +CREATE TABLE gb.sessions_y2025m01 PARTITION OF gb.sessions + FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); +CREATE TABLE gb.sessions_y2025m02 PARTITION OF gb.sessions + FOR VALUES FROM ('2025-02-01') TO ('2025-03-01'); +CREATE TABLE gb.sessions_y2025m03 PARTITION OF gb.sessions + FOR VALUES FROM ('2025-03-01') TO ('2025-04-01'); +CREATE TABLE gb.sessions_y2025m04 PARTITION OF gb.sessions + FOR VALUES FROM ('2025-04-01') TO ('2025-05-01'); +CREATE TABLE gb.sessions_y2025m05 PARTITION OF gb.sessions + FOR VALUES FROM ('2025-05-01') TO ('2025-06-01'); +CREATE TABLE gb.sessions_y2025m06 PARTITION OF gb.sessions + FOR VALUES FROM ('2025-06-01') TO ('2025-07-01'); +CREATE TABLE gb.sessions_y2025m07 PARTITION OF gb.sessions + FOR VALUES FROM ('2025-07-01') TO ('2025-08-01'); +CREATE TABLE gb.sessions_y2025m08 PARTITION OF gb.sessions + FOR VALUES FROM ('2025-08-01') TO ('2025-09-01'); +CREATE TABLE gb.sessions_y2025m09 PARTITION OF gb.sessions + FOR VALUES FROM ('2025-09-01') TO ('2025-10-01'); +CREATE TABLE gb.sessions_y2025m10 PARTITION OF gb.sessions + FOR VALUES FROM ('2025-10-01') TO ('2025-11-01'); +CREATE TABLE gb.sessions_y2025m11 PARTITION OF gb.sessions + FOR VALUES FROM ('2025-11-01') TO ('2025-12-01'); +CREATE TABLE gb.sessions_y2025m12 PARTITION OF gb.sessions + FOR VALUES FROM ('2025-12-01') TO ('2026-01-01'); +-- Default partition for future data +CREATE TABLE gb.sessions_default PARTITION OF gb.sessions DEFAULT; + +CREATE INDEX idx_sessions_user ON gb.sessions(user_id, created_at DESC); +CREATE INDEX idx_sessions_bot ON gb.sessions(bot_id, created_at DESC); +CREATE INDEX idx_sessions_tenant ON gb.sessions(tenant_id, created_at DESC); +CREATE INDEX idx_sessions_external ON gb.sessions(external_id); + +-- Message History (partitioned by created_at, highest volume table) +CREATE TABLE gb.messages ( + id BIGSERIAL, + session_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + user_id BIGINT NOT NULL, + role gb.message_role NOT NULL, + message_type gb.message_type NOT NULL DEFAULT 'text', + content TEXT NOT NULL, -- Encrypted content + content_hash CHAR(64), -- SHA-256 for deduplication + media_url VARCHAR(1024), + metadata JSONB DEFAULT '{}'::jsonb, + token_count INT DEFAULT 0, + processing_time_ms INT, + llm_model VARCHAR(64), + message_index INT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id, created_at) +) PARTITION BY RANGE (created_at); + +-- Create partitions for messages (monthly - can be more granular for high volume) +CREATE TABLE gb.messages_y2024m01 PARTITION OF gb.messages FOR VALUES FROM ('2024-01-01') TO ('2024-02-01'); +CREATE TABLE gb.messages_y2024m02 PARTITION OF gb.messages FOR VALUES FROM ('2024-02-01') TO ('2024-03-01'); +CREATE TABLE gb.messages_y2024m03 PARTITION OF gb.messages FOR VALUES FROM ('2024-03-01') TO ('2024-04-01'); +CREATE TABLE gb.messages_y2024m04 PARTITION OF gb.messages FOR VALUES FROM ('2024-04-01') TO ('2024-05-01'); +CREATE TABLE gb.messages_y2024m05 PARTITION OF gb.messages FOR VALUES FROM ('2024-05-01') TO ('2024-06-01'); +CREATE TABLE gb.messages_y2024m06 PARTITION OF gb.messages FOR VALUES FROM ('2024-06-01') TO ('2024-07-01'); +CREATE TABLE gb.messages_y2024m07 PARTITION OF gb.messages FOR VALUES FROM ('2024-07-01') TO ('2024-08-01'); +CREATE TABLE gb.messages_y2024m08 PARTITION OF gb.messages FOR VALUES FROM ('2024-08-01') TO ('2024-09-01'); +CREATE TABLE gb.messages_y2024m09 PARTITION OF gb.messages FOR VALUES FROM ('2024-09-01') TO ('2024-10-01'); +CREATE TABLE gb.messages_y2024m10 PARTITION OF gb.messages FOR VALUES FROM ('2024-10-01') TO ('2024-11-01'); +CREATE TABLE gb.messages_y2024m11 PARTITION OF gb.messages FOR VALUES FROM ('2024-11-01') TO ('2024-12-01'); +CREATE TABLE gb.messages_y2024m12 PARTITION OF gb.messages FOR VALUES FROM ('2024-12-01') TO ('2025-01-01'); +CREATE TABLE gb.messages_y2025m01 PARTITION OF gb.messages FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); +CREATE TABLE gb.messages_y2025m02 PARTITION OF gb.messages FOR VALUES FROM ('2025-02-01') TO ('2025-03-01'); +CREATE TABLE gb.messages_y2025m03 PARTITION OF gb.messages FOR VALUES FROM ('2025-03-01') TO ('2025-04-01'); +CREATE TABLE gb.messages_y2025m04 PARTITION OF gb.messages FOR VALUES FROM ('2025-04-01') TO ('2025-05-01'); +CREATE TABLE gb.messages_y2025m05 PARTITION OF gb.messages FOR VALUES FROM ('2025-05-01') TO ('2025-06-01'); +CREATE TABLE gb.messages_y2025m06 PARTITION OF gb.messages FOR VALUES FROM ('2025-06-01') TO ('2025-07-01'); +CREATE TABLE gb.messages_y2025m07 PARTITION OF gb.messages FOR VALUES FROM ('2025-07-01') TO ('2025-08-01'); +CREATE TABLE gb.messages_y2025m08 PARTITION OF gb.messages FOR VALUES FROM ('2025-08-01') TO ('2025-09-01'); +CREATE TABLE gb.messages_y2025m09 PARTITION OF gb.messages FOR VALUES FROM ('2025-09-01') TO ('2025-10-01'); +CREATE TABLE gb.messages_y2025m10 PARTITION OF gb.messages FOR VALUES FROM ('2025-10-01') TO ('2025-11-01'); +CREATE TABLE gb.messages_y2025m11 PARTITION OF gb.messages FOR VALUES FROM ('2025-11-01') TO ('2025-12-01'); +CREATE TABLE gb.messages_y2025m12 PARTITION OF gb.messages FOR VALUES FROM ('2025-12-01') TO ('2026-01-01'); +CREATE TABLE gb.messages_default PARTITION OF gb.messages DEFAULT; + +CREATE INDEX idx_messages_session ON gb.messages(session_id, message_index); +CREATE INDEX idx_messages_tenant ON gb.messages(tenant_id, created_at DESC); +CREATE INDEX idx_messages_user ON gb.messages(user_id, created_at DESC); + +-- ============================================================================ +-- CONFIGURATION TABLES +-- ============================================================================ + +-- Bot Configuration (key-value with proper typing) +CREATE TABLE gb.bot_config ( + id BIGSERIAL PRIMARY KEY, + bot_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + config_key VARCHAR(128) NOT NULL, + config_value TEXT NOT NULL, + value_type SMALLINT NOT NULL DEFAULT 0, -- 0=string, 1=int, 2=float, 3=bool, 4=json + is_secret BOOLEAN DEFAULT false, + vault_path VARCHAR(512), -- If is_secret, reference to Vault + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT uq_bot_config UNIQUE (bot_id, config_key) +); +CREATE INDEX idx_bot_config_bot ON gb.bot_config(bot_id); +CREATE INDEX idx_bot_config_key ON gb.bot_config(config_key); + +-- ============================================================================ +-- MEMORY TABLES +-- ============================================================================ + +-- Bot Memories (for long-term context) +CREATE TABLE gb.memories ( + id BIGSERIAL, + bot_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + user_id BIGINT, + session_id BIGINT, + memory_type gb.memory_type NOT NULL, + content TEXT NOT NULL, + embedding_id VARCHAR(128), -- Reference to vector DB + importance_score REAL DEFAULT 0.5, + access_count INT DEFAULT 0, + last_accessed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_memories_bot ON gb.memories(bot_id, memory_type); +CREATE INDEX idx_memories_user ON gb.memories(user_id, memory_type); +CREATE INDEX idx_memories_importance ON gb.memories(bot_id, importance_score DESC); + +-- ============================================================================ +-- AUTONOMOUS TASK TABLES +-- ============================================================================ + +-- Auto Tasks +CREATE TABLE gb.auto_tasks ( + id BIGSERIAL, + bot_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + session_id BIGINT, + external_id UUID DEFAULT gen_random_uuid(), + title VARCHAR(512) NOT NULL, + intent TEXT NOT NULL, + status gb.task_status NOT NULL DEFAULT 'pending', + execution_mode gb.execution_mode NOT NULL DEFAULT 'supervised', + priority gb.task_priority NOT NULL DEFAULT 'normal', + plan_id BIGINT, + basic_program TEXT, + current_step INT DEFAULT 0, + total_steps INT DEFAULT 0, + progress REAL DEFAULT 0.0, + step_results JSONB DEFAULT '[]'::jsonb, + error_message TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_auto_tasks_bot ON gb.auto_tasks(bot_id, status); +CREATE INDEX idx_auto_tasks_session ON gb.auto_tasks(session_id); +CREATE INDEX idx_auto_tasks_status ON gb.auto_tasks(status, priority); +CREATE INDEX idx_auto_tasks_external ON gb.auto_tasks(external_id); + +-- Execution Plans +CREATE TABLE gb.execution_plans ( + id BIGSERIAL, + bot_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + task_id BIGINT, + external_id UUID DEFAULT gen_random_uuid(), + intent TEXT NOT NULL, + intent_type gb.intent_type, + confidence REAL DEFAULT 0.0, + status gb.plan_status NOT NULL DEFAULT 'pending', + steps JSONB NOT NULL DEFAULT '[]'::jsonb, + context JSONB DEFAULT '{}'::jsonb, + basic_program TEXT, + simulation_result JSONB, + risk_level gb.risk_level DEFAULT 'low', + approved_by BIGINT, + approved_at TIMESTAMPTZ, + executed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_execution_plans_bot ON gb.execution_plans(bot_id, status); +CREATE INDEX idx_execution_plans_task ON gb.execution_plans(task_id); +CREATE INDEX idx_execution_plans_external ON gb.execution_plans(external_id); + +-- Task Approvals +CREATE TABLE gb.task_approvals ( + id BIGSERIAL, + bot_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + task_id BIGINT NOT NULL, + plan_id BIGINT, + step_index INT, + action_type VARCHAR(128) NOT NULL, + action_description TEXT NOT NULL, + risk_level gb.risk_level DEFAULT 'low', + status gb.approval_status NOT NULL DEFAULT 'pending', + decision gb.approval_decision, + decision_reason TEXT, + decided_by BIGINT, + decided_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_task_approvals_task ON gb.task_approvals(task_id); +CREATE INDEX idx_task_approvals_status ON gb.task_approvals(status, expires_at); + +-- Task Decisions +CREATE TABLE gb.task_decisions ( + id BIGSERIAL, + bot_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + task_id BIGINT NOT NULL, + question TEXT NOT NULL, + options JSONB NOT NULL DEFAULT '[]'::jsonb, + context JSONB DEFAULT '{}'::jsonb, + status gb.approval_status NOT NULL DEFAULT 'pending', + selected_option VARCHAR(255), + decision_reason TEXT, + decided_by BIGINT, + decided_at TIMESTAMPTZ, + timeout_seconds INT DEFAULT 3600, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_task_decisions_task ON gb.task_decisions(task_id); +CREATE INDEX idx_task_decisions_status ON gb.task_decisions(status); + +-- Safety Audit Log +CREATE TABLE gb.safety_audit_log ( + id BIGSERIAL, + bot_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + task_id BIGINT, + plan_id BIGINT, + action_type VARCHAR(128) NOT NULL, + action_details JSONB NOT NULL DEFAULT '{}'::jsonb, + constraint_checks JSONB DEFAULT '[]'::jsonb, + simulation_result JSONB, + risk_assessment JSONB, + outcome gb.safety_outcome NOT NULL, + error_message TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_safety_audit_bot ON gb.safety_audit_log(bot_id, created_at DESC); +CREATE INDEX idx_safety_audit_outcome ON gb.safety_audit_log(outcome, created_at DESC); + +-- Intent Classifications (for analytics and ML) +CREATE TABLE gb.intent_classifications ( + id BIGSERIAL, + bot_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + session_id BIGINT, + original_text TEXT NOT NULL, + intent_type gb.intent_type NOT NULL, + confidence REAL NOT NULL DEFAULT 0.0, + entities JSONB DEFAULT '{}'::jsonb, + suggested_name VARCHAR(255), + was_correct BOOLEAN, + corrected_type gb.intent_type, + feedback TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_intent_class_bot ON gb.intent_classifications(bot_id, intent_type); +CREATE INDEX idx_intent_class_confidence ON gb.intent_classifications(confidence); + +-- ============================================================================ +-- APP GENERATION TABLES +-- ============================================================================ + +-- Generated Apps +CREATE TABLE gb.generated_apps ( + id BIGSERIAL, + bot_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + external_id UUID DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + domain VARCHAR(128), + intent_source TEXT, + pages JSONB DEFAULT '[]'::jsonb, + tables_created JSONB DEFAULT '[]'::jsonb, + tools JSONB DEFAULT '[]'::jsonb, + schedulers JSONB DEFAULT '[]'::jsonb, + app_path VARCHAR(512), + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id), + CONSTRAINT uq_generated_apps UNIQUE (bot_id, name) +); +CREATE INDEX idx_generated_apps_bot ON gb.generated_apps(bot_id); +CREATE INDEX idx_generated_apps_external ON gb.generated_apps(external_id); + +-- Designer Changes (for undo support) +CREATE TABLE gb.designer_changes ( + id BIGSERIAL, + bot_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + session_id BIGINT, + change_type gb.designer_change_type NOT NULL, + description TEXT NOT NULL, + file_path VARCHAR(512) NOT NULL, + original_content TEXT NOT NULL, + new_content TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_designer_changes_bot ON gb.designer_changes(bot_id, created_at DESC); + +-- ============================================================================ +-- KNOWLEDGE BASE TABLES +-- ============================================================================ + +-- KB Collections +CREATE TABLE gb.kb_collections ( + id BIGSERIAL, + bot_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + external_id UUID DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + folder_path VARCHAR(512), + qdrant_collection VARCHAR(255), + document_count INT DEFAULT 0, + chunk_count INT DEFAULT 0, + total_tokens INT DEFAULT 0, + last_indexed_at TIMESTAMPTZ, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id), + CONSTRAINT uq_kb_collections UNIQUE (bot_id, name) +); +CREATE INDEX idx_kb_collections_bot ON gb.kb_collections(bot_id); +CREATE INDEX idx_kb_collections_external ON gb.kb_collections(external_id); + +-- KB Documents +CREATE TABLE gb.kb_documents ( + id BIGSERIAL, + collection_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + external_id UUID DEFAULT gen_random_uuid(), + file_path VARCHAR(512) NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_type VARCHAR(32), + file_size BIGINT, + content_hash CHAR(64), + chunk_count INT DEFAULT 0, + is_indexed BOOLEAN DEFAULT false, + indexed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_kb_documents_collection ON gb.kb_documents(collection_id); +CREATE INDEX idx_kb_documents_hash ON gb.kb_documents(content_hash); + +-- Session KB Associations +CREATE TABLE gb.session_kb_associations ( + id BIGSERIAL, + session_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + kb_name VARCHAR(255) NOT NULL, + kb_folder_path VARCHAR(512), + qdrant_collection VARCHAR(255), + added_by_tool VARCHAR(255), + is_active BOOLEAN DEFAULT true, + added_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id), + CONSTRAINT uq_session_kb UNIQUE (session_id, kb_name) +); +CREATE INDEX idx_session_kb_session ON gb.session_kb_associations(session_id); + +-- ============================================================================ +-- ANALYTICS TABLES (partitioned for high volume) +-- ============================================================================ + +-- Usage Analytics (daily aggregates per user/bot) +CREATE TABLE gb.usage_analytics ( + id BIGSERIAL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + user_id BIGINT NOT NULL, + bot_id BIGINT NOT NULL, + date DATE NOT NULL, + session_count INT DEFAULT 0, + message_count INT DEFAULT 0, + total_tokens INT DEFAULT 0, + total_processing_time_ms BIGINT DEFAULT 0, + avg_response_time_ms INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id, date), + CONSTRAINT uq_usage_daily UNIQUE (user_id, bot_id, date) +) PARTITION BY RANGE (date); + +-- Create partitions for analytics (monthly) +CREATE TABLE gb.usage_analytics_y2024m01 PARTITION OF gb.usage_analytics FOR VALUES FROM ('2024-01-01') TO ('2024-02-01'); +CREATE TABLE gb.usage_analytics_y2024m02 PARTITION OF gb.usage_analytics FOR VALUES FROM ('2024-02-01') TO ('2024-03-01'); +CREATE TABLE gb.usage_analytics_y2024m03 PARTITION OF gb.usage_analytics FOR VALUES FROM ('2024-03-01') TO ('2024-04-01'); +CREATE TABLE gb.usage_analytics_y2024m04 PARTITION OF gb.usage_analytics FOR VALUES FROM ('2024-04-01') TO ('2024-05-01'); +CREATE TABLE gb.usage_analytics_y2024m05 PARTITION OF gb.usage_analytics FOR VALUES FROM ('2024-05-01') TO ('2024-06-01'); +CREATE TABLE gb.usage_analytics_y2024m06 PARTITION OF gb.usage_analytics FOR VALUES FROM ('2024-06-01') TO ('2024-07-01'); +CREATE TABLE gb.usage_analytics_y2024m07 PARTITION OF gb.usage_analytics FOR VALUES FROM ('2024-07-01') TO ('2024-08-01'); +CREATE TABLE gb.usage_analytics_y2024m08 PARTITION OF gb.usage_analytics FOR VALUES FROM ('2024-08-01') TO ('2024-09-01'); +CREATE TABLE gb.usage_analytics_y2024m09 PARTITION OF gb.usage_analytics FOR VALUES FROM ('2024-09-01') TO ('2024-10-01'); +CREATE TABLE gb.usage_analytics_y2024m10 PARTITION OF gb.usage_analytics FOR VALUES FROM ('2024-10-01') TO ('2024-11-01'); +CREATE TABLE gb.usage_analytics_y2024m11 PARTITION OF gb.usage_analytics FOR VALUES FROM ('2024-11-01') TO ('2024-12-01'); +CREATE TABLE gb.usage_analytics_y2024m12 PARTITION OF gb.usage_analytics FOR VALUES FROM ('2024-12-01') TO ('2025-01-01'); +CREATE TABLE gb.usage_analytics_y2025 PARTITION OF gb.usage_analytics FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); +CREATE TABLE gb.usage_analytics_default PARTITION OF gb.usage_analytics DEFAULT; + +CREATE INDEX idx_usage_analytics_tenant ON gb.usage_analytics(tenant_id, date); +CREATE INDEX idx_usage_analytics_bot ON gb.usage_analytics(bot_id, date); + +-- Analytics Events (for detailed tracking) +CREATE TABLE gb.analytics_events ( + id BIGSERIAL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + user_id BIGINT, + session_id BIGINT, + bot_id BIGINT, + event_type VARCHAR(64) NOT NULL, + event_data JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id, created_at) +) PARTITION BY RANGE (created_at); + +CREATE TABLE gb.analytics_events_y2024 PARTITION OF gb.analytics_events FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); +CREATE TABLE gb.analytics_events_y2025 PARTITION OF gb.analytics_events FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); +CREATE TABLE gb.analytics_events_default PARTITION OF gb.analytics_events DEFAULT; + +CREATE INDEX idx_analytics_events_type ON gb.analytics_events(event_type, created_at DESC); +CREATE INDEX idx_analytics_events_tenant ON gb.analytics_events(tenant_id, created_at DESC); + +-- ============================================================================ +-- TOOLS AND AUTOMATION TABLES +-- ============================================================================ + +-- Tools Definition +CREATE TABLE gb.tools ( + id BIGSERIAL, + bot_id BIGINT, -- NULL for system-wide tools + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + external_id UUID DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + parameters JSONB DEFAULT '{}'::jsonb, + script TEXT NOT NULL, + tool_type VARCHAR(64) DEFAULT 'basic', + is_system BOOLEAN DEFAULT false, + is_active BOOLEAN DEFAULT true, + usage_count BIGINT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_tools_bot ON gb.tools(bot_id); +CREATE INDEX idx_tools_name ON gb.tools(name); +CREATE UNIQUE INDEX idx_tools_unique_name ON gb.tools(tenant_id, COALESCE(bot_id, 0), name); + +-- System Automations +CREATE TABLE gb.automations ( + id BIGSERIAL, + bot_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + external_id UUID DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + kind SMALLINT NOT NULL, -- 1=scheduler, 2=monitor, 3=trigger + target VARCHAR(255), + schedule VARCHAR(64), -- Cron expression + param VARCHAR(255), + recurrence gb.recurrence_pattern, + is_active BOOLEAN DEFAULT true, + last_triggered TIMESTAMPTZ, + next_trigger TIMESTAMPTZ, + run_count BIGINT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_automations_bot ON gb.automations(bot_id); +CREATE INDEX idx_automations_next ON gb.automations(next_trigger) WHERE is_active; + +-- ============================================================================ +-- CALENDAR AND SCHEDULING TABLES +-- ============================================================================ + +-- Calendar Events +CREATE TABLE gb.calendar_events ( + id BIGSERIAL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + user_id BIGINT NOT NULL, + external_id UUID DEFAULT gen_random_uuid(), + title VARCHAR(512) NOT NULL, + description TEXT, + location VARCHAR(512), + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + all_day BOOLEAN DEFAULT false, + recurrence gb.recurrence_pattern, + recurrence_rule TEXT, + reminder_minutes INT[], + status gb.booking_status DEFAULT 'confirmed', + is_private BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_calendar_events_user ON gb.calendar_events(user_id, start_time); +CREATE INDEX idx_calendar_events_time ON gb.calendar_events(start_time, end_time); + +-- Resources (rooms, equipment, etc.) +CREATE TABLE gb.resources ( + id BIGSERIAL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + external_id UUID DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + resource_type gb.resource_type NOT NULL, + capacity INT, + location VARCHAR(512), + amenities JSONB DEFAULT '[]'::jsonb, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_resources_tenant ON gb.resources(tenant_id, resource_type); + +-- Resource Bookings +CREATE TABLE gb.resource_bookings ( + id BIGSERIAL, + resource_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + user_id BIGINT NOT NULL, + event_id BIGINT, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + status gb.booking_status NOT NULL DEFAULT 'pending', + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_resource_bookings_resource ON gb.resource_bookings(resource_id, start_time); +CREATE INDEX idx_resource_bookings_user ON gb.resource_bookings(user_id); + +-- ============================================================================ +-- EMAIL TABLES +-- ============================================================================ + +-- Email Messages +CREATE TABLE gb.email_messages ( + id BIGSERIAL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + user_id BIGINT NOT NULL, + external_id UUID DEFAULT gen_random_uuid(), + folder VARCHAR(64) DEFAULT 'inbox', + from_address VARCHAR(255) NOT NULL, + to_addresses TEXT[] NOT NULL, + cc_addresses TEXT[], + bcc_addresses TEXT[], + subject VARCHAR(998), + body_text TEXT, + body_html TEXT, + headers JSONB DEFAULT '{}'::jsonb, + attachments JSONB DEFAULT '[]'::jsonb, + status gb.email_status NOT NULL DEFAULT 'draft', + is_read BOOLEAN DEFAULT false, + is_starred BOOLEAN DEFAULT false, + labels TEXT[], + sent_at TIMESTAMPTZ, + received_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_email_messages_user ON gb.email_messages(user_id, folder, received_at DESC); +CREATE INDEX idx_email_messages_status ON gb.email_messages(status); + +-- ============================================================================ +-- MEETING TABLES +-- ============================================================================ + +-- Meetings +CREATE TABLE gb.meetings ( + id BIGSERIAL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + bot_id BIGINT, + external_id UUID DEFAULT gen_random_uuid(), + title VARCHAR(512) NOT NULL, + description TEXT, + host_id BIGINT NOT NULL, + room_code VARCHAR(32) UNIQUE, + scheduled_start TIMESTAMPTZ, + scheduled_end TIMESTAMPTZ, + actual_start TIMESTAMPTZ, + actual_end TIMESTAMPTZ, + max_participants INT DEFAULT 100, + settings JSONB DEFAULT '{}'::jsonb, + recording_url VARCHAR(1024), + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_meetings_host ON gb.meetings(host_id); +CREATE INDEX idx_meetings_room ON gb.meetings(room_code); + +-- Meeting Participants +CREATE TABLE gb.meeting_participants ( + id BIGSERIAL, + meeting_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + user_id BIGINT, + external_email VARCHAR(255), + display_name VARCHAR(255), + status gb.participant_status NOT NULL DEFAULT 'invited', + role VARCHAR(32) DEFAULT 'participant', + joined_at TIMESTAMPTZ, + left_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_meeting_participants_meeting ON gb.meeting_participants(meeting_id); +CREATE INDEX idx_meeting_participants_user ON gb.meeting_participants(user_id); + +-- ============================================================================ +-- TASK MANAGEMENT TABLES +-- ============================================================================ + +-- Tasks (traditional task management, not auto_tasks) +CREATE TABLE gb.tasks ( + id BIGSERIAL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + external_id UUID DEFAULT gen_random_uuid(), + title VARCHAR(512) NOT NULL, + description TEXT, + assignee_id BIGINT, + reporter_id BIGINT, + project_id BIGINT, + parent_task_id BIGINT, + status gb.task_status NOT NULL DEFAULT 'pending', + priority gb.task_priority NOT NULL DEFAULT 'normal', + due_date TIMESTAMPTZ, + estimated_hours REAL, + actual_hours REAL, + progress SMALLINT DEFAULT 0, + tags TEXT[], + dependencies BIGINT[], + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_tasks_assignee ON gb.tasks(assignee_id, status); +CREATE INDEX idx_tasks_project ON gb.tasks(project_id, status); +CREATE INDEX idx_tasks_due ON gb.tasks(due_date) WHERE status NOT IN ('completed', 'cancelled'); + +-- Task Comments +CREATE TABLE gb.task_comments ( + id BIGSERIAL, + task_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + author_id BIGINT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_task_comments_task ON gb.task_comments(task_id); + +-- ============================================================================ +-- CONNECTED ACCOUNTS AND INTEGRATIONS +-- ============================================================================ + +-- Connected Accounts (OAuth integrations) +CREATE TABLE gb.connected_accounts ( + id BIGSERIAL, + user_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + external_id UUID DEFAULT gen_random_uuid(), + provider VARCHAR(64) NOT NULL, + provider_user_id VARCHAR(255), + email VARCHAR(255), + display_name VARCHAR(255), + access_token_vault VARCHAR(512), -- Vault path for encrypted token + refresh_token_vault VARCHAR(512), + token_expires_at TIMESTAMPTZ, + scopes TEXT[], + sync_status gb.sync_status DEFAULT 'pending', + last_sync_at TIMESTAMPTZ, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id), + CONSTRAINT uq_connected_accounts UNIQUE (user_id, provider, provider_user_id) +); +CREATE INDEX idx_connected_accounts_user ON gb.connected_accounts(user_id); +CREATE INDEX idx_connected_accounts_provider ON gb.connected_accounts(provider); + +-- ============================================================================ +-- PENDING INFO (for ASK LATER keyword) +-- ============================================================================ + +CREATE TABLE gb.pending_info ( + id BIGSERIAL, + bot_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + shard_id SMALLINT NOT NULL, + field_name VARCHAR(128) NOT NULL, + field_label VARCHAR(255) NOT NULL, + field_type VARCHAR(64) NOT NULL DEFAULT 'text', + reason TEXT, + config_key VARCHAR(255) NOT NULL, + is_filled BOOLEAN DEFAULT false, + filled_at TIMESTAMPTZ, + filled_value TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, shard_id) +); +CREATE INDEX idx_pending_info_bot ON gb.pending_info(bot_id, is_filled); +CREATE INDEX idx_pending_info_config ON gb.pending_info(config_key); + +-- ============================================================================ +-- HELPER FUNCTIONS FOR SHARDING +-- ============================================================================ + +-- Function to get shard_id for a tenant +CREATE OR REPLACE FUNCTION gb.get_shard_id(p_tenant_id BIGINT) +RETURNS SMALLINT AS $$ +BEGIN + RETURN (SELECT shard_id FROM gb.tenant_shard_map WHERE tenant_id = p_tenant_id); +END; +$$ LANGUAGE plpgsql STABLE; + +-- Function to generate next ID with shard awareness +CREATE OR REPLACE FUNCTION gb.generate_sharded_id(p_shard_id SMALLINT) +RETURNS BIGINT AS $$ +DECLARE + v_time_part BIGINT; + v_shard_part BIGINT; + v_seq_part BIGINT; +BEGIN + -- Snowflake-like ID: timestamp (41 bits) + shard (10 bits) + sequence (12 bits) + v_time_part := (EXTRACT(EPOCH FROM NOW())::BIGINT - 1704067200) << 22; -- Since 2024-01-01 + v_shard_part := (p_shard_id::BIGINT & 1023) << 12; + v_seq_part := (nextval('gb.global_seq') & 4095); + RETURN v_time_part | v_shard_part | v_seq_part; +END; +$$ LANGUAGE plpgsql; + +-- Global sequence for ID generation +CREATE SEQUENCE IF NOT EXISTS gb.global_seq; + +-- ============================================================================ +-- GRANTS AND COMMENTS +-- ============================================================================ + +COMMENT ON SCHEMA gb IS 'General Bots billion-scale schema v7.0.0'; +COMMENT ON TABLE gb.shard_config IS 'Shard configuration for horizontal scaling'; +COMMENT ON TABLE gb.tenant_shard_map IS 'Maps tenants to their respective shards'; +COMMENT ON TABLE gb.tenants IS 'Multi-tenant organizations'; +COMMENT ON TABLE gb.users IS 'User accounts with tenant isolation'; +COMMENT ON TABLE gb.bots IS 'Bot configurations'; +COMMENT ON TABLE gb.sessions IS 'Conversation sessions (partitioned by month)'; +COMMENT ON TABLE gb.messages IS 'Message history (partitioned by month, highest volume)'; +COMMENT ON TABLE gb.auto_tasks IS 'Autonomous task execution'; +COMMENT ON TABLE gb.execution_plans IS 'LLM-compiled execution plans'; + +-- Default shard for single-node deployment +INSERT INTO gb.shard_config (shard_id, region_code, datacenter, connection_string, is_primary, min_tenant_id, max_tenant_id) +VALUES (1, 'USA', 'local', 'postgresql://localhost:5432/botserver', true, 1, 9223372036854775807); + +-- Default tenant for backwards compatibility +INSERT INTO gb.tenants (id, shard_id, name, slug, region_code, plan_tier) +VALUES (1, 1, 'Default', 'default', 'USA', 0); + +INSERT INTO gb.tenant_shard_map (tenant_id, shard_id, region_code) +VALUES (1, 1, 'USA'); diff --git a/src/auto_task/app_generator.rs b/src/auto_task/app_generator.rs index e967346b9..0e96262b5 100644 --- a/src/auto_task/app_generator.rs +++ b/src/auto_task/app_generator.rs @@ -2,6 +2,7 @@ use crate::auto_task::app_logs::{log_generator_error, log_generator_info}; use crate::basic::keywords::table_definition::{ generate_create_table_sql, FieldDefinition, TableDefinition, }; +use crate::core::config::ConfigManager; use crate::core::shared::get_content_type; use crate::core::shared::models::UserSession; use crate::core::shared::state::AppState; @@ -167,7 +168,7 @@ impl AppGenerator { ), ); - let llm_app = match self.generate_complete_app_with_llm(intent).await { + let llm_app = match self.generate_complete_app_with_llm(intent, session.bot_id).await { Ok(app) => { log_generator_info( &app.name, @@ -425,6 +426,7 @@ guid, string, text, integer, decimal, boolean, date, datetime, json async fn generate_complete_app_with_llm( &self, intent: &str, + bot_id: Uuid, ) -> Result> { let platform = Self::get_platform_prompt(); @@ -478,7 +480,7 @@ IMPORTANT: Respond with valid JSON only."# ); - let response = self.call_llm(&prompt).await?; + let response = self.call_llm(&prompt, bot_id).await?; Self::parse_llm_app_response(&response) } @@ -551,10 +553,28 @@ Respond with valid JSON only."# async fn call_llm( &self, prompt: &str, + bot_id: Uuid, ) -> Result> { #[cfg(feature = "llm")] { - let config = serde_json::json!({ + // Get model and key from bot configuration + let config_manager = ConfigManager::new(self.state.conn.clone()); + let model = config_manager + .get_config(&bot_id, "llm-model", None) + .unwrap_or_else(|_| { + config_manager + .get_config(&Uuid::nil(), "llm-model", None) + .unwrap_or_else(|_| "gpt-4".to_string()) + }); + let key = config_manager + .get_config(&bot_id, "llm-key", None) + .unwrap_or_else(|_| { + config_manager + .get_config(&Uuid::nil(), "llm-key", None) + .unwrap_or_default() + }); + + let llm_config = serde_json::json!({ "temperature": 0.7, "max_tokens": 16000 }); @@ -562,7 +582,7 @@ Respond with valid JSON only."# match self .state .llm_provider - .generate(prompt, &config, "gpt-4", "") + .generate(prompt, &llm_config, &model, &key) .await { Ok(response) => return Ok(response), diff --git a/src/auto_task/designer_ai.rs b/src/auto_task/designer_ai.rs index 5965fb391..cd8403e14 100644 --- a/src/auto_task/designer_ai.rs +++ b/src/auto_task/designer_ai.rs @@ -1,3 +1,4 @@ +use crate::core::config::ConfigManager; use crate::shared::models::UserSession; use crate::shared::state::AppState; use chrono::{DateTime, Utc}; @@ -142,7 +143,7 @@ impl DesignerAI { // Analyze what the user wants to modify let analysis = self - .analyze_modification(&request.instruction, &request.context) + .analyze_modification(&request.instruction, &request.context, session.bot_id) .await?; trace!("Modification analysis: {:?}", analysis.modification_type); @@ -292,6 +293,7 @@ impl DesignerAI { &self, instruction: &str, context: &DesignerContext, + bot_id: Uuid, ) -> Result> { let context_json = serde_json::to_string(context)?; @@ -334,7 +336,7 @@ Guidelines: Respond ONLY with valid JSON."# ); - let response = self.call_llm(&prompt).await?; + let response = self.call_llm(&prompt, bot_id).await?; Self::parse_analysis_response(&response, instruction) } @@ -1037,19 +1039,37 @@ Respond ONLY with valid JSON."# async fn call_llm( &self, prompt: &str, + bot_id: Uuid, ) -> Result> { trace!("Designer calling LLM"); #[cfg(feature = "llm")] { - let config = serde_json::json!({ + // Get model and key from bot configuration + let config_manager = ConfigManager::new(self.state.conn.clone()); + let model = config_manager + .get_config(&bot_id, "llm-model", None) + .unwrap_or_else(|_| { + config_manager + .get_config(&Uuid::nil(), "llm-model", None) + .unwrap_or_else(|_| "gpt-4".to_string()) + }); + let key = config_manager + .get_config(&bot_id, "llm-key", None) + .unwrap_or_else(|_| { + config_manager + .get_config(&Uuid::nil(), "llm-key", None) + .unwrap_or_default() + }); + + let llm_config = serde_json::json!({ "temperature": 0.3, "max_tokens": 2000 }); let response = self .state .llm_provider - .generate(prompt, &config, "gpt-4", "") + .generate(prompt, &llm_config, &model, &key) .await?; return Ok(response); } diff --git a/src/auto_task/intent_classifier.rs b/src/auto_task/intent_classifier.rs index 5f2e0d4b7..9e337f531 100644 --- a/src/auto_task/intent_classifier.rs +++ b/src/auto_task/intent_classifier.rs @@ -1,5 +1,6 @@ use crate::auto_task::app_generator::AppGenerator; use crate::auto_task::intent_compiler::IntentCompiler; +use crate::core::config::ConfigManager; use crate::shared::models::UserSession; use crate::shared::state::AppState; use chrono::{DateTime, Utc}; @@ -155,7 +156,7 @@ impl IntentClassifier { ); // Use LLM to classify the intent - let classification = self.classify_with_llm(intent).await?; + let classification = self.classify_with_llm(intent, session.bot_id).await?; // Store classification for analytics self.store_classification(&classification, session)?; @@ -222,6 +223,7 @@ impl IntentClassifier { async fn classify_with_llm( &self, intent: &str, + bot_id: Uuid, ) -> Result> { let prompt = format!( r#"Classify this user request into one of these intent types: @@ -273,7 +275,7 @@ Respond with JSON only: }}"# ); - let response = self.call_llm(&prompt).await?; + let response = self.call_llm(&prompt, bot_id).await?; Self::parse_classification_response(&response, intent) } @@ -952,19 +954,37 @@ END TRIGGER async fn call_llm( &self, prompt: &str, + bot_id: Uuid, ) -> Result> { trace!("Calling LLM for intent classification"); #[cfg(feature = "llm")] { - let config = serde_json::json!({ + // Get model and key from bot configuration + let config_manager = ConfigManager::new(self.state.conn.clone()); + let model = config_manager + .get_config(&bot_id, "llm-model", None) + .unwrap_or_else(|_| { + config_manager + .get_config(&Uuid::nil(), "llm-model", None) + .unwrap_or_else(|_| "gpt-4".to_string()) + }); + let key = config_manager + .get_config(&bot_id, "llm-key", None) + .unwrap_or_else(|_| { + config_manager + .get_config(&Uuid::nil(), "llm-key", None) + .unwrap_or_default() + }); + + let llm_config = serde_json::json!({ "temperature": 0.3, "max_tokens": 1000 }); let response = self .state .llm_provider - .generate(prompt, &config, "gpt-4", "") + .generate(prompt, &llm_config, &model, &key) .await?; return Ok(response); } diff --git a/src/auto_task/intent_compiler.rs b/src/auto_task/intent_compiler.rs index e4628a233..e3f58c239 100644 --- a/src/auto_task/intent_compiler.rs +++ b/src/auto_task/intent_compiler.rs @@ -1,3 +1,4 @@ +use crate::core::config::ConfigManager; use crate::shared::models::UserSession; use crate::shared::state::AppState; use chrono::{DateTime, Utc}; @@ -341,10 +342,10 @@ impl IntentCompiler { &intent[..intent.len().min(100)] ); - let entities = self.extract_entities(intent).await?; + let entities = self.extract_entities(intent, session.bot_id).await?; trace!("Extracted entities: {entities:?}"); - let plan = self.generate_plan(intent, &entities).await?; + let plan = self.generate_plan(intent, &entities, session.bot_id).await?; trace!("Generated plan with {} steps", plan.steps.len()); let basic_program = Self::generate_basic_program(&plan, &entities); @@ -382,6 +383,7 @@ impl IntentCompiler { async fn extract_entities( &self, intent: &str, + bot_id: Uuid, ) -> Result> { let prompt = format!( r#"Analyze this user request and extract structured information. @@ -406,7 +408,7 @@ Extract the following as JSON: Respond ONLY with valid JSON, no explanation."# ); - let response = self.call_llm(&prompt).await?; + let response = self.call_llm(&prompt, bot_id).await?; let entities: IntentEntities = serde_json::from_str(&response).unwrap_or_else(|e| { warn!("Failed to parse entity extraction response: {e}"); IntentEntities { @@ -423,6 +425,7 @@ Respond ONLY with valid JSON, no explanation."# &self, intent: &str, entities: &IntentEntities, + bot_id: Uuid, ) -> Result> { let keywords_list = self.config.available_keywords.join(", "); let mcp_servers_list = self.config.available_mcp_servers.join(", "); @@ -483,7 +486,7 @@ Respond ONLY with valid JSON."#, self.config.max_plan_steps ); - let response = self.call_llm(&prompt).await?; + let response = self.call_llm(&prompt, bot_id).await?; #[derive(Deserialize)] struct PlanResponse { @@ -680,19 +683,37 @@ Respond ONLY with valid JSON."#, async fn call_llm( &self, prompt: &str, + bot_id: Uuid, ) -> Result> { trace!("Calling LLM with prompt length: {}", prompt.len()); #[cfg(feature = "llm")] { - let config = serde_json::json!({ + // Get model and key from bot configuration + let config_manager = ConfigManager::new(self.state.conn.clone()); + let model = config_manager + .get_config(&bot_id, "llm-model", None) + .unwrap_or_else(|_| { + config_manager + .get_config(&Uuid::nil(), "llm-model", None) + .unwrap_or_else(|_| self.config.model.clone()) + }); + let key = config_manager + .get_config(&bot_id, "llm-key", None) + .unwrap_or_else(|_| { + config_manager + .get_config(&Uuid::nil(), "llm-key", None) + .unwrap_or_default() + }); + + let llm_config = serde_json::json!({ "temperature": self.config.temperature, "max_tokens": self.config.max_tokens }); let response = self .state .llm_provider - .generate(prompt, &config, &self.config.model, "") + .generate(prompt, &llm_config, &model, &key) .await?; return Ok(response); } diff --git a/src/core/bootstrap/mod.rs b/src/core/bootstrap/mod.rs index a0f275dec..1af44f4f6 100644 --- a/src/core/bootstrap/mod.rs +++ b/src/core/bootstrap/mod.rs @@ -735,7 +735,7 @@ impl BootstrapManager { let pm = PackageManager::new(self.install_mode.clone(), self.tenant.clone())?; - let required_components = vec!["vault", "tables", "directory", "drive", "cache", "llm"]; + let required_components = vec!["vault", "tables", "directory", "drive", "cache", "llm", "vector_db"]; let vault_needs_setup = !self.stack_dir("conf/vault/init.json").exists(); diff --git a/src/core/kb/kb_indexer.rs b/src/core/kb/kb_indexer.rs index fcb9b9213..5ccfaafed 100644 --- a/src/core/kb/kb_indexer.rs +++ b/src/core/kb/kb_indexer.rs @@ -93,6 +93,16 @@ impl KbIndexer { } } + /// Check if Qdrant vector database is available + pub async fn check_qdrant_health(&self) -> Result { + let health_url = format!("{}/healthz", self.qdrant_config.url); + + match self.http_client.get(&health_url).send().await { + Ok(response) => Ok(response.status().is_success()), + Err(_) => Ok(false), + } + } + pub async fn index_kb_folder( &self, bot_name: &str, @@ -101,6 +111,19 @@ impl KbIndexer { ) -> Result { info!("Indexing KB folder: {} for bot {}", kb_name, bot_name); + // Check if Qdrant is available before proceeding + if !self.check_qdrant_health().await.unwrap_or(false) { + warn!( + "Qdrant vector database is not available at {}. KB indexing skipped. \ + Install and start vector_db component to enable KB indexing.", + self.qdrant_config.url + ); + return Err(anyhow::anyhow!( + "Qdrant vector database not available at {}. Start the vector_db service to enable KB indexing.", + self.qdrant_config.url + )); + } + let collection_name = format!("{}_{}", bot_name, kb_name); self.ensure_collection_exists(&collection_name).await?; diff --git a/src/core/package_manager/installer.rs b/src/core/package_manager/installer.rs index 31862c73b..9426dd768 100644 --- a/src/core/package_manager/installer.rs +++ b/src/core/package_manager/installer.rs @@ -1085,8 +1085,8 @@ EOF"#.to_string(), } } - info!( - "[START] About to spawn shell command for {}: {}", + trace!( + "About to spawn shell command for {}: {}", component.name, rendered_cmd ); trace!("[START] Working dir: {}", bin_path.display()); @@ -1097,15 +1097,15 @@ EOF"#.to_string(), .envs(&evaluated_envs) .spawn(); - info!( - "[START] Spawn result for {}: {:?}", + trace!( + "Spawn result for {}: {:?}", component.name, child.is_ok() ); std::thread::sleep(std::time::Duration::from_secs(2)); - info!( - "[START] Checking if {} process exists after 2s sleep...", + trace!( + "Checking if {} process exists after 2s sleep...", component.name ); let check_proc = std::process::Command::new("pgrep") @@ -1113,8 +1113,8 @@ EOF"#.to_string(), .output(); if let Ok(output) = check_proc { let pids = String::from_utf8_lossy(&output.stdout); - info!( - "[START] pgrep '{}' result: '{}'", + trace!( + "pgrep '{}' result: '{}'", component.name, pids.trim() ); @@ -1122,11 +1122,11 @@ EOF"#.to_string(), match child { Ok(c) => { - trace!("[START] Component {} started successfully", component.name); + trace!("Component {} started successfully", component.name); Ok(c) } Err(e) => { - error!("[START] Spawn failed for {}: {}", component.name, e); + error!("Spawn failed for {}: {}", component.name, e); let err_msg = e.to_string(); if err_msg.contains("already running") || err_msg.contains("be running") diff --git a/src/core/shared/enums.rs b/src/core/shared/enums.rs new file mode 100644 index 000000000..c4ccaa98b --- /dev/null +++ b/src/core/shared/enums.rs @@ -0,0 +1,816 @@ +//! Database Enum Types for Billion-Scale Schema +//! +//! This module defines Rust enums that map directly to PostgreSQL enum types. +//! Using enums instead of TEXT columns provides: +//! - Type safety at compile time +//! - Efficient storage (stored as integers internally) +//! - Fast comparisons and indexing +//! - Automatic validation +//! +//! All enums derive necessary traits for Diesel ORM integration. + +use diesel::deserialize::{self, FromSql}; +use diesel::pg::{Pg, PgValue}; +use diesel::serialize::{self, Output, ToSql}; +use diesel::sql_types::SmallInt; +use diesel::{AsExpression, FromSqlRow}; +use serde::{Deserialize, Serialize}; +use std::io::Write; + +// ============================================================================ +// CHANNEL TYPES +// ============================================================================ + +/// Communication channel types for bot interactions +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[diesel(sql_type = SmallInt)] +#[serde(rename_all = "snake_case")] +#[repr(i16)] +pub enum ChannelType { + Web = 0, + WhatsApp = 1, + Telegram = 2, + MsTeams = 3, + Slack = 4, + Email = 5, + Sms = 6, + Voice = 7, + Instagram = 8, + Api = 9, +} + +impl Default for ChannelType { + fn default() -> Self { + Self::Web + } +} + +impl ToSql for ChannelType { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + let v = *self as i16; + out.write_all(&v.to_be_bytes())?; + Ok(serialize::IsNull::No) + } +} + +impl FromSql for ChannelType { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let value = i16::from_sql(bytes)?; + match value { + 0 => Ok(Self::Web), + 1 => Ok(Self::WhatsApp), + 2 => Ok(Self::Telegram), + 3 => Ok(Self::MsTeams), + 4 => Ok(Self::Slack), + 5 => Ok(Self::Email), + 6 => Ok(Self::Sms), + 7 => Ok(Self::Voice), + 8 => Ok(Self::Instagram), + 9 => Ok(Self::Api), + _ => Err(format!("Unknown ChannelType: {}", value).into()), + } + } +} + +impl std::fmt::Display for ChannelType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Web => write!(f, "web"), + Self::WhatsApp => write!(f, "whatsapp"), + Self::Telegram => write!(f, "telegram"), + Self::MsTeams => write!(f, "msteams"), + Self::Slack => write!(f, "slack"), + Self::Email => write!(f, "email"), + Self::Sms => write!(f, "sms"), + Self::Voice => write!(f, "voice"), + Self::Instagram => write!(f, "instagram"), + Self::Api => write!(f, "api"), + } + } +} + +impl std::str::FromStr for ChannelType { + type Err = String; + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "web" => Ok(Self::Web), + "whatsapp" => Ok(Self::WhatsApp), + "telegram" => Ok(Self::Telegram), + "msteams" | "ms_teams" | "teams" => Ok(Self::MsTeams), + "slack" => Ok(Self::Slack), + "email" => Ok(Self::Email), + "sms" => Ok(Self::Sms), + "voice" => Ok(Self::Voice), + "instagram" => Ok(Self::Instagram), + "api" => Ok(Self::Api), + _ => Err(format!("Unknown channel type: {}", s)), + } + } +} + +// ============================================================================ +// MESSAGE ROLE +// ============================================================================ + +/// Role of a message in a conversation +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[diesel(sql_type = SmallInt)] +#[serde(rename_all = "snake_case")] +#[repr(i16)] +pub enum MessageRole { + User = 1, + Assistant = 2, + System = 3, + Tool = 4, + Episodic = 9, + Compact = 10, +} + +impl Default for MessageRole { + fn default() -> Self { + Self::User + } +} + +impl ToSql for MessageRole { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + let v = *self as i16; + out.write_all(&v.to_be_bytes())?; + Ok(serialize::IsNull::No) + } +} + +impl FromSql for MessageRole { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let value = i16::from_sql(bytes)?; + match value { + 1 => Ok(Self::User), + 2 => Ok(Self::Assistant), + 3 => Ok(Self::System), + 4 => Ok(Self::Tool), + 9 => Ok(Self::Episodic), + 10 => Ok(Self::Compact), + _ => Err(format!("Unknown MessageRole: {}", value).into()), + } + } +} + +impl std::fmt::Display for MessageRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::User => write!(f, "user"), + Self::Assistant => write!(f, "assistant"), + Self::System => write!(f, "system"), + Self::Tool => write!(f, "tool"), + Self::Episodic => write!(f, "episodic"), + Self::Compact => write!(f, "compact"), + } + } +} + +impl std::str::FromStr for MessageRole { + type Err = String; + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "user" => Ok(Self::User), + "assistant" => Ok(Self::Assistant), + "system" => Ok(Self::System), + "tool" => Ok(Self::Tool), + "episodic" => Ok(Self::Episodic), + "compact" => Ok(Self::Compact), + _ => Err(format!("Unknown message role: {}", s)), + } + } +} + +// ============================================================================ +// MESSAGE TYPE +// ============================================================================ + +/// Type of message content +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[diesel(sql_type = SmallInt)] +#[serde(rename_all = "snake_case")] +#[repr(i16)] +pub enum MessageType { + Text = 0, + Image = 1, + Audio = 2, + Video = 3, + Document = 4, + Location = 5, + Contact = 6, + Sticker = 7, + Reaction = 8, +} + +impl Default for MessageType { + fn default() -> Self { + Self::Text + } +} + +impl ToSql for MessageType { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + let v = *self as i16; + out.write_all(&v.to_be_bytes())?; + Ok(serialize::IsNull::No) + } +} + +impl FromSql for MessageType { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let value = i16::from_sql(bytes)?; + match value { + 0 => Ok(Self::Text), + 1 => Ok(Self::Image), + 2 => Ok(Self::Audio), + 3 => Ok(Self::Video), + 4 => Ok(Self::Document), + 5 => Ok(Self::Location), + 6 => Ok(Self::Contact), + 7 => Ok(Self::Sticker), + 8 => Ok(Self::Reaction), + _ => Err(format!("Unknown MessageType: {}", value).into()), + } + } +} + +impl std::fmt::Display for MessageType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Text => write!(f, "text"), + Self::Image => write!(f, "image"), + Self::Audio => write!(f, "audio"), + Self::Video => write!(f, "video"), + Self::Document => write!(f, "document"), + Self::Location => write!(f, "location"), + Self::Contact => write!(f, "contact"), + Self::Sticker => write!(f, "sticker"), + Self::Reaction => write!(f, "reaction"), + } + } +} + +// ============================================================================ +// LLM PROVIDER +// ============================================================================ + +/// Supported LLM providers +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[diesel(sql_type = SmallInt)] +#[serde(rename_all = "snake_case")] +#[repr(i16)] +pub enum LlmProvider { + OpenAi = 0, + Anthropic = 1, + AzureOpenAi = 2, + AzureClaude = 3, + Google = 4, + Local = 5, + Ollama = 6, + Groq = 7, + Mistral = 8, + Cohere = 9, +} + +impl Default for LlmProvider { + fn default() -> Self { + Self::OpenAi + } +} + +impl ToSql for LlmProvider { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + let v = *self as i16; + out.write_all(&v.to_be_bytes())?; + Ok(serialize::IsNull::No) + } +} + +impl FromSql for LlmProvider { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let value = i16::from_sql(bytes)?; + match value { + 0 => Ok(Self::OpenAi), + 1 => Ok(Self::Anthropic), + 2 => Ok(Self::AzureOpenAi), + 3 => Ok(Self::AzureClaude), + 4 => Ok(Self::Google), + 5 => Ok(Self::Local), + 6 => Ok(Self::Ollama), + 7 => Ok(Self::Groq), + 8 => Ok(Self::Mistral), + 9 => Ok(Self::Cohere), + _ => Err(format!("Unknown LlmProvider: {}", value).into()), + } + } +} + +impl std::fmt::Display for LlmProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::OpenAi => write!(f, "openai"), + Self::Anthropic => write!(f, "anthropic"), + Self::AzureOpenAi => write!(f, "azure_openai"), + Self::AzureClaude => write!(f, "azure_claude"), + Self::Google => write!(f, "google"), + Self::Local => write!(f, "local"), + Self::Ollama => write!(f, "ollama"), + Self::Groq => write!(f, "groq"), + Self::Mistral => write!(f, "mistral"), + Self::Cohere => write!(f, "cohere"), + } + } +} + +// ============================================================================ +// CONTEXT PROVIDER (Vector DB) +// ============================================================================ + +/// Supported vector database providers +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[diesel(sql_type = SmallInt)] +#[serde(rename_all = "snake_case")] +#[repr(i16)] +pub enum ContextProvider { + None = 0, + Qdrant = 1, + Pinecone = 2, + Weaviate = 3, + Milvus = 4, + PgVector = 5, + Elasticsearch = 6, +} + +impl Default for ContextProvider { + fn default() -> Self { + Self::Qdrant + } +} + +impl ToSql for ContextProvider { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + let v = *self as i16; + out.write_all(&v.to_be_bytes())?; + Ok(serialize::IsNull::No) + } +} + +impl FromSql for ContextProvider { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let value = i16::from_sql(bytes)?; + match value { + 0 => Ok(Self::None), + 1 => Ok(Self::Qdrant), + 2 => Ok(Self::Pinecone), + 3 => Ok(Self::Weaviate), + 4 => Ok(Self::Milvus), + 5 => Ok(Self::PgVector), + 6 => Ok(Self::Elasticsearch), + _ => Err(format!("Unknown ContextProvider: {}", value).into()), + } + } +} + +// ============================================================================ +// TASK STATUS +// ============================================================================ + +/// Status of a task (both regular tasks and auto-tasks) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[diesel(sql_type = SmallInt)] +#[serde(rename_all = "snake_case")] +#[repr(i16)] +pub enum TaskStatus { + Pending = 0, + Ready = 1, + Running = 2, + Paused = 3, + WaitingApproval = 4, + Completed = 5, + Failed = 6, + Cancelled = 7, +} + +impl Default for TaskStatus { + fn default() -> Self { + Self::Pending + } +} + +impl ToSql for TaskStatus { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + let v = *self as i16; + out.write_all(&v.to_be_bytes())?; + Ok(serialize::IsNull::No) + } +} + +impl FromSql for TaskStatus { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let value = i16::from_sql(bytes)?; + match value { + 0 => Ok(Self::Pending), + 1 => Ok(Self::Ready), + 2 => Ok(Self::Running), + 3 => Ok(Self::Paused), + 4 => Ok(Self::WaitingApproval), + 5 => Ok(Self::Completed), + 6 => Ok(Self::Failed), + 7 => Ok(Self::Cancelled), + _ => Err(format!("Unknown TaskStatus: {}", value).into()), + } + } +} + +impl std::fmt::Display for TaskStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Pending => write!(f, "pending"), + Self::Ready => write!(f, "ready"), + Self::Running => write!(f, "running"), + Self::Paused => write!(f, "paused"), + Self::WaitingApproval => write!(f, "waiting_approval"), + Self::Completed => write!(f, "completed"), + Self::Failed => write!(f, "failed"), + Self::Cancelled => write!(f, "cancelled"), + } + } +} + +impl std::str::FromStr for TaskStatus { + type Err = String; + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "pending" => Ok(Self::Pending), + "ready" => Ok(Self::Ready), + "running" => Ok(Self::Running), + "paused" => Ok(Self::Paused), + "waiting_approval" | "waitingapproval" => Ok(Self::WaitingApproval), + "completed" | "done" => Ok(Self::Completed), + "failed" | "error" => Ok(Self::Failed), + "cancelled" | "canceled" => Ok(Self::Cancelled), + _ => Err(format!("Unknown task status: {}", s)), + } + } +} + +// ============================================================================ +// TASK PRIORITY +// ============================================================================ + +/// Priority level for tasks +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[diesel(sql_type = SmallInt)] +#[serde(rename_all = "snake_case")] +#[repr(i16)] +pub enum TaskPriority { + Low = 0, + Normal = 1, + High = 2, + Urgent = 3, + Critical = 4, +} + +impl Default for TaskPriority { + fn default() -> Self { + Self::Normal + } +} + +impl ToSql for TaskPriority { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + let v = *self as i16; + out.write_all(&v.to_be_bytes())?; + Ok(serialize::IsNull::No) + } +} + +impl FromSql for TaskPriority { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let value = i16::from_sql(bytes)?; + match value { + 0 => Ok(Self::Low), + 1 => Ok(Self::Normal), + 2 => Ok(Self::High), + 3 => Ok(Self::Urgent), + 4 => Ok(Self::Critical), + _ => Err(format!("Unknown TaskPriority: {}", value).into()), + } + } +} + +impl std::fmt::Display for TaskPriority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Low => write!(f, "low"), + Self::Normal => write!(f, "normal"), + Self::High => write!(f, "high"), + Self::Urgent => write!(f, "urgent"), + Self::Critical => write!(f, "critical"), + } + } +} + +impl std::str::FromStr for TaskPriority { + type Err = String; + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "low" => Ok(Self::Low), + "normal" | "medium" => Ok(Self::Normal), + "high" => Ok(Self::High), + "urgent" => Ok(Self::Urgent), + "critical" => Ok(Self::Critical), + _ => Err(format!("Unknown task priority: {}", s)), + } + } +} + +// ============================================================================ +// EXECUTION MODE +// ============================================================================ + +/// Execution mode for autonomous tasks +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[diesel(sql_type = SmallInt)] +#[serde(rename_all = "snake_case")] +#[repr(i16)] +pub enum ExecutionMode { + Manual = 0, + Supervised = 1, + Autonomous = 2, +} + +impl Default for ExecutionMode { + fn default() -> Self { + Self::Supervised + } +} + +impl ToSql for ExecutionMode { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + let v = *self as i16; + out.write_all(&v.to_be_bytes())?; + Ok(serialize::IsNull::No) + } +} + +impl FromSql for ExecutionMode { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let value = i16::from_sql(bytes)?; + match value { + 0 => Ok(Self::Manual), + 1 => Ok(Self::Supervised), + 2 => Ok(Self::Autonomous), + _ => Err(format!("Unknown ExecutionMode: {}", value).into()), + } + } +} + +impl std::fmt::Display for ExecutionMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Manual => write!(f, "manual"), + Self::Supervised => write!(f, "supervised"), + Self::Autonomous => write!(f, "autonomous"), + } + } +} + +// ============================================================================ +// RISK LEVEL +// ============================================================================ + +/// Risk assessment level for actions +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[diesel(sql_type = SmallInt)] +#[serde(rename_all = "snake_case")] +#[repr(i16)] +pub enum RiskLevel { + None = 0, + Low = 1, + Medium = 2, + High = 3, + Critical = 4, +} + +impl Default for RiskLevel { + fn default() -> Self { + Self::Low + } +} + +impl ToSql for RiskLevel { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + let v = *self as i16; + out.write_all(&v.to_be_bytes())?; + Ok(serialize::IsNull::No) + } +} + +impl FromSql for RiskLevel { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let value = i16::from_sql(bytes)?; + match value { + 0 => Ok(Self::None), + 1 => Ok(Self::Low), + 2 => Ok(Self::Medium), + 3 => Ok(Self::High), + 4 => Ok(Self::Critical), + _ => Err(format!("Unknown RiskLevel: {}", value).into()), + } + } +} + +impl std::fmt::Display for RiskLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => write!(f, "none"), + Self::Low => write!(f, "low"), + Self::Medium => write!(f, "medium"), + Self::High => write!(f, "high"), + Self::Critical => write!(f, "critical"), + } + } +} + +// ============================================================================ +// APPROVAL STATUS +// ============================================================================ + +/// Status of an approval request +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[diesel(sql_type = SmallInt)] +#[serde(rename_all = "snake_case")] +#[repr(i16)] +pub enum ApprovalStatus { + Pending = 0, + Approved = 1, + Rejected = 2, + Expired = 3, + Skipped = 4, +} + +impl Default for ApprovalStatus { + fn default() -> Self { + Self::Pending + } +} + +impl ToSql for ApprovalStatus { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + let v = *self as i16; + out.write_all(&v.to_be_bytes())?; + Ok(serialize::IsNull::No) + } +} + +impl FromSql for ApprovalStatus { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let value = i16::from_sql(bytes)?; + match value { + 0 => Ok(Self::Pending), + 1 => Ok(Self::Approved), + 2 => Ok(Self::Rejected), + 3 => Ok(Self::Expired), + 4 => Ok(Self::Skipped), + _ => Err(format!("Unknown ApprovalStatus: {}", value).into()), + } + } +} + +impl std::fmt::Display for ApprovalStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Pending => write!(f, "pending"), + Self::Approved => write!(f, "approved"), + Self::Rejected => write!(f, "rejected"), + Self::Expired => write!(f, "expired"), + Self::Skipped => write!(f, "skipped"), + } + } +} + +// ============================================================================ +// APPROVAL DECISION +// ============================================================================ + +/// Decision made on an approval request +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[diesel(sql_type = SmallInt)] +#[serde(rename_all = "snake_case")] +#[repr(i16)] +pub enum ApprovalDecision { + Approve = 0, + Reject = 1, + Skip = 2, +} + +impl ToSql for ApprovalDecision { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + let v = *self as i16; + out.write_all(&v.to_be_bytes())?; + Ok(serialize::IsNull::No) + } +} + +impl FromSql for ApprovalDecision { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let value = i16::from_sql(bytes)?; + match value { + 0 => Ok(Self::Approve), + 1 => Ok(Self::Reject), + 2 => Ok(Self::Skip), + _ => Err(format!("Unknown ApprovalDecision: {}", value).into()), + } + } +} + +impl std::fmt::Display for ApprovalDecision { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Approve => write!(f, "approve"), + Self::Reject => write!(f, "reject"), + Self::Skip => write!(f, "skip"), + } + } +} + +// ============================================================================ +// INTENT TYPE +// ============================================================================ + +/// Classified intent type from user requests +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, AsExpression, FromSqlRow)] +#[diesel(sql_type = SmallInt)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[repr(i16)] +pub enum IntentType { + Unknown = 0, + AppCreate = 1, + Todo = 2, + Monitor = 3, + Action = 4, + Schedule = 5, + Goal = 6, + Tool = 7, + Query = 8, +} + +impl Default for IntentType { + fn default() -> Self { + Self::Unknown + } +} + +impl ToSql for IntentType { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + let v = *self as i16; + out.write_all(&v.to_be_bytes())?; + Ok(serialize::IsNull::No) + } +} + +impl FromSql for IntentType { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let value = i16::from_sql(bytes)?; + match value { + 0 => Ok(Self::Unknown), + 1 => Ok(Self::AppCreate), + 2 => Ok(Self::Todo), + 3 => Ok(Self::Monitor), + 4 => Ok(Self::Action), + 5 => Ok(Self::Schedule), + 6 => Ok(Self::Goal), + 7 => Ok(Self::Tool), + 8 => Ok(Self::Query), + _ => Err(format!("Unknown IntentType: {}", value).into()), + } + } +} + +impl std::fmt::Display for IntentType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unknown => write!(f, "UNKNOWN"), + Self::AppCreate => write!(f, "APP_CREATE"), + Self::Todo => write!(f, "TODO"), + Self::Monitor => write!(f, "MONITOR"), + Self::Action => write!(f, "ACTION"), + Self::Schedule => write!(f, "SCHEDULE"), + Self::Goal => write!(f, "GOAL"), + Self::Tool => write!(f, "TOOL"), + Self::Query => write!(f, "QUERY"), + } + } +} + +impl std::str::FromStr for IntentType { + type Err = String; + fn from_str(s: &str) -> Result { + match s.to_uppercase().as_str() { + "UNKNOWN" => Ok(Self::Unknown), + "APP_CREATE" | "APPCREATE" | "APP" | "APPLICATION" | "CREATE_APP" => Ok(Self::AppCreate), + "TODO" | "TASK" | "REMINDER" => Ok(Self::Todo), + "MONITOR" | "WATCH" | "ALERT" | "ON_CHANGE" => Ok(Self::Monitor), diff --git a/src/core/shared/mod.rs b/src/core/shared/mod.rs index 0244528e5..ba1da5236 100644 --- a/src/core/shared/mod.rs +++ b/src/core/shared/mod.rs @@ -5,6 +5,7 @@ pub mod admin; pub mod analytics; +pub mod enums; pub mod models; pub mod schema; pub mod state; @@ -13,6 +14,7 @@ pub mod test_utils; pub mod utils; +pub use enums::*; pub use schema::*; diff --git a/src/llm/claude.rs b/src/llm/claude.rs index bd497f7a6..3726a68b5 100644 --- a/src/llm/claude.rs +++ b/src/llm/claude.rs @@ -119,6 +119,21 @@ impl ClaudeClient { headers } + /// Normalize role names for Claude API compatibility. + /// Claude only accepts "user" or "assistant" roles in messages. + /// - "episodic" and "compact" roles (conversation summaries) are converted to "user" with a context prefix + /// - "system" roles should be handled separately (not in messages array) + /// - Unknown roles default to "user" + fn normalize_role(role: &str) -> Option<(String, bool)> { + match role { + "user" => Some(("user".to_string(), false)), + "assistant" => Some(("assistant".to_string(), false)), + "system" => None, // System messages handled separately + "episodic" | "compact" => Some(("user".to_string(), true)), // Mark as context + _ => Some(("user".to_string(), false)), + } + } + pub fn build_messages( system_prompt: &str, context_data: &str, @@ -133,6 +148,13 @@ impl ClaudeClient { system_parts.push(context_data.to_string()); } + // Extract episodic memory content and add to system prompt + for (role, content) in history { + if role == "episodic" || role == "compact" { + system_parts.push(format!("[Previous conversation summary]: {}", content)); + } + } + let system = if system_parts.is_empty() { None } else { @@ -141,9 +163,16 @@ impl ClaudeClient { let messages: Vec = history .iter() - .map(|(role, content)| ClaudeMessage { - role: role.clone(), - content: content.clone(), + .filter_map(|(role, content)| { + match Self::normalize_role(role) { + Some((normalized_role, is_context)) if !is_context => { + Some(ClaudeMessage { + role: normalized_role, + content: content.clone(), + }) + } + _ => None, // Skip system, episodic, compact (already in system prompt) + } }) .collect(); @@ -180,7 +209,7 @@ impl LLMProvider for ClaudeClient { }; let empty_vec = vec![]; - let claude_messages: Vec = if messages.is_array() { + let mut claude_messages: Vec = if messages.is_array() { let arr = messages.as_array().unwrap_or(&empty_vec); if arr.is_empty() { vec![ClaudeMessage { @@ -192,11 +221,16 @@ impl LLMProvider for ClaudeClient { .filter_map(|m| { let role = m["role"].as_str().unwrap_or("user"); let content = m["content"].as_str().unwrap_or(""); - if role == "system" { + // Skip system messages (handled separately), episodic/compact (context), and empty content + if role == "system" || role == "episodic" || role == "compact" || content.is_empty() { None } else { + let normalized_role = match role { + "user" | "assistant" => role.to_string(), + _ => "user".to_string(), + }; Some(ClaudeMessage { - role: role.to_string(), + role: normalized_role, content: content.to_string(), }) } @@ -210,6 +244,14 @@ impl LLMProvider for ClaudeClient { }] }; + // Ensure at least one user message exists + if claude_messages.is_empty() && !prompt.is_empty() { + claude_messages.push(ClaudeMessage { + role: "user".to_string(), + content: prompt.to_string(), + }); + } + let system_prompt: Option = if messages.is_array() { messages .as_array() @@ -226,6 +268,11 @@ impl LLMProvider for ClaudeClient { let system = system_prompt.filter(|s| !s.is_empty()); + // Validate we have at least one message with content + if claude_messages.is_empty() { + return Err("Cannot send request to Claude: no messages with content".into()); + } + let request = ClaudeRequest { model: model_name.to_string(), max_tokens: 4096, @@ -279,7 +326,7 @@ impl LLMProvider for ClaudeClient { }; let empty_vec = vec![]; - let claude_messages: Vec = if messages.is_array() { + let mut claude_messages: Vec = if messages.is_array() { let arr = messages.as_array().unwrap_or(&empty_vec); if arr.is_empty() { vec![ClaudeMessage { @@ -291,11 +338,16 @@ impl LLMProvider for ClaudeClient { .filter_map(|m| { let role = m["role"].as_str().unwrap_or("user"); let content = m["content"].as_str().unwrap_or(""); - if role == "system" { + // Skip system messages (handled separately), episodic/compact (context), and empty content + if role == "system" || role == "episodic" || role == "compact" || content.is_empty() { None } else { + let normalized_role = match role { + "user" | "assistant" => role.to_string(), + _ => "user".to_string(), + }; Some(ClaudeMessage { - role: role.to_string(), + role: normalized_role, content: content.to_string(), }) } @@ -309,6 +361,14 @@ impl LLMProvider for ClaudeClient { }] }; + // Ensure at least one user message exists + if claude_messages.is_empty() && !prompt.is_empty() { + claude_messages.push(ClaudeMessage { + role: "user".to_string(), + content: prompt.to_string(), + }); + } + let system_prompt: Option = if messages.is_array() { messages .as_array() @@ -325,6 +385,11 @@ impl LLMProvider for ClaudeClient { let system = system_prompt.filter(|s| !s.is_empty()); + // Validate we have at least one message with content + if claude_messages.is_empty() { + return Err("Cannot send streaming request to Claude: no messages with content".into()); + } + let request = ClaudeRequest { model: model_name.to_string(), max_tokens: 4096, diff --git a/src/main.rs b/src/main.rs index 17f079381..5f0eadac4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -178,14 +178,13 @@ async fn run_axum_server( let cors = create_cors_layer(); // Create auth config for protected routes + // TODO: Re-enable auth for production - currently disabled for development let auth_config = Arc::new(AuthConfig::default() .add_anonymous_path("/health") .add_anonymous_path("/healthz") - .add_anonymous_path("/api/health") - .add_anonymous_path("/api/v1/health") + .add_anonymous_path("/api") // Disable auth for all API routes during development .add_anonymous_path("/ws") .add_anonymous_path("/auth") - .add_anonymous_path("/api/auth") .add_public_path("/static") .add_public_path("/favicon.ico"));