File size: 13,413 Bytes
35717ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
#!/usr/bin/env python3
"""
End-to-end test of the segment classifier + fused model chain.

Simulates the full dictation pipeline without a microphone:
1. Takes mixed dictation text (natural + protocol)
2. Runs ProtocolSegmentClassifier to extract segments
3. Sends protocol segments to TalkieInference via talkie-dev CLI
4. Reassembles output and compares against expected

Usage:
  python3 test-full-chain.py                    # run all test cases
  python3 test-full-chain.py --case 3           # run specific case
  python3 test-full-chain.py --classifier-only  # skip model, just test segmentation
"""

import json
import subprocess
import sys
import time
import importlib
from pathlib import Path
import numpy as np

# Import classifier from training script
_mod = importlib.import_module("train-segment-classifier")
extract_features = _mod.extract_features
is_protocol_word = _mod.is_protocol_word
is_strong_protocol = _mod.is_strong_protocol

# Load model
MODEL_PATH = Path(__file__).parent / "segment-classifier-model.json"
with open(MODEL_PATH) as f:
    _model = json.load(f)
WEIGHTS = np.array(_model["weights"])
BIAS = _model["bias"]
THRESHOLD = _model.get("threshold", 0.5)

# The model ID for the fused bash reconstruction model
MODEL_ID = "arach/qwen3-0.6b-bash-v1"
SYSTEM_PROMPT = "You are a dictation-to-bash converter. The input is a segment of dictated speech containing a command. Words like 'space', 'dash', 'dot', 'slash', 'colon', 'underscore', 'tilde', 'quote' are literal symbols. Number words like 'eight zero eight zero' mean '8080'. 'capital' means uppercase the next letter. 'all caps' means uppercase the next word. Ignore filler words like 'so', 'try', 'run', 'do', 'with'. Output ONLY the reconstructed command, nothing else."


# ─── Test cases ───

TEST_CASES = [
    {
        "input": "I want to check the directory ls dash la",
        "expected_natural": "I want to check the directory",
        "expected_command": "ls -la",
        "note": "Simple mixed: natural + short command"
    },
    {
        "input": "let me see what's running ps dash aux",
        "expected_natural": "let me see what's running",
        "expected_command": "ps -aux",
        "note": "Casual intro + ps"
    },
    {
        "input": "git space push space dash u space origin space main",
        "expected_natural": "",
        "expected_command": "git push -u origin main",
        "note": "Pure protocol, no natural speech"
    },
    {
        "input": "the meeting is at three o'clock tomorrow afternoon",
        "expected_natural": "the meeting is at three o'clock tomorrow afternoon",
        "expected_command": "",
        "note": "Pure natural, should NOT trigger model"
    },
    {
        "input": "can you run git space clone space https colon slash slash github dot com slash user slash repo for me",
        "expected_natural": "can you run ... for me",
        "expected_command": "git clone https://github.com/user/repo",
        "note": "Command sandwiched in natural speech"
    },
    {
        "input": "alright first do cd space tilde slash projects and then run npm space install",
        "expected_natural": "alright first do ... and then run",
        "expected_command": "cd ~/projects | npm install",
        "note": "Two commands with natural bridge"
    },
    {
        "input": "the quarterly report shows revenue increased by twelve percent year over year",
        "expected_natural": "the quarterly report shows revenue increased by twelve percent year over year",
        "expected_command": "",
        "note": "Business English with 'percent' β€” should NOT trigger"
    },
    {
        "input": "ssh space dash i space tilde slash dot ssh slash id underscore rsa space root at one nine two dot one six eight dot one dot one",
        "expected_natural": "",
        "expected_command": "ssh -i ~/.ssh/id_rsa root@192.168.1.1",
        "note": "Complex SSH command with IP"
    },
    {
        "input": "I think the problem is the firewall so try iptables space dash capital A space INPUT space dash p space tcp space dash dash dport space eight zero eight zero space dash j space ACCEPT",
        "expected_natural": "I think the problem is the firewall so try",
        "expected_command": "iptables -A INPUT -p tcp --dport 8080 -j ACCEPT",
        "note": "Diagnostic reasoning + iptables"
    },
    {
        "input": "according to the handbook all new employees must complete training within two weeks",
        "expected_natural": "according to the handbook all new employees must complete training within two weeks",
        "expected_command": "",
        "note": "HR policy with 'all', 'new', 'two' β€” pure natural"
    },
    {
        "input": "to find large files run find space dot space dash type space f space dash size space plus one hundred capital M",
        "expected_natural": "to find large files run",
        "expected_command": "find . -type f -size +100M",
        "note": "Instruction + find command"
    },
    {
        "input": "curl space dash capital X space all caps POST space dash capital H space quote Content dash Type colon space application slash json quote space https colon slash slash api dot example dot com slash users",
        "expected_natural": "",
        "expected_command": "curl -X POST -H \"Content-Type: application/json\" https://api.example.com/users",
        "note": "Complex curl β€” pure protocol"
    },
]


