Skip to content

Telegram Dynamic Tailoring -- Phase 1

Design spec for inline keyboard feedback and EMA scoring in the Hinata Telegram bot (CT106 minato-telegram).

Architecture

mermaid
graph TD
    subgraph CT106["CT106 minato-telegram"]
        BOT["telegram-bot.py"]
        SCHED["APScheduler<br/>(EMA cron)"]
    end

    subgraph CT100["CT100 jimmy-neutron-postgres"]
        DB[(PostgreSQL 15<br/>hinata)]
        FB["hinata.response_feedback"]
        EMA["allmight.ema_readings"]
        BURN["hinata.burnout_checks"]
    end

    MICHAEL((Michael)) -->|message| BOT
    BOT -->|substantive reply<br/>+ inline keyboard| MICHAEL
    MICHAEL -->|button press| BOT
    BOT -->|INSERT feedback| FB
    SCHED -->|3x daily<br/>conditional| MICHAEL
    MICHAEL -->|EMA button press| BOT
    BOT -->|INSERT reading| EMA

    FB -->|weekly aggregate| OROQ["Orochimaru<br/>scout query"]
    EMA -->|trend query| ALLQ["AllMight<br/>burnout heuristic"]
    ALLQ -->|write| BURN
    BURN -->|threshold alert| BOT
    BOT -->|adjusted response<br/>style| MICHAEL

1. Inline Keyboard Feedback

Data model

Table: hinata.response_feedback (CT100, hinata schema -- alongside existing burnout_checks, system_events)

