0a497ca0f1
Run Tests / Secret Detection (pull_request) Failing after 42s
Run Tests / Backend Tests (pull_request) Successful in 32m26s
Run Tests / Frontend Tests (pull_request) Failing after 26s
Deploy Dashboard (Dev) / Backend Tests (push) Successful in 30m35s
Deploy Dashboard (Dev) / Frontend Tests (push) Failing after 16m37s
Deploy Dashboard (Dev) / Deploy to Dev (push) Has been skipped
- Add agent fetchrow to OPC mock to fix "Task or agent not found" errors
- Reorder conversation tracker routes: /search, /stats, /trigger before /{session_id}
- Fix test_task_creator_tracked_in_metadata to expect token username (testuser)
- Mock shutil/psutil in system stats test for non-Synology environments
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
287 lines
8.5 KiB
Python
287 lines
8.5 KiB
Python
import os
|
|
import aiosqlite
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
from typing import Optional
|
|
|
|
router = APIRouter(tags=["conversations"])
|
|
|
|
CONVERSATION_TRACKER_DB = os.environ.get("CONVERSATION_TRACKER_DB", "/app/data/claude-code-tracker/conversations.db")
|
|
|
|
|
|
async def _db():
|
|
return aiosqlite.connect(CONVERSATION_TRACKER_DB)
|
|
|
|
|
|
@router.get("/dates")
|
|
async def list_dates():
|
|
"""List all dates with conversations"""
|
|
async with await _db() as db:
|
|
cursor = await db.execute("""
|
|
SELECT DISTINCT DATE(started_at) as date
|
|
FROM conversations
|
|
ORDER BY date DESC
|
|
""")
|
|
return [r[0] for r in await cursor.fetchall()]
|
|
|
|
|
|
@router.get("/summary/{date}")
|
|
async def get_summary(date: str):
|
|
"""Get daily summary for a date"""
|
|
async with await _db() as db:
|
|
cursor = await db.execute("""
|
|
SELECT summary_content, conversation_count, total_messages,
|
|
total_tokens, created_at, project_path
|
|
FROM daily_summaries
|
|
WHERE date = ?
|
|
""", (date,))
|
|
row = await cursor.fetchone()
|
|
|
|
if not row:
|
|
raise HTTPException(404, "No summary for this date")
|
|
|
|
return {
|
|
"date": date,
|
|
"content": row[0],
|
|
"conversation_count": row[1],
|
|
"total_messages": row[2],
|
|
"total_tokens": row[3],
|
|
"created_at": row[4],
|
|
"projects": row[5]
|
|
}
|
|
|
|
|
|
@router.get("/list")
|
|
async def list_conversations(
|
|
date: Optional[str] = None,
|
|
project_path: Optional[str] = None,
|
|
limit: int = Query(50, le=200),
|
|
offset: int = 0
|
|
):
|
|
"""List conversations with filters"""
|
|
async with await _db() as db:
|
|
query = "SELECT session_id, project_path, slug, started_at, message_count, total_input_tokens, total_output_tokens, model FROM conversations WHERE 1=1"
|
|
params = []
|
|
|
|
if date:
|
|
query += " AND DATE(started_at) = ?"
|
|
params.append(date)
|
|
|
|
if project_path:
|
|
query += " AND project_path LIKE ?"
|
|
params.append(f"%{project_path}%")
|
|
|
|
query += " ORDER BY started_at DESC LIMIT ? OFFSET ?"
|
|
params.extend([limit, offset])
|
|
|
|
cursor = await db.execute(query, params)
|
|
rows = await cursor.fetchall()
|
|
|
|
return [{
|
|
"session_id": r[0],
|
|
"project_path": r[1],
|
|
"slug": r[2],
|
|
"started_at": r[3],
|
|
"message_count": r[4],
|
|
"total_tokens": r[5] + r[6],
|
|
"model": r[7]
|
|
} for r in rows]
|
|
|
|
|
|
@router.get("/search")
|
|
async def search_conversations(
|
|
q: str = Query(..., min_length=2),
|
|
date_from: Optional[str] = None,
|
|
date_to: Optional[str] = None,
|
|
limit: int = Query(50, le=200)
|
|
):
|
|
"""Search conversations by text"""
|
|
async with await _db() as db:
|
|
query = """
|
|
SELECT DISTINCT m.session_id, c.project_path, c.slug, c.started_at,
|
|
c.message_count, c.total_input_tokens, c.total_output_tokens
|
|
FROM messages m
|
|
JOIN conversations c ON m.session_id = c.session_id
|
|
WHERE m.content_text LIKE ?
|
|
"""
|
|
params = [f"%{q}%"]
|
|
|
|
if date_from:
|
|
query += " AND DATE(c.started_at) >= ?"
|
|
params.append(date_from)
|
|
|
|
if date_to:
|
|
query += " AND DATE(c.started_at) <= ?"
|
|
params.append(date_to)
|
|
|
|
query += " ORDER BY c.started_at DESC LIMIT ?"
|
|
params.append(limit)
|
|
|
|
cursor = await db.execute(query, params)
|
|
rows = await cursor.fetchall()
|
|
|
|
return [{
|
|
"session_id": r[0],
|
|
"project_path": r[1],
|
|
"slug": r[2],
|
|
"started_at": r[3],
|
|
"message_count": r[4],
|
|
"total_tokens": r[5] + r[6]
|
|
} for r in rows]
|
|
|
|
|
|
@router.get("/stats")
|
|
async def get_stats(
|
|
date_from: Optional[str] = None,
|
|
date_to: Optional[str] = None
|
|
):
|
|
"""Get overall statistics"""
|
|
async with await _db() as db:
|
|
query = "SELECT COUNT(*), SUM(message_count), SUM(total_input_tokens + total_output_tokens) FROM conversations WHERE 1=1"
|
|
params = []
|
|
|
|
if date_from:
|
|
query += " AND DATE(started_at) >= ?"
|
|
params.append(date_from)
|
|
|
|
if date_to:
|
|
query += " AND DATE(started_at) <= ?"
|
|
params.append(date_to)
|
|
|
|
cursor = await db.execute(query, params)
|
|
stats = await cursor.fetchone()
|
|
|
|
# Get top projects
|
|
cursor = await db.execute("""
|
|
SELECT project_path, COUNT(*) as count
|
|
FROM conversations
|
|
GROUP BY project_path
|
|
ORDER BY count DESC
|
|
LIMIT 10
|
|
""")
|
|
projects = await cursor.fetchall()
|
|
|
|
# Get tool usage
|
|
cursor = await db.execute("""
|
|
SELECT tool_name, COUNT(*) as count
|
|
FROM tool_calls
|
|
GROUP BY tool_name
|
|
ORDER BY count DESC
|
|
LIMIT 10
|
|
""")
|
|
tools = await cursor.fetchall()
|
|
|
|
return {
|
|
"total_conversations": stats[0] or 0,
|
|
"total_messages": stats[1] or 0,
|
|
"total_tokens": stats[2] or 0,
|
|
"top_projects": [{"path": p[0], "count": p[1]} for p in projects],
|
|
"top_tools": [{"name": t[0], "count": t[1]} for t in tools]
|
|
}
|
|
|
|
|
|
@router.post("/trigger")
|
|
async def trigger_summary():
|
|
"""Trigger manual summary generation"""
|
|
trigger_path = os.environ.get("TRIGGER_PATH", "/app/data/claude-code-tracker/trigger")
|
|
|
|
# Validate path
|
|
trigger_path = os.path.abspath(trigger_path)
|
|
forbidden_prefixes = ("/etc/", "/root/", "/sys/", "/proc/", "/boot/")
|
|
if any(trigger_path.startswith(prefix) for prefix in forbidden_prefixes):
|
|
raise HTTPException(status_code=400, detail="Invalid trigger path")
|
|
|
|
os.makedirs(os.path.dirname(trigger_path), exist_ok=True)
|
|
with open(trigger_path, "w") as f:
|
|
f.write("1")
|
|
|
|
return {"status": "triggered"}
|
|
|
|
|
|
@router.get("/{session_id}")
|
|
async def get_conversation(session_id: str):
|
|
"""Get conversation details"""
|
|
async with await _db() as db:
|
|
# Get conversation metadata
|
|
cursor = await db.execute("""
|
|
SELECT project_path, git_branch, slug, started_at, last_updated_at,
|
|
message_count, total_input_tokens, total_output_tokens, model
|
|
FROM conversations
|
|
WHERE session_id = ?
|
|
""", (session_id,))
|
|
conv = await cursor.fetchone()
|
|
|
|
if not conv:
|
|
raise HTTPException(404, "Conversation not found")
|
|
|
|
# Get message count
|
|
cursor = await db.execute("""
|
|
SELECT COUNT(*) FROM messages WHERE session_id = ?
|
|
""", (session_id,))
|
|
msg_count = (await cursor.fetchone())[0]
|
|
|
|
return {
|
|
"session_id": session_id,
|
|
"project_path": conv[0],
|
|
"git_branch": conv[1],
|
|
"slug": conv[2],
|
|
"started_at": conv[3],
|
|
"last_updated_at": conv[4],
|
|
"message_count": msg_count,
|
|
"total_input_tokens": conv[6],
|
|
"total_output_tokens": conv[7],
|
|
"model": conv[8]
|
|
}
|
|
|
|
|
|
@router.get("/{session_id}/messages")
|
|
async def get_messages(
|
|
session_id: str,
|
|
limit: int = Query(100, le=500),
|
|
offset: int = 0
|
|
):
|
|
"""Get messages for a conversation"""
|
|
async with await _db() as db:
|
|
cursor = await db.execute("""
|
|
SELECT message_uuid, role, content_text, timestamp, model,
|
|
input_tokens, output_tokens, has_tool_use, has_thinking
|
|
FROM messages
|
|
WHERE session_id = ?
|
|
ORDER BY timestamp
|
|
LIMIT ? OFFSET ?
|
|
""", (session_id, limit, offset))
|
|
rows = await cursor.fetchall()
|
|
|
|
messages = []
|
|
for r in rows:
|
|
msg = {
|
|
"uuid": r[0],
|
|
"role": r[1],
|
|
"content": r[2],
|
|
"timestamp": r[3],
|
|
"model": r[4],
|
|
"input_tokens": r[5],
|
|
"output_tokens": r[6],
|
|
"has_tool_use": bool(r[7]),
|
|
"has_thinking": bool(r[8]),
|
|
"tool_calls": []
|
|
}
|
|
|
|
# Get tool calls for this message
|
|
if msg["has_tool_use"]:
|
|
cursor2 = await db.execute("""
|
|
SELECT tool_name, tool_input, tool_result, is_error
|
|
FROM tool_calls
|
|
WHERE message_uuid = ?
|
|
""", (r[0],))
|
|
tool_rows = await cursor2.fetchall()
|
|
msg["tool_calls"] = [{
|
|
"name": tr[0],
|
|
"input": tr[1],
|
|
"result": tr[2],
|
|
"is_error": bool(tr[3])
|
|
} for tr in tool_rows]
|
|
|
|
messages.append(msg)
|
|
|
|
return messages
|