Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions hindsight-api-slim/hindsight_api/engine/retain/fact_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import json
import logging
import uuid
from datetime import datetime

from ...config import get_config
from ..memory_engine import fq_table
Expand Down Expand Up @@ -340,6 +341,7 @@ async def handle_document_tracking(
# source memory_units but leaves observation rows pointing at IDs that
# no longer exist (consolidated_at on co-source memories also stays
# frozen). Same cleanup the explicit ``delete_document`` API performs.
preserved_created_at = None
if is_first_batch:
existing_unit_rows = await conn.fetch(
f"""
Expand All @@ -366,14 +368,24 @@ async def handle_document_tracking(
document_id,
bank_id,
)
await conn.fetchval(
f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING id",
# Capture created_at before deletion so re-ingestion preserves it.
preserved_created_at = await conn.fetchval(
f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING created_at",
document_id,
bank_id,
)

# Insert document (or update if exists from concurrent operations)
await _upsert_document_row(conn, bank_id, document_id, combined_content, content_hash, retain_params, document_tags)
await _upsert_document_row(
conn,
bank_id,
document_id,
combined_content,
content_hash,
retain_params,
document_tags,
preserved_created_at=preserved_created_at,
)


async def upsert_document_metadata(
Expand Down Expand Up @@ -406,12 +418,19 @@ async def _upsert_document_row(
content_hash: str,
retain_params: dict | None = None,
document_tags: list[str] | None = None,
preserved_created_at: datetime | None = None,
) -> None:
"""Insert or update a document row."""
"""Insert or update a document row.

When ``preserved_created_at`` is provided, it is used for ``created_at`` on
INSERT so that re-ingesting a document (which deletes + inserts the row)
keeps the original creation timestamp. ``updated_at`` is always set to
``NOW()`` on both INSERT and the ON CONFLICT UPDATE branch.
"""
await conn.execute(
f"""
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, retain_params, tags)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, retain_params, tags, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, NOW()), NOW())
ON CONFLICT (id, bank_id) DO UPDATE
SET original_text = EXCLUDED.original_text,
content_hash = EXCLUDED.content_hash,
Expand All @@ -425,6 +444,7 @@ async def _upsert_document_row(
content_hash,
json.dumps(retain_params) if retain_params else None,
document_tags or [],
preserved_created_at,
)


