Move files

This commit is contained in:
Andras Schmelczer 2022-07-04 19:31:15 +02:00
parent 3cf28379e8
commit 00cc8225c5
159 changed files with 31 additions and 49 deletions

View file

@ -0,0 +1 @@
from .great_ai import GreatAI

266
great_ai/deploy/great_ai.py Normal file
View file

@ -0,0 +1,266 @@
import inspect
from functools import lru_cache, partial, wraps
from typing import (
Any,
Callable,
Generic,
Iterable,
List,
Optional,
Type,
TypeVar,
cast,
overload,
)
from fastapi import APIRouter, FastAPI, status
from pydantic import BaseModel, create_model
from ..constants import DASHBOARD_PATH
from ..context import get_context
from ..helper import (
freeze_arguments,
get_function_metadata_store,
snake_case_to_text,
use_http_exceptions,
)
from ..models import model_versions
from ..parameters import automatically_decorate_parameters
from ..tracing.tracing_context import TracingContext
from ..utilities import parallel_map
from ..views import ApiMetadata, CacheStatistics, HealthCheckResponse, Trace
from .routes import (
bootstrap_docs_endpoints,
bootstrap_feedback_endpoints,
bootstrap_trace_endpoints,
)
from .routes.bootstrap_dashboard import bootstrap_dashboard
T = TypeVar("T")
class GreatAI(Generic[T]):
def __init__(self, func: Callable[..., Any], version: str, return_raw_result: bool):
is_asynchronous = inspect.iscoroutinefunction(func)
func = automatically_decorate_parameters(func)
get_function_metadata_store(func).is_finalised = True
self._func = func
def func_in_tracing_context_sync(
*args: Any, do_not_persist_traces: bool = False, **kwargs: Any
) -> Trace[T]:
with TracingContext[T](
func.__name__, do_not_persist_traces=do_not_persist_traces
) as t:
result = func(*args, **kwargs)
output = t.finalise(output=result)
return result if return_raw_result else output
async def func_in_tracing_context_async(
*args: Any, do_not_persist_traces: bool = False, **kwargs: Any
) -> Trace[T]:
with TracingContext[T](
func.__name__, do_not_persist_traces=do_not_persist_traces
) as t:
result = await func(*args, **kwargs)
output = t.finalise(output=result)
return result if return_raw_result else output
func_in_tracing_context = (
func_in_tracing_context_async
if is_asynchronous
else func_in_tracing_context_sync
)
self._cached_func = lru_cache(get_context().prediction_cache_size)(
func_in_tracing_context
) # cannot put decorator on method, because it require the context to be setup
wraps(func)(self)
self._version = version
self.app = FastAPI(
title=self.name,
version=self.version,
description=self.documentation
+ f"\n\nFind out more in the [dashboard]({DASHBOARD_PATH}).",
docs_url=None,
redoc_url=None,
)
@overload
@staticmethod
def create(
func: Optional[Callable[..., T]] = None,
) -> "GreatAI[T]":
...
@overload
@staticmethod
def create(
version: str,
return_raw_result: bool,
disable_rest_api: bool,
disable_docs: bool,
disable_dashboard: bool,
) -> Callable[[Callable[..., T]], "GreatAI[T]"]:
...
@staticmethod
def create(
func: Optional[Callable[..., T]] = None,
*,
version: str = "0.0.1",
return_raw_result: bool = False,
disable_rest_api: bool = False,
disable_docs: bool = False,
disable_dashboard: bool = False,
):
if func is None:
return cast(
Callable[[Callable[..., T]], GreatAI[T]],
partial(
GreatAI.create,
version=version,
return_raw_result=return_raw_result,
disable_rest_api=disable_rest_api,
disable_docs=disable_docs,
disable_dashboard=disable_dashboard,
),
)
instance = GreatAI[T](
func, version=version, return_raw_result=return_raw_result
)
if not disable_rest_api:
instance._bootstrap_rest_api(
disable_docs=disable_docs, disable_dashboard=disable_dashboard
)
return instance
@freeze_arguments
def __call__(self, *args: Any, **kwargs: Any) -> Trace[T]:
return self._cached_func(*args, **kwargs)
def process_batch(
self,
batch: Iterable[Any],
concurrency: Optional[int] = None,
do_not_persist_traces: bool = False,
) -> List[Trace[T]]:
return list(
parallel_map(
freeze_arguments(
partial(
self._cached_func, do_not_persist_traces=do_not_persist_traces
)
),
batch,
concurrency=concurrency,
)
)
@property
def name(self) -> str:
return snake_case_to_text(self._func.__name__)
@property
def version(self) -> str:
flat_model_versions = ".".join(f"{k}-v{v}" for k, v in model_versions)
if flat_model_versions:
flat_model_versions = f"+{flat_model_versions}"
return f"{self._version}{flat_model_versions}"
@property
def documentation(self) -> str:
return (
f"GreatAI wrapper for interacting with the `{self._func.__name__}` function.\n\n"
+ (
"\n".join(
line.strip()
for line in (self._func.__doc__ or "").split("\n")
if line.strip()
)
)
)
def _bootstrap_rest_api(self, disable_docs: bool, disable_dashboard: bool) -> None:
self._bootstrap_prediction_endpoint()
if not disable_docs:
bootstrap_docs_endpoints(self.app)
if not disable_dashboard:
bootstrap_dashboard(
self.app,
function_name=self._func.__name__,
documentation=self.documentation,
)
bootstrap_trace_endpoints(self.app)
bootstrap_feedback_endpoints(self.app)
self._bootstrap_meta_endpoints()
def _bootstrap_prediction_endpoint(self) -> None:
router = APIRouter(
tags=["predictions"],
)
schema = self._get_schema()
@router.post(
"/predict", status_code=status.HTTP_200_OK, response_model=Trace[T]
)
@use_http_exceptions
def predict(input_value: schema) -> Trace[T]: # type: ignore
return self(**cast(BaseModel, input_value).dict())
self.app.include_router(router)
def _get_schema(self) -> Type[BaseModel]:
signature = inspect.signature(self._func)
parameters = {
p.name: (
p.annotation if p.annotation != inspect._empty else Any,
p.default if p.default != inspect._empty else ...,
)
for p in signature.parameters.values()
if p.name in get_function_metadata_store(self._func).input_parameter_names
}
schema: Type[BaseModel] = create_model("InputModel", **parameters) # type: ignore
return schema
def _bootstrap_meta_endpoints(self) -> None:
router = APIRouter(
tags=["meta"],
)
@router.get("/health", status_code=status.HTTP_200_OK)
def check_health() -> HealthCheckResponse:
hits, misses, maxsize, cache_size = self._cached_func.cache_info()
cache_statistics = CacheStatistics(
hits=hits, misses=misses, size=cache_size, max_size=maxsize
)
return HealthCheckResponse(
is_healthy=True, cache_statistics=cache_statistics
)
@router.get(
"/version", response_model=ApiMetadata, status_code=status.HTTP_200_OK
)
def get_version() -> ApiMetadata:
return ApiMetadata(
name=self.name,
version=self.version,
documentation=self.documentation,
configuration=get_context().to_flat_dict(),
)
self.app.include_router(router)