sql
CREATE TABLE hinata.response_feedback (
    id              BIGSERIAL PRIMARY KEY,
    message_id      BIGINT NOT NULL,          -- Telegram message_id of the bot response
    chat_id         BIGINT NOT NULL,          -- Telegram chat_id (always Michael's)
    rating          TEXT NOT NULL,             -- 'good' | 'bad' | 'wrong_commander' | 'too_long'
    commander       TEXT,                      -- commander active at response time (from RAG-lite)
    followup_text   TEXT,                      -- optional free-text Michael sends after rating
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_feedback_created ON hinata.response_feedback (created_at DESC);
CREATE INDEX idx_feedback_commander ON hinata.response_feedback (commander);

Message flow

  1. After every concierge response (not capture-mode acks), telegram-bot.py attaches an InlineKeyboardMarkup to the reply.
  2. Keyboard layout (single row, four buttons):
[ +1 ] [ -1 ] [ Wrong domain ] [ Too long ]

Callback data encoding: fb:{message_id}:{rating} -- e.g. fb:12345:good.

  1. CallbackQueryHandler receives the button press:

    • Parse callback data.
    • INSERT into hinata.response_feedback via async psycopg (connection pooled).
    • Answer the callback query with a brief ack ("Noted" / "Flagged -- will adjust").
    • Edit the original message to remove the inline keyboard (prevents double-rating).
  2. Optional followup capture: If Michael sends a text message within 30 seconds of a -1 or wrong_commander rating, attach it as followup_text to the same feedback row (UPDATE by message_id).

Implementation in telegram-bot.py

python
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import CallbackQueryHandler

FEEDBACK_KEYBOARD = InlineKeyboardMarkup([
    [
        InlineKeyboardButton("+1", callback_data="fb:__MID__:good"),
        InlineKeyboardButton("-1", callback_data="fb:__MID__:bad"),
        InlineKeyboardButton("Wrong domain", callback_data="fb:__MID__:wrong_commander"),
        InlineKeyboardButton("Too long", callback_data="fb:__MID__:too_long"),
    ]
])

def make_feedback_kb(message_id: int) -> InlineKeyboardMarkup:
    """Stamp the message_id into the callback_data template."""
    return InlineKeyboardMarkup([
        [
            InlineKeyboardButton("+1", callback_data=f"fb:{message_id}:good"),
            InlineKeyboardButton("-1", callback_data=f"fb:{message_id}:bad"),
            InlineKeyboardButton("Wrong domain", callback_data=f"fb:{message_id}:wrong_commander"),
            InlineKeyboardButton("Too long", callback_data=f"fb:{message_id}:too_long"),
        ]
    ])

Attach to reply in handle_message:

python
sent = await update.message.reply_text(response_text, parse_mode="Markdown")
# Only for concierge (substantive) responses
if concierge_available():
    await sent.edit_reply_markup(reply_markup=make_feedback_kb(sent.message_id))

Orochimaru consumption

Orochimaru's weekly scout query (Sunday 21:00) reads aggregated feedback:

sql
-- Commander quality score for the week
SELECT commander,
       COUNT(*) FILTER (WHERE rating = 'good') AS positive,
       COUNT(*) FILTER (WHERE rating = 'bad') AS negative,
       COUNT(*) FILTER (WHERE rating = 'wrong_commander') AS misrouted,
       COUNT(*) FILTER (WHERE rating = 'too_long') AS verbose
FROM hinata.response_feedback
WHERE created_at > NOW() - INTERVAL '7 days'
GROUP BY commander
ORDER BY negative DESC;

This feeds the "response quality" dimension in Meruem's 5-dimension maturity scoring.


2. EMA Scoring (Ecological Momentary Assessment)

Data model

Table: allmight.ema_readings (CT100, new allmight schema -- AllMight-health owns this data)

sql
CREATE SCHEMA IF NOT EXISTS allmight;

CREATE TABLE allmight.ema_readings (
    id              BIGSERIAL PRIMARY KEY,
    energy          SMALLINT NOT NULL CHECK (energy BETWEEN 1 AND 5),
    mood            SMALLINT NOT NULL CHECK (mood BETWEEN 1 AND 5),
    focus           SMALLINT NOT NULL CHECK (focus BETWEEN 1 AND 5),
    followup_text   TEXT,                      -- optional free-text ("What's on your mind?")
    prompt_type     TEXT NOT NULL DEFAULT 'scheduled',  -- 'scheduled' | 'manual' | 'burnout_followup'
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_ema_created ON allmight.ema_readings (created_at DESC);

Prompt schedule and logic

Three daily windows, using APScheduler (already available in the python-telegram-bot ecosystem via JobQueue):

WindowTarget timeVariationCondition
Morning09:00+/- 15 min randomMichael messaged bot in last 4 hours
Midday13:00+/- 15 min randomMichael messaged bot in last 4 hours
Evening20:00+/- 15 min randomMichael messaged bot in last 4 hours

Attention boundary rule: Before sending an EMA prompt, check _last_user_message_ts (already tracked by the bot's session state). If now - _last_user_message_ts > 4 hours, skip this window silently. Do not queue -- the reading is simply missed. This respects the noise policy.

Random jitter: Add random.randint(-15, 15) minutes to each scheduled time at bot startup to prevent the check-in feeling robotic. Recompute jitter daily.

EMA inline keyboard flow

The EMA prompt is a three-stage inline keyboard conversation:

Stage 1 -- Energy:

How's your energy?
[ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ]

Callback data: ema:energy:{value}

Stage 2 -- Mood (sent after energy selection):

Mood?
[ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ]

Callback data: ema:mood:{value}

Stage 3 -- Focus (sent after mood selection):

Focus?
[ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ]

Callback data: ema:focus:{value}

Stage 4 -- Optional followup (sent after focus selection):

Anything on your mind? (send a message or tap Skip)
[ Skip ]

Callback data: ema:skip

If Michael sends a text message instead of tapping Skip, capture it as followup_text.

State tracking: Use context.user_data["ema_pending"] dict to hold partial readings while the 3-stage flow completes. Structure:

python
context.user_data["ema_pending"] = {
    "energy": None,    # filled after stage 1
    "mood": None,      # filled after stage 2
    "focus": None,     # filled after stage 3
    "followup": None,  # filled after stage 4 or skip
    "started_at": datetime.now(timezone.utc),
}

On completion (all three scores + skip/text), INSERT into allmight.ema_readings and clear ema_pending.

Timeout: If ema_pending is older than 30 minutes when the next message arrives, discard it silently. Stale partial readings are noise.

Manual trigger

/checkin command triggers an EMA prompt immediately, bypassing the schedule and attention boundary. Uses prompt_type = 'manual'.

JobQueue setup (in main)

python
from telegram.ext import JobQueue

async def ema_job(context: ContextTypes.DEFAULT_TYPE):
    """Scheduled EMA prompt -- respects attention boundary."""
    last_msg = context.bot_data.get("last_user_message_ts")
    if last_msg is None:
        return  # no interaction this session
    gap = (datetime.now(timezone.utc) - last_msg).total_seconds()
    if gap > 4 * 3600:
        log.info("EMA skipped -- no user message in last 4h")
        return
    await send_ema_prompt(context, int(CHAT_ID))

# In main(), after app.build():
job_queue = app.job_queue
job_queue.run_daily(ema_job, time=time(hour=9, minute=0), name="ema_morning")
job_queue.run_daily(ema_job, time=time(hour=13, minute=0), name="ema_midday")
job_queue.run_daily(ema_job, time=time(hour=20, minute=0), name="ema_evening")

3. Burnout Risk Integration

Signal sources

SignalSourceWeight
EMA trendallmight.ema_readings -- 3+ consecutive readings with avg < 2.5 across dimensionsPrimary
Token burn spikeorochimaru.token_burn_daily -- daily burn > 2x 7-day rolling averageSecondary
Combined signalHigh token burn + low energy EMA in same dayCritical

Detection logic

Run after every EMA INSERT (trigger in the callback handler, not a separate cron):

python
async def check_burnout_risk(pool) -> str | None:
    """Returns 'RED' | 'AMBER' | None based on recent EMA readings + token burn."""
    async with pool.acquire() as conn:
        # Last 3 EMA readings
        rows = await conn.fetch("""
            SELECT energy, mood, focus, created_at
            FROM allmight.ema_readings
            ORDER BY created_at DESC LIMIT 3
        """)
        if len(rows) < 3:
            return None

        avg_energy = sum(r['energy'] for r in rows) / 3
        avg_mood = sum(r['mood'] for r in rows) / 3
        avg_focus = sum(r['focus'] for r in rows) / 3
        composite = (avg_energy + avg_mood + avg_focus) / 3

        # Check token burn spike (today vs 7-day avg)
        burn_row = await conn.fetchrow("""
            SELECT
                COALESCE((SELECT total_tokens FROM orochimaru.token_burn_daily
                          WHERE burn_date = CURRENT_DATE), 0) AS today,
                COALESCE((SELECT AVG(total_tokens) FROM orochimaru.token_burn_daily
                          WHERE burn_date > CURRENT_DATE - 7), 1) AS avg_7d
        """)
        burn_spike = (burn_row['today'] / burn_row['avg_7d']) > 2.0 if burn_row['avg_7d'] > 0 else False

        # Decision
        if composite < 2.0 and burn_spike:
            return 'RED'
        elif composite < 2.5:
            return 'AMBER'
        elif composite < 3.0 and burn_spike:
            return 'AMBER'
        return None

Response adjustment

When burnout risk is detected, the bot modifies its behaviour for the remainder of the session:

Risk levelBot adjustments
AMBERAppend to system prompt: "Michael may be fatigued. Keep responses under 3 sentences. End with one supportive note."
REDSame as AMBER + proactive message: "You've been running hard. Consider stepping away for a bit." + suppress non-critical notifications for 2 hours

Storage: Write risk level to hinata.burnout_checks (existing table) with source 'ema_trigger':

sql
INSERT INTO hinata.burnout_checks (risk_level, rationale, source, created_at)
VALUES ($1, $2, 'ema_trigger', NOW());

Session flag: Set context.bot_data["burnout_mode"] = "AMBER" (or "RED"). The concierge response builder checks this flag and adjusts the system prompt before calling the Anthropic API.

AllMight weekly flag integration

AllMight's existing burnout-risk-weekly-flag ambient reads from allmight.ema_readings instead of (currently unwired) session-log heuristics. This wires the "energy signal" input that AllMight's burnout flag has been waiting for (see data-plumbing gap noted in AllMight context).


4. Database Connection

CT106 connects to CT100 postgres over the LAN (192.168.1.253:5432). Connection pool initialised at bot startup:

python
import asyncpg

DB_DSN = "postgresql://hinata_bot:$PASSWORD@192.168.1.253:5432/hinata"

async def init_db_pool():
    return await asyncpg.create_pool(DB_DSN, min_size=1, max_size=3)

Credential: Store the postgres password in CT106 env (/etc/hinata/telegram.env) as POSTGRES_DSN. Load alongside existing credential flow. Register in Vaultwarden via Itachi.

Fallback: If postgres is unreachable, log feedback/EMA to local JSON files (/tmp/hinata-feedback-buffer.jsonl, /tmp/hinata-ema-buffer.jsonl). A retry loop flushes the buffer every 5 minutes. This prevents feedback loss during CT100 maintenance windows.


5. Implementation Phases

Phase 1 -- Feedback keyboard (1 session)

  • [ ] Create hinata.response_feedback table on CT100
  • [ ] Add make_feedback_kb() to telegram-bot.py
  • [ ] Add CallbackQueryHandler for fb:* pattern
  • [ ] Attach keyboard to concierge responses in handle_message
  • [ ] Wire asyncpg connection pool + INSERT
  • [ ] Add local JSON fallback buffer
  • [ ] Test: send message, rate it, verify row in postgres

Phase 2 -- EMA prompts (1 session)

  • [ ] Create allmight schema and ema_readings table on CT100
  • [ ] Implement send_ema_prompt() 3-stage keyboard flow
  • [ ] Add CallbackQueryHandler for ema:* pattern
  • [ ] Track partial readings in context.user_data["ema_pending"]
  • [ ] Add /checkin command
  • [ ] Wire JobQueue for 3x daily schedule
  • [ ] Add attention boundary check (4-hour rule)
  • [ ] Test: trigger /checkin, complete all 3 stages, verify row in postgres

Phase 3 -- Burnout integration (1 session)

  • [ ] Implement check_burnout_risk() triggered after every EMA INSERT
  • [ ] Write risk flags to hinata.burnout_checks
  • [ ] Add burnout_mode session flag and system prompt injection
  • [ ] Wire proactive message for RED risk
  • [ ] Add notification suppression for RED (2-hour window)
  • [ ] Test: insert low EMA scores, verify AMBER/RED detection and prompt adjustment

Phase 4 -- Orochimaru consumption (next scout cycle)

  • [ ] Add feedback aggregation query to Orochimaru weekly scout
  • [ ] Add EMA trend summary to AllMight burnout-risk-weekly-flag
  • [ ] Surface misrouted-commander count in Meruem evolution assessment

6. Dependencies and Blockers

DependencyStatusBlocker?
asyncpg installed in CT106 venvNot yetYes -- pip install asyncpg in CT106
CT106 network access to CT100:5432Existing (LAN)No
hinata schema on CT100Exists (renamed 2026-06-08)No
allmight schema on CT100Does not existYes -- CREATE SCHEMA
orochimaru.token_burn_daily tableExistsNo
Postgres bot user + passwordNot yetYes -- create role, register in Vaultwarden
python-telegram-bot InlineKeyboard supportExists (v20+)No
python-telegram-bot JobQueueExists (v20+)No
AllMight burnout flag data plumbingUnwired (see context)No -- this spec wires it

Security notes

  • Postgres DSN loaded from env, never hardcoded.
  • Bot user gets INSERT/SELECT only on hinata.response_feedback, allmight.ema_readings, hinata.burnout_checks, and SELECT on orochimaru.token_burn_daily. No DELETE, no DDL.
  • Callback data is validated server-side (regex match on fb:{int}:{enum} and ema:{dimension}:{int}). Malformed callbacks are silently dropped.
  • EMA readings contain no PII beyond the implicit "this is Michael's data" -- no user ID stored, single-tenant by design.

Cross-links: federation/captain_canary-organiser_context | federation/colonel_saitama-foundation_allmight-health_context | federation/captain_orochimaru-scout_context | supreme-court/telegram/telegram-noise-policy | the-government/information_reference/reference_z2-service-catalog