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:
parent
b1fd78e0c1
commit
0495f47c13
15 changed files with 557 additions and 24 deletions
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(docker compose:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
10
scripts/03-mentions-engagement.sql
Normal file
10
scripts/03-mentions-engagement.sql
Normal 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);
|
||||
20
scripts/04-human-review.sql
Normal file
20
scripts/04-human-review.sql
Normal 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);
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
16
src/db.py
16
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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
50
src/web/routes/review.py
Normal file
50
src/web/routes/review.py
Normal 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
|
||||
|
|
@ -44,8 +44,10 @@
|
|||
<tr>
|
||||
<th>{{ sort_header('handle', 'Account') }}</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('mention_tox', 'Avg Mention Toxicity') }}</th>
|
||||
<th>Max Mention Toxicity</th>
|
||||
<th>{{ sort_header('flagged_mentions', 'Flagged Mentions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -80,12 +82,32 @@
|
|||
</div>
|
||||
</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 -->
|
||||
<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">
|
||||
{{ account.flagged_posts | format_number }}
|
||||
<span class="count-total">/ {{ account.scored_posts | format_number }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Avg Mention Toxicity -->
|
||||
|
|
@ -104,12 +126,32 @@
|
|||
</div>
|
||||
</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 -->
|
||||
<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">
|
||||
{{ account.flagged_mentions | format_number }}
|
||||
<span class="count-total">/ {{ account.scored_mentions | format_number }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% 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 @@
|
|||
}
|
||||
</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>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const chartData = {{ top_targeted_json | safe }};
|
||||
|
|
@ -548,6 +673,10 @@
|
|||
const scores = chartData.map(item => item.avg_mention_tox);
|
||||
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');
|
||||
if (ctx) {
|
||||
new Chart(ctx, {
|
||||
|
|
@ -580,6 +709,12 @@
|
|||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#0f3460',
|
||||
titleColor: 'rgba(224, 224, 224, 1)',
|
||||
bodyColor: 'rgba(224, 224, 224, 0.9)',
|
||||
borderColor: '#00b4d8',
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
afterLabel: function(context) {
|
||||
return 'Flagged: ' + flagged[context.dataIndex];
|
||||
|
|
@ -590,7 +725,7 @@
|
|||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
max: 1,
|
||||
max: dynamicMax,
|
||||
ticks: {
|
||||
color: 'rgba(224, 224, 224, 0.7)',
|
||||
callback: function(value) {
|
||||
|
|
@ -600,6 +735,15 @@
|
|||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)',
|
||||
drawBorder: false
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Average Mention Toxicity',
|
||||
color: 'rgba(224, 224, 224, 0.9)',
|
||||
font: {
|
||||
size: 12,
|
||||
weight: 'bold'
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
|
|
|
|||
|
|
@ -429,7 +429,7 @@
|
|||
</div>
|
||||
|
||||
<!-- 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>
|
||||
// Chart color scheme
|
||||
const chartColors = {
|
||||
|
|
@ -619,6 +619,29 @@
|
|||
const categoriesData = {{ categories_json | 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 categoriesChart = new Chart(categoriesCtx, {
|
||||
type: 'bar',
|
||||
|
|
@ -627,7 +650,7 @@
|
|||
datasets: [
|
||||
{
|
||||
label: 'Average Toxicity Score',
|
||||
data: categoryNames.map(cat => categoriesData[cat] || 0),
|
||||
data: categoryValues,
|
||||
backgroundColor: chartColors.categories,
|
||||
borderColor: chartColors.categories.map(c => c.replace('0.', '1.')),
|
||||
borderWidth: 1.5,
|
||||
|
|
@ -660,7 +683,7 @@
|
|||
scales: {
|
||||
x: {
|
||||
min: 0,
|
||||
max: 1,
|
||||
max: dynamicMax,
|
||||
grid: {
|
||||
color: chartColors.gridLine,
|
||||
drawBorder: false
|
||||
|
|
@ -671,8 +694,15 @@
|
|||
size: 11
|
||||
},
|
||||
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) + '%';
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
|
|
|
|||
|
|
@ -684,5 +684,6 @@
|
|||
<footer class="footer">
|
||||
<span>Bluesky Collector</span>
|
||||
</footer>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Content Table -->
|
||||
{% 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">
|
||||
<table class="flagged-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Author</th>
|
||||
<th>{{ sort_header('author_handle', 'Author') }}</th>
|
||||
<th>Content</th>
|
||||
<th>Score</th>
|
||||
<th>{{ sort_header('overall', 'Score') }}</th>
|
||||
<th>Category</th>
|
||||
<th>Created</th>
|
||||
<th>{{ sort_header('created_at', 'Created') }}</th>
|
||||
<th>Review</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -105,9 +138,17 @@
|
|||
<a href="{{ url_for('statuses.detail', encoded_uri=encode_uri(item.item_id)) }}" class="content-link">
|
||||
{{ item.text | truncate_text(200) }}
|
||||
</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 %}
|
||||
<span class="content-text">{{ item.text | truncate_text(200) }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Score with Bar -->
|
||||
|
|
@ -141,6 +182,15 @@
|
|||
{{ item.created_at | time_ago }}
|
||||
</span>
|
||||
</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>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
@ -151,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) }}" 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=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) }}" 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=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 %}
|
||||
|
|
@ -325,6 +375,25 @@
|
|||
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 {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
|
|
@ -360,6 +429,10 @@
|
|||
width: 120px;
|
||||
}
|
||||
|
||||
.col-review {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
|
|
@ -478,6 +551,60 @@
|
|||
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 {
|
||||
text-align: center;
|
||||
|
|
@ -592,3 +719,67 @@
|
|||
}
|
||||
</style>
|
||||
{% 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 %}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue