CoolFace
Apppublic

kussssh/IPO-Analyzer

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
api.py1999 linesDownload Raw Back to backend
1"""2FastAPI surface for the DRHP-first IPO analyzer.3"""4from __future__ import annotations5 6import asyncio7import concurrent.futures8import json9import os10import re11import shutil12import threading13import traceback14import urllib.error15import urllib.parse16import urllib.request17import uuid18from datetime import datetime, timezone19from pathlib import Path20from typing import Any, Dict21 22from fastapi import FastAPI, File, HTTPException, UploadFile23from fastapi.middleware.cors import CORSMiddleware24from fastapi.responses import FileResponse25from fastapi.staticfiles import StaticFiles26 27from backend.company_pipeline import (28    ANALYSIS_CACHE_VERSION,29    MODULE_ORDER,30    analyze_company_document,31    hydrate_company_documents,32    prewarm_company,33)34from backend.config import (35    BACKGROUND_PRECOMPUTE_LIMIT,36    BACKGROUND_PREP_WORKERS,37    CACHE_DB_PATH,38    CHROMA_BASE_DIR,39    ENABLE_RECENT_COMPANY_PRECOMPUTE,40    HF_CACHE_ROOT,41    OUTPUT_DIR,42    RUNTIME_ROOT,43    UPLOAD_DIR,44)45from backend.database import (46    get_company,47    get_analysis,48    get_company_row,49    get_document_record,50    init_db,51    list_cached_analyses,52    list_companies,53    purge_invalid_catalog_rows,54    upsert_companies,55    get_company_sector,56    save_company_sector,57    get_connection,58    reset_analysis_state,59    update_document_record,60)61from backend.embeddings import log_embedding_runtime_config62from backend.pipeline import run_full_analysis63from backend.sebi import get_latest_filing_highlights, refresh_drhp_company_catalog64 65# Ensure database tables exist automatically on startup66init_db()67log_embedding_runtime_config()68 69# Pre-warm the CrossEncoder reranker in the background so the first analysis70# request doesn't pay the 2–5s model-load cold start cost.71def _prewarm_reranker() -> None:72    try:73        from backend.retriever import load_reranker74        load_reranker()75        print("✅ Reranker pre-warmed and ready.")76    except Exception as exc:77        print(f"⚠️ Reranker pre-warm failed (will load on demand): {exc}")78 79threading.Thread(target=_prewarm_reranker, daemon=True, name="reranker-prewarm").start()80 81# Pre-warm the embedding model in the background.82# For local sentence-transformers: downloads+caches the 90MB model at boot83# so the first analysis doesn't trigger a cold-start download mid-session.84def _prewarm_embeddings() -> None:85    try:86        from backend.embeddings import create_embeddings_client87        client = create_embeddings_client()88        # Run a tiny test embed so the model is fully loaded into memory89        client.embed_query("warmup")90        print("✅ Embedding model pre-warmed and ready.")91    except Exception as exc:92        print(f"⚠️ Embedding pre-warm failed (will load on demand): {exc}")93 94threading.Thread(target=_prewarm_embeddings, daemon=True, name="embeddings-prewarm").start()95 96FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"97 98app = FastAPI(title="IPO Analyzer API", version="3.0.0")99app.add_middleware(100    CORSMiddleware,101    allow_origins=["*"],102    allow_credentials=True,103    allow_methods=["*"],104    allow_headers=["*"],105)106 107if FRONTEND_DIR.exists():108    app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")109 110# Static file serving is removed for /results so that the dynamic API routes111# can accurately fetch historical analysis data from the SQLite database.112 113 114jobs: Dict[str, Dict[str, Any]] = {}115jobs_lock = threading.Lock()116active_analysis_jobs: Dict[tuple[int, str], str] = {}117background_precompute_executor = concurrent.futures.ThreadPoolExecutor(118    max_workers=max(1, BACKGROUND_PREP_WORKERS),119    thread_name_prefix="ipo-precompute",120)121background_precompute_jobs: set[tuple[int, str]] = set()122background_precompute_lock = threading.Lock()123catalog_refresh_state: Dict[str, Any] = {124    "status": "idle",125    "message": "Catalog not refreshed yet",126    "last_count": 0,127    "last_error": None,128    "started_at": None,129    "finished_at": None,130}131filing_highlights_cache: Dict[str, Any] = {132    "data": {"drhp": [], "rhp": [], "prospectus": []},133    "fetched_at": None,134}135MODULE_TITLES = {136    "business": "Business Quality",137    "financials": "Revenue & Profitability",138    "growth_quality": "Growth vs Profitability",139    "valuation": "Valuation vs Peers",140    "promoter_ofs": "Promoter & OFS",141    "use_of_proceeds": "Use of Proceeds",142    "risks": "Risk Factors",143    "institutional": "Anchor & Institutional",144}145 146DOCUMENT_STAGE_MESSAGES = {147    "cataloged": "Document located in catalog",148    "resolving_pdf": "Resolving PDF link",149    "downloading": "Downloading document PDF",150    "re_downloading": "Detected corrupted cached PDF, re-downloading",151    "parsing": "Parsing document pages",152    "chunking": "Chunking extracted content",153    "saving_chunks": "Saving document chunks",154    "indexing": "Building retrieval store",155    "ready": "Document indexed and ready",156    "missing": "Document not available",157    "error": "Document processing failed",158}159STALE_ANALYSIS_MINUTES = 15160 161 162def _safe_count_dir(path: Path) -> int | None:163    try:164        if not path.exists():165            return 0166        return sum(1 for _ in path.iterdir())167    except Exception:168        return None169 170 171def _safe_size_bytes(path: Path) -> int | None:172    try:173        if not path.exists():174            return 0175        if path.is_file():176            return path.stat().st_size177        total = 0178        for child in path.rglob("*"):179            if child.is_file():180                total += child.stat().st_size181        return total182    except Exception:183        return None184 185 186def _clear_runtime_directory(path: Path) -> int:187    if not path.exists():188        return 0189    removed = 0190    for child in path.iterdir():191        if child.is_dir():192            shutil.rmtree(child, ignore_errors=True)193            removed += 1194        else:195            child.unlink(missing_ok=True)196            removed += 1197    return removed198 199 200def _runtime_storage_diagnostics() -> Dict[str, Any]:201    return {202        "runtime_root": str(RUNTIME_ROOT),203        "runtime_root_exists": RUNTIME_ROOT.exists(),204        "data_mount_exists": Path("/data").exists(),205        "db_path": str(CACHE_DB_PATH),206        "db_exists": CACHE_DB_PATH.exists(),207        "db_size_bytes": _safe_size_bytes(CACHE_DB_PATH),208        "uploads_path": str(UPLOAD_DIR),209        "uploads_exists": UPLOAD_DIR.exists(),210        "uploads_entries": _safe_count_dir(UPLOAD_DIR),211        "uploads_size_bytes": _safe_size_bytes(UPLOAD_DIR),212        "outputs_path": str(OUTPUT_DIR),213        "outputs_exists": OUTPUT_DIR.exists(),214        "outputs_entries": _safe_count_dir(OUTPUT_DIR),215        "outputs_size_bytes": _safe_size_bytes(OUTPUT_DIR),216        "chroma_path": str(CHROMA_BASE_DIR),217        "chroma_exists": CHROMA_BASE_DIR.exists(),218        "chroma_entries": _safe_count_dir(CHROMA_BASE_DIR),219        "chroma_size_bytes": _safe_size_bytes(CHROMA_BASE_DIR),220        "hf_cache_path": str(HF_CACHE_ROOT),221        "hf_cache_exists": HF_CACHE_ROOT.exists(),222        "hf_cache_entries": _safe_count_dir(HF_CACHE_ROOT),223        "hf_cache_size_bytes": _safe_size_bytes(HF_CACHE_ROOT),224        "env": {225            "IPO_DATA_DIR": os.getenv("IPO_DATA_DIR"),226            "HF_HOME": os.getenv("HF_HOME"),227            "TRANSFORMERS_CACHE": os.getenv("TRANSFORMERS_CACHE"),228            "SENTENCE_TRANSFORMERS_HOME": os.getenv("SENTENCE_TRANSFORMERS_HOME"),229            "XDG_CACHE_HOME": os.getenv("XDG_CACHE_HOME"),230        },231    }232 233 234import jwt235import bcrypt236from fastapi.security import OAuth2PasswordBearer237from fastapi import Depends238from pydantic import BaseModel239import random240 241JWT_SECRET = os.getenv("JWT_SECRET", "super_secret_ipo_key_123")242oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")243 244RESEND_API_KEY = os.getenv("RESEND_API_KEY", "")245FROM_EMAIL     = os.getenv("FROM_EMAIL", "hello@prospektlab.com")246AIRTABLE_API_TOKEN  = os.getenv("AIRTABLE_API_TOKEN", "").strip()247AIRTABLE_BASE_ID    = os.getenv("AIRTABLE_BASE_ID", "").strip()248AIRTABLE_TABLE_NAME = os.getenv("AIRTABLE_TABLE_NAME", "").strip()249APP_URL = os.getenv("APP_URL", "https://prospektlab.com")250 251 252class SignupRequest(BaseModel):253    email: str254    password: str255    full_name: str256    phone: str = ""257    otp_code: str = ""258    referral_code: str = ""259    notify_filings: bool = True260 261class LoginRequest(BaseModel):262    email: str263    password: str264 265class OTPRequest(BaseModel):266    email: str267 268class CompleteProfileRequest(BaseModel):269    phone: str270 271class TelemetryRequest(BaseModel):272    event_type: str273    metadata: Dict[str, Any]274 275 276def get_current_user(token: str = Depends(oauth2_scheme)):277    try:278        payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])279        return payload.get("user_id")280    except:281        return None282 283 284def _send_email_via_resend(to_email: str, subject: str, html_body: str) -> bool:285    """Send email via Resend API. Returns True on success."""286    if not RESEND_API_KEY:287        print(f"[Resend] No API key — would send to {to_email}: {subject}")288        return False289    try:290        payload = json.dumps({291            "from": FROM_EMAIL,292            "to": [to_email],293            "subject": subject,294            "html": html_body,295        }).encode()296        req = urllib.request.Request(297            "https://api.resend.com/emails",298            data=payload,299            headers={300                "Authorization": f"Bearer {RESEND_API_KEY}", 301                "Content-Type": "application/json",302                "User-Agent": "ProspektLab-Backend/1.0 Mozilla/5.0"303            },304            method="POST"305        )306        with urllib.request.urlopen(req, timeout=10) as r:307            return r.status in (200, 201)308    except urllib.error.HTTPError as e:309        error_body = e.read().decode('utf-8')310        print(f"[Resend] HTTP Error {e.code}: {e.reason}")311        print(f"[Resend] Response Body: {error_body}")312        return False313    except Exception as e:314        print(f"[Resend] Error: {e}")315        return False316 317 318def _sync_to_airtable(user: dict):319    """Push signup data to Airtable CRM (fire-and-forget)."""320    if not all([AIRTABLE_API_TOKEN, AIRTABLE_BASE_ID, AIRTABLE_TABLE_NAME]):321        return322    try:323        payload = json.dumps({324            "records": [325                {326                    "fields": {327                        "User ID": user.get("id"),328                        "Email": user.get("email"),329                        "Full Name": user.get("full_name") or "",330                        "Phone": user.get("phone") or "",331                        "Auth Provider": user.get("auth_provider") or "local",332                        "Is Subscribed": bool(user.get("is_subscribed")),333                        "Analyses Count": user.get("analyses_count") or 0,334                        "Subscribed At": user.get("subscribed_at") or "",335                        "Referral Code": user.get("referral_code") or "",336                        "Referred By": str(user.get("referred_by")) if user.get("referred_by") else "",337                        "Signup Date": user.get("created_at") or datetime.now(timezone.utc).strftime("%Y-%m-%d"),338                    }339                }340            ]341        }).encode()342        req = urllib.request.Request(343            f"https://api.airtable.com/v0/{AIRTABLE_BASE_ID}/{urllib.parse.quote(AIRTABLE_TABLE_NAME)}",344            data=payload,345            headers={346                "Authorization": f"Bearer {AIRTABLE_API_TOKEN}",347                "Content-Type": "application/json",348                "User-Agent": "ProspektLab-Backend/1.0"349            },350            method="POST"351        )352        with urllib.request.urlopen(req, timeout=8):353            pass354    except urllib.error.HTTPError as e:355        error_body = e.read().decode('utf-8')356        print(f"[Airtable] HTTP Error {e.code}: {e.reason}")357        print(f"[Airtable] Response Body: {error_body}")358    except Exception as e:359        print(f"[Airtable] Sync error: {e}")360 361 362@app.post("/auth/send-otp")363async def send_otp(req: OTPRequest):364    """Generate and send a 6-digit OTP to the given email for signup verification."""365    from backend.database import store_otp, cleanup_expired_otps366    from backend.disposable_domains import is_disposable_email367 368    if is_disposable_email(req.email):369        raise HTTPException(status_code=400, detail="Disposable or temporary email addresses are not allowed. Please use a real email.")370 371    cleanup_expired_otps()372    otp_code = str(random.randint(100000, 999999))373    from datetime import timedelta374    expires_at = (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat()375    store_otp(req.email, otp_code, expires_at)376 377    html = f"""378    <div style="font-family:Arial,sans-serif;max-width:480px;margin:0 auto;padding:24px;background:#f9fafb;border-radius:12px">379      <h2 style="color:#1a1a2e;margin-bottom:8px">Verify your email</h2>380      <p style="color:#6b7280">Use the code below to complete your ProspektLab signup. It expires in 10 minutes.</p>381      <div style="font-size:40px;font-weight:900;letter-spacing:8px;color:#0062ff;text-align:center;padding:24px 0">{otp_code}</div>382      <p style="color:#9ca3af;font-size:12px">If you didn't request this, you can safely ignore this email.</p>383    </div>384    """385    sent = _send_email_via_resend(req.email, "Your ProspektLab verification code", html)386    if not sent and RESEND_API_KEY:387        raise HTTPException(status_code=500, detail="Failed to send OTP email. Please try again.")388    return {"success": True, "message": "OTP sent to your email."}389 390 391@app.post("/auth/signup")392async def signup(req: SignupRequest):393    from backend.database import create_user, verify_otp394    from backend.disposable_domains import is_disposable_email395 396    if is_disposable_email(req.email):397        raise HTTPException(status_code=400, detail="Disposable email addresses are not allowed.")398 399    # Verify OTP (required for email/password signups)400    if not req.otp_code:401        raise HTTPException(status_code=400, detail="OTP verification required. Please verify your email first.")402    if not verify_otp(req.email, req.otp_code):403        raise HTTPException(status_code=400, detail="Invalid or expired OTP. Please request a new one.")404 405    salt = bcrypt.gensalt()406    hash_pw = bcrypt.hashpw(req.password.encode('utf-8'), salt).decode('utf-8')407    user_id = create_user(408        req.email, hash_pw, req.full_name,409        phone=req.phone or None,410        referral_code=req.referral_code or None411    )412    if not user_id:413        raise HTTPException(status_code=400, detail="Email already registered")414 415    from backend.database import record_referral, get_user_by_email416    new_user = get_user_by_email(req.email)417 418    # Record referral if applicable419    if req.referral_code and new_user and new_user.get("referred_by"):420        record_referral(new_user["referred_by"], user_id)421 422    if new_user:423        threading.Thread(target=_sync_to_airtable, args=(new_user,), daemon=True).start()424 425    # Create welcome notification426    try:427        from backend.database import create_notification428        create_notification(429            user_id,430            "Welcome to ProspektLab! 🎉",431            f"Hi {req.full_name or 'there'}! Your account is ready. You have {FREE_ANALYSIS_LIMIT} free IPO analyses to get started. Explore the latest SEBI filings and unlock deep intelligence.",432            notif_type="system"433        )434    except Exception:435        pass436 437    token = jwt.encode({"user_id": user_id, "email": req.email}, JWT_SECRET, algorithm="HS256")438    return {"token": token, "user_id": user_id, "email": req.email, "full_name": req.full_name, "needs_phone": not bool(req.phone)}439 440 441@app.post("/auth/login")442async def login(req: LoginRequest):443    from backend.database import get_user_by_email444    user = get_user_by_email(req.email)445    if not user:446        raise HTTPException(status_code=401, detail="Invalid credentials")447    try:448        is_valid = bcrypt.checkpw(req.password.encode('utf-8'), user["password_hash"].encode('utf-8'))449    except Exception:450        is_valid = False451    if not is_valid:452        raise HTTPException(status_code=401, detail="Invalid credentials")453    token = jwt.encode({"user_id": user["id"], "email": user["email"]}, JWT_SECRET, algorithm="HS256")454    return {455        "token": token,456        "user_id": user["id"],457        "email": user["email"],458        "full_name": user["full_name"],459        "needs_phone": not bool(user.get("phone")),460        "is_subscribed": bool(user.get("is_subscribed")),461    }462 463 464class GoogleAuthRequest(BaseModel):465    credential: str466    referral_code: str = ""467 468 469@app.post("/auth/google")470async def google_auth(req: GoogleAuthRequest):471    """Verify a Google ID token from GIS popup and return a session JWT."""472    from backend.database import create_user, get_user_by_email473 474    try:475        url = f"https://oauth2.googleapis.com/tokeninfo?id_token={urllib.parse.quote(req.credential)}"476        with urllib.request.urlopen(url, timeout=10) as resp:477            info = json.loads(resp.read())478    except urllib.error.HTTPError:479        raise HTTPException(status_code=401, detail="Invalid Google token")480    except Exception as e:481        raise HTTPException(status_code=500, detail=f"Google verification failed: {e}")482 483    email = info.get("email")484    name  = info.get("name") or info.get("given_name", "")485    if not email or not info.get("email_verified"):486        raise HTTPException(status_code=401, detail="Google account email not verified")487 488    existing = get_user_by_email(email)489    is_new = False490    if existing:491        user_id      = existing["id"]492        full_name    = existing["full_name"] or name493        has_phone    = bool(existing.get("phone"))494        is_subscribed = bool(existing.get("is_subscribed"))495    else:496        is_new  = True497        user_id = create_user(email, None, name, auth_provider="google", referral_code=req.referral_code or None)498        if not user_id:499            raise HTTPException(status_code=500, detail="Failed to create user")500        full_name     = name501        has_phone     = False502        is_subscribed = False503        from backend.database import record_referral, get_user_by_email as gube504        new_user = gube(email)505 506        if req.referral_code and new_user and new_user.get("referred_by"):507            record_referral(new_user["referred_by"], user_id)508 509        if new_user:510            threading.Thread(target=_sync_to_airtable, args=(new_user,), daemon=True).start()511 512    token = jwt.encode({"user_id": user_id, "email": email}, JWT_SECRET, algorithm="HS256")513    return {514        "token":        token,515        "user_id":      user_id,516        "email":        email,517        "full_name":    full_name,518        "needs_phone":  not has_phone,519        "is_subscribed": is_subscribed,520    }521 522 523@app.post("/auth/complete-profile")524async def complete_profile(req: CompleteProfileRequest, user_id: int = Depends(get_current_user)):525    """Save phone number for a user (used after Google OAuth)."""526    if not user_id:527        raise HTTPException(status_code=401, detail="Not authenticated")528    from backend.database import update_user_phone, get_user_by_id529    update_user_phone(user_id, req.phone)530    user = get_user_by_id(user_id)531    threading.Thread(532        target=_sync_to_airtable,533        args=(user,),534        daemon=True535    ).start()536    return {"success": True}537 538 539@app.get("/referral/stats")540async def referral_stats(user_id: int = Depends(get_current_user)):541    if not user_id:542        raise HTTPException(status_code=401, detail="Not authenticated")543    from backend.database import get_referral_stats544    stats = get_referral_stats(user_id)545    stats["referral_url"] = f"{APP_URL}/signup?ref={stats.get('referral_code', '')}"546    return stats547 548 549@app.post("/telemetry")550async def record_telemetry(req: TelemetryRequest, user_id: int = Depends(get_current_user)):551    if not user_id:552        # Don't write anonymous/invalid-token telemetry — it creates NULL FK rows553        raise HTTPException(status_code=401, detail="Not authenticated")554    try:555        from backend.database import log_telemetry556        log_telemetry(user_id, req.event_type, req.metadata)557        print(f"[Telemetry] user={user_id} event={req.event_type}")558    except Exception as exc:559        # Log the real error to stdout so it appears in HF/production logs560        print(f"[Telemetry] ERROR writing to DB for user={user_id} event={req.event_type}: {exc}")561        # Don't raise — client should never see a 500 for telemetry562    return {"status": "ok"}563 564@app.get("/admin/insights")565async def fetch_insights():566    try:567        from backend.database import get_telemetry_insights568        return {"insights": get_telemetry_insights()}569    except Exception as exc:570        print(f"[Admin/Insights] ERROR: {exc}")571        return {"insights": [], "error": str(exc)}572 573 574# ── Notification endpoints ──────────────────────────────────────────────────────575@app.get("/notifications")576async def list_notifications(user_id: int = Depends(get_current_user)):577    if not user_id:578        raise HTTPException(status_code=401, detail="Not authenticated")579    from backend.database import get_user_notifications, get_unread_count580    notifications = get_user_notifications(user_id)581    unread = get_unread_count(user_id)582    return {"notifications": notifications, "unread_count": unread}583 584 585@app.post("/notifications/{notification_id}/read")586async def read_notification(notification_id: int, user_id: int = Depends(get_current_user)):587    if not user_id:588        raise HTTPException(status_code=401, detail="Not authenticated")589    from backend.database import mark_notification_read590    mark_notification_read(notification_id, user_id)591    return {"success": True}592 593 594@app.post("/notifications/read-all")595async def read_all_notifications(user_id: int = Depends(get_current_user)):596    if not user_id:597        raise HTTPException(status_code=401, detail="Not authenticated")598    from backend.database import mark_all_notifications_read599    mark_all_notifications_read(user_id)600    return {"success": True}601 602 603 604 605import hmac606import hashlib607from fastapi import Request as FastAPIRequest608 609# ── Razorpay Configuration ─────────────────────────────────────────────────────610PAYMENT_ENV_FILES = (611    Path(__file__).resolve().parent.parent / ".env",612    Path(__file__).resolve().parent.parent / "frontend-next" / ".env.local",613)614 615 616def _read_env_file_value(path: Path, *keys: str) -> str:617    if not path.exists():618        return ""619 620    try:621        lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()622    except Exception:623        return ""624 625    for raw_line in lines:626        line = raw_line.strip()627        if not line or line.startswith("#"):628            continue629        if line.lower().startswith("export "):630            line = line[7:].lstrip()631 632        match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.*)$", line)633        if not match:634            continue635 636        key, value = match.groups()637        if key not in keys:638            continue639 640        cleaned = value.strip()641        if cleaned and cleaned[0] == cleaned[-1] and cleaned[0] in {"'", '"'}:642            cleaned = cleaned[1:-1]643        return cleaned.strip()644 645    return ""646 647 648def _get_runtime_config_value(*keys: str) -> str:649    # Prefer file values so fixes to local .env files win over stale process env.650    for path in PAYMENT_ENV_FILES:651        value = _read_env_file_value(path, *keys)652        if value:653            return value654 655    for key in keys:656        value = os.getenv(key, "").strip()657        if value:658            return value659 660    return ""661 662 663def _get_razorpay_credentials() -> tuple[str, str]:664    key_id = _get_runtime_config_value("RAZORPAY_KEY_ID", "NEXT_PUBLIC_RAZORPAY_KEY_ID")665    key_secret = _get_runtime_config_value("RAZORPAY_KEY_SECRET")666    return key_id, key_secret667 668 669def _get_plan_amount_paise() -> int:670    raw_value = _get_runtime_config_value("RAZORPAY_PRO_MONTHLY_PRICE_PAISE") or "29900"671    try:672        return max(100, int(raw_value))673    except ValueError:674        return 29900675 676 677PLAN_AMOUNT_PAISE = _get_plan_amount_paise()678 679FREE_ANALYSIS_LIMIT = 3  # analyses allowed on free tier680 681 682# ── User Status ───────────────────────────────────────────────────────────────683@app.get("/user/status")684async def get_user_status(user_id: int = Depends(get_current_user)):685    """Return subscription status + usage count for the logged-in user."""686    if not user_id:687        raise HTTPException(status_code=401, detail="Not authenticated")688    from backend.database import get_user_by_id689    user = get_user_by_id(user_id)690    if not user:691        raise HTTPException(status_code=404, detail="User not found")692    return {693        "is_subscribed": bool(user.get("is_subscribed", 0)),694        "analyses_count": user.get("analyses_count", 0),695        "free_limit": FREE_ANALYSIS_LIMIT,696        "analyses_remaining": max(0, FREE_ANALYSIS_LIMIT - user.get("analyses_count", 0)) if not user.get("is_subscribed") else None,697    }698 699 700# ── Track Analysis (paywall gate) ─────────────────────────────────────────────701@app.post("/payment/track-analysis")702async def track_analysis(user_id: int = Depends(get_current_user)):703    """704    Called BEFORE starting an analysis.705    Returns {allowed: true} or {allowed: false, show_paywall: true}.706    """707    if not user_id:708        raise HTTPException(status_code=401, detail="Not authenticated")709    from backend.database import get_user_by_id, increment_user_analysis_count, qualify_referral, check_and_grant_reward710    user = get_user_by_id(user_id)711    if not user:712        raise HTTPException(status_code=404, detail="User not found")713        714    count = user.get("analyses_count", 0)715    if not user.get("is_subscribed") and count >= FREE_ANALYSIS_LIMIT:716        return {"allowed": False, "show_paywall": True, "analyses_count": count}717        718    new_count = increment_user_analysis_count(user_id)719    720    # Trigger referral qualification on 1st analysis721    if new_count == 1:722        referrer_id = qualify_referral(user_id)723        if referrer_id:724            check_and_grant_reward(referrer_id)725            726    if user.get("is_subscribed"):727        return {"allowed": True, "is_subscribed": True}728        729    return {"allowed": True, "analyses_count": new_count, "analyses_remaining": FREE_ANALYSIS_LIMIT - new_count}730 731 732# ── Create Razorpay Order ───────────────────────────────────────────────────────733class CreateOrderRequest(BaseModel):734    plan: str = "Pro"735 736@app.post("/payment/create-order")737async def create_razorpay_order(req: CreateOrderRequest, user_id: int = Depends(get_current_user)):738    """Create a Razorpay order. Returns key_id, order_id, amount for the frontend checkout."""739    if not user_id:740        raise HTTPException(status_code=401, detail="Not authenticated")741 742    razorpay_key_id, razorpay_key_secret = _get_razorpay_credentials()743    plan_amount_paise = _get_plan_amount_paise()744 745    if not razorpay_key_id or not razorpay_key_secret:746        # Dev mode — return mock order so frontend can test the modal747        return {748            "key_id": "rzp_test_mock",749            "order_id": "order_mock_123",750            "amount": plan_amount_paise,751            "currency": "INR",752            "mock": True,753        }754 755    try:756        import razorpay757        client = razorpay.Client(auth=(razorpay_key_id, razorpay_key_secret))758        759        order_data = {760            "amount": plan_amount_paise,761            "currency": "INR",762            "receipt": f"user_{user_id}",763            "notes": {"user_id": str(user_id), "plan": req.plan},764        }765        766        order = client.order.create(data=order_data)767        768    except Exception as e:769        print(f"[Razorpay] create-order error: {e}")770        error_str = str(e)771        if "Authentication failed" in error_str or "unauthorized" in error_str.lower():772            raise HTTPException(773                status_code=401,774                detail="Razorpay Authentication Failed: Your test keys in .env are invalid or revoked. Please regenerate them."775            )776        raise HTTPException(status_code=500, detail=f"Razorpay order creation failed: {e}")777 778    return {779        "key_id": razorpay_key_id,780        "order_id": order["id"],781        "amount": order["amount"],782        "currency": order["currency"],783    }784 785 786# ── Verify Payment (client-side callback) ──────────────────────────────────────787class VerifyPaymentRequest(BaseModel):788    razorpay_order_id:   str789    razorpay_payment_id: str790    razorpay_signature:  str791 792@app.post("/payment/verify")793async def verify_razorpay_payment(req: VerifyPaymentRequest, user_id: int = Depends(get_current_user)):794    """795    Verifies the HMAC-SHA256 signature from the Razorpay checkout callback.796    If valid, marks the user as subscribed.797    """798    if not user_id:799        raise HTTPException(status_code=401, detail="Not authenticated")800 801    razorpay_key_id, razorpay_key_secret = _get_razorpay_credentials()802 803    if not razorpay_key_secret or razorpay_key_secret == "...":804        # Dev mode — accept mock verification805        if req.razorpay_order_id == "order_mock_123":806            from backend.database import upgrade_user_subscription807            upgrade_user_subscription(user_id)808            return {"success": True, "message": "Mock subscription activated"}809        raise HTTPException(status_code=400, detail="Mock payment: invalid order id")810 811    import razorpay812    client = razorpay.Client(auth=(razorpay_key_id, razorpay_key_secret))813 814    try:815        client.utility.verify_payment_signature({816            'razorpay_order_id': req.razorpay_order_id,817            'razorpay_payment_id': req.razorpay_payment_id,818            'razorpay_signature': req.razorpay_signature819        })820    except razorpay.errors.SignatureVerificationError:821        raise HTTPException(status_code=400, detail="Payment verification failed — invalid signature")822 823    from backend.database import upgrade_user_subscription824    upgrade_user_subscription(user_id)825    print(f"  ✅ User {user_id} subscribed via Razorpay payment {req.razorpay_payment_id}")826    return {"success": True, "message": "Subscription activated", "is_subscribed": True}827 828 829# ── Razorpay Webhook (server-to-server event, secondary mechanism) ─────────────830@app.post("/payment/webhook")831async def razorpay_webhook(request: FastAPIRequest):832    """Process Razorpay webhook events for payment.captured / subscription.charged."""833    payload    = await request.body()834    sig_header = request.headers.get("x-razorpay-signature", "")835    _, razorpay_key_secret = _get_razorpay_credentials()836 837    if razorpay_key_secret and sig_header:838        expected = hmac.new(839            razorpay_key_secret.encode(),840            payload,841            hashlib.sha256,842        ).hexdigest()843        if not hmac.compare_digest(expected, sig_header):844            raise HTTPException(status_code=400, detail="Invalid webhook signature")845 846    try:847        event = json.loads(payload)848    except Exception:849        raise HTTPException(status_code=400, detail="Invalid JSON payload")850 851    event_type = event.get("event", "")852    if event_type in ("payment.captured", "subscription.charged"):853        notes = (854            event.get("payload", {})855                 .get("payment", {})856                 .get("entity", {})857                 .get("notes", {})858        )859        user_id_str = notes.get("user_id")860        if user_id_str:861            from backend.database import upgrade_user_subscription862            upgrade_user_subscription(int(user_id_str))863            print(f"  ✅ Razorpay webhook: User {user_id_str} upgraded")864 865    return {"received": True}866 867 868@app.get("/admin/set-subscription")869async def admin_set_subscription(email: str, status: int, secret_key: str):870    """Hidden endpoint to toggle premium status without accessing HF Spaces terminal"""871    if secret_key != "admin123":872        return {"error": "Unauthorized"}873        874    try:875        import sqlite3876        from backend.config import CACHE_DB_PATH877        conn = sqlite3.connect(CACHE_DB_PATH)878        cursor = conn.cursor()879        cursor.execute("UPDATE users SET is_subscribed = ? WHERE email = ?", (status, email))880        conn.commit()881        updated = cursor.rowcount882        conn.close()883        884        if updated > 0:885            return {"success": True, "message": f"Updated {email} subscription to {status}"}886        else:887            return {"error": f"User {email} not found"}888    except Exception as e:889        return {"error": str(e)}890 891 892@app.get("/admin/clear-users")893async def admin_clear_users(secret_key: str):894    """Hidden endpoint to completely wipe the users table in production."""895    if secret_key != "admin123":896        return {"error": "Unauthorized"}897        898    try:899        import sqlite3900        from backend.config import CACHE_DB_PATH901        conn = sqlite3.connect(CACHE_DB_PATH)902        cursor = conn.cursor()903        904        # Delete all users905        cursor.execute("DELETE FROM users")906        deleted_count = cursor.rowcount907        908        # Reset the auto-increment ID counter for the users table909        cursor.execute("DELETE FROM sqlite_sequence WHERE name='users'")910        911        conn.commit()912        conn.close()913        914        return {"success": True, "message": f"Successfully deleted {deleted_count} users from the production database."}915    except Exception as e:916        return {"error": str(e)}917 918 919def _prewarm_reranker() -> None:920    """Load the CrossEncoder reranker at startup so it's warm for the first analysis.921 922    On HF Spaces the model is downloaded from HuggingFace Hub the first time it's923    needed. Running this at boot (in a background thread) means the first user924    request finds the model already cached in RAM instead of waiting 5–30 seconds.925    """926    try:927        from backend.retriever import load_reranker928        load_reranker()929        print("[Startup] ✅ Reranker pre-warmed and ready.")930    except Exception as exc:931        print(f"[Startup] ⚠️ Reranker pre-warm failed (non-fatal): {exc}")932 933 934@app.on_event("startup")935async def on_startup() -> None:936    init_db()937    diagnostics = _runtime_storage_diagnostics()938    print("[Runtime] Storage diagnostics:")939    print(json.dumps(diagnostics, indent=2))940    # Run the initial, heavy 3-tab SEBI catalog scrape in the background941    # so uvicorn starts the server instantly.942    start_catalog_refresh(background=True, trigger="startup")943    # Pre-warm the CrossEncoder reranker in the background.944    asyncio.create_task(asyncio.to_thread(_prewarm_reranker))945    # Pre-warm filing highlights cache so the first /companies call is instant.946    asyncio.create_task(asyncio.to_thread(get_cached_filing_highlights))947 948@app.get("/")949async def root():950    index_path = FRONTEND_DIR / "index.html"951    if index_path.exists():952        return FileResponse(str(index_path))953    return {"status": "ok"}954 955 956@app.get("/debug/runtime-storage")957async def debug_runtime_storage():958    return _runtime_storage_diagnostics()959 960 961@app.get("/companies")962async def get_companies():963    """Returns companies from DB immediately (never blocks on SEBI scrape)."""964    _expire_stuck_catalog_refresh()965    companies = list_companies()966    if not companies and catalog_refresh_state["status"] == "idle":967        start_catalog_refresh(background=True, trigger="auto")968    return {969        "companies": companies,970        "catalog_refresh": dict(catalog_refresh_state),971        # Always use cached highlights — never block on a live SEBI request here.972        "highlights": filing_highlights_cache["data"],973    }974 975 976@app.post("/companies/refresh")977async def refresh_companies():978    """979    Returns existing DB companies IMMEDIATELY and starts a background SEBI refresh.980    The refresh typically takes 30-120 s; poll GET /companies/refresh/status981    every 2 s until status == 'complete' to get the freshly-scraped list.982    """983    existing = list_companies()984    # Fire background refresh (no-op if one is already running)985    start_catalog_refresh(background=True, trigger="user_refresh")986    return {987        "count": len(existing),988        "companies": existing,989        "catalog_refresh": dict(catalog_refresh_state),990        "highlights": filing_highlights_cache["data"],991        "message": "Refresh started in background. Poll /companies/refresh/status for completion.",992    }993 994 995@app.get("/companies/refresh/status")996async def refresh_status():997    """998    Poll this endpoint (~every 2 s) after calling POST /companies/refresh.999    Returns the current scrape state and, when complete, the fresh company list.1000    """1001    _expire_stuck_catalog_refresh()1002    state = dict(catalog_refresh_state)1003    if state.get("status") == "complete":1004        return {1005            **state,1006            "companies": list_companies(),1007            "highlights": filing_highlights_cache["data"],1008        }1009    return state1010 1011 1012@app.get("/sebi/ipo-tracker")1013async def get_ipo_tracker():1014    """Alias for /companies to support the new frontend API structure."""1015    return await get_companies()1016 1017 1018@app.get("/sebi/sector")1019async def get_sector(name: str):1020    cached = get_company_sector(name)1021    if cached:1022        return {"sector": cached}1023    1024    try:1025        from langchain_core.prompts import ChatPromptTemplate1026        from backend.llm import create_chat_llm1027        1028        llm = create_chat_llm(temperature=0)1029        1030        prompt = ChatPromptTemplate.from_messages([1031            ("system", "You are an expert financial analyst. Categorize the given company into ONE primary business sector from this list: Technology, Pharmaceuticals, Fintech, NBFC, Manufacturing, Aerospace, FMCG, Energy, Real Estate, Retail, Logistics, Agriculture, Construction, Textile, Automobile, Media, Education, Telecom, Chemical, Healthcare. Reply ONLY with the sector name."),1032            ("human", "Company Name: {company_name}")1033        ])1034        1035        msg = prompt | llm1036        result = msg.invoke({"company_name": name})1037        sector = str(result.content).strip()1038        save_company_sector(name, sector)1039        return {"sector": sector}1040    except Exception as e:1041        print(f"Sector resolution failed for {name}: {e}")1042        return {"sector": "General"}1043 1044 1045@app.delete("/companies/{company_id}/analysis/{doc_type}/cache")1046async def clear_analysis_cache(company_id: int, doc_type: str):1047    """Delete cached analysis for a company so the next run re-generates fresh results."""1048    from backend.database import delete_analysis1049    if doc_type not in {"drhp", "rhp", "prospectus", "all"}:1050        raise HTTPException(status_code=400, detail="Invalid doc_type")1051    deleted = delete_analysis(company_id, None if doc_type == "all" else doc_type)1052    return {"deleted_rows": deleted, "message": f"Cleared {doc_type.upper()} cache for company {company_id}"}1053 1054 1055@app.post("/admin/reset-analysis-cache")1056async def reset_analysis_cache():1057    with jobs_lock:1058        jobs.clear()1059        active_analysis_jobs.clear()1060    with background_precompute_lock:1061        background_precompute_jobs.clear()1062 1063    counts = reset_analysis_state()1064    outputs_removed = _clear_runtime_directory(OUTPUT_DIR)1065    chroma_removed = _clear_runtime_directory(CHROMA_BASE_DIR)1066    uploads_removed = _clear_runtime_directory(UPLOAD_DIR)1067 1068    if ENABLE_RECENT_COMPANY_PRECOMPUTE:1069        queue_recent_company_precompute()1070 1071    return {1072        **counts,1073        "outputs_removed": outputs_removed,1074        "chroma_entries_removed": chroma_removed,1075        "uploads_removed": uploads_removed,1076        "message": "Analysis cache reset complete",1077    }1078 1079 1080@app.post("/companies/{company_id}/hydrate")1081async def hydrate_company(company_id: int):1082    company = hydrate_company_documents(company_id, discover_matches=False, prepare_documents=False)1083    threading.Thread(1084        target=_background_discover_company_matches,1085        args=(company_id,),1086        daemon=True,1087    ).start()1088    return {"company": company}1089 1090 1091@app.get("/companies/{company_id}/analysis")1092async def get_company_analysis(company_id: int, doc_type: str = "drhp"):1093    if doc_type not in {"drhp", "rhp", "prospectus"}:1094        raise HTTPException(status_code=400, detail="Invalid document type")1095 1096    company = get_company(company_id)1097    if not company:1098        raise HTTPException(status_code=404, detail="Company not found")1099 1100    payload = _build_company_analysis_status(company_id, doc_type)1101    if payload.get("source") == "cache":1102        payload["source"] = "server_cache"1103    if payload.get("status") == "not_started":1104        queue_company_precompute(company_id, doc_type)1105    payload["company_id"] = company_id1106    payload["company"] = company1107    return payload1108 1109 1110@app.get("/admin/document-prep/{company_id}/{doc_type}")1111async def get_document_preparation_debug(company_id: int, doc_type: str):1112    if doc_type not in {"drhp", "rhp", "prospectus"}:1113        raise HTTPException(status_code=400, detail="Invalid document type")1114 1115    company = get_company(company_id)1116    if not company:1117        raise HTTPException(status_code=404, detail="Company not found")1118 1119    document = get_document_record(company_id, doc_type) or {}1120    metadata = document.get("metadata_json")1121    if isinstance(metadata, str):1122        try:1123            metadata = json.loads(metadata or "{}")1124        except Exception:1125            metadata = {}1126    elif not isinstance(metadata, dict):1127        metadata = {}1128 1129    with background_precompute_lock:1130        queue_depth = len(background_precompute_jobs)1131        queued = (company_id, doc_type) in background_precompute_jobs1132 1133    return {1134        "company_id": company_id,1135        "company": company,1136        "doc_type": doc_type,1137        "document_status": document.get("status"),1138        "pdf_sha256": document.get("pdf_sha256"),1139        "index_ready": bool(metadata.get("index_ready")),1140        "retriever_source": metadata.get("retriever_source"),1141        "retriever_cache_key": metadata.get("retriever_cache_key"),1142        "prepared_via": metadata.get("prepared_via"),1143        "chunk_count": metadata.get("chunk_count"),1144        "avg_chunk_chars": metadata.get("avg_chunk_chars"),1145        "financial_chunk_count": metadata.get("financial_chunk_count"),1146        "narrative_chunk_count": metadata.get("narrative_chunk_count"),1147        "timings": metadata.get("stage_timings_ms", {}),1148        "embed_duration_ms": metadata.get("embed_duration_ms"),1149        "chroma_write_duration_ms": metadata.get("chroma_write_duration_ms"),1150        "queue_depth": queue_depth,1151        "queued_for_background_precompute": queued,1152        "metadata": metadata,1153    }1154 1155 1156@app.get("/companies/{company_id}/documents")1157async def get_company_documents(company_id: int):1158    if not get_company(company_id):1159        raise HTTPException(status_code=404, detail="Company not found")1160    payload = {}1161    for doc_type in ("drhp", "rhp", "prospectus"):1162        payload[doc_type] = get_document_record(company_id, doc_type)1163    return {"company_id": company_id, "documents": payload}1164 1165 1166def _get_live_job_for_company(company_id: int, doc_type: str) -> Dict[str, Any] | None:1167    with jobs_lock:1168        active_job_id = active_analysis_jobs.get((company_id, doc_type))1169        if active_job_id:1170            active_job = jobs.get(active_job_id)1171            if active_job:1172                return active_job1173    for job in reversed(list(jobs.values())):1174        if job.get("company_id") == company_id and job.get("doc_type") == doc_type:1175            return job1176    return None1177 1178 1179def _run_background_precompute(company_id: int, doc_type: str) -> None:1180    try:1181        if not get_company(company_id):1182            return1183        asyncio.run(prewarm_company(company_id, doc_type))1184        if _get_current_cached_analysis(company_id, doc_type):1185            return1186        with jobs_lock:1187            active_job_id = active_analysis_jobs.get((company_id, doc_type))1188            active_job = jobs.get(active_job_id) if active_job_id else None1189            if active_job and active_job.get("status") == "processing":1190                return1191        asyncio.run(analyze_company_document(company_id, doc_type))1192    except Exception as exc:1193        print(1194            f"[Background] Precompute skipped for company {company_id} {doc_type.upper()}: {exc}"1195        )1196    finally:1197        with background_precompute_lock:1198            background_precompute_jobs.discard((company_id, doc_type))1199 1200 

Showing the first 1,200 of 1999 lines. Download the file for the rest.