View file

@ -0,0 +1,4 @@
from .bootstrap_dashboard import bootstrap_dashboard
from .bootstrap_docs_endpoints import bootstrap_docs_endpoints
from .bootstrap_feedback_endpoints import bootstrap_feedback_endpoints
from .bootstrap_trace_endpoints import bootstrap_trace_endpoints

View file

@ -0,0 +1,27 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.wsgi import WSGIMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from ...constants import DASHBOARD_PATH
from .dashboard import create_dash_app
PATH = Path(__file__).parent.resolve()
def bootstrap_dashboard(app: FastAPI, function_name: str, documentation: str) -> None:
dash_app = create_dash_app(function_name, app.version, documentation)
app.mount(DASHBOARD_PATH, WSGIMiddleware(dash_app))
@app.get("/", include_in_schema=False)
def redirect_to_entrypoint() -> RedirectResponse:
return RedirectResponse(DASHBOARD_PATH)
app.mount(
"/assets",
StaticFiles(directory=PATH / "dashboard/assets"),
name="static",
)

View file

@ -0,0 +1,14 @@
from fastapi import FastAPI
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import RedirectResponse
from starlette.responses import HTMLResponse
def bootstrap_docs_endpoints(app: FastAPI) -> None:
@app.get("/docs", include_in_schema=False)
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_docs() -> RedirectResponse:
return RedirectResponse("/docs")

