Add human review feature and enhance data collection

Enhanced the web interface with comprehensive human review capabilities
for validating AI-flagged toxicity classifications. Added date filtering
and improved data collection to include engagement metrics for mentions.

Features added:
- Human review system with ✓/✗/? status buttons and filtering
- Date range filtering (from/to) for flagged content
- Review status tracking with database migrations
- Engagement metrics collection for mentions (likes, replies, reposts, quotes)
- Interactive review buttons that allow changing classifications
- Review filter to show unreviewed, correct, incorrect, or unsure items

UI improvements:
- Fixed Chart.js CDN URLs (switched to jsdelivr)
- Smart axis scaling for toxicity category charts with dynamic decimal places
- Clickable max toxicity badges linking to filtered content
- Improved mention author display using raw_json fallback
- Sortable table columns with visual indicators
- Review status preserved across pagination and filtering

Bug fixes:
- Commented out problematic account (stephanvanbaarle.bsky.social)
- Fixed filter parameter names (content_type, account_did)
- Fixed threshold boundary issues with 0.001 offset
- Added extra_js block to base template for JavaScript functionality

Database changes:
- Migration 03: Added engagement columns to mentions table
- Migration 04: Added human_reviewed, review_status, reviewed_at columns

🤖 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 14:13:14 +02:00
parent b1fd78e0c1
commit 0495f47c13
15 changed files with 557 additions and 24 deletions

View file

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(docker compose:*)"
],
"deny": [],
"ask": []
}
}

View file

@ -99,7 +99,7 @@ accounts:
- handle: marietbosman62.bsky.social # Mariet Bosman (BBB) - handle: marietbosman62.bsky.social # Mariet Bosman (BBB)
- handle: natalienauta.bsky.social # Natalie Nauta (BBB) - handle: natalienauta.bsky.social # Natalie Nauta (BBB)
- handle: nielsoosterom.bsky.social # Niels Oosterom (BBB) - handle: nielsoosterom.bsky.social # Niels Oosterom (BBB)
- handle: stephanvanbaarle.bsky.social # Stephan van Baarle (DENK) # - handle: stephanvanbaarle.bsky.social # Stephan van Baarle (DENK) - DISABLED: Account returns 400 error
- handle: ananninga.bsky.social # Annabel Nanninga (JA21) - handle: ananninga.bsky.social # Annabel Nanninga (JA21)
- handle: djhvandijk.bsky.social # Diederik van Dijk (SGP) - handle: djhvandijk.bsky.social # Diederik van Dijk (SGP)
- handle: carladikfaber.bsky.social # Carla Dik-Faber (ChristenUnie) - handle: carladikfaber.bsky.social # Carla Dik-Faber (ChristenUnie)

View file

@ -0,0 +1,10 @@
-- Add engagement metrics to mentions table
-- Migration to add like_count, reply_count, repost_count, quote_count
ALTER TABLE mentions
ADD COLUMN IF NOT EXISTS like_count INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS reply_count INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS repost_count INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS quote_count INTEGER DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_mentions_like_count ON mentions (like_count DESC);

View file

@ -0,0 +1,20 @@
-- Add human review functionality to toxicity scores
-- Migration to add human_reviewed, review_status, and reviewed_at columns
-- Add columns to toxicity_scores (for posts)
ALTER TABLE toxicity_scores
ADD COLUMN IF NOT EXISTS human_reviewed BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS review_status VARCHAR(20) DEFAULT NULL, -- 'correct', 'incorrect', 'unsure'
ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMP DEFAULT NULL;
-- Add columns to mention_toxicity_scores (for mentions)
ALTER TABLE mention_toxicity_scores
ADD COLUMN IF NOT EXISTS human_reviewed BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS review_status VARCHAR(20) DEFAULT NULL, -- 'correct', 'incorrect', 'unsure'
ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMP DEFAULT NULL;
-- Create indexes for filtering
CREATE INDEX IF NOT EXISTS idx_toxicity_scores_human_reviewed ON toxicity_scores (human_reviewed);
CREATE INDEX IF NOT EXISTS idx_toxicity_scores_review_status ON toxicity_scores (review_status);
CREATE INDEX IF NOT EXISTS idx_mention_toxicity_scores_human_reviewed ON mention_toxicity_scores (human_reviewed);
CREATE INDEX IF NOT EXISTS idx_mention_toxicity_scores_review_status ON mention_toxicity_scores (review_status);

View file

