mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.41.24.0 fix(conversation-parser): threshold gates + bold-paren-time pattern — 20,167 Circleback messages unblocked (closes #1533) (#1543)
* fix(conversation-parser): threshold-gated fallback + acceptance floor (closes #1533) `gbrain conversation-parser scan` reported `phase: no_match` on meeting pages where 175 of 226 lines (77.8%) were valid `imessage-slack` format. The 36 reformatted Circleback meetings could not flow through the conversation facts pipeline. Root cause: `scorePattern` only scans the first 10 non-blank lines. A meeting page's `## Summary` + blockquote + `## Transcript` preamble takes all 10 head slots, so every pattern scored 0 and the orchestrator short-circuited to `no_match` without ever seeing the transcript. Fix: two-tier scoring with threshold gates. 1. Fast path unchanged: chat-only pages match on line 1, scoring 1.0, skipping the fallback entirely. 2. Full-body fallback fires when `top.score < SCORING_HEAD_TRIGGER_THRESHOLD` (0.3). NOT `=== 0` — Codex P1 #1 caught the bug class where a stray head match (blockquote that accidentally matches an unrelated pattern at 0.1) would suppress the fallback. 0.3 leaves the fast path untouched while triggering on any preamble-dominated page. 3. Minimum acceptance floor `SCORING_MIN_ACCEPTANCE` (0.05) prevents essay false positives: a 300-line essay with one stray `**Name** (date time):` line scores ~0.003 — without the floor it would flip to `regex_match` with `messages.length = 1`. Closes Codex P1 #2. DRY refactor: extract `getNonBlankLines` + `scoreFromLines` so the quick_reject + regex loop lives in one place. New exported `scorePatternFull` for direct unit testing. Fallback pre-splits the body ONCE per pass to avoid 12 redundant splits. Plan + decisions + Codex consult absorption at: ~/.claude/plans/system-instruction-you-are-working-starry-frost.md Tests: 10 new cases in test/conversation-parser/parse.test.ts (87 pass). Highlights: - #1533 IRON-RULE regression pin (meeting page → regex_match, imessage-slack, 20 messages) - Stray-head-match guard (Codex P1 #1: irc-classic 0.1 in head does not suppress fallback; imessage-slack wins on full body) - Essay false-positive guard (Codex P1 #2: 1/301 score below acceptance floor stays no_match) - 300-line preamble + 50 chat lines hits fallback - Cap test reshaped (Codex P2 #6): pins behavior not constant value Once landed and a brain has `cycle.conversation_facts_backfill.enabled = true` (opt-in), the 36 Circleback meetings flow through the fact extractor automatically. Operators on the manual path run `gbrain extract-conversation-facts <source>` directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(conversation-parser): add bold-paren-time built-in pattern (closes user-facing half of #1533) Per /codex follow-up D-FOLLOWUP-1.B: the threshold-gated fallback fix (8d7a18ac) closed the bug CLASS — but the user's actual 112 Circleback meeting files at ~/git/brain/meetings/ use a transcript shape that no existing built-in pattern matches: **Participant 2** (00:00): Companies that we... ← (HH:MM) **Participant 1** (00:00:00): We found the... ← (HH:MM:SS) Without a pattern that matches this shape, even the fallback re-scoring all 12 candidates against the full body returned zero matches → no_match. Add `bold-paren-time` as the 13th built-in. Two sub-shapes covered via non-capturing optional seconds group; capture indexes stay identical. date_source: frontmatter so the page's `date:` provides the day anchor. Time semantics: Circleback timestamps are elapsed-time-from-meeting- start, not wall-clock. Parser treats them as wall-clock 24h on the frontmatter date, so every message lands on the same day at HH:MM. The fact extractor only cares about speaker + content, so this is honest enough; precise per-line wall-clock would need an elapsed_time flag on PatternEntry (v0.42+ scope). Declaration position: after imessage-slack and telegram-bracket so on the rare tie those more-specific patterns win. The regex requires `\)` immediately after the time group, so imessage-slack's `(2024-03-15 9:00 AM)` shape falls through correctly. EMPIRICAL RESULT against all Circleback meetings in ~/git/brain/meetings: - 367 total files with `source: circleback` - Pre-fix: 0 parsed (no pattern matched the shape) - Post-fix: 113 parsed (112 via bold-paren-time, 1 via telegram-bracket) - 20,167 messages flow through to the fact extractor (was 0) - 254 remain no_match (notes-only meetings without inline transcripts — transcripts in those cases live in separate files referenced via blockquote, not in the meeting body) Smoke-tested manually against 3 representative files: - 2026-03-19-yc-partner-strategy-ai-leverage-review.md → 225 messages - 2026-01-15-ro-khanna-c4.md → 294 messages - 2026-04-01-narrative-arc-equity-regrets.md → 9 messages (HH:MM:SS variant) Tests: 54 unit cases pass (88 across full parser suite). 3 new cases in parse.test.ts pin the contract: (HH:MM) shape matches, (HH:MM:SS) shape matches, imessage-slack still wins on full-datetime overlap, and meeting page with preamble + bold-paren-time transcript hits the threshold fallback correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.41.21.0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: bump conversation-parser entry to v0.41.21.0 (13 patterns + threshold gates) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(privacy): scrub OpenClaw fork name from new pattern's source_doc + CLAUDE.md CI verify failed on check:privacy — the new bold-paren-time pattern added in80208f21referenced the private OpenClaw fork name in builtins.ts:125 (comment) and builtins.ts:178 (source_doc), and the CLAUDE.md doc-sync commit1710020aleaked it once more. CLAUDE.md privacy rule (line 550): the private fork name is banned in CHANGELOG.md, README.md, docs/, skills/, PR titles + bodies, commit messages, and comments in checked-in code. Canonical replacement: "your OpenClaw" or "OpenClaw reference deployment". This commit rewrites all three sites. Source pipeline attribution stays accurate ("OpenClaw meeting-ingestion pipeline reformat of Circleback transcripts") without naming the specific private fork. bun run verify: 28/28 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version 0.41.21.0 → 0.41.24.0 Queue reservation by user — 0.41.22.0 / 0.41.23.0 slots left for sibling worktrees. Bumps VERSION, package.json, CHANGELOG header, and the CLAUDE.md entry's version tag in lockstep. llms-full.txt regenerated. bun run verify: 28/28 green. Parser tests: 92/92. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
48e1000306
commit
726dfff02c
+138
@@ -2,6 +2,70 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.41.24.0] - 2026-05-27
|
||||
|
||||
**Your reformatted meeting transcripts now actually parse.**
|
||||
|
||||
If you reformat Circleback meetings into a per-line shape like
|
||||
`**Garry Tan** (12:34): hello`, gbrain used to look at them and say
|
||||
`no_match` — even when 78% of the page was valid chat. Your 36 reformatted
|
||||
meetings sat there inert and the fact extractor never saw a single
|
||||
message. v0.41.24.0 makes them flow through.
|
||||
|
||||
Two problems were stacked. First, the parser only looked at the first
|
||||
10 lines of a page to figure out which chat format it was. Meeting
|
||||
pages start with `## Summary`, a blockquote, a `## Transcript` heading
|
||||
— ~10 lines of prose before the actual chat. None of those lines
|
||||
matched any chat regex, so the parser gave up. Second, even if the
|
||||
parser had kept looking, none of the 12 built-in patterns recognized
|
||||
the time-only shape Circleback exports use: `**Speaker** (HH:MM):` or
|
||||
`**Speaker** (HH:MM:SS):`. Both shapes were invisible.
|
||||
|
||||
The fix tightens the detector AND teaches it the missing shape. The
|
||||
detector now falls back to scanning the full body when the head pass
|
||||
returns a weak score, AND it refuses to accept a final score below 5%
|
||||
(so an essay with one stray chat-shape line stays `no_match` instead
|
||||
of false-positive parsing as a 1-message conversation). The new
|
||||
`bold-paren-time` built-in covers the two Circleback variants. After
|
||||
the fix, 113 of 367 Circleback meeting pages on the canonical brain
|
||||
parse correctly (was 0), and **20,167 messages** flow through to the
|
||||
fact extractor for the first time. The other 254 are notes-only
|
||||
meetings that link to a separate transcript file — those legitimately
|
||||
have nothing to parse.
|
||||
|
||||
**How to use it:** `gbrain upgrade`. Then either enable the cycle phase
|
||||
that runs in the background (`gbrain config set
|
||||
cycle.conversation_facts_backfill.enabled true`) or fire it manually
|
||||
(`gbrain extract-conversation-facts <source>`). The parser fix
|
||||
unblocks the pipeline; the trigger is yours.
|
||||
|
||||
**The detector numbers that matter** (parse.ts):
|
||||
|
||||
| Setting | Value | What it does |
|
||||
|---|---|---|
|
||||
| `SCORING_HEAD_LINES` | 10 | Lines scanned in the fast path (unchanged) |
|
||||
| `SCORING_HEAD_TRIGGER_THRESHOLD` | 0.3 | Below this, fall back to full-body scoring |
|
||||
| `SCORING_MIN_ACCEPTANCE` | 0.05 | Final score floor — below this, return `no_match` |
|
||||
|
||||
The 0.3 trigger threshold (instead of "fall back only on score === 0")
|
||||
closes a silent bug class: a blockquote that accidentally matches an
|
||||
unrelated pattern at 0.1 used to suppress the fallback entirely. Now
|
||||
it doesn't.
|
||||
|
||||
**Things to watch:**
|
||||
|
||||
- The new pattern treats Circleback's `(00:00)` / `(00:00:00)` as
|
||||
wall-clock time on the page's frontmatter date. Real Circleback
|
||||
timestamps are elapsed-time-from-meeting-start, so every message in
|
||||
a meeting lands on the same day starting at 00:00 + offset. The
|
||||
fact extractor cares about speaker + content, not precise
|
||||
timestamps, so this is honest enough. Proper per-line wall-clock
|
||||
reconstruction would need an `elapsed_time: true` flag on
|
||||
PatternEntry — filed as v0.42+ scope.
|
||||
- `imessage-slack` still wins on full-datetime overlaps. The new
|
||||
pattern's regex requires `\)` immediately after the time group, so
|
||||
`(2024-03-15 9:00 AM)` shapes correctly fall through to the more
|
||||
specific pattern.
|
||||
## [0.41.23.0] - 2026-05-26
|
||||
|
||||
**You can now see how every extractor in your brain is doing — how
|
||||
@@ -577,6 +641,80 @@ doctor` and `~/.gbrain/upgrade-errors.jsonl` if it exists.
|
||||
### Itemized changes
|
||||
|
||||
#### Added
|
||||
- `bold-paren-time` built-in pattern in
|
||||
`src/core/conversation-parser/builtins.ts` recognizes
|
||||
`**Speaker** (HH:MM): text` and `**Speaker** (HH:MM:SS): text`.
|
||||
Verified against 367 Circleback meeting files at `~/git/brain/meetings/`:
|
||||
113 now parse (was 0), 20,167 messages flowing through.
|
||||
- `scorePatternFull(body, entry)` exported from
|
||||
`src/core/conversation-parser/parse.ts` for callers that need
|
||||
full-body scoring of a single pattern.
|
||||
|
||||
#### Changed
|
||||
- `parseConversation()` falls back to full-body re-scoring when the
|
||||
head pass's top score is below `SCORING_HEAD_TRIGGER_THRESHOLD`
|
||||
(0.3) instead of only when it is exactly 0. The pre-fix shape left
|
||||
a real bug class open where a stray head match suppressed the
|
||||
fallback.
|
||||
- `parseConversation()` returns `no_match` when the final winner's
|
||||
score is below `SCORING_MIN_ACCEPTANCE` (0.05) to prevent
|
||||
essay-false-positive parsing.
|
||||
- `scorePattern()` is now a thin wrapper over a private
|
||||
`scoreFromLines` core that holds the quick_reject + regex loop in
|
||||
one place. `scorePattern` behavior + contract + signature unchanged.
|
||||
|
||||
#### Tests
|
||||
- 11 new unit cases in `test/conversation-parser/parse.test.ts`:
|
||||
the #1533 IRON-RULE regression pin (meeting page →
|
||||
`regex_match`), honest unmatched-count after fallback, pure-prose
|
||||
stays `no_match`, 300-line preamble + 50 chat lines hits fallback,
|
||||
`scorePatternFull` direct boundaries, stray-head-match
|
||||
miscategorization guard, essay false-positive acceptance-floor
|
||||
guard, `bold-paren-time` matches HH:MM, `bold-paren-time` matches
|
||||
HH:MM:SS, `imessage-slack` still wins on full-datetime overlap,
|
||||
meeting page with preamble + `bold-paren-time` transcript hits
|
||||
fallback.
|
||||
- Existing "caps at SCORING_HEAD_LINES (10)" test reshaped to pin
|
||||
behavior (10 match + 1 non-match scores 1.0; 9 non-match + 1 match
|
||||
+ 100 after scores 0.1) instead of importing the constant.
|
||||
|
||||
#### Closed
|
||||
- Closes #1533 — both the bug class (trigger threshold + acceptance
|
||||
floor) and the user-facing case (113 Circleback meetings × 20,167
|
||||
messages).
|
||||
|
||||
## To take advantage of v0.41.24.0
|
||||
|
||||
`gbrain upgrade` should do this automatically. Then to actually flow
|
||||
your reformatted Circleback meetings through the fact extractor:
|
||||
|
||||
1. **One-shot manual extraction (immediate):**
|
||||
```bash
|
||||
gbrain extract-conversation-facts <source>
|
||||
```
|
||||
Replace `<source>` with the source id holding your meeting pages.
|
||||
Use `gbrain sources list` to find it.
|
||||
|
||||
2. **OR enable the cycle phase (continuous, opt-in):**
|
||||
```bash
|
||||
gbrain config set cycle.conversation_facts_backfill.enabled true
|
||||
```
|
||||
The dream cycle picks them up on the next tick.
|
||||
|
||||
3. **Verify the parser sees them:**
|
||||
```bash
|
||||
gbrain conversation-parser scan <slug> --json
|
||||
```
|
||||
Expected output for a Circleback meeting:
|
||||
`"phase": "regex_match"`, `"matched_pattern_id":
|
||||
"bold-paren-time"`, `"message_count"` in the dozens to hundreds.
|
||||
|
||||
4. **If parser still reports `no_match`** on a page you expected to
|
||||
parse, your meeting may use a transcript shape outside the 13
|
||||
built-in patterns. Run `gbrain conversation-parser list-builtins`
|
||||
to see what shapes are recognized, file an issue with a sample
|
||||
page, or wait for v0.42+'s user-declared `simple_pattern`
|
||||
support.
|
||||
- `atomsExistingForHashes(engine, sourceId, hashes[])` exported from
|
||||
`src/core/cycle/extract-atoms.ts` — one batched SQL roundtrip that
|
||||
returns the set of `content_hash16` values already extracted as atoms
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -140,5 +140,5 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.41.23.0"
|
||||
"version": "0.41.24.0"
|
||||
}
|
||||
|
||||
@@ -119,6 +119,65 @@ export const BUILTIN_PATTERNS: readonly PatternEntry[] = [
|
||||
source_doc: 'PR #1461 (closed); preserved verbatim with Co-Authored-By',
|
||||
},
|
||||
|
||||
{
|
||||
// v0.41.18+ (D-FOLLOWUP-1.B closes the user-facing half of #1533):
|
||||
// matches the shape Circleback meeting exports use after an
|
||||
// OpenClaw meeting-ingestion pipeline reformats them. Two
|
||||
// sub-shapes in the wild (verified across a 367-file corpus):
|
||||
// **Participant 2** (00:00): Companies that we have... ← (HH:MM)
|
||||
// **Participant 1** (00:00:00): We found the apostrophes... ← (HH:MM:SS)
|
||||
//
|
||||
// The time group is elapsed time from meeting start, NOT
|
||||
// wall-clock. Parser treats it as wall-clock 24h on the
|
||||
// frontmatter date — speaker + text are captured correctly, but
|
||||
// every message lands on the same day starting at 00:00 + offset
|
||||
// minutes. The downstream fact extractor only cares about
|
||||
// speaker + content, so this is honest-enough; precise per-line
|
||||
// wall-clock timestamps would require a new `elapsed_time:
|
||||
// true` flag on PatternEntry (v0.42+).
|
||||
//
|
||||
// Declaration position is AFTER imessage-slack + telegram-
|
||||
// bracket so on the rare tie those more-specific patterns win.
|
||||
// The regex deliberately requires `\)` immediately after the
|
||||
// time so `(2024-03-15 9:00 AM)` and `(9:00 AM)` shapes fall
|
||||
// through to imessage-slack instead of false-matching here.
|
||||
// The seconds segment is a non-capturing optional group so
|
||||
// capture indexes stay identical across both sub-shapes.
|
||||
id: 'bold-paren-time',
|
||||
origin: 'builtin',
|
||||
regex: /^\*\*(.+?)\*\*\s+\((\d{1,2}):(\d{2})(?::\d{2})?\)\s*:\s*(.*)$/,
|
||||
captures: {
|
||||
speaker_group: 1,
|
||||
hour_group: 2,
|
||||
minute_group: 3,
|
||||
text_group: 4,
|
||||
},
|
||||
date_source: 'frontmatter',
|
||||
time_format: '24h',
|
||||
timezone_policy: 'utc_assumed_with_warn',
|
||||
multi_line: false,
|
||||
quick_reject: /^\*\*/,
|
||||
test_positive: [
|
||||
'**Garry Tan** (00:00): hello world',
|
||||
'**Participant 2** (02:22): response here',
|
||||
'**Alex Graveley** (15:09): That’s exactly right.',
|
||||
'**Participant 1** (00:00:00): hello world with seconds',
|
||||
'**Participant 2** (01:23:45): mid-meeting line',
|
||||
],
|
||||
test_negative: [
|
||||
// imessage-slack shape (full date+time) MUST fall through to imessage-slack:
|
||||
'**Alice Example** (2024-03-15 9:00 AM): iMessage shape',
|
||||
// telegram-bracket shape MUST fall through to telegram-bracket:
|
||||
'**[18:37] \u{1f464} G T:** telegram bracket',
|
||||
// No bold markers:
|
||||
'Alice (00:00): missing the bold',
|
||||
// Bold but no parens:
|
||||
'**Alice** hello world',
|
||||
],
|
||||
source_doc:
|
||||
'OpenClaw meeting-ingestion pipeline reformat of Circleback transcripts (see your OpenClaw skills/meeting-ingestion/SKILL.md)',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'telegram-text-export',
|
||||
origin: 'builtin',
|
||||
|
||||
@@ -48,6 +48,33 @@ export type { ParseConversationOpts, ParseResult, MatchedMessage } from './types
|
||||
*/
|
||||
const SCORING_HEAD_LINES = 10;
|
||||
|
||||
/**
|
||||
* Head-pass score below which `parseConversation` falls back to a
|
||||
* full-body re-score (v0.41.18+ fix for #1533 + Codex P1 #1).
|
||||
*
|
||||
* Why a threshold instead of `=== 0`: meeting pages start with
|
||||
* `## Summary` + blockquotes + `## Transcript` headings. A
|
||||
* blockquote like `> [12:00] Foo` can accidentally match an
|
||||
* unrelated pattern's regex (irc-classic at score 0.1) which would
|
||||
* suppress the fallback even when 175 of 226 lines further down are
|
||||
* valid imessage-slack. 0.3 = "fewer than 3 of 10 head lines
|
||||
* matched"; chat-only pages still score 1.0 and skip the fallback
|
||||
* entirely, so the fast path is preserved.
|
||||
*/
|
||||
const SCORING_HEAD_TRIGGER_THRESHOLD = 0.3;
|
||||
|
||||
/**
|
||||
* Minimum final winner score required to accept `regex_match`
|
||||
* (v0.41.18+ Codex P1 #2). A 500-line essay with one stray
|
||||
* `**Name** (date time):` line scores ~1/500 = 0.002 for
|
||||
* imessage-slack, which without this floor would flip to
|
||||
* `regex_match` with `messages.length = 1` — a false positive that
|
||||
* silently corrupts downstream fact extraction. 0.05 = "at least 5%
|
||||
* of non-blank lines anchored a message"; real transcript pages
|
||||
* typically score 0.5+ and sail through, accidental anchors do not.
|
||||
*/
|
||||
const SCORING_MIN_ACCEPTANCE = 0.05;
|
||||
|
||||
/**
|
||||
* Tie-breaker priority: lower index wins on score tie. Mirrors
|
||||
* BUILTIN_PATTERNS declaration order. User-declared patterns get
|
||||
@@ -334,6 +361,41 @@ export function applyPattern(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a body into non-blank trimmed lines. `headCap` (when set)
|
||||
* limits the result to the first N lines — used by the head-pass
|
||||
* scorer; omit for full-body scoring.
|
||||
*/
|
||||
function getNonBlankLines(body: string, headCap?: number): string[] {
|
||||
if (!body) return [];
|
||||
const all = body
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
return headCap !== undefined ? all.slice(0, headCap) : all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core scorer over a pre-split line array. Both `scorePattern` (head
|
||||
* window) and `scorePatternFull` (whole body) delegate here so the
|
||||
* quick_reject + regex loop lives in one place. Reused by
|
||||
* `parseConversation`'s fallback path which pre-splits ONCE and
|
||||
* passes the array to all 12 candidates (saves 11 redundant body
|
||||
* splits per fallback pass).
|
||||
*/
|
||||
function scoreFromLines(
|
||||
lines: readonly string[],
|
||||
entry: PatternEntry,
|
||||
): number {
|
||||
if (lines.length === 0) return 0;
|
||||
let anchored = 0;
|
||||
for (const line of lines) {
|
||||
if (entry.quick_reject && !entry.quick_reject.test(line)) continue;
|
||||
if (entry.regex.test(line)) anchored++;
|
||||
}
|
||||
return anchored / lines.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score how well a pattern matches the first N lines of a body (D18).
|
||||
* Returns 0..1 ratio of matched lines. Higher = more confident.
|
||||
@@ -344,19 +406,22 @@ export function applyPattern(
|
||||
* Exported for tests.
|
||||
*/
|
||||
export function scorePattern(body: string, entry: PatternEntry): number {
|
||||
if (!body) return 0;
|
||||
const lines = body
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0)
|
||||
.slice(0, SCORING_HEAD_LINES);
|
||||
if (lines.length === 0) return 0;
|
||||
let anchored = 0;
|
||||
for (const line of lines) {
|
||||
if (entry.quick_reject && !entry.quick_reject.test(line)) continue;
|
||||
if (entry.regex.test(line)) anchored++;
|
||||
}
|
||||
return anchored / lines.length;
|
||||
return scoreFromLines(getNonBlankLines(body, SCORING_HEAD_LINES), entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Score how well a pattern matches the FULL body, no head cap
|
||||
* (v0.41.18+ Codex P1 #1 — full-body fallback when head pass falls
|
||||
* below `SCORING_HEAD_TRIGGER_THRESHOLD`).
|
||||
*
|
||||
* Cost-aware: in `parseConversation`'s fallback path we pre-split
|
||||
* ONCE and route through `scoreFromLines` directly, NOT through this
|
||||
* wrapper, to avoid 12 redundant body splits per pass. This wrapper
|
||||
* exists for direct unit testing and for any future caller that
|
||||
* needs full-body scoring of a single pattern.
|
||||
*/
|
||||
export function scorePatternFull(body: string, entry: PatternEntry): number {
|
||||
return scoreFromLines(getNonBlankLines(body), entry);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -392,23 +457,51 @@ export function parseConversation(
|
||||
// declared priority order (built-in declaration order; user patterns
|
||||
// lose every tie).
|
||||
type Scored = { entry: PatternEntry; score: number; priority: number };
|
||||
const scored: Scored[] = candidates.map((entry) => ({
|
||||
const sortScored = (arr: Scored[]) =>
|
||||
arr.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return a.priority - b.priority;
|
||||
});
|
||||
let scored: Scored[] = candidates.map((entry) => ({
|
||||
entry,
|
||||
score: scorePattern(body, entry),
|
||||
priority: priorityOf(entry.id),
|
||||
}));
|
||||
// Sort: score desc, then priority asc.
|
||||
scored.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return a.priority - b.priority;
|
||||
});
|
||||
sortScored(scored);
|
||||
|
||||
// REGRESSION (closes #1533 + Codex P1 #1): meeting pages have
|
||||
// ## Summary + blockquote + ## Transcript ahead of the chat. The
|
||||
// pre-fix "trigger fallback only when score === 0" shape left a
|
||||
// real bug class open — a stray head match (e.g. a blockquote
|
||||
// that accidentally matches an unrelated pattern at 0.1)
|
||||
// suppressed the fallback even when 175 of 226 lines further down
|
||||
// were valid imessage-slack. Threshold 0.3 means "fewer than 3 of
|
||||
// 10 head lines matched"; chat-only pages still score 1.0 and skip
|
||||
// the fallback. Re-score every candidate against the full body,
|
||||
// pre-splitting ONCE to avoid 12 redundant body splits.
|
||||
if (scored[0].score < SCORING_HEAD_TRIGGER_THRESHOLD) {
|
||||
const allLines = getNonBlankLines(body);
|
||||
scored = candidates.map((entry) => ({
|
||||
entry,
|
||||
score: scoreFromLines(allLines, entry),
|
||||
priority: priorityOf(entry.id),
|
||||
}));
|
||||
sortScored(scored);
|
||||
// NOTE: patterns_scored stays as scored.length (= candidate
|
||||
// count, typically 12) even when the fallback runs — the
|
||||
// diagnostic reports "candidates considered" not "scoring
|
||||
// attempts" (Codex P2 #7).
|
||||
}
|
||||
|
||||
const top = scored[0];
|
||||
const patternsScored = scored.length;
|
||||
|
||||
// If even the top scorer matches zero lines, no regex won. Caller
|
||||
// may invoke LLM fallback (T4); for now return no_match.
|
||||
if (top.score === 0) {
|
||||
// Minimum acceptance floor (closes Codex P1 #2): an essay with
|
||||
// one stray `**Name** (date time):` line scores ~1/300 ≈ 0.003 —
|
||||
// below the 5% floor we stay no_match instead of returning a
|
||||
// 1-message false positive. Real transcript pages typically score
|
||||
// 0.5+ and sail through.
|
||||
if (top.score < SCORING_MIN_ACCEPTANCE) {
|
||||
return {
|
||||
messages: [],
|
||||
phase: 'no_match',
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
deriveDateContext,
|
||||
applyPattern,
|
||||
scorePattern,
|
||||
scorePatternFull,
|
||||
} from '../../src/core/conversation-parser/parse.ts';
|
||||
import { BUILTIN_PATTERNS } from '../../src/core/conversation-parser/builtins.ts';
|
||||
import type { Page } from '../../src/core/types.ts';
|
||||
@@ -336,13 +337,272 @@ describe('scorePattern — boundary', () => {
|
||||
const tg = BUILTIN_PATTERNS.find((p) => p.id === 'telegram-bracket')!;
|
||||
expect(scorePattern('\n\n \n', tg)).toBe(0);
|
||||
});
|
||||
test('caps at SCORING_HEAD_LINES (10) lines', () => {
|
||||
// 100 telegram lines → still scores 1.0 because only first 10 sampled.
|
||||
const body = Array.from(
|
||||
{ length: 100 },
|
||||
(_, i) => `**[18:${String(i).padStart(2, '0')}] \u{1f464} Alice:** msg ${i}`,
|
||||
).join('\n');
|
||||
// T5 reshape (Codex P2 #6): pins BEHAVIOR not the constant value.
|
||||
// The prior test ("100 matching lines score 1.0") would pass with
|
||||
// head=10 or head=1000 — it didn't prove anything about the cap.
|
||||
test('head cap ignores lines past line 10 (10 match + 1 non-match scores 1.0)', () => {
|
||||
const tg = BUILTIN_PATTERNS.find((p) => p.id === 'telegram-bracket')!;
|
||||
const matching = Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) => `**[18:${String(i).padStart(2, '0')}] \u{1f464} Alice:** msg ${i}`,
|
||||
);
|
||||
const body = [...matching, 'plain text outside the head window'].join('\n');
|
||||
// First 10 lines all match → 10/10. Line 11 was ignored.
|
||||
expect(scorePattern(body, tg)).toBe(1);
|
||||
});
|
||||
test('head cap stops at line 10 (9 non-match + 1 match at line 10 + 100 match after scores 0.1)', () => {
|
||||
const tg = BUILTIN_PATTERNS.find((p) => p.id === 'telegram-bracket')!;
|
||||
const nonMatches = Array.from({ length: 9 }, (_, i) => `non-matching prose line ${i}`);
|
||||
const matchingLate = Array.from(
|
||||
{ length: 100 },
|
||||
(_, i) => `**[18:${String(i).padStart(2, '0')}] \u{1f464} Alice:** msg ${i}`,
|
||||
);
|
||||
// Line 10 (index 9 in the matching array) IS a match; lines 11-109 are too
|
||||
// but are past the head cap and don't count.
|
||||
const body = [...nonMatches, matchingLate[0], ...matchingLate.slice(1)].join('\n');
|
||||
// Head sees 9 non-matches + 1 match = 1/10 = 0.1. Pre-fix: same result.
|
||||
// Post-fix: same result (this test pins head-cap behavior, not the new
|
||||
// fallback path — that's tested separately below).
|
||||
expect(scorePattern(body, tg)).toBe(0.1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scorePatternFull — direct unit tests (v0.41.18+ T3 #5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('scorePatternFull — full-body scoring (v0.41.18+ Codex P1 #1)', () => {
|
||||
test('empty body scores 0', () => {
|
||||
const im = BUILTIN_PATTERNS.find((p) => p.id === 'imessage-slack')!;
|
||||
expect(scorePatternFull('', im)).toBe(0);
|
||||
});
|
||||
test('preamble + 20 matching lines scores 20/(preamble + 20)', () => {
|
||||
const im = BUILTIN_PATTERNS.find((p) => p.id === 'imessage-slack')!;
|
||||
const preamble = ['## Summary', 'Three sentences.', '> Source: ref', '## Transcript'];
|
||||
const matches = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `**Garry Tan** (2026-01-29 12:00 PM): message ${i}`,
|
||||
);
|
||||
const body = [...preamble, ...matches].join('\n');
|
||||
// 24 total non-blank, 20 match → 20/24 ≈ 0.833
|
||||
expect(scorePatternFull(body, im)).toBeCloseTo(20 / 24, 5);
|
||||
});
|
||||
test('preamble-only-no-match scores 0', () => {
|
||||
const im = BUILTIN_PATTERNS.find((p) => p.id === 'imessage-slack')!;
|
||||
const body = '## Summary\nProse paragraph.\n> Blockquote\n## Heading';
|
||||
expect(scorePatternFull(body, im)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bold-paren-time pattern (v0.41.18+ D-FOLLOWUP-1.B; closes user-facing
|
||||
// half of #1533 — the 112 Circleback meeting files at
|
||||
// ~/git/brain/meetings/*.md with `source: circleback` frontmatter)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('bold-paren-time pattern (Circleback meeting transcripts)', () => {
|
||||
test('matches **Speaker** (HH:MM): text with frontmatter date', () => {
|
||||
const body = [
|
||||
'**Garry Tan** (00:00): Hey, can you hear me?',
|
||||
'**Participant 2** (02:22): Yeah, just joined.',
|
||||
'**Garry Tan** (15:09): That makes sense.',
|
||||
].join('\n');
|
||||
const r = parseConversation(body, { fallbackDate: '2026-03-19' });
|
||||
expect(r.phase).toBe('regex_match');
|
||||
expect(r.matched_pattern_id).toBe('bold-paren-time');
|
||||
expect(r.messages).toHaveLength(3);
|
||||
expect(r.messages[0]).toEqual({
|
||||
speaker: 'Garry Tan',
|
||||
timestamp: '2026-03-19T00:00:00Z',
|
||||
text: 'Hey, can you hear me?',
|
||||
});
|
||||
expect(r.messages[2]).toEqual({
|
||||
speaker: 'Garry Tan',
|
||||
timestamp: '2026-03-19T15:09:00Z',
|
||||
text: 'That makes sense.',
|
||||
});
|
||||
});
|
||||
|
||||
test('matches **Speaker** (HH:MM:SS): text shape (Circleback seconds variant)', () => {
|
||||
const body = [
|
||||
'**Participant 1** (00:00:00): opening line',
|
||||
'**Participant 2** (00:00:19): quick reply',
|
||||
'**Participant 1** (01:23:45): later in the meeting',
|
||||
].join('\n');
|
||||
const r = parseConversation(body, { fallbackDate: '2026-04-01' });
|
||||
expect(r.phase).toBe('regex_match');
|
||||
expect(r.matched_pattern_id).toBe('bold-paren-time');
|
||||
expect(r.messages).toHaveLength(3);
|
||||
// Seconds segment is non-capturing; minute_group still captures the
|
||||
// minutes component. Time-format is wall-clock 24h on frontmatter date.
|
||||
expect(r.messages[0].timestamp).toBe('2026-04-01T00:00:00Z');
|
||||
expect(r.messages[1].timestamp).toBe('2026-04-01T00:00:00Z');
|
||||
expect(r.messages[2].timestamp).toBe('2026-04-01T01:23:00Z');
|
||||
});
|
||||
|
||||
test('imessage-slack shape still wins over bold-paren-time on overlap', () => {
|
||||
// Both patterns start with `**` and have parens. The imessage-
|
||||
// slack regex requires a full date+time inside; bold-paren-time
|
||||
// requires just `(HH:MM)`. The dates-with-AM/PM shape MUST fall
|
||||
// through to imessage-slack, not bold-paren-time.
|
||||
const body = '**Alice Example** (2024-03-15 9:00 AM): hello world';
|
||||
const r = parseConversation(body);
|
||||
expect(r.phase).toBe('regex_match');
|
||||
expect(r.matched_pattern_id).toBe('imessage-slack');
|
||||
expect(r.messages).toHaveLength(1);
|
||||
expect(r.messages[0].text).toBe('hello world');
|
||||
});
|
||||
|
||||
test('meeting page with preamble + bold-paren-time transcript hits fallback', () => {
|
||||
// Real Circleback shape: ## Summary + blockquote + ## Transcript
|
||||
// before the bold-paren-time chat. Same fallback gate that
|
||||
// closes #1533 must work for this pattern too.
|
||||
const preamble = [
|
||||
'## Summary',
|
||||
'Meeting covered Q1 roadmap discussion.',
|
||||
'> Source: circleback meeting #7411053',
|
||||
'## Topics Discussed',
|
||||
'- Roadmap',
|
||||
'- Hiring',
|
||||
'## Transcript',
|
||||
];
|
||||
const transcript = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `**Participant 2** (${String(Math.floor(i / 6)).padStart(2, '0')}:${String((i * 11) % 60).padStart(2, '0')}): transcript line ${i}`,
|
||||
);
|
||||
const body = [...preamble, ...transcript].join('\n');
|
||||
const r = parseConversation(body, { fallbackDate: '2026-03-19' });
|
||||
expect(r.phase).toBe('regex_match');
|
||||
expect(r.matched_pattern_id).toBe('bold-paren-time');
|
||||
expect(r.messages).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseConversation — full-body fallback (v0.41.18+ #1533 + Codex P1 #1, #2, #8)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseConversation — full-body fallback', () => {
|
||||
// T3 #1: IRON-RULE regression pin for #1533. Pre-fix this returns
|
||||
// no_match because head 10 sees only preamble.
|
||||
test('#1533: meeting page with ## Summary + blockquote + ## Transcript before chat hits fallback', () => {
|
||||
const preamble = [
|
||||
'## Summary',
|
||||
'This meeting covered Q1 roadmap discussion.',
|
||||
'Three engineers participated in the call.',
|
||||
'Action items were captured during the conversation.',
|
||||
'> Source: [meeting recording](https://example.com/rec/123)',
|
||||
'## Topics Discussed',
|
||||
'- Product roadmap for Q1',
|
||||
'- Engineering team allocation',
|
||||
'- Customer feedback synthesis',
|
||||
'## Transcript',
|
||||
];
|
||||
const transcript = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `**Garry Tan** (2026-01-29 12:00 PM): line ${i}`,
|
||||
);
|
||||
const body = [...preamble, ...transcript].join('\n');
|
||||
const r = parseConversation(body);
|
||||
expect(r.phase).toBe('regex_match');
|
||||
expect(r.matched_pattern_id).toBe('imessage-slack');
|
||||
expect(r.messages).toHaveLength(20);
|
||||
});
|
||||
|
||||
// T3 #2: diagnostic now reports total_non_blank - matched, not total.
|
||||
test('#1533: unmatched_line_count subtracts matched messages after fallback', () => {
|
||||
const preamble = [
|
||||
'## Summary',
|
||||
'Prose A.',
|
||||
'Prose B.',
|
||||
'> Blockquote',
|
||||
'## Transcript',
|
||||
];
|
||||
const transcript = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `**Garry Tan** (2026-01-29 12:00 PM): line ${i}`,
|
||||
);
|
||||
const body = [...preamble, ...transcript].join('\n');
|
||||
const r = parseConversation(body, { diagnostic: true });
|
||||
expect(r.phase).toBe('regex_match');
|
||||
expect(r.unmatched_line_count).toBe(5); // 25 total non-blank - 20 messages = 5
|
||||
});
|
||||
|
||||
// T3 #3: a 50-line essay with no chat shape stays no_match.
|
||||
test('pure-prose 50-line essay stays no_match (fallback found nothing to anchor)', () => {
|
||||
const body = Array.from(
|
||||
{ length: 50 },
|
||||
(_, i) => `This is the ${i + 1}th paragraph of a pure-prose article.`,
|
||||
).join('\n');
|
||||
const r = parseConversation(body);
|
||||
expect(r.phase).toBe('no_match');
|
||||
expect(r.messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
// T3 #4: proves "full-body" not just "wider window" — 300-line preamble
|
||||
// far exceeds any reasonable head-bump alternative.
|
||||
test('300-line preamble + 50 chat lines hits fallback (any preamble length)', () => {
|
||||
const preamble = Array.from(
|
||||
{ length: 300 },
|
||||
(_, i) => `Preamble paragraph ${i + 1} with prose content here.`,
|
||||
);
|
||||
const transcript = Array.from(
|
||||
{ length: 50 },
|
||||
(_, i) => `**Garry Tan** (2026-01-29 12:00 PM): chat line ${i}`,
|
||||
);
|
||||
const body = [...preamble, ...transcript].join('\n');
|
||||
const r = parseConversation(body);
|
||||
expect(r.phase).toBe('regex_match');
|
||||
expect(r.matched_pattern_id).toBe('imessage-slack');
|
||||
expect(r.messages).toHaveLength(50);
|
||||
});
|
||||
|
||||
// T3 #6 (Codex P1 #1 + #8): stray-head-match doesn't suppress fallback.
|
||||
// Pre-fix: irc-classic 0.1 in head → no fallback → irc-classic wins with 1
|
||||
// message. Post-fix: 0.1 < 0.3 trigger → fallback re-scores → imessage-slack
|
||||
// wins (50/60 ≈ 0.83 vs irc-classic 1/60 ≈ 0.017).
|
||||
test('Codex P1 #1: stray irc-classic match in head does not suppress fallback', () => {
|
||||
const preamble = [
|
||||
'## Meeting Notes',
|
||||
'<presenter> Garry Tan opening remarks', // stray irc-classic match
|
||||
'- agenda item 1',
|
||||
'- agenda item 2',
|
||||
'- agenda item 3',
|
||||
'- agenda item 4',
|
||||
'- agenda item 5',
|
||||
'- agenda item 6',
|
||||
'- agenda item 7',
|
||||
'## Transcript',
|
||||
];
|
||||
const transcript = Array.from(
|
||||
{ length: 50 },
|
||||
(_, i) => `**Garry Tan** (2024-01-29 12:00 PM): real transcript line ${i}`,
|
||||
);
|
||||
const body = [...preamble, ...transcript].join('\n');
|
||||
const r = parseConversation(body);
|
||||
expect(r.phase).toBe('regex_match');
|
||||
// The critical assertion: imessage-slack wins, NOT irc-classic.
|
||||
expect(r.matched_pattern_id).toBe('imessage-slack');
|
||||
expect(r.messages).toHaveLength(50);
|
||||
});
|
||||
|
||||
// T3 #7 (Codex P1 #2): essay with one stray chat-shape line stays
|
||||
// no_match. 1/301 ≈ 0.003, below SCORING_MIN_ACCEPTANCE (0.05).
|
||||
test('Codex P1 #2: 300-line essay with one stray chat line stays no_match (acceptance floor)', () => {
|
||||
const prose = Array.from(
|
||||
{ length: 150 },
|
||||
(_, i) => `Essay paragraph ${i + 1} of pure prose with no chat shape.`,
|
||||
);
|
||||
const strayChatLine = '**Author Name** (2024-01-01 9:00 AM): stray quoted snippet';
|
||||
const morePros = Array.from(
|
||||
{ length: 150 },
|
||||
(_, i) => `Essay continuation paragraph ${i + 151}.`,
|
||||
);
|
||||
const body = [...prose, strayChatLine, ...morePros].join('\n');
|
||||
const r = parseConversation(body);
|
||||
// Pre-fix: regex_match with messages.length === 1.
|
||||
// Post-fix: no_match because 1/301 < 0.05 acceptance floor.
|
||||
expect(r.phase).toBe('no_match');
|
||||
expect(r.messages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user