View file

@ -0,0 +1,44 @@
from typing import Any
from fastapi import APIRouter, FastAPI, HTTPException, Response, status
from ...context import get_context
from ...views import EvaluationFeedbackRequest
def bootstrap_feedback_endpoints(app: FastAPI) -> None:
router = APIRouter(
prefix="/traces/{trace_id}/feedback",
tags=["feedback"],
)
@router.put("/", status_code=status.HTTP_202_ACCEPTED)
def set_feedback(trace_id: str, input: EvaluationFeedbackRequest) -> Response:
trace = get_context().tracing_database.get(trace_id)
if trace is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
trace.feedback = input.feedback
get_context().tracing_database.update(trace_id, trace)
return Response(status_code=status.HTTP_202_ACCEPTED)
@router.get("/", status_code=status.HTTP_200_OK)
def get_feedback(trace_id: str) -> Any:
trace = get_context().tracing_database.get(trace_id)
if trace is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return trace.feedback
@router.delete("/", status_code=status.HTTP_204_NO_CONTENT)
def delete_feedback(trace_id: str) -> Any:
trace = get_context().tracing_database.get(trace_id)
if trace is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
trace.feedback = None
get_context().tracing_database.update(trace_id, trace)
return Response(status_code=status.HTTP_204_NO_CONTENT)
app.include_router(router)

View file

