From 0495f47c139750d07eef9a9ab21a5b2e18dbd3e8 Mon Sep 17 00:00:00 2001 From: Pieter Date: Mon, 30 Mar 2026 14:13:14 +0200 Subject: [PATCH] Add human review feature and enhance data collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/settings.local.json | 9 ++ config/accounts.yml | 2 +- scripts/03-mentions-engagement.sql | 10 ++ scripts/04-human-review.sql | 20 +++ src/bluesky_client.py | 4 + src/db.py | 16 +- src/models.py | 4 + src/web/app.py | 2 + src/web/db.py | 51 +++++- src/web/routes/analysis.py | 19 ++- src/web/routes/review.py | 50 ++++++ src/web/templates/account_toxicity.html | 148 ++++++++++++++++- src/web/templates/analysis.html | 38 ++++- src/web/templates/base.html | 1 + src/web/templates/flagged.html | 207 +++++++++++++++++++++++- 15 files changed, 557 insertions(+), 24 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 scripts/03-mentions-engagement.sql create mode 100644 scripts/04-human-review.sql create mode 100644 src/web/routes/review.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..f264340 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(docker compose:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/config/accounts.yml b/config/accounts.yml index ac2fae7..9c6ad12 100644 --- a/config/accounts.yml +++ b/config/accounts.yml @@ -99,7 +99,7 @@ accounts: - handle: marietbosman62.bsky.social # Mariet Bosman (BBB) - handle: natalienauta.bsky.social # Natalie Nauta (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: djhvandijk.bsky.social # Diederik van Dijk (SGP) - handle: carladikfaber.bsky.social # Carla Dik-Faber (ChristenUnie) diff --git a/scripts/03-mentions-engagement.sql b/scripts/03-mentions-engagement.sql new file mode 100644 index 0000000..792de0c --- /dev/null +++ b/scripts/03-mentions-engagement.sql @@ -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); diff --git a/scripts/04-human-review.sql b/scripts/04-human-review.sql new file mode 100644 index 0000000..a570dd1 --- /dev/null +++ b/scripts/04-human-review.sql @@ -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); diff --git a/src/bluesky_client.py b/src/bluesky_client.py index f34cab5..a33cda6 100644 --- a/src/bluesky_client.py +++ b/src/bluesky_client.py @@ -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"), post_text=record.get("text"), 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, ) diff --git a/src/db.py b/src/db.py index bb4e93b..4cacf5a 100644 --- a/src/db.py +++ b/src/db.py @@ -136,15 +136,25 @@ class Database: """ INSERT INTO mentions ( post_uri, mentioned_did, mentioning_did, - post_text, post_created_at, raw_json - ) VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (post_uri, mentioned_did) DO NOTHING + post_text, post_created_at, + like_count, reply_count, repost_count, quote_count, + 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.mentioned_did, m.mentioning_did, m.post_text, m.post_created_at, + m.like_count, + m.reply_count, + m.repost_count, + m.quote_count, json.dumps(m.raw_json), ) if "INSERT 0 1" in result: diff --git a/src/models.py b/src/models.py index 18b7bc4..79bf6fd 100644 --- a/src/models.py +++ b/src/models.py @@ -46,6 +46,10 @@ class Mention: mentioning_did: str | None post_text: str | 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) diff --git a/src/web/app.py b/src/web/app.py index 92a9e66..8454a0b 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -48,6 +48,7 @@ def create_app() -> Flask: from .routes.mentions import bp as mentions_bp from .routes.export import bp as export_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(accounts_bp) @@ -55,6 +56,7 @@ def create_app() -> Flask: app.register_blueprint(mentions_bp) app.register_blueprint(export_bp) app.register_blueprint(analysis_bp) + app.register_blueprint(review_bp) # Teardown @app.teardown_appcontext diff --git a/src/web/db.py b/src/web/db.py index 8be79ce..468504f 100644 --- a/src/web/db.py +++ b/src/web/db.py @@ -495,6 +495,11 @@ def get_flagged_content( category: str | None = None, account_did: str | None = None, 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, offset: int = 0, ) -> tuple[list[dict], int]: @@ -522,6 +527,29 @@ def get_flagged_content( mention_conditions += " AND m.mentioned_did = %s" 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_mention = "" if content_type == "mention": @@ -533,6 +561,14 @@ def get_flagged_content( elif content_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: # Count cur.execute(f""" @@ -564,7 +600,8 @@ def get_flagged_content( ts.overall, ts.toxic, ts.threat, ts.hate_speech, ts.racism, ts.antisemitism, ts.islamophobia, 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 JOIN posts p ON p.uri = ts.uri 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_text AS text, m.mentioning_did AS author_did, - NULL AS author_handle, + COALESCE(aa.handle, m.raw_json->'author'->>'handle') AS author_handle, m.mentioned_did, ma.handle AS mentioned_handle, m.post_created_at AS created_at, mts.overall, mts.toxic, mts.threat, mts.hate_speech, mts.racism, mts.antisemitism, mts.islamophobia, 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 JOIN mentions m ON m.id = mts.mention_id 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} ) combined - ORDER BY overall DESC, created_at DESC + {order_by} LIMIT %s OFFSET %s """, params_posts + params_mentions + [limit, offset]) rows = [dict(r) for r in cur.fetchall()] @@ -636,15 +675,18 @@ def get_account_toxicity_summary( SELECT a.did, a.handle, a.display_name, 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.total, 0) AS scored_posts, 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.total, 0) AS scored_mentions FROM accounts a LEFT JOIN ( SELECT p.author_did, avg(ts.overall) AS avg_tox, + max(ts.overall) AS max_tox, count(*) FILTER (WHERE ts.flagged) AS flagged, count(*) AS total FROM toxicity_scores ts @@ -654,6 +696,7 @@ def get_account_toxicity_summary( LEFT JOIN ( SELECT m.mentioned_did, avg(mts.overall) AS avg_tox, + max(mts.overall) AS max_tox, count(*) FILTER (WHERE mts.flagged) AS flagged, count(*) AS total FROM mention_toxicity_scores mts diff --git a/src/web/routes/analysis.py b/src/web/routes/analysis.py index f1a496c..46e2174 100644 --- a/src/web/routes/analysis.py +++ b/src/web/routes/analysis.py @@ -55,10 +55,15 @@ def index(): @bp.route("/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 - 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) + 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)) per_page = 50 @@ -67,6 +72,11 @@ def flagged(): category=category, account_did=account_did, threshold=threshold, + review_status=review_status, + date_from=date_from, + date_to=date_to, + sort=sort, + direction=direction, limit=per_page, offset=(page - 1) * per_page, ) @@ -85,6 +95,11 @@ def flagged(): category=category or "", account_did=account_did or "", threshold=threshold, + review_status=review_status or "", + date_from=date_from or "", + date_to=date_to or "", + sort=sort, + direction=direction, ) diff --git a/src/web/routes/review.py b/src/web/routes/review.py new file mode 100644 index 0000000..f3fa373 --- /dev/null +++ b/src/web/routes/review.py @@ -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 diff --git a/src/web/templates/account_toxicity.html b/src/web/templates/account_toxicity.html index bb3d659..260b03f 100644 --- a/src/web/templates/account_toxicity.html +++ b/src/web/templates/account_toxicity.html @@ -44,8 +44,10 @@ {{ sort_header('handle', 'Account') }} {{ sort_header('post_tox', 'Avg Post Toxicity') }} + Max Post Toxicity {{ sort_header('flagged_posts', 'Flagged Posts') }} {{ sort_header('mention_tox', 'Avg Mention Toxicity') }} + Max Mention Toxicity {{ sort_header('flagged_mentions', 'Flagged Mentions') }} @@ -80,12 +82,32 @@ + + + {% if account.max_post_tox > 0 %} + +
+ {{ "%.2f" | format(account.max_post_tox) }} +
+
+ {% else %} + + {% endif %} + + + {% if account.flagged_posts > 0 %} + + {{ account.flagged_posts | format_number }} + / {{ account.scored_posts | format_number }} + + {% else %} {{ account.flagged_posts | format_number }} / {{ account.scored_posts | format_number }} + {% endif %} @@ -104,12 +126,32 @@ + + + {% if account.max_mention_tox > 0 %} + +
+ {{ "%.2f" | format(account.max_mention_tox) }} +
+
+ {% else %} + + {% endif %} + + + {% if account.flagged_mentions > 0 %} + + {{ account.flagged_mentions | format_number }} + / {{ account.scored_mentions | format_number }} + + {% else %} {{ account.flagged_mentions | format_number }} / {{ account.scored_mentions | format_number }} + {% endif %} {% endfor %} @@ -274,11 +316,78 @@ width: 180px; } + .col-max-score { + width: 100px; + text-align: center; + } + .col-count { width: 140px; 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 { display: flex; @@ -381,6 +490,22 @@ 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 { color: rgba(255, 255, 255, 0.5); font-weight: 400; @@ -538,7 +663,7 @@ } - + + +{% endblock %}