diff --git a/good_ai/src/good_ai/good_ai/deploy/create_fastapi_app.py b/good_ai/src/good_ai/good_ai/deploy/create_fastapi_app.py index 71e6cc9..90e6f50 100644 --- a/good_ai/src/good_ai/good_ai/deploy/create_fastapi_app.py +++ b/good_ai/src/good_ai/good_ai/deploy/create_fastapi_app.py @@ -1,12 +1,20 @@ +from typing import Any, Dict, List + from fastapi import FastAPI, status +from fastapi.middleware.wsgi import WSGIMiddleware from fastapi.openapi.docs import get_swagger_ui_html +from fastapi.responses import RedirectResponse from starlette.responses import HTMLResponse +from ..context import get_context from ..helper import snake_case_to_text -from ..views import HealthCheckResponse +from ..metrics import create_dash_app +from ..views import HealthCheckResponse, Query -def create_fastapi_app(function_name: str, disable_docs: bool) -> FastAPI: +def create_fastapi_app( + function_name: str, disable_docs: bool, disable_metrics: bool +) -> FastAPI: app = FastAPI( title=snake_case_to_text(function_name), description=f"REST API wrapper for interacting with the '{function_name}' function.", @@ -20,6 +28,27 @@ def create_fastapi_app(function_name: str, disable_docs: bool) -> FastAPI: def custom_swagger_ui_html() -> HTMLResponse: return get_swagger_ui_html(openapi_url="openapi.json", title=app.title) + @app.get("/docs/index.html", include_in_schema=False) + def redirect_to_entrypoint() -> RedirectResponse: + return RedirectResponse("/docs") + + if not disable_metrics: + dash_app = create_dash_app(function_name) + app.mount(get_context().metrics_path, WSGIMiddleware(dash_app)) + + @app.get("/", include_in_schema=False) + def redirect_to_entrypoint() -> RedirectResponse: + return RedirectResponse("/metrics") + + @app.post("/query", status_code=status.HTTP_200_OK) + def query_metrics(query: Query) -> List[Dict[str, Any]]: + return get_context().persistence.query( + conjunctive_filters=query.filter, + sort_by=query.sort, + skip=query.skip, + take=query.take, + ) + @app.get("/health", status_code=status.HTTP_200_OK) def check_health() -> HealthCheckResponse: return HealthCheckResponse(is_healthy=True) diff --git a/good_ai/src/good_ai/good_ai/deploy/serve.py b/good_ai/src/good_ai/good_ai/deploy/serve.py index b618302..ec482bd 100644 --- a/good_ai/src/good_ai/good_ai/deploy/serve.py +++ b/good_ai/src/good_ai/good_ai/deploy/serve.py @@ -2,15 +2,10 @@ from typing import Any, Callable import uvicorn from fastapi import FastAPI, status -from fastapi.middleware.wsgi import WSGIMiddleware -from fastapi.responses import RedirectResponse -from good_ai.good_ai.deploy.create_fastapi_app import create_fastapi_app - -from ..context import get_context -from ..metrics import create_dash_app from ..tracing import TracingContext from ..views import Trace +from .create_fastapi_app import create_fastapi_app def serve( @@ -19,15 +14,9 @@ def serve( disable_metrics: bool = False, configure: Callable[[FastAPI], None] = lambda _: None, ) -> None: - app = create_fastapi_app(function.__name__, disable_docs=disable_docs) - - if not disable_metrics: - dash_app = create_dash_app(function.__name__) - app.mount(get_context().metrics_path, WSGIMiddleware(dash_app)) - - @app.get("/", include_in_schema=False) - def redirect_to_entrypoint() -> RedirectResponse: - return RedirectResponse("/metrics") + app = create_fastapi_app( + function.__name__, disable_docs=disable_docs, disable_metrics=disable_metrics + ) @app.post("/score", status_code=status.HTTP_200_OK, response_model=Trace) def process(input: Any) -> Trace: diff --git a/good_ai/src/good_ai/good_ai/metrics/create_dash_app.py b/good_ai/src/good_ai/good_ai/metrics/create_dash_app.py index 5f2f2e2..df16f7f 100644 --- a/good_ai/src/good_ai/good_ai/metrics/create_dash_app.py +++ b/good_ai/src/good_ai/good_ai/metrics/create_dash_app.py @@ -1,27 +1,34 @@ -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List import pandas as pd -from dash import Dash, dash_table, html +import plotly.express as px +import plotly.graph_objects as go +from dash import Dash, dash_table, dcc, html from dash.dependencies import Input, Output from flask import Flask from ..context import get_context -from ..views import Filter, SortBy, operators +from ..helper import snake_case_to_text +from ..views import SortBy from .get_description import get_description +from .get_filter_from_datatable import get_filter_from_datatable def create_dash_app(function_name: str) -> Flask: - app = Dash(function_name, requests_pathname_prefix=get_context().metrics_path + "/") + app = Dash( + function_name, + requests_pathname_prefix=get_context().metrics_path + "/", + title=snake_case_to_text(function_name), + ) documents = get_context().persistence.get_documents() df = pd.DataFrame(documents) app.layout = html.Div( - children=[ + [ get_description(function_name), html.Div( - dash_table.DataTable( - id="table-paging-with-graph", + table := dash_table.DataTable( columns=[{"name": i, "id": i} for i in df.columns], page_current=0, page_size=20, @@ -32,23 +39,34 @@ def create_dash_app(function_name: str) -> Flask: sort_mode="multi", sort_by=[], ), - style={"height": 750, "overflowY": "scroll"}, ), - html.Div(id="table-paging-with-graph-container"), + execution_time_histogram := dcc.Graph(), + parallel_coords := dcc.Graph(), + interval := dcc.Interval( + interval=4 * 1000, # in milliseconds + n_intervals=0, + ), ] ) @app.callback( - Output("table-paging-with-graph", "data"), - Input("table-paging-with-graph", "page_current"), - Input("table-paging-with-graph", "page_size"), - Input("table-paging-with-graph", "sort_by"), - Input("table-paging-with-graph", "filter_query"), + Output(table, "data"), + Input(table, "page_current"), + Input(table, "page_size"), + Input(table, "sort_by"), + Input(table, "filter_query"), + Input(interval, "n_intervals"), ) def update_table( - page_current: int, page_size: int, sort_by: List[SortBy], filter: str + page_current: int, + page_size: int, + sort_by: List[SortBy], + filter: str, + n_intervals: int, ) -> List[Dict[str, Any]]: - conjunctive_filters = [get_filter(f) for f in filter.split(" && ")] + conjunctive_filters = [ + get_filter_from_datatable(f) for f in filter.split(" && ") + ] non_null_conjunctive_filters = [f for f in conjunctive_filters if f is not None] return get_context().persistence.query( @@ -58,55 +76,49 @@ def create_dash_app(function_name: str) -> Flask: take=page_size, ) - # @app.callback( - # Output('table-paging-with-graph-container', "children"), - # Input('table-paging-with-graph', "data")) - # def update_graph(rows): - # dff = pd.DataFrame(rows) - # return html.Div( - # [ - # dcc.Graph( - # id=column, - # figure={ - # "data": [ - # { - # "x": dff["country"], - # "y": dff[column] if column in dff else [], - # "type": "bar", - # "marker": {"color": "#0074D9"}, - # } - # ], - # "layout": { - # "xaxis": {"automargin": True}, - # "yaxis": {"automargin": True}, - # "height": 250, - # "margin": {"t": 10, "l": 10, "r": 10}, - # }, - # }, - # ) - # for column in ["pop", "lifeExp", "gdpPercap"] - # ] - # ) + @app.callback( + Output(execution_time_histogram, "figure"), + Input(table, "filter_query"), + Input(interval, "n_intervals"), + ) + def update_execution_times(filter: str, _n_intervals: int) -> go.Figure: + conjunctive_filters = [ + get_filter_from_datatable(f) for f in filter.split(" && ") + ] + non_null_conjunctive_filters = [f for f in conjunctive_filters if f is not None] + + rows = get_context().persistence.query( + conjunctive_filters=non_null_conjunctive_filters + ) + df = pd.DataFrame(rows) + + return px.histogram( + df, + x="execution_time_ms", + labels={"execution_time_ms": "Execution time (ms)"}, + nbins=20, + title="Execution times", + log_y=True, + ) + + @app.callback( + Output(parallel_coords, "figure"), + Input(table, "filter_query"), + Input(interval, "n_intervals"), + ) + def update_parallel_coords(filter: str, _n_intervals: int) -> go.Figure: + conjunctive_filters = [ + get_filter_from_datatable(f) for f in filter.split(" && ") + ] + non_null_conjunctive_filters = [f for f in conjunctive_filters if f is not None] + + rows = get_context().persistence.query( + conjunctive_filters=non_null_conjunctive_filters + ) + + df = pd.DataFrame(rows) + return px.parallel_coordinates( + df, labels={c: snake_case_to_text(c) for c in df.columns} + ) return app.server - - -def get_filter(description: str) -> Optional[Filter]: - print(description) - for operator in operators: - if operator in description: - name_part, value_part = description.split(operator, 1) - value_part = value_part.strip() - name_part = name_part[name_part.find("{") + 1 : name_part.rfind("}")] - - v0 = value_part[0] - if v0 == value_part[-1] and v0 in ("'", '"', "`"): - value: Union[str, float] = value_part[1:-1].replace("\\" + v0, v0) - else: - try: - value = float(value_part) - except ValueError: - value = value_part - return Filter(property=name_part, operator=operator, value=value) - - return None diff --git a/good_ai/src/good_ai/good_ai/metrics/get_filter_from_datatable.py b/good_ai/src/good_ai/good_ai/metrics/get_filter_from_datatable.py new file mode 100644 index 0000000..8da7c35 --- /dev/null +++ b/good_ai/src/good_ai/good_ai/metrics/get_filter_from_datatable.py @@ -0,0 +1,23 @@ +from typing import Optional, Union + +from ..views import Filter, operators + + +def get_filter_from_datatable(description: str) -> Optional[Filter]: + for operator in operators: + if operator in description: + name_part, value_part = description.split(operator, 1) + value_part = value_part.strip() + name_part = name_part[name_part.find("{") + 1 : name_part.rfind("}")] + + v0 = value_part[0] + if v0 == value_part[-1] and v0 in ("'", '"', "`"): + value: Union[str, float] = value_part[1:-1].replace("\\" + v0, v0) + else: + try: + value = float(value_part) + except ValueError: + value = value_part + return Filter(property=name_part, operator=operator, value=value) + + return None diff --git a/good_ai/src/good_ai/good_ai/persistence/parallel_tinydb_driver.py b/good_ai/src/good_ai/good_ai/persistence/parallel_tinydb_driver.py index c6f6d1f..239a80a 100644 --- a/good_ai/src/good_ai/good_ai/persistence/parallel_tinydb_driver.py +++ b/good_ai/src/good_ai/good_ai/persistence/parallel_tinydb_driver.py @@ -1,6 +1,6 @@ from multiprocessing import Lock from pathlib import Path -from typing import Any, Callable, Dict +from typing import Any, Callable, Dict, Optional import pandas as pd from black import List @@ -35,9 +35,9 @@ class ParallelTinyDbDriver(PersistenceDriver): def query( self, conjunctive_filters: List[Filter], - sort_by: List[SortBy], - skip: int, - take: int, + sort_by: List[SortBy] = [], + skip: int = 0, + take: Optional[int] = None, ) -> List[Dict[str, Any]]: documents = self.get_documents() df = pd.DataFrame(documents) @@ -57,7 +57,9 @@ class ParallelTinyDbDriver(PersistenceDriver): inplace=False, ) - return df.iloc[skip : skip + take].to_dict("records") + result = df.iloc[skip:] if take is None else df.iloc[skip : skip + take] + + return result.to_dict("records") def _safe_execute(self, func: Callable[[TinyDB], Any]) -> Any: with lock: diff --git a/good_ai/src/good_ai/good_ai/persistence/persistence_driver.py b/good_ai/src/good_ai/good_ai/persistence/persistence_driver.py index 6e0615f..8f9a1f4 100644 --- a/good_ai/src/good_ai/good_ai/persistence/persistence_driver.py +++ b/good_ai/src/good_ai/good_ai/persistence/persistence_driver.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Any, Dict +from typing import Any, Dict, Optional from black import List @@ -25,8 +25,8 @@ class PersistenceDriver(ABC): def query( self, conjunctive_filters: List[Filter], - sort_by: List[SortBy], - skip: int, - take: int, + sort_by: List[SortBy] = [], + skip: int = 0, + take: Optional[int] = None, ) -> List[Dict[str, Any]]: pass diff --git a/good_ai/src/good_ai/good_ai/views/__init__.py b/good_ai/src/good_ai/good_ai/views/__init__.py index 02eab0b..668939f 100644 --- a/good_ai/src/good_ai/good_ai/views/__init__.py +++ b/good_ai/src/good_ai/good_ai/views/__init__.py @@ -2,5 +2,6 @@ from .filter import Filter from .health_check_response import HealthCheckResponse from .model import Model from .operators import operators +from .query import Query from .sort_by import SortBy from .trace import Trace diff --git a/good_ai/src/good_ai/good_ai/views/query.py b/good_ai/src/good_ai/good_ai/views/query.py new file mode 100644 index 0000000..e91cbec --- /dev/null +++ b/good_ai/src/good_ai/good_ai/views/query.py @@ -0,0 +1,13 @@ +from typing import List + +from pydantic import BaseModel + +from .filter import Filter +from .sort_by import SortBy + + +class Query(BaseModel): + filter: List[Filter] = [] + sort: List[SortBy] = [] + skip: int = 0 + take: int = 100 diff --git a/good_ai/src/good_ai/good_ai/views/sort_by.py b/good_ai/src/good_ai/good_ai/views/sort_by.py index 11bf16b..5b2b4a9 100644 --- a/good_ai/src/good_ai/good_ai/views/sort_by.py +++ b/good_ai/src/good_ai/good_ai/views/sort_by.py @@ -1,4 +1,5 @@ -from typing import Literal, TypedDict +from typing import Literal +from typing_extensions import TypedDict class SortBy(TypedDict):