Skip to content

Per-topic response profiling: learn user priorities by topic cluster #14

Description

@tyejcoleman

Problem

Response rate is a single number across all signals. GetResponseRate() in sqlite_heartbeat.go returns one float64 for the entire entity:

func (s *SQLiteStore) GetResponseRate(ctx context.Context, entityID, agentID string, days int) (float64, int, error)

This means responseCooldownMultiplier() applies the same backoff to all signals. But users have different response patterns per topic: fast on contributor issues, slow on documentation tasks, immediate on deadlines.

The heartbeat_actions table already stores topic_entities (JSON array of entity IDs from TopicEntities on HeartbeatResult), and the knowledge graph has EntityType classification (person, organization, product, concept, event). This is enough to cluster topics.

Proposal

Topic clustering

Map signals to topic clusters using entity types from the knowledge graph:

Entity Type Topic Cluster
person contributors
organization organizations
product projects
concept architecture
event events
No entity (pure memory) Derive from MemoryType: PLAN→tasks, ACTIVITY→activities, CONTEXT→conversations

New table

CREATE TABLE topic_response_profile (
    entity_id TEXT NOT NULL,
    agent_id TEXT NOT NULL DEFAULT 'default',
    topic_cluster TEXT NOT NULL,
    avg_response_time_ms INTEGER,
    response_rate REAL,
    sample_count INTEGER DEFAULT 0,
    updated_at TEXT DEFAULT (datetime('now')),
    PRIMARY KEY (entity_id, agent_id, topic_cluster)
);

Behavior changes

  1. Recording: After checkResponseTracking() detects a response, classify the tick's signals into topic clusters using topic_entities from heartbeat_actions + entity type lookup. Update topic_response_profile with rolling averages.

  2. Per-topic cooldown: In evaluateShouldAct(), replace the single responseCooldownMultiplier(rate) call with per-topic multipliers:

    func (k *Keyoku) topicCooldownMultiplier(ctx context.Context, entityID, agentID string, signals []Signal) float64 {
        // Get primary topic cluster for current signals
        cluster := classifySignalCluster(signals)
        profile := k.store.GetTopicProfile(ctx, entityID, agentID, cluster)
        if profile == nil || profile.SampleCount < 5 {
            return responseCooldownMultiplier(globalRate) // fallback
        }
        return responseCooldownMultiplier(profile.ResponseRate)
    }
  3. Priority inference: Expose per-topic profiles in the watcher status API so users can see what the system learned about their priorities.

Files to modify

  • storage/sqlite_heartbeat.go — new table, UpsertTopicProfile(), GetTopicProfile(), ListTopicProfiles()
  • storage/sqlite_migrate.go — migration
  • heartbeat_decide.goclassifySignalCluster(), replace responseCooldownMultiplier with per-topic version
  • watcher.go — expose profiles in Status() response
  • storage/models.goTopicResponseProfile struct

Constraints

  • Minimum 5 samples per cluster before using learned rate (fallback to global rate)
  • 14-day rolling window for averages
  • Cluster classification is best-effort: if no entities are extracted, fall back to memory type
  • Profile updates happen asynchronously (don't block the tick)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions