| import streamlit as st |
| import pandas as pd |
| import numpy as np |
| import matplotlib.pyplot as plt |
| import seaborn as sns |
| from uap_analyzer import (UAPParser, UAPAnalyzer, UAPVisualizer, |
| OPENAI_MODELS, DEEPSEEK_MODELS, estimate_cost, _extract_json, |
| get_embed_model) |
| |
| |
| from Levenshtein import distance |
| from sklearn.model_selection import train_test_split |
| from sklearn.metrics import confusion_matrix |
| from tqdm import tqdm |
| import streamlit.components.v1 as components |
| from dateutil import parser |
| from sentence_transformers import SentenceTransformer |
| import torch |
| import squarify |
| import matplotlib.colors as mcolors |
| import textwrap |
| import datamapplot |
| import openai |
| from openai import OpenAI |
| import os |
| import json |
| from utils.data_processing import DataProcessor |
| import plotly.graph_objects as go |
|
|
| |
|
|
| from pandas.api.types import ( |
| is_categorical_dtype, |
| is_datetime64_any_dtype, |
| is_numeric_dtype, |
| is_object_dtype, |
| ) |
|
|
|
|
|
|
| def load_data(file_path, key='df'): |
| return pd.read_hdf(file_path, key=key) |
|
|
|
|
| def gemini_query(question, selected_data, gemini_key): |
|
|
| if question == "": |
| question = "Summarize the following data in relevant bullet points" |
|
|
| import pathlib |
| import textwrap |
|
|
| import google.generativeai as genai |
|
|
| from IPython.display import display |
| from IPython.display import Markdown |
|
|
|
|
| def to_markdown(text): |
| text = text.replace('Γ’β¬Β’', ' *') |
| return Markdown(textwrap.indent(text, '> ', predicate=lambda _: True)) |
| |
| |
| |
|
|
| filtered = [str(x) for x in selected_data if str(x) != '' and x is not None] |
| |
| context = '\n'.join(filtered) |
|
|
| genai.configure(api_key=gemini_key) |
| query_model = genai.GenerativeModel('models/gemini-3.1-pro-preview') |
| response = query_model.generate_content([f"{question}\n Answer based on this context: {context}\n\n"]) |
| return(response.text) |
|
|
| def plot_treemap(df, column, top_n=32): |
| |
| value_counts = df[column].value_counts() |
| top_labels = value_counts.iloc[:top_n].index |
| |
| |
| revised_column = f'{column}_revised' |
| df[revised_column] = np.where(df[column].isin(top_labels), df[column], 'Other') |
|
|
| |
| sizes = df[revised_column].value_counts().values |
| labels = df[revised_column].value_counts().index |
|
|
| |
| |
|
|
| n_colors = len(sizes) |
| colors = plt.cm.Oranges(np.linspace(0.3, 0.9, n_colors))[::-1] |
|
|
|
|
| |
| percents = sizes / sizes.sum() |
|
|
| |
| labels = [f'{label}\n {percent:.1%}' for label, percent in zip(labels, percents)] |
|
|
| fig, ax = plt.subplots(figsize=(20, 12)) |
|
|
| |
| squarify.plot(sizes=sizes, label=labels, alpha=0.7, pad=True, color=colors, text_kwargs={'fontsize': 10}) |
|
|
| ax = plt.gca() |
| for text, rect in zip(ax.texts, ax.patches): |
| background_color = rect.get_facecolor() |
| r, g, b, _ = mcolors.to_rgba(background_color) |
| brightness = np.average([r, g, b]) |
| text.set_color('white' if brightness < 0.5 else 'black') |
|
|
| fig.patch.set_alpha(0) |
| ax.patch.set_alpha(0) |
| plt.tight_layout() |
| return fig |
|
|
| |
|
|
| from pandas.api.types import ( |
| is_categorical_dtype, |
| is_datetime64_any_dtype, |
| is_numeric_dtype, |
| is_object_dtype, |
| ) |
|
|
|
|
| class CachedUAPParser(UAPParser): |
| def __init__(self, *args, **kwargs): |
| super().__init__(*args, **kwargs) |
| if 'parsed_responses' not in st.session_state: |
| st.session_state['parsed_responses'] = {} |
|
|
| def parse_responses(self): |
| parsed_responses = {} |
| not_parsed = 0 |
| try: |
| for k, v in self.responses.items(): |
| if k in {"__batch_ids__", "__batch_descs__"}: |
| continue |
| result = _extract_json(v) |
| if result is not None: |
| parsed_responses[k] = result |
| else: |
| not_parsed += 1 |
|
|
| st.session_state['parsed_responses'] = parsed_responses |
| except Exception as e: |
| st.error(f"Error parsing responses: {e}") |
|
|
| st.write(f"Parsed: {len(parsed_responses)} | Failed: {not_parsed}") |
| return st.session_state['parsed_responses'] |
|
|
| def responses_to_df(self, col, parsed_responses): |
| try: |
| parsed_df = pd.DataFrame(parsed_responses).T |
| |
| if col is not None and col in parsed_df.columns: |
| parsed_df2 = pd.json_normalize(parsed_df[col].tolist()) |
| parsed_df2.index = parsed_df.index |
| else: |
| |
| |
| |
| parsed_df2 = pd.json_normalize(list(parsed_responses.values())) |
| parsed_df2.index = parsed_df.index |
|
|
| for column in parsed_df2.columns: |
| if parsed_df2[column].dtype == 'object': |
| parsed_df2[column] = parsed_df2[column].astype(str) |
|
|
| return parsed_df2 |
| except Exception as e: |
| st.error(f"Error converting responses to DataFrame: {e}") |
| return pd.DataFrame() |
|
|
|
|
| def load_data(file_path, key='df'): |
| return pd.read_hdf(file_path, key=key) |
|
|
|
|
| def gemini_query(question, selected_data, gemini_key): |
|
|
| if question == "": |
| question = "Summarize the following data in relevant bullet points" |
|
|
| import pathlib |
| import textwrap |
|
|
| import google.generativeai as genai |
|
|
| from IPython.display import display |
| from IPython.display import Markdown |
|
|
|
|
| def to_markdown(text): |
| text = text.replace('Γ’β¬Β’', ' *') |
| return Markdown(textwrap.indent(text, '> ', predicate=lambda _: True)) |
| |
| |
| |
|
|
| filtered = [str(x) for x in selected_data if str(x) != '' and x is not None] |
| |
| context = '\n'.join(filtered) |
|
|
| genai.configure(api_key=gemini_key) |
| query_model = genai.GenerativeModel('models/gemini-3.1-pro-preview') |
| response = query_model.generate_content([f"{question}\n Answer based on this context: {context}\n\n"]) |
| return(response.text) |
|
|
|
|
| def plot_hist(df, column, bins=10, kde=True, figsize=(12, 6), color='orange', title=None): |
| """ |
| Create a histogram with improved styling and error handling. |
| |
| Args: |
| df (pd.DataFrame): DataFrame containing the data |
| column (str): Column name to plot |
| bins (int): Number of bins for histogram |
| kde (bool): Whether to show kernel density estimation |
| figsize (tuple): Figure size |
| color (str): Color for the plot |
| title (str): Custom title for the plot |
| |
| Returns: |
| matplotlib.figure.Figure: The figure object |
| """ |
| try: |
| fig, ax = plt.subplots(figsize=figsize, dpi=150) |
| |
| |
| if column not in df.columns: |
| ax.text(0.5, 0.5, f'Column "{column}" not found', ha='center', va='center', |
| transform=ax.transAxes, fontsize=12, color='red') |
| return fig |
| |
| data_series = df[column].dropna() |
| if len(data_series) == 0: |
| ax.text(0.5, 0.5, 'No data to plot', ha='center', va='center', |
| transform=ax.transAxes, fontsize=12, color='red') |
| return fig |
| |
| |
| if bins == 'auto': |
| bins = min(50, max(10, int(np.sqrt(len(data_series))))) |
| |
| sns.histplot(data=df, x=column, kde=kde, bins=bins, color=color, ax=ax, alpha=0.7) |
| |
| |
| ax.set_title(title or f'Distribution of {column}', color=color, fontweight='bold', fontsize=14) |
| ax.set_xlabel(column, color=color, fontsize=12) |
| ax.set_ylabel('Count', color=color, fontsize=12) |
| |
| |
| for spine in ax.spines.values(): |
| spine.set_color(color) |
| |
| ax.tick_params(axis='both', colors=color) |
| ax.grid(True, alpha=0.3, color=color) |
| |
| |
| fig.patch.set_alpha(0) |
| ax.patch.set_alpha(0) |
| |
| plt.tight_layout() |
| return fig |
| |
| except Exception as e: |
| fig, ax = plt.subplots(figsize=figsize) |
| ax.text(0.5, 0.5, f'Error creating histogram:\n{str(e)}', |
| ha='center', va='center', transform=ax.transAxes, fontsize=12, color='red') |
| ax.set_title('Histogram Error', color='red') |
| fig.patch.set_alpha(0) |
| ax.patch.set_alpha(0) |
| return fig |
|
|
|
|
|
|
| def is_api_key_valid(api_key, model='gpt-4o-mini'): |
| try: |
| os.environ['OPENAI_API_KEY'] = api_key |
| client = OpenAI() |
| response = client.chat.completions.create( |
| model=model, |
| messages=[{"role": "user", "content": 'Say Hello World!'}]) |
| text = response.choices[0].message.content |
| if len(text) >= 0: |
| return True |
| except Exception as e: |
| st.error(f'Error with the API key :{e}') |
| return False |
|
|
| def download_json(data): |
| json_str = json.dumps(data, indent=2) |
| return json_str |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| def _flatten_dotted(d, prefix=""): |
| """Flatten a nested dict to {dotted_key: leaf_value}; non-dict values |
| (strings, lists, numbers) are treated as leaves.""" |
| flat = {} |
| for k, v in d.items(): |
| key = f"{prefix}.{k}" if prefix else str(k) |
| if isinstance(v, dict) and v: |
| flat.update(_flatten_dotted(v, key)) |
| else: |
| flat[key] = v |
| return flat |
|
|
|
|
| def _unflatten_dotted(flat): |
| """Inverse of _flatten_dotted: expand dotted keys back into a nested dict.""" |
| out = {} |
| for key, v in flat.items(): |
| parts = [p for p in str(key).split(".") if p != ""] |
| if not parts: |
| continue |
| node = out |
| for p in parts[:-1]: |
| nxt = node.get(p) |
| if not isinstance(nxt, dict): |
| nxt = {} |
| node[p] = nxt |
| node = nxt |
| node[parts[-1]] = v |
| return out |
|
|
|
|
| def _value_to_text(v): |
| """Render a leaf value for the editor cell (strings verbatim, else JSON).""" |
| return v if isinstance(v, str) else json.dumps(v, ensure_ascii=False) |
|
|
|
|
| def _coerce_custom_value(s): |
| """Parse an editor cell back to a typed value. Bare strings stay strings; |
| JSON literals (lists, objects, numbers, booleans, null) are decoded so they |
| round-trip cleanly and don't trigger a sync loop.""" |
| if not isinstance(s, str): |
| return s |
| t = s.strip() |
| if t == "": |
| return "" |
| if t in ("true", "false", "null"): |
| return {"true": True, "false": False, "null": None}[t] |
| if t[:1] in ("[", "{", "-") or t[:1].isdigit(): |
| try: |
| return json.loads(t) |
| except (ValueError, TypeError): |
| return s |
| return s |
|
|
|
|
| def _rows_to_dict(edited_df): |
| """Build a nested dict from the field-builder rows (skipping blank keys).""" |
| flat = {} |
| for _, row in edited_df.iterrows(): |
| key = str(row.get("Field (dotted path)", "")).strip() |
| if not key: |
| continue |
| flat[key] = _coerce_custom_value(str(row.get("Description / value", ""))) |
| return _unflatten_dotted(flat) |
|
|
|
|
| def attach_kept_columns(df): |
| """Append the user-selected source columns onto a parsed-output DataFrame. |
| |
| The parsed DataFrame is indexed by the LLM input text (the concatenated |
| raw-text columns); `st.session_state['source_keep_df']` is indexed the same |
| way, so a reindex aligns each kept source column to its parsed row. A kept |
| column whose name collides with a parsed column is prefixed `src_`. Returns |
| the DataFrame unchanged when no carry-through columns were selected. |
| """ |
| keep_df = st.session_state.get('source_keep_df') |
| if keep_df is None or df is None or df.empty or keep_df.empty: |
| return df |
| aligned = keep_df.reindex(df.index) |
| out = df.copy() |
| for c in aligned.columns: |
| name = c if c not in out.columns else f"src_{c}" |
| out[name] = aligned[c].values |
| return out |
|
|
|
|
| def _parsed_responses_dict(): |
| """The parsing dict ({description: parsed_json}) from session state, or None. |
| |
| Other pages (analyzing.py, map.py, β¦) store a *DataFrame* under the shared |
| 'parsed_responses' session key. Returning None for any non-dict value keeps |
| parsing.py's dict-only paths from raising "The truth value of a DataFrame is |
| ambiguous" when the user navigates back here after using another page. |
| """ |
| pr = st.session_state.get('parsed_responses') |
| return pr if isinstance(pr, dict) and pr else None |
|
|
|
|
| def convert_cached_data_to_df(parsed_responses): |
| if not isinstance(parsed_responses, dict) or not parsed_responses: |
| st.warning("No cached data available. Please parse the dataset first.") |
| return |
| try: |
| parsed_df_raw = pd.DataFrame(parsed_responses).T |
| |
| |
| |
| if set(parsed_df_raw.columns) == {'sightingDetails'}: |
| responses_df = pd.json_normalize(parsed_df_raw['sightingDetails'].tolist()) |
| responses_df.index = parsed_df_raw.index |
| else: |
| responses_df = pd.json_normalize(list(parsed_responses.values())) |
| responses_df.index = parsed_df_raw.index |
|
|
| for column in responses_df.columns: |
| if responses_df[column].dtype == 'object': |
| responses_df[column] = responses_df[column].astype(str) |
|
|
| if not responses_df.empty: |
| responses_df = attach_kept_columns(responses_df) |
| st.dataframe(responses_df) |
| st.session_state['parsed_responses_df'] = responses_df.copy() |
| st.success("Successfully converted cached data to DataFrame.") |
| else: |
| st.error("Failed to create DataFrame from cached responses.") |
| except Exception as e: |
| st.error(f"Error converting responses to DataFrame: {e}") |
|
|
|
|
| def render_scu_normalization(parsed_responses): |
| """Run the SCU v2 normalizer on parsed responses; show audit + downloads. |
| |
| Builds a DataFrame straight from the raw parsed-response dicts (so real |
| NaNs and list-valued fields are preserved), runs scu_normalizer.normalize(), |
| then surfaces the SCU five-criterion eligibility funnel and download buttons. |
| Toggled on/off via the global "Apply SCU normalization" switch in app.py. |
| """ |
| if not isinstance(parsed_responses, dict) or not parsed_responses: |
| st.info("No parsed data to normalize yet.") |
| return |
| try: |
| import scu_normalizer |
| except Exception as e: |
| st.error(f"Could not import scu_normalizer: {e}") |
| return |
| try: |
| raw_df = pd.json_normalize(list(parsed_responses.values())) |
| except Exception as e: |
| st.error(f"Could not build a DataFrame from parsed responses: {e}") |
| return |
| if raw_df.empty: |
| st.warning("Parsed responses produced an empty DataFrame.") |
| return |
| try: |
| norm_df, audit = scu_normalizer.normalize(raw_df) |
| except Exception as e: |
| st.error(f"Normalization failed: {e}") |
| return |
|
|
| c1, c2, c3, c4 = st.columns(4) |
| c1.metric("Rows", f"{audit.get('output_rows', len(norm_df)):,}") |
| c2.metric("SCU-eligible", f"{audit.get('scu_eligible_count', 0):,}") |
| c3.metric("In 1945-1975 window", f"{audit.get('in_scu_window_count', 0):,}") |
| c4.metric("Credible witness", f"{audit.get('has_credible_witness_count', 0):,}") |
|
|
| audit_md = scu_normalizer.audit_to_markdown(audit) |
| with st.expander("Normalization audit report", expanded=False): |
| st.markdown(audit_md) |
|
|
| _disp = norm_df.copy() |
| for _c in _disp.columns: |
| if _disp[_c].dtype == 'object': |
| _disp[_c] = _disp[_c].astype(str) |
| st.dataframe(_disp) |
| st.session_state['scu_normalized_df'] = norm_df |
|
|
| col_a, col_b = st.columns(2) |
| col_a.download_button( |
| "Download normalized CSV", |
| data=convert_df(_disp), |
| file_name="parsed_reports_normalized.csv", |
| mime="text/csv", |
| key="dl_scu_norm_csv", |
| ) |
| col_b.download_button( |
| "Download audit report (.md)", |
| data=audit_md, |
| file_name="normalization_audit.md", |
| mime="text/markdown", |
| key="dl_scu_norm_audit", |
| ) |
|
|
| st.divider() |
| render_scu_filter(norm_df) |
|
|
|
|
| def render_scu_filter(norm_df): |
| """SCU eligibility filter section: presets + incremental-filter funnel. |
| |
| Operates on the normalized DataFrame from scu_normalizer.normalize(), which |
| carries one boolean column per SCU criterion. Selecting a preset ANDs the |
| chosen criteria together (in cascade order) and draws a funnel showing how |
| each incremental filter shrinks the dataset, plus the filtered rows. |
| """ |
| import scu_normalizer |
|
|
| st.markdown("### π SCU Eligibility Filter") |
|
|
| criteria = scu_normalizer.SCU_CRITERIA |
| extra_criteria = scu_normalizer.SCU_EXTRA_CRITERIA |
| all_keys = [k for k, _c, _l in criteria] |
| label_map = {k: lbl for k, _c, lbl in (criteria + extra_criteria)} |
|
|
| |
| post_1975_keys = ["post_1975_window"] + [k for k in all_keys if k != "in_scu_window"] |
|
|
| |
| presets = { |
| "Full five-criterion gate (1945-1975, recommended)": all_keys, |
| "Post-1975 five-criterion gate (1975 onwards)": post_1975_keys, |
| "Phase-3 analog β no window / anomaly / credibility gates": [ |
| "has_core_fields", "has_investigation_channel", |
| "has_engagement_signal", "day_night_resolved", "military_public_known", |
| ], |
| "SCU window only (1945-1975)": ["in_scu_window"], |
| "Post-1975 window only (1975 onwards)": ["post_1975_window"], |
| "Data quality only β Criterion 2": ["has_core_fields"], |
| "Custom": None, |
| } |
|
|
| preset = st.selectbox("Preset", list(presets.keys()), key="scu_filter_preset") |
|
|
| if preset == "Custom": |
| st.caption("Tick the criteria to AND together (cascade order):") |
| selected = [] |
| _cols = st.columns(2) |
| for _i, (key, _col, label) in enumerate(criteria): |
| with _cols[_i % 2]: |
| if st.checkbox(label, value=True, key=f"scu_crit_{key}"): |
| selected.append(key) |
| else: |
| selected = list(presets[preset]) |
| st.caption("Criteria applied (cascade order):") |
| st.markdown( |
| "\n".join(f"- β
{label_map.get(k, k)}" for k in selected) |
| ) |
|
|
| col_x1, col_x2, col_x3 = st.columns(3) |
| with col_x1: |
| require_1yr = st.checkbox( |
| "Also require report within 1 year of sighting", |
| value=False, |
| key="scu_filter_within_1yr", |
| help="SCU Criterion 1 β uses investigation.reports_within_1_year_of_sighting.", |
| ) |
| with col_x2: |
| require_1mo = st.checkbox( |
| "Also require report within 1 month of sighting", |
| value=False, |
| key="scu_filter_within_1mo", |
| help="SCU_v2 β uses investigation.reports_within_1_month_of_sighting.", |
| ) |
| with col_x3: |
| exclude_contradict = st.checkbox( |
| "Also exclude source-denied UAPs (assessment.contradictsUap = true)", |
| value=False, |
| key="scu_filter_exclude_contradict", |
| help="SCU_v2 only β drops rows the source itself says were not a UAP.", |
| ) |
|
|
| if require_1yr and "reports_within_1_year" in norm_df.columns: |
| selected = list(selected) + ["reports_within_1_year"] |
| if require_1mo and "reports_within_1_month" in norm_df.columns: |
| selected = list(selected) + ["reports_within_1_month"] |
|
|
| if not selected and not exclude_contradict: |
| st.info("Select at least one criterion to apply a filter.") |
| return |
|
|
| stages, values, mask = scu_normalizer.incremental_funnel(norm_df, selected) |
| if exclude_contradict and "contradicts_uap" in norm_df.columns: |
| mask = mask & ~norm_df["contradicts_uap"].fillna(False).astype(bool) |
| stages = stages + ["Exclude source-denied UAPs"] |
| values = values + [int(mask.sum())] |
|
|
| total = values[0] if values else 0 |
|
|
| |
| fig = go.Figure(go.Funnel( |
| y=stages, |
| x=values, |
| textposition="inside", |
| textinfo="value+percent initial", |
| textfont={"color": "white"}, |
| marker={ |
| "color": "rgba(255,140,0,0.85)", |
| "line": {"color": "orange", "width": 1.5}, |
| }, |
| connector={"line": {"color": "orange", "width": 1, "dash": "dot"}}, |
| )) |
| fig.update_layout( |
| title={"text": "Incremental SCU filter cascade", "font": {"color": "orange"}}, |
| paper_bgcolor="rgba(0,0,0,0)", |
| plot_bgcolor="rgba(0,0,0,0)", |
| font={"color": "orange"}, |
| height=80 * len(stages) + 100, |
| margin={"l": 10, "r": 10, "t": 50, "b": 10}, |
| ) |
| st.plotly_chart(fig, use_container_width=True) |
|
|
| |
| drop_rows = [] |
| for _i in range(1, len(stages)): |
| drop_rows.append({ |
| "Step": stages[_i], |
| "Remaining": values[_i], |
| "Dropped here": values[_i - 1] - values[_i], |
| "% of parsed": f"{values[_i] / total * 100:.1f}%" if total else "β", |
| }) |
| if drop_rows: |
| st.dataframe( |
| pd.DataFrame(drop_rows), hide_index=True, use_container_width=True |
| ) |
|
|
| |
| filtered = norm_df[mask] |
| m1, m2, m3 = st.columns(3) |
| m1.metric("Parsed rows", f"{total:,}") |
| m2.metric("Rows passing filter", f"{len(filtered):,}") |
| m3.metric( |
| "% retained", f"{len(filtered) / total * 100:.1f}%" if total else "β" |
| ) |
|
|
| st.session_state['scu_filtered_df'] = filtered |
| _fdisp = filtered.copy() |
| for _c in _fdisp.columns: |
| if _fdisp[_c].dtype == 'object': |
| _fdisp[_c] = _fdisp[_c].astype(str) |
| st.dataframe(_fdisp) |
| st.download_button( |
| "Download filtered CSV", |
| data=convert_df(_fdisp), |
| file_name="scu_filtered.csv", |
| mime="text/csv", |
| key="dl_scu_filtered_csv", |
| ) |
|
|
|
|
| def plot_line(df, x_column, y_columns, figsize=(12, 10), color='orange', title=None, rolling_mean_value=2): |
| import matplotlib.cm as cm |
| |
| df = df.sort_values(by=x_column) |
|
|
| |
| if rolling_mean_value: |
| df[y_columns] = df[y_columns].rolling(len(df) // rolling_mean_value).mean() |
|
|
| |
| fig, ax = plt.subplots(figsize=figsize) |
|
|
| colors = cm.Oranges(np.linspace(0.2, 1, len(y_columns))) |
|
|
| |
| for i, y_column in enumerate(y_columns): |
| df.plot(x=x_column, y=y_column, ax=ax, color=colors[i], label=y_column, linewidth=.5) |
|
|
| |
| ax.set_xticklabels(ax.get_xticklabels(), rotation=30, ha='right') |
|
|
| |
| if np.issubdtype(df[x_column].dtype, np.datetime64) or np.issubdtype(df[x_column].dtype, np.timedelta64): |
| df[x_column] = pd.to_datetime(df[x_column]).dt.date |
|
|
| |
| ax.set_title(title or f'{", ".join(y_columns)} over {x_column}', color=color, fontweight='bold') |
| ax.set_xlabel(x_column, color=color) |
| ax.set_ylabel(', '.join(y_columns), color=color) |
| ax.spines['bottom'].set_color('orange') |
| ax.spines['top'].set_color('orange') |
| ax.spines['right'].set_color('orange') |
| ax.spines['left'].set_color('orange') |
| ax.xaxis.label.set_color('orange') |
| ax.yaxis.label.set_color('orange') |
| ax.tick_params(axis='x', colors='orange') |
| ax.tick_params(axis='y', colors='orange') |
| ax.title.set_color('orange') |
|
|
| ax.legend(loc='upper right', bbox_to_anchor=(1, 1), facecolor='black', framealpha=.4, labelcolor='orange', edgecolor='orange') |
|
|
| |
| fig.patch.set_alpha(0) |
| ax.patch.set_alpha(0) |
|
|
| return fig |
|
|
|
|
| def plot_bar(df, x_column, y_column, figsize=(12, 10), color='orange', title=None): |
| fig, ax = plt.subplots(figsize=figsize) |
|
|
| sns.barplot(data=df, x=x_column, y=y_column, color=color, ax=ax) |
|
|
| |
| ax.set_xticklabels(ax.get_xticklabels(), rotation=45, ha='right') |
|
|
| ax.set_title(title if title else f'{y_column} by {x_column}', color=color, fontweight='bold') |
| ax.set_xlabel(x_column, color=color) |
| ax.set_ylabel(y_column, color=color) |
|
|
| ax.tick_params(axis='x', colors=color) |
| ax.tick_params(axis='y', colors=color) |
|
|
| |
| fig.patch.set_alpha(0) |
| ax.patch.set_alpha(0) |
| ax.spines['bottom'].set_color('orange') |
| ax.spines['top'].set_color('orange') |
| ax.spines['right'].set_color('orange') |
| ax.spines['left'].set_color('orange') |
| ax.xaxis.label.set_color('orange') |
| ax.yaxis.label.set_color('orange') |
| ax.tick_params(axis='x', colors='orange') |
| ax.tick_params(axis='y', colors='orange') |
| ax.title.set_color('orange') |
| ax.legend(loc='upper right', bbox_to_anchor=(1, 1), facecolor='black', framealpha=.4, labelcolor='orange', edgecolor='orange') |
| return fig |
|
|
| def plot_grouped_bar(df, x_columns, y_column, figsize=(12, 10), colors=None, title=None): |
| fig, ax = plt.subplots(figsize=figsize) |
|
|
| width = 0.8 / len(x_columns) |
| x = np.arange(len(df)) |
|
|
| for i, x_column in enumerate(x_columns): |
| sns.barplot(data=df, x=x, y=y_column, color=colors[i] if colors else None, ax=ax, width=width, label=x_column) |
| x += width |
|
|
| ax.set_title(title if title else f'{y_column} by {", ".join(x_columns)}', color='orange', fontweight='bold') |
| ax.set_xlabel('Groups', color='orange') |
| ax.set_ylabel(y_column, color='orange') |
|
|
| ax.set_xticks(x - width * len(x_columns) / 2) |
| ax.set_xticklabels(df.index) |
|
|
| ax.tick_params(axis='x', colors='orange') |
| ax.tick_params(axis='y', colors='orange') |
|
|
| |
| fig.patch.set_alpha(0) |
| ax.patch.set_alpha(0) |
| ax.spines['bottom'].set_color('orange') |
| ax.spines['top'].set_color('orange') |
| ax.spines['right'].set_color('orange') |
| ax.spines['left'].set_color('orange') |
| ax.xaxis.label.set_color('orange') |
| ax.yaxis.label.set_color('orange') |
| ax.title.set_color('orange') |
| ax.legend(loc='upper right', bbox_to_anchor=(1, 1), facecolor='black', framealpha=.4, labelcolor='orange', edgecolor='orange') |
|
|
| return fig |
|
|
| @st.cache_data |
| def convert_df(df): |
| |
| try: |
| csv = df.to_csv().encode("utf-8") |
| except: |
| csv = df.to_csv().encode("utf-8-sig") |
| return csv |
|
|
|
|
| def filter_dataframe(df: pd.DataFrame) -> pd.DataFrame: |
| """Shared filtering UI β delegates to the DataProcessor so the parsing and |
| analysis pages use one and the same filter system (mirrors |
| ``analyzing.py``'s ``filter_dataframe``). Binary 0/1 columns get a value |
| picker; other numerics get range/percentile/std-dev modes. |
| """ |
| return DataProcessor.filter_dataframe_enhanced( |
| df, enable_quick_filters=False, enable_advanced_filters=True |
| ) |
|
|
|
|
| def filter_dataframe_legacy(df: pd.DataFrame) -> pd.DataFrame: |
| """ |
| Adds a UI on top of a dataframe to let viewers filter columns |
| |
| Args: |
| df (pd.DataFrame): Original dataframe |
| |
| Returns: |
| pd.DataFrame: Filtered dataframe |
| """ |
|
|
| title_font = "Arial" |
| body_font = "Arial" |
| title_size = 32 |
| colors = ["red", "green", "blue"] |
| interpretation = False |
| extract_docx = False |
| title = "My Chart" |
| regex = ".*" |
| img_path = 'default_image.png' |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| df_ = df.copy() |
| |
|
|
| |
|
|
| |
| to_filter_columns = st.multiselect("Filter dataframe on", df_.columns) |
|
|
| date_column = None |
| filtered_columns = [] |
|
|
| for column in to_filter_columns: |
| left, right = st.columns((1, 20)) |
| |
| if is_categorical_dtype(df_[column]) or (df_[column].nunique() < 120 and not is_datetime64_any_dtype(df_[column]) and not is_numeric_dtype(df_[column])): |
| user_cat_input = right.multiselect( |
| f"Values for {column}", |
| df_[column].value_counts().index.tolist(), |
| default=list(df_[column].value_counts().index) |
| ) |
| df_ = df_[df_[column].isin(user_cat_input)] |
| filtered_columns.append(column) |
|
|
| with st.status(f"Category Distribution: {column}", expanded=False) as stat: |
| st.pyplot(plot_treemap(df_, column)) |
|
|
| elif is_numeric_dtype(df_[column]): |
| _nonnull = df_[column].dropna() |
| _uniq = set(_nonnull.unique()) |
| |
| |
| if _uniq and _uniq.issubset({0, 1}): |
| _opts = sorted(_uniq) |
| user_bin_input = right.multiselect( |
| f"Values for {column}", |
| _opts, |
| default=_opts, |
| format_func=lambda v: f"{int(v)} β {'true' if int(v) == 1 else 'false'}", |
| key=f"filt_bin_{column}", |
| ) |
| df_ = df_[df_[column].isin(user_bin_input)] |
| filtered_columns.append(column) |
|
|
| with st.status(f"Binary Distribution: {column}", expanded=False): |
| _vc = df_[column].value_counts().sort_index() |
| _vc.index = _vc.index.map( |
| lambda v: f"{int(v)} ({'true' if int(v) == 1 else 'false'})" |
| ) |
| st.bar_chart(_vc) |
| else: |
| _min = float(df_[column].min()) |
| _max = float(df_[column].max()) |
| step = (_max - _min) / 100 |
| user_num_input = right.slider( |
| f"Values for {column}", |
| min_value=_min, |
| max_value=_max, |
| value=(_min, _max), |
| step=step, |
| ) |
| df_ = df_[df_[column].between(*user_num_input)] |
| filtered_columns.append(column) |
|
|
| |
| |
|
|
| with st.status(f"Numerical Distribution: {column}", expanded=False) as stat_: |
| st.pyplot(plot_hist(df_, column, bins=int(round(len(df_[column].unique())-1)/2))) |
|
|
| elif is_object_dtype(df_[column]): |
| _orig_col = df_[column].copy() |
| _is_valid_date = False |
| try: |
| df_[column] = pd.to_datetime(df_[column], errors='coerce').astype('datetime64[ms]') |
| except Exception: |
| pass |
|
|
| if is_datetime64_any_dtype(df_[column]): |
| try: |
| df_[column] = df_[column].dt.tz_localize(None) |
| except TypeError: |
| df_[column] = df_[column].dt.tz_convert(None) |
| valid = df_[column].dropna() |
| if not valid.empty: |
| min_date = valid.min().date() |
| max_date = valid.max().date() |
| if min_date != max_date: |
| _is_valid_date = True |
| user_date_input = right.date_input( |
| f"Values for {column}", |
| value=(min_date, max_date), |
| min_value=min_date, |
| max_value=max_date, |
| ) |
| if len(user_date_input) == 2: |
| user_date_input = tuple(map(pd.to_datetime, user_date_input)) |
| start_date, end_date = user_date_input |
|
|
| time_units = { |
| 'year': df_[column].dt.year, |
| 'month': df_[column].dt.to_period('M'), |
| 'day': df_[column].dt.date |
| } |
| unique_counts = {unit: col.nunique() for unit, col in time_units.items()} |
| closest_to_36 = min(unique_counts, key=lambda k: abs(unique_counts[k] - 36)) |
|
|
| grouped = df_.groupby(time_units[closest_to_36]).size().reset_index(name='count') |
| grouped.columns = [column, 'count'] |
|
|
| if closest_to_36 == 'year': |
| date_range = pd.date_range(start=f"{start_date.year}-01-01", end=f"{end_date.year}-12-31", freq='YS') |
| elif closest_to_36 == 'month': |
| date_range = pd.date_range(start=start_date.replace(day=1), end=end_date + pd.offsets.MonthEnd(0), freq='MS') |
| else: |
| date_range = pd.date_range(start=start_date, end=end_date, freq='D') |
|
|
| complete_range = pd.DataFrame({column: date_range}) |
| if closest_to_36 == 'year': |
| complete_range[column] = complete_range[column].dt.year |
| elif closest_to_36 == 'month': |
| complete_range[column] = complete_range[column].dt.to_period('M') |
|
|
| final_data = pd.merge(complete_range, grouped, on=column, how='left').fillna(0) |
| with st.status(f"Date Distributions: {column}", expanded=False) as stat: |
| try: |
| st.pyplot(plot_bar(final_data, column, 'count')) |
| except Exception as e: |
| st.error(f"Error plotting bar chart: {e}") |
| df_ = df_.loc[df_[column].between(start_date, end_date)] |
|
|
| date_column = column |
| if date_column and filtered_columns: |
| numeric_columns = [col for col in filtered_columns if is_numeric_dtype(df_[col])] |
| categorical_columns = [col for col in filtered_columns if is_categorical_dtype(df_[col])] |
| with st.status(f"Date Distribution: {column}", expanded=False) as stat: |
| if numeric_columns: |
| try: |
| st.pyplot(plot_line(df_, date_column, numeric_columns)) |
| except Exception as e: |
| st.error(f"Error plotting line chart: {e}") |
| if categorical_columns: |
| try: |
| st.pyplot(plot_bar(df_, date_column, categorical_columns[0])) |
| except Exception as e: |
| st.error(f"Error plotting bar chart: {e}") |
|
|
| if not _is_valid_date: |
| df_[column] = _orig_col |
| _txt_col, _btn_col = right.columns([5, 1]) |
| user_text_input = _txt_col.text_input( |
| f"Substring or regex in {column}", |
| key=f"regex_{column}", |
| ) |
| _drop_null = _btn_col.checkbox("Drop nulls", key=f"dropnull_{column}") |
| if user_text_input: |
| try: |
| mask = df_[column].astype(str).str.contains(user_text_input, case=False, na=False, regex=True) |
| except Exception: |
| mask = df_[column].astype(str).str.contains(user_text_input, case=False, na=False, regex=False) |
| df_ = df_.loc[mask] |
| filtered_columns.append(column) |
| if _drop_null: |
| df_ = df_.loc[df_[column].notna() & (df_[column].astype(str).str.strip() != '')] |
| if column not in filtered_columns: |
| filtered_columns.append(column) |
| |
| st.write(f"{len(df_)} rows ({len(df_) / len(df) * 100:.2f}%)") |
| return df_ |
|
|
|
|
| from config import ( |
| FORMAT_LONG, FORMAT_LONG_XLSX, FORMAT_SCU_V1, FORMAT_SCU_V2, FORMAT_SCU_V3, FORMAT_MERGED, FORMAT_UFOSETI_RU, |
| FORMAT_NUFORC, FORMAT_BLUE_BOOK, FORMAT_UK_NATIONAL_ARCHIVES, |
| FORMAT_COBEPS_NOTIFICATIONS_PAN, FORMAT_COBEPS_COB_2021, |
| FORMAT_GEP, FORMAT_UPDB_NICAP, FORMAT_OVNIBASE, FORMAT_UFOSETI, |
| FORMAT_UAP_SIGHTINGS_GITHUB, FORMAT_WEINSTEIN_PILOT_CATALOG, |
| FORMAT_PETROWITSCH_LATAM, FORMAT_UFOCAT, |
| FORMAT_CISU_CISUCAT, FORMAT_CUFOC_1977_ITALY_FRANCE, FORMAT_UAPCHECK, |
| ) |
|
|
| OPENAI_KEY = st.secrets["OPENAI_KEY"] |
| GEMINI_KEY = st.secrets["GEMINI_KEY"] |
| DEEPSEEK_KEY = st.secrets.get("DEEPSEEK_KEY", "") |
|
|
|
|
| |
| |
| |
| |
| SCHEMA_FORMATS = { |
| "SCU_v1": FORMAT_SCU_V1, |
| "Default UAP Format": FORMAT_LONG, |
| "SCU Spreadsheet": FORMAT_LONG_XLSX, |
| "SCU_v2": FORMAT_SCU_V2, |
| "SCU_v3": FORMAT_SCU_V3, |
| "UFOSETI (RU)": FORMAT_UFOSETI_RU, |
| "NUFORC": FORMAT_NUFORC, |
| "Blue Book (USAF)": FORMAT_BLUE_BOOK, |
| "UK National Archives": FORMAT_UK_NATIONAL_ARCHIVES, |
| "COBEPS β PAN Notifications (BE)": FORMAT_COBEPS_NOTIFICATIONS_PAN, |
| "COBEPS β COB 2021 (BE)": FORMAT_COBEPS_COB_2021, |
| "GEP (DE)": FORMAT_GEP, |
| "UPDB / NICAP": FORMAT_UPDB_NICAP, |
| "OVNIBASE (FR)": FORMAT_OVNIBASE, |
| "UFOSETI (raw)": FORMAT_UFOSETI, |
| "UAP Sightings GitHub": FORMAT_UAP_SIGHTINGS_GITHUB, |
| "Weinstein Pilot Catalog": FORMAT_WEINSTEIN_PILOT_CATALOG, |
| "Petrowitsch LATAM": FORMAT_PETROWITSCH_LATAM, |
| "UFOCAT 2023": FORMAT_UFOCAT, |
| "CISU / CISUCAT (IT)": FORMAT_CISU_CISUCAT, |
| "CUFOC 1977 Italy-France": FORMAT_CUFOC_1977_ITALY_FRANCE, |
| "UAPCHECK Registry": FORMAT_UAPCHECK, |
| } |
|
|
|
|
| |
| |
| |
| SCHEMA_FORMAT_GROUPS = { |
| "Canonical & SCU": [ |
| "SCU_v1", "Default UAP Format", "SCU Spreadsheet", "SCU_v2", "SCU_v3", |
| ], |
| "Government & official archives": [ |
| "Blue Book (USAF)", "UK National Archives", |
| ], |
| "European national databases": [ |
| "COBEPS β PAN Notifications (BE)", "COBEPS β COB 2021 (BE)", |
| "GEP (DE)", "OVNIBASE (FR)", "CISU / CISUCAT (IT)", |
| "CUFOC 1977 Italy-France", |
| ], |
| "Research catalogs": [ |
| "UPDB / NICAP", "UFOCAT 2023", "Weinstein Pilot Catalog", |
| "Petrowitsch LATAM", |
| ], |
| "Public & crowd-sourced": [ |
| "NUFORC", "UAP Sightings GitHub", "UAPCHECK Registry", |
| ], |
| "UFOSETI / SETI": [ |
| "UFOSETI (RU)", "UFOSETI (raw)", |
| ], |
| } |
|
|
|
|
| |
| |
| SCHEMA_FORMAT_ORIGINS = { |
| "SCU_v1": |
| "The default extraction schema β the full SCU_v3 field set minus the " |
| "verbose `sightingDetails` narrative block and the all-null `manifest` " |
| "join placeholders. Keeps source, assessment, anomaly, location, " |
| "witness, craft, performance, military, effects and engagement fields.", |
| "Default UAP Format": |
| "The project's canonical nested narrative schema (`sightingDetails`). " |
| "General-purpose GPT extraction template covering location, craft, " |
| "observer, evidence and effects.", |
| "SCU Spreadsheet": |
| "Mirrors the *UAP Activities Data 1975 Onwards* spreadsheet column " |
| "groups; used by `uap_xlsx_filler.py` to populate the SCU spreadsheet.", |
| "SCU_v2": |
| "A redlined evolution of the SCU spreadsheet schema β adds " |
| "source/assessment/anomaly blocks, tri-state (Y/N/U) flags, structured " |
| "witness roles and an evidence array for the SCU five-criterion gate.", |
| "SCU_v3": |
| "Extends SCU_v2 with UFOSETI assessment frameworks: UFOCAT-style " |
| "explanation taxonomy, HynekβVallΓ©e recovery + biological-entity flags, " |
| "and a human-injury medical-effect extension.", |
| "UFOSETI (RU)": |
| "LLM extraction template mirroring the Russian UFOSETI database export; " |
| "anomaly codes follow the UFOCAT Codebook 2023.", |
| "NUFORC": |
| "US National UFO Reporting Center β the large public US crowd-sourced " |
| "report database (~70,000 rows, EN).", |
| "Blue Book (USAF)": |
| "US Air Force Project Blue Book official case files, 1947β1969 " |
| "(~15,000 rows, EN).", |
| "UK National Archives": |
| "UK Ministry of Defence / National Archives UAP catalogue, 1978β2000 " |
| "(~2,827 rows, EN).", |
| "COBEPS β PAN Notifications (BE)": |
| "Belgium's COBEPS official PAN (UAP) notifications up to 31 Dec 2023 " |
| "(~1,308 rows, FR).", |
| "COBEPS β COB 2021 (BE)": |
| "COBEPS *COB Online* historical Belgian catalogue, pre-2021 " |
| "(~2,111 rows, FR).", |
| "GEP (DE)": |
| "German GEP UFO/UAP case data, 1972β2023 (~5,577 rows, DE).", |
| "UPDB / NICAP": |
| "UPDB / NICAP *NSID* database of US cases (~5,885 rows, EN).", |
| "OVNIBASE (FR)": |
| "French OVNIBASE UFO database (~3,145 rows, FR).", |
| "UFOSETI (raw)": |
| "Raw column schema of the UFOSETI.org Russian/international UAP dataset " |
| "(~3,069 rows, EN+RU). The RU variant above is its extraction template.", |
| "UAP Sightings GitHub": |
| "MiChaelinzo/UAP_SIGTHINGS multi-source sighting JSON aggregation " |
| "(~1,000-row sample, EN).", |
| "Weinstein Pilot Catalog": |
| "Dominique Weinstein's catalogue of aircraft-crew UAP encounters, " |
| "1916β1999 (~1,300 rows, EN).", |
| "Petrowitsch LATAM": |
| "Petrowitsch catalogue of Chilean & Latin American UAP cases " |
| "(~3,529+ rows, ES).", |
| "UFOCAT 2023": |
| "UFOCAT 2023 from the J. Allen Hynek Center for UFO Studies / Sun River " |
| "Research Institute β a very large research catalogue (~146,000 rows, EN).", |
| "CISU / CISUCAT (IT)": |
| "The Italian CISU national UAP catalogue (CISUCAT) " |
| "(~50,000+ rows est., IT).", |
| "CUFOC 1977 Italy-France": |
| "CUFOC study of the 1977 ItalyβFrance wave (Bourdon/Delaval " |
| "digitisation), FR/IT.", |
| "UAPCHECK Registry": |
| "UAPCHECK.com international data-exchange API registry of 22 " |
| "organisations β an organisational registry, not a sighting catalogue.", |
| } |
|
|
|
|
| def schema_format_groups() -> dict: |
| """SCHEMA_FORMAT_GROUPS, but with any label not yet assigned to a group |
| swept into an 'Other' bucket β so a newly added schema never disappears |
| from the picker if someone forgets to slot it into a group.""" |
| grouped = {g: [lbl for lbl in labels if lbl in SCHEMA_FORMATS] |
| for g, labels in SCHEMA_FORMAT_GROUPS.items()} |
| assigned = {lbl for labels in grouped.values() for lbl in labels} |
| leftover = [lbl for lbl in SCHEMA_FORMATS if lbl not in assigned] |
| if leftover: |
| grouped["Other"] = leftover |
| return {g: labels for g, labels in grouped.items() if labels} |
|
|
|
|
| def _fmt_as_dict(v) -> dict: |
| """Coerce a schema entry (dict or JSON string) to a dict; {} on failure.""" |
| if isinstance(v, dict): |
| return v |
| try: |
| return json.loads(v) |
| except Exception: |
| return {} |
|
|
|
|
| def _deep_merge(base: dict, override: dict) -> dict: |
| """Recursively merge `override` onto `base` without mutating either.""" |
| result = base.copy() |
| for k, v in override.items(): |
| if k in result and isinstance(result[k], dict) and isinstance(v, dict): |
| result[k] = _deep_merge(result[k], v) |
| else: |
| result[k] = v |
| return result |
|
|
|
|
| def _current_dataset_columns() -> pd.DataFrame: |
| """Best-available view of the curated dataset's columns for comparison. |
| |
| Prefers the flattened parsed-table DataFrame; otherwise normalizes the |
| parsed_responses record dict. Returns an empty frame when nothing's loaded. |
| """ |
| df = st.session_state.get('parsed_responses_df') |
| if isinstance(df, pd.DataFrame) and not df.empty: |
| return df |
| pr = _parsed_responses_dict() |
| if pr: |
| try: |
| return pd.json_normalize(list(pr.values())) |
| except Exception: |
| return pd.DataFrame() |
| return pd.DataFrame() |
|
|
|
|
| def render_schema_dataset_diff(schema_dict: dict, df: pd.DataFrame, |
| ignore_parent: bool = True) -> dict: |
| """Field-level coverage diff between a JSON schema and a dataset. |
| |
| Renders a red/green table: |
| π΄ missing β a schema field with no matching dataset column |
| π’ added β a dataset column the schema doesn't define |
| β matched β present in both |
| |
| `ignore_parent` matches on the leaf field name (so `location.city` β‘ `city`), |
| which is what you want when the curated dataset flattened or renamed parents. |
| Returns the three name-lists for downstream use (e.g. upsert planning). |
| """ |
| norm = (lambda s: s.split(".")[-1]) if ignore_parent else (lambda s: s) |
|
|
| schema_leaves = list(_flatten_dotted(schema_dict).keys()) |
| data_cols = [str(c) for c in df.columns] |
|
|
| |
| s_map: dict = {} |
| for s in schema_leaves: |
| s_map.setdefault(norm(s), s) |
| d_map: dict = {} |
| for c in data_cols: |
| d_map.setdefault(norm(c), c) |
|
|
| s_keys, d_keys = set(s_map), set(d_map) |
| matched_k = sorted(s_keys & d_keys) |
| missing_k = sorted(s_keys - d_keys) |
| extra_k = sorted(d_keys - s_keys) |
|
|
| n_schema = len(s_keys) |
| coverage = (len(matched_k) / n_schema * 100) if n_schema else 0.0 |
|
|
| m1, m2, m3, m4 = st.columns(4) |
| m1.metric("Schema fields", n_schema) |
| m2.metric("β Matched", len(matched_k)) |
| m3.metric("π΄ Missing", len(missing_k), |
| help="Defined in the schema but absent from the dataset.") |
| m4.metric("π’ Added", len(extra_k), |
| help="Present in the dataset but not defined by the schema.") |
| st.progress(min(1.0, coverage / 100), |
| text=f"Schema coverage: {coverage:.0f}% of schema fields present in the dataset") |
|
|
| rows = ( |
| [{"Field": s_map[k], "Status": "β In both"} for k in matched_k] |
| + [{"Field": s_map[k], "Status": "π΄ Missing from dataset"} for k in missing_k] |
| + [{"Field": d_map[k], "Status": "π’ Added (not in schema)"} for k in extra_k] |
| ) |
| diff_df = pd.DataFrame(rows, columns=["Field", "Status"]) |
|
|
| def _row_style(r): |
| status = r["Status"] |
| if status.startswith("π΄"): |
| color = "rgba(255, 75, 75, 0.22)" |
| elif status.startswith("π’"): |
| color = "rgba(60, 200, 90, 0.22)" |
| else: |
| color = "" |
| return [f"background-color: {color}"] * len(r) |
|
|
| st.dataframe( |
| diff_df.style.apply(_row_style, axis=1), |
| hide_index=True, use_container_width=True, |
| ) |
| st.download_button( |
| "β¬οΈ Download schemaβdataset diff (CSV)", |
| diff_df.to_csv(index=False), |
| "schema_dataset_diff.csv", "text/csv", key="dl_schema_dataset_diff", |
| ) |
|
|
| |
| |
| |
| |
| flat_schema = _flatten_dotted(schema_dict) |
| src_payload = dict(flat_schema) |
| both_payload = {s_map[k]: flat_schema.get(s_map[k], "string") for k in matched_k} |
| miss_payload = {s_map[k]: flat_schema.get(s_map[k], "string") for k in missing_k} |
| dest_payload = {d_map[k]: flat_schema.get(s_map[k], "string") for k in matched_k} |
| dest_payload.update({d_map[k]: "string β in dataset, not defined by the schema" |
| for k in extra_k}) |
|
|
| st.caption( |
| "**Send to extract** β fill the JSON Format editor on the **UAP Feature " |
| "Extraction** step with one side of this diff:" |
| ) |
|
|
| def _send_to_extract(note: str, payload: dict) -> None: |
| n = len(payload) |
| st.session_state["_extract_seed_json"] = json.dumps( |
| _unflatten_dotted(payload), indent=2 |
| ) |
| st.session_state["_extract_seed_note"] = f"{note} ({n} field{'' if n == 1 else 's'})" |
| st.toast(f"π€ Sent {n} field{'' if n == 1 else 's'} to the extraction JSON editor.") |
|
|
| sb1, sb2, sb3, sb4 = st.columns(4) |
| if sb1.button(f"π€ Source Β· {len(src_payload)}", key="send_extract_source", |
| use_container_width=True, |
| help="All fields defined by the selected schema."): |
| _send_to_extract("Source / schema fields", src_payload) |
| if sb2.button(f"π€ Destination Β· {len(dest_payload)}", key="send_extract_dest", |
| use_container_width=True, |
| help="Every column in the uploaded dataset (matched + π’ extra)."): |
| _send_to_extract("Destination / dataset columns", dest_payload) |
| if sb3.button(f"π€ Missing Β· {len(miss_payload)}", key="send_extract_missing", |
| use_container_width=True, disabled=not miss_payload, |
| help="π΄ Schema fields the dataset lacks β extract these to fill the gaps."): |
| _send_to_extract("Missing fields", miss_payload) |
| if sb4.button(f"π€ Both Β· {len(both_payload)}", key="send_extract_both", |
| use_container_width=True, disabled=not both_payload, |
| help="β Fields present in both the schema and the dataset."): |
| _send_to_extract("Matched fields", both_payload) |
|
|
| return { |
| "matched": [s_map[k] for k in matched_k], |
| "missing": [s_map[k] for k in missing_k], |
| "added": [d_map[k] for k in extra_k], |
| } |
|
|
|
|
| def render_schema_dataset_diff_ui(df: pd.DataFrame) -> None: |
| """Grouped schema picker β explicit Compare β coverage diff. |
| |
| The schema formats act as the interoperability layer: you upload a curated |
| dataset, pick the standard schema to map it onto (organised by logical |
| group), then press **Compare**. Results persist across reruns so changing |
| other widgets doesn't wipe them. |
| """ |
| st.caption( |
| "Pick the standard schema to map your curated dataset onto, then press " |
| "**Compare**. **π΄ red** = in the schema but missing from the dataset; " |
| "**π’ green** = extra columns in the dataset the schema doesn't define." |
| ) |
|
|
| groups = schema_format_groups() |
| c1, c2, c3 = st.columns([2, 3, 2]) |
| with c1: |
| group = st.selectbox( |
| "Schema group", list(groups.keys()), key="schema_diff_group" |
| ) |
| with c2: |
| |
| _opts = groups.get(group, []) |
| if st.session_state.get("schema_diff_schema") not in _opts: |
| st.session_state["schema_diff_schema"] = _opts[0] if _opts else None |
| schema_label = st.selectbox( |
| "Schema", _opts, key="schema_diff_schema" |
| ) |
| with c3: |
| ignore_parent = st.checkbox( |
| "Match on field name only", |
| value=True, key="schema_diff_ignore_parent", |
| help="Treat `location.city` and `city` as the same field β useful " |
| "when the curated dataset flattens or renames parent paths.", |
| ) |
|
|
| if schema_label and schema_label in SCHEMA_FORMAT_ORIGINS: |
| st.caption(f"βΉοΈ *Origin β* {SCHEMA_FORMAT_ORIGINS[schema_label]}") |
|
|
| st.caption(f"Dataset: **{len(df):,}** rows Γ **{len(df.columns)}** columns.") |
|
|
| if st.button("π Compare", type="primary", key="schema_diff_run", |
| disabled=not schema_label): |
| st.session_state["schema_diff_result"] = { |
| "schema_label": schema_label, |
| "ignore_parent": ignore_parent, |
| } |
|
|
| res = st.session_state.get("schema_diff_result") |
| if not res: |
| st.info("Choose a schema and press **Compare** to see the coverage diff.") |
| return |
| if res["schema_label"] not in SCHEMA_FORMATS: |
| st.session_state.pop("schema_diff_result", None) |
| return |
|
|
| st.markdown(f"**Comparing dataset against schema:** `{res['schema_label']}`") |
| merged = _fmt_as_dict(SCHEMA_FORMATS[res["schema_label"]]) |
| render_schema_dataset_diff(merged, df, ignore_parent=res["ignore_parent"]) |
|
|
| with torch.no_grad(): |
| torch.cuda.empty_cache() |
|
|
| |
| |
| |
| |
| |
| |
|
|
| st.title('UAP Feature Extraction') |
|
|
| |
| if 'analyzers' not in st.session_state: |
| st.session_state['analyzers'] = [] |
| if 'col_names' not in st.session_state: |
| st.session_state['col_names'] = [] |
| if 'clusters' not in st.session_state: |
| st.session_state['clusters'] = {} |
| if 'new_data' not in st.session_state: |
| st.session_state['new_data'] = pd.DataFrame() |
| if 'dataset' not in st.session_state: |
| st.session_state['dataset'] = pd.DataFrame() |
| if 'data_processed' not in st.session_state: |
| st.session_state['data_processed'] = False |
| if 'stage' not in st.session_state: |
| st.session_state['stage'] = 0 |
| if 'filtered_data' not in st.session_state: |
| st.session_state['filtered_data'] = None |
| if 'gemini_answer' not in st.session_state: |
| st.session_state['gemini_answer'] = None |
| if 'parsed_responses' not in st.session_state: |
| st.session_state['parsed_responses'] = None |
| if 'parsed_responses_df' not in st.session_state: |
| st.session_state['parsed_responses_df'] = None |
| if 'json_format' not in st.session_state: |
| st.session_state['json_format'] = None |
| if 'api_key_valid' not in st.session_state: |
| st.session_state['api_key_valid'] = False |
| if 'previous_api_key' not in st.session_state: |
| st.session_state['previous_api_key'] = None |
|
|
| |
| |
| |
| |
| col_up1, col_up2 = st.columns(2) |
| unparsed = col_up1.file_uploader("Upload Raw DataFrame (CSV / XLSX / JSON)", type=["csv", "xlsx", "json"]) |
| restore_file = col_up2.file_uploader( |
| "Or restore parsed data (JSON / CSV / XLSX)", |
| type=["json", "csv", "xlsx"], |
| help=( |
| "Skip re-parsing by restoring a previous session β the loader is " |
| "chosen by file type:\n\n" |
| "β’ **parsed_responses.json** β the exported records dict.\n\n" |
| "β’ **a saved flat parsed table (CSV / XLSX, e.g. uap_data.csv)** β each " |
| "row is re-nested into the parsed_responses dict so the SCU normalizer " |
| "can run; extra/custom schema columns pass through unchanged." |
| ), |
| ) |
| |
| |
| _is_json_restore = ( |
| restore_file is not None and restore_file.name.lower().endswith(".json") |
| ) |
| json_restore = restore_file if _is_json_restore else None |
| parsed_table_restore = ( |
| restore_file if (restore_file is not None and not _is_json_restore) else None |
| ) |
|
|
| if json_restore is not None: |
| try: |
| restored = json.loads(json_restore.read()) |
| st.session_state['parsed_responses'] = restored |
| st.success(f"Loaded {len(restored)} records from JSON.") |
| except Exception as e: |
| st.error(f"Could not read JSON: {e}") |
|
|
| if parsed_table_restore is not None: |
| try: |
| import ast as _ast |
|
|
| if parsed_table_restore.name.lower().endswith(".xlsx"): |
| _flat_df = pd.read_excel(parsed_table_restore) |
| else: |
| _flat_df = pd.read_csv(parsed_table_restore) |
| |
| _flat_df = _flat_df.loc[ |
| :, ~_flat_df.columns.astype(str).str.match(r"^Unnamed: \d+$") |
| ] |
|
|
| |
| |
| |
| |
| |
| |
| def _maybe_literal(v): |
| if not isinstance(v, str): |
| return v |
| s = v.strip() |
| if len(s) >= 2 and ((s[0] == "[" and s[-1] == "]") or |
| (s[0] == "{" and s[-1] == "}") or |
| (s[0] == "(" and s[-1] == ")")): |
| try: |
| return _ast.literal_eval(s) |
| except (ValueError, SyntaxError): |
| return v |
| return v |
|
|
| |
| |
| |
| |
| restored_table = { |
| str(idx): { |
| k: _maybe_literal(v) |
| for k, v in row.items() if pd.notna(v) |
| } |
| for idx, row in _flat_df.iterrows() |
| } |
| st.session_state['parsed_responses'] = restored_table |
| st.success( |
| f"Loaded {len(restored_table)} rows Γ {len(_flat_df.columns)} columns. " |
| "Enable 'Apply SCU normalization' in the sidebar to normalize." |
| ) |
| except Exception as e: |
| st.error(f"Could not read CSV/XLSX: {e}") |
|
|
| |
| if json_restore is not None and unparsed is None and _parsed_responses_dict(): |
| st.divider() |
| st.markdown("**Loaded from JSON β export options**") |
|
|
| json_str = download_json(st.session_state['parsed_responses']) |
| st.download_button( |
| label="Download Parsed Data as JSON", |
| data=json_str, |
| file_name="parsed_responses.json", |
| mime="application/json", |
| key="dl_json_restore", |
| ) |
| if st.button("Convert to DataFrame", key="btn_convert_json"): |
| convert_cached_data_to_df(st.session_state['parsed_responses']) |
|
|
| if st.session_state.get('parsed_responses_df') is not None: |
| st.download_button( |
| label="Save CSV", |
| data=convert_df(st.session_state['parsed_responses_df']), |
| file_name="uap_data.csv", |
| mime="text/csv", |
| key="dl_csv_from_json", |
| ) |
|
|
| st.divider() |
| st.markdown("**Export to SCU Spreadsheet**") |
| xlsx_template_j = st.file_uploader( |
| "Upload template spreadsheet (.xlsx)", |
| type=["xlsx"], |
| key="scu_template_json", |
| help="Upload 'UAP Activities Data 1975 Onwards.xlsx'.", |
| ) |
| start_num_j = st.number_input( |
| "Starting Internal Number (0 = leave blank)", min_value=0, value=0, step=1, |
| key="scu_startnum_json", |
| ) |
| if st.button("Generate SCU xlsx", key="btn_scu_json"): |
| if xlsx_template_j is None: |
| st.warning("Please upload the template .xlsx first.") |
| else: |
| try: |
| import tempfile |
| from uap_xlsx_filler import build_dataframe, append_to_xlsx |
|
|
| _proxy = CachedUAPParser.__new__(CachedUAPParser) |
| _proxy.responses = {} |
| import threading as _t2 |
| _proxy._responses_lock = _t2.Lock() |
|
|
| new_df = build_dataframe(_proxy, st.session_state['parsed_responses']) |
| st.write(f"Built DataFrame: {len(new_df)} rows Γ {len(new_df.columns)} columns") |
|
|
| with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp: |
| tmp.write(xlsx_template_j.read()) |
| out_path = tmp.name |
| append_to_xlsx( |
| new_df, |
| xlsx_path=out_path, |
| output_path=out_path, |
| start_internal_number=int(start_num_j) if start_num_j > 0 else None, |
| ) |
| with open(out_path, "rb") as f: |
| xlsx_bytes = f.read() |
| st.download_button( |
| label="Download SCU xlsx", |
| data=xlsx_bytes, |
| file_name="UAP_Activities_filled.xlsx", |
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", |
| key="dl_scu_from_json", |
| ) |
| st.success(f"{len(new_df)} rows written to spreadsheet.") |
| except Exception as e: |
| st.error(f"xlsx export failed: {e}") |
|
|
| |
| |
| |
| |
| |
| |
| _cmp_df = _current_dataset_columns() |
| if not _cmp_df.empty: |
| with st.expander("π’π΄ Compare schema to dataset (coverage diff)", expanded=False): |
| render_schema_dataset_diff_ui(_cmp_df) |
|
|
| |
| |
| |
| if False: |
| st.markdown( |
| "Upload a batch of `.md` files (e.g. case notes, reports). " |
| "Each file is sent to the LLM and extracted into **FORMAT_MERGED** β " |
| "a combined schema covering both UAP and SCU fields β " |
| "then concatenated into a single DataFrame stored in the session." |
| ) |
|
|
| md_files = st.file_uploader( |
| "Upload .md files", |
| type=["md"], |
| accept_multiple_files=True, |
| key="md_folder_uploader", |
| ) |
|
|
| if md_files: |
| st.caption(f"{len(md_files)} file(s) selected.") |
|
|
| col_md1, col_md2 = st.columns(2) |
| with col_md1: |
| md_provider = st.radio( |
| "Provider", ["OpenAI", "DeepSeek"], horizontal=True, key="md_provider" |
| ) |
| with col_md2: |
| md_model = st.selectbox( |
| "Model", |
| OPENAI_MODELS if md_provider == "OpenAI" else DEEPSEEK_MODELS, |
| index=0, |
| key="md_model", |
| ) |
|
|
| md_api_key = st.text_input( |
| f"{md_provider} API Key", |
| OPENAI_KEY if md_provider == "OpenAI" else DEEPSEEK_KEY, |
| type="password", |
| key="md_api_key_input", |
| ) |
| md_workers = st.slider( |
| "Max parallel workers", 1, 32, 5, key="md_workers", |
| help="Number of concurrent LLM calls. Start low for rate-limited providers.", |
| ) |
|
|
| with st.expander("Preview FORMAT_MERGED schema", expanded=False): |
| st.json(FORMAT_MERGED) |
|
|
| if st.button("Process Markdown Files", key="btn_process_md"): |
| if not md_api_key: |
| st.warning("Please enter an API key.") |
| else: |
| import concurrent.futures as _cf |
|
|
| md_contents: dict[str, str] = {} |
| for _f in md_files: |
| try: |
| md_contents[_f.name] = _f.read().decode("utf-8") |
| except Exception as _e: |
| st.warning(f"Could not read {_f.name}: {_e}") |
|
|
| _md_parser = CachedUAPParser( |
| api_key=md_api_key, |
| model=md_model, |
| use_batch=False, |
| col=[], |
| ) |
|
|
| _merged_fmt = json.dumps(FORMAT_MERGED, indent=2) |
| md_results: dict = {} |
| md_errors: list[str] = [] |
|
|
| with st.status( |
| f"Processing {len(md_contents)} markdown file(s)β¦", expanded=True |
| ) as _md_stat: |
| _progress = st.progress(0) |
| with _cf.ThreadPoolExecutor(max_workers=md_workers) as _executor: |
| _future_to_name = { |
| _executor.submit( |
| _md_parser.fetch_response, content, _merged_fmt |
| ): name |
| for name, content in md_contents.items() |
| } |
| _done = 0 |
| for _future in _cf.as_completed(_future_to_name): |
| _name = _future_to_name[_future] |
| _done += 1 |
| _progress.progress(_done / len(md_contents), text=_name) |
| try: |
| _resp = _future.result() |
| _text = ( |
| _resp.choices[0].message.content if _resp else None |
| ) |
| if _text: |
| _parsed = _extract_json(_text) |
| if _parsed is not None: |
| md_results[_name] = _parsed |
| else: |
| md_errors.append( |
| f"{_name}: JSON extraction failed" |
| ) |
| else: |
| md_errors.append(f"{_name}: empty response") |
| except Exception as _exc: |
| md_errors.append(f"{_name}: {_exc}") |
|
|
| if md_errors: |
| st.warning(f"{len(md_errors)} file(s) had errors.") |
| with st.expander("Error details"): |
| for _err in md_errors: |
| st.code(_err) |
|
|
| if md_results: |
| st.session_state['parsed_responses'] = md_results |
| st.success( |
| f"{len(md_results)} file(s) processed. " |
| "Results stored β use the export controls below." |
| ) |
| _md_stat.update( |
| label="Processing complete", state="complete", expanded=False |
| ) |
|
|
| |
| try: |
| _records = [] |
| for _fname, _parsed in md_results.items(): |
| _flat: dict = {"_filename": _fname} |
| for _group, _fields in _parsed.items(): |
| if isinstance(_fields, dict): |
| for _k, _v in _fields.items(): |
| if isinstance(_v, dict): |
| for _k2, _v2 in _v.items(): |
| _flat[f"{_group}.{_k}.{_k2}"] = _v2 |
| else: |
| _flat[f"{_group}.{_k}"] = _v |
| else: |
| _flat[_group] = _fields |
| _records.append(_flat) |
|
|
| _md_df = pd.DataFrame(_records).fillna("") |
| for _c in _md_df.columns: |
| if _md_df[_c].dtype == "object": |
| _md_df[_c] = _md_df[_c].astype(str) |
| st.dataframe(_md_df) |
| st.session_state['parsed_responses_df'] = _md_df |
| except Exception as _df_err: |
| st.error(f"Could not build preview DataFrame: {_df_err}") |
| else: |
| st.error("No files were processed successfully.") |
|
|
| |
| if _parsed_responses_dict() and md_files: |
| _pr = st.session_state['parsed_responses'] |
| _json_dl = json.dumps(_pr, indent=2) |
| st.download_button( |
| "Download parsed JSON", |
| data=_json_dl, |
| file_name="md_parsed_responses.json", |
| mime="application/json", |
| key="dl_md_json", |
| ) |
| if st.session_state.get('parsed_responses_df') is not None: |
| st.download_button( |
| "Download CSV", |
| data=convert_df(st.session_state['parsed_responses_df']), |
| file_name="md_parsed_data.csv", |
| mime="text/csv", |
| key="dl_md_csv", |
| ) |
|
|
| st.divider() |
|
|
| if unparsed is not None: |
| filtered_data = None |
| try: |
| if unparsed.type == "text/csv": |
| data = pd.read_csv(unparsed) |
| elif unparsed.type == "application/json" or unparsed.name.endswith(".json"): |
| raw = unparsed.read().decode("utf-8").strip() |
| |
| try: |
| parsed_json = json.loads(raw) |
| if isinstance(parsed_json, list): |
| data = pd.json_normalize(parsed_json) |
| else: |
| data = pd.json_normalize([parsed_json]) |
| except json.JSONDecodeError: |
| |
| |
| repaired = None |
| if raw.lstrip().startswith("["): |
| last_brace = raw.rfind("}") |
| if last_brace != -1: |
| candidate = raw[: last_brace + 1].rstrip().rstrip(",") + "\n]" |
| try: |
| repaired = json.loads(candidate) |
| except json.JSONDecodeError: |
| pass |
| if repaired is not None: |
| n_dropped = raw.count('"pages"') - len(repaired) |
| if n_dropped > 0: |
| st.warning( |
| f"JSON was truncated β loaded {len(repaired)} complete records " |
| f"and dropped {n_dropped} incomplete trailing record(s)." |
| ) |
| data = pd.json_normalize(repaired) |
| else: |
| |
| records = [json.loads(line) for line in raw.splitlines() if line.strip()] |
| data = pd.json_normalize(records) |
|
|
| |
| nested_cols = [ |
| c for c in data.columns |
| if data[c].dropna().apply(lambda v: isinstance(v, list)).any() |
| ] |
| if nested_cols: |
| explode_col = st.selectbox( |
| "Nested array detected β explode column into rows:", |
| options=["(keep as-is)"] + nested_cols, |
| index=1 if len(nested_cols) == 1 else 0, |
| key="json_explode_col", |
| help="Select the column whose list-of-objects should become individual rows. " |
| "Parent fields (e.g. source_file) are carried forward on every row.", |
| ) |
| if explode_col != "(keep as-is)": |
| |
| |
| parent_cols = [c for c in data.columns if c != explode_col] |
| exploded = data.explode(explode_col).reset_index(drop=True) |
| nested_df = pd.json_normalize( |
| exploded[explode_col].dropna().tolist() |
| ) |
| nested_df.index = exploded[explode_col].dropna().index |
| |
| |
| |
| |
| |
| |
| overlap = [c for c in nested_df.columns if c in parent_cols] |
| if overlap: |
| nested_df = nested_df.drop(columns=overlap) |
| data = exploded[parent_cols].join(nested_df).reset_index(drop=True) |
| if overlap: |
| st.caption( |
| f"De-duplicated {len(overlap)} field(s) present at both " |
| f"the document and report level: {', '.join(overlap)}." |
| ) |
| st.caption( |
| f"Exploded **{explode_col}** β {len(data):,} rows " |
| f"({', '.join(nested_df.columns.tolist()[:6])}{'β¦' if len(nested_df.columns) > 6 else ''})" |
| ) |
| else: |
| data = pd.read_excel(unparsed) |
| filtered_data = filter_dataframe(data) |
| st.dataframe(filtered_data) |
| except Exception as e: |
| st.error(f"An error occurred while reading the file: {e}") |
|
|
| if filtered_data is None: |
| st.stop() |
|
|
| |
| _FORMAT_MAP = SCHEMA_FORMATS |
|
|
| fmt_choices = st.multiselect( |
| "Output JSON Format (select multiple to merge fields)", |
| list(_FORMAT_MAP.keys()), |
| default=[list(_FORMAT_MAP.keys())[0]], |
| ) |
| if not fmt_choices: |
| st.warning("Select at least one format.") |
| st.stop() |
|
|
| |
| _merged_dict: dict = {} |
| for _lbl in fmt_choices: |
| _merged_dict = _deep_merge(_merged_dict, _fmt_as_dict(_FORMAT_MAP[_lbl])) |
|
|
| active_format = json.dumps(_merged_dict, indent=2) |
|
|
| with st.expander("β Custom / Additional Fields (merged last)", expanded=False): |
| st.caption( |
| "Add or override schema fields. The **field builder** and the **raw " |
| "JSON** stay in sync β edit either side. Use dotted paths " |
| "(e.g. `location.city`) to reach nested levels." |
| ) |
|
|
| if 'custom_fields_dict' not in st.session_state: |
| st.session_state['custom_fields_dict'] = {} |
| _src = st.session_state['custom_fields_dict'] |
|
|
| _col_form, _col_raw = st.columns(2) |
|
|
| |
| |
| with _col_form: |
| st.markdown("**Field builder**") |
| _flat = _flatten_dotted(_src) |
| _rows_df = pd.DataFrame( |
| [{"Field (dotted path)": k, "Description / value": _value_to_text(v)} |
| for k, v in _flat.items()], |
| columns=["Field (dotted path)", "Description / value"], |
| ) |
| _edited = st.data_editor( |
| _rows_df, |
| num_rows="dynamic", |
| use_container_width=True, |
| hide_index=True, |
| ) |
| _form_dict = _rows_to_dict(_edited) |
|
|
| |
| with _col_raw: |
| st.markdown("**Raw JSON**") |
| _raw_text = st.text_area( |
| "JSON object β fields added or overriding the selected schema(s)", |
| value=json.dumps(_src, indent=2) if _src else "{}", |
| height=260, |
| label_visibility="collapsed", |
| ) |
| _raw_ok = True |
| try: |
| _raw_dict = json.loads(_raw_text) if _raw_text.strip() else {} |
| if not isinstance(_raw_dict, dict): |
| _raw_ok = False |
| st.warning("Top-level custom JSON must be an object.") |
| _raw_dict = _src |
| except json.JSONDecodeError as _je: |
| _raw_ok = False |
| st.warning(f"Custom fields JSON is invalid: {_je}") |
| _raw_dict = _src |
|
|
| |
| |
| if _raw_ok and _raw_dict != _src: |
| st.session_state['custom_fields_dict'] = _raw_dict |
| st.rerun() |
| elif _form_dict != _src: |
| st.session_state['custom_fields_dict'] = _form_dict |
| st.rerun() |
|
|
| _custom_extra = st.session_state['custom_fields_dict'] |
| if _custom_extra: |
| _merged_dict = _deep_merge(_merged_dict, _custom_extra) |
| active_format = json.dumps(_merged_dict, indent=2) |
|
|
| |
| |
| |
| |
| _seed = st.session_state.pop("_extract_seed_json", None) |
| if _seed is not None: |
| active_format = _seed |
| st.session_state.pop("preview_json_text", None) |
| _seed_note = st.session_state.pop("_extract_seed_note", "") |
| st.success( |
| f"π₯ Loaded {_seed_note} from the schema diff into the editor." |
| if _seed_note else |
| "π₯ Loaded fields from the schema diff into the editor." |
| ) |
|
|
| st.markdown("### Preview / Edit JSON Format") |
| _tab_tree, _tab_fields, _tab_raw = st.tabs(["π³ Tree", "π Fields", "βοΈ Raw"]) |
|
|
| |
| |
| |
| with _tab_raw: |
| active_format = st.text_area( |
| "JSON Format", active_format, height=500, key="preview_json_text" |
| ) |
| st.download_button("Save Format", active_format) |
|
|
| |
| try: |
| _preview_obj = json.loads(active_format) |
| except json.JSONDecodeError: |
| _preview_obj = None |
|
|
| with _tab_tree: |
| if _preview_obj is None: |
| st.info("Fix the JSON in the **Raw** tab to see the tree view.") |
| else: |
| |
| try: |
| st.json(_preview_obj, expanded=1) |
| except TypeError: |
| st.json(_preview_obj, expanded=False) |
|
|
| with _tab_fields: |
| if not isinstance(_preview_obj, dict): |
| st.info("Fix the JSON in the **Raw** tab to see the field table.") |
| else: |
| _pflat = _flatten_dotted(_preview_obj) |
| _fields_df = pd.DataFrame( |
| [{"Field (dotted path)": k, "Description / type": _value_to_text(v)} |
| for k, v in _pflat.items()], |
| columns=["Field (dotted path)", "Description / type"], |
| ) |
| st.caption(f"{len(_fields_df)} leaf field(s) across the merged schema.") |
| st.dataframe( |
| _fields_df, hide_index=True, use_container_width=True |
| ) |
|
|
| try: |
| _fmt_parsed = json.loads(active_format) |
| _top_keys = set(_fmt_parsed.keys()) if isinstance(_fmt_parsed, dict) else set() |
| |
| |
| _extract_key = 'sightingDetails' if _top_keys == {'sightingDetails'} else None |
| st.session_state['json_format'] = True |
| st.session_state['_json_format_error'] = None |
| except json.JSONDecodeError as e: |
| st.session_state['json_format'] = False |
| st.session_state['_json_format_error'] = str(e) |
|
|
| |
| _lines = active_format.splitlines() |
| _el = e.lineno - 1 |
| _ec = e.colno |
| _ctx_start = max(0, _el - 4) |
| _ctx_end = min(len(_lines), _el + 5) |
| _ctx = [] |
| for _i in range(_ctx_start, _ctx_end): |
| _pfx = f"{_i + 1:5d} | " |
| if _i == _el: |
| _ctx.append(f">>> {_pfx}{_lines[_i]}") |
| _ctx.append(f" {' ' * len(_pfx)}{'-' * max(1, _ec - 1)}^ {e.msg}") |
| else: |
| _ctx.append(f" {_pfx}{_lines[_i]}") |
|
|
| st.error( |
| f"**Invalid JSON** β line {e.lineno}, column {e.colno}: `{e.msg}`\n\n" |
| f"Fix it in the **Preview / Edit** expander above, then re-run.\n\n" |
| f"```\n{chr(10).join(_ctx)}\n```" |
| ) |
| st.stop() |
|
|
| |
| st.divider() |
| col_prov, col_model = st.columns(2) |
| with col_prov: |
| provider = st.radio("Provider", ["OpenAI", "DeepSeek"], horizontal=True) |
| with col_model: |
| model = st.selectbox("Model", |
| OPENAI_MODELS if provider == "OpenAI" else DEEPSEEK_MODELS, |
| index=0) |
|
|
| if provider == "OpenAI": |
| API_KEY = st.text_input("OpenAI API Key", OPENAI_KEY, type="password") |
| ds_key = None |
| else: |
| API_KEY = None |
| ds_key = st.text_input("DeepSeek API Key", DEEPSEEK_KEY, type="password") |
|
|
| |
| cols_unparsed = st.multiselect( |
| "Select columns containing raw text (concatenated)", |
| data.columns, |
| default=[data.columns[0]], |
| ) |
| if not cols_unparsed: |
| st.warning("Select at least one column.") |
| st.stop() |
|
|
| def _build_text(row): |
| parts = [ |
| str(row[c]).strip() |
| for c in cols_unparsed |
| if pd.notna(row[c]) and str(row[c]).strip() |
| ] |
| return " - ".join(parts) if parts else None |
|
|
| |
| cols_keep = st.multiselect( |
| "Columns to keep from the source dataset", |
| list(data.columns), |
| help="Carried straight through and appended to the parsed output β no " |
| "manual merge afterwards. Rows are aligned on the raw-text input " |
| "(the columns selected above).", |
| ) |
| if cols_keep: |
| _keep_texts = filtered_data.apply(_build_text, axis=1) |
| _keep_src = filtered_data[cols_keep].copy() |
| _keep_src.index = pd.Index(_keep_texts.values) |
| _keep_src = _keep_src[_keep_src.index.notna()] |
| _keep_src = _keep_src[~_keep_src.index.duplicated(keep="first")] |
| st.session_state['source_keep_df'] = _keep_src |
| st.caption( |
| f"{len(cols_keep)} source column(s) will be appended to the parsed output." |
| ) |
| else: |
| st.session_state['source_keep_df'] = None |
|
|
| |
| st.divider() |
| with st.expander("β‘ Execution Mode", expanded=True): |
| exec_mode = st.radio( |
| "Mode", |
| ["π Client parallel (live results)", "π¦ Server Batch β OpenAI only (async, 50% off)"], |
| horizontal=True, |
| ) |
|
|
| if exec_mode.startswith("π"): |
| use_batch = False |
| max_workers = st.slider( |
| "Max parallel workers", |
| min_value=1, max_value=64, value=10, step=1, |
| help=( |
| "Number of concurrent HTTP requests sent to the API at the same time. " |
| "Higher = faster but you may hit the provider's rate limits. " |
| "OpenAI Tier 1 typically supports ~60 RPM for gpt-4o-mini. " |
| "DeepSeek has dynamic limits; start at 5-10 and raise if no 429 errors." |
| ), |
| ) |
| ckpt_path = st.text_input( |
| "Checkpoint file (optional)", |
| value=st.session_state.get("ckpt_path_live", ""), |
| placeholder="e.g. checkpoints/my_run.json", |
| help="If set, completed responses are saved immediately. Resume an interrupted run by pointing to the same file.", |
| key="ckpt_path_live", |
| ) |
| st.caption( |
| "Requests are processed in a thread-pool while this browser tab is open. " |
| "A progress bar is shown live. If you close the tab mid-run, completed " |
| "responses in the checkpoint file are preserved and will be skipped on the next run." |
| ) |
|
|
| else: |
| use_batch = True |
| max_workers = 1 |
|
|
| if provider != "OpenAI": |
| st.warning("Server Batch requires OpenAI. Switch provider or choose Client parallel.") |
| use_batch = False |
|
|
| import uuid as _uuid |
| if "server_batch_ckpt" not in st.session_state: |
| st.session_state["server_batch_ckpt"] = ( |
| f"checkpoints/batch_{_uuid.uuid4().hex[:10]}.json" |
| ) |
|
|
| ckpt_path = st.text_input( |
| "Checkpoint file path", |
| value=st.session_state["server_batch_ckpt"], |
| help="The batch ID is stored here. Save this path β you will need it to fetch results after reconnecting.", |
| key="server_batch_ckpt_input", |
| ) |
| st.session_state["server_batch_ckpt"] = ckpt_path |
|
|
| st.info( |
| "**How server batch works**\n\n" |
| "1. **Submit** β all requests are bundled into a JSONL file, uploaded to " |
| "OpenAI, and a batch job is created. The **batch ID** is written to the " |
| "checkpoint file on this server. Returns immediately β no waiting.\n\n" |
| "2. **Disconnect** β OpenAI processes everything on their side. " |
| "You can close this tab. The job runs for up to 24 h.\n\n" |
| "3. **Reconnect** β open this page, scroll here, paste the same " |
| "checkpoint path, and click **Fetch / Check Results**. " |
| "The app polls OpenAI once and writes any completed responses into " |
| "the checkpoint file.\n\n" |
| "4. **Multiple users** β every user gets a unique checkpoint filename " |
| "(UUID-based). Checkpoint files are independent: user A's results never " |
| "mix with user B's. If you want a colleague to fetch your results, " |
| "share your checkpoint path with them β they enter it and click Fetch." |
| ) |
|
|
| |
| st.divider() |
| st.markdown("**Fetch results from a previous batch**") |
| fetch_ckpt = st.text_input( |
| "Checkpoint path to check", |
| value=ckpt_path, |
| placeholder="checkpoints/batch_abc123def.json", |
| key="fetch_ckpt_input", |
| ) |
| fetch_api_key = st.text_input( |
| "OpenAI API Key (for fetching)", OPENAI_KEY, type="password", |
| key="fetch_api_key", |
| ) |
| if st.button("Fetch / Check Results"): |
| if not fetch_api_key: |
| st.warning("Enter your OpenAI API key to check batch status.") |
| elif not fetch_ckpt: |
| st.warning("Enter the checkpoint path from your original submission.") |
| else: |
| _fetcher = CachedUAPParser( |
| api_key=fetch_api_key, |
| model=model, |
| checkpoint_path=fetch_ckpt, |
| ) |
| result = _fetcher.fetch_batch_results() |
| if result["status"] == "no_batch": |
| st.info(f"No active batch found in `{fetch_ckpt}`.") |
| elif result["status"] == "in_progress": |
| st.warning("Batch still in progress.") |
| for b in result["batches"]: |
| prog = b["completed"] / max(b["total"], 1) |
| st.progress(prog, text=( |
| f"{b['batch_id']}: {b['status']} β " |
| f"{b['completed']}/{b['total']} done, " |
| f"{b['failed']} failed" |
| )) |
| st.caption("Come back later and click Fetch again.") |
| else: |
| st.success( |
| f"Batch complete! {result['n_saved']} new responses collected." |
| ) |
| st.session_state['parsed_responses'] = _fetcher.parse_responses() |
| responses_df = _fetcher.responses_to_df( |
| _extract_key, st.session_state['parsed_responses'] |
| ) |
| responses_df = attach_kept_columns(responses_df) |
| if not responses_df.empty: |
| st.dataframe(responses_df) |
| st.session_state['parsed_responses_df'] = responses_df.copy() |
|
|
| |
| descriptions_preview = filtered_data.apply(_build_text, axis=1).dropna().tolist() |
| if descriptions_preview: |
| try: |
| est = estimate_cost( |
| descriptions_preview, |
| active_format, |
| model=model, |
| use_cache=True, |
| use_batch=use_batch, |
| ) |
| with st.expander("π° Estimated Cost", expanded=True): |
| c1, c2, c3, c4 = st.columns(4) |
| c1.metric("Total", f"${est['total_usd']:.4f}") |
| c2.metric("Queries", f"{est['n_queries']:,}") |
| c3.metric("Input tokens", f"{est['total_input_tokens']:,}") |
| c4.metric("Output tokens", f"{est['output_tokens']:,}") |
| c1b, c2b, c3b, c4b = st.columns(4) |
| c1b.metric("Cached input", f"${est['cost_cached_usd']:.4f}") |
| c2b.metric("Uncached input", f"${est['cost_input_usd']:.4f}") |
| c3b.metric("Output cost", f"${est['cost_output_usd']:.4f}") |
| c4b.metric("Batch discount", f"{est['batch_discount_pct']*100:.0f}%") |
| st.caption( |
| f"Model: **{est['model']}** | Provider: **{est['provider']}** | " |
| f"Avg tokens/query: **{est['avg_desc_tokens']}** | " |
| f"Prefix (cached): **{est['prefix_tokens']}**" |
| ) |
| except Exception as e: |
| st.warning(f"Could not estimate cost: {e}") |
|
|
| |
| parse_label = "Submit Batch" if use_batch else "Parse Dataset" |
| if use_batch: |
| st.info("π¦ **Server Batch mode** β clicking Submit will enqueue the job with OpenAI and return immediately. No results until you fetch later.") |
| else: |
| st.info(f"π **Client parallel mode** β {len(filtered_data.apply(_build_text, axis=1).dropna())} descriptions will be sent concurrently to **{provider} / {model}** while this tab stays open.") |
| if st.button(parse_label) and st.session_state['json_format']: |
| active_api_key = API_KEY if provider == "OpenAI" else ds_key |
| if not active_api_key: |
| st.warning("Please enter your API key to proceed.") |
| st.stop() |
|
|
| selected_column_data = filtered_data.apply(_build_text, axis=1).tolist() |
| st.session_state.result = selected_column_data |
|
|
| with st.status( |
| "Submitting batchβ¦" if use_batch else "Parsingβ¦", expanded=True |
| ) as stat: |
| try: |
| _parser = CachedUAPParser( |
| api_key=active_api_key, |
| model=model, |
| use_batch=False, |
| col=st.session_state.result, |
| checkpoint_path=ckpt_path if ckpt_path else None, |
| ) |
|
|
| if use_batch: |
| |
| saved_ckpt = _parser.submit_batch_only( |
| st.session_state.result, active_format |
| ) |
| st.success( |
| f"Batch submitted to OpenAI. **Checkpoint file:** `{saved_ckpt}`\n\n" |
| "Save this path. Return to this page within 24 h, paste the path " |
| "into **Fetch / Check Results** above, and click the button to " |
| "collect your results." |
| ) |
| stat.update(label="Batch submitted", state="complete", expanded=False) |
|
|
| else: |
| n_desc = len(st.session_state.result) |
| st.write(f"Sending **{n_desc}** descriptions to **{provider} / {model}**β¦") |
| if n_desc == 0: |
| st.error("No descriptions found in the selected column. Check your column selection.") |
| st.stop() |
|
|
| _prog_bar = st.progress(0.0) |
| _prog_text = st.empty() |
|
|
| def _on_progress(n_ok, n_fail, total): |
| done = n_ok + n_fail |
| pct = done / total if total else 1.0 |
| _prog_bar.progress(min(pct, 1.0)) |
| _prog_text.caption( |
| f"β {n_ok} ok Β· β {n_fail} failed Β· " |
| f"{done}/{total} ({pct*100:.1f}%)" |
| ) |
|
|
| _parser.process_descriptions( |
| st.session_state.result, active_format, |
| max_workers=max_workers, |
| progress_callback=_on_progress, |
| ) |
| _prog_bar.progress(1.0) |
| _prog_text.empty() |
|
|
| |
| if _parser.last_errors: |
| st.warning( |
| f"{len(_parser.last_errors)} of {n_desc} API call(s) failed." |
| ) |
| with st.expander("Error details (first 10)"): |
| for err in _parser.last_errors[:10]: |
| st.code(err) |
|
|
| if not _parser.responses: |
| st.error( |
| "No responses were stored. Likely causes: invalid API key, " |
| "wrong model name, or network error. See error details above." |
| ) |
| st.stop() |
|
|
| st.session_state['parsed_responses'] = _parser.parse_responses() |
| responses_df = _parser.responses_to_df( |
| _extract_key, st.session_state['parsed_responses'] |
| ) |
| responses_df = attach_kept_columns(responses_df) |
| if not responses_df.empty: |
| st.dataframe(responses_df) |
| st.session_state['parsed_responses_df'] = responses_df.copy() |
| stat.update(label="Parsing complete", state="complete", expanded=False) |
| else: |
| st.error("Failed to create DataFrame from parsed responses.") |
| except Exception as e: |
| st.error(f"An error occurred: {str(e)}") |
|
|
| |
| if _parsed_responses_dict() is not None: |
| json_str = download_json(st.session_state['parsed_responses']) |
| st.download_button( |
| label="Download Parsed Data as JSON", |
| data=json_str, |
| file_name="parsed_responses.json", |
| mime="application/json", |
| ) |
| if st.button("Convert Cached Data to DataFrame"): |
| convert_cached_data_to_df(st.session_state['parsed_responses']) |
|
|
| if st.session_state['parsed_responses_df'] is not None: |
| st.download_button( |
| label="Save CSV", |
| data=convert_df(st.session_state['parsed_responses_df']), |
| file_name="uap_data.csv", |
| mime="text/csv", |
| ) |
|
|
| |
| if "SCU Spreadsheet" in fmt_choices: |
| st.divider() |
| st.markdown("**Export to SCU Spreadsheet**") |
|
|
| |
| |
| if _parsed_responses_dict() is None: |
| st.info( |
| "No parsed data in session. Upload a previously saved " |
| "`parsed_responses.json` **or** an SCU-format `.csv` to continue." |
| ) |
| col_restore1, col_restore2 = st.columns(2) |
| json_upload = col_restore1.file_uploader( |
| "Load parsed_responses.json", |
| type=["json"], |
| key="scu_json_restore", |
| ) |
| csv_upload = col_restore2.file_uploader( |
| "Load SCU CSV (group.field columns)", |
| type=["csv"], |
| key="scu_csv_restore", |
| ) |
| if json_upload is not None: |
| try: |
| st.session_state['parsed_responses'] = json.loads(json_upload.read()) |
| st.success( |
| f"Loaded {len(st.session_state['parsed_responses'])} records from JSON." |
| ) |
| st.rerun() |
| except Exception as e: |
| st.error(f"Could not load JSON: {e}") |
| if csv_upload is not None: |
| st.session_state['_scu_csv_df'] = pd.read_csv(csv_upload) |
| st.success(f"Loaded CSV: {len(st.session_state['_scu_csv_df'])} rows.") |
|
|
| if _parsed_responses_dict() is not None or '_scu_csv_df' in st.session_state: |
| col_t, col_n = st.columns(2) |
| xlsx_template = col_t.file_uploader( |
| "Upload template spreadsheet (.xlsx)", |
| type=["xlsx"], |
| help="Upload 'UAP Activities Data 1975 Onwards.xlsx'. " |
| "The output will be a filled copy β your original is never modified.", |
| key="scu_template", |
| ) |
| start_num = col_n.number_input( |
| "Starting Internal Number (0 = leave blank)", min_value=0, value=0, step=1 |
| ) |
| if st.button("Generate SCU xlsx"): |
| if xlsx_template is None: |
| st.warning("Please upload the template .xlsx first.") |
| else: |
| try: |
| import tempfile |
| from uap_xlsx_filler import ( |
| build_dataframe, build_dataframe_from_csv, append_to_xlsx |
| ) |
|
|
| |
| if '_scu_csv_df' in st.session_state: |
| new_df = build_dataframe_from_csv(st.session_state['_scu_csv_df']) |
| else: |
| _proxy = CachedUAPParser.__new__(CachedUAPParser) |
| _proxy.responses = {} |
| import threading as _t |
| _proxy._responses_lock = _t.Lock() |
| new_df = build_dataframe(_proxy, st.session_state['parsed_responses']) |
|
|
| st.write(f"Built DataFrame: {len(new_df)} rows Γ {len(new_df.columns)} columns") |
|
|
| with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp: |
| tmp.write(xlsx_template.read()) |
| out_path = tmp.name |
| append_to_xlsx( |
| new_df, |
| xlsx_path=out_path, |
| output_path=out_path, |
| start_internal_number=int(start_num) if start_num > 0 else None, |
| ) |
| with open(out_path, "rb") as f: |
| xlsx_bytes = f.read() |
| st.download_button( |
| label="Download SCU xlsx", |
| data=xlsx_bytes, |
| file_name="UAP_Activities_filled.xlsx", |
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", |
| ) |
| st.success(f"{len(new_df)} rows written to spreadsheet.") |
| except Exception as e: |
| st.error(f"xlsx export failed: {e}") |
|
|
|
|
| |
| |
| |
| |
|
|
| |
| st.divider() |
| with st.expander("𧬠Compute Embeddings β HDF5", expanded=False): |
| st.markdown( |
| "Batch-encode a text column with `microsoft/harrier-oss-v1-0.6b` " |
| "and download the full DataFrame (with an added `embeddings` column) as an `.h5` file." |
| ) |
|
|
| _embed_src_opts = [] |
| if st.session_state.get("parsed_responses_df") is not None: |
| _embed_src_opts.append("Parsed responses DataFrame (current session)") |
| _embed_src_opts.append("Upload CSV / HDF5") |
|
|
| _embed_src = st.radio("Data source", _embed_src_opts, horizontal=True, key="embed_src") |
|
|
| _embed_df = None |
| if _embed_src == "Parsed responses DataFrame (current session)": |
| _embed_df = st.session_state["parsed_responses_df"] |
| else: |
| _embed_upload = st.file_uploader( |
| "Upload CSV or HDF5 (.h5)", type=["csv", "h5"], key="embed_upload" |
| ) |
| if _embed_upload is not None: |
| if _embed_upload.name.endswith(".h5"): |
| _embed_h5_key_in = st.text_input("HDF5 key to read", value="df", key="embed_h5_key_in") |
| import tempfile as _tmp_in_mod |
| with _tmp_in_mod.NamedTemporaryFile(suffix=".h5", delete=False) as _tmp_in: |
| _tmp_in.write(_embed_upload.read()) |
| _embed_tmp_path = _tmp_in.name |
| try: |
| _embed_df = pd.read_hdf(_embed_tmp_path, key=_embed_h5_key_in) |
| except Exception as _e: |
| st.error(f"Could not read HDF5: {_e}") |
| else: |
| _embed_df = pd.read_csv(_embed_upload) |
|
|
| if _embed_df is not None: |
| st.caption(f"{len(_embed_df):,} rows Γ {len(_embed_df.columns)} columns") |
| col_e1, col_e2 = st.columns(2) |
| with col_e1: |
| _embed_col = st.selectbox("Column to embed", _embed_df.columns, key="embed_col") |
| with col_e2: |
| _embed_prompt = st.selectbox( |
| "Prompt type", |
| ["none (document)", "web_search_query"], |
| key="embed_prompt", |
| help="Use 'web_search_query' for short queries; 'none (document)' for passage text.", |
| ) |
| col_e3, col_e4 = st.columns(2) |
| with col_e3: |
| _embed_batch = st.slider("Batch size", 16, 512, 256, step=16, key="embed_batch") |
| with col_e4: |
| _embed_out_key = st.text_input("HDF5 key", value="df", key="embed_out_key") |
| _embed_out_name = st.text_input( |
| "Output filename", value="embeddings_output.h5", key="embed_out_name" |
| ) |
|
|
| if st.button("Compute Embeddings", key="btn_compute_embed"): |
| import tempfile as _tmp_out_mod |
| texts = _embed_df[_embed_col].fillna("").astype(str).tolist() |
| _is_query = _embed_prompt == "web_search_query" |
| with st.status( |
| f"Encoding {len(texts):,} textsβ¦", expanded=True |
| ) as _embed_stat: |
| import numpy as _np |
| from stqdm import stqdm as _stqdm |
| _batches = [texts[i:i + _embed_batch] for i in range(0, len(texts), _embed_batch)] |
| _all_embs = [] |
| for _batch in _stqdm(_batches, desc=f"Encoding ({len(texts):,} texts, batch={_embed_batch})"): |
| with torch.no_grad(): |
| _encode = get_embed_model().encode_query if _is_query else get_embed_model().encode_document |
| _all_embs.append(_encode(_batch, show_progress_bar=False)) |
| _embeddings = _np.vstack(_all_embs) |
| _out_df = _embed_df.copy() |
| _out_df["embeddings"] = _embeddings.tolist() |
| with _tmp_out_mod.NamedTemporaryFile(suffix=".h5", delete=False) as _out_tmp: |
| _out_path = _out_tmp.name |
| _out_df.to_hdf(_out_path, key=_embed_out_key, mode="w") |
| with open(_out_path, "rb") as _fh: |
| _h5_bytes = _fh.read() |
| _embed_stat.update( |
| label=f"Done β {len(texts):,} rows, dim {_embeddings.shape[1]}", |
| state="complete", |
| expanded=False, |
| ) |
| st.success(f"Embedding shape: {_embeddings.shape}") |
| st.download_button( |
| label=f"Download {_embed_out_name}", |
| data=_h5_bytes, |
| file_name=_embed_out_name, |
| mime="application/octet-stream", |
| key="dl_embed_h5", |
| ) |
|
|
|
|
| |
| |
| |
| |
| if st.session_state.get('scu_normalize_enabled'): |
| st.divider() |
| if _parsed_responses_dict(): |
| |
| |
| |
| st.markdown("#### π§Ή SCU Normalization (SCU v2)") |
| st.markdown( |
| "Canonicalises the parsed data β country ISO codes, US-state " |
| "codes, witness roles, craft shape/size bands β and derives the " |
| "**SCU five-criterion eligibility gate** (`scu_eligible` plus the " |
| "per-criterion columns). Turn this off in the app sidebar to hide." |
| ) |
| render_scu_normalization(st.session_state['parsed_responses']) |
| else: |
| st.caption( |
| "π§Ή SCU Normalization is enabled β parse a dataset or load a " |
| "`parsed_responses.json`, and the normalized output will appear here." |
| ) |
|
|
|
|