def classify_words(words, weights, bias, threshold=0.5):
    """Classify each word. Returns list of (word, prob, is_protocol)."""
    results = []
    n = len(words)
    for i, word in enumerate(words):
        ctx_start = max(0, i - 2)
        ctx_end = min(n, i + 3)
        context = words[ctx_start:ctx_end]
        features = extract_features(word, context, i, n)
        logit = float(np.dot(features, weights) + bias)
        prob = 1.0 / (1.0 + np.exp(-logit))
        results.append((word, prob, prob >= threshold))
    return results


def extract_segments(text, weights, bias, threshold=0.5, expansion=2):
    """Run classifier + expansion, return segments."""
    words = text.split()
    if not words:
        return []

    results = classify_words(words, weights, bias, threshold)
    anchors = [r[2] for r in results]

    # Expand
    n = len(words)
    expanded = [False] * n
    for i in range(n):
        if anchors[i]:
            for j in range(max(0, i - expansion), min(n, i + expansion + 1)):
                expanded[j] = True

    # Build segments
    segments = []
    current_kind = "protocol" if expanded[0] else "natural"
    current_words = [words[0]]

    for i in range(1, n):
        kind = "protocol" if expanded[i] else "natural"
        if kind == current_kind:
            current_words.append(words[i])
        else:
            segments.append({"kind": current_kind, "text": " ".join(current_words)})
            current_kind = kind
            current_words = [words[i]]
    segments.append({"kind": current_kind, "text": " ".join(current_words)})

    return segments


def call_inference(prompt, model_id=MODEL_ID, system_prompt=SYSTEM_PROMPT, timeout=30):
    """Call talkie-dev inference generate and return the output."""
    cmd = [
        "talkie-dev", "inference", "generate", prompt,
        "--model", model_id,
        "--system", system_prompt,
        "--temp", "0",
        "--tokens", "120",
        "--json",
    ]
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
            cwd=str(Path(__file__).parent.parent)
        )
        if result.returncode != 0:
            return None, f"exit {result.returncode}: {result.stderr.strip()}"
        # Output is JSON β€” parse and extract text field
        try:
            data = json.loads(result.stdout.strip())
            text = data.get("text", "").strip()
            # Strip <think>...</think> tags from Qwen3 reasoning
            import re
            text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
            # Also strip unclosed <think> tags
            text = re.sub(r'<think>.*', '', text, flags=re.DOTALL).strip()
            return text, None
        except json.JSONDecodeError:
            # Fallback: try first line
            lines = result.stdout.strip().split("\n")
            return lines[0].strip() if lines else "", None
    except subprocess.TimeoutExpired:
        return None, "timeout"
    except Exception as e:
        return None, str(e)


