Skip to content

# TASK_PHASE_CITY_BACKEND_FINISHER.md

DAARION CITY — Backend Completion for Phase 3 (MVP)

Цей таск закриває City Backend до рівня, коли MVP можна деплоїти на сервер (daarion.space) і реально користуватись:

- Public Rooms (міські кімнати)
- Presence System (онлайн-статуси)
- Second Me (персональний агент MVP)
- City Home інтеграція (дані для дашборду міста)

Фронтенд уже реалізований (CityRoomsPage, SecondMePage, PresenceBar тощо),
цей таск — про backend-реалізацію API + WS + Redis + DB + інтеграцію з Agents Core.

## 0. База / припущення

1. Primary DB: PostgreSQL (той самий, що й для microdao).
2. Cache / presence: Redis (ok додати новий контейнер або використовувати існуючий).
3. Message bus: NATS JetStream (вже є для Agents Core).
4. HTTP API gateway: уже налаштований (`/api/...`, `/ws/...`), ти додаєш нові маршрути.
5. Існує Agents Core з endpoints `/agents/{id}/invoke` і NATS-темами `agents.invoke` / `agents.reply`.

## 1. Структура Backend-модулів City

Створити (або доповнити, якщо частково вже є):

    services/
      city-service/
        __init__.py
        models.py
        schemas.py
        routes_city.py
        ws_city.py
        presence.py
        feed.py
        rooms.py
        repo.py
      secondme-service/
        __init__.py
        models.py
        schemas.py
        routes_secondme.py
        service_secondme.py
      common/
        redis_client.py      # якщо ще немає

І підключити:

- `routes_city.py` і `routes_secondme.py` до основного `main.py` (або відповідного API-aggregator service).
- `ws_city.py` — до WebSocket router'а (`/ws/...`).

## 2. PostgreSQL: нові таблиці

### 2.1 Таблиця `city_rooms`

    create table city_rooms (
      id text primary key,              -- room_id, напр. "room_city_general"
      slug text not null unique,        -- "general", "science"
      name text not null,               -- "General", "Science"
      description text null,
      is_default boolean not null default false,
      created_at timestamptz not null default now(),
      created_by text null              -- user_id (u_*)
    );
    create index ix_city_rooms_slug on city_rooms(slug);

### 2.2 Таблиця `city_room_messages`

    create table city_room_messages (
      id text primary key,              -- ksuid/ulid, префікс m_city_
      room_id text not null references city_rooms(id) on delete cascade,
      author_user_id text null,         -- u_*
      author_agent_id text null,        -- ag_*
      body text not null,
      created_at timestamptz not null default now()
    );
    create index ix_city_room_messages_room_time on city_room_messages(room_id, created_at desc);

### 2.3 Таблиця `city_feed_events`

    create table city_feed_events (
      id text primary key,              -- evt_city_*
      kind text not null,               -- 'room_message','agent_reply','system'
      room_id text null references city_rooms(id) on delete set null,
      user_id text null,
      agent_id text null,
      payload jsonb not null,
      created_at timestamptz not null default now()
    );
    create index ix_city_feed_time on city_feed_events(created_at desc);

### 2.4 Таблиця `secondme_sessions` (історія Second Me)

    create table secondme_sessions (
      id text primary key,              -- smsess_*
      user_id text not null,            -- u_*
      created_at timestamptz not null default now()
    );

    create table secondme_messages (
      id text primary key,              -- smmsg_*
      session_id text not null references secondme_sessions(id) on delete cascade,
      user_id text not null,
      role text not null check (role in ('user','assistant')),
      content text not null,
      created_at timestamptz not null default now()
    );
    create index ix_secondme_messages_session_time on secondme_messages(session_id, created_at desc);

Для MVP: можна використовувати одну активну session per user (останню).

## 3. Redis: Presence System

Використати Redis як KV-store для онлайн-присутності:

- key: `presence:user:{user_id}` → value: `"online"`
- TTL: 40 секунд
- WS heartbeat кожні 20 секунд оновлює TTL

### Redis-клієнт

