keikei2023 commited on
Commit
fc20048
·
verified ·
1 Parent(s): 758a2dd

Add text-only deck critique endpoint

Browse files

Wires /api/deck-critique to return a structured text-only critique before vision upload support lands.

- accepts deck_text/slides/startup context
- uses NVIDIA JSON mode when available
- falls back to local heuristics when the model is unavailable
- adds focused unittest coverage for validation, fallback, and model JSON paths

Verified with:
- python3 -m unittest tests/test_deck_critique.py -v
- python3 -m py_compile app.py core/*.py tests/test_deck_critique.py

app.py CHANGED
@@ -13,7 +13,7 @@ from gradio import Server
13
 
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,
@@ -119,8 +119,8 @@ def api_end_deal(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
119
 
120
 
121
  @app.post("/api/deck-critique")
122
- def api_deck_critique(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, str]:
123
- return handle_deck_critique_placeholder(payload)
124
 
125
 
126
  # ---------------------------------------------------------------------------
 
13
 
14
  from core.api_handlers import (
15
  handle_chat_round,
16
+ handle_deck_critique,
17
  handle_end_battle,
18
  handle_end_deal,
19
  handle_deal_round,
 
119
 
120
 
121
  @app.post("/api/deck-critique")
122
+ def api_deck_critique(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
123
+ return handle_deck_critique(payload)
124
 
125
 
126
  # ---------------------------------------------------------------------------
core/api_handlers.py CHANGED
@@ -1126,12 +1126,201 @@ def handle_deal_session_placeholder(_payload: dict[str, Any] | None = None) -> d
1126
  }
1127
 
1128
 
1129
- def handle_deck_critique_placeholder(_payload: dict[str, Any] | None = None) -> dict[str, str]:
1130
- """Reserved endpoint for pitch deck critique."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1131
  return {
1132
- "status": "not_implemented",
1133
- "message": (
1134
- "Deck critique endpoint is reserved and will be connected "
1135
- "after MiniCPM-V vision integration."
1136
- ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1137
  }
 
 
 
 
 
 
1126
  }
1127
 
1128
 
1129
+ _DECK_TEXT_FIELDS = (
1130
+ "deck_text",
1131
+ "pitch_text",
1132
+ "slides_text",
1133
+ "content",
1134
+ )
1135
+
1136
+
1137
+ def _coerce_text_list(value: Any) -> list[str]:
1138
+ if not isinstance(value, list):
1139
+ return []
1140
+ items: list[str] = []
1141
+ for item in value:
1142
+ if isinstance(item, str):
1143
+ text = item.strip()
1144
+ if text:
1145
+ items.append(text)
1146
+ elif isinstance(item, dict):
1147
+ title = str(item.get("title") or item.get("heading") or "").strip()
1148
+ body = str(item.get("body") or item.get("text") or item.get("content") or "").strip()
1149
+ combined = ": ".join(part for part in (title, body) if part)
1150
+ if combined:
1151
+ items.append(combined)
1152
+ return items
1153
+
1154
+
1155
+ def _deck_text_from_payload(payload: dict[str, Any]) -> str:
1156
+ for field in _DECK_TEXT_FIELDS:
1157
+ value = payload.get(field)
1158
+ if isinstance(value, str) and value.strip():
1159
+ return value.strip()
1160
+
1161
+ slide_items = _coerce_text_list(payload.get("slides"))
1162
+ if slide_items:
1163
+ return "\n".join(slide_items)
1164
+
1165
+ startup = payload.get("startup")
1166
+ if isinstance(startup, dict):
1167
+ lines = []
1168
+ for field in _STARTUP_CONTEXT_FIELDS:
1169
+ value = str(startup.get(field) or "").strip()
1170
+ if value:
1171
+ lines.append(f"{field.replace('_', ' ').title()}: {value}")
1172
+ return "\n".join(lines).strip()
1173
+
1174
+ return ""
1175
+
1176
+
1177
+ def _as_string_list(value: Any, fallback: list[str]) -> list[str]:
1178
+ if isinstance(value, list):
1179
+ cleaned = [str(item).strip() for item in value if str(item).strip()]
1180
+ if cleaned:
1181
+ return cleaned[:5]
1182
+ if isinstance(value, str) and value.strip():
1183
+ return [value.strip()]
1184
+ return fallback
1185
+
1186
+
1187
+ def _local_deck_critique(deck_text: str) -> dict[str, Any]:
1188
+ text = re.sub(r"\s+", " ", deck_text).strip()
1189
+ lower = text.lower()
1190
+ has_number = bool(re.search(r"\d", text))
1191
+ has_user = bool(re.search(r"\b(user|users|student|students|customer|customers|founder|founders)\b", lower))
1192
+ has_problem = "problem" in lower or "pain" in lower or "miss" in lower
1193
+ has_solution = "solution" in lower or "assistant" in lower or "platform" in lower or "app" in lower
1194
+ has_business = bool(re.search(r"\b(revenue|pricing|pay|paid|charge|business model|buyer|customer)\b", lower))
1195
+ has_competition = bool(re.search(r"\b(competitor|competitors|alternative|instead of|versus|vs\.?)\b", lower))
1196
+
1197
+ strengths: list[str] = []
1198
+ if has_problem and has_user:
1199
+ strengths.append("Names a clear user pain and target audience.")
1200
+ if has_solution:
1201
+ strengths.append("Explains the product direction in concrete terms.")
1202
+ if has_number:
1203
+ strengths.append("Includes at least one number that can become proof.")
1204
+ if not strengths:
1205
+ strengths.append("Gives enough raw material to start a pitch critique.")
1206
+
1207
+ risks: list[str] = []
1208
+ if not has_business:
1209
+ risks.append("The deck does not yet make the business model or buyer clear.")
1210
+ if not has_competition:
1211
+ risks.append("The competitive wedge is underdeveloped.")
1212
+ if not has_number:
1213
+ risks.append("The deck needs measurable proof, not only claims.")
1214
+ if not risks:
1215
+ risks.append("The story is promising; the main risk is whether the proof is strong enough under judge pressure.")
1216
+
1217
+ recommended_fixes: list[str] = []
1218
+ if not has_business:
1219
+ recommended_fixes.append("Add one slide that says who pays, why they pay, and the first realistic price point.")
1220
+ if not has_competition:
1221
+ recommended_fixes.append("Add a comparison slide showing why users choose this over the closest existing workflow.")
1222
+ if not has_number:
1223
+ recommended_fixes.append("Add one traction or validation number that a judge can remember.")
1224
+ recommended_fixes.append("Rewrite the weakest claim as: claim, evidence, and why it matters now.")
1225
+
1226
+ summary = (
1227
+ "The deck has a usable pitch foundation, but it needs sharper evidence "
1228
+ "and a clearer business case before a serious judge round."
1229
+ )
1230
+ next_question = (
1231
+ "What single metric proves this is a real user pain rather than a nice-to-have idea?"
1232
+ if has_business
1233
+ else "Who pays for this, and what evidence shows they will pay?"
1234
+ )
1235
+
1236
  return {
1237
+ "summary": summary,
1238
+ "strengths": strengths[:5],
1239
+ "risks": risks[:5],
1240
+ "recommended_fixes": recommended_fixes[:5],
1241
+ "next_pitch_question": next_question,
1242
+ }
1243
+
1244
+
1245
+ def _build_deck_critique_messages(deck_text: str) -> list[dict[str, str]]:
1246
+ return [
1247
+ {
1248
+ "role": "system",
1249
+ "content": (
1250
+ "You are PitchFight AI's pitch deck coach. Review text extracted from a founder's pitch deck. "
1251
+ "Return ONLY valid JSON with this schema: "
1252
+ '{"summary":"","strengths":[""],"risks":[""],"recommended_fixes":[""],"next_pitch_question":""}. '
1253
+ "Be direct, specific, and useful for a student founder. Do not use markdown."
1254
+ ),
1255
+ },
1256
+ {
1257
+ "role": "user",
1258
+ "content": (
1259
+ "Critique this pitch deck text. Focus on clarity, proof, differentiation, business model, "
1260
+ "and what a judge would challenge first.\n\n"
1261
+ + deck_text[:6000]
1262
+ ),
1263
+ },
1264
+ ]
1265
+
1266
+
1267
+ def handle_deck_critique(_payload: dict[str, Any] | None = None) -> dict[str, Any]:
1268
+ """Return a text-only pitch deck critique.
1269
+
1270
+ This keeps the endpoint useful before MiniCPM-V vision upload support lands.
1271
+ """
1272
+ payload = _payload or {}
1273
+ deck_text = _deck_text_from_payload(payload)
1274
+ if len(deck_text.strip()) < 20:
1275
+ return {
1276
+ "ok": False,
1277
+ "error": "deck_text, slides, or startup context is required for deck critique.",
1278
+ "provider": "none",
1279
+ "model_ok": False,
1280
+ }
1281
+
1282
+ model_mode = str(payload.get("model_mode") or "premium_nvidia")
1283
+ local = _local_deck_critique(deck_text)
1284
+
1285
+ model_error: str | None = None
1286
+ try:
1287
+ result = model_router.generate_deck_critique_response(
1288
+ _build_deck_critique_messages(deck_text),
1289
+ model_mode=model_mode,
1290
+ )
1291
+ if result.get("ok") and result.get("content"):
1292
+ parsed, _repair_needed = parse_model_json(result["content"])
1293
+ if isinstance(parsed, dict) and parsed:
1294
+ return {
1295
+ "ok": True,
1296
+ "provider": result.get("provider", "nvidia"),
1297
+ "model_mode": result.get("model_mode", model_mode),
1298
+ "model_ok": True,
1299
+ "summary": str(parsed.get("summary") or local["summary"]).strip(),
1300
+ "strengths": _as_string_list(parsed.get("strengths"), local["strengths"]),
1301
+ "risks": _as_string_list(parsed.get("risks"), local["risks"]),
1302
+ "recommended_fixes": _as_string_list(
1303
+ parsed.get("recommended_fixes"), local["recommended_fixes"]
1304
+ ),
1305
+ "next_pitch_question": str(
1306
+ parsed.get("next_pitch_question") or local["next_pitch_question"]
1307
+ ).strip(),
1308
+ }
1309
+ model_error = result.get("error") or "Model returned empty deck critique."
1310
+ except Exception as exc:
1311
+ logger.warning("handle_deck_critique: model critique failed: %s", exc)
1312
+ model_error = str(exc)
1313
+
1314
+ return {
1315
+ "ok": True,
1316
+ "provider": "local",
1317
+ "model_mode": "local_fallback",
1318
+ "model_ok": False,
1319
+ **local,
1320
+ **({"model_error": model_error} if model_error else {}),
1321
  }
1322
+
1323
+
1324
+ def handle_deck_critique_placeholder(_payload: dict[str, Any] | None = None) -> dict[str, Any]:
1325
+ """Backward-compatible wrapper for older imports."""
1326
+ return handle_deck_critique(_payload)
core/model_router.py CHANGED
@@ -606,6 +606,14 @@ def generate_structure_pitch_response(
606
  return _call_nvidia_json_mode(messages, "structure_pitch", model_mode, "structure pitch")
607
 
608
 
 
 
 
 
 
 
 
 
609
  def generate_structure_pitch_repair_response(
610
  raw_bad_content: str,
611
  model_mode: str | None = None,
 
606
  return _call_nvidia_json_mode(messages, "structure_pitch", model_mode, "structure pitch")
607
 
608
 
609
+ def generate_deck_critique_response(
610
+ messages: list[dict[str, str]],
611
+ model_mode: str | None = None,
612
+ ) -> dict[str, Any]:
613
+ """Generate text-only pitch deck critique JSON."""
614
+ return _call_nvidia_json_mode(messages, "deck_critique", model_mode, "deck critique")
615
+
616
+
617
  def generate_structure_pitch_repair_response(
618
  raw_bad_content: str,
619
  model_mode: str | None = None,
core/nvidia_client.py CHANGED
@@ -225,6 +225,13 @@ _TASK_DEFAULTS: dict[str, dict[str, Any]] = {
225
  "temperature": 0.0,
226
  "top_p": 0.95,
227
  },
 
 
 
 
 
 
 
228
  }
229
 
230
  _VALID_AUDIO_FORMATS = frozenset({"webm", "wav", "mp3", "m4a", "ogg"})
@@ -258,6 +265,7 @@ _JSON_MODES: frozenset[str] = frozenset({
258
  "deal_scorecard_scoring_repair",
259
  "deal_scorecard_coaching",
260
  "deal_scorecard_repair",
 
261
  })
262
 
263
 
 
225
  "temperature": 0.0,
226
  "top_p": 0.95,
227
  },
228
+ "deck_critique": {
229
+ "enable_thinking": False,
230
+ "reasoning_budget": 0,
231
+ "max_tokens": 1800,
232
+ "temperature": 0.2,
233
+ "top_p": 0.95,
234
+ },
235
  }
236
 
237
  _VALID_AUDIO_FORMATS = frozenset({"webm", "wav", "mp3", "m4a", "ogg"})
 
265
  "deal_scorecard_scoring_repair",
266
  "deal_scorecard_coaching",
267
  "deal_scorecard_repair",
268
+ "deck_critique",
269
  })
270
 
271
 
tests/test_deck_critique.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import types
3
+ import unittest
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+ import sys
7
+
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ if str(ROOT) not in sys.path:
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ # Keep the handler unit test lightweight. The app imports optional runtime
14
+ # clients at module import time, but this test never calls external services.
15
+ dotenv_module = types.ModuleType("dotenv")
16
+ dotenv_module.load_dotenv = lambda *args, **kwargs: None
17
+ sys.modules.setdefault("dotenv", dotenv_module)
18
+
19
+ openai_module = types.ModuleType("openai")
20
+ openai_module.OpenAI = object
21
+ openai_module.APIConnectionError = type("APIConnectionError", (Exception,), {})
22
+ openai_module.APIStatusError = type("APIStatusError", (Exception,), {})
23
+ openai_module.APITimeoutError = type("APITimeoutError", (Exception,), {})
24
+ sys.modules.setdefault("openai", openai_module)
25
+
26
+ pymongo_module = types.ModuleType("pymongo")
27
+ pymongo_module.MongoClient = object
28
+ pymongo_errors_module = types.ModuleType("pymongo.errors")
29
+ pymongo_collection_module = types.ModuleType("pymongo.collection")
30
+ pymongo_collection_module.Collection = object
31
+ pymongo_database_module = types.ModuleType("pymongo.database")
32
+ pymongo_database_module.Database = object
33
+ pymongo_errors_module.PyMongoError = type("PyMongoError", (Exception,), {})
34
+ pymongo_errors_module.ServerSelectionTimeoutError = type(
35
+ "ServerSelectionTimeoutError", (Exception,), {}
36
+ )
37
+ sys.modules.setdefault("pymongo", pymongo_module)
38
+ sys.modules.setdefault("pymongo.collection", pymongo_collection_module)
39
+ sys.modules.setdefault("pymongo.database", pymongo_database_module)
40
+ sys.modules.setdefault("pymongo.errors", pymongo_errors_module)
41
+
42
+ from core.api_handlers import handle_deck_critique
43
+
44
+
45
+ DECK_TEXT = """
46
+ EventRadar AI
47
+ Problem: students miss hackathons because events are scattered across WhatsApp, LinkedIn, Luma, and club pages.
48
+ Solution: an AI event discovery assistant ranks events by skills, goals, location, and deadline urgency.
49
+ Users: college students and student founders.
50
+ Traction: tested with 80 students; 62 missed at least one relevant event last month.
51
+ Ask: mentor feedback and pilot introductions.
52
+ """
53
+
54
+
55
+ class DeckCritiqueTests(unittest.TestCase):
56
+ def test_deck_critique_returns_local_fallback_instead_of_placeholder(self):
57
+ with patch(
58
+ "core.api_handlers.model_router.generate_deck_critique_response",
59
+ return_value={
60
+ "ok": False,
61
+ "model_mode": "premium_nvidia",
62
+ "provider": "nvidia",
63
+ "content": "",
64
+ "error": "model unavailable",
65
+ },
66
+ ):
67
+ result = handle_deck_critique({"deck_text": DECK_TEXT})
68
+
69
+ self.assertTrue(result["ok"])
70
+ self.assertNotEqual(result.get("status"), "not_implemented")
71
+ self.assertEqual(result["provider"], "local")
72
+ self.assertFalse(result["model_ok"])
73
+ self.assertTrue(result["summary"])
74
+ self.assertGreaterEqual(len(result["strengths"]), 1)
75
+ self.assertGreaterEqual(len(result["risks"]), 1)
76
+ self.assertGreaterEqual(len(result["recommended_fixes"]), 1)
77
+ self.assertTrue(result["next_pitch_question"].endswith("?"))
78
+
79
+ def test_deck_critique_requires_text_or_startup_context(self):
80
+ result = handle_deck_critique({})
81
+
82
+ self.assertFalse(result["ok"])
83
+ self.assertIn("deck_text", result["error"])
84
+
85
+ def test_deck_critique_uses_model_json_when_available(self):
86
+ model_payload = {
87
+ "summary": "The deck has a focused student-founder wedge but needs sharper proof.",
88
+ "strengths": ["Clear student pain", "Specific initial traction"],
89
+ "risks": ["Business model is still vague"],
90
+ "recommended_fixes": ["Add one slide on who pays and why this is urgent"],
91
+ "next_pitch_question": "Who pays for this, and what evidence shows they will pay?",
92
+ }
93
+
94
+ with patch(
95
+ "core.api_handlers.model_router.generate_deck_critique_response",
96
+ return_value={
97
+ "ok": True,
98
+ "model_mode": "premium_nvidia",
99
+ "provider": "nvidia",
100
+ "content": json.dumps(model_payload),
101
+ "error": None,
102
+ },
103
+ create=True,
104
+ ):
105
+ result = handle_deck_critique(
106
+ {"deck_text": DECK_TEXT, "model_mode": "premium_nvidia"}
107
+ )
108
+
109
+ self.assertTrue(result["ok"])
110
+ self.assertTrue(result["model_ok"])
111
+ self.assertEqual(result["provider"], "nvidia")
112
+ self.assertEqual(result["summary"], model_payload["summary"])
113
+ self.assertEqual(result["recommended_fixes"], model_payload["recommended_fixes"])
114
+
115
+
116
+ if __name__ == "__main__":
117
+ unittest.main()