Skip to content

Commit 17b892e

Browse files
committed
feat(retain): expose processed_content_tokens on RetainResult
Delta retain already knows, at chunk-level granularity, which content was new vs unchanged on an upsert to an existing document_id. Surface that signal to post-retain hooks so extensions can reason about "how much content actually went through the extraction pipeline" without re-implementing the dedup logic. New field `RetainResult.processed_content_tokens: int | None`: * None — the retain went through the full (non-delta) path or has no dedup signal. Consumers should treat this as "the full submitted payload was processed." * 0 — the submission matched prior content exactly; no chunks went through extraction (metadata-only update). * N>0 — only N tokens of content+context were actually re-extracted. The remainder matched existing chunks by content_hash and was skipped. Populated in three places: * Streaming / full retain path → None * `_try_delta_retain` no-changes fallthrough (`_delta_metadata_only`) → 0 * `_try_delta_retain` partial-delta success → sum of count_tokens(content) + count_tokens(context) across the chunks built for extraction (delta_contents) Sub-batch aggregation propagates None if any sub-batch bypassed dedup, so callers never accidentally undercount when only part of a large batch was eligible for delta processing. Tests exercise the full path, unchanged-resubmit, appended-content, and no-document-id cases plus a unit check on the aggregation helper.
1 parent 45f47a9 commit 17b892e

5 files changed

Lines changed: 349 additions & 33 deletions

File tree

