Rozšíření AnthropicGatewayService o per-user kvótový systém (Redis rolling windows 5h + weekly) a fatigue state machine — Gateway před každým Anthropic callem zkontroluje spotřebu, určí stav únavy Marou (FRESH → ASLEEP) a podle něj prohodí model, omezí extended thinking nebo injektuje delay. FE dostává fatigue_envelope v každé Mara odpovědi a zobrazuje odpovídající avatar + notifikace.
- Navazuje na UC-08001 Anthropic Call Gateway — Gateway infrastruktura a
api_usage_ledgerjsou předpokladem pro tento UC. - Tracking probíhá v Redis (rolling windows) + PG ledger (audit trail). Redis je pouze výpočetní vrstva; source of truth je ledger.
- Redis deployment: lokální dev / test používá samostatný Redis kontejner (
redis:7-alpine,~/Virtual/Redis/docker-compose.yml, port 6379). Spring Boot se připojuje přesspring.data.redis.host=localhost+spring.data.redis.port=6379. Pro integrační testy:@Testcontainerssredis:7-alpine. Produkční Redis (DO Managed Redis nebo self-hosted Redis pod v K8s) je out-of-scope tohoto UC — viztalkide-infrafollow-up issue. Spring Data RedisRedisTemplateAPI je kompatibilní napříč všemi těmito runtime variantami. - Configurable kill switch:
talkide.gateway.fatigue.enabled(defaulttrue). PřifalseGateway funguje jako pass-through — žádná kvóta, žádný model swap, žádný delay. Určeno pro lokální vývoj. - Global emergency brake: při globálním měsíčním spend > 85 % cap → všichni uživatelé posunutí o 1 stupeň fatigue; > 95 % → zmrazení nových konverzací; ≥ 100 % → HTTP 503 pro všechny Anthropic cally.
- Kvóta se kontroluje před turnem (grace finish) — pokud obě windows mají headroom, celý turn proběhne i kdyby mezitím přesáhl limit. Viz ADR-020 Rozhodnutí 5.
- Žádný carry-over nepoužité kvóty — nevyužitý budget z expirované window propadá. Viz ADR-020 Rozhodnutí 3.
- Backend posílá pouze enum + timestamp; lokalizovaný text a message pool jsou výhradně FE zodpovědnost. Viz ADR-020 Rozhodnutí 8.
sequenceDiagram
actor User
User->>+FE: odešle zprávu v chatu
FE->>+BE: POST /api/v1/projects/{projectId}/conversations/{conversationId}/messages <br> Authorization: Bearer {accessToken} <br> SendMessageRequest
BE->>+SendMessageUseCase: execute(input)
SendMessageUseCase->>SendMessageUseCase: načti konverzaci, sestav prompt kontext
SendMessageUseCase->>+AnthropicGatewayService: callAnthropic(userId, GatewayRequest)
AnthropicGatewayService->>AnthropicGatewayService: zkontroluj fatigue.enabled
AnthropicGatewayService->>+QuotaCheckService: check(userId)
Note over QuotaCheckService: Redis Lua script <br> atomická kontrola 5h + weekly
alt stav ASLEEP (quota 100 %)
QuotaCheckService-->>AnthropicGatewayService: throws MaraAsleepException(sleepUntilTs)
AnthropicGatewayService-->>SendMessageUseCase: propaguje výjimku
SendMessageUseCase-->>BE: propaguje výjimku
BE-->>FE: 429 Too Many Requests <br> AsleepErrorResponse
FE-->>User: zobraz "Mara spí" screen + countdown
end
QuotaCheckService-->>-AnthropicGatewayService: QuotaStatus(used5hPct, usedWeeklyPct)
AnthropicGatewayService->>+FatigueResolver: resolve(used5hPct, usedWeeklyPct)
Note over FatigueResolver: effectiveState = max(5h%, weekly%) <br> FRESH/WARMED_UP/TIRED/GROGGY/EXHAUSTED
FatigueResolver-->>-AnthropicGatewayService: FatigueState
AnthropicGatewayService->>AnthropicGatewayService: SlowdownPolicy.apply(state) <br> (model swap, thinking cap, delay)
alt delay > 0 (GROGGY=500ms, EXHAUSTED=5s)
AnthropicGatewayService->>AnthropicGatewayService: Mono.delay / Thread.sleep
end
AnthropicGatewayService->>+MaraExecutor: startProcess(modifiedRequest)
Note over MaraExecutor: model swappován dle fatigue state
MaraExecutor->>Anthropic API: API call (swapped model)
Anthropic API-->>MaraExecutor: streaming response + usage object
MaraExecutor-->>-AnthropicGatewayService: onComplete(fullText, usage)
AnthropicGatewayService->>AnthropicGatewayService: PricingConfig.computeCost(usage)
AnthropicGatewayService->>+QuotaCheckService: decrementAtomic(userId, costCents)
Note over QuotaCheckService: Lua script — decrement 5h + weekly atomicky
QuotaCheckService-->>-AnthropicGatewayService: OK
AnthropicGatewayService->>DB: INSERT api_usage_ledger <br> (+ fatigue_state_at_request)
AnthropicGatewayService-->>-SendMessageUseCase: GatewayResponse(content, fatigueEnvelope)
SendMessageUseCase->>DB: INSERT message (role=PM, content)
SendMessageUseCase->>DB: UPDATE conversation updatedAt
SendMessageUseCase-->>-BE: SendMessageOutput
BE-->>-FE: SSE event: message <br> MessageDto (+ fatigue_envelope)
FE->>FE: useFatigueStore.update(fatigue_envelope)
FE->>FE: avatar swap + animace
alt dosažení prahu notifikace (50 % / 80 %)
FE-->>User: subtle banner notifikace
end
FE-->>-User: zobraz PM odpověď v chatu
HTTP Kontrakt — rozšíření existujícího endpointu
Tento UC neobsahuje nový HTTP endpoint. Modifikuje response existujícího endpointu z UC-04003 Send Message přidáním pole fatigue_envelope.
Úspěšná odpověď — rozšíření MessageDto
200 OK nebo SSE event: message MessageDto (rozšíření stávajícího DTO):
{
"id": 42,
"conversationId": 7,
"role": "PM",
"content": "Jasně, tady je návrh architektury...",
"createdAt": "2026-05-08T10:15:30Z",
"fatigue_envelope": {
"fatigue_level": "GROGGY",
"window_5h_pct": 88,
"window_weekly_pct": 71,
"sleep_until_ts": null
}
}
ASLEEP response — 429
429 Too Many Requests AsleepErrorResponse:
{
"code": "MARA_ASLEEP",
"message": "Mara je momentálně unavená. Zkus to znovu za chvíli.",
"sleep_until_ts": "2026-05-08T16:34:00Z",
"retry_after_seconds": 3240
}
Global emergency brake — 503
503 Service Unavailable ErrorResponse (při globálním monthly cap ≥ 100 %):
{
"code": "PLATFORM_MAINTENANCE",
"message": "Platforma je dočasně nedostupná. Zkus to brzy znovu."
}
fatigue_envelope — popis polí
| Pole | Typ | Popis |
|---|---|---|
fatigue_level | enum | Aktuální stav Marou — FRESH, WARMED_UP, TIRED, GROGGY, EXHAUSTED, ASLEEP |
window_5h_pct | int | Procento spotřeby v aktuálním 5h okně (0–100+) |
window_weekly_pct | int | Procento spotřeby v aktuálním weekly okně (0–100+) |
sleep_until_ts | ISO 8601 timestamp \| null | Timestamp expiry nejbližšího vyčerpaného okna. Non-null pouze při ASLEEP. FE z něj vypočítá countdown. |
Konfigurační properties
| Property | Default | Popis |
|---|---|---|
talkide.gateway.fatigue.enabled | true | Kill switch pro celý fatigue systém. Při false = pass-through. |
talkide.fup.budget-5h-cents | 200 | Budget 5h okna v centech (200 = $2.00) |
talkide.fup.budget-weekly-cents | 1000 | Budget weekly okna v centech (1000 = $10.00) |
talkide.fup.monthly-cap-cents | (dle tieru) | Globální monthly cap v centech — při překročení global emergency brake |
Slowdown policy dle fatigue state
| State | Práh | Model | Thinking budget | Delay | Max souběžné konv. |
|---|---|---|---|---|---|
FRESH | 0–50 % | Sonnet 4.5 | 8 000 tokenů | 0 | neomezeno |
WARMED_UP | 50–70 % | Sonnet 4.5 | 2 000 tokenů | 0 | neomezeno |
TIRED | 70–85 % | Haiku 4 (simple), Sonnet (tool loops) | plný | 0 | neomezeno |
GROGGY | 85–95 % | Haiku 4 | 0 (bez thinking) | 500 ms | neomezeno |
EXHAUSTED | 95–100 % | Haiku 4 | 0 | 5 s | max 1 |
ASLEEP | 100 %+ | — | — | — | 0 (hard 429) |
Redis Schema
Žádná nová PostgreSQL tabulka — tracking se zapisuje do existující api_usage_ledger (UC-08001), rozšířené o sloupec fatigue_state_at_request. Redis slouží jako výpočetní vrstva pro real-time kvótu.
Redis keys
| Key | Typ | TTL | Popis |
|---|---|---|---|
user:{id}:5h_window_start_ts | String (epoch ms) | 5 hodin | Timestamp startu aktuálního 5h okna |
user:{id}:5h_used_cents | Integer | 5 hodin | Spotřeba v aktuálním 5h okně (centy) |
user:{id}:weekly_window_start_ts | String (epoch ms) | 7 dní | Timestamp startu aktuálního weekly okna |
user:{id}:weekly_used_cents | Integer | 7 dní | Spotřeba v aktuálním weekly okně (centy) |
global:monthly_used_cents | Integer | do konce billing měsíce | Globální měsíční spotřeba (centy) |
Poznámky:
- TTL na window keys zajistí automatický reset bez explicitního cron jobu. Po expiry Redis klíč zmizí → příští request startuje nové okno.
- Atomic check + decrement probíhají v jednom Lua scriptu (
EVALSHA) — race-condition free i při souběžných requestech stejného uživatele. global:monthly_used_centsse resetuje na začátku každého billing měsíce přes scheduled job (ShedLock protected).
DB rozšíření — api_usage_ledger
Přidání sloupce do existující tabulky z UC-08001 (pre-production, drop-first režim — modifikovat existující Liquibase migraci in-place):
ALTER TABLE api_usage_ledger
ADD COLUMN fatigue_state_at_request VARCHAR(20);
Frontend
Validations
| Pole / stav | Pravidlo | Poznámka |
|---|---|---|
fatigue_envelope.fatigue_level | not_null po každém Mara turnu | FE musí vždy zpracovat envelope — neměl by ho ignorovat ani cachovat |
fatigue_envelope.sleep_until_ts | non-null právě když level === 'ASLEEP' | FE musí zkontrolovat před rendrováním countdownu |
| Avatar state | CSS class swap dle fatigue_level | 6 stavů — každý stav má vlastní CSS class a asset |
| Countdown timer | Zobrazit pokud sleep_until_ts !== null | Výpočet: sleep_until_ts - now(), formát “za H h MM min” |
| Chat input | Disabled + locked pokud level === 'ASLEEP' | Po expiry countdownu automaticky odemknout bez refreshe |
| Banner — 50 % | Zobrazit subtle banner při prvním překročení prahu | Zobrazit jen jednou za session (neukazovat opakovaně) |
| Banner — 80 % | Zobrazit prominent banner při prvním překročení prahu | Zobrazit jen jednou za session |
| Banner — 100 % | Modal při prvním obdržení ASLEEP v session | Jednorázový modal, pak chat locked se sleep screen |
| Pinia store | useFatigueStore aktualizovat po každém Mara turnu | Store hydratovat z fatigue_envelope; ostatní komponenty reaktivně čtou ze store |
| i18n strings | CS + EN pro všechny stavy, countdowny, notifikace | Viz sekce UX Guidelines níže — kompletní text pool |
Backend
Validations
| Pole / pravidlo | Constraints | Poznámka |
|---|---|---|
userId | not_null, positive, existuje v DB | Vždy přítomno; bez platného userId nelze vytvořit Redis key |
| 5h window TTL | 5 hodin od window_start_ts | Automatická Redis expiry — BE nesmí manuálně resetovat bez triggeru |
| Weekly window TTL | 7 dní od window_start_ts | Automatická Redis expiry |
| Atomic decrement | Lua script — check + decrement v jedné operaci | Žádné WATCH/MULTI/EXEC — Lua je atomičtější a jednodušší |
| Effective fatigue state | max(5h_pct, weekly_pct) | Přísnější z obou windows určuje stav |
| Model swap | Haiku 4 pro TIRED/GROGGY/EXHAUSTED | Cena se liší — PricingConfig musí mít pricing pro oba modely |
| Delay | Thread.sleep nebo Mono.delay PŘED MaraExecutor.startProcess | Nesmí blokovat scheduler thread v reaktivní architektuře |
EXHAUSTED — max 1 konv | Zkontrolovat počet aktivních konverzací pro userId | Odmítnout nové konverzace pokud je activeConversations >= 1 |
| ASLEEP → výjimka | MaraAsleepException propagována z Gateway → controller → 429 | Controller musí mít @ExceptionHandler(MaraAsleepException) |
| Global brake 85 % | Všichni uživatelé +1 stupeň fatigue | Aplikovat jako post-processing step na výsledek FatigueResolver |
| Global brake 95 % | Freeze new conversations — odmítnout AgentStartConversationUseCase | Pokračování existujících konverzací povoleno |
| Global brake 100 % | Vrátit 503 pro všechny Anthropic cally | BE nesmí posílat žádné cally na Anthropic |
| Kill switch disabled | Při fatigue.enabled=false přeskočit celý fatigue flow | Gateway pass-through; žádná Redis operace, žádný model swap |
fatigue_state_at_request | Uložit do api_usage_ledger při každém callu | NULL pokud kill switch disabled |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| Nový uživatel, Redis prázdný (0 % obou oken) | callAnthropic() — první Mara zpráva | State FRESH; Sonnet 4.5, thinking 8k, 0ms delay; fatigue_envelope.fatigue_level = FRESH; Redis 5h_window_start_ts a weekly_window_start_ts nastaveny |
| Uživatel na 60 % 5h budgetu, weekly 30 % | callAnthropic() — další turn | State WARMED_UP (max 60 %, 30 % = 60 %); Sonnet 4.5, thinking cap 2k; fatigue_level = WARMED_UP v envelope |
| Uživatel na 80 % 5h budgetu | callAnthropic() — simple task | State TIRED; model swappován na Haiku 4; fatigue_level = TIRED v envelope; tool loop stále na Sonnet |
| Uživatel na 90 % 5h budgetu | callAnthropic() je zavolán | State GROGGY; Haiku 4, no extended thinking, 500ms delay aplikován před MaraExecutor.startProcess; fatigue_level = GROGGY |
| Uživatel na 99 % 5h budgetu, má 1 aktivní konverzaci | callAnthropic() pro novou konverzaci | State EXHAUSTED; nová konverzace odmítnuta (activeConversations >= 1); 5s delay pokud existující konv; fatigue_level = EXHAUSTED |
| Uživatel na 100 % 5h budgetu | callAnthropic() je zavolán | MaraAsleepException vyhozena; žádný Anthropic call; response 429 s sleep_until_ts = expiry 5h okna; retry_after_seconds spočítán |
| Uživatel na 100 % weekly budgetu, 5h window má 40 % headroom | callAnthropic() je zavolán | State ASLEEP (weekly window vyčerpána); 429 response; sleep_until_ts = expiry weekly okna |
| Uživatel na 70 % 5h a 90 % weekly | callAnthropic() je zavolán | Effective state = max(70 %, 90 %) = 90 % → GROGGY; weekly window je přísnější; window_weekly_pct = 90 v envelope |
| 2 paralelní requesty stejného uživatele, oba pod kvótou | Oba callAnthropic() zavolány současně | Lua script zajistí atomicitu — oba mohou proběhnout (pokud headroom stačí pro oba); žádný race condition; celková spotřeba odpovídá součtu obou callů |
| Uživatel na 80 %, Redis TTL expiruje v průběhu testu | callAnthropic() po expiry | Redis klíče zmizí; nové okno startuje; stav resetován na FRESH; nový window_start_ts nastaven |
| Globální měsíční spend = 87 % monthly cap | callAnthropic() pro jakéhokoli uživatele ve stavu FRESH | Global brake +1 → uživatel dostane WARMED_UP (ne FRESH); fatigue_level = WARMED_UP i přes 0 % personal budget |
talkide.gateway.fatigue.enabled=false | callAnthropic() s uživatelem na 100 % 5h budgetu | Žádná kvóta kontrola; žádný model swap; žádný delay; Sonnet 4.5 se plným thinkingem; fatigue_envelope v response null nebo chybí |
| Nový uživatel bez jakékoli Redis aktivity | callAnthropic() — první zpráva (trigger 5h okna) | 5h okno startuje; TTL nastaven na 5h od nyní; window_5h_pct = 0 (před decrementem) nebo pct odpovídá ceně prvního callu |
Uživatel ve stavu ASLEEP — TTL 5h okna expiruje | FE automatický check po countdown = 0 | Další callAnthropic() spustí nové 5h okno; state FRESH; chat input odemčen na FE |
UX Guidelines
Mara avatar — vizuální stavy
Avatar Marou je klíčový UX prvek — vizuálně komunikuje stav bez nutnosti číst text. Každý stav má vlastní CSS class, která se aplikuje na avatar kontejner.
Zdroj assetů: TODO — FE developer vytvoří / sežene ilustrace Marou pro každý stav. Doporučený formát: SVG nebo PNG (2x density), min. 64×64 px pro sidebar, 128×128 px pro sleep screen.
| State | CSS class | Vizuální popis | Barvy / hint |
|---|---|---|---|
FRESH | .mara-fresh | Standardní Mara, otevřené oči, úsměv | Plná sytost, primární brand barva |
WARMED_UP | .mara-warmed-up | Mara se lehce přimhuřuje, stále usměvavá | Lehce desaturovaná, jinak stejná |
TIRED | .mara-tired | Přivřené oči, unavený výraz | Bledší tón, mírně snížená sytost |
GROGGY | .mara-groggy | Zívající avatar, malátný výraz | Výrazně desaturovaná, šedozelená |
EXHAUSTED | .mara-exhausted | Přivřené oči, sotva otevřené, šedá | Téměř šedá, nízká sytost |
ASLEEP | .mara-asleep | Spící avatar, zavřené oči, “zzz” | Modrošedá, animace pomalu “dýchá” (opacity pulse) |
Animace přechodu: CSS transition opacity 0.3s ease + filter saturate() — jemná, neflashující změna při přechodu mezi stavy. Mara spící avatar má background pulse animaci (subtilní @keyframes opacity 0.7–1.0 v cyklu 3s).
Mara zprávy — i18n message pool
FE vybírá náhodnou variantu z pool při renderu komponenty (ne při příchodu zprávy). Klíče v locales/cs.json a locales/en.json.
CS varianty (3–5 per stav):
| State | Klíč | Varianta |
|---|---|---|
FRESH | mara.status.fresh.0 | ”Mara je v pohodě a připravená kódit” |
FRESH | mara.status.fresh.1 | ”Mara je svěží, jdeme na to” |
FRESH | mara.status.fresh.2 | ”Mara má plné baterky” |
WARMED_UP | mara.status.warmed_up.0 | ”Mara se rozjela, jede v tempu” |
WARMED_UP | mara.status.warmed_up.1 | ”Mara už chvíli pracuje, ale jede dál” |
WARMED_UP | mara.status.warmed_up.2 | ”Mara se rozjela” |
TIRED | mara.status.tired.0 | ”Mara je trochu unavená, šetří síly” |
TIRED | mara.status.tired.1 | ”Mara zpomaluje, ale táhne” |
TIRED | mara.status.tired.2 | ”Mara jede na druhý dech” |
GROGGY | mara.status.groggy.0 | ”Mara zívá. Možná jí dnes moc přemýšlení nejde.” |
GROGGY | mara.status.groggy.1 | ”Mara mhouří oči” |
GROGGY | mara.status.groggy.2 | ”Mara loví druhý dech” |
GROGGY | mara.status.groggy.3 | ”Mara je malátná, ale snaží se” |
EXHAUSTED | mara.status.exhausted.0 | ”Mara má motýlky před očima, drží se z posledních sil” |
EXHAUSTED | mara.status.exhausted.1 | ”Mara sotva stojí” |
EXHAUSTED | mara.status.exhausted.2 | ”Mara je na hraně” |
ASLEEP | mara.status.asleep.0 | ”Mara potřebuje vyspat. Vrať se za {countdown}.” |
ASLEEP | mara.status.asleep.1 | ”Mara si dává pauzu, bude zpátky v {time}.” |
ASLEEP | mara.status.asleep.2 | ”Mara spí. Chat se odemkne v {time}.” |
{countdown} = dynamický interpolovaný string, např. “2 h 34 min”. {time} = formátovaný čas z sleep_until_ts, např. “14:23”.
Countdown formát
- Více než 1 hodina: “za X h YY min” (příklad: “za 2 h 07 min”)
- Méně než 1 hodina: “za YY min” (příklad: “za 34 min”)
- Méně než 1 minuta: “za chvíli” (příklad: 45 sekund zbývá)
- Tick: refresh každých 60 sekund (stačí minutová granularita), ne setInterval na každou sekundu
Banner notifikace
| Práh | Typ | Styl | Text (CS) |
|---|---|---|---|
| 50 % | Subtle info banner | Modrá linka nahoře v chatu, zavíratelný (×) | “Mara je v polovině svého denního výkonu.” |
| 80 % | Prominent warning banner | Žlutý banner s ikonou, zavíratelný | ”Mara je unavená — zbývá jen málo energie. Dokonči co je nejdůležitější.” |
| 100 % | Modal (jednorázový) | Centrovaný modal, overlay | ”Mara si jde odpočinout. Vrátí se v {time}. Mezitím si prohlédni svoje projekty.” |
Bannery zobrazit jen jednou za session (state v Pinia — bannerShown50, bannerShown80). Modal při přechodu do ASLEEP jednorázový — po potvrzení se zobrazí sleep screen.
”Mara spí” screen
Plnohodnotná náhrada chat oblasti (ne error overlay). Zobrazí se když fatigue_level === 'ASLEEP'.
Layout (orientační):
┌─────────────────────────────────────────────────────┐
│ │
│ [Mara spící avatar — velký, 128px] │
│ │
│ Mara si odpočívá │
│ Vrátí se za 2 h 07 min (countdown) │
│ │
│ ────────────────────────────────────────────── │
│ │
│ Mezitím si můžeš prohlédnout svoje projekty → │
│ │
└─────────────────────────────────────────────────────┘
- Countdown se aktualizuje každou minutu (setInterval 60s)
- Po
countdown = 0FE automaticky odemkne chat (bez refreshe) — voláníuseFatigueStore.checkWakeUp()nebo watch nasleep_until_ts - “Prohlédnout projekty” = odkaz na
/projects(dashboard) - Chat input disabled,
aria-disabled="true", placeholder “Mara odpočívá…”
Accessibility
aria-live="polite"na avatar status text — screen reader oznámí změnu stavu (ne při každé aktualizaci, pouze při změně state)aria-labelna avatar obrázek popisující stav: např.aria-label="Mara — unavená"- Countdown region:
aria-live="off"(ticker nepotřebuje screen reader update každou minutu; pouze při přechodu do ASLEEP) - Barevné rozlišení stavů NESMÍ být jediným rozlišovacím prvkem — každý stav má i textový popis a avatar výraz
- Chat input při
ASLEEP:disabledatribut +aria-disabled="true"+ tooltip při hoveru / focus
Pinia store — useFatigueStore
// Orientační rozhraní — FE developer implementuje
interface FatigueStore {
level: FatigueLevel | null // aktuální stav
window5hPct: number // 0–100+
windowWeeklyPct: number // 0–100+
sleepUntilTs: string | null // ISO timestamp nebo null
bannerShown50: boolean
bannerShown80: boolean
// actions
update(envelope: FatigueEnvelope): void
checkWakeUp(): void // volat po countdown = 0
}
Store je hydratován po každém Mara turnu z fatigue_envelope v SSE eventu / HTTP response.
Reference
- UC-08001 Anthropic Call Gateway — Gateway infrastruktura,
api_usage_ledger,GatewayResponsedata class - specification/mara-fatigue-fup.md — Detailní spec quota mechanismu, state machine, Redis keys, global emergency brake
- adr/ADR-020 — Architektonická rozhodnutí: rolling windows, no carry-over, grace finish, i18n na FE
Thanks for the feedback.