@ -0,0 +1,44 @@
from typing import List
from fastapi import APIRouter, FastAPI, HTTPException, Response, status
from ...context import get_context
from ...views import Query, Trace
def bootstrap_trace_endpoints(app: FastAPI) -> None:
router = APIRouter(
prefix="/traces",
tags=["traces"],
)
@router.post("", status_code=status.HTTP_200_OK, response_model=List[Trace])
def query_traces(
query: Query,
skip: int = 0,
take: int = 100,
) -> List[Trace]:
return get_context().tracing_database.query(
conjunctive_filters=query.filter,
conjunctive_tags=query.conjunctive_tags,
since=query.since,
until=query.until,
has_feedback=query.has_feedback,
sort_by=query.sort,
skip=skip,
take=take,
)[0]
@router.get("/{trace_id}", status_code=status.HTTP_200_OK, response_model=Trace)
def get_trace(trace_id: str) -> Trace:
result = get_context().tracing_database.get(trace_id)
if result is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return result
@router.delete("/{trace_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_trace(trace_id: str) -> Response:
get_context().tracing_database.delete(trace_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
app.include_router(router)

View file

@ -0,0 +1 @@
from .create_dash_app import create_dash_app

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,232 @@
:root {
--background-color: #edf5f6;
--small-padding: 10px;
--medium-padding: 20px;
--large-padding: 40px;
--border-radius: 10px;
--shadow: 0 4px 6px -1px rgb(0 0 0 / 10%), 0 2px 4px -1px rgb(0 0 0 / 6%);
--disclaimer-width: 180px;
--disclaimer-height: 35px;
}
@media (max-width: 900px) {
body {
zoom: 0.8;
}
}
@media (max-width: 550px) {
:root {
--small-padding: 5px;
--medium-padding: 10px;
--large-padding: 20px;
--border-radius: 8px;
}
.environment {
margin-top: calc(-1 * var(--large-padding));
margin-bottom: var(--large-padding);
}
}
@media (min-width: 551px) {
.environment {
position: absolute;
width: var(--disclaimer-width);
height: var(--disclaimer-height);
transform: rotate(-45deg);
top: calc(
var(--disclaimer-width) / 1.4142 - var(--disclaimer-height) / 1.4142
);
left: calc(-1 * var(--disclaimer-height) / 1.4142);
transform-origin: top left;
z-index: 100;
}
}
* {
margin: 0;
box-sizing: border-box;
word-break: break-word;
}
body {
background-color: var(--background-color);
font-family: Arial, Helvetica, sans-serif;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: var(--medium-padding) 0 var(--small-padding) 0;
}
h6 {
margin-top: 0;
font-size: 3rem;
}
html,
body,
#react-entry-point,
main {
height: 100%;
}
main {
padding-top: var(--large-padding);
display: flex;
flex-direction: column;
}
.environment {
color: white;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
main > header,
.configuration-container,
.traces-table-container,
.parallel-coordinates,
main > footer {
padding: var(--large-padding);
flex-shrink: 0;
overflow: hidden;
}
main > header,
.configuration-container,
.traces-table-container,
.parallel-coordinates {
margin: 0 var(--large-padding) var(--large-padding) var(--large-padding);
border-radius: var(--border-radius);
box-shadow: var(--shadow);
background-color: white;
}
main > header {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
}
main > header > div:nth-child(1) {
min-width: 350px;
max-width: 450px;
flex: 1;
}
main > header > div > h1 {
margin-top: 0;
}
.version-tag {
border-radius: var(--border-radius);
background: #ddd;
display: inline-block;
font-size: 1rem;
padding: 3px 6px;
margin-left: var(--small-padding)
}
main > header > *:nth-child(2) {
min-width: 250px;
max-width: 550px;
flex: 1;
}
main > header .placeholder {
opacity: 0.35;
font-size: 1.5rem;
text-align: center;
display: block;
min-width: 250px;
width: 60%;
margin: auto;
}
.configuration-container {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.configuration-item {
padding-left: var(--small-padding);
margin: var(--medium-padding);
}
.configuration-item h4 {
font-weight: bold;
margin: 0 0 var(--small-padding) 0;
}
.traces-table-container {
padding: 0;
}
.traces-table-container header {
padding: var(--large-padding);
}
.traces-table-container header h2 {
margin-top: 0;
}
.dash-filter--case {
display: none;
}
.traces-table-container td > div {
white-space: pre !important;
max-height: 150px !important;
overflow: auto !important;
display: inline-block !important;
text-align: left !important;
}
.traces-table-container th > div {
text-align: left !important;
}
.space-filler {
flex-grow: 1;
}
main > footer {
opacity: 0.35;
margin: 0;
}
main > footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--large-padding);
background-color: #ddd;
position: relative;
}
.parallel-coordinates {
padding: 0;
}
a img {
display: block;
margin-left: var(--large-padding);
width: 64px;
height: 64px;
cursor: pointer;
transition: transform 300ms;
}
a img:hover {
transform: scale(1.1);
}

View file

@ -0,0 +1,259 @@
from math import ceil
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from dash import Dash, dcc, html
from dash.dependencies import Input, Output
from flask import Flask
from ....constants import DASHBOARD_PATH, ONLINE_TAG_NAME
from ....context import get_context
from ....helper import freeze, snake_case_to_text, text_to_hex_color
from ....utilities import unique
from ....views import SortBy, Trace
from .get_description import get_description
from .get_filter_from_datatable import get_filter_from_datatable
from .get_footer import get_footer
from .get_traces_table import get_traces_table
def create_dash_app(function_name: str, version: str, function_docs: str) -> Flask:
accent_color = text_to_hex_color(function_name)
app = Dash(
function_name,
requests_pathname_prefix=DASHBOARD_PATH + "/",
server=Flask(__name__),
title=snake_case_to_text(function_name),
update_title=None,
external_stylesheets=[
"/assets/index.css",
],
)
app.layout = html.Main(
[
html.Div(
html.P("PRODUCTION" if get_context().is_production else "DEVELOPMENT"),
className="environment",
style={"background": accent_color},
),
html.Header(
[
get_description(
function_name=function_name,
version=version,
function_docs=function_docs,
accent_color=accent_color,
),
execution_time_histogram_container := html.Div(),
],
),
configuration_container := html.Div(
className="configuration-container",
),
traces_table_container := html.Div(
[
html.Header(
[
html.H2("Latest traces"),
html.P(
"Recent traces and aggregated metrics are presented below. Try filtering the table."
),
html.A(
"Filtering syntax.",
href="https://dash.plotly.com/datatable/filtering",
target="_blank",
),
]
),
table := get_traces_table(),
],
className="traces-table-container",
),
parallel_coordinates := dcc.Graph(
className="parallel-coordinates", config={"displaylogo": False}
),
html.Div(className="space-filler"),
get_footer(),
interval := dcc.Interval(
interval=4 * 1000, # in milliseconds
),
]
)
@app.callback(
Output(configuration_container, "children"),
Input(interval, "n_intervals"),
)
def update_configuration(
n_intervals: int,
) -> List[html.Div]:
config = get_context().to_flat_dict()
return [
html.Div(
[
html.H4(snake_case_to_text(key)),
html.P(str(value)),
],
className="configuration-item",
style={"border-left": f"2px solid {accent_color}"},
)
for key, value in config.items()
]
@app.callback(
Output(table, "data"),
Output(table, "page_count"),
Output(table, "columns"),
Output(traces_table_container, "style"),
Output(execution_time_histogram_container, "children"),
Output(parallel_coordinates, "figure"),
Output(parallel_coordinates, "style"),
Input(table, "page_current"),
Input(table, "page_size"),
Input(table, "sort_by"),
Input(table, "filter_query"),
Input(interval, "n_intervals"),
)
def update_page(
page_current: int,
page_size: int,
sort_by: List[Dict[str, Union[str, int]]],
filter_query: str,
n_intervals: int,
) -> Tuple[
List[Dict[str, Any]],
int,
List[Dict[str, Sequence[str]]],
Dict[str, Any],
Any,
go.Figure,
Dict[str, Any],
]:
conjunctive_filters = (
[get_filter_from_datatable(f) for f in filter_query.split(" && ")]
if filter_query
else []
)
non_null_conjunctive_filters = [f for f in conjunctive_filters if f is not None]
elements, count = get_context().tracing_database.query(
skip=page_current * page_size,
take=page_size,
conjunctive_filters=non_null_conjunctive_filters,
conjunctive_tags=[ONLINE_TAG_NAME],
sort_by=[SortBy.parse_obj(s) for s in sort_by],
)
columns, style = update_layout(elements[0] if elements else None)
execution_time_histogram, parallel_coords_fig, style = update_charts(
elements=elements, function_name=function_name, accent_color=accent_color
)
return (
[
{k: str(v) for k, v in e.to_flat_dict(include_original=False).items()}
for e in elements
],
max(1, ceil(count / page_size)),
columns,
style,
execution_time_histogram,
parallel_coords_fig,
style,
)
return app.server
def update_layout(
first_element: Optional[Trace],
) -> Tuple[List[Dict[str, Sequence[str]]], Dict[str, Any]]:
if first_element:
keys = list(first_element.to_flat_dict(include_original=False).keys())
header_height = max(len(i.split(":")) for i in keys)
columns = [
{
"name": [""] * (header_height - len(k.split(":")))
+ k.replace("_flat", "").split(":"),
"id": k,
}
for k in keys
]
else:
columns = []
return (
columns,
{"display": "none" if first_element is None else "block"},
)
def update_charts(
elements: List[Trace], function_name: str, accent_color: str
) -> Tuple[Any, go.Figure, Dict[str, Any]]:
if not elements:
return (
html.Span(
f"No traces yet: call your function ({function_name}) to create one.",
className="placeholder",
),
go.Figure(),
{"display": "none"},
)
flat_elements = [e.to_flat_dict(include_original=False) for e in elements]
execution_time_histogram = dcc.Graph(config={"displaylogo": False})
df = pd.DataFrame(flat_elements)
fig = px.histogram(
df,
x="original_execution_time_ms",
labels={"original_execution_time_ms": "Execution time (ms)"},
nbins=20,
height=400,
log_y=True,
color_discrete_sequence=[accent_color],
)
fig.update_layout(
margin=dict(l=0, r=0, b=0, t=0, pad=0),
)
execution_time_histogram.figure = fig
parallel_coords_fig = go.Figure(
go.Parcoords(
dimensions=[
get_dimension_descriptor(df, c)
for c in df.columns
if c not in {"trace_id", "created", "output", "exception", "feedback"}
and "_flat" not in c
],
line_color=accent_color,
)
)
return execution_time_histogram, parallel_coords_fig, {}
def get_dimension_descriptor(df: pd.DataFrame, column: str) -> Dict[str, Any]:
dimension: Dict[str, Any] = {
"label": snake_case_to_text(column),
}
values = df[column]
try:
dimension["values"] = [float(v) for v in values]
except (TypeError, ValueError):
MAX_LENGTH = 40
unique_values = unique(values, key=freeze)
value_mapping = {str(v)[:MAX_LENGTH]: i for i, v in enumerate(unique_values)}
dimension["values"] = [value_mapping[str(v)[:MAX_LENGTH]] for v in values]
dimension["tickvals"] = list(value_mapping.values())
dimension["ticktext"] = [k[:MAX_LENGTH] for k in value_mapping.keys()]
return dimension

View file

@ -0,0 +1,35 @@
from dash import dcc, html
from ....helper import snake_case_to_text, strip_lines
def get_description(
function_name: str, version: str, function_docs: str, accent_color: str
) -> html.Div:
return html.Div(
[
html.H1(
[
f"{snake_case_to_text(function_name)} - dashboard",
html.Span(version, className="version-tag"),
],
style={"color": accent_color},
),
dcc.Markdown(
strip_lines(
f"""
> View the live data of your deployment here.
## Using the API
You can find the available endpoints at [/docs](/docs).
## Details
{function_docs}
"""
),
className="description",
),
]
)

View file

@ -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

View file

@ -0,0 +1,23 @@
from dash import html
from ....constants import GITHUB_LINK
def get_footer() -> html.Footer:
return html.Footer(
[
html.Div(
[
html.H6("GreatAI"),
html.P(
"A human-friendly framework for robust end-to-end AI deployments."
),
]
),
html.A(
html.Img(src="/assets/github.png"),
href=GITHUB_LINK,
target="_blank",
),
],
)

View file

@ -0,0 +1,34 @@
from dash import dash_table
from ....context import get_context
def get_traces_table() -> dash_table.DataTable:
return dash_table.DataTable(
page_current=0,
page_size=get_context().dashboard_table_size,
page_action="custom",
filter_action="custom",
sort_action="custom",
sort_mode="multi",
sort_by=[
{"column_id": "created", "direction": "desc"},
],
style_data={
"white-space": "normal",
"height": "auto",
"max-height": "300px",
"overflow": "hidden",
"text-overflow": "ellipsis",
},
style_cell={"padding": "5px"},
style_header={
"background-color": "white",
"font-weight": "bold",
},
merge_duplicate_headers=True,
style_cell_conditional=[
{"if": {"column_id": "output"}, "width": 1500},
],
style_table={"overflow": "auto"},
)