-
Notifications
You must be signed in to change notification settings - Fork 56
feat(lifecycle): add forget flag + TTL to v2 ingest API (#166) #228
base: main
Are you sure you want to change the base?
Changes from 5 commits
8553057
e921618
1698c9c
485f733
9a6f8fe
8ae9176
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,71 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Memory lifecycle — pure, deterministic helper functions for forget/TTL. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| is_retrievable() is the single retrieval-time gate. build_forget_metadata() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| stamps the lifecycle fields onto a metadata dict for storage. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| All functions are side-effect-free so they can be tested without live services. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from datetime import datetime | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from typing import Any, Dict, Mapping, Optional | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def is_retrievable(metadata: Mapping[str, Any], now: datetime) -> bool: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Return True when a record should appear in retrieval results. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Rules (applied in order): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 1. ``lifecycle_state == "forgotten"`` → hidden (manual soft-forget). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 2. ``forget is True`` and ``expires_at`` is present and in the past → hidden (TTL expired). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 3. Everything else (including all legacy records with no lifecycle keys) → retrievable. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Missing keys default to the legacy-safe value so records stored before | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| lifecycle was introduced are never hidden. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if metadata.get("lifecycle_state", "active") == "forgotten": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if metadata.get("forget"): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expires_raw = metadata.get("expires_at") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if expires_raw: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expires_at = datetime.fromisoformat(str(expires_raw)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Make both sides timezone-aware or both naive for comparison | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if expires_at.tzinfo is None and now.tzinfo is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from datetime import timezone | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expires_at = expires_at.replace(tzinfo=timezone.utc) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| elif expires_at.tzinfo is not None and now.tzinfo is None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expires_at = expires_at.replace(tzinfo=None) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if expires_at < now: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except (ValueError, TypeError): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return True | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def build_lifecycle_metadata( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| now: datetime, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ttl_days: float, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| reason: Optional[str] = None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> Dict[str, Any]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Return the lifecycle metadata dict to merge onto a forget=true record. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Called once at v2 ingestion time when the caller sets ``forget=true``. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| The result is stored as part of the vector record's metadata so the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| retrieval-time filter can enforce the TTL without any background sweeper. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from datetime import timedelta | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expires_at = now + timedelta(days=ttl_days) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| meta: Dict[str, Any] = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "forget": True, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "expires_at": expires_at.isoformat(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "lifecycle_state": "active", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "created_at": now.isoformat(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "updated_at": now.isoformat(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if reason: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| meta["forget_reason"] = reason | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return meta | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -21,6 +21,7 @@ | |||||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| import asyncio | ||||||||||||||||||||||||||||||
| from datetime import datetime, timezone | ||||||||||||||||||||||||||||||
| import logging | ||||||||||||||||||||||||||||||
| from typing import Any, Callable, Dict, List, Optional | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -31,6 +32,7 @@ | |||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| from src.config import settings | ||||||||||||||||||||||||||||||
| from src.graph.neo4j_client import Neo4jClient | ||||||||||||||||||||||||||||||
| from src.pipelines.lifecycle import is_retrievable | ||||||||||||||||||||||||||||||
| from src.prompts.retrieval import ANSWER_PROMPT, build_system_prompt | ||||||||||||||||||||||||||||||
| from src.schemas.retrieval import RetrievalResult, SourceRecord | ||||||||||||||||||||||||||||||
| from src.schemas.code import snippets_namespace | ||||||||||||||||||||||||||||||
|
|
@@ -100,6 +102,7 @@ def __init__( | |||||||||||||||||||||||||||||
| model: Optional[BaseChatModel] = None, | ||||||||||||||||||||||||||||||
| vector_store: Optional[BaseVectorStore] = None, | ||||||||||||||||||||||||||||||
| neo4j_client: Optional[Neo4jClient] = None, | ||||||||||||||||||||||||||||||
| _now: Optional[Callable[[], datetime]] = None, | ||||||||||||||||||||||||||||||
| ) -> None: | ||||||||||||||||||||||||||||||
| # ── LLM ─────────────────────────────────────────────────────── | ||||||||||||||||||||||||||||||
| if model is None: | ||||||||||||||||||||||||||||||
|
|
@@ -133,6 +136,7 @@ def __init__( | |||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| self.embed_fn = embed_fn | ||||||||||||||||||||||||||||||
| self._snippet_stores: Dict[str, BaseVectorStore] = {} | ||||||||||||||||||||||||||||||
| self._now: Callable[[], datetime] = _now or (lambda: datetime.now(timezone.utc)) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| logger.info("RetrievalPipeline initialized") | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -413,8 +417,11 @@ async def _search_summary( | |||||||||||||||||||||||||||||
| user_id: str, | ||||||||||||||||||||||||||||||
| top_k: int = 10, | ||||||||||||||||||||||||||||||
| ) -> List[SourceRecord]: | ||||||||||||||||||||||||||||||
| """Semantic search over summary entries in Pinecone.""" | ||||||||||||||||||||||||||||||
| """Semantic search over summary entries in Pinecone. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Records ingested with ``forget=true`` whose TTL has passed are filtered | ||||||||||||||||||||||||||||||
| out at read time. Legacy records (no lifecycle keys) always pass through. | ||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||
| results = await self.vector_store.search_by_text( | ||||||||||||||||||||||||||||||
| query_text=query, | ||||||||||||||||||||||||||||||
| top_k=top_k, | ||||||||||||||||||||||||||||||
|
|
@@ -424,8 +431,11 @@ async def _search_summary( | |||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| now = self._now() | ||||||||||||||||||||||||||||||
| records = [] | ||||||||||||||||||||||||||||||
| for r in results: | ||||||||||||||||||||||||||||||
| if not is_retrievable(r.metadata, now): | ||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||
| records.append(SourceRecord( | ||||||||||||||||||||||||||||||
| domain="summary", | ||||||||||||||||||||||||||||||
| content=r.content, | ||||||||||||||||||||||||||||||
|
|
@@ -503,10 +513,15 @@ def _fetch_profile_catalog(self, user_id: str): | |||||||||||||||||||||||||||||
| logger.warning("Failed to fetch profile catalog: %s", exc) | ||||||||||||||||||||||||||||||
| return [], [] | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| now = self._now() | ||||||||||||||||||||||||||||||
| catalog: List[Dict[str, str]] = [] | ||||||||||||||||||||||||||||||
| seen = set() | ||||||||||||||||||||||||||||||
| live_results = [] | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| for r in results: | ||||||||||||||||||||||||||||||
| if not is_retrievable(r.metadata, now): | ||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||
| live_results.append(r) | ||||||||||||||||||||||||||||||
| main_content = r.metadata.get("main_content", "") | ||||||||||||||||||||||||||||||
| if not main_content or main_content in seen: | ||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||
|
Comment on lines
521
to
527
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If
Suggested change
|
||||||||||||||||||||||||||||||
|
|
@@ -524,7 +539,7 @@ def _fetch_profile_catalog(self, user_id: str): | |||||||||||||||||||||||||||||
| "sub_topic": "", | ||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return catalog, results | ||||||||||||||||||||||||||||||
| return catalog, live_results | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def _format_catalog(self, catalog: List[Dict[str, str]]) -> str: | ||||||||||||||||||||||||||||||
| """Format profile catalog for the system prompt.""" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -13,6 +13,7 @@ | |||||||||||
| from __future__ import annotations | ||||||||||||
|
|
||||||||||||
| import asyncio | ||||||||||||
| from datetime import datetime, timezone | ||||||||||||
| from functools import partial | ||||||||||||
| import logging | ||||||||||||
| from typing import Any, Callable, Dict, List, Optional | ||||||||||||
|
|
@@ -63,6 +64,7 @@ def __init__( | |||||||||||
| code_vector_store: Optional[BaseVectorStore] = None, | ||||||||||||
| graph_create_annotation: Optional[GraphCreateAnnotationFn] = None, | ||||||||||||
| snippet_vector_store: Optional[BaseVectorStore] = None, | ||||||||||||
| _now: Optional[Callable[[], datetime]] = None, | ||||||||||||
| ) -> None: | ||||||||||||
| self.vector_store = vector_store | ||||||||||||
| self.embed_fn = embed_fn | ||||||||||||
|
|
@@ -72,6 +74,7 @@ def __init__( | |||||||||||
| self.code_vector_store = code_vector_store | ||||||||||||
| self.graph_create_annotation = graph_create_annotation | ||||||||||||
| self.snippet_vector_store = snippet_vector_store | ||||||||||||
| self._now: Callable[[], datetime] = _now or (lambda: datetime.now(timezone.utc)) | ||||||||||||
|
|
||||||||||||
| # ------------------------------------------------------------------ | ||||||||||||
| # Public entry point | ||||||||||||
|
|
@@ -82,6 +85,7 @@ async def execute( | |||||||||||
| judge_result: JudgeResult, | ||||||||||||
| domain: JudgeDomain, | ||||||||||||
| user_id: str, | ||||||||||||
| extra_metadata: Optional[Dict[str, Any]] = None, | ||||||||||||
| ) -> WeaverResult: | ||||||||||||
| result = WeaverResult() | ||||||||||||
|
|
||||||||||||
|
|
@@ -91,7 +95,9 @@ async def execute( | |||||||||||
|
|
||||||||||||
| # Optimization: Batch vector operations if possible | ||||||||||||
| if domain not in (JudgeDomain.TEMPORAL, JudgeDomain.CODE, JudgeDomain.SNIPPET) and self.vector_store: | ||||||||||||
| batched_executed = await self._execute_batched_vector(judge_result.operations, domain, user_id) | ||||||||||||
| batched_executed = await self._execute_batched_vector( | ||||||||||||
| judge_result.operations, domain, user_id, extra_metadata=extra_metadata | ||||||||||||
| ) | ||||||||||||
| result.executed.extend(batched_executed) | ||||||||||||
| else: | ||||||||||||
| for op in judge_result.operations: | ||||||||||||
|
|
@@ -106,6 +112,7 @@ async def _execute_batched_vector( | |||||||||||
| operations: List[Operation], | ||||||||||||
| domain: JudgeDomain, | ||||||||||||
| user_id: str, | ||||||||||||
| extra_metadata: Optional[Dict[str, Any]] = None, | ||||||||||||
| ) -> List[ExecutedOp]: | ||||||||||||
| """Batch ADD and DELETE operations to reduce vector store round-trips.""" | ||||||||||||
| executed_ops: List[ExecutedOp] = [] | ||||||||||||
|
|
@@ -142,6 +149,8 @@ async def flush_add_batch(): | |||||||||||
| try: | ||||||||||||
| meta = {"user_id": user_id, "domain": domain.value} | ||||||||||||
| meta.update(_extract_structured_metadata(op.content)) | ||||||||||||
| if extra_metadata: | ||||||||||||
| meta.update(extra_metadata) | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Merging
Suggested change
|
||||||||||||
|
|
||||||||||||
| valid_ops.append(op) | ||||||||||||
| texts.append(op.content) | ||||||||||||
|
|
@@ -371,7 +380,11 @@ async def _execute_vector( | |||||||||||
| ) | ||||||||||||
|
|
||||||||||||
| async def _vector_add( | ||||||||||||
| self, op: Operation, domain: JudgeDomain, user_id: str, | ||||||||||||
| self, | ||||||||||||
| op: Operation, | ||||||||||||
| domain: JudgeDomain, | ||||||||||||
| user_id: str, | ||||||||||||
| extra_metadata: Optional[Dict[str, Any]] = None, | ||||||||||||
| ) -> ExecutedOp: | ||||||||||||
| if not self.embed_fn: | ||||||||||||
| return ExecutedOp( | ||||||||||||
|
|
@@ -385,6 +398,8 @@ async def _vector_add( | |||||||||||
| # Store structured metadata for deterministic lookups | ||||||||||||
| structured = _extract_structured_metadata(op.content) | ||||||||||||
| metadata.update(structured) | ||||||||||||
| if extra_metadata: | ||||||||||||
| metadata.update(extra_metadata) | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Merging
Suggested change
|
||||||||||||
|
|
||||||||||||
| ids = self.vector_store.add( | ||||||||||||
| texts=[op.content], | ||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of manually constructing the
lifecycle_metadatadictionary, use the newly introducedbuild_lifecycle_metadatahelper function fromsrc.pipelines.lifecycle. This avoids code duplication and ensures that thecreated_atandupdated_atfields are consistently populated on ingested records as designed.