Skip to content

Commit 1078566

Browse files
authored
fix(retain): preserve document created_at across upsert; UI edit flow (#1194)
Re-ingesting a document via retain with the same document_id deletes and reinserts the documents row, which reset created_at to NOW(). The ON CONFLICT DO UPDATE branch preserved it, but was never reached because the explicit DELETE removed the row first. - Capture created_at via RETURNING on the DELETE and pass it through to _upsert_document_row, which now uses COALESCE($7, NOW()) on INSERT. - updated_at continues to advance on every insert/update. Control plane: - File upload defaults document_id to the file name so uploads keep a meaningful identifier instead of a server-generated UUID. - Documents table shows an "Updated" column alongside "Created". - Document detail panel supports editing original_text; Save calls retain with the same document_id and preserves the original context, event date, metadata, and tags, triggering the upsert path. Regression test added for created_at preservation.
1 parent 59f9a2b commit 1078566

4 files changed

Lines changed: 218 additions & 19 deletions

File tree

hindsight-api-slim/hindsight_api/engine/retain/fact_storage.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import json
88
import logging
99
import uuid
10+
from datetime import datetime
1011

1112
from ...config import get_config
1213
from ..memory_engine import fq_table
@@ -340,6 +341,7 @@ async def handle_document_tracking(
340341
# source memory_units but leaves observation rows pointing at IDs that
341342
# no longer exist (consolidated_at on co-source memories also stays
342343
# frozen). Same cleanup the explicit ``delete_document`` API performs.
344+
preserved_created_at = None
343345
if is_first_batch:
344346
existing_unit_rows = await conn.fetch(
345347
f"""
@@ -366,14 +368,24 @@ async def handle_document_tracking(
366368
document_id,
367369
bank_id,
368370
)
369-
await conn.fetchval(
370-
f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING id",
371+
# Capture created_at before deletion so re-ingestion preserves it.
372+
preserved_created_at = await conn.fetchval(
373+
f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING created_at",
371374
document_id,
372375
bank_id,
373376
)
374377

375378
# Insert document (or update if exists from concurrent operations)
376-
await _upsert_document_row(conn, bank_id, document_id, combined_content, content_hash, retain_params, document_tags)
379+
await _upsert_document_row(
380+
conn,
381+
bank_id,
382+
document_id,
383+
combined_content,
384+
content_hash,
385+
retain_params,
386+
document_tags,
387+
preserved_created_at=preserved_created_at,
388+
)
377389

378390

379391
async def upsert_document_metadata(
@@ -406,12 +418,19 @@ async def _upsert_document_row(
406418
content_hash: str,
407419
retain_params: dict | None = None,
408420
document_tags: list[str] | None = None,
421+
preserved_created_at: datetime | None = None,
409422
) -> None:
410-
"""Insert or update a document row."""
423+
"""Insert or update a document row.
424+
425+
When ``preserved_created_at`` is provided, it is used for ``created_at`` on
426+
INSERT so that re-ingesting a document (which deletes + inserts the row)
427+
keeps the original creation timestamp. ``updated_at`` is always set to
428+
``NOW()`` on both INSERT and the ON CONFLICT UPDATE branch.
429+
"""
411430
await conn.execute(
412431
f"""
413-
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, retain_params, tags)
414-
VALUES ($1, $2, $3, $4, $5, $6)
432+
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, retain_params, tags, created_at, updated_at)
433+
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, NOW()), NOW())
415434
ON CONFLICT (id, bank_id) DO UPDATE
416435
SET original_text = EXCLUDED.original_text,
417436
content_hash = EXCLUDED.content_hash,
@@ -425,6 +444,7 @@ async def _upsert_document_row(
425444
content_hash,
426445
json.dumps(retain_params) if retain_params else None,
427446
document_tags or [],
447+
preserved_created_at,
428448
)
429449

430450

