"""Тексты системных уведомлений (ТЗ 3.21).
Строки собирает сервер, а не service worker: воркер живёт между сессиями и не
знает ни языка пользователя, ни его настроек — у него нет ни бандла локалей, ни
запроса, из которого язык можно взять. Поэтому ru/en/uk живут здесь и осознанно
дублируют фронтовые `gamify.claimed`/`gamify.review`: тост и системное
уведомление говорят об одном и том же разными словами.
Язык разрешается той же цепочкой, что и на фронте (`i18n/locale.ts`): ручное
переопределение в настройках → локаль SSO → «ru».
"""
from sqlalchemy.orm import Session
from app.models import AppSetting, User
# Тот же ключ, что `LANGUAGE_KEY` в api/settings.py: импортировать оттуда значило бы
# развернуть зависимость «сервис → API» ради одного строкового литерала
LANGUAGE_KEY = "language"
DEFAULT_LANGUAGE = "ru"
# Языки, для которых есть строки; всё остальное — «ru»
SUPPORTED = ("ru", "en", "uk")
AGENT_FALLBACK: dict[str, str] = {
"ru": "ИИ-агент",
"en": "the AI agent",
"uk": "ШІ-агент",
}
# kind → язык → (заголовок, тело). Плейсхолдеры подставляются через str.format
TEXTS: dict[str, dict[str, tuple[str, str]]] = {
"task.claimed": {
"ru": (
"Агент взял задачу в работу",
"«{title}» — {name} взялась за дело, не трогайте её, пока работает",
),
"en": (
"An agent took the task",
"“{title}” — {name} got to work, leave it alone while she works",
),
"uk": (
"Агент узяв завдання в роботу",
"«{title}» — {name} взялася до справи, не чіпайте його, поки працює",
),
},
"task.review": {
"ru": (
"Работа ждёт приёмки",
"«{title}» — посмотрите, что сделано, и примите или верните",
),
"en": (
"Work awaits review",
"“{title}” — check what was done, then accept or return it",
),
"uk": (
"Робота чекає на приймання",
"«{title}» — подивіться, що зроблено, і прийміть або поверніть",
),
},
# Напоминания о сроке (0.89). Нестрогий период («в течение недели») сюда не
# попадает вовсе — у него нет дня отсчёта, см. services/reminders.py
"task.due_tomorrow": {
"ru": ("Срок завтра", "«{title}» — дедлайн завтра"),
"en": ("Due tomorrow", "“{title}” — the deadline is tomorrow"),
"uk": ("Термін завтра", "«{title}» — дедлайн завтра"),
},
"task.due_today": {
"ru": ("Срок сегодня", "«{title}» — сегодня последний день"),
"en": ("Due today", "“{title}” — today is the last day"),
"uk": ("Термін сьогодні", "«{title}» — сьогодні останній день"),
},
"task.overdue": {
"ru": ("Просрочено", "«{title}» — срок прошёл, задача всё ещё открыта"),
"en": ("Overdue", "“{title}” — the deadline has passed, the task is still open"),
"uk": ("Прострочено", "«{title}» — термін минув, завдання досі відкрите"),
},
# Регулярная задача: напоминание по ритму, а не «появился экземпляр» —
# экземпляр рождается только после закрытия предыдущего
"task.rhythm": {
"ru": ("Пора", "«{title}» — по расписанию"),
"en": ("Time for it", "“{title}” — it is on the schedule"),
"uk": ("Час", "«{title}» — за розкладом"),
},
# Сводка: тело собирается из частей ниже, пустые не показываем
"summary": {
"ru": ("Сводка на сегодня", "{parts}"),
"en": ("Today at a glance", "{parts}"),
"uk": ("Підсумок на сьогодні", "{parts}"),
},
}
# Части сводки: ключ → счётчик от `reminders.summary_counts`. Нулевые части
# выбрасываются, поэтому строка-шаблон описывает все три сразу
SUMMARY_PARTS: dict[str, dict[str, str]] = {
"ru": {
"overdue": "Просрочено: {overdue}",
"today": "Срок сегодня: {today}",
"review": "Ждут приёмки: {review}",
},
"en": {
"overdue": "Overdue: {overdue}",
"today": "Due today: {today}",
"review": "Awaiting review: {review}",
},
"uk": {
"overdue": "Прострочено: {overdue}",
"today": "Термін сьогодні: {today}",
"review": "Чекають на приймання: {review}",
},
}
# Тексты уведомления живут секунды на экране блокировки — длинное тело в нём не
# читают, а усекать его по дороге обиднее, чем обрезать здесь
MAX_TITLE = 60
MAX_BODY = 140
def user_language(db: Session, user_id: str) -> str:
"""Язык уведомлений пользователя: настройка → локаль SSO → «ru».
Настройка читается напрямую, а не через `api/settings.py`: импортировать роутер
в сервис значило бы завести обратную зависимость, а правило — три строки.
"""
row = db.get(AppSetting, (user_id, LANGUAGE_KEY))
if row is not None and row.value in SUPPORTED:
return row.value
user = db.get(User, user_id)
locale = (user.locale or "") if user is not None else ""
# SSO отдаёт локали вида «ru-RU»: берём язык, отбрасывая регион
language = locale.replace("_", "-").split("-")[0].lower()
return language if language in SUPPORTED else DEFAULT_LANGUAGE
def _clip(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + "…"
def summary_body(language: str, counts: dict[str, int]) -> str:
"""Тело сводки: только непустые части, через « · ».
Отдельная функция, а не `render`: сводка — не «текст с подстановкой одного
поля», а склейка переменного числа частей, и собирать её шаблоном неудобно.
"""
parts = SUMMARY_PARTS.get(language, SUMMARY_PARTS[DEFAULT_LANGUAGE])
keys = ("overdue", "today", "review")
text = " · ".join(parts[key].format(**counts) for key in keys if counts.get(key))
return _clip(text, MAX_BODY)
def render(kind: str, language: str, **fields: str) -> tuple[str, str] | None:
"""Собрать (заголовок, тело) для события; неизвестный kind → None.
Заголовок задачи усекаем: он приходит от пользователя (до 500 символов), а
системное уведомление длиннее пары строк всё равно не показывает.
"""
variants = TEXTS.get(kind)
if variants is None:
return None
title, body = variants.get(language, variants[DEFAULT_LANGUAGE])
fields.setdefault("name", AGENT_FALLBACK.get(language, AGENT_FALLBACK[DEFAULT_LANGUAGE]))
if "title" in fields:
fields["title"] = _clip(fields["title"], MAX_TITLE)
return title, _clip(body.format(**fields), MAX_BODY)