pymetadataeditor 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.0}/PKG-INFO +3 -3
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.0}/README.md +2 -2
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.0}/pymetadataeditor/interface.py +137 -33
- pymetadataeditor-0.3.0/pymetadataeditor/llm_helpers.py +257 -0
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.0}/pymetadataeditor/requester.py +27 -12
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.0}/pyproject.toml +3 -1
- pymetadataeditor-0.2.0/pymetadataeditor/llm_helpers.py +0 -145
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.0}/LICENSE +0 -0
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.0}/pymetadataeditor/__init__.py +0 -0
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.0}/pymetadataeditor/templates.py +0 -0
- {pymetadataeditor-0.2.0 → pymetadataeditor-0.3.0}/pymetadataeditor/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pymetadataeditor
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Metadata Editor client for python
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Gordon Blackadder
|
|
@@ -317,9 +317,9 @@ If you are running on Windows and see errors about numpy installation errors the
|
|
|
317
317
|
|
|
318
318
|
### Markdown API Documentation
|
|
319
319
|
|
|
320
|
-
Create documentation for the pyMetadataEditor class by running:
|
|
320
|
+
Create documentation for the pyMetadataEditor class by running the following command:
|
|
321
321
|
```
|
|
322
|
-
|
|
322
|
+
python make_docs.py
|
|
323
323
|
```
|
|
324
324
|
|
|
325
325
|
## Notes
|
|
@@ -295,9 +295,9 @@ If you are running on Windows and see errors about numpy installation errors the
|
|
|
295
295
|
|
|
296
296
|
### Markdown API Documentation
|
|
297
297
|
|
|
298
|
-
Create documentation for the pyMetadataEditor class by running:
|
|
298
|
+
Create documentation for the pyMetadataEditor class by running the following command:
|
|
299
299
|
```
|
|
300
|
-
|
|
300
|
+
python make_docs.py
|
|
301
301
|
```
|
|
302
302
|
|
|
303
303
|
## Notes
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""The interface between pyMetadataEditor and the Metadata Editor API."""
|
|
2
2
|
|
|
3
3
|
import warnings
|
|
4
|
+
from io import BufferedReader
|
|
4
5
|
from json import JSONDecodeError
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from tempfile import NamedTemporaryFile
|
|
@@ -22,6 +23,7 @@ from urllib3.exceptions import InsecureRequestWarning
|
|
|
22
23
|
from pymetadataeditor.llm_helpers import (
|
|
23
24
|
_iterated_validated_update_to_outline,
|
|
24
25
|
_prepend_draft_drop_non_str,
|
|
26
|
+
call_per_field,
|
|
25
27
|
get_date_as_text,
|
|
26
28
|
json_to_markdown,
|
|
27
29
|
)
|
|
@@ -133,7 +135,7 @@ class MetadataEditor:
|
|
|
133
135
|
|
|
134
136
|
Args:
|
|
135
137
|
api_url (str): the URL typically looks like 'https://<name_of_your_metadata_database>.org/index.php/api'
|
|
136
|
-
api_key (str): typically this is created through the web interface of the metadata system
|
|
138
|
+
api_key (str): typically this is created through the web interface of the metadata system.
|
|
137
139
|
allow_http (bool): whether to allow calls to the metadata system when the URL begins "http" instead of the
|
|
138
140
|
more secure "https". Defaults to False.
|
|
139
141
|
verify_ssl (bool): Although it is good practice for API requests to verify SSL, some systems do not allow
|
|
@@ -227,6 +229,13 @@ class MetadataEditor:
|
|
|
227
229
|
projects = pd.DataFrame.from_dict(projects).set_index("id")
|
|
228
230
|
except KeyError: # is this the best way to cope with times when there are no projects?
|
|
229
231
|
return pd.DataFrame(columns=["id", "created"]).set_index("id")
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
new_index = projects.index.astype(int)
|
|
235
|
+
except ValueError:
|
|
236
|
+
pass
|
|
237
|
+
else:
|
|
238
|
+
projects.index = new_index
|
|
230
239
|
return projects
|
|
231
240
|
|
|
232
241
|
def get_project_by_id(self, id: int) -> pd.Series:
|
|
@@ -246,6 +255,45 @@ class MetadataEditor:
|
|
|
246
255
|
response = self._apinterface.get_request(get_project_template, id=id)
|
|
247
256
|
return pd.Series(response["project"])
|
|
248
257
|
|
|
258
|
+
####################################################################################################################
|
|
259
|
+
# Generic API requests
|
|
260
|
+
####################################################################################################################
|
|
261
|
+
|
|
262
|
+
def generic_api_request(
|
|
263
|
+
self,
|
|
264
|
+
method: str,
|
|
265
|
+
endpoint: str,
|
|
266
|
+
params: Optional[Dict] = None,
|
|
267
|
+
data: Optional[Dict] = None,
|
|
268
|
+
json: Optional[Dict] = None,
|
|
269
|
+
files: Optional[Dict[str, BufferedReader]] = None,
|
|
270
|
+
) -> Dict:
|
|
271
|
+
"""Make a generic API request to the Metadata Editor API.
|
|
272
|
+
|
|
273
|
+
It's generally better to use the specific functions such as list_projects, create_project_log etc. but this
|
|
274
|
+
function is provided for flexibility and to allow for future changes in the API.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
method (str): Either 'POST' or 'GET'
|
|
278
|
+
endpoint (str): The path appended to the API_URL to which a GET or POST request is sent.
|
|
279
|
+
params (optional dict): additional parameters to send with a GET request.
|
|
280
|
+
data (optional dict): The data to send with a POST request.
|
|
281
|
+
json (optional dict): The JSON data to send with a POST request.
|
|
282
|
+
files (optional dict[str, BufferedReader]): The files to send with a POST request in the form
|
|
283
|
+
```{"filename": open(filename, "rb")}```.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Dict: The response from the API as a dictionary.
|
|
287
|
+
"""
|
|
288
|
+
return self._apinterface._request(
|
|
289
|
+
method=method,
|
|
290
|
+
pth=endpoint,
|
|
291
|
+
params=params,
|
|
292
|
+
data=data,
|
|
293
|
+
json=json,
|
|
294
|
+
files=files,
|
|
295
|
+
)
|
|
296
|
+
|
|
249
297
|
####################################################################################################################
|
|
250
298
|
# METADATA Input and Output
|
|
251
299
|
####################################################################################################################
|
|
@@ -644,7 +692,7 @@ class MetadataEditor:
|
|
|
644
692
|
llm_model_name="gpt-4o",
|
|
645
693
|
tokenizer_model="o200k_base",
|
|
646
694
|
max_tokens=128_000,
|
|
647
|
-
|
|
695
|
+
llm_base_url: Optional[str] = None,
|
|
648
696
|
azure_llm_base_url: Optional[str] = None,
|
|
649
697
|
) -> Union[BaseModel, Dict, str]:
|
|
650
698
|
"""Automatically generate *draft* metadata for a project based on local files or web pages.
|
|
@@ -681,11 +729,11 @@ class MetadataEditor:
|
|
|
681
729
|
The option is provided in case OpenAI deprecated the 4o model.
|
|
682
730
|
max_tokens (int): The maximum number of tokens to use when sending the content to OpenAI.
|
|
683
731
|
Defaults to 128_000, which has been the typical maximum for the 4o model.
|
|
684
|
-
|
|
732
|
+
llm_base_url (Optional[str]): The base URL for the LLM API. If None, the default URL is used which
|
|
685
733
|
sends the request to OpenAI. This argument is ignored if an azure_llm_base_url is provided.
|
|
686
734
|
azure_llm_base_url (Optional[str]): The base URL for the Azure LLM API. Typically used when an organization
|
|
687
735
|
has its own deployment of an LLM model, possibly for privacy reasons. The Azure endpoint will be used
|
|
688
|
-
even if a
|
|
736
|
+
even if a llm_base_url is provided. If None, then the llm_base_url endpoint is used. If
|
|
689
737
|
that's also None, then OpenAI is used.
|
|
690
738
|
|
|
691
739
|
Returns:
|
|
@@ -706,6 +754,19 @@ class MetadataEditor:
|
|
|
706
754
|
title="My Metadata",
|
|
707
755
|
)
|
|
708
756
|
|
|
757
|
+
# Example with a local model running on Ollama:
|
|
758
|
+
me.draft_metadata_from_files(
|
|
759
|
+
llm_api_key="ollama", # pragma: allowlist secret
|
|
760
|
+
files=["/path/to/word_file1.docx", "http://www.example.com/report.pdf"],
|
|
761
|
+
output_mode="pydantic",
|
|
762
|
+
metadata_type_or_template_uid="indicator",
|
|
763
|
+
metadata_producer_organization="My Organization",
|
|
764
|
+
filename="output.xlsx",
|
|
765
|
+
title="My Metadata",
|
|
766
|
+
llm_base_url="http://localhost:11434/v1/",
|
|
767
|
+
llm_model_name="llama3.1"
|
|
768
|
+
)
|
|
769
|
+
|
|
709
770
|
# Example with an Azure instance of a Large Language Model:
|
|
710
771
|
me.draft_metadata_from_files(
|
|
711
772
|
llm_api_key="...",
|
|
@@ -726,7 +787,7 @@ class MetadataEditor:
|
|
|
726
787
|
)
|
|
727
788
|
enc = tiktoken.get_encoding(tokenizer_model)
|
|
728
789
|
if azure_llm_base_url is None:
|
|
729
|
-
client = OpenAI(api_key=llm_api_key, base_url=
|
|
790
|
+
client = OpenAI(api_key=llm_api_key, base_url=llm_base_url)
|
|
730
791
|
else:
|
|
731
792
|
client = AzureOpenAI(api_key=llm_api_key, base_url=azure_llm_base_url, api_version="2024-10-01")
|
|
732
793
|
|
|
@@ -765,29 +826,29 @@ class MetadataEditor:
|
|
|
765
826
|
f" {max_tokens}, truncating the content and proceeding."
|
|
766
827
|
)
|
|
767
828
|
else:
|
|
768
|
-
print(f"
|
|
829
|
+
print(f"Reading {doc}, running token count is {num_tokens}")
|
|
769
830
|
messages += user_message
|
|
770
831
|
|
|
771
832
|
endpoint_name = (
|
|
772
|
-
azure_llm_base_url
|
|
773
|
-
if azure_llm_base_url is not None
|
|
774
|
-
else "OpenAI"
|
|
775
|
-
if public_llm_base_url is None
|
|
776
|
-
else public_llm_base_url
|
|
833
|
+
azure_llm_base_url if azure_llm_base_url is not None else "OpenAI" if llm_base_url is None else llm_base_url
|
|
777
834
|
)
|
|
778
835
|
print(f"Sending to {endpoint_name}, this may take a few minutes...")
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
message = completion.choices[0].message
|
|
786
|
-
if not message.parsed:
|
|
787
|
-
raise ValueError(message.refusal)
|
|
836
|
+
try:
|
|
837
|
+
completion = client.beta.chat.completions.parse(
|
|
838
|
+
model=llm_model_name,
|
|
839
|
+
messages=messages,
|
|
840
|
+
response_format=metadata_class_no_rules,
|
|
841
|
+
)
|
|
788
842
|
|
|
789
|
-
|
|
843
|
+
message = completion.choices[0].message
|
|
844
|
+
if not message.parsed:
|
|
845
|
+
raise ValueError(message.refusal)
|
|
790
846
|
|
|
847
|
+
metadata_dict = message.parsed.model_dump(exclude_none=True, exclude_unset=True)
|
|
848
|
+
except ValidationError:
|
|
849
|
+
metadata_dict = call_per_field(metadata_class_no_rules, client, llm_model_name, messages).model_dump(
|
|
850
|
+
exclude_none=True, exclude_unset=True
|
|
851
|
+
)
|
|
791
852
|
# if prefix:
|
|
792
853
|
# metadata_dict = self._prepend_draft_drop_non_str(metadata_dict, prefix)
|
|
793
854
|
metadata_class_with_rules = self.get_metadata_class(metadata_type_or_template_uid)
|
|
@@ -814,7 +875,7 @@ class MetadataEditor:
|
|
|
814
875
|
llm_model_name="gpt-4o",
|
|
815
876
|
tokenizer_model="o200k_base",
|
|
816
877
|
max_tokens=128_000,
|
|
817
|
-
|
|
878
|
+
llm_base_url: Optional[str] = None,
|
|
818
879
|
azure_llm_base_url: Optional[str] = None,
|
|
819
880
|
) -> Union[BaseModel, Dict, str]:
|
|
820
881
|
"""Augment existing metadata with information from files or web pages.
|
|
@@ -843,11 +904,11 @@ class MetadataEditor:
|
|
|
843
904
|
The option is provided in case OpenAI deprecated the 4o model.
|
|
844
905
|
max_tokens (int): The maximum number of tokens to use when sending the content to OpenAI.
|
|
845
906
|
Defaults to 128_000, which has been the typical maximum for the 4o model.
|
|
846
|
-
|
|
907
|
+
llm_base_url (Optional[str]): The base URL for the LLM API. If None, the default URL is used which
|
|
847
908
|
sends the request to OpenAI.
|
|
848
909
|
azure_llm_base_url (Optional[str]): The base URL for the Azure LLM API. Typically used when an organization
|
|
849
910
|
has its own deployment of an LLM model, possibly for privacy reasons. The Azure endpoint will be used
|
|
850
|
-
even if a
|
|
911
|
+
even if a llm_base_url is provided. If None, then the llm_base_url endpoint is used. If
|
|
851
912
|
that's also None, then OpenAI is used.
|
|
852
913
|
|
|
853
914
|
Returns:
|
|
@@ -946,7 +1007,7 @@ class MetadataEditor:
|
|
|
946
1007
|
llm_model_name=llm_model_name,
|
|
947
1008
|
tokenizer_model=tokenizer_model,
|
|
948
1009
|
max_tokens=max_tokens,
|
|
949
|
-
|
|
1010
|
+
llm_base_url=llm_base_url,
|
|
950
1011
|
azure_llm_base_url=azure_llm_base_url,
|
|
951
1012
|
)
|
|
952
1013
|
|
|
@@ -1111,7 +1172,7 @@ class MetadataEditor:
|
|
|
1111
1172
|
- "add": Adds a value to the specified path. If the path already exists, the value is replaced.
|
|
1112
1173
|
- "remove": Removes the value at the specified path.
|
|
1113
1174
|
- "replace": Replaces the value at the specified path with a new value.
|
|
1114
|
-
- "test": Tests that the
|
|
1175
|
+
- "test": Tests that the specified path contains the given value.
|
|
1115
1176
|
|
|
1116
1177
|
The `path` is a string that uses a slash (`/`) notation to specify the location within the JSON document.
|
|
1117
1178
|
For example, `/author` refers to the "author" field, and `/metadata/title` refers to the "title" field
|
|
@@ -1132,7 +1193,18 @@ class MetadataEditor:
|
|
|
1132
1193
|
Example:
|
|
1133
1194
|
```python
|
|
1134
1195
|
me = MetadataEditor(api_url = api_url, api_key = api_key)
|
|
1196
|
+
|
|
1197
|
+
# set the author of the project with ID 123 to "John Doe"
|
|
1135
1198
|
me.patch_update_project_log_by_id(id=123, op="add", path="/author", value="John Doe")
|
|
1199
|
+
|
|
1200
|
+
# test that the author is "John Doe"
|
|
1201
|
+
me.patch_update_project_log_by_id(id=123, op="test", path="/author", value="John Doe")
|
|
1202
|
+
|
|
1203
|
+
# change the author to "Jane Doe"
|
|
1204
|
+
me.patch_update_project_log_by_id(id=123, op="replace", path="/author", value="Jane Doe")
|
|
1205
|
+
|
|
1206
|
+
# remove the value of author
|
|
1207
|
+
me.patch_update_project_log_by_id(id=123, op="remove", path="/author")
|
|
1136
1208
|
```
|
|
1137
1209
|
"""
|
|
1138
1210
|
project_data = self.get_project_by_id(id)
|
|
@@ -1144,7 +1216,7 @@ class MetadataEditor:
|
|
|
1144
1216
|
patches["value"] = value
|
|
1145
1217
|
if isinstance(patches, dict):
|
|
1146
1218
|
patches = [patches]
|
|
1147
|
-
patches = validate_json_patches(patches) # depend on serverside validation
|
|
1219
|
+
patches = validate_json_patches(patches) # could instead only depend on serverside validation...
|
|
1148
1220
|
self._apinterface.post_request(pth=pth + "{}", id=id, json={"patches": patches, "validate": False})
|
|
1149
1221
|
|
|
1150
1222
|
####################################################################################################################
|
|
@@ -1175,6 +1247,13 @@ class MetadataEditor:
|
|
|
1175
1247
|
return row["data_type"]
|
|
1176
1248
|
|
|
1177
1249
|
self._default_templates["data_type"] = self._default_templates.apply(standardize, axis=1)
|
|
1250
|
+
|
|
1251
|
+
try:
|
|
1252
|
+
new_index = templates.index.astype(int)
|
|
1253
|
+
except ValueError:
|
|
1254
|
+
pass
|
|
1255
|
+
else:
|
|
1256
|
+
templates.index = new_index
|
|
1178
1257
|
return templates
|
|
1179
1258
|
|
|
1180
1259
|
def get_template_by_uid(self, uid: str) -> pd.Series:
|
|
@@ -1242,9 +1321,12 @@ class MetadataEditor:
|
|
|
1242
1321
|
if not (output_template_uid is None or output_template_uid == uid):
|
|
1243
1322
|
new_klass, new_type, _ = self._get_metadata_class_and_type_and_UID(output_template_uid)
|
|
1244
1323
|
try:
|
|
1245
|
-
metadata = new_klass.model_validate(
|
|
1246
|
-
|
|
1247
|
-
|
|
1324
|
+
# metadata = new_klass.model_validate(
|
|
1325
|
+
# remove_empty_from_dict(metadata.model_dump(mode="json", exclude_none=True, exclude_unset=True)),
|
|
1326
|
+
# strict=False,
|
|
1327
|
+
# )
|
|
1328
|
+
metadata = _iterated_validated_update_to_outline(
|
|
1329
|
+
new_klass, updates=metadata.model_dump(mode="json", exclude_none=True, exclude_unset=True)
|
|
1248
1330
|
)
|
|
1249
1331
|
except ValidationError as e:
|
|
1250
1332
|
raise TemplateError(
|
|
@@ -1332,7 +1414,14 @@ class MetadataEditor:
|
|
|
1332
1414
|
response = self._apinterface.get_request("collections")
|
|
1333
1415
|
if "collections" not in response or len(response["collections"]) == 0:
|
|
1334
1416
|
return pd.DataFrame([], columns=["id", "title", "created"]).set_index("id")
|
|
1335
|
-
|
|
1417
|
+
df = pd.DataFrame(response["collections"]).set_index("id")
|
|
1418
|
+
try:
|
|
1419
|
+
new_index = df.index.astype(int)
|
|
1420
|
+
except ValueError:
|
|
1421
|
+
pass
|
|
1422
|
+
else:
|
|
1423
|
+
df.index = new_index
|
|
1424
|
+
return df
|
|
1336
1425
|
|
|
1337
1426
|
def get_collection_by_id(self, id: int) -> pd.Series:
|
|
1338
1427
|
"""Get information about a collection like title, description, created date.
|
|
@@ -1449,7 +1538,15 @@ class MetadataEditor:
|
|
|
1449
1538
|
ret = self._apinterface.get_request("editor?collection={}", id=collection, params=params)
|
|
1450
1539
|
if len(ret["projects"]) == 0:
|
|
1451
1540
|
return pd.DataFrame([], columns=["id", "type", "idno", "title"]).set_index("id")
|
|
1452
|
-
|
|
1541
|
+
df = pd.DataFrame(ret["projects"]).set_index("id")
|
|
1542
|
+
|
|
1543
|
+
try:
|
|
1544
|
+
new_index = df.index.astype(int)
|
|
1545
|
+
except ValueError:
|
|
1546
|
+
pass
|
|
1547
|
+
else:
|
|
1548
|
+
df.index = new_index
|
|
1549
|
+
return df
|
|
1453
1550
|
|
|
1454
1551
|
def add_projects_to_collection(
|
|
1455
1552
|
self, collection: Union[int, List[int]], id_format: str, projects: Union[int, List[int], str, List[str]]
|
|
@@ -1580,7 +1677,14 @@ class MetadataEditor:
|
|
|
1580
1677
|
"""
|
|
1581
1678
|
response = self._apinterface.get_request("resources/{}", id=id)
|
|
1582
1679
|
if "resources" in response and len(response["resources"]) > 0:
|
|
1583
|
-
|
|
1680
|
+
df = pd.DataFrame(response["resources"])
|
|
1681
|
+
try:
|
|
1682
|
+
new_index = df.index.astype(int)
|
|
1683
|
+
except ValueError:
|
|
1684
|
+
pass
|
|
1685
|
+
else:
|
|
1686
|
+
df.index = new_index
|
|
1687
|
+
return df
|
|
1584
1688
|
else:
|
|
1585
1689
|
return pd.DataFrame(columns=["id", "sid", "dctype", "title", "subtitle", "author", "filename", "dcformat"])
|
|
1586
1690
|
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Helper functions for the LLM Metadata Editor when working with Pydantic models and LLM outputs."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, List, get_args
|
|
5
|
+
|
|
6
|
+
from metadataschemas.utils.quick_start import make_skeleton
|
|
7
|
+
from metadataschemas.utils.utils import (
|
|
8
|
+
get_subtype_of_optional_or_list,
|
|
9
|
+
is_list_annotation,
|
|
10
|
+
is_optional_annotation,
|
|
11
|
+
is_optional_list,
|
|
12
|
+
subset_pydantic_model_type,
|
|
13
|
+
)
|
|
14
|
+
from openai import APITimeoutError
|
|
15
|
+
from pydantic import BaseModel, ValidationError
|
|
16
|
+
|
|
17
|
+
# from tqdm import tqdm
|
|
18
|
+
from tqdm import tqdm
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _prepend_draft_drop_non_str(d: Any, prefix: str) -> dict | list | str | None:
|
|
22
|
+
"""Recursively prepend a prefix to all strings in a dictionary or list and drop empty strings and non-strings.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
d (Any): The dictionary or list to process.
|
|
26
|
+
prefix (str): The prefix to prepend to all strings.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
dict | list | str | None: The processed dictionary or list, or the string with the prefix prepended.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(d, dict):
|
|
32
|
+
out = {}
|
|
33
|
+
for k, v in d.items():
|
|
34
|
+
new_v = _prepend_draft_drop_non_str(v, prefix)
|
|
35
|
+
if new_v is not None:
|
|
36
|
+
out[k] = new_v
|
|
37
|
+
if len(out) > 0:
|
|
38
|
+
return out
|
|
39
|
+
else:
|
|
40
|
+
return None
|
|
41
|
+
elif isinstance(d, list):
|
|
42
|
+
out = []
|
|
43
|
+
for v in d:
|
|
44
|
+
new_v = _prepend_draft_drop_non_str(v, prefix)
|
|
45
|
+
if new_v is not None:
|
|
46
|
+
out.append(new_v)
|
|
47
|
+
if len(out) > 0:
|
|
48
|
+
return out
|
|
49
|
+
else:
|
|
50
|
+
return None
|
|
51
|
+
elif isinstance(d, str):
|
|
52
|
+
if d.strip() == "":
|
|
53
|
+
return None
|
|
54
|
+
else:
|
|
55
|
+
return prefix + d
|
|
56
|
+
else:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _iterated_validated_update_to_outline(model_def: type[BaseModel], updates: dict, verbose=False) -> BaseModel:
|
|
61
|
+
"""Recursively updates a model definition with given updates and validates the result.
|
|
62
|
+
|
|
63
|
+
This function takes a model definition and a dictionary of updates, makes a skeleton and applies the updates
|
|
64
|
+
to that skeleton, and validates the updated model. If validation fails for any update value, the
|
|
65
|
+
original skeleton value is retained.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
model_def (type[BaseModel]): The model definition class to be updated.
|
|
69
|
+
updates (dict): A dictionary containing the updates to be applied to the model.
|
|
70
|
+
verbose (bool, optional): If True, prints detailed information about validation failures. Defaults to False.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
BaseModel: The validated model instance with the applied updates.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValidationError: If the final model validation fails.
|
|
77
|
+
|
|
78
|
+
Steps:
|
|
79
|
+
1. Create a skeleton of the model and dump its initial state.
|
|
80
|
+
2. Validate the initial state of the model.
|
|
81
|
+
3. Iterate over the updates dictionary.
|
|
82
|
+
4. For each update, check if the key exists in the original model.
|
|
83
|
+
5. Determine the type of the field (annotation) and handle optional and list types.
|
|
84
|
+
6. Recursively update nested models if the value is a dictionary or a list of dictionaries.
|
|
85
|
+
7. Apply the candidate value to the original model.
|
|
86
|
+
8. Validate the updated model.
|
|
87
|
+
9. If validation fails, revert to the original value and optionally print the error.
|
|
88
|
+
10. Return the validated model instance.
|
|
89
|
+
"""
|
|
90
|
+
original_model = make_skeleton(model_def).model_dump()
|
|
91
|
+
# print(original_model)
|
|
92
|
+
model_def.model_validate(original_model, strict=False)
|
|
93
|
+
|
|
94
|
+
model_fields = model_def.model_fields
|
|
95
|
+
for key, value in updates.items():
|
|
96
|
+
if key not in original_model:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# get annotation aka type of the field
|
|
100
|
+
annotation = model_fields[key].annotation
|
|
101
|
+
if is_optional_annotation(annotation):
|
|
102
|
+
annotation = get_args(annotation)[0]
|
|
103
|
+
is_list = False
|
|
104
|
+
if is_list_annotation(annotation):
|
|
105
|
+
annotation = get_args(annotation)[0]
|
|
106
|
+
is_list = True
|
|
107
|
+
# print(annotation)
|
|
108
|
+
|
|
109
|
+
# get candidate value
|
|
110
|
+
if isinstance(value, dict) and issubclass(annotation, BaseModel):
|
|
111
|
+
candidate_value = _iterated_validated_update_to_outline(annotation, value, verbose=verbose).model_dump()
|
|
112
|
+
elif is_list and isinstance(value, list) and issubclass(annotation, BaseModel):
|
|
113
|
+
candidate_value = []
|
|
114
|
+
for item in value:
|
|
115
|
+
candidate_value.append(
|
|
116
|
+
_iterated_validated_update_to_outline(annotation, item, verbose=verbose).model_dump()
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
candidate_value = value
|
|
120
|
+
|
|
121
|
+
# try the candidate value
|
|
122
|
+
original_value = original_model[key]
|
|
123
|
+
original_model[key] = candidate_value
|
|
124
|
+
try:
|
|
125
|
+
model_def.model_validate(original_model, strict=False)
|
|
126
|
+
except ValidationError as e:
|
|
127
|
+
# If validation fails, leave the original value
|
|
128
|
+
if verbose:
|
|
129
|
+
print(f"Skipping {key}({annotation})={candidate_value} because of {e}\n")
|
|
130
|
+
original_model[key] = original_value
|
|
131
|
+
# print(original_model)
|
|
132
|
+
|
|
133
|
+
return model_def.model_validate(original_model, strict=False)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_date_as_text():
|
|
137
|
+
"""Returns the current date as a formatted string with an ordinal suffix for the day.
|
|
138
|
+
|
|
139
|
+
The format of the returned date string is "Month DaySuffix, Year", where:
|
|
140
|
+
- Month is the full name of the month (e.g., January, February).
|
|
141
|
+
- DaySuffix is the day of the month with an appropriate ordinal suffix (e.g., 1st, 2nd, 3rd, 4th, etc.).
|
|
142
|
+
- Year is the four-digit year.
|
|
143
|
+
|
|
144
|
+
Example:
|
|
145
|
+
If today's date is October 21, 2023, the function will return "October 21st, 2023".
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
str: The formatted date string.
|
|
149
|
+
"""
|
|
150
|
+
today = datetime.today()
|
|
151
|
+
day = today.day
|
|
152
|
+
# Determine ordinal suffix
|
|
153
|
+
if 10 <= day % 100 <= 20:
|
|
154
|
+
suffix = "th"
|
|
155
|
+
else:
|
|
156
|
+
suffix = {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
|
|
157
|
+
|
|
158
|
+
# Format the date
|
|
159
|
+
return today.strftime(f"%B %-d{suffix}, %Y")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def json_to_markdown(data, level=1):
|
|
163
|
+
"""Converts a JSON object into Markdown. Future versions of MarkItDown will support this natively.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
data: The JSON object (dict, list, or primitive type).
|
|
167
|
+
level: Current Markdown heading level for nested keys.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
A Markdown string.
|
|
171
|
+
"""
|
|
172
|
+
markdown = ""
|
|
173
|
+
|
|
174
|
+
if isinstance(data, dict):
|
|
175
|
+
for key, value in data.items():
|
|
176
|
+
markdown += f"{'#' * level} {key}\n\n" # Add heading for the key
|
|
177
|
+
markdown += json_to_markdown(value, level + 1) # Recursively process value
|
|
178
|
+
elif isinstance(data, list):
|
|
179
|
+
for item in data:
|
|
180
|
+
markdown += f"- {json_to_markdown(item, level + 1).strip()}\n" # Format as a list
|
|
181
|
+
else:
|
|
182
|
+
# Format primitive types (string, number, boolean, null)
|
|
183
|
+
markdown += f"{data}\n\n"
|
|
184
|
+
|
|
185
|
+
return markdown
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def call_per_field(
|
|
189
|
+
klass: type[BaseModel], client, model: str, messages: list[dict[str, str]], show_progress_bar: bool = False
|
|
190
|
+
) -> BaseModel:
|
|
191
|
+
"""Calls the LLM per field and returns a validated model.
|
|
192
|
+
|
|
193
|
+
This function is typically used when the LLM model is less powerful such as when the model is running locally.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
klass (type[BaseModel]): The Pydantic model class to be updated.
|
|
197
|
+
client: The LLM client. such as OpenAI(api_key=..., base_url=...) or AzureOpenAI(api_key=..., base_url=...)
|
|
198
|
+
model (str): The model name, for example "gpt-4o" or "llama3.1".
|
|
199
|
+
messages (list[dict[str, str]]): The messages to be sent to the LLM.
|
|
200
|
+
show_progress_bar (bool): If True, shows a progress bar. Defaults to False but is set to True nested calls.
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
BaseModel: The validated model instance with the applied updates.
|
|
205
|
+
|
|
206
|
+
Example:
|
|
207
|
+
```python
|
|
208
|
+
klass = metadata_class_no_rules, metadata_type, _ = MetadataEditor._get_metadata_class_and_type_and_UID(
|
|
209
|
+
metadata_type_or_template_uid, apply_template_rules=False
|
|
210
|
+
)
|
|
211
|
+
client = OpenAI(api_key=..., base_url=...)
|
|
212
|
+
model = "gpt-4o"
|
|
213
|
+
messages = [{"role": "system", "content": "You are a helpful assistant who writes metadata."},
|
|
214
|
+
{"role": "user", "content": "A report on the state of the world"}]
|
|
215
|
+
updated_model = call_per_field(klass, client, model, messages)
|
|
216
|
+
```
|
|
217
|
+
1. The function iterates over each field in the Pydantic model.
|
|
218
|
+
2. For each field, it sends a request to the LLM with the provided messages.
|
|
219
|
+
3. If the LLM response is valid, it updates the field in the model.
|
|
220
|
+
4. If the LLM response is invalid, it attempts to handle nested fields or lists.
|
|
221
|
+
5. The function returns the updated and validated model.
|
|
222
|
+
"""
|
|
223
|
+
final_dict = {}
|
|
224
|
+
for field in tqdm(klass.model_fields.keys(), disable=not show_progress_bar):
|
|
225
|
+
try:
|
|
226
|
+
completion = client.beta.chat.completions.parse(
|
|
227
|
+
model=model,
|
|
228
|
+
messages=messages,
|
|
229
|
+
response_format=subset_pydantic_model_type(klass, [field]),
|
|
230
|
+
temperature=0.0,
|
|
231
|
+
timeout=60 * 1,
|
|
232
|
+
)
|
|
233
|
+
except APITimeoutError:
|
|
234
|
+
# print(f"{' '*(indent+2)}Timeout")
|
|
235
|
+
continue
|
|
236
|
+
except ValidationError:
|
|
237
|
+
# print(f"{' '*indent}Validation Error for {field}, attempting to step through subfields")
|
|
238
|
+
field_annotation = klass.model_fields[field].annotation
|
|
239
|
+
# print(f"{' '*indent}field")
|
|
240
|
+
if is_optional_list(field_annotation) or is_list_annotation(field_annotation):
|
|
241
|
+
# print(get_subtype_of_optional_or_list(field_annotation))
|
|
242
|
+
class subtype(BaseModel):
|
|
243
|
+
field: List[get_subtype_of_optional_or_list(field_annotation)]
|
|
244
|
+
|
|
245
|
+
final_dict[field] = call_per_field(subtype, client, model, messages, show_progress_bar=True)
|
|
246
|
+
elif is_optional_annotation(field_annotation):
|
|
247
|
+
# print(get_subtype_of_optional_or_list(field_annotation))
|
|
248
|
+
final_dict[field] = call_per_field(
|
|
249
|
+
get_subtype_of_optional_or_list(field_annotation), client, model, messages, show_progress_bar=True
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
continue
|
|
253
|
+
else:
|
|
254
|
+
message = completion.choices[0].message
|
|
255
|
+
metadata_dict = message.parsed.model_dump(exclude_none=True, exclude_unset=True)
|
|
256
|
+
final_dict = {**final_dict, **metadata_dict}
|
|
257
|
+
return _iterated_validated_update_to_outline(klass, final_dict)
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
"""A module to handle HTTP requests with specific error handling for SSL and JSON decoding errors.
|
|
2
|
+
|
|
3
|
+
This module defines a class `RequestsWithSpecificErrors` that provides methods for making GET and POST requests
|
|
4
|
+
to a specified API URL. It includes error handling for SSL errors, HTTP errors, and JSON decoding errors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
1
7
|
from io import BufferedReader
|
|
2
8
|
from json import JSONDecodeError
|
|
3
9
|
from ssl import SSLError as ssl_SSLError
|
|
@@ -9,6 +15,8 @@ from requests.exceptions import HTTPError, SSLError
|
|
|
9
15
|
|
|
10
16
|
|
|
11
17
|
class RequestsWithSpecificErrors(BaseModel):
|
|
18
|
+
"""A class to handle HTTP requests with specific error handling for SSL and JSON decoding errors."""
|
|
19
|
+
|
|
12
20
|
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
13
21
|
|
|
14
22
|
api_url: AnyHttpUrl
|
|
@@ -30,7 +38,7 @@ class RequestsWithSpecificErrors(BaseModel):
|
|
|
30
38
|
else:
|
|
31
39
|
raise ValueError(
|
|
32
40
|
f"URL scheme should be 'https' but got {self.api_url}"
|
|
33
|
-
"To allow the less secure use of 'http', set
|
|
41
|
+
"To allow the less secure use of 'http', set allow_http=True"
|
|
34
42
|
)
|
|
35
43
|
|
|
36
44
|
def _request(
|
|
@@ -43,12 +51,17 @@ class RequestsWithSpecificErrors(BaseModel):
|
|
|
43
51
|
data: Optional[Dict[str, str]] = None,
|
|
44
52
|
files: Optional[Dict[str, BufferedReader]] = None,
|
|
45
53
|
) -> Dict:
|
|
46
|
-
"""
|
|
47
|
-
Sends a GET or POST request to the specified URL with the API key in the headers and returns the JSON response.
|
|
54
|
+
"""Perform a GET or POST request to the specified URL with the API key in the headers.
|
|
48
55
|
|
|
49
56
|
Args:
|
|
50
57
|
method (str): Either post or get
|
|
51
58
|
pth (str): The path appended to the API_URL to which the GET or POST request is sent.
|
|
59
|
+
json (optional dict): The JSON data to send with the POST request.
|
|
60
|
+
params (optional dict): additional parameters to send with the get request such as 'keywords'
|
|
61
|
+
id (optional int or str): The id of a specific collection or project.
|
|
62
|
+
If not none, then pth should contain '{}' where the id ought to go.
|
|
63
|
+
data (optional dict): The data to send with the POST request.
|
|
64
|
+
files (optional dict): The files to send with the POST request.
|
|
52
65
|
|
|
53
66
|
Returns:
|
|
54
67
|
Dict[str, str]: The JSON response from the server, parsed into a dictionary.
|
|
@@ -102,14 +115,14 @@ class RequestsWithSpecificErrors(BaseModel):
|
|
|
102
115
|
raise HTTPError(error_msg) from None
|
|
103
116
|
elif response.status_code == 403:
|
|
104
117
|
raise PermissionError(
|
|
105
|
-
f"Access to that URL is denied for {url}
|
|
118
|
+
f"Access to that URL is denied for {url} Check that the API key is correct"
|
|
106
119
|
) from e
|
|
107
|
-
elif response.status_code == 400 and "message" in response.text:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
120
|
+
# elif response.status_code == 400 and "message" in response.text:
|
|
121
|
+
# if isinstance(response.text, dict):
|
|
122
|
+
# error_message = response.text["message"]
|
|
123
|
+
# else:
|
|
124
|
+
# error_message = response.text
|
|
125
|
+
# raise PermissionError(error_message) from e
|
|
113
126
|
else:
|
|
114
127
|
raise HTTPError(f"Status Code: {response.status_code}, Response: {response.text}") from e
|
|
115
128
|
try:
|
|
@@ -125,7 +138,8 @@ class RequestsWithSpecificErrors(BaseModel):
|
|
|
125
138
|
def get_request(
|
|
126
139
|
self, pth: str, id: Optional[Union[int, str]] = None, params: Optional[Dict[str, Union[str, List[str]]]] = None
|
|
127
140
|
) -> Dict:
|
|
128
|
-
"""
|
|
141
|
+
"""Performs a GET request to the specified URL with the API key in the headers and returns the JSON response.
|
|
142
|
+
|
|
129
143
|
Args:
|
|
130
144
|
pth (str): The path appended to the API_URL to which the GET request is sent.
|
|
131
145
|
id (optional int or str): The id of a specific collection or project.
|
|
@@ -145,7 +159,8 @@ class RequestsWithSpecificErrors(BaseModel):
|
|
|
145
159
|
data: Optional[Dict[str, str]] = None,
|
|
146
160
|
files: Optional[Dict[str, BufferedReader]] = None,
|
|
147
161
|
):
|
|
148
|
-
"""
|
|
162
|
+
"""Performs a POST request to the specified URL with the API key in the headers and returns the JSON response.
|
|
163
|
+
|
|
149
164
|
Args:
|
|
150
165
|
pth (str): The path appended to the API_URL to which the POST request is sent.
|
|
151
166
|
json (optional dict): The JSON data to send with the POST request.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "pymetadataeditor"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Metadata Editor client for python"
|
|
5
5
|
authors = ["Gordon Blackadder <gblackadder@worldbank.org>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -35,6 +35,8 @@ fix = true
|
|
|
35
35
|
[tool.ruff.lint]
|
|
36
36
|
extend-select = ["I","E","F","D"]
|
|
37
37
|
ignore = ["D203","D204","D213","D215","D400","D401","D404","D406","D407","D408","D409","D413"]
|
|
38
|
+
# Ignore missing docstring warnings in test files since unit tests don't need them
|
|
39
|
+
per-file-ignores = { "tests/*.py" = ["D103", "D100"] }
|
|
38
40
|
|
|
39
41
|
[tool.ruff.lint.pydocstyle]
|
|
40
42
|
convention = "google"
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
from datetime import datetime
|
|
2
|
-
from typing import Any, get_args
|
|
3
|
-
|
|
4
|
-
from metadataschemas.utils.quick_start import make_skeleton
|
|
5
|
-
from metadataschemas.utils.utils import is_list_annotation, is_optional_annotation
|
|
6
|
-
from pydantic import BaseModel, ValidationError
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def _prepend_draft_drop_non_str(d: Any, prefix: str) -> dict | list | str | None:
|
|
10
|
-
"""
|
|
11
|
-
Recursively prepend a prefix to all strings in a dictionary or list and drop empty strings and non-strings.
|
|
12
|
-
|
|
13
|
-
Args:
|
|
14
|
-
|
|
15
|
-
d (Any): The dictionary or list to process.
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
if isinstance(d, dict):
|
|
19
|
-
out = {}
|
|
20
|
-
for k, v in d.items():
|
|
21
|
-
new_v = _prepend_draft_drop_non_str(v, prefix)
|
|
22
|
-
if new_v is not None:
|
|
23
|
-
out[k] = new_v
|
|
24
|
-
if len(out) > 0:
|
|
25
|
-
return out
|
|
26
|
-
else:
|
|
27
|
-
return None
|
|
28
|
-
elif isinstance(d, list):
|
|
29
|
-
out = []
|
|
30
|
-
for v in d:
|
|
31
|
-
new_v = _prepend_draft_drop_non_str(v, prefix)
|
|
32
|
-
if new_v is not None:
|
|
33
|
-
out.append(new_v)
|
|
34
|
-
if len(out) > 0:
|
|
35
|
-
return out
|
|
36
|
-
else:
|
|
37
|
-
return None
|
|
38
|
-
elif isinstance(d, str):
|
|
39
|
-
if d.strip() == "":
|
|
40
|
-
return None
|
|
41
|
-
else:
|
|
42
|
-
return prefix + d
|
|
43
|
-
else:
|
|
44
|
-
return None
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _iterated_validated_update_to_outline(model_def: type[BaseModel], updates: dict, verbose=False) -> BaseModel:
|
|
48
|
-
original_model = make_skeleton(model_def).model_dump()
|
|
49
|
-
# print(original_model)
|
|
50
|
-
model_def.model_validate(original_model, strict=False)
|
|
51
|
-
|
|
52
|
-
model_fields = model_def.model_fields
|
|
53
|
-
for key, value in updates.items():
|
|
54
|
-
if key not in original_model:
|
|
55
|
-
continue
|
|
56
|
-
|
|
57
|
-
# get annotation aka type of the field
|
|
58
|
-
annotation = model_fields[key].annotation
|
|
59
|
-
if is_optional_annotation(annotation):
|
|
60
|
-
annotation = get_args(annotation)[0]
|
|
61
|
-
is_list = False
|
|
62
|
-
if is_list_annotation(annotation):
|
|
63
|
-
annotation = get_args(annotation)[0]
|
|
64
|
-
is_list = True
|
|
65
|
-
# print(annotation)
|
|
66
|
-
|
|
67
|
-
# get candidate value
|
|
68
|
-
if isinstance(value, dict) and issubclass(annotation, BaseModel):
|
|
69
|
-
candidate_value = _iterated_validated_update_to_outline(annotation, value, verbose=verbose).model_dump()
|
|
70
|
-
elif is_list and isinstance(value, list) and issubclass(annotation, BaseModel):
|
|
71
|
-
candidate_value = []
|
|
72
|
-
for item in value:
|
|
73
|
-
candidate_value.append(
|
|
74
|
-
_iterated_validated_update_to_outline(annotation, item, verbose=verbose).model_dump()
|
|
75
|
-
)
|
|
76
|
-
else:
|
|
77
|
-
candidate_value = value
|
|
78
|
-
|
|
79
|
-
# try the candidate value
|
|
80
|
-
original_value = original_model[key]
|
|
81
|
-
original_model[key] = candidate_value
|
|
82
|
-
try:
|
|
83
|
-
model_def.model_validate(original_model, strict=False)
|
|
84
|
-
except ValidationError as e:
|
|
85
|
-
# If validation fails, leave the original value
|
|
86
|
-
if verbose:
|
|
87
|
-
print(f"Skipping {key}({annotation})={candidate_value} because of {e}\n")
|
|
88
|
-
original_model[key] = original_value
|
|
89
|
-
# print(original_model)
|
|
90
|
-
|
|
91
|
-
return model_def.model_validate(original_model, strict=False)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def get_date_as_text():
|
|
95
|
-
"""
|
|
96
|
-
Returns the current date as a formatted string with an ordinal suffix for the day.
|
|
97
|
-
|
|
98
|
-
The format of the returned date string is "Month DaySuffix, Year", where:
|
|
99
|
-
- Month is the full name of the month (e.g., January, February).
|
|
100
|
-
- DaySuffix is the day of the month with an appropriate ordinal suffix (e.g., 1st, 2nd, 3rd, 4th, etc.).
|
|
101
|
-
- Year is the four-digit year.
|
|
102
|
-
|
|
103
|
-
Example:
|
|
104
|
-
If today's date is October 21, 2023, the function will return "October 21st, 2023".
|
|
105
|
-
|
|
106
|
-
Returns:
|
|
107
|
-
str: The formatted date string.
|
|
108
|
-
"""
|
|
109
|
-
today = datetime.today()
|
|
110
|
-
day = today.day
|
|
111
|
-
# Determine ordinal suffix
|
|
112
|
-
if 10 <= day % 100 <= 20:
|
|
113
|
-
suffix = "th"
|
|
114
|
-
else:
|
|
115
|
-
suffix = {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
|
|
116
|
-
|
|
117
|
-
# Format the date
|
|
118
|
-
return today.strftime(f"%B %-d{suffix}, %Y")
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def json_to_markdown(data, level=1):
|
|
122
|
-
"""
|
|
123
|
-
Converts a JSON object into Markdown. Future versions of MarkItDown will support this natively.
|
|
124
|
-
|
|
125
|
-
Args:
|
|
126
|
-
data: The JSON object (dict, list, or primitive type).
|
|
127
|
-
level: Current Markdown heading level for nested keys.
|
|
128
|
-
|
|
129
|
-
Returns:
|
|
130
|
-
A Markdown string.
|
|
131
|
-
"""
|
|
132
|
-
markdown = ""
|
|
133
|
-
|
|
134
|
-
if isinstance(data, dict):
|
|
135
|
-
for key, value in data.items():
|
|
136
|
-
markdown += f"{'#' * level} {key}\n\n" # Add heading for the key
|
|
137
|
-
markdown += json_to_markdown(value, level + 1) # Recursively process value
|
|
138
|
-
elif isinstance(data, list):
|
|
139
|
-
for item in data:
|
|
140
|
-
markdown += f"- {json_to_markdown(item, level + 1).strip()}\n" # Format as a list
|
|
141
|
-
else:
|
|
142
|
-
# Format primitive types (string, number, boolean, null)
|
|
143
|
-
markdown += f"{data}\n\n"
|
|
144
|
-
|
|
145
|
-
return markdown
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|