commit 5bd496d355d399b3e3dc710f4abb051f7128133e Author: belviskhoremk Date: Sun Feb 22 21:59:37 2026 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de81240 --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +.venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment variables +.env +.env.test +.env.local +.env.*.local +.env.example + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Uploads +uploads/ +*.jpg +*.jpeg +*.png +*.pdf +*.doc +*.docx + +# Alembic +alembic/versions/*.pyc + +# Temporary files +*.tmp +*.temp \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7c31a51 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6104c6a --- /dev/null +++ b/README.md @@ -0,0 +1,462 @@ +# Contexta — AI Chatbot SaaS Platform + +A full-stack SaaS platform that lets businesses build custom AI chatbots from their documents, publish them to a marketplace, and optionally export self-hostable code. + +--- + +## Architecture at a Glance + +``` +contexta_be/ → FastAPI backend (Python 3.11) +contexta_fe/ → React + TypeScript frontend (Vite) +``` + +**Key tech stack:** +- **Backend**: FastAPI, Supabase (auth + DB), Qdrant (vectors), LangChain +- **Frontend**: React 18, TypeScript, Tailwind CSS, TanStack Query, Zustand +- **AI**: Fireworks AI (free tier), OpenAI, Anthropic, Google Gemini +- **Payments**: Stripe +- **Infra**: Docker, Railway/Vercel-ready + +--- + +## Prerequisites + +| Tool | Version | Purpose | +|------|---------|---------| +| Python | 3.11+ | Backend runtime | +| Node.js | 18+ | Frontend build | +| Git | any | Version control | + +**External services you need to set up (all have free tiers):** + +| Service | What for | Free tier? | +|---------|----------|------------| +| [Supabase](https://supabase.com) | Database + Auth | ✅ Yes | +| [Qdrant Cloud](https://cloud.qdrant.io) | Vector search | ✅ Yes (1GB) | +| [Fireworks AI](https://fireworks.ai) | LLM (free models) | ✅ Yes | +| [OpenAI](https://platform.openai.com) | Embeddings + GPT-4 | Pay-per-use | +| [Stripe](https://stripe.com) | Payments | ✅ Test mode | + +--- + +## Part 1: Supabase Setup (Database + Auth) + +### 1.1 Create a Supabase project + +1. Go to [supabase.com](https://supabase.com) → New project +2. Save your **Project URL** and both keys (anon + service_role) + +### 1.2 Run the database schema + +1. In your Supabase dashboard → **SQL Editor** +2. Open `contexta_be/supabase_schema.sql` +3. Paste the entire file → Run + +This creates all tables, RLS policies, and triggers. + +### 1.3 Enable Email Auth + +1. In Supabase → **Authentication** → **Providers** +2. Ensure "Email" is enabled +3. Optionally disable "Confirm email" for faster testing + +--- + +## Part 2: Qdrant Setup (Vector Database) + +### Option A: Qdrant Cloud (recommended) + +1. Go to [cloud.qdrant.io](https://cloud.qdrant.io) → Create cluster (free tier) +2. Get your **Cluster URL** and **API Key** + +### Option B: Local Qdrant (for development) + +```bash +docker run -p 6333:6333 qdrant/qdrant +``` + +Then set `QDRANT_URL=http://localhost:6333` and leave `QDRANT_API_KEY` empty. + +--- + +## Part 3: Backend Setup (`contexta_be`) + +### 3.1 Clone and configure + +```bash +cd contexta_be +cp .env.example .env +``` + +Edit `.env` with your credentials: + +```env +# App +APP_ENV=development +APP_SECRET_KEY=change-this-to-a-random-string-in-production +ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 + +# Supabase (from your project settings) +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_ANON_KEY=eyJ... +SUPABASE_SERVICE_ROLE_KEY=eyJ... + +# Qdrant +QDRANT_URL=https://your-cluster.qdrant.io +QDRANT_API_KEY=your-key-here + +# LLM - MINIMUM REQUIRED: OpenAI (for embeddings) +OPENAI_API_KEY=sk-... + +# LLM - Optional but recommended +FIREWORKS_API_KEY=fw_... # Free models for starter plan +ANTHROPIC_API_KEY=sk-ant-... # Claude models +GOOGLE_API_KEY=AIza... # Gemini models + +# Stripe (use test keys for development) +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_STARTER_PRICE_ID=price_... +STRIPE_PRO_PRICE_ID=price_... +``` + +### 3.2 Create virtual environment and install + +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +pip install -r requirements.txt +``` + +### 3.3 Start the backend + +```bash +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +The API will be available at: +- **API**: http://localhost:8000 +- **Swagger docs**: http://localhost:8000/docs +- **Health check**: http://localhost:8000/health + +### 3.4 Docker alternative + +```bash +# Copy and edit .env first +cp .env.example .env +# Edit .env... + +# Start with Docker (includes Qdrant locally) +docker-compose up -d +``` + +--- + +## Part 4: Frontend Setup (`contexta_fe`) + +### 4.1 Install dependencies + +```bash +cd contexta_fe +npm install +``` + +### 4.2 Configure environment + +```bash +cp .env.example .env +``` + +`.env` content: +```env +VITE_API_URL=http://localhost:8000 +VITE_APP_NAME=Contexta +``` + +### 4.3 Start the frontend + +```bash +npm run dev +``` + +Frontend available at: http://localhost:5173 + +--- + +## Part 5: Stripe Setup (Payments) + +### 5.1 Create Stripe products + +1. Go to [dashboard.stripe.com](https://dashboard.stripe.com) → Products +2. Create **Starter** product → Add price → $39/month recurring +3. Create **Pro** product → Add price → $119/month recurring +4. Copy the **Price IDs** (start with `price_`) → paste into backend `.env` + +### 5.2 Set up webhook (for local testing) + +Install Stripe CLI: +```bash +# Mac +brew install stripe/stripe-cli/stripe + +# Others: https://stripe.com/docs/stripe-cli +``` + +Forward webhooks to local: +```bash +stripe listen --forward-to localhost:8000/api/v1/billing/webhook +``` + +Copy the webhook signing secret → paste into `STRIPE_WEBHOOK_SECRET` in `.env` + +### 5.3 Test a subscription + +Use Stripe test card: `4242 4242 4242 4242` with any future expiry and any CVC. + +--- + +## Part 6: Testing the Full Flow + +### 6.1 Create an account + +1. Open http://localhost:5173 +2. Click "Get started free" or go to http://localhost:5173/signup +3. Fill in company name, email, password + +### 6.2 Create a chatbot + +1. After login → **Dashboard** → **New Chatbot** +2. Enter a name and configure settings +3. Click **Save** + +### 6.3 Upload documents + +1. In the chatbot builder → **Documents** tab +2. Drag and drop a PDF, DOCX, or CSV file +3. Wait for processing (status changes to "completed" with chunk count) + +> ⚠️ Document processing requires a valid OpenAI API key for embeddings and a working Qdrant instance. + +### 6.4 Test in preview mode + +1. In the chatbot builder → **Preview** tab +2. Type a question related to your uploaded document +3. The chatbot should answer based on the document content + +### 6.5 Publish to marketplace (requires paid plan) + +1. Subscribe to Starter or Pro (Stripe checkout) +2. Back in Dashboard → click **Publish** on your chatbot +3. Visit http://localhost:5173/marketplace to see it listed + +### 6.6 Export code (Pro plan) + +In the chatbot builder → click **Export Code** button → downloads a ZIP containing: +- `backend/` — Complete FastAPI app with your vectors +- `frontend/` — React TypeScript chat widget +- `setup.py` — Interactive setup wizard +- `QUICK_START.md` — 5-minute deployment guide + +--- + +## API Reference + +The complete API is documented via Swagger at http://localhost:8000/docs when the backend is running. + +### Key endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/auth/signup` | Create account | +| POST | `/api/v1/auth/login` | Login | +| GET | `/api/v1/auth/me` | Current user | +| GET | `/api/v1/chatbots` | List your chatbots | +| POST | `/api/v1/chatbots` | Create chatbot | +| PUT | `/api/v1/chatbots/{id}` | Update chatbot | +| POST | `/api/v1/chatbots/{id}/publish` | Publish to marketplace | +| POST | `/api/v1/chatbots/{id}/export` | Download code export (Pro) | +| POST | `/api/v1/chatbots/{chatbot_id}/documents` | Upload document | +| POST | `/api/v1/chat/{chatbot_id}` | Send chat message | +| GET | `/api/v1/marketplace/chatbots` | Browse marketplace | +| POST | `/api/v1/billing/checkout` | Create Stripe checkout | + +--- + +## Deployment + +### Backend → Railway + +```bash +# Install Railway CLI +npm i -g @railway/cli + +cd contexta_be +railway login +railway init +railway up +``` + +Set all environment variables in Railway dashboard → Variables tab. + +### Frontend → Vercel + +```bash +npm i -g vercel + +cd contexta_fe +vercel +``` + +Set `VITE_API_URL` to your Railway backend URL in Vercel environment variables. + +### Update CORS + +Once deployed, update `ALLOWED_ORIGINS` in backend to include your Vercel domain: +``` +ALLOWED_ORIGINS=https://your-app.vercel.app +``` + +--- + +## Minimal Setup (Testing Only, No Real AI) + +If you want to test the UI without real API keys, you can: + +1. Run only the frontend +2. Mock the API responses + +For the quickest backend test without Qdrant/OpenAI, comment out the vector store initialization in startup and the RAG processing in document upload. The chat will still work but won't return relevant answers. + +--- + +## Environment Variables Reference + +### Backend (`contexta_be/.env`) + +| Variable | Required | Description | +|----------|----------|-------------| +| `SUPABASE_URL` | ✅ | Supabase project URL | +| `SUPABASE_ANON_KEY` | ✅ | Supabase anon key | +| `SUPABASE_SERVICE_ROLE_KEY` | ✅ | Supabase service role key | +| `QDRANT_URL` | ✅ | Qdrant cluster URL | +| `QDRANT_API_KEY` | ⚠️ | Required for Qdrant Cloud | +| `OPENAI_API_KEY` | ✅ | Required for embeddings | +| `FIREWORKS_API_KEY` | ⚠️ | For free-tier Llama/Mixtral models | +| `ANTHROPIC_API_KEY` | ⚠️ | For Claude models (Pro plan) | +| `GOOGLE_API_KEY` | ⚠️ | For Gemini models (Pro plan) | +| `STRIPE_SECRET_KEY` | ⚠️ | For billing (use test key in dev) | +| `STRIPE_WEBHOOK_SECRET` | ⚠️ | For Stripe webhooks | +| `STRIPE_STARTER_PRICE_ID` | ⚠️ | Stripe price ID for Starter plan | +| `STRIPE_PRO_PRICE_ID` | ⚠️ | Stripe price ID for Pro plan | +| `ALLOWED_ORIGINS` | ✅ | Comma-separated CORS origins | +| `APP_SECRET_KEY` | ✅ | Random secret for production | + +### Frontend (`contexta_fe/.env`) + +| Variable | Required | Description | +|----------|----------|-------------| +| `VITE_API_URL` | ✅ | Backend API URL | + +--- + +## Project Structure + +``` +contexta_be/ +├── app/ +│ ├── main.py # FastAPI app + router registration +│ ├── config.py # Settings + plan limits +│ ├── database.py # Supabase client +│ ├── models.py # Pydantic models +│ ├── dependencies.py # Auth middleware +│ ├── routers/ +│ │ ├── auth.py # Signup, login, me +│ │ ├── chatbots.py # CRUD + publish + export +│ │ ├── documents.py # File upload + processing +│ │ ├── chat.py # RAG chat endpoint +│ │ ├── marketplace.py # Public chatbot listing +│ │ └── billing.py # Stripe checkout + webhooks +│ └── services/ +│ ├── vector_store.py # Qdrant operations +│ ├── embeddings.py # OpenAI embeddings +│ ├── document_processor.py # PDF/DOCX/CSV parsing +│ ├── llm.py # Multi-provider LLM routing +│ ├── rag.py # RAG pipeline +│ └── code_export.py # ZIP code generation +├── supabase_schema.sql # Database schema (run once) +├── requirements.txt +├── Dockerfile +└── docker-compose.yml + +contexta_fe/ +├── src/ +│ ├── main.tsx # Entry point +│ ├── App.tsx # Router + routes +│ ├── index.css # Tailwind + global styles +│ ├── types/index.ts # TypeScript interfaces +│ ├── services/api.ts # API client (axios) +│ ├── store/authStore.ts # Zustand auth state +│ ├── lib/utils.ts # Helpers + constants +│ ├── components/ +│ │ ├── ui.tsx # Reusable UI (Button, Input, Card...) +│ │ ├── Layout.tsx # App shell with sidebar +│ │ └── ChatInterface.tsx # Chat UI with sources +│ └── pages/ +│ ├── LandingPage.tsx +│ ├── AuthPages.tsx # Login + Signup +│ ├── DashboardPage.tsx +│ ├── ChatbotBuilderPage.tsx # Builder + docs + preview +│ ├── MarketplacePage.tsx +│ ├── PricingPage.tsx +│ └── SettingsPage.tsx +├── package.json +├── vite.config.ts +└── tailwind.config.js +``` + +--- + +## Troubleshooting + +### "CORS error" in browser +→ Check `ALLOWED_ORIGINS` in backend `.env` includes your frontend URL + +### "Could not connect to Qdrant" +→ Check `QDRANT_URL` is correct and the cluster is running +→ For local Docker: ensure the qdrant service is started + +### "Invalid or expired token" on API calls +→ The Supabase JWT has expired. Log out and log back in. +→ Check `SUPABASE_SERVICE_ROLE_KEY` is correct + +### Document processing stuck on "processing" +→ Check OpenAI API key is valid +→ Check Qdrant connection +→ Check backend logs: `docker logs contexta-api` or terminal output + +### "Upgrade to publish" even after Stripe payment +→ Ensure Stripe webhook is configured and received the `checkout.session.completed` event +→ Check backend logs for webhook processing +→ For local testing, use `stripe listen --forward-to localhost:8000/api/v1/billing/webhook` + +### Chat returns "I don't have information about that" +→ Document processing may have failed — check document status in builder +→ The uploaded content may not be relevant to the question +→ Try re-uploading the document + +--- + +## Development Tips + +- **Backend hot reload**: `uvicorn app.main:app --reload` watches for file changes +- **Frontend hot reload**: `npm run dev` automatically reloads +- **API testing**: Use http://localhost:8000/docs (Swagger UI) +- **Database inspection**: Supabase dashboard → Table Editor +- **Vector inspection**: Qdrant dashboard at http://localhost:6333/dashboard (local only) + +--- + +## License + +MIT — build freely, use commercially. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..52c93e5 --- /dev/null +++ b/app/config.py @@ -0,0 +1,127 @@ +from pydantic_settings import BaseSettings +from typing import Optional, List + + +class Settings(BaseSettings): + # App + app_env: str = "development" + app_secret_key: str = "dev-secret-key" + allowed_origins: str = "http://localhost:5173,http://localhost:3000" + + # Supabase + supabase_url: str = "" + supabase_anon_key: str = "" + supabase_service_role_key: str = "" + + # Qdrant + qdrant_url: str = "http://localhost:6333" + qdrant_api_key: Optional[str] = None + + # LLM Providers + openai_api_key: Optional[str] = None + anthropic_api_key: Optional[str] = None + google_api_key: Optional[str] = None + fireworks_api_key: Optional[str] = None + + # Embeddings + embedding_model: str = "text-embedding-3-small" + + # Stripe + stripe_secret_key: str = "" + stripe_webhook_secret: str = "" + stripe_starter_price_id: str = "" + stripe_pro_price_id: str = "" + + # Redis + redis_url: str = "redis://localhost:6379" + + # Sentry + sentry_dsn: Optional[str] = None + + # Files + max_file_size_mb: int = 50 + + @property + def allowed_origins_list(self) -> List[str]: + return [o.strip() for o in self.allowed_origins.split(",")] + + class Config: + env_file = ".env" + case_sensitive = False + + +settings = Settings() + +# Plan limits +PLAN_LIMITS = { + "free": { + "max_chatbots": 999999, # unlimited creation + "max_published": 0, # cannot publish + "models": [], + "conversations_limit": 999999, # unlimited preview + "code_export": False, + "features": ["preview_mode", "testing"], + }, + "starter": { + "max_chatbots": 999999, + "max_published": 1, + "models": [ + "accounts/fireworks/models/llama-v3p1-70b-instruct", + "accounts/fireworks/models/mixtral-8x7b-instruct", + "accounts/fireworks/models/qwen2p5-72b-instruct", + ], + "conversations_limit": 5000, + "code_export": False, + "features": ["marketplace", "analytics", "branding"], + }, + "pro": { + "max_chatbots": 3, + "max_published": 3, + "models": [ + "accounts/fireworks/models/llama-v3p1-70b-instruct", + "accounts/fireworks/models/mixtral-8x7b-instruct", + "gpt-4o", + "gpt-4-turbo", + "gpt-3.5-turbo", + "claude-3-5-sonnet-20241022", + "claude-3-opus-20240229", + "gemini-1.5-pro", + ], + "conversations_limit": 20000, + "code_export": True, + "features": [ + "marketplace", + "code_export", + "advanced_analytics", + "priority_support", + "custom_domain", + "ab_testing", + ], + }, + "enterprise": { + "max_chatbots": 999999, + "max_published": 999999, + "models": ["*"], + "conversations_limit": 999999, + "code_export": True, + "features": ["*"], + }, +} + +MODEL_PROVIDERS = { + "accounts/fireworks/models/llama-v3p1-70b-instruct": "fireworks", + "accounts/fireworks/models/mixtral-8x7b-instruct": "fireworks", + "accounts/fireworks/models/qwen2p5-72b-instruct": "fireworks", + "gpt-4o": "openai", + "gpt-4-turbo": "openai", + "gpt-3.5-turbo": "openai", + "claude-3-5-sonnet-20241022": "anthropic", + "claude-3-opus-20240229": "anthropic", + "gemini-1.5-pro": "google", +} + +DEFAULT_MODELS = { + "starter": "accounts/fireworks/models/llama-v3p1-70b-instruct", + "pro": "gpt-4o", + "enterprise": "gpt-4o", +} diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..ee5a058 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,82 @@ +from fastapi import Depends, HTTPException, status, Header +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from typing import Optional +from app.database import get_supabase +from app.config import settings +import logging + +logger = logging.getLogger(__name__) +security = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), +): + """Extract and verify the current user from Supabase JWT""" + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + + token = credentials.credentials + supabase = get_supabase() + + try: + response = supabase.auth.get_user(token) + if not response or not response.user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + ) + return response.user + except Exception as e: + logger.error(f"Auth error: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + ) + + +async def get_optional_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), +): + """Optional auth - returns None if not authenticated""" + if not credentials: + return None + try: + return await get_current_user(credentials) + except HTTPException: + return None + + +async def get_user_subscription(user=Depends(get_current_user)): + """Get user's subscription plan""" + supabase = get_supabase() + try: + result = ( + supabase.table("subscriptions") + .select("*") + .eq("user_id", user.id) + .eq("status", "active") + .execute() + ) + if result.data: + return result.data[0] + return {"plan": "free", "status": "active", "user_id": user.id} + except Exception: + return {"plan": "free", "status": "active", "user_id": user.id} + + +async def require_plan(min_plan: str, user=Depends(get_current_user)): + """Require a minimum plan level""" + plan_order = ["free", "starter", "pro", "enterprise"] + subscription = await get_user_subscription(user) + user_plan = subscription.get("plan", "free") + + if plan_order.index(user_plan) < plan_order.index(min_plan): + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail=f"This feature requires {min_plan} plan or higher", + ) + return user diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..0a5e928 --- /dev/null +++ b/app/main.py @@ -0,0 +1,74 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +import logging + +from app.config import settings +from app.routers import auth, chatbots, documents, chat, marketplace, billing + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + +# ── App ──────────────────────────────────────────────────────────────────────── +app = FastAPI( + title="Contexta API", + description="AI Chatbot Platform - Create, deploy and share custom AI chatbots powered by your data", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", +) + +# ── Middleware ───────────────────────────────────────────────────────────────── +app.add_middleware( + CORSMiddleware, + allow_origins=settings.allowed_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ── Routers ──────────────────────────────────────────────────────────────────── +app.include_router(auth.router, prefix="/api/v1") +app.include_router(chatbots.router, prefix="/api/v1") +app.include_router(documents.router, prefix="/api/v1") +app.include_router(chat.router, prefix="/api/v1") +app.include_router(marketplace.router, prefix="/api/v1") +app.include_router(billing.router, prefix="/api/v1") + +# ── Health & Info ────────────────────────────────────────────────────────────── +@app.get("/") +async def root(): + return { + "name": "Contexta API", + "version": "1.0.0", + "status": "running", + "docs": "/docs", + } + + +@app.get("/health") +async def health(): + return {"status": "healthy", "environment": settings.app_env} + + +# ── Sentry ──────────────────────────────────────────────────────────────────── +if settings.sentry_dsn: + import sentry_sdk + sentry_sdk.init(dsn=settings.sentry_dsn, traces_sample_rate=0.1) + logger.info("Sentry initialized") + +# ── Startup ─────────────────────────────────────────────────────────────────── +@app.on_event("startup") +async def startup_event(): + logger.info("Contexta API starting up...") + logger.info(f"Environment: {settings.app_env}") + logger.info(f"Allowed origins: {settings.allowed_origins_list}") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..9a1ffc5 --- /dev/null +++ b/app/models.py @@ -0,0 +1,306 @@ +from pydantic import BaseModel, EmailStr, Field +from typing import Optional, List, Dict, Any +from datetime import datetime +from enum import Enum +import uuid + + +# ─── Enums ──────────────────────────────────────────────────────────────────── + +class PlanType(str, Enum): + free = "free" + starter = "starter" + pro = "pro" + enterprise = "enterprise" + + +class SubscriptionStatus(str, Enum): + active = "active" + canceled = "canceled" + past_due = "past_due" + unpaid = "unpaid" + trialing = "trialing" + + +class ChatbotVisibility(str, Enum): + preview = "preview" + published = "published" + + +class DocumentStatus(str, Enum): + pending = "pending" + processing = "processing" + completed = "completed" + failed = "failed" + + +class MessageRole(str, Enum): + user = "user" + assistant = "assistant" + system = "system" + + +# ─── Auth Models ────────────────────────────────────────────────────────────── + +class UserSignup(BaseModel): + email: EmailStr + password: str = Field(min_length=8) + company_name: str = Field(min_length=2, max_length=100) + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class UserResponse(BaseModel): + id: str + email: str + company_name: Optional[str] = None + plan: str = "free" + created_at: Optional[datetime] = None + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserResponse + + +# ─── Company Models ──────────────────────────────────────────────────────────── + +class CompanyCreate(BaseModel): + name: str = Field(min_length=2, max_length=100) + website: Optional[str] = None + industry: Optional[str] = None + + +class CompanyUpdate(BaseModel): + name: Optional[str] = None + website: Optional[str] = None + industry: Optional[str] = None + logo_url: Optional[str] = None + + +class CompanyResponse(BaseModel): + id: str + owner_id: str + name: str + website: Optional[str] = None + industry: Optional[str] = None + logo_url: Optional[str] = None + created_at: Optional[datetime] = None + + +# ─── Chatbot Models ──────────────────────────────────────────────────────────── + +class ChatbotCreate(BaseModel): + name: str = Field(min_length=2, max_length=100) + description: Optional[str] = None + system_prompt: Optional[str] = None + model: str = "accounts/fireworks/models/llama-v3p1-70b-instruct" + temperature: float = Field(default=0.7, ge=0.0, le=2.0) + max_tokens: int = Field(default=1000, ge=100, le=8000) + primary_color: str = "#6366f1" + welcome_message: str = "Hello! How can I help you today?" + category: Optional[str] = None + industry: Optional[str] = None + languages: List[str] = ["en"] + + +class ChatbotUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + system_prompt: Optional[str] = None + model: Optional[str] = None + temperature: Optional[float] = None + max_tokens: Optional[int] = None + primary_color: Optional[str] = None + welcome_message: Optional[str] = None + category: Optional[str] = None + industry: Optional[str] = None + languages: Optional[List[str]] = None + + +class ChatbotResponse(BaseModel): + id: str + company_id: str + name: str + description: Optional[str] = None + system_prompt: Optional[str] = None + model: str + temperature: float + max_tokens: int + primary_color: str + welcome_message: str + category: Optional[str] = None + industry: Optional[str] = None + languages: List[str] + visibility: str + is_published: bool + qdrant_collection_name: Optional[str] = None + document_count: int = 0 + conversation_count: int = 0 + average_rating: Optional[float] = None + created_at: Optional[datetime] = None + published_at: Optional[datetime] = None + + +class ChatbotPublicResponse(BaseModel): + """For marketplace display""" + id: str + name: str + description: Optional[str] = None + category: Optional[str] = None + industry: Optional[str] = None + languages: List[str] + primary_color: str + welcome_message: str + average_rating: Optional[float] = None + total_conversations: int = 0 + company_name: Optional[str] = None + company_logo: Optional[str] = None + created_at: Optional[datetime] = None + published_at: Optional[datetime] = None + + +# ─── Document Models ─────────────────────────────────────────────────────────── + +class DocumentResponse(BaseModel): + id: str + chatbot_id: str + file_name: str + file_type: str + file_size: int + chunk_count: int + status: str + error_message: Optional[str] = None + created_at: Optional[datetime] = None + + +# ─── Chat Models ─────────────────────────────────────────────────────────────── + +class ChatMessage(BaseModel): + message: str = Field(min_length=1, max_length=4000) + session_id: Optional[str] = None + language: str = "en" + + +class SourceDocument(BaseModel): + document_name: str + chunk_text: str + score: float + page_number: Optional[int] = None + + +class ChatResponse(BaseModel): + response: str + session_id: str + sources: List[SourceDocument] = [] + model_used: str + tokens_used: int = 0 + + +class MessageResponse(BaseModel): + id: str + role: str + content: str + sources: Optional[List[Dict]] = None + created_at: Optional[datetime] = None + + +class ConversationResponse(BaseModel): + id: str + chatbot_id: str + session_id: Optional[str] = None + language: str + message_count: int + created_at: Optional[datetime] = None + messages: List[MessageResponse] = [] + + +# ─── Subscription Models ─────────────────────────────────────────────────────── + +class SubscriptionResponse(BaseModel): + id: str + user_id: str + plan: str + status: str + stripe_customer_id: Optional[str] = None + current_period_start: Optional[datetime] = None + current_period_end: Optional[datetime] = None + chatbots_published: int = 0 + conversations_used: int = 0 + created_at: Optional[datetime] = None + + +class CheckoutSessionCreate(BaseModel): + plan: str # starter or pro + success_url: str + cancel_url: str + + +class CheckoutSessionResponse(BaseModel): + checkout_url: str + session_id: str + + +# ─── Analytics Models ────────────────────────────────────────────────────────── + +class ChatbotAnalytics(BaseModel): + chatbot_id: str + total_conversations: int = 0 + unique_users: int = 0 + average_conversation_length: float = 0.0 + total_messages: int = 0 + average_rating: float = 0.0 + top_queries: List[str] = [] + conversations_last_7_days: List[Dict] = [] + conversations_last_30_days: int = 0 + + +# ─── Marketplace Models ──────────────────────────────────────────────────────── + +class MarketplaceFilter(BaseModel): + category: Optional[str] = None + industry: Optional[str] = None + language: Optional[str] = None + search: Optional[str] = None + page: int = 1 + limit: int = 20 + + +class MarketplaceResponse(BaseModel): + chatbots: List[ChatbotPublicResponse] + total: int + page: int + limit: int + has_more: bool + + +class RatingCreate(BaseModel): + rating: int = Field(ge=1, le=5) + feedback: Optional[str] = None + + +# ─── Code Export Models ──────────────────────────────────────────────────────── + +class CodeExportRequest(BaseModel): + chatbot_id: str + include_frontend: bool = True + + +# ─── Generic Response Models ─────────────────────────────────────────────────── + +class MessageDetail(BaseModel): + detail: str + + +class SuccessResponse(BaseModel): + success: bool + message: str + + +class ErrorResponse(BaseModel): + error: str + detail: Optional[str] = None diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..4d5cda4 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,133 @@ +from fastapi import APIRouter, HTTPException, status, Depends +from app.models import UserSignup, UserLogin, UserResponse, TokenResponse +from app.database import get_supabase +from app.dependencies import get_current_user +import logging + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/auth", tags=["Authentication"]) + + +@router.post("/signup", response_model=TokenResponse) +async def signup(data: UserSignup): + supabase = get_supabase() + try: + # Create auth user + auth_resp = supabase.auth.sign_up( + {"email": data.email, "password": data.password} + ) + if not auth_resp.user: + raise HTTPException(status_code=400, detail="Failed to create account") + + user = auth_resp.user + + # Create company record + supabase.table("companies").insert( + { + "owner_id": user.id, + "name": data.company_name, + } + ).execute() + + # Create free subscription + supabase.table("subscriptions").insert( + { + "user_id": user.id, + "plan": "free", + "status": "active", + } + ).execute() + + token = auth_resp.session.access_token if auth_resp.session else "" + return TokenResponse( + access_token=token, + user=UserResponse( + id=user.id, + email=user.email, + company_name=data.company_name, + plan="free", + ), + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Signup error: {e}") + if "already registered" in str(e).lower() or "already exists" in str(e).lower(): + raise HTTPException(status_code=400, detail="Email already registered") + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/login", response_model=TokenResponse) +async def login(data: UserLogin): + supabase = get_supabase() + try: + auth_resp = supabase.auth.sign_in_with_password( + {"email": data.email, "password": data.password} + ) + if not auth_resp.user or not auth_resp.session: + raise HTTPException(status_code=401, detail="Invalid credentials") + + user = auth_resp.user + + # Get company info + company = supabase.table("companies").select("name").eq("owner_id", user.id).execute() + company_name = company.data[0]["name"] if company.data else "" + + # Get subscription + sub = ( + supabase.table("subscriptions") + .select("plan") + .eq("user_id", user.id) + .eq("status", "active") + .execute() + ) + plan = sub.data[0]["plan"] if sub.data else "free" + + return TokenResponse( + access_token=auth_resp.session.access_token, + user=UserResponse( + id=user.id, + email=user.email, + company_name=company_name, + plan=plan, + ), + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Login error: {e}") + raise HTTPException(status_code=401, detail="Invalid credentials") + + +@router.post("/logout") +async def logout(user=Depends(get_current_user)): + supabase = get_supabase() + try: + supabase.auth.sign_out() + except Exception: + pass + return {"message": "Logged out successfully"} + + +@router.get("/me", response_model=UserResponse) +async def get_me(user=Depends(get_current_user)): + supabase = get_supabase() + + company = supabase.table("companies").select("name").eq("owner_id", user.id).execute() + company_name = company.data[0]["name"] if company.data else "" + + sub = ( + supabase.table("subscriptions") + .select("plan") + .eq("user_id", user.id) + .eq("status", "active") + .execute() + ) + plan = sub.data[0]["plan"] if sub.data else "free" + + return UserResponse( + id=user.id, + email=user.email, + company_name=company_name, + plan=plan, + ) diff --git a/app/routers/billing.py b/app/routers/billing.py new file mode 100644 index 0000000..3b7ef08 --- /dev/null +++ b/app/routers/billing.py @@ -0,0 +1,187 @@ +from fastapi import APIRouter, HTTPException, Depends, Request, Header +from app.models import CheckoutSessionCreate, CheckoutSessionResponse, SubscriptionResponse +from app.database import get_supabase +from app.dependencies import get_current_user +from app.config import settings +from typing import Optional +import logging + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/billing", tags=["Billing"]) + +PLAN_PRICE_IDS = { + "starter": settings.stripe_starter_price_id, + "pro": settings.stripe_pro_price_id, +} + + +@router.post("/checkout", response_model=CheckoutSessionResponse) +async def create_checkout_session(data: CheckoutSessionCreate, user=Depends(get_current_user)): + try: + import stripe + stripe.api_key = settings.stripe_secret_key + + if data.plan not in PLAN_PRICE_IDS: + raise HTTPException(status_code=400, detail=f"Invalid plan: {data.plan}") + + price_id = PLAN_PRICE_IDS[data.plan] + if not price_id: + raise HTTPException(status_code=400, detail="Plan price not configured") + + supabase = get_supabase() + sub = supabase.table("subscriptions").select("stripe_customer_id").eq("user_id", user.id).execute() + + customer_id = None + if sub.data and sub.data[0].get("stripe_customer_id"): + customer_id = sub.data[0]["stripe_customer_id"] + else: + customer = stripe.Customer.create(email=user.email) + customer_id = customer.id + + session = stripe.checkout.Session.create( + customer=customer_id, + payment_method_types=["card"], + line_items=[{"price": price_id, "quantity": 1}], + mode="subscription", + success_url=data.success_url + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url=data.cancel_url, + metadata={"user_id": user.id, "plan": data.plan}, + ) + + return CheckoutSessionResponse(checkout_url=session.url, session_id=session.id) + + except ImportError: + raise HTTPException(status_code=500, detail="Stripe not configured") + except HTTPException: + raise + except Exception as e: + logger.error(f"Checkout error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/webhook") +async def stripe_webhook( + request: Request, + stripe_signature: Optional[str] = Header(None), +): + try: + import stripe + + stripe.api_key = settings.stripe_secret_key + payload = await request.body() + + if settings.stripe_webhook_secret and stripe_signature: + try: + event = stripe.Webhook.construct_event( + payload, stripe_signature, settings.stripe_webhook_secret + ) + except stripe.error.SignatureVerificationError: + raise HTTPException(status_code=400, detail="Invalid signature") + else: + import json + event = json.loads(payload) + + supabase = get_supabase() + event_type = event.get("type", "") + + if event_type == "checkout.session.completed": + session = event["data"]["object"] + user_id = session.get("metadata", {}).get("user_id") + plan = session.get("metadata", {}).get("plan", "starter") + customer_id = session.get("customer") + subscription_id = session.get("subscription") + + if user_id: + supabase.table("subscriptions").upsert({ + "user_id": user_id, + "plan": plan, + "status": "active", + "stripe_customer_id": customer_id, + "stripe_subscription_id": subscription_id, + }, on_conflict="user_id").execute() + + elif event_type in ("customer.subscription.updated", "customer.subscription.deleted"): + sub_obj = event["data"]["object"] + customer_id = sub_obj.get("customer") + status = sub_obj.get("status", "canceled") + + existing = supabase.table("subscriptions").select("*").eq("stripe_customer_id", customer_id).execute() + if existing.data: + mapped_status = "active" if status in ("active", "trialing") else "canceled" + supabase.table("subscriptions").update({ + "status": mapped_status, + }).eq("stripe_customer_id", customer_id).execute() + + # Unpublish chatbots if subscription canceled + if mapped_status == "canceled": + user_id = existing.data[0]["user_id"] + company = supabase.table("companies").select("id").eq("owner_id", user_id).execute() + if company.data: + supabase.table("chatbots").update({ + "is_published": False, + "visibility": "preview", + }).eq("company_id", company.data[0]["id"]).execute() + + return {"received": True} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Webhook error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/subscription", response_model=SubscriptionResponse) +async def get_subscription(user=Depends(get_current_user)): + supabase = get_supabase() + result = supabase.table("subscriptions").select("*").eq("user_id", user.id).execute() + + if not result.data: + return SubscriptionResponse( + id="free", + user_id=user.id, + plan="free", + status="active", + ) + + sub = result.data[0] + return SubscriptionResponse( + id=sub["id"], + user_id=sub["user_id"], + plan=sub["plan"], + status=sub["status"], + stripe_customer_id=sub.get("stripe_customer_id"), + current_period_start=sub.get("current_period_start"), + current_period_end=sub.get("current_period_end"), + chatbots_published=sub.get("chatbots_published", 0), + conversations_used=sub.get("conversations_used", 0), + created_at=sub.get("created_at"), + ) + + +@router.post("/portal") +async def customer_portal(request: Request, user=Depends(get_current_user)): + """Create Stripe customer portal session""" + try: + import stripe + stripe.api_key = settings.stripe_secret_key + + supabase = get_supabase() + sub = supabase.table("subscriptions").select("stripe_customer_id").eq("user_id", user.id).execute() + + if not sub.data or not sub.data[0].get("stripe_customer_id"): + raise HTTPException(status_code=404, detail="No subscription found") + + body = await request.json() + return_url = body.get("return_url", "http://localhost:5173/settings") + + session = stripe.billing_portal.Session.create( + customer=sub.data[0]["stripe_customer_id"], + return_url=return_url, + ) + return {"url": session.url} + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/routers/chat.py b/app/routers/chat.py new file mode 100644 index 0000000..fc331ec --- /dev/null +++ b/app/routers/chat.py @@ -0,0 +1,212 @@ +from fastapi import APIRouter, HTTPException, Depends +from app.models import ChatMessage, ChatResponse, ConversationResponse, MessageResponse +from app.database import get_supabase +from app.dependencies import get_current_user, get_optional_user +from app.services.rag import rag_engine +from typing import List, Optional +import uuid +import logging + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["Chat"]) + + +def _get_public_chatbot(chatbot_id: str, supabase) -> dict: + """Get a published chatbot (or any chatbot for preview)""" + result = supabase.table("chatbots").select("*, companies(name, logo_url)").eq("id", chatbot_id).execute() + if not result.data: + raise HTTPException(status_code=404, detail="Chatbot not found") + return result.data[0] + + +@router.post("/chat/{chatbot_id}", response_model=ChatResponse) +async def chat( + chatbot_id: str, + message: ChatMessage, + user=Depends(get_optional_user), +): + supabase = get_supabase() + chatbot = _get_public_chatbot(chatbot_id, supabase) + + # Allow preview access for owner, require published for public + if not chatbot.get("is_published"): + if not user: + raise HTTPException(status_code=403, detail="This chatbot is in preview mode") + # Check ownership + company = supabase.table("companies").select("id").eq("owner_id", user.id).execute() + if not company.data or company.data[0]["id"] != chatbot.get("company_id"): + raise HTTPException(status_code=403, detail="This chatbot is in preview mode") + + collection_name = chatbot.get("qdrant_collection_name") + if not collection_name: + raise HTTPException(status_code=400, detail="Chatbot has no knowledge base configured") + + # Get or create conversation + session_id = message.session_id or str(uuid.uuid4()) + conversation = _get_or_create_conversation( + chatbot_id=chatbot_id, + session_id=session_id, + user_id=user.id if user else None, + language=message.language, + supabase=supabase, + ) + + # Get conversation history + history = _get_conversation_history(conversation["id"], supabase) + + # Get company info for context + company_data = chatbot.get("companies", {}) or {} + chatbot_config = { + **chatbot, + "company_name": company_data.get("name", ""), + } + + # Run RAG + result = await rag_engine.process_query( + query=message.message, + collection_name=collection_name, + chatbot_config=chatbot_config, + conversation_history=history, + language=message.language, + ) + + # Save messages + _save_message(conversation["id"], "user", message.message, supabase) + _save_message( + conversation["id"], + "assistant", + result["response"], + supabase, + sources=[s.model_dump() for s in result.get("sources", [])], + model=result.get("model", ""), + ) + + # Update conversation message count + supabase.table("conversations").update({ + "message_count": len(history) + 2 + }).eq("id", conversation["id"]).execute() + + return ChatResponse( + response=result["response"], + session_id=session_id, + sources=result.get("sources", []), + model_used=result.get("model", ""), + tokens_used=result.get("tokens_used", 0), + ) + + +@router.get("/chat/{chatbot_id}/history/{session_id}", response_model=List[MessageResponse]) +async def get_chat_history( + chatbot_id: str, + session_id: str, + user=Depends(get_optional_user), +): + supabase = get_supabase() + + conversation = supabase.table("conversations").select("*") \ + .eq("chatbot_id", chatbot_id) \ + .eq("session_id", session_id) \ + .execute() + + if not conversation.data: + return [] + + conv_id = conversation.data[0]["id"] + messages = supabase.table("messages").select("*") \ + .eq("conversation_id", conv_id) \ + .order("created_at", asc=True) \ + .execute() + + return [ + MessageResponse( + id=m["id"], + role=m["role"], + content=m["content"], + sources=m.get("sources"), + created_at=m.get("created_at"), + ) + for m in (messages.data or []) + ] + + +# ── Analytics endpoint ──────────────────────────────────────────────────────── + +@router.get("/analytics/{chatbot_id}") +async def get_analytics(chatbot_id: str, user=Depends(get_current_user)): + supabase = get_supabase() + + # Verify ownership + company = supabase.table("companies").select("id").eq("owner_id", user.id).execute() + if not company.data: + raise HTTPException(status_code=404, detail="Company not found") + + chatbot = supabase.table("chatbots").select("id").eq("id", chatbot_id).eq("company_id", company.data[0]["id"]).execute() + if not chatbot.data: + raise HTTPException(status_code=404, detail="Chatbot not found") + + total_convs = supabase.table("conversations").select("id", count="exact").eq("chatbot_id", chatbot_id).execute() + total_msgs = supabase.table("messages").select("id", count="exact").execute() + + return { + "chatbot_id": chatbot_id, + "total_conversations": total_convs.count or 0, + "total_messages": total_msgs.count or 0, + "average_rating": 0.0, + "conversations_last_30_days": total_convs.count or 0, + } + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _get_or_create_conversation( + chatbot_id: str, + session_id: str, + user_id: Optional[str], + language: str, + supabase, +) -> dict: + existing = supabase.table("conversations").select("*") \ + .eq("chatbot_id", chatbot_id) \ + .eq("session_id", session_id) \ + .execute() + + if existing.data: + return existing.data[0] + + new_conv = { + "id": str(uuid.uuid4()), + "chatbot_id": chatbot_id, + "user_id": user_id, + "session_id": session_id, + "language": language, + "message_count": 0, + } + result = supabase.table("conversations").insert(new_conv).execute() + return result.data[0] + + +def _get_conversation_history(conversation_id: str, supabase) -> List[dict]: + messages = supabase.table("messages").select("role, content") \ + .eq("conversation_id", conversation_id) \ + .order("created_at", asc=True) \ + .limit(20) \ + .execute() + return messages.data or [] + + +def _save_message( + conversation_id: str, + role: str, + content: str, + supabase, + sources: Optional[list] = None, + model: str = "", +): + supabase.table("messages").insert({ + "id": str(uuid.uuid4()), + "conversation_id": conversation_id, + "role": role, + "content": content, + "sources": sources, + "model": model, + }).execute() diff --git a/app/routers/chatbots.py b/app/routers/chatbots.py new file mode 100644 index 0000000..f9cf4e4 --- /dev/null +++ b/app/routers/chatbots.py @@ -0,0 +1,240 @@ +from fastapi import APIRouter, HTTPException, Depends, status +from app.models import ( + ChatbotCreate, ChatbotUpdate, ChatbotResponse, SuccessResponse +) +from app.database import get_supabase +from app.dependencies import get_current_user, get_user_subscription +from app.services.vector_store import vector_store +from app.config import PLAN_LIMITS +from typing import List +import uuid +import logging + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/chatbots", tags=["Chatbots"]) + + +def _get_user_company(user_id: str, supabase) -> dict: + result = supabase.table("companies").select("*").eq("owner_id", user_id).execute() + if not result.data: + raise HTTPException(status_code=404, detail="Company not found") + return result.data[0] + + +async def _check_plan_limits(user_id: str, supabase, action: str = "create"): + sub = supabase.table("subscriptions").select("*").eq("user_id", user_id).eq("status", "active").execute() + plan = sub.data[0]["plan"] if sub.data else "free" + limits = PLAN_LIMITS[plan] + + if action == "publish": + published = supabase.table("chatbots").select("id", count="exact") \ + .eq("company_id", _get_user_company(user_id, supabase)["id"]) \ + .eq("is_published", True).execute() + count = published.count or 0 + max_pub = limits["max_published"] + if max_pub == 0: + raise HTTPException(status_code=402, detail="Upgrade to publish chatbots to marketplace") + if count >= max_pub: + raise HTTPException( + status_code=402, + detail=f"Publish limit reached ({max_pub}). Upgrade to publish more chatbots." + ) + return plan + + +@router.post("", response_model=ChatbotResponse, status_code=201) +async def create_chatbot(data: ChatbotCreate, user=Depends(get_current_user)): + supabase = get_supabase() + company = _get_user_company(user.id, supabase) + + # Create Qdrant collection + collection_name = f"company_{company['id']}_{uuid.uuid4().hex[:8]}" + try: + vector_store.create_collection(collection_name) + except Exception as e: + logger.error(f"Failed to create Qdrant collection: {e}") + # Continue without vector store for now + collection_name = None + + chatbot_data = { + "id": str(uuid.uuid4()), + "company_id": company["id"], + "name": data.name, + "description": data.description, + "system_prompt": data.system_prompt, + "model": data.model, + "temperature": data.temperature, + "max_tokens": data.max_tokens, + "primary_color": data.primary_color, + "welcome_message": data.welcome_message, + "category": data.category, + "industry": data.industry, + "languages": data.languages, + "visibility": "preview", + "is_published": False, + "qdrant_collection_name": collection_name, + } + + result = supabase.table("chatbots").insert(chatbot_data).execute() + if not result.data: + raise HTTPException(status_code=500, detail="Failed to create chatbot") + + return _format_chatbot(result.data[0], supabase) + + +@router.get("", response_model=List[ChatbotResponse]) +async def list_chatbots(user=Depends(get_current_user)): + supabase = get_supabase() + company = _get_user_company(user.id, supabase) + + result = supabase.table("chatbots").select("*") \ + .eq("company_id", company["id"]) \ + .order("created_at", desc=True) \ + .execute() + + return [_format_chatbot(c, supabase) for c in (result.data or [])] + + +@router.get("/{chatbot_id}", response_model=ChatbotResponse) +async def get_chatbot(chatbot_id: str, user=Depends(get_current_user)): + supabase = get_supabase() + company = _get_user_company(user.id, supabase) + chatbot = _get_owned_chatbot(chatbot_id, company["id"], supabase) + return _format_chatbot(chatbot, supabase) + + +@router.put("/{chatbot_id}", response_model=ChatbotResponse) +async def update_chatbot(chatbot_id: str, data: ChatbotUpdate, user=Depends(get_current_user)): + supabase = get_supabase() + company = _get_user_company(user.id, supabase) + _get_owned_chatbot(chatbot_id, company["id"], supabase) + + update_data = {k: v for k, v in data.model_dump().items() if v is not None} + if not update_data: + raise HTTPException(status_code=400, detail="No fields to update") + + result = supabase.table("chatbots").update(update_data).eq("id", chatbot_id).execute() + if not result.data: + raise HTTPException(status_code=500, detail="Update failed") + + return _format_chatbot(result.data[0], supabase) + + +@router.delete("/{chatbot_id}", response_model=SuccessResponse) +async def delete_chatbot(chatbot_id: str, user=Depends(get_current_user)): + supabase = get_supabase() + company = _get_user_company(user.id, supabase) + chatbot = _get_owned_chatbot(chatbot_id, company["id"], supabase) + + # Delete Qdrant collection + if chatbot.get("qdrant_collection_name"): + try: + vector_store.delete_collection(chatbot["qdrant_collection_name"]) + except Exception as e: + logger.warning(f"Failed to delete collection: {e}") + + supabase.table("chatbots").delete().eq("id", chatbot_id).execute() + return SuccessResponse(success=True, message="Chatbot deleted") + + +@router.post("/{chatbot_id}/publish", response_model=ChatbotResponse) +async def publish_chatbot(chatbot_id: str, user=Depends(get_current_user)): + supabase = get_supabase() + company = _get_user_company(user.id, supabase) + chatbot = _get_owned_chatbot(chatbot_id, company["id"], supabase) + + await _check_plan_limits(user.id, supabase, "publish") + + result = supabase.table("chatbots").update({ + "is_published": True, + "visibility": "published", + }).eq("id", chatbot_id).execute() + + return _format_chatbot(result.data[0], supabase) + + +@router.post("/{chatbot_id}/unpublish", response_model=ChatbotResponse) +async def unpublish_chatbot(chatbot_id: str, user=Depends(get_current_user)): + supabase = get_supabase() + company = _get_user_company(user.id, supabase) + _get_owned_chatbot(chatbot_id, company["id"], supabase) + + result = supabase.table("chatbots").update({ + "is_published": False, + "visibility": "preview", + }).eq("id", chatbot_id).execute() + + return _format_chatbot(result.data[0], supabase) + + +@router.post("/{chatbot_id}/export") +async def export_chatbot(chatbot_id: str, user=Depends(get_current_user)): + from fastapi.responses import StreamingResponse + from app.services.code_export import generate_export_package + from app.config import settings + + supabase = get_supabase() + company = _get_user_company(user.id, supabase) + chatbot = _get_owned_chatbot(chatbot_id, company["id"], supabase) + + # Check plan + sub = supabase.table("subscriptions").select("plan").eq("user_id", user.id).eq("status", "active").execute() + plan = sub.data[0]["plan"] if sub.data else "free" + if plan not in ("pro", "enterprise"): + raise HTTPException(status_code=402, detail="Code export requires Pro plan or higher") + + zip_bytes = generate_export_package( + chatbot=chatbot, + company=company, + qdrant_url=settings.qdrant_url, + qdrant_key=settings.qdrant_api_key or "", + ) + + filename = chatbot["name"].lower().replace(" ", "-") + "-chatbot.zip" + return StreamingResponse( + iter([zip_bytes]), + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _get_owned_chatbot(chatbot_id: str, company_id: str, supabase) -> dict: + result = supabase.table("chatbots").select("*").eq("id", chatbot_id).eq("company_id", company_id).execute() + if not result.data: + raise HTTPException(status_code=404, detail="Chatbot not found") + return result.data[0] + + +def _format_chatbot(chatbot: dict, supabase) -> ChatbotResponse: + doc_count = supabase.table("documents").select("id", count="exact") \ + .eq("chatbot_id", chatbot["id"]) \ + .eq("status", "completed") \ + .execute() + conv_count = supabase.table("conversations").select("id", count="exact") \ + .eq("chatbot_id", chatbot["id"]) \ + .execute() + + return ChatbotResponse( + id=chatbot["id"], + company_id=chatbot["company_id"], + name=chatbot["name"], + description=chatbot.get("description"), + system_prompt=chatbot.get("system_prompt"), + model=chatbot.get("model", "accounts/fireworks/models/llama-v3p1-70b-instruct"), + temperature=chatbot.get("temperature", 0.7), + max_tokens=chatbot.get("max_tokens", 1000), + primary_color=chatbot.get("primary_color", "#6366f1"), + welcome_message=chatbot.get("welcome_message", "Hello! How can I help?"), + category=chatbot.get("category"), + industry=chatbot.get("industry"), + languages=chatbot.get("languages", ["en"]), + visibility=chatbot.get("visibility", "preview"), + is_published=chatbot.get("is_published", False), + qdrant_collection_name=chatbot.get("qdrant_collection_name"), + document_count=doc_count.count or 0, + conversation_count=conv_count.count or 0, + created_at=chatbot.get("created_at"), + published_at=chatbot.get("published_at"), + ) diff --git a/app/routers/documents.py b/app/routers/documents.py new file mode 100644 index 0000000..4eaaf63 --- /dev/null +++ b/app/routers/documents.py @@ -0,0 +1,208 @@ +from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks +from app.models import DocumentResponse, SuccessResponse +from app.database import get_supabase +from app.dependencies import get_current_user +from app.services.document_processor import process_document +from app.services.embeddings import embedding_service +from app.services.vector_store import vector_store +from app.config import settings +from typing import List +import uuid +import logging + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/chatbots/{chatbot_id}/documents", tags=["Documents"]) + +ALLOWED_TYPES = { + "application/pdf": ".pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "text/csv": ".csv", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "text/plain": ".txt", + "text/markdown": ".md", +} + +ALLOWED_EXTENSIONS = {".pdf", ".docx", ".csv", ".xlsx", ".txt", ".md"} + + +def _get_user_chatbot(chatbot_id: str, user_id: str, supabase) -> dict: + company = supabase.table("companies").select("id").eq("owner_id", user_id).execute() + if not company.data: + raise HTTPException(status_code=404, detail="Company not found") + company_id = company.data[0]["id"] + + chatbot = supabase.table("chatbots").select("*").eq("id", chatbot_id).eq("company_id", company_id).execute() + if not chatbot.data: + raise HTTPException(status_code=404, detail="Chatbot not found") + return chatbot.data[0] + + +@router.post("", response_model=DocumentResponse, status_code=201) +async def upload_document( + chatbot_id: str, + background_tasks: BackgroundTasks, + file: UploadFile = File(...), + user=Depends(get_current_user), +): + supabase = get_supabase() + chatbot = _get_user_chatbot(chatbot_id, user.id, supabase) + + # Validate file + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + ext = "." + file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "" + if ext not in ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=f"File type not supported. Allowed: {', '.join(ALLOWED_EXTENSIONS)}" + ) + + # Read file + file_bytes = await file.read() + file_size = len(file_bytes) + max_size = settings.max_file_size_mb * 1024 * 1024 + + if file_size > max_size: + raise HTTPException( + status_code=413, + detail=f"File too large. Max size: {settings.max_file_size_mb}MB" + ) + + # Create document record + doc_id = str(uuid.uuid4()) + doc_data = { + "id": doc_id, + "chatbot_id": chatbot_id, + "file_name": file.filename, + "file_type": ext, + "file_size": file_size, + "chunk_count": 0, + "status": "processing", + } + + result = supabase.table("documents").insert(doc_data).execute() + if not result.data: + raise HTTPException(status_code=500, detail="Failed to create document record") + + # Process in background + background_tasks.add_task( + _process_document_bg, + file_bytes=file_bytes, + file_name=file.filename, + doc_id=doc_id, + chatbot=chatbot, + supabase=supabase, + ) + + return DocumentResponse(**result.data[0]) + + +async def _process_document_bg( + file_bytes: bytes, + file_name: str, + doc_id: str, + chatbot: dict, + supabase, +): + """Background task to process and embed a document""" + try: + company_id = chatbot.get("company_id", "") + collection_name = chatbot.get("qdrant_collection_name") + + if not collection_name: + logger.error(f"No Qdrant collection for chatbot {chatbot['id']}") + supabase.table("documents").update({ + "status": "failed", + "error_message": "Vector store not configured" + }).eq("id", doc_id).execute() + return + + # Ensure collection exists + if not vector_store.collection_exists(collection_name): + vector_store.create_collection(collection_name) + + # Process document + chunks, payloads = process_document( + file_bytes=file_bytes, + file_name=file_name, + document_id=doc_id, + company_id=company_id, + ) + + if not chunks: + raise ValueError("No text extracted from document") + + # Generate embeddings in batches + batch_size = 50 + all_ids = [] + all_vectors = [] + all_payloads = [] + + for i in range(0, len(chunks), batch_size): + batch_chunks = chunks[i:i + batch_size] + batch_payloads = payloads[i:i + batch_size] + + vectors = embedding_service.embed_batch(batch_chunks) + ids = [str(uuid.uuid4()) for _ in vectors] + + all_ids.extend(ids) + all_vectors.extend(vectors) + all_payloads.extend(batch_payloads) + + # Upsert to Qdrant + vector_store.upsert_vectors( + collection_name=collection_name, + vectors=all_vectors, + payloads=all_payloads, + ids=all_ids, + ) + + # Update document record + supabase.table("documents").update({ + "status": "completed", + "chunk_count": len(chunks), + }).eq("id", doc_id).execute() + + logger.info(f"Document {doc_id} processed: {len(chunks)} chunks") + + except Exception as e: + logger.error(f"Document processing error for {doc_id}: {e}") + supabase.table("documents").update({ + "status": "failed", + "error_message": str(e)[:500], + }).eq("id", doc_id).execute() + + +@router.get("", response_model=List[DocumentResponse]) +async def list_documents(chatbot_id: str, user=Depends(get_current_user)): + supabase = get_supabase() + _get_user_chatbot(chatbot_id, user.id, supabase) + + result = supabase.table("documents").select("*") \ + .eq("chatbot_id", chatbot_id) \ + .order("created_at", desc=True) \ + .execute() + + return [DocumentResponse(**d) for d in (result.data or [])] + + +@router.delete("/{document_id}", response_model=SuccessResponse) +async def delete_document(chatbot_id: str, document_id: str, user=Depends(get_current_user)): + supabase = get_supabase() + chatbot = _get_user_chatbot(chatbot_id, user.id, supabase) + + doc = supabase.table("documents").select("*").eq("id", document_id).eq("chatbot_id", chatbot_id).execute() + if not doc.data: + raise HTTPException(status_code=404, detail="Document not found") + + # Remove vectors from Qdrant + collection_name = chatbot.get("qdrant_collection_name") + if collection_name: + try: + vector_store.delete_by_document_id(collection_name, document_id) + except Exception as e: + logger.warning(f"Failed to delete vectors: {e}") + + supabase.table("documents").delete().eq("id", document_id).execute() + return SuccessResponse(success=True, message="Document deleted") diff --git a/app/routers/marketplace.py b/app/routers/marketplace.py new file mode 100644 index 0000000..09e0e34 --- /dev/null +++ b/app/routers/marketplace.py @@ -0,0 +1,133 @@ +from fastapi import APIRouter, HTTPException, Depends, Query +from app.models import ChatbotPublicResponse, MarketplaceResponse, RatingCreate +from app.database import get_supabase +from app.dependencies import get_optional_user, get_current_user +from typing import Optional +import logging + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/marketplace", tags=["Marketplace"]) + +CATEGORIES = [ + "Customer Support", "Sales", "FAQ", "E-commerce", + "Healthcare", "Finance", "Education", "HR", "Legal", "Other" +] + +INDUSTRIES = [ + "Technology", "E-commerce", "Healthcare", "Finance", + "Education", "Legal", "Real Estate", "Hospitality", "Retail", "Other" +] + + +@router.get("/chatbots", response_model=MarketplaceResponse) +async def list_marketplace_chatbots( + category: Optional[str] = Query(None), + industry: Optional[str] = Query(None), + language: Optional[str] = Query(None), + search: Optional[str] = Query(None), + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + user=Depends(get_optional_user), +): + supabase = get_supabase() + + query = supabase.table("chatbots").select( + "*, companies(name, logo_url)" + ).eq("is_published", True).eq("visibility", "published") + + if category: + query = query.eq("category", category) + if industry: + query = query.eq("industry", industry) + if search: + query = query.ilike("name", f"%{search}%") + + offset = (page - 1) * limit + result = query.order("created_at", desc=True).range(offset, offset + limit - 1).execute() + all_result = supabase.table("chatbots").select("id", count="exact").eq("is_published", True).execute() + total = all_result.count or 0 + + chatbots = [] + for c in (result.data or []): + company_data = c.get("companies") or {} + chatbots.append( + ChatbotPublicResponse( + id=c["id"], + name=c["name"], + description=c.get("description"), + category=c.get("category"), + industry=c.get("industry"), + languages=c.get("languages", ["en"]), + primary_color=c.get("primary_color", "#6366f1"), + welcome_message=c.get("welcome_message", "Hello!"), + average_rating=c.get("average_rating"), + total_conversations=c.get("total_conversations", 0), + company_name=company_data.get("name"), + company_logo=company_data.get("logo_url"), + created_at=c.get("created_at"), + published_at=c.get("published_at"), + ) + ) + + return MarketplaceResponse( + chatbots=chatbots, + total=total, + page=page, + limit=limit, + has_more=(offset + limit) < total, + ) + + +@router.get("/chatbots/{chatbot_id}", response_model=ChatbotPublicResponse) +async def get_marketplace_chatbot(chatbot_id: str): + supabase = get_supabase() + result = supabase.table("chatbots").select("*, companies(name, logo_url)") \ + .eq("id", chatbot_id) \ + .eq("is_published", True) \ + .execute() + + if not result.data: + raise HTTPException(status_code=404, detail="Chatbot not found in marketplace") + + c = result.data[0] + company_data = c.get("companies") or {} + return ChatbotPublicResponse( + id=c["id"], + name=c["name"], + description=c.get("description"), + category=c.get("category"), + industry=c.get("industry"), + languages=c.get("languages", ["en"]), + primary_color=c.get("primary_color", "#6366f1"), + welcome_message=c.get("welcome_message", "Hello!"), + average_rating=c.get("average_rating"), + total_conversations=c.get("total_conversations", 0), + company_name=company_data.get("name"), + company_logo=company_data.get("logo_url"), + created_at=c.get("created_at"), + published_at=c.get("published_at"), + ) + + +@router.get("/categories") +async def get_categories(): + return {"categories": CATEGORIES, "industries": INDUSTRIES} + + +@router.post("/chatbots/{chatbot_id}/rate") +async def rate_chatbot( + chatbot_id: str, + rating: RatingCreate, + user=Depends(get_current_user), +): + supabase = get_supabase() + chatbot = supabase.table("chatbots").select("id, average_rating").eq("id", chatbot_id).eq("is_published", True).execute() + if not chatbot.data: + raise HTTPException(status_code=404, detail="Chatbot not found") + + # Simple rating update (average) + current = chatbot.data[0].get("average_rating") or rating.rating + new_avg = (current + rating.rating) / 2 + + supabase.table("chatbots").update({"average_rating": round(new_avg, 1)}).eq("id", chatbot_id).execute() + return {"message": "Rating submitted", "new_average": round(new_avg, 1)} diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/code_export.py b/app/services/code_export.py new file mode 100644 index 0000000..a75abf2 --- /dev/null +++ b/app/services/code_export.py @@ -0,0 +1,713 @@ +import zipfile +import io +from typing import Dict, Any + + +def generate_export_package( + chatbot: Dict[str, Any], + company: Dict[str, Any], + qdrant_url: str, + qdrant_key: str, +) -> bytes: + """ + Generate a complete export ZIP with FastAPI backend + React widget + """ + buffer = io.BytesIO() + + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + # ── Backend files ────────────────────────────────────────── + zf.writestr("backend/requirements.txt", _requirements()) + zf.writestr("backend/.env.example", _env_example(chatbot, qdrant_url, qdrant_key)) + zf.writestr("backend/main.py", _main_py(chatbot)) + zf.writestr("backend/rag_engine.py", _rag_engine_py()) + zf.writestr("backend/Dockerfile", _dockerfile()) + zf.writestr("backend/docker-compose.yml", _docker_compose(chatbot)) + zf.writestr("backend/README.md", _backend_readme(chatbot)) + + # ── Frontend files ───────────────────────────────────────── + zf.writestr("frontend/src/ChatWidget.tsx", _chat_widget_tsx(chatbot)) + zf.writestr("frontend/src/useChat.ts", _use_chat_ts()) + zf.writestr("frontend/src/api.ts", _api_ts()) + zf.writestr("frontend/src/types.ts", _types_ts()) + zf.writestr("frontend/package.json", _package_json(chatbot)) + zf.writestr("frontend/tsconfig.json", _tsconfig()) + zf.writestr("frontend/vite.config.ts", _vite_config()) + zf.writestr("frontend/README.md", _frontend_readme(chatbot)) + + # ── Root ─────────────────────────────────────────────────── + zf.writestr("QUICK_START.md", _quick_start(chatbot)) + zf.writestr("setup.py", _setup_wizard(chatbot)) + + buffer.seek(0) + return buffer.read() + + +def _requirements(): + return """fastapi==0.115.0 +uvicorn[standard]==0.30.6 +python-dotenv==1.0.1 +pydantic==2.8.2 +qdrant-client==1.11.1 +openai==1.51.0 +anthropic==0.34.2 +google-generativeai==0.8.1 +httpx==0.27.2 +langdetect==1.0.9 +""" + + +def _env_example(chatbot: Dict, qdrant_url: str, qdrant_key: str): + name = chatbot.get("name", "My Chatbot").upper().replace(" ", "_") + return f"""# {name} - Environment Configuration +# Copy to .env and fill in your values + +# LLM Provider (choose one) +LLM_PROVIDER=openai +LLM_MODEL=gpt-4o +LLM_API_KEY=sk-your-openai-key + +# For Anthropic: sk-ant-your-key +# For Google: your-google-api-key +# For Fireworks: your-fireworks-key + +# Embeddings (required - OpenAI) +EMBEDDING_API_KEY=sk-your-openai-key +EMBEDDING_MODEL=text-embedding-3-small + +# Qdrant Vector Database +QDRANT_URL={qdrant_url} +QDRANT_API_KEY={qdrant_key} +QDRANT_COLLECTION={chatbot.get("qdrant_collection_name", "chatbot_collection")} + +# Server +PORT=8000 +HOST=0.0.0.0 +ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com +""" + + +def _main_py(chatbot: Dict): + return f'''""" +Auto-generated FastAPI backend for: {chatbot.get("name", "Chatbot")} +Generated by Contexta Platform +""" +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import List, Optional +import os +from dotenv import load_dotenv +from rag_engine import RAGEngine + +load_dotenv() + +app = FastAPI( + title="{chatbot.get("name", "Chatbot")} API", + version="1.0.0" +) + +app.add_middleware( + CORSMiddleware, + allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +rag = RAGEngine( + qdrant_url=os.getenv("QDRANT_URL"), + qdrant_api_key=os.getenv("QDRANT_API_KEY"), + collection_name=os.getenv("QDRANT_COLLECTION"), + llm_provider=os.getenv("LLM_PROVIDER", "openai"), + llm_model=os.getenv("LLM_MODEL", "gpt-4o"), + llm_api_key=os.getenv("LLM_API_KEY"), + embedding_api_key=os.getenv("EMBEDDING_API_KEY"), + embedding_model=os.getenv("EMBEDDING_MODEL", "text-embedding-3-small"), + system_prompt="""{chatbot.get("system_prompt") or "You are a helpful assistant."}""", +) + + +class ChatRequest(BaseModel): + message: str + session_id: Optional[str] = None + language: str = "en" + history: List[dict] = [] + + +class Source(BaseModel): + document_name: str + text: str + score: float + + +class ChatResponse(BaseModel): + response: str + session_id: str + sources: List[Source] + + +@app.post("/chat", response_model=ChatResponse) +async def chat(request: ChatRequest): + import uuid + session_id = request.session_id or str(uuid.uuid4()) + result = await rag.query( + query=request.message, + history=request.history, + language=request.language, + ) + return ChatResponse( + response=result["response"], + session_id=session_id, + sources=[Source(**s) for s in result.get("sources", [])], + ) + + +@app.get("/health") +def health(): + return {{"status": "healthy", "chatbot": "{chatbot.get("name", "Chatbot")}"}} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host=os.getenv("HOST", "0.0.0.0"), port=int(os.getenv("PORT", 8000))) +''' + + +def _rag_engine_py(): + return '''"""RAG Engine - Retrieval-Augmented Generation""" +from qdrant_client import QdrantClient +from qdrant_client.http.models import Distance, VectorParams +from openai import AsyncOpenAI +from typing import List, Dict, Any, Optional +import logging + +logger = logging.getLogger(__name__) + + +class RAGEngine: + def __init__(self, qdrant_url, qdrant_api_key, collection_name, + llm_provider, llm_model, llm_api_key, + embedding_api_key, embedding_model, system_prompt=""): + self.collection_name = collection_name + self.llm_provider = llm_provider + self.llm_model = llm_model + self.llm_api_key = llm_api_key + self.embedding_model = embedding_model + self.system_prompt = system_prompt + + # Qdrant + qdrant_kwargs = {"url": qdrant_url} + if qdrant_api_key: + qdrant_kwargs["api_key"] = qdrant_api_key + self.qdrant = QdrantClient(**qdrant_kwargs) + + # OpenAI for embeddings + self.embed_client = AsyncOpenAI(api_key=embedding_api_key) + + async def embed(self, text: str) -> List[float]: + resp = await self.embed_client.embeddings.create( + model=self.embedding_model, input=text + ) + return resp.data[0].embedding + + async def retrieve(self, query_vector: List[float], limit: int = 5) -> List[Dict]: + results = self.qdrant.search( + collection_name=self.collection_name, + query_vector=query_vector, + limit=limit, + score_threshold=0.3, + ) + return [{"text": r.payload.get("text", ""), "document_name": r.payload.get("file_name", ""), "score": r.score} + for r in results] + + async def generate(self, messages: List[Dict]) -> str: + if self.llm_provider == "openai": + from openai import AsyncOpenAI + client = AsyncOpenAI(api_key=self.llm_api_key) + resp = await client.chat.completions.create( + model=self.llm_model, messages=messages, max_tokens=1000 + ) + return resp.choices[0].message.content + elif self.llm_provider == "anthropic": + import anthropic + client = anthropic.AsyncAnthropic(api_key=self.llm_api_key) + system = next((m["content"] for m in messages if m["role"] == "system"), "") + conv = [m for m in messages if m["role"] != "system"] + resp = await client.messages.create( + model=self.llm_model, max_tokens=1000, system=system, messages=conv + ) + return resp.content[0].text + elif self.llm_provider == "fireworks": + import httpx + async with httpx.AsyncClient(timeout=60) as c: + r = await c.post( + "https://api.fireworks.ai/inference/v1/chat/completions", + headers={"Authorization": f"Bearer {self.llm_api_key}"}, + json={"model": self.llm_model, "messages": messages, "max_tokens": 1000}, + ) + r.raise_for_status() + return r.json()["choices"][0]["message"]["content"] + return "Error: unknown provider" + + async def query(self, query: str, history: List[Dict] = None, language: str = "en") -> Dict: + if history is None: + history = [] + + query_vec = await self.embed(query) + docs = await self.retrieve(query_vec) + context = "\\n\\n---\\n\\n".join(d["text"] for d in docs) or "No context found." + + system = f"{self.system_prompt}\\n\\nContext:\\n{context}" + messages = [{"role": "system", "content": system}] + for h in history[-10:]: + messages.append(h) + messages.append({"role": "user", "content": query}) + + response = await self.generate(messages) + return {"response": response, "sources": docs} +''' + + +def _dockerfile(): + return """FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +""" + + +def _docker_compose(chatbot: Dict): + name = chatbot.get("name", "chatbot").lower().replace(" ", "-") + return f"""version: '3.8' +services: + api: + build: . + ports: + - "8000:8000" + env_file: .env + restart: unless-stopped + container_name: {name}-api +""" + + +def _chat_widget_tsx(chatbot: Dict): + color = chatbot.get("primary_color", "#6366f1") + welcome = chatbot.get("welcome_message", "Hello! How can I help you today?") + name = chatbot.get("name", "Assistant") + return f'''import React, {{ useState, useRef, useEffect }} from "react"; +import {{ useChat }} from "./useChat"; + +const PRIMARY_COLOR = "{color}"; +const BOT_NAME = "{name}"; +const WELCOME_MESSAGE = "{welcome}"; + +export const ChatWidget: React.FC = () => {{ + const [isOpen, setIsOpen] = useState(false); + const {{ messages, isLoading, sendMessage }} = useChat(WELCOME_MESSAGE); + const bottomRef = useRef(null); + + useEffect(() => {{ + bottomRef.current?.scrollIntoView({{ behavior: "smooth" }}); + }}, [messages]); + + return ( + <> + {{isOpen && ( +
+
+ {{BOT_NAME}} + +
+
+ {{messages.map((msg, i) => ( +
+
{{msg.content}}
+
+ ))}} + {{isLoading &&
Thinking...
}} +
+
+
+ {{ + if (e.key === "Enter" && !e.shiftKey) {{ + e.preventDefault(); + const val = (e.target as HTMLInputElement).value.trim(); + if (val) {{ sendMessage(val); (e.target as HTMLInputElement).value = ""; }} + }} + }}}} + /> + +
+
+ )}} + + + ); +}}; +''' + + +def _use_chat_ts(): + return '''import { useState, useCallback } from "react"; +import { sendChatMessage } from "./api"; + +interface Message { + role: "user" | "assistant"; + content: string; +} + +export function useChat(welcomeMessage: string) { + const [messages, setMessages] = useState([ + { role: "assistant", content: welcomeMessage } + ]); + const [isLoading, setIsLoading] = useState(false); + const [sessionId] = useState(() => crypto.randomUUID()); + + const sendMessage = useCallback(async (content: string) => { + setMessages(prev => [...prev, { role: "user", content }]); + setIsLoading(true); + try { + const history = messages.map(m => ({ role: m.role, content: m.content })); + const result = await sendChatMessage({ message: content, session_id: sessionId, history }); + setMessages(prev => [...prev, { role: "assistant", content: result.response }]); + } catch { + setMessages(prev => [...prev, { role: "assistant", content: "Sorry, I encountered an error. Please try again." }]); + } finally { + setIsLoading(false); + } + }, [messages, sessionId]); + + return { messages, isLoading, sendMessage }; +} +''' + + +def _api_ts(): + return '''const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; + +export async function sendChatMessage(payload: { + message: string; + session_id: string; + history?: any[]; +}) { + const response = await fetch(`${API_URL}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error("Chat request failed"); + return response.json(); +} +''' + + +def _types_ts(): + return '''export interface Message { + role: "user" | "assistant"; + content: string; +} + +export interface Source { + document_name: string; + text: string; + score: number; +} + +export interface ChatResponse { + response: string; + session_id: string; + sources: Source[]; +} +''' + + +def _package_json(chatbot: Dict): + name = chatbot.get("name", "chatbot").lower().replace(" ", "-") + return f'''{{ + "name": "{name}-widget", + "version": "1.0.0", + "scripts": {{ + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }}, + "dependencies": {{ + "react": "^18.2.0", + "react-dom": "^18.2.0" + }}, + "devDependencies": {{ + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "typescript": "^5.0.0", + "vite": "^5.0.0", + "@vitejs/plugin-react": "^4.0.0" + }} +}} +''' + + +def _tsconfig(): + return '''{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} +''' + + +def _vite_config(): + return '''import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + build: { + lib: { + entry: "src/main.tsx", + name: "ChatWidget", + fileName: "chatbot-widget" + }, + rollupOptions: { + external: ["react", "react-dom"], + } + } +}); +''' + + +def _backend_readme(chatbot: Dict): + return f"""# {chatbot.get("name", "Chatbot")} - Backend API + +## Quick Start + +```bash +cp .env.example .env +# Edit .env with your API keys +pip install -r requirements.txt +uvicorn main:app --reload --port 8000 +``` + +## Deploy with Docker + +```bash +cp .env.example .env +# Edit .env +docker-compose up -d +``` + +## API Endpoints + +- `POST /chat` - Send a message +- `GET /health` - Health check + +## Environment Variables + +See `.env.example` for all required variables. +""" + + +def _frontend_readme(chatbot: Dict): + return f"""# {chatbot.get("name", "Chatbot")} - Chat Widget + +## Quick Start + +```bash +cp .env.example .env +# Set VITE_API_URL to your backend URL +npm install +npm run dev +``` + +## Build for Production + +```bash +npm run build +``` + +## Embed in Any Website + +```html + +``` + +## Environment Variables + +- `VITE_API_URL` - Backend API URL (default: http://localhost:8000) +""" + + +def _quick_start(chatbot: Dict): + return f"""# Quick Start - {chatbot.get("name", "Chatbot")} + +Get your chatbot running in 5 minutes! + +## Prerequisites +- Python 3.11+ +- Node.js 18+ +- API key from OpenAI, Anthropic, or Google + +## 1. Configure Environment (2 min) + +Run the setup wizard: +```bash +python setup.py +``` + +Or manually: +```bash +cd backend +cp .env.example .env +# Edit .env with your keys +``` + +## 2. Start Backend (1 min) + +```bash +cd backend +pip install -r requirements.txt +uvicorn main:app --reload +``` + +Backend runs at: http://localhost:8000 + +## 3. Start Frontend Widget (1 min) + +```bash +cd frontend +npm install +npm run dev +``` + +Widget available at: http://localhost:3000 + +## 4. Embed in Your Website + +After building (`npm run build`): +```html + +``` + +## Deploy + +### Railway (Recommended) +```bash +railway init +railway up +``` + +### Docker +```bash +cd backend && docker-compose up -d +``` +""" + + +def _setup_wizard(chatbot: Dict): + return f'''#!/usr/bin/env python3 +""" +Interactive setup wizard for {chatbot.get("name", "Chatbot")} +""" +import os +from pathlib import Path + + +def main(): + print(""" +╔══════════════════════════════════════╗ +║ {chatbot.get("name", "Chatbot")} Setup Wizard ║ +╚══════════════════════════════════════╝ + """) + + print("Choose your LLM provider:") + print("1. OpenAI (GPT-4o)") + print("2. Anthropic (Claude)") + print("3. Google (Gemini)") + print("4. Fireworks AI (Free, open-source models)") + choice = input("\\nEnter choice (1-4): ").strip() + + providers = {{"1": "openai", "2": "anthropic", "3": "google", "4": "fireworks"}} + models = {{"1": "gpt-4o", "2": "claude-3-5-sonnet-20241022", "3": "gemini-1.5-pro", + "4": "accounts/fireworks/models/llama-v3p1-70b-instruct"}} + + provider = providers.get(choice, "openai") + model = models.get(choice, "gpt-4o") + + api_key = input(f"Enter your {{provider}} API key: ").strip() + + env_content = f"""LLM_PROVIDER={{provider}} +LLM_MODEL={{model}} +LLM_API_KEY={{api_key}} +EMBEDDING_API_KEY={{api_key if provider == "openai" else input("Enter OpenAI key for embeddings: ").strip()}} +EMBEDDING_MODEL=text-embedding-3-small +QDRANT_URL={os.getenv("QDRANT_URL", "your-qdrant-url")} +QDRANT_API_KEY={os.getenv("QDRANT_API_KEY", "your-qdrant-key")} +QDRANT_COLLECTION={chatbot.get("qdrant_collection_name", "chatbot_collection")} +""" + + env_file = Path("backend/.env") + env_file.write_text(env_content) + print("\\n✅ .env file created!") + + frontend_url = input("\\nBackend URL for frontend (default: http://localhost:8000): ").strip() + if not frontend_url: + frontend_url = "http://localhost:8000" + + Path("frontend/.env").write_text(f"VITE_API_URL={{frontend_url}}\\n") + print("✅ Frontend .env created!") + + print(""" +\\n╔══════════════════════════════════════╗ +║ Setup Complete! 🎉 ║ +╠══════════════════════════════════════╣ +║ Backend: cd backend && uvicorn ║ +║ main:app --reload ║ +║ Frontend: cd frontend && npm dev ║ +╚══════════════════════════════════════╝ + """) + + +if __name__ == "__main__": + main() +''' diff --git a/app/services/document_processor.py b/app/services/document_processor.py new file mode 100644 index 0000000..3318e30 --- /dev/null +++ b/app/services/document_processor.py @@ -0,0 +1,221 @@ +import io +import logging +from typing import List, Dict, Any, Tuple +from pathlib import Path + +logger = logging.getLogger(__name__) + +CHUNK_SIZE = 512 # tokens approximate (chars ÷ 4) +CHUNK_OVERLAP = 50 + + +def parse_pdf(file_bytes: bytes) -> List[Dict[str, Any]]: + """Parse PDF and return list of {text, page_number}""" + try: + import pypdf + + reader = pypdf.PdfReader(io.BytesIO(file_bytes)) + pages = [] + for i, page in enumerate(reader.pages): + text = page.extract_text() or "" + text = text.strip() + if text: + pages.append({"text": text, "page_number": i + 1}) + return pages + except Exception as e: + logger.error(f"PDF parse error: {e}") + raise ValueError(f"Failed to parse PDF: {str(e)}") + + +def parse_docx(file_bytes: bytes) -> List[Dict[str, Any]]: + """Parse DOCX and return list of {text, page_number}""" + try: + from docx import Document + + doc = Document(io.BytesIO(file_bytes)) + sections = [] + current_text = [] + section_idx = 1 + + for para in doc.paragraphs: + text = para.text.strip() + if not text: + continue + + # New section on headings + if para.style.name.startswith("Heading"): + if current_text: + sections.append( + {"text": "\n".join(current_text), "page_number": section_idx} + ) + current_text = [] + section_idx += 1 + current_text.append(text) + + if current_text: + sections.append({"text": "\n".join(current_text), "page_number": section_idx}) + + return sections if sections else [{"text": "", "page_number": 1}] + except Exception as e: + logger.error(f"DOCX parse error: {e}") + raise ValueError(f"Failed to parse DOCX: {str(e)}") + + +def parse_csv(file_bytes: bytes) -> List[Dict[str, Any]]: + """Parse CSV - each row becomes a chunk""" + try: + import pandas as pd + + df = pd.read_csv(io.BytesIO(file_bytes)) + columns = list(df.columns) + chunks = [] + + # Process in batches of rows + batch_size = 10 + for start in range(0, len(df), batch_size): + batch = df.iloc[start : start + batch_size] + rows_text = [] + for _, row in batch.iterrows(): + row_parts = [f"{col}: {val}" for col, val in zip(columns, row) if str(val) != "nan"] + rows_text.append(" | ".join(row_parts)) + text = "\n".join(rows_text) + chunks.append({"text": text, "page_number": (start // batch_size) + 1}) + + return chunks + except Exception as e: + logger.error(f"CSV parse error: {e}") + raise ValueError(f"Failed to parse CSV: {str(e)}") + + +def parse_xlsx(file_bytes: bytes) -> List[Dict[str, Any]]: + """Parse XLSX - each sheet becomes sections""" + try: + import pandas as pd + + xl = pd.ExcelFile(io.BytesIO(file_bytes)) + chunks = [] + page_num = 1 + + for sheet_name in xl.sheet_names: + df = xl.parse(sheet_name) + columns = list(df.columns) + + batch_size = 10 + for start in range(0, len(df), batch_size): + batch = df.iloc[start : start + batch_size] + rows_text = [f"Sheet: {sheet_name}"] + for _, row in batch.iterrows(): + row_parts = [ + f"{col}: {val}" + for col, val in zip(columns, row) + if str(val) not in ("nan", "NaT", "None") + ] + if row_parts: + rows_text.append(" | ".join(row_parts)) + text = "\n".join(rows_text) + if text.strip(): + chunks.append({"text": text, "page_number": page_num}) + page_num += 1 + + return chunks + except Exception as e: + logger.error(f"XLSX parse error: {e}") + raise ValueError(f"Failed to parse XLSX: {str(e)}") + + +def parse_txt(file_bytes: bytes) -> List[Dict[str, Any]]: + """Parse plain text""" + try: + text = file_bytes.decode("utf-8", errors="ignore") + # Split into sections by double newlines + sections = [s.strip() for s in text.split("\n\n") if s.strip()] + if not sections: + sections = [text.strip()] + return [{"text": s, "page_number": i + 1} for i, s in enumerate(sections)] + except Exception as e: + raise ValueError(f"Failed to parse TXT: {str(e)}") + + +def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]: + """Split text into overlapping chunks""" + # Approximate token count: 1 token ≈ 4 chars + char_size = chunk_size * 4 + char_overlap = overlap * 4 + + if len(text) <= char_size: + return [text] + + chunks = [] + start = 0 + while start < len(text): + end = min(start + char_size, len(text)) + + # Try to break at sentence boundary + if end < len(text): + for sep in [". ", "! ", "? ", "\n", " "]: + pos = text.rfind(sep, start, end) + if pos > start + char_size // 2: + end = pos + len(sep) + break + + chunk = text[start:end].strip() + if chunk: + chunks.append(chunk) + + start = end - char_overlap if end - char_overlap > start else end + + return chunks + + +def process_document( + file_bytes: bytes, + file_name: str, + document_id: str, + company_id: str, +) -> Tuple[List[str], List[Dict[str, Any]]]: + """ + Main entry point: parse and chunk a document. + Returns (chunks_text, chunk_payloads) + """ + ext = Path(file_name).suffix.lower() + + # Parse + if ext == ".pdf": + pages = parse_pdf(file_bytes) + elif ext == ".docx": + pages = parse_docx(file_bytes) + elif ext == ".csv": + pages = parse_csv(file_bytes) + elif ext in (".xlsx", ".xls"): + pages = parse_xlsx(file_bytes) + elif ext in (".txt", ".md"): + pages = parse_txt(file_bytes) + else: + raise ValueError(f"Unsupported file type: {ext}") + + # Chunk + all_chunks = [] + all_payloads = [] + + for page in pages: + text = page["text"] + page_num = page.get("page_number", 1) + + chunks = chunk_text(text) + for idx, chunk in enumerate(chunks): + all_chunks.append(chunk) + all_payloads.append( + { + "document_id": document_id, + "company_id": company_id, + "file_name": file_name, + "page_number": page_num, + "chunk_index": idx, + "text": chunk, + } + ) + + logger.info( + f"Processed {file_name}: {len(pages)} pages → {len(all_chunks)} chunks" + ) + return all_chunks, all_payloads diff --git a/app/services/embeddings.py b/app/services/embeddings.py new file mode 100644 index 0000000..f1750bb --- /dev/null +++ b/app/services/embeddings.py @@ -0,0 +1,54 @@ +from openai import OpenAI +from app.config import settings +from typing import List +import logging + +logger = logging.getLogger(__name__) + +_openai_client = None + + +def get_openai_client() -> OpenAI: + global _openai_client + if _openai_client is None: + _openai_client = OpenAI(api_key=settings.openai_api_key) + return _openai_client + + +class EmbeddingService: + def __init__(self): + self.model = settings.embedding_model + + def embed_text(self, text: str) -> List[float]: + """Generate embedding for a single text""" + client = get_openai_client() + try: + response = client.embeddings.create( + model=self.model, + input=text, + ) + return response.data[0].embedding + except Exception as e: + logger.error(f"Embedding error: {e}") + raise + + def embed_batch(self, texts: List[str]) -> List[List[float]]: + """Generate embeddings for multiple texts""" + client = get_openai_client() + try: + # Clean texts + cleaned = [t.replace("\n", " ").strip() for t in texts if t.strip()] + if not cleaned: + return [] + + response = client.embeddings.create( + model=self.model, + input=cleaned, + ) + return [item.embedding for item in response.data] + except Exception as e: + logger.error(f"Batch embedding error: {e}") + raise + + +embedding_service = EmbeddingService() diff --git a/app/services/llm.py b/app/services/llm.py new file mode 100644 index 0000000..0549efe --- /dev/null +++ b/app/services/llm.py @@ -0,0 +1,171 @@ +from app.config import settings, MODEL_PROVIDERS, PLAN_LIMITS +from typing import List, Dict, Any, Optional, AsyncGenerator +import logging + +logger = logging.getLogger(__name__) + + +class LLMService: + """Routes requests to appropriate LLM provider""" + + async def generate( + self, + messages: List[Dict[str, str]], + model: str, + max_tokens: int = 1000, + temperature: float = 0.7, + ) -> Dict[str, Any]: + """Generate a response from the LLM""" + provider = MODEL_PROVIDERS.get(model, "openai") + + try: + if provider == "fireworks": + return await self._call_fireworks(messages, model, max_tokens, temperature) + elif provider == "openai": + return await self._call_openai(messages, model, max_tokens, temperature) + elif provider == "anthropic": + return await self._call_anthropic(messages, model, max_tokens, temperature) + elif provider == "google": + return await self._call_google(messages, model, max_tokens, temperature) + else: + return await self._call_openai(messages, model, max_tokens, temperature) + except Exception as e: + logger.error(f"LLM error ({provider}/{model}): {e}") + # Fallback to a basic model if available + if model != "accounts/fireworks/models/llama-v3p1-70b-instruct" and settings.fireworks_api_key: + logger.info("Falling back to Fireworks AI") + return await self._call_fireworks( + messages, + "accounts/fireworks/models/llama-v3p1-70b-instruct", + max_tokens, + temperature, + ) + raise + + async def _call_fireworks( + self, + messages: List[Dict[str, str]], + model: str, + max_tokens: int, + temperature: float, + ) -> Dict[str, Any]: + import httpx + + headers = { + "Authorization": f"Bearer {settings.fireworks_api_key}", + "Content-Type": "application/json", + } + payload = { + "model": model, + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + } + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post( + "https://api.fireworks.ai/inference/v1/chat/completions", + headers=headers, + json=payload, + ) + resp.raise_for_status() + data = resp.json() + return { + "content": data["choices"][0]["message"]["content"], + "tokens_used": data.get("usage", {}).get("total_tokens", 0), + "model": model, + } + + async def _call_openai( + self, + messages: List[Dict[str, str]], + model: str, + max_tokens: int, + temperature: float, + ) -> Dict[str, Any]: + from openai import AsyncOpenAI + + client = AsyncOpenAI(api_key=settings.openai_api_key) + response = await client.chat.completions.create( + model=model, + messages=messages, + max_tokens=max_tokens, + temperature=temperature, + ) + return { + "content": response.choices[0].message.content, + "tokens_used": response.usage.total_tokens if response.usage else 0, + "model": model, + } + + async def _call_anthropic( + self, + messages: List[Dict[str, str]], + model: str, + max_tokens: int, + temperature: float, + ) -> Dict[str, Any]: + import anthropic + + client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) + + # Separate system message from conversation + system_msg = "" + conv_messages = [] + for msg in messages: + if msg["role"] == "system": + system_msg = msg["content"] + else: + conv_messages.append(msg) + + response = await client.messages.create( + model=model, + max_tokens=max_tokens, + system=system_msg if system_msg else "You are a helpful assistant.", + messages=conv_messages, + temperature=temperature, + ) + return { + "content": response.content[0].text, + "tokens_used": response.usage.input_tokens + response.usage.output_tokens, + "model": model, + } + + async def _call_google( + self, + messages: List[Dict[str, str]], + model: str, + max_tokens: int, + temperature: float, + ) -> Dict[str, Any]: + import google.generativeai as genai + + genai.configure(api_key=settings.google_api_key) + gemini_model = genai.GenerativeModel(model) + + # Convert messages + parts = [] + for msg in messages: + role = "user" if msg["role"] in ("user", "system") else "model" + parts.append({"role": role, "parts": [msg["content"]]}) + + # Use last message as prompt if only one + if len(parts) == 1: + response = await gemini_model.generate_content_async( + parts[0]["parts"][0], + generation_config={"max_output_tokens": max_tokens, "temperature": temperature}, + ) + else: + chat = gemini_model.start_chat(history=parts[:-1]) + response = await chat.send_message_async( + parts[-1]["parts"][0], + generation_config={"max_output_tokens": max_tokens, "temperature": temperature}, + ) + + return { + "content": response.text, + "tokens_used": 0, + "model": model, + } + + +llm_service = LLMService() diff --git a/app/services/rag.py b/app/services/rag.py new file mode 100644 index 0000000..c55c4c3 --- /dev/null +++ b/app/services/rag.py @@ -0,0 +1,130 @@ +from app.services.embeddings import embedding_service +from app.services.vector_store import vector_store +from app.services.llm import llm_service +from app.models import SourceDocument +from typing import List, Dict, Any, Optional, Tuple +import logging + +logger = logging.getLogger(__name__) + +RAG_SYSTEM_PROMPT = """You are a helpful AI assistant for {company_name}. +Your role is to answer questions based on the provided context from company documents. + +IMPORTANT RULES: +1. Only answer based on the provided context +2. If information is not in the context, say "I don't have information about that in my knowledge base" +3. Be concise and helpful +4. Always maintain a professional, friendly tone +5. If asked about topics outside the context, politely redirect to relevant topics + +{custom_instructions} + +Context from knowledge base: +{context} +""" + + +class RAGEngine: + def __init__(self): + self.embedding_svc = embedding_service + self.vector_svc = vector_store + self.llm_svc = llm_service + + async def process_query( + self, + query: str, + collection_name: str, + chatbot_config: Dict[str, Any], + conversation_history: List[Dict[str, str]] = None, + language: str = "en", + ) -> Dict[str, Any]: + """ + Full RAG pipeline: embed → retrieve → generate + """ + if conversation_history is None: + conversation_history = [] + + # Step 1: Embed the query + try: + query_embedding = self.embedding_svc.embed_text(query) + except Exception as e: + logger.error(f"Embedding error: {e}") + return { + "response": "I'm having trouble processing your request. Please try again.", + "sources": [], + "tokens_used": 0, + "model": chatbot_config.get("model", "unknown"), + } + + # Step 2: Retrieve relevant chunks + retrieved = self.vector_svc.search( + collection_name=collection_name, + query_vector=query_embedding, + limit=5, + score_threshold=0.3, + ) + + # Step 3: Build sources + sources = [] + context_parts = [] + seen_texts = set() + + for item in retrieved: + payload = item.get("payload", {}) + text = payload.get("text", "") + if text and text not in seen_texts: + seen_texts.add(text) + context_parts.append(text) + sources.append( + SourceDocument( + document_name=payload.get("file_name", "Document"), + chunk_text=text[:200] + "..." if len(text) > 200 else text, + score=item.get("score", 0.0), + page_number=payload.get("page_number"), + ) + ) + + context = "\n\n---\n\n".join(context_parts) if context_parts else "No relevant information found." + + # Step 4: Build messages + system_prompt = RAG_SYSTEM_PROMPT.format( + company_name=chatbot_config.get("company_name", ""), + custom_instructions=chatbot_config.get("system_prompt") or "", + context=context, + ) + + messages = [{"role": "system", "content": system_prompt}] + + # Add conversation history (last 10 messages) + for msg in conversation_history[-10:]: + messages.append({"role": msg["role"], "content": msg["content"]}) + + # Add current query + messages.append({"role": "user", "content": query}) + + # Step 5: Generate response + model = chatbot_config.get("model", "accounts/fireworks/models/llama-v3p1-70b-instruct") + try: + result = await self.llm_svc.generate( + messages=messages, + model=model, + max_tokens=chatbot_config.get("max_tokens", 1000), + temperature=chatbot_config.get("temperature", 0.7), + ) + return { + "response": result["content"], + "sources": sources, + "tokens_used": result.get("tokens_used", 0), + "model": result.get("model", model), + } + except Exception as e: + logger.error(f"LLM generation error: {e}") + return { + "response": "I'm having trouble generating a response. Please try again later.", + "sources": sources, + "tokens_used": 0, + "model": model, + } + + +rag_engine = RAGEngine() diff --git a/app/services/vector_store.py b/app/services/vector_store.py new file mode 100644 index 0000000..a0a35bd --- /dev/null +++ b/app/services/vector_store.py @@ -0,0 +1,153 @@ +from qdrant_client import QdrantClient, models +from qdrant_client.http.models import ( + Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue +) +from app.config import settings +from typing import List, Dict, Any, Optional +import logging +import uuid + +logger = logging.getLogger(__name__) + +_qdrant_client: QdrantClient = None + + +def get_qdrant_client() -> QdrantClient: + global _qdrant_client + if _qdrant_client is None: + kwargs = {"url": settings.qdrant_url} + if settings.qdrant_api_key: + kwargs["api_key"] = settings.qdrant_api_key + _qdrant_client = QdrantClient(**kwargs) + return _qdrant_client + + +class VectorStoreService: + VECTOR_SIZE = 1536 # text-embedding-3-small + + def __init__(self): + self.client = get_qdrant_client() + + def create_collection(self, collection_name: str) -> bool: + """Create a new collection for a chatbot""" + try: + self.client.create_collection( + collection_name=collection_name, + vectors_config=VectorParams( + size=self.VECTOR_SIZE, + distance=Distance.COSINE, + ), + ) + logger.info(f"Created collection: {collection_name}") + return True + except Exception as e: + if "already exists" in str(e).lower(): + return True + logger.error(f"Error creating collection {collection_name}: {e}") + raise + + def delete_collection(self, collection_name: str) -> bool: + """Delete a chatbot's collection""" + try: + self.client.delete_collection(collection_name=collection_name) + logger.info(f"Deleted collection: {collection_name}") + return True + except Exception as e: + logger.error(f"Error deleting collection {collection_name}: {e}") + return False + + def collection_exists(self, collection_name: str) -> bool: + try: + self.client.get_collection(collection_name) + return True + except Exception: + return False + + def upsert_vectors( + self, + collection_name: str, + vectors: List[List[float]], + payloads: List[Dict[str, Any]], + ids: Optional[List[str]] = None, + ) -> bool: + """Upsert vectors into collection""" + if ids is None: + ids = [str(uuid.uuid4()) for _ in vectors] + + points = [ + PointStruct( + id=idx, + vector=vector, + payload=payload, + ) + for idx, vector, payload in zip(ids, vectors, payloads) + ] + + try: + self.client.upsert( + collection_name=collection_name, + points=points, + ) + return True + except Exception as e: + logger.error(f"Error upserting vectors: {e}") + raise + + def search( + self, + collection_name: str, + query_vector: List[float], + limit: int = 5, + score_threshold: float = 0.3, + ) -> List[Dict[str, Any]]: + """Search for similar vectors""" + try: + results = self.client.search( + collection_name=collection_name, + query_vector=query_vector, + limit=limit, + score_threshold=score_threshold, + ) + return [ + { + "id": str(r.id), + "score": r.score, + "payload": r.payload, + } + for r in results + ] + except Exception as e: + logger.error(f"Error searching vectors: {e}") + return [] + + def delete_by_document_id(self, collection_name: str, document_id: str) -> bool: + """Delete all vectors for a document""" + try: + self.client.delete( + collection_name=collection_name, + points_selector=models.FilterSelector( + filter=Filter( + must=[ + FieldCondition( + key="document_id", + match=MatchValue(value=document_id), + ) + ] + ) + ), + ) + return True + except Exception as e: + logger.error(f"Error deleting document vectors: {e}") + return False + + def count_vectors(self, collection_name: str) -> int: + """Count vectors in a collection""" + try: + result = self.client.count(collection_name=collection_name) + return result.count + except Exception: + return 0 + + +vector_store = VectorStoreService() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6447560 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: '3.8' + +services: + api: + build: . + ports: + - "8000:8000" + env_file: .env + restart: unless-stopped + volumes: + - .:/app + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + # Optional: Local Qdrant (alternatively use Qdrant Cloud) + qdrant: + image: qdrant/qdrant:latest + ports: + - "6333:6333" + volumes: + - qdrant_data:/qdrant/storage + restart: unless-stopped + +volumes: + qdrant_data: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a2874f9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "contexta-be" +version = "0.1.0" +description = "Add your description here" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.131.0", + "uvicorn>=0.41.0", +] diff --git a/supabase_schema.sql b/supabase_schema.sql new file mode 100644 index 0000000..1473478 --- /dev/null +++ b/supabase_schema.sql @@ -0,0 +1,192 @@ +-- ============================================================ +-- CONTEXTA - Supabase Database Schema +-- Run this in your Supabase SQL Editor +-- ============================================================ + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ─── Companies ──────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS companies ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + owner_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + website VARCHAR(255), + industry VARCHAR(100), + logo_url TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ─── Subscriptions ──────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS subscriptions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE, + plan VARCHAR(50) DEFAULT 'free', + status VARCHAR(50) DEFAULT 'active', + stripe_customer_id VARCHAR(255) UNIQUE, + stripe_subscription_id VARCHAR(255), + stripe_price_id VARCHAR(255), + current_period_start TIMESTAMPTZ, + current_period_end TIMESTAMPTZ, + chatbots_published INT DEFAULT 0, + conversations_used INT DEFAULT 0, + trial_end TIMESTAMPTZ, + canceled_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ─── Chatbots ───────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS chatbots ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + company_id UUID REFERENCES companies(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + system_prompt TEXT, + model VARCHAR(200) DEFAULT 'accounts/fireworks/models/llama-v3p1-70b-instruct', + temperature DECIMAL(3,2) DEFAULT 0.70, + max_tokens INT DEFAULT 1000, + primary_color VARCHAR(20) DEFAULT '#6366f1', + welcome_message TEXT DEFAULT 'Hello! How can I help you today?', + category VARCHAR(100), + industry VARCHAR(100), + languages JSONB DEFAULT '["en"]', + visibility VARCHAR(50) DEFAULT 'preview', + is_published BOOLEAN DEFAULT FALSE, + qdrant_collection_name VARCHAR(255) UNIQUE, + average_rating DECIMAL(3,1), + total_conversations INT DEFAULT 0, + is_featured BOOLEAN DEFAULT FALSE, + published_at TIMESTAMPTZ, + unpublished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ─── Documents ──────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + chatbot_id UUID REFERENCES chatbots(id) ON DELETE CASCADE, + file_name VARCHAR(500) NOT NULL, + file_type VARCHAR(50), + file_size BIGINT DEFAULT 0, + file_url TEXT, + chunk_count INT DEFAULT 0, + status VARCHAR(50) DEFAULT 'pending', + error_message TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ─── Conversations ──────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS conversations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + chatbot_id UUID REFERENCES chatbots(id) ON DELETE CASCADE, + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + session_id UUID, + language VARCHAR(20) DEFAULT 'en', + message_count INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ─── Messages ───────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE, + role VARCHAR(50) NOT NULL, + content TEXT NOT NULL, + sources JSONB, + model VARCHAR(200), + tokens_used INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ─── Indexes ────────────────────────────────────────────────────────────────── +CREATE INDEX IF NOT EXISTS idx_companies_owner ON companies(owner_id); +CREATE INDEX IF NOT EXISTS idx_chatbots_company ON chatbots(company_id); +CREATE INDEX IF NOT EXISTS idx_chatbots_published ON chatbots(is_published, visibility); +CREATE INDEX IF NOT EXISTS idx_documents_chatbot ON documents(chatbot_id); +CREATE INDEX IF NOT EXISTS idx_conversations_chatbot ON conversations(chatbot_id); +CREATE INDEX IF NOT EXISTS idx_conversations_session ON conversations(session_id); +CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id); + +-- ─── RLS Policies ───────────────────────────────────────────────────────────── +ALTER TABLE companies ENABLE ROW LEVEL SECURITY; +ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY; +ALTER TABLE chatbots ENABLE ROW LEVEL SECURITY; +ALTER TABLE documents ENABLE ROW LEVEL SECURITY; +ALTER TABLE conversations ENABLE ROW LEVEL SECURITY; +ALTER TABLE messages ENABLE ROW LEVEL SECURITY; + +-- Companies: users can only see/edit their own +CREATE POLICY "companies_own" ON companies + FOR ALL USING (auth.uid() = owner_id); + +-- Subscriptions: users can only see their own +CREATE POLICY "subscriptions_own" ON subscriptions + FOR ALL USING (auth.uid() = user_id); + +-- Chatbots: owners can manage, public can read published +CREATE POLICY "chatbots_owner" ON chatbots + FOR ALL USING ( + company_id IN (SELECT id FROM companies WHERE owner_id = auth.uid()) + ); +CREATE POLICY "chatbots_public_read" ON chatbots + FOR SELECT USING (is_published = TRUE AND visibility = 'published'); + +-- Documents: only chatbot owners +CREATE POLICY "documents_owner" ON documents + FOR ALL USING ( + chatbot_id IN ( + SELECT c.id FROM chatbots c + JOIN companies co ON c.company_id = co.id + WHERE co.owner_id = auth.uid() + ) + ); + +-- Conversations & Messages: open for anonymous users to create +CREATE POLICY "conversations_insert" ON conversations FOR INSERT WITH CHECK (true); +CREATE POLICY "conversations_select" ON conversations FOR SELECT USING (true); +CREATE POLICY "messages_insert" ON messages FOR INSERT WITH CHECK (true); +CREATE POLICY "messages_select" ON messages FOR SELECT USING (true); + +-- ─── Marketplace View ───────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW marketplace_chatbots AS +SELECT + c.id, c.name, c.description, c.category, c.industry, + c.languages, c.primary_color, c.welcome_message, + c.average_rating, c.total_conversations, c.is_featured, + c.published_at, c.created_at, + co.name AS company_name, co.logo_url AS company_logo +FROM chatbots c +JOIN companies co ON c.company_id = co.id +JOIN subscriptions s ON co.owner_id = s.user_id +WHERE + c.visibility = 'published' + AND c.is_published = TRUE + AND s.status = 'active' + AND s.plan IN ('starter', 'pro', 'enterprise'); + +-- ─── Auto-unpublish on subscription cancel ──────────────────────────────────── +CREATE OR REPLACE FUNCTION unpublish_on_subscription_end() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.status IN ('canceled', 'unpaid', 'past_due') THEN + UPDATE chatbots + SET visibility = 'preview', is_published = FALSE + WHERE company_id IN ( + SELECT id FROM companies WHERE owner_id = NEW.user_id + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS subscription_status_change ON subscriptions; +CREATE TRIGGER subscription_status_change + AFTER UPDATE ON subscriptions + FOR EACH ROW + WHEN (OLD.status IS DISTINCT FROM NEW.status) + EXECUTE FUNCTION unpublish_on_subscription_end(); diff --git a/test_main.http b/test_main.http new file mode 100644 index 0000000..a2d81a9 --- /dev/null +++ b/test_main.http @@ -0,0 +1,11 @@ +# Test your FastAPI endpoints + +GET http://127.0.0.1:8000/ +Accept: application/json + +### + +GET http://127.0.0.1:8000/hello/User +Accept: application/json + +### diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..aa7124e --- /dev/null +++ b/uv.lock @@ -0,0 +1,237 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contexta-be" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.131.0" }, + { name = "uvicorn", specifier = ">=0.41.0" }, +] + +[[package]] +name = "fastapi" +version = "0.131.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/32/158cbf685b7d5a26f87131069da286bf10fc9fbf7fc968d169d48a45d689/fastapi-0.131.0.tar.gz", hash = "sha256:6531155e52bee2899a932c746c9a8250f210e3c3303a5f7b9f8a808bfe0548ff", size = 369612, upload-time = "2026-02-22T16:38:11.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/94/b58ec24c321acc2ad1327f69b033cadc005e0f26df9a73828c9e9c7db7ce/fastapi-0.131.0-py3-none-any.whl", hash = "sha256:ed0e53decccf4459de78837ce1b867cd04fa9ce4579497b842579755d20b405a", size = 103854, upload-time = "2026-02-22T16:38:09.814Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, +]