Expand Down
52 changes: 52 additions & 0 deletions hindsight-api-slim/tests/test_retain.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Test retain function and chunk storage.
"""
import asyncio
import logging
from datetime import datetime, timedelta, timezone

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


@pytest.mark.asyncio
async def test_document_upsert_preserves_created_at(memory, request_context):
"""Re-ingesting a document keeps the original created_at; updated_at advances."""
bank_id = f"test_upsert_ts_{datetime.now(timezone.utc).timestamp()}"
document_id = "timestamp_doc"

try:
await memory.retain_async(
bank_id=bank_id,
content="Initial content about the project.",
document_id=document_id,
request_context=request_context,
)

async with memory._pool.acquire() as conn:
v1_row = await conn.fetchrow(
"SELECT created_at, updated_at FROM documents WHERE id = $1 AND bank_id = $2",
document_id,
bank_id,
)
assert v1_row is not None
v1_created = v1_row["created_at"]
v1_updated = v1_row["updated_at"]

# Small delay so updated_at can advance visibly
await asyncio.sleep(1.1)

await memory.retain_async(
bank_id=bank_id,
content="Updated content about the project, with more detail.",
document_id=document_id,
request_context=request_context,
)

async with memory._pool.acquire() as conn:
v2_row = await conn.fetchrow(
"SELECT created_at, updated_at FROM documents WHERE id = $1 AND bank_id = $2",
document_id,
bank_id,
)
assert v2_row is not None
assert v2_row["created_at"] == v1_created, (
f"created_at should be preserved across upsert (was {v1_created}, now {v2_row['created_at']})"
)
assert v2_row["updated_at"] > v1_updated, (
f"updated_at should advance on upsert (was {v1_updated}, now {v2_row['updated_at']})"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)


# ============================================================
# Chunk Storage Advanced Tests
# ============================================================
Expand Down
6 changes: 3 additions & 3 deletions hindsight-control-plane/src/components/bank-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,10 @@ function BankSelectorInner() {
return result;
};

const emptyFileMeta = () => ({
const emptyFileMeta = (documentId = "") => ({
context: "",
timestamp: "",
document_id: "",
document_id: documentId,
tags: "",
metadata: "",
strategy: "",
Expand All @@ -251,7 +251,7 @@ function BankSelectorInner() {
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
setSelectedFiles((prev) => [...prev, ...files]);
setFilesMetadata((prev) => [...prev, ...files.map(emptyFileMeta)]);
setFilesMetadata((prev) => [...prev, ...files.map((f) => emptyFileMeta(f.name))]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
Expand Down
147 changes: 137 additions & 10 deletions hindsight-control-plane/src/components/documents-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ export function DocumentsView() {
const [tagInput, setTagInput] = useState("");
const [savingTags, setSavingTags] = useState(false);

// Content editing state
const [editingContent, setEditingContent] = useState(false);
const [contentInput, setContentInput] = useState("");
const [savingContent, setSavingContent] = useState(false);

// Delete confirmation dialog state
const [documentToDelete, setDocumentToDelete] = useState<{
id: string;
Expand Down Expand Up @@ -143,6 +148,8 @@ export function DocumentsView() {
setSelectedDocument({ id: documentId }); // Set placeholder to show loading
setEditingTags(false);
setTagInput("");
setEditingContent(false);
setContentInput("");

try {
const doc: any = await client.getDocument(documentId, currentBank);
Expand Down Expand Up @@ -201,6 +208,56 @@ export function DocumentsView() {
setTagInput("");
};

const startEditContent = () => {
setContentInput(selectedDocument?.original_text ?? "");
setEditingContent(true);
};

const cancelEditContent = () => {
setEditingContent(false);
setContentInput("");
};

const saveDocumentContent = async () => {
if (!currentBank || !selectedDocument) return;

const newContent = contentInput;
if (!newContent.trim()) return;

const retainParams = selectedDocument.retain_params ?? {};
const item: Parameters<typeof client.retain>[0]["items"][number] = {
content: newContent,
document_id: selectedDocument.id,
};
if (retainParams.context) item.context = retainParams.context;
if (retainParams.event_date) item.timestamp = retainParams.event_date;
if (retainParams.metadata && Object.keys(retainParams.metadata).length > 0) {
item.metadata = retainParams.metadata;
}
if (selectedDocument.tags && selectedDocument.tags.length > 0) {
item.tags = selectedDocument.tags;
}

setSavingContent(true);
try {
await client.retain({
bank_id: currentBank,
items: [item],
async: false,
});
// Refresh the document and the list
const doc: any = await client.getDocument(selectedDocument.id, currentBank);
setSelectedDocument(doc);
setEditingContent(false);
setContentInput("");
loadDocuments(currentPage);
} catch (error) {
console.error("Error updating document content:", error);
} finally {
setSavingContent(false);
}
};

const saveDocumentTags = async () => {
if (!currentBank || !selectedDocument) return;

Expand Down Expand Up @@ -279,6 +336,7 @@ export function DocumentsView() {
<TableRow>
<TableHead>Document ID</TableHead>
<TableHead>Created</TableHead>
<TableHead>Updated</TableHead>
<TableHead>Tags</TableHead>
<TableHead>Metadata</TableHead>
<TableHead>Size</TableHead>
Expand All @@ -302,6 +360,12 @@ export function DocumentsView() {
>
{doc.created_at ? formatRelativeTime(doc.created_at) : "N/A"}
</TableCell>
<TableCell
className="text-card-foreground"
title={doc.updated_at ? new Date(doc.updated_at).toLocaleString() : ""}
>
{doc.updated_at ? formatRelativeTime(doc.updated_at) : "N/A"}
</TableCell>
<TableCell className="text-card-foreground">
{doc.tags && doc.tags.length > 0 ? (
<div className="flex flex-wrap gap-1">
Expand Down Expand Up @@ -341,7 +405,7 @@ export function DocumentsView() {
))
) : (
<TableRow>
<TableCell colSpan={6} className="text-center">
<TableCell colSpan={7} className="text-center">
Click "Load Documents" to view data
</TableCell>
</TableRow>
Expand Down Expand Up @@ -452,7 +516,7 @@ export function DocumentsView() {
</div>
</div>

{/* Created & Memory Units */}
{/* Created, Updated & Memory Units */}
{selectedDocument.created_at && (
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-muted/50 rounded-lg">
Expand All @@ -463,6 +527,16 @@ export function DocumentsView() {
{new Date(selectedDocument.created_at).toLocaleString()}
</div>
</div>
{selectedDocument.updated_at && (
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Updated
</div>
<div className="text-sm font-medium text-card-foreground">
{new Date(selectedDocument.updated_at).toLocaleString()}
</div>
</div>
)}
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Memory Units
Expand Down Expand Up @@ -613,16 +687,69 @@ export function DocumentsView() {
</div>

{/* Original Text */}
{selectedDocument.original_text && (
{selectedDocument.original_text !== undefined && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Original Text
</div>
<div className="p-4 bg-muted/50 rounded-lg border border-border max-h-[400px] overflow-y-auto">
<pre className="text-sm whitespace-pre-wrap font-mono leading-relaxed text-card-foreground">
{selectedDocument.original_text}
</pre>
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-bold text-muted-foreground uppercase">
Original Text
</div>
{!editingContent && (
<Button
variant="ghost"
size="sm"
onClick={startEditContent}
className="h-6 px-2 gap-1 text-xs"
>
<Pencil className="h-3 w-3" />
Edit
</Button>
)}
</div>
{editingContent ? (
<div className="space-y-2">
<textarea
value={contentInput}
onChange={(e) => setContentInput(e.target.value)}
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"
autoFocus
/>
<p className="text-xs text-muted-foreground">
Saving will re-ingest this document via retain (upsert). Existing memory
units for this document will be replaced.
</p>
<div className="flex gap-2">
<Button
size="sm"
onClick={saveDocumentContent}
disabled={savingContent || !contentInput.trim()}
className="h-7 px-3 gap-1 text-xs"
>
{savingContent ? (
<span className="animate-spin">⏳</span>
) : (
<Check className="h-3 w-3" />
)}
Save
</Button>
<Button
variant="outline"
size="sm"
onClick={cancelEditContent}
disabled={savingContent}
className="h-7 px-3 gap-1 text-xs"
>
<X className="h-3 w-3" />
Cancel
</Button>
</div>
</div>
) : (
<div className="p-4 bg-muted/50 rounded-lg border border-border max-h-[400px] overflow-y-auto">
<pre className="text-sm whitespace-pre-wrap font-mono leading-relaxed text-card-foreground">
{selectedDocument.original_text}
</pre>
</div>
)}
</div>
)}
</div>
Expand Down
Loading