Internal Documentation internal
TalkIDE internal documentation

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_ledger jsou 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řes spring.data.redis.host=localhost + spring.data.redis.port=6379. Pro integrační testy: @Testcontainers s redis:7-alpine. Produkční Redis (DO Managed Redis nebo self-hosted Redis pod v K8s) je out-of-scope tohoto UC — viz talkide-infra follow-up issue. Spring Data Redis RedisTemplate API je kompatibilní napříč všemi těmito runtime variantami.
  • Configurable kill switch: talkide.gateway.fatigue.enabled (default true). Při false Gateway 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í

PoleTypPopis
fatigue_levelenumAktuální stav Marou — FRESH, WARMED_UP, TIRED, GROGGY, EXHAUSTED, ASLEEP
window_5h_pctintProcento spotřeby v aktuálním 5h okně (0–100+)
window_weekly_pctintProcento spotřeby v aktuálním weekly okně (0–100+)
sleep_until_tsISO 8601 timestamp \&#124; nullTimestamp expiry nejbližšího vyčerpaného okna. Non-null pouze při ASLEEP. FE z něj vypočítá countdown.

Konfigurační properties

PropertyDefaultPopis
talkide.gateway.fatigue.enabledtrueKill switch pro celý fatigue systém. Při false = pass-through.
talkide.fup.budget-5h-cents200Budget 5h okna v centech (200 = $2.00)
talkide.fup.budget-weekly-cents1000Budget 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

StatePráhModelThinking budgetDelayMax souběžné konv.
FRESH0–50 %Sonnet 4.58 000 tokenů0neomezeno
WARMED_UP50–70 %Sonnet 4.52 000 tokenů0neomezeno
TIRED70–85 %Haiku 4 (simple), Sonnet (tool loops)plný0neomezeno
GROGGY85–95 %Haiku 40 (bez thinking)500 msneomezeno
EXHAUSTED95–100 %Haiku 405 smax 1
ASLEEP100 %+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

KeyTypTTLPopis
user:{id}:5h_window_start_tsString (epoch ms)5 hodinTimestamp startu aktuálního 5h okna
user:{id}:5h_used_centsInteger5 hodinSpotřeba v aktuálním 5h okně (centy)
user:{id}:weekly_window_start_tsString (epoch ms)7 dníTimestamp startu aktuálního weekly okna
user:{id}:weekly_used_centsInteger7 dníSpotřeba v aktuálním weekly okně (centy)
global:monthly_used_centsIntegerdo konce billing měsíceGlobá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_cents se 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 / stavPravidloPoznámka
fatigue_envelope.fatigue_levelnot_null po každém Mara turnuFE musí vždy zpracovat envelope — neměl by ho ignorovat ani cachovat
fatigue_envelope.sleep_until_tsnon-null právě když level === 'ASLEEP'FE musí zkontrolovat před rendrováním countdownu
Avatar stateCSS class swap dle fatigue_level6 stavů — každý stav má vlastní CSS class a asset
Countdown timerZobrazit pokud sleep_until_ts !== nullVýpočet: sleep_until_ts - now(), formát “za H h MM min”
Chat inputDisabled + locked pokud level === 'ASLEEP'Po expiry countdownu automaticky odemknout bez refreshe
Banner — 50 %Zobrazit subtle banner při prvním překročení prahuZobrazit jen jednou za session (neukazovat opakovaně)
Banner — 80 %Zobrazit prominent banner při prvním překročení prahuZobrazit jen jednou za session
Banner — 100 %Modal při prvním obdržení ASLEEP v sessionJednorázový modal, pak chat locked se sleep screen
Pinia storeuseFatigueStore aktualizovat po každém Mara turnuStore hydratovat z fatigue_envelope; ostatní komponenty reaktivně čtou ze store
i18n stringsCS + EN pro všechny stavy, countdowny, notifikaceViz sekce UX Guidelines níže — kompletní text pool

Backend

Validations

Pole / pravidloConstraintsPoznámka
userIdnot_null, positive, existuje v DBVždy přítomno; bez platného userId nelze vytvořit Redis key
5h window TTL5 hodin od window_start_tsAutomatická Redis expiry — BE nesmí manuálně resetovat bez triggeru
Weekly window TTL7 dní od window_start_tsAutomatická Redis expiry
Atomic decrementLua script — check + decrement v jedné operaciŽádné WATCH/MULTI/EXEC — Lua je atomičtější a jednodušší
Effective fatigue statemax(5h_pct, weekly_pct)Přísnější z obou windows určuje stav
Model swapHaiku 4 pro TIRED/GROGGY/EXHAUSTEDCena se liší — PricingConfig musí mít pricing pro oba modely
DelayThread.sleep nebo Mono.delay PŘED MaraExecutor.startProcessNesmí blokovat scheduler thread v reaktivní architektuře
EXHAUSTED — max 1 konvZkontrolovat počet aktivních konverzací pro userIdOdmítnout nové konverzace pokud je activeConversations >= 1
ASLEEP → výjimkaMaraAsleepException propagována z Gateway → controller → 429Controller musí mít @ExceptionHandler(MaraAsleepException)
Global brake 85 %Všichni uživatelé +1 stupeň fatigueAplikovat jako post-processing step na výsledek FatigueResolver
Global brake 95 %Freeze new conversations — odmítnout AgentStartConversationUseCasePokračování existujících konverzací povoleno
Global brake 100 %Vrátit 503 pro všechny Anthropic callyBE nesmí posílat žádné cally na Anthropic
Kill switch disabledPři fatigue.enabled=false přeskočit celý fatigue flowGateway pass-through; žádná Redis operace, žádný model swap
fatigue_state_at_requestUložit do api_usage_ledger při každém calluNULL pokud kill switch disabled

