Add text-only deck critique endpoint
#2
by keikei2023 - opened
- README.md +28 -0
- app.py +8 -3
- core/api_handlers.py +196 -7
- core/model_router.py +8 -0
- core/nvidia_client.py +8 -0
- tests/test_deck_critique.py +174 -0
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 |
-
|
| 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,
|
| 123 |
-
return
|
| 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 |
-
|
| 1130 |
-
""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1131 |
return {
|
| 1132 |
-
"
|
| 1133 |
-
"
|
| 1134 |
-
|
| 1135 |
-
|
| 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()
|