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.
- parseforge/__init__.py +1 -1
- parseforge/cli/main.py +158 -11
- parseforge/generation.py +95 -23
- parseforge/integration.py +314 -0
- parseforge/naming/__init__.py +18 -3
- parseforge/naming/assemble.py +27 -2
- parseforge/naming/cache.py +4 -3
- parseforge/naming/llm.py +47 -21
- parseforge/naming/prompts.py +14 -0
- parseforge/naming/prompts.yaml +29 -0
- parseforge/naming/providers/__init__.py +4 -0
- parseforge/naming/providers/anthropic.py +105 -0
- parseforge/naming/providers/cost.py +29 -0
- parseforge/naming/providers/deepseek.py +123 -0
- parseforge/naming/providers/errors.py +63 -0
- parseforge/naming/providers/models.py +26 -0
- parseforge/naming/providers/models.yaml +24 -0
- parseforge/naming/providers/text.py +13 -0
- parseforge/naming/resolver.py +50 -13
- parseforge/paths.py +108 -8
- parseforge/pipeline.py +198 -27
- parseforge/promotion.py +475 -1
- parseforge/sampling/__init__.py +3 -0
- parseforge/sampling/backends/__init__.py +3 -0
- parseforge/sampling/backends/netmiko.py +35 -0
- parseforge/{sampling.py → sampling/core.py} +3 -2
- parseforge/validation.py +14 -28
- {parseforge-0.2.1.dist-info → parseforge-0.2.2.dist-info}/METADATA +8 -4
- parseforge-0.2.2.dist-info/RECORD +34 -0
- parseforge-0.2.1.dist-info/RECORD +0 -20
- {parseforge-0.2.1.dist-info → parseforge-0.2.2.dist-info}/WHEEL +0 -0
- {parseforge-0.2.1.dist-info → parseforge-0.2.2.dist-info}/entry_points.txt +0 -0
- {parseforge-0.2.1.dist-info → parseforge-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {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
|
+
__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,
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
10
|
-
from
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
parseforge/naming/__init__.py
CHANGED
|
@@ -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
|
|
4
|
-
|
|
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
|
]
|