From b13cc3d9b1b8d9be72864337d41afde1d70a8125 Mon Sep 17 00:00:00 2001 From: Rafael Lopez Date: Mon, 19 Oct 2026 18:44:19 -0500 Subject: [PATCH] fix: align scheduled Alexa briefs with en-US voice --- .../systemd/ralfia-daily-companion.service | 1 + .../agents/ag50_daily_companion.py | 149 ++++++++++++++---- platform/scripts/run_daily_companion_cycle.py | 5 +- platform/tests/test_ag50_daily_companion.py | 37 ++++- 4 files changed, 162 insertions(+), 30 deletions(-) diff --git a/platform/deploy/systemd/ralfia-daily-companion.service b/platform/deploy/systemd/ralfia-daily-companion.service index 37b896c4..7410f1e9 100644 --- a/platform/deploy/systemd/ralfia-daily-companion.service +++ b/platform/deploy/systemd/ralfia-daily-companion.service @@ -7,6 +7,7 @@ Type=oneshot WorkingDirectory=/home/rlopez/inneros/inneros_core/platform Environment=PYTHONPATH=/home/rlopez/inneros/inneros_core/platform Environment=RALFIA_DAILY_COMPANION_ECHO=notify.echo_cuarto_speak +Environment=RALFIA_DAILY_COMPANION_LANGUAGE=en-US ExecStart=/home/rlopez/inneros/inneros_core/platform/venv/bin/python3 /home/rlopez/inneros/inneros_core/platform/scripts/run_daily_companion_cycle.py [Install] diff --git a/platform/inneros_core_runtime/agents/ag50_daily_companion.py b/platform/inneros_core_runtime/agents/ag50_daily_companion.py index 25bc21d4..33d01a5c 100644 --- a/platform/inneros_core_runtime/agents/ag50_daily_companion.py +++ b/platform/inneros_core_runtime/agents/ag50_daily_companion.py @@ -2,22 +2,59 @@ from __future__ import annotations +from datetime import datetime from typing import Any +from zoneinfo import ZoneInfo from raphiia_openai.agent_auto_log import record_agent_run AGENT_ID = "AG-50_DAILY_COMPANION" +DEFAULT_TIMEZONE = "America/Guayaquil" -def _spoken_snapshot(live: dict[str, Any], current_state: dict[str, Any]) -> str: +def _is_english(language: str) -> bool: + return str(language or "").lower().startswith("en") + + +def _greeting(language: str, now: datetime | None = None) -> str: + local_now = now or datetime.now(ZoneInfo(DEFAULT_TIMEZONE)) + hour = local_now.hour + if _is_english(language): + if hour < 12: + return "Good morning" + if hour < 18: + return "Good afternoon" + return "Good evening" + if hour < 12: + return "Buenos días" + if hour < 18: + return "Buenas tardes" + return "Buenas noches" + + +def _spoken_task_count(count: int, language: str) -> str: + """Avoid large raw numbers that Alexa reads digit-by-digit in announcements.""" + if count > 100: + return "more than one hundred" if _is_english(language) else "más de cien" + return str(max(0, count)) + + +def _spoken_snapshot( + live: dict[str, Any], + current_state: dict[str, Any], + *, + language: str = "es-US", +) -> str: """Build a deterministic owner brief before any model is involved.""" parts: list[str] = [] priority = live.get("current_priority") or {} title = str(priority.get("title") or "").strip() summary = str(priority.get("summary") or "").strip() + english = _is_english(language) if title: - parts.append(f"Prioridad principal: {title}.") + label = "Main priority" if english else "Prioridad principal" + parts.append(f"{label}: {title}.") if summary: parts.append(summary[:360].rstrip(".") + ".") @@ -28,19 +65,33 @@ def _spoken_snapshot(live: dict[str, Any], current_state: dict[str, Any]) -> str task_title = str(task.get("title") or "").strip() if not task_title: continue - status = str(task.get("status") or "pendiente").replace("_", " ") + status = str(task.get("status") or ("pending" if english else "pendiente")).replace("_", " ") priority_name = str(task.get("priority") or "").upper() prefix = f"{priority_name} " if priority_name else "" task_lines.append(f"{prefix}{task_title} ({status})") if task_lines: - parts.append( - f"Tienes {int(live.get('open_ops_count') or len(tasks))} tareas de desarrollo abiertas. " - + " Las principales son: " - + "; ".join(task_lines) - + "." - ) + count = int(live.get("open_ops_count") or len(tasks)) + spoken_count = _spoken_task_count(count, language) + if english: + parts.append( + f"There are {spoken_count} open development tasks. " + + "The main ones are: " + + "; ".join(task_lines) + + "." + ) + else: + parts.append( + f"Tienes {spoken_count} tareas de desarrollo abiertas. " + + "Las principales son: " + + "; ".join(task_lines) + + "." + ) else: - parts.append("No hay órdenes de desarrollo abiertas en coordinación.") + parts.append( + "There are no open development orders in coordination." + if english + else "No hay órdenes de desarrollo abiertas en coordinación." + ) state = current_state.get("state") if isinstance(current_state, dict) else None if isinstance(state, dict): @@ -55,24 +106,49 @@ def _spoken_snapshot(live: dict[str, Any], current_state: dict[str, Any]) -> str if label: labels.append(str(label).strip()) if labels: - parts.append("Pendientes personales: " + "; ".join(labels) + ".") + label = "Personal reminders" if english else "Pendientes personales" + parts.append(label + ": " + "; ".join(labels) + ".") return " ".join(parts).strip() -def _polish_spoken_brief(snapshot: str, local_model_router: Any) -> tuple[str, dict[str, Any]]: +def _polish_spoken_brief( + snapshot: str, + local_model_router: Any, + *, + language: str = "es-US", + now: datetime | None = None, +) -> tuple[str, dict[str, Any]]: """Use local inference only as a presentation layer; deterministic text remains fallback.""" + greeting = _greeting(language, now) + english = _is_english(language) if not snapshot: - return "No encontré pendientes prioritarios para esta mañana.", {"ok": True, "fallback": True} - - prompt = ( - "Convierte este snapshot en un brief hablado en español para Rafael. " - "Máximo 110 palabras. Empieza con 'Buenos días'. Sé concreto, no inventes datos, " - "no menciones IDs internos, telemetría, nombres de modelos ni detalles de infraestructura " - "salvo que sean un bloqueo del proyecto. Ordena: prioridad principal, hasta cuatro pendientes, " - "y una frase final de enfoque.\n\n" - f"SNAPSHOT:\n{snapshot}" - ) + empty = ( + f"{greeting}. I found no priority items right now." + if english + else f"{greeting}. No encontré pendientes prioritarios en este momento." + ) + return empty, {"ok": True, "fallback": True} + + if english: + prompt = ( + "Turn this snapshot into a natural spoken English briefing for Rafael. " + f"Use United States English and begin exactly with '{greeting}'. Maximum 90 words. " + "Be concrete and do not invent facts. Do not mention internal IDs, telemetry, model names, " + "or infrastructure details unless they block the project. Avoid reading long numbers or lists; " + "summarize large counts naturally. Order: main priority, up to four items, and one closing focus sentence.\n\n" + f"SNAPSHOT:\n{snapshot}" + ) + else: + prompt = ( + "Convierte este snapshot en un brief hablado en español natural para Rafael. " + f"Empieza exactamente con '{greeting}'. Máximo 90 palabras. Sé concreto, no inventes datos, " + "no menciones IDs internos, telemetría, nombres de modelos ni detalles de infraestructura " + "salvo que sean un bloqueo del proyecto. Evita números largos y listas extensas; resume los " + "conteos grandes de forma natural. Ordena: prioridad principal, hasta cuatro pendientes, " + "y una frase final de enfoque.\n\n" + f"SNAPSHOT:\n{snapshot}" + ) result = local_model_router.run_local_model( task_type="daily_brief", prompt=prompt, @@ -83,10 +159,16 @@ def _polish_spoken_brief(snapshot: str, local_model_router: Any) -> tuple[str, d text = str(result.get("response") or "").strip() if text: return text[:900], result - return snapshot[:900], result + return f"{greeting}. {snapshot[:820]}", result -def run_daily_companion(message: str = "", *, include_brief: bool = True) -> dict[str, Any]: +def run_daily_companion( + message: str = "", + *, + include_brief: bool = True, + language: str = "es-US", + now: datetime | None = None, +) -> dict[str, Any]: """Brief real de proyectos + memoria personal + conversación local.""" from raphiia_openai import coordination_live from raphiia_openai import daily_memory @@ -104,8 +186,12 @@ def run_daily_companion(message: str = "", *, include_brief: bool = True) -> dic "actor": "RAFAEL", }) - snapshot = _spoken_snapshot(live, current_state) if include_brief else "" - spoken_brief, brief_model = _polish_spoken_brief(snapshot, local_model_router) if include_brief else ("", {"ok": True, "skipped": True}) + snapshot = _spoken_snapshot(live, current_state, language=language) if include_brief else "" + spoken_brief, brief_model = ( + _polish_spoken_brief(snapshot, local_model_router, language=language, now=now) + if include_brief + else ("", {"ok": True, "skipped": True}) + ) reply = "" route = local_model_router.route_ai_task( @@ -114,9 +200,15 @@ def run_daily_companion(message: str = "", *, include_brief: bool = True) -> dic task_type="daily_brief", ) if message.strip(): + instruction = ( + "Answer as Rafael's operational companion using only this real context. " + "If information is missing, say so. Be brief." + if _is_english(language) + else "Responde como compañero operativo de Rafael usando solo este contexto real. " + "Si falta información, dilo. Sé breve." + ) prompt = ( - "Responde como compañero operativo de Rafael usando solo este contexto real. " - "Si falta información, dilo. Sé breve.\n\n" + f"{instruction}\n\n" f"Mensaje: {message}\n" f"Brief actual: {spoken_brief}\n" f"Memoria reciente encontrada: {recent.get('count', 0)} items\n" @@ -154,6 +246,7 @@ def run_daily_companion(message: str = "", *, include_brief: bool = True) -> dic "model": brief_model.get("model"), }, "profile_mcp": "daily_companion", + "language": language, } diff --git a/platform/scripts/run_daily_companion_cycle.py b/platform/scripts/run_daily_companion_cycle.py index 245f7e51..9f07017e 100644 --- a/platform/scripts/run_daily_companion_cycle.py +++ b/platform/scripts/run_daily_companion_cycle.py @@ -10,13 +10,15 @@ sys.path.insert(0, ROOT) DEFAULT_ECHO = "notify.echo_cuarto_speak" +DEFAULT_LANGUAGE = "en-US" def main() -> int: from raphiia_openai import homeassistant_client as ha from raphiia_openai.agents import ag50_daily_companion as ag50 - result = ag50.run_daily_companion("", include_brief=True) + language = os.getenv("RALFIA_DAILY_COMPANION_LANGUAGE", DEFAULT_LANGUAGE).strip() or DEFAULT_LANGUAGE + result = ag50.run_daily_companion("", include_brief=True, language=language) if not result.get("ok"): print("ok=False reason=companion_failed") return 1 @@ -38,6 +40,7 @@ def main() -> int: "ops=", result.get("open_ops_count"), "memory=", result.get("memory_hits"), "echo=", notify_entity, + "language=", language, ) return 0 if delivery.get("ok") else 1 diff --git a/platform/tests/test_ag50_daily_companion.py b/platform/tests/test_ag50_daily_companion.py index f4220a48..1bd1eba4 100644 --- a/platform/tests/test_ag50_daily_companion.py +++ b/platform/tests/test_ag50_daily_companion.py @@ -1,5 +1,6 @@ import importlib.util import unittest +from datetime import datetime from pathlib import Path MODULE_PATH = Path(__file__).resolve().parents[1] / "inneros_core_runtime" / "agents" / "ag50_daily_companion.py" @@ -37,10 +38,44 @@ def run_local_model(**kwargs): spoken, result = ag50._polish_spoken_brief( "Prioridad principal: cerrar Alexa.", Router(), + now=datetime(2026, 10, 19, 8, 0), ) - self.assertEqual(spoken, "Prioridad principal: cerrar Alexa.") + self.assertEqual(spoken, "Buenos días. Prioridad principal: cerrar Alexa.") self.assertFalse(result["ok"]) + def test_english_snapshot_is_voice_safe_for_large_counts(self): + live = { + "open_ops_count": 143, + "open_ops_tasks": [ + {"title": "Finish Alexa demo", "status": "in_progress", "priority": "p0"}, + ], + } + text = ag50._spoken_snapshot(live, {"state": {}}, language="en-US") + self.assertIn("more than one hundred", text) + self.assertIn("Finish Alexa demo", text) + self.assertNotIn("143", text) + self.assertNotIn("Tienes", text) + + def test_english_evening_fallback_uses_local_time_greeting(self): + class Router: + @staticmethod + def run_local_model(**kwargs): + return {"ok": False, "error": "offline"} + + spoken, _ = ag50._polish_spoken_brief( + "Main priority: restore the Alexa demo.", + Router(), + language="en-US", + now=datetime(2026, 10, 19, 18, 0), + ) + self.assertEqual(spoken, "Good evening. Main priority: restore the Alexa demo.") + + def test_spanish_greeting_is_not_hardcoded_to_morning(self): + self.assertEqual( + ag50._greeting("es-US", datetime(2026, 10, 19, 18, 0)), + "Buenas noches", + ) + if __name__ == "__main__": unittest.main()