Test Cases

GIVENWHENTHEN
Nový uživatel, Redis prázdný (0 % obou oken)callAnthropic() — první Mara zprávaState 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ší turnState WARMED_UP (max 60 %, 30 % = 60 %); Sonnet 4.5, thinking cap 2k; fatigue_level = WARMED_UP v envelope
Uživatel na 80 % 5h budgetucallAnthropic() — simple taskState TIRED; model swappován na Haiku 4; fatigue_level = TIRED v envelope; tool loop stále na Sonnet
Uživatel na 90 % 5h budgetucallAnthropic() je zavolánState 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í konverzacicallAnthropic() pro novou konverzaciState EXHAUSTED; nová konverzace odmítnuta (activeConversations >= 1); 5s delay pokud existující konv; fatigue_level = EXHAUSTED
Uživatel na 100 % 5h budgetucallAnthropic() je zavolánMaraAsleepException 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 % headroomcallAnthropic() je zavolánState ASLEEP (weekly window vyčerpána); 429 response; sleep_until_ts = expiry weekly okna
Uživatel na 70 % 5h a 90 % weeklycallAnthropic() je zavolánEffective 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ótouOba 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 testucallAnthropic() po expiryRedis klíče zmizí; nové okno startuje; stav resetován na FRESH; nový window_start_ts nastaven
Globální měsíční spend = 87 % monthly capcallAnthropic() pro jakéhokoli uživatele ve stavu FRESHGlobal brake +1 → uživatel dostane WARMED_UP (ne FRESH); fatigue_level = WARMED_UP i přes 0 % personal budget
talkide.gateway.fatigue.enabled=falsecallAnthropic() 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 aktivitycallAnthropic() — 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 expirujeFE automatický check po countdown = 0Další 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.

StateCSS classVizuální popisBarvy / hint
FRESH.mara-freshStandardní Mara, otevřené oči, úsměvPlná sytost, primární brand barva
WARMED_UP.mara-warmed-upMara se lehce přimhuřuje, stále usměvaváLehce desaturovaná, jinak stejná
TIRED.mara-tiredPřivřené oči, unavený výrazBledší tón, mírně snížená sytost
GROGGY.mara-groggyZívající avatar, malátný výrazVýrazně desaturovaná, šedozelená
EXHAUSTED.mara-exhaustedPřivřené oči, sotva otevřené, šedáTéměř šedá, nízká sytost
ASLEEP.mara-asleepSpí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):

StateKlíčVarianta
FRESHmara.status.fresh.0”Mara je v pohodě a připravená kódit”
FRESHmara.status.fresh.1”Mara je svěží, jdeme na to”
FRESHmara.status.fresh.2”Mara má plné baterky”
WARMED_UPmara.status.warmed_up.0”Mara se rozjela, jede v tempu”
WARMED_UPmara.status.warmed_up.1”Mara už chvíli pracuje, ale jede dál”
WARMED_UPmara.status.warmed_up.2”Mara se rozjela”
TIREDmara.status.tired.0”Mara je trochu unavená, šetří síly”
TIREDmara.status.tired.1”Mara zpomaluje, ale táhne”
TIREDmara.status.tired.2”Mara jede na druhý dech”
GROGGYmara.status.groggy.0”Mara zívá. Možná jí dnes moc přemýšlení nejde.”
GROGGYmara.status.groggy.1”Mara mhouří oči”
GROGGYmara.status.groggy.2”Mara loví druhý dech”
GROGGYmara.status.groggy.3”Mara je malátná, ale snaží se”
EXHAUSTEDmara.status.exhausted.0”Mara má motýlky před očima, drží se z posledních sil”
EXHAUSTEDmara.status.exhausted.1”Mara sotva stojí”
EXHAUSTEDmara.status.exhausted.2”Mara je na hraně”
ASLEEPmara.status.asleep.0”Mara potřebuje vyspat. Vrať se za {countdown}.”
ASLEEPmara.status.asleep.1”Mara si dává pauzu, bude zpátky v {time}.”
ASLEEPmara.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
PráhTypStylText (CS)
50 %Subtle info bannerModrá 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 = 0 FE automaticky odemkne chat (bez refreshe) — volání useFatigueStore.checkWakeUp() nebo watch na sleep_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-label na 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: disabled atribut + 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


Was this page helpful?

Thanks for the feedback.