Skip to content

Commit b8757cb

Browse files
GottZclaude
andcommitted
feat(temporal): created_at als Meta-Anker — multiple anchors per block
User-Prinzip (Session 20): "der blockinhalt kann ja auch zeiten referenzieren. ergo kann ein block mehrere temporale anker haben". Vorher: ein Block bekam temporale Dimensionen NUR aus Datums- Erwaehnungen im Content. Ohne Date-Match im Text: nur _none Sentinel. Bloecke mit abstraktem Inhalt waren temporal unauffindbar. Jetzt: jeder Block hat MINDESTENS einen temporalen Anker (created_at). Content-Anchors + Meta-Anchor koexistieren als Union. Ein Block mit "Meeting am Dienstag" erstellt am Freitag hat: - weekday=2 (content, semantic) - weekday=5 (created_at, meta) Beide Signale tragen unabhaengige Dimensionen bei. Go: - PopulateTemporal(..., createdAt time.Time) als neuer Pflicht-Parameter - allTimes = [createdAt] + contentTimes (SQL ON CONFLICT handelt Overlap) - isZero-Check als defensiver Fallback - BackfillTemporal: SELECT erweitert um cb.created_at - 3 Handler-Caller (context_store, context_manage, ingest) passen block.CreatedAt durch - Branch "len(times) > 0" entfernt — createdAt ist immer da Migration 021: - Backfill: 8 Dimensionen pro aktivem Block aus created_at via EXTRACT(YEAR/MONTH/WEEK/ISODOW/QUARTER/DAY/DOY/HOUR) - LATERAL + VALUES statt 8 separate INSERTs - Cleanup: _none Sentinels entfernt wo echte Dimensionen existieren - Archivierte Bloecke bleiben mit _none (nicht retrieved anyway) Verifikation: - 158 → 603 Dimensionen pro Dimension-Typ (+281% Coverage) - 28 _none verbleibend (nur archivierte Bloecke) - "abends"-Query: blocks_with_dims 46 → 98 (alle aktiven Bloecke mit daily-Dim via created_at.Hour()) - test.sh 16/16, eval.sh 43/43, keine Regression - Retrieval P95: 64ms Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b1b1ea7 commit b8757cb

5 files changed

Lines changed: 96 additions & 23 deletions

File tree

