From afd5b8c60e5adf9f5efde97422ff97c3322e4a19 Mon Sep 17 00:00:00 2001 From: eulaly Date: Fri, 8 May 2026 21:57:04 -0400 Subject: [PATCH] full local streamlit support --- docs/streamlit-snapshot.png | Bin 0 -> 31037 bytes docs/tasks.org | 90 +++++++++++++++----------- viz/streamlit.py | 123 +++++++++++++++++++----------------- 3 files changed, 119 insertions(+), 94 deletions(-) create mode 100644 docs/streamlit-snapshot.png diff --git a/docs/streamlit-snapshot.png b/docs/streamlit-snapshot.png new file mode 100644 index 0000000000000000000000000000000000000000..d7c3d42323ea6edc1eec5c172cdda2ab9a8485a3 GIT binary patch literal 31037 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 nG3-C|5C6kA`?5TJ`t@SlM(cZSHBAZtC`wCR|M=.9 - -2. add forum_url, forum_collected_date to scraper - -2. create graph for Stance/Share - - stacked h-bar with % support/oppose/neutral/unknown + raw totals, eg 63% (5720) / 37% (3320) / 0.09% (8) / 0.37% (34) - - later, consider centered diverging h-bar: oppose ← | neutral/unknown | → support -3. create graph for Stance/Time: - - cumulative support/oppose % over time -4. create graph for Stance/Tone (heatmap count) -5. create graph for Confidence/Stance (boxplot or histogram) - - -** acceptance criteria -1. load parquet/csv review dataset -2. show stance counts, tone counts, tag counts, and confidence histogram -3. provide filters for stance, tone, confidence, tag, and text search -4. show filtered comment table -5. clicking/selecting a comment shows full text and model rationale -6. app runs locally with one command - -** notes - -** evidence -- commit: -- tests: -- datetime: - -* === Backlog === - -* [ ] X: complete proposal information -Ensure we capture as much useful information as possible about the actual proposal - contact information, etc. what the state actually says about what was posted. -** acceptance criteria -1. Item: `Forum` stores id, url, proposal title, description, open/close date, number of comments, agency, board, guidance document id - - add details for guidanceDoc, publication date, comments, guidance docs - eg: https://www.townhall.virginia.gov/L/GDocForum.cfm?GDocForumID=452 -2. Item: `Comment` stores forum_id, comment_id, author, title, text, date, url diff --git a/viz/streamlit.py b/viz/streamlit.py index 491b2da..81f54d7 100644 --- a/viz/streamlit.py +++ b/viz/streamlit.py @@ -1,6 +1,7 @@ -# streamlit run analysis/viz/comment_streamlit2.py -from datetime import datetime as dt +# streamlit run analysis/viz/streamlit.py +import argparse from pathlib import Path +from datetime import datetime as dt import pandas as pd import plotly.graph_objects as go import plotly.express as px @@ -15,25 +16,21 @@ forum = pd.read_json(workdir/"forum.jsonl", lines=True).iloc[0].to_dict() prompt = (workdir/"prompt.txt").read_text(encoding="utf-8") stance_colors = {'oppose':'#ffa15a', 'neutral':'#e377c2','support':'#19d3f3','unknown':'#000000'} -#stance_colors = {'oppose':'orange', 'neutral':'green','support':'blue','unknown':'gray','mixed':'violet'} -stance_order = ["oppose", "neutral", "unknown", "support"] +stance_order = ["oppose", "mixed", "unknown", "neutral", "support"] st.set_page_config(layout="wide") -st.title("Virginia Townhall Explorer") -st.divider() -st.subheader(forum.get('reg_title')) +st.title("Virginia Townhall Explorer",anchor=None) +st.caption("Explore data collected from Virginia's public comment system. Source code at https://github.com/eulaly/vath") + +st.subheader("Proposal",anchor=None,divider="gray") +st.markdown(f"**{forum.get('reg_title')}**") st.text(forum.get('reg_desc')) -st.caption(f"Link: https://www.townhall.virginia.gov/L/Comments.cfm?GDocForumID={forum.get('forum_id')}") +st.caption(f'Comments posted from {dt.strftime(min(df.date_dt),"%D")}—{dt.strftime(max(df.date_dt),"%D")} at https://www.townhall.virginia.gov/L/Comments.cfm?GDocForumID={forum.get("forum_id")}') -st.write(f'Comments posted from {dt.strftime(min(df.date_dt),"%D")}—{dt.strftime(max(df.date_dt),"%D")}') -st.write('Data collected on _') - -st.subheader("Comment Summary") -# summary +st.subheader("Comment Summary",anchor=False,divider="gray") summary_left, summary_right = st.columns([1,2]) with summary_left: - # summary table - #summary_stats = df.groupby("stance").size().reindex(stance_order,fill_value=0).reset_index(name="count") +# Summary Table summary_stats = ( df.groupby("stance").size() .reindex(stance_order, fill_value=0) @@ -43,7 +40,7 @@ with summary_left: st.dataframe(summary_stats, hide_index=True, width="stretch") with summary_right: -# stance div-h +# Stance div-h counts = df["stance"].value_counts() stance_divh = go.Figure() stance_divh.add_bar(y=["stance"], x=[-counts.get("oppose",0)], name="oppose", orientation="h", marker_color=stance_colors.get('oppose'), text=[counts.get("oppose",0)], textposition="inside") @@ -52,14 +49,7 @@ with summary_right: stance_divh.add_bar(y=["stance"], x=[counts.get("support",0)], name="support", orientation="h", marker_color=stance_colors.get('support'), text=[counts.get("support",0)], textposition="inside") stance_divh.update_yaxes(title_text="",showticklabels=False) stance_divh.update_layout(barmode="relative", title="", height=180, margin=dict(l=0,r=0,t=0,b=0),xaxis_title="", yaxis_title="",legend=dict(orientation="v",y=0.12)) - #legend_orientation="v") st.plotly_chart(stance_divh,width='stretch') - -# stance_time -#stance_order = ["oppose", "neutral","unknown","support"] -#daily = df.groupby(["date_day", "stance"]).size().reset_index(name="count") -#stance_time = px.bar(daily, x="date_day", y="count", color="stance", category_orders={"stance": stance_order},color_discrete_map=stance_colors,title="") -#st.plotly_chart(stance_time, width='stretch') # Daily Comments Breakdown, 3 Tabs daily_wide = ( @@ -90,6 +80,7 @@ cum_share_long = ( .melt(id_vars="date_day", var_name="stance", value_name="cumulative_share") ) + tab_daily, tab_area, tab_share = st.tabs([ "Daily", "Cumulative", @@ -132,46 +123,64 @@ with tab_share: fig.update_yaxes(tickformat=".0%", range=[0, 1]) fig.update_layout(height=420, legend_orientation="v") st.plotly_chart(fig, width="stretch") - -st.subheader("Comment Explorer") - -# stance/tone heatmap -# TODO add raw values -# TODO OPT add button to swap between pct/tone <> pct/stance -x_order = ["unknown","oppose","mixed","neutral","support"] # includes mixed even if absent; harmless zero column -y_order = ["positive","neutral","mixed","negative","unclear"] -tab = pd.crosstab(df["tone"], df["stance"]).reindex(index=y_order, columns=x_order, fill_value=0) -pct = tab.div(tab.sum(axis=1).replace(0, pd.NA), axis=0).fillna(0) -fig = px.imshow( - pct, - x=x_order, y=y_order, - text_auto=".0%", - aspect="auto", - color_continuous_scale="Greens", - title="tone by stance, percent within tone", -) -fig.update_traces(text=tab.astype(str) + " / " + (pct*100).round(0).astype(int).astype(str) + "%") -fig.update_layout(height=420, xaxis_title="stance", yaxis_title="tone") -st.plotly_chart(fig, width='stretch') - + +st.subheader("Comment Explorer",anchor=False,divider="gray") # comment explorer -stance = st.multiselect("Filter stance", sorted(df["stance"].dropna().unique()), default=sorted(df["stance"].dropna().unique())) -q = st.text_input("Search comment text") -dff = df[df["stance"].isin(stance)] -if q: - dff = dff[dff["text"].fillna("").str.contains(q, case=False, regex=False)] +cex_left, cex_right = st.columns([1,1]) +with cex_left: + stance = st.multiselect("Filter stance", sorted(df["stance"].dropna().unique()), default=sorted(df["stance"].dropna().unique())) + q = st.text_input("Search comment title and text") + dff = df[df["stance"].isin(stance)] + if q: + dff = dff[dff["text"].fillna("").str.contains(q, case=False, regex=False)] + +with cex_right: + filter_tone = st.multiselect("Filter tone", sorted(df["tone"].dropna().unique()), default=sorted(df["tone"].dropna().unique())) + st.text(""); st.text("") + st.text("Showing " + str(len(dff))+ " comments",text_alignment="right", width="stretch") st.dataframe(dff[["comment_id", "title", "text", "stance", "stance_confidence", "tone"]], width="stretch") -st.write("Showing " + str(len(dff))+ " comments") -cid = st.selectbox("comment", dff["comment_id"].astype(str)) +cid = st.selectbox("Select comment to view:", dff["comment_id"].astype(str)) row = dff[dff["comment_id"].astype(str) == cid].iloc[0] -st.subheader(row["title"]) -st.write(row["text"]) -st.write(row["author"] + ", " + row["date"][:10]) -st.markdown(f"**stance:** {row['stance']} \t|\t **confidence:** {row['stance_confidence']:.2f} \t|\t **tone:** {row['tone']}") -st.write("**analysis:** "+ row["stance_rationale"]) +st.markdown(f'**{row["title"]}**') +st.text(row["text"]) +st.write(row["author"] + ", " + row["date_dt"].strftime("%D")) + +st.divider() + +st.subheader('Analysis') +cexs_left, cexs_right = st.columns([1,1]) +with cexs_left: + st.write(f"**stance:** {row['stance']}") + st.write(f"**stance_confidence:** {row['stance_confidence']:.2f}") + st.write(f"**tone:** {row['tone']}") + st.write("**analysis:** "+ row["stance_rationale"]) +with cexs_right: + x_order = ["unknown","oppose","mixed","neutral","support"] # includes mixed even if absent; harmless zero column + y_order = ["positive","neutral","mixed","negative","unclear"] + tab = pd.crosstab(df["tone"], df["stance"]).reindex(index=y_order, columns=x_order, fill_value=0) + pct = tab.div(tab.sum(axis=1).replace(0, pd.NA), axis=0).fillna(0) + tone_stance = px.imshow( + pct, + x=x_order, y=y_order, + text_auto=".0%", + aspect="auto", + color_continuous_scale="Greens", + ) + tone_stance.update_traces(text=tab.astype(str) + " / " + (pct*100).round(0).astype(int).astype(str) + "%") + tone_stance.add_scatter(x=[row["stance"]],y=[row["tone"]],mode="markers",marker=dict(size=15,color="yellow",symbol="cross",line=dict(width=1, color="red")),showlegend=False) + tone_stance.update_layout(height=420, xaxis_title="stance", yaxis_title="tone") + st.plotly_chart(tone_stance, width='stretch') + st.caption("Tone by stance, % within tone", text_alignment="right",width="stretch") + +st.divider() st.write("**model:** " + str(row["model"])) with st.expander("Prompt", expanded=False): st.code(prompt, language="text") + +tone_conf = px.box(df,x="stance",y="stance_confidence",color="stance",category_orders={"stance":stance_order},color_discrete_map=stance_colors,points="outliers",title="Comment Stance Classification Confidence") +tone_conf.update_yaxes(range=[0,1.02]) +tone_conf.update_layout(height=430, legend_orientation="v") +st.plotly_chart(tone_conf,width="stretch")