Spjimr / reference_app.py
shahidshaikh's picture
Upload 40 files
a52bae4 verified
# ============================================================================
# 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)