parseforge 0.2.1__py3-none-any.whl → 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. parseforge/__init__.py +1 -1
  2. parseforge/cli/main.py +158 -11
  3. parseforge/generation.py +95 -23
  4. parseforge/integration.py +314 -0
  5. parseforge/naming/__init__.py +18 -3
  6. parseforge/naming/assemble.py +27 -2
  7. parseforge/naming/cache.py +4 -3
  8. parseforge/naming/llm.py +47 -21
  9. parseforge/naming/prompts.py +14 -0
  10. parseforge/naming/prompts.yaml +29 -0
  11. parseforge/naming/providers/__init__.py +4 -0
  12. parseforge/naming/providers/anthropic.py +105 -0
  13. parseforge/naming/providers/cost.py +29 -0
  14. parseforge/naming/providers/deepseek.py +123 -0
  15. parseforge/naming/providers/errors.py +63 -0
  16. parseforge/naming/providers/models.py +26 -0
  17. parseforge/naming/providers/models.yaml +24 -0
  18. parseforge/naming/providers/text.py +13 -0
  19. parseforge/naming/resolver.py +50 -13
  20. parseforge/paths.py +108 -8
  21. parseforge/pipeline.py +198 -27
  22. parseforge/promotion.py +475 -1
  23. parseforge/sampling/__init__.py +3 -0
  24. parseforge/sampling/backends/__init__.py +3 -0
  25. parseforge/sampling/backends/netmiko.py +35 -0
  26. parseforge/{sampling.py → sampling/core.py} +3 -2
  27. parseforge/validation.py +14 -28
  28. {parseforge-0.2.1.dist-info → parseforge-0.2.2.dist-info}/METADATA +8 -4
  29. parseforge-0.2.2.dist-info/RECORD +34 -0
  30. parseforge-0.2.1.dist-info/RECORD +0 -20
  31. {parseforge-0.2.1.dist-info → parseforge-0.2.2.dist-info}/WHEEL +0 -0
  32. {parseforge-0.2.1.dist-info → parseforge-0.2.2.dist-info}/entry_points.txt +0 -0
  33. {parseforge-0.2.1.dist-info → parseforge-0.2.2.dist-info}/licenses/LICENSE +0 -0
  34. {parseforge-0.2.1.dist-info → parseforge-0.2.2.dist-info}/top_level.txt +0 -0
parseforge/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.2.1"
1
+ __version__ = "0.2.2"
parseforge/cli/main.py CHANGED
@@ -6,6 +6,22 @@ import click
6
6
 
7
7
  from parseforge import naming
8
8
 
9
+ _BUILDERS: dict[str, type[naming.RegexBuilder]] = {
10
+ "anthropic": naming.AnthropicRegexBuilder,
11
+ "deepseek": naming.DeepSeekRegexBuilder,
12
+ }
13
+
14
+
15
+ def _build_regex_builder(
16
+ provider: str, api_key: str | None, model: str | None
17
+ ) -> naming.RegexBuilder:
18
+ kwargs: dict[str, str] = {}
19
+ if api_key is not None:
20
+ kwargs["api_key"] = api_key
21
+ if model is not None:
22
+ kwargs["model"] = model
23
+ return _BUILDERS[provider](**kwargs)
24
+
9
25
 
10
26
  @click.group()
11
27
  @click.version_option(package_name="parseforge")
@@ -18,19 +34,42 @@ def main() -> None:
18
34
  @click.option("--family", required=True)
19
35
  @click.option("--os", "os_", required=True)
20
36
  @click.option("--version", required=True)
37
+ @click.option(
38
+ "--provider",
39
+ type=click.Choice(sorted(_BUILDERS)),
40
+ default="anthropic",
41
+ show_default=True,
42
+ )
43
+ @click.option(
44
+ "--api-key",
45
+ default=None,
46
+ help="Provider API key. Defaults to that provider's own API key environment "
47
+ "variable (ANTHROPIC_API_KEY, DEEPSEEK_API_KEY); only needed on a cache miss.",
48
+ )
49
+ @click.option(
50
+ "--model",
51
+ default=None,
52
+ help="Defaults to the selected provider's own default model.",
53
+ )
21
54
  @click.argument("command", nargs=-1, required=True)
