Merge pull request #10125 from arihantsethia/feat/dashboard-skill-analytics
feat: add skill analytics to the dashboard
This commit is contained in:
@@ -124,6 +124,7 @@ class InsightsEngine:
|
|||||||
# Gather raw data
|
# Gather raw data
|
||||||
sessions = self._get_sessions(cutoff, source)
|
sessions = self._get_sessions(cutoff, source)
|
||||||
tool_usage = self._get_tool_usage(cutoff, source)
|
tool_usage = self._get_tool_usage(cutoff, source)
|
||||||
|
skill_usage = self._get_skill_usage(cutoff, source)
|
||||||
message_stats = self._get_message_stats(cutoff, source)
|
message_stats = self._get_message_stats(cutoff, source)
|
||||||
|
|
||||||
if not sessions:
|
if not sessions:
|
||||||
@@ -135,6 +136,15 @@ class InsightsEngine:
|
|||||||
"models": [],
|
"models": [],
|
||||||
"platforms": [],
|
"platforms": [],
|
||||||
"tools": [],
|
"tools": [],
|
||||||
|
"skills": {
|
||||||
|
"summary": {
|
||||||
|
"total_skill_loads": 0,
|
||||||
|
"total_skill_edits": 0,
|
||||||
|
"total_skill_actions": 0,
|
||||||
|
"distinct_skills_used": 0,
|
||||||
|
},
|
||||||
|
"top_skills": [],
|
||||||
|
},
|
||||||
"activity": {},
|
"activity": {},
|
||||||
"top_sessions": [],
|
"top_sessions": [],
|
||||||
}
|
}
|
||||||
@@ -144,6 +154,7 @@ class InsightsEngine:
|
|||||||
models = self._compute_model_breakdown(sessions)
|
models = self._compute_model_breakdown(sessions)
|
||||||
platforms = self._compute_platform_breakdown(sessions)
|
platforms = self._compute_platform_breakdown(sessions)
|
||||||
tools = self._compute_tool_breakdown(tool_usage)
|
tools = self._compute_tool_breakdown(tool_usage)
|
||||||
|
skills = self._compute_skill_breakdown(skill_usage)
|
||||||
activity = self._compute_activity_patterns(sessions)
|
activity = self._compute_activity_patterns(sessions)
|
||||||
top_sessions = self._compute_top_sessions(sessions)
|
top_sessions = self._compute_top_sessions(sessions)
|
||||||
|
|
||||||
@@ -156,6 +167,7 @@ class InsightsEngine:
|
|||||||
"models": models,
|
"models": models,
|
||||||
"platforms": platforms,
|
"platforms": platforms,
|
||||||
"tools": tools,
|
"tools": tools,
|
||||||
|
"skills": skills,
|
||||||
"activity": activity,
|
"activity": activity,
|
||||||
"top_sessions": top_sessions,
|
"top_sessions": top_sessions,
|
||||||
}
|
}
|
||||||
@@ -284,6 +296,82 @@ class InsightsEngine:
|
|||||||
for name, count in tool_counts.most_common()
|
for name, count in tool_counts.most_common()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def _get_skill_usage(self, cutoff: float, source: str = None) -> List[Dict]:
|
||||||
|
"""Extract per-skill usage from assistant tool calls."""
|
||||||
|
skill_counts: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
if source:
|
||||||
|
cursor = self._conn.execute(
|
||||||
|
"""SELECT m.tool_calls, m.timestamp
|
||||||
|
FROM messages m
|
||||||
|
JOIN sessions s ON s.id = m.session_id
|
||||||
|
WHERE s.started_at >= ? AND s.source = ?
|
||||||
|
AND m.role = 'assistant' AND m.tool_calls IS NOT NULL""",
|
||||||
|
(cutoff, source),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = self._conn.execute(
|
||||||
|
"""SELECT m.tool_calls, m.timestamp
|
||||||
|
FROM messages m
|
||||||
|
JOIN sessions s ON s.id = m.session_id
|
||||||
|
WHERE s.started_at >= ?
|
||||||
|
AND m.role = 'assistant' AND m.tool_calls IS NOT NULL""",
|
||||||
|
(cutoff,),
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
try:
|
||||||
|
calls = row["tool_calls"]
|
||||||
|
if isinstance(calls, str):
|
||||||
|
calls = json.loads(calls)
|
||||||
|
if not isinstance(calls, list):
|
||||||
|
continue
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
timestamp = row["timestamp"]
|
||||||
|
for call in calls:
|
||||||
|
if not isinstance(call, dict):
|
||||||
|
continue
|
||||||
|
func = call.get("function", {})
|
||||||
|
tool_name = func.get("name")
|
||||||
|
if tool_name not in {"skill_view", "skill_manage"}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
args = func.get("arguments")
|
||||||
|
if isinstance(args, str):
|
||||||
|
try:
|
||||||
|
args = json.loads(args)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
skill_name = args.get("name")
|
||||||
|
if not isinstance(skill_name, str) or not skill_name.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry = skill_counts.setdefault(
|
||||||
|
skill_name,
|
||||||
|
{
|
||||||
|
"skill": skill_name,
|
||||||
|
"view_count": 0,
|
||||||
|
"manage_count": 0,
|
||||||
|
"last_used_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if tool_name == "skill_view":
|
||||||
|
entry["view_count"] += 1
|
||||||
|
else:
|
||||||
|
entry["manage_count"] += 1
|
||||||
|
|
||||||
|
if timestamp is not None and (
|
||||||
|
entry["last_used_at"] is None or timestamp > entry["last_used_at"]
|
||||||
|
):
|
||||||
|
entry["last_used_at"] = timestamp
|
||||||
|
|
||||||
|
return list(skill_counts.values())
|
||||||
|
|
||||||
def _get_message_stats(self, cutoff: float, source: str = None) -> Dict:
|
def _get_message_stats(self, cutoff: float, source: str = None) -> Dict:
|
||||||
"""Get aggregate message statistics."""
|
"""Get aggregate message statistics."""
|
||||||
if source:
|
if source:
|
||||||
@@ -475,6 +563,46 @@ class InsightsEngine:
|
|||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _compute_skill_breakdown(self, skill_usage: List[Dict]) -> Dict[str, Any]:
|
||||||
|
"""Process per-skill usage into summary + ranked list."""
|
||||||
|
total_skill_loads = sum(s["view_count"] for s in skill_usage) if skill_usage else 0
|
||||||
|
total_skill_edits = sum(s["manage_count"] for s in skill_usage) if skill_usage else 0
|
||||||
|
total_skill_actions = total_skill_loads + total_skill_edits
|
||||||
|
|
||||||
|
top_skills = []
|
||||||
|
for skill in skill_usage:
|
||||||
|
total_count = skill["view_count"] + skill["manage_count"]
|
||||||
|
percentage = (total_count / total_skill_actions * 100) if total_skill_actions else 0
|
||||||
|
top_skills.append({
|
||||||
|
"skill": skill["skill"],
|
||||||
|
"view_count": skill["view_count"],
|
||||||
|
"manage_count": skill["manage_count"],
|
||||||
|
"total_count": total_count,
|
||||||
|
"percentage": percentage,
|
||||||
|
"last_used_at": skill.get("last_used_at"),
|
||||||
|
})
|
||||||
|
|
||||||
|
top_skills.sort(
|
||||||
|
key=lambda s: (
|
||||||
|
s["total_count"],
|
||||||
|
s["view_count"],
|
||||||
|
s["manage_count"],
|
||||||
|
s["last_used_at"] or 0,
|
||||||
|
s["skill"],
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": {
|
||||||
|
"total_skill_loads": total_skill_loads,
|
||||||
|
"total_skill_edits": total_skill_edits,
|
||||||
|
"total_skill_actions": total_skill_actions,
|
||||||
|
"distinct_skills_used": len(skill_usage),
|
||||||
|
},
|
||||||
|
"top_skills": top_skills,
|
||||||
|
}
|
||||||
|
|
||||||
def _compute_activity_patterns(self, sessions: List[Dict]) -> Dict:
|
def _compute_activity_patterns(self, sessions: List[Dict]) -> Dict:
|
||||||
"""Analyze activity patterns by day of week and hour."""
|
"""Analyze activity patterns by day of week and hour."""
|
||||||
day_counts = Counter() # 0=Monday ... 6=Sunday
|
day_counts = Counter() # 0=Monday ... 6=Sunday
|
||||||
@@ -670,6 +798,28 @@ class InsightsEngine:
|
|||||||
lines.append(f" ... and {len(report['tools']) - 15} more tools")
|
lines.append(f" ... and {len(report['tools']) - 15} more tools")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
# Skill usage
|
||||||
|
skills = report.get("skills", {})
|
||||||
|
top_skills = skills.get("top_skills", [])
|
||||||
|
if top_skills:
|
||||||
|
lines.append(" 🧠 Top Skills")
|
||||||
|
lines.append(" " + "─" * 56)
|
||||||
|
lines.append(f" {'Skill':<28} {'Loads':>7} {'Edits':>7} {'Last used':>11}")
|
||||||
|
for skill in top_skills[:10]:
|
||||||
|
last_used = "—"
|
||||||
|
if skill.get("last_used_at"):
|
||||||
|
last_used = datetime.fromtimestamp(skill["last_used_at"]).strftime("%b %d")
|
||||||
|
lines.append(
|
||||||
|
f" {skill['skill'][:28]:<28} {skill['view_count']:>7,} {skill['manage_count']:>7,} {last_used:>11}"
|
||||||
|
)
|
||||||
|
summary = skills.get("summary", {})
|
||||||
|
lines.append(
|
||||||
|
f" Distinct skills: {summary.get('distinct_skills_used', 0)} "
|
||||||
|
f"Loads: {summary.get('total_skill_loads', 0):,} "
|
||||||
|
f"Edits: {summary.get('total_skill_edits', 0):,}"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
# Activity patterns
|
# Activity patterns
|
||||||
act = report.get("activity", {})
|
act = report.get("activity", {})
|
||||||
if act.get("by_day"):
|
if act.get("by_day"):
|
||||||
@@ -753,6 +903,18 @@ class InsightsEngine:
|
|||||||
lines.append(f" {t['tool']} — {t['count']:,} calls ({t['percentage']:.1f}%)")
|
lines.append(f" {t['tool']} — {t['count']:,} calls ({t['percentage']:.1f}%)")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
skills = report.get("skills", {})
|
||||||
|
if skills.get("top_skills"):
|
||||||
|
lines.append("**🧠 Top Skills:**")
|
||||||
|
for skill in skills["top_skills"][:5]:
|
||||||
|
suffix = ""
|
||||||
|
if skill.get("last_used_at"):
|
||||||
|
suffix = f", last used {datetime.fromtimestamp(skill['last_used_at']).strftime('%b %d')}"
|
||||||
|
lines.append(
|
||||||
|
f" {skill['skill']} — {skill['view_count']:,} loads, {skill['manage_count']:,} edits{suffix}"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
# Activity summary
|
# Activity summary
|
||||||
act = report.get("activity", {})
|
act = report.get("activity", {})
|
||||||
if act.get("busiest_day") and act.get("busiest_hour"):
|
if act.get("busiest_day") and act.get("busiest_hour"):
|
||||||
|
|||||||
@@ -1958,6 +1958,8 @@ async def update_config_raw(body: RawConfigUpdate):
|
|||||||
@app.get("/api/analytics/usage")
|
@app.get("/api/analytics/usage")
|
||||||
async def get_usage_analytics(days: int = 30):
|
async def get_usage_analytics(days: int = 30):
|
||||||
from hermes_state import SessionDB
|
from hermes_state import SessionDB
|
||||||
|
from agent.insights import InsightsEngine
|
||||||
|
|
||||||
db = SessionDB()
|
db = SessionDB()
|
||||||
try:
|
try:
|
||||||
cutoff = time.time() - (days * 86400)
|
cutoff = time.time() - (days * 86400)
|
||||||
@@ -1997,8 +1999,24 @@ async def get_usage_analytics(days: int = 30):
|
|||||||
FROM sessions WHERE started_at > ?
|
FROM sessions WHERE started_at > ?
|
||||||
""", (cutoff,))
|
""", (cutoff,))
|
||||||
totals = dict(cur3.fetchone())
|
totals = dict(cur3.fetchone())
|
||||||
|
insights_report = InsightsEngine(db).generate(days=days)
|
||||||
|
skills = insights_report.get("skills", {
|
||||||
|
"summary": {
|
||||||
|
"total_skill_loads": 0,
|
||||||
|
"total_skill_edits": 0,
|
||||||
|
"total_skill_actions": 0,
|
||||||
|
"distinct_skills_used": 0,
|
||||||
|
},
|
||||||
|
"top_skills": [],
|
||||||
|
})
|
||||||
|
|
||||||
return {"daily": daily, "by_model": by_model, "totals": totals, "period_days": days}
|
return {
|
||||||
|
"daily": daily,
|
||||||
|
"by_model": by_model,
|
||||||
|
"totals": totals,
|
||||||
|
"period_days": days,
|
||||||
|
"skills": skills,
|
||||||
|
}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ def populated_db(db):
|
|||||||
db.append_message("s1", role="assistant", content="I found the bug. Let me fix it.",
|
db.append_message("s1", role="assistant", content="I found the bug. Let me fix it.",
|
||||||
tool_calls=[{"function": {"name": "patch"}}])
|
tool_calls=[{"function": {"name": "patch"}}])
|
||||||
db.append_message("s1", role="tool", content="patched successfully", tool_name="patch")
|
db.append_message("s1", role="tool", content="patched successfully", tool_name="patch")
|
||||||
|
db.append_message(
|
||||||
|
"s1",
|
||||||
|
role="assistant",
|
||||||
|
content="Let me load the PR workflow skill.",
|
||||||
|
tool_calls=[{"function": {"name": "skill_view", "arguments": '{"name":"github-pr-workflow"}'}}],
|
||||||
|
)
|
||||||
db.append_message("s1", role="user", content="Thanks!")
|
db.append_message("s1", role="user", content="Thanks!")
|
||||||
db.append_message("s1", role="assistant", content="You're welcome!")
|
db.append_message("s1", role="assistant", content="You're welcome!")
|
||||||
|
|
||||||
@@ -88,6 +94,12 @@ def populated_db(db):
|
|||||||
db.append_message("s3", role="assistant", content="And search files",
|
db.append_message("s3", role="assistant", content="And search files",
|
||||||
tool_calls=[{"function": {"name": "search_files"}}])
|
tool_calls=[{"function": {"name": "search_files"}}])
|
||||||
db.append_message("s3", role="tool", content="found stuff", tool_name="search_files")
|
db.append_message("s3", role="tool", content="found stuff", tool_name="search_files")
|
||||||
|
db.append_message(
|
||||||
|
"s3",
|
||||||
|
role="assistant",
|
||||||
|
content="Load the debugging skill.",
|
||||||
|
tool_calls=[{"function": {"name": "skill_view", "arguments": '{"name":"systematic-debugging"}'}}],
|
||||||
|
)
|
||||||
|
|
||||||
# Session 4: Discord, same model as s1, ended, 1 day ago
|
# Session 4: Discord, same model as s1, ended, 1 day ago
|
||||||
db.create_session(
|
db.create_session(
|
||||||
@@ -100,6 +112,15 @@ def populated_db(db):
|
|||||||
db.update_token_counts("s4", input_tokens=10000, output_tokens=5000)
|
db.update_token_counts("s4", input_tokens=10000, output_tokens=5000)
|
||||||
db.append_message("s4", role="user", content="Quick question")
|
db.append_message("s4", role="user", content="Quick question")
|
||||||
db.append_message("s4", role="assistant", content="Sure, go ahead")
|
db.append_message("s4", role="assistant", content="Sure, go ahead")
|
||||||
|
db.append_message(
|
||||||
|
"s4",
|
||||||
|
role="assistant",
|
||||||
|
content="Load and update GitHub skills.",
|
||||||
|
tool_calls=[
|
||||||
|
{"function": {"name": "skill_view", "arguments": '{"name":"github-pr-workflow"}'}},
|
||||||
|
{"function": {"name": "skill_manage", "arguments": '{"name":"github-code-review"}'}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
# Session 5: Old session, 45 days ago (should be excluded from 30-day window)
|
# Session 5: Old session, 45 days ago (should be excluded from 30-day window)
|
||||||
db.create_session(
|
db.create_session(
|
||||||
@@ -332,6 +353,35 @@ class TestInsightsPopulated:
|
|||||||
total_pct = sum(t["percentage"] for t in tools)
|
total_pct = sum(t["percentage"] for t in tools)
|
||||||
assert total_pct == pytest.approx(100.0, abs=0.1)
|
assert total_pct == pytest.approx(100.0, abs=0.1)
|
||||||
|
|
||||||
|
def test_skill_breakdown(self, populated_db):
|
||||||
|
engine = InsightsEngine(populated_db)
|
||||||
|
report = engine.generate(days=30)
|
||||||
|
skills = report["skills"]
|
||||||
|
|
||||||
|
assert skills["summary"]["distinct_skills_used"] == 3
|
||||||
|
assert skills["summary"]["total_skill_loads"] == 3
|
||||||
|
assert skills["summary"]["total_skill_edits"] == 1
|
||||||
|
assert skills["summary"]["total_skill_actions"] == 4
|
||||||
|
|
||||||
|
top_skill = skills["top_skills"][0]
|
||||||
|
assert top_skill["skill"] == "github-pr-workflow"
|
||||||
|
assert top_skill["view_count"] == 2
|
||||||
|
assert top_skill["manage_count"] == 0
|
||||||
|
assert top_skill["total_count"] == 2
|
||||||
|
assert top_skill["last_used_at"] is not None
|
||||||
|
|
||||||
|
def test_skill_breakdown_respects_days_filter(self, populated_db):
|
||||||
|
engine = InsightsEngine(populated_db)
|
||||||
|
report = engine.generate(days=3)
|
||||||
|
skills = report["skills"]
|
||||||
|
|
||||||
|
assert skills["summary"]["distinct_skills_used"] == 2
|
||||||
|
assert skills["summary"]["total_skill_loads"] == 2
|
||||||
|
assert skills["summary"]["total_skill_edits"] == 1
|
||||||
|
|
||||||
|
skill_names = [s["skill"] for s in skills["top_skills"]]
|
||||||
|
assert "systematic-debugging" not in skill_names
|
||||||
|
|
||||||
def test_activity_patterns(self, populated_db):
|
def test_activity_patterns(self, populated_db):
|
||||||
engine = InsightsEngine(populated_db)
|
engine = InsightsEngine(populated_db)
|
||||||
report = engine.generate(days=30)
|
report = engine.generate(days=30)
|
||||||
@@ -401,6 +451,7 @@ class TestTerminalFormatting:
|
|||||||
assert "Overview" in text
|
assert "Overview" in text
|
||||||
assert "Models Used" in text
|
assert "Models Used" in text
|
||||||
assert "Top Tools" in text
|
assert "Top Tools" in text
|
||||||
|
assert "Top Skills" in text
|
||||||
assert "Activity Patterns" in text
|
assert "Activity Patterns" in text
|
||||||
assert "Notable Sessions" in text
|
assert "Notable Sessions" in text
|
||||||
|
|
||||||
@@ -469,8 +520,9 @@ class TestGatewayFormatting:
|
|||||||
report = engine.generate(days=30)
|
report = engine.generate(days=30)
|
||||||
text = engine.format_gateway(report)
|
text = engine.format_gateway(report)
|
||||||
|
|
||||||
assert "$" not in text
|
assert "$" in text
|
||||||
assert "Est. cost" not in text
|
assert "Top Skills" in text
|
||||||
|
assert "Est. cost" in text
|
||||||
assert "cache" not in text.lower()
|
assert "cache" not in text.lower()
|
||||||
|
|
||||||
def test_gateway_format_shows_models(self, populated_db):
|
def test_gateway_format_shows_models(self, populated_db):
|
||||||
|
|||||||
@@ -101,14 +101,19 @@ class TestWebServerEndpoints:
|
|||||||
"""Test the FastAPI REST endpoints using Starlette TestClient."""
|
"""Test the FastAPI REST endpoints using Starlette TestClient."""
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _setup_test_client(self):
|
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
|
||||||
"""Create a TestClient — import is deferred to avoid requiring fastapi."""
|
"""Create a TestClient and isolate the state DB under the test HERMES_HOME."""
|
||||||
try:
|
try:
|
||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pytest.skip("fastapi/starlette not installed")
|
pytest.skip("fastapi/starlette not installed")
|
||||||
|
|
||||||
|
import hermes_state
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
from hermes_cli.web_server import app, _SESSION_TOKEN
|
from hermes_cli.web_server import app, _SESSION_TOKEN
|
||||||
|
|
||||||
|
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||||
|
|
||||||
self.client = TestClient(app)
|
self.client = TestClient(app)
|
||||||
self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}"
|
self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}"
|
||||||
|
|
||||||
@@ -511,12 +516,18 @@ class TestNewEndpoints:
|
|||||||
"""Tests for session detail, logs, cron, skills, tools, raw config, analytics."""
|
"""Tests for session detail, logs, cron, skills, tools, raw config, analytics."""
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _setup(self):
|
def _setup(self, monkeypatch, _isolate_hermes_home):
|
||||||
try:
|
try:
|
||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pytest.skip("fastapi/starlette not installed")
|
pytest.skip("fastapi/starlette not installed")
|
||||||
|
|
||||||
|
import hermes_state
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
from hermes_cli.web_server import app, _SESSION_TOKEN
|
from hermes_cli.web_server import app, _SESSION_TOKEN
|
||||||
|
|
||||||
|
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||||
|
|
||||||
self.client = TestClient(app)
|
self.client = TestClient(app)
|
||||||
self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}"
|
self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}"
|
||||||
|
|
||||||
@@ -692,8 +703,74 @@ class TestNewEndpoints:
|
|||||||
assert "daily" in data
|
assert "daily" in data
|
||||||
assert "by_model" in data
|
assert "by_model" in data
|
||||||
assert "totals" in data
|
assert "totals" in data
|
||||||
|
assert "skills" in data
|
||||||
assert isinstance(data["daily"], list)
|
assert isinstance(data["daily"], list)
|
||||||
assert "total_sessions" in data["totals"]
|
assert "total_sessions" in data["totals"]
|
||||||
|
assert data["skills"] == {
|
||||||
|
"summary": {
|
||||||
|
"total_skill_loads": 0,
|
||||||
|
"total_skill_edits": 0,
|
||||||
|
"total_skill_actions": 0,
|
||||||
|
"distinct_skills_used": 0,
|
||||||
|
},
|
||||||
|
"top_skills": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_analytics_usage_includes_skill_breakdown(self):
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
|
||||||
|
db = SessionDB()
|
||||||
|
try:
|
||||||
|
db.create_session(
|
||||||
|
session_id="skills-analytics-test",
|
||||||
|
source="cli",
|
||||||
|
model="anthropic/claude-sonnet-4",
|
||||||
|
)
|
||||||
|
db.update_token_counts(
|
||||||
|
"skills-analytics-test",
|
||||||
|
input_tokens=120,
|
||||||
|
output_tokens=45,
|
||||||
|
)
|
||||||
|
db.append_message(
|
||||||
|
"skills-analytics-test",
|
||||||
|
role="assistant",
|
||||||
|
content="Loading and updating skills.",
|
||||||
|
tool_calls=[
|
||||||
|
{
|
||||||
|
"function": {
|
||||||
|
"name": "skill_view",
|
||||||
|
"arguments": '{"name":"github-pr-workflow"}',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"function": {
|
||||||
|
"name": "skill_manage",
|
||||||
|
"arguments": '{"name":"github-code-review"}',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
resp = self.client.get("/api/analytics/usage?days=7")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
assert data["skills"]["summary"] == {
|
||||||
|
"total_skill_loads": 1,
|
||||||
|
"total_skill_edits": 1,
|
||||||
|
"total_skill_actions": 2,
|
||||||
|
"distinct_skills_used": 2,
|
||||||
|
}
|
||||||
|
assert len(data["skills"]["top_skills"]) == 2
|
||||||
|
|
||||||
|
top_skill = data["skills"]["top_skills"][0]
|
||||||
|
assert top_skill["skill"] == "github-pr-workflow"
|
||||||
|
assert top_skill["view_count"] == 1
|
||||||
|
assert top_skill["manage_count"] == 0
|
||||||
|
assert top_skill["total_count"] == 1
|
||||||
|
assert top_skill["last_used_at"] is not None
|
||||||
|
|
||||||
def test_session_token_endpoint_removed(self):
|
def test_session_token_endpoint_removed(self):
|
||||||
"""GET /api/auth/session-token no longer exists."""
|
"""GET /api/auth/session-token no longer exists."""
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ export const en: Translations = {
|
|||||||
dailyTokenUsage: "Daily Token Usage",
|
dailyTokenUsage: "Daily Token Usage",
|
||||||
dailyBreakdown: "Daily Breakdown",
|
dailyBreakdown: "Daily Breakdown",
|
||||||
perModelBreakdown: "Per-Model Breakdown",
|
perModelBreakdown: "Per-Model Breakdown",
|
||||||
|
topSkills: "Top Skills",
|
||||||
|
skill: "Skill",
|
||||||
|
loads: "Agent Loaded",
|
||||||
|
edits: "Agent Managed",
|
||||||
|
lastUsed: "Last Used",
|
||||||
input: "Input",
|
input: "Input",
|
||||||
output: "Output",
|
output: "Output",
|
||||||
total: "Total",
|
total: "Total",
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ export interface Translations {
|
|||||||
dailyTokenUsage: string;
|
dailyTokenUsage: string;
|
||||||
dailyBreakdown: string;
|
dailyBreakdown: string;
|
||||||
perModelBreakdown: string;
|
perModelBreakdown: string;
|
||||||
|
topSkills: string;
|
||||||
|
skill: string;
|
||||||
|
loads: string;
|
||||||
|
edits: string;
|
||||||
|
lastUsed: string;
|
||||||
input: string;
|
input: string;
|
||||||
output: string;
|
output: string;
|
||||||
total: string;
|
total: string;
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ export const zh: Translations = {
|
|||||||
dailyTokenUsage: "每日 Token 用量",
|
dailyTokenUsage: "每日 Token 用量",
|
||||||
dailyBreakdown: "每日明细",
|
dailyBreakdown: "每日明细",
|
||||||
perModelBreakdown: "模型用量明细",
|
perModelBreakdown: "模型用量明细",
|
||||||
|
topSkills: "常用技能",
|
||||||
|
skill: "技能",
|
||||||
|
loads: "代理加载",
|
||||||
|
edits: "代理管理",
|
||||||
|
lastUsed: "最近使用",
|
||||||
input: "输入",
|
input: "输入",
|
||||||
output: "输出",
|
output: "输出",
|
||||||
total: "总计",
|
total: "总计",
|
||||||
|
|||||||
@@ -300,6 +300,22 @@ export interface AnalyticsModelEntry {
|
|||||||
sessions: number;
|
sessions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsSkillEntry {
|
||||||
|
skill: string;
|
||||||
|
view_count: number;
|
||||||
|
manage_count: number;
|
||||||
|
total_count: number;
|
||||||
|
percentage: number;
|
||||||
|
last_used_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsSkillsSummary {
|
||||||
|
total_skill_loads: number;
|
||||||
|
total_skill_edits: number;
|
||||||
|
total_skill_actions: number;
|
||||||
|
distinct_skills_used: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AnalyticsResponse {
|
export interface AnalyticsResponse {
|
||||||
daily: AnalyticsDailyEntry[];
|
daily: AnalyticsDailyEntry[];
|
||||||
by_model: AnalyticsModelEntry[];
|
by_model: AnalyticsModelEntry[];
|
||||||
@@ -312,6 +328,10 @@ export interface AnalyticsResponse {
|
|||||||
total_actual_cost: number;
|
total_actual_cost: number;
|
||||||
total_sessions: number;
|
total_sessions: number;
|
||||||
};
|
};
|
||||||
|
skills: {
|
||||||
|
summary: AnalyticsSkillsSummary;
|
||||||
|
top_skills: AnalyticsSkillEntry[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CronJob {
|
export interface CronJob {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Brain,
|
||||||
Cpu,
|
Cpu,
|
||||||
Hash,
|
Hash,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry } from "@/lib/api";
|
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry, AnalyticsSkillEntry } from "@/lib/api";
|
||||||
|
import { timeAgo } from "@/lib/utils";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
@@ -227,6 +229,52 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
if (skills.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Brain className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<CardTitle className="text-base">{t.analytics.topSkills}</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||||
|
<th className="text-left py-2 pr-4 font-medium">{t.analytics.skill}</th>
|
||||||
|
<th className="text-right py-2 px-4 font-medium">{t.analytics.loads}</th>
|
||||||
|
<th className="text-right py-2 px-4 font-medium">{t.analytics.edits}</th>
|
||||||
|
<th className="text-right py-2 px-4 font-medium">{t.analytics.total}</th>
|
||||||
|
<th className="text-right py-2 pl-4 font-medium">{t.analytics.lastUsed}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<tr key={skill.skill} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<span className="font-mono-ui text-xs">{skill.skill}</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-2 px-4 text-muted-foreground">{skill.view_count}</td>
|
||||||
|
<td className="text-right py-2 px-4 text-muted-foreground">{skill.manage_count}</td>
|
||||||
|
<td className="text-right py-2 px-4">{skill.total_count}</td>
|
||||||
|
<td className="text-right py-2 pl-4 text-muted-foreground">
|
||||||
|
{skill.last_used_at ? timeAgo(skill.last_used_at) : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AnalyticsPage() {
|
export default function AnalyticsPage() {
|
||||||
const [days, setDays] = useState(30);
|
const [days, setDays] = useState(30);
|
||||||
const [data, setData] = useState<AnalyticsResponse | null>(null);
|
const [data, setData] = useState<AnalyticsResponse | null>(null);
|
||||||
@@ -310,10 +358,11 @@ export default function AnalyticsPage() {
|
|||||||
{/* Tables */}
|
{/* Tables */}
|
||||||
<DailyTable daily={data.daily} />
|
<DailyTable daily={data.daily} />
|
||||||
<ModelTable models={data.by_model} />
|
<ModelTable models={data.by_model} />
|
||||||
|
<SkillTable skills={data.skills.top_skills} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data && data.daily.length === 0 && data.by_model.length === 0 && (
|
{data && data.daily.length === 0 && data.by_model.length === 0 && data.skills.top_skills.length === 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12">
|
<CardContent className="py-12">
|
||||||
<div className="flex flex-col items-center text-muted-foreground">
|
<div className="flex flex-col items-center text-muted-foreground">
|
||||||
|
|||||||
Reference in New Issue
Block a user