from __future__ import annotations import contextlib import io import json import sys import base64 import time from pathlib import Path from typing import Any PROJECT_ROOT = Path(__file__).resolve().parents[2] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from journal.ai import analysis, chat from journal.core.models import JournalEntry def _emit(payload: dict[str, Any]) -> None: print(json.dumps(payload, ensure_ascii=True), flush=True) def _ok(data: Any) -> None: _emit({"ok": True, "data": data}) def _error(message: str) -> None: _emit({"ok": False, "error": message}) def _read_request() -> dict[str, Any]: line = sys.stdin.readline() if not line: raise ValueError("Missing request body.") try: payload = json.loads(line) except json.JSONDecodeError as exc: raise ValueError("Invalid JSON request.") from exc if not isinstance(payload, dict): raise ValueError("Request must be a JSON object.") return payload def _safe_call(fn, *args, **kwargs): with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): return fn(*args, **kwargs) def _to_entry(item: Any) -> JournalEntry: if isinstance(item, str): return JournalEntry(date="", raw_content=item) if isinstance(item, dict): raw_content = str(item.get("raw_content") or item.get("content") or "") date = str(item.get("date") or item.get("file_stem") or "") return JournalEntry(date=date, raw_content=raw_content) raise ValueError("Each entry must be a string or object.") def _normalize_entries(payload: dict[str, Any]) -> list[JournalEntry]: entries = payload.get("entries") if entries is None: return [] if not isinstance(entries, list): raise ValueError("payload.entries must be an array.") return [_to_entry(item) for item in entries] def _run_action(action: str, payload: dict[str, Any]) -> Any: if action == "health": backend = _safe_call(analysis.get_nlp_backend) return { "provider": "python-sidecar", "healthy": True, "message": f"ok ({backend})", } if action == "summarize_entry": content = payload.get("content") if not isinstance(content, str) or not content.strip(): raise ValueError("payload.content is required.") file_stem = payload.get("file_stem") date = str(file_stem) if file_stem is not None else str(payload.get("date") or "") entry = JournalEntry(date=date, raw_content=content) return _safe_call(analysis.summarize_entry, entry) if action == "summarize_all": entries = _normalize_entries(payload) return _safe_call(analysis.summarize_all_entries, entries) if action == "chat": prompt = payload.get("prompt") if not isinstance(prompt, str) or not prompt.strip(): raise ValueError("payload.prompt is required.") return _safe_call(chat.get_cloud_ai_response, prompt) if action == "embed": content = payload.get("content") if not isinstance(content, str) or not content.strip(): raise ValueError("payload.content is required.") return _safe_call(analysis.generate_embedding, content) if action == "speech.devices.list": try: import speech_recognition as sr names = sr.Microphone.list_microphone_names() devices = [ {"index": index, "name": name} for index, name in enumerate(names) ] warning = "No microphone devices detected." if not devices else None return {"devices": devices, "warning": warning} except Exception as exc: return {"devices": [], "warning": f"Speech device listing unavailable: {exc}"} if action == "speech.transcribe": engine = str(payload.get("engine") or "whisper").strip().lower() or "whisper" whisper_model = str(payload.get("whisper_model") or "base").strip() or "base" simulate_delay_ms = payload.get("simulate_delay_ms") if isinstance(simulate_delay_ms, int) and simulate_delay_ms > 0: time.sleep(simulate_delay_ms / 1000.0) passthrough = payload.get("text") if isinstance(passthrough, str) and passthrough.strip(): return {"text": passthrough, "engine": engine} audio_base64 = payload.get("audio_base64") if not isinstance(audio_base64, str) or not audio_base64.strip(): raise ValueError("payload.audio_base64 is required.") try: audio_bytes = base64.b64decode(audio_base64, validate=True) except Exception as exc: raise ValueError("payload.audio_base64 must be valid base64.") from exc import speech_recognition as sr recognizer = sr.Recognizer() with sr.AudioFile(io.BytesIO(audio_bytes)) as source: audio = recognizer.record(source) if engine == "google": text = recognizer.recognize_google(audio) elif engine == "whisper": text = recognizer.recognize_whisper(audio, model=whisper_model) elif engine == "faster-whisper": if not hasattr(recognizer, "recognize_faster_whisper"): raise ValueError("faster-whisper engine unavailable in current SpeechRecognition build.") text = recognizer.recognize_faster_whisper(audio, model=whisper_model) elif engine == "sphinx": text = recognizer.recognize_sphinx(audio) else: raise ValueError(f"Unsupported speech engine: {engine}") return {"text": text, "engine": engine} raise ValueError(f"Unknown action: {action}") def main() -> int: try: request = _read_request() action = request.get("action") payload = request.get("payload") or {} if not isinstance(action, str) or not action.strip(): raise ValueError("Request action is required.") if not isinstance(payload, dict): raise ValueError("Request payload must be an object.") result = _run_action(action.strip(), payload) _ok(result) return 0 except Exception as exc: _error(str(exc)) return 1 if __name__ == "__main__": raise SystemExit(main())