22
55
  def name_cmd(
23
- vendor: str, family: str, os_: str, version: str, command: tuple[str, ...]
56
+ vendor: str,
57
+ family: str,
58
+ os_: str,
59
+ version: str,
60
+ provider: str,
61
+ api_key: str | None,
62
+ model: str | None,
63
+ command: tuple[str, ...],
24
64
  ) -> None:
25
65
  """Print the canonical cli-name for a raw CLI COMMAND.
26
66
 
27
- Looked up in the local cache first; only sent to an LLM (via a
28
- RegexBuilder) on a cache miss. No RegexBuilder is wired into the
29
- scaffold yet, so this only succeeds for commands already in
30
- ~/.parseforge/.cli-name.json until one is implemented.
67
+ Looked up in the local cache first; only sent to the selected --provider
68
+ on a cache miss.
31
69
  """
32
70
  context = naming.CliContext(vendor=vendor, family=family, os=os_, version=version)
33
- click.echo(naming.cli_name(" ".join(command), context))
71
+ builder = _build_regex_builder(provider, api_key, model)
72
+ click.echo(naming.cli_name(" ".join(command), context, builder=builder))
34
73
 
35
74
 
36
75
  @main.command("run")
@@ -38,17 +77,125 @@ def name_cmd(
38
77
  @click.option("--family", required=True)
39
78
  @click.option("--os", "os_", required=True)
40
79
  @click.option("--version", required=True)
80
+ @click.option("--host", required=True, help="Device host/IP to sample from.")
81
+ @click.option("--username", required=True)
82
+ @click.option(
83
+ "--password",
84
+ required=True,
85
+ envvar="PARSEFORGE_DEVICE_PASSWORD",
86
+ help="Device password. Defaults to the PARSEFORGE_DEVICE_PASSWORD "
87
+ "environment variable.",
88
+ )
89
+ @click.option(
90
+ "--device-type",
91
+ required=True,
92
+ help='Netmiko device_type, e.g. "cisco_ios".',
93
+ )
94
+ @click.option(
95
+ "--naming-provider",
96
+ type=click.Choice(sorted(_BUILDERS)),
97
+ default="anthropic",
98
+ show_default=True,
99
+ )
100
+ @click.option(
101
+ "--naming-api-key",
102
+ default=None,
103
+ help="API key for the naming LLM call. Defaults to that provider's own "
104
+ "API key environment variable; only needed on a cache miss.",
105
+ )
106
+ @click.option(
107
+ "--naming-model",
108
+ default=None,
109
+ help="Defaults to the naming provider's own default model.",
110
+ )
111
+ @click.option(
112
+ "--generation-provider",
113
+ required=True,
114
+ help="LLM provider for template generation (textfsm-ai's own registry, "
115
+ 'e.g. "anthropic", "deepseek").',
116
+ )
117
+ @click.option(
118
+ "--generation-api-key",
119
+ required=True,
120
+ help="API key for the template-generation LLM call.",
121
+ )
122
+ @click.option(
123
+ "--generation-model",
124
+ required=True,
125
+ help="Model for the template-generation LLM call.",
126
+ )
127
+ @click.option(
128
+ "--store-root",
129
+ default=None,
130
+ help="Root directory for trial output. Defaults to ~/.parseforge/tests.",
131
+ )
132
+ @click.option("--project", default=None)
133
+ @click.option("--email", default=None)
134
+ @click.option("--description", default=None)
41
135
  @click.argument("command", nargs=-1, required=True)
42
136
  def run_cmd(
43
- vendor: str, family: str, os_: str, version: str, command: tuple[str, ...]
137
+ vendor: str,
138
+ family: str,
139
+ os_: str,
140
+ version: str,
141
+ host: str,
142
+ username: str,
143
+ password: str,
144
+ device_type: str,
145
+ naming_provider: str,
146
+ naming_api_key: str | None,
147
+ naming_model: str | None,
148
+ generation_provider: str,
149
+ generation_api_key: str,
150
+ generation_model: str,
151
+ store_root: str | None,
152
+ project: str | None,
153
+ email: str | None,
154
+ description: str | None,
155
+ command: tuple[str, ...],
44
156
  ) -> None:
45
- """Run the pipeline for a single CLI COMMAND against a device (§5)."""
46
- from parseforge.pipeline import run_command_pipeline
157
+ """Run a single trial: sample COMMAND from a device, generate a
158
+ TextFSM template for it, and write the result under a trial
159
+ directory (SPEC.md §5 steps 1-7)."""
160
+ from pathlib import Path
161
+
162
+ from parseforge import paths
163
+ from parseforge.pipeline import (
164
+ LLMProviderConfig,
165
+ TrialMetadata,
166
+ run_command_pipeline,
167
+ )
168
+ from parseforge.sampling import DeviceConnection
169
+ from parseforge.sampling.backends import NetmikoSampler
170
+
171
+ context = naming.CliContext(vendor=vendor, family=family, os=os_, version=version)
172
+ connection = DeviceConnection(
173
+ host=host, username=username, password=password, device_type=device_type
174
+ )
175
+ naming_builder = _build_regex_builder(naming_provider, naming_api_key, naming_model)
176
+ generation_config = LLMProviderConfig(
177
+ provider=generation_provider, api_key=generation_api_key, model=generation_model
178
+ )
179
+ metadata = TrialMetadata(
180
+ project=project,
181
+ username=username,
182
+ email=email,
183
+ description=description,
184
+ )
47
185
 
48
- run_command_pipeline(
186
+ result = run_command_pipeline(
49
187
  " ".join(command),
50
- key_fields={"vendor": vendor, "family": family, "os": os_, "version": version},
188
+ context,
189
+ connection,
190
+ naming_builder,
191
+ NetmikoSampler(),
192
+ generation_config,
193
+ store_root=Path(store_root) if store_root else paths.DEFAULT_STORE_ROOT,
194
+ metadata=metadata,
51
195
  )
196
+ click.echo(f"cli_name : {result.cli_name}")
197
+ click.echo(f"passed : {result.passed}")
198
+ click.echo(f"run_dir : {result.run_dir}")
52
199
 
53
200
 
54
201
  if __name__ == "__main__":
parseforge/generation.py CHANGED
@@ -1,40 +1,112 @@
1
- """Generation stage — LLM call and template extraction (SPEC.md §5 steps 5-6).
1
+ """Generation stage — LLM call, extraction, cleanup, and DSL compile
2
+ (SPEC.md §5 steps 5-6), delegated to textfsm-ai's delivery pipeline
3
+ rather than reimplemented here.
2
4
 
3
- Writes raw-llm-response.txt / usage.txt (step 5), then extracts and
4
- cleans the template into raw-template / template.textfsm (step 6).
5
+ textfsm-ai's run_pipeline() already covers extraction + cleanup + DSL
6
+ compilation (canonical template, readable DSL, recognizers) in one
7
+ call — see https://github.com/Geeks-Trident-LLC/textfsm-ai. Always run
8
+ in debug mode with JSON output, for full usage/timing/pipeline detail
9
+ (see GenerationResult.raw).
5
10
  """
6
11
 
7
12
  from __future__ import annotations
8
13
 
9
- from dataclasses import dataclass
10
- from typing import Protocol
14
+ import json
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from textfsm_ai import run_pipeline
19
+
20
+ _SENSITIVE_KEYS = {"api_key"}
21
+
22
+
23
+ def _redact(value: Any) -> Any:
24
+ """Strip credential-shaped fields from a parsed debug payload.
25
+
26
+ textfsm-ai's Serializable.to_dict()/to_json() run plain
27
+ dataclasses.asdict() — masking (mask_middle()) only happens in its
28
+ *text*-mode output, not JSON. Debug mode's JSON embeds the raw API
29
+ key unmasked, so it must be stripped before this result is stored
30
+ or logged anywhere.
31
+ """
32
+ if isinstance(value, dict):
33
+ return {
34
+ k: "<redacted>" if k in _SENSITIVE_KEYS else _redact(v)
35
+ for k, v in value.items()
36
+ }
37
+ if isinstance(value, list):
38
+ return [_redact(v) for v in value]
39
+ return value
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class TokenUsage:
44
+ input_tokens: int
45
+ output_tokens: int
46
+ total_tokens: int
47
+ estimated_cost: float
11
48
 
12
49
 
13
50
  @dataclass(frozen=True)
14
51
  class GenerationResult:
15
- raw_response: str
16
- usage: str
17
- raw_template: str
18
- template: str
52
+ """Structured result of generating + DSL-compiling a template from a sample."""
19
53
 
54
+ template: str # canonical, DSL-compiled TextFSM template
55
+ raw_template: str # pre-cleanup template, as the LLM returned it
56
+ readable_dsl: str
57
+ recognizers: list[str]
58
+ records: list[dict[str, str]]
59
+ usage: TokenUsage
60
+ duration_ms: float
61
+ ready: bool
62
+ reason: str
63
+ raw: dict[str, Any] = field(repr=False)
20
64
 
21
- class Generator(Protocol):
22
- """An LLM backend capable of producing a TextFSM template from sample input.
23
65
 
24
- ``prior_context`` carries additional samples of the same command when
25
- running in Mode 1 (batch) per §4.
26
- """
66
+ def generate(
67
+ sample: str,
68
+ provider: str,
69
+ api_key: str,
70
+ model: str,
71
+ **kwargs: Any,
72
+ ) -> GenerationResult:
73
+ """Run textfsm-ai's full sample -> template -> DSL-compiled pipeline.
74
+
75
+ Always runs in debug mode with JSON output (not overridable — pass
76
+ ``mode``/``as_json`` in ``kwargs`` and this raises, same as calling
77
+ ``run_pipeline()`` twice for the same argument would). ``**kwargs``
78
+ forwards to ``run_pipeline()`` for everything else (``endpoint``,
79
+ ``region``, ``max_tries``, ...).
27
80
 
28
- def generate(
29
- self, input_text: str, prior_context: list[str] | None = None
30
- ) -> GenerationResult: ...
81
+ Never raises for a failed generation — check ``.ready``.
82
+ """
83
+ result = run_pipeline(
84
+ sample, provider, api_key, model, mode="debug", as_json=True, **kwargs
85
+ )
86
+ debug = _redact(json.loads(result.output))
31
87
 
88
+ usage_data = debug.get("usage") or {}
89
+ usage = TokenUsage(
90
+ input_tokens=usage_data.get("input_tokens", 0),
91
+ output_tokens=usage_data.get("output_tokens", 0),
92
+ total_tokens=usage_data.get("total_tokens", 0),
93
+ estimated_cost=usage_data.get("estimated_cost", 0.0),
94
+ )
32
95
 
33
- def extract_template(raw_response: str) -> str:
34
- """Pull the template body out of a raw LLM response (step 6, pre-cleanup)."""
35
- raise NotImplementedError("extraction rule TBD — see SPEC.md §5 step 6")
96
+ gen_stage = (debug.get("generation_pipeline") or {}).get("last_stage") or {}
97
+ gen_metadata = gen_stage.get("metadata") or {}
36
98
 
99
+ dsl = (debug.get("dsl_pipeline") or {}).get("dsl") or {}
37
100
 
38
- def clean_template(raw_template: str) -> str:
39
- """Normalize an extracted template into a valid template.textfsm body."""
40
- raise NotImplementedError("cleanup rule TBD — see SPEC.md §5 step 6")
101
+ return GenerationResult(
102
+ template=dsl.get("canonical") or "",
103
+ raw_template=dsl.get("raw_template") or gen_metadata.get("template") or "",
104
+ readable_dsl=dsl.get("readable") or "",
105
+ recognizers=dsl.get("recognizers") or [],
106
+ records=gen_metadata.get("records") or [],
107
+ usage=usage,
108
+ duration_ms=debug.get("duration_ms", 0.0),
109
+ ready=result.passed,
110
+ reason=result.error,
111
+ raw=debug,
112
+ )
@@ -0,0 +1,314 @@
1
+ """Integration — cluster trials for a cli-name into output-schema groups
2
+ (SPEC.md §3.2, §5 step 8; §6 multi-variant note).
3
+
4
+ Unlike §3.2's original single "common-result" winner, a cli-name's output
5
+ can legitimately vary by hardware/firmware (§6). ``build_integration``
6
+ groups every trial for a cli-name by the field-key signature of its parsed
7
+ records, and within a group further distinguishes byte-identical template
8
+ variants from divergent ones. This is pure evidence gathering — no
9
+ promotion decision is made or stored here (see :mod:`parseforge.promotion`
10
+ for that, which reads ``reference.json`` back out).
11
+
12
+ Only trials whose own summary.json says ``passed: true`` are eligible —
13
+ see :func:`_trial_passed`. A trial can fail for reasons a template/sample
14
+ re-parse alone wouldn't catch (e.g. a truncated, not-ready generation
15
+ whose partial output still happens to self-parse), so that stored verdict
16
+ is trusted rather than re-derived.
17
+
18
+ ``build_integration`` is a full rebuild each call: every trial run-dir for
19
+ the cli-name is rescanned and ``reference.json`` is regenerated from
20
+ scratch, rather than incrementally diffed against its prior contents. A new
21
+ trial is only ever compared against the (small) set of distinct groups
22
+ already found, not every past trial, so this costs the same either way and
23
+ avoids partial-state bugs. Group/variant numbering stays deterministic
24
+ because trial run-dirs are iterated in chronological run-id order.
25
+
26
+ ``reference.json`` is ``{total_case_count, total_passed_case_count,
27
+ groups: {group_id: {keys, sample_path, group_case_count, variants}}}`` —
28
+ every one of those counts is a snapshot from that same rebuild (as fresh
29
+ as everything else in the file), included so match rate is readable
30
+ without re-globbing trials/. :func:`build_reference_summary` turns those
31
+ counts into ratios across every cli-name in one report;
32
+ :mod:`parseforge.promotion` still recomputes its own count independently
33
+ at decision time rather than trusting either snapshot.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ from dataclasses import asdict, dataclass, field
40
+ from pathlib import Path
41
+ from typing import Any
42
+
43
+ from parseforge import paths, validation
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ReferenceVariant:
48
+ template_path: str
49
+ exact_template_count: int
50
+ exact_records_count: int
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ReferenceGroup:
55
+ keys: list[str]
56
+ sample_path: str
57
+ variants: dict[str, ReferenceVariant] = field(default_factory=dict)
58
+
59
+ @property
60
+ def group_case_count(self) -> int:
61
+ """Total trials belonging to this group — the sum of every
62
+ variant's exact_template_count. A property (not a stored field) so
63
+ it can never drift out of sync with ``variants``."""
64
+ return sum(v.exact_template_count for v in self.variants.values())
65
+
66
+
67
+ Reference = dict[str, ReferenceGroup]
68
+
69
+
70
+ def _load_reference(path: Path) -> Reference:
71
+ if not path.exists():
72
+ return {}
73
+ raw = json.loads(path.read_text(encoding="utf-8"))
74
+ reference: Reference = {}
75
+ for group_id, group_raw in raw.get("groups", {}).items():
76
+ variants = {
77
+ variant_id: ReferenceVariant(**variant_raw)
78
+ for variant_id, variant_raw in group_raw["variants"].items()
79
+ }
80
+ reference[group_id] = ReferenceGroup(
81
+ keys=group_raw["keys"],
82
+ sample_path=group_raw["sample_path"],
83
+ variants=variants,
84
+ )
85
+ return reference
86
+
87
+
88
+ def _save_reference(
89
+ path: Path,
90
+ reference: Reference,
91
+ total_case_count: int,
92
+ total_passed_case_count: int,
93
+ ) -> None:
94
+ """Write reference.json as
95
+ ``{total_case_count, total_passed_case_count, groups}``.
96
+
97
+ Both counts are snapshots from the same trial scan that produced
98
+ ``groups`` below them — as fresh as the rest of this file, and
99
+ included purely so a reader (or :func:`build_reference_summary`) can
100
+ compute match rate without re-globbing trials/. Promotion decisions
101
+ still recompute their own count independently at decision time (see
102
+ :mod:`parseforge.promotion`) rather than trusting either snapshot.
103
+
104
+ ``total_passed_case_count`` (trials whose own summary.json says
105
+ passed) is normally close to the sum of every group's
106
+ group_case_count, but isn't guaranteed identical to it — a passed
107
+ trial can still fall out of clustering if its derive/samples files
108
+ are missing or its template unexpectedly fails to re-parse. Reporting
109
+ both numbers surfaces that gap instead of hiding it.
110
+ """
111
+ path.parent.mkdir(parents=True, exist_ok=True)
112
+ serializable = {
113
+ "total_case_count": total_case_count,
114
+ "total_passed_case_count": total_passed_case_count,
115
+ "groups": {
116
+ group_id: {
117
+ "keys": group.keys,
118
+ "sample_path": group.sample_path,
119
+ "group_case_count": group.group_case_count,
120
+ "variants": {
121
+ variant_id: asdict(variant)
122
+ for variant_id, variant in group.variants.items()
123
+ },
124
+ }
125
+ for group_id, group in reference.items()
126
+ },
127
+ }
128
+ path.write_text(
129
+ json.dumps(serializable, indent=2, sort_keys=True), encoding="utf-8"
130
+ )
131
+
132
+
133
+ def build_integration(store_root: Path, key: paths.DeviceKey) -> Reference:
134
+ """Rebuild the group clustering for ``key.cli_name`` from every trial
135
+ currently under ``trials/<vendor>/.../<cli-name>/``, and persist it to
136
+ ``integration/.../<cli-name>/reference.json``.
137
+ """
138
+ trials_dir = paths.tier_path(store_root, paths.TRIALS, key)
139
+ integration_dir = paths.integration_dir(store_root, key)
140
+ integration_dir.mkdir(parents=True, exist_ok=True)
141
+
142
+ reference: Reference = {}
143
+ if not trials_dir.exists():
144
+ _save_reference(
145
+ integration_dir / "reference.json",
146
+ reference,
147
+ total_case_count=0,
148
+ total_passed_case_count=0,
149
+ )
150
+ return reference
151
+
152
+ run_dirs = sorted(d for d in trials_dir.iterdir() if d.is_dir())
153
+ total_passed_case_count = 0
154
+
155
+ for run_dir in run_dirs:
156
+ if not _trial_passed(run_dir):
157
+ continue
158
+ total_passed_case_count += 1
159
+
160
+ template_path = run_dir / "derive" / "template.textfsm"
161
+ sample_path = run_dir / "samples" / "sample.txt"
162
+ if not template_path.exists() or not sample_path.exists():
163
+ continue
164
+
165
+ template_text = template_path.read_text(encoding="utf-8")
166
+ sample_text = sample_path.read_text(encoding="utf-8")
167
+
168
+ parsed = validation.parse(template_text, sample_text)
169
+ if not parsed.passed or not parsed.records:
170
+ continue
171
+
172
+ record_keys = sorted(parsed.records[0].keys())
173
+ group_id = _find_matching_group(reference, record_keys)
174
+ if group_id is None:
175
+ group_id = f"group{len(reference) + 1}"
176
+ reference[group_id] = ReferenceGroup(
177
+ keys=record_keys, sample_path=str(sample_path), variants={}
178
+ )
179
+
180
+ group = reference[group_id]
181
+ variant_id = _find_matching_variant(group, template_text)
182
+ if variant_id is not None:
183
+ variant = group.variants[variant_id]
184
+ group.variants[variant_id] = ReferenceVariant(
185
+ template_path=variant.template_path,
186
+ exact_template_count=variant.exact_template_count + 1,
187
+ exact_records_count=variant.exact_records_count + len(parsed.records),
188
+ )
189
+ else:
190
+ variant_id = str(len(group.variants) + 1)
191
+ group.variants[variant_id] = ReferenceVariant(
192
+ template_path=str(template_path),
193
+ exact_template_count=1,
194
+ exact_records_count=len(parsed.records),
195
+ )
196
+ dest_name = f"{group_id}-template{variant_id}.textfsm"
197
+ (integration_dir / dest_name).write_text(template_text, encoding="utf-8")
198
+
199
+ _save_reference(
200
+ integration_dir / "reference.json",
201
+ reference,
202
+ total_case_count=len(run_dirs),
203
+ total_passed_case_count=total_passed_case_count,
204
+ )
205
+ return reference
206
+
207
+
208
+ def _trial_passed(run_dir: Path) -> bool:
209
+ """A trial is only clustering material if its own summary.json says it
210
+ passed. This isn't re-derivable from template.textfsm + sample.txt
211
+ alone: a template can be truncated (generation not ready) yet still
212
+ happen to parse its own sample, which the record-schema re-parse below
213
+ wouldn't catch on its own — unlike total_case_count, "did this specific
214
+ past trial succeed" isn't something that goes stale, so trusting the
215
+ stored verdict here is correct, not a shortcut."""
216
+ summary_path = run_dir / "summary.json"
217
+ if not summary_path.exists():
218
+ return False
219
+ summary = json.loads(summary_path.read_text(encoding="utf-8"))
220
+ return bool(summary.get("passed"))
221
+
222
+
223
+ def _find_matching_group(reference: Reference, keys: list[str]) -> str | None:
224
+ for group_id, group in reference.items():
225
+ if group.keys == keys:
226
+ return group_id
227
+ return None
228
+
229
+
230
+ def _find_matching_variant(group: ReferenceGroup, template_text: str) -> str | None:
231
+ for variant_id, variant in group.variants.items():
232
+ existing_text = Path(variant.template_path).read_text(encoding="utf-8")
233
+ if existing_text == template_text:
234
+ return variant_id
235
+ return None
236
+
237
+
238
+ def build_reference_summary(store_root: Path) -> dict[str, Any]:
239
+ """Aggregate every reference.json under the integration tier into one
240
+ cross-cli-name match-rate report — a read-only transform of whatever
241
+ build_integration() has already written, no filesystem rescan of
242
+ trials/. Ratios are 0.0 for any cli-name with total_case_count (or
243
+ total_passed_case_count) == 0, rather than dividing by zero.
244
+
245
+ Every ratio comes in two flavors: ``ratio_of_total`` (share of *every*
246
+ trial attempted, pass or fail) and ``ratio_of_passed`` (share of only
247
+ the trials that actually passed — the only trials group_case_count
248
+ can ever include, since a failed trial can never join a group). The
249
+ "of_total" figure is diluted by raw generation failures; "of_passed"
250
+ isolates template-consistency among the runs that succeeded.
251
+ """
252
+ integration_root = paths.tier_root(store_root, paths.INTEGRATION)
253
+ cases: dict[str, Any] = {}
254
+
255
+ if integration_root.exists():
256
+ for reference_path in sorted(integration_root.rglob("reference.json")):
257
+ case_key = reference_path.parent.relative_to(integration_root).as_posix()
258
+ raw = json.loads(reference_path.read_text(encoding="utf-8"))
259
+ total_case_count = raw.get("total_case_count", 0)
260
+ total_passed_case_count = raw.get("total_passed_case_count", 0)
261
+
262
+ groups_summary = {}
263
+ for group_id, group_raw in raw.get("groups", {}).items():
264
+ group_case_count = group_raw.get("group_case_count", 0)
265
+ variants_summary = {}
266
+ for variant_id, variant_raw in group_raw.get("variants", {}).items():
267
+ exact = variant_raw.get("exact_template_count", 0)
268
+ variants_summary[variant_id] = {
269
+ "ratio_of_group": (
270
+ exact / group_case_count if group_case_count else 0.0
271
+ ),
272
+ "ratio_of_total": (
273
+ exact / total_case_count if total_case_count else 0.0
274
+ ),
275
+ "ratio_of_passed": (
276
+ exact / total_passed_case_count
277
+ if total_passed_case_count
278
+ else 0.0
279
+ ),
280
+ }
281
+ groups_summary[group_id] = {
282
+ "keys": group_raw.get("keys", []),
283
+ "case_count": group_case_count,
284
+ "ratio_of_total": (
285
+ group_case_count / total_case_count if total_case_count else 0.0
286
+ ),
287
+ "ratio_of_passed": (
288
+ group_case_count / total_passed_case_count
289
+ if total_passed_case_count
290
+ else 0.0
291
+ ),
292
+ "variants": variants_summary,
293
+ }
294
+
295
+ cases[case_key] = {
296
+ "total_case_count": total_case_count,
297
+ "total_passed_case_count": total_passed_case_count,
298
+ "groups": groups_summary,
299
+ }
300
+
301
+ return {
302
+ "trial_project": str(paths.tier_root(store_root, paths.TRIALS)),
303
+ "cases": cases,
304
+ }
305
+
306
+
307
+ def write_reference_summary(store_root: Path) -> Path:
308
+ """Write build_reference_summary()'s report to
309
+ integration/reference-summary.json and return its path."""
310
+ summary = build_reference_summary(store_root)
311
+ path = paths.reference_summary_path(store_root)
312
+ path.parent.mkdir(parents=True, exist_ok=True)
313
+ path.write_text(json.dumps(summary, indent=2, sort_keys=True), encoding="utf-8")
314
+ return path
@@ -1,15 +1,30 @@
1
- from .assemble import pattern_to_cli_name
1
+ from .assemble import normalize_pattern, pattern_to_cli_name
2
2
  from .cache import DEFAULT_INDEX_PATH, NameIndex
3
- from .llm import CliContext, RegexBuilder, UnimplementedRegexBuilder, build_prompt
4
- from .resolver import cli_name
3
+ from .llm import (
4
+ CliContext,
5
+ LLMCLIResponse,
6
+ RegexBuilder,
7
+ TokenUsage,
8
+ UnimplementedRegexBuilder,
9
+ build_prompt,
10
+ )
11
+ from .providers import AnthropicRegexBuilder, DeepSeekRegexBuilder
12
+ from .resolver import NamingResolution, cli_name, resolve_cli_name
5
13
 
6
14
  __all__ = [
7
15
  "cli_name",
16
+ "resolve_cli_name",
17
+ "NamingResolution",
8
18
  "CliContext",
9
19
  "RegexBuilder",
10
20
  "UnimplementedRegexBuilder",
21
+ "AnthropicRegexBuilder",
22
+ "DeepSeekRegexBuilder",
23
+ "LLMCLIResponse",
24
+ "TokenUsage",
11
25
  "build_prompt",
12
26
  "NameIndex",
13
27
  "DEFAULT_INDEX_PATH",
14
28
  "pattern_to_cli_name",
29
+ "normalize_pattern",
15
30
  ]