Compare commits
9 Commits
fix/router
...
kimi/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a9ff78b1c | ||
| 6f66670396 | |||
| 4cdd82818b | |||
| 99ad672e4d | |||
| a3f61c67d3 | |||
| 32dbdc68c8 | |||
| 84302aedac | |||
| 2c217104db | |||
| 7452e8a4f0 |
@@ -1,4 +1,4 @@
|
|||||||
from datetime import date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
|
||||||
from sqlalchemy import JSON, Boolean, Column, Date, DateTime, Index, Integer, String
|
from sqlalchemy import JSON, Boolean, Column, Date, DateTime, Index, Integer, String
|
||||||
@@ -40,8 +40,13 @@ class Task(Base):
|
|||||||
deferred_at = Column(DateTime, nullable=True)
|
deferred_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
updated_at = Column(
|
||||||
|
DateTime,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
__table_args__ = (Index("ix_task_state_order", "state", "sort_order"),)
|
__table_args__ = (Index("ix_task_state_order", "state", "sort_order"),)
|
||||||
|
|
||||||
@@ -59,4 +64,4 @@ class JournalEntry(Base):
|
|||||||
gratitude = Column(String(500), nullable=True)
|
gratitude = Column(String(500), nullable=True)
|
||||||
energy_level = Column(Integer, nullable=True) # User-reported, 1-10
|
energy_level = Column(Integer, nullable=True) # User-reported, 1-10
|
||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
created_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False)
|
||||||
|
|||||||
@@ -38,6 +38,56 @@ def get_later_tasks(db: Session) -> list[Task]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_mit_tasks(db: Session, titles: list[str | None]) -> list[int]:
|
||||||
|
"""Create MIT tasks from a list of titles, return their IDs."""
|
||||||
|
task_ids: list[int] = []
|
||||||
|
for title in titles:
|
||||||
|
if title:
|
||||||
|
task = Task(
|
||||||
|
title=title,
|
||||||
|
is_mit=True,
|
||||||
|
state=TaskState.LATER,
|
||||||
|
certainty=TaskCertainty.SOFT,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
task_ids.append(task.id)
|
||||||
|
return task_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _create_other_tasks(db: Session, other_tasks: str):
|
||||||
|
"""Create non-MIT tasks from newline-separated text."""
|
||||||
|
for line in other_tasks.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
task = Task(
|
||||||
|
title=line,
|
||||||
|
state=TaskState.LATER,
|
||||||
|
certainty=TaskCertainty.FUZZY,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_now_next(db: Session):
|
||||||
|
"""Set initial NOW/NEXT states when both slots are empty."""
|
||||||
|
if get_now_task(db) or get_next_task(db):
|
||||||
|
return
|
||||||
|
later_tasks = (
|
||||||
|
db.query(Task)
|
||||||
|
.filter(Task.state == TaskState.LATER)
|
||||||
|
.order_by(Task.is_mit.desc(), Task.sort_order)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if later_tasks:
|
||||||
|
later_tasks[0].state = TaskState.NOW
|
||||||
|
db.add(later_tasks[0])
|
||||||
|
db.flush()
|
||||||
|
if len(later_tasks) > 1:
|
||||||
|
later_tasks[1].state = TaskState.NEXT
|
||||||
|
db.add(later_tasks[1])
|
||||||
|
|
||||||
|
|
||||||
def promote_tasks(db: Session):
|
def promote_tasks(db: Session):
|
||||||
"""Enforce the NOW/NEXT/LATER state machine invariants.
|
"""Enforce the NOW/NEXT/LATER state machine invariants.
|
||||||
|
|
||||||
@@ -114,63 +164,19 @@ async def post_morning_ritual(
|
|||||||
other_tasks: str = Form(""),
|
other_tasks: str = Form(""),
|
||||||
):
|
):
|
||||||
"""Process morning ritual: create MITs, other tasks, and set initial states."""
|
"""Process morning ritual: create MITs, other tasks, and set initial states."""
|
||||||
# Create Journal Entry
|
|
||||||
mit_task_ids = []
|
|
||||||
journal_entry = JournalEntry(entry_date=date.today())
|
journal_entry = JournalEntry(entry_date=date.today())
|
||||||
db.add(journal_entry)
|
db.add(journal_entry)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(journal_entry)
|
db.refresh(journal_entry)
|
||||||
|
|
||||||
# Create MIT tasks
|
journal_entry.mit_task_ids = _create_mit_tasks(db, [mit1_title, mit2_title, mit3_title])
|
||||||
for mit_title in [mit1_title, mit2_title, mit3_title]:
|
|
||||||
if mit_title:
|
|
||||||
task = Task(
|
|
||||||
title=mit_title,
|
|
||||||
is_mit=True,
|
|
||||||
state=TaskState.LATER, # Initially LATER, will be promoted
|
|
||||||
certainty=TaskCertainty.SOFT,
|
|
||||||
)
|
|
||||||
db.add(task)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(task)
|
|
||||||
mit_task_ids.append(task.id)
|
|
||||||
|
|
||||||
journal_entry.mit_task_ids = mit_task_ids
|
|
||||||
db.add(journal_entry)
|
db.add(journal_entry)
|
||||||
|
|
||||||
# Create other tasks
|
_create_other_tasks(db, other_tasks)
|
||||||
for task_title in other_tasks.split("\n"):
|
|
||||||
task_title = task_title.strip()
|
|
||||||
if task_title:
|
|
||||||
task = Task(
|
|
||||||
title=task_title,
|
|
||||||
state=TaskState.LATER,
|
|
||||||
certainty=TaskCertainty.FUZZY,
|
|
||||||
)
|
|
||||||
db.add(task)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Set initial NOW/NEXT states
|
_seed_now_next(db)
|
||||||
# Set initial NOW/NEXT states after all tasks are created
|
db.commit()
|
||||||
if not get_now_task(db) and not get_next_task(db):
|
|
||||||
later_tasks = (
|
|
||||||
db.query(Task)
|
|
||||||
.filter(Task.state == TaskState.LATER)
|
|
||||||
.order_by(Task.is_mit.desc(), Task.sort_order)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
if later_tasks:
|
|
||||||
# Set the highest priority LATER task to NOW
|
|
||||||
later_tasks[0].state = TaskState.NOW
|
|
||||||
db.add(later_tasks[0])
|
|
||||||
db.flush() # Flush to make the change visible for the next query
|
|
||||||
|
|
||||||
# Set the next highest priority LATER task to NEXT
|
|
||||||
if len(later_tasks) > 1:
|
|
||||||
later_tasks[1].state = TaskState.NEXT
|
|
||||||
db.add(later_tasks[1])
|
|
||||||
db.commit() # Commit changes after initial NOW/NEXT setup
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import sqlite3
|
|||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from contextlib import closing, contextmanager
|
from contextlib import closing, contextmanager
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, HTTPException, Request
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
@@ -219,7 +219,7 @@ async def create_task_form(
|
|||||||
raise HTTPException(status_code=400, detail="Task title cannot be empty")
|
raise HTTPException(status_code=400, detail="Task title cannot be empty")
|
||||||
|
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
priority = priority if priority in VALID_PRIORITIES else "normal"
|
priority = priority if priority in VALID_PRIORITIES else "normal"
|
||||||
|
|
||||||
with _get_db() as db:
|
with _get_db() as db:
|
||||||
@@ -287,7 +287,7 @@ async def modify_task(
|
|||||||
async def _set_status(request: Request, task_id: str, new_status: str):
|
async def _set_status(request: Request, task_id: str, new_status: str):
|
||||||
"""Helper to update status and return refreshed task card."""
|
"""Helper to update status and return refreshed task card."""
|
||||||
completed_at = (
|
completed_at = (
|
||||||
datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None
|
datetime.now(UTC).isoformat() if new_status in ("completed", "vetoed", "failed") else None
|
||||||
)
|
)
|
||||||
with _get_db() as db:
|
with _get_db() as db:
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -316,7 +316,7 @@ async def api_create_task(request: Request):
|
|||||||
raise HTTPException(422, "title is required")
|
raise HTTPException(422, "title is required")
|
||||||
|
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
priority = body.get("priority", "normal")
|
priority = body.get("priority", "normal")
|
||||||
if priority not in VALID_PRIORITIES:
|
if priority not in VALID_PRIORITIES:
|
||||||
priority = "normal"
|
priority = "normal"
|
||||||
@@ -358,7 +358,7 @@ async def api_update_status(task_id: str, request: Request):
|
|||||||
raise HTTPException(422, f"Invalid status. Must be one of: {VALID_STATUSES}")
|
raise HTTPException(422, f"Invalid status. Must be one of: {VALID_STATUSES}")
|
||||||
|
|
||||||
completed_at = (
|
completed_at = (
|
||||||
datetime.utcnow().isoformat() if new_status in ("completed", "vetoed", "failed") else None
|
datetime.now(UTC).isoformat() if new_status in ("completed", "vetoed", "failed") else None
|
||||||
)
|
)
|
||||||
with _get_db() as db:
|
with _get_db() as db:
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import sqlite3
|
|||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from contextlib import closing, contextmanager
|
from contextlib import closing, contextmanager
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, HTTPException, Request
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
@@ -144,7 +144,7 @@ async def submit_work_order(
|
|||||||
related_files: str = Form(""),
|
related_files: str = Form(""),
|
||||||
):
|
):
|
||||||
wo_id = str(uuid.uuid4())
|
wo_id = str(uuid.uuid4())
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
priority = priority if priority in PRIORITIES else "medium"
|
priority = priority if priority in PRIORITIES else "medium"
|
||||||
category = category if category in CATEGORIES else "suggestion"
|
category = category if category in CATEGORIES else "suggestion"
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ async def active_partial(request: Request):
|
|||||||
|
|
||||||
async def _update_status(request: Request, wo_id: str, new_status: str, **extra):
|
async def _update_status(request: Request, wo_id: str, new_status: str, **extra):
|
||||||
completed_at = (
|
completed_at = (
|
||||||
datetime.utcnow().isoformat() if new_status in ("completed", "rejected") else None
|
datetime.now(UTC).isoformat() if new_status in ("completed", "rejected") else None
|
||||||
)
|
)
|
||||||
with _get_db() as db:
|
with _get_db() as db:
|
||||||
sets = ["status=?", "completed_at=COALESCE(?, completed_at)"]
|
sets = ["status=?", "completed_at=COALESCE(?, completed_at)"]
|
||||||
|
|||||||
@@ -138,6 +138,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Spark Intelligence -->
|
||||||
|
{% from "macros.html" import panel %}
|
||||||
|
<div class="mc-card-spaced">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Spark Intelligence</h2>
|
||||||
|
<div>
|
||||||
|
<span class="badge" id="spark-status-badge">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-3">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value" id="spark-events">-</div>
|
||||||
|
<div class="stat-label">Events</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value" id="spark-memories">-</div>
|
||||||
|
<div class="stat-label">Memories</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value" id="spark-predictions">-</div>
|
||||||
|
<div class="stat-label">Predictions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-2 mc-section-gap">
|
||||||
|
{% call panel("SPARK TIMELINE", id="spark-timeline-panel",
|
||||||
|
hx_get="/spark/timeline",
|
||||||
|
hx_trigger="load, every 10s") %}
|
||||||
|
<div class="spark-timeline-scroll">
|
||||||
|
<p class="chat-history-placeholder">Loading timeline...</p>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% call panel("SPARK INSIGHTS", id="spark-insights-panel",
|
||||||
|
hx_get="/spark/insights",
|
||||||
|
hx_trigger="load, every 30s") %}
|
||||||
|
<p class="chat-history-placeholder">Loading insights...</p>
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Chat History -->
|
<!-- Chat History -->
|
||||||
<div class="card mc-card-spaced">
|
<div class="card mc-card-spaced">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -428,7 +469,34 @@ async function loadGrokStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load Spark status
|
||||||
|
async function loadSparkStatus() {
|
||||||
|
try {
|
||||||
|
var response = await fetch('/spark');
|
||||||
|
var data = await response.json();
|
||||||
|
var st = data.status || {};
|
||||||
|
|
||||||
|
document.getElementById('spark-events').textContent = st.total_events || 0;
|
||||||
|
document.getElementById('spark-memories').textContent = st.total_memories || 0;
|
||||||
|
document.getElementById('spark-predictions').textContent = st.total_predictions || 0;
|
||||||
|
|
||||||
|
var badge = document.getElementById('spark-status-badge');
|
||||||
|
if (st.total_events > 0) {
|
||||||
|
badge.textContent = 'Active';
|
||||||
|
badge.className = 'badge badge-success';
|
||||||
|
} else {
|
||||||
|
badge.textContent = 'Idle';
|
||||||
|
badge.className = 'badge badge-warning';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
var badge = document.getElementById('spark-status-badge');
|
||||||
|
badge.textContent = 'Offline';
|
||||||
|
badge.className = 'badge badge-danger';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
|
loadSparkStatus();
|
||||||
loadSovereignty();
|
loadSovereignty();
|
||||||
loadHealth();
|
loadHealth();
|
||||||
loadSwarmStats();
|
loadSwarmStats();
|
||||||
@@ -442,5 +510,6 @@ setInterval(loadHealth, 10000);
|
|||||||
setInterval(loadSwarmStats, 5000);
|
setInterval(loadSwarmStats, 5000);
|
||||||
setInterval(updateHeartbeat, 5000);
|
setInterval(updateHeartbeat, 5000);
|
||||||
setInterval(loadGrokStats, 10000);
|
setInterval(loadGrokStats, 10000);
|
||||||
|
setInterval(loadSparkStatus, 15000);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -564,6 +564,7 @@ class CascadeRouter:
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
model=model or provider.get_default_model(),
|
model=model or provider.get_default_model(),
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
)
|
)
|
||||||
elif provider.type == "openai":
|
elif provider.type == "openai":
|
||||||
@@ -604,6 +605,7 @@ class CascadeRouter:
|
|||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
model: str,
|
model: str,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
|
max_tokens: int | None = None,
|
||||||
content_type: ContentType = ContentType.TEXT,
|
content_type: ContentType = ContentType.TEXT,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Call Ollama API with multi-modal support."""
|
"""Call Ollama API with multi-modal support."""
|
||||||
@@ -614,13 +616,15 @@ class CascadeRouter:
|
|||||||
# Transform messages for Ollama format (including images)
|
# Transform messages for Ollama format (including images)
|
||||||
transformed_messages = self._transform_messages_for_ollama(messages)
|
transformed_messages = self._transform_messages_for_ollama(messages)
|
||||||
|
|
||||||
|
options = {"temperature": temperature}
|
||||||
|
if max_tokens:
|
||||||
|
options["num_predict"] = max_tokens
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": transformed_messages,
|
"messages": transformed_messages,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"options": {
|
"options": options,
|
||||||
"temperature": temperature,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout = aiohttp.ClientTimeout(total=self.config.timeout_seconds)
|
timeout = aiohttp.ClientTimeout(total=self.config.timeout_seconds)
|
||||||
|
|||||||
@@ -174,15 +174,8 @@ class ConversationManager:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def should_use_tools(self, message: str, context: ConversationContext) -> bool:
|
_TOOL_KEYWORDS = frozenset(
|
||||||
"""Determine if this message likely requires tools.
|
{
|
||||||
|
|
||||||
Returns True if tools are likely needed, False for simple chat.
|
|
||||||
"""
|
|
||||||
message_lower = message.lower().strip()
|
|
||||||
|
|
||||||
# Tool keywords that suggest tool usage is needed
|
|
||||||
tool_keywords = [
|
|
||||||
"search",
|
"search",
|
||||||
"look up",
|
"look up",
|
||||||
"find",
|
"find",
|
||||||
@@ -203,10 +196,11 @@ class ConversationManager:
|
|||||||
"shell",
|
"shell",
|
||||||
"command",
|
"command",
|
||||||
"install",
|
"install",
|
||||||
]
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Chat-only keywords that definitely don't need tools
|
_CHAT_ONLY_KEYWORDS = frozenset(
|
||||||
chat_only = [
|
{
|
||||||
"hello",
|
"hello",
|
||||||
"hi ",
|
"hi ",
|
||||||
"hey",
|
"hey",
|
||||||
@@ -221,30 +215,47 @@ class ConversationManager:
|
|||||||
"goodbye",
|
"goodbye",
|
||||||
"tell me about yourself",
|
"tell me about yourself",
|
||||||
"what can you do",
|
"what can you do",
|
||||||
]
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Check for chat-only patterns first
|
_SIMPLE_QUESTION_PREFIXES = ("what is", "who is", "how does", "why is", "when did", "where is")
|
||||||
for pattern in chat_only:
|
_TIME_WORDS = ("today", "now", "current", "latest", "this week", "this month")
|
||||||
if pattern in message_lower:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check for tool keywords
|
def _is_chat_only(self, message_lower: str) -> bool:
|
||||||
for keyword in tool_keywords:
|
"""Return True if the message matches a chat-only pattern."""
|
||||||
if keyword in message_lower:
|
return any(kw in message_lower for kw in self._CHAT_ONLY_KEYWORDS)
|
||||||
return True
|
|
||||||
|
|
||||||
# Simple questions (starting with what, who, how, why, when, where)
|
def _has_tool_keyword(self, message_lower: str) -> bool:
|
||||||
# usually don't need tools unless about current/real-time info
|
"""Return True if the message contains a tool-related keyword."""
|
||||||
simple_question_words = ["what is", "who is", "how does", "why is", "when did", "where is"]
|
return any(kw in message_lower for kw in self._TOOL_KEYWORDS)
|
||||||
for word in simple_question_words:
|
|
||||||
if message_lower.startswith(word):
|
def _is_simple_question(self, message_lower: str) -> bool | None:
|
||||||
# Check if it's asking about current/real-time info
|
"""Check if message is a simple question.
|
||||||
time_words = ["today", "now", "current", "latest", "this week", "this month"]
|
|
||||||
if any(t in message_lower for t in time_words):
|
Returns True if it needs tools (real-time info), False if it
|
||||||
return True
|
doesn't, or None if the message isn't a simple question.
|
||||||
return False
|
"""
|
||||||
|
for prefix in self._SIMPLE_QUESTION_PREFIXES:
|
||||||
|
if message_lower.startswith(prefix):
|
||||||
|
return any(t in message_lower for t in self._TIME_WORDS)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def should_use_tools(self, message: str, context: ConversationContext) -> bool:
|
||||||
|
"""Determine if this message likely requires tools.
|
||||||
|
|
||||||
|
Returns True if tools are likely needed, False for simple chat.
|
||||||
|
"""
|
||||||
|
message_lower = message.lower().strip()
|
||||||
|
|
||||||
|
if self._is_chat_only(message_lower):
|
||||||
|
return False
|
||||||
|
if self._has_tool_keyword(message_lower):
|
||||||
|
return True
|
||||||
|
|
||||||
|
simple = self._is_simple_question(message_lower)
|
||||||
|
if simple is not None:
|
||||||
|
return simple
|
||||||
|
|
||||||
# Default: don't use tools for unclear cases
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import shutil
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -196,7 +196,7 @@ def _bridge_to_work_order(title: str, body: str, category: str) -> None:
|
|||||||
body,
|
body,
|
||||||
category,
|
category,
|
||||||
"timmy-thinking",
|
"timmy-thinking",
|
||||||
datetime.utcnow().isoformat(),
|
datetime.now(UTC).isoformat(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -392,31 +392,26 @@ def _build_insights(
|
|||||||
return insights or ["Conversations look healthy. Keep up the good work."]
|
return insights or ["Conversations look healthy. Keep up the good work."]
|
||||||
|
|
||||||
|
|
||||||
def self_reflect(limit: int = 30) -> str:
|
def _format_recurring_topics(repeated: list[tuple[str, int]]) -> list[str]:
|
||||||
"""Review recent conversations and reflect on Timmy's own behavior.
|
"""Format the recurring-topics section of the reflection report."""
|
||||||
|
if repeated:
|
||||||
|
lines = ["### Recurring Topics"]
|
||||||
|
for word, count in repeated:
|
||||||
|
lines.append(f'- "{word}" ({count} mentions)')
|
||||||
|
lines.append("")
|
||||||
|
return lines
|
||||||
|
return ["### Recurring Topics\nNo strong patterns detected.\n"]
|
||||||
|
|
||||||
Scans past session entries for patterns: low-confidence responses,
|
|
||||||
errors, repeated topics, and conversation quality signals. Returns
|
|
||||||
a structured reflection that Timmy can use to improve.
|
|
||||||
|
|
||||||
Args:
|
def _build_reflection_report(
|
||||||
limit: How many recent entries to review (default 30).
|
entries: list[dict],
|
||||||
|
errors: list[dict],
|
||||||
Returns:
|
timmy_msgs: list[dict],
|
||||||
A formatted self-reflection report.
|
user_msgs: list[dict],
|
||||||
"""
|
low_conf: list[dict],
|
||||||
sl = get_session_logger()
|
repeated: list[tuple[str, int]],
|
||||||
sl.flush()
|
) -> str:
|
||||||
entries = sl.get_recent_entries(limit=limit)
|
"""Assemble the full self-reflection report from analysed data."""
|
||||||
|
|
||||||
if not entries:
|
|
||||||
return "No conversation history to reflect on yet."
|
|
||||||
|
|
||||||
_messages, errors, timmy_msgs, user_msgs = _categorize_entries(entries)
|
|
||||||
low_conf = _find_low_confidence(timmy_msgs)
|
|
||||||
repeated = _find_repeated_topics(user_msgs)
|
|
||||||
|
|
||||||
# Build reflection report
|
|
||||||
sections: list[str] = ["## Self-Reflection Report\n"]
|
sections: list[str] = ["## Self-Reflection Report\n"]
|
||||||
sections.append(
|
sections.append(
|
||||||
f"Reviewed {len(entries)} recent entries: "
|
f"Reviewed {len(entries)} recent entries: "
|
||||||
@@ -446,16 +441,37 @@ def self_reflect(limit: int = 30) -> str:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if repeated:
|
sections.extend(_format_recurring_topics(repeated))
|
||||||
sections.append("### Recurring Topics")
|
|
||||||
for word, count in repeated:
|
|
||||||
sections.append(f'- "{word}" ({count} mentions)')
|
|
||||||
sections.append("")
|
|
||||||
else:
|
|
||||||
sections.append("### Recurring Topics\nNo strong patterns detected.\n")
|
|
||||||
|
|
||||||
sections.append("### Insights")
|
sections.append("### Insights")
|
||||||
for insight in _build_insights(low_conf, errors, repeated):
|
for insight in _build_insights(low_conf, errors, repeated):
|
||||||
sections.append(f"- {insight}")
|
sections.append(f"- {insight}")
|
||||||
|
|
||||||
return "\n".join(sections)
|
return "\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
def self_reflect(limit: int = 30) -> str:
|
||||||
|
"""Review recent conversations and reflect on Timmy's own behavior.
|
||||||
|
|
||||||
|
Scans past session entries for patterns: low-confidence responses,
|
||||||
|
errors, repeated topics, and conversation quality signals. Returns
|
||||||
|
a structured reflection that Timmy can use to improve.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: How many recent entries to review (default 30).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A formatted self-reflection report.
|
||||||
|
"""
|
||||||
|
sl = get_session_logger()
|
||||||
|
sl.flush()
|
||||||
|
entries = sl.get_recent_entries(limit=limit)
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
return "No conversation history to reflect on yet."
|
||||||
|
|
||||||
|
_messages, errors, timmy_msgs, user_msgs = _categorize_entries(entries)
|
||||||
|
low_conf = _find_low_confidence(timmy_msgs)
|
||||||
|
repeated = _find_repeated_topics(user_msgs)
|
||||||
|
|
||||||
|
return _build_reflection_report(entries, errors, timmy_msgs, user_msgs, low_conf, repeated)
|
||||||
|
|||||||
@@ -89,45 +89,31 @@ def list_swarm_agents() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def delegate_to_kimi(task: str, working_directory: str = "") -> dict[str, Any]:
|
def _find_kimi_cli() -> str | None:
|
||||||
"""Delegate a coding task to Kimi, the external coding agent.
|
"""Return the path to the kimi CLI binary, or None if not installed."""
|
||||||
|
|
||||||
Kimi has 262K context and is optimized for code tasks: writing,
|
|
||||||
debugging, refactoring, test writing. Timmy thinks and plans,
|
|
||||||
Kimi executes bulk code changes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task: Clear, specific coding task description. Include file paths
|
|
||||||
and expected behavior. Good: "Fix the bug in src/timmy/session.py
|
|
||||||
where sessions don't persist." Bad: "Fix all bugs."
|
|
||||||
working_directory: Directory for Kimi to work in. Defaults to repo root.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with success status and Kimi's output or error.
|
|
||||||
"""
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
|
return shutil.which("kimi")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_workdir(working_directory: str) -> str | dict[str, Any]:
|
||||||
|
"""Return a validated working directory path, or an error dict."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
kimi_path = shutil.which("kimi")
|
|
||||||
if not kimi_path:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": "kimi CLI not found on PATH. Install with: pip install kimi-cli",
|
|
||||||
}
|
|
||||||
|
|
||||||
workdir = working_directory or settings.repo_root
|
workdir = working_directory or settings.repo_root
|
||||||
if not Path(workdir).is_dir():
|
if not Path(workdir).is_dir():
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": f"Working directory does not exist: {workdir}",
|
"error": f"Working directory does not exist: {workdir}",
|
||||||
}
|
}
|
||||||
|
return workdir
|
||||||
|
|
||||||
cmd = [kimi_path, "--print", "-p", task]
|
|
||||||
|
|
||||||
logger.info("Delegating to Kimi: %s (cwd=%s)", task[:80], workdir)
|
def _run_kimi(cmd: list[str], workdir: str) -> dict[str, Any]:
|
||||||
|
"""Execute the kimi subprocess and return a result dict."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -157,3 +143,34 @@ def delegate_to_kimi(task: str, working_directory: str = "") -> dict[str, Any]:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"error": f"Failed to run Kimi: {exc}",
|
"error": f"Failed to run Kimi: {exc}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def delegate_to_kimi(task: str, working_directory: str = "") -> dict[str, Any]:
|
||||||
|
"""Delegate a coding task to Kimi, the external coding agent.
|
||||||
|
|
||||||
|
Kimi has 262K context and is optimized for code tasks: writing,
|
||||||
|
debugging, refactoring, test writing. Timmy thinks and plans,
|
||||||
|
Kimi executes bulk code changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Clear, specific coding task description. Include file paths
|
||||||
|
and expected behavior. Good: "Fix the bug in src/timmy/session.py
|
||||||
|
where sessions don't persist." Bad: "Fix all bugs."
|
||||||
|
working_directory: Directory for Kimi to work in. Defaults to repo root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status and Kimi's output or error.
|
||||||
|
"""
|
||||||
|
kimi_path = _find_kimi_cli()
|
||||||
|
if not kimi_path:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "kimi CLI not found on PATH. Install with: pip install kimi-cli",
|
||||||
|
}
|
||||||
|
|
||||||
|
workdir = _resolve_workdir(working_directory)
|
||||||
|
if isinstance(workdir, dict):
|
||||||
|
return workdir
|
||||||
|
|
||||||
|
logger.info("Delegating to Kimi: %s (cwd=%s)", task[:80], workdir)
|
||||||
|
return _run_kimi([kimi_path, "--print", "-p", task], workdir)
|
||||||
|
|||||||
@@ -86,6 +86,40 @@ def _pip_snapshot(mood: str, confidence: float) -> dict:
|
|||||||
return pip_familiar.snapshot().to_dict()
|
return pip_familiar.snapshot().to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_mood(state) -> str:
|
||||||
|
"""Map cognitive mood/engagement to a presence mood string."""
|
||||||
|
if state.engagement == "idle" and state.mood == "settled":
|
||||||
|
return "calm"
|
||||||
|
return _MOOD_MAP.get(state.mood, "calm")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_confidence(state) -> float:
|
||||||
|
"""Compute normalised confidence from cognitive tracker state."""
|
||||||
|
if state._confidence_count > 0:
|
||||||
|
raw = state._confidence_sum / state._confidence_count
|
||||||
|
else:
|
||||||
|
raw = 0.7
|
||||||
|
return round(max(0.0, min(1.0, raw)), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_active_threads(state) -> list[dict]:
|
||||||
|
"""Convert active commitments into presence thread dicts."""
|
||||||
|
return [
|
||||||
|
{"type": "thinking", "ref": c[:80], "status": "active"}
|
||||||
|
for c in state.active_commitments[:10]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_environment() -> dict:
|
||||||
|
"""Return the environment section using local wall-clock time."""
|
||||||
|
local_now = datetime.now()
|
||||||
|
return {
|
||||||
|
"time_of_day": _time_of_day(local_now.hour),
|
||||||
|
"local_time": local_now.strftime("%-I:%M %p"),
|
||||||
|
"day_of_week": local_now.strftime("%A"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_state_dict() -> dict:
|
def get_state_dict() -> dict:
|
||||||
"""Build presence state dict from current cognitive state.
|
"""Build presence state dict from current cognitive state.
|
||||||
|
|
||||||
@@ -98,37 +132,19 @@ def get_state_dict() -> dict:
|
|||||||
state = cognitive_tracker.get_state()
|
state = cognitive_tracker.get_state()
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
# Map cognitive mood to presence mood
|
mood = _resolve_mood(state)
|
||||||
mood = _MOOD_MAP.get(state.mood, "calm")
|
confidence = _resolve_confidence(state)
|
||||||
if state.engagement == "idle" and state.mood == "settled":
|
|
||||||
mood = "calm"
|
|
||||||
|
|
||||||
# Confidence from cognitive tracker
|
|
||||||
if state._confidence_count > 0:
|
|
||||||
confidence = state._confidence_sum / state._confidence_count
|
|
||||||
else:
|
|
||||||
confidence = 0.7
|
|
||||||
|
|
||||||
# Build active threads from commitments
|
|
||||||
threads = []
|
|
||||||
for commitment in state.active_commitments[:10]:
|
|
||||||
threads.append({"type": "thinking", "ref": commitment[:80], "status": "active"})
|
|
||||||
|
|
||||||
# Activity
|
|
||||||
activity = _ACTIVITY_MAP.get(state.engagement, "idle")
|
activity = _ACTIVITY_MAP.get(state.engagement, "idle")
|
||||||
|
|
||||||
# Environment
|
|
||||||
local_now = datetime.now()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"liveness": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
"liveness": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
"current_focus": state.focus_topic or "",
|
"current_focus": state.focus_topic or "",
|
||||||
"active_threads": threads,
|
"active_threads": _build_active_threads(state),
|
||||||
"recent_events": [],
|
"recent_events": [],
|
||||||
"concerns": [],
|
"concerns": [],
|
||||||
"mood": mood,
|
"mood": mood,
|
||||||
"confidence": round(max(0.0, min(1.0, confidence)), 2),
|
"confidence": confidence,
|
||||||
"energy": round(_current_energy(), 2),
|
"energy": round(_current_energy(), 2),
|
||||||
"identity": {
|
"identity": {
|
||||||
"name": "Timmy",
|
"name": "Timmy",
|
||||||
@@ -143,11 +159,7 @@ def get_state_dict() -> dict:
|
|||||||
"visitor_present": False,
|
"visitor_present": False,
|
||||||
"conversation_turns": state.conversation_depth,
|
"conversation_turns": state.conversation_depth,
|
||||||
},
|
},
|
||||||
"environment": {
|
"environment": _build_environment(),
|
||||||
"time_of_day": _time_of_day(local_now.hour),
|
|
||||||
"local_time": local_now.strftime("%-I:%M %p"),
|
|
||||||
"day_of_week": local_now.strftime("%A"),
|
|
||||||
},
|
|
||||||
"familiar": _pip_snapshot(mood, confidence),
|
"familiar": _pip_snapshot(mood, confidence),
|
||||||
"meta": {
|
"meta": {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
|
|||||||
187
tests/dashboard/test_tower.py
Normal file
187
tests/dashboard/test_tower.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""Tests for Tower dashboard route (/tower)."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_spark_engine():
|
||||||
|
"""Return a mock spark_engine with realistic return values."""
|
||||||
|
engine = MagicMock()
|
||||||
|
|
||||||
|
engine.status.return_value = {
|
||||||
|
"enabled": True,
|
||||||
|
"events_captured": 5,
|
||||||
|
"memories_stored": 3,
|
||||||
|
"predictions": {"total": 2, "avg_accuracy": 0.85},
|
||||||
|
"event_types": {
|
||||||
|
"task_posted": 2,
|
||||||
|
"bid_submitted": 1,
|
||||||
|
"task_assigned": 1,
|
||||||
|
"task_completed": 1,
|
||||||
|
"task_failed": 0,
|
||||||
|
"agent_joined": 0,
|
||||||
|
"tool_executed": 0,
|
||||||
|
"creative_step": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
event = MagicMock()
|
||||||
|
event.event_type = "task_completed"
|
||||||
|
event.description = "Task finished"
|
||||||
|
event.importance = 0.8
|
||||||
|
event.created_at = "2026-01-01T00:00:00"
|
||||||
|
event.agent_id = "agent-1234-abcd"
|
||||||
|
event.task_id = "task-5678-efgh"
|
||||||
|
event.data = '{"result": "ok"}'
|
||||||
|
engine.get_timeline.return_value = [event]
|
||||||
|
|
||||||
|
pred = MagicMock()
|
||||||
|
pred.task_id = "task-5678-efgh"
|
||||||
|
pred.accuracy = 0.9
|
||||||
|
pred.evaluated_at = "2026-01-01T01:00:00"
|
||||||
|
pred.created_at = "2026-01-01T00:30:00"
|
||||||
|
pred.predicted_value = '{"outcome": "success"}'
|
||||||
|
engine.get_predictions.return_value = [pred]
|
||||||
|
|
||||||
|
advisory = MagicMock()
|
||||||
|
advisory.category = "performance"
|
||||||
|
advisory.priority = "high"
|
||||||
|
advisory.title = "Slow tasks"
|
||||||
|
advisory.detail = "Tasks taking longer than expected"
|
||||||
|
advisory.suggested_action = "Scale up workers"
|
||||||
|
engine.get_advisories.return_value = [advisory]
|
||||||
|
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
class TestTowerUI:
|
||||||
|
"""Tests for GET /tower endpoint."""
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
def test_tower_returns_200(self, mock_engine, client):
|
||||||
|
response = client.get("/tower")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
def test_tower_returns_html(self, mock_engine, client):
|
||||||
|
response = client.get("/tower")
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
def test_tower_contains_dashboard_content(self, mock_engine, client):
|
||||||
|
response = client.get("/tower")
|
||||||
|
body = response.text
|
||||||
|
assert "tower" in body.lower() or "spark" in body.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSparkSnapshot:
|
||||||
|
"""Tests for _spark_snapshot helper."""
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
def test_snapshot_structure(self, mock_engine):
|
||||||
|
from dashboard.routes.tower import _spark_snapshot
|
||||||
|
|
||||||
|
snap = _spark_snapshot()
|
||||||
|
assert snap["type"] == "spark_state"
|
||||||
|
assert "status" in snap
|
||||||
|
assert "events" in snap
|
||||||
|
assert "predictions" in snap
|
||||||
|
assert "advisories" in snap
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
def test_snapshot_events_parsed(self, mock_engine):
|
||||||
|
from dashboard.routes.tower import _spark_snapshot
|
||||||
|
|
||||||
|
snap = _spark_snapshot()
|
||||||
|
ev = snap["events"][0]
|
||||||
|
assert ev["event_type"] == "task_completed"
|
||||||
|
assert ev["importance"] == 0.8
|
||||||
|
assert ev["agent_id"] == "agent-12"
|
||||||
|
assert ev["task_id"] == "task-567"
|
||||||
|
assert ev["data"] == {"result": "ok"}
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
def test_snapshot_predictions_parsed(self, mock_engine):
|
||||||
|
from dashboard.routes.tower import _spark_snapshot
|
||||||
|
|
||||||
|
snap = _spark_snapshot()
|
||||||
|
pred = snap["predictions"][0]
|
||||||
|
assert pred["task_id"] == "task-567"
|
||||||
|
assert pred["accuracy"] == 0.9
|
||||||
|
assert pred["evaluated"] is True
|
||||||
|
assert pred["predicted"] == {"outcome": "success"}
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
def test_snapshot_advisories_parsed(self, mock_engine):
|
||||||
|
from dashboard.routes.tower import _spark_snapshot
|
||||||
|
|
||||||
|
snap = _spark_snapshot()
|
||||||
|
adv = snap["advisories"][0]
|
||||||
|
assert adv["category"] == "performance"
|
||||||
|
assert adv["priority"] == "high"
|
||||||
|
assert adv["title"] == "Slow tasks"
|
||||||
|
assert adv["suggested_action"] == "Scale up workers"
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine")
|
||||||
|
def test_snapshot_handles_empty_state(self, mock_engine):
|
||||||
|
mock_engine.status.return_value = {"enabled": False}
|
||||||
|
mock_engine.get_timeline.return_value = []
|
||||||
|
mock_engine.get_predictions.return_value = []
|
||||||
|
mock_engine.get_advisories.return_value = []
|
||||||
|
|
||||||
|
from dashboard.routes.tower import _spark_snapshot
|
||||||
|
|
||||||
|
snap = _spark_snapshot()
|
||||||
|
assert snap["events"] == []
|
||||||
|
assert snap["predictions"] == []
|
||||||
|
assert snap["advisories"] == []
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine")
|
||||||
|
def test_snapshot_handles_invalid_json_data(self, mock_engine):
|
||||||
|
mock_engine.status.return_value = {"enabled": True}
|
||||||
|
|
||||||
|
event = MagicMock()
|
||||||
|
event.event_type = "test"
|
||||||
|
event.description = "bad data"
|
||||||
|
event.importance = 0.5
|
||||||
|
event.created_at = "2026-01-01T00:00:00"
|
||||||
|
event.agent_id = None
|
||||||
|
event.task_id = None
|
||||||
|
event.data = "not-json{"
|
||||||
|
mock_engine.get_timeline.return_value = [event]
|
||||||
|
|
||||||
|
pred = MagicMock()
|
||||||
|
pred.task_id = None
|
||||||
|
pred.accuracy = None
|
||||||
|
pred.evaluated_at = None
|
||||||
|
pred.created_at = "2026-01-01T00:00:00"
|
||||||
|
pred.predicted_value = None
|
||||||
|
mock_engine.get_predictions.return_value = [pred]
|
||||||
|
|
||||||
|
mock_engine.get_advisories.return_value = []
|
||||||
|
|
||||||
|
from dashboard.routes.tower import _spark_snapshot
|
||||||
|
|
||||||
|
snap = _spark_snapshot()
|
||||||
|
ev = snap["events"][0]
|
||||||
|
assert ev["data"] == {}
|
||||||
|
assert "agent_id" not in ev
|
||||||
|
assert "task_id" not in ev
|
||||||
|
|
||||||
|
pred = snap["predictions"][0]
|
||||||
|
assert pred["task_id"] == "?"
|
||||||
|
assert pred["predicted"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestTowerWebSocket:
|
||||||
|
"""Tests for WS /tower/ws endpoint."""
|
||||||
|
|
||||||
|
@patch("dashboard.routes.tower.spark_engine", new_callable=_mock_spark_engine)
|
||||||
|
@patch("dashboard.routes.tower._PUSH_INTERVAL", 0)
|
||||||
|
def test_ws_sends_initial_snapshot(self, mock_engine, client):
|
||||||
|
import json
|
||||||
|
|
||||||
|
with client.websocket_connect("/tower/ws") as ws:
|
||||||
|
data = json.loads(ws.receive_text())
|
||||||
|
assert data["type"] == "spark_state"
|
||||||
|
assert "status" in data
|
||||||
|
assert "events" in data
|
||||||
Reference in New Issue
Block a user