diff --git a/backend/main.py b/backend/main.py index fda3196..c981dad 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,7 +9,7 @@ from core.rate_limit import limiter from database import get_db -from routers import annotate, auth, clean, collect, data, review, seed, users +from routers import annotate, auth, clean, collect, dashboard, data, review, seed, users # Em produção, definir CORS_ORIGINS no Vercel Dashboard: # CORS_ORIGINS=https://seu-frontend.vercel.app @@ -45,6 +45,7 @@ async def rate_limit_handler(request: Request, exc: RateLimitExceeded): app.include_router(collect.router) app.include_router(clean.router) app.include_router(data.router) +app.include_router(dashboard.router) app.include_router(annotate.router) app.include_router(review.router) app.include_router(seed.router) diff --git a/backend/requirements.txt b/backend/requirements.txt index 3ec92bd..d1b376d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,3 +16,4 @@ pytest-mock==3.14.0 ruff==0.6.9 bandit==1.9.4 pip-audit==2.10.0 +plotly==5.24.1 diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py new file mode 100644 index 0000000..b225b28 --- /dev/null +++ b/backend/routers/dashboard.py @@ -0,0 +1,96 @@ +"""Router da US-06 — Dashboard de Análise.""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from database import get_db +from models.user import User +from schemas.dashboard import ( + BotCommentsResponse, + CriteriaEffectivenessItem, + GlobalDashboardResponse, + UserDashboardResponse, + VideoDashboardResponse, +) +from services.auth import get_current_user +from services.dashboard import ( + get_bot_comments, + get_criteria_effectiveness, + get_global_dashboard, + get_user_dashboard, + get_video_dashboard, +) + +router = APIRouter(prefix="/dashboard", tags=["dashboard"]) + + +@router.get("/global", response_model=GlobalDashboardResponse) +def global_endpoint( + criteria: str | None = Query(default=None), + db: Session = Depends(get_db), + _user: User = Depends(get_current_user), +): + criteria_list = ( + [c.strip() for c in criteria.split(",") if c.strip()] if criteria else None + ) + return get_global_dashboard(db, criteria=criteria_list) + + +@router.get( + "/criteria-effectiveness", + response_model=list[CriteriaEffectivenessItem], +) +def criteria_effectiveness_endpoint( + video_id: str | None = Query(default=None), + db: Session = Depends(get_db), + _user: User = Depends(get_current_user), +): + return get_criteria_effectiveness(db, video_id=video_id) + + +@router.get("/video", response_model=VideoDashboardResponse) +def video_endpoint( + video_id: str = Query(), + criteria: str | None = Query(default=None), + db: Session = Depends(get_db), + _user: User = Depends(get_current_user), +): + criteria_list = ( + [c.strip() for c in criteria.split(",") if c.strip()] if criteria else None + ) + return get_video_dashboard(db, video_id=video_id, criteria=criteria_list) + + +@router.get("/user", response_model=UserDashboardResponse) +def user_endpoint( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return get_user_dashboard(db, user_id=current_user.id) + + +@router.get("/bots", response_model=BotCommentsResponse) +def bots_endpoint( + dataset_id: str | None = Query(default=None), + video_id: str | None = Query(default=None), + author: str | None = Query(default=None), + search: str | None = Query(default=None), + criteria: str | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + db: Session = Depends(get_db), + _user: User = Depends(get_current_user), +): + criteria_list = ( + [c.strip() for c in criteria.split(",") if c.strip()] if criteria else None + ) + return get_bot_comments( + db, + dataset_id=dataset_id, + video_id=video_id, + author=author, + search=search, + criteria_filter=criteria_list, + page=page, + page_size=page_size, + ) diff --git a/backend/schemas/dashboard.py b/backend/schemas/dashboard.py new file mode 100644 index 0000000..92c72f7 --- /dev/null +++ b/backend/schemas/dashboard.py @@ -0,0 +1,126 @@ +"""Schemas Pydantic da US-06 — Dashboard de Análise.""" + +import uuid + +from pydantic import BaseModel + +# ── Visão Geral (Global) ─────────────────────────────────────────── + + +class GlobalSummary(BaseModel): + total_datasets: int + total_comments_annotated: int + total_comments_in_datasets: int + annotation_progress: float + total_bots: int + total_humans: int + total_conflicts: int + pending_conflicts: int + agreement_rate: float + + +class GlobalDashboardResponse(BaseModel): + summary: GlobalSummary + active_criteria_filter: list[str] + label_distribution_chart: str + comparativo_por_dataset_chart: str + annotations_over_time_chart: str + bot_rate_by_dataset_chart: str + agreement_by_dataset_chart: str + criteria_effectiveness_chart: str + + +# ── Eficácia por Critério ────────────────────────────────────────── + + +class CriteriaEffectivenessItem(BaseModel): + criteria: str + group: str + total_datasets: int + total_comments_selected: int + total_bots: int + bot_rate: float + + +# ── Por Vídeo ────────────────────────────────────────────────────── + + +class VideoSummary(BaseModel): + total_comments_collected: int + total_comments_in_datasets: int + total_annotated: int + total_bots: int + total_humans: int + total_conflicts: int + pending_conflicts: int + agreement_rate: float + + +class VideoHighlight(BaseModel): + label: str + value: str + detail: str | None = None + + +class VideoDashboardResponse(BaseModel): + video_id: str + summary: VideoSummary + highlights: list[VideoHighlight] + active_criteria_filter: list[str] + label_distribution_chart: str + comparativo_por_dataset_chart: str + bot_rate_by_criteria_chart: str + comment_timeline_chart: str + + +# ── Meu Progresso ────────────────────────────────────────────────── + + +class UserSummary(BaseModel): + total_datasets_assigned: int + datasets_completed: int + datasets_pending: int + total_annotated: int + total_pending: int + bots: int + humans: int + conflicts_generated: int + + +class UserDatasetProgress(BaseModel): + dataset_id: uuid.UUID + dataset_name: str + video_id: str + total_comments: int + annotated_by_me: int + pending: int + percent_complete: float + my_bots: int + my_conflicts: int + status: str + + +class UserDashboardResponse(BaseModel): + summary: UserSummary + datasets: list[UserDatasetProgress] + my_label_distribution_chart: str + my_progress_by_dataset_chart: str + my_annotations_over_time_chart: str + + +# ── Tabela de Bots ───────────────────────────────────────────────── + + +class BotCommentItem(BaseModel): + dataset_name: str + author_display_name: str + text_original: str + concordance_pct: int + conflict_status: str | None = None + annotators_count: int = 0 + criteria: list[str] = [] + + +class BotCommentsResponse(BaseModel): + total: int + items: list[BotCommentItem] diff --git a/backend/services/dashboard.py b/backend/services/dashboard.py new file mode 100644 index 0000000..b914611 --- /dev/null +++ b/backend/services/dashboard.py @@ -0,0 +1,1347 @@ +"""Serviço da US-06 — Dashboard de Análise. + +Agregações SQL + geração de gráficos Plotly (JSON). +Regra: nunca carregar registros em Python para calcular — +usar func.count, func.avg, GROUP BY no SQLAlchemy. +""" + +import logging +import uuid +from collections import defaultdict + +import plotly.graph_objects as go +import plotly.io as pio +from sqlalchemy import Date, case, cast, func +from sqlalchemy.orm import Session + +from models.annotation import Annotation, AnnotationConflict +from models.collection import Collection, Comment +from models.dataset import Dataset, DatasetEntry + +logger = logging.getLogger(__name__) + +COLORS = { + "humano": "#10b981", + "bot": "#ef4444", + "conflito": "#f59e0b", + "indigo": "#6366f1", + "slate": "#64748b", + "teal": "#14b8a6", + "sky": "#0ea5e9", +} + +# Layout base compartilhado por todos os gráficos +_BASE_LAYOUT = { + "font": {"family": "Inter, system-ui, sans-serif", "size": 12}, + "paper_bgcolor": "rgba(0,0,0,0)", + "plot_bgcolor": "rgba(0,0,0,0)", + "margin": {"t": 20, "b": 40, "l": 50, "r": 20}, + "showlegend": True, + "legend": { + "orientation": "h", + "yanchor": "bottom", + "y": 1.02, + "xanchor": "center", + "x": 0.5, + "font": {"size": 11}, + }, +} + +CRITERIA_GROUPS = { + "percentil": "numerico", + "media": "numerico", + "moda": "numerico", + "mediana": "numerico", + "curtos": "comportamental", + "intervalo": "comportamental", + "identicos": "comportamental", + "perfil": "comportamental", +} + + +# ═══════════════════════════════════════════════════════════════════ +# Helpers — batch loading reutilizável +# ═══════════════════════════════════════════════════════════════════ + + +def _get_datasets_filtered( + db: Session, criteria: list[str] | None, video_id: str | None = None +) -> list[Dataset]: + """Retorna datasets, opcionalmente filtrados por critério e/ou vídeo.""" + q = db.query(Dataset) + if video_id: + q = q.join(Collection, Dataset.collection_id == Collection.id).filter( + Collection.video_id == video_id + ) + datasets = q.order_by(Dataset.created_at.desc()).all() + + if criteria: + datasets = [ + ds + for ds in datasets + if all(c in (ds.criteria_applied or []) for c in criteria) + ] + return datasets + + +def _get_comment_ids_for_datasets( + db: Session, datasets: list[Dataset] +) -> tuple[ + dict[uuid.UUID, list[uuid.UUID]], + dict[uuid.UUID, tuple[uuid.UUID, str]], +]: + """Retorna (ds_id → [comment_ids], comment_id → (collection_id, author_channel_id)). + + Batch loading sem N+1. + """ + if not datasets: + return {}, {} + + ds_ids = [ds.id for ds in datasets] + + # entries → authors por dataset + all_entries = ( + db.query(DatasetEntry.dataset_id, DatasetEntry.author_channel_id) + .filter(DatasetEntry.dataset_id.in_(ds_ids)) + .all() + ) + authors_by_ds: dict[uuid.UUID, list[str]] = defaultdict(list) + for dataset_id, author_channel_id in all_entries: + authors_by_ds[dataset_id].append(author_channel_id) + + # pares (collection_id, author_ids) para buscar comentários + ds_col_map = {ds.id: ds.collection_id for ds in datasets} + col_authors: dict[uuid.UUID, set[str]] = defaultdict(set) + for ds in datasets: + for author_id in authors_by_ds.get(ds.id, []): + col_authors[ds.collection_id].add(author_id) + + # batch: todos os comentários relevantes + comment_info: dict[uuid.UUID, tuple[uuid.UUID, str]] = {} + comments_by_col_author: dict[tuple[uuid.UUID, str], list[uuid.UUID]] = defaultdict( + list + ) + + for col_id, author_set in col_authors.items(): + if not author_set: + continue + rows = ( + db.query(Comment.id, Comment.collection_id, Comment.author_channel_id) + .filter( + Comment.collection_id == col_id, + Comment.author_channel_id.in_(list(author_set)), + ) + .all() + ) + for cid, c_col_id, c_author_id in rows: + comment_info[cid] = (c_col_id, c_author_id) + comments_by_col_author[(c_col_id, c_author_id)].append(cid) + + # montar comment_ids por dataset + ds_comment_ids: dict[uuid.UUID, list[uuid.UUID]] = {} + for ds in datasets: + ids: list[uuid.UUID] = [] + for author_id in authors_by_ds.get(ds.id, []): + ids.extend(comments_by_col_author.get((ds_col_map[ds.id], author_id), [])) + ds_comment_ids[ds.id] = ids + + return ds_comment_ids, comment_info + + +def _get_annotations_and_conflicts( + db: Session, all_comment_ids: list[uuid.UUID] +) -> tuple[ + dict[uuid.UUID, list[tuple[uuid.UUID, str]]], + dict[uuid.UUID, tuple[str, str | None]], +]: + """Retorna anotações e conflitos por comment_id.""" + anns_by_comment: dict[uuid.UUID, list[tuple[uuid.UUID, str]]] = defaultdict(list) + conflict_map: dict[uuid.UUID, tuple[str, str | None]] = {} + + if not all_comment_ids: + return anns_by_comment, conflict_map + + all_annotations = ( + db.query(Annotation.comment_id, Annotation.annotator_id, Annotation.label) + .filter(Annotation.comment_id.in_(all_comment_ids)) + .all() + ) + for comment_id, annotator_id, label in all_annotations: + anns_by_comment[comment_id].append((annotator_id, label)) + + all_conflicts = ( + db.query( + AnnotationConflict.comment_id, + AnnotationConflict.status, + AnnotationConflict.resolved_label, + ) + .filter(AnnotationConflict.comment_id.in_(all_comment_ids)) + .all() + ) + for comment_id, status, resolved_label in all_conflicts: + conflict_map[comment_id] = (status, resolved_label) + + return anns_by_comment, conflict_map + + +def _classify_comment( + cid: uuid.UUID, + anns_by_comment: dict[uuid.UUID, list[tuple[uuid.UUID, str]]], + conflict_map: dict[uuid.UUID, tuple[str, str | None]], +) -> str | None: + """Classifica um comentário: 'bot', 'humano', 'conflito' ou None (sem anotação).""" + anns = anns_by_comment.get(cid, []) + if not anns: + return None + + if cid in conflict_map: + status, resolved_label = conflict_map[cid] + if status == "resolved" and resolved_label: + return resolved_label + return "conflito" + + labels = {label for _, label in anns} + if len(labels) == 1: + return labels.pop() + return None + + +def _compute_agreement_rate( + comment_ids: list[uuid.UUID], + anns_by_comment: dict[uuid.UUID, list[tuple[uuid.UUID, str]]], +) -> float: + """Agreement rate = consenso / total com 2+ anotações.""" + with_two = 0 + consensus = 0 + for cid in comment_ids: + anns = anns_by_comment.get(cid, []) + if len(anns) >= 2: + with_two += 1 + labels = {label for _, label in anns} + if len(labels) == 1: + consensus += 1 + if with_two == 0: + return 0.0 + return round(consensus / with_two, 4) + + +# ═══════════════════════════════════════════════════════════════════ +# Gráficos Plotly +# ═══════════════════════════════════════════════════════════════════ + + +def _layout(**overrides) -> dict: + """Mescla layout base com overrides específicos do gráfico.""" + layout = {**_BASE_LAYOUT} + for key, val in overrides.items(): + if isinstance(val, dict) and key in layout and isinstance(layout[key], dict): + layout[key] = {**layout[key], **val} + else: + layout[key] = val + return layout + + +def _make_donut_chart(bots: int, humans: int, conflicts: int) -> str: + total = bots + humans + conflicts + fig = go.Figure( + go.Pie( + labels=["Humano", "Bot", "Conflito"], + values=[humans, bots, conflicts], + hole=0.55, + marker_colors=[ + COLORS["humano"], + COLORS["bot"], + COLORS["conflito"], + ], + textinfo="label+percent", + textfont_size=12, + hovertemplate=( + "%{label}
" + "%{value} comentários (%{percent})" + "" + ), + pull=[0, 0.03, 0], + ) + ) + fig.update_layout( + **_layout( + annotations=[ + { + "text": f"{total}", + "x": 0.5, + "y": 0.5, + "font_size": 28, + "font_color": "#1e293b", + "showarrow": False, + } + ], + margin={"t": 10, "b": 10, "l": 10, "r": 10}, + ) + ) + return pio.to_json(fig, validate=False) + + +def _make_comparativo_chart(datasets_data: list[dict]) -> str: + names = [d["name"] for d in datasets_data] + fig = go.Figure( + data=[ + go.Bar( + name="Humano", + x=names, + y=[d["humans"] for d in datasets_data], + marker_color=COLORS["humano"], + marker_line_width=0, + ), + go.Bar( + name="Bot", + x=names, + y=[d["bots"] for d in datasets_data], + marker_color=COLORS["bot"], + marker_line_width=0, + ), + go.Bar( + name="Conflito", + x=names, + y=[d["conflicts"] for d in datasets_data], + marker_color=COLORS["conflito"], + marker_line_width=0, + ), + ] + ) + fig.update_layout( + **_layout( + barmode="group", + bargap=0.25, + bargroupgap=0.1, + xaxis={ + "tickangle": -20, + "tickfont": {"size": 10}, + "showgrid": False, + }, + yaxis={ + "gridcolor": "#f1f5f9", + "title": {"text": "Comentários", "font": {"size": 11}}, + }, + ) + ) + return pio.to_json(fig, validate=False) + + +def _make_timeline_chart( + buckets: list[dict], title: str = "Evolução das Anotações" +) -> str: + fig = go.Figure( + go.Scatter( + x=[b["date"] for b in buckets], + y=[b["count"] for b in buckets], + mode="lines+markers", + line={"color": COLORS["indigo"], "width": 2.5}, + marker={"size": 7, "color": COLORS["indigo"]}, + fill="tozeroy", + fillcolor="rgba(99,102,241,0.08)", + hovertemplate=("%{x|%d/%m/%Y}
" "%{y} anotações"), + ) + ) + fig.update_layout( + **_layout( + showlegend=False, + xaxis={"showgrid": False}, + yaxis={ + "gridcolor": "#f1f5f9", + "title": {"text": "Anotações", "font": {"size": 11}}, + }, + ) + ) + return pio.to_json(fig, validate=False) + + +def _make_bot_rate_chart(datasets_data: list[dict], orientation: str = "h") -> str: + names = [d["name"] for d in datasets_data] + rates = [d["bot_rate"] for d in datasets_data] + colors = [COLORS["bot"] if r > 10 else COLORS["teal"] for r in rates] + + if orientation == "h": + fig = go.Figure( + go.Bar( + y=names, + x=rates, + orientation="h", + marker_color=colors, + marker_line_width=0, + text=[f"{r:.1f}%" for r in rates], + textposition="outside", + textfont={"size": 10}, + hovertemplate=("%{y}
" "Taxa: %{x:.1f}%"), + ) + ) + fig.update_layout( + **_layout( + showlegend=False, + xaxis={ + "showgrid": False, + "title": {"text": "% de Bots", "font": {"size": 11}}, + }, + yaxis={"tickfont": {"size": 10}}, + margin={"l": 100}, + ) + ) + else: + fig = go.Figure( + go.Bar( + x=names, + y=rates, + marker_color=colors, + marker_line_width=0, + text=[f"{r:.1f}%" for r in rates], + textposition="outside", + textfont={"size": 10}, + hovertemplate=("%{x}
" "Taxa: %{y:.1f}%"), + ) + ) + fig.update_layout( + **_layout( + showlegend=False, + yaxis={ + "gridcolor": "#f1f5f9", + "title": {"text": "% de Bots", "font": {"size": 11}}, + }, + ) + ) + return pio.to_json(fig, validate=False) + + +def _make_criteria_effectiveness_chart(data: list[dict]) -> str: + """Bar horizontal simples: taxa de bots (%) por critério.""" + criterios = [d["criteria"].capitalize() for d in data] + rates = [round(d["bot_rate"] * 100, 1) for d in data] + colors = [COLORS["bot"] if r > 10 else COLORS["teal"] for r in rates] + + fig = go.Figure( + go.Bar( + y=criterios, + x=rates, + orientation="h", + marker_color=colors, + marker_line_width=0, + text=[f"{r:.1f}%" for r in rates], + textposition="outside", + textfont={"size": 10}, + hovertemplate=("%{y}
" "Taxa de bots: %{x:.1f}%"), + ) + ) + fig.update_layout( + **_layout( + showlegend=False, + xaxis={ + "showgrid": False, + "title": { + "text": "Taxa de bots (%)", + "font": {"size": 11}, + }, + }, + yaxis={"tickfont": {"size": 11}}, + margin={"l": 90}, + ) + ) + return pio.to_json(fig, validate=False) + + +def _make_agreement_by_dataset_chart( + datasets_data: list[dict], +) -> str: + """Bar horizontal: concordância (%) por dataset.""" + names = [d["name"] for d in datasets_data] + rates = [d["agreement_rate"] for d in datasets_data] + colors = [ + COLORS["humano"] + if r >= 80 + else COLORS["conflito"] + if r >= 50 + else COLORS["bot"] + for r in rates + ] + + fig = go.Figure( + go.Bar( + y=names, + x=rates, + orientation="h", + marker_color=colors, + marker_line_width=0, + text=[f"{r:.0f}%" for r in rates], + textposition="outside", + textfont={"size": 10}, + hovertemplate=("%{y}
" "Concordância: %{x:.1f}%"), + ) + ) + fig.update_layout( + **_layout( + showlegend=False, + xaxis={ + "range": [0, 110], + "showgrid": False, + "title": { + "text": "Concordância (%)", + "font": {"size": 11}, + }, + }, + yaxis={"tickfont": {"size": 10}}, + margin={"l": 100}, + ) + ) + return pio.to_json(fig, validate=False) + + +def _make_comment_timeline_chart(buckets: list[dict]) -> str: + fig = go.Figure( + go.Bar( + x=[b["date"] for b in buckets], + y=[b["count"] for b in buckets], + marker_color=COLORS["teal"], + marker_line_width=0, + hovertemplate=( + "%{x|%d/%m/%Y}
" "%{y} comentários" + ), + ) + ) + fig.update_layout( + **_layout( + showlegend=False, + xaxis={"showgrid": False}, + yaxis={ + "gridcolor": "#f1f5f9", + "title": {"text": "Comentários", "font": {"size": 11}}, + }, + ) + ) + return pio.to_json(fig, validate=False) + + +def _make_user_progress_chart(datasets_data: list[dict]) -> str: + names = [d["name"] for d in datasets_data] + percents = [d["percent"] for d in datasets_data] + colors = [ + COLORS["humano"] if p == 100 else COLORS["indigo"] if p > 0 else "#cbd5e1" + for p in percents + ] + fig = go.Figure( + go.Bar( + y=names, + x=percents, + orientation="h", + marker_color=colors, + marker_line_width=0, + text=[f"{p:.0f}%" for p in percents], + textposition="outside", + textfont={"size": 10}, + hovertemplate=("%{y}
" "%{x:.0f}% concluído"), + ) + ) + fig.update_layout( + **_layout( + showlegend=False, + xaxis={ + "range": [0, 110], + "showgrid": False, + "showticklabels": False, + }, + yaxis={"tickfont": {"size": 10}}, + margin={"l": 100}, + ) + ) + return pio.to_json(fig, validate=False) + + +# ═══════════════════════════════════════════════════════════════════ +# Endpoints — lógica principal +# ═══════════════════════════════════════════════════════════════════ + + +def get_global_dashboard(db: Session, criteria: list[str] | None = None) -> dict: + """Seção 1 — Visão Geral.""" + datasets = _get_datasets_filtered(db, criteria) + ds_comment_ids, comment_info = _get_comment_ids_for_datasets(db, datasets) + + all_cids = [] + for cids in ds_comment_ids.values(): + all_cids.extend(cids) + all_cids = list(set(all_cids)) + + anns_by_comment, conflict_map = _get_annotations_and_conflicts(db, all_cids) + + # KPIs + total_bots = 0 + total_humans = 0 + total_conflicts = 0 + pending_conflicts = 0 + annotated_cids: set[uuid.UUID] = set() + + for cid in all_cids: + classification = _classify_comment(cid, anns_by_comment, conflict_map) + if classification == "bot": + total_bots += 1 + elif classification == "humano": + total_humans += 1 + elif classification == "conflito": + total_conflicts += 1 + + if anns_by_comment.get(cid): + annotated_cids.add(cid) + + # conflitos totais e pendentes + for cid in all_cids: + if cid in conflict_map: + status, _ = conflict_map[cid] + if status == "pending": + pending_conflicts += 1 + + total_all_conflicts = sum(1 for cid in all_cids if cid in conflict_map) + agreement_rate = _compute_agreement_rate(all_cids, anns_by_comment) + + # Dados por dataset para gráficos + datasets_chart_data = [] + for ds in datasets: + cids = ds_comment_ids.get(ds.id, []) + bots = humans = conflicts = 0 + annotated = 0 + for cid in cids: + cl = _classify_comment(cid, anns_by_comment, conflict_map) + if cl == "bot": + bots += 1 + elif cl == "humano": + humans += 1 + elif cl == "conflito": + conflicts += 1 + if anns_by_comment.get(cid): + annotated += 1 + bot_rate = (bots / annotated * 100) if annotated > 0 else 0.0 + ds_agreement = _compute_agreement_rate(cids, anns_by_comment) + datasets_chart_data.append( + { + "name": ds.name, + "bots": bots, + "humans": humans, + "conflicts": conflicts, + "bot_rate": bot_rate, + "agreement_rate": round(ds_agreement * 100, 1), + } + ) + + # Timeline de anotações (agrupado por dia) + annotation_buckets = _get_annotation_timeline(db, all_cids) + + # Eficácia por critério + criteria_data = _compute_criteria_effectiveness( + db, datasets, ds_comment_ids, anns_by_comment, conflict_map + ) + + # Progresso geral + total_in_datasets = len(all_cids) + total_annotated_count = len(annotated_cids) + annotation_progress = ( + round(total_annotated_count / total_in_datasets * 100, 1) + if total_in_datasets > 0 + else 0.0 + ) + + return { + "summary": { + "total_datasets": len(datasets), + "total_comments_annotated": total_annotated_count, + "total_comments_in_datasets": total_in_datasets, + "annotation_progress": annotation_progress, + "total_bots": total_bots, + "total_humans": total_humans, + "total_conflicts": total_all_conflicts, + "pending_conflicts": pending_conflicts, + "agreement_rate": agreement_rate, + }, + "active_criteria_filter": criteria or [], + "label_distribution_chart": _make_donut_chart( + total_bots, total_humans, total_conflicts + ), + "comparativo_por_dataset_chart": _make_comparativo_chart(datasets_chart_data), + "annotations_over_time_chart": _make_timeline_chart(annotation_buckets), + "bot_rate_by_dataset_chart": _make_bot_rate_chart(datasets_chart_data), + "agreement_by_dataset_chart": _make_agreement_by_dataset_chart( + datasets_chart_data + ), + "criteria_effectiveness_chart": _make_criteria_effectiveness_chart( + criteria_data + ), + } + + +def get_video_dashboard( + db: Session, video_id: str, criteria: list[str] | None = None +) -> dict: + """Seção 2 — Por Vídeo.""" + # Total de comentários coletados para este vídeo + total_collected = ( + db.query(func.count(Comment.id)) + .join(Collection) + .filter(Collection.video_id == video_id) + .scalar() + ) or 0 + + datasets = _get_datasets_filtered(db, criteria, video_id=video_id) + ds_comment_ids, comment_info = _get_comment_ids_for_datasets(db, datasets) + + all_cids = list({cid for cids in ds_comment_ids.values() for cid in cids}) + anns_by_comment, conflict_map = _get_annotations_and_conflicts(db, all_cids) + + total_bots = 0 + total_humans = 0 + total_conflicts = 0 + pending_conflicts = 0 + annotated_count = 0 + + for cid in all_cids: + cl = _classify_comment(cid, anns_by_comment, conflict_map) + if cl == "bot": + total_bots += 1 + elif cl == "humano": + total_humans += 1 + elif cl == "conflito": + total_conflicts += 1 + if anns_by_comment.get(cid): + annotated_count += 1 + if cid in conflict_map and conflict_map[cid][0] == "pending": + pending_conflicts += 1 + + all_conflicts_count = sum(1 for cid in all_cids if cid in conflict_map) + agreement_rate = _compute_agreement_rate(all_cids, anns_by_comment) + + # Dados por dataset + datasets_chart_data = [] + for ds in datasets: + cids = ds_comment_ids.get(ds.id, []) + bots = humans = conflicts = annotated = 0 + for cid in cids: + cl = _classify_comment(cid, anns_by_comment, conflict_map) + if cl == "bot": + bots += 1 + elif cl == "humano": + humans += 1 + elif cl == "conflito": + conflicts += 1 + if anns_by_comment.get(cid): + annotated += 1 + bot_rate = (bots / annotated * 100) if annotated > 0 else 0.0 + datasets_chart_data.append( + { + "name": ds.name, + "bots": bots, + "humans": humans, + "conflicts": conflicts, + "bot_rate": bot_rate, + } + ) + + # Taxa de bots por critério neste vídeo + criteria_rates = _compute_bot_rate_by_criteria( + datasets, ds_comment_ids, anns_by_comment, conflict_map + ) + + # Timeline de comentários postados + comment_timeline = _get_comment_published_timeline(db, video_id) + + # Destaques do vídeo + highlights = _compute_video_highlights(db, video_id) + + return { + "video_id": video_id, + "summary": { + "total_comments_collected": total_collected, + "total_comments_in_datasets": len(all_cids), + "total_annotated": annotated_count, + "total_bots": total_bots, + "total_humans": total_humans, + "total_conflicts": all_conflicts_count, + "pending_conflicts": pending_conflicts, + "agreement_rate": agreement_rate, + }, + "highlights": highlights, + "active_criteria_filter": criteria or [], + "label_distribution_chart": _make_donut_chart( + total_bots, total_humans, total_conflicts + ), + "comparativo_por_dataset_chart": _make_comparativo_chart(datasets_chart_data), + "bot_rate_by_criteria_chart": _make_bot_rate_chart( + criteria_rates, orientation="h" + ), + "comment_timeline_chart": _make_comment_timeline_chart(comment_timeline), + } + + +def get_user_dashboard(db: Session, user_id: uuid.UUID) -> dict: + """Seção 3 — Meu Progresso.""" + # Todos os datasets (sem filtro — o usuário pode anotar em qualquer um) + datasets = db.query(Dataset).order_by(Dataset.created_at.desc()).all() + if not datasets: + return _empty_user_response() + + ds_comment_ids, comment_info = _get_comment_ids_for_datasets(db, datasets) + all_cids = list({cid for cids in ds_comment_ids.values() for cid in cids}) + + # Anotações do usuário autenticado + my_annotations: dict[uuid.UUID, str] = {} + if all_cids: + rows = ( + db.query(Annotation.comment_id, Annotation.label) + .filter( + Annotation.comment_id.in_(all_cids), + Annotation.annotator_id == user_id, + ) + .all() + ) + my_annotations = {cid: label for cid, label in rows} + + # Conflitos gerados pelo usuário + my_conflict_cids: set[uuid.UUID] = set() + if all_cids: + conflict_rows = ( + db.query(AnnotationConflict.comment_id) + .join(Annotation, AnnotationConflict.annotation_a_id == Annotation.id) + .filter( + AnnotationConflict.comment_id.in_(all_cids), + Annotation.annotator_id == user_id, + ) + .all() + ) + my_conflict_cids.update(r[0] for r in conflict_rows) + conflict_rows_b = ( + db.query(AnnotationConflict.comment_id) + .join(Annotation, AnnotationConflict.annotation_b_id == Annotation.id) + .filter( + AnnotationConflict.comment_id.in_(all_cids), + Annotation.annotator_id == user_id, + ) + .all() + ) + my_conflict_cids.update(r[0] for r in conflict_rows_b) + + # collection_id por dataset + ds_col_map = {ds.id: ds.collection_id for ds in datasets} + col_video_map: dict[uuid.UUID, str] = {} + col_ids = list({ds.collection_id for ds in datasets}) + if col_ids: + cols = ( + db.query(Collection.id, Collection.video_id) + .filter(Collection.id.in_(col_ids)) + .all() + ) + col_video_map = {c_id: vid for c_id, vid in cols} + + # Progresso por dataset + ds_progress = [] + total_annotated = 0 + total_pending = 0 + total_bots = 0 + total_humans = 0 + total_conflicts = 0 + datasets_completed = 0 + datasets_with_data = 0 + + for ds in datasets: + cids = ds_comment_ids.get(ds.id, []) + if not cids: + continue + + annotated_by_me = sum(1 for cid in cids if cid in my_annotations) + pending = len(cids) - annotated_by_me + + # Apenas contar o dataset se o usuário tem algo para anotar + # (todos os datasets são atribuídos a todos os anotadores) + datasets_with_data += 1 + + my_bots = sum(1 for cid in cids if my_annotations.get(cid) == "bot") + my_humans = sum(1 for cid in cids if my_annotations.get(cid) == "humano") + my_conflicts = sum(1 for cid in cids if cid in my_conflict_cids) + + percent = round(annotated_by_me / len(cids) * 100, 1) if cids else 0.0 + + if annotated_by_me == len(cids): + status = "completed" + datasets_completed += 1 + elif annotated_by_me > 0: + status = "in_progress" + else: + status = "not_started" + + total_annotated += annotated_by_me + total_pending += pending + total_bots += my_bots + total_humans += my_humans + total_conflicts += my_conflicts + + ds_progress.append( + { + "dataset_id": ds.id, + "dataset_name": ds.name, + "video_id": col_video_map.get(ds_col_map[ds.id], ""), + "total_comments": len(cids), + "annotated_by_me": annotated_by_me, + "pending": pending, + "percent_complete": percent, + "my_bots": my_bots, + "my_conflicts": my_conflicts, + "status": status, + } + ) + + datasets_pending = datasets_with_data - datasets_completed + + # Timeline de minhas anotações + my_timeline = _get_user_annotation_timeline(db, user_id) + + # Gráficos + progress_chart_data = [ + {"name": d["dataset_name"], "percent": d["percent_complete"]} + for d in ds_progress + ] + + return { + "summary": { + "total_datasets_assigned": datasets_with_data, + "datasets_completed": datasets_completed, + "datasets_pending": datasets_pending, + "total_annotated": total_annotated, + "total_pending": total_pending, + "bots": total_bots, + "humans": total_humans, + "conflicts_generated": total_conflicts, + }, + "datasets": ds_progress, + "my_label_distribution_chart": _make_donut_chart(total_bots, total_humans, 0), + "my_progress_by_dataset_chart": _make_user_progress_chart(progress_chart_data), + "my_annotations_over_time_chart": _make_timeline_chart( + my_timeline, title="Minhas Anotações ao Longo do Tempo" + ), + } + + +def get_bot_comments( + db: Session, + dataset_id: str | None = None, + video_id: str | None = None, + author: str | None = None, + search: str | None = None, + criteria_filter: list[str] | None = None, + page: int = 1, + page_size: int = 20, +) -> dict: + """Tabela de comentários classificados como bot.""" + q = ( + db.query( + Dataset.name.label("dataset_name"), + Dataset.id.label("dataset_id"), + Comment.author_display_name, + Comment.author_channel_id, + Comment.text_original, + Annotation.comment_id, + ) + .join(DatasetEntry, DatasetEntry.dataset_id == Dataset.id) + .join( + Comment, + (Comment.collection_id == Dataset.collection_id) + & (Comment.author_channel_id == DatasetEntry.author_channel_id), + ) + .join(Annotation, Annotation.comment_id == Comment.id) + .filter(Annotation.label == "bot") + ) + + if dataset_id: + q = q.filter(Dataset.id == dataset_id) + if video_id: + q = q.join(Collection, Collection.id == Dataset.collection_id).filter( + Collection.video_id == video_id + ) + if author: + q = q.filter(Comment.author_display_name.ilike(f"%{author}%")) + if search: + q = q.filter(Comment.text_original.ilike(f"%{search}%")) + if criteria_filter: + for crit in criteria_filter: + q = q.filter(Dataset.criteria_applied.any(crit)) + + q = q.distinct(Annotation.comment_id) + total = q.count() + + rows = ( + q.order_by(Annotation.comment_id) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + comment_ids = [r.comment_id for r in rows] + + # Batch: conflitos + conflict_status_map: dict[uuid.UUID, str] = {} + if comment_ids: + conflicts = ( + db.query(AnnotationConflict.comment_id, AnnotationConflict.status) + .filter(AnnotationConflict.comment_id.in_(comment_ids)) + .all() + ) + conflict_status_map = {cid: st for cid, st in conflicts} + + # Batch: concordância + nº de anotadores + concordance_map: dict[uuid.UUID, int] = {} + annotators_map: dict[uuid.UUID, int] = {} + if comment_ids: + ann_counts = ( + db.query( + Annotation.comment_id, + func.count(Annotation.id).label("total"), + func.count( + case( + (Annotation.label == "bot", 1), + ) + ).label("bot_count"), + ) + .filter(Annotation.comment_id.in_(comment_ids)) + .group_by(Annotation.comment_id) + .all() + ) + for cid, total_anns, bot_count in ann_counts: + annotators_map[cid] = total_anns + if total_anns > 0: + concordance_map[cid] = round(bot_count / total_anns * 100) + + # Batch: critérios que flagaram cada autor (via DatasetEntry) + ds_ids = list({r.dataset_id for r in rows}) + author_ids = list({r.author_channel_id for r in rows if r.author_channel_id}) + criteria_map: dict[str, list[str]] = {} + if ds_ids and author_ids: + entries = ( + db.query( + DatasetEntry.author_channel_id, + DatasetEntry.matched_criteria, + ) + .filter( + DatasetEntry.dataset_id.in_(ds_ids), + DatasetEntry.author_channel_id.in_(author_ids), + ) + .all() + ) + for aid, matched in entries: + if aid not in criteria_map: + criteria_map[aid] = [] + for c in matched or []: + if c not in criteria_map[aid]: + criteria_map[aid].append(c) + + items = [] + for row in rows: + items.append( + { + "dataset_name": row.dataset_name, + "author_display_name": row.author_display_name, + "text_original": row.text_original, + "concordance_pct": concordance_map.get(row.comment_id, 0), + "conflict_status": conflict_status_map.get(row.comment_id), + "annotators_count": annotators_map.get(row.comment_id, 0), + "criteria": criteria_map.get(row.author_channel_id, []), + } + ) + + return {"total": total, "items": items} + + +def get_criteria_effectiveness(db: Session, video_id: str | None = None) -> list[dict]: + """Eficácia de cada critério de limpeza.""" + datasets = _get_datasets_filtered(db, criteria=None, video_id=video_id) + if not datasets: + return [] + + ds_comment_ids, _ = _get_comment_ids_for_datasets(db, datasets) + all_cids = list({cid for cids in ds_comment_ids.values() for cid in cids}) + anns_by_comment, conflict_map = _get_annotations_and_conflicts(db, all_cids) + + return _compute_criteria_effectiveness( + db, datasets, ds_comment_ids, anns_by_comment, conflict_map + ) + + +# ═══════════════════════════════════════════════════════════════════ +# Helpers internos +# ═══════════════════════════════════════════════════════════════════ + + +def _get_annotation_timeline(db: Session, comment_ids: list[uuid.UUID]) -> list[dict]: + """Anotações agrupadas por dia.""" + if not comment_ids: + return [] + rows = ( + db.query( + cast(Annotation.annotated_at, Date).label("day"), + func.count(Annotation.id), + ) + .filter(Annotation.comment_id.in_(comment_ids)) + .group_by("day") + .order_by("day") + .all() + ) + return [{"date": str(day), "count": count} for day, count in rows] + + +def _get_user_annotation_timeline(db: Session, user_id: uuid.UUID) -> list[dict]: + """Anotações do usuário agrupadas por dia.""" + rows = ( + db.query( + cast(Annotation.annotated_at, Date).label("day"), + func.count(Annotation.id), + ) + .filter(Annotation.annotator_id == user_id) + .group_by("day") + .order_by("day") + .all() + ) + return [{"date": str(day), "count": count} for day, count in rows] + + +def _get_comment_published_timeline(db: Session, video_id: str) -> list[dict]: + """Comentários publicados agrupados por dia para um vídeo.""" + rows = ( + db.query( + cast(Comment.published_at, Date).label("day"), + func.count(Comment.id), + ) + .join(Collection) + .filter(Collection.video_id == video_id) + .group_by("day") + .order_by("day") + .all() + ) + return [{"date": str(day), "count": count} for day, count in rows] + + +def _compute_bot_rate_by_criteria( + datasets: list[Dataset], + ds_comment_ids: dict[uuid.UUID, list[uuid.UUID]], + anns_by_comment: dict[uuid.UUID, list[tuple[uuid.UUID, str]]], + conflict_map: dict[uuid.UUID, tuple[str, str | None]], +) -> list[dict]: + """Taxa de bots agrupada por critério para gráfico horizontal.""" + criteria_stats: dict[str, dict] = {} + + for ds in datasets: + cids = ds_comment_ids.get(ds.id, []) + if not cids: + continue + + bots = sum( + 1 + for cid in cids + if _classify_comment(cid, anns_by_comment, conflict_map) == "bot" + ) + annotated = sum(1 for cid in cids if anns_by_comment.get(cid)) + + for crit in ds.criteria_applied or []: + if crit not in criteria_stats: + criteria_stats[crit] = {"bots": 0, "annotated": 0} + criteria_stats[crit]["bots"] += bots + criteria_stats[crit]["annotated"] += annotated + + result = [] + for crit, stats in sorted(criteria_stats.items()): + bot_rate = ( + stats["bots"] / stats["annotated"] * 100 if stats["annotated"] > 0 else 0 + ) + result.append({"name": crit, "bot_rate": bot_rate}) + return result + + +def _compute_criteria_effectiveness( + db: Session, + datasets: list[Dataset], + ds_comment_ids: dict[uuid.UUID, list[uuid.UUID]], + anns_by_comment: dict[uuid.UUID, list[tuple[uuid.UUID, str]]], + conflict_map: dict[uuid.UUID, tuple[str, str | None]], +) -> list[dict]: + """Calcula eficácia de cada critério: datasets, comentários, bots, taxa.""" + criteria_stats: dict[str, dict] = {} + + for ds in datasets: + cids = ds_comment_ids.get(ds.id, []) + bots = sum( + 1 + for cid in cids + if _classify_comment(cid, anns_by_comment, conflict_map) == "bot" + ) + for crit in ds.criteria_applied or []: + if crit not in criteria_stats: + criteria_stats[crit] = { + "total_datasets": 0, + "total_comments_selected": 0, + "total_bots": 0, + } + criteria_stats[crit]["total_datasets"] += 1 + criteria_stats[crit]["total_comments_selected"] += len(cids) + criteria_stats[crit]["total_bots"] += bots + + # Ordenar: numéricos primeiro, depois comportamentais + ordered_criteria = [ + "percentil", + "media", + "moda", + "mediana", + "curtos", + "intervalo", + "identicos", + "perfil", + ] + + result = [] + for crit in ordered_criteria: + if crit not in criteria_stats: + continue + stats = criteria_stats[crit] + bot_rate = ( + stats["total_bots"] / stats["total_comments_selected"] + if stats["total_comments_selected"] > 0 + else 0.0 + ) + result.append( + { + "criteria": crit, + "group": CRITERIA_GROUPS.get(crit, "outro"), + "total_datasets": stats["total_datasets"], + "total_comments_selected": stats["total_comments_selected"], + "total_bots": stats["total_bots"], + "bot_rate": round(bot_rate, 4), + } + ) + return result + + +def _compute_video_highlights(db: Session, video_id: str) -> list[dict]: + """Destaques estatísticos do vídeo baseados nos comentários coletados.""" + base = db.query(Comment).join(Collection).filter(Collection.video_id == video_id) + + highlights: list[dict] = [] + + # 1. Autor com mais comentários + top_author = ( + db.query( + Comment.author_display_name, + func.count(Comment.id).label("cnt"), + ) + .join(Collection) + .filter(Collection.video_id == video_id) + .group_by(Comment.author_display_name) + .order_by(func.count(Comment.id).desc()) + .first() + ) + if top_author: + highlights.append( + { + "label": "Autor mais ativo", + "value": top_author[0], + "detail": f"{top_author[1]} comentários", + } + ) + + # 2. Comentário com mais respostas + top_replies = base.order_by(Comment.reply_count.desc()).first() + if top_replies and top_replies.reply_count > 0: + text = top_replies.text_original + preview = (text[:60] + "...") if len(text) > 60 else text + highlights.append( + { + "label": "Mais respostas", + "value": f"{top_replies.reply_count} respostas", + "detail": preview, + } + ) + + # 3. Comentário com mais likes + top_likes = base.order_by(Comment.like_count.desc()).first() + if top_likes and top_likes.like_count > 0: + text = top_likes.text_original + preview = (text[:60] + "...") if len(text) > 60 else text + highlights.append( + { + "label": "Mais curtido", + "value": f"{top_likes.like_count} likes", + "detail": preview, + } + ) + + # 4. Conta mais nova (canal criado mais recentemente) + newest = ( + base.filter(Comment.author_channel_published_at.isnot(None)) + .order_by(Comment.author_channel_published_at.desc()) + .first() + ) + if newest and newest.author_channel_published_at: + dt = newest.author_channel_published_at + highlights.append( + { + "label": "Conta mais nova", + "value": newest.author_display_name, + "detail": f"Criada em {dt.strftime('%d/%m/%Y')}", + } + ) + + # 5. Conta mais antiga + oldest = ( + base.filter(Comment.author_channel_published_at.isnot(None)) + .order_by(Comment.author_channel_published_at.asc()) + .first() + ) + if ( + oldest + and oldest.author_channel_published_at + and oldest.author_channel_published_at.year > 1970 + ): + dt = oldest.author_channel_published_at + highlights.append( + { + "label": "Conta mais antiga", + "value": oldest.author_display_name, + "detail": f"Criada em {dt.strftime('%d/%m/%Y')}", + } + ) + + # 6. Média de likes por comentário + avg_likes = ( + db.query(func.avg(Comment.like_count)) + .join(Collection) + .filter(Collection.video_id == video_id) + .scalar() + ) + if avg_likes is not None: + highlights.append( + { + "label": "Média de likes", + "value": f"{avg_likes:.1f}", + "detail": "Por comentário", + } + ) + + return highlights + + +def _empty_user_response() -> dict: + """Resposta vazia para quando não há datasets.""" + return { + "summary": { + "total_datasets_assigned": 0, + "datasets_completed": 0, + "datasets_pending": 0, + "total_annotated": 0, + "total_pending": 0, + "bots": 0, + "humans": 0, + "conflicts_generated": 0, + }, + "datasets": [], + "my_label_distribution_chart": _make_donut_chart(0, 0, 0), + "my_progress_by_dataset_chart": _make_user_progress_chart([]), + "my_annotations_over_time_chart": _make_timeline_chart( + [], title="Minhas Anotações ao Longo do Tempo" + ), + } diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py new file mode 100644 index 0000000..12a7ff6 --- /dev/null +++ b/backend/tests/test_dashboard.py @@ -0,0 +1,606 @@ +"""Testes da US-06 — Dashboard de Análise.""" + +import json +import uuid +from datetime import datetime, timedelta + +from models.annotation import Annotation, AnnotationConflict +from models.collection import Collection, Comment +from models.dataset import Dataset, DatasetEntry + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_collection(db, user_id, *, video_id="vid123", status="completed"): + col = Collection( + video_id=video_id, + status=status, + collected_by=user_id, + total_comments=5, + ) + db.add(col) + db.flush() + return col + + +def _make_comments(db, collection_id, author_channel_id, count=5, base_date=None): + if base_date is None: + base_date = datetime(2024, 6, 15) + comments = [] + for i in range(count): + c = Comment( + collection_id=collection_id, + comment_id=f"{author_channel_id}_c{i}_{uuid.uuid4().hex[:4]}", + author_channel_id=author_channel_id, + author_display_name=f"User {author_channel_id}", + text_original=f"Comentario {i} do {author_channel_id}", + like_count=i * 2, + reply_count=0, + published_at=base_date + timedelta(hours=i), + updated_at=base_date + timedelta(hours=i), + ) + db.add(c) + comments.append(c) + db.flush() + return comments + + +def _make_dataset( + db, collection_id, user_id, author_channel_ids, criteria=None, name=None +): + if criteria is None: + criteria = ["percentil"] + ds = Dataset( + name=name or f"ds_{uuid.uuid4().hex[:6]}", + collection_id=collection_id, + criteria_applied=criteria, + thresholds={}, + total_users_original=10, + total_users_selected=len(author_channel_ids), + created_by=user_id, + ) + db.add(ds) + db.flush() + for channel_id in author_channel_ids: + entry = DatasetEntry( + dataset_id=ds.id, + author_channel_id=channel_id, + author_display_name=f"User {channel_id}", + comment_count=5, + matched_criteria=criteria, + ) + db.add(entry) + db.flush() + return ds + + +def _annotate(db, comment, user, label, justificativa=None): + ann = Annotation( + comment_id=comment.id, + annotator_id=user.id, + label=label, + justificativa=justificativa, + annotated_at=datetime(2024, 7, 1, 10, 0), + ) + db.add(ann) + db.flush() + return ann + + +def _make_conflict( + db, + comment, + ann_a, + ann_b, + *, + resolved_by=None, + resolved_label=None, + status="pending", +): + conflict = AnnotationConflict( + comment_id=comment.id, + annotation_a_id=ann_a.id, + annotation_b_id=ann_b.id, + status=status, + resolved_by=resolved_by, + resolved_label=resolved_label, + resolved_at=datetime(2024, 7, 5) if status == "resolved" else None, + ) + db.add(conflict) + db.flush() + return conflict + + +def _assert_valid_plotly_json(chart_json: str): + """Verifica que o chart é JSON parseável com chaves data e layout.""" + fig = json.loads(chart_json) + assert "data" in fig, "Plotly JSON deve conter 'data'" + assert "layout" in fig, "Plotly JSON deve conter 'layout'" + + +def _populate_full_scenario(db, user_a, user_b): + """Cria cenário completo: 2 vídeos, 3 datasets, anotações, conflitos. + + Retorna dict com referências para assertions nos testes. + """ + # Vídeo 1 — 2 datasets com critérios diferentes + col1 = _make_collection(db, user_a.id, video_id="vid_alpha") + comments_a1 = _make_comments(db, col1.id, "author_a", count=3) + comments_b1 = _make_comments(db, col1.id, "author_b", count=2) + + ds1 = _make_dataset( + db, + col1.id, + user_a.id, + ["author_a"], + criteria=["percentil"], + name="alpha_percentil", + ) + ds2 = _make_dataset( + db, + col1.id, + user_a.id, + ["author_b"], + criteria=["media", "curtos"], + name="alpha_media_curtos", + ) + + # Vídeo 2 — 1 dataset + col2 = _make_collection(db, user_a.id, video_id="vid_beta") + comments_c2 = _make_comments(db, col2.id, "author_c", count=4) + ds3 = _make_dataset( + db, + col2.id, + user_a.id, + ["author_c"], + criteria=["percentil", "identicos"], + name="beta_percentil_identicos", + ) + + # ── Anotações do vídeo 1 ── + + # ds1: author_a → 3 comentários + # comment_a1[0]: consenso bot (ambos dizem bot) + _annotate(db, comments_a1[0], user_a, "bot", "spam") + _annotate(db, comments_a1[0], user_b, "bot", "concordo") + + # comment_a1[1]: consenso humano + _annotate(db, comments_a1[1], user_a, "humano") + _annotate(db, comments_a1[1], user_b, "humano") + + # comment_a1[2]: conflito pendente + ann_a2_a = _annotate(db, comments_a1[2], user_a, "bot", "suspeito") + ann_a2_b = _annotate(db, comments_a1[2], user_b, "humano") + _make_conflict(db, comments_a1[2], ann_a2_a, ann_a2_b, status="pending") + + # ds2: author_b → 2 comentários + # comment_b1[0]: conflito resolvido como bot + ann_b0_a = _annotate(db, comments_b1[0], user_a, "bot", "repetitivo") + ann_b0_b = _annotate(db, comments_b1[0], user_b, "humano") + _make_conflict( + db, + comments_b1[0], + ann_b0_a, + ann_b0_b, + status="resolved", + resolved_by=user_a.id, + resolved_label="bot", + ) + + # comment_b1[1]: apenas user_a anotou (1 anotação) + _annotate(db, comments_b1[1], user_a, "humano") + + # ── Anotações do vídeo 2 ── + + # ds3: author_c → 4 comentários + # comment_c2[0]: consenso humano + _annotate(db, comments_c2[0], user_a, "humano") + _annotate(db, comments_c2[0], user_b, "humano") + + # comment_c2[1]: consenso bot + _annotate(db, comments_c2[1], user_a, "bot", "bot óbvio") + _annotate(db, comments_c2[1], user_b, "bot", "concordo") + + # comment_c2[2] e [3]: sem anotação + db.commit() + + return { + "col1": col1, + "col2": col2, + "ds1": ds1, + "ds2": ds2, + "ds3": ds3, + "comments_a1": comments_a1, + "comments_b1": comments_b1, + "comments_c2": comments_c2, + } + + +# --------------------------------------------------------------------------- +# GET /dashboard/global +# --------------------------------------------------------------------------- + + +class TestGlobalDashboard: + def test_retorna_kpis_e_charts_validos(self, client, db, auth_as_user, admin_user): + """KPIs corretos e todos os charts são JSON Plotly válidos.""" + _populate_full_scenario(db, auth_as_user, admin_user) + + resp = client.get("/dashboard/global") + assert resp.status_code == 200 + data = resp.json() + + s = data["summary"] + assert s["total_datasets"] == 3 + # bots: consenso bot em vid_alpha (author_a[0]) + resolvido bot (author_b[0]) + # + consenso bot em vid_beta (author_c[1]) = 3 + assert s["total_bots"] == 3 + # humanos: consenso humano (a[1]) + 1 anotação humano (b[1]) + consenso (c[0]) = 3 + assert s["total_humans"] == 3 + # conflitos totais: 2 (pendente + resolvido) + assert s["total_conflicts"] == 2 + assert s["pending_conflicts"] == 1 + + # Progresso geral + assert s["total_comments_in_datasets"] > 0 + assert 0 <= s["annotation_progress"] <= 100 + + # Charts válidos + for key in [ + "label_distribution_chart", + "comparativo_por_dataset_chart", + "annotations_over_time_chart", + "bot_rate_by_dataset_chart", + "agreement_by_dataset_chart", + "criteria_effectiveness_chart", + ]: + _assert_valid_plotly_json(data[key]) + + def test_sem_token_retorna_401(self, client): + resp = client.get("/dashboard/global") + assert resp.status_code == 401 + + def test_sem_dados_retorna_zeros(self, client, auth_as_user): + """Banco vazio retorna zeros e charts válidos — nunca 404.""" + resp = client.get("/dashboard/global") + assert resp.status_code == 200 + data = resp.json() + s = data["summary"] + assert s["total_datasets"] == 0 + assert s["total_bots"] == 0 + assert s["total_humans"] == 0 + assert s["agreement_rate"] == 0.0 + _assert_valid_plotly_json(data["label_distribution_chart"]) + + def test_filtro_criteria_filtra_datasets( + self, client, db, auth_as_user, admin_user + ): + """Filtrar por criteria=percentil retorna apenas datasets com percentil.""" + _populate_full_scenario(db, auth_as_user, admin_user) + + resp = client.get("/dashboard/global?criteria=percentil") + assert resp.status_code == 200 + data = resp.json() + + # Datasets com percentil: alpha_percentil e beta_percentil_identicos + assert data["summary"]["total_datasets"] == 2 + assert data["active_criteria_filter"] == ["percentil"] + + def test_filtro_criteria_multiplo(self, client, db, auth_as_user, admin_user): + """criteria=percentil,identicos retorna apenas datasets com AMBOS.""" + _populate_full_scenario(db, auth_as_user, admin_user) + + resp = client.get("/dashboard/global?criteria=percentil,identicos") + assert resp.status_code == 200 + data = resp.json() + + # Apenas beta_percentil_identicos tem ambos + assert data["summary"]["total_datasets"] == 1 + + def test_agreement_rate_correto(self, client, db, auth_as_user, admin_user): + """Agreement = consenso / total com 2 anotações.""" + _populate_full_scenario(db, auth_as_user, admin_user) + + resp = client.get("/dashboard/global") + data = resp.json() + rate = data["summary"]["agreement_rate"] + + # Com 2 anotações: + # vid_alpha: a[0]=consenso, a[1]=consenso, a[2]=conflito, b[0]=conflito → 2 consenso / 4 + # vid_beta: c[0]=consenso, c[1]=consenso → 2 consenso / 2 + # Total: 4 consenso / 6 = 0.6667 + assert rate == round(4 / 6, 4) + + +# --------------------------------------------------------------------------- +# GET /dashboard/video +# --------------------------------------------------------------------------- + + +class TestVideoDashboard: + def test_retorna_dados_do_video_filtrado( + self, client, db, auth_as_user, admin_user + ): + """Apenas dados do video_id requisitado.""" + _populate_full_scenario(db, auth_as_user, admin_user) + + resp = client.get("/dashboard/video?video_id=vid_alpha") + assert resp.status_code == 200 + data = resp.json() + + assert data["video_id"] == "vid_alpha" + s = data["summary"] + # vid_alpha tem 5 comentários coletados (3 de author_a + 2 de author_b) + assert s["total_comments_collected"] == 5 + assert s["total_comments_in_datasets"] == 5 + + # Charts válidos + for key in [ + "label_distribution_chart", + "comparativo_por_dataset_chart", + "bot_rate_by_criteria_chart", + "comment_timeline_chart", + ]: + _assert_valid_plotly_json(data[key]) + + def test_sem_token_retorna_401(self, client): + resp = client.get("/dashboard/video?video_id=vid_alpha") + assert resp.status_code == 401 + + def test_video_inexistente_retorna_zeros(self, client, db, auth_as_user): + """video_id que não existe retorna 200 com zeros — nunca 404.""" + resp = client.get("/dashboard/video?video_id=inexistente") + assert resp.status_code == 200 + data = resp.json() + s = data["summary"] + assert s["total_comments_collected"] == 0 + assert s["total_annotated"] == 0 + assert s["total_bots"] == 0 + + def test_video_com_filtro_criteria(self, client, db, auth_as_user, admin_user): + """Filtro por critério no contexto de um vídeo.""" + _populate_full_scenario(db, auth_as_user, admin_user) + + resp = client.get("/dashboard/video?video_id=vid_alpha&criteria=media,curtos") + assert resp.status_code == 200 + data = resp.json() + + # Apenas alpha_media_curtos tem ambos media e curtos + assert data["summary"]["total_comments_in_datasets"] == 2 + + +# --------------------------------------------------------------------------- +# GET /dashboard/user +# --------------------------------------------------------------------------- + + +class TestUserDashboard: + def test_retorna_dados_do_pesquisador_autenticado( + self, client, db, auth_as_user, admin_user + ): + """Apenas anotações do usuário autenticado (auth_as_user).""" + _populate_full_scenario(db, auth_as_user, admin_user) + + resp = client.get("/dashboard/user") + assert resp.status_code == 200 + data = resp.json() + + s = data["summary"] + # auth_as_user anotou: + # vid_alpha: a[0]=bot, a[1]=humano, a[2]=bot, b[0]=bot, b[1]=humano + # vid_beta: c[0]=humano, c[1]=bot + # Total: 7 anotados, bots=4, humans=3 + assert s["total_annotated"] == 7 + assert s["bots"] == 4 + assert s["humans"] == 3 + assert s["total_datasets_assigned"] == 3 + assert len(data["datasets"]) == 3 + + # Charts válidos + for key in [ + "my_label_distribution_chart", + "my_progress_by_dataset_chart", + "my_annotations_over_time_chart", + ]: + _assert_valid_plotly_json(data[key]) + + def test_sem_token_retorna_401(self, client): + resp = client.get("/dashboard/user") + assert resp.status_code == 401 + + def test_nao_expoe_dados_de_outro_pesquisador( + self, client, db, auth_as_user, admin_user + ): + """Dados do admin_user não aparecem no dashboard do auth_as_user.""" + _populate_full_scenario(db, auth_as_user, admin_user) + + resp = client.get("/dashboard/user") + assert resp.status_code == 200 + text = resp.text + + # Username do admin não deve aparecer em nenhum campo + assert admin_user.username not in text + + def test_sem_dados_retorna_zeros(self, client, auth_as_user): + """Sem datasets retorna zeros e charts válidos.""" + resp = client.get("/dashboard/user") + assert resp.status_code == 200 + data = resp.json() + s = data["summary"] + assert s["total_datasets_assigned"] == 0 + assert s["total_annotated"] == 0 + assert s["bots"] == 0 + assert data["datasets"] == [] + _assert_valid_plotly_json(data["my_label_distribution_chart"]) + + def test_dataset_status_correto(self, client, db, auth_as_user, admin_user): + """Verifica status completed, in_progress e not_started.""" + col = _make_collection(db, auth_as_user.id, video_id="vid_status") + comments_a = _make_comments(db, col.id, "ch_a", count=2) + comments_b = _make_comments(db, col.id, "ch_b", count=2) + _make_comments(db, col.id, "ch_c", count=2) + + _make_dataset(db, col.id, auth_as_user.id, ["ch_a"], name="ds_done") + _make_dataset(db, col.id, auth_as_user.id, ["ch_b"], name="ds_partial") + _make_dataset(db, col.id, auth_as_user.id, ["ch_c"], name="ds_empty") + + # ds_done: anotar todos + for c in comments_a: + _annotate(db, c, auth_as_user, "humano") + + # ds_partial: anotar 1 de 2 + _annotate(db, comments_b[0], auth_as_user, "bot", "teste") + + # ds_empty: nenhuma anotação + db.commit() + + resp = client.get("/dashboard/user") + data = resp.json() + + ds_map = {d["dataset_name"]: d for d in data["datasets"]} + assert ds_map["ds_done"]["status"] == "completed" + assert ds_map["ds_done"]["percent_complete"] == 100.0 + assert ds_map["ds_partial"]["status"] == "in_progress" + assert ds_map["ds_partial"]["percent_complete"] == 50.0 + assert ds_map["ds_empty"]["status"] == "not_started" + assert ds_map["ds_empty"]["percent_complete"] == 0.0 + + +# --------------------------------------------------------------------------- +# GET /dashboard/bots +# --------------------------------------------------------------------------- + + +class TestBotComments: + def test_retorna_bots_com_concordancia(self, client, db, auth_as_user, admin_user): + """Tabela de bots retorna concordance_pct correto.""" + col = _make_collection(db, auth_as_user.id, video_id="vid_bots") + comments = _make_comments(db, col.id, "ch_bot", count=2) + _make_dataset(db, col.id, auth_as_user.id, ["ch_bot"]) + + # Consenso bot no comment[0] + _annotate(db, comments[0], auth_as_user, "bot", "spam") + _annotate(db, comments[0], admin_user, "bot", "concordo") + + # Conflito no comment[1] + ann_a = _annotate(db, comments[1], auth_as_user, "bot", "suspeito") + ann_b = _annotate(db, comments[1], admin_user, "humano") + _make_conflict(db, comments[1], ann_a, ann_b, status="pending") + db.commit() + + resp = client.get("/dashboard/bots") + assert resp.status_code == 200 + data = resp.json() + + assert data["total"] >= 1 + # Consenso bot → 100% + bot_items = [i for i in data["items"] if i["concordance_pct"] == 100] + assert len(bot_items) >= 1 + + def test_sem_token_retorna_401(self, client): + resp = client.get("/dashboard/bots") + assert resp.status_code == 401 + + def test_filtro_por_search(self, client, db, auth_as_user, admin_user): + """Filtro de busca por texto do comentário.""" + col = _make_collection(db, auth_as_user.id, video_id="vid_search") + c = Comment( + collection_id=col.id, + comment_id="unique_search_c1", + author_channel_id="ch_search", + author_display_name="Busca User", + text_original="TEXTO ÚNICO PARA BUSCA", + like_count=0, + reply_count=0, + published_at=datetime(2024, 1, 1), + updated_at=datetime(2024, 1, 1), + ) + db.add(c) + db.flush() + _make_dataset(db, col.id, auth_as_user.id, ["ch_search"]) + _annotate(db, c, auth_as_user, "bot", "teste busca") + db.commit() + + resp = client.get("/dashboard/bots?search=ÚNICO PARA") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 1 + assert any("ÚNICO" in i["text_original"] for i in data["items"]) + + +# --------------------------------------------------------------------------- +# GET /dashboard/criteria-effectiveness +# --------------------------------------------------------------------------- + + +class TestCriteriaEffectiveness: + def test_retorna_eficacia_por_criterio(self, client, db, auth_as_user, admin_user): + """Cada critério retorna total_datasets, total_bots e bot_rate.""" + _populate_full_scenario(db, auth_as_user, admin_user) + + resp = client.get("/dashboard/criteria-effectiveness") + assert resp.status_code == 200 + data = resp.json() + + assert len(data) > 0 + for item in data: + assert "criteria" in item + assert "group" in item + assert item["group"] in ("numerico", "comportamental") + assert "total_datasets" in item + assert "bot_rate" in item + + # percentil aparece em 2 datasets + percentil = next(i for i in data if i["criteria"] == "percentil") + assert percentil["total_datasets"] == 2 + + def test_sem_token_retorna_401(self, client): + resp = client.get("/dashboard/criteria-effectiveness") + assert resp.status_code == 401 + + def test_filtro_por_video_id(self, client, db, auth_as_user, admin_user): + """Filtra eficácia por vídeo específico.""" + _populate_full_scenario(db, auth_as_user, admin_user) + + resp = client.get("/dashboard/criteria-effectiveness?video_id=vid_alpha") + assert resp.status_code == 200 + data = resp.json() + + criterios = {i["criteria"] for i in data} + # vid_alpha tem percentil e media+curtos + assert "percentil" in criterios + assert "media" in criterios + # identicos é do vid_beta — não deve aparecer + assert "identicos" not in criterios + + def test_sem_dados_retorna_vazio(self, client, auth_as_user): + resp = client.get("/dashboard/criteria-effectiveness") + assert resp.status_code == 200 + assert resp.json() == [] + + +# --------------------------------------------------------------------------- +# Segurança — nenhum endpoint expõe username de outro pesquisador +# --------------------------------------------------------------------------- + + +class TestSeguranca: + def test_global_nao_expoe_username(self, client, db, auth_as_user, admin_user): + """Visão geral não expõe username de nenhum pesquisador.""" + _populate_full_scenario(db, auth_as_user, admin_user) + + resp = client.get("/dashboard/global") + text = resp.text + assert admin_user.username not in text + assert auth_as_user.username not in text + + def test_video_nao_expoe_username(self, client, db, auth_as_user, admin_user): + """Por Vídeo não expõe username de nenhum pesquisador.""" + _populate_full_scenario(db, auth_as_user, admin_user) + + resp = client.get("/dashboard/video?video_id=vid_alpha") + text = resp.text + assert admin_user.username not in text + assert auth_as_user.username not in text diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2ac3383..73fe408 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,11 +8,13 @@ "name": "plataforma-davint", "version": "0.1.0", "dependencies": { + "plotly.js-dist-min": "^3.4.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2" }, "devDependencies": { + "@types/plotly.js": "^3.0.10", "@types/react": "^18.3.10", "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^7.18.0", @@ -27,6 +29,9 @@ "tailwindcss": "^3.4.19", "typescript": "~5.9.0", "vite": "^5.4.8" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/@alloc/quick-lru": { @@ -1295,6 +1300,12 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, + "node_modules/@types/plotly.js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-3.0.10.tgz", + "integrity": "sha512-q+MgO4aajC2HrO7FllTYWzrpdfbTjboSMfjkz/aXKjg1v7HNo1zMEFfAW7quKfk6SL+bH74A5ThBEps/7hZxOA==", + "dev": true + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -3065,6 +3076,11 @@ "node": ">= 6" } }, + "node_modules/plotly.js-dist-min": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/plotly.js-dist-min/-/plotly.js-dist-min-3.4.0.tgz", + "integrity": "sha512-eo7xh7oyE9fFoE/wintgmvfOjvTKwCb3wRf9ShQv90du4n+EVkOY7w5qEkmUS9SSkHRnAw8sk/0QI7wEc5U+8Q==" + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 39ce215..563d7d4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,11 +17,13 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "plotly.js-dist-min": "^3.4.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2" }, "devDependencies": { + "@types/plotly.js": "^3.0.10", "@types/react": "^18.3.10", "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^7.18.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 21c195e..12d6484 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,37 +1,12 @@ -import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; -import { ProtectedRoute } from "./components/ProtectedRoute"; +import { BrowserRouter } from "react-router-dom"; import { AuthProvider } from "./contexts/AuthContext"; -import { LoginPage } from "./pages/Auth/LoginPage"; -import { AnnotatePage } from "./pages/Annotate/AnnotatePage"; -import { CleanPage } from "./pages/Clean/CleanPage"; -import { CollectPage } from "./pages/Collect/CollectPage"; -import { HomePage } from "./pages/Home/HomePage"; -import { ReviewPage } from "./pages/Review/ReviewPage"; -import { DataPage } from "./pages/Data/DataPage"; -import { UsersPage } from "./pages/Users/UsersPage"; +import { AppRoutes } from "./routes/AppRoutes"; export function App() { return ( - - } /> - - }> - } /> - } /> - - - }> - } /> - } /> - } /> - } /> - } /> - - - } /> - + ); diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts new file mode 100644 index 0000000..6346afe --- /dev/null +++ b/frontend/src/api/dashboard.ts @@ -0,0 +1,171 @@ +import { request } from "./http"; + +// ── Visão Geral ─────────────────────────────────────────────────── + +export interface GlobalSummary { + total_datasets: number; + total_comments_annotated: number; + total_comments_in_datasets: number; + annotation_progress: number; + total_bots: number; + total_humans: number; + total_conflicts: number; + pending_conflicts: number; + agreement_rate: number; +} + +export interface GlobalDashboardResponse { + summary: GlobalSummary; + active_criteria_filter: string[]; + label_distribution_chart: string; + comparativo_por_dataset_chart: string; + annotations_over_time_chart: string; + bot_rate_by_dataset_chart: string; + agreement_by_dataset_chart: string; + criteria_effectiveness_chart: string; +} + +// ── Eficácia por Critério ───────────────────────────────────────── + +export interface CriteriaEffectivenessItem { + criteria: string; + group: string; + total_datasets: number; + total_comments_selected: number; + total_bots: number; + bot_rate: number; +} + +// ── Por Vídeo ───────────────────────────────────────────────────── + +export interface VideoSummary { + total_comments_collected: number; + total_comments_in_datasets: number; + total_annotated: number; + total_bots: number; + total_humans: number; + total_conflicts: number; + pending_conflicts: number; + agreement_rate: number; +} + +export interface VideoHighlight { + label: string; + value: string; + detail: string | null; +} + +export interface VideoDashboardResponse { + video_id: string; + summary: VideoSummary; + highlights: VideoHighlight[]; + active_criteria_filter: string[]; + label_distribution_chart: string; + comparativo_por_dataset_chart: string; + bot_rate_by_criteria_chart: string; + comment_timeline_chart: string; +} + +// ── Meu Progresso ───────────────────────────────────────────────── + +export interface UserSummary { + total_datasets_assigned: number; + datasets_completed: number; + datasets_pending: number; + total_annotated: number; + total_pending: number; + bots: number; + humans: number; + conflicts_generated: number; +} + +export interface UserDatasetProgress { + dataset_id: string; + dataset_name: string; + video_id: string; + total_comments: number; + annotated_by_me: number; + pending: number; + percent_complete: number; + my_bots: number; + my_conflicts: number; + status: string; +} + +export interface UserDashboardResponse { + summary: UserSummary; + datasets: UserDatasetProgress[]; + my_label_distribution_chart: string; + my_progress_by_dataset_chart: string; + my_annotations_over_time_chart: string; +} + +// ── Tabela de Bots ──────────────────────────────────────────────── + +export interface BotCommentItem { + dataset_name: string; + author_display_name: string; + text_original: string; + concordance_pct: number; + conflict_status: string | null; + annotators_count: number; + criteria: string[]; +} + +export interface BotCommentsResponse { + total: number; + items: BotCommentItem[]; +} + +// ── API ─────────────────────────────────────────────────────────── + +export const dashboardApi = { + global: (token: string, criteria?: string[]) => { + const params = criteria?.length ? `?criteria=${criteria.join(",")}` : ""; + return request(`/dashboard/global${params}`, {}, token); + }, + + video: (token: string, videoId: string, criteria?: string[]) => { + const criteriaParam = criteria?.length ? `&criteria=${criteria.join(",")}` : ""; + return request( + `/dashboard/video?video_id=${encodeURIComponent(videoId)}${criteriaParam}`, + {}, + token + ); + }, + + user: (token: string) => request("/dashboard/user", {}, token), + + bots: ( + token: string, + params?: { + dataset_id?: string; + video_id?: string; + author?: string; + search?: string; + criteria?: string[]; + page?: number; + page_size?: number; + } + ) => { + const qs = new URLSearchParams(); + if (params?.dataset_id) qs.set("dataset_id", params.dataset_id); + if (params?.video_id) qs.set("video_id", params.video_id); + if (params?.author) qs.set("author", params.author); + if (params?.search) qs.set("search", params.search); + if (params?.criteria?.length) qs.set("criteria", params.criteria.join(",")); + if (params?.page) qs.set("page", String(params.page)); + if (params?.page_size) qs.set("page_size", String(params.page_size)); + const query = qs.toString(); + return request(`/dashboard/bots${query ? `?${query}` : ""}`, {}, token); + }, + + criteriaEffectiveness: (token: string, videoId?: string) => { + const params = videoId ? `?video_id=${encodeURIComponent(videoId)}` : ""; + return request( + `/dashboard/criteria-effectiveness${params}`, + {}, + token + ); + }, +}; diff --git a/frontend/src/hooks/useDashboard.ts b/frontend/src/hooks/useDashboard.ts new file mode 100644 index 0000000..a6697d0 --- /dev/null +++ b/frontend/src/hooks/useDashboard.ts @@ -0,0 +1,121 @@ +import { useCallback, useState } from "react"; +import { + dashboardApi, + type BotCommentsResponse, + type GlobalDashboardResponse, + type UserDashboardResponse, + type VideoDashboardResponse, +} from "../api/dashboard"; +import { dataApi, type DataCollection } from "../api/data"; +import { useAuthContext } from "../contexts/AuthContext"; + +export function useDashboard() { + const { token } = useAuthContext(); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Dados + const [globalData, setGlobalData] = useState(null); + const [videoData, setVideoData] = useState(null); + const [userData, setUserData] = useState(null); + const [botsData, setBotsData] = useState(null); + const [collections, setCollections] = useState([]); + + // Filtro de critérios ativo + const [activeCriteria, setActiveCriteria] = useState([]); + + const fetchGlobal = useCallback( + async (criteria?: string[]) => { + if (!token) return; + setLoading(true); + setError(null); + try { + const data = await dashboardApi.global(token, criteria); + setGlobalData(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Erro ao carregar dashboard."); + } finally { + setLoading(false); + } + }, + [token] + ); + + const fetchVideo = useCallback( + async (videoId: string, criteria?: string[]) => { + if (!token) return; + setLoading(true); + setError(null); + try { + const data = await dashboardApi.video(token, videoId, criteria); + setVideoData(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Erro ao carregar dados do vídeo."); + } finally { + setLoading(false); + } + }, + [token] + ); + + const fetchUser = useCallback(async () => { + if (!token) return; + setLoading(true); + setError(null); + try { + const data = await dashboardApi.user(token); + setUserData(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Erro ao carregar progresso."); + } finally { + setLoading(false); + } + }, [token]); + + const fetchBots = useCallback( + async (params?: { + dataset_id?: string; + author?: string; + search?: string; + page?: number; + page_size?: number; + }) => { + if (!token) return; + try { + const data = await dashboardApi.bots(token, params); + setBotsData(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Erro ao carregar tabela de bots."); + } + }, + [token] + ); + + const fetchCollections = useCallback(async () => { + if (!token) return; + try { + const data = await dataApi.collections(token); + setCollections(data); + } catch { + // silencioso — collections é auxiliar para o dropdown + } + }, [token]); + + return { + loading, + error, + globalData, + videoData, + userData, + botsData, + collections, + activeCriteria, + setActiveCriteria, + fetchGlobal, + fetchVideo, + fetchUser, + fetchBots, + fetchCollections, + }; +} diff --git a/frontend/src/pages/Dashboard/AbbreviatedChart.tsx b/frontend/src/pages/Dashboard/AbbreviatedChart.tsx new file mode 100644 index 0000000..2a350e8 --- /dev/null +++ b/frontend/src/pages/Dashboard/AbbreviatedChart.tsx @@ -0,0 +1,92 @@ +import { useMemo } from "react"; +import { PlotlyChart } from "./PlotlyChart"; + +interface AbbreviatedChartProps { + figureJson: string; + height?: number; + prefix?: string; +} + +/** + * Renderiza um gráfico Plotly substituindo nomes longos no eixo X/Y + * por siglas curtas (D1, D2, ...) e mostra uma legenda compacta abaixo. + */ +export function AbbreviatedChart({ figureJson, height, prefix = "D" }: AbbreviatedChartProps) { + const { modifiedJson, legend } = useMemo(() => { + try { + const fig = JSON.parse(figureJson) as { + data: Array>; + layout: Record; + }; + + // Encontrar nomes originais do primeiro trace (x ou y dependendo da orientação) + const firstTrace = fig.data[0]; + if (!firstTrace) return { modifiedJson: figureJson, legend: [] }; + + const isHorizontal = firstTrace.orientation === "h"; + const axis = isHorizontal ? "y" : "x"; + const origNames = (firstTrace[axis] as string[]) ?? []; + + if (origNames.length === 0) { + return { modifiedJson: figureJson, legend: [] }; + } + + // Criar mapeamento abreviado + const mapping = origNames.map((name, i) => ({ + short: `${prefix}${i + 1}`, + full: name, + })); + const shortNames = mapping.map((m) => m.short); + + // Substituir em todos os traces + for (const trace of fig.data) { + if (Array.isArray(trace[axis]) && (trace[axis] as string[]).length === origNames.length) { + (trace as Record)[axis] = shortNames; + } + // Adicionar nome completo no hover via customdata + if (!trace.customdata) { + (trace as Record).customdata = origNames.map((n) => [n]); + } + // Atualizar hovertemplate para mostrar nome completo + if (typeof trace.hovertemplate === "string") { + const ht = trace.hovertemplate as string; + if (isHorizontal) { + (trace as Record).hovertemplate = ht.replace( + "%{y}", + "%{customdata[0]}" + ); + } else { + (trace as Record).hovertemplate = ht.replace( + "%{x}", + "%{customdata[0]}" + ); + } + } + } + + return { + modifiedJson: JSON.stringify(fig), + legend: mapping, + }; + } catch { + return { modifiedJson: figureJson, legend: [] }; + } + }, [figureJson, prefix]); + + return ( +
+ + {legend.length > 0 && ( +
+
+ {legend.map((item) => ( + + {item.short} {item.full} + + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Dashboard/BotCommentsTable.tsx b/frontend/src/pages/Dashboard/BotCommentsTable.tsx new file mode 100644 index 0000000..5ce0677 --- /dev/null +++ b/frontend/src/pages/Dashboard/BotCommentsTable.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useState } from "react"; +import type { BotCommentItem, BotCommentsResponse } from "../../api/dashboard"; + +interface BotCommentsTableProps { + data: BotCommentsResponse | null; + videoId?: string; + onFetch: (params: { + search?: string; + video_id?: string; + criteria?: string[]; + page?: number; + page_size?: number; + }) => void; +} + +const CRITERIA_OPTIONS = [ + "percentil", + "media", + "moda", + "mediana", + "curtos", + "intervalo", + "identicos", + "perfil", +]; + +export function BotCommentsTable({ data, videoId, onFetch }: BotCommentsTableProps) { + const [search, setSearch] = useState(""); + const [criteriaFilter, setCriteriaFilter] = useState([]); + const [page, setPage] = useState(1); + const pageSize = 10; + + const doFetch = useCallback( + (p: number, s: string, crit: string[]) => { + onFetch({ + search: s || undefined, + video_id: videoId, + criteria: crit.length > 0 ? crit : undefined, + page: p, + page_size: pageSize, + }); + }, + [onFetch, videoId] + ); + + useEffect(() => { + doFetch(1, "", []); + }, [doFetch]); + + const handleSearch = () => { + setPage(1); + doFetch(1, search, criteriaFilter); + }; + + const handleCriteriaToggle = (crit: string) => { + const next = criteriaFilter.includes(crit) + ? criteriaFilter.filter((c) => c !== crit) + : [...criteriaFilter, crit]; + setCriteriaFilter(next); + setPage(1); + doFetch(1, search, next); + }; + + const handlePage = (newPage: number) => { + setPage(newPage); + doFetch(newPage, search, criteriaFilter); + }; + + const totalPages = data ? Math.ceil(data.total / pageSize) : 0; + + return ( +
+
+
+
+

Comentários Bot

+ {data && ( +

+ {data.total} comentários encontrados +

+ )} +
+
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + className="form-input text-xs px-2 py-1 w-48" + /> + +
+
+ {/* Filtro por critério */} +
+ + Critério: + + {CRITERIA_OPTIONS.map((crit) => ( + + ))} + {criteriaFilter.length > 0 && ( + + )} +
+
+ +
+ + + + + + + + + + + + + + {data?.items.map((item: BotCommentItem, i: number) => ( + + + + + + + + + + ))} + {data?.items.length === 0 && ( + + + + )} + +
+ Dataset + + Autor + + Comentário + + Anot. + + Concord. + + Status + + Critérios +
+ {item.dataset_name} + + {item.author_display_name} + + {item.text_original} + + {item.annotators_count} + + = 50 + ? "bg-amber-50 text-amber-700" + : "bg-red-50 text-red-700" + }`} + > + {item.concordance_pct}% + + + {item.conflict_status && ( + + {item.conflict_status === "resolved" ? "Resolvido" : "Pendente"} + + )} + +
+ {item.criteria.map((c) => ( + + {c} + + ))} +
+
+ Nenhum comentário bot encontrado. +
+
+ + {totalPages > 1 && ( +
+ + + Página {page} de {totalPages} + + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Dashboard/CriteriaFilterBar.tsx b/frontend/src/pages/Dashboard/CriteriaFilterBar.tsx new file mode 100644 index 0000000..da7cc36 --- /dev/null +++ b/frontend/src/pages/Dashboard/CriteriaFilterBar.tsx @@ -0,0 +1,58 @@ +interface CriteriaFilterBarProps { + active: string[]; + onChange: (criteria: string[]) => void; +} + +const GROUPS = [ + { + label: "Numérico", + criteria: ["percentil", "media", "moda", "mediana"], + }, + { + label: "Comportamental", + criteria: ["curtos", "intervalo", "identicos", "perfil"], + }, +]; + +export function CriteriaFilterBar({ active, onChange }: CriteriaFilterBarProps) { + const toggle = (crit: string) => { + if (active.includes(crit)) { + onChange(active.filter((c) => c !== crit)); + } else { + onChange([...active, crit]); + } + }; + + const clear = () => onChange([]); + + return ( +
+ {GROUPS.map((group) => ( +
+ + {group.label}: + + {group.criteria.map((crit) => ( + + ))} +
+ ))} + {active.length > 0 && ( + + )} +
+ ); +} diff --git a/frontend/src/pages/Dashboard/DashboardPage.tsx b/frontend/src/pages/Dashboard/DashboardPage.tsx new file mode 100644 index 0000000..2c6ec6f --- /dev/null +++ b/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { PageHeader } from "../../components/PageHeader"; +import { useDashboard } from "../../hooks/useDashboard"; +import { GlobalTab } from "./GlobalTab"; +import { UserTab } from "./UserTab"; +import { VideoTab } from "./VideoTab"; + +type Tab = "global" | "video" | "user"; + +const TABS: { key: Tab; label: string; description: string }[] = [ + { key: "global", label: "Visão Geral", description: "Métricas globais de todos os vídeos" }, + { key: "video", label: "Por Vídeo", description: "Análise detalhada por vídeo" }, + { key: "user", label: "Meu Progresso", description: "Seu progresso como anotador" }, +]; + +export function DashboardPage() { + const [tab, setTab] = useState("global"); + const { + error, + globalData, + videoData, + userData, + botsData, + collections, + activeCriteria, + setActiveCriteria, + fetchGlobal, + fetchVideo, + fetchUser, + fetchBots, + fetchCollections, + } = useDashboard(); + + return ( +
+ + +
+
+

Dashboard de Análise

+

{TABS.find((t) => t.key === tab)?.description}

+
+ + {error &&
{error}
} + + {/* Tabs */} +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === "global" && ( + + )} + {tab === "video" && ( + + )} + {tab === "user" && } +
+
+ ); +} diff --git a/frontend/src/pages/Dashboard/GlobalTab.tsx b/frontend/src/pages/Dashboard/GlobalTab.tsx new file mode 100644 index 0000000..eabb863 --- /dev/null +++ b/frontend/src/pages/Dashboard/GlobalTab.tsx @@ -0,0 +1,102 @@ +import { useEffect } from "react"; +import type { BotCommentsResponse, GlobalDashboardResponse } from "../../api/dashboard"; +import { AbbreviatedChart } from "./AbbreviatedChart"; +import { BotCommentsTable } from "./BotCommentsTable"; +import { CriteriaFilterBar } from "./CriteriaFilterBar"; +import { KpiCards } from "./KpiCards"; +import { ChartCard, PlotlyChart } from "./PlotlyChart"; + +interface GlobalTabProps { + data: GlobalDashboardResponse | null; + botsData: BotCommentsResponse | null; + activeCriteria: string[]; + onCriteriaChange: (criteria: string[]) => void; + onFetchGlobal: (criteria?: string[]) => void; + onFetchBots: (params: { search?: string; page?: number; page_size?: number }) => void; +} + +export function GlobalTab({ + data, + botsData, + activeCriteria, + onCriteriaChange, + onFetchGlobal, + onFetchBots, +}: GlobalTabProps) { + useEffect(() => { + onFetchGlobal(); + }, [onFetchGlobal]); + + const handleCriteriaChange = (criteria: string[]) => { + onCriteriaChange(criteria); + onFetchGlobal(criteria.length > 0 ? criteria : undefined); + }; + + if (!data) { + return ( +
+
Carregando dashboard...
+
+ ); + } + + const s = data.summary; + + return ( +
+ + + + + {/* Linha 1: Distribuição + Comparativo */} +
+ + + + + + +
+ + {/* Linha 2: Taxa de Bots + Concordância por Dataset */} +
+ + + + + + +
+ + {/* Linha 3: Timeline + Eficácia por Critério */} +
+ + + + + + +
+ + +
+ ); +} diff --git a/frontend/src/pages/Dashboard/KpiCards.tsx b/frontend/src/pages/Dashboard/KpiCards.tsx new file mode 100644 index 0000000..100038d --- /dev/null +++ b/frontend/src/pages/Dashboard/KpiCards.tsx @@ -0,0 +1,39 @@ +interface KpiCard { + label: string; + value: number | string; + color?: string; +} + +interface KpiCardsProps { + cards: KpiCard[]; +} + +const STYLES: Record = { + green: { bg: "bg-emerald-50", text: "text-emerald-600", ring: "ring-emerald-200" }, + red: { bg: "bg-red-50", text: "text-red-600", ring: "ring-red-200" }, + yellow: { bg: "bg-amber-50", text: "text-amber-600", ring: "ring-amber-200" }, + blue: { bg: "bg-blue-50", text: "text-blue-600", ring: "ring-blue-200" }, + orange: { bg: "bg-orange-50", text: "text-orange-600", ring: "ring-orange-200" }, + neutral: { bg: "bg-gray-50", text: "text-gray-700", ring: "ring-gray-200" }, +}; + +export function KpiCards({ cards }: KpiCardsProps) { + return ( +
+ {cards.map((card) => { + const s = STYLES[card.color ?? "neutral"]; + return ( +
+

+ {card.label} +

+

{card.value}

+
+ ); + })} +
+ ); +} diff --git a/frontend/src/pages/Dashboard/PlotlyChart.tsx b/frontend/src/pages/Dashboard/PlotlyChart.tsx new file mode 100644 index 0000000..37029ff --- /dev/null +++ b/frontend/src/pages/Dashboard/PlotlyChart.tsx @@ -0,0 +1,70 @@ +import Plotly from "plotly.js-dist-min"; +import { useEffect, useRef } from "react"; + +interface PlotlyChartProps { + figureJson: string; + height?: number; +} + +export function PlotlyChart({ figureJson, height = 320 }: PlotlyChartProps) { + const ref = useRef(null); + + useEffect(() => { + if (!ref.current || !figureJson) return; + + let fig: { data: Plotly.Data[]; layout: Partial }; + try { + fig = JSON.parse(figureJson) as { + data: Plotly.Data[]; + layout: Partial; + }; + } catch { + return; + } + + const layout: Partial = { + ...fig.layout, + autosize: true, + height, + }; + + Plotly.newPlot(ref.current, fig.data, layout, { + responsive: true, + displayModeBar: false, + }); + + const el = ref.current; + const ro = new ResizeObserver(() => { + if (el) Plotly.Plots.resize(el); + }); + ro.observe(el); + + return () => { + ro.disconnect(); + if (el) Plotly.purge(el); + }; + }, [figureJson, height]); + + return
; +} + +interface ChartCardProps { + title: string; + subtitle?: string; + children: React.ReactNode; + full?: boolean; +} + +export function ChartCard({ title, subtitle, children, full }: ChartCardProps) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {children} +
+ ); +} diff --git a/frontend/src/pages/Dashboard/UserTab.tsx b/frontend/src/pages/Dashboard/UserTab.tsx new file mode 100644 index 0000000..cfef253 --- /dev/null +++ b/frontend/src/pages/Dashboard/UserTab.tsx @@ -0,0 +1,168 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import type { UserDashboardResponse } from "../../api/dashboard"; +import { ProgressBar } from "../../components/ProgressBar"; +import { AbbreviatedChart } from "./AbbreviatedChart"; +import { KpiCards } from "./KpiCards"; +import { ChartCard, PlotlyChart } from "./PlotlyChart"; + +interface UserTabProps { + data: UserDashboardResponse | null; + onFetchUser: () => void; +} + +export function UserTab({ data, onFetchUser }: UserTabProps) { + const navigate = useNavigate(); + + useEffect(() => { + onFetchUser(); + }, [onFetchUser]); + + if (!data) { + return ( +
+
Carregando progresso...
+
+ ); + } + + const s = data.summary; + + const statusBadge = (status: string) => { + const map: Record = { + completed: { label: "Concluído", cls: "bg-emerald-50 text-emerald-700" }, + in_progress: { label: "Em andamento", cls: "bg-blue-50 text-blue-700" }, + not_started: { label: "Não iniciado", cls: "bg-gray-100 text-gray-500" }, + }; + const { label, cls } = map[status] ?? map.not_started; + return ( + + {label} + + ); + }; + + return ( +
+ + + {/* Gráficos */} +
+ + + + + + +
+ +
+ + + +
+ + {/* Tabela de progresso por dataset */} +
+
+

Detalhamento por Dataset

+

+ Progresso individual em cada dataset atribuído +

+
+
+ + + + + + + + + + + + + + {data.datasets.map((ds) => ( + + + + + + + + + + ))} + {data.datasets.length === 0 && ( + + + + )} + +
+ Dataset + + Vídeo + + Progresso + + Bots + + Conflitos + + Status + + Ação +
+ {ds.dataset_name} + + {ds.video_id} + +
+
+ +
+ + {ds.annotated_by_me}/{ds.total_comments} + +
+
+ {ds.my_bots} + + {ds.my_conflicts} + {statusBadge(ds.status)} + {ds.status !== "completed" && ( + + )} +
+ Nenhum dataset disponível para anotação. +
+
+
+
+ ); +} diff --git a/frontend/src/pages/Dashboard/VideoTab.tsx b/frontend/src/pages/Dashboard/VideoTab.tsx new file mode 100644 index 0000000..bc4f421 --- /dev/null +++ b/frontend/src/pages/Dashboard/VideoTab.tsx @@ -0,0 +1,174 @@ +import { useEffect, useState } from "react"; +import type { DataCollection } from "../../api/data"; +import type { BotCommentsResponse, VideoDashboardResponse } from "../../api/dashboard"; +import { AbbreviatedChart } from "./AbbreviatedChart"; +import { BotCommentsTable } from "./BotCommentsTable"; +import { CriteriaFilterBar } from "./CriteriaFilterBar"; +import { KpiCards } from "./KpiCards"; +import { ChartCard, PlotlyChart } from "./PlotlyChart"; + +interface VideoTabProps { + data: VideoDashboardResponse | null; + botsData: BotCommentsResponse | null; + collections: DataCollection[]; + activeCriteria: string[]; + onCriteriaChange: (criteria: string[]) => void; + onFetchVideo: (videoId: string, criteria?: string[]) => void; + onFetchCollections: () => void; + onFetchBots: (params: { + search?: string; + video_id?: string; + criteria?: string[]; + page?: number; + page_size?: number; + }) => void; +} + +export function VideoTab({ + data, + botsData, + collections, + activeCriteria, + onCriteriaChange, + onFetchVideo, + onFetchCollections, + onFetchBots, +}: VideoTabProps) { + const [selectedVideoId, setSelectedVideoId] = useState(""); + + useEffect(() => { + onFetchCollections(); + }, [onFetchCollections]); + + const uniqueVideos = collections.reduce( + (acc, col) => { + if (!acc.find((v) => v.video_id === col.video_id)) { + acc.push({ video_id: col.video_id, video_title: col.video_title }); + } + return acc; + }, + [] as { video_id: string; video_title: string | null }[] + ); + + const handleSelectVideo = (videoId: string) => { + setSelectedVideoId(videoId); + if (videoId) { + onFetchVideo(videoId, activeCriteria.length > 0 ? activeCriteria : undefined); + } + }; + + const handleCriteriaChange = (criteria: string[]) => { + onCriteriaChange(criteria); + if (selectedVideoId) { + onFetchVideo(selectedVideoId, criteria.length > 0 ? criteria : undefined); + } + }; + + return ( +
+
+ + +
+ + {!selectedVideoId && ( +
+ + + +

Selecione um vídeo acima para visualizar as métricas.

+
+ )} + + {selectedVideoId && data && ( + <> + + + {/* Destaques do vídeo */} + {data.highlights.length > 0 && ( +
+ {data.highlights.map((h) => ( +
+

+ {h.label} +

+

+ {h.value} +

+ {h.detail && ( +

+ {h.detail} +

+ )} +
+ ))} +
+ )} + + + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + )} +
+ ); +} diff --git a/frontend/src/pages/Home/HomePage.tsx b/frontend/src/pages/Home/HomePage.tsx index 7315736..4c5300b 100644 --- a/frontend/src/pages/Home/HomePage.tsx +++ b/frontend/src/pages/Home/HomePage.tsx @@ -211,7 +211,7 @@ const TOOLS_CARDS: StageCard[] = [ "Visualize métricas globais e individuais sobre as anotações com gráficos interativos.", route: "/dashboard", adminOnly: false, - available: false, + available: true, icon: , }, ]; diff --git a/frontend/src/pages/NotFound/NotFoundPage.tsx b/frontend/src/pages/NotFound/NotFoundPage.tsx new file mode 100644 index 0000000..b3ac36d --- /dev/null +++ b/frontend/src/pages/NotFound/NotFoundPage.tsx @@ -0,0 +1,18 @@ +import { useNavigate } from "react-router-dom"; + +export function NotFoundPage() { + const navigate = useNavigate(); + + return ( +
+

404

+

Página não encontrada

+

+ O endereço que você tentou acessar não existe ou foi removido. +

+ +
+ ); +} diff --git a/frontend/src/routes/AppRoutes.tsx b/frontend/src/routes/AppRoutes.tsx new file mode 100644 index 0000000..a40b901 --- /dev/null +++ b/frontend/src/routes/AppRoutes.tsx @@ -0,0 +1,40 @@ +import { Route, Routes } from "react-router-dom"; +import { ProtectedRoute } from "../components/ProtectedRoute"; +import { LoginPage } from "../pages/Auth/LoginPage"; +import { AnnotatePage } from "../pages/Annotate/AnnotatePage"; +import { CleanPage } from "../pages/Clean/CleanPage"; +import { CollectPage } from "../pages/Collect/CollectPage"; +import { DashboardPage } from "../pages/Dashboard/DashboardPage"; +import { DataPage } from "../pages/Data/DataPage"; +import { HomePage } from "../pages/Home/HomePage"; +import { NotFoundPage } from "../pages/NotFound/NotFoundPage"; +import { ReviewPage } from "../pages/Review/ReviewPage"; +import { UsersPage } from "../pages/Users/UsersPage"; + +export function AppRoutes() { + return ( + + {/* Pública */} + } /> + + {/* Protegidas — admin */} + }> + } /> + } /> + + + {/* Protegidas — qualquer usuário autenticado */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* 404 */} + } /> + + ); +} diff --git a/frontend/src/types/plotly.d.ts b/frontend/src/types/plotly.d.ts new file mode 100644 index 0000000..f6d13fb --- /dev/null +++ b/frontend/src/types/plotly.d.ts @@ -0,0 +1,4 @@ +declare module "plotly.js-dist-min" { + import Plotly from "plotly.js"; + export = Plotly; +}