# ============================================================================ # node_pattern_detection.py — Step 1 (REAL, compliant thin wrapper) # ============================================================================ # # COMPLIANCE # ---------- # This node is a thin orchestrator. It: # 1. reads params from state # 2. calls one tool to cluster sentences # 3. calls one tool to label each cluster # 4. shapes output rows and writes the structured result to state # # All domain logic lives in the tools (workbench_grounded_theory/tools/). # This file only routes data between state, tools, and state again. # ============================================================================ from training_data import TRAINING_EXAMPLES from .tools import cluster_sentences, label_clusters def pattern_detection_node(state): sentences = [e["sentence"] for e in TRAINING_EXAMPLES] true_labels = [e["label"] for e in TRAINING_EXAMPLES] cluster_result = cluster_sentences( sentences=sentences, similarity_threshold=state["similarity_threshold"], min_cluster_size=state["min_cluster_size"], n_nearest=state["n_nearest"], ) # Build the representatives payload for the labeling tool: # {cluster_id_str: [sentence, sentence, ...], ...} reps_for_llm = { str(cid): [sentences[i] for i, _d in reps] for cid, reps in cluster_result["representatives"].items() } cluster_labels = label_clusters( cluster_representatives=reps_for_llm, llm_provider=state["llm_provider"], llm_key=state["llm_key"], ) # Shape output rows. dict.get with default handles noise sentences # without a branching if/else. sentence_rows = [ { "idx": idx, "sentence": sentences[idx], "true_label": true_labels[idx], "cluster_id": str(cluster_result["cluster_ids"][idx]), "cluster_label": cluster_labels.get( str(cluster_result["cluster_ids"][idx]), "" ), "dist_to_centroid": cluster_result["distances_to_centroid"][idx], } for idx in range(len(sentences)) ] detection_result = { "n_clusters_found": cluster_result["n_clusters_found"], "n_noise_points": cluster_result["n_noise_points"], "cluster_labels": cluster_labels, "similarity_threshold": state["similarity_threshold"], "min_cluster_size": state["min_cluster_size"], "n_nearest": state["n_nearest"], "sentence_rows": sentence_rows, } return { "detection_result": detection_result, "steps": [{ "step": state.get("iteration", 0), "node": "pattern_detection", "action": "cluster + label (one LLM call per cluster)", "detail": ( f"{cluster_result['n_clusters_found']} clusters, " f"{cluster_result['n_noise_points']} noise" ), }], }