mirror of
http://88.130.71.182:3000/BlitTech/contexta_be.git
synced 2026-06-12 23:23:21 +00:00
Initial commit
This commit is contained in:
76
.gitignore
vendored
Normal file
76
.gitignore
vendored
Normal file
@@ -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
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -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"]
|
||||
462
README.md
Normal file
462
README.md
Normal file
@@ -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.
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
127
app/config.py
Normal file
127
app/config.py
Normal file
@@ -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",
|
||||
}
|
||||
82
app/dependencies.py
Normal file
82
app/dependencies.py
Normal file
@@ -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
|
||||
74
app/main.py
Normal file
74
app/main.py
Normal file
@@ -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)
|
||||
306
app/models.py
Normal file
306
app/models.py
Normal file
@@ -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
|
||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
133
app/routers/auth.py
Normal file
133
app/routers/auth.py
Normal file
@@ -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,
|
||||
)
|
||||
187
app/routers/billing.py
Normal file
187
app/routers/billing.py
Normal file
@@ -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))
|
||||
212
app/routers/chat.py
Normal file
212
app/routers/chat.py
Normal file
@@ -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()
|
||||
240
app/routers/chatbots.py
Normal file
240
app/routers/chatbots.py
Normal file
@@ -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"),
|
||||
)
|
||||
208
app/routers/documents.py
Normal file
208
app/routers/documents.py
Normal file
@@ -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")
|
||||
133
app/routers/marketplace.py
Normal file
133
app/routers/marketplace.py
Normal file
@@ -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)}
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
713
app/services/code_export.py
Normal file
713
app/services/code_export.py
Normal file
@@ -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<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {{
|
||||
bottomRef.current?.scrollIntoView({{ behavior: "smooth" }});
|
||||
}}, [messages]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{{isOpen && (
|
||||
<div style={{{{
|
||||
position: "fixed", bottom: 90, right: 20, width: 360, height: 520,
|
||||
borderRadius: 16, boxShadow: "0 20px 60px rgba(0,0,0,0.2)",
|
||||
display: "flex", flexDirection: "column", background: "#fff",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif", zIndex: 9999
|
||||
}}}}>
|
||||
<div style={{{{ background: PRIMARY_COLOR, padding: "16px 20px",
|
||||
borderRadius: "16px 16px 0 0", display: "flex", justifyContent: "space-between", alignItems: "center" }}}}>
|
||||
<span style={{{{ color: "#fff", fontWeight: 600, fontSize: 16 }}}}>{{BOT_NAME}}</span>
|
||||
<button onClick={{() => setIsOpen(false)}}
|
||||
style={{{{ background: "none", border: "none", color: "#fff", cursor: "pointer", fontSize: 20 }}}}>×</button>
|
||||
</div>
|
||||
<div style={{{{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 12 }}}}>
|
||||
{{messages.map((msg, i) => (
|
||||
<div key={{i}} style={{{{ display: "flex", justifyContent: msg.role === "user" ? "flex-end" : "flex-start" }}}}>
|
||||
<div style={{{{
|
||||
maxWidth: "80%", padding: "10px 14px", borderRadius: 12, fontSize: 14, lineHeight: 1.5,
|
||||
background: msg.role === "user" ? PRIMARY_COLOR : "#f3f4f6",
|
||||
color: msg.role === "user" ? "#fff" : "#111"
|
||||
}}}}>{{msg.content}}</div>
|
||||
</div>
|
||||
))}}
|
||||
{{isLoading && <div style={{{{ color: "#6b7280", fontSize: 13 }}}}>Thinking...</div>}}
|
||||
<div ref={{bottomRef}} />
|
||||
</div>
|
||||
<div style={{{{ padding: "12px 16px", borderTop: "1px solid #e5e7eb", display: "flex", gap: 8 }}}}>
|
||||
<input
|
||||
style={{{{ flex: 1, border: "1px solid #e5e7eb", borderRadius: 8, padding: "8px 12px", outline: "none", fontSize: 14 }}}}
|
||||
placeholder="Type a message..."
|
||||
onKeyDown={{(e) => {{
|
||||
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 = ""; }}
|
||||
}}
|
||||
}}}}
|
||||
/>
|
||||
<button
|
||||
style={{{{ background: PRIMARY_COLOR, color: "#fff", border: "none", borderRadius: 8,
|
||||
padding: "8px 14px", cursor: "pointer", fontSize: 14 }}}}
|
||||
onClick={{(e) => {{
|
||||
const input = (e.currentTarget.previousSibling as HTMLInputElement);
|
||||
const val = input.value.trim();
|
||||
if (val) {{ sendMessage(val); input.value = ""; }}
|
||||
}}}}
|
||||
>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
)}}
|
||||
<button
|
||||
onClick={{() => setIsOpen(!isOpen)}}
|
||||
style={{{{
|
||||
position: "fixed", bottom: 20, right: 20, width: 56, height: 56,
|
||||
borderRadius: "50%", background: PRIMARY_COLOR, border: "none",
|
||||
cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.2)", zIndex: 9999, fontSize: 24
|
||||
}}}}
|
||||
>
|
||||
{{isOpen ? "×" : "💬"}}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}};
|
||||
'''
|
||||
|
||||
|
||||
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<Message[]>([
|
||||
{ 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
|
||||
<script src="path/to/dist/chatbot-widget.umd.cjs"></script>
|
||||
```
|
||||
|
||||
## 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
|
||||
<script src="dist/chatbot-widget.umd.cjs"></script>
|
||||
```
|
||||
|
||||
## 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()
|
||||
'''
|
||||
221
app/services/document_processor.py
Normal file
221
app/services/document_processor.py
Normal file
@@ -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
|
||||
54
app/services/embeddings.py
Normal file
54
app/services/embeddings.py
Normal file
@@ -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()
|
||||
171
app/services/llm.py
Normal file
171
app/services/llm.py
Normal file
@@ -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()
|
||||
130
app/services/rag.py
Normal file
130
app/services/rag.py
Normal file
@@ -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()
|
||||
153
app/services/vector_store.py
Normal file
153
app/services/vector_store.py
Normal file
@@ -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()
|
||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@@ -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:
|
||||
9
pyproject.toml
Normal file
9
pyproject.toml
Normal file
@@ -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",
|
||||
]
|
||||
192
supabase_schema.sql
Normal file
192
supabase_schema.sql
Normal file
@@ -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();
|
||||
11
test_main.http
Normal file
11
test_main.http
Normal file
@@ -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
|
||||
|
||||
###
|
||||
237
uv.lock
generated
Normal file
237
uv.lock
generated
Normal file
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user