@ -297,5 +297,9 @@ def map_search_post_to_mention(post_data: dict, mentioned_did: str) -> Mention:
mentioning_did=post_data.get("author", {}).get("did"), mentioning_did=post_data.get("author", {}).get("did"),
post_text=record.get("text"), post_text=record.get("text"),
post_created_at=_parse_dt(record.get("createdAt")), post_created_at=_parse_dt(record.get("createdAt")),
like_count=post_data.get("likeCount", 0) or 0,
reply_count=post_data.get("replyCount", 0) or 0,
repost_count=post_data.get("repostCount", 0) or 0,
quote_count=post_data.get("quoteCount", 0) or 0,
raw_json=post_data, raw_json=post_data,
) )

View file

@ -136,15 +136,25 @@ class Database:
""" """
INSERT INTO mentions ( INSERT INTO mentions (
post_uri, mentioned_did, mentioning_did, post_uri, mentioned_did, mentioning_did,
post_text, post_created_at, raw_json post_text, post_created_at,
) VALUES ($1, $2, $3, $4, $5, $6) like_count, reply_count, repost_count, quote_count,
ON CONFLICT (post_uri, mentioned_did) DO NOTHING raw_json
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (post_uri, mentioned_did) DO UPDATE SET
like_count = EXCLUDED.like_count,
reply_count = EXCLUDED.reply_count,
repost_count = EXCLUDED.repost_count,
quote_count = EXCLUDED.quote_count
""", """,
m.post_uri, m.post_uri,
m.mentioned_did, m.mentioned_did,
m.mentioning_did, m.mentioning_did,
m.post_text, m.post_text,
m.post_created_at, m.post_created_at,
m.like_count,
m.reply_count,
m.repost_count,
m.quote_count,
json.dumps(m.raw_json), json.dumps(m.raw_json),
) )
if "INSERT 0 1" in result: if "INSERT 0 1" in result:

View file

@ -46,6 +46,10 @@ class Mention:
mentioning_did: str | None mentioning_did: str | None
post_text: str | None post_text: str | None
post_created_at: datetime | None post_created_at: datetime | None
like_count: int = 0
reply_count: int = 0
repost_count: int = 0
quote_count: int = 0
raw_json: dict[str, Any] = field(default_factory=dict) raw_json: dict[str, Any] = field(default_factory=dict)

View file

@ -48,6 +48,7 @@ def create_app() -> Flask:
from .routes.mentions import bp as mentions_bp from .routes.mentions import bp as mentions_bp
from .routes.export import bp as export_bp from .routes.export import bp as export_bp
from .routes.analysis import bp as analysis_bp from .routes.analysis import bp as analysis_bp
from .routes.review import bp as review_bp
app.register_blueprint(dashboard_bp) app.register_blueprint(dashboard_bp)
app.register_blueprint(accounts_bp) app.register_blueprint(accounts_bp)
@ -55,6 +56,7 @@ def create_app() -> Flask:
app.register_blueprint(mentions_bp) app.register_blueprint(mentions_bp)
app.register_blueprint(export_bp) app.register_blueprint(export_bp)
app.register_blueprint(analysis_bp) app.register_blueprint(analysis_bp)
app.register_blueprint(review_bp)
# Teardown # Teardown
@app.teardown_appcontext @app.teardown_appcontext

View file

