Fix toxicity analysis web interface

- Fix analysis_helpers stats to match template expectations (posts/replies/mentions breakdown)
- Fix SQL interval syntax in trend query
- Fix URL routing in templates (analysis_flagged, accounts_list)
- Add .claude/ to .gitignore

Analysis dashboard now accessible at http://localhost:8585/analysis

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pieter 2026-03-30 17:07:12 +02:00
parent a8b28f63a0
commit 2172efa701
5 changed files with 49 additions and 24 deletions

View file

@ -9,7 +9,11 @@
"Bash(docker compose up -d)",
"Bash(docker exec mastodon-collector-collector-1 bash -c \"ANALYZER_LIMIT=100 python -m app.analyzer\")",
"Bash(docker compose build collector)",
"Bash(docker compose up -d collector)"
"Bash(docker compose up -d collector)",
"Bash(docker compose build web)",
"Bash(docker compose up -d web)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8585/analysis)",
"Bash(docker logs mastodon-collector-web-1 --tail 30)"
],
"deny": [],
"ask": []

1
.gitignore vendored
View file

@ -46,6 +46,7 @@ venv.bak/
*.swo
*~
.DS_Store
.claude/
# Database files
*.sqlite

View file

@ -17,21 +17,40 @@ def get_analysis_stats(session: Session) -> dict:
"""Get overall toxicity analysis statistics."""
from sqlalchemy import text
# Total statuses and scored statuses
total_statuses = session.query(func.count(Status.id)).scalar() or 0
# Total statuses by type
total_posts = session.query(func.count(Status.id)).filter(Status.status_type == 'post').scalar() or 0
total_replies = session.query(func.count(Status.id)).filter(Status.status_type == 'reply').scalar() or 0
total_mentions = session.query(func.count(Status.id)).filter(Status.status_type == 'mention').scalar() or 0
scored = session.execute(text("""
SELECT COUNT(*) as total_scored,
COUNT(*) FILTER (WHERE flagged = true) as flagged,
AVG(overall) as avg_toxicity
FROM toxicity_scores
# Scored statuses by type
scored_stats = session.execute(text("""
SELECT
COUNT(*) FILTER (WHERE s.status_type = 'post') as scored_posts,
COUNT(*) FILTER (WHERE s.status_type = 'reply') as scored_replies,
COUNT(*) FILTER (WHERE s.status_type = 'mention') as scored_mentions,
COUNT(*) FILTER (WHERE ts.flagged = true AND s.status_type = 'post') as flagged_posts,
COUNT(*) FILTER (WHERE ts.flagged = true AND s.status_type = 'reply') as flagged_replies,
COUNT(*) FILTER (WHERE ts.flagged = true AND s.status_type = 'mention') as flagged_mentions,
AVG(ts.overall) FILTER (WHERE s.status_type = 'post') as avg_toxicity_posts,
AVG(ts.overall) FILTER (WHERE s.status_type = 'reply') as avg_toxicity_replies,
AVG(ts.overall) FILTER (WHERE s.status_type = 'mention') as avg_toxicity_mentions
FROM toxicity_scores ts
JOIN statuses s ON s.id = ts.status_id
""")).fetchone()
return {
"total_statuses": total_statuses,
"total_scored_statuses": scored[0] if scored else 0,
"flagged_statuses": scored[1] if scored else 0,
"avg_toxicity_statuses": float(scored[2]) if scored and scored[2] else 0.0,
"total_posts": total_posts,
"total_replies": total_replies,
"total_mentions": total_mentions,
"total_scored_posts": scored_stats[0] if scored_stats else 0,
"total_scored_replies": scored_stats[1] if scored_stats else 0,
"total_scored_mentions": scored_stats[2] if scored_stats else 0,
"flagged_posts": scored_stats[3] if scored_stats else 0,
"flagged_replies": scored_stats[4] if scored_stats else 0,
"flagged_mentions": scored_stats[5] if scored_stats else 0,
"avg_toxicity_posts": float(scored_stats[6]) if scored_stats and scored_stats[6] else 0.0,
"avg_toxicity_replies": float(scored_stats[7]) if scored_stats and scored_stats[7] else 0.0,
"avg_toxicity_mentions": float(scored_stats[8]) if scored_stats and scored_stats[8] else 0.0,
}
@ -39,17 +58,17 @@ def get_toxicity_trend(session: Session, weeks: int = 12) -> list[dict]:
"""Get toxicity trend over time (weekly aggregates)."""
from sqlalchemy import text
result = session.execute(text("""
result = session.execute(text(f"""
SELECT
DATE_TRUNC('week', s.created_at) as week,
AVG(ts.overall) as avg_toxicity,
COUNT(*) FILTER (WHERE ts.flagged = true) as flagged_count
FROM statuses s
JOIN toxicity_scores ts ON ts.status_id = s.id
WHERE s.created_at >= NOW() - INTERVAL ':weeks weeks'
WHERE s.created_at >= NOW() - INTERVAL '{weeks} weeks'
GROUP BY week
ORDER BY week DESC
"""), {"weeks": weeks})
"""))
return [{"week": r[0], "avg_toxicity": float(r[1]) if r[1] else 0.0, "flagged_statuses": r[2]} for r in result]
@ -98,7 +117,8 @@ def get_recent_analysis_runs(session: Session, limit: int = 5) -> list[dict]:
"started_at": r[1],
"finished_at": r[2],
"status": r[3],
"statuses_scored": r[4],
"posts_scored": r[4], # Template expects posts_scored
"mentions_scored": 0, # Not tracked separately in Mastodon
"errors": r[5],
"cost_usd": r[6],
"duration_secs": r[7]

View file

@ -423,8 +423,8 @@
<!-- Quick Links -->
<div style="margin-top: 2rem;">
<div class="quick-links">
<a href="{{ url_for('analysis.flagged') }}" class="quick-link">View Flagged Content</a>
<a href="{{ url_for('analysis.accounts') }}" class="quick-link">Account Breakdown</a>
<a href="{{ url_for('analysis_flagged') }}" class="quick-link">View Flagged Content</a>
<a href="{{ url_for('accounts_list') }}" class="quick-link">Account Breakdown</a>
</div>
</div>

View file

@ -12,7 +12,7 @@
<!-- Filter Bar -->
<div class="filter-bar">
<form method="get" action="{{ url_for('analysis.flagged') }}" class="filter-form">
<form method="get" action="{{ url_for('analysis_flagged') }}" class="filter-form">
<div class="filter-group">
<label for="content-type">Type:</label>
<select id="content-type" name="content_type" class="filter-select">
@ -78,7 +78,7 @@
{% macro sort_header(col, label) %}
{% set new_dir = 'asc' if (sort == col and direction == 'desc') else 'desc' %}
<a href="{{ url_for('analysis.flagged', content_type=content_type, category=category, account_did=account_did, threshold=threshold, review_status=review_status, date_from=date_from, date_to=date_to, sort=col, dir=new_dir) }}" class="sort-link">
<a href="{{ url_for('analysis_flagged', content_type=content_type, category=category, account_did=account_did, threshold=threshold, review_status=review_status, date_from=date_from, date_to=date_to, sort=col, dir=new_dir) }}" class="sort-link">
{{ label }}
{% if sort == col %}
<span class="sort-icon">{{ '▼' if direction == 'desc' else '▲' }}</span>
@ -201,15 +201,15 @@
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="{{ url_for('analysis.flagged', page=1, content_type=content_type, category=category, account_did=account_did, threshold=threshold, review_status=review_status, date_from=date_from, date_to=date_to, sort=sort, dir=direction) }}" class="btn-pagination">First</a>
<a href="{{ url_for('analysis.flagged', page=page-1, content_type=content_type, category=category, account_did=account_did, threshold=threshold, review_status=review_status, date_from=date_from, date_to=date_to, sort=sort, dir=direction) }}" class="btn-pagination">Previous</a>
<a href="{{ url_for('analysis_flagged', page=1, content_type=content_type, category=category, account_did=account_did, threshold=threshold, review_status=review_status, date_from=date_from, date_to=date_to, sort=sort, dir=direction) }}" class="btn-pagination">First</a>
<a href="{{ url_for('analysis_flagged', page=page-1, content_type=content_type, category=category, account_did=account_did, threshold=threshold, review_status=review_status, date_from=date_from, date_to=date_to, sort=sort, dir=direction) }}" class="btn-pagination">Previous</a>
{% endif %}
<span class="pagination-info">Page {{ page }} of {{ total_pages }}</span>
{% if page < total_pages %}
<a href="{{ url_for('analysis.flagged', page=page+1, content_type=content_type, category=category, account_did=account_did, threshold=threshold, review_status=review_status, date_from=date_from, date_to=date_to, sort=sort, dir=direction) }}" class="btn-pagination">Next</a>
<a href="{{ url_for('analysis.flagged', page=total_pages, content_type=content_type, category=category, account_did=account_did, threshold=threshold, review_status=review_status, date_from=date_from, date_to=date_to, sort=sort, dir=direction) }}" class="btn-pagination">Last</a>
<a href="{{ url_for('analysis_flagged', page=page+1, content_type=content_type, category=category, account_did=account_did, threshold=threshold, review_status=review_status, date_from=date_from, date_to=date_to, sort=sort, dir=direction) }}" class="btn-pagination">Next</a>
<a href="{{ url_for('analysis_flagged', page=total_pages, content_type=content_type, category=category, account_did=account_did, threshold=threshold, review_status=review_status, date_from=date_from, date_to=date_to, sort=sort, dir=direction) }}" class="btn-pagination">Last</a>
{% endif %}
</div>
{% endif %}