Spaces:
Sleeping
Sleeping
| # ============================================================================ | |
| # app.py — Four-backend agent teaching demo (Gradio UI shell) | |
| # ============================================================================ | |
| # | |
| # PURPOSE | |
| # ------- | |
| # A chat-driven Gradio app that demonstrates FOUR different backend | |
| # implementations of the same agent task, side by side. This file is the | |
| # UI SHELL ONLY — it owns the chat, the tabs, the data source loaders, | |
| # the training panels, and the download list. It knows nothing about how | |
| # any individual backend works; it dispatches through a 4-symbol contract. | |
| # | |
| # THE FOUR BACKENDS | |
| # ----------------- | |
| # agent_workflow.py — Workflow: 2-step prompt chain, no tools (raw SDK) | |
| # agent_py.py — Simple Python Agent: tool-calling loop (raw SDK) | |
| # agent_langchain.py — LangChain AgentExecutor with tool calling | |
| # agent_langgraph.py — LangGraph state graph with supervisor + task nodes | |
| # | |
| # THE CONTRACT (every backend file exports these four symbols) | |
| # ------------------------------------------------------------ | |
| # BACKEND_NAME — string shown in the UI radio | |
| # get_client(api_key) — returns whatever 'client' the runner needs | |
| # run(client, user_message) — returns {"reply", "steps", "extracted"} | |
| # build_code_snippets(user_message, steps) -> str — for the Code tab | |
| # | |
| # Adding a new backend = new file with these four symbols, then one | |
| # import line in ZONE 2 and a registration into BACKENDS dict. No | |
| # handler, UI, or wiring changes. | |
| # | |
| # GRACEFUL DEGRADATION | |
| # -------------------- | |
| # agent_langchain and agent_langgraph are imported inside try/except. | |
| # If langchain / langchain-mistralai / langgraph are not installed, those | |
| # modes are silently hidden from the radio at startup and a warning prints | |
| # to the console. The app keeps running with Workflow + Simple Python Agent. | |
| # | |
| # CODE ORGANIZATION | |
| # ----------------- | |
| # ZONE 1: Imports & constants | |
| # ZONE 2: Backend imports + helpers (save_json_artifact, build_outputs, ...) | |
| # ZONE 3: Action handlers (wired to UI buttons) | |
| # ZONE 4: UI definition (gr.Blocks) | |
| # ZONE 5: Event wiring (.click handlers — the glue) | |
| # | |
| # LOGICAL FLOW OF ONE CHAT TURN | |
| # ----------------------------- | |
| # User types in chat, clicks Send. | |
| # -> send_btn.click fires process_message(...) | |
| # -> if loaded_context is set, prepend it to user_message | |
| # -> backend = BACKENDS[mode] | |
| # -> client = backend.get_client(api_key) | |
| # -> result = backend.run(client, effective_message) | |
| # -> returns {reply, steps, extracted} | |
| # -> build_outputs() produces table / chart / code / extracted JSON | |
| # -> calls backend.build_code_snippets(...) for the Code tab | |
| # -> save_json_artifact() writes a timestamped run_*.json | |
| # -> returns 8 values matching the chat_outputs list in ZONE 5 | |
| # 1. new chat history -> chatbot | |
| # 2. steps dataframe -> Results > Table | |
| # 3. extracted JSON -> Results > Extracted | |
| # 4. chart dataframe -> Visuals | |
| # 5. code snippet -> Results > Code | |
| # 6. downloads list -> downloads_state | |
| # 7. downloads list (same) -> Downloads tab file list | |
| # 8. empty string -> chat_input (clears it) | |
| # | |
| # DATA SOURCE LOADERS follow a shorter pattern: | |
| # User loads a URL / PDF / spreadsheet / ML examples -> saves JSON artifact, | |
| # appends to downloads, updates loaded_context_state for next chat turn. | |
| # Returns 5 values: preview, status, context, downloads_state, downloads_files. | |
| # | |
| # THE TWO RULES THAT WILL SAVE YOU PAIN | |
| # ------------------------------------- | |
| # 1. Handler return order MUST match its wiring outputs list. | |
| # Function returns N values -> outputs=[c1, c2, ..., cN] must have N items | |
| # in the same order. Mismatch is the #1 source of silent breakage. | |
| # | |
| # 2. All chat handlers (process_message, submit_form, new_chat) share | |
| # the same chat_outputs list. If you change the shape of one, change | |
| # all three at once. | |
| # | |
| # WHERE TO ADD NEW THINGS | |
| # ----------------------- | |
| # New backend -> Create agent_<name>.py with the 4 contract symbols, | |
| # add one import line in ZONE 2, add it to BACKENDS. | |
| # Nothing else changes. | |
| # | |
| # New top-level tab -> ZONE 4 inside outer gr.Tabs() | |
| # + handler in ZONE 3 | |
| # + wiring in ZONE 5 | |
| # | |
| # New sub-tab -> ZONE 4 inside the parent tab's inner gr.Tabs() | |
| # + handler in ZONE 3 following scrape_url pattern | |
| # + wiring in ZONE 5 following scrape_btn pattern | |
| # | |
| # New output display -> ZONE 4 component + expand build_outputs in ZONE 2 | |
| # + add to chat_outputs list | |
| # + update process_message, submit_form, new_chat | |
| # to return one more value in the matching position | |
| # | |
| # New data source -> Same as sub-tab. Always call save_json_artifact() | |
| # and always return the 5-tuple shape. | |
| # | |
| # New agent tool -> Edit tools.py only. Add function to TOOL_FUNCTIONS | |
| # dict and schema to TOOL_SCHEMAS list. The raw-SDK | |
| # backends pick it up automatically. For LangChain | |
| # and LangGraph, also wrap it with @lc_tool in | |
| # agent_langchain.py and (if math/info scoped) add | |
| # to MATH_TOOLS or INFO_TOOLS in agent_langgraph.py. | |
| # | |
| # New field in an -> Find the `artifact = {...}` dict in the relevant | |
| # existing JSON handler in ZONE 3 and add your key. | |
| # | |
| # ============================================================================ | |
| # ============================================================================ | |
| # ZONE 1 — Imports & constants | |
| # ============================================================================ | |
| import os | |
| import json | |
| from datetime import datetime | |
| import gradio as gr | |
| import pandas as pd | |
| import requests | |
| from bs4 import BeautifulSoup | |
| from pypdf import PdfReader | |
| MAX_CONTEXT_CHARS = 5000 | |
| # ============================================================================ | |
| # ZONE 2 — Helpers (pure functions, no UI knowledge) | |
| # ============================================================================ | |
| # These functions take plain Python inputs and return plain Python outputs. | |
| # They know nothing about Gradio. Reusable and testable on their own. | |
| # | |
| # NOTE: the actual LLM orchestration (Workflow and Agent runners, the | |
| # MODES dict, the client, and the code snippet builder) lives in agent.py | |
| # so that it can be swapped for alternative implementations (LangChain, | |
| # LangGraph, etc.) without touching this file. We just import what we need. | |
| # ---------------------------------------------------------------- | |
| # Agent backend — swappable module | |
| # ---------------------------------------------------------------- | |
| # ---------------------------------------------------------------- | |
| # Agent backends — each file is an independent import. | |
| # ALL backend imports are wrapped in try/except so the app boots even | |
| # if one file is broken (missing dep, version conflict, import error). | |
| # Broken backends are silently hidden from the mode radio at startup and | |
| # a warning is printed to the console. At least one backend must load | |
| # or the app will show an empty mode list, but the app itself will run. | |
| # ---------------------------------------------------------------- | |
| BACKENDS = {} | |
| # Ringmaster is listed FIRST so it becomes the default selection | |
| try: | |
| import agent_langgraph_ringmaster | |
| BACKENDS[agent_langgraph_ringmaster.BACKEND_NAME] = agent_langgraph_ringmaster | |
| except Exception as _rm_err: | |
| print(f"[app.py] LangGraph Ringmaster backend unavailable: {_rm_err}") | |
| try: | |
| import agent_workflow | |
| BACKENDS[agent_workflow.BACKEND_NAME] = agent_workflow | |
| except Exception as _wf_err: | |
| print(f"[app.py] Workflow backend unavailable: {_wf_err}") | |
| try: | |
| import agent_py | |
| BACKENDS[agent_py.BACKEND_NAME] = agent_py | |
| except Exception as _py_err: | |
| print(f"[app.py] Simple Python Agent backend unavailable: {_py_err}") | |
| try: | |
| import agent_langchain | |
| BACKENDS[agent_langchain.BACKEND_NAME] = agent_langchain | |
| except Exception as _lc_err: | |
| print(f"[app.py] LangChain backend unavailable: {_lc_err}") | |
| try: | |
| import agent_langgraph | |
| BACKENDS[agent_langgraph.BACKEND_NAME] = agent_langgraph | |
| except Exception as _lg_err: | |
| print(f"[app.py] LangGraph backend unavailable: {_lg_err}") | |
| try: | |
| import agent_smolagents | |
| BACKENDS[agent_smolagents.BACKEND_NAME] = agent_smolagents | |
| except Exception as _sa_err: | |
| print(f"[app.py] smolagents backend unavailable: {_sa_err}") | |
| try: | |
| import agent_crewai | |
| BACKENDS[agent_crewai.BACKEND_NAME] = agent_crewai | |
| except Exception as _crew_err: | |
| print(f"[app.py] CrewAI backend unavailable: {_crew_err}") | |
| try: | |
| import agent_llama_index | |
| BACKENDS[agent_llama_index.BACKEND_NAME] = agent_llama_index | |
| except Exception as _li_err: | |
| print(f"[app.py] LlamaIndex backend unavailable: {_li_err}") | |
| # Fallback so the UI never crashes on an empty BACKENDS dict | |
| if not BACKENDS: | |
| print("[app.py] WARNING: no backends loaded. Check build logs.") | |
| from examples import ML_EXAMPLES | |
| from training_data import TRAINING_EXAMPLES | |
| from training import ( | |
| train_classifier, predict as classifier_predict, | |
| cluster_hierarchical, cluster_report, | |
| ) | |
| try: | |
| import vectorstore | |
| VECTORSTORE_OK = True | |
| except Exception as _vs_err: | |
| print(f"[app.py] vectorstore unavailable: {_vs_err}") | |
| VECTORSTORE_OK = False | |
| import providers | |
| # Workbench packages — each is a self-contained LangGraph supervisor workflow. | |
| # Wrapped so a broken workbench does not kill the whole app on cold boot. | |
| # ============================================================================ | |
| # !!! RULE_VIOLATION_6 — DELIBERATE — see COMPLIANCE.md !!! | |
| # ---------------------------------------------------------------------------- | |
| # Pattern: try/except around module imports + WB_*_OK flags + print fallback. | |
| # Reason: A broken workbench folder (wrong upload, missing __init__, syntax | |
| # slip after an edit) must NOT bring down the entire Space on cold | |
| # boot. Defensive import lets the seven-backend chat, Supervised ML, | |
| # Unsupervised ML, and Vector Processing tabs keep working even if | |
| # one workbench is broken. | |
| # Fix-when: Never. This is the one boundary where graceful degradation is | |
| # worth more than strict compliance. Alternative would be pinning | |
| # every workbench dependency exhaustively — brittle on HF Spaces. | |
| # ============================================================================ | |
| try: | |
| import workbench_grounded_theory as wb_cgt | |
| WB_CGT_OK = True | |
| _wb_cgt_err = None | |
| except Exception as _e: | |
| WB_CGT_OK = False | |
| _wb_cgt_err = str(_e) | |
| print(f"[app.py] workbench_grounded_theory unavailable: {_wb_cgt_err}") | |
| try: | |
| import workbench_thematic_analysis as wb_cta | |
| WB_CTA_OK = True | |
| _wb_cta_err = None | |
| except Exception as _e: | |
| WB_CTA_OK = False | |
| _wb_cta_err = str(_e) | |
| print(f"[app.py] workbench_thematic_analysis unavailable: {_wb_cta_err}") | |
| try: | |
| from workbench_thematic_analysis import phase2_agent | |
| PHASE2_AGENT_OK = True | |
| _phase2_agent_err = None | |
| except Exception as _e: | |
| PHASE2_AGENT_OK = False | |
| _phase2_agent_err = str(_e) | |
| print(f"[app.py] phase2_agent unavailable: {_phase2_agent_err}") | |
| try: | |
| from phase3_themes import run_phase3_searching_themes | |
| PHASE3_OK = True | |
| _phase3_err = None | |
| except Exception as _e: | |
| PHASE3_OK = False | |
| _phase3_err = str(_e) | |
| print(f"[app.py] phase3_themes unavailable: {_phase3_err}") | |
| # ---------------------------------------------------------------- | |
| # Artifact writer — every input/run becomes a timestamped JSON file | |
| # ---------------------------------------------------------------- | |
| def save_json_artifact(data, prefix): | |
| ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] | |
| path = f"{prefix}_{ts}.json" | |
| with open(path, "w") as f: | |
| json.dump(data, f, indent=2, default=str, ensure_ascii=False) | |
| return path | |
| # ---------------------------------------------------------------- | |
| # Build outputs for the Results/Visuals tabs from a run result | |
| # ---------------------------------------------------------------- | |
| def build_outputs(user_message, mode, result): | |
| steps_df = pd.DataFrame(result["steps"]) | |
| extracted_json = json.dumps(result["extracted"], indent=2) | |
| tool_counts = {} | |
| for s in result["steps"]: | |
| tool_counts[s["tool"]] = tool_counts.get(s["tool"], 0) + 1 | |
| if tool_counts: | |
| chart_df = pd.DataFrame( | |
| [{"tool": k, "count": v} for k, v in tool_counts.items()] | |
| ) | |
| else: | |
| chart_df = pd.DataFrame([{"tool": "(none)", "count": 0}]) | |
| # Each backend has its own build_code_snippets — pick the right one. | |
| backend = BACKENDS.get(mode) | |
| if backend is not None: | |
| code_snippet = backend.build_code_snippets(user_message, result["steps"]) | |
| else: | |
| code_snippet = f"# Unknown backend: {mode}" | |
| return steps_df, extracted_json, chart_df, code_snippet | |
| # ============================================================================ | |
| # ZONE 3 — Action handlers (wired to UI buttons in Zone 5) | |
| # ============================================================================ | |
| # These are the functions Gradio calls when a button is clicked or a form | |
| # is submitted. They read state, call Zone 2 helpers, and return values | |
| # that go directly into UI components. | |
| # | |
| # CONVENTIONS: | |
| # - Data source loaders return 5 values: | |
| # (preview, status, loaded_context, downloads_state, downloads_files) | |
| # - Chat handlers (process_message, submit_form, new_chat) return 8 values: | |
| # (chat_history, table_df, extracted_json, chart_df, code_snippet, | |
| # downloads_state, downloads_files, empty_string_to_clear_input) | |
| # - Clear handlers return only the fields they reset. Never touch downloads. | |
| # | |
| # ---------------------------------------------------------------- | |
| # Data source loaders | |
| # Each returns: preview, status, loaded_context, downloads_state, downloads_files | |
| # Each saves a timestamped JSON artifact and appends to the downloads list. | |
| # ---------------------------------------------------------------- | |
| def scrape_url(url, downloads_list): | |
| dl = list(downloads_list or []) | |
| if not url or not url.strip(): | |
| return "", "Nothing loaded.", "", dl, dl | |
| resp = requests.get(url.strip(), timeout=15) | |
| soup = BeautifulSoup(resp.text, "html.parser") | |
| for tag in soup(["script", "style", "noscript"]): | |
| tag.decompose() | |
| text = soup.get_text(separator=" ", strip=True)[:MAX_CONTEXT_CHARS] | |
| status = f"**Loaded:** {url.strip()} — {len(text)} chars" | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "web_scrape", | |
| "url": url.strip(), | |
| "char_count": len(text), | |
| "content": text, | |
| } | |
| path = save_json_artifact(artifact, "scrape") | |
| dl.append(path) | |
| return text, status, text, dl, dl | |
| def extract_pdf(file_obj, downloads_list): | |
| dl = list(downloads_list or []) | |
| if file_obj is None: | |
| return "", "Nothing loaded.", "", dl, dl | |
| reader = PdfReader(file_obj.name) | |
| text = "\n".join((page.extract_text() or "") for page in reader.pages) | |
| text = text[:MAX_CONTEXT_CHARS] | |
| status = f"**Loaded:** PDF with {len(reader.pages)} pages — {len(text)} chars" | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "pdf_upload", | |
| "filename": os.path.basename(file_obj.name), | |
| "page_count": len(reader.pages), | |
| "char_count": len(text), | |
| "content": text, | |
| } | |
| path = save_json_artifact(artifact, "pdf") | |
| dl.append(path) | |
| return text, status, text, dl, dl | |
| def load_spreadsheet(file_obj, downloads_list): | |
| dl = list(downloads_list or []) | |
| if file_obj is None: | |
| return pd.DataFrame(), "Nothing loaded.", "", dl, dl | |
| path_in = file_obj.name | |
| if path_in.lower().endswith(".csv"): | |
| df = pd.read_csv(path_in) | |
| else: | |
| df = pd.read_excel(path_in) | |
| preview_df = df.head(20) | |
| text = df.head(50).to_string()[:MAX_CONTEXT_CHARS] | |
| status = f"**Loaded:** {len(df)} rows x {len(df.columns)} columns" | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "spreadsheet_upload", | |
| "filename": os.path.basename(path_in), | |
| "row_count": int(len(df)), | |
| "column_count": int(len(df.columns)), | |
| "columns": list(df.columns), | |
| "rows": df.head(100).to_dict(orient="records"), | |
| } | |
| path_out = save_json_artifact(artifact, "spreadsheet") | |
| dl.append(path_out) | |
| return preview_df, status, text, dl, dl | |
| def load_ml_examples(downloads_list): | |
| """Load the built-in ML paper catalog as context. No upload needed.""" | |
| dl = list(downloads_list or []) | |
| paper_ids = {e["paper_id"] for e in ML_EXAMPLES} | |
| preview_lines = [ | |
| f"[{e['label']}] {e['sentence'][:90]}{'...' if len(e['sentence']) > 90 else ''}" | |
| f" — {e['paper_title']}, {e['year']}" | |
| for e in ML_EXAMPLES[:8] | |
| ] | |
| preview_lines.append(f"\n... and {max(0, len(ML_EXAMPLES) - 8)} more sentences") | |
| preview = "\n".join(preview_lines) | |
| status = f"**Loaded:** {len(ML_EXAMPLES)} labeled sentences from {len(paper_ids)} ML papers" | |
| context_text = json.dumps(ML_EXAMPLES, indent=2, ensure_ascii=False)[:MAX_CONTEXT_CHARS] | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "ml_examples_catalog", | |
| "sentence_count": len(ML_EXAMPLES), | |
| "paper_count": len(paper_ids), | |
| "examples": ML_EXAMPLES, | |
| } | |
| path = save_json_artifact(artifact, "ml_examples") | |
| dl.append(path) | |
| return preview, status, context_text, dl, dl | |
| # ---------------------------------------------------------------- | |
| # Clear handlers — reset only the source-specific fields | |
| # ---------------------------------------------------------------- | |
| def clear_scrape(): | |
| return "", "", "Nothing loaded.", "" | |
| def clear_pdf(): | |
| return None, "", "Nothing loaded.", "" | |
| def clear_spreadsheet(): | |
| return None, pd.DataFrame(), "Nothing loaded.", "" | |
| def clear_ml_examples(): | |
| return "", "Nothing loaded.", "" | |
| # ---------------------------------------------------------------- | |
| # Training handlers — supervised and unsupervised ML on TRAINING_EXAMPLES | |
| # ---------------------------------------------------------------- | |
| def handle_train(downloads_list): | |
| """Fit a TF-IDF + logistic regression classifier and save the result.""" | |
| dl = list(downloads_list or []) | |
| trained = train_classifier() | |
| # Build a display-friendly confusion matrix dataframe | |
| cm_df = pd.DataFrame( | |
| trained.confusion, | |
| columns=[f"pred:{l}" for l in trained.labels], | |
| ) | |
| cm_df.insert(0, "actual", trained.labels) | |
| status = ( | |
| f"**Accuracy:** {trained.accuracy:.1%} \n" | |
| f"**Train size:** {trained.train_size}, " | |
| f"**Test size:** {trained.test_size}" | |
| ) | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "supervised_training", | |
| "accuracy": trained.accuracy, | |
| "train_size": trained.train_size, | |
| "test_size": trained.test_size, | |
| "labels": trained.labels, | |
| "confusion_matrix": trained.confusion, | |
| } | |
| path = save_json_artifact(artifact, "training") | |
| dl.append(path) | |
| return trained, status, cm_df, dl, dl | |
| def handle_predict(trained, sentence, downloads_list): | |
| """Predict the label of a new sentence using a previously trained model.""" | |
| dl = list(downloads_list or []) | |
| if trained is None: | |
| return "Train the classifier first.", dl, dl | |
| if not sentence or not sentence.strip(): | |
| return "Enter a sentence to predict.", dl, dl | |
| result = classifier_predict(trained, sentence.strip()) | |
| lines = [ | |
| f"**Predicted label:** `{result['predicted_label']}`", | |
| f"**Confidence:** {result['confidence']:.1%}", | |
| "", | |
| "**Class probabilities:**", | |
| ] | |
| for label, prob in sorted(result["probabilities"].items(), key=lambda x: -x[1]): | |
| lines.append(f"- `{label}`: {prob:.1%}") | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "supervised_prediction", | |
| **result, | |
| } | |
| path = save_json_artifact(artifact, "prediction") | |
| dl.append(path) | |
| return "\n".join(lines), dl, dl | |
| def handle_cluster(similarity_threshold, min_cluster_size, n_nearest, | |
| enable_llm_labels, llm_provider, llm_key, downloads_list): | |
| """Parameterized clustering with optional LLM labeling of each cluster. | |
| Uses training.cluster_with_params which returns: | |
| - cluster_ids per sentence (-1 = noise) | |
| - centroids per surviving cluster | |
| - n_nearest representative sentences per cluster | |
| Then (optionally) sends those representatives to an LLM with a | |
| constrained prompt that asks for a short cluster label. | |
| """ | |
| from training import cluster_with_params as _cwp | |
| dl = list(downloads_list or []) | |
| sentences = [e["sentence"] for e in TRAINING_EXAMPLES] | |
| true_labels = [e["label"] for e in TRAINING_EXAMPLES] | |
| result = _cwp( | |
| sentences, | |
| similarity_threshold=float(similarity_threshold), | |
| min_cluster_size=int(min_cluster_size), | |
| n_nearest=int(n_nearest), | |
| ) | |
| cluster_ids = result["cluster_ids"] | |
| representatives = result["representatives"] | |
| distances = result["distances_to_centroid"] | |
| # Build LLM labels if enabled | |
| llm_labels = {} | |
| llm_error = None | |
| if enable_llm_labels and result["n_clusters_found"] > 0: | |
| try: | |
| client = providers.get_llm_client(llm_provider, llm_key) | |
| model_name = providers.get_llm_model(llm_provider) | |
| for cid, reps in representatives.items(): | |
| rep_sentences = [sentences[i] for i, _d in reps] | |
| numbered = "\n".join( | |
| f"{k+1}. {s}" for k, s in enumerate(rep_sentences) | |
| ) | |
| prompt = ( | |
| f"The following {len(rep_sentences)} sentences were grouped " | |
| f"together by a clustering algorithm. Based ONLY on these " | |
| f"sentences, produce a short label (2-5 words) that describes " | |
| f"what they have in common. Output ONLY the label, nothing else.\n\n" | |
| f"{numbered}\n\nLabel:" | |
| ) | |
| resp = client.chat.complete( | |
| model=model_name, | |
| messages=[{"role": "user", "content": prompt}], | |
| temperature=0.2, | |
| max_tokens=40, | |
| ) | |
| label = (resp.choices[0].message.content or "").strip() | |
| # Trim to first line, cap length | |
| label = label.split("\n")[0][:60] | |
| llm_labels[cid] = label | |
| except Exception as e: | |
| llm_error = str(e) | |
| # Build sentence-level dataframe | |
| sent_rows = [] | |
| for idx, sent in enumerate(sentences): | |
| cid = cluster_ids[idx] | |
| rep_idxs = {i for i, _d in representatives.get(cid, [])} | |
| sent_rows.append({ | |
| "idx": idx, | |
| "sentence": sent, | |
| "true_label": true_labels[idx], | |
| "cluster_id": "noise" if cid == -1 else str(cid), | |
| "cluster_label": llm_labels.get(cid, "") if cid != -1 else "", | |
| "is_representative": idx in rep_idxs, | |
| "dist_to_centroid": ( | |
| round(distances[idx], 4) if distances[idx] is not None else None | |
| ), | |
| }) | |
| sent_df = pd.DataFrame(sent_rows) | |
| n_found = result["n_clusters_found"] | |
| n_noise = result["n_noise_points"] | |
| status_parts = [ | |
| f"**Similarity >= {float(similarity_threshold):.2f}**, " | |
| f"**min size = {int(min_cluster_size)}**, " | |
| f"**N nearest = {int(n_nearest)}**", | |
| f"**Found:** {n_found} cluster(s), **Noise:** {n_noise} sentence(s)", | |
| ] | |
| if enable_llm_labels: | |
| if llm_error: | |
| status_parts.append(f"**LLM labeling failed:** {llm_error}") | |
| else: | |
| status_parts.append(f"**LLM labels generated** via {llm_provider}") | |
| status = " \n".join(status_parts) | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "unsupervised_clustering_parameterized", | |
| "algorithm": "Hierarchical Agglomerative", | |
| "similarity_threshold": float(similarity_threshold), | |
| "min_cluster_size": int(min_cluster_size), | |
| "n_nearest": int(n_nearest), | |
| "n_clusters_found": n_found, | |
| "n_noise_points": n_noise, | |
| "llm_provider": llm_provider if enable_llm_labels else None, | |
| "llm_labels": {str(k): v for k, v in llm_labels.items()}, | |
| "sentences": sent_rows, | |
| } | |
| path = save_json_artifact(artifact, "clusters_params") | |
| dl.append(path) | |
| return sent_df, status, dl, dl | |
| # ---------------------------------------------------------------- | |
| # Workbench handlers — Grounded Theory (Nelson 2020) + Thematic Analysis | |
| # ---------------------------------------------------------------- | |
| def handle_wb_cgt(user_message, similarity_threshold, min_cluster_size, | |
| n_nearest, llm_provider, llm_key, loaded_context, downloads_list): | |
| """Run the Computational Grounded Theory supervisor graph. | |
| Three-step framework from Nelson 2020. Round 1: Pattern Detection is | |
| a real LangGraph node, Pattern Refinement and Pattern Confirmation | |
| are placeholders that return 'not yet implemented'. | |
| Sentence source resolution: | |
| 1. If loaded_context (from the Inputs tab) is non-empty, split it | |
| on newlines and use those sentences with true_labels="(unknown)". | |
| 2. Otherwise fall back to the built-in TRAINING_EXAMPLES demo corpus | |
| with its real ground-truth labels. | |
| """ | |
| dl = list(downloads_list or []) | |
| # !!! RULE_VIOLATION_7 — DELIBERATE — see COMPLIANCE.md !!! | |
| if not WB_CGT_OK: | |
| return ( | |
| pd.DataFrame(), | |
| "# Workbench unavailable\n\n" + (_wb_cgt_err or "unknown error"), | |
| pd.DataFrame(), | |
| dl, dl, | |
| ) | |
| # ---- Resolve sentence source ---- | |
| if loaded_context and loaded_context.strip(): | |
| sentences = [s.strip() for s in loaded_context.split("\n") if s.strip()] | |
| true_labels = ["(unknown)"] * len(sentences) | |
| data_source = "uploaded" | |
| else: | |
| from training_data import TRAINING_EXAMPLES | |
| sentences = [e["sentence"] for e in TRAINING_EXAMPLES] | |
| true_labels = [e["label"] for e in TRAINING_EXAMPLES] | |
| data_source = "demo" | |
| result = wb_cgt.run( | |
| user_message=user_message or "Run computational grounded theory on the training data.", | |
| sentences=sentences, | |
| true_labels=true_labels, | |
| data_source=data_source, | |
| similarity_threshold=float(similarity_threshold), | |
| min_cluster_size=int(min_cluster_size), | |
| n_nearest=int(n_nearest), | |
| llm_provider=llm_provider, | |
| llm_key=llm_key, | |
| ) | |
| trace_df = pd.DataFrame(result.get("steps") or []) | |
| reply_md = "## Supervisor reply\n\n" + (result.get("reply") or "(empty)") | |
| reply_md += f"\n\n*Data source: **{data_source}** ({len(sentences)} sentences)*" | |
| det = result.get("detection_result") or {} | |
| sentence_rows = det.get("sentence_rows") or [] | |
| sentences_df = pd.DataFrame(sentence_rows) if sentence_rows else pd.DataFrame() | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "workbench_cgt", | |
| "paper": "Nelson 2020 - Computational Grounded Theory", | |
| "data_source": data_source, | |
| "n_sentences": len(sentences), | |
| "parameters": { | |
| "similarity_threshold": float(similarity_threshold), | |
| "min_cluster_size": int(min_cluster_size), | |
| "n_nearest": int(n_nearest), | |
| "llm_provider": llm_provider, | |
| }, | |
| "reply": result.get("reply"), | |
| "steps": result.get("steps"), | |
| "detection_result": result.get("detection_result"), | |
| "refinement_result": result.get("refinement_result"), | |
| "confirmation_result": result.get("confirmation_result"), | |
| } | |
| path = save_json_artifact(artifact, "workbench_cgt") | |
| dl.append(path) | |
| return trace_df, reply_md, sentences_df, dl, dl | |
| def handle_wb_cta(user_message, max_sentences, llm_provider, llm_key, | |
| loaded_context, downloads_list): | |
| """Run the Computational Thematic Analysis supervisor graph. | |
| Six-phase framework from Braun & Clarke 2006. Round 1: Phase 2 | |
| (Generating Initial Codes) is a real LangGraph node, Phases 1, 3, | |
| 4, 5, 6 are placeholders that return 'not yet implemented'. | |
| Sentence source resolution: same as CGT — loaded_context from Inputs | |
| tab first, fall back to TRAINING_EXAMPLES demo corpus. | |
| """ | |
| dl = list(downloads_list or []) | |
| # !!! RULE_VIOLATION_7 — DELIBERATE — see COMPLIANCE.md !!! | |
| # Same pattern as above: pairs with RULE_VIOLATION_6 on cold-boot | |
| # import failure. | |
| if not WB_CTA_OK: | |
| return ( | |
| pd.DataFrame(), | |
| "# Workbench unavailable\n\n" + (_wb_cta_err or "unknown error"), | |
| pd.DataFrame(), | |
| dl, dl, | |
| ) | |
| # ---- Resolve sentence source ---- | |
| if loaded_context and loaded_context.strip(): | |
| sentences = [s.strip() for s in loaded_context.split("\n") if s.strip()] | |
| true_labels = ["(unknown)"] * len(sentences) | |
| data_source = "uploaded" | |
| else: | |
| from training_data import TRAINING_EXAMPLES | |
| sentences = [e["sentence"] for e in TRAINING_EXAMPLES] | |
| true_labels = [e["label"] for e in TRAINING_EXAMPLES] | |
| data_source = "demo" | |
| result = wb_cta.run( | |
| user_message=user_message or "Run reflexive thematic analysis on the training data.", | |
| sentences=sentences, | |
| true_labels=true_labels, | |
| data_source=data_source, | |
| max_sentences_to_code=int(max_sentences), | |
| llm_provider=llm_provider, | |
| llm_key=llm_key, | |
| ) | |
| trace_df = pd.DataFrame(result.get("steps") or []) | |
| reply_md = "## Supervisor reply\n\n" + (result.get("reply") or "(empty)") | |
| reply_md += f"\n\n*Data source: **{data_source}** ({len(sentences)} sentences)*" | |
| phase2 = result.get("phase2_initial_codes") or {} | |
| coded_rows = phase2.get("coded_rows") or [] | |
| codes_df = pd.DataFrame(coded_rows) if coded_rows else pd.DataFrame() | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "workbench_cta", | |
| "paper": "Braun & Clarke 2006 - Reflexive Thematic Analysis", | |
| "data_source": data_source, | |
| "n_sentences": len(sentences), | |
| "parameters": { | |
| "max_sentences_to_code": int(max_sentences), | |
| "llm_provider": llm_provider, | |
| }, | |
| "reply": result.get("reply"), | |
| "steps": result.get("steps"), | |
| "phase1_familiarization": result.get("phase1_familiarization"), | |
| "phase2_initial_codes": result.get("phase2_initial_codes"), | |
| "phase3_searching_themes": result.get("phase3_searching_themes"), | |
| "phase4_reviewing_themes": result.get("phase4_reviewing_themes"), | |
| "phase5_defining_naming": result.get("phase5_defining_naming"), | |
| "phase6_producing_report": result.get("phase6_producing_report"), | |
| } | |
| path = save_json_artifact(artifact, "workbench_cta") | |
| dl.append(path) | |
| return trace_df, reply_md, codes_df, dl, dl | |
| def clear_training(): | |
| return None, "Not trained yet.", pd.DataFrame(), "" | |
| def clear_clustering(): | |
| return pd.DataFrame(), "Not clustered yet." | |
| def filter_training_dataset(label): | |
| """Filter the training-data dataframe shown in the Supervised Dataset sub-tab.""" | |
| if label == "(all)" or not label: | |
| return pd.DataFrame(TRAINING_EXAMPLES) | |
| return pd.DataFrame([e for e in TRAINING_EXAMPLES if e["label"] == label]) | |
| # ============================================================================ | |
| # Phase 1 Familiarization handlers — Braun & Clarke 2006, Phase 1 | |
| # ============================================================================ | |
| # These handlers drive the Phase 1 — Familiarization sub-tab inside CTA. | |
| # The flow follows Braun & Clarke's active-reading protocol, implemented | |
| # through grounded dialogue partners (Gemini Gems + NotebookLM) plus | |
| # researcher confirmation: | |
| # 1. Load canonical corpus CSV (doc_id, doc_title, section, sub_section, sentence) | |
| # 2. Researcher runs Familiarization Facilitator dialogue in Gemini, | |
| # pastes familiarization notes + transcript + source evidence back | |
| # 3. Researcher runs Reflexive Companion dialogue, pastes reflexive | |
| # challenges + reflexive positioning + immersion coverage back | |
| # 4. Build researcher confirmation table joining corpus with noticings | |
| # 5. Researcher edits the table (confirm/refine/reject each noticing) | |
| # 6. Save to JSON artifact for Downloads tab | |
| # ---------------------------------------------------------------- | |
| P1_REQUIRED_COLUMNS = ["doc_id", "doc_title", "section", "sub_section", "sentence"] | |
| def handle_p1_load_test_csv(downloads_list): | |
| """Load the built-in test_phase1.csv for pipeline verification.""" | |
| dl = list(downloads_list or []) | |
| try: | |
| df = pd.read_csv("test_phase1.csv") | |
| except Exception as e: | |
| return ( | |
| [], | |
| f"Failed to load test_phase1.csv: {e}", | |
| pd.DataFrame(), | |
| dl, dl, | |
| ) | |
| missing = [c for c in P1_REQUIRED_COLUMNS if c not in df.columns] | |
| if missing: | |
| return ( | |
| [], | |
| f"test_phase1.csv is missing required columns: {missing}", | |
| pd.DataFrame(), | |
| dl, dl, | |
| ) | |
| corpus = df[P1_REQUIRED_COLUMNS].to_dict("records") | |
| status = ( | |
| f"**Loaded test_phase1.csv** — {len(corpus)} sentences across " | |
| f"{df['doc_id'].nunique()} documents, " | |
| f"{df['section'].nunique()} unique sections." | |
| ) | |
| return corpus, status, df[P1_REQUIRED_COLUMNS], dl, dl | |
| def handle_p1_upload_csv(file_obj, downloads_list): | |
| """Load a user-uploaded canonical CSV.""" | |
| dl = list(downloads_list or []) | |
| if file_obj is None: | |
| return [], "No file uploaded.", pd.DataFrame(), dl, dl | |
| try: | |
| df = pd.read_csv(file_obj.name) | |
| except Exception as e: | |
| return [], f"Failed to read CSV: {e}", pd.DataFrame(), dl, dl | |
| missing = [c for c in P1_REQUIRED_COLUMNS if c not in df.columns] | |
| if missing: | |
| return ( | |
| [], | |
| f"Uploaded CSV is missing required columns: {missing}. " | |
| f"Canonical schema is: {P1_REQUIRED_COLUMNS}", | |
| pd.DataFrame(), | |
| dl, dl, | |
| ) | |
| corpus = df[P1_REQUIRED_COLUMNS].to_dict("records") | |
| status = ( | |
| f"**Loaded uploaded CSV** — {len(corpus)} sentences across " | |
| f"{df['doc_id'].nunique()} documents." | |
| ) | |
| return corpus, status, df[P1_REQUIRED_COLUMNS], dl, dl | |
| def handle_p1_build_validation_table( | |
| corpus, | |
| facilitator_memo, facilitator_transcript, facilitator_citations, | |
| companion_challenges, companion_reflexivity, companion_breadth, | |
| ): | |
| """Build the researcher confirmation table from corpus + pasted Phase 1 outputs. | |
| Strategy: start with every corpus row (doc_id, doc_title, section, | |
| sub_section, sentence), then append empty initial_noticing / | |
| researcher_confirmation columns. The researcher edits the table inline | |
| to attach initial noticings to specific sentences and mark each one | |
| confirm/refine/reject. | |
| This is the minimum viable version. A future round will parse the | |
| pasted source evidence and auto-populate the initial_noticing column | |
| for sentences that were explicitly quoted during the dialogue. | |
| """ | |
| if not corpus: | |
| empty = pd.DataFrame(columns=[ | |
| "doc_id", "doc_title", "section", "sub_section", "sentence", | |
| "initial_noticing", "reflexive_challenge", | |
| "researcher_confirmation", "refined_noticing", | |
| ]) | |
| return empty | |
| rows = [] | |
| for r in corpus: | |
| rows.append({ | |
| "doc_id": r.get("doc_id", ""), | |
| "doc_title": r.get("doc_title", ""), | |
| "section": r.get("section", ""), | |
| "sub_section": r.get("sub_section", ""), | |
| "sentence": r.get("sentence", ""), | |
| "initial_noticing": "", | |
| "reflexive_challenge": "", | |
| "researcher_confirmation": "", | |
| "refined_noticing": "", | |
| }) | |
| return pd.DataFrame(rows) | |
| def handle_p1_save( | |
| corpus, | |
| facilitator_memo, facilitator_transcript, facilitator_citations, | |
| companion_challenges, companion_reflexivity, companion_breadth, | |
| validation_table, | |
| downloads_list, | |
| ): | |
| """Save all Phase 1 outputs as a timestamped JSON artifact.""" | |
| dl = list(downloads_list or []) | |
| # Convert confirmation dataframe to list-of-dicts for JSON | |
| if isinstance(validation_table, pd.DataFrame): | |
| confirmation_rows = validation_table.fillna("").to_dict("records") | |
| else: | |
| confirmation_rows = [] | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "phase1_familiarization", | |
| "methodology": "Braun & Clarke 2006 Phase 1 — Familiarizing Yourself With Your Data", | |
| "corpus_size": len(corpus or []), | |
| "step1_familiarization_facilitator": { | |
| "familiarization_notes": facilitator_memo or "", | |
| "active_reading_transcript": facilitator_transcript or "", | |
| "source_evidence": facilitator_citations or "", | |
| }, | |
| "step2_reflexive_companion": { | |
| "reflexive_challenges": companion_challenges or "", | |
| "reflexive_positioning": companion_reflexivity or "", | |
| "dataset_immersion_coverage": companion_breadth or "", | |
| }, | |
| "step3_researcher_confirmation_table": confirmation_rows, | |
| } | |
| path = save_json_artifact(artifact, "phase1_familiarization") | |
| dl.append(path) | |
| status = ( | |
| f"**Saved Phase 1 familiarization output** — {len(corpus or [])} corpus sentences, " | |
| f"{len(confirmation_rows)} confirmation rows. " | |
| f"Artifact: `{path.split('/')[-1]}`" | |
| ) | |
| return status, dl, dl | |
| # ============================================================================ | |
| # Phase 2 Initial Coding handlers — Braun & Clarke 2006, Phase 2 | |
| # ============================================================================ | |
| # Round 1: scaffolding + data flow. Round 2 replaces placeholder agent with | |
| # real LangGraph supervisor. Round 3 adds iteration 2/3 + convergence. | |
| # | |
| # The agent architecture (Round 2) will have 7 tools: | |
| # - read_corpus(filter) | |
| # - read_phase1_context() | |
| # - propose_code(sentence, semantic, latent) | |
| # - check_codebook(code_name) | |
| # - add_to_codebook(code_name, definition, example) | |
| # - flag_for_review(sentence, reason) | |
| # - save_iteration(n) | |
| # ---------------------------------------------------------------- | |
| def handle_p2_refresh_corpus( | |
| corpus, | |
| facilitator_memo, companion_reflexivity, validation_table, | |
| ): | |
| """Refresh Phase 2 corpus status + Phase 1 context summary. | |
| Phase 2 reads the corpus loaded in Phase 1 (shared state). It also | |
| surfaces Phase 1's reflexive positioning and confirmed noticings as | |
| context for the agent. | |
| """ | |
| if not corpus: | |
| return ( | |
| "**No corpus loaded.** Go to Phase 1 — Familiarization and load " | |
| "test_phase1.csv (or your own canonical CSV) first.", | |
| "*Phase 1 output will appear here after Save Phase 1.*", | |
| ) | |
| # Count confirmed noticings from Phase 1 validation table | |
| confirmed_count = 0 | |
| if isinstance(validation_table, pd.DataFrame) and not validation_table.empty: | |
| noticings_col = validation_table.get("initial_noticing") | |
| if noticings_col is not None: | |
| confirmed_count = sum( | |
| 1 for v in noticings_col.fillna("").tolist() if str(v).strip() | |
| ) | |
| n_docs = len({r.get("doc_id", "") for r in corpus}) | |
| corpus_status = ( | |
| f"**Corpus ready** — {len(corpus)} sentences across {n_docs} documents. " | |
| f"Inherited from Phase 1 state." | |
| ) | |
| p1_summary_parts = [] | |
| if facilitator_memo and facilitator_memo.strip(): | |
| preview = facilitator_memo.strip()[:300] | |
| p1_summary_parts.append(f"**Familiarization notes:** {preview}...") | |
| if companion_reflexivity and companion_reflexivity.strip(): | |
| preview = companion_reflexivity.strip()[:300] | |
| p1_summary_parts.append(f"**Reflexive positioning:** {preview}...") | |
| p1_summary_parts.append( | |
| f"**Confirmed initial noticings:** {confirmed_count} rows with non-empty `initial_noticing`." | |
| ) | |
| p1_summary = "\n\n".join(p1_summary_parts) if p1_summary_parts else ( | |
| "*Phase 1 output will appear here after Save Phase 1.*" | |
| ) | |
| return corpus_status, p1_summary | |
| def handle_p2_run_iteration( | |
| iteration_n, corpus, | |
| existing_codes_table, existing_codebook_table, | |
| facilitator_memo, companion_reflexivity, validation_table, | |
| llm_provider, llm_key, | |
| orientation, | |
| ): | |
| """Run one Phase 2 coding iteration via the real LangGraph agent. | |
| Strict B&C 2006 Phase 2: | |
| - Multiple codes per segment (1-5) | |
| - Context window (2 before + 2 after) | |
| - Researcher-chosen orientation (semantic OR latent, not both) | |
| - Reflexive positioning injected into every code prompt | |
| - Researcher override is final | |
| """ | |
| # Empty corpus guard | |
| if not corpus: | |
| empty_codes = pd.DataFrame(columns=[ | |
| "doc_id", "doc_title", "section", "sub_section", "sentence", | |
| "ai_code_iter1", "human_code_iter1", | |
| "ai_code_iter2", "human_code_iter2", | |
| "ai_code_iter3", "human_code_iter3", | |
| "final_code", "flagged", | |
| ]) | |
| empty_codebook = pd.DataFrame(columns=[ | |
| "code_name", "definition", "created_by", "provenance", "sentence_count", | |
| ]) | |
| return ( | |
| empty_codes, empty_codebook, | |
| "**Cannot run — no corpus loaded.** Load corpus in Phase 1 first.", | |
| ) | |
| # Agent availability guard | |
| if not PHASE2_AGENT_OK: | |
| empty_codes = pd.DataFrame(columns=[ | |
| "doc_id", "doc_title", "section", "sub_section", "sentence", | |
| "ai_code_iter1", "human_code_iter1", | |
| "ai_code_iter2", "human_code_iter2", | |
| "ai_code_iter3", "human_code_iter3", | |
| "final_code", "flagged", | |
| ]) | |
| empty_codebook = pd.DataFrame(columns=[ | |
| "code_name", "definition", "created_by", "provenance", "sentence_count", | |
| ]) | |
| return ( | |
| empty_codes, empty_codebook, | |
| f"**Phase 2 agent unavailable** — `{_phase2_agent_err}`", | |
| ) | |
| # API key guard | |
| if not llm_key or not str(llm_key).strip(): | |
| empty_codes = pd.DataFrame(columns=[ | |
| "doc_id", "doc_title", "section", "sub_section", "sentence", | |
| "ai_code_iter1", "human_code_iter1", | |
| "ai_code_iter2", "human_code_iter2", | |
| "ai_code_iter3", "human_code_iter3", | |
| "final_code", "flagged", | |
| ]) | |
| empty_codebook = pd.DataFrame(columns=[ | |
| "code_name", "definition", "created_by", "provenance", "sentence_count", | |
| ]) | |
| return ( | |
| empty_codes, empty_codebook, | |
| "**Cannot run — Mistral API key is missing.** Paste it in the sidebar first.", | |
| ) | |
| # Initialize the codes table (carry forward if it exists) | |
| if isinstance(existing_codes_table, pd.DataFrame) and not existing_codes_table.empty: | |
| codes_df = existing_codes_table.copy() | |
| else: | |
| rows = [] | |
| for r in corpus: | |
| rows.append({ | |
| "doc_id": r.get("doc_id", ""), | |
| "doc_title": r.get("doc_title", ""), | |
| "section": r.get("section", ""), | |
| "sub_section": r.get("sub_section", ""), | |
| "sentence": r.get("sentence", ""), | |
| "ai_code_iter1": "", | |
| "human_code_iter1": "", | |
| "ai_code_iter2": "", | |
| "human_code_iter2": "", | |
| "ai_code_iter3": "", | |
| "human_code_iter3": "", | |
| "final_code": "", | |
| "flagged": "", | |
| }) | |
| codes_df = pd.DataFrame(rows) | |
| # Initialize codebook | |
| if isinstance(existing_codebook_table, pd.DataFrame) and not existing_codebook_table.empty: | |
| codebook_list = existing_codebook_table.fillna("").to_dict("records") | |
| else: | |
| codebook_list = [] | |
| # Build confirmed_noticings list from Phase 1 validation table | |
| confirmed_noticings = [] | |
| if isinstance(validation_table, pd.DataFrame) and not validation_table.empty: | |
| noticing_col = validation_table.get("initial_noticing") | |
| if noticing_col is not None: | |
| confirmed_noticings = [ | |
| str(v).strip() for v in noticing_col.fillna("").tolist() | |
| if str(v).strip() | |
| ] | |
| # Build agent context | |
| agent_context = { | |
| "corpus": corpus, | |
| "phase1": { | |
| "reflexive_positioning": companion_reflexivity or "", | |
| "familiarization_notes": facilitator_memo or "", | |
| "confirmed_noticings": confirmed_noticings, | |
| }, | |
| "orientation": orientation or "semantic", | |
| "existing_codes_df": codes_df if iteration_n >= 2 else None, | |
| "codebook": codebook_list, | |
| "proposed_codes": {}, | |
| } | |
| # Run the agent | |
| try: | |
| steps, reply, result_context = phase2_agent.run_phase2_iteration( | |
| llm_provider=llm_provider, | |
| llm_key=llm_key, | |
| iteration_n=int(iteration_n), | |
| context=agent_context, | |
| ) | |
| except Exception as e: | |
| return ( | |
| codes_df, | |
| pd.DataFrame(codebook_list) if codebook_list else pd.DataFrame(columns=[ | |
| "code_name", "definition", "created_by", "provenance", "sentence_count", | |
| ]), | |
| f"**Phase 2 agent error:** {e}", | |
| ) | |
| # Merge agent results into codes_df | |
| # New shape: each proposed entry has "codes": [list of 1-5 strings] | |
| proposed = result_context.get("proposed_codes", {}) | |
| ai_col = f"ai_code_iter{int(iteration_n)}" | |
| for idx, code_dict in proposed.items(): | |
| if 0 <= int(idx) < len(codes_df): | |
| codes_list = code_dict.get("codes", []) or [] | |
| if isinstance(codes_list, str): | |
| codes_list = [codes_list] | |
| combined = ", ".join(c for c in codes_list if c) | |
| codes_df.at[int(idx), ai_col] = combined | |
| # Update final_code column — latest human edit wins, else latest AI code | |
| for i in range(len(codes_df)): | |
| final = "" | |
| for it in (3, 2, 1): | |
| h = codes_df.at[i, f"human_code_iter{it}"] | |
| if h and str(h).strip(): | |
| final = str(h).strip() | |
| break | |
| if not final: | |
| for it in (3, 2, 1): | |
| a = codes_df.at[i, f"ai_code_iter{it}"] | |
| if a and str(a).strip(): | |
| final = str(a).strip() | |
| break | |
| codes_df.at[i, "final_code"] = final | |
| # Build codebook DataFrame | |
| updated_codebook = result_context.get("codebook", []) | |
| codebook_df = pd.DataFrame(updated_codebook) if updated_codebook else pd.DataFrame( | |
| columns=["code_name", "definition", "created_by", "provenance", "sentence_count"] | |
| ) | |
| total_codes = sum(len(v.get("codes", [])) for v in proposed.values()) | |
| status = ( | |
| f"**Iteration {iteration_n} complete** ({orientation} orientation). " | |
| f"Coded {len(proposed)} sentences with {total_codes} total codes " | |
| f"(avg {total_codes/len(proposed) if proposed else 0:.1f} codes/sentence). " | |
| f"Codebook has {len(updated_codebook)} entries. " | |
| f"Agent took {len(steps)} steps. " | |
| f"Reply: {reply[:200]}" | |
| ) | |
| return codes_df, codebook_df, status | |
| def handle_p2_save( | |
| corpus, | |
| codes_table, codebook_table, | |
| downloads_list, | |
| ): | |
| """Save Phase 2 outputs as a timestamped JSON artifact.""" | |
| dl = list(downloads_list or []) | |
| if isinstance(codes_table, pd.DataFrame): | |
| codes_rows = codes_table.fillna("").to_dict("records") | |
| else: | |
| codes_rows = [] | |
| if isinstance(codebook_table, pd.DataFrame): | |
| codebook_rows = codebook_table.fillna("").to_dict("records") | |
| else: | |
| codebook_rows = [] | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "phase2_initial_coding", | |
| "methodology": "Braun & Clarke 2006 Phase 2 — Generating Initial Codes (agentic)", | |
| "corpus_size": len(corpus or []), | |
| "codes_table": codes_rows, | |
| "codebook": codebook_rows, | |
| } | |
| path = save_json_artifact(artifact, "phase2_initial_coding") | |
| dl.append(path) | |
| status = ( | |
| f"**Saved Phase 2 initial coding output** — {len(codes_rows)} coded rows, " | |
| f"{len(codebook_rows)} codebook entries. Artifact: `{path.split('/')[-1]}`" | |
| ) | |
| return status, dl, dl | |
| # ---------------------------------------------------------------- | |
| # Phase 3 -- Searching for Themes handlers (Braun & Clarke 2006) | |
| # ---------------------------------------------------------------- | |
| def handle_p3_run( | |
| codebook_table, | |
| similarity_threshold, | |
| min_cluster_size, | |
| orientation, | |
| companion_reflexivity, | |
| llm_provider, llm_key, | |
| downloads_list, | |
| ): | |
| dl = list(downloads_list or []) | |
| empty_themes = pd.DataFrame(columns=[ | |
| "theme_id", "candidate_theme_name", "description", "rationale", | |
| "member_codes", "code_count", "researcher_theme_name", "researcher_notes", | |
| ]) | |
| empty_noise = pd.DataFrame(columns=["code_name", "definition"]) | |
| if not PHASE3_OK: | |
| return (empty_themes, empty_noise, | |
| f"**Phase 3 unavailable** -- {_phase3_err}", dl, dl) | |
| if codebook_table is None or (isinstance(codebook_table, pd.DataFrame) and codebook_table.empty): | |
| return (empty_themes, empty_noise, | |
| "**Cannot run Phase 3** -- no codebook. Run Phase 2 first.", dl, dl) | |
| key = (llm_key or "").strip() or os.environ.get("MISTRAL_API_KEY", "") | |
| if not key: | |
| return (empty_themes, empty_noise, | |
| "**Cannot run Phase 3** -- Mistral API key missing.", dl, dl) | |
| codebook_df = codebook_table.copy() if isinstance(codebook_table, pd.DataFrame) else pd.DataFrame(codebook_table) | |
| try: | |
| result = run_phase3_searching_themes( | |
| codebook_df=codebook_df, | |
| llm_provider=llm_provider or "Mistral", | |
| llm_key=key, | |
| similarity_threshold=float(similarity_threshold), | |
| min_cluster_size=int(min_cluster_size), | |
| orientation=orientation or "semantic", | |
| reflexive_pos=companion_reflexivity or "", | |
| ) | |
| except Exception as e: | |
| return (empty_themes, empty_noise, f"**Phase 3 error:** {e}", dl, dl) | |
| themes_df = pd.DataFrame(result["themes_rows"]) if result["themes_rows"] else empty_themes | |
| noise_df = pd.DataFrame(result["noise_codes"]) if result["noise_codes"] else empty_noise | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "phase3_searching_themes", | |
| "methodology": "Braun & Clarke 2006 Phase 3 -- Searching for Themes", | |
| "similarity_threshold": float(similarity_threshold), | |
| "min_cluster_size": int(min_cluster_size), | |
| "orientation": orientation, | |
| "n_themes": result["n_themes"], | |
| "n_noise": result["n_noise"], | |
| "themes": result["themes_rows"], | |
| "noise_codes": result["noise_codes"], | |
| } | |
| path = save_json_artifact(artifact, "phase3_searching_themes") | |
| dl.append(path) | |
| status = ( | |
| "**Phase 3 complete.** " | |
| + str(result["n_themes"]) + " candidate themes from " | |
| + str(len(codebook_df)) + " codes. " | |
| + str(result["n_noise"]) + " codes in noise bucket. " | |
| + "Artifact: `" + path.split("/")[-1] + "`" | |
| ) | |
| return themes_df, noise_df, status, dl, dl | |
| def handle_p3_save(themes_table, noise_table, downloads_list): | |
| dl = list(downloads_list or []) | |
| themes_rows = themes_table.fillna("").to_dict("records") if isinstance(themes_table, pd.DataFrame) else [] | |
| noise_rows = noise_table.fillna("").to_dict("records") if isinstance(noise_table, pd.DataFrame) else [] | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "phase3_researcher_confirmed_themes", | |
| "methodology": "Braun & Clarke 2006 Phase 3 -- Researcher-confirmed candidate themes", | |
| "themes": themes_rows, | |
| "noise_codes": noise_rows, | |
| } | |
| path = save_json_artifact(artifact, "phase3_themes") | |
| dl.append(path) | |
| status = ( | |
| "**Saved Phase 3 themes** -- " | |
| + str(len(themes_rows)) + " themes, " | |
| + str(len(noise_rows)) + " noise codes. Artifact: `" + path.split("/")[-1] + "`" | |
| ) | |
| return status, dl, dl | |
| # ---------------------------------------------------------------- | |
| # Vectorstore handlers — Vectorize + Vector DB sub-tabs | |
| # ---------------------------------------------------------------- | |
| def handle_vectorize_preview(embedding_provider, embedding_key, downloads_list): | |
| """Compute embeddings for the first 10 training sentences and show them.""" | |
| dl = list(downloads_list or []) | |
| if not VECTORSTORE_OK: | |
| return pd.DataFrame(), "vectorstore unavailable — check build logs", dl, dl | |
| try: | |
| rows = vectorstore.preview_vectors( | |
| n=10, | |
| embedding_provider=embedding_provider, | |
| embedding_api_key=embedding_key, | |
| ) | |
| except Exception as e: | |
| return ( | |
| pd.DataFrame(), | |
| f"Embedding failed on provider `{embedding_provider}`: {e}", | |
| dl, dl, | |
| ) | |
| df = pd.DataFrame(rows) | |
| status = ( | |
| f"**Embedding provider:** `{embedding_provider}` \n" | |
| f"**Vector dim:** {rows[0]['vector_dim'] if rows else '?'} \n" | |
| f"Showing first 10 sentences with the first 8 of the vector dimensions." | |
| ) | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "vectorize_preview", | |
| "embedding_provider": embedding_provider, | |
| "preview_rows": rows, | |
| } | |
| path = save_json_artifact(artifact, "vectors_preview") | |
| dl.append(path) | |
| return df, status, dl, dl | |
| def handle_vector_index(embedding_provider, embedding_key, downloads_list): | |
| """Embed all 100 sentences and write them to ChromaDB.""" | |
| dl = list(downloads_list or []) | |
| if not VECTORSTORE_OK: | |
| return "vectorstore unavailable — check build logs", dl, dl | |
| try: | |
| result = vectorstore.index_training_data( | |
| embedding_provider=embedding_provider, | |
| embedding_api_key=embedding_key, | |
| ) | |
| except Exception as e: | |
| return ( | |
| f"Indexing failed on provider `{embedding_provider}`: {e}", | |
| dl, dl, | |
| ) | |
| status = ( | |
| f"**Indexed {result['indexed']} sentences** into ChromaDB collection " | |
| f"`{result['collection_name']}`. \n" | |
| f"**Vector dim:** {result['vector_dim']} \n" | |
| f"**Embedding provider:** `{result['embedding_provider']}` \n" | |
| f"**Embedding model:** `{result['embedding_model']}` \n" | |
| f"**Persist dir:** `{result['persist_dir']}`" | |
| ) | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "vector_index", | |
| **result, | |
| } | |
| path = save_json_artifact(artifact, "vector_index") | |
| dl.append(path) | |
| return status, dl, dl | |
| def handle_vector_search(query, n_results, | |
| embedding_provider, embedding_key, downloads_list): | |
| """Semantic search — embed query and retrieve top-N nearest sentences.""" | |
| dl = list(downloads_list or []) | |
| if not VECTORSTORE_OK: | |
| return pd.DataFrame(), "vectorstore unavailable — check build logs", dl, dl | |
| if not query or not query.strip(): | |
| return pd.DataFrame(), "Enter a query to search.", dl, dl | |
| try: | |
| hits = vectorstore.search( | |
| query.strip(), | |
| n_results=int(n_results), | |
| embedding_provider=embedding_provider, | |
| embedding_api_key=embedding_key, | |
| ) | |
| except Exception as e: | |
| return ( | |
| pd.DataFrame(), | |
| f"Search failed on provider `{embedding_provider}`: {e}", | |
| dl, dl, | |
| ) | |
| if not hits: | |
| return ( | |
| pd.DataFrame(), | |
| "No results. Have you indexed the collection yet? " | |
| "Click 'Index all 100 sentences' in the Vector DB tab first. " | |
| "Note: indexing and searching must use the SAME embedding provider " | |
| "because vector dimensions differ between providers.", | |
| dl, dl, | |
| ) | |
| df = pd.DataFrame([ | |
| { | |
| "rank": i + 1, | |
| "similarity": round(h["similarity"], 4), | |
| "label": h["label"], | |
| "sentence": h["sentence"], | |
| } | |
| for i, h in enumerate(hits) | |
| ]) | |
| status = f"**Query:** `{query}` — found {len(hits)} nearest neighbors" | |
| artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "vector_search", | |
| "query": query, | |
| "n_results": int(n_results), | |
| "embedding_provider": embedding_provider, | |
| "hits": hits, | |
| } | |
| path = save_json_artifact(artifact, "vector_search") | |
| dl.append(path) | |
| return df, status, dl, dl | |
| def handle_vector_clear(downloads_list): | |
| """Drop all rows from the Chroma collection.""" | |
| dl = list(downloads_list or []) | |
| if not VECTORSTORE_OK: | |
| return "vectorstore unavailable", dl, dl | |
| result = vectorstore.clear_collection() | |
| stats = vectorstore.collection_stats() | |
| status = f"**Cleared {result['cleared']} vectors.** Collection now has {stats['count']} rows." | |
| return status, dl, dl | |
| def clear_vectorize_preview(): | |
| return pd.DataFrame(), "Click 'Preview embeddings' to see sentence vectors." | |
| # ---------------------------------------------------------------- | |
| # Main chat handler | |
| # ---------------------------------------------------------------- | |
| # Only the two raw-SDK backends (Workflow, Simple Python Agent) respect | |
| # the chosen LLM provider. Framework backends are pinned to Mistral | |
| # because each framework wires its LLM differently and swapping them | |
| # per-provider is a larger rewrite. | |
| PROVIDER_AWARE_BACKENDS = {"Workflow", "Simple Python Agent"} | |
| def process_message(user_message, mode, llm_provider, llm_key, | |
| chat_history, loaded_context, downloads_list): | |
| dl = list(downloads_list or []) | |
| if not user_message or not user_message.strip(): | |
| return chat_history, pd.DataFrame(), "", pd.DataFrame(), "", dl, dl, "" | |
| backend = BACKENDS.get(mode) | |
| if backend is None: | |
| return chat_history, pd.DataFrame(), "", pd.DataFrame(), \ | |
| f"# Unknown backend: {mode}", dl, dl, "" | |
| # Framework backends always use Mistral; raw-SDK backends use chosen provider | |
| effective_provider = llm_provider if mode in PROVIDER_AWARE_BACKENDS else "Mistral" | |
| try: | |
| if mode in PROVIDER_AWARE_BACKENDS: | |
| client = backend.get_client(llm_key, provider=effective_provider) | |
| else: | |
| client = backend.get_client(llm_key) | |
| except Exception as e: | |
| err = f"# Could not create client for {effective_provider}: {e}" | |
| return chat_history, pd.DataFrame(), "", pd.DataFrame(), err, dl, dl, "" | |
| # ---------------------------------------------------------------- | |
| # Dispatch: ringmaster-aware backend vs legacy backend | |
| # ---------------------------------------------------------------- | |
| is_ringmaster = hasattr(backend, "run_ringmaster") | |
| if is_ringmaster: | |
| # Ringmaster receives the raw user message plus a context dict | |
| # holding session state. The supervisor calls check_data_status | |
| # as its first tool, so we must NOT prefix the message with the | |
| # loaded data the way legacy backends do. | |
| ringmaster_context = { | |
| "loaded_context": loaded_context or "", | |
| "llm_provider": effective_provider, | |
| "llm_key": llm_key or "", | |
| "cgt_result": None, | |
| "cta_result": None, | |
| } | |
| try: | |
| result = backend.run_ringmaster(client, user_message, ringmaster_context) | |
| except Exception as e: | |
| err_reply = f"(error from {mode} / {effective_provider}: {e})" | |
| new_history = (chat_history or []) + [ | |
| {"role": "user", "content": user_message}, | |
| {"role": "assistant", "content": err_reply}, | |
| ] | |
| return new_history, pd.DataFrame(), "", pd.DataFrame(), "", dl, dl, "" | |
| else: | |
| # Legacy path: prefix loaded_context into the message text, call | |
| # backend.run(client, message) or backend.run(client, message, provider=...) | |
| if loaded_context: | |
| effective_message = ( | |
| f"Available data:\n{loaded_context[:MAX_CONTEXT_CHARS]}\n\n" | |
| f"User question: {user_message}" | |
| ) | |
| else: | |
| effective_message = user_message | |
| try: | |
| if mode in PROVIDER_AWARE_BACKENDS: | |
| result = backend.run(client, effective_message, provider=effective_provider) | |
| else: | |
| result = backend.run(client, effective_message) | |
| except Exception as e: | |
| err_reply = f"(error from {mode} / {effective_provider}: {e})" | |
| new_history = (chat_history or []) + [ | |
| {"role": "user", "content": user_message}, | |
| {"role": "assistant", "content": err_reply}, | |
| ] | |
| return new_history, pd.DataFrame(), "", pd.DataFrame(), "", dl, dl, "" | |
| new_history = (chat_history or []) + [ | |
| {"role": "user", "content": user_message}, | |
| {"role": "assistant", "content": result["reply"]}, | |
| ] | |
| steps_df, extracted_json, chart_df, code_snippet = build_outputs( | |
| user_message, mode, result | |
| ) | |
| # For the artifact log, record what was actually sent to the backend. | |
| # Ringmaster receives the raw user_message; legacy backends may receive | |
| # the prefixed effective_message. | |
| logged_effective = effective_message if not is_ringmaster else user_message | |
| run_artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": f"chat_run_{mode.lower()}", | |
| "mode": mode, | |
| "llm_provider": effective_provider, | |
| "user_message": user_message, | |
| "effective_message": logged_effective, | |
| "reply": result["reply"], | |
| "steps": result["steps"], | |
| "extracted": result["extracted"], | |
| } | |
| run_path = save_json_artifact(run_artifact, f"run_{mode.lower()}") | |
| dl.append(run_path) | |
| return ( | |
| new_history, steps_df, extracted_json, chart_df, code_snippet, | |
| dl, dl, "", | |
| ) | |
| # ---------------------------------------------------------------- | |
| # Form submission — saves a form JSON, then routes through process_message | |
| # ---------------------------------------------------------------- | |
| def submit_form(task_type, operation, num_a, num_b, city, notes, | |
| mode, llm_provider, llm_key, chat_history, loaded_context, downloads_list): | |
| dl = list(downloads_list or []) | |
| form_artifact = { | |
| "timestamp": datetime.now().isoformat(), | |
| "source_type": "form_submission", | |
| "task_type": task_type, | |
| "operation": operation, | |
| "number_a": num_a, | |
| "number_b": num_b, | |
| "city": city, | |
| "notes": notes, | |
| } | |
| form_path = save_json_artifact(form_artifact, "form") | |
| dl.append(form_path) | |
| builders = { | |
| "Math": lambda: f"Calculate {num_a} {operation.lower()} {num_b}", | |
| "Weather": lambda: f"What is the weather in {city}?", | |
| "General": lambda: notes or "Hello", | |
| } | |
| user_message = builders[task_type]() | |
| return process_message(user_message, mode, llm_provider, llm_key, | |
| chat_history, loaded_context, dl) | |
| def clear_form(): | |
| return "Math", "Add", 0, 0, "", "" | |
| def new_chat(downloads_list): | |
| dl = list(downloads_list or []) | |
| return [], pd.DataFrame(), "", pd.DataFrame(), "", dl, dl, "" | |
| # ============================================================================ | |
| # ZONE 4 — UI definition (gr.Blocks) | |
| # ============================================================================ | |
| # Layout tree: | |
| # Row | |
| # +-- Column (sidebar): settings, mode, new chat, tab guide | |
| # +-- Column (main): | |
| # +-- Chatbot (display) | |
| # +-- Row: chat_input + send_btn | |
| # +-- Tabs (top-level) | |
| # +-- Data sources (Tab) | |
| # | +-- Tabs (inner) | |
| # | +-- Web scraping | |
| # | +-- PDF upload | |
| # | +-- CSV / Excel upload | |
| # +-- Form (Tab) | |
| # +-- Results (Tab) | |
| # | +-- Tabs (inner) | |
| # | +-- Table | |
| # | +-- Code | |
| # | +-- Extracted | |
| # +-- Visuals (Tab) | |
| # +-- Downloads (Tab) | |
| # | |
| # TWO gr.State OBJECTS persist values across clicks: | |
| # loaded_context_state -> text from the last loaded data source | |
| # downloads_state -> list of file paths, grows as artifacts are created | |
| # ---------------------------------------------------------------- | |
| # UI | |
| # ---------------------------------------------------------------- | |
| with gr.Blocks(theme=gr.themes.Soft(primary_hue="orange"), title="Agentic AI Tutorial") as demo: | |
| gr.Markdown("# Agentic AI Tutorial — Seven Backends, One Chat") | |
| gr.Markdown( | |
| "A hands-on comparison of seven ways to build the same agent: " | |
| "**Workflow**, **Simple Python Agent** (raw Mistral SDK), " | |
| "**LangChain**, **LangGraph** (supervisor pattern), " | |
| "**smolagents** (code-writing), **CrewAI** (multi-agent), " | |
| "and **LlamaIndex**. Same Mistral LLM, same tools, different orchestration. " | |
| "Every input and every run is saved as a timestamped JSON file in the Downloads tab." | |
| ) | |
| loaded_context_state = gr.State("") | |
| downloads_state = gr.State([]) | |
| trained_state = gr.State(None) | |
| # Phase 1 Familiarization state — canonical corpus CSV (list of dicts) | |
| p1_corpus_state = gr.State([]) | |
| with gr.Row(): | |
| # ---------------- Sidebar ---------------- | |
| with gr.Column(scale=1, min_width=220): | |
| new_chat_btn = gr.Button("+ New chat", variant="primary") | |
| gr.Markdown("### LLM provider") | |
| gr.Markdown( | |
| "*This release is locked to **Mistral**. Other providers " | |
| "(OpenAI, Anthropic, Gemini, Llama, Qwen, DeepSeek) will " | |
| "be enabled in a future release once the ringmaster workflow " | |
| "is stable.*" | |
| ) | |
| llm_provider_select = gr.Dropdown( | |
| choices=list(providers.LLM_PROVIDERS.keys()), | |
| value="Mistral", | |
| label="LLM provider", | |
| interactive=False, | |
| info="Locked to Mistral for this release.", | |
| ) | |
| llm_key_input = gr.Textbox( | |
| label="LLM API key", | |
| type="password", | |
| placeholder="paste your Mistral API key", | |
| ) | |
| gr.Markdown("### Embedding provider") | |
| gr.Markdown( | |
| "*This release is locked to **MiniLM (local)**. MiniLM is " | |
| "a 384-dim sentence-transformers model that downloads once " | |
| "on first use (~90 MB) and then runs locally with no API " | |
| "key. Other embedding providers will be enabled in a " | |
| "future release.*" | |
| ) | |
| embedding_provider_select = gr.Dropdown( | |
| choices=list(providers.EMBEDDING_PROVIDERS.keys()), | |
| value="MiniLM (local)", | |
| label="Embedding provider", | |
| interactive=False, | |
| info="Locked to MiniLM (local) for this release.", | |
| ) | |
| embedding_key_input = gr.Textbox( | |
| label="Embedding API key", | |
| type="password", | |
| placeholder="not needed for MiniLM (local)", | |
| interactive=False, | |
| ) | |
| gr.Markdown("### Agent backend") | |
| gr.Markdown( | |
| "*This release is locked to **Research Assistant enabled by " | |
| "Vector Embeddings** — the chat-driven coordinator that calls " | |
| "the research workbenches as tools. Other backends (Workflow, " | |
| "Simple Python Agent, LangChain, LangGraph, smolagents, " | |
| "CrewAI, LlamaIndex) will be enabled in a future release.*" | |
| ) | |
| _mode_choices = list(BACKENDS.keys()) or ["(no backends loaded)"] | |
| # Prefer Research Assistant as the default if present | |
| if "Research Assistant enabled by Vector Embeddings" in _mode_choices: | |
| _mode_default = "Research Assistant enabled by Vector Embeddings" | |
| else: | |
| _mode_default = _mode_choices[0] | |
| mode_select = gr.Radio( | |
| choices=_mode_choices, | |
| value=_mode_default, | |
| label="Backend", | |
| interactive=False, | |
| info="Locked to Research Assistant for this release.", | |
| ) | |
| gr.Markdown("### Tab guide") | |
| gr.Markdown( | |
| "**Inputs**\n" | |
| "- Data sources\n" | |
| "- Form\n\n" | |
| "**Processing / Analysis**\n" | |
| "- Supervised Machine Learning\n" | |
| "- Unsupervised Machine Learning\n" | |
| "- Vector Processing\n\n" | |
| "**Outputs**\n" | |
| "- Results\n" | |
| "- Visuals\n" | |
| "- Downloads" | |
| ) | |
| # ---------------- Main area ---------------- | |
| with gr.Column(scale=3): | |
| chatbot = gr.Chatbot(height=320, label="Conversation") | |
| with gr.Row(): | |
| chat_input = gr.Textbox( | |
| placeholder="Message the agent...", | |
| show_label=False, | |
| scale=5, | |
| ) | |
| send_btn = gr.Button("Send", scale=1, variant="primary") | |
| with gr.Tabs(): | |
| # =================== INPUTS =================== | |
| # =================== INPUTS =================== | |
| with gr.Tab("Inputs"): | |
| with gr.Tabs(): | |
| with gr.Tab("Data sources"): | |
| gr.Markdown( | |
| "Load external data as context. Each load is saved " | |
| "as a timestamped JSON file in the Downloads tab." | |
| ) | |
| with gr.Tabs(): | |
| with gr.Tab("Web scraping"): | |
| url_input = gr.Textbox( | |
| label="URL", placeholder="https://example.com", | |
| ) | |
| with gr.Row(): | |
| scrape_btn = gr.Button("Scrape", variant="primary") | |
| scrape_clear_btn = gr.Button("Clear") | |
| scrape_preview = gr.Textbox( | |
| label="Extracted text", lines=8, interactive=False, | |
| ) | |
| scrape_status = gr.Markdown("Nothing loaded.") | |
| with gr.Tab("PDF upload"): | |
| pdf_input = gr.File( | |
| label="Upload PDF", file_types=[".pdf"], | |
| ) | |
| with gr.Row(): | |
| pdf_extract_btn = gr.Button("Extract text", variant="primary") | |
| pdf_clear_btn = gr.Button("Clear") | |
| pdf_preview = gr.Textbox( | |
| label="Extracted text", lines=8, interactive=False, | |
| ) | |
| pdf_status = gr.Markdown("Nothing loaded.") | |
| with gr.Tab("CSV / Excel upload"): | |
| csv_input = gr.File( | |
| label="Upload CSV or Excel", | |
| file_types=[".csv", ".xlsx", ".xls"], | |
| ) | |
| with gr.Row(): | |
| csv_load_btn = gr.Button("Load", variant="primary") | |
| csv_clear_btn = gr.Button("Clear") | |
| csv_preview = gr.Dataframe( | |
| label="Preview (first 20 rows)", interactive=False, | |
| ) | |
| csv_status = gr.Markdown("Nothing loaded.") | |
| with gr.Tab("ML examples"): | |
| gr.Markdown( | |
| "Load the built-in catalog of labeled ML paper " | |
| "sentences as context. No upload needed — the " | |
| "dataset lives in examples.py." | |
| ) | |
| with gr.Row(): | |
| ml_load_btn = gr.Button("Load catalog", variant="primary") | |
| ml_clear_btn = gr.Button("Clear") | |
| ml_preview = gr.Textbox( | |
| label="Catalog preview", lines=10, interactive=False, | |
| ) | |
| ml_status = gr.Markdown("Nothing loaded.") | |
| with gr.Tab("Form"): | |
| gr.Markdown( | |
| "Fill structured fields and hit Submit. Generates a chat " | |
| "message and saves the form fields as their own JSON file." | |
| ) | |
| form_task = gr.Dropdown( | |
| ["Math", "Weather", "General"], | |
| value="Math", label="Task type", | |
| ) | |
| form_op = gr.Dropdown( | |
| ["Add", "Multiply"], | |
| value="Add", label="Operation (Math only)", | |
| ) | |
| with gr.Row(): | |
| form_a = gr.Number(label="Number A", value=0) | |
| form_b = gr.Number(label="Number B", value=0) | |
| form_city = gr.Textbox( | |
| label="City (Weather only)", placeholder="e.g. Tokyo", | |
| ) | |
| form_notes = gr.Textbox( | |
| label="Notes (General only)", lines=2, | |
| ) | |
| with gr.Row(): | |
| form_submit = gr.Button("Submit", variant="primary") | |
| form_clear = gr.Button("Clear") | |
| # =================== SUPERVISED MACHINE LEARNING =================== | |
| # =================== PROCESSING / ANALYSIS =================== | |
| with gr.Tab("Processing / Analysis"): | |
| with gr.Tabs(): | |
| with gr.Tab("Supervised Machine Learning"): | |
| gr.Markdown( | |
| "**Supervised ML** on the built-in 100-sentence customer-feedback " | |
| "dataset (6 labels). Uses semantic embeddings from " | |
| "`sentence-transformers/all-MiniLM-L6-v2` + logistic regression. " | |
| "No LLM involved." | |
| ) | |
| with gr.Tabs(): | |
| with gr.Tab("Dataset"): | |
| gr.Markdown( | |
| "The 100 labeled sentences the classifier learns from. " | |
| "Six labels, roughly balanced: positive_review, " | |
| "negative_review, question, complaint, compliment, " | |
| "feature_request." | |
| ) | |
| sup_label_filter = gr.Dropdown( | |
| choices=["(all)"] + list(sorted( | |
| {e["label"] for e in TRAINING_EXAMPLES} | |
| )), | |
| value="(all)", | |
| label="Filter by label", | |
| ) | |
| sup_dataset_view = gr.Dataframe( | |
| value=pd.DataFrame(TRAINING_EXAMPLES), | |
| label=f"Training dataset ({len(TRAINING_EXAMPLES)} sentences)", | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| with gr.Tab("Train"): | |
| gr.Markdown( | |
| "Click Train to fit a logistic regression classifier on " | |
| "semantic embeddings of 80 sentences (stratified split), " | |
| "then evaluate on the remaining 20." | |
| ) | |
| with gr.Row(): | |
| train_btn = gr.Button("Train classifier", variant="primary") | |
| train_clear_btn = gr.Button("Clear") | |
| train_status = gr.Markdown("Not trained yet.") | |
| confusion_out = gr.Dataframe( | |
| label="Confusion matrix (rows=actual, cols=predicted)", | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| with gr.Tab("Predict"): | |
| gr.Markdown( | |
| "Type a new sentence to classify. The classifier must " | |
| "be trained first — go to the Train sub-tab and click " | |
| "Train classifier before using this panel." | |
| ) | |
| predict_input = gr.Textbox( | |
| label="Sentence", | |
| placeholder="e.g. this product is amazing", | |
| lines=2, | |
| ) | |
| predict_btn = gr.Button("Predict", variant="primary") | |
| predict_out = gr.Markdown("No prediction yet.") | |
| # =================== UNSUPERVISED MACHINE LEARNING =================== | |
| with gr.Tab("Unsupervised Machine Learning"): | |
| gr.Markdown( | |
| "**Unsupervised ML** on the same 100-sentence dataset with the " | |
| "labels hidden from the algorithm. Uses semantic embeddings from " | |
| "`sentence-transformers/all-MiniLM-L6-v2` + **Hierarchical " | |
| "Agglomerative Clustering** with cosine distance." | |
| ) | |
| with gr.Tabs(): | |
| with gr.Tab("Dataset"): | |
| gr.Markdown( | |
| "The 100 sentences the clustering algorithm sees. " | |
| "Labels are hidden here on purpose — unsupervised " | |
| "learning works without them. After clustering runs, " | |
| "the Cluster sub-tab compares discovered clusters to " | |
| "the true labels so you can see what the algorithm " | |
| "figured out on its own." | |
| ) | |
| unsup_dataset_view = gr.Dataframe( | |
| value=pd.DataFrame( | |
| [{"sentence": e["sentence"]} for e in TRAINING_EXAMPLES] | |
| ), | |
| label=f"Sentences only ({len(TRAINING_EXAMPLES)} rows, no labels)", | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| with gr.Tab("Cluster"): | |
| gr.Markdown( | |
| "**Hierarchical Agglomerative Clustering** on " | |
| "semantic embeddings. Clusters emerge from a " | |
| "similarity threshold instead of a fixed count. " | |
| "Small clusters become **noise**. Each surviving " | |
| "cluster exposes its **centroid** and the " | |
| "**N nearest-to-centroid** sentences as " | |
| "representatives — optionally sent to an LLM " | |
| "for an automatic cluster label." | |
| ) | |
| cluster_sim = gr.Slider( | |
| 0.40, 0.90, value=0.60, step=0.05, | |
| label="Similarity threshold", | |
| info="Minimum cosine similarity between vectors to merge.", | |
| ) | |
| cluster_min = gr.Slider( | |
| 2, 10, value=3, step=1, | |
| label="Minimum cluster size", | |
| info="Clusters smaller than this are reassigned to noise.", | |
| ) | |
| cluster_nnear = gr.Slider( | |
| 1, 10, value=3, step=1, | |
| label="N nearest-to-centroid", | |
| info="How many representative sentences to pick per cluster.", | |
| ) | |
| cluster_llm_toggle = gr.Checkbox( | |
| label="Label clusters with LLM", | |
| value=False, | |
| info="Sends the N nearest sentences per cluster to the sidebar LLM provider for a short label. Adds ~2s per cluster.", | |
| ) | |
| with gr.Row(): | |
| cluster_btn = gr.Button("Cluster", variant="primary") | |
| cluster_clear_btn = gr.Button("Clear") | |
| cluster_status = gr.Markdown("Not clustered yet.") | |
| cluster_out = gr.Dataframe( | |
| label="Sentence-level cluster table", | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| # =================== VECTOR PROCESSING =================== | |
| with gr.Tab("Vector Processing"): | |
| gr.Markdown( | |
| "**Semantic vector storage and retrieval** using ChromaDB " | |
| "as a persistent on-disk vector database. \n" | |
| "Same embedding model as Supervised / Unsupervised ML " | |
| "(`sentence-transformers/all-MiniLM-L6-v2`), 384 dimensions, " | |
| "cosine similarity. Every sentence is stored with its label " | |
| "as metadata so retrieval results include ground-truth labels." | |
| ) | |
| with gr.Tabs(): | |
| with gr.Tab("Vectorize"): | |
| gr.Markdown( | |
| "See what a sentence embedding actually looks like. " | |
| "Click Preview to compute embeddings for the first " | |
| "10 training sentences and show the first 8 dimensions " | |
| "of each 384-dim vector." | |
| ) | |
| with gr.Row(): | |
| vectorize_btn = gr.Button( | |
| "Preview embeddings", variant="primary", | |
| ) | |
| vectorize_clear_btn = gr.Button("Clear") | |
| vectorize_status = gr.Markdown( | |
| "Click 'Preview embeddings' to see sentence vectors." | |
| ) | |
| vectorize_out = gr.Dataframe( | |
| label="Sentences with embedding preview", | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| with gr.Tab("Vector DB"): | |
| gr.Markdown( | |
| "**ChromaDB-backed persistent vector store.** \n" | |
| "Step 1: Click 'Index all 100 sentences' once per " | |
| "session to embed the training data and write it to " | |
| "the local Chroma collection. \n" | |
| "Step 2: Type a query and click 'Semantic search' to " | |
| "retrieve the nearest training sentences. The results " | |
| "show cosine similarity and the ground-truth label " | |
| "from the metadata." | |
| ) | |
| gr.Markdown("### Index") | |
| with gr.Row(): | |
| vector_index_btn = gr.Button( | |
| "Index all 100 sentences", variant="primary", | |
| ) | |
| vector_clear_btn = gr.Button("Clear index") | |
| vector_index_status = gr.Markdown("Not indexed yet.") | |
| gr.Markdown("### Semantic search") | |
| vector_query = gr.Textbox( | |
| label="Query", | |
| placeholder="e.g. the app keeps crashing", | |
| lines=2, | |
| ) | |
| vector_n = gr.Slider( | |
| 1, 10, value=5, step=1, | |
| label="Number of results", | |
| ) | |
| vector_search_btn = gr.Button( | |
| "Semantic search", variant="primary", | |
| ) | |
| vector_search_status = gr.Markdown( | |
| "Enter a query and click 'Semantic search'." | |
| ) | |
| vector_search_out = gr.Dataframe( | |
| label="Nearest neighbors (cosine similarity)", | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| # =================== OUTPUTS =================== | |
| # =================== OUTPUTS =================== | |
| with gr.Tab("Outputs"): | |
| with gr.Tabs(): | |
| with gr.Tab("Results"): | |
| with gr.Tabs(): | |
| with gr.Tab("Table"): | |
| gr.Markdown("Step log for the most recent run.") | |
| table_out = gr.Dataframe( | |
| headers=["step", "type", "tool", "args", "result"], | |
| label="", | |
| wrap=True, | |
| ) | |
| with gr.Tab("Code"): | |
| gr.Markdown("Python snippets for the most recent run.") | |
| code_out = gr.Code(language="python", label="") | |
| with gr.Tab("Extracted"): | |
| gr.Markdown("What the agent parsed from the most recent run.") | |
| extracted_out = gr.Code(language="json", label="") | |
| with gr.Tab("Visuals"): | |
| gr.Markdown("Tool-call counts for the most recent run.") | |
| chart_out = gr.BarPlot( | |
| x="tool", y="count", | |
| title="", tooltip=["tool", "count"], | |
| height=280, | |
| ) | |
| with gr.Tab("Downloads"): | |
| gr.Markdown( | |
| "Every input and every run is saved here as a " | |
| "timestamped JSON file. Files accumulate across the session." | |
| ) | |
| downloads_files_out = gr.File( | |
| label="All artifacts (timestamped JSON)", | |
| file_count="multiple", | |
| interactive=False, | |
| ) | |
| # ======================= RESEARCHER WORKBENCH (parent tab) ======================= | |
| with gr.Tab("Researcher Workbench"): | |
| gr.Markdown( | |
| "**Researcher Workbench** groups two self-contained " | |
| "LangGraph supervisor workflows that apply published " | |
| "research methodologies to the training data. Each " | |
| "methodology has its own sub-tab with its own state, " | |
| "prompts, tools, and supervisor." | |
| ) | |
| with gr.Tabs(): | |
| # ==================== COMPUTATIONAL GROUNDED THEORY ==================== | |
| with gr.Tab("Computational Grounded Theory"): | |
| gr.Markdown( | |
| "**Nelson 2020** — three-step methodological framework. " | |
| "A LangGraph supervisor routes the request through three " | |
| "phase nodes in order: \n" | |
| "1. **Pattern Detection** — inductive clustering + LLM labeling (real) \n" | |
| "2. **Pattern Refinement** — interpretive review (placeholder) \n" | |
| "3. **Pattern Confirmation** — classifier validation (placeholder) \n\n" | |
| "Maps to traditional grounded theory: open -> axial -> selective coding." | |
| ) | |
| wb_cgt_msg = gr.Textbox( | |
| label="Request to the supervisor", | |
| value="Run computational grounded theory on the training data.", | |
| lines=2, | |
| ) | |
| with gr.Row(): | |
| wb_cgt_sim = gr.Slider( | |
| 0.40, 0.90, value=0.60, step=0.05, | |
| label="Similarity threshold", | |
| ) | |
| wb_cgt_min = gr.Slider( | |
| 2, 10, value=3, step=1, | |
| label="Minimum cluster size", | |
| ) | |
| wb_cgt_nnear = gr.Slider( | |
| 1, 10, value=3, step=1, | |
| label="N nearest to centroid", | |
| ) | |
| with gr.Row(): | |
| wb_cgt_run = gr.Button("Run Workbench", variant="primary") | |
| wb_cgt_reply = gr.Markdown("Not run yet.") | |
| gr.Markdown("### Graph execution trace") | |
| wb_cgt_trace = gr.Dataframe( | |
| headers=["step", "node", "action", "detail"], | |
| label="Supervisor routing + node invocations", | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| gr.Markdown("### Pattern Detection output (Step 1)") | |
| wb_cgt_sentences = gr.Dataframe( | |
| label="Sentences with cluster id + LLM cluster label", | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| # ==================== COMPUTATIONAL THEMATIC ANALYSIS ==================== | |
| with gr.Tab("Computational Thematic Analysis"): | |
| gr.Markdown( | |
| "**Braun & Clarke 2006** — six-phase reflexive thematic analysis. " | |
| "This workbench groups two complementary paths: \n" | |
| "- **Workbench** — the LangGraph supervisor approach (Phase 2 real, rest placeholders) \n" | |
| "- **Phase 1 — Familiarization** — active-reading dialogue via grounded " | |
| "dialogue partners, followed by researcher confirmation of each initial noticing" | |
| ) | |
| with gr.Tabs(): | |
| # ------------ Existing Workbench path ------------ | |
| with gr.Tab("Workbench (LangGraph)"): | |
| gr.Markdown( | |
| "Six-phase supervisor routing via LangGraph: \n" | |
| "1. **Familiarization** (placeholder) \n" | |
| "2. **Generating Initial Codes** — LLM codes each sentence (real) \n" | |
| "3. **Searching for Themes** (placeholder) \n" | |
| "4. **Reviewing Themes** (placeholder) \n" | |
| "5. **Defining and Naming Themes** (placeholder) \n" | |
| "6. **Producing the Report** (placeholder)" | |
| ) | |
| wb_cta_msg = gr.Textbox( | |
| label="Request to the supervisor", | |
| value="Run reflexive thematic analysis on the training data.", | |
| lines=2, | |
| ) | |
| wb_cta_max = gr.Slider( | |
| 5, 100, value=20, step=5, | |
| label="Max sentences to code", | |
| info="One LLM call per sentence in Phase 2. " | |
| "Default 20 keeps runtime under ~40 seconds.", | |
| ) | |
| wb_cta_run = gr.Button("Run Workbench", variant="primary") | |
| wb_cta_reply = gr.Markdown("Not run yet.") | |
| gr.Markdown("### Graph execution trace") | |
| wb_cta_trace = gr.Dataframe( | |
| headers=["step", "node", "action", "detail"], | |
| label="Supervisor routing + node invocations", | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| gr.Markdown("### Phase 2 output — Initial Codes") | |
| wb_cta_codes = gr.Dataframe( | |
| label="Sentences with LLM-generated codes", | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| # ------------ NEW: Phase 1 — Familiarization path ------------ | |
| with gr.Tab("Phase 1 — Familiarization"): | |
| gr.Markdown( | |
| "## Phase 1 — Familiarizing Yourself With Your Data\n\n" | |
| "*Braun & Clarke 2006, Phase 1: \"immerse yourself in the data " | |
| "to the extent that you are familiar with the depth and breadth " | |
| "of the content\"* (p. 87).\n\n" | |
| "This workbench implements Phase 1 through a three-step " | |
| "active-reading protocol. Two complementary dialogue partners " | |
| "(implemented as Gemini Gems backed by NotebookLM) guide the " | |
| "researcher through immersion and reflexive engagement, " | |
| "followed by researcher confirmation of every initial noticing " | |
| "against its source evidence.\n\n" | |
| "**Step 1 — Familiarization Facilitator** — an active-reading " | |
| "dialogue partner that asks grounded questions, surfaces " | |
| "patterns, and prompts the researcher to articulate initial " | |
| "noticings. Every response is anchored in direct quotation " | |
| "from the source corpus. \n" | |
| "**Step 2 — Reflexive Companion** — a critical dialogue partner " | |
| "that challenges the researcher's initial noticings, probes " | |
| "reflexive positioning, and verifies dataset immersion " | |
| "coverage across all sources. \n" | |
| "**Step 3 — Researcher Confirmation** — the researcher reviews " | |
| "each initial noticing against its source sentence and " | |
| "confirms, refines, or rejects it. This forces active " | |
| "engagement with the evidence and is the researcher's own " | |
| "analytic act — not the dialogue partner's.\n\n" | |
| "**Braun & Clarke 2006 compliance target:** ≥90% when both " | |
| "dialogue partners are engaged with iteration. Unclosable " | |
| "gaps documented in COMPLIANCE.md: felt sense of the data " | |
| "(phenomenological, unautomatable), and time-on-task " | |
| "verification (researcher's own responsibility)." | |
| ) | |
| # ---- Corpus loader ---- | |
| gr.Markdown("### Corpus — Canonical CSV") | |
| gr.Markdown( | |
| "*Phase 1 consumes a canonical CSV with five columns: " | |
| "`doc_id`, `doc_title`, `section`, `sub_section`, `sentence`. " | |
| "Inputs tab transformers (PDF→CSV, web scrape→CSV) will " | |
| "produce this schema in a future round. For pipeline testing, " | |
| "load the built-in test corpus.*" | |
| ) | |
| with gr.Row(): | |
| p1_load_test_btn = gr.Button( | |
| "Load test_phase1.csv", | |
| variant="secondary", | |
| ) | |
| p1_upload_csv = gr.File( | |
| label="Or upload your own canonical CSV", | |
| file_types=[".csv"], | |
| ) | |
| p1_corpus_status = gr.Markdown("No corpus loaded.") | |
| p1_corpus_preview = gr.Dataframe( | |
| label="Corpus preview", | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| # ---- Step 1 — Familiarization Facilitator ---- | |
| gr.Markdown("---") | |
| gr.Markdown("### Step 1 — Familiarization Facilitator") | |
| gr.Markdown( | |
| "An active-reading dialogue partner grounded in your " | |
| "corpus via NotebookLM. Copy the instructions below, " | |
| "create a Gem in Gemini with your NotebookLM notebook " | |
| "attached under Knowledge, engage in the active-reading " | |
| "dialogue, then paste your outputs here." | |
| ) | |
| p1_facilitator_instructions = gr.Textbox( | |
| label="Familiarization Facilitator instructions (paste into Gemini Gem)", | |
| value="(instructions will be drafted in next round)", | |
| lines=8, | |
| max_lines=20, | |
| ) | |
| p1_facilitator_memo = gr.Textbox( | |
| label="Paste: Familiarization notes (Braun & Clarke 2006, Phase 1 output)", | |
| lines=4, | |
| ) | |
| p1_facilitator_transcript = gr.Textbox( | |
| label="Paste: Full active-reading dialogue transcript", | |
| lines=6, | |
| ) | |
| p1_facilitator_citations = gr.Textbox( | |
| label="Paste: Source evidence — quoted sentences anchoring each initial noticing", | |
| lines=4, | |
| info="One citation per line. Format: doc_id | section | sentence", | |
| ) | |
| # ---- Step 2 — Reflexive Companion ---- | |
| gr.Markdown("---") | |
| gr.Markdown("### Step 2 — Reflexive Companion") | |
| gr.Markdown( | |
| "A critical dialogue partner that challenges your initial " | |
| "noticings, probes your reflexive positioning, and verifies " | |
| "immersion coverage across all sources. Run this after the " | |
| "Facilitator dialogue is complete." | |
| ) | |
| p1_companion_instructions = gr.Textbox( | |
| label="Reflexive Companion instructions (paste into Gemini Gem)", | |
| value="(instructions will be drafted in next round)", | |
| lines=8, | |
| max_lines=20, | |
| ) | |
| p1_companion_challenges = gr.Textbox( | |
| label="Paste: Reflexive challenges raised by Companion", | |
| lines=4, | |
| ) | |
| p1_companion_reflexivity = gr.Textbox( | |
| label="Paste: Reflexive positioning statement", | |
| lines=4, | |
| info="Your position as researcher — assumptions, theoretical lens, relationship to the data.", | |
| ) | |
| p1_companion_breadth = gr.Textbox( | |
| label="Paste: Dataset immersion coverage notes", | |
| lines=3, | |
| info="Which sources and sections were engaged with, which remain unread.", | |
| ) | |
| # ---- Step 3 — Researcher Confirmation ---- | |
| gr.Markdown("---") | |
| gr.Markdown("### Step 3 — Researcher Confirmation") | |
| gr.Markdown( | |
| "Review each initial noticing against its source sentence. " | |
| "Confirm, refine, or reject each one. This is the researcher's " | |
| "own analytic act — not the dialogue partner's. Braun & Clarke " | |
| "2019/2021 insist that reflexive thematic analysis is *constructed* " | |
| "by the researcher's engagement with the data, not *extracted* by a tool." | |
| ) | |
| p1_build_table_btn = gr.Button( | |
| "Build confirmation table from Steps 1 + 2", | |
| variant="secondary", | |
| ) | |
| p1_validation_table = gr.Dataframe( | |
| headers=[ | |
| "doc_id", "doc_title", "section", "sub_section", | |
| "sentence", "initial_noticing", | |
| "reflexive_challenge", "researcher_confirmation", | |
| "refined_noticing", | |
| ], | |
| label="Phase 1 Researcher Confirmation Table — edit the last 4 columns", | |
| interactive=True, | |
| wrap=True, | |
| ) | |
| # ---- Save ---- | |
| gr.Markdown("---") | |
| p1_save_btn = gr.Button( | |
| "Save Phase 1 output (all 3 steps → JSON artifact)", | |
| variant="primary", | |
| ) | |
| p1_save_status = gr.Markdown("") | |
| # ------------ Phase 2 — Initial Coding ------------ | |
| with gr.Tab("Phase 2 — Initial Coding"): | |
| gr.Markdown( | |
| "## Phase 2 — Generating Initial Codes\n\n" | |
| "*Braun & Clarke 2006, Phase 2: \"Coding interesting features " | |
| "of the data in a systematic fashion across the entire data " | |
| "set, collating data relevant to each code\"* (p. 87).\n\n" | |
| "This workbench implements Phase 2 through a **fully agentic " | |
| "LangGraph architecture**. The agent loops systematically " | |
| "across every sentence, generates both semantic and latent " | |
| "codes, maintains a growing codebook with definitions, and " | |
| "iterates with researcher-edited context. The researcher is " | |
| "the final authority — human code columns always override AI.\n\n" | |
| "**Architecture:** LangGraph supervisor + 7 agent tools " | |
| "(read_corpus, read_phase1_context, propose_code, " | |
| "check_codebook, add_to_codebook, flag_for_review, " | |
| "save_iteration). Agent decides ordering, flags ambiguous " | |
| "sentences, and avoids codebook duplication.\n\n" | |
| "**Braun & Clarke 2006 compliance target:** ~88% with full " | |
| "agent + 3 iterations + researcher review. Unclosable gaps: " | |
| "reflexive engagement depth, time-on-task verification, felt " | |
| "sense of codes (documented in COMPLIANCE.md).\n\n" | |
| "**Round 2 status (this release):** Real LangGraph agent wired. " | |
| "Click Run iteration 1 to invoke Mistral through the 7-tool " | |
| "supervisor loop. Runtime: ~60-120 seconds for 30 sentences. " | |
| "Iteration 2 reads researcher edits from iteration 1. " | |
| "Iteration 3 is the final convergence pass." | |
| ) | |
| # ---- Corpus source ---- | |
| gr.Markdown("### Corpus — inherited from Phase 1") | |
| gr.Markdown( | |
| "*Phase 2 reads the canonical corpus loaded in Phase 1. " | |
| "If no corpus is loaded, go to Phase 1 → Familiarization " | |
| "and load test_phase1.csv or your own canonical CSV first.*" | |
| ) | |
| p2_corpus_status = gr.Markdown("No corpus loaded. Load in Phase 1 first.") | |
| p2_refresh_btn = gr.Button( | |
| "Refresh corpus status from Phase 1", | |
| variant="secondary", | |
| ) | |
| # ---- Phase 1 context consumption ---- | |
| gr.Markdown("---") | |
| gr.Markdown("### Phase 1 context (consumed by the agent)") | |
| gr.Markdown( | |
| "*The Phase 2 agent reads the researcher's reflexive " | |
| "positioning and confirmed initial noticings from Phase 1 " | |
| "as context. This ensures Phase 2 coding is grounded in " | |
| "the researcher's familiarization, not starting from scratch.*" | |
| ) | |
| p2_phase1_summary = gr.Markdown( | |
| "*Phase 1 output will appear here after Save Phase 1.*" | |
| ) | |
| # ---- Orientation — Braun & Clarke p. 84 ---- | |
| gr.Markdown("---") | |
| gr.Markdown("### Coding orientation (Braun & Clarke p. 84)") | |
| gr.Markdown( | |
| "*Braun & Clarke 2006 (p. 84) treat SEMANTIC vs LATENT as " | |
| "an analysis-wide choice, not a per-sentence distinction. " | |
| "Choose ONE orientation for this whole analysis. The agent " | |
| "will code every sentence at the level you pick.* \n\n" | |
| "**Semantic** — surface content, what the text explicitly says \n" | |
| "**Latent** — underlying assumptions, what the text implies" | |
| ) | |
| p2_orientation = gr.Radio( | |
| choices=["semantic", "latent"], | |
| value="semantic", | |
| label="Coding orientation for this analysis", | |
| interactive=True, | |
| ) | |
| # ---- Iteration controls ---- | |
| gr.Markdown("---") | |
| gr.Markdown("### Agentic coding iterations") | |
| gr.Markdown( | |
| "Braun & Clarke insist on iterative refinement. Run " | |
| "iteration 1 → review AI codes in the table → edit human " | |
| "columns → run iteration 2 (agent reads your edits as " | |
| "context) → review → iteration 3 → converge." | |
| ) | |
| with gr.Row(): | |
| p2_run_iter1_btn = gr.Button( | |
| "Run iteration 1", | |
| variant="primary", | |
| ) | |
| p2_run_iter2_btn = gr.Button( | |
| "Run iteration 2 (reads your edits)", | |
| variant="secondary", | |
| ) | |
| p2_run_iter3_btn = gr.Button( | |
| "Run iteration 3 (final)", | |
| variant="secondary", | |
| ) | |
| p2_iter_status = gr.Markdown("*No iterations run yet.*") | |
| # ---- Coding table ---- | |
| gr.Markdown("---") | |
| gr.Markdown("### Initial Codes Table") | |
| gr.Markdown( | |
| "*Every sentence gets two code levels (semantic + latent) " | |
| "per iteration. Edit the `human_code_iterN` columns to " | |
| "override the agent. The `final_code` column is populated " | |
| "from the latest human edit or the latest AI code if no " | |
| "human edit exists.*" | |
| ) | |
| p2_codes_table = gr.Dataframe( | |
| headers=[ | |
| "doc_id", "doc_title", "section", "sub_section", "sentence", | |
| "ai_code_iter1", "human_code_iter1", | |
| "ai_code_iter2", "human_code_iter2", | |
| "ai_code_iter3", "human_code_iter3", | |
| "final_code", "flagged", | |
| ], | |
| label="Phase 2 Initial Codes — edit human_code_iterN columns", | |
| interactive=True, | |
| wrap=True, | |
| ) | |
| # ---- Codebook ---- | |
| gr.Markdown("---") | |
| gr.Markdown("### Codebook") | |
| gr.Markdown( | |
| "*Braun & Clarke 2006 require a codebook: the dictionary " | |
| "of codes with definitions, provenance, and usage counts. " | |
| "The agent maintains this as it codes; the researcher can " | |
| "edit definitions directly.*" | |
| ) | |
| p2_codebook_table = gr.Dataframe( | |
| headers=[ | |
| "code_name", "definition", "created_by", | |
| "provenance", "sentence_count", | |
| ], | |
| label="Phase 2 Codebook — edit definitions", | |
| interactive=True, | |
| wrap=True, | |
| ) | |
| # ---- Save ---- | |
| gr.Markdown("---") | |
| p2_save_btn = gr.Button( | |
| "Save Phase 2 output (codes + codebook → JSON artifact)", | |
| variant="primary", | |
| ) | |
| p2_save_status = gr.Markdown("") | |
| # ------------ Phase 3 -- Searching for Themes ------------ | |
| with gr.Tab("Phase 3 -- Searching for Themes"): | |
| gr.Markdown( | |
| "## Phase 3 -- Searching for Themes\n\n" | |
| "*Braun & Clarke 2006, Phase 3: \"Collating codes into potential " | |
| "themes, gathering all data relevant to each potential theme\" (p. 89).*\n\n" | |
| "This phase clusters the Phase 2 codebook codes by semantic similarity " | |
| "(sentence-transformers embeddings + agglomerative clustering), then " | |
| "proposes a candidate theme name and description for each cluster " | |
| "via one Mistral call per cluster.\n\n" | |
| "**Researcher action:** review the candidate themes, edit " | |
| "`researcher_theme_name` and `researcher_notes` columns, then " | |
| "re-run with different thresholds if needed. B&C 2006 explicitly " | |
| "say Phase 3 is tentative and iterative." | |
| ) | |
| gr.Markdown("### Clustering parameters (researcher-controlled)") | |
| gr.Markdown( | |
| "*B&C 2006 do not prescribe a fixed number of themes. " | |
| "Themes emerge from the clustering threshold you set. " | |
| "Lower similarity = fewer, broader themes. " | |
| "Higher similarity = more, tighter themes.*" | |
| ) | |
| with gr.Row(): | |
| p3_similarity = gr.Slider( | |
| minimum=0.3, maximum=0.95, value=0.60, step=0.05, | |
| label="Similarity threshold", | |
| info="Codes more similar than this cluster together. Default 0.60.", | |
| ) | |
| p3_min_size = gr.Slider( | |
| minimum=2, maximum=10, value=2, step=1, | |
| label="Minimum cluster size", | |
| info="Clusters smaller than this go into noise bucket. Default 2.", | |
| ) | |
| p3_run_btn = gr.Button( | |
| "Run Phase 3 -- Cluster codes into candidate themes", | |
| variant="primary", | |
| ) | |
| p3_status = gr.Markdown("*No themes generated yet. Run Phase 2 first.*") | |
| gr.Markdown("---") | |
| gr.Markdown( | |
| "### Candidate Themes Table\n" | |
| "*Edit `researcher_theme_name` and `researcher_notes` to override " | |
| "or refine the AI-generated theme names. Researcher is the final " | |
| "authority (Braun & Clarke 2006, reflexive TA principle).*" | |
| ) | |
| p3_themes_table = gr.Dataframe( | |
| headers=[ | |
| "theme_id", "candidate_theme_name", "description", | |
| "rationale", "member_codes", "code_count", | |
| "researcher_theme_name", "researcher_notes", | |
| ], | |
| label="Phase 3 Candidate Themes -- edit researcher_theme_name and researcher_notes", | |
| interactive=True, | |
| wrap=True, | |
| ) | |
| gr.Markdown("---") | |
| gr.Markdown( | |
| "### Noise Codes\n" | |
| "*Codes that did not fit any cluster (below minimum cluster size). " | |
| "Review these -- they may represent important edge cases or require " | |
| "lower similarity threshold to be absorbed.*" | |
| ) | |
| p3_noise_table = gr.Dataframe( | |
| headers=["code_name", "definition"], | |
| label="Noise codes (did not cluster)", | |
| interactive=False, | |
| wrap=True, | |
| ) | |
| gr.Markdown("---") | |
| p3_save_btn = gr.Button( | |
| "Save Phase 3 output (themes + noise -> JSON artifact)", | |
| variant="secondary", | |
| ) | |
| p3_save_status = gr.Markdown("") | |
| # ======================================================================== | |
| # ZONE 5 — Event wiring (.click handlers — the glue) | |
| # ======================================================================== | |
| # Each .click() connects a button to a handler function. The function's | |
| # return values go into the components listed in outputs=[...]. | |
| # | |
| # GOLDEN RULE: the number of return values from the handler must match | |
| # the length of the outputs list, in the same order. | |
| # | |
| # chat_outputs is the shared list used by process_message, submit_form, | |
| # and new_chat. All three must return 8 values in the same order. | |
| # ---------------- | |
| # Event wiring | |
| # ---------------- | |
| chat_outputs = [ | |
| chatbot, table_out, extracted_out, chart_out, code_out, | |
| downloads_state, downloads_files_out, chat_input, | |
| ] | |
| send_btn.click( | |
| process_message, | |
| inputs=[chat_input, mode_select, llm_provider_select, llm_key_input, | |
| chatbot, loaded_context_state, downloads_state], | |
| outputs=chat_outputs, | |
| ) | |
| chat_input.submit( | |
| process_message, | |
| inputs=[chat_input, mode_select, llm_provider_select, llm_key_input, | |
| chatbot, loaded_context_state, downloads_state], | |
| outputs=chat_outputs, | |
| ) | |
| form_submit.click( | |
| submit_form, | |
| inputs=[ | |
| form_task, form_op, form_a, form_b, form_city, form_notes, | |
| mode_select, llm_provider_select, llm_key_input, chatbot, | |
| loaded_context_state, downloads_state, | |
| ], | |
| outputs=chat_outputs, | |
| ) | |
| form_clear.click( | |
| clear_form, | |
| outputs=[form_task, form_op, form_a, form_b, form_city, form_notes], | |
| ) | |
| new_chat_btn.click( | |
| new_chat, | |
| inputs=[downloads_state], | |
| outputs=chat_outputs, | |
| ) | |
| # Data source handlers | |
| scrape_btn.click( | |
| scrape_url, | |
| inputs=[url_input, downloads_state], | |
| outputs=[scrape_preview, scrape_status, loaded_context_state, | |
| downloads_state, downloads_files_out], | |
| ) | |
| scrape_clear_btn.click( | |
| clear_scrape, | |
| outputs=[url_input, scrape_preview, scrape_status, loaded_context_state], | |
| ) | |
| pdf_extract_btn.click( | |
| extract_pdf, | |
| inputs=[pdf_input, downloads_state], | |
| outputs=[pdf_preview, pdf_status, loaded_context_state, | |
| downloads_state, downloads_files_out], | |
| ) | |
| pdf_clear_btn.click( | |
| clear_pdf, | |
| outputs=[pdf_input, pdf_preview, pdf_status, loaded_context_state], | |
| ) | |
| csv_load_btn.click( | |
| load_spreadsheet, | |
| inputs=[csv_input, downloads_state], | |
| outputs=[csv_preview, csv_status, loaded_context_state, | |
| downloads_state, downloads_files_out], | |
| ) | |
| csv_clear_btn.click( | |
| clear_spreadsheet, | |
| outputs=[csv_input, csv_preview, csv_status, loaded_context_state], | |
| ) | |
| ml_load_btn.click( | |
| load_ml_examples, | |
| inputs=[downloads_state], | |
| outputs=[ml_preview, ml_status, loaded_context_state, | |
| downloads_state, downloads_files_out], | |
| ) | |
| ml_clear_btn.click( | |
| clear_ml_examples, | |
| outputs=[ml_preview, ml_status, loaded_context_state], | |
| ) | |
| # Training handlers (supervised) | |
| train_btn.click( | |
| handle_train, | |
| inputs=[downloads_state], | |
| outputs=[trained_state, train_status, confusion_out, | |
| downloads_state, downloads_files_out], | |
| ) | |
| train_clear_btn.click( | |
| clear_training, | |
| outputs=[trained_state, train_status, confusion_out, predict_out], | |
| ) | |
| predict_btn.click( | |
| handle_predict, | |
| inputs=[trained_state, predict_input, downloads_state], | |
| outputs=[predict_out, downloads_state, downloads_files_out], | |
| ) | |
| sup_label_filter.change( | |
| filter_training_dataset, | |
| inputs=[sup_label_filter], | |
| outputs=[sup_dataset_view], | |
| ) | |
| # Training handlers (unsupervised) | |
| cluster_btn.click( | |
| handle_cluster, | |
| inputs=[cluster_sim, cluster_min, cluster_nnear, cluster_llm_toggle, | |
| llm_provider_select, llm_key_input, downloads_state], | |
| outputs=[cluster_out, cluster_status, downloads_state, downloads_files_out], | |
| ) | |
| cluster_clear_btn.click( | |
| clear_clustering, | |
| outputs=[cluster_out, cluster_status], | |
| ) | |
| # ---- Vector Processing wiring ---- | |
| vectorize_btn.click( | |
| handle_vectorize_preview, | |
| inputs=[embedding_provider_select, embedding_key_input, downloads_state], | |
| outputs=[vectorize_out, vectorize_status, | |
| downloads_state, downloads_files_out], | |
| ) | |
| vectorize_clear_btn.click( | |
| clear_vectorize_preview, | |
| outputs=[vectorize_out, vectorize_status], | |
| ) | |
| vector_index_btn.click( | |
| handle_vector_index, | |
| inputs=[embedding_provider_select, embedding_key_input, downloads_state], | |
| outputs=[vector_index_status, downloads_state, downloads_files_out], | |
| ) | |
| vector_clear_btn.click( | |
| handle_vector_clear, | |
| inputs=[downloads_state], | |
| outputs=[vector_index_status, downloads_state, downloads_files_out], | |
| ) | |
| vector_search_btn.click( | |
| handle_vector_search, | |
| inputs=[vector_query, vector_n, | |
| embedding_provider_select, embedding_key_input, downloads_state], | |
| outputs=[vector_search_out, vector_search_status, | |
| downloads_state, downloads_files_out], | |
| ) | |
| # ---- Workbench wiring ---- | |
| wb_cgt_run.click( | |
| handle_wb_cgt, | |
| inputs=[wb_cgt_msg, wb_cgt_sim, wb_cgt_min, wb_cgt_nnear, | |
| llm_provider_select, llm_key_input, | |
| loaded_context_state, downloads_state], | |
| outputs=[wb_cgt_trace, wb_cgt_reply, wb_cgt_sentences, | |
| downloads_state, downloads_files_out], | |
| ) | |
| wb_cta_run.click( | |
| handle_wb_cta, | |
| inputs=[wb_cta_msg, wb_cta_max, | |
| llm_provider_select, llm_key_input, | |
| loaded_context_state, downloads_state], | |
| outputs=[wb_cta_trace, wb_cta_reply, wb_cta_codes, | |
| downloads_state, downloads_files_out], | |
| ) | |
| # ---- Phase 1 Familiarization wiring ---- | |
| p1_load_test_btn.click( | |
| handle_p1_load_test_csv, | |
| inputs=[downloads_state], | |
| outputs=[p1_corpus_state, p1_corpus_status, p1_corpus_preview, | |
| downloads_state, downloads_files_out], | |
| ) | |
| p1_upload_csv.upload( | |
| handle_p1_upload_csv, | |
| inputs=[p1_upload_csv, downloads_state], | |
| outputs=[p1_corpus_state, p1_corpus_status, p1_corpus_preview, | |
| downloads_state, downloads_files_out], | |
| ) | |
| p1_build_table_btn.click( | |
| handle_p1_build_validation_table, | |
| inputs=[p1_corpus_state, | |
| p1_facilitator_memo, p1_facilitator_transcript, p1_facilitator_citations, | |
| p1_companion_challenges, p1_companion_reflexivity, p1_companion_breadth], | |
| outputs=[p1_validation_table], | |
| ) | |
| p1_save_btn.click( | |
| handle_p1_save, | |
| inputs=[p1_corpus_state, | |
| p1_facilitator_memo, p1_facilitator_transcript, p1_facilitator_citations, | |
| p1_companion_challenges, p1_companion_reflexivity, p1_companion_breadth, | |
| p1_validation_table, | |
| downloads_state], | |
| outputs=[p1_save_status, downloads_state, downloads_files_out], | |
| ) | |
| # ---- Phase 2 Initial Coding wiring ---- | |
| p2_refresh_btn.click( | |
| handle_p2_refresh_corpus, | |
| inputs=[p1_corpus_state, | |
| p1_facilitator_memo, p1_companion_reflexivity, p1_validation_table], | |
| outputs=[p2_corpus_status, p2_phase1_summary], | |
| ) | |
| p2_run_iter1_btn.click( | |
| lambda corpus, codes, codebook, memo, reflex, vtable, prov, key, orient: | |
| handle_p2_run_iteration(1, corpus, codes, codebook, memo, reflex, vtable, prov, key, orient), | |
| inputs=[p1_corpus_state, p2_codes_table, p2_codebook_table, | |
| p1_facilitator_memo, p1_companion_reflexivity, p1_validation_table, | |
| llm_provider_select, llm_key_input, p2_orientation], | |
| outputs=[p2_codes_table, p2_codebook_table, p2_iter_status], | |
| ) | |
| p2_run_iter2_btn.click( | |
| lambda corpus, codes, codebook, memo, reflex, vtable, prov, key, orient: | |
| handle_p2_run_iteration(2, corpus, codes, codebook, memo, reflex, vtable, prov, key, orient), | |
| inputs=[p1_corpus_state, p2_codes_table, p2_codebook_table, | |
| p1_facilitator_memo, p1_companion_reflexivity, p1_validation_table, | |
| llm_provider_select, llm_key_input, p2_orientation], | |
| outputs=[p2_codes_table, p2_codebook_table, p2_iter_status], | |
| ) | |
| p2_run_iter3_btn.click( | |
| lambda corpus, codes, codebook, memo, reflex, vtable, prov, key, orient: | |
| handle_p2_run_iteration(3, corpus, codes, codebook, memo, reflex, vtable, prov, key, orient), | |
| inputs=[p1_corpus_state, p2_codes_table, p2_codebook_table, | |
| p1_facilitator_memo, p1_companion_reflexivity, p1_validation_table, | |
| llm_provider_select, llm_key_input, p2_orientation], | |
| outputs=[p2_codes_table, p2_codebook_table, p2_iter_status], | |
| ) | |
| p2_save_btn.click( | |
| handle_p2_save, | |
| inputs=[p1_corpus_state, p2_codes_table, p2_codebook_table, downloads_state], | |
| outputs=[p2_save_status, downloads_state, downloads_files_out], | |
| ) | |
| # ---- Phase 3 Searching for Themes wiring ---- | |
| p3_run_btn.click( | |
| handle_p3_run, | |
| inputs=[ | |
| p2_codebook_table, | |
| p3_similarity, p3_min_size, p2_orientation, | |
| p1_companion_reflexivity, | |
| llm_provider_select, llm_key_input, | |
| downloads_state, | |
| ], | |
| outputs=[p3_themes_table, p3_noise_table, p3_status, downloads_state, downloads_files_out], | |
| ) | |
| p3_save_btn.click( | |
| handle_p3_save, | |
| inputs=[p3_themes_table, p3_noise_table, downloads_state], | |
| outputs=[p3_save_status, downloads_state, downloads_files_out], | |
| ) | |
| if __name__ == "__main__": | |
| # ssr_mode=False: Gradio 5/6's Server-Side Rendering breaks demo.launch() | |
| # on HuggingFace Spaces with the "localhost not accessible" error. | |
| # Confirmed workaround from HF forums + Gradio Discord. | |
| demo.launch(ssr_mode=False) | |