Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions platform/deploy/systemd/ralfia-daily-companion.service
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
149 changes: 121 additions & 28 deletions platform/inneros_core_runtime/agents/ag50_daily_companion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(".") + ".")

Expand All @@ -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):
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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"
Expand Down Expand Up @@ -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,
}


Expand Down
5 changes: 4 additions & 1 deletion platform/scripts/run_daily_companion_cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
37 changes: 36 additions & 1 deletion platform/tests/test_ag50_daily_companion.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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()
Loading