Skip to content

Commit f903948

Browse files
authored
feat: support timestamp="unset" to retain content without a date (#465)
* feat: support timestamp="unset" to retain content without a date When callers retain timeless content (e.g. fictional documents, static reference material), passing timestamp="unset" now skips the utcnow() default so mentioned_at is stored as NULL instead of an artificial date. - HTTP: validate_timestamp recognises "unset" sentinel and threads it through api_retain as event_date=None (key present, value None), which the orchestrator distinguishes from key-absent (still defaults to now) - Orchestrator: new branching logic separates "key absent" → utcnow() from "key present but None" → no date - types.py: RetainContent.event_date and ProcessedFact.mentioned_at are now datetime | None; removed the unused _now_utc factory - fact_extraction.py: all event_date params accept datetime | None; _build_user_message emits "Event Date: Unknown" when None; removed mentioned_at from the Fact LLM response model (LLM never sets it) - embedding_processing: skip date suffix when fact_date is None - entity_resolver: COALESCE(event_date, now()) for first_seen/last_seen so entities table NOT NULL constraint is preserved - link_utils: skip temporal linking for units without event_date - Migration aa2b3c4d5e6f: DROP NOT NULL on memory_units.event_date - Tests: test_retain_no_timestamp and test_retain_omit_timestamp_defaults_to_now - Docs + OpenAPI + TypeScript client updated * refactor: replace _TIMESTAMP_UNKNOWN sentinel with plain string comparison The sentinel object() was only needed to distinguish "unset" from None at the boundary — but since the field type is datetime | str | None, "unset" can pass through the validator unchanged and be compared directly. * chore: regenerate OpenAPI spec and clients after timestamp type change timestamp field is now datetime | str | None to accept the "unset" sentinel value.
1 parent 77defd9 commit f903948

20 files changed

Lines changed: 540 additions & 69 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Make event_date nullable in memory_units to support timestamp-free content
2+
3+
Revision ID: aa2b3c4d5e6f
4+
Revises: z1u2v3w4x5y6
5+
Create Date: 2026-03-02
6+
7+
When callers retain content without a timestamp (e.g. fictional documents, static text),
8+
the event_date column should be allowed to be NULL rather than defaulting to utcnow().
9+
"""
10+
11+
from collections.abc import Sequence
12+
13+
from alembic import context, op
14+
15+
revision: str = "aa2b3c4d5e6f"
16+
down_revision: str | Sequence[str] | None = "z1u2v3w4x5y6"
17+
branch_labels: str | Sequence[str] | None = None
18+
depends_on: str | Sequence[str] | None = None
19+
20+
21+
def _get_schema_prefix() -> str:
22+
"""Get schema prefix for table names (required for multi-tenant support)."""
23+
schema = context.config.get_main_option("target_schema")
24+
return f'"{schema}".' if schema else ""
25+
26+
27+
def upgrade() -> None:
28+
schema = _get_schema_prefix()
29+
op.execute(f"ALTER TABLE {schema}memory_units ALTER COLUMN event_date DROP NOT NULL")
30+
31+
32+
def downgrade() -> None:
33+
schema = _get_schema_prefix()
34+
# Backfill NULLs with now() before restoring the NOT NULL constraint
35+
op.execute(f"UPDATE {schema}memory_units SET event_date = now() WHERE event_date IS NULL")
36+
op.execute(f"ALTER TABLE {schema}memory_units ALTER COLUMN event_date SET NOT NULL")

hindsight-api/hindsight_api/api/http.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,15 @@ class MemoryItem(BaseModel):
383383
)
384384

385385
content: str
386-
timestamp: datetime | None = None
386+
timestamp: datetime | str | None = Field(
387+
default=None,
388+
description=(
389+
"When the content occurred. "
390+
"Accepts an ISO 8601 datetime string (e.g. '2024-01-15T10:30:00Z'), null/omitted (defaults to now), "
391+
"or the special string 'unset' to explicitly store without any timestamp "
392+
"(use this for timeless content such as fictional documents or static reference material)."
393+
),
394+
)
387395
context: str | None = None
388396
metadata: dict[str, str] | None = None
389397
document_id: str | None = Field(default=None, description="Optional document ID for this memory item.")
@@ -414,12 +422,14 @@ def validate_timestamp(cls, v):
414422
if isinstance(v, datetime):
415423
return v
416424
if isinstance(v, str):
425+
if v.lower() == "unset":
426+
return "unset"
417427
try:
418428
# Try parsing as ISO format
419429
return datetime.fromisoformat(v.replace("Z", "+00:00"))
420430
except ValueError as e:
421431
raise ValueError(
422-
f"Invalid timestamp/event_date format: '{v}'. Expected ISO format like '2024-01-15T10:30:00' or '2024-01-15T10:30:00Z'"
432+
f"Invalid timestamp/event_date format: '{v}'. Expected ISO format like '2024-01-15T10:30:00' or '2024-01-15T10:30:00Z', or the special value 'unset' to store without a timestamp."
423433
) from e
424434
raise ValueError(f"timestamp must be a string or datetime, got {type(v).__name__}")
425435

@@ -3878,7 +3888,9 @@ async def api_retain(
38783888
contents = []
38793889
for item in request.items:
38803890
content_dict = {"content": item.content}
3881-
if item.timestamp:
3891+
if item.timestamp == "unset":
3892+
content_dict["event_date"] = None
3893+
elif item.timestamp:
38823894
content_dict["event_date"] = item.timestamp
38833895
if item.context:
38843896
content_dict["context"] = item.context

hindsight-api/hindsight_api/engine/entity_resolver.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ async def _resolve_entities_batch_impl(
237237
rows = await conn.fetch(
238238
f"""
239239
INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count)
240-
SELECT $1, name, event_date, event_date, cnt
240+
SELECT $1, name, COALESCE(event_date, now()), COALESCE(event_date, now()), cnt
241241
FROM unnest($2::text[], $3::timestamptz[], $4::int[]) AS t(name, event_date, cnt)
242242
ON CONFLICT (bank_id, LOWER(canonical_name))
243243
DO UPDATE SET
@@ -408,7 +408,7 @@ async def _create_entity(
408408
entity_id = await conn.fetchval(
409409
f"""
410410
INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count)
411-
VALUES ($1, $2, $3, $4, 1)
411+
VALUES ($1, $2, COALESCE($3, now()), COALESCE($4, now()), 1)
412412
ON CONFLICT (bank_id, LOWER(canonical_name))
413413
DO UPDATE SET
414414
mention_count = {fq_table("entities")}.mention_count + 1,

hindsight-api/hindsight_api/engine/retain/embedding_processing.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,12 @@ def augment_texts_with_dates(facts: list[ExtractedFact], format_date_fn) -> list
2929
for fact in facts:
3030
# Use occurred_start as the representative date
3131
fact_date = fact.occurred_start or fact.mentioned_at
32-
readable_date = format_date_fn(fact_date)
33-
# Augment text with date for embedding (but store original text in DB)
34-
augmented_text = f"{fact.fact_text} (happened in {readable_date})"
32+
if fact_date is not None:
33+
readable_date = format_date_fn(fact_date)
34+
# Augment text with date for embedding (but store original text in DB)
35+
augmented_text = f"{fact.fact_text} (happened in {readable_date})"
36+
else:
37+
augmented_text = fact.fact_text
3538
augmented_texts.append(augmented_text)
3639
return augmented_texts
3740

hindsight-api/hindsight_api/engine/retain/fact_extraction.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,16 @@
1919
from ..response_models import TokenUsage
2020

2121

22-
def _infer_temporal_date(fact_text: str, event_date: datetime) -> str | None:
22+
def _infer_temporal_date(fact_text: str, event_date: datetime | None) -> str | None:
2323
"""
2424
Infer a temporal date from fact text when LLM didn't provide occurred_start.
2525
2626
This is a fallback for when the LLM fails to extract temporal information
2727
from relative time expressions like "last night", "yesterday", etc.
2828
"""
29+
if event_date is None:
30+
return None
31+
2932
fact_lower = fact_text.lower()
3033

3134
# Map relative time expressions to day offsets
@@ -100,7 +103,6 @@ class Fact(BaseModel):
100103
# Optional temporal fields
101104
occurred_start: str | None = None
102105
occurred_end: str | None = None
103-
mentioned_at: str | None = None
104106

105107
# Optional location field
106108
where: str | None = Field(
@@ -747,7 +749,7 @@ def _build_user_message(
747749
chunk: str,
748750
chunk_index: int,
749751
total_chunks: int,
750-
event_date: datetime,
752+
event_date: datetime | None,
751753
context: str,
752754
metadata: dict[str, str] | None = None,
753755
) -> str:
@@ -756,8 +758,12 @@ def _build_user_message(
756758

757759
sanitized_chunk = _sanitize_text(chunk)
758760
sanitized_context = _sanitize_text(context) if context else "none"
759-
event_date = parse_datetime_flexible(event_date)
760-
event_date_formatted = event_date.strftime("%A, %B %d, %Y")
761+
762+
if event_date is not None:
763+
event_date = parse_datetime_flexible(event_date)
764+
event_date_str = f"{event_date.strftime('%A, %B %d, %Y')} ({event_date.isoformat()})"
765+
else:
766+
event_date_str = "Unknown"
761767

762768
metadata_section = ""
763769
if metadata:
@@ -767,7 +773,7 @@ def _build_user_message(
767773
return f"""Extract facts from the following text chunk.
768774
769775
Chunk: {chunk_index + 1}/{total_chunks}
770-
Event Date: {event_date_formatted} ({event_date.isoformat()})
776+
Event Date: {event_date_str}
771777
Context: {sanitized_context}{metadata_section}
772778
773779
Text:
@@ -805,7 +811,7 @@ async def _extract_facts_from_chunk(
805811
chunk: str,
806812
chunk_index: int,
807813
total_chunks: int,
808-
event_date: datetime,
814+
event_date: datetime | None,
809815
context: str,
810816
llm_config: "LLMConfig",
811817
config,
@@ -1043,8 +1049,9 @@ def get_value(field_name):
10431049
if validated_relations:
10441050
fact_data["causal_relations"] = validated_relations
10451051

1046-
# Always set mentioned_at to the event_date (when the conversation/document occurred)
1047-
fact_data["mentioned_at"] = event_date.isoformat()
1052+
# Set mentioned_at to the event_date (when the conversation/document occurred),
1053+
# or None when the caller opted into no timestamp.
1054+
fact_data["mentioned_at"] = event_date.isoformat() if event_date is not None else None
10481055

10491056
# Build Fact model instance
10501057
try:
@@ -1107,7 +1114,7 @@ async def _extract_facts_with_auto_split(
11071114
chunk: str,
11081115
chunk_index: int,
11091116
total_chunks: int,
1110-
event_date: datetime,
1117+
event_date: datetime | None,
11111118
context: str,
11121119
llm_config: LLMConfig,
11131120
config,
@@ -1226,7 +1233,7 @@ async def _extract_facts_with_auto_split(
12261233

12271234
async def extract_facts_from_text(
12281235
text: str,
1229-
event_date: datetime,
1236+
event_date: datetime | None,
12301237
llm_config: LLMConfig,
12311238
agent_name: str,
12321239
config,
@@ -1641,8 +1648,9 @@ def get_value(field_name):
16411648
if validated_relations:
16421649
fact_data["causal_relations"] = validated_relations
16431650

1644-
# Always set mentioned_at
1645-
fact_data["mentioned_at"] = event_date.isoformat()
1651+
# Set mentioned_at to the event_date (when the conversation/document occurred),
1652+
# or None when the caller opted into no timestamp.
1653+
fact_data["mentioned_at"] = event_date.isoformat() if event_date is not None else None
16461654

16471655
try:
16481656
fact = Fact(fact=combined_text, fact_type=fact_type, **fact_data)

hindsight-api/hindsight_api/engine/retain/link_utils.py

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ def compute_temporal_links(
4747

4848
links = []
4949
for unit_id, unit_event_date in new_units.items():
50+
# Units without event_date can't form temporal links
51+
if unit_event_date is None:
52+
continue
5053
# Normalize unit_event_date for consistent comparison
5154
unit_event_date_norm = _normalize_datetime(unit_event_date)
5255

@@ -96,7 +99,11 @@ def compute_temporal_query_bounds(
9699
return None, None
97100

98101
# Normalize all dates to be timezone-aware to avoid comparison issues
99-
all_dates = [_normalize_datetime(d) for d in new_units.values()]
102+
# Filter out None values — units without event_date can't form temporal links
103+
all_dates = [_normalize_datetime(d) for d in new_units.values() if d is not None]
104+
105+
if not all_dates:
106+
return None, None
100107

101108
try:
102109
min_date = min(all_dates) - timedelta(hours=time_window_hours)
@@ -432,20 +439,23 @@ async def create_temporal_links_batch_per_fact(
432439
min_date, max_date = compute_temporal_query_bounds(new_units, time_window_hours)
433440

434441
fetch_neighbors_start = time_mod.time()
435-
all_candidates = await conn.fetch(
436-
f"""
437-
SELECT id, event_date
438-
FROM {fq_table("memory_units")}
439-
WHERE bank_id = $1
440-
AND event_date BETWEEN $2 AND $3
441-
AND id::text != ALL($4)
442-
ORDER BY event_date DESC
443-
""",
444-
bank_id,
445-
min_date,
446-
max_date,
447-
unit_ids,
448-
)
442+
if min_date is not None and max_date is not None:
443+
all_candidates = await conn.fetch(
444+
f"""
445+
SELECT id, event_date
446+
FROM {fq_table("memory_units")}
447+
WHERE bank_id = $1
448+
AND event_date BETWEEN $2 AND $3
449+
AND id::text != ALL($4)
450+
ORDER BY event_date DESC
451+
""",
452+
bank_id,
453+
min_date,
454+
max_date,
455+
unit_ids,
456+
)
457+
else:
458+
all_candidates = []
449459
_log(
450460
log_buffer,
451461
f" [7.2] Fetch {len(all_candidates)} candidate neighbors (1 query): {time_mod.time() - fetch_neighbors_start:.3f}s",
@@ -460,11 +470,15 @@ async def create_temporal_links_batch_per_fact(
460470
# Convert new_units dict to candidate format for within-batch linking
461471
new_unit_items = list(new_units.items())
462472
for i, (unit_id, event_date) in enumerate(new_unit_items):
473+
if event_date is None:
474+
continue # Skip units without event_date for temporal linking
463475
unit_event_date_norm = _normalize_datetime(event_date)
464476

465477
# Compare with other new units (only those after this one to avoid duplicates)
466478
for j in range(i + 1, len(new_unit_items)):
467479
other_id, other_event_date = new_unit_items[j]
480+
if other_event_date is None:
481+
continue # Skip units without event_date
468482
other_event_date_norm = _normalize_datetime(other_event_date)
469483

470484
# Check if within time window

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,14 @@ async def retain_batch(
128128
item_tags = item.get("tags", []) or []
129129
merged_tags = list(set(item_tags + (document_tags or [])))
130130

131-
# Handle event_date: parse flexibly (handles both datetime objects and ISO strings)
132-
event_date_value = item.get("event_date")
133-
if event_date_value:
134-
event_date_value = parse_datetime_flexible(event_date_value)
131+
# Handle event_date: distinguish "not provided" (default to now) from
132+
# "explicitly None" (caller opted into no timestamp).
133+
if "event_date" in item and item["event_date"] is None:
134+
event_date_value = None # Caller explicitly signalled "unknown date"
135+
elif item.get("event_date"):
136+
event_date_value = parse_datetime_flexible(item["event_date"])
135137
else:
136-
event_date_value = utcnow()
138+
event_date_value = utcnow() # Backward-compatible default
137139

138140
content = RetainContent(
139141
content=item["content"],

hindsight-api/hindsight_api/engine/retain/types.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"""
77

88
from dataclasses import dataclass, field
9-
from datetime import UTC, datetime
9+
from datetime import datetime
1010
from typing import Literal, TypedDict
1111
from uuid import UUID
1212

@@ -29,7 +29,7 @@ class RetainContentDict(TypedDict, total=False):
2929

3030
content: str # Required
3131
context: str
32-
event_date: datetime
32+
event_date: datetime | None
3333
metadata: dict[str, str]
3434
document_id: str
3535
entities: list[dict[str, str]] # [{"text": "...", "type": "..."}]
@@ -39,11 +39,6 @@ class RetainContentDict(TypedDict, total=False):
3939
) # Observation scopes for consolidation
4040

4141

42-
def _now_utc() -> datetime:
43-
"""Factory function for default event_date."""
44-
return datetime.now(UTC)
45-
46-
4742
@dataclass
4843
class RetainContent:
4944
"""
@@ -54,7 +49,7 @@ class RetainContent:
5449

5550
content: str
5651
context: str = ""
57-
event_date: datetime = field(default_factory=_now_utc)
52+
event_date: datetime | None = None
5853
metadata: dict[str, str] = field(default_factory=dict)
5954
entities: list[dict[str, str]] = field(default_factory=list) # User-provided entities
6055
tags: list[str] = field(default_factory=list) # Visibility scope tags
@@ -147,7 +142,7 @@ class ProcessedFact:
147142
# Temporal data
148143
occurred_start: datetime | None
149144
occurred_end: datetime | None
150-
mentioned_at: datetime
145+
mentioned_at: datetime | None
151146

152147
# Context and metadata
153148
context: str
@@ -200,12 +195,10 @@ def from_extracted_fact(
200195
Returns:
201196
ProcessedFact ready for storage
202197
"""
203-
from datetime import datetime
204-
205198
# Use occurred dates only if explicitly provided by LLM
206199
occurred_start = extracted_fact.occurred_start
207200
occurred_end = extracted_fact.occurred_end
208-
mentioned_at = extracted_fact.mentioned_at or datetime.now(UTC)
201+
mentioned_at = extracted_fact.mentioned_at # May be None when caller opted into no timestamp
209202

210203
# Convert entity strings to EntityRef objects
211204
entities = [EntityRef(name=name) for name in extracted_fact.entities]

0 commit comments

Comments
 (0)