`common/redis_client.py`:

    import aioredis
    from typing import Optional

    _redis = None

    async def get_redis() -> aioredis.Redis:
        global _redis
        if _redis is None:
            _redis = await aioredis.from_url(
                os.getenv("REDIS_URL", "redis://redis:6379/0"),
                encoding="utf-8",
                decode_responses=True,
            )
        return _redis

## 4. HTTP API — City Rooms / Feed

### 4.1 Маршрути (routes_city.py)

Base prefix: `/city`.

#### GET `/city/rooms`

- Повертає список всіх кімнат.
- Query params: (optional) `limit`, `offset`.
- Response:

    [
      {
        "id": "room_city_general",
        "slug": "general",
        "name": "General",
        "description": "Головна кімната міста",
        "members_online": 42,
        "last_event": "2025-11-23T10:15:00Z"
      }
    ]

`members_online` рахувати через Redis:

- keys: `presence:user:*` → map users → rooms (див. нижче в Presence).

Для MVP можна:

- рахувати `members_online` приблизно: число унікальних `presence:user:*` (спрощено),
- або додати key `presence:room:{room_id}` (більш точно).

#### POST `/city/rooms`

Body:

    {
      "name": "Science",
      "slug": "science",
      "description": "Наукова кімната"
    }

- Генерує `id = room_city_{slug}`.
- Створює запис у `city_rooms`.
- Віддає створену кімнату.

#### GET `/city/rooms/{room_id}`

Returns:

- room meta
- останні 50 повідомлень
- приблизний `members_online`

    {
      "room": {
        "id": "room_city_general",
        "name": "General",
        "description": "Головна кімната міста"
      },
      "messages": [
        {
          "id": "m_city_...",
          "author_user_id": "u_123",
          "author_agent_id": null,
          "body": "Привіт місто!",
          "created_at": "..."
        }
      ],
      "members_online": 12
    }

#### POST `/city/rooms/{room_id}/messages`

- Body:

    {
      "body": "Текст повідомлення"
    }

- Запис у `city_room_messages`.
- Запис у `city_feed_events` з kind = `"room_message"`.
- Публікація WS event (див. WS нижче).
- Повертає створене повідомлення.

#### GET `/city/feed`

- Повертає останні N (наприклад, 20) подій:

    [
      {
        "id": "evt_city_...",
        "kind": "room_message",
        "room_id": "room_city_general",
        "user_id": "u_123",
        "payload": {"body": "Текст...", "snippet": "..."},
        "created_at": "..."
      }
    ]

## 5. WebSocket — City Rooms + Presence

### 5.1 City Rooms WS (`ws_city.py`)

Шлях (через already existing WS server):

    /ws/city/rooms/{room_id}

Події (JSON):

- Вхідні від клієнта:

    { "event": "room.join", "room_id": "room_city_general" }
    { "event": "room.leave", "room_id": "room_city_general" }
    { "event": "room.message.send", "room_id": "...", "body": "..." }

- Вихідні до клієнтів:

    { "event": "room.message", "room_id": "...", "message": { ... } }
    { "event": "room.join", "room_id": "...", "user_id": "u_123" }
    { "event": "room.leave", "room_id": "...", "user_id": "u_123" }
    { "event": "room.presence", "room_id": "...", "user_id": "u_123", "status": "online" }

При `room.message.send`:

1. зберегти в DB (`city_room_messages`)
2. зберегти в `city_feed_events`
3. розіслати WS подію `room.message` усім підписникам

### 5.2 Presence WS (`/ws/city/presence`)

- Вхідні:

    { "event": "presence.heartbeat", "user_id": "u_123" }

Обробка:

1. `SETEX presence:user:u_123 "online" 40`
2. broadcast (опційно) `presence.update`:

    { "event": "presence.update", "user_id": "u_123", "status": "online" }

1. Періодично (background task, наприклад кожні 30 сек):

2. сканувати `presence:user:*`,

3. якщо TTL минув (Redis сам видаляє keys), ws-клієнтам можна розіслати `presence.update` зі статусом `"offline"` (або робити lazy-оновлення при наступних запитах).

## 6. Backend для Second Me

### 6.1 Service logic (`secondme-service/service_secondme.py`)

