Skip to content

Commit a8ab74e

Browse files
committed
LCORE-1570: utils/compaction: additive summarize_chunk + Red Hat prompt
Add the additive summarization primitive of compaction (spike decision 2). One call to summarize_chunk produces one ConversationSummary; the caller accumulates them. The function does not mutate the conversation, does not write to the cache, does not acquire any lock — those side effects live in LCORE-1571 / 1572. SUMMARIZATION_PROMPT is a module constant carrying the five preservation directives required by the spec doc and the JIRA AC (user question + environment; errors + commands + outcomes; decisions + rationale; resolved vs open; user-vs-assistant attribution). It is scoped to Red Hat product support so the LLM keeps the operator-relevant signal that a generic summarizer typically prunes. Exposing it as a constant lets tests assert directive presence and centralizes future tuning. summarize_chunk takes summarized_through_turn from the caller rather than deriving it from old_items alone — additive compaction spans multiple invocations whose chunk boundaries do not start at zero, and the running total is the caller's bookkeeping. The function uses store=False on the responses.create call. The summarization request is a one-shot; its output is not stored as a conversation item by Llama Stack. Injecting the produced summary into the user's conversation under some marker scheme is LCORE-1572's responsibility — this module just produces the value. A LLM call that returns no extractable text raises ValueError rather than constructing an empty ConversationSummary. The PositiveInt token_count would refuse a zero-token summary anyway, and a useless context wrapper is worse than a clear error. Tests cover: * All five JIRA-required preservation directives present in the prompt constant. * Prompt body construction concatenates prompt and transcript. * Happy path: ConversationSummary is fully populated; token_count is positive; model_used is preserved; created_at is non-empty. * The LLM call uses store=False and stream=False. * Empty LLM response raises ValueError. * Additive AC ("second compaction appends a new summary chunk, does not re-summarize the first"): two sequential calls produce two independent records and the second call's prompt does not include the first chunk. Tests for partition_conversation now also run alongside the new ones in the same module.
1 parent d4aba68 commit a8ab74e

2 files changed

Lines changed: 389 additions & 2 deletions

File tree

