parseforge 0.2.1__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/LICENSE +21 -0
- parseforge-0.2.1/MANIFEST.in +12 -0
- parseforge-0.2.1/PKG-INFO +79 -0
- parseforge-0.2.1/README.md +41 -0
- parseforge-0.2.1/SPEC.md +173 -0
- parseforge-0.2.1/parseforge/__init__.py +1 -0
- parseforge-0.2.1/parseforge/cli/__init__.py +0 -0
- parseforge-0.2.1/parseforge/cli/main.py +55 -0
- parseforge-0.2.1/parseforge/generation.py +40 -0
- parseforge-0.2.1/parseforge/naming/__init__.py +15 -0
- parseforge-0.2.1/parseforge/naming/assemble.py +35 -0
- parseforge-0.2.1/parseforge/naming/cache.py +48 -0
- parseforge-0.2.1/parseforge/naming/llm.py +62 -0
- parseforge-0.2.1/parseforge/naming/resolver.py +45 -0
- parseforge-0.2.1/parseforge/paths.py +68 -0
- parseforge-0.2.1/parseforge/pipeline.py +49 -0
- parseforge-0.2.1/parseforge/promotion.py +43 -0
- parseforge-0.2.1/parseforge/sampling.py +36 -0
- parseforge-0.2.1/parseforge/validation.py +50 -0
- parseforge-0.2.1/parseforge.egg-info/PKG-INFO +79 -0
- parseforge-0.2.1/parseforge.egg-info/SOURCES.txt +25 -0
- parseforge-0.2.1/parseforge.egg-info/dependency_links.txt +1 -0
- parseforge-0.2.1/parseforge.egg-info/entry_points.txt +2 -0
- parseforge-0.2.1/parseforge.egg-info/requires.txt +13 -0
- parseforge-0.2.1/parseforge.egg-info/top_level.txt +1 -0
- parseforge-0.2.1/pyproject.toml +98 -0
- parseforge-0.2.1/setup.cfg +4 -0
parseforge-0.2.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Geeks Trident LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
include LICENSE
|
|
2
|
+
include README.md
|
|
3
|
+
include SPEC.md
|
|
4
|
+
|
|
5
|
+
# Include any YAML/JSON data files inside the package (future-proof)
|
|
6
|
+
recursive-include parseforge *.yaml
|
|
7
|
+
recursive-include parseforge *.json
|
|
8
|
+
|
|
9
|
+
# Exclude tests, docs, generated template store from source distribution
|
|
10
|
+
prune tests
|
|
11
|
+
prune docs
|
|
12
|
+
prune store
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parseforge
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: LLM-driven pipeline that forges, validates, and promotes TextFSM templates from network CLI output
|
|
5
|
+
Author-email: Tuyen Mathew Duong <tuyen@geekstrident.com>
|
|
6
|
+
Maintainer-email: Tuyen Mathew Duong <tuyen@geekstrident.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: textfsm,network automation,cli parsing,template generator,ai,llm,netmiko,device output parsing
|
|
9
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: Information Technology
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
20
|
+
Classifier: Topic :: Text Processing
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Classifier: Operating System :: OS Independent
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: click>=8.1
|
|
27
|
+
Requires-Dist: PyYAML>=6.0
|
|
28
|
+
Requires-Dist: textfsm>=1.1.0
|
|
29
|
+
Provides-Extra: sampling
|
|
30
|
+
Requires-Dist: netmiko>=4.0; extra == "sampling"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: ruff>=0.4.0; extra == "dev"
|
|
35
|
+
Requires-Dist: black>=24.0.0; extra == "dev"
|
|
36
|
+
Requires-Dist: mypy>=1.10.0; extra == "dev"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# ParseForge
|
|
40
|
+
|
|
41
|
+
LLM-driven pipeline that forges, cross-validates, and promotes [TextFSM](https://github.com/google/textfsm)
|
|
42
|
+
templates from network device CLI output.
|
|
43
|
+
|
|
44
|
+
Full design plan: [SPEC.md](SPEC.md).
|
|
45
|
+
|
|
46
|
+
## Layout
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
parseforge/
|
|
50
|
+
naming.py CLI command -> canonical cli-name (SPEC §2)
|
|
51
|
+
paths.py trials/integration/authoritative path resolution (SPEC §3)
|
|
52
|
+
sampling.py device connection + command capture (SPEC §5 step 4)
|
|
53
|
+
generation.py LLM call + template extraction (SPEC §5 steps 5-6)
|
|
54
|
+
validation.py self-validation + cross-validation scoring (SPEC §5 steps 7-8)
|
|
55
|
+
promotion.py authoritative promotion gate + drift status (SPEC §3.3, §5 steps 9-10)
|
|
56
|
+
pipeline.py orchestrates the above; Mode LOOP (MVP) vs Mode BATCH (SPEC §4)
|
|
57
|
+
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
|
+
```
|
|
62
|
+
|
|
63
|
+
`naming.py` and `paths.py` are implemented and tested. `sampling.py`, `generation.py`,
|
|
64
|
+
`validation.py`, `promotion.py`, and `pipeline.py` are stub interfaces — the SPEC.md
|
|
65
|
+
section cited in each docstring is the next thing to implement.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
pip install -e ".[dev,sampling]"
|
|
71
|
+
pytest
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## CLI
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
parseforge name show interface GE1.1 status
|
|
78
|
+
# -> show-interface-var1-status
|
|
79
|
+
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# ParseForge
|
|
2
|
+
|
|
3
|
+
LLM-driven pipeline that forges, cross-validates, and promotes [TextFSM](https://github.com/google/textfsm)
|
|
4
|
+
templates from network device CLI output.
|
|
5
|
+
|
|
6
|
+
Full design plan: [SPEC.md](SPEC.md).
|
|
7
|
+
|
|
8
|
+
## Layout
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
parseforge/
|
|
12
|
+
naming.py CLI command -> canonical cli-name (SPEC §2)
|
|
13
|
+
paths.py trials/integration/authoritative path resolution (SPEC §3)
|
|
14
|
+
sampling.py device connection + command capture (SPEC §5 step 4)
|
|
15
|
+
generation.py LLM call + template extraction (SPEC §5 steps 5-6)
|
|
16
|
+
validation.py self-validation + cross-validation scoring (SPEC §5 steps 7-8)
|
|
17
|
+
promotion.py authoritative promotion gate + drift status (SPEC §3.3, §5 steps 9-10)
|
|
18
|
+
pipeline.py orchestrates the above; Mode LOOP (MVP) vs Mode BATCH (SPEC §4)
|
|
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
|
+
```
|
|
24
|
+
|
|
25
|
+
`naming.py` and `paths.py` are implemented and tested. `sampling.py`, `generation.py`,
|
|
26
|
+
`validation.py`, `promotion.py`, and `pipeline.py` are stub interfaces — the SPEC.md
|
|
27
|
+
section cited in each docstring is the next thing to implement.
|
|
28
|
+
|
|
29
|
+
## Development
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
pip install -e ".[dev,sampling]"
|
|
33
|
+
pytest
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## CLI
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
parseforge name show interface GE1.1 status
|
|
40
|
+
# -> show-interface-var1-status
|
|
41
|
+
```
|
parseforge-0.2.1/SPEC.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# Network CLI → TextFSM Template Generator — Project Plan & Requirements
|
|
2
|
+
|
|
3
|
+
## 1. Project & Repo Naming
|
|
4
|
+
|
|
5
|
+
| Candidate | Repo name (kebab-case) | Rationale |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| **ParseForge** | `parseforge` | Short, memorable, clearly about forging parsers. Available-sounding, no CLI/network jargon lock-in if you expand beyond Cisco later. |
|
|
8
|
+
| **CLIFsm** | `cli-fsm-forge` | Literal — CLI + FSM (finite state machine, what TextFSM templates are). Good for discoverability if someone searches "textfsm" + "cli". |
|
|
9
|
+
| **ShowParse AI** | `showparse-ai` | Leans into the fact that almost every input is a `show` command. Very descriptive, less generic. |
|
|
10
|
+
| **NetFSM** | `netfsm` | Shortest option. Good if this becomes a pip-installable library/CLI tool people type often. |
|
|
11
|
+
|
|
12
|
+
**Recommendation:** `parseforge` as the project name, `parseforge` (or `parseforge-textfsm`) as the repo name. It reads well as a CLI tool name (`parseforge run ...`), isn't locked to Cisco/TextFSM specifically if you extend to other vendors or output formats (e.g., ntc-templates-style YAML, TTP, or Genie parsers) later, and is easy to say out loud.
|
|
13
|
+
|
|
14
|
+
If you want to keep `textfsm-ai` since that's the term you're already using internally/in docs, that's a perfectly reasonable literal alternative — just know it reads as "TextFSM, but AI" rather than as a product name, which is fine for an internal tool but less distinctive if this is ever open-sourced.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 2. CLI-to-Name Translation
|
|
19
|
+
|
|
20
|
+
**Rule:** Tokenize the full command string. Classify each token as **literal** (fixed across all invocations of this command type) or **variable** (an identifier — interface name, VLAN ID, IP, hostname, etc.).
|
|
21
|
+
|
|
22
|
+
**Naming convention — always index variables, even when there's only one:**
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
show version → show-version
|
|
26
|
+
show interface GE1.1 status → show-interface-var1-status
|
|
27
|
+
show interface GE1.1 counters → show-interface-var1-counters
|
|
28
|
+
show ip route 10.0.0.0 → show-ip-route-var1
|
|
29
|
+
show bgp neighbors 10.1.1.1 advertised-routes
|
|
30
|
+
→ show-bgp-neighbors-var1-advertised-routes
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Why always number, even for a single variable (`var1` not `var`):**
|
|
34
|
+
A naming function that omits the index for single-variable commands has to *already know* the final variable count before naming — but that can change: a newer OS version might add an optional second argument to the same command family, or you might discover a command has two variable slots you didn't notice on first sample. Indexing consistently means:
|
|
35
|
+
- The name-generation code is a pure, stateless function of token position — no lookahead needed.
|
|
36
|
+
- No silent renames/migrations later if a command turns out to have more variables than expected.
|
|
37
|
+
- Multi-variable commands (2+) stay unambiguous by construction — `var1`, `var2` in left-to-right order of appearance.
|
|
38
|
+
|
|
39
|
+
This differs slightly from the older convention used in the `ntc-templates` community library (which just drops variable tokens entirely rather than placeholding them — e.g. `cisco_ios_show_interfaces.textfsm` regardless of interface name). That works for them because their catalog is hand-curated and collisions are manually resolved. Since your pipeline is LLM-driven and needs to run unattended, explicit indexed placeholders are safer and self-documenting.
|
|
40
|
+
|
|
41
|
+
**Edge case — literal tokens that look variable-ish:** keywords like `brief`, `status`, `detail`, `counters`, `summary` are still literal (they're fixed sub-command keywords, not user-supplied values) and stay as literal tokens in the name.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 3. Storage Layout — Three-Tier Promotion
|
|
46
|
+
|
|
47
|
+
This mirrors the candidate → staging → production pattern used for ML model registries, applied to templates: **trials** (raw, unreviewed attempts) → **integration** (cross-validated candidates, pending approval) → **authoritative** (approved, in production use, drift-monitored).
|
|
48
|
+
|
|
49
|
+
Shared path prefix under all three tiers:
|
|
50
|
+
```
|
|
51
|
+
<vendor>/<device-family>/<os>/<version>/<cli-name>/
|
|
52
|
+
```
|
|
53
|
+
e.g. `cisco/catalyst9200/ios-xe/17.9.1/show-interface-var1-status/`
|
|
54
|
+
|
|
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
|
+
|
|
57
|
+
### 3.1 `trials/`
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
trials/<vendor>/<family>/<os>/<version>/<cli-name>/
|
|
61
|
+
<yyyymmdd-HHMMSS-shortid>/
|
|
62
|
+
input.txt
|
|
63
|
+
raw-llm-response.txt
|
|
64
|
+
usage.txt
|
|
65
|
+
raw-template
|
|
66
|
+
template.textfsm
|
|
67
|
+
readable-dsl.txt
|
|
68
|
+
recognizers.txt
|
|
69
|
+
llm-records.json
|
|
70
|
+
records.json
|
|
71
|
+
debug.txt
|
|
72
|
+
status.txt
|
|
73
|
+
```
|
|
74
|
+
Keep the timestamp+shortid directories (not `result1..N`) — chronological ordering and collision-safety in batch mode come for free, and sequential numbers throw both away.
|
|
75
|
+
|
|
76
|
+
| File | Purpose |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `input.txt` | Raw CLI output sample fed to the LLM |
|
|
79
|
+
| `raw-llm-response.txt` | Full, unprocessed LLM response |
|
|
80
|
+
| `usage.txt` | Token counts / cost metadata for that call |
|
|
81
|
+
| `raw-template` | Template as extracted from the LLM response, pre-cleanup |
|
|
82
|
+
| `template.textfsm` | Cleaned TextFSM template candidate (`.textfsm` extension — recognized by TextFSM tooling/linters, unlike `textfsm.template`) |
|
|
83
|
+
| `readable-dsl.txt` | Human-readable description of what the template captures |
|
|
84
|
+
| `recognizers.txt` | Heuristics/signatures for detecting this output type at runtime |
|
|
85
|
+
| `llm-records.json` | Structured record of the LLM interaction (prompt, model, params, timestamps) |
|
|
86
|
+
| `records.json` | Result of running this trial's `template.textfsm` against its own `input.txt` |
|
|
87
|
+
| `debug.txt` | Parse errors, warnings, retry attempts |
|
|
88
|
+
| `status.txt` | `passed`/`failed`, `llm-duration-ms`, `pipeline-duration-ms`, error summary |
|
|
89
|
+
|
|
90
|
+
### 3.2 `integration/` (no human review yet)
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
integration/<vendor>/<family>/<os>/<version>/<cli-name>/
|
|
94
|
+
common-result/
|
|
95
|
+
template.textfsm ← the winning candidate, copied from trials/
|
|
96
|
+
selection-report.json ← which trials were considered, why this one won
|
|
97
|
+
artifact/ ← supporting evidence (see below)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**How "common" gets chosen — don't just self-validate:** test *every* candidate `template.textfsm` from `trials/<cli-name>/` against *every* `input.txt` collected for that `cli-name`, not only the sample it was generated from. Promote whichever template parses the largest share of all known samples correctly. A template that only works against its own source sample is exactly the overfit case integration should catch. If you generate multiple candidates from the same input (repeated LLM calls), a structure that recurs across generations is additional evidence of stability — worth folding into the score, not just accuracy alone.
|
|
101
|
+
|
|
102
|
+
`selection-report.json` should record: which trial run IDs were candidates, the cross-validation match rate for each, which samples the winner failed on (if any), and the score that made it win. This is what a human reviewer actually reads — the raw template alone doesn't convey robustness.
|
|
103
|
+
|
|
104
|
+
`artifact/` holds whatever a reviewer needs to judge the result without re-running the pipeline: the aggregated `records.json` outputs across all cross-validated samples, and a diff/summary of any samples where the winning template didn't fully match.
|
|
105
|
+
|
|
106
|
+
### 3.3 `authoritative/` (approved via human review, or confidence-gated auto-promotion)
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
authoritative/<vendor>/<family>/<os>/<version>/<cli-name>/
|
|
110
|
+
template.textfsm ← current approved template
|
|
111
|
+
artifact/ ← carried over from the winning integration candidate
|
|
112
|
+
history/
|
|
113
|
+
<yyyymmdd-HHMMSS>-<shortid>.textfsm ← every prior approved version, kept
|
|
114
|
+
promotion-log.json ← who/what approved each version, when, why, and what it replaced
|
|
115
|
+
drift-log.json ← match-rate over time against live production samples
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Promotion from `integration/common-result` to `authoritative/` should default to human review, but can be confidence-gated: if the cross-validation match rate is at or near 100% across all known samples with no ambiguous fields, auto-promote; otherwise queue for human review. This isn't a replacement for review — it's a filter that keeps the easy, unambiguous cases from waiting on a person while still requiring a human on anything uncertain. A custom drift/quality-scoring model can slot into this same gate later without changing the pipeline shape.
|
|
119
|
+
|
|
120
|
+
**Drift detection** runs the current `authoritative/.../template.textfsm` against new production samples on an ongoing basis, logging match rate to `drift-log.json`. When match rate drops below threshold, that failing sample is written into `trials/.../<cli-name>/` as a new run, which re-enters the pipeline at §5 step 4 and works its way back through `integration/` to a possible new `authoritative/` version — closing the loop rather than treating drift as a one-off alert.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## 4. Execution Modes
|
|
125
|
+
|
|
126
|
+
**Mode 1 — Batch (gather-then-generate):**
|
|
127
|
+
- Pro: LLM can be shown patterns across multiple samples of the *same* command at once (e.g., three different interface states) in one shot, likely improving template robustness on the first try.
|
|
128
|
+
- Con: Slower feedback loop — you don't know command #3 has a connectivity problem until after collecting #1–#10.
|
|
129
|
+
|
|
130
|
+
**Mode 2 — Loop (sample → generate, per command):**
|
|
131
|
+
- Pro: Fails fast and isolates problems per-command; simpler to debug; easier to parallelize across commands.
|
|
132
|
+
- Con: Each template is generated from a single sample unless you explicitly loop multiple times per command and merge.
|
|
133
|
+
|
|
134
|
+
**Recommendation:** Build Mode 2 first as the MVP — it's the simpler pipeline and gives you per-command `status.txt` results immediately. Add Mode 1 as a config flag afterward that changes only the *sampling* stage (collect N samples per command before invoking the LLM) — the generation, storage, and validation stages stay identical between modes if you design sampling as a separable stage up front.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 5. Pipeline Steps
|
|
139
|
+
|
|
140
|
+
1. **Input intake** — device OS/version/family, auth, command list, mode selection.
|
|
141
|
+
2. **Name generation** — tokenize each command → canonical `cli-name` per §2.
|
|
142
|
+
3. **Path resolution** — compute `<vendor>/<device-family>/<os>/<version>/<cli-name>/` per §3.
|
|
143
|
+
4. **Sampling** — connect (Netmiko/similar), run command(s), capture raw output → `trials/.../<run-id>/input.txt`.
|
|
144
|
+
5. **Generation** — send `input.txt` (+ prior context if Mode 1) to LLM → `raw-llm-response.txt`, `usage.txt`.
|
|
145
|
+
6. **Extraction & cleanup** — pull template from response → `raw-template` → cleaned `template.textfsm`.
|
|
146
|
+
7. **Self-validation** — run `template.textfsm` against its own `input.txt` → `records.json`; capture errors → `debug.txt`; write trial `status.txt`.
|
|
147
|
+
8. **Integration selection** — cross-validate every candidate `template.textfsm` in `trials/.../<cli-name>/` against every known `input.txt` for that `cli-name`; promote the best-scoring one to `integration/.../common-result/`, writing `selection-report.json`.
|
|
148
|
+
9. **Authoritative promotion** — auto-promote if the winning candidate clears the confidence threshold across all known samples; otherwise queue for human review. On approval, copy to `authoritative/.../template.textfsm`, archive the prior version to `history/`, and write `promotion-log.json`.
|
|
149
|
+
10. **Drift monitoring** — continuously run the authoritative template against new production samples, logging match rate to `drift-log.json`. On breach, feed the failing sample back into `trials/` (→ step 4) to generate a replacement candidate.
|
|
150
|
+
11. **Repeat/loop or batch-complete** depending on mode.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 6. Design Notes: Is Three-Tier Enough?
|
|
155
|
+
|
|
156
|
+
**Yes, for the promotion pipeline itself.** `trials → integration → authoritative` covers the full lifecycle a template needs to go through to earn trust: generate, cross-validate, approve. Adding a fourth *promotion* tier (e.g. a separate "staging" between integration and authoritative) wouldn't buy you anything distinct — integration already *is* the staging step, since nothing in it is live yet.
|
|
157
|
+
|
|
158
|
+
Where a fourth tier is tempting but better handled as **metadata instead of a new directory layer:**
|
|
159
|
+
|
|
160
|
+
- **Quarantine / stale-but-still-serving:** when drift is detected, the authoritative template is still the one in production use — you don't want to yank it out mid-flight while a replacement works its way through the pipeline. Rather than a new top-level tier, this is a `status` field in `drift-log.json` (`ok` / `drifting` / `superseded`) on the existing authoritative entry. The template keeps serving; the status just flags that a replacement is in flight.
|
|
161
|
+
- **Archived / deprecated (EOL device-OS combos):** for template families you no longer actively maintain (e.g. an OS version taken out of production fleet-wide), this is also a status flag rather than a directory move — `history/` already retains every prior version, so "archived" just means promotion-log stops getting new entries, not that files need to relocate.
|
|
162
|
+
- **Multiple simultaneously-valid templates for one `cli-name`:** if a command's output legitimately varies by hardware config (e.g. a chassis with vs. without an optional module) rather than by drift, that's not a tier problem — it's a case for storing *multiple* authoritative templates under the same `cli-name` path with distinct `recognizers.txt` signatures, and letting runtime dispatch pick the right one. Worth flagging now so `recognizers.txt` is designed to support one-of-many matching from the start rather than assuming exactly one template per `cli-name`.
|
|
163
|
+
|
|
164
|
+
**Bottom line:** keep the three directories; push the "in-between" states (drifting-but-live, archived, multi-variant) into metadata/status fields on the existing tiers. Adding more top-level directories per edge case leads to directory sprawl without adding real distinctions in *how* a template is used.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## 7. Open Questions for Next Iteration
|
|
169
|
+
|
|
170
|
+
- Do you want a **registry/index file** (e.g. `catalog.json`) at the repo root listing every `<vendor>/<family>/<os>/<version>/<cli-name>` combination that exists, plus its authoritative status, for fast lookup without walking the filesystem?
|
|
171
|
+
- 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
|
+
- 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
|
+
- 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.1"
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""parseforge CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from parseforge import naming
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
@click.version_option(package_name="parseforge")
|
|
12
|
+
def main() -> None:
|
|
13
|
+
"""ParseForge — forge, validate, and promote TextFSM templates from CLI output."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@main.command("name")
|
|
17
|
+
@click.option("--vendor", required=True)
|
|
18
|
+
@click.option("--family", required=True)
|
|
19
|
+
@click.option("--os", "os_", required=True)
|
|
20
|
+
@click.option("--version", required=True)
|
|
21
|
+
@click.argument("command", nargs=-1, required=True)
|
|
22
|
+
def name_cmd(
|
|
23
|
+
vendor: str, family: str, os_: str, version: str, command: tuple[str, ...]
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Print the canonical cli-name for a raw CLI COMMAND.
|
|
26
|
+
|
|
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.
|
|
31
|
+
"""
|
|
32
|
+
context = naming.CliContext(vendor=vendor, family=family, os=os_, version=version)
|
|
33
|
+
click.echo(naming.cli_name(" ".join(command), context))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@main.command("run")
|
|
37
|
+
@click.option("--vendor", required=True)
|
|
38
|
+
@click.option("--family", required=True)
|
|
39
|
+
@click.option("--os", "os_", required=True)
|
|
40
|
+
@click.option("--version", required=True)
|
|
41
|
+
@click.argument("command", nargs=-1, required=True)
|
|
42
|
+
def run_cmd(
|
|
43
|
+
vendor: str, family: str, os_: str, version: str, command: tuple[str, ...]
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Run the pipeline for a single CLI COMMAND against a device (§5)."""
|
|
46
|
+
from parseforge.pipeline import run_command_pipeline
|
|
47
|
+
|
|
48
|
+
run_command_pipeline(
|
|
49
|
+
" ".join(command),
|
|
50
|
+
key_fields={"vendor": vendor, "family": family, "os": os_, "version": version},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
main()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Generation stage — LLM call and template extraction (SPEC.md §5 steps 5-6).
|
|
2
|
+
|
|
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
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class GenerationResult:
|
|
15
|
+
raw_response: str
|
|
16
|
+
usage: str
|
|
17
|
+
raw_template: str
|
|
18
|
+
template: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Generator(Protocol):
|
|
22
|
+
"""An LLM backend capable of producing a TextFSM template from sample input.
|
|
23
|
+
|
|
24
|
+
``prior_context`` carries additional samples of the same command when
|
|
25
|
+
running in Mode 1 (batch) per §4.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def generate(
|
|
29
|
+
self, input_text: str, prior_context: list[str] | None = None
|
|
30
|
+
) -> GenerationResult: ...
|
|
31
|
+
|
|
32
|
+
|
|
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")
|
|
36
|
+
|
|
37
|
+
|
|
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")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .assemble import pattern_to_cli_name
|
|
2
|
+
from .cache import DEFAULT_INDEX_PATH, NameIndex
|
|
3
|
+
from .llm import CliContext, RegexBuilder, UnimplementedRegexBuilder, build_prompt
|
|
4
|
+
from .resolver import cli_name
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"cli_name",
|
|
8
|
+
"CliContext",
|
|
9
|
+
"RegexBuilder",
|
|
10
|
+
"UnimplementedRegexBuilder",
|
|
11
|
+
"build_prompt",
|
|
12
|
+
"NameIndex",
|
|
13
|
+
"DEFAULT_INDEX_PATH",
|
|
14
|
+
"pattern_to_cli_name",
|
|
15
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Turn a validated regex pattern into a canonical, kebab-case cli-name.
|
|
2
|
+
|
|
3
|
+
Fixed-text segments pass through as-is; named capture groups
|
|
4
|
+
(``(?P<var1>...)``) become their group name. Tokens are joined with
|
|
5
|
+
``-`` and lowercased, matching SPEC.md §2's naming convention.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
_LEADING_FLAGS = re.compile(r"^\(\?[a-zA-Z]+\)")
|
|
13
|
+
_NAMED_GROUP = re.compile(r"^\(\?P<(?P<name>\w+)>.*\)$")
|
|
14
|
+
_ESCAPED_CHAR = re.compile(r"\\(.)")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pattern_to_cli_name(pattern: str) -> str:
|
|
18
|
+
"""
|
|
19
|
+
>>> pattern_to_cli_name(r"(?i)show\\s+version")
|
|
20
|
+
'show-version'
|
|
21
|
+
>>> pattern_to_cli_name(r"(?i)show\\s+interface\\s+(?P<var1>\\S+)\\s+status")
|
|
22
|
+
'show-interface-var1-status'
|
|
23
|
+
"""
|
|
24
|
+
body = _LEADING_FLAGS.sub("", pattern)
|
|
25
|
+
tokens = [t for t in re.split(r"\\s\+", body) if t.strip()]
|
|
26
|
+
|
|
27
|
+
parts: list[str] = []
|
|
28
|
+
for token in tokens:
|
|
29
|
+
token = token.strip()
|
|
30
|
+
group = _NAMED_GROUP.match(token)
|
|
31
|
+
if group:
|
|
32
|
+
parts.append(group.group("name"))
|
|
33
|
+
else:
|
|
34
|
+
parts.append(_ESCAPED_CHAR.sub(r"\1", token))
|
|
35
|
+
return "-".join(parts).lower()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""On-disk cli-name -> regex index, so a CLI is only ever sent to the LLM once.
|
|
2
|
+
|
|
3
|
+
Persisted as a flat JSON dict (SPEC.md naming discussion):
|
|
4
|
+
{"show-version": "(?i)show\\s+version", ...}
|
|
5
|
+
|
|
6
|
+
The cli-name segment is vendor/OS-agnostic by design — vendor, family, os,
|
|
7
|
+
and version are already distinguishing directory levels in the storage
|
|
8
|
+
layout (SPEC.md §3), so a single global index is intentional rather than
|
|
9
|
+
one index per device context.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
DEFAULT_INDEX_PATH = Path.home() / ".parseforge" / ".cli-name.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NameIndex:
|
|
22
|
+
"""Loads, queries, and persists the cli-name -> regex mapping."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, path: Path = DEFAULT_INDEX_PATH) -> None:
|
|
25
|
+
self.path = path
|
|
26
|
+
self._entries: dict[str, str] = self._load()
|
|
27
|
+
|
|
28
|
+
def _load(self) -> dict[str, str]:
|
|
29
|
+
if not self.path.exists():
|
|
30
|
+
return {}
|
|
31
|
+
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
32
|
+
|
|
33
|
+
def match(self, command: str) -> str | None:
|
|
34
|
+
"""Return the cli-name of the first stored pattern that fully matches
|
|
35
|
+
``command``, or None if this command hasn't been named before."""
|
|
36
|
+
for cli_name, pattern in self._entries.items():
|
|
37
|
+
if re.fullmatch(pattern, command):
|
|
38
|
+
return cli_name
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
def add(self, cli_name: str, pattern: str) -> None:
|
|
42
|
+
self._entries[cli_name] = pattern
|
|
43
|
+
|
|
44
|
+
def save(self) -> None:
|
|
45
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
self.path.write_text(
|
|
47
|
+
json.dumps(self._entries, indent=2, sort_keys=True), encoding="utf-8"
|
|
48
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""LLM-driven regex construction for a CLI command.
|
|
2
|
+
|
|
3
|
+
Only called on a cache miss (see resolver.py) — once a command's pattern
|
|
4
|
+
is in the index, matching is pure regex and costs no tokens.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
PROMPT_TEMPLATE = """\
|
|
13
|
+
This is CLI "{command}" of {vendor} {family} {os} version {version}.
|
|
14
|
+
Create a regex pattern to match it:
|
|
15
|
+
- If a token is fixed text, use it as-is.
|
|
16
|
+
- If a token is variable text, use a regex to match it, and name the \
|
|
17
|
+
capture group var1, var2, ... in left-to-right order of appearance.
|
|
18
|
+
- Use \\s+ to match whitespace between tokens.
|
|
19
|
+
|
|
20
|
+
Example 1: "show version" -> "show" and "version" are fixed text ->
|
|
21
|
+
r"(?i)show\\s+version"
|
|
22
|
+
|
|
23
|
+
Example 2: "show interface Ge1.1 status" -> "show", "interface", and \
|
|
24
|
+
"status" are fixed text ->
|
|
25
|
+
r"(?i)show\\s+interface\\s+(?P<var1>\\S+)\\s+status"
|
|
26
|
+
|
|
27
|
+
Respond with only the regex pattern, nothing else.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class CliContext:
|
|
33
|
+
vendor: str
|
|
34
|
+
family: str
|
|
35
|
+
os: str
|
|
36
|
+
version: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_prompt(command: str, context: CliContext) -> str:
|
|
40
|
+
return PROMPT_TEMPLATE.format(
|
|
41
|
+
command=command,
|
|
42
|
+
vendor=context.vendor,
|
|
43
|
+
family=context.family,
|
|
44
|
+
os=context.os,
|
|
45
|
+
version=context.version,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RegexBuilder(Protocol):
|
|
50
|
+
"""An LLM backend that turns a raw CLI command into a regex pattern."""
|
|
51
|
+
|
|
52
|
+
def build_pattern(self, command: str, context: CliContext) -> str: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UnimplementedRegexBuilder:
|
|
56
|
+
"""Default RegexBuilder — no LLM client is wired into the scaffold yet."""
|
|
57
|
+
|
|
58
|
+
def build_pattern(self, command: str, context: CliContext) -> str:
|
|
59
|
+
raise NotImplementedError(
|
|
60
|
+
"no RegexBuilder configured — wire an LLM client that sends "
|
|
61
|
+
"build_prompt(command, context) and returns the regex pattern"
|
|
62
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Resolve a raw CLI command to its canonical cli-name, LLM-backed with caching.
|
|
2
|
+
|
|
3
|
+
Flow:
|
|
4
|
+
1. Check the on-disk index (:mod:`parseforge.naming.cache`) for a stored
|
|
5
|
+
pattern that already matches this command — no LLM call on a hit.
|
|
6
|
+
2. On a miss, ask the LLM to build a regex for it
|
|
7
|
+
(:mod:`parseforge.naming.llm`).
|
|
8
|
+
3. Validate the LLM's pattern actually matches its own source command via
|
|
9
|
+
``re.fullmatch`` before trusting it.
|
|
10
|
+
4. Assemble the cli-name from the pattern (:mod:`parseforge.naming.assemble`)
|
|
11
|
+
and persist the new cli-name -> pattern entry to the index.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .assemble import pattern_to_cli_name
|
|
20
|
+
from .cache import DEFAULT_INDEX_PATH, NameIndex
|
|
21
|
+
from .llm import CliContext, RegexBuilder, UnimplementedRegexBuilder
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cli_name(
|
|
25
|
+
command: str,
|
|
26
|
+
context: CliContext,
|
|
27
|
+
builder: RegexBuilder = UnimplementedRegexBuilder(),
|
|
28
|
+
index_path: Path = DEFAULT_INDEX_PATH,
|
|
29
|
+
) -> str:
|
|
30
|
+
index = NameIndex(index_path)
|
|
31
|
+
|
|
32
|
+
cached = index.match(command)
|
|
33
|
+
if cached is not None:
|
|
34
|
+
return cached
|
|
35
|
+
|
|
36
|
+
pattern = builder.build_pattern(command, context)
|
|
37
|
+
if not re.fullmatch(pattern, command):
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"LLM-built pattern {pattern!r} does not match its own command {command!r}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
name = pattern_to_cli_name(pattern)
|
|
43
|
+
index.add(name, pattern)
|
|
44
|
+
index.save()
|
|
45
|
+
return name
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Storage layout — three-tier promotion path resolution (SPEC.md §3).
|
|
2
|
+
|
|
3
|
+
Shared path prefix under all three tiers:
|
|
4
|
+
<vendor>/<device-family>/<os>/<version>/<cli-name>/
|
|
5
|
+
|
|
6
|
+
Use hyphenated OS family names (``ios-xe``, ``nx-os``) once multiple
|
|
7
|
+
Cisco OS families share the tree, per §3's note.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import random
|
|
13
|
+
import string
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
TRIALS = "trials"
|
|
19
|
+
INTEGRATION = "integration"
|
|
20
|
+
AUTHORITATIVE = "authoritative"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class DeviceKey:
|
|
25
|
+
"""Identifies a template family: vendor/device-family/os/version/cli-name."""
|
|
26
|
+
|
|
27
|
+
vendor: str
|
|
28
|
+
family: str
|
|
29
|
+
os: str
|
|
30
|
+
version: str
|
|
31
|
+
cli_name: str
|
|
32
|
+
|
|
33
|
+
def relative_path(self) -> Path:
|
|
34
|
+
return Path(self.vendor, self.family, self.os, self.version, self.cli_name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def tier_root(store_root: Path, tier: str) -> Path:
|
|
38
|
+
if tier not in (TRIALS, INTEGRATION, AUTHORITATIVE):
|
|
39
|
+
raise ValueError(f"unknown tier: {tier!r}")
|
|
40
|
+
return store_root / tier
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def tier_path(store_root: Path, tier: str, key: DeviceKey) -> Path:
|
|
44
|
+
"""Resolve the directory for ``key`` under the given tier."""
|
|
45
|
+
return tier_root(store_root, tier) / key.relative_path()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def new_run_id() -> str:
|
|
49
|
+
"""Timestamp+shortid run identifier (§3.1) — chronological, collision-safe."""
|
|
50
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
51
|
+
shortid = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
|
52
|
+
return f"{timestamp}-{shortid}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def trial_run_dir(store_root: Path, key: DeviceKey, run_id: str | None = None) -> Path:
|
|
56
|
+
"""Directory for a single trial run: trials/.../<cli-name>/<run-id>/."""
|
|
57
|
+
run_id = run_id or new_run_id()
|
|
58
|
+
return tier_path(store_root, TRIALS, key) / run_id
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def integration_result_dir(store_root: Path, key: DeviceKey) -> Path:
|
|
62
|
+
"""The single common-result candidate directory for a cli-name (§3.2)."""
|
|
63
|
+
return tier_path(store_root, INTEGRATION, key) / "common-result"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def authoritative_dir(store_root: Path, key: DeviceKey) -> Path:
|
|
67
|
+
"""The approved, in-production directory for a cli-name (§3.3)."""
|
|
68
|
+
return tier_path(store_root, AUTHORITATIVE, key)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Pipeline orchestration (SPEC.md §4, §5).
|
|
2
|
+
|
|
3
|
+
Mode 2 (loop: sample -> generate, per command) is the MVP per §4's
|
|
4
|
+
recommendation. Mode 1 (batch: gather-then-generate) is a config flag
|
|
5
|
+
that only changes the sampling stage — generation, storage, and
|
|
6
|
+
validation stay identical between modes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
from parseforge import naming, paths
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Mode(str, Enum):
|
|
17
|
+
LOOP = "loop" # MVP: sample -> generate, per command (§4)
|
|
18
|
+
BATCH = "batch" # collect N samples per command before generation (§4)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_command_pipeline(
|
|
22
|
+
command: str,
|
|
23
|
+
key_fields: dict,
|
|
24
|
+
regex_builder: naming.RegexBuilder = naming.UnimplementedRegexBuilder(),
|
|
25
|
+
mode: Mode = Mode.LOOP,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Execute steps 1-7 of the pipeline for a single CLI command.
|
|
28
|
+
|
|
29
|
+
1. Input intake is the caller's responsibility (device info, auth,
|
|
30
|
+
command list, mode selection already resolved into ``key_fields``,
|
|
31
|
+
which must supply vendor/family/os/version).
|
|
32
|
+
2. Name generation — :func:`parseforge.naming.cli_name` (LLM-backed,
|
|
33
|
+
cached — see :mod:`parseforge.naming`).
|
|
34
|
+
3. Path resolution — :mod:`parseforge.paths`.
|
|
35
|
+
4. Sampling — :mod:`parseforge.sampling`.
|
|
36
|
+
5-6. Generation — :mod:`parseforge.generation`.
|
|
37
|
+
7. Self-validation — :mod:`parseforge.validation`.
|
|
38
|
+
|
|
39
|
+
Steps 8-11 (integration selection, authoritative promotion, drift
|
|
40
|
+
monitoring, repeat/loop) run out-of-band across all trials for a
|
|
41
|
+
cli-name, not per single pipeline invocation — see
|
|
42
|
+
:mod:`parseforge.validation` and :mod:`parseforge.promotion`.
|
|
43
|
+
"""
|
|
44
|
+
context = naming.CliContext(**key_fields)
|
|
45
|
+
name = naming.cli_name(command, context, builder=regex_builder)
|
|
46
|
+
key = paths.DeviceKey(cli_name=name, **key_fields)
|
|
47
|
+
raise NotImplementedError(
|
|
48
|
+
f"wire sampling -> generation -> validation for {key!r} (mode={mode.value})"
|
|
49
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Authoritative promotion and drift monitoring (SPEC.md §3.3, §5 steps 9-10)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PromotionDecision(str, Enum):
|
|
10
|
+
AUTO_PROMOTED = "auto_promoted"
|
|
11
|
+
QUEUED_FOR_REVIEW = "queued_for_review"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class PromotionGate:
|
|
16
|
+
"""Confidence threshold gating auto-promotion vs. human review (§3.3, §7).
|
|
17
|
+
|
|
18
|
+
Left configurable per-project rather than hardcoded — see the open
|
|
19
|
+
question in SPEC.md §7.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
match_rate_threshold: float = 1.0
|
|
23
|
+
min_sample_count: int = 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def decide_promotion(
|
|
27
|
+
match_rate: float, sample_count: int, gate: PromotionGate
|
|
28
|
+
) -> PromotionDecision:
|
|
29
|
+
if (
|
|
30
|
+
sample_count >= gate.min_sample_count
|
|
31
|
+
and match_rate >= gate.match_rate_threshold
|
|
32
|
+
):
|
|
33
|
+
return PromotionDecision.AUTO_PROMOTED
|
|
34
|
+
return PromotionDecision.QUEUED_FOR_REVIEW
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class DriftStatus:
|
|
39
|
+
match_rate: float
|
|
40
|
+
status: str # "ok" | "drifting" | "superseded" — see SPEC.md §6
|
|
41
|
+
|
|
42
|
+
def breached(self, threshold: float) -> bool:
|
|
43
|
+
return self.match_rate < threshold
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Sampling stage — connect to a device and capture raw CLI output (SPEC.md §5 step 4).
|
|
2
|
+
|
|
3
|
+
Kept as a separable stage so that Mode 1 (batch: collect N samples per
|
|
4
|
+
command before generation) and Mode 2 (loop: sample-then-generate per
|
|
5
|
+
command) share the same generation/storage/validation stages and differ
|
|
6
|
+
only in how many samples this stage collects before handing off — see
|
|
7
|
+
§4.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class DeviceConnection:
|
|
18
|
+
host: str
|
|
19
|
+
username: str
|
|
20
|
+
password: str
|
|
21
|
+
device_type: str # e.g. "cisco_ios", "cisco_nxos"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Sampler(Protocol):
|
|
25
|
+
"""A backend capable of running a command on a device and returning raw text.
|
|
26
|
+
|
|
27
|
+
A Netmiko-backed implementation is the expected default; see the
|
|
28
|
+
optional ``sampling`` extra in pyproject.toml.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def run_command(self, connection: DeviceConnection, command: str) -> str: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sample(sampler: Sampler, connection: DeviceConnection, command: str) -> str:
|
|
35
|
+
"""Run ``command`` against ``connection`` and return raw output for input.txt."""
|
|
36
|
+
return sampler.run_command(connection, command)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Validation — self-validation (SPEC.md §5 step 7) and cross-validation
|
|
2
|
+
for integration selection (§3.2, §5 step 8).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ParseResult:
|
|
12
|
+
records: list[dict]
|
|
13
|
+
errors: list[str]
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def passed(self) -> bool:
|
|
17
|
+
return not self.errors
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse(template_text: str, input_text: str) -> ParseResult:
|
|
21
|
+
"""Run a template.textfsm against an input.txt sample.
|
|
22
|
+
|
|
23
|
+
Used both for self-validation (a template against its own sample,
|
|
24
|
+
step 7) and cross-validation (every candidate against every known
|
|
25
|
+
sample for a cli-name, step 8).
|
|
26
|
+
"""
|
|
27
|
+
raise NotImplementedError("wrap textfsm.TextFSM — see SPEC.md §5 steps 7-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CandidateScore:
|
|
32
|
+
run_id: str
|
|
33
|
+
match_rate: float
|
|
34
|
+
failed_samples: list[str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def select_common_result(
|
|
38
|
+
candidates: dict[str, str], samples: dict[str, str]
|
|
39
|
+
) -> CandidateScore:
|
|
40
|
+
"""Cross-validate every candidate template against every known sample.
|
|
41
|
+
|
|
42
|
+
``candidates`` maps trial run_id -> template.textfsm text.
|
|
43
|
+
``samples`` maps sample id -> input.txt text.
|
|
44
|
+
|
|
45
|
+
Promotes whichever template parses the largest share of *all* known
|
|
46
|
+
samples correctly (not just the sample it was generated from) — an
|
|
47
|
+
overfit template that only matches its own source sample must lose
|
|
48
|
+
to a template that generalizes, per §3.2.
|
|
49
|
+
"""
|
|
50
|
+
raise NotImplementedError("scoring/selection rule — see SPEC.md §3.2")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parseforge
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: LLM-driven pipeline that forges, validates, and promotes TextFSM templates from network CLI output
|
|
5
|
+
Author-email: Tuyen Mathew Duong <tuyen@geekstrident.com>
|
|
6
|
+
Maintainer-email: Tuyen Mathew Duong <tuyen@geekstrident.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: textfsm,network automation,cli parsing,template generator,ai,llm,netmiko,device output parsing
|
|
9
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: Information Technology
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
20
|
+
Classifier: Topic :: Text Processing
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Classifier: Operating System :: OS Independent
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: click>=8.1
|
|
27
|
+
Requires-Dist: PyYAML>=6.0
|
|
28
|
+
Requires-Dist: textfsm>=1.1.0
|
|
29
|
+
Provides-Extra: sampling
|
|
30
|
+
Requires-Dist: netmiko>=4.0; extra == "sampling"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: ruff>=0.4.0; extra == "dev"
|
|
35
|
+
Requires-Dist: black>=24.0.0; extra == "dev"
|
|
36
|
+
Requires-Dist: mypy>=1.10.0; extra == "dev"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# ParseForge
|
|
40
|
+
|
|
41
|
+
LLM-driven pipeline that forges, cross-validates, and promotes [TextFSM](https://github.com/google/textfsm)
|
|
42
|
+
templates from network device CLI output.
|
|
43
|
+
|
|
44
|
+
Full design plan: [SPEC.md](SPEC.md).
|
|
45
|
+
|
|
46
|
+
## Layout
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
parseforge/
|
|
50
|
+
naming.py CLI command -> canonical cli-name (SPEC §2)
|
|
51
|
+
paths.py trials/integration/authoritative path resolution (SPEC §3)
|
|
52
|
+
sampling.py device connection + command capture (SPEC §5 step 4)
|
|
53
|
+
generation.py LLM call + template extraction (SPEC §5 steps 5-6)
|
|
54
|
+
validation.py self-validation + cross-validation scoring (SPEC §5 steps 7-8)
|
|
55
|
+
promotion.py authoritative promotion gate + drift status (SPEC §3.3, §5 steps 9-10)
|
|
56
|
+
pipeline.py orchestrates the above; Mode LOOP (MVP) vs Mode BATCH (SPEC §4)
|
|
57
|
+
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
|
+
```
|
|
62
|
+
|
|
63
|
+
`naming.py` and `paths.py` are implemented and tested. `sampling.py`, `generation.py`,
|
|
64
|
+
`validation.py`, `promotion.py`, and `pipeline.py` are stub interfaces — the SPEC.md
|
|
65
|
+
section cited in each docstring is the next thing to implement.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
pip install -e ".[dev,sampling]"
|
|
71
|
+
pytest
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## CLI
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
parseforge name show interface GE1.1 status
|
|
78
|
+
# -> show-interface-var1-status
|
|
79
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
SPEC.md
|
|
5
|
+
pyproject.toml
|
|
6
|
+
parseforge/__init__.py
|
|
7
|
+
parseforge/generation.py
|
|
8
|
+
parseforge/paths.py
|
|
9
|
+
parseforge/pipeline.py
|
|
10
|
+
parseforge/promotion.py
|
|
11
|
+
parseforge/sampling.py
|
|
12
|
+
parseforge/validation.py
|
|
13
|
+
parseforge.egg-info/PKG-INFO
|
|
14
|
+
parseforge.egg-info/SOURCES.txt
|
|
15
|
+
parseforge.egg-info/dependency_links.txt
|
|
16
|
+
parseforge.egg-info/entry_points.txt
|
|
17
|
+
parseforge.egg-info/requires.txt
|
|
18
|
+
parseforge.egg-info/top_level.txt
|
|
19
|
+
parseforge/cli/__init__.py
|
|
20
|
+
parseforge/cli/main.py
|
|
21
|
+
parseforge/naming/__init__.py
|
|
22
|
+
parseforge/naming/assemble.py
|
|
23
|
+
parseforge/naming/cache.py
|
|
24
|
+
parseforge/naming/llm.py
|
|
25
|
+
parseforge/naming/resolver.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
parseforge
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "parseforge"
|
|
7
|
+
version = "0.2.1"
|
|
8
|
+
description = "LLM-driven pipeline that forges, validates, and promotes TextFSM templates from network CLI output"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Tuyen Mathew Duong", email = "tuyen@geekstrident.com" }
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
maintainers = [
|
|
18
|
+
{ name = "Tuyen Mathew Duong", email = "tuyen@geekstrident.com" }
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
dependencies = [
|
|
22
|
+
"click>=8.1",
|
|
23
|
+
"PyYAML>=6.0",
|
|
24
|
+
"textfsm>=1.1.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
classifiers = [
|
|
28
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
29
|
+
"Intended Audience :: Developers",
|
|
30
|
+
"Intended Audience :: Information Technology",
|
|
31
|
+
"Intended Audience :: System Administrators",
|
|
32
|
+
|
|
33
|
+
"Programming Language :: Python :: 3",
|
|
34
|
+
"Programming Language :: Python :: 3.9",
|
|
35
|
+
"Programming Language :: Python :: 3.10",
|
|
36
|
+
"Programming Language :: Python :: 3.11",
|
|
37
|
+
"Programming Language :: Python :: 3.12",
|
|
38
|
+
|
|
39
|
+
"Topic :: Software Development :: Libraries",
|
|
40
|
+
"Topic :: Software Development :: Code Generators",
|
|
41
|
+
"Topic :: Text Processing",
|
|
42
|
+
"Topic :: Utilities",
|
|
43
|
+
|
|
44
|
+
"Operating System :: OS Independent",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
keywords = [
|
|
48
|
+
"textfsm",
|
|
49
|
+
"network automation",
|
|
50
|
+
"cli parsing",
|
|
51
|
+
"template generator",
|
|
52
|
+
"ai",
|
|
53
|
+
"llm",
|
|
54
|
+
"netmiko",
|
|
55
|
+
"device output parsing",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[project.optional-dependencies]
|
|
59
|
+
sampling = ["netmiko>=4.0"]
|
|
60
|
+
dev = [
|
|
61
|
+
"pytest>=8.0.0",
|
|
62
|
+
"pytest-cov>=5.0.0",
|
|
63
|
+
"ruff>=0.4.0",
|
|
64
|
+
"black>=24.0.0",
|
|
65
|
+
"mypy>=1.10.0",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[project.scripts]
|
|
69
|
+
parseforge = "parseforge.cli.main:main"
|
|
70
|
+
|
|
71
|
+
[tool.setuptools]
|
|
72
|
+
include-package-data = true
|
|
73
|
+
|
|
74
|
+
[tool.setuptools.packages.find]
|
|
75
|
+
where = ["."]
|
|
76
|
+
include = ["parseforge*"]
|
|
77
|
+
exclude = ["tests*", "store*"]
|
|
78
|
+
|
|
79
|
+
[tool.pytest.ini_options]
|
|
80
|
+
addopts = "-ra --cov=parseforge --cov-report=term-missing"
|
|
81
|
+
testpaths = ["tests"]
|
|
82
|
+
pythonpath = ["."]
|
|
83
|
+
|
|
84
|
+
[tool.coverage.report]
|
|
85
|
+
# Raise as stub modules (sampling/generation/validation/promotion/pipeline)
|
|
86
|
+
# get real implementations — see README.md.
|
|
87
|
+
fail_under = 0
|
|
88
|
+
|
|
89
|
+
[tool.black]
|
|
90
|
+
line-length = 88
|
|
91
|
+
target-version = ["py39"]
|
|
92
|
+
|
|
93
|
+
[tool.ruff]
|
|
94
|
+
line-length = 88
|
|
95
|
+
fix = true
|
|
96
|
+
|
|
97
|
+
[tool.ruff.lint]
|
|
98
|
+
select = ["E", "F", "W", "I"]
|