Simplify graph UX with focused default member view
Browse files
dataset_bundle/evidence_audit/consistency_report.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"generated_at": "2026-04-
|
| 3 |
"event_provenance": {
|
| 4 |
"event_count": 3918,
|
| 5 |
"events_with_artifacts": 3878,
|
|
|
|
| 1 |
{
|
| 2 |
+
"generated_at": "2026-04-19T11:09:07-04:00",
|
| 3 |
"event_provenance": {
|
| 4 |
"event_count": 3918,
|
| 5 |
"events_with_artifacts": 3878,
|
dataset_bundle/network_graph/graph_config.json
CHANGED
|
@@ -20,9 +20,10 @@
|
|
| 20 |
"default_filters": {
|
| 21 |
"relationship_family": "sector",
|
| 22 |
"review_status": "stronger",
|
| 23 |
-
"max_edges":
|
| 24 |
"hide_unresolved_only": true,
|
| 25 |
-
"overview_member_limit":
|
|
|
|
| 26 |
},
|
| 27 |
"example_member_searches": [
|
| 28 |
"Josh Gottheimer",
|
|
|
|
| 20 |
"default_filters": {
|
| 21 |
"relationship_family": "sector",
|
| 22 |
"review_status": "stronger",
|
| 23 |
+
"max_edges": 30,
|
| 24 |
"hide_unresolved_only": true,
|
| 25 |
+
"overview_member_limit": 5,
|
| 26 |
+
"default_member_search": "Josh Gottheimer"
|
| 27 |
},
|
| 28 |
"example_member_searches": [
|
| 29 |
"Josh Gottheimer",
|
dataset_bundle/public_release_manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
{
|
| 2 |
"public_version": "congress-public-records-slice-2026-04-v1",
|
| 3 |
"title": "Congress Public Records Slice",
|
| 4 |
-
"release_date": "2026-04-
|
| 5 |
"slice_description": "A neutral, review-oriented slice of House public-record linkages across financial disclosures, sector overlap, and community project funding recipient relationships.",
|
| 6 |
"source_run_name": "house_all_baseline_20260418_v21_recipienthardening",
|
| 7 |
"dataset_repo_id": "cjc0013/cmp-data",
|
|
|
|
| 1 |
{
|
| 2 |
"public_version": "congress-public-records-slice-2026-04-v1",
|
| 3 |
"title": "Congress Public Records Slice",
|
| 4 |
+
"release_date": "2026-04-19T08:23:16-04:00",
|
| 5 |
"slice_description": "A neutral, review-oriented slice of House public-record linkages across financial disclosures, sector overlap, and community project funding recipient relationships.",
|
| 6 |
"source_run_name": "house_all_baseline_20260418_v21_recipienthardening",
|
| 7 |
"dataset_repo_id": "cjc0013/cmp-data",
|
public_space_app.py
CHANGED
|
@@ -153,15 +153,27 @@ def _graph_intro_markdown(config: Dict[str, Any]) -> str:
|
|
| 153 |
status_counts = config.get("relationship_status_counts") or {}
|
| 154 |
defaults = config.get("default_filters") or {}
|
| 155 |
example_members = [str(item) for item in (config.get("example_member_searches") or []) if str(item).strip()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
return "\n".join(
|
| 157 |
[
|
| 158 |
"### What you are looking at",
|
| 159 |
"",
|
| 160 |
"- Green dots are House members, rust dots are funding recipients, and gold dots are sectors.",
|
| 161 |
"- Thicker lines mean more supporting relationship rows in this released slice.",
|
| 162 |
-
|
| 163 |
f"- The default status filter is `{_plain_status_label(str(defaults.get('review_status', 'stronger'))).lower()}`.",
|
| 164 |
f"- Unresolved-only edges start hidden: `{str(bool(defaults.get('hide_unresolved_only', True))).lower()}`.",
|
|
|
|
| 165 |
*([f"- Example member searches: {', '.join(f'`{item}`' for item in example_members)}."] if example_members else []),
|
| 166 |
f"- Current graph inventory: `{int(node_counts.get('member', 0) or 0)}` members, `{int(node_counts.get('recipient', 0) or 0)}` recipients, `{int(node_counts.get('sector', 0) or 0)}` sectors.",
|
| 167 |
f"- Relationship counts: `{int(edge_counts.get('recipient', 0) or 0)}` recipient edges, `{int(edge_counts.get('sector', 0) or 0)}` sector edges.",
|
|
@@ -172,6 +184,84 @@ def _graph_intro_markdown(config: Dict[str, Any]) -> str:
|
|
| 172 |
)
|
| 173 |
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
def _filter_events(events: pd.DataFrame, member_query: str, event_type: str, score_label: str, text_query: str) -> pd.DataFrame:
|
| 176 |
filtered = events.copy()
|
| 177 |
if member_query.strip():
|
|
@@ -261,11 +351,28 @@ def _render_graph(nodes: pd.DataFrame, edges: pd.DataFrame) -> str:
|
|
| 261 |
if edges.empty:
|
| 262 |
return "<div style=\"padding: 1rem; border: 1px solid #d6d0c4; background: #fffdf8; color: #3a3a3a;\">No relationships match the current filters.</div>"
|
| 263 |
network = Network(height="720px", width="100%", bgcolor="#fbf7ee", font_color="#1f2b2d")
|
| 264 |
-
network.barnes_hut(gravity=-15000, central_gravity=0.15, spring_length=220, spring_strength=0.02)
|
| 265 |
network.set_options("""
|
| 266 |
var options = {
|
| 267 |
"interaction": {"hover": true, "tooltipDelay": 120, "navigationButtons": true, "keyboard": true},
|
| 268 |
-
"physics":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
}
|
| 270 |
""")
|
| 271 |
color_map = {"member": "#1f5f5b", "recipient": "#a24e2c", "sector": "#c08d2e"}
|
|
@@ -301,6 +408,7 @@ def _render_graph(nodes: pd.DataFrame, edges: pd.DataFrame) -> str:
|
|
| 301 |
title="<br>".join(title_lines),
|
| 302 |
color=color_map.get(str(node.get("node_type", "")), "#6e6e6e"),
|
| 303 |
shape="dot",
|
|
|
|
| 304 |
size=16 + min(int(node.get("connected_edge_count", 0) or 0), 20),
|
| 305 |
)
|
| 306 |
for row in edges.to_dict("records"):
|
|
@@ -428,6 +536,7 @@ def build_app(copy_path: str | Path):
|
|
| 428 |
("Funding recipients", "recipient"),
|
| 429 |
("All relationships", "all"),
|
| 430 |
]
|
|
|
|
| 431 |
event_id_choices = sorted(events["event_id"].dropna().unique().tolist())
|
| 432 |
graph_defaults = data["graph_config"].get("default_filters") or {}
|
| 433 |
overview_member_limit = int(graph_defaults.get("overview_member_limit", 8))
|
|
@@ -438,22 +547,34 @@ def build_app(copy_path: str | Path):
|
|
| 438 |
gr.Markdown(_graph_intro_markdown(data["graph_config"]))
|
| 439 |
with gr.Row():
|
| 440 |
family = gr.Dropdown(label="Relationship view", choices=graph_family_choices, value=str(graph_defaults.get("relationship_family", "sector")))
|
| 441 |
-
member_graph_query = gr.Textbox(label="
|
| 442 |
target_query = gr.Textbox(label="Recipient or sector search")
|
| 443 |
graph_score = gr.Dropdown(label="Score label", choices=graph_score_choices, value="all")
|
| 444 |
review_status = gr.Dropdown(label="Relationship strength", choices=graph_status_choices, value=str(graph_defaults.get("review_status", "stronger")))
|
|
|
|
|
|
|
| 445 |
with gr.Row():
|
| 446 |
hide_unresolved_only = gr.Checkbox(label="Hide unresolved relationships", value=bool(graph_defaults.get("hide_unresolved_only", True)))
|
| 447 |
max_edges = gr.Slider(label="Max visible relationships", minimum=25, maximum=300, step=25, value=int(graph_defaults.get("max_edges", 60)))
|
|
|
|
| 448 |
graph_html = gr.HTML()
|
|
|
|
| 449 |
graph_df = gr.Dataframe(interactive=False)
|
| 450 |
def _update_graph(family: str, member_graph_query: str, target_query: str, graph_score: str, review_status: str, hide_unresolved_only: bool, max_edges: int):
|
| 451 |
filtered_edges = _filter_graph(edges, family, member_graph_query, target_query, graph_score, review_status, hide_unresolved_only, max_edges, overview_member_limit)
|
| 452 |
filtered_nodes = nodes[nodes["node_id"].isin(set(filtered_edges["source_node_id"]).union(set(filtered_edges["target_node_id"])))]
|
| 453 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
for control in (family, member_graph_query, target_query, graph_score, review_status, hide_unresolved_only, max_edges):
|
| 455 |
-
control.change(_update_graph, [family, member_graph_query, target_query, graph_score, review_status, hide_unresolved_only, max_edges], [graph_html, graph_df])
|
| 456 |
-
app.load(_update_graph, [family, member_graph_query, target_query, graph_score, review_status, hide_unresolved_only, max_edges], [graph_html, graph_df])
|
| 457 |
with gr.Tab("Explore"):
|
| 458 |
with gr.Row():
|
| 459 |
member_query = gr.Textbox(label="Member name or slug")
|
|
|
|
| 153 |
status_counts = config.get("relationship_status_counts") or {}
|
| 154 |
defaults = config.get("default_filters") or {}
|
| 155 |
example_members = [str(item) for item in (config.get("example_member_searches") or []) if str(item).strip()]
|
| 156 |
+
default_member = str(defaults.get("default_member_search", "") or "").strip()
|
| 157 |
+
opening_line = (
|
| 158 |
+
f"- This graph opens focused on `{default_member}` so the first view is readable."
|
| 159 |
+
if default_member
|
| 160 |
+
else f"- This graph opens in a small `{_plain_family_label(str(defaults.get('relationship_family', 'sector'))).lower()}` overview."
|
| 161 |
+
)
|
| 162 |
+
next_step_line = (
|
| 163 |
+
"- Replace the member name above to explore someone else, or clear it to return to the small overview."
|
| 164 |
+
if default_member
|
| 165 |
+
else "- Search one House member above for the clearest view."
|
| 166 |
+
)
|
| 167 |
return "\n".join(
|
| 168 |
[
|
| 169 |
"### What you are looking at",
|
| 170 |
"",
|
| 171 |
"- Green dots are House members, rust dots are funding recipients, and gold dots are sectors.",
|
| 172 |
"- Thicker lines mean more supporting relationship rows in this released slice.",
|
| 173 |
+
opening_line,
|
| 174 |
f"- The default status filter is `{_plain_status_label(str(defaults.get('review_status', 'stronger'))).lower()}`.",
|
| 175 |
f"- Unresolved-only edges start hidden: `{str(bool(defaults.get('hide_unresolved_only', True))).lower()}`.",
|
| 176 |
+
next_step_line,
|
| 177 |
*([f"- Example member searches: {', '.join(f'`{item}`' for item in example_members)}."] if example_members else []),
|
| 178 |
f"- Current graph inventory: `{int(node_counts.get('member', 0) or 0)}` members, `{int(node_counts.get('recipient', 0) or 0)}` recipients, `{int(node_counts.get('sector', 0) or 0)}` sectors.",
|
| 179 |
f"- Relationship counts: `{int(edge_counts.get('recipient', 0) or 0)}` recipient edges, `{int(edge_counts.get('sector', 0) or 0)}` sector edges.",
|
|
|
|
| 184 |
)
|
| 185 |
|
| 186 |
|
| 187 |
+
def _graph_view_summary_markdown(
|
| 188 |
+
edges: pd.DataFrame,
|
| 189 |
+
*,
|
| 190 |
+
family: str,
|
| 191 |
+
member_query: str,
|
| 192 |
+
target_query: str,
|
| 193 |
+
review_status: str,
|
| 194 |
+
max_edges: int,
|
| 195 |
+
) -> str:
|
| 196 |
+
if edges.empty:
|
| 197 |
+
return "\n".join(
|
| 198 |
+
[
|
| 199 |
+
"### Current view",
|
| 200 |
+
"",
|
| 201 |
+
"No relationships match the current filters.",
|
| 202 |
+
"",
|
| 203 |
+
"Try one House member name, switch relationship view, or clear the current filters.",
|
| 204 |
+
]
|
| 205 |
+
)
|
| 206 |
+
member_count = int(edges["member_slug"].nunique())
|
| 207 |
+
target_count = int(edges["target_key"].nunique())
|
| 208 |
+
visible_count = int(len(edges))
|
| 209 |
+
family_label = _plain_family_label(family)
|
| 210 |
+
status_label = _plain_status_label(review_status)
|
| 211 |
+
lines = [
|
| 212 |
+
"### Current view",
|
| 213 |
+
"",
|
| 214 |
+
f"- Showing `{visible_count}` visible relationships across `{member_count}` House members and `{target_count}` targets.",
|
| 215 |
+
f"- Relationship view: `{family_label}`",
|
| 216 |
+
f"- Strength filter: `{status_label}`",
|
| 217 |
+
f"- Visible relationship cap: `{int(max_edges)}`",
|
| 218 |
+
]
|
| 219 |
+
if member_query.strip():
|
| 220 |
+
focus_members = ", ".join(sorted({str(value) for value in edges["member_name"].fillna("").tolist() if str(value).strip()})[:4])
|
| 221 |
+
if focus_members:
|
| 222 |
+
lines.append(f"- Focused on: `{focus_members}`")
|
| 223 |
+
lines.append("- Tip: change the member name above to compare someone else, or clear it to return to the small overview.")
|
| 224 |
+
else:
|
| 225 |
+
lines.append("- This is an overview, so it only shows a small set of members. Search one member name for the clearest read.")
|
| 226 |
+
if target_query.strip():
|
| 227 |
+
lines.append(f"- Target filter: `{target_query.strip()}`")
|
| 228 |
+
return "\n".join(lines)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _graph_table(edges: pd.DataFrame) -> pd.DataFrame:
|
| 232 |
+
if edges.empty:
|
| 233 |
+
return pd.DataFrame(columns=["member", "target", "relationship_view", "strength", "supporting_rows"])
|
| 234 |
+
rows: list[dict[str, Any]] = []
|
| 235 |
+
for row in edges.to_dict("records"):
|
| 236 |
+
status = str(row.get("relationship_status", "") or "")
|
| 237 |
+
family = str(row.get("relationship_family", "") or "")
|
| 238 |
+
stronger_support = int(
|
| 239 |
+
row.get("linked_count", 0) or 0
|
| 240 |
+
if family == "recipient"
|
| 241 |
+
else row.get("strong_event_count", 0) or 0
|
| 242 |
+
)
|
| 243 |
+
caution_support = int(
|
| 244 |
+
row.get("review_count", 0) or 0
|
| 245 |
+
if family == "recipient"
|
| 246 |
+
else row.get("weak_event_count", 0) or 0
|
| 247 |
+
)
|
| 248 |
+
source_examples = ", ".join(_split_pipe_values(row.get("source_urls", ""), limit=2))
|
| 249 |
+
rows.append(
|
| 250 |
+
{
|
| 251 |
+
"member": str(row.get("member_name") or row.get("member_slug") or ""),
|
| 252 |
+
"target": str(row.get("target_label") or ""),
|
| 253 |
+
"relationship_view": _plain_family_label(family),
|
| 254 |
+
"strength": _plain_status_label(status),
|
| 255 |
+
"supporting_rows": int(row.get("link_count", 0) or 0),
|
| 256 |
+
"stronger_support": stronger_support,
|
| 257 |
+
"caution_support": caution_support,
|
| 258 |
+
"unresolved_refs": int(row.get("unresolved_source_ref_count", 0) or 0),
|
| 259 |
+
"source_examples": source_examples,
|
| 260 |
+
}
|
| 261 |
+
)
|
| 262 |
+
return pd.DataFrame(rows)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
def _filter_events(events: pd.DataFrame, member_query: str, event_type: str, score_label: str, text_query: str) -> pd.DataFrame:
|
| 266 |
filtered = events.copy()
|
| 267 |
if member_query.strip():
|
|
|
|
| 351 |
if edges.empty:
|
| 352 |
return "<div style=\"padding: 1rem; border: 1px solid #d6d0c4; background: #fffdf8; color: #3a3a3a;\">No relationships match the current filters.</div>"
|
| 353 |
network = Network(height="720px", width="100%", bgcolor="#fbf7ee", font_color="#1f2b2d")
|
|
|
|
| 354 |
network.set_options("""
|
| 355 |
var options = {
|
| 356 |
"interaction": {"hover": true, "tooltipDelay": 120, "navigationButtons": true, "keyboard": true},
|
| 357 |
+
"physics": false,
|
| 358 |
+
"layout": {
|
| 359 |
+
"hierarchical": {
|
| 360 |
+
"enabled": true,
|
| 361 |
+
"direction": "LR",
|
| 362 |
+
"sortMethod": "directed",
|
| 363 |
+
"nodeSpacing": 170,
|
| 364 |
+
"treeSpacing": 220,
|
| 365 |
+
"levelSeparation": 220
|
| 366 |
+
}
|
| 367 |
+
},
|
| 368 |
+
"edges": {
|
| 369 |
+
"smooth": {
|
| 370 |
+
"enabled": true,
|
| 371 |
+
"type": "cubicBezier",
|
| 372 |
+
"forceDirection": "horizontal",
|
| 373 |
+
"roundness": 0.35
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
}
|
| 377 |
""")
|
| 378 |
color_map = {"member": "#1f5f5b", "recipient": "#a24e2c", "sector": "#c08d2e"}
|
|
|
|
| 408 |
title="<br>".join(title_lines),
|
| 409 |
color=color_map.get(str(node.get("node_type", "")), "#6e6e6e"),
|
| 410 |
shape="dot",
|
| 411 |
+
level=0 if node_type == "member" else 1,
|
| 412 |
size=16 + min(int(node.get("connected_edge_count", 0) or 0), 20),
|
| 413 |
)
|
| 414 |
for row in edges.to_dict("records"):
|
|
|
|
| 536 |
("Funding recipients", "recipient"),
|
| 537 |
("All relationships", "all"),
|
| 538 |
]
|
| 539 |
+
example_member_choices = [[item] for item in data["graph_config"].get("example_member_searches") or []]
|
| 540 |
event_id_choices = sorted(events["event_id"].dropna().unique().tolist())
|
| 541 |
graph_defaults = data["graph_config"].get("default_filters") or {}
|
| 542 |
overview_member_limit = int(graph_defaults.get("overview_member_limit", 8))
|
|
|
|
| 547 |
gr.Markdown(_graph_intro_markdown(data["graph_config"]))
|
| 548 |
with gr.Row():
|
| 549 |
family = gr.Dropdown(label="Relationship view", choices=graph_family_choices, value=str(graph_defaults.get("relationship_family", "sector")))
|
| 550 |
+
member_graph_query = gr.Textbox(label="House member to focus", value=str(graph_defaults.get("default_member_search", "")))
|
| 551 |
target_query = gr.Textbox(label="Recipient or sector search")
|
| 552 |
graph_score = gr.Dropdown(label="Score label", choices=graph_score_choices, value="all")
|
| 553 |
review_status = gr.Dropdown(label="Relationship strength", choices=graph_status_choices, value=str(graph_defaults.get("review_status", "stronger")))
|
| 554 |
+
if example_member_choices:
|
| 555 |
+
gr.Examples(examples=example_member_choices, inputs=[member_graph_query], label="Try one of these example members")
|
| 556 |
with gr.Row():
|
| 557 |
hide_unresolved_only = gr.Checkbox(label="Hide unresolved relationships", value=bool(graph_defaults.get("hide_unresolved_only", True)))
|
| 558 |
max_edges = gr.Slider(label="Max visible relationships", minimum=25, maximum=300, step=25, value=int(graph_defaults.get("max_edges", 60)))
|
| 559 |
+
graph_summary_md = gr.Markdown()
|
| 560 |
graph_html = gr.HTML()
|
| 561 |
+
gr.Markdown("#### Relationships in this view")
|
| 562 |
graph_df = gr.Dataframe(interactive=False)
|
| 563 |
def _update_graph(family: str, member_graph_query: str, target_query: str, graph_score: str, review_status: str, hide_unresolved_only: bool, max_edges: int):
|
| 564 |
filtered_edges = _filter_graph(edges, family, member_graph_query, target_query, graph_score, review_status, hide_unresolved_only, max_edges, overview_member_limit)
|
| 565 |
filtered_nodes = nodes[nodes["node_id"].isin(set(filtered_edges["source_node_id"]).union(set(filtered_edges["target_node_id"])))]
|
| 566 |
+
summary = _graph_view_summary_markdown(
|
| 567 |
+
filtered_edges,
|
| 568 |
+
family=family,
|
| 569 |
+
member_query=member_graph_query,
|
| 570 |
+
target_query=target_query,
|
| 571 |
+
review_status=review_status,
|
| 572 |
+
max_edges=max_edges,
|
| 573 |
+
)
|
| 574 |
+
return summary, _render_graph(filtered_nodes, filtered_edges), _graph_table(filtered_edges)
|
| 575 |
for control in (family, member_graph_query, target_query, graph_score, review_status, hide_unresolved_only, max_edges):
|
| 576 |
+
control.change(_update_graph, [family, member_graph_query, target_query, graph_score, review_status, hide_unresolved_only, max_edges], [graph_summary_md, graph_html, graph_df])
|
| 577 |
+
app.load(_update_graph, [family, member_graph_query, target_query, graph_score, review_status, hide_unresolved_only, max_edges], [graph_summary_md, graph_html, graph_df])
|
| 578 |
with gr.Tab("Explore"):
|
| 579 |
with gr.Row():
|
| 580 |
member_query = gr.Textbox(label="Member name or slug")
|