feat: expose document_metadata in API and control plane (#798)

* feat: expose document_metadata in API and control plane

Add document_metadata (sourced from retain_params.metadata) to both
list and get document endpoints. Display it in the control plane
documents table and detail panel. Drop the unused metadata column
from the documents table (was always stored as empty {}).

* fix: code review fixes for document_metadata feature

- Remove unnecessary `import json as _json` (json already imported at module level)
- Simplify redundant truthiness checks in retain_params parsing
- Regenerate OpenAPI spec and client SDKs (Python, TypeScript, Go)
- Add tests for document_metadata in get_document and list_documents

* feat(ui): improve documents table and detail panel

- Relative timestamps with full date on hover
- Remove context column from table
- Metadata shown as k=v badges (blue, like tags)
- Size in bytes instead of chars
- Document IDs wrap instead of truncating
- Detail panel wider (560px)
- Retain params: context, event_date, metadata badges
This commit is contained in:
Nicolò Boschi 2026-03-31 11:42:02 +02:00 committed by GitHub
parent bdb33c58d1
commit 627ec5d524
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 361 additions and 39 deletions

View file

@ -0,0 +1,34 @@
"""Drop unused metadata column from documents table
Revision ID: d6e7f8a9b0c1
Revises: c2d3e4f5g6h7, c5d6e7f8a9b0
Create Date: 2026-03-30
The metadata column on documents was always stored as an empty dict {}.
Actual document metadata is stored inside retain_params.metadata.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "d6e7f8a9b0c1"
down_revision: str | Sequence[str] | None = ("c2d3e4f5g6h7", "c5d6e7f8a9b0")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}documents DROP COLUMN IF EXISTS metadata")
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}documents ADD COLUMN IF NOT EXISTS metadata jsonb DEFAULT '{{}}'")

View file

@ -1277,6 +1277,8 @@ class DocumentResponse(BaseModel):
"updated_at": "2024-01-15T10:30:00Z",
"memory_unit_count": 15,
"tags": ["user_a", "session_123"],
"document_metadata": {"source": "slack", "channel": "#general"},
"retain_params": {"context": "Team meeting notes", "event_date": "2024-01-15"},
}
}
)
@ -1289,6 +1291,8 @@ class DocumentResponse(BaseModel):
updated_at: str
memory_unit_count: int
tags: list[str] = FieldWithDefault(list, description="Tags associated with this document")
document_metadata: dict[str, Any] | None = Field(default=None, description="Document metadata")
retain_params: dict[str, Any] | None = Field(default=None, description="Parameters used during retain")
class UpdateDocumentRequest(BaseModel):

View file

@ -3509,11 +3509,13 @@ class MemoryEngine(MemoryEngineInterface):
doc = await conn.fetchrow(
f"""
SELECT d.id, d.bank_id, d.original_text, d.content_hash,
d.created_at, d.updated_at, d.tags, COUNT(mu.id) as unit_count
d.created_at, d.updated_at, d.tags, d.retain_params,
COUNT(mu.id) as unit_count
FROM {fq_table("documents")} d
LEFT JOIN {fq_table("memory_units")} mu ON mu.document_id = d.id
WHERE d.id = $1 AND d.bank_id = $2
GROUP BY d.id, d.bank_id, d.original_text, d.content_hash, d.created_at, d.updated_at, d.tags
GROUP BY d.id, d.bank_id, d.original_text, d.content_hash,
d.created_at, d.updated_at, d.tags, d.retain_params
""",
document_id,
bank_id,
@ -3522,6 +3524,14 @@ class MemoryEngine(MemoryEngineInterface):
if not doc:
return None
retain_params_raw = doc["retain_params"]
retain_params_parsed = (
json.loads(retain_params_raw) if isinstance(retain_params_raw, str) else retain_params_raw
)
# document_metadata is sourced from retain_params.metadata
document_metadata = retain_params_parsed.get("metadata") if retain_params_parsed else None
return {
"id": doc["id"],
"bank_id": doc["bank_id"],
@ -3531,6 +3541,8 @@ class MemoryEngine(MemoryEngineInterface):
"created_at": doc["created_at"].isoformat() if doc["created_at"] else None,
"updated_at": doc["updated_at"].isoformat() if doc["updated_at"] else None,
"tags": list(doc["tags"]) if doc["tags"] else [],
"document_metadata": document_metadata or None,
"retain_params": retain_params_parsed or None,
}
async def delete_document(
@ -4950,6 +4962,14 @@ class MemoryEngine(MemoryEngineInterface):
bank_id_val = row["bank_id"]
unit_count = count_map.get((doc_id, bank_id_val), 0)
retain_params_val = row["retain_params"]
retain_params_val = (
json.loads(retain_params_val) if isinstance(retain_params_val, str) else retain_params_val
)
# document_metadata is sourced from retain_params.metadata
document_metadata = retain_params_val.get("metadata") if retain_params_val else None
items.append(
{
"id": doc_id,
@ -4959,7 +4979,8 @@ class MemoryEngine(MemoryEngineInterface):
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else "",
"text_length": row["text_length"] or 0,
"memory_unit_count": unit_count,
"retain_params": row["retain_params"] if row["retain_params"] else None,
"retain_params": retain_params_val or None,
"document_metadata": document_metadata or None,
"tags": row["tags"] if row["tags"] else [],
}
)

View file

@ -288,12 +288,11 @@ async def _upsert_document_row(
"""Insert or update a document row."""
await conn.execute(
f"""
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, metadata, retain_params, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, retain_params, tags)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id, bank_id) DO UPDATE
SET original_text = EXCLUDED.original_text,
content_hash = EXCLUDED.content_hash,
metadata = EXCLUDED.metadata,
retain_params = EXCLUDED.retain_params,
tags = EXCLUDED.tags,
updated_at = NOW()
@ -302,7 +301,6 @@ async def _upsert_document_row(
bank_id,
combined_content,
content_hash,
json.dumps({}), # Empty metadata dict
json.dumps(retain_params) if retain_params else None,
document_tags or [],
)

