Add text-only deck critique endpoint

#2
by keikei2023 - opened
README.md CHANGED
@@ -243,6 +243,34 @@ MONGODB_URI=
243
 
244
  Never put API keys in frontend code or commit real secrets to GitHub.
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  ---
247
 
248
  ## Run locally
 
243
 
244
  Never put API keys in frontend code or commit real secrets to GitHub.
245
 
246
+ ### Deck critique API
247
+
248
+ `POST /api/deck-critique` supports a text-only MVP before vision upload support lands.
249
+
250
+ Request body accepts one of:
251
+
252
+ ```json
253
+ {
254
+ "deck_text": "Slide text pasted by the founder",
255
+ "model_mode": "premium_nvidia"
256
+ }
257
+ ```
258
+
259
+ or:
260
+
261
+ ```json
262
+ {
263
+ "slides": [
264
+ { "title": "Problem", "body": "Students miss relevant hackathons." },
265
+ { "title": "Traction", "body": "80 student interviews; 62 missed an event last month." }
266
+ ]
267
+ }
268
+ ```
269
+
270
+ or an existing `startup` object. The response includes `summary`, `strengths`, `risks`,
271
+ `recommended_fixes`, `next_pitch_question`, `provider`, and `model_ok`. If NVIDIA is
272
+ unavailable, the endpoint returns a local heuristic critique instead of a placeholder.
273
+
274
  ---
275
 
276
  ## Run locally
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
  # ---------------------------------------------------------------------------
@@ -153,6 +153,11 @@ def gradio_reset_session(payload: dict[str, Any]) -> dict[str, Any]:
153
  return handle_reset_session(payload)
154
 
155
 
 
 
 
 
 
156
  # ---------------------------------------------------------------------------
157
  # Frontend
158
  # ---------------------------------------------------------------------------
 
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
  # ---------------------------------------------------------------------------
 
153
  return handle_reset_session(payload)
154
 
155
 
156
+ @app.api(name="deck_critique")
157
+ def gradio_deck_critique(payload: dict[str, Any]) -> dict[str, Any]:
158
+ return handle_deck_critique(payload)
159
+
160
+
161
  # ---------------------------------------------------------------------------
162
  # Frontend
