harden filters

This commit is contained in:
phact 2026-01-05 11:03:15 -05:00
parent c923ecb396
commit 534c3021dd
5 changed files with 115 additions and 21 deletions

View file

@ -53,7 +53,20 @@ export function KnowledgeFilterList({
};
const parseQueryData = (queryData: string): ParsedQueryData => {
return JSON.parse(queryData) as ParsedQueryData;
const parsed = JSON.parse(queryData);
// Provide defaults for missing fields to handle API-created filters
return {
query: parsed.query ?? "",
filters: {
data_sources: parsed.filters?.data_sources ?? ["*"],
document_types: parsed.filters?.document_types ?? ["*"],
owners: parsed.filters?.owners ?? ["*"],
},
limit: parsed.limit ?? 10,
scoreThreshold: parsed.scoreThreshold ?? 0,
color: parsed.color ?? "zinc",
icon: parsed.icon ?? "filter",
};
};
return (

View file

@ -96,15 +96,16 @@ export function KnowledgeFilterPanel() {
setQuery(parsedFilterData.query || "");
// Set the actual filter selections from the saved knowledge filter
const filters = parsedFilterData.filters;
const filters = parsedFilterData.filters || {};
// Use the exact selections from the saved filter
// Empty arrays mean "none selected" not "all selected"
// Provide defaults for missing fields to handle API-created filters
const processedFilters = {
data_sources: filters.data_sources,
document_types: filters.document_types,
owners: filters.owners,
connector_types: filters.connector_types || ["*"],
data_sources: filters.data_sources ?? ["*"],
document_types: filters.document_types ?? ["*"],
owners: filters.owners ?? ["*"],
connector_types: filters.connector_types ?? ["*"],
};
console.log("[DEBUG] Loading filter selections:", processedFilters);
@ -114,8 +115,8 @@ export function KnowledgeFilterPanel() {
setScoreThreshold(parsedFilterData.scoreThreshold || 0);
setName(selectedFilter.name);
setDescription(selectedFilter.description || "");
setColor(parsedFilterData.color);
setIconKey(parsedFilterData.icon);
setColor(parsedFilterData.color ?? "zinc");
setIconKey(parsedFilterData.icon ?? "filter");
}
}, [selectedFilter, parsedFilterData]);
@ -123,13 +124,20 @@ export function KnowledgeFilterPanel() {
useEffect(() => {
if (createMode && parsedFilterData) {
setQuery(parsedFilterData.query || "");
setSelectedFilters(parsedFilterData.filters);
// Provide defaults for missing filter fields
const filters = parsedFilterData.filters || {};
setSelectedFilters({
data_sources: filters.data_sources ?? ["*"],
document_types: filters.document_types ?? ["*"],
owners: filters.owners ?? ["*"],
connector_types: filters.connector_types ?? ["*"],
});
setResultLimit(parsedFilterData.limit || 10);
setScoreThreshold(parsedFilterData.scoreThreshold || 0);
setName("");
setDescription("");
setColor(parsedFilterData.color);
setIconKey(parsedFilterData.icon);
setColor(parsedFilterData.color ?? "zinc");
setIconKey(parsedFilterData.icon ?? "filter");
}
}, [createMode, parsedFilterData]);

View file

@ -50,7 +50,10 @@ export function MultiSelect({
const [open, setOpen] = React.useState(false);
const [searchValue, setSearchValue] = React.useState("");
const isAllSelected = value.includes("*");
// Normalize value to empty array if undefined/null to prevent crashes
const safeValue = value ?? [];
const isAllSelected = safeValue.includes("*");
const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(searchValue.toLowerCase()),
@ -66,12 +69,12 @@ export function MultiSelect({
}
} else {
let newValue: string[];
if (value.includes(optionValue)) {
if (safeValue.includes(optionValue)) {
// Remove the item
newValue = value.filter((v) => v !== optionValue && v !== "*");
newValue = safeValue.filter((v) => v !== optionValue && v !== "*");
} else {
// Add the item and remove "All" if present
newValue = [...value.filter((v) => v !== "*"), optionValue];
newValue = [...safeValue.filter((v) => v !== "*"), optionValue];
// Check max selection limit
if (maxSelection && newValue.length > maxSelection) {
@ -87,7 +90,7 @@ export function MultiSelect({
return allOptionLabel;
}
if (value.length === 0) {
if (safeValue.length === 0) {
return placeholder;
}
@ -96,7 +99,7 @@ export function MultiSelect({
.toLowerCase()
.replace("select ", "")
.replace("...", "");
return `${value.length} ${noun}`;
return `${safeValue.length} ${noun}`;
};
return (
@ -152,7 +155,7 @@ export function MultiSelect({
<Check
className={cn(
"mr-2 h-4 w-4",
value.includes(option.value)
safeValue.includes(option.value)
? "opacity-100"
: "opacity-0",
)}

View file

@ -84,7 +84,22 @@ export function KnowledgeFilterProvider({
if (filter) {
setCreateMode(false);
try {
const parsed = JSON.parse(filter.query_data) as ParsedQueryData;
const raw = JSON.parse(filter.query_data);
// Normalize parsed data with defaults for missing fields
// This handles filters created via API with incomplete queryData
const parsed: ParsedQueryData = {
query: raw.query ?? "",
filters: {
data_sources: raw.filters?.data_sources ?? ["*"],
document_types: raw.filters?.document_types ?? ["*"],
owners: raw.filters?.owners ?? ["*"],
connector_types: raw.filters?.connector_types ?? ["*"],
},
limit: raw.limit ?? 10,
scoreThreshold: raw.scoreThreshold ?? 0,
color: raw.color ?? "zinc",
icon: raw.icon ?? "filter",
};
setParsedFilterData(parsed);
// Auto-open panel when filter is selected

View file

@ -8,6 +8,42 @@ from utils.logging_config import get_logger
logger = get_logger(__name__)
def normalize_query_data(query_data: str | dict) -> str:
"""
Normalize query_data to ensure all required fields exist with defaults.
This prevents frontend crashes when API-created filters have incomplete data.
"""
# Parse if string
if isinstance(query_data, str):
try:
data = json.loads(query_data)
except json.JSONDecodeError:
data = {}
else:
data = query_data or {}
# Ensure filters object exists with all required fields
filters = data.get("filters") or {}
normalized_filters = {
"data_sources": filters.get("data_sources", ["*"]),
"document_types": filters.get("document_types", ["*"]),
"owners": filters.get("owners", ["*"]),
"connector_types": filters.get("connector_types", ["*"]),
}
# Build normalized query_data with defaults
normalized = {
"query": data.get("query", ""),
"filters": normalized_filters,
"limit": data.get("limit", 10),
"scoreThreshold": data.get("scoreThreshold", 0),
"color": data.get("color", "zinc"),
"icon": data.get("icon", "filter"),
}
return json.dumps(normalized)
async def create_knowledge_filter(
request: Request, knowledge_filter_service, session_manager
):
@ -25,6 +61,15 @@ async def create_knowledge_filter(
if not query_data:
return JSONResponse({"error": "Query data is required"}, status_code=400)
# Normalize query_data to ensure all required fields exist
try:
normalized_query_data = normalize_query_data(query_data)
except Exception as e:
logger.error(f"Failed to normalize query_data: {e}")
return JSONResponse(
{"error": f"Invalid queryData format: {str(e)}"}, status_code=400
)
user = request.state.user
jwt_token = session_manager.get_effective_jwt_token(user.user_id, request.state.jwt_token)
@ -34,7 +79,7 @@ async def create_knowledge_filter(
"id": filter_id,
"name": name,
"description": description,
"query_data": query_data, # Store the full search query JSON
"query_data": normalized_query_data, # Store normalized query JSON with defaults
"owner": user.user_id,
"allowed_users": payload.get("allowedUsers", []), # ACL field for future use
"allowed_groups": payload.get("allowedGroups", []), # ACL field for future use
@ -158,12 +203,22 @@ async def update_knowledge_filter(
{"error": "Failed to delete existing knowledge filter"}, status_code=500
)
# Normalize query_data if provided, otherwise use existing
query_data = payload.get("queryData", existing_filter["query_data"])
try:
normalized_query_data = normalize_query_data(query_data)
except Exception as e:
logger.error(f"Failed to normalize query_data: {e}")
return JSONResponse(
{"error": f"Invalid queryData format: {str(e)}"}, status_code=400
)
# Create updated knowledge filter document with same ID
updated_filter = {
"id": filter_id,
"name": payload.get("name", existing_filter["name"]),
"description": payload.get("description", existing_filter["description"]),
"query_data": payload.get("queryData", existing_filter["query_data"]),
"query_data": normalized_query_data,
"owner": existing_filter["owner"],
"allowed_users": payload.get(
"allowedUsers", existing_filter.get("allowed_users", [])