Aspectgg commited on
Commit
5df55ff
·
0 Parent(s):

first commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +31 -0
  2. .gitignore +43 -0
  3. README.md +82 -0
  4. app.py +134 -0
  5. config/attack_tags.json +32 -0
  6. config/personas.json +46 -0
  7. config/pitch_rubric.json +11 -0
  8. config/sample_startups.json +14 -0
  9. core/__init__.py +1 -0
  10. core/api_handlers.py +512 -0
  11. core/attack_tags.py +50 -0
  12. core/battle_flow.py +301 -0
  13. core/claim_extractor.py +274 -0
  14. core/feedback_generator.py +41 -0
  15. core/json_utils.py +192 -0
  16. core/local_text_model.py +30 -0
  17. core/minicpm_client.py +13 -0
  18. core/model_router.py +373 -0
  19. core/nvidia_client.py +234 -0
  20. core/output_sanitizer.py +63 -0
  21. core/persona_builder.py +66 -0
  22. core/samples.py +27 -0
  23. core/scoring_engine.py +908 -0
  24. core/session_manager.py +77 -0
  25. core/transcription_client.py +13 -0
  26. core/vision_client.py +13 -0
  27. core/voice_transcriber.py +13 -0
  28. docs/BACKEND_API.md +393 -0
  29. docs/CLAUDE_PROJECT_CONTEXT.md +299 -0
  30. docs/DEMO_NOTES.md +48 -0
  31. docs/DOCUMENTATION.md +1447 -0
  32. docs/FIELD_NOTES.md +92 -0
  33. docs/MODELS_FINAL.md +434 -0
  34. docs/NEMOTRON_OMNI_AUDIO.md +294 -0
  35. docs/PHASE_WISE_PLAN.md +478 -0
  36. docs/PROMPTS.md +155 -0
  37. docs/TASK_TRACKER.md +105 -0
  38. frontend/assets/logo.svg +7 -0
  39. frontend/index.html +155 -0
  40. frontend/script.js +376 -0
  41. frontend/styles.css +453 -0
  42. packages.txt +1 -0
  43. requirements.txt +9 -0
  44. scripts/test_claim_based_scoring.py +336 -0
  45. scripts/test_nvidia_client.py +90 -0
  46. scripts/test_phase3_pitch_battle.py +123 -0
  47. scripts/test_phase3b_battle_flow.py +217 -0
  48. scripts/test_phase4_battle_stability.py +261 -0
  49. scripts/test_phase5_scorecard.py +329 -0
  50. 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("&", "&amp;")
68
+ .replaceAll("<", "&lt;")
69
+ .replaceAll(">", "&gt;");
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()