Skip to content

Commit 3fab51e

Browse files
committed
Enhance local-model assist documentation and add affected line tracking in candidate selection
1 parent 9c5be86 commit 3fab51e

4 files changed

Lines changed: 173 additions & 10 deletions

File tree

‎README.md‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,17 @@ committed public sample outputs from a different input unless
9999

100100
## Local-model assist
101101

102-
Start an OpenAI-compatible server bound to `127.0.0.1`, then run:
102+
The review loop needs a local OpenAI-compatible server on
103+
`http://127.0.0.1:8090/v1`. Any server works; the tested one is
104+
`llama-server` (llama.cpp) with a 2B-class quant:
105+
106+
```bash
107+
llama-server -m Qwen3.8-2B-Q6_K.gguf --host 127.0.0.1 --port 8090
108+
```
109+
110+
The default `--model` alias is `qwen3.8-2b-q6k` (Qwen3.8-2B distill,
111+
Q6_K, about 2 GB of weights). It runs on CPU: 16 SecOps-2k candidates
112+
take a few minutes after a short model load. Then run:
103113

104114
```bash
105115
uv run trail-lm-assist \
@@ -109,13 +119,16 @@ uv run trail-lm-assist \
109119
--out-csv results/raw/sample_lm.csv
110120
```
111121

112-
The default endpoint is `http://127.0.0.1:8090/v1` and the default model
113-
alias is `qwen3.8-2b-q6k`; override them with `--base-url` and `--model`.
114-
Only the literal `127.0.0.1` is accepted. The client bypasses environment
115-
proxies, rejects redirects, and sends requests one at a time. Use `--dry-run`
122+
The default endpoint is `http://127.0.0.1:8090/v1`; override endpoint
123+
and model with `--base-url` and `--model`. Only the literal
124+
`127.0.0.1` is accepted. The client bypasses environment proxies,
125+
rejects redirects, and sends requests one at a time. Use `--dry-run`
116126
to inspect candidates without contacting a model or writing outputs. The
117-
command aborts above 100 candidates unless `--max-candidates` is explicit.
118-
It refuses to replace an existing `*_lm.csv` unless `--force` is passed.
127+
command aborts above 100 candidates unless `--max-candidates` is explicit,
128+
and refuses to replace an existing `*_lm.csv` unless `--force` is passed.
129+
Accepts touching more than 10 lines are recorded as `needs-human` and
130+
never materialized; small-fragment merges still auto-apply. Reviews and
131+
assisted CSVs live under the ignored `results/raw/` directory.
119132
`scripts/lm_assist.py` remains a source-checkout compatibility wrapper.
120133

121134
## Metrics

‎docs/PHASE2-LM.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@ human or a threshold accepts them. Nothing parses without a trail.
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.
27-
4. Append the proposal, cited audit lines, model identity, prompt version,
28-
raw response, and accept/reject reason to `*.lm-review.jsonl`.
27+
4. Append the proposal, cited audit lines, affected-line count, model identity, prompt version,
28+
raw response, and accept/reject/needs-human reason to `*.lm-review.jsonl`.
2929
5. If accepted decisions are applied, write a new assisted CSV. Do not
30-
mutate the deterministic CSV or parse audit.
30+
mutate the deterministic CSV or parse audit. Accepts touching more
31+
than `MAX_AUTO_AFFECTED` lines (10) are recorded as `needs-human`
32+
and never materialized: splitting one line out of a large pure
33+
cluster, or merging two big clusters, rewrites too much grouping
34+
for an unattended model verdict.
3135

3236
The installed `trail-lm-assist` command implements this loop
3337
(`scripts/lm_assist.py` is a compatibility wrapper). It validates matching

‎tests/test_assist.py‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,31 @@ def test_committed_sample_candidates_have_three_audit_cited_examples():
8383
)
8484

8585

