cq-test / app /services /journal.py
NANI-Nithin's picture
feat: Added voice journal recording with Cohere ASR integration
e7db016
Raw
History Blame Contribute Delete
12.2 kB
"""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 for heuristic mood detection ──────────────────────────────
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",
],
}
# Story-value indicators
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",
]
# ── Transcription ───────────────────────────────────────────────────────────
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}")
# ── 1. Cohere Transcribe (preferred) ──────────────────────────────────
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":
# Don't fall back to Whisper unless the caller asks for it.
return ""
# ── 2. Whisper fallback (opt-in) ──────────────────────────────────────
try:
import whisper # type: ignore
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 ""
# ── Mood detection ──────────────────────────────────────────────────────────
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" # safe default
return max(scores, key=scores.get)
# ── Tag extraction ──────────────────────────────────────────────────────────
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
# ── Story-value scoring ─────────────────────────────────────────────────────
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())
# Very short entries are low value regardless
if word_count < 8:
return "low"
if hits >= 3:
return "high"
if hits >= 1 or word_count >= 30:
return "medium"
return "low"
# ── Summarization ───────────────────────────────────────────────────────────
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)
# Build a short summary (first sentence or first 40 words)
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,
}
# ── Full journal entry builder ──────────────────────────────────────────────
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