hindsight-api-slim/tests/test_retain.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Test retain function and chunk storage.
33
"""
4+
import asyncio
45
import logging
56
from datetime import datetime, timedelta, timezone
67

@@ -1121,6 +1122,57 @@ async def test_document_upsert_behavior(memory, request_context):
11211122
await memory.delete_bank(bank_id, request_context=request_context)
11221123

11231124

1125+
@pytest.mark.asyncio
1126+
async def test_document_upsert_preserves_created_at(memory, request_context):
1127+
"""Re-ingesting a document keeps the original created_at; updated_at advances."""
1128+
bank_id = f"test_upsert_ts_{datetime.now(timezone.utc).timestamp()}"
1129+
document_id = "timestamp_doc"
1130+
1131+
try:
1132+
await memory.retain_async(
1133+
bank_id=bank_id,
1134+
content="Initial content about the project.",
1135+
document_id=document_id,
1136+
request_context=request_context,
1137+
)
1138+
1139+
async with memory._pool.acquire() as conn:
1140+
v1_row = await conn.fetchrow(
1141+
"SELECT created_at, updated_at FROM documents WHERE id = $1 AND bank_id = $2",
1142+
document_id,
1143+
bank_id,
1144+
)
1145+
assert v1_row is not None
1146+
v1_created = v1_row["created_at"]
1147+
v1_updated = v1_row["updated_at"]
1148+
1149+
# Small delay so updated_at can advance visibly
1150+
await asyncio.sleep(1.1)
1151+
1152+
await memory.retain_async(
1153+
bank_id=bank_id,
1154+
content="Updated content about the project, with more detail.",
1155+
document_id=document_id,
1156+
request_context=request_context,
1157+
)
1158+
1159+
async with memory._pool.acquire() as conn:
1160+
v2_row = await conn.fetchrow(
1161+
"SELECT created_at, updated_at FROM documents WHERE id = $1 AND bank_id = $2",
1162+
document_id,
1163+
bank_id,
1164+
)
1165+
assert v2_row is not None
1166+
assert v2_row["created_at"] == v1_created, (
1167+
f"created_at should be preserved across upsert (was {v1_created}, now {v2_row['created_at']})"
1168+
)
1169+
assert v2_row["updated_at"] > v1_updated, (
1170+
f"updated_at should advance on upsert (was {v1_updated}, now {v2_row['updated_at']})"
1171+
)
1172+
finally:
1173+
await memory.delete_bank(bank_id, request_context=request_context)
1174+
1175+
11241176
# ============================================================
11251177
# Chunk Storage Advanced Tests
11261178
# ============================================================

hindsight-control-plane/src/components/bank-selector.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,10 @@ function BankSelectorInner() {
237237
return result;
238238
};
239239

240-
const emptyFileMeta = () => ({
240+
const emptyFileMeta = (documentId = "") => ({
241241
context: "",
242242
timestamp: "",
243-
document_id: "",
243+
document_id: documentId,
244244
tags: "",
245245
metadata: "",
246246
strategy: "",
@@ -251,7 +251,7 @@ function BankSelectorInner() {
251251
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
252252
const files = Array.from(e.target.files || []);
253253
setSelectedFiles((prev) => [...prev, ...files]);
254-
setFilesMetadata((prev) => [...prev, ...files.map(emptyFileMeta)]);
254+
setFilesMetadata((prev) => [...prev, ...files.map((f) => emptyFileMeta(f.name))]);
255255
if (fileInputRef.current) {
256256
fileInputRef.current.value = "";
257257
}

hindsight-control-plane/src/components/documents-view.tsx

Lines changed: 137 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ export function DocumentsView() {
100100
const [tagInput, setTagInput] = useState("");
101101
const [savingTags, setSavingTags] = useState(false);
102102

103+
// Content editing state
104+
const [editingContent, setEditingContent] = useState(false);
105+
const [contentInput, setContentInput] = useState("");
106+
const [savingContent, setSavingContent] = useState(false);
107+
103108
// Delete confirmation dialog state
104109
const [documentToDelete, setDocumentToDelete] = useState<{
105110
id: string;
@@ -143,6 +148,8 @@ export function DocumentsView() {
143148
setSelectedDocument({ id: documentId }); // Set placeholder to show loading
144149
setEditingTags(false);
145150
setTagInput("");
151+
setEditingContent(false);
152+
setContentInput("");
146153

147154
try {
148155
const doc: any = await client.getDocument(documentId, currentBank);
@@ -201,6 +208,56 @@ export function DocumentsView() {
201208
setTagInput("");
202209
};
203210

211+
const startEditContent = () => {
212+
setContentInput(selectedDocument?.original_text ?? "");
213+
setEditingContent(true);
214+
};
215+
216+
const cancelEditContent = () => {
217+
setEditingContent(false);
218+
setContentInput("");
219+
};
220+
221+
const saveDocumentContent = async () => {
222+
if (!currentBank || !selectedDocument) return;
223+
224+
const newContent = contentInput;
225+
if (!newContent.trim()) return;
226+
227+
const retainParams = selectedDocument.retain_params ?? {};
228+
const item: Parameters<typeof client.retain>[0]["items"][number] = {
229+
content: newContent,
230+
document_id: selectedDocument.id,
231+
};
232+
if (retainParams.context) item.context = retainParams.context;
233+
if (retainParams.event_date) item.timestamp = retainParams.event_date;
234+
if (retainParams.metadata && Object.keys(retainParams.metadata).length > 0) {
235+
item.metadata = retainParams.metadata;
236+
}
237+
if (selectedDocument.tags && selectedDocument.tags.length > 0) {
238+
item.tags = selectedDocument.tags;
239+
}
240+
241+
setSavingContent(true);
242+
try {
243+
await client.retain({
244+
bank_id: currentBank,
245+
items: [item],
246+
async: false,
247+
});
248+
// Refresh the document and the list
249+
const doc: any = await client.getDocument(selectedDocument.id, currentBank);
250+
setSelectedDocument(doc);
251+
setEditingContent(false);
252+
setContentInput("");
253+
loadDocuments(currentPage);
254+
} catch (error) {
255+
console.error("Error updating document content:", error);
256+
} finally {
257+
setSavingContent(false);
258+
}
259+
};
260+
204261
const saveDocumentTags = async () => {
205262
if (!currentBank || !selectedDocument) return;
206263

@@ -279,6 +336,7 @@ export function DocumentsView() {
279336
<TableRow>
280337
<TableHead>Document ID</TableHead>
281338
<TableHead>Created</TableHead>
339+
<TableHead>Updated</TableHead>
282340
<TableHead>Tags</TableHead>
283341
<TableHead>Metadata</TableHead>
284342
<TableHead>Size</TableHead>
@@ -302,6 +360,12 @@ export function DocumentsView() {
302360
>
303361
{doc.created_at ? formatRelativeTime(doc.created_at) : "N/A"}
304362
</TableCell>
363+
<TableCell
364+
className="text-card-foreground"
365+
title={doc.updated_at ? new Date(doc.updated_at).toLocaleString() : ""}
366+
>
367+
{doc.updated_at ? formatRelativeTime(doc.updated_at) : "N/A"}
368+
</TableCell>
305369
<TableCell className="text-card-foreground">
306370
{doc.tags && doc.tags.length > 0 ? (
307371
<div className="flex flex-wrap gap-1">
@@ -341,7 +405,7 @@ export function DocumentsView() {
341405
))
342406
) : (
343407
<TableRow>
344-
<TableCell colSpan={6} className="text-center">
408+
<TableCell colSpan={7} className="text-center">
345409
Click "Load Documents" to view data
346410
</TableCell>
347411
</TableRow>
@@ -452,7 +516,7 @@ export function DocumentsView() {
452516
</div>
453517
</div>
454518

455-
{/* Created & Memory Units */}
519+
{/* Created, Updated & Memory Units */}
456520
{selectedDocument.created_at && (
457521
<div className="grid grid-cols-2 gap-4">
458522
<div className="p-4 bg-muted/50 rounded-lg">
@@ -463,6 +527,16 @@ export function DocumentsView() {
463527
{new Date(selectedDocument.created_at).toLocaleString()}
464528
</div>
465529
</div>
530+
{selectedDocument.updated_at && (
531+
<div className="p-4 bg-muted/50 rounded-lg">
532+
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
533+
Updated
534+
</div>
535+
<div className="text-sm font-medium text-card-foreground">
536+
{new Date(selectedDocument.updated_at).toLocaleString()}
537+
</div>
538+
</div>
539+
)}
466540
<div className="p-4 bg-muted/50 rounded-lg">
467541
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
468542
Memory Units
@@ -613,16 +687,69 @@ export function DocumentsView() {
613687
</div>
614688

615689
{/* Original Text */}
616-
{selectedDocument.original_text && (
690+
{selectedDocument.original_text !== undefined && (
617691
<div>
618-
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
619-
Original Text
620-
</div>
621-
<div className="p-4 bg-muted/50 rounded-lg border border-border max-h-[400px] overflow-y-auto">
622-
<pre className="text-sm whitespace-pre-wrap font-mono leading-relaxed text-card-foreground">
623-
{selectedDocument.original_text}
624-
</pre>
692+
<div className="flex items-center justify-between mb-2">
693+
<div className="text-xs font-bold text-muted-foreground uppercase">
694+
Original Text
695+
</div>
696+
{!editingContent && (
697+
<Button
698+
variant="ghost"
699+
size="sm"
700+
onClick={startEditContent}
701+
className="h-6 px-2 gap-1 text-xs"
702+
>
703+
<Pencil className="h-3 w-3" />
704+
Edit
705+
</Button>
706+
)}
625707
</div>
708+
{editingContent ? (
709+
<div className="space-y-2">
710+
<textarea
711+
value={contentInput}
712+
onChange={(e) => setContentInput(e.target.value)}
713+
className="w-full min-h-[300px] max-h-[500px] p-4 bg-muted/50 rounded-lg border border-border text-sm font-mono leading-relaxed text-card-foreground resize-y"
714+
autoFocus
715+
/>
716+
<p className="text-xs text-muted-foreground">
717+
Saving will re-ingest this document via retain (upsert). Existing memory
718+
units for this document will be replaced.
719+
</p>
720+
<div className="flex gap-2">
721+
<Button
722+
size="sm"
723+
onClick={saveDocumentContent}
724+
disabled={savingContent || !contentInput.trim()}
725+
className="h-7 px-3 gap-1 text-xs"
726+
>
727+
{savingContent ? (
728+
<span className="animate-spin"></span>
729+
) : (
730+
<Check className="h-3 w-3" />
731+
)}
732+
Save
733+
</Button>
734+
<Button
735+
variant="outline"
736+
size="sm"
737+
onClick={cancelEditContent}
738+
disabled={savingContent}
739+
className="h-7 px-3 gap-1 text-xs"
740+
>
741+
<X className="h-3 w-3" />
742+
Cancel
743+
</Button>
744+
</div>
745+
</div>
746+
) : (
747+
<div className="p-4 bg-muted/50 rounded-lg border border-border max-h-[400px] overflow-y-auto">
748+
<pre className="text-sm whitespace-pre-wrap font-mono leading-relaxed text-card-foreground">
749+
{selectedDocument.original_text}
750+
</pre>
751+
</div>
752+
)}
626753
</div>
627754
)}
628755
</div>

0 commit comments

Comments
 (0)