View file

@ -141,6 +141,70 @@ async def test_memory_without_document(memory, request_context):
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_document_metadata_from_retain_params(memory, request_context):
"""Test that document_metadata is returned from retain_params.metadata in both get and list."""
bank_id = f"test_doc_meta_{datetime.now(timezone.utc).timestamp()}"
try:
document_id = "doc-with-metadata"
metadata = {"source": "slack", "channel": "#general"}
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Alice works at Google.", "context": "Team meeting", "metadata": metadata}],
document_id=document_id,
request_context=request_context,
)
# get_document should include document_metadata
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None
assert doc["document_metadata"] == metadata
assert doc["retain_params"] is not None
assert doc["retain_params"]["metadata"] == metadata
# list_documents should also include document_metadata
docs_list = await memory.list_documents(
bank_id=bank_id, search_query=None, limit=100, offset=0, request_context=request_context
)
listed_doc = next(d for d in docs_list["items"] if d["id"] == document_id)
assert listed_doc["document_metadata"] == metadata
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_document_without_metadata(memory, request_context):
"""Test that document_metadata is None when no metadata was provided during retain."""
bank_id = f"test_doc_no_meta_{datetime.now(timezone.utc).timestamp()}"
try:
document_id = "doc-no-metadata"
await memory.retain_async(
bank_id=bank_id,
content="Bob works at Microsoft.",
context="Meeting",
document_id=document_id,
request_context=request_context,
)
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
assert doc is not None
assert doc["document_metadata"] is None
docs_list = await memory.list_documents(
bank_id=bank_id, search_query=None, limit=100, offset=0, request_context=request_context
)
listed_doc = next(d for d in docs_list["items"] if d["id"] == document_id)
assert listed_doc["document_metadata"] is None
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_document_persisted_with_zero_facts(memory, request_context):
"""

View file

@ -3910,9 +3910,15 @@ components:
bank_id: user123
content_hash: abc123
created_at: 2024-01-15T10:30:00Z
document_metadata:
channel: '#general'
source: slack
id: session_1
memory_unit_count: 15
original_text: Full document text here...
retain_params:
context: Team meeting notes
event_date: 2024-01-15
tags:
- user_a
- session_123
@ -3945,6 +3951,12 @@ components:
items:
type: string
type: array
document_metadata:
additionalProperties: {}
nullable: true
retain_params:
additionalProperties: {}
nullable: true
required:
- bank_id
- content_hash

View file

@ -30,6 +30,8 @@ type DocumentResponse struct {
MemoryUnitCount int32 `json:"memory_unit_count"`
// Tags associated with this document
Tags []string `json:"tags,omitempty"`
DocumentMetadata map[string]interface{} `json:"document_metadata,omitempty"`
RetainParams map[string]interface{} `json:"retain_params,omitempty"`
}
type _DocumentResponse DocumentResponse
@ -260,6 +262,72 @@ func (o *DocumentResponse) SetTags(v []string) {
o.Tags = v
}
// GetDocumentMetadata returns the DocumentMetadata field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *DocumentResponse) GetDocumentMetadata() map[string]interface{} {
if o == nil {
var ret map[string]interface{}
return ret
}
return o.DocumentMetadata
}
// GetDocumentMetadataOk returns a tuple with the DocumentMetadata field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *DocumentResponse) GetDocumentMetadataOk() (map[string]interface{}, bool) {
if o == nil || IsNil(o.DocumentMetadata) {
return map[string]interface{}{}, false
}
return o.DocumentMetadata, true
}
// HasDocumentMetadata returns a boolean if a field has been set.
func (o *DocumentResponse) HasDocumentMetadata() bool {
if o != nil && !IsNil(o.DocumentMetadata) {
return true
}
return false
}
// SetDocumentMetadata gets a reference to the given map[string]interface{} and assigns it to the DocumentMetadata field.
func (o *DocumentResponse) SetDocumentMetadata(v map[string]interface{}) {
o.DocumentMetadata = v
}
// GetRetainParams returns the RetainParams field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *DocumentResponse) GetRetainParams() map[string]interface{} {
if o == nil {
var ret map[string]interface{}
return ret
}
return o.RetainParams
}
// GetRetainParamsOk returns a tuple with the RetainParams field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *DocumentResponse) GetRetainParamsOk() (map[string]interface{}, bool) {
if o == nil || IsNil(o.RetainParams) {
return map[string]interface{}{}, false
}
return o.RetainParams, true
}
// HasRetainParams returns a boolean if a field has been set.
func (o *DocumentResponse) HasRetainParams() bool {
if o != nil && !IsNil(o.RetainParams) {
return true
}
return false
}
// SetRetainParams gets a reference to the given map[string]interface{} and assigns it to the RetainParams field.
func (o *DocumentResponse) SetRetainParams(v map[string]interface{}) {
o.RetainParams = v
}
func (o DocumentResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
@ -280,6 +348,12 @@ func (o DocumentResponse) ToMap() (map[string]interface{}, error) {
if !IsNil(o.Tags) {
toSerialize["tags"] = o.Tags
}
if o.DocumentMetadata != nil {
toSerialize["document_metadata"] = o.DocumentMetadata
}
if o.RetainParams != nil {
toSerialize["retain_params"] = o.RetainParams
}
return toSerialize, nil
}

View file

@ -34,7 +34,9 @@ class DocumentResponse(BaseModel):
updated_at: StrictStr
memory_unit_count: StrictInt
tags: Optional[List[StrictStr]] = Field(default=None, description="Tags associated with this document")
__properties: ClassVar[List[str]] = ["id", "bank_id", "original_text", "content_hash", "created_at", "updated_at", "memory_unit_count", "tags"]
document_metadata: Optional[Dict[str, Any]] = None
retain_params: Optional[Dict[str, Any]] = None
__properties: ClassVar[List[str]] = ["id", "bank_id", "original_text", "content_hash", "created_at", "updated_at", "memory_unit_count", "tags", "document_metadata", "retain_params"]
model_config = ConfigDict(
populate_by_name=True,
@ -80,6 +82,16 @@ class DocumentResponse(BaseModel):
if self.content_hash is None and "content_hash" in self.model_fields_set:
_dict['content_hash'] = None
# set to None if document_metadata (nullable) is None
# and model_fields_set contains the field
if self.document_metadata is None and "document_metadata" in self.model_fields_set:
_dict['document_metadata'] = None
# set to None if retain_params (nullable) is None
# and model_fields_set contains the field
if self.retain_params is None and "retain_params" in self.model_fields_set:
_dict['retain_params'] = None
return _dict
@classmethod
@ -99,7 +111,9 @@ class DocumentResponse(BaseModel):
"created_at": obj.get("created_at"),
"updated_at": obj.get("updated_at"),
"memory_unit_count": obj.get("memory_unit_count"),
"tags": obj.get("tags")
"tags": obj.get("tags"),
"document_metadata": obj.get("document_metadata"),
"retain_params": obj.get("retain_params")
})
return _obj

View file

@ -954,6 +954,22 @@ export type DocumentResponse = {
* Tags associated with this document
*/
tags?: Array<string>;
/**
* Document Metadata
*
* Document metadata
*/
document_metadata?: {
[key: string]: unknown;
} | null;
/**
* Retain Params
*
* Parameters used during retain
*/
retain_params?: {
[key: string]: unknown;
} | null;
};
/**

View file

@ -36,6 +36,48 @@ import {
const ITEMS_PER_PAGE = 50;
function formatRelativeTime(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const seconds = Math.floor((now - then) / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function MetadataBadges({ metadata }: { metadata: Record<string, any> }) {
const entries = Object.entries(metadata);
if (entries.length === 0) return <span>-</span>;
return (
<div className="flex flex-wrap gap-1">
{entries.slice(0, 3).map(([k, v]) => (
<span
key={k}
className="text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400 font-medium"
>
{k}={String(v)}
</span>
))}
{entries.length > 3 && (
<span className="text-xs px-2 py-0.5 text-muted-foreground">+{entries.length - 3}</span>
)}
</div>
);
}
export function DocumentsView() {
const { currentBank } = useBank();
const [documents, setDocuments] = useState<any[]>([]);
@ -238,8 +280,8 @@ export function DocumentsView() {
<TableHead>Document ID</TableHead>
<TableHead>Created</TableHead>
<TableHead>Tags</TableHead>
<TableHead>Context</TableHead>
<TableHead>Text Length</TableHead>
<TableHead>Metadata</TableHead>
<TableHead>Size</TableHead>
<TableHead>Memory Units</TableHead>
</TableRow>
</TableHeader>
@ -251,11 +293,14 @@ export function DocumentsView() {
className={`cursor-pointer hover:bg-muted/50 ${selectedDocument?.id === doc.id ? "bg-primary/10" : ""}`}
onClick={() => viewDocumentText(doc.id)}
>
<TableCell title={doc.id} className="text-card-foreground">
{doc.id.length > 30 ? doc.id.substring(0, 30) + "..." : doc.id}
<TableCell className="text-card-foreground font-mono text-xs break-all">
{doc.id}
</TableCell>
<TableCell className="text-card-foreground">
{doc.created_at ? new Date(doc.created_at).toLocaleString() : "N/A"}
<TableCell
className="text-card-foreground"
title={doc.created_at ? new Date(doc.created_at).toLocaleString() : ""}
>
{doc.created_at ? formatRelativeTime(doc.created_at) : "N/A"}
</TableCell>
<TableCell className="text-card-foreground">
{doc.tags && doc.tags.length > 0 ? (
@ -279,10 +324,15 @@ export function DocumentsView() {
)}
</TableCell>
<TableCell className="text-card-foreground">
{doc.retain_params?.context || "-"}
{doc.document_metadata &&
Object.keys(doc.document_metadata).length > 0 ? (
<MetadataBadges metadata={doc.document_metadata} />
) : (
"-"
)}
</TableCell>
<TableCell className="text-card-foreground">
{doc.text_length?.toLocaleString()} chars
{formatBytes(doc.text_length || 0)}
</TableCell>
<TableCell className="text-card-foreground">
{doc.memory_unit_count}
@ -362,7 +412,7 @@ export function DocumentsView() {
{/* Document Detail Panel - Fixed on Right */}
{documents.length > 0 && selectedDocument && (
<div className="fixed right-0 top-0 h-screen w-[420px] bg-card border-l-2 border-primary shadow-2xl z-50 overflow-y-auto animate-in slide-in-from-right duration-300 ease-out">
<div className="fixed right-0 top-0 h-screen w-[560px] bg-card border-l-2 border-primary shadow-2xl z-50 overflow-y-auto animate-in slide-in-from-right duration-300 ease-out">
<div className="p-5">
{/* Header with close button */}
<div className="flex justify-between items-center mb-6 pb-4 border-b border-border">
@ -424,46 +474,47 @@ export function DocumentsView() {
</div>
)}
{/* Text Length */}
{/* Text Size */}
{selectedDocument.original_text && (
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Text Length
Size
</div>
<div className="text-sm font-medium text-card-foreground">
{selectedDocument.original_text.length.toLocaleString()} characters
{formatBytes(new Blob([selectedDocument.original_text]).size)}
</div>
</div>
)}
{/* Retain Parameters */}
{selectedDocument.retain_params && (
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
<div className="p-4 bg-muted/50 rounded-lg space-y-3">
<div className="text-xs font-bold text-muted-foreground uppercase">
Retain Parameters
</div>
<div className="text-sm space-y-2 text-card-foreground">
{selectedDocument.retain_params.context && (
<div>
<span className="font-semibold">Context:</span>{" "}
<div className="text-xs text-muted-foreground mb-1">Context</div>
<div className="text-sm text-card-foreground">
{selectedDocument.retain_params.context}
</div>
</div>
)}
{selectedDocument.retain_params.event_date && (
<div>
<span className="font-semibold">Event Date:</span>{" "}
<div className="text-xs text-muted-foreground mb-1">Event Date</div>
<div className="text-sm text-card-foreground">
{new Date(selectedDocument.retain_params.event_date).toLocaleString()}
</div>
)}
{selectedDocument.retain_params.metadata && (
<div className="mt-2">
<span className="font-semibold">Metadata:</span>
<pre className="mt-1 text-xs bg-background p-2 rounded text-card-foreground">
{JSON.stringify(selectedDocument.retain_params.metadata, null, 2)}
</pre>
</div>
)}
{selectedDocument.retain_params.metadata &&
Object.keys(selectedDocument.retain_params.metadata).length > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-1">Metadata</div>
<MetadataBadges metadata={selectedDocument.retain_params.metadata} />
</div>
)}
</div>
)}

View file

@ -5815,6 +5815,32 @@
"title": "Tags",
"description": "Tags associated with this document",
"default": []
},
"document_metadata": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Document Metadata",
"description": "Document metadata"
},
"retain_params": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Retain Params",
"description": "Parameters used during retain"
}
},
"type": "object",
@ -5833,9 +5859,17 @@
"bank_id": "user123",
"content_hash": "abc123",
"created_at": "2024-01-15T10:30:00Z",
"document_metadata": {
"channel": "#general",
"source": "slack"
},
"id": "session_1",
"memory_unit_count": 15,
"original_text": "Full document text here...",
"retain_params": {
"context": "Team meeting notes",
"event_date": "2024-01-15"
},
"tags": [
"user_a",
"session_123"