go/internal/handler/context_manage.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -272,10 +272,9 @@ func (h *ManageHandler) handleUpdate(w http.ResponseWriter, r *http.Request, ar
272272
if err := store.UpdateContentTimes(ctx, h.pool, block.ID, times); err != nil {
273273
slog.Error("manage: content_times update failed", "error", err, "block_id", block.ID, "request_id", reqID)
274274
}
275-
if len(times) > 0 {
276-
if err := store.PopulateTemporal(ctx, h.pool, block.ID, times); err != nil {
277-
slog.Error("manage: temporal populate failed", "error", err, "block_id", block.ID, "request_id", reqID)
278-
}
275+
// Always populate: createdAt is included as meta-anchor even without content times.
276+
if err := store.PopulateTemporal(ctx, h.pool, block.ID, times, block.CreatedAt); err != nil {
277+
slog.Error("manage: temporal populate failed", "error", err, "block_id", block.ID, "request_id", reqID)
279278
}
280279
}
281280

go/internal/handler/context_store.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,9 @@ func (h *StoreHandler) HandleStore(w http.ResponseWriter, r *http.Request) {
166166
if err := store.UpdateContentTimes(bgCtx, h.pool, block.ID, times); err != nil {
167167
slog.Error("store: content_times update failed", "error", err, "block_id", block.ID, "request_id", reqID)
168168
}
169-
if len(times) > 0 {
170-
if err := store.PopulateTemporal(bgCtx, h.pool, block.ID, times); err != nil {
171-
slog.Error("store: temporal populate failed", "error", err, "block_id", block.ID, "request_id", reqID)
172-
}
169+
// Always populate: createdAt is included as meta-anchor even without content times.
170+
if err := store.PopulateTemporal(bgCtx, h.pool, block.ID, times, block.CreatedAt); err != nil {
171+
slog.Error("store: temporal populate failed", "error", err, "block_id", block.ID, "request_id", reqID)
173172
}
174173
}()
175174

go/internal/handler/ingest.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -260,11 +260,10 @@ func (h *IngestHandler) processChunk(ctx context.Context, reqID string, chunk In
260260
slog.Error("ingest: content_times update failed",
261261
"error", err, "block_id", block.ID, "request_id", reqID)
262262
}
263-
if len(times) > 0 {
264-
if err := store.PopulateTemporal(bgCtx, h.pool, block.ID, times); err != nil {
265-
slog.Error("ingest: temporal populate failed",
266-
"error", err, "block_id", block.ID, "request_id", reqID)
267-
}
263+
// Always populate: createdAt is included as meta-anchor even without content times.
264+
if err := store.PopulateTemporal(bgCtx, h.pool, block.ID, times, block.CreatedAt); err != nil {
265+
slog.Error("ingest: temporal populate failed",
266+
"error", err, "block_id", block.ID, "request_id", reqID)
268267
}
269268
}()
270269

go/internal/store/temporal.go

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,26 @@ func BuildTemporalBatch(blockID string, times []time.Time, links ...[]string) (*
125125
}
126126

127127
// PopulateTemporal replaces all temporal dimensions for a block.
128-
// Deletes existing rows, then batch-inserts new dimensions for each timestamp.
129-
func PopulateTemporal(ctx context.Context, pool *pgxpool.Pool, blockID string, times []time.Time) error {
130-
batch, queryCount := BuildTemporalBatch(blockID, times)
128+
// Deletes existing rows, then batch-inserts new dimensions.
129+
//
130+
// A block can have multiple temporal anchors simultaneously:
131+
// - contentTimes: timestamps mentioned/extracted from block content (semantic)
132+
// - createdAt: the block's creation timestamp (meta anchor)
133+
//
134+
// Both sources contribute INDEPENDENT dimensions. A block created on Friday
135+
// whose content says "Meeting am Dienstag" gets weekday=2 (content) AND
136+
// weekday=5 (meta) — enables retrieval for both signals. SQL-level dedup
137+
// via ON CONFLICT handles timestamp overlap between sources.
138+
//
139+
// If createdAt is zero, only contentTimes is used (defensive fallback).
140+
func PopulateTemporal(ctx context.Context, pool *pgxpool.Pool, blockID string, contentTimes []time.Time, createdAt time.Time) error {
141+
allTimes := contentTimes
142+
if !createdAt.IsZero() {
143+
allTimes = make([]time.Time, 0, len(contentTimes)+1)
144+
allTimes = append(allTimes, createdAt)
145+
allTimes = append(allTimes, contentTimes...)
146+
}
147+
batch, queryCount := BuildTemporalBatch(blockID, allTimes)
131148

132149
br := pool.SendBatch(ctx, batch)
133150
defer func() { _ = br.Close() }()
@@ -240,7 +257,7 @@ func BackfillTemporal(ctx context.Context, pool *pgxpool.Pool) (int, error) {
240257

241258
for {
242259
rows, err := pool.Query(ctx,
243-
`SELECT cb.id, cb.content
260+
`SELECT cb.id, cb.content, cb.created_at
244261
FROM context_blocks cb
245262
WHERE NOT cb.is_archived
246263
AND NOT EXISTS (SELECT 1 FROM context_temporal ct WHERE ct.block_id = cb.id)
@@ -252,13 +269,14 @@ func BackfillTemporal(ctx context.Context, pool *pgxpool.Pool) (int, error) {
252269
}
253270

254271
type blockRow struct {
255-
ID string
256-
Content string
272+
ID string
273+
Content string
274+
CreatedAt time.Time
257275
}
258276
var blocks []blockRow
259277
for rows.Next() {
260278
var b blockRow
261-
if err := rows.Scan(&b.ID, &b.Content); err != nil {
279+
if err := rows.Scan(&b.ID, &b.Content, &b.CreatedAt); err != nil {
262280
rows.Close()
263281
return processed, fmt.Errorf("store: temporal: backfill scan: %w", err)
264282
}
@@ -282,9 +300,9 @@ func BackfillTemporal(ctx context.Context, pool *pgxpool.Pool) (int, error) {
282300
continue
283301
}
284302

285-
// Always populate temporal — for blocks without times this inserts
286-
// a sentinel (_none) so NOT EXISTS doesn't re-select them.
287-
if err := PopulateTemporal(ctx, pool, b.ID, times); err != nil {
303+
// Always populate temporal — createdAt is passed as meta-anchor.
304+
// Blocks without content times still get dimensions from their creation time.
305+
if err := PopulateTemporal(ctx, pool, b.ID, times, b.CreatedAt); err != nil {
288306
slog.Warn("backfill: populate temporal failed",
289307
"block_id", b.ID, "error", err)
290308
continue
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
-- =============================================================================
2+
-- 021_created_at_anchors.sql — Backfill created_at as meta-anchor dimensions
3+
-- Part of ctx by GottZ (https://github.com/GottZ/ctx)
4+
-- =============================================================================
5+
-- User decision (Session 20, 2026-04-05): "der blockinhalt kann ja auch
6+
-- zeiten referenzieren. ergo kann ein block mehrere temporale anker haben".
7+
--
8+
-- Every block has a natural temporal anchor: its created_at timestamp. This
9+
-- is independent from any dates mentioned in the content. A block about
10+
-- "Meeting am Dienstag" written on Friday has TWO legitimate anchors:
11+
-- - weekday=2 (content, semantic)
12+
-- - weekday=5 (created_at, meta)
13+
--
14+
-- Both signals should contribute dimensions. Before M021, only content-
15+
-- derived times were extracted — blocks without date mentions had no
16+
-- temporal dimensions at all (only '_none' sentinel).
17+
--
18+
-- This migration backfills all 8 dimensions for every non-archived block's
19+
-- created_at. ON CONFLICT DO NOTHING handles overlap with existing content-
20+
-- derived dimensions. The '_none' sentinel is NOT removed — future content
21+
-- updates via PopulateTemporal will clean it up via DELETE+INSERT pattern.
22+
-- =============================================================================
23+
24+
-- =============================================================================
25+
-- Backfill all 8 temporal dimensions from created_at for every active block
26+
-- =============================================================================
27+
WITH block_times AS (
28+
SELECT id, created_at
29+
FROM context_blocks
30+
WHERE NOT is_archived
31+
)
32+
INSERT INTO context_temporal (block_id, dimension, value, source_time)
33+
SELECT id, dimension, value, created_at
34+
FROM block_times,
35+
LATERAL (VALUES
36+
('year', EXTRACT(YEAR FROM created_at)::TEXT),
37+
('month', EXTRACT(MONTH FROM created_at)::TEXT),
38+
('week', EXTRACT(WEEK FROM created_at)::TEXT),
39+
('weekday', EXTRACT(ISODOW FROM created_at)::TEXT),
40+
('quarter', EXTRACT(QUARTER FROM created_at)::TEXT),
41+
('monthday', EXTRACT(DAY FROM created_at)::TEXT),
42+
('seasonal', EXTRACT(DOY FROM created_at)::TEXT),
43+
('daily', EXTRACT(HOUR FROM created_at)::TEXT)
44+
) AS dims(dimension, value)
45+
ON CONFLICT DO NOTHING;
46+
47+
-- =============================================================================
48+
-- Clean up '_none' sentinel rows for blocks that now have real dimensions
49+
-- =============================================================================
50+
-- After the backfill, every non-archived block has at least 8 dimensions
51+
-- from its created_at. The '_none' sentinel is obsolete for these blocks.
52+
DELETE FROM context_temporal ct
53+
WHERE ct.dimension = '_none'
54+
AND EXISTS (
55+
SELECT 1 FROM context_temporal ct2
56+
WHERE ct2.block_id = ct.block_id
57+
AND ct2.dimension != '_none'
58+
);

0 commit comments

Comments
 (0)