File size: 3,005 Bytes
9ebfd41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# ============================================================================
# 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"
            ),
        }],
    }