src/utils/compaction.py

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
* ``summarize_chunk`` — call the LLM once to summarize a chunk of items
1414
and return a :class:`ConversationSummary`. This is the *additive*
1515
primitive of decision 2: each chunk is summarized once and kept.
16-
Lives in a later commit.
1716
1817
* ``recursively_resummarize`` — fall back to a single-summary collapse
1918
when the cumulative size of existing summaries itself approaches the
@@ -28,8 +27,37 @@
2827
to disentangle a tangle of side effects.
2928
"""
3029

30+
from datetime import datetime, timezone
3131
from typing import Any
3232

33+
from llama_stack_client import AsyncLlamaStackClient
34+
35+
from log import get_logger
36+
from models.compaction import ConversationSummary
37+
38+
logger = get_logger(__name__)
39+
40+
41+
SUMMARIZATION_PROMPT = (
42+
"Summarize this conversation history for an AI assistant that helps with\n"
43+
"Red Hat product support. Preserve:\n"
44+
"1. The user's original question and environment details.\n"
45+
"2. All error messages, commands run, and their outcomes.\n"
46+
"3. Key decisions and their rationale.\n"
47+
"4. What was resolved and what remains open.\n"
48+
"5. Clear attribution (what the user reported vs what the assistant"
49+
" suggested).\n"
50+
"\n"
51+
"Be concise but complete. The assistant will use this summary as its only\n"
52+
"memory of older conversation turns.\n"
53+
)
54+
"""Domain-specific summarization prompt.
55+
56+
Includes the five preservation directives required by the spec doc and
57+
the JIRA acceptance criteria. Exposed as a module constant so tests can
58+
assert directive presence and so future tuning is visible in one place.
59+
"""
60+
3361

3462
def is_message_item(item: Any) -> bool:
3563
"""Return True when *item* is a chat-message-shaped conversation item.
@@ -194,3 +222,135 @@ def partition_conversation(
194222
# the buffer-fully-degraded branch so the function has a single
195223
# well-defined behavior in every reachable state.
196224
return items, []
225+
226+
227+
def _build_summarization_prompt_body(items: list[Any]) -> str:
228+
"""Render the full prompt body sent to the summarization LLM call.
229+
230+
Concatenates the system-style summarization prompt with a
231+
``role: text``-formatted transcript of the items being summarized.
232+
233+
Parameters:
234+
items: The chunk to summarize.
235+
236+
Returns:
237+
Full prompt body string.
238+
"""
239+
transcript = format_conversation_for_summary(items)
240+
return f"{SUMMARIZATION_PROMPT}\nConversation:\n{transcript}"
241+
242+
243+
def _extract_response_text(response: Any) -> str:
244+
"""Pull the human-readable text out of a Responses-API result.
245+
246+
Walks ``response.output`` and concatenates the ``.text`` field of
247+
every content part that has one. Mirrors the shape-handling done
248+
by :func:`utils.responses.extract_text_from_response_items` but is
249+
kept local here so the compaction module does not depend on the
250+
response-handling utility module that LCORE-1572 will eventually
251+
integrate with.
252+
253+
Parameters:
254+
response: The result of ``client.responses.create``.
255+
256+
Returns:
257+
Concatenated response text, or the empty string when nothing
258+
could be extracted.
259+
"""
260+
parts: list[str] = []
261+
output = getattr(response, "output", None) or []
262+
for output_item in output:
263+
content = getattr(output_item, "content", None)
264+
if not content:
265+
continue
266+
for content_part in content:
267+
text = getattr(content_part, "text", None)
268+
if text:
269+
parts.append(text)
270+
return "".join(parts)
271+
272+
273+
async def summarize_chunk(
274+
client: AsyncLlamaStackClient,
275+
model: str,
276+
old_items: list[Any],
277+
summarized_through_turn: int,
278+
encoding_name: str,
279+
) -> ConversationSummary:
280+
"""Summarize *old_items* via one LLM call and return a ConversationSummary.
281+
282+
This is the additive primitive of compaction decision 2. The
283+
function:
284+
285+
1. Renders the summarization prompt against the transcript of
286+
*old_items*.
287+
2. Calls ``client.responses.create`` once with ``store=False`` (the
288+
summarization call is a one-shot — its output is not stored as
289+
a conversation item by Llama Stack; the caller in LCORE-1572 is
290+
responsible for injecting the summary into the conversation
291+
under whatever marker scheme it chooses).
292+
3. Wraps the resulting text in a :class:`ConversationSummary` with
293+
a freshly-computed token count and a UTC ISO 8601 timestamp.
294+
295+
The function does **not** mutate the caller's conversation, does
296+
not write to the cache, does not acquire any lock. Concurrency
297+
control and persistence belong to LCORE-1572 / LCORE-1571.
298+
299+
Parameters:
300+
client: Llama Stack client to call.
301+
model: Fully-qualified model identifier (e.g.,
302+
``"openai/gpt-4o-mini"``). The spec mandates the same
303+
model as the user's query (spike decision 3); the choice
304+
is the caller's, this function only records it.
305+
old_items: Conversation items to summarize. Non-message items
306+
are filtered out by the transcript renderer.
307+
summarized_through_turn: Running total of items the caller has
308+
already summarized at the moment this chunk completes —
309+
this value is stored on the returned ConversationSummary
310+
so the caller can advance the partition boundary on the
311+
next compaction. Caller-tracked; not derived from
312+
``old_items`` alone because additive compaction can span
313+
multiple invocations whose chunk boundaries do not start
314+
at zero.
315+
encoding_name: Tiktoken encoding name used to count tokens in
316+
the produced summary. Should match the encoding used to
317+
decide the compaction trigger.
318+
319+
Returns:
320+
A populated ConversationSummary.
321+
322+
Raises:
323+
ValueError: When the LLM call returns no extractable text and
324+
the resulting summary would be empty. A zero-token summary
325+
cannot be persisted (PositiveInt) and is also useless as
326+
context, so propagating an error is the honest behavior.
327+
"""
328+
from utils.token_estimator import estimate_tokens
329+
330+
prompt = _build_summarization_prompt_body(old_items)
331+
logger.info(
332+
"Summarizing %d conversation items (%d messages) for model %s.",
333+
len(old_items),
334+
sum(1 for item in old_items if is_message_item(item)),
335+
model,
336+
)
337+
response = await client.responses.create(
338+
input=prompt,
339+
model=model,
340+
stream=False,
341+
store=False,
342+
)
343+
summary_text = _extract_response_text(response).strip()
344+
if not summary_text:
345+
raise ValueError(
346+
"Summarization LLM call returned no extractable text; "
347+
"cannot construct ConversationSummary."
348+
)
349+
token_count = estimate_tokens(summary_text, encoding_name=encoding_name)
350+
return ConversationSummary(
351+
summary_text=summary_text,
352+
summarized_through_turn=summarized_through_turn,
353+
token_count=token_count,
354+
created_at=datetime.now(timezone.utc).isoformat(),
355+
model_used=model,
356+
)

0 commit comments

Comments
 (0)