| """Voice journal capture and summarization module. |
| |
| Provides two paths for journal creation: |
| 1. Voice path: record audio β transcribe β summarize (requires STT backend) |
| 2. Text path: type a journal entry directly β summarize |
| |
| Summarization uses a heuristic + optional LLM approach so the pipeline |
| works even when no LLM is available. |
| """ |
|
|
| import json |
| import uuid |
| import re |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from typing import Optional |
|
|
|
|
| |
| MOOD_KEYWORDS: dict[str, list[str]] = { |
| "funny": [ |
| "hilarious", "laughed", "laughing", "funny", "silly", "ridiculous", |
| "comedic", "joke", "cracked up", "wheezing", "snort", |
| ], |
| "confused": [ |
| "confused", "lost", "don't understand", "no idea", "where is", |
| "which way", "puzzled", "baffled", "not sure", "wait what", |
| ], |
| "excited": [ |
| "excited", "amazing", "awesome", "incredible", "wow", "yes!", |
| "let's go", "so cool", "unbelievable", "found it", "nailed it", |
| ], |
| "tense": [ |
| "nervous", "worried", "oh no", "scary", "rushed", "panicked", |
| "close call", "barely made it", "running out of time", "hurry", |
| ], |
| "lucky": [ |
| "lucky", "by chance", "just happened", "stumbled", "coincidence", |
| "right place", "phew", "got lucky", "luckily", "close one", |
| ], |
| "chaotic": [ |
| "chaos", "everything at once", "all over the place", "wild", |
| "crazy", "mayhem", "pandemonium", "disaster", "total mess", "frantic", |
| ], |
| } |
|
|
| |
| HIGH_VALUE_SIGNALS = [ |
| "turning point", "decided to", "changed our mind", "found it", |
| "last second", "just in time", "unexpected", "surprise", "nobody expected", |
| "close call", "first time", "only team", "beat them", "won", |
| "everyone cheered", "high five", "best moment", "highlight", |
| ] |
|
|
| LOCATION_KEYWORDS = [ |
| "square", "street", "corner", "park", "garden", "bridge", "cafe", |
| "church", "museum", "statue", "fountain", "canal", "river", "market", |
| "tower", "palace", "mural", "gallery", "arch", "gate", "alley", |
| "plaza", "staircase", "passage", "courtyard", |
| ] |
|
|
|
|
| |
|
|
| def transcribe_journal( |
| audio_path: str, |
| language: str = "en", |
| prefer: str = "cohere", |
| ) -> str: |
| """Transcribe voice journal audio to text. |
| |
| Tries (in order, controlled by ``prefer``): |
| 1. ``"cohere"`` (default) β ``CohereLabs/cohere-transcribe-03-2026`` |
| via ``app.services.asr``. Lazy-loaded; honors the |
| ``CITYQUEST_SKIP_MODEL`` / ``CITYQUEST_FAST_TEST`` env vars. |
| 2. ``"whisper"`` β OpenAI Whisper, runs locally. |
| 3. Returns an empty string with a warning so the caller can fall |
| back to typed input. |
| |
| Args: |
| audio_path: Path to a recorded audio file (wav/mp3/m4a/ogg/webm). |
| language: Language code passed to the ASR model (e.g. ``"en"``). |
| prefer: ``"cohere"`` | ``"whisper"`` | ``"any"``. |
| |
| Returns: |
| Transcribed text, or ``""`` if transcription is unavailable. |
| """ |
| path = Path(audio_path) |
| if not path.exists(): |
| raise FileNotFoundError(f"Audio file not found: {audio_path}") |
|
|
| |
| if prefer in ("cohere", "any"): |
| try: |
| from app.services.asr import transcribe_text as _cohere_transcribe |
|
|
| text = _cohere_transcribe(str(path), language=language) |
| if text: |
| return text |
| except ImportError as exc: |
| print(f"[journal] Cohere ASR import failed: {exc}") |
| except Exception as exc: |
| print(f"[journal] Cohere ASR failed: {type(exc).__name__}: {exc}") |
|
|
| if prefer == "cohere": |
| |
| return "" |
|
|
| |
| try: |
| import whisper |
|
|
| model = whisper.load_model("base") |
| result = model.transcribe(str(path)) |
| return result.get("text", "").strip() |
| except ImportError: |
| print( |
| "[journal] whisper not installed β install with: " |
| "pip install openai-whisper. Falling back to empty transcript." |
| ) |
| except Exception as exc: |
| print(f"[journal] Whisper transcription failed: {exc}") |
|
|
| return "" |
|
|
|
|
| |
|
|
| def detect_mood(transcript: str) -> str: |
| """Detect the dominant mood from transcript text using keyword matching. |
| |
| Returns one of: ``funny``, ``confused``, ``excited``, ``tense``, |
| ``lucky``, ``chaotic``. Defaults to ``excited`` if no clear signal. |
| """ |
| lower = transcript.lower() |
| scores: dict[str, int] = {} |
|
|
| for mood, keywords in MOOD_KEYWORDS.items(): |
| count = sum(1 for kw in keywords if kw in lower) |
| if count > 0: |
| scores[mood] = count |
|
|
| if not scores: |
| return "excited" |
|
|
| return max(scores, key=scores.get) |
|
|
|
|
| |
|
|
| def extract_tags(transcript: str, task_id: Optional[str] = None) -> list[str]: |
| """Extract meaningful tags from the transcript. |
| |
| Tags include: |
| - The associated ``task_id`` if provided. |
| - Detected mood. |
| - Any mentioned locations / landmarks. |
| - Any detected story-value signal words. |
| """ |
| lower = transcript.lower() |
| tags: list[str] = [] |
|
|
| if task_id: |
| tags.append(task_id) |
|
|
| tags.append(detect_mood(transcript)) |
|
|
| for loc in LOCATION_KEYWORDS: |
| if loc in lower: |
| tags.append(loc) |
|
|
| return tags |
|
|
|
|
| |
|
|
| def assess_story_value(transcript: str) -> str: |
| """Rate the story value of a journal entry as ``low``, ``medium``, or ``high``. |
| |
| Heuristic: count the number of high-value signals present in the text. |
| """ |
| lower = transcript.lower() |
| hits = sum(1 for sig in HIGH_VALUE_SIGNALS if sig in lower) |
|
|
| word_count = len(transcript.split()) |
|
|
| |
| if word_count < 8: |
| return "low" |
| if hits >= 3: |
| return "high" |
| if hits >= 1 or word_count >= 30: |
| return "medium" |
| return "low" |
|
|
|
|
| |
|
|
| def summarize_journal( |
| transcript: str, |
| task_id: Optional[str] = None, |
| location_note: str = "", |
| ) -> dict: |
| """Summarize a journal entry with tags and story value. |
| |
| Uses a keyword-based heuristic that works offline. When an LLM is |
| available it can optionally produce a richer summary. |
| |
| Args: |
| transcript: Journal transcript text (typed or transcribed). |
| task_id: Optional associated task ID. |
| location_note: Where the entry was recorded. |
| |
| Returns: |
| Dictionary with keys ``moment_summary``, ``tags``, ``story_value``. |
| """ |
| if not transcript or not transcript.strip(): |
| return { |
| "moment_summary": "No content recorded.", |
| "tags": [], |
| "story_value": "low", |
| } |
|
|
| mood = detect_mood(transcript) |
| tags = extract_tags(transcript, task_id) |
| story_value = assess_story_value(transcript) |
|
|
| |
| sentences = re.split(r'[.!?]+', transcript) |
| first_sentence = sentences[0].strip() if sentences else transcript[:200] |
|
|
| if len(first_sentence.split()) > 40: |
| first_sentence = " ".join(first_sentence.split()[:40]) + "β¦" |
|
|
| moment_summary = f"[{mood}] {first_sentence}" |
|
|
| return { |
| "moment_summary": moment_summary, |
| "tags": tags, |
| "story_value": story_value, |
| } |
|
|
|
|
| |
|
|
| def create_journal_entry( |
| transcript: str, |
| session_id: str, |
| team_id: str = "team-a", |
| task_id: Optional[str] = None, |
| location_note: str = "", |
| photo_refs: Optional[list[str]] = None, |
| audio_ref: Optional[str] = None, |
| asr_metadata: Optional[dict] = None, |
| transcript_source: str = "typed", |
| ) -> dict: |
| """Build a complete journal entry dict matching the journal schema. |
| |
| Args: |
| transcript: Journal transcript text. |
| session_id: Game session identifier. |
| team_id: Team identifier. |
| task_id: Optional associated task ID. |
| location_note: Where the entry was recorded. |
| photo_refs: List of photo identifiers to attach. |
| audio_ref: Optional path/URL of the recorded audio clip. |
| asr_metadata: Optional dict describing the ASR pass that |
| produced ``transcript`` (model, language, status, error). |
| transcript_source: One of ``"typed"`` | ``"asr"`` | ``"hybrid"`` β |
| whether the transcript came from typing, the ASR service, |
| or a combination (ASR with manual edits). |
| |
| Returns: |
| Full journal entry dict. |
| """ |
| mood = detect_mood(transcript) |
|
|
| entry = { |
| "journal_id": str(uuid.uuid4()), |
| "timestamp": datetime.now(timezone.utc).isoformat(), |
| "session_id": session_id, |
| "team_id": team_id, |
| "transcript": transcript, |
| "mood": mood, |
| "location_note": location_note or "Unknown location", |
| "photo_refs": photo_refs or [], |
| "transcript_source": transcript_source, |
| } |
|
|
| if task_id: |
| entry["task_id"] = task_id |
|
|
| if audio_ref: |
| entry["audio_ref"] = audio_ref |
|
|
| if asr_metadata: |
| entry["asr"] = asr_metadata |
|
|
| return entry |
|
|
|
|
| def save_journal_entry(entry: dict, log_dir: str = "app/logs") -> dict: |
| """Persist a journal entry to a JSONL file. |
| |
| Args: |
| entry: Journal entry dict (as returned by ``create_journal_entry``). |
| log_dir: Directory to store logs. |
| |
| Returns: |
| The same entry dict for chaining. |
| """ |
| log_path = Path(log_dir) |
| log_path.mkdir(parents=True, exist_ok=True) |
| journal_file = log_path / "journals.jsonl" |
|
|
| with open(journal_file, "a", encoding="utf-8") as fh: |
| fh.write(json.dumps(entry, ensure_ascii=False) + "\n") |
|
|
| return entry |
|
|
|
|
| def load_journal_entries( |
| session_id: Optional[str] = None, log_dir: str = "app/logs" |
| ) -> list[dict]: |
| """Load journal entries from the JSONL log, optionally by session. |
| |
| Args: |
| session_id: If provided, only return entries for this session. |
| log_dir: Directory containing journal logs. |
| |
| Returns: |
| List of journal entry dicts. |
| """ |
| journal_file = Path(log_dir) / "journals.jsonl" |
| if not journal_file.exists(): |
| return [] |
|
|
| entries: list[dict] = [] |
| with open(journal_file, "r", encoding="utf-8") as fh: |
| for line in fh: |
| line = line.strip() |
| if not line: |
| continue |
| try: |
| entry = json.loads(line) |
| except json.JSONDecodeError: |
| continue |
| if session_id and entry.get("session_id") != session_id: |
| continue |
| entries.append(entry) |
|
|
| return entries |
|
|