diff --git a/src/pr_analyzer/ui/app.py b/src/pr_analyzer/ui/app.py index 05d39b8..c65e4f9 100644 --- a/src/pr_analyzer/ui/app.py +++ b/src/pr_analyzer/ui/app.py @@ -5,7 +5,7 @@ 1. Page configuration 2. CSS injection 3. Session-state bootstrap (including the raw_prs tuple from the pipeline) - 4. Sidebar rendering -> filter values + LLM toggle + 4. Sidebar rendering → filter values + LLM toggle 5. Filtering the active DataFrame (and optionally enriching via dev3+dev4) 6. Routing to the correct main-area view (empty state OR tabs) @@ -18,12 +18,14 @@ import streamlit as st +# ── Page config (must be the very first Streamlit call) ─────────────────────── st.set_page_config( page_title="GitAnalyzer", page_icon="🧬", layout="wide", ) +# ── Internal imports (after set_page_config) ────────────────────────────────── from components.sidebar import render_sidebar # noqa: E402 from components.tabs import ( # noqa: E402 render_tab_dashboard, @@ -33,6 +35,7 @@ from utils.constants import APP_NAME, APP_VERSION # noqa: E402 from utils.data import apply_filters, get_mock_data # noqa: E402 from utils.pipeline_bridge import ( # noqa: E402 + CacheCounter, enrich_prs, enriched_to_dataframe, ) @@ -40,8 +43,10 @@ from pr_analyzer.llm.client import create_llm_client # noqa: E402 +# ── CSS ─────────────────────────────────────────────────────────────────────── inject_css() +# ── Session-state bootstrap ─────────────────────────────────────────────────── if "file_loaded" not in st.session_state: st.session_state.file_loaded = False if "fname" not in st.session_state: @@ -59,9 +64,13 @@ if "llm_enriched" not in st.session_state: st.session_state.llm_enriched = False -sel_lang, sel_nature, cleaning, llm_tag, metrics = render_sidebar() +# ── Sidebar ─────────────────────────────────────────────────────────────────── +sel_lang, sel_nature, sel_type, sel_clarity, cleaning, llm_tag, metrics = ( + render_sidebar() +) +# ── LLM enrichment (TASK-39) ────────────────────────────────────────────────── def _maybe_enrich() -> None: """Classifica PRs com o LLM configurado. Roda apenas uma vez por dataset carregado.""" if not llm_tag: @@ -93,6 +102,7 @@ def _maybe_enrich() -> None: st.error(f"Erro ao conectar ao {backend.upper()}: {exc}") return + counter = CacheCounter() enriched_list = [] with st.status( @@ -101,25 +111,35 @@ def _maybe_enrich() -> None: ) as status: st.caption(f"Modelo: `{model}`") bar = st.progress(0.0) - for i, ep in enumerate(enrich_prs(prs, client=client), 1): + for i, ep in enumerate(enrich_prs(prs, client=client, cache=counter), 1): enriched_list.append(ep) bar.progress(i / n) status.update( - label=f"✓ {n} PRs classificados com {backend.upper()}", + label=f"✓ {n} PRs classificados — {counter.cache_hits} do cache, {counter.calls_made} chamadas reais", state="complete", expanded=False, ) st.session_state.df = enriched_to_dataframe(enriched_list) - st.session_state.llm_cache_stats = {"cache_hits": 0, "calls_made": n, "total": n} + st.session_state.llm_cache_stats = { + "cache_hits": counter.cache_hits, + "calls_made": counter.calls_made, + "total": counter.total, + } st.session_state.llm_enriched = True _maybe_enrich() -df = apply_filters(st.session_state.df, sel_lang, sel_nature) +# ── Filtered DataFrame (pure transform) ─────────────────────────────────────── +df = apply_filters(st.session_state.df, sel_lang, sel_nature, sel_type, sel_clarity) + +# ══════════════════════════════════════════════════════════════════════════════ +# MAIN AREA +# ══════════════════════════════════════════════════════════════════════════════ if not st.session_state.file_loaded: + # ── Empty / landing state ───────────────────────────────────────────────── st.markdown( """
@@ -140,6 +160,7 @@ def _maybe_enrich() -> None: st.rerun() else: + # ── Main tabs ───────────────────────────────────────────────────────────── tab_dash, tab_explore, tab_export = st.tabs(["DASH", "EXPLORAR", "EXPORTAR"]) with tab_dash: @@ -151,6 +172,7 @@ def _maybe_enrich() -> None: with tab_export: render_tab_export(df) +# ── Footer ──────────────────────────────────────────────────────────────────── st.markdown( f"

