diff --git a/community/conversation-insights-coach/README.md b/community/conversation-insights-coach/README.md new file mode 100644 index 00000000..8f510dd5 --- /dev/null +++ b/community/conversation-insights-coach/README.md @@ -0,0 +1,81 @@ +# Conversation Insights Coach +![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) +![Author](https://img.shields.io/badge/Author-Muhammad_Hassan-lightgrey?style=flat-square) + +## What It Does + +A passive background coach that silently monitors HOW you communicate — not what you say. Tracks filler words, hedging language, vocabulary diversity, question-to-statement ratio, and utterance length. Ask for a report anytime and get a spoken, personalized coaching summary with historical trends, milestones, and actionable tips. + +## Trigger Words + +- how am I communicating +- communication insights +- my filler words +- speech insights +- speaking report +- coach my speech +- set filler goal +- watch my fillers + +## Setup + +No external API keys or services needed. Fully self-contained using OpenHome's built-in LLM and TTS. + +## How It Works + +1. Background daemon starts automatically when your session connects +2. Every 20 seconds it silently analyzes new conversation messages +3. Tracks: filler words, hedging phrases, vocabulary, questions vs statements, utterance length +4. Say **"how am I communicating?"** anytime to get your spoken coaching report +5. Reports compare today with your historical averages and celebrate personal bests + +## Features + +- **Passive Analysis** — runs silently, zero friction, no trigger words needed during conversation +- **Filler Word Tracking** — detects "um", "uh", "like", "you know", "basically", "I mean", and more +- **Hedging Detection** — tracks "maybe", "I think", "I guess", "sort of" — a proxy for assertiveness +- **Vocabulary Diversity** — unique words vs total words, tracks new vocabulary per session +- **Question Ratio** — balance between asking questions and making statements +- **Historical Trends** — compares today with your 30-day history +- **Streak Tracking** — "You've been improving for 3 days in a row!" +- **Milestone Celebrations** — personal bests are announced in your report +- **Goal Setting** — set a filler target ("set a goal of 10 fillers today") +- **Real-Time Nudges** — optional mode that gives a gentle spoken tip when filler clusters are detected +- **Detail Drill-Down** — ask about specific areas: fillers, vocabulary, assertiveness, questions, pace +- **Coaching Tips** — every report ends with one specific, actionable tip based on your weakest area +- **Session Persistence** — same-day sessions resume after reconnect; daily history saved for 30 days + +## What Gets Tracked + +| Metric | What it means | +|---|---| +| Filler words | "um", "uh", "like", "you know", "basically", "I mean", "sort of"… | +| Hedging phrases | "maybe", "I think", "I guess", "probably", "sort of"… | +| Vocabulary diversity | Unique words ÷ total words (higher = more varied language) | +| Question ratio | % of utterances that are questions | +| Avg utterance length | Average words per spoken turn | +| New vocabulary | Words you haven't used in previous sessions | + +## Example Conversation + +**User:** how am I communicating? + +**Coach:** You've been on a roll today — 34 utterances with a vocabulary diversity of 58%, which is above your usual average. You used 12 filler words, with 'like' showing up 5 times. That's down from your average of 18, so you're clearly making progress! One thing to try: next time you feel a filler coming, just pause for a beat instead — silence is more powerful than it feels. + +**User:** tell me more about my fillers + +**Coach:** Your top fillers today were 'like' at 5, 'you know' at 3, and 'basically' at 2. Your filler rate is 3.8% of total words, down from your average of 5.2% — that's a real improvement! Try replacing 'you know' with a brief pause; it makes your sentences land more cleanly. + +**User:** set a goal of 8 fillers + +**Coach:** Goal set! I'll track your progress toward fewer than 8 filler words today. Ask for a report anytime to see how you're doing. + +**User:** watch my fillers + +**Coach:** Real-time coaching is on. I'll give you a gentle heads-up whenever I notice a cluster of filler words. You've got this! + +## Difficulty / Nudge Settings + +- **Silent mode (default)** — analyzes passively, reports on demand only +- **Nudge mode** — say "watch my fillers" to enable gentle real-time tips when 3+ fillers appear in one utterance (max once every 2 minutes to avoid being annoying) +- Turn off nudges anytime: "stop watching my fillers" diff --git a/community/conversation-insights-coach/__init__.py b/community/conversation-insights-coach/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/conversation-insights-coach/background.py b/community/conversation-insights-coach/background.py new file mode 100644 index 00000000..d65d967a --- /dev/null +++ b/community/conversation-insights-coach/background.py @@ -0,0 +1,516 @@ +import json +import re +import time +import random +from datetime import datetime + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +# ============================================================================= +# CONVERSATION INSIGHTS COACH — Background Daemon +# Starts automatically on session connect. Passively monitors conversation +# history every 20s and analyzes HOW the user communicates: filler words, +# hedging language, question ratio, vocabulary diversity, and utterance length. +# Results are persisted to insights_stats.json for the interactive skill to read. +# +# NOTE: MatchingCapability uses Pydantic — no arbitrary self.* attributes. +# All mutable state lives in a local `s` dict passed between helpers. +# ============================================================================= + +# ── Filler word detection ──────────────────────────────────────────────────── +MULTI_WORD_FILLERS = [ + "you know", "i mean", "sort of", "kind of", + "at the end of the day", "to be honest", "to be fair", + "basically like", +] + +SINGLE_WORD_FILLERS = { + "um", "uh", "uhh", "umm", "erm", "hmm", "hm", + "basically", "literally", "actually", "right", + "anyway", "anyways", "whatever", +} + +LIKE_NON_FILLER_PATTERNS = [ + re.compile(r'\b(i|you|we|they|he|she|it|who|that|which)\s+like\b', re.IGNORECASE), + re.compile(r'\b(look|looks|sound|sounds|feel|feels|seem|seems|taste|tastes)s?\s+like\b', re.IGNORECASE), + re.compile(r'\b(would|do|don\'t|dont|did|didn\'t|didnt|will|can|could|should|might)\s+like\b', re.IGNORECASE), + re.compile(r'^like\b', re.IGNORECASE), +] + +# ── Hedging language ───────────────────────────────────────────────────────── +MULTI_WORD_HEDGES = [ + "i think", "i guess", "i suppose", "i feel like", + "kind of", "sort of", "not sure", "i'm not sure", + "might be", "could be", "i believe", +] + +SINGLE_WORD_HEDGES = { + "maybe", "perhaps", "probably", "possibly", + "apparently", "seemingly", +} + +# ── Question-starting words ────────────────────────────────────────────────── +QUESTION_STARTERS = {"who", "what", "when", "where", "why", "how", "is", "are", + "was", "were", "will", "would", "can", "could", "should", + "do", "does", "did", "have", "has", "had"} + +# ── Nudge messages pool ───────────────────────────────────────────────────── +NUDGE_MESSAGES = [ + "Quick tip — try pausing for a beat instead of filling the gap. You're doing great!", + "Heads up — noticed a few filler words there. A confident pause works just as well.", + "Small note — you used some filler words just now. No worries, just something to be aware of.", + "Just a gentle nudge — watch those fillers. Your ideas are strong, let them stand on their own!", + "Nice thought! Tip: try replacing filler words with a brief pause. Sounds more polished.", +] + +STATS_FILE = "insights_stats.json" +POLL_INTERVAL = 20.0 +SAVE_EVERY_N_POLLS = 30 +NUDGE_COOLDOWN_SECS = 120 +MAX_UNIQUE_WORDS = 5000 + + +def _new_state() -> dict: + """Return a fresh mutable state dict (lives as a local var, never on self).""" + return { + "total_utterances": 0, + "total_words": 0, + "unique_words": set(), + "filler_counts": {}, + "hedging_counts": {}, + "question_count": 0, + "statement_count": 0, + "utterance_lengths": [], + "new_vocab_words": set(), + "repeat_count": 0, + "last_processed_index": 0, + "polls_since_save": 0, + "session_date": datetime.now().strftime("%Y-%m-%d"), + "goal": None, + "nudge_enabled": False, + "prev_vocab": set(), + "nudge_pending": False, + "nudge_trigger_word": "", + "nudge_trigger_count": 0, + "last_nudge_time": 0.0, + } + + +def _empty_stats() -> dict: + return { + "current_session": { + "date": datetime.now().strftime("%Y-%m-%d"), + "total_utterances": 0, + "total_words": 0, + "unique_word_count": 0, + "filler_counts": {}, + "hedging_counts": {}, + "question_count": 0, + "statement_count": 0, + "avg_utterance_length": 0.0, + "new_vocab_words": [], + "repeat_count": 0, + }, + "daily_history": [], + "settings": { + "nudge_enabled": False, + "filler_goal": None, + }, + } + + +class ConversationInsightsBackground(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + background_daemon_mode: bool = False + + # Do not change following tag of register capability + # {{register capability}} + + # ------------------------------------------------------------------ + # Filler & hedge detection — pure Python, no LLM, no self-state + # ------------------------------------------------------------------ + + def _is_like_filler(self, text, match_start): + for pattern in LIKE_NON_FILLER_PATTERNS: + window = text[max(0, match_start - 25):match_start + 10] + if pattern.search(window): + return False + return True + + def _count_fillers(self, text): + counts = {} + lower = text.lower() + masked = lower + + for phrase in MULTI_WORD_FILLERS: + n = masked.count(phrase) + if n: + counts[phrase] = n + masked = masked.replace(phrase, " _F_ ") + + like_count = 0 + for m in re.finditer(r'\blike\b', masked): + if self._is_like_filler(masked, m.start()): + like_count += 1 + if like_count: + counts["like"] = like_count + masked = re.sub(r'\blike\b', '_F_', masked) + + for word in re.findall(r'\b\w+\b', masked): + if word in SINGLE_WORD_FILLERS: + counts[word] = counts.get(word, 0) + 1 + + return counts + + def _count_hedges(self, text): + counts = {} + lower = text.lower() + masked = lower + + for phrase in MULTI_WORD_HEDGES: + n = masked.count(phrase) + if n: + counts[phrase] = n + masked = masked.replace(phrase, " _H_ ") + + for word in re.findall(r'\b\w+\b', masked): + if word in SINGLE_WORD_HEDGES: + counts[word] = counts.get(word, 0) + 1 + + return counts + + def _count_questions(self, text): + count = 0 + segments = text.split("?") + count += max(0, len(segments) - 1) + if count == 0: + first_word = text.strip().split()[0].lower().rstrip(".,!") if text.strip() else "" + if first_word in QUESTION_STARTERS: + count = 1 + return count + + def _detect_repeats(self, text): + matches = re.findall(r'\b(\w+)(?:\s+\1)+\b', text.lower()) + return len(matches) + + # ------------------------------------------------------------------ + # Utterance analysis — mutates the state dict `s` + # ------------------------------------------------------------------ + + def _analyze_utterance(self, text, s): + text_clean = re.sub(r'\s+', ' ', text).strip() + if not text_clean: + return + + words = [w for w in re.findall(r"\b[a-zA-Z']+\b", text_clean.lower()) if len(w) > 1] + word_count = len(words) + + if word_count < 3: + s["total_utterances"] += 1 + return + + s["total_utterances"] += 1 + s["total_words"] += word_count + + for w in words: + if len(s["unique_words"]) < MAX_UNIQUE_WORDS: + s["unique_words"].add(w) + if w not in s["prev_vocab"] and w not in s["new_vocab_words"]: + s["new_vocab_words"].add(w) + + s["utterance_lengths"].append(word_count) + + q_count = self._count_questions(text_clean) + s["question_count"] += q_count + if q_count == 0: + s["statement_count"] += 1 + + fillers = self._count_fillers(text_clean) + for k, v in fillers.items(): + s["filler_counts"][k] = s["filler_counts"].get(k, 0) + v + + hedges = self._count_hedges(text_clean) + for k, v in hedges.items(): + s["hedging_counts"][k] = s["hedging_counts"].get(k, 0) + v + + s["repeat_count"] += self._detect_repeats(text_clean) + + total_fillers_this = sum(fillers.values()) + if total_fillers_this >= 3: + s["nudge_pending"] = True + s["nudge_trigger_word"] = max(fillers, key=fillers.get) + s["nudge_trigger_count"] = fillers[s["nudge_trigger_word"]] + + # ------------------------------------------------------------------ + # File I/O + # ------------------------------------------------------------------ + + async def _load_stats(self): + try: + exists = await self.capability_worker.check_if_file_exists(STATS_FILE, False) + if not exists: + return _empty_stats() + raw = await self.capability_worker.read_file(STATS_FILE, False) + if not raw or not raw.strip(): + return _empty_stats() + return json.loads(raw) + except Exception as e: + self.worker.editor_logging_handler.error(f"[InsightsCoach] Load error: {e}") + return _empty_stats() + + async def _save_stats(self, data): + try: + exists = await self.capability_worker.check_if_file_exists(STATS_FILE, False) + if exists: + await self.capability_worker.delete_file(STATS_FILE, False) + await self.capability_worker.write_file( + STATS_FILE, json.dumps(data, indent=2), False + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"[InsightsCoach] Save error: {e}") + + # ------------------------------------------------------------------ + # Snapshot builders — read from state dict `s` + # ------------------------------------------------------------------ + + def _build_session_snapshot(self, s): + lengths = s["utterance_lengths"] + avg_len = round(sum(lengths) / len(lengths), 1) if lengths else 0.0 + return { + "date": s["session_date"], + "total_utterances": s["total_utterances"], + "total_words": s["total_words"], + "unique_word_count": len(s["unique_words"]), + "filler_counts": dict(s["filler_counts"]), + "hedging_counts": dict(s["hedging_counts"]), + "question_count": s["question_count"], + "statement_count": s["statement_count"], + "avg_utterance_length": avg_len, + "new_vocab_words": list(s["new_vocab_words"])[:20], + "repeat_count": s["repeat_count"], + } + + def _build_history_entry(self, s): + total_fillers = sum(s["filler_counts"].values()) + total_hedges = sum(s["hedging_counts"].values()) + tw = s["total_words"] + tu = s["total_utterances"] + lengths = s["utterance_lengths"] + vocab_diversity = round(len(s["unique_words"]) / tw, 3) if tw > 0 else 0.0 + filler_rate = round(total_fillers / tw, 4) if tw > 0 else 0.0 + question_ratio = round(s["question_count"] / tu, 3) if tu > 0 else 0.0 + avg_len = round(sum(lengths) / len(lengths), 1) if lengths else 0.0 + top_fillers = sorted(s["filler_counts"], key=s["filler_counts"].get, reverse=True)[:3] + return { + "date": s["session_date"], + "total_utterances": tu, + "total_words": tw, + "vocabulary_diversity": vocab_diversity, + "filler_rate": filler_rate, + "total_fillers": total_fillers, + "total_hedges": total_hedges, + "question_ratio": question_ratio, + "avg_utterance_length": avg_len, + "top_fillers": top_fillers, + "new_vocab_count": len(s["new_vocab_words"]), + } + + # ------------------------------------------------------------------ + # Checkpoint + # ------------------------------------------------------------------ + + async def _save_checkpoint(self, data, s): + today = datetime.now().strftime("%Y-%m-%d") + + if s["session_date"] and today != s["session_date"]: + entry = self._build_history_entry(s) + history = data.get("daily_history", []) + history = [h for h in history if h.get("date") != s["session_date"]] + history.append(entry) + if len(history) > 30: + history = history[-30:] + data["daily_history"] = history + + s["prev_vocab"].update(s["unique_words"]) + # Reset counters for new day + for k in ("total_utterances", "total_words", "question_count", + "statement_count", "repeat_count"): + s[k] = 0 + s["unique_words"] = set() + s["filler_counts"] = {} + s["hedging_counts"] = {} + s["utterance_lengths"] = [] + s["new_vocab_words"] = set() + s["session_date"] = today + + self.worker.editor_logging_handler.info( + f"[InsightsCoach] Day rollover → archived {entry['date']}" + ) + + data["current_session"] = self._build_session_snapshot(s) + await self._save_stats(data) + + self.worker.editor_logging_handler.info( + f"[InsightsCoach] Checkpoint saved — " + f"{s['total_utterances']} utterances, " + f"{sum(s['filler_counts'].values())} fillers" + ) + + # ------------------------------------------------------------------ + # Restore state on reconnect + # ------------------------------------------------------------------ + + async def _restore_from_file(self, s): + data = await self._load_stats() + today = datetime.now().strftime("%Y-%m-%d") + session = data.get("current_session", {}) + + if session.get("date") == today: + s["total_utterances"] = session.get("total_utterances", 0) + s["total_words"] = session.get("total_words", 0) + s["filler_counts"] = session.get("filler_counts", {}) + s["hedging_counts"] = session.get("hedging_counts", {}) + s["question_count"] = session.get("question_count", 0) + s["statement_count"] = session.get("statement_count", 0) + s["repeat_count"] = session.get("repeat_count", 0) + s["new_vocab_words"] = set(session.get("new_vocab_words", [])) + self.worker.editor_logging_handler.info( + f"[InsightsCoach] Resumed today's session — " + f"{s['total_utterances']} utterances already tracked" + ) + else: + if session.get("date") and session.get("total_utterances", 0) > 0: + tw = max(session.get("total_words", 1), 1) + tu = max(session.get("total_utterances", 1), 1) + fc = session.get("filler_counts", {}) + hc = session.get("hedging_counts", {}) + entry = { + "date": session["date"], + "total_utterances": session.get("total_utterances", 0), + "total_words": session.get("total_words", 0), + "vocabulary_diversity": round(session.get("unique_word_count", 0) / tw, 3), + "filler_rate": round(sum(fc.values()) / tw, 4), + "total_fillers": sum(fc.values()), + "total_hedges": sum(hc.values()), + "question_ratio": round(session.get("question_count", 0) / tu, 3), + "avg_utterance_length": session.get("avg_utterance_length", 0.0), + "top_fillers": sorted(fc, key=lambda k: fc[k], reverse=True)[:3], + "new_vocab_count": len(session.get("new_vocab_words", [])), + } + history = data.get("daily_history", []) + history = [h for h in history if h.get("date") != session["date"]] + history.append(entry) + if len(history) > 30: + history = history[-30:] + data["daily_history"] = history + await self._save_stats(data) + self.worker.editor_logging_handler.info( + f"[InsightsCoach] New day — archived {session['date']}" + ) + + settings = data.get("settings", {}) + s["goal"] = settings.get("filler_goal") + s["nudge_enabled"] = settings.get("nudge_enabled", False) + s["session_date"] = today + return data + + # ------------------------------------------------------------------ + # Watch loop — ALL mutable state in local dict `s` + # ------------------------------------------------------------------ + + async def watch_loop(self): + s = _new_state() + + self.worker.editor_logging_handler.info( + "[InsightsCoach] daemon started — monitoring communication (20s interval)" + ) + + await self._restore_from_file(s) + + while True: + try: + history = self.capability_worker.get_full_message_history() + history = history or [] + current_length = len(history) + + if s["last_processed_index"] == 0 and current_length > 10: + s["last_processed_index"] = current_length - 10 + self.worker.editor_logging_handler.info( + f"[InsightsCoach] First poll: skip to index {s['last_processed_index']}" + ) + + if s["last_processed_index"] > current_length: + self.worker.editor_logging_handler.info( + "[InsightsCoach] History shrunk, resetting pointer" + ) + s["last_processed_index"] = max(0, current_length - 3) + + new_messages = history[s["last_processed_index"]:] + s["last_processed_index"] = current_length + + new_count = 0 + for msg in new_messages: + if msg.get("role") != "user": + continue + text = msg.get("content", "") + if not isinstance(text, str): + continue + text = text.strip() + if not text or len(text) < 5: + continue + self._analyze_utterance(text, s) + new_count += 1 + + if new_count: + tf = sum(s["filler_counts"].values()) + self.worker.editor_logging_handler.info( + f"[InsightsCoach] +{new_count} utterances — " + f"{tf} fillers, {len(s['unique_words'])} unique words" + ) + + s["polls_since_save"] += 1 + if s["polls_since_save"] >= SAVE_EVERY_N_POLLS or new_count: + fresh = await self._load_stats() + settings = fresh.get("settings", {}) + s["nudge_enabled"] = settings.get("nudge_enabled", False) + s["goal"] = settings.get("filler_goal") + fresh["settings"] = settings + await self._save_checkpoint(fresh, s) + if s["polls_since_save"] >= SAVE_EVERY_N_POLLS: + s["polls_since_save"] = 0 + + if s["nudge_pending"] and s["nudge_enabled"]: + now = time.time() + if now - s["last_nudge_time"] >= NUDGE_COOLDOWN_SECS: + msg_text = random.choice(NUDGE_MESSAGES) + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak(msg_text) + s["last_nudge_time"] = now + self.worker.editor_logging_handler.info( + f"[InsightsCoach] Nudge sent — " + f"'{s['nudge_trigger_word']}' x{s['nudge_trigger_count']}" + ) + s["nudge_pending"] = False + + except Exception as e: + self.worker.editor_logging_handler.error( + f"[InsightsCoach] Loop error: {e}" + ) + + await self.worker.session_tasks.sleep(POLL_INTERVAL) + + # ------------------------------------------------------------------ + # Entry point + # ------------------------------------------------------------------ + + def call(self, worker: AgentWorker, background_daemon_mode: bool): + self.worker = worker + self.background_daemon_mode = background_daemon_mode + self.capability_worker = CapabilityWorker(self.worker) + self.worker.editor_logging_handler.info( + "[InsightsCoach] background.py call() — launching watch_loop" + ) + self.worker.session_tasks.create(self.watch_loop()) diff --git a/community/conversation-insights-coach/main.py b/community/conversation-insights-coach/main.py new file mode 100644 index 00000000..085c4a72 --- /dev/null +++ b/community/conversation-insights-coach/main.py @@ -0,0 +1,577 @@ +import json +import re +from typing import Optional + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +# ============================================================================= +# CONVERSATION INSIGHTS COACH — Interactive Skill +# Triggered by hotwords like "how am I communicating?" or "my filler words". +# Reads analysis from insights_stats.json (written by background.py) and +# delivers a natural spoken report with trends, milestones, and coaching tips. +# Also handles goal-setting and real-time nudge toggling. +# ============================================================================= + +STATS_FILE = "insights_stats.json" + +HOTWORDS = { + "how am i communicating", "how am i speaking", + "communication insights", "my filler words", + "speech insights", "conversation insights", + "speaking report", "communication report", + "how do i talk", "analyze my speech", + "my speaking patterns", "coach my speech", + "set filler goal", "watch my fillers", + "stop watching my fillers", "speech coach", + "how's my speech", "hows my speech", +} + +EXIT_WORDS = { + "stop", "exit", "quit", "cancel", "done", "bye", + "goodbye", "never mind", "nevermind", "no thanks", + "that's all", "thats all", "end", "nothing", +} + +COACHING_TIPS = { + "fillers": "Try replacing filler words with a brief, confident pause. Silence is powerful — it gives your listener time to absorb what you said.", + "hedging": "Notice when you say 'I think' or 'maybe' unnecessarily. If you know something, state it directly. Confidence in your voice builds trust.", + "vocabulary": "Challenge yourself to use one or two new words each conversation. It keeps your language fresh and engaging.", + "questions": "You ask a lot of questions — great for curiosity! Try balancing with statements to share your own perspective more.", + "pace": "Your average sentence length is on the shorter side. Try elaborating a bit more to give your ideas room to breathe.", +} + + +class ConversationInsightsCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + # Do not change following tag of register capability + # {{register capability}} + + # ------------------------------------------------------------------ + # Hotword matching + # ------------------------------------------------------------------ + + def does_match(self, text: str) -> bool: + t = text.lower().strip() + return any(hw in t for hw in HOTWORDS) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _is_exit(self, text: str) -> bool: + if not text: + return True + return any(w in text.lower() for w in EXIT_WORDS) + + def _clean_json(self, raw: str) -> str: + raw = raw.strip() + if raw.startswith("```"): + lines = raw.splitlines() + raw = "\n".join( + lines[1:-1] if lines[-1].strip() == "```" else lines[1:] + ).strip() + return raw + + # ------------------------------------------------------------------ + # File I/O + # ------------------------------------------------------------------ + + async def _load_stats(self) -> Optional[dict]: + try: + exists = await self.capability_worker.check_if_file_exists(STATS_FILE, False) + if not exists: + return None + raw = await self.capability_worker.read_file(STATS_FILE, False) + if not raw or not raw.strip(): + return None + return json.loads(raw) + except Exception: + return None + + async def _save_stats(self, data: dict): + try: + exists = await self.capability_worker.check_if_file_exists(STATS_FILE, False) + if exists: + await self.capability_worker.delete_file(STATS_FILE, False) + await self.capability_worker.write_file( + STATS_FILE, json.dumps(data, indent=2), False + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"[InsightsCoach] Save error: {e}") + + # ------------------------------------------------------------------ + # Intent classification + # ------------------------------------------------------------------ + + def _classify_intent(self, text: str) -> str: + t = text.lower() + + if any(w in t for w in ("set goal", "filler goal", "fewer than", "less than", "target", "limit")): + return "SET_GOAL" + + nudge_on = any(w in t for w in ("watch my fillers", "real-time", "coach me", "nudge", "alert me", "heads up")) + nudge_off = any(w in t for w in ("stop watching", "stop coaching", "no nudge", "stop alerts", "disable nudge")) + if nudge_on and not nudge_off: + return "TOGGLE_NUDGE_ON" + if nudge_off: + return "TOGGLE_NUDGE_OFF" + + detail_areas = { + "filler": "DETAIL_FILLERS", + "hedge": "DETAIL_ASSERTIVENESS", + "assertive": "DETAIL_ASSERTIVENESS", + "vocab": "DETAIL_VOCABULARY", + "word": "DETAIL_VOCABULARY", + "question": "DETAIL_QUESTIONS", + "length": "DETAIL_PACE", + "sentence": "DETAIL_PACE", + } + for kw, intent in detail_areas.items(): + if kw in t: + return intent + + return "REPORT" + + # ------------------------------------------------------------------ + # Metrics helpers + # ------------------------------------------------------------------ + + def _total_fillers(self, session: dict) -> int: + return sum(session.get("filler_counts", {}).values()) + + def _vocab_diversity(self, session: dict) -> float: + words = session.get("total_words", 0) + unique = session.get("unique_word_count", 0) + return round(unique / words, 2) if words > 0 else 0.0 + + def _filler_rate(self, session: dict) -> float: + words = session.get("total_words", 0) + fillers = self._total_fillers(session) + return round(fillers / words, 4) if words > 0 else 0.0 + + def _question_ratio(self, session: dict) -> float: + utterances = session.get("total_utterances", 0) + questions = session.get("question_count", 0) + return round(questions / utterances, 2) if utterances > 0 else 0.0 + + def _calc_streak(self, history: list) -> int: + """Count consecutive days of decreasing filler_rate.""" + if len(history) < 2: + return 0 + streak = 0 + sorted_h = sorted(history, key=lambda x: x.get("date", ""), reverse=True) + for i in range(len(sorted_h) - 1): + if sorted_h[i].get("filler_rate", 0) < sorted_h[i + 1].get("filler_rate", 0): + streak += 1 + else: + break + return streak + + def _detect_milestone(self, session: dict, history: list) -> str: + """Return a milestone string if the user hit a personal best, else ''.""" + if not history: + return "" + total_fillers_today = self._total_fillers(session) + filler_rate_today = self._filler_rate(session) + vocab_today = self._vocab_diversity(session) + + historical_filler_rates = [h.get("filler_rate", 999) for h in history] + historical_vocab = [h.get("vocabulary_diversity", 0) for h in history] + + if total_fillers_today == 0 and session.get("total_utterances", 0) >= 10: + return "Flawless session — not a single filler word detected!" + if filler_rate_today < min(historical_filler_rates): + return "This is your cleanest session yet — lowest filler rate on record!" + if vocab_today > max(historical_vocab, default=0): + return "Your vocabulary diversity hit a new personal high today!" + return "" + + # ------------------------------------------------------------------ + # Goal helpers + # ------------------------------------------------------------------ + + def _parse_goal_number(self, text: str) -> Optional[int]: + """Extract a target number from text like 'fewer than 10 fillers'.""" + prompt = ( + f"The user said: '{text}'\n" + "Extract the numeric filler word target they want to set. " + "Return ONLY valid JSON — no markdown:\n" + '{"target": 10}' + "\nIf no number found, return: {\"target\": null}" + ) + try: + raw = self.capability_worker.text_to_text_response(prompt) + result = json.loads(self._clean_json(raw)) + val = result.get("target") + return int(val) if val is not None else None + except Exception: + # Fallback: regex + m = re.search(r'\b(\d+)\b', text) + return int(m.group(1)) if m else None + + # ------------------------------------------------------------------ + # Report generation + # ------------------------------------------------------------------ + + def _generate_report(self, session: dict, history: list, + goal: Optional[int], streak: int, milestone: str) -> str: + total_fillers = self._total_fillers(session) + vocab_div = self._vocab_diversity(session) + self._filler_rate(session) + question_ratio = self._question_ratio(session) + avg_len = session.get("avg_utterance_length", 0.0) + total_utterances = session.get("total_utterances", 0) + top_fillers = sorted( + session.get("filler_counts", {}), + key=lambda k: session["filler_counts"][k], + reverse=True, + )[:3] + new_vocab = session.get("new_vocab_words", [])[:3] + total_hedges = sum(session.get("hedging_counts", {}).values()) + + # Historical averages + hist_avg_fillers = ( + round(sum(h.get("total_fillers", 0) for h in history) / len(history), 1) + if history else None + ) + hist_avg_vocab = ( + round(sum(h.get("vocabulary_diversity", 0) for h in history) / len(history), 2) + if history else None + ) + + # Goal progress text + goal_text = "" + if goal is not None: + if total_fillers <= goal: + goal_text = f"You crushed your filler goal of {goal} — only {total_fillers} fillers!" + else: + remaining = goal - total_fillers + if remaining > 0: + goal_text = f"You're at {total_fillers} fillers, {remaining} away from your goal of {goal}." + else: + goal_text = f"You went over your goal of {goal} — {total_fillers} fillers so far. Keep working on it!" + + # New vocab highlight + vocab_text = "" + if new_vocab: + words = ", ".join(f"'{w}'" for w in new_vocab) + vocab_text = f"New words today: {words}." + + system_prompt = ( + "You are a friendly, encouraging communication coach delivering a brief spoken report. " + "Be warm and supportive — like a trusted friend who happens to be an expert. " + "Lead with something genuinely positive. Add one specific improvement suggestion at the end. " + "Be specific with numbers. Keep it to 4-6 sentences. " + "No bullet points — this will be spoken aloud. No robotic or clinical tone. " + "If there's a milestone or streak, celebrate it enthusiastically but naturally. " + "Note: filler counts may be slightly conservative because speech-to-text sometimes " + "removes fillers like 'um' and 'uh' from transcriptions." + ) + + prompt = ( + f"Today's communication stats:\n" + f"- Total utterances: {total_utterances}\n" + f"- Filler words: {total_fillers} total" + + (f" (top: {', '.join(top_fillers)})" if top_fillers else "") + "\n" + f"- Hedging phrases (maybe, I think, etc.): {total_hedges}\n" + f"- Vocabulary diversity: {int(vocab_div * 100)}%\n" + f"- Question ratio: {int(question_ratio * 100)}% of utterances are questions\n" + f"- Average utterance length: {avg_len} words\n" + ) + + if hist_avg_fillers is not None: + prompt += f"\nHistorical average: {hist_avg_fillers} fillers/session, {int((hist_avg_vocab or 0) * 100)}% vocabulary diversity\n" + if streak > 0: + prompt += f"\nStreak: {streak} consecutive days of improving filler rate!\n" + if milestone: + prompt += f"\nMilestone: {milestone}\n" + if goal_text: + prompt += f"\nGoal progress: {goal_text}\n" + if vocab_text: + prompt += f"\n{vocab_text}\n" + + prompt += "\nGenerate the spoken coaching report now." + + try: + return self.capability_worker.text_to_text_response( + prompt, system_prompt=system_prompt + ) + except Exception: + # Fallback template + parts = [ + f"So far today you've spoken {total_utterances} times.", + f"You used {total_fillers} filler words" + ( + f" — your most common being '{top_fillers[0]}'" if top_fillers else "" + ) + ".", + ] + if hist_avg_fillers is not None: + direction = "down" if total_fillers < hist_avg_fillers else "up" + parts.append(f"That's {direction} from your average of {hist_avg_fillers}.") + if milestone: + parts.append(milestone) + parts.append(f"Your vocabulary diversity is {int(vocab_div * 100)}%.") + return " ".join(parts) + + def _generate_detail_report(self, area: str, session: dict, history: list) -> str: + """Generate a focused sub-report for a specific communication area.""" + area_data = {} + + if area == "fillers": + filler_counts = session.get("filler_counts", {}) + total = sum(filler_counts.values()) + top = sorted(filler_counts, key=filler_counts.get, reverse=True)[:5] + hist_rates = [h.get("filler_rate", 0) for h in history] + area_data = { + "total_fillers": total, + "breakdown": {k: filler_counts[k] for k in top}, + "rate_percent": round(self._filler_rate(session) * 100, 1), + "historical_avg_rate": round(sum(hist_rates) / len(hist_rates) * 100, 1) if hist_rates else None, + "tip": COACHING_TIPS["fillers"], + } + prompt = f"Filler word detail report:\n{json.dumps(area_data, indent=2)}\nDeliver a 2-3 sentence spoken breakdown. Mention the top fillers by name, give the rate, compare with history if available, and end with the tip. Warm, encouraging tone." + + elif area == "assertiveness": + hedge_counts = session.get("hedging_counts", {}) + total = sum(hedge_counts.values()) + top = sorted(hedge_counts, key=hedge_counts.get, reverse=True)[:3] + utterances = session.get("total_utterances", 1) + area_data = { + "total_hedges": total, + "hedge_rate": round(total / utterances, 2), + "top_hedges": {k: hedge_counts[k] for k in top}, + "tip": COACHING_TIPS["hedging"], + } + prompt = f"Assertiveness / hedging detail report:\n{json.dumps(area_data, indent=2)}\nDeliver a 2-3 sentence spoken summary. Warm, encouraging tone." + + elif area == "vocabulary": + new_vocab = session.get("new_vocab_words", [])[:5] + diversity = int(self._vocab_diversity(session) * 100) + hist_vocab = [int(h.get("vocabulary_diversity", 0) * 100) for h in history] + area_data = { + "diversity_percent": diversity, + "historical_avg_percent": round(sum(hist_vocab) / len(hist_vocab)) if hist_vocab else None, + "new_words_today": new_vocab, + "tip": COACHING_TIPS["vocabulary"], + } + prompt = f"Vocabulary detail report:\n{json.dumps(area_data, indent=2)}\nDeliver a 2-3 sentence spoken summary. Mention new words if any. Warm, encouraging tone." + + elif area == "questions": + ratio = int(self._question_ratio(session) * 100) + hist_ratios = [int(h.get("question_ratio", 0) * 100) for h in history] + area_data = { + "question_percent": ratio, + "question_count": session.get("question_count", 0), + "statement_count": session.get("statement_count", 0), + "historical_avg_percent": round(sum(hist_ratios) / len(hist_ratios)) if hist_ratios else None, + "tip": COACHING_TIPS["questions"], + } + prompt = f"Question vs statement detail report:\n{json.dumps(area_data, indent=2)}\nDeliver a 2-3 sentence spoken summary. Warm, encouraging tone." + + elif area == "pace": + avg_len = session.get("avg_utterance_length", 0.0) + area_data = { + "avg_utterance_length_words": avg_len, + "tip": COACHING_TIPS["pace"], + } + prompt = f"Speaking pace / length detail report:\n{json.dumps(area_data, indent=2)}\nDeliver a 2-3 sentence spoken summary. Warm, encouraging tone." + + else: + return "I don't have a detail breakdown for that area yet." + + system_prompt = ( + "You are a friendly communication coach. Keep it to 2-3 sentences, " + "spoken aloud, no bullet points. Be specific with numbers. Warm and encouraging." + ) + try: + return self.capability_worker.text_to_text_response( + prompt, system_prompt=system_prompt + ) + except Exception: + return f"Here's what I tracked: {json.dumps(area_data)}." + + # ------------------------------------------------------------------ + # Main run loop + # ------------------------------------------------------------------ + + async def _run(self): + try: + await self.capability_worker.wait_for_complete_transcription() + + # Get the trigger utterance for intent classification + trigger_text = "" + try: + history = self.capability_worker.get_full_message_history() + history = history or [] + user_msgs = [m for m in history if m.get("role") == "user"] + if user_msgs: + trigger_text = user_msgs[-1].get("content", "") or "" + if not isinstance(trigger_text, str): + trigger_text = "" + except Exception: + trigger_text = "" + + intent = self._classify_intent(trigger_text) + + # Load stats + data = await self._load_stats() + + # ── TOGGLE NUDGE ──────────────────────────────────────── + if intent in ("TOGGLE_NUDGE_ON", "TOGGLE_NUDGE_OFF"): + enable = intent == "TOGGLE_NUDGE_ON" + if data: + data.setdefault("settings", {})["nudge_enabled"] = enable + await self._save_stats(data) + if enable: + await self.capability_worker.speak( + "Real-time coaching is on. I'll give you a gentle heads-up " + "whenever I notice a cluster of filler words. You've got this!" + ) + else: + await self.capability_worker.speak( + "Got it — I'll stop the real-time nudges. " + "You can still ask for a full report anytime." + ) + return + + # ── SET GOAL ──────────────────────────────────────────── + if intent == "SET_GOAL": + target = self._parse_goal_number(trigger_text) + if target is None: + await self.capability_worker.speak( + "I didn't catch a number. Try saying something like " + "'set a goal of 10 fillers today'." + ) + return + if data: + data.setdefault("settings", {})["filler_goal"] = target + await self._save_stats(data) + await self.capability_worker.speak( + f"Goal set! I'll track your progress toward fewer than {target} filler words today. " + "Ask for a report anytime to see how you're doing." + ) + return + + # ── Check minimum data ────────────────────────────────── + if not data: + await self.capability_worker.speak( + "I haven't collected any data yet. " + "Keep chatting and ask me again in a few minutes!" + ) + return + + session = data.get("current_session", {}) + utterances = session.get("total_utterances", 0) + + if utterances < 5: + await self.capability_worker.speak( + f"I've only tracked {utterances} utterances so far — " + "not quite enough for a meaningful report. " + "Keep the conversation going and check back in a few minutes!" + ) + return + + history = data.get("daily_history", []) + settings = data.get("settings", {}) + goal = settings.get("filler_goal") + + # ── DETAIL REPORTS ────────────────────────────────────── + detail_map = { + "DETAIL_FILLERS": "fillers", + "DETAIL_ASSERTIVENESS": "assertiveness", + "DETAIL_VOCABULARY": "vocabulary", + "DETAIL_QUESTIONS": "questions", + "DETAIL_PACE": "pace", + } + if intent in detail_map: + area = detail_map[intent] + report = self._generate_detail_report(area, session, history) + await self.capability_worker.speak(report) + + reply = await self.capability_worker.user_response() + if not self._is_exit(reply) and reply: + # One follow-up: check if they want another area + follow_intent = self._classify_intent(reply) + if follow_intent in detail_map: + follow_report = self._generate_detail_report( + detail_map[follow_intent], session, history + ) + await self.capability_worker.speak(follow_report) + return + + # ── FULL REPORT ───────────────────────────────────────── + streak = self._calc_streak(history) + milestone = self._detect_milestone(session, history) + report = self._generate_report(session, history, goal, streak, milestone) + await self.capability_worker.speak(report) + + # Follow-up offer + await self.capability_worker.speak( + "Want more detail on any area? " + "I can break down your fillers, vocabulary, assertiveness, or question ratio. " + "Or just say stop." + ) + + follow_reply = await self.capability_worker.user_response() + if self._is_exit(follow_reply) or not follow_reply: + return + + follow_intent = self._classify_intent(follow_reply) + + # Handle goal/nudge toggle in follow-up + if follow_intent == "SET_GOAL": + target = self._parse_goal_number(follow_reply) + if target: + data.setdefault("settings", {})["filler_goal"] = target + await self._save_stats(data) + await self.capability_worker.speak( + f"Done — filler goal set to {target}. I'll track your progress!" + ) + return + + if follow_intent in ("TOGGLE_NUDGE_ON", "TOGGLE_NUDGE_OFF"): + enable = follow_intent == "TOGGLE_NUDGE_ON" + data.setdefault("settings", {})["nudge_enabled"] = enable + await self._save_stats(data) + msg = ( + "Real-time coaching enabled. I'll nudge you when I spot filler clusters." + if enable else + "Real-time nudges turned off. Ask for a report anytime!" + ) + await self.capability_worker.speak(msg) + return + + if follow_intent in detail_map: + follow_report = self._generate_detail_report( + detail_map[follow_intent], session, history + ) + await self.capability_worker.speak(follow_report) + return + + # Fallback: they said something, but we couldn't classify it + await self.capability_worker.speak( + "No problem! Come back anytime you want to check in on your communication." + ) + + except Exception as e: + try: + self.worker.editor_logging_handler.error(f"[InsightsCoach] Skill error: {e}") + await self.capability_worker.speak( + "Something went wrong. Try asking again in a moment." + ) + except Exception: + pass + finally: + self.capability_worker.resume_normal_flow() + + # ------------------------------------------------------------------ + # Entry point + # ------------------------------------------------------------------ + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self._run())