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: 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)
|
||||||
|
|
|
||||||
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"),
|
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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
16
src/db.py
16
src/db.py
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
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>
|
<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: {
|
||||||
|
|
|
||||||
|
|
@ -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,7 +694,14 @@
|
||||||
size: 11
|
size: 11
|
||||||
},
|
},
|
||||||
callback: function(value) {
|
callback: function(value) {
|
||||||
return (value * 100).toFixed(0) + '%';
|
// 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: {
|
title: {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -106,7 +139,15 @@
|
||||||
{{ item.text | truncate_text(200) }}
|
{{ item.text | truncate_text(200) }}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="content-text">{{ item.text | truncate_text(200) }}</span>
|
{# 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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue