Skip to content

Commit 7507eb6

Browse files
TMFNKcursoragent
andcommitted
Add audit-cited local-model assist
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1dfc4e3 commit 7507eb6

11 files changed

Lines changed: 1165 additions & 25 deletions

File tree

‎CITATION.cff‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@ authors:
1111
abstract: >
1212
Trail: a deterministic-first log template miner with a per-decision
1313
audit trail. v0.1 ships the deterministic core, the audit JSONL format,
14-
and a 60-line labeled sample. A small-local-model assist that proposes
15-
template merges from the audit log is planned as Phase 2.
14+
and a 60-line labeled sample. Its optional local-model assist proposes
15+
audit-cited template merges and splits without changing parser outputs.
1616
keywords:
1717
- log-parsing
1818
- template-mining
1919
- audit-trail
2020
- offline
2121
- reproducibility
22+
- small-language-models
2223
references:
2324
- type: article
2425
title: "A Large-scale Evaluation for Log Parsing Techniques: How Far are We?"

‎README.md‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ Trail is a deterministic-first log template miner. Each parsed line gets
44
a template and a receipt: one audit record per decision, so any
55
template traces back to the exact lines and merges that built it.
66

7-
v0.1 is the deterministic core only. Planned model assist:
8-
`docs/PHASE2-LM.md` (stub in `scripts/lm_assist.py`). No scores on this
9-
page involve a model.
7+
The deterministic miner remains the parser of record. An optional local-model
8+
assist reviews low-confidence joins and near-duplicate templates without
9+
changing the parse CSV or audit JSONL. No scores on this page involve a model.
1010

11-
Keywords: log parsing, template mining, audit trail, offline, Drain
12-
alternative.
11+
Keywords: log parsing, template mining, audit trail, offline, small language
12+
models, Drain alternative.
1313

1414
GitHub topics: `log-parsing` `template-mining` `audit-trail` `offline`
15-
`reproducibility`
15+
`reproducibility` `small-language-models`
1616

1717
## One-command run
1818

@@ -36,17 +36,19 @@ LogParser-Trail/
3636
│ ├── miner.py # deterministic core (original code, no Drain copy)
3737
│ ├── audit.py # JSONL writer + summary
3838
│ ├── metrics.py # GA/PA/FGA/FTA (LogHub-2.0 formulas, Apache-2.0)
39-
│ └── io.py # LogHub-shaped CSV readers/writers
39+
│ ├── io.py # LogHub-shaped CSV readers/writers
40+
│ ├── assist.py # candidate selection + review materialization
41+
│ └── lm.py # loopback-only OpenAI-compatible client
4042
├── examples/ # committed 60-line labeled sample (8 templates)
4143
├── scripts/
4244
│ ├── make_sample.py # seeded sample generator (seed 7)
4345
│ ├── parse.py # log -> structured CSV + audit JSONL
4446
│ ├── score.py # parsed CSV vs truth, four scores
4547
│ ├── verify_golden.py # sample_60 GA/PA/FGA/FTA + template count
46-
│ └── lm_assist.py # Phase 2 stub (exits 2, not wired)
48+
│ └── lm_assist.py # local review CLI; deterministic inputs stay immutable
4749
├── expected/sample_60.json # CI golden for the 60-line sample
4850
├── docs/DESIGN.md # algorithm, audit schema, known limits
49-
├── docs/PHASE2-LM.md # the planned model loop
51+
├── docs/PHASE2-LM.md # local-model review contract
5052
├── results/ # committed sample run (parsed CSV, audit, table)
5153
└── tests/
5254
```
@@ -66,6 +68,24 @@ another private, ignored directory. The parser refuses to replace the
6668
committed public sample outputs from a different input unless
6769
`--allow-public-output` is explicit.
6870

71+
## Local-model assist
72+
73+
Start an OpenAI-compatible server bound to `127.0.0.1`, then run:
74+
75+
```bash
76+
uv run python scripts/lm_assist.py \
77+
--csv results/parsed_sample.csv \
78+
--audit results/audit.jsonl \
79+
--review results/raw/sample.lm-review.jsonl \
80+
--out-csv results/raw/sample_lm.csv
81+
```
82+
83+
The default endpoint is `http://127.0.0.1:8090/v1` and the default model
84+
alias is `qwen3.8-2b-q6k`; override them with `--base-url` and `--model`.
85+
Only the literal `127.0.0.1` is accepted. The client bypasses environment
86+
proxies, rejects redirects, and sends requests one at a time. Use `--dry-run`
87+
to inspect candidates without contacting a model or writing outputs.
88+
6989
## Scores
7090

7191
`seclog/metrics.py` in Tier B and `harness/metrics.py` in Tier A use

‎docs/DESIGN.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,25 @@ Example text comes from the paired structured CSV, joined by `LineId`.
5454
An assisted parse, if produced, is a separate CSV; it never replaces the
5555
deterministic parser output.
5656

