PITCHFIGHT_AI / core /judge_settings.py
Aspectgg's picture
Prepare PitchFight AI completion
8fab536
Raw
History Blame Contribute Delete
12.5 kB
"""Difficulty profile loader for PitchFight AI.
Reads config/judge_settings.json and exposes typed accessors for:
- question style (tone, jargon avoidance, max sentences)
- scoring calibration (floors, ranges per profile)
- battle phase tone
- coaching style
Safe defaults are embedded here so the app works even if the JSON is missing.
"""
from __future__ import annotations
import json
import logging
import os
from typing import Any
logger = logging.getLogger(__name__)
_CONFIG_PATH = os.path.join(
os.path.dirname(__file__), "..", "config", "judge_settings.json"
)
# ---------------------------------------------------------------------------
# Alias normalization map
# ---------------------------------------------------------------------------
_ALIAS_MAP: dict[str, str] = {
# practice
"practice": "practice",
"easy": "practice",
"beginner": "practice",
"student": "practice",
# judge
"judge": "judge",
"medium": "judge",
"balanced": "judge",
"hackathon": "judge",
"high": "judge", # legacy frontend sent "high"
# investor
"investor": "investor",
"hard": "investor",
"vc": "investor",
"expert": "investor",
}
# ---------------------------------------------------------------------------
# Hardcoded safe defaults (used when config file is absent or invalid)
# ---------------------------------------------------------------------------
_DEFAULT_SETTINGS: dict[str, Any] = {
"default_profile": "practice",
"profiles": {
"practice": {
"label": "Practice Mode",
"description": "Student-friendly pitch practice for first/second-time founders.",
"profile_display": {
"pressure_labels": {
"explore": "Warm-up",
"pressure": "Focused",
"close": "Final Practice",
},
},
"question_style": {
"plain_language": True,
"avoid_jargon": True,
"avoid_compound_questions": True,
"one_question_only": True,
"max_sentences": 3,
"tone": "challenging_but_supportive",
"instruction": (
"This founder is practicing for the first or second time. "
"Challenge them enough to help them improve, but do not destroy confidence. "
"Ask one clear focused question at a time. "
"Use plain language and avoid jargon-heavy VC terminology."
),
},
"coaching_style": {
"tone": "encouraging_actionable",
"instruction": (
"Be encouraging but honest. Explain what the founder did right, "
"then show exactly how to make the answer stronger with one specific number, "
"example, or proof point."
),
"example": (
"Your answer touched on the right idea. "
"Here's how to make it more convincing with one specific number or example."
),
},
"battle_phase_tone": {
"explore": "medium",
"pressure": "medium_high_but_clear",
"close": "firm_but_supportive",
},
"scoring_calibration": {
"attempted_answer_floor": 35,
"partial_signal_floor": 40,
"concrete_signal_floor": 52,
"non_answer_max": 20,
"startup_context_max": 45,
"vague_on_topic_range": [35, 45],
"one_concrete_signal_range": [52, 62],
"strong_answer_range": [71, 85],
"excellent_answer_range": [86, 100],
},
},
"judge": {
"label": "Judge Mode",
"description": "Balanced hackathon judge simulation.",
"profile_display": {
"pressure_labels": {
"explore": "Moderate",
"pressure": "High",
"close": "Panel Ready",
},
},
"question_style": {
"plain_language": True,
"avoid_jargon": True,
"avoid_compound_questions": True,
"one_question_only": True,
"max_sentences": 3,
"tone": "realistic_hackathon_judge",
"instruction": (
"Act like a realistic hackathon judge. Be sharp and specific, "
"but keep questions understandable. Ask one clear question at a time "
"and focus on demo strength, novelty, user pain, and feasibility."
),
},
"coaching_style": {
"tone": "balanced_judge_feedback",
"instruction": (
"Be direct and fair. Highlight what would convince a hackathon judge "
"and what still needs proof before demo time."
),
"example": (
"This answer has a useful signal, but a judge still needs to see proof "
"in the demo. Make the claim measurable and show it quickly."
),
},
"battle_phase_tone": {
"explore": "medium_high",
"pressure": "high_but_fair",
"close": "firm_judging_panel",
},
"scoring_calibration": {
"attempted_answer_floor": 30,
"partial_signal_floor": 38,
"concrete_signal_floor": 48,
"non_answer_max": 18,
"startup_context_max": 40,
"vague_on_topic_range": [30, 42],
"one_concrete_signal_range": [48, 60],
"strong_answer_range": [70, 85],
"excellent_answer_range": [86, 100],
},
},
"investor": {
"label": "Investor Mode",
"description": "Harder skeptical VC-style pressure.",
"profile_display": {
"pressure_labels": {
"explore": "High",
"pressure": "Very High",
"close": "Investor Pressure",
},
},
"question_style": {
"plain_language": False,
"avoid_jargon": False,
"avoid_compound_questions": False,
"one_question_only": True,
"max_sentences": 4,
"tone": "skeptical_investor",
"instruction": (
"Act like a skeptical early-stage investor. Pressure-test market size, "
"moat, retention, revenue logic, distribution, and why now. "
"Be tougher than Practice Mode, but still focus on one main pressure point per round."
),
},
"coaching_style": {
"tone": "sharp_investor_feedback",
"instruction": (
"Be sharper and more business-focused. Explain what would worry an investor "
"and what proof is needed to reduce that concern."
),
"example": (
"This answer lacks defensibility. A real investor needs to hear your moat mechanism, "
"not just what the product does."
),
},
"battle_phase_tone": {
"explore": "high",
"pressure": "very_high",
"close": "investment_committee",
},
"scoring_calibration": {
"attempted_answer_floor": 25,
"partial_signal_floor": 35,
"concrete_signal_floor": 45,
"non_answer_max": 15,
"startup_context_max": 35,
"vague_on_topic_range": [25, 38],
"one_concrete_signal_range": [45, 58],
"strong_answer_range": [70, 85],
"excellent_answer_range": [86, 100],
},
},
},
}
# ---------------------------------------------------------------------------
# Module-level singleton (loaded once)
# ---------------------------------------------------------------------------
_settings: dict[str, Any] | None = None
def load_judge_settings() -> dict[str, Any]:
"""Load and cache judge_settings.json. Falls back to hardcoded defaults."""
global _settings
if _settings is not None:
return _settings
path = os.path.abspath(_CONFIG_PATH)
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict) or "profiles" not in data:
raise ValueError("Missing 'profiles' key")
_settings = data
logger.info("judge_settings: loaded from %s", path)
except FileNotFoundError:
logger.warning("judge_settings: config not found at %s — using defaults", path)
_settings = _DEFAULT_SETTINGS
except Exception as exc:
logger.warning("judge_settings: failed to load (%s) — using defaults", exc)
_settings = _DEFAULT_SETTINGS
return _settings
def get_default_profile() -> str:
s = load_judge_settings()
return s.get("default_profile", "practice")
def normalize_difficulty(value: str | None) -> str:
"""Map any incoming difficulty string to a canonical profile name.
Supports legacy frontend values like "high" → "judge" and aliases like
"beginner" → "practice". Unknown values fall back to the default profile.
"""
if not value:
return get_default_profile()
key = str(value).strip().lower()
normalized = _ALIAS_MAP.get(key)
if normalized:
return normalized
# If the value is already a valid profile name, accept it
s = load_judge_settings()
if key in s.get("profiles", {}):
return key
logger.warning("judge_settings: unknown difficulty %r — using default", value)
return get_default_profile()
def get_profile(profile_name: str | None) -> dict[str, Any]:
"""Return the full profile dict for the given (or default) profile."""
s = load_judge_settings()
name = normalize_difficulty(profile_name)
profiles = s.get("profiles", {})
profile = profiles.get(name)
if not profile:
logger.warning("judge_settings: profile %r not found — falling back to practice", name)
profile = profiles.get("practice") or list(profiles.values())[0]
return profile
def get_question_style(profile_name: str | None) -> dict[str, Any]:
return get_profile(profile_name).get("question_style", {})
def get_scoring_calibration(profile_name: str | None) -> dict[str, Any]:
return get_profile(profile_name).get("scoring_calibration", {})
def get_battle_phase_tone(profile_name: str | None, battle_phase: str) -> str:
tones = get_profile(profile_name).get("battle_phase_tone", {})
return tones.get(battle_phase, "medium")
def get_coaching_style(profile_name: str | None) -> dict[str, Any]:
return get_profile(profile_name).get("coaching_style", {})
def get_label(profile_name: str | None) -> str:
return get_profile(profile_name).get("label", "Practice Mode")
_PRESSURE_FALLBACKS: dict[str, dict[str, str]] = {
"practice": {"explore": "Warm-up", "pressure": "Focused", "close": "Final Practice"},
"judge": {"explore": "Moderate", "pressure": "High", "close": "Panel Ready"},
"investor": {"explore": "High", "pressure": "Very High", "close": "Investor Pressure"},
}
def get_pressure_display_label(profile_name: str | None, battle_phase: str) -> str:
"""Return a human-readable pressure label for the sidebar.
Never returns "Extreme" for Practice Mode.
Falls back to hardcoded defaults if config is missing.
"""
profile = get_profile(profile_name)
display = profile.get("profile_display", {})
labels = display.get("pressure_labels", {})
phase_key = battle_phase if battle_phase in ("explore", "pressure", "close") else "explore"
if labels and phase_key in labels:
return labels[phase_key]
canonical = normalize_difficulty(profile_name)
return _PRESSURE_FALLBACKS.get(canonical, _PRESSURE_FALLBACKS["practice"]).get(
phase_key, "Warm-up"
)