parseforge 0.2.1__tar.gz → 0.2.2__tar.gz
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-0.2.1/parseforge.egg-info → parseforge-0.2.2}/PKG-INFO +8 -4
- {parseforge-0.2.1 → parseforge-0.2.2}/README.md +4 -3
- {parseforge-0.2.1 → parseforge-0.2.2}/SPEC.md +9 -7
- parseforge-0.2.2/parseforge/__init__.py +1 -0
- parseforge-0.2.2/parseforge/cli/main.py +202 -0
- parseforge-0.2.2/parseforge/generation.py +112 -0
- parseforge-0.2.2/parseforge/integration.py +314 -0
- parseforge-0.2.2/parseforge/naming/__init__.py +30 -0
- parseforge-0.2.2/parseforge/naming/assemble.py +60 -0
- {parseforge-0.2.1 → parseforge-0.2.2}/parseforge/naming/cache.py +4 -3
- parseforge-0.2.2/parseforge/naming/llm.py +88 -0
- parseforge-0.2.2/parseforge/naming/prompts.py +14 -0
- parseforge-0.2.2/parseforge/naming/prompts.yaml +29 -0
- parseforge-0.2.2/parseforge/naming/providers/__init__.py +4 -0
- parseforge-0.2.2/parseforge/naming/providers/anthropic.py +105 -0
- parseforge-0.2.2/parseforge/naming/providers/cost.py +29 -0
- parseforge-0.2.2/parseforge/naming/providers/deepseek.py +123 -0
- parseforge-0.2.2/parseforge/naming/providers/errors.py +63 -0
- parseforge-0.2.2/parseforge/naming/providers/models.py +26 -0
- parseforge-0.2.2/parseforge/naming/providers/models.yaml +24 -0
- parseforge-0.2.2/parseforge/naming/providers/text.py +13 -0
- parseforge-0.2.2/parseforge/naming/resolver.py +82 -0
- parseforge-0.2.2/parseforge/paths.py +168 -0
- parseforge-0.2.2/parseforge/pipeline.py +220 -0
- parseforge-0.2.2/parseforge/promotion.py +517 -0
- parseforge-0.2.2/parseforge/sampling/__init__.py +3 -0
- parseforge-0.2.2/parseforge/sampling/backends/__init__.py +3 -0
- parseforge-0.2.2/parseforge/sampling/backends/netmiko.py +35 -0
- parseforge-0.2.1/parseforge/sampling.py → parseforge-0.2.2/parseforge/sampling/core.py +3 -2
- parseforge-0.2.2/parseforge/validation.py +36 -0
- {parseforge-0.2.1 → parseforge-0.2.2/parseforge.egg-info}/PKG-INFO +8 -4
- parseforge-0.2.2/parseforge.egg-info/SOURCES.txt +39 -0
- {parseforge-0.2.1 → parseforge-0.2.2}/parseforge.egg-info/requires.txt +3 -0
- {parseforge-0.2.1 → parseforge-0.2.2}/pyproject.toml +11 -1
- parseforge-0.2.1/parseforge/__init__.py +0 -1
- parseforge-0.2.1/parseforge/cli/main.py +0 -55
- parseforge-0.2.1/parseforge/generation.py +0 -40
- parseforge-0.2.1/parseforge/naming/__init__.py +0 -15
- parseforge-0.2.1/parseforge/naming/assemble.py +0 -35
- parseforge-0.2.1/parseforge/naming/llm.py +0 -62
- parseforge-0.2.1/parseforge/naming/resolver.py +0 -45
- parseforge-0.2.1/parseforge/paths.py +0 -68
- parseforge-0.2.1/parseforge/pipeline.py +0 -49
- parseforge-0.2.1/parseforge/promotion.py +0 -43
- parseforge-0.2.1/parseforge/validation.py +0 -50
- parseforge-0.2.1/parseforge.egg-info/SOURCES.txt +0 -25
- {parseforge-0.2.1 → parseforge-0.2.2}/LICENSE +0 -0
- {parseforge-0.2.1 → parseforge-0.2.2}/MANIFEST.in +0 -0
- {parseforge-0.2.1 → parseforge-0.2.2}/parseforge/cli/__init__.py +0 -0
- {parseforge-0.2.1 → parseforge-0.2.2}/parseforge.egg-info/dependency_links.txt +0 -0
- {parseforge-0.2.1 → parseforge-0.2.2}/parseforge.egg-info/entry_points.txt +0 -0
- {parseforge-0.2.1 → parseforge-0.2.2}/parseforge.egg-info/top_level.txt +0 -0
- {parseforge-0.2.1 → parseforge-0.2.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: parseforge
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: LLM-driven pipeline that forges, validates, and promotes TextFSM templates from network CLI output
|
|
5
5
|
Author-email: Tuyen Mathew Duong <tuyen@geekstrident.com>
|
|
6
6
|
Maintainer-email: Tuyen Mathew Duong <tuyen@geekstrident.com>
|
|
@@ -26,6 +26,9 @@ License-File: LICENSE
|
|
|
26
26
|
Requires-Dist: click>=8.1
|
|
27
27
|
Requires-Dist: PyYAML>=6.0
|
|
28
28
|
Requires-Dist: textfsm>=1.1.0
|
|
29
|
+
Requires-Dist: anthropic>=0.40.0
|
|
30
|
+
Requires-Dist: openai>=1.0.0
|
|
31
|
+
Requires-Dist: textfsm-ai>=0.5.1
|
|
29
32
|
Provides-Extra: sampling
|
|
30
33
|
Requires-Dist: netmiko>=4.0; extra == "sampling"
|
|
31
34
|
Provides-Extra: dev
|
|
@@ -55,11 +58,12 @@ parseforge/
|
|
|
55
58
|
promotion.py authoritative promotion gate + drift status (SPEC §3.3, §5 steps 9-10)
|
|
56
59
|
pipeline.py orchestrates the above; Mode LOOP (MVP) vs Mode BATCH (SPEC §4)
|
|
57
60
|
cli/ `parseforge` command-line entry point
|
|
58
|
-
|
|
59
|
-
store/ runtime template tree: trials/ -> integration/ -> authoritative/
|
|
60
|
-
(generated at runtime, not checked in — see .gitignore)
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
The runtime template tree (trials/ -> integration/ -> authoritative/) is generated at
|
|
64
|
+
`~/.parseforge/tests` by default (see `paths.DEFAULT_STORE_ROOT`), not checked into
|
|
65
|
+
this repo.
|
|
66
|
+
|
|
63
67
|
`naming.py` and `paths.py` are implemented and tested. `sampling.py`, `generation.py`,
|
|
64
68
|
`validation.py`, `promotion.py`, and `pipeline.py` are stub interfaces — the SPEC.md
|
|
65
69
|
section cited in each docstring is the next thing to implement.
|
|
@@ -17,11 +17,12 @@ parseforge/
|
|
|
17
17
|
promotion.py authoritative promotion gate + drift status (SPEC §3.3, §5 steps 9-10)
|
|
18
18
|
pipeline.py orchestrates the above; Mode LOOP (MVP) vs Mode BATCH (SPEC §4)
|
|
19
19
|
cli/ `parseforge` command-line entry point
|
|
20
|
-
|
|
21
|
-
store/ runtime template tree: trials/ -> integration/ -> authoritative/
|
|
22
|
-
(generated at runtime, not checked in — see .gitignore)
|
|
23
20
|
```
|
|
24
21
|
|
|
22
|
+
The runtime template tree (trials/ -> integration/ -> authoritative/) is generated at
|
|
23
|
+
`~/.parseforge/tests` by default (see `paths.DEFAULT_STORE_ROOT`), not checked into
|
|
24
|
+
this repo.
|
|
25
|
+
|
|
25
26
|
`naming.py` and `paths.py` are implemented and tested. `sampling.py`, `generation.py`,
|
|
26
27
|
`validation.py`, `promotion.py`, and `pipeline.py` are stub interfaces — the SPEC.md
|
|
27
28
|
section cited in each docstring is the next thing to implement.
|
|
@@ -48,16 +48,18 @@ This mirrors the candidate → staging → production pattern used for ML model
|
|
|
48
48
|
|
|
49
49
|
Shared path prefix under all three tiers:
|
|
50
50
|
```
|
|
51
|
-
<vendor>/<device-family>/<os>/<
|
|
51
|
+
<vendor>/<device-family>/<os>/<cli-name>/
|
|
52
52
|
```
|
|
53
|
-
e.g. `cisco/catalyst9200/ios-xe/
|
|
53
|
+
e.g. `cisco/catalyst9200/ios-xe/show-interface-var1-status/`
|
|
54
54
|
|
|
55
55
|
Note: use `ios-xe` / `nx-os` (hyphenated) rather than `xe` / `ios` alone once multiple Cisco OS families share the tree — `xe` alone is ambiguous without the `ios-` prefix once IOS classic, IOS XE, and IOS XR all live under `cisco/`.
|
|
56
56
|
|
|
57
|
+
Deliberately no `<version>` segment in the path: a cli-name's output structure usually doesn't change across minor OS versions, and when it legitimately does, that's exactly the variance §5 step 8's group clustering is built to catch — lumping versions together under one path gives more evidence per group instead of silently fragmenting it across per-version directories. The OS version a trial was sampled from is recorded per trial instead, in that trial's `summary.json` (`command_info.version`).
|
|
58
|
+
|
|
57
59
|
### 3.1 `trials/`
|
|
58
60
|
|
|
59
61
|
```
|
|
60
|
-
trials/<vendor>/<family>/<os>/<
|
|
62
|
+
trials/<vendor>/<family>/<os>/<cli-name>/
|
|
61
63
|
<yyyymmdd-HHMMSS-shortid>/
|
|
62
64
|
input.txt
|
|
63
65
|
raw-llm-response.txt
|
|
@@ -90,7 +92,7 @@ Keep the timestamp+shortid directories (not `result1..N`) — chronological orde
|
|
|
90
92
|
### 3.2 `integration/` (no human review yet)
|
|
91
93
|
|
|
92
94
|
```
|
|
93
|
-
integration/<vendor>/<family>/<os>/<
|
|
95
|
+
integration/<vendor>/<family>/<os>/<cli-name>/
|
|
94
96
|
common-result/
|
|
95
97
|
template.textfsm ← the winning candidate, copied from trials/
|
|
96
98
|
selection-report.json ← which trials were considered, why this one won
|
|
@@ -106,7 +108,7 @@ integration/<vendor>/<family>/<os>/<version>/<cli-name>/
|
|
|
106
108
|
### 3.3 `authoritative/` (approved via human review, or confidence-gated auto-promotion)
|
|
107
109
|
|
|
108
110
|
```
|
|
109
|
-
authoritative/<vendor>/<family>/<os>/<
|
|
111
|
+
authoritative/<vendor>/<family>/<os>/<cli-name>/
|
|
110
112
|
template.textfsm ← current approved template
|
|
111
113
|
artifact/ ← carried over from the winning integration candidate
|
|
112
114
|
history/
|
|
@@ -139,7 +141,7 @@ Promotion from `integration/common-result` to `authoritative/` should default to
|
|
|
139
141
|
|
|
140
142
|
1. **Input intake** — device OS/version/family, auth, command list, mode selection.
|
|
141
143
|
2. **Name generation** — tokenize each command → canonical `cli-name` per §2.
|
|
142
|
-
3. **Path resolution** — compute `<vendor>/<device-family>/<os>/<
|
|
144
|
+
3. **Path resolution** — compute `<vendor>/<device-family>/<os>/<cli-name>/` per §3.
|
|
143
145
|
4. **Sampling** — connect (Netmiko/similar), run command(s), capture raw output → `trials/.../<run-id>/input.txt`.
|
|
144
146
|
5. **Generation** — send `input.txt` (+ prior context if Mode 1) to LLM → `raw-llm-response.txt`, `usage.txt`.
|
|
145
147
|
6. **Extraction & cleanup** — pull template from response → `raw-template` → cleaned `template.textfsm`.
|
|
@@ -167,7 +169,7 @@ Where a fourth tier is tempting but better handled as **metadata instead of a ne
|
|
|
167
169
|
|
|
168
170
|
## 7. Open Questions for Next Iteration
|
|
169
171
|
|
|
170
|
-
- Do you want a **registry/index file** (e.g. `catalog.json`) at the repo root listing every `<vendor>/<family>/<os>/<
|
|
172
|
+
- Do you want a **registry/index file** (e.g. `catalog.json`) at the repo root listing every `<vendor>/<family>/<os>/<cli-name>` combination that exists, plus its authoritative status, for fast lookup without walking the filesystem?
|
|
171
173
|
- What **confidence threshold** (match-rate %, sample count minimum) should gate auto-promotion vs. human review in step 9 — worth making this configurable per-project rather than hardcoded?
|
|
172
174
|
- Should `recognizers.txt` support **one-of-many matching** from day one (per the multi-variant note in §6), or is that a v2 concern?
|
|
173
175
|
- What's the **LLM provider/model** for generation — worth pinning per-project so `usage.txt` costs are comparable across runs?
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.2"
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""parseforge CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from parseforge import naming
|
|
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
|
+
|
|
25
|
+
|
|
26
|
+
@click.group()
|
|
27
|
+
@click.version_option(package_name="parseforge")
|
|
28
|
+
def main() -> None:
|
|
29
|
+
"""ParseForge — forge, validate, and promote TextFSM templates from CLI output."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@main.command("name")
|
|
33
|
+
@click.option("--vendor", required=True)
|
|
34
|
+
@click.option("--family", required=True)
|
|
35
|
+
@click.option("--os", "os_", required=True)
|
|
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
|
+
)
|
|
54
|
+
@click.argument("command", nargs=-1, required=True)
|
|
55
|
+
def name_cmd(
|
|
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, ...],
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Print the canonical cli-name for a raw CLI COMMAND.
|
|
66
|
+
|
|
67
|
+
Looked up in the local cache first; only sent to the selected --provider
|
|
68
|
+
on a cache miss.
|
|
69
|
+
"""
|
|
70
|
+
context = naming.CliContext(vendor=vendor, family=family, os=os_, version=version)
|
|
71
|
+
builder = _build_regex_builder(provider, api_key, model)
|
|
72
|
+
click.echo(naming.cli_name(" ".join(command), context, builder=builder))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@main.command("run")
|
|
76
|
+
@click.option("--vendor", required=True)
|
|
77
|
+
@click.option("--family", required=True)
|
|
78
|
+
@click.option("--os", "os_", required=True)
|
|
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)
|
|
135
|
+
@click.argument("command", nargs=-1, required=True)
|
|
136
|
+
def run_cmd(
|
|
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, ...],
|
|
156
|
+
) -> None:
|
|
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
|
+
)
|
|
185
|
+
|
|
186
|
+
result = run_command_pipeline(
|
|
187
|
+
" ".join(command),
|
|
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,
|
|
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}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
main()
|
|
@@ -0,0 +1,112 @@
|
|
|
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.
|
|
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).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
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
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class GenerationResult:
|
|
52
|
+
"""Structured result of generating + DSL-compiling a template from a sample."""
|
|
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)
|
|
64
|
+
|
|
65
|
+
|
|
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``, ...).
|
|
80
|
+
|
|
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))
|
|
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
|
+
)
|
|
95
|
+
|
|
96
|
+
gen_stage = (debug.get("generation_pipeline") or {}).get("last_stage") or {}
|
|
97
|
+
gen_metadata = gen_stage.get("metadata") or {}
|
|
98
|
+
|
|
99
|
+
dsl = (debug.get("dsl_pipeline") or {}).get("dsl") or {}
|
|
100
|
+
|
|
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
|
+
)
|