57+
## Local-model review
58+
59+
The optional assist joins audit `line` to structured CSV `LineId`. It selects
60+
matched decisions below `0.7` similarity and pairs of final cluster templates
61+
whose token-level Levenshtein edit distance is one. A candidate is sent only
62+
when three distinct cited example lines are available.
63+
64+
Prompt version `trail-lm-v1` asks for exactly `SAME` or `TWO`. For a
65+
low-confidence join, `TWO` accepts splitting the cited target line. For a
66+
near-duplicate pair, `SAME` accepts merging the clusters. Any absent,
67+
conflicting, or otherwise unparseable answer is rejected. Accepted changes
68+
are materialized only in a separate `*_lm.csv`.
69+
70+
The append-only review schema is `lm-review-v1`. Every record stores source
71+
SHA-256 digests, cited audit lines, examples, templates, request payload,
72+
prompt version, model identity, raw and parsed response, decision, change,
73+
and reason. Model traffic uses direct HTTP to literal `127.0.0.1`; proxies,
74+
redirects, hostnames, TLS endpoints, and concurrent requests are disallowed.
75+
5776
## Known limits
5877

5978
- Header split is syntactic only. Timestamps, pids, and hostnames are
@@ -69,3 +88,6 @@ deterministic parser output.
6988
- The committed 60-line sample is the self-contained regression fixture.
7089
SecOps-2k scoring runs only when the Tier B checkout sits next to this
7190
repo (see `./reproduce.sh`).
91+
- A low-confidence accepted split isolates the cited line; it does not infer
92+
whether later members should follow it. The review artifact remains
93+
inspectable and the deterministic output remains unchanged.

‎docs/PHASE2-LM.md‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Phase 2: local-model assist (planned, not built)
1+
# Phase 2: local-model assist
22

33
The deterministic core stays. The model only proposes template merges
44
and splits, and every proposal cites the audit records behind it. A
@@ -20,7 +20,7 @@ human or a threshold accepts them. Nothing parses without a trail.
2020
1. Run the miner. Read its audit JSONL and paired structured CSV.
2121
2. Flag low-confidence joins (similarity under 0.7) and near-duplicate
2222
clusters whose final templates have token-level edit distance one.
23-
3. Send each candidate, with 3 example lines, to a local small model
23+
3. Send each candidate that has 3 distinct example lines to a local small model
2424
over an OpenAI-compatible endpoint (`127.0.0.1`, same pattern as
2525
the Tier A harness server script). Ask one question: same event or
2626
two? No log leaves the machine.
@@ -29,6 +29,21 @@ human or a threshold accepts them. Nothing parses without a trail.
2929
5. If accepted decisions are applied, write a new assisted CSV. Do not
3030
mutate the deterministic CSV or parse audit.
3131

32+
`scripts/lm_assist.py` implements this loop. A reply is accepted only when it
33+
contains one unambiguous `SAME` or `TWO` decision after any Qwen `<think>`
34+
block is removed. `TWO` accepts a low-confidence split; `SAME` accepts a
35+
near-duplicate merge. Other replies are rejected and still recorded. A
36+
failed request is recorded as a rejection before the CLI stops.
37+
38+
The client uses Python's direct `HTTPConnection` to the literal `127.0.0.1`.
39+
It does not read proxy environment variables, does not resolve hostnames, and
40+
does not follow redirects. Calls are serialized with one in-flight request.
41+
42+
Each review record uses schema `lm-review-v1` and contains the candidate kind,
43+
cluster ids, cited audit lines, examples, templates, source-file SHA-256
44+
digests, prompt version, exact request, model identity, raw response, parsed
45+
proposal, decision, change, and reason.
46+
3247
## Model
3348

3449
Small and local. Qwen3-class 2–4B quant, CPU-served, one request at a

‎scripts/lm_assist.py‎

Lines changed: 125 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,138 @@
11
# SPDX-License-Identifier: Apache-2.0
22
# Copyright 2026 MbitAI — see NOTICE for attribution.
3-
"""LM assist (Phase 2 stub). Not wired — exits 2 with a pointer.
4-
5-
The assist will read an immutable parse audit plus its paired structured
6-
CSV, then write proposals and decisions to a separate append-only review
7-
JSONL. It will never modify deterministic outputs. See docs/PHASE2-LM.md.
8-
Never runs in v0.1.
9-
"""
3+
"""Review deterministic parse candidates with a local OpenAI-compatible model."""
104

115
from __future__ import annotations
126

