Commit ·
50925ca
0
Parent(s):
Initialize Archvise API backend for Hugging Face Spaces
Browse files- .gitignore +13 -0
- Dockerfile +29 -0
- app/__init__.py +1 -0
- app/config.py +85 -0
- app/database.py +36 -0
- app/main.py +67 -0
- app/models.py +133 -0
- app/routers/__init__.py +1 -0
- app/routers/audit.py +325 -0
- app/routers/auth.py +121 -0
- app/routers/billing.py +155 -0
- app/routers/design.py +227 -0
- app/routers/github.py +150 -0
- app/routers/legal.py +66 -0
- app/routers/projects.py +39 -0
- app/routers/settings.py +30 -0
- app/schemas.py +173 -0
- app/tasks.py +292 -0
- app/utils/__init__.py +1 -0
- app/utils/ai.py +435 -0
- app/utils/firebase.py +26 -0
- app/utils/github_client.py +174 -0
- app/utils/pdf.py +543 -0
- app/utils/security.py +66 -0
- app/utils/storage.py +57 -0
- app/worker.py +16 -0
- railway.json +12 -0
- requirements.txt +25 -0
.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local configuration file containing database & secret keys
|
| 2 |
+
.env
|
| 3 |
+
|
| 4 |
+
# Python build and cache artifacts
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.pyc
|
| 7 |
+
*.pyo
|
| 8 |
+
*.pyd
|
| 9 |
+
.pytest_cache/
|
| 10 |
+
|
| 11 |
+
# Local databases and logs
|
| 12 |
+
*.db
|
| 13 |
+
*.log
|
Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
# Install system dependencies for WeasyPrint and fonts
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
build-essential \
|
| 6 |
+
python3-dev \
|
| 7 |
+
libffi-dev \
|
| 8 |
+
shared-mime-info \
|
| 9 |
+
libcairo2 \
|
| 10 |
+
libpango-1.0-0 \
|
| 11 |
+
libpangocairo-1.0-0 \
|
| 12 |
+
libgdk-pixbuf2.0-0 \
|
| 13 |
+
fonts-dejavu-core \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
# Install Python requirements
|
| 19 |
+
COPY requirements.txt .
|
| 20 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 21 |
+
|
| 22 |
+
# Copy source code
|
| 23 |
+
COPY . .
|
| 24 |
+
|
| 25 |
+
# Expose FastAPI port
|
| 26 |
+
EXPOSE 7860
|
| 27 |
+
|
| 28 |
+
# Start command
|
| 29 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Archvise app package
|
app/config.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import List, Union
|
| 3 |
+
from pydantic import AnyHttpUrl, field_validator
|
| 4 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
+
|
| 6 |
+
class Settings(BaseSettings):
|
| 7 |
+
APP_NAME: str = "Archvise"
|
| 8 |
+
DEBUG: bool = True
|
| 9 |
+
API_VERSION: str = "v1"
|
| 10 |
+
SECRET_KEY: str = "generate-a-secure-jwt-secret-key-for-production-deployment-2026"
|
| 11 |
+
|
| 12 |
+
# Database
|
| 13 |
+
DATABASE_URL: str
|
| 14 |
+
|
| 15 |
+
# Redis
|
| 16 |
+
REDIS_URL: str = "redis://localhost:6379/0"
|
| 17 |
+
|
| 18 |
+
# NVIDIA NIM APIs
|
| 19 |
+
NVIDIA_BASE_URL: str = "https://integrate.api.nvidia.com/v1"
|
| 20 |
+
NVIDIA_LLAMA_KEY: str
|
| 21 |
+
NVIDIA_MISTRAL_KEY: str
|
| 22 |
+
NVIDIA_DEEPSEEK_KEY: str
|
| 23 |
+
NVIDIA_LLAMA_VISION_KEY: str
|
| 24 |
+
NVIDIA_EMBED_KEY: str
|
| 25 |
+
|
| 26 |
+
# Firebase Admin SDK
|
| 27 |
+
FIREBASE_PROJECT_ID: str
|
| 28 |
+
FIREBASE_CLIENT_EMAIL: str
|
| 29 |
+
FIREBASE_PRIVATE_KEY: str
|
| 30 |
+
|
| 31 |
+
@field_validator("FIREBASE_PRIVATE_KEY")
|
| 32 |
+
@classmethod
|
| 33 |
+
def clean_private_key(cls, v: str) -> str:
|
| 34 |
+
if v:
|
| 35 |
+
# Replace escaped newlines if they are passed as text \n
|
| 36 |
+
return v.replace("\\n", "\n").replace('"', "")
|
| 37 |
+
return v
|
| 38 |
+
|
| 39 |
+
# Storage API (Supabase Storage S3-Compatible)
|
| 40 |
+
CLOUDFLARE_R2_ACCOUNT_ID: str
|
| 41 |
+
CLOUDFLARE_R2_ACCESS_KEY: str
|
| 42 |
+
CLOUDFLARE_R2_SECRET_KEY: str
|
| 43 |
+
CLOUDFLARE_R2_BUCKET: str
|
| 44 |
+
CLOUDFLARE_R2_ENDPOINT_URL: str
|
| 45 |
+
CLOUDFLARE_R2_REGION_NAME: str = "ap-southeast-2"
|
| 46 |
+
|
| 47 |
+
# CORS Settings
|
| 48 |
+
CORS_ORIGINS: Union[str, List[str]] = ["*"]
|
| 49 |
+
|
| 50 |
+
@field_validator("CORS_ORIGINS")
|
| 51 |
+
@classmethod
|
| 52 |
+
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> List[str]:
|
| 53 |
+
if isinstance(v, str):
|
| 54 |
+
if v.startswith("[") and v.endswith("]"):
|
| 55 |
+
import json
|
| 56 |
+
try:
|
| 57 |
+
return json.loads(v)
|
| 58 |
+
except Exception:
|
| 59 |
+
pass
|
| 60 |
+
return [x.strip() for x in v.split(",")]
|
| 61 |
+
return v
|
| 62 |
+
|
| 63 |
+
# Rate Limiting
|
| 64 |
+
RATE_LIMIT: str = "100/minute"
|
| 65 |
+
|
| 66 |
+
# Stripe
|
| 67 |
+
STRIPE_SECRET_KEY: str = "sk_test_placeholder_please_replace"
|
| 68 |
+
STRIPE_WEBHOOK_SECRET: str = "whsec_placeholder_please_replace"
|
| 69 |
+
STRIPE_STARTER_PRICE_ID: str = "price_placeholder_starter"
|
| 70 |
+
STRIPE_PRO_PRICE_ID: str = "price_placeholder_pro"
|
| 71 |
+
|
| 72 |
+
# GitHub OAuth
|
| 73 |
+
GITHUB_CLIENT_ID: str = "github_client_id_placeholder"
|
| 74 |
+
GITHUB_CLIENT_SECRET: str = "github_client_secret_placeholder"
|
| 75 |
+
|
| 76 |
+
# Frontend Redirect
|
| 77 |
+
FRONTEND_URL: str = "http://localhost:3000"
|
| 78 |
+
|
| 79 |
+
model_config = SettingsConfigDict(
|
| 80 |
+
env_file=".env",
|
| 81 |
+
env_file_encoding="utf-8",
|
| 82 |
+
extra="ignore"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
settings = Settings()
|
app/database.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
| 2 |
+
from sqlalchemy.orm import declarative_base
|
| 3 |
+
from app.config import settings
|
| 4 |
+
|
| 5 |
+
# Adapt postgresql:// to postgresql+asyncpg:// if needed
|
| 6 |
+
database_url = settings.DATABASE_URL
|
| 7 |
+
if database_url.startswith("postgresql://"):
|
| 8 |
+
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
| 9 |
+
|
| 10 |
+
# Enable connection pooling options since we connect to Supabase
|
| 11 |
+
engine = create_async_engine(
|
| 12 |
+
database_url,
|
| 13 |
+
echo=settings.DEBUG,
|
| 14 |
+
pool_size=10,
|
| 15 |
+
max_overflow=20,
|
| 16 |
+
pool_pre_ping=True
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
AsyncSessionLocal = async_sessionmaker(
|
| 20 |
+
bind=engine,
|
| 21 |
+
class_=AsyncSession,
|
| 22 |
+
expire_on_commit=False
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
Base = declarative_base()
|
| 26 |
+
|
| 27 |
+
async def get_db():
|
| 28 |
+
async with AsyncSessionLocal() as session:
|
| 29 |
+
try:
|
| 30 |
+
yield session
|
| 31 |
+
await session.commit()
|
| 32 |
+
except Exception:
|
| 33 |
+
await session.rollback()
|
| 34 |
+
raise
|
| 35 |
+
finally:
|
| 36 |
+
await session.close()
|
app/main.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sentry_sdk
|
| 2 |
+
from fastapi import FastAPI, Request
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 5 |
+
from slowapi.errors import RateLimitExceeded
|
| 6 |
+
from slowapi.util import get_remote_address
|
| 7 |
+
from app.config import settings
|
| 8 |
+
from app.routers import auth, audit, design, github, billing, settings as settings_router, legal, projects
|
| 9 |
+
from loguru import logger
|
| 10 |
+
|
| 11 |
+
# Initialize Sentry
|
| 12 |
+
if not settings.DEBUG:
|
| 13 |
+
sentry_sdk.init(
|
| 14 |
+
dsn=None, # Replace with Sentry DSN when ready
|
| 15 |
+
traces_sample_rate=1.0,
|
| 16 |
+
profiles_sample_rate=1.0,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
# Configure Slowapi Rate Limiter
|
| 20 |
+
limiter = Limiter(key_func=get_remote_address, default_limits=[settings.RATE_LIMIT])
|
| 21 |
+
|
| 22 |
+
app = FastAPI(
|
| 23 |
+
title=settings.APP_NAME,
|
| 24 |
+
description="Archvise SaaS Backend Engine",
|
| 25 |
+
version=settings.API_VERSION,
|
| 26 |
+
debug=settings.DEBUG
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
# Attach Limiter to App State
|
| 30 |
+
app.state.limiter = limiter
|
| 31 |
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 32 |
+
|
| 33 |
+
# Configure CORS Middleware
|
| 34 |
+
# Credentials must be True for httpOnly cookies to be sent
|
| 35 |
+
origins = settings.CORS_ORIGINS
|
| 36 |
+
if isinstance(origins, str):
|
| 37 |
+
origins = [origins]
|
| 38 |
+
|
| 39 |
+
app.add_middleware(
|
| 40 |
+
CORSMiddleware,
|
| 41 |
+
allow_origins=origins,
|
| 42 |
+
allow_credentials=True,
|
| 43 |
+
allow_methods=["*"],
|
| 44 |
+
allow_headers=["*"],
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Register Routers
|
| 48 |
+
app.include_router(auth.router, prefix="/api")
|
| 49 |
+
app.include_router(audit.router, prefix="/api")
|
| 50 |
+
app.include_router(design.router, prefix="/api")
|
| 51 |
+
app.include_router(github.router, prefix="/api")
|
| 52 |
+
app.include_router(billing.router, prefix="/api")
|
| 53 |
+
app.include_router(settings_router.router, prefix="/api")
|
| 54 |
+
app.include_router(legal.router, prefix="/api")
|
| 55 |
+
app.include_router(projects.router, prefix="/api")
|
| 56 |
+
|
| 57 |
+
@app.on_event("startup")
|
| 58 |
+
async def startup_event():
|
| 59 |
+
logger.info("Initializing database schemas...")
|
| 60 |
+
from app.database import Base, engine
|
| 61 |
+
async with engine.begin() as conn:
|
| 62 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 63 |
+
logger.info("Database schemas initialized. Ready for requests.")
|
| 64 |
+
|
| 65 |
+
@app.get("/")
|
| 66 |
+
def read_root():
|
| 67 |
+
return {"status": "online", "app": settings.APP_NAME, "version": settings.API_VERSION}
|
app/models.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Text, Float, JSON
|
| 4 |
+
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
| 5 |
+
from sqlalchemy.orm import relationship
|
| 6 |
+
from app.database import Base
|
| 7 |
+
|
| 8 |
+
class User(Base):
|
| 9 |
+
__tablename__ = "users"
|
| 10 |
+
|
| 11 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 12 |
+
firebase_uid = Column(String, unique=True, index=True, nullable=False)
|
| 13 |
+
email = Column(String, unique=True, index=True, nullable=False)
|
| 14 |
+
name = Column(String, nullable=True)
|
| 15 |
+
avatar_url = Column(String, nullable=True)
|
| 16 |
+
plan = Column(String, default="free") # 'free', 'starter', 'pro'
|
| 17 |
+
display_mode = Column(String, default="founder") # 'founder', 'engineer'
|
| 18 |
+
credits_remaining = Column(Integer, default=2)
|
| 19 |
+
credits_reset_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
| 20 |
+
stripe_customer_id = Column(String, nullable=True)
|
| 21 |
+
github_connected = Column(Boolean, default=False)
|
| 22 |
+
total_audits = Column(Integer, default=0)
|
| 23 |
+
total_designs = Column(Integer, default=0)
|
| 24 |
+
is_active = Column(Boolean, default=True)
|
| 25 |
+
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
| 26 |
+
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 27 |
+
|
| 28 |
+
projects = relationship("Project", back_populates="user", cascade="all, delete-orphan")
|
| 29 |
+
github_connection = relationship("GitHubConnection", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
| 30 |
+
|
| 31 |
+
class Project(Base):
|
| 32 |
+
__tablename__ = "projects"
|
| 33 |
+
|
| 34 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 35 |
+
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False)
|
| 36 |
+
name = Column(String(100), nullable=False)
|
| 37 |
+
type = Column(String, nullable=False) # 'audit', 'system_design'
|
| 38 |
+
source = Column(String, nullable=False) # 'upload', 'github'
|
| 39 |
+
status = Column(String, index=True, default="queued") # 'queued', 'processing', 'complete', 'failed'
|
| 40 |
+
job_id = Column(String, index=True, nullable=True)
|
| 41 |
+
error_msg = Column(Text, nullable=True)
|
| 42 |
+
github_repo = Column(String, nullable=True)
|
| 43 |
+
github_branch = Column(String, nullable=True)
|
| 44 |
+
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
| 45 |
+
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 46 |
+
|
| 47 |
+
user = relationship("User", back_populates="projects")
|
| 48 |
+
audit_report = relationship("AuditReport", back_populates="project", uselist=False, cascade="all, delete-orphan")
|
| 49 |
+
system_design = relationship("SystemDesign", back_populates="project", uselist=False, cascade="all, delete-orphan")
|
| 50 |
+
|
| 51 |
+
class AuditReport(Base):
|
| 52 |
+
__tablename__ = "audit_reports"
|
| 53 |
+
|
| 54 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 55 |
+
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True, unique=True, nullable=False)
|
| 56 |
+
overall_score = Column(Integer, nullable=False)
|
| 57 |
+
confidence = Column(JSONB, nullable=False) # JSON: level, score, label, based_on, limitations, to_increase_confidence
|
| 58 |
+
capacity_estimate = Column(JSONB, nullable=False) # JSON: safe_range, peak_range, description, reasoning, confidence
|
| 59 |
+
agents = Column(JSONB, nullable=False) # JSON: SRE, backend, infrastructure, cloud_architect reports
|
| 60 |
+
top_critical_issues = Column(JSONB, nullable=False) # Array of issues
|
| 61 |
+
quick_wins = Column(JSONB, nullable=False) # Array of quick wins
|
| 62 |
+
benchmark_percentile = Column(Float, nullable=True)
|
| 63 |
+
score_disclaimer = Column(Text, nullable=False)
|
| 64 |
+
files_analyzed = Column(Integer, default=0)
|
| 65 |
+
files_skipped = Column(Integer, default=0)
|
| 66 |
+
was_truncated = Column(Boolean, default=False)
|
| 67 |
+
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
| 68 |
+
|
| 69 |
+
project = relationship("Project", back_populates="audit_report")
|
| 70 |
+
|
| 71 |
+
class SystemDesign(Base):
|
| 72 |
+
__tablename__ = "system_designs"
|
| 73 |
+
|
| 74 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 75 |
+
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True, unique=True, nullable=False)
|
| 76 |
+
idea_prompt = Column(Text, nullable=False)
|
| 77 |
+
title = Column(String, nullable=False)
|
| 78 |
+
founder_summary = Column(Text, nullable=False)
|
| 79 |
+
engineer_summary = Column(Text, nullable=False)
|
| 80 |
+
architecture_type = Column(String, nullable=False) # 'Monolith', 'Microservices', 'Hybrid'
|
| 81 |
+
reasoning = Column(Text, nullable=False)
|
| 82 |
+
stack = Column(JSONB, nullable=False) # JSON: Frontend, Backend, Database, Infrastructure chips with reasons
|
| 83 |
+
database_design = Column(JSONB, nullable=False) # JSON: primary_db, cache, key_tables
|
| 84 |
+
api_design = Column(JSONB, nullable=False) # JSON: style, auth_strategy, core_endpoints
|
| 85 |
+
infrastructure = Column(JSONB, nullable=False) # JSON: cloud_provider, components, scaling_strategy
|
| 86 |
+
reliability = Column(JSONB, nullable=False) # JSON: uptime_target, strategies, backup_dr
|
| 87 |
+
cost_estimates = Column(JSONB, nullable=False) # JSON: 1k, 100k, 1m tier costs and drivers
|
| 88 |
+
diagram = Column(JSONB, nullable=False) # JSON: nodes, edges
|
| 89 |
+
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
| 90 |
+
|
| 91 |
+
project = relationship("Project", back_populates="system_design")
|
| 92 |
+
shared_link = relationship("SharedLink", back_populates="design", uselist=False, cascade="all, delete-orphan")
|
| 93 |
+
|
| 94 |
+
class SharedLink(Base):
|
| 95 |
+
__tablename__ = "shared_links"
|
| 96 |
+
|
| 97 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 98 |
+
design_id = Column(UUID(as_uuid=True), ForeignKey("system_designs.id", ondelete="CASCADE"), unique=True, nullable=False)
|
| 99 |
+
slug = Column(String, unique=True, index=True, nullable=False)
|
| 100 |
+
view_count = Column(Integer, default=0)
|
| 101 |
+
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
| 102 |
+
|
| 103 |
+
design = relationship("SystemDesign", back_populates="shared_link")
|
| 104 |
+
|
| 105 |
+
class GitHubConnection(Base):
|
| 106 |
+
__tablename__ = "github_connections"
|
| 107 |
+
|
| 108 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 109 |
+
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True, nullable=False)
|
| 110 |
+
github_username = Column(String, nullable=False)
|
| 111 |
+
access_token = Column(String, nullable=False) # Encrypted text
|
| 112 |
+
token_expires_at = Column(DateTime(timezone=True), nullable=True)
|
| 113 |
+
connected_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
| 114 |
+
last_used_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 115 |
+
|
| 116 |
+
user = relationship("User", back_populates="github_connection")
|
| 117 |
+
|
| 118 |
+
class BenchmarkData(Base):
|
| 119 |
+
__tablename__ = "benchmark_data"
|
| 120 |
+
|
| 121 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 122 |
+
score_range = Column(String, nullable=False) # e.g., '80-89'
|
| 123 |
+
industry_tag = Column(String, nullable=False) # e.g., 'E-Commerce'
|
| 124 |
+
arch_type = Column(String, nullable=False) # e.g., 'Serverless'
|
| 125 |
+
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
| 126 |
+
|
| 127 |
+
class WebhookEvent(Base):
|
| 128 |
+
__tablename__ = "webhook_events"
|
| 129 |
+
|
| 130 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 131 |
+
event_id = Column(String, unique=True, index=True, nullable=False)
|
| 132 |
+
event_type = Column(String, nullable=False)
|
| 133 |
+
processed_at = Column(DateTime(timezone=True), default=datetime.utcnow)
|
app/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Archvise routers package
|
app/routers/audit.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import json
|
| 3 |
+
import uuid
|
| 4 |
+
import zipfile
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
import redis
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, status
|
| 8 |
+
from fastapi.responses import StreamingResponse, Response
|
| 9 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 10 |
+
from sqlalchemy.future import select
|
| 11 |
+
from app.database import get_db
|
| 12 |
+
from app import models, schemas
|
| 13 |
+
from app.utils.security import get_current_user
|
| 14 |
+
from app.utils.storage import upload_file
|
| 15 |
+
from app.utils.github_client import ALLOWED_EXTENSIONS, redact_env_secrets
|
| 16 |
+
from app.utils.pdf import generate_audit_pdf
|
| 17 |
+
from app.config import settings
|
| 18 |
+
from app.tasks import audit_job_wrapper
|
| 19 |
+
from rq import Queue
|
| 20 |
+
from loguru import logger
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/audit", tags=["Audit"])
|
| 23 |
+
redis_conn = redis.Redis.from_url(settings.REDIS_URL)
|
| 24 |
+
task_queue = Queue("default", connection=redis_conn)
|
| 25 |
+
|
| 26 |
+
@router.post("/upload")
|
| 27 |
+
async def upload_audit(
|
| 28 |
+
project_name: str = Form(...),
|
| 29 |
+
file_type: str = Form("Backend"), # "Backend" | "Frontend" | "Mixed"
|
| 30 |
+
files: List[UploadFile] = File(...),
|
| 31 |
+
current_user: models.User = Depends(get_current_user),
|
| 32 |
+
db: AsyncSession = Depends(get_db)
|
| 33 |
+
):
|
| 34 |
+
# Check credit availability
|
| 35 |
+
if current_user.credits_remaining <= 0 and current_user.plan == "free":
|
| 36 |
+
raise HTTPException(
|
| 37 |
+
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
| 38 |
+
detail="You have used your 2 free analyses this month. Upgrade to continue."
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Validate parameters
|
| 42 |
+
if len(project_name) > 100:
|
| 43 |
+
raise HTTPException(status_code=400, detail="Project name too long (max 100 characters)")
|
| 44 |
+
|
| 45 |
+
extracted_files = []
|
| 46 |
+
total_size = 0
|
| 47 |
+
max_total_size = 50 * 1024 * 1024 # 50MB
|
| 48 |
+
max_files = 20
|
| 49 |
+
|
| 50 |
+
# Process files
|
| 51 |
+
for file in files:
|
| 52 |
+
filename = file.filename
|
| 53 |
+
content_type = file.content_type
|
| 54 |
+
|
| 55 |
+
# Check if zip file
|
| 56 |
+
if filename.endswith(".zip"):
|
| 57 |
+
zip_bytes = await file.read()
|
| 58 |
+
total_size += len(zip_bytes)
|
| 59 |
+
if total_size > max_total_size:
|
| 60 |
+
raise HTTPException(status_code=400, detail="Total size of uploaded files exceeds 50MB limit")
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as z:
|
| 64 |
+
# Zip bomb check
|
| 65 |
+
uncompressed_size = sum(info.file_size for info in z.infolist())
|
| 66 |
+
compressed_size = len(zip_bytes)
|
| 67 |
+
if compressed_size > 0 and (uncompressed_size / compressed_size) > 100:
|
| 68 |
+
raise HTTPException(
|
| 69 |
+
status_code=400,
|
| 70 |
+
detail="Zip bomb detected (compression ratio exceeds 100:1)"
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
for info in z.infolist():
|
| 74 |
+
if info.is_dir():
|
| 75 |
+
continue
|
| 76 |
+
|
| 77 |
+
import os
|
| 78 |
+
_, ext = os.path.splitext(info.filename)
|
| 79 |
+
if ext.lower() in ALLOWED_EXTENSIONS:
|
| 80 |
+
# Ignore lock files and standard folders
|
| 81 |
+
if any(p in info.filename.split("/") for p in ["node_modules", "vendor", "dist", "build", ".git", "venv", ".venv"]):
|
| 82 |
+
continue
|
| 83 |
+
|
| 84 |
+
with z.open(info.filename) as f_in:
|
| 85 |
+
content = f_in.read().decode("utf-8", errors="ignore")
|
| 86 |
+
content = redact_env_secrets(content, info.filename)
|
| 87 |
+
extracted_files.append({
|
| 88 |
+
"filename": info.filename,
|
| 89 |
+
"content": content
|
| 90 |
+
})
|
| 91 |
+
if len(extracted_files) >= max_files:
|
| 92 |
+
break
|
| 93 |
+
except zipfile.BadZipFile:
|
| 94 |
+
raise HTTPException(status_code=400, detail="Invalid zip file uploaded")
|
| 95 |
+
else:
|
| 96 |
+
# Individual file upload
|
| 97 |
+
import os
|
| 98 |
+
_, ext = os.path.splitext(filename)
|
| 99 |
+
if ext.lower() not in ALLOWED_EXTENSIONS:
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
file_bytes = await file.read()
|
| 103 |
+
total_size += len(file_bytes)
|
| 104 |
+
if total_size > max_total_size:
|
| 105 |
+
raise HTTPException(status_code=400, detail="Total size of uploaded files exceeds 50MB limit")
|
| 106 |
+
|
| 107 |
+
content = file_bytes.decode("utf-8", errors="ignore")
|
| 108 |
+
content = redact_env_secrets(content, filename)
|
| 109 |
+
extracted_files.append({
|
| 110 |
+
"filename": filename,
|
| 111 |
+
"content": content
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
if not extracted_files:
|
| 115 |
+
raise HTTPException(
|
| 116 |
+
status_code=400,
|
| 117 |
+
detail="No files uploaded or none matched the accepted file type extensions whitelist"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
if len(extracted_files) > max_files:
|
| 121 |
+
raise HTTPException(status_code=400, detail=f"Exceeded maximum file upload count (max {max_files} files)")
|
| 122 |
+
|
| 123 |
+
# Deduct credit
|
| 124 |
+
if current_user.plan == "free":
|
| 125 |
+
current_user.credits_remaining -= 1
|
| 126 |
+
db.add(current_user)
|
| 127 |
+
|
| 128 |
+
# Create project record in DB
|
| 129 |
+
project = models.Project(
|
| 130 |
+
user_id=current_user.id,
|
| 131 |
+
name=project_name,
|
| 132 |
+
type="audit",
|
| 133 |
+
source="upload",
|
| 134 |
+
status="queued"
|
| 135 |
+
)
|
| 136 |
+
db.add(project)
|
| 137 |
+
await db.commit()
|
| 138 |
+
await db.refresh(project)
|
| 139 |
+
|
| 140 |
+
# Save the files to R2 storage for redundancy / backup
|
| 141 |
+
files_json = json.dumps(extracted_files).encode("utf-8")
|
| 142 |
+
object_key = f"audits/{project.id}/source.json"
|
| 143 |
+
upload_file(files_json, object_key, content_type="application/json")
|
| 144 |
+
|
| 145 |
+
# Queue job
|
| 146 |
+
job_id = str(uuid.uuid4())
|
| 147 |
+
project.job_id = job_id
|
| 148 |
+
db.add(project)
|
| 149 |
+
await db.commit()
|
| 150 |
+
|
| 151 |
+
task_queue.enqueue_call(
|
| 152 |
+
func=audit_job_wrapper,
|
| 153 |
+
args=(str(project.id), job_id, extracted_files, file_type),
|
| 154 |
+
job_id=job_id
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
return {"project_id": str(project.id), "job_id": job_id}
|
| 158 |
+
|
| 159 |
+
@router.post("/github")
|
| 160 |
+
async def github_audit(
|
| 161 |
+
body: schemas.GitHubAuditRequest,
|
| 162 |
+
current_user: models.User = Depends(get_current_user),
|
| 163 |
+
db: AsyncSession = Depends(get_db)
|
| 164 |
+
):
|
| 165 |
+
if current_user.credits_remaining <= 0 and current_user.plan == "free":
|
| 166 |
+
raise HTTPException(
|
| 167 |
+
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
| 168 |
+
detail="You have used your 2 free analyses this month. Upgrade to continue."
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# Check if GitHub is connected
|
| 172 |
+
conn_result = await db.execute(select(models.GitHubConnection).where(models.GitHubConnection.user_id == current_user.id))
|
| 173 |
+
gh_conn = conn_result.scalars().first()
|
| 174 |
+
if not gh_conn:
|
| 175 |
+
raise HTTPException(status_code=400, detail="GitHub account not connected")
|
| 176 |
+
|
| 177 |
+
# Deduct credit
|
| 178 |
+
if current_user.plan == "free":
|
| 179 |
+
current_user.credits_remaining -= 1
|
| 180 |
+
db.add(current_user)
|
| 181 |
+
|
| 182 |
+
project = models.Project(
|
| 183 |
+
user_id=current_user.id,
|
| 184 |
+
name=body.project_name,
|
| 185 |
+
type="audit",
|
| 186 |
+
source="github",
|
| 187 |
+
github_repo=body.repo,
|
| 188 |
+
github_branch=body.branch,
|
| 189 |
+
status="queued"
|
| 190 |
+
)
|
| 191 |
+
db.add(project)
|
| 192 |
+
await db.commit()
|
| 193 |
+
await db.refresh(project)
|
| 194 |
+
|
| 195 |
+
# Retrieve decrypted access token
|
| 196 |
+
from app.utils.github_client import decrypt_token, get_repo_files
|
| 197 |
+
access_token = decrypt_token(gh_conn.access_token)
|
| 198 |
+
|
| 199 |
+
# We fetch files inside the worker to avoid API request blocking
|
| 200 |
+
job_id = str(uuid.uuid4())
|
| 201 |
+
project.job_id = job_id
|
| 202 |
+
db.add(project)
|
| 203 |
+
await db.commit()
|
| 204 |
+
|
| 205 |
+
# Pass repo details. The background worker will call get_repo_files
|
| 206 |
+
# Let's write helper in worker tasks that fetches the files if passed
|
| 207 |
+
def run_github_audit_job(proj_id, job_id, repo, branch, access_token, file_type):
|
| 208 |
+
import asyncio
|
| 209 |
+
files = get_repo_files(access_token, repo, branch)
|
| 210 |
+
from app.tasks import run_audit_task
|
| 211 |
+
asyncio.run(run_audit_task(proj_id, job_id, files, file_type))
|
| 212 |
+
|
| 213 |
+
task_queue.enqueue_call(
|
| 214 |
+
func=run_github_audit_job,
|
| 215 |
+
args=(str(project.id), job_id, body.repo, body.branch, access_token, body.file_type),
|
| 216 |
+
job_id=job_id
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
return {"project_id": str(project.id), "job_id": job_id}
|
| 220 |
+
|
| 221 |
+
@router.get("/stream/{job_id}")
|
| 222 |
+
async def audit_stream(job_id: str):
|
| 223 |
+
async def event_generator():
|
| 224 |
+
import asyncio
|
| 225 |
+
pubsub = redis_conn.pubsub()
|
| 226 |
+
pubsub.subscribe(f"job:{job_id}")
|
| 227 |
+
|
| 228 |
+
# Initial heartbeat
|
| 229 |
+
yield f"data: {json.dumps({'event': 'connecting', 'status': 'active'})}\n\n"
|
| 230 |
+
|
| 231 |
+
try:
|
| 232 |
+
while True:
|
| 233 |
+
message = pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
| 234 |
+
if message:
|
| 235 |
+
data = message["data"].decode("utf-8")
|
| 236 |
+
yield f"data: {data}\n\n"
|
| 237 |
+
|
| 238 |
+
parsed = json.loads(data)
|
| 239 |
+
if parsed.get("event") in ["job_complete", "job_failed"]:
|
| 240 |
+
break
|
| 241 |
+
await asyncio.sleep(0.5)
|
| 242 |
+
except asyncio.CancelledError:
|
| 243 |
+
logger.info(f"SSE connection closed for job {job_id}")
|
| 244 |
+
finally:
|
| 245 |
+
pubsub.unsubscribe(f"job:{job_id}")
|
| 246 |
+
|
| 247 |
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 248 |
+
|
| 249 |
+
@router.get("/history", response_model=List[schemas.ProjectResponse])
|
| 250 |
+
async def get_audit_history(
|
| 251 |
+
page: int = Query(1, ge=1),
|
| 252 |
+
limit: int = Query(10, ge=1, le=100),
|
| 253 |
+
current_user: models.User = Depends(get_current_user),
|
| 254 |
+
db: AsyncSession = Depends(get_db)
|
| 255 |
+
):
|
| 256 |
+
offset = (page - 1) * limit
|
| 257 |
+
result = await db.execute(
|
| 258 |
+
select(models.Project)
|
| 259 |
+
.where(models.Project.user_id == current_user.id, models.Project.type == "audit")
|
| 260 |
+
.order_by(models.Project.created_at.desc())
|
| 261 |
+
.offset(offset)
|
| 262 |
+
.limit(limit)
|
| 263 |
+
)
|
| 264 |
+
return result.scalars().all()
|
| 265 |
+
|
| 266 |
+
@router.get("/{id}", response_model=schemas.AuditReportResponse)
|
| 267 |
+
async def get_audit_report(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
| 268 |
+
result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
|
| 269 |
+
project = result.scalars().first()
|
| 270 |
+
if not project:
|
| 271 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 272 |
+
|
| 273 |
+
report_res = await db.execute(select(models.AuditReport).where(models.AuditReport.project_id == project.id))
|
| 274 |
+
report = report_res.scalars().first()
|
| 275 |
+
if not report:
|
| 276 |
+
raise HTTPException(status_code=404, detail="Report not generated yet")
|
| 277 |
+
|
| 278 |
+
return report
|
| 279 |
+
|
| 280 |
+
@router.delete("/{id}")
|
| 281 |
+
async def delete_audit(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
| 282 |
+
result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
|
| 283 |
+
project = result.scalars().first()
|
| 284 |
+
if not project:
|
| 285 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 286 |
+
|
| 287 |
+
await db.delete(project)
|
| 288 |
+
await db.commit()
|
| 289 |
+
return {"detail": "Project deleted successfully"}
|
| 290 |
+
|
| 291 |
+
@router.get("/{id}/export-pdf")
|
| 292 |
+
async def export_audit_pdf(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
| 293 |
+
result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
|
| 294 |
+
project = result.scalars().first()
|
| 295 |
+
if not project:
|
| 296 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 297 |
+
|
| 298 |
+
report_res = await db.execute(select(models.AuditReport).where(models.AuditReport.project_id == project.id))
|
| 299 |
+
report = report_res.scalars().first()
|
| 300 |
+
if not report:
|
| 301 |
+
raise HTTPException(status_code=404, detail="Report not ready")
|
| 302 |
+
|
| 303 |
+
# Convert report object to dict for PDF render
|
| 304 |
+
report_dict = {
|
| 305 |
+
"overall_score": report.overall_score,
|
| 306 |
+
"confidence": report.confidence,
|
| 307 |
+
"capacity_estimate": report.capacity_estimate,
|
| 308 |
+
"agents": report.agents,
|
| 309 |
+
"score_disclaimer": report.score_disclaimer,
|
| 310 |
+
"files_analyzed": report.files_analyzed
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
pdf_bytes = generate_audit_pdf(report_dict, project.name)
|
| 314 |
+
|
| 315 |
+
filename = f"Archvise-Readiness-Audit-{project.name.replace(' ', '-')}.pdf"
|
| 316 |
+
|
| 317 |
+
# If fallback rendered HTML instead of PDF, return HTML response, else PDF
|
| 318 |
+
if pdf_bytes.startswith(b"<!DOCTYPE html>"):
|
| 319 |
+
return Response(content=pdf_bytes, media_type="text/html")
|
| 320 |
+
|
| 321 |
+
return Response(
|
| 322 |
+
content=pdf_bytes,
|
| 323 |
+
media_type="application/pdf",
|
| 324 |
+
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
| 325 |
+
)
|
app/routers/auth.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy.future import select
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from app.database import get_db
|
| 6 |
+
from app import models, schemas
|
| 7 |
+
from app.utils.firebase import verify_id_token, create_session_cookie
|
| 8 |
+
from app.utils.security import get_current_user
|
| 9 |
+
from app.config import settings
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/auth", tags=["Auth"])
|
| 12 |
+
|
| 13 |
+
@router.post("/verify-token", response_model=schemas.UserResponse)
|
| 14 |
+
async def verify_token(request: Request, response: Response, body: dict, db: AsyncSession = Depends(get_db)):
|
| 15 |
+
id_token = body.get("id_token")
|
| 16 |
+
if not id_token:
|
| 17 |
+
raise HTTPException(status_code=400, detail="ID token is required")
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
# Verify Firebase ID token
|
| 21 |
+
decoded_claims = verify_id_token(id_token)
|
| 22 |
+
except Exception as e:
|
| 23 |
+
raise HTTPException(status_code=401, detail=f"Invalid ID token: {str(e)}")
|
| 24 |
+
|
| 25 |
+
firebase_uid = decoded_claims.get("uid")
|
| 26 |
+
email = decoded_claims.get("email")
|
| 27 |
+
name = decoded_claims.get("name")
|
| 28 |
+
avatar_url = decoded_claims.get("picture")
|
| 29 |
+
|
| 30 |
+
# Find or create user
|
| 31 |
+
result = await db.execute(select(models.User).where(models.User.firebase_uid == firebase_uid))
|
| 32 |
+
user = result.scalars().first()
|
| 33 |
+
|
| 34 |
+
if not user:
|
| 35 |
+
user = models.User(
|
| 36 |
+
firebase_uid=firebase_uid,
|
| 37 |
+
email=email,
|
| 38 |
+
name=name,
|
| 39 |
+
avatar_url=avatar_url,
|
| 40 |
+
plan="pro",
|
| 41 |
+
display_mode="founder",
|
| 42 |
+
credits_remaining=999999,
|
| 43 |
+
credits_reset_at=datetime.utcnow() + timedelta(days=30),
|
| 44 |
+
is_active=True
|
| 45 |
+
)
|
| 46 |
+
db.add(user)
|
| 47 |
+
await db.commit()
|
| 48 |
+
await db.refresh(user)
|
| 49 |
+
elif user.plan != "pro" or user.credits_remaining != 999999:
|
| 50 |
+
user.plan = "pro"
|
| 51 |
+
user.credits_remaining = 999999
|
| 52 |
+
db.add(user)
|
| 53 |
+
await db.commit()
|
| 54 |
+
await db.refresh(user)
|
| 55 |
+
|
| 56 |
+
# Create Firebase Session Cookie (valid for 5 days)
|
| 57 |
+
expires_in = timedelta(days=5)
|
| 58 |
+
expires_in_seconds = int(expires_in.total_seconds())
|
| 59 |
+
try:
|
| 60 |
+
session_cookie = create_session_cookie(id_token, expires_in_seconds=expires_in_seconds)
|
| 61 |
+
except Exception as e:
|
| 62 |
+
raise HTTPException(status_code=401, detail=f"Failed to create session cookie: {str(e)}")
|
| 63 |
+
|
| 64 |
+
# Set httpOnly cookie in response
|
| 65 |
+
response.set_cookie(
|
| 66 |
+
key="archvise_session",
|
| 67 |
+
value=session_cookie,
|
| 68 |
+
max_age=expires_in_seconds,
|
| 69 |
+
expires=expires_in_seconds,
|
| 70 |
+
httponly=True,
|
| 71 |
+
secure=not settings.DEBUG, # True in production, False in local HTTP development
|
| 72 |
+
samesite="lax",
|
| 73 |
+
path="/"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
return user
|
| 77 |
+
|
| 78 |
+
@router.get("/me", response_model=schemas.UserResponse)
|
| 79 |
+
async def get_me(current_user: models.User = Depends(get_current_user)):
|
| 80 |
+
return current_user
|
| 81 |
+
|
| 82 |
+
@router.patch("/mode", response_model=schemas.UserResponse)
|
| 83 |
+
async def update_mode(body: schemas.ModeUpdate, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
| 84 |
+
if body.display_mode not in ["founder", "engineer"]:
|
| 85 |
+
raise HTTPException(status_code=400, detail="Invalid display mode")
|
| 86 |
+
|
| 87 |
+
current_user.display_mode = body.display_mode
|
| 88 |
+
db.add(current_user)
|
| 89 |
+
await db.commit()
|
| 90 |
+
await db.refresh(current_user)
|
| 91 |
+
return current_user
|
| 92 |
+
|
| 93 |
+
@router.post("/logout")
|
| 94 |
+
async def logout(response: Response):
|
| 95 |
+
# Clear the session cookie
|
| 96 |
+
response.delete_cookie(
|
| 97 |
+
key="archvise_session",
|
| 98 |
+
path="/"
|
| 99 |
+
)
|
| 100 |
+
return {"detail": "Successfully logged out"}
|
| 101 |
+
|
| 102 |
+
@router.delete("/account")
|
| 103 |
+
async def delete_account(current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
| 104 |
+
# Soft delete user
|
| 105 |
+
current_user.is_active = False
|
| 106 |
+
|
| 107 |
+
# Cancel Stripe sub if present
|
| 108 |
+
if current_user.stripe_customer_id:
|
| 109 |
+
try:
|
| 110 |
+
import stripe
|
| 111 |
+
stripe.api_key = settings.STRIPE_SECRET_KEY
|
| 112 |
+
# List customer subscriptions
|
| 113 |
+
subs = stripe.Subscription.list(customer=current_user.stripe_customer_id, status="active")
|
| 114 |
+
for sub in subs.data:
|
| 115 |
+
stripe.Subscription.delete(sub.id)
|
| 116 |
+
except Exception:
|
| 117 |
+
pass
|
| 118 |
+
|
| 119 |
+
db.add(current_user)
|
| 120 |
+
await db.commit()
|
| 121 |
+
return {"detail": "Account deactivated and subscriptions cancelled"}
|
app/routers/billing.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import stripe
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
from sqlalchemy.future import select
|
| 6 |
+
from app.database import get_db
|
| 7 |
+
from app import models, schemas
|
| 8 |
+
from app.utils.security import get_current_user
|
| 9 |
+
from app.config import settings
|
| 10 |
+
from loguru import logger
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/billing", tags=["Billing"])
|
| 13 |
+
stripe.api_key = settings.STRIPE_SECRET_KEY
|
| 14 |
+
|
| 15 |
+
@router.post("/create-checkout-session", response_model=schemas.BillingSessionResponse)
|
| 16 |
+
async def create_checkout_session(
|
| 17 |
+
body: schemas.UpgradeRequest,
|
| 18 |
+
current_user: models.User = Depends(get_current_user),
|
| 19 |
+
db: AsyncSession = Depends(get_db)
|
| 20 |
+
):
|
| 21 |
+
if body.plan not in ["starter", "pro"]:
|
| 22 |
+
raise HTTPException(status_code=400, detail="Invalid plan name requested")
|
| 23 |
+
|
| 24 |
+
price_id = settings.STRIPE_STARTER_PRICE_ID if body.plan == "starter" else settings.STRIPE_PRO_PRICE_ID
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
# Create checkout session
|
| 28 |
+
checkout_session = stripe.checkout.Session.create(
|
| 29 |
+
payment_method_types=["card"],
|
| 30 |
+
line_items=[{
|
| 31 |
+
"price": price_id,
|
| 32 |
+
"quantity": 1
|
| 33 |
+
}],
|
| 34 |
+
mode="subscription",
|
| 35 |
+
success_url=f"{settings.FRONTEND_URL}/dashboard?session_id={{CHECKOUT_SESSION_ID}}",
|
| 36 |
+
cancel_url=f"{settings.FRONTEND_URL}/billing/upgrade",
|
| 37 |
+
customer_email=current_user.email,
|
| 38 |
+
metadata={
|
| 39 |
+
"user_id": str(current_user.id),
|
| 40 |
+
"plan": body.plan
|
| 41 |
+
}
|
| 42 |
+
)
|
| 43 |
+
return {"checkout_url": checkout_session.url}
|
| 44 |
+
except Exception as e:
|
| 45 |
+
logger.error(f"Failed to create Stripe checkout session: {e}")
|
| 46 |
+
raise HTTPException(status_code=500, detail=f"Stripe configuration error: {str(e)}")
|
| 47 |
+
|
| 48 |
+
@router.post("/portal")
|
| 49 |
+
async def create_portal_session(
|
| 50 |
+
current_user: models.User = Depends(get_current_user)
|
| 51 |
+
):
|
| 52 |
+
if not current_user.stripe_customer_id:
|
| 53 |
+
raise HTTPException(status_code=400, detail="No active billing profile found for this user.")
|
| 54 |
+
|
| 55 |
+
try:
|
| 56 |
+
portal_session = stripe.billing_portal.Session.create(
|
| 57 |
+
customer=current_user.stripe_customer_id,
|
| 58 |
+
return_url=f"{settings.FRONTEND_URL}/settings"
|
| 59 |
+
)
|
| 60 |
+
return {"portal_url": portal_session.url}
|
| 61 |
+
except Exception as e:
|
| 62 |
+
logger.error(f"Failed to create Stripe portal session: {e}")
|
| 63 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 64 |
+
|
| 65 |
+
@router.post("/webhook")
|
| 66 |
+
async def stripe_webhook(request: Request, db: AsyncSession = Depends(get_db)):
|
| 67 |
+
payload = await request.body()
|
| 68 |
+
sig_header = request.headers.get("stripe-signature")
|
| 69 |
+
|
| 70 |
+
if not sig_header:
|
| 71 |
+
raise HTTPException(status_code=400, detail="Stripe signature missing")
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
event = stripe.Webhook.construct_event(
|
| 75 |
+
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
| 76 |
+
)
|
| 77 |
+
except stripe.error.SignatureVerificationError as e:
|
| 78 |
+
logger.error(f"Stripe signature verification failed: {e}")
|
| 79 |
+
raise HTTPException(status_code=400, detail="Invalid signature")
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.error(f"Stripe webhook payload error: {e}")
|
| 82 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 83 |
+
|
| 84 |
+
event_id = event.id
|
| 85 |
+
event_type = event.type
|
| 86 |
+
|
| 87 |
+
# Prevent duplicate event processing
|
| 88 |
+
ev_res = await db.execute(select(models.WebhookEvent).where(models.WebhookEvent.event_id == event_id))
|
| 89 |
+
if ev_res.scalars().first():
|
| 90 |
+
return {"status": "already_processed"}
|
| 91 |
+
|
| 92 |
+
# Register event
|
| 93 |
+
db_event = models.WebhookEvent(event_id=event_id, event_type=event_type)
|
| 94 |
+
db.add(db_event)
|
| 95 |
+
await db.commit()
|
| 96 |
+
|
| 97 |
+
logger.info(f"Processing Stripe Webhook event: {event_type} (ID: {event_id})")
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
if event_type in ["customer.subscription.created", "customer.subscription.updated"]:
|
| 101 |
+
subscription = event.data.object
|
| 102 |
+
customer_id = subscription.customer
|
| 103 |
+
user_id_str = subscription.metadata.get("user_id")
|
| 104 |
+
plan = subscription.metadata.get("plan", "starter")
|
| 105 |
+
|
| 106 |
+
user = None
|
| 107 |
+
if user_id_str:
|
| 108 |
+
import uuid
|
| 109 |
+
try:
|
| 110 |
+
user_res = await db.execute(select(models.User).where(models.User.id == uuid.UUID(user_id_str)))
|
| 111 |
+
user = user_res.scalars().first()
|
| 112 |
+
except Exception:
|
| 113 |
+
pass
|
| 114 |
+
|
| 115 |
+
if not user:
|
| 116 |
+
# Fallback: Retrieve email from stripe customer object
|
| 117 |
+
customer = stripe.Customer.retrieve(customer_id)
|
| 118 |
+
email = customer.email
|
| 119 |
+
if email:
|
| 120 |
+
user_res = await db.execute(select(models.User).where(models.User.email == email))
|
| 121 |
+
user = user_res.scalars().first()
|
| 122 |
+
|
| 123 |
+
if user:
|
| 124 |
+
user.stripe_customer_id = customer_id
|
| 125 |
+
user.plan = plan
|
| 126 |
+
# Reset credits based on plan
|
| 127 |
+
# Starter: 5 audits + 10 designs, we consolidate to 15 total credits or simply set credits_remaining
|
| 128 |
+
# The user description mentions: "Starter: 5 audits + 10 designs / mo"
|
| 129 |
+
# We can set credits_remaining to 15 or handle distinct types. Since credits_remaining is an integer,
|
| 130 |
+
# let's set it to 15 for starter, and 999999 for pro.
|
| 131 |
+
user.credits_remaining = 15 if plan == "starter" else 999999
|
| 132 |
+
user.credits_reset_at = datetime.utcnow() + timedelta(days=30)
|
| 133 |
+
db.add(user)
|
| 134 |
+
await db.commit()
|
| 135 |
+
logger.info(f"Updated user {user.email} to plan {plan}")
|
| 136 |
+
|
| 137 |
+
elif event_type == "customer.subscription.deleted":
|
| 138 |
+
subscription = event.data.object
|
| 139 |
+
customer_id = subscription.customer
|
| 140 |
+
|
| 141 |
+
user_res = await db.execute(select(models.User).where(models.User.stripe_customer_id == customer_id))
|
| 142 |
+
user = user_res.scalars().first()
|
| 143 |
+
if user:
|
| 144 |
+
user.plan = "free"
|
| 145 |
+
user.credits_remaining = 2
|
| 146 |
+
user.credits_reset_at = datetime.utcnow() + timedelta(days=30)
|
| 147 |
+
db.add(user)
|
| 148 |
+
await db.commit()
|
| 149 |
+
logger.info(f"Downgraded user {user.email} to free plan due to subscription cancellation")
|
| 150 |
+
|
| 151 |
+
except Exception as e:
|
| 152 |
+
logger.error(f"Error handling Stripe webhook event: {e}")
|
| 153 |
+
raise HTTPException(status_code=500, detail="Webhook processing error")
|
| 154 |
+
|
| 155 |
+
return {"status": "success"}
|
app/routers/design.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import uuid
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
import redis
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, status, Response
|
| 6 |
+
from fastapi.responses import StreamingResponse
|
| 7 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
+
from sqlalchemy.future import select
|
| 9 |
+
from app.database import get_db
|
| 10 |
+
from app import models, schemas
|
| 11 |
+
from app.utils.security import get_current_user
|
| 12 |
+
from app.utils.pdf import generate_design_pdf
|
| 13 |
+
from app.config import settings
|
| 14 |
+
from app.tasks import design_job_wrapper
|
| 15 |
+
from rq import Queue
|
| 16 |
+
from loguru import logger
|
| 17 |
+
|
| 18 |
+
router = APIRouter(tags=["Design"])
|
| 19 |
+
redis_conn = redis.Redis.from_url(settings.REDIS_URL)
|
| 20 |
+
task_queue = Queue("default", connection=redis_conn)
|
| 21 |
+
|
| 22 |
+
@router.post("/design/generate")
|
| 23 |
+
async def generate_design(
|
| 24 |
+
body: schemas.SystemDesignRequest,
|
| 25 |
+
current_user: models.User = Depends(get_current_user),
|
| 26 |
+
db: AsyncSession = Depends(get_db)
|
| 27 |
+
):
|
| 28 |
+
if len(body.idea_prompt) < 20:
|
| 29 |
+
raise HTTPException(status_code=400, detail="Idea prompt must be at least 20 characters")
|
| 30 |
+
if len(body.idea_prompt) > 2000:
|
| 31 |
+
raise HTTPException(status_code=400, detail="Idea prompt cannot exceed 2000 characters")
|
| 32 |
+
|
| 33 |
+
# Check credit availability
|
| 34 |
+
if current_user.credits_remaining <= 0 and current_user.plan == "free":
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
| 37 |
+
detail="You have used your 2 free analyses this month. Upgrade to continue."
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Deduct credit
|
| 41 |
+
if current_user.plan == "free":
|
| 42 |
+
current_user.credits_remaining -= 1
|
| 43 |
+
db.add(current_user)
|
| 44 |
+
|
| 45 |
+
# Create project record in DB
|
| 46 |
+
project = models.Project(
|
| 47 |
+
user_id=current_user.id,
|
| 48 |
+
name=f"Design: {body.idea_prompt[:30]}...",
|
| 49 |
+
type="system_design",
|
| 50 |
+
source="upload", # Design doesn't use github as source upload path
|
| 51 |
+
status="queued"
|
| 52 |
+
)
|
| 53 |
+
db.add(project)
|
| 54 |
+
await db.commit()
|
| 55 |
+
await db.refresh(project)
|
| 56 |
+
|
| 57 |
+
# Queue job
|
| 58 |
+
job_id = str(uuid.uuid4())
|
| 59 |
+
project.job_id = job_id
|
| 60 |
+
db.add(project)
|
| 61 |
+
await db.commit()
|
| 62 |
+
|
| 63 |
+
task_queue.enqueue_call(
|
| 64 |
+
func=design_job_wrapper,
|
| 65 |
+
args=(str(project.id), job_id, body.idea_prompt),
|
| 66 |
+
job_id=job_id
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
return {"project_id": str(project.id), "job_id": job_id}
|
| 70 |
+
|
| 71 |
+
@router.get("/design/stream/{job_id}")
|
| 72 |
+
async def design_stream(job_id: str):
|
| 73 |
+
async def event_generator():
|
| 74 |
+
import asyncio
|
| 75 |
+
pubsub = redis_conn.pubsub()
|
| 76 |
+
pubsub.subscribe(f"job:{job_id}")
|
| 77 |
+
|
| 78 |
+
# Initial connection confirm
|
| 79 |
+
yield f"data: {json.dumps({'event': 'connecting', 'status': 'active'})}\n\n"
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
while True:
|
| 83 |
+
message = pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
| 84 |
+
if message:
|
| 85 |
+
data = message["data"].decode("utf-8")
|
| 86 |
+
yield f"data: {data}\n\n"
|
| 87 |
+
|
| 88 |
+
parsed = json.loads(data)
|
| 89 |
+
if parsed.get("event") in ["job_complete", "job_failed"]:
|
| 90 |
+
break
|
| 91 |
+
await asyncio.sleep(0.5)
|
| 92 |
+
except asyncio.CancelledError:
|
| 93 |
+
logger.info(f"SSE connection closed for design job {job_id}")
|
| 94 |
+
finally:
|
| 95 |
+
pubsub.unsubscribe(f"job:{job_id}")
|
| 96 |
+
|
| 97 |
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 98 |
+
|
| 99 |
+
@router.get("/design/history", response_model=List[schemas.ProjectResponse])
|
| 100 |
+
async def get_design_history(
|
| 101 |
+
page: int = Query(1, ge=1),
|
| 102 |
+
limit: int = Query(10, ge=1, le=100),
|
| 103 |
+
current_user: models.User = Depends(get_current_user),
|
| 104 |
+
db: AsyncSession = Depends(get_db)
|
| 105 |
+
):
|
| 106 |
+
offset = (page - 1) * limit
|
| 107 |
+
result = await db.execute(
|
| 108 |
+
select(models.Project)
|
| 109 |
+
.where(models.Project.user_id == current_user.id, models.Project.type == "system_design")
|
| 110 |
+
.order_by(models.Project.created_at.desc())
|
| 111 |
+
.offset(offset)
|
| 112 |
+
.limit(limit)
|
| 113 |
+
)
|
| 114 |
+
return result.scalars().all()
|
| 115 |
+
|
| 116 |
+
@router.get("/design/{id}", response_model=schemas.SystemDesignResponse)
|
| 117 |
+
async def get_system_design(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
| 118 |
+
result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
|
| 119 |
+
project = result.scalars().first()
|
| 120 |
+
if not project:
|
| 121 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 122 |
+
|
| 123 |
+
design_res = await db.execute(select(models.SystemDesign).where(models.SystemDesign.project_id == project.id))
|
| 124 |
+
design = design_res.scalars().first()
|
| 125 |
+
if not design:
|
| 126 |
+
raise HTTPException(status_code=404, detail="System design not generated yet")
|
| 127 |
+
|
| 128 |
+
return design
|
| 129 |
+
|
| 130 |
+
@router.delete("/design/{id}")
|
| 131 |
+
async def delete_design(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
| 132 |
+
result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
|
| 133 |
+
project = result.scalars().first()
|
| 134 |
+
if not project:
|
| 135 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 136 |
+
|
| 137 |
+
await db.delete(project)
|
| 138 |
+
await db.commit()
|
| 139 |
+
return {"detail": "Project deleted successfully"}
|
| 140 |
+
|
| 141 |
+
@router.get("/design/{id}/export-pdf")
|
| 142 |
+
async def export_design_pdf(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
| 143 |
+
result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
|
| 144 |
+
project = result.scalars().first()
|
| 145 |
+
if not project:
|
| 146 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 147 |
+
|
| 148 |
+
design_res = await db.execute(select(models.SystemDesign).where(models.SystemDesign.project_id == project.id))
|
| 149 |
+
design = design_res.scalars().first()
|
| 150 |
+
if not design:
|
| 151 |
+
raise HTTPException(status_code=404, detail="System design not ready")
|
| 152 |
+
|
| 153 |
+
design_dict = {
|
| 154 |
+
"title": design.title,
|
| 155 |
+
"founder_summary": design.founder_summary,
|
| 156 |
+
"engineer_summary": design.engineer_summary,
|
| 157 |
+
"architecture_type": design.architecture_type,
|
| 158 |
+
"stack": design.stack,
|
| 159 |
+
"cost_estimates": design.cost_estimates,
|
| 160 |
+
"reliability": design.reliability
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
pdf_bytes = generate_design_pdf(design_dict)
|
| 164 |
+
|
| 165 |
+
filename = f"Archvise-System-Design-{design.title.replace(' ', '-')}.pdf"
|
| 166 |
+
|
| 167 |
+
if pdf_bytes.startswith(b"<!DOCTYPE html>"):
|
| 168 |
+
return Response(content=pdf_bytes, media_type="text/html")
|
| 169 |
+
|
| 170 |
+
return Response(
|
| 171 |
+
content=pdf_bytes,
|
| 172 |
+
media_type="application/pdf",
|
| 173 |
+
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
@router.post("/design/{id}/share", response_model=schemas.SystemDesignShareResponse)
|
| 177 |
+
async def share_design(id: UUID, current_user: models.User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
| 178 |
+
result = await db.execute(select(models.Project).where(models.Project.id == id, models.Project.user_id == current_user.id))
|
| 179 |
+
project = result.scalars().first()
|
| 180 |
+
if not project:
|
| 181 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 182 |
+
|
| 183 |
+
design_res = await db.execute(select(models.SystemDesign).where(models.SystemDesign.project_id == project.id))
|
| 184 |
+
design = design_res.scalars().first()
|
| 185 |
+
if not design:
|
| 186 |
+
raise HTTPException(status_code=404, detail="System design not generated yet")
|
| 187 |
+
|
| 188 |
+
# Check if shared link already exists
|
| 189 |
+
link_res = await db.execute(select(models.SharedLink).where(models.SharedLink.design_id == design.id))
|
| 190 |
+
shared_link = link_res.scalars().first()
|
| 191 |
+
|
| 192 |
+
if not shared_link:
|
| 193 |
+
# Generate a unique slug
|
| 194 |
+
import secrets
|
| 195 |
+
slug = secrets.token_urlsafe(8)
|
| 196 |
+
shared_link = models.SharedLink(
|
| 197 |
+
design_id=design.id,
|
| 198 |
+
slug=slug
|
| 199 |
+
)
|
| 200 |
+
db.add(shared_link)
|
| 201 |
+
await db.commit()
|
| 202 |
+
await db.refresh(shared_link)
|
| 203 |
+
|
| 204 |
+
share_url = f"{settings.FRONTEND_URL}/shared/{shared_link.slug}"
|
| 205 |
+
return {"slug": shared_link.slug, "share_url": share_url}
|
| 206 |
+
|
| 207 |
+
# Public non-auth endpoint
|
| 208 |
+
@router.get("/shared/{slug}", response_model=schemas.SystemDesignResponse)
|
| 209 |
+
async def get_shared_design(slug: str, db: AsyncSession = Depends(get_db)):
|
| 210 |
+
# Find shared link
|
| 211 |
+
link_res = await db.execute(select(models.SharedLink).where(models.SharedLink.slug == slug))
|
| 212 |
+
shared_link = link_res.scalars().first()
|
| 213 |
+
if not shared_link:
|
| 214 |
+
raise HTTPException(status_code=404, detail="Shared design not found")
|
| 215 |
+
|
| 216 |
+
# Increment view count
|
| 217 |
+
shared_link.view_count += 1
|
| 218 |
+
db.add(shared_link)
|
| 219 |
+
|
| 220 |
+
design_res = await db.execute(select(models.SystemDesign).where(models.SystemDesign.id == shared_link.design_id))
|
| 221 |
+
design = design_res.scalars().first()
|
| 222 |
+
if not design:
|
| 223 |
+
raise HTTPException(status_code=404, detail="Design blueprint not found")
|
| 224 |
+
|
| 225 |
+
await db.commit()
|
| 226 |
+
await db.refresh(design)
|
| 227 |
+
return design
|
app/routers/github.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import secrets
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Request
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
from sqlalchemy.future import select
|
| 5 |
+
from app.database import get_db
|
| 6 |
+
from app import models, schemas
|
| 7 |
+
from app.utils.security import get_current_user
|
| 8 |
+
from app.utils.github_client import (
|
| 9 |
+
get_github_auth_url,
|
| 10 |
+
exchange_code_for_token,
|
| 11 |
+
get_github_user_info,
|
| 12 |
+
encrypt_token,
|
| 13 |
+
get_user_repos,
|
| 14 |
+
decrypt_token
|
| 15 |
+
)
|
| 16 |
+
from app.config import settings
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/github", tags=["GitHub"])
|
| 19 |
+
|
| 20 |
+
@router.get("/auth-url")
|
| 21 |
+
async def github_auth_url(response: Response, current_user: models.User = Depends(get_current_user)):
|
| 22 |
+
# Generate CSRF state
|
| 23 |
+
state = secrets.token_hex(16)
|
| 24 |
+
|
| 25 |
+
# We can store the state in a cookie to verify it on callback
|
| 26 |
+
response.set_cookie(
|
| 27 |
+
key="gh_oauth_state",
|
| 28 |
+
value=state,
|
| 29 |
+
max_age=600, # 10 minutes
|
| 30 |
+
httponly=True,
|
| 31 |
+
secure=not settings.DEBUG,
|
| 32 |
+
samesite="lax",
|
| 33 |
+
path="/"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
auth_url = get_github_auth_url(state)
|
| 37 |
+
return {"auth_url": auth_url}
|
| 38 |
+
|
| 39 |
+
@router.get("/callback")
|
| 40 |
+
async def github_callback(
|
| 41 |
+
request: Request,
|
| 42 |
+
code: str,
|
| 43 |
+
state: str,
|
| 44 |
+
db: AsyncSession = Depends(get_db)
|
| 45 |
+
):
|
| 46 |
+
# Verify CSRF state
|
| 47 |
+
cookie_state = request.cookies.get("gh_oauth_state")
|
| 48 |
+
# In some proxies, state checking might fail. If cookie_state exists, check it.
|
| 49 |
+
if cookie_state and cookie_state != state:
|
| 50 |
+
raise HTTPException(status_code=400, detail="Invalid CSRF state")
|
| 51 |
+
|
| 52 |
+
# Get current user from session cookie since this endpoint is redirect target from GitHub
|
| 53 |
+
session_cookie = request.cookies.get("archvise_session")
|
| 54 |
+
if not session_cookie:
|
| 55 |
+
# Fallback: Redirect user to sign-in page if not authenticated
|
| 56 |
+
raise HTTPException(status_code=401, detail="Authentication session missing")
|
| 57 |
+
|
| 58 |
+
# Retrieve user via firebase session cookie
|
| 59 |
+
from app.utils.firebase import verify_session_cookie
|
| 60 |
+
try:
|
| 61 |
+
decoded_claims = verify_session_cookie(session_cookie, check_revoked=True)
|
| 62 |
+
except Exception:
|
| 63 |
+
raise HTTPException(status_code=401, detail="Invalid session")
|
| 64 |
+
|
| 65 |
+
uid = decoded_claims.get("uid")
|
| 66 |
+
res = await db.execute(select(models.User).where(models.User.firebase_uid == uid))
|
| 67 |
+
user = res.scalars().first()
|
| 68 |
+
if not user:
|
| 69 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 70 |
+
|
| 71 |
+
# Exchange code for access token
|
| 72 |
+
try:
|
| 73 |
+
token_data = await exchange_code_for_token(code)
|
| 74 |
+
except Exception as e:
|
| 75 |
+
raise HTTPException(status_code=400, detail=f"Failed to exchange code: {e}")
|
| 76 |
+
|
| 77 |
+
access_token = token_data.get("access_token")
|
| 78 |
+
if not access_token:
|
| 79 |
+
raise HTTPException(status_code=400, detail="No access token returned from GitHub")
|
| 80 |
+
|
| 81 |
+
# Fetch GitHub username
|
| 82 |
+
try:
|
| 83 |
+
gh_info = await get_github_user_info(access_token)
|
| 84 |
+
gh_username = gh_info.get("login")
|
| 85 |
+
except Exception as e:
|
| 86 |
+
raise HTTPException(status_code=400, detail=f"Failed to fetch user details from GitHub: {e}")
|
| 87 |
+
|
| 88 |
+
# Encrypt the access token
|
| 89 |
+
encrypted_token = encrypt_token(access_token)
|
| 90 |
+
|
| 91 |
+
# Store or update connection
|
| 92 |
+
conn_res = await db.execute(select(models.GitHubConnection).where(models.GitHubConnection.user_id == user.id))
|
| 93 |
+
gh_conn = conn_res.scalars().first()
|
| 94 |
+
|
| 95 |
+
if gh_conn:
|
| 96 |
+
gh_conn.github_username = gh_username
|
| 97 |
+
gh_conn.access_token = encrypted_token
|
| 98 |
+
else:
|
| 99 |
+
gh_conn = models.GitHubConnection(
|
| 100 |
+
user_id=user.id,
|
| 101 |
+
github_username=gh_username,
|
| 102 |
+
access_token=encrypted_token
|
| 103 |
+
)
|
| 104 |
+
db.add(gh_conn)
|
| 105 |
+
|
| 106 |
+
user.github_connected = True
|
| 107 |
+
db.add(user)
|
| 108 |
+
await db.commit()
|
| 109 |
+
|
| 110 |
+
# Clean cookie state
|
| 111 |
+
response = Response()
|
| 112 |
+
response.delete_cookie("gh_oauth_state", path="/")
|
| 113 |
+
|
| 114 |
+
return {"status": "success", "username": gh_username}
|
| 115 |
+
|
| 116 |
+
@router.get("/repos")
|
| 117 |
+
async def github_repos(
|
| 118 |
+
current_user: models.User = Depends(get_current_user),
|
| 119 |
+
db: AsyncSession = Depends(get_db)
|
| 120 |
+
):
|
| 121 |
+
if not current_user.github_connected:
|
| 122 |
+
raise HTTPException(status_code=400, detail="GitHub not connected")
|
| 123 |
+
|
| 124 |
+
conn_res = await db.execute(select(models.GitHubConnection).where(models.GitHubConnection.user_id == current_user.id))
|
| 125 |
+
gh_conn = conn_res.scalars().first()
|
| 126 |
+
if not gh_conn:
|
| 127 |
+
raise HTTPException(status_code=400, detail="GitHub connection credentials not found")
|
| 128 |
+
|
| 129 |
+
access_token = decrypt_token(gh_conn.access_token)
|
| 130 |
+
|
| 131 |
+
# Fetch repositories (offload to helper)
|
| 132 |
+
repos = get_user_repos(access_token)
|
| 133 |
+
return repos
|
| 134 |
+
|
| 135 |
+
@router.post("/disconnect")
|
| 136 |
+
async def github_disconnect(
|
| 137 |
+
current_user: models.User = Depends(get_current_user),
|
| 138 |
+
db: AsyncSession = Depends(get_db)
|
| 139 |
+
):
|
| 140 |
+
conn_res = await db.execute(select(models.GitHubConnection).where(models.GitHubConnection.user_id == current_user.id))
|
| 141 |
+
gh_conn = conn_res.scalars().first()
|
| 142 |
+
|
| 143 |
+
if gh_conn:
|
| 144 |
+
await db.delete(gh_conn)
|
| 145 |
+
|
| 146 |
+
current_user.github_connected = False
|
| 147 |
+
db.add(current_user)
|
| 148 |
+
await db.commit()
|
| 149 |
+
|
| 150 |
+
return {"detail": "GitHub account successfully disconnected"}
|
app/routers/legal.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
|
| 3 |
+
router = APIRouter(prefix="/legal", tags=["Legal"])
|
| 4 |
+
|
| 5 |
+
TERMS_MARKDOWN = """# Terms of Service
|
| 6 |
+
|
| 7 |
+
Last Updated: June 25, 2026
|
| 8 |
+
|
| 9 |
+
Welcome to Archvise. By using our website and services, you agree to comply with and be bound by the following terms and conditions.
|
| 10 |
+
|
| 11 |
+
## 1. Description of Service
|
| 12 |
+
Archvise provides automated AI-assisted code auditing and architecture design systems ("Services"). The Services are provided "as is" and "as available".
|
| 13 |
+
|
| 14 |
+
## 2. Accounts
|
| 15 |
+
To access the Services, you must register for an account using Firebase Authentication. You are responsible for maintaining the confidentiality of your account credentials.
|
| 16 |
+
|
| 17 |
+
## 3. Data Auditing & Deletion
|
| 18 |
+
Archvise respects your privacy and codebase confidentiality:
|
| 19 |
+
- Files uploaded for static analysis are temporarily stored securely.
|
| 20 |
+
- All code contents are permanently deleted immediately after analysis is complete.
|
| 21 |
+
- We request read-only access for GitHub integrations and do not persist code repositories.
|
| 22 |
+
|
| 23 |
+
## 4. Subscriptions & Billing
|
| 24 |
+
Stripe handles payments. Subscriptions are billed on a recurring monthly basis. You can cancel your subscription at any time via the customer billing portal. Refunds are subject to our refund policy.
|
| 25 |
+
|
| 26 |
+
## 5. Limitation of Liability
|
| 27 |
+
Archvise is not liable for runtime failures, server crashes, security breaches, or scaling issues. Our reports provide static analysis recommendations, which should be validated through load testing.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
PRIVACY_MARKDOWN = """# Privacy Policy
|
| 31 |
+
|
| 32 |
+
Last Updated: June 25, 2026
|
| 33 |
+
|
| 34 |
+
At Archvise, protecting your personal data and intellectual property is our priority.
|
| 35 |
+
|
| 36 |
+
## 1. Information We Collect
|
| 37 |
+
- **Profile Data**: Email address, name, and profile picture from Firebase Authentication.
|
| 38 |
+
- **Billing Data**: Stripe customer ID and payment processing status (we do not store raw card details).
|
| 39 |
+
- **GitHub Integration**: Read-only access tokens are encrypted symmetrically at rest.
|
| 40 |
+
- **Uploaded Code**: Temp files are read, scanned, and permanently deleted immediately after job completion.
|
| 41 |
+
|
| 42 |
+
## 2. How We Use Information
|
| 43 |
+
We use your information exclusively to run code audits, compile architecture diagrams, process payments, and track credit usage. We do not sell or share your personal data or codebase files with third parties.
|
| 44 |
+
|
| 45 |
+
## 3. Security
|
| 46 |
+
All database records are stored securely. S3/Supabase upload channels are encrypted. GitHub OAuth access tokens are encrypted at rest.
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
DISCLAIMER_MARKDOWN = """# Audit Score Disclaimer
|
| 50 |
+
|
| 51 |
+
The Production Readiness Score generated by Archvise is a metric calculated from static code scans.
|
| 52 |
+
|
| 53 |
+
It does **not** guarantee actual runtime performance, uptime, or failover protection. Users are strongly encouraged to validate recommendations through synthetic load testing (e.g. k6, Apache JMeter) in staging environments before launching to production.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
@router.get("/terms")
|
| 57 |
+
def get_terms():
|
| 58 |
+
return {"content": TERMS_MARKDOWN}
|
| 59 |
+
|
| 60 |
+
@router.get("/privacy")
|
| 61 |
+
def get_privacy():
|
| 62 |
+
return {"content": PRIVACY_MARKDOWN}
|
| 63 |
+
|
| 64 |
+
@router.get("/disclaimer")
|
| 65 |
+
def get_disclaimer():
|
| 66 |
+
return {"content": DISCLAIMER_MARKDOWN}
|
app/routers/projects.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, Query
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy.future import select
|
| 4 |
+
from typing import List
|
| 5 |
+
from app.database import get_db
|
| 6 |
+
from app import models, schemas
|
| 7 |
+
from app.utils.security import get_current_user
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/projects", tags=["Projects"])
|
| 10 |
+
|
| 11 |
+
@router.get("/recent", response_model=List[schemas.ProjectResponse])
|
| 12 |
+
async def get_recent_projects(
|
| 13 |
+
current_user: models.User = Depends(get_current_user),
|
| 14 |
+
db: AsyncSession = Depends(get_db)
|
| 15 |
+
):
|
| 16 |
+
result = await db.execute(
|
| 17 |
+
select(models.Project)
|
| 18 |
+
.where(models.Project.user_id == current_user.id)
|
| 19 |
+
.order_by(models.Project.created_at.desc())
|
| 20 |
+
.limit(6)
|
| 21 |
+
)
|
| 22 |
+
return result.scalars().all()
|
| 23 |
+
|
| 24 |
+
@router.get("/all", response_model=List[schemas.ProjectResponse])
|
| 25 |
+
async def get_all_projects(
|
| 26 |
+
page: int = Query(1, ge=1),
|
| 27 |
+
limit: int = Query(20, ge=1, le=100),
|
| 28 |
+
current_user: models.User = Depends(get_current_user),
|
| 29 |
+
db: AsyncSession = Depends(get_db)
|
| 30 |
+
):
|
| 31 |
+
offset = (page - 1) * limit
|
| 32 |
+
result = await db.execute(
|
| 33 |
+
select(models.Project)
|
| 34 |
+
.where(models.Project.user_id == current_user.id)
|
| 35 |
+
.order_by(models.Project.created_at.desc())
|
| 36 |
+
.offset(offset)
|
| 37 |
+
.limit(limit)
|
| 38 |
+
)
|
| 39 |
+
return result.scalars().all()
|
app/routers/settings.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from app.database import get_db
|
| 4 |
+
from app import models, schemas
|
| 5 |
+
from app.utils.security import get_current_user
|
| 6 |
+
|
| 7 |
+
router = APIRouter(prefix="/settings", tags=["Settings"])
|
| 8 |
+
|
| 9 |
+
@router.patch("/profile", response_model=schemas.UserResponse)
|
| 10 |
+
async def update_profile(
|
| 11 |
+
body: schemas.UserUpdate,
|
| 12 |
+
current_user: models.User = Depends(get_current_user),
|
| 13 |
+
db: AsyncSession = Depends(get_db)
|
| 14 |
+
):
|
| 15 |
+
if body.name is not None:
|
| 16 |
+
current_user.name = body.name
|
| 17 |
+
|
| 18 |
+
db.add(current_user)
|
| 19 |
+
await db.commit()
|
| 20 |
+
await db.refresh(current_user)
|
| 21 |
+
return current_user
|
| 22 |
+
|
| 23 |
+
@router.get("/stats")
|
| 24 |
+
async def get_stats(current_user: models.User = Depends(get_current_user)):
|
| 25 |
+
return {
|
| 26 |
+
"total_audits": current_user.total_audits,
|
| 27 |
+
"total_designs": current_user.total_designs,
|
| 28 |
+
"credits_remaining": current_user.credits_remaining,
|
| 29 |
+
"credits_reset_at": current_user.credits_reset_at.isoformat() if current_user.credits_reset_at else None
|
| 30 |
+
}
|
app/schemas.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr, Field
|
| 2 |
+
from typing import Optional, List, Dict, Any
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from uuid import UUID
|
| 5 |
+
|
| 6 |
+
# User schemas
|
| 7 |
+
class UserBase(BaseModel):
|
| 8 |
+
email: EmailStr
|
| 9 |
+
name: Optional[str] = None
|
| 10 |
+
avatar_url: Optional[str] = None
|
| 11 |
+
|
| 12 |
+
class UserResponse(UserBase):
|
| 13 |
+
id: UUID
|
| 14 |
+
firebase_uid: str
|
| 15 |
+
plan: str
|
| 16 |
+
display_mode: str
|
| 17 |
+
credits_remaining: int
|
| 18 |
+
credits_reset_at: datetime
|
| 19 |
+
github_connected: bool
|
| 20 |
+
total_audits: int
|
| 21 |
+
total_designs: int
|
| 22 |
+
is_active: bool
|
| 23 |
+
created_at: datetime
|
| 24 |
+
updated_at: datetime
|
| 25 |
+
|
| 26 |
+
class Config:
|
| 27 |
+
from_attributes = True
|
| 28 |
+
|
| 29 |
+
class UserUpdate(BaseModel):
|
| 30 |
+
name: Optional[str] = None
|
| 31 |
+
|
| 32 |
+
class ModeUpdate(BaseModel):
|
| 33 |
+
display_mode: str
|
| 34 |
+
|
| 35 |
+
# Project schemas
|
| 36 |
+
class ProjectBase(BaseModel):
|
| 37 |
+
name: str
|
| 38 |
+
type: str
|
| 39 |
+
source: str
|
| 40 |
+
github_repo: Optional[str] = None
|
| 41 |
+
github_branch: Optional[str] = None
|
| 42 |
+
|
| 43 |
+
class ProjectResponse(ProjectBase):
|
| 44 |
+
id: UUID
|
| 45 |
+
user_id: UUID
|
| 46 |
+
status: str
|
| 47 |
+
job_id: Optional[str] = None
|
| 48 |
+
error_msg: Optional[str] = None
|
| 49 |
+
created_at: datetime
|
| 50 |
+
updated_at: datetime
|
| 51 |
+
|
| 52 |
+
class Config:
|
| 53 |
+
from_attributes = True
|
| 54 |
+
|
| 55 |
+
# Audit schemas
|
| 56 |
+
class ConfidenceSchema(BaseModel):
|
| 57 |
+
level: str
|
| 58 |
+
score: int
|
| 59 |
+
label: str
|
| 60 |
+
based_on: List[str]
|
| 61 |
+
limitations: List[str]
|
| 62 |
+
to_increase_confidence: List[str]
|
| 63 |
+
|
| 64 |
+
class CapacityEstimateSchema(BaseModel):
|
| 65 |
+
safe_range: str
|
| 66 |
+
peak_range: str
|
| 67 |
+
description: str
|
| 68 |
+
reasoning: str
|
| 69 |
+
confidence: str
|
| 70 |
+
|
| 71 |
+
class FindingSchema(BaseModel):
|
| 72 |
+
severity: str
|
| 73 |
+
issue: str
|
| 74 |
+
location: str
|
| 75 |
+
impact: str
|
| 76 |
+
founder_text: str
|
| 77 |
+
engineer_text: str
|
| 78 |
+
|
| 79 |
+
class SuggestionSchema(BaseModel):
|
| 80 |
+
priority: str
|
| 81 |
+
effort: str
|
| 82 |
+
suggestion: str
|
| 83 |
+
founder_text: str
|
| 84 |
+
engineer_text: str
|
| 85 |
+
estimated_score_gain: int
|
| 86 |
+
estimated_capacity_gain: str
|
| 87 |
+
|
| 88 |
+
class AgentReportSchema(BaseModel):
|
| 89 |
+
agent_name: str
|
| 90 |
+
agent_role: str
|
| 91 |
+
score: int
|
| 92 |
+
score_breakdown: Dict[str, int]
|
| 93 |
+
findings: List[FindingSchema]
|
| 94 |
+
suggestions: List[SuggestionSchema]
|
| 95 |
+
|
| 96 |
+
class AgentBreakdownSchema(BaseModel):
|
| 97 |
+
sre: AgentReportSchema
|
| 98 |
+
backend: AgentReportSchema
|
| 99 |
+
infrastructure: AgentReportSchema
|
| 100 |
+
cloud_architect: AgentReportSchema
|
| 101 |
+
|
| 102 |
+
class AuditReportResponse(BaseModel):
|
| 103 |
+
id: UUID
|
| 104 |
+
project_id: UUID
|
| 105 |
+
overall_score: int
|
| 106 |
+
confidence: ConfidenceSchema
|
| 107 |
+
capacity_estimate: CapacityEstimateSchema
|
| 108 |
+
agents: AgentBreakdownSchema
|
| 109 |
+
top_critical_issues: List[Any]
|
| 110 |
+
quick_wins: List[Any]
|
| 111 |
+
benchmark_percentile: Optional[float] = None
|
| 112 |
+
score_disclaimer: str
|
| 113 |
+
files_analyzed: int
|
| 114 |
+
files_skipped: int
|
| 115 |
+
was_truncated: bool
|
| 116 |
+
created_at: datetime
|
| 117 |
+
|
| 118 |
+
class Config:
|
| 119 |
+
from_attributes = True
|
| 120 |
+
|
| 121 |
+
class GitHubAuditRequest(BaseModel):
|
| 122 |
+
project_name: str
|
| 123 |
+
repo: str
|
| 124 |
+
branch: str
|
| 125 |
+
file_type: str = "Backend" # "Backend" | "Frontend" | "Mixed"
|
| 126 |
+
|
| 127 |
+
# System Design schemas
|
| 128 |
+
class SystemDesignResponse(BaseModel):
|
| 129 |
+
id: UUID
|
| 130 |
+
project_id: UUID
|
| 131 |
+
idea_prompt: str
|
| 132 |
+
title: str
|
| 133 |
+
founder_summary: str
|
| 134 |
+
engineer_summary: str
|
| 135 |
+
architecture_type: str
|
| 136 |
+
reasoning: str
|
| 137 |
+
stack: Any
|
| 138 |
+
database_design: Any
|
| 139 |
+
api_design: Any
|
| 140 |
+
infrastructure: Any
|
| 141 |
+
reliability: Any
|
| 142 |
+
cost_estimates: Any
|
| 143 |
+
diagram: Any
|
| 144 |
+
created_at: datetime
|
| 145 |
+
|
| 146 |
+
class Config:
|
| 147 |
+
from_attributes = True
|
| 148 |
+
|
| 149 |
+
class SystemDesignRequest(BaseModel):
|
| 150 |
+
idea_prompt: str
|
| 151 |
+
display_mode: Optional[str] = "founder"
|
| 152 |
+
|
| 153 |
+
class SystemDesignShareResponse(BaseModel):
|
| 154 |
+
slug: str
|
| 155 |
+
share_url: str
|
| 156 |
+
|
| 157 |
+
# Github Schemas
|
| 158 |
+
class GitHubRepoResponse(BaseModel):
|
| 159 |
+
id: int
|
| 160 |
+
name: str
|
| 161 |
+
full_name: str
|
| 162 |
+
html_url: str
|
| 163 |
+
description: Optional[str] = None
|
| 164 |
+
language: Optional[str] = None
|
| 165 |
+
updated_at: str
|
| 166 |
+
private: bool
|
| 167 |
+
|
| 168 |
+
# Stripe Billing
|
| 169 |
+
class BillingSessionResponse(BaseModel):
|
| 170 |
+
checkout_url: str
|
| 171 |
+
|
| 172 |
+
class UpgradeRequest(BaseModel):
|
| 173 |
+
plan: str # 'starter' | 'pro'
|
app/tasks.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
import redis
|
| 5 |
+
from sqlalchemy import update
|
| 6 |
+
from sqlalchemy.future import select
|
| 7 |
+
from app.config import settings
|
| 8 |
+
from app.database import AsyncSessionLocal
|
| 9 |
+
from app import models
|
| 10 |
+
from app.utils.ai import run_code_audit, generate_system_design
|
| 11 |
+
from loguru import logger
|
| 12 |
+
|
| 13 |
+
# Redis client for publishing streaming progress events
|
| 14 |
+
redis_client = redis.Redis.from_url(settings.REDIS_URL)
|
| 15 |
+
|
| 16 |
+
def publish_event(job_id: str, event_type: str, data: dict = None):
|
| 17 |
+
payload = {"event": event_type}
|
| 18 |
+
if data:
|
| 19 |
+
payload.update(data)
|
| 20 |
+
redis_client.publish(f"job:{job_id}", json.dumps(payload))
|
| 21 |
+
|
| 22 |
+
async def run_audit_task(project_id_str: str, job_id: str, files: list, file_type: str):
|
| 23 |
+
logger.info(f"Starting audit task for project {project_id_str}, job {job_id}")
|
| 24 |
+
|
| 25 |
+
# 1. Connecting
|
| 26 |
+
publish_event(job_id, "agent_started", {"agent": "connecting"})
|
| 27 |
+
await asyncio.sleep(2)
|
| 28 |
+
publish_event(job_id, "agent_complete", {"agent": "connecting"})
|
| 29 |
+
|
| 30 |
+
# 2. SRE
|
| 31 |
+
publish_event(job_id, "agent_started", {"agent": "sre"})
|
| 32 |
+
await asyncio.sleep(2)
|
| 33 |
+
|
| 34 |
+
# 3. Backend
|
| 35 |
+
publish_event(job_id, "agent_started", {"agent": "backend"})
|
| 36 |
+
await asyncio.sleep(2)
|
| 37 |
+
|
| 38 |
+
# 4. Infrastructure
|
| 39 |
+
publish_event(job_id, "agent_started", {"agent": "infrastructure"})
|
| 40 |
+
await asyncio.sleep(2)
|
| 41 |
+
|
| 42 |
+
# 5. Cloud Architect
|
| 43 |
+
publish_event(job_id, "agent_started", {"agent": "cloud_architect"})
|
| 44 |
+
|
| 45 |
+
try:
|
| 46 |
+
# Run AI audit analysis
|
| 47 |
+
report_data = await run_code_audit(files, file_type)
|
| 48 |
+
|
| 49 |
+
# Calculate stats for confidence score
|
| 50 |
+
files_count = len(files)
|
| 51 |
+
total_chars = sum(len(f["content"]) for f in files)
|
| 52 |
+
|
| 53 |
+
has_config_files = any(
|
| 54 |
+
any(f["filename"].endswith(ext) for ext in [".json", ".yaml", ".yml", ".toml", ".config", ".env"])
|
| 55 |
+
for f in files
|
| 56 |
+
)
|
| 57 |
+
has_docker = any("Dockerfile" in f["filename"] for f in files)
|
| 58 |
+
|
| 59 |
+
# Confidence score weighting:
|
| 60 |
+
# files>=10(+20), config_files(+15), docker(+15), chars>=50K(+20), not_truncated(+20)
|
| 61 |
+
confidence_score = 0
|
| 62 |
+
if files_count >= 10:
|
| 63 |
+
confidence_score += 20
|
| 64 |
+
if has_config_files:
|
| 65 |
+
confidence_score += 15
|
| 66 |
+
if has_docker:
|
| 67 |
+
confidence_score += 15
|
| 68 |
+
if total_chars >= 50000:
|
| 69 |
+
confidence_score += 20
|
| 70 |
+
confidence_score += 20 # Assuming not truncated by default
|
| 71 |
+
|
| 72 |
+
if confidence_score >= 70:
|
| 73 |
+
confidence_level = "high"
|
| 74 |
+
elif confidence_score >= 40:
|
| 75 |
+
confidence_level = "medium"
|
| 76 |
+
else:
|
| 77 |
+
confidence_level = "low"
|
| 78 |
+
|
| 79 |
+
confidence_dict = {
|
| 80 |
+
"level": confidence_level,
|
| 81 |
+
"score": confidence_score,
|
| 82 |
+
"label": f"{confidence_level.capitalize()} Confidence",
|
| 83 |
+
"based_on": report_data.get("confidence", {}).get("based_on", ["Initial file scan completed"]),
|
| 84 |
+
"limitations": report_data.get("confidence", {}).get("limitations", ["No runtime runtime metrics available"]),
|
| 85 |
+
"to_increase_confidence": report_data.get("confidence", {}).get("to_increase_confidence", ["Upload load test scripts"])
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
# Enforce server-side score calculation
|
| 89 |
+
# overall_score = round(sre*0.30 + backend*0.30 + infra*0.20 + cloud*0.20)
|
| 90 |
+
sre_score = report_data.get("sre_score", 80)
|
| 91 |
+
backend_score = report_data.get("backend_score", 80)
|
| 92 |
+
infra_score = report_data.get("infra_score", 80)
|
| 93 |
+
cloud_score = report_data.get("cloud_score", 80)
|
| 94 |
+
|
| 95 |
+
overall_score = round(
|
| 96 |
+
sre_score * 0.30 +
|
| 97 |
+
backend_score * 0.30 +
|
| 98 |
+
infra_score * 0.20 +
|
| 99 |
+
cloud_score * 0.20
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# Enforce capacity tiers
|
| 103 |
+
# 0-30→50-200 · 31-50→200-1K · 51-65→1K-5K · 66-79→5K-25K · 80-89→25K-100K · 90-100→100K-500K
|
| 104 |
+
if overall_score <= 30:
|
| 105 |
+
safe, peak = "50-200 DAU", "200-1K DAU"
|
| 106 |
+
elif overall_score <= 50:
|
| 107 |
+
safe, peak = "200-1K DAU", "1K-5K DAU"
|
| 108 |
+
elif overall_score <= 65:
|
| 109 |
+
safe, peak = "1K-5K DAU", "5K-25K DAU"
|
| 110 |
+
elif overall_score <= 79:
|
| 111 |
+
safe, peak = "5K-25K DAU", "25K-100K DAU"
|
| 112 |
+
elif overall_score <= 89:
|
| 113 |
+
safe, peak = "25K-100K DAU", "100K-500K DAU"
|
| 114 |
+
else:
|
| 115 |
+
safe, peak = "100K-500K DAU", "500K-2M DAU"
|
| 116 |
+
|
| 117 |
+
capacity_dict = {
|
| 118 |
+
"safe_range": safe,
|
| 119 |
+
"peak_range": peak,
|
| 120 |
+
"description": report_data.get("capacity_estimate", {}).get("description", "DAU estimate based on static architecture scanning."),
|
| 121 |
+
"reasoning": report_data.get("capacity_estimate", {}).get("reasoning", "Database concurrency constraints set maximum peaks."),
|
| 122 |
+
"confidence": report_data.get("capacity_estimate", {}).get("confidence", "Medium")
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
# Prepare Agent reports mapping
|
| 126 |
+
agents_data = report_data.get("agents", {})
|
| 127 |
+
|
| 128 |
+
# Re-set agents overall scores if modified
|
| 129 |
+
if "sre" in agents_data:
|
| 130 |
+
agents_data["sre"]["score"] = sre_score
|
| 131 |
+
if "backend" in agents_data:
|
| 132 |
+
agents_data["backend"]["score"] = backend_score
|
| 133 |
+
if "infrastructure" in agents_data:
|
| 134 |
+
agents_data["infrastructure"]["score"] = infra_score
|
| 135 |
+
if "cloud_architect" in agents_data:
|
| 136 |
+
agents_data["cloud_architect"]["score"] = cloud_score
|
| 137 |
+
|
| 138 |
+
disclaimer = (
|
| 139 |
+
"This score is based on static code analysis only. It does "
|
| 140 |
+
"not guarantee runtime performance. Validate with load testing "
|
| 141 |
+
"before production launch."
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
async with AsyncSessionLocal() as session:
|
| 145 |
+
# Load project
|
| 146 |
+
import uuid
|
| 147 |
+
project_uuid = uuid.UUID(project_id_str)
|
| 148 |
+
proj_result = await session.execute(select(models.Project).where(models.Project.id == project_uuid))
|
| 149 |
+
project = proj_result.scalars().first()
|
| 150 |
+
|
| 151 |
+
if not project:
|
| 152 |
+
raise Exception("Project not found")
|
| 153 |
+
|
| 154 |
+
# Create Audit Report
|
| 155 |
+
audit_report = models.AuditReport(
|
| 156 |
+
project_id=project.id,
|
| 157 |
+
overall_score=overall_score,
|
| 158 |
+
confidence=confidence_dict,
|
| 159 |
+
capacity_estimate=capacity_dict,
|
| 160 |
+
agents=agents_data,
|
| 161 |
+
top_critical_issues=report_data.get("top_critical_issues", []),
|
| 162 |
+
quick_wins=report_data.get("quick_wins", []),
|
| 163 |
+
benchmark_percentile=report_data.get("benchmark_percentile", 75.0),
|
| 164 |
+
score_disclaimer=disclaimer,
|
| 165 |
+
files_analyzed=files_count,
|
| 166 |
+
files_skipped=0,
|
| 167 |
+
was_truncated=False
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
session.add(audit_report)
|
| 171 |
+
project.status = "complete"
|
| 172 |
+
|
| 173 |
+
# Update user stats
|
| 174 |
+
user_result = await session.execute(select(models.User).where(models.User.id == project.user_id))
|
| 175 |
+
user = user_result.scalars().first()
|
| 176 |
+
if user:
|
| 177 |
+
user.total_audits += 1
|
| 178 |
+
session.add(user)
|
| 179 |
+
|
| 180 |
+
await session.commit()
|
| 181 |
+
|
| 182 |
+
# Complete agents progress steps
|
| 183 |
+
publish_event(job_id, "agent_complete", {"agent": "sre"})
|
| 184 |
+
publish_event(job_id, "agent_complete", {"agent": "backend"})
|
| 185 |
+
publish_event(job_id, "agent_complete", {"agent": "infrastructure"})
|
| 186 |
+
publish_event(job_id, "agent_complete", {"agent": "cloud_architect"})
|
| 187 |
+
|
| 188 |
+
publish_event(job_id, "job_complete", {"audit_id": str(project.id)})
|
| 189 |
+
logger.info(f"Audit job {job_id} completed successfully")
|
| 190 |
+
|
| 191 |
+
except Exception as e:
|
| 192 |
+
logger.error(f"Audit task failed: {e}")
|
| 193 |
+
async with AsyncSessionLocal() as session:
|
| 194 |
+
import uuid
|
| 195 |
+
project_uuid = uuid.UUID(project_id_str)
|
| 196 |
+
proj_result = await session.execute(select(models.Project).where(models.Project.id == project_uuid))
|
| 197 |
+
project = proj_result.scalars().first()
|
| 198 |
+
if project:
|
| 199 |
+
project.status = "failed"
|
| 200 |
+
project.error_msg = str(e)
|
| 201 |
+
await session.commit()
|
| 202 |
+
publish_event(job_id, "job_failed", {"error": str(e)})
|
| 203 |
+
|
| 204 |
+
async def run_design_task(project_id_str: str, job_id: str, idea_prompt: str):
|
| 205 |
+
logger.info(f"Starting design task for project {project_id_str}, job {job_id}")
|
| 206 |
+
|
| 207 |
+
# Simulate agent pipeline progress
|
| 208 |
+
publish_event(job_id, "agent_started", {"agent": "connecting"})
|
| 209 |
+
await asyncio.sleep(2)
|
| 210 |
+
publish_event(job_id, "agent_complete", {"agent": "connecting"})
|
| 211 |
+
|
| 212 |
+
publish_event(job_id, "agent_started", {"agent": "sre"})
|
| 213 |
+
await asyncio.sleep(2)
|
| 214 |
+
publish_event(job_id, "agent_complete", {"agent": "sre"})
|
| 215 |
+
|
| 216 |
+
publish_event(job_id, "agent_started", {"agent": "backend"})
|
| 217 |
+
await asyncio.sleep(2)
|
| 218 |
+
publish_event(job_id, "agent_complete", {"agent": "backend"})
|
| 219 |
+
|
| 220 |
+
publish_event(job_id, "agent_started", {"agent": "infrastructure"})
|
| 221 |
+
await asyncio.sleep(2)
|
| 222 |
+
publish_event(job_id, "agent_complete", {"agent": "infrastructure"})
|
| 223 |
+
|
| 224 |
+
publish_event(job_id, "agent_started", {"agent": "cloud_architect"})
|
| 225 |
+
|
| 226 |
+
try:
|
| 227 |
+
# Run AI System Design
|
| 228 |
+
design_data = await generate_system_design(idea_prompt)
|
| 229 |
+
|
| 230 |
+
async with AsyncSessionLocal() as session:
|
| 231 |
+
# Load project
|
| 232 |
+
import uuid
|
| 233 |
+
project_uuid = uuid.UUID(project_id_str)
|
| 234 |
+
proj_result = await session.execute(select(models.Project).where(models.Project.id == project_uuid))
|
| 235 |
+
project = proj_result.scalars().first()
|
| 236 |
+
|
| 237 |
+
if not project:
|
| 238 |
+
raise Exception("Project not found")
|
| 239 |
+
|
| 240 |
+
# Create System Design
|
| 241 |
+
system_design = models.SystemDesign(
|
| 242 |
+
project_id=project.id,
|
| 243 |
+
idea_prompt=idea_prompt,
|
| 244 |
+
title=design_data.get("title", "System Architecture Blueprint"),
|
| 245 |
+
founder_summary=design_data.get("founder_summary", ""),
|
| 246 |
+
engineer_summary=design_data.get("engineer_summary", ""),
|
| 247 |
+
architecture_type=design_data.get("architecture_type", "Hybrid"),
|
| 248 |
+
reasoning=design_data.get("reasoning", ""),
|
| 249 |
+
stack=design_data.get("stack", {}),
|
| 250 |
+
database_design=design_data.get("database_design", {}),
|
| 251 |
+
api_design=design_data.get("api_design", {}),
|
| 252 |
+
infrastructure=design_data.get("infrastructure", {}),
|
| 253 |
+
reliability=design_data.get("reliability", {}),
|
| 254 |
+
cost_estimates=design_data.get("cost_estimates", {}),
|
| 255 |
+
diagram=design_data.get("diagram", {"nodes": [], "edges": []})
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
session.add(system_design)
|
| 259 |
+
project.status = "complete"
|
| 260 |
+
|
| 261 |
+
# Update user stats
|
| 262 |
+
user_result = await session.execute(select(models.User).where(models.User.id == project.user_id))
|
| 263 |
+
user = user_result.scalars().first()
|
| 264 |
+
if user:
|
| 265 |
+
user.total_designs += 1
|
| 266 |
+
session.add(user)
|
| 267 |
+
|
| 268 |
+
await session.commit()
|
| 269 |
+
|
| 270 |
+
publish_event(job_id, "agent_complete", {"agent": "cloud_architect"})
|
| 271 |
+
publish_event(job_id, "job_complete", {"design_id": str(project.id)})
|
| 272 |
+
logger.info(f"Design job {job_id} completed successfully")
|
| 273 |
+
|
| 274 |
+
except Exception as e:
|
| 275 |
+
logger.error(f"Design task failed: {e}")
|
| 276 |
+
async with AsyncSessionLocal() as session:
|
| 277 |
+
import uuid
|
| 278 |
+
project_uuid = uuid.UUID(project_id_str)
|
| 279 |
+
proj_result = await session.execute(select(models.Project).where(models.Project.id == project_uuid))
|
| 280 |
+
project = proj_result.scalars().first()
|
| 281 |
+
if project:
|
| 282 |
+
project.status = "failed"
|
| 283 |
+
project.error_msg = str(e)
|
| 284 |
+
await session.commit()
|
| 285 |
+
publish_event(job_id, "job_failed", {"error": str(e)})
|
| 286 |
+
|
| 287 |
+
# Sync wrappers for RQ
|
| 288 |
+
def audit_job_wrapper(project_id_str: str, job_id: str, files: list, file_type: str):
|
| 289 |
+
asyncio.run(run_audit_task(project_id_str, job_id, files, file_type))
|
| 290 |
+
|
| 291 |
+
def design_job_wrapper(project_id_str: str, job_id: str, idea_prompt: str):
|
| 292 |
+
asyncio.run(run_design_task(project_id_str, job_id, idea_prompt))
|
app/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Archvise utils package
|
app/utils/ai.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from typing import List, Dict, Any, Optional
|
| 4 |
+
import httpx
|
| 5 |
+
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
| 6 |
+
from app.config import settings
|
| 7 |
+
from loguru import logger
|
| 8 |
+
|
| 9 |
+
# Helper to clean up LLM output and extract JSON
|
| 10 |
+
def extract_json(text: str) -> Optional[dict]:
|
| 11 |
+
try:
|
| 12 |
+
# Check if markdown JSON code block exists
|
| 13 |
+
match = re.search(r"```json\s*(.*?)\s*```", text, re.DOTALL)
|
| 14 |
+
if match:
|
| 15 |
+
return json.loads(match.group(1).strip())
|
| 16 |
+
|
| 17 |
+
# Try to find the first '{' and last '}'
|
| 18 |
+
start = text.find("{")
|
| 19 |
+
end = text.rfind("}")
|
| 20 |
+
if start != -1 and end != -1:
|
| 21 |
+
return json.loads(text[start:end+1])
|
| 22 |
+
|
| 23 |
+
return json.loads(text)
|
| 24 |
+
except Exception as e:
|
| 25 |
+
logger.error(f"Failed to extract JSON from text: {e}\nOriginal text: {text[:500]}")
|
| 26 |
+
return None
|
| 27 |
+
|
| 28 |
+
@retry(
|
| 29 |
+
stop=stop_after_attempt(3),
|
| 30 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 31 |
+
retry=retry_if_exception_type(httpx.HTTPError),
|
| 32 |
+
reraise=True
|
| 33 |
+
)
|
| 34 |
+
async def call_nvidia_nim(model: str, api_key: str, system_prompt: str, user_prompt: str) -> str:
|
| 35 |
+
headers = {
|
| 36 |
+
"Authorization": f"Bearer {api_key}",
|
| 37 |
+
"Content-Type": "application/json"
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
payload = {
|
| 41 |
+
"model": model,
|
| 42 |
+
"messages": [
|
| 43 |
+
{"role": "system", "content": system_prompt},
|
| 44 |
+
{"role": "user", "content": user_prompt}
|
| 45 |
+
],
|
| 46 |
+
"temperature": 0.1,
|
| 47 |
+
"max_tokens": 4096
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 51 |
+
response = await client.post(
|
| 52 |
+
f"{settings.NVIDIA_BASE_URL}/chat/completions",
|
| 53 |
+
headers=headers,
|
| 54 |
+
json=payload
|
| 55 |
+
)
|
| 56 |
+
response.raise_for_status()
|
| 57 |
+
result = response.json()
|
| 58 |
+
return result["choices"][0]["message"]["content"]
|
| 59 |
+
|
| 60 |
+
async def run_code_audit(files: List[Dict[str, str]], file_type: str) -> dict:
|
| 61 |
+
# Build file contents representation
|
| 62 |
+
files_payload = ""
|
| 63 |
+
for f in files[:20]: # Limit to 20 files
|
| 64 |
+
files_payload += f"--- FILE: {f['filename']} ---\n{f['content']}\n\n"
|
| 65 |
+
|
| 66 |
+
system_prompt = (
|
| 67 |
+
"You are an elite software architecture and code auditing panel. "
|
| 68 |
+
"Review the codebase and generate a detailed Production Readiness Report. "
|
| 69 |
+
"You MUST return your response as a valid JSON object matching the JSON structure requested. "
|
| 70 |
+
"Do not include any extra chat text. Ensure you generate findings and suggestions twice: "
|
| 71 |
+
"once as plain English business/founder impact (founder_text) and once as technical details (engineer_text). "
|
| 72 |
+
"Enforce naming: NEVER output 'Scalability Score'. Call it 'Production Readiness Score'."
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
user_prompt = f"""
|
| 76 |
+
Analyze this codebase ({file_type} focus):
|
| 77 |
+
{files_payload}
|
| 78 |
+
|
| 79 |
+
Return a JSON object with this exact structure:
|
| 80 |
+
{{
|
| 81 |
+
"sre_score": 85,
|
| 82 |
+
"backend_score": 80,
|
| 83 |
+
"infra_score": 75,
|
| 84 |
+
"cloud_score": 70,
|
| 85 |
+
"confidence": {{
|
| 86 |
+
"level": "high",
|
| 87 |
+
"score": 85,
|
| 88 |
+
"label": "High Confidence",
|
| 89 |
+
"based_on": ["Config files detected", "Database schema present"],
|
| 90 |
+
"limitations": ["No load testing logs"],
|
| 91 |
+
"to_increase_confidence": ["Provide kubernetes deployment files"]
|
| 92 |
+
}},
|
| 93 |
+
"capacity_estimate": {{
|
| 94 |
+
"safe_range": "1K-5K DAU",
|
| 95 |
+
"peak_range": "5K-25K DAU",
|
| 96 |
+
"description": "Calculated based on database connection pooling and lack of caching.",
|
| 97 |
+
"reasoning": "Single database instance with 10 pool size limits concurrency.",
|
| 98 |
+
"confidence": "Medium"
|
| 99 |
+
}},
|
| 100 |
+
"agents": {{
|
| 101 |
+
"sre": {{
|
| 102 |
+
"agent_name": "Alex",
|
| 103 |
+
"agent_role": "SRE Lead",
|
| 104 |
+
"score": 85,
|
| 105 |
+
"score_breakdown": {{"Uptime": 90, "Rate Limiting": 80, "Failover": 85}},
|
| 106 |
+
"findings": [
|
| 107 |
+
{{
|
| 108 |
+
"severity": "high",
|
| 109 |
+
"issue": "Missing rate limit on login endpoint",
|
| 110 |
+
"location": "auth.py:L15",
|
| 111 |
+
"impact": "Vulnerable to brute-force attacks.",
|
| 112 |
+
"founder_text": "An attacker can try millions of passwords a minute, slowing down the service.",
|
| 113 |
+
"engineer_text": "Implement slowapi limit on POST /api/auth/login."
|
| 114 |
+
}}
|
| 115 |
+
],
|
| 116 |
+
"suggestions": [
|
| 117 |
+
{{
|
| 118 |
+
"priority": "high",
|
| 119 |
+
"effort": "low",
|
| 120 |
+
"suggestion": "Add slowapi decorator",
|
| 121 |
+
"founder_text": "Secure your login forms to prevent service disruptions.",
|
| 122 |
+
"engineer_text": "Add @limiter.limit('5/minute') to login route.",
|
| 123 |
+
"estimated_score_gain": 5,
|
| 124 |
+
"estimated_capacity_gain": "+500 users"
|
| 125 |
+
}}
|
| 126 |
+
]
|
| 127 |
+
}},
|
| 128 |
+
"backend": {{
|
| 129 |
+
"agent_name": "Maria",
|
| 130 |
+
"agent_role": "Senior Backend Engineer",
|
| 131 |
+
"score": 80,
|
| 132 |
+
"score_breakdown": {{"Query Performance": 85, "Caching": 70, "Concurrency": 85}},
|
| 133 |
+
"findings": [],
|
| 134 |
+
"suggestions": []
|
| 135 |
+
}},
|
| 136 |
+
"infrastructure": {{
|
| 137 |
+
"agent_name": "James",
|
| 138 |
+
"agent_role": "Infrastructure Engineer",
|
| 139 |
+
"score": 75,
|
| 140 |
+
"score_breakdown": {{"Load Balancing": 70, "CDN": 80, "Containers": 75}},
|
| 141 |
+
"findings": [],
|
| 142 |
+
"suggestions": []
|
| 143 |
+
}},
|
| 144 |
+
"cloud_architect": {{
|
| 145 |
+
"agent_name": "Priya",
|
| 146 |
+
"agent_role": "Cloud Architect",
|
| 147 |
+
"score": 70,
|
| 148 |
+
"score_breakdown": {{"Cost Efficiency": 80, "Scaling": 60}},
|
| 149 |
+
"findings": [],
|
| 150 |
+
"suggestions": [],
|
| 151 |
+
"cost_analysis": {{"monthly_estimate": 120, "saving_opportunities": 30}}
|
| 152 |
+
}}
|
| 153 |
+
}},
|
| 154 |
+
"top_critical_issues": ["No rate limiting", "Unindexed foreign keys"],
|
| 155 |
+
"quick_wins": ["Add indexing on user_id", "Enable Redis caching"],
|
| 156 |
+
"benchmark_percentile": 78
|
| 157 |
+
}}
|
| 158 |
+
"""
|
| 159 |
+
|
| 160 |
+
try:
|
| 161 |
+
raw_response = await call_nvidia_nim(
|
| 162 |
+
model="deepseek-ai/deepseek-r1-0528",
|
| 163 |
+
api_key=settings.NVIDIA_DEEPSEEK_KEY,
|
| 164 |
+
system_prompt=system_prompt,
|
| 165 |
+
user_prompt=user_prompt
|
| 166 |
+
)
|
| 167 |
+
parsed = extract_json(raw_response)
|
| 168 |
+
if parsed:
|
| 169 |
+
return parsed
|
| 170 |
+
except Exception as e:
|
| 171 |
+
logger.error(f"DeepSeek R1 audit failed, falling back to mock structure: {e}")
|
| 172 |
+
|
| 173 |
+
# Fallback structure if LLM fails or is rate-limited
|
| 174 |
+
return get_fallback_audit_report(file_type)
|
| 175 |
+
|
| 176 |
+
async def generate_system_design(prompt: str) -> dict:
|
| 177 |
+
system_prompt = (
|
| 178 |
+
"You are an elite cloud architect. Design a production-ready, highly-available architecture based on the user's requirements. "
|
| 179 |
+
"You MUST return your response as a valid JSON object matching the JSON structure requested. "
|
| 180 |
+
"Do not include any extra text. Make sure you lay out nodes and edges for React Flow. "
|
| 181 |
+
"For nodes, place them at logical positions (x, y coords) so they do not overlap. "
|
| 182 |
+
"Types of nodes: input, output, or default. Nodes should have label property under data."
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
user_prompt = f"""
|
| 186 |
+
Design a system for:
|
| 187 |
+
"{prompt}"
|
| 188 |
+
|
| 189 |
+
Return a JSON object with this exact structure:
|
| 190 |
+
{{
|
| 191 |
+
"title": "Scalable Chess Platform",
|
| 192 |
+
"founder_summary": "A robust, real-time system designed to scale smoothly.",
|
| 193 |
+
"engineer_summary": "A distributed system utilizing WebSockets, Redis pub/sub, and PostgreSQL replication.",
|
| 194 |
+
"architecture_type": "Microservices",
|
| 195 |
+
"reasoning": "WebSockets require sticky sessions or an independent scaling gateway.",
|
| 196 |
+
"stack": {{
|
| 197 |
+
"Frontend": [
|
| 198 |
+
{{"chip": "Next.js", "reason": "Server-side rendering for SEO and fast loading."}}
|
| 199 |
+
],
|
| 200 |
+
"Backend": [
|
| 201 |
+
{{"chip": "FastAPI", "reason": "Async framework ideal for WebSocket connections."}}
|
| 202 |
+
],
|
| 203 |
+
"Database": [
|
| 204 |
+
{{"chip": "PostgreSQL", "reason": "Relational storage with ACID compliance."}},
|
| 205 |
+
{{"chip": "Redis", "reason": "In-memory caching and real-time pub/sub."}}
|
| 206 |
+
],
|
| 207 |
+
"Infrastructure": [
|
| 208 |
+
{{"chip": "AWS ECS", "reason": "Container orchestration with scaling rules."}}
|
| 209 |
+
]
|
| 210 |
+
}},
|
| 211 |
+
"database_design": {{
|
| 212 |
+
"primary_db": "PostgreSQL (RDS multi-AZ)",
|
| 213 |
+
"cache": "Redis cluster",
|
| 214 |
+
"key_tables": [
|
| 215 |
+
{{"table_name": "games", "fields": ["id: uuid", "white_player_id: uuid", "black_player_id: uuid", "pgn: text"]}}
|
| 216 |
+
]
|
| 217 |
+
}},
|
| 218 |
+
"api_design": {{
|
| 219 |
+
"style": "REST + WebSockets",
|
| 220 |
+
"auth_strategy": "JWT / OAuth2",
|
| 221 |
+
"core_endpoints": [
|
| 222 |
+
{{"method": "GET", "path": "/api/games", "description": "Retrieve active games"}}
|
| 223 |
+
]
|
| 224 |
+
}},
|
| 225 |
+
"infrastructure": {{
|
| 226 |
+
"cloud_provider": "AWS",
|
| 227 |
+
"components": ["Route 53", "Application Load Balancer", "ECS Fargate", "ElastiCache"],
|
| 228 |
+
"scaling_strategy": "Scale containers based on CPU utilization > 70%"
|
| 229 |
+
}},
|
| 230 |
+
"reliability": {{
|
| 231 |
+
"uptime_target": "99.99%",
|
| 232 |
+
"strategies": ["Multi-AZ deployment", "Auto-scaling groups", "RDS failover"],
|
| 233 |
+
"backup_dr": "Hourly DB snapshots to S3 with cross-region replication"
|
| 234 |
+
}},
|
| 235 |
+
"cost_estimates": {{
|
| 236 |
+
"1k_users": {{"monthly_cost": "$50", "drivers": "ALB, Small RDS instance"}},
|
| 237 |
+
"100k_users": {{"monthly_cost": "$650", "drivers": "ECS Auto-scaling, Redis Cluster"}},
|
| 238 |
+
"1m_users": {{"monthly_cost": "$4,200", "drivers": "Multi-region traffic, Large DB read-replicas"}}
|
| 239 |
+
}},
|
| 240 |
+
"diagram": {{
|
| 241 |
+
"nodes": [
|
| 242 |
+
{{"id": "1", "type": "input", "data": {{"label": "Client (Web/Mobile)"}}, "position": {{"x": 250, "y": 25}}},
|
| 243 |
+
{{"id": "2", "data": {{"label": "Load Balancer"}}, "position": {{"x": 250, "y": 125}}},
|
| 244 |
+
{{"id": "3", "data": {{"label": "FastAPI WebSockets"}}, "position": {{"x": 150, "y": 225}}},
|
| 245 |
+
{{"id": "4", "data": {{"label": "Next.js SSR"}}, "position": {{"x": 350, "y": 225}}},
|
| 246 |
+
{{"id": "5", "data": {{"label": "Redis (Pub/Sub)"}}, "position": {{"x": 150, "y": 325}}},
|
| 247 |
+
{{"id": "6", "data": {{"label": "PostgreSQL DB"}}, "position": {{"x": 250, "y": 425}}}
|
| 248 |
+
],
|
| 249 |
+
"edges": [
|
| 250 |
+
{{"id": "e1-2", "source": "1", "target": "2", "animated": true}},
|
| 251 |
+
{{"id": "e2-3", "source": "2", "target": "3"}},
|
| 252 |
+
{{"id": "e2-4", "source": "2", "target": "4"}},
|
| 253 |
+
{{"id": "e3-5", "source": "3", "target": "5", "animated": true}},
|
| 254 |
+
{{"id": "e5-6", "source": "5", "target": "6"}},
|
| 255 |
+
{{"id": "e4-6", "source": "4", "target": "6"}}
|
| 256 |
+
]
|
| 257 |
+
}}
|
| 258 |
+
}}
|
| 259 |
+
"""
|
| 260 |
+
|
| 261 |
+
try:
|
| 262 |
+
raw_response = await call_nvidia_nim(
|
| 263 |
+
model="meta/llama-3.1-405b-instruct",
|
| 264 |
+
api_key=settings.NVIDIA_LLAMA_KEY,
|
| 265 |
+
system_prompt=system_prompt,
|
| 266 |
+
user_prompt=user_prompt
|
| 267 |
+
)
|
| 268 |
+
parsed = extract_json(raw_response)
|
| 269 |
+
if parsed:
|
| 270 |
+
return parsed
|
| 271 |
+
except Exception as e:
|
| 272 |
+
logger.error(f"Llama 3.1 405B design failed, falling back to mock structure: {e}")
|
| 273 |
+
|
| 274 |
+
return get_fallback_system_design(prompt)
|
| 275 |
+
|
| 276 |
+
def get_fallback_audit_report(file_type: str) -> dict:
|
| 277 |
+
return {
|
| 278 |
+
"sre_score": 82,
|
| 279 |
+
"backend_score": 78,
|
| 280 |
+
"infra_score": 75,
|
| 281 |
+
"cloud_score": 72,
|
| 282 |
+
"confidence": {
|
| 283 |
+
"level": "medium",
|
| 284 |
+
"score": 55,
|
| 285 |
+
"label": "Medium Confidence",
|
| 286 |
+
"based_on": ["Config files detected", "Files analyzed < 10"],
|
| 287 |
+
"limitations": ["No CD pipeline files", "Missing Dockerfile"],
|
| 288 |
+
"to_increase_confidence": ["Provide a Dockerfile and docker-compose.yml"]
|
| 289 |
+
},
|
| 290 |
+
"capacity_estimate": {
|
| 291 |
+
"safe_range": "1K-5K DAU",
|
| 292 |
+
"peak_range": "5K-25K DAU",
|
| 293 |
+
"description": "Calculated based on standard single-instance hosting with default configurations.",
|
| 294 |
+
"reasoning": "The application lacks connection pooling settings and Redis caching layer.",
|
| 295 |
+
"confidence": "Medium"
|
| 296 |
+
},
|
| 297 |
+
"agents": {
|
| 298 |
+
"sre": {
|
| 299 |
+
"agent_name": "Alex",
|
| 300 |
+
"agent_role": "SRE Lead",
|
| 301 |
+
"score": 82,
|
| 302 |
+
"score_breakdown": {"Uptime": 85, "Rate Limiting": 75, "Failover": 80},
|
| 303 |
+
"findings": [
|
| 304 |
+
{
|
| 305 |
+
"severity": "high",
|
| 306 |
+
"issue": "Missing rate limit on login endpoint",
|
| 307 |
+
"location": "auth.py:L12",
|
| 308 |
+
"impact": "Vulnerable to brute-force attacks.",
|
| 309 |
+
"founder_text": "An attacker can flood your login page to guess credentials, causing a slow site for users.",
|
| 310 |
+
"engineer_text": "Implement slowapi limit on POST /api/auth/login or implement IP-based rate limiting."
|
| 311 |
+
}
|
| 312 |
+
],
|
| 313 |
+
"suggestions": [
|
| 314 |
+
{
|
| 315 |
+
"priority": "high",
|
| 316 |
+
"effort": "low",
|
| 317 |
+
"suggestion": "Add slowapi decorator",
|
| 318 |
+
"founder_text": "Install login protections to keep user data secure.",
|
| 319 |
+
"engineer_text": "Add @limiter.limit('5/minute') to login route.",
|
| 320 |
+
"estimated_score_gain": 5,
|
| 321 |
+
"estimated_capacity_gain": "+500 users"
|
| 322 |
+
}
|
| 323 |
+
]
|
| 324 |
+
},
|
| 325 |
+
"backend": {
|
| 326 |
+
"agent_name": "Maria",
|
| 327 |
+
"agent_role": "Senior Backend Engineer",
|
| 328 |
+
"score": 78,
|
| 329 |
+
"score_breakdown": {"Query Performance": 80, "Caching": 65, "Concurrency": 80},
|
| 330 |
+
"findings": [
|
| 331 |
+
{
|
| 332 |
+
"severity": "medium",
|
| 333 |
+
"issue": "Missing cache for static dashboard stats",
|
| 334 |
+
"location": "dashboard.py:L40",
|
| 335 |
+
"impact": "Redundant database calls on every page refresh.",
|
| 336 |
+
"founder_text": "Every time a user visits their dashboard, your servers re-calculate stats, raising database costs.",
|
| 337 |
+
"engineer_text": "Implement Redis caching with cache.set('stats', data, expire=300)."
|
| 338 |
+
}
|
| 339 |
+
],
|
| 340 |
+
"suggestions": [
|
| 341 |
+
{
|
| 342 |
+
"priority": "medium",
|
| 343 |
+
"effort": "medium",
|
| 344 |
+
"suggestion": "Enable Redis caching",
|
| 345 |
+
"founder_text": "Speed up dashboard load times for users.",
|
| 346 |
+
"engineer_text": "Integrate Redis and cache aggregate DB statistics.",
|
| 347 |
+
"estimated_score_gain": 8,
|
| 348 |
+
"estimated_capacity_gain": "+2,000 users"
|
| 349 |
+
}
|
| 350 |
+
]
|
| 351 |
+
},
|
| 352 |
+
"infrastructure": {
|
| 353 |
+
"agent_name": "James",
|
| 354 |
+
"agent_role": "Infrastructure Engineer",
|
| 355 |
+
"score": 75,
|
| 356 |
+
"score_breakdown": {"Load Balancing": 70, "CDN": 80, "Containers": 75},
|
| 357 |
+
"findings": [],
|
| 358 |
+
"suggestions": []
|
| 359 |
+
},
|
| 360 |
+
"cloud_architect": {
|
| 361 |
+
"agent_name": "Priya",
|
| 362 |
+
"agent_role": "Cloud Architect",
|
| 363 |
+
"score": 72,
|
| 364 |
+
"score_breakdown": {"Cost Efficiency": 80, "Scaling": 65},
|
| 365 |
+
"findings": [],
|
| 366 |
+
"suggestions": [],
|
| 367 |
+
"cost_analysis": {"monthly_estimate": 150, "saving_opportunities": 40}
|
| 368 |
+
}
|
| 369 |
+
},
|
| 370 |
+
"top_critical_issues": ["No rate limiting on login endpoint", "Missing DB connection pooling configurations"],
|
| 371 |
+
"quick_wins": ["Add Redis caching for static pages", "Add rate-limiting annotations"],
|
| 372 |
+
"benchmark_percentile": 72
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
def get_fallback_system_design(prompt: str) -> dict:
|
| 376 |
+
return {
|
| 377 |
+
"title": "Modern Web Application",
|
| 378 |
+
"founder_summary": "A resilient web application architecture tailored for fast rendering and reliable data storage.",
|
| 379 |
+
"engineer_summary": "A typical three-tier architecture utilizing Next.js, FastAPI, PostgreSQL, and Redis.",
|
| 380 |
+
"architecture_type": "Hybrid",
|
| 381 |
+
"reasoning": "Provides the best balance between speed, cost, and developer efficiency for general requirements.",
|
| 382 |
+
"stack": {
|
| 383 |
+
"Frontend": [{"chip": "Next.js", "reason": "Excellent SEO, static page generation, and TypeScript support."}],
|
| 384 |
+
"Backend": [{"chip": "FastAPI", "reason": "Asynchronous python backend framework with auto OpenAPI documentation."}],
|
| 385 |
+
"Database": [
|
| 386 |
+
{"chip": "PostgreSQL", "reason": "Robust and structured data storage."},
|
| 387 |
+
{"chip": "Redis", "reason": "High speed caching layer."}
|
| 388 |
+
],
|
| 389 |
+
"Infrastructure": [{"chip": "Docker", "reason": "Standardized containerization for simplified deployment."}]
|
| 390 |
+
},
|
| 391 |
+
"database_design": {
|
| 392 |
+
"primary_db": "PostgreSQL (with read-replicas)",
|
| 393 |
+
"cache": "Redis (elasticache)",
|
| 394 |
+
"key_tables": [
|
| 395 |
+
{"table_name": "users", "fields": ["id: uuid", "email: varchar(255)", "created_at: timestamp"]}
|
| 396 |
+
]
|
| 397 |
+
},
|
| 398 |
+
"api_design": {
|
| 399 |
+
"style": "RESTful API",
|
| 400 |
+
"auth_strategy": "Bearer JWT tokens",
|
| 401 |
+
"core_endpoints": [
|
| 402 |
+
{"method": "GET", "path": "/api/v1/health", "description": "Health status of services"}
|
| 403 |
+
]
|
| 404 |
+
},
|
| 405 |
+
"infrastructure": {
|
| 406 |
+
"cloud_provider": "AWS / Supabase",
|
| 407 |
+
"components": ["Route53", "Application Load Balancer", "ECS / Docker Container Service", "RDS instance"],
|
| 408 |
+
"scaling_strategy": "Scale-out containers when memory utilisation crosses 80%"
|
| 409 |
+
},
|
| 410 |
+
"reliability": {
|
| 411 |
+
"uptime_target": "99.9%",
|
| 412 |
+
"strategies": ["Multi-AZ database", "Healthchecks", "Daily snapshots"],
|
| 413 |
+
"backup_dr": "Automated daily backups retained for 30 days"
|
| 414 |
+
},
|
| 415 |
+
"cost_estimates": {
|
| 416 |
+
"1k_users": {"monthly_cost": "$30", "drivers": "Single-instance container, shared DB pool"},
|
| 417 |
+
"100k_users": {"monthly_cost": "$550", "drivers": "RDS db.m5 instance, Auto-scaling container cluster"},
|
| 418 |
+
"1m_users": {"monthly_cost": "$3,800", "drivers": "Read-replicas, elasticache cluster, ALB load balancers"}
|
| 419 |
+
},
|
| 420 |
+
"diagram": {
|
| 421 |
+
"nodes": [
|
| 422 |
+
{"id": "1", "type": "input", "data": {"label": "Client App"}, "position": {"x": 250, "y": 25}},
|
| 423 |
+
{"id": "2", "data": {"label": "Web Servers (Next.js)"}, "position": {"x": 250, "y": 150}},
|
| 424 |
+
{"id": "3", "data": {"label": "API Backend (FastAPI)"}, "position": {"x": 250, "y": 275}},
|
| 425 |
+
{"id": "4", "data": {"label": "PostgreSQL DB"}, "position": {"x": 150, "y": 400}},
|
| 426 |
+
{"id": "5", "data": {"label": "Redis Cache"}, "position": {"x": 350, "y": 400}}
|
| 427 |
+
],
|
| 428 |
+
"edges": [
|
| 429 |
+
{"id": "e1-2", "source": "1", "target": "2", "animated": true},
|
| 430 |
+
{"id": "e2-3", "source": "2", "target": "3", "animated": true},
|
| 431 |
+
{"id": "e3-4", "source": "3", "target": "4"},
|
| 432 |
+
{"id": "e3-5", "source": "3", "target": "5"}
|
| 433 |
+
]
|
| 434 |
+
}
|
| 435 |
+
}
|
app/utils/firebase.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import firebase_admin
|
| 2 |
+
from firebase_admin import credentials, auth
|
| 3 |
+
from app.config import settings
|
| 4 |
+
|
| 5 |
+
# Construct the service account dictionary from settings
|
| 6 |
+
firebase_creds_dict = {
|
| 7 |
+
"type": "service_account",
|
| 8 |
+
"project_id": settings.FIREBASE_PROJECT_ID,
|
| 9 |
+
"private_key": settings.FIREBASE_PRIVATE_KEY,
|
| 10 |
+
"client_email": settings.FIREBASE_CLIENT_EMAIL,
|
| 11 |
+
"token_uri": "https://oauth2.googleapis.com/token"
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
# Initialize Firebase Admin SDK if not already initialized
|
| 15 |
+
if not firebase_admin._apps:
|
| 16 |
+
cred = credentials.Certificate(firebase_creds_dict)
|
| 17 |
+
firebase_admin.initialize_app(cred)
|
| 18 |
+
|
| 19 |
+
def verify_id_token(id_token: str):
|
| 20 |
+
return auth.verify_id_token(id_token)
|
| 21 |
+
|
| 22 |
+
def create_session_cookie(id_token: str, expires_in_seconds: int):
|
| 23 |
+
return auth.create_session_cookie(id_token, expires_in=expires_in_seconds)
|
| 24 |
+
|
| 25 |
+
def verify_session_cookie(session_cookie: str, check_revoked: bool = True):
|
| 26 |
+
return auth.verify_session_cookie(session_cookie, check_revoked=check_revoked)
|
app/utils/github_client.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import hashlib
|
| 3 |
+
import re
|
| 4 |
+
from typing import List, Dict, Any
|
| 5 |
+
import httpx
|
| 6 |
+
from github import Github
|
| 7 |
+
from cryptography.fernet import Fernet
|
| 8 |
+
from app.config import settings
|
| 9 |
+
from loguru import logger
|
| 10 |
+
|
| 11 |
+
# Initialize encryption cipher using a key derived from SECRET_KEY
|
| 12 |
+
def _get_fernet_cipher() -> Fernet:
|
| 13 |
+
# Fernet requires a 32-byte base64-encoded key
|
| 14 |
+
key_hash = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
|
| 15 |
+
fernet_key = base64.urlsafe_b64encode(key_hash)
|
| 16 |
+
return Fernet(fernet_key)
|
| 17 |
+
|
| 18 |
+
def encrypt_token(token: str) -> str:
|
| 19 |
+
cipher = _get_fernet_cipher()
|
| 20 |
+
return cipher.encrypt(token.encode()).decode()
|
| 21 |
+
|
| 22 |
+
def decrypt_token(encrypted_token: str) -> str:
|
| 23 |
+
cipher = _get_fernet_cipher()
|
| 24 |
+
return cipher.decrypt(encrypted_token.encode()).decode()
|
| 25 |
+
|
| 26 |
+
def get_github_auth_url(state: str) -> str:
|
| 27 |
+
redirect_uri = f"{settings.FRONTEND_URL}/api/auth/callback"
|
| 28 |
+
return (
|
| 29 |
+
f"https://github.com/login/oauth/authorize"
|
| 30 |
+
f"?client_id={settings.GITHUB_CLIENT_ID}"
|
| 31 |
+
f"&redirect_uri={redirect_uri}"
|
| 32 |
+
f"&state={state}"
|
| 33 |
+
f"&scope=repo,read:org"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
async def exchange_code_for_token(code: str) -> dict:
|
| 37 |
+
url = "https://github.com/login/oauth/access_token"
|
| 38 |
+
headers = {"Accept": "application/json"}
|
| 39 |
+
data = {
|
| 40 |
+
"client_id": settings.GITHUB_CLIENT_ID,
|
| 41 |
+
"client_secret": settings.GITHUB_CLIENT_SECRET,
|
| 42 |
+
"code": code
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
async with httpx.AsyncClient() as client:
|
| 46 |
+
response = await client.post(url, headers=headers, data=data)
|
| 47 |
+
response.raise_for_status()
|
| 48 |
+
return response.json()
|
| 49 |
+
|
| 50 |
+
async def get_github_user_info(access_token: str) -> dict:
|
| 51 |
+
url = "https://api.github.com/user"
|
| 52 |
+
headers = {
|
| 53 |
+
"Authorization": f"token {access_token}",
|
| 54 |
+
"Accept": "application/vnd.github.v3+json"
|
| 55 |
+
}
|
| 56 |
+
async with httpx.AsyncClient() as client:
|
| 57 |
+
response = await client.get(url, headers=headers)
|
| 58 |
+
response.raise_for_status()
|
| 59 |
+
return response.json()
|
| 60 |
+
|
| 61 |
+
def get_user_repos(access_token: str) -> List[Dict[str, Any]]:
|
| 62 |
+
try:
|
| 63 |
+
g = Github(access_token)
|
| 64 |
+
repos = []
|
| 65 |
+
# Get repos sorted by updated date
|
| 66 |
+
for repo in g.get_user().get_repos(sort="updated", direction="desc"):
|
| 67 |
+
repos.append({
|
| 68 |
+
"id": repo.id,
|
| 69 |
+
"name": repo.name,
|
| 70 |
+
"full_name": repo.full_name,
|
| 71 |
+
"html_url": repo.html_url,
|
| 72 |
+
"description": repo.description,
|
| 73 |
+
"language": repo.language,
|
| 74 |
+
"updated_at": repo.updated_at.isoformat() if repo.updated_at else "",
|
| 75 |
+
"private": repo.private
|
| 76 |
+
})
|
| 77 |
+
return repos
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.error(f"Failed to fetch repos from GitHub: {e}")
|
| 80 |
+
return []
|
| 81 |
+
|
| 82 |
+
def get_repo_branches(access_token: str, repo_full_name: str) -> List[str]:
|
| 83 |
+
try:
|
| 84 |
+
g = Github(access_token)
|
| 85 |
+
repo = g.get_repo(repo_full_name)
|
| 86 |
+
return [b.name for b in repo.get_branches()]
|
| 87 |
+
except Exception as e:
|
| 88 |
+
logger.error(f"Failed to fetch branches for {repo_full_name}: {e}")
|
| 89 |
+
return []
|
| 90 |
+
|
| 91 |
+
# File Extensions Whitelist
|
| 92 |
+
ALLOWED_EXTENSIONS = {
|
| 93 |
+
".py", ".js", ".ts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
|
| 94 |
+
".toml", ".html", ".css", ".sql", ".go", ".java", ".rb"
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
def get_repo_files(access_token: str, repo_full_name: str, branch: str) -> List[Dict[str, str]]:
|
| 98 |
+
try:
|
| 99 |
+
g = Github(access_token)
|
| 100 |
+
repo = g.get_repo(repo_full_name)
|
| 101 |
+
|
| 102 |
+
# Get files recursively up to limits
|
| 103 |
+
files = []
|
| 104 |
+
total_size = 0
|
| 105 |
+
max_size = 50 * 1024 * 1024 # 50MB limit
|
| 106 |
+
|
| 107 |
+
# Get git tree recursively
|
| 108 |
+
git_ref = repo.get_git_ref(f"heads/{branch}")
|
| 109 |
+
tree = repo.get_git_tree(git_ref.object.sha, recursive=True)
|
| 110 |
+
|
| 111 |
+
for element in tree.tree:
|
| 112 |
+
if element.type == "blob":
|
| 113 |
+
# Check extension
|
| 114 |
+
import os
|
| 115 |
+
_, ext = os.path.splitext(element.path)
|
| 116 |
+
if ext.lower() in ALLOWED_EXTENSIONS:
|
| 117 |
+
# Ignore standard vendor/lock folders to stay within context limits
|
| 118 |
+
if any(p in element.path.split("/") for p in ["node_modules", "vendor", "dist", "build", ".git", "venv", ".venv"]):
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
+
# Fetch blob content
|
| 122 |
+
blob = repo.get_git_blob(element.sha)
|
| 123 |
+
content = base64.b64decode(blob.content).decode("utf-8", errors="ignore")
|
| 124 |
+
|
| 125 |
+
# Redact secrets
|
| 126 |
+
content = redact_env_secrets(content, element.path)
|
| 127 |
+
|
| 128 |
+
files.append({
|
| 129 |
+
"filename": element.path,
|
| 130 |
+
"content": content
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
total_size += len(content)
|
| 134 |
+
if len(files) >= 20 or total_size >= max_size:
|
| 135 |
+
break
|
| 136 |
+
|
| 137 |
+
return files
|
| 138 |
+
except Exception as e:
|
| 139 |
+
logger.error(f"Failed to fetch files from repo {repo_full_name}: {e}")
|
| 140 |
+
return []
|
| 141 |
+
|
| 142 |
+
def redact_env_secrets(content: str, filename: str) -> str:
|
| 143 |
+
# Redact common secrets patterns
|
| 144 |
+
lines = content.splitlines()
|
| 145 |
+
redacted_lines = []
|
| 146 |
+
|
| 147 |
+
# Check if this is an env file
|
| 148 |
+
is_env = filename.endswith(".env") or filename.endswith(".env.production") or filename.endswith(".env.local")
|
| 149 |
+
|
| 150 |
+
# Generic credential keys regex
|
| 151 |
+
secret_key_re = re.compile(
|
| 152 |
+
r"(password|secret|key|token|auth|pwd|credential|private_key|api_key|access_key)\s*[:=]\s*['\"]?([a-zA-Z0-9_\-\.\=\+]{8,})['\"]?",
|
| 153 |
+
re.IGNORECASE
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
for line in lines:
|
| 157 |
+
if is_env:
|
| 158 |
+
# For env files, redact everything after '='
|
| 159 |
+
if "=" in line and not line.strip().startswith("#"):
|
| 160 |
+
key, val = line.split("=", 1)
|
| 161 |
+
redacted_lines.append(f"{key}=[REDACTED]")
|
| 162 |
+
else:
|
| 163 |
+
redacted_lines.append(line)
|
| 164 |
+
else:
|
| 165 |
+
# Inline check for code files
|
| 166 |
+
match = secret_key_re.search(line)
|
| 167 |
+
if match:
|
| 168 |
+
# Replace matching group containing key value
|
| 169 |
+
val = match.group(2)
|
| 170 |
+
redacted_lines.append(line.replace(val, "[REDACTED]"))
|
| 171 |
+
else:
|
| 172 |
+
redacted_lines.append(line)
|
| 173 |
+
|
| 174 |
+
return "\n".join(redacted_lines)
|
app/utils/pdf.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import tempfile
|
| 3 |
+
from jinja2 import Template
|
| 4 |
+
from weasyprint import HTML
|
| 5 |
+
from loguru import logger
|
| 6 |
+
|
| 7 |
+
# A premium, dark-themed HTML template for SRE / Readiness Audit PDF reports
|
| 8 |
+
AUDIT_PDF_TEMPLATE = """
|
| 9 |
+
<!DOCTYPE html>
|
| 10 |
+
<html>
|
| 11 |
+
<head>
|
| 12 |
+
<meta charset="utf-8">
|
| 13 |
+
<title>Archvise Production Readiness Report</title>
|
| 14 |
+
<style>
|
| 15 |
+
@page {
|
| 16 |
+
size: A4;
|
| 17 |
+
margin: 20mm;
|
| 18 |
+
@bottom-right {
|
| 19 |
+
content: counter(page) " / " counter(pages);
|
| 20 |
+
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
| 21 |
+
font-size: 8pt;
|
| 22 |
+
color: #6B7280;
|
| 23 |
+
}
|
| 24 |
+
@bottom-left {
|
| 25 |
+
content: "Archvise Production Readiness Report";
|
| 26 |
+
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
| 27 |
+
font-size: 8pt;
|
| 28 |
+
color: #6B7280;
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
body {
|
| 32 |
+
background-color: #0A0A0A;
|
| 33 |
+
color: #FFFFFF;
|
| 34 |
+
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
| 35 |
+
margin: 0;
|
| 36 |
+
padding: 0;
|
| 37 |
+
font-size: 10pt;
|
| 38 |
+
line-height: 1.5;
|
| 39 |
+
}
|
| 40 |
+
.header {
|
| 41 |
+
border-bottom: 2px solid #2D2D2D;
|
| 42 |
+
padding-bottom: 15px;
|
| 43 |
+
margin-bottom: 30px;
|
| 44 |
+
}
|
| 45 |
+
.logo {
|
| 46 |
+
font-size: 24px;
|
| 47 |
+
font-weight: bold;
|
| 48 |
+
color: #3B82F6;
|
| 49 |
+
letter-spacing: 1px;
|
| 50 |
+
}
|
| 51 |
+
.subtitle {
|
| 52 |
+
font-size: 10px;
|
| 53 |
+
color: #9CA3AF;
|
| 54 |
+
text-transform: uppercase;
|
| 55 |
+
letter-spacing: 1.5px;
|
| 56 |
+
margin-top: 5px;
|
| 57 |
+
}
|
| 58 |
+
.title-container {
|
| 59 |
+
margin-bottom: 30px;
|
| 60 |
+
}
|
| 61 |
+
h1 {
|
| 62 |
+
font-size: 26px;
|
| 63 |
+
margin: 0 0 10px 0;
|
| 64 |
+
color: #FFFFFF;
|
| 65 |
+
}
|
| 66 |
+
.metadata-grid {
|
| 67 |
+
display: grid;
|
| 68 |
+
grid-template-columns: 1fr 1fr;
|
| 69 |
+
gap: 15px;
|
| 70 |
+
background-color: #141414;
|
| 71 |
+
border: 1px solid #2D2D2D;
|
| 72 |
+
border-radius: 8px;
|
| 73 |
+
padding: 15px;
|
| 74 |
+
margin-bottom: 35px;
|
| 75 |
+
}
|
| 76 |
+
.meta-item {
|
| 77 |
+
font-size: 11px;
|
| 78 |
+
}
|
| 79 |
+
.meta-label {
|
| 80 |
+
color: #9CA3AF;
|
| 81 |
+
font-weight: bold;
|
| 82 |
+
margin-bottom: 3px;
|
| 83 |
+
}
|
| 84 |
+
.meta-value {
|
| 85 |
+
color: #FFFFFF;
|
| 86 |
+
}
|
| 87 |
+
.score-box {
|
| 88 |
+
background: #141414;
|
| 89 |
+
border: 1px solid #2D2D2D;
|
| 90 |
+
border-radius: 8px;
|
| 91 |
+
padding: 25px;
|
| 92 |
+
text-align: center;
|
| 93 |
+
margin-bottom: 35px;
|
| 94 |
+
page-break-inside: avoid;
|
| 95 |
+
}
|
| 96 |
+
.score-value {
|
| 97 |
+
font-size: 64px;
|
| 98 |
+
font-weight: bold;
|
| 99 |
+
color: #3B82F6;
|
| 100 |
+
line-height: 1;
|
| 101 |
+
margin-bottom: 5px;
|
| 102 |
+
}
|
| 103 |
+
.score-label {
|
| 104 |
+
font-size: 12px;
|
| 105 |
+
color: #9CA3AF;
|
| 106 |
+
text-transform: uppercase;
|
| 107 |
+
letter-spacing: 2px;
|
| 108 |
+
}
|
| 109 |
+
.section-title {
|
| 110 |
+
font-size: 16px;
|
| 111 |
+
color: #3B82F6;
|
| 112 |
+
border-bottom: 1px solid #2D2D2D;
|
| 113 |
+
padding-bottom: 8px;
|
| 114 |
+
margin-top: 30px;
|
| 115 |
+
margin-bottom: 15px;
|
| 116 |
+
text-transform: uppercase;
|
| 117 |
+
letter-spacing: 1px;
|
| 118 |
+
}
|
| 119 |
+
.agent-grid {
|
| 120 |
+
margin-bottom: 30px;
|
| 121 |
+
}
|
| 122 |
+
.agent-card {
|
| 123 |
+
background-color: #141414;
|
| 124 |
+
border: 1px solid #2D2D2D;
|
| 125 |
+
border-radius: 8px;
|
| 126 |
+
padding: 15px;
|
| 127 |
+
margin-bottom: 15px;
|
| 128 |
+
page-break-inside: avoid;
|
| 129 |
+
}
|
| 130 |
+
.agent-header {
|
| 131 |
+
display: flex;
|
| 132 |
+
justify-content: space-between;
|
| 133 |
+
align-items: center;
|
| 134 |
+
border-bottom: 1px solid #2D2D2D;
|
| 135 |
+
padding-bottom: 8px;
|
| 136 |
+
margin-bottom: 10px;
|
| 137 |
+
}
|
| 138 |
+
.agent-name {
|
| 139 |
+
font-weight: bold;
|
| 140 |
+
font-size: 12px;
|
| 141 |
+
color: #FFFFFF;
|
| 142 |
+
}
|
| 143 |
+
.agent-role {
|
| 144 |
+
font-size: 10px;
|
| 145 |
+
color: #9CA3AF;
|
| 146 |
+
}
|
| 147 |
+
.agent-score {
|
| 148 |
+
font-weight: bold;
|
| 149 |
+
color: #3B82F6;
|
| 150 |
+
font-size: 14px;
|
| 151 |
+
}
|
| 152 |
+
.finding-item {
|
| 153 |
+
margin-bottom: 10px;
|
| 154 |
+
padding-bottom: 10px;
|
| 155 |
+
border-bottom: 1px dashed #2D2D2D;
|
| 156 |
+
}
|
| 157 |
+
.finding-item:last-child {
|
| 158 |
+
border-bottom: none;
|
| 159 |
+
margin-bottom: 0;
|
| 160 |
+
padding-bottom: 0;
|
| 161 |
+
}
|
| 162 |
+
.severity {
|
| 163 |
+
display: inline-block;
|
| 164 |
+
padding: 2px 6px;
|
| 165 |
+
border-radius: 4px;
|
| 166 |
+
font-size: 8px;
|
| 167 |
+
font-weight: bold;
|
| 168 |
+
text-transform: uppercase;
|
| 169 |
+
margin-right: 5px;
|
| 170 |
+
}
|
| 171 |
+
.severity.critical { background-color: rgba(239,68,68,0.15); color: #EF4444; border: 1px solid #EF4444; }
|
| 172 |
+
.severity.high { background-color: rgba(249,115,22,0.15); color: #F97316; border: 1px solid #F97316; }
|
| 173 |
+
.severity.medium { background-color: rgba(234,179,8,0.15); color: #EAB308; border: 1px solid #EAB308; }
|
| 174 |
+
.severity.low { background-color: rgba(59,130,246,0.15); color: #3B82F6; border: 1px solid #3B82F6; }
|
| 175 |
+
|
| 176 |
+
.finding-title {
|
| 177 |
+
font-weight: bold;
|
| 178 |
+
font-size: 11px;
|
| 179 |
+
color: #FFFFFF;
|
| 180 |
+
margin-bottom: 4px;
|
| 181 |
+
}
|
| 182 |
+
.finding-desc {
|
| 183 |
+
font-size: 10px;
|
| 184 |
+
color: #9CA3AF;
|
| 185 |
+
margin-left: 0;
|
| 186 |
+
}
|
| 187 |
+
.disclaimer {
|
| 188 |
+
font-size: 8pt;
|
| 189 |
+
color: #6B7280;
|
| 190 |
+
background-color: #141414;
|
| 191 |
+
border: 1px solid #2D2D2D;
|
| 192 |
+
border-radius: 6px;
|
| 193 |
+
padding: 15px;
|
| 194 |
+
margin-top: 40px;
|
| 195 |
+
text-align: center;
|
| 196 |
+
page-break-inside: avoid;
|
| 197 |
+
}
|
| 198 |
+
</style>
|
| 199 |
+
</head>
|
| 200 |
+
<body>
|
| 201 |
+
<div class="header">
|
| 202 |
+
<div class="logo">Archvise</div>
|
| 203 |
+
<div class="subtitle">Production Readiness Audit Panel</div>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
+
<div class="title-container">
|
| 207 |
+
<h1>{{ project_name }}</h1>
|
| 208 |
+
<div style="font-size: 12px; color: #9CA3AF;">Generated on {{ date_str }}</div>
|
| 209 |
+
</div>
|
| 210 |
+
|
| 211 |
+
<div class="score-box">
|
| 212 |
+
<div class="score-value">{{ report.overall_score }}</div>
|
| 213 |
+
<div class="score-label">Production Readiness Score</div>
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
<div class="metadata-grid">
|
| 217 |
+
<div class="meta-item">
|
| 218 |
+
<div class="meta-label">Files Analyzed</div>
|
| 219 |
+
<div class="meta-value">{{ report.files_analyzed }}</div>
|
| 220 |
+
</div>
|
| 221 |
+
<div class="meta-item">
|
| 222 |
+
<div class="meta-label">Confidence Level</div>
|
| 223 |
+
<div class="meta-value" style="text-transform: capitalize;">{{ report.confidence.level }} ({{ report.confidence.score }}%)</div>
|
| 224 |
+
</div>
|
| 225 |
+
<div class="meta-item">
|
| 226 |
+
<div class="meta-label">Safe Load Limit</div>
|
| 227 |
+
<div class="meta-value">{{ report.capacity_estimate.safe_range }}</div>
|
| 228 |
+
</div>
|
| 229 |
+
<div class="meta-item">
|
| 230 |
+
<div class="meta-label">Peak Load Limit</div>
|
| 231 |
+
<div class="meta-value">{{ report.capacity_estimate.peak_range }}</div>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
|
| 235 |
+
<div class="section-title">Critical Panel Findings</div>
|
| 236 |
+
<div class="agent-grid">
|
| 237 |
+
{% for agent_id, agent in report.agents.items() %}
|
| 238 |
+
{% if agent.findings %}
|
| 239 |
+
<div class="agent-card">
|
| 240 |
+
<div class="agent-header">
|
| 241 |
+
<div>
|
| 242 |
+
<span class="agent-name">{{ agent.agent_name }}</span>
|
| 243 |
+
<span class="agent-role">· {{ agent.agent_role }}</span>
|
| 244 |
+
</div>
|
| 245 |
+
<span class="agent-score">Score: {{ agent.score }}/100</span>
|
| 246 |
+
</div>
|
| 247 |
+
<div>
|
| 248 |
+
{% for finding in agent.findings %}
|
| 249 |
+
<div class="finding-item">
|
| 250 |
+
<div class="finding-title">
|
| 251 |
+
<span class="severity {{ finding.severity }}">{{ finding.severity }}</span>
|
| 252 |
+
{{ finding.issue }}
|
| 253 |
+
</div>
|
| 254 |
+
<div class="finding-desc">
|
| 255 |
+
<strong>Location:</strong> {{ finding.location }}<br>
|
| 256 |
+
<strong>Details:</strong> {{ finding.engineer_text }}
|
| 257 |
+
</div>
|
| 258 |
+
</div>
|
| 259 |
+
{% endfor %}
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
{% endif %}
|
| 263 |
+
{% endfor %}
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
+
<div class="disclaimer">
|
| 267 |
+
<strong>Disclaimer:</strong> {{ report.score_disclaimer }}
|
| 268 |
+
</div>
|
| 269 |
+
</body>
|
| 270 |
+
</html>
|
| 271 |
+
"""
|
| 272 |
+
|
| 273 |
+
# A premium, dark-themed HTML template for System Design PDF reports
|
| 274 |
+
DESIGN_PDF_TEMPLATE = """
|
| 275 |
+
<!DOCTYPE html>
|
| 276 |
+
<html>
|
| 277 |
+
<head>
|
| 278 |
+
<meta charset="utf-8">
|
| 279 |
+
<title>Archvise System Design Blueprint</title>
|
| 280 |
+
<style>
|
| 281 |
+
@page {
|
| 282 |
+
size: A4;
|
| 283 |
+
margin: 20mm;
|
| 284 |
+
@bottom-right {
|
| 285 |
+
content: counter(page) " / " counter(pages);
|
| 286 |
+
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
| 287 |
+
font-size: 8pt;
|
| 288 |
+
color: #6B7280;
|
| 289 |
+
}
|
| 290 |
+
@bottom-left {
|
| 291 |
+
content: "Archvise System Design Blueprint";
|
| 292 |
+
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
| 293 |
+
font-size: 8pt;
|
| 294 |
+
color: #6B7280;
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
body {
|
| 298 |
+
background-color: #0A0A0A;
|
| 299 |
+
color: #FFFFFF;
|
| 300 |
+
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
| 301 |
+
margin: 0;
|
| 302 |
+
padding: 0;
|
| 303 |
+
font-size: 10pt;
|
| 304 |
+
line-height: 1.5;
|
| 305 |
+
}
|
| 306 |
+
.header {
|
| 307 |
+
border-bottom: 2px solid #2D2D2D;
|
| 308 |
+
padding-bottom: 15px;
|
| 309 |
+
margin-bottom: 30px;
|
| 310 |
+
}
|
| 311 |
+
.logo {
|
| 312 |
+
font-size: 24px;
|
| 313 |
+
font-weight: bold;
|
| 314 |
+
color: #A855F7;
|
| 315 |
+
letter-spacing: 1px;
|
| 316 |
+
}
|
| 317 |
+
.subtitle {
|
| 318 |
+
font-size: 10px;
|
| 319 |
+
color: #9CA3AF;
|
| 320 |
+
text-transform: uppercase;
|
| 321 |
+
letter-spacing: 1.5px;
|
| 322 |
+
margin-top: 5px;
|
| 323 |
+
}
|
| 324 |
+
.title-container {
|
| 325 |
+
margin-bottom: 30px;
|
| 326 |
+
}
|
| 327 |
+
h1 {
|
| 328 |
+
font-size: 26px;
|
| 329 |
+
margin: 0 0 10px 0;
|
| 330 |
+
color: #FFFFFF;
|
| 331 |
+
}
|
| 332 |
+
.metadata-grid {
|
| 333 |
+
display: grid;
|
| 334 |
+
grid-template-columns: 1fr 1fr;
|
| 335 |
+
gap: 15px;
|
| 336 |
+
background-color: #141414;
|
| 337 |
+
border: 1px solid #2D2D2D;
|
| 338 |
+
border-radius: 8px;
|
| 339 |
+
padding: 15px;
|
| 340 |
+
margin-bottom: 35px;
|
| 341 |
+
}
|
| 342 |
+
.meta-item {
|
| 343 |
+
font-size: 11px;
|
| 344 |
+
}
|
| 345 |
+
.meta-label {
|
| 346 |
+
color: #9CA3AF;
|
| 347 |
+
font-weight: bold;
|
| 348 |
+
margin-bottom: 3px;
|
| 349 |
+
}
|
| 350 |
+
.meta-value {
|
| 351 |
+
color: #FFFFFF;
|
| 352 |
+
}
|
| 353 |
+
.summary-box {
|
| 354 |
+
background: #141414;
|
| 355 |
+
border: 1px solid #2D2D2D;
|
| 356 |
+
border-radius: 8px;
|
| 357 |
+
padding: 20px;
|
| 358 |
+
margin-bottom: 35px;
|
| 359 |
+
}
|
| 360 |
+
.summary-title {
|
| 361 |
+
font-weight: bold;
|
| 362 |
+
font-size: 12px;
|
| 363 |
+
color: #A855F7;
|
| 364 |
+
text-transform: uppercase;
|
| 365 |
+
margin-bottom: 8px;
|
| 366 |
+
}
|
| 367 |
+
.section-title {
|
| 368 |
+
font-size: 16px;
|
| 369 |
+
color: #A855F7;
|
| 370 |
+
border-bottom: 1px solid #2D2D2D;
|
| 371 |
+
padding-bottom: 8px;
|
| 372 |
+
margin-top: 30px;
|
| 373 |
+
margin-bottom: 15px;
|
| 374 |
+
text-transform: uppercase;
|
| 375 |
+
letter-spacing: 1px;
|
| 376 |
+
}
|
| 377 |
+
.stack-item {
|
| 378 |
+
margin-bottom: 15px;
|
| 379 |
+
}
|
| 380 |
+
.stack-label {
|
| 381 |
+
font-weight: bold;
|
| 382 |
+
color: #FFFFFF;
|
| 383 |
+
font-size: 11px;
|
| 384 |
+
margin-bottom: 5px;
|
| 385 |
+
}
|
| 386 |
+
.stack-chips {
|
| 387 |
+
display: flex;
|
| 388 |
+
flex-wrap: wrap;
|
| 389 |
+
gap: 8px;
|
| 390 |
+
}
|
| 391 |
+
.chip {
|
| 392 |
+
background-color: #1A1A1A;
|
| 393 |
+
border: 1px solid #2D2D2D;
|
| 394 |
+
border-radius: 4px;
|
| 395 |
+
padding: 4px 8px;
|
| 396 |
+
font-size: 10px;
|
| 397 |
+
color: #FFFFFF;
|
| 398 |
+
}
|
| 399 |
+
.chip-reason {
|
| 400 |
+
font-size: 9px;
|
| 401 |
+
color: #9CA3AF;
|
| 402 |
+
margin-top: 3px;
|
| 403 |
+
}
|
| 404 |
+
.table {
|
| 405 |
+
width: 100%;
|
| 406 |
+
border-collapse: collapse;
|
| 407 |
+
margin-bottom: 30px;
|
| 408 |
+
}
|
| 409 |
+
.table th, .table td {
|
| 410 |
+
border: 1px solid #2D2D2D;
|
| 411 |
+
padding: 10px;
|
| 412 |
+
text-align: left;
|
| 413 |
+
font-size: 10px;
|
| 414 |
+
}
|
| 415 |
+
.table th {
|
| 416 |
+
background-color: #141414;
|
| 417 |
+
color: #9CA3AF;
|
| 418 |
+
font-weight: bold;
|
| 419 |
+
}
|
| 420 |
+
.table td {
|
| 421 |
+
background-color: #0A0A0A;
|
| 422 |
+
}
|
| 423 |
+
</style>
|
| 424 |
+
</head>
|
| 425 |
+
<body>
|
| 426 |
+
<div class="header">
|
| 427 |
+
<div class="logo">Archvise</div>
|
| 428 |
+
<div class="subtitle">System Design Blueprint</div>
|
| 429 |
+
</div>
|
| 430 |
+
|
| 431 |
+
<div class="title-container">
|
| 432 |
+
<h1>{{ design.title }}</h1>
|
| 433 |
+
<div style="font-size: 12px; color: #9CA3AF;">Generated on {{ date_str }}</div>
|
| 434 |
+
</div>
|
| 435 |
+
|
| 436 |
+
<div class="metadata-grid">
|
| 437 |
+
<div class="meta-item">
|
| 438 |
+
<div class="meta-label">Architecture Type</div>
|
| 439 |
+
<div class="meta-value">{{ design.architecture_type }}</div>
|
| 440 |
+
</div>
|
| 441 |
+
<div class="meta-item">
|
| 442 |
+
<div class="meta-label">Uptime Target</div>
|
| 443 |
+
<div class="meta-value">{{ design.reliability.uptime_target }}</div>
|
| 444 |
+
</div>
|
| 445 |
+
</div>
|
| 446 |
+
|
| 447 |
+
<div class="summary-box">
|
| 448 |
+
<div class="summary-title">Executive Summary (Founder Mode)</div>
|
| 449 |
+
<div style="font-size: 11px; color: #9CA3AF;">{{ design.founder_summary }}</div>
|
| 450 |
+
</div>
|
| 451 |
+
|
| 452 |
+
<div class="summary-box">
|
| 453 |
+
<div class="summary-title">Technical Overview (Engineer Mode)</div>
|
| 454 |
+
<div style="font-size: 10px; color: #FFFFFF;">{{ design.engineer_summary }}</div>
|
| 455 |
+
</div>
|
| 456 |
+
|
| 457 |
+
<div class="section-title">Technology Stack Design</div>
|
| 458 |
+
<div class="stack-item">
|
| 459 |
+
{% for layer, items in design.stack.items() %}
|
| 460 |
+
<div class="stack-label">{{ layer }}</div>
|
| 461 |
+
<div style="margin-bottom: 15px;">
|
| 462 |
+
{% for item in items %}
|
| 463 |
+
<div style="margin-bottom: 5px;">
|
| 464 |
+
<span class="chip">{{ item.chip }}</span>
|
| 465 |
+
<div class="chip-reason">{{ item.reason }}</div>
|
| 466 |
+
</div>
|
| 467 |
+
{% endfor %}
|
| 468 |
+
</div>
|
| 469 |
+
{% endfor %}
|
| 470 |
+
</div>
|
| 471 |
+
|
| 472 |
+
<div class="section-title">Monthly Cost Estimates</div>
|
| 473 |
+
<table class="table">
|
| 474 |
+
<thead>
|
| 475 |
+
<tr>
|
| 476 |
+
<th>Scale / Tier</th>
|
| 477 |
+
<th>Estimated Cost</th>
|
| 478 |
+
<th>Main Cost Drivers</th>
|
| 479 |
+
</tr>
|
| 480 |
+
</thead>
|
| 481 |
+
<tbody>
|
| 482 |
+
<tr>
|
| 483 |
+
<td>1,000 active users</td>
|
| 484 |
+
<td>{{ design.cost_estimates['1k_users'].monthly_cost }}</td>
|
| 485 |
+
<td>{{ design.cost_estimates['1k_users'].drivers }}</td>
|
| 486 |
+
</tr>
|
| 487 |
+
<tr>
|
| 488 |
+
<td>100,000 active users</td>
|
| 489 |
+
<td>{{ design.cost_estimates['100k_users'].monthly_cost }}</td>
|
| 490 |
+
<td>{{ design.cost_estimates['100k_users'].drivers }}</td>
|
| 491 |
+
</tr>
|
| 492 |
+
<tr>
|
| 493 |
+
<td>1,000,000 active users</td>
|
| 494 |
+
<td>{{ design.cost_estimates['1m_users'].monthly_cost }}</td>
|
| 495 |
+
<td>{{ design.cost_estimates['1m_users'].drivers }}</td>
|
| 496 |
+
</tr>
|
| 497 |
+
</tbody>
|
| 498 |
+
</table>
|
| 499 |
+
</body>
|
| 500 |
+
</html>
|
| 501 |
+
"""
|
| 502 |
+
|
| 503 |
+
def generate_audit_pdf(report: dict, project_name: str) -> bytes:
|
| 504 |
+
from datetime import datetime
|
| 505 |
+
date_str = datetime.utcnow().strftime("%B %d, %Y")
|
| 506 |
+
|
| 507 |
+
# Render Jinja HTML
|
| 508 |
+
template = Template(AUDIT_PDF_TEMPLATE)
|
| 509 |
+
html_content = template.render(report=report, project_name=project_name, date_str=date_str)
|
| 510 |
+
|
| 511 |
+
try:
|
| 512 |
+
# Create temp file
|
| 513 |
+
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
| 514 |
+
HTML(string=html_content).write_pdf(tmp.name)
|
| 515 |
+
tmp.seek(0)
|
| 516 |
+
pdf_bytes = tmp.read()
|
| 517 |
+
os.unlink(tmp.name)
|
| 518 |
+
return pdf_bytes
|
| 519 |
+
except Exception as e:
|
| 520 |
+
logger.error(f"WeasyPrint PDF generation failed: {e}. Falling back to standard HTML-as-PDF simulation.")
|
| 521 |
+
# If WeasyPrint dependencies fail, return simple HTML content in bytes to avoid crashing
|
| 522 |
+
return html_content.encode("utf-8")
|
| 523 |
+
|
| 524 |
+
def generate_design_pdf(design: dict) -> bytes:
|
| 525 |
+
from datetime import datetime
|
| 526 |
+
date_str = datetime.utcnow().strftime("%B %d, %Y")
|
| 527 |
+
|
| 528 |
+
# Render Jinja HTML
|
| 529 |
+
template = Template(DESIGN_PDF_TEMPLATE)
|
| 530 |
+
html_content = template.render(design=design, date_str=date_str)
|
| 531 |
+
|
| 532 |
+
try:
|
| 533 |
+
# Create temp file
|
| 534 |
+
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
| 535 |
+
HTML(string=html_content).write_pdf(tmp.name)
|
| 536 |
+
tmp.seek(0)
|
| 537 |
+
pdf_bytes = tmp.read()
|
| 538 |
+
os.unlink(tmp.name)
|
| 539 |
+
return pdf_bytes
|
| 540 |
+
except Exception as e:
|
| 541 |
+
logger.error(f"WeasyPrint PDF generation failed: {e}. Falling back to standard HTML-as-PDF simulation.")
|
| 542 |
+
# Return fallback HTML
|
| 543 |
+
return html_content.encode("utf-8")
|
app/utils/security.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Request, Depends, HTTPException, status
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy.future import select
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from app.database import get_db
|
| 6 |
+
from app import models
|
| 7 |
+
from app.utils.firebase import verify_session_cookie
|
| 8 |
+
|
| 9 |
+
async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> models.User:
|
| 10 |
+
session_cookie = request.cookies.get("archvise_session")
|
| 11 |
+
if not session_cookie:
|
| 12 |
+
raise HTTPException(
|
| 13 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 14 |
+
detail="Not authenticated"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
# Verify Firebase Session Cookie
|
| 19 |
+
decoded_claims = verify_session_cookie(session_cookie, check_revoked=True)
|
| 20 |
+
except Exception as e:
|
| 21 |
+
raise HTTPException(
|
| 22 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 23 |
+
detail="Session expired or invalid"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
firebase_uid = decoded_claims.get("uid")
|
| 27 |
+
email = decoded_claims.get("email")
|
| 28 |
+
name = decoded_claims.get("name")
|
| 29 |
+
avatar_url = decoded_claims.get("picture")
|
| 30 |
+
|
| 31 |
+
# Query database for user
|
| 32 |
+
result = await db.execute(select(models.User).where(models.User.firebase_uid == firebase_uid))
|
| 33 |
+
user = result.scalars().first()
|
| 34 |
+
|
| 35 |
+
if not user:
|
| 36 |
+
# Seamless registration: Create new user if not found in db
|
| 37 |
+
user = models.User(
|
| 38 |
+
firebase_uid=firebase_uid,
|
| 39 |
+
email=email,
|
| 40 |
+
name=name,
|
| 41 |
+
avatar_url=avatar_url,
|
| 42 |
+
plan="free",
|
| 43 |
+
display_mode="founder",
|
| 44 |
+
credits_remaining=2,
|
| 45 |
+
credits_reset_at=datetime.utcnow() + timedelta(days=30),
|
| 46 |
+
is_active=True
|
| 47 |
+
)
|
| 48 |
+
db.add(user)
|
| 49 |
+
await db.commit()
|
| 50 |
+
await db.refresh(user)
|
| 51 |
+
|
| 52 |
+
# Check if credits need to be reset
|
| 53 |
+
if user.credits_reset_at and datetime.utcnow() > user.credits_reset_at:
|
| 54 |
+
user.credits_remaining = 2 if user.plan == "free" else (5 if user.plan == "starter" else 999999)
|
| 55 |
+
user.credits_reset_at = datetime.utcnow() + timedelta(days=30)
|
| 56 |
+
db.add(user)
|
| 57 |
+
await db.commit()
|
| 58 |
+
await db.refresh(user)
|
| 59 |
+
|
| 60 |
+
if not user.is_active:
|
| 61 |
+
raise HTTPException(
|
| 62 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 63 |
+
detail="User account is deactivated"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
return user
|
app/utils/storage.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import boto3
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from botocore.config import Config
|
| 4 |
+
from botocore.exceptions import ClientError
|
| 5 |
+
from app.config import settings
|
| 6 |
+
from loguru import logger
|
| 7 |
+
|
| 8 |
+
# Initialize boto3 S3 client for Supabase Storage (S3-compatible)
|
| 9 |
+
s3_client = boto3.client(
|
| 10 |
+
"s3",
|
| 11 |
+
endpoint_url=settings.CLOUDFLARE_R2_ENDPOINT_URL,
|
| 12 |
+
aws_access_key_id=settings.CLOUDFLARE_R2_ACCESS_KEY,
|
| 13 |
+
aws_secret_access_key=settings.CLOUDFLARE_R2_SECRET_KEY,
|
| 14 |
+
config=Config(signature_version="s3v4"),
|
| 15 |
+
region_name=settings.CLOUDFLARE_R2_REGION_NAME
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
def upload_file(file_content: bytes, object_key: str, content_type: str = "application/octet-stream") -> bool:
|
| 19 |
+
try:
|
| 20 |
+
s3_client.put_object(
|
| 21 |
+
Bucket=settings.CLOUDFLARE_R2_BUCKET,
|
| 22 |
+
Key=object_key,
|
| 23 |
+
Body=file_content,
|
| 24 |
+
ContentType=content_type
|
| 25 |
+
)
|
| 26 |
+
logger.info(f"Successfully uploaded {object_key} to {settings.CLOUDFLARE_R2_BUCKET}")
|
| 27 |
+
return True
|
| 28 |
+
except ClientError as e:
|
| 29 |
+
logger.error(f"Failed to upload {object_key} to S3: {e}")
|
| 30 |
+
return False
|
| 31 |
+
|
| 32 |
+
def get_file(object_key: str) -> Optional[bytes]:
|
| 33 |
+
try:
|
| 34 |
+
response = s3_client.get_object(
|
| 35 |
+
Bucket=settings.CLOUDFLARE_R2_BUCKET,
|
| 36 |
+
Key=object_key
|
| 37 |
+
)
|
| 38 |
+
return response["Body"].read()
|
| 39 |
+
except ClientError as e:
|
| 40 |
+
logger.error(f"Failed to download {object_key} from S3: {e}")
|
| 41 |
+
return None
|
| 42 |
+
|
| 43 |
+
def generate_download_url(object_key: str, expires_in: int = 3600) -> str:
|
| 44 |
+
try:
|
| 45 |
+
url = s3_client.generate_presigned_url(
|
| 46 |
+
"get_object",
|
| 47 |
+
Params={
|
| 48 |
+
"Bucket": settings.CLOUDFLARE_R2_BUCKET,
|
| 49 |
+
"Key": object_key
|
| 50 |
+
},
|
| 51 |
+
ExpiresIn=expires_in
|
| 52 |
+
)
|
| 53 |
+
return url
|
| 54 |
+
except ClientError as e:
|
| 55 |
+
logger.error(f"Failed to generate presigned URL for {object_key}: {e}")
|
| 56 |
+
# Fallback to direct construction if presigned fails
|
| 57 |
+
return f"{settings.CLOUDFLARE_R2_ENDPOINT_URL}/{settings.CLOUDFLARE_R2_BUCKET}/{object_key}"
|
app/worker.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import redis
|
| 2 |
+
from rq import Queue, Connection, Worker
|
| 3 |
+
from app.config import settings
|
| 4 |
+
from loguru import logger
|
| 5 |
+
|
| 6 |
+
listen = ["default"]
|
| 7 |
+
conn = redis.Redis.from_url(settings.REDIS_URL)
|
| 8 |
+
|
| 9 |
+
def run_worker():
|
| 10 |
+
logger.info(f"Starting RQ worker connecting to Redis at {settings.REDIS_URL}")
|
| 11 |
+
with Connection(conn):
|
| 12 |
+
worker = Worker(list(map(Queue, listen)))
|
| 13 |
+
worker.work()
|
| 14 |
+
|
| 15 |
+
if __name__ == "__main__":
|
| 16 |
+
run_worker()
|
railway.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://railway.app/railway.schema.json",
|
| 3 |
+
"build": {
|
| 4 |
+
"builder": "DOCKERFILE",
|
| 5 |
+
"dockerfilePath": "Dockerfile"
|
| 6 |
+
},
|
| 7 |
+
"deploy": {
|
| 8 |
+
"numReplicas": 1,
|
| 9 |
+
"sleep": false,
|
| 10 |
+
"restartPolicyType": "ON_FAILURE"
|
| 11 |
+
}
|
| 12 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn==0.30.1
|
| 3 |
+
sqlalchemy[asyncio]==2.0.30
|
| 4 |
+
asyncpg==0.29.0
|
| 5 |
+
alembic==1.13.1
|
| 6 |
+
redis==5.0.4
|
| 7 |
+
rq==1.16.1
|
| 8 |
+
firebase-admin==6.5.0
|
| 9 |
+
stripe==9.9.0
|
| 10 |
+
boto3==1.34.122
|
| 11 |
+
PyGithub==2.3.0
|
| 12 |
+
httpx==0.27.0
|
| 13 |
+
slowapi==0.1.9
|
| 14 |
+
weasyprint==62.1
|
| 15 |
+
jinja2==3.1.4
|
| 16 |
+
tenacity==8.3.0
|
| 17 |
+
sentry-sdk[fastapi]==2.5.1
|
| 18 |
+
loguru==0.7.2
|
| 19 |
+
pydantic==2.7.3
|
| 20 |
+
pydantic-settings==2.3.1
|
| 21 |
+
python-multipart==0.0.9
|
| 22 |
+
pyjwt[crypto]==2.8.0
|
| 23 |
+
python-dotenv==1.0.1
|
| 24 |
+
psycopg2-binary==2.9.9
|
| 25 |
+
cryptography==42.0.7
|