86+
def test_candidates_carry_affected_line_counts():
87+
contents = {1: "event a", 2: "event b", 3: "event c"}
88+
records = []
89+
rows = []
90+
for i in range(1, 13):
91+
content = contents.get(i, "event a")
92+
decision = "matched" if i == 2 else "new_cluster"
93+
records.append(
94+
{
95+
"line": i,
96+
"cluster": "T1",
97+
"decision": decision,
98+
"similarity": 0.5 if i == 2 else 0.0,
99+
"template": "event <*>",
100+
}
101+
)
102+
rows.append(row(i, "T1", "event <*>", content))
103+
104+
(candidate,) = select_candidates(records, rows)
105+
106+
assert candidate.kind == "low_confidence"
107+
assert candidate.target_line == 2
108+
assert candidate.affected_lines == 12
109+
110+
86111
def test_candidates_without_three_examples_are_not_sent():
87112
records = [
88113
{
@@ -345,5 +370,97 @@ def test_apply_decisions_rejects_unequal_length_merge():
345370
apply_decisions(source, [{"change": "merge", "cluster_ids": ["T1", "T2"]}])
346371

347372

373+
def test_big_split_is_held_for_human_review():
374+
candidate = Candidate(
375+
kind="low_confidence",
376+
cluster_ids=("T3",),
377+
cited_audit_lines=(18, 3, 21),
378+
examples=("target", "peer one", "peer two"),
379+
templates=("event <*>",),
380+
similarity=0.5,
381+
target_line=18,
382+
affected_lines=410,
383+
)
384+
assert decide(candidate, "TWO") == (
385+
"needs-human",
386+
"none",
387+
"split of 410 lines in T3 held for human review",
388+
)
389+
390+
391+
def test_big_merge_is_held_for_human_review():
392+
candidate = Candidate(
393+
kind="near_duplicate",
394+
cluster_ids=("T7", "T11"),
395+
cited_audit_lines=(7, 15, 10),
396+
examples=("event a", "event b", "event c"),
397+
templates=("event <*>", "event <*>"),
398+
similarity=None,
399+
target_line=None,
400+
affected_lines=160,
401+
)
402+
assert decide(candidate, "SAME") == (
403+
"needs-human",
404+
"none",
405+
"merge of 160 lines in T7,T11 held for human review",
406+
)
407+
408+
409+
def test_small_accepts_still_apply():
410+
split = Candidate(
411+
kind="low_confidence",
412+
cluster_ids=("T1",),
413+
cited_audit_lines=(2, 1, 3),
414+
examples=("target", "peer one", "peer two"),
415+
templates=("event <*>",),
416+
similarity=0.5,
417+
target_line=2,
418+
affected_lines=3,
419+
)
420+
assert decide(split, "TWO") == ("accept", "split", "model said TWO")
421+
merge = Candidate(
422+
kind="near_duplicate",
423+
cluster_ids=("T1", "T2"),
424+
cited_audit_lines=(1, 2, 3),
425+
examples=("event a", "event b", "event c"),
426+
templates=("event <*>", "event <*>"),
427+
similarity=None,
428+
target_line=None,
429+
affected_lines=6,
430+
)
431+
assert decide(merge, "SAME") == ("accept", "merge", "model said SAME")
432+
433+
434+
def test_needs_human_records_are_not_materialized():
435+
source = [
436+
row(1, "T1", "event <*>", "event a"),
437+
row(2, "T1", "event <*>", "event b"),
438+
row(3, "T2", "event <*>", "event c"),
439+
]
440+
reviews = [
441+
{
442+
"decision": "needs-human",
443+
"change": "none",
444+
"target_line": 2,
445+
"cluster_ids": ["T1"],
446+
},
447+
{
448+
"decision": "needs-human",
449+
"change": "none",
450+
"target_line": None,
451+
"cluster_ids": ["T1", "T2"],
452+
},
453+
]
454+
455+
assisted = apply_decisions(source, reviews)
456+
457+
assert [item["EventId"] for item in assisted] == ["T1", "T1", "T2"]
458+
assert [item["EventTemplate"] for item in assisted] == [
459+
"event <*>",
460+
"event <*>",
461+
"event <*>",
462+
]
463+
464+
348465
def test_apply_decisions_accepts_empty_parse():
349466
assert apply_decisions([], []) == []

‎trailparse/assist.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717
LOW_SIMILARITY = 0.7
1818
MAX_EXAMPLES = 3
1919
DEFAULT_MAX_CANDIDATES = 100
20+
# Auto-apply ceiling: a model accept touching more lines than this is
21+
# recorded as needs-human and never materialized. Splitting one line
22+
# out of a large pure cluster (or merging two big ones) rewrites the
23+
# grouping of every line involved; that blast radius needs a human.
24+
# Small sudo-fragment merges (a handful of lines) still auto-apply.
25+
MAX_AUTO_AFFECTED = 10
2026
_WORD = re.compile(r"[A-Za-z]+")
2127
_THINK = re.compile(r"<think>.*?</think>", re.I | re.S)
2228

@@ -30,6 +36,11 @@ class Candidate:
3036
templates: tuple[str, ...]
3137
similarity: float | None
3238
target_line: int | None
39+
# Lines whose grouping would change if this candidate is applied:
40+
# the whole cluster for a split, both clusters for a merge.
41+
# select_candidates always sets it; the 0 default keeps older
42+
# unit-test constructions on the previous accept/reject behavior.
43+
affected_lines: int = 0
3344

3445

3546
def token_edit_distance(left: list[str], right: list[str]) -> int:
@@ -215,6 +226,7 @@ def select_candidates(
215226
templates=(templates[cid],),
216227
similarity=rec["similarity"],
217228
target_line=target_line,
229+
affected_lines=len(members[cid]),
218230
)
219231
)
220232
if len(candidates) > max_candidates:
@@ -239,6 +251,7 @@ def select_candidates(
239251
templates=(templates[left_id], templates[right_id]),
240252
similarity=None,
241253
target_line=None,
254+
affected_lines=len(members[left_id]) + len(members[right_id]),
242255
)
243256
)
244257
return candidates
@@ -287,13 +300,28 @@ def decide(candidate: Candidate, proposal: str | None) -> tuple[str, str, str]:
287300
return "reject", "none", "unparsed model reply"
288301
if candidate.kind == "low_confidence":
289302
if proposal == "TWO":
303+
if candidate.affected_lines > MAX_AUTO_AFFECTED:
304+
return (
305+
"needs-human",
306+
"none",
307+
f"split of {candidate.affected_lines} lines in "
308+
f"{candidate.cluster_ids[0]} held for human review",
309+
)
290310
return "accept", "split", "model said TWO"
291311
return "reject", "none", "model said SAME; keep miner join"
292312
if proposal == "SAME":
293313
if len(candidate.templates[0].split()) != len(
294314
candidate.templates[1].split()
295315
):
296316
return "reject", "none", "unequal-length merge is not materialized"
317+
if candidate.affected_lines > MAX_AUTO_AFFECTED:
318+
clusters = ",".join(candidate.cluster_ids)
319+
return (
320+
"needs-human",
321+
"none",
322+
f"merge of {candidate.affected_lines} lines in "
323+
f"{clusters} held for human review",
324+
)
297325
return "accept", "merge", "model said SAME"
298326
return "reject", "none", "model said TWO; keep clusters split"
299327

@@ -317,6 +345,7 @@ def review_candidate(
317345
"cluster_ids": list(candidate.cluster_ids),
318346
"cited_audit_lines": list(candidate.cited_audit_lines),
319347
"target_line": candidate.target_line,
348+
"affected_lines": candidate.affected_lines,
320349
"templates": list(candidate.templates),
321350
"examples": list(candidate.examples),
322351
"similarity": candidate.similarity,

0 commit comments

Comments
 (0)