7+
import argparse
8+
import hashlib
139
import sys
10+
from pathlib import Path
11+
12+
ROOT = Path(__file__).resolve().parents[1]
13+
sys.path.insert(0, str(ROOT))
14+
15+
from trailparse import audit as audit_mod # noqa: E402
16+
from trailparse import io as io_mod # noqa: E402
17+
from trailparse.assist import ( # noqa: E402
18+
append_review,
19+
apply_decisions,
20+
review_candidate,
21+
select_candidates,
22+
)
23+
from trailparse.lm import ( # noqa: E402
24+
DEFAULT_BASE_URL,
25+
DEFAULT_MODEL,
26+
LocalModelClient,
27+
)
28+
29+
RAW_RESULTS = (ROOT / "results" / "raw").resolve()
30+
31+
32+
def sha256(path: Path) -> str:
33+
return hashlib.sha256(path.read_bytes()).hexdigest()
34+
35+
36+
def validate_paths(
37+
csv_path: Path,
38+
audit_path: Path,
39+
review_path: Path,
40+
out_csv: Path | None,
41+
) -> None:
42+
inputs = {csv_path.resolve(), audit_path.resolve()}
43+
if len(inputs) != 2:
44+
raise ValueError("CSV and audit inputs must be different files")
45+
if review_path.resolve() in inputs:
46+
raise ValueError("review output cannot overwrite an input")
47+
if not review_path.name.endswith(".lm-review.jsonl"):
48+
raise ValueError("review output must end with .lm-review.jsonl")
49+
if not review_path.resolve().is_relative_to(RAW_RESULTS):
50+
raise ValueError("review output must be under results/raw/")
51+
if out_csv is not None:
52+
if out_csv.resolve() in inputs or out_csv.resolve() == review_path.resolve():
53+
raise ValueError("assisted CSV cannot overwrite an input or review log")
54+
if not out_csv.name.endswith("_lm.csv"):
55+
raise ValueError("assisted CSV must end with _lm.csv")
56+
if not out_csv.resolve().is_relative_to(RAW_RESULTS):
57+
raise ValueError("assisted CSV must be under results/raw/")
1458

1559

1660
def main() -> None:
17-
print("LM assist is Phase 2 and not wired. See docs/PHASE2-LM.md.")
18-
print(
19-
"Parse audit and CSV inputs stay immutable; model review will use "
20-
"a separate local-only JSONL."
61+
ap = argparse.ArgumentParser()
62+
ap.add_argument("--csv", required=True, help="immutable structured CSV")
63+
ap.add_argument("--audit", required=True, help="immutable parse audit JSONL")
64+
ap.add_argument(
65+
"--review",
66+
default="results/raw/trail.lm-review.jsonl",
67+
help="append-only review JSONL under results/raw/",
2168
)
22-
sys.exit(2)
69+
ap.add_argument(
70+
"--out-csv",
71+
default="",
72+
help="optional assisted CSV under results/raw/, ending in _lm.csv",
73+
)
74+
ap.add_argument("--base-url", default=DEFAULT_BASE_URL)
75+
ap.add_argument("--model", default=DEFAULT_MODEL)
76+
ap.add_argument("--timeout", type=float, default=120.0)
77+
ap.add_argument(
78+
"--dry-run",
79+
action="store_true",
80+
help="list candidates without calling a model or writing outputs",
81+
)
82+
args = ap.parse_args()
83+
84+
csv_path = Path(args.csv)
85+
audit_path = Path(args.audit)
86+
review_path = Path(args.review)
87+
out_csv = Path(args.out_csv) if args.out_csv else None
88+
try:
89+
validate_paths(csv_path, audit_path, review_path, out_csv)
90+
except ValueError as exc:
91+
ap.error(str(exc))
92+
93+
before = {csv_path: sha256(csv_path), audit_path: sha256(audit_path)}
94+
records = audit_mod.read_jsonl(audit_path)
95+
rows = io_mod.read_structured(csv_path)
96+
candidates = select_candidates(records, rows)
97+
print(f"selected {len(candidates)} candidates")
98+
if args.dry_run:
99+
for candidate in candidates:
100+
print(
101+
f"{candidate.kind}: {','.join(candidate.cluster_ids)} "
102+
f"audit lines={list(candidate.cited_audit_lines)}"
103+
)
104+
return
105+
106+
try:
107+
client = LocalModelClient(args.base_url, args.model, args.timeout)
108+
except ValueError as exc:
109+
ap.error(str(exc))
110+
reviews = []
111+
for candidate in candidates:
112+
review = review_candidate(
113+
candidate,
114+
client,
115+
audit_sha256=before[audit_path],
116+
csv_sha256=before[csv_path],
117+
)
118+
append_review(review_path, review)
119+
reviews.append(review)
120+
print(
121+
f"{candidate.kind} {','.join(candidate.cluster_ids)}: "
122+
f"{review['decision']} ({review['reason']})"
123+
)
124+
if review["response"].get("error"):
125+
raise SystemExit(
126+
f"local model request failed; rejection recorded in {review_path}"
127+
)
128+
129+
if out_csv is not None:
130+
io_mod.write_structured(apply_decisions(rows, reviews), out_csv)
131+
print(f"wrote assisted CSV to {out_csv}")
132+
133+
after = {csv_path: sha256(csv_path), audit_path: sha256(audit_path)}
134+
if after != before:
135+
raise RuntimeError("immutable input changed during LM review")
23136

24137

25138
if __name__ == "__main__":

0 commit comments

Comments
 (0)