Skip to content
Draft
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
39 changes: 39 additions & 0 deletions alembic/versions/0008_metadata_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""custom metadata field catalog

Revision ID: 0008_metadata_fields
Revises: 0007_add_knowledge_delete_anonymous
Create Date: 2026-07-16 00:00:00.000000
"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = "0008_metadata_fields"
down_revision: str | Sequence[str] | None = "0007_add_knowledge_delete_anonymous"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.create_table(
"metadata_fields",
sa.Column("key", sa.String(length=64), nullable=False),
sa.Column("metadata_type", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
Comment on lines +25 to +26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the production database backend and established timestamp convention.
rg -n -C3 'create_async_engine|postgres|sqlite|DATABASE' \
  src/db/engine.py src/config/settings.py
rg -n -C2 'DateTime\(timezone=True\)|datetime\.now\(UTC\)' alembic src

Repository: langflow-ai/openrag

Length of output: 18104


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- alembic/versions/0008_metadata_fields.py ---'
sed -n '1,120p' alembic/versions/0008_metadata_fields.py

echo
echo '--- src/db/models/metadata_field.py ---'
sed -n '1,120p' src/db/models/metadata_field.py

echo
echo '--- src/db/repositories/metadata_field_repo.py ---'
sed -n '1,120p' src/db/repositories/metadata_field_repo.py

echo
echo '--- similar timestamp column definitions in neighboring models ---'
rg -n -C2 'sa\.DateTime\(|Field\(default_factory=lambda: datetime\.now\(UTC\)\)|sa_column=Column\(".*DateTime' src/db/models alembic/versions/0008_metadata_fields.py

Repository: langflow-ai/openrag

Length of output: 7103


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- metadata_field usages ---'
rg -n -C2 'MetadataField|created_at|updated_at' src | sed -n '1,220p'

echo
echo '--- timestamp handling patterns in repository/model code ---'
rg -n -C2 'datetime\.now\(UTC\)|isoformat\(\)|timezone=True' src/db src/api src/services alembic/versions | sed -n '1,260p'

Repository: langflow-ai/openrag

Length of output: 33505


Align metadata_fields timestamps with UTC-aware columns.
alembic/versions/0008_metadata_fields.py defines created_at/updated_at as timezone-naive, but src/db/models/metadata_field.py and src/db/repositories/metadata_field_repo.py both use datetime.now(UTC). Set these columns to sa.DateTime(timezone=True) so the UTC offset is preserved in Postgres.

📍 Affects 3 files
  • alembic/versions/0008_metadata_fields.py#L25-L26 (this comment)
  • src/db/models/metadata_field.py#L13-L14
  • src/db/repositories/metadata_field_repo.py#L23-L27
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@alembic/versions/0008_metadata_fields.py` around lines 25 - 26, Update the
created_at and updated_at columns in alembic/versions/0008_metadata_fields.py to
use timezone-aware DateTime definitions via timezone=True. Keep the existing
datetime.now(UTC) behavior in MetadataField and metadata_field_repo.py
unchanged; those sibling sites require no direct changes.

sa.PrimaryKeyConstraint("key", name="pk_metadata_fields"),
)
op.create_index(
"ix_metadata_fields_metadata_type",
"metadata_fields",
["metadata_type"],
unique=False,
)


def downgrade() -> None:
op.drop_index("ix_metadata_fields_metadata_type", table_name="metadata_fields")
op.drop_table("metadata_fields")
3 changes: 3 additions & 0 deletions frontend/app/api/queries/useGetSearchQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type { MetadataGroup } from "@/components/metadata-filter-builder";
import type { ParsedQueryData } from "@/contexts/knowledge-filter-context";
import { SEARCH_CONSTANTS } from "@/lib/constants";
import { buildSearchPayloadFilters } from "@/lib/filter-normalization";
Expand All @@ -16,6 +17,7 @@ export interface SearchPayload {
document_types?: string[];
owners?: string[];
connector_types?: string[];
metadata?: MetadataGroup;
};
}

Expand All @@ -39,6 +41,7 @@ export interface ChunkResult {
index?: number;
allowed_users?: string[];
allowed_groups?: string[];
metadata?: Record<string, { type: string; value: unknown }>;
}

export interface File {
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/chat/_types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export interface SelectedFilters {
document_types: string[];
owners: string[];
connector_types: string[];
metadata?: import("@/components/metadata-filter-builder").MetadataGroup;
}

export interface KnowledgeFilterData {
Expand All @@ -109,6 +110,7 @@ export interface RequestBody {
document_types?: string[];
owners?: string[];
connector_types?: string[];
metadata?: import("@/components/metadata-filter-builder").MetadataGroup;
};
filter_id?: string;
limit?: number;
Expand Down
177 changes: 177 additions & 0 deletions frontend/components/cloud-picker/custom-metadata-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"use client";

import { Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { CustomMetadataEntry, CustomMetadataType } from "./types";

interface MetadataFieldSuggestion {
key: string;
type: CustomMetadataType;
}

interface Props {
value: CustomMetadataEntry[];
onChange: (value: CustomMetadataEntry[]) => void;
}

const defaultValue = (type: CustomMetadataType): string | number | boolean => {
if (type === "number") return 0;
if (type === "boolean") return false;
return "";
};

export function CustomMetadataEditor({ value, onChange }: Props) {
const [suggestions, setSuggestions] = useState<MetadataFieldSuggestion[]>([]);

useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-fetch-in-effect (warning)

fetch() inside useEffect can race, double-fire, or leak. Use a data-fetching layer or Server Component instead.

Fix → Use a data-fetching layer or Server Component so fetches do not race, double-fire, or leak from useEffect.

Docs

let active = true;
fetch("/api/metadata/fields")
.then((response) => (response.ok ? response.json() : { fields: [] }))
.then((result) => {
if (active) setSuggestions(result.fields ?? []);
})
.catch(() => undefined);
return () => {
active = false;
};
}, []);

const fieldTypes = useMemo(
() => new Map(suggestions.map((field) => [field.key, field.type])),
[suggestions],
);

const update = (index: number, patch: Partial<CustomMetadataEntry>) => {
const next = [...value];
next[index] = { ...next[index], ...patch };
onChange(next);
};

return (
<div className="mt-6 space-y-3 border-t pt-4">
<div>
<div className="text-sm font-semibold">Custom metadata</div>
<div className="text-sm text-muted-foreground">
Applied to every document selected for this ingest.
</div>
</div>
<datalist id="custom-metadata-keys">
{suggestions.map((field) => (
<option key={field.key} value={field.key} />
))}
</datalist>
{value.map((entry, index) => {
const knownType = fieldTypes.get(entry.key.trim().toLowerCase());
return (
<div
className="grid grid-cols-[minmax(0,1fr)_130px_minmax(0,1fr)_36px] gap-2"
key={`${index}-${entry.key}`}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-array-index-as-key (warning)

Your users can see & submit the wrong data when this list reorders or filters, so use a stable id like key={item.id}, not the array index "index".

Fix → Use a stable id from the item, like key={item.id} or key={item.slug}. Index keys break when the list reorders or filters.

Docs

>
<Input
aria-label="Metadata key"
list="custom-metadata-keys"
placeholder="supplier"
value={entry.key}
onChange={(event) => {
const key = event.target.value.toLowerCase();
const suggestedType = fieldTypes.get(key);
update(index, {
key,
...(suggestedType && suggestedType !== entry.type
? {
type: suggestedType,
value: defaultValue(suggestedType),
}
: {}),
});
}}
/>
Comment on lines +75 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fix React key to prevent typing focus loss.

Using entry.key in the React key prop forces the element to unmount and remount on every keystroke, causing the input to lose focus after the first character is typed.

Additionally, stripping invalid characters (like spaces or hyphens) during typing prevents users from encountering a 400 error on upload, keeping the input aligned with the backend validation rules.

🐛 Proposed fix
-          <div
-            className="grid grid-cols-[minmax(0,1fr)_130px_minmax(0,1fr)_36px] gap-2"
-            key={`${index}-${entry.key}`}
-          >
+          <div
+            className="grid grid-cols-[minmax(0,1fr)_130px_minmax(0,1fr)_36px] gap-2"
+            key={index}
+          >
             <Input
               aria-label="Metadata key"
               list="custom-metadata-keys"
               placeholder="supplier"
               value={entry.key}
               onChange={(event) => {
-                const key = event.target.value.toLowerCase();
+                const key = event.target.value.toLowerCase().replace(/[^a-z0-9_]/g, "");
                 const suggestedType = fieldTypes.get(key);
                 update(index, {
                   key,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
className="grid grid-cols-[minmax(0,1fr)_130px_minmax(0,1fr)_36px] gap-2"
key={`${index}-${entry.key}`}
>
<Input
aria-label="Metadata key"
list="custom-metadata-keys"
placeholder="supplier"
value={entry.key}
onChange={(event) => {
const key = event.target.value.toLowerCase();
const suggestedType = fieldTypes.get(key);
update(index, {
key,
...(suggestedType && suggestedType !== entry.type
? {
type: suggestedType,
value: defaultValue(suggestedType),
}
: {}),
});
}}
/>
<div
className="grid grid-cols-[minmax(0,1fr)_130px_minmax(0,1fr)_36px] gap-2"
key={index}
>
<Input
aria-label="Metadata key"
list="custom-metadata-keys"
placeholder="supplier"
value={entry.key}
onChange={(event) => {
const key = event.target.value.toLowerCase().replace(/[^a-z0-9_]/g, "");
const suggestedType = fieldTypes.get(key);
update(index, {
key,
...(suggestedType && suggestedType !== entry.type
? {
type: suggestedType,
value: defaultValue(suggestedType),
}
: {}),
});
}}
/>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/components/cloud-picker/custom-metadata-editor.tsx` around lines 75
- 97, Update the metadata row key in the custom metadata editor to use a stable
identifier that does not change when entry.key is edited, preserving input focus
across keystrokes. In the key input’s onChange handler, normalize the entered
key by removing characters rejected by backend validation, including spaces and
hyphens, before updating the entry and deriving its suggested type.

<Select
disabled={!!knownType}
value={knownType ?? entry.type}
onValueChange={(type: CustomMetadataType) =>
update(index, { type, value: defaultValue(type) })
}
>
<SelectTrigger aria-label="Metadata type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">Text</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="date">Date</SelectItem>
<SelectItem value="boolean">Boolean</SelectItem>
</SelectContent>
</Select>
{entry.type === "boolean" ? (
<Select
value={String(entry.value)}
onValueChange={(next) =>
update(index, { value: next === "true" })
}
>
<SelectTrigger aria-label="Metadata value">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
aria-label="Metadata value"
type={
entry.type === "date"
? "date"
: entry.type === "number"
? "number"
: "text"
}
value={String(entry.value)}
onChange={(event) =>
update(index, {
value:
entry.type === "number"
? Number(event.target.value)
: event.target.value,
})
}
/>
)}
<Button
aria-label="Remove metadata"
onClick={() =>
onChange(value.filter((_, itemIndex) => itemIndex !== index))
}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
<Button
onClick={() =>
onChange([...value, { key: "", type: "string", value: "" }])
}
size="sm"
type="button"
variant="outline"
>
<Plus className="h-4 w-4" /> Add metadata
</Button>
</div>
);
}
7 changes: 7 additions & 0 deletions frontend/components/cloud-picker/ingest-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from "@/components/ui/tooltip";
import { useAuth } from "@/contexts/auth-context";
import { knowledgeToIngestSettings } from "@/lib/ingest-settings-knowledge";
import { CustomMetadataEditor } from "./custom-metadata-editor";
import type { IngestSettings as IngestSettingsType } from "./types";

interface IngestSettingsProps {
Expand Down Expand Up @@ -285,6 +286,12 @@ export const IngestSettings = ({
</div>
</div>
)}
{showAdvancedSettings && (
<CustomMetadataEditor
value={currentSettings.metadata ?? []}
onChange={(metadata) => handleSettingsChange({ metadata })}
/>
)}

<div className={showAdvancedSettings ? "" : "mt-6"}>
{showShared && (
Expand Down
11 changes: 11 additions & 0 deletions frontend/components/cloud-picker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ export interface IngestSettings {
embeddingModel: string;
/** When true, index without an owner so all users in the instance can retrieve the document. COS only. */
shared?: boolean;
/** Typed custom metadata applied to every document in the ingest batch. */
metadata?: CustomMetadataEntry[];
}

export type CustomMetadataType = "string" | "number" | "date" | "boolean";
export type CustomMetadataValue = string | number | boolean;

export interface CustomMetadataEntry {
key: string;
type: CustomMetadataType;
value: CustomMetadataValue;
}

/** Inline error message if chunk settings are invalid; otherwise null. */
Expand Down
Loading
Loading