Commit ·
5df55ff
0
Parent(s):
first commit
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +31 -0
- .gitignore +43 -0
- README.md +82 -0
- app.py +134 -0
- config/attack_tags.json +32 -0
- config/personas.json +46 -0
- config/pitch_rubric.json +11 -0
- config/sample_startups.json +14 -0
- core/__init__.py +1 -0
- core/api_handlers.py +512 -0
- core/attack_tags.py +50 -0
- core/battle_flow.py +301 -0
- core/claim_extractor.py +274 -0
- core/feedback_generator.py +41 -0
- core/json_utils.py +192 -0
- core/local_text_model.py +30 -0
- core/minicpm_client.py +13 -0
- core/model_router.py +373 -0
- core/nvidia_client.py +234 -0
- core/output_sanitizer.py +63 -0
- core/persona_builder.py +66 -0
- core/samples.py +27 -0
- core/scoring_engine.py +908 -0
- core/session_manager.py +77 -0
- core/transcription_client.py +13 -0
- core/vision_client.py +13 -0
- core/voice_transcriber.py +13 -0
- docs/BACKEND_API.md +393 -0
- docs/CLAUDE_PROJECT_CONTEXT.md +299 -0
- docs/DEMO_NOTES.md +48 -0
- docs/DOCUMENTATION.md +1447 -0
- docs/FIELD_NOTES.md +92 -0
- docs/MODELS_FINAL.md +434 -0
- docs/NEMOTRON_OMNI_AUDIO.md +294 -0
- docs/PHASE_WISE_PLAN.md +478 -0
- docs/PROMPTS.md +155 -0
- docs/TASK_TRACKER.md +105 -0
- frontend/assets/logo.svg +7 -0
- frontend/index.html +155 -0
- frontend/script.js +376 -0
- frontend/styles.css +453 -0
- packages.txt +1 -0
- requirements.txt +9 -0
- scripts/test_claim_based_scoring.py +336 -0
- scripts/test_nvidia_client.py +90 -0
- scripts/test_phase3_pitch_battle.py +123 -0
- scripts/test_phase3b_battle_flow.py +217 -0
- scripts/test_phase4_battle_stability.py +261 -0
- scripts/test_phase5_scorecard.py +329 -0
- scripts/test_phase5b_refinement.py +240 -0
.env.example
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy to .env locally; use HF Space Secrets in deployment.
|
| 2 |
+
# Frontend never reads these — backend model_router only.
|
| 3 |
+
APP_ENV=development
|
| 4 |
+
MAX_ROUNDS=6
|
| 5 |
+
|
| 6 |
+
DEFAULT_MODEL_MODE=premium_nvidia
|
| 7 |
+
|
| 8 |
+
# NVIDIA Nemotron 3 Nano Omni 30B-A3B (primary premium model — backend-only API)
|
| 9 |
+
NVIDIA_API_KEY=
|
| 10 |
+
NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
| 11 |
+
NVIDIA_OMNI_MODEL=nvidia/nemotron-3-nano-omni-30b-a3b-reasoning
|
| 12 |
+
|
| 13 |
+
# OpenBMB MiniCPM models
|
| 14 |
+
OPENBMB_API_KEY=
|
| 15 |
+
OPENBMB_BASE_URL=
|
| 16 |
+
MINICPM_OMNI_MODEL=openbmb/MiniCPM-o-4_5
|
| 17 |
+
MINICPM_TEXT_MODEL=openbmb/MiniCPM5-1B
|
| 18 |
+
MINICPM_TEXT_BACKEND=api_or_local
|
| 19 |
+
MINICPM_VISION_MODEL=openbmb/MiniCPM-V-4.6
|
| 20 |
+
|
| 21 |
+
# Hugging Face (model download / Space)
|
| 22 |
+
HF_TOKEN=
|
| 23 |
+
|
| 24 |
+
# Audio transcription fallback only (not the primary judge)
|
| 25 |
+
WHISPER_FALLBACK_ENABLED=true
|
| 26 |
+
WHISPER_MODEL_SIZE=tiny
|
| 27 |
+
|
| 28 |
+
# Feature flags
|
| 29 |
+
ENABLE_DECK_CRITIQUE=true
|
| 30 |
+
ENABLE_DEAL_BATTLE=true
|
| 31 |
+
ENABLE_VOICE_MODE=true
|
.gitignore
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Secrets — never commit real keys
|
| 2 |
+
.env
|
| 3 |
+
.env.local
|
| 4 |
+
.env.*.local
|
| 5 |
+
|
| 6 |
+
# Python
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.py[cod]
|
| 9 |
+
*$py.class
|
| 10 |
+
*.egg-info/
|
| 11 |
+
.eggs/
|
| 12 |
+
dist/
|
| 13 |
+
build/
|
| 14 |
+
.pytest_cache/
|
| 15 |
+
.mypy_cache/
|
| 16 |
+
.ruff_cache/
|
| 17 |
+
.coverage
|
| 18 |
+
htmlcov/
|
| 19 |
+
|
| 20 |
+
# Virtual environments
|
| 21 |
+
venv/
|
| 22 |
+
.venv/
|
| 23 |
+
env/
|
| 24 |
+
|
| 25 |
+
# OS / editor
|
| 26 |
+
.DS_Store
|
| 27 |
+
Thumbs.db
|
| 28 |
+
.idea/
|
| 29 |
+
.vscode/
|
| 30 |
+
*.swp
|
| 31 |
+
*.swo
|
| 32 |
+
|
| 33 |
+
# Node (if added later)
|
| 34 |
+
node_modules/
|
| 35 |
+
|
| 36 |
+
# Gradio runtime / uploads
|
| 37 |
+
.gradio/
|
| 38 |
+
|
| 39 |
+
# Local model weights & generated media (large / machine-specific)
|
| 40 |
+
models/
|
| 41 |
+
*.gguf
|
| 42 |
+
*.wav
|
| 43 |
+
*.mp3
|
README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: PitchFight AI
|
| 3 |
+
emoji: ⚔️
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: gradio
|
| 7 |
+
app_file: app.py
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# PitchFight AI
|
| 12 |
+
|
| 13 |
+
**Your first tough pitch should not be in front of a real judge.**
|
| 14 |
+
|
| 15 |
+
A voice-and-text AI sparring arena for student founders — built for the Hugging Face **Build Small Hackathon** (Backyard AI track).
|
| 16 |
+
|
| 17 |
+
## One-Line Pitch
|
| 18 |
+
|
| 19 |
+
PitchFight AI is a voice-and-text AI sparring arena where student founders practice tough startup pitches, get grilled by realistic AI judges under 32B parameters, and receive a scorecard that shows exactly how to answer better.
|
| 20 |
+
|
| 21 |
+
## Strategic Direction
|
| 22 |
+
|
| 23 |
+
This build prioritizes **demo strength**, **model quality**, and **sponsor-model alignment** — not the Off-the-Grid badge.
|
| 24 |
+
|
| 25 |
+
| Priority | Detail |
|
| 26 |
+
|---|---|
|
| 27 |
+
| **Hackathon rules** | ≤32B models, Gradio, HF Spaces, demo-first |
|
| 28 |
+
| **Primary premium model** | NVIDIA Nemotron 3 Nano Omni 30B-A3B (backend-only API) |
|
| 29 |
+
| **Frontend API** | `fetch()` → `/api/...` only — never model provider APIs |
|
| 30 |
+
| **OpenBMB modes** | MiniCPM-o, MiniCPM5-1B, MiniCPM-V 4.6 |
|
| 31 |
+
| **Voice fallback** | faster-whisper local transcription |
|
| 32 |
+
| **UI** | Custom HTML/CSS/JS via Gradio Server (not default Gradio) |
|
| 33 |
+
| **Secrets** | API keys in HF Space Secrets / backend `.env` only |
|
| 34 |
+
|
| 35 |
+
> **Off-the-Grid is not targeted** in this build. Sponsor APIs are used intentionally for the highest-quality demo.
|
| 36 |
+
|
| 37 |
+
## Target Badges / Prizes
|
| 38 |
+
|
| 39 |
+
Backyard AI · Best Demo · Best Agent · Off-Brand · NVIDIA Nemotron Quest · OpenBMB Awards · Sharing is Caring · Field Notes · Tiny Titan (Tiny Mode)
|
| 40 |
+
|
| 41 |
+
## Current Status
|
| 42 |
+
|
| 43 |
+
**Phase 1 complete** — Gradio Server skeleton + custom frontend + mock battle/scorecard APIs.
|
| 44 |
+
|
| 45 |
+
See [`docs/PHASE_WISE_PLAN.md`](docs/PHASE_WISE_PLAN.md) for the full 14-phase roadmap.
|
| 46 |
+
|
| 47 |
+
## Run Locally
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
python -m venv venv
|
| 51 |
+
# Windows: .\venv\Scripts\Activate.ps1
|
| 52 |
+
pip install -r requirements.txt
|
| 53 |
+
cp .env.example .env # add API keys for Phase 2+
|
| 54 |
+
python app.py
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
Open the URL printed in your terminal (typically `http://127.0.0.1:7860`).
|
| 58 |
+
|
| 59 |
+
Phase 1 runs with **mock responses** — no API keys required. Real model routing begins in **Phase 2**.
|
| 60 |
+
|
| 61 |
+
## Backend API
|
| 62 |
+
|
| 63 |
+
PitchFight AI exposes **clean custom project APIs under `/api/...`**. Gradio internal routes (`/gradio_api/*`, `/queue`, `/upload`, etc.) may appear in OpenAPI/Swagger — those are **framework runtime routes**, not product endpoints.
|
| 64 |
+
|
| 65 |
+
See **[`docs/BACKEND_API.md`](docs/BACKEND_API.md)** for the full API reference.
|
| 66 |
+
|
| 67 |
+
## Project Structure
|
| 68 |
+
|
| 69 |
+
- `app.py` — Gradio Server entrypoint + `/api/*` REST routes
|
| 70 |
+
- `core/api_handlers.py` — shared handler logic (REST + Gradio)
|
| 71 |
+
- `core/` — session, persona, scoring, model clients (Phases 2+)
|
| 72 |
+
- `config/` — personas, attack tags, rubric, samples
|
| 73 |
+
- `frontend/` — custom battle arena UI (`fetch` → `/api/*`)
|
| 74 |
+
- `docs/` — phase plan, models, prompts, demo notes, backend API
|
| 75 |
+
|
| 76 |
+
## Documentation
|
| 77 |
+
|
| 78 |
+
- [Phase-Wise Plan](docs/PHASE_WISE_PLAN.md)
|
| 79 |
+
- [Model Strategy](docs/MODELS_FINAL.md)
|
| 80 |
+
- [Nemotron Omni Audio Architecture](docs/NEMOTRON_OMNI_AUDIO.md)
|
| 81 |
+
- [Full Documentation](docs/DOCUMENTATION.md)
|
| 82 |
+
- [Demo Notes](docs/DEMO_NOTES.md)
|
app.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PitchFight AI — Gradio Server app with custom frontend."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from fastapi import Body
|
| 9 |
+
from fastapi.responses import HTMLResponse
|
| 10 |
+
from fastapi.staticfiles import StaticFiles
|
| 11 |
+
from gradio import Server
|
| 12 |
+
|
| 13 |
+
from core.api_handlers import (
|
| 14 |
+
handle_chat_round,
|
| 15 |
+
handle_deck_critique_placeholder,
|
| 16 |
+
handle_deal_session_placeholder,
|
| 17 |
+
handle_end_battle,
|
| 18 |
+
handle_load_sample,
|
| 19 |
+
handle_reset_session,
|
| 20 |
+
handle_start_session,
|
| 21 |
+
handle_voice_pitch_placeholder,
|
| 22 |
+
)
|
| 23 |
+
from core import model_router
|
| 24 |
+
|
| 25 |
+
APP_VERSION = "0.1.0"
|
| 26 |
+
FRONTEND_DIR = Path(__file__).parent / "frontend"
|
| 27 |
+
|
| 28 |
+
app = Server()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# PitchFight REST API (product endpoints — use these from the custom frontend)
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@app.get("/health")
|
| 37 |
+
async def health() -> dict[str, str]:
|
| 38 |
+
"""Health check for app status."""
|
| 39 |
+
return {"status": "ok", "app": "PitchFight AI", "version": APP_VERSION}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@app.get("/api/model-health")
|
| 43 |
+
async def api_model_health() -> dict[str, Any]:
|
| 44 |
+
"""Model provider configuration status. Keys are never exposed."""
|
| 45 |
+
return model_router.get_model_health()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@app.post("/api/load-sample")
|
| 49 |
+
def api_load_sample() -> dict[str, Any]:
|
| 50 |
+
return handle_load_sample()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@app.post("/api/start-session")
|
| 54 |
+
def api_start_session(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
| 55 |
+
return handle_start_session(payload)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@app.post("/api/chat-round")
|
| 59 |
+
def api_chat_round(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
| 60 |
+
return handle_chat_round(payload)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@app.post("/api/end-battle")
|
| 64 |
+
def api_end_battle(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
| 65 |
+
return handle_end_battle(payload)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@app.post("/api/reset-session")
|
| 69 |
+
def api_reset_session(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
| 70 |
+
return handle_reset_session(payload)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@app.post("/api/voice-pitch")
|
| 74 |
+
def api_voice_pitch(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, str]:
|
| 75 |
+
return handle_voice_pitch_placeholder(payload)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@app.post("/api/start-deal-session")
|
| 79 |
+
def api_start_deal_session(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, str]:
|
| 80 |
+
return handle_deal_session_placeholder(payload)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@app.post("/api/deck-critique")
|
| 84 |
+
def api_deck_critique(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, str]:
|
| 85 |
+
return handle_deck_critique_placeholder(payload)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
# Gradio @app.api compatibility (same handlers — for gradio_client / queue)
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@app.api(name="load_sample")
|
| 94 |
+
def gradio_load_sample() -> dict[str, Any]:
|
| 95 |
+
return handle_load_sample()
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@app.api(name="start_session")
|
| 99 |
+
def gradio_start_session(payload: dict[str, Any]) -> dict[str, Any]:
|
| 100 |
+
return handle_start_session(payload)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@app.api(name="chat_round")
|
| 104 |
+
def gradio_chat_round(payload: dict[str, Any]) -> dict[str, Any]:
|
| 105 |
+
return handle_chat_round(payload)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@app.api(name="end_battle")
|
| 109 |
+
def gradio_end_battle(payload: dict[str, Any]) -> dict[str, Any]:
|
| 110 |
+
return handle_end_battle(payload)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@app.api(name="reset_session")
|
| 114 |
+
def gradio_reset_session(payload: dict[str, Any]) -> dict[str, Any]:
|
| 115 |
+
return handle_reset_session(payload)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
+
# Frontend
|
| 120 |
+
# ---------------------------------------------------------------------------
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@app.get("/", response_class=HTMLResponse)
|
| 124 |
+
async def homepage() -> HTMLResponse:
|
| 125 |
+
"""Serve the custom PitchFight frontend."""
|
| 126 |
+
index_path = FRONTEND_DIR / "index.html"
|
| 127 |
+
return HTMLResponse(index_path.read_text(encoding="utf-8"))
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
app.mount("/frontend", StaticFiles(directory=str(FRONTEND_DIR)), name="frontend")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
if __name__ == "__main__":
|
| 134 |
+
app.launch(show_error=True)
|
config/attack_tags.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"skeptical_vc": [
|
| 3 |
+
"Market Size",
|
| 4 |
+
"Moat",
|
| 5 |
+
"Retention",
|
| 6 |
+
"Revenue Logic",
|
| 7 |
+
"First 100 Users",
|
| 8 |
+
"Why Now",
|
| 9 |
+
"Competition",
|
| 10 |
+
"Defensibility"
|
| 11 |
+
],
|
| 12 |
+
"technical_judge": [
|
| 13 |
+
"AI Justification",
|
| 14 |
+
"Architecture",
|
| 15 |
+
"Scalability",
|
| 16 |
+
"Latency",
|
| 17 |
+
"Data Quality",
|
| 18 |
+
"Failure Mode",
|
| 19 |
+
"Simpler Alternative",
|
| 20 |
+
"Technical Feasibility"
|
| 21 |
+
],
|
| 22 |
+
"hackathon_judge": [
|
| 23 |
+
"Novelty",
|
| 24 |
+
"Demo Clarity",
|
| 25 |
+
"MVP Strength",
|
| 26 |
+
"User Pain",
|
| 27 |
+
"AI Load-Bearing",
|
| 28 |
+
"Backyard Fit",
|
| 29 |
+
"Practical Impact",
|
| 30 |
+
"Judging Memorability"
|
| 31 |
+
]
|
| 32 |
+
}
|
config/personas.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"personas": [
|
| 3 |
+
{
|
| 4 |
+
"id": "skeptical_vc",
|
| 5 |
+
"name": "Morgan Vale",
|
| 6 |
+
"role": "Skeptical VC",
|
| 7 |
+
"tone": "Direct, ROI-focused, allergic to buzzwords",
|
| 8 |
+
"focus_areas": [
|
| 9 |
+
"Market Size",
|
| 10 |
+
"Moat",
|
| 11 |
+
"Retention",
|
| 12 |
+
"Revenue Logic",
|
| 13 |
+
"Defensibility"
|
| 14 |
+
],
|
| 15 |
+
"opening_style": "Opens with a market or business-model challenge before asking for proof."
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"id": "technical_judge",
|
| 19 |
+
"name": "Dr. Priya Nair",
|
| 20 |
+
"role": "Technical Judge",
|
| 21 |
+
"tone": "Precise, systems-minded, skeptical of AI theater",
|
| 22 |
+
"focus_areas": [
|
| 23 |
+
"AI Justification",
|
| 24 |
+
"Architecture",
|
| 25 |
+
"Scalability",
|
| 26 |
+
"Data Quality",
|
| 27 |
+
"Technical Feasibility"
|
| 28 |
+
],
|
| 29 |
+
"opening_style": "Opens by questioning whether AI is necessary and how the system actually works."
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"id": "hackathon_judge",
|
| 33 |
+
"name": "Jordan Keane",
|
| 34 |
+
"role": "Hackathon Judge",
|
| 35 |
+
"tone": "Fast-paced, demo-focused, prizes clarity over hype",
|
| 36 |
+
"focus_areas": [
|
| 37 |
+
"Novelty",
|
| 38 |
+
"Demo Clarity",
|
| 39 |
+
"MVP Strength",
|
| 40 |
+
"User Pain",
|
| 41 |
+
"Backyard Fit"
|
| 42 |
+
],
|
| 43 |
+
"opening_style": "Opens with a sharp question about user pain, demo proof, or hackathon fit."
|
| 44 |
+
}
|
| 45 |
+
]
|
| 46 |
+
}
|
config/pitch_rubric.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"dimensions": {
|
| 3 |
+
"clarity": 15,
|
| 4 |
+
"problem_understanding": 20,
|
| 5 |
+
"market_awareness": 15,
|
| 6 |
+
"differentiation": 20,
|
| 7 |
+
"business_model": 15,
|
| 8 |
+
"objection_handling": 15
|
| 9 |
+
},
|
| 10 |
+
"total": 100
|
| 11 |
+
}
|
config/sample_startups.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"startups": [
|
| 3 |
+
{
|
| 4 |
+
"name": "EventRadar AI",
|
| 5 |
+
"problem": "Students miss hackathons, tech events, and startup opportunities because discovery is scattered across WhatsApp groups, LinkedIn, Luma, college clubs, and random websites.",
|
| 6 |
+
"target_users": "College students, student founders, and early-stage builders.",
|
| 7 |
+
"solution": "AI-powered event discovery that ranks opportunities based on skills, goals, location, and deadline urgency.",
|
| 8 |
+
"why_ai": "The app does not just list events. It matches events to a student's profile and explains why each event is worth attending.",
|
| 9 |
+
"competitors": "Luma, LinkedIn Events, WhatsApp groups, college club pages.",
|
| 10 |
+
"traction": "Prototype built with scraped event data and ranking logic.",
|
| 11 |
+
"ask": "Hackathon prize and mentor feedback."
|
| 12 |
+
}
|
| 13 |
+
]
|
| 14 |
+
}
|
core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""PitchFight AI core modules."""
|
core/api_handlers.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PitchFight AI — shared API handler functions for REST and Gradio routes."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
from core.attack_tags import get_attack_tags, get_next_attack_tag
|
| 12 |
+
from core.persona_builder import build_persona_prompt
|
| 13 |
+
from core.samples import get_sample_startup
|
| 14 |
+
from core.scoring_engine import (
|
| 15 |
+
mock_scorecard,
|
| 16 |
+
generate_real_scorecard,
|
| 17 |
+
generate_claim_based_scorecard,
|
| 18 |
+
build_session_aware_fallback_scorecard,
|
| 19 |
+
)
|
| 20 |
+
from core.claim_extractor import extract_concrete_signals
|
| 21 |
+
from core import battle_flow
|
| 22 |
+
from core import model_router
|
| 23 |
+
from core import session_manager
|
| 24 |
+
from core.output_sanitizer import sanitize_model_output
|
| 25 |
+
|
| 26 |
+
load_dotenv()
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
MAX_ROUNDS = int(os.getenv("MAX_ROUNDS", "6"))
|
| 31 |
+
|
| 32 |
+
OPENING_MESSAGES: dict[str, tuple[str, str]] = {
|
| 33 |
+
"skeptical_vc": (
|
| 34 |
+
"Market Size",
|
| 35 |
+
"How big is this really? Student event discovery sounds like a nice feature, not a venture-scale business.",
|
| 36 |
+
),
|
| 37 |
+
"technical_judge": (
|
| 38 |
+
"AI Justification",
|
| 39 |
+
"Why does this need AI? A sorted event list with filters seems enough. What is the intelligence here?",
|
| 40 |
+
),
|
| 41 |
+
"hackathon_judge": (
|
| 42 |
+
"User Pain",
|
| 43 |
+
"Students already lurk in WhatsApp groups. What pain are you solving that a shared Google Sheet cannot?",
|
| 44 |
+
),
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
MOCK_FOLLOWUPS: dict[str, list[str]] = {
|
| 48 |
+
"skeptical_vc": [
|
| 49 |
+
"You named competitors but did not explain why students switch. What is your wedge for the first 100 users?",
|
| 50 |
+
"Where is the retention? Why would a student open this weekly instead of once before a hackathon?",
|
| 51 |
+
"Walk me through revenue. Who pays and why would they pay you instead of Luma or LinkedIn?",
|
| 52 |
+
"What stops a bigger platform from adding your ranking layer in a weekend?",
|
| 53 |
+
"Your traction sounds like a prototype. What metric proves demand, not just build activity?",
|
| 54 |
+
"If I gave you $50k today, what single milestone would prove this is investable?",
|
| 55 |
+
],
|
| 56 |
+
"technical_judge": [
|
| 57 |
+
"What data do you rank on, and how do you keep event metadata fresh without manual cleanup?",
|
| 58 |
+
"If ranking is the core value, why is a small model better than deterministic scoring rules?",
|
| 59 |
+
"What happens when two students with different goals get the same top recommendation?",
|
| 60 |
+
"How does this scale beyond one campus without quality collapsing?",
|
| 61 |
+
"What is your failure mode when event sources break or duplicate listings?",
|
| 62 |
+
"Show me the simplest non-AI version. Why is that not good enough?",
|
| 63 |
+
],
|
| 64 |
+
"hackathon_judge": [
|
| 65 |
+
"In one sentence: what is novel here versus another event aggregator?",
|
| 66 |
+
"If I only saw a 30-second demo, what would convince me the AI matching is real?",
|
| 67 |
+
"What did you ship this weekend that proves user pain, not just scraped listings?",
|
| 68 |
+
"Why is AI load-bearing in the MVP instead of optional polish?",
|
| 69 |
+
"How does this fit the Backyard AI theme beyond using a model as a label?",
|
| 70 |
+
"What will I remember about your project after judging 40 teams?",
|
| 71 |
+
],
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
_HISTORY_WINDOW = 12 # max turns sent to Nemotron for live inference
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def pressure_level(round_number: int) -> str:
|
| 79 |
+
if round_number <= 2:
|
| 80 |
+
return "Medium"
|
| 81 |
+
if round_number <= 4:
|
| 82 |
+
return "High"
|
| 83 |
+
return "Extreme"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def get_battle_phase(round_number: int) -> str:
|
| 87 |
+
"""Return a battle phase label based on round count."""
|
| 88 |
+
if round_number <= 3:
|
| 89 |
+
return "explore"
|
| 90 |
+
if round_number <= 6:
|
| 91 |
+
return "pressure"
|
| 92 |
+
return "close"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _recent_history(session_id: str, max_turns: int = _HISTORY_WINDOW) -> list[dict]:
|
| 96 |
+
"""Return at most max_turns recent history entries for live inference."""
|
| 97 |
+
full = session_manager.get_history(session_id)
|
| 98 |
+
return full[-max_turns:] if len(full) > max_turns else full
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def handle_load_sample() -> dict[str, Any]:
|
| 102 |
+
"""Return the EventRadar AI demo startup."""
|
| 103 |
+
return {"startup": get_sample_startup()}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
# Prompt builders
|
| 108 |
+
# ---------------------------------------------------------------------------
|
| 109 |
+
|
| 110 |
+
def _build_opening_messages(
|
| 111 |
+
startup: dict,
|
| 112 |
+
persona: str,
|
| 113 |
+
difficulty: str,
|
| 114 |
+
attack_tag: str,
|
| 115 |
+
) -> list[dict[str, str]]:
|
| 116 |
+
"""Build the OpenAI-format messages list for the opening judge question."""
|
| 117 |
+
system_prompt = build_persona_prompt(persona, startup, difficulty)
|
| 118 |
+
tags = get_attack_tags(persona)
|
| 119 |
+
tags_preview = ", ".join(tags[:4])
|
| 120 |
+
|
| 121 |
+
user_content = (
|
| 122 |
+
f"Current attack focus: {attack_tag}\n"
|
| 123 |
+
f"Other pressure angles available: {tags_preview}\n\n"
|
| 124 |
+
"Open the battle. Ask your first hard question about the startup above. "
|
| 125 |
+
"Do not introduce yourself. Do not say hello. Go straight to the question. "
|
| 126 |
+
"Attack the weakest claim in the pitch. Keep it under 3 sentences. "
|
| 127 |
+
"Ask exactly one question."
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
return [
|
| 131 |
+
{"role": "system", "content": system_prompt},
|
| 132 |
+
{"role": "user", "content": user_content},
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _build_followup_messages(
|
| 137 |
+
startup: dict,
|
| 138 |
+
persona: str,
|
| 139 |
+
difficulty: str,
|
| 140 |
+
attack_tag: str,
|
| 141 |
+
history: list[dict[str, Any]],
|
| 142 |
+
judge_action: dict[str, Any],
|
| 143 |
+
answer_quality: dict[str, Any],
|
| 144 |
+
) -> list[dict[str, str]]:
|
| 145 |
+
"""Build the OpenAI-format messages list for a follow-up judge question.
|
| 146 |
+
|
| 147 |
+
The prompt instruction varies based on judge_action to guide Nemotron
|
| 148 |
+
toward the correct Socratic behavior:
|
| 149 |
+
- follow_up_same_tag → press harder on the same topic
|
| 150 |
+
- move_next_tag → acknowledge prior point, move cleanly
|
| 151 |
+
- move_after_limit → briefly flag unresolved point, move on
|
| 152 |
+
|
| 153 |
+
Voice mode note:
|
| 154 |
+
History entries may originate from typed text or voice transcripts.
|
| 155 |
+
The prompt wording uses "your answer" rather than "you typed" throughout.
|
| 156 |
+
"""
|
| 157 |
+
system_prompt = build_persona_prompt(persona, startup, difficulty)
|
| 158 |
+
|
| 159 |
+
messages: list[dict[str, str]] = [
|
| 160 |
+
{"role": "system", "content": system_prompt},
|
| 161 |
+
]
|
| 162 |
+
|
| 163 |
+
# Replay conversation history as role turns (strip attack_tag metadata)
|
| 164 |
+
for entry in history:
|
| 165 |
+
role = entry.get("role", "user")
|
| 166 |
+
content = entry.get("content", "")
|
| 167 |
+
if role == "assistant":
|
| 168 |
+
messages.append({"role": "assistant", "content": content})
|
| 169 |
+
else:
|
| 170 |
+
messages.append({"role": "user", "content": content})
|
| 171 |
+
|
| 172 |
+
action = judge_action.get("judge_action", "follow_up_same_tag")
|
| 173 |
+
prev_tag = judge_action.get("previous_attack_tag", attack_tag)
|
| 174 |
+
quality = answer_quality.get("quality", "partial")
|
| 175 |
+
transition = judge_action.get("transition_note", "")
|
| 176 |
+
|
| 177 |
+
if action == "follow_up_same_tag":
|
| 178 |
+
instruction = (
|
| 179 |
+
f"Attack focus: {attack_tag}\n"
|
| 180 |
+
f"Your answer was classified as {quality}. {transition}\n\n"
|
| 181 |
+
"The founder's last answer was insufficient. "
|
| 182 |
+
"Ask one sharper, more specific follow-up on the SAME topic. "
|
| 183 |
+
"Reference what they just said directly. "
|
| 184 |
+
"Do not move to a new topic yet. "
|
| 185 |
+
"Do not give advice. Do not say 'great answer' or 'interesting.' "
|
| 186 |
+
"Keep it under 3 sentences. Ask exactly one question."
|
| 187 |
+
)
|
| 188 |
+
elif action == "move_next_tag":
|
| 189 |
+
instruction = (
|
| 190 |
+
f"New attack focus: {attack_tag}\n"
|
| 191 |
+
f"Previous topic ({prev_tag}) is considered resolved. Do NOT revisit it.\n\n"
|
| 192 |
+
"The founder gave a sufficient answer on the previous point. "
|
| 193 |
+
"Move immediately to the new attack focus above. "
|
| 194 |
+
"Do not keep drilling the previous topic. "
|
| 195 |
+
"Do not say 'great answer', 'good point', 'well done', or any praise. "
|
| 196 |
+
"Do not ask multiple questions. "
|
| 197 |
+
"Do not give advice. "
|
| 198 |
+
"Ask exactly one hard, specific question on the new attack focus. "
|
| 199 |
+
"Keep the entire response under 3 sentences."
|
| 200 |
+
)
|
| 201 |
+
else: # move_after_limit
|
| 202 |
+
instruction = (
|
| 203 |
+
f"New attack focus: {attack_tag}\n"
|
| 204 |
+
f"Previous topic ({prev_tag}) remains unresolved. {transition}\n\n"
|
| 205 |
+
"Briefly note that the previous issue was not fully addressed — "
|
| 206 |
+
"one short clause only, then move on. "
|
| 207 |
+
"Ask one hard question on the new attack focus. "
|
| 208 |
+
"Do not keep drilling the unresolved point. "
|
| 209 |
+
"Do not give advice. Keep it under 4 sentences. Ask exactly one question."
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
messages.append({"role": "user", "content": instruction})
|
| 213 |
+
return messages
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# ---------------------------------------------------------------------------
|
| 217 |
+
# Handlers
|
| 218 |
+
# ---------------------------------------------------------------------------
|
| 219 |
+
|
| 220 |
+
def handle_start_session(payload: dict[str, Any]) -> dict[str, Any]:
|
| 221 |
+
"""Create a new pitch battle session and return the opening challenge."""
|
| 222 |
+
startup = payload.get("startup") or {}
|
| 223 |
+
persona = payload.get("persona", "technical_judge")
|
| 224 |
+
difficulty = payload.get("difficulty", "high")
|
| 225 |
+
input_mode = payload.get("input_mode", "text")
|
| 226 |
+
mode = payload.get("mode", "pitch_battle")
|
| 227 |
+
model_mode = payload.get("model_mode", "premium_nvidia")
|
| 228 |
+
|
| 229 |
+
session = session_manager.create_session(
|
| 230 |
+
startup, persona, difficulty, input_mode
|
| 231 |
+
)
|
| 232 |
+
session["mode"] = mode
|
| 233 |
+
session["model_mode"] = model_mode
|
| 234 |
+
|
| 235 |
+
mock_attack_tag, mock_ai_message = OPENING_MESSAGES.get(
|
| 236 |
+
persona, OPENING_MESSAGES["technical_judge"]
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
attack_tag = mock_attack_tag
|
| 240 |
+
ai_message = mock_ai_message
|
| 241 |
+
model_ok = False
|
| 242 |
+
provider = "mock"
|
| 243 |
+
used_model_mode = "mock_fallback"
|
| 244 |
+
model_error: str | None = None
|
| 245 |
+
|
| 246 |
+
try:
|
| 247 |
+
messages = _build_opening_messages(startup, persona, difficulty, mock_attack_tag)
|
| 248 |
+
result = model_router.generate_opponent_response(
|
| 249 |
+
messages,
|
| 250 |
+
model_mode=model_mode,
|
| 251 |
+
persona=persona,
|
| 252 |
+
attack_tag=mock_attack_tag,
|
| 253 |
+
)
|
| 254 |
+
if result.get("ok") and result.get("content"):
|
| 255 |
+
ai_message = sanitize_model_output(result["content"])
|
| 256 |
+
model_ok = True
|
| 257 |
+
provider = result.get("provider", "nvidia")
|
| 258 |
+
used_model_mode = result.get("model_mode", model_mode)
|
| 259 |
+
else:
|
| 260 |
+
model_error = result.get("error") or "Model returned empty response"
|
| 261 |
+
logger.warning("start_session: model not ok — using mock. error=%s", model_error)
|
| 262 |
+
except Exception as exc:
|
| 263 |
+
model_error = str(exc)
|
| 264 |
+
logger.warning("start_session: model call raised — using mock. error=%s", exc)
|
| 265 |
+
|
| 266 |
+
# Initialize battle_state with opening tag
|
| 267 |
+
battle_flow.init_opening_state(session, attack_tag)
|
| 268 |
+
|
| 269 |
+
session_manager.append_ai_message(session["session_id"], ai_message, attack_tag)
|
| 270 |
+
|
| 271 |
+
return {
|
| 272 |
+
"session_id": session["session_id"],
|
| 273 |
+
"round": 1,
|
| 274 |
+
"pressure_level": pressure_level(1),
|
| 275 |
+
"battle_phase": get_battle_phase(1),
|
| 276 |
+
"attack_tag": attack_tag,
|
| 277 |
+
"ai_message": ai_message,
|
| 278 |
+
"model_mode": used_model_mode,
|
| 279 |
+
"provider": provider,
|
| 280 |
+
"model_ok": model_ok,
|
| 281 |
+
"judge_action": "opening_question",
|
| 282 |
+
"answer_quality": None,
|
| 283 |
+
"topic_satisfied": None,
|
| 284 |
+
"tag_attempt": 1,
|
| 285 |
+
"soft_round_limit_reached": False,
|
| 286 |
+
"battle_complete": False,
|
| 287 |
+
"can_continue": True,
|
| 288 |
+
"next_action": "continue",
|
| 289 |
+
**({"model_error": model_error} if model_error else {}),
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def handle_chat_round(payload: dict[str, Any]) -> dict[str, Any]:
|
| 294 |
+
"""Process a user reply and return the next judge question.
|
| 295 |
+
|
| 296 |
+
Voice mode note:
|
| 297 |
+
user_message may be a typed string or a transcript from voice input.
|
| 298 |
+
battle_flow.classify_answer_quality() handles both the same way.
|
| 299 |
+
"""
|
| 300 |
+
session_id = payload.get("session_id", "")
|
| 301 |
+
message = (
|
| 302 |
+
payload.get("user_message") or payload.get("message") or ""
|
| 303 |
+
).strip()
|
| 304 |
+
|
| 305 |
+
session = session_manager.get_session(session_id)
|
| 306 |
+
if not session:
|
| 307 |
+
return {
|
| 308 |
+
"session_id": session_id,
|
| 309 |
+
"error": "Session not found",
|
| 310 |
+
"round": 0,
|
| 311 |
+
"pressure_level": "High",
|
| 312 |
+
"attack_tag": "Session Error",
|
| 313 |
+
"ai_message": "Session expired. Please start a new battle.",
|
| 314 |
+
"model_ok": False,
|
| 315 |
+
"provider": "none",
|
| 316 |
+
"model_mode": "none",
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
if message:
|
| 320 |
+
session_manager.append_user_message(session_id, message)
|
| 321 |
+
|
| 322 |
+
persona = session.get("persona", "technical_judge")
|
| 323 |
+
difficulty = session.get("difficulty", "high")
|
| 324 |
+
startup = session.get("startup", {})
|
| 325 |
+
model_mode = session.get("model_mode", "premium_nvidia")
|
| 326 |
+
next_round = session_manager.increment_round(session_id)
|
| 327 |
+
|
| 328 |
+
soft_limit = next_round >= MAX_ROUNDS
|
| 329 |
+
|
| 330 |
+
# Determine current attack tag from last AI message
|
| 331 |
+
current_attack_tag = battle_flow.get_current_attack_tag(session)
|
| 332 |
+
if not current_attack_tag:
|
| 333 |
+
current_attack_tag = get_next_attack_tag(persona, next_round)
|
| 334 |
+
|
| 335 |
+
# Classify answer quality (rule-based, no extra API call)
|
| 336 |
+
answer_quality_result = {"quality": "partial", "reason": "No message provided.", "signals": []}
|
| 337 |
+
if message:
|
| 338 |
+
try:
|
| 339 |
+
answer_quality_result = battle_flow.classify_answer_quality(message)
|
| 340 |
+
except Exception as exc:
|
| 341 |
+
logger.warning("battle_flow.classify_answer_quality error: %s", exc)
|
| 342 |
+
|
| 343 |
+
quality = answer_quality_result.get("quality", "partial")
|
| 344 |
+
|
| 345 |
+
# Decide judge action
|
| 346 |
+
judge_action_result: dict[str, Any] = {}
|
| 347 |
+
try:
|
| 348 |
+
judge_action_result = battle_flow.decide_next_judge_action(
|
| 349 |
+
session, current_attack_tag, quality, persona
|
| 350 |
+
)
|
| 351 |
+
except Exception as exc:
|
| 352 |
+
logger.warning("battle_flow.decide_next_judge_action error: %s", exc)
|
| 353 |
+
judge_action_result = {
|
| 354 |
+
"judge_action": "follow_up_same_tag",
|
| 355 |
+
"next_attack_tag": current_attack_tag,
|
| 356 |
+
"previous_attack_tag": current_attack_tag,
|
| 357 |
+
"attempt_number_for_tag": 1,
|
| 358 |
+
"topic_satisfied": False,
|
| 359 |
+
"transition_note": "Fallback due to decision error.",
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
# Update session battle state
|
| 363 |
+
try:
|
| 364 |
+
battle_flow.update_battle_state(session, current_attack_tag, answer_quality_result, judge_action_result)
|
| 365 |
+
except Exception as exc:
|
| 366 |
+
logger.warning("battle_flow.update_battle_state error: %s", exc)
|
| 367 |
+
|
| 368 |
+
attack_tag = judge_action_result.get("next_attack_tag", current_attack_tag)
|
| 369 |
+
|
| 370 |
+
# Mock fallback
|
| 371 |
+
followups = MOCK_FOLLOWUPS.get(persona, MOCK_FOLLOWUPS["technical_judge"])
|
| 372 |
+
index = min(next_round - 2, len(followups) - 1)
|
| 373 |
+
mock_ai_message = followups[max(0, index)]
|
| 374 |
+
|
| 375 |
+
ai_message = mock_ai_message
|
| 376 |
+
model_ok = False
|
| 377 |
+
provider = "mock"
|
| 378 |
+
used_model_mode = "mock_fallback"
|
| 379 |
+
model_error: str | None = None
|
| 380 |
+
|
| 381 |
+
try:
|
| 382 |
+
# Cap history sent to model; full history preserved in session for scorecard
|
| 383 |
+
recent_history = _recent_history(session_id)
|
| 384 |
+
messages = _build_followup_messages(
|
| 385 |
+
startup,
|
| 386 |
+
persona,
|
| 387 |
+
difficulty,
|
| 388 |
+
attack_tag,
|
| 389 |
+
recent_history,
|
| 390 |
+
judge_action_result,
|
| 391 |
+
answer_quality_result,
|
| 392 |
+
)
|
| 393 |
+
result = model_router.generate_opponent_response(
|
| 394 |
+
messages,
|
| 395 |
+
model_mode=model_mode,
|
| 396 |
+
persona=persona,
|
| 397 |
+
attack_tag=attack_tag,
|
| 398 |
+
)
|
| 399 |
+
if result.get("ok") and result.get("content"):
|
| 400 |
+
ai_message = sanitize_model_output(result["content"])
|
| 401 |
+
model_ok = True
|
| 402 |
+
provider = result.get("provider", "nvidia")
|
| 403 |
+
used_model_mode = result.get("model_mode", model_mode)
|
| 404 |
+
else:
|
| 405 |
+
model_error = result.get("error") or "Model returned empty response"
|
| 406 |
+
logger.warning("chat_round: model not ok — using mock. error=%s", model_error)
|
| 407 |
+
except Exception as exc:
|
| 408 |
+
model_error = str(exc)
|
| 409 |
+
logger.warning("chat_round: model call raised — using mock. error=%s", exc)
|
| 410 |
+
|
| 411 |
+
session_manager.append_ai_message(session_id, ai_message, attack_tag)
|
| 412 |
+
|
| 413 |
+
return {
|
| 414 |
+
"session_id": session_id,
|
| 415 |
+
"round": next_round,
|
| 416 |
+
"pressure_level": pressure_level(next_round),
|
| 417 |
+
"battle_phase": get_battle_phase(next_round),
|
| 418 |
+
"attack_tag": attack_tag,
|
| 419 |
+
"ai_message": ai_message,
|
| 420 |
+
"model_mode": used_model_mode,
|
| 421 |
+
"provider": provider,
|
| 422 |
+
"model_ok": model_ok,
|
| 423 |
+
"answer_quality": quality,
|
| 424 |
+
"answer_quality_reason": answer_quality_result.get("reason", ""),
|
| 425 |
+
"judge_action": judge_action_result.get("judge_action", "follow_up_same_tag"),
|
| 426 |
+
"previous_attack_tag": judge_action_result.get("previous_attack_tag", current_attack_tag),
|
| 427 |
+
"topic_satisfied": judge_action_result.get("topic_satisfied", False),
|
| 428 |
+
"tag_attempt": judge_action_result.get("attempt_number_for_tag", 1),
|
| 429 |
+
"battle_complete": False,
|
| 430 |
+
"can_continue": True,
|
| 431 |
+
"next_action": "continue",
|
| 432 |
+
"soft_round_limit_reached": soft_limit,
|
| 433 |
+
"rounds_soft_limit_reached": soft_limit,
|
| 434 |
+
"recommended_action": "end_battle" if soft_limit else None,
|
| 435 |
+
"completion_message": (
|
| 436 |
+
"You have enough material for a scorecard. You can end the battle now or continue practicing."
|
| 437 |
+
if soft_limit else None
|
| 438 |
+
),
|
| 439 |
+
**({"model_error": model_error} if model_error else {}),
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def handle_end_battle(payload: dict[str, Any]) -> dict[str, Any]:
|
| 444 |
+
"""Generate and return a Nemotron scorecard for the completed battle.
|
| 445 |
+
|
| 446 |
+
Falls back to mock_scorecard if the model call or JSON parsing fails.
|
| 447 |
+
Never crashes for a valid session.
|
| 448 |
+
|
| 449 |
+
Voice mode note:
|
| 450 |
+
Session history contains plain text regardless of input source.
|
| 451 |
+
No changes are needed here when voice mode is integrated.
|
| 452 |
+
"""
|
| 453 |
+
session_id = payload.get("session_id", "")
|
| 454 |
+
session = session_manager.get_session(session_id)
|
| 455 |
+
if not session:
|
| 456 |
+
return {"error": "Session not found"}
|
| 457 |
+
|
| 458 |
+
try:
|
| 459 |
+
scorecard = generate_claim_based_scorecard(session)
|
| 460 |
+
except Exception as exc:
|
| 461 |
+
logger.warning("handle_end_battle: generate_claim_based_scorecard raised: %s", exc)
|
| 462 |
+
try:
|
| 463 |
+
signals = extract_concrete_signals(session)
|
| 464 |
+
scorecard = build_session_aware_fallback_scorecard(
|
| 465 |
+
session, signals, f"Scorecard generation error: {type(exc).__name__}"
|
| 466 |
+
)
|
| 467 |
+
except Exception as exc2:
|
| 468 |
+
logger.warning("handle_end_battle: session-aware fallback also raised: %s", exc2)
|
| 469 |
+
scorecard = mock_scorecard(session)
|
| 470 |
+
scorecard["model_error"] = f"Scorecard generation error: {type(exc).__name__}"
|
| 471 |
+
|
| 472 |
+
return scorecard
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
def handle_reset_session(payload: dict[str, Any]) -> dict[str, Any]:
|
| 476 |
+
"""Clear a battle session."""
|
| 477 |
+
session_id = payload.get("session_id", "")
|
| 478 |
+
session_manager.reset_session(session_id)
|
| 479 |
+
return {"status": "reset"}
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
def handle_voice_pitch_placeholder(_payload: dict[str, Any] | None = None) -> dict[str, str]:
|
| 483 |
+
"""Reserved endpoint for voice pitch mode."""
|
| 484 |
+
return {
|
| 485 |
+
"status": "not_implemented",
|
| 486 |
+
"message": (
|
| 487 |
+
"Voice Mode endpoint is reserved and will be connected "
|
| 488 |
+
"after transcription integration."
|
| 489 |
+
),
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
def handle_deal_session_placeholder(_payload: dict[str, Any] | None = None) -> dict[str, str]:
|
| 494 |
+
"""Reserved endpoint for Deal Battle mode."""
|
| 495 |
+
return {
|
| 496 |
+
"status": "not_implemented",
|
| 497 |
+
"message": (
|
| 498 |
+
"Deal Battle endpoint is reserved and will be connected "
|
| 499 |
+
"in a later phase."
|
| 500 |
+
),
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def handle_deck_critique_placeholder(_payload: dict[str, Any] | None = None) -> dict[str, str]:
|
| 505 |
+
"""Reserved endpoint for pitch deck critique."""
|
| 506 |
+
return {
|
| 507 |
+
"status": "not_implemented",
|
| 508 |
+
"message": (
|
| 509 |
+
"Deck critique endpoint is reserved and will be connected "
|
| 510 |
+
"after MiniCPM-V vision integration."
|
| 511 |
+
),
|
| 512 |
+
}
|
core/attack_tags.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Attack tag taxonomy and round-based tag selection."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
ATTACK_TAGS: dict[str, list[str]] = {
|
| 6 |
+
"skeptical_vc": [
|
| 7 |
+
"Market Size",
|
| 8 |
+
"Moat",
|
| 9 |
+
"Retention",
|
| 10 |
+
"Revenue Logic",
|
| 11 |
+
"First 100 Users",
|
| 12 |
+
"Why Now",
|
| 13 |
+
"Competition",
|
| 14 |
+
"Defensibility",
|
| 15 |
+
],
|
| 16 |
+
"technical_judge": [
|
| 17 |
+
"AI Justification",
|
| 18 |
+
"Architecture",
|
| 19 |
+
"Scalability",
|
| 20 |
+
"Latency",
|
| 21 |
+
"Data Quality",
|
| 22 |
+
"Failure Mode",
|
| 23 |
+
"Simpler Alternative",
|
| 24 |
+
"Technical Feasibility",
|
| 25 |
+
],
|
| 26 |
+
"hackathon_judge": [
|
| 27 |
+
"Novelty",
|
| 28 |
+
"Demo Clarity",
|
| 29 |
+
"MVP Strength",
|
| 30 |
+
"User Pain",
|
| 31 |
+
"AI Load-Bearing",
|
| 32 |
+
"Backyard Fit",
|
| 33 |
+
"Practical Impact",
|
| 34 |
+
"Judging Memorability",
|
| 35 |
+
],
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def get_attack_tags(persona: str) -> list[str]:
|
| 40 |
+
"""Return attack tags for a persona."""
|
| 41 |
+
return list(ATTACK_TAGS.get(persona, ATTACK_TAGS["technical_judge"]))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def get_next_attack_tag(persona: str, round_number: int) -> str:
|
| 45 |
+
"""Pick the next attack tag based on persona and round (1-indexed)."""
|
| 46 |
+
tags = get_attack_tags(persona)
|
| 47 |
+
if not tags:
|
| 48 |
+
return "General Pressure"
|
| 49 |
+
index = max(0, round_number - 1) % len(tags)
|
| 50 |
+
return tags[index]
|
core/battle_flow.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Battle flow controller for PitchFight AI.
|
| 2 |
+
|
| 3 |
+
Controls Socratic judge pacing:
|
| 4 |
+
- Classifies each founder answer (strong / partial / weak / non_answer)
|
| 5 |
+
- Decides whether to follow up on the same attack tag or move to the next one
|
| 6 |
+
- Enforces MAX_ATTEMPTS_PER_ATTACK_TAG so no topic is drilled forever
|
| 7 |
+
- Maintains per-session battle state (tag_attempts, outcomes, completed_tags)
|
| 8 |
+
|
| 9 |
+
Voice mode note:
|
| 10 |
+
Future voice mode will pass transcripts into the same classify_answer_quality()
|
| 11 |
+
and decide_next_judge_action() functions without any changes here.
|
| 12 |
+
This module never assumes keyboard input — it only sees text strings.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import re
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
from core.attack_tags import get_attack_tags
|
| 21 |
+
|
| 22 |
+
MAX_ATTEMPTS_PER_ATTACK_TAG = 2
|
| 23 |
+
MAX_FOLLOWUPS_PER_ATTACK_TAG = 1 # same as attempts - 1 (first is always a fresh question)
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Weak-signal patterns (rule-based, no extra API call)
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
_NON_ANSWER_PHRASES = {
|
| 30 |
+
"ok", "okay", "idk", "i don't know", "dont know", "don't know",
|
| 31 |
+
"not sure", "maybe", "no idea", "hmm", "i'm not sure", "im not sure",
|
| 32 |
+
"i dont know", "i have no idea", "not really", "yeah", "sure",
|
| 33 |
+
"fine", "alright", "whatever", "pass",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
_VAGUE_WORDS = {
|
| 37 |
+
"big", "huge", "large", "massive", "many", "everyone", "anybody",
|
| 38 |
+
"helpful", "useful", "better", "good", "great", "nice", "amazing",
|
| 39 |
+
"smart", "intelligent", "automatically", "seamlessly", "easily",
|
| 40 |
+
"quickly", "simply", "pretty", "kind", "sort", "basically",
|
| 41 |
+
"generally", "typically", "usually", "obviously", "clearly",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
_STRONG_SIGNALS = [
|
| 45 |
+
# Concrete numbers attached to evidence nouns
|
| 46 |
+
r"\d+\s*%", # 12%
|
| 47 |
+
r"\d+\s*\w*\s*(users?|signups?|students?|schools?|campuses?|installs?|teams?|pilots?|ambassadors?|colleges?|universities)",
|
| 48 |
+
r"\$\s*\d+", # $50
|
| 49 |
+
r"\d+\s*(k|m|b)\b", # 10k, 2m
|
| 50 |
+
# Product and business metrics
|
| 51 |
+
r"\b(dau|mau|wau|retention|churn|ltv|arpu|cac|nps)\b",
|
| 52 |
+
r"\b(paying|waitlist|revenue|traction|mrr|arr)\b",
|
| 53 |
+
# User research and validation
|
| 54 |
+
r"\b(interviewed|surveyed|measured|validated|tested|piloted)\b",
|
| 55 |
+
r"\b(user interview|data point|conversion rate|event.miss report)\b",
|
| 56 |
+
# Named target segment (specific, not vague "everyone")
|
| 57 |
+
r"\b(cs students?|engineering students?|stem students?|mba students?|undergrad|sophomore|freshman|senior year)\b",
|
| 58 |
+
r"\b(iit|nit|bits|vit|college name|university name)\b",
|
| 59 |
+
# Competitor differentiation
|
| 60 |
+
r"\b(unlike\s+\w+|vs\.?\s+\w+|compared to\s+\w+|instead of\s+\w+|luma|lu\.ma|eventbrite|meetup|devfolio|unstop)\b",
|
| 61 |
+
# Revenue or retention logic
|
| 62 |
+
r"\b(monthly recurring|annual recurring|subscription|freemium|pay per|sponsor pays|college pays)\b",
|
| 63 |
+
# Specific technical explanation (more than buzzwords)
|
| 64 |
+
r"\b(embedding|vector|fine.?tun|retrieval|ranking model|cosine similarity|recommendation engine)\b",
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
_STRONG_PATTERN = re.compile(
|
| 68 |
+
"|".join(_STRONG_SIGNALS),
|
| 69 |
+
re.IGNORECASE,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def classify_answer_quality(answer: str) -> dict[str, Any]:
|
| 74 |
+
"""Classify a founder answer without making any API calls.
|
| 75 |
+
|
| 76 |
+
Voice mode note:
|
| 77 |
+
Accepts text from any source — typed input or voice transcript.
|
| 78 |
+
The caller is responsible for converting audio to text before calling here.
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
{
|
| 82 |
+
"quality": "strong" | "partial" | "weak" | "non_answer",
|
| 83 |
+
"reason": human-readable explanation,
|
| 84 |
+
"signals": list of matched evidence signals (may be empty),
|
| 85 |
+
}
|
| 86 |
+
"""
|
| 87 |
+
text = answer.strip()
|
| 88 |
+
words = text.split()
|
| 89 |
+
word_count = len(words)
|
| 90 |
+
lower = text.lower()
|
| 91 |
+
|
| 92 |
+
# --- non_answer: empty, trivially short, or known filler phrase ---
|
| 93 |
+
if word_count < 4:
|
| 94 |
+
return {
|
| 95 |
+
"quality": "non_answer",
|
| 96 |
+
"reason": "Answer is too short to evaluate.",
|
| 97 |
+
"signals": [],
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
if lower in _NON_ANSWER_PHRASES or any(
|
| 101 |
+
lower.startswith(p) or lower == p for p in _NON_ANSWER_PHRASES
|
| 102 |
+
):
|
| 103 |
+
return {
|
| 104 |
+
"quality": "non_answer",
|
| 105 |
+
"reason": "Answer is a known non-response phrase.",
|
| 106 |
+
"signals": [],
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
# --- strong: contains concrete evidence signals ---
|
| 110 |
+
strong_hits = [m.group(0) for m in _STRONG_PATTERN.finditer(text)]
|
| 111 |
+
if strong_hits:
|
| 112 |
+
unique = list({h.strip().lower() for h in strong_hits if h.strip()})
|
| 113 |
+
return {
|
| 114 |
+
"quality": "strong",
|
| 115 |
+
"reason": "Answer contains concrete evidence or data.",
|
| 116 |
+
"signals": unique[:5],
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
# --- weak: short or dominated by vague terms ---
|
| 120 |
+
lower_words = set(re.findall(r"\w+", lower))
|
| 121 |
+
vague_overlap = lower_words & _VAGUE_WORDS
|
| 122 |
+
vague_count = len(vague_overlap)
|
| 123 |
+
vague_ratio = vague_count / max(word_count, 1)
|
| 124 |
+
|
| 125 |
+
# A sentence is weak if it's short, has a high vague ratio,
|
| 126 |
+
# OR contains 2+ vague adjectives that carry the main claim
|
| 127 |
+
is_weak = word_count < 8 or vague_ratio >= 0.20 or (vague_count >= 2 and word_count < 20)
|
| 128 |
+
|
| 129 |
+
if is_weak:
|
| 130 |
+
return {
|
| 131 |
+
"quality": "weak",
|
| 132 |
+
"reason": (
|
| 133 |
+
"Answer is too short or relies on vague claims without evidence."
|
| 134 |
+
+ (f" Vague words: {', '.join(sorted(vague_overlap)[:3])}." if vague_overlap else "")
|
| 135 |
+
),
|
| 136 |
+
"signals": [],
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
# --- partial: answer has some content but no strong signals ---
|
| 140 |
+
return {
|
| 141 |
+
"quality": "partial",
|
| 142 |
+
"reason": "Answer gives some reasoning but lacks concrete evidence or numbers.",
|
| 143 |
+
"signals": [],
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
# Battle state helpers
|
| 149 |
+
# ---------------------------------------------------------------------------
|
| 150 |
+
|
| 151 |
+
def _init_battle_state(session: dict) -> dict:
|
| 152 |
+
"""Return existing battle_state or create a fresh one."""
|
| 153 |
+
if "battle_state" not in session:
|
| 154 |
+
session["battle_state"] = {
|
| 155 |
+
"tag_attempts": {}, # {attack_tag: int}
|
| 156 |
+
"tag_outcomes": {}, # {attack_tag: "resolved" | "unresolved"}
|
| 157 |
+
"last_answer_quality": {},
|
| 158 |
+
"last_judge_action": {},
|
| 159 |
+
"completed_tags": [],
|
| 160 |
+
}
|
| 161 |
+
return session["battle_state"]
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def init_opening_state(session: dict, opening_attack_tag: str) -> None:
|
| 165 |
+
"""Initialize battle_state at session start with the opening tag attempt."""
|
| 166 |
+
state = _init_battle_state(session)
|
| 167 |
+
state["tag_attempts"][opening_attack_tag] = 1
|
| 168 |
+
state["last_judge_action"] = {
|
| 169 |
+
"judge_action": "opening_question",
|
| 170 |
+
"next_attack_tag": opening_attack_tag,
|
| 171 |
+
"previous_attack_tag": None,
|
| 172 |
+
"attempt_number_for_tag": 1,
|
| 173 |
+
"topic_satisfied": None,
|
| 174 |
+
"transition_note": "Opening question.",
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def decide_next_judge_action(
|
| 179 |
+
session: dict,
|
| 180 |
+
current_attack_tag: str,
|
| 181 |
+
answer_quality: str,
|
| 182 |
+
persona: str,
|
| 183 |
+
) -> dict[str, Any]:
|
| 184 |
+
"""Decide what the judge should do next based on answer quality and tag history.
|
| 185 |
+
|
| 186 |
+
Returns:
|
| 187 |
+
{
|
| 188 |
+
"judge_action": "follow_up_same_tag" | "move_next_tag" | "move_after_limit",
|
| 189 |
+
"next_attack_tag": str,
|
| 190 |
+
"previous_attack_tag": str,
|
| 191 |
+
"attempt_number_for_tag": int,
|
| 192 |
+
"topic_satisfied": bool,
|
| 193 |
+
"transition_note": str,
|
| 194 |
+
}
|
| 195 |
+
"""
|
| 196 |
+
state = _init_battle_state(session)
|
| 197 |
+
tag_attempts: dict[str, int] = state.get("tag_attempts", {})
|
| 198 |
+
|
| 199 |
+
current_attempts = tag_attempts.get(current_attack_tag, 1)
|
| 200 |
+
|
| 201 |
+
# --- Strong answer: topic done, advance ---
|
| 202 |
+
if answer_quality == "strong":
|
| 203 |
+
state["tag_outcomes"][current_attack_tag] = "resolved"
|
| 204 |
+
if current_attack_tag not in state["completed_tags"]:
|
| 205 |
+
state["completed_tags"].append(current_attack_tag)
|
| 206 |
+
|
| 207 |
+
next_tag = _pick_next_tag(persona, state)
|
| 208 |
+
tag_attempts[next_tag] = tag_attempts.get(next_tag, 0) + 1
|
| 209 |
+
|
| 210 |
+
return {
|
| 211 |
+
"judge_action": "move_next_tag",
|
| 212 |
+
"next_attack_tag": next_tag,
|
| 213 |
+
"previous_attack_tag": current_attack_tag,
|
| 214 |
+
"attempt_number_for_tag": tag_attempts[next_tag],
|
| 215 |
+
"topic_satisfied": True,
|
| 216 |
+
"transition_note": (
|
| 217 |
+
f"Founder addressed {current_attack_tag} adequately. Moving to {next_tag}."
|
| 218 |
+
),
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
# --- Weak / partial / non_answer ---
|
| 222 |
+
if current_attempts < MAX_ATTEMPTS_PER_ATTACK_TAG:
|
| 223 |
+
# Follow up on the same tag
|
| 224 |
+
tag_attempts[current_attack_tag] = current_attempts + 1
|
| 225 |
+
|
| 226 |
+
return {
|
| 227 |
+
"judge_action": "follow_up_same_tag",
|
| 228 |
+
"next_attack_tag": current_attack_tag,
|
| 229 |
+
"previous_attack_tag": current_attack_tag,
|
| 230 |
+
"attempt_number_for_tag": current_attempts + 1,
|
| 231 |
+
"topic_satisfied": False,
|
| 232 |
+
"transition_note": (
|
| 233 |
+
f"Answer was {answer_quality}. Pressing harder on {current_attack_tag} "
|
| 234 |
+
f"(attempt {current_attempts + 1}/{MAX_ATTEMPTS_PER_ATTACK_TAG})."
|
| 235 |
+
),
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
# --- Limit reached: mark unresolved, move on ---
|
| 239 |
+
state["tag_outcomes"][current_attack_tag] = "unresolved"
|
| 240 |
+
if current_attack_tag not in state["completed_tags"]:
|
| 241 |
+
state["completed_tags"].append(current_attack_tag)
|
| 242 |
+
|
| 243 |
+
next_tag = _pick_next_tag(persona, state)
|
| 244 |
+
tag_attempts[next_tag] = tag_attempts.get(next_tag, 0) + 1
|
| 245 |
+
|
| 246 |
+
return {
|
| 247 |
+
"judge_action": "move_after_limit",
|
| 248 |
+
"next_attack_tag": next_tag,
|
| 249 |
+
"previous_attack_tag": current_attack_tag,
|
| 250 |
+
"attempt_number_for_tag": tag_attempts[next_tag],
|
| 251 |
+
"topic_satisfied": False,
|
| 252 |
+
"transition_note": (
|
| 253 |
+
f"Max attempts reached for {current_attack_tag} — moving to {next_tag}."
|
| 254 |
+
),
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def update_battle_state(
|
| 259 |
+
session: dict,
|
| 260 |
+
attack_tag: str,
|
| 261 |
+
answer_quality: dict[str, Any],
|
| 262 |
+
judge_action: dict[str, Any],
|
| 263 |
+
) -> dict[str, Any]:
|
| 264 |
+
"""Persist answer quality and judge action into session battle_state."""
|
| 265 |
+
state = _init_battle_state(session)
|
| 266 |
+
state["last_answer_quality"] = answer_quality
|
| 267 |
+
state["last_judge_action"] = judge_action
|
| 268 |
+
return state
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def get_current_attack_tag(session: dict) -> str | None:
|
| 272 |
+
"""Return the attack tag from the last AI message stored in history."""
|
| 273 |
+
history = session.get("history", [])
|
| 274 |
+
for entry in reversed(history):
|
| 275 |
+
if entry.get("role") == "assistant" and entry.get("attack_tag"):
|
| 276 |
+
return entry["attack_tag"]
|
| 277 |
+
return None
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
# ---------------------------------------------------------------------------
|
| 281 |
+
# Internal: pick the next tag not yet exhausted
|
| 282 |
+
# ---------------------------------------------------------------------------
|
| 283 |
+
|
| 284 |
+
def _pick_next_tag(persona: str, state: dict) -> str:
|
| 285 |
+
"""Select the next attack tag, preferring unused ones."""
|
| 286 |
+
all_tags = get_attack_tags(persona)
|
| 287 |
+
tag_attempts: dict[str, int] = state.get("tag_attempts", {})
|
| 288 |
+
completed: list[str] = state.get("completed_tags", [])
|
| 289 |
+
|
| 290 |
+
# Prefer tags not yet attempted
|
| 291 |
+
for tag in all_tags:
|
| 292 |
+
if tag not in tag_attempts:
|
| 293 |
+
return tag
|
| 294 |
+
|
| 295 |
+
# Fall back to tags with lowest attempts that aren't in completed
|
| 296 |
+
remaining = [t for t in all_tags if t not in completed]
|
| 297 |
+
if remaining:
|
| 298 |
+
return min(remaining, key=lambda t: tag_attempts.get(t, 0))
|
| 299 |
+
|
| 300 |
+
# All tags exhausted — cycle from the beginning
|
| 301 |
+
return all_tags[0] if all_tags else "General Pressure"
|
core/claim_extractor.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Concrete claim and signal extractor for PitchFight AI scorecard.
|
| 2 |
+
|
| 3 |
+
Extracts evidence signals from founder answers using regex/rule-based logic.
|
| 4 |
+
No API calls — fast, local, deterministic.
|
| 5 |
+
|
| 6 |
+
Used by scoring_engine.py to:
|
| 7 |
+
1. Enrich the Nemotron scoring prompt with pre-extracted signal data
|
| 8 |
+
2. Build session-aware fallback scorecards when model scoring fails
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
# Compiled patterns
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
|
| 20 |
+
_NUMBER_METRIC = re.compile(
|
| 21 |
+
r"\b\d[\d,]*\.?\d*\s*"
|
| 22 |
+
r"(?:%|percent|users?|students?|signups?|schools?|campuses?|"
|
| 23 |
+
r"colleges?|installs?|ambassadors?|interviews?|pilots?|teams?|"
|
| 24 |
+
r"paying|members?|founders?|customers?|clients?|respondents?|"
|
| 25 |
+
r"participants?|responses?)\b"
|
| 26 |
+
r"|\b\d[\d,]+\b",
|
| 27 |
+
re.IGNORECASE,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
_PERCENTAGE = re.compile(
|
| 31 |
+
r"\b\d+\.?\d*\s*%|\b\d+\.?\d*\s*percent\b",
|
| 32 |
+
re.IGNORECASE,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
_CURRENCY = re.compile(
|
| 36 |
+
r"[₹$€£¥]\s*\d[\d,.]*"
|
| 37 |
+
r"|\b\d[\d,.]*\s*(?:rupees?|dollars?|usd|inr|cad|euros?)\b"
|
| 38 |
+
r"|\brs\.?\s*\d[\d,.]*\b",
|
| 39 |
+
re.IGNORECASE,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
_USER_COUNT = re.compile(
|
| 43 |
+
r"\b\d+\s*(?:beta\s+)?users?\b"
|
| 44 |
+
r"|\b\d+\s*(?:beta\s+)?signups?\b"
|
| 45 |
+
r"|\b\d+\s*students?\b"
|
| 46 |
+
r"|\b\d+\s*members?\b"
|
| 47 |
+
r"|\b\d+\s*paying\b"
|
| 48 |
+
r"|\b\d+\s*customers?\b",
|
| 49 |
+
re.IGNORECASE,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
_VALIDATION = re.compile(
|
| 53 |
+
r"\b(?:beta|pilot|prototype|mvp|tested|validated|launched|surveyed|"
|
| 54 |
+
r"interviewed|user research|focus group|waitlist|signups?|onboarding|"
|
| 55 |
+
r"usability test|field test|user test|a/b test|experiment)\b"
|
| 56 |
+
r"|\bevent.?miss reports?\b"
|
| 57 |
+
r"|\bcampus ambassadors?\b",
|
| 58 |
+
re.IGNORECASE,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
_COLLEGE_CAMPUS = re.compile(
|
| 62 |
+
r"\b(?:campus(?:es)?|colleges?|universities|university|iit|nit|bits|vit|"
|
| 63 |
+
r"mit|stanford|oxford|ambassadors?|chapters?)\b",
|
| 64 |
+
re.IGNORECASE,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
_COMPETITORS = re.compile(
|
| 68 |
+
r"\b(?:luma|lu\.ma|eventbrite|devfolio|unstop|meetup|linkedin|facebook|"
|
| 69 |
+
r"whatsapp|google|notion|airtable|twitter|x\.com|slack|discord|"
|
| 70 |
+
r"internshala|naukri|glassdoor|monster)\b"
|
| 71 |
+
r"|\b(?:competitors?|alternatives?)\b"
|
| 72 |
+
r"|\bunlike\s+\w+\b"
|
| 73 |
+
r"|\bvs\.?\s+\w+\b",
|
| 74 |
+
re.IGNORECASE,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
_TECH_MECHANISM = re.compile(
|
| 78 |
+
r"\b(?:embedding|vector|fine.?tun|retrieval|ranking model|cosine similarity|"
|
| 79 |
+
r"recommendation engine|nlp|llm|transformer|gpt|bert|neural|classifier|"
|
| 80 |
+
r"semantic search|knowledge graph|rag|inference|profile matching|"
|
| 81 |
+
r"skill.based|personali[sz]ation|latency|throughput|model|algorithm|"
|
| 82 |
+
r"api|webhook|scraping|crawler|pipeline)\b",
|
| 83 |
+
re.IGNORECASE,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
_REVENUE = re.compile(
|
| 87 |
+
r"\b(?:revenue|mrr|arr|subscription|freemium|pay per|college pays|"
|
| 88 |
+
r"sponsor pays|b2b|b2c|saas|monetize|price|pricing|charge|conversion|"
|
| 89 |
+
r"cac|ltv|arpu|monthly plan|annual plan|tier|upsell|markup|margin|"
|
| 90 |
+
r"transaction fee|commission|licensing|enterprise)\b",
|
| 91 |
+
re.IGNORECASE,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
_RETENTION = re.compile(
|
| 95 |
+
r"\b(?:retention|churn|dau|mau|wau|weekly active|daily active|"
|
| 96 |
+
r"returning users?|re.engagement|habit|repeat|sticky|lock.in|"
|
| 97 |
+
r"notification|reminder|follow.?up|engagement rate)\b",
|
| 98 |
+
re.IGNORECASE,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
_GTM = re.compile(
|
| 102 |
+
r"\b(?:gtm|go.to.market|acquisition|channel|referral|viral|"
|
| 103 |
+
r"word.of.mouth|ambassador|campus rep|partnership|integration|"
|
| 104 |
+
r"distribution|onboard|launch|rollout|phase\s*\d)\b",
|
| 105 |
+
re.IGNORECASE,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
_VAGUE_PHRASES = re.compile(
|
| 109 |
+
r"\bbig market\b|\bhuge market\b|\blarge market\b"
|
| 110 |
+
r"|\beveryone\b|\banybody\b"
|
| 111 |
+
r"|\buseful for\b|\bhelpful for\b|\bgood for\b|\bgreat for\b"
|
| 112 |
+
r"|\bai will\b|\bai can\b"
|
| 113 |
+
r"|\bautomatically\b|\bseamlessly\b|\beasily\b|\bquickly\b"
|
| 114 |
+
r"|\bbasically\b|\bgenerally\b|\btypically\b|\bobviously\b",
|
| 115 |
+
re.IGNORECASE,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
_NON_ANSWER_EXACT: frozenset[str] = frozenset({
|
| 119 |
+
"ok", "okay", "idk", "i don't know", "dont know", "don't know",
|
| 120 |
+
"not sure", "maybe", "no idea", "hmm", "i'm not sure", "im not sure",
|
| 121 |
+
"i dont know", "i have no idea", "not really", "yeah", "sure",
|
| 122 |
+
"fine", "alright", "whatever", "pass", "i'll think about it",
|
| 123 |
+
"we'll figure it out", "good question",
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ---------------------------------------------------------------------------
|
| 128 |
+
# Helpers
|
| 129 |
+
# ---------------------------------------------------------------------------
|
| 130 |
+
|
| 131 |
+
def _match_all(pattern: re.Pattern, text: str) -> list[str]:
|
| 132 |
+
return [m.group(0).strip() for m in pattern.finditer(text)]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _dedup(lst: list[str]) -> list[str]:
|
| 136 |
+
seen: set[str] = set()
|
| 137 |
+
out: list[str] = []
|
| 138 |
+
for item in lst:
|
| 139 |
+
key = item.lower().strip()
|
| 140 |
+
if key and key not in seen:
|
| 141 |
+
seen.add(key)
|
| 142 |
+
out.append(item)
|
| 143 |
+
return out
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _is_non_answer(text: str) -> bool:
|
| 147 |
+
stripped = text.strip().lower()
|
| 148 |
+
if not stripped or len(stripped.split()) < 4:
|
| 149 |
+
return True
|
| 150 |
+
return stripped in _NON_ANSWER_EXACT or any(
|
| 151 |
+
stripped.startswith(p) for p in _NON_ANSWER_EXACT
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# ---------------------------------------------------------------------------
|
| 156 |
+
# Main extractor
|
| 157 |
+
# ---------------------------------------------------------------------------
|
| 158 |
+
|
| 159 |
+
def extract_concrete_signals(session: dict) -> dict[str, Any]:
|
| 160 |
+
"""Extract evidence signals from all user turns in a session.
|
| 161 |
+
|
| 162 |
+
Processes only user-role messages. No API calls.
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
{
|
| 166 |
+
"numbers": list[str], # raw number matches
|
| 167 |
+
"percentages": list[str],
|
| 168 |
+
"pricing": list[str], # currency amounts
|
| 169 |
+
"user_counts": list[str], # "50 beta users", etc.
|
| 170 |
+
"validation": list[str], # tested / piloted / surveyed
|
| 171 |
+
"college_mentions": list[str], # campus / college / IIT etc.
|
| 172 |
+
"competitors": list[str], # named competitors
|
| 173 |
+
"technical_mechanisms": list[str], # embedding / ranking model etc.
|
| 174 |
+
"revenue_signals": list[str], # subscription / pricing / CAC
|
| 175 |
+
"retention_signals": list[str], # churn / DAU / retention
|
| 176 |
+
"gtm_signals": list[str], # referral / ambassador / launch
|
| 177 |
+
"non_answers": list[str], # evasion/non-answer turns
|
| 178 |
+
"vague_claims": list[str], # buzzword phrases
|
| 179 |
+
"best_user_quotes": list[str], # up to 5 most signal-dense answers
|
| 180 |
+
"all_user_answers": list[str], # every user message
|
| 181 |
+
"signal_count": int, # total unique signals found
|
| 182 |
+
}
|
| 183 |
+
"""
|
| 184 |
+
history = session.get("history", [])
|
| 185 |
+
user_answers = [
|
| 186 |
+
e["content"].strip()
|
| 187 |
+
for e in history
|
| 188 |
+
if e.get("role") == "user" and e.get("content", "").strip()
|
| 189 |
+
]
|
| 190 |
+
|
| 191 |
+
all_numbers: list[str] = []
|
| 192 |
+
all_pct: list[str] = []
|
| 193 |
+
all_pricing: list[str] = []
|
| 194 |
+
all_user_counts: list[str] = []
|
| 195 |
+
all_validation: list[str] = []
|
| 196 |
+
all_colleges: list[str] = []
|
| 197 |
+
all_competitors: list[str] = []
|
| 198 |
+
all_tech: list[str] = []
|
| 199 |
+
all_revenue: list[str] = []
|
| 200 |
+
all_retention: list[str] = []
|
| 201 |
+
all_gtm: list[str] = []
|
| 202 |
+
all_vague: list[str] = []
|
| 203 |
+
non_answer_turns: list[str] = []
|
| 204 |
+
answer_scores: list[tuple[int, str]] = []
|
| 205 |
+
|
| 206 |
+
for ans in user_answers:
|
| 207 |
+
if _is_non_answer(ans):
|
| 208 |
+
non_answer_turns.append(ans)
|
| 209 |
+
answer_scores.append((0, ans))
|
| 210 |
+
continue
|
| 211 |
+
|
| 212 |
+
nums = _match_all(_NUMBER_METRIC, ans)
|
| 213 |
+
pcts = _match_all(_PERCENTAGE, ans)
|
| 214 |
+
prices = _match_all(_CURRENCY, ans)
|
| 215 |
+
ucounts = _match_all(_USER_COUNT, ans)
|
| 216 |
+
val = _match_all(_VALIDATION, ans)
|
| 217 |
+
cols = _match_all(_COLLEGE_CAMPUS, ans)
|
| 218 |
+
comps = _match_all(_COMPETITORS, ans)
|
| 219 |
+
techs = _match_all(_TECH_MECHANISM, ans)
|
| 220 |
+
revs = _match_all(_REVENUE, ans)
|
| 221 |
+
rets = _match_all(_RETENTION, ans)
|
| 222 |
+
gtms = _match_all(_GTM, ans)
|
| 223 |
+
vagues = _match_all(_VAGUE_PHRASES, ans)
|
| 224 |
+
|
| 225 |
+
all_numbers.extend(nums)
|
| 226 |
+
all_pct.extend(pcts)
|
| 227 |
+
all_pricing.extend(prices)
|
| 228 |
+
all_user_counts.extend(ucounts)
|
| 229 |
+
all_validation.extend(val)
|
| 230 |
+
all_colleges.extend(cols)
|
| 231 |
+
all_competitors.extend(comps)
|
| 232 |
+
all_tech.extend(techs)
|
| 233 |
+
all_revenue.extend(revs)
|
| 234 |
+
all_retention.extend(rets)
|
| 235 |
+
all_gtm.extend(gtms)
|
| 236 |
+
all_vague.extend(vagues)
|
| 237 |
+
|
| 238 |
+
# Signal density score for ranking quotes
|
| 239 |
+
density = (
|
| 240 |
+
len(nums) + len(pcts) + len(prices) + len(ucounts) +
|
| 241 |
+
len(val) + len(cols) + len(comps) + len(techs) + len(revs)
|
| 242 |
+
)
|
| 243 |
+
answer_scores.append((density, ans))
|
| 244 |
+
|
| 245 |
+
# Sort by density descending; take top 5 non-trivial answers
|
| 246 |
+
sorted_answers = sorted(answer_scores, key=lambda x: x[0], reverse=True)
|
| 247 |
+
best_quotes = [ans for score, ans in sorted_answers if score > 0][:5]
|
| 248 |
+
|
| 249 |
+
total_signals = (
|
| 250 |
+
len(_dedup(all_numbers)) + len(_dedup(all_pct)) +
|
| 251 |
+
len(_dedup(all_pricing)) + len(_dedup(all_user_counts)) +
|
| 252 |
+
len(_dedup(all_validation)) + len(_dedup(all_colleges)) +
|
| 253 |
+
len(_dedup(all_competitors)) + len(_dedup(all_tech)) +
|
| 254 |
+
len(_dedup(all_revenue))
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
return {
|
| 258 |
+
"numbers": _dedup(all_numbers),
|
| 259 |
+
"percentages": _dedup(all_pct),
|
| 260 |
+
"pricing": _dedup(all_pricing),
|
| 261 |
+
"user_counts": _dedup(all_user_counts),
|
| 262 |
+
"validation": _dedup(all_validation),
|
| 263 |
+
"college_mentions": _dedup(all_colleges),
|
| 264 |
+
"competitors": _dedup(all_competitors),
|
| 265 |
+
"technical_mechanisms": _dedup(all_tech),
|
| 266 |
+
"revenue_signals": _dedup(all_revenue),
|
| 267 |
+
"retention_signals": _dedup(all_retention),
|
| 268 |
+
"gtm_signals": _dedup(all_gtm),
|
| 269 |
+
"non_answers": non_answer_turns,
|
| 270 |
+
"vague_claims": _dedup(all_vague),
|
| 271 |
+
"best_user_quotes": best_quotes,
|
| 272 |
+
"all_user_answers": user_answers,
|
| 273 |
+
"signal_count": total_signals,
|
| 274 |
+
}
|
core/feedback_generator.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Placeholder feedback generators for Phase 1 mock responses."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def generate_improved_answer(weak_answer: str, startup: dict) -> str:
|
| 7 |
+
"""Return a stronger mock rewrite for a weak founder answer."""
|
| 8 |
+
name = startup.get("name", "the product")
|
| 9 |
+
return (
|
| 10 |
+
f"Instead of staying vague, anchor your answer in specifics: "
|
| 11 |
+
f"'{name}' solves a concrete discovery problem for students by ranking events against "
|
| 12 |
+
f"skills, deadlines, and location — not just aggregating listings. "
|
| 13 |
+
f"We already tested ranking logic on scraped campus events and saw students shortlist "
|
| 14 |
+
f"3x faster than browsing WhatsApp groups.' "
|
| 15 |
+
f"(Your original answer was too thin: \"{weak_answer[:120]}...\")"
|
| 16 |
+
if len(weak_answer) > 120
|
| 17 |
+
else (
|
| 18 |
+
f"Lead with proof: '{name}' matches events to student profiles using skills, goals, "
|
| 19 |
+
f"and urgency — we validated this with a prototype on real campus event data. "
|
| 20 |
+
f"Your answer needed more specificity than: \"{weak_answer}\""
|
| 21 |
+
)
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def generate_improved_pitch(startup: dict) -> str:
|
| 26 |
+
"""Return a mock 60-second pitch rewrite."""
|
| 27 |
+
name = startup.get("name", "Our startup")
|
| 28 |
+
problem = startup.get("problem", "a real student pain point")
|
| 29 |
+
solution = startup.get("solution", "a focused product")
|
| 30 |
+
why_ai = startup.get("why_ai", "intelligent matching")
|
| 31 |
+
traction = startup.get("traction", "early prototype traction")
|
| 32 |
+
ask = startup.get("ask", "feedback and support")
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
f"{name} helps student founders stop missing the events that actually matter. "
|
| 36 |
+
f"The problem is simple: {problem} "
|
| 37 |
+
f"Our solution: {solution} "
|
| 38 |
+
f"AI is load-bearing because {why_ai} "
|
| 39 |
+
f"Traction so far: {traction} "
|
| 40 |
+
f"We're asking for {ask}."
|
| 41 |
+
)
|
core/json_utils.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JSON parsing utilities with safe fallbacks."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def extract_json_block(text: str) -> str | None:
|
| 11 |
+
"""Extract the first JSON object or array block from text."""
|
| 12 |
+
if not text:
|
| 13 |
+
return None
|
| 14 |
+
|
| 15 |
+
fenced = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
|
| 16 |
+
if fenced:
|
| 17 |
+
return fenced.group(1).strip()
|
| 18 |
+
|
| 19 |
+
for opener, closer in (("{", "}"), ("[", "]")):
|
| 20 |
+
start = text.find(opener)
|
| 21 |
+
if start == -1:
|
| 22 |
+
continue
|
| 23 |
+
depth = 0
|
| 24 |
+
for index in range(start, len(text)):
|
| 25 |
+
char = text[index]
|
| 26 |
+
if char == opener:
|
| 27 |
+
depth += 1
|
| 28 |
+
elif char == closer:
|
| 29 |
+
depth -= 1
|
| 30 |
+
if depth == 0:
|
| 31 |
+
return text[start : index + 1]
|
| 32 |
+
return None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def safe_json_parse(text: str, default: Any = None) -> Any:
|
| 36 |
+
"""Parse JSON from raw text, attempting block extraction on failure."""
|
| 37 |
+
if default is None:
|
| 38 |
+
default = {}
|
| 39 |
+
|
| 40 |
+
if not text:
|
| 41 |
+
return default
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
return json.loads(text)
|
| 45 |
+
except json.JSONDecodeError:
|
| 46 |
+
block = extract_json_block(text)
|
| 47 |
+
if not block:
|
| 48 |
+
return default
|
| 49 |
+
try:
|
| 50 |
+
return json.loads(block)
|
| 51 |
+
except json.JSONDecodeError:
|
| 52 |
+
return default
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def fallback_scorecard() -> dict[str, Any]:
|
| 56 |
+
"""Return a minimal scorecard when model JSON parsing fails."""
|
| 57 |
+
return {
|
| 58 |
+
"overall": 0,
|
| 59 |
+
"scores": {},
|
| 60 |
+
"best_answer": "No scorecard could be generated.",
|
| 61 |
+
"weakest_answer": "",
|
| 62 |
+
"improved_answer": "",
|
| 63 |
+
"improved_pitch": "",
|
| 64 |
+
"top_3_questions": [],
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
_REQUIRED_SCORECARD_DIMS = {
|
| 69 |
+
"clarity",
|
| 70 |
+
"problem_understanding",
|
| 71 |
+
"market_awareness",
|
| 72 |
+
"differentiation",
|
| 73 |
+
"business_model",
|
| 74 |
+
"objection_handling",
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _coerce_score(value: Any) -> int:
|
| 79 |
+
"""Clamp a raw score value to integer 0–100."""
|
| 80 |
+
try:
|
| 81 |
+
return max(0, min(100, int(float(value))))
|
| 82 |
+
except (TypeError, ValueError):
|
| 83 |
+
return 0
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _score_label(score: int) -> str:
|
| 87 |
+
"""Map an integer score 0–100 to a human-readable label.
|
| 88 |
+
|
| 89 |
+
Phase 5C bands (claim-based calibration):
|
| 90 |
+
0–30: Not addressed
|
| 91 |
+
31–50: Developing
|
| 92 |
+
51–70: Solid
|
| 93 |
+
71–85: Strong
|
| 94 |
+
86–100: Excellent
|
| 95 |
+
"""
|
| 96 |
+
if score <= 30:
|
| 97 |
+
return "Not addressed"
|
| 98 |
+
if score <= 50:
|
| 99 |
+
return "Developing"
|
| 100 |
+
if score <= 70:
|
| 101 |
+
return "Solid"
|
| 102 |
+
if score <= 85:
|
| 103 |
+
return "Strong"
|
| 104 |
+
return "Excellent"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _validate_dim(raw: Any) -> dict[str, Any]:
|
| 108 |
+
"""Normalise a raw score dimension into {score, label, reason, quote, signals_used}."""
|
| 109 |
+
if not isinstance(raw, dict):
|
| 110 |
+
return {
|
| 111 |
+
"score": 0,
|
| 112 |
+
"label": _score_label(0),
|
| 113 |
+
"reason": "No data.",
|
| 114 |
+
"quote": "",
|
| 115 |
+
"signals_used": [],
|
| 116 |
+
}
|
| 117 |
+
score = _coerce_score(raw.get("score", 0))
|
| 118 |
+
raw_signals = raw.get("signals_used", [])
|
| 119 |
+
signals = (
|
| 120 |
+
[str(s).strip() for s in raw_signals if str(s).strip()]
|
| 121 |
+
if isinstance(raw_signals, list)
|
| 122 |
+
else []
|
| 123 |
+
)
|
| 124 |
+
return {
|
| 125 |
+
"score": score,
|
| 126 |
+
"label": _score_label(score),
|
| 127 |
+
"reason": str(raw.get("reason", "")).strip() or "No reasoning provided.",
|
| 128 |
+
"quote": str(raw.get("quote", "")).strip(),
|
| 129 |
+
"signals_used": signals[:8],
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def parse_scorecard_json(raw_text: str) -> dict[str, Any] | None:
|
| 134 |
+
"""Parse and validate Nemotron scorecard JSON.
|
| 135 |
+
|
| 136 |
+
Fallback order:
|
| 137 |
+
1. json.loads(raw_text)
|
| 138 |
+
2. extract_json_block → json.loads
|
| 139 |
+
3. safe_json_parse
|
| 140 |
+
|
| 141 |
+
Returns a validated dict with all required keys, or None if parsing fails
|
| 142 |
+
completely so the caller can fall back to mock_scorecard.
|
| 143 |
+
|
| 144 |
+
Voice mode note:
|
| 145 |
+
This function is input-source agnostic — it receives only the text
|
| 146 |
+
output from the model and does not need to change for voice mode.
|
| 147 |
+
"""
|
| 148 |
+
parsed = safe_json_parse(raw_text)
|
| 149 |
+
if not parsed or not isinstance(parsed, dict):
|
| 150 |
+
return None
|
| 151 |
+
|
| 152 |
+
# Validate and normalise scores dict
|
| 153 |
+
raw_scores = parsed.get("scores", {})
|
| 154 |
+
if not isinstance(raw_scores, dict):
|
| 155 |
+
raw_scores = {}
|
| 156 |
+
|
| 157 |
+
scores: dict[str, Any] = {}
|
| 158 |
+
for dim in _REQUIRED_SCORECARD_DIMS:
|
| 159 |
+
scores[dim] = _validate_dim(raw_scores.get(dim))
|
| 160 |
+
|
| 161 |
+
# overall: prefer explicit field, else average of dimension scores
|
| 162 |
+
if "overall" in parsed and parsed["overall"] is not None:
|
| 163 |
+
overall = _coerce_score(parsed["overall"])
|
| 164 |
+
else:
|
| 165 |
+
dim_scores = [scores[d]["score"] for d in _REQUIRED_SCORECARD_DIMS]
|
| 166 |
+
overall = round(sum(dim_scores) / len(dim_scores)) if dim_scores else 0
|
| 167 |
+
|
| 168 |
+
def _str(key: str, default: str = "") -> str:
|
| 169 |
+
return str(parsed.get(key, default)).strip() or default
|
| 170 |
+
|
| 171 |
+
def _list_of_str(key: str) -> list[str]:
|
| 172 |
+
val = parsed.get(key, [])
|
| 173 |
+
if isinstance(val, list):
|
| 174 |
+
return [str(v).strip() for v in val if str(v).strip()]
|
| 175 |
+
return []
|
| 176 |
+
|
| 177 |
+
top_3 = _list_of_str("top_3_questions")[:3]
|
| 178 |
+
# Pad to 3 if model returned fewer
|
| 179 |
+
while len(top_3) < 3:
|
| 180 |
+
top_3.append("What concrete evidence do you have to support this claim?")
|
| 181 |
+
|
| 182 |
+
return {
|
| 183 |
+
"overall": overall,
|
| 184 |
+
"overall_label": _score_label(overall),
|
| 185 |
+
"scores": scores,
|
| 186 |
+
"best_answer": _str("best_answer", "Not identified."),
|
| 187 |
+
"weakest_answer": _str("weakest_answer", "Not identified."),
|
| 188 |
+
"why_weak": _str("why_weak", ""),
|
| 189 |
+
"improved_answer": _str("improved_answer", ""),
|
| 190 |
+
"improved_pitch": _str("improved_pitch", ""),
|
| 191 |
+
"top_3_questions": top_3,
|
| 192 |
+
}
|
core/local_text_model.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Placeholder local text model for Phase 2 integration."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class LocalTextModel:
|
| 9 |
+
"""Stub for the future local MiniCPM-family model runtime."""
|
| 10 |
+
|
| 11 |
+
def __init__(self) -> None:
|
| 12 |
+
self.loaded = False
|
| 13 |
+
|
| 14 |
+
def health_check(self) -> dict[str, str]:
|
| 15 |
+
return {
|
| 16 |
+
"status": "not_loaded",
|
| 17 |
+
"message": "Local model will be added in Phase 2",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
def generate(
|
| 21 |
+
self,
|
| 22 |
+
messages: list[dict[str, str]],
|
| 23 |
+
temperature: float = 0.7,
|
| 24 |
+
max_tokens: int = 300,
|
| 25 |
+
) -> str:
|
| 26 |
+
_ = (messages, temperature, max_tokens)
|
| 27 |
+
return (
|
| 28 |
+
"[Phase 1 placeholder] The local text model is not loaded yet. "
|
| 29 |
+
"Mock battle responses are served by the API layer."
|
| 30 |
+
)
|
core/minicpm_client.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stub MiniCPM client — OpenBMB integration planned for Phase 9."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def health_check() -> dict[str, Any]:
|
| 9 |
+
return {
|
| 10 |
+
"status": "not_configured",
|
| 11 |
+
"provider": "openbmb",
|
| 12 |
+
"message": "MiniCPM integration planned for Phase 9",
|
| 13 |
+
}
|
core/model_router.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Central model routing layer for PitchFight AI.
|
| 2 |
+
|
| 3 |
+
Routes task requests to the correct model client based on model_mode.
|
| 4 |
+
All model calls are backend-only. Frontend never calls this layer directly.
|
| 5 |
+
|
| 6 |
+
Supported mode keys:
|
| 7 |
+
premium_nvidia — NVIDIA Nemotron 3 Nano Omni 30B-A3B (default)
|
| 8 |
+
openbmb_omni — MiniCPM-o 4.5 (Phase 9)
|
| 9 |
+
tiny_minicpm — MiniCPM5-1B (Phase 9)
|
| 10 |
+
vision_deck — MiniCPM-V 4.6 (Phase 10)
|
| 11 |
+
whisper_fallback — faster-whisper transcription (Phase 7)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
import os
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
from dotenv import load_dotenv
|
| 21 |
+
|
| 22 |
+
from core import nvidia_client
|
| 23 |
+
from core import minicpm_client
|
| 24 |
+
from core import vision_client
|
| 25 |
+
from core import transcription_client
|
| 26 |
+
|
| 27 |
+
load_dotenv()
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
SUPPORTED_MODES = {
|
| 32 |
+
"premium_nvidia",
|
| 33 |
+
"openbmb_omni",
|
| 34 |
+
"tiny_minicpm",
|
| 35 |
+
"vision_deck",
|
| 36 |
+
"whisper_fallback",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
_FALLBACK_OPPONENT_MESSAGE = (
|
| 40 |
+
"Your answer lacked specificity. "
|
| 41 |
+
"What concrete proof — a metric, a test result, or a user quote — "
|
| 42 |
+
"can you give me right now to back that claim?"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
_FALLBACK_SCORECARD: dict[str, Any] = {
|
| 46 |
+
"overall": 0,
|
| 47 |
+
"scores": {},
|
| 48 |
+
"best_answer": "Model scoring unavailable.",
|
| 49 |
+
"weakest_answer": "",
|
| 50 |
+
"improved_answer": "",
|
| 51 |
+
"improved_pitch": "",
|
| 52 |
+
"top_3_questions": [],
|
| 53 |
+
"_fallback": True,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def get_default_model_mode() -> str:
|
| 58 |
+
"""Return the configured default model mode."""
|
| 59 |
+
mode = os.getenv("DEFAULT_MODEL_MODE", "premium_nvidia").strip()
|
| 60 |
+
return mode if mode in SUPPORTED_MODES else "premium_nvidia"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def get_model_health() -> dict[str, Any]:
|
| 64 |
+
"""Return health status for all model clients (no keys exposed)."""
|
| 65 |
+
return {
|
| 66 |
+
"default_mode": get_default_model_mode(),
|
| 67 |
+
"supported_modes": sorted(SUPPORTED_MODES),
|
| 68 |
+
"providers": {
|
| 69 |
+
"nvidia": nvidia_client.health_check(),
|
| 70 |
+
"minicpm": minicpm_client.health_check(),
|
| 71 |
+
"vision": vision_client.health_check(),
|
| 72 |
+
"transcription": transcription_client.health_check(),
|
| 73 |
+
},
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _resolve_mode(model_mode: str | None) -> str:
|
| 78 |
+
"""Validate and return a mode key, falling back to default if invalid."""
|
| 79 |
+
if model_mode and model_mode in SUPPORTED_MODES:
|
| 80 |
+
return model_mode
|
| 81 |
+
default = get_default_model_mode()
|
| 82 |
+
if model_mode and model_mode not in SUPPORTED_MODES:
|
| 83 |
+
logger.warning(
|
| 84 |
+
"Unknown model_mode '%s', falling back to '%s'", model_mode, default
|
| 85 |
+
)
|
| 86 |
+
return default
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def generate_opponent_response(
|
| 90 |
+
messages: list[dict[str, str]],
|
| 91 |
+
model_mode: str | None = None,
|
| 92 |
+
persona: str | None = None,
|
| 93 |
+
attack_tag: str | None = None,
|
| 94 |
+
) -> dict[str, Any]:
|
| 95 |
+
"""Route an opponent-turn request to the correct model client.
|
| 96 |
+
|
| 97 |
+
Returns a result dict:
|
| 98 |
+
ok — bool, True on success
|
| 99 |
+
model_mode — the mode key used
|
| 100 |
+
provider — which provider was called
|
| 101 |
+
content — the model's text response
|
| 102 |
+
error — None on success, error description on failure
|
| 103 |
+
"""
|
| 104 |
+
mode = _resolve_mode(model_mode)
|
| 105 |
+
|
| 106 |
+
if mode == "premium_nvidia":
|
| 107 |
+
return _call_nvidia_opponent(messages, mode)
|
| 108 |
+
|
| 109 |
+
if mode in ("openbmb_omni", "tiny_minicpm"):
|
| 110 |
+
return _placeholder_result(
|
| 111 |
+
mode,
|
| 112 |
+
"openbmb",
|
| 113 |
+
f"OpenBMB mode '{mode}' is planned for Phase 9.",
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
if mode == "vision_deck":
|
| 117 |
+
return _placeholder_result(
|
| 118 |
+
mode,
|
| 119 |
+
"openbmb",
|
| 120 |
+
"Vision/deck mode is planned for Phase 10.",
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
if mode == "whisper_fallback":
|
| 124 |
+
return _placeholder_result(
|
| 125 |
+
mode,
|
| 126 |
+
"local",
|
| 127 |
+
"Whisper fallback is planned for Phase 7.",
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
return _placeholder_result(mode, "unknown", f"Unsupported mode: {mode}")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def generate_scorecard_response(
|
| 134 |
+
messages: list[dict[str, str]],
|
| 135 |
+
model_mode: str | None = None,
|
| 136 |
+
) -> dict[str, Any]:
|
| 137 |
+
"""Route a scorecard-generation request to the correct model client."""
|
| 138 |
+
mode = _resolve_mode(model_mode)
|
| 139 |
+
|
| 140 |
+
if mode == "premium_nvidia":
|
| 141 |
+
return _call_nvidia_scorecard(messages, mode)
|
| 142 |
+
|
| 143 |
+
return _placeholder_result(
|
| 144 |
+
mode,
|
| 145 |
+
"mock",
|
| 146 |
+
f"Scorecard via '{mode}' is not yet implemented. Using mock scorecard.",
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def generate_coaching_response(
|
| 151 |
+
messages: list[dict[str, str]],
|
| 152 |
+
model_mode: str | None = None,
|
| 153 |
+
) -> dict[str, Any]:
|
| 154 |
+
"""Route a coaching-JSON request to Nemotron (mode=scorecard_coaching).
|
| 155 |
+
|
| 156 |
+
Nemotron generates only: improved_answer, improved_pitch, top_3_questions.
|
| 157 |
+
Thinking is OFF for this mode — direct JSON output is faster and more reliable.
|
| 158 |
+
"""
|
| 159 |
+
mode = _resolve_mode(model_mode)
|
| 160 |
+
|
| 161 |
+
if mode == "premium_nvidia":
|
| 162 |
+
try:
|
| 163 |
+
content = nvidia_client.generate_nemotron_response(
|
| 164 |
+
messages, mode="scorecard_coaching"
|
| 165 |
+
)
|
| 166 |
+
return {
|
| 167 |
+
"ok": True,
|
| 168 |
+
"model_mode": mode,
|
| 169 |
+
"provider": "nvidia",
|
| 170 |
+
"content": content,
|
| 171 |
+
"error": None,
|
| 172 |
+
}
|
| 173 |
+
except RuntimeError as exc:
|
| 174 |
+
logger.warning("NVIDIA coaching call failed: %s", exc)
|
| 175 |
+
return {
|
| 176 |
+
"ok": False,
|
| 177 |
+
"model_mode": mode,
|
| 178 |
+
"provider": "nvidia",
|
| 179 |
+
"content": "",
|
| 180 |
+
"error": str(exc),
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
return _placeholder_result(mode, "mock", f"Coaching via '{mode}' not implemented.")
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def generate_coaching_repair_response(
|
| 187 |
+
raw_bad_content: str,
|
| 188 |
+
model_mode: str | None = None,
|
| 189 |
+
) -> dict[str, Any]:
|
| 190 |
+
"""Repair a non-JSON coaching response into valid JSON (mode=scorecard_coaching_repair)."""
|
| 191 |
+
mode = _resolve_mode(model_mode)
|
| 192 |
+
|
| 193 |
+
if mode != "premium_nvidia":
|
| 194 |
+
return _placeholder_result(mode, "mock", "Coaching repair only available for premium_nvidia.")
|
| 195 |
+
|
| 196 |
+
repair_messages = [
|
| 197 |
+
{
|
| 198 |
+
"role": "system",
|
| 199 |
+
"content": (
|
| 200 |
+
"You are a JSON formatter. Convert the input text into the exact JSON schema below. "
|
| 201 |
+
"Return ONLY valid JSON. First character must be { and last must be }. "
|
| 202 |
+
"No markdown. No explanation. No preface.\n\n"
|
| 203 |
+
"REQUIRED SCHEMA:\n"
|
| 204 |
+
'{"improved_answer": "", "improved_pitch": "", "top_3_questions": ["", "", ""]}'
|
| 205 |
+
),
|
| 206 |
+
},
|
| 207 |
+
{
|
| 208 |
+
"role": "user",
|
| 209 |
+
"content": (
|
| 210 |
+
"Convert this text into the JSON schema. Output JSON only:\n\n"
|
| 211 |
+
+ raw_bad_content[:4000]
|
| 212 |
+
),
|
| 213 |
+
},
|
| 214 |
+
]
|
| 215 |
+
|
| 216 |
+
try:
|
| 217 |
+
content = nvidia_client.generate_nemotron_response(
|
| 218 |
+
repair_messages, mode="scorecard_coaching_repair"
|
| 219 |
+
)
|
| 220 |
+
return {
|
| 221 |
+
"ok": True,
|
| 222 |
+
"model_mode": mode,
|
| 223 |
+
"provider": "nvidia",
|
| 224 |
+
"content": content,
|
| 225 |
+
"error": None,
|
| 226 |
+
}
|
| 227 |
+
except RuntimeError as exc:
|
| 228 |
+
logger.warning("NVIDIA coaching repair call failed: %s", exc)
|
| 229 |
+
return {
|
| 230 |
+
"ok": False,
|
| 231 |
+
"model_mode": mode,
|
| 232 |
+
"provider": "nvidia",
|
| 233 |
+
"content": "",
|
| 234 |
+
"error": str(exc),
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def generate_scorecard_repair_response(
|
| 239 |
+
raw_bad_content: str,
|
| 240 |
+
model_mode: str | None = None,
|
| 241 |
+
) -> dict[str, Any]:
|
| 242 |
+
"""Ask Nemotron to repair a non-JSON scorecard response into valid JSON.
|
| 243 |
+
|
| 244 |
+
Called when the primary scorecard call returns content that cannot be parsed.
|
| 245 |
+
Uses temperature=0.0 and mode='scorecard_repair' for a deterministic rewrite.
|
| 246 |
+
|
| 247 |
+
Voice mode note:
|
| 248 |
+
Input is model text output — no source-specific changes needed.
|
| 249 |
+
"""
|
| 250 |
+
mode = _resolve_mode(model_mode)
|
| 251 |
+
|
| 252 |
+
if mode != "premium_nvidia":
|
| 253 |
+
return _placeholder_result(mode, "mock", "Repair only available for premium_nvidia.")
|
| 254 |
+
|
| 255 |
+
repair_messages = [
|
| 256 |
+
{
|
| 257 |
+
"role": "system",
|
| 258 |
+
"content": (
|
| 259 |
+
"You are a JSON formatter. "
|
| 260 |
+
"Convert the input text into the exact JSON schema shown below. "
|
| 261 |
+
"Return ONLY valid JSON. The first character must be { and the last must be }. "
|
| 262 |
+
"No markdown. No explanation. No preface. No chain-of-thought. "
|
| 263 |
+
"Fill every field. Use 0 for missing scores. Use empty string for missing text.\n\n"
|
| 264 |
+
"REQUIRED SCHEMA:\n"
|
| 265 |
+
'{\n'
|
| 266 |
+
' "overall": 0,\n'
|
| 267 |
+
' "scores": {\n'
|
| 268 |
+
' "clarity": {"score": 0, "reason": "", "quote": "", "signals_used": []},\n'
|
| 269 |
+
' "problem_understanding": {"score": 0, "reason": "", "quote": "", "signals_used": []},\n'
|
| 270 |
+
' "market_awareness": {"score": 0, "reason": "", "quote": "", "signals_used": []},\n'
|
| 271 |
+
' "differentiation": {"score": 0, "reason": "", "quote": "", "signals_used": []},\n'
|
| 272 |
+
' "business_model": {"score": 0, "reason": "", "quote": "", "signals_used": []},\n'
|
| 273 |
+
' "objection_handling": {"score": 0, "reason": "", "quote": "", "signals_used": []}\n'
|
| 274 |
+
' },\n'
|
| 275 |
+
' "best_answer": "",\n'
|
| 276 |
+
' "weakest_answer": "",\n'
|
| 277 |
+
' "why_weak": "",\n'
|
| 278 |
+
' "improved_answer": "",\n'
|
| 279 |
+
' "improved_pitch": "",\n'
|
| 280 |
+
' "top_3_questions": ["", "", ""]\n'
|
| 281 |
+
"}"
|
| 282 |
+
),
|
| 283 |
+
},
|
| 284 |
+
{
|
| 285 |
+
"role": "user",
|
| 286 |
+
"content": (
|
| 287 |
+
"Convert this text into the JSON schema. "
|
| 288 |
+
"Extract scores and reasoning from the text below. "
|
| 289 |
+
"Output JSON only:\n\n"
|
| 290 |
+
+ raw_bad_content[:6000]
|
| 291 |
+
),
|
| 292 |
+
},
|
| 293 |
+
]
|
| 294 |
+
|
| 295 |
+
try:
|
| 296 |
+
content = nvidia_client.generate_nemotron_response(
|
| 297 |
+
repair_messages,
|
| 298 |
+
mode="scorecard_repair",
|
| 299 |
+
)
|
| 300 |
+
return {
|
| 301 |
+
"ok": True,
|
| 302 |
+
"model_mode": mode,
|
| 303 |
+
"provider": "nvidia",
|
| 304 |
+
"content": content,
|
| 305 |
+
"error": None,
|
| 306 |
+
}
|
| 307 |
+
except RuntimeError as exc:
|
| 308 |
+
logger.warning("NVIDIA scorecard repair call failed: %s", exc)
|
| 309 |
+
return {
|
| 310 |
+
"ok": False,
|
| 311 |
+
"model_mode": mode,
|
| 312 |
+
"provider": "nvidia",
|
| 313 |
+
"content": "",
|
| 314 |
+
"error": str(exc),
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# ---------------------------------------------------------------------------
|
| 319 |
+
# Internal helpers
|
| 320 |
+
# ---------------------------------------------------------------------------
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def _call_nvidia_opponent(messages: list[dict], mode: str) -> dict[str, Any]:
|
| 324 |
+
try:
|
| 325 |
+
content = nvidia_client.generate_nemotron_response(messages, mode="opponent")
|
| 326 |
+
return {
|
| 327 |
+
"ok": True,
|
| 328 |
+
"model_mode": mode,
|
| 329 |
+
"provider": "nvidia",
|
| 330 |
+
"content": content,
|
| 331 |
+
"error": None,
|
| 332 |
+
}
|
| 333 |
+
except RuntimeError as exc:
|
| 334 |
+
logger.warning("NVIDIA opponent call failed: %s", exc)
|
| 335 |
+
return {
|
| 336 |
+
"ok": False,
|
| 337 |
+
"model_mode": mode,
|
| 338 |
+
"provider": "nvidia",
|
| 339 |
+
"content": _FALLBACK_OPPONENT_MESSAGE,
|
| 340 |
+
"error": str(exc),
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _call_nvidia_scorecard(messages: list[dict], mode: str) -> dict[str, Any]:
|
| 345 |
+
try:
|
| 346 |
+
content = nvidia_client.generate_nemotron_response(messages, mode="scorecard")
|
| 347 |
+
return {
|
| 348 |
+
"ok": True,
|
| 349 |
+
"model_mode": mode,
|
| 350 |
+
"provider": "nvidia",
|
| 351 |
+
"content": content,
|
| 352 |
+
"error": None,
|
| 353 |
+
}
|
| 354 |
+
except RuntimeError as exc:
|
| 355 |
+
logger.warning("NVIDIA scorecard call failed: %s", exc)
|
| 356 |
+
return {
|
| 357 |
+
"ok": False,
|
| 358 |
+
"model_mode": mode,
|
| 359 |
+
"provider": "nvidia",
|
| 360 |
+
"content": "",
|
| 361 |
+
"error": str(exc),
|
| 362 |
+
"fallback_scorecard": _FALLBACK_SCORECARD,
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def _placeholder_result(mode: str, provider: str, message: str) -> dict[str, Any]:
|
| 367 |
+
return {
|
| 368 |
+
"ok": False,
|
| 369 |
+
"model_mode": mode,
|
| 370 |
+
"provider": provider,
|
| 371 |
+
"content": _FALLBACK_OPPONENT_MESSAGE,
|
| 372 |
+
"error": message,
|
| 373 |
+
}
|
core/nvidia_client.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Backend-only NVIDIA Nemotron API client.
|
| 2 |
+
|
| 3 |
+
All calls are backend-only. API key is never passed to the frontend.
|
| 4 |
+
Key is read from NVIDIA_API_KEY environment variable only.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
import os
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
from openai import OpenAI, APIConnectionError, APIStatusError, APITimeoutError
|
| 15 |
+
|
| 16 |
+
load_dotenv()
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
_DEFAULT_BASE_URL = "https://integrate.api.nvidia.com/v1"
|
| 21 |
+
_DEFAULT_MODEL = "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning"
|
| 22 |
+
|
| 23 |
+
# Per-mode settings for nemotron-3-nano-omni-30b-a3b-reasoning.
|
| 24 |
+
#
|
| 25 |
+
# enable_thinking: True → reasoning model uses internal chain-of-thought
|
| 26 |
+
# False → thinking disabled; output is direct (faster, cheaper)
|
| 27 |
+
# reasoning_budget: token budget for internal reasoning (clamped to max_tokens)
|
| 28 |
+
#
|
| 29 |
+
# Modes in main path:
|
| 30 |
+
# opponent — live judge questions during battle (thinking on)
|
| 31 |
+
# scorecard_coaching — coaching JSON only (thinking off — faster, more reliable JSON)
|
| 32 |
+
# scorecard_coaching_repair — JSON repair for coaching output (thinking off)
|
| 33 |
+
# rewrite — rewrite utility (thinking on, lighter budget)
|
| 34 |
+
# legacy_full_scorecard — diagnostic / legacy path only; not main path (thinking off)
|
| 35 |
+
_TASK_DEFAULTS: dict[str, dict[str, Any]] = {
|
| 36 |
+
"opponent": {
|
| 37 |
+
"enable_thinking": True,
|
| 38 |
+
"reasoning_budget": 512,
|
| 39 |
+
"max_tokens": 900,
|
| 40 |
+
"temperature": 0.65,
|
| 41 |
+
"top_p": 0.95,
|
| 42 |
+
},
|
| 43 |
+
"scorecard_coaching": {
|
| 44 |
+
"enable_thinking": False,
|
| 45 |
+
"reasoning_budget": 0,
|
| 46 |
+
"max_tokens": 1600,
|
| 47 |
+
"temperature": 0.2,
|
| 48 |
+
"top_p": 0.95,
|
| 49 |
+
},
|
| 50 |
+
"scorecard_coaching_repair": {
|
| 51 |
+
"enable_thinking": False,
|
| 52 |
+
"reasoning_budget": 0,
|
| 53 |
+
"max_tokens": 1200,
|
| 54 |
+
"temperature": 0.0,
|
| 55 |
+
"top_p": 0.95,
|
| 56 |
+
},
|
| 57 |
+
"rewrite": {
|
| 58 |
+
"enable_thinking": True,
|
| 59 |
+
"reasoning_budget": 256,
|
| 60 |
+
"max_tokens": 900,
|
| 61 |
+
"temperature": 0.45,
|
| 62 |
+
"top_p": 0.95,
|
| 63 |
+
},
|
| 64 |
+
"legacy_full_scorecard": {
|
| 65 |
+
"enable_thinking": False,
|
| 66 |
+
"reasoning_budget": 0,
|
| 67 |
+
"max_tokens": 3000,
|
| 68 |
+
"temperature": 0.1,
|
| 69 |
+
"top_p": 0.95,
|
| 70 |
+
},
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
# Modes where the response must be JSON — apply safe extraction from reasoning_content if needed
|
| 74 |
+
_JSON_MODES: frozenset[str] = frozenset({
|
| 75 |
+
"scorecard_coaching",
|
| 76 |
+
"scorecard_coaching_repair",
|
| 77 |
+
"legacy_full_scorecard",
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _extract_json_from_reasoning(reasoning: str) -> str | None:
|
| 82 |
+
"""Extract first complete JSON object block from reasoning_content."""
|
| 83 |
+
start = reasoning.find("{")
|
| 84 |
+
end = reasoning.rfind("}")
|
| 85 |
+
if start != -1 and end != -1 and end > start:
|
| 86 |
+
return reasoning[start : end + 1].strip()
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _get_config() -> tuple[str, str, str]:
|
| 91 |
+
"""Return (api_key, base_url, model). Raises RuntimeError if key is absent."""
|
| 92 |
+
api_key = os.getenv("NVIDIA_API_KEY", "").strip()
|
| 93 |
+
if not api_key:
|
| 94 |
+
raise RuntimeError(
|
| 95 |
+
"NVIDIA_API_KEY is not set. "
|
| 96 |
+
"Add it to your .env file locally or as a HF Space Secret in deployment. "
|
| 97 |
+
"Never hardcode the key."
|
| 98 |
+
)
|
| 99 |
+
base_url = os.getenv("NVIDIA_BASE_URL", _DEFAULT_BASE_URL).strip() or _DEFAULT_BASE_URL
|
| 100 |
+
model = os.getenv("NVIDIA_OMNI_MODEL", _DEFAULT_MODEL).strip() or _DEFAULT_MODEL
|
| 101 |
+
return api_key, base_url, model
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def is_configured() -> bool:
|
| 105 |
+
"""Return True if NVIDIA_API_KEY is present in the environment."""
|
| 106 |
+
return bool(os.getenv("NVIDIA_API_KEY", "").strip())
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def health_check() -> dict[str, Any]:
|
| 110 |
+
"""Return configuration status without exposing the API key."""
|
| 111 |
+
configured = is_configured()
|
| 112 |
+
base_url = os.getenv("NVIDIA_BASE_URL", _DEFAULT_BASE_URL)
|
| 113 |
+
model = os.getenv("NVIDIA_OMNI_MODEL", _DEFAULT_MODEL)
|
| 114 |
+
return {
|
| 115 |
+
"provider": "nvidia",
|
| 116 |
+
"configured": configured,
|
| 117 |
+
"base_url": base_url,
|
| 118 |
+
"model": model,
|
| 119 |
+
"api_key_present": configured,
|
| 120 |
+
"message": (
|
| 121 |
+
"NVIDIA client ready" if configured
|
| 122 |
+
else "NVIDIA_API_KEY missing — add to .env or HF Space Secrets"
|
| 123 |
+
),
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def generate_nemotron_response(
|
| 128 |
+
messages: list[dict[str, str]],
|
| 129 |
+
mode: str = "opponent",
|
| 130 |
+
temperature: float | None = None,
|
| 131 |
+
max_tokens: int | None = None,
|
| 132 |
+
timeout: int = 30,
|
| 133 |
+
) -> str:
|
| 134 |
+
"""Call NVIDIA Nemotron and return the response text.
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
messages: OpenAI-format message list [{"role": ..., "content": ...}, ...]
|
| 138 |
+
mode: task type key — "opponent", "scorecard_coaching", "rewrite", etc.
|
| 139 |
+
temperature: overrides mode default if provided
|
| 140 |
+
max_tokens: overrides mode default if provided
|
| 141 |
+
timeout: request timeout in seconds
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
Response text string from the model.
|
| 145 |
+
|
| 146 |
+
Raises:
|
| 147 |
+
RuntimeError: on missing key or any API failure (clean message, no key leak).
|
| 148 |
+
"""
|
| 149 |
+
api_key, base_url, model = _get_config()
|
| 150 |
+
|
| 151 |
+
defaults = _TASK_DEFAULTS.get(mode, _TASK_DEFAULTS["opponent"])
|
| 152 |
+
temp = temperature if temperature is not None else defaults["temperature"]
|
| 153 |
+
tokens = max_tokens if max_tokens is not None else defaults["max_tokens"]
|
| 154 |
+
top_p: float = defaults.get("top_p", 0.95)
|
| 155 |
+
enable_thinking: bool = defaults.get("enable_thinking", True)
|
| 156 |
+
# reasoning_budget must not exceed max_tokens
|
| 157 |
+
reasoning_budget: int = min(defaults.get("reasoning_budget", 0), tokens)
|
| 158 |
+
|
| 159 |
+
client = OpenAI(
|
| 160 |
+
api_key=api_key,
|
| 161 |
+
base_url=base_url,
|
| 162 |
+
timeout=timeout,
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
try:
|
| 166 |
+
completion = client.chat.completions.create(
|
| 167 |
+
model=model,
|
| 168 |
+
messages=messages, # type: ignore[arg-type]
|
| 169 |
+
temperature=temp,
|
| 170 |
+
max_tokens=tokens,
|
| 171 |
+
top_p=top_p,
|
| 172 |
+
extra_body={
|
| 173 |
+
"chat_template_kwargs": {"enable_thinking": enable_thinking},
|
| 174 |
+
"reasoning_budget": reasoning_budget,
|
| 175 |
+
},
|
| 176 |
+
)
|
| 177 |
+
msg = completion.choices[0].message
|
| 178 |
+
content = (msg.content or "").strip()
|
| 179 |
+
reasoning = (getattr(msg, "reasoning_content", None) or "").strip()
|
| 180 |
+
|
| 181 |
+
if not content:
|
| 182 |
+
if mode in _JSON_MODES and reasoning:
|
| 183 |
+
# For JSON modes: try to salvage a JSON block from reasoning_content
|
| 184 |
+
extracted = _extract_json_from_reasoning(reasoning)
|
| 185 |
+
if extracted:
|
| 186 |
+
logger.info(
|
| 187 |
+
"Nemotron content empty; extracted JSON block from reasoning_content (mode=%s)",
|
| 188 |
+
mode,
|
| 189 |
+
)
|
| 190 |
+
content = extracted
|
| 191 |
+
else:
|
| 192 |
+
logger.warning(
|
| 193 |
+
"Nemotron content empty; checked reasoning_content fallback (mode=%s, no JSON found)",
|
| 194 |
+
mode,
|
| 195 |
+
)
|
| 196 |
+
elif reasoning:
|
| 197 |
+
# Non-JSON mode (e.g. opponent): use reasoning trace as last resort
|
| 198 |
+
logger.warning(
|
| 199 |
+
"Nemotron content empty; checked reasoning_content fallback (mode=%s)",
|
| 200 |
+
mode,
|
| 201 |
+
)
|
| 202 |
+
content = reasoning
|
| 203 |
+
|
| 204 |
+
if not content:
|
| 205 |
+
raise RuntimeError(
|
| 206 |
+
"NVIDIA model returned an empty response. "
|
| 207 |
+
"The reasoning model may need a larger max_tokens budget."
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
return content
|
| 211 |
+
|
| 212 |
+
except APITimeoutError:
|
| 213 |
+
logger.warning("NVIDIA API timed out after %ds (mode=%s)", timeout, mode)
|
| 214 |
+
raise RuntimeError(
|
| 215 |
+
f"NVIDIA Nemotron request timed out after {timeout}s. "
|
| 216 |
+
"Check your connection or increase timeout."
|
| 217 |
+
)
|
| 218 |
+
except APIConnectionError as exc:
|
| 219 |
+
logger.warning("NVIDIA API connection error: %s", exc)
|
| 220 |
+
raise RuntimeError(
|
| 221 |
+
"Could not connect to NVIDIA API. "
|
| 222 |
+
"Verify NVIDIA_BASE_URL and your network connection."
|
| 223 |
+
)
|
| 224 |
+
except APIStatusError as exc:
|
| 225 |
+
logger.warning("NVIDIA API status error %s: %s", exc.status_code, exc.message)
|
| 226 |
+
raise RuntimeError(
|
| 227 |
+
f"NVIDIA API returned HTTP {exc.status_code}. "
|
| 228 |
+
"Check your NVIDIA_API_KEY and model ID."
|
| 229 |
+
)
|
| 230 |
+
except Exception as exc:
|
| 231 |
+
logger.warning("NVIDIA API unexpected error: %s", type(exc).__name__)
|
| 232 |
+
raise RuntimeError(
|
| 233 |
+
f"NVIDIA model call failed ({type(exc).__name__}). See server logs."
|
| 234 |
+
)
|
core/output_sanitizer.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sanitizer for Nemotron judge output to remove instruction leakage."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
_LEAKAGE_PATTERNS = [
|
| 8 |
+
r"we need to\b",
|
| 9 |
+
r"the prompt says\b",
|
| 10 |
+
r"the instruction says\b",
|
| 11 |
+
r"as instructed\b",
|
| 12 |
+
r"my instructions\b",
|
| 13 |
+
r"according to my system prompt\b",
|
| 14 |
+
r"i am supposed to\b",
|
| 15 |
+
r"i should follow\b",
|
| 16 |
+
r"the rules say\b",
|
| 17 |
+
r"let'?s parse\b",
|
| 18 |
+
r"^first[,\s]",
|
| 19 |
+
r"\bneed to\b.*\binstructions?\b",
|
| 20 |
+
r"we have to note\b",
|
| 21 |
+
r"i should\b.*\binstructions?\b",
|
| 22 |
+
r"per the instructions?\b",
|
| 23 |
+
r"based on the instructions?\b",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
_LEAKAGE_RE = re.compile(
|
| 27 |
+
"|".join(_LEAKAGE_PATTERNS),
|
| 28 |
+
re.IGNORECASE,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
_SAFE_FALLBACK = (
|
| 32 |
+
"What concrete evidence can you give me right now to back that claim?"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def sanitize_model_output(text: str) -> str:
|
| 37 |
+
"""Remove instruction-leakage lines from Nemotron judge output.
|
| 38 |
+
|
| 39 |
+
Splits by sentence/line, drops any that contain leakage patterns,
|
| 40 |
+
returns the joined remainder. If nothing survives, returns a safe
|
| 41 |
+
fallback question.
|
| 42 |
+
"""
|
| 43 |
+
if not text:
|
| 44 |
+
return _SAFE_FALLBACK
|
| 45 |
+
|
| 46 |
+
# Split on newlines first, then also on sentence boundaries
|
| 47 |
+
lines = text.splitlines()
|
| 48 |
+
clean_lines: list[str] = []
|
| 49 |
+
for line in lines:
|
| 50 |
+
stripped = line.strip()
|
| 51 |
+
if not stripped:
|
| 52 |
+
continue
|
| 53 |
+
if _LEAKAGE_RE.search(stripped):
|
| 54 |
+
continue
|
| 55 |
+
clean_lines.append(stripped)
|
| 56 |
+
|
| 57 |
+
result = " ".join(clean_lines).strip()
|
| 58 |
+
|
| 59 |
+
# If too little survived, return fallback
|
| 60 |
+
if len(result.split()) < 4:
|
| 61 |
+
return _SAFE_FALLBACK
|
| 62 |
+
|
| 63 |
+
return result
|
core/persona_builder.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persona prompt builder for AI opponents."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
PERSONA_LABELS = {
|
| 6 |
+
"skeptical_vc": "Skeptical VC",
|
| 7 |
+
"technical_judge": "Technical Judge",
|
| 8 |
+
"hackathon_judge": "Hackathon Judge",
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def build_persona_prompt(
|
| 13 |
+
persona: str,
|
| 14 |
+
startup: dict,
|
| 15 |
+
difficulty: str = "high",
|
| 16 |
+
) -> str:
|
| 17 |
+
"""Build a system prompt for the selected opponent persona."""
|
| 18 |
+
label = PERSONA_LABELS.get(persona, "Tough Judge")
|
| 19 |
+
name = startup.get("name", "this startup")
|
| 20 |
+
problem = startup.get("problem", "")
|
| 21 |
+
solution = startup.get("solution", "")
|
| 22 |
+
why_ai = startup.get("why_ai", "")
|
| 23 |
+
|
| 24 |
+
rules = """
|
| 25 |
+
Behavior rules:
|
| 26 |
+
- Ask one sharp question at a time.
|
| 27 |
+
- Keep responses under 4 sentences.
|
| 28 |
+
- Reference the founder's previous answer when pushing back.
|
| 29 |
+
- Do not give advice during the battle.
|
| 30 |
+
- Do not compliment the founder.
|
| 31 |
+
- Attack vague, generic, or unsubstantiated claims.
|
| 32 |
+
- Raise difficulty after strong answers.
|
| 33 |
+
- Stay in character at all times.
|
| 34 |
+
- Be firm but not abusive.
|
| 35 |
+
""".strip()
|
| 36 |
+
|
| 37 |
+
persona_focus = {
|
| 38 |
+
"skeptical_vc": (
|
| 39 |
+
"You are a skeptical venture capitalist evaluating whether this is a real business. "
|
| 40 |
+
"Attack market size, moat, retention, revenue logic, competition, and defensibility."
|
| 41 |
+
),
|
| 42 |
+
"technical_judge": (
|
| 43 |
+
"You are a senior technical judge who stress-tests whether AI is necessary and whether "
|
| 44 |
+
"the system can actually work at scale. Attack architecture, data quality, latency, "
|
| 45 |
+
"and simpler alternatives."
|
| 46 |
+
),
|
| 47 |
+
"hackathon_judge": (
|
| 48 |
+
"You are a hackathon judge deciding if this project deserves a prize. "
|
| 49 |
+
"Attack novelty, demo clarity, MVP strength, user pain, and whether AI is load-bearing."
|
| 50 |
+
),
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
focus = persona_focus.get(persona, persona_focus["hackathon_judge"])
|
| 54 |
+
|
| 55 |
+
return f"""You are {label}, a tough pitch opponent in PitchFight AI.
|
| 56 |
+
Difficulty: {difficulty}
|
| 57 |
+
|
| 58 |
+
Startup: {name}
|
| 59 |
+
Problem: {problem}
|
| 60 |
+
Solution: {solution}
|
| 61 |
+
Why AI: {why_ai}
|
| 62 |
+
|
| 63 |
+
{focus}
|
| 64 |
+
|
| 65 |
+
{rules}
|
| 66 |
+
"""
|
core/samples.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sample startup data for demos and testing."""
|
| 2 |
+
|
| 3 |
+
EVENTRADAR_SAMPLE = {
|
| 4 |
+
"name": "EventRadar AI",
|
| 5 |
+
"problem": (
|
| 6 |
+
"Students miss hackathons, tech events, and startup opportunities because "
|
| 7 |
+
"discovery is scattered across WhatsApp groups, LinkedIn, Luma, college clubs, "
|
| 8 |
+
"and random websites."
|
| 9 |
+
),
|
| 10 |
+
"target_users": "College students, student founders, and early-stage builders.",
|
| 11 |
+
"solution": (
|
| 12 |
+
"AI-powered event discovery that ranks opportunities based on skills, goals, "
|
| 13 |
+
"location, and deadline urgency."
|
| 14 |
+
),
|
| 15 |
+
"why_ai": (
|
| 16 |
+
"The app does not just list events. It matches events to a student's profile "
|
| 17 |
+
"and explains why each event is worth attending."
|
| 18 |
+
),
|
| 19 |
+
"competitors": "Luma, LinkedIn Events, WhatsApp groups, college club pages.",
|
| 20 |
+
"traction": "Prototype built with scraped event data and ranking logic.",
|
| 21 |
+
"ask": "Hackathon prize and mentor feedback.",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_sample_startup() -> dict:
|
| 26 |
+
"""Return the EventRadar AI sample startup."""
|
| 27 |
+
return dict(EVENTRADAR_SAMPLE)
|
core/scoring_engine.py
ADDED
|
@@ -0,0 +1,908 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scoring engine for PitchFight AI — Phase 5D: Hybrid Claim-Based Scorecard.
|
| 2 |
+
|
| 3 |
+
Architecture (permanent):
|
| 4 |
+
- Local deterministic logic scores all 6 dimensions using extracted signals.
|
| 5 |
+
- Nemotron generates ONLY: improved_answer, improved_pitch, top_3_questions.
|
| 6 |
+
- No giant fragile Nemotron full-scorecard JSON in the main path.
|
| 7 |
+
|
| 8 |
+
scorecard_source values:
|
| 9 |
+
"hybrid_claims_nemotron" — local scores + Nemotron coaching succeeded
|
| 10 |
+
"hybrid_claims_local" — local scores + local coaching fallback (still useful)
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
import os
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
from core import model_router
|
| 20 |
+
from core.json_utils import safe_json_parse, _score_label
|
| 21 |
+
from core.claim_extractor import extract_concrete_signals
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
MAX_ROUNDS = int(os.getenv("MAX_ROUNDS", "6"))
|
| 26 |
+
|
| 27 |
+
_REQUIRED_DIMS = (
|
| 28 |
+
"clarity",
|
| 29 |
+
"problem_understanding",
|
| 30 |
+
"market_awareness",
|
| 31 |
+
"differentiation",
|
| 32 |
+
"business_model",
|
| 33 |
+
"objection_handling",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
# Shared helpers
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
|
| 41 |
+
def _clamp(v: int, lo: int = 0, hi: int = 100) -> int:
|
| 42 |
+
return max(lo, min(hi, v))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _dimension(score: int, reason: str, quote: str, signals: list[str] | None = None) -> dict:
|
| 46 |
+
return {
|
| 47 |
+
"score": score,
|
| 48 |
+
"label": _score_label(score),
|
| 49 |
+
"reason": reason,
|
| 50 |
+
"quote": quote,
|
| 51 |
+
"signals_used": signals or [],
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _empty_signals() -> dict:
|
| 56 |
+
return {
|
| 57 |
+
"numbers": [], "percentages": [], "pricing": [], "user_counts": [],
|
| 58 |
+
"validation": [], "college_mentions": [], "competitors": [],
|
| 59 |
+
"technical_mechanisms": [], "revenue_signals": [], "retention_signals": [],
|
| 60 |
+
"gtm_signals": [], "non_answers": [], "vague_claims": [],
|
| 61 |
+
"best_user_quotes": [], "all_user_answers": [], "signal_count": 0,
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _first(lst: list, default: str = "") -> str:
|
| 66 |
+
return lst[0] if lst else default
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ---------------------------------------------------------------------------
|
| 70 |
+
# Local dimension scoring functions
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
|
| 73 |
+
def _score_clarity(signals: dict, engagement: int, total: int) -> tuple[int, str, str, list]:
|
| 74 |
+
"""Did the founder communicate what the product does and who it helps?"""
|
| 75 |
+
has_tech = bool(signals.get("technical_mechanisms"))
|
| 76 |
+
has_numbers = bool(signals.get("numbers") or signals.get("user_counts"))
|
| 77 |
+
has_validation = bool(signals.get("validation"))
|
| 78 |
+
has_vague_only = bool(signals.get("vague_claims")) and not has_tech and not has_numbers
|
| 79 |
+
best_quotes = signals.get("best_user_quotes", [])
|
| 80 |
+
quote = best_quotes[0][:160] if best_quotes else ""
|
| 81 |
+
used: list[str] = []
|
| 82 |
+
|
| 83 |
+
if engagement == 0:
|
| 84 |
+
return 15, "No substantive answers — product explanation absent.", quote, []
|
| 85 |
+
|
| 86 |
+
# Floor: any on-topic answer = at least 33
|
| 87 |
+
score = 33
|
| 88 |
+
parts: list[str] = []
|
| 89 |
+
|
| 90 |
+
if has_tech and (has_numbers or has_validation):
|
| 91 |
+
score = max(score, 65)
|
| 92 |
+
techs = signals.get("technical_mechanisms", [])[:2]
|
| 93 |
+
used += techs
|
| 94 |
+
parts.append(f"Technical mechanism described ({', '.join(techs)}) with supporting evidence.")
|
| 95 |
+
elif has_tech:
|
| 96 |
+
score = max(score, 58)
|
| 97 |
+
techs = signals.get("technical_mechanisms", [])[:2]
|
| 98 |
+
used += techs
|
| 99 |
+
parts.append(f"Technical mechanism explained: {', '.join(techs)}.")
|
| 100 |
+
elif has_validation:
|
| 101 |
+
score = max(score, 55)
|
| 102 |
+
vals = signals.get("validation", [])[:2]
|
| 103 |
+
used += vals
|
| 104 |
+
parts.append(f"Validation evidence present ({', '.join(vals)}) — product is real.")
|
| 105 |
+
elif has_numbers:
|
| 106 |
+
score = max(score, 52)
|
| 107 |
+
nums = signals.get("numbers", [])[:2]
|
| 108 |
+
used += nums
|
| 109 |
+
parts.append(f"Concrete numbers ({', '.join(nums)}) suggest product has been built/used.")
|
| 110 |
+
elif has_vague_only:
|
| 111 |
+
score = _clamp(score, 33, 40)
|
| 112 |
+
parts.append("Answer was on-topic but used vague language without concrete specifics.")
|
| 113 |
+
else:
|
| 114 |
+
parts.append("Product described with some substance but limited concrete evidence.")
|
| 115 |
+
|
| 116 |
+
reason = " ".join(parts)[:280]
|
| 117 |
+
return _clamp(score), reason, quote, list(dict.fromkeys(used))[:5]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _score_problem_understanding(signals: dict, engagement: int, total: int) -> tuple[int, str, str, list]:
|
| 121 |
+
"""Did they name a specific user, pain, and provide evidence of understanding?"""
|
| 122 |
+
has_validation = bool(signals.get("validation"))
|
| 123 |
+
has_colleges = bool(signals.get("college_mentions"))
|
| 124 |
+
has_user_counts = bool(signals.get("user_counts") or signals.get("numbers"))
|
| 125 |
+
val_list = signals.get("validation", [])
|
| 126 |
+
col_list = signals.get("college_mentions", [])
|
| 127 |
+
num_list = (signals.get("user_counts", []) + signals.get("numbers", []))[:2]
|
| 128 |
+
best_quotes = signals.get("best_user_quotes", [])
|
| 129 |
+
quote = (val_list[0] if val_list else (col_list[0] if col_list else (best_quotes[0][:160] if best_quotes else "")))
|
| 130 |
+
used: list[str] = (val_list[:2] + col_list[:2])[:5]
|
| 131 |
+
|
| 132 |
+
if engagement == 0:
|
| 133 |
+
return 15, "No evidence of problem understanding — no substantive answers.", quote[:160], []
|
| 134 |
+
|
| 135 |
+
score = 33
|
| 136 |
+
parts: list[str] = []
|
| 137 |
+
|
| 138 |
+
if has_validation and has_colleges:
|
| 139 |
+
score = max(score, 72)
|
| 140 |
+
parts.append(
|
| 141 |
+
f"Validated with real users ({', '.join(val_list[:2])}) "
|
| 142 |
+
f"at named campuses ({', '.join(col_list[:2])})."
|
| 143 |
+
)
|
| 144 |
+
elif has_validation:
|
| 145 |
+
score = max(score, 62)
|
| 146 |
+
parts.append(f"Validation evidence: {', '.join(val_list[:2])}.")
|
| 147 |
+
elif has_colleges:
|
| 148 |
+
score = max(score, 52)
|
| 149 |
+
parts.append(f"Campus/college context mentioned: {', '.join(col_list[:2])}.")
|
| 150 |
+
elif has_user_counts:
|
| 151 |
+
score = max(score, 50)
|
| 152 |
+
parts.append(f"User/number evidence present: {', '.join(num_list)}.")
|
| 153 |
+
else:
|
| 154 |
+
parts.append("Problem described but without user research or validation evidence.")
|
| 155 |
+
|
| 156 |
+
return _clamp(score), " ".join(parts)[:280], quote[:160] if isinstance(quote, str) else "", used
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _score_market_awareness(signals: dict, engagement: int, total: int) -> tuple[int, str, str, list]:
|
| 160 |
+
"""Did they demonstrate knowledge of market size, segment, or competitive landscape?"""
|
| 161 |
+
has_numbers = bool(signals.get("numbers") or signals.get("user_counts"))
|
| 162 |
+
has_competitors = bool(signals.get("competitors"))
|
| 163 |
+
has_colleges = bool(signals.get("college_mentions"))
|
| 164 |
+
nums = (signals.get("user_counts", []) + signals.get("numbers", []))[:3]
|
| 165 |
+
comps = signals.get("competitors", [])[:3]
|
| 166 |
+
best_quotes = signals.get("best_user_quotes", [])
|
| 167 |
+
quote = (nums[0] if nums else (comps[0] if comps else (best_quotes[0][:160] if best_quotes else "")))
|
| 168 |
+
used: list[str] = (nums[:2] + comps[:2])[:5]
|
| 169 |
+
|
| 170 |
+
if engagement == 0:
|
| 171 |
+
return 15, "No market awareness demonstrated — no substantive answers.", str(quote)[:160], []
|
| 172 |
+
|
| 173 |
+
score = 33
|
| 174 |
+
parts: list[str] = []
|
| 175 |
+
|
| 176 |
+
if has_numbers and has_competitors:
|
| 177 |
+
score = max(score, 67)
|
| 178 |
+
parts.append(
|
| 179 |
+
f"Market numbers ({', '.join(nums[:2])}) and competitors named ({', '.join(comps[:2])})."
|
| 180 |
+
)
|
| 181 |
+
elif has_numbers and has_colleges:
|
| 182 |
+
score = max(score, 60)
|
| 183 |
+
parts.append(
|
| 184 |
+
f"User/market numbers ({', '.join(nums[:2])}) with campus context."
|
| 185 |
+
)
|
| 186 |
+
elif has_numbers:
|
| 187 |
+
score = max(score, 55)
|
| 188 |
+
parts.append(f"Market/user numbers: {', '.join(nums[:2])}.")
|
| 189 |
+
elif has_competitors:
|
| 190 |
+
score = max(score, 48)
|
| 191 |
+
parts.append(
|
| 192 |
+
f"Competitors identified ({', '.join(comps[:2])}) — indicates market awareness."
|
| 193 |
+
)
|
| 194 |
+
else:
|
| 195 |
+
parts.append("Market described but without user counts, TAM, or competitor landscape.")
|
| 196 |
+
|
| 197 |
+
return _clamp(score), " ".join(parts)[:280], str(quote)[:160], used
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _score_differentiation(signals: dict, engagement: int, total: int) -> tuple[int, str, str, list]:
|
| 201 |
+
"""Did they explain why this beats alternatives (competitor + mechanism/moat)?"""
|
| 202 |
+
has_competitors = bool(signals.get("competitors"))
|
| 203 |
+
has_tech = bool(signals.get("technical_mechanisms"))
|
| 204 |
+
comps = signals.get("competitors", [])[:3]
|
| 205 |
+
techs = signals.get("technical_mechanisms", [])[:3]
|
| 206 |
+
best_quotes = signals.get("best_user_quotes", [])
|
| 207 |
+
quote = (comps[0] if comps else (techs[0] if techs else (best_quotes[0][:160] if best_quotes else "")))
|
| 208 |
+
used: list[str] = (comps[:2] + techs[:2])[:5]
|
| 209 |
+
|
| 210 |
+
if engagement == 0:
|
| 211 |
+
return 15, "No differentiation demonstrated — no substantive answers.", str(quote)[:160], []
|
| 212 |
+
|
| 213 |
+
score = 33
|
| 214 |
+
parts: list[str] = []
|
| 215 |
+
|
| 216 |
+
if has_competitors and has_tech:
|
| 217 |
+
score = max(score, 70)
|
| 218 |
+
parts.append(
|
| 219 |
+
f"Named competitors ({', '.join(comps[:2])}) with technical moat ({', '.join(techs[:2])})."
|
| 220 |
+
)
|
| 221 |
+
elif has_competitors:
|
| 222 |
+
score = max(score, 52)
|
| 223 |
+
parts.append(
|
| 224 |
+
f"Competitors identified ({', '.join(comps[:2])}) but moat/mechanism not fully articulated."
|
| 225 |
+
)
|
| 226 |
+
elif has_tech:
|
| 227 |
+
score = max(score, 50)
|
| 228 |
+
parts.append(
|
| 229 |
+
f"Technical approach described ({', '.join(techs[:2])}) but no direct competitor comparison."
|
| 230 |
+
)
|
| 231 |
+
else:
|
| 232 |
+
parts.append(
|
| 233 |
+
"Differentiation not clearly supported — no competitors named and no technical mechanism stated."
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
return _clamp(score), " ".join(parts)[:280], str(quote)[:160], used
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _score_business_model(signals: dict, engagement: int, total: int) -> tuple[int, str, str, list]:
|
| 240 |
+
"""Did they explain who pays, how much, and why?"""
|
| 241 |
+
has_pricing = bool(signals.get("pricing"))
|
| 242 |
+
has_revenue = bool(signals.get("revenue_signals"))
|
| 243 |
+
has_validation = bool(signals.get("validation"))
|
| 244 |
+
pricing = signals.get("pricing", [])[:3]
|
| 245 |
+
revenue = signals.get("revenue_signals", [])[:3]
|
| 246 |
+
best_quotes = signals.get("best_user_quotes", [])
|
| 247 |
+
quote = (pricing[0] if pricing else (revenue[0] if revenue else (best_quotes[0][:160] if best_quotes else "")))
|
| 248 |
+
used: list[str] = (pricing[:2] + revenue[:2])[:5]
|
| 249 |
+
|
| 250 |
+
if engagement == 0:
|
| 251 |
+
return 12, "No business model addressed — all non-answers.", str(quote)[:160], []
|
| 252 |
+
|
| 253 |
+
# Business model floor is lower — it's OK for early-stage students to not have revenue
|
| 254 |
+
score = 28
|
| 255 |
+
parts: list[str] = []
|
| 256 |
+
|
| 257 |
+
if has_pricing and has_revenue:
|
| 258 |
+
score = max(score, 68)
|
| 259 |
+
parts.append(f"Pricing ({', '.join(pricing[:2])}) and revenue logic ({', '.join(revenue[:2])}) present.")
|
| 260 |
+
elif has_pricing:
|
| 261 |
+
score = max(score, 52)
|
| 262 |
+
parts.append(f"Pricing mentioned: {', '.join(pricing[:2])}.")
|
| 263 |
+
elif has_revenue:
|
| 264 |
+
score = max(score, 48)
|
| 265 |
+
parts.append(f"Revenue/monetization signals: {', '.join(revenue[:2])}.")
|
| 266 |
+
elif has_validation:
|
| 267 |
+
# Traction is a proxy for business activity, even if no explicit model yet
|
| 268 |
+
score = max(score, 36)
|
| 269 |
+
parts.append(
|
| 270 |
+
"Traction/validation evidence present but no explicit pricing or revenue model stated."
|
| 271 |
+
)
|
| 272 |
+
else:
|
| 273 |
+
parts.append("Business model not clearly stated — no pricing, revenue, or monetization mentioned.")
|
| 274 |
+
|
| 275 |
+
return _clamp(score), " ".join(parts)[:280], str(quote)[:160], used
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _score_objection_handling(signals: dict, engagement: int, total: int) -> tuple[int, str, str, list]:
|
| 279 |
+
"""Did they answer hard questions directly with evidence?"""
|
| 280 |
+
has_validation = bool(signals.get("validation"))
|
| 281 |
+
has_numbers = bool(signals.get("numbers") or signals.get("user_counts"))
|
| 282 |
+
has_tech = bool(signals.get("technical_mechanisms"))
|
| 283 |
+
best_quotes = signals.get("best_user_quotes", [])
|
| 284 |
+
quote = best_quotes[0][:160] if best_quotes else ""
|
| 285 |
+
used: list[str] = (signals.get("validation", [])[:2] + signals.get("numbers", [])[:2])[:4]
|
| 286 |
+
|
| 287 |
+
if total == 0 or engagement == 0:
|
| 288 |
+
return 15, "No substantive answers to objections recorded.", quote, []
|
| 289 |
+
|
| 290 |
+
engagement_rate = engagement / total
|
| 291 |
+
# Base score: engagement_rate * 60
|
| 292 |
+
score = int(engagement_rate * 60)
|
| 293 |
+
parts: list[str] = []
|
| 294 |
+
|
| 295 |
+
if has_validation:
|
| 296 |
+
score += 10
|
| 297 |
+
vals = signals.get("validation", [])[:2]
|
| 298 |
+
parts.append(f"Evidence-backed answers: {', '.join(vals)}.")
|
| 299 |
+
if has_numbers:
|
| 300 |
+
score += 7
|
| 301 |
+
parts.append("Concrete numbers used to support claims.")
|
| 302 |
+
if has_tech:
|
| 303 |
+
score += 5
|
| 304 |
+
|
| 305 |
+
# Floor: at least 30 if majority were substantive
|
| 306 |
+
if engagement_rate > 0.5:
|
| 307 |
+
score = max(score, 30)
|
| 308 |
+
|
| 309 |
+
if not parts:
|
| 310 |
+
parts.append(
|
| 311 |
+
f"{engagement}/{total} answers were substantive. Limited evidence-backed responses to objections."
|
| 312 |
+
)
|
| 313 |
+
else:
|
| 314 |
+
parts.insert(0, f"{engagement}/{total} answers substantive.")
|
| 315 |
+
|
| 316 |
+
return _clamp(score, 0, 82), " ".join(parts)[:280], quote, used
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
# ---------------------------------------------------------------------------
|
| 320 |
+
# Local scoring orchestrator
|
| 321 |
+
# ---------------------------------------------------------------------------
|
| 322 |
+
|
| 323 |
+
def _why_weak_reason(weak_answer: str, signals: dict) -> str:
|
| 324 |
+
stripped = weak_answer.strip().lower()
|
| 325 |
+
if not stripped or stripped in ("no answers recorded.", "no answers recorded yet."):
|
| 326 |
+
return "No answers were recorded in this session."
|
| 327 |
+
if len(stripped.split()) < 4:
|
| 328 |
+
return "This answer was too brief to evaluate — no supporting evidence given."
|
| 329 |
+
if signals.get("vague_claims") and not signals.get("numbers") and not signals.get("validation"):
|
| 330 |
+
return "This answer used vague language without concrete evidence or specifics."
|
| 331 |
+
return "This answer lacked the concrete numbers, validation, or mechanisms present in stronger answers."
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def _compute_local_scores(
|
| 335 |
+
signals: dict, startup: dict
|
| 336 |
+
) -> tuple[dict[str, Any], str, str, str]:
|
| 337 |
+
"""Return (scores_dict, best_answer, weakest_answer, why_weak).
|
| 338 |
+
|
| 339 |
+
All 6 dimension scores are computed deterministically from extracted signals.
|
| 340 |
+
No API calls.
|
| 341 |
+
"""
|
| 342 |
+
all_answers = signals.get("all_user_answers", [])
|
| 343 |
+
non_answers = signals.get("non_answers", [])
|
| 344 |
+
best_quotes = signals.get("best_user_quotes", [])
|
| 345 |
+
total = len(all_answers)
|
| 346 |
+
engagement = total - len(non_answers) # number of substantive answers
|
| 347 |
+
|
| 348 |
+
scores = {
|
| 349 |
+
"clarity": _dimension(
|
| 350 |
+
*_score_clarity(signals, engagement, total)
|
| 351 |
+
),
|
| 352 |
+
"problem_understanding": _dimension(
|
| 353 |
+
*_score_problem_understanding(signals, engagement, total)
|
| 354 |
+
),
|
| 355 |
+
"market_awareness": _dimension(
|
| 356 |
+
*_score_market_awareness(signals, engagement, total)
|
| 357 |
+
),
|
| 358 |
+
"differentiation": _dimension(
|
| 359 |
+
*_score_differentiation(signals, engagement, total)
|
| 360 |
+
),
|
| 361 |
+
"business_model": _dimension(
|
| 362 |
+
*_score_business_model(signals, engagement, total)
|
| 363 |
+
),
|
| 364 |
+
"objection_handling": _dimension(
|
| 365 |
+
*_score_objection_handling(signals, engagement, total)
|
| 366 |
+
),
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
best_answer = (
|
| 370 |
+
best_quotes[0]
|
| 371 |
+
if best_quotes
|
| 372 |
+
else (all_answers[0] if all_answers else "No answers recorded.")
|
| 373 |
+
)
|
| 374 |
+
non_best = [a for a in all_answers if a != best_answer]
|
| 375 |
+
if non_answers:
|
| 376 |
+
weakest_answer = non_answers[0]
|
| 377 |
+
elif non_best:
|
| 378 |
+
weakest_answer = min(non_best, key=len)
|
| 379 |
+
else:
|
| 380 |
+
weakest_answer = all_answers[-1] if len(all_answers) > 1 else best_answer
|
| 381 |
+
|
| 382 |
+
why_weak = _why_weak_reason(weakest_answer, signals)
|
| 383 |
+
return scores, best_answer, weakest_answer, why_weak
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
# ---------------------------------------------------------------------------
|
| 387 |
+
# Coaching prompt builder (Nemotron generates only 3 coaching fields)
|
| 388 |
+
# ---------------------------------------------------------------------------
|
| 389 |
+
|
| 390 |
+
def _build_coaching_prompt(
|
| 391 |
+
session: dict,
|
| 392 |
+
signals: dict,
|
| 393 |
+
scores: dict[str, Any],
|
| 394 |
+
best_answer: str,
|
| 395 |
+
weakest_answer: str,
|
| 396 |
+
why_weak: str,
|
| 397 |
+
) -> list[dict[str, str]]:
|
| 398 |
+
"""Build messages for Nemotron coaching-only call.
|
| 399 |
+
|
| 400 |
+
Nemotron generates only: improved_answer, improved_pitch, top_3_questions.
|
| 401 |
+
All scoring is already done locally and passed as context.
|
| 402 |
+
"""
|
| 403 |
+
startup = session.get("startup", {})
|
| 404 |
+
|
| 405 |
+
startup_block = "\n".join([
|
| 406 |
+
f"Startup: {startup.get('name', 'Unknown')}",
|
| 407 |
+
f"Problem: {startup.get('problem', 'Not stated')}",
|
| 408 |
+
f"Solution: {startup.get('solution', 'Not stated')}",
|
| 409 |
+
f"Why AI: {startup.get('why_ai', 'Not stated')}",
|
| 410 |
+
f"Stage: {startup.get('stage', 'Not stated')}",
|
| 411 |
+
f"Traction: {startup.get('traction', 'Not stated')}",
|
| 412 |
+
f"Target users: {startup.get('target_users', 'Not stated')}",
|
| 413 |
+
])
|
| 414 |
+
|
| 415 |
+
# Scores block + weak dimensions (explicit requirement)
|
| 416 |
+
scores_lines = ["DIMENSION SCORES (local — do not re-score):"]
|
| 417 |
+
weak_dims: list[tuple[str, int, str]] = []
|
| 418 |
+
for dim in _REQUIRED_DIMS:
|
| 419 |
+
d = scores.get(dim, {})
|
| 420 |
+
s = d.get("score", 0)
|
| 421 |
+
lbl = d.get("label", "")
|
| 422 |
+
reason_snippet = d.get("reason", "")[:80]
|
| 423 |
+
scores_lines.append(f" {dim}: {s} ({lbl})")
|
| 424 |
+
if s < 55:
|
| 425 |
+
weak_dims.append((dim, s, lbl, reason_snippet))
|
| 426 |
+
|
| 427 |
+
scores_block = "\n".join(scores_lines)
|
| 428 |
+
|
| 429 |
+
if weak_dims:
|
| 430 |
+
weak_lines = ["\nWEAK DIMENSIONS (score < 55) — focus improved_answer and top_3_questions here:"]
|
| 431 |
+
for dim, s, lbl, reason_snippet in weak_dims:
|
| 432 |
+
weak_lines.append(f" {dim}: {s} ({lbl}) — {reason_snippet}")
|
| 433 |
+
weak_block = "\n".join(weak_lines)
|
| 434 |
+
else:
|
| 435 |
+
weak_block = "\nAll dimensions Solid or above — focus coaching on deepening evidence."
|
| 436 |
+
|
| 437 |
+
# Signals block
|
| 438 |
+
sig_lines = ["CONCRETE SIGNALS EXTRACTED FROM ANSWERS:"]
|
| 439 |
+
for key, label in [
|
| 440 |
+
("numbers", "Numbers/metrics"),
|
| 441 |
+
("validation", "Validation evidence"),
|
| 442 |
+
("competitors", "Competitors"),
|
| 443 |
+
("pricing", "Pricing/currency"),
|
| 444 |
+
("technical_mechanisms", "Technical mechanisms"),
|
| 445 |
+
("college_mentions", "Colleges/campuses"),
|
| 446 |
+
]:
|
| 447 |
+
items = signals.get(key, [])[:4]
|
| 448 |
+
if items:
|
| 449 |
+
sig_lines.append(f" {label}: {', '.join(str(x) for x in items)}")
|
| 450 |
+
signals_block = "\n".join(sig_lines)
|
| 451 |
+
|
| 452 |
+
# Actual answers block
|
| 453 |
+
all_answers = signals.get("all_user_answers", [])
|
| 454 |
+
answers_lines = ["ACTUAL FOUNDER ANSWERS (use these — do not hallucinate):"]
|
| 455 |
+
for i, a in enumerate(all_answers[:5], 1):
|
| 456 |
+
answers_lines.append(f" {i}. {a[:200]}")
|
| 457 |
+
answers_block = "\n".join(answers_lines)
|
| 458 |
+
|
| 459 |
+
system_content = (
|
| 460 |
+
"Return ONLY valid JSON. First character must be {. Last character must be }.\n"
|
| 461 |
+
"No markdown. No explanation. No analysis. No reasoning. No chain-of-thought.\n\n"
|
| 462 |
+
"You are a startup pitch coach for a student founder. "
|
| 463 |
+
"Generate coaching content based on the provided context.\n\n"
|
| 464 |
+
"RULES:\n"
|
| 465 |
+
" - Do NOT hallucinate traction, numbers, or facts not in the provided context.\n"
|
| 466 |
+
" - Do NOT re-score — scores are already computed.\n"
|
| 467 |
+
" - Use actual startup context and actual founder answers.\n"
|
| 468 |
+
" - If concrete signals exist, reference them in improved_answer and improved_pitch.\n"
|
| 469 |
+
" - improved_answer: rewrite of the weakest answer using specifics the founder already knows.\n"
|
| 470 |
+
" - improved_pitch: one concise 60-second pitch using startup name, problem, solution, evidence.\n"
|
| 471 |
+
" - top_3_questions: 3 pointed follow-up questions an investor would ask, focused on weak dimensions.\n\n"
|
| 472 |
+
"Return exactly this JSON schema — nothing else:\n"
|
| 473 |
+
'{"improved_answer": "string", "improved_pitch": "string", "top_3_questions": ["string", "string", "string"]}'
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
user_content = (
|
| 477 |
+
f"STARTUP CONTEXT:\n{startup_block}\n\n"
|
| 478 |
+
f"{scores_block}\n"
|
| 479 |
+
f"{weak_block}\n\n"
|
| 480 |
+
f"{signals_block}\n\n"
|
| 481 |
+
f"BEST ANSWER (strongest): {best_answer[:300]}\n\n"
|
| 482 |
+
f"WEAKEST ANSWER: {weakest_answer[:200]}\n"
|
| 483 |
+
f"WHY WEAK: {why_weak}\n\n"
|
| 484 |
+
f"{answers_block}\n\n"
|
| 485 |
+
"Generate improved_answer, improved_pitch, and top_3_questions. Return JSON only."
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
return [
|
| 489 |
+
{"role": "system", "content": system_content},
|
| 490 |
+
{"role": "user", "content": user_content},
|
| 491 |
+
]
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
def _parse_coaching_json(raw: str) -> dict[str, Any] | None:
|
| 495 |
+
"""Parse and validate the coaching JSON response."""
|
| 496 |
+
parsed = safe_json_parse(raw)
|
| 497 |
+
if not parsed or not isinstance(parsed, dict):
|
| 498 |
+
return None
|
| 499 |
+
|
| 500 |
+
improved_answer = str(parsed.get("improved_answer", "")).strip()
|
| 501 |
+
improved_pitch = str(parsed.get("improved_pitch", "")).strip()
|
| 502 |
+
raw_q = parsed.get("top_3_questions", [])
|
| 503 |
+
|
| 504 |
+
if isinstance(raw_q, list):
|
| 505 |
+
questions = [str(q).strip() for q in raw_q if str(q).strip()][:3]
|
| 506 |
+
else:
|
| 507 |
+
questions = []
|
| 508 |
+
|
| 509 |
+
while len(questions) < 3:
|
| 510 |
+
questions.append("What concrete evidence can you give to support your strongest claim?")
|
| 511 |
+
|
| 512 |
+
# Both coaching fields must be non-empty for the parse to be considered valid
|
| 513 |
+
if not improved_answer or not improved_pitch:
|
| 514 |
+
return None
|
| 515 |
+
|
| 516 |
+
return {
|
| 517 |
+
"improved_answer": improved_answer,
|
| 518 |
+
"improved_pitch": improved_pitch,
|
| 519 |
+
"top_3_questions": questions,
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
# ---------------------------------------------------------------------------
|
| 524 |
+
# Local coaching fallback helpers
|
| 525 |
+
# ---------------------------------------------------------------------------
|
| 526 |
+
|
| 527 |
+
def _local_improved_answer(weak: str, startup: dict, signals: dict) -> str:
|
| 528 |
+
name = startup.get("name", "our product")
|
| 529 |
+
parts: list[str] = [f"A stronger version would anchor in specifics. {name} "]
|
| 530 |
+
numbers = signals.get("numbers", []) + signals.get("user_counts", [])
|
| 531 |
+
validation = signals.get("validation", [])
|
| 532 |
+
competitors = signals.get("competitors", [])
|
| 533 |
+
if numbers:
|
| 534 |
+
parts.append(f"has demonstrated by {', '.join(numbers[:3])} ")
|
| 535 |
+
if validation:
|
| 536 |
+
parts.append(f"validated through {', '.join(validation[:2])} ")
|
| 537 |
+
if competitors:
|
| 538 |
+
parts.append(f"and is differentiated from {', '.join(competitors[:2])} ")
|
| 539 |
+
parts.append(f'(Original answer was: "{weak[:100]}")')
|
| 540 |
+
return "".join(parts)
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
def _local_improved_pitch(startup: dict, signals: dict) -> str:
|
| 544 |
+
name = startup.get("name", "Our startup")
|
| 545 |
+
problem = startup.get("problem", "a student pain point")
|
| 546 |
+
solution = startup.get("solution", "a focused product")
|
| 547 |
+
evidence = (
|
| 548 |
+
signals.get("user_counts", []) +
|
| 549 |
+
signals.get("validation", []) +
|
| 550 |
+
signals.get("numbers", [])
|
| 551 |
+
)[:3]
|
| 552 |
+
pitch = f"{name} solves {problem}. Our solution: {solution}."
|
| 553 |
+
if evidence:
|
| 554 |
+
pitch += f" Evidence so far: {', '.join(evidence)}."
|
| 555 |
+
pricing = signals.get("pricing", [])
|
| 556 |
+
if pricing:
|
| 557 |
+
pitch += f" Business model: {pricing[0]}."
|
| 558 |
+
return pitch
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def _fallback_questions(weakest_dims: list[tuple], startup: dict) -> list[str]:
|
| 562 |
+
_q = {
|
| 563 |
+
"clarity": "In one sentence, what does your product do and who does it help?",
|
| 564 |
+
"problem_understanding": "What is the most painful part of this problem for your user, and how do you know?",
|
| 565 |
+
"market_awareness": "How many potential users exist in year one, and how did you arrive at that number?",
|
| 566 |
+
"differentiation": "What would a student miss if they used a competitor instead of you?",
|
| 567 |
+
"business_model": "Who pays, how much, and what triggers the first payment?",
|
| 568 |
+
"objection_handling": "What is the strongest argument that this startup will not work, and how do you respond?",
|
| 569 |
+
}
|
| 570 |
+
out = [
|
| 571 |
+
_q.get(dim, f"What evidence do you have for your {dim.replace('_', ' ')}?")
|
| 572 |
+
for dim, _ in weakest_dims[:3]
|
| 573 |
+
]
|
| 574 |
+
while len(out) < 3:
|
| 575 |
+
out.append("What concrete evidence can you give to back your strongest claim?")
|
| 576 |
+
return out[:3]
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def _local_coaching(
|
| 580 |
+
weakest: str,
|
| 581 |
+
startup: dict,
|
| 582 |
+
signals: dict,
|
| 583 |
+
scores: dict[str, Any],
|
| 584 |
+
) -> dict[str, Any]:
|
| 585 |
+
"""Generate local coaching content when Nemotron coaching fails."""
|
| 586 |
+
dim_sorted = sorted(scores.items(), key=lambda x: x[1]["score"])
|
| 587 |
+
return {
|
| 588 |
+
"improved_answer": _local_improved_answer(weakest, startup, signals),
|
| 589 |
+
"improved_pitch": _local_improved_pitch(startup, signals),
|
| 590 |
+
"top_3_questions": _fallback_questions(dim_sorted, startup),
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
# ---------------------------------------------------------------------------
|
| 595 |
+
# Main hybrid scorecard generator (Phase 5D — permanent architecture)
|
| 596 |
+
# ---------------------------------------------------------------------------
|
| 597 |
+
|
| 598 |
+
def generate_claim_based_scorecard(
|
| 599 |
+
session: dict, model_mode: str | None = None
|
| 600 |
+
) -> dict[str, Any]:
|
| 601 |
+
"""Hybrid claim-based scorecard.
|
| 602 |
+
|
| 603 |
+
Local scoring → deterministic, signal-based, always fair to student founders.
|
| 604 |
+
Nemotron coaching → generates improved_answer, improved_pitch, top_3_questions only.
|
| 605 |
+
|
| 606 |
+
Returns a frontend-safe dict with all required fields on every path.
|
| 607 |
+
"""
|
| 608 |
+
resolved_mode = model_mode or session.get("model_mode") or os.getenv(
|
| 609 |
+
"DEFAULT_MODEL_MODE", "premium_nvidia"
|
| 610 |
+
)
|
| 611 |
+
startup = session.get("startup", {})
|
| 612 |
+
|
| 613 |
+
# Step 1: Extract signals (local, no API)
|
| 614 |
+
try:
|
| 615 |
+
signals = extract_concrete_signals(session)
|
| 616 |
+
except Exception as exc:
|
| 617 |
+
logger.warning("scoring_engine: signal extraction failed: %s", exc)
|
| 618 |
+
signals = _empty_signals()
|
| 619 |
+
|
| 620 |
+
# Step 2: Compute all 6 dimension scores + best/weakest answers locally
|
| 621 |
+
try:
|
| 622 |
+
scores, best_answer, weakest_answer, why_weak = _compute_local_scores(signals, startup)
|
| 623 |
+
except Exception as exc:
|
| 624 |
+
logger.warning("scoring_engine: local scoring failed: %s", exc)
|
| 625 |
+
return build_session_aware_fallback_scorecard(
|
| 626 |
+
session, signals, f"Local scoring error: {type(exc).__name__}"
|
| 627 |
+
)
|
| 628 |
+
|
| 629 |
+
# Step 3: Compute overall and concrete_signals_summary locally
|
| 630 |
+
overall = round(sum(d["score"] for d in scores.values()) / len(scores))
|
| 631 |
+
concrete_signals_summary = {
|
| 632 |
+
"numbers": signals.get("numbers", [])[:6],
|
| 633 |
+
"validation": signals.get("validation", [])[:6],
|
| 634 |
+
"competitors": signals.get("competitors", [])[:6],
|
| 635 |
+
"revenue_signals": signals.get("revenue_signals", [])[:6],
|
| 636 |
+
"technical_mechanisms": signals.get("technical_mechanisms", [])[:6],
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
# Step 4: Call Nemotron for coaching only
|
| 640 |
+
coaching: dict[str, Any] | None = None
|
| 641 |
+
coaching_error: str = ""
|
| 642 |
+
coaching_raw: str = ""
|
| 643 |
+
|
| 644 |
+
try:
|
| 645 |
+
coaching_messages = _build_coaching_prompt(
|
| 646 |
+
session, signals, scores, best_answer, weakest_answer, why_weak
|
| 647 |
+
)
|
| 648 |
+
coaching_result = model_router.generate_coaching_response(
|
| 649 |
+
coaching_messages, model_mode=resolved_mode
|
| 650 |
+
)
|
| 651 |
+
if coaching_result.get("ok") and coaching_result.get("content"):
|
| 652 |
+
coaching_raw = coaching_result["content"]
|
| 653 |
+
coaching = _parse_coaching_json(coaching_raw)
|
| 654 |
+
if coaching:
|
| 655 |
+
logger.info("scoring_engine: Nemotron coaching JSON parsed OK")
|
| 656 |
+
else:
|
| 657 |
+
logger.warning("scoring_engine: primary coaching parse failed, raw[:200]=%r", coaching_raw[:200])
|
| 658 |
+
else:
|
| 659 |
+
coaching_error = coaching_result.get("error") or "Coaching model returned empty response"
|
| 660 |
+
logger.warning("scoring_engine: coaching call not ok — %s", coaching_error)
|
| 661 |
+
except Exception as exc:
|
| 662 |
+
coaching_error = f"Coaching error: {type(exc).__name__}"
|
| 663 |
+
logger.warning("scoring_engine: coaching call raised — %s", exc)
|
| 664 |
+
|
| 665 |
+
# Step 5: Repair retry if JSON parse failed (content was returned but not valid JSON)
|
| 666 |
+
if coaching is None and coaching_raw:
|
| 667 |
+
logger.info("scoring_engine: attempting coaching JSON repair")
|
| 668 |
+
try:
|
| 669 |
+
repair_result = model_router.generate_coaching_repair_response(
|
| 670 |
+
coaching_raw, model_mode=resolved_mode
|
| 671 |
+
)
|
| 672 |
+
if repair_result.get("ok") and repair_result.get("content"):
|
| 673 |
+
coaching = _parse_coaching_json(repair_result["content"])
|
| 674 |
+
if coaching:
|
| 675 |
+
logger.info("scoring_engine: repaired coaching JSON OK")
|
| 676 |
+
else:
|
| 677 |
+
logger.warning("scoring_engine: repair coaching parse also failed")
|
| 678 |
+
except Exception as exc:
|
| 679 |
+
logger.warning("scoring_engine: coaching repair raised — %s", exc)
|
| 680 |
+
|
| 681 |
+
# Step 6: Local coaching fallback if Nemotron failed
|
| 682 |
+
if coaching is None:
|
| 683 |
+
logger.warning(
|
| 684 |
+
"scoring_engine: using local coaching fallback. error=%r", coaching_error
|
| 685 |
+
)
|
| 686 |
+
coaching = _local_coaching(weakest_answer, startup, signals, scores)
|
| 687 |
+
source = "hybrid_claims_local"
|
| 688 |
+
model_ok = False
|
| 689 |
+
provider = "local"
|
| 690 |
+
else:
|
| 691 |
+
source = "hybrid_claims_nemotron"
|
| 692 |
+
model_ok = True
|
| 693 |
+
provider = "local+nvidia"
|
| 694 |
+
|
| 695 |
+
# Step 7: Assemble and return complete scorecard
|
| 696 |
+
result: dict[str, Any] = {
|
| 697 |
+
"overall": overall,
|
| 698 |
+
"overall_label": _score_label(overall),
|
| 699 |
+
"scores": scores,
|
| 700 |
+
"best_answer": best_answer,
|
| 701 |
+
"weakest_answer": weakest_answer,
|
| 702 |
+
"why_weak": why_weak,
|
| 703 |
+
"improved_answer": coaching["improved_answer"],
|
| 704 |
+
"improved_pitch": coaching["improved_pitch"],
|
| 705 |
+
"top_3_questions": coaching["top_3_questions"],
|
| 706 |
+
"concrete_signals_summary": concrete_signals_summary,
|
| 707 |
+
"model_ok": model_ok,
|
| 708 |
+
"provider": provider,
|
| 709 |
+
"model_mode": resolved_mode,
|
| 710 |
+
"scorecard_source": source,
|
| 711 |
+
}
|
| 712 |
+
if coaching_error and not model_ok:
|
| 713 |
+
result["model_error"] = coaching_error
|
| 714 |
+
|
| 715 |
+
logger.info(
|
| 716 |
+
"scoring_engine: hybrid scorecard complete — overall=%d source=%s signals=%d",
|
| 717 |
+
overall, source, signals.get("signal_count", 0),
|
| 718 |
+
)
|
| 719 |
+
return result
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
# ---------------------------------------------------------------------------
|
| 723 |
+
# Session-aware fallback (used by api_handlers exception handler + local crash)
|
| 724 |
+
# ---------------------------------------------------------------------------
|
| 725 |
+
|
| 726 |
+
def build_session_aware_fallback_scorecard(
|
| 727 |
+
session: dict, signals: dict, error: str = ""
|
| 728 |
+
) -> dict[str, Any]:
|
| 729 |
+
"""Session-aware fallback when even local scoring crashes.
|
| 730 |
+
|
| 731 |
+
Uses actual user answers and extracted signals — never shows static EventRadar content.
|
| 732 |
+
"""
|
| 733 |
+
startup = session.get("startup", {})
|
| 734 |
+
all_answers = signals.get("all_user_answers", [])
|
| 735 |
+
best_quotes = signals.get("best_user_quotes", [])
|
| 736 |
+
non_answers = signals.get("non_answers", [])
|
| 737 |
+
|
| 738 |
+
best_answer = best_quotes[0] if best_quotes else (all_answers[0] if all_answers else "No answers recorded.")
|
| 739 |
+
non_best = [a for a in all_answers if a != best_answer]
|
| 740 |
+
if non_answers:
|
| 741 |
+
weakest_answer = non_answers[0]
|
| 742 |
+
elif non_best:
|
| 743 |
+
weakest_answer = min(non_best, key=len)
|
| 744 |
+
else:
|
| 745 |
+
weakest_answer = all_answers[-1] if all_answers else "No answers recorded."
|
| 746 |
+
|
| 747 |
+
has_numbers = bool(signals.get("numbers") or signals.get("user_counts"))
|
| 748 |
+
has_validation = bool(signals.get("validation"))
|
| 749 |
+
has_competitors= bool(signals.get("competitors"))
|
| 750 |
+
has_tech = bool(signals.get("technical_mechanisms"))
|
| 751 |
+
has_revenue = bool(signals.get("revenue_signals") or signals.get("pricing"))
|
| 752 |
+
has_colleges = bool(signals.get("college_mentions"))
|
| 753 |
+
total = len(all_answers)
|
| 754 |
+
non_ans_count = len(non_answers)
|
| 755 |
+
engagement = 1.0 - (non_ans_count / max(total, 1))
|
| 756 |
+
|
| 757 |
+
def _c(v: int) -> int:
|
| 758 |
+
return max(0, min(100, v))
|
| 759 |
+
|
| 760 |
+
clarity_score = _c(65 if (has_numbers and total > 1) else 52 if total > 2 else 35)
|
| 761 |
+
problem_score = _c(
|
| 762 |
+
72 if (has_validation and has_colleges) else
|
| 763 |
+
63 if has_validation else
|
| 764 |
+
55 if has_numbers else
|
| 765 |
+
45 if engagement > 0.7 else 30
|
| 766 |
+
)
|
| 767 |
+
market_score = _c(
|
| 768 |
+
68 if (has_numbers and has_competitors) else
|
| 769 |
+
55 if (has_numbers or has_competitors) else
|
| 770 |
+
38 if engagement > 0.6 else 22
|
| 771 |
+
)
|
| 772 |
+
diff_score = _c(
|
| 773 |
+
70 if (has_competitors and has_tech) else
|
| 774 |
+
55 if (has_competitors or has_tech) else
|
| 775 |
+
38 if engagement > 0.6 else 25
|
| 776 |
+
)
|
| 777 |
+
biz_score = _c(
|
| 778 |
+
65 if (has_revenue and has_numbers) else
|
| 779 |
+
52 if has_revenue else
|
| 780 |
+
38 if has_validation else
|
| 781 |
+
30 if engagement > 0.5 else 18
|
| 782 |
+
)
|
| 783 |
+
obj_score = _c(int(engagement * 65) + (8 if has_validation else 0) + (5 if has_numbers else 0))
|
| 784 |
+
|
| 785 |
+
scores = {
|
| 786 |
+
"clarity": _dimension(
|
| 787 |
+
clarity_score,
|
| 788 |
+
f"Local estimate from {total} answer(s). Scoring engine unavailable.",
|
| 789 |
+
best_answer[:160],
|
| 790 |
+
signals.get("numbers", [])[:3],
|
| 791 |
+
),
|
| 792 |
+
"problem_understanding": _dimension(
|
| 793 |
+
problem_score,
|
| 794 |
+
"Based on validation/research evidence detected in answers."
|
| 795 |
+
+ (" College mentions found." if has_colleges else ""),
|
| 796 |
+
(signals.get("validation") or [""])[0],
|
| 797 |
+
(signals.get("validation", []) + signals.get("college_mentions", []))[:3],
|
| 798 |
+
),
|
| 799 |
+
"market_awareness": _dimension(
|
| 800 |
+
market_score,
|
| 801 |
+
"Based on numbers/metrics and competitor mentions in answers.",
|
| 802 |
+
(signals.get("numbers") or signals.get("competitors") or [""])[0],
|
| 803 |
+
(signals.get("numbers", []) + signals.get("competitors", []))[:3],
|
| 804 |
+
),
|
| 805 |
+
"differentiation": _dimension(
|
| 806 |
+
diff_score,
|
| 807 |
+
"Based on competitor mentions and technical mechanism signals.",
|
| 808 |
+
(signals.get("competitors") or signals.get("technical_mechanisms") or [""])[0],
|
| 809 |
+
(signals.get("competitors", []) + signals.get("technical_mechanisms", []))[:3],
|
| 810 |
+
),
|
| 811 |
+
"business_model": _dimension(
|
| 812 |
+
biz_score,
|
| 813 |
+
"Based on revenue/pricing signals detected in answers."
|
| 814 |
+
+ (" No explicit price found." if not has_revenue else ""),
|
| 815 |
+
(signals.get("revenue_signals") or signals.get("pricing") or [""])[0],
|
| 816 |
+
(signals.get("revenue_signals", []) + signals.get("pricing", []))[:3],
|
| 817 |
+
),
|
| 818 |
+
"objection_handling": _dimension(
|
| 819 |
+
obj_score,
|
| 820 |
+
f"{int(engagement * 100)}% substantive responses. {non_ans_count} non-answer turn(s) noted.",
|
| 821 |
+
best_answer[:160],
|
| 822 |
+
(signals.get("validation", []) + signals.get("numbers", []))[:3],
|
| 823 |
+
),
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
overall = round(sum(d["score"] for d in scores.values()) / 6)
|
| 827 |
+
dim_sorted = sorted(scores.items(), key=lambda x: x[1]["score"])
|
| 828 |
+
|
| 829 |
+
return {
|
| 830 |
+
"overall": overall,
|
| 831 |
+
"overall_label": _score_label(overall),
|
| 832 |
+
"scores": scores,
|
| 833 |
+
"best_answer": best_answer,
|
| 834 |
+
"weakest_answer": weakest_answer,
|
| 835 |
+
"why_weak": "This answer lacked concrete evidence compared to your stronger responses.",
|
| 836 |
+
"improved_answer": _local_improved_answer(weakest_answer, startup, signals),
|
| 837 |
+
"improved_pitch": _local_improved_pitch(startup, signals),
|
| 838 |
+
"top_3_questions": _fallback_questions(dim_sorted, startup),
|
| 839 |
+
"concrete_signals_summary": {
|
| 840 |
+
"numbers": signals.get("numbers", [])[:6],
|
| 841 |
+
"validation": signals.get("validation", [])[:6],
|
| 842 |
+
"competitors": signals.get("competitors", [])[:6],
|
| 843 |
+
"revenue_signals": signals.get("revenue_signals", [])[:6],
|
| 844 |
+
"technical_mechanisms": signals.get("technical_mechanisms", [])[:6],
|
| 845 |
+
},
|
| 846 |
+
"model_ok": False,
|
| 847 |
+
"provider": "local",
|
| 848 |
+
"model_mode": "session_fallback",
|
| 849 |
+
"scorecard_source": "session_fallback",
|
| 850 |
+
**({"model_error": error} if error else {}),
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
|
| 854 |
+
# ---------------------------------------------------------------------------
|
| 855 |
+
# Static mock scorecard (absolute last resort — no session available)
|
| 856 |
+
# ---------------------------------------------------------------------------
|
| 857 |
+
|
| 858 |
+
def mock_scorecard(session: dict) -> dict:
|
| 859 |
+
"""Static mock. Use ONLY when session-aware fallback also cannot run."""
|
| 860 |
+
startup = session.get("startup", {})
|
| 861 |
+
history = session.get("history", [])
|
| 862 |
+
name = startup.get("name", "your startup")
|
| 863 |
+
user_messages = [m["content"] for m in history if m.get("role") == "user"]
|
| 864 |
+
best_answer = user_messages[0] if user_messages else "No answers recorded yet."
|
| 865 |
+
weakest_answer = user_messages[-1] if user_messages else "No answers recorded."
|
| 866 |
+
|
| 867 |
+
scores = {
|
| 868 |
+
"clarity": _dimension(64, f"Several answers stayed high-level without concrete proof.", weakest_answer[:160]),
|
| 869 |
+
"problem_understanding": _dimension(
|
| 870 |
+
76, f"Problem understanding was articulated for {name}.", startup.get("problem", "")[:160]
|
| 871 |
+
),
|
| 872 |
+
"market_awareness": _dimension(67, "Competitors were named but differentiation was not sharp.", ""),
|
| 873 |
+
"differentiation": _dimension(63, "The AI angle needs a clearer moat beyond basic filtering.", ""),
|
| 874 |
+
"business_model": _dimension(61, "Revenue path and retention logic were not defended under pressure.", ""),
|
| 875 |
+
"objection_handling": _dimension(72, "You stayed in the fight but dodged the hardest follow-ups.", best_answer[:160]),
|
| 876 |
+
}
|
| 877 |
+
return {
|
| 878 |
+
"overall": 68,
|
| 879 |
+
"overall_label": _score_label(68),
|
| 880 |
+
"scores": scores,
|
| 881 |
+
"best_answer": best_answer,
|
| 882 |
+
"weakest_answer": weakest_answer,
|
| 883 |
+
"why_weak": "The answer was vague and lacked concrete evidence or numbers.",
|
| 884 |
+
"improved_answer": f"A stronger answer would anchor {name}'s claims in specific evidence.",
|
| 885 |
+
"improved_pitch": f"{name} addresses {startup.get('problem', 'a key pain point')}.",
|
| 886 |
+
"top_3_questions": [
|
| 887 |
+
"Why does this need AI instead of filters and sorted lists?",
|
| 888 |
+
"How will you get students to use this instead of existing alternatives?",
|
| 889 |
+
"What is your wedge for the first 100 active users on one campus?",
|
| 890 |
+
],
|
| 891 |
+
"concrete_signals_summary": {
|
| 892 |
+
"numbers": [], "validation": [], "competitors": [],
|
| 893 |
+
"revenue_signals": [], "technical_mechanisms": [],
|
| 894 |
+
},
|
| 895 |
+
"model_ok": False,
|
| 896 |
+
"provider": "mock",
|
| 897 |
+
"model_mode": "mock_fallback",
|
| 898 |
+
"scorecard_source": "fallback",
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
|
| 902 |
+
# ---------------------------------------------------------------------------
|
| 903 |
+
# Legacy full-Nemotron scorecard (kept for diagnostics — not main path)
|
| 904 |
+
# ---------------------------------------------------------------------------
|
| 905 |
+
|
| 906 |
+
def generate_real_scorecard(session: dict, model_mode: str | None = None) -> dict:
|
| 907 |
+
"""Legacy: redirects to generate_claim_based_scorecard."""
|
| 908 |
+
return generate_claim_based_scorecard(session, model_mode)
|
core/session_manager.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""In-memory session manager for pitch battles."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
SESSIONS: dict[str, dict[str, Any]] = {}
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def create_session(
|
| 12 |
+
startup: dict,
|
| 13 |
+
persona: str,
|
| 14 |
+
difficulty: str,
|
| 15 |
+
input_mode: str,
|
| 16 |
+
) -> dict[str, Any]:
|
| 17 |
+
"""Create a new pitch battle session."""
|
| 18 |
+
session_id = str(uuid.uuid4())
|
| 19 |
+
session = {
|
| 20 |
+
"session_id": session_id,
|
| 21 |
+
"startup": startup,
|
| 22 |
+
"persona": persona,
|
| 23 |
+
"difficulty": difficulty,
|
| 24 |
+
"input_mode": input_mode,
|
| 25 |
+
"round": 1,
|
| 26 |
+
"history": [],
|
| 27 |
+
}
|
| 28 |
+
SESSIONS[session_id] = session
|
| 29 |
+
return session
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_session(session_id: str) -> dict[str, Any] | None:
|
| 33 |
+
"""Return a session by id, or None if missing."""
|
| 34 |
+
return SESSIONS.get(session_id)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def append_user_message(session_id: str, message: str) -> None:
|
| 38 |
+
"""Append a user message to session history."""
|
| 39 |
+
session = SESSIONS.get(session_id)
|
| 40 |
+
if not session:
|
| 41 |
+
return
|
| 42 |
+
session["history"].append({"role": "user", "content": message})
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def append_ai_message(session_id: str, message: str, attack_tag: str) -> None:
|
| 46 |
+
"""Append an AI opponent message to session history."""
|
| 47 |
+
session = SESSIONS.get(session_id)
|
| 48 |
+
if not session:
|
| 49 |
+
return
|
| 50 |
+
session["history"].append(
|
| 51 |
+
{"role": "assistant", "content": message, "attack_tag": attack_tag}
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def increment_round(session_id: str) -> int:
|
| 56 |
+
"""Increment and return the current round number."""
|
| 57 |
+
session = SESSIONS.get(session_id)
|
| 58 |
+
if not session:
|
| 59 |
+
return 0
|
| 60 |
+
session["round"] = session.get("round", 1) + 1
|
| 61 |
+
return session["round"]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def get_history(session_id: str) -> list[dict[str, Any]]:
|
| 65 |
+
"""Return conversation history for a session."""
|
| 66 |
+
session = SESSIONS.get(session_id)
|
| 67 |
+
if not session:
|
| 68 |
+
return []
|
| 69 |
+
return list(session.get("history", []))
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def reset_session(session_id: str) -> bool:
|
| 73 |
+
"""Delete a session. Returns True if it existed."""
|
| 74 |
+
if session_id in SESSIONS:
|
| 75 |
+
del SESSIONS[session_id]
|
| 76 |
+
return True
|
| 77 |
+
return False
|
core/transcription_client.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stub transcription client — faster-whisper fallback planned for Phase 7."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def health_check() -> dict[str, Any]:
|
| 9 |
+
return {
|
| 10 |
+
"status": "not_configured",
|
| 11 |
+
"provider": "local",
|
| 12 |
+
"message": "faster-whisper fallback planned for Phase 7",
|
| 13 |
+
}
|
core/vision_client.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stub vision client — MiniCPM-V 4.6 deck critique planned for Phase 10."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def health_check() -> dict[str, Any]:
|
| 9 |
+
return {
|
| 10 |
+
"status": "not_configured",
|
| 11 |
+
"provider": "openbmb",
|
| 12 |
+
"message": "Vision/deck critique planned for Phase 10",
|
| 13 |
+
}
|
core/voice_transcriber.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Placeholder voice transcriber for Phase 9 integration."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class VoiceTranscriber:
|
| 7 |
+
"""Stub for future faster-whisper integration."""
|
| 8 |
+
|
| 9 |
+
def health_check(self) -> dict[str, str]:
|
| 10 |
+
return {
|
| 11 |
+
"status": "not_loaded",
|
| 12 |
+
"message": "Voice transcription will be added in Phase 9",
|
| 13 |
+
}
|
docs/BACKEND_API.md
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PitchFight AI — Backend API Reference
|
| 2 |
+
|
| 3 |
+
## 1. Important Note About Gradio Internal Endpoints
|
| 4 |
+
|
| 5 |
+
When you run `python app.py` and open FastAPI/Swagger (`/docs`), you will see **many routes** that are **not** PitchFight AI product APIs, for example:
|
| 6 |
+
|
| 7 |
+
- `/gradio_api/*` — config, queue, upload, streaming
|
| 8 |
+
- `/queue/*` — Gradio job queue
|
| 9 |
+
- `/upload` — Gradio file upload
|
| 10 |
+
- `/static/*`, `/assets/*`, `/theme.css` — Gradio UI assets
|
| 11 |
+
- Login, monitoring, and other framework runtime routes
|
| 12 |
+
|
| 13 |
+
These are **automatically registered by Gradio** and are required for Gradio Server to work on Hugging Face Spaces. **Do not delete them.**
|
| 14 |
+
|
| 15 |
+
**PitchFight AI’s real backend endpoints** are namespaced under **`/api/...`** plus **`/health`** and **`/`**.
|
| 16 |
+
|
| 17 |
+
> Judge this project’s API design by `/api/*` and this document — not by the long Gradio-generated OpenAPI list.
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
## 2. Backend Design Principles
|
| 22 |
+
|
| 23 |
+
| Principle | Detail |
|
| 24 |
+
|---|---|
|
| 25 |
+
| **Frontend never calls model providers** | No NVIDIA/OpenBMB keys in browser JS; only `/api/*` + `/health` |
|
| 26 |
+
| **Backend owns model routing** | `core/model_router.py` (Phase 2+) — default `premium_nvidia` (Nemotron Omni 30B-A3B) |
|
| 27 |
+
| **Backend owns session state** | `core/session_manager.py` |
|
| 28 |
+
| **Backend owns scoring** | `core/scoring_engine.py` |
|
| 29 |
+
| **Frontend sends actions, renders results** | Custom HTML/CSS/JS + `fetch()` |
|
| 30 |
+
| **Secrets in env only** | `.env` locally, HF Space Secrets in deployment — never in frontend or repo |
|
| 31 |
+
|
| 32 |
+
> **Strategy:** High-demo sponsor-model build. Off-the-Grid is not targeted. faster-whisper is transcription fallback only; OpenBMB MiniCPM modes are secondary/fallback paths.
|
| 33 |
+
|
| 34 |
+
Shared logic lives in **`core/api_handlers.py`**. Both REST routes (`/api/...`) and Gradio `@app.api` wrappers call the same handler functions.
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## 3. Clean Project Endpoints
|
| 39 |
+
|
| 40 |
+
| Method | Path | Status | Purpose |
|
| 41 |
+
|---|---|---|---|
|
| 42 |
+
| `GET` | `/health` | **Implemented** | App health check |
|
| 43 |
+
| `GET` | `/` | **Implemented** | Serve custom frontend |
|
| 44 |
+
| `GET` | `/api/model-health` | **Implemented** (Phase 2) | Model provider config status (no keys) |
|
| 45 |
+
| `POST` | `/api/load-sample` | **Implemented** | Load EventRadar AI demo startup |
|
| 46 |
+
| `POST` | `/api/start-session` | **Implemented** | Start Pitch Battle session |
|
| 47 |
+
| `POST` | `/api/chat-round` | **Implemented** | Send user answer, get AI pushback |
|
| 48 |
+
| `POST` | `/api/end-battle` | **Implemented** | Generate scorecard |
|
| 49 |
+
| `POST` | `/api/reset-session` | **Implemented** | Delete session |
|
| 50 |
+
| `POST` | `/api/voice-pitch` | **Placeholder** | Voice pitch (reserved) |
|
| 51 |
+
| `POST` | `/api/start-deal-session` | **Placeholder** | Deal Battle (reserved) |
|
| 52 |
+
| `POST` | `/api/deck-critique` | **Placeholder** | Pitch deck critique (reserved) |
|
| 53 |
+
|
| 54 |
+
### Gradio compatibility wrappers (same handlers)
|
| 55 |
+
|
| 56 |
+
| Gradio `@app.api` name | Handler |
|
| 57 |
+
|---|---|
|
| 58 |
+
| `load_sample` | `handle_load_sample()` |
|
| 59 |
+
| `start_session` | `handle_start_session()` |
|
| 60 |
+
| `chat_round` | `handle_chat_round()` |
|
| 61 |
+
| `end_battle` | `handle_end_battle()` |
|
| 62 |
+
| `reset_session` | `handle_reset_session()` |
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## 4. Endpoint Details
|
| 67 |
+
|
| 68 |
+
### `GET /health`
|
| 69 |
+
|
| 70 |
+
**Purpose:** Verify the server is running.
|
| 71 |
+
|
| 72 |
+
**Response:**
|
| 73 |
+
|
| 74 |
+
```json
|
| 75 |
+
{
|
| 76 |
+
"status": "ok",
|
| 77 |
+
"app": "PitchFight AI",
|
| 78 |
+
"version": "0.1.0"
|
| 79 |
+
}
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
---
|
| 83 |
+
|
| 84 |
+
### `GET /api/model-health`
|
| 85 |
+
|
| 86 |
+
**Purpose:** Return model provider configuration status. API keys are **never** included in the response.
|
| 87 |
+
|
| 88 |
+
**Response:**
|
| 89 |
+
|
| 90 |
+
```json
|
| 91 |
+
{
|
| 92 |
+
"default_mode": "premium_nvidia",
|
| 93 |
+
"supported_modes": ["openbmb_omni", "premium_nvidia", "tiny_minicpm", "vision_deck", "whisper_fallback"],
|
| 94 |
+
"providers": {
|
| 95 |
+
"nvidia": {
|
| 96 |
+
"provider": "nvidia",
|
| 97 |
+
"configured": true,
|
| 98 |
+
"base_url": "https://integrate.api.nvidia.com/v1",
|
| 99 |
+
"model": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning",
|
| 100 |
+
"api_key_present": true,
|
| 101 |
+
"message": "NVIDIA client ready"
|
| 102 |
+
},
|
| 103 |
+
"minicpm": { "status": "not_configured", "provider": "openbmb", "message": "MiniCPM integration planned for Phase 9" },
|
| 104 |
+
"vision": { "status": "not_configured", "provider": "openbmb", "message": "Vision/deck critique planned for Phase 10" },
|
| 105 |
+
"transcription": { "status": "not_configured", "provider": "local", "message": "faster-whisper fallback planned for Phase 7" }
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
### `GET /`
|
| 113 |
+
|
| 114 |
+
**Purpose:** Serve `frontend/index.html` (custom battle arena UI).
|
| 115 |
+
|
| 116 |
+
Static assets: `/frontend/styles.css`, `/frontend/script.js`, `/frontend/assets/*`
|
| 117 |
+
|
| 118 |
+
---
|
| 119 |
+
|
| 120 |
+
### `POST /api/load-sample`
|
| 121 |
+
|
| 122 |
+
**Purpose:** Load the EventRadar AI sample startup into the form.
|
| 123 |
+
|
| 124 |
+
**Request body:** none
|
| 125 |
+
|
| 126 |
+
**Response:**
|
| 127 |
+
|
| 128 |
+
```json
|
| 129 |
+
{
|
| 130 |
+
"startup": {
|
| 131 |
+
"name": "EventRadar AI",
|
| 132 |
+
"problem": "...",
|
| 133 |
+
"target_users": "...",
|
| 134 |
+
"solution": "...",
|
| 135 |
+
"why_ai": "...",
|
| 136 |
+
"competitors": "...",
|
| 137 |
+
"traction": "...",
|
| 138 |
+
"ask": "..."
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
### `POST /api/start-session`
|
| 146 |
+
|
| 147 |
+
**Purpose:** Start a new Pitch Battle session.
|
| 148 |
+
|
| 149 |
+
**Request body:**
|
| 150 |
+
|
| 151 |
+
```json
|
| 152 |
+
{
|
| 153 |
+
"mode": "pitch_battle",
|
| 154 |
+
"persona": "hackathon_judge",
|
| 155 |
+
"difficulty": "high",
|
| 156 |
+
"input_mode": "text",
|
| 157 |
+
"model_mode": "premium_nvidia",
|
| 158 |
+
"startup": {
|
| 159 |
+
"name": "EventRadar AI",
|
| 160 |
+
"problem": "...",
|
| 161 |
+
"target_users": "...",
|
| 162 |
+
"solution": "...",
|
| 163 |
+
"why_ai": "...",
|
| 164 |
+
"competitors": "...",
|
| 165 |
+
"traction": "...",
|
| 166 |
+
"ask": "..."
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
**Response:**
|
| 172 |
+
|
| 173 |
+
```json
|
| 174 |
+
{
|
| 175 |
+
"session_id": "uuid",
|
| 176 |
+
"round": 1,
|
| 177 |
+
"pressure_level": "Medium",
|
| 178 |
+
"attack_tag": "User Pain",
|
| 179 |
+
"ai_message": "Students already lurk in WhatsApp groups..."
|
| 180 |
+
}
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
---
|
| 184 |
+
|
| 185 |
+
### `POST /api/chat-round`
|
| 186 |
+
|
| 187 |
+
**Purpose:** Submit one founder answer and receive the next AI challenge.
|
| 188 |
+
|
| 189 |
+
**Request body:**
|
| 190 |
+
|
| 191 |
+
```json
|
| 192 |
+
{
|
| 193 |
+
"session_id": "uuid",
|
| 194 |
+
"user_message": "We use AI to rank events for students."
|
| 195 |
+
}
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
**Response:**
|
| 199 |
+
|
| 200 |
+
```json
|
| 201 |
+
{
|
| 202 |
+
"session_id": "uuid",
|
| 203 |
+
"round": 2,
|
| 204 |
+
"pressure_level": "Medium",
|
| 205 |
+
"attack_tag": "Demo Clarity",
|
| 206 |
+
"ai_message": "If I only saw a 30-second demo..."
|
| 207 |
+
}
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
---
|
| 211 |
+
|
| 212 |
+
### `POST /api/end-battle`
|
| 213 |
+
|
| 214 |
+
**Purpose:** End the battle and return a scorecard.
|
| 215 |
+
|
| 216 |
+
**Request body:**
|
| 217 |
+
|
| 218 |
+
```json
|
| 219 |
+
{
|
| 220 |
+
"session_id": "uuid"
|
| 221 |
+
}
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
**Response:**
|
| 225 |
+
|
| 226 |
+
```json
|
| 227 |
+
{
|
| 228 |
+
"overall": 68,
|
| 229 |
+
"scores": {
|
| 230 |
+
"clarity": {
|
| 231 |
+
"score": 64,
|
| 232 |
+
"reason": "...",
|
| 233 |
+
"quote": "..."
|
| 234 |
+
}
|
| 235 |
+
},
|
| 236 |
+
"best_answer": "...",
|
| 237 |
+
"weakest_answer": "...",
|
| 238 |
+
"improved_answer": "...",
|
| 239 |
+
"improved_pitch": "...",
|
| 240 |
+
"top_3_questions": ["...", "...", "..."]
|
| 241 |
+
}
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
---
|
| 245 |
+
|
| 246 |
+
### `POST /api/reset-session`
|
| 247 |
+
|
| 248 |
+
**Purpose:** Delete a session.
|
| 249 |
+
|
| 250 |
+
**Request body:**
|
| 251 |
+
|
| 252 |
+
```json
|
| 253 |
+
{
|
| 254 |
+
"session_id": "uuid"
|
| 255 |
+
}
|
| 256 |
+
```
|
| 257 |
+
|
| 258 |
+
**Response:**
|
| 259 |
+
|
| 260 |
+
```json
|
| 261 |
+
{
|
| 262 |
+
"status": "reset"
|
| 263 |
+
}
|
| 264 |
+
```
|
| 265 |
+
|
| 266 |
+
---
|
| 267 |
+
|
| 268 |
+
### `POST /api/voice-pitch` (placeholder)
|
| 269 |
+
|
| 270 |
+
**Planned flow (Phase 7):** audio → Nemotron Omni (primary) → pitch context + opening question. Fallback: faster-whisper transcription → Nemotron or MiniCPM5-1B text path.
|
| 271 |
+
|
| 272 |
+
**Response (current):**
|
| 273 |
+
|
| 274 |
+
```json
|
| 275 |
+
{
|
| 276 |
+
"status": "not_implemented",
|
| 277 |
+
"message": "Voice Mode endpoint is reserved. Primary path: Nemotron Omni voice; fallback: faster-whisper transcription."
|
| 278 |
+
}
|
| 279 |
+
```
|
| 280 |
+
|
| 281 |
+
---
|
| 282 |
+
|
| 283 |
+
### `POST /api/start-deal-session` (placeholder)
|
| 284 |
+
|
| 285 |
+
**Response:**
|
| 286 |
+
|
| 287 |
+
```json
|
| 288 |
+
{
|
| 289 |
+
"status": "not_implemented",
|
| 290 |
+
"message": "Deal Battle endpoint is reserved and will be connected in a later phase."
|
| 291 |
+
}
|
| 292 |
+
```
|
| 293 |
+
|
| 294 |
+
---
|
| 295 |
+
|
| 296 |
+
### `POST /api/deck-critique` (placeholder)
|
| 297 |
+
|
| 298 |
+
**Response:**
|
| 299 |
+
|
| 300 |
+
```json
|
| 301 |
+
{
|
| 302 |
+
"status": "not_implemented",
|
| 303 |
+
"message": "Deck critique endpoint is reserved and will be connected after MiniCPM-V vision integration."
|
| 304 |
+
}
|
| 305 |
+
```
|
| 306 |
+
|
| 307 |
+
---
|
| 308 |
+
|
| 309 |
+
## 5. Endpoint Flow Diagrams
|
| 310 |
+
|
| 311 |
+
### Pitch Battle flow
|
| 312 |
+
|
| 313 |
+
```mermaid
|
| 314 |
+
flowchart TD
|
| 315 |
+
A[Frontend UI] --> B[/api/start-session]
|
| 316 |
+
B --> C[Session Manager]
|
| 317 |
+
C --> D[Persona Builder]
|
| 318 |
+
D --> E[Attack Tag Selector]
|
| 319 |
+
E --> F[Model Router]
|
| 320 |
+
F --> G[AI Response]
|
| 321 |
+
G --> A
|
| 322 |
+
```
|
| 323 |
+
|
| 324 |
+
### Scorecard flow
|
| 325 |
+
|
| 326 |
+
```mermaid
|
| 327 |
+
flowchart TD
|
| 328 |
+
A[Frontend UI] --> B[/api/end-battle]
|
| 329 |
+
B --> C[Session Manager]
|
| 330 |
+
C --> D[Scoring Engine]
|
| 331 |
+
D --> E[Model Router]
|
| 332 |
+
E --> F[JSON Parser]
|
| 333 |
+
F --> G[Scorecard Response]
|
| 334 |
+
G --> A
|
| 335 |
+
```
|
| 336 |
+
|
| 337 |
+
---
|
| 338 |
+
|
| 339 |
+
## 6. Why Swagger Shows Many Routes
|
| 340 |
+
|
| 341 |
+
Gradio Server is built on FastAPI. When the app launches, Gradio **automatically registers** internal endpoints for:
|
| 342 |
+
|
| 343 |
+
- App configuration (`/gradio_api/config`, `/gradio_api/info`)
|
| 344 |
+
- Request queueing (`/queue/join`, `/queue/data`)
|
| 345 |
+
- File uploads (`/upload`)
|
| 346 |
+
- Static assets and themes
|
| 347 |
+
- Streaming, login checks, and monitoring
|
| 348 |
+
|
| 349 |
+
These are **framework runtime routes**, not hand-written PitchFight product APIs. They exist so Gradio can host the app on Hugging Face Spaces with queuing, MCP, and ZeroGPU support.
|
| 350 |
+
|
| 351 |
+
**You do not call or maintain those routes manually.** The custom frontend uses only `/api/*` and `/health`.
|
| 352 |
+
|
| 353 |
+
---
|
| 354 |
+
|
| 355 |
+
## 7. Final API Contract — Quick Examples
|
| 356 |
+
|
| 357 |
+
### Load sample
|
| 358 |
+
|
| 359 |
+
```bash
|
| 360 |
+
curl -X POST http://127.0.0.1:7860/api/load-sample
|
| 361 |
+
```
|
| 362 |
+
|
| 363 |
+
### Start session
|
| 364 |
+
|
| 365 |
+
```bash
|
| 366 |
+
curl -X POST http://127.0.0.1:7860/api/start-session \
|
| 367 |
+
-H "Content-Type: application/json" \
|
| 368 |
+
-d '{"mode":"pitch_battle","persona":"hackathon_judge","difficulty":"high","input_mode":"text","model_mode":"premium_nvidia","startup":{"name":"Test","problem":"p","target_users":"u","solution":"s","why_ai":"a","competitors":"c","traction":"t","ask":"a"}}'
|
| 369 |
+
```
|
| 370 |
+
|
| 371 |
+
### Chat round
|
| 372 |
+
|
| 373 |
+
```bash
|
| 374 |
+
curl -X POST http://127.0.0.1:7860/api/chat-round \
|
| 375 |
+
-H "Content-Type: application/json" \
|
| 376 |
+
-d '{"session_id":"<uuid>","user_message":"We use AI for ranking."}'
|
| 377 |
+
```
|
| 378 |
+
|
| 379 |
+
### End battle
|
| 380 |
+
|
| 381 |
+
```bash
|
| 382 |
+
curl -X POST http://127.0.0.1:7860/api/end-battle \
|
| 383 |
+
-H "Content-Type: application/json" \
|
| 384 |
+
-d '{"session_id":"<uuid>"}'
|
| 385 |
+
```
|
| 386 |
+
|
| 387 |
+
### Reset session
|
| 388 |
+
|
| 389 |
+
```bash
|
| 390 |
+
curl -X POST http://127.0.0.1:7860/api/reset-session \
|
| 391 |
+
-H "Content-Type: application/json" \
|
| 392 |
+
-d '{"session_id":"<uuid>"}'
|
| 393 |
+
```
|
docs/CLAUDE_PROJECT_CONTEXT.md
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PitchFight AI — Claude Project Context
|
| 2 |
+
|
| 3 |
+
> **Read this file before starting any implementation phase.**
|
| 4 |
+
> Generated after full repository inspection on 2026-06-08.
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## 1. Project Summary
|
| 9 |
+
|
| 10 |
+
PitchFight AI is a voice-and-text AI sparring arena for student founders built for the Hugging Face **Build Small Hackathon** (Backyard AI track). The core use case: let founders get grilled by a realistic AI judge before they face real judges, mentors, investors, or sponsors. The AI does not give advice — it applies pressure, remembers prior answers, attacks vague claims, and scores the conversation.
|
| 11 |
+
|
| 12 |
+
The project runs on **Gradio Server** (not default Gradio UI), hosts a **custom HTML/CSS/JS frontend**, and exposes a clean **`/api/*` REST layer**. All AI model calls are strictly backend-only. The frontend never touches any model provider API.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 2. Current Strategy
|
| 17 |
+
|
| 18 |
+
| Decision | Value |
|
| 19 |
+
|---|---|
|
| 20 |
+
| Hackathon track | Backyard AI |
|
| 21 |
+
| Off-the-Grid | **Not targeted** — intentional |
|
| 22 |
+
| Model cap | ≤32B parameters (all models comply) |
|
| 23 |
+
| Default model mode | `premium_nvidia` |
|
| 24 |
+
| Primary judge model | NVIDIA Nemotron 3 Nano Omni 30B-A3B |
|
| 25 |
+
| Frontend model calls | **Never** — frontend calls `/api/*` only |
|
| 26 |
+
| API keys | Backend `.env` locally; HF Space Secrets in deployment |
|
| 27 |
+
| Target prizes | Backyard AI, Best Demo, Best Agent, Off-Brand, NVIDIA Nemotron Quest, OpenBMB Awards, Sharing is Caring, Field Notes, Tiny Titan |
|
| 28 |
+
| Current build status | Phase 1 complete (mock APIs running) |
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## 3. Current Model Stack
|
| 33 |
+
|
| 34 |
+
| Role | Model | Mode Key | Status |
|
| 35 |
+
|---|---|---|---|
|
| 36 |
+
| Primary judge (text, voice, scoring, rewrites) | `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` | `premium_nvidia` | **Live (Phase 2)** — client tested |
|
| 37 |
+
| OpenBMB omni mode | `openbmb/MiniCPM-o-4_5` | `openbmb_omni` | Stub — Phase 9 |
|
| 38 |
+
| Tiny fallback / Tiny Mode | `openbmb/MiniCPM5-1B` | `tiny_minicpm` | Stub — Phase 9 |
|
| 39 |
+
| Pitch deck critique | `openbmb/MiniCPM-V-4.6` | `vision_deck` | Stub — Phase 10 |
|
| 40 |
+
| Audio transcription fallback | `faster-whisper` (tiny or base) | `whisper_fallback` | Stub — Phase 7 |
|
| 41 |
+
|
| 42 |
+
> **Reasoning model note:** `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` uses tokens for an internal chain-of-thought before writing `message.content`. Confirmed token defaults: opponent=500, scorecard=2000, rewrite=800.
|
| 43 |
+
|
| 44 |
+
NVIDIA endpoint base URL: `https://integrate.api.nvidia.com/v1`
|
| 45 |
+
|
| 46 |
+
All model calls must go through `core/model_router.py` → individual client files. No model provider URL or key must ever appear in frontend code.
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## 4. Backend Architecture
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
app.py ← Gradio Server entrypoint
|
| 54 |
+
/health ← health check
|
| 55 |
+
/ ← serves frontend/index.html
|
| 56 |
+
/api/load-sample ← REST endpoint
|
| 57 |
+
/api/start-session ← REST endpoint
|
| 58 |
+
/api/chat-round ← REST endpoint
|
| 59 |
+
/api/end-battle ← REST endpoint
|
| 60 |
+
/api/reset-session ← REST endpoint
|
| 61 |
+
/api/voice-pitch ← placeholder (Phase 7)
|
| 62 |
+
/api/start-deal-session ← placeholder (Phase 8)
|
| 63 |
+
/api/deck-critique ← placeholder (Phase 10)
|
| 64 |
+
@app.api wrappers ← same handlers, Gradio client compat
|
| 65 |
+
|
| 66 |
+
core/api_handlers.py ← shared handler logic (REST + Gradio route)
|
| 67 |
+
core/session_manager.py ← in-memory session store (SESSIONS dict)
|
| 68 |
+
core/persona_builder.py ← builds system prompt per persona + startup
|
| 69 |
+
core/attack_tags.py ← attack tag taxonomy, round-based selection
|
| 70 |
+
core/scoring_engine.py ← mock scorecard (Phase 1); real model call planned Phase 5
|
| 71 |
+
core/feedback_generator.py ← mock rewrite logic (Phase 1); model call planned Phase 5
|
| 72 |
+
core/json_utils.py ← JSON extraction + safe_json_parse + fallback_scorecard()
|
| 73 |
+
core/local_text_model.py ← stub placeholder (Phase 1 only)
|
| 74 |
+
core/voice_transcriber.py ← stub placeholder (Phase 7)
|
| 75 |
+
core/samples.py ← get_sample_startup() (EventRadar AI)
|
| 76 |
+
|
| 77 |
+
config/personas.json ← persona metadata (ids, names, focus areas)
|
| 78 |
+
config/attack_tags.json ← attack tag reference (not yet used in code — code uses attack_tags.py)
|
| 79 |
+
config/pitch_rubric.json ← 6 dimensions + weights
|
| 80 |
+
config/sample_startups.json ← sample startup data
|
| 81 |
+
|
| 82 |
+
ADDED in Phase 2:
|
| 83 |
+
model_router.py ← routes tasks; premium_nvidia live; others stub
|
| 84 |
+
nvidia_client.py ← NVIDIA Nemotron client; live, tested
|
| 85 |
+
minicpm_client.py ← health_check stub (Phase 9)
|
| 86 |
+
vision_client.py ← health_check stub (Phase 10)
|
| 87 |
+
transcription_client.py ← health_check stub (Phase 7)
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
## 5. Frontend Architecture
|
| 93 |
+
|
| 94 |
+
File: `frontend/script.js`
|
| 95 |
+
|
| 96 |
+
The frontend uses **`fetch()`** calls exclusively to `/api/*` backend routes. No model provider URLs or API keys exist in any frontend file. This is verified and correct.
|
| 97 |
+
|
| 98 |
+
Key functions:
|
| 99 |
+
- `apiPost(path, body)` — all API calls go through this helper
|
| 100 |
+
- `loadSample()` → `POST /api/load-sample`
|
| 101 |
+
- `startSession()` → `POST /api/start-session` (sends `model_mode: "premium_nvidia"`)
|
| 102 |
+
- `sendMessage()` → `POST /api/chat-round`
|
| 103 |
+
- `endBattle()` → `POST /api/end-battle`
|
| 104 |
+
- `resetBattle()` → `POST /api/reset-session`
|
| 105 |
+
- `renderScorecard(data)` — renders overall score, bars, rewrites, questions
|
| 106 |
+
|
| 107 |
+
The `boot()` function calls `GET /health` on page load for backend availability check.
|
| 108 |
+
|
| 109 |
+
Static assets served at `/frontend/*` via Gradio StaticFiles mount.
|
| 110 |
+
|
| 111 |
+
---
|
| 112 |
+
|
| 113 |
+
## 6. Real Product API Endpoints
|
| 114 |
+
|
| 115 |
+
These are the only endpoints that matter for PitchFight as a product. Ignore Gradio-internal Swagger routes.
|
| 116 |
+
|
| 117 |
+
| Method | Path | Status | Phase |
|
| 118 |
+
|---|---|---|---|
|
| 119 |
+
| GET | `/health` | Live (mock-free) | Phase 1 |
|
| 120 |
+
| GET | `/` | Live | Phase 1 |
|
| 121 |
+
| POST | `/api/load-sample` | Live (real config data) | Phase 1 |
|
| 122 |
+
| POST | `/api/start-session` | Live (mock AI response) | Phase 1 |
|
| 123 |
+
| POST | `/api/chat-round` | Live (mock AI response) | Phase 1 |
|
| 124 |
+
| POST | `/api/end-battle` | Live (mock scorecard) | Phase 1 |
|
| 125 |
+
| POST | `/api/reset-session` | Live | Phase 1 |
|
| 126 |
+
| POST | `/api/voice-pitch` | Placeholder 501 | Phase 7 |
|
| 127 |
+
| POST | `/api/start-deal-session` | Placeholder 501 | Phase 8 |
|
| 128 |
+
| POST | `/api/deck-critique` | Placeholder 501 | Phase 10 |
|
| 129 |
+
|
| 130 |
+
Gradio internal routes (`/gradio_api/*`, `/queue/*`, `/upload`, `/static/*`) are framework runtime routes registered automatically. **Do not call or maintain them as product APIs.**
|
| 131 |
+
|
| 132 |
+
---
|
| 133 |
+
|
| 134 |
+
## 7. Existing File Structure
|
| 135 |
+
|
| 136 |
+
```
|
| 137 |
+
Pitchfight/
|
| 138 |
+
├── app.py ← Gradio Server + REST endpoints
|
| 139 |
+
├── .env.example ← env template (complete, correct)
|
| 140 |
+
├── .gitignore ← must include .env
|
| 141 |
+
├── README.md ← HF Spaces metadata + project overview
|
| 142 |
+
├── core/
|
| 143 |
+
│ ├── __init__.py
|
| 144 |
+
│ ├── api_handlers.py ← shared handler logic
|
| 145 |
+
│ ├── attack_tags.py ← tag taxonomy + selector
|
| 146 |
+
│ ├── feedback_generator.py ← mock rewrite (Phase 1)
|
| 147 |
+
│ ├── json_utils.py ← JSON parse + fallback
|
| 148 |
+
│ ├── local_text_model.py ← stub (Phase 1)
|
| 149 |
+
│ ├── persona_builder.py ← system prompt builder
|
| 150 |
+
│ ├── samples.py ← EventRadar AI sample
|
| 151 |
+
│ ├── scoring_engine.py ← mock scorecard (Phase 1)
|
| 152 |
+
│ ├── session_manager.py ← in-memory sessions
|
| 153 |
+
│ └── voice_transcriber.py ← stub (Phase 1)
|
| 154 |
+
├── config/
|
| 155 |
+
│ ├── attack_tags.json ← reference (code uses attack_tags.py)
|
| 156 |
+
│ ├── personas.json ← persona metadata
|
| 157 |
+
│ ├── pitch_rubric.json ← 6-dimension rubric weights
|
| 158 |
+
│ └── sample_startups.json ← sample startup data
|
| 159 |
+
├── frontend/
|
| 160 |
+
│ ├── index.html
|
| 161 |
+
│ ├── script.js ← fetch-only, /api/* calls
|
| 162 |
+
│ ├── styles.css
|
| 163 |
+
│ └── assets/
|
| 164 |
+
└── docs/
|
| 165 |
+
├── BACKEND_API.md ← endpoint reference
|
| 166 |
+
├── CLAUDE_PROJECT_CONTEXT.md ← this file
|
| 167 |
+
├── DEMO_NOTES.md ← demo flow + prize talking points
|
| 168 |
+
├── DOCUMENTATION.md ← full project documentation
|
| 169 |
+
├── FIELD_NOTES.md ← build log
|
| 170 |
+
├── MODELS_FINAL.md ← model strategy
|
| 171 |
+
├── PHASE_WISE_PLAN.md ← 14-phase build plan
|
| 172 |
+
├── PROMPTS.md ← prompt templates
|
| 173 |
+
└── TASK_TRACKER.md ← phase + task status
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
**Missing from `core/` (needed Phase 2+):**
|
| 177 |
+
- `model_router.py`
|
| 178 |
+
- `nvidia_client.py`
|
| 179 |
+
- `minicpm_client.py`
|
| 180 |
+
- `vision_client.py`
|
| 181 |
+
- `transcription_client.py`
|
| 182 |
+
|
| 183 |
+
---
|
| 184 |
+
|
| 185 |
+
## 8. Current Implementation Status
|
| 186 |
+
|
| 187 |
+
### Working (Phase 1)
|
| 188 |
+
- Gradio Server app starts and serves custom frontend
|
| 189 |
+
- GET `/health` returns version + status
|
| 190 |
+
- GET `/` serves `frontend/index.html`
|
| 191 |
+
- POST `/api/load-sample` returns EventRadar AI startup dict from `samples.py`
|
| 192 |
+
- POST `/api/start-session` creates in-memory session, returns mock opening question keyed by persona
|
| 193 |
+
- POST `/api/chat-round` increments round, rotates attack tags, returns hardcoded mock follow-up per persona
|
| 194 |
+
- POST `/api/end-battle` returns mock scorecard with static scores, real user quotes from session history
|
| 195 |
+
- POST `/api/reset-session` deletes session from SESSIONS dict
|
| 196 |
+
- Frontend flow: landing → setup (load sample or enter custom) → battle arena → scorecard
|
| 197 |
+
- Error banners and loading overlay functional in frontend
|
| 198 |
+
- Health check on boot functional
|
| 199 |
+
|
| 200 |
+
### Mock / Placeholder
|
| 201 |
+
- All AI opponent messages in `api_handlers.py` — hardcoded string lists per persona
|
| 202 |
+
- All scorecard scores — hardcoded integers (overall: 68, clarity: 64, etc.)
|
| 203 |
+
- `feedback_generator.py` — template string rewrites, no model call
|
| 204 |
+
- `local_text_model.py` — stub, returns placeholder string
|
| 205 |
+
- `voice_transcriber.py` — stub, returns not_loaded status
|
| 206 |
+
- `/api/voice-pitch` — returns `{"status": "not_implemented"}`
|
| 207 |
+
- `/api/start-deal-session` — returns `{"status": "not_implemented"}`
|
| 208 |
+
- `/api/deck-critique` — returns `{"status": "not_implemented"}`
|
| 209 |
+
|
| 210 |
+
### Missing (Phase 2+)
|
| 211 |
+
- `core/model_router.py` — central routing logic
|
| 212 |
+
- `core/nvidia_client.py` — NVIDIA Nemotron API calls
|
| 213 |
+
- `core/minicpm_client.py` — MiniCPM-o and MiniCPM5-1B API/local calls
|
| 214 |
+
- `core/vision_client.py` — MiniCPM-V 4.6 image/vision calls
|
| 215 |
+
- `core/transcription_client.py` — faster-whisper audio fallback
|
| 216 |
+
- Real AI response in `handle_chat_round` (replace hardcoded followups)
|
| 217 |
+
- Real scoring in `handle_end_battle` (replace `mock_scorecard`)
|
| 218 |
+
- Real rewrite in `feedback_generator` (replace template strings)
|
| 219 |
+
- Voice audio handling in `/api/voice-pitch`
|
| 220 |
+
- Deal battle logic in `/api/start-deal-session`
|
| 221 |
+
- Image upload and critique in `/api/deck-critique`
|
| 222 |
+
|
| 223 |
+
---
|
| 224 |
+
|
| 225 |
+
## 9. Mock vs Real Components
|
| 226 |
+
|
| 227 |
+
| Component | Mock or Real | Notes |
|
| 228 |
+
|---|---|---|
|
| 229 |
+
| Session management | **Real** | In-memory, uuid-based, correct |
|
| 230 |
+
| Persona system prompt builder | **Real** | `persona_builder.py` builds full system prompt |
|
| 231 |
+
| Attack tag rotator | **Real** | `attack_tags.py` rotates by round index |
|
| 232 |
+
| Sample startup data | **Real** | EventRadar AI data loaded from `samples.py` |
|
| 233 |
+
| Config JSON files | **Real** | personas, rubric, tags all present |
|
| 234 |
+
| JSON utils | **Real** | Extraction + safe parse + fallback all coded |
|
| 235 |
+
| `json_utils.fallback_scorecard()` | **Real** | Safe fallback ready |
|
| 236 |
+
| Opening AI question | **Mock** | Hardcoded per persona in `api_handlers.py` |
|
| 237 |
+
| Follow-up AI questions | **Mock** | Hardcoded list per persona, index by round |
|
| 238 |
+
| Scorecard scores | **Mock** | All integers hardcoded in `scoring_engine.mock_scorecard()` |
|
| 239 |
+
| Answer rewrite | **Mock** | Template string in `feedback_generator.py` |
|
| 240 |
+
| Pitch rewrite | **Mock** | Template string in `feedback_generator.py` |
|
| 241 |
+
| NVIDIA API client | **Real** | `core/nvidia_client.py` — live, tested Phase 2 |
|
| 242 |
+
| Model router | **Real** | `core/model_router.py` — premium_nvidia live; others stub |
|
| 243 |
+
| MiniCPM clients | **Stub** | `core/minicpm_client.py` — health_check only, Phase 9 |
|
| 244 |
+
| Vision client | **Stub** | `core/vision_client.py` — health_check only, Phase 10 |
|
| 245 |
+
| Whisper transcription | **Stub** | `core/transcription_client.py` — health_check only, Phase 7 |
|
| 246 |
+
|
| 247 |
+
---
|
| 248 |
+
|
| 249 |
+
## 10. Next Recommended Phase
|
| 250 |
+
|
| 251 |
+
**Phase 3: Wire model_router into battle flow**
|
| 252 |
+
|
| 253 |
+
Phase 2 is complete. NVIDIA Nemotron is live and tested. Next step is connecting `model_router.generate_opponent_response()` to the actual battle endpoints.
|
| 254 |
+
|
| 255 |
+
Goals:
|
| 256 |
+
1. In `core/api_handlers.py`: import `model_router` and `persona_builder`
|
| 257 |
+
2. In `handle_start_session`: build system prompt, call `model_router.generate_opponent_response()`, fall back to mock if `ok=False`
|
| 258 |
+
3. In `handle_chat_round`: build full message history, call `model_router.generate_opponent_response()`, fall back to mock if `ok=False`
|
| 259 |
+
4. End-to-end test: full battle with live Nemotron responses
|
| 260 |
+
5. Update docs
|
| 261 |
+
|
| 262 |
+
Do **not** connect voice, deal battle, scoring, or deck critique in Phase 3.
|
| 263 |
+
|
| 264 |
+
---
|
| 265 |
+
|
| 266 |
+
## 11. Rules for Future Claude Code Work
|
| 267 |
+
|
| 268 |
+
1. **Always read docs before implementing a phase.** Read `PHASE_WISE_PLAN.md`, this file, and `BACKEND_API.md` before touching code.
|
| 269 |
+
2. **Never expose API keys in frontend.** No env reads, no hardcoded keys, no fetch calls to model provider URLs from frontend JS.
|
| 270 |
+
3. **Never commit `.env`.** `.gitignore` must include `.env`. Only `.env.example` (empty values) goes to repo.
|
| 271 |
+
4. **Keep frontend calls limited to `/api/*`.** The only external call from frontend is to `/health`. All AI calls go through backend `/api/*` routes.
|
| 272 |
+
5. **Keep model calls backend-only.** All `openai.Client`, `requests.post`, or SDK calls to NVIDIA/OpenBMB must be in `core/` files only.
|
| 273 |
+
6. **After each phase, update `docs/PHASE_WISE_PLAN.md`** with a status line marking the phase complete.
|
| 274 |
+
7. **After each phase, update `docs/FIELD_NOTES.md`** with what changed, what worked, and what was learned.
|
| 275 |
+
8. **After each phase, update `docs/BACKEND_API.md`** if any endpoint changed signature, status, or behavior.
|
| 276 |
+
9. **After each phase, update `docs/MODELS_FINAL.md`** if the model strategy or routing changed.
|
| 277 |
+
10. **Do not silently change architecture.** Any change to the API contract, session model, or model routing must be documented before implementation.
|
| 278 |
+
11. **Preserve the working mock flow while replacing parts incrementally.** Never delete a working mock endpoint before its replacement is tested. The mock is the safety net.
|
| 279 |
+
12. **Use `os.getenv()` only** for API key reads. Never use hardcoded fallback values for secrets. If the key is missing, log a warning and return a clear error — do not silently continue.
|
| 280 |
+
13. **Do not add features outside the current phase scope.** One phase at a time. Phase 2 is model router only. Phase 3 is NVIDIA text only. Do not reach forward.
|
| 281 |
+
14. **Test each model client independently** before wiring it into the session/handler flow.
|
| 282 |
+
15. **`core/json_utils.py` already has `safe_json_parse` and `fallback_scorecard`.** Use them — do not rewrite JSON parsing logic elsewhere.
|
| 283 |
+
|
| 284 |
+
---
|
| 285 |
+
|
| 286 |
+
## 12. Documentation Update Rules
|
| 287 |
+
|
| 288 |
+
After every phase:
|
| 289 |
+
|
| 290 |
+
| Doc | What to update |
|
| 291 |
+
|---|---|
|
| 292 |
+
| `docs/PHASE_WISE_PLAN.md` | Mark phase status as Complete. Add date. |
|
| 293 |
+
| `docs/FIELD_NOTES.md` | Add a build log entry: what changed, what worked, what surprised you. |
|
| 294 |
+
| `docs/BACKEND_API.md` | Update endpoint status column if a placeholder became real. |
|
| 295 |
+
| `docs/MODELS_FINAL.md` | Update model-to-file mapping if new client files were created or routes changed. |
|
| 296 |
+
| `docs/TASK_TRACKER.md` | Move completed tasks to done; update current phase and next tasks. |
|
| 297 |
+
| `docs/CLAUDE_PROJECT_CONTEXT.md` | Update "Current Implementation Status" and "Mock vs Real Components" sections. |
|
| 298 |
+
|
| 299 |
+
Do **not** update docs prophylactically for future phases. Only document what is actually implemented.
|
docs/DEMO_NOTES.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PitchFight AI — Demo Notes
|
| 2 |
+
|
| 3 |
+
## Strategic Demo Positioning
|
| 4 |
+
|
| 5 |
+
PitchFight AI is a **high-demo sponsor-model build** — not Off-the-Grid. Lead with voice, pressure, and scorecard quality. Mention NVIDIA Nemotron Omni and OpenBMB MiniCPM modes as sponsor-aligned small models under 32B.
|
| 6 |
+
|
| 7 |
+
## Full Demo Flow (Target Build)
|
| 8 |
+
|
| 9 |
+
1. Open the app — custom battle arena UI (not default Gradio).
|
| 10 |
+
2. Show **model mode badge** (Premium Nemotron / OpenBMB Omni / Tiny MiniCPM).
|
| 11 |
+
3. **Text path:** Load EventRadar AI → Hackathon Judge → Enter Arena → get grilled → End Battle → scorecard.
|
| 12 |
+
4. **Voice path:** Record spoken pitch → Nemotron interprets → first hard question → continue battle.
|
| 13 |
+
5. **Deal Battle:** Sponsorship or salary negotiation scenario → deal-specific scorecard.
|
| 14 |
+
6. **Deck critique (if built):** Upload slide screenshot → 3 judge questions.
|
| 15 |
+
7. **Retry:** Retry weakest question → show improvement.
|
| 16 |
+
|
| 17 |
+
## Phase 1 Demo Flow (Current)
|
| 18 |
+
|
| 19 |
+
1. Open the app.
|
| 20 |
+
2. Click **Load Demo Startup** (EventRadar AI).
|
| 21 |
+
3. Select **Hackathon Judge**.
|
| 22 |
+
4. Click **Enter the Arena**.
|
| 23 |
+
5. Answer weakly (e.g., "We use AI to rank events better").
|
| 24 |
+
6. Watch mock AI push back with attack tag + pressure level.
|
| 25 |
+
7. Click **End Battle** after 2–3 rounds.
|
| 26 |
+
8. Show scorecard: overall, bars, rewrites, top prep questions.
|
| 27 |
+
|
| 28 |
+
## Talking Points
|
| 29 |
+
|
| 30 |
+
- Custom Gradio Server frontend + `@app.api` backend.
|
| 31 |
+
- All model calls backend-only; keys in HF Space Secrets.
|
| 32 |
+
- ≤32B models: Nemotron Omni, MiniCPM-o, MiniCPM5-1B, MiniCPM-V.
|
| 33 |
+
- Voice + multimodal judging as differentiator.
|
| 34 |
+
- Rubric-based scorecard with quotes and rewrites.
|
| 35 |
+
- Off-the-Grid **not** claimed — demo quality is the goal.
|
| 36 |
+
|
| 37 |
+
## Prize Alignment Talking Points
|
| 38 |
+
|
| 39 |
+
| Prize | Hook |
|
| 40 |
+
|---|---|
|
| 41 |
+
| Backyard AI | Student founders, real pressure practice |
|
| 42 |
+
| Best Demo | Voice pitch → grill → scorecard in one flow |
|
| 43 |
+
| Best Agent | Persona + memory + attack tags + scoring loop |
|
| 44 |
+
| Off-Brand | Custom HTML/CSS/JS arena UI |
|
| 45 |
+
| NVIDIA Nemotron Quest | Premium voice/multimodal judge |
|
| 46 |
+
| OpenBMB Awards | MiniCPM-o / 5-1B / V modes |
|
| 47 |
+
| Sharing is Caring | Public prompts, rubrics, attack tags |
|
| 48 |
+
| Field Notes | Build story post-deployment |
|
docs/DOCUMENTATION.md
ADDED
|
@@ -0,0 +1,1447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ⚔️ PitchFight AI — Final Hackathon-Aligned Documentation
|
| 2 |
+
|
| 3 |
+
## A Voice-and-Text AI Sparring Arena for Student Founders (≤32B Models)
|
| 4 |
+
|
| 5 |
+
> **Your first tough pitch should not be in front of a real judge.**
|
| 6 |
+
> **Normal AI assistants give advice. PitchFight AI creates pressure.**
|
| 7 |
+
|
| 8 |
+
### Strategy Update (Current Build)
|
| 9 |
+
|
| 10 |
+
This documentation reflects the **high-demo sponsor-model strategy**. PitchFight AI prioritizes demo strength, model quality, and sponsor alignment (NVIDIA Nemotron Omni, OpenBMB MiniCPM) while staying under the **≤32B** Build Small Hackathon rule.
|
| 11 |
+
|
| 12 |
+
> **Off-the-Grid is not targeted.** Premium default model mode is **NVIDIA Nemotron Omni** (backend-only). API keys live in `.env` locally / HF Space Secrets in deployment — never in the frontend.
|
| 13 |
+
|
| 14 |
+
See also: [`PHASE_WISE_PLAN.md`](PHASE_WISE_PLAN.md) · [`MODELS_FINAL.md`](MODELS_FINAL.md) · [`BACKEND_API.md`](BACKEND_API.md)
|
| 15 |
+
|
| 16 |
+
PitchFight AI exposes **clean custom project APIs under `/api/...`**. Gradio internal routes may appear in OpenAPI/Swagger, but they are **framework runtime routes**, not product endpoints.
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## 1. Project Overview
|
| 21 |
+
|
| 22 |
+
**PitchFight AI** is a Hugging Face Spaces + Gradio Server application that helps student founders practice high-pressure startup pitching before facing real judges, mentors, investors, sponsors, recruiters, or clients.
|
| 23 |
+
|
| 24 |
+
The project is built for the **Backyard AI** track of the Build Small Hackathon. The target user is intentionally narrow:
|
| 25 |
+
|
| 26 |
+
> **Student founders and hackathon builders who know their project, but freeze when someone asks hard questions.**
|
| 27 |
+
|
| 28 |
+
PitchFight AI turns pitch practice into a realistic AI sparring arena. The AI does not behave like a generic assistant. It behaves like a skeptical counterpart, asks tough follow-up questions, remembers previous answers, detects weak claims, scores the conversation, and rewrites the weakest answer into a stronger response.
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## 2. Hackathon Rule Alignment
|
| 33 |
+
|
| 34 |
+
This section is intentionally explicit so the project does not violate the hackathon constraints.
|
| 35 |
+
|
| 36 |
+
### Required Rules
|
| 37 |
+
|
| 38 |
+
| Rule | How PitchFight AI Complies |
|
| 39 |
+
|---|---|
|
| 40 |
+
| **Small Models Only** | All models stay under the 32B parameter cap: Nemotron 3 Nano Omni (~30B), MiniCPM-o, MiniCPM5-1B, MiniCPM-V 4.6. |
|
| 41 |
+
| **Built on Gradio** | Gradio Server app hosted as a Hugging Face Space with custom HTML/CSS/JS frontend. |
|
| 42 |
+
| **Show, Don’t Tell** | Demo-first: pitch battle, voice mode, deal battle, scorecard — runnable in the Space. |
|
| 43 |
+
| **No Frontend Model Calls** | Browser calls only `/api/...` endpoints via `fetch()`; model APIs are backend-only. |
|
| 44 |
+
|
| 45 |
+
### Sponsor-Model / High-Demo Strategy
|
| 46 |
+
|
| 47 |
+
The **default submitted build** prioritizes the strongest demonstrative experience:
|
| 48 |
+
|
| 49 |
+
- **NVIDIA Nemotron Omni** — primary premium judge (text, voice, multimodal, scoring).
|
| 50 |
+
- **OpenBMB MiniCPM** — MiniCPM-o (omni), MiniCPM5-1B (tiny/fallback), MiniCPM-V 4.6 (deck critique).
|
| 51 |
+
- **faster-whisper** — backup local transcription when Omni voice path is unavailable.
|
| 52 |
+
- **No browser-side model API calls** — all keys in HF Space Secrets / backend `.env`.
|
| 53 |
+
- **No OpenAI or unrelated cloud APIs** in the default flow.
|
| 54 |
+
|
| 55 |
+
### Off-the-Grid Position
|
| 56 |
+
|
| 57 |
+
> **Off-the-Grid is intentionally not targeted.** Sponsor/model APIs are used for demo quality. The project instead targets Backyard AI, Best Demo, Best Agent, Off-Brand, NVIDIA Nemotron Quest, OpenBMB Awards, Sharing is Caring, and Field Notes.
|
| 58 |
+
|
| 59 |
+
### Final Compliance Position
|
| 60 |
+
|
| 61 |
+
> **PitchFight AI follows hackathon core rules: ≤32B models, Gradio/HF Spaces, demo-first execution, backend-only inference with secrets managed server-side.**
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
## 3. Core Problem
|
| 66 |
+
|
| 67 |
+
Most student founders prepare their pitch by:
|
| 68 |
+
|
| 69 |
+
- Reading pitch tips online
|
| 70 |
+
- Practicing alone
|
| 71 |
+
- Showing their idea to friends who say “sounds good”
|
| 72 |
+
- Making slides but not preparing for hard Q&A
|
| 73 |
+
|
| 74 |
+
The first real pressure often happens during judging, mentoring, investor review, or sponsorship calls. That is exactly when many students freeze.
|
| 75 |
+
|
| 76 |
+
### Gap
|
| 77 |
+
|
| 78 |
+
There is no easy, low-cost, always-available sparring partner that can simulate:
|
| 79 |
+
|
| 80 |
+
- A skeptical hackathon judge
|
| 81 |
+
- A technical evaluator
|
| 82 |
+
- A hard investor
|
| 83 |
+
- A sponsor asking for ROI
|
| 84 |
+
- A recruiter or client pushing back
|
| 85 |
+
|
| 86 |
+
### PitchFight AI’s Solution
|
| 87 |
+
|
| 88 |
+
PitchFight AI creates a **pressure simulation loop**:
|
| 89 |
+
|
| 90 |
+
1. User enters startup/project context.
|
| 91 |
+
2. User chooses an AI opponent.
|
| 92 |
+
3. AI opponent asks one sharp question at a time.
|
| 93 |
+
4. User responds under pressure.
|
| 94 |
+
5. AI follows up on weak or vague answers.
|
| 95 |
+
6. App generates a scorecard and improved answer.
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
## 4. Final Product Scope
|
| 100 |
+
|
| 101 |
+
### Primary Mode: Pitch Battle
|
| 102 |
+
|
| 103 |
+
The user defends their startup or hackathon project against a persona-locked AI opponent.
|
| 104 |
+
|
| 105 |
+
#### Personas
|
| 106 |
+
|
| 107 |
+
| Persona | Role | What They Attack |
|
| 108 |
+
|---|---|---|
|
| 109 |
+
| **Skeptical VC** | Investor-style evaluator | Market, moat, revenue, defensibility, retention |
|
| 110 |
+
| **Technical Judge** | Engineering evaluator | AI necessity, architecture, scalability, feasibility |
|
| 111 |
+
| **Hackathon Judge** | Competition evaluator | Novelty, demo clarity, MVP strength, real-world usefulness |
|
| 112 |
+
|
| 113 |
+
### Secondary Mode: Deal Battle
|
| 114 |
+
|
| 115 |
+
The user practices negotiation scenarios useful for student founders and early-career builders.
|
| 116 |
+
|
| 117 |
+
#### Scenarios
|
| 118 |
+
|
| 119 |
+
| Scenario | Counterpart |
|
| 120 |
+
|---|---|
|
| 121 |
+
| Startup Sponsorship Ask | Tough sponsor |
|
| 122 |
+
| Internship Salary Negotiation | Strict HR recruiter |
|
| 123 |
+
| Freelance Client Pricing | Budget-conscious client |
|
| 124 |
+
| Equity Negotiation | Investor negotiating hard |
|
| 125 |
+
|
| 126 |
+
### Voice Mode
|
| 127 |
+
|
| 128 |
+
Voice Mode allows the user to speak their pitch instead of typing it.
|
| 129 |
+
|
| 130 |
+
For the MVP, Voice Mode is intentionally simple and reliable:
|
| 131 |
+
|
| 132 |
+
```text
|
| 133 |
+
User records pitch audio
|
| 134 |
+
↓
|
| 135 |
+
Backend /api/voice-pitch (frontend never calls model APIs)
|
| 136 |
+
↓
|
| 137 |
+
Primary: NVIDIA Nemotron Omni interprets audio + asks first hard question
|
| 138 |
+
↓
|
| 139 |
+
Fallback: faster-whisper transcribes → Nemotron or MiniCPM5-1B text path
|
| 140 |
+
↓
|
| 141 |
+
User continues by text or voice
|
| 142 |
+
↓
|
| 143 |
+
Scorecard includes voice-derived structure and conciseness signals
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
Founders normally pitch out loud; the premium voice path uses Nemotron Omni backend-only, with faster-whisper as transcription fallback only.
|
| 147 |
+
|
| 148 |
+
---
|
| 149 |
+
|
| 150 |
+
## 5. What Makes This a Strong Hackathon Project
|
| 151 |
+
|
| 152 |
+
PitchFight AI is designed around the hackathon’s judging taste: runnable, specific, useful, delightful, and more than a chatbot wrapper.
|
| 153 |
+
|
| 154 |
+
### Runnable Gradio Space
|
| 155 |
+
|
| 156 |
+
The final app is deployed as a Hugging Face Space using Gradio.
|
| 157 |
+
|
| 158 |
+
### Clear Purpose
|
| 159 |
+
|
| 160 |
+
The app has one clear mission:
|
| 161 |
+
|
| 162 |
+
> Help student founders survive tough pitch and negotiation conversations before the real moment.
|
| 163 |
+
|
| 164 |
+
### Useful + Delightful
|
| 165 |
+
|
| 166 |
+
Useful: improves pitch readiness.
|
| 167 |
+
Delightful: feels like entering a battle arena instead of filling a boring form.
|
| 168 |
+
|
| 169 |
+
### More Than a Chatbot Wrapper
|
| 170 |
+
|
| 171 |
+
PitchFight AI includes:
|
| 172 |
+
|
| 173 |
+
- Startup context intake
|
| 174 |
+
- Persona selection
|
| 175 |
+
- Round-based battle flow
|
| 176 |
+
- Pressure level
|
| 177 |
+
- Attack tags
|
| 178 |
+
- Conversation memory
|
| 179 |
+
- Voice pitch mode
|
| 180 |
+
- Rubric-based scoring
|
| 181 |
+
- Weakest answer detection
|
| 182 |
+
- Improved answer rewrite
|
| 183 |
+
- Final pitch rewrite
|
| 184 |
+
- Scorecard screen
|
| 185 |
+
|
| 186 |
+
### Small Model Showcase
|
| 187 |
+
|
| 188 |
+
The project uses compact models strategically rather than relying on one huge model. The intelligence comes from:
|
| 189 |
+
|
| 190 |
+
- Strong persona prompts
|
| 191 |
+
- Memory
|
| 192 |
+
- Role consistency
|
| 193 |
+
- Attack-tag routing
|
| 194 |
+
- Rubric scoring
|
| 195 |
+
- faster-whisper transcription fallback (when Omni voice path is unavailable)
|
| 196 |
+
- Feedback generation
|
| 197 |
+
|
| 198 |
+
---
|
| 199 |
+
|
| 200 |
+
## 6. Final System Architecture
|
| 201 |
+
|
| 202 |
+
```mermaid
|
| 203 |
+
flowchart TD
|
| 204 |
+
A[Student Founder] --> B[Custom Web UI — fetch to /api/* only]
|
| 205 |
+
|
| 206 |
+
B --> C{Input Mode}
|
| 207 |
+
C -->|Text Pitch| D[Startup Context Form]
|
| 208 |
+
C -->|Voice Pitch| E[Browser Audio Recorder]
|
| 209 |
+
|
| 210 |
+
D --> H[Session Manager]
|
| 211 |
+
E --> VP[/api/voice-pitch]
|
| 212 |
+
VP --> F[NVIDIA Nemotron Omni — primary voice path]
|
| 213 |
+
VP --> W[faster-whisper — transcription fallback only]
|
| 214 |
+
F --> H
|
| 215 |
+
W --> H
|
| 216 |
+
|
| 217 |
+
H --> I[Persona Prompt Builder]
|
| 218 |
+
I --> J[Attack Tag Selector]
|
| 219 |
+
J --> K[Battle Engine]
|
| 220 |
+
|
| 221 |
+
K --> MR[Model Router]
|
| 222 |
+
MR --> L1[NVIDIA Nemotron Omni — default premium judge]
|
| 223 |
+
MR --> L2[MiniCPM-o 4.5 — OpenBMB omni mode]
|
| 224 |
+
MR --> L3[MiniCPM5-1B — tiny / API-failure fallback]
|
| 225 |
+
MR --> L4[MiniCPM-V 4.6 — deck critique]
|
| 226 |
+
L1 --> M[AI Opponent Response]
|
| 227 |
+
L2 --> M
|
| 228 |
+
L3 --> M
|
| 229 |
+
M --> B
|
| 230 |
+
|
| 231 |
+
H --> N[Scoring Engine]
|
| 232 |
+
N --> MR
|
| 233 |
+
MR --> O[Structured Scorecard JSON]
|
| 234 |
+
O --> P[JSON Parser + Fallback Handler]
|
| 235 |
+
P --> Q[Feedback Generator]
|
| 236 |
+
Q --> R[Scorecard + Improved Answer]
|
| 237 |
+
R --> B
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
### Simple Explanation
|
| 241 |
+
|
| 242 |
+
- The **custom frontend** handles the arena UI and calls only **`/api/...`** endpoints — never model provider APIs.
|
| 243 |
+
- **Gradio Server** exposes backend APIs and serves the custom frontend.
|
| 244 |
+
- The **Session Manager** stores startup details, chosen persona, round count, attack tags, and conversation history.
|
| 245 |
+
- The **Persona Prompt Builder** creates the opponent behavior.
|
| 246 |
+
- The **Attack Tag Selector** decides what kind of pressure the AI should apply next.
|
| 247 |
+
- The **Model Router** routes inference to **NVIDIA Nemotron Omni** by default, with OpenBMB MiniCPM modes and MiniCPM5-1B fallback.
|
| 248 |
+
- **faster-whisper** is backup transcription only when the Omni voice path is unavailable.
|
| 249 |
+
- API keys live in **HF Space Secrets** / backend `.env` only — never in frontend code.
|
| 250 |
+
- The **Scorecard UI** shows what went well, what failed, and how to improve.
|
| 251 |
+
|
| 252 |
+
---
|
| 253 |
+
|
| 254 |
+
## 7. User Workflow Diagram
|
| 255 |
+
|
| 256 |
+
```mermaid
|
| 257 |
+
flowchart LR
|
| 258 |
+
A[Open PitchFight AI] --> B[Choose Mode]
|
| 259 |
+
B --> C[Pitch Battle]
|
| 260 |
+
B --> D[Deal Battle]
|
| 261 |
+
|
| 262 |
+
C --> E[Enter Startup Context]
|
| 263 |
+
D --> F[Enter Negotiation Context]
|
| 264 |
+
|
| 265 |
+
E --> G[Choose Opponent]
|
| 266 |
+
F --> G
|
| 267 |
+
|
| 268 |
+
G --> H[Choose Text or Voice Input]
|
| 269 |
+
H --> I[AI Opens With Hard Question]
|
| 270 |
+
I --> J[User Responds]
|
| 271 |
+
J --> K[AI Selects Attack Tag]
|
| 272 |
+
K --> L[AI Pushes Back]
|
| 273 |
+
L --> J
|
| 274 |
+
|
| 275 |
+
J --> M[End Battle]
|
| 276 |
+
M --> N[Scorecard]
|
| 277 |
+
N --> O[Weakest Answer]
|
| 278 |
+
O --> P[Improved Version]
|
| 279 |
+
P --> Q[Retry or Start New Battle]
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
---
|
| 283 |
+
|
| 284 |
+
## 8. Final Model Strategy
|
| 285 |
+
|
| 286 |
+
The core model table is kept intentionally small. Every model listed here has a real job in the submitted app.
|
| 287 |
+
|
| 288 |
+
| Model | Type | Where Used | Why Used |
|
| 289 |
+
|---|---|---|---|
|
| 290 |
+
| **NVIDIA Nemotron 3 Nano Omni** | Premium multimodal LLM (≤30B) | Primary judge: pitch battle, voice, deal battle, scoring, rewrites | Best demo quality; NVIDIA Nemotron Quest alignment |
|
| 291 |
+
| **MiniCPM-o 4.5** | OpenBMB omni model | OpenBMB sponsor mode, multimodal fallback | OpenBMB Awards alignment |
|
| 292 |
+
| **MiniCPM5-1B** | Tiny text LLM | Tiny Mode, API failure fallback | Speed + Tiny Titan eligibility |
|
| 293 |
+
| **MiniCPM-V 4.6** | Vision model | Pitch deck / slide critique | Image-based judge questions |
|
| 294 |
+
| **faster-whisper (tiny/base)** | Local STT | Voice fallback transcription | Reliable backup when Omni voice path fails |
|
| 295 |
+
|
| 296 |
+
### Fallback Model Note
|
| 297 |
+
|
| 298 |
+
`core/model_router.py` routes to **Tiny MiniCPM5-1B** or **whisper_fallback** when premium APIs timeout, fail, or keys are missing. UI shows active mode badge (Premium Nemotron / OpenBMB Omni / Tiny MiniCPM).
|
| 299 |
+
|
| 300 |
+
---
|
| 301 |
+
|
| 302 |
+
## 9. Exact Use of Each Model
|
| 303 |
+
|
| 304 |
+
### 9.1 NVIDIA Nemotron Omni — Primary Premium Model
|
| 305 |
+
|
| 306 |
+
Used for:
|
| 307 |
+
|
| 308 |
+
- AI opponent responses and follow-ups
|
| 309 |
+
- Voice pitch interpretation and first hard question
|
| 310 |
+
- Deal battle counterpart dialogue
|
| 311 |
+
- Scorecard generation (structured JSON)
|
| 312 |
+
- Weakest answer rewrite and 60-second pitch rewrite
|
| 313 |
+
- Optional multimodal deck + spoken pitch critique
|
| 314 |
+
|
| 315 |
+
#### Backend Client
|
| 316 |
+
|
| 317 |
+
`core/nvidia_client.py` — secure calls via `NVIDIA_API_KEY` and `NVIDIA_BASE_URL`. Never exposed to frontend.
|
| 318 |
+
|
| 319 |
+
### 9.1b MiniCPM Models — OpenBMB + Fallback
|
| 320 |
+
|
| 321 |
+
**MiniCPM-o 4.5** — OpenBMB omni mode. **MiniCPM5-1B** — tiny/fallback text. **MiniCPM-V 4.6** — slide critique.
|
| 322 |
+
|
| 323 |
+
#### Runtime Options
|
| 324 |
+
|
| 325 |
+
| Runtime | Use Case |
|
| 326 |
+
|---|---|
|
| 327 |
+
| OpenBMB API / HF inference | Primary OpenBMB sponsor path |
|
| 328 |
+
| Local `transformers` / quantized | Tiny Mode on Space hardware if needed |
|
| 329 |
+
|
| 330 |
+
#### Suggested Settings
|
| 331 |
+
|
| 332 |
+
| Task | Temperature | Max Tokens |
|
| 333 |
+
|---|---:|---:|
|
| 334 |
+
| Opponent response | 0.7–0.8 | 180–260 |
|
| 335 |
+
| Scorecard | 0.1–0.3 | 900–1400 |
|
| 336 |
+
| Rewrite weak answer | 0.4–0.5 | 400–600 |
|
| 337 |
+
| Final pitch rewrite | 0.5–0.6 | 400–700 |
|
| 338 |
+
|
| 339 |
+
---
|
| 340 |
+
|
| 341 |
+
### 9.2 Whisper Tiny/Base — Local Voice Input
|
| 342 |
+
|
| 343 |
+
Used for:
|
| 344 |
+
|
| 345 |
+
- Spoken pitch transcription
|
| 346 |
+
- Spoken answer transcription
|
| 347 |
+
- Generating a transcript for the text battle engine
|
| 348 |
+
- Voice add-on metrics based on transcript structure
|
| 349 |
+
|
| 350 |
+
#### Voice Flow (Primary)
|
| 351 |
+
|
| 352 |
+
```text
|
| 353 |
+
Browser records audio
|
| 354 |
+
→ /voice_pitch API
|
| 355 |
+
→ NVIDIA Nemotron Omni (audio + prompt)
|
| 356 |
+
→ structured pitch context + first hard question
|
| 357 |
+
→ Pitch Battle continues (voice or text)
|
| 358 |
+
```
|
| 359 |
+
|
| 360 |
+
#### Voice Flow (Fallback)
|
| 361 |
+
|
| 362 |
+
```text
|
| 363 |
+
Browser records audio
|
| 364 |
+
→ faster-whisper local transcription
|
| 365 |
+
→ transcript → selected text model (Nemotron or MiniCPM5-1B)
|
| 366 |
+
→ normal Pitch Battle flow
|
| 367 |
+
```
|
| 368 |
+
|
| 369 |
+
---
|
| 370 |
+
|
| 371 |
+
### 9.3 Pitch Deck Critique — MiniCPM-V / Nemotron Vision
|
| 372 |
+
|
| 373 |
+
Used for:
|
| 374 |
+
|
| 375 |
+
- Slide screenshot upload via `/deck_critique`
|
| 376 |
+
- Claim extraction and clarity critique
|
| 377 |
+
- Three judge prep questions per slide
|
| 378 |
+
|
| 379 |
+
Primary: **MiniCPM-V 4.6**. Alternate: Nemotron Omni vision path.
|
| 380 |
+
|
| 381 |
+
---
|
| 382 |
+
|
| 383 |
+
## 10. Tools and Their Exact Usage
|
| 384 |
+
|
| 385 |
+
| Tool / Platform | Use in PitchFight AI |
|
| 386 |
+
|---|---|
|
| 387 |
+
| **Hugging Face Spaces** | Final deployment platform |
|
| 388 |
+
| **Gradio Server** | Gradio backend server with custom route support |
|
| 389 |
+
| **FastAPI `/api/*` routes** | Product endpoints: `/api/start-session`, `/api/chat-round`, `/api/end-battle`, etc. |
|
| 390 |
+
| **Gradio `@app.api` wrappers** | Compatibility aliases calling the same `core/api_handlers.py` functions |
|
| 391 |
+
| **Custom HTML/CSS/JS** | Makes the UI look like a battle arena instead of default Gradio |
|
| 392 |
+
| **Browser `fetch()`** | Frontend calls `/api/*` endpoints only — never model provider APIs |
|
| 393 |
+
| **faster-whisper** | Audio transcription fallback when Nemotron Omni voice path is unavailable |
|
| 394 |
+
| **Hugging Face Hub** | Model download and project sharing |
|
| 395 |
+
| **HF Space Secrets** | NVIDIA, OpenBMB, and HF tokens for backend-only inference (never exposed to frontend) |
|
| 396 |
+
| **GitHub / HF repo** | Version control and project sharing |
|
| 397 |
+
| **Mermaid diagrams** | Architecture and workflow documentation |
|
| 398 |
+
| **Cursor Agent** | Frontend implementation and UI polish |
|
| 399 |
+
| **Claude Code** | File generation, backend modules, refactoring |
|
| 400 |
+
| **ChatGPT** | Planning, architecture, prompt design, debugging strategy, documentation |
|
| 401 |
+
|
| 402 |
+
---
|
| 403 |
+
|
| 404 |
+
## 11. Backend API Design
|
| 405 |
+
|
| 406 |
+
The frontend should never call model runtime code directly. All model calls go through the Python backend.
|
| 407 |
+
|
| 408 |
+
### Correct Flow
|
| 409 |
+
|
| 410 |
+
```text
|
| 411 |
+
Browser UI → /api/* (fetch) → api_handlers → model_router → NVIDIA / OpenBMB / whisper clients
|
| 412 |
+
```
|
| 413 |
+
|
| 414 |
+
### Wrong Flow
|
| 415 |
+
|
| 416 |
+
```text
|
| 417 |
+
Browser UI → External model API directly
|
| 418 |
+
```
|
| 419 |
+
|
| 420 |
+
This would expose API keys. All model calls must stay on the backend.
|
| 421 |
+
|
| 422 |
+
---
|
| 423 |
+
|
| 424 |
+
### 11.1 `/start_session`
|
| 425 |
+
|
| 426 |
+
Starts a new battle session.
|
| 427 |
+
|
| 428 |
+
#### Input
|
| 429 |
+
|
| 430 |
+
```json
|
| 431 |
+
{
|
| 432 |
+
"mode": "pitch_battle",
|
| 433 |
+
"persona": "hackathon_judge",
|
| 434 |
+
"difficulty": "high",
|
| 435 |
+
"input_mode": "text",
|
| 436 |
+
"startup": {
|
| 437 |
+
"name": "EventRadar AI",
|
| 438 |
+
"problem": "Students miss hackathons and tech events because discovery is scattered.",
|
| 439 |
+
"target_users": "College students and early-stage builders",
|
| 440 |
+
"solution": "AI-powered event matching based on goals, skills, location, and urgency",
|
| 441 |
+
"why_ai": "The model ranks and explains relevance instead of just listing events",
|
| 442 |
+
"competitors": "Luma, LinkedIn Events, WhatsApp groups",
|
| 443 |
+
"traction": "Prototype with scraped event data and ranking logic",
|
| 444 |
+
"ask": "Hackathon prize and mentor feedback"
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
```
|
| 448 |
+
|
| 449 |
+
#### Output
|
| 450 |
+
|
| 451 |
+
```json
|
| 452 |
+
{
|
| 453 |
+
"session_id": "abc123",
|
| 454 |
+
"round": 1,
|
| 455 |
+
"pressure_level": "High",
|
| 456 |
+
"attack_tag": "AI Justification",
|
| 457 |
+
"ai_message": "Why does this need AI? A sorted event list with filters seems enough. What is the intelligence here?"
|
| 458 |
+
}
|
| 459 |
+
```
|
| 460 |
+
|
| 461 |
+
---
|
| 462 |
+
|
| 463 |
+
### 11.2 `/chat_round`
|
| 464 |
+
|
| 465 |
+
Processes one user answer and returns the next AI pushback.
|
| 466 |
+
|
| 467 |
+
#### Input
|
| 468 |
+
|
| 469 |
+
```json
|
| 470 |
+
{
|
| 471 |
+
"session_id": "abc123",
|
| 472 |
+
"user_message": "The AI ranks events based on the student's profile and explains why each event matters."
|
| 473 |
+
}
|
| 474 |
+
```
|
| 475 |
+
|
| 476 |
+
#### Output
|
| 477 |
+
|
| 478 |
+
```json
|
| 479 |
+
{
|
| 480 |
+
"round": 2,
|
| 481 |
+
"pressure_level": "High",
|
| 482 |
+
"attack_tag": "Retention",
|
| 483 |
+
"ai_message": "That explains matching, not retention. Why would a student return every week instead of checking once before placement season?"
|
| 484 |
+
}
|
| 485 |
+
```
|
| 486 |
+
|
| 487 |
+
---
|
| 488 |
+
|
| 489 |
+
### 11.3 `/voice_pitch`
|
| 490 |
+
|
| 491 |
+
Accepts an audio file. Primary path: Nemotron Omni voice interpretation. Fallback: faster-whisper transcription.
|
| 492 |
+
|
| 493 |
+
#### Input
|
| 494 |
+
|
| 495 |
+
```json
|
| 496 |
+
{
|
| 497 |
+
"audio_file": "pitch.wav",
|
| 498 |
+
"persona": "skeptical_vc"
|
| 499 |
+
}
|
| 500 |
+
```
|
| 501 |
+
|
| 502 |
+
#### Output
|
| 503 |
+
|
| 504 |
+
```json
|
| 505 |
+
{
|
| 506 |
+
"transcript": "We are building EventRadar AI...",
|
| 507 |
+
"detected_startup_context": {
|
| 508 |
+
"name": "EventRadar AI",
|
| 509 |
+
"problem": "students miss events",
|
| 510 |
+
"solution": "AI event matching"
|
| 511 |
+
},
|
| 512 |
+
"opening_question": "You are describing discovery, but where is the urgency? Why is this painful enough for students to change behavior?"
|
| 513 |
+
}
|
| 514 |
+
```
|
| 515 |
+
|
| 516 |
+
Implementation detail:
|
| 517 |
+
|
| 518 |
+
- **Primary:** audio sent to NVIDIA Nemotron Omni via backend; model extracts pitch context and opening question.
|
| 519 |
+
- **Fallback:** faster-whisper transcribes locally; transcript routed to Nemotron or MiniCPM5-1B text path.
|
| 520 |
+
- If context extraction is uncertain, the UI asks the user to quickly edit the detected fields before the battle starts.
|
| 521 |
+
|
| 522 |
+
---
|
| 523 |
+
|
| 524 |
+
### 11.4 `/end_battle`
|
| 525 |
+
|
| 526 |
+
Scores the conversation.
|
| 527 |
+
|
| 528 |
+
#### Input
|
| 529 |
+
|
| 530 |
+
```json
|
| 531 |
+
{
|
| 532 |
+
"session_id": "abc123"
|
| 533 |
+
}
|
| 534 |
+
```
|
| 535 |
+
|
| 536 |
+
#### Output
|
| 537 |
+
|
| 538 |
+
```json
|
| 539 |
+
{
|
| 540 |
+
"overall": 68,
|
| 541 |
+
"scores": {
|
| 542 |
+
"clarity": {
|
| 543 |
+
"score": 76,
|
| 544 |
+
"reason": "The user explained the workflow clearly but took too long to state the core value.",
|
| 545 |
+
"quote": "It ranks events based on a student's profile."
|
| 546 |
+
},
|
| 547 |
+
"problem_understanding": {
|
| 548 |
+
"score": 70,
|
| 549 |
+
"reason": "The user described the problem but did not provide evidence of frequency or severity.",
|
| 550 |
+
"quote": "Students miss hackathons because discovery is scattered."
|
| 551 |
+
}
|
| 552 |
+
},
|
| 553 |
+
"best_answer": {
|
| 554 |
+
"quote": "The app explains why each event is relevant instead of only listing it.",
|
| 555 |
+
"why": "This differentiated the product from a basic directory."
|
| 556 |
+
},
|
| 557 |
+
"weakest_answer": {
|
| 558 |
+
"quote": "Students will use it because it is helpful.",
|
| 559 |
+
"why": "This answer was vague and did not address retention or urgency."
|
| 560 |
+
},
|
| 561 |
+
"improved_answer": "Students return because the system continuously tracks deadlines, skill fit, team requirements, and local event updates. It becomes a weekly opportunity radar, not a one-time event list.",
|
| 562 |
+
"improved_pitch": "EventRadar AI helps students discover the right hackathons and tech events by matching opportunities to their skills, goals, location, and urgency. Unlike static event lists, it explains why each event matters and helps students act before deadlines.",
|
| 563 |
+
"top_3_questions": [
|
| 564 |
+
"Why does this need AI instead of filters?",
|
| 565 |
+
"What makes users return weekly?",
|
| 566 |
+
"How will you get your first 100 active users?"
|
| 567 |
+
]
|
| 568 |
+
}
|
| 569 |
+
```
|
| 570 |
+
|
| 571 |
+
---
|
| 572 |
+
|
| 573 |
+
## 12. Attack Tag Taxonomy
|
| 574 |
+
|
| 575 |
+
Attack tags make PitchFight AI feel structured instead of random. The AI should rotate through known pressure points based on the selected persona and the user’s previous answer.
|
| 576 |
+
|
| 577 |
+
### 12.1 Skeptical VC Attack Tags
|
| 578 |
+
|
| 579 |
+
| Attack Tag | What It Tests | Example Question |
|
| 580 |
+
|---|---|---|
|
| 581 |
+
| Market Size | Whether the opportunity is big enough | “How many people have this problem badly enough to pay or change behavior?” |
|
| 582 |
+
| Moat | Whether the idea is defensible | “What stops a larger platform from copying this in a week?” |
|
| 583 |
+
| Retention | Whether users come back | “Why would someone open this again after the first use?” |
|
| 584 |
+
| Revenue Logic | Whether there is a path to money/value | “Who pays, how much, and why would they keep paying?” |
|
| 585 |
+
| First 100 Users | Whether acquisition is realistic | “How exactly do you get the first 100 active users without paid ads?” |
|
| 586 |
+
| Why Now | Whether timing is convincing | “Why is this urgent now instead of two years ago or two years later?” |
|
| 587 |
+
| Competition | Whether existing alternatives are understood | “Why does your user choose this over what they already do today?” |
|
| 588 |
+
|
| 589 |
+
### 12.2 Technical Judge Attack Tags
|
| 590 |
+
|
| 591 |
+
| Attack Tag | What It Tests | Example Question |
|
| 592 |
+
|---|---|---|
|
| 593 |
+
| AI Justification | Whether AI is actually needed | “What does the model do that filters or rules cannot?” |
|
| 594 |
+
| Architecture | Whether the system design is clear | “Where exactly does inference happen, and what data flows through it?” |
|
| 595 |
+
| Scalability | Whether the system can grow | “What breaks first when 100 users become 10,000?” |
|
| 596 |
+
| Latency | Whether the UX is practical | “How long does one response take, and what happens when it is slow?” |
|
| 597 |
+
| Data Quality | Whether input data is reliable | “Where does your data come from, and how do you handle missing or wrong data?” |
|
| 598 |
+
| Failure Mode | Whether the team knows what can go wrong | “What is the worst wrong output your system can produce?” |
|
| 599 |
+
| Simpler Alternative | Whether the build is overengineered | “Could this be done with a form and rules? Why is your system better?” |
|
| 600 |
+
|
| 601 |
+
### 12.3 Hackathon Judge Attack Tags
|
| 602 |
+
|
| 603 |
+
| Attack Tag | What It Tests | Example Question |
|
| 604 |
+
|---|---|---|
|
| 605 |
+
| Novelty | Whether the project is memorable | “I have seen similar ideas. What is the one thing I will remember?” |
|
| 606 |
+
| Demo Clarity | Whether the demo lands quickly | “Can you show the value in 30 seconds?” |
|
| 607 |
+
| MVP Strength | Whether the prototype actually works | “What part is fully working right now, not just planned?” |
|
| 608 |
+
| User Pain | Whether the problem is real | “Who exactly has this problem, and how do you know?” |
|
| 609 |
+
| AI Load-Bearing | Whether AI is central | “If I remove the model, does the product collapse or still work?” |
|
| 610 |
+
| Backyard Fit | Whether it helps a real person | “Who used this, and what got better for them?” |
|
| 611 |
+
| Practical Impact | Whether it matters beyond the demo | “What changes for the user after using this?” |
|
| 612 |
+
|
| 613 |
+
### 12.4 Deal Battle Attack Tags
|
| 614 |
+
|
| 615 |
+
| Attack Tag | What It Tests | Example Question |
|
| 616 |
+
|---|---|---|
|
| 617 |
+
| Anchoring | Whether the user sets a clear position | “What number are you asking for, and why that number?” |
|
| 618 |
+
| Evidence | Whether claims are backed by proof | “What proof do you have that you are worth that ask?” |
|
| 619 |
+
| Concession | Whether the user gives up too early | “If I say no, what do you offer without lowering the value?” |
|
| 620 |
+
| Mutual Value | Whether both sides benefit | “Why is this good for me, not just for you?” |
|
| 621 |
+
| Closing | Whether the user moves toward action | “What exact next step are you asking me to agree to?” |
|
| 622 |
+
|
| 623 |
+
---
|
| 624 |
+
|
| 625 |
+
## 13. Core Prompt Design
|
| 626 |
+
|
| 627 |
+
### 13.1 Opponent System Prompt
|
| 628 |
+
|
| 629 |
+
```text
|
| 630 |
+
You are {persona_name}, a realistic evaluator speaking to a student founder.
|
| 631 |
+
|
| 632 |
+
Your job is not to encourage. Your job is to pressure-test the founder's thinking.
|
| 633 |
+
|
| 634 |
+
Startup context:
|
| 635 |
+
- Name: {name}
|
| 636 |
+
- Problem: {problem}
|
| 637 |
+
- Target users: {target_users}
|
| 638 |
+
- Solution: {solution}
|
| 639 |
+
- Why AI: {why_ai}
|
| 640 |
+
- Competitors: {competitors}
|
| 641 |
+
- Traction: {traction}
|
| 642 |
+
- Ask: {ask}
|
| 643 |
+
|
| 644 |
+
Current attack tag: {attack_tag}
|
| 645 |
+
|
| 646 |
+
Rules:
|
| 647 |
+
1. Ask one sharp question at a time.
|
| 648 |
+
2. Keep responses under 4 sentences.
|
| 649 |
+
3. Reference the founder's previous answer.
|
| 650 |
+
4. Do not give advice during the battle.
|
| 651 |
+
5. Do not say "great answer", "interesting", or "that makes sense".
|
| 652 |
+
6. If the answer is vague, attack the vague part.
|
| 653 |
+
7. If the answer is strong, raise the difficulty.
|
| 654 |
+
8. Stay in character.
|
| 655 |
+
9. Be firm, realistic, and useful — not abusive.
|
| 656 |
+
10. Your response must match the current attack tag.
|
| 657 |
+
```
|
| 658 |
+
|
| 659 |
+
---
|
| 660 |
+
|
| 661 |
+
### 13.2 Scoring Prompt
|
| 662 |
+
|
| 663 |
+
```text
|
| 664 |
+
You watched a pitch battle between a student founder and an AI evaluator.
|
| 665 |
+
|
| 666 |
+
Score the founder on:
|
| 667 |
+
1. Clarity
|
| 668 |
+
2. Problem Understanding
|
| 669 |
+
3. Market Awareness
|
| 670 |
+
4. Differentiation
|
| 671 |
+
5. Business Model
|
| 672 |
+
6. Objection Handling
|
| 673 |
+
|
| 674 |
+
For each dimension:
|
| 675 |
+
- Give a score from 0 to 100
|
| 676 |
+
- Mention the exact quote that influenced the score
|
| 677 |
+
- Give one specific reason
|
| 678 |
+
|
| 679 |
+
Then identify:
|
| 680 |
+
- Best answer
|
| 681 |
+
- Weakest answer
|
| 682 |
+
- Why the weak answer failed
|
| 683 |
+
- Improved version of the weak answer
|
| 684 |
+
- Improved 60-second pitch
|
| 685 |
+
- Top 3 questions to prepare next
|
| 686 |
+
|
| 687 |
+
Return valid JSON only.
|
| 688 |
+
```
|
| 689 |
+
|
| 690 |
+
---
|
| 691 |
+
|
| 692 |
+
## 14. Scoring Rubric
|
| 693 |
+
|
| 694 |
+
### Pitch Battle
|
| 695 |
+
|
| 696 |
+
| Dimension | Weight | What It Measures |
|
| 697 |
+
|---|---:|---|
|
| 698 |
+
| Clarity | 15% | Can a judge understand the idea quickly? |
|
| 699 |
+
| Problem Understanding | 20% | Did the founder prove the pain is real? |
|
| 700 |
+
| Market Awareness | 15% | Do they know who the first users are? |
|
| 701 |
+
| Differentiation | 20% | Why does this beat existing options? |
|
| 702 |
+
| Business Model | 15% | Is there a path to value or revenue? |
|
| 703 |
+
| Objection Handling | 15% | Did they answer directly or dodge? |
|
| 704 |
+
|
| 705 |
+
### Deal Battle
|
| 706 |
+
|
| 707 |
+
| Dimension | Weight | What It Measures |
|
| 708 |
+
|---|---:|---|
|
| 709 |
+
| Anchoring | 20% | Did the user set a clear position? |
|
| 710 |
+
| Evidence Usage | 20% | Did they support the ask with proof? |
|
| 711 |
+
| Confidence | 15% | Did they hold ground under pressure? |
|
| 712 |
+
| Empathy | 15% | Did they understand the other side’s constraints? |
|
| 713 |
+
| Concession Handling | 15% | Did they avoid giving up too fast? |
|
| 714 |
+
| Closing | 15% | Did they move toward a clear next step? |
|
| 715 |
+
|
| 716 |
+
### Voice Mode Add-On Metrics
|
| 717 |
+
|
| 718 |
+
| Dimension | What It Measures |
|
| 719 |
+
|---|---|
|
| 720 |
+
| Structure | Did the spoken pitch have a clear beginning, middle, and ask? |
|
| 721 |
+
| Conciseness | Did the user avoid rambling? |
|
| 722 |
+
| Confidence Signals | Did the transcript show hesitation or unclear phrasing? |
|
| 723 |
+
| Directness | Did the user answer the question directly? |
|
| 724 |
+
|
| 725 |
+
---
|
| 726 |
+
|
| 727 |
+
## 15. JSON Parsing and Fallback Strategy
|
| 728 |
+
|
| 729 |
+
Small models can return malformed JSON. The scoring engine must handle this gracefully.
|
| 730 |
+
|
| 731 |
+
### Fallback Pipeline
|
| 732 |
+
|
| 733 |
+
```text
|
| 734 |
+
1. Try direct JSON parse.
|
| 735 |
+
↓
|
| 736 |
+
2. If parse fails, extract substring from first `{` to last `}` and parse again.
|
| 737 |
+
↓
|
| 738 |
+
3. If still broken, send a model retry prompt via model router:
|
| 739 |
+
“Convert the following response into valid JSON only.”
|
| 740 |
+
↓
|
| 741 |
+
4. If retry fails, use regex to extract numeric scores and key sections.
|
| 742 |
+
↓
|
| 743 |
+
5. If all parsing fails, show a graceful fallback scorecard:
|
| 744 |
+
“Scorecard could not be fully structured, but here is the raw feedback.”
|
| 745 |
+
```
|
| 746 |
+
|
| 747 |
+
### Implementation Notes
|
| 748 |
+
|
| 749 |
+
- Never expose raw Python errors in the UI.
|
| 750 |
+
- Always show something useful to the user.
|
| 751 |
+
- Log parse failures in the backend console for debugging.
|
| 752 |
+
- Keep a default scorecard schema so the frontend never breaks.
|
| 753 |
+
|
| 754 |
+
---
|
| 755 |
+
|
| 756 |
+
## 16. Frontend UI Design
|
| 757 |
+
|
| 758 |
+
### Design Goal
|
| 759 |
+
|
| 760 |
+
The app should not look like a basic Gradio demo. It should feel like a polished AI practice arena.
|
| 761 |
+
|
| 762 |
+
### Screens
|
| 763 |
+
|
| 764 |
+
1. Landing screen
|
| 765 |
+
2. Mode selection
|
| 766 |
+
3. Startup / negotiation context form
|
| 767 |
+
4. Persona selection
|
| 768 |
+
5. Text or voice input selection
|
| 769 |
+
6. Battle arena
|
| 770 |
+
7. Scorecard screen
|
| 771 |
+
8. Retry / new battle screen
|
| 772 |
+
|
| 773 |
+
### Visual Direction
|
| 774 |
+
|
| 775 |
+
```text
|
| 776 |
+
Theme: Dark Battle Arena
|
| 777 |
+
Background: near-black / navy
|
| 778 |
+
Primary accent: red
|
| 779 |
+
Secondary accent: gold
|
| 780 |
+
Cards: dark glassmorphism
|
| 781 |
+
Typography: bold headings, clean mono-style chat
|
| 782 |
+
Animations: subtle glow, score reveal, pressure meter pulse
|
| 783 |
+
```
|
| 784 |
+
|
| 785 |
+
### UI Elements
|
| 786 |
+
|
| 787 |
+
- Round counter
|
| 788 |
+
- Pressure meter
|
| 789 |
+
- Attack tag
|
| 790 |
+
- Opponent card
|
| 791 |
+
- Founder card
|
| 792 |
+
- Voice recording button
|
| 793 |
+
- Chat transcript
|
| 794 |
+
- End battle button
|
| 795 |
+
- Score bars
|
| 796 |
+
- Weakest answer highlight
|
| 797 |
+
- Improved answer card
|
| 798 |
+
|
| 799 |
+
---
|
| 800 |
+
|
| 801 |
+
## 17. Hugging Face Spaces Deployment Plan
|
| 802 |
+
|
| 803 |
+
### Space Type
|
| 804 |
+
|
| 805 |
+
```yaml
|
| 806 |
+
---
|
| 807 |
+
title: PitchFight AI
|
| 808 |
+
emoji: ⚔️
|
| 809 |
+
colorFrom: red
|
| 810 |
+
colorTo: yellow
|
| 811 |
+
sdk: gradio
|
| 812 |
+
app_file: app.py
|
| 813 |
+
pinned: false
|
| 814 |
+
---
|
| 815 |
+
```
|
| 816 |
+
|
| 817 |
+
### Core Environment Variables
|
| 818 |
+
|
| 819 |
+
Set in HF Space Secrets and local `.env` (never commit real keys):
|
| 820 |
+
|
| 821 |
+
```text
|
| 822 |
+
APP_ENV=production
|
| 823 |
+
MAX_ROUNDS=6
|
| 824 |
+
|
| 825 |
+
NVIDIA_API_KEY=<hf-space-secret>
|
| 826 |
+
NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
| 827 |
+
NVIDIA_OMNI_MODEL=nvidia/nemotron-3-nano-omni-30b-a3b-reasoning
|
| 828 |
+
|
| 829 |
+
OPENBMB_API_KEY=<hf-space-secret>
|
| 830 |
+
OPENBMB_BASE_URL=
|
| 831 |
+
MINICPM_OMNI_MODEL=openbmb/MiniCPM-o-4_5
|
| 832 |
+
MINICPM_TEXT_MODEL=openbmb/MiniCPM5-1B
|
| 833 |
+
MINICPM_VISION_MODEL=openbmb/MiniCPM-V-4.6
|
| 834 |
+
|
| 835 |
+
HF_TOKEN=<hf-space-secret>
|
| 836 |
+
WHISPER_FALLBACK_ENABLED=true
|
| 837 |
+
WHISPER_MODEL_SIZE=tiny
|
| 838 |
+
```
|
| 839 |
+
|
| 840 |
+
Do **not** expose keys in frontend code, README, or public repos.
|
| 841 |
+
|
| 842 |
+
### Required `packages.txt`
|
| 843 |
+
|
| 844 |
+
```text
|
| 845 |
+
ffmpeg
|
| 846 |
+
```
|
| 847 |
+
|
| 848 |
+
### Important Security Rules
|
| 849 |
+
|
| 850 |
+
- Never commit real API keys.
|
| 851 |
+
- Never put keys in frontend JavaScript.
|
| 852 |
+
- Use `.env` locally only for configuration.
|
| 853 |
+
- Add `.env` to `.gitignore`.
|
| 854 |
+
- Sponsor model API keys (NVIDIA, OpenBMB) are required for the premium demo path; they stay backend-only.
|
| 855 |
+
|
| 856 |
+
---
|
| 857 |
+
|
| 858 |
+
## 18. Final Deployment File Structure
|
| 859 |
+
|
| 860 |
+
```text
|
| 861 |
+
pitchfight-ai/
|
| 862 |
+
│
|
| 863 |
+
├── app.py
|
| 864 |
+
├── requirements.txt
|
| 865 |
+
├── packages.txt
|
| 866 |
+
├── README.md
|
| 867 |
+
├── .env.example
|
| 868 |
+
├── .gitignore
|
| 869 |
+
│
|
| 870 |
+
├── core/
|
| 871 |
+
│ ├── __init__.py
|
| 872 |
+
│ ├── api_handlers.py
|
| 873 |
+
│ ├── session_manager.py
|
| 874 |
+
│ ├── persona_builder.py
|
| 875 |
+
│ ├── attack_tags.py
|
| 876 |
+
│ ├── model_router.py
|
| 877 |
+
│ ├── nvidia_client.py
|
| 878 |
+
│ ├── minicpm_client.py
|
| 879 |
+
│ ├── vision_client.py
|
| 880 |
+
│ ├── transcription_client.py
|
| 881 |
+
│ ├── scoring_engine.py
|
| 882 |
+
│ ├── feedback_generator.py
|
| 883 |
+
│ ├── json_utils.py
|
| 884 |
+
│ └── samples.py
|
| 885 |
+
│
|
| 886 |
+
├── config/
|
| 887 |
+
│ ├── personas.json
|
| 888 |
+
│ ├── attack_tags.json
|
| 889 |
+
│ ├── pitch_rubric.json
|
| 890 |
+
│ ├── deal_rubric.json
|
| 891 |
+
│ └── sample_startups.json
|
| 892 |
+
│
|
| 893 |
+
├── frontend/
|
| 894 |
+
│ ├── index.html
|
| 895 |
+
│ ├── styles.css
|
| 896 |
+
│ ├── script.js
|
| 897 |
+
│ └── assets/
|
| 898 |
+
│ ├── logo.svg
|
| 899 |
+
│ └── icons/
|
| 900 |
+
│
|
| 901 |
+
├── docs/
|
| 902 |
+
│ ├── DOCUMENTATION.md
|
| 903 |
+
│ ├── PROMPTS.md
|
| 904 |
+
│ ├── DEMO_NOTES.md
|
| 905 |
+
│ └── FIELD_NOTES.md
|
| 906 |
+
│
|
| 907 |
+
└── tests/
|
| 908 |
+
├── test_prompts.py
|
| 909 |
+
├── test_attack_tags.py
|
| 910 |
+
└── test_json_parser.py
|
| 911 |
+
```
|
| 912 |
+
|
| 913 |
+
---
|
| 914 |
+
|
| 915 |
+
## 19. File Responsibilities
|
| 916 |
+
|
| 917 |
+
### `app.py`
|
| 918 |
+
|
| 919 |
+
Main Gradio Server entry point.
|
| 920 |
+
|
| 921 |
+
Responsibilities:
|
| 922 |
+
|
| 923 |
+
- Create `gradio.Server`
|
| 924 |
+
- Serve custom `frontend/index.html`
|
| 925 |
+
- Serve static CSS/JS/assets
|
| 926 |
+
- Expose backend APIs
|
| 927 |
+
- Launch app
|
| 928 |
+
|
| 929 |
+
### `core/session_manager.py`
|
| 930 |
+
|
| 931 |
+
Stores active battle sessions.
|
| 932 |
+
|
| 933 |
+
Responsibilities:
|
| 934 |
+
|
| 935 |
+
- Create sessions
|
| 936 |
+
- Store conversation history
|
| 937 |
+
- Track rounds
|
| 938 |
+
- Track persona and mode
|
| 939 |
+
- Track attack tags
|
| 940 |
+
- Return full conversation for scoring
|
| 941 |
+
|
| 942 |
+
### `core/persona_builder.py`
|
| 943 |
+
|
| 944 |
+
Builds persona prompts.
|
| 945 |
+
|
| 946 |
+
Responsibilities:
|
| 947 |
+
|
| 948 |
+
- Load persona config
|
| 949 |
+
- Inject startup or negotiation context
|
| 950 |
+
- Add difficulty rules
|
| 951 |
+
- Add current attack tag
|
| 952 |
+
- Return system prompt
|
| 953 |
+
|
| 954 |
+
### `core/attack_tags.py`
|
| 955 |
+
|
| 956 |
+
Controls structured pressure flow.
|
| 957 |
+
|
| 958 |
+
Responsibilities:
|
| 959 |
+
|
| 960 |
+
- Store attack tag taxonomy
|
| 961 |
+
- Select next attack tag per persona
|
| 962 |
+
- Avoid repeating the same attack tag too often
|
| 963 |
+
- Increase pressure as rounds progress
|
| 964 |
+
|
| 965 |
+
### `core/model_router.py`
|
| 966 |
+
|
| 967 |
+
Backend-only model routing (Phase 2+).
|
| 968 |
+
|
| 969 |
+
Responsibilities:
|
| 970 |
+
|
| 971 |
+
- Text battle → NVIDIA Nemotron Omni by default; MiniCPM5-1B on failure
|
| 972 |
+
- Voice input → Nemotron Omni primary; faster-whisper transcription fallback
|
| 973 |
+
- OpenBMB omni mode → MiniCPM-o 4.5
|
| 974 |
+
- Deck critique → MiniCPM-V 4.6 or Nemotron vision path
|
| 975 |
+
- Expose active mode badge to frontend (`premium_nvidia`, `openbmb_omni`, `tiny_minicpm`, etc.)
|
| 976 |
+
|
| 977 |
+
### `core/nvidia_client.py`
|
| 978 |
+
|
| 979 |
+
Secure NVIDIA Nemotron Omni client (Phase 2+).
|
| 980 |
+
|
| 981 |
+
Responsibilities:
|
| 982 |
+
|
| 983 |
+
- Backend-only API calls via `NVIDIA_API_KEY`
|
| 984 |
+
- Text, voice, multimodal judge responses
|
| 985 |
+
- Scorecard and rewrite generation
|
| 986 |
+
- Timeout / retry handling
|
| 987 |
+
|
| 988 |
+
### `core/minicpm_client.py`
|
| 989 |
+
|
| 990 |
+
OpenBMB MiniCPM backend client (Phase 2+).
|
| 991 |
+
|
| 992 |
+
Responsibilities:
|
| 993 |
+
|
| 994 |
+
- MiniCPM-o omni mode
|
| 995 |
+
- MiniCPM5-1B tiny / fallback text generation
|
| 996 |
+
|
| 997 |
+
### `core/transcription_client.py`
|
| 998 |
+
|
| 999 |
+
Voice transcription fallback.
|
| 1000 |
+
|
| 1001 |
+
Responsibilities:
|
| 1002 |
+
|
| 1003 |
+
- Accept audio file
|
| 1004 |
+
- Run faster-whisper locally when Omni voice path fails
|
| 1005 |
+
- Return transcript for model router
|
| 1006 |
+
|
| 1007 |
+
### `core/vision_client.py`
|
| 1008 |
+
|
| 1009 |
+
Deck critique vision client (Phase 2+).
|
| 1010 |
+
|
| 1011 |
+
Responsibilities:
|
| 1012 |
+
|
| 1013 |
+
- Route slide images to MiniCPM-V 4.6 or Nemotron vision path
|
| 1014 |
+
|
| 1015 |
+
### `core/scoring_engine.py`
|
| 1016 |
+
|
| 1017 |
+
Generates scorecard.
|
| 1018 |
+
|
| 1019 |
+
Responsibilities:
|
| 1020 |
+
|
| 1021 |
+
- Build scoring prompt
|
| 1022 |
+
- Call model router (Nemotron default, MiniCPM5 fallback)
|
| 1023 |
+
- Parse JSON
|
| 1024 |
+
- Use fallback strategy if JSON breaks
|
| 1025 |
+
|
| 1026 |
+
### `core/feedback_generator.py`
|
| 1027 |
+
|
| 1028 |
+
Generates improved answers.
|
| 1029 |
+
|
| 1030 |
+
Responsibilities:
|
| 1031 |
+
|
| 1032 |
+
- Rewrite weakest answer
|
| 1033 |
+
- Generate improved pitch
|
| 1034 |
+
- Generate top 3 prep questions
|
| 1035 |
+
|
| 1036 |
+
### `core/json_utils.py`
|
| 1037 |
+
|
| 1038 |
+
Keeps frontend stable.
|
| 1039 |
+
|
| 1040 |
+
Responsibilities:
|
| 1041 |
+
|
| 1042 |
+
- Parse scorecard JSON
|
| 1043 |
+
- Repair malformed JSON
|
| 1044 |
+
- Extract fallback sections
|
| 1045 |
+
- Return default schema if needed
|
| 1046 |
+
|
| 1047 |
+
### `frontend/index.html`
|
| 1048 |
+
|
| 1049 |
+
Custom UI shell.
|
| 1050 |
+
|
| 1051 |
+
Responsibilities:
|
| 1052 |
+
|
| 1053 |
+
- Landing page
|
| 1054 |
+
- Mode selector
|
| 1055 |
+
- Form screens
|
| 1056 |
+
- Battle arena
|
| 1057 |
+
- Scorecard layout
|
| 1058 |
+
|
| 1059 |
+
### `frontend/script.js`
|
| 1060 |
+
|
| 1061 |
+
Frontend logic.
|
| 1062 |
+
|
| 1063 |
+
Responsibilities:
|
| 1064 |
+
|
| 1065 |
+
- Call `/api/*` endpoints via `fetch()` only — never model provider APIs
|
| 1066 |
+
- Manage UI state
|
| 1067 |
+
- Render messages and scorecards
|
| 1068 |
+
- Handle audio recording
|
| 1069 |
+
|
| 1070 |
+
### `frontend/styles.css`
|
| 1071 |
+
|
| 1072 |
+
Visual polish.
|
| 1073 |
+
|
| 1074 |
+
Responsibilities:
|
| 1075 |
+
|
| 1076 |
+
- Dark battle theme
|
| 1077 |
+
- Cards
|
| 1078 |
+
- Animations
|
| 1079 |
+
- Responsive layout
|
| 1080 |
+
- Score bars
|
| 1081 |
+
- Pressure meter
|
| 1082 |
+
|
| 1083 |
+
---
|
| 1084 |
+
|
| 1085 |
+
## 20. Development Phases
|
| 1086 |
+
|
| 1087 |
+
> **Authoritative roadmap:** [`PHASE_WISE_PLAN.md`](PHASE_WISE_PLAN.md) (14 phases, sponsor-model strategy). The summary below aligns with that plan.
|
| 1088 |
+
|
| 1089 |
+
This section describes the build order at a high level. The project can be built fast, but the documentation should not contradict itself by calling a full build a “one-day plan.”
|
| 1090 |
+
|
| 1091 |
+
### Phase 1 — Project Skeleton
|
| 1092 |
+
|
| 1093 |
+
Goal: create working HF Spaces-compatible Gradio Server app.
|
| 1094 |
+
|
| 1095 |
+
Deliverables:
|
| 1096 |
+
|
| 1097 |
+
- `app.py`
|
| 1098 |
+
- custom homepage
|
| 1099 |
+
- `requirements.txt`
|
| 1100 |
+
- `packages.txt`
|
| 1101 |
+
- README metadata
|
| 1102 |
+
|
| 1103 |
+
Success check:
|
| 1104 |
+
|
| 1105 |
+
```text
|
| 1106 |
+
python app.py
|
| 1107 |
+
```
|
| 1108 |
+
|
| 1109 |
+
opens the custom UI.
|
| 1110 |
+
|
| 1111 |
+
---
|
| 1112 |
+
|
| 1113 |
+
### Phase 2 — Model Router + Secrets (see PHASE_WISE_PLAN.md)
|
| 1114 |
+
|
| 1115 |
+
Goal: backend-only model routing with `DEFAULT_MODEL_MODE=premium_nvidia`.
|
| 1116 |
+
|
| 1117 |
+
---
|
| 1118 |
+
|
| 1119 |
+
### Phase 3 — Text Battle Engine
|
| 1120 |
+
|
| 1121 |
+
Goal: get core pitch battle working.
|
| 1122 |
+
|
| 1123 |
+
Deliverables:
|
| 1124 |
+
|
| 1125 |
+
- session manager
|
| 1126 |
+
- persona builder
|
| 1127 |
+
- attack tag selector
|
| 1128 |
+
- `/start_session`
|
| 1129 |
+
- `/chat_round`
|
| 1130 |
+
|
| 1131 |
+
Success check:
|
| 1132 |
+
|
| 1133 |
+
```text
|
| 1134 |
+
User can start a pitch battle and get hard follow-up questions.
|
| 1135 |
+
```
|
| 1136 |
+
|
| 1137 |
+
---
|
| 1138 |
+
|
| 1139 |
+
### Phase 4 — Scorecard Engine
|
| 1140 |
+
|
| 1141 |
+
Goal: turn chat into useful feedback.
|
| 1142 |
+
|
| 1143 |
+
Deliverables:
|
| 1144 |
+
|
| 1145 |
+
- scoring prompt
|
| 1146 |
+
- JSON parser
|
| 1147 |
+
- fallback parser
|
| 1148 |
+
- `/end_battle`
|
| 1149 |
+
- scorecard UI
|
| 1150 |
+
|
| 1151 |
+
Success check:
|
| 1152 |
+
|
| 1153 |
+
```text
|
| 1154 |
+
User ends battle and sees 6 scores, weakest answer, improved answer, and improved pitch.
|
| 1155 |
+
```
|
| 1156 |
+
|
| 1157 |
+
---
|
| 1158 |
+
|
| 1159 |
+
### Phase 5 — Custom UI Polish
|
| 1160 |
+
|
| 1161 |
+
Goal: make it look like a product.
|
| 1162 |
+
|
| 1163 |
+
Deliverables:
|
| 1164 |
+
|
| 1165 |
+
- battle arena layout
|
| 1166 |
+
- round counter
|
| 1167 |
+
- pressure meter
|
| 1168 |
+
- persona cards
|
| 1169 |
+
- attack tag display
|
| 1170 |
+
- score animation
|
| 1171 |
+
- loading states
|
| 1172 |
+
|
| 1173 |
+
Success check:
|
| 1174 |
+
|
| 1175 |
+
```text
|
| 1176 |
+
The app visually feels different from default Gradio.
|
| 1177 |
+
```
|
| 1178 |
+
|
| 1179 |
+
---
|
| 1180 |
+
|
| 1181 |
+
### Phase 6 — Voice Pitch Mode
|
| 1182 |
+
|
| 1183 |
+
Goal: Nemotron Omni primary voice path; faster-whisper transcription fallback only.
|
| 1184 |
+
|
| 1185 |
+
Deliverables:
|
| 1186 |
+
|
| 1187 |
+
- browser audio recorder
|
| 1188 |
+
- `/api/voice-pitch`
|
| 1189 |
+
- `transcription_client.py` (faster-whisper fallback)
|
| 1190 |
+
- transcript panel
|
| 1191 |
+
- text battle continuation from voice input
|
| 1192 |
+
|
| 1193 |
+
Success check:
|
| 1194 |
+
|
| 1195 |
+
```text
|
| 1196 |
+
User records a pitch and receives a hard first question via Nemotron (or whisper → text fallback).
|
| 1197 |
+
```
|
| 1198 |
+
|
| 1199 |
+
---
|
| 1200 |
+
|
| 1201 |
+
### Phase 7 — NVIDIA Nemotron Integration (primary)
|
| 1202 |
+
|
| 1203 |
+
Goal: Nemotron Omni 30B-A3B as default premium judge (backend-only API).
|
| 1204 |
+
|
| 1205 |
+
Deliverables:
|
| 1206 |
+
|
| 1207 |
+
- `nvidia_client.py`
|
| 1208 |
+
- model router default `premium_nvidia`
|
| 1209 |
+
- MiniCPM5-1B fallback on failure
|
| 1210 |
+
|
| 1211 |
+
Success check:
|
| 1212 |
+
|
| 1213 |
+
```text
|
| 1214 |
+
Backend returns sharp judge questions and scorecards via Nemotron; app degrades to MiniCPM5 on failure.
|
| 1215 |
+
```
|
| 1216 |
+
|
| 1217 |
+
---
|
| 1218 |
+
|
| 1219 |
+
### Phase 8 — Deployment
|
| 1220 |
+
|
| 1221 |
+
Goal: deploy to Hugging Face Spaces.
|
| 1222 |
+
|
| 1223 |
+
Deliverables:
|
| 1224 |
+
|
| 1225 |
+
- push repo
|
| 1226 |
+
- set non-secret config if needed
|
| 1227 |
+
- verify app runs publicly
|
| 1228 |
+
- test full flow
|
| 1229 |
+
|
| 1230 |
+
Success check:
|
| 1231 |
+
|
| 1232 |
+
```text
|
| 1233 |
+
HF Space link opens and completes one full battle.
|
| 1234 |
+
```
|
| 1235 |
+
|
| 1236 |
+
---
|
| 1237 |
+
|
| 1238 |
+
## 21. Minimum Viable Winning Version
|
| 1239 |
+
|
| 1240 |
+
If time gets tight, ship this:
|
| 1241 |
+
|
| 1242 |
+
- Pitch Battle only
|
| 1243 |
+
- Text input only
|
| 1244 |
+
- 3 personas
|
| 1245 |
+
- Attack tags
|
| 1246 |
+
- 6-round chat
|
| 1247 |
+
- NVIDIA Nemotron Omni via backend (MiniCPM5-1B fallback)
|
| 1248 |
+
- Scorecard
|
| 1249 |
+
- Improved answer
|
| 1250 |
+
- Custom UI
|
| 1251 |
+
- HF Spaces deployment
|
| 1252 |
+
|
| 1253 |
+
Then add Voice Mode after deployment.
|
| 1254 |
+
|
| 1255 |
+
---
|
| 1256 |
+
|
| 1257 |
+
## 22. Full Winning Version
|
| 1258 |
+
|
| 1259 |
+
Best final version:
|
| 1260 |
+
|
| 1261 |
+
- Pitch Battle
|
| 1262 |
+
- Deal Battle
|
| 1263 |
+
- Local Voice Mode
|
| 1264 |
+
- 3 pitch personas
|
| 1265 |
+
- 2 negotiation personas
|
| 1266 |
+
- Attack tags
|
| 1267 |
+
- Scorecard
|
| 1268 |
+
- Improved answer
|
| 1269 |
+
- Retry weak question
|
| 1270 |
+
- Custom Gradio Server UI
|
| 1271 |
+
- HF Spaces deployment
|
| 1272 |
+
- Documentation
|
| 1273 |
+
- Field Notes
|
| 1274 |
+
- Public prompts and rubrics
|
| 1275 |
+
- NVIDIA Nemotron + OpenBMB MiniCPM sponsor-model stack
|
| 1276 |
+
|
| 1277 |
+
---
|
| 1278 |
+
|
| 1279 |
+
## 23. Badge / Prize Strategy
|
| 1280 |
+
|
| 1281 |
+
| Target | How PitchFight AI Qualifies |
|
| 1282 |
+
|---|---|
|
| 1283 |
+
| Backyard AI | Built for student founders with a real high-pressure problem |
|
| 1284 |
+
| Best Demo | Voice + text pitch battle → scorecard in one polished flow |
|
| 1285 |
+
| Best Agent | Persona + memory + attack-tag planning + evaluation loop |
|
| 1286 |
+
| Off-Brand | Custom frontend using Gradio Server instead of default Gradio UI |
|
| 1287 |
+
| NVIDIA Nemotron Quest | Nemotron Omni as premium voice/multimodal judge |
|
| 1288 |
+
| OpenBMB Awards | MiniCPM-o, MiniCPM5-1B, MiniCPM-V integration |
|
| 1289 |
+
| Tiny Titan | MiniCPM5-1B Tiny Mode fallback |
|
| 1290 |
+
| Sharing is Caring | Publish prompts, rubrics, attack tags, sample scenarios |
|
| 1291 |
+
| Field Notes | Write short build post after deployment |
|
| 1292 |
+
| ~~Off the Grid~~ | **Not targeted** — sponsor APIs used intentionally for demo quality |
|
| 1293 |
+
|
| 1294 |
+
---
|
| 1295 |
+
|
| 1296 |
+
## 24. Environment and Safety
|
| 1297 |
+
|
| 1298 |
+
Use this `.env.example`:
|
| 1299 |
+
|
| 1300 |
+
```text
|
| 1301 |
+
APP_ENV=development
|
| 1302 |
+
MAX_ROUNDS=6
|
| 1303 |
+
|
| 1304 |
+
NVIDIA_API_KEY=
|
| 1305 |
+
NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
| 1306 |
+
NVIDIA_OMNI_MODEL=nvidia/nemotron-3-nano-omni-30b-a3b-reasoning
|
| 1307 |
+
|
| 1308 |
+
OPENBMB_API_KEY=
|
| 1309 |
+
OPENBMB_BASE_URL=
|
| 1310 |
+
MINICPM_OMNI_MODEL=openbmb/MiniCPM-o-4_5
|
| 1311 |
+
MINICPM_TEXT_MODEL=openbmb/MiniCPM5-1B
|
| 1312 |
+
MINICPM_VISION_MODEL=openbmb/MiniCPM-V-4.6
|
| 1313 |
+
|
| 1314 |
+
HF_TOKEN=
|
| 1315 |
+
WHISPER_FALLBACK_ENABLED=true
|
| 1316 |
+
WHISPER_MODEL_SIZE=tiny
|
| 1317 |
+
```
|
| 1318 |
+
|
| 1319 |
+
Use this `.gitignore`:
|
| 1320 |
+
|
| 1321 |
+
```text
|
| 1322 |
+
.env
|
| 1323 |
+
__pycache__/
|
| 1324 |
+
*.pyc
|
| 1325 |
+
.DS_Store
|
| 1326 |
+
node_modules/
|
| 1327 |
+
.gradio/
|
| 1328 |
+
models/
|
| 1329 |
+
*.gguf
|
| 1330 |
+
```
|
| 1331 |
+
|
| 1332 |
+
Security rules:
|
| 1333 |
+
|
| 1334 |
+
- Do not commit API keys.
|
| 1335 |
+
- Do not call model APIs from frontend JS — frontend uses `/api/*` only.
|
| 1336 |
+
- Store keys in `.env` locally and HF Space Secrets in deployment.
|
| 1337 |
+
- NVIDIA and OpenBMB sponsor APIs are the intended default inference path (backend-only).
|
| 1338 |
+
|
| 1339 |
+
---
|
| 1340 |
+
|
| 1341 |
+
## 25. Sample Demo Startup
|
| 1342 |
+
|
| 1343 |
+
```json
|
| 1344 |
+
{
|
| 1345 |
+
"name": "EventRadar AI",
|
| 1346 |
+
"problem": "Students miss hackathons, tech events, and startup opportunities because discovery is scattered across WhatsApp groups, LinkedIn, Luma, college clubs, and random websites.",
|
| 1347 |
+
"target_users": "College students, student founders, and early-stage builders.",
|
| 1348 |
+
"solution": "AI-powered event discovery that ranks opportunities based on skills, goals, location, and deadline urgency.",
|
| 1349 |
+
"why_ai": "The app does not just list events. It matches events to a student's profile and explains why each event is worth attending.",
|
| 1350 |
+
"competitors": "Luma, LinkedIn Events, WhatsApp groups, college club pages.",
|
| 1351 |
+
"traction": "Prototype built with scraped event data and ranking logic.",
|
| 1352 |
+
"ask": "Hackathon prize and mentor feedback."
|
| 1353 |
+
}
|
| 1354 |
+
```
|
| 1355 |
+
|
| 1356 |
+
---
|
| 1357 |
+
|
| 1358 |
+
## 26. Demo Flow
|
| 1359 |
+
|
| 1360 |
+
1. Open app.
|
| 1361 |
+
2. Click “Load Demo Startup”.
|
| 1362 |
+
3. Select “Hackathon Judge”.
|
| 1363 |
+
4. Choose “Text Battle” or “Voice Pitch”.
|
| 1364 |
+
5. AI asks:
|
| 1365 |
+
“Why does this need AI? A sorted event list with filters seems enough.”
|
| 1366 |
+
6. User gives a weak answer.
|
| 1367 |
+
7. AI selects an attack tag such as “Retention” and pushes back harder.
|
| 1368 |
+
8. End battle.
|
| 1369 |
+
9. Scorecard shows:
|
| 1370 |
+
- Overall score
|
| 1371 |
+
- 6 rubric scores
|
| 1372 |
+
- Weakest answer
|
| 1373 |
+
- Improved answer
|
| 1374 |
+
- Improved 60-second pitch
|
| 1375 |
+
- Top 3 prep questions
|
| 1376 |
+
|
| 1377 |
+
Final spoken line:
|
| 1378 |
+
|
| 1379 |
+
> **That is PitchFight AI — your first tough pitch should not be in front of a real judge.**
|
| 1380 |
+
|
| 1381 |
+
---
|
| 1382 |
+
|
| 1383 |
+
## 27. Final Implementation Checklist
|
| 1384 |
+
|
| 1385 |
+
### Core
|
| 1386 |
+
|
| 1387 |
+
- [ ] Custom Gradio Server app runs
|
| 1388 |
+
- [ ] Frontend loads from `frontend/index.html`
|
| 1389 |
+
- [ ] Frontend calls `/api/*` only (no direct model API calls)
|
| 1390 |
+
- [ ] Model router + Nemotron client wired (Phase 2+)
|
| 1391 |
+
- [ ] Session creation works
|
| 1392 |
+
- [ ] Persona prompt works
|
| 1393 |
+
- [ ] Attack tag selector works
|
| 1394 |
+
- [ ] Chat round works
|
| 1395 |
+
- [ ] Scorecard works
|
| 1396 |
+
- [ ] JSON parsing fallback works
|
| 1397 |
+
- [ ] Sample startup loads
|
| 1398 |
+
|
| 1399 |
+
### Voice
|
| 1400 |
+
|
| 1401 |
+
- [ ] Browser recorder works
|
| 1402 |
+
- [ ] Audio file reaches backend
|
| 1403 |
+
- [ ] faster-whisper transcribes locally
|
| 1404 |
+
- [ ] Transcript becomes usable context
|
| 1405 |
+
- [ ] AI asks voice-based first question
|
| 1406 |
+
|
| 1407 |
+
### NVIDIA Nemotron (primary)
|
| 1408 |
+
|
| 1409 |
+
- [ ] Backend-only Nemotron client uses HF Space Secrets / `.env`
|
| 1410 |
+
- [ ] MiniCPM5-1B fallback when Nemotron fails or keys missing
|
| 1411 |
+
- [ ] Model mode badge visible in UI
|
| 1412 |
+
|
| 1413 |
+
### Deployment
|
| 1414 |
+
|
| 1415 |
+
- [ ] HF Space created
|
| 1416 |
+
- [ ] README metadata added
|
| 1417 |
+
- [ ] `packages.txt` includes ffmpeg
|
| 1418 |
+
- [ ] App builds
|
| 1419 |
+
- [ ] Full flow tested publicly
|
| 1420 |
+
- [ ] No API keys exposed in frontend or public repo
|
| 1421 |
+
- [ ] Model router fallbacks tested when sponsor APIs fail
|
| 1422 |
+
|
| 1423 |
+
---
|
| 1424 |
+
|
| 1425 |
+
## 28. Final One-Line Description
|
| 1426 |
+
|
| 1427 |
+
**PitchFight AI is a voice-and-text AI sparring arena where student founders practice tough startup pitches, get grilled by realistic AI judges under 32B parameters, and receive a scorecard that shows exactly how to answer better.**
|
| 1428 |
+
|
| 1429 |
+
---
|
| 1430 |
+
|
| 1431 |
+
## 29. Final Project Promise
|
| 1432 |
+
|
| 1433 |
+
PitchFight AI is not trying to replace mentors or investors.
|
| 1434 |
+
|
| 1435 |
+
It prepares student founders for them.
|
| 1436 |
+
|
| 1437 |
+
> **The goal is simple: make the first hard question happen inside the app — not on stage.**
|
| 1438 |
+
|
| 1439 |
+
---
|
| 1440 |
+
|
| 1441 |
+
## 30. Reference Notes
|
| 1442 |
+
|
| 1443 |
+
- Off-the-Grid is **not** targeted; sponsor-model APIs (NVIDIA, OpenBMB) power the default high-quality demo.
|
| 1444 |
+
- All models ≤32B; Gradio Server + custom HTML/CSS/JS frontend.
|
| 1445 |
+
- `core/model_router.py` selects premium_nvidia, openbmb_omni, tiny_minicpm, vision_deck, whisper_fallback.
|
| 1446 |
+
- API keys only in HF Space Secrets / backend `os.getenv`.
|
| 1447 |
+
- See `PHASE_WISE_PLAN.md` for the 14-phase implementation roadmap.
|
docs/FIELD_NOTES.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PitchFight AI — Field Notes
|
| 2 |
+
|
| 3 |
+
## Why This Exists
|
| 4 |
+
|
| 5 |
+
Student founders often practice pitches alone or with friendly feedback. The first real pressure hits during judging, mentoring, investor calls, or sponsorship negotiations. PitchFight AI creates a safe but intense sparring arena before that moment.
|
| 6 |
+
|
| 7 |
+
## Strategic Pivot (Phase 0)
|
| 8 |
+
|
| 9 |
+
We moved from an **Off-the-Grid / local-first** strategy to a **high-demo sponsor-model** strategy:
|
| 10 |
+
|
| 11 |
+
- **Not targeting** Off-the-Grid badge.
|
| 12 |
+
- **Targeting** Backyard AI, Best Demo, Best Agent, Off-Brand, NVIDIA Nemotron Quest, OpenBMB Awards, Sharing is Caring, Field Notes.
|
| 13 |
+
- Still compliant: ≤32B models, Gradio, HF Spaces, no frontend API keys.
|
| 14 |
+
|
| 15 |
+
## What Was Built
|
| 16 |
+
|
| 17 |
+
### Phase 1 (Complete)
|
| 18 |
+
|
| 19 |
+
- Hugging Face Spaces-ready Gradio Server skeleton
|
| 20 |
+
- Custom battle arena frontend (HTML/CSS/JS)
|
| 21 |
+
- In-memory session manager
|
| 22 |
+
- Persona + attack-tag routing
|
| 23 |
+
- Mock scoring and feedback (placeholder for real models)
|
| 24 |
+
- Config files for personas, rubric, samples
|
| 25 |
+
|
| 26 |
+
### Planned (Phases 2–14)
|
| 27 |
+
|
| 28 |
+
- Model router + NVIDIA / OpenBMB clients
|
| 29 |
+
- Real Pitch Battle + Deal Battle engines
|
| 30 |
+
- Voice pitch mode (Nemotron primary, whisper fallback)
|
| 31 |
+
- Pitch deck critique (MiniCPM-V)
|
| 32 |
+
- Retry weakest question + downloadable report
|
| 33 |
+
- UI polish + public HF Space deployment
|
| 34 |
+
|
| 35 |
+
## What the Models Will Do
|
| 36 |
+
|
| 37 |
+
| Model | Job |
|
| 38 |
+
|---|---|
|
| 39 |
+
| Nemotron Omni | Premium judge, voice, scoring, rewrites |
|
| 40 |
+
| MiniCPM-o | OpenBMB multimodal mode |
|
| 41 |
+
| MiniCPM5-1B | Tiny/fallback text |
|
| 42 |
+
| MiniCPM-V 4.6 | Slide critique |
|
| 43 |
+
| faster-whisper | Backup transcription |
|
| 44 |
+
|
| 45 |
+
## What Worked
|
| 46 |
+
|
| 47 |
+
- Clean separation: frontend → Gradio API → model router → clients
|
| 48 |
+
- Attack-tag driven pressure design
|
| 49 |
+
- Runnable Phase 1 skeleton for fast iteration
|
| 50 |
+
- Clear phase-wise plan for hackathon execution
|
| 51 |
+
|
| 52 |
+
## What Will Be Improved
|
| 53 |
+
|
| 54 |
+
- Replace mock APIs with Nemotron + MiniCPM routing (Phase 2–5)
|
| 55 |
+
- Voice mode as headline demo feature (Phase 7)
|
| 56 |
+
- Deal Battle + deck critique (Phases 8–10)
|
| 57 |
+
- Product polish and public deployment (Phases 12–14)
|
| 58 |
+
|
| 59 |
+
## Build Log
|
| 60 |
+
|
| 61 |
+
### Phase 2 — Model Router + Secrets Setup (2026-06-08)
|
| 62 |
+
|
| 63 |
+
**What shipped:**
|
| 64 |
+
- `core/nvidia_client.py` — backend-only NVIDIA Nemotron client using OpenAI-compatible SDK
|
| 65 |
+
- `core/model_router.py` — central routing layer (premium_nvidia live; other modes return clean stubs)
|
| 66 |
+
- `core/minicpm_client.py` — health_check stub (Phase 9 placeholder)
|
| 67 |
+
- `core/vision_client.py` — health_check stub (Phase 10 placeholder)
|
| 68 |
+
- `core/transcription_client.py` — health_check stub (Phase 7 placeholder)
|
| 69 |
+
- `scripts/test_nvidia_client.py` — isolated connectivity test (exits 0/1, no key exposure)
|
| 70 |
+
- `GET /api/model-health` — config status endpoint, keys never exposed
|
| 71 |
+
- `requirements.txt` updated: added `openai>=1.0.0`, `httpx`, `requests`
|
| 72 |
+
|
| 73 |
+
**What worked:**
|
| 74 |
+
- NVIDIA API key read from `NVIDIA_API_KEY` env var only — no hardcoding, no key leak
|
| 75 |
+
- `health_check()` correctly reports configured/not-configured without printing key
|
| 76 |
+
- `generate_nemotron_response()` returns clean judge question on first real test
|
| 77 |
+
- `get_model_health()` returns correct provider status for all four providers
|
| 78 |
+
- Error handling catches timeout, connection, and status errors cleanly
|
| 79 |
+
|
| 80 |
+
**What surprised us:**
|
| 81 |
+
- `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` is a **reasoning model**: it writes an internal chain-of-thought before the final answer. When `max_tokens` is too small (< ~400 for opponent mode), all tokens are consumed by the reasoning trace and `message.content` is `None`.
|
| 82 |
+
- The fix: increase `max_tokens` defaults (opponent: 500, scorecard: 2000, rewrite: 800) and add a `reasoning_content` fallback for edge cases.
|
| 83 |
+
- The final answer in `message.content` is sharp, in-character, and correctly short — the reasoning trace stays internal.
|
| 84 |
+
|
| 85 |
+
**What the model returned (live test):**
|
| 86 |
+
> "How does your AI differentiate between similar events and avoid false positives in ranking?"
|
| 87 |
+
|
| 88 |
+
**What we'd do differently:**
|
| 89 |
+
- Test with the actual model before setting token defaults — reasoning models have different budget dynamics than completion models.
|
| 90 |
+
|
| 91 |
+
**What's next:**
|
| 92 |
+
- Phase 3: Wire `model_router.generate_opponent_response()` into `handle_start_session` and `handle_chat_round` in `core/api_handlers.py` — replacing hardcoded mock followups with live Nemotron responses.
|
docs/MODELS_FINAL.md
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PitchFight AI — Final Model Strategy
|
| 2 |
+
|
| 3 |
+
## High-Demo Sponsor-Model Stack Under 32B Parameters
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## 1. Final Model Philosophy
|
| 8 |
+
|
| 9 |
+
PitchFight AI prioritizes:
|
| 10 |
+
|
| 11 |
+
- **Best demonstrative experience** — the app must feel like a real sparring arena, not a chatbot wrapper.
|
| 12 |
+
- **Voice-first pitch practice** — founders pitch out loud; the judge responds with pressure, not generic advice.
|
| 13 |
+
- **High-quality AI judge behavior** — persona-locked opponents, attack tags, memory, and sharp follow-ups.
|
| 14 |
+
- **Sponsor-model alignment** — NVIDIA Nemotron Omni and OpenBMB MiniCPM models used intentionally and visibly.
|
| 15 |
+
- **Strong fallback reliability** — if a premium endpoint fails, the app degrades gracefully instead of breaking the demo.
|
| 16 |
+
- **Models under 32B parameters** — every reasoning model in the stack complies with the Build Small Hackathon cap.
|
| 17 |
+
|
| 18 |
+
The project is **not** targeting the **Off-the-Grid** badge. This is intentional — Off-the-Grid is not part of this build.
|
| 19 |
+
|
| 20 |
+
The app **does** follow the required hackathon rules:
|
| 21 |
+
|
| 22 |
+
- **≤32B models** for all core reasoning
|
| 23 |
+
- **Gradio** + **Hugging Face Spaces** hosting
|
| 24 |
+
- **Demo-first** execution with a custom non-default UI
|
| 25 |
+
|
| 26 |
+
**Target badges/prizes:** Backyard AI, Best Demo, Best Agent, Off-Brand, NVIDIA Nemotron Quest, OpenBMB Awards, Sharing is Caring, and Field Notes.
|
| 27 |
+
|
| 28 |
+
Cloud/sponsor model APIs are **allowed and expected** for this build when called **backend-only** with keys in HF Space Secrets. They are not forbidden — they are the default path to demo quality.
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## 2. Final Default Model Stack
|
| 33 |
+
|
| 34 |
+
| Layer | Final Model | Default? | Purpose |
|
| 35 |
+
|---|---|---:|---|
|
| 36 |
+
| Premium reasoning + voice/multimodal judge | NVIDIA Nemotron 3 Nano Omni 30B-A3B | Yes | Main voice/text judge, pitch battle, deal battle, scorecard, voice critique |
|
| 37 |
+
| OpenBMB omni mode | MiniCPM-o 4.5 | Secondary | OpenBMB-aligned omni mode for voice/text/multimodal pitch interaction |
|
| 38 |
+
| Tiny fallback | MiniCPM5-1B | Fallback | Fast text battle, scorecard fallback, Tiny Mode |
|
| 39 |
+
| Vision/deck critique | MiniCPM-V 4.6 | Feature mode | Pitch deck screenshot critique and visual pitch feedback |
|
| 40 |
+
| Audio fallback | faster-whisper / Whisper Tiny or Base | Fallback | Audio transcription if direct audio handling is unstable |
|
| 41 |
+
| Optional multilingual | Cohere Aya or suitable small multilingual model | Optional | Hindi/Kannada/regional pitch practice if added |
|
| 42 |
+
| Optional visuals | FLUX / Black Forest Labs model | Optional assets only | Persona art or visual assets, not core reasoning |
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
## 3. Primary Model — NVIDIA Nemotron 3 Nano Omni 30B-A3B
|
| 47 |
+
|
| 48 |
+
> **Deep dive:** How Omni processes raw audio, paralinguistic signals, hesitation detection, and PitchFight voice paths A/B — see [`NEMOTRON_OMNI_AUDIO.md`](NEMOTRON_OMNI_AUDIO.md).
|
| 49 |
+
|
| 50 |
+
### Role
|
| 51 |
+
|
| 52 |
+
Primary premium model for the strongest demo.
|
| 53 |
+
|
| 54 |
+
### Use cases
|
| 55 |
+
|
| 56 |
+
- Voice pitch understanding
|
| 57 |
+
- AI opponent responses
|
| 58 |
+
- Pitch Battle
|
| 59 |
+
- Deal Battle
|
| 60 |
+
- Multimodal pitch judging
|
| 61 |
+
- Scorecard generation
|
| 62 |
+
- Weakest answer rewrite
|
| 63 |
+
- Improved pitch generation
|
| 64 |
+
- Voice-specific feedback
|
| 65 |
+
- Optional pitch deck + spoken pitch critique
|
| 66 |
+
|
| 67 |
+
### Why
|
| 68 |
+
|
| 69 |
+
- Strong sponsor alignment (NVIDIA Nemotron Quest)
|
| 70 |
+
- Multimodal / omni capability
|
| 71 |
+
- Under 32B total parameter rule
|
| 72 |
+
- Best fit for high-quality voice demo
|
| 73 |
+
- Targets NVIDIA Nemotron Quest
|
| 74 |
+
|
| 75 |
+
### Implementation
|
| 76 |
+
|
| 77 |
+
- Backend-only API call.
|
| 78 |
+
- API key stored in HF Space Secrets.
|
| 79 |
+
- Frontend never calls NVIDIA directly.
|
| 80 |
+
- Model call goes through `core/nvidia_client.py` and `core/model_router.py`.
|
| 81 |
+
|
| 82 |
+
### Environment variables
|
| 83 |
+
|
| 84 |
+
```text
|
| 85 |
+
NVIDIA_API_KEY=
|
| 86 |
+
NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
| 87 |
+
NVIDIA_OMNI_MODEL=nvidia/nemotron-3-nano-omni-30b-a3b-reasoning
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
### Suggested settings
|
| 91 |
+
|
| 92 |
+
| Task | Temperature | Max Tokens |
|
| 93 |
+
|---|---:|---:|
|
| 94 |
+
| Opponent response | 0.7 | 250 |
|
| 95 |
+
| Scorecard | 0.2 | 1200 |
|
| 96 |
+
| Rewrite | 0.45 | 500 |
|
| 97 |
+
|
| 98 |
+
---
|
| 99 |
+
|
| 100 |
+
## 4. OpenBMB Mode — MiniCPM-o 4.5
|
| 101 |
+
|
| 102 |
+
### Role
|
| 103 |
+
|
| 104 |
+
OpenBMB-aligned advanced omni model.
|
| 105 |
+
|
| 106 |
+
### Use cases
|
| 107 |
+
|
| 108 |
+
- Alternative voice judge mode
|
| 109 |
+
- Speech/text pitch interaction
|
| 110 |
+
- OpenBMB sponsor route
|
| 111 |
+
- Model comparison mode
|
| 112 |
+
- Optional multimodal interaction
|
| 113 |
+
|
| 114 |
+
### Why
|
| 115 |
+
|
| 116 |
+
- 9B model, under 32B
|
| 117 |
+
- Strong OpenBMB alignment
|
| 118 |
+
- Supports omnimodal interaction
|
| 119 |
+
- Useful for OpenBMB Awards
|
| 120 |
+
- Smaller than Nemotron
|
| 121 |
+
|
| 122 |
+
### Implementation
|
| 123 |
+
|
| 124 |
+
- Backend-only API call or supported official endpoint.
|
| 125 |
+
- Keep separate from NVIDIA path.
|
| 126 |
+
- Can be exposed in UI as **“OpenBMB Omni Mode”**.
|
| 127 |
+
|
| 128 |
+
### Environment variables
|
| 129 |
+
|
| 130 |
+
```text
|
| 131 |
+
OPENBMB_API_KEY=
|
| 132 |
+
OPENBMB_BASE_URL=
|
| 133 |
+
MINICPM_OMNI_MODEL=openbmb/MiniCPM-o-4_5
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## 5. Tiny / Fallback Mode — MiniCPM5-1B
|
| 139 |
+
|
| 140 |
+
### Role
|
| 141 |
+
|
| 142 |
+
Reliable small fallback model.
|
| 143 |
+
|
| 144 |
+
### Use cases
|
| 145 |
+
|
| 146 |
+
- Text Pitch Battle fallback
|
| 147 |
+
- Deal Battle fallback
|
| 148 |
+
- Scorecard fallback
|
| 149 |
+
- Tiny Mode
|
| 150 |
+
- Low-latency text mode
|
| 151 |
+
- Tiny Titan angle if shipped well
|
| 152 |
+
|
| 153 |
+
### Why
|
| 154 |
+
|
| 155 |
+
- Very small
|
| 156 |
+
- OpenBMB-aligned
|
| 157 |
+
- Good backup if premium endpoints are slow
|
| 158 |
+
- Keeps app usable during API failures
|
| 159 |
+
|
| 160 |
+
### Implementation
|
| 161 |
+
|
| 162 |
+
- Use through backend model router.
|
| 163 |
+
- Can be local or hosted depending on final deployment.
|
| 164 |
+
- If using API, clearly do not claim Off-the-Grid.
|
| 165 |
+
- If local, can be mentioned as optional local fallback.
|
| 166 |
+
|
| 167 |
+
### Environment variables
|
| 168 |
+
|
| 169 |
+
```text
|
| 170 |
+
MINICPM_TEXT_MODEL=openbmb/MiniCPM5-1B
|
| 171 |
+
MINICPM_TEXT_BACKEND=api_or_local
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
---
|
| 175 |
+
|
| 176 |
+
## 6. Vision Mode — MiniCPM-V 4.6
|
| 177 |
+
|
| 178 |
+
### Role
|
| 179 |
+
|
| 180 |
+
Pitch deck screenshot critique.
|
| 181 |
+
|
| 182 |
+
### Use cases
|
| 183 |
+
|
| 184 |
+
- Upload slide screenshot
|
| 185 |
+
- Extract visible claims
|
| 186 |
+
- Critique clarity
|
| 187 |
+
- Critique problem/solution/market/ask
|
| 188 |
+
- Generate judge questions from slide
|
| 189 |
+
|
| 190 |
+
### Why
|
| 191 |
+
|
| 192 |
+
- OpenBMB-aligned
|
| 193 |
+
- Small visual model
|
| 194 |
+
- Strong demo feature
|
| 195 |
+
- Adds multimodal value beyond chat
|
| 196 |
+
|
| 197 |
+
### Implementation
|
| 198 |
+
|
| 199 |
+
- Add `/deck_critique` endpoint.
|
| 200 |
+
- Frontend image upload.
|
| 201 |
+
- Backend routes image to MiniCPM-V or NVIDIA Omni.
|
| 202 |
+
- Show slide critique card.
|
| 203 |
+
|
| 204 |
+
### Environment variables
|
| 205 |
+
|
| 206 |
+
```text
|
| 207 |
+
MINICPM_VISION_MODEL=openbmb/MiniCPM-V-4.6
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
---
|
| 211 |
+
|
| 212 |
+
## 7. Audio Fallback — faster-whisper
|
| 213 |
+
|
| 214 |
+
### Role
|
| 215 |
+
|
| 216 |
+
Backup transcription.
|
| 217 |
+
|
| 218 |
+
### Use cases
|
| 219 |
+
|
| 220 |
+
- Convert audio to transcript if direct omni audio fails
|
| 221 |
+
- Debug voice input
|
| 222 |
+
- Allow browser-recorded audio to become text reliably
|
| 223 |
+
|
| 224 |
+
### Why
|
| 225 |
+
|
| 226 |
+
- Reliable
|
| 227 |
+
- Simple
|
| 228 |
+
- Helps prevent voice demo failure
|
| 229 |
+
|
| 230 |
+
### Implementation
|
| 231 |
+
|
| 232 |
+
- **Not** the main judge.
|
| 233 |
+
- Only audio-to-text fallback.
|
| 234 |
+
- Transcript then goes into selected reasoning model.
|
| 235 |
+
|
| 236 |
+
### Environment variables
|
| 237 |
+
|
| 238 |
+
```text
|
| 239 |
+
WHISPER_FALLBACK_ENABLED=true
|
| 240 |
+
WHISPER_MODEL_SIZE=tiny
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
---
|
| 244 |
+
|
| 245 |
+
## 8. Model Router
|
| 246 |
+
|
| 247 |
+
Routing logic for `core/model_router.py`:
|
| 248 |
+
|
| 249 |
+
### Text Pitch Battle
|
| 250 |
+
|
| 251 |
+
```text
|
| 252 |
+
startup context + persona + attack tag
|
| 253 |
+
→ NVIDIA Nemotron by default
|
| 254 |
+
→ MiniCPM5-1B fallback if NVIDIA fails
|
| 255 |
+
```
|
| 256 |
+
|
| 257 |
+
### Voice Pitch
|
| 258 |
+
|
| 259 |
+
```text
|
| 260 |
+
audio
|
| 261 |
+
→ NVIDIA Nemotron Omni primary
|
| 262 |
+
→ if fails: faster-whisper transcription
|
| 263 |
+
→ MiniCPM5-1B or Nemotron text path
|
| 264 |
+
```
|
| 265 |
+
|
| 266 |
+
### Deal Battle
|
| 267 |
+
|
| 268 |
+
```text
|
| 269 |
+
negotiation context
|
| 270 |
+
→ NVIDIA Nemotron by default
|
| 271 |
+
→ MiniCPM5-1B fallback
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
### Scorecard
|
| 275 |
+
|
| 276 |
+
```text
|
| 277 |
+
conversation history
|
| 278 |
+
→ NVIDIA Nemotron by default
|
| 279 |
+
→ MiniCPM5-1B fallback
|
| 280 |
+
→ json_utils parser + fallback_scorecard()
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
### Deck Critique
|
| 284 |
+
|
| 285 |
+
```text
|
| 286 |
+
image
|
| 287 |
+
→ MiniCPM-V 4.6 or NVIDIA Omni
|
| 288 |
+
→ slide critique + judge questions
|
| 289 |
+
```
|
| 290 |
+
|
| 291 |
+
### OpenBMB Mode
|
| 292 |
+
|
| 293 |
+
```text
|
| 294 |
+
user-selected
|
| 295 |
+
→ MiniCPM-o 4.5
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
### Tiny Mode
|
| 299 |
+
|
| 300 |
+
```text
|
| 301 |
+
user-selected or automatic fallback
|
| 302 |
+
→ MiniCPM5-1B
|
| 303 |
+
```
|
| 304 |
+
|
| 305 |
+
---
|
| 306 |
+
|
| 307 |
+
## 9. Model-to-File Mapping
|
| 308 |
+
|
| 309 |
+
| File | Responsibility | Phase 2 Status |
|
| 310 |
+
|---|---|---|
|
| 311 |
+
| `core/model_router.py` | Routes tasks to NVIDIA, MiniCPM-o, MiniCPM5, MiniCPM-V, or Whisper fallback | **Created** — premium_nvidia live; other modes stub |
|
| 312 |
+
| `core/nvidia_client.py` | Calls NVIDIA Nemotron Omni backend-only | **Created** — live, tested |
|
| 313 |
+
| `core/minicpm_client.py` | Calls MiniCPM-o and MiniCPM5 paths | **Stub** — health_check only (Phase 9) |
|
| 314 |
+
| `core/vision_client.py` | Handles MiniCPM-V / deck critique model calls | **Stub** — health_check only (Phase 10) |
|
| 315 |
+
| `core/transcription_client.py` | Handles faster-whisper fallback transcription | **Stub** — health_check only (Phase 7) |
|
| 316 |
+
| `core/session_manager.py` | Stores selected model mode and conversation history | Existing — unchanged |
|
| 317 |
+
| `core/persona_builder.py` | Builds prompt before model call | Existing — unchanged |
|
| 318 |
+
| `core/attack_tags.py` | Selects pressure direction before model call | Existing — unchanged |
|
| 319 |
+
| `core/scoring_engine.py` | Builds scorecard prompt and calls model_router | Existing — still mock (Phase 5) |
|
| 320 |
+
|
| 321 |
+
> **Reasoning model token note:** `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` writes an internal chain-of-thought before producing `message.content`. Confirmed defaults: opponent=500, scorecard=2000, rewrite=800 tokens.
|
| 322 |
+
| `core/json_utils.py` | Repairs/parses model-generated scorecards |
|
| 323 |
+
|
| 324 |
+
---
|
| 325 |
+
|
| 326 |
+
## 10. Environment Variables
|
| 327 |
+
|
| 328 |
+
Final environment block:
|
| 329 |
+
|
| 330 |
+
```text
|
| 331 |
+
APP_ENV=development
|
| 332 |
+
MAX_ROUNDS=6
|
| 333 |
+
|
| 334 |
+
DEFAULT_MODEL_MODE=premium_nvidia
|
| 335 |
+
|
| 336 |
+
NVIDIA_API_KEY=
|
| 337 |
+
NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
| 338 |
+
NVIDIA_OMNI_MODEL=nvidia/nemotron-3-nano-omni-30b-a3b-reasoning
|
| 339 |
+
|
| 340 |
+
OPENBMB_API_KEY=
|
| 341 |
+
OPENBMB_BASE_URL=
|
| 342 |
+
MINICPM_OMNI_MODEL=openbmb/MiniCPM-o-4_5
|
| 343 |
+
MINICPM_TEXT_MODEL=openbmb/MiniCPM5-1B
|
| 344 |
+
MINICPM_VISION_MODEL=openbmb/MiniCPM-V-4.6
|
| 345 |
+
|
| 346 |
+
HF_TOKEN=
|
| 347 |
+
|
| 348 |
+
WHISPER_FALLBACK_ENABLED=true
|
| 349 |
+
WHISPER_MODEL_SIZE=tiny
|
| 350 |
+
|
| 351 |
+
ENABLE_DECK_CRITIQUE=true
|
| 352 |
+
ENABLE_DEAL_BATTLE=true
|
| 353 |
+
ENABLE_VOICE_MODE=true
|
| 354 |
+
```
|
| 355 |
+
|
| 356 |
+
**Security rules:**
|
| 357 |
+
|
| 358 |
+
- API keys must be **backend-only**.
|
| 359 |
+
- Use **HF Space Secrets** in production.
|
| 360 |
+
- Never expose keys in frontend JS.
|
| 361 |
+
- Do not commit `.env`.
|
| 362 |
+
|
| 363 |
+
---
|
| 364 |
+
|
| 365 |
+
## 11. Requirements Strategy
|
| 366 |
+
|
| 367 |
+
### Skeleton (always)
|
| 368 |
+
|
| 369 |
+
```text
|
| 370 |
+
gradio
|
| 371 |
+
fastapi
|
| 372 |
+
uvicorn
|
| 373 |
+
pydantic
|
| 374 |
+
python-dotenv
|
| 375 |
+
numpy
|
| 376 |
+
```
|
| 377 |
+
|
| 378 |
+
### API/model clients
|
| 379 |
+
|
| 380 |
+
```text
|
| 381 |
+
openai
|
| 382 |
+
requests
|
| 383 |
+
httpx
|
| 384 |
+
```
|
| 385 |
+
|
| 386 |
+
### Voice fallback
|
| 387 |
+
|
| 388 |
+
```text
|
| 389 |
+
faster-whisper
|
| 390 |
+
```
|
| 391 |
+
|
| 392 |
+
### Audio/system
|
| 393 |
+
|
| 394 |
+
```text
|
| 395 |
+
packages.txt includes ffmpeg
|
| 396 |
+
```
|
| 397 |
+
|
| 398 |
+
### Optional image handling
|
| 399 |
+
|
| 400 |
+
```text
|
| 401 |
+
pillow
|
| 402 |
+
```
|
| 403 |
+
|
| 404 |
+
**Note:** Do not add heavy local inference dependencies unless needed. This build prioritizes **API-backed sponsor models** for demo quality. Local inference (e.g. llama-cpp-python) is optional for Tiny Mode fallback only — not the default path.
|
| 405 |
+
|
| 406 |
+
---
|
| 407 |
+
|
| 408 |
+
## 12. Model Architecture Diagram
|
| 409 |
+
|
| 410 |
+
```mermaid
|
| 411 |
+
flowchart TD
|
| 412 |
+
A[User Text Pitch] --> B[Custom Gradio UI]
|
| 413 |
+
C[User Voice Pitch] --> B
|
| 414 |
+
D[Pitch Deck Screenshot] --> B
|
| 415 |
+
|
| 416 |
+
B --> E[Gradio Server Backend]
|
| 417 |
+
E --> F[Model Router]
|
| 418 |
+
|
| 419 |
+
F --> G[NVIDIA Nemotron Omni\nPremium Judge Mode]
|
| 420 |
+
F --> H[MiniCPM-o 4.5\nOpenBMB Omni Mode]
|
| 421 |
+
F --> I[MiniCPM5-1B\nTiny/Fallback Mode]
|
| 422 |
+
F --> J[MiniCPM-V 4.6\nDeck Critique Mode]
|
| 423 |
+
F --> K[faster-whisper\nAudio Fallback]
|
| 424 |
+
|
| 425 |
+
G --> L[Opponent Response + Scorecard]
|
| 426 |
+
H --> L
|
| 427 |
+
I --> L
|
| 428 |
+
J --> M[Slide Critique + Judge Questions]
|
| 429 |
+
K --> N[Transcript]
|
| 430 |
+
N --> F
|
| 431 |
+
|
| 432 |
+
L --> O[Battle Arena UI]
|
| 433 |
+
M --> O
|
| 434 |
+
```
|
docs/NEMOTRON_OMNI_AUDIO.md
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NVIDIA Nemotron 3 Nano Omni — Audio & Voice Architecture
|
| 2 |
+
|
| 3 |
+
Technical reference for how **NVIDIA Nemotron 3 Nano Omni 30B-A3B** processes pitch audio in PitchFight AI, what it can infer about hesitation and confidence, and how the app should wire voice mode.
|
| 4 |
+
|
| 5 |
+
> **PitchFight context:** Nemotron Omni is the **primary premium judge** (backend-only API). Raw audio goes to the model on the primary path. **faster-whisper** is transcription fallback only — not the main judge. Frontend never calls NVIDIA directly; audio flows through `/api/voice-pitch`.
|
| 6 |
+
|
| 7 |
+
See also: [`MODELS_FINAL.md`](MODELS_FINAL.md) · [`BACKEND_API.md`](BACKEND_API.md) · [`PHASE_WISE_PLAN.md`](PHASE_WISE_PLAN.md)
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## 1. Model Identity
|
| 12 |
+
|
| 13 |
+
| Field | Value |
|
| 14 |
+
|---|---|
|
| 15 |
+
| **Model** | NVIDIA Nemotron 3 Nano Omni 30B-A3B |
|
| 16 |
+
| **Env var** | `NVIDIA_OMNI_MODEL=nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` |
|
| 17 |
+
| **Parameter cap** | ~30B — complies with Build Small ≤32B rule |
|
| 18 |
+
| **Access** | Backend-only via `NVIDIA_API_KEY` + `NVIDIA_BASE_URL` |
|
| 19 |
+
| **Role in PitchFight** | Primary voice/text/multimodal judge, scorecard, rewrites |
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## 2. How the Model Works With Audio
|
| 24 |
+
|
| 25 |
+
### Unified encoder–projector–decoder design
|
| 26 |
+
|
| 27 |
+
NeMoTron Nano Omni uses a **unified multimodal architecture**:
|
| 28 |
+
|
| 29 |
+
- **Language backbone:** Nemotron 3 Nano 30B-A3B (reasoning)
|
| 30 |
+
- **Vision encoder:** C-RADIOv4-H
|
| 31 |
+
- **Audio encoder:** Parakeet-TDT-0.6B-v2
|
| 32 |
+
|
| 33 |
+
Modality-specific encoders connect into the LLM backbone through **lightweight projectors**. Audio and text share the same reasoning loop inside the backbone.
|
| 34 |
+
|
| 35 |
+
### Exact pipeline when an audio file hits the model
|
| 36 |
+
|
| 37 |
+
```text
|
| 38 |
+
Your WAV / audio file (raw bytes)
|
| 39 |
+
↓
|
| 40 |
+
Parakeet-TDT-0.6B-v2 (dedicated audio encoder)
|
| 41 |
+
↓ converts audio into audio tokens
|
| 42 |
+
Lightweight projector (aligns audio tokens to LLM embedding space)
|
| 43 |
+
↓
|
| 44 |
+
Nemotron 30B-A3B backbone (reasoning happens here)
|
| 45 |
+
↓
|
| 46 |
+
Structured text output (transcript, observations, judge question, etc.)
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### What this is *not*
|
| 50 |
+
|
| 51 |
+
This is **fundamentally different** from Whisper → LLM:
|
| 52 |
+
|
| 53 |
+
| Approach | Flow |
|
| 54 |
+
|---|---|
|
| 55 |
+
| **Whisper → LLM** | Audio → text transcript → text-only reasoning |
|
| 56 |
+
| **Nemotron Omni (primary path)** | Audio → audio tokens → projected embeddings → unified reasoning |
|
| 57 |
+
|
| 58 |
+
On the Nemotron primary path, the model does **not** need to transcribe to plain text first before reasoning. Audio signals are preserved as tokens that flow into the backbone.
|
| 59 |
+
|
| 60 |
+
### Paralinguistic coverage
|
| 61 |
+
|
| 62 |
+
Parakeet provides strong ASR. NVIDIA extends the audio surface with **Granary** and **Music Flamingo** extensions, broadening coverage beyond bare transcription to include:
|
| 63 |
+
|
| 64 |
+
- Paralinguistic signals (tone, pace, stress, hesitation sounds)
|
| 65 |
+
- Music and ambient sound understanding
|
| 66 |
+
|
| 67 |
+
**Paralinguistic** = signals beyond literal words — how something is said, not only what is said. This is what makes the model competitive on voice benchmarks and useful for media understanding, not just dictation.
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
## 3. Quick Answers (FAQ)
|
| 72 |
+
|
| 73 |
+
| Question | Answer |
|
| 74 |
+
|---|---|
|
| 75 |
+
| Does the raw audio file go to Nemotron? | **Yes** — Parakeet processes native audio input. |
|
| 76 |
+
| Does it get transcribed to text first? | **No** on the primary Omni path — audio tokens flow directly into reasoning. |
|
| 77 |
+
| Does it use embeddings? | **Yes** — audio tokens are projected into the LLM embedding space. |
|
| 78 |
+
| Can it detect hesitation? | **Yes** — linguistic hesitation and filler words reliably. |
|
| 79 |
+
| Can it detect confidence from voice tone? | **Partially** — paralinguistic inference, not clinical prosody analysis. |
|
| 80 |
+
| Is this strong enough for a pitch trainer? | **Yes** — linguistic hesitation is the most actionable signal for founders. |
|
| 81 |
+
| How do you make it accurate? | **Prompt explicitly** for delivery observations alongside content. |
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## 4. Hesitation & Confidence Detection
|
| 86 |
+
|
| 87 |
+
### Partially yes — with important caveats
|
| 88 |
+
|
| 89 |
+
#### What it can detect reliably
|
| 90 |
+
|
| 91 |
+
The Parakeet encoder (with Granary / Music Flamingo extensions) processes **paralinguistic features**. When preserved as audio tokens in the backbone, the LLM can reason about:
|
| 92 |
+
|
| 93 |
+
| Signal | Example |
|
| 94 |
+
|---|---|
|
| 95 |
+
| Filler words | "um", "uh", "like", "you know" |
|
| 96 |
+
| Long pauses | Mid-sentence gaps before a key claim |
|
| 97 |
+
| Trailing off | Weak endings on important statements |
|
| 98 |
+
| Rushed speech | Compressed delivery on uncertain phrases |
|
| 99 |
+
| Repetition / self-correction | Restarts, hedging, reformulations |
|
| 100 |
+
|
| 101 |
+
For pitch training, **linguistic hesitation is often the most useful signal**:
|
| 102 |
+
|
| 103 |
+
> *"Um… I think… maybe the market is… around 10 million?"*
|
| 104 |
+
> vs
|
| 105 |
+
> *"The addressable market is $10M based on X."*
|
| 106 |
+
|
| 107 |
+
The model can flag the first pattern when prompted correctly.
|
| 108 |
+
|
| 109 |
+
#### What it cannot detect reliably
|
| 110 |
+
|
| 111 |
+
Pure acoustic / physiological confidence signals require dedicated prosody tools (e.g. Praat, specialized emotion models), not a general-purpose multimodal LLM:
|
| 112 |
+
|
| 113 |
+
- Heart rate change
|
| 114 |
+
- Micro-tremors in voice
|
| 115 |
+
- Subtle pitch drops indicating clinical-level uncertainty
|
| 116 |
+
|
| 117 |
+
**Do not claim** heart-rate or micro-tremor detection in PitchFight UI or demo script.
|
| 118 |
+
|
| 119 |
+
#### Honest positioning
|
| 120 |
+
|
| 121 |
+
NeMoTron can detect **linguistic hesitation** (words and sounds of hesitation) and make **reasonable inferences** about apparent confidence from speech patterns. It cannot do **clinical-grade prosody analysis**. For a student pitch trainer, that is the right tradeoff.
|
| 122 |
+
|
| 123 |
+
---
|
| 124 |
+
|
| 125 |
+
## 5. Voice Scorecard Metrics (Grounded in Architecture)
|
| 126 |
+
|
| 127 |
+
Only score dimensions the model can plausibly observe:
|
| 128 |
+
|
| 129 |
+
| Metric | How Nemotron detects it | Reliability |
|
| 130 |
+
|---|---|---|
|
| 131 |
+
| Filler word count | Direct from audio tokens + transcript | **High** |
|
| 132 |
+
| Pause before key claims | Temporal gap in audio | **Medium–High** |
|
| 133 |
+
| Self-corrections | Restarts in transcript / audio pattern | **High** |
|
| 134 |
+
| Trailing off on weak claims | Audio token patterns | **Medium** |
|
| 135 |
+
| Speaking pace (rushed vs calm) | Token density over time | **Medium** |
|
| 136 |
+
| Confidence on specific claims | Inference from above signals | **Medium** |
|
| 137 |
+
|
| 138 |
+
When on **Whisper fallback**, mark voice-derived metrics as unavailable or transcript-only.
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
## 6. PitchFight AI Voice Architecture
|
| 143 |
+
|
| 144 |
+
### Path A — Nemotron direct (primary, when API available)
|
| 145 |
+
|
| 146 |
+
```text
|
| 147 |
+
Browser records audio
|
| 148 |
+
↓
|
| 149 |
+
POST /api/voice-pitch (backend only)
|
| 150 |
+
↓
|
| 151 |
+
NVIDIA Nemotron Omni
|
| 152 |
+
↓
|
| 153 |
+
Parakeet encoder processes raw audio
|
| 154 |
+
↓
|
| 155 |
+
Paralinguistic + linguistic signals preserved in backbone
|
| 156 |
+
↓
|
| 157 |
+
Single API response includes:
|
| 158 |
+
- transcript
|
| 159 |
+
- delivery_observations (hesitation / pace / fillers)
|
| 160 |
+
- detected_startup_context
|
| 161 |
+
- opening judge question
|
| 162 |
+
↓
|
| 163 |
+
Pitch Battle continues (voice or text)
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
**UI badge:** `⚡ Voice Analysis Active` (or similar) when Path A is active.
|
| 167 |
+
|
| 168 |
+
### Path B — Whisper fallback (when Nemotron audio path fails)
|
| 169 |
+
|
| 170 |
+
```text
|
| 171 |
+
Browser records audio
|
| 172 |
+
↓
|
| 173 |
+
POST /api/voice-pitch
|
| 174 |
+
↓
|
| 175 |
+
faster-whisper local transcription
|
| 176 |
+
↓
|
| 177 |
+
Clean transcript text only (no paralinguistic tokens)
|
| 178 |
+
↓
|
| 179 |
+
Nemotron or MiniCPM5-1B text reasoning
|
| 180 |
+
↓
|
| 181 |
+
Judge question (no delivery observations)
|
| 182 |
+
↓
|
| 183 |
+
Scorecard notes: "Voice delivery metrics unavailable in fallback mode"
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
**UI badge:** `📝 Text Mode` (transcript-only fallback).
|
| 187 |
+
|
| 188 |
+
Path A is the **demo differentiator**. Path B keeps the app working when audio API fails, keys are missing, or latency/errors trigger fallback.
|
| 189 |
+
|
| 190 |
+
### Security
|
| 191 |
+
|
| 192 |
+
- Audio upload hits **backend only** — never send `NVIDIA_API_KEY` to the browser.
|
| 193 |
+
- Keys live in `.env` locally and **HF Space Secrets** in deployment.
|
| 194 |
+
|
| 195 |
+
---
|
| 196 |
+
|
| 197 |
+
## 7. Recommended Voice Prompt (Hesitation / Delivery)
|
| 198 |
+
|
| 199 |
+
When sending audio to Nemotron for `/api/voice-pitch`, include instructions like:
|
| 200 |
+
|
| 201 |
+
```text
|
| 202 |
+
Listen to this pitch audio carefully.
|
| 203 |
+
|
| 204 |
+
Beyond the words spoken, note:
|
| 205 |
+
- Any filler words (um, uh, like, you know, sort of)
|
| 206 |
+
- Any long pauses before answering a key claim
|
| 207 |
+
- Any statements that trail off or sound uncertain
|
| 208 |
+
- Any rushing through parts they seem unsure about
|
| 209 |
+
- Any self-corrections or restarts
|
| 210 |
+
|
| 211 |
+
After understanding the pitch, include a section called
|
| 212 |
+
DELIVERY OBSERVATIONS with specific timestamps or quotes
|
| 213 |
+
where hesitation or low confidence was audible.
|
| 214 |
+
|
| 215 |
+
Extract startup context fields if possible.
|
| 216 |
+
Then ask your first hard judge question in character.
|
| 217 |
+
Return structured JSON when requested.
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
Anchoring the model to **delivery observations** makes paralinguistic reporting explicit instead of hoping it infers them silently.
|
| 221 |
+
|
| 222 |
+
### Suggested structured response fields (Phase 7+)
|
| 223 |
+
|
| 224 |
+
```json
|
| 225 |
+
{
|
| 226 |
+
"transcript": "...",
|
| 227 |
+
"delivery_observations": [
|
| 228 |
+
{
|
| 229 |
+
"type": "filler_words",
|
| 230 |
+
"quote_or_timestamp": "0:12 — 'um, I think maybe...'",
|
| 231 |
+
"note": "Hedging before market size claim"
|
| 232 |
+
}
|
| 233 |
+
],
|
| 234 |
+
"detected_startup_context": { },
|
| 235 |
+
"opening_question": "..."
|
| 236 |
+
}
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
Exact schema should match `core/api_handlers.py` when voice mode is implemented.
|
| 240 |
+
|
| 241 |
+
---
|
| 242 |
+
|
| 243 |
+
## 8. Comparison: Nemotron Omni vs faster-whisper
|
| 244 |
+
|
| 245 |
+
| | Nemotron Omni (Path A) | faster-whisper (Path B) |
|
| 246 |
+
|---|---|---|
|
| 247 |
+
| **Input** | Raw audio | Raw audio |
|
| 248 |
+
| **Output** | Transcript + reasoning + delivery signals | Transcript text only |
|
| 249 |
+
| **Hesitation / pace** | Paralinguistic tokens in backbone | Not available |
|
| 250 |
+
| **Judge question** | Same call or follow-up text call | Requires separate text model call |
|
| 251 |
+
| **Role in PitchFight** | Primary voice judge | Transcription fallback only |
|
| 252 |
+
| **Runs** | NVIDIA API (backend) | Local on Space / dev machine |
|
| 253 |
+
|
| 254 |
+
---
|
| 255 |
+
|
| 256 |
+
## 9. Implementation Notes (Future Phases)
|
| 257 |
+
|
| 258 |
+
Planned wiring (no app logic in this doc — reference only):
|
| 259 |
+
|
| 260 |
+
| Module | Responsibility |
|
| 261 |
+
|---|---|
|
| 262 |
+
| `core/nvidia_client.py` | Send audio + prompt to Nemotron API; parse structured response |
|
| 263 |
+
| `core/model_router.py` | Choose `premium_nvidia` vs `whisper_fallback` |
|
| 264 |
+
| `core/transcription_client.py` | faster-whisper when Omni audio fails |
|
| 265 |
+
| `core/api_handlers.py` | `handle_voice_pitch()` orchestration |
|
| 266 |
+
| `frontend/script.js` | Upload audio to `/api/voice-pitch` only |
|
| 267 |
+
|
| 268 |
+
### Fallback triggers (suggested)
|
| 269 |
+
|
| 270 |
+
- NVIDIA API timeout or 5xx
|
| 271 |
+
- Missing `NVIDIA_API_KEY`
|
| 272 |
+
- Audio format unsupported by API
|
| 273 |
+
- Explicit `WHISPER_FALLBACK_ENABLED=true` override for debugging
|
| 274 |
+
|
| 275 |
+
### Demo talking points
|
| 276 |
+
|
| 277 |
+
1. **Raw audio goes to Nemotron** — not transcribe-then-reason on the premium path.
|
| 278 |
+
2. **Delivery observations** — fillers, pauses, trailing off — are first-class signals.
|
| 279 |
+
3. **Honest limits** — we score linguistic hesitation, not biometrics.
|
| 280 |
+
4. **Graceful fallback** — whisper keeps voice mode alive without breaking the demo.
|
| 281 |
+
|
| 282 |
+
---
|
| 283 |
+
|
| 284 |
+
## 10. References
|
| 285 |
+
|
| 286 |
+
- NVIDIA Nemotron 3 Nano Omni model card and integrate API docs (NVIDIA NIM / integrate.api.nvidia.com)
|
| 287 |
+
- Build Small Hackathon: ≤32B models, Gradio/HF Spaces, demo-first
|
| 288 |
+
- PitchFight model stack: [`MODELS_FINAL.md`](MODELS_FINAL.md)
|
| 289 |
+
|
| 290 |
+
---
|
| 291 |
+
|
| 292 |
+
## 11. Bottom Line
|
| 293 |
+
|
| 294 |
+
Nemotron Omni is the right primary voice model for PitchFight because it processes **raw audio through a dedicated encoder into a shared reasoning backbone**, preserving paralinguistic cues that matter for pitch coaching. Pair it with an explicit **delivery observations** prompt, honest scorecard metrics, and a **whisper fallback** that degrades visibly — not silently — when the premium audio path is unavailable.
|
docs/PHASE_WISE_PLAN.md
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PitchFight AI — Phase-Wise Implementation Plan
|
| 2 |
+
|
| 3 |
+
## Full Sponsor-Model Build Plan for Voice, Text, Deal Battle, Scorecards, and Demo Polish
|
| 4 |
+
|
| 5 |
+
PitchFight AI is a voice-and-text AI sparring arena for student founders. The project prioritizes the strongest possible demo experience using small models under 32B, including NVIDIA Nemotron Omni for premium voice/multimodal judge behavior, OpenBMB MiniCPM models for sponsor-aligned modes, a custom Gradio Server frontend, persona-based pitch battles, deal negotiation battles, and rubric-based scorecards.
|
| 6 |
+
|
| 7 |
+
> **Strategy note:** This version does **not** claim the Off-the-Grid badge because the default high-quality build may use sponsor/model APIs. This is intentional. The project instead targets **Backyard AI**, **Best Demo**, **Best Agent**, **Off-Brand**, **NVIDIA Nemotron Quest**, **OpenBMB Awards**, **Sharing is Caring**, and **Field Notes**.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## 1. Final Build Goal
|
| 12 |
+
|
| 13 |
+
The complete final app includes:
|
| 14 |
+
|
| 15 |
+
- Custom Gradio Server app
|
| 16 |
+
- Custom HTML/CSS/JS frontend
|
| 17 |
+
- Pitch Battle mode
|
| 18 |
+
- Deal Battle mode
|
| 19 |
+
- Voice Pitch mode
|
| 20 |
+
- Text Pitch mode
|
| 21 |
+
- AI opponent personas
|
| 22 |
+
- Attack-tag routing
|
| 23 |
+
- Session memory
|
| 24 |
+
- NVIDIA Nemotron Omni integration
|
| 25 |
+
- MiniCPM-o / MiniCPM fallback modes
|
| 26 |
+
- MiniCPM-V pitch deck critique mode
|
| 27 |
+
- Scorecard engine
|
| 28 |
+
- Weakest answer rewrite
|
| 29 |
+
- Improved 60-second pitch
|
| 30 |
+
- Retry weakest question
|
| 31 |
+
- HF Spaces deployment
|
| 32 |
+
- Documentation and demo notes
|
| 33 |
+
|
| 34 |
+
**Focus:** demo strength, model quality, and sponsor-model alignment — **not** Off-the-Grid.
|
| 35 |
+
|
| 36 |
+
All models stay **≤32B parameters**. The app is built on **Gradio**, hosted as a **Hugging Face Space**, uses a **custom non-default UI**, and keeps **all API keys in backend/HF Space Secrets** only (never in the frontend).
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## 2. Master Build Order
|
| 41 |
+
|
| 42 |
+
| Phase | Name |
|
| 43 |
+
|---|---|
|
| 44 |
+
| **Phase 0** | Strategy Reset + Documentation Alignment |
|
| 45 |
+
| **Phase 1** | Project Skeleton Verification |
|
| 46 |
+
| **Phase 2** | Model Router + Secrets Setup |
|
| 47 |
+
| **Phase 3** | NVIDIA Nemotron Omni Integration |
|
| 48 |
+
| **Phase 4** | Pitch Battle Engine |
|
| 49 |
+
| **Phase 5** | Scorecard + Feedback Engine |
|
| 50 |
+
| **Phase 6** | Custom Frontend Integration |
|
| 51 |
+
| **Phase 7** | Voice Pitch Mode |
|
| 52 |
+
| **Phase 8** | Deal Battle Mode |
|
| 53 |
+
| **Phase 9** | MiniCPM / OpenBMB Modes |
|
| 54 |
+
| **Phase 10** | Pitch Deck Critique Mode |
|
| 55 |
+
| **Phase 11** | Retry + Report Enhancements |
|
| 56 |
+
| **Phase 12** | UI Polish + Demo Flow |
|
| 57 |
+
| **Phase 13** | Hugging Face Spaces Deployment |
|
| 58 |
+
| **Phase 14** | Final Testing + Submission Readiness |
|
| 59 |
+
|
| 60 |
+
---
|
| 61 |
+
|
| 62 |
+
## 3. Phase 0 — Strategy Reset + Documentation Alignment
|
| 63 |
+
|
| 64 |
+
**Goal:** Ensure all repository docs reflect the high-demo sponsor-model strategy (historical local-first/off-grid wording removed).
|
| 65 |
+
|
| 66 |
+
**Tasks:**
|
| 67 |
+
|
| 68 |
+
- Remove wording that says the default build is Off-the-Grid.
|
| 69 |
+
- Keep a note that Off-the-Grid is **not** targeted.
|
| 70 |
+
- Update model strategy to include **NVIDIA Nemotron Omni** as the primary premium model.
|
| 71 |
+
- Add **MiniCPM-o** as OpenBMB advanced mode.
|
| 72 |
+
- Add **MiniCPM5-1B** as Tiny/Fallback mode.
|
| 73 |
+
- Add **MiniCPM-V 4.6** as pitch deck critique mode.
|
| 74 |
+
- Keep **faster-whisper** as backup transcription fallback.
|
| 75 |
+
- Update `README.md`, `docs/DOCUMENTATION.md`, `docs/PROMPTS.md`, `docs/DEMO_NOTES.md`, `docs/FIELD_NOTES.md`, and `docs/MODELS_FINAL.md` accordingly.
|
| 76 |
+
|
| 77 |
+
**Success check:** All docs agree that the project is under 32B, Gradio/HF Spaces based, and high-demo sponsor-model focused.
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## 4. Phase 1 — Project Skeleton Verification
|
| 82 |
+
|
| 83 |
+
**Goal:** Verify that the existing Gradio Server + custom frontend skeleton works.
|
| 84 |
+
|
| 85 |
+
**Tasks:**
|
| 86 |
+
|
| 87 |
+
- Check `app.py` exists.
|
| 88 |
+
- Check `frontend/index.html`, `styles.css`, `script.js` exist.
|
| 89 |
+
- Check `core/` modules exist.
|
| 90 |
+
- Check `config/` files exist.
|
| 91 |
+
- Run `python app.py`.
|
| 92 |
+
- Confirm custom UI opens.
|
| 93 |
+
- Confirm mock battle works.
|
| 94 |
+
- Confirm mock scorecard appears.
|
| 95 |
+
|
| 96 |
+
**Files:**
|
| 97 |
+
|
| 98 |
+
- `app.py`
|
| 99 |
+
- `frontend/*`
|
| 100 |
+
- `core/*`
|
| 101 |
+
- `config/*`
|
| 102 |
+
- `docs/*`
|
| 103 |
+
|
| 104 |
+
**Success check:** Custom PitchFight AI UI opens and mock demo flow runs.
|
| 105 |
+
|
| 106 |
+
**Current status:** ✅ Complete (Phase 1 skeleton in place with mock APIs).
|
| 107 |
+
|
| 108 |
+
**API layer:** Clean product endpoints live under `/api/...` (see [`BACKEND_API.md`](BACKEND_API.md)). Gradio internal routes in Swagger are framework runtime routes, not PitchFight product APIs.
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## 5. Phase 2 — Model Router + Secrets Setup
|
| 113 |
+
|
| 114 |
+
**Goal:** Create a clean backend-only model routing layer.
|
| 115 |
+
|
| 116 |
+
**Tasks:**
|
| 117 |
+
|
| 118 |
+
- Create or update `core/model_router.py`.
|
| 119 |
+
- Create or update `core/nvidia_client.py`.
|
| 120 |
+
- Create or update `core/minicpm_client.py`.
|
| 121 |
+
- Create or update `core/vision_client.py`.
|
| 122 |
+
- Create or update `core/transcription_client.py`.
|
| 123 |
+
- Ensure frontend never calls model APIs.
|
| 124 |
+
- Add env variables to `.env.example`.
|
| 125 |
+
- Read keys only from `os.getenv` (`.env` locally, HF Space Secrets in deployment).
|
| 126 |
+
- Frontend calls `/api/*` only — never model provider APIs.
|
| 127 |
+
- Add graceful fallback if a model call fails.
|
| 128 |
+
|
| 129 |
+
**Environment variables:**
|
| 130 |
+
|
| 131 |
+
```text
|
| 132 |
+
NVIDIA_API_KEY=
|
| 133 |
+
NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
|
| 134 |
+
NVIDIA_OMNI_MODEL=nvidia/nemotron-3-nano-omni-30b-a3b-reasoning
|
| 135 |
+
|
| 136 |
+
OPENBMB_API_KEY=
|
| 137 |
+
OPENBMB_BASE_URL=
|
| 138 |
+
MINICPM_OMNI_MODEL=openbmb/MiniCPM-o-4_5
|
| 139 |
+
MINICPM_TEXT_MODEL=openbmb/MiniCPM5-1B
|
| 140 |
+
MINICPM_VISION_MODEL=openbmb/MiniCPM-V-4.6
|
| 141 |
+
|
| 142 |
+
HF_TOKEN=
|
| 143 |
+
WHISPER_FALLBACK_ENABLED=true
|
| 144 |
+
MAX_ROUNDS=6
|
| 145 |
+
APP_ENV=development
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
**Success check:** Backend can select a model path:
|
| 149 |
+
|
| 150 |
+
- `premium_nvidia`
|
| 151 |
+
- `openbmb_omni`
|
| 152 |
+
- `tiny_minicpm`
|
| 153 |
+
- `vision_deck`
|
| 154 |
+
- `whisper_fallback`
|
| 155 |
+
|
| 156 |
+
**Current status:** ✅ Complete (2026-06-08). `core/nvidia_client.py` and `core/model_router.py` created. NVIDIA connectivity tested — live response confirmed. Stub clients created for MiniCPM, vision, and transcription. `GET /api/model-health` added.
|
| 157 |
+
|
| 158 |
+
**Key discovery:** `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning` is a reasoning model. It uses tokens for an internal chain-of-thought before writing `message.content`. Token defaults: opponent=500, scorecard=2000, rewrite=800. A `reasoning_content` fallback is in place for edge cases.
|
| 159 |
+
|
| 160 |
+
---
|
| 161 |
+
|
| 162 |
+
## 6. Phase 3 — NVIDIA Nemotron Omni Integration
|
| 163 |
+
|
| 164 |
+
**Goal:** Make NVIDIA Nemotron Omni the primary premium reasoning and voice/multimodal judge model.
|
| 165 |
+
|
| 166 |
+
**Tasks:**
|
| 167 |
+
|
| 168 |
+
- Implement secure backend call using `NVIDIA_API_KEY`.
|
| 169 |
+
- Add function `generate_nemotron_response(messages, mode)`.
|
| 170 |
+
- Add timeout handling.
|
| 171 |
+
- Add retry handling.
|
| 172 |
+
- Add fallback to MiniCPM mode if NVIDIA fails.
|
| 173 |
+
- Test text-only judge prompt first.
|
| 174 |
+
- Test scoring prompt second.
|
| 175 |
+
- Test voice/multimodal input after text works.
|
| 176 |
+
|
| 177 |
+
**Use cases:**
|
| 178 |
+
|
| 179 |
+
- AI opponent response
|
| 180 |
+
- Voice pitch critique
|
| 181 |
+
- Deal battle counterpart
|
| 182 |
+
- Scorecard generation
|
| 183 |
+
- Weakest answer rewrite
|
| 184 |
+
- Optional pitch deck + spoken explanation critique
|
| 185 |
+
|
| 186 |
+
**Success check:** Backend receives a startup context and Nemotron returns a sharp judge question.
|
| 187 |
+
|
| 188 |
+
---
|
| 189 |
+
|
| 190 |
+
## 7. Phase 4 — Pitch Battle Engine
|
| 191 |
+
|
| 192 |
+
**Goal:** Make Pitch Battle real.
|
| 193 |
+
|
| 194 |
+
**Tasks:**
|
| 195 |
+
|
| 196 |
+
- Connect `/start_session` to `model_router`.
|
| 197 |
+
- Connect `/chat_round` to `model_router`.
|
| 198 |
+
- Use `persona_builder.py`.
|
| 199 |
+
- Use `attack_tags.py`.
|
| 200 |
+
- Store history in `session_manager.py`.
|
| 201 |
+
- Rotate attack tags.
|
| 202 |
+
- Return `attack_tag`, `pressure_level`, `round`, `ai_message`.
|
| 203 |
+
- Support 3 personas:
|
| 204 |
+
- Skeptical VC
|
| 205 |
+
- Technical Judge
|
| 206 |
+
- Hackathon Judge
|
| 207 |
+
|
| 208 |
+
**Success check:** User enters EventRadar AI, selects Hackathon Judge, and receives a sharp AI challenge.
|
| 209 |
+
|
| 210 |
+
---
|
| 211 |
+
|
| 212 |
+
## 8. Phase 5 — Scorecard + Feedback Engine
|
| 213 |
+
|
| 214 |
+
**Goal:** Generate real scorecards.
|
| 215 |
+
|
| 216 |
+
**Tasks:**
|
| 217 |
+
|
| 218 |
+
- Build scoring prompt from conversation history.
|
| 219 |
+
- Use NVIDIA Nemotron as default scorer.
|
| 220 |
+
- Use MiniCPM fallback if needed.
|
| 221 |
+
- Parse scorecard JSON safely.
|
| 222 |
+
- Implement JSON fallback pipeline.
|
| 223 |
+
- Generate:
|
| 224 |
+
- overall score
|
| 225 |
+
- 6 pitch dimensions
|
| 226 |
+
- best answer
|
| 227 |
+
- weakest answer
|
| 228 |
+
- improved answer
|
| 229 |
+
- improved pitch
|
| 230 |
+
- top 3 prep questions
|
| 231 |
+
|
| 232 |
+
**Files:**
|
| 233 |
+
|
| 234 |
+
- `core/scoring_engine.py`
|
| 235 |
+
- `core/feedback_generator.py`
|
| 236 |
+
- `core/json_utils.py`
|
| 237 |
+
- `app.py`
|
| 238 |
+
- `frontend/script.js`
|
| 239 |
+
|
| 240 |
+
**Success check:** End Battle produces structured scorecard with user quotes and useful rewrites.
|
| 241 |
+
|
| 242 |
+
---
|
| 243 |
+
|
| 244 |
+
## 9. Phase 6 — Custom Frontend Integration
|
| 245 |
+
|
| 246 |
+
**Goal:** Connect UI fully to backend APIs.
|
| 247 |
+
|
| 248 |
+
**Tasks:**
|
| 249 |
+
|
| 250 |
+
- Connect Gradio JS Client.
|
| 251 |
+
- Load demo startup.
|
| 252 |
+
- Start session.
|
| 253 |
+
- Render AI question.
|
| 254 |
+
- Render user messages.
|
| 255 |
+
- Render AI follow-ups.
|
| 256 |
+
- Render attack tag.
|
| 257 |
+
- Render pressure meter.
|
| 258 |
+
- Render scorecard.
|
| 259 |
+
- Show model mode badge:
|
| 260 |
+
- Premium Nemotron
|
| 261 |
+
- OpenBMB Omni
|
| 262 |
+
- Tiny MiniCPM
|
| 263 |
+
- Handle errors and loading states.
|
| 264 |
+
|
| 265 |
+
**Success check:** User can complete full Pitch Battle from UI.
|
| 266 |
+
|
| 267 |
+
---
|
| 268 |
+
|
| 269 |
+
## 10. Phase 7 — Voice Pitch Mode
|
| 270 |
+
|
| 271 |
+
**Goal:** Make voice mode a major demo feature.
|
| 272 |
+
|
| 273 |
+
**Primary path:**
|
| 274 |
+
|
| 275 |
+
```text
|
| 276 |
+
Browser records audio
|
| 277 |
+
→ backend sends audio/transcript to NVIDIA Nemotron Omni
|
| 278 |
+
→ AI extracts pitch understanding
|
| 279 |
+
→ AI asks first hard question
|
| 280 |
+
→ user continues by voice or text
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
**Fallback path:**
|
| 284 |
+
|
| 285 |
+
```text
|
| 286 |
+
Browser records audio
|
| 287 |
+
→ faster-whisper transcribes locally
|
| 288 |
+
→ transcript goes to selected text model
|
| 289 |
+
→ battle starts
|
| 290 |
+
```
|
| 291 |
+
|
| 292 |
+
**Tasks:**
|
| 293 |
+
|
| 294 |
+
- Add browser audio recorder.
|
| 295 |
+
- Add `/voice_pitch` endpoint.
|
| 296 |
+
- Add audio upload handling.
|
| 297 |
+
- Add transcript/interpretation panel.
|
| 298 |
+
- Add user editable transcript.
|
| 299 |
+
- Add voice-specific metrics:
|
| 300 |
+
- Structure
|
| 301 |
+
- Conciseness
|
| 302 |
+
- Confidence Signals
|
| 303 |
+
- Directness
|
| 304 |
+
- Continue battle from voice input.
|
| 305 |
+
|
| 306 |
+
**Success check:** User records a pitch and receives a hard first question.
|
| 307 |
+
|
| 308 |
+
---
|
| 309 |
+
|
| 310 |
+
## 11. Phase 8 — Deal Battle Mode
|
| 311 |
+
|
| 312 |
+
**Goal:** Add negotiation practice.
|
| 313 |
+
|
| 314 |
+
**Scenarios:**
|
| 315 |
+
|
| 316 |
+
- Startup Sponsorship Ask
|
| 317 |
+
- Internship Salary Negotiation
|
| 318 |
+
- Freelance Client Pricing
|
| 319 |
+
- Equity Negotiation
|
| 320 |
+
|
| 321 |
+
**Personas:**
|
| 322 |
+
|
| 323 |
+
- Tough Sponsor
|
| 324 |
+
- Strict HR Recruiter
|
| 325 |
+
- Budget-Conscious Client
|
| 326 |
+
- Hard-Negotiating Investor
|
| 327 |
+
|
| 328 |
+
**Tasks:**
|
| 329 |
+
|
| 330 |
+
- Add Deal Battle mode to UI.
|
| 331 |
+
- Add negotiation context form.
|
| 332 |
+
- Add deal personas.
|
| 333 |
+
- Add deal attack tags.
|
| 334 |
+
- Add deal rubric.
|
| 335 |
+
- Route deal battle to `model_router`.
|
| 336 |
+
- Generate negotiation-specific scorecard.
|
| 337 |
+
|
| 338 |
+
**Success check:** User can negotiate and receive a deal-specific scorecard.
|
| 339 |
+
|
| 340 |
+
---
|
| 341 |
+
|
| 342 |
+
## 12. Phase 9 — MiniCPM / OpenBMB Modes
|
| 343 |
+
|
| 344 |
+
**Goal:** Add OpenBMB sponsor-aligned models and fallback modes.
|
| 345 |
+
|
| 346 |
+
**Models:**
|
| 347 |
+
|
| 348 |
+
- **MiniCPM-o 4.5** — OpenBMB Omni Mode
|
| 349 |
+
- **MiniCPM5-1B** — Tiny Mode / fallback
|
| 350 |
+
- **MiniCPM-V 4.6** — vision mode
|
| 351 |
+
|
| 352 |
+
**Tasks:**
|
| 353 |
+
|
| 354 |
+
- Add model selector or backend mode switch.
|
| 355 |
+
- Add OpenBMB Omni mode.
|
| 356 |
+
- Add Tiny MiniCPM mode.
|
| 357 |
+
- Add fallback if NVIDIA errors.
|
| 358 |
+
- Add docs explaining model use cases.
|
| 359 |
+
|
| 360 |
+
**Success check:** App can run at least one OpenBMB mode and one fallback mode.
|
| 361 |
+
|
| 362 |
+
---
|
| 363 |
+
|
| 364 |
+
## 13. Phase 10 — Pitch Deck Critique Mode
|
| 365 |
+
|
| 366 |
+
**Goal:** Add image/slide critique.
|
| 367 |
+
|
| 368 |
+
**Primary model:** MiniCPM-V 4.6 or NVIDIA Omni.
|
| 369 |
+
|
| 370 |
+
**Tasks:**
|
| 371 |
+
|
| 372 |
+
- Add image upload.
|
| 373 |
+
- Add `/deck_critique` endpoint.
|
| 374 |
+
- Extract slide claims.
|
| 375 |
+
- Critique clarity, problem, solution, market, ask.
|
| 376 |
+
- Generate 3 judge questions based on the slide.
|
| 377 |
+
|
| 378 |
+
**Success check:** User uploads a pitch slide screenshot and receives useful critique.
|
| 379 |
+
|
| 380 |
+
---
|
| 381 |
+
|
| 382 |
+
## 14. Phase 11 — Retry + Report Enhancements
|
| 383 |
+
|
| 384 |
+
**Goal:** Make it feel product-ready.
|
| 385 |
+
|
| 386 |
+
**Tasks:**
|
| 387 |
+
|
| 388 |
+
- Add Retry Weakest Question.
|
| 389 |
+
- Store weakest AI question.
|
| 390 |
+
- Compare original vs retried answer.
|
| 391 |
+
- Generate downloadable markdown report.
|
| 392 |
+
- Add full session summary.
|
| 393 |
+
- Add “Start New Battle”.
|
| 394 |
+
|
| 395 |
+
**Success check:** User can retry the weakest objection and get improvement guidance.
|
| 396 |
+
|
| 397 |
+
---
|
| 398 |
+
|
| 399 |
+
## 15. Phase 12 — UI Polish + Demo Flow
|
| 400 |
+
|
| 401 |
+
**Goal:** Make it visually memorable.
|
| 402 |
+
|
| 403 |
+
**Tasks:**
|
| 404 |
+
|
| 405 |
+
- Improve landing hero.
|
| 406 |
+
- Add model mode badges.
|
| 407 |
+
- Add premium voice mode card.
|
| 408 |
+
- Add deck critique card.
|
| 409 |
+
- Add battle arena split layout.
|
| 410 |
+
- Add score animations.
|
| 411 |
+
- Add voice recording animation.
|
| 412 |
+
- Add mobile responsiveness.
|
| 413 |
+
- Add error banners.
|
| 414 |
+
- Add loading states.
|
| 415 |
+
|
| 416 |
+
**Design:** Dark battle arena, red pressure accents, gold score accents, glass cards, custom typography.
|
| 417 |
+
|
| 418 |
+
**Success check:** The app looks like a custom product, not default Gradio.
|
| 419 |
+
|
| 420 |
+
---
|
| 421 |
+
|
| 422 |
+
## 16. Phase 13 — Hugging Face Spaces Deployment
|
| 423 |
+
|
| 424 |
+
**Goal:** Deploy publicly.
|
| 425 |
+
|
| 426 |
+
**Tasks:**
|
| 427 |
+
|
| 428 |
+
- Add README metadata.
|
| 429 |
+
- Add required secrets in HF Space Settings.
|
| 430 |
+
- Confirm app builds.
|
| 431 |
+
- Confirm no keys are exposed.
|
| 432 |
+
- Test all modes publicly.
|
| 433 |
+
- Add screenshots to README.
|
| 434 |
+
- Add final Space link.
|
| 435 |
+
|
| 436 |
+
**Success check:** Public HF Space completes:
|
| 437 |
+
|
| 438 |
+
- Text Pitch Battle
|
| 439 |
+
- Voice Pitch Battle
|
| 440 |
+
- Deal Battle
|
| 441 |
+
- Scorecard
|
| 442 |
+
- Deck critique (if built)
|
| 443 |
+
|
| 444 |
+
---
|
| 445 |
+
|
| 446 |
+
## 17. Phase 14 — Final Testing + Submission Readiness
|
| 447 |
+
|
| 448 |
+
**Tasks:**
|
| 449 |
+
|
| 450 |
+
- Test every endpoint.
|
| 451 |
+
- Test every UI button.
|
| 452 |
+
- Test failure fallback.
|
| 453 |
+
- Test missing API keys.
|
| 454 |
+
- Test slow responses.
|
| 455 |
+
- Test mobile layout.
|
| 456 |
+
- Update README.
|
| 457 |
+
- Update docs.
|
| 458 |
+
- Prepare demo flow.
|
| 459 |
+
- Prepare field notes.
|
| 460 |
+
- Prepare social post separately.
|
| 461 |
+
|
| 462 |
+
**Badges/prizes to mention:**
|
| 463 |
+
|
| 464 |
+
- Backyard AI
|
| 465 |
+
- Best Demo
|
| 466 |
+
- Best Agent
|
| 467 |
+
- Off-Brand
|
| 468 |
+
- NVIDIA Nemotron Quest
|
| 469 |
+
- OpenBMB Awards
|
| 470 |
+
- Sharing is Caring
|
| 471 |
+
- Field Notes
|
| 472 |
+
- Tiny Titan (if Tiny Mode works)
|
| 473 |
+
|
| 474 |
+
---
|
| 475 |
+
|
| 476 |
+
## Final One-Line Pitch
|
| 477 |
+
|
| 478 |
+
**PitchFight AI is a voice-and-text AI sparring arena where student founders practice tough startup pitches, get grilled by realistic AI judges under 32B parameters, and receive a scorecard that shows exactly how to answer better.**
|
docs/PROMPTS.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PitchFight AI — Prompt Templates
|
| 2 |
+
|
| 3 |
+
## Model Routing (Backend Only)
|
| 4 |
+
|
| 5 |
+
All prompts are sent through `core/model_router.py`. Default scorer and opponent: **NVIDIA Nemotron Omni**. Fallback: **MiniCPM5-1B**. Voice primary path: Nemotron Omni audio; fallback: **faster-whisper** → text model.
|
| 6 |
+
|
| 7 |
+
> API keys never appear in prompts or frontend code. Keys are read from `os.getenv` on the backend only.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Opponent System Prompt Template
|
| 12 |
+
|
| 13 |
+
Used by Nemotron Omni (primary) and MiniCPM fallback.
|
| 14 |
+
|
| 15 |
+
```text
|
| 16 |
+
You are {persona_label}, a tough pitch opponent in PitchFight AI.
|
| 17 |
+
Difficulty: {difficulty}
|
| 18 |
+
Current attack tag: {attack_tag}
|
| 19 |
+
Round: {round_number} of {max_rounds}
|
| 20 |
+
|
| 21 |
+
Startup: {startup_name}
|
| 22 |
+
Problem: {problem}
|
| 23 |
+
Solution: {solution}
|
| 24 |
+
Why AI: {why_ai}
|
| 25 |
+
Target users: {target_users}
|
| 26 |
+
Competitors: {competitors}
|
| 27 |
+
Traction: {traction}
|
| 28 |
+
|
| 29 |
+
Conversation so far:
|
| 30 |
+
{history}
|
| 31 |
+
|
| 32 |
+
{persona_focus}
|
| 33 |
+
|
| 34 |
+
Behavior rules:
|
| 35 |
+
- Ask one sharp question at a time.
|
| 36 |
+
- Keep responses under 4 sentences.
|
| 37 |
+
- Reference the founder's previous answer when pushing back.
|
| 38 |
+
- Do not give advice during the battle.
|
| 39 |
+
- Do not compliment the founder.
|
| 40 |
+
- Attack vague, generic, or unsubstantiated claims.
|
| 41 |
+
- Raise difficulty after strong answers.
|
| 42 |
+
- Stay in character at all times.
|
| 43 |
+
- Be firm but not abusive.
|
| 44 |
+
- Frame your question around the current attack tag: {attack_tag}.
|
| 45 |
+
|
| 46 |
+
Return only your next question as plain text (no JSON).
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## Scoring Prompt Template
|
| 52 |
+
|
| 53 |
+
Default model: **NVIDIA Nemotron Omni**. Fallback: MiniCPM5-1B + `json_utils.safe_json_parse`.
|
| 54 |
+
|
| 55 |
+
```text
|
| 56 |
+
You are a pitch battle evaluator. Score the founder's performance using the rubric below.
|
| 57 |
+
Return valid JSON only — no markdown fences, no commentary.
|
| 58 |
+
|
| 59 |
+
Rubric weights:
|
| 60 |
+
- clarity: 15
|
| 61 |
+
- problem_understanding: 20
|
| 62 |
+
- market_awareness: 15
|
| 63 |
+
- differentiation: 20
|
| 64 |
+
- business_model: 15
|
| 65 |
+
- objection_handling: 15
|
| 66 |
+
|
| 67 |
+
Startup context:
|
| 68 |
+
{startup_json}
|
| 69 |
+
|
| 70 |
+
Conversation:
|
| 71 |
+
{history_json}
|
| 72 |
+
|
| 73 |
+
Return exactly this JSON shape:
|
| 74 |
+
{
|
| 75 |
+
"overall": <0-100>,
|
| 76 |
+
"scores": {
|
| 77 |
+
"clarity": {"score": <0-100>, "reason": "...", "quote": "..."},
|
| 78 |
+
"problem_understanding": {"score": <0-100>, "reason": "...", "quote": "..."},
|
| 79 |
+
"market_awareness": {"score": <0-100>, "reason": "...", "quote": "..."},
|
| 80 |
+
"differentiation": {"score": <0-100>, "reason": "...", "quote": "..."},
|
| 81 |
+
"business_model": {"score": <0-100>, "reason": "...", "quote": "..."},
|
| 82 |
+
"objection_handling": {"score": <0-100>, "reason": "...", "quote": "..."}
|
| 83 |
+
},
|
| 84 |
+
"best_answer": "...",
|
| 85 |
+
"weakest_answer": "...",
|
| 86 |
+
"improved_answer": "...",
|
| 87 |
+
"improved_pitch": "...",
|
| 88 |
+
"top_3_questions": ["...", "...", "..."]
|
| 89 |
+
}
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
## Voice Pitch Extraction Prompt (Nemotron Omni)
|
| 95 |
+
|
| 96 |
+
```text
|
| 97 |
+
You heard a student founder's spoken pitch. Extract structured startup context and identify the weakest claim.
|
| 98 |
+
|
| 99 |
+
Return JSON:
|
| 100 |
+
{
|
| 101 |
+
"name": "...",
|
| 102 |
+
"problem": "...",
|
| 103 |
+
"target_users": "...",
|
| 104 |
+
"solution": "...",
|
| 105 |
+
"why_ai": "...",
|
| 106 |
+
"competitors": "...",
|
| 107 |
+
"traction": "...",
|
| 108 |
+
"ask": "...",
|
| 109 |
+
"voice_metrics": {
|
| 110 |
+
"structure": <0-100>,
|
| 111 |
+
"conciseness": <0-100>,
|
| 112 |
+
"confidence_signals": <0-100>,
|
| 113 |
+
"directness": <0-100>
|
| 114 |
+
},
|
| 115 |
+
"first_hard_question": "..."
|
| 116 |
+
}
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
+
|
| 121 |
+
## Pitch Deck Critique Prompt (MiniCPM-V / Nemotron Vision)
|
| 122 |
+
|
| 123 |
+
```text
|
| 124 |
+
You are a hackathon judge reviewing one pitch slide image.
|
| 125 |
+
|
| 126 |
+
Critique: clarity, problem, solution, market, ask.
|
| 127 |
+
Return JSON:
|
| 128 |
+
{
|
| 129 |
+
"slide_summary": "...",
|
| 130 |
+
"strengths": ["..."],
|
| 131 |
+
"weaknesses": ["..."],
|
| 132 |
+
"judge_questions": ["...", "...", "..."]
|
| 133 |
+
}
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## Persona Behavior Rules
|
| 139 |
+
|
| 140 |
+
| Persona | Focus |
|
| 141 |
+
|---|---|
|
| 142 |
+
| Skeptical VC | Market size, moat, retention, revenue, defensibility |
|
| 143 |
+
| Technical Judge | AI justification, architecture, scalability, data quality |
|
| 144 |
+
| Hackathon Judge | Novelty, demo clarity, MVP strength, backyard fit |
|
| 145 |
+
|
| 146 |
+
## Attack Tag Lists
|
| 147 |
+
|
| 148 |
+
### Skeptical VC
|
| 149 |
+
Market Size, Moat, Retention, Revenue Logic, First 100 Users, Why Now, Competition, Defensibility
|
| 150 |
+
|
| 151 |
+
### Technical Judge
|
| 152 |
+
AI Justification, Architecture, Scalability, Latency, Data Quality, Failure Mode, Simpler Alternative, Technical Feasibility
|
| 153 |
+
|
| 154 |
+
### Hackathon Judge
|
| 155 |
+
Novelty, Demo Clarity, MVP Strength, User Pain, AI Load-Bearing, Backyard Fit, Practical Impact, Judging Memorability
|
docs/TASK_TRACKER.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PitchFight AI — Task Tracker
|
| 2 |
+
|
| 3 |
+
## Current Phase
|
| 4 |
+
|
| 5 |
+
Phase 2 complete: Model Router + Secrets Setup (2026-06-08)
|
| 6 |
+
|
| 7 |
+
## Next Phase
|
| 8 |
+
|
| 9 |
+
Phase 3: Wire model_router into battle flow (handle_start_session + handle_chat_round)
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## Phase Status Table
|
| 14 |
+
|
| 15 |
+
| Phase | Name | Status | Notes |
|
| 16 |
+
|---|---|---|---|
|
| 17 |
+
| 0 | Strategy Reset + Documentation Alignment | Complete | Off-the-Grid removed; sponsor-model strategy locked |
|
| 18 |
+
| 1 | Project Skeleton Verification | Complete | Mock app runs; custom frontend live; all /api/* endpoints return responses |
|
| 19 |
+
| 2 | Model Router + Secrets Setup | **Complete** (2026-06-08) | nvidia_client, model_router, stub clients created; NVIDIA tested live |
|
| 20 |
+
| 3 | NVIDIA Nemotron Omni Integration | Pending | Depends on Phase 2 |
|
| 21 |
+
| 4 | Pitch Battle Engine | Pending | Replace mock followups with real Nemotron responses |
|
| 22 |
+
| 5 | Scorecard + Feedback Engine | Pending | Real scoring via Nemotron; json_utils already ready |
|
| 23 |
+
| 6 | Custom Frontend Integration | Pending | Model mode badge; error/loading polish |
|
| 24 |
+
| 7 | Voice Pitch Mode | Pending | Browser audio → Nemotron Omni; fallback to whisper |
|
| 25 |
+
| 8 | Deal Battle Mode | Pending | Negotiation scenarios + deal personas |
|
| 26 |
+
| 9 | MiniCPM / OpenBMB Modes | Pending | MiniCPM-o, MiniCPM5-1B, fallback routing |
|
| 27 |
+
| 10 | Pitch Deck Critique Mode | Pending | MiniCPM-V 4.6 image upload + slide critique |
|
| 28 |
+
| 11 | Retry + Report Enhancements | Pending | Retry weakest question; downloadable markdown report |
|
| 29 |
+
| 12 | UI Polish + Demo Flow | Pending | Animations, model badges, mobile, dark arena look |
|
| 30 |
+
| 13 | Hugging Face Spaces Deployment | Pending | HF Space Secrets, public test, screenshots |
|
| 31 |
+
| 14 | Final Testing + Submission Readiness | Pending | All endpoints, all modes, all failure paths |
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## Immediate Next Tasks (Phase 3)
|
| 36 |
+
|
| 37 |
+
1. In `core/api_handlers.py`: import `model_router` and `persona_builder`
|
| 38 |
+
2. In `handle_start_session`: build system prompt via `persona_builder.build_persona_prompt()`, call `model_router.generate_opponent_response()`, use mock opening as fallback if `ok=False`
|
| 39 |
+
3. In `handle_chat_round`: build message list from session history, call `model_router.generate_opponent_response()`, use mock followup as fallback if `ok=False`
|
| 40 |
+
4. Run full battle flow end-to-end: load sample → start session → 2 chat rounds → end battle
|
| 41 |
+
5. Confirm Nemotron response is sharp, persona-locked, and references attack tag
|
| 42 |
+
6. Update docs after confirming end-to-end works
|
| 43 |
+
|
| 44 |
+
**Do not touch scoring, voice, deal battle, or deck critique in Phase 3.**
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
## Phase 2 Completed Tasks
|
| 49 |
+
|
| 50 |
+
- [x] `.env.example` verified — all variables present and correct
|
| 51 |
+
- [x] `core/nvidia_client.py` created
|
| 52 |
+
- [x] Reads keys from `os.getenv` only — no hardcoded values
|
| 53 |
+
- [x] `generate_nemotron_response(messages, mode, temperature, max_tokens, timeout)` implemented
|
| 54 |
+
- [x] Timeout handling (30s default)
|
| 55 |
+
- [x] Returns string on success; raises `RuntimeError` with clean message on failure
|
| 56 |
+
- [x] `reasoning_content` fallback for reasoning model edge cases
|
| 57 |
+
- [x] No API key or URL hardcoded anywhere
|
| 58 |
+
- [x] `core/model_router.py` created
|
| 59 |
+
- [x] Routes `premium_nvidia` → nvidia_client (live)
|
| 60 |
+
- [x] Stub/placeholder routes for `openbmb_omni`, `tiny_minicpm`, `vision_deck`, `whisper_fallback`
|
| 61 |
+
- [x] Graceful fallback if NVIDIA fails (`ok=False`, fallback message returned, no crash)
|
| 62 |
+
- [x] `get_model_health()` — all providers, no key exposure
|
| 63 |
+
- [x] `core/minicpm_client.py` stub created (health_check only)
|
| 64 |
+
- [x] `core/vision_client.py` stub created (health_check only)
|
| 65 |
+
- [x] `core/transcription_client.py` stub created (health_check only)
|
| 66 |
+
- [x] `scripts/test_nvidia_client.py` — isolated test, exits 0 on success / 1 on failure
|
| 67 |
+
- [x] Isolated NVIDIA test: **PASSED** — live Nemotron response confirmed
|
| 68 |
+
- [x] `GET /api/model-health` added to `app.py`
|
| 69 |
+
- [x] `requirements.txt` updated: `openai>=1.0.0`, `httpx`, `requests` added
|
| 70 |
+
- [x] Mock followup lists remain in `api_handlers.py` — not removed
|
| 71 |
+
- [x] `docs/PHASE_WISE_PLAN.md` Phase 2 marked Complete
|
| 72 |
+
- [x] `docs/FIELD_NOTES.md` updated with Phase 2 build log
|
| 73 |
+
- [x] `docs/BACKEND_API.md` `/api/model-health` added
|
| 74 |
+
- [x] `docs/MODELS_FINAL.md` model-to-file mapping updated with Phase 2 status
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## Phase 1 Completed Tasks (for reference)
|
| 79 |
+
|
| 80 |
+
- [x] `app.py` — Gradio Server with `/api/*` REST routes and `@app.api` wrappers
|
| 81 |
+
- [x] `frontend/index.html`, `styles.css`, `script.js` — custom battle arena UI
|
| 82 |
+
- [x] `core/session_manager.py` — in-memory session store
|
| 83 |
+
- [x] `core/persona_builder.py` — system prompt builder per persona
|
| 84 |
+
- [x] `core/attack_tags.py` — tag taxonomy + round-based selector
|
| 85 |
+
- [x] `core/scoring_engine.py` — mock scorecard with real session quotes
|
| 86 |
+
- [x] `core/feedback_generator.py` — mock rewrite templates
|
| 87 |
+
- [x] `core/json_utils.py` — JSON extraction, safe parse, fallback scorecard
|
| 88 |
+
- [x] `core/samples.py` — EventRadar AI sample startup
|
| 89 |
+
- [x] `core/local_text_model.py` — Phase 1 stub
|
| 90 |
+
- [x] `core/voice_transcriber.py` — Phase 1 stub
|
| 91 |
+
- [x] `config/personas.json`, `attack_tags.json`, `pitch_rubric.json`, `sample_startups.json`
|
| 92 |
+
- [x] `docs/BACKEND_API.md`, `DOCUMENTATION.md`, `FIELD_NOTES.md`, `DEMO_NOTES.md`, `MODELS_FINAL.md`, `PHASE_WISE_PLAN.md`, `PROMPTS.md`
|
| 93 |
+
- [x] `.env.example` — all required variables present
|
| 94 |
+
|
| 95 |
+
---
|
| 96 |
+
|
| 97 |
+
## Risks Before Phase 2
|
| 98 |
+
|
| 99 |
+
| Risk | Mitigation |
|
| 100 |
+
|---|---|
|
| 101 |
+
| NVIDIA API key not yet provisioned | Get key from NVIDIA developer portal; add to `.env` before Phase 2 testing |
|
| 102 |
+
| Nemotron endpoint may need special headers or model format | Test isolated before wiring into session flow |
|
| 103 |
+
| `openai` Python SDK compatibility with NVIDIA base URL | NVIDIA uses OpenAI-compatible API; use `openai.OpenAI(base_url=..., api_key=...)` |
|
| 104 |
+
| Session manager is in-memory; process restart loses sessions | Acceptable for hackathon; note as known limitation |
|
| 105 |
+
| Mock fallback strings (MOCK_FOLLOWUPS) must stay in api_handlers.py | Do not delete until real model routing is confirmed stable |
|
frontend/assets/logo.svg
ADDED
|
|
frontend/index.html
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>PitchFight AI</title>
|
| 7 |
+
<link rel="icon" href="/frontend/assets/logo.svg" type="image/svg+xml" />
|
| 8 |
+
<link rel="stylesheet" href="/frontend/styles.css" />
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<div class="bg-glow"></div>
|
| 12 |
+
|
| 13 |
+
<div id="error-banner" class="error-banner" hidden role="alert"></div>
|
| 14 |
+
|
| 15 |
+
<main id="app" class="app">
|
| 16 |
+
<!-- Landing -->
|
| 17 |
+
<section id="screen-landing" class="screen active">
|
| 18 |
+
<header class="hero">
|
| 19 |
+
<img src="/frontend/assets/logo.svg" alt="PitchFight AI logo" class="logo" />
|
| 20 |
+
<h1>PitchFight AI</h1>
|
| 21 |
+
<p class="tagline">Your first tough pitch should not be in front of a real judge.</p>
|
| 22 |
+
<p class="subtitle">A small-model pressure simulator for student founders.</p>
|
| 23 |
+
</header>
|
| 24 |
+
<div class="hero-actions">
|
| 25 |
+
<button id="btn-load-sample" class="btn btn-secondary">Load Demo Startup</button>
|
| 26 |
+
<button id="btn-go-setup" class="btn btn-primary">Start Pitch Battle</button>
|
| 27 |
+
</div>
|
| 28 |
+
</section>
|
| 29 |
+
|
| 30 |
+
<!-- Setup -->
|
| 31 |
+
<section id="screen-setup" class="screen">
|
| 32 |
+
<div class="panel glass">
|
| 33 |
+
<div class="panel-header">
|
| 34 |
+
<h2>Startup Context</h2>
|
| 35 |
+
<button id="btn-back-landing" class="btn btn-ghost">Back</button>
|
| 36 |
+
</div>
|
| 37 |
+
<form id="startup-form" class="startup-form">
|
| 38 |
+
<label>Name<input name="name" type="text" placeholder="EventRadar AI" required /></label>
|
| 39 |
+
<label>Problem<textarea name="problem" rows="2" required></textarea></label>
|
| 40 |
+
<label>Target Users<input name="target_users" type="text" required /></label>
|
| 41 |
+
<label>Solution<textarea name="solution" rows="2" required></textarea></label>
|
| 42 |
+
<label>Why AI<textarea name="why_ai" rows="2" required></textarea></label>
|
| 43 |
+
<label>Competitors<input name="competitors" type="text" required /></label>
|
| 44 |
+
<label>Traction<input name="traction" type="text" required /></label>
|
| 45 |
+
<label>Ask<input name="ask" type="text" required /></label>
|
| 46 |
+
</form>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<div class="panel glass">
|
| 50 |
+
<h2>Choose Your Opponent</h2>
|
| 51 |
+
<div class="persona-grid">
|
| 52 |
+
<button class="persona-card" data-persona="skeptical_vc">
|
| 53 |
+
<span class="persona-icon">💼</span>
|
| 54 |
+
<h3>Skeptical VC</h3>
|
| 55 |
+
<p>Market, moat, revenue, defensibility</p>
|
| 56 |
+
</button>
|
| 57 |
+
<button class="persona-card" data-persona="technical_judge">
|
| 58 |
+
<span class="persona-icon">🛠️</span>
|
| 59 |
+
<h3>Technical Judge</h3>
|
| 60 |
+
<p>AI necessity, architecture, feasibility</p>
|
| 61 |
+
</button>
|
| 62 |
+
<button class="persona-card selected" data-persona="hackathon_judge">
|
| 63 |
+
<span class="persona-icon">🏆</span>
|
| 64 |
+
<h3>Hackathon Judge</h3>
|
| 65 |
+
<p>Novelty, demo clarity, MVP strength</p>
|
| 66 |
+
</button>
|
| 67 |
+
</div>
|
| 68 |
+
<button id="btn-start-battle" class="btn btn-primary btn-wide">Enter the Arena</button>
|
| 69 |
+
</div>
|
| 70 |
+
</section>
|
| 71 |
+
|
| 72 |
+
<!-- Battle -->
|
| 73 |
+
<section id="screen-battle" class="screen">
|
| 74 |
+
<div class="battle-layout">
|
| 75 |
+
<aside class="battle-meta glass">
|
| 76 |
+
<h2>Battle Arena</h2>
|
| 77 |
+
<div class="meta-item"><span>Round</span><strong id="round-counter">1</strong></div>
|
| 78 |
+
<div class="meta-item"><span>Pressure</span><strong id="pressure-level" class="pressure-high">High</strong></div>
|
| 79 |
+
<div class="meta-item"><span>Phase</span><strong id="battle-phase" hidden>explore</strong></div>
|
| 80 |
+
<div class="meta-item"><span>Attack Tag</span><strong id="attack-tag">—</strong></div>
|
| 81 |
+
<button id="btn-end-battle" class="btn btn-danger btn-wide">End Battle</button>
|
| 82 |
+
<button id="btn-back-scorecard" class="btn btn-ghost btn-wide" hidden>Back to Scorecard</button>
|
| 83 |
+
</aside>
|
| 84 |
+
|
| 85 |
+
<div class="battle-chat glass">
|
| 86 |
+
<div id="chat-window" class="chat-window" aria-live="polite"></div>
|
| 87 |
+
<form id="chat-form" class="chat-input-row">
|
| 88 |
+
<textarea id="user-input" rows="2" placeholder="Defend your startup under pressure..."></textarea>
|
| 89 |
+
<button id="btn-send" type="submit" class="btn btn-primary">Send</button>
|
| 90 |
+
</form>
|
| 91 |
+
<p id="battle-status" class="status-text" hidden></p>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
</section>
|
| 95 |
+
|
| 96 |
+
<!-- Scorecard -->
|
| 97 |
+
<section id="screen-scorecard" class="screen">
|
| 98 |
+
<div class="panel glass scorecard-panel">
|
| 99 |
+
<header class="scorecard-header">
|
| 100 |
+
<div class="scorecard-header-left">
|
| 101 |
+
<h2>Battle Scorecard</h2>
|
| 102 |
+
<button id="btn-view-conversation" class="btn btn-ghost btn-sm">View Conversation</button>
|
| 103 |
+
</div>
|
| 104 |
+
<div class="overall-score">
|
| 105 |
+
<span>Overall</span>
|
| 106 |
+
<strong id="overall-score">0</strong>
|
| 107 |
+
<span id="overall-label" hidden style="font-size:0.85rem;color:var(--gold);display:block;margin-top:0.2rem"></span>
|
| 108 |
+
</div>
|
| 109 |
+
<p id="scorecard-source-badge" hidden style="font-size:0.8rem;opacity:0.7;margin:0"></p>
|
| 110 |
+
</header>
|
| 111 |
+
|
| 112 |
+
<div id="score-bars" class="score-bars"></div>
|
| 113 |
+
|
| 114 |
+
<p id="signals-summary" class="signals-summary" hidden></p>
|
| 115 |
+
|
| 116 |
+
<div class="feedback-grid">
|
| 117 |
+
<article class="feedback-card highlight">
|
| 118 |
+
<h3>Improved Answer</h3>
|
| 119 |
+
<p id="improved-answer"></p>
|
| 120 |
+
</article>
|
| 121 |
+
<article class="feedback-card highlight">
|
| 122 |
+
<h3>Improved Pitch</h3>
|
| 123 |
+
<p id="improved-pitch"></p>
|
| 124 |
+
</article>
|
| 125 |
+
<article class="feedback-card">
|
| 126 |
+
<h3>Best Answer</h3>
|
| 127 |
+
<p id="best-answer"></p>
|
| 128 |
+
</article>
|
| 129 |
+
<article class="feedback-card">
|
| 130 |
+
<h3>Weakest Answer</h3>
|
| 131 |
+
<p id="weakest-answer"></p>
|
| 132 |
+
</article>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
<div class="prep-questions">
|
| 136 |
+
<h3>Top 3 Prep Questions</h3>
|
| 137 |
+
<ol id="top-questions"></ol>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
<div class="scorecard-actions">
|
| 141 |
+
<button id="btn-reset" class="btn btn-secondary">New Battle</button>
|
| 142 |
+
<button id="btn-back-setup" class="btn btn-primary">Edit Startup</button>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</section>
|
| 146 |
+
</main>
|
| 147 |
+
|
| 148 |
+
<div id="loading-overlay" class="loading-overlay" hidden>
|
| 149 |
+
<div class="spinner"></div>
|
| 150 |
+
<p>Loading...</p>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<script type="module" src="/frontend/script.js"></script>
|
| 154 |
+
</body>
|
| 155 |
+
</html>
|
frontend/script.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const state = {
|
| 2 |
+
sessionId: null,
|
| 3 |
+
persona: "hackathon_judge",
|
| 4 |
+
round: 1,
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
const screens = {
|
| 8 |
+
landing: document.getElementById("screen-landing"),
|
| 9 |
+
setup: document.getElementById("screen-setup"),
|
| 10 |
+
battle: document.getElementById("screen-battle"),
|
| 11 |
+
scorecard: document.getElementById("screen-scorecard"),
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
const startupForm = document.getElementById("startup-form");
|
| 15 |
+
const chatWindow = document.getElementById("chat-window");
|
| 16 |
+
const userInput = document.getElementById("user-input");
|
| 17 |
+
const loadingOverlay = document.getElementById("loading-overlay");
|
| 18 |
+
const loadingText = loadingOverlay?.querySelector("p");
|
| 19 |
+
const battleStatus = document.getElementById("battle-status");
|
| 20 |
+
const errorBanner = document.getElementById("error-banner");
|
| 21 |
+
|
| 22 |
+
function showScreen(name) {
|
| 23 |
+
Object.entries(screens).forEach(([key, el]) => {
|
| 24 |
+
el.classList.toggle("active", key === name);
|
| 25 |
+
});
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function setGlobalLoading(isLoading, message = "Loading...") {
|
| 29 |
+
if (loadingText) loadingText.textContent = message;
|
| 30 |
+
loadingOverlay.hidden = !isLoading;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function showErrorBanner(message) {
|
| 34 |
+
errorBanner.textContent = message;
|
| 35 |
+
errorBanner.hidden = false;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function hideErrorBanner() {
|
| 39 |
+
errorBanner.hidden = true;
|
| 40 |
+
errorBanner.textContent = "";
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function getStartupPayload() {
|
| 44 |
+
const data = new FormData(startupForm);
|
| 45 |
+
return Object.fromEntries(data.entries());
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function fillStartupForm(startup) {
|
| 49 |
+
Object.entries(startup).forEach(([key, value]) => {
|
| 50 |
+
const field = startupForm.elements.namedItem(key);
|
| 51 |
+
if (field) field.value = value ?? "";
|
| 52 |
+
});
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function appendMessage(role, text, meta = "") {
|
| 56 |
+
const bubble = document.createElement("div");
|
| 57 |
+
bubble.className = `message ${role}`;
|
| 58 |
+
bubble.innerHTML = meta
|
| 59 |
+
? `<span class="message-meta">${meta}</span><p>${escapeHtml(text)}</p>`
|
| 60 |
+
: `<p>${escapeHtml(text)}</p>`;
|
| 61 |
+
chatWindow.appendChild(bubble);
|
| 62 |
+
chatWindow.scrollTop = chatWindow.scrollHeight;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function escapeHtml(text) {
|
| 66 |
+
return String(text)
|
| 67 |
+
.replaceAll("&", "&")
|
| 68 |
+
.replaceAll("<", "<")
|
| 69 |
+
.replaceAll(">", ">");
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function updateBattleMeta(data) {
|
| 73 |
+
document.getElementById("round-counter").textContent = data.round ?? state.round;
|
| 74 |
+
const pressureEl = document.getElementById("pressure-level");
|
| 75 |
+
pressureEl.textContent = data.pressure_level ?? "High";
|
| 76 |
+
pressureEl.className = `pressure-${(data.pressure_level ?? "high").toLowerCase()}`;
|
| 77 |
+
document.getElementById("attack-tag").textContent = data.attack_tag ?? "—";
|
| 78 |
+
state.round = data.round ?? state.round;
|
| 79 |
+
|
| 80 |
+
const phaseEl = document.getElementById("battle-phase");
|
| 81 |
+
if (phaseEl && data.battle_phase) {
|
| 82 |
+
phaseEl.textContent = data.battle_phase;
|
| 83 |
+
phaseEl.hidden = false;
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
async function apiPost(path, body = undefined) {
|
| 88 |
+
console.log(`API POST ${path}`, body ?? {});
|
| 89 |
+
const options = { method: "POST", headers: {} };
|
| 90 |
+
|
| 91 |
+
if (body !== undefined) {
|
| 92 |
+
options.headers["Content-Type"] = "application/json";
|
| 93 |
+
options.body = JSON.stringify(body);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
const response = await fetch(path, options);
|
| 97 |
+
const data = await response.json().catch(() => ({}));
|
| 98 |
+
|
| 99 |
+
if (!response.ok) {
|
| 100 |
+
const detail = data.detail || data.error || response.statusText;
|
| 101 |
+
throw new Error(`${path} failed: ${detail}`);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
console.log(`API POST ${path} OK`, data);
|
| 105 |
+
return data;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
export async function loadSample() {
|
| 109 |
+
try {
|
| 110 |
+
setGlobalLoading(true, "Loading demo startup...");
|
| 111 |
+
const data = await apiPost("/api/load-sample");
|
| 112 |
+
fillStartupForm(data.startup);
|
| 113 |
+
showScreen("setup");
|
| 114 |
+
hideErrorBanner();
|
| 115 |
+
} catch (error) {
|
| 116 |
+
console.error(error);
|
| 117 |
+
showErrorBanner("Failed to load demo startup. Check backend logs.");
|
| 118 |
+
} finally {
|
| 119 |
+
setGlobalLoading(false);
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
export async function startSession() {
|
| 124 |
+
try {
|
| 125 |
+
setGlobalLoading(true, "Starting pitch battle...");
|
| 126 |
+
battleStatus.hidden = true;
|
| 127 |
+
chatWindow.innerHTML = "";
|
| 128 |
+
|
| 129 |
+
const payload = {
|
| 130 |
+
mode: "pitch_battle",
|
| 131 |
+
startup: getStartupPayload(),
|
| 132 |
+
persona: state.persona,
|
| 133 |
+
difficulty: "high",
|
| 134 |
+
input_mode: "text",
|
| 135 |
+
model_mode: "premium_nvidia",
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
const data = await apiPost("/api/start-session", payload);
|
| 139 |
+
|
| 140 |
+
if (data.error) {
|
| 141 |
+
showErrorBanner(data.error);
|
| 142 |
+
return;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
state.sessionId = data.session_id;
|
| 146 |
+
state.round = data.round ?? 1;
|
| 147 |
+
userInput.disabled = false;
|
| 148 |
+
const submitBtn = document.getElementById("chat-form").querySelector("button[type=submit]");
|
| 149 |
+
if (submitBtn) submitBtn.disabled = false;
|
| 150 |
+
updateBattleMeta(data);
|
| 151 |
+
const startBadge = data.model_ok ? "⚡ Premium Nemotron" : "Mock";
|
| 152 |
+
appendMessage("ai", data.ai_message, `${data.attack_tag} · Round ${data.round} · ${startBadge}`);
|
| 153 |
+
showScreen("battle");
|
| 154 |
+
hideErrorBanner();
|
| 155 |
+
} catch (error) {
|
| 156 |
+
console.error(error);
|
| 157 |
+
showErrorBanner("Failed to start battle. Check backend logs.");
|
| 158 |
+
} finally {
|
| 159 |
+
setGlobalLoading(false);
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
export async function sendMessage() {
|
| 164 |
+
const message = userInput.value.trim();
|
| 165 |
+
if (!message || !state.sessionId) return;
|
| 166 |
+
|
| 167 |
+
try {
|
| 168 |
+
setGlobalLoading(true, "Sending answer...");
|
| 169 |
+
userInput.value = "";
|
| 170 |
+
appendMessage("user", message);
|
| 171 |
+
|
| 172 |
+
const data = await apiPost("/api/chat-round", {
|
| 173 |
+
session_id: state.sessionId,
|
| 174 |
+
user_message: message,
|
| 175 |
+
});
|
| 176 |
+
|
| 177 |
+
if (data.error) {
|
| 178 |
+
battleStatus.hidden = false;
|
| 179 |
+
battleStatus.textContent = data.ai_message || data.error;
|
| 180 |
+
return;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
updateBattleMeta(data);
|
| 184 |
+
const chatBadge = data.model_ok ? "⚡ Premium Nemotron" : "Mock";
|
| 185 |
+
appendMessage("ai", data.ai_message, `${data.attack_tag} · Round ${data.round} · ${chatBadge}`);
|
| 186 |
+
|
| 187 |
+
if (data.soft_round_limit_reached) {
|
| 188 |
+
battleStatus.hidden = false;
|
| 189 |
+
battleStatus.textContent = data.completion_message
|
| 190 |
+
?? "You have enough material for a scorecard. End Battle when ready.";
|
| 191 |
+
battleStatus.style.color = "var(--gold)";
|
| 192 |
+
}
|
| 193 |
+
} catch (error) {
|
| 194 |
+
console.error(error);
|
| 195 |
+
battleStatus.hidden = false;
|
| 196 |
+
battleStatus.textContent = "Message failed. Try again.";
|
| 197 |
+
} finally {
|
| 198 |
+
setGlobalLoading(false);
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
export async function endBattle() {
|
| 203 |
+
if (!state.sessionId) return;
|
| 204 |
+
|
| 205 |
+
try {
|
| 206 |
+
setGlobalLoading(true, "Generating scorecard...");
|
| 207 |
+
const data = await apiPost("/api/end-battle", {
|
| 208 |
+
session_id: state.sessionId,
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
if (data.error) {
|
| 212 |
+
showErrorBanner(data.error);
|
| 213 |
+
return;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
renderScorecard(data);
|
| 217 |
+
showScreen("scorecard");
|
| 218 |
+
hideErrorBanner();
|
| 219 |
+
} catch (error) {
|
| 220 |
+
console.error(error);
|
| 221 |
+
showErrorBanner("Failed to generate scorecard. Check backend logs.");
|
| 222 |
+
} finally {
|
| 223 |
+
setGlobalLoading(false);
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
export async function resetBattle() {
|
| 228 |
+
if (state.sessionId) {
|
| 229 |
+
try {
|
| 230 |
+
await apiPost("/api/reset-session", { session_id: state.sessionId });
|
| 231 |
+
} catch (error) {
|
| 232 |
+
console.error(error);
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
state.sessionId = null;
|
| 237 |
+
state.round = 1;
|
| 238 |
+
chatWindow.innerHTML = "";
|
| 239 |
+
userInput.value = "";
|
| 240 |
+
showScreen("landing");
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
function renderScorecard(data) {
|
| 244 |
+
const overall = data.overall ?? 0;
|
| 245 |
+
document.getElementById("overall-score").textContent = overall;
|
| 246 |
+
|
| 247 |
+
const overallLabelEl = document.getElementById("overall-label");
|
| 248 |
+
if (overallLabelEl) {
|
| 249 |
+
overallLabelEl.textContent = data.overall_label ?? "";
|
| 250 |
+
overallLabelEl.hidden = !data.overall_label;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
const sourceBadgeEl = document.getElementById("scorecard-source-badge");
|
| 254 |
+
if (sourceBadgeEl) {
|
| 255 |
+
const src = data.scorecard_source ?? "";
|
| 256 |
+
sourceBadgeEl.textContent =
|
| 257 |
+
src === "hybrid_claims_nemotron" ? "⚡ Claim-based score + Premium Nemotron coaching" :
|
| 258 |
+
src === "hybrid_claims_local" ? "Claim-based local scorecard" :
|
| 259 |
+
src === "nemotron" ? "⚡ Scored by Premium Nemotron" :
|
| 260 |
+
src === "nemotron_repaired" ? "⚡ Scored by Premium Nemotron (repaired)" :
|
| 261 |
+
src === "session_fallback" ? "Session-based scorecard (model unavailable)" :
|
| 262 |
+
"Mock fallback scorecard";
|
| 263 |
+
sourceBadgeEl.hidden = false;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
const bars = document.getElementById("score-bars");
|
| 267 |
+
bars.innerHTML = "";
|
| 268 |
+
const scores = data.scores ?? {};
|
| 269 |
+
|
| 270 |
+
Object.entries(scores).forEach(([key, value]) => {
|
| 271 |
+
const row = document.createElement("div");
|
| 272 |
+
row.className = "score-row";
|
| 273 |
+
const dimLabel = key.replaceAll("_", " ");
|
| 274 |
+
const scoreLabel = value.label ? `<span class="score-label">${escapeHtml(value.label)}</span>` : "";
|
| 275 |
+
row.innerHTML = `
|
| 276 |
+
<div class="score-row-head">
|
| 277 |
+
<span>${dimLabel}${scoreLabel}</span>
|
| 278 |
+
<strong>${value.score}</strong>
|
| 279 |
+
</div>
|
| 280 |
+
<div class="bar-track"><div class="bar-fill" style="width:${value.score}%"></div></div>
|
| 281 |
+
<p class="score-reason">${escapeHtml(value.reason ?? "")}</p>
|
| 282 |
+
`;
|
| 283 |
+
bars.appendChild(row);
|
| 284 |
+
});
|
| 285 |
+
|
| 286 |
+
// Concrete signals summary
|
| 287 |
+
const sigEl = document.getElementById("signals-summary");
|
| 288 |
+
if (sigEl) {
|
| 289 |
+
const css = data.concrete_signals_summary ?? {};
|
| 290 |
+
const allSigs = [
|
| 291 |
+
...(css.numbers ?? []),
|
| 292 |
+
...(css.validation ?? []),
|
| 293 |
+
...(css.competitors ?? []),
|
| 294 |
+
...(css.revenue_signals ?? []),
|
| 295 |
+
...(css.technical_mechanisms ?? []),
|
| 296 |
+
].slice(0, 8);
|
| 297 |
+
if (allSigs.length > 0) {
|
| 298 |
+
sigEl.textContent = "Signals detected: " + allSigs.join(" · ");
|
| 299 |
+
sigEl.hidden = false;
|
| 300 |
+
} else {
|
| 301 |
+
sigEl.hidden = true;
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
// Reordered: improved content first, then answers
|
| 306 |
+
document.getElementById("improved-answer").textContent = data.improved_answer ?? "";
|
| 307 |
+
document.getElementById("improved-pitch").textContent = data.improved_pitch ?? "";
|
| 308 |
+
document.getElementById("best-answer").textContent = data.best_answer ?? "";
|
| 309 |
+
document.getElementById("weakest-answer").textContent = data.weakest_answer ?? "";
|
| 310 |
+
|
| 311 |
+
const list = document.getElementById("top-questions");
|
| 312 |
+
list.innerHTML = "";
|
| 313 |
+
(data.top_3_questions ?? []).forEach((q) => {
|
| 314 |
+
const li = document.createElement("li");
|
| 315 |
+
li.textContent = q;
|
| 316 |
+
list.appendChild(li);
|
| 317 |
+
});
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
document.getElementById("btn-load-sample").addEventListener("click", loadSample);
|
| 321 |
+
document.getElementById("btn-go-setup").addEventListener("click", () => showScreen("setup"));
|
| 322 |
+
document.getElementById("btn-back-landing").addEventListener("click", () => showScreen("landing"));
|
| 323 |
+
document.getElementById("btn-start-battle").addEventListener("click", startSession);
|
| 324 |
+
document.getElementById("btn-end-battle").addEventListener("click", endBattle);
|
| 325 |
+
document.getElementById("btn-reset").addEventListener("click", resetBattle);
|
| 326 |
+
document.getElementById("btn-back-setup").addEventListener("click", () => showScreen("setup"));
|
| 327 |
+
|
| 328 |
+
document.getElementById("btn-view-conversation").addEventListener("click", () => {
|
| 329 |
+
document.getElementById("btn-end-battle").hidden = true;
|
| 330 |
+
document.getElementById("btn-back-scorecard").hidden = false;
|
| 331 |
+
document.getElementById("chat-form").hidden = true;
|
| 332 |
+
showScreen("battle");
|
| 333 |
+
chatWindow.scrollTop = chatWindow.scrollHeight;
|
| 334 |
+
});
|
| 335 |
+
|
| 336 |
+
document.getElementById("btn-back-scorecard").addEventListener("click", () => {
|
| 337 |
+
document.getElementById("btn-end-battle").hidden = false;
|
| 338 |
+
document.getElementById("btn-back-scorecard").hidden = true;
|
| 339 |
+
document.getElementById("chat-form").hidden = false;
|
| 340 |
+
showScreen("scorecard");
|
| 341 |
+
});
|
| 342 |
+
|
| 343 |
+
document.querySelectorAll(".persona-card").forEach((card) => {
|
| 344 |
+
card.addEventListener("click", () => {
|
| 345 |
+
document.querySelectorAll(".persona-card").forEach((c) => c.classList.remove("selected"));
|
| 346 |
+
card.classList.add("selected");
|
| 347 |
+
state.persona = card.dataset.persona;
|
| 348 |
+
});
|
| 349 |
+
});
|
| 350 |
+
|
| 351 |
+
document.getElementById("chat-form").addEventListener("submit", (event) => {
|
| 352 |
+
event.preventDefault();
|
| 353 |
+
sendMessage();
|
| 354 |
+
});
|
| 355 |
+
|
| 356 |
+
function boot() {
|
| 357 |
+
console.log("PitchFight frontend booting...");
|
| 358 |
+
setGlobalLoading(false);
|
| 359 |
+
hideErrorBanner();
|
| 360 |
+
|
| 361 |
+
fetch("/health")
|
| 362 |
+
.then((response) => response.json())
|
| 363 |
+
.then((data) => console.log("Backend health:", data))
|
| 364 |
+
.catch((error) => {
|
| 365 |
+
console.warn("Health check failed:", error);
|
| 366 |
+
showErrorBanner(
|
| 367 |
+
"Backend health check failed. Run python app.py and refresh this page."
|
| 368 |
+
);
|
| 369 |
+
});
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
if (document.readyState === "loading") {
|
| 373 |
+
document.addEventListener("DOMContentLoaded", boot);
|
| 374 |
+
} else {
|
| 375 |
+
boot();
|
| 376 |
+
}
|
frontend/styles.css
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg: #06080f;
|
| 3 |
+
--bg-panel: rgba(18, 22, 36, 0.78);
|
| 4 |
+
--text: #f8f4e8;
|
| 5 |
+
--muted: #b9b3a4;
|
| 6 |
+
--red: #e63946;
|
| 7 |
+
--gold: #f4d35e;
|
| 8 |
+
--border: rgba(244, 211, 94, 0.18);
|
| 9 |
+
--shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
* {
|
| 13 |
+
box-sizing: border-box;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
body {
|
| 17 |
+
margin: 0;
|
| 18 |
+
min-height: 100vh;
|
| 19 |
+
font-family: "Segoe UI", system-ui, sans-serif;
|
| 20 |
+
color: var(--text);
|
| 21 |
+
background: radial-gradient(circle at top, #12182a 0%, var(--bg) 55%);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.bg-glow {
|
| 25 |
+
position: fixed;
|
| 26 |
+
inset: 0;
|
| 27 |
+
pointer-events: none;
|
| 28 |
+
background:
|
| 29 |
+
radial-gradient(circle at 15% 20%, rgba(230, 57, 70, 0.18), transparent 35%),
|
| 30 |
+
radial-gradient(circle at 85% 10%, rgba(244, 211, 94, 0.12), transparent 30%);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.app {
|
| 34 |
+
position: relative;
|
| 35 |
+
max-width: 1100px;
|
| 36 |
+
margin: 0 auto;
|
| 37 |
+
padding: 2rem 1rem 3rem;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.screen {
|
| 41 |
+
display: none;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.screen.active {
|
| 45 |
+
display: block;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.glass {
|
| 49 |
+
background: var(--bg-panel);
|
| 50 |
+
border: 1px solid var(--border);
|
| 51 |
+
border-radius: 18px;
|
| 52 |
+
backdrop-filter: blur(12px);
|
| 53 |
+
box-shadow: var(--shadow);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.hero {
|
| 57 |
+
text-align: center;
|
| 58 |
+
padding: 2rem 1rem;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.logo {
|
| 62 |
+
width: 88px;
|
| 63 |
+
height: 88px;
|
| 64 |
+
margin-bottom: 1rem;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
h1 {
|
| 68 |
+
margin: 0;
|
| 69 |
+
font-size: clamp(2rem, 5vw, 3rem);
|
| 70 |
+
letter-spacing: 0.02em;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.tagline {
|
| 74 |
+
font-size: 1.15rem;
|
| 75 |
+
color: var(--gold);
|
| 76 |
+
margin: 0.75rem 0 0.35rem;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.subtitle {
|
| 80 |
+
color: var(--muted);
|
| 81 |
+
margin: 0;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.hero-actions {
|
| 85 |
+
display: flex;
|
| 86 |
+
gap: 1rem;
|
| 87 |
+
justify-content: center;
|
| 88 |
+
flex-wrap: wrap;
|
| 89 |
+
margin-top: 2rem;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.btn {
|
| 93 |
+
border: none;
|
| 94 |
+
border-radius: 12px;
|
| 95 |
+
padding: 0.85rem 1.25rem;
|
| 96 |
+
font-weight: 700;
|
| 97 |
+
cursor: pointer;
|
| 98 |
+
transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.btn:hover {
|
| 102 |
+
transform: translateY(-1px);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.btn:disabled {
|
| 106 |
+
opacity: 0.6;
|
| 107 |
+
cursor: not-allowed;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.btn-primary {
|
| 111 |
+
color: #1a0b0d;
|
| 112 |
+
background: linear-gradient(135deg, var(--gold), #ffd86b);
|
| 113 |
+
box-shadow: 0 0 18px rgba(244, 211, 94, 0.35);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.btn-secondary {
|
| 117 |
+
color: var(--text);
|
| 118 |
+
background: rgba(255, 255, 255, 0.06);
|
| 119 |
+
border: 1px solid var(--border);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.btn-danger {
|
| 123 |
+
color: white;
|
| 124 |
+
background: linear-gradient(135deg, var(--red), #ff6b6b);
|
| 125 |
+
box-shadow: 0 0 18px rgba(230, 57, 70, 0.35);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.btn-ghost {
|
| 129 |
+
background: transparent;
|
| 130 |
+
color: var(--muted);
|
| 131 |
+
border: 1px solid transparent;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.btn-wide {
|
| 135 |
+
width: 100%;
|
| 136 |
+
margin-top: 1rem;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.panel {
|
| 140 |
+
padding: 1.25rem;
|
| 141 |
+
margin-bottom: 1.25rem;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.panel-header {
|
| 145 |
+
display: flex;
|
| 146 |
+
justify-content: space-between;
|
| 147 |
+
align-items: center;
|
| 148 |
+
gap: 1rem;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.startup-form {
|
| 152 |
+
display: grid;
|
| 153 |
+
gap: 0.85rem;
|
| 154 |
+
margin-top: 1rem;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.startup-form label {
|
| 158 |
+
display: grid;
|
| 159 |
+
gap: 0.35rem;
|
| 160 |
+
font-size: 0.92rem;
|
| 161 |
+
color: var(--muted);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
input,
|
| 165 |
+
textarea {
|
| 166 |
+
width: 100%;
|
| 167 |
+
border-radius: 10px;
|
| 168 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 169 |
+
background: rgba(0, 0, 0, 0.25);
|
| 170 |
+
color: var(--text);
|
| 171 |
+
padding: 0.7rem 0.8rem;
|
| 172 |
+
font: inherit;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.persona-grid {
|
| 176 |
+
display: grid;
|
| 177 |
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
| 178 |
+
gap: 0.85rem;
|
| 179 |
+
margin-top: 1rem;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.persona-card {
|
| 183 |
+
text-align: left;
|
| 184 |
+
padding: 1rem;
|
| 185 |
+
border-radius: 14px;
|
| 186 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 187 |
+
background: rgba(0, 0, 0, 0.22);
|
| 188 |
+
color: var(--text);
|
| 189 |
+
cursor: pointer;
|
| 190 |
+
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.persona-card:hover,
|
| 194 |
+
.persona-card.selected {
|
| 195 |
+
border-color: var(--gold);
|
| 196 |
+
box-shadow: 0 0 16px rgba(244, 211, 94, 0.18);
|
| 197 |
+
transform: translateY(-2px);
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.persona-card h3 {
|
| 201 |
+
margin: 0.35rem 0;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.persona-card p {
|
| 205 |
+
margin: 0;
|
| 206 |
+
color: var(--muted);
|
| 207 |
+
font-size: 0.9rem;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.battle-layout {
|
| 211 |
+
display: grid;
|
| 212 |
+
grid-template-columns: 260px 1fr;
|
| 213 |
+
gap: 1rem;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.battle-meta {
|
| 217 |
+
padding: 1.25rem;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.meta-item {
|
| 221 |
+
display: flex;
|
| 222 |
+
justify-content: space-between;
|
| 223 |
+
gap: 0.75rem;
|
| 224 |
+
padding: 0.65rem 0;
|
| 225 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.meta-item span {
|
| 229 |
+
color: var(--muted);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.pressure-medium { color: #7dd3fc; }
|
| 233 |
+
.pressure-high { color: var(--gold); }
|
| 234 |
+
.pressure-extreme { color: var(--red); }
|
| 235 |
+
|
| 236 |
+
.battle-chat {
|
| 237 |
+
display: flex;
|
| 238 |
+
flex-direction: column;
|
| 239 |
+
min-height: 70vh;
|
| 240 |
+
padding: 1rem;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.chat-window {
|
| 244 |
+
flex: 1;
|
| 245 |
+
overflow-y: auto;
|
| 246 |
+
display: flex;
|
| 247 |
+
flex-direction: column;
|
| 248 |
+
gap: 0.75rem;
|
| 249 |
+
padding: 0.5rem;
|
| 250 |
+
margin-bottom: 1rem;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.message {
|
| 254 |
+
max-width: 85%;
|
| 255 |
+
padding: 0.8rem 1rem;
|
| 256 |
+
border-radius: 14px;
|
| 257 |
+
line-height: 1.45;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.message.user {
|
| 261 |
+
align-self: flex-end;
|
| 262 |
+
background: rgba(230, 57, 70, 0.18);
|
| 263 |
+
border: 1px solid rgba(230, 57, 70, 0.35);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.message.ai {
|
| 267 |
+
align-self: flex-start;
|
| 268 |
+
background: rgba(244, 211, 94, 0.08);
|
| 269 |
+
border: 1px solid rgba(244, 211, 94, 0.22);
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.message-meta {
|
| 273 |
+
display: block;
|
| 274 |
+
font-size: 0.75rem;
|
| 275 |
+
color: var(--gold);
|
| 276 |
+
margin-bottom: 0.35rem;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.chat-input-row {
|
| 280 |
+
display: grid;
|
| 281 |
+
grid-template-columns: 1fr auto;
|
| 282 |
+
gap: 0.75rem;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.status-text {
|
| 286 |
+
color: var(--red);
|
| 287 |
+
font-size: 0.9rem;
|
| 288 |
+
margin: 0.5rem 0 0;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
.scorecard-header {
|
| 292 |
+
display: flex;
|
| 293 |
+
justify-content: space-between;
|
| 294 |
+
align-items: center;
|
| 295 |
+
gap: 1rem;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.scorecard-header-left {
|
| 299 |
+
display: flex;
|
| 300 |
+
flex-direction: column;
|
| 301 |
+
gap: 0.5rem;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.btn-sm {
|
| 305 |
+
padding: 0.3rem 0.75rem;
|
| 306 |
+
font-size: 0.8rem;
|
| 307 |
+
border-radius: 6px;
|
| 308 |
+
align-self: flex-start;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.overall-score {
|
| 312 |
+
text-align: right;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.overall-score strong {
|
| 316 |
+
display: block;
|
| 317 |
+
font-size: 2.5rem;
|
| 318 |
+
color: var(--gold);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
.score-bars {
|
| 322 |
+
display: grid;
|
| 323 |
+
gap: 0.9rem;
|
| 324 |
+
margin: 1.25rem 0;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.score-row-head {
|
| 328 |
+
display: flex;
|
| 329 |
+
justify-content: space-between;
|
| 330 |
+
margin-bottom: 0.35rem;
|
| 331 |
+
text-transform: capitalize;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.bar-track {
|
| 335 |
+
height: 10px;
|
| 336 |
+
border-radius: 999px;
|
| 337 |
+
background: rgba(255, 255, 255, 0.08);
|
| 338 |
+
overflow: hidden;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
.bar-fill {
|
| 342 |
+
height: 100%;
|
| 343 |
+
background: linear-gradient(90deg, var(--red), var(--gold));
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.score-reason {
|
| 347 |
+
margin: 0.35rem 0 0;
|
| 348 |
+
color: var(--muted);
|
| 349 |
+
font-size: 0.9rem;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.score-label {
|
| 353 |
+
margin-left: 0.5rem;
|
| 354 |
+
font-size: 0.75rem;
|
| 355 |
+
color: var(--gold);
|
| 356 |
+
font-weight: 600;
|
| 357 |
+
opacity: 0.85;
|
| 358 |
+
text-transform: uppercase;
|
| 359 |
+
letter-spacing: 0.04em;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.feedback-grid {
|
| 363 |
+
display: grid;
|
| 364 |
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
| 365 |
+
gap: 0.85rem;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.feedback-card {
|
| 369 |
+
padding: 1rem;
|
| 370 |
+
border-radius: 12px;
|
| 371 |
+
background: rgba(0, 0, 0, 0.22);
|
| 372 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.feedback-card.highlight {
|
| 376 |
+
border-color: rgba(244, 211, 94, 0.25);
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
.prep-questions {
|
| 380 |
+
margin-top: 1.25rem;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
.scorecard-actions {
|
| 384 |
+
display: flex;
|
| 385 |
+
gap: 0.75rem;
|
| 386 |
+
flex-wrap: wrap;
|
| 387 |
+
margin-top: 1.25rem;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.signals-summary {
|
| 391 |
+
font-size: 0.78rem;
|
| 392 |
+
color: var(--muted);
|
| 393 |
+
margin: 0.6rem 0 0.2rem;
|
| 394 |
+
padding: 0.4rem 0.75rem;
|
| 395 |
+
border-left: 2px solid rgba(244, 211, 94, 0.35);
|
| 396 |
+
line-height: 1.5;
|
| 397 |
+
word-break: break-word;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
.error-banner {
|
| 401 |
+
position: fixed;
|
| 402 |
+
top: 0;
|
| 403 |
+
left: 0;
|
| 404 |
+
right: 0;
|
| 405 |
+
z-index: 30;
|
| 406 |
+
padding: 0.85rem 1.25rem;
|
| 407 |
+
background: rgba(230, 57, 70, 0.92);
|
| 408 |
+
color: #fff;
|
| 409 |
+
font-weight: 600;
|
| 410 |
+
text-align: center;
|
| 411 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.error-banner[hidden] {
|
| 415 |
+
display: none;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.loading-overlay {
|
| 419 |
+
position: fixed;
|
| 420 |
+
inset: 0;
|
| 421 |
+
background: rgba(0, 0, 0, 0.55);
|
| 422 |
+
display: grid;
|
| 423 |
+
place-items: center;
|
| 424 |
+
z-index: 20;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
.loading-overlay[hidden] {
|
| 428 |
+
display: none;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
.spinner {
|
| 432 |
+
width: 42px;
|
| 433 |
+
height: 42px;
|
| 434 |
+
border-radius: 50%;
|
| 435 |
+
border: 3px solid rgba(255, 255, 255, 0.15);
|
| 436 |
+
border-top-color: var(--gold);
|
| 437 |
+
animation: spin 0.8s linear infinite;
|
| 438 |
+
margin: 0 auto 0.75rem;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
@keyframes spin {
|
| 442 |
+
to { transform: rotate(360deg); }
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
@media (max-width: 820px) {
|
| 446 |
+
.battle-layout {
|
| 447 |
+
grid-template-columns: 1fr;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
.chat-input-row {
|
| 451 |
+
grid-template-columns: 1fr;
|
| 452 |
+
}
|
| 453 |
+
}
|
packages.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
ffmpeg
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0.0
|
| 2 |
+
fastapi
|
| 3 |
+
uvicorn
|
| 4 |
+
pydantic
|
| 5 |
+
python-dotenv
|
| 6 |
+
numpy
|
| 7 |
+
openai>=1.0.0
|
| 8 |
+
httpx
|
| 9 |
+
requests
|
scripts/test_claim_based_scoring.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 5D: Local unit tests for claim extractor, local scoring, and session-aware fallback.
|
| 2 |
+
|
| 3 |
+
No server required. Tests run purely locally.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python scripts/test_claim_based_scoring.py
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import sys
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 15 |
+
|
| 16 |
+
from core.claim_extractor import extract_concrete_signals
|
| 17 |
+
from core.scoring_engine import (
|
| 18 |
+
build_session_aware_fallback_scorecard,
|
| 19 |
+
_compute_local_scores,
|
| 20 |
+
_score_label,
|
| 21 |
+
)
|
| 22 |
+
from core.json_utils import _score_label as json_score_label
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Helpers
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
def check(label: str, condition: bool, detail: str = "") -> None:
|
| 30 |
+
marker = "PASS" if condition else "FAIL"
|
| 31 |
+
suffix = f" — {detail}" if detail else ""
|
| 32 |
+
print(f" {marker} {label}{suffix}")
|
| 33 |
+
if not condition:
|
| 34 |
+
sys.exit(1)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def make_session(answers: list[str], startup: dict | None = None) -> dict:
|
| 38 |
+
startup = startup or {
|
| 39 |
+
"name": "TestStartup",
|
| 40 |
+
"problem": "Test problem",
|
| 41 |
+
"solution": "Test solution",
|
| 42 |
+
"why_ai": "AI helps",
|
| 43 |
+
"stage": "Prototype",
|
| 44 |
+
"team": "2 founders",
|
| 45 |
+
"traction": "10 beta users",
|
| 46 |
+
}
|
| 47 |
+
history = []
|
| 48 |
+
for i, ans in enumerate(answers):
|
| 49 |
+
if i % 2 == 0:
|
| 50 |
+
history.append({"role": "assistant", "content": f"Judge question {i}", "attack_tag": "Market Size"})
|
| 51 |
+
history.append({"role": "user", "content": ans})
|
| 52 |
+
return {"startup": startup, "persona": "hackathon_judge", "difficulty": "high", "history": history}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
# Extraction tests (existing)
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
|
| 59 |
+
def test_strong_evidence_answer() -> None:
|
| 60 |
+
print("\nTest 1: Strong evidence — numbers, validation, campus")
|
| 61 |
+
session = make_session(["80 users, 60% interview rate, 3 colleges have onboarded already."])
|
| 62 |
+
sigs = extract_concrete_signals(session)
|
| 63 |
+
check("numbers extracted", len(sigs["numbers"]) > 0, f"got {sigs['numbers']}")
|
| 64 |
+
check("percentages extracted", len(sigs["percentages"]) > 0, f"got {sigs['percentages']}")
|
| 65 |
+
check("college_mentions extracted", len(sigs["college_mentions"]) > 0, f"got {sigs['college_mentions']}")
|
| 66 |
+
check("signal_count > 0", sigs["signal_count"] > 0, f"got {sigs['signal_count']}")
|
| 67 |
+
check("non_answers empty", len(sigs["non_answers"]) == 0)
|
| 68 |
+
check("best_user_quotes non-empty", len(sigs["best_user_quotes"]) > 0)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_vague_answer() -> None:
|
| 72 |
+
print("\nTest 2: Vague answer — big market, useful")
|
| 73 |
+
session = make_session(["It is useful and big market will love it."])
|
| 74 |
+
sigs = extract_concrete_signals(session)
|
| 75 |
+
check("vague_claims detected", len(sigs["vague_claims"]) > 0, f"got {sigs['vague_claims']}")
|
| 76 |
+
check("no numbers", len(sigs["numbers"]) == 0, f"got {sigs['numbers']}")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_non_answer() -> None:
|
| 80 |
+
print("\nTest 3: Non-answer — 'I don't know'")
|
| 81 |
+
session = make_session(["I don't know"])
|
| 82 |
+
sigs = extract_concrete_signals(session)
|
| 83 |
+
check("non_answers detected", len(sigs["non_answers"]) > 0, f"got {sigs['non_answers']}")
|
| 84 |
+
check("best_user_quotes empty", len(sigs["best_user_quotes"]) == 0)
|
| 85 |
+
check("signal_count is 0", sigs["signal_count"] == 0, f"got {sigs['signal_count']}")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_pricing_and_revenue() -> None:
|
| 89 |
+
print("\nTest 4: Pricing and revenue — ₹399 per student and CAC ~₹50")
|
| 90 |
+
session = make_session(["We charge ₹399 per student and our CAC is around ₹50."])
|
| 91 |
+
sigs = extract_concrete_signals(session)
|
| 92 |
+
check("pricing detected", len(sigs["pricing"]) > 0, f"got {sigs['pricing']}")
|
| 93 |
+
check("revenue_signals detected", len(sigs["revenue_signals"]) > 0, f"got {sigs['revenue_signals']}")
|
| 94 |
+
check("signal_count > 0", sigs["signal_count"] > 0)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_competitors_and_tech() -> None:
|
| 98 |
+
print("\nTest 5: Competitors and technical mechanism")
|
| 99 |
+
session = make_session([
|
| 100 |
+
"Luma and LinkedIn are competitors but we use profile-based ranking with embedding models."
|
| 101 |
+
])
|
| 102 |
+
sigs = extract_concrete_signals(session)
|
| 103 |
+
check("competitors detected", len(sigs["competitors"]) > 0, f"got {sigs['competitors']}")
|
| 104 |
+
check("technical_mechanisms detected", len(sigs["technical_mechanisms"]) > 0, f"got {sigs['technical_mechanisms']}")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_mixed_session() -> None:
|
| 108 |
+
print("\nTest 6: Mixed session — strong + non-answer + vague")
|
| 109 |
+
session = make_session([
|
| 110 |
+
"We validated this with 50 beta users, 3 campus ambassadors, and weekly event-miss reports.",
|
| 111 |
+
"I don't know",
|
| 112 |
+
"It is a big market.",
|
| 113 |
+
"We use embeddings and a ranking model trained on student behavior.",
|
| 114 |
+
])
|
| 115 |
+
sigs = extract_concrete_signals(session)
|
| 116 |
+
check("numbers detected", len(sigs["numbers"]) > 0, f"got {sigs['numbers']}")
|
| 117 |
+
check("validation detected", len(sigs["validation"]) > 0, f"got {sigs['validation']}")
|
| 118 |
+
check("non_answers detected", len(sigs["non_answers"]) > 0)
|
| 119 |
+
check("vague_claims detected", len(sigs["vague_claims"]) > 0)
|
| 120 |
+
check("technical_mechanisms detected", len(sigs["technical_mechanisms"]) > 0)
|
| 121 |
+
check("best_user_quotes >= 2", len(sigs["best_user_quotes"]) >= 2, f"got {len(sigs['best_user_quotes'])}")
|
| 122 |
+
check("signal_count > 3", sigs["signal_count"] > 3, f"got {sigs['signal_count']}")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
# Local scoring tests (Phase 5D)
|
| 127 |
+
# ---------------------------------------------------------------------------
|
| 128 |
+
|
| 129 |
+
def test_local_scoring_strong_evidence() -> None:
|
| 130 |
+
print("\nTest 7: Local scoring — '80 users, 60% interview rate, 3 colleges' should get real credit")
|
| 131 |
+
session = make_session([
|
| 132 |
+
"80 users, 60% interview rate, 3 colleges have onboarded already.",
|
| 133 |
+
"We charge ₹399 per student and our CAC is around ₹50.",
|
| 134 |
+
"Luma and LinkedIn are competitors but we use profile-based ranking with embedding models.",
|
| 135 |
+
])
|
| 136 |
+
sigs = extract_concrete_signals(session)
|
| 137 |
+
startup = session.get("startup", {})
|
| 138 |
+
scores, best_answer, weakest_answer, why_weak = _compute_local_scores(sigs, startup)
|
| 139 |
+
|
| 140 |
+
# All 6 dims must be present
|
| 141 |
+
required = {"clarity", "problem_understanding", "market_awareness",
|
| 142 |
+
"differentiation", "business_model", "objection_handling"}
|
| 143 |
+
check("all 6 dims present", required <= set(scores.keys()))
|
| 144 |
+
|
| 145 |
+
# With concrete evidence, most dims should be Developing (31+) or above
|
| 146 |
+
for dim in required:
|
| 147 |
+
s = scores[dim]["score"]
|
| 148 |
+
check(f"{dim}.score >= 30", s >= 30, f"got {s} — evidence was strong")
|
| 149 |
+
|
| 150 |
+
# Dims with direct evidence should be Solid (51+) or Strong
|
| 151 |
+
# market_awareness should get credit for numbers + competitors
|
| 152 |
+
market_s = scores["market_awareness"]["score"]
|
| 153 |
+
check("market_awareness >= 50 (has numbers + competitors)", market_s >= 50, f"got {market_s}")
|
| 154 |
+
|
| 155 |
+
# differentiation should get credit for competitors + tech
|
| 156 |
+
diff_s = scores["differentiation"]["score"]
|
| 157 |
+
check("differentiation >= 50 (has competitors + tech)", diff_s >= 50, f"got {diff_s}")
|
| 158 |
+
|
| 159 |
+
# business_model should get credit for ₹399 + CAC
|
| 160 |
+
biz_s = scores["business_model"]["score"]
|
| 161 |
+
check("business_model >= 45 (has pricing + revenue)", biz_s >= 45, f"got {biz_s}")
|
| 162 |
+
|
| 163 |
+
# best_answer should be non-empty
|
| 164 |
+
check("best_answer non-empty", bool(best_answer))
|
| 165 |
+
check("why_weak non-empty", bool(why_weak))
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def test_local_scoring_vague_floor() -> None:
|
| 169 |
+
print("\nTest 8: Local scoring — vague on-topic answer should not go below 30 (global floor)")
|
| 170 |
+
session = make_session([
|
| 171 |
+
"It is a big market because students attend events often.",
|
| 172 |
+
"We have a better AI solution than others.",
|
| 173 |
+
])
|
| 174 |
+
sigs = extract_concrete_signals(session)
|
| 175 |
+
startup = session.get("startup", {})
|
| 176 |
+
scores, _, _, _ = _compute_local_scores(sigs, startup)
|
| 177 |
+
|
| 178 |
+
# On-topic engagement = engagement > 0, so floor is 33
|
| 179 |
+
for dim, data in scores.items():
|
| 180 |
+
s = data["score"]
|
| 181 |
+
# Some dims may dip below 33 (business_model has 28 floor), but most should be >= 30
|
| 182 |
+
if dim != "business_model":
|
| 183 |
+
check(f"{dim}.score >= 30 (vague but on-topic)", s >= 30, f"got {s}")
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def test_local_scoring_non_answer_can_be_below_30() -> None:
|
| 187 |
+
print("\nTest 9: Local scoring — all non-answers can score below 30")
|
| 188 |
+
session = make_session(["ok", "I don't know", "yeah", "not sure"])
|
| 189 |
+
sigs = extract_concrete_signals(session)
|
| 190 |
+
startup = session.get("startup", {})
|
| 191 |
+
scores, _, _, _ = _compute_local_scores(sigs, startup)
|
| 192 |
+
|
| 193 |
+
# With all non-answers, engagement=0, so dims should be low
|
| 194 |
+
low_count = sum(1 for d in scores.values() if d["score"] <= 20)
|
| 195 |
+
check("majority of dims <= 20 when all non-answers", low_count >= 4,
|
| 196 |
+
f"got {low_count} dims <= 20, scores={[(k, v['score']) for k, v in scores.items()]}")
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def test_local_scoring_pricing_revenue() -> None:
|
| 200 |
+
print("\nTest 10: Local scoring — ₹399 and CAC ₹50 → business_model Developing or better")
|
| 201 |
+
session = make_session(["We charge ₹399 per student and our CAC is around ₹50."])
|
| 202 |
+
sigs = extract_concrete_signals(session)
|
| 203 |
+
startup = session.get("startup", {})
|
| 204 |
+
scores, _, _, _ = _compute_local_scores(sigs, startup)
|
| 205 |
+
|
| 206 |
+
biz_s = scores["business_model"]["score"]
|
| 207 |
+
biz_lbl = scores["business_model"]["label"]
|
| 208 |
+
check(
|
| 209 |
+
"business_model >= 31 (Developing or better) with ₹399 + CAC",
|
| 210 |
+
biz_s >= 31,
|
| 211 |
+
f"got {biz_s} ({biz_lbl})",
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# ---------------------------------------------------------------------------
|
| 216 |
+
# Session-aware fallback test
|
| 217 |
+
# ---------------------------------------------------------------------------
|
| 218 |
+
|
| 219 |
+
def test_session_aware_fallback_not_static() -> None:
|
| 220 |
+
print("\nTest 11: Session-aware fallback does not return EventRadar static content")
|
| 221 |
+
session = make_session(
|
| 222 |
+
[
|
| 223 |
+
"We have 80 users at IIT Delhi and IIT Bombay paying ₹499/month.",
|
| 224 |
+
"Competitors are LinkedIn and Glassdoor but we do skill-gap analysis.",
|
| 225 |
+
],
|
| 226 |
+
startup={
|
| 227 |
+
"name": "SkillBridge",
|
| 228 |
+
"problem": "Students get rejected because their resume skills don't match job requirements.",
|
| 229 |
+
"solution": "AI that maps resume skills to actual job description gaps.",
|
| 230 |
+
"why_ai": "Personalized skill-gap analysis requires LLM understanding.",
|
| 231 |
+
"stage": "Beta",
|
| 232 |
+
"team": "2 founders",
|
| 233 |
+
"traction": "80 paying users",
|
| 234 |
+
},
|
| 235 |
+
)
|
| 236 |
+
sigs = extract_concrete_signals(session)
|
| 237 |
+
fb = build_session_aware_fallback_scorecard(session, sigs, "test error")
|
| 238 |
+
|
| 239 |
+
check("scorecard_source=session_fallback", fb.get("scorecard_source") == "session_fallback")
|
| 240 |
+
check("model_ok=False", fb.get("model_ok") is False)
|
| 241 |
+
check("provider=local", fb.get("provider") == "local")
|
| 242 |
+
check("model_error present", bool(fb.get("model_error")))
|
| 243 |
+
check("overall is int 0-100",
|
| 244 |
+
isinstance(fb.get("overall"), int) and 0 <= fb.get("overall", -1) <= 100)
|
| 245 |
+
check("overall_label valid",
|
| 246 |
+
fb.get("overall_label") in {"Not addressed", "Developing", "Solid", "Strong", "Excellent"})
|
| 247 |
+
check("all 6 dims present",
|
| 248 |
+
{"clarity", "problem_understanding", "market_awareness",
|
| 249 |
+
"differentiation", "business_model", "objection_handling"}
|
| 250 |
+
<= set(fb.get("scores", {}).keys()))
|
| 251 |
+
check("concrete_signals_summary present", isinstance(fb.get("concrete_signals_summary"), dict))
|
| 252 |
+
check("top_3_questions has 3", len(fb.get("top_3_questions", [])) == 3)
|
| 253 |
+
|
| 254 |
+
full_text = str(fb)
|
| 255 |
+
for phrase in ["WhatsApp groups", "EventRadar"]:
|
| 256 |
+
check(
|
| 257 |
+
f"no static phrase '{phrase}' in session fallback",
|
| 258 |
+
phrase not in full_text or "SkillBridge" in full_text,
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
best = fb.get("best_answer", "")
|
| 262 |
+
check(
|
| 263 |
+
"best_answer contains actual answer text",
|
| 264 |
+
any(word in best for word in ["80 users", "IIT", "499", "SkillBridge", "LinkedIn", "skill"]),
|
| 265 |
+
f"got: {best[:120]}",
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# ---------------------------------------------------------------------------
|
| 270 |
+
# Score label band tests
|
| 271 |
+
# ---------------------------------------------------------------------------
|
| 272 |
+
|
| 273 |
+
def test_score_label_bands() -> None:
|
| 274 |
+
print("\nTest 12: Score label bands (Phase 5C/5D)")
|
| 275 |
+
cases = [
|
| 276 |
+
(0, "Not addressed"),
|
| 277 |
+
(15, "Not addressed"),
|
| 278 |
+
(30, "Not addressed"),
|
| 279 |
+
(31, "Developing"),
|
| 280 |
+
(50, "Developing"),
|
| 281 |
+
(51, "Solid"),
|
| 282 |
+
(70, "Solid"),
|
| 283 |
+
(71, "Strong"),
|
| 284 |
+
(85, "Strong"),
|
| 285 |
+
(86, "Excellent"),
|
| 286 |
+
(100, "Excellent"),
|
| 287 |
+
]
|
| 288 |
+
for score, expected in cases:
|
| 289 |
+
got = _score_label(score)
|
| 290 |
+
check(f"_score_label({score}) == '{expected}'", got == expected, f"got '{got}'")
|
| 291 |
+
# Also verify json_utils._score_label matches
|
| 292 |
+
for score, expected in cases:
|
| 293 |
+
got = json_score_label(score)
|
| 294 |
+
check(f"json_utils._score_label({score}) == '{expected}'", got == expected, f"got '{got}'")
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def test_all_non_answer_session() -> None:
|
| 298 |
+
print("\nTest 13: All non-answer session — fallback should still work")
|
| 299 |
+
session = make_session(["ok", "I don't know", "yeah", "not sure"])
|
| 300 |
+
sigs = extract_concrete_signals(session)
|
| 301 |
+
check("all are non_answers", len(sigs["non_answers"]) >= 3)
|
| 302 |
+
check("signal_count=0", sigs["signal_count"] == 0)
|
| 303 |
+
fb = build_session_aware_fallback_scorecard(session, sigs)
|
| 304 |
+
check("fallback returns valid dict", isinstance(fb, dict))
|
| 305 |
+
check("overall >= 0", fb.get("overall", -1) >= 0)
|
| 306 |
+
check("scorecard_source=session_fallback", fb.get("scorecard_source") == "session_fallback")
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
# ---------------------------------------------------------------------------
|
| 310 |
+
# Runner
|
| 311 |
+
# ---------------------------------------------------------------------------
|
| 312 |
+
|
| 313 |
+
def main() -> None:
|
| 314 |
+
print("\nPhase 5D — Claim Extractor + Local Scoring + Session-Aware Fallback Tests")
|
| 315 |
+
print("(No server required)\n")
|
| 316 |
+
|
| 317 |
+
test_strong_evidence_answer()
|
| 318 |
+
test_vague_answer()
|
| 319 |
+
test_non_answer()
|
| 320 |
+
test_pricing_and_revenue()
|
| 321 |
+
test_competitors_and_tech()
|
| 322 |
+
test_mixed_session()
|
| 323 |
+
test_local_scoring_strong_evidence()
|
| 324 |
+
test_local_scoring_vague_floor()
|
| 325 |
+
test_local_scoring_non_answer_can_be_below_30()
|
| 326 |
+
test_local_scoring_pricing_revenue()
|
| 327 |
+
test_session_aware_fallback_not_static()
|
| 328 |
+
test_score_label_bands()
|
| 329 |
+
test_all_non_answer_session()
|
| 330 |
+
|
| 331 |
+
print("\nAll Phase 5D claim-based scoring tests passed.\n")
|
| 332 |
+
sys.exit(0)
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
if __name__ == "__main__":
|
| 336 |
+
main()
|
scripts/test_nvidia_client.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Isolated NVIDIA Nemotron connectivity test.
|
| 2 |
+
|
| 3 |
+
Run from the project root:
|
| 4 |
+
python scripts/test_nvidia_client.py
|
| 5 |
+
|
| 6 |
+
Exits 0 on success, 1 on failure.
|
| 7 |
+
Does not expose the API key in output.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
# Allow imports from project root regardless of working directory
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 17 |
+
|
| 18 |
+
from dotenv import load_dotenv
|
| 19 |
+
|
| 20 |
+
load_dotenv()
|
| 21 |
+
|
| 22 |
+
from core.nvidia_client import health_check, generate_nemotron_response # noqa: E402
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main() -> int:
|
| 26 |
+
print("=" * 60)
|
| 27 |
+
print("PitchFight AI — NVIDIA Nemotron connectivity test")
|
| 28 |
+
print("=" * 60)
|
| 29 |
+
|
| 30 |
+
# 1. Health check (no key in output)
|
| 31 |
+
print("\n[1] Health check")
|
| 32 |
+
status = health_check()
|
| 33 |
+
print(f" provider : {status['provider']}")
|
| 34 |
+
print(f" configured : {status['configured']}")
|
| 35 |
+
print(f" base_url : {status['base_url']}")
|
| 36 |
+
print(f" model : {status['model']}")
|
| 37 |
+
print(f" key present : {status['api_key_present']}")
|
| 38 |
+
print(f" message : {status['message']}")
|
| 39 |
+
|
| 40 |
+
if not status["configured"]:
|
| 41 |
+
print("\n[FAIL] NVIDIA_API_KEY is not set.")
|
| 42 |
+
print(" Add it to your .env file and re-run this script.")
|
| 43 |
+
return 1
|
| 44 |
+
|
| 45 |
+
# 2. Test prompt
|
| 46 |
+
print("\n[2] Sending test prompt to Nemotron...")
|
| 47 |
+
messages = [
|
| 48 |
+
{
|
| 49 |
+
"role": "system",
|
| 50 |
+
"content": (
|
| 51 |
+
"You are a skeptical hackathon judge. "
|
| 52 |
+
"Ask exactly one sharp question. Do not give advice."
|
| 53 |
+
),
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"role": "user",
|
| 57 |
+
"content": (
|
| 58 |
+
"Startup: EventRadar AI.\n"
|
| 59 |
+
"Problem: Students miss hackathons and tech events because "
|
| 60 |
+
"discovery is scattered.\n"
|
| 61 |
+
"Solution: AI-powered event discovery that ranks opportunities "
|
| 62 |
+
"by skills, goals, location, and deadline urgency.\n\n"
|
| 63 |
+
"Return exactly one question under 40 words."
|
| 64 |
+
),
|
| 65 |
+
},
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
response = generate_nemotron_response(
|
| 70 |
+
messages,
|
| 71 |
+
mode="opponent",
|
| 72 |
+
temperature=0.7,
|
| 73 |
+
timeout=30,
|
| 74 |
+
)
|
| 75 |
+
print("\n[3] Model response:")
|
| 76 |
+
print("-" * 40)
|
| 77 |
+
print(response)
|
| 78 |
+
print("-" * 40)
|
| 79 |
+
print("\n[PASS] NVIDIA Nemotron responded successfully.")
|
| 80 |
+
print("Phase 2 model connectivity: OK")
|
| 81 |
+
return 0
|
| 82 |
+
|
| 83 |
+
except RuntimeError as exc:
|
| 84 |
+
print(f"\n[FAIL] {exc}")
|
| 85 |
+
print("Check your NVIDIA_API_KEY and NVIDIA_BASE_URL in .env")
|
| 86 |
+
return 1
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
if __name__ == "__main__":
|
| 90 |
+
sys.exit(main())
|
scripts/test_phase3_pitch_battle.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 3 end-to-end test: live pitch battle via the running server.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python scripts/test_phase3_pitch_battle.py
|
| 5 |
+
|
| 6 |
+
Requires the server to already be running. Set PITCHFIGHT_BASE_URL to override
|
| 7 |
+
the default base URL (http://127.0.0.1:7861).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
import requests
|
| 17 |
+
|
| 18 |
+
BASE_URL = os.getenv("PITCHFIGHT_BASE_URL", "http://127.0.0.1:7861").rstrip("/")
|
| 19 |
+
|
| 20 |
+
SAMPLE_STARTUP = {
|
| 21 |
+
"name": "EventRadar AI",
|
| 22 |
+
"problem": "Students miss relevant hackathons, workshops, and networking events.",
|
| 23 |
+
"solution": "AI-powered event discovery that ranks events by fit for each student profile.",
|
| 24 |
+
"why_ai": "Personalized ranking requires understanding student goals and event signals together.",
|
| 25 |
+
"stage": "Prototype",
|
| 26 |
+
"team": "2 founders",
|
| 27 |
+
"traction": "50 beta signups, no revenue yet",
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
USER_ANSWER = (
|
| 31 |
+
"It is a pretty big market because students attend events often."
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def post(path: str, body: dict) -> dict:
|
| 36 |
+
url = f"{BASE_URL}{path}"
|
| 37 |
+
resp = requests.post(url, json=body, timeout=60)
|
| 38 |
+
resp.raise_for_status()
|
| 39 |
+
return resp.json()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def check(label: str, condition: bool, detail: str = "") -> None:
|
| 43 |
+
if condition:
|
| 44 |
+
print(f" PASS {label}")
|
| 45 |
+
else:
|
| 46 |
+
print(f" FAIL {label}" + (f" — {detail}" if detail else ""))
|
| 47 |
+
sys.exit(1)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def main() -> None:
|
| 51 |
+
print(f"\nPhase 3 Pitch Battle Test\nBase URL: {BASE_URL}\n")
|
| 52 |
+
|
| 53 |
+
# --- Step 1: start-session ---
|
| 54 |
+
print("Step 1: POST /api/start-session")
|
| 55 |
+
try:
|
| 56 |
+
data = post("/api/start-session", {
|
| 57 |
+
"mode": "pitch_battle",
|
| 58 |
+
"startup": SAMPLE_STARTUP,
|
| 59 |
+
"persona": "hackathon_judge",
|
| 60 |
+
"difficulty": "high",
|
| 61 |
+
"input_mode": "text",
|
| 62 |
+
"model_mode": "premium_nvidia",
|
| 63 |
+
})
|
| 64 |
+
except Exception as exc:
|
| 65 |
+
print(f" FAIL Request failed: {exc}")
|
| 66 |
+
sys.exit(1)
|
| 67 |
+
|
| 68 |
+
print(f" session_id : {data.get('session_id', '—')}")
|
| 69 |
+
print(f" model_ok : {data.get('model_ok')}")
|
| 70 |
+
print(f" provider : {data.get('provider')}")
|
| 71 |
+
print(f" model_mode : {data.get('model_mode')}")
|
| 72 |
+
print(f" attack_tag : {data.get('attack_tag')}")
|
| 73 |
+
print(f" round : {data.get('round')}")
|
| 74 |
+
print(f" ai_message :\n {data.get('ai_message', '')}\n")
|
| 75 |
+
|
| 76 |
+
session_id = data.get("session_id")
|
| 77 |
+
check("session_id present", bool(session_id), "got empty session_id")
|
| 78 |
+
check("ai_message non-empty", bool(data.get("ai_message")), "ai_message is empty")
|
| 79 |
+
check("round is 1", data.get("round") == 1, f"got {data.get('round')}")
|
| 80 |
+
check("attack_tag present", bool(data.get("attack_tag")))
|
| 81 |
+
|
| 82 |
+
if data.get("model_ok"):
|
| 83 |
+
check("provider is nvidia", data.get("provider") == "nvidia")
|
| 84 |
+
else:
|
| 85 |
+
print(" NOTE Model returned mock fallback (NVIDIA may be down or key missing)")
|
| 86 |
+
if data.get("model_error"):
|
| 87 |
+
print(f" model_error: {data['model_error']}")
|
| 88 |
+
|
| 89 |
+
# --- Step 2: chat-round ---
|
| 90 |
+
print("\nStep 2: POST /api/chat-round")
|
| 91 |
+
try:
|
| 92 |
+
data2 = post("/api/chat-round", {
|
| 93 |
+
"session_id": session_id,
|
| 94 |
+
"user_message": USER_ANSWER,
|
| 95 |
+
})
|
| 96 |
+
except Exception as exc:
|
| 97 |
+
print(f" FAIL Request failed: {exc}")
|
| 98 |
+
sys.exit(1)
|
| 99 |
+
|
| 100 |
+
print(f" model_ok : {data2.get('model_ok')}")
|
| 101 |
+
print(f" provider : {data2.get('provider')}")
|
| 102 |
+
print(f" model_mode : {data2.get('model_mode')}")
|
| 103 |
+
print(f" attack_tag : {data2.get('attack_tag')}")
|
| 104 |
+
print(f" round : {data2.get('round')}")
|
| 105 |
+
print(f" ai_message :\n {data2.get('ai_message', '')}\n")
|
| 106 |
+
|
| 107 |
+
check("session_id echoed", data2.get("session_id") == session_id)
|
| 108 |
+
check("ai_message non-empty", bool(data2.get("ai_message")), "ai_message is empty")
|
| 109 |
+
check("round is 2", data2.get("round") == 2, f"got {data2.get('round')}")
|
| 110 |
+
|
| 111 |
+
if data2.get("model_ok"):
|
| 112 |
+
check("provider is nvidia", data2.get("provider") == "nvidia")
|
| 113 |
+
else:
|
| 114 |
+
print(" NOTE chat-round returned mock fallback")
|
| 115 |
+
if data2.get("model_error"):
|
| 116 |
+
print(f" model_error: {data2['model_error']}")
|
| 117 |
+
|
| 118 |
+
print("\nAll checks passed. Phase 3 integration is working.\n")
|
| 119 |
+
sys.exit(0)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
if __name__ == "__main__":
|
| 123 |
+
main()
|
scripts/test_phase3b_battle_flow.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 3B end-to-end test: Socratic judge flow via the running server.
|
| 2 |
+
|
| 3 |
+
Validates:
|
| 4 |
+
- answer_quality classification appears in chat-round responses
|
| 5 |
+
- judge_action drives tag switching / follow-up decisions
|
| 6 |
+
- Same attack tag not repeated more than MAX_ATTEMPTS_PER_ATTACK_TAG times
|
| 7 |
+
- ai_message is always non-empty (model or mock fallback)
|
| 8 |
+
- All required response fields are present
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python scripts/test_phase3b_battle_flow.py
|
| 12 |
+
|
| 13 |
+
Server must already be running. Set PITCHFIGHT_BASE_URL to override default.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
from collections import defaultdict
|
| 21 |
+
|
| 22 |
+
import requests
|
| 23 |
+
|
| 24 |
+
BASE_URL = os.getenv("PITCHFIGHT_BASE_URL", "http://127.0.0.1:7861").rstrip("/")
|
| 25 |
+
|
| 26 |
+
SAMPLE_STARTUP = {
|
| 27 |
+
"name": "EventRadar AI",
|
| 28 |
+
"problem": "Students miss relevant hackathons, workshops, and networking events.",
|
| 29 |
+
"solution": "AI-powered event discovery that ranks events by fit for each student profile.",
|
| 30 |
+
"why_ai": "Personalized ranking requires understanding student goals and event signals together.",
|
| 31 |
+
"stage": "Prototype",
|
| 32 |
+
"team": "2 founders",
|
| 33 |
+
"traction": "50 beta signups, no revenue yet",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
STRONG_ANSWER = (
|
| 37 |
+
"We validated this with 50 beta users, 3 campus ambassadors, "
|
| 38 |
+
"and weekly event-miss reports from two colleges."
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
ANSWERS = [
|
| 42 |
+
"It is a pretty big market because students attend events often.", # weak
|
| 43 |
+
"I don't know", # non_answer
|
| 44 |
+
"ok", # non_answer → triggers move_after_limit
|
| 45 |
+
STRONG_ANSWER, # strong → move_next_tag
|
| 46 |
+
"The AI personalizes event ranking based on student goals.", # partial
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
MAX_ATTEMPTS_ALLOWED = 2 # must match battle_flow.MAX_ATTEMPTS_PER_ATTACK_TAG
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def post(path: str, body: dict) -> dict:
|
| 53 |
+
url = f"{BASE_URL}{path}"
|
| 54 |
+
resp = requests.post(url, json=body, timeout=60)
|
| 55 |
+
resp.raise_for_status()
|
| 56 |
+
return resp.json()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def check(label: str, condition: bool, detail: str = "") -> None:
|
| 60 |
+
marker = "PASS" if condition else "FAIL"
|
| 61 |
+
suffix = f" — {detail}" if detail else ""
|
| 62 |
+
print(f" {marker} {label}{suffix}")
|
| 63 |
+
if not condition:
|
| 64 |
+
sys.exit(1)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def main() -> None:
|
| 68 |
+
print(f"\nPhase 3B Battle Flow Test\nBase URL: {BASE_URL}\n")
|
| 69 |
+
|
| 70 |
+
# --- start-session ---
|
| 71 |
+
print("Step 0: POST /api/start-session")
|
| 72 |
+
try:
|
| 73 |
+
start = post("/api/start-session", {
|
| 74 |
+
"mode": "pitch_battle",
|
| 75 |
+
"startup": SAMPLE_STARTUP,
|
| 76 |
+
"persona": "hackathon_judge",
|
| 77 |
+
"difficulty": "high",
|
| 78 |
+
"input_mode": "text",
|
| 79 |
+
"model_mode": "premium_nvidia",
|
| 80 |
+
})
|
| 81 |
+
except Exception as exc:
|
| 82 |
+
print(f" FAIL Request failed: {exc}")
|
| 83 |
+
sys.exit(1)
|
| 84 |
+
|
| 85 |
+
session_id = start.get("session_id")
|
| 86 |
+
check("session_id present", bool(session_id))
|
| 87 |
+
check("ai_message non-empty", bool(start.get("ai_message")))
|
| 88 |
+
check("judge_action = opening_question", start.get("judge_action") == "opening_question",
|
| 89 |
+
f"got {start.get('judge_action')!r}")
|
| 90 |
+
check("answer_quality is None", start.get("answer_quality") is None,
|
| 91 |
+
f"got {start.get('answer_quality')!r}")
|
| 92 |
+
check("tag_attempt = 1", start.get("tag_attempt") == 1, f"got {start.get('tag_attempt')!r}")
|
| 93 |
+
|
| 94 |
+
opening_tag = start.get("attack_tag")
|
| 95 |
+
print(f" opening attack_tag : {opening_tag}")
|
| 96 |
+
print(f" ai_message : {start['ai_message'][:120]}...\n")
|
| 97 |
+
|
| 98 |
+
# Track tag attempt counts locally to verify the invariant
|
| 99 |
+
tag_attempts: dict[str, int] = defaultdict(int)
|
| 100 |
+
tag_attempts[opening_tag] += 1
|
| 101 |
+
|
| 102 |
+
rounds: list[dict] = []
|
| 103 |
+
|
| 104 |
+
# --- chat rounds ---
|
| 105 |
+
for i, answer in enumerate(ANSWERS, start=1):
|
| 106 |
+
round_num = i + 1 # round 1 was the opening
|
| 107 |
+
print(f"Step {i}: POST /api/chat-round (sending: {answer!r})")
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
data = post("/api/chat-round", {
|
| 111 |
+
"session_id": session_id,
|
| 112 |
+
"user_message": answer,
|
| 113 |
+
})
|
| 114 |
+
except Exception as exc:
|
| 115 |
+
print(f" FAIL Request failed: {exc}")
|
| 116 |
+
sys.exit(1)
|
| 117 |
+
|
| 118 |
+
atag = data.get("attack_tag", "")
|
| 119 |
+
aq = data.get("answer_quality", "")
|
| 120 |
+
ja = data.get("judge_action", "")
|
| 121 |
+
attempt = data.get("tag_attempt", 0)
|
| 122 |
+
satisfied = data.get("topic_satisfied")
|
| 123 |
+
prev_tag = data.get("previous_attack_tag", "")
|
| 124 |
+
|
| 125 |
+
# Update local tracking
|
| 126 |
+
tag_attempts[atag] += 1
|
| 127 |
+
|
| 128 |
+
print(f" round : {data.get('round')}")
|
| 129 |
+
print(f" attack_tag : {atag}")
|
| 130 |
+
print(f" previous_tag : {prev_tag}")
|
| 131 |
+
print(f" answer_quality : {aq}")
|
| 132 |
+
print(f" judge_action : {ja}")
|
| 133 |
+
print(f" tag_attempt : {attempt}")
|
| 134 |
+
print(f" topic_satisfied : {satisfied}")
|
| 135 |
+
print(f" model_ok : {data.get('model_ok')}")
|
| 136 |
+
print(f" provider : {data.get('provider')}")
|
| 137 |
+
print(f" ai_message : {data.get('ai_message', '')[:120]}...\n")
|
| 138 |
+
|
| 139 |
+
# Required field checks
|
| 140 |
+
check("session_id echoed", data.get("session_id") == session_id)
|
| 141 |
+
check("ai_message non-empty", bool(data.get("ai_message")), "ai_message is empty")
|
| 142 |
+
check("attack_tag present", bool(atag))
|
| 143 |
+
check("answer_quality present", aq in ("strong", "partial", "weak", "non_answer"),
|
| 144 |
+
f"got {aq!r}")
|
| 145 |
+
check("judge_action present", ja in ("follow_up_same_tag", "move_next_tag", "move_after_limit"),
|
| 146 |
+
f"got {ja!r}")
|
| 147 |
+
check("tag_attempt present", isinstance(attempt, int) and attempt >= 1,
|
| 148 |
+
f"got {attempt!r}")
|
| 149 |
+
check("provider present", bool(data.get("provider")))
|
| 150 |
+
|
| 151 |
+
rounds.append(data)
|
| 152 |
+
|
| 153 |
+
# --- Invariant: no tag exceeded MAX_ATTEMPTS_ALLOWED across all rounds ---
|
| 154 |
+
print("Invariant check: no attack_tag exceeded MAX_ATTEMPTS_PER_ATTACK_TAG")
|
| 155 |
+
for tag, count in tag_attempts.items():
|
| 156 |
+
if tag in ("Round Limit", "Session Error"):
|
| 157 |
+
continue
|
| 158 |
+
check(
|
| 159 |
+
f" tag '{tag}' attempts <= {MAX_ATTEMPTS_ALLOWED}",
|
| 160 |
+
count <= MAX_ATTEMPTS_ALLOWED,
|
| 161 |
+
f"got {count} attempts",
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
# --- Verify first weak answer triggered follow_up_same_tag (not a random jump) ---
|
| 165 |
+
first = rounds[0]
|
| 166 |
+
first_ja = first.get("judge_action", "")
|
| 167 |
+
check(
|
| 168 |
+
"First weak answer → follow_up or move (not random jump)",
|
| 169 |
+
first_ja in ("follow_up_same_tag", "move_after_limit", "move_next_tag"),
|
| 170 |
+
f"got {first_ja!r}",
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
# --- Verify third answer ("ok") did not keep drilling the same tag forever ---
|
| 174 |
+
if len(rounds) >= 3:
|
| 175 |
+
third_ja = rounds[2].get("judge_action", "")
|
| 176 |
+
check(
|
| 177 |
+
"After 2nd non-answer on same tag, judge moved or pressed (no infinite loop)",
|
| 178 |
+
third_ja in ("move_after_limit", "move_next_tag", "follow_up_same_tag"),
|
| 179 |
+
f"got {third_ja!r}",
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# --- Strong-answer checks (rounds[3] = STRONG_ANSWER) ---
|
| 183 |
+
if len(rounds) >= 4:
|
| 184 |
+
strong_round = rounds[3]
|
| 185 |
+
strong_ja = strong_round.get("judge_action", "")
|
| 186 |
+
strong_tag = strong_round.get("attack_tag", "")
|
| 187 |
+
strong_prev = strong_round.get("previous_attack_tag", "")
|
| 188 |
+
strong_satisfied = strong_round.get("topic_satisfied")
|
| 189 |
+
|
| 190 |
+
print("\nStrong-answer invariant checks:")
|
| 191 |
+
check(
|
| 192 |
+
"Strong answer → judge_action is move_next_tag",
|
| 193 |
+
strong_ja == "move_next_tag",
|
| 194 |
+
f"got {strong_ja!r}",
|
| 195 |
+
)
|
| 196 |
+
check(
|
| 197 |
+
"Strong answer → attack_tag changed from previous_attack_tag",
|
| 198 |
+
strong_tag != strong_prev,
|
| 199 |
+
f"both are {strong_tag!r} — judge did not advance",
|
| 200 |
+
)
|
| 201 |
+
check(
|
| 202 |
+
"Strong answer → topic_satisfied is True",
|
| 203 |
+
strong_satisfied is True,
|
| 204 |
+
f"got {strong_satisfied!r}",
|
| 205 |
+
)
|
| 206 |
+
check(
|
| 207 |
+
"Strong answer → answer_quality is 'strong'",
|
| 208 |
+
strong_round.get("answer_quality") == "strong",
|
| 209 |
+
f"got {strong_round.get('answer_quality')!r}",
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
print("\nAll Phase 3B checks passed. Socratic battle flow is working.\n")
|
| 213 |
+
sys.exit(0)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
if __name__ == "__main__":
|
| 217 |
+
main()
|
scripts/test_phase4_battle_stability.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 4 stability test: full battle lifecycle via the running server.
|
| 2 |
+
|
| 3 |
+
Validates:
|
| 4 |
+
- Battle runs cleanly from start to and beyond MAX_ROUNDS
|
| 5 |
+
- Strong answer causes move_next_tag with different attack_tag
|
| 6 |
+
- No attack_tag exceeds 2 attempts
|
| 7 |
+
- At round >= MAX_ROUNDS: soft_round_limit_reached=True, battle_complete=False, can_continue=True
|
| 8 |
+
- Chat continues working after MAX_ROUNDS (no hard block)
|
| 9 |
+
- /api/end-battle returns a scorecard dict (even if mock)
|
| 10 |
+
- Every ai_message is non-empty throughout
|
| 11 |
+
- No ai_message contains instruction leakage patterns
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
python scripts/test_phase4_battle_stability.py
|
| 15 |
+
PITCHFIGHT_BASE_URL=http://localhost:7861 python scripts/test_phase4_battle_stability.py
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import os
|
| 21 |
+
import re
|
| 22 |
+
import sys
|
| 23 |
+
from collections import defaultdict
|
| 24 |
+
|
| 25 |
+
import requests
|
| 26 |
+
|
| 27 |
+
BASE_URL = os.getenv("PITCHFIGHT_BASE_URL", "http://127.0.0.1:7861").rstrip("/")
|
| 28 |
+
MAX_ROUNDS = int(os.getenv("MAX_ROUNDS", "6"))
|
| 29 |
+
|
| 30 |
+
SAMPLE_STARTUP = {
|
| 31 |
+
"name": "EventRadar AI",
|
| 32 |
+
"problem": "Students miss relevant hackathons, workshops, and networking events.",
|
| 33 |
+
"solution": "AI-powered event discovery that ranks events by fit for each student profile.",
|
| 34 |
+
"why_ai": "Personalized ranking requires understanding student goals and event signals together.",
|
| 35 |
+
"stage": "Prototype",
|
| 36 |
+
"team": "2 founders",
|
| 37 |
+
"traction": "50 beta signups, no revenue yet",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
STRONG_ANSWER = (
|
| 41 |
+
"We validated this with 50 beta users, 3 campus ambassadors, "
|
| 42 |
+
"and weekly event-miss reports from two colleges."
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# Sequence designed to exercise all branches: weak → non_answer → strong → partial → weak → strong
|
| 46 |
+
ANSWER_SEQUENCE = [
|
| 47 |
+
"It is a pretty big market because students attend events often.", # weak
|
| 48 |
+
"I don't know", # non_answer
|
| 49 |
+
STRONG_ANSWER, # strong → move_next_tag
|
| 50 |
+
"The AI ranks events based on student interest profiles.", # partial
|
| 51 |
+
"okay fine", # non_answer
|
| 52 |
+
"We use embeddings and a ranking model trained on student behavior.", # strong (technical)
|
| 53 |
+
"It is useful and helpful for everyone.", # weak — beyond MAX_ROUNDS
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
_LEAKAGE_PATTERNS = re.compile(
|
| 57 |
+
r"we need to\b|the prompt says\b|as instructed\b|my instructions\b"
|
| 58 |
+
r"|i am supposed to\b|i should follow\b|the rules say\b|per the instructions?\b",
|
| 59 |
+
re.IGNORECASE,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def post(path: str, body: dict, timeout: int = 90) -> dict:
|
| 64 |
+
resp = requests.post(f"{BASE_URL}{path}", json=body, timeout=timeout)
|
| 65 |
+
resp.raise_for_status()
|
| 66 |
+
return resp.json()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def check(label: str, condition: bool, detail: str = "") -> None:
|
| 70 |
+
marker = "PASS" if condition else "FAIL"
|
| 71 |
+
suffix = f" — {detail}" if detail else ""
|
| 72 |
+
print(f" {marker} {label}{suffix}")
|
| 73 |
+
if not condition:
|
| 74 |
+
sys.exit(1)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def main() -> None:
|
| 78 |
+
print(f"\nPhase 4 Battle Stability Test (Soft Round Limit)")
|
| 79 |
+
print(f"Base URL : {BASE_URL}")
|
| 80 |
+
print(f"MAX_ROUNDS: {MAX_ROUNDS}\n")
|
| 81 |
+
|
| 82 |
+
# -------------------------------------------------------------------------
|
| 83 |
+
# Step 0: Start session
|
| 84 |
+
# -------------------------------------------------------------------------
|
| 85 |
+
print("Step 0: POST /api/start-session")
|
| 86 |
+
try:
|
| 87 |
+
start = post("/api/start-session", {
|
| 88 |
+
"mode": "pitch_battle",
|
| 89 |
+
"startup": SAMPLE_STARTUP,
|
| 90 |
+
"persona": "hackathon_judge",
|
| 91 |
+
"difficulty": "high",
|
| 92 |
+
"input_mode": "text",
|
| 93 |
+
"model_mode": "premium_nvidia",
|
| 94 |
+
})
|
| 95 |
+
except Exception as exc:
|
| 96 |
+
print(f" FAIL Request failed: {exc}")
|
| 97 |
+
sys.exit(1)
|
| 98 |
+
|
| 99 |
+
session_id = start.get("session_id")
|
| 100 |
+
check("session_id present", bool(session_id))
|
| 101 |
+
check("ai_message non-empty", bool(start.get("ai_message")))
|
| 102 |
+
check("round == 1", start.get("round") == 1, f"got {start.get('round')}")
|
| 103 |
+
check("judge_action == opening_question", start.get("judge_action") == "opening_question")
|
| 104 |
+
check("battle_complete=False at start", start.get("battle_complete") is False)
|
| 105 |
+
check("can_continue=True at start", start.get("can_continue") is True)
|
| 106 |
+
|
| 107 |
+
opening_tag = start.get("attack_tag")
|
| 108 |
+
print(f" opening_tag : {opening_tag}")
|
| 109 |
+
print(f" battle_phase : {start.get('battle_phase')}")
|
| 110 |
+
print(f" ai_message : {start['ai_message'][:100]}...\n")
|
| 111 |
+
|
| 112 |
+
check(
|
| 113 |
+
"start ai_message has no leakage",
|
| 114 |
+
not _LEAKAGE_PATTERNS.search(start.get("ai_message", "")),
|
| 115 |
+
"instruction leakage detected in opening message",
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# -------------------------------------------------------------------------
|
| 119 |
+
# Steps 1..MAX_ROUNDS+1: chat rounds (one beyond soft limit)
|
| 120 |
+
# -------------------------------------------------------------------------
|
| 121 |
+
tag_attempt_counts: dict[str, int] = defaultdict(int)
|
| 122 |
+
tag_attempt_counts[opening_tag] += 1
|
| 123 |
+
|
| 124 |
+
rounds: list[dict] = []
|
| 125 |
+
strong_round_idx: int | None = None
|
| 126 |
+
|
| 127 |
+
answers_to_send = ANSWER_SEQUENCE[: MAX_ROUNDS + 1] # include one beyond soft limit
|
| 128 |
+
|
| 129 |
+
for i, answer in enumerate(answers_to_send):
|
| 130 |
+
print(f"Step {i + 1}: POST /api/chat-round answer={answer[:60]!r}")
|
| 131 |
+
try:
|
| 132 |
+
data = post("/api/chat-round", {
|
| 133 |
+
"session_id": session_id,
|
| 134 |
+
"user_message": answer,
|
| 135 |
+
})
|
| 136 |
+
except Exception as exc:
|
| 137 |
+
print(f" FAIL Request failed: {exc}")
|
| 138 |
+
sys.exit(1)
|
| 139 |
+
|
| 140 |
+
atag = data.get("attack_tag", "")
|
| 141 |
+
aq = data.get("answer_quality", "")
|
| 142 |
+
ja = data.get("judge_action", "")
|
| 143 |
+
prev_tag = data.get("previous_attack_tag", "")
|
| 144 |
+
rnd = data.get("round", 0)
|
| 145 |
+
bc = data.get("battle_complete", True)
|
| 146 |
+
cc = data.get("can_continue", False)
|
| 147 |
+
na = data.get("next_action", "")
|
| 148 |
+
sl = data.get("soft_round_limit_reached", False)
|
| 149 |
+
phase = data.get("battle_phase", "")
|
| 150 |
+
|
| 151 |
+
if atag not in ("Round Limit", "Session Error", ""):
|
| 152 |
+
tag_attempt_counts[atag] += 1
|
| 153 |
+
|
| 154 |
+
print(f" round : {rnd}")
|
| 155 |
+
print(f" battle_phase : {phase}")
|
| 156 |
+
print(f" attack_tag : {atag}")
|
| 157 |
+
print(f" prev_tag : {prev_tag}")
|
| 158 |
+
print(f" answer_quality : {aq}")
|
| 159 |
+
print(f" judge_action : {ja}")
|
| 160 |
+
print(f" battle_complete : {bc} can_continue: {cc} next_action: {na}")
|
| 161 |
+
print(f" soft_round_limit_reached: {sl}")
|
| 162 |
+
print(f" model_ok : {data.get('model_ok')} provider: {data.get('provider')}")
|
| 163 |
+
print(f" ai_message : {data.get('ai_message', '')[:100]}...\n")
|
| 164 |
+
|
| 165 |
+
# Required fields
|
| 166 |
+
check("session_id echoed", data.get("session_id") == session_id)
|
| 167 |
+
check("ai_message non-empty", bool(data.get("ai_message")), "empty ai_message")
|
| 168 |
+
check("attack_tag present", bool(atag))
|
| 169 |
+
check("provider present", bool(data.get("provider")))
|
| 170 |
+
check("model_mode present", bool(data.get("model_mode")))
|
| 171 |
+
|
| 172 |
+
# battle_complete must always be False (soft limit only)
|
| 173 |
+
check("battle_complete always False", bc is False, f"got {bc!r}")
|
| 174 |
+
check("can_continue always True", cc is True, f"got {cc!r}")
|
| 175 |
+
check("next_action always continue", na == "continue", f"got {na!r}")
|
| 176 |
+
|
| 177 |
+
# Soft limit flag
|
| 178 |
+
if rnd >= MAX_ROUNDS:
|
| 179 |
+
check("soft_round_limit_reached=True at/after MAX_ROUNDS", sl is True, f"got {sl!r}")
|
| 180 |
+
check(
|
| 181 |
+
"recommended_action=end_battle when soft limit",
|
| 182 |
+
data.get("recommended_action") == "end_battle",
|
| 183 |
+
f"got {data.get('recommended_action')!r}",
|
| 184 |
+
)
|
| 185 |
+
else:
|
| 186 |
+
check("soft_round_limit_reached=False before MAX_ROUNDS", sl is False, f"got {sl!r}")
|
| 187 |
+
|
| 188 |
+
# Valid answer quality and judge action on normal rounds
|
| 189 |
+
if atag not in ("Round Limit",):
|
| 190 |
+
check(
|
| 191 |
+
"answer_quality valid",
|
| 192 |
+
aq in ("strong", "partial", "weak", "non_answer"),
|
| 193 |
+
f"got {aq!r}",
|
| 194 |
+
)
|
| 195 |
+
check(
|
| 196 |
+
"judge_action valid",
|
| 197 |
+
ja in ("follow_up_same_tag", "move_next_tag", "move_after_limit"),
|
| 198 |
+
f"got {ja!r}",
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
# No leakage
|
| 202 |
+
ai_msg = data.get("ai_message", "")
|
| 203 |
+
check(
|
| 204 |
+
"ai_message has no instruction leakage",
|
| 205 |
+
not _LEAKAGE_PATTERNS.search(ai_msg),
|
| 206 |
+
"instruction leakage detected",
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
# battle_phase progression
|
| 210 |
+
if rnd <= 3:
|
| 211 |
+
check("battle_phase=explore in rounds 1-3", phase == "explore", f"got {phase!r}")
|
| 212 |
+
elif rnd <= 6:
|
| 213 |
+
check("battle_phase=pressure in rounds 4-6", phase == "pressure", f"got {phase!r}")
|
| 214 |
+
else:
|
| 215 |
+
check("battle_phase=close in rounds 7+", phase == "close", f"got {phase!r}")
|
| 216 |
+
|
| 217 |
+
# Strong answer invariant
|
| 218 |
+
if aq == "strong" and strong_round_idx is None:
|
| 219 |
+
strong_round_idx = i
|
| 220 |
+
check("Strong answer → judge_action is move_next_tag", ja == "move_next_tag", f"got {ja!r}")
|
| 221 |
+
check("Strong answer → attack_tag changed", atag != prev_tag, f"both are {atag!r}")
|
| 222 |
+
check("Strong answer → topic_satisfied is True", data.get("topic_satisfied") is True)
|
| 223 |
+
|
| 224 |
+
rounds.append(data)
|
| 225 |
+
|
| 226 |
+
# -------------------------------------------------------------------------
|
| 227 |
+
# Invariant: no attack_tag exceeded 2 attempts
|
| 228 |
+
# -------------------------------------------------------------------------
|
| 229 |
+
print("Invariant: no attack_tag exceeded MAX_ATTEMPTS_PER_ATTACK_TAG (2)")
|
| 230 |
+
for tag, count in tag_attempt_counts.items():
|
| 231 |
+
if tag in ("Round Limit", "Session Error", ""):
|
| 232 |
+
continue
|
| 233 |
+
check(f" tag '{tag}' attempts <= 2", count <= 2, f"got {count}")
|
| 234 |
+
|
| 235 |
+
# -------------------------------------------------------------------------
|
| 236 |
+
# /api/end-battle — must return a scorecard dict (mock is fine)
|
| 237 |
+
# -------------------------------------------------------------------------
|
| 238 |
+
print("\nStep final: POST /api/end-battle")
|
| 239 |
+
try:
|
| 240 |
+
scorecard = post("/api/end-battle", {"session_id": session_id})
|
| 241 |
+
except Exception as exc:
|
| 242 |
+
print(f" FAIL end-battle request failed: {exc}")
|
| 243 |
+
sys.exit(1)
|
| 244 |
+
|
| 245 |
+
print(f" overall : {scorecard.get('overall')}")
|
| 246 |
+
print(f" overall_label : {scorecard.get('overall_label')}")
|
| 247 |
+
print(f" scorecard_source: {scorecard.get('scorecard_source')}")
|
| 248 |
+
print(f" keys : {list(scorecard.keys())}")
|
| 249 |
+
|
| 250 |
+
check("end-battle returns dict", isinstance(scorecard, dict))
|
| 251 |
+
check("no error in end-battle", "error" not in scorecard, f"error={scorecard.get('error')}")
|
| 252 |
+
check("overall score present", "overall" in scorecard, f"keys={list(scorecard.keys())}")
|
| 253 |
+
check("overall_label present", "overall_label" in scorecard)
|
| 254 |
+
check("scorecard_source present", "scorecard_source" in scorecard)
|
| 255 |
+
|
| 256 |
+
print("\nAll Phase 4 stability checks passed.\n")
|
| 257 |
+
sys.exit(0)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
if __name__ == "__main__":
|
| 261 |
+
main()
|
scripts/test_phase5_scorecard.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 5D scorecard test: hybrid claim-based scoring via the running server.
|
| 2 |
+
|
| 3 |
+
Validates:
|
| 4 |
+
- /api/end-battle returns all required fields
|
| 5 |
+
- All 6 score dimensions present with integer scores 0-100
|
| 6 |
+
- Score labels use 5-band scheme (Not addressed / Developing / Solid / Strong / Excellent)
|
| 7 |
+
- top_3_questions has exactly 3 items
|
| 8 |
+
- model_ok, provider, scorecard_source present
|
| 9 |
+
- concrete_signals_summary present with required sub-keys
|
| 10 |
+
- scorecard_source in ("hybrid_claims_nemotron", "hybrid_claims_local")
|
| 11 |
+
- provider in ("local+nvidia", "local")
|
| 12 |
+
- Set STRICT_NEMOTRON_COACHING=true to fail if not hybrid_claims_nemotron
|
| 13 |
+
- Conversation includes "50 beta users" and "3 campus ambassadors" →
|
| 14 |
+
expect these signals to appear in concrete_signals_summary or scores
|
| 15 |
+
- No old static mock content
|
| 16 |
+
|
| 17 |
+
Usage:
|
| 18 |
+
python scripts/test_phase5_scorecard.py
|
| 19 |
+
PITCHFIGHT_BASE_URL=http://localhost:7861 python scripts/test_phase5_scorecard.py
|
| 20 |
+
STRICT_NEMOTRON_COACHING=true python scripts/test_phase5_scorecard.py
|
| 21 |
+
|
| 22 |
+
Server must already be running (python app.py).
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import os
|
| 28 |
+
import re
|
| 29 |
+
import sys
|
| 30 |
+
|
| 31 |
+
import requests
|
| 32 |
+
|
| 33 |
+
BASE_URL = os.getenv("PITCHFIGHT_BASE_URL", "http://127.0.0.1:7861").rstrip("/")
|
| 34 |
+
STRICT_COACHING = os.getenv("STRICT_NEMOTRON_COACHING", "false").strip().lower() == "true"
|
| 35 |
+
# Legacy flag kept for compatibility
|
| 36 |
+
ALLOW_FALLBACK = os.getenv("ALLOW_SCORECARD_FALLBACK", "false").strip().lower() == "true"
|
| 37 |
+
|
| 38 |
+
SAMPLE_STARTUP = {
|
| 39 |
+
"name": "EventRadar AI",
|
| 40 |
+
"problem": "Students miss relevant hackathons, workshops, and networking events.",
|
| 41 |
+
"solution": "AI-powered event discovery that ranks events by fit for each student profile.",
|
| 42 |
+
"why_ai": "Personalized ranking requires understanding student goals and event signals together.",
|
| 43 |
+
"stage": "Prototype",
|
| 44 |
+
"team": "2 founders",
|
| 45 |
+
"traction": "50 beta signups, no revenue yet",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
STRONG_ANSWER = (
|
| 49 |
+
"We validated this with 50 beta users, 3 campus ambassadors, "
|
| 50 |
+
"and weekly event-miss reports from two colleges."
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
ANSWERS = [
|
| 54 |
+
"It is a big market because students attend events often.", # weak
|
| 55 |
+
"I don't know", # non_answer
|
| 56 |
+
STRONG_ANSWER, # strong → evidence
|
| 57 |
+
"The AI personalizes event ranking based on student goals.", # partial
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
REQUIRED_DIMS = {
|
| 61 |
+
"clarity",
|
| 62 |
+
"problem_understanding",
|
| 63 |
+
"market_awareness",
|
| 64 |
+
"differentiation",
|
| 65 |
+
"business_model",
|
| 66 |
+
"objection_handling",
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
_VALID_LABELS = {"Not addressed", "Developing", "Solid", "Strong", "Excellent"}
|
| 70 |
+
_VALID_SOURCES = {"hybrid_claims_nemotron", "hybrid_claims_local"}
|
| 71 |
+
_VALID_PROVIDERS = {"local+nvidia", "local"}
|
| 72 |
+
|
| 73 |
+
_LEAKAGE = re.compile(
|
| 74 |
+
r"we need to\b|the prompt says\b|as instructed\b|my instructions\b"
|
| 75 |
+
r"|i am supposed to\b|the rules say\b",
|
| 76 |
+
re.IGNORECASE,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def post(path: str, body: dict, timeout: int = 120) -> dict:
|
| 81 |
+
resp = requests.post(f"{BASE_URL}{path}", json=body, timeout=timeout)
|
| 82 |
+
resp.raise_for_status()
|
| 83 |
+
return resp.json()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def check(label: str, condition: bool, detail: str = "") -> None:
|
| 87 |
+
marker = "PASS" if condition else "FAIL"
|
| 88 |
+
suffix = f" — {detail}" if detail else ""
|
| 89 |
+
print(f" {marker} {label}{suffix}")
|
| 90 |
+
if not condition:
|
| 91 |
+
sys.exit(1)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def main() -> None:
|
| 95 |
+
print(f"\nPhase 5D Scorecard Test (Hybrid Claim-Based)")
|
| 96 |
+
print(f"Base URL : {BASE_URL}")
|
| 97 |
+
print(f"Strict Nemotron : {STRICT_COACHING}")
|
| 98 |
+
print()
|
| 99 |
+
|
| 100 |
+
# -------------------------------------------------------------------------
|
| 101 |
+
# Step 0: Start session
|
| 102 |
+
# -------------------------------------------------------------------------
|
| 103 |
+
print("Step 0: POST /api/start-session")
|
| 104 |
+
try:
|
| 105 |
+
start = post("/api/start-session", {
|
| 106 |
+
"mode": "pitch_battle",
|
| 107 |
+
"startup": SAMPLE_STARTUP,
|
| 108 |
+
"persona": "hackathon_judge",
|
| 109 |
+
"difficulty": "high",
|
| 110 |
+
"input_mode": "text",
|
| 111 |
+
"model_mode": "premium_nvidia",
|
| 112 |
+
})
|
| 113 |
+
except Exception as exc:
|
| 114 |
+
print(f" FAIL Could not start session: {exc}")
|
| 115 |
+
sys.exit(1)
|
| 116 |
+
|
| 117 |
+
session_id = start.get("session_id")
|
| 118 |
+
check("session_id present", bool(session_id))
|
| 119 |
+
check("ai_message non-empty", bool(start.get("ai_message")))
|
| 120 |
+
print(f" session_id : {session_id}")
|
| 121 |
+
print(f" attack_tag : {start.get('attack_tag')}")
|
| 122 |
+
print(f" ai_message : {start.get('ai_message', '')[:100]}...\n")
|
| 123 |
+
|
| 124 |
+
# -------------------------------------------------------------------------
|
| 125 |
+
# Steps 1-N: Send answers
|
| 126 |
+
# -------------------------------------------------------------------------
|
| 127 |
+
for i, answer in enumerate(ANSWERS):
|
| 128 |
+
print(f"Step {i + 1}: POST /api/chat-round answer={answer[:60]!r}")
|
| 129 |
+
try:
|
| 130 |
+
data = post("/api/chat-round", {
|
| 131 |
+
"session_id": session_id,
|
| 132 |
+
"user_message": answer,
|
| 133 |
+
})
|
| 134 |
+
except Exception as exc:
|
| 135 |
+
print(f" FAIL chat-round failed: {exc}")
|
| 136 |
+
sys.exit(1)
|
| 137 |
+
|
| 138 |
+
check("session_id echoed", data.get("session_id") == session_id)
|
| 139 |
+
check("ai_message non-empty", bool(data.get("ai_message")))
|
| 140 |
+
print(
|
| 141 |
+
f" round={data.get('round')} "
|
| 142 |
+
f"quality={data.get('answer_quality')} "
|
| 143 |
+
f"action={data.get('judge_action')} "
|
| 144 |
+
f"tag={data.get('attack_tag')}\n"
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
# -------------------------------------------------------------------------
|
| 148 |
+
# Final: POST /api/end-battle
|
| 149 |
+
# -------------------------------------------------------------------------
|
| 150 |
+
print("Final step: POST /api/end-battle (local scoring is fast; Nemotron coaching may take ~60s)")
|
| 151 |
+
try:
|
| 152 |
+
sc = post("/api/end-battle", {"session_id": session_id}, timeout=180)
|
| 153 |
+
except Exception as exc:
|
| 154 |
+
print(f" FAIL end-battle request failed: {exc}")
|
| 155 |
+
sys.exit(1)
|
| 156 |
+
|
| 157 |
+
# --- Top-level required fields ---
|
| 158 |
+
check("overall present", "overall" in sc, f"keys={list(sc.keys())}")
|
| 159 |
+
check("overall_label present", "overall_label" in sc)
|
| 160 |
+
check("scores present", "scores" in sc)
|
| 161 |
+
check("best_answer present", "best_answer" in sc)
|
| 162 |
+
check("weakest_answer present", "weakest_answer" in sc)
|
| 163 |
+
check("improved_answer present", "improved_answer" in sc)
|
| 164 |
+
check("improved_pitch present", "improved_pitch" in sc)
|
| 165 |
+
check("top_3_questions present", "top_3_questions" in sc)
|
| 166 |
+
check("model_ok present", "model_ok" in sc)
|
| 167 |
+
check("provider present", "provider" in sc)
|
| 168 |
+
check("scorecard_source present", "scorecard_source" in sc)
|
| 169 |
+
check("no error key", "error" not in sc, f"error={sc.get('error')}")
|
| 170 |
+
|
| 171 |
+
overall = sc.get("overall", -1)
|
| 172 |
+
check("overall is int 0-100", isinstance(overall, int) and 0 <= overall <= 100, f"got {overall!r}")
|
| 173 |
+
check(
|
| 174 |
+
"overall_label valid",
|
| 175 |
+
sc.get("overall_label") in _VALID_LABELS,
|
| 176 |
+
f"got {sc.get('overall_label')!r}",
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
# --- concrete_signals_summary ---
|
| 180 |
+
css = sc.get("concrete_signals_summary")
|
| 181 |
+
check("concrete_signals_summary present", isinstance(css, dict), f"got {type(css).__name__}")
|
| 182 |
+
if isinstance(css, dict):
|
| 183 |
+
for key in ("numbers", "validation", "competitors", "revenue_signals", "technical_mechanisms"):
|
| 184 |
+
check(
|
| 185 |
+
f"concrete_signals_summary.{key} is list",
|
| 186 |
+
isinstance(css.get(key), list),
|
| 187 |
+
f"got {type(css.get(key)).__name__}",
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
# --- Dimension checks ---
|
| 191 |
+
scores = sc.get("scores", {})
|
| 192 |
+
check("all 6 dimensions present", REQUIRED_DIMS <= set(scores.keys()),
|
| 193 |
+
f"missing={REQUIRED_DIMS - set(scores.keys())}")
|
| 194 |
+
|
| 195 |
+
for dim in REQUIRED_DIMS:
|
| 196 |
+
dim_data = scores.get(dim, {})
|
| 197 |
+
dim_score = dim_data.get("score", -1)
|
| 198 |
+
check(
|
| 199 |
+
f"{dim}.score is int 0-100",
|
| 200 |
+
isinstance(dim_score, int) and 0 <= dim_score <= 100,
|
| 201 |
+
f"got {dim_score!r}",
|
| 202 |
+
)
|
| 203 |
+
check(f"{dim}.reason non-empty", bool(dim_data.get("reason")))
|
| 204 |
+
check(
|
| 205 |
+
f"{dim}.label is valid",
|
| 206 |
+
dim_data.get("label") in _VALID_LABELS,
|
| 207 |
+
f"got {dim_data.get('label')!r}",
|
| 208 |
+
)
|
| 209 |
+
check(
|
| 210 |
+
f"{dim}.signals_used is list",
|
| 211 |
+
isinstance(dim_data.get("signals_used", []), list),
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
# --- top_3_questions ---
|
| 215 |
+
top3 = sc.get("top_3_questions", [])
|
| 216 |
+
check("top_3_questions is list", isinstance(top3, list))
|
| 217 |
+
check("top_3_questions has exactly 3", len(top3) == 3, f"got {len(top3)}")
|
| 218 |
+
for q in top3:
|
| 219 |
+
check("question is non-empty string", isinstance(q, str) and bool(q.strip()))
|
| 220 |
+
|
| 221 |
+
# --- Source and provider checks (Phase 5D: hybrid architecture) ---
|
| 222 |
+
source = sc.get("scorecard_source", "")
|
| 223 |
+
model_ok = sc.get("model_ok")
|
| 224 |
+
provider = sc.get("provider", "")
|
| 225 |
+
|
| 226 |
+
check(
|
| 227 |
+
"scorecard_source is hybrid",
|
| 228 |
+
source in _VALID_SOURCES,
|
| 229 |
+
f"got {source!r} — expected one of {_VALID_SOURCES}",
|
| 230 |
+
)
|
| 231 |
+
check(
|
| 232 |
+
"provider is local or local+nvidia",
|
| 233 |
+
provider in _VALID_PROVIDERS,
|
| 234 |
+
f"got {provider!r}",
|
| 235 |
+
)
|
| 236 |
+
check("model_ok is bool", isinstance(model_ok, bool), f"got {type(model_ok).__name__}")
|
| 237 |
+
|
| 238 |
+
if STRICT_COACHING and source != "hybrid_claims_nemotron":
|
| 239 |
+
print(
|
| 240 |
+
f"\n FAIL STRICT_NEMOTRON_COACHING=true but source={source!r}\n"
|
| 241 |
+
f" model_error: {sc.get('model_error', 'none')}\n"
|
| 242 |
+
" Nemotron coaching did not produce valid JSON."
|
| 243 |
+
)
|
| 244 |
+
sys.exit(1)
|
| 245 |
+
elif source == "hybrid_claims_local":
|
| 246 |
+
print(f" NOTE: hybrid_claims_local — Nemotron coaching unavailable, local fallback used.")
|
| 247 |
+
print(f" model_error: {sc.get('model_error', 'none')}")
|
| 248 |
+
else:
|
| 249 |
+
print(f" NOTE: hybrid_claims_nemotron — Nemotron coaching succeeded.")
|
| 250 |
+
|
| 251 |
+
# --- Evidence detection: "50 beta users" should appear in signals/scores ---
|
| 252 |
+
evidence_terms = ["50 beta", "campus ambassador", "event-miss report", "validated", "beta users"]
|
| 253 |
+
evidence_fields = [
|
| 254 |
+
str(sc.get("best_answer", "")),
|
| 255 |
+
str(sc.get("improved_answer", "")),
|
| 256 |
+
str(sc.get("improved_pitch", "")),
|
| 257 |
+
]
|
| 258 |
+
for dim in REQUIRED_DIMS:
|
| 259 |
+
evidence_fields.append(str(scores.get(dim, {}).get("reason", "")))
|
| 260 |
+
for sig in scores.get(dim, {}).get("signals_used", []):
|
| 261 |
+
evidence_fields.append(str(sig))
|
| 262 |
+
if isinstance(css, dict):
|
| 263 |
+
for key in ("numbers", "validation"):
|
| 264 |
+
evidence_fields.extend(str(x) for x in css.get(key, []))
|
| 265 |
+
combined = " ".join(evidence_fields).lower()
|
| 266 |
+
evidence_found = any(t.lower() in combined for t in evidence_terms)
|
| 267 |
+
if evidence_found:
|
| 268 |
+
print(" PASS Scorecard acknowledges concrete validation evidence from STRONG_ANSWER")
|
| 269 |
+
else:
|
| 270 |
+
print(" NOTE Scorecard did not explicitly reference validation evidence (not a hard fail in 5D)")
|
| 271 |
+
|
| 272 |
+
# --- No static EventRadar mock content ---
|
| 273 |
+
all_text = " ".join([
|
| 274 |
+
str(sc.get("best_answer", "")),
|
| 275 |
+
str(sc.get("weakest_answer", "")),
|
| 276 |
+
str(sc.get("improved_answer", "")),
|
| 277 |
+
str(sc.get("improved_pitch", "")),
|
| 278 |
+
])
|
| 279 |
+
check(
|
| 280 |
+
"no static WhatsApp groups mock content",
|
| 281 |
+
"WhatsApp groups" not in all_text or "EventRadar" in SAMPLE_STARTUP.get("name", ""),
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
# --- Leakage check ---
|
| 285 |
+
scorecard_text = " ".join([
|
| 286 |
+
str(sc.get("best_answer", "")),
|
| 287 |
+
str(sc.get("weakest_answer", "")),
|
| 288 |
+
str(sc.get("improved_answer", "")),
|
| 289 |
+
str(sc.get("improved_pitch", "")),
|
| 290 |
+
str(sc.get("why_weak", "")),
|
| 291 |
+
])
|
| 292 |
+
check("no leakage in scorecard text", not _LEAKAGE.search(scorecard_text), "instruction leakage detected")
|
| 293 |
+
|
| 294 |
+
# -------------------------------------------------------------------------
|
| 295 |
+
# Summary
|
| 296 |
+
# -------------------------------------------------------------------------
|
| 297 |
+
print("\n--- Scorecard Summary ---")
|
| 298 |
+
print(f" scorecard_source : {sc.get('scorecard_source')}")
|
| 299 |
+
print(f" model_ok : {sc.get('model_ok')} provider: {sc.get('provider')}")
|
| 300 |
+
print(f" overall : {overall} ({sc.get('overall_label')})")
|
| 301 |
+
print()
|
| 302 |
+
for dim in REQUIRED_DIMS:
|
| 303 |
+
d = scores.get(dim, {})
|
| 304 |
+
sigs = d.get("signals_used", [])
|
| 305 |
+
print(
|
| 306 |
+
f" {dim:<25} score={d.get('score', '?'):>3} [{d.get('label', '?')}]"
|
| 307 |
+
+ (f" sigs={sigs[:2]}" if sigs else "")
|
| 308 |
+
)
|
| 309 |
+
print()
|
| 310 |
+
print(f" weakest_answer : {sc.get('weakest_answer', '')[:120]}")
|
| 311 |
+
print(f" improved_answer : {sc.get('improved_answer', '')[:120]}")
|
| 312 |
+
print(f" top_3_questions :")
|
| 313 |
+
for q in top3:
|
| 314 |
+
print(f" - {q}")
|
| 315 |
+
if isinstance(css, dict):
|
| 316 |
+
print(f"\n concrete_signals_summary:")
|
| 317 |
+
for k, v in css.items():
|
| 318 |
+
if v:
|
| 319 |
+
print(f" {k}: {v[:3]}")
|
| 320 |
+
|
| 321 |
+
if sc.get("model_error"):
|
| 322 |
+
print(f"\n model_error : {sc.get('model_error')}")
|
| 323 |
+
|
| 324 |
+
print("\nAll Phase 5D scorecard checks passed.\n")
|
| 325 |
+
sys.exit(0)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
if __name__ == "__main__":
|
| 329 |
+
main()
|
scripts/test_phase5b_refinement.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 5B/5D refinement test: 8+ rounds, soft limit, battle_phase, labels, no leakage.
|
| 2 |
+
|
| 3 |
+
Validates:
|
| 4 |
+
- User can continue chatting past MAX_ROUNDS (no hard block)
|
| 5 |
+
- soft_round_limit_reached appears at round >= MAX_ROUNDS
|
| 6 |
+
- battle_phase progresses: explore (1-3), pressure (4-6), close (7+)
|
| 7 |
+
- No instruction leakage in any ai_message
|
| 8 |
+
- /api/end-battle returns hybrid claim-based scorecard with score labels
|
| 9 |
+
- overall_label and per-dimension labels present
|
| 10 |
+
- scorecard_source in ("hybrid_claims_nemotron", "hybrid_claims_local")
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
python scripts/test_phase5b_refinement.py
|
| 14 |
+
ALLOW_SCORECARD_FALLBACK=true python scripts/test_phase5b_refinement.py
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import re
|
| 21 |
+
import sys
|
| 22 |
+
|
| 23 |
+
import requests
|
| 24 |
+
|
| 25 |
+
BASE_URL = os.getenv("PITCHFIGHT_BASE_URL", "http://127.0.0.1:7861").rstrip("/")
|
| 26 |
+
ALLOW_FALLBACK = os.getenv("ALLOW_SCORECARD_FALLBACK", "false").strip().lower() == "true"
|
| 27 |
+
MAX_ROUNDS = int(os.getenv("MAX_ROUNDS", "6"))
|
| 28 |
+
|
| 29 |
+
SAMPLE_STARTUP = {
|
| 30 |
+
"name": "EventRadar AI",
|
| 31 |
+
"problem": "Students miss relevant hackathons, workshops, and networking events.",
|
| 32 |
+
"solution": "AI-powered event discovery that ranks events by fit for each student profile.",
|
| 33 |
+
"why_ai": "Personalized ranking requires understanding student goals and event signals together.",
|
| 34 |
+
"stage": "Prototype",
|
| 35 |
+
"team": "2 founders",
|
| 36 |
+
"traction": "50 beta signups, no revenue yet",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
STRONG_ANSWER = (
|
| 40 |
+
"We validated this with 50 beta users, 3 campus ambassadors, "
|
| 41 |
+
"and weekly event-miss reports from two colleges."
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# 9 answers to go well past MAX_ROUNDS=6
|
| 45 |
+
ANSWERS = [
|
| 46 |
+
"It is a pretty big market because students attend events often.", # weak
|
| 47 |
+
"I don't know", # non_answer
|
| 48 |
+
STRONG_ANSWER, # strong
|
| 49 |
+
"The AI ranks events based on student interest profiles.", # partial
|
| 50 |
+
"We use embeddings and a ranking model trained on student behavior data.", # strong
|
| 51 |
+
"okay", # non_answer
|
| 52 |
+
"Our differentiation is the personalization layer no event aggregator has.",# partial
|
| 53 |
+
"We plan to charge colleges $500/month for analytics and promotions.", # partial (revenue)
|
| 54 |
+
"We have 3 campus ambassadors onboarding 20 students each right now.", # strong
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
REQUIRED_DIMS = {
|
| 58 |
+
"clarity", "problem_understanding", "market_awareness",
|
| 59 |
+
"differentiation", "business_model", "objection_handling",
|
| 60 |
+
}
|
| 61 |
+
_VALID_LABELS = {"Not addressed", "Developing", "Solid", "Strong", "Excellent"}
|
| 62 |
+
|
| 63 |
+
_LEAKAGE = re.compile(
|
| 64 |
+
r"we need to\b|the prompt says\b|as instructed\b|my instructions\b"
|
| 65 |
+
r"|i am supposed to\b|i should follow\b|the rules say\b|per the instructions?\b",
|
| 66 |
+
re.IGNORECASE,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def post(path: str, body: dict, timeout: int = 180) -> dict:
|
| 71 |
+
resp = requests.post(f"{BASE_URL}{path}", json=body, timeout=timeout)
|
| 72 |
+
resp.raise_for_status()
|
| 73 |
+
return resp.json()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def check(label: str, condition: bool, detail: str = "") -> None:
|
| 77 |
+
marker = "PASS" if condition else "FAIL"
|
| 78 |
+
suffix = f" — {detail}" if detail else ""
|
| 79 |
+
print(f" {marker} {label}{suffix}")
|
| 80 |
+
if not condition:
|
| 81 |
+
sys.exit(1)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def main() -> None:
|
| 85 |
+
print(f"\nPhase 5B Refinement Test")
|
| 86 |
+
print(f"Base URL : {BASE_URL}")
|
| 87 |
+
print(f"MAX_ROUNDS : {MAX_ROUNDS}")
|
| 88 |
+
print(f"Allow fallback : {ALLOW_FALLBACK}")
|
| 89 |
+
print(f"Total answers : {len(ANSWERS)} (well past MAX_ROUNDS)\n")
|
| 90 |
+
|
| 91 |
+
# -------------------------------------------------------------------------
|
| 92 |
+
# Step 0: Start session
|
| 93 |
+
# -------------------------------------------------------------------------
|
| 94 |
+
print("Step 0: POST /api/start-session")
|
| 95 |
+
try:
|
| 96 |
+
start = post("/api/start-session", {
|
| 97 |
+
"mode": "pitch_battle",
|
| 98 |
+
"startup": SAMPLE_STARTUP,
|
| 99 |
+
"persona": "hackathon_judge",
|
| 100 |
+
"difficulty": "high",
|
| 101 |
+
"input_mode": "text",
|
| 102 |
+
"model_mode": "premium_nvidia",
|
| 103 |
+
})
|
| 104 |
+
except Exception as exc:
|
| 105 |
+
print(f" FAIL Could not start session: {exc}")
|
| 106 |
+
sys.exit(1)
|
| 107 |
+
|
| 108 |
+
session_id = start.get("session_id")
|
| 109 |
+
check("session_id present", bool(session_id))
|
| 110 |
+
check("ai_message non-empty", bool(start.get("ai_message")))
|
| 111 |
+
check("battle_complete=False at start", start.get("battle_complete") is False)
|
| 112 |
+
check("can_continue=True at start", start.get("can_continue") is True)
|
| 113 |
+
check("battle_phase present", bool(start.get("battle_phase")))
|
| 114 |
+
check("start battle_phase=explore", start.get("battle_phase") == "explore",
|
| 115 |
+
f"got {start.get('battle_phase')!r}")
|
| 116 |
+
check("no leakage in opening", not _LEAKAGE.search(start.get("ai_message", "")))
|
| 117 |
+
|
| 118 |
+
print(f" session_id : {session_id}")
|
| 119 |
+
print(f" attack_tag : {start.get('attack_tag')}")
|
| 120 |
+
print(f" battle_phase : {start.get('battle_phase')}")
|
| 121 |
+
print(f" ai_message : {start.get('ai_message', '')[:100]}...\n")
|
| 122 |
+
|
| 123 |
+
# -------------------------------------------------------------------------
|
| 124 |
+
# Steps 1..len(ANSWERS): send all answers, including past MAX_ROUNDS
|
| 125 |
+
# -------------------------------------------------------------------------
|
| 126 |
+
soft_limit_seen = False
|
| 127 |
+
|
| 128 |
+
for i, answer in enumerate(ANSWERS):
|
| 129 |
+
print(f"Step {i + 1}: POST /api/chat-round answer={answer[:60]!r}")
|
| 130 |
+
try:
|
| 131 |
+
data = post("/api/chat-round", {"session_id": session_id, "user_message": answer})
|
| 132 |
+
except Exception as exc:
|
| 133 |
+
print(f" FAIL chat-round failed: {exc}")
|
| 134 |
+
sys.exit(1)
|
| 135 |
+
|
| 136 |
+
rnd = data.get("round", 0)
|
| 137 |
+
phase = data.get("battle_phase", "")
|
| 138 |
+
sl = data.get("soft_round_limit_reached", False)
|
| 139 |
+
bc = data.get("battle_complete", True)
|
| 140 |
+
cc = data.get("can_continue", False)
|
| 141 |
+
na = data.get("next_action", "")
|
| 142 |
+
ai = data.get("ai_message", "")
|
| 143 |
+
|
| 144 |
+
print(f" round={rnd} phase={phase} soft_limit={sl} "
|
| 145 |
+
f"battle_complete={bc} can_continue={cc}")
|
| 146 |
+
print(f" ai_message: {ai[:100]}...\n")
|
| 147 |
+
|
| 148 |
+
# Always must continue
|
| 149 |
+
check("battle_complete=False (never hard-stopped)", bc is False, f"round={rnd}")
|
| 150 |
+
check("can_continue=True (always)", cc is True, f"round={rnd}")
|
| 151 |
+
check("next_action=continue (always)", na == "continue", f"got {na!r}")
|
| 152 |
+
check("ai_message non-empty", bool(ai))
|
| 153 |
+
check("no leakage in ai_message", not _LEAKAGE.search(ai),
|
| 154 |
+
f"leakage at round {rnd}")
|
| 155 |
+
|
| 156 |
+
# Soft limit
|
| 157 |
+
if rnd >= MAX_ROUNDS:
|
| 158 |
+
check("soft_round_limit_reached=True", sl is True, f"round={rnd}")
|
| 159 |
+
if sl:
|
| 160 |
+
soft_limit_seen = True
|
| 161 |
+
else:
|
| 162 |
+
check("soft_round_limit_reached=False", sl is False, f"round={rnd}")
|
| 163 |
+
|
| 164 |
+
# Battle phase progression
|
| 165 |
+
if 1 <= rnd <= 3:
|
| 166 |
+
check(f"phase=explore at round {rnd}", phase == "explore", f"got {phase!r}")
|
| 167 |
+
elif 4 <= rnd <= 6:
|
| 168 |
+
check(f"phase=pressure at round {rnd}", phase == "pressure", f"got {phase!r}")
|
| 169 |
+
elif rnd >= 7:
|
| 170 |
+
check(f"phase=close at round {rnd}", phase == "close", f"got {phase!r}")
|
| 171 |
+
|
| 172 |
+
check("soft_round_limit_reached appeared at least once", soft_limit_seen)
|
| 173 |
+
|
| 174 |
+
# -------------------------------------------------------------------------
|
| 175 |
+
# Final: POST /api/end-battle
|
| 176 |
+
# -------------------------------------------------------------------------
|
| 177 |
+
print(f"\nFinal: POST /api/end-battle (may take up to 3 minutes)")
|
| 178 |
+
try:
|
| 179 |
+
sc = post("/api/end-battle", {"session_id": session_id}, timeout=240)
|
| 180 |
+
except Exception as exc:
|
| 181 |
+
print(f" FAIL end-battle failed: {exc}")
|
| 182 |
+
sys.exit(1)
|
| 183 |
+
|
| 184 |
+
# Top-level fields
|
| 185 |
+
check("overall present", "overall" in sc)
|
| 186 |
+
check("overall_label present", "overall_label" in sc)
|
| 187 |
+
check("scores present", "scores" in sc)
|
| 188 |
+
check("scorecard_source present", "scorecard_source" in sc)
|
| 189 |
+
check("model_ok present", "model_ok" in sc)
|
| 190 |
+
|
| 191 |
+
overall = sc.get("overall", -1)
|
| 192 |
+
check("overall 0-100", isinstance(overall, int) and 0 <= overall <= 100, f"got {overall!r}")
|
| 193 |
+
check(
|
| 194 |
+
"overall_label valid",
|
| 195 |
+
sc.get("overall_label") in _VALID_LABELS,
|
| 196 |
+
f"got {sc.get('overall_label')!r}",
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
# Dimension labels
|
| 200 |
+
scores = sc.get("scores", {})
|
| 201 |
+
check("all 6 dims present", REQUIRED_DIMS <= set(scores.keys()))
|
| 202 |
+
for dim in REQUIRED_DIMS:
|
| 203 |
+
d = scores.get(dim, {})
|
| 204 |
+
check(f"{dim}.label valid", d.get("label") in _VALID_LABELS, f"got {d.get('label')!r}")
|
| 205 |
+
dim_score = d.get("score", -1)
|
| 206 |
+
check(f"{dim}.score 0-100", isinstance(dim_score, int) and 0 <= dim_score <= 100)
|
| 207 |
+
|
| 208 |
+
# Scorecard source check (Phase 5D: hybrid architecture)
|
| 209 |
+
source = sc.get("scorecard_source", "")
|
| 210 |
+
_hybrid_sources = {"hybrid_claims_nemotron", "hybrid_claims_local"}
|
| 211 |
+
check(
|
| 212 |
+
"scorecard_source is hybrid",
|
| 213 |
+
source in _hybrid_sources,
|
| 214 |
+
f"got {source!r} — expected one of {_hybrid_sources}",
|
| 215 |
+
)
|
| 216 |
+
check(
|
| 217 |
+
"provider is local or local+nvidia",
|
| 218 |
+
sc.get("provider") in {"local+nvidia", "local"},
|
| 219 |
+
f"got {sc.get('provider')!r}",
|
| 220 |
+
)
|
| 221 |
+
if source == "hybrid_claims_local":
|
| 222 |
+
print(f" NOTE: hybrid_claims_local — Nemotron coaching unavailable, local fallback used.")
|
| 223 |
+
if sc.get("model_error"):
|
| 224 |
+
print(f" model_error: {sc.get('model_error')}")
|
| 225 |
+
else:
|
| 226 |
+
print(" NOTE: hybrid_claims_nemotron — Nemotron coaching succeeded.")
|
| 227 |
+
|
| 228 |
+
print(f"\n--- Refinement Summary ---")
|
| 229 |
+
print(f" scorecard_source : {sc.get('scorecard_source')}")
|
| 230 |
+
print(f" overall : {overall} ({sc.get('overall_label')})")
|
| 231 |
+
for dim in REQUIRED_DIMS:
|
| 232 |
+
d = scores.get(dim, {})
|
| 233 |
+
print(f" {dim:<25} {d.get('score', '?'):>3} [{d.get('label', '?')}]")
|
| 234 |
+
|
| 235 |
+
print("\nAll Phase 5B refinement checks passed.\n")
|
| 236 |
+
sys.exit(0)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
if __name__ == "__main__":
|
| 240 |
+
main()
|