Fix mis-attributed fragments + LLM naming guardrails + re-process saved sessions

Investigating Grant's real 38-min group call: 'Marty' was a GARBAGE cluster (192
segs, 0.37s mean, 186 ≤2 words, 125 single words flanked by the same other speaker —
diarization micro-fragments split mid-sentence, then LLM-named 'Marty'). Same for
'Message'/'HI'.

- SpeakerReconciler.smoothFragments: dissolve non-self clusters whose MEDIAN segment
  duration ≤ 1s (≥3 segs) — reassign each fragment to the temporally-nearest real
  speaker. (Median, not max, so one stray long segment can't rescue a fragment
  cluster — the bug in the first cut.) On the real call: 7 speakers (3 junk) → 4 real
  (Marty/Message/HI absorbed into Grant/Jonathan/Me/MH). Runs before LLM naming.
- LLM naming guardrails: forbid assigning the self name or ANY already-taken name to
  another voice (fixes 'Grant' = the user's name pinned on a remote speaker); prompt
  demands self-intro / direct-address evidence (mention ≠ presence), 'precision over
  coverage', one name per speaker.
- Open saved session now offers Open Editor vs Re-process, so newer logic can be
  applied to past calls (+ always-visible progress from the prior fix).

NOTE: the self-name guardrail needs the app to KNOW the user's name — selfName is still
'Me', so set it in Settings (e.g. 'Grant') so the LLM can't reuse it. 62/62 XCTest.
This commit is contained in:
Grant Gilliam
2026-06-08 12:45:17 -05:00
parent 9a18664429
commit 1c133c8970
3 changed files with 119 additions and 17 deletions
@@ -49,6 +49,30 @@ final class SpeakerReconcilerTests: XCTestCase {
XCTAssertEqual(out.speakers.count, 2)
}
func testSmoothDissolvesFragmentCluster() {
// "Frag" is mostly micro-segments (the Marty pattern: median 1s) even though
// it has one longer stray still absorbed into the surrounding real speaker.
let f = file([sp("Grant", "content"), sp("Frag", "content")],
[seg(0, 4, "Grant"), seg(4.0, 4.3, "Frag"), seg(4.4, 8, "Grant"),
seg(20, 24, "Grant"), seg(24.0, 24.2, "Frag"), seg(24.3, 28, "Grant"),
seg(30, 30.3, "Frag"), seg(31, 33, "Frag")]) // 4 Frag: 3 micro + 1 stray 2s
let out = SpeakerReconciler.smoothFragments(f, protected: [])
XCTAssertEqual(Set(out.speakers.map { $0.name }), ["Grant"]) // median(Frag)=0.3 1 dissolved
XCTAssertFalse(out.segments.contains { $0.speaker == "Frag" })
}
func testSmoothKeepsRealSpeakerWithMostlyLongSegs() {
let f = file([sp("A", "content")], [seg(0, 3, "A"), seg(3, 6, "A"), seg(6, 6.2, "A")]) // median 3 real
XCTAssertEqual(SpeakerReconciler.smoothFragments(f, protected: []).speakers.map { $0.name }, ["A"])
}
func testSmoothProtectsSelfEvenIfAllShort() {
let f = file([sp("Me", "mic_channel"), sp("A", "content")],
[seg(0, 0.3, "Me"), seg(1, 4, "A"), seg(4, 4.2, "Me")])
let out = SpeakerReconciler.smoothFragments(f, protected: ["Me"])
XCTAssertTrue(out.speakers.contains { $0.name == "Me" }) // self never dissolved
}
func testParseNamingDropsNullAndKeepsConfidence() {
let json = #"{"speakers":[{"current":"MH","name":"Jonathan Kirkwood","confidence":"high"},{"current":"Unknown_0","name":null,"confidence":"low"}]}"#
let m = SpeakerReconciler.parseNaming(json)