Функції:

    async def get_or_create_session(user_id: str) -> SecondMeSession:
        # бере останню сесію або створює нову

    async def get_last_messages(user_id: str, limit: int = 5) -> list[SecondMeMessage]:
        # повертає останні 5 повідомлень з secondme_messages

    async def invoke_second_me(user_id: str, prompt: str) -> SecondMeMessage:
        # 1. get_or_create_session
        # 2. зберегти user-повідомлення
        # 3. зібрати короткий контекст (останні N повідомлень)
        # 4. викликати Agents Core:
        #    POST /agents/{second_me_agent_id}/invoke
        #    або NATS publish agents.invoke
        # 5. зберегти assistant-відповідь
        # 6. повернути відповідь

`second_me_agent_id` поки можна:

- або hardcode (один глобальний Second Me agent),
- або зберігати у таблиці `users` / `agents` як поле.

Для MVP — допустимо hardcode у конфігу.

### 6.2 API (`routes_secondme.py`)

#### POST `/secondme/invoke`

Body:

    { "prompt": "..." }

З HTTP-контексту брати `user_id` (із JWT). Викликати `invoke_second_me`, повернути:

    {
      "reply": "Текст відповіді",
      "history": [
        {"role": "user", "content": "..."},
        {"role": "assistant", "content": "..."}
      ]
    }

#### GET `/secondme/history`

Query: (optional) `limit`, default 5. Повернути:

    [
      {"role": "user", "content": "...", "created_at": "..."},
      {"role": "assistant", "content": "...", "created_at": "..."}
    ]

## 7. Інтеграція з Agents Core

Для Second Me використати стандартний шлях:

- або HTTP-level:

`text POST /agents/{second_me_agent_id}/invoke { "input": "...prompt+context...", "context": { "user_id": "...", "kind": "secondme" } }`

- або NATS (якщо вже зручно):

`json { "event": "agents.invoke", "agent_id": "ag_secondme", "payload": { "input": "...", "user_id": "u_123", "source": "secondme" } }`

Для MVP допустимо використати HTTP-виклик до Agents Core service.

## 8. Конфігурація (ENV)

Додати змінні:

    REDIS_URL=redis://redis:6379/0
    SECONDME_AGENT_ID=ag_secondme_global
    CITY_DEFAULT_ROOMS=general,welcome,builders

При старті `city-service`:

- якщо `CITY_DEFAULT_ROOMS` порожній → створити дефолтні кімнати:

- `room_city_general` ("General")

- `room_city_welcome` ("Welcome")
- `room_city_builders` ("Builders")

## 9. Acceptance Criteria

### 9.1 Public Rooms

- `GET /city/rooms` повертає список кімнат.
- `POST /city/rooms` створює кімнату, видно в фронтенді.
- `GET /city/rooms/{room_id}` повертає кімнату та останні повідомлення.

- `POST /city/rooms/{room_id}/messages`:

- зберігає повідомлення в DB,

- відправляє WS-івент `room.message`,
- додає запис у `city_feed_events`.

### 9.2 Presence

- при підключенні фронтенду до `/ws/city/presence` і надсиланні `presence.heartbeat`:

- Redis має ключ `presence:user:<user_id>` із TTL ~40с;

- інші клієнти отримують `presence.update` (online).

- при припиненні heartbeat ключ зникає → статус offline (або lazily оновлюється).

### 9.3 Second Me

- `POST /secondme/invoke`:

- робить виклик до Agents Core,

- повертає текст відповіді,

- історія зберігається у `secondme_messages`.

- `GET /secondme/history` повертає останні N записів.

### 9.4 City Home

- `GET /city/feed` повертає події (room messages мінімум).
- Frontend City Home (вже реалізований) отримує всі необхідні дані через API.

## 10. Команда до Cursor

"Реалізувати backend для City MVP згідно TASK_PHASE_CITY_BACKEND_FINISHER.md. Створити city-service (rooms, feed, presence) та secondme-service. Інтегрувати з Redis (presence) та Agents Core (Second Me). Не змінювати існуючий фронтенд, тільки підключити API."