hindsight-api-slim/hindsight_api/engine/memory_engine.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2267,6 +2267,11 @@ async def retain_batch_async(
22672267
# Calculate total token count
22682268
total_tokens = sum(count_tokens(item.get("content", "")) for item in contents)
22692269
total_usage = TokenUsage()
2270+
# Aggregate "content tokens that actually went through extraction after
2271+
# chunk-level dedup" across sub-batches. ``None`` in any sub-batch
2272+
# means that sub-batch bypassed dedup, so the aggregate is None
2273+
# (see RetainResult.processed_content_tokens).
2274+
total_processed_content_tokens: int | None = 0
22702275

22712276
# Get batch size threshold from config
22722277
config = get_config()
@@ -2318,7 +2323,7 @@ async def retain_batch_async(
23182323
f"Processing sub-batch {i}/{len(sub_batches)}: {len(sub_batch)} items, {sub_batch_tokens:,} tokens"
23192324
)
23202325

2321-
sub_results, sub_usage = await self._retain_batch_async_internal(
2326+
sub_results, sub_usage, sub_processed = await self._retain_batch_async_internal(
23222327
bank_id=bank_id,
23232328
contents=sub_batch,
23242329
request_context=request_context,
@@ -2334,6 +2339,10 @@ async def retain_batch_async(
23342339
)
23352340
all_results.extend(sub_results)
23362341
total_usage = total_usage + sub_usage
2342+
if total_processed_content_tokens is None or sub_processed is None:
2343+
total_processed_content_tokens = None
2344+
else:
2345+
total_processed_content_tokens = total_processed_content_tokens + sub_processed
23372346

23382347
total_time = time.time() - start_time
23392348
logger.info(
@@ -2342,7 +2351,7 @@ async def retain_batch_async(
23422351
result = all_results
23432352
else:
23442353
# Small batch - use internal method directly
2345-
result, total_usage = await self._retain_batch_async_internal(
2354+
result, total_usage, total_processed_content_tokens = await self._retain_batch_async_internal(
23462355
bank_id=bank_id,
23472356
contents=contents,
23482357
request_context=request_context,
@@ -2371,6 +2380,7 @@ async def retain_batch_async(
23712380
llm_input_tokens=total_usage.input_tokens,
23722381
llm_output_tokens=total_usage.output_tokens,
23732382
llm_total_tokens=total_usage.total_tokens,
2383+
processed_content_tokens=total_processed_content_tokens,
23742384
)
23752385
try:
23762386
await self._operation_validator.on_retain_complete(result_ctx)
@@ -2403,7 +2413,7 @@ async def _retain_batch_async_internal(
24032413
operation_id: str | None = None,
24042414
outbox_callback: "Callable[[asyncpg.Connection], Awaitable[None]] | None" = None,
24052415
strategy: str | None = None,
2406-
) -> tuple[list[list[str]], "TokenUsage"]:
2416+
) -> tuple[list[list[str]], "TokenUsage", int | None]:
24072417
"""
24082418
Internal method for batch processing without chunking logic.
24092419
@@ -2422,7 +2432,9 @@ async def _retain_batch_async_internal(
24222432
document_tags: Tags applied to all items in this batch
24232433
24242434
Returns:
2425-
Tuple of (unit ID lists, token usage for fact extraction)
2435+
Tuple of (unit ID lists, LLM token usage, processed_content_tokens).
2436+
See ``RetainResult.processed_content_tokens`` for the semantics of
2437+
the third element.
24262438
"""
24272439
# Use the new modular orchestrator
24282440
from .retain import orchestrator

hindsight-api-slim/hindsight_api/engine/retain/orchestrator.py

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from ...worker.stage import set_stage
1818
from ..db_utils import acquire_with_retry
19-
from ..memory_engine import fq_table
19+
from ..memory_engine import count_tokens, fq_table
2020
from . import bank_utils
2121

2222

@@ -25,6 +25,32 @@ def utcnow():
2525
return datetime.now(UTC)
2626

2727

28+
def _merge_processed_content_tokens(a: int | None, b: int | None) -> int | None:
29+
"""Combine the processed-content-tokens signal across sub-results.
30+
31+
Semantics (see RetainResult.processed_content_tokens):
32+
* None means "this part of the retain did not go through chunk-level
33+
dedup" — i.e. the entire submitted payload was processed. If any
34+
sub-result is None, the aggregate is None so callers conservatively
35+
bill the full content.
36+
* Otherwise, accumulate the int values.
37+
"""
38+
if a is None or b is None:
39+
return None
40+
return a + b
41+
42+
43+
def _count_delta_content_tokens(delta_contents: list["RetainContent"]) -> int:
44+
"""Sum content + context tokens across the chunk items that were
45+
actually fed into the extraction pipeline on a partial-delta retain.
46+
"""
47+
total = 0
48+
for c in delta_contents:
49+
total += count_tokens(c.content or "")
50+
total += count_tokens(c.context or "")
51+
return total
52+
53+
2854
def parse_datetime_flexible(value: Any) -> datetime:
2955
"""
3056
Parse a datetime value that could be either a datetime object or an ISO string.
@@ -417,13 +443,21 @@ async def retain_batch(
417443
schema: str | None = None,
418444
outbox_callback: Callable[["asyncpg.Connection"], Awaitable[None]] | None = None,
419445
db_semaphore: "asyncio.Semaphore | None" = None,
420-
) -> tuple[list[list[str]], TokenUsage]:
446+
) -> tuple[list[list[str]], TokenUsage, int | None]:
421447
"""
422448
Process a batch of content through the retain pipeline.
423449
424450
Supports delta retain: when upserting a document that already has chunks,
425451
only re-processes chunks whose content has changed. Unchanged chunks keep
426452
their existing facts, entities, and links.
453+
454+
Returns a three-tuple of:
455+
* per-content-item unit ID lists
456+
* aggregate LLM token usage
457+
* processed_content_tokens — content+context tokens that actually went
458+
through extraction after chunk-level dedup, or ``None`` if this path
459+
didn't dedup (caller should treat as "bill full submitted content").
460+
See ``RetainResult.processed_content_tokens`` for details.
427461
"""
428462
start_time = time.time()
429463
total_chars = sum(len(item.get("content", "")) for item in contents_dicts)
@@ -463,8 +497,9 @@ async def retain_batch(
463497
# Process each group and merge results back in original order
464498
result_unit_ids: list[list[str]] = [[] for _ in contents_dicts]
465499
total_usage = TokenUsage()
500+
total_processed_tokens: int | None = 0
466501
for doc_key, (group_dicts, group_contents) in groups.items():
467-
group_ids, group_usage = await retain_batch(
502+
group_ids, group_usage, group_processed = await retain_batch(
468503
pool=pool,
469504
embeddings_model=embeddings_model,
470505
llm_config=llm_config,
@@ -486,7 +521,8 @@ async def retain_batch(
486521
if group_idx < len(group_ids):
487522
result_unit_ids[orig_idx] = group_ids[group_idx]
488523
total_usage = total_usage + group_usage
489-
return result_unit_ids, total_usage
524+
total_processed_tokens = _merge_processed_content_tokens(total_processed_tokens, group_processed)
525+
return result_unit_ids, total_usage, total_processed_tokens
490526

491527
# Resolve effective document_id early so both delta and streaming paths
492528
# can find existing chunks from a prior attempt. On retry, a generated
@@ -595,7 +631,9 @@ async def retain_batch(
595631
f"{datetime.fromtimestamp(start_time, tz=UTC).isoformat()})"
596632
)
597633
logger.info("\n" + "\n".join(log_buffer) + "\n")
598-
return [[] for _ in contents], TokenUsage()
634+
# No new content was processed — report 0 so callers can skip
635+
# billing cleanly instead of falling back to full-content billing.
636+
return [[] for _ in contents], TokenUsage(), 0
599637

600638
# --- Delta retain: check if we can skip unchanged chunks ---
601639
if is_first_batch:
@@ -1388,7 +1426,10 @@ async def _run_mini_batch_db_work() -> None:
13881426
# Map all unit_ids back to the original content items.
13891427
# For streaming mode with a single document, all units belong to content 0.
13901428
result_unit_ids = [all_unit_ids] + [[] for _ in contents[1:]]
1391-
return result_unit_ids, total_usage
1429+
# The streaming path doesn't compute per-chunk content-hash dedup in
1430+
# a way that lets us report a partial-processed tokens count — signal
1431+
# ``None`` so callers bill against the full submitted payload.
1432+
return result_unit_ids, total_usage, None
13921433

13931434

13941435
# ---------------------------------------------------------------------------
@@ -1416,10 +1457,15 @@ async def _try_delta_retain(
14161457
schema,
14171458
outbox_callback,
14181459
db_semaphore: "asyncio.Semaphore | None" = None,
1419-
):
1460+
) -> tuple[list[list[str]], TokenUsage, int | None] | None:
14201461
"""
14211462
Attempt delta retain for a document upsert. Returns result tuple if delta
14221463
was performed, or None to fall back to full retain.
1464+
1465+
When a result tuple is returned, the third element is the content+context
1466+
token count for the chunks that actually went through extraction
1467+
(``0`` if the submission matched prior content exactly and nothing was
1468+
re-extracted).
14231469
"""
14241470
# Need a single document_id
14251471
effective_doc_id = document_id
@@ -1678,7 +1724,12 @@ async def _run_delta_db_work() -> None:
16781724
await _run_delta_db_work()
16791725
else:
16801726
await _run_delta_db_work()
1681-
return result_unit_ids, usage
1727+
# Count content + context tokens that actually went through extraction.
1728+
# ``delta_contents`` holds the per-chunk RetainContent items for the
1729+
# changed/new chunks (see ``_build_delta_contents``) — i.e. exactly what
1730+
# the LLM pipeline saw this call. Unchanged chunks contribute zero.
1731+
processed_tokens = _count_delta_content_tokens(delta_contents)
1732+
return result_unit_ids, usage, processed_tokens
16821733

16831734

16841735
async def _delta_metadata_only(
@@ -1718,7 +1769,11 @@ async def _delta_metadata_only(
17181769
total_time = time.time() - start_time
17191770
log_buffer.append(f"DELTA RETAIN (no changes): metadata updated in {total_time:.3f}s")
17201771
logger.info("\n" + "\n".join(log_buffer) + "\n")
1721-
return [[] for _ in contents], TokenUsage()
1772+
# Nothing went through the extraction pipeline — report 0 processed
1773+
# content tokens so callers can bill accordingly (a caller that's been
1774+
# told ``0`` knows the retain was a pure metadata update and should
1775+
# charge nothing for content).
1776+
return [[] for _ in contents], TokenUsage(), 0
17221777

17231778

17241779
# ---------------------------------------------------------------------------

hindsight-api-slim/hindsight_api/extensions/operation_validator.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,22 @@ class RetainResult:
176176
llm_input_tokens: int | None = None
177177
llm_output_tokens: int | None = None
178178
llm_total_tokens: int | None = None
179+
# Content tokens the retain pipeline actually processed, after
180+
# chunk-level content-hash deduplication. Semantics:
181+
# None — no dedup signal available (e.g. a first-time retain or a
182+
# path that doesn't compute it). Callers that care about
183+
# "what was actually new on this retain" should treat None
184+
# as "the full submitted content was processed."
185+
# 0 — the entire submission was a duplicate of prior content
186+
# (all chunks matched by content_hash); nothing went
187+
# through LLM extraction.
188+
# N>0 — only N tokens of content + context went through the
189+
# extraction pipeline. The remainder was dedup'd against
190+
# existing chunks.
191+
# This is the basis most billing/metering extensions want to use
192+
# when the customer's client resubmits growing payloads to the same
193+
# document_id (e.g. a session transcript appended to on each turn).
194+
processed_content_tokens: int | None = None
179195

180196

181197
@dataclass

0 commit comments

Comments
 (0)