mlrun 1.10.0rc7__py3-none-any.whl → 1.10.0rc8__py3-none-any.whl

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.

Potentially problematic release.


This version of mlrun might be problematic. Click here for more details.

Files changed (34) hide show
  1. mlrun/__init__.py +3 -1
  2. mlrun/common/schemas/background_task.py +5 -0
  3. mlrun/common/schemas/model_monitoring/__init__.py +2 -0
  4. mlrun/common/schemas/model_monitoring/constants.py +16 -0
  5. mlrun/common/schemas/project.py +4 -0
  6. mlrun/common/schemas/serving.py +2 -0
  7. mlrun/config.py +11 -22
  8. mlrun/datastore/utils.py +3 -1
  9. mlrun/db/base.py +11 -10
  10. mlrun/db/httpdb.py +97 -25
  11. mlrun/db/nopdb.py +5 -4
  12. mlrun/frameworks/tf_keras/__init__.py +4 -4
  13. mlrun/frameworks/tf_keras/callbacks/logging_callback.py +23 -20
  14. mlrun/frameworks/tf_keras/model_handler.py +69 -9
  15. mlrun/frameworks/tf_keras/utils.py +12 -1
  16. mlrun/launcher/base.py +6 -0
  17. mlrun/launcher/client.py +1 -21
  18. mlrun/projects/pipelines.py +33 -3
  19. mlrun/projects/project.py +13 -16
  20. mlrun/run.py +37 -5
  21. mlrun/runtimes/nuclio/serving.py +14 -5
  22. mlrun/serving/__init__.py +2 -0
  23. mlrun/serving/server.py +156 -26
  24. mlrun/serving/states.py +215 -18
  25. mlrun/serving/system_steps.py +391 -0
  26. mlrun/serving/v2_serving.py +9 -8
  27. mlrun/utils/helpers.py +18 -0
  28. mlrun/utils/version/version.json +2 -2
  29. {mlrun-1.10.0rc7.dist-info → mlrun-1.10.0rc8.dist-info}/METADATA +8 -8
  30. {mlrun-1.10.0rc7.dist-info → mlrun-1.10.0rc8.dist-info}/RECORD +34 -33
  31. {mlrun-1.10.0rc7.dist-info → mlrun-1.10.0rc8.dist-info}/WHEEL +0 -0
  32. {mlrun-1.10.0rc7.dist-info → mlrun-1.10.0rc8.dist-info}/entry_points.txt +0 -0
  33. {mlrun-1.10.0rc7.dist-info → mlrun-1.10.0rc8.dist-info}/licenses/LICENSE +0 -0
  34. {mlrun-1.10.0rc7.dist-info → mlrun-1.10.0rc8.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,391 @@
1
+ # Copyright 2023 Iguazio
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import random
16
+ from copy import copy, deepcopy
17
+ from datetime import timedelta
18
+ from typing import Any, Optional, Union
19
+
20
+ import storey
21
+
22
+ import mlrun
23
+ import mlrun.artifacts
24
+ import mlrun.common.schemas.model_monitoring as mm_schemas
25
+ import mlrun.serving
26
+ from mlrun.common.schemas import MonitoringData
27
+ from mlrun.utils import logger
28
+
29
+
30
+ class MonitoringPreProcessor(storey.MapClass):
31
+ """preprocess step, reconstructs the serving output event body to StreamProcessingEvent schema"""
32
+
33
+ def __init__(
34
+ self,
35
+ context,
36
+ **kwargs,
37
+ ):
38
+ super().__init__(**kwargs)
39
+ self.context = copy(context)
40
+
41
+ def reconstruct_request_resp_fields(
42
+ self, event, model: str, model_monitoring_data: dict
43
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
44
+ result_path = model_monitoring_data.get(MonitoringData.RESULT_PATH)
45
+ input_path = model_monitoring_data.get(MonitoringData.INPUT_PATH)
46
+
47
+ result = self._get_data_from_path(
48
+ result_path, event.body.get(model, event.body)
49
+ )
50
+ output_schema = model_monitoring_data.get(MonitoringData.OUTPUTS)
51
+ input_schema = model_monitoring_data.get(MonitoringData.INPUTS)
52
+ logger.debug("output schema retrieved", output_schema=output_schema)
53
+ if isinstance(result, dict):
54
+ if len(result) > 1:
55
+ # transpose by key the outputs:
56
+ outputs = self.transpose_by_key(result, output_schema)
57
+ elif len(result) == 1:
58
+ outputs = (
59
+ result[output_schema[0]]
60
+ if output_schema
61
+ else list(result.values())[0]
62
+ )
63
+ else:
64
+ outputs = []
65
+ if not output_schema:
66
+ logger.warn(
67
+ "Output schema was not provided using Project:log_model or by ModelRunnerStep:add_model order "
68
+ "may not preserved"
69
+ )
70
+ else:
71
+ outputs = result
72
+
73
+ event_inputs = event._metadata.get("inputs", {})
74
+ event_inputs = self._get_data_from_path(input_path, event_inputs)
75
+ if isinstance(event_inputs, dict):
76
+ if len(event_inputs) > 1:
77
+ # transpose by key the inputs:
78
+ inputs = self.transpose_by_key(event_inputs, input_schema)
79
+ else:
80
+ inputs = (
81
+ event_inputs[input_schema[0]]
82
+ if input_schema
83
+ else list(result.values())[0]
84
+ )
85
+ else:
86
+ inputs = event_inputs
87
+
88
+ if outputs and isinstance(outputs[0], list):
89
+ if output_schema and len(output_schema) != len(outputs[0]):
90
+ logger.info(
91
+ "The number of outputs returned by the model does not match the number of outputs "
92
+ "specified in the model endpoint.",
93
+ model_endpoint=model,
94
+ output_len=len(outputs[0]),
95
+ schema_len=len(output_schema),
96
+ )
97
+ elif outputs:
98
+ if output_schema and len(output_schema) != 1:
99
+ logger.info(
100
+ "The number of outputs returned by the model does not match the number of outputs "
101
+ "specified in the model endpoint.",
102
+ model_endpoint=model,
103
+ output_len=len(outputs),
104
+ schema_len=len(output_schema),
105
+ )
106
+ request = {"inputs": inputs, "id": getattr(event, "id", None)}
107
+ resp = {"outputs": outputs}
108
+
109
+ return request, resp
110
+
111
+ @staticmethod
112
+ def transpose_by_key(
113
+ data_to_transpose, schema: Optional[list[str]] = None
114
+ ) -> list[list[float]]:
115
+ values = (
116
+ list(data_to_transpose.values())
117
+ if not schema
118
+ else [data_to_transpose[key] for key in schema]
119
+ )
120
+ if values and not isinstance(values[0], list):
121
+ values = [values]
122
+ transposed = (
123
+ list(map(list, zip(*values)))
124
+ if all(isinstance(v, list) for v in values) and len(values) > 1
125
+ else values
126
+ )
127
+ return transposed
128
+
129
+ @staticmethod
130
+ def _get_data_from_path(
131
+ path: Union[str, list[str], None], data: dict
132
+ ) -> dict[str, Any]:
133
+ if isinstance(path, str):
134
+ output_data = data.get(path)
135
+ elif isinstance(path, list):
136
+ output_data = deepcopy(data)
137
+ for key in path:
138
+ output_data = output_data.get(key, {})
139
+ elif path is None:
140
+ output_data = data
141
+ else:
142
+ raise mlrun.errors.MLRunInvalidArgumentError(
143
+ "Expected path be of type str or list of str or None"
144
+ )
145
+ if isinstance(output_data, (int, float)):
146
+ output_data = [output_data]
147
+ return output_data
148
+
149
+ def do(self, event):
150
+ monitoring_event_list = []
151
+ server: mlrun.serving.GraphServer = getattr(self.context, "server", None)
152
+ model_runner_name = event._metadata.get("model_runner_name", "")
153
+ step = server.graph.steps[model_runner_name] if server else {}
154
+ monitoring_data = step.monitoring_data
155
+ logger.debug(
156
+ "monitoring preprocessor started",
157
+ event=event,
158
+ model_endpoints=monitoring_data,
159
+ metadata=event._metadata,
160
+ )
161
+ if len(monitoring_data) > 1:
162
+ for model in event.body.keys():
163
+ if model in monitoring_data:
164
+ request, resp = self.reconstruct_request_resp_fields(
165
+ event, model, monitoring_data[model]
166
+ )
167
+ monitoring_event_list.append(
168
+ {
169
+ mm_schemas.StreamProcessingEvent.MODEL: model,
170
+ mm_schemas.StreamProcessingEvent.MODEL_CLASS: monitoring_data[
171
+ model
172
+ ].get(mm_schemas.StreamProcessingEvent.MODEL_CLASS),
173
+ mm_schemas.StreamProcessingEvent.MICROSEC: event._metadata.get(
174
+ model, {}
175
+ ).get(mm_schemas.StreamProcessingEvent.MICROSEC),
176
+ mm_schemas.StreamProcessingEvent.WHEN: event._metadata.get(
177
+ model, {}
178
+ ).get(mm_schemas.StreamProcessingEvent.WHEN),
179
+ mm_schemas.StreamProcessingEvent.ENDPOINT_ID: monitoring_data[
180
+ model
181
+ ].get(
182
+ mlrun.common.schemas.MonitoringData.MODEL_ENDPOINT_UID
183
+ ),
184
+ mm_schemas.StreamProcessingEvent.LABELS: monitoring_data[
185
+ model
186
+ ].get(mlrun.common.schemas.MonitoringData.OUTPUTS),
187
+ mm_schemas.StreamProcessingEvent.FUNCTION_URI: server.function_uri
188
+ if server
189
+ else None,
190
+ mm_schemas.StreamProcessingEvent.REQUEST: request,
191
+ mm_schemas.StreamProcessingEvent.RESPONSE: resp,
192
+ mm_schemas.StreamProcessingEvent.ERROR: event.body[model][
193
+ mm_schemas.StreamProcessingEvent.ERROR
194
+ ]
195
+ if mm_schemas.StreamProcessingEvent.ERROR
196
+ in event.body[model]
197
+ else None,
198
+ mm_schemas.StreamProcessingEvent.METRICS: event.body[model][
199
+ mm_schemas.StreamProcessingEvent.METRICS
200
+ ]
201
+ if mm_schemas.StreamProcessingEvent.METRICS
202
+ in event.body[model]
203
+ else None,
204
+ }
205
+ )
206
+ elif monitoring_data:
207
+ model = list(monitoring_data.keys())[0]
208
+ request, resp = self.reconstruct_request_resp_fields(
209
+ event, model, monitoring_data[model]
210
+ )
211
+ monitoring_event_list.append(
212
+ {
213
+ mm_schemas.StreamProcessingEvent.MODEL: model,
214
+ mm_schemas.StreamProcessingEvent.MODEL_CLASS: monitoring_data[
215
+ model
216
+ ].get(mm_schemas.StreamProcessingEvent.MODEL_CLASS),
217
+ mm_schemas.StreamProcessingEvent.MICROSEC: event._metadata.get(
218
+ mm_schemas.StreamProcessingEvent.MICROSEC
219
+ ),
220
+ mm_schemas.StreamProcessingEvent.WHEN: event._metadata.get(
221
+ mm_schemas.StreamProcessingEvent.WHEN
222
+ ),
223
+ mm_schemas.StreamProcessingEvent.ENDPOINT_ID: monitoring_data[
224
+ model
225
+ ].get(mlrun.common.schemas.MonitoringData.MODEL_ENDPOINT_UID),
226
+ mm_schemas.StreamProcessingEvent.LABELS: monitoring_data[model].get(
227
+ mlrun.common.schemas.MonitoringData.OUTPUTS
228
+ ),
229
+ mm_schemas.StreamProcessingEvent.FUNCTION_URI: server.function_uri
230
+ if server
231
+ else None,
232
+ mm_schemas.StreamProcessingEvent.REQUEST: request,
233
+ mm_schemas.StreamProcessingEvent.RESPONSE: resp,
234
+ mm_schemas.StreamProcessingEvent.ERROR: event.body[
235
+ mm_schemas.StreamProcessingEvent.ERROR
236
+ ]
237
+ if mm_schemas.StreamProcessingEvent.ERROR in event.body
238
+ else None,
239
+ mm_schemas.StreamProcessingEvent.METRICS: event.body[
240
+ mm_schemas.StreamProcessingEvent.METRICS
241
+ ]
242
+ if mm_schemas.StreamProcessingEvent.METRICS in event.body
243
+ else None,
244
+ }
245
+ )
246
+ event.body = monitoring_event_list
247
+ return event
248
+
249
+
250
+ class BackgroundTaskStatus(storey.MapClass):
251
+ """
252
+ background task status checker, prevent events from pushing to the model monitoring stream target if model endpoints
253
+ creation failed or in progress
254
+ """
255
+
256
+ def __init__(self, context, **kwargs):
257
+ self.context = copy(context)
258
+ self.server: mlrun.serving.GraphServer = getattr(self.context, "server", None)
259
+ self._background_task_check_timestamp = None
260
+ self._background_task_state = mlrun.common.schemas.BackgroundTaskState.running
261
+ super().__init__(**kwargs)
262
+
263
+ def do(self, event):
264
+ if (self.context and self.context.is_mock) or self.context is None:
265
+ return event
266
+ if self.server is None:
267
+ return None
268
+
269
+ if (
270
+ self._background_task_state
271
+ == mlrun.common.schemas.BackgroundTaskState.running
272
+ and (
273
+ self._background_task_check_timestamp is None
274
+ or mlrun.utils.now_date() - self._background_task_check_timestamp
275
+ >= timedelta(
276
+ seconds=mlrun.mlconf.model_endpoint_monitoring.model_endpoint_creation_check_period
277
+ )
278
+ )
279
+ ):
280
+ background_task = mlrun.get_run_db().get_project_background_task(
281
+ self.server.project, self.server.model_endpoint_creation_task_name
282
+ )
283
+ self._background_task_check_timestamp = mlrun.utils.now_date()
284
+ self._log_background_task_state(background_task.status.state)
285
+ self._background_task_state = background_task.status.state
286
+ if (
287
+ background_task.status.state
288
+ == mlrun.common.schemas.BackgroundTaskState.succeeded
289
+ ):
290
+ return event
291
+ else:
292
+ return None
293
+ elif (
294
+ self._background_task_state
295
+ == mlrun.common.schemas.BackgroundTaskState.failed
296
+ ):
297
+ return None
298
+ return event
299
+
300
+ def _log_background_task_state(
301
+ self, background_task_state: mlrun.common.schemas.BackgroundTaskState
302
+ ):
303
+ logger.info(
304
+ "Checking model endpoint creation task status",
305
+ task_name=self.server.model_endpoint_creation_task_name,
306
+ )
307
+ if (
308
+ background_task_state
309
+ in mlrun.common.schemas.BackgroundTaskState.terminal_states()
310
+ ):
311
+ logger.info(
312
+ f"Model endpoint creation task completed with state {background_task_state}"
313
+ )
314
+ else: # in progress
315
+ logger.info(
316
+ f"Model endpoint creation task is still in progress with the current state: "
317
+ f"{background_task_state}. Events will not be monitored for the next 15 seconds",
318
+ name=self.name,
319
+ background_task_check_timestamp=self._background_task_check_timestamp.isoformat(),
320
+ )
321
+
322
+
323
+ class SamplingStep(storey.MapClass):
324
+ """sampling step, samples the serving outputs for the model monitoring as sampling_percentage defines"""
325
+
326
+ def __init__(
327
+ self,
328
+ sampling_percentage: Optional[float] = 100.0,
329
+ **kwargs,
330
+ ):
331
+ super().__init__(**kwargs)
332
+ self.sampling_percentage = (
333
+ sampling_percentage if 0 < sampling_percentage <= 100 else 100
334
+ )
335
+
336
+ def do(self, event):
337
+ logger.debug(
338
+ "sampling step runs",
339
+ event=event,
340
+ sampling_percentage=self.sampling_percentage,
341
+ )
342
+ if self.sampling_percentage != 100:
343
+ request = event[mm_schemas.StreamProcessingEvent.REQUEST]
344
+ num_of_inputs = len(request["inputs"])
345
+ sampled_requests_indices = self._pick_random_requests(
346
+ num_of_inputs, self.sampling_percentage
347
+ )
348
+ if not sampled_requests_indices:
349
+ return None
350
+
351
+ event[mm_schemas.StreamProcessingEvent.REQUEST]["inputs"] = [
352
+ request["inputs"][i] for i in sampled_requests_indices
353
+ ]
354
+
355
+ if isinstance(
356
+ event[mm_schemas.StreamProcessingEvent.RESPONSE]["outputs"], list
357
+ ):
358
+ event[mm_schemas.StreamProcessingEvent.RESPONSE]["outputs"] = [
359
+ event[mm_schemas.StreamProcessingEvent.RESPONSE]["outputs"][i]
360
+ for i in sampled_requests_indices
361
+ ]
362
+ event[mm_schemas.EventFieldType.SAMPLING_PERCENTAGE] = self.sampling_percentage
363
+ event[mm_schemas.EventFieldType.EFFECTIVE_SAMPLE_COUNT] = len(
364
+ event.get(mm_schemas.StreamProcessingEvent.REQUEST, {}).get("inputs", [])
365
+ )
366
+ return event
367
+
368
+ @staticmethod
369
+ def _pick_random_requests(num_of_reqs: int, percentage: float) -> list[int]:
370
+ """
371
+ Randomly selects indices of requests to sample based on the given percentage
372
+
373
+ :param num_of_reqs: Number of requests to select from
374
+ :param percentage: Sample percentage for each request
375
+ :return: A list containing the indices of the selected requests
376
+ """
377
+
378
+ return [
379
+ req for req in range(num_of_reqs) if random.random() < (percentage / 100)
380
+ ]
381
+
382
+
383
+ class MockStreamPusher(storey.MapClass):
384
+ def __init__(self, context, output_stream=None, **kwargs):
385
+ super().__init__(**kwargs)
386
+ self.output_stream = output_stream or context.stream.output_stream
387
+
388
+ def do(self, event):
389
+ self.output_stream.push(
390
+ [event], partition_key=mm_schemas.StreamProcessingEvent.ENDPOINT_ID
391
+ )
@@ -384,15 +384,15 @@ class V2ModelServer(StepToDict):
384
384
  return event
385
385
 
386
386
  def logged_results(self, request: dict, response: dict, op: str):
387
- """hook for controlling which results are tracked by the model monitoring
387
+ """Hook for controlling which results are tracked by the model monitoring
388
388
 
389
- this hook allows controlling which input/output data is logged by the model monitoring
390
- allow filtering out columns or adding custom values, can also be used to monitor derived metrics
391
- for example in image classification calculate and track the RGB values vs the image bitmap
389
+ This hook allows controlling which input/output data is logged by the model monitoring.
390
+ It allows filtering out columns or adding custom values, and can also be used to monitor derived metrics,
391
+ for example in image classification to calculate and track the RGB values vs the image bitmap.
392
392
 
393
- the request["inputs"] holds a list of input values/arrays, the response["outputs"] holds a list of
394
- corresponding output values/arrays (the schema of the input/output fields is stored in the model object),
395
- this method should return lists of alternative inputs and outputs which will be monitored
393
+ The request ["inputs"] holds a list of input values/arrays, the response ["outputs"] holds a list of
394
+ corresponding output values/arrays (the schema of the input/output fields is stored in the model object).
395
+ This method should return lists of alternative inputs and outputs which will be monitored.
396
396
 
397
397
  :param request: predict/explain request, see model serving docs for details
398
398
  :param response: result from the model predict/explain (after postprocess())
@@ -422,6 +422,7 @@ class V2ModelServer(StepToDict):
422
422
 
423
423
  def predict(self, request: dict) -> list:
424
424
  """model prediction operation
425
+
425
426
  :return: list with the model prediction results (can be multi-port) or list of lists for multiple predictions
426
427
  """
427
428
  raise NotImplementedError()
@@ -436,7 +437,7 @@ class V2ModelServer(StepToDict):
436
437
  where the internal list order is according to the ArtifactModel inputs.
437
438
 
438
439
  :param request: event
439
- :return: evnet body converting the inputs to be list of lists
440
+ :return: event body converting the inputs to be list of lists
440
441
  """
441
442
  if self.model_spec and self.model_spec.inputs:
442
443
  input_order = [feature.name for feature in self.model_spec.inputs]
mlrun/utils/helpers.py CHANGED
@@ -2121,6 +2121,23 @@ def join_urls(base_url: Optional[str], path: Optional[str]) -> str:
2121
2121
  return f"{base_url.rstrip('/')}/{path.lstrip('/')}" if path else base_url
2122
2122
 
2123
2123
 
2124
+ def warn_on_deprecated_image(image: Optional[str]):
2125
+ """
2126
+ Warn if the provided image is the deprecated 'mlrun/ml-base' image.
2127
+ This image is deprecated as of 1.10.0 and will be removed in 1.12.0.
2128
+ """
2129
+ deprecated_images = ["mlrun/ml-base"]
2130
+ if image and any(
2131
+ image in deprecated_image for deprecated_image in deprecated_images
2132
+ ):
2133
+ warnings.warn(
2134
+ "'mlrun/ml-base' image is deprecated in 1.10.0 and will be replaced by 'mlrun/mlrun'. "
2135
+ "This behavior will be removed in 1.12.0 ",
2136
+ # TODO: Remove this in 1.12.0
2137
+ FutureWarning,
2138
+ )
2139
+
2140
+
2124
2141
  class Workflow:
2125
2142
  @staticmethod
2126
2143
  def get_workflow_steps(
@@ -2290,6 +2307,7 @@ class Workflow:
2290
2307
  workflow_id: str,
2291
2308
  ) -> typing.Optional[mlrun_pipelines.models.PipelineManifest]:
2292
2309
  kfp_client = mlrun_pipelines.utils.get_client(
2310
+ logger=logger,
2293
2311
  url=mlrun.mlconf.kfp_url,
2294
2312
  namespace=mlrun.mlconf.namespace,
2295
2313
  )
@@ -1,4 +1,4 @@
1
1
  {
2
- "git_commit": "7e46ae9ef39327d23bfacc0910fef7b83e23e75d",
3
- "version": "1.10.0-rc7"
2
+ "git_commit": "fede26558b2c8db736315ad1f48e15e3ce2f387d",
3
+ "version": "1.10.0-rc8"
4
4
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlrun
3
- Version: 1.10.0rc7
3
+ Version: 1.10.0rc8
4
4
  Summary: Tracking and config of machine learning runs
5
5
  Home-page: https://github.com/mlrun/mlrun
6
6
  Author: Yaron Haviv
@@ -52,7 +52,7 @@ Requires-Dist: setuptools>=75.2
52
52
  Requires-Dist: deprecated~=1.2
53
53
  Requires-Dist: jinja2>=3.1.6,~=3.1
54
54
  Requires-Dist: orjson<4,>=3.9.15
55
- Requires-Dist: mlrun-pipelines-kfp-common~=0.5.5
55
+ Requires-Dist: mlrun-pipelines-kfp-common~=0.5.6
56
56
  Requires-Dist: mlrun-pipelines-kfp-v1-8~=0.5.4
57
57
  Requires-Dist: docstring_parser~=0.16
58
58
  Requires-Dist: aiosmtplib~=3.0
@@ -250,7 +250,7 @@ Dynamic: summary
250
250
  [![Documentation](https://readthedocs.org/projects/mlrun/badge/?version=latest)](https://mlrun.readthedocs.io/en/latest/?badge=latest)
251
251
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
252
252
  ![GitHub commit activity](https://img.shields.io/github/commit-activity/w/mlrun/mlrun)
253
- ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/mlrun/mlrun?sort=semver)
253
+ [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/mlrun/mlrun?sort=semver)](https://github.com/mlrun/mlrun/releases)
254
254
  [![Join MLOps Live](https://img.shields.io/badge/slack-join_chat-white.svg?logo=slack&style=social)](https://mlopslive.slack.com)
255
255
 
256
256
  <div>
@@ -298,9 +298,9 @@ Removing inappropriate data at an early stage saves resources that would otherwi
298
298
  [Vector databases](https://docs.mlrun.org/en/stable/genai/data-mgmt/vector-databases.html)
299
299
  [Guardrails for data management](https://docs.mlrun.org/en/stable/genai/data-mgmt/guardrails-data.html)
300
300
  **Demo:**
301
- [Call center demo](https://github.com/mlrun/demo-call-center>`
301
+ [Call center demo](https://github.com/mlrun/demo-call-center)
302
302
  **Video:**
303
- [Call center](https://youtu.be/YycMbxRgLBA>`
303
+ [Call center](https://youtu.be/YycMbxRgLBA)
304
304
 
305
305
  ### Development
306
306
  Use MLRun to build an automated ML pipeline to: collect data,
@@ -321,13 +321,13 @@ inferring results using one or more models, and driving actions.
321
321
 
322
322
 
323
323
  **Docs:**
324
- [Serving gen AI models](https://docs.mlrun.org/en/stable/genai/deployment/genai_serving.html), GPU utilization](https://docs.mlrun.org/en/stable/genai/deployment/gpu_utilization.html), [Gen AI realtime serving graph](https://docs.mlrun.org/en/stable/genai/deployment/genai_serving_graph.html)
324
+ [Serving gen AI models](https://docs.mlrun.org/en/stable/genai/deployment/genai_serving.html), [GPU utilization](https://docs.mlrun.org/en/stable/genai/deployment/gpu_utilization.html), [Gen AI realtime serving graph](https://docs.mlrun.org/en/stable/genai/deployment/genai_serving_graph.html)
325
325
  **Tutorial:**
326
326
  [Deploy LLM using MLRun](https://docs.mlrun.org/en/stable/tutorials/genai_01_basic_tutorial.html)
327
327
  **Demos:**
328
- [Call center demo](https://github.com/mlrun/demo-call-center), [Build & deploy custom(fine-tuned)]LLM models and applications <https://github.com/mlrun/demo-llm-tuning/blob/main), [Interactive bot demo using LLMs]<https://github.com/mlrun/demo-llm-bot/blob/main)
328
+ [Call center demo](https://github.com/mlrun/demo-call-center), [Build & deploy custom(fine-tuned)LLM models and applications](https://github.com/mlrun/demo-llm-tuning/blob/main), [Interactive bot demo using LLMs](https://github.com/mlrun/demo-llm-bot/blob/main)
329
329
  **Video:**
330
- [Call center]<https://youtu.be/YycMbxRgLBA)
330
+ [Call center](https://youtu.be/YycMbxRgLBA)
331
331
 
332
332
 
333
333
  ### Live Ops