-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathorchestrator.py
More file actions
2446 lines (2198 loc) · 107 KB
/
Copy pathorchestrator.py
File metadata and controls
2446 lines (2198 loc) · 107 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Main orchestrator for the retain pipeline.
Coordinates all retain pipeline modules to store memories efficiently.
"""
import asyncio
import hashlib
import json
import logging
import time
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from ...extensions.memory_defense import (
DefenseAction,
DefenseDecision,
MemoryDefenseExtension,
apply_redaction,
parse_policy,
)
from ...worker.stage import set_stage
from ..db_utils import acquire_with_retry
from ..memory_engine import count_tokens, fq_table
from . import bank_utils
@dataclass
class BlockedViolation:
"""One item blocked by the Memory Defense policy (surfaced in the 422 body)."""
index: int
detector: str | None
message: str
class MemoryDefenseAllBlockedError(Exception):
"""Raised when every item in a retain batch is blocked by the Memory Defense policy."""
def __init__(self, violations: list[BlockedViolation]) -> None:
self.violations = violations
super().__init__(f"all {len(violations)} items blocked by Memory Defense policy")
def utcnow():
"""Get current UTC time."""
return datetime.now(UTC)
def _redact_document_body(body: str, config: Any) -> str:
"""Apply Memory Defense redaction to a document body.
Per-item screening only scrubs the chunked content that goes through
`screen()`. When a sub-batch carries `document_body_override` (the full
original text of an oversized item — see `_split_contents_into_sub_batches`),
that override bypasses screening and would persist verbatim into
`documents.original_text`. Apply the same redactor here so the document
body is scrubbed regardless of which path produced it.
"""
try:
policy = parse_policy(getattr(config, "memory_defense", None))
except Exception:
return body
if not policy.enabled:
return body
if not any(r.on == "sensitive_data" for r in policy.rules):
return body
return apply_redaction(body).content
async def _fire_memory_defense_webhook(
webhook_manager: Any,
*,
conn: Any,
schema: str | None,
bank_id: str,
operation_id: str | None,
document_id: str | None,
decision: DefenseDecision,
) -> None:
"""Fire a memory_defense.triggered webhook for a non-allow decision.
No-op when no webhook manager is wired or none is subscribed. Delivery
failures are swallowed so screening never blocks a retain.
"""
if webhook_manager is None:
return
try:
from ...webhooks import (
MemoryDefenseEventData,
MemoryDefenseHit,
WebhookEvent,
WebhookEventType,
)
# Translate per-match raw dicts on the decision into MemoryDefenseHit
# entries on the wire. The decision's hits list is already fingerprinted
# by apply_redaction (the raw value never lands in hits, by contract),
# so this is purely a shape conversion. None when no per-hit data is
# available so receivers can distinguish "no preview info" from
# "scanned, nothing matched" (the latter wouldn't be a webhook delivery
# in the first place).
decision_hits = getattr(decision, "hits", None) or []
hits: list[MemoryDefenseHit] | None = [
MemoryDefenseHit(
detector=str(h.get("detector") or ""),
preview=str(h.get("preview") or ""),
)
for h in decision_hits
if h.get("detector") and h.get("preview")
] or None
event = WebhookEvent(
event=WebhookEventType.MEMORY_DEFENSE_TRIGGERED,
bank_id=bank_id,
operation_id=operation_id or "",
status=decision.action.value,
timestamp=utcnow(),
data=MemoryDefenseEventData(
action=decision.action.value,
detector=decision.detector,
document_id=document_id,
matched_types=decision.matched_types or None,
message=decision.message or None,
hits=hits,
# Optional SIEM-enrichment fields populated by downstream
# extensions (e.g. hindsight-cloud's _CloudDefenseDecision
# subclass). Read via getattr so OSS doesn't need to know
# about extension subclasses. Combined with the manager's
# exclude_none serialization, missing values stay absent
# from the wire entirely rather than appearing as null.
severity=getattr(decision, "severity", None),
api_key_name=getattr(decision, "api_key_name", None),
memory_unit_id=getattr(decision, "memory_unit_id", None),
receipt_uri=getattr(decision, "receipt_uri", None),
),
)
await webhook_manager.fire_event_with_conn(event, conn, schema=schema)
except Exception:
logger.warning("memory_defense webhook delivery failed", exc_info=True)
def _audit_memory_defense(
audit_logger: Any,
*,
bank_id: str,
document_id: str | None,
decision: DefenseDecision,
) -> None:
"""Write a fire-and-forget ``memory_defense`` audit entry for a non-allow decision.
No-op when audit logging is disabled (the logger gates on its own config).
The action taken (redact/block) and what matched live in the entry metadata.
"""
if audit_logger is None:
return
from ..audit import AuditEntry
entry = AuditEntry(
action="memory_defense",
transport="system",
bank_id=bank_id,
metadata={
"action": decision.action.value,
"detector": decision.detector,
"document_id": document_id,
"matched_types": decision.matched_types,
"message": decision.message,
},
)
entry.ended_at = entry.started_at # point-in-time policy decision (duration 0)
audit_logger.log_fire_and_forget(entry)
def _merge_processed_content_tokens(a: int | None, b: int | None) -> int | None:
"""Combine the processed-content-tokens signal across sub-results.
Semantics (see RetainResult.processed_content_tokens):
* None means "this part of the retain did not go through chunk-level
dedup" — i.e. the entire submitted payload was processed. If any
sub-result is None, the aggregate is None so callers conservatively
bill the full content.
* Otherwise, accumulate the int values.
"""
if a is None or b is None:
return None
return a + b
def _count_delta_content_tokens(delta_contents: list["RetainContent"]) -> int:
"""Sum content + context tokens across the chunk items that were
actually fed into the extraction pipeline on a partial-delta retain.
"""
total = 0
for c in delta_contents:
total += count_tokens(c.content or "")
total += count_tokens(c.context or "")
return total
def parse_datetime_flexible(value: Any) -> datetime:
"""
Parse a datetime value that could be either a datetime object or an ISO string.
This handles datetime values from both direct Python calls and deserialized JSON
(where datetime objects are serialized as ISO strings).
Args:
value: Either a datetime object or an ISO format string
Returns:
datetime object (timezone-aware)
Raises:
TypeError: If value is neither datetime nor string
ValueError: If string is not a valid ISO datetime
"""
if isinstance(value, datetime):
# Ensure timezone-aware
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value
elif isinstance(value, str):
# Parse ISO format string (handles both 'Z' and '+00:00' timezone formats)
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
# Ensure timezone-aware
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt
else:
raise TypeError(f"Expected datetime or string, got {type(value).__name__}")
import asyncpg
from ..response_models import TokenUsage
from . import (
chunk_storage,
embedding_processing,
entity_processing,
fact_extraction,
fact_storage,
link_creation,
)
from .types import (
ChunkMetadata,
EntityResolutionResult,
Phase1Result,
ProcessedFact,
RetainContent,
RetainContentDict,
)
logger = logging.getLogger(__name__)
RetainOutboxCallback = Callable[[asyncpg.Connection], Awaitable[None]]
RetainOutboxCallbackFactory = Callable[[list[RetainContentDict]], RetainOutboxCallback | None]
def _resolve_narrator(profile_name: str, bank_id: str) -> str | None:
"""Resolve the narrator (memory owner) used to prime fact extraction.
The narrator is injected as a "Narrator: {name}" line in fact extraction and
is stamped into the who-dimension of every first-person fact — and the
observations later consolidated from those facts. That is correct for a named
agent retaining its own logs, but harmful when ``name`` is just the bank_id:
on auto-create the bank ``name`` defaults to ``bank_id``, which is typically a
routing key (e.g. ``my-agent::channel-456::user-789``), not a speaker. Priming
extraction with a routing key embeds that string into stored fact text and
pollutes downstream observations (issue #1680). Suppress it in that case.
Returns the narrator name, or ``None`` to omit the Narrator line entirely.
"""
if profile_name == bank_id:
return None
return profile_name
def _build_retain_params(contents_dicts, document_tags=None, doc_contents=None):
"""Build retain_params and merged_tags from content dicts."""
if doc_contents is not None:
# Per-document mode: doc_contents is list of (idx, content_dict)
items = [item for _, item in doc_contents]
else:
items = contents_dicts
all_tags = set(document_tags or [])
for item in items:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
retain_params = {}
if items:
first_item = items[0]
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
if first_item.get("observation_scopes") is not None:
retain_params["observation_scopes"] = first_item["observation_scopes"]
return retain_params, merged_tags
async def _pre_resolve_phase1(
pool: Any,
entity_resolver,
bank_id: str,
contents: list[RetainContent],
processed_facts: list[ProcessedFact],
config,
log_buffer: list[str],
skip_semantic_ann: bool = False,
) -> Phase1Result:
"""
Phase 1: Run expensive read-heavy operations on a separate connection
OUTSIDE the write transaction.
- Entity resolution: trigram GIN scan + co-occurrence fetch + scoring
- Semantic ANN: HNSW index probes to find similar existing units
Running these outside the transaction avoids holding row locks during
slow reads, eliminating TimeoutErrors under concurrent load.
"""
set_stage("retain.phase1.resolve")
from .link_utils import compute_semantic_links_ann
user_entities_per_content = {idx: content.entities for idx, content in enumerate(contents) if content.entities}
# Use placeholder unit_ids for grouping during resolution. The actual
# unit_ids are created later by insert_facts_batch inside the transaction,
# but entity resolution and ANN search only need them as grouping keys.
placeholder_unit_ids = [str(i) for i in range(len(processed_facts))]
embeddings = [fact.embedding for fact in processed_facts]
async with acquire_with_retry(pool) as resolve_conn:
resolved_entity_ids, entity_to_unit, unit_to_entity_ids = await entity_processing.resolve_entities(
entity_resolver,
resolve_conn,
bank_id,
placeholder_unit_ids,
processed_facts,
log_buffer,
user_entities_per_content=user_entities_per_content,
entity_labels=getattr(config, "entity_labels", None),
)
# Semantic ANN search on the same connection (autocommit, no transaction).
# Skipped in streaming mode — deferred to Phase 3 to avoid O(bank_size)
# scaling bottleneck that makes later streaming batches progressively slower.
semantic_ann_links = []
if not skip_semantic_ann:
fact_types = [fact.fact_type for fact in processed_facts]
semantic_ann_links = await compute_semantic_links_ann(
resolve_conn, bank_id, placeholder_unit_ids, embeddings, fact_types=fact_types, log_buffer=log_buffer
)
return Phase1Result(
entities=EntityResolutionResult(
resolved_entity_ids=resolved_entity_ids,
entity_to_unit=entity_to_unit,
unit_to_entity_ids=unit_to_entity_ids,
),
semantic_ann_links=semantic_ann_links,
)
def _remap_phase1_results(
resolved_entity_ids: list[str],
entity_to_unit: list[tuple],
unit_to_entity_ids: dict[str, list[str]],
semantic_ann_links: list[tuple],
actual_unit_ids: list[str],
) -> tuple[list[tuple], dict[str, list[str]], list[tuple]]:
"""
Remap Phase 1 results from placeholder unit IDs to actual unit IDs.
During Phase 1 we use str(fact_index) as placeholder unit IDs.
After insert_facts_batch creates real UUIDs, this function replaces the
placeholders so that all rows reference the correct memory_units.
"""
# Build placeholder -> actual mapping
placeholder_to_actual = {str(i): actual_id for i, actual_id in enumerate(actual_unit_ids)}
# Remap entity_to_unit tuples
remapped_entity_to_unit = [
(placeholder_to_actual.get(unit_id, unit_id), local_idx, fact_date)
for unit_id, local_idx, fact_date in entity_to_unit
]
# Remap unit_to_entity_ids keys
remapped_unit_to_entity_ids: dict[str, list[str]] = {}
for placeholder_id, entity_ids in unit_to_entity_ids.items():
actual_id = placeholder_to_actual.get(placeholder_id, placeholder_id)
remapped_unit_to_entity_ids[actual_id] = entity_ids
# Remap semantic ANN links (from_id uses placeholder)
remapped_semantic = [
(placeholder_to_actual.get(lnk[0], lnk[0]), lnk[1], lnk[2], lnk[3], lnk[4]) for lnk in semantic_ann_links
]
return remapped_entity_to_unit, remapped_unit_to_entity_ids, remapped_semantic
async def _insert_facts_and_links(
conn,
entity_resolver,
bank_id: str,
contents: list[RetainContent],
extracted_facts: list,
processed_facts: list[ProcessedFact],
config,
log_buffer: list[str],
resolved_entity_ids: list[str],
entity_to_unit: list[tuple],
unit_to_entity_ids: dict[str, list[str]],
semantic_ann_links: list[tuple],
skip_semantic_links: bool = False,
outbox_callback=None,
ops=None,
) -> list[list[str]]:
"""
Phase 2 of the retain pipeline: insert facts and retrieval-critical links.
Runs inside a single database transaction to ensure atomicity of the data
that retrieval depends on (facts, unit_entities, temporal/semantic/causal links).
Entity edges for UI graph visualization are derived on demand from
unit_entities by the /graph endpoint, so no entity rows are written to
memory_links here.
"""
set_stage("retain.phase2.insert_facts")
unit_ids = await fact_storage.insert_facts_batch(conn, bank_id, processed_facts, ops=ops)
step_start = time.time()
log_buffer.append(f" Insert facts: {len(unit_ids)} units in {time.time() - step_start:.3f}s")
if unit_ids:
# Entity resolution was done in Phase 1 (separate connection).
# Remap placeholder IDs to actual unit IDs.
step_start = time.time()
remapped_entity_to_unit, _remapped_unit_to_entity_ids, remapped_semantic = _remap_phase1_results(
resolved_entity_ids, entity_to_unit, unit_to_entity_ids, semantic_ann_links or [], unit_ids
)
# Update semantic_ann_links with remapped IDs for Phase 2
semantic_ann_links = remapped_semantic
# INSERT unit_entities (FK to memory_units, must be in transaction).
# Pass fact_date alongside so entity_cooccurrences.last_cooccurred
# tracks the event timeline, not the ingest moment.
unit_entity_pairs = [
(unit_id, resolved_entity_ids[idx], fact_date)
for idx, (unit_id, _local_idx, fact_date) in enumerate(remapped_entity_to_unit)
]
await entity_resolver.link_units_to_entities_batch(unit_entity_pairs, conn=conn)
log_buffer.append(f" Insert unit_entities: {len(unit_entity_pairs)} pairs in {time.time() - step_start:.3f}s")
# Create temporal links
step_start = time.time()
temporal_link_count = await link_creation.create_temporal_links_batch(conn, bank_id, unit_ids, ops=ops)
log_buffer.append(f" Temporal links: {temporal_link_count} links in {time.time() - step_start:.3f}s")
# Create semantic links (within-batch + pre-computed ANN from Phase 1)
if skip_semantic_links:
log_buffer.append(" Semantic links: skipped (deferred to final ANN pass)")
semantic_link_count = 0
else:
step_start = time.time()
embeddings_for_links = [fact.embedding for fact in processed_facts]
semantic_link_count = await link_creation.create_semantic_links_batch(
conn,
bank_id,
unit_ids,
embeddings_for_links,
pre_computed_ann_links=semantic_ann_links,
ops=ops,
)
log_buffer.append(f" Semantic links: {semantic_link_count} links in {time.time() - step_start:.3f}s")
# NOTE: Entity links are NOT inserted here. They are deferred to
# Phase 3 (post-transaction, best-effort) since retrieval uses the
# unit_entities self-join instead. Entity links only serve UI visualization.
# Create causal links
step_start = time.time()
causal_link_count = await link_creation.create_causal_links_batch(
conn, bank_id, unit_ids, processed_facts, ops=ops
)
log_buffer.append(f" Causal links: {causal_link_count} links in {time.time() - step_start:.3f}s")
# Map results back to original content items. Use processed_facts (not
# extracted_facts) because unit_ids has 1:1 alignment with processed_facts —
# any upstream drop between extraction and processing would otherwise cause
# an IndexError (see issue #1037).
result_unit_ids = _map_results_to_contents(contents, processed_facts, unit_ids if unit_ids else [])
if outbox_callback is not None:
await outbox_callback(conn)
return result_unit_ids
async def _extract_and_embed(
contents: list[RetainContent],
llm_config,
agent_name: str,
config,
embeddings_model,
format_date_fn,
fact_type_override: str | None,
log_buffer: list[str],
pool: Any = None,
operation_id: str | None = None,
schema: str | None = None,
) -> tuple[list, list[ProcessedFact], list[ChunkMetadata], TokenUsage]:
"""
Shared pipeline: extract facts from contents and generate embeddings.
Returns:
Tuple of (extracted_facts, processed_facts, chunks_metadata, usage)
"""
set_stage("retain.extract_and_embed")
step_start = time.time()
extracted_facts, chunks, usage = await fact_extraction.extract_facts_from_contents(
contents, llm_config, agent_name, config, pool, operation_id, schema
)
log_buffer.append(
f" Extract facts: {len(extracted_facts)} facts, {len(chunks)} chunks "
f"from {len(contents)} contents in {time.time() - step_start:.3f}s"
)
if not extracted_facts:
return extracted_facts, [], chunks, usage
if fact_type_override:
for fact in extracted_facts:
fact.fact_type = fact_type_override
step_start = time.time()
augmented_texts = embedding_processing.augment_texts_with_dates(extracted_facts, format_date_fn)
embeddings = await embedding_processing.generate_embeddings_batch(embeddings_model, augmented_texts)
log_buffer.append(f" Generate embeddings: {len(embeddings)} embeddings in {time.time() - step_start:.3f}s")
processed_facts = [
pf
for ef, emb in zip(extracted_facts, embeddings)
if (pf := ProcessedFact.from_extracted_fact(ef, emb)) is not None
]
return extracted_facts, processed_facts, chunks, usage
async def retain_batch(
pool: Any,
embeddings_model,
llm_config,
entity_resolver,
format_date_fn,
bank_id: str,
contents_dicts: list[RetainContentDict],
config,
document_id: str | None = None,
is_first_batch: bool = True,
fact_type_override: str | None = None,
document_tags: list[str] | None = None,
operation_id: str | None = None,
schema: str | None = None,
outbox_callback: RetainOutboxCallback | None = None,
outbox_callback_factory: RetainOutboxCallbackFactory | None = None,
db_semaphore: "asyncio.Semaphore | None" = None,
document_body_override: str | None = None,
chunk_index_offset: int = 0,
progress_callback: "Callable[..., Awaitable[None]] | None" = None,
webhook_manager: Any = None,
memory_defense_extension: "MemoryDefenseExtension | None" = None,
audit_logger: Any = None,
) -> tuple[list[list[str]], TokenUsage, int | None]:
"""
Process a batch of content through the retain pipeline.
Supports delta retain: when upserting a document that already has chunks,
only re-processes chunks whose content has changed. Unchanged chunks keep
their existing facts, entities, and links.
``chunk_index_offset`` shifts the chunk_index (and therefore the derived
``chunk_id = {bank}_{doc}_{index}``) of every chunk this call stores. The
in-process splitter slices an oversized single item into several
sub-batches that all share one document_id and run sequentially; without
a per-document offset each sub-batch would restart chunk_index at 0, so
their chunk_ids collide and later sub-batches overwrite earlier chunks —
leaving only one sub-batch's worth of chunks/memories behind (issue #1888).
Returns a three-tuple of:
* per-content-item unit ID lists
* aggregate LLM token usage
* processed_content_tokens — content+context tokens that actually went
through extraction after chunk-level dedup, or ``None`` if this path
didn't dedup (caller should treat as "bill full submitted content").
See ``RetainResult.processed_content_tokens`` for details.
"""
start_time = time.time()
total_chars = sum(len(item.get("content", "")) for item in contents_dicts)
log_buffer = []
log_buffer.append(f"{'=' * 60}")
log_buffer.append(f"RETAIN_BATCH START: {bank_id}")
log_buffer.append(f"Batch size: {len(contents_dicts)} content items, {total_chars:,} chars")
log_buffer.append(f"{'=' * 60}")
# Get bank profile
profile = await bank_utils.get_bank_profile(pool, bank_id)
# Suppress the narrator when name == bank_id (auto-create default) — see
# _resolve_narrator for why a routing-key narrator pollutes extraction (#1680).
agent_name = _resolve_narrator(profile["name"], bank_id)
# Convert dicts to RetainContent objects
contents = _build_contents(contents_dicts, document_tags)
# When contents have multiple distinct per-content document_ids and no
# batch-level document_id, group by doc_id and process each group
# independently so each document is tracked separately.
if not document_id:
per_content_doc_ids = [item.get("document_id") for item in contents_dicts]
unique_doc_ids = {d for d in per_content_doc_ids if d}
if len(unique_doc_ids) > 1:
# Group contents by document_id, preserving original order
groups: dict[str, tuple[list[RetainContentDict], list[RetainContent]]] = {}
original_indices: dict[str, list[int]] = {}
for idx, (cd, c) in enumerate(zip(contents_dicts, contents)):
doc_key = cd.get("document_id") or str(uuid.uuid4())
if doc_key not in groups:
groups[doc_key] = ([], [])
original_indices[doc_key] = []
groups[doc_key][0].append(cd)
groups[doc_key][1].append(c)
original_indices[doc_key].append(idx)
# Process each group and merge results back in original order
result_unit_ids: list[list[str]] = [[] for _ in contents_dicts]
total_usage = TokenUsage()
total_processed_tokens: int | None = 0
for doc_key, (group_dicts, group_contents) in groups.items():
group_outbox_callback = (
outbox_callback_factory(group_dicts) if outbox_callback_factory is not None else outbox_callback
)
group_ids, group_usage, group_processed = await retain_batch(
pool=pool,
embeddings_model=embeddings_model,
llm_config=llm_config,
entity_resolver=entity_resolver,
format_date_fn=format_date_fn,
bank_id=bank_id,
contents_dicts=group_dicts,
config=config,
document_id=doc_key,
is_first_batch=is_first_batch,
fact_type_override=fact_type_override,
document_tags=document_tags,
operation_id=operation_id,
schema=schema,
outbox_callback=group_outbox_callback,
outbox_callback_factory=outbox_callback_factory,
db_semaphore=db_semaphore,
document_body_override=document_body_override,
chunk_index_offset=chunk_index_offset,
progress_callback=progress_callback,
webhook_manager=webhook_manager,
memory_defense_extension=memory_defense_extension,
audit_logger=audit_logger,
)
for group_idx, orig_idx in enumerate(original_indices[doc_key]):
if group_idx < len(group_ids):
result_unit_ids[orig_idx] = group_ids[group_idx]
total_usage = total_usage + group_usage
total_processed_tokens = _merge_processed_content_tokens(total_processed_tokens, group_processed)
return result_unit_ids, total_usage, total_processed_tokens
# --- Memory Defense pre-extraction screening ---
# Delegate to the loaded extension. `config` is a resolved HindsightConfig
# object at this point (see _retain_batch_async_internal). On a non-allow
# decision we redact in place or drop the item, and fire a
# memory_defense.triggered webhook when one is configured.
_policy = parse_policy(getattr(config, "memory_defense", None))
_blocked_violations: list[BlockedViolation] = []
if memory_defense_extension is not None and _policy.enabled:
async with acquire_with_retry(pool) as _defense_conn:
for _idx, _content in enumerate(contents):
# Prefer the per-item document_id over the batch-level value so
# the decision and webhook carry the document the caller
# submitted, not whichever doc_id the batch happens to share.
_item_doc_id = contents_dicts[_idx].get("document_id") or document_id
_decision = await memory_defense_extension.screen(
policy=_policy,
bank_id=bank_id,
document_id=_item_doc_id,
content=_content.content,
tags=_content.tags,
)
if _decision.action is DefenseAction.ALLOW:
continue
if _decision.action is DefenseAction.REDACT:
_redacted = _decision.redacted_content or _content.content
_content.content = _redacted
# Mirror the redaction into the raw dict so the document
# body persisted further down the pipeline also stores the
# redacted text, not the verbatim secret.
contents_dicts[_idx]["content"] = _redacted
elif _decision.action is DefenseAction.BLOCK:
_blocked_violations.append(
BlockedViolation(
index=_idx,
detector=_decision.detector,
message=_decision.message,
)
)
await _fire_memory_defense_webhook(
webhook_manager,
conn=_defense_conn,
schema=schema,
bank_id=bank_id,
operation_id=operation_id,
document_id=_item_doc_id,
decision=_decision,
)
_audit_memory_defense(
audit_logger,
bank_id=bank_id,
document_id=_item_doc_id,
decision=_decision,
)
if _blocked_violations:
# All items blocked → raise so the HTTP layer can return 422.
if len(_blocked_violations) == len(contents):
raise MemoryDefenseAllBlockedError(_blocked_violations)
# Remove blocked items from the pipeline.
_skip_indices = {v.index for v in _blocked_violations}
if _skip_indices:
_surviving = [i for i in range(len(contents)) if i not in _skip_indices]
contents = [contents[i] for i in _surviving]
contents_dicts = [contents_dicts[i] for i in _surviving]
# If nothing survives, return empty results immediately.
if not contents:
return [[] for _ in contents_dicts], TokenUsage(), 0
# Resolve effective document_id early so both delta and streaming paths
# can find existing chunks from a prior attempt. On retry, a generated
# document_id is recovered from operation result_metadata.document_ids[0].
effective_doc_id = document_id
if not effective_doc_id:
doc_ids = {item.get("document_id") for item in contents_dicts if item.get("document_id")}
if len(doc_ids) == 1:
effective_doc_id = doc_ids.pop()
if not effective_doc_id and operation_id:
try:
async with acquire_with_retry(pool) as conn:
row = await conn.fetchrow(
f"SELECT result_metadata FROM {fq_table('async_operations')} WHERE operation_id = $1",
uuid.UUID(operation_id),
)
if row and row["result_metadata"]:
meta = (
row["result_metadata"]
if isinstance(row["result_metadata"], dict)
else json.loads(row["result_metadata"])
)
recovered = meta.get("document_ids") or []
if recovered:
effective_doc_id = recovered[0]
except Exception:
pass
if not effective_doc_id:
effective_doc_id = str(uuid.uuid4())
# Record effective_doc_id on the operation (idempotent set-append). Captures
# both user-provided and generated ids so the operation shows every document
# it touched, and lets retries reuse the same generated id.
if operation_id:
try:
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"""
UPDATE {fq_table("async_operations")}
SET result_metadata = jsonb_set(
COALESCE(result_metadata, '{{}}'::jsonb),
'{{document_ids}}',
CASE
WHEN COALESCE(result_metadata->'document_ids', '[]'::jsonb) @> $1::jsonb
THEN result_metadata->'document_ids'
ELSE COALESCE(result_metadata->'document_ids', '[]'::jsonb) || $1::jsonb
END,
true
),
updated_at = now()
WHERE operation_id = $2
""",
json.dumps([effective_doc_id]),
uuid.UUID(operation_id),
)
except Exception:
logger.warning("Failed to persist document_id", exc_info=True)
# --- Append mode: prepend existing document content to new content ---
# When update_mode="append", fetch the existing document text and prepend it
# so the full document is reprocessed (delta retain will skip unchanged chunks).
update_mode = None
for item in contents_dicts:
item_mode = item.get("update_mode")
if item_mode:
update_mode = item_mode
break
if update_mode == "append" and effective_doc_id and is_first_batch:
async with acquire_with_retry(pool) as conn:
existing_text = await fact_storage.get_document_content(conn, bank_id, effective_doc_id)
if existing_text:
# Prepend existing text as a new content item at the beginning
existing_content: RetainContentDict = {"content": existing_text}
# Copy context/tags from first item for consistency
first = contents_dicts[0]
if first.get("context"):
existing_content["context"] = first["context"]
if first.get("event_date"):
existing_content["event_date"] = first["event_date"]
if first.get("metadata"):
existing_content["metadata"] = first["metadata"]
if first.get("observation_scopes") is not None:
existing_content["observation_scopes"] = first["observation_scopes"]
if first.get("tags"):
existing_content["tags"] = first["tags"]
contents_dicts = [existing_content, *contents_dicts]
# Merge JSON arrays to keep original_text valid (#2409).
# Without this, combined_content joins items with "\n", producing
# "[...]\n[...]" which is not valid JSON. On the next append cycle
# chunk_text() fails to parse it and falls through to sentence-
# boundary text splitting, breaking speaker attribution.
try:
_merged = []
for _item in contents_dicts:
_parsed = json.loads(_item.get("content", ""))
if isinstance(_parsed, list) and all(isinstance(_e, dict) for _e in _parsed):
_merged.extend(_parsed)
else:
_merged = None
break
if _merged is not None:
contents_dicts = [{"content": json.dumps(_merged, ensure_ascii=False)}]
if first.get("context"):
contents_dicts[0]["context"] = first["context"]
if first.get("event_date"):
contents_dicts[0]["event_date"] = first["event_date"]
if first.get("metadata"):
contents_dicts[0]["metadata"] = first["metadata"]
if first.get("observation_scopes") is not None:
contents_dicts[0]["observation_scopes"] = first["observation_scopes"]
if first.get("tags"):
contents_dicts[0]["tags"] = first["tags"]
except (json.JSONDecodeError, ValueError, TypeError):
pass
# Rebuild contents list to match
contents = _build_contents(contents_dicts, document_tags)
log_buffer.append(
f"[append] Prepended {len(existing_text):,} chars from existing document {effective_doc_id}"
)
# --- Stale-request check (best-effort, before LLM extraction) ---
# If the document was already updated by a more recent retain (updated_at > our
# start_time), skip this request entirely to avoid overwriting newer content
# (e.g. a longer conversation) with older data. This is an optimization — the
# real correctness guarantee comes from the FOR UPDATE + content_hash check
# inside each batch TXN (see _run_mini_batch_db_work).
async with acquire_with_retry(pool) as conn:
doc_row = await conn.fetchrow(
f"SELECT updated_at FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2",
effective_doc_id,
bank_id,
)
if doc_row and doc_row["updated_at"]:
doc_updated = doc_row["updated_at"].timestamp()
if doc_updated > start_time:
log_buffer.append(
f"[stale] Skipping retain: document {effective_doc_id} was updated at "
f"{doc_row['updated_at'].isoformat()} (after this request started at "
f"{datetime.fromtimestamp(start_time, tz=UTC).isoformat()})"
)
logger.info("\n" + "\n".join(log_buffer) + "\n")
# No new content was processed — report 0 so callers can skip
# billing cleanly instead of falling back to full-content billing.
return [[] for _ in contents], TokenUsage(), 0
# --- Delta retain: check if we can skip unchanged chunks ---
if is_first_batch:
delta_result = await _try_delta_retain(
pool,
embeddings_model,
llm_config,
entity_resolver,
format_date_fn,
bank_id,
contents_dicts,
contents,
config,
effective_doc_id,
fact_type_override,
document_tags,
agent_name,
log_buffer,
start_time,
operation_id,
schema,
outbox_callback,
db_semaphore,
document_body_override=document_body_override,
)
if delta_result is not None:
return delta_result
# --- Always use the streaming pipeline (producer-consumer batching) ---
# Even small documents go through the same path — they just end up as a
# single batch. This eliminates the maintenance burden of two separate
# retain code paths.
chunk_batch_size = getattr(config, "retain_chunk_batch_size", 100)
chunk_size = getattr(config, "retain_chunk_size", 3000)
structured_chunk_size = getattr(config, "retain_structured_chunk_size", None)
all_pre_chunks: list[str] = []
chunk_to_content: list[int] = [] # maps chunk index -> index into contents
for content_idx, content in enumerate(contents):
content_chunks = fact_extraction.chunk_text(
content.content,
chunk_size,
structured_chunk_size=structured_chunk_size,
)
all_pre_chunks.extend(content_chunks)
chunk_to_content.extend([content_idx] * len(content_chunks))
# Memory: after chunking, the original content bodies in RetainContent are
# no longer needed (all_pre_chunks holds the working set). Clear them so
# Python can reclaim the (potentially multi-MB) strings.
# Note: contents_dicts["content"] is still needed briefly for hash computation
# inside _streaming_retain_batch, but gets cleared there after use.
for content in contents:
content.content = ""
total_pre_chunks = len(all_pre_chunks)
num_batches = (total_pre_chunks + chunk_batch_size - 1) // chunk_batch_size if total_pre_chunks > 0 else 1
log_buffer.append(
f"[streaming] {total_pre_chunks} chunks, batch_size {chunk_batch_size} — "
f"{num_batches} batch{'es' if num_batches != 1 else ''}"
)
return await _streaming_retain_batch(
pool=pool,
embeddings_model=embeddings_model,
llm_config=llm_config,
entity_resolver=entity_resolver,
format_date_fn=format_date_fn,
bank_id=bank_id,
contents_dicts=contents_dicts,
contents=contents,
config=config,
document_id=effective_doc_id,
is_first_batch=is_first_batch,
fact_type_override=fact_type_override,
document_tags=document_tags,
agent_name=agent_name,
log_buffer=log_buffer,
start_time=start_time,
all_pre_chunks=all_pre_chunks,
chunk_to_content=chunk_to_content,
chunk_batch_size=chunk_batch_size,
operation_id=operation_id,
schema=schema,
outbox_callback=outbox_callback,
db_semaphore=db_semaphore,
document_body_override=document_body_override,
chunk_index_offset=chunk_index_offset,
progress_callback=progress_callback,
)
# ---------------------------------------------------------------------------
# Final semantic ANN pass (post-commit)
# ---------------------------------------------------------------------------
_ANN_CHUNK_SIZE = 1000 # Max seeds per ANN query — smaller chunks avoid timeouts