"""Deal scorecard + combined pitch/deal summary (Phase 9D).""" from __future__ import annotations import logging from typing import Any from core.deal_claim_extractor import ( extract_deal_signals, is_substantive_move, is_one_word_ack, ) from core.deal_persona_builder import build_compact_deal_context from core.judge_settings import get_scoring_calibration, normalize_difficulty from core.json_utils import ( parse_model_json, parse_json_object, safe_json_parse, extract_partial_string_fields, extract_partial_string_list, ends_abruptly, sanitize_for_log, ) from core import model_router logger = logging.getLogger(__name__) DEAL_DIMS = ( "anchoring", "evidence", "concession_control", "alternatives", "value_articulation", "closing", ) _DIM_LABELS = { "anchoring": "Anchoring", "evidence": "Evidence", "concession_control": "Concession Control", "alternatives": "Alternatives", "value_articulation": "Value Articulation", "closing": "Closing", } def _deal_score_label(score: int) -> str: if score >= 80: return "Strong" if score >= 60: return "Solid" if score >= 40: return "Developing" return "Weak" def _clamp(n: int, lo: int = 0, hi: int = 100) -> int: return max(lo, min(hi, n)) def _dim_entry(score: int, reason: str, quote: str = "") -> dict[str, Any]: return { "score": _clamp(score), "label": _deal_score_label(score), "reason": reason[:280], "quote": quote[:200], } def _best_user_quote(deal_history: list[dict]) -> str: users = [h.get("message", "") for h in deal_history if h.get("role") == "user"] if not users: return "" return max(users, key=lambda t: len(t.split()))[:200] def _weakest_user_quote(deal_history: list[dict], signals: dict) -> str: users = [h.get("message", "") for h in deal_history if h.get("role") == "user"] if not users: return "" if signals.get("weak_concession_signals"): for u in users: if any(w.lower() in u.lower() for w in signals["weak_concession_signals"]): return u[:200] return min(users, key=lambda t: len(t.split()))[:200] def _move_signal_strength(message: str) -> int: """Rank a single founder message by how much negotiation substance it carries.""" s = extract_deal_signals([{"role": "user", "message": message}]) score = 0 if s["evidence_signals"]: score += 2 if s["specific_numbers"]: score += 2 if s["counteroffers"] or s["tradeoffs"]: score += 2 if s["anchor_points"]: score += 1 if s["closing_signals"]: score += 1 if s["alternative_signals"]: score += 1 return score def select_best_and_weakest_deal_moves( deal_history: list[dict], scores: dict, signals: dict, ) -> dict[str, str]: """Pick best/weakest founder moves from SUBSTANTIVE messages only. One-word acknowledgements ("sure", "ok", "yes", "fine") are never eligible as the weakest move unless they actually conceded a term. Returns human-readable sentences, never a bare quote, so the scorecard explains the move rather than dumping a word. """ users = [str(h.get("message", "")).strip() for h in deal_history if h.get("role") == "user"] substantive = [u for u in users if is_substantive_move(u)] if not substantive: return { "best_move": "No substantive negotiation move was recorded.", "weakest_move": "No real counters were made — every reply was a bare acknowledgement.", "best_quote": "", "weakest_quote": "", } best_quote = max(substantive, key=_move_signal_strength) if _move_signal_strength(best_quote) == 0: best_quote = max(substantive, key=lambda t: len(t.split())) # Weakest: prefer a substantive message that conceded without extracting anything. weak_quote = "" for u in substantive: s = extract_deal_signals([{"role": "user", "message": u}]) if s["weak_concession_signals"] and not (s["counteroffers"] or s["tradeoffs"]): weak_quote = u break if not weak_quote: candidates = [u for u in substantive if u != best_quote] if candidates: low = min(candidates, key=_move_signal_strength) if _move_signal_strength(low) <= 1: weak_quote = low best_move = f'Your strongest moment: "{best_quote[:200]}"' if weak_quote: weakest_move = ( f'Watch this moment: "{weak_quote[:200]}" — you gave ground without ' "anchoring a counter or extracting a tradeoff." ) else: weakest_move = ( "No major single weak move detected; the main weakness was that " "alternatives and leverage were underdeveloped." ) return { "best_move": best_move, "weakest_move": weakest_move, "best_quote": best_quote, "weakest_quote": weak_quote, } def calculate_deal_dimension_scores( deal_signals: dict, deal_history: list[dict], deal_context: dict, difficulty_profile: str, ) -> dict[str, dict[str, Any]]: """Local rule-based deal dimension scores.""" cal = get_scoring_calibration(difficulty_profile) floor = cal.get("attempted_answer_floor", 33) user_turns = deal_signals.get("user_turns", 0) best_q = _best_user_quote(deal_history) weak_q = _weakest_user_quote(deal_history, deal_signals) if user_turns == 0: empty = _dim_entry(0, "No deal counters were submitted.", "") return {d: dict(empty) for d in DEAL_DIMS} anchors = deal_signals.get("anchor_points", []) numbers = deal_signals.get("specific_numbers", []) evidence = deal_signals.get("evidence_signals", []) weak_con = deal_signals.get("weak_concession_signals", []) concessions = deal_signals.get("concession_signals", []) alts = deal_signals.get("alternative_signals", []) value = deal_signals.get("value_signals", []) closing = deal_signals.get("closing_signals", []) counters = deal_signals.get("counteroffers", []) tradeoffs = deal_signals.get("tradeoffs", []) # Anchoring — repeated clear counters should not cap low. anchoring_score = floor if anchors and counters: anchoring_score = 78 if len(counters) >= 2 else 72 elif anchors or counters: anchoring_score = 60 elif numbers: anchoring_score = 50 evidence_score = floor if evidence and numbers: evidence_score = 74 elif evidence or numbers: evidence_score = 56 # Concession control — reward trading concessions for conditions; only punish a # bare giveaway with no counter/tradeoff. A harmless "sure" never lands here. concession_score = 52 if weak_con and not (counters or tradeoffs): concession_score = 32 elif tradeoffs and not weak_con: concession_score = 76 elif concessions and counters: concession_score = 70 elif concessions or tradeoffs: concession_score = 60 # Alternatives — credit implied leverage/options, not just exact BATNA wording. alt_score = 38 if alts and (numbers or tradeoffs): alt_score = 76 elif alts: alt_score = 66 value_score = floor if value and numbers: value_score = 72 elif value: value_score = 56 closing_score = 32 if closing and counters: closing_score = 76 elif closing: closing_score = 66 elif user_turns >= 3 and counters: closing_score = 50 raw = { "anchoring": anchoring_score, "evidence": evidence_score, "concession_control": concession_score, "alternatives": alt_score, "value_articulation": value_score, "closing": closing_score, } # Synergy: a well-rounded negotiation (≥5 dimensions already solid) earns a small # lift so a genuinely strong founder can crest into the 80s instead of capping low. if sum(1 for v in raw.values() if v >= 60) >= 5: raw = {k: _clamp(v + 6) for k, v in raw.items()} anchoring_score = raw["anchoring"] evidence_score = raw["evidence"] concession_score = raw["concession_control"] alt_score = raw["alternatives"] value_score = raw["value_articulation"] closing_score = raw["closing"] return { "anchoring": _dim_entry( anchoring_score, "Clear term anchors and counteroffers strengthen your position." if anchors else "Terms were not anchored with specific numbers or structure.", best_q, ), "evidence": _dim_entry( evidence_score, "Evidence-backed counters build credibility." if evidence else "Deal counters lacked proof points from traction or pilots.", best_q, ), "concession_control": _dim_entry( concession_score, "You gave up too much too fast." if weak_con else "Concession pacing was acceptable for this stage.", weak_q or best_q, ), "alternatives": _dim_entry( alt_score, "BATNA or alternatives mentioned." if alts else "No alternatives or leverage cited.", best_q, ), "value_articulation": _dim_entry( value_score, "Value and ROI were articulated." if value else "Fair value and ROI were under-explained.", best_q, ), "closing": _dim_entry( closing_score, "Closing signals present." if closing else "No concrete closing step proposed.", best_q, ), } def determine_deal_outcome(scores: dict, deal_history: list[dict], signals: dict) -> str: """Return deal outcome label.""" s = {k: int(v.get("score", 0)) for k, v in scores.items()} user_turns = signals.get("user_turns", 0) if user_turns == 0: return "no_deal" if signals.get("weak_concession_signals") and s["concession_control"] < 40: return "weak_concession" if ( s["anchoring"] >= 65 and s["evidence"] >= 60 and s["concession_control"] >= 55 and s["closing"] >= 55 ): return "strong_win" if s["closing"] >= 50 and s["value_articulation"] >= 50: return "favorable_partial" if s["concession_control"] >= 45 and s["anchoring"] >= 45: return "balanced" if s["closing"] < 35 and s["value_articulation"] < 40: return "no_deal" return "balanced" _DEAL_OUTCOME_LABELS = frozenset({ "strong_win", "favorable_partial", "balanced", "weak_concession", "no_deal", }) def _is_human_deal_summary(text: str) -> bool: t = (text or "").strip() if not t or len(t) < 25: return False normalized = t.lower().replace(" ", "_").replace("-", "_") if normalized in _DEAL_OUTCOME_LABELS: return False return not ends_abruptly(t) _OUTCOME_SUMMARIES = { "strong_win": "You held your position with evidence and moved toward concrete terms.", "favorable_partial": "You negotiated acceptably but left some value on the table.", "balanced": "A mixed negotiation — some strong counters alongside a few gaps.", "weak_concession": "You conceded too quickly without extracting tradeoffs in return.", "no_deal": "No closing path emerged — terms were not defended strongly enough.", } def humanize_deal_outcome(outcome: str) -> str: """Return a human-readable sentence for a deal outcome label.""" return _OUTCOME_SUMMARIES.get(outcome, _OUTCOME_SUMMARIES["balanced"]) # --------------------------------------------------------------------------- # Nemotron semantic scoring (Call 1) — primary judge for the 6 deal dimensions # --------------------------------------------------------------------------- _DEAL_SCORING_SCHEMA = ( '{"scores":{' '"anchoring":{"score":0,"reason":"","quote":""},' '"evidence":{"score":0,"reason":"","quote":""},' '"concession_control":{"score":0,"reason":"","quote":""},' '"alternatives":{"score":0,"reason":"","quote":""},' '"value_articulation":{"score":0,"reason":"","quote":""},' '"closing":{"score":0,"reason":"","quote":""}},' '"deal_outcome":"strong_win|favorable_partial|balanced|weak_concession|no_deal",' '"best_move":"","weakest_move":""}' ) def _build_deal_scoring_prompt( session: dict, signals: dict, local_scores: dict, ) -> list[dict[str, str]]: """Build the scoring-only messages for Nemotron (compact context, full founder turns).""" ctx = build_compact_deal_context(session) deal_history = session.get("deal_history") or [] # Full founder turns (these are what we score); judge turns truncated for context. transcript_lines: list[str] = [] for h in deal_history: role = "FOUNDER" if h.get("role") == "user" else "JUDGE" msg = str(h.get("message", "")).strip() if not msg: continue if role == "FOUNDER": transcript_lines.append(f"FOUNDER: {msg[:400]}") else: transcript_lines.append(f"JUDGE: {msg[:160]}") transcript = "\n".join(transcript_lines[-14:]) hints = ( f"anchors={signals.get('anchor_points', [])[:4]} " f"numbers={signals.get('specific_numbers', [])[:4]} " f"evidence={signals.get('evidence_signals', [])[:4]} " f"alternatives={signals.get('alternative_signals', [])[:4]} " f"tradeoffs={signals.get('tradeoffs', [])[:4]} " f"closing={signals.get('closing_signals', [])[:4]}" ) system = ( "You are an experienced startup negotiation judge scoring a founder's DEAL " "negotiation. Score SEMANTICALLY based on what the founder actually argued — " "not on keyword matching. Return ONLY one JSON object. First character {, last }. " "No markdown. No reasoning. No array.\n\n" "Score each of 6 dimensions 0-100:\n" " anchoring — did they anchor specific terms/numbers and hold a clear position?\n" " evidence — did they back terms with proof (traction, pilots, metrics)?\n" " concession_control — did they trade concessions for conditions, or give ground freely?\n" " alternatives — did they show leverage/options? Credit this even when phrased " "naturally ('we're also talking to other partners', 'we're not dependent on this') " "without the word BATNA.\n" " value_articulation — did they explain ROI / why the terms are fair?\n" " closing — did they push toward a concrete next step or commitment?\n\n" "Scoring rules:\n" "- Do NOT punish a harmless one-word acknowledgement like 'sure' or 'ok' unless it " "clearly conceded a term.\n" "- Pick weakest_move from a SUBSTANTIVE negotiation moment, never the shortest message.\n" "- Allow 80+ when the founder anchors, proves, keeps concession control, shows " "alternatives, articulates value, and closes.\n" "- Do not over-score vague confidence with no specifics.\n" "- quote must be copied from an actual FOUNDER message. Do not invent quotes.\n" "- Each reason: one short sentence.\n\n" f"REQUIRED JSON SCHEMA:\n{_DEAL_SCORING_SCHEMA}" ) user = ( f"Deal type: {ctx.get('deal_type_label', '')}\n" f"Founder ask: {ctx.get('ask', '')}\n" f"Judge opening offer: {ctx.get('opening_offer', '')}\n" f"Local signal hints (reference only, may be incomplete): {hints}\n" f"Local reference scores (do not just copy — judge for yourself): " f"{ {k: v.get('score') for k, v in local_scores.items()} }\n\n" f"NEGOTIATION TRANSCRIPT:\n{transcript}\n\n" "Score the 6 dimensions now. Output the JSON object only." ) return [{"role": "system", "content": system}, {"role": "user", "content": user}] def _extract_deal_scores(parsed: Any) -> dict[str, Any]: """Locate the 6-dimension scores dict, tolerant of model JSON shape. The model sometimes nests scores under "scores" and sometimes (after lossy JSON extraction) the dimensions land at the root. Handle both so a valid scorecard is never thrown away over a wrapper key. """ if not isinstance(parsed, dict): return {} raw = parsed.get("scores") if isinstance(raw, dict) and any(d in raw for d in DEAL_DIMS): return raw if any(d in parsed for d in DEAL_DIMS): return {d: parsed[d] for d in DEAL_DIMS if d in parsed} return {} def _validate_deal_scoring(parsed: Any) -> bool: """True if all 6 dims have a numeric score AND the scores are not all zero. Rejecting an all-zero result is deliberate: it filters out the empty repair skeleton (every score 0) so we fall back to local scoring instead of emitting a bogus overall of 0 for a real negotiation. """ scores = _extract_deal_scores(parsed) if not scores: return False total = 0.0 for dim in DEAL_DIMS: entry = scores.get(dim) if not isinstance(entry, dict): return False try: total += float(entry.get("score")) except (TypeError, ValueError): return False return total > 0 def _normalize_deal_scoring( parsed: dict, deal_history: list[dict], signals: dict, ) -> dict[str, Any]: """Clamp scores, attach labels, validate outcome, and resolve best/weakest moves.""" raw = _extract_deal_scores(parsed) scores: dict[str, dict[str, Any]] = {} for dim in DEAL_DIMS: entry = raw.get(dim, {}) if isinstance(raw.get(dim), dict) else {} try: val = int(round(float(entry.get("score", 0)))) except (TypeError, ValueError): val = 0 reason = str(entry.get("reason", "")).strip() or "Judged from the negotiation transcript." quote = str(entry.get("quote", "")).strip() scores[dim] = _dim_entry(val, reason, quote) outcome = str(parsed.get("deal_outcome", "")).strip().lower().replace(" ", "_") if outcome not in _DEAL_OUTCOME_LABELS: outcome = determine_deal_outcome(scores, deal_history, signals) # Best/weakest: trust the model only if its text is substantive; else derive locally. local_moves = select_best_and_weakest_deal_moves(deal_history, scores, signals) best_move = str(parsed.get("best_move", "")).strip() weakest_move = str(parsed.get("weakest_move", "")).strip() if len(best_move) < 12 or is_one_word_ack(best_move): best_move = local_moves["best_move"] if len(weakest_move) < 12 or is_one_word_ack(weakest_move): weakest_move = local_moves["weakest_move"] overall = round(sum(s["score"] for s in scores.values()) / len(scores)) return { "scores": scores, "deal_outcome": outcome, "best_move": best_move[:300], "weakest_move": weakest_move[:300], "overall": overall, "overall_label": _deal_score_label(overall), } def call_nemotron_deal_scoring( session: dict, signals: dict, local_scorecard: dict, ) -> dict[str, Any] | None: """Call 1 — Nemotron semantic scoring. Returns normalized scores or None on failure.""" messages = _build_deal_scoring_prompt(session, signals, local_scorecard.get("scores", {})) model_mode = session.get("model_mode", "premium_nvidia") result = model_router.generate_deal_scoring_response(messages, model_mode=model_mode) if not result.get("ok") or not result.get("content"): logger.warning("deal_scoring: Nemotron scoring call failed — %s", result.get("error")) return None parsed = safe_json_parse(result["content"]) if not _validate_deal_scoring(parsed): logger.warning( "deal_scoring: scoring JSON invalid, trying repair preview=%r", sanitize_for_log(result["content"]), ) repair = model_router.generate_deal_scoring_repair_response( result["content"], model_mode=model_mode ) if repair.get("ok") and repair.get("content"): parsed = safe_json_parse(repair["content"]) if not _validate_deal_scoring(parsed): logger.warning("deal_scoring: scoring fallback used — Nemotron scores unavailable") return None return _normalize_deal_scoring(parsed, session.get("deal_history", []), signals) def _parse_deal_coaching_json(raw: str) -> dict[str, Any]: """Best-effort parse of deal coaching JSON.""" parsed = parse_json_object( raw, string_fields=[ "deal_outcome_summary", "best_move", "weakest_move", "improved_response", "combined_summary", "next_best_action", ], ) if not parsed: parsed = extract_partial_string_fields(raw, [ "deal_outcome_summary", "best_move", "weakest_move", "improved_response", "combined_summary", "next_best_action", ]) result: dict[str, Any] = {} for key in ( "deal_outcome_summary", "best_move", "weakest_move", "improved_response", "combined_summary", "next_best_action", ): val = str(parsed.get(key, "")).strip() if not val: continue if key == "deal_outcome_summary" and not _is_human_deal_summary(val): continue if ends_abruptly(val) and key in ("best_move", "weakest_move", "next_best_action"): continue if ends_abruptly(val) and key == "improved_response" and len(val) < 40: continue result[key] = val q3 = parsed.get("top_3_prep_points") if not isinstance(q3, list) or len(q3) < 3: q3 = extract_partial_string_list(raw, "top_3_prep_points", min_items=3) if isinstance(q3, list): items = [str(q).strip() for q in q3 if str(q).strip() and not ends_abruptly(str(q))] if items: result["top_3_prep_points"] = items[:3] return result def _merge_deal_coaching(local: dict[str, Any], nemotron: dict[str, Any]) -> tuple[dict[str, Any], str]: merged = dict(local) hits = 0 for key in ( "deal_outcome_summary", "best_move", "weakest_move", "improved_response", "combined_summary", "next_best_action", ): val = str(nemotron.get(key, "")).strip() if val: merged[key] = val[:400 if key == "improved_response" else 300] hits += 1 n_q = nemotron.get("top_3_prep_points") if isinstance(n_q, list) and len(n_q) >= 3: merged["top_3_prep_points"] = [str(q).strip() for q in n_q[:3]] hits += 1 if hits >= 5: return merged, "nemotron" if hits > 0: return merged, "partial_nemotron_local" return merged, "local" def build_local_deal_coaching( session: dict, scores: dict, signals: dict, outcome: str, ) -> dict[str, Any]: """Local coaching text when Nemotron unavailable.""" deal_context = session.get("deal_context") or {} moves = select_best_and_weakest_deal_moves( session.get("deal_history", []), scores, signals ) weakest_dim = min(scores.items(), key=lambda x: x[1]["score"])[0] return { "deal_outcome_summary": humanize_deal_outcome(outcome), "best_move": moves["best_move"], "weakest_move": moves["weakest_move"], "improved_response": ( f"A stronger {weakest_dim.replace('_', ' ')} counter would anchor specific terms, " "cite one proof point, and propose a tradeoff instead of conceding." ), "top_3_prep_points": [ "Anchor every counter with a specific number or term.", "Cite one pilot metric before conceding on price or equity.", "Always propose a tradeoff — never concede without getting something back.", ], "combined_summary": "", "next_best_action": f"Practice {weakest_dim.replace('_', ' ')} in your next deal drill.", } def call_nemotron_deal_coaching( session: dict, local_scorecard: dict, signals: dict, ) -> dict[str, Any] | None: """Nemotron coaching for deal scorecard.""" deal_context = session.get("deal_context") or {} history_text = "\n".join( f"{h.get('role', '').upper()}: {h.get('message', '')[:200]}" for h in (session.get("deal_history") or [])[-12:] ) system = ( "You are a startup negotiation coach. Return ONLY valid JSON.\n" "Return one JSON object only. First character must be {. Last character must be }.\n" "No markdown. No reasoning. No array wrapper.\n" "Keep each field short and complete. Do not end mid-sentence.\n" "Use only provided deal history and signals. Do not hallucinate terms reached.\n" "deal_outcome_summary must be a human-readable explanation (2 sentences max), " "NOT a label like weak_concession or strong_win.\n\n" "FIELD LIMITS:\n" " deal_outcome_summary: 2 sentences max\n" " best_move: 1 sentence\n" " weakest_move: 1 sentence\n" " improved_response: 3-5 sentences\n" " each top_3_prep_points item: 1 sentence\n" " combined_summary: 2 sentences max\n" " next_best_action: 1 sentence\n\n" "REQUIRED JSON:\n" '{"deal_outcome_summary":"","best_move":"","weakest_move":"",' '"improved_response":"","top_3_prep_points":["","",""],' '"combined_summary":"","next_best_action":""}' ) user = ( f"Deal type: {deal_context.get('deal_type', '')}\n" f"Deal outcome: {local_scorecard.get('deal_outcome', '')}\n" f"Overall deal score: {local_scorecard.get('overall', 0)}\n" f"Dimension scores: {local_scorecard.get('scores', {})}\n" f"Signals: {signals}\n\n" f"Deal history:\n{history_text}\n" ) messages = [{"role": "system", "content": system}, {"role": "user", "content": user}] model_mode = session.get("model_mode", "premium_nvidia") result = model_router.generate_deal_scorecard_coaching_response(messages, model_mode=model_mode) if not result.get("ok") or not result.get("content"): return None raw = result["content"] local_coaching = build_local_deal_coaching( session, local_scorecard.get("scores", {}), signals, local_scorecard.get("deal_outcome", "balanced"), ) nemotron = _parse_deal_coaching_json(raw) if not nemotron.get("deal_outcome_summary"): logger.warning("deal_scoring: coaching parse failed, trying repair preview=%r", sanitize_for_log(raw)) repair = model_router.generate_deal_scorecard_repair_response(raw, model_mode=model_mode) if repair.get("ok") and repair.get("content"): repaired = _parse_deal_coaching_json(repair["content"]) for k, v in repaired.items(): if v and not nemotron.get(k): nemotron[k] = v merged, coaching_source = _merge_deal_coaching(local_coaching, nemotron) if coaching_source == "local": logger.warning("deal_scoring: coaching using local fallback preview=%r", sanitize_for_log(raw)) return None q3 = list(merged.get("top_3_prep_points") or local_coaching["top_3_prep_points"]) while len(q3) < 3: q3.append("Anchor terms with specific numbers.") merged["top_3_prep_points"] = q3[:3] merged["coaching_source"] = coaching_source return merged def build_combined_scorecard( session: dict, pitch_scorecard: dict, deal_scorecard: dict, coaching: dict | None = None, ) -> dict[str, Any]: """Build combined pitch + deal summary.""" pitch_overall = int(pitch_scorecard.get("overall", 0) or 0) deal_overall = int(deal_scorecard.get("overall", 0) or 0) combined = round(pitch_overall * 0.6 + deal_overall * 0.4) if pitch_overall >= 70 and deal_overall >= 70: profile = "Strong pitcher, strong negotiator" elif pitch_overall >= 65 and deal_overall < 55: profile = "Strong pitcher, developing negotiator" elif pitch_overall < 55 and deal_overall >= 65: profile = "Developing pitcher, strong negotiator" elif pitch_overall >= 50 and deal_overall >= 50: profile = "Promising founder, needs sharper proof and negotiation control" else: profile = "Early-stage founder, needs stronger fundamentals before investor conversations" if combined >= 80: combined_label = "Strong" elif combined >= 60: combined_label = "Solid" elif combined >= 40: combined_label = "Developing" else: combined_label = "Weak" coaching = coaching or {} summary = coaching.get("combined_summary") or ( f"Pitch scored {pitch_overall}/100; deal negotiation scored {deal_overall}/100. " f"Combined read: {profile}." ) return { "pitch_overall": pitch_overall, "deal_overall": deal_overall, "combined_overall": combined, "combined_label": combined_label, "founder_profile": profile, "summary": summary[:500], "next_best_action": coaching.get( "next_best_action", "Practice anchoring terms before your next investor conversation.", )[:200], } def build_local_deal_scorecard(session: dict, deal_signals: dict) -> dict[str, Any]: """Full local deal scorecard without Nemotron.""" difficulty = session.get("difficulty_profile") or normalize_difficulty( session.get("difficulty", "practice") ) deal_context = session.get("deal_context") or {} deal_history = session.get("deal_history") or [] scores = calculate_deal_dimension_scores( deal_signals, deal_history, deal_context, difficulty ) outcome = determine_deal_outcome(scores, deal_history, deal_signals) overall = round(sum(s["score"] for s in scores.values()) / len(scores)) coaching = build_local_deal_coaching(session, scores, deal_signals, outcome) return { "overall": overall, "overall_label": _deal_score_label(overall), "deal_outcome": outcome, "scores": scores, "deal_outcome_summary": coaching["deal_outcome_summary"], "best_move": coaching["best_move"], "weakest_move": coaching["weakest_move"], "improved_response": coaching["improved_response"], "top_3_prep_points": coaching["top_3_prep_points"], "concrete_signals_summary": { "anchor_points": deal_signals.get("anchor_points", [])[:5], "evidence_signals": deal_signals.get("evidence_signals", [])[:5], "specific_numbers": deal_signals.get("specific_numbers", [])[:5], "closing_signals": deal_signals.get("closing_signals", [])[:5], }, "scorecard_source": "hybrid_deal_local", "provider": "local", "model_ok": False, } def build_negotiation_transcript(session: dict) -> list[dict[str, Any]]: """Structured transcript for the 'View Negotiation Conversation' UI.""" transcript: list[dict[str, Any]] = [] for h in session.get("deal_history", []) or []: transcript.append({ "round": h.get("round"), "role": "judge" if h.get("role") == "judge" else "founder", "message": str(h.get("message", "")), "negotiation_tag": h.get("negotiation_tag", ""), "answer_quality": h.get("answer_quality", ""), "action": h.get("action", ""), "input_mode": h.get("input_mode", "") or "text", }) return transcript def generate_deal_scorecard(session: dict) -> dict[str, Any]: """Generate deal scorecard + combined summary using a split Nemotron call. Call 1 (deal_scorecard_scoring) is the PRIMARY judge for the 6 dimension scores and determines scorecard_source. Call 2 (deal_scorecard_coaching) only adds coaching text; its failure falls back to local coaching but never downgrades scorecard_source. """ if not session.get("deal_phase_active") and not session.get("deal_history"): return {"error": "No deal phase found. Complete a deal negotiation first."} session["deal_phase_active"] = False deal_signals = extract_deal_signals( session.get("deal_history", []), session.get("deal_context"), ) # Local scorecard: reference context for the model + safety fallback. scorecard = build_local_deal_scorecard(session, deal_signals) # --- Call 1: Nemotron semantic scoring (determines scorecard_source) --- nem_scoring = call_nemotron_deal_scoring(session, deal_signals, scorecard) if nem_scoring is not None: scorecard["scores"] = nem_scoring["scores"] scorecard["overall"] = nem_scoring["overall"] scorecard["overall_label"] = nem_scoring["overall_label"] scorecard["deal_outcome"] = nem_scoring["deal_outcome"] scorecard["best_move"] = nem_scoring["best_move"] scorecard["weakest_move"] = nem_scoring["weakest_move"] scorecard["deal_outcome_summary"] = humanize_deal_outcome(nem_scoring["deal_outcome"]) scorecard["scorecard_source"] = "nemotron_full" scorecard["provider"] = "nvidia" scorecard["model_ok"] = True else: scorecard["scorecard_source"] = "hybrid_deal_local" scorecard["provider"] = "local" scorecard["model_ok"] = False scorecard["model_error"] = "Nemotron deal scoring failed; used local scoring fallback." # --- Call 2: Nemotron coaching text (non-fatal; never downgrades source) --- coaching = call_nemotron_deal_coaching(session, scorecard, deal_signals) if coaching: if coaching.get("deal_outcome_summary"): scorecard["deal_outcome_summary"] = coaching["deal_outcome_summary"] scorecard["improved_response"] = coaching.get("improved_response", scorecard["improved_response"]) scorecard["top_3_prep_points"] = coaching.get("top_3_prep_points", scorecard["top_3_prep_points"]) # Only adopt the model's move text if it is substantive and we don't already # have a semantic-scoring move (scoring-path moves are preferred). if nem_scoring is None: if coaching.get("best_move") and not is_one_word_ack(coaching["best_move"]): scorecard["best_move"] = coaching["best_move"] if coaching.get("weakest_move") and not is_one_word_ack(coaching["weakest_move"]): scorecard["weakest_move"] = coaching["weakest_move"] scorecard["coaching_source"] = coaching.get("coaching_source", "nemotron") else: coaching = build_local_deal_coaching( session, scorecard["scores"], deal_signals, scorecard["deal_outcome"] ) scorecard["coaching_source"] = "local" pitch_scorecard = session.get("latest_scorecard") or {} combined = build_combined_scorecard(session, pitch_scorecard, scorecard, coaching) transcript = build_negotiation_transcript(session) session["deal_scorecard"] = scorecard session["combined_scorecard"] = combined return { "session_id": session.get("session_id", ""), "deal_scorecard": scorecard, "combined_scorecard": combined, "negotiation_transcript": transcript, }