" 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 0acf6b8..b991a2b 100644 --- a/src/pr_analyzer/ui/components/sidebar.py +++ b/src/pr_analyzer/ui/components/sidebar.py @@ -1,12 +1,11 @@ """ -components/sidebar.py — Sidebar with branding, data source, LLM backend, pipeline -toggles, and filters. Returns filter selections so app.py stays decoupled from -widget state. - -The "Classificação LLM" toggle controls whether enrich_prs is applied to -PRRecords coming from the functional pipeline. The result is surfaced back -through st.session_state.llm_cache_stats so the explorer tab can show the -"resultados do cache" indicator. +components/sidebar.py — Sidebar with branding, data source, LLM backend, pipeline toggles, +and filters. Returns filter selections so app.py stays decoupled from widget state. + +The "Classificação LLM" toggle (TASK-39) controls whether `enrich_prs` is +applied to PRRecords coming from the functional pipeline. The result is +surfaced back through st.session_state.llm_cache_stats so the explorer tab +can show the "resultados do cache" indicator. """ from __future__ import annotations @@ -30,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: @@ -44,9 +43,12 @@ 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, sel_type, sel_clarity, cleaning, llm_tag, metrics - return sel_lang, sel_nature, cleaning, llm_tag, metrics + +# ── Private helpers ─────────────────────────────────────────────────────────── def _render_brand() -> None: @@ -114,33 +116,22 @@ def _render_file_status() -> None: def _handle_upload(uploaded: object) -> None: - """Route uploaded CSVs through the functional pipeline when possible. - - The Streamlit UploadedFile cursor may be at an arbitrary position after - widget rendering, so we always seek(0) and read into a fresh BytesIO - before passing to any parser — avoiding empty-read errors. - """ - from io import BytesIO - + """Route uploaded CSVs through the functional pipeline when possible.""" filename = getattr(uploaded, "name", "uploaded.csv") try: - if hasattr(uploaded, "seek"): - uploaded.seek(0) - raw_bytes: bytes = uploaded.read() if hasattr(uploaded, "read") else b"" - buf = BytesIO(raw_bytes) if filename.endswith(".csv"): - df, prs = load_uploaded(buf, filename) + df, prs = load_uploaded(uploaded, filename) st.session_state.df = df st.session_state.raw_prs = prs else: - st.session_state.df = load_dataframe(buf, filename) + st.session_state.df = load_dataframe(uploaded, filename) st.session_state.raw_prs = None st.session_state.file_loaded = True st.session_state.fname = filename st.session_state.llm_cache_stats = None st.session_state.llm_enriched = False - except Exception as exc: - st.error(f'Não foi possível carregar "{filename}": {exc}') + except ValueError as exc: + st.error(str(exc)) def _render_local_datasets() -> None: @@ -148,7 +139,7 @@ def _render_local_datasets() -> None: if not datasets: return - with st.expander("DATASETS LOCAIS", expanded=True): + with st.expander("DATASETS LOCAIS", expanded=False): labels: list[str] = ["— selecionar —", *(d["label"] for d in datasets)] choice: str = st.selectbox( "Dataset local", @@ -156,16 +147,11 @@ def _render_local_datasets() -> None: key="local_ds_select", label_visibility="collapsed", ) - if choice != "— selecionar —": + if choice != "— selecionar —" and st.button( + "Carregar", key="load_local_btn", use_container_width=True + ): selected = next(d for d in datasets if d["label"] == choice) - if selected.get("oversized"): - st.warning( - f"Arquivo excede o limite configurado " - f"({os.environ.get('MAX_DATASET_SIZE_GB', '10')} GB). " - "Ajuste MAX_DATASET_SIZE_GB no .env para carregar." - ) - elif st.button("Carregar", key="load_local_btn", use_container_width=True): - _load_local(selected) + _load_local(selected) def _load_local(dataset: dict[str, Any]) -> None: @@ -198,6 +184,9 @@ def _load_local(dataset: dict[str, Any]) -> None: st.rerun() +# ── LLM backend ─────────────────────────────────────────────────────────────── + + def _render_llm_backend() -> None: st.markdown("### 🤖 BACKEND LLM") @@ -265,40 +254,24 @@ def _render_ollama_panel() -> None: def _render_ollama_setup(host: str) -> None: st.markdown( "

" - "Como configurar (Docker):

", + "Como configurar:

", unsafe_allow_html=True, ) - st.markdown("1. Instale o Ollama em **ollama.com**") + st.markdown("1. Instale em **ollama.com**") st.markdown("2. Baixe um modelo:") st.code("ollama pull llama3", language="bash") - st.markdown("3. Faça o Ollama escutar em todas as interfaces:") - st.code( - "sudo tee /etc/systemd/system/ollama.service.d/override.conf <<'EOF'\n" - "[Service]\n" - 'Environment="OLLAMA_HOST=0.0.0.0"\n' - "EOF\n" - "sudo systemctl daemon-reload && sudo systemctl restart ollama", - language="bash", - ) - st.markdown("4. Configure o `.env` do projeto:") - st.code( - "LLM_BACKEND=ollama\n" - "OLLAMA_HOST=http://host.docker.internal:11434\n" - "LLM_MODEL=llama3", - language="bash", - ) - st.info( - "O `host.docker.internal` é resolvido automaticamente pelo Docker " - "via `extra_hosts` no `docker-compose.yml`. Certifique-se de que o " - "Ollama está escutando em `0.0.0.0` (passo 3) antes de iniciar o container." - ) - if "localhost" in host and "host.docker.internal" not in host: - st.warning( - "Host atual aponta para `localhost`, que dentro do container " - "não alcança o Ollama do host. Atualize `OLLAMA_HOST` no `.env`." + st.markdown("3. Inicie o servidor:") + st.code("ollama serve", language="bash") + if "host.docker.internal" not in host and "localhost" in host: + st.info( + "No Docker, defina no `.env`:\n" + "`OLLAMA_HOST=http://host.docker.internal:11434`" ) +# ── Pipeline toggles ────────────────────────────────────────────────────────── + + def _render_pipeline() -> tuple[bool, bool, bool]: st.markdown("### ⚙ PIPELINE") cleaning = st.toggle("Sanitização Funcional", value=True, key="cleaning") @@ -325,9 +298,14 @@ def _render_cache_indicator() -> None: ) -def _render_filters() -> tuple[str, str]: +# ── Filters ─────────────────────────────────────────────────────────────────── + + +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 25fdf76..4e34edf 100644 --- a/src/pr_analyzer/ui/utils/data.py +++ b/src/pr_analyzer/ui/utils/data.py @@ -15,10 +15,10 @@ import pandas as pd import streamlit as st -MAX_DATASET_SIZE_GB: float = float(os.environ.get("MAX_DATASET_SIZE_GB", "10")) +# ─── Mock / demo dataset ───────────────────────────────────────────────────── -@st.cache_data(show_spinner=False) +@st.cache_data(show_spinner=False) # type: ignore[misc] def get_mock_data() -> pd.DataFrame: """Return a small but representative demo DataFrame.""" rows: list[dict[str, Any]] = [ @@ -89,62 +89,14 @@ def get_mock_data() -> pd.DataFrame: # ─── Loading from uploaded files ───────────────────────────────────────────── -def _is_mined_comments(data: Any) -> bool: - """Return True when data matches the mined-comments archive shape. - - Expected shape: { "owner/repo": [ {comment_dict}, ... ], ... } - """ - if not isinstance(data, dict): - return False - sample = next(iter(data.values()), None) - return isinstance(sample, list) - - -def _mined_comments_to_dataframe(data: dict[str, Any]) -> pd.DataFrame: - """Flatten a mined-comments dict into a UI-compatible DataFrame. - - Re-uses the same _comment_row / heuristics that load_archive_sample uses, - so the display columns are identical whether the file came from upload or - from the local dataset picker. - """ - rows: list[dict[str, Any]] = [] - for repo_full, comments in data.items(): - if not isinstance(comments, list): - continue - for c in comments: - file_path = str(c.get("path", "")) - body = str(c.get("body", "")) - lang_fallback = repo_full.split("/")[-1] if "/" in repo_full else repo_full - rows.append( - _comment_row( - int(c.get("id", len(rows))), - repo_full, - file_path, - body, - lang_fallback, - ) - ) - if not rows: - return pd.DataFrame() - return pd.DataFrame(rows) - - def load_dataframe(file: BytesIO, filename: str) -> pd.DataFrame: - """Parse an uploaded file into a DataFrame. - - Handles three JSON shapes: - - mined-comments archive: { "owner/repo": [{comment}, ...] } - - list of records: [ {row}, ... ] - - flat dict: { col: [values] } - + """ + Parse an uploaded file into a DataFrame. Raises ValueError with a descriptive message on failure. """ try: if filename.endswith(".json"): - data = json.load(file) - if _is_mined_comments(data): - return _mined_comments_to_dataframe(data) - return pd.DataFrame(data) + return pd.DataFrame(json.load(file)) return pd.read_csv(file) except Exception as exc: raise ValueError(f"Não foi possível ler '{filename}': {exc}") from exc @@ -157,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 ─────────────────────────────────────────────────────────── @@ -199,37 +164,23 @@ def build_report_markdown(df: pd.DataFrame) -> str: } -def discover_datasets( - data_dir: str, - max_size_gb: float = MAX_DATASET_SIZE_GB, -) -> list[dict[str, Any]]: - """Return available datasets in data_dir (top-level files + archive subdirs). - - Files larger than max_size_gb are listed but flagged as oversized so the UI - can warn the user instead of silently skipping them. - """ +def discover_datasets(data_dir: str) -> list[dict[str, Any]]: + """Return available datasets in data_dir (top-level files + archive subdirs).""" base = Path(data_dir) if not base.is_dir(): return [] results: list[dict[str, Any]] = [] - max_bytes = max_size_gb * 1024**3 for entry in sorted(base.iterdir()): if entry.is_file() and entry.suffix in (".csv", ".json"): - size_bytes = entry.stat().st_size - mb = size_bytes / 1024**2 - oversized = size_bytes > max_bytes - label = f"{entry.name} ({mb:.1f} MB)" - if oversized: - label += f" ⚠ >{max_size_gb:.0f} GB" + mb = entry.stat().st_size / 1024**2 results.append( { - "label": label, + "label": f"{entry.name} ({mb:.1f} MB)", "path": str(entry), "format": entry.suffix.lstrip("."), "lang": None, - "oversized": oversized, } ) @@ -238,22 +189,16 @@ def discover_datasets( for sub in sorted(archive.iterdir()): inner = sub / sub.name if sub.is_dir() and inner.is_file(): - size_bytes = inner.stat().st_size - gb = size_bytes / 1024**3 - oversized = size_bytes > max_bytes + gb = inner.stat().st_size / 1024**3 lang = sub.name.replace("mined-comments-25stars-25prs-", "").replace( ".json", "" ) - label = f"{lang} ({gb:.1f} GB · amostra)" - if oversized: - label += f" ⚠ >{max_size_gb:.0f} GB" results.append( { - "label": label, + "label": f"{lang} ({gb:.1f} GB · amostra)", "path": str(inner), "format": "archive", "lang": lang, - "oversized": oversized, } ) @@ -270,7 +215,7 @@ def check_ollama(host: str) -> tuple[bool, list[str]]: return False, [] -@st.cache_data(show_spinner=False) +@st.cache_data(show_spinner=False) # type: ignore[misc] def load_archive_sample( path: str, lang: str, @@ -350,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 \ No newline at end of file +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 b326ff0..60b64e9 100644 --- a/src/pr_analyzer/ui/utils/pipeline_bridge.py +++ b/src/pr_analyzer/ui/utils/pipeline_bridge.py @@ -166,6 +166,8 @@ def enrich_prs( "nature", "clarity", "size", + "chars", + "words", "state", "title", ) @@ -182,6 +184,8 @@ def enriched_to_dataframe(items: Iterable[Any]) -> pd.DataFrame: "nature": e.contribution_nature.title() if e.contribution_nature else "—", "clarity": _capitalize_clarity(e.description_clarity), "size": (e.pr.additions or 0) + (e.pr.deletions or 0), + "chars": len(e.pr.body) if e.pr.body else 0, + "words": len(e.pr.body.split()) if e.pr.body else 0, "state": e.pr.state.title() if e.pr.state else "—", "title": e.pr.title, } @@ -193,11 +197,7 @@ def enriched_to_dataframe(items: Iterable[Any]) -> 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, @@ -207,6 +207,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, }