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 2ff5ed78da
commit 7f892456ae
4 changed files with 44 additions and 23 deletions

1
.gitignore vendored
View file

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

View file

@ -17,21 +17,40 @@ def get_analysis_stats(session: Session) -> dict:
"""Get overall toxicity analysis statistics.""" """Get overall toxicity analysis statistics."""
from sqlalchemy import text from sqlalchemy import text
# Total statuses and scored statuses # Total statuses by type
total_statuses = session.query(func.count(Status.id)).scalar() or 0 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(""" # Scored statuses by type
SELECT COUNT(*) as total_scored, scored_stats = session.execute(text("""
COUNT(*) FILTER (WHERE flagged = true) as flagged, SELECT
AVG(overall) as avg_toxicity COUNT(*) FILTER (WHERE s.status_type = 'post') as scored_posts,
FROM toxicity_scores 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() """)).fetchone()
return { return {
"total_statuses": total_statuses, "total_posts": total_posts,
"total_scored_statuses": scored[0] if scored else 0, "total_replies": total_replies,
"flagged_statuses": scored[1] if scored else 0, "total_mentions": total_mentions,
"avg_toxicity_statuses": float(scored[2]) if scored and scored[2] else 0.0, "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).""" """Get toxicity trend over time (weekly aggregates)."""
from sqlalchemy import text from sqlalchemy import text
result = session.execute(text(""" result = session.execute(text(f"""
SELECT SELECT
DATE_TRUNC('week', s.created_at) as week, DATE_TRUNC('week', s.created_at) as week,
AVG(ts.overall) as avg_toxicity, AVG(ts.overall) as avg_toxicity,
COUNT(*) FILTER (WHERE ts.flagged = true) as flagged_count COUNT(*) FILTER (WHERE ts.flagged = true) as flagged_count
FROM statuses s FROM statuses s
JOIN toxicity_scores ts ON ts.status_id = s.id 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 GROUP BY week
ORDER BY week DESC 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] 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], "started_at": r[1],
"finished_at": r[2], "finished_at": r[2],
"status": r[3], "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], "errors": r[5],
"cost_usd": r[6], "cost_usd": r[6],
"duration_secs": r[7] "duration_secs": r[7]

View file

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

View file

@ -12,7 +12,7 @@
<!-- Filter Bar --> <!-- Filter Bar -->
<div class="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"> <div class="filter-group">
<label for="content-type">Type:</label> <label for="content-type">Type:</label>
<select id="content-type" name="content_type" class="filter-select"> <select id="content-type" name="content_type" class="filter-select">
@ -78,7 +78,7 @@
{% macro sort_header(col, label) %} {% macro sort_header(col, label) %}
{% set new_dir = 'asc' if (sort == col and direction == 'desc') else 'desc' %} {% 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 }} {{ label }}
{% if sort == col %} {% if sort == col %}
<span class="sort-icon">{{ '▼' if direction == 'desc' else '▲' }}</span> <span class="sort-icon">{{ '▼' if direction == 'desc' else '▲' }}</span>
@ -201,15 +201,15 @@
{% if total_pages > 1 %} {% if total_pages > 1 %}
<div class="pagination"> <div class="pagination">
{% if page > 1 %} {% 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=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=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 %} {% endif %}
<span class="pagination-info">Page {{ page }} of {{ total_pages }}</span> <span class="pagination-info">Page {{ page }} of {{ total_pages }}</span>
{% if page < total_pages %} {% 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=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=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 %} {% endif %}
</div> </div>
{% endif %} {% endif %}