feat: add Agent Internal Monologue Visualizer
Real-time UI component for watching the agent's thinking process: - New /monologue route with page, JSON API, and WebSocket endpoint - Live thought streaming via WebSocket with automatic reconnection - Raw/Summarized view toggle for thought content - Monospace terminal-style display with Mission Control theme - Seed-type color-coded badges (existential, creative, sovereignty, etc.) - Auto-scroll with manual override, connection status indicator - Mobile-responsive layout - 10 unit tests covering page, API, and summarise helper Fixes #1005 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ from dashboard.routes.memory import router as memory_router
|
||||
from dashboard.routes.mobile import router as mobile_router
|
||||
from dashboard.routes.models import api_router as models_api_router
|
||||
from dashboard.routes.models import router as models_router
|
||||
from dashboard.routes.monologue import router as monologue_router
|
||||
from dashboard.routes.quests import router as quests_router
|
||||
from dashboard.routes.scorecards import router as scorecards_router
|
||||
from dashboard.routes.spark import router as spark_router
|
||||
@@ -618,6 +619,7 @@ app.include_router(models_api_router)
|
||||
app.include_router(chat_api_router)
|
||||
app.include_router(chat_api_v1_router)
|
||||
app.include_router(thinking_router)
|
||||
app.include_router(monologue_router)
|
||||
app.include_router(calm_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(work_orders_router)
|
||||
|
||||
110
src/dashboard/routes/monologue.py
Normal file
110
src/dashboard/routes/monologue.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Internal Monologue Visualizer — real-time agent reasoning stream.
|
||||
|
||||
GET /monologue — render the monologue visualizer page
|
||||
GET /monologue/api — JSON list of recent thoughts (with optional summary)
|
||||
WS /monologue/ws — WebSocket stream of live thoughts
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from dashboard.templating import templates
|
||||
from timmy.thinking import thinking_engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/monologue", tags=["monologue"])
|
||||
|
||||
|
||||
def _summarise(content: str, max_len: int = 120) -> str:
|
||||
"""Return a short summary of a thought — first sentence or truncated."""
|
||||
# Take first sentence
|
||||
for sep in (".", "!", "?"):
|
||||
idx = content.find(sep)
|
||||
if 0 < idx < max_len:
|
||||
return content[: idx + 1]
|
||||
# Fallback: truncate
|
||||
if len(content) <= max_len:
|
||||
return content
|
||||
return content[:max_len].rsplit(" ", 1)[0] + "..."
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def monologue_page(request: Request):
|
||||
"""Render the internal monologue visualizer."""
|
||||
thoughts = thinking_engine.get_recent_thoughts(limit=50)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"monologue.html",
|
||||
{"thoughts": thoughts},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api", response_class=JSONResponse)
|
||||
async def monologue_api(
|
||||
limit: int = Query(default=30, ge=1, le=100),
|
||||
view: str = Query(default="raw", pattern="^(raw|summarized)$"),
|
||||
):
|
||||
"""Return recent thoughts as JSON, optionally summarised."""
|
||||
thoughts = thinking_engine.get_recent_thoughts(limit=limit)
|
||||
results = []
|
||||
for t in thoughts:
|
||||
entry = {
|
||||
"id": t.id,
|
||||
"content": t.content,
|
||||
"seed_type": t.seed_type,
|
||||
"parent_id": t.parent_id,
|
||||
"created_at": t.created_at,
|
||||
}
|
||||
if view == "summarized":
|
||||
entry["summary"] = _summarise(t.content)
|
||||
results.append(entry)
|
||||
return results
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def monologue_ws(websocket: WebSocket):
|
||||
"""Stream thoughts to the client in real time.
|
||||
|
||||
Piggybacks on the existing ws_manager broadcast infrastructure.
|
||||
We register a dedicated connection list here so monologue clients
|
||||
only receive thought events (not all swarm traffic).
|
||||
"""
|
||||
await websocket.accept()
|
||||
logger.info("Monologue WebSocket connected")
|
||||
|
||||
# Send recent thoughts as backfill
|
||||
try:
|
||||
recent = thinking_engine.get_recent_thoughts(limit=20)
|
||||
for t in reversed(recent): # oldest first
|
||||
await websocket.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "thought",
|
||||
"data": {
|
||||
"thought_id": t.id,
|
||||
"content": t.content,
|
||||
"seed_type": t.seed_type,
|
||||
"parent_id": t.parent_id,
|
||||
"created_at": t.created_at,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Monologue backfill error: %s", exc)
|
||||
|
||||
# Register with ws_manager so we receive broadcast events
|
||||
from infrastructure.ws_manager.handler import ws_manager
|
||||
|
||||
await ws_manager.connect(websocket, accept=False)
|
||||
try:
|
||||
while True:
|
||||
# Keep alive — client may send pings or view-mode switches
|
||||
await websocket.receive_text()
|
||||
except (WebSocketDisconnect, Exception) as exc:
|
||||
logger.debug("Monologue WebSocket disconnect: %s", exc)
|
||||
ws_manager.disconnect(websocket)
|
||||
@@ -49,6 +49,7 @@
|
||||
<a href="/tasks" class="mc-test-link">TASKS</a>
|
||||
<a href="/briefing" class="mc-test-link">BRIEFING</a>
|
||||
<a href="/thinking" class="mc-test-link mc-link-thinking">THINKING</a>
|
||||
<a href="/monologue" class="mc-test-link mc-link-monologue">MONOLOGUE</a>
|
||||
<a href="/swarm/mission-control" class="mc-test-link">MISSION CTRL</a>
|
||||
<a href="/swarm/live" class="mc-test-link">SWARM</a>
|
||||
<a href="/scorecards" class="mc-test-link">SCORECARDS</a>
|
||||
@@ -122,6 +123,7 @@
|
||||
<a href="/tasks" class="mc-mobile-link">TASKS</a>
|
||||
<a href="/briefing" class="mc-mobile-link">BRIEFING</a>
|
||||
<a href="/thinking" class="mc-mobile-link">THINKING</a>
|
||||
<a href="/monologue" class="mc-mobile-link">MONOLOGUE</a>
|
||||
<a href="/swarm/mission-control" class="mc-mobile-link">MISSION CONTROL</a>
|
||||
<a href="/swarm/live" class="mc-mobile-link">SWARM</a>
|
||||
<a href="/scorecards" class="mc-mobile-link">SCORECARDS</a>
|
||||
|
||||
221
src/dashboard/templates/monologue.html
Normal file
221
src/dashboard/templates/monologue.html
Normal file
@@ -0,0 +1,221 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Internal Monologue{% endblock %}
|
||||
|
||||
{% block extra_styles %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container monologue-container py-4">
|
||||
|
||||
<div class="monologue-header mb-4">
|
||||
<div class="monologue-title">Internal Monologue</div>
|
||||
<div class="monologue-subtitle">
|
||||
Real-time reasoning stream — watch the agent think.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Controls ── #}
|
||||
<div class="monologue-controls mb-3 d-flex align-items-center gap-3">
|
||||
<div class="monologue-view-toggle btn-group" role="group" aria-label="View mode">
|
||||
<button type="button" class="btn btn-sm monologue-btn active" data-view="raw">RAW</button>
|
||||
<button type="button" class="btn btn-sm monologue-btn" data-view="summarized">SUMMARIZED</button>
|
||||
</div>
|
||||
<div class="monologue-status">
|
||||
<span class="monologue-dot"></span>
|
||||
<span class="monologue-status-text">Connecting...</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm monologue-btn monologue-scroll-btn" title="Auto-scroll to latest">AUTO-SCROLL</button>
|
||||
</div>
|
||||
|
||||
{# ── Thought stream ── #}
|
||||
<div class="card mc-panel">
|
||||
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
|
||||
<span>// INTERNAL MONOLOGUE</span>
|
||||
<span class="badge monologue-count-badge">{{ thoughts | length }} thoughts</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="monologue-stream" class="monologue-stream">
|
||||
{% if thoughts %}
|
||||
{% for thought in thoughts %}
|
||||
<div class="monologue-entry" data-thought-id="{{ thought.id }}" data-seed="{{ thought.seed_type }}">
|
||||
<div class="monologue-entry-gutter">
|
||||
<span class="monologue-timestamp">{{ thought.created_at[11:19] }}</span>
|
||||
<span class="monologue-seed seed-{{ thought.seed_type }}">{{ thought.seed_type }}</span>
|
||||
</div>
|
||||
<div class="monologue-entry-content">
|
||||
<span class="monologue-raw">{{ thought.content | e }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="monologue-empty">
|
||||
Waiting for first thought... the thinking engine will begin shortly after startup.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var stream = document.getElementById('monologue-stream');
|
||||
var statusDot = document.querySelector('.monologue-dot');
|
||||
var statusText = document.querySelector('.monologue-status-text');
|
||||
var countBadge = document.querySelector('.monologue-count-badge');
|
||||
var viewBtns = document.querySelectorAll('.monologue-view-toggle .monologue-btn');
|
||||
var scrollBtn = document.querySelector('.monologue-scroll-btn');
|
||||
var currentView = 'raw';
|
||||
var autoScroll = true;
|
||||
var thoughtCount = stream.querySelectorAll('.monologue-entry').length;
|
||||
var ws = null;
|
||||
var reconnectDelay = 1000;
|
||||
var seenIds = new Set();
|
||||
|
||||
// Track already-rendered thought IDs
|
||||
stream.querySelectorAll('.monologue-entry').forEach(function(el) {
|
||||
var id = el.getAttribute('data-thought-id');
|
||||
if (id) seenIds.add(id);
|
||||
});
|
||||
|
||||
// ── View toggle ──
|
||||
viewBtns.forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
viewBtns.forEach(function(b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
currentView = btn.getAttribute('data-view');
|
||||
applyView();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Auto-scroll toggle ──
|
||||
scrollBtn.classList.add('active');
|
||||
scrollBtn.addEventListener('click', function() {
|
||||
autoScroll = !autoScroll;
|
||||
scrollBtn.classList.toggle('active', autoScroll);
|
||||
});
|
||||
|
||||
function applyView() {
|
||||
stream.querySelectorAll('.monologue-entry').forEach(function(entry) {
|
||||
var raw = entry.querySelector('.monologue-raw');
|
||||
var summary = entry.querySelector('.monologue-summary');
|
||||
if (currentView === 'summarized') {
|
||||
if (raw) raw.style.display = 'none';
|
||||
if (summary) summary.style.display = '';
|
||||
} else {
|
||||
if (raw) raw.style.display = '';
|
||||
if (summary) summary.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function summarise(text) {
|
||||
// First sentence or truncate at 120 chars
|
||||
var m = text.match(/^[^.!?]*[.!?]/);
|
||||
if (m && m[0].length < 120) return m[0];
|
||||
if (text.length <= 120) return text;
|
||||
return text.substring(0, 120).replace(/\s+\S*$/, '') + '...';
|
||||
}
|
||||
|
||||
function createEntry(data) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'monologue-entry monologue-entry-new';
|
||||
div.setAttribute('data-thought-id', data.thought_id || '');
|
||||
div.setAttribute('data-seed', data.seed_type || 'freeform');
|
||||
|
||||
var ts = '';
|
||||
if (data.created_at && data.created_at.length >= 19) {
|
||||
ts = data.created_at.substring(11, 19);
|
||||
}
|
||||
|
||||
var escaped = (data.content || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
var summaryText = summarise(data.content || '');
|
||||
var escapedSummary = summaryText.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
div.innerHTML =
|
||||
'<div class="monologue-entry-gutter">' +
|
||||
'<span class="monologue-timestamp">' + ts + '</span>' +
|
||||
'<span class="monologue-seed seed-' + (data.seed_type || 'freeform') + '">' + (data.seed_type || 'freeform') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="monologue-entry-content">' +
|
||||
'<span class="monologue-raw"' + (currentView === 'summarized' ? ' style="display:none"' : '') + '>' + escaped + '</span>' +
|
||||
'<span class="monologue-summary"' + (currentView === 'raw' ? ' style="display:none"' : '') + '>' + escapedSummary + '</span>' +
|
||||
'</div>';
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
function addThought(data) {
|
||||
var id = data.thought_id;
|
||||
if (id && seenIds.has(id)) return;
|
||||
if (id) seenIds.add(id);
|
||||
|
||||
// Remove empty placeholder
|
||||
var empty = stream.querySelector('.monologue-empty');
|
||||
if (empty) empty.remove();
|
||||
|
||||
var entry = createEntry(data);
|
||||
stream.appendChild(entry);
|
||||
thoughtCount++;
|
||||
countBadge.textContent = thoughtCount + ' thoughts';
|
||||
|
||||
// Trigger animation
|
||||
requestAnimationFrame(function() {
|
||||
entry.classList.remove('monologue-entry-new');
|
||||
});
|
||||
|
||||
if (autoScroll) {
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(state) {
|
||||
statusDot.className = 'monologue-dot monologue-dot-' + state;
|
||||
if (state === 'connected') statusText.textContent = 'Live';
|
||||
else if (state === 'connecting') statusText.textContent = 'Connecting...';
|
||||
else statusText.textContent = 'Disconnected';
|
||||
}
|
||||
|
||||
function connect() {
|
||||
setStatus('connecting');
|
||||
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(proto + '//' + location.host + '/monologue/ws');
|
||||
|
||||
ws.onopen = function() {
|
||||
setStatus('connected');
|
||||
reconnectDelay = 1000;
|
||||
};
|
||||
|
||||
ws.onmessage = function(evt) {
|
||||
try {
|
||||
var msg = JSON.parse(evt.data);
|
||||
// Accept both direct thought events and ws_manager broadcast format
|
||||
if (msg.event === 'thought' && msg.data) {
|
||||
addThought(msg.data);
|
||||
} else if (msg.event === 'timmy_thought' && msg.data) {
|
||||
addThought(msg.data);
|
||||
}
|
||||
} catch(e) {
|
||||
// Ignore non-JSON or irrelevant messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function() {
|
||||
setStatus('disconnected');
|
||||
setTimeout(connect, reconnectDelay);
|
||||
reconnectDelay = Math.min(reconnectDelay * 1.5, 15000);
|
||||
};
|
||||
|
||||
ws.onerror = function() {
|
||||
setStatus('disconnected');
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
// Scroll to bottom on load
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -2547,3 +2547,73 @@
|
||||
.tower-adv-title { font-size: 0.85rem; font-weight: 600; color: var(--text-bright); }
|
||||
.tower-adv-detail { font-size: 0.8rem; color: var(--text); margin-top: 2px; }
|
||||
.tower-adv-action { font-size: 0.75rem; color: var(--green); margin-top: 4px; font-style: italic; }
|
||||
|
||||
/* ── Internal Monologue Visualizer ────────────────────────── */
|
||||
|
||||
.monologue-container { max-width: 960px; }
|
||||
.monologue-title { font-size: 1.6rem; font-weight: 700; color: var(--text-bright); letter-spacing: 0.04em; }
|
||||
.monologue-subtitle { font-size: 0.85rem; color: var(--text-dim); margin-top: 2px; }
|
||||
|
||||
/* Controls bar */
|
||||
.monologue-controls { flex-wrap: wrap; }
|
||||
.monologue-btn { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; letter-spacing: 0.08em; border: 1px solid var(--border); color: var(--text-dim); background: transparent; padding: 3px 10px; transition: all 0.2s ease; }
|
||||
.monologue-btn:hover { color: var(--text); border-color: var(--purple); }
|
||||
.monologue-btn.active { color: var(--purple); border-color: var(--purple); background: rgba(168, 85, 247, 0.08); }
|
||||
.monologue-count-badge { background: rgba(168, 85, 247, 0.15); color: var(--purple); font-size: 0.7rem; letter-spacing: 0.06em; }
|
||||
|
||||
/* Status indicator */
|
||||
.monologue-status { display: flex; align-items: center; gap: 6px; font-size: 0.7rem; color: var(--text-dim); letter-spacing: 0.06em; }
|
||||
.monologue-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim); transition: background 0.3s ease; }
|
||||
.monologue-dot-connected { background: var(--green); box-shadow: 0 0 6px var(--green); animation: monologue-pulse 2s ease-in-out infinite; }
|
||||
.monologue-dot-connecting { background: var(--amber); animation: monologue-pulse 1s ease-in-out infinite; }
|
||||
.monologue-dot-disconnected { background: var(--red); }
|
||||
|
||||
@keyframes monologue-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Stream container */
|
||||
.monologue-stream { max-height: 70vh; overflow-y: auto; padding: 0; font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; line-height: 1.6; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||
.monologue-stream::-webkit-scrollbar { width: 6px; }
|
||||
.monologue-stream::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
.monologue-stream::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* Individual thought entry */
|
||||
.monologue-entry { display: flex; gap: 12px; padding: 8px 14px; border-bottom: 1px solid rgba(59, 26, 92, 0.3); transition: opacity 0.4s ease, transform 0.4s ease, background 0.2s ease; }
|
||||
.monologue-entry:hover { background: rgba(168, 85, 247, 0.04); }
|
||||
.monologue-entry-new { opacity: 0; transform: translateY(8px); }
|
||||
|
||||
/* Gutter (timestamp + seed badge) */
|
||||
.monologue-entry-gutter { display: flex; flex-direction: column; align-items: flex-end; min-width: 90px; flex-shrink: 0; gap: 2px; padding-top: 1px; }
|
||||
.monologue-timestamp { font-size: 0.65rem; color: var(--text-dim); letter-spacing: 0.04em; }
|
||||
.monologue-seed { font-size: 0.55rem; letter-spacing: 0.08em; text-transform: uppercase; padding: 1px 5px; border-radius: 2px; color: var(--text-dim); background: rgba(59, 26, 92, 0.3); }
|
||||
|
||||
/* Seed-type colour accents */
|
||||
.seed-existential { color: var(--purple); background: rgba(168, 85, 247, 0.12); }
|
||||
.seed-swarm { color: var(--green); background: rgba(0, 232, 122, 0.10); }
|
||||
.seed-scripture { color: var(--amber); background: rgba(255, 184, 0, 0.10); }
|
||||
.seed-creative { color: var(--orange); background: rgba(255, 122, 42, 0.10); }
|
||||
.seed-memory { color: #60a5fa; background: rgba(96, 165, 250, 0.10); }
|
||||
.seed-freeform { color: var(--text-dim); background: rgba(59, 26, 92, 0.3); }
|
||||
.seed-sovereignty { color: var(--red); background: rgba(255, 68, 85, 0.08); }
|
||||
.seed-observation { color: var(--green); background: rgba(0, 232, 122, 0.08); }
|
||||
.seed-workspace { color: var(--amber); background: rgba(255, 184, 0, 0.08); }
|
||||
.seed-prompted { color: var(--text-bright); background: rgba(237, 224, 255, 0.08); }
|
||||
|
||||
/* Content area */
|
||||
.monologue-entry-content { flex: 1; color: var(--text); word-break: break-word; }
|
||||
.monologue-summary { color: var(--text-dim); font-style: italic; }
|
||||
|
||||
/* Empty state */
|
||||
.monologue-empty { padding: 40px 20px; text-align: center; color: var(--text-dim); font-size: 0.85rem; }
|
||||
|
||||
/* Scroll button */
|
||||
.monologue-scroll-btn.active { color: var(--green); border-color: var(--green); background: rgba(0, 232, 122, 0.08); }
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 576px) {
|
||||
.monologue-entry { flex-direction: column; gap: 4px; }
|
||||
.monologue-entry-gutter { flex-direction: row; align-items: center; min-width: auto; gap: 8px; }
|
||||
.monologue-stream { max-height: 60vh; font-size: 0.75rem; }
|
||||
}
|
||||
|
||||
84
tests/dashboard/test_monologue.py
Normal file
84
tests/dashboard/test_monologue.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Tests for the internal monologue visualizer routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def client_fixture():
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from dashboard.app import app
|
||||
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestMonologuePage:
|
||||
"""GET /monologue — renders the visualizer page."""
|
||||
|
||||
def test_page_returns_200(self, client):
|
||||
resp = client.get("/monologue")
|
||||
assert resp.status_code == 200
|
||||
assert "Internal Monologue" in resp.text
|
||||
|
||||
def test_page_contains_stream_container(self, client):
|
||||
resp = client.get("/monologue")
|
||||
assert 'id="monologue-stream"' in resp.text
|
||||
|
||||
def test_page_contains_view_toggle(self, client):
|
||||
resp = client.get("/monologue")
|
||||
assert 'data-view="raw"' in resp.text
|
||||
assert 'data-view="summarized"' in resp.text
|
||||
|
||||
|
||||
class TestMonologueAPI:
|
||||
"""GET /monologue/api — JSON thought list."""
|
||||
|
||||
def test_api_returns_list(self, client):
|
||||
resp = client.get("/monologue/api")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
def test_api_respects_limit(self, client):
|
||||
resp = client.get("/monologue/api?limit=5")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) <= 5
|
||||
|
||||
def test_api_raw_view_no_summary(self, client):
|
||||
resp = client.get("/monologue/api?view=raw")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
for entry in data:
|
||||
assert "summary" not in entry
|
||||
|
||||
def test_api_summarized_view_has_summary(self, client):
|
||||
resp = client.get("/monologue/api?view=summarized")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
for entry in data:
|
||||
assert "summary" in entry
|
||||
|
||||
|
||||
class TestSummarise:
|
||||
"""Unit tests for the _summarise helper."""
|
||||
|
||||
def test_short_text_unchanged(self):
|
||||
from dashboard.routes.monologue import _summarise
|
||||
|
||||
assert _summarise("Short thought.") == "Short thought."
|
||||
|
||||
def test_first_sentence_extracted(self):
|
||||
from dashboard.routes.monologue import _summarise
|
||||
|
||||
text = "First sentence. Second sentence continues here."
|
||||
assert _summarise(text) == "First sentence."
|
||||
|
||||
def test_long_text_truncated(self):
|
||||
from dashboard.routes.monologue import _summarise
|
||||
|
||||
text = "A " * 200 # No sentence-ending punctuation
|
||||
result = _summarise(text.strip())
|
||||
assert len(result) <= 125 # max_len + ellipsis
|
||||
assert result.endswith("...")
|
||||
Reference in New Issue
Block a user