Aspectgg commited on
Commit
8fab536
·
1 Parent(s): 346d654

Prepare PitchFight AI completion

Browse files
README.md CHANGED
@@ -5,7 +5,7 @@ colorFrom: red
5
  colorTo: yellow
6
  sdk: gradio
7
  app_file: app.py
8
- pinned: false
9
  ---
10
 
11
  # PitchFight AI
 
5
  colorTo: yellow
6
  sdk: gradio
7
  app_file: app.py
8
+ pinned: true
9
  ---
10
 
11
  # PitchFight AI
app.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  from pathlib import Path
6
  from typing import Any
7
 
@@ -13,16 +14,22 @@ from gradio import Server
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()
@@ -65,19 +72,44 @@ 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")
@@ -131,4 +163,17 @@ app.mount("/frontend", StaticFiles(directory=str(FRONTEND_DIR)), name="frontend"
131
 
132
 
133
  if __name__ == "__main__":
134
- app.launch(show_error=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
+ import os
6
  from pathlib import Path
7
  from typing import Any
8
 
 
14
  from core.api_handlers import (
15
  handle_chat_round,
16
  handle_deck_critique_placeholder,
 
17
  handle_end_battle,
18
+ handle_end_deal,
19
+ handle_deal_round,
20
  handle_load_sample,
21
  handle_reset_session,
22
+ handle_retry_weakest_start,
23
+ handle_retry_weakest_submit,
24
+ handle_start_deal_phase,
25
  handle_start_session,
26
+ handle_voice_pitch,
27
+ handle_voice_turn,
28
  )
29
  from core import model_router
30
 
31
  APP_VERSION = "0.1.0"
32
+ PITCHFIGHT_PORT = int(os.getenv("PITCHFIGHT_PORT", "7860"))
33
  FRONTEND_DIR = Path(__file__).parent / "frontend"
34
 
35
  app = Server()
 
72
  return handle_end_battle(payload)
73
 
74
 
75
+ @app.post("/api/retry-weakest-question/start")
76
+ def api_retry_weakest_start(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
77
+ return handle_retry_weakest_start(payload)
78
+
79
+
80
+ @app.post("/api/retry-weakest-question/submit")
81
+ def api_retry_weakest_submit(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
82
+ return handle_retry_weakest_submit(payload)
83
+
84
+
85
  @app.post("/api/reset-session")
86
  def api_reset_session(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
87
  return handle_reset_session(payload)
88
 
89
 
90
  @app.post("/api/voice-pitch")
91
+ def api_voice_pitch(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
92
+ return handle_voice_pitch(payload)
93
+
94
+
95
+ @app.post("/api/voice-turn")
96
+ def api_voice_turn(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
97
+ return handle_voice_turn(payload)
98
+
99
+
100
+ @app.post("/api/start-deal-phase")
101
+ def api_start_deal_phase(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
102
+ return handle_start_deal_phase(payload)
103
+
104
+
105
+ @app.post("/api/deal-round")
106
+ def api_deal_round(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
107
+ return handle_deal_round(payload)
108
 
109
 
110
+ @app.post("/api/end-deal")
111
+ def api_end_deal(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
112
+ return handle_end_deal(payload)
113
 
114
 
115
  @app.post("/api/deck-critique")
 
163
 
164
 
165
  if __name__ == "__main__":
166
+ url = f"http://127.0.0.1:{PITCHFIGHT_PORT}"
167
+ print(f"Starting PitchFight AI on {url}")
168
+ try:
169
+ app.launch(show_error=True, server_port=PITCHFIGHT_PORT)
170
+ except OSError as exc:
171
+ if "empty port" in str(exc).lower() or str(PITCHFIGHT_PORT) in str(exc):
172
+ print(
173
+ f"\nERROR: Port {PITCHFIGHT_PORT} is already in use by another process.\n"
174
+ f"Stop the old server (Ctrl+C in its terminal), or free the port:\n"
175
+ f" netstat -ano | findstr \":{PITCHFIGHT_PORT}\"\n"
176
+ f" taskkill /PID <pid> /F\n"
177
+ f"Then run: python app.py\n"
178
+ )
179
+ raise SystemExit(1) from exc
config/judge_settings.json ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "default_profile": "practice",
3
+ "profiles": {
4
+ "practice": {
5
+ "label": "Practice Mode",
6
+ "description": "Student-friendly pitch practice for first/second-time founders.",
7
+ "profile_display": {
8
+ "pressure_labels": {
9
+ "explore": "Warm-up",
10
+ "pressure": "Focused",
11
+ "close": "Final Practice"
12
+ }
13
+ },
14
+ "question_style": {
15
+ "plain_language": true,
16
+ "avoid_jargon": true,
17
+ "avoid_compound_questions": true,
18
+ "one_question_only": true,
19
+ "max_sentences": 3,
20
+ "tone": "challenging_but_supportive",
21
+ "instruction": "This founder is practicing for the first or second time. Ask ONE clear, short question using plain everyday language. Do NOT use terms like: unit economics, contribution margin, defensibility, TAM, SAM, SOM, moat, CAC, LTV, load-bearing mechanism, demonstrably, quantify match accuracy, precision threshold, or any benchmark jargon. Instead ask things like: 'What number proves this is working?', 'How do you know students need this?', 'How will you get your first 100 users?', 'Why would students choose this over existing options?', 'How do you know users will come back?', 'How much will you charge, and who pays?'. Be challenging and ask for evidence — but keep the language a first-time founder can understand without a business degree."
22
+ },
23
+ "coaching_style": {
24
+ "tone": "encouraging_actionable",
25
+ "instruction": "Be encouraging but honest. Explain what the founder did right, then show exactly how to make the answer stronger with one specific number, example, or proof point.",
26
+ "example": "Your answer touched on the right idea. Here's how to make it more convincing with one specific number or example."
27
+ },
28
+ "battle_phase_tone": {
29
+ "explore": "medium",
30
+ "pressure": "medium_high_but_clear",
31
+ "close": "firm_but_supportive"
32
+ },
33
+ "scoring_calibration": {
34
+ "attempted_answer_floor": 32,
35
+ "partial_signal_floor": 38,
36
+ "concrete_signal_floor": 48,
37
+ "non_answer_max": 22,
38
+ "startup_context_max": 48,
39
+ "vague_on_topic_range": [32, 48],
40
+ "one_concrete_signal_range": [48, 65],
41
+ "strong_answer_range": [71, 85],
42
+ "excellent_answer_range": [86, 100]
43
+ }
44
+ },
45
+ "judge": {
46
+ "label": "Judge Mode",
47
+ "description": "Balanced hackathon judge simulation.",
48
+ "profile_display": {
49
+ "pressure_labels": {
50
+ "explore": "Moderate",
51
+ "pressure": "High",
52
+ "close": "Panel Ready"
53
+ }
54
+ },
55
+ "question_style": {
56
+ "plain_language": true,
57
+ "avoid_jargon": true,
58
+ "avoid_compound_questions": true,
59
+ "one_question_only": true,
60
+ "max_sentences": 3,
61
+ "tone": "realistic_hackathon_judge",
62
+ "instruction": "Act like a realistic hackathon judge. Be sharp and specific, but keep questions understandable. Ask one clear question at a time and focus on demo strength, novelty, user pain, and feasibility."
63
+ },
64
+ "coaching_style": {
65
+ "tone": "balanced_judge_feedback",
66
+ "instruction": "Be direct and fair. Highlight what would convince a hackathon judge and what still needs proof before demo time.",
67
+ "example": "This answer has a useful signal, but a judge still needs to see proof in the demo. Make the claim measurable and show it quickly."
68
+ },
69
+ "battle_phase_tone": {
70
+ "explore": "medium_high",
71
+ "pressure": "high_but_fair",
72
+ "close": "firm_judging_panel"
73
+ },
74
+ "scoring_calibration": {
75
+ "attempted_answer_floor": 30,
76
+ "partial_signal_floor": 38,
77
+ "concrete_signal_floor": 48,
78
+ "non_answer_max": 18,
79
+ "vague_on_topic_range": [30, 42],
80
+ "one_concrete_signal_range": [48, 60],
81
+ "strong_answer_range": [70, 85],
82
+ "excellent_answer_range": [86, 100]
83
+ }
84
+ },
85
+ "investor": {
86
+ "label": "Investor Mode",
87
+ "description": "Harder skeptical VC-style pressure.",
88
+ "profile_display": {
89
+ "pressure_labels": {
90
+ "explore": "High",
91
+ "pressure": "Very High",
92
+ "close": "Investor Pressure"
93
+ }
94
+ },
95
+ "question_style": {
96
+ "plain_language": false,
97
+ "avoid_jargon": false,
98
+ "avoid_compound_questions": false,
99
+ "one_question_only": true,
100
+ "max_sentences": 4,
101
+ "tone": "skeptical_investor",
102
+ "instruction": "Act like a skeptical early-stage investor. Pressure-test market size, moat, retention, revenue logic, distribution, and why now. Be tougher than Practice Mode, but still focus on one main pressure point per round."
103
+ },
104
+ "coaching_style": {
105
+ "tone": "sharp_investor_feedback",
106
+ "instruction": "Be sharper and more business-focused. Explain what would worry an investor and what proof is needed to reduce that concern.",
107
+ "example": "This answer lacks defensibility. A real investor needs to hear your moat mechanism, not just what the product does."
108
+ },
109
+ "battle_phase_tone": {
110
+ "explore": "high",
111
+ "pressure": "very_high",
112
+ "close": "investment_committee"
113
+ },
114
+ "scoring_calibration": {
115
+ "attempted_answer_floor": 25,
116
+ "partial_signal_floor": 35,
117
+ "concrete_signal_floor": 45,
118
+ "non_answer_max": 15,
119
+ "vague_on_topic_range": [25, 38],
120
+ "one_concrete_signal_range": [45, 58],
121
+ "strong_answer_range": [70, 85],
122
+ "excellent_answer_range": [86, 100]
123
+ }
124
+ }
125
+ }
126
+ }
core/api_handlers.py CHANGED
@@ -8,7 +8,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 (
@@ -18,10 +18,18 @@ from core.scoring_engine import (
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
 
@@ -92,6 +100,37 @@ def get_battle_phase(round_number: int) -> str:
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)
@@ -221,16 +260,30 @@ 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"]
@@ -244,7 +297,7 @@ def handle_start_session(payload: dict[str, Any]) -> dict[str, Any]:
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,
@@ -268,12 +321,18 @@ def handle_start_session(payload: dict[str, Any]) -> dict[str, Any]:
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,
@@ -286,6 +345,8 @@ def handle_start_session(payload: dict[str, Any]) -> dict[str, Any]:
286
  "battle_complete": False,
287
  "can_continue": True,
288
  "next_action": "continue",
 
 
289
  **({"model_error": model_error} if model_error else {}),
290
  }
291
 
@@ -320,7 +381,11 @@ def handle_chat_round(payload: dict[str, Any]) -> dict[str, Any]:
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)
@@ -384,7 +449,7 @@ def handle_chat_round(payload: dict[str, Any]) -> dict[str, Any]:
384
  messages = _build_followup_messages(
385
  startup,
386
  persona,
387
- difficulty,
388
  attack_tag,
389
  recent_history,
390
  judge_action_result,
@@ -410,12 +475,24 @@ def handle_chat_round(payload: dict[str, Any]) -> dict[str, Any]:
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,
@@ -436,6 +513,8 @@ def handle_chat_round(payload: dict[str, Any]) -> dict[str, Any]:
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
 
@@ -469,9 +548,181 @@ def handle_end_battle(payload: dict[str, Any]) -> dict[str, Any]:
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", "")
@@ -479,15 +730,19 @@ def handle_reset_session(payload: dict[str, Any]) -> dict[str, Any]:
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]:
 
8
 
9
  from dotenv import load_dotenv
10
 
11
+ from core.attack_tags import get_attack_tags, get_next_attack_tag, get_answer_checklist
12
  from core.persona_builder import build_persona_prompt
13
  from core.samples import get_sample_startup
14
  from core.scoring_engine import (
 
18
  build_session_aware_fallback_scorecard,
19
  )
20
  from core.claim_extractor import extract_concrete_signals
21
+ from core.judge_settings import normalize_difficulty, get_label, get_pressure_display_label
22
  from core import battle_flow
23
  from core import model_router
24
  from core import session_manager
25
  from core.output_sanitizer import sanitize_model_output
26
+ from core import voice_handler
27
+ from core import retry_handler
28
+ from core import session_repository
29
+ from core.deal_verdict import build_judge_verdict
30
+ from core.deal_phase import start_deal_phase
31
+ from core.deal_flow import next_deal_round
32
+ from core.deal_scoring_engine import generate_deal_scorecard
33
 
34
  load_dotenv()
35
 
 
100
  return "close"
101
 
102
 
103
+ import re as _re
104
+
105
+ _HAS_NUMBER = _re.compile(r"\d")
106
+ _HAS_USER_WORD = _re.compile(
107
+ r"\b(users?|customers?|students?|people|patients?|teachers?|clients?|founders?|hospitals?)\b",
108
+ _re.IGNORECASE,
109
+ )
110
+
111
+
112
+ def _micro_coach_tip(message: str, quality: str, attack_tag: str, difficulty_profile: str) -> str:
113
+ """One short, encouraging nudge after a round — teaches the next answer, not the score.
114
+
115
+ Local only (no API). Trains the founder toward 'minimum viable answer': one number,
116
+ one user, one real result. Kept gentle for Practice; empty when nothing useful to add.
117
+ """
118
+ text = (message or "").strip()
119
+ if not text:
120
+ return ""
121
+ if quality == "non_answer":
122
+ return "Take a real guess next time — even one specific detail beats a blank."
123
+
124
+ has_number = bool(_HAS_NUMBER.search(text))
125
+ has_user = bool(_HAS_USER_WORD.search(text))
126
+
127
+ if not has_number:
128
+ return "Good start — next time add one number (a count, a result, or a price)."
129
+ if not has_user:
130
+ return "Nice, you gave a number — next time say who it's for or who it's from."
131
+ return "Solid — to go further, tie that proof directly to the question asked."
132
+
133
+
134
  def _recent_history(session_id: str, max_turns: int = _HISTORY_WINDOW) -> list[dict]:
135
  """Return at most max_turns recent history entries for live inference."""
136
  full = session_manager.get_history(session_id)
 
260
  """Create a new pitch battle session and return the opening challenge."""
261
  startup = payload.get("startup") or {}
262
  persona = payload.get("persona", "technical_judge")
263
+ # Accept difficulty_profile (new) or difficulty (legacy) — normalize both
264
+ raw_difficulty = (
265
+ payload.get("difficulty_profile")
266
+ or payload.get("difficulty")
267
+ or "practice"
268
+ )
269
+ difficulty_profile = normalize_difficulty(raw_difficulty)
270
+ difficulty_label = get_label(difficulty_profile)
271
  input_mode = payload.get("input_mode", "text")
272
  mode = payload.get("mode", "pitch_battle")
273
  model_mode = payload.get("model_mode", "premium_nvidia")
274
 
275
  session = session_manager.create_session(
276
+ startup, persona, difficulty_profile, input_mode
277
  )
278
  session["mode"] = mode
279
  session["model_mode"] = model_mode
280
+ # Store normalized profile so scorecard and chat rounds can use it
281
+ session["difficulty_profile"] = difficulty_profile
282
+ session["difficulty_label"] = difficulty_label
283
+
284
+ voice_pitch = payload.get("voice_pitch")
285
+ if input_mode == "voice" and isinstance(voice_pitch, dict):
286
+ session_manager.set_voice_pitch(session["session_id"], voice_pitch)
287
 
288
  mock_attack_tag, mock_ai_message = OPENING_MESSAGES.get(
289
  persona, OPENING_MESSAGES["technical_judge"]
 
297
  model_error: str | None = None
298
 
299
  try:
300
+ messages = _build_opening_messages(startup, persona, difficulty_profile, mock_attack_tag)
301
  result = model_router.generate_opponent_response(
302
  messages,
303
  model_mode=model_mode,
 
321
 
322
  session_manager.append_ai_message(session["session_id"], ai_message, attack_tag)
323
 
324
+ # Phase 9.5: persist fully-populated session (opening message already in history).
325
+ session_repository.save_session(session)
326
+
327
+ _phase_start = get_battle_phase(1)
328
  return {
329
  "session_id": session["session_id"],
330
  "round": 1,
331
  "pressure_level": pressure_level(1),
332
+ "battle_phase": _phase_start,
333
+ "pressure_label": get_pressure_display_label(difficulty_profile, _phase_start),
334
  "attack_tag": attack_tag,
335
+ "answer_hint": get_answer_checklist(attack_tag),
336
  "ai_message": ai_message,
337
  "model_mode": used_model_mode,
338
  "provider": provider,
 
345
  "battle_complete": False,
346
  "can_continue": True,
347
  "next_action": "continue",
348
+ "difficulty_profile": difficulty_profile,
349
+ "difficulty_label": difficulty_label,
350
  **({"model_error": model_error} if model_error else {}),
351
  }
352
 
 
381
  session_manager.append_user_message(session_id, message)
382
 
383
  persona = session.get("persona", "technical_judge")
384
+ # Use stored normalized profile; fall back to normalizing legacy difficulty field
385
+ difficulty_profile = session.get("difficulty_profile") or normalize_difficulty(
386
+ session.get("difficulty", "practice")
387
+ )
388
+ difficulty_label = session.get("difficulty_label") or get_label(difficulty_profile)
389
  startup = session.get("startup", {})
390
  model_mode = session.get("model_mode", "premium_nvidia")
391
  next_round = session_manager.increment_round(session_id)
 
449
  messages = _build_followup_messages(
450
  startup,
451
  persona,
452
+ difficulty_profile,
453
  attack_tag,
454
  recent_history,
455
  judge_action_result,
 
475
 
476
  session_manager.append_ai_message(session_id, ai_message, attack_tag)
477
 
478
+ # Phase 9.5: persist the new user + judge history entries (last 2 appended above).
479
+ session_repository.update_round(session_id, session.get("history", [])[-2:])
480
+
481
+ input_mode = payload.get("input_mode") or session.get("input_mode", "text")
482
+ voice_turn_id = payload.get("voice_turn_id", "")
483
+ if input_mode == "voice" and voice_turn_id and message:
484
+ voice_handler.confirm_voice_turn(session_id, voice_turn_id, message)
485
+
486
+ _phase_chat = get_battle_phase(next_round)
487
  return {
488
  "session_id": session_id,
489
  "round": next_round,
490
  "pressure_level": pressure_level(next_round),
491
+ "battle_phase": _phase_chat,
492
+ "pressure_label": get_pressure_display_label(difficulty_profile, _phase_chat),
493
  "attack_tag": attack_tag,
494
+ "answer_hint": get_answer_checklist(attack_tag),
495
+ "micro_coach": _micro_coach_tip(message, quality, current_attack_tag, difficulty_profile),
496
  "ai_message": ai_message,
497
  "model_mode": used_model_mode,
498
  "provider": provider,
 
513
  "You have enough material for a scorecard. You can end the battle now or continue practicing."
514
  if soft_limit else None
515
  ),
516
+ "difficulty_profile": difficulty_profile,
517
+ "difficulty_label": difficulty_label,
518
  **({"model_error": model_error} if model_error else {}),
519
  }
520
 
 
548
  scorecard = mock_scorecard(session)
549
  scorecard["model_error"] = f"Scorecard generation error: {type(exc).__name__}"
550
 
551
+ voice_summary = voice_handler.build_voice_delivery_summary(session)
552
+ if voice_summary:
553
+ scorecard["voice_delivery"] = voice_summary
554
+
555
+ session["latest_scorecard"] = scorecard
556
+
557
+ # Phase 9.5: persist scorecard and battle summary after in-memory mutation.
558
+ session_repository.save_scorecard(session_id, scorecard)
559
+ session_repository.update_battle_summary(session_id, {
560
+ "total_rounds": session.get("round", 0),
561
+ "final_round": session.get("round", 0),
562
+ "status": "completed",
563
+ "battle_complete": True,
564
+ })
565
+
566
+ try:
567
+ judge_verdict = build_judge_verdict(session, scorecard)
568
+ session["judge_verdict"] = judge_verdict
569
+ scorecard["judge_verdict"] = judge_verdict
570
+ # Phase 9.5: persist judge verdict after it is stored on session.
571
+ session_repository.save_judge_verdict(session_id, judge_verdict)
572
+ except Exception as exc:
573
+ logger.warning("handle_end_battle: judge verdict failed: %s", exc)
574
+
575
  return scorecard
576
 
577
 
578
+ def handle_start_deal_phase(payload: dict[str, Any]) -> dict[str, Any]:
579
+ """Start integrated deal phase from pitch session."""
580
+ session_id = str(payload.get("session_id", "")).strip()
581
+ if not session_id:
582
+ return {"error": "session_id is required"}
583
+
584
+ session = session_manager.get_session(session_id)
585
+ if not session:
586
+ return {"error": "Session not found"}
587
+
588
+ try:
589
+ result = start_deal_phase(session)
590
+ # Phase 9.5: persist the opening judge deal message after deal_history is populated.
591
+ if isinstance(result, dict) and "error" not in result:
592
+ deal_history = session.get("deal_history", [])
593
+ if deal_history:
594
+ session_repository.update_deal_round(session_id, deal_history[-1])
595
+ return result
596
+ except Exception as exc:
597
+ logger.warning("handle_start_deal_phase raised: %s", exc)
598
+ return {"error": "Could not start deal phase."}
599
+
600
+
601
+ def handle_deal_round(payload: dict[str, Any]) -> dict[str, Any]:
602
+ """Process one deal negotiation round."""
603
+ session_id = str(payload.get("session_id", "")).strip()
604
+ message = str(payload.get("user_message", "")).strip()
605
+ input_mode = str(payload.get("input_mode", "text") or "text")
606
+ voice_turn_id = str(payload.get("voice_turn_id", "") or "")
607
+
608
+ if not session_id:
609
+ return {"error": "session_id is required"}
610
+
611
+ session = session_manager.get_session(session_id)
612
+ if not session:
613
+ return {"error": "Session not found"}
614
+
615
+ if input_mode == "voice" and voice_turn_id and message:
616
+ voice_handler.confirm_voice_turn(session_id, voice_turn_id, message)
617
+
618
+ try:
619
+ result = next_deal_round(session, message, input_mode=input_mode, voice_turn_id=voice_turn_id)
620
+ # Phase 9.5: persist the new founder + judge deal entries (last 2 appended above).
621
+ if isinstance(result, dict) and "error" not in result:
622
+ deal_history = session.get("deal_history", [])
623
+ new_entries = deal_history[-2:] if len(deal_history) >= 2 else deal_history
624
+ if new_entries:
625
+ session_repository.update_deal_round(session_id, new_entries)
626
+ return result
627
+ except Exception as exc:
628
+ logger.warning("handle_deal_round raised: %s", exc)
629
+ return {"error": "Could not process deal round."}
630
+
631
+
632
+ def handle_end_deal(payload: dict[str, Any]) -> dict[str, Any]:
633
+ """End deal phase and return deal + combined scorecards."""
634
+ session_id = str(payload.get("session_id", "")).strip()
635
+ if not session_id:
636
+ return {"error": "session_id is required"}
637
+
638
+ session = session_manager.get_session(session_id)
639
+ if not session:
640
+ return {"error": "Session not found"}
641
+
642
+ try:
643
+ result = generate_deal_scorecard(session)
644
+ # Phase 9.5: persist deal scorecard + combined scorecard after generation.
645
+ if isinstance(result, dict) and "error" not in result:
646
+ session_repository.save_deal_scorecard(
647
+ session_id,
648
+ result.get("deal_scorecard") or {},
649
+ result.get("combined_scorecard") or {},
650
+ )
651
+ return result
652
+ except Exception as exc:
653
+ logger.warning("handle_end_deal raised: %s", exc)
654
+ return {"error": "Could not generate deal scorecard."}
655
+
656
+
657
+ def handle_retry_weakest_start(payload: dict[str, Any]) -> dict[str, Any]:
658
+ """Start a retry drill from the latest scorecard answer_to_retry."""
659
+ session_id = str(payload.get("session_id", "")).strip()
660
+ if not session_id:
661
+ return {"error": "session_id is required"}
662
+
663
+ session = session_manager.get_session(session_id)
664
+ if not session:
665
+ return {"error": "Session not found"}
666
+
667
+ try:
668
+ result = retry_handler.start_retry_drill(session)
669
+ # Phase 9.5: persist the newly created drill after it is stored on session.
670
+ retry_id = result.get("retry_id") if isinstance(result, dict) else None
671
+ if retry_id and "error" not in result:
672
+ drill = session.get("retry_drills", {}).get(retry_id)
673
+ if drill:
674
+ session_repository.save_retry_drill(session_id, drill)
675
+ return result
676
+ except Exception as exc:
677
+ logger.warning("handle_retry_weakest_start raised: %s", exc)
678
+ return {"error": "Could not start retry drill. Try ending a battle first."}
679
+
680
+
681
+ def handle_retry_weakest_submit(payload: dict[str, Any]) -> dict[str, Any]:
682
+ """Evaluate a retry answer against the original weak answer."""
683
+ session_id = str(payload.get("session_id", "")).strip()
684
+ retry_id = str(payload.get("retry_id", "")).strip()
685
+ retry_answer = str(payload.get("retry_answer", "")).strip()
686
+ input_mode = str(payload.get("input_mode", "text") or "text")
687
+ voice_turn_id = str(payload.get("voice_turn_id", "") or "")
688
+
689
+ if not session_id:
690
+ return {"error": "session_id is required"}
691
+ if not retry_id:
692
+ return {"error": "retry_id is required"}
693
+
694
+ session = session_manager.get_session(session_id)
695
+ if not session:
696
+ return {"error": "Session not found"}
697
+
698
+ if input_mode == "voice" and voice_turn_id and retry_answer:
699
+ voice_handler.confirm_voice_turn(session_id, voice_turn_id, retry_answer)
700
+
701
+ try:
702
+ result = retry_handler.evaluate_retry_answer(
703
+ session,
704
+ retry_id,
705
+ retry_answer,
706
+ input_mode=input_mode,
707
+ voice_turn_id=voice_turn_id,
708
+ )
709
+ # Phase 9.5: persist updated drill, scorecard, and refreshed verdict after eval.
710
+ if isinstance(result, dict) and "error" not in result:
711
+ drill = session.get("retry_drills", {}).get(retry_id)
712
+ if drill:
713
+ session_repository.save_retry_drill(session_id, drill)
714
+ latest_scorecard = session.get("latest_scorecard")
715
+ if isinstance(latest_scorecard, dict):
716
+ session_repository.save_scorecard(session_id, latest_scorecard)
717
+ latest_verdict = session.get("judge_verdict")
718
+ if isinstance(latest_verdict, dict):
719
+ session_repository.save_judge_verdict(session_id, latest_verdict)
720
+ return result
721
+ except Exception as exc:
722
+ logger.warning("handle_retry_weakest_submit raised: %s", exc)
723
+ return {"error": "Could not evaluate retry answer. Please try again."}
724
+
725
+
726
  def handle_reset_session(payload: dict[str, Any]) -> dict[str, Any]:
727
  """Clear a battle session."""
728
  session_id = payload.get("session_id", "")
 
730
  return {"status": "reset"}
731
 
732
 
733
+ def handle_voice_pitch(payload: dict[str, Any]) -> dict[str, Any]:
734
+ """Process opening spoken pitch audio via Nemotron Omni."""
735
+ audio = payload.get("audio") or payload.get("audio_base64") or ""
736
+ audio_format = payload.get("audio_format", "webm")
737
+ return voice_handler.process_voice_pitch(str(audio), str(audio_format))
738
+
739
+
740
+ def handle_voice_turn(payload: dict[str, Any]) -> dict[str, Any]:
741
+ """Process one spoken battle answer — returns transcript for confirmation."""
742
+ session_id = payload.get("session_id", "")
743
+ audio = payload.get("audio") or payload.get("audio_base64") or ""
744
+ audio_format = payload.get("audio_format", "webm")
745
+ return voice_handler.process_voice_turn(session_id, str(audio), str(audio_format))
746
 
747
 
748
  def handle_deal_session_placeholder(_payload: dict[str, Any] | None = None) -> dict[str, str]:
core/attack_tags.py CHANGED
@@ -36,6 +36,46 @@ ATTACK_TAGS: dict[str, list[str]] = {
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"]))
 
36
  }
37
 
38
 
39
+ # "Minimum viable answer" recipes — what a student should try to include for each
40
+ # question type. A recipe, not a script: short, plain, one or two concrete things.
41
+ ANSWER_CHECKLISTS: dict[str, str] = {
42
+ # skeptical_vc
43
+ "Market Size": "Try to include: who exactly + roughly how many of them.",
44
+ "Moat": "Name one thing only you do — and why it's hard to copy.",
45
+ "Retention": "Give one number: how many came back, or how often.",
46
+ "Revenue Logic": "Say who pays + one price or amount.",
47
+ "First 100 Users": "Name where your first users came from.",
48
+ "Why Now": "One reason this works today and not 2 years ago.",
49
+ "Competition": "Name one competitor + one thing you do differently.",
50
+ "Defensibility": "One thing that gets stronger as you grow.",
51
+ # technical_judge
52
+ "AI Justification": "Say what breaks if you remove the AI.",
53
+ "Architecture": "Walk through the main steps, in order.",
54
+ "Scalability": "One number: users or load you can handle.",
55
+ "Latency": "Roughly how fast does it respond?",
56
+ "Data Quality": "Where your data comes from + how much.",
57
+ "Failure Mode": "What happens when the model is wrong.",
58
+ "Simpler Alternative": "Why a simpler tool wouldn't be enough.",
59
+ "Technical Feasibility": "One thing you've already built and tested.",
60
+ # hackathon_judge
61
+ "Novelty": "Name one thing that's actually new here.",
62
+ "Demo Clarity": "Walk through your demo in 3 steps.",
63
+ "MVP Strength": "What works right now (not someday).",
64
+ "User Pain": "Who hurts + one proof they care.",
65
+ "AI Load-Bearing": "Say what the AI does that nothing else could.",
66
+ "Backyard Fit": "Why this fits a small/scrappy build.",
67
+ "Practical Impact": "One real result or test you ran.",
68
+ "Judging Memorability": "The one line you want remembered.",
69
+ }
70
+
71
+ _DEFAULT_CHECKLIST = "Try to include: one number, one user, or one real result."
72
+
73
+
74
+ def get_answer_checklist(attack_tag: str) -> str:
75
+ """Return a one-line 'minimum viable answer' recipe for a question type."""
76
+ return ANSWER_CHECKLISTS.get(attack_tag, _DEFAULT_CHECKLIST)
77
+
78
+
79
  def get_attack_tags(persona: str) -> list[str]:
80
  """Return attack tags for a persona."""
81
  return list(ATTACK_TAGS.get(persona, ATTACK_TAGS["technical_judge"]))
core/claim_extractor.py CHANGED
@@ -156,6 +156,120 @@ def _is_non_answer(text: str) -> bool:
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
 
@@ -209,33 +323,43 @@ def extract_concrete_signals(session: dict) -> dict[str, Any]:
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)
 
156
  # Main extractor
157
  # ---------------------------------------------------------------------------
158
 
159
+ def _accumulate_signals_from_text(
160
+ text: str,
161
+ *,
162
+ all_numbers: list[str],
163
+ all_pct: list[str],
164
+ all_pricing: list[str],
165
+ all_user_counts: list[str],
166
+ all_validation: list[str],
167
+ all_colleges: list[str],
168
+ all_competitors: list[str],
169
+ all_tech: list[str],
170
+ all_revenue: list[str],
171
+ all_retention: list[str],
172
+ all_gtm: list[str],
173
+ all_vague: list[str],
174
+ ) -> None:
175
+ """Run regex extractors on a single text block into accumulator lists."""
176
+ if not text or not str(text).strip():
177
+ return
178
+ all_numbers.extend(_match_all(_NUMBER_METRIC, text))
179
+ all_pct.extend(_match_all(_PERCENTAGE, text))
180
+ all_pricing.extend(_match_all(_CURRENCY, text))
181
+ all_user_counts.extend(_match_all(_USER_COUNT, text))
182
+ all_validation.extend(_match_all(_VALIDATION, text))
183
+ all_colleges.extend(_match_all(_COLLEGE_CAMPUS, text))
184
+ all_competitors.extend(_match_all(_COMPETITORS, text))
185
+ all_tech.extend(_match_all(_TECH_MECHANISM, text))
186
+ all_revenue.extend(_match_all(_REVENUE, text))
187
+ all_retention.extend(_match_all(_RETENTION, text))
188
+ all_gtm.extend(_match_all(_GTM, text))
189
+ all_vague.extend(_match_all(_VAGUE_PHRASES, text))
190
+
191
+
192
+ def extract_startup_context_signals(session: dict) -> dict[str, Any]:
193
+ """Extract evidence signals from startup form + voice pitch transcript only.
194
+
195
+ Used when the founder gave no battle answers but described the idea upfront.
196
+ Does not include battle Q&A history.
197
+ """
198
+ startup = session.get("startup", {}) or {}
199
+ texts: list[str] = []
200
+ for key in (
201
+ "name", "problem", "target_users", "solution",
202
+ "why_ai", "competitors", "traction", "ask",
203
+ ):
204
+ val = str(startup.get(key, "")).strip()
205
+ if val:
206
+ texts.append(val)
207
+
208
+ voice_pitch = session.get("voice_pitch") or {}
209
+ if isinstance(voice_pitch, dict):
210
+ transcript = str(voice_pitch.get("transcript", "")).strip()
211
+ if transcript:
212
+ texts.append(transcript)
213
+
214
+ all_numbers: list[str] = []
215
+ all_pct: list[str] = []
216
+ all_pricing: list[str] = []
217
+ all_user_counts: list[str] = []
218
+ all_validation: list[str] = []
219
+ all_colleges: list[str] = []
220
+ all_competitors: list[str] = []
221
+ all_tech: list[str] = []
222
+ all_revenue: list[str] = []
223
+ all_retention: list[str] = []
224
+ all_gtm: list[str] = []
225
+ all_vague: list[str] = []
226
+
227
+ for block in texts:
228
+ _accumulate_signals_from_text(
229
+ block,
230
+ all_numbers=all_numbers,
231
+ all_pct=all_pct,
232
+ all_pricing=all_pricing,
233
+ all_user_counts=all_user_counts,
234
+ all_validation=all_validation,
235
+ all_colleges=all_colleges,
236
+ all_competitors=all_competitors,
237
+ all_tech=all_tech,
238
+ all_revenue=all_revenue,
239
+ all_retention=all_retention,
240
+ all_gtm=all_gtm,
241
+ all_vague=all_vague,
242
+ )
243
+
244
+ total_signals = (
245
+ len(_dedup(all_numbers)) + len(_dedup(all_pct)) +
246
+ len(_dedup(all_pricing)) + len(_dedup(all_user_counts)) +
247
+ len(_dedup(all_validation)) + len(_dedup(all_colleges)) +
248
+ len(_dedup(all_competitors)) + len(_dedup(all_tech)) +
249
+ len(_dedup(all_revenue))
250
+ )
251
+
252
+ return {
253
+ "numbers": _dedup(all_numbers),
254
+ "percentages": _dedup(all_pct),
255
+ "pricing": _dedup(all_pricing),
256
+ "user_counts": _dedup(all_user_counts),
257
+ "validation": _dedup(all_validation),
258
+ "college_mentions": _dedup(all_colleges),
259
+ "competitors": _dedup(all_competitors),
260
+ "technical_mechanisms": _dedup(all_tech),
261
+ "revenue_signals": _dedup(all_revenue),
262
+ "retention_signals": _dedup(all_retention),
263
+ "gtm_signals": _dedup(all_gtm),
264
+ "non_answers": [],
265
+ "vague_claims": _dedup(all_vague),
266
+ "best_user_quotes": [t[:200] for t in texts if len(t.split()) >= 6][:3],
267
+ "all_user_answers": [],
268
+ "signal_count": total_signals,
269
+ "source": "startup_context",
270
+ }
271
+
272
+
273
  def extract_concrete_signals(session: dict) -> dict[str, Any]:
274
  """Extract evidence signals from all user turns in a session.
275
 
 
323
  answer_scores.append((0, ans))
324
  continue
325
 
326
+ nums: list[str] = []
327
+ pcts: list[str] = []
328
+ prices: list[str] = []
329
+ ucounts: list[str] = []
330
+ val: list[str] = []
331
+ cols: list[str] = []
332
+ comps: list[str] = []
333
+ techs: list[str] = []
334
+ revs: list[str] = []
335
+ rets: list[str] = []
336
+ gtms: list[str] = []
337
+ vagues: list[str] = []
338
+ _accumulate_signals_from_text(
339
+ ans,
340
+ all_numbers=all_numbers,
341
+ all_pct=all_pct,
342
+ all_pricing=all_pricing,
343
+ all_user_counts=all_user_counts,
344
+ all_validation=all_validation,
345
+ all_colleges=all_colleges,
346
+ all_competitors=all_competitors,
347
+ all_tech=all_tech,
348
+ all_revenue=all_revenue,
349
+ all_retention=all_retention,
350
+ all_gtm=all_gtm,
351
+ all_vague=all_vague,
352
+ )
353
+ nums = _match_all(_NUMBER_METRIC, ans)
354
+ pcts = _match_all(_PERCENTAGE, ans)
355
+ prices = _match_all(_CURRENCY, ans)
356
  ucounts = _match_all(_USER_COUNT, ans)
357
+ val = _match_all(_VALIDATION, ans)
358
+ cols = _match_all(_COLLEGE_CAMPUS, ans)
359
+ comps = _match_all(_COMPETITORS, ans)
360
+ techs = _match_all(_TECH_MECHANISM, ans)
361
+ revs = _match_all(_REVENUE, ans)
362
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  density = (
364
  len(nums) + len(pcts) + len(prices) + len(ucounts) +
365
  len(val) + len(cols) + len(comps) + len(techs) + len(revs)
core/db.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MongoDB connection utilities for PitchFight AI.
3
+
4
+ Phase 9.5 rule:
5
+ - MongoDB is optional background persistence only.
6
+ - In-memory session_manager remains the live source of truth.
7
+ - If MongoDB is disabled, missing, or unavailable, the app must continue normally.
8
+ - Never print MongoDB URI, password, API keys, or secrets.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from typing import Optional
15
+
16
+ from dotenv import load_dotenv
17
+ from pymongo import MongoClient
18
+ from pymongo.collection import Collection
19
+ from pymongo.database import Database
20
+ from pymongo.errors import PyMongoError, ServerSelectionTimeoutError
21
+
22
+
23
+ load_dotenv()
24
+
25
+ _client: Optional[MongoClient] = None
26
+ _db: Optional[Database] = None
27
+ _connected: bool = False
28
+
29
+ _warned_disabled: bool = False
30
+ _warned_missing_uri: bool = False
31
+ _warned_connection_failed: bool = False
32
+
33
+
34
+ def is_mongodb_enabled() -> bool:
35
+ """
36
+ Return True only when MongoDB persistence is explicitly enabled.
37
+
38
+ Expected env:
39
+ MONGODB_ENABLED=true
40
+ """
41
+ return os.getenv("MONGODB_ENABLED", "false").strip().lower() == "true"
42
+
43
+
44
+ def get_db() -> Optional[Database]:
45
+ """
46
+ Return the MongoDB database instance if enabled and reachable.
47
+
48
+ Returns None when:
49
+ - MONGODB_ENABLED is not true
50
+ - MONGODB_URI is missing
51
+ - MongoDB connection/ping fails
52
+
53
+ This function must never raise DB errors into the main app.
54
+ """
55
+ global _client, _db, _connected
56
+ global _warned_disabled, _warned_missing_uri, _warned_connection_failed
57
+
58
+ if not is_mongodb_enabled():
59
+ if not _warned_disabled:
60
+ print("[MongoDB] Disabled. Persistence skipped.")
61
+ _warned_disabled = True
62
+ return None
63
+
64
+ if _db is not None and _connected:
65
+ return _db
66
+
67
+ uri = os.getenv("MONGODB_URI", "").strip()
68
+ db_name = os.getenv("MONGODB_DB_NAME", "pitchfight_db").strip() or "pitchfight_db"
69
+
70
+ if not uri:
71
+ if not _warned_missing_uri:
72
+ print("[MongoDB] MONGODB_URI missing. Persistence skipped.")
73
+ _warned_missing_uri = True
74
+ return None
75
+
76
+ try:
77
+ _client = MongoClient(uri, serverSelectionTimeoutMS=5000)
78
+ _client.admin.command("ping")
79
+
80
+ _db = _client[db_name]
81
+ _connected = True
82
+
83
+ print(f"[MongoDB] Connected to database: {db_name}")
84
+ return _db
85
+
86
+ except (ServerSelectionTimeoutError, PyMongoError, Exception) as exc:
87
+ _connected = False
88
+ _db = None
89
+
90
+ if not _warned_connection_failed:
91
+ print(
92
+ "[MongoDB] Connection unavailable. "
93
+ f"App will continue without persistence. Reason: {type(exc).__name__}"
94
+ )
95
+ _warned_connection_failed = True
96
+
97
+ return None
98
+
99
+
100
+ def get_sessions_collection() -> Optional[Collection]:
101
+ """
102
+ Return the main sessions collection.
103
+
104
+ Target:
105
+ pitchfight_db.sessions
106
+ """
107
+ db = get_db()
108
+ if db is None:
109
+ return None
110
+
111
+ return db["sessions"]
112
+
113
+
114
+ def is_connected() -> bool:
115
+ """
116
+ Return True if MongoDB is enabled and currently reachable.
117
+ """
118
+ db = get_db()
119
+ return db is not None and _connected
core/deal_claim_extractor.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deal negotiation signal extractor — local regex/keyword logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ _ANCHOR = re.compile(
9
+ r"(?:\d+\.?\d*\s*%)"
10
+ r"|(?:₹|rs\.?|\$|€)\s*[\d,.]+(?:\s*(?:lakhs?|crores?|cr|k|million|mn))?"
11
+ r"|(?:\d+\s*(?:weeks?|months?|years?))"
12
+ r"|(?:\d+\.?\d*\s*(?:equity|stake|ownership))"
13
+ r"|(?:discount\s*(?:of\s*)?\d+\.?\d*\s*%)"
14
+ r"|(?:pilot\s*(?:for\s*)?\d+\s*(?:weeks?|months?))",
15
+ re.IGNORECASE,
16
+ )
17
+
18
+ _EVIDENCE = re.compile(
19
+ r"\b(?:users?|patients?|hospitals?|pilots?|mou|loi|contract|deployed|revenue|"
20
+ r"retention|roi|case study|reference|paying|customers?|clients?|traction)\b",
21
+ re.IGNORECASE,
22
+ )
23
+
24
+ _CONCESSION = re.compile(
25
+ r"\b(?:we can accept|willing to|flexible on|we can reduce|"
26
+ r"open to|meet you halfway|compromise|we can move|we could give)\b",
27
+ re.IGNORECASE,
28
+ )
29
+
30
+ # Weak concession = giving ground with no condition extracted. Bare "ok"/"sure" handled
31
+ # separately so a harmless acknowledgement is NOT treated as a weak concession.
32
+ _WEAK_CONCESSION = re.compile(
33
+ r"\b(?:okay fine|whatever works|if you insist|i guess|that's okay too|"
34
+ r"no problem,? we can do that|sure,? take it|you decide|accept whatever|"
35
+ r"whatever (?:terms|you want)|fine,? we can accept)\b",
36
+ re.IGNORECASE,
37
+ )
38
+
39
+ # Alternatives / leverage — credit natural phrasing, not just "BATNA".
40
+ _ALTERNATIVES = re.compile(
41
+ r"\b(?:other investors?|another offer|other clients?|other (?:colleges?|campuses?|"
42
+ r"mentors?|partners?|customers?|buyers?)|also talking to|also speaking|in talks with|"
43
+ r"parallel (?:conversations?|talks)|multiple options|alternatives?|pipeline|"
44
+ r"not dependent|don'?t depend|batna|backup(?: option| plan)?|another route|"
45
+ r"we can still|we are also|we'?re also|we already have|we have other)\b",
46
+ re.IGNORECASE,
47
+ )
48
+
49
+ _VALUE = re.compile(
50
+ r"\b(?:roi|return on|value|fair terms|risk.?reward|worth it|payback|"
51
+ r"cost.?benefit|margin|unit economics|saves?|reduces? cost|cost savings?|"
52
+ r"efficien\w+|growth|upside|long.?term value)\b",
53
+ re.IGNORECASE,
54
+ )
55
+
56
+ _CLOSING = re.compile(
57
+ r"\b(?:term sheet|next step|follow.?up|finalize|schedule|move forward|"
58
+ r"pilot agreement|sign|timeline|commit|let'?s proceed|shake on|proceed|"
59
+ r"confirm(?: today)?|send (?:over )?(?:the )?(?:term sheet|paperwork|contract)|"
60
+ r"lock (?:in|the metric)|close (?:this|the deal)|wrap (?:this )?up)\b",
61
+ re.IGNORECASE,
62
+ )
63
+
64
+ _NUMBERS = re.compile(
65
+ r"(?:₹|rs\.?|\$)\s*[\d,.]+(?:\s*(?:lakhs?|crores?|cr|k))?"
66
+ r"|\b\d+\.?\d*\s*%"
67
+ r"|\b\d[\d,]+\s*(?:users?|patients?|hospitals?|customers?)",
68
+ re.IGNORECASE,
69
+ )
70
+
71
+ _COUNTEROFFER = re.compile(
72
+ r"\b(?:counter|instead(?: of)?|how about|we propose|we'?d accept|offer of|"
73
+ r"would take|we'?d take|at \d+\.?\d*\s*%|for \d+\.?\d*\s*%)\b",
74
+ re.IGNORECASE,
75
+ )
76
+
77
+ _TRADEOFF = re.compile(
78
+ r"\b(?:in exchange|trade.?off|if you|in return|conditional on|provided that|"
79
+ r"only if|i can agree if|we can (?:move|agree) if|milestone|vesting|cliff|"
80
+ r"in turn|as long as|on the condition)\b",
81
+ re.IGNORECASE,
82
+ )
83
+
84
+ # Bare one-word acknowledgements — must NOT count as substantive moves or weak concessions.
85
+ _ONE_WORD_ACK = frozenset({
86
+ "sure", "ok", "okay", "yes", "fine", "yeah", "yep", "agreed", "cool",
87
+ "alright", "right", "sounds good", "got it", "understood",
88
+ })
89
+
90
+ # Deal terms whose presence makes even a short message "substantive".
91
+ _DEAL_TERM = re.compile(
92
+ r"(?:₹|rs\.?|\$|€|\d+\s*%|\bequity\b|\bstake\b|\bpilot\b|\bmilestone\b|"
93
+ r"\bvaluation\b|\btranche\b|\bdiscount\b|\bmonths?\b|\bweeks?\b|\bsign\b|"
94
+ r"\bterm sheet\b|\bcounter\b)",
95
+ re.IGNORECASE,
96
+ )
97
+
98
+
99
+ def is_one_word_ack(message: str) -> bool:
100
+ """True if the message is a bare acknowledgement with no deal term (e.g. 'sure', 'ok')."""
101
+ t = (message or "").strip().lower().rstrip(".!?")
102
+ t = re.sub(r"[^a-z ]", "", t).strip()
103
+ if not t:
104
+ return True
105
+ if _DEAL_TERM.search(message or ""):
106
+ return False
107
+ return t in _ONE_WORD_ACK
108
+
109
+
110
+ def is_substantive_move(message: str) -> bool:
111
+ """True if a founder message is a real negotiation move worth judging.
112
+
113
+ Substantive = longer than 20 characters OR contains a concrete deal term.
114
+ Bare one-word acknowledgements ('sure', 'ok', 'yes', 'fine') are NOT substantive.
115
+ """
116
+ t = (message or "").strip()
117
+ if not t or is_one_word_ack(t):
118
+ return False
119
+ if len(t) > 20:
120
+ return True
121
+ if _DEAL_TERM.search(t):
122
+ return True
123
+ return len(t.split()) >= 4
124
+
125
+
126
+ def _dedup(items: list[str]) -> list[str]:
127
+ seen: set[str] = set()
128
+ out: list[str] = []
129
+ for item in items:
130
+ k = item.strip().lower()
131
+ if k and k not in seen:
132
+ seen.add(k)
133
+ out.append(item.strip())
134
+ return out
135
+
136
+
137
+ def _match_all(pattern: re.Pattern[str], text: str) -> list[str]:
138
+ return _dedup([m.group(0).strip() for m in pattern.finditer(text or "")])
139
+
140
+
141
+ def extract_deal_signals(deal_history: list[dict], deal_context: dict | None = None) -> dict[str, Any]:
142
+ """Extract negotiation signals from deal history and context."""
143
+ texts: list[str] = []
144
+ user_texts: list[str] = []
145
+ for entry in deal_history or []:
146
+ msg = str(entry.get("message", "")).strip()
147
+ if msg:
148
+ texts.append(msg)
149
+ if entry.get("role") == "user":
150
+ user_texts.append(msg)
151
+
152
+ ctx = deal_context or {}
153
+ for key in ("ask", "opening_offer", "founder_position", "judge_position"):
154
+ val = str(ctx.get(key, "")).strip()
155
+ if val:
156
+ texts.append(val)
157
+
158
+ combined = " ".join(texts)
159
+ user_combined = " ".join(user_texts)
160
+
161
+ return {
162
+ "anchor_points": _match_all(_ANCHOR, user_combined),
163
+ "evidence_signals": _match_all(_EVIDENCE, user_combined),
164
+ "concession_signals": _match_all(_CONCESSION, user_combined),
165
+ "weak_concession_signals": _match_all(_WEAK_CONCESSION, user_combined),
166
+ "alternative_signals": _match_all(_ALTERNATIVES, user_combined),
167
+ "value_signals": _match_all(_VALUE, user_combined),
168
+ "closing_signals": _match_all(_CLOSING, user_combined),
169
+ "specific_numbers": _match_all(_NUMBERS, user_combined),
170
+ "counteroffers": _match_all(_COUNTEROFFER, user_combined),
171
+ "tradeoffs": _match_all(_TRADEOFF, user_combined),
172
+ "user_turns": len(user_texts),
173
+ "signal_count": (
174
+ len(_match_all(_ANCHOR, user_combined))
175
+ + len(_match_all(_EVIDENCE, user_combined))
176
+ + len(_match_all(_NUMBERS, user_combined))
177
+ ),
178
+ }
core/deal_flow.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deal negotiation round flow (Phase 9C)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from core.deal_claim_extractor import extract_deal_signals
11
+ from core.deal_persona_builder import build_deal_system_prompt, build_deal_round_prompt
12
+ from core.deal_phase import NEGOTIATION_TAGS
13
+ from core import model_router
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Adaptive deal length — measured in FOUNDER replies, not raw round counter.
18
+ MIN_FOUNDER_REPLIES = 3 # below this we never recommend ending
19
+ SOFT_FOUNDER_REPLIES = 4 # ideal length; soft-recommend ending around here
20
+ HARD_MAX_FOUNDER_REPLIES = 6 # never force more than this
21
+
22
+ # Kept for backward compatibility with existing callers/tests.
23
+ MAX_DEAL_ROUNDS = 8
24
+ SOFT_DEAL_LIMIT = 5
25
+
26
+ _ALL_DEAL_DIMS = (
27
+ "anchoring", "evidence", "concession_control",
28
+ "alternatives", "value_articulation", "closing",
29
+ )
30
+
31
+
32
+ def _count_founder_replies(session: dict) -> int:
33
+ return sum(1 for h in session.get("deal_history", []) if h.get("role") == "user")
34
+
35
+
36
+ def evaluate_deal_readiness(session: dict) -> dict[str, Any]:
37
+ """Decide whether enough negotiation signal exists to score the deal.
38
+
39
+ Returns a readiness dict (see schema in PART A). Driven by how many dimensions
40
+ the founder has actually touched plus the number of founder replies, so the deal
41
+ can end naturally after 3–4 strong replies instead of grinding fixed rounds.
42
+ """
43
+ history = session.get("deal_history") or []
44
+ deal_context = session.get("deal_context") or {}
45
+ signals = extract_deal_signals(history, deal_context)
46
+ replies = _count_founder_replies(session)
47
+
48
+ covered: list[str] = []
49
+ if signals.get("anchor_points") or signals.get("counteroffers"):
50
+ covered.append("anchoring")
51
+ if signals.get("evidence_signals") or signals.get("specific_numbers"):
52
+ covered.append("evidence")
53
+ has_concession = bool(
54
+ signals.get("concession_signals")
55
+ or signals.get("tradeoffs")
56
+ or signals.get("weak_concession_signals")
57
+ )
58
+ if has_concession:
59
+ covered.append("concession_control")
60
+ if signals.get("alternative_signals"):
61
+ covered.append("alternatives")
62
+ if signals.get("value_signals"):
63
+ covered.append("value_articulation")
64
+ if signals.get("closing_signals"):
65
+ covered.append("closing")
66
+
67
+ missing = [d for d in _ALL_DEAL_DIMS if d not in covered]
68
+
69
+ # Core negotiation evidence: anchor/counter + proof + a concession/tradeoff + value.
70
+ core_ready = (
71
+ ("anchoring" in covered)
72
+ and ("evidence" in covered)
73
+ and has_concession
74
+ and ("value_articulation" in covered)
75
+ )
76
+ enough = (replies >= MIN_FOUNDER_REPLIES and core_ready) or replies >= HARD_MAX_FOUNDER_REPLIES
77
+
78
+ if len(covered) >= 5 and replies >= SOFT_FOUNDER_REPLIES:
79
+ confidence = "high"
80
+ elif enough:
81
+ confidence = "medium"
82
+ else:
83
+ confidence = "low"
84
+
85
+ if replies >= HARD_MAX_FOUNDER_REPLIES:
86
+ action = "force_end"
87
+ reason = "You have negotiated enough rounds — time to lock in your deal scorecard."
88
+ elif enough and replies >= SOFT_FOUNDER_REPLIES:
89
+ action = "recommend_end"
90
+ reason = "You have enough negotiation material for a scorecard."
91
+ elif enough:
92
+ action = "recommend_end"
93
+ reason = "You have covered the key negotiation points. End now or push one more round."
94
+ else:
95
+ action = "continue"
96
+ reason = "Keep negotiating — anchor your terms, cite proof, and propose a tradeoff."
97
+
98
+ return {
99
+ "enough_to_score": bool(enough),
100
+ "recommended_action": action,
101
+ "reason": reason,
102
+ "covered_dimensions": covered,
103
+ "missing_dimensions": missing,
104
+ "rounds_completed": replies,
105
+ "confidence": confidence,
106
+ }
107
+
108
+ _NON_ANSWER = re.compile(
109
+ r"^(ok|yeah|yes|no|idk|i don'?t know|not sure|maybe|n/?a|pass)\.?$",
110
+ re.IGNORECASE,
111
+ )
112
+
113
+ _VALID_ACTIONS = frozenset({
114
+ "acknowledge_and_counter",
115
+ "press_harder",
116
+ "escalate_stakes",
117
+ "partial_concession",
118
+ "move_to_close",
119
+ })
120
+
121
+
122
+ def classify_deal_answer_quality(
123
+ user_message: str,
124
+ deal_context: dict,
125
+ current_tag: str,
126
+ ) -> str:
127
+ """Classify founder deal answer: strong | partial | weak | non_answer."""
128
+ text = (user_message or "").strip()
129
+ if not text or _NON_ANSWER.match(text) or len(text.split()) < 3:
130
+ return "non_answer"
131
+
132
+ signals = extract_deal_signals(
133
+ [{"role": "user", "message": text}],
134
+ deal_context,
135
+ )
136
+
137
+ if signals.get("weak_concession_signals") and not signals.get("counteroffers"):
138
+ return "weak"
139
+
140
+ has_proof = bool(
141
+ signals.get("specific_numbers")
142
+ or signals.get("evidence_signals")
143
+ or signals.get("counteroffers")
144
+ )
145
+ has_anchor = bool(signals.get("anchor_points") or signals.get("tradeoffs"))
146
+
147
+ if current_tag == "Closing Move" and signals.get("closing_signals"):
148
+ return "strong"
149
+ if has_proof and has_anchor and len(text.split()) >= 12:
150
+ return "strong"
151
+ if has_proof or has_anchor:
152
+ return "partial"
153
+ if signals.get("concession_signals") and not has_proof:
154
+ return "weak"
155
+ if len(text.split()) >= 8:
156
+ return "partial"
157
+ return "weak"
158
+
159
+
160
+ def choose_next_negotiation_tag(session: dict) -> str:
161
+ """Pick next tag based on round and history."""
162
+ history = session.get("deal_history") or []
163
+ used = [h.get("negotiation_tag") for h in history if h.get("negotiation_tag")]
164
+ deal_round = session.get("deal_round", 1)
165
+
166
+ if deal_round >= 6:
167
+ return "Closing Move"
168
+ if deal_round >= 4 and "Evidence Quality" not in used:
169
+ return "Evidence Quality"
170
+ if deal_round >= 3 and "Concession Control" not in used:
171
+ return "Concession Control"
172
+
173
+ for tag in NEGOTIATION_TAGS:
174
+ if tag not in used:
175
+ return tag
176
+ return NEGOTIATION_TAGS[(deal_round - 1) % len(NEGOTIATION_TAGS)]
177
+
178
+
179
+ def choose_deal_action(answer_quality: str, tag: str, session: dict) -> str:
180
+ """Map answer quality + tag to judge action."""
181
+ if answer_quality == "non_answer":
182
+ return "press_harder"
183
+ if answer_quality == "weak":
184
+ return "escalate_stakes" if tag in ("Anchoring", "Concession Control") else "press_harder"
185
+ if answer_quality == "strong" and tag == "Closing Move":
186
+ return "partial_concession"
187
+ if answer_quality == "strong":
188
+ return "acknowledge_and_counter"
189
+ if tag == "Closing Move":
190
+ return "move_to_close"
191
+ if tag == "Concession Control":
192
+ return "press_harder"
193
+ return "acknowledge_and_counter"
194
+
195
+
196
+ def should_soft_limit_deal(session: dict) -> bool:
197
+ return _count_founder_replies(session) >= SOFT_FOUNDER_REPLIES
198
+
199
+
200
+ def _deal_state(signals: dict, answer_quality: str) -> dict[str, str]:
201
+ weak_con = bool(signals.get("weak_concession_signals"))
202
+ if answer_quality == "strong":
203
+ founder = "strong"
204
+ elif answer_quality in ("partial", "weak") and not weak_con:
205
+ founder = "mixed"
206
+ else:
207
+ founder = "weak"
208
+
209
+ if answer_quality == "strong":
210
+ momentum = "improving"
211
+ elif answer_quality == "non_answer" or weak_con:
212
+ momentum = "declining"
213
+ else:
214
+ momentum = "neutral"
215
+
216
+ concession = "none"
217
+ if signals.get("concession_signals") and not weak_con:
218
+ concession = "small"
219
+ if weak_con:
220
+ concession = "medium"
221
+
222
+ return {
223
+ "judge_concession_level": concession,
224
+ "founder_position_strength": founder,
225
+ "deal_momentum": momentum,
226
+ }
227
+
228
+
229
+ def _generate_deal_ai_message(
230
+ session: dict,
231
+ user_message: str,
232
+ negotiation_tag: str,
233
+ answer_quality: str,
234
+ action: str,
235
+ ) -> str:
236
+ deal_context = session.get("deal_context") or {}
237
+ system = build_deal_system_prompt(session, deal_context, negotiation_tag)
238
+ user_prompt = build_deal_round_prompt(
239
+ session, user_message, negotiation_tag, answer_quality, action
240
+ )
241
+
242
+ messages = [{"role": "system", "content": system}]
243
+ # Token control: only the last 6 deal turns go to the model (full history kept for scoring).
244
+ for entry in session.get("deal_history", [])[-6:]:
245
+ role = "assistant" if entry.get("role") == "judge" else "user"
246
+ messages.append({"role": role, "content": entry.get("message", "")})
247
+ messages.append({"role": "user", "content": user_prompt})
248
+
249
+ model_mode = session.get("model_mode", "premium_nvidia")
250
+ result = model_router.generate_deal_round_response(messages, model_mode=model_mode)
251
+ if result.get("ok") and result.get("content"):
252
+ return str(result["content"]).strip()[:600]
253
+
254
+ fallbacks = {
255
+ "press_harder": "That answer does not give me enough proof to move on terms. Be specific.",
256
+ "escalate_stakes": "I still see too much risk here. What number or commitment changes that?",
257
+ "partial_concession": "I can move slightly, but I need a concrete tradeoff from you.",
258
+ "move_to_close": "If we agree in principle, what is the exact next step and timeline?",
259
+ "acknowledge_and_counter": "I hear you, but my terms still need to reflect the risk I see.",
260
+ }
261
+ logger.warning("deal_flow: Nemotron deal round failed — %s", result.get("error"))
262
+ return fallbacks.get(action, fallbacks["acknowledge_and_counter"])
263
+
264
+
265
+ def next_deal_round(
266
+ session: dict,
267
+ user_message: str,
268
+ input_mode: str = "text",
269
+ voice_turn_id: str = "",
270
+ ) -> dict[str, Any]:
271
+ """Process one deal negotiation round."""
272
+ if not session.get("deal_phase_active"):
273
+ return {"error": "Deal phase is not active. Start deal phase from the judge verdict."}
274
+
275
+ message = (user_message or "").strip()
276
+ if not message:
277
+ return {"error": "Deal counter cannot be empty."}
278
+
279
+ if _count_founder_replies(session) >= HARD_MAX_FOUNDER_REPLIES:
280
+ return {"error": "Maximum deal rounds reached. End the deal to see your scorecard."}
281
+
282
+ current_round = int(session.get("deal_round", 1))
283
+
284
+ deal_context = session.get("deal_context") or {}
285
+ last_tag = "Anchoring"
286
+ if session.get("deal_history"):
287
+ for h in reversed(session["deal_history"]):
288
+ if h.get("negotiation_tag"):
289
+ last_tag = h["negotiation_tag"]
290
+ break
291
+
292
+ answer_quality = classify_deal_answer_quality(message, deal_context, last_tag)
293
+ signals = extract_deal_signals(
294
+ session.get("deal_history", []) + [{"role": "user", "message": message}],
295
+ deal_context,
296
+ )
297
+
298
+ session.setdefault("deal_history", []).append({
299
+ "round": current_round,
300
+ "role": "user",
301
+ "message": message,
302
+ "negotiation_tag": last_tag,
303
+ "answer_quality": answer_quality,
304
+ "action": "",
305
+ "input_mode": input_mode or "text",
306
+ "voice_turn_id": voice_turn_id or "",
307
+ "timestamp": datetime.now(timezone.utc).isoformat(),
308
+ })
309
+
310
+ next_round = current_round + 1
311
+ negotiation_tag = choose_next_negotiation_tag(session)
312
+ action = choose_deal_action(answer_quality, negotiation_tag, session)
313
+ if action not in _VALID_ACTIONS:
314
+ action = "acknowledge_and_counter"
315
+
316
+ ai_message = _generate_deal_ai_message(
317
+ session, message, negotiation_tag, answer_quality, action
318
+ )
319
+
320
+ session["deal_round"] = next_round
321
+ session["deal_history"].append({
322
+ "round": next_round,
323
+ "role": "judge",
324
+ "message": ai_message,
325
+ "negotiation_tag": negotiation_tag,
326
+ "answer_quality": "",
327
+ "action": action,
328
+ "input_mode": "",
329
+ "voice_turn_id": "",
330
+ "timestamp": datetime.now(timezone.utc).isoformat(),
331
+ })
332
+
333
+ readiness = evaluate_deal_readiness(session)
334
+ founder_replies = readiness["rounds_completed"]
335
+ soft_limit = readiness["recommended_action"] in ("recommend_end", "force_end")
336
+ can_continue = founder_replies < HARD_MAX_FOUNDER_REPLIES
337
+
338
+ return {
339
+ "session_id": session.get("session_id", ""),
340
+ "deal_phase_id": session.get("deal_phase_id", ""),
341
+ "round": next_round,
342
+ "negotiation_tag": negotiation_tag,
343
+ "answer_quality": answer_quality,
344
+ "action": action,
345
+ "ai_message": ai_message,
346
+ "deal_state": _deal_state(signals, answer_quality),
347
+ "readiness": readiness,
348
+ "soft_limit_reached": soft_limit,
349
+ "can_continue": can_continue,
350
+ "completion_message": (readiness["reason"] if soft_limit else ""),
351
+ }
core/deal_persona_builder.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deal-phase persona prompt builders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from core.judge_settings import get_label, normalize_difficulty
8
+ from core.persona_builder import PERSONA_LABELS, build_persona_prompt
9
+
10
+ DEAL_TYPE_LABELS = {
11
+ "equity": "Equity Negotiation",
12
+ "mentorship": "Mentorship Terms",
13
+ "pilot": "Pilot Agreement",
14
+ "sponsorship": "Sponsorship Terms",
15
+ "verdict_only": "Hackathon Verdict",
16
+ "none": "General Discussion",
17
+ }
18
+
19
+
20
+ def get_persona_display(persona: str) -> tuple[str, str]:
21
+ """Return (persona_name, persona_role)."""
22
+ name = PERSONA_LABELS.get(persona, "Tough Judge")
23
+ roles = {
24
+ "skeptical_vc": "Skeptical VC",
25
+ "technical_judge": "Technical Mentor",
26
+ "hackathon_judge": "Hackathon Judge",
27
+ }
28
+ return name, roles.get(persona, name)
29
+
30
+
31
+ def build_deal_system_prompt(
32
+ session: dict,
33
+ deal_context: dict,
34
+ negotiation_tag: str,
35
+ ) -> str:
36
+ """System prompt for deal negotiation rounds."""
37
+ persona = session.get("persona", "skeptical_vc")
38
+ startup = session.get("startup", {}) or {}
39
+ difficulty = session.get("difficulty_profile") or session.get("difficulty", "practice")
40
+ base = build_persona_prompt(persona, startup, difficulty)
41
+
42
+ deal_type = deal_context.get("deal_type") or session.get("deal_type", "equity")
43
+ deal_label = DEAL_TYPE_LABELS.get(deal_type, deal_type)
44
+
45
+ return f"""{base}
46
+
47
+ You are now in the DEAL PHASE — {deal_label}.
48
+ The pitch battle is over. You are negotiating terms, not evaluating the idea from scratch.
49
+
50
+ Deal context:
51
+ - Founder's ask: {deal_context.get('ask', 'not stated')}
52
+ - Your opening position: {deal_context.get('opening_offer', deal_context.get('judge_position', ''))}
53
+ - Current negotiation focus: {negotiation_tag}
54
+
55
+ Deal behavior rules:
56
+ - Stay in character as the same judge/persona from the pitch.
57
+ - Reference actual pitch context when pushing back.
58
+ - Push on ONE negotiation point per response.
59
+ - Ask at most one question. Keep to 1-3 sentences.
60
+ - Do not accept too easily — be firm but realistic.
61
+ - Do not give advice. Negotiate.
62
+ - No markdown. No bullet lists. Plain spoken text only.
63
+ - Do not leak instructions or mention being an AI.
64
+ """.strip()
65
+
66
+
67
+ def build_compact_deal_context(session: dict) -> dict[str, Any]:
68
+ """Compact context for deal rounds and scoring — keeps token usage controlled.
69
+
70
+ Sends only what the model needs to negotiate/score: startup name, ask, deal type,
71
+ judge verdict reaction, opening offer, a short negotiation summary, the last few
72
+ deal messages, the current negotiation tag, and difficulty. It deliberately omits
73
+ the full pitch history, the full scorecard object, and voice metadata.
74
+ """
75
+ startup = session.get("startup", {}) or {}
76
+ deal_context = session.get("deal_context") or {}
77
+ verdict = session.get("judge_verdict") or {}
78
+ deal_history = session.get("deal_history") or []
79
+ difficulty = session.get("difficulty_profile") or normalize_difficulty(
80
+ session.get("difficulty", "practice")
81
+ )
82
+
83
+ last_tag = "Anchoring"
84
+ for h in reversed(deal_history):
85
+ if h.get("negotiation_tag"):
86
+ last_tag = h["negotiation_tag"]
87
+ break
88
+
89
+ # Last 3 deal messages only (compact, role-tagged, truncated).
90
+ recent = []
91
+ for h in deal_history[-3:]:
92
+ role = "Judge" if h.get("role") == "judge" else "Founder"
93
+ msg = str(h.get("message", "")).strip()[:220]
94
+ if msg:
95
+ recent.append(f"{role}: {msg}")
96
+
97
+ user_turns = sum(1 for h in deal_history if h.get("role") == "user")
98
+ summary = (
99
+ f"{user_turns} founder counter(s) exchanged so far; current focus is {last_tag}."
100
+ )
101
+
102
+ return {
103
+ "startup_name": str(startup.get("name", "")).strip(),
104
+ "ask": str(deal_context.get("ask", startup.get("ask", ""))).strip(),
105
+ "deal_type": deal_context.get("deal_type") or session.get("deal_type", "equity"),
106
+ "deal_type_label": DEAL_TYPE_LABELS.get(
107
+ deal_context.get("deal_type") or session.get("deal_type", "equity"),
108
+ "Deal",
109
+ ),
110
+ "judge_verdict": str(verdict.get("judge_reaction", "")).strip()[:240],
111
+ "opening_offer": str(
112
+ deal_context.get("opening_offer") or deal_context.get("judge_position", "")
113
+ ).strip(),
114
+ "negotiation_summary": summary,
115
+ "recent_messages": recent,
116
+ "negotiation_tag": last_tag,
117
+ "difficulty_profile": difficulty,
118
+ }
119
+
120
+
121
+ def build_deal_round_prompt(
122
+ session: dict,
123
+ user_message: str,
124
+ negotiation_tag: str,
125
+ answer_quality: str,
126
+ action: str,
127
+ ) -> str:
128
+ """User-side instruction for the next deal counter."""
129
+ deal_context = session.get("deal_context") or {}
130
+ pitch_overall = (session.get("latest_scorecard") or {}).get("overall", 0)
131
+
132
+ action_hints = {
133
+ "acknowledge_and_counter": "Acknowledge one valid point, then counter with your terms.",
134
+ "press_harder": "The founder's answer was weak — press harder on proof or terms.",
135
+ "escalate_stakes": "Raise the stakes — explain what risk you still see.",
136
+ "partial_concession": "Offer a small movement in terms, but extract something in return.",
137
+ "move_to_close": "Push toward a concrete next step or final terms.",
138
+ }
139
+ hint = action_hints.get(action, action_hints["acknowledge_and_counter"])
140
+
141
+ return (
142
+ f"Negotiation tag: {negotiation_tag}\n"
143
+ f"Founder's answer quality: {answer_quality}\n"
144
+ f"Your action: {action} — {hint}\n"
145
+ f"Pitch score context: overall {pitch_overall}/100\n"
146
+ f"Opening offer on table: {deal_context.get('opening_offer', '')}\n\n"
147
+ f"Founder's latest counter/answer:\n{user_message}\n\n"
148
+ "Respond as the judge with one concise negotiation pushback (1-3 sentences)."
149
+ )
core/deal_phase.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deal phase start helpers (Phase 9B)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from core.deal_persona_builder import DEAL_TYPE_LABELS, build_deal_system_prompt, get_persona_display
11
+ from core.judge_settings import get_label
12
+ from core import model_router
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ NEGOTIATION_TAGS = [
17
+ "Anchoring",
18
+ "Evidence Quality",
19
+ "Concession Control",
20
+ "Alternatives",
21
+ "Value Articulation",
22
+ "Closing Move",
23
+ ]
24
+
25
+ _DEAL_FIRST_FALLBACK: dict[str, str] = {
26
+ "equity": (
27
+ "You mentioned traction in your pitch, but it is not enough for your full ask. "
28
+ "I would open below your terms. Convince me why I should move closer to what you want."
29
+ ),
30
+ "mentorship": (
31
+ "You clearly need help sharpening execution. I can offer limited mentorship time, "
32
+ "but I need you to define exactly what outcome you want from me."
33
+ ),
34
+ "pilot": (
35
+ "I am open to a pilot, but I will not approve a long free trial without risk controls. "
36
+ "What paid pilot structure can you defend?"
37
+ ),
38
+ "sponsorship": (
39
+ "I can consider sponsorship, but I need measurable return and audience quality. "
40
+ "What do we get for the budget you are asking?"
41
+ ),
42
+ }
43
+
44
+
45
+ def get_deal_negotiation_tags(deal_type: str) -> list[str]:
46
+ return list(NEGOTIATION_TAGS)
47
+
48
+
49
+ def build_deal_context(session: dict) -> dict[str, Any]:
50
+ """Build deal context from session, scorecard, and verdict."""
51
+ startup = session.get("startup", {}) or {}
52
+ verdict = session.get("judge_verdict") or {}
53
+ scorecard = session.get("latest_scorecard") or {}
54
+ difficulty_profile = session.get("difficulty_profile") or session.get("difficulty", "practice")
55
+ deal_type = session.get("deal_type") or verdict.get("deal_type", "equity")
56
+
57
+ return {
58
+ "ask": str(startup.get("ask", "")).strip(),
59
+ "opening_offer": str(verdict.get("deal_opening_offer", "")).strip(),
60
+ "founder_position": str(startup.get("ask", "")).strip(),
61
+ "judge_position": str(verdict.get("deal_opening_offer", "")).strip(),
62
+ "deal_type": deal_type,
63
+ "deal_type_label": DEAL_TYPE_LABELS.get(deal_type, deal_type),
64
+ "pitch_overall": scorecard.get("overall", 0),
65
+ "traction": str(startup.get("traction", "")).strip(),
66
+ "startup_name": str(startup.get("name", "")).strip(),
67
+ "difficulty_profile": difficulty_profile,
68
+ "difficulty_label": get_label(difficulty_profile),
69
+ }
70
+
71
+
72
+ def _build_first_deal_messages(session: dict, deal_context: dict) -> list[dict[str, str]]:
73
+ system = build_deal_system_prompt(session, deal_context, "Anchoring")
74
+ startup = session.get("startup", {}) or {}
75
+ verdict = session.get("judge_verdict") or {}
76
+
77
+ user = (
78
+ f"Transition from pitch to deal negotiation.\n"
79
+ f"Startup: {startup.get('name', '')}\n"
80
+ f"Traction: {startup.get('traction', '')}\n"
81
+ f"Founder's ask: {deal_context.get('ask', '')}\n"
82
+ f"Your opening offer: {deal_context.get('opening_offer', '')}\n"
83
+ f"Verdict reaction: {verdict.get('judge_reaction', '')}\n\n"
84
+ "Deliver your first deal negotiation message. Reference the pitch if possible. "
85
+ "1-3 sentences. One pushback or opening terms. Plain text only."
86
+ )
87
+ return [{"role": "system", "content": system}, {"role": "user", "content": user}]
88
+
89
+
90
+ def build_first_deal_message(session: dict, deal_context: dict) -> dict[str, str]:
91
+ """Generate first deal message via Nemotron with local fallback."""
92
+ deal_type = deal_context.get("deal_type", "equity")
93
+ fallback = _DEAL_FIRST_FALLBACK.get(deal_type, _DEAL_FIRST_FALLBACK["equity"])
94
+
95
+ verdict = session.get("judge_verdict") or {}
96
+ if verdict.get("judge_reaction"):
97
+ fallback = str(verdict["judge_reaction"])
98
+ if deal_context.get("opening_offer"):
99
+ fallback += f" {deal_context['opening_offer']}"
100
+
101
+ messages = _build_first_deal_messages(session, deal_context)
102
+ model_mode = session.get("model_mode", "premium_nvidia")
103
+ result = model_router.generate_deal_round_response(messages, model_mode=model_mode)
104
+
105
+ if result.get("ok") and result.get("content"):
106
+ text = str(result["content"]).strip()
107
+ if text:
108
+ return {"ai_message": text[:600], "provider": result.get("provider", "nvidia")}
109
+
110
+ logger.warning("deal_phase: first message fallback — %s", result.get("error"))
111
+ return {"ai_message": fallback[:600], "provider": "local"}
112
+
113
+
114
+ def start_deal_phase(session: dict) -> dict[str, Any]:
115
+ """Initialize deal phase on existing pitch session."""
116
+ if not session.get("latest_scorecard"):
117
+ return {"error": "No scorecard found. End a battle before starting deal phase."}
118
+
119
+ verdict = session.get("judge_verdict")
120
+ if not verdict:
121
+ return {"error": "No judge verdict found. End a battle first."}
122
+
123
+ if not verdict.get("can_continue_to_deal"):
124
+ return {
125
+ "error": (
126
+ f"Deal phase cannot start because the judge verdict is "
127
+ f"{verdict.get('interest_level', 'no_interest')}."
128
+ )
129
+ }
130
+
131
+ if session.get("deal_phase_active") and session.get("deal_history"):
132
+ deal_context = session.get("deal_context") or build_deal_context(session)
133
+ last_judge = next(
134
+ (h for h in reversed(session.get("deal_history", [])) if h.get("role") == "judge"),
135
+ None,
136
+ )
137
+ return {
138
+ "session_id": session.get("session_id", ""),
139
+ "deal_phase_id": session.get("deal_phase_id", ""),
140
+ "deal_type": session.get("deal_type", ""),
141
+ "persona_name": verdict.get("persona_name", ""),
142
+ "persona_role": verdict.get("persona_type", ""),
143
+ "round": session.get("deal_round", 1),
144
+ "negotiation_tag": last_judge.get("negotiation_tag", "Anchoring") if last_judge else "Anchoring",
145
+ "ai_message": last_judge.get("message", "") if last_judge else "",
146
+ "deal_context": deal_context,
147
+ "can_continue": True,
148
+ "soft_limit_reached": False,
149
+ }
150
+
151
+ deal_phase_id = str(uuid.uuid4())
152
+ deal_type = verdict.get("deal_type", "equity")
153
+ deal_context = build_deal_context(session)
154
+ persona_name, persona_role = get_persona_display(session.get("persona", "skeptical_vc"))
155
+
156
+ first = build_first_deal_message(session, deal_context)
157
+ ai_message = first["ai_message"]
158
+ tag = "Anchoring"
159
+
160
+ session["deal_phase_active"] = True
161
+ session["deal_phase_id"] = deal_phase_id
162
+ session["deal_type"] = deal_type
163
+ session["deal_context"] = deal_context
164
+ session["deal_round"] = 1
165
+ session["deal_history"] = [{
166
+ "round": 1,
167
+ "role": "judge",
168
+ "message": ai_message,
169
+ "negotiation_tag": tag,
170
+ "answer_quality": "",
171
+ "action": "opening",
172
+ "input_mode": "",
173
+ "voice_turn_id": "",
174
+ "timestamp": datetime.now(timezone.utc).isoformat(),
175
+ }]
176
+ session["deal_scorecard"] = {}
177
+ session["combined_scorecard"] = {}
178
+
179
+ return {
180
+ "session_id": session.get("session_id", ""),
181
+ "deal_phase_id": deal_phase_id,
182
+ "deal_type": deal_type,
183
+ "persona_name": persona_name,
184
+ "persona_role": persona_role,
185
+ "round": 1,
186
+ "negotiation_tag": tag,
187
+ "ai_message": ai_message,
188
+ "deal_context": deal_context,
189
+ "can_continue": True,
190
+ "soft_limit_reached": False,
191
+ }
core/deal_scoring_engine.py ADDED
@@ -0,0 +1,910 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deal scorecard + combined pitch/deal summary (Phase 9D)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from core.deal_claim_extractor import (
9
+ extract_deal_signals,
10
+ is_substantive_move,
11
+ is_one_word_ack,
12
+ )
13
+ from core.deal_persona_builder import build_compact_deal_context
14
+ from core.judge_settings import get_scoring_calibration, normalize_difficulty
15
+ from core.json_utils import (
16
+ parse_model_json,
17
+ parse_json_object,
18
+ safe_json_parse,
19
+ extract_partial_string_fields,
20
+ extract_partial_string_list,
21
+ ends_abruptly,
22
+ sanitize_for_log,
23
+ )
24
+ from core import model_router
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ DEAL_DIMS = (
29
+ "anchoring",
30
+ "evidence",
31
+ "concession_control",
32
+ "alternatives",
33
+ "value_articulation",
34
+ "closing",
35
+ )
36
+
37
+ _DIM_LABELS = {
38
+ "anchoring": "Anchoring",
39
+ "evidence": "Evidence",
40
+ "concession_control": "Concession Control",
41
+ "alternatives": "Alternatives",
42
+ "value_articulation": "Value Articulation",
43
+ "closing": "Closing",
44
+ }
45
+
46
+
47
+ def _deal_score_label(score: int) -> str:
48
+ if score >= 80:
49
+ return "Strong"
50
+ if score >= 60:
51
+ return "Solid"
52
+ if score >= 40:
53
+ return "Developing"
54
+ return "Weak"
55
+
56
+
57
+ def _clamp(n: int, lo: int = 0, hi: int = 100) -> int:
58
+ return max(lo, min(hi, n))
59
+
60
+
61
+ def _dim_entry(score: int, reason: str, quote: str = "") -> dict[str, Any]:
62
+ return {
63
+ "score": _clamp(score),
64
+ "label": _deal_score_label(score),
65
+ "reason": reason[:280],
66
+ "quote": quote[:200],
67
+ }
68
+
69
+
70
+ def _best_user_quote(deal_history: list[dict]) -> str:
71
+ users = [h.get("message", "") for h in deal_history if h.get("role") == "user"]
72
+ if not users:
73
+ return ""
74
+ return max(users, key=lambda t: len(t.split()))[:200]
75
+
76
+
77
+ def _weakest_user_quote(deal_history: list[dict], signals: dict) -> str:
78
+ users = [h.get("message", "") for h in deal_history if h.get("role") == "user"]
79
+ if not users:
80
+ return ""
81
+ if signals.get("weak_concession_signals"):
82
+ for u in users:
83
+ if any(w.lower() in u.lower() for w in signals["weak_concession_signals"]):
84
+ return u[:200]
85
+ return min(users, key=lambda t: len(t.split()))[:200]
86
+
87
+
88
+ def _move_signal_strength(message: str) -> int:
89
+ """Rank a single founder message by how much negotiation substance it carries."""
90
+ s = extract_deal_signals([{"role": "user", "message": message}])
91
+ score = 0
92
+ if s["evidence_signals"]:
93
+ score += 2
94
+ if s["specific_numbers"]:
95
+ score += 2
96
+ if s["counteroffers"] or s["tradeoffs"]:
97
+ score += 2
98
+ if s["anchor_points"]:
99
+ score += 1
100
+ if s["closing_signals"]:
101
+ score += 1
102
+ if s["alternative_signals"]:
103
+ score += 1
104
+ return score
105
+
106
+
107
+ def select_best_and_weakest_deal_moves(
108
+ deal_history: list[dict],
109
+ scores: dict,
110
+ signals: dict,
111
+ ) -> dict[str, str]:
112
+ """Pick best/weakest founder moves from SUBSTANTIVE messages only.
113
+
114
+ One-word acknowledgements ("sure", "ok", "yes", "fine") are never eligible as the
115
+ weakest move unless they actually conceded a term. Returns human-readable sentences,
116
+ never a bare quote, so the scorecard explains the move rather than dumping a word.
117
+ """
118
+ users = [str(h.get("message", "")).strip() for h in deal_history if h.get("role") == "user"]
119
+ substantive = [u for u in users if is_substantive_move(u)]
120
+
121
+ if not substantive:
122
+ return {
123
+ "best_move": "No substantive negotiation move was recorded.",
124
+ "weakest_move": "No real counters were made — every reply was a bare acknowledgement.",
125
+ "best_quote": "",
126
+ "weakest_quote": "",
127
+ }
128
+
129
+ best_quote = max(substantive, key=_move_signal_strength)
130
+ if _move_signal_strength(best_quote) == 0:
131
+ best_quote = max(substantive, key=lambda t: len(t.split()))
132
+
133
+ # Weakest: prefer a substantive message that conceded without extracting anything.
134
+ weak_quote = ""
135
+ for u in substantive:
136
+ s = extract_deal_signals([{"role": "user", "message": u}])
137
+ if s["weak_concession_signals"] and not (s["counteroffers"] or s["tradeoffs"]):
138
+ weak_quote = u
139
+ break
140
+ if not weak_quote:
141
+ candidates = [u for u in substantive if u != best_quote]
142
+ if candidates:
143
+ low = min(candidates, key=_move_signal_strength)
144
+ if _move_signal_strength(low) <= 1:
145
+ weak_quote = low
146
+
147
+ best_move = f'Your strongest moment: "{best_quote[:200]}"'
148
+ if weak_quote:
149
+ weakest_move = (
150
+ f'Watch this moment: "{weak_quote[:200]}" — you gave ground without '
151
+ "anchoring a counter or extracting a tradeoff."
152
+ )
153
+ else:
154
+ weakest_move = (
155
+ "No major single weak move detected; the main weakness was that "
156
+ "alternatives and leverage were underdeveloped."
157
+ )
158
+
159
+ return {
160
+ "best_move": best_move,
161
+ "weakest_move": weakest_move,
162
+ "best_quote": best_quote,
163
+ "weakest_quote": weak_quote,
164
+ }
165
+
166
+
167
+ def calculate_deal_dimension_scores(
168
+ deal_signals: dict,
169
+ deal_history: list[dict],
170
+ deal_context: dict,
171
+ difficulty_profile: str,
172
+ ) -> dict[str, dict[str, Any]]:
173
+ """Local rule-based deal dimension scores."""
174
+ cal = get_scoring_calibration(difficulty_profile)
175
+ floor = cal.get("attempted_answer_floor", 33)
176
+ user_turns = deal_signals.get("user_turns", 0)
177
+ best_q = _best_user_quote(deal_history)
178
+ weak_q = _weakest_user_quote(deal_history, deal_signals)
179
+
180
+ if user_turns == 0:
181
+ empty = _dim_entry(0, "No deal counters were submitted.", "")
182
+ return {d: dict(empty) for d in DEAL_DIMS}
183
+
184
+ anchors = deal_signals.get("anchor_points", [])
185
+ numbers = deal_signals.get("specific_numbers", [])
186
+ evidence = deal_signals.get("evidence_signals", [])
187
+ weak_con = deal_signals.get("weak_concession_signals", [])
188
+ concessions = deal_signals.get("concession_signals", [])
189
+ alts = deal_signals.get("alternative_signals", [])
190
+ value = deal_signals.get("value_signals", [])
191
+ closing = deal_signals.get("closing_signals", [])
192
+ counters = deal_signals.get("counteroffers", [])
193
+ tradeoffs = deal_signals.get("tradeoffs", [])
194
+
195
+ # Anchoring — repeated clear counters should not cap low.
196
+ anchoring_score = floor
197
+ if anchors and counters:
198
+ anchoring_score = 78 if len(counters) >= 2 else 72
199
+ elif anchors or counters:
200
+ anchoring_score = 60
201
+ elif numbers:
202
+ anchoring_score = 50
203
+
204
+ evidence_score = floor
205
+ if evidence and numbers:
206
+ evidence_score = 74
207
+ elif evidence or numbers:
208
+ evidence_score = 56
209
+
210
+ # Concession control — reward trading concessions for conditions; only punish a
211
+ # bare giveaway with no counter/tradeoff. A harmless "sure" never lands here.
212
+ concession_score = 52
213
+ if weak_con and not (counters or tradeoffs):
214
+ concession_score = 32
215
+ elif tradeoffs and not weak_con:
216
+ concession_score = 76
217
+ elif concessions and counters:
218
+ concession_score = 70
219
+ elif concessions or tradeoffs:
220
+ concession_score = 60
221
+
222
+ # Alternatives — credit implied leverage/options, not just exact BATNA wording.
223
+ alt_score = 38
224
+ if alts and (numbers or tradeoffs):
225
+ alt_score = 76
226
+ elif alts:
227
+ alt_score = 66
228
+
229
+ value_score = floor
230
+ if value and numbers:
231
+ value_score = 72
232
+ elif value:
233
+ value_score = 56
234
+
235
+ closing_score = 32
236
+ if closing and counters:
237
+ closing_score = 76
238
+ elif closing:
239
+ closing_score = 66
240
+ elif user_turns >= 3 and counters:
241
+ closing_score = 50
242
+
243
+ raw = {
244
+ "anchoring": anchoring_score,
245
+ "evidence": evidence_score,
246
+ "concession_control": concession_score,
247
+ "alternatives": alt_score,
248
+ "value_articulation": value_score,
249
+ "closing": closing_score,
250
+ }
251
+ # Synergy: a well-rounded negotiation (≥5 dimensions already solid) earns a small
252
+ # lift so a genuinely strong founder can crest into the 80s instead of capping low.
253
+ if sum(1 for v in raw.values() if v >= 60) >= 5:
254
+ raw = {k: _clamp(v + 6) for k, v in raw.items()}
255
+ anchoring_score = raw["anchoring"]
256
+ evidence_score = raw["evidence"]
257
+ concession_score = raw["concession_control"]
258
+ alt_score = raw["alternatives"]
259
+ value_score = raw["value_articulation"]
260
+ closing_score = raw["closing"]
261
+
262
+ return {
263
+ "anchoring": _dim_entry(
264
+ anchoring_score,
265
+ "Clear term anchors and counteroffers strengthen your position."
266
+ if anchors else "Terms were not anchored with specific numbers or structure.",
267
+ best_q,
268
+ ),
269
+ "evidence": _dim_entry(
270
+ evidence_score,
271
+ "Evidence-backed counters build credibility."
272
+ if evidence else "Deal counters lacked proof points from traction or pilots.",
273
+ best_q,
274
+ ),
275
+ "concession_control": _dim_entry(
276
+ concession_score,
277
+ "You gave up too much too fast."
278
+ if weak_con else "Concession pacing was acceptable for this stage.",
279
+ weak_q or best_q,
280
+ ),
281
+ "alternatives": _dim_entry(
282
+ alt_score,
283
+ "BATNA or alternatives mentioned." if alts else "No alternatives or leverage cited.",
284
+ best_q,
285
+ ),
286
+ "value_articulation": _dim_entry(
287
+ value_score,
288
+ "Value and ROI were articulated." if value else "Fair value and ROI were under-explained.",
289
+ best_q,
290
+ ),
291
+ "closing": _dim_entry(
292
+ closing_score,
293
+ "Closing signals present." if closing else "No concrete closing step proposed.",
294
+ best_q,
295
+ ),
296
+ }
297
+
298
+
299
+ def determine_deal_outcome(scores: dict, deal_history: list[dict], signals: dict) -> str:
300
+ """Return deal outcome label."""
301
+ s = {k: int(v.get("score", 0)) for k, v in scores.items()}
302
+ user_turns = signals.get("user_turns", 0)
303
+ if user_turns == 0:
304
+ return "no_deal"
305
+
306
+ if signals.get("weak_concession_signals") and s["concession_control"] < 40:
307
+ return "weak_concession"
308
+
309
+ if (
310
+ s["anchoring"] >= 65
311
+ and s["evidence"] >= 60
312
+ and s["concession_control"] >= 55
313
+ and s["closing"] >= 55
314
+ ):
315
+ return "strong_win"
316
+
317
+ if s["closing"] >= 50 and s["value_articulation"] >= 50:
318
+ return "favorable_partial"
319
+
320
+ if s["concession_control"] >= 45 and s["anchoring"] >= 45:
321
+ return "balanced"
322
+
323
+ if s["closing"] < 35 and s["value_articulation"] < 40:
324
+ return "no_deal"
325
+
326
+ return "balanced"
327
+
328
+
329
+ _DEAL_OUTCOME_LABELS = frozenset({
330
+ "strong_win", "favorable_partial", "balanced", "weak_concession", "no_deal",
331
+ })
332
+
333
+
334
+ def _is_human_deal_summary(text: str) -> bool:
335
+ t = (text or "").strip()
336
+ if not t or len(t) < 25:
337
+ return False
338
+ normalized = t.lower().replace(" ", "_").replace("-", "_")
339
+ if normalized in _DEAL_OUTCOME_LABELS:
340
+ return False
341
+ return not ends_abruptly(t)
342
+
343
+
344
+ _OUTCOME_SUMMARIES = {
345
+ "strong_win": "You held your position with evidence and moved toward concrete terms.",
346
+ "favorable_partial": "You negotiated acceptably but left some value on the table.",
347
+ "balanced": "A mixed negotiation — some strong counters alongside a few gaps.",
348
+ "weak_concession": "You conceded too quickly without extracting tradeoffs in return.",
349
+ "no_deal": "No closing path emerged — terms were not defended strongly enough.",
350
+ }
351
+
352
+
353
+ def humanize_deal_outcome(outcome: str) -> str:
354
+ """Return a human-readable sentence for a deal outcome label."""
355
+ return _OUTCOME_SUMMARIES.get(outcome, _OUTCOME_SUMMARIES["balanced"])
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # Nemotron semantic scoring (Call 1) — primary judge for the 6 deal dimensions
360
+ # ---------------------------------------------------------------------------
361
+
362
+ _DEAL_SCORING_SCHEMA = (
363
+ '{"scores":{'
364
+ '"anchoring":{"score":0,"reason":"","quote":""},'
365
+ '"evidence":{"score":0,"reason":"","quote":""},'
366
+ '"concession_control":{"score":0,"reason":"","quote":""},'
367
+ '"alternatives":{"score":0,"reason":"","quote":""},'
368
+ '"value_articulation":{"score":0,"reason":"","quote":""},'
369
+ '"closing":{"score":0,"reason":"","quote":""}},'
370
+ '"deal_outcome":"strong_win|favorable_partial|balanced|weak_concession|no_deal",'
371
+ '"best_move":"","weakest_move":""}'
372
+ )
373
+
374
+
375
+ def _build_deal_scoring_prompt(
376
+ session: dict,
377
+ signals: dict,
378
+ local_scores: dict,
379
+ ) -> list[dict[str, str]]:
380
+ """Build the scoring-only messages for Nemotron (compact context, full founder turns)."""
381
+ ctx = build_compact_deal_context(session)
382
+ deal_history = session.get("deal_history") or []
383
+
384
+ # Full founder turns (these are what we score); judge turns truncated for context.
385
+ transcript_lines: list[str] = []
386
+ for h in deal_history:
387
+ role = "FOUNDER" if h.get("role") == "user" else "JUDGE"
388
+ msg = str(h.get("message", "")).strip()
389
+ if not msg:
390
+ continue
391
+ if role == "FOUNDER":
392
+ transcript_lines.append(f"FOUNDER: {msg[:400]}")
393
+ else:
394
+ transcript_lines.append(f"JUDGE: {msg[:160]}")
395
+ transcript = "\n".join(transcript_lines[-14:])
396
+
397
+ hints = (
398
+ f"anchors={signals.get('anchor_points', [])[:4]} "
399
+ f"numbers={signals.get('specific_numbers', [])[:4]} "
400
+ f"evidence={signals.get('evidence_signals', [])[:4]} "
401
+ f"alternatives={signals.get('alternative_signals', [])[:4]} "
402
+ f"tradeoffs={signals.get('tradeoffs', [])[:4]} "
403
+ f"closing={signals.get('closing_signals', [])[:4]}"
404
+ )
405
+
406
+ system = (
407
+ "You are an experienced startup negotiation judge scoring a founder's DEAL "
408
+ "negotiation. Score SEMANTICALLY based on what the founder actually argued — "
409
+ "not on keyword matching. Return ONLY one JSON object. First character {, last }. "
410
+ "No markdown. No reasoning. No array.\n\n"
411
+ "Score each of 6 dimensions 0-100:\n"
412
+ " anchoring — did they anchor specific terms/numbers and hold a clear position?\n"
413
+ " evidence — did they back terms with proof (traction, pilots, metrics)?\n"
414
+ " concession_control — did they trade concessions for conditions, or give ground freely?\n"
415
+ " alternatives — did they show leverage/options? Credit this even when phrased "
416
+ "naturally ('we're also talking to other partners', 'we're not dependent on this') "
417
+ "without the word BATNA.\n"
418
+ " value_articulation — did they explain ROI / why the terms are fair?\n"
419
+ " closing — did they push toward a concrete next step or commitment?\n\n"
420
+ "Scoring rules:\n"
421
+ "- Do NOT punish a harmless one-word acknowledgement like 'sure' or 'ok' unless it "
422
+ "clearly conceded a term.\n"
423
+ "- Pick weakest_move from a SUBSTANTIVE negotiation moment, never the shortest message.\n"
424
+ "- Allow 80+ when the founder anchors, proves, keeps concession control, shows "
425
+ "alternatives, articulates value, and closes.\n"
426
+ "- Do not over-score vague confidence with no specifics.\n"
427
+ "- quote must be copied from an actual FOUNDER message. Do not invent quotes.\n"
428
+ "- Each reason: one short sentence.\n\n"
429
+ f"REQUIRED JSON SCHEMA:\n{_DEAL_SCORING_SCHEMA}"
430
+ )
431
+
432
+ user = (
433
+ f"Deal type: {ctx.get('deal_type_label', '')}\n"
434
+ f"Founder ask: {ctx.get('ask', '')}\n"
435
+ f"Judge opening offer: {ctx.get('opening_offer', '')}\n"
436
+ f"Local signal hints (reference only, may be incomplete): {hints}\n"
437
+ f"Local reference scores (do not just copy — judge for yourself): "
438
+ f"{ {k: v.get('score') for k, v in local_scores.items()} }\n\n"
439
+ f"NEGOTIATION TRANSCRIPT:\n{transcript}\n\n"
440
+ "Score the 6 dimensions now. Output the JSON object only."
441
+ )
442
+ return [{"role": "system", "content": system}, {"role": "user", "content": user}]
443
+
444
+
445
+ def _extract_deal_scores(parsed: Any) -> dict[str, Any]:
446
+ """Locate the 6-dimension scores dict, tolerant of model JSON shape.
447
+
448
+ The model sometimes nests scores under "scores" and sometimes (after lossy JSON
449
+ extraction) the dimensions land at the root. Handle both so a valid scorecard is
450
+ never thrown away over a wrapper key.
451
+ """
452
+ if not isinstance(parsed, dict):
453
+ return {}
454
+ raw = parsed.get("scores")
455
+ if isinstance(raw, dict) and any(d in raw for d in DEAL_DIMS):
456
+ return raw
457
+ if any(d in parsed for d in DEAL_DIMS):
458
+ return {d: parsed[d] for d in DEAL_DIMS if d in parsed}
459
+ return {}
460
+
461
+
462
+ def _validate_deal_scoring(parsed: Any) -> bool:
463
+ """True if all 6 dims have a numeric score AND the scores are not all zero.
464
+
465
+ Rejecting an all-zero result is deliberate: it filters out the empty repair
466
+ skeleton (every score 0) so we fall back to local scoring instead of emitting a
467
+ bogus overall of 0 for a real negotiation.
468
+ """
469
+ scores = _extract_deal_scores(parsed)
470
+ if not scores:
471
+ return False
472
+ total = 0.0
473
+ for dim in DEAL_DIMS:
474
+ entry = scores.get(dim)
475
+ if not isinstance(entry, dict):
476
+ return False
477
+ try:
478
+ total += float(entry.get("score"))
479
+ except (TypeError, ValueError):
480
+ return False
481
+ return total > 0
482
+
483
+
484
+ def _normalize_deal_scoring(
485
+ parsed: dict,
486
+ deal_history: list[dict],
487
+ signals: dict,
488
+ ) -> dict[str, Any]:
489
+ """Clamp scores, attach labels, validate outcome, and resolve best/weakest moves."""
490
+ raw = _extract_deal_scores(parsed)
491
+ scores: dict[str, dict[str, Any]] = {}
492
+ for dim in DEAL_DIMS:
493
+ entry = raw.get(dim, {}) if isinstance(raw.get(dim), dict) else {}
494
+ try:
495
+ val = int(round(float(entry.get("score", 0))))
496
+ except (TypeError, ValueError):
497
+ val = 0
498
+ reason = str(entry.get("reason", "")).strip() or "Judged from the negotiation transcript."
499
+ quote = str(entry.get("quote", "")).strip()
500
+ scores[dim] = _dim_entry(val, reason, quote)
501
+
502
+ outcome = str(parsed.get("deal_outcome", "")).strip().lower().replace(" ", "_")
503
+ if outcome not in _DEAL_OUTCOME_LABELS:
504
+ outcome = determine_deal_outcome(scores, deal_history, signals)
505
+
506
+ # Best/weakest: trust the model only if its text is substantive; else derive locally.
507
+ local_moves = select_best_and_weakest_deal_moves(deal_history, scores, signals)
508
+ best_move = str(parsed.get("best_move", "")).strip()
509
+ weakest_move = str(parsed.get("weakest_move", "")).strip()
510
+ if len(best_move) < 12 or is_one_word_ack(best_move):
511
+ best_move = local_moves["best_move"]
512
+ if len(weakest_move) < 12 or is_one_word_ack(weakest_move):
513
+ weakest_move = local_moves["weakest_move"]
514
+
515
+ overall = round(sum(s["score"] for s in scores.values()) / len(scores))
516
+ return {
517
+ "scores": scores,
518
+ "deal_outcome": outcome,
519
+ "best_move": best_move[:300],
520
+ "weakest_move": weakest_move[:300],
521
+ "overall": overall,
522
+ "overall_label": _deal_score_label(overall),
523
+ }
524
+
525
+
526
+ def call_nemotron_deal_scoring(
527
+ session: dict,
528
+ signals: dict,
529
+ local_scorecard: dict,
530
+ ) -> dict[str, Any] | None:
531
+ """Call 1 — Nemotron semantic scoring. Returns normalized scores or None on failure."""
532
+ messages = _build_deal_scoring_prompt(session, signals, local_scorecard.get("scores", {}))
533
+ model_mode = session.get("model_mode", "premium_nvidia")
534
+ result = model_router.generate_deal_scoring_response(messages, model_mode=model_mode)
535
+
536
+ if not result.get("ok") or not result.get("content"):
537
+ logger.warning("deal_scoring: Nemotron scoring call failed — %s", result.get("error"))
538
+ return None
539
+
540
+ parsed = safe_json_parse(result["content"])
541
+ if not _validate_deal_scoring(parsed):
542
+ logger.warning(
543
+ "deal_scoring: scoring JSON invalid, trying repair preview=%r",
544
+ sanitize_for_log(result["content"]),
545
+ )
546
+ repair = model_router.generate_deal_scoring_repair_response(
547
+ result["content"], model_mode=model_mode
548
+ )
549
+ if repair.get("ok") and repair.get("content"):
550
+ parsed = safe_json_parse(repair["content"])
551
+
552
+ if not _validate_deal_scoring(parsed):
553
+ logger.warning("deal_scoring: scoring fallback used — Nemotron scores unavailable")
554
+ return None
555
+
556
+ return _normalize_deal_scoring(parsed, session.get("deal_history", []), signals)
557
+
558
+
559
+ def _parse_deal_coaching_json(raw: str) -> dict[str, Any]:
560
+ """Best-effort parse of deal coaching JSON."""
561
+ parsed = parse_json_object(
562
+ raw,
563
+ string_fields=[
564
+ "deal_outcome_summary", "best_move", "weakest_move",
565
+ "improved_response", "combined_summary", "next_best_action",
566
+ ],
567
+ )
568
+ if not parsed:
569
+ parsed = extract_partial_string_fields(raw, [
570
+ "deal_outcome_summary", "best_move", "weakest_move",
571
+ "improved_response", "combined_summary", "next_best_action",
572
+ ])
573
+
574
+ result: dict[str, Any] = {}
575
+ for key in (
576
+ "deal_outcome_summary", "best_move", "weakest_move",
577
+ "improved_response", "combined_summary", "next_best_action",
578
+ ):
579
+ val = str(parsed.get(key, "")).strip()
580
+ if not val:
581
+ continue
582
+ if key == "deal_outcome_summary" and not _is_human_deal_summary(val):
583
+ continue
584
+ if ends_abruptly(val) and key in ("best_move", "weakest_move", "next_best_action"):
585
+ continue
586
+ if ends_abruptly(val) and key == "improved_response" and len(val) < 40:
587
+ continue
588
+ result[key] = val
589
+
590
+ q3 = parsed.get("top_3_prep_points")
591
+ if not isinstance(q3, list) or len(q3) < 3:
592
+ q3 = extract_partial_string_list(raw, "top_3_prep_points", min_items=3)
593
+ if isinstance(q3, list):
594
+ items = [str(q).strip() for q in q3 if str(q).strip() and not ends_abruptly(str(q))]
595
+ if items:
596
+ result["top_3_prep_points"] = items[:3]
597
+ return result
598
+
599
+
600
+ def _merge_deal_coaching(local: dict[str, Any], nemotron: dict[str, Any]) -> tuple[dict[str, Any], str]:
601
+ merged = dict(local)
602
+ hits = 0
603
+ for key in (
604
+ "deal_outcome_summary", "best_move", "weakest_move",
605
+ "improved_response", "combined_summary", "next_best_action",
606
+ ):
607
+ val = str(nemotron.get(key, "")).strip()
608
+ if val:
609
+ merged[key] = val[:400 if key == "improved_response" else 300]
610
+ hits += 1
611
+ n_q = nemotron.get("top_3_prep_points")
612
+ if isinstance(n_q, list) and len(n_q) >= 3:
613
+ merged["top_3_prep_points"] = [str(q).strip() for q in n_q[:3]]
614
+ hits += 1
615
+ if hits >= 5:
616
+ return merged, "nemotron"
617
+ if hits > 0:
618
+ return merged, "partial_nemotron_local"
619
+ return merged, "local"
620
+
621
+
622
+ def build_local_deal_coaching(
623
+ session: dict,
624
+ scores: dict,
625
+ signals: dict,
626
+ outcome: str,
627
+ ) -> dict[str, Any]:
628
+ """Local coaching text when Nemotron unavailable."""
629
+ deal_context = session.get("deal_context") or {}
630
+ moves = select_best_and_weakest_deal_moves(
631
+ session.get("deal_history", []), scores, signals
632
+ )
633
+ weakest_dim = min(scores.items(), key=lambda x: x[1]["score"])[0]
634
+
635
+ return {
636
+ "deal_outcome_summary": humanize_deal_outcome(outcome),
637
+ "best_move": moves["best_move"],
638
+ "weakest_move": moves["weakest_move"],
639
+ "improved_response": (
640
+ f"A stronger {weakest_dim.replace('_', ' ')} counter would anchor specific terms, "
641
+ "cite one proof point, and propose a tradeoff instead of conceding."
642
+ ),
643
+ "top_3_prep_points": [
644
+ "Anchor every counter with a specific number or term.",
645
+ "Cite one pilot metric before conceding on price or equity.",
646
+ "Always propose a tradeoff — never concede without getting something back.",
647
+ ],
648
+ "combined_summary": "",
649
+ "next_best_action": f"Practice {weakest_dim.replace('_', ' ')} in your next deal drill.",
650
+ }
651
+
652
+
653
+ def call_nemotron_deal_coaching(
654
+ session: dict,
655
+ local_scorecard: dict,
656
+ signals: dict,
657
+ ) -> dict[str, Any] | None:
658
+ """Nemotron coaching for deal scorecard."""
659
+ deal_context = session.get("deal_context") or {}
660
+ history_text = "\n".join(
661
+ f"{h.get('role', '').upper()}: {h.get('message', '')[:200]}"
662
+ for h in (session.get("deal_history") or [])[-12:]
663
+ )
664
+
665
+ system = (
666
+ "You are a startup negotiation coach. Return ONLY valid JSON.\n"
667
+ "Return one JSON object only. First character must be {. Last character must be }.\n"
668
+ "No markdown. No reasoning. No array wrapper.\n"
669
+ "Keep each field short and complete. Do not end mid-sentence.\n"
670
+ "Use only provided deal history and signals. Do not hallucinate terms reached.\n"
671
+ "deal_outcome_summary must be a human-readable explanation (2 sentences max), "
672
+ "NOT a label like weak_concession or strong_win.\n\n"
673
+ "FIELD LIMITS:\n"
674
+ " deal_outcome_summary: 2 sentences max\n"
675
+ " best_move: 1 sentence\n"
676
+ " weakest_move: 1 sentence\n"
677
+ " improved_response: 3-5 sentences\n"
678
+ " each top_3_prep_points item: 1 sentence\n"
679
+ " combined_summary: 2 sentences max\n"
680
+ " next_best_action: 1 sentence\n\n"
681
+ "REQUIRED JSON:\n"
682
+ '{"deal_outcome_summary":"","best_move":"","weakest_move":"",'
683
+ '"improved_response":"","top_3_prep_points":["","",""],'
684
+ '"combined_summary":"","next_best_action":""}'
685
+ )
686
+
687
+ user = (
688
+ f"Deal type: {deal_context.get('deal_type', '')}\n"
689
+ f"Deal outcome: {local_scorecard.get('deal_outcome', '')}\n"
690
+ f"Overall deal score: {local_scorecard.get('overall', 0)}\n"
691
+ f"Dimension scores: {local_scorecard.get('scores', {})}\n"
692
+ f"Signals: {signals}\n\n"
693
+ f"Deal history:\n{history_text}\n"
694
+ )
695
+
696
+ messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
697
+ model_mode = session.get("model_mode", "premium_nvidia")
698
+ result = model_router.generate_deal_scorecard_coaching_response(messages, model_mode=model_mode)
699
+
700
+ if not result.get("ok") or not result.get("content"):
701
+ return None
702
+
703
+ raw = result["content"]
704
+ local_coaching = build_local_deal_coaching(
705
+ session,
706
+ local_scorecard.get("scores", {}),
707
+ signals,
708
+ local_scorecard.get("deal_outcome", "balanced"),
709
+ )
710
+ nemotron = _parse_deal_coaching_json(raw)
711
+ if not nemotron.get("deal_outcome_summary"):
712
+ logger.warning("deal_scoring: coaching parse failed, trying repair preview=%r", sanitize_for_log(raw))
713
+ repair = model_router.generate_deal_scorecard_repair_response(raw, model_mode=model_mode)
714
+ if repair.get("ok") and repair.get("content"):
715
+ repaired = _parse_deal_coaching_json(repair["content"])
716
+ for k, v in repaired.items():
717
+ if v and not nemotron.get(k):
718
+ nemotron[k] = v
719
+
720
+ merged, coaching_source = _merge_deal_coaching(local_coaching, nemotron)
721
+ if coaching_source == "local":
722
+ logger.warning("deal_scoring: coaching using local fallback preview=%r", sanitize_for_log(raw))
723
+ return None
724
+
725
+ q3 = list(merged.get("top_3_prep_points") or local_coaching["top_3_prep_points"])
726
+ while len(q3) < 3:
727
+ q3.append("Anchor terms with specific numbers.")
728
+ merged["top_3_prep_points"] = q3[:3]
729
+ merged["coaching_source"] = coaching_source
730
+ return merged
731
+
732
+
733
+ def build_combined_scorecard(
734
+ session: dict,
735
+ pitch_scorecard: dict,
736
+ deal_scorecard: dict,
737
+ coaching: dict | None = None,
738
+ ) -> dict[str, Any]:
739
+ """Build combined pitch + deal summary."""
740
+ pitch_overall = int(pitch_scorecard.get("overall", 0) or 0)
741
+ deal_overall = int(deal_scorecard.get("overall", 0) or 0)
742
+ combined = round(pitch_overall * 0.6 + deal_overall * 0.4)
743
+
744
+ if pitch_overall >= 70 and deal_overall >= 70:
745
+ profile = "Strong pitcher, strong negotiator"
746
+ elif pitch_overall >= 65 and deal_overall < 55:
747
+ profile = "Strong pitcher, developing negotiator"
748
+ elif pitch_overall < 55 and deal_overall >= 65:
749
+ profile = "Developing pitcher, strong negotiator"
750
+ elif pitch_overall >= 50 and deal_overall >= 50:
751
+ profile = "Promising founder, needs sharper proof and negotiation control"
752
+ else:
753
+ profile = "Early-stage founder, needs stronger fundamentals before investor conversations"
754
+
755
+ if combined >= 80:
756
+ combined_label = "Strong"
757
+ elif combined >= 60:
758
+ combined_label = "Solid"
759
+ elif combined >= 40:
760
+ combined_label = "Developing"
761
+ else:
762
+ combined_label = "Weak"
763
+
764
+ coaching = coaching or {}
765
+ summary = coaching.get("combined_summary") or (
766
+ f"Pitch scored {pitch_overall}/100; deal negotiation scored {deal_overall}/100. "
767
+ f"Combined read: {profile}."
768
+ )
769
+
770
+ return {
771
+ "pitch_overall": pitch_overall,
772
+ "deal_overall": deal_overall,
773
+ "combined_overall": combined,
774
+ "combined_label": combined_label,
775
+ "founder_profile": profile,
776
+ "summary": summary[:500],
777
+ "next_best_action": coaching.get(
778
+ "next_best_action",
779
+ "Practice anchoring terms before your next investor conversation.",
780
+ )[:200],
781
+ }
782
+
783
+
784
+ def build_local_deal_scorecard(session: dict, deal_signals: dict) -> dict[str, Any]:
785
+ """Full local deal scorecard without Nemotron."""
786
+ difficulty = session.get("difficulty_profile") or normalize_difficulty(
787
+ session.get("difficulty", "practice")
788
+ )
789
+ deal_context = session.get("deal_context") or {}
790
+ deal_history = session.get("deal_history") or []
791
+
792
+ scores = calculate_deal_dimension_scores(
793
+ deal_signals, deal_history, deal_context, difficulty
794
+ )
795
+ outcome = determine_deal_outcome(scores, deal_history, deal_signals)
796
+ overall = round(sum(s["score"] for s in scores.values()) / len(scores))
797
+
798
+ coaching = build_local_deal_coaching(session, scores, deal_signals, outcome)
799
+
800
+ return {
801
+ "overall": overall,
802
+ "overall_label": _deal_score_label(overall),
803
+ "deal_outcome": outcome,
804
+ "scores": scores,
805
+ "deal_outcome_summary": coaching["deal_outcome_summary"],
806
+ "best_move": coaching["best_move"],
807
+ "weakest_move": coaching["weakest_move"],
808
+ "improved_response": coaching["improved_response"],
809
+ "top_3_prep_points": coaching["top_3_prep_points"],
810
+ "concrete_signals_summary": {
811
+ "anchor_points": deal_signals.get("anchor_points", [])[:5],
812
+ "evidence_signals": deal_signals.get("evidence_signals", [])[:5],
813
+ "specific_numbers": deal_signals.get("specific_numbers", [])[:5],
814
+ "closing_signals": deal_signals.get("closing_signals", [])[:5],
815
+ },
816
+ "scorecard_source": "hybrid_deal_local",
817
+ "provider": "local",
818
+ "model_ok": False,
819
+ }
820
+
821
+
822
+ def build_negotiation_transcript(session: dict) -> list[dict[str, Any]]:
823
+ """Structured transcript for the 'View Negotiation Conversation' UI."""
824
+ transcript: list[dict[str, Any]] = []
825
+ for h in session.get("deal_history", []) or []:
826
+ transcript.append({
827
+ "round": h.get("round"),
828
+ "role": "judge" if h.get("role") == "judge" else "founder",
829
+ "message": str(h.get("message", "")),
830
+ "negotiation_tag": h.get("negotiation_tag", ""),
831
+ "answer_quality": h.get("answer_quality", ""),
832
+ "action": h.get("action", ""),
833
+ "input_mode": h.get("input_mode", "") or "text",
834
+ })
835
+ return transcript
836
+
837
+
838
+ def generate_deal_scorecard(session: dict) -> dict[str, Any]:
839
+ """Generate deal scorecard + combined summary using a split Nemotron call.
840
+
841
+ Call 1 (deal_scorecard_scoring) is the PRIMARY judge for the 6 dimension scores and
842
+ determines scorecard_source. Call 2 (deal_scorecard_coaching) only adds coaching text;
843
+ its failure falls back to local coaching but never downgrades scorecard_source.
844
+ """
845
+ if not session.get("deal_phase_active") and not session.get("deal_history"):
846
+ return {"error": "No deal phase found. Complete a deal negotiation first."}
847
+
848
+ session["deal_phase_active"] = False
849
+
850
+ deal_signals = extract_deal_signals(
851
+ session.get("deal_history", []),
852
+ session.get("deal_context"),
853
+ )
854
+
855
+ # Local scorecard: reference context for the model + safety fallback.
856
+ scorecard = build_local_deal_scorecard(session, deal_signals)
857
+
858
+ # --- Call 1: Nemotron semantic scoring (determines scorecard_source) ---
859
+ nem_scoring = call_nemotron_deal_scoring(session, deal_signals, scorecard)
860
+ if nem_scoring is not None:
861
+ scorecard["scores"] = nem_scoring["scores"]
862
+ scorecard["overall"] = nem_scoring["overall"]
863
+ scorecard["overall_label"] = nem_scoring["overall_label"]
864
+ scorecard["deal_outcome"] = nem_scoring["deal_outcome"]
865
+ scorecard["best_move"] = nem_scoring["best_move"]
866
+ scorecard["weakest_move"] = nem_scoring["weakest_move"]
867
+ scorecard["deal_outcome_summary"] = humanize_deal_outcome(nem_scoring["deal_outcome"])
868
+ scorecard["scorecard_source"] = "nemotron_full"
869
+ scorecard["provider"] = "nvidia"
870
+ scorecard["model_ok"] = True
871
+ else:
872
+ scorecard["scorecard_source"] = "hybrid_deal_local"
873
+ scorecard["provider"] = "local"
874
+ scorecard["model_ok"] = False
875
+ scorecard["model_error"] = "Nemotron deal scoring failed; used local scoring fallback."
876
+
877
+ # --- Call 2: Nemotron coaching text (non-fatal; never downgrades source) ---
878
+ coaching = call_nemotron_deal_coaching(session, scorecard, deal_signals)
879
+ if coaching:
880
+ if coaching.get("deal_outcome_summary"):
881
+ scorecard["deal_outcome_summary"] = coaching["deal_outcome_summary"]
882
+ scorecard["improved_response"] = coaching.get("improved_response", scorecard["improved_response"])
883
+ scorecard["top_3_prep_points"] = coaching.get("top_3_prep_points", scorecard["top_3_prep_points"])
884
+ # Only adopt the model's move text if it is substantive and we don't already
885
+ # have a semantic-scoring move (scoring-path moves are preferred).
886
+ if nem_scoring is None:
887
+ if coaching.get("best_move") and not is_one_word_ack(coaching["best_move"]):
888
+ scorecard["best_move"] = coaching["best_move"]
889
+ if coaching.get("weakest_move") and not is_one_word_ack(coaching["weakest_move"]):
890
+ scorecard["weakest_move"] = coaching["weakest_move"]
891
+ scorecard["coaching_source"] = coaching.get("coaching_source", "nemotron")
892
+ else:
893
+ coaching = build_local_deal_coaching(
894
+ session, scorecard["scores"], deal_signals, scorecard["deal_outcome"]
895
+ )
896
+ scorecard["coaching_source"] = "local"
897
+
898
+ pitch_scorecard = session.get("latest_scorecard") or {}
899
+ combined = build_combined_scorecard(session, pitch_scorecard, scorecard, coaching)
900
+
901
+ transcript = build_negotiation_transcript(session)
902
+ session["deal_scorecard"] = scorecard
903
+ session["combined_scorecard"] = combined
904
+
905
+ return {
906
+ "session_id": session.get("session_id", ""),
907
+ "deal_scorecard": scorecard,
908
+ "combined_scorecard": combined,
909
+ "negotiation_transcript": transcript,
910
+ }
core/deal_verdict.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Judge verdict engine after pitch scorecard (Phase 9A)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from typing import Any
8
+
9
+ from core.deal_persona_builder import get_persona_display
10
+ from core.judge_settings import get_label, normalize_difficulty
11
+ from core.json_utils import parse_model_json, sanitize_for_log
12
+ from core import model_router
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _INTEREST_LABELS = {
17
+ "strong_interest": "Strong Interest",
18
+ "mild_interest": "Mild Interest",
19
+ "too_early": "Too Early",
20
+ "no_interest": "No Interest",
21
+ }
22
+
23
+ _DEAL_TYPE_FROM_ASK: list[tuple[str, str]] = [
24
+ (r"\b(?:seed|series|funding|investment|equity|valuation|lakhs?|crores?|vc|investor)\b", "equity"),
25
+ (r"\b(?:mentorship|mentor|guidance|advisor|architecture|introduc)\b", "mentorship"),
26
+ (r"\b(?:pilot|paid pilot|contract|deployment|client|procurement|enterprise)\b", "pilot"),
27
+ (r"\b(?:sponsor|sponsorship|partnership|event|fest|budget)\b", "sponsorship"),
28
+ ]
29
+
30
+
31
+ def _get_battle_phase(round_number: int) -> str:
32
+ if round_number <= 3:
33
+ return "explore"
34
+ if round_number <= 6:
35
+ return "pressure"
36
+ return "close"
37
+
38
+
39
+ def _session_maturity(session: dict) -> dict[str, Any]:
40
+ history = session.get("history", [])
41
+ user_turns = sum(1 for m in history if m.get("role") == "user")
42
+ rounds_completed = max(user_turns, session.get("round", 1) - 1)
43
+ battle_phase = _get_battle_phase(session.get("round", rounds_completed + 1))
44
+ enough_context = (
45
+ rounds_completed >= 4 and battle_phase in ("pressure", "close")
46
+ ) or (rounds_completed >= 3 and battle_phase == "close")
47
+ return {
48
+ "rounds_completed": rounds_completed,
49
+ "battle_phase_reached": battle_phase,
50
+ "enough_context": enough_context,
51
+ }
52
+
53
+
54
+ def determine_deal_type(session: dict, pitch_scorecard: dict) -> str:
55
+ """Infer deal type from persona + ask field."""
56
+ persona = session.get("persona", "hackathon_judge")
57
+ difficulty = normalize_difficulty(
58
+ session.get("difficulty_profile") or session.get("difficulty", "practice")
59
+ )
60
+
61
+ # Practice mode: always allow a deal drill (mentorship terms) even with hackathon persona.
62
+ if difficulty == "practice":
63
+ if persona == "hackathon_judge":
64
+ return "mentorship"
65
+ # fall through for other personas in practice
66
+
67
+ if persona == "hackathon_judge":
68
+ return "verdict_only"
69
+
70
+ startup = session.get("startup", {}) or {}
71
+ ask = " ".join([
72
+ str(startup.get("ask", "")),
73
+ str(startup.get("traction", "")),
74
+ str(startup.get("problem", "")),
75
+ ]).lower()
76
+
77
+ for pattern, deal_type in _DEAL_TYPE_FROM_ASK:
78
+ if re.search(pattern, ask, re.IGNORECASE):
79
+ return deal_type
80
+
81
+ if persona == "skeptical_vc":
82
+ return "equity"
83
+ if persona == "technical_judge":
84
+ return "mentorship"
85
+ return "none"
86
+
87
+
88
+ def determine_interest_level(session: dict, pitch_scorecard: dict) -> str:
89
+ """Return interest_level from score + battle maturity."""
90
+ overall = int(pitch_scorecard.get("overall", 0) or 0)
91
+ maturity = _session_maturity(session)
92
+ rounds = maturity["rounds_completed"]
93
+ phase = maturity["battle_phase_reached"]
94
+ enough = maturity["enough_context"]
95
+ difficulty = normalize_difficulty(
96
+ session.get("difficulty_profile") or session.get("difficulty", "practice")
97
+ )
98
+ is_practice = difficulty == "practice"
99
+
100
+ persona = session.get("persona", "hackathon_judge")
101
+ if persona == "hackathon_judge":
102
+ return "mild_interest" if overall >= 48 else "no_interest"
103
+
104
+ # Practice mode: slightly more room to continue into deal for demo learning.
105
+ score_for_verdict = overall + (5 if is_practice else 0)
106
+
107
+ if rounds < 3 and score_for_verdict < 68:
108
+ return "too_early"
109
+ if phase == "explore" and score_for_verdict < 62:
110
+ return "too_early"
111
+ if not enough and score_for_verdict < 58:
112
+ return "too_early"
113
+
114
+ if score_for_verdict >= 65 and rounds >= 3:
115
+ return "strong_interest"
116
+ if score_for_verdict >= 48 and rounds >= 3:
117
+ return "mild_interest"
118
+ if score_for_verdict < 40:
119
+ return "no_interest"
120
+ if is_practice and rounds >= 2 and overall >= 42:
121
+ return "mild_interest"
122
+ return "mild_interest" if enough else "too_early"
123
+
124
+
125
+ def _weakest_dimension(pitch_scorecard: dict) -> tuple[str, int]:
126
+ scores = pitch_scorecard.get("scores") or {}
127
+ if not scores:
128
+ return "business_model", 30
129
+ dim, data = min(scores.items(), key=lambda x: int(x[1].get("score", 0)))
130
+ return dim, int(data.get("score", 0))
131
+
132
+
133
+ def _strongest_dimension(pitch_scorecard: dict) -> tuple[str, int]:
134
+ scores = pitch_scorecard.get("scores") or {}
135
+ if not scores:
136
+ return "clarity", 50
137
+ dim, data = max(scores.items(), key=lambda x: int(x[1].get("score", 0)))
138
+ return dim, int(data.get("score", 0))
139
+
140
+
141
+ def build_local_verdict_fallback(
142
+ session: dict,
143
+ pitch_scorecard: dict,
144
+ interest_level: str,
145
+ deal_type: str,
146
+ ) -> dict[str, str]:
147
+ """Local verdict text when Nemotron is unavailable."""
148
+ overall = int(pitch_scorecard.get("overall", 0) or 0)
149
+ weak_dim, weak_score = _weakest_dimension(pitch_scorecard)
150
+ strong_dim, strong_score = _strongest_dimension(pitch_scorecard)
151
+ weak_name = weak_dim.replace("_", " ")
152
+ startup = session.get("startup", {}) or {}
153
+ ask = str(startup.get("ask", "")).strip() or "your stated ask"
154
+ persona_name, _ = get_persona_display(session.get("persona", "skeptical_vc"))
155
+
156
+ if interest_level == "strong_interest" and deal_type == "equity":
157
+ reaction = (
158
+ f"The traction is real enough for me to continue. I am interested, "
159
+ f"but your valuation needs pressure-testing. I would open below {ask}."
160
+ )
161
+ offer = "₹30 lakhs for 15% — subject to due diligence on your pilot metrics."
162
+ why = f"Strong overall pitch ({overall}/100) but {weak_name} ({weak_score}) still needs proof."
163
+ next_label = "Continue to Deal Round"
164
+ elif interest_level == "mild_interest":
165
+ if deal_type == "mentorship":
166
+ reaction = (
167
+ "Good practice session. I see enough promise to walk through mentorship terms — "
168
+ "what you would get from me and what commitment I expect from you."
169
+ )
170
+ offer = "Two hours per week for four weeks focused on your weakest pitch dimension."
171
+ else:
172
+ reaction = (
173
+ "I am not fully convinced yet, but there is enough here to discuss terms. "
174
+ "My offer would reflect the risk I still see in your market and proof."
175
+ )
176
+ offer = "Terms would be conservative until you strengthen your weakest answers."
177
+ why = (
178
+ f"Score {overall}/100 — strongest area {strong_dim.replace('_', ' ')} ({strong_score}), "
179
+ f"still work needed on {weak_name} ({weak_score})."
180
+ )
181
+ next_label = "Start Negotiation →"
182
+ elif interest_level == "too_early":
183
+ reaction = (
184
+ "You ended before I could test the harder parts of this business. "
185
+ "I cannot make a serious deal decision yet."
186
+ )
187
+ offer = ""
188
+ why = "Not enough battle rounds or maturity to negotiate terms credibly."
189
+ next_label = "Practice More — Negotiate Later"
190
+ elif deal_type == "verdict_only":
191
+ reaction = (
192
+ "You are on the right track, but this is not a deal negotiation. "
193
+ "What separates you from top submissions is stronger proof, sharper differentiation, "
194
+ "and a clearer demo story."
195
+ )
196
+ offer = ""
197
+ why = f"Hackathon verdict at {overall}/100 — focus on {weak_name} before finals."
198
+ next_label = "View Winning Gap Analysis"
199
+ else:
200
+ reaction = (
201
+ "I am not ready to invest at this stage. The weakest part is your "
202
+ f"{weak_name}, and I would need stronger proof before discussing terms."
203
+ )
204
+ offer = ""
205
+ why = f"Overall {overall}/100 is below the bar for term discussion."
206
+ next_label = "Retry Weakest Question"
207
+
208
+ return {
209
+ "judge_reaction": reaction,
210
+ "deal_opening_offer": offer,
211
+ "why_this_verdict": why,
212
+ "next_step_label": next_label,
213
+ }
214
+
215
+
216
+ def _build_verdict_nemotron_messages(
217
+ session: dict,
218
+ pitch_scorecard: dict,
219
+ interest_level: str,
220
+ deal_type: str,
221
+ maturity: dict,
222
+ ) -> list[dict[str, str]]:
223
+ startup = session.get("startup", {}) or {}
224
+ persona = session.get("persona", "skeptical_vc")
225
+ persona_name, persona_role = get_persona_display(persona)
226
+ weak_dim, weak_score = _weakest_dimension(pitch_scorecard)
227
+ strong_dim, strong_score = _strongest_dimension(pitch_scorecard)
228
+ difficulty = session.get("difficulty_profile") or "practice"
229
+
230
+ system = (
231
+ f"You are {persona_name} ({persona_role}) giving a post-pitch verdict.\n"
232
+ "Return ONLY valid JSON. No markdown. No reasoning. No array wrapper.\n"
233
+ "Use the persona voice. Do not hallucinate facts. Use only provided context.\n\n"
234
+ "REQUIRED JSON:\n"
235
+ '{"judge_reaction":"","deal_opening_offer":"","why_this_verdict":"","next_step_label":""}\n\n'
236
+ "Rules:\n"
237
+ "- judge_reaction: 2-3 sentences in character.\n"
238
+ "- deal_opening_offer: opening terms if deal continues; empty string if not.\n"
239
+ "- For verdict_only (hackathon): deal_opening_offer must be empty; "
240
+ "next_step_label = 'View Winning Gap Analysis'.\n"
241
+ "- For too_early/no_interest: deal_opening_offer empty.\n"
242
+ "- next_step_label: 'Start Negotiation' if interested; "
243
+ "'Practice More — Negotiate Later' if too_early; 'Retry Weakest Question' if no_interest."
244
+ )
245
+
246
+ user = (
247
+ f"Startup: {startup.get('name', '')}\n"
248
+ f"Ask: {startup.get('ask', '')}\n"
249
+ f"Traction: {startup.get('traction', '')}\n"
250
+ f"Pitch overall: {pitch_scorecard.get('overall', 0)}/100\n"
251
+ f"Weakest dimension: {weak_dim} ({weak_score})\n"
252
+ f"Strongest dimension: {strong_dim} ({strong_score})\n"
253
+ f"Interest level (computed): {interest_level}\n"
254
+ f"Deal type: {deal_type}\n"
255
+ f"Rounds completed: {maturity.get('rounds_completed')}\n"
256
+ f"Battle phase: {maturity.get('battle_phase_reached')}\n"
257
+ f"Difficulty: {difficulty}\n"
258
+ )
259
+ return [{"role": "system", "content": system}, {"role": "user", "content": user}]
260
+
261
+
262
+ def generate_judge_reaction_with_nemotron(
263
+ session: dict,
264
+ pitch_scorecard: dict,
265
+ verdict_base: dict[str, str],
266
+ ) -> dict[str, str]:
267
+ """Enhance verdict text via Nemotron; fall back to verdict_base on failure."""
268
+ messages = _build_verdict_nemotron_messages(
269
+ session,
270
+ pitch_scorecard,
271
+ verdict_base.get("interest_level", "no_interest"),
272
+ verdict_base.get("deal_type", "none"),
273
+ verdict_base.get("session_maturity", {}),
274
+ )
275
+ model_mode = session.get("model_mode", "premium_nvidia")
276
+ result = model_router.generate_deal_verdict_response(messages, model_mode=model_mode)
277
+ if not result.get("ok") or not result.get("content"):
278
+ return {
279
+ "judge_reaction": verdict_base.get("judge_reaction", ""),
280
+ "deal_opening_offer": verdict_base.get("deal_opening_offer", ""),
281
+ "why_this_verdict": verdict_base.get("why_this_verdict", ""),
282
+ "next_step_label": verdict_base.get("next_step_label", ""),
283
+ }
284
+
285
+ raw = result["content"]
286
+ parsed, _ = parse_model_json(raw)
287
+ if not isinstance(parsed, dict) or not parsed:
288
+ repair = model_router.generate_deal_verdict_repair_response(raw, model_mode=model_mode)
289
+ if repair.get("ok") and repair.get("content"):
290
+ parsed, _ = parse_model_json(repair["content"])
291
+
292
+ if isinstance(parsed, dict) and parsed.get("judge_reaction"):
293
+ return {
294
+ "judge_reaction": str(parsed.get("judge_reaction", ""))[:500],
295
+ "deal_opening_offer": str(parsed.get("deal_opening_offer", ""))[:300],
296
+ "why_this_verdict": str(parsed.get("why_this_verdict", ""))[:400],
297
+ "next_step_label": str(parsed.get("next_step_label", verdict_base.get("next_step_label", "")))[:80],
298
+ }
299
+
300
+ logger.warning("deal_verdict: Nemotron parse failed preview=%r", sanitize_for_log(raw))
301
+ return {
302
+ "judge_reaction": verdict_base.get("judge_reaction", ""),
303
+ "deal_opening_offer": verdict_base.get("deal_opening_offer", ""),
304
+ "why_this_verdict": verdict_base.get("why_this_verdict", ""),
305
+ "next_step_label": verdict_base.get("next_step_label", ""),
306
+ }
307
+
308
+
309
+ def build_judge_verdict(
310
+ session: dict,
311
+ pitch_scorecard: dict,
312
+ local_only: bool = False,
313
+ ) -> dict[str, Any]:
314
+ """Build full judge_verdict object after pitch scorecard."""
315
+ from core.scoring_engine import _sync_overall_to_dimensions
316
+
317
+ pitch_scorecard = _sync_overall_to_dimensions(dict(pitch_scorecard))
318
+ session["latest_scorecard"] = pitch_scorecard
319
+
320
+ persona = session.get("persona", "hackathon_judge")
321
+ persona_name, persona_role = get_persona_display(persona)
322
+ maturity = _session_maturity(session)
323
+ deal_type = determine_deal_type(session, pitch_scorecard)
324
+ interest_level = determine_interest_level(session, pitch_scorecard)
325
+
326
+ startup = session.get("startup", {}) or {}
327
+ ask_detected = str(startup.get("ask", "")).strip()
328
+
329
+ difficulty_profile = session.get("difficulty_profile") or normalize_difficulty(
330
+ session.get("difficulty", "practice")
331
+ )
332
+
333
+ can_continue = (
334
+ interest_level in ("strong_interest", "mild_interest")
335
+ and deal_type not in ("verdict_only", "none")
336
+ )
337
+ if deal_type == "verdict_only":
338
+ can_continue = False
339
+ if interest_level in ("too_early", "no_interest"):
340
+ can_continue = False
341
+ # Practice mode: mild/strong interest always unlocks deal practice.
342
+ if difficulty_profile == "practice" and interest_level in ("strong_interest", "mild_interest"):
343
+ can_continue = True
344
+ if deal_type in ("verdict_only", "none"):
345
+ deal_type = "mentorship"
346
+
347
+ local = build_local_verdict_fallback(session, pitch_scorecard, interest_level, deal_type)
348
+ verdict_base = {
349
+ **local,
350
+ "interest_level": interest_level,
351
+ "deal_type": deal_type,
352
+ "session_maturity": maturity,
353
+ }
354
+
355
+ use_local = local_only or bool(pitch_scorecard.get("retry_applied"))
356
+ if use_local:
357
+ nemotron_text = {
358
+ "judge_reaction": local["judge_reaction"],
359
+ "deal_opening_offer": local["deal_opening_offer"],
360
+ "why_this_verdict": local["why_this_verdict"],
361
+ "next_step_label": local["next_step_label"],
362
+ }
363
+ else:
364
+ nemotron_text = generate_judge_reaction_with_nemotron(session, pitch_scorecard, verdict_base)
365
+
366
+ return {
367
+ "interest_level": interest_level,
368
+ "interest_label": _INTEREST_LABELS.get(interest_level, interest_level),
369
+ "deal_type": deal_type,
370
+ "can_continue_to_deal": can_continue,
371
+ "judge_reaction": nemotron_text.get("judge_reaction", local["judge_reaction"]),
372
+ "deal_opening_offer": nemotron_text.get("deal_opening_offer", local["deal_opening_offer"]),
373
+ "why_this_verdict": nemotron_text.get("why_this_verdict", local["why_this_verdict"]),
374
+ "next_step_label": nemotron_text.get("next_step_label", local["next_step_label"]),
375
+ "persona_name": persona_name,
376
+ "persona_type": persona_role,
377
+ "ask_detected": ask_detected,
378
+ "session_maturity": maturity,
379
+ "difficulty_profile": difficulty_profile,
380
+ "difficulty_label": get_label(difficulty_profile),
381
+ }
core/json_utils.py CHANGED
@@ -3,22 +3,35 @@
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)):
@@ -28,8 +41,46 @@ def extract_json_block(text: str) -> str | None:
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:
@@ -40,16 +91,153 @@ def safe_json_parse(text: str, default: Any = None) -> Any:
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]:
 
3
  from __future__ import annotations
4
 
5
  import json
6
+ import logging
7
  import re
8
  from typing import Any
9
 
10
+ logger = logging.getLogger(__name__)
11
 
 
 
 
 
12
 
13
+ def strip_markdown_fences(text: str) -> str:
14
+ """Remove markdown code fences and trim surrounding whitespace."""
15
+ if not text:
16
+ return ""
17
+ stripped = text.strip()
18
+ fenced = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", stripped, re.IGNORECASE)
19
  if fenced:
20
  return fenced.group(1).strip()
21
+ # Strip lone opening/closing fence lines
22
+ lines = stripped.splitlines()
23
+ if lines and lines[0].strip().startswith("```"):
24
+ lines = lines[1:]
25
+ if lines and lines[-1].strip() == "```":
26
+ lines = lines[:-1]
27
+ return "\n".join(lines).strip()
28
+
29
+
30
+ def _balanced_blocks(text: str, opener: str, closer: str) -> list[str]:
31
+ """Return all balanced opener/closer blocks found in text."""
32
+ blocks: list[str] = []
33
+ for start in range(len(text)):
34
+ if text[start] != opener:
35
  continue
36
  depth = 0
37
  for index in range(start, len(text)):
 
41
  elif char == closer:
42
  depth -= 1
43
  if depth == 0:
44
+ blocks.append(text[start : index + 1])
45
+ break
46
+ return blocks
47
+
48
+
49
+ def extract_largest_json_object(text: str) -> str | None:
50
+ """Extract the largest parseable JSON object from mixed model output."""
51
+ if not text:
52
+ return None
53
+
54
+ cleaned = strip_markdown_fences(text)
55
+ candidates = _balanced_blocks(cleaned, "{", "}")
56
+ if not candidates:
57
+ return None
58
+
59
+ # Prefer the largest block that parses cleanly
60
+ for block in sorted(candidates, key=len, reverse=True):
61
+ try:
62
+ parsed = json.loads(block)
63
+ if isinstance(parsed, dict):
64
+ return block
65
+ except json.JSONDecodeError:
66
+ continue
67
+
68
+ # Fall back to largest balanced block even if not yet parseable
69
+ return max(candidates, key=len)
70
+
71
+
72
+ def extract_json_block(text: str) -> str | None:
73
+ """Extract the largest JSON object block from text (legacy name, improved behavior)."""
74
+ if not text:
75
+ return None
76
+ return extract_largest_json_object(text)
77
+
78
+
79
+ def sanitize_for_log(text: str, limit: int = 200) -> str:
80
+ """Return a safe preview string for debug logs (no secrets, truncated)."""
81
+ preview = strip_markdown_fences(text or "")
82
+ preview = re.sub(r"\s+", " ", preview).strip()
83
+ return preview[:limit]
84
 
85
 
86
  def safe_json_parse(text: str, default: Any = None) -> Any:
 
91
  if not text:
92
  return default
93
 
94
+ cleaned = strip_markdown_fences(text)
95
+
96
  try:
97
+ return json.loads(cleaned)
98
  except json.JSONDecodeError:
99
+ pass
100
+
101
+ block = extract_largest_json_object(cleaned)
102
+ if not block:
103
+ return default
104
+ try:
105
+ return json.loads(block)
106
+ except json.JSONDecodeError:
107
+ return default
108
+
109
+
110
+ def ends_abruptly(text: str) -> bool:
111
+ """Return True if text looks cut off mid-sentence."""
112
+ t = (text or "").strip()
113
+ if not t:
114
+ return True
115
+ if t[-1] in ".!?":
116
+ return False
117
+ if len(t) < 50:
118
+ return True
119
+ last_word = t.split()[-1] if t.split() else ""
120
+ return len(last_word) <= 2 and len(t) < 80
121
+
122
+
123
+ def normalize_parsed_root(parsed: Any) -> dict[str, Any] | None:
124
+ """Unwrap array-wrapped or nested model JSON into a single object."""
125
+ if isinstance(parsed, dict):
126
+ return parsed
127
+ if isinstance(parsed, list):
128
+ for item in parsed:
129
+ if isinstance(item, dict) and item:
130
+ return item
131
+ return None
132
+
133
+
134
+ def extract_partial_string_fields(text: str, keys: list[str]) -> dict[str, str]:
135
+ """Best-effort regex extraction of string fields from truncated JSON."""
136
+ if not text:
137
+ return {}
138
+ cleaned = strip_markdown_fences(text)
139
+ found: dict[str, str] = {}
140
+ for key in keys:
141
+ pattern = rf'"{re.escape(key)}"\s*:\s*"((?:[^"\\]|\\.)*)"'
142
+ match = re.search(pattern, cleaned, re.DOTALL)
143
+ if match:
144
+ try:
145
+ found[key] = json.loads(f'"{match.group(1)}"')
146
+ except json.JSONDecodeError:
147
+ found[key] = match.group(1).replace('\\"', '"').strip()
148
+ return found
149
+
150
+
151
+ def extract_partial_string_list(text: str, key: str, min_items: int = 1) -> list[str]:
152
+ """Extract a JSON string array field from truncated output."""
153
+ if not text:
154
+ return []
155
+ cleaned = strip_markdown_fences(text)
156
+ match = re.search(rf'"{re.escape(key)}"\s*:\s*\[([\s\S]*?)\]', cleaned)
157
+ if not match:
158
+ return []
159
+ items: list[str] = []
160
+ for item_match in re.finditer(r'"((?:[^"\\]|\\.)*)"', match.group(1)):
161
+ try:
162
+ items.append(json.loads(f'"{item_match.group(1)}"'))
163
+ except json.JSONDecodeError:
164
+ items.append(item_match.group(1).replace('\\"', '"').strip())
165
+ return [i for i in items if i][:max(min_items, 8)]
166
+
167
+
168
+ def parse_json_object(
169
+ text: str,
170
+ reasoning_fallback: str | None = None,
171
+ string_fields: list[str] | None = None,
172
+ ) -> dict[str, Any]:
173
+ """Parse model output into a dict using multiple extraction strategies."""
174
+ parsed, _ = parse_model_json(text, reasoning_fallback=reasoning_fallback)
175
+ root = normalize_parsed_root(parsed)
176
+ if root:
177
+ return root
178
+
179
+ partial = extract_partial_string_fields(text, string_fields or [])
180
+ if partial:
181
+ return partial
182
+
183
+ fallback = safe_json_parse(text)
184
+ root = normalize_parsed_root(fallback)
185
+ return root if root else {}
186
+
187
+
188
+ def parse_model_json(
189
+ text: str,
190
+ reasoning_fallback: str | None = None,
191
+ ) -> tuple[Any, bool]:
192
+ """Parse model JSON output with extraction fallbacks.
193
+
194
+ Returns (parsed_value, repair_needed).
195
+ repair_needed is True when direct parse failed and extraction/reasoning was used.
196
+ """
197
+ default: dict[str, Any] = {}
198
+ if not text and not reasoning_fallback:
199
+ return default, False
200
+
201
+ content = strip_markdown_fences(text or "")
202
+ repair_needed = False
203
+
204
+ if content:
205
  try:
206
+ parsed = json.loads(content)
207
+ if isinstance(parsed, dict):
208
+ return parsed, False
209
+ if isinstance(parsed, list) and len(parsed) == 1 and isinstance(parsed[0], dict):
210
+ return parsed[0], True
211
+ if isinstance(parsed, list):
212
+ return parsed, True
213
  except json.JSONDecodeError:
214
+ repair_needed = True
215
+
216
+ block = extract_largest_json_object(content)
217
+ if block:
218
+ try:
219
+ parsed = json.loads(block)
220
+ if isinstance(parsed, (dict, list)):
221
+ return parsed, repair_needed
222
+ except json.JSONDecodeError:
223
+ pass
224
+
225
+ if reasoning_fallback:
226
+ fb = strip_markdown_fences(reasoning_fallback)
227
+ block = extract_largest_json_object(fb)
228
+ if block:
229
+ try:
230
+ parsed = json.loads(block)
231
+ if isinstance(parsed, (dict, list)):
232
+ logger.info(
233
+ "json_utils: parsed JSON from reasoning_content fallback (len=%d)",
234
+ len(fb),
235
+ )
236
+ return parsed, True
237
+ except json.JSONDecodeError:
238
+ pass
239
+
240
+ return default, True
241
 
242
 
243
  def fallback_scorecard() -> dict[str, Any]:
core/judge_settings.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Difficulty profile loader for PitchFight AI.
2
+
3
+ Reads config/judge_settings.json and exposes typed accessors for:
4
+ - question style (tone, jargon avoidance, max sentences)
5
+ - scoring calibration (floors, ranges per profile)
6
+ - battle phase tone
7
+ - coaching style
8
+
9
+ Safe defaults are embedded here so the app works even if the JSON is missing.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import os
17
+ from typing import Any
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _CONFIG_PATH = os.path.join(
22
+ os.path.dirname(__file__), "..", "config", "judge_settings.json"
23
+ )
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Alias normalization map
27
+ # ---------------------------------------------------------------------------
28
+
29
+ _ALIAS_MAP: dict[str, str] = {
30
+ # practice
31
+ "practice": "practice",
32
+ "easy": "practice",
33
+ "beginner": "practice",
34
+ "student": "practice",
35
+ # judge
36
+ "judge": "judge",
37
+ "medium": "judge",
38
+ "balanced": "judge",
39
+ "hackathon": "judge",
40
+ "high": "judge", # legacy frontend sent "high"
41
+ # investor
42
+ "investor": "investor",
43
+ "hard": "investor",
44
+ "vc": "investor",
45
+ "expert": "investor",
46
+ }
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Hardcoded safe defaults (used when config file is absent or invalid)
50
+ # ---------------------------------------------------------------------------
51
+
52
+ _DEFAULT_SETTINGS: dict[str, Any] = {
53
+ "default_profile": "practice",
54
+ "profiles": {
55
+ "practice": {
56
+ "label": "Practice Mode",
57
+ "description": "Student-friendly pitch practice for first/second-time founders.",
58
+ "profile_display": {
59
+ "pressure_labels": {
60
+ "explore": "Warm-up",
61
+ "pressure": "Focused",
62
+ "close": "Final Practice",
63
+ },
64
+ },
65
+ "question_style": {
66
+ "plain_language": True,
67
+ "avoid_jargon": True,
68
+ "avoid_compound_questions": True,
69
+ "one_question_only": True,
70
+ "max_sentences": 3,
71
+ "tone": "challenging_but_supportive",
72
+ "instruction": (
73
+ "This founder is practicing for the first or second time. "
74
+ "Challenge them enough to help them improve, but do not destroy confidence. "
75
+ "Ask one clear focused question at a time. "
76
+ "Use plain language and avoid jargon-heavy VC terminology."
77
+ ),
78
+ },
79
+ "coaching_style": {
80
+ "tone": "encouraging_actionable",
81
+ "instruction": (
82
+ "Be encouraging but honest. Explain what the founder did right, "
83
+ "then show exactly how to make the answer stronger with one specific number, "
84
+ "example, or proof point."
85
+ ),
86
+ "example": (
87
+ "Your answer touched on the right idea. "
88
+ "Here's how to make it more convincing with one specific number or example."
89
+ ),
90
+ },
91
+ "battle_phase_tone": {
92
+ "explore": "medium",
93
+ "pressure": "medium_high_but_clear",
94
+ "close": "firm_but_supportive",
95
+ },
96
+ "scoring_calibration": {
97
+ "attempted_answer_floor": 35,
98
+ "partial_signal_floor": 40,
99
+ "concrete_signal_floor": 52,
100
+ "non_answer_max": 20,
101
+ "startup_context_max": 45,
102
+ "vague_on_topic_range": [35, 45],
103
+ "one_concrete_signal_range": [52, 62],
104
+ "strong_answer_range": [71, 85],
105
+ "excellent_answer_range": [86, 100],
106
+ },
107
+ },
108
+ "judge": {
109
+ "label": "Judge Mode",
110
+ "description": "Balanced hackathon judge simulation.",
111
+ "profile_display": {
112
+ "pressure_labels": {
113
+ "explore": "Moderate",
114
+ "pressure": "High",
115
+ "close": "Panel Ready",
116
+ },
117
+ },
118
+ "question_style": {
119
+ "plain_language": True,
120
+ "avoid_jargon": True,
121
+ "avoid_compound_questions": True,
122
+ "one_question_only": True,
123
+ "max_sentences": 3,
124
+ "tone": "realistic_hackathon_judge",
125
+ "instruction": (
126
+ "Act like a realistic hackathon judge. Be sharp and specific, "
127
+ "but keep questions understandable. Ask one clear question at a time "
128
+ "and focus on demo strength, novelty, user pain, and feasibility."
129
+ ),
130
+ },
131
+ "coaching_style": {
132
+ "tone": "balanced_judge_feedback",
133
+ "instruction": (
134
+ "Be direct and fair. Highlight what would convince a hackathon judge "
135
+ "and what still needs proof before demo time."
136
+ ),
137
+ "example": (
138
+ "This answer has a useful signal, but a judge still needs to see proof "
139
+ "in the demo. Make the claim measurable and show it quickly."
140
+ ),
141
+ },
142
+ "battle_phase_tone": {
143
+ "explore": "medium_high",
144
+ "pressure": "high_but_fair",
145
+ "close": "firm_judging_panel",
146
+ },
147
+ "scoring_calibration": {
148
+ "attempted_answer_floor": 30,
149
+ "partial_signal_floor": 38,
150
+ "concrete_signal_floor": 48,
151
+ "non_answer_max": 18,
152
+ "startup_context_max": 40,
153
+ "vague_on_topic_range": [30, 42],
154
+ "one_concrete_signal_range": [48, 60],
155
+ "strong_answer_range": [70, 85],
156
+ "excellent_answer_range": [86, 100],
157
+ },
158
+ },
159
+ "investor": {
160
+ "label": "Investor Mode",
161
+ "description": "Harder skeptical VC-style pressure.",
162
+ "profile_display": {
163
+ "pressure_labels": {
164
+ "explore": "High",
165
+ "pressure": "Very High",
166
+ "close": "Investor Pressure",
167
+ },
168
+ },
169
+ "question_style": {
170
+ "plain_language": False,
171
+ "avoid_jargon": False,
172
+ "avoid_compound_questions": False,
173
+ "one_question_only": True,
174
+ "max_sentences": 4,
175
+ "tone": "skeptical_investor",
176
+ "instruction": (
177
+ "Act like a skeptical early-stage investor. Pressure-test market size, "
178
+ "moat, retention, revenue logic, distribution, and why now. "
179
+ "Be tougher than Practice Mode, but still focus on one main pressure point per round."
180
+ ),
181
+ },
182
+ "coaching_style": {
183
+ "tone": "sharp_investor_feedback",
184
+ "instruction": (
185
+ "Be sharper and more business-focused. Explain what would worry an investor "
186
+ "and what proof is needed to reduce that concern."
187
+ ),
188
+ "example": (
189
+ "This answer lacks defensibility. A real investor needs to hear your moat mechanism, "
190
+ "not just what the product does."
191
+ ),
192
+ },
193
+ "battle_phase_tone": {
194
+ "explore": "high",
195
+ "pressure": "very_high",
196
+ "close": "investment_committee",
197
+ },
198
+ "scoring_calibration": {
199
+ "attempted_answer_floor": 25,
200
+ "partial_signal_floor": 35,
201
+ "concrete_signal_floor": 45,
202
+ "non_answer_max": 15,
203
+ "startup_context_max": 35,
204
+ "vague_on_topic_range": [25, 38],
205
+ "one_concrete_signal_range": [45, 58],
206
+ "strong_answer_range": [70, 85],
207
+ "excellent_answer_range": [86, 100],
208
+ },
209
+ },
210
+ },
211
+ }
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Module-level singleton (loaded once)
215
+ # ---------------------------------------------------------------------------
216
+
217
+ _settings: dict[str, Any] | None = None
218
+
219
+
220
+ def load_judge_settings() -> dict[str, Any]:
221
+ """Load and cache judge_settings.json. Falls back to hardcoded defaults."""
222
+ global _settings
223
+ if _settings is not None:
224
+ return _settings
225
+
226
+ path = os.path.abspath(_CONFIG_PATH)
227
+ try:
228
+ with open(path, encoding="utf-8") as f:
229
+ data = json.load(f)
230
+ if not isinstance(data, dict) or "profiles" not in data:
231
+ raise ValueError("Missing 'profiles' key")
232
+ _settings = data
233
+ logger.info("judge_settings: loaded from %s", path)
234
+ except FileNotFoundError:
235
+ logger.warning("judge_settings: config not found at %s — using defaults", path)
236
+ _settings = _DEFAULT_SETTINGS
237
+ except Exception as exc:
238
+ logger.warning("judge_settings: failed to load (%s) — using defaults", exc)
239
+ _settings = _DEFAULT_SETTINGS
240
+
241
+ return _settings
242
+
243
+
244
+ def get_default_profile() -> str:
245
+ s = load_judge_settings()
246
+ return s.get("default_profile", "practice")
247
+
248
+
249
+ def normalize_difficulty(value: str | None) -> str:
250
+ """Map any incoming difficulty string to a canonical profile name.
251
+
252
+ Supports legacy frontend values like "high" → "judge" and aliases like
253
+ "beginner" → "practice". Unknown values fall back to the default profile.
254
+ """
255
+ if not value:
256
+ return get_default_profile()
257
+ key = str(value).strip().lower()
258
+ normalized = _ALIAS_MAP.get(key)
259
+ if normalized:
260
+ return normalized
261
+ # If the value is already a valid profile name, accept it
262
+ s = load_judge_settings()
263
+ if key in s.get("profiles", {}):
264
+ return key
265
+ logger.warning("judge_settings: unknown difficulty %r — using default", value)
266
+ return get_default_profile()
267
+
268
+
269
+ def get_profile(profile_name: str | None) -> dict[str, Any]:
270
+ """Return the full profile dict for the given (or default) profile."""
271
+ s = load_judge_settings()
272
+ name = normalize_difficulty(profile_name)
273
+ profiles = s.get("profiles", {})
274
+ profile = profiles.get(name)
275
+ if not profile:
276
+ logger.warning("judge_settings: profile %r not found — falling back to practice", name)
277
+ profile = profiles.get("practice") or list(profiles.values())[0]
278
+ return profile
279
+
280
+
281
+ def get_question_style(profile_name: str | None) -> dict[str, Any]:
282
+ return get_profile(profile_name).get("question_style", {})
283
+
284
+
285
+ def get_scoring_calibration(profile_name: str | None) -> dict[str, Any]:
286
+ return get_profile(profile_name).get("scoring_calibration", {})
287
+
288
+
289
+ def get_battle_phase_tone(profile_name: str | None, battle_phase: str) -> str:
290
+ tones = get_profile(profile_name).get("battle_phase_tone", {})
291
+ return tones.get(battle_phase, "medium")
292
+
293
+
294
+ def get_coaching_style(profile_name: str | None) -> dict[str, Any]:
295
+ return get_profile(profile_name).get("coaching_style", {})
296
+
297
+
298
+ def get_label(profile_name: str | None) -> str:
299
+ return get_profile(profile_name).get("label", "Practice Mode")
300
+
301
+
302
+ _PRESSURE_FALLBACKS: dict[str, dict[str, str]] = {
303
+ "practice": {"explore": "Warm-up", "pressure": "Focused", "close": "Final Practice"},
304
+ "judge": {"explore": "Moderate", "pressure": "High", "close": "Panel Ready"},
305
+ "investor": {"explore": "High", "pressure": "Very High", "close": "Investor Pressure"},
306
+ }
307
+
308
+
309
+ def get_pressure_display_label(profile_name: str | None, battle_phase: str) -> str:
310
+ """Return a human-readable pressure label for the sidebar.
311
+
312
+ Never returns "Extreme" for Practice Mode.
313
+ Falls back to hardcoded defaults if config is missing.
314
+ """
315
+ profile = get_profile(profile_name)
316
+ display = profile.get("profile_display", {})
317
+ labels = display.get("pressure_labels", {})
318
+ phase_key = battle_phase if battle_phase in ("explore", "pressure", "close") else "explore"
319
+ if labels and phase_key in labels:
320
+ return labels[phase_key]
321
+ canonical = normalize_difficulty(profile_name)
322
+ return _PRESSURE_FALLBACKS.get(canonical, _PRESSURE_FALLBACKS["practice"]).get(
323
+ phase_key, "Warm-up"
324
+ )
core/model_router.py CHANGED
@@ -147,6 +147,167 @@ def generate_scorecard_response(
147
  )
148
 
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  def generate_coaching_response(
151
  messages: list[dict[str, str]],
152
  model_mode: str | None = None,
@@ -201,7 +362,11 @@ def generate_coaching_repair_response(
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
  {
@@ -315,6 +480,230 @@ def generate_scorecard_repair_response(
315
  }
316
 
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  # ---------------------------------------------------------------------------
319
  # Internal helpers
320
  # ---------------------------------------------------------------------------
 
147
  )
148
 
149
 
150
+ def generate_scoring_response(
151
+ messages: list[dict[str, str]],
152
+ model_mode: str | None = None,
153
+ ) -> dict[str, Any]:
154
+ """Route a dimension-scoring-only request (mode=scorecard_scoring).
155
+
156
+ Nemotron judges all 6 dimensions from actual Q&A. Returns scores + best/weakest only.
157
+ Coaching fields (improved_answer, improved_pitch, top_3_questions) are NOT included.
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_scoring"
165
+ )
166
+ return {"ok": True, "model_mode": mode, "provider": "nvidia", "content": content, "error": None}
167
+ except RuntimeError as exc:
168
+ logger.warning("NVIDIA scoring call failed: %s", exc)
169
+ return {"ok": False, "model_mode": mode, "provider": "nvidia", "content": "", "error": str(exc)}
170
+
171
+ return _placeholder_result(mode, "mock", f"Scoring via '{mode}' not implemented.")
172
+
173
+
174
+ def generate_scoring_repair_response(
175
+ raw_bad_content: str,
176
+ model_mode: str | None = None,
177
+ ) -> dict[str, Any]:
178
+ """Repair a broken scoring-only JSON response (mode=scorecard_scoring_repair)."""
179
+ mode = _resolve_mode(model_mode)
180
+
181
+ if mode != "premium_nvidia":
182
+ return _placeholder_result(mode, "mock", "Scoring repair only for premium_nvidia.")
183
+
184
+ repair_messages = [
185
+ {
186
+ "role": "system",
187
+ "content": (
188
+ "You are a JSON formatter. Convert the input into the exact schema below. "
189
+ "Return ONLY valid JSON. First character must be { last must be }. "
190
+ "No markdown. No explanation.\n\n"
191
+ "REQUIRED SCHEMA:\n"
192
+ '{"scores":{"clarity":{"score":0,"reason":"","quote":"","signals_used":[]},'
193
+ '"problem_understanding":{"score":0,"reason":"","quote":"","signals_used":[]},'
194
+ '"market_awareness":{"score":0,"reason":"","quote":"","signals_used":[]},'
195
+ '"differentiation":{"score":0,"reason":"","quote":"","signals_used":[]},'
196
+ '"business_model":{"score":0,"reason":"","quote":"","signals_used":[]},'
197
+ '"objection_handling":{"score":0,"reason":"","quote":"","signals_used":[]}},'
198
+ '"best_answer":"","weakest_answer":"","why_weak":""}'
199
+ ),
200
+ },
201
+ {
202
+ "role": "user",
203
+ "content": "Convert this text into the JSON schema. Output JSON only:\n\n" + raw_bad_content[:4000],
204
+ },
205
+ ]
206
+
207
+ try:
208
+ content = nvidia_client.generate_nemotron_response(repair_messages, mode="scorecard_scoring_repair")
209
+ return {"ok": True, "model_mode": mode, "provider": "nvidia", "content": content, "error": None}
210
+ except RuntimeError as exc:
211
+ logger.warning("NVIDIA scoring repair call failed: %s", exc)
212
+ return {"ok": False, "model_mode": mode, "provider": "nvidia", "content": "", "error": str(exc)}
213
+
214
+
215
+ def generate_full_scorecard_response(
216
+ messages: list[dict[str, str]],
217
+ model_mode: str | None = None,
218
+ ) -> dict[str, Any]:
219
+ """Route a full Nemotron scoring request (mode=scorecard_full).
220
+
221
+ Nemotron judges all 6 dimensions from the actual Q&A conversation.
222
+ Returns the full scorecard JSON including scores + coaching + score_explanation.
223
+ """
224
+ mode = _resolve_mode(model_mode)
225
+
226
+ if mode == "premium_nvidia":
227
+ try:
228
+ content = nvidia_client.generate_nemotron_response(
229
+ messages, mode="scorecard_full"
230
+ )
231
+ return {
232
+ "ok": True,
233
+ "model_mode": mode,
234
+ "provider": "nvidia",
235
+ "content": content,
236
+ "error": None,
237
+ }
238
+ except RuntimeError as exc:
239
+ logger.warning("NVIDIA full scorecard call failed: %s", exc)
240
+ return {
241
+ "ok": False,
242
+ "model_mode": mode,
243
+ "provider": "nvidia",
244
+ "content": "",
245
+ "error": str(exc),
246
+ }
247
+
248
+ return _placeholder_result(mode, "mock", f"Full scorecard via '{mode}' not implemented.")
249
+
250
+
251
+ def generate_full_scorecard_repair_response(
252
+ raw_bad_content: str,
253
+ model_mode: str | None = None,
254
+ ) -> dict[str, Any]:
255
+ """Repair a broken full scorecard JSON using mode=scorecard_full_repair."""
256
+ mode = _resolve_mode(model_mode)
257
+
258
+ if mode != "premium_nvidia":
259
+ return _placeholder_result(mode, "mock", "Full scorecard repair only for premium_nvidia.")
260
+
261
+ repair_messages = [
262
+ {
263
+ "role": "system",
264
+ "content": (
265
+ "You are a JSON formatter. Convert the input into the exact schema below. "
266
+ "Return ONLY valid JSON. First character must be { last must be }. "
267
+ "No markdown. No explanation. No preface.\n\n"
268
+ "REQUIRED SCHEMA (fill all fields, use 0 for missing scores, empty string for text):\n"
269
+ '{"scores":{"clarity":{"score":0,"reason":"","quote":"","signals_used":[]},'
270
+ '"problem_understanding":{"score":0,"reason":"","quote":"","signals_used":[]},'
271
+ '"market_awareness":{"score":0,"reason":"","quote":"","signals_used":[]},'
272
+ '"differentiation":{"score":0,"reason":"","quote":"","signals_used":[]},'
273
+ '"business_model":{"score":0,"reason":"","quote":"","signals_used":[]},'
274
+ '"objection_handling":{"score":0,"reason":"","quote":"","signals_used":[]}},'
275
+ '"best_answer":"","weakest_answer":"","why_weak":"",'
276
+ '"improved_answer":"","improved_pitch":"","top_3_questions":["","",""],'
277
+ '"score_explanation":{"why_you_scored_this":"","what_stopped_80":"",'
278
+ '"answer_to_retry":{"round":null,"attack_tag":"","dimension":"","original_answer":"",'
279
+ '"why_it_hurt":"","retry_advice":"","sample_stronger_answer":""},'
280
+ '"estimated_score_if_fixed":{"current_overall":0,"estimated_new_overall":0,"reason":""}}}'
281
+ ),
282
+ },
283
+ {
284
+ "role": "user",
285
+ "content": "Convert this text into the JSON schema. Output JSON only:\n\n" + raw_bad_content[:5000],
286
+ },
287
+ ]
288
+
289
+ try:
290
+ content = nvidia_client.generate_nemotron_response(
291
+ repair_messages, mode="scorecard_full_repair"
292
+ )
293
+ return {
294
+ "ok": True,
295
+ "model_mode": mode,
296
+ "provider": "nvidia",
297
+ "content": content,
298
+ "error": None,
299
+ }
300
+ except RuntimeError as exc:
301
+ logger.warning("NVIDIA full scorecard repair call failed: %s", exc)
302
+ return {
303
+ "ok": False,
304
+ "model_mode": mode,
305
+ "provider": "nvidia",
306
+ "content": "",
307
+ "error": str(exc),
308
+ }
309
+
310
+
311
  def generate_coaching_response(
312
  messages: list[dict[str, str]],
313
  model_mode: str | None = None,
 
362
  "Return ONLY valid JSON. First character must be { and last must be }. "
363
  "No markdown. No explanation. No preface.\n\n"
364
  "REQUIRED SCHEMA:\n"
365
+ '{"improved_answer":"","improved_pitch":"","top_3_questions":["","",""],'
366
+ '"score_explanation":{"why_you_scored_this":"","what_stopped_80":"",'
367
+ '"answer_to_retry":{"round":null,"attack_tag":"","dimension":"","original_answer":"",'
368
+ '"why_it_hurt":"","retry_advice":"","sample_stronger_answer":""},'
369
+ '"estimated_score_if_fixed":{"current_overall":0,"estimated_new_overall":0,"reason":""}}}'
370
  ),
371
  },
372
  {
 
480
  }
481
 
482
 
483
+ def generate_retry_comparison_response(
484
+ messages: list[dict[str, str]],
485
+ model_mode: str | None = None,
486
+ ) -> dict[str, Any]:
487
+ """Route a retry answer comparison request (mode=retry_comparison)."""
488
+ mode = _resolve_mode(model_mode)
489
+
490
+ if mode == "premium_nvidia":
491
+ try:
492
+ content = nvidia_client.generate_nemotron_response(
493
+ messages, mode="retry_comparison"
494
+ )
495
+ return {
496
+ "ok": True,
497
+ "model_mode": mode,
498
+ "provider": "nvidia",
499
+ "content": content,
500
+ "error": None,
501
+ }
502
+ except RuntimeError as exc:
503
+ logger.warning("NVIDIA retry comparison call failed: %s", exc)
504
+ return {
505
+ "ok": False,
506
+ "model_mode": mode,
507
+ "provider": "nvidia",
508
+ "content": "",
509
+ "error": str(exc),
510
+ }
511
+
512
+ return _placeholder_result(mode, "mock", f"Retry comparison via '{mode}' not implemented.")
513
+
514
+
515
+ def generate_retry_comparison_repair_response(
516
+ raw_bad_content: str,
517
+ model_mode: str | None = None,
518
+ ) -> dict[str, Any]:
519
+ """Repair a broken retry comparison JSON (mode=retry_comparison_repair)."""
520
+ mode = _resolve_mode(model_mode)
521
+
522
+ if mode != "premium_nvidia":
523
+ return _placeholder_result(mode, "mock", "Retry comparison repair only for premium_nvidia.")
524
+
525
+ repair_messages = [
526
+ {
527
+ "role": "system",
528
+ "content": (
529
+ "You are a JSON formatter. Convert the input into the exact schema below. "
530
+ "Return ONLY valid JSON. First character must be { last must be }.\n\n"
531
+ "REQUIRED SCHEMA:\n"
532
+ '{"comparison":{"old_answer_summary":"","new_answer_summary":"","what_improved":"",'
533
+ '"still_missing":"","specific_tip":"","estimated_dimension_before":0,'
534
+ '"estimated_dimension_after":0,"estimated_overall_lift":0,'
535
+ '"verdict":"improved|slightly_improved|needs_more_work"},'
536
+ '"next_practice_prompt":""}'
537
+ ),
538
+ },
539
+ {
540
+ "role": "user",
541
+ "content": "Convert this text into the JSON schema. Output JSON only:\n\n" + raw_bad_content[:4000],
542
+ },
543
+ ]
544
+
545
+ try:
546
+ content = nvidia_client.generate_nemotron_response(
547
+ repair_messages, mode="retry_comparison_repair"
548
+ )
549
+ return {
550
+ "ok": True,
551
+ "model_mode": mode,
552
+ "provider": "nvidia",
553
+ "content": content,
554
+ "error": None,
555
+ }
556
+ except RuntimeError as exc:
557
+ logger.warning("NVIDIA retry comparison repair call failed: %s", exc)
558
+ return {
559
+ "ok": False,
560
+ "model_mode": mode,
561
+ "provider": "nvidia",
562
+ "content": "",
563
+ "error": str(exc),
564
+ }
565
+
566
+
567
+ def _call_nvidia_json_mode(
568
+ messages: list[dict[str, str]],
569
+ nemotron_mode: str,
570
+ model_mode: str | None,
571
+ label: str,
572
+ ) -> dict[str, Any]:
573
+ mode = _resolve_mode(model_mode)
574
+ if mode != "premium_nvidia":
575
+ return _placeholder_result(mode, "mock", f"{label} only for premium_nvidia.")
576
+ try:
577
+ content = nvidia_client.generate_nemotron_response(messages, mode=nemotron_mode)
578
+ return {"ok": True, "model_mode": mode, "provider": "nvidia", "content": content, "error": None}
579
+ except RuntimeError as exc:
580
+ logger.warning("NVIDIA %s call failed: %s", label, exc)
581
+ return {"ok": False, "model_mode": mode, "provider": "nvidia", "content": "", "error": str(exc)}
582
+
583
+
584
+ def _call_nvidia_repair_mode(
585
+ repair_messages: list[dict[str, str]],
586
+ nemotron_mode: str,
587
+ model_mode: str | None,
588
+ label: str,
589
+ ) -> dict[str, Any]:
590
+ mode = _resolve_mode(model_mode)
591
+ if mode != "premium_nvidia":
592
+ return _placeholder_result(mode, "mock", f"{label} repair only for premium_nvidia.")
593
+ try:
594
+ content = nvidia_client.generate_nemotron_response(repair_messages, mode=nemotron_mode)
595
+ return {"ok": True, "model_mode": mode, "provider": "nvidia", "content": content, "error": None}
596
+ except RuntimeError as exc:
597
+ logger.warning("NVIDIA %s repair failed: %s", label, exc)
598
+ return {"ok": False, "model_mode": mode, "provider": "nvidia", "content": "", "error": str(exc)}
599
+
600
+
601
+ def generate_deal_verdict_response(
602
+ messages: list[dict[str, str]],
603
+ model_mode: str | None = None,
604
+ ) -> dict[str, Any]:
605
+ return _call_nvidia_json_mode(messages, "deal_verdict", model_mode, "deal verdict")
606
+
607
+
608
+ def generate_deal_verdict_repair_response(
609
+ raw_bad_content: str,
610
+ model_mode: str | None = None,
611
+ ) -> dict[str, Any]:
612
+ repair_messages = [
613
+ {
614
+ "role": "system",
615
+ "content": (
616
+ "Convert input to JSON. Return ONLY valid JSON.\n"
617
+ '{"judge_reaction":"","deal_opening_offer":"","why_this_verdict":"","next_step_label":""}'
618
+ ),
619
+ },
620
+ {"role": "user", "content": "Output JSON only:\n\n" + raw_bad_content[:4000]},
621
+ ]
622
+ return _call_nvidia_repair_mode(repair_messages, "deal_verdict_repair", model_mode, "deal verdict")
623
+
624
+
625
+ def generate_deal_round_response(
626
+ messages: list[dict[str, str]],
627
+ model_mode: str | None = None,
628
+ ) -> dict[str, Any]:
629
+ mode = _resolve_mode(model_mode)
630
+ if mode == "premium_nvidia":
631
+ try:
632
+ content = nvidia_client.generate_nemotron_response(messages, mode="deal_round")
633
+ return {"ok": True, "model_mode": mode, "provider": "nvidia", "content": content, "error": None}
634
+ except RuntimeError as exc:
635
+ logger.warning("NVIDIA deal round call failed: %s", exc)
636
+ return {"ok": False, "model_mode": mode, "provider": "nvidia", "content": "", "error": str(exc)}
637
+ return _placeholder_result(mode, "mock", f"Deal round via '{mode}' not implemented.")
638
+
639
+
640
+ def generate_deal_scoring_response(
641
+ messages: list[dict[str, str]],
642
+ model_mode: str | None = None,
643
+ ) -> dict[str, Any]:
644
+ """Route a deal dimension-scoring request (mode=deal_scorecard_scoring).
645
+
646
+ Nemotron judges all 6 deal dimensions semantically from the negotiation transcript.
647
+ Returns scores + deal_outcome + best_move + weakest_move only (no coaching text).
648
+ """
649
+ return _call_nvidia_json_mode(messages, "deal_scorecard_scoring", model_mode, "deal scorecard scoring")
650
+
651
+
652
+ def generate_deal_scoring_repair_response(
653
+ raw_bad_content: str,
654
+ model_mode: str | None = None,
655
+ ) -> dict[str, Any]:
656
+ """Repair a broken deal scoring JSON (mode=deal_scorecard_scoring_repair)."""
657
+ repair_messages = [
658
+ {
659
+ "role": "system",
660
+ "content": (
661
+ "You are a JSON formatter. Convert the input into the exact schema below. "
662
+ "Return ONLY valid JSON. First character must be { last must be }. "
663
+ "No markdown. No reasoning. No array.\n\n"
664
+ "REQUIRED SCHEMA:\n"
665
+ '{"scores":{"anchoring":{"score":0,"reason":"","quote":""},'
666
+ '"evidence":{"score":0,"reason":"","quote":""},'
667
+ '"concession_control":{"score":0,"reason":"","quote":""},'
668
+ '"alternatives":{"score":0,"reason":"","quote":""},'
669
+ '"value_articulation":{"score":0,"reason":"","quote":""},'
670
+ '"closing":{"score":0,"reason":"","quote":""}},'
671
+ '"deal_outcome":"balanced","best_move":"","weakest_move":""}'
672
+ ),
673
+ },
674
+ {"role": "user", "content": "Output JSON only:\n\n" + raw_bad_content[:4000]},
675
+ ]
676
+ return _call_nvidia_repair_mode(
677
+ repair_messages, "deal_scorecard_scoring_repair", model_mode, "deal scorecard scoring"
678
+ )
679
+
680
+
681
+ def generate_deal_scorecard_coaching_response(
682
+ messages: list[dict[str, str]],
683
+ model_mode: str | None = None,
684
+ ) -> dict[str, Any]:
685
+ return _call_nvidia_json_mode(messages, "deal_scorecard_coaching", model_mode, "deal scorecard coaching")
686
+
687
+
688
+ def generate_deal_scorecard_repair_response(
689
+ raw_bad_content: str,
690
+ model_mode: str | None = None,
691
+ ) -> dict[str, Any]:
692
+ repair_messages = [
693
+ {
694
+ "role": "system",
695
+ "content": (
696
+ "Convert input to JSON. Return ONLY valid JSON.\n"
697
+ '{"deal_outcome_summary":"","best_move":"","weakest_move":"",'
698
+ '"improved_response":"","top_3_prep_points":["","",""],'
699
+ '"combined_summary":"","next_best_action":""}'
700
+ ),
701
+ },
702
+ {"role": "user", "content": "Output JSON only:\n\n" + raw_bad_content[:5000]},
703
+ ]
704
+ return _call_nvidia_repair_mode(repair_messages, "deal_scorecard_repair", model_mode, "deal scorecard")
705
+
706
+
707
  # ---------------------------------------------------------------------------
708
  # Internal helpers
709
  # ---------------------------------------------------------------------------
core/nvidia_client.py CHANGED
@@ -8,6 +8,7 @@ from __future__ import annotations
8
 
9
  import logging
10
  import os
 
11
  from typing import Any
12
 
13
  from dotenv import load_dotenv
@@ -20,6 +21,22 @@ logger = logging.getLogger(__name__)
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
@@ -40,17 +57,45 @@ _TASK_DEFAULTS: dict[str, dict[str, Any]] = {
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
  },
@@ -68,13 +113,132 @@ _TASK_DEFAULTS: dict[str, dict[str, Any]] = {
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
 
@@ -124,6 +288,181 @@ def health_check() -> dict[str, Any]:
124
  }
125
 
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  def generate_nemotron_response(
128
  messages: list[dict[str, str]],
129
  mode: str = "opponent",
@@ -147,88 +486,49 @@ def generate_nemotron_response(
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
- )
 
8
 
9
  import logging
10
  import os
11
+ import time
12
  from typing import Any
13
 
14
  from dotenv import load_dotenv
 
21
  _DEFAULT_BASE_URL = "https://integrate.api.nvidia.com/v1"
22
  _DEFAULT_MODEL = "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning"
23
 
24
+
25
+ class OmniAudioError(RuntimeError):
26
+ """Structured Omni audio failure — metadata safe for API responses."""
27
+
28
+ def __init__(self, message: str, **meta: Any) -> None:
29
+ super().__init__(message)
30
+ self.meta = meta
31
+
32
+ def to_error_dict(self) -> dict[str, Any]:
33
+ return {
34
+ "error": "Omni audio call failed",
35
+ "detail": str(self),
36
+ **self.meta,
37
+ }
38
+
39
+
40
  # Per-mode settings for nemotron-3-nano-omni-30b-a3b-reasoning.
41
  #
42
  # enable_thinking: True → reasoning model uses internal chain-of-thought
 
57
  "temperature": 0.65,
58
  "top_p": 0.95,
59
  },
60
+ "scorecard_scoring": {
61
+ "enable_thinking": False,
62
+ "reasoning_budget": 0,
63
+ "max_tokens": 1700,
64
+ "temperature": 0.1,
65
+ "top_p": 0.95,
66
+ },
67
+ "scorecard_scoring_repair": {
68
+ "enable_thinking": False,
69
+ "reasoning_budget": 0,
70
+ "max_tokens": 1400,
71
+ "temperature": 0.0,
72
+ "top_p": 0.95,
73
+ },
74
+ "scorecard_full": {
75
+ "enable_thinking": False,
76
+ "reasoning_budget": 0,
77
+ "max_tokens": 2500,
78
+ "temperature": 0.1,
79
+ "top_p": 0.95,
80
+ },
81
+ "scorecard_full_repair": {
82
+ "enable_thinking": False,
83
+ "reasoning_budget": 0,
84
+ "max_tokens": 2200,
85
+ "temperature": 0.0,
86
+ "top_p": 0.95,
87
+ },
88
  "scorecard_coaching": {
89
  "enable_thinking": False,
90
  "reasoning_budget": 0,
91
+ "max_tokens": 2400,
92
  "temperature": 0.2,
93
  "top_p": 0.95,
94
  },
95
  "scorecard_coaching_repair": {
96
  "enable_thinking": False,
97
  "reasoning_budget": 0,
98
+ "max_tokens": 1600,
99
  "temperature": 0.0,
100
  "top_p": 0.95,
101
  },
 
113
  "temperature": 0.1,
114
  "top_p": 0.95,
115
  },
116
+ "voice_extraction": {
117
+ "enable_thinking": False,
118
+ "reasoning_budget": 0,
119
+ "max_tokens": 1800,
120
+ "temperature": 0.1,
121
+ "top_p": 0.95,
122
+ },
123
+ "voice_extraction_repair": {
124
+ "enable_thinking": False,
125
+ "reasoning_budget": 0,
126
+ "max_tokens": 1200,
127
+ "temperature": 0.0,
128
+ "top_p": 0.95,
129
+ },
130
+ "voice_turn": {
131
+ "enable_thinking": False,
132
+ "reasoning_budget": 0,
133
+ "max_tokens": 700,
134
+ "temperature": 0.0,
135
+ "top_p": 0.95,
136
+ },
137
+ "voice_turn_repair": {
138
+ "enable_thinking": False,
139
+ "reasoning_budget": 0,
140
+ "max_tokens": 600,
141
+ "temperature": 0.0,
142
+ "top_p": 0.95,
143
+ },
144
+ "retry_comparison": {
145
+ "enable_thinking": False,
146
+ "reasoning_budget": 0,
147
+ "max_tokens": 1000,
148
+ "temperature": 0.15,
149
+ "top_p": 0.95,
150
+ },
151
+ "retry_comparison_repair": {
152
+ "enable_thinking": False,
153
+ "reasoning_budget": 0,
154
+ "max_tokens": 800,
155
+ "temperature": 0.0,
156
+ "top_p": 0.95,
157
+ },
158
+ "deal_verdict": {
159
+ "enable_thinking": False,
160
+ "reasoning_budget": 0,
161
+ "max_tokens": 1000,
162
+ "temperature": 0.2,
163
+ "top_p": 0.95,
164
+ },
165
+ "deal_verdict_repair": {
166
+ "enable_thinking": False,
167
+ "reasoning_budget": 0,
168
+ "max_tokens": 800,
169
+ "temperature": 0.0,
170
+ "top_p": 0.95,
171
+ },
172
+ "deal_round": {
173
+ "enable_thinking": True,
174
+ "reasoning_budget": 512,
175
+ "max_tokens": 900,
176
+ "temperature": 0.65,
177
+ "top_p": 0.95,
178
+ },
179
+ # Deal phase: semantic dimension scoring (JSON, split call 1 — scores only)
180
+ "deal_scorecard_scoring": {
181
+ "enable_thinking": False,
182
+ "reasoning_budget": 0,
183
+ "max_tokens": 1700,
184
+ "temperature": 0.1,
185
+ "top_p": 0.95,
186
+ },
187
+ "deal_scorecard_scoring_repair": {
188
+ "enable_thinking": False,
189
+ "reasoning_budget": 0,
190
+ "max_tokens": 1300,
191
+ "temperature": 0.0,
192
+ "top_p": 0.95,
193
+ },
194
+ # Deal phase: coaching text (JSON, split call 2 — coaching only)
195
+ "deal_scorecard_coaching": {
196
+ "enable_thinking": False,
197
+ "reasoning_budget": 0,
198
+ "max_tokens": 2200,
199
+ "temperature": 0.2,
200
+ "top_p": 0.95,
201
+ },
202
+ "deal_scorecard_repair": {
203
+ "enable_thinking": False,
204
+ "reasoning_budget": 0,
205
+ "max_tokens": 1200,
206
+ "temperature": 0.0,
207
+ "top_p": 0.95,
208
+ },
209
+ }
210
+
211
+ _VALID_AUDIO_FORMATS = frozenset({"webm", "wav", "mp3", "m4a", "ogg"})
212
+
213
+ _AUDIO_MIME: dict[str, str] = {
214
+ "webm": "audio/webm",
215
+ "wav": "audio/wav",
216
+ "mp3": "audio/mpeg",
217
+ "m4a": "audio/mp4",
218
+ "ogg": "audio/ogg",
219
  }
220
 
221
  # Modes where the response must be JSON — apply safe extraction from reasoning_content if needed
222
  _JSON_MODES: frozenset[str] = frozenset({
223
+ "scorecard_scoring",
224
+ "scorecard_scoring_repair",
225
+ "scorecard_full",
226
+ "scorecard_full_repair",
227
  "scorecard_coaching",
228
  "scorecard_coaching_repair",
229
  "legacy_full_scorecard",
230
+ "voice_extraction",
231
+ "voice_extraction_repair",
232
+ "voice_turn",
233
+ "voice_turn_repair",
234
+ "retry_comparison",
235
+ "retry_comparison_repair",
236
+ "deal_verdict",
237
+ "deal_verdict_repair",
238
+ "deal_scorecard_scoring",
239
+ "deal_scorecard_scoring_repair",
240
+ "deal_scorecard_coaching",
241
+ "deal_scorecard_repair",
242
  })
243
 
244
 
 
288
  }
289
 
290
 
291
+ def _resolve_mode_params(
292
+ mode: str,
293
+ temperature: float | None = None,
294
+ max_tokens: int | None = None,
295
+ ) -> tuple[float, int, float, bool, int]:
296
+ """Return (temp, tokens, top_p, enable_thinking, reasoning_budget) for a mode."""
297
+ defaults = _TASK_DEFAULTS.get(mode, _TASK_DEFAULTS["opponent"])
298
+ temp = temperature if temperature is not None else defaults["temperature"]
299
+ tokens = max_tokens if max_tokens is not None else defaults["max_tokens"]
300
+ top_p: float = defaults.get("top_p", 0.95)
301
+ enable_thinking: bool = defaults.get("enable_thinking", True)
302
+ reasoning_budget: int = defaults.get("reasoning_budget", 0)
303
+ if mode in _JSON_MODES:
304
+ enable_thinking = False
305
+ reasoning_budget = 0
306
+ reasoning_budget = min(reasoning_budget, tokens)
307
+ return temp, tokens, top_p, enable_thinking, reasoning_budget
308
+
309
+
310
+ def _complete_chat(
311
+ client: OpenAI,
312
+ model: str,
313
+ messages: list[dict],
314
+ mode: str,
315
+ temperature: float | None = None,
316
+ max_tokens: int | None = None,
317
+ ) -> str:
318
+ """Shared chat completion with mode-specific token/thinking settings."""
319
+ temp, tokens, top_p, enable_thinking, reasoning_budget = _resolve_mode_params(
320
+ mode, temperature, max_tokens
321
+ )
322
+ completion = client.chat.completions.create(
323
+ model=model,
324
+ messages=messages, # type: ignore[arg-type]
325
+ temperature=temp,
326
+ max_tokens=tokens,
327
+ top_p=top_p,
328
+ extra_body={
329
+ "chat_template_kwargs": {"enable_thinking": enable_thinking},
330
+ "reasoning_budget": reasoning_budget,
331
+ },
332
+ )
333
+ temp_d, tokens_d, _tp, thinking_d, _rb = _resolve_mode_params(mode, temperature, max_tokens)
334
+ msg = completion.choices[0].message
335
+ content = (msg.content or "").strip()
336
+ reasoning = (getattr(msg, "reasoning_content", None) or "").strip()
337
+
338
+ # Diagnostics (no secrets, no full prompt/audio): help spot truncation vs empty content.
339
+ logger.debug(
340
+ "Nemotron mode=%s max_tokens=%s thinking=%s content_len=%d reasoning_present=%s",
341
+ mode, tokens_d, thinking_d, len(content), bool(reasoning),
342
+ )
343
+
344
+ if not content:
345
+ if mode in _JSON_MODES and reasoning:
346
+ extracted = _extract_json_from_reasoning(reasoning)
347
+ if extracted:
348
+ logger.info(
349
+ "Nemotron content empty; extracted JSON block from reasoning_content (mode=%s)",
350
+ mode,
351
+ )
352
+ content = extracted
353
+ else:
354
+ logger.warning(
355
+ "Nemotron content empty; checked reasoning_content fallback (mode=%s, no JSON found)",
356
+ mode,
357
+ )
358
+ elif reasoning:
359
+ logger.warning(
360
+ "Nemotron content empty; checked reasoning_content fallback (mode=%s)",
361
+ mode,
362
+ )
363
+ content = reasoning
364
+
365
+ if not content:
366
+ raise RuntimeError(
367
+ "NVIDIA model returned an empty response. "
368
+ "The reasoning model may need a larger max_tokens budget."
369
+ )
370
+ return content
371
+
372
+
373
+ def call_omni_audio_json(
374
+ prompt: str,
375
+ audio_base64: str,
376
+ audio_format: str,
377
+ mode: str = "voice_extraction",
378
+ timeout: int = 60,
379
+ source_format: str | None = None,
380
+ decoded_bytes: int | None = None,
381
+ ) -> str:
382
+ """Call Nemotron Omni with audio + text prompt; return response text (JSON expected).
383
+
384
+ Raises:
385
+ ValueError: invalid audio input
386
+ OmniAudioError: API rejected audio or call failed
387
+ """
388
+ if not audio_base64 or not str(audio_base64).strip():
389
+ raise ValueError("audio_base64 is required and must be non-empty")
390
+ fmt = str(audio_format or "").strip().lower().lstrip(".")
391
+ if fmt not in _VALID_AUDIO_FORMATS:
392
+ raise ValueError(
393
+ f"audio_format must be one of: {', '.join(sorted(_VALID_AUDIO_FORMATS))}"
394
+ )
395
+
396
+ api_key, base_url, model = _get_config()
397
+ mime = _AUDIO_MIME[fmt]
398
+ audio_url = f"data:{mime};base64,{audio_base64.strip()}"
399
+
400
+ logger.info(
401
+ "nvidia_client: omni audio call mode=%s format=%s source_format=%s bytes=%s mime=%s",
402
+ mode,
403
+ fmt,
404
+ source_format or fmt,
405
+ decoded_bytes if decoded_bytes is not None else "unknown",
406
+ mime,
407
+ )
408
+
409
+ messages = [
410
+ {
411
+ "role": "user",
412
+ "content": [
413
+ {"type": "text", "text": prompt},
414
+ {"type": "audio_url", "audio_url": {"url": audio_url}},
415
+ ],
416
+ }
417
+ ]
418
+
419
+ client = OpenAI(api_key=api_key, base_url=base_url, timeout=timeout)
420
+ try:
421
+ return _complete_chat(client, model, messages, mode)
422
+ except APIStatusError as exc:
423
+ detail = exc.message or str(exc)
424
+ logger.warning(
425
+ "NVIDIA Omni audio status error %s: %s (format=%s bytes=%s)",
426
+ exc.status_code,
427
+ detail,
428
+ fmt,
429
+ decoded_bytes,
430
+ )
431
+ raise OmniAudioError(
432
+ detail,
433
+ audio_format_sent=fmt,
434
+ source_format=source_format or fmt,
435
+ decoded_bytes=decoded_bytes,
436
+ mode=mode,
437
+ suggestion=(
438
+ "Audio was normalized to WAV when possible. "
439
+ "Verify NVIDIA audio payload schema or install ffmpeg for conversion."
440
+ ),
441
+ ) from exc
442
+ except (APITimeoutError, APIConnectionError) as exc:
443
+ logger.warning("NVIDIA Omni audio connection error: %s", type(exc).__name__)
444
+ raise OmniAudioError(
445
+ f"Connection error: {type(exc).__name__}",
446
+ audio_format_sent=fmt,
447
+ source_format=source_format or fmt,
448
+ decoded_bytes=decoded_bytes,
449
+ mode=mode,
450
+ suggestion="Retry the voice request or check NVIDIA API connectivity.",
451
+ ) from exc
452
+ except RuntimeError:
453
+ raise
454
+ except Exception as exc:
455
+ logger.warning("NVIDIA Omni audio unexpected error: %s", type(exc).__name__)
456
+ raise OmniAudioError(
457
+ type(exc).__name__,
458
+ audio_format_sent=fmt,
459
+ source_format=source_format or fmt,
460
+ decoded_bytes=decoded_bytes,
461
+ mode=mode,
462
+ suggestion="Verify NVIDIA audio payload schema or audio conversion.",
463
+ ) from exc
464
+
465
+
466
  def generate_nemotron_response(
467
  messages: list[dict[str, str]],
468
  mode: str = "opponent",
 
486
  RuntimeError: on missing key or any API failure (clean message, no key leak).
487
  """
488
  api_key, base_url, model = _get_config()
489
+ client = OpenAI(api_key=api_key, base_url=base_url, timeout=timeout)
490
 
491
+ # One retry on transient server/rate errors only — never retry 4xx (e.g. bad audio 400).
492
+ for attempt in range(2):
493
+ try:
494
+ return _complete_chat(client, model, messages, mode, temperature, max_tokens)
 
 
 
495
 
496
+ except APITimeoutError:
497
+ logger.warning("NVIDIA API timed out after %ds (mode=%s)", timeout, mode)
498
+ raise RuntimeError(
499
+ f"NVIDIA Nemotron request timed out after {timeout}s. "
500
+ "Check your connection or increase timeout."
501
+ )
502
+ except APIConnectionError as exc:
503
+ if attempt == 0:
504
+ logger.warning("NVIDIA connection error (mode=%s) — retrying once", mode)
505
+ time.sleep(0.8)
506
+ continue
507
+ logger.warning("NVIDIA API connection error: %s", exc)
508
+ raise RuntimeError(
509
+ "Could not connect to NVIDIA API. "
510
+ "Verify NVIDIA_BASE_URL and your network connection."
511
+ )
512
+ except APIStatusError as exc:
513
+ transient = exc.status_code in (429, 500, 502, 503)
514
+ if transient and attempt == 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  logger.warning(
516
+ "NVIDIA transient HTTP %s (mode=%s) — retrying once", exc.status_code, mode
 
517
  )
518
+ time.sleep(0.8)
519
+ continue
520
+ logger.warning(
521
+ "NVIDIA API status error %s (mode=%s): %s", exc.status_code, mode, exc.message
522
+ )
523
  raise RuntimeError(
524
+ f"NVIDIA API returned HTTP {exc.status_code}. "
525
+ "Check your NVIDIA_API_KEY and model ID."
526
+ )
527
+ except Exception as exc:
528
+ logger.warning("NVIDIA API unexpected error (mode=%s): %s", mode, type(exc).__name__)
529
+ raise RuntimeError(
530
+ f"NVIDIA model call failed ({type(exc).__name__}). See server logs."
531
  )
532
 
533
+ # Unreachable (loop either returns or raises) — defensive.
534
+ raise RuntimeError("NVIDIA model call failed after retry.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
core/output_sanitizer.py CHANGED
@@ -11,6 +11,7 @@ _LEAKAGE_PATTERNS = [
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",
@@ -21,6 +22,11 @@ _LEAKAGE_PATTERNS = [
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(
@@ -33,6 +39,28 @@ _SAFE_FALLBACK = (
33
  )
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def sanitize_model_output(text: str) -> str:
37
  """Remove instruction-leakage lines from Nemotron judge output.
38
 
@@ -43,7 +71,6 @@ def sanitize_model_output(text: str) -> str:
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:
@@ -56,7 +83,6 @@ def sanitize_model_output(text: str) -> str:
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
 
 
11
  r"as instructed\b",
12
  r"my instructions\b",
13
  r"according to my system prompt\b",
14
+ r"according to the system prompt\b",
15
  r"i am supposed to\b",
16
  r"i should follow\b",
17
  r"the rules say\b",
 
22
  r"i should\b.*\binstructions?\b",
23
  r"per the instructions?\b",
24
  r"based on the instructions?\b",
25
+ r"the schema requires\b",
26
+ r"we must return json\b",
27
+ r"need to output\b",
28
+ r"first[,\s]+we need\b",
29
+ r"i should\b",
30
  ]
31
 
32
  _LEAKAGE_RE = re.compile(
 
39
  )
40
 
41
 
42
+ def check_leakage(text: str) -> dict:
43
+ """Return a structured leakage check result.
44
+
45
+ Returns:
46
+ {"ok": True} — no leakage detected
47
+ {"ok": False, "matched_pattern": str, "excerpt": str} — leakage found
48
+ """
49
+ if not text:
50
+ return {"ok": True}
51
+ m = _LEAKAGE_RE.search(text)
52
+ if m:
53
+ start = max(0, m.start() - 20)
54
+ end = min(len(text), m.end() + 40)
55
+ excerpt = text[start:end].replace("\n", " ").strip()
56
+ return {
57
+ "ok": False,
58
+ "matched_pattern": m.group(0),
59
+ "excerpt": excerpt,
60
+ }
61
+ return {"ok": True}
62
+
63
+
64
  def sanitize_model_output(text: str) -> str:
65
  """Remove instruction-leakage lines from Nemotron judge output.
66
 
 
71
  if not text:
72
  return _SAFE_FALLBACK
73
 
 
74
  lines = text.splitlines()
75
  clean_lines: list[str] = []
76
  for line in lines:
 
83
 
84
  result = " ".join(clean_lines).strip()
85
 
 
86
  if len(result.split()) < 4:
87
  return _SAFE_FALLBACK
88
 
core/persona_builder.py CHANGED
@@ -2,6 +2,8 @@
2
 
3
  from __future__ import annotations
4
 
 
 
5
  PERSONA_LABELS = {
6
  "skeptical_vc": "Skeptical VC",
7
  "technical_judge": "Technical Judge",
@@ -12,19 +14,36 @@ PERSONA_LABELS = {
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.
@@ -32,6 +51,8 @@ Behavior rules:
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 = {
@@ -52,8 +73,11 @@ Behavior rules:
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}
@@ -62,5 +86,8 @@ Why AI: {why_ai}
62
 
63
  {focus}
64
 
 
 
 
65
  {rules}
66
  """
 
2
 
3
  from __future__ import annotations
4
 
5
+ from core.judge_settings import get_question_style, normalize_difficulty
6
+
7
  PERSONA_LABELS = {
8
  "skeptical_vc": "Skeptical VC",
9
  "technical_judge": "Technical Judge",
 
14
  def build_persona_prompt(
15
  persona: str,
16
  startup: dict,
17
+ difficulty: str = "practice",
18
  ) -> str:
19
+ """Build a system prompt for the selected opponent persona.
20
+
21
+ The difficulty argument accepts any alias (e.g. "high", "practice",
22
+ "beginner") — it is normalized to a canonical profile internally.
23
+ question_style.instruction from the profile is injected so Nemotron
24
+ adjusts wording complexity, jargon level, and tone accordingly.
25
+ """
26
+ profile_name = normalize_difficulty(difficulty)
27
+ qs = get_question_style(profile_name)
28
+
29
  label = PERSONA_LABELS.get(persona, "Tough Judge")
30
  name = startup.get("name", "this startup")
31
  problem = startup.get("problem", "")
32
  solution = startup.get("solution", "")
33
  why_ai = startup.get("why_ai", "")
34
 
35
+ # Behavior rules shared across all personas
36
+ max_sentences = qs.get("max_sentences", 3)
37
+ avoid_jargon = qs.get("avoid_jargon", False)
38
+ jargon_note = (
39
+ "\n- FORBIDDEN WORDS for this profile: unit economics, contribution margin, defensibility, "
40
+ "TAM, SAM, SOM, moat, CAC, LTV, load-bearing, demonstrably, quantify match accuracy, "
41
+ "precision threshold. Use plain student-friendly language instead."
42
+ if avoid_jargon else ""
43
+ )
44
+ rules = f"""Behavior rules:
45
+ - Ask one question at a time — never ask two questions in a single response.
46
+ - Keep responses under {max_sentences} sentences.
47
  - Reference the founder's previous answer when pushing back.
48
  - Do not give advice during the battle.
49
  - Do not compliment the founder.
 
51
  - Raise difficulty after strong answers.
52
  - Stay in character at all times.
53
  - Be firm but not abusive.
54
+ - Use plain, clear language unless the difficulty profile explicitly allows jargon.{jargon_note}
55
+ - Voice-style or casual answers that contain concrete numbers or validation still deserve credit — do not dismiss them for tone.
56
  """.strip()
57
 
58
  persona_focus = {
 
73
 
74
  focus = persona_focus.get(persona, persona_focus["hackathon_judge"])
75
 
76
+ # Difficulty-specific question instruction from config
77
+ question_instruction = qs.get("instruction", "")
78
+
79
  return f"""You are {label}, a tough pitch opponent in PitchFight AI.
80
+ Difficulty profile: {profile_name}
81
 
82
  Startup: {name}
83
  Problem: {problem}
 
86
 
87
  {focus}
88
 
89
+ QUESTION STYLE ({profile_name}):
90
+ {question_instruction}
91
+
92
  {rules}
93
  """
core/retry_handler.py ADDED
@@ -0,0 +1,522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Retry weakest-question drill handler (Phase 8)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import uuid
8
+ from datetime import datetime, timezone
9
+ from typing import Any
10
+
11
+ from core.claim_extractor import extract_concrete_signals
12
+ from core.judge_settings import get_label, normalize_difficulty
13
+ from core.json_utils import parse_model_json, sanitize_for_log, _score_label
14
+ from core.scoring_engine import _sync_overall_to_dimensions
15
+ from core import model_router
16
+ from core.deal_verdict import build_judge_verdict
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _VALID_VERDICTS = frozenset({"improved", "slightly_improved", "needs_more_work"})
21
+
22
+ _NON_ANSWER_RE = re.compile(
23
+ r"^(ok|yeah|yes|no|idk|i don'?t know|not sure|maybe|n/?a)\.?$",
24
+ re.IGNORECASE,
25
+ )
26
+
27
+ _DIM_RETRY_QUESTIONS: dict[str, str] = {
28
+ "clarity": (
29
+ "Explain your product again in one clear sentence. "
30
+ "Who is it for, what does it do, and what outcome does it create?"
31
+ ),
32
+ "problem_understanding": (
33
+ "Give one specific example that proves this user pain is real and repeated."
34
+ ),
35
+ "market_awareness": (
36
+ "Name your first target segment and one number that proves this market is worth starting with."
37
+ ),
38
+ "differentiation": (
39
+ "Why would someone choose your product over existing alternatives? "
40
+ "Give one concrete mechanism or proof point."
41
+ ),
42
+ "business_model": (
43
+ "Who pays, how much do they pay, and why does the math work?"
44
+ ),
45
+ "objection_handling": (
46
+ "Answer the judge's objection directly using one specific number, example, or proof point."
47
+ ),
48
+ }
49
+
50
+
51
+ def build_local_retry_question(answer_to_retry: dict) -> str:
52
+ """Build a coaching retry question from dimension when original judge text is missing."""
53
+ dim = str(answer_to_retry.get("dimension", "")).strip().lower()
54
+ return _DIM_RETRY_QUESTIONS.get(
55
+ dim,
56
+ _DIM_RETRY_QUESTIONS["objection_handling"],
57
+ )
58
+
59
+
60
+ def _find_original_question(
61
+ session: dict,
62
+ round_num: int | None,
63
+ attack_tag: str,
64
+ ) -> str:
65
+ """Locate the judge question that prompted the weak answer."""
66
+ history = session.get("history", [])
67
+ if round_num and int(round_num) > 0:
68
+ target = int(round_num)
69
+ user_count = 0
70
+ for idx, msg in enumerate(history):
71
+ if msg.get("role") != "user":
72
+ continue
73
+ user_count += 1
74
+ if user_count == target:
75
+ for j in range(idx - 1, -1, -1):
76
+ if history[j].get("role") == "assistant":
77
+ return str(history[j].get("content", "")).strip()
78
+ break
79
+
80
+ tag_norm = str(attack_tag or "").lower().replace("_", " ").strip()
81
+ if tag_norm:
82
+ for msg in reversed(history):
83
+ if msg.get("role") != "assistant":
84
+ continue
85
+ msg_tag = str(msg.get("attack_tag", "")).lower().replace("_", " ").strip()
86
+ if msg_tag and (tag_norm in msg_tag or msg_tag in tag_norm):
87
+ return str(msg.get("content", "")).strip()
88
+
89
+ for msg in reversed(history):
90
+ if msg.get("role") == "assistant":
91
+ return str(msg.get("content", "")).strip()
92
+ return ""
93
+
94
+
95
+ def _dimension_score(scorecard: dict, dimension: str) -> int:
96
+ scores = scorecard.get("scores") or {}
97
+ dim_data = scores.get(dimension) or {}
98
+ try:
99
+ return int(dim_data.get("score", 30))
100
+ except (TypeError, ValueError):
101
+ return 30
102
+
103
+
104
+ def start_retry_drill(session: dict) -> dict[str, Any]:
105
+ """Prepare a retry drill from the latest scorecard answer_to_retry."""
106
+ scorecard = session.get("latest_scorecard")
107
+ if not scorecard:
108
+ return {"error": "No scorecard found. End a battle before retrying."}
109
+
110
+ se = scorecard.get("score_explanation") or {}
111
+ atr = se.get("answer_to_retry") or {}
112
+ dimension = str(atr.get("dimension", "")).strip()
113
+ if not dimension:
114
+ return {"error": "No answer to retry found in scorecard."}
115
+
116
+ session_id = str(session.get("session_id", ""))
117
+ attack_tag = str(atr.get("attack_tag", ""))
118
+ round_num = atr.get("round")
119
+ original_answer = str(atr.get("original_answer", ""))
120
+ why_it_hurt = str(atr.get("why_it_hurt", ""))
121
+ sample_stronger = str(atr.get("sample_stronger_answer", ""))
122
+
123
+ original_question = _find_original_question(session, round_num, attack_tag)
124
+ retry_question = original_question or build_local_retry_question(atr)
125
+
126
+ difficulty_profile = session.get("difficulty_profile") or normalize_difficulty(
127
+ session.get("difficulty", "practice")
128
+ )
129
+ difficulty_label = session.get("difficulty_label") or get_label(difficulty_profile)
130
+
131
+ retry_id = str(uuid.uuid4())
132
+ drill = {
133
+ "retry_id": retry_id,
134
+ "created_at": datetime.now(timezone.utc).isoformat(),
135
+ "source": "scorecard_path_to_80",
136
+ "dimension": dimension,
137
+ "attack_tag": attack_tag,
138
+ "original_question": original_question,
139
+ "retry_question": retry_question,
140
+ "original_answer": original_answer,
141
+ "why_it_hurt": why_it_hurt,
142
+ "sample_stronger_answer": sample_stronger,
143
+ "input_mode": "",
144
+ "retry_answer": "",
145
+ "result": {},
146
+ "dimension_score_before": _dimension_score(scorecard, dimension),
147
+ }
148
+ session.setdefault("retry_drills", {})[retry_id] = drill
149
+
150
+ return {
151
+ "session_id": session_id,
152
+ "retry_id": retry_id,
153
+ "retry_question": retry_question,
154
+ "original_question": original_question,
155
+ "original_answer": original_answer,
156
+ "dimension": dimension,
157
+ "attack_tag": attack_tag,
158
+ "why_it_hurt": why_it_hurt,
159
+ "sample_stronger_answer": sample_stronger,
160
+ "difficulty_profile": difficulty_profile,
161
+ "difficulty_label": difficulty_label,
162
+ }
163
+
164
+
165
+ def _answer_has_signals(text: str) -> bool:
166
+ sigs = extract_concrete_signals({
167
+ "history": [{"role": "user", "content": text}],
168
+ "startup": {},
169
+ })
170
+ return sigs.get("signal_count", 0) > 0 or bool(re.search(r"\d", text))
171
+
172
+
173
+ def build_local_retry_fallback(
174
+ original_answer: str,
175
+ retry_answer: str,
176
+ dimension: str,
177
+ dimension_before: int = 30,
178
+ ) -> dict[str, Any]:
179
+ """Local comparison when Nemotron is unavailable."""
180
+ original = original_answer.strip()
181
+ retry = retry_answer.strip()
182
+ before = max(0, min(100, int(dimension_before)))
183
+
184
+ if not retry or _NON_ANSWER_RE.match(retry) or len(retry.split()) < 4:
185
+ after = before
186
+ verdict = "needs_more_work"
187
+ what_improved = "The retry answer was too brief or did not address the question."
188
+ still_missing = "A specific fact, number, user example, or mechanism is still missing."
189
+ tip = build_local_retry_question({"dimension": dimension})
190
+ elif _answer_has_signals(retry) and len(retry) > len(original) + 8:
191
+ gain = min(26, max(12, len(retry.split()) // 2))
192
+ after = min(before + gain, 78)
193
+ verdict = "improved" if gain >= 12 else "slightly_improved"
194
+ what_improved = "You added concrete evidence or specifics that were missing before."
195
+ still_missing = (
196
+ "Tighten the answer further with one sharper proof point tied to the judge's question."
197
+ if after < 55 else "Good progress — add one more proof point to make it investor-ready."
198
+ )
199
+ tip = f"Lead with your strongest number or example when answering {dimension.replace('_', ' ')} questions."
200
+ elif len(retry) > len(original) + 4:
201
+ after = min(before + 8, 58)
202
+ verdict = "slightly_improved" if after > before else "needs_more_work"
203
+ what_improved = "The retry answer is more complete, but proof is still thin."
204
+ still_missing = "Add one number, named user segment, or competitor contrast."
205
+ tip = build_local_retry_question({"dimension": dimension})
206
+ else:
207
+ after = before if len(retry) <= len(original) else min(before + 5, 50)
208
+ verdict = "needs_more_work" if after == before else "slightly_improved"
209
+ what_improved = "Some extra detail was added, but the core objection may still be open."
210
+ still_missing = "Answer the exact question with one verifiable fact or example."
211
+ tip = build_local_retry_question({"dimension": dimension})
212
+
213
+ overall_lift = max(0, min(15, int((after - before) * 0.45)))
214
+ if overall_lift < 4 and after > before:
215
+ overall_lift = 4
216
+
217
+ return {
218
+ "comparison": {
219
+ "old_answer_summary": original[:200] or "No substantive prior answer.",
220
+ "new_answer_summary": retry[:200],
221
+ "what_improved": what_improved,
222
+ "still_missing": still_missing,
223
+ "specific_tip": tip,
224
+ "estimated_dimension_before": before,
225
+ "estimated_dimension_after": after,
226
+ "estimated_overall_lift": overall_lift,
227
+ "verdict": verdict,
228
+ },
229
+ "next_practice_prompt": build_local_retry_question({"dimension": dimension}),
230
+ }
231
+
232
+
233
+ def _build_retry_comparison_messages(
234
+ session: dict,
235
+ drill: dict,
236
+ retry_answer: str,
237
+ ) -> list[dict[str, str]]:
238
+ startup = session.get("startup", {}) or {}
239
+ scorecard = session.get("latest_scorecard") or {}
240
+ difficulty_profile = session.get("difficulty_profile") or "practice"
241
+ difficulty_label = session.get("difficulty_label") or get_label(difficulty_profile)
242
+ dim = drill.get("dimension", "")
243
+ dim_before = drill.get("dimension_score_before", _dimension_score(scorecard, dim))
244
+
245
+ startup_lines = [
246
+ f"Name: {startup.get('name', '')}",
247
+ f"Problem: {startup.get('problem', '')}",
248
+ f"Solution: {startup.get('solution', '')}",
249
+ f"Traction: {startup.get('traction', '')}",
250
+ ]
251
+
252
+ system = (
253
+ "You are a startup pitch coach comparing an old weak answer to a new retry answer.\n"
254
+ "You are NOT rescoring the whole battle — only one dimension.\n"
255
+ "Be specific and coaching-oriented. Do not overpraise. Do not hallucinate facts.\n"
256
+ "Use only the provided text. Return ONLY valid JSON.\n\n"
257
+ "REQUIRED JSON:\n"
258
+ '{"comparison":{"old_answer_summary":"","new_answer_summary":"","what_improved":"",'
259
+ '"still_missing":"","specific_tip":"","estimated_dimension_before":0,'
260
+ '"estimated_dimension_after":0,"estimated_overall_lift":0,'
261
+ '"verdict":"improved|slightly_improved|needs_more_work"},'
262
+ '"next_practice_prompt":""}\n\n'
263
+ "Rules:\n"
264
+ f"- estimated_dimension_before should be near {dim_before}.\n"
265
+ "- estimated_dimension_after must be realistic (do not jump above 75 unless strong proof).\n"
266
+ "- estimated_overall_lift usually 3–12 points.\n"
267
+ "- Each text field: 1–2 sentences max.\n"
268
+ "- next_practice_prompt: one coaching question only.\n"
269
+ "- verdict must be improved, slightly_improved, or needs_more_work."
270
+ )
271
+
272
+ user = (
273
+ f"Difficulty: {difficulty_label} ({difficulty_profile})\n"
274
+ f"Dimension: {dim}\n"
275
+ f"Attack tag: {drill.get('attack_tag', '')}\n\n"
276
+ f"Startup context:\n" + "\n".join(startup_lines) + "\n\n"
277
+ f"Original judge question:\n{drill.get('original_question') or drill.get('retry_question', '')}\n\n"
278
+ f"Retry question:\n{drill.get('retry_question', '')}\n\n"
279
+ f"Original weak answer:\n{drill.get('original_answer', '')}\n\n"
280
+ f"Why it hurt:\n{drill.get('why_it_hurt', '')}\n\n"
281
+ f"Sample stronger direction:\n{drill.get('sample_stronger_answer', '')}\n\n"
282
+ f"New retry answer:\n{retry_answer}\n"
283
+ )
284
+
285
+ return [
286
+ {"role": "system", "content": system},
287
+ {"role": "user", "content": user},
288
+ ]
289
+
290
+
291
+ def _normalize_comparison_result(
292
+ parsed: dict,
293
+ drill: dict,
294
+ original_answer: str,
295
+ retry_answer: str,
296
+ ) -> dict[str, Any]:
297
+ comp = parsed.get("comparison") if isinstance(parsed.get("comparison"), dict) else parsed
298
+ if not isinstance(comp, dict):
299
+ raise ValueError("missing comparison object")
300
+
301
+ before = drill.get("dimension_score_before", 30)
302
+ try:
303
+ est_before = int(comp.get("estimated_dimension_before", before))
304
+ except (TypeError, ValueError):
305
+ est_before = before
306
+ try:
307
+ est_after = int(comp.get("estimated_dimension_after", est_before))
308
+ except (TypeError, ValueError):
309
+ est_after = est_before
310
+
311
+ est_before = max(0, min(100, est_before))
312
+ est_after = max(est_before, min(82, est_after))
313
+ if est_after < est_before:
314
+ est_after = est_before
315
+
316
+ verdict = str(comp.get("verdict", "needs_more_work")).strip().lower()
317
+ if verdict not in _VALID_VERDICTS:
318
+ verdict = "slightly_improved" if est_after > est_before else "needs_more_work"
319
+
320
+ try:
321
+ lift = int(comp.get("estimated_overall_lift", 0))
322
+ except (TypeError, ValueError):
323
+ lift = max(0, int((est_after - est_before) * 0.35))
324
+ lift = max(0, min(15, lift))
325
+ if est_after > est_before and lift < 4:
326
+ lift = 4
327
+
328
+ return {
329
+ "comparison": {
330
+ "old_answer_summary": str(comp.get("old_answer_summary", original_answer[:200]))[:300],
331
+ "new_answer_summary": str(comp.get("new_answer_summary", retry_answer[:200]))[:300],
332
+ "what_improved": str(comp.get("what_improved", ""))[:300],
333
+ "still_missing": str(comp.get("still_missing", ""))[:300],
334
+ "specific_tip": str(comp.get("specific_tip", ""))[:300],
335
+ "estimated_dimension_before": est_before,
336
+ "estimated_dimension_after": est_after,
337
+ "estimated_overall_lift": lift,
338
+ "verdict": verdict,
339
+ },
340
+ "next_practice_prompt": str(
341
+ parsed.get("next_practice_prompt")
342
+ or build_local_retry_question({"dimension": drill.get("dimension", "")})
343
+ )[:300],
344
+ }
345
+
346
+
347
+ def call_nemotron_retry_comparison(
348
+ session: dict,
349
+ drill: dict,
350
+ retry_answer: str,
351
+ model_mode: str | None = None,
352
+ ) -> dict[str, Any] | None:
353
+ """Call Nemotron to compare old vs new retry answer. Returns None on failure."""
354
+ messages = _build_retry_comparison_messages(session, drill, retry_answer)
355
+ resolved = model_mode or session.get("model_mode") or "premium_nvidia"
356
+ result = model_router.generate_retry_comparison_response(messages, model_mode=resolved)
357
+ if not result.get("ok") or not result.get("content"):
358
+ logger.warning("retry_handler: Nemotron comparison failed — %s", result.get("error"))
359
+ return None
360
+
361
+ raw = result["content"]
362
+ parsed, _ = parse_model_json(raw)
363
+ if not isinstance(parsed, dict) or not parsed:
364
+ repair = model_router.generate_retry_comparison_repair_response(raw, model_mode=resolved)
365
+ if repair.get("ok") and repair.get("content"):
366
+ parsed, _ = parse_model_json(repair["content"])
367
+ if not isinstance(parsed, dict) or not parsed:
368
+ logger.warning(
369
+ "retry_handler: comparison JSON parse failed preview=%r",
370
+ sanitize_for_log(raw),
371
+ )
372
+ return None
373
+
374
+ try:
375
+ return _normalize_comparison_result(
376
+ parsed, drill, drill.get("original_answer", ""), retry_answer
377
+ )
378
+ except ValueError as exc:
379
+ logger.warning("retry_handler: comparison normalize failed — %s", exc)
380
+ return None
381
+
382
+
383
+ def apply_retry_to_scorecard(
384
+ session: dict,
385
+ drill: dict,
386
+ comparison: dict,
387
+ ) -> dict[str, Any] | None:
388
+ """Apply retry improvement to stored scorecard so UI reflects the new score."""
389
+ scorecard = session.get("latest_scorecard")
390
+ if not scorecard or not isinstance(scorecard, dict):
391
+ return None
392
+
393
+ dim = str(drill.get("dimension", "")).strip()
394
+ if not dim:
395
+ return None
396
+
397
+ try:
398
+ after_dim = int(comparison.get("estimated_dimension_after", 0))
399
+ lift = int(comparison.get("estimated_overall_lift", 0))
400
+ except (TypeError, ValueError):
401
+ return None
402
+
403
+ verdict = str(comparison.get("verdict", "")).lower()
404
+ if verdict == "needs_more_work" and after_dim <= int(drill.get("dimension_score_before", 0)):
405
+ return scorecard
406
+
407
+ scores = scorecard.get("scores") or {}
408
+ dim_data = scores.get(dim)
409
+
410
+ # Capture the overall and dimension-sum BEFORE the update so we can apply the
411
+ # improvement as a delta. This preserves any offset baked into the displayed overall
412
+ # (e.g. the Practice nudge) instead of silently dropping it on a pure-mean recompute —
413
+ # which previously made a real dimension gain look like "overall didn't change".
414
+ old_overall = int(scorecard.get("overall", 0) or 0)
415
+ n_dims = len(scores) or 1
416
+ old_sum = sum(int(v.get("score", 0)) for v in scores.values())
417
+
418
+ updated = False
419
+ if isinstance(dim_data, dict) and after_dim > int(dim_data.get("score", 0)):
420
+ dim_data = dict(dim_data)
421
+ dim_data["score"] = after_dim
422
+ dim_data["label"] = _score_label(after_dim)
423
+ improved = str(comparison.get("what_improved", "")).strip()
424
+ if improved:
425
+ dim_data["reason"] = improved[:280]
426
+ retry_text = str(drill.get("retry_answer", "")).strip()
427
+ if retry_text:
428
+ dim_data["quote"] = retry_text[:200]
429
+ scores[dim] = dim_data
430
+ scorecard["scores"] = scores
431
+ updated = True
432
+
433
+ if updated:
434
+ new_sum = sum(int(v.get("score", 0)) for v in scores.values())
435
+ delta = round((new_sum - old_sum) / n_dims)
436
+ new_overall = max(0, min(100, old_overall + delta))
437
+ scorecard["overall"] = new_overall
438
+ scorecard["overall_label"] = _score_label(new_overall)
439
+ # Real lift the UI can trust (matches the overall it now displays).
440
+ actual_lift = new_overall - old_overall
441
+ else:
442
+ new_overall = old_overall
443
+ actual_lift = 0
444
+
445
+ se = dict(scorecard.get("score_explanation") or {})
446
+ esif = dict(se.get("estimated_score_if_fixed") or {})
447
+ esif["current_overall"] = new_overall
448
+ esif["estimated_new_overall"] = min(95, max(new_overall + 4, int(esif.get("estimated_new_overall", new_overall))))
449
+ se["estimated_score_if_fixed"] = esif
450
+ atr = dict(se.get("answer_to_retry") or {})
451
+ if drill.get("retry_answer"):
452
+ atr["original_answer"] = str(drill["retry_answer"])[:300]
453
+ se["answer_to_retry"] = atr
454
+ scorecard["score_explanation"] = se
455
+
456
+ if drill.get("retry_answer"):
457
+ scorecard["weakest_answer"] = str(drill["retry_answer"])[:400]
458
+
459
+ scorecard["retry_applied"] = True
460
+ scorecard["retry_dimension"] = dim
461
+ scorecard["retry_overall_lift"] = actual_lift
462
+ session["latest_scorecard"] = scorecard
463
+ return scorecard
464
+
465
+
466
+ def evaluate_retry_answer(
467
+ session: dict,
468
+ retry_id: str,
469
+ retry_answer: str,
470
+ input_mode: str = "text",
471
+ voice_turn_id: str = "",
472
+ ) -> dict[str, Any]:
473
+ """Evaluate a retry answer and store the result on the session."""
474
+ session_id = str(session.get("session_id", ""))
475
+ drills = session.get("retry_drills") or {}
476
+ drill = drills.get(retry_id)
477
+ if not drill:
478
+ return {"error": "Retry drill not found. Start a new retry from the scorecard."}
479
+
480
+ answer = str(retry_answer or "").strip()
481
+ if not answer:
482
+ return {"error": "Retry answer cannot be empty."}
483
+
484
+ drill["retry_answer"] = answer
485
+ drill["input_mode"] = input_mode or "text"
486
+ if voice_turn_id:
487
+ drill["voice_turn_id"] = voice_turn_id
488
+
489
+ comparison_result = call_nemotron_retry_comparison(session, drill, answer)
490
+ if comparison_result is None:
491
+ comparison_result = build_local_retry_fallback(
492
+ drill.get("original_answer", ""),
493
+ answer,
494
+ drill.get("dimension", "objection_handling"),
495
+ drill.get("dimension_score_before", 30),
496
+ )
497
+
498
+ drill["result"] = comparison_result
499
+ comp = comparison_result.get("comparison", {})
500
+ updated_scorecard = apply_retry_to_scorecard(session, drill, comp)
501
+
502
+ response: dict[str, Any] = {
503
+ "session_id": session_id,
504
+ "retry_id": retry_id,
505
+ "dimension": drill.get("dimension", ""),
506
+ "attack_tag": drill.get("attack_tag", ""),
507
+ "original_question": drill.get("original_question", ""),
508
+ "retry_question": drill.get("retry_question", ""),
509
+ "original_answer": drill.get("original_answer", ""),
510
+ "retry_answer": answer,
511
+ "comparison": comp,
512
+ "next_practice_prompt": comparison_result.get("next_practice_prompt", ""),
513
+ }
514
+ if updated_scorecard:
515
+ response["updated_scorecard"] = updated_scorecard
516
+ try:
517
+ verdict = build_judge_verdict(session, updated_scorecard, local_only=True)
518
+ session["judge_verdict"] = verdict
519
+ response["judge_verdict"] = verdict
520
+ except Exception as exc:
521
+ logger.warning("retry_handler: could not refresh judge verdict — %s", exc)
522
+ return response
core/scoring_engine.py CHANGED
@@ -1,24 +1,43 @@
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
 
@@ -66,12 +85,251 @@ 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"))
@@ -80,45 +338,51 @@ def _score_clarity(signals: dict, engagement: int, total: int) -> tuple[int, str
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"))
@@ -129,35 +393,41 @@ def _score_problem_understanding(signals: dict, engagement: int, total: int) ->
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"))
@@ -167,38 +437,44 @@ def _score_market_awareness(signals: dict, engagement: int, total: int) -> tuple
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]
@@ -207,24 +483,24 @@ def _score_differentiation(signals: dict, engagement: int, total: int) -> tuple[
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
  )
@@ -233,11 +509,19 @@ def _score_differentiation(signals: dict, engagement: int, total: int) -> tuple[
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"))
@@ -247,36 +531,39 @@ def _score_business_model(signals: dict, engagement: int, total: int) -> tuple[i
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"))
@@ -285,10 +572,10 @@ def _score_objection_handling(signals: dict, engagement: int, total: int) -> tup
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
 
@@ -302,9 +589,9 @@ def _score_objection_handling(signals: dict, engagement: int, total: int) -> tup
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(
@@ -320,6 +607,15 @@ def _score_objection_handling(signals: dict, engagement: int, total: int) -> tup
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."):
@@ -332,13 +628,14 @@ def _why_weak_reason(weak_answer: str, signals: dict) -> str:
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", [])
@@ -347,40 +644,257 @@ def _compute_local_scores(
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
  # ---------------------------------------------------------------------------
@@ -394,6 +908,7 @@ def _build_coaching_prompt(
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
 
@@ -401,6 +916,8 @@ def _build_coaching_prompt(
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')}",
@@ -456,21 +973,41 @@ def _build_coaching_prompt(
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 = (
@@ -491,33 +1028,243 @@ def _build_coaching_prompt(
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
  # ---------------------------------------------------------------------------
@@ -592,42 +1339,312 @@ def _local_coaching(
592
 
593
 
594
  # ---------------------------------------------------------------------------
595
- # Main hybrid scorecard generator (Phase 5Dpermanent 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],
@@ -636,87 +1653,251 @@ def generate_claim_based_scorecard(
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
  # ---------------------------------------------------------------------------
@@ -825,6 +2006,28 @@ def build_session_aware_fallback_scorecard(
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,
@@ -832,7 +2035,7 @@ def build_session_aware_fallback_scorecard(
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),
@@ -843,6 +2046,7 @@ def build_session_aware_fallback_scorecard(
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",
 
1
+ """Scoring engine for PitchFight AI — Phase 8: Nemotron Full Scoring as Primary.
2
 
3
+ Architecture:
4
+ Primary path — Nemotron reads the full battle Q&A and scores all 6 dimensions.
5
+ Fallback path — Local claim-based scoring if Nemotron full scoring fails.
6
+ Last resort — Session-aware local fallback for catastrophic errors.
7
 
8
  scorecard_source values:
9
+ "nemotron_full" Nemotron judged all 6 dimensions from actual Q&A (primary)
10
+ "hybrid_claims_nemotron" Local regex scores + Nemotron coaching (fallback 1)
11
+ "hybrid_claims_local" — Local scores + local coaching (fallback 2)
12
+ "session_fallback" — Catastrophic crash fallback
13
  """
14
 
15
  from __future__ import annotations
16
 
17
  import logging
18
  import os
19
+ import re
20
  from typing import Any
21
 
22
  from core import model_router
23
+ from core.json_utils import (
24
+ safe_json_parse,
25
+ parse_model_json,
26
+ parse_json_object,
27
+ normalize_parsed_root,
28
+ extract_partial_string_fields,
29
+ extract_partial_string_list,
30
+ ends_abruptly,
31
+ sanitize_for_log,
32
+ _score_label,
33
+ )
34
+ from core.claim_extractor import extract_concrete_signals, extract_startup_context_signals
35
+ from core.judge_settings import (
36
+ normalize_difficulty,
37
+ get_scoring_calibration,
38
+ get_coaching_style,
39
+ get_label,
40
+ )
41
 
42
  logger = logging.getLogger(__name__)
43
 
 
85
  return lst[0] if lst else default
86
 
87
 
88
+ _ROUND_REF_RE = re.compile(r"^(?:round\s*)?r?\s*(\d{1,2})$", re.IGNORECASE)
89
+
90
+
91
+ def _user_answers_from_session(session: dict) -> list[str]:
92
+ """Return founder answer texts in battle order."""
93
+ history = session.get("history", [])
94
+ return [
95
+ str(m.get("content", "")).strip()
96
+ for m in history
97
+ if m.get("role") == "user" and str(m.get("content", "")).strip()
98
+ ]
99
+
100
+
101
+ def _parse_round_reference(text: str) -> int | None:
102
+ """Parse round labels like R4, r2, Round 3 into a 1-based round number."""
103
+ t = text.strip()
104
+ if not t:
105
+ return None
106
+ m = re.fullmatch(r"[Rr](\d{1,2})", t)
107
+ if m:
108
+ return int(m.group(1))
109
+ m = _ROUND_REF_RE.fullmatch(t)
110
+ if m:
111
+ return int(m.group(1))
112
+ return None
113
+
114
+
115
+ def _is_round_reference(text: str) -> bool:
116
+ """Return True when text is only a round label (e.g. R4), not real answer content."""
117
+ return _parse_round_reference(text) is not None
118
+
119
+
120
+ def _resolve_answer_text(
121
+ raw: str,
122
+ session: dict,
123
+ local_fallback: str,
124
+ ) -> tuple[str, int | None]:
125
+ """Map Nemotron round refs (R2, R4) to actual founder answer text."""
126
+ text = str(raw or "").strip()
127
+ user_answers = _user_answers_from_session(session)
128
+
129
+ round_num = _parse_round_reference(text)
130
+ if round_num is not None and 1 <= round_num <= len(user_answers):
131
+ return user_answers[round_num - 1][:400], round_num
132
+
133
+ if _is_prompt_artifact(text):
134
+ text = ""
135
+
136
+ if _is_round_reference(text) or (text and len(text) <= 6 and not text.endswith((".", "!", "?"))):
137
+ if local_fallback and not _is_prompt_artifact(local_fallback):
138
+ return local_fallback[:400], None
139
+ if user_answers:
140
+ return user_answers[-1][:400], len(user_answers)
141
+ return "No battle answers were submitted.", None
142
+
143
+ if text:
144
+ # Try to match a quoted excerpt back to a round for UI badges
145
+ for idx, answer in enumerate(user_answers, start=1):
146
+ snippet = answer[:80].lower()
147
+ if snippet and snippet in text.lower():
148
+ return text[:400], idx
149
+ return text[:400], None
150
+
151
+ if local_fallback:
152
+ return local_fallback[:400], None
153
+ if user_answers:
154
+ return user_answers[0][:400], 1
155
+ return "No answer recorded.", None
156
+
157
+
158
+ def _resolve_best_weakest_answers(
159
+ session: dict,
160
+ best_raw: str,
161
+ weakest_raw: str,
162
+ local_best: str,
163
+ local_weakest: str,
164
+ ) -> tuple[str, str, int | None, int | None]:
165
+ """Ensure best/weakest fields contain readable answer text, not round codes."""
166
+ best_answer, best_round = _resolve_answer_text(best_raw, session, local_best)
167
+ weakest_answer, weakest_round = _resolve_answer_text(weakest_raw, session, local_weakest)
168
+ return best_answer, weakest_answer, best_round, weakest_round
169
+
170
+
171
+ def _sync_overall_to_dimensions(scorecard: dict[str, Any]) -> dict[str, Any]:
172
+ """Keep overall aligned with the six dimension scores shown in the UI."""
173
+ scores = scorecard.get("scores") or {}
174
+ if len(scores) < 6:
175
+ return scorecard
176
+ avg = round(sum(int(v.get("score", 0)) for v in scores.values()) / len(scores))
177
+ scorecard["overall"] = avg
178
+ scorecard["overall_label"] = _score_label(avg)
179
+ return scorecard
180
+
181
+
182
+ def _apply_practice_score_nudge(
183
+ overall: int,
184
+ signals: dict,
185
+ difficulty_profile: str,
186
+ ) -> int:
187
+ """Small practice-mode nudge when the founder showed real effort."""
188
+ if normalize_difficulty(difficulty_profile) != "practice":
189
+ return overall
190
+ answers = signals.get("all_user_answers") or []
191
+ has_substance = signals.get("signal_count", 0) > 0 or any(
192
+ len(str(a).split()) >= 4 for a in answers if str(a).strip()
193
+ )
194
+ if not has_substance:
195
+ return overall
196
+ return min(100, overall + 3)
197
+
198
+
199
+ def _apply_practice_signal_floor(
200
+ scores: dict[str, Any],
201
+ local_reference: dict | None,
202
+ difficulty_profile: str,
203
+ ) -> dict[str, Any]:
204
+ """In Practice mode, never score a dimension below what real signals already justify.
205
+
206
+ The local claim-based scorer (`_compute_local_scores`) uses student-friendly floors and
207
+ only credits genuinely extracted signals, so it cannot be gamed by fluff. Lifting the
208
+ Nemotron score up to that deterministic reference protects honest students who gave a
209
+ short answer with one real proof point from a harsh prose-biased judgement.
210
+
211
+ Practice mode only — Judge and Investor keep pure Nemotron scoring.
212
+ """
213
+ if normalize_difficulty(difficulty_profile) != "practice":
214
+ return scores
215
+ if not local_reference or not isinstance(local_reference.get("scores"), dict):
216
+ return scores
217
+ ref = local_reference["scores"]
218
+ for dim, entry in scores.items():
219
+ ref_score = ref.get(dim, {}).get("score")
220
+ if not isinstance(ref_score, (int, float)):
221
+ continue
222
+ try:
223
+ cur = int(entry.get("score", 0))
224
+ except (TypeError, ValueError):
225
+ cur = 0
226
+ if ref_score > cur:
227
+ lifted = _clamp(int(round(ref_score)), 0, 100)
228
+ entry["score"] = lifted
229
+ entry["label"] = _score_label(lifted)
230
+ return scores
231
+
232
+
233
+ _PROMPT_ARTIFACTS = frozenset({
234
+ "battle q&a", "local ref", "battle q&a:", "local ref (hints only)",
235
+ "no answer recorded.", "no answers recorded.", "not identified.",
236
+ })
237
+
238
+
239
+ def _is_prompt_artifact(text: str) -> bool:
240
+ """True when text is a scoring-prompt label, not a founder answer."""
241
+ t = text.strip().lower()
242
+ if not t:
243
+ return True
244
+ if t in _PROMPT_ARTIFACTS:
245
+ return True
246
+ if "local ref" in t and len(t) < 40:
247
+ return True
248
+ if t.startswith("battle q") and len(t) < 30:
249
+ return True
250
+ return False
251
+
252
+
253
+ def _merge_signal_dicts(battle: dict, extra: dict) -> dict:
254
+ """Merge two signal dicts (lists concatenated, deduped where sensible)."""
255
+ merged = dict(battle)
256
+ for key in (
257
+ "numbers", "percentages", "pricing", "user_counts", "validation",
258
+ "college_mentions", "competitors", "technical_mechanisms",
259
+ "revenue_signals", "retention_signals", "gtm_signals", "vague_claims",
260
+ "best_user_quotes",
261
+ ):
262
+ a = list(merged.get(key, []) or [])
263
+ b = list(extra.get(key, []) or [])
264
+ seen: set[str] = set()
265
+ out: list[str] = []
266
+ for item in a + b:
267
+ k = str(item).strip().lower()
268
+ if k and k not in seen:
269
+ seen.add(k)
270
+ out.append(str(item).strip())
271
+ merged[key] = out
272
+ merged["all_user_answers"] = list(merged.get("all_user_answers", []) or [])
273
+ merged["non_answers"] = list(merged.get("non_answers", []) or [])
274
+ merged["signal_count"] = (
275
+ len(merged.get("numbers", [])) + len(merged.get("validation", [])) +
276
+ len(merged.get("competitors", [])) + len(merged.get("technical_mechanisms", [])) +
277
+ len(merged.get("pricing", [])) + len(merged.get("user_counts", [])) +
278
+ len(merged.get("revenue_signals", []))
279
+ )
280
+ return merged
281
+
282
+
283
+ def _battle_engagement(signals: dict) -> dict[str, int]:
284
+ """Summarize how much the founder actually answered during the battle."""
285
+ all_answers = signals.get("all_user_answers", [])
286
+ non_answers = signals.get("non_answers", [])
287
+ user_turns = len(all_answers)
288
+ substantive = max(0, user_turns - len(non_answers))
289
+ return {
290
+ "user_turns": user_turns,
291
+ "substantive_answers": substantive,
292
+ }
293
+
294
+
295
+ def _has_startup_context(startup: dict, startup_signals: dict) -> bool:
296
+ """Return True if the idea was described upfront (form or voice pitch)."""
297
+ if startup_signals.get("signal_count", 0) > 0:
298
+ return True
299
+ substantive_fields = 0
300
+ for key in ("problem", "solution", "why_ai", "traction", "target_users"):
301
+ if len(str(startup.get(key, "")).strip()) > 20:
302
+ substantive_fields += 1
303
+ return substantive_fields >= 2
304
+
305
+
306
+ def _apply_startup_context_cap(score: int, engagement: int, cal: dict) -> int:
307
+ """Cap scores when battle had no substantive answers (startup-only credit)."""
308
+ if engagement > 0:
309
+ return score
310
+ cap = cal.get("startup_context_max", 45)
311
+ return min(score, cap)
312
+
313
+
314
+ def _zero_engagement_reason(total: int, has_startup: bool) -> str:
315
+ if total == 0 and not has_startup:
316
+ return "No battle answers were submitted."
317
+ if total == 0 and has_startup:
318
+ return "Scored from startup description only — no battle answers were given."
319
+ return "No substantive battle answers were given."
320
+
321
+
322
  # ---------------------------------------------------------------------------
323
  # Local dimension scoring functions
324
  # ---------------------------------------------------------------------------
325
 
326
+ def _score_clarity(signals: dict, engagement: int, total: int, cal: dict | None = None) -> tuple[int, str, str, list]:
327
  """Did the founder communicate what the product does and who it helps?"""
328
+ cal = cal or {}
329
+ floor = cal.get("attempted_answer_floor", 33)
330
+ non_ans_max = cal.get("non_answer_max", 15)
331
+ concrete_floor = cal.get("concrete_signal_floor", 52)
332
+
333
  has_tech = bool(signals.get("technical_mechanisms"))
334
  has_numbers = bool(signals.get("numbers") or signals.get("user_counts"))
335
  has_validation = bool(signals.get("validation"))
 
338
  quote = best_quotes[0][:160] if best_quotes else ""
339
  used: list[str] = []
340
 
341
+ if engagement == 0 and signals.get("signal_count", 0) == 0:
342
+ return 0, _zero_engagement_reason(total, False), quote, []
343
 
344
+ score = floor
 
345
  parts: list[str] = []
346
 
347
  if has_tech and (has_numbers or has_validation):
348
+ score = max(score, concrete_floor + 13)
349
  techs = signals.get("technical_mechanisms", [])[:2]
350
  used += techs
351
  parts.append(f"Technical mechanism described ({', '.join(techs)}) with supporting evidence.")
352
  elif has_tech:
353
+ score = max(score, concrete_floor + 6)
354
  techs = signals.get("technical_mechanisms", [])[:2]
355
  used += techs
356
  parts.append(f"Technical mechanism explained: {', '.join(techs)}.")
357
  elif has_validation:
358
+ score = max(score, concrete_floor + 3)
359
  vals = signals.get("validation", [])[:2]
360
  used += vals
361
  parts.append(f"Validation evidence present ({', '.join(vals)}) — product is real.")
362
  elif has_numbers:
363
+ score = max(score, concrete_floor)
364
  nums = signals.get("numbers", [])[:2]
365
  used += nums
366
  parts.append(f"Concrete numbers ({', '.join(nums)}) suggest product has been built/used.")
367
  elif has_vague_only:
368
+ vague_hi = cal.get("vague_on_topic_range", [floor, floor + 10])[1]
369
+ score = _clamp(score, floor, vague_hi)
370
  parts.append("Answer was on-topic but used vague language without concrete specifics.")
371
  else:
372
  parts.append("Product described with some substance but limited concrete evidence.")
373
 
374
  reason = " ".join(parts)[:280]
375
+ score = _apply_startup_context_cap(_clamp(score), engagement, cal)
376
+ return score, reason, quote, list(dict.fromkeys(used))[:5]
377
 
378
 
379
+ def _score_problem_understanding(signals: dict, engagement: int, total: int, cal: dict | None = None) -> tuple[int, str, str, list]:
380
  """Did they name a specific user, pain, and provide evidence of understanding?"""
381
+ cal = cal or {}
382
+ floor = cal.get("attempted_answer_floor", 33)
383
+ non_ans_max = cal.get("non_answer_max", 15)
384
+ concrete_floor = cal.get("concrete_signal_floor", 52)
385
+
386
  has_validation = bool(signals.get("validation"))
387
  has_colleges = bool(signals.get("college_mentions"))
388
  has_user_counts = bool(signals.get("user_counts") or signals.get("numbers"))
 
393
  quote = (val_list[0] if val_list else (col_list[0] if col_list else (best_quotes[0][:160] if best_quotes else "")))
394
  used: list[str] = (val_list[:2] + col_list[:2])[:5]
395
 
396
+ if engagement == 0 and signals.get("signal_count", 0) == 0:
397
+ return 0, _zero_engagement_reason(total, False), quote[:160], []
398
 
399
+ score = floor
400
  parts: list[str] = []
401
 
402
  if has_validation and has_colleges:
403
+ score = max(score, concrete_floor + 20)
404
  parts.append(
405
  f"Validated with real users ({', '.join(val_list[:2])}) "
406
  f"at named campuses ({', '.join(col_list[:2])})."
407
  )
408
  elif has_validation:
409
+ score = max(score, concrete_floor + 10)
410
  parts.append(f"Validation evidence: {', '.join(val_list[:2])}.")
411
  elif has_colleges:
412
+ score = max(score, concrete_floor)
413
  parts.append(f"Campus/college context mentioned: {', '.join(col_list[:2])}.")
414
  elif has_user_counts:
415
+ score = max(score, concrete_floor - 2)
416
  parts.append(f"User/number evidence present: {', '.join(num_list)}.")
417
  else:
418
  parts.append("Problem described but without user research or validation evidence.")
419
 
420
+ score = _apply_startup_context_cap(_clamp(score), engagement, cal)
421
+ return score, " ".join(parts)[:280], quote[:160] if isinstance(quote, str) else "", used
422
 
423
 
424
+ def _score_market_awareness(signals: dict, engagement: int, total: int, cal: dict | None = None) -> tuple[int, str, str, list]:
425
  """Did they demonstrate knowledge of market size, segment, or competitive landscape?"""
426
+ cal = cal or {}
427
+ floor = cal.get("attempted_answer_floor", 33)
428
+ non_ans_max = cal.get("non_answer_max", 15)
429
+ concrete_floor = cal.get("concrete_signal_floor", 52)
430
+
431
  has_numbers = bool(signals.get("numbers") or signals.get("user_counts"))
432
  has_competitors = bool(signals.get("competitors"))
433
  has_colleges = bool(signals.get("college_mentions"))
 
437
  quote = (nums[0] if nums else (comps[0] if comps else (best_quotes[0][:160] if best_quotes else "")))
438
  used: list[str] = (nums[:2] + comps[:2])[:5]
439
 
440
+ if engagement == 0 and signals.get("signal_count", 0) == 0:
441
+ return 0, _zero_engagement_reason(total, False), str(quote)[:160], []
442
 
443
+ score = floor
444
  parts: list[str] = []
445
 
446
  if has_numbers and has_competitors:
447
+ score = max(score, concrete_floor + 15)
448
  parts.append(
449
  f"Market numbers ({', '.join(nums[:2])}) and competitors named ({', '.join(comps[:2])})."
450
  )
451
  elif has_numbers and has_colleges:
452
+ score = max(score, concrete_floor + 8)
453
  parts.append(
454
  f"User/market numbers ({', '.join(nums[:2])}) with campus context."
455
  )
456
  elif has_numbers:
457
+ score = max(score, concrete_floor + 3)
458
  parts.append(f"Market/user numbers: {', '.join(nums[:2])}.")
459
  elif has_competitors:
460
+ score = max(score, concrete_floor - 4)
461
  parts.append(
462
  f"Competitors identified ({', '.join(comps[:2])}) — indicates market awareness."
463
  )
464
  else:
465
  parts.append("Market described but without user counts, TAM, or competitor landscape.")
466
 
467
+ score = _apply_startup_context_cap(_clamp(score), engagement, cal)
468
+ return score, " ".join(parts)[:280], str(quote)[:160], used
469
 
470
 
471
+ def _score_differentiation(signals: dict, engagement: int, total: int, cal: dict | None = None) -> tuple[int, str, str, list]:
472
  """Did they explain why this beats alternatives (competitor + mechanism/moat)?"""
473
+ cal = cal or {}
474
+ floor = cal.get("attempted_answer_floor", 33)
475
+ non_ans_max = cal.get("non_answer_max", 15)
476
+ concrete_floor = cal.get("concrete_signal_floor", 52)
477
+
478
  has_competitors = bool(signals.get("competitors"))
479
  has_tech = bool(signals.get("technical_mechanisms"))
480
  comps = signals.get("competitors", [])[:3]
 
483
  quote = (comps[0] if comps else (techs[0] if techs else (best_quotes[0][:160] if best_quotes else "")))
484
  used: list[str] = (comps[:2] + techs[:2])[:5]
485
 
486
+ if engagement == 0 and signals.get("signal_count", 0) == 0:
487
+ return 0, _zero_engagement_reason(total, False), str(quote)[:160], []
488
 
489
+ score = floor
490
  parts: list[str] = []
491
 
492
  if has_competitors and has_tech:
493
+ score = max(score, concrete_floor + 18)
494
  parts.append(
495
  f"Named competitors ({', '.join(comps[:2])}) with technical moat ({', '.join(techs[:2])})."
496
  )
497
  elif has_competitors:
498
+ score = max(score, concrete_floor)
499
  parts.append(
500
  f"Competitors identified ({', '.join(comps[:2])}) but moat/mechanism not fully articulated."
501
  )
502
  elif has_tech:
503
+ score = max(score, concrete_floor - 2)
504
  parts.append(
505
  f"Technical approach described ({', '.join(techs[:2])}) but no direct competitor comparison."
506
  )
 
509
  "Differentiation not clearly supported — no competitors named and no technical mechanism stated."
510
  )
511
 
512
+ score = _apply_startup_context_cap(_clamp(score), engagement, cal)
513
+ return score, " ".join(parts)[:280], str(quote)[:160], used
514
 
515
 
516
+ def _score_business_model(signals: dict, engagement: int, total: int, cal: dict | None = None) -> tuple[int, str, str, list]:
517
  """Did they explain who pays, how much, and why?"""
518
+ cal = cal or {}
519
+ # Business model floor is intentionally lower — early-stage students often lack revenue
520
+ partial_floor = cal.get("partial_signal_floor", 38)
521
+ concrete_floor = cal.get("concrete_signal_floor", 52)
522
+ non_ans_max = cal.get("non_answer_max", 15)
523
+ biz_floor = max(partial_floor - 10, 12) # lower than other dims by design
524
+
525
  has_pricing = bool(signals.get("pricing"))
526
  has_revenue = bool(signals.get("revenue_signals"))
527
  has_validation = bool(signals.get("validation"))
 
531
  quote = (pricing[0] if pricing else (revenue[0] if revenue else (best_quotes[0][:160] if best_quotes else "")))
532
  used: list[str] = (pricing[:2] + revenue[:2])[:5]
533
 
534
+ if engagement == 0 and signals.get("signal_count", 0) == 0:
535
+ return 0, _zero_engagement_reason(total, False), str(quote)[:160], []
536
 
537
+ score = biz_floor
 
538
  parts: list[str] = []
539
 
540
  if has_pricing and has_revenue:
541
+ score = max(score, concrete_floor + 16)
542
  parts.append(f"Pricing ({', '.join(pricing[:2])}) and revenue logic ({', '.join(revenue[:2])}) present.")
543
  elif has_pricing:
544
+ score = max(score, concrete_floor)
545
  parts.append(f"Pricing mentioned: {', '.join(pricing[:2])}.")
546
  elif has_revenue:
547
+ score = max(score, concrete_floor - 4)
548
  parts.append(f"Revenue/monetization signals: {', '.join(revenue[:2])}.")
549
  elif has_validation:
550
+ score = max(score, partial_floor - 2)
 
551
  parts.append(
552
  "Traction/validation evidence present but no explicit pricing or revenue model stated."
553
  )
554
  else:
555
  parts.append("Business model not clearly stated — no pricing, revenue, or monetization mentioned.")
556
 
557
+ score = _apply_startup_context_cap(_clamp(score), engagement, cal)
558
+ return score, " ".join(parts)[:280], str(quote)[:160], used
559
 
560
 
561
+ def _score_objection_handling(signals: dict, engagement: int, total: int, cal: dict | None = None) -> tuple[int, str, str, list]:
562
  """Did they answer hard questions directly with evidence?"""
563
+ cal = cal or {}
564
+ floor = cal.get("attempted_answer_floor", 33)
565
+ non_ans_max = cal.get("non_answer_max", 15)
566
+
567
  has_validation = bool(signals.get("validation"))
568
  has_numbers = bool(signals.get("numbers") or signals.get("user_counts"))
569
  has_tech = bool(signals.get("technical_mechanisms"))
 
572
  used: list[str] = (signals.get("validation", [])[:2] + signals.get("numbers", [])[:2])[:4]
573
 
574
  if total == 0 or engagement == 0:
575
+ has_ctx = signals.get("signal_count", 0) > 0
576
+ return 0, _zero_engagement_reason(total, has_ctx), quote, []
577
 
578
  engagement_rate = engagement / total
 
579
  score = int(engagement_rate * 60)
580
  parts: list[str] = []
581
 
 
589
  if has_tech:
590
  score += 5
591
 
592
+ # Floor: use profile floor if majority were substantive
593
  if engagement_rate > 0.5:
594
+ score = max(score, floor - 3)
595
 
596
  if not parts:
597
  parts.append(
 
607
  # Local scoring orchestrator
608
  # ---------------------------------------------------------------------------
609
 
610
+ def _startup_summary_snippet(startup: dict) -> str:
611
+ """Short excerpt from startup form when no battle answers exist."""
612
+ for key in ("solution", "problem", "why_ai", "traction"):
613
+ val = str(startup.get(key, "")).strip()
614
+ if len(val) > 20:
615
+ return val[:400]
616
+ return ""
617
+
618
+
619
  def _why_weak_reason(weak_answer: str, signals: dict) -> str:
620
  stripped = weak_answer.strip().lower()
621
  if not stripped or stripped in ("no answers recorded.", "no answers recorded yet."):
 
628
 
629
 
630
  def _compute_local_scores(
631
+ signals: dict, startup: dict, cal: dict | None = None
632
  ) -> tuple[dict[str, Any], str, str, str]:
633
  """Return (scores_dict, best_answer, weakest_answer, why_weak).
634
 
635
  All 6 dimension scores are computed deterministically from extracted signals.
636
+ No API calls. cal = scoring_calibration dict from the active difficulty profile.
637
  """
638
+ cal = cal or {}
639
  all_answers = signals.get("all_user_answers", [])
640
  non_answers = signals.get("non_answers", [])
641
  best_quotes = signals.get("best_user_quotes", [])
 
644
 
645
  scores = {
646
  "clarity": _dimension(
647
+ *_score_clarity(signals, engagement, total, cal)
648
  ),
649
  "problem_understanding": _dimension(
650
+ *_score_problem_understanding(signals, engagement, total, cal)
651
  ),
652
  "market_awareness": _dimension(
653
+ *_score_market_awareness(signals, engagement, total, cal)
654
  ),
655
  "differentiation": _dimension(
656
+ *_score_differentiation(signals, engagement, total, cal)
657
  ),
658
  "business_model": _dimension(
659
+ *_score_business_model(signals, engagement, total, cal)
660
  ),
661
  "objection_handling": _dimension(
662
+ *_score_objection_handling(signals, engagement, total, cal)
663
  ),
664
  }
665
 
666
+ if engagement == 0 and total == 0:
667
+ snippet = _startup_summary_snippet(startup)
668
+ if snippet:
669
+ best_answer = snippet
670
+ weakest_answer = "No battle answers were submitted."
671
+ why_weak = "The judge asked questions but no answers were given during the battle."
672
+ else:
673
+ best_answer = "No battle answers were submitted."
674
+ weakest_answer = "No battle answers were submitted."
675
+ why_weak = "No answers were recorded in this session."
676
+ else:
677
+ best_answer = (
678
+ best_quotes[0]
679
+ if best_quotes
680
+ else (all_answers[0] if all_answers else "No battle answers were submitted.")
681
+ )
682
+ non_best = [a for a in all_answers if a != best_answer]
683
+ if non_answers:
684
+ weakest_answer = non_answers[0]
685
+ elif non_best:
686
+ weakest_answer = min(non_best, key=len)
687
+ else:
688
+ weakest_answer = all_answers[-1] if len(all_answers) > 1 else best_answer
689
+ why_weak = _why_weak_reason(weakest_answer, signals)
690
+ return scores, best_answer, weakest_answer, why_weak
691
+
692
+
693
+ # ---------------------------------------------------------------------------
694
+ # "Path to 80+" score explanation builder (fully local — no API call)
695
+ # ---------------------------------------------------------------------------
696
+
697
+ _DIM_NAMES = {
698
+ "clarity": "clarity",
699
+ "problem_understanding": "problem understanding",
700
+ "market_awareness": "market awareness",
701
+ "differentiation": "differentiation",
702
+ "business_model": "business model",
703
+ "objection_handling": "objection handling",
704
+ }
705
+
706
+ _DIM_TO_RETRY_ADVICE = {
707
+ "clarity": (
708
+ "When answering questions about your product, name the specific thing you built, "
709
+ "who uses it today, and one number that proves it works."
710
+ ),
711
+ "problem_understanding": (
712
+ "Show you researched the problem — name a real user, a campus, or a specific pain point "
713
+ "you observed firsthand. One validation data point changes everything."
714
+ ),
715
+ "market_awareness": (
716
+ "Size the market with one real number and name at least two alternatives students use today. "
717
+ "Judges want to know you understand the competitive landscape."
718
+ ),
719
+ "differentiation": (
720
+ "Name your top two competitors, then explain the one thing they cannot easily copy from you. "
721
+ "A technical mechanism, a relationship, or a data advantage all count."
722
+ ),
723
+ "business_model": (
724
+ "Say exactly who pays, how much, and when the first payment happens. "
725
+ "Even a rough plan ('₹499/student/month, collect at onboarding') is far stronger than silence."
726
+ ),
727
+ "objection_handling": (
728
+ "When challenged, do not deflect. Answer the exact question with a number, a fact, or a concrete example. "
729
+ "Judges remember founders who hold their ground under pressure."
730
+ ),
731
+ }
732
+
733
+ _TONE_OPENER = {
734
+ "practice": "You are closer than the score feels.",
735
+ "judge": "Here is what the scorecard is actually telling you.",
736
+ "investor": "Here is exactly what held this pitch back.",
737
+ }
738
+
739
+
740
+ def _build_score_explanation(
741
+ overall: int,
742
+ scores: dict[str, Any],
743
+ weakest_answer: str,
744
+ why_weak: str,
745
+ signals: dict,
746
+ session: dict,
747
+ difficulty_profile: str = "practice",
748
+ ) -> dict[str, Any]:
749
+ """Build the 'Path to 80+' coaching section from local data only — no API call."""
750
+ dim_items = sorted(scores.items(), key=lambda x: x[1]["score"])
751
+ strong_dims = [(k, v) for k, v in scores.items() if v["score"] >= 70]
752
+ weak_dims = [(k, v) for k, v in dim_items if v["score"] < 55]
753
+ blocker_dim, blocker_data = dim_items[0] # lowest scoring
754
+
755
+ strong_names = [_DIM_NAMES.get(k, k) for k, _ in strong_dims]
756
+ blocker_name = _DIM_NAMES.get(blocker_dim, blocker_dim)
757
+ blocker_score = blocker_data["score"]
758
+ blocker_label = blocker_data["label"]
759
+
760
+ tone_opener = _TONE_OPENER.get(difficulty_profile, _TONE_OPENER["practice"])
761
+
762
+ # --- why_you_scored_this ---
763
+ if strong_names:
764
+ strong_str = " and ".join(strong_names[:2])
765
+ why_scored = (
766
+ f"{tone_opener} "
767
+ f"Your {strong_str} answer{'s were' if len(strong_names) > 1 else ' was'} solid, "
768
+ f"but your {blocker_name} answer brought the score down. "
769
+ f"The judge scored {blocker_name} at {blocker_score} ({blocker_label}) "
770
+ f"because {blocker_data.get('reason', 'it lacked concrete evidence')[:120]}."
771
+ )
772
+ else:
773
+ why_scored = (
774
+ f"{tone_opener} "
775
+ f"Your {blocker_name} answer was the main drag on the score — "
776
+ f"{blocker_score} ({blocker_label}). "
777
+ f"{blocker_data.get('reason', 'It lacked concrete evidence')[:120]}."
778
+ )
779
+
780
+ # --- what_stopped_80 ---
781
+ history = session.get("history", [])
782
+ ai_messages = [m["content"] for m in history if m.get("role") == "assistant"]
783
+ blocker_question = ai_messages[0][:180] if ai_messages else ""
784
+
785
+ if blocker_question:
786
+ what_stopped = (
787
+ f"The biggest gap was in {blocker_name}. "
788
+ f"The judge pressed on this with a question like: \"{blocker_question}\" "
789
+ f"and the answer did not fully land. {why_weak}"
790
+ )
791
+ else:
792
+ what_stopped = (
793
+ f"The biggest gap was in {blocker_name} ({blocker_score}/100). "
794
+ f"{why_weak} "
795
+ f"A stronger answer here alone could push the overall score into the mid-70s."
796
+ )
797
+
798
+ # --- answer_to_retry ---
799
+ all_answers = signals.get("all_user_answers", [])
800
+ non_answers = signals.get("non_answers", [])
801
+
802
+ # Pick the answer most associated with the blocker dimension
803
+ original_answer = weakest_answer
804
+ attack_tag_for_retry = blocker_name.replace(" ", "_")
805
+ round_for_retry: int | None = None
806
+
807
+ # Try to find the round number from history
808
+ user_turns = [m for m in history if m.get("role") == "user"]
809
+ if user_turns:
810
+ worst_idx = None
811
+ shortest_len = 9999
812
+ for idx, m in enumerate(user_turns):
813
+ content = m.get("content", "")
814
+ if content in non_answers and (worst_idx is None or len(content) < shortest_len):
815
+ worst_idx = idx
816
+ shortest_len = len(content)
817
+ if worst_idx is not None:
818
+ round_for_retry = worst_idx + 1
819
+ original_answer = user_turns[worst_idx].get("content", weakest_answer)
820
+ else:
821
+ round_for_retry = len(user_turns)
822
+
823
+ retry_advice = _DIM_TO_RETRY_ADVICE.get(blocker_dim, "Give one concrete piece of evidence to back your claim.")
824
+
825
+ # Sample stronger answer using signals from session
826
+ numbers = signals.get("numbers", []) + signals.get("user_counts", [])
827
+ valid = signals.get("validation", [])
828
+ comps = signals.get("competitors", [])
829
+ pricing = signals.get("pricing", [])
830
+ startup = session.get("startup", {})
831
+ sname = startup.get("name", "our product")
832
+
833
+ sample_parts: list[str] = [f"A stronger answer would say: '{sname} "]
834
+ if numbers:
835
+ sample_parts.append(f"has {numbers[0]} ")
836
+ if valid:
837
+ sample_parts.append(f"validated through {valid[0]} ")
838
+ if comps:
839
+ sample_parts.append(f"— unlike {comps[0]}, we ")
840
+ if pricing:
841
+ sample_parts.append(f"charge {pricing[0]}")
842
+ else:
843
+ sample_parts.append("and our advantage is the specific data and relationships we have built'")
844
+ sample_stronger_answer = "".join(sample_parts).strip()
845
+ if not sample_stronger_answer.endswith("'"):
846
+ sample_stronger_answer += "'"
847
+
848
+ why_it_hurt = (
849
+ f"This answer scored low on {blocker_name} because it {why_weak.lower()} "
850
+ f"The judge needs one specific fact, number, or example to move on."
851
  )
852
+
853
+ answer_to_retry = {
854
+ "round": round_for_retry,
855
+ "attack_tag": attack_tag_for_retry,
856
+ "dimension": blocker_dim,
857
+ "original_answer": original_answer[:300],
858
+ "why_it_hurt": why_it_hurt[:300],
859
+ "retry_advice": retry_advice,
860
+ "sample_stronger_answer": sample_stronger_answer[:400],
861
+ }
862
+
863
+ # --- estimated_score_if_fixed ---
864
+ if overall >= 80:
865
+ # Already strong — advise path to 90
866
+ gap_to_90 = 90 - overall
867
+ estimated_new = _clamp(overall + min(gap_to_90, 8))
868
+ improvement_reason = (
869
+ f"Your pitch is already strong. To reach 90+, deepen the evidence in "
870
+ f"{blocker_name} and {_DIM_NAMES.get(dim_items[1][0], 'your second weakest area')} "
871
+ f"with specific numbers and a sharper competitive contrast."
872
+ )
873
  else:
874
+ # Estimate conservative improvement from fixing the main blocker
875
+ blocker_weight = 1 / len(scores)
876
+ point_gain = max(8, min(15, int((70 - blocker_score) * blocker_weight * 1.4)))
877
+ estimated_new = _clamp(overall + point_gain, overall, 82)
878
+ if len(strong_dims) >= 3:
879
+ estimated_new = min(estimated_new + 3, 85)
880
+ improvement_reason = (
881
+ f"Fixing your {blocker_name} answer alone could add roughly {point_gain} points overall. "
882
+ f"This would move your pitch from '{_score_label(overall)}' toward "
883
+ f"'{_score_label(estimated_new)}' territory."
884
+ )
885
 
886
+ estimated_score_if_fixed = {
887
+ "current_overall": overall,
888
+ "estimated_new_overall": estimated_new,
889
+ "reason": improvement_reason,
890
+ }
891
+
892
+ return {
893
+ "why_you_scored_this": why_scored,
894
+ "what_stopped_80": what_stopped,
895
+ "answer_to_retry": answer_to_retry,
896
+ "estimated_score_if_fixed": estimated_score_if_fixed,
897
+ }
898
 
899
 
900
  # ---------------------------------------------------------------------------
 
908
  best_answer: str,
909
  weakest_answer: str,
910
  why_weak: str,
911
+ difficulty_profile: str = "practice",
912
  ) -> list[dict[str, str]]:
913
  """Build messages for Nemotron coaching-only call.
914
 
 
916
  All scoring is already done locally and passed as context.
917
  """
918
  startup = session.get("startup", {})
919
+ coaching_style = get_coaching_style(difficulty_profile)
920
+ difficulty_label = get_label(difficulty_profile)
921
 
922
  startup_block = "\n".join([
923
  f"Startup: {startup.get('name', 'Unknown')}",
 
973
  answers_lines.append(f" {i}. {a[:200]}")
974
  answers_block = "\n".join(answers_lines)
975
 
976
+ coaching_instruction = coaching_style.get(
977
+ "instruction",
978
+ "Be encouraging but honest. Show how to make the answer stronger with one specific number or proof point.",
979
+ )
980
+ coaching_example = coaching_style.get("example", "")
981
+
982
  system_content = (
983
+ "Return ONLY valid JSON. Return one JSON object only.\n"
984
+ "First character must be {. Last character must be }.\n"
985
+ "Do not wrap in an array. No markdown. No explanation. No analysis. No reasoning.\n"
986
+ "Keep each field short and complete. Do not end mid-sentence.\n\n"
987
+ f"You are a startup pitch coach for a student founder. Difficulty profile: {difficulty_label}.\n\n"
988
+ f"COACHING STYLE: {coaching_instruction}\n"
989
+ + (f"TONE EXAMPLE: {coaching_example}\n\n" if coaching_example else "\n")
990
+ + "RULES:\n"
991
  " - Do NOT hallucinate traction, numbers, or facts not in the provided context.\n"
992
  " - Do NOT re-score — scores are already computed.\n"
993
  " - Use actual startup context and actual founder answers.\n"
994
  " - If concrete signals exist, reference them in improved_answer and improved_pitch.\n"
995
+ " - Use 'your answer' or 'you said' never 'you typed' (voice transcripts also arrive here).\n"
996
+ " - improved_answer: 3-5 sentences rewriting the weakest answer.\n"
997
+ " - improved_pitch: 4-6 sentences one concise 60-second pitch.\n"
998
+ " - top_3_questions: exactly 3 strings.\n"
999
+ " - score_explanation: Path to 80+ coaching — keep each field SHORT and COMPLETE.\n"
1000
+ " why_you_scored_this: max 2 sentences.\n"
1001
+ " what_stopped_80: max 2 sentences.\n"
1002
+ " answer_to_retry.retry_advice: ONE complete sentence only.\n"
1003
+ " answer_to_retry.sample_stronger_answer: 3-4 sentences max.\n\n"
1004
  "Return exactly this JSON schema — nothing else:\n"
1005
+ '{"improved_answer":"string","improved_pitch":"string","top_3_questions":["string","string","string"],'
1006
+ '"score_explanation":{"why_you_scored_this":"string","what_stopped_80":"string",'
1007
+ '"answer_to_retry":{"round":null,"attack_tag":"string","dimension":"string",'
1008
+ '"original_answer":"string","why_it_hurt":"string","retry_advice":"string",'
1009
+ '"sample_stronger_answer":"string"},'
1010
+ '"estimated_score_if_fixed":{"current_overall":0,"estimated_new_overall":0,"reason":"string"}}}'
1011
  )
1012
 
1013
  user_content = (
 
1028
  ]
1029
 
1030
 
1031
+ def _ends_abruptly(text: str) -> bool:
1032
+ """Return True if text looks cut off mid-sentence."""
1033
+ t = text.strip()
1034
+ if not t:
1035
+ return True
1036
+ if t[-1] in ".!?":
1037
+ return False
1038
+ # Short fragment without terminal punctuation is likely truncated
1039
+ if len(t) < 50:
1040
+ return True
1041
+ # Longer text without punctuation may still be valid — only flag if very short last token
1042
+ last_word = t.split()[-1] if t.split() else ""
1043
+ return len(last_word) <= 2 and len(t) < 80
1044
+
1045
+
1046
+ def _field_looks_truncated(field: str, text: str) -> bool:
1047
+ """Return True if a score_explanation text field appears incomplete."""
1048
+ t = text.strip()
1049
+ if not t:
1050
+ return True
1051
+ if field == "retry_advice":
1052
+ return len(t) < 20 or _ends_abruptly(t)
1053
+ if field in ("why_you_scored_this", "what_stopped_80"):
1054
+ return _ends_abruptly(t)
1055
+ if field == "sample_stronger_answer":
1056
+ return len(t) < 30 or _ends_abruptly(t)
1057
+ return _ends_abruptly(t)
1058
+
1059
+
1060
+ def _parse_coaching_score_explanation(raw: Any) -> dict[str, Any] | None:
1061
+ """Parse score_explanation sub-object from coaching JSON."""
1062
+ if not isinstance(raw, dict):
1063
+ return None
1064
+ atr_raw = raw.get("answer_to_retry", {})
1065
+ esif_raw = raw.get("estimated_score_if_fixed", {})
1066
+ if not isinstance(atr_raw, dict):
1067
+ atr_raw = {}
1068
+ if not isinstance(esif_raw, dict):
1069
+ esif_raw = {}
1070
+ why = str(raw.get("why_you_scored_this", "")).strip()
1071
+ if not why:
1072
  return None
1073
+ return {
1074
+ "why_you_scored_this": why,
1075
+ "what_stopped_80": str(raw.get("what_stopped_80", "")).strip(),
1076
+ "answer_to_retry": {
1077
+ "round": atr_raw.get("round"),
1078
+ "attack_tag": str(atr_raw.get("attack_tag", "")).strip(),
1079
+ "dimension": str(atr_raw.get("dimension", "")).strip(),
1080
+ "original_answer": str(atr_raw.get("original_answer", "")).strip()[:300],
1081
+ "why_it_hurt": str(atr_raw.get("why_it_hurt", "")).strip()[:300],
1082
+ "retry_advice": str(atr_raw.get("retry_advice", "")).strip(),
1083
+ "sample_stronger_answer": str(atr_raw.get("sample_stronger_answer", "")).strip()[:400],
1084
+ },
1085
+ "estimated_score_if_fixed": {
1086
+ "current_overall": esif_raw.get("current_overall"),
1087
+ "estimated_new_overall": esif_raw.get("estimated_new_overall"),
1088
+ "reason": str(esif_raw.get("reason", "")).strip(),
1089
+ },
1090
+ }
1091
+
1092
+
1093
+ def _resolve_score_explanation(
1094
+ nemotron_se: dict[str, Any] | None,
1095
+ local_se: dict[str, Any],
1096
+ overall: int,
1097
+ ) -> dict[str, Any]:
1098
+ """Merge Nemotron score_explanation with local fallback — never keep truncated fields."""
1099
+ if not nemotron_se:
1100
+ return local_se
1101
+
1102
+ result = {
1103
+ "why_you_scored_this": local_se.get("why_you_scored_this", ""),
1104
+ "what_stopped_80": local_se.get("what_stopped_80", ""),
1105
+ "answer_to_retry": dict(local_se.get("answer_to_retry", {})),
1106
+ "estimated_score_if_fixed": dict(local_se.get("estimated_score_if_fixed", {})),
1107
+ }
1108
+
1109
+ for field in ("why_you_scored_this", "what_stopped_80"):
1110
+ n_val = str(nemotron_se.get(field, "")).strip()
1111
+ if n_val and not _field_looks_truncated(field, n_val):
1112
+ result[field] = n_val
1113
+
1114
+ local_atr = local_se.get("answer_to_retry", {})
1115
+ n_atr = nemotron_se.get("answer_to_retry", {})
1116
+ merged_atr = dict(local_atr) if isinstance(local_atr, dict) else {}
1117
+ if isinstance(n_atr, dict):
1118
+ for key in ("round", "attack_tag", "dimension", "original_answer", "why_it_hurt"):
1119
+ n_val = n_atr.get(key)
1120
+ if key in ("original_answer", "why_it_hurt"):
1121
+ n_val = str(n_val or "").strip()
1122
+ if n_val and not _field_looks_truncated(key, n_val):
1123
+ merged_atr[key] = n_val[:300]
1124
+ elif n_val is not None and str(n_val).strip():
1125
+ merged_atr[key] = n_val
1126
+ retry = str(n_atr.get("retry_advice", "")).strip()
1127
+ if retry and not _field_looks_truncated("retry_advice", retry):
1128
+ merged_atr["retry_advice"] = retry
1129
+ sample = str(n_atr.get("sample_stronger_answer", "")).strip()
1130
+ if sample and not _field_looks_truncated("sample_stronger_answer", sample):
1131
+ merged_atr["sample_stronger_answer"] = sample[:400]
1132
+ result["answer_to_retry"] = merged_atr
1133
+
1134
+ local_esif = local_se.get("estimated_score_if_fixed", {})
1135
+ n_esif = nemotron_se.get("estimated_score_if_fixed", {})
1136
+ merged_esif = dict(local_esif) if isinstance(local_esif, dict) else {}
1137
+ if isinstance(n_esif, dict):
1138
+ est = n_esif.get("estimated_new_overall")
1139
+ reason = str(n_esif.get("reason", "")).strip()
1140
+ if isinstance(est, (int, float)) and not _field_looks_truncated("reason", reason):
1141
+ merged_esif["estimated_new_overall"] = _clamp(int(est), overall, 95)
1142
+ if reason and not _field_looks_truncated("reason", reason):
1143
+ merged_esif["reason"] = reason
1144
+ merged_esif["current_overall"] = overall
1145
+ result["estimated_score_if_fixed"] = merged_esif
1146
+
1147
+ return result
1148
+
1149
+
1150
+ def _parse_coaching_json(raw: str) -> dict[str, Any]:
1151
+ """Parse coaching JSON — best effort, may return partial fields."""
1152
+ parsed = parse_json_object(
1153
+ raw,
1154
+ string_fields=["improved_answer", "improved_pitch", "why_you_scored_this", "what_stopped_80"],
1155
+ )
1156
+ if not parsed:
1157
+ partial = extract_partial_string_fields(raw, ["improved_answer", "improved_pitch"])
1158
+ parsed = partial
1159
+
1160
+ if not parsed:
1161
+ return {}
1162
 
1163
  improved_answer = str(parsed.get("improved_answer", "")).strip()
1164
  improved_pitch = str(parsed.get("improved_pitch", "")).strip()
1165
  raw_q = parsed.get("top_3_questions", [])
1166
 
1167
+ if not raw_q:
1168
+ raw_q = extract_partial_string_list(raw, "top_3_questions", min_items=3)
1169
+
1170
  if isinstance(raw_q, list):
1171
  questions = [str(q).strip() for q in raw_q if str(q).strip()][:3]
1172
  else:
1173
  questions = []
1174
 
1175
+ result: dict[str, Any] = {}
1176
+ if improved_answer and not ends_abruptly(improved_answer):
1177
+ result["improved_answer"] = improved_answer
1178
+ if improved_pitch and not ends_abruptly(improved_pitch):
1179
+ result["improved_pitch"] = improved_pitch
1180
+ if questions:
1181
+ result["top_3_questions"] = questions
1182
+
1183
+ se = _parse_coaching_score_explanation(parsed.get("score_explanation"))
1184
+ if se:
1185
+ result["score_explanation"] = se
1186
+ elif isinstance(parsed.get("score_explanation"), dict):
1187
+ se_partial = parsed["score_explanation"]
1188
+ if isinstance(se_partial, dict):
1189
+ partial_se: dict[str, Any] = {}
1190
+ for field in ("why_you_scored_this", "what_stopped_80"):
1191
+ val = str(se_partial.get(field, "")).strip()
1192
+ if val and not _field_looks_truncated(field, val):
1193
+ partial_se[field] = val
1194
+ if partial_se:
1195
+ result["score_explanation"] = partial_se
1196
 
1197
+ return result
 
 
1198
 
1199
+
1200
+ def _coaching_source_label(nemotron: dict[str, Any], local: dict[str, Any]) -> str:
1201
+ """Classify how much coaching came from Nemotron vs local fallback."""
1202
+ core_keys = ("improved_answer", "improved_pitch", "top_3_questions")
1203
+ n_hits = sum(1 for k in core_keys if nemotron.get(k))
1204
+ if n_hits >= 3:
1205
+ return "nemotron"
1206
+ if n_hits > 0:
1207
+ return "partial_nemotron_local"
1208
+ return "local"
1209
+
1210
+
1211
+ def _merge_coaching_with_local(
1212
+ nemotron: dict[str, Any] | None,
1213
+ local: dict[str, Any],
1214
+ ) -> tuple[dict[str, Any], str]:
1215
+ """Merge Nemotron coaching with local fallback field-by-field."""
1216
+ nemotron = nemotron or {}
1217
+ merged = dict(local)
1218
+ for key in ("improved_answer", "improved_pitch"):
1219
+ val = str(nemotron.get(key, "")).strip()
1220
+ if val and not ends_abruptly(val):
1221
+ merged[key] = val
1222
+ n_q = nemotron.get("top_3_questions")
1223
+ if isinstance(n_q, list) and len(n_q) >= 3:
1224
+ merged["top_3_questions"] = [str(q).strip() for q in n_q[:3]]
1225
+ elif isinstance(n_q, list) and n_q:
1226
+ base = list(local.get("top_3_questions", []))
1227
+ for i, q in enumerate(n_q):
1228
+ if i < 3 and str(q).strip():
1229
+ if i < len(base):
1230
+ base[i] = str(q).strip()
1231
+ else:
1232
+ base.append(str(q).strip())
1233
+ while len(base) < 3:
1234
+ base.append("What concrete evidence can you give to support your strongest claim?")
1235
+ merged["top_3_questions"] = base[:3]
1236
+ if nemotron.get("score_explanation"):
1237
+ merged["score_explanation"] = nemotron["score_explanation"]
1238
+ return merged, _coaching_source_label(nemotron, local)
1239
+
1240
+
1241
+ def _resolve_coaching_from_raw(
1242
+ coaching_raw: str,
1243
+ local: dict[str, Any],
1244
+ resolved_mode: str,
1245
+ ) -> tuple[dict[str, Any], str]:
1246
+ """Parse + repair Nemotron coaching, merging with local field-by-field."""
1247
+ nemotron = _parse_coaching_json(coaching_raw) if coaching_raw else {}
1248
+ if not nemotron.get("improved_answer") and not nemotron.get("improved_pitch") and coaching_raw:
1249
+ logger.warning("scoring_engine: coaching parse failed, trying repair")
1250
+ try:
1251
+ repair = model_router.generate_coaching_repair_response(coaching_raw, model_mode=resolved_mode)
1252
+ if repair.get("ok") and repair.get("content"):
1253
+ repaired = _parse_coaching_json(repair["content"])
1254
+ for k, v in repaired.items():
1255
+ if v and not nemotron.get(k):
1256
+ nemotron[k] = v
1257
+ if repaired:
1258
+ logger.info("scoring_engine: repaired coaching JSON OK")
1259
+ except Exception as exc:
1260
+ logger.warning("scoring_engine: coaching repair raised — %s", exc)
1261
+
1262
+ merged, source = _merge_coaching_with_local(nemotron, local)
1263
+ if source == "local":
1264
+ logger.warning("scoring_engine: coaching fallback to local (scoring still nemotron_full)")
1265
+ elif source == "partial_nemotron_local":
1266
+ logger.info("scoring_engine: partial Nemotron coaching merged with local fields")
1267
+ return merged, source
1268
 
1269
 
1270
  # ---------------------------------------------------------------------------
 
1339
 
1340
 
1341
  # ---------------------------------------------------------------------------
1342
+ # Nemotron primary scoring path (Phase 8)split into two smaller calls
1343
+ #
1344
+ # Call 1: scorecard_scoring — 6 dimension scores + best/weakest/why_weak
1345
+ # Call 2: scorecard_coaching — improved_answer, improved_pitch, top_3_questions
1346
+ #
1347
+ # This split keeps each JSON payload small enough for long battles (9+ rounds).
1348
+ # scorecard_source = "nemotron_full" when Call 1 succeeds, regardless of Call 2.
1349
+ # ---------------------------------------------------------------------------
1350
+
1351
+ _SCORING_SCHEMA = (
1352
+ '{"scores":{'
1353
+ '"clarity":{"score":0,"reason":"","quote":"","signals_used":[]},'
1354
+ '"problem_understanding":{"score":0,"reason":"","quote":"","signals_used":[]},'
1355
+ '"market_awareness":{"score":0,"reason":"","quote":"","signals_used":[]},'
1356
+ '"differentiation":{"score":0,"reason":"","quote":"","signals_used":[]},'
1357
+ '"business_model":{"score":0,"reason":"","quote":"","signals_used":[]},'
1358
+ '"objection_handling":{"score":0,"reason":"","quote":"","signals_used":[]}},'
1359
+ '"best_answer":"","weakest_answer":"","why_weak":""}'
1360
+ )
1361
+
1362
+
1363
+ def _build_scoring_only_prompt(
1364
+ session: dict,
1365
+ signals: dict,
1366
+ local_reference: dict | None,
1367
+ difficulty_profile: str,
1368
+ difficulty_label: str,
1369
+ ) -> list[dict[str, str]]:
1370
+ """Build the Nemotron scoring-only prompt (Call 1).
1371
+
1372
+ Returns scores for all 6 dims + best/weakest/why_weak.
1373
+ No coaching text, no score_explanation — keeps the JSON small.
1374
+ """
1375
+ startup = session.get("startup", {})
1376
+ history = session.get("history", [])
1377
+
1378
+ startup_block = "\n".join([
1379
+ f"Startup: {startup.get('name', 'Unknown')}",
1380
+ f"Problem: {startup.get('problem', 'Not stated')}",
1381
+ f"Solution: {startup.get('solution', 'Not stated')}",
1382
+ f"Stage: {startup.get('stage', 'Not stated')}",
1383
+ f"Traction: {startup.get('traction', 'Not stated')}",
1384
+ ])
1385
+
1386
+ # Battle history — truncate each turn to keep prompt lean
1387
+ ai_turns = [m for m in history if m.get("role") == "assistant"]
1388
+ user_turns = [m for m in history if m.get("role") == "user"]
1389
+ history_lines: list[str] = ["BATTLE Q&A:"]
1390
+ for i, (ai_msg, user_msg) in enumerate(zip(ai_turns, user_turns), start=1):
1391
+ history_lines.append(f"R{i} Judge: {ai_msg.get('content','')[:200]}")
1392
+ history_lines.append(f"R{i} Founder: {user_msg.get('content','')[:200]}")
1393
+ battle_block = "\n".join(history_lines)
1394
+
1395
+ # Signals block — brief
1396
+ sig_parts: list[str] = []
1397
+ for key, label in [
1398
+ ("numbers", "Numbers"), ("validation", "Validation"),
1399
+ ("competitors", "Competitors"), ("pricing", "Pricing"),
1400
+ ("technical_mechanisms", "Tech"), ("non_answers", "Non-answers"),
1401
+ ]:
1402
+ items = signals.get(key, [])[:4]
1403
+ if items:
1404
+ sig_parts.append(f"{label}: {', '.join(str(x) for x in items)}")
1405
+ signals_block = "SIGNALS: " + " | ".join(sig_parts) if sig_parts else ""
1406
+
1407
+ local_block = ""
1408
+ if local_reference and isinstance(local_reference.get("scores"), dict):
1409
+ ref_parts = []
1410
+ for dim in _REQUIRED_DIMS:
1411
+ d = local_reference["scores"].get(dim, {})
1412
+ ref_parts.append(f"{dim}={d.get('score','?')}")
1413
+ local_block = "LOCAL REF (hints only): " + ", ".join(ref_parts)
1414
+
1415
+ profile_guidance = {
1416
+ "practice": (
1417
+ "This founder is a STUDENT practising. Judge intent and real signals generously. "
1418
+ "A genuine attempt that includes one concrete detail (a number, a named user, a "
1419
+ "real test result) should land 55+. Never punish casual phrasing, nerves, short "
1420
+ "answers, or imperfect grammar. Reserve low scores for non-answers or honest "
1421
+ "admissions of not knowing."
1422
+ ),
1423
+ "judge": "Balanced hackathon judging. Reward concrete evidence. Penalise deflection.",
1424
+ "investor": "Investor-grade. Vague answers on revenue/moat hurt significantly.",
1425
+ }.get(difficulty_profile, "Be fair and honest.")
1426
+
1427
+ system_content = (
1428
+ "Return ONLY valid JSON. First character {. Last character }. No markdown. No explanation.\n\n"
1429
+ "You are scoring a real founder talking, often a student. Judge whether the answer "
1430
+ "contains the RIGHT KIND OF PROOF for the question — not how polished it sounds.\n\n"
1431
+ "SCORE WHAT MATTERS:\n"
1432
+ " Score the PRESENCE and RELEVANCE of concrete signals (a real number, a named user, "
1433
+ "a test/pilot result, a named competitor with a reason, a pricing figure).\n"
1434
+ " Do NOT reward length, fluency, grammar, jargon, or polish. A short, plain, or "
1435
+ "informal answer that carries one real proof point must score the SAME as a long "
1436
+ "polished answer with the same proof. Do not reward verbosity.\n"
1437
+ " Example: 'we tested with 40 students and the quiz group did better' is REAL "
1438
+ "validation — score it as concrete evidence even though it is short and casual.\n\n"
1439
+ "RELEVANCE GUARD (do not let this be gamed):\n"
1440
+ " A signal only counts for the dimension it actually addresses. Naming a competitor "
1441
+ "or saying a buzzword does NOT earn differentiation if the founder cannot say why they "
1442
+ "are better. If the founder says 'I don't know' / 'okay' / one word, or admits the "
1443
+ "issue is unsolved, score THAT dimension honestly low (10-25) even if keywords appear.\n\n"
1444
+ "BANDS:\n"
1445
+ " Non-answer / 'I don't know' / one word = 10-25.\n"
1446
+ " Relevant attempt but no concrete proof = 35-50.\n"
1447
+ " At least one real, relevant proof point (even if short/casual) = 55-78.\n"
1448
+ " Strong answer with specific, well-matched proof = 79-92.\n"
1449
+ " Recovery rule: score the strongest relevant answer if the founder improved later.\n"
1450
+ " Do NOT hallucinate facts not in the conversation.\n"
1451
+ " Each reason: 1 sentence. Quote: short excerpt. signals_used: max 4 items.\n"
1452
+ " best_answer and weakest_answer: copy the ACTUAL founder answer text verbatim.\n"
1453
+ " NEVER use round labels like R1, R2, R4 — always paste the real answer sentence(s).\n\n"
1454
+ "The SIGNALS block below was extracted from the founder's answers — credit those real "
1455
+ "signals for the dimensions they fit, even when the wording was brief or informal.\n\n"
1456
+ f"PROFILE: {difficulty_label} — {profile_guidance}\n\n"
1457
+ "SCHEMA:\n" + _SCORING_SCHEMA
1458
+ )
1459
+
1460
+ user_content = (
1461
+ f"{startup_block}\n\n"
1462
+ f"{battle_block}\n\n"
1463
+ f"{signals_block}\n"
1464
+ f"{local_block}\n\n"
1465
+ "Score each dimension based on what was ACTUALLY said. Return JSON only."
1466
+ )
1467
+
1468
+ return [
1469
+ {"role": "system", "content": system_content},
1470
+ {"role": "user", "content": user_content},
1471
+ ]
1472
+
1473
+
1474
+ def _normalize_scoring_json(parsed: dict) -> dict:
1475
+ """Fill missing reason fields so structurally valid JSON passes validation."""
1476
+ if not isinstance(parsed, dict):
1477
+ return parsed
1478
+ scores = parsed.get("scores")
1479
+ if not isinstance(scores, dict):
1480
+ return parsed
1481
+ for dim in _REQUIRED_DIMS:
1482
+ d = scores.get(dim)
1483
+ if not isinstance(d, dict):
1484
+ scores[dim] = {"score": 0, "reason": "No reasoning provided.", "quote": "", "signals_used": []}
1485
+ continue
1486
+ if not str(d.get("reason", "")).strip():
1487
+ score = d.get("score", 0)
1488
+ try:
1489
+ score_int = int(round(float(score)))
1490
+ except (TypeError, ValueError):
1491
+ score_int = 0
1492
+ d["reason"] = f"Score: {score_int} based on answer quality."
1493
+ return parsed
1494
+
1495
+
1496
+ def _validate_scoring_json(parsed: dict) -> bool:
1497
+ """Return True if the scoring-only JSON has all required dimension fields."""
1498
+ if not isinstance(parsed, dict):
1499
+ return False
1500
+ scores = parsed.get("scores")
1501
+ if not isinstance(scores, dict) or len(scores) < 6:
1502
+ return False
1503
+ for dim in _REQUIRED_DIMS:
1504
+ d = scores.get(dim)
1505
+ if not isinstance(d, dict):
1506
+ return False
1507
+ score = d.get("score")
1508
+ if not isinstance(score, (int, float)):
1509
+ return False
1510
+ if not str(d.get("reason", "")).strip():
1511
+ return False
1512
+ return True
1513
+
1514
+
1515
+ def _normalize_scoring_result(parsed: dict) -> tuple[dict[str, Any], str, str, str]:
1516
+ """Extract and normalize dimension scores from scoring-only JSON.
1517
+
1518
+ Returns (scores_dict, best_answer, weakest_answer, why_weak).
1519
+ Backend computes labels from Nemotron scores.
1520
+ """
1521
+ raw_scores = parsed.get("scores", {})
1522
+ scores: dict[str, Any] = {}
1523
+ for dim in _REQUIRED_DIMS:
1524
+ d = raw_scores.get(dim, {})
1525
+ raw_score = d.get("score", 0)
1526
+ score = _clamp(int(round(float(raw_score))), 0, 100)
1527
+ scores[dim] = {
1528
+ "score": score,
1529
+ "label": _score_label(score),
1530
+ "reason": str(d.get("reason", "")).strip()[:280] or f"Score: {score}",
1531
+ "quote": str(d.get("quote", "")).strip()[:160],
1532
+ "signals_used": [str(s) for s in d.get("signals_used", [])][:5],
1533
+ }
1534
+ best_answer = str(parsed.get("best_answer", "")).strip()[:400]
1535
+ weakest_answer = str(parsed.get("weakest_answer", "")).strip()[:400]
1536
+ why_weak = str(parsed.get("why_weak", "")).strip()[:300]
1537
+ return scores, best_answer, weakest_answer, why_weak
1538
+
1539
+
1540
+ def _call_nemotron_scoring(
1541
+ session: dict,
1542
+ signals: dict,
1543
+ local_reference: dict | None,
1544
+ difficulty_profile: str,
1545
+ difficulty_label: str,
1546
+ resolved_mode: str,
1547
+ ) -> tuple[dict[str, Any], str, str, str] | None:
1548
+ """Call Nemotron for dimension scores only (Call 1).
1549
+
1550
+ Returns (scores, best_answer, weakest_answer, why_weak) on success, or None on failure.
1551
+ """
1552
+ messages = _build_scoring_only_prompt(
1553
+ session, signals, local_reference, difficulty_profile, difficulty_label
1554
+ )
1555
+ raw_content = ""
1556
+ try:
1557
+ result = model_router.generate_scoring_response(messages, model_mode=resolved_mode)
1558
+ if result.get("ok") and result.get("content"):
1559
+ raw_content = result["content"]
1560
+ else:
1561
+ logger.warning("scoring_engine: Nemotron scoring call not ok — %s", result.get("error"))
1562
+ return None
1563
+ except Exception as exc:
1564
+ logger.warning("scoring_engine: Nemotron scoring raised — %s", exc)
1565
+ return None
1566
+
1567
+ parsed, extraction_used = parse_model_json(raw_content)
1568
+ if not isinstance(parsed, dict) or not parsed:
1569
+ parsed = safe_json_parse(raw_content)
1570
+ extraction_used = True
1571
+
1572
+ if isinstance(parsed, dict) and parsed:
1573
+ parsed = _normalize_scoring_json(parsed)
1574
+ if _validate_scoring_json(parsed):
1575
+ logger.info(
1576
+ "scoring_engine: Nemotron scoring JSON parsed OK (content_len=%d extraction=%s preview=%r)",
1577
+ len(raw_content),
1578
+ extraction_used,
1579
+ sanitize_for_log(raw_content),
1580
+ )
1581
+ return _normalize_scoring_result(parsed)
1582
+
1583
+ # Repair attempt
1584
+ logger.warning(
1585
+ "scoring_engine: Nemotron scoring parse failed, attempting repair "
1586
+ "(content_len=%d preview=%r)",
1587
+ len(raw_content),
1588
+ sanitize_for_log(raw_content),
1589
+ )
1590
+ try:
1591
+ repair = model_router.generate_scoring_repair_response(raw_content, model_mode=resolved_mode)
1592
+ if repair.get("ok") and repair.get("content"):
1593
+ repaired, _ = parse_model_json(repair["content"])
1594
+ if not isinstance(repaired, dict) or not repaired:
1595
+ repaired = safe_json_parse(repair["content"])
1596
+ if isinstance(repaired, dict) and repaired:
1597
+ repaired = _normalize_scoring_json(repaired)
1598
+ if isinstance(repaired, dict) and repaired and _validate_scoring_json(repaired):
1599
+ logger.info("scoring_engine: repaired scoring JSON OK")
1600
+ return _normalize_scoring_result(repaired)
1601
+ except Exception as exc:
1602
+ logger.warning("scoring_engine: scoring repair raised — %s", exc)
1603
+
1604
+ logger.warning("scoring_engine: Nemotron scoring failed — will fall back to local scores")
1605
+ return None
1606
+
1607
+
1608
+ # ---------------------------------------------------------------------------
1609
+ # Main scorecard generator — Nemotron full scoring primary (Phase 8)
1610
  # ---------------------------------------------------------------------------
1611
 
1612
  def generate_claim_based_scorecard(
1613
  session: dict, model_mode: str | None = None
1614
  ) -> dict[str, Any]:
1615
+ """Main scorecard generator — split Nemotron calls for reliability.
1616
+
1617
+ Call 1 (scorecard_scoring): Nemotron judges all 6 dims from actual Q&A.
1618
+ Call 2 (scorecard_coaching): Nemotron generates coaching text + score_explanation.
1619
 
1620
+ scorecard_source = "nemotron_full" when Call 1 succeeds (regardless of Call 2).
1621
+ scorecard_source = "hybrid_claims_*" when Call 1 fails (fallback only).
1622
 
1623
  Returns a frontend-safe dict with all required fields on every path.
1624
  """
1625
  resolved_mode = model_mode or session.get("model_mode") or os.getenv(
1626
  "DEFAULT_MODEL_MODE", "premium_nvidia"
1627
  )
1628
+ difficulty_profile = normalize_difficulty(
1629
+ session.get("difficulty_profile") or session.get("difficulty") or "practice"
1630
+ )
1631
+ difficulty_label = get_label(difficulty_profile)
1632
+ cal = get_scoring_calibration(difficulty_profile)
1633
  startup = session.get("startup", {})
1634
 
1635
+ # Step 1: Extract signals always needed for context + fallback
1636
  try:
1637
  signals = extract_concrete_signals(session)
1638
  except Exception as exc:
1639
  logger.warning("scoring_engine: signal extraction failed: %s", exc)
1640
  signals = _empty_signals()
1641
 
1642
+ engagement_info = _battle_engagement(signals)
1643
+ startup_signals = extract_startup_context_signals(session)
1644
+ has_startup = _has_startup_context(startup, startup_signals)
1645
+ if engagement_info["substantive_answers"] == 0:
1646
+ signals = _merge_signal_dicts(signals, startup_signals)
 
 
 
1647
 
 
 
1648
  concrete_signals_summary = {
1649
  "numbers": signals.get("numbers", [])[:6],
1650
  "validation": signals.get("validation", [])[:6],
 
1653
  "technical_mechanisms": signals.get("technical_mechanisms", [])[:6],
1654
  }
1655
 
1656
+ # Step 2: Compute local scores as reference context + fallback
1657
+ local_reference: dict[str, Any] | None = None
1658
+ local_scores: dict[str, Any] | None = None
1659
+ local_best = ""
1660
+ local_weakest = ""
1661
+ local_why_weak = ""
1662
+ try:
1663
+ _ls, local_best, local_weakest, local_why_weak = _compute_local_scores(signals, startup, cal)
1664
+ local_scores = _ls
1665
+ local_reference = {"scores": local_scores, "best_answer": local_best, "weakest_answer": local_weakest}
1666
+ except Exception as exc:
1667
+ logger.warning("scoring_engine: local scoring failed: %s", exc)
1668
 
1669
+ # Step 3: Nemotron scoring call (Call 1) — skip when no substantive battle answers
1670
+ nemotron_scoring_result = None
1671
+ skip_nemotron_scoring = engagement_info["substantive_answers"] == 0
1672
+ if skip_nemotron_scoring:
1673
+ logger.info(
1674
+ "scoring_engine: skipping Nemotron scoring — no substantive battle answers "
1675
+ "(user_turns=%d substantive=%d has_startup=%s)",
1676
+ engagement_info["user_turns"],
1677
+ engagement_info["substantive_answers"],
1678
+ has_startup,
1679
+ )
1680
+ elif resolved_mode == "premium_nvidia":
1681
+ nemotron_scoring_result = _call_nemotron_scoring(
1682
+ session, signals, local_reference,
1683
+ difficulty_profile, difficulty_label, resolved_mode,
1684
+ )
1685
+
1686
+ if nemotron_scoring_result is not None:
1687
+ # Call 1 succeeded — scorecard_source is always "nemotron_full" from here
1688
+ scores, best_answer, weakest_answer, why_weak = nemotron_scoring_result
1689
+
1690
+ # Practice fairness: lift any dimension up to its signal-justified local floor
1691
+ # so a short, genuine student answer is not dragged down by a prose-biased judge.
1692
+ scores = _apply_practice_signal_floor(scores, local_reference, difficulty_profile)
1693
+
1694
+ # Resolve round refs (R2, R4) to actual founder answer text
1695
+ best_answer, weakest_answer, best_round, weakest_round = _resolve_best_weakest_answers(
1696
+ session, best_answer, weakest_answer, local_best, local_weakest,
1697
+ )
1698
+ if not why_weak and local_why_weak:
1699
+ why_weak = local_why_weak
1700
+
1701
+ overall = round(sum(d["score"] for d in scores.values()) / len(scores))
1702
+ overall = _apply_practice_score_nudge(overall, signals, difficulty_profile)
1703
+
1704
+ # Step 4: Nemotron coaching call
1705
+ local_coaching = _local_coaching(weakest_answer, startup, signals, scores)
1706
+ coaching_raw = ""
1707
+ try:
1708
+ coaching_messages = _build_coaching_prompt(
1709
+ session, signals, scores, best_answer, weakest_answer, why_weak,
1710
+ difficulty_profile=difficulty_profile,
1711
+ )
1712
+ coaching_result = model_router.generate_coaching_response(
1713
+ coaching_messages, model_mode=resolved_mode
1714
+ )
1715
+ if coaching_result.get("ok") and coaching_result.get("content"):
1716
+ coaching_raw = coaching_result["content"]
1717
+ else:
1718
+ logger.warning("scoring_engine: coaching call not ok — %s", coaching_result.get("error"))
1719
+ except Exception as exc:
1720
+ logger.warning("scoring_engine: coaching call raised — %s", exc)
1721
+
1722
+ coaching, coaching_source = _resolve_coaching_from_raw(
1723
+ coaching_raw, local_coaching, resolved_mode
1724
+ )
1725
+
1726
+ # Step 5: score_explanation — merge Nemotron coaching with local fallback (no truncated fields)
1727
+ try:
1728
+ local_score_explanation = _build_score_explanation(
1729
+ overall, scores, weakest_answer, why_weak, signals, session, difficulty_profile
1730
+ )
1731
+ except Exception as exc:
1732
+ logger.warning("scoring_engine: score_explanation build failed: %s", exc)
1733
+ local_score_explanation = {
1734
+ "why_you_scored_this": f"Your overall score is {overall}/100.",
1735
+ "what_stopped_80": "Focus on your weakest dimension to improve.",
1736
+ "answer_to_retry": {
1737
+ "round": None, "attack_tag": "", "dimension": "",
1738
+ "original_answer": weakest_answer[:200], "why_it_hurt": why_weak,
1739
+ "retry_advice": "", "sample_stronger_answer": "",
1740
+ },
1741
+ "estimated_score_if_fixed": {
1742
+ "current_overall": overall,
1743
+ "estimated_new_overall": min(overall + 10, 82),
1744
+ "reason": "Fixing your weakest answer could raise your overall score.",
1745
+ },
1746
+ }
1747
+ se_raw = coaching.get("score_explanation") if isinstance(coaching.get("score_explanation"), dict) else None
1748
+ score_explanation = _resolve_score_explanation(se_raw, local_score_explanation, overall)
1749
+
1750
+ q3 = coaching.get("top_3_questions", [])
1751
+ while len(q3) < 3:
1752
+ q3.append("What concrete evidence can you give to support your strongest claim?")
1753
+
1754
+ logger.info(
1755
+ "scoring_engine: nemotron_full complete — overall=%d signals=%d",
1756
+ overall, signals.get("signal_count", 0),
1757
+ )
1758
+ result = {
1759
+ "overall": overall,
1760
+ "overall_label": _score_label(overall),
1761
+ "scores": scores,
1762
+ "best_answer": best_answer,
1763
+ "weakest_answer": weakest_answer,
1764
+ "best_answer_round": best_round,
1765
+ "weakest_answer_round": weakest_round,
1766
+ "why_weak": why_weak,
1767
+ "improved_answer": coaching.get("improved_answer", ""),
1768
+ "improved_pitch": coaching.get("improved_pitch", ""),
1769
+ "top_3_questions": q3[:3],
1770
+ "concrete_signals_summary": concrete_signals_summary,
1771
+ "score_explanation": score_explanation,
1772
+ "model_ok": True,
1773
+ "provider": "nvidia",
1774
+ "model_mode": resolved_mode,
1775
+ "scorecard_source": "nemotron_full",
1776
+ "coaching_source": coaching_source,
1777
+ "difficulty_profile": difficulty_profile,
1778
+ "difficulty_label": difficulty_label,
1779
+ }
1780
+ result = _sync_overall_to_dimensions(result)
1781
+ result["overall"] = _apply_practice_score_nudge(
1782
+ int(result["overall"]), signals, difficulty_profile
1783
+ )
1784
+ result["overall_label"] = _score_label(result["overall"])
1785
+ return result
1786
+
1787
+ # Step 6: Local claim-based path (Nemotron skipped or failed)
1788
+ if skip_nemotron_scoring:
1789
+ logger.info("scoring_engine: building local scorecard for zero-engagement battle")
1790
+ else:
1791
+ logger.warning("scoring_engine: Nemotron scoring failed; using claim-based fallback")
1792
+
1793
+ if local_scores is None:
1794
+ return build_session_aware_fallback_scorecard(
1795
+ session, signals, "All scoring paths failed"
1796
+ )
1797
+
1798
+ scores = local_scores
1799
+ best_answer, weakest_answer, best_round, weakest_round = _resolve_best_weakest_answers(
1800
+ session, local_best, local_weakest, local_best, local_weakest,
1801
+ )
1802
+ why_weak = local_why_weak
1803
+ overall = round(sum(d["score"] for d in scores.values()) / len(scores))
1804
+ overall = _apply_practice_score_nudge(overall, signals, difficulty_profile)
1805
+
1806
+ coaching = None
1807
+ coaching_raw = ""
1808
+ local_coaching = _local_coaching(weakest_answer, startup, signals, scores)
1809
  try:
1810
  coaching_messages = _build_coaching_prompt(
1811
+ session, signals, scores, best_answer, weakest_answer, why_weak,
1812
+ difficulty_profile=difficulty_profile,
1813
  )
1814
  coaching_result = model_router.generate_coaching_response(
1815
  coaching_messages, model_mode=resolved_mode
1816
  )
1817
  if coaching_result.get("ok") and coaching_result.get("content"):
1818
  coaching_raw = coaching_result["content"]
 
 
 
 
 
 
 
 
1819
  except Exception as exc:
1820
+ logger.warning("scoring_engine: fallback coaching raised — %s", exc)
 
1821
 
1822
+ coaching, coaching_source = _resolve_coaching_from_raw(
1823
+ coaching_raw, local_coaching, resolved_mode
1824
+ )
 
 
 
 
 
 
 
 
 
 
 
 
1825
 
1826
+ if skip_nemotron_scoring:
1827
+ if has_startup or signals.get("signal_count", 0) > 0:
1828
+ source = "startup_context_only"
1829
+ else:
1830
+ source = "no_battle_response"
1831
+ provider = "local+nvidia" if coaching_source != "local" else "local"
1832
+ elif coaching_source == "local":
1833
  source = "hybrid_claims_local"
 
1834
  provider = "local"
1835
  else:
1836
  source = "hybrid_claims_nemotron"
 
1837
  provider = "local+nvidia"
1838
 
1839
+ try:
1840
+ score_explanation = _build_score_explanation(
1841
+ overall, scores, weakest_answer, why_weak, signals, session, difficulty_profile
1842
+ )
1843
+ except Exception:
1844
+ score_explanation = {
1845
+ "why_you_scored_this": f"Your overall score is {overall}/100.",
1846
+ "what_stopped_80": "Focus on your weakest dimension to improve.",
1847
+ "answer_to_retry": {
1848
+ "round": None, "attack_tag": "", "dimension": "",
1849
+ "original_answer": weakest_answer[:200], "why_it_hurt": why_weak,
1850
+ "retry_advice": "", "sample_stronger_answer": "",
1851
+ },
1852
+ "estimated_score_if_fixed": {
1853
+ "current_overall": overall,
1854
+ "estimated_new_overall": min(overall + 10, 82),
1855
+ "reason": "Fixing your weakest answer could raise your overall score.",
1856
+ },
1857
+ }
1858
+
1859
+ se_raw = coaching.get("score_explanation") if isinstance(coaching.get("score_explanation"), dict) else None
1860
+ score_explanation = _resolve_score_explanation(se_raw, score_explanation, overall)
1861
+
1862
+ q3 = coaching.get("top_3_questions", [])
1863
+ while len(q3) < 3:
1864
+ q3.append("What concrete evidence can you give to support your strongest claim?")
1865
+
1866
+ logger.info(
1867
+ "scoring_engine: fallback scorecard complete — overall=%d source=%s",
1868
+ overall, source,
1869
+ )
1870
+ return {
1871
  "overall": overall,
1872
  "overall_label": _score_label(overall),
1873
  "scores": scores,
1874
  "best_answer": best_answer,
1875
  "weakest_answer": weakest_answer,
1876
+ "best_answer_round": best_round,
1877
+ "weakest_answer_round": weakest_round,
1878
  "why_weak": why_weak,
1879
+ "improved_answer": coaching.get("improved_answer", ""),
1880
+ "improved_pitch": coaching.get("improved_pitch", ""),
1881
+ "top_3_questions": q3[:3],
1882
  "concrete_signals_summary": concrete_signals_summary,
1883
+ "score_explanation": score_explanation,
1884
+ "model_ok": False,
1885
  "provider": provider,
1886
  "model_mode": resolved_mode,
1887
  "scorecard_source": source,
1888
+ "coaching_source": coaching_source,
1889
+ "difficulty_profile": difficulty_profile,
1890
+ "difficulty_label": difficulty_label,
1891
+ "model_error": (
1892
+ "No battle answers were submitted."
1893
+ if skip_nemotron_scoring and not has_startup and signals.get("signal_count", 0) == 0
1894
+ else (
1895
+ "Scored from startup description only — complete the battle to earn full points."
1896
+ if skip_nemotron_scoring
1897
+ else "Nemotron scoring failed; used local scoring fallback."
1898
+ )
1899
+ ),
1900
  }
 
 
 
 
 
 
 
 
1901
 
1902
 
1903
  # ---------------------------------------------------------------------------
 
2006
 
2007
  overall = round(sum(d["score"] for d in scores.values()) / 6)
2008
  dim_sorted = sorted(scores.items(), key=lambda x: x[1]["score"])
2009
+ fallback_why_weak = "This answer lacked concrete evidence compared to your stronger responses."
2010
+
2011
+ try:
2012
+ fb_explanation = _build_score_explanation(
2013
+ overall, scores, weakest_answer, fallback_why_weak, signals, session, "practice"
2014
+ )
2015
+ except Exception:
2016
+ fb_explanation = {
2017
+ "why_you_scored_this": f"Your overall score is {overall}/100.",
2018
+ "what_stopped_80": "Focus on your weakest dimension to improve.",
2019
+ "answer_to_retry": {
2020
+ "round": None, "attack_tag": "", "dimension": "",
2021
+ "original_answer": weakest_answer[:200],
2022
+ "why_it_hurt": fallback_why_weak, "retry_advice": "",
2023
+ "sample_stronger_answer": "",
2024
+ },
2025
+ "estimated_score_if_fixed": {
2026
+ "current_overall": overall,
2027
+ "estimated_new_overall": min(overall + 10, 82),
2028
+ "reason": "Fixing your weakest answer could raise your overall score.",
2029
+ },
2030
+ }
2031
 
2032
  return {
2033
  "overall": overall,
 
2035
  "scores": scores,
2036
  "best_answer": best_answer,
2037
  "weakest_answer": weakest_answer,
2038
+ "why_weak": fallback_why_weak,
2039
  "improved_answer": _local_improved_answer(weakest_answer, startup, signals),
2040
  "improved_pitch": _local_improved_pitch(startup, signals),
2041
  "top_3_questions": _fallback_questions(dim_sorted, startup),
 
2046
  "revenue_signals": signals.get("revenue_signals", [])[:6],
2047
  "technical_mechanisms": signals.get("technical_mechanisms", [])[:6],
2048
  },
2049
+ "score_explanation": fb_explanation,
2050
  "model_ok": False,
2051
  "provider": "local",
2052
  "model_mode": "session_fallback",
core/session_manager.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import uuid
6
  from typing import Any
7
 
@@ -24,6 +25,9 @@ def create_session(
24
  "input_mode": input_mode,
25
  "round": 1,
26
  "history": [],
 
 
 
27
  }
28
  SESSIONS[session_id] = session
29
  return session
@@ -75,3 +79,51 @@ def reset_session(session_id: str) -> bool:
75
  del SESSIONS[session_id]
76
  return True
77
  return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
+ import re
6
  import uuid
7
  from typing import Any
8
 
 
25
  "input_mode": input_mode,
26
  "round": 1,
27
  "history": [],
28
+ "voice_pitch": None,
29
+ "pending_voice_turns": {},
30
+ "confirmed_voice_turns": [],
31
  }
32
  SESSIONS[session_id] = session
33
  return session
 
79
  del SESSIONS[session_id]
80
  return True
81
  return False
82
+
83
+
84
+ def set_voice_pitch(session_id: str, voice_pitch: dict[str, Any]) -> None:
85
+ """Store opening voice pitch metadata on session."""
86
+ session = SESSIONS.get(session_id)
87
+ if session:
88
+ session["voice_pitch"] = voice_pitch
89
+
90
+
91
+ def store_pending_voice_turn(session_id: str, turn_record: dict[str, Any]) -> None:
92
+ """Store a pending (unconfirmed) voice turn."""
93
+ session = SESSIONS.get(session_id)
94
+ if not session:
95
+ return
96
+ pending = session.setdefault("pending_voice_turns", {})
97
+ vid = turn_record.get("voice_turn_id", "")
98
+ if vid:
99
+ pending[vid] = turn_record
100
+
101
+
102
+ def confirm_voice_turn(
103
+ session_id: str,
104
+ voice_turn_id: str,
105
+ final_transcript: str,
106
+ ) -> bool:
107
+ """Confirm a pending voice turn and move it to confirmed_voice_turns."""
108
+ session = SESSIONS.get(session_id)
109
+ if not session:
110
+ return False
111
+ pending = session.get("pending_voice_turns") or {}
112
+ turn = pending.get(voice_turn_id)
113
+ if not turn:
114
+ return False
115
+ turn = dict(turn)
116
+ turn["transcript"] = str(final_transcript).strip()
117
+ turn["confirmed"] = True
118
+ turn["word_count"] = len(re.findall(r"\b\w+\b", turn["transcript"]))
119
+ session.setdefault("confirmed_voice_turns", []).append(turn)
120
+ del pending[voice_turn_id]
121
+ return True
122
+
123
+
124
+ def get_pending_voice_turn(session_id: str, voice_turn_id: str) -> dict[str, Any] | None:
125
+ """Return a pending voice turn by id."""
126
+ session = SESSIONS.get(session_id)
127
+ if not session:
128
+ return None
129
+ return (session.get("pending_voice_turns") or {}).get(voice_turn_id)
core/session_repository.py ADDED
@@ -0,0 +1,678 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MongoDB session repository for PitchFight AI.
3
+
4
+ Phase 9.5 rules:
5
+ - MongoDB is optional background persistence only.
6
+ - In-memory session_manager remains the live source of truth.
7
+ - Every repository operation is best-effort.
8
+ - MongoDB failure must never crash API handlers.
9
+ - Never store API keys, raw audio/base64, uploaded files, or model reasoning_content.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime, timezone
15
+ from typing import Any, Callable, Optional
16
+
17
+ from pymongo.collection import Collection
18
+ from pymongo.errors import PyMongoError
19
+
20
+ from core.db import get_sessions_collection, is_mongodb_enabled
21
+
22
+
23
+ SENSITIVE_KEYS = {
24
+ "api_key",
25
+ "apikey",
26
+ "nvidia_api_key",
27
+ "openai_api_key",
28
+ "secret",
29
+ "password",
30
+ "mongodb_uri",
31
+ "mongo_uri",
32
+ "uri",
33
+ "audio",
34
+ "audio_base64",
35
+ "raw_audio",
36
+ "base64_audio",
37
+ "uploaded_file",
38
+ "uploaded_files",
39
+ "file_bytes",
40
+ "raw_file",
41
+ "reasoning_content",
42
+ }
43
+
44
+ MAX_STRING_LENGTH = 12000
45
+
46
+
47
+ def _utc_now() -> str:
48
+ return datetime.now(timezone.utc).isoformat()
49
+
50
+
51
+ def _safe(operation_name: str, fn: Callable[[], Any]) -> Any:
52
+ """
53
+ Execute a MongoDB operation safely.
54
+
55
+ Never raise persistence errors into app/runtime flow.
56
+ """
57
+ if not is_mongodb_enabled():
58
+ return None
59
+
60
+ try:
61
+ return fn()
62
+ except (PyMongoError, Exception) as exc:
63
+ print(
64
+ "[MongoDB] Non-critical persistence warning "
65
+ f"in {operation_name}: {type(exc).__name__}"
66
+ )
67
+ return None
68
+
69
+
70
+ def _get_collection() -> Optional[Collection]:
71
+ return get_sessions_collection()
72
+
73
+
74
+ def _sanitize(value: Any) -> Any:
75
+ """
76
+ Recursively sanitize values before MongoDB storage.
77
+
78
+ Removes secrets, raw audio/base64 payloads, raw files, and model reasoning traces.
79
+ Converts unknown objects to strings.
80
+ """
81
+ if isinstance(value, dict):
82
+ cleaned: dict[str, Any] = {}
83
+
84
+ for key, item in value.items():
85
+ key_str = str(key)
86
+ key_lower = key_str.lower()
87
+
88
+ if key_lower in SENSITIVE_KEYS:
89
+ continue
90
+
91
+ cleaned[key_str] = _sanitize(item)
92
+
93
+ return cleaned
94
+
95
+ if isinstance(value, list):
96
+ return [_sanitize(item) for item in value]
97
+
98
+ if isinstance(value, tuple):
99
+ return [_sanitize(item) for item in value]
100
+
101
+ if isinstance(value, str):
102
+ if len(value) > MAX_STRING_LENGTH:
103
+ return value[:MAX_STRING_LENGTH] + "...[truncated]"
104
+ return value
105
+
106
+ if isinstance(value, (int, float, bool)) or value is None:
107
+ return value
108
+
109
+ return str(value)
110
+
111
+
112
+ def _startup_context_from_session(session: dict) -> dict:
113
+ startup = session.get("startup") or {}
114
+
115
+ context = {
116
+ "name": startup.get("name", ""),
117
+ "problem": startup.get("problem", ""),
118
+ "target_users": startup.get("target_users", ""),
119
+ "solution": startup.get("solution", ""),
120
+ "why_ai": startup.get("why_ai", ""),
121
+ "traction": startup.get("traction", ""),
122
+ "competitors": startup.get("competitors", ""),
123
+ "ask": startup.get("ask", ""),
124
+ "entry_mode": "voice_pitch" if session.get("voice_pitch") else "form",
125
+ }
126
+
127
+ # Preserve useful extra startup fields without storing secrets/raw payloads.
128
+ extra = {
129
+ key: value
130
+ for key, value in startup.items()
131
+ if key not in context
132
+ }
133
+
134
+ if extra:
135
+ context["extra"] = _sanitize(extra)
136
+
137
+ return _sanitize(context)
138
+
139
+
140
+ def _voice_pitch_entry_from_session(session: dict) -> dict:
141
+ voice_pitch = session.get("voice_pitch") or {}
142
+
143
+ if not voice_pitch:
144
+ return {
145
+ "used": False,
146
+ "transcript": "",
147
+ "delivery_observations": {
148
+ "filler_words": [],
149
+ "filler_word_count": 0,
150
+ "pace": "",
151
+ "clarity": "",
152
+ "confidence_signal": "",
153
+ "word_count": 0,
154
+ "delivery_note": "",
155
+ },
156
+ "extraction_confidence": "",
157
+ }
158
+
159
+ transcript = voice_pitch.get("transcript", "") or ""
160
+ observations = voice_pitch.get("delivery_observations") or {}
161
+
162
+ filler_words = observations.get("filler_words") or []
163
+ if not isinstance(filler_words, list):
164
+ filler_words = [str(filler_words)]
165
+
166
+ delivery_observations = {
167
+ "filler_words": filler_words,
168
+ "filler_word_count": len(filler_words),
169
+ "pace": observations.get("pace", ""),
170
+ "clarity": observations.get("clarity", ""),
171
+ "confidence_signal": observations.get("confidence_signal", ""),
172
+ "word_count": len(transcript.split()) if transcript else 0,
173
+ "delivery_note": observations.get("delivery_note", ""),
174
+ }
175
+
176
+ return _sanitize(
177
+ {
178
+ "used": True,
179
+ "transcript": transcript,
180
+ "delivery_observations": delivery_observations,
181
+ "extraction_confidence": voice_pitch.get("extraction_confidence", ""),
182
+ }
183
+ )
184
+
185
+
186
+ def _rounds_from_history(history: Any) -> list[dict]:
187
+ if not isinstance(history, list):
188
+ return []
189
+
190
+ rounds: list[dict] = []
191
+
192
+ for index, item in enumerate(history):
193
+ if not isinstance(item, dict):
194
+ continue
195
+
196
+ rounds.append(
197
+ _sanitize(
198
+ {
199
+ "sequence": index + 1,
200
+ "round_number": item.get("round") or item.get("round_number") or None,
201
+ "role": item.get("role", ""),
202
+ "content": item.get("content", ""),
203
+ "attack_tag": item.get("attack_tag", ""),
204
+ "answer_quality": item.get("answer_quality", ""),
205
+ "timestamp": item.get("timestamp", ""),
206
+ }
207
+ )
208
+ )
209
+
210
+ return rounds
211
+
212
+
213
+ def _retry_drills_from_session(session: dict) -> list[dict]:
214
+ retry_drills = session.get("retry_drills") or {}
215
+
216
+ if isinstance(retry_drills, dict):
217
+ drills = list(retry_drills.values())
218
+ elif isinstance(retry_drills, list):
219
+ drills = retry_drills
220
+ else:
221
+ drills = []
222
+
223
+ cleaned_drills = []
224
+
225
+ for drill in drills:
226
+ if not isinstance(drill, dict):
227
+ continue
228
+
229
+ cleaned_drill = dict(drill)
230
+ cleaned_drill.pop("voice_turn_id", None)
231
+ cleaned_drills.append(_sanitize(cleaned_drill))
232
+
233
+ return cleaned_drills
234
+
235
+
236
+ def _scorecard_for_storage(scorecard: dict) -> dict:
237
+ if not isinstance(scorecard, dict):
238
+ return {}
239
+
240
+ cleaned = dict(scorecard)
241
+
242
+ # Store judge verdict separately to avoid duplicated nested state.
243
+ cleaned.pop("judge_verdict", None)
244
+
245
+ return _sanitize(cleaned)
246
+
247
+
248
+ def _deal_phase_from_session(session: dict) -> dict:
249
+ return _sanitize(
250
+ {
251
+ "activated": bool(session.get("deal_phase_active", False)),
252
+ "deal_phase_id": session.get("deal_phase_id", ""),
253
+ "deal_type": session.get("deal_type", ""),
254
+ "deal_context": session.get("deal_context", {}) or {},
255
+ "deal_round": session.get("deal_round", 0) or 0,
256
+ "deal_history": session.get("deal_history", []) or [],
257
+ "deal_scorecard": session.get("deal_scorecard", {}) or {},
258
+ "combined_scorecard": session.get("combined_scorecard", {}) or {},
259
+ }
260
+ )
261
+
262
+
263
+ def _infer_status(session: dict) -> str:
264
+ if session.get("combined_scorecard") or session.get("deal_scorecard"):
265
+ return "completed"
266
+
267
+ if session.get("latest_scorecard") and not session.get("deal_phase_active"):
268
+ return "completed"
269
+
270
+ return "active"
271
+
272
+
273
+ def _session_document(session: dict) -> dict:
274
+ session_id = session.get("session_id")
275
+
276
+ return _sanitize(
277
+ {
278
+ "_id": session_id,
279
+ "meta": {
280
+ "created_at": _utc_now(),
281
+ "updated_at": _utc_now(),
282
+ "status": _infer_status(session),
283
+ "mode": session.get("mode", "pitch_battle"),
284
+ "input_mode": session.get("input_mode", "text"),
285
+ },
286
+ "config": {
287
+ "opponent": session.get("persona", ""),
288
+ "persona": session.get("persona", ""),
289
+ "model_mode": session.get("model_mode", ""),
290
+ "difficulty_profile": session.get("difficulty_profile")
291
+ or session.get("difficulty")
292
+ or "",
293
+ "difficulty_label": session.get("difficulty_label", ""),
294
+ },
295
+ "startup_context": _startup_context_from_session(session),
296
+ "voice_pitch_entry": _voice_pitch_entry_from_session(session),
297
+ "rounds": _rounds_from_history(session.get("history", [])),
298
+ "battle_summary": {
299
+ "total_rounds": session.get("round", 0),
300
+ "final_round": session.get("round", 0),
301
+ },
302
+ "scorecard": _scorecard_for_storage(session.get("latest_scorecard", {}) or {}),
303
+ "retry_drills": _retry_drills_from_session(session),
304
+ "judge_verdict": _sanitize(session.get("judge_verdict", {}) or {}),
305
+ "deal_phase": _deal_phase_from_session(session),
306
+ }
307
+ )
308
+
309
+
310
+ def _set_with_touch(
311
+ collection: Collection,
312
+ session_id: str,
313
+ fields: dict,
314
+ *,
315
+ status: Optional[str] = None,
316
+ ) -> None:
317
+ now = _utc_now()
318
+
319
+ set_fields = _sanitize(fields)
320
+ set_fields["meta.updated_at"] = now
321
+
322
+ if status:
323
+ set_fields["meta.status"] = status
324
+
325
+ collection.update_one(
326
+ {"_id": session_id},
327
+ {
328
+ "$setOnInsert": {
329
+ "_id": session_id,
330
+ "meta.created_at": now,
331
+ },
332
+ "$set": set_fields,
333
+ },
334
+ upsert=True,
335
+ )
336
+
337
+
338
+ def save_session(session: dict) -> Any:
339
+ """
340
+ Save/initialize one PitchFight session document.
341
+
342
+ Called after /api/start-session succeeds.
343
+ """
344
+ def op() -> Any:
345
+ collection = _get_collection()
346
+ if collection is None:
347
+ return None
348
+
349
+ doc = _session_document(session)
350
+ session_id = doc.get("_id")
351
+
352
+ if not session_id:
353
+ print("[MongoDB] save_session skipped: missing session_id.")
354
+ return None
355
+
356
+ meta = doc.pop("meta", {}) or {}
357
+ doc_without_id = {key: value for key, value in doc.items() if key != "_id"}
358
+
359
+ set_fields = dict(doc_without_id)
360
+ set_fields["meta.updated_at"] = _utc_now()
361
+ set_fields["meta.status"] = meta.get("status", "active")
362
+ set_fields["meta.mode"] = meta.get("mode", "pitch_battle")
363
+ set_fields["meta.input_mode"] = meta.get("input_mode", "text")
364
+
365
+ return collection.update_one(
366
+ {"_id": session_id},
367
+ {
368
+ "$setOnInsert": {
369
+ "_id": session_id,
370
+ "meta.created_at": meta.get("created_at", _utc_now()),
371
+ },
372
+ "$set": set_fields,
373
+ },
374
+ upsert=True,
375
+ )
376
+
377
+ return _safe("save_session", op)
378
+
379
+
380
+ def update_round(session_id: str, round_data: Any) -> Any:
381
+ """
382
+ Append one or more battle round/history entries.
383
+
384
+ Called after /api/chat-round succeeds.
385
+ """
386
+ def op() -> Any:
387
+ collection = _get_collection()
388
+ if collection is None or not session_id:
389
+ return None
390
+
391
+ if isinstance(round_data, list):
392
+ entries = [_sanitize(item) for item in round_data if isinstance(item, dict)]
393
+ elif isinstance(round_data, dict):
394
+ entries = [_sanitize(round_data)]
395
+ else:
396
+ entries = []
397
+
398
+ if not entries:
399
+ return None
400
+
401
+ now = _utc_now()
402
+
403
+ return collection.update_one(
404
+ {"_id": session_id},
405
+ {
406
+ "$setOnInsert": {
407
+ "_id": session_id,
408
+ "meta.created_at": now,
409
+ "meta.status": "active",
410
+ },
411
+ "$set": {
412
+ "meta.updated_at": now,
413
+ },
414
+ "$push": {
415
+ "rounds": {
416
+ "$each": entries,
417
+ }
418
+ },
419
+ },
420
+ upsert=True,
421
+ )
422
+
423
+ return _safe("update_round", op)
424
+
425
+
426
+ def update_battle_summary(session_id: str, summary: dict) -> Any:
427
+ """
428
+ Save final pitch battle summary.
429
+
430
+ Called after /api/end-battle.
431
+ """
432
+ def op() -> Any:
433
+ collection = _get_collection()
434
+ if collection is None or not session_id:
435
+ return None
436
+
437
+ return _set_with_touch(
438
+ collection,
439
+ session_id,
440
+ {
441
+ "battle_summary": _sanitize(summary or {}),
442
+ },
443
+ )
444
+
445
+ return _safe("update_battle_summary", op)
446
+
447
+
448
+ def save_scorecard(session_id: str, scorecard: dict) -> Any:
449
+ """
450
+ Save latest pitch scorecard.
451
+
452
+ Called after scorecard generation succeeds.
453
+ """
454
+ def op() -> Any:
455
+ collection = _get_collection()
456
+ if collection is None or not session_id:
457
+ return None
458
+
459
+ return _set_with_touch(
460
+ collection,
461
+ session_id,
462
+ {
463
+ "scorecard": _scorecard_for_storage(scorecard or {}),
464
+ },
465
+ )
466
+
467
+ return _safe("save_scorecard", op)
468
+
469
+
470
+ def save_retry_drill(session_id: str, drill: dict) -> Any:
471
+ """
472
+ Save or replace one retry drill in retry_drills array.
473
+
474
+ Called after retry weakest question start/submit succeeds.
475
+ """
476
+ def op() -> Any:
477
+ collection = _get_collection()
478
+ if collection is None or not session_id or not isinstance(drill, dict):
479
+ return None
480
+
481
+ cleaned_drill = dict(drill)
482
+ cleaned_drill.pop("voice_turn_id", None)
483
+ cleaned_drill = _sanitize(cleaned_drill)
484
+
485
+ retry_id = cleaned_drill.get("retry_id")
486
+ now = _utc_now()
487
+
488
+ if retry_id:
489
+ collection.update_one(
490
+ {"_id": session_id},
491
+ {
492
+ "$pull": {
493
+ "retry_drills": {
494
+ "retry_id": retry_id,
495
+ }
496
+ },
497
+ "$set": {
498
+ "meta.updated_at": now,
499
+ },
500
+ },
501
+ upsert=False,
502
+ )
503
+
504
+ return collection.update_one(
505
+ {"_id": session_id},
506
+ {
507
+ "$setOnInsert": {
508
+ "_id": session_id,
509
+ "meta.created_at": now,
510
+ "meta.status": "active",
511
+ },
512
+ "$set": {
513
+ "meta.updated_at": now,
514
+ },
515
+ "$push": {
516
+ "retry_drills": cleaned_drill,
517
+ },
518
+ },
519
+ upsert=True,
520
+ )
521
+
522
+ return _safe("save_retry_drill", op)
523
+
524
+
525
+ def save_judge_verdict(session_id: str, verdict: dict) -> Any:
526
+ """
527
+ Save judge verdict separately from scorecard.
528
+ """
529
+ def op() -> Any:
530
+ collection = _get_collection()
531
+ if collection is None or not session_id:
532
+ return None
533
+
534
+ return _set_with_touch(
535
+ collection,
536
+ session_id,
537
+ {
538
+ "judge_verdict": _sanitize(verdict or {}),
539
+ },
540
+ )
541
+
542
+ return _safe("save_judge_verdict", op)
543
+
544
+
545
+ def update_deal_round(session_id: str, deal_round: Any) -> Any:
546
+ """
547
+ Append one or more deal-phase history entries.
548
+
549
+ Called after /api/start-deal-phase and /api/deal-round.
550
+ """
551
+ def op() -> Any:
552
+ collection = _get_collection()
553
+ if collection is None or not session_id:
554
+ return None
555
+
556
+ if isinstance(deal_round, list):
557
+ entries = [_sanitize(item) for item in deal_round if isinstance(item, dict)]
558
+ elif isinstance(deal_round, dict):
559
+ entries = [_sanitize(deal_round)]
560
+ else:
561
+ entries = []
562
+
563
+ if not entries:
564
+ return None
565
+
566
+ now = _utc_now()
567
+
568
+ return collection.update_one(
569
+ {"_id": session_id},
570
+ {
571
+ "$setOnInsert": {
572
+ "_id": session_id,
573
+ "meta.created_at": now,
574
+ },
575
+ "$set": {
576
+ "meta.updated_at": now,
577
+ "meta.status": "active",
578
+ "deal_phase.activated": True,
579
+ },
580
+ "$push": {
581
+ "deal_phase.deal_history": {
582
+ "$each": entries,
583
+ }
584
+ },
585
+ },
586
+ upsert=True,
587
+ )
588
+
589
+ return _safe("update_deal_round", op)
590
+
591
+
592
+ def save_deal_scorecard(
593
+ session_id: str,
594
+ deal_scorecard: dict,
595
+ combined_scorecard: dict,
596
+ ) -> Any:
597
+ """
598
+ Save final deal scorecard and combined pitch + deal scorecard.
599
+
600
+ Called after /api/end-deal succeeds.
601
+ """
602
+ def op() -> Any:
603
+ collection = _get_collection()
604
+ if collection is None or not session_id:
605
+ return None
606
+
607
+ return _set_with_touch(
608
+ collection,
609
+ session_id,
610
+ {
611
+ "deal_phase.activated": False,
612
+ "deal_phase.deal_scorecard": _sanitize(deal_scorecard or {}),
613
+ "deal_phase.combined_scorecard": _sanitize(combined_scorecard or {}),
614
+ },
615
+ status="completed",
616
+ )
617
+
618
+ return _safe("save_deal_scorecard", op)
619
+
620
+
621
+ def get_session(session_id: str) -> Optional[dict]:
622
+ """
623
+ Optional read helper.
624
+ """
625
+ def op() -> Optional[dict]:
626
+ collection = _get_collection()
627
+ if collection is None or not session_id:
628
+ return None
629
+
630
+ return collection.find_one({"_id": session_id})
631
+
632
+ return _safe("get_session", op)
633
+
634
+
635
+ def get_recent_sessions(limit: int = 10) -> list[dict]:
636
+ """
637
+ Optional read helper for debugging/history screens later.
638
+ """
639
+ def op() -> list[dict]:
640
+ collection = _get_collection()
641
+ if collection is None:
642
+ return []
643
+
644
+ safe_limit = max(1, min(int(limit), 50))
645
+
646
+ return list(
647
+ collection.find({})
648
+ .sort("meta.created_at", -1)
649
+ .limit(safe_limit)
650
+ )
651
+
652
+ result = _safe("get_recent_sessions", op)
653
+ return result or []
654
+
655
+
656
+ def get_session_scorecard(session_id: str) -> Optional[dict]:
657
+ """
658
+ Optional read helper.
659
+ """
660
+ def op() -> Optional[dict]:
661
+ collection = _get_collection()
662
+ if collection is None or not session_id:
663
+ return None
664
+
665
+ doc = collection.find_one(
666
+ {"_id": session_id},
667
+ {
668
+ "_id": 1,
669
+ "scorecard": 1,
670
+ "judge_verdict": 1,
671
+ "deal_phase.deal_scorecard": 1,
672
+ "deal_phase.combined_scorecard": 1,
673
+ },
674
+ )
675
+
676
+ return doc
677
+
678
+ return _safe("get_session_scorecard", op)
core/voice_handler.py ADDED
@@ -0,0 +1,679 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Voice input layer for PitchFight AI — Phase 7.
2
+
3
+ Converts spoken audio to confirmed text + delivery cues via Nemotron Omni API.
4
+ Does not replace the battle engine — only produces transcripts for existing flows.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import binascii
11
+ import logging
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import tempfile
16
+ import uuid
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from core import nvidia_client
21
+ from core import session_manager
22
+ from core.json_utils import parse_model_json, safe_json_parse
23
+ from core.nvidia_client import OmniAudioError
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _FILLER_PATTERNS = [
28
+ r"\bum\b", r"\buh\b", r"\buhm\b", r"\ber\b", r"\bah\b",
29
+ r"\blike\b", r"\byou know\b", r"\bkind of\b", r"\bsort of\b",
30
+ r"\bbasically\b", r"\bliterally\b", r"\bactually\b", r"\bso+\b",
31
+ r"\bi mean\b", r"\bwell\b",
32
+ ]
33
+
34
+ _VOICE_PITCH_PROMPT = """The founder just recorded an opening startup pitch.
35
+
36
+ Listen to the audio carefully and extract only what was actually said.
37
+
38
+ Return ONLY valid JSON.
39
+ First character must be {.
40
+ Last character must be }.
41
+ No markdown.
42
+ No explanation.
43
+ No reasoning.
44
+ Do not hallucinate.
45
+ Do not invent traction, users, revenue, competitors, or market data.
46
+ If a field was not mentioned, return an empty string.
47
+
48
+ Do NOT claim emotion, stress, anxiety, or psychological state detection.
49
+ Only report observable delivery cues such as filler words, pauses, pacing, repetition, self-corrections, and clarity.
50
+
51
+ Required JSON:
52
+
53
+ {
54
+ "transcript": "exact words spoken",
55
+ "extracted": {
56
+ "name": "startup name or empty string",
57
+ "problem": "problem described or empty string",
58
+ "target_users": "who they are building for or empty string",
59
+ "solution": "what the product does or empty string",
60
+ "why_ai": "why AI is needed or empty string",
61
+ "traction": "any validation/users/pilots mentioned or empty string",
62
+ "competitors": "any competitors named or empty string",
63
+ "ask": "what they are asking for or empty string"
64
+ },
65
+ "delivery_observations": {
66
+ "filler_words": ["list of filler words heard"],
67
+ "pace": "rushed / measured / slow / unclear",
68
+ "clarity": "one sentence observation based only on delivery",
69
+ "confidence_signal": "confident / mixed / hesitant / unclear based only on observable delivery cues",
70
+ "delivery_note": "one concise sentence"
71
+ },
72
+ "extraction_confidence": "high / medium / low"
73
+ }"""
74
+
75
+ _VOICE_TURN_PROMPT = """Transcribe this spoken battle answer exactly as spoken.
76
+
77
+ Return ONLY valid JSON.
78
+ First character must be {.
79
+ Last character must be }.
80
+ No markdown.
81
+ No explanation.
82
+ No reasoning.
83
+ Do not interpret or expand the answer.
84
+ Do not add words not spoken.
85
+
86
+ Do NOT claim emotion, stress, anxiety, or psychological state detection.
87
+ Only report observable delivery cues such as filler words, pauses, pacing, repetition, self-corrections, and clarity.
88
+
89
+ Required JSON:
90
+
91
+ {
92
+ "transcript": "exact words spoken",
93
+ "delivery_note": "one concise sentence about observable delivery cues. If clean, say Clean delivery.",
94
+ "word_count": 0,
95
+ "delivery_cues": {
96
+ "filler_words": [],
97
+ "pace": "rushed / measured / slow / unclear",
98
+ "clarity": "clear / mostly clear / unclear",
99
+ "repetition": "low / medium / high",
100
+ "self_corrections": 0,
101
+ "confidence_signal": "confident delivery / mixed delivery / hesitant delivery / unclear"
102
+ }
103
+ }"""
104
+
105
+ _EXTRACTED_FIELDS = (
106
+ "name", "problem", "target_users", "solution",
107
+ "why_ai", "traction", "competitors", "ask",
108
+ )
109
+
110
+ _WAV_RIFF = b"RIFF"
111
+ _WEBM_MAGIC = b"\x1aE\xdf\xa3"
112
+ _OGG_MAGIC = b"OggS"
113
+ _MP3_ID3 = b"ID3"
114
+
115
+ # Minimum decoded audio size to treat as a real recording. Anything smaller is an
116
+ # empty/instant tap that NVIDIA Omni rejects with HTTP 400. ~1s of webm/opus is several KB.
117
+ _MIN_AUDIO_BYTES = 1024
118
+ _MP3_SYNC = b"\xff\xfb"
119
+
120
+
121
+ def _detect_audio_magic(data: bytes) -> str:
122
+ if len(data) >= 4 and data[:4] == _WAV_RIFF:
123
+ return "wav"
124
+ if len(data) >= 4 and data[:4] == _WEBM_MAGIC:
125
+ return "webm"
126
+ if len(data) >= 4 and data[:4] == _OGG_MAGIC:
127
+ return "ogg"
128
+ if len(data) >= 3 and data[:3] == _MP3_ID3:
129
+ return "mp3"
130
+ if len(data) >= 2 and data[:2] == _MP3_SYNC:
131
+ return "mp3"
132
+ return "unknown"
133
+
134
+
135
+ def _magic_hex(data: bytes, n: int = 8) -> str:
136
+ return data[:n].hex() if data else ""
137
+
138
+
139
+ def _convert_audio_to_wav_ffmpeg(input_bytes: bytes, input_ext: str) -> bytes | None:
140
+ ffmpeg = shutil.which("ffmpeg")
141
+ if not ffmpeg:
142
+ return None
143
+ with tempfile.TemporaryDirectory() as tmp:
144
+ inp = Path(tmp) / f"input.{input_ext or 'webm'}"
145
+ out = Path(tmp) / "output.wav"
146
+ inp.write_bytes(input_bytes)
147
+ cmd = [
148
+ ffmpeg,
149
+ "-y",
150
+ "-hide_banner",
151
+ "-loglevel",
152
+ "error",
153
+ "-i",
154
+ str(inp),
155
+ "-ar",
156
+ "16000",
157
+ "-ac",
158
+ "1",
159
+ "-c:a",
160
+ "pcm_s16le",
161
+ str(out),
162
+ ]
163
+ try:
164
+ result = subprocess.run(cmd, capture_output=True, timeout=45, check=False)
165
+ except (OSError, subprocess.TimeoutExpired) as exc:
166
+ logger.warning("voice_handler: ffmpeg conversion failed — %s", exc)
167
+ return None
168
+ if result.returncode != 0 or not out.is_file():
169
+ stderr = (result.stderr or b"").decode("utf-8", errors="replace")[:200]
170
+ logger.warning("voice_handler: ffmpeg exit=%s stderr=%s", result.returncode, stderr)
171
+ return None
172
+ return out.read_bytes()
173
+
174
+
175
+ def normalize_audio_for_omni(
176
+ audio_base64: str,
177
+ audio_format: str,
178
+ mode: str = "voice_extraction",
179
+ ) -> dict[str, Any]:
180
+ """Decode browser audio and normalize to WAV for NVIDIA Omni when needed."""
181
+ fmt = str(audio_format or "webm").strip().lower().lstrip(".")
182
+ if fmt not in {"webm", "wav", "mp3", "m4a", "ogg"}:
183
+ return {
184
+ "error": f"Unsupported audio_format: {fmt}",
185
+ "audio_format": fmt,
186
+ "mode": mode,
187
+ }
188
+
189
+ try:
190
+ raw = base64.b64decode(audio_base64.strip(), validate=True)
191
+ except (binascii.Error, ValueError) as exc:
192
+ return {
193
+ "error": "Invalid base64 audio payload",
194
+ "detail": str(exc),
195
+ "audio_format": fmt,
196
+ "mode": mode,
197
+ }
198
+
199
+ if not raw:
200
+ return {"error": "Decoded audio is empty", "audio_format": fmt, "mode": mode}
201
+
202
+ # Guard against empty/instant taps (e.g. a few-byte payload). These are not real
203
+ # recordings and NVIDIA Omni rejects them with HTTP 400 ("Invalid audio file").
204
+ # Catch it here and return a clean, user-facing message instead of an API error.
205
+ if len(raw) < _MIN_AUDIO_BYTES:
206
+ logger.info(
207
+ "voice_handler: rejecting too-small audio mode=%s bytes=%d (min=%d)",
208
+ mode, len(raw), _MIN_AUDIO_BYTES,
209
+ )
210
+ return {
211
+ "error": "That recording was too short. Tap the mic, speak, then tap again to stop.",
212
+ "audio_format": fmt,
213
+ "byte_size": len(raw),
214
+ "mode": mode,
215
+ }
216
+
217
+ detected = _detect_audio_magic(raw)
218
+ logger.info(
219
+ "voice_handler: normalize audio mode=%s declared=%s detected=%s bytes=%d magic=%s",
220
+ mode,
221
+ fmt,
222
+ detected,
223
+ len(raw),
224
+ _magic_hex(raw),
225
+ )
226
+
227
+ if detected == "wav" or (fmt == "wav" and raw[:4] == _WAV_RIFF):
228
+ return {
229
+ "audio_base64": base64.b64encode(raw).decode("ascii"),
230
+ "audio_format": "wav",
231
+ "source_format": fmt,
232
+ "byte_size": len(raw),
233
+ "converted": fmt != "wav" and detected == "wav",
234
+ "mode": mode,
235
+ }
236
+
237
+ source_ext = detected if detected != "unknown" else fmt
238
+ wav_bytes = _convert_audio_to_wav_ffmpeg(raw, source_ext)
239
+ if wav_bytes and wav_bytes[:4] == _WAV_RIFF:
240
+ logger.info(
241
+ "voice_handler: converted %s → wav (%d → %d bytes)",
242
+ source_ext,
243
+ len(raw),
244
+ len(wav_bytes),
245
+ )
246
+ return {
247
+ "audio_base64": base64.b64encode(wav_bytes).decode("ascii"),
248
+ "audio_format": "wav",
249
+ "source_format": fmt,
250
+ "byte_size": len(wav_bytes),
251
+ "converted": True,
252
+ "mode": mode,
253
+ }
254
+
255
+ # ffmpeg unavailable or conversion failed — pass through original browser audio
256
+ # (restores pre-stability behavior; Omni accepts webm on many setups).
257
+ send_fmt = source_ext if source_ext != "unknown" else fmt
258
+ logger.info(
259
+ "voice_handler: passthrough audio mode=%s format=%s bytes=%d (ffmpeg=%s)",
260
+ mode,
261
+ send_fmt,
262
+ len(raw),
263
+ bool(shutil.which("ffmpeg")),
264
+ )
265
+ return {
266
+ "audio_base64": base64.b64encode(raw).decode("ascii"),
267
+ "audio_format": send_fmt,
268
+ "source_format": fmt,
269
+ "byte_size": len(raw),
270
+ "converted": False,
271
+ "mode": mode,
272
+ }
273
+
274
+
275
+ def _call_omni_with_normalized_audio(
276
+ prompt: str,
277
+ audio_base64: str,
278
+ audio_format: str,
279
+ mode: str,
280
+ ) -> str | dict[str, Any]:
281
+ normalized = normalize_audio_for_omni(audio_base64, audio_format, mode=mode)
282
+ if normalized.get("error"):
283
+ return normalized
284
+
285
+ def _invoke(payload: dict[str, Any]) -> str:
286
+ return nvidia_client.call_omni_audio_json(
287
+ prompt,
288
+ payload["audio_base64"],
289
+ payload["audio_format"],
290
+ mode=mode,
291
+ source_format=payload.get("source_format", audio_format),
292
+ decoded_bytes=payload.get("byte_size"),
293
+ )
294
+
295
+ try:
296
+ return _invoke(normalized)
297
+ except OmniAudioError as exc:
298
+ # If passthrough failed and ffmpeg can convert, retry once as WAV.
299
+ if not normalized.get("converted"):
300
+ raw_bytes = base64.b64decode(normalized["audio_base64"])
301
+ detected = _detect_audio_magic(raw_bytes)
302
+ source_ext = detected if detected != "unknown" else normalized.get("source_format", audio_format)
303
+ wav_bytes = _convert_audio_to_wav_ffmpeg(raw_bytes, source_ext)
304
+ if wav_bytes and wav_bytes[:4] == _WAV_RIFF:
305
+ logger.info("voice_handler: Omni rejected passthrough; retrying as wav")
306
+ retry_payload = {
307
+ "audio_base64": base64.b64encode(wav_bytes).decode("ascii"),
308
+ "audio_format": "wav",
309
+ "source_format": normalized.get("source_format", audio_format),
310
+ "byte_size": len(wav_bytes),
311
+ "converted": True,
312
+ }
313
+ try:
314
+ return _invoke(retry_payload)
315
+ except OmniAudioError as retry_exc:
316
+ err = retry_exc.to_error_dict()
317
+ err["source_format"] = normalized.get("source_format", audio_format)
318
+ err["converted"] = True
319
+ err["error"] = "Voice transcription failed. Try recording again or type your answer."
320
+ return err
321
+
322
+ err = exc.to_error_dict()
323
+ err["source_format"] = normalized.get("source_format", audio_format)
324
+ err["converted"] = normalized.get("converted", False)
325
+ err["error"] = "Voice transcription failed. Try recording again or type your answer."
326
+ return err
327
+ except ValueError as exc:
328
+ return {"error": str(exc)}
329
+ except RuntimeError as exc:
330
+ return {"error": str(exc)}
331
+
332
+
333
+ def count_filler_words(transcript: str) -> list[str]:
334
+ """Return filler words/phrases found in transcript (case-insensitive)."""
335
+ if not transcript:
336
+ return []
337
+ text = transcript.lower()
338
+ found: list[str] = []
339
+ for pattern in _FILLER_PATTERNS:
340
+ if re.search(pattern, text, re.IGNORECASE):
341
+ label = pattern.strip(r"\b").replace("\\b", "")
342
+ if label not in found:
343
+ found.append(label)
344
+ return found
345
+
346
+
347
+ def estimate_word_count(transcript: str) -> int:
348
+ """Count words in transcript."""
349
+ if not transcript:
350
+ return 0
351
+ return len(re.findall(r"\b\w+\b", transcript))
352
+
353
+
354
+ def detect_self_corrections(transcript: str) -> int:
355
+ """Count simple self-correction cues in transcript."""
356
+ if not transcript:
357
+ return 0
358
+ patterns = [
359
+ r"\bi mean\b", r"\bwait\b", r"\bsorry\b", r"\bno,\s", r"\bactually\b",
360
+ r"\blet me rephrase\b", r"\bwhat i meant\b",
361
+ ]
362
+ count = 0
363
+ lower = transcript.lower()
364
+ for p in patterns:
365
+ count += len(re.findall(p, lower))
366
+ return count
367
+
368
+
369
+ def _detect_repeated_phrases(transcript: str) -> int:
370
+ """Count repeated 3-word phrases (simple repetition signal)."""
371
+ words = re.findall(r"\b\w+\b", (transcript or "").lower())
372
+ if len(words) < 6:
373
+ return 0
374
+ trigrams: dict[str, int] = {}
375
+ for i in range(len(words) - 2):
376
+ tri = " ".join(words[i : i + 3])
377
+ trigrams[tri] = trigrams.get(tri, 0) + 1
378
+ return sum(1 for c in trigrams.values() if c > 1)
379
+
380
+
381
+ def sanitize_voice_json(data: dict[str, Any]) -> dict[str, Any]:
382
+ """Normalize voice JSON fields with safe delivery-only wording."""
383
+ if not isinstance(data, dict):
384
+ return {}
385
+ out = dict(data)
386
+ out["transcript"] = str(out.get("transcript", "")).strip()
387
+ extracted = out.get("extracted")
388
+ if isinstance(extracted, dict):
389
+ out["extracted"] = {
390
+ k: str(extracted.get(k, "")).strip() for k in _EXTRACTED_FIELDS
391
+ }
392
+ delivery = out.get("delivery_observations")
393
+ if isinstance(delivery, dict):
394
+ fillers = delivery.get("filler_words", [])
395
+ out["delivery_observations"] = {
396
+ "filler_words": [str(f).strip() for f in fillers if str(f).strip()][:20]
397
+ if isinstance(fillers, list) else [],
398
+ "pace": str(delivery.get("pace", "")).strip() or "unclear",
399
+ "clarity": str(delivery.get("clarity", "")).strip(),
400
+ "confidence_signal": str(delivery.get("confidence_signal", "")).strip() or "unclear",
401
+ "delivery_note": str(delivery.get("delivery_note", "")).strip(),
402
+ }
403
+ conf = str(out.get("extraction_confidence", "")).strip().lower()
404
+ if conf not in ("high", "medium", "low"):
405
+ conf = "medium"
406
+ out["extraction_confidence"] = conf
407
+ return out
408
+
409
+
410
+ def _sanitize_turn_json(data: dict[str, Any]) -> dict[str, Any]:
411
+ if not isinstance(data, dict):
412
+ return {}
413
+ transcript = str(data.get("transcript", "")).strip()
414
+ cues_raw = data.get("delivery_cues", {})
415
+ cues: dict[str, Any] = {}
416
+ if isinstance(cues_raw, dict):
417
+ fillers = cues_raw.get("filler_words", [])
418
+ cues = {
419
+ "filler_words": [str(f).strip() for f in fillers if str(f).strip()][:20]
420
+ if isinstance(fillers, list) else [],
421
+ "pace": str(cues_raw.get("pace", "")).strip() or "unclear",
422
+ "clarity": str(cues_raw.get("clarity", "")).strip() or "unclear",
423
+ "repetition": str(cues_raw.get("repetition", "")).strip() or "low",
424
+ "self_corrections": int(cues_raw.get("self_corrections", 0) or 0),
425
+ "confidence_signal": str(cues_raw.get("confidence_signal", "")).strip() or "unclear",
426
+ }
427
+ local_fillers = count_filler_words(transcript)
428
+ if not cues.get("filler_words") and local_fillers:
429
+ cues["filler_words"] = local_fillers
430
+ if cues.get("self_corrections", 0) == 0:
431
+ cues["self_corrections"] = detect_self_corrections(transcript)
432
+ rep_count = _detect_repeated_phrases(transcript)
433
+ if cues.get("repetition") == "low" and rep_count >= 2:
434
+ cues["repetition"] = "medium"
435
+ return {
436
+ "transcript": transcript,
437
+ "delivery_note": str(data.get("delivery_note", "")).strip() or "Clean delivery.",
438
+ "word_count": estimate_word_count(transcript),
439
+ "delivery_cues": cues,
440
+ }
441
+
442
+
443
+ def _parse_pitch_json(raw: str) -> dict[str, Any] | None:
444
+ parsed, _ = parse_model_json(raw)
445
+ if not isinstance(parsed, dict) or not parsed:
446
+ parsed = safe_json_parse(raw)
447
+ if not isinstance(parsed, dict) or not parsed:
448
+ return None
449
+ transcript = str(parsed.get("transcript", "")).strip()
450
+ if not transcript:
451
+ return None
452
+ sanitized = sanitize_voice_json(parsed)
453
+ sanitized["transcript"] = transcript
454
+ return sanitized
455
+
456
+
457
+ def _parse_turn_json(raw: str) -> dict[str, Any] | None:
458
+ parsed, _ = parse_model_json(raw)
459
+ if not isinstance(parsed, dict) or not parsed:
460
+ parsed = safe_json_parse(raw)
461
+ if not isinstance(parsed, dict) or not parsed:
462
+ return None
463
+ transcript = str(parsed.get("transcript", "")).strip()
464
+ if not transcript:
465
+ return None
466
+ return _sanitize_turn_json(parsed)
467
+
468
+
469
+ def _repair_pitch_json(raw_bad: str) -> dict[str, Any] | None:
470
+ repair_prompt = (
471
+ "Convert the input into valid JSON matching this schema exactly. "
472
+ "Return ONLY JSON. First char { last char }.\n"
473
+ '{"transcript":"","extracted":{"name":"","problem":"","target_users":"",'
474
+ '"solution":"","why_ai":"","traction":"","competitors":"","ask":""},'
475
+ '"delivery_observations":{"filler_words":[],"pace":"","clarity":"",'
476
+ '"confidence_signal":"","delivery_note":""},"extraction_confidence":"medium"}\n\n'
477
+ + raw_bad[:4000]
478
+ )
479
+ try:
480
+ content = nvidia_client.generate_nemotron_response(
481
+ [{"role": "user", "content": repair_prompt}],
482
+ mode="voice_extraction_repair",
483
+ )
484
+ return _parse_pitch_json(content)
485
+ except Exception as exc:
486
+ logger.warning("voice_handler: pitch repair failed — %s", exc)
487
+ return None
488
+
489
+
490
+ def _repair_turn_json(raw_bad: str) -> dict[str, Any] | None:
491
+ repair_prompt = (
492
+ "Convert the input into valid JSON matching this schema exactly. "
493
+ "Return ONLY JSON. First char { last char }.\n"
494
+ '{"transcript":"","delivery_note":"","word_count":0,'
495
+ '"delivery_cues":{"filler_words":[],"pace":"","clarity":"",'
496
+ '"repetition":"low","self_corrections":0,"confidence_signal":""}}\n\n'
497
+ + raw_bad[:3000]
498
+ )
499
+ try:
500
+ content = nvidia_client.generate_nemotron_response(
501
+ [{"role": "user", "content": repair_prompt}],
502
+ mode="voice_turn_repair",
503
+ )
504
+ return _parse_turn_json(content)
505
+ except Exception as exc:
506
+ logger.warning("voice_handler: turn repair failed — %s", exc)
507
+ return None
508
+
509
+
510
+ def process_voice_pitch(audio_base64: str, audio_format: str) -> dict[str, Any]:
511
+ """Opening spoken pitch → transcript + extracted startup fields + delivery cues."""
512
+ if not nvidia_client.is_configured():
513
+ return {"error": "NVIDIA_API_KEY is not configured on the server."}
514
+
515
+ raw = _call_omni_with_normalized_audio(
516
+ _VOICE_PITCH_PROMPT, audio_base64, audio_format, mode="voice_extraction"
517
+ )
518
+ if isinstance(raw, dict):
519
+ return raw
520
+
521
+ parsed = _parse_pitch_json(raw)
522
+ if parsed is None:
523
+ logger.warning("voice_handler: pitch parse failed, attempting repair")
524
+ parsed = _repair_pitch_json(raw)
525
+
526
+ if parsed is None:
527
+ return {"error": "Could not parse voice pitch response from Nemotron Omni."}
528
+
529
+ return parsed
530
+
531
+
532
+ def process_voice_turn(
533
+ session_id: str,
534
+ audio_base64: str,
535
+ audio_format: str,
536
+ ) -> dict[str, Any]:
537
+ """One battle answer audio → transcript + delivery note (pending confirmation)."""
538
+ session = session_manager.get_session(session_id)
539
+ if not session:
540
+ return {"error": "Session not found", "session_id": session_id}
541
+
542
+ if not nvidia_client.is_configured():
543
+ return {"error": "NVIDIA_API_KEY is not configured on the server.", "session_id": session_id}
544
+
545
+ raw = _call_omni_with_normalized_audio(
546
+ _VOICE_TURN_PROMPT, audio_base64, audio_format, mode="voice_turn"
547
+ )
548
+ if isinstance(raw, dict):
549
+ raw["session_id"] = session_id
550
+ return raw
551
+
552
+ parsed = _parse_turn_json(raw)
553
+ if parsed is None:
554
+ logger.warning("voice_handler: turn parse failed, attempting repair")
555
+ parsed = _repair_turn_json(raw)
556
+
557
+ if parsed is None:
558
+ return {"error": "Could not parse voice turn response from Nemotron Omni.", "session_id": session_id}
559
+
560
+ voice_turn_id = str(uuid.uuid4())
561
+ transcript = parsed["transcript"]
562
+ fillers = parsed["delivery_cues"].get("filler_words") or count_filler_words(transcript)
563
+ filler_count = len(fillers)
564
+
565
+ turn_record = {
566
+ "voice_turn_id": voice_turn_id,
567
+ "transcript": transcript,
568
+ "delivery_note": parsed.get("delivery_note", ""),
569
+ "word_count": parsed.get("word_count", estimate_word_count(transcript)),
570
+ "delivery_cues": parsed.get("delivery_cues", {}),
571
+ "filler_word_count": filler_count,
572
+ "confirmed": False,
573
+ }
574
+ session_manager.store_pending_voice_turn(session_id, turn_record)
575
+
576
+ return {
577
+ "session_id": session_id,
578
+ "voice_turn_id": voice_turn_id,
579
+ "transcript": transcript,
580
+ "delivery_note": turn_record["delivery_note"],
581
+ "word_count": turn_record["word_count"],
582
+ "delivery_cues": turn_record["delivery_cues"],
583
+ }
584
+
585
+
586
+ def confirm_voice_turn(
587
+ session_id: str,
588
+ voice_turn_id: str,
589
+ final_transcript: str,
590
+ ) -> bool:
591
+ """Mark a pending voice turn as confirmed with the user's final transcript."""
592
+ return session_manager.confirm_voice_turn(session_id, voice_turn_id, final_transcript)
593
+
594
+
595
+ def _is_generic_delivery_note(note: str) -> bool:
596
+ """Skip filler delivery notes that clutter the scorecard UI."""
597
+ n = (note or "").strip().lower().rstrip(".")
598
+ return n in ("clean delivery", "clean delivery.", "")
599
+
600
+
601
+ def build_voice_delivery_summary(session: dict) -> dict[str, Any] | None:
602
+ """Aggregate confirmed voice turns into a scorecard delivery summary (local only)."""
603
+ confirmed = session.get("confirmed_voice_turns") or []
604
+ voice_pitch = session.get("voice_pitch")
605
+ if not confirmed and not voice_pitch:
606
+ return None
607
+
608
+ all_fillers: list[str] = []
609
+ delivery_notes: list[str] = []
610
+ pace_counts: dict[str, int] = {}
611
+ clarity_signals: list[str] = []
612
+ confidence_signals: list[str] = []
613
+
614
+ if isinstance(voice_pitch, dict):
615
+ obs = voice_pitch.get("delivery_observations") or {}
616
+ if isinstance(obs, dict):
617
+ note = str(obs.get("delivery_note", "")).strip()
618
+ if note and not _is_generic_delivery_note(note):
619
+ delivery_notes.append(f"Opening pitch: {note}")
620
+ for f in obs.get("filler_words") or []:
621
+ if str(f).strip():
622
+ all_fillers.append(str(f).strip())
623
+ pace = str(obs.get("pace", "")).strip()
624
+ if pace:
625
+ pace_counts[pace] = pace_counts.get(pace, 0) + 1
626
+ clarity = str(obs.get("clarity", "")).strip()
627
+ if clarity:
628
+ clarity_signals.append(clarity)
629
+ conf = str(obs.get("confidence_signal", "")).strip()
630
+ if conf:
631
+ confidence_signals.append(conf)
632
+
633
+ for turn in confirmed:
634
+ if not isinstance(turn, dict):
635
+ continue
636
+ note = str(turn.get("delivery_note", "")).strip()
637
+ if note and not _is_generic_delivery_note(note):
638
+ delivery_notes.append(note)
639
+ cues = turn.get("delivery_cues") or {}
640
+ if isinstance(cues, dict):
641
+ for f in cues.get("filler_words") or []:
642
+ if str(f).strip():
643
+ all_fillers.append(str(f).strip())
644
+ pace = str(cues.get("pace", "")).strip()
645
+ if pace:
646
+ pace_counts[pace] = pace_counts.get(pace, 0) + 1
647
+ clarity = str(cues.get("clarity", "")).strip()
648
+ if clarity:
649
+ clarity_signals.append(clarity)
650
+ conf = str(cues.get("confidence_signal", "")).strip()
651
+ if conf:
652
+ confidence_signals.append(conf)
653
+
654
+ filler_unique = list(dict.fromkeys(all_fillers))
655
+ total_fillers = len(all_fillers)
656
+ avg_pace = max(pace_counts, key=pace_counts.get) if pace_counts else "unclear"
657
+
658
+ if total_fillers == 0 and len(confirmed) >= 2:
659
+ overall = "Voice delivery was generally clear across your spoken answers."
660
+ elif total_fillers > 5:
661
+ overall = (
662
+ f"Filler words appeared often ({total_fillers} total). "
663
+ "Practice pausing briefly instead of using fillers before key claims."
664
+ )
665
+ elif delivery_notes:
666
+ overall = "Review the delivery notes below and practice smoother pacing on your weakest round."
667
+ else:
668
+ overall = "Voice turns recorded — delivery was acceptable for a practice session."
669
+
670
+ return {
671
+ "total_voice_turns": len(confirmed),
672
+ "total_filler_words": total_fillers,
673
+ "filler_word_list": filler_unique[:12],
674
+ "delivery_notes": list(dict.fromkeys(delivery_notes))[:4],
675
+ "average_pace": avg_pace,
676
+ "clarity_signal": clarity_signals[-1] if clarity_signals else "unclear",
677
+ "confidence_signal": confidence_signals[-1] if confidence_signals else "unclear",
678
+ "overall_delivery_feedback": overall,
679
+ }
frontend/index.html CHANGED
@@ -6,6 +6,9 @@
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>
@@ -13,143 +16,1207 @@
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>
 
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
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
+ <link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700&display=swap" rel="stylesheet" />
12
  </head>
13
  <body>
14
  <div class="bg-glow"></div>
 
16
  <div id="error-banner" class="error-banner" hidden role="alert"></div>
17
 
18
  <main id="app" class="app">
19
+ <!-- Landing — Founder Pressure Arena (Pass 1) -->
20
  <section id="screen-landing" class="screen active">
21
+ <div class="arena-landing">
22
+ <div class="arena-scene" aria-hidden="true">
23
+ <div class="arena-scene-base"></div>
24
+ <div class="hero-center-haze"></div>
25
+ <div class="spotlight-core"></div>
26
+ <div class="hero-spotlight-particles"></div>
27
+ <div class="spotlight-beam"></div>
28
+ <div class="arena-floor"></div>
29
+ <div class="pressure-line"></div>
30
+ <div class="arena-glow arena-glow-founder"></div>
31
+ <div class="arena-glow arena-glow-judge"></div>
32
+
33
+ <div class="founder-silhouette">
34
+ <div class="founder-aura"></div>
35
+ <svg class="character-svg character-svg-founder" viewBox="0 0 140 230" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
36
+ <defs>
37
+ <linearGradient id="founder-skin" x1="0%" y1="0%" x2="0%" y2="100%">
38
+ <stop offset="0%" stop-color="#c9954a"/>
39
+ <stop offset="100%" stop-color="#3d2810"/>
40
+ </linearGradient>
41
+ <linearGradient id="founder-suit" x1="0%" y1="0%" x2="0%" y2="100%">
42
+ <stop offset="0%" stop-color="#5a4020"/>
43
+ <stop offset="100%" stop-color="#120c06"/>
44
+ </linearGradient>
45
+ <linearGradient id="gold-glow" x1="0%" y1="0%" x2="100%" y2="100%">
46
+ <stop offset="0%" stop-color="#f4d35e" stop-opacity="0.9"/>
47
+ <stop offset="100%" stop-color="#a67c1a" stop-opacity="0.4"/>
48
+ </linearGradient>
49
+ <filter id="founder-soft-glow" x="-20%" y="-20%" width="140%" height="140%">
50
+ <feGaussianBlur stdDeviation="3" result="blur"/>
51
+ <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
52
+ </filter>
53
+ </defs>
54
+ <!-- Podium platform -->
55
+ <ellipse cx="70" cy="206" rx="58" ry="6" fill="rgba(244,211,94,0.08)"/>
56
+ <rect x="14" y="198" width="112" height="16" rx="3" fill="#1a1208" stroke="#f4d35e" stroke-opacity="0.45"/>
57
+ <rect x="20" y="190" width="100" height="10" rx="2" fill="#2a1e0c" stroke="#f4d35e" stroke-opacity="0.28"/>
58
+ <rect x="26" y="184" width="88" height="8" rx="1" fill="#1f160a" stroke="#f4d35e" stroke-opacity="0.15"/>
59
+ <!-- Traction glow ring -->
60
+ <ellipse cx="70" cy="188" rx="48" ry="4" fill="none" stroke="#f4d35e" stroke-opacity="0.2" stroke-width="1"/>
61
+ <!-- Money bag (the ask) -->
62
+ <g class="prop-money-bag" filter="url(#founder-soft-glow)">
63
+ <path d="M8 185 Q8 168 22 162 Q28 158 28 150 L32 150 Q32 158 38 162 Q52 168 52 185 Q52 198 30 198 Q8 198 8 185Z" fill="#2a2210" stroke="#f4d35e" stroke-opacity="0.55"/>
64
+ <path d="M22 150 Q30 146 38 150" fill="none" stroke="#f4d35e" stroke-opacity="0.45" stroke-width="2"/>
65
+ <text x="30" y="182" text-anchor="middle" fill="#f4d35e" font-size="14" font-weight="700" opacity="0.85">$</text>
66
+ </g>
67
+ <!-- Legs -->
68
+ <path d="M46 168 L42 192 L56 192 L52 168 Z" fill="#0e0a06"/>
69
+ <path d="M88 168 L84 192 L98 192 L94 168 Z" fill="#0e0a06"/>
70
+ <!-- Torso / jacket -->
71
+ <path d="M40 92 Q70 84 100 92 L106 168 Q70 176 34 168 Z" fill="url(#founder-suit)"/>
72
+ <path d="M40 92 Q70 84 100 92 L98 108 Q70 112 42 108 Z" fill="#f4d35e" fill-opacity="0.06"/>
73
+ <path d="M56 92 L84 92 L82 168 L58 168 Z" fill="#f4d35e" fill-opacity="0.1"/>
74
+ <!-- Lapels -->
75
+ <path d="M56 92 L62 108 L58 168 L52 108 Z" fill="#2a1e0c" fill-opacity="0.6"/>
76
+ <path d="M84 92 L78 108 L82 168 L88 108 Z" fill="#2a1e0c" fill-opacity="0.6"/>
77
+ <!-- Shirt collar -->
78
+ <path d="M56 92 L70 92 L66 110 L62 110 Z" fill="#1a1510"/>
79
+ <path d="M70 92 L84 92 L82 110 L74 110 Z" fill="#1a1510"/>
80
+ <!-- Head -->
81
+ <ellipse cx="70" cy="70" rx="24" ry="27" fill="url(#founder-skin)"/>
82
+ <path d="M52 78 Q70 88 88 78" fill="none" stroke="#3d2810" stroke-opacity="0.35" stroke-width="1"/>
83
+ <!-- Hair -->
84
+ <path d="M46 66 Q70 44 94 66 Q90 54 70 50 Q50 54 46 66Z" fill="#1a1008"/>
85
+ <path d="M48 62 Q70 48 92 62" fill="none" stroke="#f4d35e" stroke-opacity="0.12" stroke-width="2"/>
86
+ <!-- Pitch deck / tablet -->
87
+ <g class="prop-pitch-deck">
88
+ <rect x="88" y="108" width="36" height="48" rx="3" fill="#1a1510" stroke="#f4d35e" stroke-opacity="0.6" transform="rotate(8 106 132)"/>
89
+ <line x1="94" y1="120" x2="118" y2="122" stroke="#f4d35e" stroke-opacity="0.4" stroke-width="2" transform="rotate(8 106 132)"/>
90
+ <line x1="94" y1="130" x2="114" y2="132" stroke="#f4d35e" stroke-opacity="0.3" stroke-width="2" transform="rotate(8 106 132)"/>
91
+ <line x1="94" y1="140" x2="116" y2="142" stroke="#f4d35e" stroke-opacity="0.25" stroke-width="2" transform="rotate(8 106 132)"/>
92
+ </g>
93
+ <!-- Presenting arm -->
94
+ <path d="M100 92 Q122 98 128 118 Q130 130 120 134 Q108 128 102 112 Q98 102 100 92Z" fill="url(#founder-skin)"/>
95
+ <!-- Other arm -->
96
+ <path d="M40 92 Q24 106 22 128 Q20 140 30 138 Q38 132 42 114 Q44 102 40 92Z" fill="url(#founder-suit)"/>
97
+ </svg>
98
+ <div class="arena-artifacts arena-artifacts-founder" aria-hidden="true">
99
+ <span class="artifact artifact-pitch-card"></span>
100
+ <span class="artifact artifact-deck-tile"></span>
101
+ <span class="artifact artifact-traction-graph"></span>
102
+ </div>
103
+ <span class="character-shadow character-shadow-founder"></span>
104
+ <span class="character-label character-label-founder">Founder</span>
105
+ </div>
106
+
107
+ <div class="judge-silhouette">
108
+ <div class="judge-aura"></div>
109
+ <svg class="character-svg character-svg-judge" viewBox="0 0 150 240" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
110
+ <defs>
111
+ <linearGradient id="judge-metal" x1="0%" y1="0%" x2="0%" y2="100%">
112
+ <stop offset="0%" stop-color="#1a3040"/>
113
+ <stop offset="100%" stop-color="#060a10"/>
114
+ </linearGradient>
115
+ <linearGradient id="visor-glow" x1="0%" y1="0%" x2="100%" y2="0%">
116
+ <stop offset="0%" stop-color="#4ecdc4" stop-opacity="0.2"/>
117
+ <stop offset="50%" stop-color="#7efff0" stop-opacity="1"/>
118
+ <stop offset="100%" stop-color="#4ecdc4" stop-opacity="0.2"/>
119
+ </linearGradient>
120
+ <linearGradient id="cash-green" x1="0%" y1="0%" x2="0%" y2="100%">
121
+ <stop offset="0%" stop-color="#3d8a62"/>
122
+ <stop offset="100%" stop-color="#1a4030"/>
123
+ </linearGradient>
124
+ <filter id="judge-cyan-glow" x="-30%" y="-30%" width="160%" height="160%">
125
+ <feGaussianBlur stdDeviation="2.5" result="blur"/>
126
+ <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
127
+ </filter>
128
+ </defs>
129
+ <!-- Platform -->
130
+ <ellipse cx="75" cy="216" rx="62" ry="6" fill="rgba(78,205,196,0.1)"/>
131
+ <rect x="16" y="206" width="118" height="16" rx="3" fill="#0a1420" stroke="#4ecdc4" stroke-opacity="0.45"/>
132
+ <rect x="22" y="198" width="106" height="10" rx="2" fill="#0e1824" stroke="#4ecdc4" stroke-opacity="0.25"/>
133
+ <!-- Cash stack -->
134
+ <g class="prop-cash-stack">
135
+ <rect x="6" y="186" width="34" height="8" rx="1" fill="url(#cash-green)" stroke="#4ecdc4" stroke-opacity="0.3"/>
136
+ <rect x="8" y="178" width="30" height="8" rx="1" fill="url(#cash-green)" stroke="#4ecdc4" stroke-opacity="0.35"/>
137
+ <rect x="10" y="170" width="26" height="8" rx="1" fill="url(#cash-green)" stroke="#4ecdc4" stroke-opacity="0.4"/>
138
+ <rect x="14" y="162" width="18" height="8" rx="1" fill="#2a6048" stroke="#f4d35e" stroke-opacity="0.35"/>
139
+ </g>
140
+ <!-- Briefcase (capital) -->
141
+ <g class="prop-briefcase">
142
+ <rect x="108" y="172" width="38" height="28" rx="3" fill="#0c1018" stroke="#4ecdc4" stroke-opacity="0.5"/>
143
+ <path d="M118 172 L118 166 Q127 160 136 166 L136 172" fill="none" stroke="#4ecdc4" stroke-opacity="0.55" stroke-width="2"/>
144
+ <rect x="122" y="182" width="10" height="6" rx="1" fill="#4ecdc4" fill-opacity="0.25"/>
145
+ <text x="127" y="194" text-anchor="middle" fill="#f4d35e" font-size="11" font-weight="700">$</text>
146
+ </g>
147
+ <!-- Legs / base -->
148
+ <rect x="50" y="176" width="16" height="32" rx="3" fill="#060a10" stroke="#4ecdc4" stroke-opacity="0.15"/>
149
+ <rect x="84" y="176" width="16" height="32" rx="3" fill="#060a10" stroke="#4ecdc4" stroke-opacity="0.15"/>
150
+ <!-- Body -->
151
+ <path d="M34 102 Q75 90 116 102 L122 178 Q75 188 28 178 Z" fill="url(#judge-metal)" stroke="#4ecdc4" stroke-opacity="0.3"/>
152
+ <!-- Shoulder armor -->
153
+ <path d="M34 102 Q22 108 18 128 L34 118 Z" fill="#122030" stroke="#4ecdc4" stroke-opacity="0.35"/>
154
+ <path d="M116 102 Q128 108 132 128 L116 118 Z" fill="#122030" stroke="#4ecdc4" stroke-opacity="0.35"/>
155
+ <!-- Core panel -->
156
+ <rect x="54" y="124" width="42" height="44" rx="3" fill="#060a10" stroke="#4ecdc4" stroke-opacity="0.4"/>
157
+ <line x1="54" y1="146" x2="96" y2="146" stroke="#4ecdc4" stroke-opacity="0.25"/>
158
+ <line x1="75" y1="124" x2="75" y2="168" stroke="#4ecdc4" stroke-opacity="0.25"/>
159
+ <!-- Score bars on panel -->
160
+ <rect x="60" y="152" width="8" height="12" rx="1" fill="#4ecdc4" fill-opacity="0.35"/>
161
+ <rect x="72" y="146" width="8" height="18" rx="1" fill="#4ecdc4" fill-opacity="0.55"/>
162
+ <rect x="84" y="138" width="8" height="26" rx="1" fill="#7efff0" fill-opacity="0.7"/>
163
+ <!-- Neck -->
164
+ <rect x="64" y="94" width="22" height="14" rx="3" fill="#0a1018" stroke="#4ecdc4" stroke-opacity="0.25"/>
165
+ <!-- Head -->
166
+ <rect x="44" y="48" width="62" height="52" rx="8" fill="#0a1420" stroke="#4ecdc4" stroke-opacity="0.5"/>
167
+ <rect x="48" y="52" width="54" height="8" rx="2" fill="#4ecdc4" fill-opacity="0.08"/>
168
+ <!-- Visor -->
169
+ <rect class="judge-visor-svg" x="48" y="66" width="54" height="12" rx="2" fill="url(#visor-glow)" filter="url(#judge-cyan-glow)"/>
170
+ <line x1="52" y1="72" x2="98" y2="72" stroke="#7efff0" stroke-opacity="0.35" stroke-width="1"/>
171
+ <!-- Antenna array -->
172
+ <line x1="75" y1="48" x2="75" y2="38" stroke="#4ecdc4" stroke-opacity="0.7" stroke-width="2"/>
173
+ <circle cx="75" cy="36" r="3.5" fill="#4ecdc4" class="judge-sensor-svg"/>
174
+ <line x1="58" y1="52" x2="52" y2="44" stroke="#4ecdc4" stroke-opacity="0.45" stroke-width="1.5"/>
175
+ <line x1="92" y1="52" x2="98" y2="44" stroke="#4ecdc4" stroke-opacity="0.45" stroke-width="1.5"/>
176
+ <!-- Side sensors -->
177
+ <circle cx="50" cy="60" r="3" fill="#4ecdc4" fill-opacity="0.75" class="judge-sensor-svg"/>
178
+ <circle cx="100" cy="60" r="3" fill="#4ecdc4" fill-opacity="0.75" class="judge-sensor-svg"/>
179
+ </svg>
180
+ <div class="arena-artifacts arena-artifacts-judge" aria-hidden="true">
181
+ <span class="artifact artifact-score-tile"><span class="artifact-score-num">72</span></span>
182
+ <span class="artifact artifact-scanner"></span>
183
+ <span class="artifact artifact-deal-chip">DEAL</span>
184
+ </div>
185
+ <span class="character-shadow character-shadow-judge"></span>
186
+ <span class="character-label character-label-judge">AI Judge</span>
187
+ </div>
188
+
189
+ <div class="arena-dust"></div>
190
+ <div class="arena-pressure-scan"></div>
191
+ <div class="arena-scanline"></div>
192
+ </div>
193
+
194
+ <div class="arena-landing-content">
195
+ <p class="arena-eyebrow">AI Founder Pressure Arena</p>
196
+
197
+ <div class="arena-logo-wrap">
198
+ <img src="/frontend/assets/logo.svg" alt="PitchFight AI logo" class="logo arena-logo" />
199
+ </div>
200
+
201
+ <h1 class="arena-title">PitchFight AI</h1>
202
+
203
+ <div class="arena-hook" id="landing-typewriter" aria-live="polite">
204
+ <p class="arena-hook-line" id="landing-type-line-1">
205
+ <span class="arena-type-text" data-text="Your pitch is ready."></span>
206
+ <span class="arena-cursor" aria-hidden="true"></span>
207
+ </p>
208
+ <p class="arena-hook-line arena-hook-line-accent" id="landing-type-line-2">
209
+ <span class="arena-type-text" data-text="Now survive the questions."></span>
210
+ </p>
211
+ </div>
212
+
213
+ <p class="arena-support">
214
+ Train against an AI judge, defend your startup under pressure, and leave with a scorecard that shows exactly what to fix.
215
+ </p>
216
+
217
+ <div class="hero-actions arena-cta-row" id="landing-cta-row">
218
+ <button id="btn-go-setup" class="btn btn-primary btn-arena-primary" type="button">Enter the Arena</button>
219
+ <button id="btn-load-sample" class="btn btn-secondary btn-arena-secondary" type="button">Load Demo Founder</button>
220
+ </div>
221
+
222
+ <div class="arena-feature-chips" id="landing-feature-chips" aria-label="Features">
223
+ <span class="arena-chip">Pitch Battle</span>
224
+ <span class="arena-chip">Voice Mode</span>
225
+ <span class="arena-chip">Retry Weakest</span>
226
+ <span class="arena-chip">Deal Phase</span>
227
+ </div>
228
+
229
+ <footer class="arena-landing-footer">
230
+ <p class="arena-trust-badge">Powered by NVIDIA Nemotron</p>
231
+ <p class="arena-footer-line">Built for student founders before the real room.</p>
232
+ </footer>
233
+ </div>
234
  </div>
235
  </section>
236
 
237
+ <!-- Start method -->
238
+ <section id="screen-start-method" class="screen">
239
  <div class="panel glass">
240
  <div class="panel-header">
241
+ <h2>How do you want to start?</h2>
242
+ <button id="btn-start-back-landing" class="btn btn-ghost">Back</button>
243
  </div>
244
+ <div class="start-method-grid">
245
+ <button id="btn-start-text" class="start-method-card selected" type="button">
246
+ <span class="start-method-icon"></span>
247
+ <h3>Fill Details</h3>
248
+ <p>Type your startup context manually.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  </button>
250
+ <button id="btn-start-voice" class="start-method-card" type="button">
251
+ <span class="start-method-icon voice-icon">🎙</span>
252
+ <h3>Pitch First</h3>
253
+ <p>Speak a 60–90 second pitch and let AI extract the details.</p>
254
  </button>
255
+ </div>
256
+ <button id="btn-continue-start" class="btn btn-primary btn-wide">Continue</button>
257
+ </div>
258
+ </section>
259
+
260
+ <!-- Voice pitch recording -->
261
+ <section id="screen-voice-pitch" class="screen">
262
+ <div class="panel glass voice-panel">
263
+ <div class="panel-header">
264
+ <h2>Record Your Opening Pitch</h2>
265
+ <button id="btn-voice-pitch-back" class="btn btn-ghost">Back</button>
266
+ </div>
267
+ <div class="voice-guidance glass-inner">
268
+ <p>Try to mention: <strong>startup name</strong>, problem, users, solution, why AI, traction, competitors, ask.</p>
269
+ <p class="voice-hint">Aim for 60–90 seconds. Tap the mic to start, tap again to stop.</p>
270
+ </div>
271
+ <div class="voice-recorder">
272
+ <button id="btn-voice-pitch-record" class="voice-mic-btn" type="button" aria-label="Record pitch">
273
+ <span class="mic-ring"></span>
274
+ <span class="mic-icon">🎙</span>
275
  </button>
276
+ <p id="voice-pitch-timer" class="voice-timer">0:00</p>
277
+ <p id="voice-pitch-status" class="voice-status"></p>
278
+ <button id="btn-voice-pitch-cancel" class="btn btn-ghost btn-sm" type="button">Cancel</button>
279
+ </div>
280
+ </div>
281
+ </section>
282
+
283
+ <!-- Voice pitch confirmation -->
284
+ <section id="screen-voice-confirm" class="screen">
285
+ <div class="panel glass voice-panel">
286
+ <div class="panel-header">
287
+ <h2>Here's what we understood</h2>
288
+ </div>
289
+ <p id="voice-confidence-warning" class="voice-low-confidence" hidden>Low extraction confidence — please review and edit fields carefully.</p>
290
+ <div class="voice-transcript-card glass-inner">
291
+ <h3>Transcript</h3>
292
+ <p id="voice-confirm-transcript" class="voice-transcript-text"></p>
293
+ <p id="voice-confirm-delivery" class="voice-delivery-note"></p>
294
+ <span id="voice-confirm-confidence" class="delivery-chip"></span>
295
+ </div>
296
+ <form id="voice-extract-form" class="startup-form voice-extract-form">
297
+ <label>Name<input name="name" type="text" /></label>
298
+ <label>Problem<textarea name="problem" rows="2"></textarea></label>
299
+ <label>Target Users<input name="target_users" type="text" /></label>
300
+ <label>Solution<textarea name="solution" rows="2"></textarea></label>
301
+ <label>Why AI<textarea name="why_ai" rows="2"></textarea></label>
302
+ <label>Competitors<input name="competitors" type="text" /></label>
303
+ <label>Traction<input name="traction" type="text" /></label>
304
+ <label>Ask<input name="ask" type="text" /></label>
305
+ </form>
306
+ <div class="voice-confirm-actions">
307
+ <button id="btn-voice-edit-manual" class="btn btn-secondary" type="button">Edit manually</button>
308
+ <button id="btn-voice-looks-right" class="btn btn-primary" type="button">Looks right → Continue</button>
309
+ </div>
310
+ </div>
311
+ </section>
312
+
313
+ <!-- Setup -->
314
+ <section id="screen-setup" class="screen">
315
+ <div class="briefing-shell">
316
+ <header class="briefing-header">
317
+ <div>
318
+ <p class="results-eyebrow">Pre-Fight Briefing</p>
319
+ <h2 class="briefing-title">Founder Briefing</h2>
320
+ <p class="briefing-subtitle">Give the AI judge enough context to attack your pitch properly.</p>
321
+ <p class="briefing-helper">Sharper inputs create tougher questions.</p>
322
+ </div>
323
+ <button id="btn-back-landing" class="btn btn-ghost">Back</button>
324
+ </header>
325
+
326
+ <div class="briefing-grid">
327
+ <div class="panel glass briefing-panel">
328
+ <h3 class="briefing-section-title">Startup Identity</h3>
329
+ <form id="startup-form" class="startup-form briefing-form">
330
+ <div class="briefing-group">
331
+ <label>Name<input name="name" type="text" placeholder="EventRadar AI" required /></label>
332
+ </div>
333
+ <div class="briefing-group briefing-group-problem">
334
+ <h4 class="briefing-group-label">Problem + Users</h4>
335
+ <label>Problem<textarea name="problem" rows="2" required placeholder="What pain are you solving?"></textarea></label>
336
+ <label>Target Users<input name="target_users" type="text" required placeholder="Who feels this pain most?" /></label>
337
+ </div>
338
+ <div class="briefing-group briefing-group-solution">
339
+ <h4 class="briefing-group-label">Solution + Why AI</h4>
340
+ <label>Solution<textarea name="solution" rows="2" required placeholder="What do you build?"></textarea></label>
341
+ <label>Why AI<textarea name="why_ai" rows="2" required placeholder="Why AI instead of rules or manual work?"></textarea></label>
342
+ </div>
343
+ <div class="briefing-group briefing-group-traction">
344
+ <h4 class="briefing-group-label">Traction + Competitors</h4>
345
+ <label>Competitors<input name="competitors" type="text" required placeholder="Who else solves this?" /></label>
346
+ <label>Traction<input name="traction" type="text" required placeholder="Users, pilots, revenue, demos…" /></label>
347
+ </div>
348
+ <div class="briefing-group briefing-group-ask">
349
+ <h4 class="briefing-group-label">Ask / Desired Outcome</h4>
350
+ <label>Ask<input name="ask" type="text" required placeholder="Funding, pilot, mentorship, sponsorship…" /></label>
351
+ </div>
352
+ </form>
353
+ </div>
354
+
355
+ <div class="panel glass briefing-panel briefing-opponent-panel">
356
+ <h3 class="briefing-section-title">Choose Your Opponent</h3>
357
+ <div class="persona-grid">
358
+ <button class="persona-card" type="button" data-persona="skeptical_vc">
359
+ <span class="persona-icon">💼</span>
360
+ <h3>Skeptical VC</h3>
361
+ <p>Market, moat, revenue, defensibility</p>
362
+ </button>
363
+ <button class="persona-card" type="button" data-persona="technical_judge">
364
+ <span class="persona-icon">🛠️</span>
365
+ <h3>Technical Judge</h3>
366
+ <p>AI necessity, architecture, feasibility</p>
367
+ </button>
368
+ <button class="persona-card selected" type="button" data-persona="hackathon_judge">
369
+ <span class="persona-icon">🏆</span>
370
+ <h3>Hackathon Judge</h3>
371
+ <p>Novelty, demo clarity, MVP strength</p>
372
+ </button>
373
+ </div>
374
+ <div class="difficulty-selector">
375
+ <h3>Difficulty Mode</h3>
376
+ <div class="difficulty-grid">
377
+ <button class="difficulty-card selected" type="button" data-difficulty="practice">
378
+ <span class="difficulty-icon">🎓</span>
379
+ <h4>Practice Mode</h4>
380
+ <p>Student-friendly, clear questions, confidence-building</p>
381
+ </button>
382
+ <button class="difficulty-card" type="button" data-difficulty="judge">
383
+ <span class="difficulty-icon">⚖️</span>
384
+ <h4>Judge Mode</h4>
385
+ <p>Balanced hackathon judge, sharper, demo-focused</p>
386
+ </button>
387
+ <button class="difficulty-card" type="button" data-difficulty="investor">
388
+ <span class="difficulty-icon">💰</span>
389
+ <h4>Investor Mode</h4>
390
+ <p>Skeptical VC pressure — market, moat, and traction</p>
391
+ </button>
392
+ </div>
393
+ </div>
394
+ <button id="btn-start-battle" class="btn btn-primary btn-wide btn-arena-start" type="button">Start Pitch Battle</button>
395
+ </div>
396
  </div>
 
397
  </div>
398
  </section>
399
 
400
+ <!-- Battle — Pass 3B Duel Stage -->
401
+ <section id="screen-battle" class="screen screen-arena">
402
+ <div class="battle-arena-wrap">
403
+ <div class="battle-arena-scene" aria-hidden="true">
404
+ <div class="battle-arena-glow battle-arena-glow-left"></div>
405
+ <div class="battle-arena-glow battle-arena-glow-right"></div>
406
+ <div class="battle-arena-spotlight"></div>
407
+ <div class="battle-arena-floor"></div>
408
+ <div class="arena-energy-lines"></div>
409
+ <div class="arena-particles"></div>
410
+ </div>
411
+
412
+ <div class="battle-stage-shell arcade-shell">
413
+ <div class="arcade-grid-bg" aria-hidden="true"></div>
414
+ <div class="arcade-scanlines" aria-hidden="true"></div>
415
+
416
+ <header class="arcade-hud" aria-label="Battle status">
417
+ <div class="hud-cell hud-cell-round">
418
+ <span class="hud-cell-label">Round</span>
419
+ <span class="hud-cell-value hud-font" id="round-display">01</span>
420
+ </div>
421
+ <div class="hud-cell">
422
+ <span class="hud-cell-label">Opponent</span>
423
+ <span class="hud-cell-value hud-font-sm" id="sidebar-persona-name">AI Judge</span>
424
+ </div>
425
+ <div class="hud-cell">
426
+ <span class="hud-cell-label">Attack</span>
427
+ <span class="hud-cell-value hud-accent-cyan" id="attack-tag">—</span>
428
+ </div>
429
+ <div class="hud-cell">
430
+ <span class="hud-cell-label">Mode</span>
431
+ <span class="hud-cell-value" id="mode-chip">Practice</span>
432
+ </div>
433
+ <div class="hud-cell hud-cell-meter">
434
+ <span class="hud-cell-label">Confidence</span>
435
+ <div class="arcade-meter" aria-label="Confidence meter">
436
+ <div id="confidence-meter-fill" class="arcade-meter-fill confidence-fill"></div>
437
+ </div>
438
+ </div>
439
+ <div class="hud-cell hud-cell-combo">
440
+ <span class="hud-cell-label">Combo</span>
441
+ <div class="combo-meter" id="combo-meter" aria-label="Answer momentum">
442
+ <span class="combo-pip" data-pip="1"></span>
443
+ <span class="combo-pip" data-pip="2"></span>
444
+ <span class="combo-pip" data-pip="3"></span>
445
+ <span class="combo-pip" data-pip="4"></span>
446
+ <span class="combo-pip" data-pip="5"></span>
447
+ </div>
448
+ </div>
449
+ <div class="hud-cell hud-cell-actions">
450
+ <button id="btn-end-battle" class="btn btn-arcade-end btn-arcade-end-lg" type="button">End Battle</button>
451
+ <button id="btn-back-scorecard" class="btn btn-arcade-back" type="button" hidden>Back to Scorecard</button>
452
+ </div>
453
+ <span id="round-counter" hidden></span>
454
+ <span id="attack-tag-chip" hidden></span>
455
+ <span id="pressure-chip" hidden></span>
456
+ <span id="battle-phase" hidden></span>
457
+ <span id="difficulty-label" hidden></span>
458
+ <span id="sidebar-persona-mode" hidden></span>
459
+ <span id="pressure-level" hidden></span>
460
+ <span class="pressure-meter pressure-meter-hud" hidden aria-hidden="true">
461
+ <span class="pressure-meter-track"><span id="pressure-meter-fill" class="pressure-meter-fill pressure-warmup"></span></span>
462
+ </span>
463
+ <span id="battle-progress-fill" class="battle-progress-fill pressure-warmup" hidden aria-hidden="true"></span>
464
+ </header>
465
+
466
+ <nav class="battle-progress-strip arcade-progress" aria-label="Battle progression">
467
+ <div class="progress-strip-rounds" id="battle-progress-rounds">
468
+ <span class="progress-node" data-round="1"><span class="progress-node-dot"></span>R1</span>
469
+ <span class="progress-connector" aria-hidden="true"></span>
470
+ <span class="progress-node" data-round="2"><span class="progress-node-dot"></span>R2</span>
471
+ <span class="progress-connector" aria-hidden="true"></span>
472
+ <span class="progress-node" data-round="3"><span class="progress-node-dot"></span>R3</span>
473
+ <span class="progress-connector" aria-hidden="true"></span>
474
+ <span class="progress-node" data-round="4"><span class="progress-node-dot"></span>R4</span>
475
+ <span class="progress-connector" aria-hidden="true"></span>
476
+ <span class="progress-node" data-round="5"><span class="progress-node-dot"></span>R5</span>
477
+ </div>
478
+ <div class="progress-strip-attacks" id="battle-progress-attacks" aria-label="Attack progression">
479
+ <span class="progress-attack-tag" data-attack="User Pain">User Pain</span>
480
+ <span class="progress-attack-tag" data-attack="Novelty">Novelty</span>
481
+ <span class="progress-attack-tag" data-attack="MVP Strength">MVP</span>
482
+ <span class="progress-attack-tag" data-attack="Business Model">Model</span>
483
+ <span class="progress-attack-tag" data-attack="Objection Handling">Objections</span>
484
+ </div>
485
+ </nav>
486
+
487
+ <div class="arcade-duel-frame">
488
+ <div class="fighter-row">
489
+ <div class="fighter-panel fighter-founder">
490
+ <div class="fighter-avatar-wrap">
491
+ <svg class="holo-avatar holo-founder" viewBox="0 0 64 88" aria-hidden="true">
492
+ <defs>
493
+ <linearGradient id="holo-f-gold" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#f4d35e"/><stop offset="100%" stop-color="#8a6520"/></linearGradient>
494
+ </defs>
495
+ <ellipse cx="32" cy="80" rx="22" ry="3" fill="rgba(244,211,94,0.2)"/>
496
+ <path d="M18 38 Q32 30 46 38 L48 62 Q32 68 16 62 Z" fill="#1a1208" stroke="url(#holo-f-gold)" stroke-width="1.2"/>
497
+ <circle cx="32" cy="24" r="11" fill="#3d2810" stroke="#f4d35e" stroke-width="1"/>
498
+ <rect x="38" y="42" width="12" height="16" rx="1.5" fill="#0e0c08" stroke="#f4d35e" stroke-opacity="0.6" transform="rotate(8 44 50)"/>
499
+ <path d="M14 38 L8 48 M50 38 L56 46" stroke="#f4d35e" stroke-opacity="0.35" stroke-width="1"/>
500
+ </svg>
501
+ <span class="fighter-avatar-glow founder-glow"></span>
502
+ </div>
503
+ <div class="fighter-label-box">
504
+ <span class="fighter-tag">You</span>
505
+ <span class="fighter-name">Founder</span>
506
+ </div>
507
+ <ul class="signal-stats founder-signals" id="founder-signal-chips">
508
+ <li><span>Traction</span><strong>—</strong></li>
509
+ <li><span>Market</span><strong>Live</strong></li>
510
+ <li><span>Moat</span><strong>Building</strong></li>
511
+ </ul>
512
+ </div>
513
+
514
+ <div class="duel-node">
515
+ <div class="duel-node-badge hud-font">
516
+ <span id="round-counter-duel">01</span>
517
+ </div>
518
+ <span class="duel-node-vs hud-font">VS</span>
519
+ <span id="duel-pressure-label" class="duel-node-pressure">Warm-up</span>
520
+ <div class="duel-node-ring" aria-hidden="true"></div>
521
+ </div>
522
+
523
+ <div class="fighter-panel fighter-judge">
524
+ <div class="fighter-avatar-wrap">
525
+ <svg class="holo-avatar holo-judge" viewBox="0 0 64 88" aria-hidden="true">
526
+ <defs>
527
+ <linearGradient id="holo-j-cyan" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#4ecdc4"/><stop offset="100%" stop-color="#7efff0"/></linearGradient>
528
+ </defs>
529
+ <ellipse cx="32" cy="80" rx="22" ry="3" fill="rgba(78,205,196,0.15)"/>
530
+ <path d="M16 40 Q32 32 48 40 L50 64 Q32 70 14 64 Z" fill="#0a1420" stroke="#4ecdc4" stroke-width="1.2"/>
531
+ <rect x="20" y="18" width="24" height="20" rx="4" fill="#060a10" stroke="#4ecdc4" stroke-width="1"/>
532
+ <rect x="24" y="26" width="16" height="4" rx="1" fill="url(#holo-j-cyan)"/>
533
+ <rect x="24" y="44" width="16" height="14" rx="1" fill="#060a10" stroke="#4ecdc4" stroke-opacity="0.5"/>
534
+ <rect x="26" y="50" width="3" height="6" fill="#4ecdc4" fill-opacity="0.5"/>
535
+ <rect x="30.5" y="47" width="3" height="9" fill="#7efff0" fill-opacity="0.65"/>
536
+ <rect x="35" y="45" width="3" height="11" fill="#4ecdc4"/>
537
+ </svg>
538
+ <span class="fighter-avatar-glow judge-glow"></span>
539
+ </div>
540
+ <div class="fighter-label-box judge-label-box">
541
+ <span class="fighter-tag">Judge</span>
542
+ <span class="fighter-name" id="judge-fighter-name">AI Judge</span>
543
+ </div>
544
+ <ul class="signal-stats judge-signals" id="judge-signal-chips">
545
+ <li><span>Style</span><strong id="judge-stat-style">Socratic</strong></li>
546
+ <li><span>Focus</span><strong id="judge-stat-focus">—</strong></li>
547
+ <li><span>Pressure</span><strong id="judge-stat-pressure">Warm-up</strong></li>
548
+ </ul>
549
+ </div>
550
+ </div>
551
+
552
+ <article id="judge-live-card" class="attack-card-arena">
553
+ <div class="attack-card-banner hud-font">&gt;&gt; ATTACK &lt;&lt;</div>
554
+ <div class="attack-card-inner">
555
+ <span id="judge-attack-pill" class="judge-attack-pill attack-card-tag">—</span>
556
+ <span id="judge-question-meta" class="judge-attack-meta" hidden></span>
557
+ <blockquote id="judge-question-text" class="judge-question-text attack-card-question">AI judge is preparing the next attack…</blockquote>
558
+ </div>
559
+ <div class="attack-card-glow" aria-hidden="true"></div>
560
+ <div class="judge-card-scan" aria-hidden="true"></div>
561
+ </article>
562
+ </div>
563
+
564
+ <p id="founder-coach-hint" hidden aria-hidden="true"></p>
565
+ <p id="micro-coach" class="micro-coach" hidden aria-hidden="true"></p>
566
+ <p id="answer-hint" class="answer-hint" hidden aria-hidden="true"></p>
567
+
568
+ <div class="battle-log-ribbon arcade-log-bar" id="battle-log-ribbon" hidden aria-hidden="true">
569
+ <span class="battle-log-label hud-font">Battle Log</span>
570
+ <div class="battle-log-tabs" id="battle-log-tabs" role="tablist"></div>
571
+ <div class="battle-log-detail" id="battle-log-detail" hidden></div>
572
+ </div>
573
+
574
+ <div id="battle-readiness-prompt" class="battle-readiness-prompt arena-readiness-prompt" hidden>
575
+ <p id="battle-readiness-text">Enough signal collected. Ready for your scorecard?</p>
576
+ <div class="battle-readiness-actions">
577
+ <button id="btn-battle-readiness-end" class="btn btn-primary btn-sm" type="button">End Battle Now</button>
578
+ <button id="btn-battle-readiness-continue" class="btn btn-ghost btn-sm" type="button">Continue One More Round</button>
579
+ </div>
580
+ </div>
581
+
582
+ <details id="battle-history" hidden aria-hidden="true"></details>
583
+
584
+ <footer class="response-dock arcade-dock unified-dock">
585
+ <div class="dock-unified-header">
586
+ <div class="dock-header-main">
587
+ <p class="response-dock-label hud-font">Your Move</p>
588
+ <p id="dock-assist-hint" class="dock-assist-hint">Tip: use numbers</p>
589
+ </div>
590
+ <button id="btn-open-battle-rounds" class="btn btn-arcade-log-inline previous-rounds-toggle" type="button" hidden>
591
+ View Battle Log <span id="timeline-count" class="timeline-count"></span>
592
+ </button>
593
+ </div>
594
+
595
+ <div id="voice-turn-preview" class="voice-turn-preview voice-console-preview" hidden>
596
+ <h4>Voice transcript</h4>
597
+ <div class="voice-wave-decor voice-wave-active" aria-hidden="true"></div>
598
+ <textarea id="voice-turn-transcript" rows="3" placeholder="Edit transcript before sending…"></textarea>
599
+ <p id="voice-turn-delivery" class="voice-delivery-note"></p>
600
+ <div class="voice-turn-actions">
601
+ <button id="btn-voice-turn-send" class="btn btn-primary btn-sm" type="button">Send Voice Answer</button>
602
+ <button id="btn-voice-turn-cancel" class="btn btn-ghost btn-sm" type="button">Discard</button>
603
+ </div>
604
+ </div>
605
+
606
+ <form id="chat-form" class="response-dock-form arcade-dock-form">
607
+ <textarea id="user-input" rows="3" placeholder="Type your defense here…"></textarea>
608
+ <div class="response-action-row arcade-action-row">
609
+ <div class="response-action-left">
610
+ <button id="btn-voice-turn-record" class="btn btn-arcade-voice" type="button" aria-label="Voice mode — record answer">
611
+ <span class="arcade-voice-ring" aria-hidden="true"></span>
612
+ <svg class="arcade-voice-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
613
+ <path fill="currentColor" d="M12 14a3 3 0 0 0 3-3V6a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.92V21h2v-3.08A7 7 0 0 0 19 11h-2z"/>
614
+ </svg>
615
+ <span class="arcade-voice-label">Voice Mode</span>
616
+ </button>
617
+ <span id="voice-turn-timer" class="voice-timer voice-timer-inline hud-font">0:00</span>
618
+ <span id="voice-turn-status" class="voice-status"></span>
619
+ <div class="dock-hint-chips hint-chips" aria-label="Answer hints">
620
+ <button type="button" class="hint-chip" data-hint="We measured ">Proof</button>
621
+ <button type="button" class="hint-chip" data-hint="Our users ">User</button>
622
+ <button type="button" class="hint-chip" data-hint="Traction: ">Traction</button>
623
+ </div>
624
+ </div>
625
+ <button id="btn-send" type="submit" class="btn btn-arcade-send">Send &gt;&gt;</button>
626
+ </div>
627
+ </form>
628
+ <p id="battle-status" class="status-text battle-status-msg" hidden></p>
629
+ </footer>
630
  </div>
631
  </div>
632
  </section>
633
 
634
  <!-- Scorecard -->
635
  <section id="screen-scorecard" class="screen">
636
+ <div class="results-shell results-compact">
637
+ <article class="results-hero-card glass">
638
+ <header class="results-hero-header">
639
+ <div class="results-hero-header-left">
640
+ <p class="results-eyebrow">Pitch Battle Result</p>
641
+ <button id="btn-view-conversation" class="btn btn-conversation-review" type="button">View Conversation</button>
642
+ </div>
643
+ <p id="scorecard-source-badge" class="source-chip" hidden></p>
644
+ </header>
645
+ <p id="scorecard-fallback-warning" class="results-fallback-warn" hidden></p>
646
+
647
+ <div class="results-hero-grid">
648
+ <div class="score-orb-wrap">
649
+ <div class="score-orb" aria-label="Overall pitch score">
650
+ <strong id="overall-score" class="score-orb-value">0</strong>
651
+ <span id="overall-label" class="score-orb-label"></span>
652
+ </div>
653
+ </div>
654
+ <div class="results-hero-center">
655
+ <p id="pitch-readout" class="result-verdict-text">Your pitch battle is complete.</p>
656
+ <div class="insight-grid score-triad" id="pitch-stat-chips">
657
+ <span class="source-chip chip-strong" id="chip-strongest-dim">Strongest: —</span>
658
+ <span class="source-chip chip-weak" id="chip-weakest-dim">Weakest: —</span>
659
+ <span class="source-chip chip-source" id="chip-score-source" hidden>Nemotron Judge</span>
660
+ </div>
661
+ </div>
662
+ </div>
663
+
664
+ <div class="result-next-action result-next-action-compact" id="pitch-next-action" hidden>
665
+ <span class="result-next-action-label">Next</span>
666
+ <p id="pitch-next-action-text" class="result-next-action-text"></p>
667
+ </div>
668
+
669
+ <div class="scorecard-actions results-hero-actions results-hero-actions-compact">
670
+ <button id="btn-path-to-80" class="btn btn-path80" type="button">View Path to 80+</button>
671
+ <button id="btn-reset" class="btn btn-primary" type="button">New Battle</button>
672
+ <button id="btn-back-setup" class="btn btn-secondary" type="button">Edit Startup</button>
673
+ </div>
674
+ </article>
675
+
676
+ <article id="judge-verdict-hero" class="judge-verdict-hero glass" hidden>
677
+ <p class="results-eyebrow">Judge Verdict</p>
678
+ <div id="judge-verdict-section" class="judge-verdict-panel">
679
+ <article id="judge-verdict-card" class="judge-verdict-card glass-inner">
680
+ <div class="judge-verdict-head">
681
+ <span id="verdict-persona-badge" class="verdict-persona-badge result-verdict-badge"></span>
682
+ <span id="verdict-interest-badge" class="verdict-interest-badge"></span>
683
+ </div>
684
+ <blockquote id="verdict-reaction" class="verdict-reaction verdict-reaction-compact"></blockquote>
685
+ <div class="verdict-meta-grid verdict-meta-compact">
686
+ <div><span>Deal Type</span><strong id="verdict-deal-type"></strong></div>
687
+ <div><span>Why</span><strong id="verdict-why" class="clamp-text clamp-short"></strong></div>
688
+ </div>
689
+ <p id="verdict-opening-offer" class="verdict-opening-offer" hidden></p>
690
+ <div id="verdict-actions" class="verdict-actions"></div>
691
+ </article>
692
+ </div>
693
+ </article>
694
+
695
+ <nav class="result-tabs" id="pitch-result-tabs" role="tablist" aria-label="Pitch scorecard sections">
696
+ <button type="button" class="result-tab active" role="tab" aria-selected="true" data-tab="overview">Overview</button>
697
+ <button type="button" class="result-tab" role="tab" aria-selected="false" data-tab="dimensions">Dimensions</button>
698
+ <button type="button" class="result-tab" role="tab" aria-selected="false" data-tab="coaching">Coaching</button>
699
+ <button type="button" class="result-tab" role="tab" aria-selected="false" data-tab="voice" id="tab-voice-delivery" hidden>Voice Delivery</button>
700
+ </nav>
701
+
702
+ <div class="result-panels" id="pitch-result-panels">
703
+ <section class="result-panel active" data-panel="overview" role="tabpanel">
704
+ <div class="answer-compare overview-answers overview-answers-compact">
705
+ <article class="feedback-card answer-card answer-best coaching-card">
706
+ <div class="feedback-card-head">
707
+ <span class="answer-badge answer-badge-best">Strongest Answer</span>
708
+ <span id="best-answer-round" class="round-badge" hidden></span>
709
+ </div>
710
+ <blockquote id="best-answer" class="answer-quote clamp-text clamp-short"></blockquote>
711
+ </article>
712
+ <article class="feedback-card answer-card answer-weak coaching-card">
713
+ <div class="feedback-card-head">
714
+ <span class="answer-badge answer-badge-weak">Weakest Answer</span>
715
+ <span id="weakest-answer-round" class="round-badge" hidden></span>
716
+ </div>
717
+ <blockquote id="weakest-answer" class="answer-quote answer-quote-weak clamp-text clamp-short"></blockquote>
718
+ <p id="why-weak" class="why-weak-note clamp-text clamp-short" hidden></p>
719
+ </article>
720
+ </div>
721
+ <div id="signals-summary" class="insight-grid signals-chips signals-chips-compact" hidden></div>
722
+ </section>
723
+
724
+ <section class="result-panel" data-panel="dimensions" role="tabpanel" hidden>
725
+ <div id="score-bars" class="dimension-list"></div>
726
+ </section>
727
+
728
+ <section class="result-panel" data-panel="coaching" role="tabpanel" hidden>
729
+ <div class="feedback-grid coaching-grid">
730
+ <article class="feedback-card highlight coaching-card">
731
+ <div class="feedback-card-head">
732
+ <span class="feedback-icon" aria-hidden="true">✦</span>
733
+ <h3>Improved Answer</h3>
734
+ </div>
735
+ <p id="improved-answer" class="feedback-body clamp-text"></p>
736
+ </article>
737
+ <article class="feedback-card highlight coaching-card">
738
+ <div class="feedback-card-head">
739
+ <span class="feedback-icon" aria-hidden="true">◎</span>
740
+ <h3>Improved Pitch</h3>
741
+ </div>
742
+ <p id="improved-pitch" class="feedback-body clamp-text"></p>
743
+ </article>
744
+ </div>
745
+ <div class="coaching-card prep-questions">
746
+ <h3 class="scorecard-section-title">Top 3 Prep Questions</h3>
747
+ <ol id="top-questions" class="prep-list prep-checklist"></ol>
748
+ </div>
749
+ </section>
750
 
751
+ <section class="result-panel" data-panel="voice" role="tabpanel" hidden>
752
+ <div id="voice-delivery-section" class="voice-delivery-panel">
753
+ <p class="scorecard-section-desc">Observable delivery cues from your spoken answers.</p>
754
+ <div id="voice-delivery-content" class="voice-delivery-grid"></div>
755
+ </div>
756
+ </section>
757
+ </div>
758
+ </div>
759
+ </section>
760
+
761
+ <!-- Deal Arena — Pass 3B Duel Stage -->
762
+ <section id="screen-deal" class="screen screen-arena">
763
+ <div class="battle-arena-wrap deal-arena-wrap">
764
+ <div class="battle-arena-scene deal-arena-scene" aria-hidden="true">
765
+ <div class="battle-arena-glow battle-arena-glow-left"></div>
766
+ <div class="battle-arena-glow battle-arena-glow-right deal-glow-right"></div>
767
+ <div class="battle-arena-spotlight deal-spotlight"></div>
768
+ <div class="battle-arena-floor"></div>
769
+ <div class="arena-energy-lines deal-energy-lines"></div>
770
+ </div>
771
+
772
+ <div class="battle-stage-shell deal-stage-shell arcade-shell deal-arcade-shell">
773
+ <div class="arcade-grid-bg" aria-hidden="true"></div>
774
+ <div class="arcade-scanlines" aria-hidden="true"></div>
775
+ <header class="battle-hud deal-hud glass" aria-label="Deal status">
776
+ <div class="battle-hud-main">
777
+ <span class="hud-round">DEAL <span id="deal-round-display">01</span></span>
778
+ <span class="hud-sep" aria-hidden="true"></span>
779
+ <span class="hud-opponent" id="deal-persona-name">—</span>
780
+ <span class="hud-meta">Type: <strong id="deal-type-label">—</strong></span>
781
+ <span class="hud-meta">Focus: <strong id="deal-negotiation-tag">—</strong></span>
782
+ <span class="hud-mode deal-hud-chip" id="deal-type-chip">—</span>
783
+ </div>
784
+ <div class="battle-hud-actions">
785
+ <button id="btn-open-deal-rounds" class="btn btn-ghost btn-sm previous-rounds-toggle" type="button" hidden>
786
+ Full Log <span id="deal-timeline-count" class="timeline-count"></span>
787
+ </button>
788
+ <button id="btn-end-deal" class="btn btn-danger btn-sm" type="button">End Deal</button>
789
+ <button id="btn-deal-back-scorecard" class="btn btn-ghost btn-sm" type="button">Back to Pitch Scorecard</button>
790
+ </div>
791
+ <span id="deal-round-counter" hidden></span>
792
+ <span id="deal-focus-chip" hidden></span>
793
+ </header>
794
+
795
+ <nav class="battle-progress-strip deal-progress-strip glass" aria-label="Deal progression">
796
+ <div class="progress-strip-rounds" id="deal-progress-rounds">
797
+ <span class="progress-node" data-round="1"><span class="progress-node-dot"></span>R1</span>
798
+ <span class="progress-connector" aria-hidden="true"></span>
799
+ <span class="progress-node" data-round="2"><span class="progress-node-dot"></span>R2</span>
800
+ <span class="progress-connector" aria-hidden="true"></span>
801
+ <span class="progress-node" data-round="3"><span class="progress-node-dot"></span>R3</span>
802
+ <span class="progress-connector" aria-hidden="true"></span>
803
+ <span class="progress-node" data-round="4"><span class="progress-node-dot"></span>R4</span>
804
+ </div>
805
+ </nav>
806
+
807
+ <div class="battle-log-ribbon glass deal-log-ribbon" id="deal-log-ribbon" hidden>
808
+ <span class="battle-log-label">Negotiation Log</span>
809
+ <div class="battle-log-tabs" id="deal-log-tabs" role="tablist"></div>
810
+ <div class="battle-log-detail" id="deal-log-detail" hidden></div>
811
+ </div>
812
+
813
+ <div class="duel-stage duel-stage-arena deal-duel-stage">
814
+ <div class="duel-stage-beam duel-beam-founder" aria-hidden="true"></div>
815
+ <div class="duel-stage-beam duel-beam-judge deal-beam" aria-hidden="true"></div>
816
+
817
+ <aside class="duel-founder-card glass deal-founder-card">
818
+ <div class="duel-card-inner">
819
+ <div class="duel-character duel-character-founder deal-character" aria-hidden="true">
820
+ <svg class="duel-svg duel-svg-founder" viewBox="0 0 80 120" xmlns="http://www.w3.org/2000/svg">
821
+ <ellipse cx="40" cy="112" rx="28" ry="4" fill="rgba(74,222,128,0.12)"/>
822
+ <path d="M24 52 Q40 44 56 52 L60 88 Q40 94 20 88 Z" fill="#1a3028" stroke="#4ade80" stroke-opacity="0.3"/>
823
+ <ellipse cx="40" cy="36" rx="14" ry="16" fill="#c9954a"/>
824
+ <rect x="48" y="58" width="18" height="24" rx="2" fill="#1a1510" stroke="#4ade80" stroke-opacity="0.45" transform="rotate(6 57 70)"/>
825
+ </svg>
826
+ <span class="duel-char-glow duel-char-glow-deal"></span>
827
+ </div>
828
+ <div class="duel-founder-body">
829
+ <p class="duel-side-label">Your Terms</p>
830
+ <dl class="deal-terms-mini">
831
+ <div><dt>Opening Offer</dt><dd id="deal-opening-offer">—</dd></div>
832
+ <div><dt>Your Ask</dt><dd id="deal-your-ask">—</dd></div>
833
+ </dl>
834
+ <p class="founder-coach-single">Counter with evidence, leverage, and clarity.</p>
835
+ </div>
836
+ </div>
837
+ </aside>
838
+
839
+ <div class="duel-core deal-core">
840
+ <div class="duel-pressure-ring deal-pressure-ring pressure-core-live">
841
+ <span class="duel-core-pulse" aria-hidden="true"></span>
842
+ <span class="duel-vs">VS</span>
843
+ <span class="duel-core-round">D<span id="deal-round-counter-duel">1</span></span>
844
+ </div>
845
+ </div>
846
+
847
+ <article id="deal-judge-card" class="duel-judge-card judge-attack-card deal-judge-card glass">
848
+ <div class="judge-attack-glow deal-attack-glow" aria-hidden="true"></div>
849
+ <div class="duel-card-inner judge-card-inner">
850
+ <div class="duel-character duel-character-judge" aria-hidden="true">
851
+ <svg class="duel-svg duel-svg-judge" viewBox="0 0 80 120" xmlns="http://www.w3.org/2000/svg">
852
+ <ellipse cx="40" cy="112" rx="28" ry="4" fill="rgba(74,222,128,0.1)"/>
853
+ <path d="M22 54 Q40 46 58 54 L62 90 Q40 96 18 90 Z" fill="#0a2018" stroke="#4ade80" stroke-opacity="0.35"/>
854
+ <rect x="26" y="28" width="28" height="26" rx="5" fill="#0a1420" stroke="#4ade80" stroke-opacity="0.5"/>
855
+ <rect x="30" y="38" width="20" height="6" rx="1" fill="#4ade80" fill-opacity="0.6"/>
856
+ <rect x="28" y="58" width="24" height="28" rx="2" fill="#060a10" stroke="#4ade80" stroke-opacity="0.35"/>
857
+ </svg>
858
+ <span class="duel-char-glow duel-char-glow-judge deal-glow"></span>
859
+ </div>
860
+ <div class="judge-attack-body">
861
+ <div class="judge-card-head">
862
+ <span class="judge-badge">AI Judge</span>
863
+ <span id="deal-judge-attack-pill" class="judge-attack-pill deal-pill">—</span>
864
+ </div>
865
+ <span id="deal-judge-meta" class="judge-attack-meta" hidden></span>
866
+ <blockquote id="deal-judge-text" class="judge-question-text judge-question-quote">Preparing deal terms…</blockquote>
867
+ </div>
868
+ </div>
869
+ <div class="judge-card-scan deal-scan" aria-hidden="true"></div>
870
+ </article>
871
+ </div>
872
+
873
+ <div id="deal-readiness-prompt" class="deal-readiness-prompt arena-readiness-prompt" hidden>
874
+ <p id="deal-readiness-text">You have enough negotiation signal for a scorecard.</p>
875
+ <div class="deal-readiness-actions">
876
+ <button id="btn-deal-readiness-end" class="btn btn-primary btn-sm" type="button">End Deal Now</button>
877
+ <button id="btn-deal-readiness-continue" class="btn btn-ghost btn-sm" type="button">Continue One More Round</button>
878
+ </div>
879
+ </div>
880
+
881
+ <div id="deal-rounds-drawer" class="previous-rounds-drawer" hidden>
882
+ <button type="button" class="previous-rounds-backdrop" id="btn-close-deal-rounds" aria-label="Close previous rounds"></button>
883
+ <div class="previous-rounds-panel glass" role="dialog" aria-labelledby="deal-rounds-title">
884
+ <header class="previous-rounds-header">
885
+ <h3 id="deal-rounds-title">Previous Rounds</h3>
886
+ <button type="button" class="btn btn-ghost btn-sm" id="btn-close-deal-rounds-x">Close</button>
887
+ </header>
888
+ <div id="deal-chat-window" class="chat-window battle-timeline deal-timeline previous-rounds-timeline" aria-live="polite"></div>
889
+ </div>
890
+ </div>
891
+ <details id="deal-history" hidden aria-hidden="true"></details>
892
+
893
+ <footer class="response-dock glass deal-response-dock response-dock-move">
894
+ <div class="response-dock-header">
895
+ <p class="response-dock-label">Your Counter</p>
896
+ <p class="dock-assist-hint">Anchor on evidence. Concede with leverage.</p>
897
+ </div>
898
+
899
+ <div id="deal-voice-preview" class="voice-turn-preview voice-console-preview" hidden>
900
+ <h4>Voice transcript</h4>
901
+ <div class="voice-wave-decor voice-wave-active" aria-hidden="true"></div>
902
+ <textarea id="deal-voice-transcript" rows="3" placeholder="Edit transcript before sending…"></textarea>
903
+ <div class="voice-turn-actions">
904
+ <button id="btn-deal-voice-send" class="btn btn-primary btn-sm" type="button">Send Counter</button>
905
+ <button id="btn-deal-voice-cancel" class="btn btn-ghost btn-sm" type="button">Discard</button>
906
+ </div>
907
+ </div>
908
+
909
+ <form id="deal-form" class="response-dock-form">
910
+ <textarea id="deal-input" rows="4" placeholder="Defend your terms with evidence and leverage…"></textarea>
911
+ <div class="response-action-row">
912
+ <div class="response-action-left">
913
+ <button id="btn-deal-voice-record" class="voice-mic-btn voice-mic-btn-sm voice-pill" type="button" aria-label="Record counter">
914
+ <span class="mic-ring"></span>
915
+ <span class="mic-icon">🎙</span>
916
+ </button>
917
+ <span class="input-tab-label">Voice</span>
918
+ <span id="deal-voice-timer" class="voice-timer voice-timer-inline">0:00</span>
919
+ <span id="deal-voice-status" class="voice-status"></span>
920
+ </div>
921
+ <button id="btn-deal-send" type="submit" class="btn btn-deal-send btn-send-answer">Send Counter</button>
922
+ </div>
923
+ </form>
924
+ <p id="deal-status" class="status-text battle-status-msg" hidden></p>
925
+ </footer>
926
+ </div>
927
+ </div>
928
+ </section>
929
+
930
+ <!-- Deal + Combined Scorecard -->
931
+ <section id="screen-deal-scorecard" class="screen">
932
+ <div class="results-shell">
933
+ <article class="results-hero-card glass">
934
+ <header class="results-hero-header">
935
+ <p class="results-eyebrow">Combined Founder Readout</p>
936
+ <p id="deal-scorecard-meta" class="source-chip" hidden></p>
937
+ </header>
938
+
939
+ <div class="score-triad combined-score-triad">
940
+ <div class="combined-score-item">
941
+ <span>Pitch</span>
942
+ <strong id="combined-pitch">0</strong>
943
+ </div>
944
+ <div class="combined-score-item">
945
+ <span>Deal</span>
946
+ <strong id="combined-deal">0</strong>
947
+ </div>
948
+ <div class="combined-score-item combined-highlight">
949
+ <span>Combined</span>
950
+ <strong id="combined-overall">0</strong>
951
+ </div>
952
+ </div>
953
+
954
+ <p id="combined-profile" class="result-verdict-text combined-profile"></p>
955
+ <p id="combined-summary" class="combined-summary clamp-text"></p>
956
+
957
+ <div class="result-next-action">
958
+ <span class="result-next-action-label">Next Best Action</span>
959
+ <p id="combined-next-action" class="result-next-action-text"></p>
960
+ </div>
961
+
962
+ <div class="deal-outcome-row results-hero-meta">
963
+ <span id="deal-outcome-badge" class="deal-outcome-badge result-verdict-badge"></span>
964
+ <span id="deal-overall-label" class="deal-overall-label"></span>
965
+ </div>
966
+
967
+ <div class="scorecard-actions results-hero-actions">
968
+ <button id="btn-deal-view-pitch-scorecard" class="btn btn-secondary" type="button">View Pitch Breakdown</button>
969
+ <button id="btn-deal-view-negotiation" class="btn btn-secondary" type="button">View Negotiation Conversation</button>
970
+ <button id="btn-deal-new-battle" class="btn btn-primary" type="button">New Battle</button>
971
+ </div>
972
+ </article>
973
+
974
+ <nav class="result-tabs" id="deal-result-tabs" role="tablist" aria-label="Deal scorecard sections">
975
+ <button type="button" class="result-tab active" role="tab" aria-selected="true" data-tab="deal-summary">Combined Summary</button>
976
+ <button type="button" class="result-tab" role="tab" aria-selected="false" data-tab="pitch-vs-deal">Pitch vs Deal</button>
977
+ <button type="button" class="result-tab" role="tab" aria-selected="false" data-tab="deal-dimensions">Deal Dimensions</button>
978
+ <button type="button" class="result-tab" role="tab" aria-selected="false" data-tab="deal-highlights">Highlights</button>
979
+ <button type="button" class="result-tab" role="tab" aria-selected="false" data-tab="deal-coaching">Coaching</button>
980
+ </nav>
981
+
982
+ <div class="result-panels" id="deal-result-panels">
983
+ <section class="result-panel active" data-panel="deal-summary" role="tabpanel">
984
+ <div class="coaching-card">
985
+ <h3 class="scorecard-section-title">Session Snapshot</h3>
986
+ <p class="scorecard-section-desc">Your final founder profile after pitch + deal.</p>
987
+ <p id="deal-summary-weakest" class="deal-weakest-line"></p>
988
+ </div>
989
+ </section>
990
+
991
+ <section class="result-panel" data-panel="pitch-vs-deal" role="tabpanel" hidden>
992
+ <div class="score-triad combined-score-triad compact-triad">
993
+ <div class="combined-score-item"><span>Pitch</span><strong id="combined-pitch-tab">0</strong></div>
994
+ <div class="combined-score-item"><span>Deal</span><strong id="combined-deal-tab">0</strong></div>
995
+ <div class="combined-score-item combined-highlight"><span>Combined</span><strong id="combined-overall-tab">0</strong></div>
996
+ </div>
997
+ <p id="combined-profile-tab" class="combined-profile"></p>
998
+ </section>
999
+
1000
+ <section class="result-panel" data-panel="deal-dimensions" role="tabpanel" hidden>
1001
+ <div id="deal-score-bars" class="dimension-list"></div>
1002
+ </section>
1003
+
1004
+ <section class="result-panel" data-panel="deal-highlights" role="tabpanel" hidden>
1005
+ <div class="feedback-grid coaching-grid">
1006
+ <article class="feedback-card coaching-card"><h3>Best Move</h3><p id="deal-best-move" class="clamp-text"></p></article>
1007
+ <article class="feedback-card coaching-card answer-weak"><h3>Weakest Move</h3><p id="deal-weakest-move" class="clamp-text"></p></article>
1008
+ </div>
1009
+ </section>
1010
+
1011
+ <section class="result-panel" data-panel="deal-coaching" role="tabpanel" hidden>
1012
+ <article class="feedback-card highlight coaching-card">
1013
+ <h3>Improved Response</h3>
1014
+ <p id="deal-improved-response" class="clamp-text"></p>
1015
+ </article>
1016
+ <div class="coaching-card">
1017
+ <h3 class="scorecard-section-title">Top 3 Prep Points</h3>
1018
+ <ol id="deal-prep-points" class="prep-list prep-checklist"></ol>
1019
+ </div>
1020
+ </section>
1021
+ </div>
1022
+ </div>
1023
+ </section>
1024
+ </main>
1025
+
1026
+ <!-- Battle log — body-level so arena overflow does not clip the modal -->
1027
+ <div id="battle-rounds-drawer" class="previous-rounds-drawer conversation-split-drawer" hidden>
1028
+ <button type="button" class="previous-rounds-backdrop" id="btn-close-battle-rounds" aria-label="Close battle log"></button>
1029
+ <div class="conversation-split-panel glass" role="dialog" aria-labelledby="battle-rounds-title">
1030
+ <header class="conversation-split-header">
1031
+ <h3 id="battle-rounds-title">Battle Log</h3>
1032
+ <button type="button" class="btn btn-ghost btn-sm" id="btn-close-battle-rounds-x">Close</button>
1033
+ </header>
1034
+ <div class="conversation-split-body">
1035
+ <aside id="conversation-sidebar" class="conversation-sidebar" aria-label="Battle details"></aside>
1036
+ <div class="conversation-thread-wrap">
1037
+ <p class="conversation-thread-hint" aria-hidden="true">Scroll for full history ↓</p>
1038
+ <div id="chat-window" class="chat-window conversation-thread" aria-live="polite"></div>
1039
+ </div>
1040
+ </div>
1041
+ </div>
1042
+ </div>
1043
+
1044
+ <!-- Negotiation Conversation Overlay -->
1045
+ <div id="negotiation-overlay" class="negotiation-overlay conversation-modal" hidden aria-modal="true" role="dialog" aria-label="Negotiation Conversation">
1046
+ <div class="negotiation-panel glass results-modal-panel">
1047
+ <div class="negotiation-header">
1048
+ <div>
1049
+ <p class="results-eyebrow">Deal Phase</p>
1050
+ <h2>Negotiation Conversation</h2>
1051
+ </div>
1052
+ <button id="btn-close-negotiation" class="btn btn-ghost btn-sm" type="button">✕ Close</button>
1053
+ </div>
1054
+ <div id="negotiation-transcript" class="negotiation-transcript conversation-timeline"></div>
1055
+ <div class="negotiation-footer">
1056
+ <button id="btn-negotiation-back" class="btn btn-secondary btn-sm" type="button">Back to Deal Scorecard</button>
1057
+ </div>
1058
+ </div>
1059
+ </div>
1060
 
1061
+ <!-- Coaching Roadmap Overlay -->
1062
+ <div id="path80-overlay" class="path80-overlay conversation-modal" hidden aria-modal="true" role="dialog" aria-label="Path to 80+">
1063
+ <div class="path80-panel glass results-modal-panel">
1064
+ <div class="path80-header">
1065
+ <div>
1066
+ <p class="results-eyebrow path80-eyebrow">Coaching Roadmap</p>
1067
+ <h2 class="path80-title">Path to 80+</h2>
1068
+ <p class="path80-subtitle">What stopped this pitch from becoming investor-ready.</p>
1069
+ </div>
1070
+ <button id="btn-close-path80" class="btn btn-ghost btn-sm" aria-label="Close">✕ Close</button>
1071
+ </div>
1072
+
1073
+ <div class="path80-score-bar results-score-lift">
1074
+ <div class="path80-score-item">
1075
+ <span class="path80-score-label">Current Score</span>
1076
+ <strong id="p80-current-score" class="path80-score-num">—</strong>
1077
+ </div>
1078
+ <div class="path80-arrow">→</div>
1079
+ <div class="path80-score-item">
1080
+ <span class="path80-score-label">Potential Score</span>
1081
+ <strong id="p80-estimated-score" class="path80-score-num path80-score-green">—</strong>
1082
+ </div>
1083
+ <p id="p80-estimate-reason" class="path80-estimate-reason clamp-text"></p>
1084
+ </div>
1085
 
1086
+ <div class="path80-sections insight-grid path80-sections-compact">
1087
+ <div class="path80-card coaching-card path80-card-compact">
1088
+ <h3 class="path80-card-title">Why You Scored This</h3>
1089
+ <p id="p80-why-scored" class="path80-card-body clamp-text clamp-short"></p>
1090
+ </div>
1091
+
1092
+ <div class="path80-card path80-card-alert coaching-card path80-card-compact">
1093
+ <h3 class="path80-card-title">What Stopped 80+</h3>
1094
+ <p id="p80-what-stopped" class="path80-card-body clamp-text clamp-short"></p>
1095
+ </div>
1096
+
1097
+ <div class="path80-card path80-card-retry coaching-card path80-card-retry-compact">
1098
+ <h3 class="path80-card-title">One Answer to Retry</h3>
1099
+ <div class="path80-retry-meta">
1100
+ <span id="p80-retry-dim" class="path80-dim-badge source-chip"></span>
1101
+ <span id="p80-retry-round" class="path80-round-tag source-chip"></span>
1102
+ </div>
1103
+ <div class="path80-retry-grid">
1104
+ <div>
1105
+ <p class="path80-answer-label">Original Answer</p>
1106
+ <blockquote id="p80-original-answer" class="path80-quote path80-quote-weak quote-chip clamp-text clamp-short"></blockquote>
1107
+ </div>
1108
+ <div>
1109
+ <p class="path80-answer-label">How to Fix It</p>
1110
+ <p id="p80-retry-advice" class="path80-card-body clamp-text clamp-short"></p>
1111
+ </div>
1112
+ </div>
1113
+ <details class="path80-sample-details">
1114
+ <summary>Sample stronger answer</summary>
1115
+ <blockquote id="p80-sample-answer" class="path80-quote path80-quote-strong quote-chip"></blockquote>
1116
+ </details>
1117
+ <p id="p80-why-it-hurt" class="path80-card-body path80-muted clamp-text clamp-short path80-why-hurt"></p>
1118
+ </div>
1119
+ </div>
1120
+
1121
+ <div class="path80-footer">
1122
+ <button id="btn-retry-question" class="btn btn-retry-start" type="button">Retry This Question</button>
1123
+ <button id="btn-close-path80-bottom" class="btn btn-secondary" type="button">Close</button>
1124
+ </div>
1125
+ </div>
1126
+ </div>
1127
+
1128
+ <!-- Retry Weakest Question Drill -->
1129
+ <div id="retry-overlay" class="retry-overlay conversation-modal" hidden aria-modal="true" role="dialog" aria-label="Retry Weakest Question">
1130
+ <div class="retry-panel glass results-modal-panel retry-drill-panel">
1131
+ <div class="retry-header">
1132
+ <div>
1133
+ <p class="results-eyebrow">Training Drill</p>
1134
+ <h2 class="retry-title">Rematch Drill</h2>
1135
+ <p class="retry-subtitle">Practice the answer that held your score back.</p>
1136
+ </div>
1137
+ <button id="btn-close-retry" class="btn btn-ghost btn-sm" aria-label="Close">✕ Close</button>
1138
+ </div>
1139
+
1140
+ <div id="retry-drill-view" class="retry-drill-view">
1141
+ <div class="retry-cards insight-grid">
1142
+ <article class="retry-card coaching-card">
1143
+ <h3>Original Question</h3>
1144
+ <p id="retry-original-question" class="retry-card-body clamp-text"></p>
1145
  </article>
1146
+ <article class="retry-card retry-card-weak coaching-card">
1147
+ <h3>Your Previous Answer</h3>
1148
+ <blockquote id="retry-original-answer" class="retry-quote quote-chip clamp-text"></blockquote>
1149
  </article>
1150
+ <article class="retry-card coaching-card">
1151
+ <h3>Why It Hurt</h3>
1152
+ <p id="retry-why-hurt" class="retry-card-body clamp-text"></p>
1153
  </article>
1154
+ <article class="retry-card retry-card-tip coaching-card">
1155
+ <h3>Sample Stronger Direction</h3>
1156
+ <blockquote id="retry-sample" class="retry-quote retry-quote-strong quote-chip clamp-text"></blockquote>
1157
  </article>
1158
  </div>
1159
 
1160
+ <div class="retry-answer-area coaching-card">
1161
+ <label class="retry-input-label" for="retry-answer-input">Your retry answer</label>
1162
+ <div class="retry-input-row">
1163
+ <button id="btn-retry-voice-record" class="voice-mic-btn voice-mic-btn-sm" type="button" aria-label="Record retry answer">
1164
+ <span class="mic-ring"></span>
1165
+ <span class="mic-icon">🎙</span>
1166
+ </button>
1167
+ <span id="retry-voice-timer" class="voice-timer voice-timer-inline">0:00</span>
1168
+ <span id="retry-voice-status" class="voice-status"></span>
1169
+ </div>
1170
+ <textarea id="retry-answer-input" rows="3" placeholder="Give a stronger answer with a specific fact, number, or example…"></textarea>
1171
+ <div id="retry-voice-preview" class="retry-voice-preview" hidden>
1172
+ <p class="retry-input-label">Voice transcript — edit before submitting</p>
1173
+ <textarea id="retry-voice-transcript" rows="2"></textarea>
1174
+ </div>
1175
+ <button id="btn-submit-retry" class="btn btn-retry-submit" type="button">Submit Retry Answer</button>
1176
  </div>
1177
+ </div>
1178
 
1179
+ <div id="retry-result-view" class="retry-result-view" hidden>
1180
+ <h3 class="retry-result-title">Rematch Drill Result</h3>
1181
+ <div class="retry-vs-grid">
1182
+ <article class="retry-card retry-card-weak coaching-card">
1183
+ <h4>Old Answer</h4>
1184
+ <p id="retry-result-old" class="retry-card-body clamp-text"></p>
1185
+ </article>
1186
+ <article class="retry-card retry-card-strong coaching-card">
1187
+ <h4>New Answer</h4>
1188
+ <p id="retry-result-new" class="retry-card-body clamp-text"></p>
1189
+ </article>
1190
+ </div>
1191
+ <div class="retry-feedback-grid insight-grid">
1192
+ <article class="retry-card coaching-card"><h4>What improved</h4><p id="retry-what-improved" class="clamp-text"></p></article>
1193
+ <article class="retry-card coaching-card"><h4>Still missing</h4><p id="retry-still-missing" class="clamp-text"></p></article>
1194
+ <article class="retry-card coaching-card"><h4>Specific tip</h4><p id="retry-specific-tip" class="clamp-text"></p></article>
1195
+ </div>
1196
+ <div class="retry-estimate-bar results-score-lift">
1197
+ <span id="retry-dim-estimate" class="retry-dim-estimate"></span>
1198
+ <span id="retry-overall-lift" class="retry-overall-lift"></span>
1199
+ <span id="retry-verdict-badge" class="retry-verdict-badge result-verdict-badge"></span>
1200
+ </div>
1201
+ <p id="retry-next-prompt" class="retry-next-prompt clamp-text"></p>
1202
+ <div class="retry-result-actions scorecard-actions">
1203
+ <button id="btn-retry-again" class="btn btn-retry-start" type="button">Retry Again</button>
1204
+ <button id="btn-retry-back-scorecard" class="btn btn-secondary" type="button">Back to Scorecard</button>
1205
+ <button id="btn-retry-new-battle" class="btn btn-primary" type="button">New Battle</button>
1206
  </div>
1207
  </div>
1208
+ </div>
1209
+ </div>
1210
 
1211
+ <div id="loading-overlay" class="loading-overlay arena-loading" hidden>
1212
+ <div class="arena-loading-panel">
1213
+ <div class="spinner"></div>
1214
+ <div class="arena-loading-scan" aria-hidden="true"></div>
1215
+ <p id="loading-message">Loading…</p>
1216
+ </div>
1217
  </div>
1218
 
1219
  <script type="module" src="/frontend/script.js"></script>
1220
+ <!-- voice.js imported by script.js -->
1221
  </body>
1222
  </html>
frontend/script.js CHANGED
@@ -1,21 +1,56 @@
 
 
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
 
@@ -23,6 +58,93 @@ 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...") {
@@ -30,7 +152,36 @@ function setGlobalLoading(isLoading, message = "Loading...") {
30
  loadingOverlay.hidden = !isLoading;
31
  }
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  function showErrorBanner(message) {
 
 
 
 
34
  errorBanner.textContent = message;
35
  errorBanner.hidden = false;
36
  }
@@ -52,14 +203,402 @@ function fillStartupForm(startup) {
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) {
@@ -70,18 +609,95 @@ function escapeHtml(text) {
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) {
@@ -107,7 +723,7 @@ async function apiPost(path, body = undefined) {
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");
@@ -122,19 +738,39 @@ export async function loadSample() {
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) {
@@ -144,12 +780,20 @@ export async function startSession() {
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) {
@@ -160,19 +804,28 @@ export async function startSession() {
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;
@@ -181,15 +834,24 @@ export async function sendMessage() {
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;
@@ -203,7 +865,7 @@ 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
  });
@@ -235,14 +897,142 @@ export async function resetBattle() {
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) {
@@ -250,40 +1040,77 @@ function renderScorecard(data) {
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 ?? {};
@@ -294,52 +1121,919 @@ function renderScorecard(data) {
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"));
@@ -348,15 +2042,106 @@ document.querySelectorAll(".persona-card").forEach((card) => {
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())
 
1
+ import { initVoiceUI } from "./voice.js";
2
+
3
  const state = {
4
  sessionId: null,
5
  persona: "hackathon_judge",
6
+ difficultyProfile: "practice",
7
  round: 1,
8
+ scoreExplanation: null,
9
+ startMode: "text",
10
+ pendingVoicePitch: null,
11
+ pendingVoiceTurn: null,
12
+ retryDrill: null,
13
+ pendingRetryVoiceTurn: null,
14
+ judgeVerdict: null,
15
+ dealContext: null,
16
+ dealRound: 1,
17
+ uiMode: "pitch",
18
+ pendingDealVoiceTurn: null,
19
+ negotiationTranscript: [],
20
+ conversationLog: [],
21
+ dealConversationLog: [],
22
+ battleLog: [],
23
+ dealBattleLog: [],
24
+ battleMetaSnapshot: null,
25
+ scorecardSnapshot: null,
26
  };
27
 
28
+ let liveJudgeTurn = { text: "", meta: "" };
29
+ let liveFounderReply = "";
30
+ let liveDealJudgeTurn = { text: "", meta: "" };
31
+ let liveDealFounderReply = "";
32
+
33
  const screens = {
34
  landing: document.getElementById("screen-landing"),
35
+ startMethod: document.getElementById("screen-start-method"),
36
+ voicePitch: document.getElementById("screen-voice-pitch"),
37
+ voiceConfirm: document.getElementById("screen-voice-confirm"),
38
  setup: document.getElementById("screen-setup"),
39
  battle: document.getElementById("screen-battle"),
40
  scorecard: document.getElementById("screen-scorecard"),
41
+ deal: document.getElementById("screen-deal"),
42
+ dealScorecard: document.getElementById("screen-deal-scorecard"),
43
  };
44
 
45
+ const dealChatWindow = document.getElementById("deal-chat-window");
46
+ const dealInput = document.getElementById("deal-input");
47
+ const dealStatus = document.getElementById("deal-status");
48
+
49
  const startupForm = document.getElementById("startup-form");
50
  const chatWindow = document.getElementById("chat-window");
51
  const userInput = document.getElementById("user-input");
52
  const loadingOverlay = document.getElementById("loading-overlay");
53
+ const loadingText = document.getElementById("loading-message");
54
  const battleStatus = document.getElementById("battle-status");
55
  const errorBanner = document.getElementById("error-banner");
56
 
 
58
  Object.entries(screens).forEach(([key, el]) => {
59
  el.classList.toggle("active", key === name);
60
  });
61
+ if (name === "landing" && landingIntroComplete) {
62
+ finalizeLandingIntroStatic();
63
+ }
64
+ }
65
+
66
+ /* ---- Landing typewriter intro (Pass 1 — isolated, no API impact) ---- */
67
+
68
+ let landingIntroComplete = false;
69
+ let landingIntroRunning = false;
70
+
71
+ function prefersReducedMotion() {
72
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
73
+ }
74
+
75
+ function finalizeLandingIntroStatic() {
76
+ const line1Text = document.querySelector("#landing-type-line-1 .arena-type-text");
77
+ const line2Text = document.querySelector("#landing-type-line-2 .arena-type-text");
78
+ const line1 = document.getElementById("landing-type-line-1");
79
+ const line2 = document.getElementById("landing-type-line-2");
80
+ const landing = document.querySelector(".arena-landing");
81
+
82
+ if (line1Text?.dataset.text) line1Text.textContent = line1Text.dataset.text;
83
+ if (line2Text?.dataset.text) line2Text.textContent = line2Text.dataset.text;
84
+ line1?.classList.add("done");
85
+ line2?.classList.add("done");
86
+ document.getElementById("landing-typewriter")?.classList.add("intro-complete");
87
+ landing?.classList.add("intro-complete");
88
+ document.getElementById("landing-cta-row")?.classList.add("visible");
89
+ document.getElementById("landing-feature-chips")?.classList.add("visible");
90
+ landingIntroComplete = true;
91
+ }
92
+
93
+ function typeText(el, text, speedMs, onDone) {
94
+ if (!el || !text) {
95
+ onDone?.();
96
+ return;
97
+ }
98
+ el.textContent = "";
99
+ let i = 0;
100
+ const step = () => {
101
+ if (i < text.length) {
102
+ el.textContent += text.charAt(i);
103
+ i += 1;
104
+ setTimeout(step, speedMs);
105
+ } else {
106
+ onDone?.();
107
+ }
108
+ };
109
+ step();
110
+ }
111
+
112
+ function initLandingIntro() {
113
+ if (landingIntroRunning || landingIntroComplete) return;
114
+ landingIntroRunning = true;
115
+
116
+ const line1Text = document.querySelector("#landing-type-line-1 .arena-type-text");
117
+ const line2Text = document.querySelector("#landing-type-line-2 .arena-type-text");
118
+ const line1 = document.getElementById("landing-type-line-1");
119
+ const line2 = document.getElementById("landing-type-line-2");
120
+
121
+ if (!line1Text || !line2Text) {
122
+ landingIntroRunning = false;
123
+ return;
124
+ }
125
+
126
+ if (prefersReducedMotion()) {
127
+ finalizeLandingIntroStatic();
128
+ landingIntroRunning = false;
129
+ return;
130
+ }
131
+
132
+ const text1 = line1Text.dataset.text || "";
133
+ const text2 = line2Text.dataset.text || "";
134
+
135
+ typeText(line1Text, text1, 32, () => {
136
+ line1?.classList.add("done");
137
+ line2?.classList.add("active");
138
+ setTimeout(() => {
139
+ typeText(line2Text, text2, 28, () => {
140
+ line2?.classList.add("done");
141
+ setTimeout(() => {
142
+ finalizeLandingIntroStatic();
143
+ landingIntroRunning = false;
144
+ }, 180);
145
+ });
146
+ }, 220);
147
+ });
148
  }
149
 
150
  function setGlobalLoading(isLoading, message = "Loading...") {
 
152
  loadingOverlay.hidden = !isLoading;
153
  }
154
 
155
+ const NEMOTRON_SCORECARD_SOURCES = new Set(["nemotron_full", "nemotron", "nemotron_repaired"]);
156
+
157
+ function isNemotronScorecardSource(src) {
158
+ return NEMOTRON_SCORECARD_SOURCES.has(String(src ?? "").trim());
159
+ }
160
+
161
+ const INTERNAL_ERROR_PATTERNS = [
162
+ /nvidia/i,
163
+ /max_tokens/i,
164
+ /reasoning model/i,
165
+ /empty response/i,
166
+ /mock fallback/i,
167
+ /model scoring fallback/i,
168
+ /local fallback/i,
169
+ /server logs/i,
170
+ /nemotron omni/i,
171
+ /api_key/i,
172
+ ];
173
+
174
+ function isInternalErrorMessage(message) {
175
+ const text = String(message ?? "").trim();
176
+ if (!text) return true;
177
+ return INTERNAL_ERROR_PATTERNS.some((pattern) => pattern.test(text));
178
+ }
179
+
180
  function showErrorBanner(message) {
181
+ if (isInternalErrorMessage(message)) {
182
+ console.warn("[PitchFight] suppressed internal error from UI:", message);
183
+ return;
184
+ }
185
  errorBanner.textContent = message;
186
  errorBanner.hidden = false;
187
  }
 
203
  });
204
  }
205
 
206
+ const PERSONA_LABELS = {
207
+ skeptical_vc: "Skeptical VC",
208
+ technical_judge: "Technical Judge",
209
+ hackathon_judge: "Hackathon Judge",
210
+ };
211
+
212
+ function pressureMeterLevel(data) {
213
+ const label = String(data.pressure_label ?? data.pressure_level ?? "").toLowerCase();
214
+ if (label.includes("extreme")) return { pct: 100, tier: "extreme" };
215
+ if (label.includes("high")) return { pct: 78, tier: "high" };
216
+ if (label.includes("focused") || label.includes("medium")) return { pct: 52, tier: "focused" };
217
+ if (label.includes("warm")) return { pct: 28, tier: "warmup" };
218
+ const phase = String(data.battle_phase ?? "").toLowerCase();
219
+ if (phase.includes("extreme") || phase.includes("pressure")) return { pct: 78, tier: "high" };
220
+ if (phase.includes("challenge")) return { pct: 52, tier: "focused" };
221
+ const round = Number(data.round ?? state.round ?? 1);
222
+ if (round >= 4) return { pct: 100, tier: "extreme" };
223
+ if (round === 3) return { pct: 78, tier: "high" };
224
+ if (round === 2) return { pct: 52, tier: "focused" };
225
+ return { pct: 28, tier: "warmup" };
226
+ }
227
+
228
+ const ATTACK_PROGRESSION = [
229
+ "User Pain",
230
+ "Novelty",
231
+ "MVP Strength",
232
+ "Business Model",
233
+ "Objection Handling",
234
+ ];
235
+
236
+ function normalizeAttackKey(tag) {
237
+ return String(tag || "").trim().toLowerCase();
238
+ }
239
+
240
+ function updateProgressStrip(round, attack, mode = "battle") {
241
+ const roundStrip = document.getElementById(
242
+ mode === "deal" ? "deal-progress-rounds" : "battle-progress-rounds",
243
+ );
244
+ if (roundStrip) {
245
+ roundStrip.querySelectorAll(".progress-node").forEach((node) => {
246
+ const n = Number(node.dataset.round);
247
+ node.classList.remove("progress-node-active", "progress-node-done");
248
+ if (n < round) node.classList.add("progress-node-done");
249
+ else if (n === round) node.classList.add("progress-node-active");
250
+ });
251
+ }
252
+
253
+ if (mode !== "battle") return;
254
+ const attackStrip = document.getElementById("battle-progress-attacks");
255
+ if (!attackStrip) return;
256
+ const key = normalizeAttackKey(attack);
257
+ let activeIdx = ATTACK_PROGRESSION.findIndex((a) => {
258
+ const ak = normalizeAttackKey(a);
259
+ return key === ak || key.includes(ak.split(" ")[0]) || ak.includes(key);
260
+ });
261
+ if (activeIdx < 0 && key) activeIdx = Math.min(round - 1, ATTACK_PROGRESSION.length - 1);
262
+
263
+ attackStrip.querySelectorAll(".progress-attack-tag").forEach((tag, idx) => {
264
+ tag.classList.remove("progress-attack-active", "progress-attack-done");
265
+ if (activeIdx >= 0 && idx < activeIdx) tag.classList.add("progress-attack-done");
266
+ if (activeIdx >= 0 && idx === activeIdx) tag.classList.add("progress-attack-active");
267
+ });
268
+ }
269
+
270
+ function addBattleLogEntry(roundNum, judge, founderReply, mode = "battle") {
271
+ const log = mode === "deal" ? state.dealBattleLog : state.battleLog;
272
+ const entry = { roundNum, judge: { ...judge }, founderReply };
273
+ log.push(entry);
274
+
275
+ const ribbonId = mode === "deal" ? "deal-log-ribbon" : "battle-log-ribbon";
276
+ const tabsId = mode === "deal" ? "deal-log-tabs" : "battle-log-tabs";
277
+ const detailId = mode === "deal" ? "deal-log-detail" : "battle-log-detail";
278
+ const ribbon = document.getElementById(ribbonId);
279
+ const tabs = document.getElementById(tabsId);
280
+ if (!ribbon || !tabs) return;
281
+ /* Ribbon stays hidden — log only opens via drawer */
282
+
283
+ const btn = document.createElement("button");
284
+ btn.type = "button";
285
+ btn.className = "battle-log-tab";
286
+ btn.textContent = mode === "deal" ? `D${roundNum}` : `R${roundNum}`;
287
+ btn.dataset.round = roundNum;
288
+ btn.addEventListener("click", () => {
289
+ tabs.querySelectorAll(".battle-log-tab").forEach((t) => t.classList.remove("active"));
290
+ btn.classList.add("active");
291
+ const detail = document.getElementById(detailId);
292
+ if (!detail) return;
293
+ detail.hidden = false;
294
+ detail.innerHTML = `
295
+ <p class="log-detail-attack">${escapeHtml(simplifyJudgeMeta(judge.meta) || "—")}</p>
296
+ <p class="log-detail-label">Judge</p>
297
+ <p class="log-detail-text">${escapeHtml(judge.text)}</p>
298
+ ${founderReply ? `<p class="log-detail-label">You</p><p class="log-detail-text">${escapeHtml(founderReply)}</p>` : ""}`;
299
+ });
300
+ tabs.appendChild(btn);
301
+ }
302
+
303
+ function updateJudgeAttackPill(attack, mode = "battle") {
304
+ const pill = document.getElementById(mode === "deal" ? "deal-judge-attack-pill" : "judge-attack-pill");
305
+ if (pill) pill.textContent = attack && attack !== "—" ? attack : "Pressing";
306
+ }
307
+
308
+ function simplifyJudgeMeta(meta) {
309
+ if (!meta) return "";
310
+ const attack = String(meta).split("·")[0]?.trim();
311
+ return attack || meta;
312
+ }
313
+
314
+ function updatePressureCore(tier, label) {
315
+ const ring = document.querySelector(".duel-node-ring");
316
+ if (ring) {
317
+ ring.className = `duel-node-ring pressure-core-${tier}`;
318
+ }
319
+ const lbl = document.getElementById("duel-pressure-label");
320
+ if (lbl && label) lbl.textContent = label;
321
+ const judgePressure = document.getElementById("judge-stat-pressure");
322
+ if (judgePressure && label) judgePressure.textContent = label;
323
+ }
324
+
325
+ function updateConfidenceMeter(pressurePct) {
326
+ const fill = document.getElementById("confidence-meter-fill");
327
+ if (!fill) return;
328
+ const confidence = Math.max(8, Math.min(100, 100 - pressurePct));
329
+ fill.style.width = `${confidence}%`;
330
+ fill.classList.toggle("confidence-low", confidence < 35);
331
+ fill.classList.toggle("confidence-mid", confidence >= 35 && confidence < 65);
332
+ fill.classList.toggle("confidence-high", confidence >= 65);
333
+ }
334
+
335
+ function updateComboMeter(round) {
336
+ const meter = document.getElementById("combo-meter");
337
+ if (!meter) return;
338
+ const streak = Math.max(0, Math.min(5, Number(round) - 1));
339
+ meter.querySelectorAll(".combo-pip").forEach((pip) => {
340
+ const n = Number(pip.dataset.pip);
341
+ pip.classList.toggle("combo-pip-lit", n <= streak);
342
+ });
343
+ }
344
+
345
+ function updateJudgeSignalChips(attack, pressureLabel) {
346
+ const focus = document.getElementById("judge-stat-focus");
347
+ if (focus) focus.textContent = attack && attack !== "—" ? attack : "Scanning";
348
+ const pressure = document.getElementById("judge-stat-pressure");
349
+ if (pressure && pressureLabel) pressure.textContent = pressureLabel;
350
+ const judgeName = document.getElementById("judge-fighter-name");
351
+ const sidebarName = document.getElementById("sidebar-persona-name");
352
+ const persona = PERSONA_LABELS[state.persona] ?? "AI Judge";
353
+ if (judgeName) judgeName.textContent = persona;
354
+ if (sidebarName) sidebarName.textContent = persona;
355
+ }
356
+
357
+ function updateJudgeLiveCard(text, meta) {
358
+ const q = document.getElementById("judge-question-text");
359
+ const metaEl = document.getElementById("judge-question-meta");
360
+ const card = document.getElementById("judge-live-card");
361
+ const attack = simplifyJudgeMeta(meta);
362
+ if (q) q.textContent = text || "AI judge is preparing the next attack…";
363
+ if (metaEl) metaEl.textContent = attack;
364
+ updateJudgeAttackPill(attack, "battle");
365
+ const pressureLbl = document.getElementById("judge-stat-pressure")?.textContent;
366
+ updateJudgeSignalChips(attack !== "—" ? attack : simplifyJudgeMeta(meta), pressureLbl);
367
+ if (card) {
368
+ card.classList.remove("judge-attack-enter");
369
+ void card.offsetWidth;
370
+ card.classList.add("judge-attack-enter");
371
+ }
372
+ }
373
+
374
+ function updateDealJudgeCard(text, meta) {
375
+ const q = document.getElementById("deal-judge-text");
376
+ const metaEl = document.getElementById("deal-judge-meta");
377
+ const card = document.getElementById("deal-judge-card");
378
+ if (q) q.textContent = text || "Preparing deal terms…";
379
+ if (metaEl) metaEl.textContent = simplifyJudgeMeta(meta);
380
+ const attack = simplifyJudgeMeta(meta);
381
+ updateJudgeAttackPill(attack !== "—" ? attack : "Terms", "deal");
382
+ if (card) {
383
+ card.classList.remove("judge-attack-enter");
384
+ void card.offsetWidth;
385
+ card.classList.add("judge-attack-enter");
386
+ }
387
+ }
388
+
389
+ function refreshCoachBar() {
390
+ const dockHint = document.getElementById("dock-assist-hint");
391
+ const coach = (document.getElementById("micro-coach")?.textContent || "").trim();
392
+ const hintRaw = (document.getElementById("answer-hint")?.textContent || "").trim();
393
+
394
+ if (dockHint) {
395
+ if (coach) {
396
+ dockHint.textContent = coach;
397
+ } else if (hintRaw) {
398
+ dockHint.textContent = `Tip: ${hintRaw}`;
399
+ } else {
400
+ dockHint.textContent = "Tip: use numbers";
401
+ }
402
+ }
403
+
404
+ const bar = document.getElementById("battle-coach-bar");
405
+ if (bar) {
406
+ bar.hidden = true;
407
+ bar.textContent = "";
408
+ }
409
+ }
410
+
411
+ function extractRoundFromMeta(meta) {
412
+ const match = String(meta || "").match(/Round\s+(\d+)/i);
413
+ return match ? match[1] : null;
414
+ }
415
+
416
+ function archiveBattleRound(_container, judge, founderReply, mode = "battle") {
417
+ if (!judge.text) return;
418
+ const roundNum = extractRoundFromMeta(judge.meta) ?? Math.max(1, (state.round || 1) - 1);
419
+ addBattleLogEntry(roundNum, judge, founderReply, mode);
420
+ updateTimelineVisibility(mode, mode === "deal" ? "deal-timeline-count" : "timeline-count");
421
+ }
422
+
423
+ function updateTimelineVisibility(mode = "battle", countId) {
424
+ const count = mode === "deal"
425
+ ? (state.dealBattleLog?.length ?? 0)
426
+ : (state.battleLog?.length ?? 0);
427
+ const countEl = document.getElementById(countId);
428
+ if (countEl) countEl.textContent = count ? `(${count})` : "";
429
+ const toggleId = mode === "deal" ? "btn-open-deal-rounds" : "btn-open-battle-rounds";
430
+ const toggle = document.getElementById(toggleId);
431
+ if (toggle) toggle.hidden = count === 0;
432
+ }
433
+
434
+ const CONV_FOUNDER_AVATAR = `<svg class="conv-avatar-svg conv-avatar-founder" viewBox="0 0 64 88" aria-hidden="true"><circle cx="32" cy="24" r="11" fill="#3d2810" stroke="#c084fc" stroke-width="1"/><path d="M18 38 Q32 30 46 38 L48 62 Q32 68 16 62 Z" fill="#1a1208" stroke="#c084fc" stroke-width="1"/></svg>`;
435
+ const CONV_JUDGE_AVATAR = `<svg class="conv-avatar-svg conv-avatar-judge" viewBox="0 0 64 88" aria-hidden="true"><rect x="20" y="18" width="24" height="20" rx="4" fill="#060a10" stroke="#22d3ee" stroke-width="1"/><path d="M16 40 Q32 32 48 40 L50 64 Q32 70 14 64 Z" fill="#0a1420" stroke="#22d3ee" stroke-width="1"/></svg>`;
436
+
437
+ function renderConversationMessage(role, text, meta = "") {
438
+ const isJudge = role === "ai";
439
+ const speaker = isJudge ? (PERSONA_LABELS[state.persona] ?? "AI Judge") : "You · Founder";
440
+ const row = document.createElement("div");
441
+ row.className = `conv-message ${isJudge ? "conv-message-judge" : "conv-message-founder"}`;
442
+ row.innerHTML = `
443
+ <div class="conv-avatar ${isJudge ? "conv-avatar-judge-wrap" : "conv-avatar-founder-wrap"}">
444
+ ${isJudge ? CONV_JUDGE_AVATAR : CONV_FOUNDER_AVATAR}
445
+ </div>
446
+ <div class="conv-bubble">
447
+ <div class="conv-bubble-head">
448
+ <span class="conv-speaker">${escapeHtml(speaker)}</span>
449
+ ${meta ? `<span class="conv-meta">${escapeHtml(meta)}</span>` : ""}
450
+ </div>
451
+ <p class="conv-text">${escapeHtml(text)}</p>
452
+ </div>`;
453
+ return row;
454
+ }
455
+
456
+ function renderConversationThread(log, container) {
457
+ if (!container) return;
458
+ container.innerHTML = "";
459
+ (log || []).forEach(({ role, text, meta }) => {
460
+ container.appendChild(renderConversationMessage(role, text, meta));
461
+ });
462
+ container.scrollTop = container.scrollHeight;
463
+ }
464
+
465
+ function renderConversationSidebar(context = "battle") {
466
+ const sidebar = document.getElementById("conversation-sidebar");
467
+ if (!sidebar) return;
468
+
469
+ const meta = state.battleMetaSnapshot ?? {};
470
+ const score = state.scorecardSnapshot;
471
+ const persona = PERSONA_LABELS[meta.persona ?? state.persona] ?? "AI Judge";
472
+ const rounds = state.battleLog?.length ?? Math.max(0, Math.floor((state.conversationLog?.length ?? 0) / 2));
473
+ const attacks = ATTACK_PROGRESSION.map((label) => {
474
+ const done = state.battleLog?.some((e) => simplifyJudgeMeta(e.judge?.meta) === label);
475
+ const active = meta.attack === label;
476
+ return `<li class="${done ? "sidebar-attack-done" : ""} ${active ? "sidebar-attack-active" : ""}">${escapeHtml(label)}</li>`;
477
+ }).join("");
478
+
479
+ sidebar.innerHTML = `
480
+ <p class="sidebar-eyebrow hud-font">Battle Details</p>
481
+ <dl class="sidebar-stats">
482
+ <div><dt>Rounds</dt><dd>${rounds || meta.round || "—"}</dd></div>
483
+ <div><dt>Opponent</dt><dd>${escapeHtml(persona)}</dd></div>
484
+ <div><dt>Mode</dt><dd>${escapeHtml(meta.mode ?? "Practice")}</dd></div>
485
+ <div><dt>Last Attack</dt><dd>${escapeHtml(meta.attack ?? "—")}</dd></div>
486
+ <div><dt>Pressure</dt><dd>${escapeHtml(meta.pressure ?? "—")}</dd></div>
487
+ </dl>
488
+ ${context === "scorecard" && score ? `
489
+ <p class="sidebar-eyebrow hud-font sidebar-eyebrow-score">Scorecard</p>
490
+ <dl class="sidebar-stats sidebar-score-block">
491
+ <div><dt>Score</dt><dd class="sidebar-score-val">${score.overall ?? "—"}</dd></div>
492
+ <div><dt>Label</dt><dd>${escapeHtml(score.overallLabel ?? "—")}</dd></div>
493
+ <div><dt>Strongest</dt><dd class="sidebar-strong">${escapeHtml(score.strongest ?? "—")}</dd></div>
494
+ <div><dt>Weakest</dt><dd class="sidebar-weak">${escapeHtml(score.weakest ?? "—")}</dd></div>
495
+ </dl>` : ""}
496
+ <p class="sidebar-eyebrow hud-font">Attack Progression</p>
497
+ <ul class="sidebar-attack-list">${attacks}</ul>`;
498
+ }
499
+
500
+ function openBattleConversationLog(fromScorecard = false) {
501
+ renderConversationSidebar(fromScorecard ? "scorecard" : "battle");
502
+ renderConversationThread(state.conversationLog, chatWindow);
503
+ openRoundsDrawer("battle-rounds-drawer");
504
+ document.getElementById("conversation-sidebar")?.scrollTo(0, 0);
505
+ chatWindow?.scrollTo(0, chatWindow.scrollHeight);
506
+ }
507
+
508
+ function openRoundsDrawer(drawerId) {
509
+ const drawer = document.getElementById(drawerId);
510
+ if (!drawer) return;
511
+ drawer.hidden = false;
512
+ document.body.classList.add("rounds-drawer-open");
513
+ }
514
+
515
+ function closeRoundsDrawer(drawerId) {
516
+ const drawer = document.getElementById(drawerId);
517
+ if (!drawer) return;
518
+ drawer.hidden = true;
519
+ if (!document.querySelector(".previous-rounds-drawer:not([hidden])")) {
520
+ document.body.classList.remove("rounds-drawer-open");
521
+ }
522
+ }
523
+
524
+ function appendFounderBubble(container, text, meta = "", extraClass = "") {
525
  const bubble = document.createElement("div");
526
+ bubble.className = `message message-founder user${extraClass ? ` ${extraClass}` : ""}`;
527
+ const preview = text.length > 160 ? `${text.slice(0, 157)}…` : text;
528
  bubble.innerHTML = meta
529
+ ? `<span class="message-meta">${escapeHtml(meta)}</span><p class="message-preview">${escapeHtml(preview)}</p>`
530
+ : `<p class="message-preview">${escapeHtml(preview)}</p>`;
531
+ bubble.title = text;
532
+ container.appendChild(bubble);
533
+ container.scrollTop = container.scrollHeight;
534
+ }
535
+
536
+ function rebuildConversationView() {
537
+ openBattleConversationLog(true);
538
+ }
539
+
540
+ function resetLiveTurnTracking() {
541
+ liveJudgeTurn = { text: "", meta: "" };
542
+ liveFounderReply = "";
543
+ liveDealJudgeTurn = { text: "", meta: "" };
544
+ liveDealFounderReply = "";
545
+ }
546
+
547
+ function appendMessage(role, text, meta = "") {
548
+ state.conversationLog.push({ role, text, meta });
549
+
550
+ if (role === "ai") {
551
+ if (liveJudgeTurn.text) {
552
+ archiveBattleRound(null, liveJudgeTurn, liveFounderReply, "battle");
553
+ liveFounderReply = "";
554
+ }
555
+ liveJudgeTurn = { text, meta };
556
+ updateJudgeLiveCard(text, meta);
557
+ updateTimelineVisibility("battle", "timeline-count");
558
+ return;
559
+ }
560
+
561
+ liveFounderReply = text;
562
+ updateTimelineVisibility("battle", "timeline-count");
563
+ }
564
+
565
+ function appendDealMessage(role, text, meta = "") {
566
+ if (!dealChatWindow) return;
567
+ state.dealConversationLog.push({ role, text, meta });
568
+
569
+ if (role === "ai") {
570
+ dealChatWindow?.querySelector(".live-pending")?.remove();
571
+ if (liveDealJudgeTurn.text) {
572
+ archiveBattleRound(null, liveDealJudgeTurn, liveDealFounderReply, "deal");
573
+ liveDealFounderReply = "";
574
+ }
575
+ liveDealJudgeTurn = { text, meta };
576
+ updateDealJudgeCard(text, meta);
577
+ updateTimelineVisibility("deal", "deal-timeline-count");
578
+ return;
579
+ }
580
+
581
+ liveDealFounderReply = text;
582
+ dealChatWindow?.querySelector(".live-pending")?.remove();
583
+ updateTimelineVisibility("deal", "deal-timeline-count");
584
+ }
585
+
586
+ // "Minimum viable answer" recipe shown above the input for the current question.
587
+ function setAnswerHint(hint) {
588
+ const el = document.getElementById("answer-hint");
589
+ if (!el) return;
590
+ el.textContent = (hint || "").trim();
591
+ el.hidden = true;
592
+ refreshCoachBar();
593
+ }
594
+
595
+ // Gentle after-round nudge — shown only in the response dock hint line.
596
+ function setMicroCoach(tip) {
597
+ const el = document.getElementById("micro-coach");
598
+ if (!el) return;
599
+ el.textContent = (tip || "").trim();
600
+ el.hidden = true;
601
+ refreshCoachBar();
602
  }
603
 
604
  function escapeHtml(text) {
 
609
  }
610
 
611
  function updateBattleMeta(data) {
612
+ const round = data.round ?? state.round ?? 1;
613
+ document.getElementById("round-counter").textContent = round;
614
+ const roundDisplay = document.getElementById("round-display");
615
+ if (roundDisplay) roundDisplay.textContent = String(round).padStart(2, "0");
616
+ const roundDuel = document.getElementById("round-counter-duel");
617
+ if (roundDuel) roundDuel.textContent = String(round).padStart(2, "0");
618
+ state.round = round;
619
+
620
+ const pressureDisplay = data.pressure_label ?? data.pressure_level ?? "Warm-up";
621
  const pressureEl = document.getElementById("pressure-level");
622
+ if (pressureEl) {
623
+ pressureEl.textContent = pressureDisplay;
624
+ const { tier } = pressureMeterLevel(data);
625
+ pressureEl.className = `pressure-${tier}`;
626
+ }
627
+
628
+ const { pct, tier } = pressureMeterLevel(data);
629
+ const fill = document.getElementById("pressure-meter-fill");
630
+ if (fill) {
631
+ fill.style.width = `${pct}%`;
632
+ fill.className = `pressure-meter-fill pressure-${tier}`;
633
+ }
634
+ updateConfidenceMeter(pct);
635
+ updateComboMeter(round);
636
+ const progressFill = document.getElementById("battle-progress-fill");
637
+ if (progressFill) {
638
+ progressFill.style.width = `${pct}%`;
639
+ progressFill.className = `battle-progress-fill pressure-${tier}`;
640
+ }
641
+
642
+ const attack = data.attack_tag ?? "—";
643
+ document.getElementById("attack-tag").textContent = attack;
644
+ const attackChip = document.getElementById("attack-tag-chip");
645
+ if (attackChip) attackChip.textContent = attack;
646
+
647
+ const pressureChip = document.getElementById("pressure-chip");
648
+ if (pressureChip) pressureChip.textContent = pressureDisplay;
649
+
650
+ const diffLabel = data.difficulty_label ?? "Practice Mode";
651
+ const diffLabelEl = document.getElementById("difficulty-label");
652
+ if (diffLabelEl) diffLabelEl.textContent = diffLabel;
653
+
654
+ const modeChip = document.getElementById("mode-chip");
655
+ if (modeChip) modeChip.textContent = diffLabel.replace(" Mode", "");
656
 
657
  const phaseEl = document.getElementById("battle-phase");
658
+ if (phaseEl && data.battle_phase) phaseEl.textContent = data.battle_phase;
659
+
660
+ const sidebarMode = document.getElementById("sidebar-persona-mode");
661
+ if (sidebarMode) sidebarMode.textContent = diffLabel;
662
+
663
+ const sidebarPersona = document.getElementById("sidebar-persona-name");
664
+ if (sidebarPersona) {
665
+ sidebarPersona.textContent = PERSONA_LABELS[state.persona] ?? "AI Judge";
666
+ }
667
+
668
+ updateProgressStrip(round, attack, "battle");
669
+ updateJudgeAttackPill(attack, "battle");
670
+ updatePressureCore(tier, pressureDisplay);
671
+ updateJudgeSignalChips(attack, pressureDisplay);
672
+
673
+ state.battleMetaSnapshot = {
674
+ round,
675
+ attack,
676
+ pressure: pressureDisplay,
677
+ mode: diffLabel.replace(" Mode", ""),
678
+ persona: state.persona,
679
+ };
680
+ }
681
+
682
+ function updateBattleReadiness(data) {
683
+ const prompt = document.getElementById("battle-readiness-prompt");
684
+ const text = document.getElementById("battle-readiness-text");
685
+ if (!prompt) return;
686
+ const show = data.soft_round_limit_reached || data.recommended_action === "end_battle";
687
+ if (!show) {
688
+ prompt.hidden = true;
689
+ return;
690
  }
691
+ if (text) {
692
+ text.textContent = data.completion_message
693
+ ?? data.readiness?.reason
694
+ ?? "Enough signal collected. Ready for your scorecard?";
695
+ }
696
+ prompt.hidden = false;
697
+ }
698
+
699
+ function hideBattleReadiness() {
700
+ document.getElementById("battle-readiness-prompt")?.setAttribute("hidden", "");
701
  }
702
 
703
  async function apiPost(path, body = undefined) {
 
723
 
724
  export async function loadSample() {
725
  try {
726
+ setGlobalLoading(true, "Loading demo founder…");
727
  const data = await apiPost("/api/load-sample");
728
  fillStartupForm(data.startup);
729
  showScreen("setup");
 
738
 
739
  export async function startSession() {
740
  try {
741
+ setGlobalLoading(true, "AI judge is preparing the first attack…");
742
  battleStatus.hidden = true;
743
+ hideBattleReadiness();
744
  chatWindow.innerHTML = "";
745
+ state.conversationLog = [];
746
+ state.battleLog = [];
747
+ resetLiveTurnTracking();
748
+ document.getElementById("battle-log-tabs") && (document.getElementById("battle-log-tabs").innerHTML = "");
749
+ document.getElementById("battle-log-detail")?.setAttribute("hidden", "");
750
+ document.getElementById("battle-log-ribbon")?.setAttribute("hidden", "");
751
+ document.getElementById("btn-open-battle-rounds")?.setAttribute("hidden", "");
752
+ document.getElementById("battle-rounds-drawer")?.setAttribute("hidden", "");
753
+ document.getElementById("battle-coach-bar")?.setAttribute("hidden", "");
754
+ document.getElementById("voice-turn-preview")?.setAttribute("hidden", "");
755
 
756
  const payload = {
757
  mode: "pitch_battle",
758
  startup: getStartupPayload(),
759
  persona: state.persona,
760
+ difficulty_profile: state.difficultyProfile,
761
+ difficulty: state.difficultyProfile, // keep for backward compat
762
+ input_mode: state.startMode === "voice" ? "voice" : "text",
763
  model_mode: "premium_nvidia",
764
  };
765
 
766
+ if (state.startMode === "voice" && state.pendingVoicePitch) {
767
+ payload.voice_pitch = {
768
+ transcript: state.pendingVoicePitch.transcript,
769
+ delivery_observations: state.pendingVoicePitch.delivery_observations,
770
+ extraction_confidence: state.pendingVoicePitch.extraction_confidence,
771
+ };
772
+ }
773
+
774
  const data = await apiPost("/api/start-session", payload);
775
 
776
  if (data.error) {
 
780
 
781
  state.sessionId = data.session_id;
782
  state.round = data.round ?? 1;
783
+ state.uiMode = "pitch";
784
  userInput.disabled = false;
785
  const submitBtn = document.getElementById("chat-form").querySelector("button[type=submit]");
786
  if (submitBtn) submitBtn.disabled = false;
787
  updateBattleMeta(data);
788
+ const startMeta = data.model_ok
789
+ ? `${data.attack_tag} · Round ${data.round} · ⚡ Premium Nemotron`
790
+ : `${data.attack_tag} · Round ${data.round}`;
791
+ if (data.model_error) {
792
+ console.warn("Opponent model fallback (not shown to user):", data.model_error);
793
+ }
794
+ appendMessage("ai", data.ai_message, startMeta);
795
+ setMicroCoach("");
796
+ setAnswerHint(data.answer_hint);
797
  showScreen("battle");
798
  hideErrorBanner();
799
  } catch (error) {
 
804
  }
805
  }
806
 
807
+ export async function sendMessage(messageOverride, voiceMeta = null) {
808
+ const message = (messageOverride ?? userInput.value).trim();
809
  if (!message || !state.sessionId) return;
810
 
811
  try {
812
+ setGlobalLoading(true, "Scoring your answer");
813
  userInput.value = "";
814
  appendMessage("user", message);
815
 
816
+ const chatPayload = {
817
  session_id: state.sessionId,
818
  user_message: message,
819
+ };
820
+ if (voiceMeta?.voice_turn_id) {
821
+ chatPayload.input_mode = "voice";
822
+ chatPayload.voice_turn_id = voiceMeta.voice_turn_id;
823
+ if (voiceMeta.delivery_metadata) {
824
+ chatPayload.delivery_metadata = voiceMeta.delivery_metadata;
825
+ }
826
+ }
827
+
828
+ const data = await apiPost("/api/chat-round", chatPayload);
829
 
830
  if (data.error) {
831
  battleStatus.hidden = false;
 
834
  }
835
 
836
  updateBattleMeta(data);
837
+ const chatMeta = data.model_ok
838
+ ? `${data.attack_tag} · Round ${data.round} · ⚡ Premium Nemotron`
839
+ : `${data.attack_tag} · Round ${data.round}`;
840
+ if (data.model_error) {
841
+ console.warn("Opponent model fallback (not shown to user):", data.model_error);
842
+ }
843
+ appendMessage("ai", data.ai_message, chatMeta);
844
+ setMicroCoach(data.micro_coach);
845
+ setAnswerHint(data.answer_hint);
846
 
847
  if (data.soft_round_limit_reached) {
848
+ updateBattleReadiness(data);
849
+ } else {
850
+ hideBattleReadiness();
 
851
  }
852
+
853
+ state.pendingVoiceTurn = null;
854
+ document.getElementById("voice-turn-preview")?.setAttribute("hidden", "");
855
  } catch (error) {
856
  console.error(error);
857
  battleStatus.hidden = false;
 
865
  if (!state.sessionId) return;
866
 
867
  try {
868
+ setGlobalLoading(true, "Building scorecard");
869
  const data = await apiPost("/api/end-battle", {
870
  session_id: state.sessionId,
871
  });
 
897
 
898
  state.sessionId = null;
899
  state.round = 1;
900
+ state.pendingVoicePitch = null;
901
+ state.pendingVoiceTurn = null;
902
+ state.retryDrill = null;
903
+ state.pendingRetryVoiceTurn = null;
904
+ state.judgeVerdict = null;
905
+ state.dealContext = null;
906
+ state.dealRound = 1;
907
+ state.uiMode = "pitch";
908
+ state.pendingDealVoiceTurn = null;
909
+ state.startMode = "text";
910
+ state.conversationLog = [];
911
+ state.dealConversationLog = [];
912
+ state.battleLog = [];
913
+ state.dealBattleLog = [];
914
+ resetLiveTurnTracking();
915
  chatWindow.innerHTML = "";
916
+ if (dealChatWindow) dealChatWindow.innerHTML = "";
917
+ ["battle-log-tabs", "deal-log-tabs"].forEach((id) => {
918
+ const el = document.getElementById(id);
919
+ if (el) el.innerHTML = "";
920
+ });
921
+ document.getElementById("battle-log-ribbon")?.setAttribute("hidden", "");
922
+ document.getElementById("deal-log-ribbon")?.setAttribute("hidden", "");
923
+ document.getElementById("btn-open-battle-rounds")?.setAttribute("hidden", "");
924
+ document.getElementById("battle-rounds-drawer")?.setAttribute("hidden", "");
925
+ document.getElementById("btn-open-deal-rounds")?.setAttribute("hidden", "");
926
+ document.getElementById("deal-rounds-drawer")?.setAttribute("hidden", "");
927
+ document.getElementById("battle-coach-bar")?.setAttribute("hidden", "");
928
+ hideBattleReadiness();
929
+ updateJudgeLiveCard("", "");
930
+ updateDealJudgeCard("", "");
931
  userInput.value = "";
932
  showScreen("landing");
933
  }
934
 
935
+ function formatDimLabel(dim) {
936
+ return String(dim ?? "").replaceAll("_", " ");
937
+ }
938
+
939
+ function scoreBand(score) {
940
+ const s = Number(score) || 0;
941
+ if (s >= 70) return "score-high";
942
+ if (s >= 50) return "score-mid";
943
+ return "score-low";
944
+ }
945
+
946
+ function getStrongestWeakest(scores) {
947
+ const entries = Object.entries(scores || {}).filter(([, v]) => v && typeof v.score === "number");
948
+ if (!entries.length) return { strongest: null, weakest: null };
949
+ const sorted = [...entries].sort((a, b) => (b[1].score ?? 0) - (a[1].score ?? 0));
950
+ return { strongest: sorted[0], weakest: sorted[sorted.length - 1] };
951
+ }
952
+
953
+ function attachShowMore(el) {
954
+ if (!el || !el.textContent?.trim()) return;
955
+ el.classList.add("clamp-text");
956
+ if (el.nextElementSibling?.classList?.contains("show-more-btn")) return;
957
+ requestAnimationFrame(() => {
958
+ if (el.scrollHeight <= el.clientHeight + 2) return;
959
+ const btn = document.createElement("button");
960
+ btn.type = "button";
961
+ btn.className = "show-more-btn";
962
+ btn.textContent = "Show more";
963
+ btn.addEventListener("click", () => {
964
+ const expanded = el.classList.toggle("expanded");
965
+ btn.textContent = expanded ? "Show less" : "Show more";
966
+ });
967
+ el.insertAdjacentElement("afterend", btn);
968
+ });
969
+ }
970
+
971
+ function buildDimensionRow(key, value, opts = {}) {
972
+ const s = value?.score ?? 0;
973
+ const band = scoreBand(s);
974
+ const row = document.createElement("div");
975
+ row.className = `dimension-row score-row ${band}`;
976
+ const dimLabel = formatDimLabel(key);
977
+ const labelHtml = value?.label
978
+ ? `<span class="score-label">${escapeHtml(value.label)}</span>`
979
+ : "";
980
+ const quote = value?.quote && opts.showQuote
981
+ ? `<span class="quote-chip">"${escapeHtml(value.quote)}"</span>`
982
+ : "";
983
+ const reason = value?.reason ?? "";
984
+ row.innerHTML = `
985
+ <div class="dimension-row-head score-row-head">
986
+ <span class="dimension-name">${dimLabel}${labelHtml}</span>
987
+ <strong class="dimension-score">${s}</strong>
988
+ </div>
989
+ <div class="dimension-bar bar-track"><div class="dimension-bar-fill bar-fill" style="width:0%" data-width="${s}%"></div></div>
990
+ <p class="dimension-reason score-reason clamp-text">${escapeHtml(reason)}</p>
991
+ ${quote}
992
+ `;
993
+ requestAnimationFrame(() => {
994
+ const fill = row.querySelector(".dimension-bar-fill");
995
+ if (fill) fill.style.width = fill.dataset.width || `${s}%`;
996
+ });
997
+ return row;
998
+ }
999
+
1000
+ function initResultTabs(tabsRootId, panelsRootId) {
1001
+ const tabsRoot = document.getElementById(tabsRootId);
1002
+ const panelsRoot = document.getElementById(panelsRootId);
1003
+ if (!tabsRoot || !panelsRoot || tabsRoot.dataset.tabsInit === "1") return;
1004
+ tabsRoot.dataset.tabsInit = "1";
1005
+
1006
+ const activate = (tabName) => {
1007
+ tabsRoot.querySelectorAll(".result-tab").forEach((tab) => {
1008
+ const active = tab.dataset.tab === tabName;
1009
+ tab.classList.toggle("active", active);
1010
+ tab.setAttribute("aria-selected", active ? "true" : "false");
1011
+ });
1012
+ panelsRoot.querySelectorAll(".result-panel").forEach((panel) => {
1013
+ const active = panel.dataset.panel === tabName;
1014
+ panel.classList.toggle("active", active);
1015
+ panel.hidden = !active;
1016
+ });
1017
+ };
1018
+
1019
+ tabsRoot.addEventListener("click", (e) => {
1020
+ const tab = e.target.closest(".result-tab");
1021
+ if (!tab || tab.hidden) return;
1022
+ activate(tab.dataset.tab);
1023
+ });
1024
+ }
1025
+
1026
+ function switchResultTab(tabsRootId, tabName) {
1027
+ const tabsRoot = document.getElementById(tabsRootId);
1028
+ const tab = tabsRoot?.querySelector(`.result-tab[data-tab="${tabName}"]`);
1029
+ tab?.click();
1030
+ }
1031
+
1032
  function renderScorecard(data) {
1033
  const overall = data.overall ?? 0;
1034
+ const overallEl = document.getElementById("overall-score");
1035
+ if (overallEl) overallEl.textContent = overall;
1036
 
1037
  const overallLabelEl = document.getElementById("overall-label");
1038
  if (overallLabelEl) {
 
1040
  overallLabelEl.hidden = !data.overall_label;
1041
  }
1042
 
1043
+ const scores = data.scores ?? {};
1044
+ const { strongest, weakest } = getStrongestWeakest(scores);
1045
+
1046
+ const readoutEl = document.getElementById("pitch-readout");
1047
+ if (readoutEl) {
1048
+ const se = data.score_explanation ?? {};
1049
+ readoutEl.textContent =
1050
+ data.improved_pitch?.split(/[.!?]/)[0]?.trim()
1051
+ || se.why_you_scored_this?.split(/[.!?]/)[0]?.trim()
1052
+ || (data.overall_label ? `${data.overall_label} pitch — review dimensions below.` : "Your pitch battle is complete.");
1053
+ }
1054
+
1055
+ const strongChip = document.getElementById("chip-strongest-dim");
1056
+ const weakChip = document.getElementById("chip-weakest-dim");
1057
+ if (strongChip) {
1058
+ strongChip.textContent = strongest
1059
+ ? `Strongest: ${formatDimLabel(strongest[0])}`
1060
+ : "Strongest: —";
1061
+ }
1062
+ if (weakChip) {
1063
+ weakChip.textContent = weakest
1064
+ ? `Weakest: ${formatDimLabel(weakest[0])}`
1065
+ : "Weakest: —";
1066
+ }
1067
+
1068
  const sourceBadgeEl = document.getElementById("scorecard-source-badge");
1069
+ const chipSource = document.getElementById("chip-score-source");
1070
+ const fallbackWarnEl = document.getElementById("scorecard-fallback-warning");
1071
+ const nemotronScored = isNemotronScorecardSource(data.scorecard_source);
1072
  if (sourceBadgeEl) {
1073
+ sourceBadgeEl.textContent = nemotronScored ? "Powered by NVIDIA Nemotron" : "";
1074
+ sourceBadgeEl.hidden = !nemotronScored;
1075
+ }
1076
+ if (chipSource) {
1077
+ chipSource.textContent = nemotronScored ? "Nemotron Judge" : "";
1078
+ chipSource.hidden = !nemotronScored;
1079
+ chipSource.classList.toggle("chip-source", nemotronScored);
1080
+ }
1081
+ if (fallbackWarnEl) {
1082
+ fallbackWarnEl.textContent = "";
1083
+ fallbackWarnEl.hidden = true;
1084
+ }
1085
+ if (data.model_error) {
1086
+ console.warn("Scorecard model note (not shown to user):", data.model_error);
1087
  }
1088
 
1089
+ const nextWrap = document.getElementById("pitch-next-action");
1090
+ const nextText = document.getElementById("pitch-next-action-text");
1091
+ const se = data.score_explanation ?? {};
1092
+ const atr = se.answer_to_retry ?? {};
1093
+ const nextLine =
1094
+ atr.retry_advice
1095
+ || se.what_stopped_80?.split(/[.!?]/)[0]?.trim()
1096
+ || (weakest ? `Retry your ${formatDimLabel(weakest[0])} answer with one concrete proof point.` : "");
1097
+ if (nextWrap && nextText) {
1098
+ if (nextLine) {
1099
+ nextText.textContent = nextLine.endsWith(".") ? nextLine : `${nextLine}.`;
1100
+ nextWrap.hidden = false;
1101
+ } else {
1102
+ nextWrap.hidden = true;
1103
+ }
1104
+ }
1105
 
1106
+ const bars = document.getElementById("score-bars");
1107
+ if (bars) {
1108
+ bars.innerHTML = "";
1109
+ Object.entries(scores).forEach(([key, value]) => {
1110
+ bars.appendChild(buildDimensionRow(key, value, { showQuote: true }));
1111
+ });
1112
+ }
 
 
 
 
 
 
 
 
1113
 
 
1114
  const sigEl = document.getElementById("signals-summary");
1115
  if (sigEl) {
1116
  const css = data.concrete_signals_summary ?? {};
 
1121
  ...(css.revenue_signals ?? []),
1122
  ...(css.technical_mechanisms ?? []),
1123
  ].slice(0, 8);
1124
+ sigEl.innerHTML = "";
1125
  if (allSigs.length > 0) {
1126
+ allSigs.forEach((sig) => {
1127
+ const chip = document.createElement("span");
1128
+ chip.className = "source-chip signal-chip";
1129
+ chip.textContent = sig;
1130
+ sigEl.appendChild(chip);
1131
+ });
1132
  sigEl.hidden = false;
1133
  } else {
1134
  sigEl.hidden = true;
1135
  }
1136
  }
1137
 
1138
+ const setText = (id, text) => {
1139
+ const el = document.getElementById(id);
1140
+ if (!el) return;
1141
+ el.textContent = text ?? "";
1142
+ el.classList.remove("expanded");
1143
+ const next = el.nextElementSibling;
1144
+ if (next?.classList?.contains("show-more-btn")) next.remove();
1145
+ };
1146
+
1147
+ setText("improved-answer", data.improved_answer);
1148
+ setText("improved-pitch", data.improved_pitch);
1149
+ setText("best-answer", data.best_answer);
1150
+ setText("weakest-answer", data.weakest_answer);
1151
+
1152
+ ["improved-answer", "improved-pitch", "best-answer", "weakest-answer"].forEach((id) => {
1153
+ attachShowMore(document.getElementById(id));
1154
+ });
1155
+
1156
+ const setRoundBadge = (id, round) => {
1157
+ const el = document.getElementById(id);
1158
+ if (!el) return;
1159
+ if (round) {
1160
+ el.textContent = `Round ${round}`;
1161
+ el.hidden = false;
1162
+ } else {
1163
+ el.textContent = "";
1164
+ el.hidden = true;
1165
+ }
1166
+ };
1167
+ setRoundBadge("best-answer-round", data.best_answer_round);
1168
+ setRoundBadge("weakest-answer-round", data.weakest_answer_round);
1169
+
1170
+ const whyWeakEl = document.getElementById("why-weak");
1171
+ if (whyWeakEl) {
1172
+ const why = data.why_weak ?? "";
1173
+ whyWeakEl.textContent = why ? `Why it hurt: ${why}` : "";
1174
+ whyWeakEl.hidden = !why;
1175
+ attachShowMore(whyWeakEl);
1176
+ }
1177
 
1178
  const list = document.getElementById("top-questions");
1179
+ if (list) {
1180
+ list.innerHTML = "";
1181
+ (data.top_3_questions ?? []).forEach((q) => {
1182
+ const li = document.createElement("li");
1183
+ li.textContent = q;
1184
+ list.appendChild(li);
1185
+ });
1186
+ }
1187
+
1188
+ state.scoreExplanation = data.score_explanation ?? null;
1189
+
1190
+ const pathBtn = document.getElementById("btn-path-to-80");
1191
+ const hasExplanation = Boolean(state.scoreExplanation);
1192
+ if (pathBtn) pathBtn.hidden = !hasExplanation;
1193
+
1194
+ renderVoiceDelivery(data.voice_delivery);
1195
+ renderJudgeVerdict(data.judge_verdict);
1196
+ state.judgeVerdict = data.judge_verdict ?? null;
1197
+
1198
+ initResultTabs("pitch-result-tabs", "pitch-result-panels");
1199
+ switchResultTab("pitch-result-tabs", "overview");
1200
+
1201
+ const orb = document.querySelector(".score-orb");
1202
+ if (orb) {
1203
+ orb.classList.remove("score-high", "score-mid", "score-low");
1204
+ orb.classList.add(scoreBand(overall));
1205
+ }
1206
+
1207
+ state.scorecardSnapshot = {
1208
+ overall,
1209
+ overallLabel: data.overall_label ?? "",
1210
+ strongest: strongest ? formatDimLabel(strongest[0]) : null,
1211
+ weakest: weakest ? formatDimLabel(weakest[0]) : null,
1212
+ roundsCompleted: state.battleLog?.length ?? 0,
1213
+ };
1214
+ }
1215
+
1216
+ const DEAL_TYPE_LABELS = {
1217
+ equity: "Equity Negotiation",
1218
+ mentorship: "Mentorship Terms",
1219
+ pilot: "Pilot Agreement",
1220
+ sponsorship: "Sponsorship Terms",
1221
+ verdict_only: "Hackathon Verdict",
1222
+ none: "General Discussion",
1223
+ };
1224
+
1225
+ function verdictNegotiationCta(label, fallback = "Start Negotiation →") {
1226
+ const text = String(label || "").trim();
1227
+ if (!text || /pitch practice/i.test(text)) return fallback;
1228
+ return text
1229
+ .replace(/Continue to Deal Practice/i, "Start Negotiation")
1230
+ .replace(/Continue to Deal Round/i, "Start Negotiation")
1231
+ .replace(/Deal Practice/i, "Negotiation")
1232
+ .replace(/Deal Round/i, "Negotiation");
1233
+ }
1234
+
1235
+ function renderJudgeVerdict(verdict) {
1236
+ const heroWrap = document.getElementById("judge-verdict-hero");
1237
+ if (!verdict) {
1238
+ if (heroWrap) heroWrap.hidden = true;
1239
+ return;
1240
+ }
1241
+ if (heroWrap) heroWrap.hidden = false;
1242
+
1243
+ const interest = verdict.interest_level || "no_interest";
1244
+ const personaLine = `${verdict.persona_name || "Judge"} — ${verdict.persona_type || ""}`.trim();
1245
+
1246
+ document.getElementById("verdict-persona-badge").textContent = personaLine;
1247
+ const badge = document.getElementById("verdict-interest-badge");
1248
+ badge.textContent = verdict.interest_label || interest.replaceAll("_", " ");
1249
+ badge.className = `verdict-interest-badge result-verdict-badge interest-${interest}`;
1250
+
1251
+ const reactionEl = document.getElementById("verdict-reaction");
1252
+ if (reactionEl) {
1253
+ reactionEl.textContent = verdict.judge_reaction || "";
1254
+ }
1255
+ document.getElementById("verdict-deal-type").textContent =
1256
+ DEAL_TYPE_LABELS[verdict.deal_type] || verdict.deal_type || "—";
1257
+ const whyEl = document.getElementById("verdict-why");
1258
+ if (whyEl) whyEl.textContent = verdict.why_this_verdict || "";
1259
+
1260
+ const offerEl = document.getElementById("verdict-opening-offer");
1261
+ if (offerEl) {
1262
+ if (verdict.deal_opening_offer) {
1263
+ offerEl.textContent = `Opening offer: ${verdict.deal_opening_offer}`;
1264
+ offerEl.hidden = false;
1265
+ } else {
1266
+ offerEl.hidden = true;
1267
+ }
1268
+ }
1269
+
1270
+ const actions = document.getElementById("verdict-actions");
1271
+ if (!actions) return;
1272
+ actions.innerHTML = "";
1273
+
1274
+ if (verdict.can_continue_to_deal) {
1275
+ const btn = document.createElement("button");
1276
+ btn.className = "btn btn-deal-continue";
1277
+ btn.textContent = verdictNegotiationCta(verdict.next_step_label);
1278
+ btn.type = "button";
1279
+ btn.addEventListener("click", startDealPhase);
1280
+ actions.appendChild(btn);
1281
+ } else if (interest === "too_early") {
1282
+ const btn = document.createElement("button");
1283
+ btn.className = "btn btn-secondary";
1284
+ btn.type = "button";
1285
+ btn.textContent = verdictNegotiationCta(verdict.next_step_label, "Practice More — Negotiate Later");
1286
+ btn.addEventListener("click", () => showScreen("setup"));
1287
+ actions.appendChild(btn);
1288
+ } else if (interest === "no_interest") {
1289
+ const retryBtn = document.createElement("button");
1290
+ retryBtn.className = "btn btn-path80";
1291
+ retryBtn.type = "button";
1292
+ retryBtn.textContent = "View Path to 80+";
1293
+ retryBtn.addEventListener("click", openPath80);
1294
+ actions.appendChild(retryBtn);
1295
+ const newBtn = document.createElement("button");
1296
+ newBtn.className = "btn btn-ghost";
1297
+ newBtn.type = "button";
1298
+ newBtn.textContent = "New Battle";
1299
+ newBtn.addEventListener("click", resetBattle);
1300
+ actions.appendChild(newBtn);
1301
+ } else if (interest === "mild_interest" || interest === "strong_interest") {
1302
+ if (!verdict.can_continue_to_deal) {
1303
+ const btn = document.createElement("button");
1304
+ btn.className = "btn btn-deal-continue";
1305
+ btn.textContent = verdictNegotiationCta(verdict.next_step_label);
1306
+ btn.type = "button";
1307
+ btn.addEventListener("click", startDealPhase);
1308
+ actions.appendChild(btn);
1309
+ }
1310
+ } else if (verdict.deal_type === "verdict_only") {
1311
+ const gapBtn = document.createElement("button");
1312
+ gapBtn.className = "btn btn-path80";
1313
+ gapBtn.type = "button";
1314
+ gapBtn.textContent = verdict.next_step_label || "View Path to 80+";
1315
+ gapBtn.addEventListener("click", openPath80);
1316
+ actions.appendChild(gapBtn);
1317
+ }
1318
+ }
1319
+
1320
+ function renderDealArena(data) {
1321
+ state.uiMode = "deal";
1322
+ state.dealContext = data.deal_context || {};
1323
+ state.dealRound = data.round ?? 1;
1324
+
1325
+ document.getElementById("deal-round-counter").textContent = state.dealRound;
1326
+ const dealRoundDisplay = document.getElementById("deal-round-display");
1327
+ if (dealRoundDisplay) dealRoundDisplay.textContent = String(state.dealRound).padStart(2, "0");
1328
+ const dealRoundDuel = document.getElementById("deal-round-counter-duel");
1329
+ if (dealRoundDuel) dealRoundDuel.textContent = state.dealRound;
1330
+
1331
+ const dealType = DEAL_TYPE_LABELS[data.deal_type] || data.deal_type || "—";
1332
+ document.getElementById("deal-type-label").textContent = dealType;
1333
+ const typeChip = document.getElementById("deal-type-chip");
1334
+ if (typeChip) typeChip.textContent = dealType;
1335
+
1336
+ const focus = data.negotiation_tag || "—";
1337
+ document.getElementById("deal-negotiation-tag").textContent = focus;
1338
+ const focusChip = document.getElementById("deal-focus-chip");
1339
+ if (focusChip) focusChip.textContent = focus;
1340
+
1341
+ document.getElementById("deal-persona-name").textContent =
1342
+ `${data.persona_name || ""} — ${data.persona_role || ""}`.trim();
1343
+ document.getElementById("deal-opening-offer").textContent =
1344
+ data.deal_context?.opening_offer || data.deal_context?.judge_position || "—";
1345
+ document.getElementById("deal-your-ask").textContent = data.deal_context?.ask || "—";
1346
+
1347
+ updateProgressStrip(state.dealRound, focus, "deal");
1348
+ updateJudgeAttackPill(focus, "deal");
1349
+ }
1350
+
1351
+ export async function startDealPhase() {
1352
+ if (!state.sessionId) return;
1353
+ try {
1354
+ setGlobalLoading(true, "Preparing deal terms…");
1355
+ const data = await apiPost("/api/start-deal-phase", { session_id: state.sessionId });
1356
+ if (data.error) {
1357
+ showErrorBanner(data.error);
1358
+ return;
1359
+ }
1360
+ if (dealChatWindow) dealChatWindow.innerHTML = "";
1361
+ state.dealConversationLog = [];
1362
+ state.dealBattleLog = [];
1363
+ liveDealJudgeTurn = { text: "", meta: "" };
1364
+ liveDealFounderReply = "";
1365
+ document.getElementById("deal-log-tabs") && (document.getElementById("deal-log-tabs").innerHTML = "");
1366
+ document.getElementById("deal-log-ribbon")?.setAttribute("hidden", "");
1367
+ document.getElementById("btn-open-deal-rounds")?.setAttribute("hidden", "");
1368
+ document.getElementById("deal-rounds-drawer")?.setAttribute("hidden", "");
1369
+ if (dealInput) dealInput.value = "";
1370
+ dealStatus.hidden = true;
1371
+ document.getElementById("deal-readiness-prompt")?.setAttribute("hidden", "");
1372
+ renderDealArena(data);
1373
+ appendDealMessage(
1374
+ "ai",
1375
+ data.ai_message,
1376
+ `${data.negotiation_tag} · Round ${data.round}`,
1377
+ );
1378
+ showScreen("deal");
1379
+ hideErrorBanner();
1380
+ } catch (error) {
1381
+ console.error(error);
1382
+ showErrorBanner("Could not start deal phase.");
1383
+ } finally {
1384
+ setGlobalLoading(false);
1385
+ }
1386
+ }
1387
+
1388
+ export async function sendDealRound(messageOverride, voiceMeta = null) {
1389
+ const message = (messageOverride ?? dealInput?.value ?? "").trim();
1390
+ if (!message || !state.sessionId) return;
1391
+
1392
+ try {
1393
+ setGlobalLoading(true, "Scoring your counter…");
1394
+ if (dealInput) dealInput.value = "";
1395
+ appendDealMessage("user", message);
1396
+
1397
+ const payload = {
1398
+ session_id: state.sessionId,
1399
+ user_message: message,
1400
+ input_mode: voiceMeta?.voice_turn_id ? "voice" : "text",
1401
+ };
1402
+ if (voiceMeta?.voice_turn_id) payload.voice_turn_id = voiceMeta.voice_turn_id;
1403
+
1404
+ const data = await apiPost("/api/deal-round", payload);
1405
+ if (data.error) {
1406
+ if (dealStatus) {
1407
+ dealStatus.hidden = false;
1408
+ dealStatus.textContent = data.error;
1409
+ }
1410
+ return;
1411
+ }
1412
+
1413
+ state.dealRound = data.round ?? state.dealRound;
1414
+ document.getElementById("deal-round-counter").textContent = state.dealRound;
1415
+ const dealRoundDisplay = document.getElementById("deal-round-display");
1416
+ if (dealRoundDisplay) dealRoundDisplay.textContent = String(state.dealRound).padStart(2, "0");
1417
+ const dealRoundDuel = document.getElementById("deal-round-counter-duel");
1418
+ if (dealRoundDuel) dealRoundDuel.textContent = state.dealRound;
1419
+ document.getElementById("deal-negotiation-tag").textContent = data.negotiation_tag || "—";
1420
+ updateProgressStrip(state.dealRound, data.negotiation_tag, "deal");
1421
+
1422
+ appendDealMessage(
1423
+ "ai",
1424
+ data.ai_message,
1425
+ `${data.negotiation_tag} · Round ${data.round}`,
1426
+ );
1427
+
1428
+ updateDealReadiness(data.readiness, data.soft_limit_reached, data.completion_message);
1429
+
1430
+ state.pendingDealVoiceTurn = null;
1431
+ document.getElementById("deal-voice-preview")?.setAttribute("hidden", "");
1432
+ } catch (error) {
1433
+ console.error(error);
1434
+ dealStatus.hidden = false;
1435
+ dealStatus.textContent = "Deal round failed. Try again.";
1436
+ } finally {
1437
+ setGlobalLoading(false);
1438
+ }
1439
+ }
1440
+
1441
+ function updateDealReadiness(readiness, softLimit, completionMessage) {
1442
+ const prompt = document.getElementById("deal-readiness-prompt");
1443
+ const text = document.getElementById("deal-readiness-text");
1444
+ const action = readiness?.recommended_action;
1445
+ const shouldShow = action === "recommend_end" || action === "force_end" || softLimit;
1446
+ if (!prompt) return;
1447
+ if (!shouldShow) {
1448
+ prompt.hidden = true;
1449
+ return;
1450
+ }
1451
+ if (text) {
1452
+ text.textContent = readiness?.reason
1453
+ || completionMessage
1454
+ || "You have enough negotiation signal for a scorecard. You can end now or continue one more round.";
1455
+ }
1456
+ // At the hard cap, hide the "continue" option.
1457
+ const continueBtn = document.getElementById("btn-deal-readiness-continue");
1458
+ if (continueBtn) continueBtn.style.display = action === "force_end" ? "none" : "";
1459
+ prompt.hidden = false;
1460
+ }
1461
+
1462
+ function showDealVoicePreview(data) {
1463
+ state.pendingDealVoiceTurn = data;
1464
+ const preview = document.getElementById("deal-voice-preview");
1465
+ const transcriptEl = document.getElementById("deal-voice-transcript");
1466
+ if (preview) preview.hidden = false;
1467
+ if (transcriptEl) transcriptEl.value = data.transcript ?? "";
1468
+ if (dealInput) dealInput.value = data.transcript ?? "";
1469
+ }
1470
+
1471
+ export async function endDeal() {
1472
+ if (!state.sessionId) return;
1473
+ try {
1474
+ setGlobalLoading(true, "Building deal scorecard…");
1475
+ const data = await apiPost("/api/end-deal", { session_id: state.sessionId });
1476
+ if (data.error) {
1477
+ showErrorBanner(data.error);
1478
+ return;
1479
+ }
1480
+ state.negotiationTranscript = data.negotiation_transcript || [];
1481
+ renderDealScorecard(data);
1482
+ showScreen("dealScorecard");
1483
+ hideErrorBanner();
1484
+ } catch (error) {
1485
+ console.error(error);
1486
+ showErrorBanner("Failed to generate deal scorecard.");
1487
+ } finally {
1488
+ setGlobalLoading(false);
1489
+ }
1490
+ }
1491
+
1492
+ function renderDealScorecard(data) {
1493
+ const combined = data.combined_scorecard || {};
1494
+ const deal = data.deal_scorecard || {};
1495
+
1496
+ const pitch = combined.pitch_overall ?? 0;
1497
+ const dealScore = combined.deal_overall ?? 0;
1498
+ const overall = combined.combined_overall ?? 0;
1499
+
1500
+ document.getElementById("combined-pitch").textContent = pitch;
1501
+ document.getElementById("combined-deal").textContent = dealScore;
1502
+ document.getElementById("combined-overall").textContent = overall;
1503
+
1504
+ ["combined-pitch-tab", "combined-deal-tab", "combined-overall-tab"].forEach((id, i) => {
1505
+ const el = document.getElementById(id);
1506
+ if (el) el.textContent = [pitch, dealScore, overall][i];
1507
+ });
1508
+
1509
+ const profile = combined.founder_profile ?? "";
1510
+ document.getElementById("combined-profile").textContent = profile;
1511
+ const profileTab = document.getElementById("combined-profile-tab");
1512
+ if (profileTab) profileTab.textContent = profile;
1513
+
1514
+ const summaryEl = document.getElementById("combined-summary");
1515
+ if (summaryEl) {
1516
+ summaryEl.textContent = combined.summary ?? "";
1517
+ attachShowMore(summaryEl);
1518
+ }
1519
+
1520
+ const nextEl = document.getElementById("combined-next-action");
1521
+ if (nextEl) {
1522
+ nextEl.textContent = combined.next_best_action ?? "Review deal dimensions and prep points below.";
1523
+ }
1524
+
1525
+ const outcomeEl = document.getElementById("deal-outcome-badge");
1526
+ if (outcomeEl) {
1527
+ outcomeEl.textContent = (deal.deal_outcome || "balanced").replaceAll("_", " ");
1528
+ outcomeEl.className = `deal-outcome-badge result-verdict-badge outcome-${deal.deal_outcome || "balanced"}`;
1529
+ }
1530
+ document.getElementById("deal-overall-label").textContent =
1531
+ `${deal.overall ?? 0} — ${deal.overall_label ?? ""}`;
1532
+
1533
+ const dealWeakest = getStrongestWeakest(deal.scores || {}).weakest;
1534
+ const weakestLine = document.getElementById("deal-summary-weakest");
1535
+ if (weakestLine) {
1536
+ weakestLine.textContent = dealWeakest
1537
+ ? `Weakest negotiation skill: ${formatDimLabel(dealWeakest[0])} (${dealWeakest[1].score ?? 0})`
1538
+ : "";
1539
+ }
1540
+
1541
+ const bars = document.getElementById("deal-score-bars");
1542
+ if (bars) {
1543
+ bars.innerHTML = "";
1544
+ Object.entries(deal.scores || {}).forEach(([key, value]) => {
1545
+ bars.appendChild(buildDimensionRow(key, value, { showQuote: true }));
1546
+ });
1547
+ }
1548
+
1549
+ const WEAK_FALLBACK =
1550
+ "No major single weak move detected; the main weakness was lack of alternatives/leverage.";
1551
+ const bestEl = document.getElementById("deal-best-move");
1552
+ const weakEl = document.getElementById("deal-weakest-move");
1553
+ const improvedEl = document.getElementById("deal-improved-response");
1554
+ if (bestEl) {
1555
+ bestEl.textContent = cleanMoveText(deal.best_move) || "No standout negotiation move was recorded.";
1556
+ attachShowMore(bestEl);
1557
+ }
1558
+ if (weakEl) {
1559
+ weakEl.textContent = cleanMoveText(deal.weakest_move) || WEAK_FALLBACK;
1560
+ attachShowMore(weakEl);
1561
+ }
1562
+ if (improvedEl) {
1563
+ improvedEl.textContent = deal.improved_response ?? "";
1564
+ attachShowMore(improvedEl);
1565
+ }
1566
+
1567
+ const prep = document.getElementById("deal-prep-points");
1568
+ if (prep) {
1569
+ prep.innerHTML = "";
1570
+ (deal.top_3_prep_points || []).forEach((p) => {
1571
+ const li = document.createElement("li");
1572
+ li.textContent = p;
1573
+ prep.appendChild(li);
1574
+ });
1575
+ }
1576
+
1577
+ const metaEl = document.getElementById("deal-scorecard-meta");
1578
+ if (metaEl) {
1579
+ const nemotronScored = isNemotronScorecardSource(deal.scorecard_source);
1580
+ metaEl.textContent = nemotronScored ? "Powered by NVIDIA Nemotron" : "";
1581
+ metaEl.hidden = !nemotronScored;
1582
+ }
1583
+
1584
+ initResultTabs("deal-result-tabs", "deal-result-panels");
1585
+ switchResultTab("deal-result-tabs", "deal-summary");
1586
+ }
1587
+
1588
+ // Reject empty / single-word / obviously truncated move text so the UI never
1589
+ // shows a lone "sure" or a half-sentence.
1590
+ function cleanMoveText(text) {
1591
+ const t = (text || "").trim();
1592
+ if (!t) return "";
1593
+ const bareWords = ["sure", "ok", "okay", "yes", "fine", "yeah"];
1594
+ if (bareWords.includes(t.toLowerCase().replace(/[.!?]$/, ""))) return "";
1595
+ if (t.length < 12) return "";
1596
+ return t;
1597
+ }
1598
+
1599
+ function renderNegotiationTranscript() {
1600
+ const container = document.getElementById("negotiation-transcript");
1601
+ if (!container) return;
1602
+ container.innerHTML = "";
1603
+ const transcript = state.negotiationTranscript || [];
1604
+ if (!transcript.length) {
1605
+ container.innerHTML = `<p class="negotiation-empty">No negotiation messages were recorded.</p>`;
1606
+ return;
1607
+ }
1608
+ transcript.forEach((turn) => {
1609
+ const row = document.createElement("div");
1610
+ const role = turn.role === "judge" ? "judge" : "founder";
1611
+ row.className = `negotiation-turn negotiation-${role}`;
1612
+ const speaker = role === "judge" ? "Judge" : "Founder";
1613
+ const badges = [];
1614
+ if (turn.negotiation_tag) {
1615
+ badges.push(`<span class="neg-badge neg-tag source-chip">${escapeHtml(turn.negotiation_tag)}</span>`);
1616
+ }
1617
+ if (turn.answer_quality) {
1618
+ badges.push(`<span class="neg-badge neg-quality neg-q-${escapeHtml(turn.answer_quality)} source-chip">${escapeHtml(turn.answer_quality)}</span>`);
1619
+ }
1620
+ if ((turn.input_mode || "") === "voice") {
1621
+ badges.push(`<span class="neg-badge neg-voice source-chip">Voice</span>`);
1622
+ }
1623
+ if (turn.action_taken) {
1624
+ badges.push(`<span class="neg-badge source-chip">${escapeHtml(turn.action_taken)}</span>`);
1625
+ }
1626
+ row.innerHTML = `
1627
+ <div class="negotiation-turn-head">
1628
+ <span class="negotiation-speaker">${turn.round ? `Round ${turn.round} · ` : ""}${speaker}</span>
1629
+ <span class="negotiation-badges">${badges.join("")}</span>
1630
+ </div>
1631
+ <p class="negotiation-message">${escapeHtml(turn.message || "")}</p>
1632
+ `;
1633
+ container.appendChild(row);
1634
+ });
1635
+ container.scrollTop = container.scrollHeight;
1636
+ }
1637
+
1638
+ function openNegotiationModal() {
1639
+ renderNegotiationTranscript();
1640
+ const overlay = document.getElementById("negotiation-overlay");
1641
+ if (overlay) overlay.hidden = false;
1642
+ }
1643
+
1644
+ function closeNegotiationModal() {
1645
+ const overlay = document.getElementById("negotiation-overlay");
1646
+ if (overlay) overlay.hidden = true;
1647
+ }
1648
+
1649
+ function renderVoiceDelivery(vd) {
1650
+ const content = document.getElementById("voice-delivery-content");
1651
+ const tab = document.getElementById("tab-voice-delivery");
1652
+ if (!content) return;
1653
+ if (!vd || typeof vd !== "object") {
1654
+ if (tab) tab.hidden = true;
1655
+ content.innerHTML = "";
1656
+ return;
1657
+ }
1658
+ if (tab) tab.hidden = false;
1659
+
1660
+ const fillers = (vd.filler_word_list ?? []).slice(0, 6).join(", ") || "None detected";
1661
+ const overallNote = vd.overall_delivery_feedback
1662
+ || (vd.delivery_notes ?? []).find((n) => String(n).trim()) || "";
1663
+
1664
+ content.innerHTML = `
1665
+ <div class="voice-wave-decor" aria-hidden="true"></div>
1666
+ <div class="voice-delivery-summary voice-delivery-grid">
1667
+ <div class="voice-delivery-stat"><span>Voice turns</span><strong>${vd.total_voice_turns ?? 0}</strong></div>
1668
+ <div class="voice-delivery-stat"><span>Filler words</span><strong>${vd.total_filler_words ?? 0}</strong></div>
1669
+ <div class="voice-delivery-stat"><span>Common fillers</span><strong>${escapeHtml(fillers)}</strong></div>
1670
+ <div class="voice-delivery-stat"><span>Pace</span><strong>${escapeHtml(vd.average_pace ?? "—")}</strong></div>
1671
+ <div class="voice-delivery-stat"><span>Clarity signal</span><strong>${escapeHtml(vd.clarity_signal ?? "—")}</strong></div>
1672
+ <div class="voice-delivery-stat"><span>Confidence signal</span><strong>${escapeHtml(vd.confidence_signal ?? "—")}</strong></div>
1673
+ </div>
1674
+ ${overallNote ? `<p class="voice-delivery-overall">${escapeHtml(overallNote)}</p>` : ""}
1675
+ `;
1676
+
1677
+ const notes = (vd.delivery_notes ?? []).filter((n) => {
1678
+ const t = String(n || "").trim().toLowerCase();
1679
+ const overallLower = String(overallNote).trim().toLowerCase();
1680
+ return t && t !== "clean delivery." && t !== "clean delivery" && t !== overallLower;
1681
+ });
1682
+ const uniqueNotes = [...new Set(notes.map((n) => String(n).trim()))].slice(0, 3);
1683
+ if (uniqueNotes.length) {
1684
+ const ul = document.createElement("ul");
1685
+ ul.className = "voice-delivery-notes";
1686
+ uniqueNotes.forEach((n) => {
1687
+ const li = document.createElement("li");
1688
+ li.textContent = n;
1689
+ ul.appendChild(li);
1690
+ });
1691
+ content.appendChild(ul);
1692
+ }
1693
+ }
1694
+
1695
+ function fillVoiceExtractForm(data) {
1696
+ const form = document.getElementById("voice-extract-form");
1697
+ if (!form || !data) return;
1698
+ const extracted = data.extracted ?? {};
1699
+ Object.entries(extracted).forEach(([key, value]) => {
1700
+ const field = form.elements.namedItem(key);
1701
+ if (field) field.value = value ?? "";
1702
+ });
1703
+ const transcriptEl = document.getElementById("voice-confirm-transcript");
1704
+ if (transcriptEl) transcriptEl.textContent = data.transcript ?? "";
1705
+ const deliveryEl = document.getElementById("voice-confirm-delivery");
1706
+ const obs = data.delivery_observations ?? {};
1707
+ if (deliveryEl) {
1708
+ deliveryEl.textContent = obs.delivery_note
1709
+ ? `Delivery: ${obs.delivery_note}`
1710
+ : "";
1711
+ }
1712
+ const confEl = document.getElementById("voice-confirm-confidence");
1713
+ if (confEl) {
1714
+ const conf = data.extraction_confidence ?? "medium";
1715
+ confEl.textContent = `Confidence: ${conf}`;
1716
+ confEl.className = `delivery-chip confidence-${conf}`;
1717
+ }
1718
+ const warn = document.getElementById("voice-confidence-warning");
1719
+ if (warn) warn.hidden = (data.extraction_confidence ?? "medium") !== "low";
1720
+ }
1721
+
1722
+ function showVoiceTurnPreview(data) {
1723
+ state.pendingVoiceTurn = data;
1724
+ const preview = document.getElementById("voice-turn-preview");
1725
+ const transcriptEl = document.getElementById("voice-turn-transcript");
1726
+ const deliveryEl = document.getElementById("voice-turn-delivery");
1727
+ if (preview) preview.hidden = false;
1728
+ if (transcriptEl) transcriptEl.value = data.transcript ?? "";
1729
+ if (deliveryEl) {
1730
+ deliveryEl.textContent = data.delivery_note
1731
+ ? `Delivery: ${data.delivery_note}`
1732
+ : "";
1733
+ }
1734
+ }
1735
+
1736
+ function openPath80() {
1737
+ const ex = state.scoreExplanation;
1738
+ if (!ex) return;
1739
+
1740
+ const esif = ex.estimated_score_if_fixed ?? {};
1741
+ const atr = ex.answer_to_retry ?? {};
1742
+
1743
+ const setEl = (id, text) => {
1744
+ const el = document.getElementById(id);
1745
+ if (el) el.textContent = text ?? "";
1746
+ };
1747
+
1748
+ setEl("p80-current-score", esif.current_overall ?? "—");
1749
+ setEl("p80-estimated-score", esif.estimated_new_overall ?? "—");
1750
+ setEl("p80-estimate-reason", esif.reason ?? "");
1751
+ setEl("p80-why-scored", ex.why_you_scored_this ?? "");
1752
+ setEl("p80-what-stopped", ex.what_stopped_80 ?? "");
1753
+
1754
+ const dimBadge = document.getElementById("p80-retry-dim");
1755
+ if (dimBadge) dimBadge.textContent = (atr.dimension ?? "").replaceAll("_", " ");
1756
+
1757
+ const roundTag = document.getElementById("p80-retry-round");
1758
+ if (roundTag) roundTag.textContent = atr.round ? `Round ${atr.round}` : "";
1759
+
1760
+ setEl("p80-original-answer", atr.original_answer ?? "");
1761
+ setEl("p80-why-it-hurt", atr.why_it_hurt ?? "");
1762
+ setEl("p80-retry-advice", atr.retry_advice ?? "");
1763
+ setEl("p80-sample-answer", atr.sample_stronger_answer ?? "");
1764
+
1765
+ ["p80-why-scored", "p80-what-stopped", "p80-original-answer", "p80-why-it-hurt", "p80-retry-advice"].forEach((id) => {
1766
+ attachShowMore(document.getElementById(id));
1767
  });
1768
+
1769
+ document.getElementById("path80-overlay").hidden = false;
1770
+ document.body.style.overflow = "hidden";
1771
+ }
1772
+
1773
+ function closePath80() {
1774
+ document.getElementById("path80-overlay").hidden = true;
1775
+ document.body.style.overflow = "";
1776
+ }
1777
+
1778
+ function showRetryDrillView() {
1779
+ document.getElementById("retry-drill-view").hidden = false;
1780
+ document.getElementById("retry-result-view").hidden = true;
1781
+ }
1782
+
1783
+ function showRetryResultView() {
1784
+ document.getElementById("retry-drill-view").hidden = true;
1785
+ document.getElementById("retry-result-view").hidden = false;
1786
+ }
1787
+
1788
+ function populateRetryDrill(data) {
1789
+ const q = data.retry_question || data.original_question || "";
1790
+ document.getElementById("retry-original-question").textContent = q;
1791
+ document.getElementById("retry-original-answer").textContent = data.original_answer ?? "";
1792
+ document.getElementById("retry-why-hurt").textContent = data.why_it_hurt ?? "";
1793
+ document.getElementById("retry-sample").textContent = data.sample_stronger_answer ?? "";
1794
+ const input = document.getElementById("retry-answer-input");
1795
+ if (input) input.value = "";
1796
+ document.getElementById("retry-voice-preview")?.setAttribute("hidden", "");
1797
+ state.pendingRetryVoiceTurn = null;
1798
+ showRetryDrillView();
1799
+ }
1800
+
1801
+ async function startRetryDrill() {
1802
+ if (!state.sessionId) return;
1803
+ try {
1804
+ setGlobalLoading(true, "Preparing retry drill...");
1805
+ const data = await apiPost("/api/retry-weakest-question/start", {
1806
+ session_id: state.sessionId,
1807
+ });
1808
+ if (data.error) {
1809
+ showErrorBanner(data.error);
1810
+ return;
1811
+ }
1812
+ state.retryDrill = data;
1813
+ closePath80();
1814
+ populateRetryDrill(data);
1815
+ document.getElementById("retry-overlay").hidden = false;
1816
+ document.body.style.overflow = "hidden";
1817
+ hideErrorBanner();
1818
+ } catch (error) {
1819
+ console.error(error);
1820
+ showErrorBanner("Could not start retry drill. Try again.");
1821
+ } finally {
1822
+ setGlobalLoading(false);
1823
+ }
1824
+ }
1825
+
1826
+ function showRetryVoicePreview(data) {
1827
+ state.pendingRetryVoiceTurn = data;
1828
+ const preview = document.getElementById("retry-voice-preview");
1829
+ const transcriptEl = document.getElementById("retry-voice-transcript");
1830
+ const input = document.getElementById("retry-answer-input");
1831
+ if (preview) preview.hidden = false;
1832
+ if (transcriptEl) transcriptEl.value = data.transcript ?? "";
1833
+ if (input) input.value = data.transcript ?? "";
1834
+ }
1835
+
1836
+ function renderRetryResult(data) {
1837
+ const comp = data.comparison ?? {};
1838
+ document.getElementById("retry-result-old").textContent = comp.old_answer_summary ?? data.original_answer ?? "";
1839
+ document.getElementById("retry-result-new").textContent = comp.new_answer_summary ?? data.retry_answer ?? "";
1840
+ document.getElementById("retry-what-improved").textContent = comp.what_improved ?? "";
1841
+ document.getElementById("retry-still-missing").textContent = comp.still_missing ?? "";
1842
+ document.getElementById("retry-specific-tip").textContent = comp.specific_tip ?? "";
1843
+
1844
+ const dim = formatDimLabel(data.dimension);
1845
+ const before = comp.estimated_dimension_before ?? 0;
1846
+ const after = comp.estimated_dimension_after ?? before;
1847
+ document.getElementById("retry-dim-estimate").textContent =
1848
+ `${formatDimLabel(data.dimension)}: ${before} → ${after}`;
1849
+ const lift = data.updated_scorecard?.retry_overall_lift ?? comp.estimated_overall_lift ?? 0;
1850
+ document.getElementById("retry-overall-lift").textContent =
1851
+ data.updated_scorecard
1852
+ ? `Overall score: ${data.updated_scorecard.overall} (+${lift})`
1853
+ : `Overall lift: +${lift}`;
1854
+
1855
+ const verdictEl = document.getElementById("retry-verdict-badge");
1856
+ const verdict = comp.verdict ?? "needs_more_work";
1857
+ const verdictLabels = {
1858
+ improved: "Improved",
1859
+ slightly_improved: "Slightly improved",
1860
+ needs_more_work: "Needs more work",
1861
+ };
1862
+ if (verdictEl) {
1863
+ verdictEl.textContent = verdictLabels[verdict] ?? verdict;
1864
+ verdictEl.className = `retry-verdict-badge verdict-${verdict}`;
1865
+ }
1866
+
1867
+ const nextPrompt = document.getElementById("retry-next-prompt");
1868
+ if (nextPrompt) {
1869
+ nextPrompt.textContent = data.next_practice_prompt
1870
+ ? `Next practice: ${data.next_practice_prompt}`
1871
+ : "";
1872
+ }
1873
+ showRetryResultView();
1874
+ }
1875
+
1876
+ async function submitRetryAnswer() {
1877
+ if (!state.sessionId || !state.retryDrill?.retry_id) return;
1878
+
1879
+ const voiceTranscript = document.getElementById("retry-voice-transcript")?.value?.trim();
1880
+ const typed = document.getElementById("retry-answer-input")?.value?.trim();
1881
+ const answer = voiceTranscript || typed;
1882
+ if (!answer) return;
1883
+
1884
+ const payload = {
1885
+ session_id: state.sessionId,
1886
+ retry_id: state.retryDrill.retry_id,
1887
+ retry_answer: answer,
1888
+ input_mode: state.pendingRetryVoiceTurn ? "voice" : "text",
1889
+ };
1890
+ if (state.pendingRetryVoiceTurn?.voice_turn_id) {
1891
+ payload.voice_turn_id = state.pendingRetryVoiceTurn.voice_turn_id;
1892
+ }
1893
+
1894
+ try {
1895
+ setGlobalLoading(true, "Comparing your retry answer...");
1896
+ const data = await apiPost("/api/retry-weakest-question/submit", payload);
1897
+ if (data.error) {
1898
+ showErrorBanner(data.error);
1899
+ return;
1900
+ }
1901
+ renderRetryResult(data);
1902
+ if (data.updated_scorecard) {
1903
+ renderScorecard(data.updated_scorecard);
1904
+ state.scoreExplanation = data.updated_scorecard.score_explanation ?? state.scoreExplanation;
1905
+ if (data.judge_verdict) {
1906
+ renderJudgeVerdict(data.judge_verdict);
1907
+ state.judgeVerdict = data.judge_verdict;
1908
+ }
1909
+ }
1910
+ hideErrorBanner();
1911
+ } catch (error) {
1912
+ console.error(error);
1913
+ showErrorBanner("Retry submission failed. Try again.");
1914
+ } finally {
1915
+ setGlobalLoading(false);
1916
+ }
1917
+ }
1918
+
1919
+ function closeRetryOverlay() {
1920
+ document.getElementById("retry-overlay").hidden = true;
1921
+ document.body.style.overflow = "";
1922
+ state.pendingRetryVoiceTurn = null;
1923
+ }
1924
+
1925
+ function openRetryFromPath80() {
1926
+ startRetryDrill();
1927
  }
1928
 
1929
  document.getElementById("btn-load-sample").addEventListener("click", loadSample);
1930
+ document.getElementById("btn-go-setup").addEventListener("click", () => showScreen("startMethod"));
1931
  document.getElementById("btn-back-landing").addEventListener("click", () => showScreen("landing"));
1932
+ document.getElementById("btn-start-back-landing").addEventListener("click", () => showScreen("landing"));
1933
+
1934
+ document.getElementById("btn-start-text").addEventListener("click", () => {
1935
+ state.startMode = "text";
1936
+ document.querySelectorAll(".start-method-card").forEach((c) => c.classList.remove("selected"));
1937
+ document.getElementById("btn-start-text").classList.add("selected");
1938
+ });
1939
+ document.getElementById("btn-start-voice").addEventListener("click", () => {
1940
+ state.startMode = "voice";
1941
+ document.querySelectorAll(".start-method-card").forEach((c) => c.classList.remove("selected"));
1942
+ document.getElementById("btn-start-voice").classList.add("selected");
1943
+ });
1944
+ document.getElementById("btn-continue-start").addEventListener("click", () => {
1945
+ if (state.startMode === "voice") showScreen("voicePitch");
1946
+ else showScreen("setup");
1947
+ });
1948
+ document.getElementById("btn-voice-pitch-back").addEventListener("click", () => showScreen("startMethod"));
1949
+ document.getElementById("btn-voice-edit-manual").addEventListener("click", () => {
1950
+ const form = document.getElementById("voice-extract-form");
1951
+ const data = new FormData(form);
1952
+ fillStartupForm(Object.fromEntries(data.entries()));
1953
+ showScreen("setup");
1954
+ });
1955
+ document.getElementById("btn-voice-looks-right").addEventListener("click", () => {
1956
+ const form = document.getElementById("voice-extract-form");
1957
+ const data = new FormData(form);
1958
+ fillStartupForm(Object.fromEntries(data.entries()));
1959
+ showScreen("setup");
1960
+ });
1961
+ document.getElementById("btn-voice-turn-send").addEventListener("click", () => {
1962
+ const transcript = document.getElementById("voice-turn-transcript")?.value?.trim();
1963
+ if (!transcript || !state.pendingVoiceTurn) return;
1964
+ sendMessage(transcript, {
1965
+ voice_turn_id: state.pendingVoiceTurn.voice_turn_id,
1966
+ delivery_metadata: {
1967
+ delivery_note: state.pendingVoiceTurn.delivery_note,
1968
+ delivery_cues: state.pendingVoiceTurn.delivery_cues,
1969
+ word_count: state.pendingVoiceTurn.word_count,
1970
+ },
1971
+ });
1972
+ });
1973
  document.getElementById("btn-start-battle").addEventListener("click", startSession);
1974
  document.getElementById("btn-end-battle").addEventListener("click", endBattle);
1975
  document.getElementById("btn-reset").addEventListener("click", resetBattle);
1976
  document.getElementById("btn-back-setup").addEventListener("click", () => showScreen("setup"));
1977
+ document.getElementById("btn-path-to-80").addEventListener("click", openPath80);
1978
+ document.getElementById("btn-close-path80").addEventListener("click", closePath80);
1979
+ document.getElementById("btn-close-path80-bottom").addEventListener("click", closePath80);
1980
+ document.getElementById("btn-retry-question")?.addEventListener("click", openRetryFromPath80);
1981
+ document.getElementById("path80-overlay").addEventListener("click", (e) => {
1982
+ if (e.target === e.currentTarget) closePath80();
1983
+ });
1984
+
1985
+ document.getElementById("btn-close-retry")?.addEventListener("click", closeRetryOverlay);
1986
+ document.getElementById("btn-submit-retry")?.addEventListener("click", submitRetryAnswer);
1987
+ document.getElementById("btn-retry-again")?.addEventListener("click", () => {
1988
+ if (state.retryDrill) populateRetryDrill(state.retryDrill);
1989
+ });
1990
+ document.getElementById("btn-retry-back-scorecard")?.addEventListener("click", () => {
1991
+ closeRetryOverlay();
1992
+ showScreen("scorecard");
1993
+ });
1994
+ document.getElementById("btn-retry-new-battle")?.addEventListener("click", () => {
1995
+ closeRetryOverlay();
1996
+ resetBattle();
1997
+ });
1998
+ document.getElementById("retry-overlay")?.addEventListener("click", (e) => {
1999
+ if (e.target === e.currentTarget) closeRetryOverlay();
2000
+ });
2001
 
2002
  document.getElementById("btn-view-conversation").addEventListener("click", () => {
2003
  document.getElementById("btn-end-battle").hidden = true;
2004
  document.getElementById("btn-back-scorecard").hidden = false;
2005
  document.getElementById("chat-form").hidden = true;
2006
  showScreen("battle");
2007
+ openBattleConversationLog(true);
2008
  });
2009
 
2010
  document.getElementById("btn-back-scorecard").addEventListener("click", () => {
2011
+ closeRoundsDrawer("battle-rounds-drawer");
2012
  document.getElementById("btn-end-battle").hidden = false;
2013
  document.getElementById("btn-back-scorecard").hidden = true;
2014
  document.getElementById("chat-form").hidden = false;
2015
  showScreen("scorecard");
2016
  });
2017
 
2018
+ document.getElementById("btn-open-battle-rounds")?.addEventListener("click", () => {
2019
+ openBattleConversationLog(false);
2020
+ });
2021
+ document.getElementById("btn-close-battle-rounds")?.addEventListener("click", () => {
2022
+ closeRoundsDrawer("battle-rounds-drawer");
2023
+ });
2024
+ document.getElementById("btn-close-battle-rounds-x")?.addEventListener("click", () => {
2025
+ closeRoundsDrawer("battle-rounds-drawer");
2026
+ });
2027
+ document.getElementById("btn-open-deal-rounds")?.addEventListener("click", () => {
2028
+ openRoundsDrawer("deal-rounds-drawer");
2029
+ });
2030
+ document.getElementById("btn-close-deal-rounds")?.addEventListener("click", () => {
2031
+ closeRoundsDrawer("deal-rounds-drawer");
2032
+ });
2033
+ document.getElementById("btn-close-deal-rounds-x")?.addEventListener("click", () => {
2034
+ closeRoundsDrawer("deal-rounds-drawer");
2035
+ });
2036
+
2037
  document.querySelectorAll(".persona-card").forEach((card) => {
2038
  card.addEventListener("click", () => {
2039
  document.querySelectorAll(".persona-card").forEach((c) => c.classList.remove("selected"));
 
2042
  });
2043
  });
2044
 
2045
+ document.querySelectorAll(".difficulty-card").forEach((card) => {
2046
+ card.addEventListener("click", () => {
2047
+ document.querySelectorAll(".difficulty-card").forEach((c) => c.classList.remove("selected"));
2048
+ card.classList.add("selected");
2049
+ state.difficultyProfile = card.dataset.difficulty;
2050
+ });
2051
+ });
2052
+
2053
  document.getElementById("chat-form").addEventListener("submit", (event) => {
2054
  event.preventDefault();
2055
  sendMessage();
2056
  });
2057
 
2058
+ function bindEnterToSubmit(textarea, onSubmit) {
2059
+ textarea?.addEventListener("keydown", (event) => {
2060
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
2061
+ event.preventDefault();
2062
+ onSubmit();
2063
+ });
2064
+ }
2065
+
2066
+ bindEnterToSubmit(userInput, () => sendMessage());
2067
+ bindEnterToSubmit(document.getElementById("deal-input"), () => sendDealRound());
2068
+
2069
+ document.getElementById("deal-form")?.addEventListener("submit", (event) => {
2070
+ event.preventDefault();
2071
+ sendDealRound();
2072
+ });
2073
+
2074
+ document.getElementById("btn-end-deal")?.addEventListener("click", endDeal);
2075
+ document.getElementById("btn-deal-back-scorecard")?.addEventListener("click", () => showScreen("scorecard"));
2076
+ document.getElementById("btn-deal-new-battle")?.addEventListener("click", resetBattle);
2077
+ document.getElementById("btn-deal-view-pitch-scorecard")?.addEventListener("click", () => showScreen("scorecard"));
2078
+
2079
+ document.getElementById("btn-deal-readiness-end")?.addEventListener("click", () => {
2080
+ document.getElementById("deal-readiness-prompt")?.setAttribute("hidden", "");
2081
+ endDeal();
2082
+ });
2083
+ document.getElementById("btn-deal-readiness-continue")?.addEventListener("click", () => {
2084
+ document.getElementById("deal-readiness-prompt")?.setAttribute("hidden", "");
2085
+ document.getElementById("deal-input")?.focus();
2086
+ });
2087
+
2088
+ document.getElementById("btn-battle-readiness-end")?.addEventListener("click", () => {
2089
+ hideBattleReadiness();
2090
+ endBattle();
2091
+ });
2092
+ document.getElementById("btn-battle-readiness-continue")?.addEventListener("click", () => {
2093
+ hideBattleReadiness();
2094
+ userInput?.focus();
2095
+ });
2096
+
2097
+ document.querySelectorAll(".hint-chip").forEach((chip) => {
2098
+ chip.addEventListener("click", () => {
2099
+ const hint = chip.dataset.hint;
2100
+ if (!hint || !userInput) return;
2101
+ const val = userInput.value.trim();
2102
+ userInput.value = val ? `${val} ${hint}`.trim() : hint.trim();
2103
+ userInput.focus();
2104
+ });
2105
+ });
2106
+
2107
+ document.getElementById("btn-deal-view-negotiation")?.addEventListener("click", openNegotiationModal);
2108
+ document.getElementById("btn-close-negotiation")?.addEventListener("click", closeNegotiationModal);
2109
+ document.getElementById("btn-negotiation-back")?.addEventListener("click", closeNegotiationModal);
2110
+ document.getElementById("negotiation-overlay")?.addEventListener("click", (event) => {
2111
+ if (event.target?.id === "negotiation-overlay") closeNegotiationModal();
2112
+ });
2113
+
2114
+ document.getElementById("btn-deal-voice-send")?.addEventListener("click", () => {
2115
+ const transcript = document.getElementById("deal-voice-transcript")?.value?.trim();
2116
+ if (!transcript || !state.pendingDealVoiceTurn) return;
2117
+ sendDealRound(transcript, { voice_turn_id: state.pendingDealVoiceTurn.voice_turn_id });
2118
+ });
2119
+
2120
+ document.getElementById("btn-deal-voice-cancel")?.addEventListener("click", () => {
2121
+ document.getElementById("deal-voice-preview")?.setAttribute("hidden", "");
2122
+ state.pendingDealVoiceTurn = null;
2123
+ });
2124
+
2125
  function boot() {
2126
  console.log("PitchFight frontend booting...");
2127
  setGlobalLoading(false);
2128
  hideErrorBanner();
2129
+ initLandingIntro();
2130
+
2131
+ initVoiceUI({
2132
+ getSessionId: () => state.sessionId,
2133
+ getUiMode: () => state.uiMode,
2134
+ onPitchComplete: (data) => {
2135
+ state.pendingVoicePitch = data;
2136
+ fillVoiceExtractForm(data);
2137
+ showScreen("voiceConfirm");
2138
+ hideErrorBanner();
2139
+ },
2140
+ onTurnComplete: (data) => showVoiceTurnPreview(data),
2141
+ onRetryTurnComplete: (data) => showRetryVoicePreview(data),
2142
+ onDealTurnComplete: (data) => showDealVoicePreview(data),
2143
+ onError: (msg) => showErrorBanner(msg),
2144
+ });
2145
 
2146
  fetch("/health")
2147
  .then((response) => response.json())
frontend/styles.css CHANGED
The diff for this file is too large to render. See raw diff
 
frontend/voice.js ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Voice recording + API helpers for PitchFight AI Phase 7. */
2
+
3
+ let mediaRecorder = null;
4
+ let audioChunks = [];
5
+ let recordTimerInterval = null;
6
+ let recordStartMs = 0;
7
+ let currentRecordMode = null; // "pitch" | "turn"
8
+
9
+ const MIME_CANDIDATES = [
10
+ "audio/webm;codecs=opus",
11
+ "audio/webm",
12
+ "audio/ogg;codecs=opus",
13
+ "audio/mp4",
14
+ ];
15
+
16
+ function pickMimeType() {
17
+ if (!window.MediaRecorder) return "";
18
+ for (const m of MIME_CANDIDATES) {
19
+ if (MediaRecorder.isTypeSupported(m)) return m;
20
+ }
21
+ return "";
22
+ }
23
+
24
+ function mimeToFormat(mime) {
25
+ if (!mime) return "webm";
26
+ if (mime.includes("ogg")) return "ogg";
27
+ if (mime.includes("mp4")) return "m4a";
28
+ return "webm";
29
+ }
30
+
31
+ export function blobToBase64(blob) {
32
+ return new Promise((resolve, reject) => {
33
+ const reader = new FileReader();
34
+ reader.onloadend = () => {
35
+ const dataUrl = reader.result || "";
36
+ const base64 = String(dataUrl).split(",")[1] || "";
37
+ resolve(base64);
38
+ };
39
+ reader.onerror = reject;
40
+ reader.readAsDataURL(blob);
41
+ });
42
+ }
43
+
44
+ async function apiPost(path, body) {
45
+ const response = await fetch(path, {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/json" },
48
+ body: JSON.stringify(body),
49
+ });
50
+ const data = await response.json().catch(() => ({}));
51
+ if (!response.ok) {
52
+ throw new Error(data.error || data.detail || response.statusText);
53
+ }
54
+ return data;
55
+ }
56
+
57
+ export async function sendVoicePitch(base64, format) {
58
+ return apiPost("/api/voice-pitch", { audio: base64, audio_format: format });
59
+ }
60
+
61
+ export async function sendVoiceTurn(sessionId, base64, format) {
62
+ return apiPost("/api/voice-turn", {
63
+ session_id: sessionId,
64
+ audio: base64,
65
+ audio_format: format,
66
+ });
67
+ }
68
+
69
+ function formatTimer(seconds) {
70
+ const m = Math.floor(seconds / 60);
71
+ const s = seconds % 60;
72
+ return `${m}:${String(s).padStart(2, "0")}`;
73
+ }
74
+
75
+ function _retryModeActive() {
76
+ const overlay = document.getElementById("retry-overlay");
77
+ return overlay && !overlay.hidden;
78
+ }
79
+
80
+ function _dealModeActive() {
81
+ const dealScreen = document.getElementById("screen-deal");
82
+ return dealScreen?.classList.contains("active");
83
+ }
84
+
85
+ function _voiceContext() {
86
+ if (_dealModeActive()) return "deal";
87
+ if (_retryModeActive()) return "retry";
88
+ return "battle";
89
+ }
90
+
91
+ function setRecordingUI(active, mode) {
92
+ const pitchBtn = document.getElementById("btn-voice-pitch-record");
93
+ const turnBtn = document.getElementById("btn-voice-turn-record");
94
+ const retryBtn = document.getElementById("btn-retry-voice-record");
95
+ const dealBtn = document.getElementById("btn-deal-voice-record");
96
+ const pitchStatus = document.getElementById("voice-pitch-status");
97
+ const turnStatus = document.getElementById("voice-turn-status");
98
+ const retryStatus = document.getElementById("retry-voice-status");
99
+ const dealStatus = document.getElementById("deal-voice-status");
100
+ const pitchTimer = document.getElementById("voice-pitch-timer");
101
+ const turnTimer = document.getElementById("voice-turn-timer");
102
+ const retryTimer = document.getElementById("retry-voice-timer");
103
+ const dealTimer = document.getElementById("deal-voice-timer");
104
+ const ctx = _voiceContext();
105
+ const retryRecording = active && mode === "turn" && ctx === "retry";
106
+ const dealRecording = active && mode === "turn" && ctx === "deal";
107
+
108
+ [pitchBtn, turnBtn, retryBtn, dealBtn].forEach((btn) => btn?.classList.remove("recording"));
109
+ if (active && mode === "pitch" && pitchBtn) pitchBtn.classList.add("recording");
110
+ if (active && mode === "turn" && ctx === "battle" && turnBtn) turnBtn.classList.add("recording");
111
+ if (retryRecording && retryBtn) retryBtn.classList.add("recording");
112
+ if (dealRecording && dealBtn) dealBtn.classList.add("recording");
113
+
114
+ if (pitchStatus) pitchStatus.textContent = active && mode === "pitch" ? "Recording…" : "";
115
+ if (turnStatus) turnStatus.textContent = active && mode === "turn" && ctx === "battle" ? "Recording…" : "";
116
+ if (retryStatus) retryStatus.textContent = retryRecording ? "Recording…" : "";
117
+ if (dealStatus) dealStatus.textContent = dealRecording ? "Recording…" : "";
118
+ if (!active) {
119
+ if (pitchTimer) pitchTimer.textContent = "0:00";
120
+ if (turnTimer) turnTimer.textContent = "0:00";
121
+ if (retryTimer) retryTimer.textContent = "0:00";
122
+ if (dealTimer) dealTimer.textContent = "0:00";
123
+ }
124
+ }
125
+
126
+ function startTimer(el) {
127
+ recordStartMs = Date.now();
128
+ clearInterval(recordTimerInterval);
129
+ recordTimerInterval = setInterval(() => {
130
+ const sec = Math.floor((Date.now() - recordStartMs) / 1000);
131
+ if (el) el.textContent = formatTimer(sec);
132
+ }, 250);
133
+ }
134
+
135
+ function stopTimer() {
136
+ clearInterval(recordTimerInterval);
137
+ recordTimerInterval = null;
138
+ }
139
+
140
+ async function startRecording(mode) {
141
+ if (mediaRecorder?.state === "recording") return;
142
+ currentRecordMode = mode;
143
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
144
+ const mime = pickMimeType();
145
+ mediaRecorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
146
+ audioChunks = [];
147
+ mediaRecorder.ondataavailable = (e) => {
148
+ if (e.data.size > 0) audioChunks.push(e.data);
149
+ };
150
+ mediaRecorder.start(200);
151
+ let timerEl = document.getElementById("voice-pitch-timer");
152
+ if (mode === "turn") {
153
+ const ctx = _voiceContext();
154
+ timerEl = ctx === "deal"
155
+ ? document.getElementById("deal-voice-timer")
156
+ : ctx === "retry"
157
+ ? document.getElementById("retry-voice-timer")
158
+ : document.getElementById("voice-turn-timer");
159
+ }
160
+ startTimer(timerEl);
161
+ setRecordingUI(true, mode);
162
+ }
163
+
164
+ async function stopRecording() {
165
+ if (!mediaRecorder || mediaRecorder.state !== "recording") {
166
+ return { blob: null, format: "webm", mode: currentRecordMode };
167
+ }
168
+ const mode = currentRecordMode;
169
+ const mime = mediaRecorder.mimeType || "audio/webm";
170
+ const format = mimeToFormat(mime);
171
+
172
+ return new Promise((resolve) => {
173
+ mediaRecorder.onstop = () => {
174
+ stopTimer();
175
+ setRecordingUI(false, mode);
176
+ mediaRecorder.stream.getTracks().forEach((t) => t.stop());
177
+ const blob = new Blob(audioChunks, { type: mime });
178
+ audioChunks = [];
179
+ resolve({ blob, format, mode });
180
+ };
181
+ mediaRecorder.stop();
182
+ });
183
+ }
184
+
185
+ async function _handleTurnRecording(handlers, onComplete) {
186
+ if (mediaRecorder?.state === "recording" && currentRecordMode === "turn") {
187
+ const { blob, format } = await stopRecording();
188
+ if (!blob?.size) throw new Error("No audio captured.");
189
+ const base64 = await blobToBase64(blob);
190
+ const sessionId = handlers.getSessionId?.();
191
+ if (!sessionId) throw new Error("No active session.");
192
+ const data = await sendVoiceTurn(sessionId, base64, format);
193
+ if (data.error) throw new Error(data.error);
194
+ onComplete?.(data);
195
+ } else {
196
+ await startRecording("turn");
197
+ }
198
+ }
199
+
200
+ export function initVoiceUI(handlers = {}) {
201
+ const {
202
+ onPitchComplete,
203
+ onTurnComplete,
204
+ onRetryTurnComplete,
205
+ onDealTurnComplete,
206
+ onError,
207
+ } = handlers;
208
+
209
+ const resolveTurnComplete = (data) => {
210
+ const ctx = handlers.getUiMode?.() || _voiceContext();
211
+ if (ctx === "deal") onDealTurnComplete?.(data);
212
+ else if (ctx === "retry") (onRetryTurnComplete ?? onTurnComplete)?.(data);
213
+ else onTurnComplete?.(data);
214
+ };
215
+
216
+ document.getElementById("btn-voice-pitch-record")?.addEventListener("click", async () => {
217
+ try {
218
+ if (mediaRecorder?.state === "recording" && currentRecordMode === "pitch") {
219
+ const { blob, format } = await stopRecording();
220
+ if (!blob?.size) throw new Error("No audio captured.");
221
+ const base64 = await blobToBase64(blob);
222
+ const data = await sendVoicePitch(base64, format);
223
+ if (data.error) throw new Error(data.error);
224
+ onPitchComplete?.(data);
225
+ } else {
226
+ await startRecording("pitch");
227
+ }
228
+ } catch (err) {
229
+ onError?.(err.message || String(err));
230
+ }
231
+ });
232
+
233
+ document.getElementById("btn-voice-pitch-cancel")?.addEventListener("click", async () => {
234
+ if (mediaRecorder?.state === "recording") {
235
+ mediaRecorder.onstop = () => {
236
+ stopTimer();
237
+ setRecordingUI(false, "pitch");
238
+ mediaRecorder?.stream?.getTracks().forEach((t) => t.stop());
239
+ };
240
+ mediaRecorder.stop();
241
+ }
242
+ });
243
+
244
+ document.getElementById("btn-voice-turn-record")?.addEventListener("click", async () => {
245
+ try {
246
+ await _handleTurnRecording(handlers, resolveTurnComplete);
247
+ } catch (err) {
248
+ onError?.(err.message || String(err));
249
+ }
250
+ });
251
+
252
+ document.getElementById("btn-retry-voice-record")?.addEventListener("click", async () => {
253
+ try {
254
+ await _handleTurnRecording(handlers, resolveTurnComplete);
255
+ } catch (err) {
256
+ onError?.(err.message || String(err));
257
+ }
258
+ });
259
+
260
+ document.getElementById("btn-deal-voice-record")?.addEventListener("click", async () => {
261
+ try {
262
+ await _handleTurnRecording(handlers, resolveTurnComplete);
263
+ } catch (err) {
264
+ onError?.(err.message || String(err));
265
+ }
266
+ });
267
+
268
+ document.getElementById("btn-voice-turn-cancel")?.addEventListener("click", async () => {
269
+ if (mediaRecorder?.state === "recording") {
270
+ mediaRecorder.onstop = () => {
271
+ stopTimer();
272
+ setRecordingUI(false, "turn");
273
+ mediaRecorder?.stream?.getTracks().forEach((t) => t.stop());
274
+ };
275
+ mediaRecorder.stop();
276
+ }
277
+ document.getElementById("voice-turn-preview")?.setAttribute("hidden", "");
278
+ });
279
+ }
280
+
281
+ export function startVoicePitchRecording() {
282
+ return startRecording("pitch");
283
+ }
284
+
285
+ export function stopVoicePitchRecording() {
286
+ return stopRecording();
287
+ }
288
+
289
+ export function startVoiceTurnRecording() {
290
+ return startRecording("turn");
291
+ }
292
+
293
+ export function stopVoiceTurnRecording() {
294
+ return stopRecording();
295
+ }
requirements.txt CHANGED
@@ -7,3 +7,4 @@ numpy
7
  openai>=1.0.0
8
  httpx
9
  requests
 
 
7
  openai>=1.0.0
8
  httpx
9
  requests
10
+ pymongo[srv]==4.7.2