Revert adjacent same-speaker segment collapse
User found the merged transcript lines harder to read — too many sentences joined into one statement. Remove SpeakerReconciler.mergeAdjacent, its wiring in finishBackend (restore the no-LLM early return), and its tests. Back to one segment per diarized utterance.
This commit is contained in:
@@ -396,32 +396,24 @@ final class SessionController: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Post-transcription cleanup + LLM passes. Speaker reconciliation (merge split
|
/// Post-transcription LLM passes (best-effort, share one gateway model lookup):
|
||||||
/// clusters + content-naming) and the readable recap need the gateway LLM; the
|
/// reconcile speaker labels (merge split clusters + name from content), then build
|
||||||
/// adjacent-segment collapse does not. So the collapse runs unconditionally and
|
/// the readable recap. A missing LLM or any failure leaves speakers.json intact.
|
||||||
/// always re-persists `speakers.json`, while the LLM passes are skipped when no
|
|
||||||
/// model is available. Any failure leaves the last good `speakers.json` intact.
|
|
||||||
private func finishBackend(speakers: SpeakersFile, inputs: ProcessInputs, settings: AppSettings) async {
|
private func finishBackend(speakers: SpeakersFile, inputs: ProcessInputs, settings: AppSettings) async {
|
||||||
let llm = GatewayLLMClient(baseURL: settings.backendBaseURL, skipTLS: settings.skipTLSVerification)
|
let llm = GatewayLLMClient(baseURL: settings.backendBaseURL, skipTLS: settings.skipTLSVerification)
|
||||||
let model = await llm.chatModelId() // nil → no LLM on the gateway; LLM passes skipped
|
guard let model = await llm.chatModelId() else { return } // no LLM on the gateway → skip both
|
||||||
|
|
||||||
var resolved = speakers
|
var resolved = speakers
|
||||||
// Reconcile labels (needs the LLM): merge split clusters, dissolve fragments,
|
if settings.reconcileSpeakers, !speakers.segments.isEmpty {
|
||||||
// and name placeholders from transcript content.
|
|
||||||
if let model, settings.reconcileSpeakers, !speakers.segments.isEmpty {
|
|
||||||
self.transcriptStatus = .processing(0, 0)
|
self.transcriptStatus = .processing(0, 0)
|
||||||
let fps = RecapEditModel.loadFingerprints(inputs.folder.appendingPathComponent("cluster_fingerprints.json"))
|
let fps = RecapEditModel.loadFingerprints(inputs.folder.appendingPathComponent("cluster_fingerprints.json"))
|
||||||
resolved = await SpeakerReconciler.reconcile(file: speakers, fingerprints: fps,
|
resolved = await SpeakerReconciler.reconcile(file: speakers, fingerprints: fps,
|
||||||
selfName: inputs.selfName, llm: llm, model: model)
|
selfName: inputs.selfName, llm: llm, model: model)
|
||||||
}
|
|
||||||
// Collapse adjacent same-speaker segments (no LLM needed) so fragments
|
|
||||||
// reabsorbed by smoothing read as one clean line, then persist. Always runs
|
|
||||||
// — even when the LLM is unavailable — so the saved transcript is cleaned up.
|
|
||||||
resolved = SpeakerReconciler.mergeAdjacent(resolved)
|
|
||||||
try? resolved.write(to: inputs.folder.appendingPathComponent("speakers.json"))
|
try? resolved.write(to: inputs.folder.appendingPathComponent("speakers.json"))
|
||||||
self.transcriptStatus = .done(speakers: resolved.speakers.count, segments: resolved.segments.count)
|
self.transcriptStatus = .done(speakers: resolved.speakers.count, segments: resolved.segments.count)
|
||||||
|
}
|
||||||
|
|
||||||
guard let model, settings.recapEnabled, !resolved.segments.isEmpty else { return }
|
guard settings.recapEnabled, !resolved.segments.isEmpty else { return }
|
||||||
let analyzer = RecapAnalyzer(llm: llm, model: model)
|
let analyzer = RecapAnalyzer(llm: llm, model: model)
|
||||||
guard let result = try? await analyzer.recap(file: resolved, template: settings.defaultTemplate) else { return }
|
guard let result = try? await analyzer.recap(file: resolved, template: settings.defaultTemplate) else { return }
|
||||||
let title = Self.recapTitle(app: inputs.app, sessionId: inputs.sessionId)
|
let title = Self.recapTitle(app: inputs.app, sessionId: inputs.sessionId)
|
||||||
|
|||||||
@@ -82,28 +82,6 @@ enum SpeakerReconciler {
|
|||||||
speakers: speakers, segments: result, models: file.models)
|
speakers: speakers, segments: result, models: file.models)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collapse consecutive segments from the SAME speaker separated by ≤ `maxGap`
|
|
||||||
/// seconds into one, joining their text — so fragments reabsorbed by smoothing
|
|
||||||
/// (e.g. "I" then "need to switch it back") read as a single clean line. Pure.
|
|
||||||
static func mergeAdjacent(_ file: SpeakersFile, maxGap: Double = 2.0) -> SpeakersFile {
|
|
||||||
let sorted = file.segments.sorted { $0.start < $1.start }
|
|
||||||
guard !sorted.isEmpty else { return file }
|
|
||||||
var out: [SpeakersFile.Segment] = []
|
|
||||||
for s in sorted {
|
|
||||||
if var last = out.last, last.speaker == s.speaker, s.start - last.end <= maxGap {
|
|
||||||
let joined = [last.text, s.text].compactMap { $0?.trimmingCharacters(in: .whitespaces) }
|
|
||||||
.filter { !$0.isEmpty }.joined(separator: " ")
|
|
||||||
last = .init(start: last.start, end: max(last.end, s.end), speaker: s.speaker,
|
|
||||||
text: joined.isEmpty ? nil : joined)
|
|
||||||
out[out.count - 1] = last
|
|
||||||
} else {
|
|
||||||
out.append(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return SpeakersFile(sessionId: file.sessionId, app: file.app, durationSec: file.durationSec,
|
|
||||||
speakers: file.speakers, segments: out, models: file.models)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Voiceprint merge (pure)
|
// MARK: - Voiceprint merge (pure)
|
||||||
|
|
||||||
static func protectedNames(_ file: SpeakersFile, selfName: String) -> Set<String> {
|
static func protectedNames(_ file: SpeakersFile, selfName: String) -> Set<String> {
|
||||||
|
|||||||
@@ -73,38 +73,6 @@ final class SpeakerReconcilerTests: XCTestCase {
|
|||||||
XCTAssertTrue(out.speakers.contains { $0.name == "Me" }) // self never dissolved
|
XCTAssertTrue(out.speakers.contains { $0.name == "Me" }) // self never dissolved
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMergeAdjacentCollapsesSameSpeakerAndJoinsText() {
|
|
||||||
let f = file([sp("A", "content"), sp("B", "content")], [
|
|
||||||
SpeakersFile.Segment(start: 0, end: 1, speaker: "A", text: "I"),
|
|
||||||
SpeakersFile.Segment(start: 1.5, end: 4, speaker: "A", text: "need to switch it back"),
|
|
||||||
SpeakersFile.Segment(start: 4.2, end: 6, speaker: "B", text: "Sure"),
|
|
||||||
])
|
|
||||||
let out = SpeakerReconciler.mergeAdjacent(f, maxGap: 2.0)
|
|
||||||
XCTAssertEqual(out.segments.count, 2) // two A's collapsed
|
|
||||||
XCTAssertEqual(out.segments[0].speaker, "A")
|
|
||||||
XCTAssertEqual(out.segments[0].start, 0, accuracy: 0.001)
|
|
||||||
XCTAssertEqual(out.segments[0].end, 4, accuracy: 0.001)
|
|
||||||
XCTAssertEqual(out.segments[0].text, "I need to switch it back")
|
|
||||||
XCTAssertEqual(out.segments[1].speaker, "B") // different speaker untouched
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMergeAdjacentRespectsMaxGapAndSpeakerBoundaries() {
|
|
||||||
let f = file([sp("A", "content")], [
|
|
||||||
SpeakersFile.Segment(start: 0, end: 1, speaker: "A", text: "one"),
|
|
||||||
SpeakersFile.Segment(start: 5, end: 6, speaker: "A", text: "two"), // gap 4s > maxGap
|
|
||||||
])
|
|
||||||
let out = SpeakerReconciler.mergeAdjacent(f, maxGap: 2.0)
|
|
||||||
XCTAssertEqual(out.segments.count, 2) // large gap → not merged
|
|
||||||
|
|
||||||
// A B A must stay three segments (intervening speaker breaks the run).
|
|
||||||
let g = file([sp("A", "content"), sp("B", "content")], [
|
|
||||||
SpeakersFile.Segment(start: 0, end: 1, speaker: "A", text: "a1"),
|
|
||||||
SpeakersFile.Segment(start: 1.2, end: 2, speaker: "B", text: "b"),
|
|
||||||
SpeakersFile.Segment(start: 2.2, end: 3, speaker: "A", text: "a2"),
|
|
||||||
])
|
|
||||||
XCTAssertEqual(SpeakerReconciler.mergeAdjacent(g, maxGap: 2.0).segments.count, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testParseNamingDropsNullAndKeepsConfidence() {
|
func testParseNamingDropsNullAndKeepsConfidence() {
|
||||||
let json = #"{"speakers":[{"current":"MH","name":"Jonathan Kirkwood","confidence":"high"},{"current":"Unknown_0","name":null,"confidence":"low"}]}"#
|
let json = #"{"speakers":[{"current":"MH","name":"Jonathan Kirkwood","confidence":"high"},{"current":"Unknown_0","name":null,"confidence":"low"}]}"#
|
||||||
let m = SpeakerReconciler.parseNaming(json)
|
let m = SpeakerReconciler.parseNaming(json)
|
||||||
|
|||||||
Reference in New Issue
Block a user