Repository navigation
feat(gptme-rag): add lesson_matcher module — keyword/wildcard, session_categories, BM25 - #1468
Conversation
…n_categories, BM25 Ports the lesson-matching library from the match-lessons.py Claude Code hook into gptme-rag as a reusable, harness-agnostic module. Phase 2.4 of the upstream-retrieval-into-gptme-rag task. What's included: - keyword_to_regex / match_keyword: wildcard keyword matching (* -> \w*) - extract_frontmatter: YAML frontmatter parser with stdlib regex fallback - scan_lessons: scan lesson dirs, first-dir-wins dedup, skip archive/inactive - filter_by_session_category: gate on session_categories frontmatter field - filter_by_harness: gate on metadata.harness - filter_held_out / parse_holdout_set / is_held_out: A/B holdout filtering - BM25 scoring: in-memory BM25, z-score gate (calibrated 2026-08-05) - score_lessons: keyword + pattern + skill-descriptor + BM25 scoring - TS re-ranking intentionally excluded (caller's responsibility) conftest.py: guard chromadb import so tests run without chromadb installed. 64 tests, all pass. Co-authored-by: Bob <bob@superuserlabs.org>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
🤖 AI code reviewAdds lesson_matcher.py, a library port of the match-lessons.py hook, providing keyword/wildcard matching, frontmatter parsing with a PyYAML-free regex fallback, lesson scanning with first-dir-wins dedup, session-category/harness/holdout filters, and additive BM25 scoring. Also guards chromadb-dependent test fixtures and adds a regex dependency. Confidence Score: 3/5 — One P1 finding
4 findings · ❌ **1** P1 ·
|
| commit | score | findings | engine | when |
|---|---|---|---|---|
aedcdfeffc3f |
3/5 | 4 | llm | 2026-08-21 07:14 UTC |
39ccfc348637 |
4/5 | 2 | llm | 2026-08-21 09:28 UTC |
8b898aef2e5c |
3/5 | 2 | llm | 2026-08-21 12:06 UTC |
7f3286583805 |
3/5 | 1 | llm | 2026-08-21 13:47 UTC |
7de3ec60f858 |
3/5 | 2 | llm | 2026-08-21 14:40 UTC |
4c16917f49fa |
4/5 | 1 | llm | 2026-08-21 17:42 UTC |
73886c5f781c |
2/5 | 2 | llm | 2026-08-21 19:03 UTC |
684bff525aad |
2/5 | 2 | llm | 2026-08-21 19:33 UTC |
3cc402305c77 |
3/5 | 1 | llm | 2026-08-22 18:07 UTC |
277cda0aa602 |
4/5 | 1 | llm | 2026-08-22 19:21 UTC |
Reviewed 84c5bde08c33 · openrouter/deepseek/deepseek-v4-flash-0731 · llm engine · 211s · about this reviewer
Maintainer commands
@TimeToBuildBob review (own line) — fresh review · @TimeToBuildBob fix — a worker acts on the findings. Once per comment; 👀 = received.
|
@greptileai review |
…ching, and pattern re.IGNORECASE Three bugs found by AI review: 1. (P1) seen_names.add() was called before status/keyword validation, so an inactive lesson in an earlier dir would silently block a valid lesson with the same filename in a later dir. Moved add() to after lessons.append() so only successfully appended lessons reserve the name. 2. (P2) filter_by_session_category() compared category without lowercasing the argument, breaking case-insensitive matching for callers passing e.g. 'Code' instead of 'code'. Now lowercases category before the set lookup. 3. (P2) score_lessons() pattern matching searched prompt_lower with bare re.search(), so patterns containing uppercase chars (e.g. 'GitHub') would never match the already-lowercased prompt. Added re.IGNORECASE.
…gories/harness Two P1 fixes from AI reviewer (PR #1468): 1. scan_lessons: archive files in dir1 must not add to seen_names. A non-archived lesson with the same filename in dir2 was silently dropped (first-dir-wins dedup must only apply to ACTIVE files). 2. extract_frontmatter regex fallback: add _extract_list_frontmatter_field helper and parse session_categories, patterns, and metadata.harness. When PyYAML is unavailable a lesson with session_categories would fire in every session instead of only in matching ones (security defect). Adds 4 regression tests. Co-authored-by: Bob <bob@superuserlabs.org>
|
Fixed both P1 findings in Fix 1 — Fix 2 — regex fallback missing |
🤖 AI code review (updated — head cdf400c)Reviewed the delta between the AI-reviewed commit ( Changes in cdf400c
Tests
Verdict✅ No new findings. The fix is correct, surgical, and well-tested. All CI checks pass on this head. |
…n_categories
P1 fix 1 (lesson_matcher.py:193): yaml.safe_load on a YAML list (sequence
frontmatter like '---\n- a\n- b\n---') returns a list, which is truthy so
'(fm_yaml or {})' returned the list. Callers (scan_lessons) call .get() on
the result, producing AttributeError. Fix: validate isinstance(fm_yaml, dict)
before returning.
P1 fix 2 (lesson_matcher.py:502): session_categories written as a scalar
comma-separated string ('code, infrastructure') was wrapped in a list as
['code, infrastructure'] instead of being split. _string_list() already handles
this splitting; route through it instead of wrapping in a bare list.
Both fixes have regression tests.
|
Fixed 2 P1 findings from the latest AI review ( Fix 1 — YAML sequence frontmatter crashes scan ( Fix 2 — comma-separated session_categories not split ( |
|
Fixed the mypy type error in |
…alar session_categories Two P1 defects in the PyYAML fallback path of extract_frontmatter: 1. Block-form keywords only matched double-quoted values (r'^\s*-\s*"([^"]+)"' missed '- unquoted multi word keyword'). Real lesson files use unquoted block keywords throughout. Fixed to capture quoted, single-quoted, and unquoted multi-word values using a tri-branch regex. Added regression test. 2. Scalar session_categories (e.g. 'session_categories: code, infrastructure') were silently dropped because _extract_list_frontmatter_field only handles inline [...] and block (- val) forms. A gated lesson with scalar session_categories would fire in every session, defeating the category gate. Fixed with a direct regex fallback handling the indented (nested) form. Added regression test.
Greptile convergence adjudication — 2 P1 findings fixed in
|
… fields
The PyYAML-free regex fallback in _extract_list_frontmatter_field only ended a
block sequence on a non-indented line. For the standard nested lesson shape
match:
session_categories:
- code
keywords:
- foo
' keywords:' is still indented, so the session_categories loop kept going and
returned ['code', 'foo']. That corrupts session gating: lessons get excluded
where they should fire, or fire everywhere they were meant to be gated.
A sibling key is indented exactly like its parent's other keys, so indentation
alone cannot terminate the sequence — any non-item line now ends it.
Also fold the duplicate ad-hoc keywords parser in extract_frontmatter into this
helper. Its block form scanned the whole frontmatter with finditer, so any '-'
item under any field (tags, session_categories) became a keyword. Reusing the
helper required fixing its inline path to split on commas rather than tokenize
on word characters, which had been dropping unquoted multi-word values:
'keywords: [git push, git commit]' yielded ['git','push','git','commit'].
Block items flush with their key ('keywords:' then '- foo' at column 0) are
valid YAML and now parse instead of returning empty.
…atcher
P1-142: naive split(',') split on commas inside quoted values
- Replace with regex tokenizer that respects "..." and '...' quoting
- Commas inside quoted strings are no longer treated as separators
- All quoting styles and unquoted multi-word values now handled correctly
P1-234: quoted scalar session_categories in regex fallback not stripped
- session_categories: "code" → strip surrounding quotes before split
- Handles both double and single quotes
Add regression tests for both fixes.
Co-Authored-By: Bob <bob@superuserlabs.org>
Adjudication — convergence cap reached (3 rounds, 2/5)PR hit the AI review convergence cap. Adjudicated both P1 findings from round 3 against commit P1-142 — inline list splits on commas inside quoted stringsConfirmed real. The prior fix in Fix in P1-234 — quoted scalar
|
…allback The canonical lesson template in lessons/README.md documents `status: active # active | automated | ...`. Without PyYAML the regex fallback returned `active # active | automated | ...`, which fails the `status == "active"` check in scan_lessons, so the lesson was silently dropped. Same flaw hit name/description/when_to_use. _clean_plain_scalar strips surrounding quotes first, so a `#` inside a quoted scalar stays as data, and only treats `#` as a comment when it starts the value or follows whitespace (`issue#42` survives). Also clarifies filter_by_session_category's docstring: only the nested `match.session_categories` form is read, matching the Claude Code hook this ports.
Adjudication — round-3 findings (lines 107 / 514 / 595)Three threads were still unadjudicated (separate from the 142/234 P1s fixed in P1 @107 — inline
|
…_scalar
YAML single-quoted scalars use '' (doubled single quote) as the only
escape sequence for a literal single quote, e.g. 'it''s' → it's. The
previous code scanned for the closing quote with a simple character
comparison, so the first ' in '' terminated the scan early and returned
only the prefix ('it''s' → 'it' instead of "it's").
Split the quoted-scalar branch into single-quoted ('' escape loop) and
double-quoted (backslash escape loop). Added a focused regression test
test_regex_fallback_single_quoted_doubled_escape confirming 'it''s a
lesson' → "it's a lesson" in the PyYAML-free fallback path.
Co-authored-by: Bob <bob@superuserlabs.org>
|
Fixed the remaining P2 in Previously, Split the quoted-scalar branch: single-quoted strings now use a |
…AML scalars _clean_plain_scalar correctly found the end of a double-quoted string (treating \" as an escaped quote) but returned value[1:index] verbatim, leaving raw backslashes in the result. For example: description: "He said \"hello\"" would yield 'He said \"hello\"' instead of 'He said "hello"', silently corrupting name/description/status fields when PyYAML is unavailable and the frontmatter uses escaped-quote values. Switch the double-quoted branch to a character-by-character loop that applies the standard YAML escape map (\" → ", \\ → \, \n → newline, etc.) while scanning. Update the pre-existing test that was asserting the incorrect (raw-backslash) output to match the correct YAML result. 115 lesson_matcher tests pass. Co-authored-by: Bob <bob@superuserlabs.org>
|
Fixed the remaining open finding in What changed: The double-quoted branch was scanning correctly to find the closing Status: 115 lesson-matcher tests pass, all review threads resolved, CI running on |
…o fix no-redef mypy flagged 'result' as redefined (line 118 and 143) because both the single-quote and double-quote branches declared it with a type annotation in the same function scope. Rename the double-quote local to 'chars' to eliminate the no-redef error.
|
Fixed the typecheck/pre-commit failure in CI should go green on this head. |
…ive + metadata keyword hoisting - _bm25_zscores: when all nonzero scores are equal (sd=0), return z=1.0 for matched lessons instead of 0.0. The old code returned 0.0, which fell below _bm25_min_z(2)≈0.57, so *both* lessons in a two-way tie were silently dropped even though both genuinely matched the query. Tied lessons now get a nominal z of 1.0 and the gate admits them normally. - _extract_list_frontmatter_field: add allow_indented parameter (default True). The inline regex used ^[ \t]* which matched a same-named field at any indent level. Calling it on top_level_without_match (which still contains the metadata: block) caused metadata.keywords and metadata.patterns to be merged into the lesson's match keywords, disagreeing with the PyYAML path. Pass allow_indented=False for top-level searches to anchor the key at column 0. Tests: add test_bm25_tied_scores_both_contribute and test_regex_fallback_metadata_keywords_not_hoisted; update test_bm25_zscores_uniform to expect 1.0 for the tied case.
|
Pushed
Two new tests cover both cases: |
|
@TimeToBuildBob fix |
684bff5 accidentally restored the double-quoted scan that returns value[1:index] with literal backslashes, undoing d9d73db. Restore the escape map (\" \\ \n …) and pass quoted inline-list tokens through _clean_plain_scalar so YAML doubled single-quotes ('it''s' → it's) parse the same as the block form and the PyYAML path.
|
Pushed
118 lesson_matcher tests pass. Review-thread replies have the details. |
…1) nominal z When all nonzero BM25 scores are identical (zero variance), _bm25_zscores used to return a flat nominal z of 1.0. This works for n<=2 (where _bm25_min_z returns -inf) but fails for n>=4 where _bm25_min_z(n)>1.0 (e.g. gate=1.2 for n=4, gate=1.43 for n=5), silently dropping ALL tied lessons from BM25 contribution. Fix: assign nominal_z = sqrt(n_nonzero - 1), which is the theoretical maximum z-score achievable when one element stands out from n-1 tied others. This always exceeds the gate value (0.8*(n-1)/sqrt(n)) for any n. Add test_bm25_tied_scores_n4_admitted covering the n=4 case where the gate is >1.0 and update the existing test_bm25_zscores_uniform to assert the correct sqrt(2) value instead of the old 1.0.
|
Fixed the P1 BM25 tied-score false negative in Bug: Fix: Use Updated |
|
Pushed |
|
Adjudication — current head
AI reviewer findings on
Full CI matrix green. All inline threads resolved. Waiting for maintainer merge. |
…n scan_lessons An unreadable file (permission error / non-UTF-8) in an earlier lesson dir would claim its filename in seen_names before failing, silently blocking a valid lesson with the same filename in a later dir from being included. Move seen_names.add(f.name) to after read_text() succeeds. Add regression test covering the permission-denied case.
|
Fixed the remaining open P2 finding in |
|
Two more fixes pushed after the last comment:
CI is fully green across the matrix. All review threads are resolved. Self-merge gate ineligible: AI review covers |
|
All P1 findings from both
All 14 inline AI-review threads have been disposed (10 resolved-without-reply threads got replies today; the 4 new open threads were disposed as FP/wontfix/accepted-tradeoff). |
|
Fixed two more AI-review findings since
All 120 lesson-matcher tests pass. The full CI matrix is green. Local AI review (head |
|
@TimeToBuildBob address remaining issues in follow-up PR |
Summary
Ports the lesson-matching library from the
match-lessons.pyClaude Code hook intogptme-ragas a reusable, harness-agnostic module. This is Phase 2.4 of the upstream-retrieval-into-gptme-rag task.What's included
keyword_to_regex/match_keyword: wildcard keyword matching (*→\w*, word chars only — sopre*commitmatchesprecommitbut NOTpre-commit)extract_frontmatter: YAML frontmatter parser with stdlib regex fallback (no PyYAML required)scan_lessons: scan lesson dirs, first-dir-wins dedup by resolved path then filename, skips archive/inactive lessons; SKILL.md files not deduplicated by filenamefilter_by_session_category: gate onsession_categoriesfrontmatter field (lessons with non-empty list only fire for matching category;Noneexcludes all restricted lessons)filter_by_harness: gate onmetadata.harnessfieldfilter_held_out/parse_holdout_set/is_held_out: A/B holdout filteringscore_lessons: keyword + pattern + skill-descriptor + BM25 composite scoring; TS re-ranking intentionally NOT included (caller's responsibility)What's NOT included (stays in harness adapters)
find_workspace,load_lesson_dirs)load_prediction_model, session dropout)Tests
64 tests covering all public functions, edge cases, and BM25 scoring.
conftest.pynow guards thechromadbimport so tests run without chromadb installed.Related
upstream-retrieval-into-gptme-ragPhase 2.4Co-authored-by: Bob bob@superuserlabs.org