Feature/conversation segmenter#59
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR: Conversation segmentation for incremental extraction
Summary
Adds
ConversationSegmenter, a pure utility that splits a timestamped item stream(e.g. a chat channel's messages) into conversation segments, so each segment can be
handed to
IncrementalSource-based extraction as one conversation.Motivation
Feeding a raw firehose of messages into the incremental analyzer would window purely
by index, cutting mid-conversation. Segmenting first keeps each extraction unit
coherent. This separates two concerns:
ConversationSegmenterdecides conversation boundaries.WindowConfig) chunks within a segment bysize/overlap. Those windows, not whole segments, are what an LLM sees.
Why brief silence is not a boundary
The obvious heuristic — start a new conversation after N minutes of quiet — is wrong
for async chat. On Discord a thread routinely goes quiet for eight or twenty-four
hours while contributors sleep across timezones. Cutting on that silence would shred
exactly the long-running conversations most worth extracting.
So within a conversation, segments are bounded by size, not silence. When size
forces a cut, the cut lands on the largest interior gap, so the seam falls in a
lull rather than mid-exchange.
Why long silence is a boundary
settleis the point at which a trailing conversation is declared finished and handedto extraction. Once a conversation has been extracted, a later reply must begin a new
segment rather than re-open the extracted one — otherwise the next call re-emits a
superset of what was already consumed. The same duration therefore has to govern both:
a silence longer than
settleends the conversation, and a conversation quiet forlonger than
settleis extractable.A single knob keeps the two consistent. (An earlier draft had a separate
maxSilenceceiling defaulting to null, which contradicted
settle: a 24h gap would simultaneouslyclose a conversation and not be a boundary.)
Behavior
closedSegments(items, now, timestamp, size)returns the closed segments, each atime-ordered sub-list:
settlestarts a new conversation.maxSize, cutting at thelargest interior gap within the longest prefix that fits. The cut is constrained to
leave at least
minItemsbehind, so a lull near the head can't strand items in asub-
minItemsfragment. Ties favour the later index, keeping segments full.the trailing segment, once it has been quiet longer than
settle. The still-opentrailing segment is withheld so an in-progress conversation isn't extracted and then
re-extracted when the rest arrives.
dropped, so no message is silently lost. Only a whole conversation shorter than
minItemsis dropped.Pure and deterministic — no LLM, no I/O. Generic over the item type; the caller
supplies a timestamp accessor and, optionally, a size accessor. Each accessor is
invoked exactly once per item, so a costly one (a token estimator) is affordable.
API
On the defaults
maxSizeis not a context-window constraint — the analyzer chunks a segment intoWindowConfig.windowSize(10–20) message windows, and only those reach the model. Itis a coherence cap: past some length, a run of messages is no longer plausibly one
conversation.
The default of 500 is 25–50 analyzer windows wide, is a genuinely long thread, and
— should a whole segment ever be sent to a model — is roughly 20k tokens at typical
message length, comfortably inside any modern context window. A segment absorbing an
undersized tail may exceed it by up to
minItems - 1items; it is a cap on coherence,not a hard limit.
maxSizeis expressed in whatever units thesizeaccessor returns. The defaultaccessor counts one unit per item, making it a message count; a caller supplying a
token estimator must scale
maxSizeaccordingly. Note thatminItemsalways countsitems — setting it above the number of items
maxSizeadmits discards the stream.settledefaults to 24h: long enough to respect timezone-spanning replies, shortenough that extraction isn't perpetually stalled.
Idempotency
Segmentation of a given prefix is stable as later items arrive, so repeated calls over
a growing stream emit each closed segment exactly once — provided items are ingested in
timestamp order. An item back-dated to within
settleof an already-extracted segmentwill re-open it.
Tests
ConversationSegmenterTest(20 tests) covers:settlesplits a dormant channel, and each conversation is thenindependently capped by
maxSize.maxSize, cutting at the largest interior gap.maxSizeis never split, however lulled.nowpreceding thelast item leaves it open.
minItemsbehind.withheld rather than folded.
sizeaccessor weights items againstmaxSize, and each accessor is invokedonce per item.
maxSizebecomes its own segment.minItemsabove whatmaxSizeadmits; empty input; out-of-order input; tied timestamps; zero gapsthroughout.
Files
dice/src/main/kotlin/com/embabel/dice/incremental/ConversationSegmenter.kt(new)dice/src/test/kotlin/com/embabel/dice/incremental/ConversationSegmenterTest.kt(new)