163
  # ---------------------------------------------------------------------------
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,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import importlib
3
+ import types
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest.mock import patch
7
+ import sys
8
+
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ if str(ROOT) not in sys.path:
12
+ sys.path.insert(0, str(ROOT))
13
+
14
+ # Keep the handler unit test lightweight. The app imports optional runtime
15
+ # clients at module import time, but this test never calls external services.
16
+ dotenv_module = types.ModuleType("dotenv")
17
+ dotenv_module.load_dotenv = lambda *args, **kwargs: None
18
+ sys.modules.setdefault("dotenv", dotenv_module)
19
+
20
+ openai_module = types.ModuleType("openai")
21
+ openai_module.OpenAI = object
22
+ openai_module.APIConnectionError = type("APIConnectionError", (Exception,), {})
23
+ openai_module.APIStatusError = type("APIStatusError", (Exception,), {})
24
+ openai_module.APITimeoutError = type("APITimeoutError", (Exception,), {})
25
+ sys.modules.setdefault("openai", openai_module)
26
+
27
+ pymongo_module = types.ModuleType("pymongo")
28
+ pymongo_module.MongoClient = object
29
+ pymongo_errors_module = types.ModuleType("pymongo.errors")
30
+ pymongo_collection_module = types.ModuleType("pymongo.collection")
31
+ pymongo_collection_module.Collection = object
32
+ pymongo_database_module = types.ModuleType("pymongo.database")
33
+ pymongo_database_module.Database = object
34
+ pymongo_errors_module.PyMongoError = type("PyMongoError", (Exception,), {})
35
+ pymongo_errors_module.ServerSelectionTimeoutError = type(
36
+ "ServerSelectionTimeoutError", (Exception,), {}
37
+ )
38
+ sys.modules.setdefault("pymongo", pymongo_module)
39
+ sys.modules.setdefault("pymongo.collection", pymongo_collection_module)
40
+ sys.modules.setdefault("pymongo.database", pymongo_database_module)
41
+ sys.modules.setdefault("pymongo.errors", pymongo_errors_module)
42
+
43
+
44
+ class FakeServer:
45
+ def __init__(self):
46
+ self.routes = []
47
+ self.api_routes = {}
48
+
49
+ def _route(self, method, path=None, **kwargs):
50
+ def decorator(func):
51
+ self.routes.append((method, path, kwargs, func))
52
+ return func
53
+
54
+ return decorator
55
+
56
+ def get(self, path, **kwargs):
57
+ return self._route("GET", path, **kwargs)
58
+
59
+ def post(self, path, **kwargs):
60
+ return self._route("POST", path, **kwargs)
61
+
62
+ def api(self, name):
63
+ def decorator(func):
64
+ self.api_routes[name] = func
65
+ return func
66
+
67
+ return decorator
68
+
69
+ def mount(self, *args, **kwargs):
70
+ return None
71
+
72
+
73
+ fastapi_module = types.ModuleType("fastapi")
74
+ fastapi_module.Body = lambda *args, **kwargs: None
75
+ fastapi_responses_module = types.ModuleType("fastapi.responses")
76
+ fastapi_responses_module.HTMLResponse = str
77
+ fastapi_staticfiles_module = types.ModuleType("fastapi.staticfiles")
78
+ fastapi_staticfiles_module.StaticFiles = lambda *args, **kwargs: object()
79
+ gradio_module = types.ModuleType("gradio")
80
+ gradio_module.Server = FakeServer
81
+ sys.modules.setdefault("fastapi", fastapi_module)
82
+ sys.modules.setdefault("fastapi.responses", fastapi_responses_module)
83
+ sys.modules.setdefault("fastapi.staticfiles", fastapi_staticfiles_module)
84
+ sys.modules.setdefault("gradio", gradio_module)
85
+
86
+ from core.api_handlers import handle_deck_critique
87
+
88
+
89
+ DECK_TEXT = """
90
+ EventRadar AI
91
+ Problem: students miss hackathons because events are scattered across WhatsApp, LinkedIn, Luma, and club pages.
92
+ Solution: an AI event discovery assistant ranks events by skills, goals, location, and deadline urgency.
93
+ Users: college students and student founders.
94
+ Traction: tested with 80 students; 62 missed at least one relevant event last month.
95
+ Ask: mentor feedback and pilot introductions.
96
+ """
97
+
98
+
99
+ class DeckCritiqueTests(unittest.TestCase):
100
+ def test_deck_critique_returns_local_fallback_instead_of_placeholder(self):
101
+ with patch(
102
+ "core.api_handlers.model_router.generate_deck_critique_response",
103
+ return_value={
104
+ "ok": False,
105
+ "model_mode": "premium_nvidia",
106
+ "provider": "nvidia",
107
+ "content": "",
108
+ "error": "model unavailable",
109
+ },
110
+ ):
111
+ result = handle_deck_critique({"deck_text": DECK_TEXT})
112
+
113
+ self.assertTrue(result["ok"])
114
+ self.assertNotEqual(result.get("status"), "not_implemented")
115
+ self.assertEqual(result["provider"], "local")
116
+ self.assertFalse(result["model_ok"])
117
+ self.assertTrue(result["summary"])
118
+ self.assertGreaterEqual(len(result["strengths"]), 1)
119
+ self.assertGreaterEqual(len(result["risks"]), 1)
120
+ self.assertGreaterEqual(len(result["recommended_fixes"]), 1)
121
+ self.assertTrue(result["next_pitch_question"].endswith("?"))
122
+
123
+ def test_deck_critique_requires_text_or_startup_context(self):
124
+ result = handle_deck_critique({})
125
+
126
+ self.assertFalse(result["ok"])
127
+ self.assertIn("deck_text", result["error"])
128
+
129
+ def test_deck_critique_uses_model_json_when_available(self):
130
+ model_payload = {
131
+ "summary": "The deck has a focused student-founder wedge but needs sharper proof.",
132
+ "strengths": ["Clear student pain", "Specific initial traction"],
133
+ "risks": ["Business model is still vague"],
134
+ "recommended_fixes": ["Add one slide on who pays and why this is urgent"],
135
+ "next_pitch_question": "Who pays for this, and what evidence shows they will pay?",
136
+ }
137
+
138
+ with patch(
139
+ "core.api_handlers.model_router.generate_deck_critique_response",
140
+ return_value={
141
+ "ok": True,
142
+ "model_mode": "premium_nvidia",
143
+ "provider": "nvidia",
144
+ "content": json.dumps(model_payload),
145
+ "error": None,
146
+ },
147
+ create=True,
148
+ ):
149
+ result = handle_deck_critique(
150
+ {"deck_text": DECK_TEXT, "model_mode": "premium_nvidia"}
151
+ )
152
+
153
+ self.assertTrue(result["ok"])
154
+ self.assertTrue(result["model_ok"])
155
+ self.assertEqual(result["provider"], "nvidia")
156
+ self.assertEqual(result["summary"], model_payload["summary"])
157
+ self.assertEqual(result["recommended_fixes"], model_payload["recommended_fixes"])
158
+
159
+ def test_deck_critique_is_registered_as_gradio_api(self):
160
+ pitchfight_app = importlib.import_module("app")
161
+
162
+ self.assertIn("deck_critique", pitchfight_app.app.api_routes)
163
+ with patch(
164
+ "app.handle_deck_critique",
165
+ return_value={"ok": True, "summary": "registered"},
166
+ ) as handler:
167
+ result = pitchfight_app.app.api_routes["deck_critique"]({"deck_text": DECK_TEXT})
168
+
169
+ handler.assert_called_once_with({"deck_text": DECK_TEXT})
170
+ self.assertEqual(result["summary"], "registered")
171
+
172
+
173
+ if __name__ == "__main__":
174
+ unittest.main()