Coverage for astrocyte/pipeline/fact_extraction.py: 84%
319 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-10-29 22:10 +0000
1"""Structured fact extraction at retain time.
3Replaces the legacy two-pass approach (text chunking → separate
4entity-extraction LLM call → separate fact-causal LLM call) with a
5single LLM call that produces a list of structured facts. Each fact
6carries five dimensions plus its embedded entities and intra-batch
7causal relations:
9- ``what`` — the core factual content (1–2 sentences)
10- ``when`` — temporal expression in natural language plus optional
11 ISO ``occurred_start`` / ``occurred_end``
12- ``where`` — location or N/A
13- ``who`` — people involved + relationships
14- ``why`` — context or N/A
15- ``fact_type`` — ``"world"`` (objective external fact) vs
16 ``"experience"`` (first-person event)
17- ``entities`` — named entities embedded in the fact
18- ``causal_relations`` — directional ``caused_by`` references to other
19 facts in the SAME batch by index
21Why this matters: every downstream signal (semantic embedding, cross-
22encoder rerank, link expansion, reflect synthesis) gets cleaner inputs
23when ``who`` and ``when`` are first-class fields rather than buried in
24free text. Multi-hop and temporal questions especially benefit because
25the structured fields can be filtered / matched directly.
27Cost: single LLM call per retain text replaces two (entity + causal).
28Net cost approximately equal; output substantially richer.
30The module also exposes :func:`materialize_facts` which converts a
31list of :class:`ExtractedFact` objects into the three artefacts the
32orchestrator needs to persist:
34- :class:`VectorItem` per fact (replaces chunk-based VectorItems)
35- :class:`Entity` per unique entity mentioned across the batch
36- :class:`MemoryLink` per causal relation (memory-to-memory edges)
37"""
39from __future__ import annotations
41import asyncio
42import json
43import logging
44import re
45import uuid
46from dataclasses import dataclass, field
47from datetime import datetime, timezone
49from astrocyte.types import (
50 Entity,
51 MemoryLink,
52 Message,
53 VectorItem,
54)
56_logger = logging.getLogger("astrocyte.fact_extraction")
59# ---------------------------------------------------------------------------
60# Data shapes
61# ---------------------------------------------------------------------------
64@dataclass
65class FactEntity:
66 """An entity mentioned within a fact."""
68 name: str
69 entity_type: str = "OTHER" # PERSON, ORG, LOCATION, CONCEPT, OTHER
72@dataclass
73class FactCausalRelation:
74 """``caused_by`` reference from THIS fact to another fact in the
75 same batch. Hindsight-style: target_index < source_index is
76 enforced upstream when building MemoryLinks (we drop self-loops
77 and out-of-range references)."""
79 target_fact_index: int
80 strength: float = 1.0 # 0..1, defaults to strong
83@dataclass
84class ExtractedFact:
85 """A single structured fact extracted from retain text.
87 The 5-dimension schema (what/when/where/who/why) materializes the
88 semantic content the LLM extracted, plus embedded entities and
89 causal links between facts in the same batch.
90 """
92 what: str # core content
93 when: str = "N/A" # natural-language time expression
94 where: str = "N/A"
95 who: str = "N/A"
96 why: str = "N/A"
97 fact_type: str = "experience" # "world" | "experience"
98 occurred_start: datetime | None = None
99 occurred_end: datetime | None = None
100 entities: list[FactEntity] = field(default_factory=list)
101 causal_relations: list[FactCausalRelation] = field(default_factory=list)
102 # Last, so positional construction stays compatible.
103 #: False when ``fact_type`` is the default rather than the model's
104 #: answer (its metadata entry was missing or invalid). Storage must not
105 #: treat a default as a classification.
106 fact_type_classified: bool = True
109# ---------------------------------------------------------------------------
110# Prompt
111# ---------------------------------------------------------------------------
114_VERBATIM_SYSTEM_PROMPT = """\
115You enrich PRE-CHUNKED text with structured metadata. Do NOT rewrite \
116or paraphrase the chunk text — the caller will use the original chunk \
117text verbatim. Your job is to produce per-chunk metadata.
119Output a JSON object: {"facts": [...]}. The facts list MUST be the \
120same length as the input chunks list, in the same order. For each \
121chunk, produce ONE entry with:
122- "chunk_index" (integer): the [index] of the chunk this entry \
123describes, copied exactly from the input.
124- "when" (string, default "N/A"): natural-language time expression \
125present in this chunk; "N/A" otherwise.
126- "where" (string, default "N/A"): location.
127- "who" (string, default "N/A"): people involved.
128- "why" (string, default "N/A"): reason / motivation if explicit.
129- "fact_type" ("world" | "experience"): classify the chunk.
130- "occurred_start" (ISO 8601 string or null): resolve "when" to an \
131absolute date when possible; null otherwise.
132- "occurred_end" (ISO 8601 string or null): instant or end of range.
133- "entities" (list): each entity mentioned in this chunk, as \
134{"name": str, "entity_type": str}. Types: PERSON, ORG, LOCATION, \
135PRODUCT, CONCEPT, OTHER.
137Rules:
1381. Output exactly one entry per input chunk, in the same order, each \
139tagged with its chunk_index. Never merge or skip chunks.
1402. Don't invent. Use "N/A" / null / [] for absent metadata.
1413. Output JSON only.
142"""
145# JSON Schema for OpenAI structured outputs (Phase 2 of cost-control
146# port). When the provider supports ``response_format=json_schema`` (set
147# at the call site), the model is decode-time constrained to this shape
148# and malformed-JSON parse failures become impossible. Strict mode
149# requires every property in ``required`` and ``additionalProperties:
150# false`` at every level — keep it that way when extending.
151#
152# ``causal_relations`` is intentionally omitted from the schema:
153# ``causal_links.enabled`` defaults to false in our research configs,
154# and OpenAI strict-mode JSON schema enforces required-everywhere which
155# makes optional cross-chunk index references awkward. If we re-enable
156# causal extraction, add a separate schema variant rather than bolting
157# it on here.
158_VERBATIM_JSON_SCHEMA: dict = {
159 "name": "verbatim_facts",
160 "strict": True,
161 "schema": {
162 "type": "object",
163 "additionalProperties": False,
164 "required": ["facts"],
165 "properties": {
166 "facts": {
167 "type": "array",
168 "items": {
169 "type": "object",
170 "additionalProperties": False,
171 "required": [
172 "chunk_index",
173 "when",
174 "where",
175 "who",
176 "why",
177 "fact_type",
178 "occurred_start",
179 "occurred_end",
180 "entities",
181 ],
182 "properties": {
183 "chunk_index": {"type": "integer"},
184 "when": {"type": "string"},
185 "where": {"type": "string"},
186 "who": {"type": "string"},
187 "why": {"type": "string"},
188 "fact_type": {
189 "type": "string",
190 "enum": ["world", "experience"],
191 },
192 "occurred_start": {"type": ["string", "null"]},
193 "occurred_end": {"type": ["string", "null"]},
194 "entities": {
195 "type": "array",
196 "items": {
197 "type": "object",
198 "additionalProperties": False,
199 "required": ["name", "entity_type"],
200 "properties": {
201 "name": {"type": "string"},
202 "entity_type": {
203 "type": "string",
204 "enum": [
205 "PERSON",
206 "ORG",
207 "LOCATION",
208 "PRODUCT",
209 "CONCEPT",
210 "OTHER",
211 ],
212 },
213 },
214 },
215 },
216 },
217 },
218 }
219 },
220 },
221}
224def _build_verbatim_user_prompt(
225 chunk_texts: list[str],
226 event_date: datetime | None = None,
227) -> str:
228 lines = []
229 if event_date is not None:
230 lines.append(f"Reference date for relative time expressions: {event_date.isoformat()}")
231 lines.append("")
232 lines.append(f"Chunks ({len(chunk_texts)} total, indexed):")
233 for i, text in enumerate(chunk_texts):
234 snippet = text.strip()
235 if len(snippet) > 800:
236 snippet = snippet[:797] + "..."
237 lines.append(f"[{i}] {snippet}")
238 lines.append("")
239 lines.append("Per-chunk metadata (JSON, one entry per chunk, each with its chunk_index):")
240 return "\n".join(lines)
243# ---------------------------------------------------------------------------
244# Per-chunk parallel verbatim extraction (Phase 3 of cost-control port)
245# ---------------------------------------------------------------------------
246#
247# Splits the verbatim metadata extraction into one LLM call per chunk,
248# dispatched in parallel via ``asyncio.gather`` with a ``Semaphore``
249# bounding in-flight calls per session. Trade-offs vs the batched
250# ``extract_facts_verbatim``:
251#
252# - Output tokens per call are tiny (~150 tokens for one entry vs ~150 *
253# N for N-chunk batched), so per-call latency drops sharply on
254# gpt-4o-mini (latency is roughly linear in output tokens).
255# - More API calls overall, but they run in parallel, so wall time drops.
256# - Cross-chunk ``causal_relations`` index references are dropped — they
257# require co-located chunks in one prompt to reference. Acceptable
258# default because ``causal_links.enabled`` is false in our research
259# configs; if you need causal extraction, use the batched path.
261_VERBATIM_SINGLE_SYSTEM_PROMPT = """\
262You enrich a single PRE-CHUNKED text excerpt with structured metadata. \
263Do NOT rewrite or paraphrase the chunk — produce metadata only.
265Output a JSON object with these fields:
266- "when" (string, default "N/A"): natural-language time expression in \
267the chunk; "N/A" otherwise.
268- "where" (string, default "N/A"): location.
269- "who" (string, default "N/A"): people involved.
270- "why" (string, default "N/A"): reason / motivation if explicit.
271- "fact_type" ("world" | "experience"): classify the chunk.
272- "occurred_start" (ISO 8601 string or null): resolve "when" to an \
273absolute date when possible; null otherwise.
274- "occurred_end" (ISO 8601 string or null): instant or end of range.
275- "entities" (list): each entity as {"name": str, "entity_type": str}. \
276Types: PERSON, ORG, LOCATION, PRODUCT, CONCEPT, OTHER.
278Don't invent. Use "N/A" / null / [] for absent metadata. Output JSON \
279only.
280"""
283_VERBATIM_SINGLE_JSON_SCHEMA: dict = {
284 "name": "verbatim_chunk_metadata",
285 "strict": True,
286 "schema": {
287 "type": "object",
288 "additionalProperties": False,
289 "required": [
290 "when",
291 "where",
292 "who",
293 "why",
294 "fact_type",
295 "occurred_start",
296 "occurred_end",
297 "entities",
298 ],
299 "properties": {
300 "when": {"type": "string"},
301 "where": {"type": "string"},
302 "who": {"type": "string"},
303 "why": {"type": "string"},
304 "fact_type": {
305 "type": "string",
306 "enum": ["world", "experience"],
307 },
308 "occurred_start": {"type": ["string", "null"]},
309 "occurred_end": {"type": ["string", "null"]},
310 "entities": {
311 "type": "array",
312 "items": {
313 "type": "object",
314 "additionalProperties": False,
315 "required": ["name", "entity_type"],
316 "properties": {
317 "name": {"type": "string"},
318 "entity_type": {
319 "type": "string",
320 "enum": [
321 "PERSON",
322 "ORG",
323 "LOCATION",
324 "PRODUCT",
325 "CONCEPT",
326 "OTHER",
327 ],
328 },
329 },
330 },
331 },
332 },
333 },
334}
337def _build_verbatim_single_user_prompt(
338 chunk: str,
339 event_date: datetime | None = None,
340) -> str:
341 lines = []
342 if event_date is not None:
343 lines.append(f"Reference date for relative time expressions: {event_date.isoformat()}")
344 lines.append("")
345 lines.append("Chunk:")
346 snippet = chunk.strip()
347 # Larger cap than batched (which truncates at 800 chars per chunk
348 # to fit many in one prompt) — single-chunk path can afford the
349 # full chunk text up to chunk_max_size.
350 if len(snippet) > 8000:
351 snippet = snippet[:7997] + "..."
352 lines.append(snippet)
353 lines.append("")
354 lines.append("Metadata (JSON):")
355 return "\n".join(lines)
358@dataclass(slots=True, frozen=True)
359class _ChunkResult:
360 """Result of one parallel verbatim-extraction task.
362 Internal record paired with each input chunk. ``raw`` is the
363 parsed metadata dict from the LLM (vendor-shaped JSON, no fixed
364 schema beyond the constraints in ``_VERBATIM_SINGLE_JSON_SCHEMA``)
365 or ``{}`` when extraction fell through after retries.
366 """
368 idx: int
369 raw: dict
372class _VerbatimChunkError(Exception):
373 """Raised by ``_extract_one_chunk_verbatim_attempt`` on any failure
374 that should be retried — LLM-side exception, malformed JSON, etc.
375 Internal to the retry wrapper; never escapes the public surface."""
378async def _extract_one_chunk_verbatim_attempt(
379 chunk: str,
380 llm_provider,
381 *,
382 event_date: datetime | None = None,
383) -> dict:
384 """Single attempt at extracting verbatim metadata for ONE chunk.
385 Raises :class:`_VerbatimChunkError` on any failure so the outer
386 retry loop can back off and try again."""
387 messages_in = [
388 Message(role="system", content=_VERBATIM_SINGLE_SYSTEM_PROMPT),
389 Message(
390 role="user",
391 content=_build_verbatim_single_user_prompt(chunk, event_date),
392 ),
393 ]
394 try:
395 completion = await llm_provider.complete(
396 messages_in,
397 # Per-chunk output is one metadata object; tiny in absolute
398 # terms, but at chunk_max_size=2048 with dialogue-dense
399 # chunks the LLM can emit dozens of entities and the JSON
400 # blows past 512 tokens, getting truncated mid-emit.
401 # Structured outputs can't recover from a hard max_tokens
402 # cap, so we get parse failures even with json_schema mode.
403 # 2048 gives ~10× headroom and is still tiny vs the
404 # batched path's 4096 ceiling.
405 max_tokens=2048,
406 temperature=0.0,
407 response_format={
408 "type": "json_schema",
409 "json_schema": _VERBATIM_SINGLE_JSON_SCHEMA,
410 },
411 )
412 except TypeError:
413 # Provider's complete() pre-dates response_format kwarg —
414 # retry once without (still JSON-parseable thanks to the
415 # prompt). A real failure on this fallback raises through
416 # the surrounding except.
417 try:
418 completion = await llm_provider.complete(
419 messages_in,
420 max_tokens=2048,
421 temperature=0.0,
422 )
423 except Exception as exc:
424 raise _VerbatimChunkError(f"LLM call failed: {exc}") from exc
425 except Exception as exc:
426 raise _VerbatimChunkError(f"LLM call failed: {exc}") from exc
427 parsed = _parse_json_object(completion.text)
428 if parsed is None:
429 raise _VerbatimChunkError("malformed JSON response")
430 return parsed
433async def _extract_one_chunk_verbatim(
434 chunk: str,
435 llm_provider,
436 *,
437 event_date: datetime | None = None,
438 max_retries: int = 3,
439 base_retry_delay: float = 2.0,
440) -> dict:
441 """Extract verbatim metadata for ONE chunk with retries.
443 Phase 4 of cost-control port — wraps
444 :func:`_extract_one_chunk_verbatim_attempt` with the same retry
445 policy Hindsight uses for ``_extract_chunk_with_retry``: up to
446 ``max_retries`` attempts with exponential backoff
447 (``base_retry_delay`` × 2^attempt). On final exhaustion returns
448 ``{}`` so the caller falls through to a metadata-less
449 ExtractedFact (chunk text still preserved).
451 Tests that need to exercise the bare single-attempt failure path
452 can pass ``max_retries=1``.
453 """
454 if not chunk.strip():
455 return {}
456 last_exc: BaseException | None = None
457 for attempt in range(max(1, max_retries)):
458 try:
459 return await _extract_one_chunk_verbatim_attempt(
460 chunk,
461 llm_provider,
462 event_date=event_date,
463 )
464 except _VerbatimChunkError as exc:
465 last_exc = exc
466 if attempt < max_retries - 1:
467 delay = base_retry_delay * (2**attempt)
468 _logger.warning(
469 "fact_extraction (verbatim/parallel) chunk attempt %d/%d failed (%s); retrying in %.0fs",
470 attempt + 1,
471 max_retries,
472 exc,
473 delay,
474 )
475 await asyncio.sleep(delay)
476 _logger.warning(
477 "fact_extraction (verbatim/parallel) chunk exhausted %d retries (%s); "
478 "falling through to metadata-less ExtractedFact",
479 max_retries,
480 last_exc,
481 )
482 return {}
485def _build_extracted_fact_from_raw(
486 chunk: str,
487 raw: dict,
488) -> ExtractedFact:
489 """Convert one parsed metadata dict + the original chunk text into
490 an :class:`ExtractedFact`. Shared by the batched and per-chunk
491 parallel paths."""
492 entities: list[FactEntity] = []
493 for ent in raw.get("entities") or []:
494 if not isinstance(ent, dict):
495 continue
496 name = str(ent.get("name") or "").strip()
497 if not name:
498 continue
499 etype = str(ent.get("entity_type") or "OTHER").strip().upper() or "OTHER"
500 entities.append(FactEntity(name=name, entity_type=etype))
501 ftype = str(raw.get("fact_type") or "").strip().lower()
502 classified = ftype in {"world", "experience"}
503 if not classified:
504 ftype = "experience"
505 return ExtractedFact(
506 what=chunk,
507 when=str(raw.get("when") or "N/A").strip() or "N/A",
508 where=str(raw.get("where") or "N/A").strip() or "N/A",
509 who=str(raw.get("who") or "N/A").strip() or "N/A",
510 why=str(raw.get("why") or "N/A").strip() or "N/A",
511 fact_type=ftype,
512 fact_type_classified=classified,
513 occurred_start=_parse_iso_datetime(raw.get("occurred_start")),
514 occurred_end=_parse_iso_datetime(raw.get("occurred_end")),
515 entities=entities,
516 # Per-chunk parallel path drops cross-chunk causal_relations.
517 # Caller using this helper from the batched path can override.
518 causal_relations=[],
519 )
522async def extract_facts_verbatim_parallel(
523 chunk_texts: list[str],
524 llm_provider,
525 *,
526 event_date: datetime | None = None,
527 max_concurrency: int = 6,
528 max_retries: int = 3,
529 base_retry_delay: float = 2.0,
530) -> list[ExtractedFact]:
531 """Per-chunk parallel verbatim extraction.
533 Phase 3 of the Hindsight cost-control port. Sends one LLM call per
534 chunk in parallel, bounded by ``max_concurrency`` per session call.
535 Returns one :class:`ExtractedFact` per input chunk in the same
536 order; failed chunks get a metadata-less ExtractedFact preserving
537 the chunk text.
539 When to use vs :func:`extract_facts_verbatim`:
540 - Sessions with many small chunks (LME-shaped traffic, ~6 chunks
541 after Phase 1 chunk_size=2048): per-chunk parallel typically
542 ~2× faster wall time because per-call output is small and they
543 run concurrently.
544 - Sessions with very few chunks (1–2): batched is comparable; the
545 extra round trips don't pay for themselves.
547 Drops cross-chunk ``causal_relations`` index references — they make
548 no sense per-chunk. ``causal_links.enabled=false`` in our research
549 configs, so this is a non-loss; if causal extraction is on, prefer
550 the batched path.
551 """
552 if not chunk_texts:
553 return []
554 if not any(t.strip() for t in chunk_texts):
555 return []
556 sem = asyncio.Semaphore(max(1, max_concurrency))
558 async def _one(idx: int, text: str) -> _ChunkResult:
559 async with sem:
560 return _ChunkResult(
561 idx=idx,
562 raw=await _extract_one_chunk_verbatim(
563 text,
564 llm_provider,
565 event_date=event_date,
566 max_retries=max_retries,
567 base_retry_delay=base_retry_delay,
568 ),
569 )
571 gathered = await asyncio.gather(
572 *[_one(i, t) for i, t in enumerate(chunk_texts)],
573 return_exceptions=True,
574 )
576 # Reassemble in input order. asyncio.gather preserves order, but we
577 # store explicit ``_ChunkResult`` records to defend against future
578 # refactors that change the dispatch pattern.
579 raw_by_idx: dict[int, dict] = {}
580 for r in gathered:
581 if isinstance(r, BaseException):
582 # The semaphore-wrapped coroutine swallows exceptions inside
583 # _extract_one_chunk_verbatim and returns {}, so reaching
584 # this branch means a programming error or task
585 # cancellation. Log and continue with an empty metadata.
586 _logger.warning(
587 "fact_extraction (verbatim/parallel) task raised: %r",
588 r,
589 )
590 continue
591 raw_by_idx[r.idx] = r.raw if isinstance(r.raw, dict) else {}
593 return [_build_extracted_fact_from_raw(chunk, raw_by_idx.get(idx, {})) for idx, chunk in enumerate(chunk_texts)]
596# ---------------------------------------------------------------------------
597# JSON parsing helpers
598# ---------------------------------------------------------------------------
601def _parse_json_object(raw: str) -> dict | None:
602 text = raw.strip()
603 if text.startswith("```"):
604 text = re.sub(r"^```(?:json)?\s*", "", text)
605 text = re.sub(r"\s*```$", "", text)
606 match = re.search(r"\{.*\}", text, re.DOTALL)
607 if match is None:
608 return None
609 try:
610 payload = json.loads(match.group(0))
611 except json.JSONDecodeError:
612 return None
613 return payload if isinstance(payload, dict) else None
616def _parse_iso_datetime(value) -> datetime | None:
617 if not value:
618 return None
619 if isinstance(value, datetime):
620 return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
621 if not isinstance(value, str):
622 return None
623 s = value.strip()
624 if not s:
625 return None
626 # Tolerate trailing 'Z' (Python 3.11+ handles it, but be defensive).
627 if s.endswith("Z"):
628 s = s[:-1] + "+00:00"
629 try:
630 dt = datetime.fromisoformat(s)
631 except ValueError:
632 return None
633 return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
636# ---------------------------------------------------------------------------
637# Extraction
638# ---------------------------------------------------------------------------
641#: Running alignment counters for verbatim extraction, so the rate of
642#: dropped or misplaced metadata is observable instead of silent.
643VERBATIM_ALIGNMENT: dict[str, int] = {
644 "calls": 0, "exact": 0, "keyed": 0, "positional": 0,
645 "dropped_unlocatable": 0, "unmatched_chunks": 0,
646 "duplicate_indices": 0, "invalid_indices": 0,
647}
650def _as_index(value) -> int | None:
651 """An integer chunk index, or None. Rejects bools (an int subclass)."""
652 if isinstance(value, bool):
653 return None
654 if isinstance(value, int):
655 return value
656 if isinstance(value, str) and value.strip().isdigit():
657 return int(value.strip())
658 return None
661def align_verbatim_metadata(raw_metadata: list, n_chunks: int) -> list[dict]:
662 """Map the model's metadata entries onto chunks, never onto the wrong one.
664 The old join paired entries with chunks by list position. That is only
665 correct when the model returns exactly one entry per chunk in order. A
666 paired thinking on/off experiment (18 real LongMemEval batches, 2026-10-03)
667 found the length was wrong in 25% of calls with extended thinking on and
668 69% with it off; entity grounding in the positional chunk fell from 87.6%
669 to 74.2% (63.2% in the second half of each list) — the signature of one
670 skipped or merged entry shifting every later chunk onto its neighbour's
671 dates and entities, silently.
673 Join rules, in order:
675 1. Entries carry ``chunk_index`` -> join on it. An entry with a missing,
676 out-of-range or duplicate index is discarded; its chunk gets no
677 metadata rather than someone else's.
678 2. No entry carries an index (an older prompt or a provider that ignored
679 it) and the count matches exactly -> positional join, which is safe at
680 an exact length.
681 3. No indices and the count is wrong -> no metadata for any chunk. The
682 point where positions diverged cannot be located, and absent metadata
683 is recoverable while misattributed metadata is not.
685 Returns a list of length ``n_chunks``; unmatched chunks get ``{}``.
686 """
687 VERBATIM_ALIGNMENT["calls"] += 1
688 entries = [e for e in raw_metadata if isinstance(e, dict)]
689 aligned: list[dict] = [{} for _ in range(n_chunks)]
691 mode = "dropped"
692 if any("chunk_index" in e for e in entries):
693 mode = "keyed"
694 VERBATIM_ALIGNMENT["keyed"] += 1
695 taken: set[int] = set()
696 for e in entries:
697 idx = _as_index(e.get("chunk_index"))
698 if idx is None or not (0 <= idx < n_chunks):
699 VERBATIM_ALIGNMENT["invalid_indices"] += 1
700 continue
701 if idx in taken:
702 VERBATIM_ALIGNMENT["duplicate_indices"] += 1
703 continue
704 taken.add(idx)
705 aligned[idx] = e
706 elif len(entries) == n_chunks:
707 mode = "positional"
708 VERBATIM_ALIGNMENT["positional"] += 1
709 aligned = list(entries)
710 else:
711 VERBATIM_ALIGNMENT["dropped_unlocatable"] += 1
712 _logger.info(
713 "fact_extraction (verbatim): %d entries for %d chunks and no chunk_index; "
714 "dropping metadata rather than risk misattribution",
715 len(entries), n_chunks,
716 )
718 unmatched = sum(1 for a in aligned if not a)
719 VERBATIM_ALIGNMENT["unmatched_chunks"] += unmatched
720 if unmatched == 0:
721 VERBATIM_ALIGNMENT["exact"] += 1
722 elif mode == "keyed":
723 _logger.info(
724 "fact_extraction (verbatim): %d of %d chunks have no matching metadata entry",
725 unmatched, n_chunks,
726 )
727 return aligned
730async def extract_facts_verbatim(
731 chunk_texts: list[str],
732 llm_provider,
733 *,
734 event_date: datetime | None = None,
735) -> list[ExtractedFact]:
736 """Extract per-chunk metadata WITHOUT paraphrasing the chunk text.
738 The "what" field of each returned :class:`ExtractedFact` is set to
739 the original chunk text — not an LLM-generated summary. The LLM's
740 job here is only to produce structured metadata (entities,
741 causal_relations, temporal range, fact_type, where/who/why
742 annotations) per chunk.
744 Why this exists (the design lesson from 2026-05-02):
745 The "concise" mode :func:`extract_facts` replaces conversation text
746 with structured paraphrases like "Caroline went hiking | Involving:
747 Caroline | When: yesterday". That paraphrase loses the surface
748 vocabulary of the original conversation, which question embeddings
749 typically share — causing severe recall_hit_rate degradation. The
750 verbatim mode preserves the original vocabulary while still
751 enriching each chunk with the structured metadata needed for
752 causal/temporal/per-fact retrieval signals.
754 Returns one :class:`ExtractedFact` per input chunk, in the same
755 order, with ``what`` = the chunk text. Returns ``[]`` on any
756 failure so retain falls back to legacy chunking.
758 Args:
759 chunk_texts: List of pre-chunked source texts; one per memory.
760 llm_provider: Producer of the metadata extraction.
761 event_date: Reference for resolving relative time expressions.
762 """
763 if not chunk_texts:
764 return []
765 # Prefilter: drop empty chunks but preserve indices for the LLM.
766 if not any(t.strip() for t in chunk_texts):
767 return []
769 # Phase 2 of cost-control port: prefer structured outputs so the
770 # decoder is constrained to ``_VERBATIM_JSON_SCHEMA`` and the
771 # "malformed JSON" failure mode becomes impossible. Falls back
772 # transparently to the legacy free-form path when the provider
773 # doesn't accept the kwarg (legacy fakes / non-OpenAI providers
774 # that haven't ported the SPI extension yet).
775 messages_in = [
776 Message(role="system", content=_VERBATIM_SYSTEM_PROMPT),
777 Message(
778 role="user",
779 content=_build_verbatim_user_prompt(chunk_texts, event_date),
780 ),
781 ]
782 try:
783 completion = await llm_provider.complete(
784 messages_in,
785 max_tokens=4096,
786 temperature=0.0,
787 response_format={"type": "json_schema", "json_schema": _VERBATIM_JSON_SCHEMA},
788 )
789 except TypeError:
790 # Provider's complete() pre-dates the response_format kwarg —
791 # retry without it. Free-form path is still resilient via the
792 # _parse_json_object fallback below.
793 try:
794 completion = await llm_provider.complete(
795 messages_in,
796 max_tokens=4096,
797 temperature=0.0,
798 )
799 except Exception as exc:
800 _logger.warning("fact_extraction (verbatim) LLM call failed (%s)", exc)
801 return []
802 except Exception as exc:
803 _logger.warning("fact_extraction (verbatim) LLM call failed (%s)", exc)
804 return []
806 parsed = _parse_json_object(completion.text)
807 if parsed is None:
808 _logger.warning("fact_extraction (verbatim): malformed JSON response")
809 return []
810 raw_metadata = parsed.get("facts")
811 if not isinstance(raw_metadata, list):
812 _logger.warning("fact_extraction (verbatim): 'facts' is not a list")
813 return []
815 aligned = align_verbatim_metadata(raw_metadata, len(chunk_texts))
816 out: list[ExtractedFact] = []
817 for idx, chunk in enumerate(chunk_texts):
818 # Metadata is joined by the echoed chunk_index, not list position
819 # (see align_verbatim_metadata). Unmatched chunks still keep their
820 # text; they just carry no metadata.
821 raw = aligned[idx]
823 # Entities
824 entities: list[FactEntity] = []
825 for ent in raw.get("entities") or []:
826 if not isinstance(ent, dict):
827 continue
828 name = str(ent.get("name") or "").strip()
829 if not name:
830 continue
831 etype = str(ent.get("entity_type") or "OTHER").strip().upper() or "OTHER"
832 entities.append(FactEntity(name=name, entity_type=etype))
834 # Causal relations — same semantics as concise mode but indices
835 # reference the chunk position (which IS the memory position).
836 causal: list[FactCausalRelation] = []
837 for rel in raw.get("causal_relations") or []:
838 if not isinstance(rel, dict):
839 continue
840 try:
841 target = int(rel.get("target_fact_index"))
842 except (TypeError, ValueError):
843 continue
844 if target == idx or not (0 <= target < len(chunk_texts)):
845 continue
846 try:
847 strength = float(rel.get("strength", 1.0))
848 except (TypeError, ValueError):
849 strength = 1.0
850 causal.append(FactCausalRelation(target_fact_index=target, strength=strength))
852 ftype = str(raw.get("fact_type") or "").strip().lower()
853 classified = ftype in {"world", "experience"}
854 if not classified:
855 ftype = "experience"
857 out.append(
858 ExtractedFact(
859 # KEY: "what" is the ORIGINAL chunk text, not a paraphrase.
860 what=chunk,
861 when=str(raw.get("when") or "N/A").strip() or "N/A",
862 where=str(raw.get("where") or "N/A").strip() or "N/A",
863 who=str(raw.get("who") or "N/A").strip() or "N/A",
864 why=str(raw.get("why") or "N/A").strip() or "N/A",
865 fact_type=ftype,
866 fact_type_classified=classified,
867 occurred_start=_parse_iso_datetime(raw.get("occurred_start")),
868 occurred_end=_parse_iso_datetime(raw.get("occurred_end")),
869 entities=entities,
870 causal_relations=causal,
871 )
872 )
873 return out
876# ---------------------------------------------------------------------------
877# Materialization
878# ---------------------------------------------------------------------------
881def _slug(value: str) -> str:
882 return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "unknown"
885@dataclass
886class MaterializedFacts:
887 """The retain-pipeline artefacts produced from a fact-extraction batch.
889 - ``vector_items`` — one VectorItem per fact, ready for store_vectors.
890 The item's ``id`` is also the fact's identifier for causal/entity
891 linkage. Order matches the input ``ExtractedFact`` list.
892 - ``entities`` — deduplicated Entity list across the batch (one
893 entity per unique (name, type) tuple). Each entity uses a
894 deterministic ID ``{type}:{slug}`` so subsequent retain calls
895 collapse repeated mentions to the same canonical row.
896 - ``memory_entity_associations`` — list of (vector_item_id,
897 entity_id) tuples for the orchestrator's
898 link_memories_to_entities call.
899 - ``memory_links`` — directional caused_by edges; source is the
900 effect's memory ID, target is the cause's memory ID.
901 """
903 vector_items: list[VectorItem]
904 entities: list[Entity]
905 memory_entity_associations: list[tuple[str, str]]
906 memory_links: list[MemoryLink]
909def materialize_facts(
910 facts: list[ExtractedFact],
911 *,
912 bank_id: str,
913 tags: list[str] | None = None,
914 metadata: dict | None = None,
915 occurred_at: datetime | None = None,
916 embeddings: list[list[float]] | None = None,
917) -> MaterializedFacts:
918 """Convert extracted facts to retain-pipeline artefacts.
920 The orchestrator wires this output into its existing storage path
921 (store_vectors → store_entities → link_memories_to_entities →
922 store_memory_links) without further translation.
924 The VectorItem's ``text`` is always the raw chunk content
925 (``fact.what``) — verbatim mode is now the only supported flow
926 after the M9 concise-path removal. Storing the original vocabulary
927 is what makes question embeddings match against retained memories;
928 decorated text (``what | Involving: ... | When: ...``) caused
929 severe recall_hit_rate degradation (2026-05-02 finding).
931 Args:
932 facts: Output of :func:`extract_facts_verbatim` /
933 :func:`extract_facts_verbatim_parallel`.
934 bank_id: Target bank.
935 tags: Tags applied to every produced VectorItem.
936 metadata: Base metadata merged onto every VectorItem; the
937 fact's structured fields (when/where/who/why/fact_type)
938 are written under ``_fact_*`` prefixed keys for downstream
939 queries that want to filter/promote on them.
940 occurred_at: Default timestamp when a fact lacks
941 ``occurred_start``. Typically the retain request's
942 ``occurred_at``.
943 embeddings: Optional pre-computed embeddings per fact (must
944 match ``len(facts)``). When omitted, items are created
945 with empty vectors — caller is responsible for embedding.
946 """
947 base_metadata = dict(metadata or {})
948 base_tags = list(tags or [])
949 now = datetime.now(timezone.utc)
951 # Step 1: deduplicate entities across the batch by (name, type).
952 # Entity IDs are deterministic — same name+type produces same ID.
953 entity_by_key: dict[tuple[str, str], Entity] = {}
954 for fact in facts:
955 for fent in fact.entities:
956 key = (fent.name.strip().lower(), fent.entity_type.strip().upper())
957 if key in entity_by_key:
958 continue
959 eid = f"{key[1].lower()}:{_slug(fent.name)}"
960 entity_by_key[key] = Entity(
961 id=eid,
962 name=fent.name,
963 entity_type=fent.entity_type,
964 aliases=[fent.name],
965 metadata={"source": "fact_extraction"},
966 )
967 entities = list(entity_by_key.values())
969 # Step 2: build VectorItems, one per fact. The IDs flow through
970 # causal_relations resolution below.
971 items: list[VectorItem] = []
972 associations: list[tuple[str, str]] = []
973 for idx, fact in enumerate(facts):
974 item_id = uuid.uuid4().hex
975 fact_metadata = dict(base_metadata)
976 # Promote structured dimensions into metadata so downstream
977 # consumers can filter/rerank on them. Use the ``_fact_*``
978 # prefix to avoid colliding with caller-supplied keys.
979 if fact.when and fact.when.upper() != "N/A":
980 fact_metadata["_fact_when"] = fact.when
981 if fact.where and fact.where.upper() != "N/A":
982 fact_metadata["_fact_where"] = fact.where
983 if fact.who and fact.who.upper() != "N/A":
984 fact_metadata["_fact_who"] = fact.who
985 if fact.why and fact.why.upper() != "N/A":
986 fact_metadata["_fact_why"] = fact.why
987 fact_metadata["_fact_type"] = fact.fact_type
988 if fact.occurred_start is not None:
989 fact_metadata["_fact_occurred_start"] = fact.occurred_start.isoformat()
990 if fact.occurred_end is not None:
991 fact_metadata["_fact_occurred_end"] = fact.occurred_end.isoformat()
993 vector = embeddings[idx] if embeddings is not None and idx < len(embeddings) else []
995 items.append(
996 VectorItem(
997 id=item_id,
998 bank_id=bank_id,
999 vector=vector,
1000 # Always store the raw chunk text so question embeddings
1001 # match against the original vocabulary. The decorated-
1002 # text path (concise mode) was removed in M9.
1003 text=fact.what,
1004 metadata=fact_metadata,
1005 tags=list(base_tags),
1006 fact_type=fact.fact_type,
1007 memory_layer="raw",
1008 occurred_at=fact.occurred_start or occurred_at,
1009 retained_at=now,
1010 )
1011 )
1013 # Association: link this memory to each entity it mentions.
1014 for fent in fact.entities:
1015 key = (fent.name.strip().lower(), fent.entity_type.strip().upper())
1016 ent = entity_by_key.get(key)
1017 if ent is not None:
1018 associations.append((item_id, ent.id))
1020 # Step 3: causal relations → MemoryLinks. Source is the EFFECT
1021 # (the fact making the claim); target is the CAUSE.
1022 memory_links: list[MemoryLink] = []
1023 for src_idx, fact in enumerate(facts):
1024 for rel in fact.causal_relations:
1025 tgt_idx = rel.target_fact_index
1026 if tgt_idx == src_idx:
1027 continue # self-loop
1028 if not (0 <= tgt_idx < len(facts)):
1029 continue
1030 try:
1031 strength = float(rel.strength)
1032 except (TypeError, ValueError):
1033 strength = 1.0
1034 memory_links.append(
1035 MemoryLink(
1036 source_memory_id=items[src_idx].id,
1037 target_memory_id=items[tgt_idx].id,
1038 link_type="caused_by",
1039 confidence=min(1.0, max(0.0, strength)),
1040 weight=1.0,
1041 created_at=now,
1042 metadata={"bank_id": bank_id, "source": "fact_extraction"},
1043 )
1044 )
1046 return MaterializedFacts(
1047 vector_items=items,
1048 entities=entities,
1049 memory_entity_associations=associations,
1050 memory_links=memory_links,
1051 )