2025-12-12 17:12:38 +08:00
|
|
|
#
|
|
|
|
|
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
|
|
|
|
|
#
|
|
|
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
# you may not use this file except in compliance with the License.
|
|
|
|
|
# You may obtain a copy of the License at
|
|
|
|
|
#
|
|
|
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
#
|
|
|
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
# See the License for the specific language governing permissions and
|
|
|
|
|
# limitations under the License.
|
|
|
|
|
#
|
2025-12-30 12:56:48 +09:00
|
|
|
import ast
|
2025-12-17 16:50:36 +08:00
|
|
|
import logging
|
|
|
|
|
from typing import Any, Callable, Dict
|
|
|
|
|
|
|
|
|
|
import json_repair
|
2025-12-12 17:12:38 +08:00
|
|
|
|
|
|
|
|
def convert_conditions(metadata_condition):
|
|
|
|
|
if metadata_condition is None:
|
|
|
|
|
metadata_condition = {}
|
|
|
|
|
op_mapping = {
|
|
|
|
|
"is": "=",
|
fix: support date comparison operators (>=, <=, >, <) in metadata filtering (#12982)
## Description
This PR fixes the issue where date metadata conditions with comparison
operators (`>=`, `<=`, `>`, `<`) did not work correctly in the
`/api/v1/retrieval` endpoint.
## Problem
When using metadata conditions like:
```json
{
"metadata_condition": {
"conditions": [
{
"name": "date",
"comparison_operator": ">=",
"value": "2027-01-13"
}
]
}
}
The filtering did not work as expected because:
1. Operators >= and <= were not mapped to internal symbols ≥ and ≤
2. Date strings like "2027-01-13" failed to parse with
ast.literal_eval()
3. Non-standard date formats were incorrectly compared as strings
Solution
Changes in common/metadata_utils.py:
1. Added operator mapping in convert_conditions():
- >= → ≥
- <= → ≤
- != → ≠
2. Implemented strict date format detection in meta_filter():
- Only processes dates in YYYY-MM-DD format (10 characters, properly
formatted)
- When query value is a date, only matches data in the same standard
format
- Non-standard formats (e.g., "2026年1月13日", "2026-1-22") are skipped
3. Maintained backward compatibility:
- Numeric comparisons still work
- String comparisons still work
- Only affects date-formatted queries
Testing
All test cases pass (8/8):
- ✅ Date >= comparison
- ✅ Date > comparison
- ✅ Date < comparison
- ✅ Date <= comparison
- ✅ Date = comparison
- ✅ Date range queries
- ✅ Non-date string comparison (backward compatibility)
- ✅ Numeric comparison (backward compatibility)
Example Usage
{
"dataset_ids": ["xxx"],
"question": "test",
"metadata_condition": {
"conditions": [
{
"name": "date",
"comparison_operator": ">=",
"value": "2027-01-13"
}
]
}
}
Notes
- Only supports standard YYYY-MM-DD format
- Non-standard date formats in data are treated as data quality issues
and will not match
- Users should ensure their date metadata is in the correct format
---------
Co-authored-by: Clint-chan <Clint-chan@users.noreply.github.com>
2026-02-05 13:52:51 +08:00
|
|
|
"not is": "≠",
|
|
|
|
|
">=": "≥",
|
|
|
|
|
"<=": "≤",
|
|
|
|
|
"!=": "≠"
|
2025-12-12 17:12:38 +08:00
|
|
|
}
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"op": op_mapping.get(cond["comparison_operator"], cond["comparison_operator"]),
|
|
|
|
|
"key": cond["name"],
|
|
|
|
|
"value": cond["value"]
|
|
|
|
|
}
|
|
|
|
|
for cond in metadata_condition.get("conditions", [])
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def meta_filter(metas: dict, filters: list[dict], logic: str = "and"):
|
|
|
|
|
doc_ids = set([])
|
|
|
|
|
|
|
|
|
|
def filter_out(v2docs, operator, value):
|
|
|
|
|
ids = []
|
|
|
|
|
for input, docids in v2docs.items():
|
2025-12-24 09:32:19 +08:00
|
|
|
|
2025-12-12 17:12:38 +08:00
|
|
|
if operator in ["=", "≠", ">", "<", "≥", "≤"]:
|
fix: support date comparison operators (>=, <=, >, <) in metadata filtering (#12982)
## Description
This PR fixes the issue where date metadata conditions with comparison
operators (`>=`, `<=`, `>`, `<`) did not work correctly in the
`/api/v1/retrieval` endpoint.
## Problem
When using metadata conditions like:
```json
{
"metadata_condition": {
"conditions": [
{
"name": "date",
"comparison_operator": ">=",
"value": "2027-01-13"
}
]
}
}
The filtering did not work as expected because:
1. Operators >= and <= were not mapped to internal symbols ≥ and ≤
2. Date strings like "2027-01-13" failed to parse with
ast.literal_eval()
3. Non-standard date formats were incorrectly compared as strings
Solution
Changes in common/metadata_utils.py:
1. Added operator mapping in convert_conditions():
- >= → ≥
- <= → ≤
- != → ≠
2. Implemented strict date format detection in meta_filter():
- Only processes dates in YYYY-MM-DD format (10 characters, properly
formatted)
- When query value is a date, only matches data in the same standard
format
- Non-standard formats (e.g., "2026年1月13日", "2026-1-22") are skipped
3. Maintained backward compatibility:
- Numeric comparisons still work
- String comparisons still work
- Only affects date-formatted queries
Testing
All test cases pass (8/8):
- ✅ Date >= comparison
- ✅ Date > comparison
- ✅ Date < comparison
- ✅ Date <= comparison
- ✅ Date = comparison
- ✅ Date range queries
- ✅ Non-date string comparison (backward compatibility)
- ✅ Numeric comparison (backward compatibility)
Example Usage
{
"dataset_ids": ["xxx"],
"question": "test",
"metadata_condition": {
"conditions": [
{
"name": "date",
"comparison_operator": ">=",
"value": "2027-01-13"
}
]
}
}
Notes
- Only supports standard YYYY-MM-DD format
- Non-standard date formats in data are treated as data quality issues
and will not match
- Users should ensure their date metadata is in the correct format
---------
Co-authored-by: Clint-chan <Clint-chan@users.noreply.github.com>
2026-02-05 13:52:51 +08:00
|
|
|
# Check if input is in YYYY-MM-DD date format
|
|
|
|
|
input_str = str(input).strip()
|
|
|
|
|
value_str = str(value).strip()
|
|
|
|
|
|
|
|
|
|
# Strict date format detection: YYYY-MM-DD (must be 10 chars with correct format)
|
|
|
|
|
is_input_date = (
|
|
|
|
|
len(input_str) == 10 and
|
|
|
|
|
input_str[4] == '-' and
|
|
|
|
|
input_str[7] == '-' and
|
|
|
|
|
input_str[:4].isdigit() and
|
|
|
|
|
input_str[5:7].isdigit() and
|
|
|
|
|
input_str[8:10].isdigit()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
is_value_date = (
|
|
|
|
|
len(value_str) == 10 and
|
|
|
|
|
value_str[4] == '-' and
|
|
|
|
|
value_str[7] == '-' and
|
|
|
|
|
value_str[:4].isdigit() and
|
|
|
|
|
value_str[5:7].isdigit() and
|
|
|
|
|
value_str[8:10].isdigit()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if is_value_date:
|
|
|
|
|
# Query value is in date format
|
|
|
|
|
if is_input_date:
|
|
|
|
|
# Data is also in date format: perform date comparison
|
|
|
|
|
input = input_str
|
|
|
|
|
value = value_str
|
|
|
|
|
else:
|
|
|
|
|
# Data is not in date format: skip this record (no match)
|
|
|
|
|
continue
|
|
|
|
|
else:
|
|
|
|
|
# Query value is not in date format: use original logic
|
|
|
|
|
try:
|
|
|
|
|
if isinstance(input, list):
|
|
|
|
|
input = input[0]
|
|
|
|
|
input = ast.literal_eval(input)
|
|
|
|
|
value = ast.literal_eval(value)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Convert strings to lowercase
|
|
|
|
|
if isinstance(input, str):
|
|
|
|
|
input = input.lower()
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
value = value.lower()
|
|
|
|
|
else:
|
|
|
|
|
# Non-comparison operators: maintain original logic
|
|
|
|
|
if isinstance(input, str):
|
|
|
|
|
input = input.lower()
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
value = value.lower()
|
2025-12-12 17:12:38 +08:00
|
|
|
|
2025-12-30 12:56:48 +09:00
|
|
|
matched = False
|
|
|
|
|
try:
|
|
|
|
|
if operator == "contains":
|
2026-01-29 02:59:48 +01:00
|
|
|
matched = str(input).find(value) >= 0 if not isinstance(input, list) else any(str(i).find(value) >= 0 for i in input)
|
2025-12-30 12:56:48 +09:00
|
|
|
elif operator == "not contains":
|
2026-01-29 02:59:48 +01:00
|
|
|
matched = str(input).find(value) == -1 if not isinstance(input, list) else all(str(i).find(value) == -1 for i in input)
|
2025-12-30 12:56:48 +09:00
|
|
|
elif operator == "in":
|
|
|
|
|
matched = input in value if not isinstance(input, list) else all(i in value for i in input)
|
|
|
|
|
elif operator == "not in":
|
|
|
|
|
matched = input not in value if not isinstance(input, list) else all(i not in value for i in input)
|
|
|
|
|
elif operator == "start with":
|
|
|
|
|
matched = str(input).lower().startswith(str(value).lower()) if not isinstance(input, list) else "".join([str(i).lower() for i in input]).startswith(str(value).lower())
|
|
|
|
|
elif operator == "end with":
|
|
|
|
|
matched = str(input).lower().endswith(str(value).lower()) if not isinstance(input, list) else "".join([str(i).lower() for i in input]).endswith(str(value).lower())
|
|
|
|
|
elif operator == "empty":
|
|
|
|
|
matched = not input
|
|
|
|
|
elif operator == "not empty":
|
|
|
|
|
matched = bool(input)
|
|
|
|
|
elif operator == "=":
|
|
|
|
|
matched = input == value
|
|
|
|
|
elif operator == "≠":
|
|
|
|
|
matched = input != value
|
|
|
|
|
elif operator == ">":
|
|
|
|
|
matched = input > value
|
|
|
|
|
elif operator == "<":
|
|
|
|
|
matched = input < value
|
|
|
|
|
elif operator == "≥":
|
|
|
|
|
matched = input >= value
|
|
|
|
|
elif operator == "≤":
|
|
|
|
|
matched = input <= value
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
if matched:
|
|
|
|
|
ids.extend(docids)
|
2025-12-12 17:12:38 +08:00
|
|
|
return ids
|
|
|
|
|
|
|
|
|
|
for k, v2docs in metas.items():
|
|
|
|
|
for f in filters:
|
|
|
|
|
if k != f["key"]:
|
|
|
|
|
continue
|
|
|
|
|
ids = filter_out(v2docs, f["op"], f["value"])
|
|
|
|
|
if not doc_ids:
|
|
|
|
|
doc_ids = set(ids)
|
|
|
|
|
else:
|
|
|
|
|
if logic == "and":
|
|
|
|
|
doc_ids = doc_ids & set(ids)
|
2026-01-28 13:29:34 +08:00
|
|
|
if not doc_ids:
|
|
|
|
|
return []
|
2025-12-12 17:12:38 +08:00
|
|
|
else:
|
|
|
|
|
doc_ids = doc_ids | set(ids)
|
|
|
|
|
return list(doc_ids)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def apply_meta_data_filter(
|
|
|
|
|
meta_data_filter: dict | None,
|
|
|
|
|
metas: dict,
|
|
|
|
|
question: str,
|
|
|
|
|
chat_mdl: Any = None,
|
|
|
|
|
base_doc_ids: list[str] | None = None,
|
|
|
|
|
manual_value_resolver: Callable[[dict], dict] | None = None,
|
|
|
|
|
) -> list[str] | None:
|
|
|
|
|
"""
|
|
|
|
|
Apply metadata filtering rules and return the filtered doc_ids.
|
|
|
|
|
|
|
|
|
|
meta_data_filter supports three modes:
|
|
|
|
|
- auto: generate filter conditions via LLM (gen_meta_filter)
|
|
|
|
|
- semi_auto: generate conditions using selected metadata keys only
|
|
|
|
|
- manual: directly filter based on provided conditions
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
list of doc_ids, ["-999"] when manual filters yield no result, or None
|
|
|
|
|
when auto/semi_auto filters return empty.
|
|
|
|
|
"""
|
2026-01-29 02:59:48 +01:00
|
|
|
from rag.prompts.generator import gen_meta_filter # move from the top of the file to avoid circular import
|
|
|
|
|
|
2025-12-12 17:12:38 +08:00
|
|
|
doc_ids = list(base_doc_ids) if base_doc_ids else []
|
|
|
|
|
|
|
|
|
|
if not meta_data_filter:
|
|
|
|
|
return doc_ids
|
|
|
|
|
|
|
|
|
|
method = meta_data_filter.get("method")
|
|
|
|
|
|
|
|
|
|
if method == "auto":
|
|
|
|
|
filters: dict = await gen_meta_filter(chat_mdl, metas, question)
|
|
|
|
|
doc_ids.extend(meta_filter(metas, filters["conditions"], filters.get("logic", "and")))
|
|
|
|
|
if not doc_ids:
|
|
|
|
|
return None
|
|
|
|
|
elif method == "semi_auto":
|
Support operator constraints in semi-automatic metadata filtering (#12956)
### What problem does this PR solve?
#### Summary
This PR enhances the Semi-automatic metadata filtering mode by allowing
users to explicitly pre-define operators (e.g., contains, =, >, etc.)
for selected metadata keys. While the LLM still dynamically extracts the
filter value from the user's query, it is now strictly constrained to
use the operator specified in the UI configuration.
Using this feature is optional. By default the operator selection is set
to "automatic" resulting in the LLM choosing the operator (as
presently).
#### Rationale & Use Case
This enhancement was driven by a concrete challenge I encountered while
working with technical documentation.
In my specific use case, I was trying to filter for software versions
within a technical manual. In this dataset, a single document chunk
often applies to multiple software versions. These versions are stored
as a combined string within the metadata for each chunk.
When using the standard semi-automatic filter, the LLM would
inconsistently choose between the contains and equals operators. When it
chose equals, it would exclude every chunk that applied to more than one
version, even if the version I was searching for was clearly included in
that metadata string. This led to incomplete and frustrating retrieval
results.
By extending the semi-automatic filter to allow pre-defining the
operator for a specific key, I was able to force the use of contains for
the version field. This change immediately led to significantly improved
and more reliable results in my case.
I believe this functionality will be equally useful for others dealing
with "tagged" or multi-value metadata where the relationship between the
query and the field is known, but the specific value needs to remain
dynamic.
#### Key Changes
##### Backend & Core Logic
- `common/metadata_utils.py`: Updated apply_meta_data_filter to support
a mixed data structure for semi_auto (handling both legacy string arrays
and the new object-based format {"key": "...", "op": "..."}).
- `rag/prompts/generator.py`: Extended gen_meta_filter to accept and
pass operator constraints to the LLM.
- `rag/prompts/meta_filter.md`: Updated the system prompt to instruct
the LLM to strictly respect provided operator constraints.
##### Frontend
- `web/src/components/metadata-filter/metadata-semi-auto-fields.tsx`:
Enhanced the UI to include an operator dropdown for each selected
metadata key, utilizing existing operator constants.
- `web/src/components/metadata-filter/index.tsx`: Updated the validation
schema to accommodate the new state structure.
#### Test Plan
- Backward Compatibility: Verified that existing semi-auto filters
stored as simple strings still function correctly.
- Prompt Verification: Confirmed that constraints are correctly rendered
in the LLM system prompt when specified.
- Added unit tests as
`test/unit_test/common/test_apply_semi_auto_meta_data_filter.py`
- Manual End-to-End:
- Configured a "Semi-automatic" filter for a "Version" key with the
"contains" operator.
- Asked a version-specific query.
- Result
<img width="1173" height="704" alt="Screenshot 2026-02-02 145359"
src="https://github.com/user-attachments/assets/510a6a61-a231-4dc2-a7fe-cdfc07219132"
/>
### Type of change
- [ ] Bug Fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
---------
Co-authored-by: Philipp Heyken Soares <philipp.heyken-soares@am.ai>
2026-02-03 04:11:34 +01:00
|
|
|
selected_keys = []
|
|
|
|
|
constraints = {}
|
|
|
|
|
for item in meta_data_filter.get("semi_auto", []):
|
|
|
|
|
if isinstance(item, str):
|
|
|
|
|
selected_keys.append(item)
|
|
|
|
|
elif isinstance(item, dict):
|
|
|
|
|
key = item.get("key")
|
|
|
|
|
op = item.get("op")
|
|
|
|
|
selected_keys.append(key)
|
|
|
|
|
if op:
|
|
|
|
|
constraints[key] = op
|
|
|
|
|
|
2025-12-12 17:12:38 +08:00
|
|
|
if selected_keys:
|
|
|
|
|
filtered_metas = {key: metas[key] for key in selected_keys if key in metas}
|
|
|
|
|
if filtered_metas:
|
Support operator constraints in semi-automatic metadata filtering (#12956)
### What problem does this PR solve?
#### Summary
This PR enhances the Semi-automatic metadata filtering mode by allowing
users to explicitly pre-define operators (e.g., contains, =, >, etc.)
for selected metadata keys. While the LLM still dynamically extracts the
filter value from the user's query, it is now strictly constrained to
use the operator specified in the UI configuration.
Using this feature is optional. By default the operator selection is set
to "automatic" resulting in the LLM choosing the operator (as
presently).
#### Rationale & Use Case
This enhancement was driven by a concrete challenge I encountered while
working with technical documentation.
In my specific use case, I was trying to filter for software versions
within a technical manual. In this dataset, a single document chunk
often applies to multiple software versions. These versions are stored
as a combined string within the metadata for each chunk.
When using the standard semi-automatic filter, the LLM would
inconsistently choose between the contains and equals operators. When it
chose equals, it would exclude every chunk that applied to more than one
version, even if the version I was searching for was clearly included in
that metadata string. This led to incomplete and frustrating retrieval
results.
By extending the semi-automatic filter to allow pre-defining the
operator for a specific key, I was able to force the use of contains for
the version field. This change immediately led to significantly improved
and more reliable results in my case.
I believe this functionality will be equally useful for others dealing
with "tagged" or multi-value metadata where the relationship between the
query and the field is known, but the specific value needs to remain
dynamic.
#### Key Changes
##### Backend & Core Logic
- `common/metadata_utils.py`: Updated apply_meta_data_filter to support
a mixed data structure for semi_auto (handling both legacy string arrays
and the new object-based format {"key": "...", "op": "..."}).
- `rag/prompts/generator.py`: Extended gen_meta_filter to accept and
pass operator constraints to the LLM.
- `rag/prompts/meta_filter.md`: Updated the system prompt to instruct
the LLM to strictly respect provided operator constraints.
##### Frontend
- `web/src/components/metadata-filter/metadata-semi-auto-fields.tsx`:
Enhanced the UI to include an operator dropdown for each selected
metadata key, utilizing existing operator constants.
- `web/src/components/metadata-filter/index.tsx`: Updated the validation
schema to accommodate the new state structure.
#### Test Plan
- Backward Compatibility: Verified that existing semi-auto filters
stored as simple strings still function correctly.
- Prompt Verification: Confirmed that constraints are correctly rendered
in the LLM system prompt when specified.
- Added unit tests as
`test/unit_test/common/test_apply_semi_auto_meta_data_filter.py`
- Manual End-to-End:
- Configured a "Semi-automatic" filter for a "Version" key with the
"contains" operator.
- Asked a version-specific query.
- Result
<img width="1173" height="704" alt="Screenshot 2026-02-02 145359"
src="https://github.com/user-attachments/assets/510a6a61-a231-4dc2-a7fe-cdfc07219132"
/>
### Type of change
- [ ] Bug Fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [ ] Documentation Update
- [ ] Refactoring
- [ ] Performance Improvement
- [ ] Other (please describe):
---------
Co-authored-by: Philipp Heyken Soares <philipp.heyken-soares@am.ai>
2026-02-03 04:11:34 +01:00
|
|
|
filters: dict = await gen_meta_filter(chat_mdl, filtered_metas, question, constraints=constraints)
|
2025-12-12 17:12:38 +08:00
|
|
|
doc_ids.extend(meta_filter(metas, filters["conditions"], filters.get("logic", "and")))
|
|
|
|
|
if not doc_ids:
|
|
|
|
|
return None
|
|
|
|
|
elif method == "manual":
|
|
|
|
|
filters = meta_data_filter.get("manual", [])
|
|
|
|
|
if manual_value_resolver:
|
|
|
|
|
filters = [manual_value_resolver(flt) for flt in filters]
|
|
|
|
|
doc_ids.extend(meta_filter(metas, filters, meta_data_filter.get("logic", "and")))
|
|
|
|
|
if filters and not doc_ids:
|
|
|
|
|
doc_ids = ["-999"]
|
|
|
|
|
|
|
|
|
|
return doc_ids
|
2025-12-17 16:50:36 +08:00
|
|
|
|
|
|
|
|
|
2025-12-24 09:32:55 +08:00
|
|
|
def dedupe_list(values: list) -> list:
|
|
|
|
|
seen = set()
|
|
|
|
|
deduped = []
|
|
|
|
|
for item in values:
|
|
|
|
|
key = str(item)
|
|
|
|
|
if key in seen:
|
|
|
|
|
continue
|
|
|
|
|
seen.add(key)
|
|
|
|
|
deduped.append(item)
|
|
|
|
|
return deduped
|
|
|
|
|
|
|
|
|
|
|
2025-12-17 16:50:36 +08:00
|
|
|
def update_metadata_to(metadata, meta):
|
|
|
|
|
if not meta:
|
|
|
|
|
return metadata
|
|
|
|
|
if isinstance(meta, str):
|
|
|
|
|
try:
|
|
|
|
|
meta = json_repair.loads(meta)
|
|
|
|
|
except Exception:
|
|
|
|
|
logging.error("Meta data format error.")
|
|
|
|
|
return metadata
|
|
|
|
|
if not isinstance(meta, dict):
|
|
|
|
|
return metadata
|
2025-12-24 09:32:55 +08:00
|
|
|
|
2025-12-17 16:50:36 +08:00
|
|
|
for k, v in meta.items():
|
|
|
|
|
if isinstance(v, list):
|
|
|
|
|
v = [vv for vv in v if isinstance(vv, str)]
|
|
|
|
|
if not v:
|
|
|
|
|
continue
|
2025-12-24 09:32:55 +08:00
|
|
|
v = dedupe_list(v)
|
2025-12-17 16:50:36 +08:00
|
|
|
if not isinstance(v, list) and not isinstance(v, str):
|
|
|
|
|
continue
|
|
|
|
|
if k not in metadata:
|
|
|
|
|
metadata[k] = v
|
|
|
|
|
continue
|
|
|
|
|
if isinstance(metadata[k], list):
|
|
|
|
|
if isinstance(v, list):
|
|
|
|
|
metadata[k].extend(v)
|
|
|
|
|
else:
|
|
|
|
|
metadata[k].append(v)
|
2025-12-24 09:32:55 +08:00
|
|
|
metadata[k] = dedupe_list(metadata[k])
|
2025-12-17 16:50:36 +08:00
|
|
|
else:
|
|
|
|
|
metadata[k] = v
|
|
|
|
|
|
|
|
|
|
return metadata
|
|
|
|
|
|
|
|
|
|
|
2026-01-22 15:34:08 +08:00
|
|
|
def metadata_schema(metadata: dict|list|None) -> Dict[str, Any]:
|
2025-12-17 16:50:36 +08:00
|
|
|
if not metadata:
|
|
|
|
|
return {}
|
|
|
|
|
properties = {}
|
|
|
|
|
|
|
|
|
|
for item in metadata:
|
|
|
|
|
key = item.get("key")
|
|
|
|
|
if not key:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
prop_schema = {
|
|
|
|
|
"description": item.get("description", "")
|
|
|
|
|
}
|
|
|
|
|
if "enum" in item and item["enum"]:
|
|
|
|
|
prop_schema["enum"] = item["enum"]
|
|
|
|
|
prop_schema["type"] = "string"
|
|
|
|
|
|
|
|
|
|
properties[key] = prop_schema
|
|
|
|
|
|
|
|
|
|
json_schema = {
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": properties,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
json_schema["additionalProperties"] = False
|
2025-12-24 09:32:55 +08:00
|
|
|
return json_schema
|
2026-01-22 15:34:08 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_json_schema(obj: dict) -> bool:
|
|
|
|
|
if not isinstance(obj, dict):
|
|
|
|
|
return False
|
|
|
|
|
if "$schema" in obj:
|
|
|
|
|
return True
|
|
|
|
|
return obj.get("type") == "object" and isinstance(obj.get("properties"), dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_metadata_list(obj: list) -> bool:
|
|
|
|
|
if not isinstance(obj, list) or not obj:
|
|
|
|
|
return False
|
|
|
|
|
for item in obj:
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
return False
|
|
|
|
|
key = item.get("key")
|
|
|
|
|
if not isinstance(key, str) or not key:
|
|
|
|
|
return False
|
|
|
|
|
if "enum" in item and not isinstance(item["enum"], list):
|
|
|
|
|
return False
|
|
|
|
|
if "description" in item and not isinstance(item["description"], str):
|
|
|
|
|
return False
|
|
|
|
|
if "descriptions" in item and not isinstance(item["descriptions"], str):
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def turn2jsonschema(obj: dict | list) -> Dict[str, Any]:
|
|
|
|
|
if isinstance(obj, dict) and _is_json_schema(obj):
|
|
|
|
|
return obj
|
|
|
|
|
if isinstance(obj, list) and _is_metadata_list(obj):
|
|
|
|
|
normalized = []
|
|
|
|
|
for item in obj:
|
|
|
|
|
description = item.get("description", item.get("descriptions", ""))
|
|
|
|
|
normalized_item = {
|
|
|
|
|
"key": item.get("key"),
|
|
|
|
|
"description": description,
|
|
|
|
|
}
|
|
|
|
|
if "enum" in item:
|
|
|
|
|
normalized_item["enum"] = item["enum"]
|
|
|
|
|
normalized.append(normalized_item)
|
|
|
|
|
return metadata_schema(normalized)
|
|
|
|
|
return {}
|