From 074ccd3fcb0d99f471dffe062a30934563954590 Mon Sep 17 00:00:00 2001 From: barcelosfrederico Date: Mon, 25 May 2026 14:32:54 -0300 Subject: [PATCH] feat(ui): implementa US06, US07 e US08 faltantes do enunciado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit US06 — distribuição de tamanho de descrição: - pipeline_bridge: adiciona colunas chars e words ao DataFrame calculados diretamente do PRRecord.body - charts: render_body_size_chart() — barra agrupada de avg chars/words por linguagem, tipo ou natureza (selectbox de dimensão) US07 — cruzamento clareza x tipo/natureza/linguagem: - charts: render_clarity_cross_chart() — barra agrupada mostrando distribuição de clareza cruzada com a dimensão selecionada - tabs: nova linha _render_analysis_row() com os dois gráficos US08 — filtros por tipo de projeto e clareza: - data.apply_filters: aceita proj_type e clarity; implementado com functools.reduce sobre tupla de pares (col, val) ativos - data.get_filter_options: retorna 4 listas (lang, nature, type, clarity) - sidebar._render_filters: adiciona selectbox TIPO DE PROJETO e CLAREZA - sidebar.render_sidebar: retorna 7-tupla incluindo sel_type e sel_clarity - app: desempacota os novos valores e os passa para apply_filters Co-Authored-By: Claude Sonnet 4.6 --- src/pr_analyzer/ui/app.py | 6 +- src/pr_analyzer/ui/components/charts.py | 110 ++++++++++++++++++++ src/pr_analyzer/ui/components/sidebar.py | 16 +-- src/pr_analyzer/ui/components/tabs.py | 12 +++ src/pr_analyzer/ui/utils/data.py | 51 +++++---- src/pr_analyzer/ui/utils/pipeline_bridge.py | 12 ++- 6 files changed, 174 insertions(+), 33 deletions(-) diff --git a/src/pr_analyzer/ui/app.py b/src/pr_analyzer/ui/app.py index 9e33bdd..c65e4f9 100644 --- a/src/pr_analyzer/ui/app.py +++ b/src/pr_analyzer/ui/app.py @@ -65,7 +65,9 @@ st.session_state.llm_enriched = False # ── Sidebar ─────────────────────────────────────────────────────────────────── -sel_lang, sel_nature, cleaning, llm_tag, metrics = render_sidebar() +sel_lang, sel_nature, sel_type, sel_clarity, cleaning, llm_tag, metrics = ( + render_sidebar() +) # ── LLM enrichment (TASK-39) ────────────────────────────────────────────────── @@ -130,7 +132,7 @@ def _maybe_enrich() -> None: _maybe_enrich() # ── Filtered DataFrame (pure transform) ─────────────────────────────────────── -df = apply_filters(st.session_state.df, sel_lang, sel_nature) +df = apply_filters(st.session_state.df, sel_lang, sel_nature, sel_type, sel_clarity) # ══════════════════════════════════════════════════════════════════════════════ # MAIN AREA diff --git a/src/pr_analyzer/ui/components/charts.py b/src/pr_analyzer/ui/components/charts.py index 3e6dc43..297191e 100644 --- a/src/pr_analyzer/ui/components/charts.py +++ b/src/pr_analyzer/ui/components/charts.py @@ -226,6 +226,116 @@ def render_clarity_gauge(df: pd.DataFrame) -> None: st.plotly_chart(fig, use_container_width=True, config=_chart_cfg()) +def render_body_size_chart(df: pd.DataFrame) -> None: + """Bar chart: avg description length (chars and words) grouped by a dimension. + + US06 — visualizar distribuição de tamanho de descrição estratificada. + """ + _panel_header( + "📝 Tamanho de Descrição por Dimensão", + "linear-gradient(180deg,#34d399,#059669)", + ) + + if "chars" not in df.columns or len(df) == 0: + st.info(_NO_DATA_MSG) + return + + dim: str = st.selectbox( + "Agrupar por", + ["lang", "type", "nature"], + format_func=lambda x: { + "lang": "Linguagem", + "type": "Tipo", + "nature": "Natureza", + }[x], + key="body_size_dim", + label_visibility="collapsed", + ) + + if dim not in df.columns: + st.info(_NO_DATA_MSG) + return + + agg = ( + df[df[dim] != "—"] + .groupby(dim)[["chars", "words"]] + .mean() + .round(0) + .reset_index() + .sort_values("chars", ascending=False) + ) + + if agg.empty: + st.info(_NO_DATA_MSG) + return + + fig = px.bar( + agg, + x=dim, + y=["chars", "words"], + barmode="group", + color_discrete_map={"chars": "#6366f1", "words": "#34d399"}, + labels={dim: "", "value": "Média", "variable": "Métrica"}, + ) + fig.update_layout( + **PLOT_BASE, + legend={"orientation": "h", "y": 1.1, "x": 0}, + yaxis=_axis_style(grid=True), + xaxis=_axis_style(grid=False), + ) + st.plotly_chart(fig, use_container_width=True, config=_chart_cfg()) + + +def render_clarity_cross_chart(df: pd.DataFrame) -> None: + """Grouped bar: clarity distribution crossed with project type or nature. + + US07 — visualizar relação entre clareza, tipo, natureza e linguagem. + """ + _panel_header("🔗 Clareza por Dimensão", "linear-gradient(180deg,#f472b6,#db2777)") + + required = {"clarity", "type", "nature", "lang"} + if not required.issubset(df.columns) or len(df) == 0: + st.info(_NO_DATA_MSG) + return + + dim: str = st.selectbox( + "Cruzar clareza com", + ["type", "nature", "lang"], + format_func=lambda x: { + "type": "Tipo de Projeto", + "nature": "Natureza", + "lang": "Linguagem", + }[x], + key="clarity_cross_dim", + label_visibility="collapsed", + ) + + filtered = df[(df["clarity"] != "—") & (df[dim] != "—")] + if filtered.empty: + st.info(_NO_DATA_MSG) + return + + counts = filtered.groupby([dim, "clarity"]).size().reset_index(name="count") + + fig = px.bar( + counts, + x=dim, + y="count", + color="clarity", + barmode="group", + color_discrete_map=CLARITY_COLOR, + category_orders={"clarity": CLARITY_ORDER}, + labels={dim: "", "count": "PRs", "clarity": "Clareza"}, + ) + fig.update_layout( + **PLOT_BASE, + legend={"orientation": "h", "y": 1.1, "x": 0}, + yaxis=_axis_style(grid=True), + xaxis=_axis_style(grid=False), + ) + st.plotly_chart(fig, use_container_width=True, config=_chart_cfg()) + + # ── Private helpers ────────────────────────────────────────────────────────── diff --git a/src/pr_analyzer/ui/components/sidebar.py b/src/pr_analyzer/ui/components/sidebar.py index 0a66a66..b991a2b 100644 --- a/src/pr_analyzer/ui/components/sidebar.py +++ b/src/pr_analyzer/ui/components/sidebar.py @@ -29,10 +29,10 @@ ) -def render_sidebar() -> tuple[str, str, bool, bool, bool]: +def render_sidebar() -> tuple[str, str, str, str, bool, bool, bool]: """ Render the full sidebar and return: - (sel_lang, sel_nature, cleaning, llm_tag, metrics) + (sel_lang, sel_nature, sel_type, sel_clarity, cleaning, llm_tag, metrics) Handles file upload, local dataset selection, and LLM backend state internally. """ with st.sidebar: @@ -43,9 +43,9 @@ def render_sidebar() -> tuple[str, str, bool, bool, bool]: st.divider() cleaning, llm_tag, metrics = _render_pipeline() st.divider() - sel_lang, sel_nature = _render_filters() + sel_lang, sel_nature, sel_type, sel_clarity = _render_filters() - return sel_lang, sel_nature, cleaning, llm_tag, metrics + return sel_lang, sel_nature, sel_type, sel_clarity, cleaning, llm_tag, metrics # ── Private helpers ─────────────────────────────────────────────────────────── @@ -301,9 +301,11 @@ def _render_cache_indicator() -> None: # ── Filters ─────────────────────────────────────────────────────────────────── -def _render_filters() -> tuple[str, str]: +def _render_filters() -> tuple[str, str, str, str]: st.markdown("### 🔍 REFINAR VISÃO") - langs, natures = get_filter_options(st.session_state.df) + langs, natures, types, clarities = get_filter_options(st.session_state.df) sel_lang: str = st.selectbox("LINGUAGEM", langs) sel_nature: str = st.selectbox("NATUREZA", natures) - return sel_lang, sel_nature + sel_type: str = st.selectbox("TIPO DE PROJETO", types) + sel_clarity: str = st.selectbox("CLAREZA", clarities) + return sel_lang, sel_nature, sel_type, sel_clarity diff --git a/src/pr_analyzer/ui/components/tabs.py b/src/pr_analyzer/ui/components/tabs.py index d9fc3ae..88ba914 100644 --- a/src/pr_analyzer/ui/components/tabs.py +++ b/src/pr_analyzer/ui/components/tabs.py @@ -15,6 +15,8 @@ import pandas as pd import streamlit as st from components.charts import ( + render_body_size_chart, + render_clarity_cross_chart, render_clarity_distribution, render_clarity_gauge, render_lang_distribution, @@ -57,6 +59,8 @@ def render_tab_dashboard(df: pd.DataFrame, metrics_active: bool) -> None: _render_distribution_row_bottom(distributions) st.markdown("
", unsafe_allow_html=True) _render_correlation_row(df) + st.markdown("
", unsafe_allow_html=True) + _render_analysis_row(df) def _render_distribution_row_top(distributions: dict[str, dict[str, int]]) -> None: @@ -85,6 +89,14 @@ def _render_correlation_row(df: pd.DataFrame) -> None: render_clarity_gauge(df) +def _render_analysis_row(df: pd.DataFrame) -> None: + col_size, col_cross = st.columns(2, gap="large") + with col_size: + render_body_size_chart(df) + with col_cross: + render_clarity_cross_chart(df) + + # ══════════════════════════════════════════════════════════════════════════════ # TAB 2 — EXPLORER # ══════════════════════════════════════════════════════════════════════════════ diff --git a/src/pr_analyzer/ui/utils/data.py b/src/pr_analyzer/ui/utils/data.py index 7eceb33..4e34edf 100644 --- a/src/pr_analyzer/ui/utils/data.py +++ b/src/pr_analyzer/ui/utils/data.py @@ -109,14 +109,27 @@ def apply_filters( df: pd.DataFrame, lang: str, nature: str, + proj_type: str = "Todas", + clarity: str = "Todas", ) -> pd.DataFrame: """Return a filtered copy of *df* without mutating the original.""" - result = df.copy() - if lang != "Todas" and "lang" in result.columns: - result = result[result["lang"] == lang] - if nature != "Todas" and "nature" in result.columns: - result = result[result["nature"] == nature] - return result + from functools import reduce + + active = tuple( + (col, val) + for col, val in ( + ("lang", lang), + ("nature", nature), + ("type", proj_type), + ("clarity", clarity), + ) + if val != "Todas" + ) + return reduce( + lambda _df, cv: _df[_df[cv[0]] == cv[1]] if cv[0] in _df.columns else _df, + active, + df.copy(), + ) # ─── Export helpers ─────────────────────────────────────────────────────────── @@ -282,16 +295,16 @@ def _clarity_heuristic(body: str) -> str: # ─── Filtering (pure transform) ────────────────────────────────────────────── -def get_filter_options(df: pd.DataFrame) -> tuple[list[str], list[str]]: - """Return (lang_options, nature_options) including a 'Todas' sentinel.""" - langs = ( - ["Todas", *sorted(df["lang"].dropna().unique().tolist())] - if "lang" in df.columns - else ["Todas"] - ) - natures = ( - ["Todas", *sorted(df["nature"].dropna().unique().tolist())] - if "nature" in df.columns - else ["Todas"] - ) - return langs, natures +def get_filter_options( + df: pd.DataFrame, +) -> tuple[list[str], list[str], list[str], list[str]]: + """Return (lang, nature, type, clarity) option lists including a 'Todas' sentinel.""" + + def _opts(col: str) -> list[str]: + return ( + ["Todas", *sorted(df[col].dropna().unique().tolist())] + if col in df.columns + else ["Todas"] + ) + + return _opts("lang"), _opts("nature"), _opts("type"), _opts("clarity") diff --git a/src/pr_analyzer/ui/utils/pipeline_bridge.py b/src/pr_analyzer/ui/utils/pipeline_bridge.py index 927b91f..01facbe 100644 --- a/src/pr_analyzer/ui/utils/pipeline_bridge.py +++ b/src/pr_analyzer/ui/utils/pipeline_bridge.py @@ -259,6 +259,8 @@ def enrich_prs( "nature", "clarity", "size", + "chars", + "words", "state", "title", ) @@ -275,6 +277,8 @@ def enriched_to_dataframe(items: Iterable[EnrichedPR]) -> pd.DataFrame: "nature": e.contribution_nature.title() if e.contribution_nature else "—", "clarity": _capitalize_clarity(e.description_clarity), "size": (e.additions or 0) + (e.deletions or 0), + "chars": len(e.body) if e.body else 0, + "words": len(e.body.split()) if e.body else 0, "state": e.state.title() if e.state else "—", "title": e.title, } @@ -286,11 +290,7 @@ def enriched_to_dataframe(items: Iterable[EnrichedPR]) -> pd.DataFrame: def prs_to_dataframe(prs: Iterable[PRRecord]) -> pd.DataFrame: - """Materialize raw PRRecords (un-enriched) into a DataFrame. - - Classification columns are left empty so the UI can highlight that LLM - classification is disabled. - """ + """Materialize raw PRRecords (un-enriched) into a DataFrame.""" rows = [ { "id": pr.pr_id, @@ -300,6 +300,8 @@ def prs_to_dataframe(prs: Iterable[PRRecord]) -> pd.DataFrame: "nature": "—", "clarity": "—", "size": (pr.additions or 0) + (pr.deletions or 0), + "chars": len(pr.body) if pr.body else 0, + "words": len(pr.body.split()) if pr.body else 0, "state": pr.state.title() if pr.state else "—", "title": pr.title, }