Spaces:
Paused
Paused
Download apps/api/src/app/services/coaching.py from nathycodes/presentercoach: direct link, hf CLI and curl.
- Browser
- Download file 23.3 kB
-
https://huggingface.co/spaces/nathycodes/presentercoach/resolve/main/apps/api/src/app/services/coaching.py
- Command line
-
hf download hf://spaces/nathycodes/presentercoach/apps/api/src/app/services/coaching.py
-
curl -L -o coaching.py https://huggingface.co/spaces/nathycodes/presentercoach/resolve/main/apps/api/src/app/services/coaching.py
23.3 kB
| from __future__ import annotations | |
| import json | |
| import re | |
| import time | |
| from typing import Any | |
| import requests | |
| from app.config import settings | |
| REQUIRED_KEYS = {"summary", "strengths", "improvements", "drills", "practice_plan"} | |
| TRANSITION_PHRASES = ( | |
| "first", | |
| "second", | |
| "third", | |
| "because", | |
| "therefore", | |
| "however", | |
| "for example", | |
| "for instance", | |
| "in practice", | |
| "the takeaway", | |
| "here is why", | |
| "why this matters", | |
| "under the hood", | |
| "as a result", | |
| ) | |
| def build_coaching_prompt( | |
| transcript: str, | |
| metrics: dict[str, Any], | |
| history: list[dict[str, Any]], | |
| previous_feedback: dict[str, Any] | None = None, | |
| active_goals: list[dict[str, Any]] | None = None, | |
| ) -> str: | |
| max_chars = max(1000, settings.coaching_max_transcript_chars) | |
| truncated = transcript[:max_chars] | |
| if len(transcript) > max_chars: | |
| truncated = f"{truncated}\n[transcript truncated]" | |
| history_lines = [] | |
| for item in history[:10]: | |
| history_lines.append( | |
| f"- {item.get('recorded_at', 'unknown')}: score={item.get('overall_score', 0)}, " | |
| f"wpm={item.get('wpm', 0)}, fillers/min={item.get('filler_rate', 0)}" | |
| ) | |
| previous = "" | |
| if previous_feedback: | |
| previous = f"Previous coaching focus: {json.dumps(previous_feedback, ensure_ascii=False)[:1200]}" | |
| goal_lines = [] | |
| for goal in active_goals or []: | |
| target = str(goal.get("target_value", 0)) | |
| if goal.get("comparison") == "between": | |
| target = f"{target} to {goal.get('target_upper')}" | |
| goal_lines.append( | |
| f"- {goal.get('metric_name')}: target {goal.get('comparison')} {target}; " | |
| f"current={goal.get('current_value', 'not available')}; achieved={goal.get('achieved', False)}" | |
| ) | |
| return f"""You are a camera-presenting & technology storytelling AI coach. Analyze the practice session and return only valid JSON. | |
| Current metrics: | |
| - duration_seconds: {metrics.get("duration_seconds", 0)} | |
| - word_count: {metrics.get("word_count", 0)} | |
| - wpm: {metrics.get("wpm", 0)} | |
| - filler_count: {metrics.get("filler_count", 0)} | |
| - filler_rate: {metrics.get("filler_rate", 0)} | |
| - pause_count: {metrics.get("pause_count", 0)} | |
| - vocabulary_density: {metrics.get("vocabulary_density", 0)} | |
| - speech_score: {metrics.get("speech_score", 0)} | |
| - delivery_score: {metrics.get("delivery_score", 0)} | |
| - overall_score: {metrics.get("overall_score", 0)} | |
| - eye_contact_score: {metrics.get("eye_contact_score", "n/a")} | |
| - filler_breakdown: {json.dumps(metrics.get("filler_breakdown") or {}, ensure_ascii=False)} | |
| - session_mode: {metrics.get("session_mode", "Practice")} | |
| - curriculum_day: {metrics.get("curriculum_day", "none")} | |
| - target_keywords: {json.dumps(metrics.get("target_keywords") or [], ensure_ascii=False)} | |
| Recent history: | |
| {chr(10).join(history_lines) if history_lines else "- no prior sessions"} | |
| Active user goals: | |
| {chr(10).join(goal_lines) if goal_lines else "- no active goals"} | |
| {previous} | |
| Transcript: | |
| {truncated} | |
| Return this exact JSON shape, with no markdown: | |
| {{ | |
| "summary": "2-3 sentence assessment", | |
| "strengths": [ | |
| {{"area": "pace", "detail": "specific observation", "score_contribution": "why it matters"}} | |
| ], | |
| "improvements": [ | |
| {{"area": "fillers", "detail": "specific next action", "priority": "high", "example": "optional example"}} | |
| ], | |
| "drills": [ | |
| {{"name": "drill name", "description": "steps", "duration_minutes": 5, "targets": "metric"}} | |
| ], | |
| "practice_plan": {{ | |
| "focus_areas": ["area"], | |
| "suggested_duration_minutes": 15, | |
| "difficulty": "beginner", | |
| "next_session_goal": "specific measurable goal" | |
| }}, | |
| "progress_note": "optional progress comment" | |
| }} | |
| Rules: | |
| - Use 2-4 strengths and 2-4 improvements. | |
| - Use 1-3 drills. | |
| - Cite exact metrics where possible. | |
| - Align the next_session_goal and drills with active user goals when present. | |
| - Difficulty must be beginner, intermediate, or advanced. | |
| - If history is empty, progress_note may be empty. | |
| """ | |
| def _strip_timestamps(transcript: str) -> str: | |
| return re.sub(r"^\[\d+(?:\.\d+)?s\]\s*", "", transcript, flags=re.MULTILINE).strip() | |
| def generate_builtin_coaching_feedback( | |
| transcript: str, | |
| metrics: dict[str, Any], | |
| history: list[dict[str, Any]], | |
| previous_feedback: dict[str, Any] | None = None, | |
| active_goals: list[dict[str, Any]] | None = None, | |
| ) -> dict[str, Any]: | |
| started = time.perf_counter() | |
| clean_text = _strip_timestamps(transcript) | |
| lower_text = clean_text.lower() | |
| sentences = [s.strip() for s in re.split(r"[.!?]+", clean_text) if s.strip()] | |
| avg_sentence_words = ( | |
| round(sum(len(s.split()) for s in sentences) / len(sentences), 1) if sentences else 0.0 | |
| ) | |
| opening_line = sentences[0] if sentences else "" | |
| wpm = float(metrics.get("wpm") or 0) | |
| duration_sec = float(metrics.get("duration_seconds") or 0) | |
| word_count = int(metrics.get("word_count") or 0) | |
| filler_count = int(metrics.get("filler_count") or 0) | |
| filler_rate = float(metrics.get("filler_rate") or 0) | |
| filler_breakdown: dict[str, int] = metrics.get("filler_breakdown") or {} | |
| eye_contact = metrics.get("eye_contact_score") | |
| eye_pct = round(float(eye_contact) * 100) if eye_contact is not None else None | |
| overall_score = float(metrics.get("overall_score") or 0) | |
| speech_score = float(metrics.get("speech_score") or 0) | |
| delivery_score = float(metrics.get("delivery_score") or 0) | |
| vocab_density = float(metrics.get("vocabulary_density") or 0) | |
| pause_count = int(metrics.get("pause_count") or 0) | |
| target_keywords: list[str] = [str(k) for k in (metrics.get("target_keywords") or []) if str(k).strip()] | |
| transitions_used = [phrase for phrase in TRANSITION_PHRASES if phrase in lower_text] | |
| covered_keywords = [kw for kw in target_keywords if kw.lower() in lower_text] | |
| missing_keywords = [kw for kw in target_keywords if kw.lower() not in lower_text] | |
| strengths: list[dict[str, str]] = [] | |
| improvements: list[dict[str, str]] = [] | |
| drills: list[dict[str, Any]] = [] | |
| focus_areas: list[str] = [] | |
| # 1. Pace analysis | |
| if 125 <= wpm <= 160: | |
| strengths.append({ | |
| "area": "Presenter Pace", | |
| "detail": f"Your speaking speed averaged {wpm:.0f} WPM β right inside the ideal 125β160 WPM tech keynote window.", | |
| "score_contribution": "Gives viewers enough cognitive room to absorb technical concepts without losing energy.", | |
| }) | |
| elif wpm > 160: | |
| focus_areas.append("pace") | |
| improvements.append({ | |
| "area": "Rushing Pace", | |
| "detail": f"You averaged {wpm:.0f} WPM, which risks compressing technical terms before the audience processes them. Insert a 1-second breath after every key claim.", | |
| "priority": "high", | |
| "example": opening_line[:110] if opening_line else "Pause for one beat right after your headline statement.", | |
| }) | |
| drills.append({ | |
| "name": "140 WPM Beat-Pause Cadence Drill", | |
| "description": "Deliver a 60-second architecture or product explanation using the teleprompter at 1.0x speed, deliberately pausing for one full breath at every period.", | |
| "duration_minutes": 5, | |
| "targets": "wpm", | |
| }) | |
| elif 0 < wpm < 120: | |
| focus_areas.append("pace") | |
| improvements.append({ | |
| "area": "Vocal Momentum", | |
| "detail": f"Your pace averaged {wpm:.0f} WPM. Tightening transitions between sentences toward 130β150 WPM will make your camera presence feel sharper and more authoritative.", | |
| "priority": "medium", | |
| "example": "Start each point with a crisp verb or headline rather than thinking aloud.", | |
| }) | |
| drills.append({ | |
| "name": "60-Second Rapid Tech Briefing", | |
| "description": "Summarize one tech news story in exactly 60 seconds using 3 bullet cues only, aiming for 135 WPM with crisp sentence endings.", | |
| "duration_minutes": 5, | |
| "targets": "wpm", | |
| }) | |
| # 2. Filler analysis | |
| top_fillers = sorted(filler_breakdown.items(), key=lambda item: item[1], reverse=True)[:3] | |
| top_filler_str = ", ".join(f"'{w}' ({c}x)" for w, c in top_fillers) | |
| if filler_rate <= 2.0 and word_count >= 15: | |
| strengths.append({ | |
| "area": "Clean Articulation", | |
| "detail": f"You held filler words to {filler_rate:.1f}/min ({filler_count} total across {word_count} words), projecting calm authority.", | |
| "score_contribution": "Clean transitions keep technical explanations crisp and credible.", | |
| }) | |
| elif filler_rate > 2.0 or filler_count >= 3: | |
| focus_areas.append("fillers") | |
| detail_suffix = f" Top habits: {top_filler_str}." if top_filler_str else "" | |
| improvements.append({ | |
| "area": "Filler Discipline", | |
| "detail": f"You used {filler_count} fillers ({filler_rate:.1f}/min).{detail_suffix} Replace vocalized thinking with a silent 0.8-second pause while holding lens contact.", | |
| "priority": "high" if filler_rate > 4.5 else "medium", | |
| "example": f"Swap '{top_fillers[0][0]}' for a silent breath before your next clause." if top_fillers else "Close your lips briefly between thoughts instead of bridging with 'um' or 'like'.", | |
| }) | |
| drills.append({ | |
| "name": "Silent Pause Replacement Sprint", | |
| "description": "Speak for 90 seconds on how a favorite developer tool works. Every time you feel an 'um', 'like', or 'basically' coming, stop speaking for one beat, then start the next sentence cleanly.", | |
| "duration_minutes": 6, | |
| "targets": "filler_rate", | |
| }) | |
| # 3. Camera Presence & Eye Contact | |
| if eye_pct is not None and eye_pct >= 70: | |
| strengths.append({ | |
| "area": "Camera Lens Presence", | |
| "detail": f"Strong lens connection at {eye_pct}% eye contact (delivery score {delivery_score:.0f}/100), making viewers feel spoken to directly.", | |
| "score_contribution": "Direct lens contact builds immediate trust in on-camera tech demos and videos.", | |
| }) | |
| elif eye_pct is not None and eye_pct < 65: | |
| focus_areas.append("eye_contact") | |
| improvements.append({ | |
| "area": "Lens Eye Contact", | |
| "detail": f"Camera eye contact measured {eye_pct}%. Position the built-in teleprompter overlay at the top of the frame right beneath your webcam lens and speak to the lens as if to one engineer.", | |
| "priority": "high" if eye_pct < 45 else "medium", | |
| "example": "Lock onto the lens during your opening hook and your closing takeaway sentence.", | |
| }) | |
| drills.append({ | |
| "name": "Lens-Lock Hook & Takeaway Drill", | |
| "description": "Use Cue-Card teleprompter mode and deliver 3 back-to-back 30-second intros while keeping your eyes anchored on the camera ring for >75% of the take.", | |
| "duration_minutes": 5, | |
| "targets": "eye_contact_score", | |
| }) | |
| # 4. Storytelling Structure, Clarity & Keywords | |
| if len(transitions_used) >= 2: | |
| strengths.append({ | |
| "area": "Structured Signposting", | |
| "detail": f"Clear narrative signposts ({', '.join(transitions_used[:4])}) guided the listener cleanly from premise to takeaway.", | |
| "score_contribution": "Signposting helps audiences follow complex architecture and product arguments effortlessly.", | |
| }) | |
| else: | |
| focus_areas.append("structure") | |
| improvements.append({ | |
| "area": "Tech Storytelling Structure", | |
| "detail": "Add explicit structural signposts ('Here is the problem...', 'Under the hood...', 'For example...', 'The takeaway is...') so your audience always knows where they are in your story.", | |
| "priority": "medium", | |
| "example": "Open with a 1-sentence hook, state 2β3 numbered pillars, and land a concrete takeaway.", | |
| }) | |
| if avg_sentence_words > 24: | |
| improvements.append({ | |
| "area": "Sentence Conciseness", | |
| "detail": f"Your sentences averaged {avg_sentence_words:.0f} words. Break compound technical clauses into punchy 12β18 word sentences for stronger on-camera clarity.", | |
| "priority": "medium", | |
| "example": opening_line[:120] if opening_line else "Turn 'and so which means that' chains into crisp standalone statements.", | |
| }) | |
| elif 8 <= avg_sentence_words <= 20 and len(strengths) < 3: | |
| strengths.append({ | |
| "area": "Crisp Sentence Clarity", | |
| "detail": f"Average sentence length of {avg_sentence_words:.0f} words kept your explanation conversational and easy to follow.", | |
| "score_contribution": "Short, direct sentences sound natural on camera and quote well.", | |
| }) | |
| if target_keywords: | |
| if not missing_keywords: | |
| strengths.append({ | |
| "area": "Topic & Keyword Mastery", | |
| "detail": f"You wove in 100% of your target concepts ({', '.join(covered_keywords[:5])}).", | |
| "score_contribution": "Ensures every key technical takeaway lands as planned.", | |
| }) | |
| elif missing_keywords: | |
| improvements.append({ | |
| "area": "Key Concept Coverage", | |
| "detail": f"Covered {len(covered_keywords)}/{len(target_keywords)} target terms; consider weaving in: {', '.join(missing_keywords[:4])}.", | |
| "priority": "low", | |
| "example": f"Anchor '{missing_keywords[0]}' with a concrete real-world example.", | |
| }) | |
| # Ensure 2-4 strengths, 2-4 improvements, and 1-3 drills per contract | |
| if len(strengths) < 2: | |
| strengths.append({ | |
| "area": "Consistent Practice Rep", | |
| "detail": f"Completed a {max(1, round(duration_sec))}s on-camera rep ({word_count} words, vocabulary density {vocab_density:.2f}) with {pause_count} natural pause markers.", | |
| "score_contribution": "Daily reps compound rapidly into effortless camera confidence.", | |
| }) | |
| if len(strengths) < 2: | |
| strengths.append({ | |
| "area": "Vocal Engagement", | |
| "detail": f"Delivered an overall session score of {overall_score:.0f}/100 (speech {speech_score:.0f}/100) β a solid baseline to sharpen on your next take.", | |
| "score_contribution": "Provides measurable benchmarks for your 30-day presenter progression.", | |
| }) | |
| if len(improvements) < 2: | |
| improvements.append({ | |
| "area": "Cold-Open Hook Energy", | |
| "detail": "Challenge yourself to make the first 8 seconds 15% more energetic: lead with a surprising stat, sharp problem, or relatable developer moment before diving into details.", | |
| "priority": "low", | |
| "example": opening_line[:110] if opening_line else "Instead of 'Today I want to talk about...', open directly with the stakes.", | |
| }) | |
| if len(improvements) < 2: | |
| improvements.append({ | |
| "area": "Deliberate Emphasis Pauses", | |
| "detail": "Hold a deliberate 1-second pause right before your final takeaway sentence to underline its importance on camera.", | |
| "priority": "low", | |
| "example": "Pause, smile slightly at the lens, then deliver your closing one-liner.", | |
| }) | |
| if not drills: | |
| drills.append({ | |
| "name": "Hook β Mechanism β Impact Take-Two", | |
| "description": "Re-record the same topic in 90 seconds using 3 bullet cues: (1) 8-second sharp hook, (2) 1 clear analogy or mechanism, (3) 1 actionable takeaway while holding 75%+ lens contact.", | |
| "duration_minutes": 5, | |
| "targets": "overall_score", | |
| }) | |
| # Align next_session_goal with active user goals when present | |
| unmet_goals = [g for g in (active_goals or []) if not g.get("achieved")] | |
| if unmet_goals: | |
| g0 = unmet_goals[0] | |
| target_desc = ( | |
| f"{g0['target_value']}β{g0['target_upper']}" | |
| if g0.get("comparison") == "between" | |
| else f"{g0.get('comparison')} {g0.get('target_value')}" | |
| ) | |
| next_goal = f"Hit your active '{g0.get('metric_name')}' goal ({target_desc}) on a 90-second camera take." | |
| elif "fillers" in focus_areas: | |
| next_goal = f"Cut filler rate from {filler_rate:.1f}/min to under {max(1.5, round(filler_rate * 0.6, 1))}/min using silent beat-pauses." | |
| elif "pace" in focus_areas: | |
| next_goal = "Hold steady conversational cadence between 130 and 155 WPM across a 2-minute tech explanation." | |
| elif "eye_contact" in focus_areas: | |
| next_goal = "Maintain at least 75% camera lens eye contact while speaking from bullet cues." | |
| else: | |
| next_goal = f"Push overall score from {overall_score:.0f} to {min(98, int(overall_score + 6))}+ with a punchy 8-second hook and crisp signposts." | |
| # Progress note vs recent history | |
| prior_rows = [row for row in history if row.get("overall_score") is not None] | |
| progress_note = "" | |
| if len(prior_rows) >= 2: | |
| prior_scores = [float(r.get("overall_score") or 0) for r in prior_rows[1:6]] | |
| if prior_scores: | |
| avg_prior = sum(prior_scores) / len(prior_scores) | |
| delta = overall_score - avg_prior | |
| direction = f"up +{delta:.1f} pts" if delta >= 0 else f"{delta:.1f} pts" | |
| progress_note = ( | |
| f"Compared with your recent {len(prior_scores)}-session average ({avg_prior:.1f}/100), " | |
| f"this session scored {overall_score:.1f}/100 ({direction})." | |
| ) | |
| difficulty = "advanced" if overall_score >= 80 else "intermediate" if overall_score >= 58 else "beginner" | |
| mode_label = metrics.get("session_mode") or "presenting" | |
| day_badge = f" (30-Day Curriculum Day {metrics['curriculum_day']})" if metrics.get("curriculum_day") else "" | |
| summary = ( | |
| f"Solid {mode_label} take{day_badge} scoring {overall_score:.0f}/100 at {wpm:.0f} WPM " | |
| f"with {filler_count} filler{'s' if filler_count != 1 else ''} ({filler_rate:.1f}/min)" | |
| + (f" and {eye_pct}% lens eye contact." if eye_pct is not None else ".") | |
| + f" Your strongest asset was {strengths[0]['area'].lower()}, while your highest-leverage next upgrade is {improvements[0]['area'].lower()}." | |
| ) | |
| payload = { | |
| "summary": summary, | |
| "strengths": strengths[:4], | |
| "improvements": improvements[:4], | |
| "drills": drills[:3], | |
| "practice_plan": { | |
| "focus_areas": (focus_areas or ["storytelling", "camera_presence"])[:3], | |
| "suggested_duration_minutes": 15, | |
| "difficulty": difficulty, | |
| "next_session_goal": next_goal, | |
| }, | |
| "progress_note": progress_note, | |
| } | |
| raw_json = json.dumps(payload, ensure_ascii=False) | |
| payload["raw_response"] = raw_json | |
| payload["model_used"] = "builtin-presenter-coach" | |
| payload["generation_time_ms"] = max(1, int((time.perf_counter() - started) * 1000)) | |
| return payload | |
| def generate_coaching_feedback( | |
| transcript: str, | |
| metrics: dict[str, Any], | |
| history: list[dict[str, Any]], | |
| previous_feedback: dict[str, Any] | None = None, | |
| active_goals: list[dict[str, Any]] | None = None, | |
| ) -> dict[str, Any]: | |
| provider = settings.coaching_provider.lower() | |
| if provider == "builtin": | |
| return generate_builtin_coaching_feedback(transcript, metrics, history, previous_feedback, active_goals) | |
| prompt = build_coaching_prompt(transcript, metrics, history, previous_feedback, active_goals) | |
| started = time.perf_counter() | |
| if provider == "auto": | |
| try: | |
| raw_text = _call_ollama(prompt, connect_timeout=1.5) | |
| parsed = parse_coaching_response(raw_text) | |
| parsed["raw_response"] = raw_text | |
| parsed["model_used"] = settings.coaching_model | |
| parsed["generation_time_ms"] = int((time.perf_counter() - started) * 1000) | |
| return parsed | |
| except Exception: | |
| return generate_builtin_coaching_feedback(transcript, metrics, history, previous_feedback, active_goals) | |
| if provider not in {"ollama", "lmstudio", "llamacpp"}: | |
| raise RuntimeError(f"Unsupported coaching provider '{settings.coaching_provider}'") | |
| try: | |
| if provider == "ollama": | |
| raw_text = _call_ollama(prompt) | |
| else: | |
| raw_text = _call_openai_compatible(prompt, provider) | |
| parsed = parse_coaching_response(raw_text) | |
| parsed["raw_response"] = raw_text | |
| parsed["model_used"] = settings.coaching_model | |
| parsed["generation_time_ms"] = int((time.perf_counter() - started) * 1000) | |
| return parsed | |
| except Exception: | |
| if settings.coaching_fallback_builtin: | |
| return generate_builtin_coaching_feedback(transcript, metrics, history, previous_feedback, active_goals) | |
| raise | |
| def parse_coaching_response(raw_text: str) -> dict[str, Any]: | |
| cleaned = raw_text.strip() | |
| if cleaned.startswith("```"): | |
| cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:] | |
| if cleaned.endswith("```"): | |
| cleaned = cleaned[:-3] | |
| cleaned = cleaned.strip() | |
| start = cleaned.find("{") | |
| end = cleaned.rfind("}") | |
| if start >= 0 and end > start: | |
| cleaned = cleaned[start:end + 1] | |
| try: | |
| parsed = json.loads(cleaned) | |
| except json.JSONDecodeError as exc: | |
| raise RuntimeError(f"Local LLM returned invalid JSON: {exc}") from exc | |
| missing = REQUIRED_KEYS - set(parsed) | |
| if missing: | |
| raise RuntimeError(f"Local LLM response missing required keys: {', '.join(sorted(missing))}") | |
| parsed.setdefault("progress_note", "") | |
| return parsed | |
| def _call_ollama(prompt: str, connect_timeout: float = 3.0) -> str: | |
| url = f"{settings.ollama_base_url.rstrip('/')}/api/generate" | |
| response = requests.post( | |
| url, | |
| json={ | |
| "model": settings.coaching_model, | |
| "prompt": prompt, | |
| "stream": False, | |
| "format": "json", | |
| }, | |
| timeout=(connect_timeout, settings.coaching_timeout_seconds), | |
| ) | |
| response.raise_for_status() | |
| payload = response.json() | |
| text = payload.get("response") | |
| if not isinstance(text, str) or not text.strip(): | |
| raise RuntimeError("Ollama returned an empty coaching response") | |
| return text | |
| def _call_openai_compatible(prompt: str, provider: str) -> str: | |
| base_url = settings.lmstudio_base_url if provider == "lmstudio" else settings.llama_cpp_base_url | |
| url = f"{base_url.rstrip('/')}/v1/chat/completions" | |
| response = requests.post( | |
| url, | |
| json={ | |
| "model": settings.coaching_model, | |
| "temperature": 0.3, | |
| "messages": [{"role": "user", "content": prompt}], | |
| }, | |
| timeout=(3.0, settings.coaching_timeout_seconds), | |
| ) | |
| response.raise_for_status() | |
| payload = response.json() | |
| try: | |
| text = payload["choices"][0]["message"]["content"] | |
| except (KeyError, IndexError, TypeError) as exc: | |
| raise RuntimeError("OpenAI-compatible provider returned an invalid response") from exc | |
| if not isinstance(text, str) or not text.strip(): | |
| raise RuntimeError("OpenAI-compatible provider returned an empty coaching response") | |
| return text | |