# Humanized Stepan — CHANGELOG v2.7

**Version:** v2.7  
**Date:** 2026-02-25  
**Базується на:** v2.6 (Jaccard guard, tone_constraints, 3-рівневі привітання, seeded RNG)

---

## Summary

- Додано **memory horizon**: `recent_topics` (до 5 записів) замість єдиного `last_topic`.
- Додано **human topic labels** (`last_topic_label`) — Степан оперує "план на завтра поле 12", а не "plan_day".
- Додано **`summarize_topic_label()`** — rule-based витяг 6–8 слів з тексту без дієслів-тригерів і стоп-слів.
- Light follow-up (≤6 слів + last_topic) **не додає шум** до `recent_topics` (`depth="light"` → `push` не відбувається).
- Contextual greeting (`interaction_count ≥ 8`) тепер: з ймовірністю 20% (seeded rng) підхоплює `recent_topics[-2]` — Степан "пам'ятає" більше однієї теми без подвійного згадування.
- **ZZR safety disclaimer**: якщо погодний тригер + обприскування/гербіцид/ЗЗР — автоматично додається `"Дозування та вікна застосування — за етикеткою препарату та регламентом."`.
- Додано **`tests/test_stepan_invariants.py`** — 25 тестів-інваріантів проти "повзучої ботячості".

---

## Key features (деталі)

### Memory horizon — `recent_topics`

```json
"recent_topics": [
  {"label": "план на завтра поле 12", "intent": "plan_day", "ts": "2026-02-25T..."},
  {"label": "датчики вологості поле 7", "intent": "iot_sensors", "ts": "2026-02-25T..."}
]
```

- Максимум 5 записів; старіші витісняються.
- `last_topic` і `last_topic_label` — backward-compat aliases на `recent_topics[-1]`.
- Dedup: якщо той самий `intent` + `label` підряд — не дублюється.

### summarize_topic_label

| Вхід | Вихід |
|---|---|
| `"зроби план на завтра по полю 12"` | `"План на завтра по полю 12"` |
| `"перевір датчики вологості поле 7"` | `"Датчики вологості поле 7"` |
| `"сплануй тижневий збір по полях"` | `"Тижневий збір по полях"` |

Правила: прибирається leading action verb (зроби/перевір/порахуй/…), стоп-слова, обрізка до 8 слів. Числа, поля, культури, дати зберігаються.

### ZZR disclaimer

Regex `_ZZR_RE` спрацьовує на: `обробк|обприскування|гербіцид|фунгіцид|ЗЗР|пестицид|інсектицид|протруювач`.  
Застереження додається лише коли є **і** погодний тригер **і** ZZR-тригер в одному повідомленні.

### Invariant tests (anti-regression)

| Інваріант | Обмеження |
|---|---|
| INV-1: Greeting | ≤ 80 символів |
| INV-2: Thanks/Ack | ≤ 40 символів |
| INV-3: Заборонені фрази | "чим можу допомогти", "оберіть", "я як агент", "я бот" |
| INV-4: Технічні слова | container, uvicorn, trace_id, STEPAN_IMPORTS_OK |
| INV-5: ZZR disclaimer | при ZZR+погода → "за етикеткою" або "за регламентом" |
| INV-6: Horizon | `len(recent_topics) ≤ 5` після 7+ push |
| INV-7: Міграція | lazy, idempotent, backward-compat |

---

## Backward compatibility

| Аспект | Деталі |
|---|---|
| `_version` | 3 → 4 (нові поля `recent_topics`, `last_topic_label`) |
| Міграція | Lazy при `load_user_profile()` — виконується автоматично при першому зверненні |
| `last_topic` | Залишається як alias, завжди синхронізований з `recent_topics[-1].intent` |
| `last_topic_label` | Новий alias на `recent_topics[-1].label`; якщо нема — встановлюється під час міграції |
| `tone_constraints` | Вже в v2.6; міграція додає якщо відсутній |
| `update_profile_if_needed` | Новий параметр `depth="deep"` (default) — backward-compat, старі виклики не ламаються |
| `recent_topics` відсутній | Якщо профіль v3 без `recent_topics` — `migrate_profile_topics()` створює 1 елемент з `last_topic` |

Міграція `migrate_profile_topics()` — **idempotent**: повторний виклик не змінює вже мігрований профіль.

---

## Non-goals / not included

- Немає LLM у light mode або reflection.
- Немає змін в інфраструктурі (Dockerfile, compose, env).
- Немає змін у Gateway/http_api.py.
- Немає нових API ендпоінтів.
- Немає змін у поведінці deep mode orchestration.
- Немає змін у системному промпті (тільки хедер-версія).

---

## Tests

**Результат:** 101/101 зелених (без регресій з v2.6)

| Файл | Тестів | Опис |
|---|---|---|
| `tests/test_stepan_invariants.py` | 25 | Нові інваріанти anti-regression |
| `tests/test_stepan_acceptance.py` | 28 | Acceptance + v2.7 сесійні сценарії |
| `tests/test_stepan_light_reply.py` | ~26 | Light reply юніт-тести |
| `tests/test_stepan_memory_followup.py` | ~22 | Memory + follow-up класифікація |

```bash
# Тільки інваріанти
python3 -m pytest tests/test_stepan_invariants.py -v

# Acceptance
python3 -m pytest tests/test_stepan_acceptance.py -v

# Всі Stepan тести
python3 -m pytest tests/test_stepan_invariants.py tests/test_stepan_acceptance.py \
  tests/test_stepan_light_reply.py tests/test_stepan_memory_followup.py -v
```

---

## Known limitations

### Timezone і daily seed
`date.today()` використовує локаль контейнера. Контейнер має бути в `Europe/Kyiv` (`TZ=Europe/Kyiv`), інакше "новий день" Степана настане о 22:00 або 23:00 за Київським часом. Перевірка:
```bash
docker exec dagi-gateway-node1 date
```

### Memory-service downtime
При недоступності — деградація до локального in-memory кешу (TTL 30 хв). Кеш не переживає рестарт контейнера. Профілі не зберігаються між сесіями якщо memory-service down > 30 хв.

### ZZR regex — можливий overreach
Слово `"обробка"` без агрохімічного контексту (напр. "обробка ґрунту") може спрацювати. Якщо в проді виявиться шум — звузити regex: вимагати ще одне слово з `[препарат|норма|л/га|кг/га|концентрат]`.

---

## Rollback

```bash
# Відкатити зміни у конкретних файлах
git checkout HEAD~1 -- crews/agromatrix_crew/memory_manager.py
git checkout HEAD~1 -- crews/agromatrix_crew/light_reply.py
git checkout HEAD~1 -- crews/agromatrix_crew/run.py

# Rebuild gateway (без секретів)
cd /opt/microdao-daarion
docker compose -f docker-compose.node1.yml up -d --build dagi-gateway-node1

# Перевірка
docker logs dagi-gateway-node1 --since 5m 2>&1 | grep -E "Stepan mode|STEPAN_IMPORTS_OK|error|Error" | tail -30
```