def run_test(case, idx, classifier_only=False):
    """Run a single test case through the full chain."""
    text = case["input"]
    note = case["note"]

    print(f"\n{'='*70}")
    print(f"  TEST {idx}: {note}")
    print(f"{'='*70}")
    print(f"  INPUT: {text}")

    # Step 1: Segment
    t0 = time.time()
    segments = extract_segments(text, WEIGHTS, BIAS)
    seg_ms = (time.time() - t0) * 1000

    protocol_segments = [s for s in segments if s["kind"] == "protocol"]
    natural_segments = [s for s in segments if s["kind"] == "natural"]

    print(f"\n  SEGMENTS ({seg_ms:.1f}ms):")
    for s in segments:
        marker = "\033[91mPROTO\033[0m" if s["kind"] == "protocol" else "\033[92mNATRL\033[0m"
        print(f"    [{marker}] {s['text']}")

    has_protocol = len(protocol_segments) > 0
    expected_has_protocol = bool(case["expected_command"])

    if has_protocol != expected_has_protocol:
        if has_protocol:
            print(f"\n  \033[91mFALSE POSITIVE: detected protocol in pure natural text!\033[0m")
        else:
            print(f"\n  \033[93mFALSE NEGATIVE: missed protocol segment!\033[0m")
        return {"pass": False, "reason": "segmentation"}

    if not has_protocol:
        print(f"\n  \033[92mPASS\033[0m β€” correctly identified as pure natural speech (no model call)")
        return {"pass": True, "reason": "natural_passthrough"}

    if classifier_only:
        print(f"\n  [classifier-only mode, skipping model inference]")
        return {"pass": True, "reason": "classifier_only"}

    # Step 2: Send protocol segments to model
    print(f"\n  MODEL INFERENCE:")
    result_parts = []
    total_ms = 0

    for segment in segments:
        if segment["kind"] == "natural":
            result_parts.append(segment["text"])
            continue

        t0 = time.time()
        model_output, error = call_inference(segment["text"])
        infer_ms = (time.time() - t0) * 1000
        total_ms += infer_ms

        if error:
            print(f"    \033[91mERROR\033[0m: {error}")
            result_parts.append(segment["text"])  # fallback
        else:
            print(f"    IN:  {segment['text']}")
            print(f"    OUT: {model_output} ({infer_ms:.0f}ms)")
            result_parts.append(model_output or segment["text"])

    # Step 3: Reassemble
    assembled = " ".join(result_parts).strip()
    expected = case["expected_command"]

    print(f"\n  ASSEMBLED: {assembled}")
    print(f"  EXPECTED:  {expected}")
    print(f"  LATENCY:   {total_ms:.0f}ms (model only)")

    # Fuzzy match β€” check if the command part is present
    # (natural prefix/suffix might be included due to boundary bleed)
    if expected and expected in assembled:
        print(f"\n  \033[92mPASS\033[0m β€” command correctly reconstructed")
        return {"pass": True, "reason": "match"}
    elif expected:
        print(f"\n  \033[93mCHECK\033[0m β€” command may differ, review above")
        return {"pass": None, "reason": "fuzzy"}
    else:
        print(f"\n  \033[92mPASS\033[0m")
        return {"pass": True, "reason": "no_command"}


def main():
    import argparse
    parser = argparse.ArgumentParser(description="Test segment classifier + model chain")
    parser.add_argument("--case", type=int, help="Run specific test case (1-indexed)")
    parser.add_argument("--classifier-only", action="store_true", help="Skip model inference, just test segmentation")
    args = parser.parse_args()

    print("=" * 70)
    print("  FULL CHAIN TEST: Classifier β†’ Segmentation β†’ Model β†’ Assembly")
    if args.classifier_only:
        print("  MODE: classifier-only (no model inference)")
    print("=" * 70)

    cases = TEST_CASES
    if args.case:
        if 1 <= args.case <= len(TEST_CASES):
            cases = [TEST_CASES[args.case - 1]]
        else:
            print(f"Invalid case number. Must be 1-{len(TEST_CASES)}")
            sys.exit(1)

    results = []
    for i, case in enumerate(cases):
        idx = args.case if args.case else i + 1
        result = run_test(case, idx, classifier_only=args.classifier_only)
        results.append(result)

    # Summary
    print(f"\n{'='*70}")
    print(f"  SUMMARY")
    print(f"{'='*70}")
    passed = sum(1 for r in results if r["pass"] is True)
    failed = sum(1 for r in results if r["pass"] is False)
    check = sum(1 for r in results if r["pass"] is None)
    print(f"  Passed: {passed}/{len(results)}")
    if failed: print(f"  Failed: {failed}")
    if check: print(f"  Check:  {check} (review manually)")

    # Breakdown
    natural_ok = sum(1 for r in results if r["reason"] == "natural_passthrough")
    match_ok = sum(1 for r in results if r["reason"] == "match")
    if natural_ok: print(f"  Natural passthrough: {natural_ok}")
    if match_ok: print(f"  Command reconstructed: {match_ok}")


if __name__ == "__main__":
    main()