@ -495,6 +495,11 @@ def get_flagged_content(
category: str | None = None, category: str | None = None,
account_did: str | None = None, account_did: str | None = None,
threshold: float = 0.5, threshold: float = 0.5,
review_status: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
sort: str = "overall",
direction: str = "desc",
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
) -> tuple[list[dict], int]: ) -> tuple[list[dict], int]:
@ -522,6 +527,29 @@ def get_flagged_content(
mention_conditions += " AND m.mentioned_did = %s" mention_conditions += " AND m.mentioned_did = %s"
params_mentions.append(account_did) params_mentions.append(account_did)
if review_status:
if review_status == "unreviewed":
post_conditions += " AND ts.human_reviewed = false"
mention_conditions += " AND mts.human_reviewed = false"
elif review_status in ("correct", "incorrect", "unsure"):
post_conditions += " AND ts.review_status = %s"
params_posts.append(review_status)
mention_conditions += " AND mts.review_status = %s"
params_mentions.append(review_status)
if date_from:
post_conditions += " AND p.created_at >= %s"
params_posts.append(date_from)
mention_conditions += " AND m.post_created_at >= %s"
params_mentions.append(date_from)
if date_to:
# Add one day to include the entire "to" date
post_conditions += " AND p.created_at < (%s::date + interval '1 day')"
params_posts.append(date_to)
mention_conditions += " AND m.post_created_at < (%s::date + interval '1 day')"
params_mentions.append(date_to)
type_filter_post = "" type_filter_post = ""
type_filter_mention = "" type_filter_mention = ""
if content_type == "mention": if content_type == "mention":
@ -533,6 +561,14 @@ def get_flagged_content(
elif content_type == "reply": elif content_type == "reply":
post_conditions += " AND p.post_type = 'reply'" post_conditions += " AND p.post_type = 'reply'"
# Build ORDER BY clause
valid_sorts = {"overall", "created_at", "author_handle"}
sort_col = sort if sort in valid_sorts else "overall"
dir_sql = "DESC" if direction == "desc" else "ASC"
order_by = f"ORDER BY {sort_col} {dir_sql}"
if sort_col != "created_at":
order_by += ", created_at DESC"
with get_cursor() as cur: with get_cursor() as cur:
# Count # Count
cur.execute(f""" cur.execute(f"""
@ -564,7 +600,8 @@ def get_flagged_content(
ts.overall, ts.toxic, ts.threat, ts.hate_speech, ts.overall, ts.toxic, ts.threat, ts.hate_speech,
ts.racism, ts.antisemitism, ts.islamophobia, ts.racism, ts.antisemitism, ts.islamophobia,
ts.sexism, ts.homophobia, ts.insult, ts.dehumanization, ts.sexism, ts.homophobia, ts.insult, ts.dehumanization,
ts.extremism, ts.ableism ts.extremism, ts.ableism,
ts.human_reviewed, ts.review_status, ts.reviewed_at
FROM toxicity_scores ts FROM toxicity_scores ts
JOIN posts p ON p.uri = ts.uri JOIN posts p ON p.uri = ts.uri
LEFT JOIN accounts a ON a.did = p.author_did LEFT JOIN accounts a ON a.did = p.author_did
@ -578,20 +615,22 @@ def get_flagged_content(
m.post_uri AS item_id, m.post_uri AS item_id,
m.post_text AS text, m.post_text AS text,
m.mentioning_did AS author_did, m.mentioning_did AS author_did,
NULL AS author_handle, COALESCE(aa.handle, m.raw_json->'author'->>'handle') AS author_handle,
m.mentioned_did, m.mentioned_did,
ma.handle AS mentioned_handle, ma.handle AS mentioned_handle,
m.post_created_at AS created_at, m.post_created_at AS created_at,
mts.overall, mts.toxic, mts.threat, mts.hate_speech, mts.overall, mts.toxic, mts.threat, mts.hate_speech,
mts.racism, mts.antisemitism, mts.islamophobia, mts.racism, mts.antisemitism, mts.islamophobia,
mts.sexism, mts.homophobia, mts.insult, mts.dehumanization, mts.sexism, mts.homophobia, mts.insult, mts.dehumanization,
mts.extremism, mts.ableism mts.extremism, mts.ableism,
mts.human_reviewed, mts.review_status, mts.reviewed_at
FROM mention_toxicity_scores mts FROM mention_toxicity_scores mts
JOIN mentions m ON m.id = mts.mention_id JOIN mentions m ON m.id = mts.mention_id
LEFT JOIN accounts ma ON ma.did = m.mentioned_did LEFT JOIN accounts ma ON ma.did = m.mentioned_did
LEFT JOIN accounts aa ON aa.did = m.mentioning_did
{mention_conditions} {type_filter_mention} {mention_conditions} {type_filter_mention}
) combined ) combined
ORDER BY overall DESC, created_at DESC {order_by}
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
""", params_posts + params_mentions + [limit, offset]) """, params_posts + params_mentions + [limit, offset])
rows = [dict(r) for r in cur.fetchall()] rows = [dict(r) for r in cur.fetchall()]
@ -636,15 +675,18 @@ def get_account_toxicity_summary(
SELECT SELECT
a.did, a.handle, a.display_name, a.did, a.handle, a.display_name,
coalesce(post_agg.avg_tox, 0) AS avg_post_tox, coalesce(post_agg.avg_tox, 0) AS avg_post_tox,
coalesce(post_agg.max_tox, 0) AS max_post_tox,
coalesce(post_agg.flagged, 0) AS flagged_posts, coalesce(post_agg.flagged, 0) AS flagged_posts,
coalesce(post_agg.total, 0) AS scored_posts, coalesce(post_agg.total, 0) AS scored_posts,
coalesce(mention_agg.avg_tox, 0) AS avg_mention_tox, coalesce(mention_agg.avg_tox, 0) AS avg_mention_tox,
coalesce(mention_agg.max_tox, 0) AS max_mention_tox,
coalesce(mention_agg.flagged, 0) AS flagged_mentions, coalesce(mention_agg.flagged, 0) AS flagged_mentions,
coalesce(mention_agg.total, 0) AS scored_mentions coalesce(mention_agg.total, 0) AS scored_mentions
FROM accounts a FROM accounts a
LEFT JOIN ( LEFT JOIN (
SELECT p.author_did, SELECT p.author_did,
avg(ts.overall) AS avg_tox, avg(ts.overall) AS avg_tox,
max(ts.overall) AS max_tox,
count(*) FILTER (WHERE ts.flagged) AS flagged, count(*) FILTER (WHERE ts.flagged) AS flagged,
count(*) AS total count(*) AS total
FROM toxicity_scores ts FROM toxicity_scores ts
@ -654,6 +696,7 @@ def get_account_toxicity_summary(
LEFT JOIN ( LEFT JOIN (
SELECT m.mentioned_did, SELECT m.mentioned_did,
avg(mts.overall) AS avg_tox, avg(mts.overall) AS avg_tox,
max(mts.overall) AS max_tox,
count(*) FILTER (WHERE mts.flagged) AS flagged, count(*) FILTER (WHERE mts.flagged) AS flagged,
count(*) AS total count(*) AS total
FROM mention_toxicity_scores mts FROM mention_toxicity_scores mts

View file

@ -55,10 +55,15 @@ def index():
@bp.route("/flagged") @bp.route("/flagged")
def flagged(): def flagged():
content_type = request.args.get("type") or None content_type = request.args.get("content_type") or None
category = request.args.get("category") or None category = request.args.get("category") or None
account_did = request.args.get("account") or None account_did = request.args.get("account_did") or None
threshold = request.args.get("threshold", 0.5, type=float) threshold = request.args.get("threshold", 0.5, type=float)
review_status = request.args.get("review_status") or None
date_from = request.args.get("date_from") or None
date_to = request.args.get("date_to") or None
sort = request.args.get("sort", "overall")
direction = request.args.get("dir", "desc")
page = max(1, request.args.get("page", 1, type=int)) page = max(1, request.args.get("page", 1, type=int))
per_page = 50 per_page = 50
@ -67,6 +72,11 @@ def flagged():
category=category, category=category,
account_did=account_did, account_did=account_did,
threshold=threshold, threshold=threshold,
review_status=review_status,
date_from=date_from,
date_to=date_to,
sort=sort,
direction=direction,
limit=per_page, limit=per_page,
offset=(page - 1) * per_page, offset=(page - 1) * per_page,
) )
@ -85,6 +95,11 @@ def flagged():
category=category or "", category=category or "",
account_did=account_did or "", account_did=account_did or "",
threshold=threshold, threshold=threshold,
review_status=review_status or "",
date_from=date_from or "",
date_to=date_to or "",
sort=sort,
direction=direction,
) )

50
src/web/routes/review.py Normal file
View file

@ -0,0 +1,50 @@
"""Review API routes for human validation of toxicity scores."""
from flask import Blueprint, request, jsonify
from ..db import get_cursor
bp = Blueprint("review", __name__, url_prefix="/api/review")
@bp.route("/submit", methods=["POST"])
def submit_review():
"""Submit a human review for a flagged item."""
data = request.get_json()
item_id = data.get("item_id")
source_type = data.get("source_type") # 'post' or 'mention'
review_status = data.get("review_status") # 'correct', 'incorrect', 'unsure'
if not all([item_id, source_type, review_status]):
return jsonify({"error": "Missing required fields"}), 400
if review_status not in ['correct', 'incorrect', 'unsure']:
return jsonify({"error": "Invalid review_status"}), 400
if source_type not in ['post', 'mention']:
return jsonify({"error": "Invalid source_type"}), 400
try:
with get_cursor() as cur:
if source_type == 'post':
cur.execute("""
UPDATE toxicity_scores
SET human_reviewed = true,
review_status = %s,
reviewed_at = NOW()
WHERE uri = %s
""", (review_status, item_id))
else: # mention
cur.execute("""
UPDATE mention_toxicity_scores mts
SET human_reviewed = true,
review_status = %s,
reviewed_at = NOW()
FROM mentions m
WHERE mts.mention_id = m.id AND m.post_uri = %s
""", (review_status, item_id))
return jsonify({"success": True, "message": "Review submitted"}), 200
except Exception as e:
return jsonify({"error": str(e)}), 500

View file

@ -44,8 +44,10 @@
<tr> <tr>
<th>{{ sort_header('handle', 'Account') }}</th> <th>{{ sort_header('handle', 'Account') }}</th>
<th>{{ sort_header('post_tox', 'Avg Post Toxicity') }}</th> <th>{{ sort_header('post_tox', 'Avg Post Toxicity') }}</th>
<th>Max Post Toxicity</th>
<th>{{ sort_header('flagged_posts', 'Flagged Posts') }}</th> <th>{{ sort_header('flagged_posts', 'Flagged Posts') }}</th>
<th>{{ sort_header('mention_tox', 'Avg Mention Toxicity') }}</th> <th>{{ sort_header('mention_tox', 'Avg Mention Toxicity') }}</th>
<th>Max Mention Toxicity</th>
<th>{{ sort_header('flagged_mentions', 'Flagged Mentions') }}</th> <th>{{ sort_header('flagged_mentions', 'Flagged Mentions') }}</th>
</tr> </tr>
</thead> </thead>
@ -80,12 +82,32 @@
</div> </div>
</td> </td>
<!-- Max Post Toxicity -->
<td class="col-max-score">
{% if account.max_post_tox > 0 %}
<a href="{{ url_for('analysis.flagged', content_type='post', account_did=account.did, threshold=(account.max_post_tox - 0.001)|round(3)) }}" class="max-score-link">
<div class="max-score-badge {% if account.max_post_tox >= 0.5 %}high{% elif account.max_post_tox >= 0.3 %}medium{% else %}low{% endif %}">
{{ "%.2f" | format(account.max_post_tox) }}
</div>
</a>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<!-- Flagged Posts Count --> <!-- Flagged Posts Count -->
<td class="col-count"> <td class="col-count">
{% if account.flagged_posts > 0 %}
<a href="{{ url_for('analysis.flagged', content_type='post', account_did=account.did) }}" class="count-badge count-badge-link">
{{ account.flagged_posts | format_number }}
<span class="count-total">/ {{ account.scored_posts | format_number }}</span>
</a>
{% else %}
<span class="count-badge"> <span class="count-badge">
{{ account.flagged_posts | format_number }} {{ account.flagged_posts | format_number }}
<span class="count-total">/ {{ account.scored_posts | format_number }}</span> <span class="count-total">/ {{ account.scored_posts | format_number }}</span>
</span> </span>
{% endif %}
</td> </td>
<!-- Avg Mention Toxicity --> <!-- Avg Mention Toxicity -->
@ -104,12 +126,32 @@
</div> </div>
</td> </td>
<!-- Max Mention Toxicity -->
<td class="col-max-score">
{% if account.max_mention_tox > 0 %}
<a href="{{ url_for('analysis.flagged', content_type='mention', account_did=account.did, threshold=(account.max_mention_tox - 0.001)|round(3)) }}" class="max-score-link">
<div class="max-score-badge {% if account.max_mention_tox >= 0.5 %}high{% elif account.max_mention_tox >= 0.3 %}medium{% else %}low{% endif %}">
{{ "%.2f" | format(account.max_mention_tox) }}
</div>
</a>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<!-- Flagged Mentions Count --> <!-- Flagged Mentions Count -->
<td class="col-count"> <td class="col-count">
{% if account.flagged_mentions > 0 %}
<a href="{{ url_for('analysis.flagged', content_type='mention', account_did=account.did) }}" class="count-badge count-badge-link">
{{ account.flagged_mentions | format_number }}
<span class="count-total">/ {{ account.scored_mentions | format_number }}</span>
</a>
{% else %}
<span class="count-badge"> <span class="count-badge">
{{ account.flagged_mentions | format_number }} {{ account.flagged_mentions | format_number }}
<span class="count-total">/ {{ account.scored_mentions | format_number }}</span> <span class="count-total">/ {{ account.scored_mentions | format_number }}</span>
</span> </span>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -274,11 +316,78 @@
width: 180px; width: 180px;
} }
.col-max-score {
width: 100px;
text-align: center;
}
.col-count { .col-count {
width: 140px; width: 140px;
text-align: center; text-align: center;
} }
/* Max Score Badge */
.max-score-link {
text-decoration: none;
display: inline-block;
}
.max-score-badge {
display: inline-block;
padding: 0.5rem 0.85rem;
border-radius: 0.375rem;
font-weight: 700;
font-size: 0.95rem;
transition: all 0.2s ease;
cursor: pointer;
}
.max-score-badge.low {
background: rgba(46, 204, 113, 0.2);
color: var(--tox-low);
border: 1px solid rgba(46, 204, 113, 0.4);
}
.max-score-badge.medium {
background: rgba(243, 156, 18, 0.2);
color: var(--tox-medium);
border: 1px solid rgba(243, 156, 18, 0.4);
}
.max-score-badge.high {
background: rgba(231, 76, 60, 0.2);
color: var(--tox-high);
border: 1px solid rgba(231, 76, 60, 0.4);
}
.max-score-link:hover .max-score-badge {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.max-score-link:hover .max-score-badge.low {
background: rgba(46, 204, 113, 0.3);
border-color: var(--tox-low);
}
.max-score-link:hover .max-score-badge.medium {
background: rgba(243, 156, 18, 0.3);
border-color: var(--tox-medium);
}
.max-score-link:hover .max-score-badge.high {
background: rgba(231, 76, 60, 0.3);
border-color: var(--tox-high);
}
.max-score-link:active .max-score-badge {
transform: translateY(0);
}
.text-muted {
color: rgba(255, 255, 255, 0.3);
}
/* Account Info */ /* Account Info */
.account-info { .account-info {
display: flex; display: flex;
@ -381,6 +490,22 @@
font-size: 0.85rem; font-size: 0.85rem;
} }
.count-badge-link {
text-decoration: none;
transition: all 0.2s ease;
cursor: pointer;
}
.count-badge-link:hover {
background: rgba(0, 180, 216, 0.2);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 180, 216, 0.3);
}
.count-badge-link:active {
transform: translateY(0);
}
.count-total { .count-total {
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
font-weight: 400; font-weight: 400;
@ -538,7 +663,7 @@
} }
</style> </style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.7/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const chartData = {{ top_targeted_json | safe }}; const chartData = {{ top_targeted_json | safe }};
@ -548,6 +673,10 @@
const scores = chartData.map(item => item.avg_mention_tox); const scores = chartData.map(item => item.avg_mention_tox);
const flagged = chartData.map(item => item.flagged_mentions); const flagged = chartData.map(item => item.flagged_mentions);
// Calculate dynamic max for better readability
const maxScore = Math.max(...scores);
const dynamicMax = Math.min(1, Math.max(0.1, maxScore * 1.2)); // At least 10%, at most 100%, with 20% padding
const ctx = document.getElementById('toxicity-chart'); const ctx = document.getElementById('toxicity-chart');
if (ctx) { if (ctx) {
new Chart(ctx, { new Chart(ctx, {
@ -580,6 +709,12 @@
display: false display: false
}, },
tooltip: { tooltip: {
backgroundColor: '#0f3460',
titleColor: 'rgba(224, 224, 224, 1)',
bodyColor: 'rgba(224, 224, 224, 0.9)',
borderColor: '#00b4d8',
borderWidth: 1,
padding: 10,
callbacks: { callbacks: {
afterLabel: function(context) { afterLabel: function(context) {
return 'Flagged: ' + flagged[context.dataIndex]; return 'Flagged: ' + flagged[context.dataIndex];
@ -590,7 +725,7 @@
scales: { scales: {
x: { x: {
beginAtZero: true, beginAtZero: true,
max: 1, max: dynamicMax,
ticks: { ticks: {
color: 'rgba(224, 224, 224, 0.7)', color: 'rgba(224, 224, 224, 0.7)',
callback: function(value) { callback: function(value) {
@ -600,6 +735,15 @@
grid: { grid: {
color: 'rgba(255, 255, 255, 0.1)', color: 'rgba(255, 255, 255, 0.1)',
drawBorder: false drawBorder: false
},
title: {
display: true,
text: 'Average Mention Toxicity',
color: 'rgba(224, 224, 224, 0.9)',
font: {
size: 12,
weight: 'bold'
}
} }
}, },
y: { y: {

View file

@ -429,7 +429,7 @@
</div> </div>
<!-- Chart.js Script --> <!-- Chart.js Script -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.7/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script> <script>
// Chart color scheme // Chart color scheme
const chartColors = { const chartColors = {
@ -619,6 +619,29 @@
const categoriesData = {{ categories_json | safe }}; const categoriesData = {{ categories_json | safe }};
const categoryNames = {{ categories | tojson | safe }}; const categoryNames = {{ categories | tojson | safe }};
// Calculate dynamic max for better readability with smart scaling
const categoryValues = categoryNames.map(cat => categoriesData[cat] || 0);
const maxCategoryValue = Math.max(...categoryValues);
// Smart scaling: round up to next nice number with 20% padding
let dynamicMax;
const paddedMax = maxCategoryValue * 1.2;
if (paddedMax <= 0.01) {
dynamicMax = 0.01; // 1%
} else if (paddedMax <= 0.02) {
dynamicMax = 0.02; // 2%
} else if (paddedMax <= 0.05) {
dynamicMax = 0.05; // 5%
} else if (paddedMax <= 0.1) {
dynamicMax = 0.1; // 10%
} else if (paddedMax <= 0.2) {
dynamicMax = 0.2; // 20%
} else if (paddedMax <= 0.5) {
dynamicMax = 0.5; // 50%
} else {
dynamicMax = 1.0; // 100%
}
const categoriesCtx = document.getElementById('categoriesChart').getContext('2d'); const categoriesCtx = document.getElementById('categoriesChart').getContext('2d');
const categoriesChart = new Chart(categoriesCtx, { const categoriesChart = new Chart(categoriesCtx, {
type: 'bar', type: 'bar',
@ -627,7 +650,7 @@
datasets: [ datasets: [
{ {
label: 'Average Toxicity Score', label: 'Average Toxicity Score',
data: categoryNames.map(cat => categoriesData[cat] || 0), data: categoryValues,
backgroundColor: chartColors.categories, backgroundColor: chartColors.categories,
borderColor: chartColors.categories.map(c => c.replace('0.', '1.')), borderColor: chartColors.categories.map(c => c.replace('0.', '1.')),
borderWidth: 1.5, borderWidth: 1.5,
@ -660,7 +683,7 @@
scales: { scales: {
x: { x: {
min: 0, min: 0,
max: 1, max: dynamicMax,
grid: { grid: {
color: chartColors.gridLine, color: chartColors.gridLine,
drawBorder: false drawBorder: false
@ -671,8 +694,15 @@
size: 11 size: 11
}, },
callback: function(value) { callback: function(value) {
// Use more decimal places for small values
if (dynamicMax <= 0.01) {
return (value * 100).toFixed(2) + '%';
} else if (dynamicMax <= 0.1) {
return (value * 100).toFixed(1) + '%';
} else {
return (value * 100).toFixed(0) + '%'; return (value * 100).toFixed(0) + '%';
} }
}
}, },
title: { title: {
display: true, display: true,

View file

@ -684,5 +684,6 @@
<footer class="footer"> <footer class="footer">
<span>Bluesky Collector</span> <span>Bluesky Collector</span>
</footer> </footer>
{% block extra_js %}{% endblock %}
</body> </body>
</html> </html>

View file

@ -48,22 +48,55 @@
<input type="number" id="threshold" name="threshold" min="0.0" max="1.0" step="0.1" value="{{ threshold or 0.5 }}" class="filter-input" placeholder="0.5"> <input type="number" id="threshold" name="threshold" min="0.0" max="1.0" step="0.1" value="{{ threshold or 0.5 }}" class="filter-input" placeholder="0.5">
</div> </div>
<div class="filter-group">
<label for="review-status">Review Status:</label>
<select id="review-status" name="review_status" class="filter-select">
<option value="">All</option>
<option value="unreviewed" {% if review_status == 'unreviewed' %}selected{% endif %}>Unreviewed</option>
<option value="correct" {% if review_status == 'correct' %}selected{% endif %}>✓ Correct</option>
<option value="incorrect" {% if review_status == 'incorrect' %}selected{% endif %}>✗ Incorrect</option>
<option value="unsure" {% if review_status == 'unsure' %}selected{% endif %}>? Unsure</option>
</select>
</div>
<div class="filter-group">
<label for="date-from">From Date:</label>
<input type="date" id="date-from" name="date_from" value="{{ date_from or '' }}" class="filter-input">
</div>
<div class="filter-group">
<label for="date-to">To Date:</label>
<input type="date" id="date-to" name="date_to" value="{{ date_to or '' }}" class="filter-input">
</div>
<button type="submit" class="btn-apply">Apply Filters</button> <button type="submit" class="btn-apply">Apply Filters</button>
</form> </form>
</div> </div>
<!-- Content Table --> <!-- Content Table -->
{% if items %} {% if items %}
{% 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">
{{ label }}
{% if sort == col %}
<span class="sort-icon">{{ '▼' if direction == 'desc' else '▲' }}</span>
{% endif %}
</a>
{% endmacro %}
<div class="table-wrapper"> <div class="table-wrapper">
<table class="flagged-table"> <table class="flagged-table">
<thead> <thead>
<tr> <tr>
<th>Type</th> <th>Type</th>
<th>Author</th> <th>{{ sort_header('author_handle', 'Author') }}</th>
<th>Content</th> <th>Content</th>
<th>Score</th> <th>{{ sort_header('overall', 'Score') }}</th>
<th>Category</th> <th>Category</th>
<th>Created</th> <th>{{ sort_header('created_at', 'Created') }}</th>
<th>Review</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -105,9 +138,17 @@
<a href="{{ url_for('statuses.detail', encoded_uri=encode_uri(item.item_id)) }}" class="content-link"> <a href="{{ url_for('statuses.detail', encoded_uri=encode_uri(item.item_id)) }}" class="content-link">
{{ item.text | truncate_text(200) }} {{ item.text | truncate_text(200) }}
</a> </a>
{% else %}
{# Convert at://did:plc:xxx/app.bsky.feed.post/yyy to https://bsky.app/profile/handle/post/yyy #}
{% set uri_parts = item.item_id.replace('at://', '').split('/') %}
{% if uri_parts|length >= 3 and item.author_handle %}
<a href="https://bsky.app/profile/{{ item.author_handle }}/post/{{ uri_parts[2] }}" target="_blank" rel="noopener" class="content-link">
{{ item.text | truncate_text(200) }}
</a>
{% else %} {% else %}
<span class="content-text">{{ item.text | truncate_text(200) }}</span> <span class="content-text">{{ item.text | truncate_text(200) }}</span>
{% endif %} {% endif %}
{% endif %}
</td> </td>
<!-- Score with Bar --> <!-- Score with Bar -->
@ -141,6 +182,15 @@
{{ item.created_at | time_ago }} {{ item.created_at | time_ago }}
</span> </span>
</td> </td>
<!-- Review Buttons -->
<td class="col-review">
<div class="review-buttons" data-item-id="{{ item.item_id }}" data-source-type="{{ item.source_type }}" data-current-status="{{ item.review_status or '' }}">
<button class="btn-review btn-correct {% if item.review_status == 'correct' %}active{% endif %}" data-status="correct" title="Mark as correctly flagged"></button>
<button class="btn-review btn-incorrect {% if item.review_status == 'incorrect' %}active{% endif %}" data-status="incorrect" title="Mark as incorrectly flagged"></button>
<button class="btn-review btn-unsure {% if item.review_status == 'unsure' %}active{% endif %}" data-status="unsure" title="Mark as unsure">?</button>
</div>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -151,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) }}" 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) }}" 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) }}" 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) }}" 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 %}
@ -325,6 +375,25 @@
white-space: nowrap; white-space: nowrap;
} }
/* Sort Links */
.sort-link {
color: var(--dark-text);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.25rem;
transition: color 0.2s ease;
}
.sort-link:hover {
color: var(--accent-primary);
}
.sort-icon {
font-size: 0.8rem;
opacity: 0.7;
}
.flagged-table td { .flagged-table td {
padding: 1rem; padding: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.05);
@ -360,6 +429,10 @@
width: 120px; width: 120px;
} }
.col-review {
width: 130px;
}
/* Badges */ /* Badges */
.badge { .badge {
display: inline-block; display: inline-block;
@ -478,6 +551,60 @@
color: rgba(255, 255, 255, 0.3); color: rgba(255, 255, 255, 0.3);
} }
/* Review Buttons */
.review-buttons {
display: flex;
gap: 0.25rem;
justify-content: center;
}
.btn-review {
background: var(--dark-bg);
border: 1px solid rgba(255, 255, 255, 0.2);
color: var(--dark-text);
width: 32px;
height: 32px;
border-radius: 0.25rem;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.btn-review:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.4);
}
.btn-correct:hover, .btn-correct.active {
background: var(--tox-low);
border-color: var(--tox-low);
color: var(--dark-bg);
}
.btn-incorrect:hover, .btn-incorrect.active {
background: var(--tox-high);
border-color: var(--tox-high);
color: var(--dark-bg);
}
.btn-unsure:hover, .btn-unsure.active {
background: var(--tox-medium);
border-color: var(--tox-medium);
color: var(--dark-bg);
}
.btn-review.active {
font-weight: 700;
transform: none;
}
.btn-review.active:hover {
opacity: 0.8;
}
/* Empty State */ /* Empty State */
.empty-state { .empty-state {
text-align: center; text-align: center;
@ -592,3 +719,67 @@
} }
</style> </style>
{% endblock %} {% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Handle review button clicks
document.querySelectorAll('.btn-review').forEach(button => {
button.addEventListener('click', async function() {
const buttonContainer = this.parentElement;
const itemId = buttonContainer.dataset.itemId;
const sourceType = buttonContainer.dataset.sourceType;
const currentStatus = buttonContainer.dataset.currentStatus;
const clickedStatus = this.dataset.status;
// If clicking the same status, do nothing (it's already set)
if (currentStatus === clickedStatus) {
return;
}
// Disable all buttons while processing
buttonContainer.querySelectorAll('.btn-review').forEach(btn => btn.disabled = true);
try {
const response = await fetch('/api/review/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
item_id: itemId,
source_type: sourceType,
review_status: clickedStatus,
}),
});
if (response.ok) {
// Update current status
buttonContainer.dataset.currentStatus = clickedStatus;
// Remove active class from all buttons
buttonContainer.querySelectorAll('.btn-review').forEach(btn => {
btn.classList.remove('active');
});
// Add active class to clicked button
this.classList.add('active');
// Re-enable all buttons
buttonContainer.querySelectorAll('.btn-review').forEach(btn => btn.disabled = false);
} else {
// Re-enable buttons on error
buttonContainer.querySelectorAll('.btn-review').forEach(btn => btn.disabled = false);
alert('Failed to submit review. Please try again.');
}
} catch (error) {
// Re-enable buttons on error
buttonContainer.querySelectorAll('.btn-review').forEach(btn => btn.disabled = false);
console.error('Error submitting review:', error);
alert('Failed to submit review. Please try again.');
}
});
});
});
</script>
{% endblock %}