Move files
This commit is contained in:
parent
3cf28379e8
commit
00cc8225c5
159 changed files with 31 additions and 49 deletions
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
|
@ -43,7 +43,7 @@ jobs:
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v2
|
||||||
with:
|
with:
|
||||||
name: Unit test coverage (Python ${{ matrix.python-version }})
|
name: Unit test coverage (Python ${{ matrix.python-version }} - ${{ matrix.operating-system }})
|
||||||
path: "*.xml"
|
path: "*.xml"
|
||||||
|
|
||||||
sonar:
|
sonar:
|
||||||
|
|
|
||||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -6,6 +6,7 @@
|
||||||
"botocore",
|
"botocore",
|
||||||
"Convolutional",
|
"Convolutional",
|
||||||
"datatable",
|
"datatable",
|
||||||
|
"deduplicated",
|
||||||
"displaylogo",
|
"displaylogo",
|
||||||
"downsample",
|
"downsample",
|
||||||
"fastapi",
|
"fastapi",
|
||||||
|
|
|
||||||
23
great_ai/__init__.py
Normal file
23
great_ai/__init__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
from .context import configure
|
||||||
|
from .deploy import GreatAI
|
||||||
|
from .exceptions import (
|
||||||
|
ArgumentValidationError,
|
||||||
|
MissingArgumentError,
|
||||||
|
WrongDecoratorOrderError,
|
||||||
|
)
|
||||||
|
from .models import save_model, use_model
|
||||||
|
from .output_views import (
|
||||||
|
ClassificationOutput,
|
||||||
|
MultiLabelClassificationOutput,
|
||||||
|
RegressionOutput,
|
||||||
|
)
|
||||||
|
from .parameters import log_metric, parameter
|
||||||
|
from .persistence import MongodbDriver, ParallelTinyDbDriver, TracingDatabaseDriver
|
||||||
|
from .remote import (
|
||||||
|
HttpClient,
|
||||||
|
RemoteCallError,
|
||||||
|
call_remote_great_ai,
|
||||||
|
call_remote_great_ai_async,
|
||||||
|
)
|
||||||
|
from .tracing import add_ground_truth, delete_ground_truth, query_ground_truth
|
||||||
|
from .views import Trace
|
||||||
208
great_ai/__main__.py
Normal file
208
great_ai/__main__.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from importlib import import_module, reload
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Event
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from parse_arguments import parse_arguments
|
||||||
|
from uvicorn._subprocess import get_subprocess
|
||||||
|
from uvicorn.config import LOGGING_CONFIG, Config
|
||||||
|
from uvicorn.supervisors.basereload import BaseReload
|
||||||
|
from watchdog.events import FileSystemEvent, PatternMatchingEventHandler
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
|
||||||
|
from great_ai.constants import SERVER_NAME
|
||||||
|
from great_ai.context import _is_in_production_mode
|
||||||
|
from great_ai.deploy import GreatAI
|
||||||
|
from great_ai.exceptions import ArgumentValidationError, MissingArgumentError
|
||||||
|
from great_ai.utilities import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(SERVER_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
GREAT_AI_LOGGING_CONFIG = {
|
||||||
|
**LOGGING_CONFIG,
|
||||||
|
"formatters": {
|
||||||
|
"default": {
|
||||||
|
"()": "great_ai.logger.CustomFormatter",
|
||||||
|
"fmt": "%(asctime)s | %(levelname)8s | %(message)s",
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"()": "great_ai.logger.CustomFormatter",
|
||||||
|
"fmt": "%(asctime)s | %(levelname)8s | %(message)s", # noqa: E501
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = parse_arguments()
|
||||||
|
should_auto_reload = not _is_in_production_mode(logger=None)
|
||||||
|
|
||||||
|
if args.worker_count > 1 and should_auto_reload:
|
||||||
|
raise ArgumentValidationError(
|
||||||
|
"Cannot use auto-reload with multiple worker_count: set the `--worker_count=1` CLI argument,"
|
||||||
|
+ "or set the ENVIRONMENT environment variable to `production`."
|
||||||
|
)
|
||||||
|
|
||||||
|
common_config = dict(
|
||||||
|
host=args.host,
|
||||||
|
port=args.port,
|
||||||
|
timeout_keep_alive=args.timeout_keep_alive,
|
||||||
|
workers=args.worker_count,
|
||||||
|
server_header=False,
|
||||||
|
reload=False,
|
||||||
|
log_config=GREAT_AI_LOGGING_CONFIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not should_auto_reload:
|
||||||
|
file_name = get_script_name(args.file_name)
|
||||||
|
app = find_app(file_name)
|
||||||
|
|
||||||
|
logger.info(f"Starting uvicorn server with app={app}")
|
||||||
|
|
||||||
|
config = Config(app, **common_config)
|
||||||
|
socket = config.bind_socket()
|
||||||
|
server = GreatAIReload(
|
||||||
|
config, target=uvicorn.Server(config=config).run, sockets=[socket]
|
||||||
|
)
|
||||||
|
|
||||||
|
server.startup()
|
||||||
|
try:
|
||||||
|
Event().wait()
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
if args.file_name.endswith(".ipynb"):
|
||||||
|
Path(get_script_name_of_notebook(args.file_name)).unlink(
|
||||||
|
missing_ok=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
|
||||||
|
class EventHandler(PatternMatchingEventHandler):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(
|
||||||
|
patterns=["*.py", "*.ipynb"], ignore_patterns=["__*.py"]
|
||||||
|
)
|
||||||
|
self.server: Optional[GreatAIReload] = None
|
||||||
|
self.restart()
|
||||||
|
|
||||||
|
def on_closed(self, event: FileSystemEvent) -> None:
|
||||||
|
logger.warning(f"File {event.src_path} has triggered a restart")
|
||||||
|
self.restart()
|
||||||
|
|
||||||
|
def restart(self) -> None:
|
||||||
|
file_name = get_script_name(args.file_name)
|
||||||
|
app = find_app(file_name)
|
||||||
|
if app is None:
|
||||||
|
logger.warning("Auto-reloading skipped")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stop_server()
|
||||||
|
|
||||||
|
config = Config(app, **common_config)
|
||||||
|
socket = config.bind_socket()
|
||||||
|
self.server = GreatAIReload(
|
||||||
|
config, target=uvicorn.Server(config=config).run, sockets=[socket]
|
||||||
|
)
|
||||||
|
self.server.startup()
|
||||||
|
|
||||||
|
def stop_server(self) -> None:
|
||||||
|
if self.server:
|
||||||
|
self.server.shutdown()
|
||||||
|
|
||||||
|
restart_handler = EventHandler()
|
||||||
|
observer = Observer()
|
||||||
|
observer.schedule(restart_handler, path=".", recursive=True)
|
||||||
|
observer.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
Event().wait()
|
||||||
|
finally:
|
||||||
|
observer.stop()
|
||||||
|
restart_handler.stop_server()
|
||||||
|
if args.file_name.endswith(".ipynb"):
|
||||||
|
Path(get_script_name_of_notebook(args.file_name)).unlink(
|
||||||
|
missing_ok=True
|
||||||
|
)
|
||||||
|
observer.join()
|
||||||
|
|
||||||
|
|
||||||
|
def get_script_name(file_name_argument: str) -> str:
|
||||||
|
if file_name_argument.endswith(".ipynb"):
|
||||||
|
logger.info("Converting notebook to Python script")
|
||||||
|
from nbconvert import PythonExporter
|
||||||
|
|
||||||
|
exporter = PythonExporter()
|
||||||
|
content, _ = exporter.from_filename(file_name_argument)
|
||||||
|
file_name_argument = get_script_name_of_notebook(file_name_argument)
|
||||||
|
|
||||||
|
with open(file_name_argument, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
return re.sub(r"\.(py|ipynb)$", "", file_name_argument)
|
||||||
|
|
||||||
|
|
||||||
|
def get_script_name_of_notebook(notebook_name: str) -> str:
|
||||||
|
base_name = re.sub(r"\.ipynb$", "", notebook_name)
|
||||||
|
return f"__{base_name}__.py"
|
||||||
|
|
||||||
|
|
||||||
|
module = None
|
||||||
|
|
||||||
|
|
||||||
|
def find_app(file_name: str) -> Optional[str]:
|
||||||
|
global module
|
||||||
|
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
try:
|
||||||
|
if module is None:
|
||||||
|
module = import_module(file_name)
|
||||||
|
else:
|
||||||
|
module = reload(module)
|
||||||
|
except Exception:
|
||||||
|
logging.disable(logging.NOTSET)
|
||||||
|
logger.exception("Could not load file because of an exception: fix your code")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
logging.disable(logging.NOTSET)
|
||||||
|
|
||||||
|
for name, value in module.__dict__.items():
|
||||||
|
if isinstance(value, GreatAI):
|
||||||
|
app_name = name
|
||||||
|
|
||||||
|
if app_name:
|
||||||
|
logger.info(f"Found `{app_name}` to be the GreatAI app ")
|
||||||
|
else:
|
||||||
|
raise MissingArgumentError(
|
||||||
|
"GreatAI app could not be found, make sure to use `@GreatAI.deploy` on your prediction function"
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"{file_name}:{app_name}.app"
|
||||||
|
|
||||||
|
|
||||||
|
class GreatAIReload(BaseReload):
|
||||||
|
def startup(self) -> None:
|
||||||
|
self.process = get_subprocess(
|
||||||
|
config=self.config, target=self.target, sockets=self.sockets
|
||||||
|
)
|
||||||
|
self.process.start()
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
self.process.terminate()
|
||||||
|
self.process.join()
|
||||||
|
|
||||||
|
for sock in self.sockets:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
exit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
31
great_ai/constants.py
Normal file
31
great_ai/constants.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
from .large_file import LargeFileMongo, LargeFileS3
|
||||||
|
from .persistence.mongodb_driver import MongodbDriver
|
||||||
|
|
||||||
|
ENV_VAR_KEY = "ENVIRONMENT"
|
||||||
|
PRODUCTION_KEY = "production"
|
||||||
|
DASHBOARD_PATH = "/dashboard"
|
||||||
|
|
||||||
|
MONGO_CONFIG_PATHS = ["mongodb.ini", "mongo.ini", "mongo_db.ini", "mongo-db.ini"]
|
||||||
|
DEFAULT_TRACING_DATABASE_CONFIG_PATHS = {
|
||||||
|
MongodbDriver: MONGO_CONFIG_PATHS,
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_LARGE_FILE_CONFIG_PATHS = {
|
||||||
|
LargeFileS3: ["s3.ini", "b2.ini"],
|
||||||
|
LargeFileMongo: MONGO_CONFIG_PATHS,
|
||||||
|
}
|
||||||
|
|
||||||
|
GITHUB_LINK = "https://github.com/ScoutinScience/great_ai"
|
||||||
|
|
||||||
|
TRAIN_SPLIT_TAG_NAME = "train"
|
||||||
|
TEST_SPLIT_TAG_NAME = "test"
|
||||||
|
VALIDATION_SPLIT_TAG_NAME = "validation"
|
||||||
|
GROUND_TRUTH_TAG_NAME = "ground_truth"
|
||||||
|
PRODUCTION_TAG_NAME = "production"
|
||||||
|
DEVELOPMENT_TAG_NAME = "development"
|
||||||
|
ONLINE_TAG_NAME = "online"
|
||||||
|
|
||||||
|
SERVER_NAME = "GreatAI-Server"
|
||||||
|
|
||||||
|
SE4ML_WEBSITE = "https://se-ml.github.io/practices/"
|
||||||
|
LIST_ITEM_PREFIX = " 🔩 "
|
||||||
191
great_ai/context.py
Normal file
191
great_ai/context.py
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
from logging import DEBUG, Logger
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional, Type, cast
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .constants import (
|
||||||
|
DEFAULT_LARGE_FILE_CONFIG_PATHS,
|
||||||
|
DEFAULT_TRACING_DATABASE_CONFIG_PATHS,
|
||||||
|
ENV_VAR_KEY,
|
||||||
|
LIST_ITEM_PREFIX,
|
||||||
|
PRODUCTION_KEY,
|
||||||
|
SE4ML_WEBSITE,
|
||||||
|
)
|
||||||
|
from .large_file import LargeFileBase, LargeFileLocal
|
||||||
|
from .persistence import ParallelTinyDbDriver, TracingDatabaseDriver
|
||||||
|
from .utilities import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
class Context(BaseModel):
|
||||||
|
tracing_database: TracingDatabaseDriver
|
||||||
|
large_file_implementation: Type[LargeFileBase]
|
||||||
|
is_production: bool
|
||||||
|
logger: Logger
|
||||||
|
should_log_exception_stack: bool
|
||||||
|
prediction_cache_size: int
|
||||||
|
dashboard_table_size: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
arbitrary_types_allowed = True
|
||||||
|
|
||||||
|
def to_flat_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"tracing_database": type(self.tracing_database).__name__,
|
||||||
|
"large_file_implementation": self.large_file_implementation.__name__,
|
||||||
|
"is_production": self.is_production,
|
||||||
|
"should_log_exception_stack": self.should_log_exception_stack,
|
||||||
|
"prediction_cache_size": self.prediction_cache_size,
|
||||||
|
"dashboard_table_size": self.dashboard_table_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_context: Optional[Context] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_context() -> Context:
|
||||||
|
if _context is None:
|
||||||
|
configure()
|
||||||
|
|
||||||
|
return cast(Context, _context)
|
||||||
|
|
||||||
|
|
||||||
|
def configure(
|
||||||
|
*,
|
||||||
|
log_level: int = DEBUG,
|
||||||
|
seed: int = 42,
|
||||||
|
tracing_database: Optional[Type[TracingDatabaseDriver]] = None,
|
||||||
|
large_file_implementation: Optional[Type[LargeFileBase]] = None,
|
||||||
|
should_log_exception_stack: Optional[bool] = None,
|
||||||
|
prediction_cache_size: int = 512,
|
||||||
|
disable_se4ml_banner: bool = False,
|
||||||
|
dashboard_table_size: int = 50,
|
||||||
|
) -> None:
|
||||||
|
global _context
|
||||||
|
logger = get_logger("great_ai", level=log_level)
|
||||||
|
|
||||||
|
if _context is not None:
|
||||||
|
logger.warn(
|
||||||
|
"Configuration has been already initialised, overwriting.\n"
|
||||||
|
+ "Make sure to call `configure()` before importing your application code."
|
||||||
|
)
|
||||||
|
|
||||||
|
is_production = _is_in_production_mode(logger=logger)
|
||||||
|
|
||||||
|
_set_seed(seed)
|
||||||
|
|
||||||
|
tracing_database = _initialize_tracing_database(tracing_database, logger=logger)()
|
||||||
|
|
||||||
|
if not tracing_database.is_production_ready:
|
||||||
|
if is_production:
|
||||||
|
logger.error(
|
||||||
|
f"The selected tracing database ({type(tracing_database).__name__}) is not recommended for production"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"The selected tracing database ({type(tracing_database).__name__}) is not recommended for production"
|
||||||
|
)
|
||||||
|
|
||||||
|
_context = Context(
|
||||||
|
tracing_database=tracing_database,
|
||||||
|
large_file_implementation=_initialize_large_file(
|
||||||
|
large_file_implementation, logger=logger
|
||||||
|
),
|
||||||
|
is_production=is_production,
|
||||||
|
logger=logger,
|
||||||
|
should_log_exception_stack=not is_production
|
||||||
|
if should_log_exception_stack is None
|
||||||
|
else should_log_exception_stack,
|
||||||
|
prediction_cache_size=prediction_cache_size,
|
||||||
|
dashboard_table_size=dashboard_table_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Settings: configured ✅")
|
||||||
|
for k, v in get_context().to_flat_dict().items():
|
||||||
|
logger.info(f"{LIST_ITEM_PREFIX}{k}: {v}")
|
||||||
|
|
||||||
|
if not is_production and not disable_se4ml_banner:
|
||||||
|
logger.warning(
|
||||||
|
"You still need to check whether you follow all best practices before trusting your deployment."
|
||||||
|
)
|
||||||
|
logger.warning(f"> Find out more at {SE4ML_WEBSITE}")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_in_production_mode(logger: Optional[Logger]) -> bool:
|
||||||
|
environment = os.environ.get(ENV_VAR_KEY)
|
||||||
|
|
||||||
|
if environment is None:
|
||||||
|
if logger:
|
||||||
|
logger.warning(
|
||||||
|
f"Environment variable {ENV_VAR_KEY} is not set, defaulting to development mode ‼️"
|
||||||
|
)
|
||||||
|
is_production = False
|
||||||
|
else:
|
||||||
|
is_production = environment.lower() == PRODUCTION_KEY
|
||||||
|
if logger:
|
||||||
|
if not is_production:
|
||||||
|
logger.info(
|
||||||
|
f"Value of {ENV_VAR_KEY} is `{environment}` which is not equal to `{PRODUCTION_KEY}`"
|
||||||
|
+ " defaulting to development mode ‼️"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Running in production mode ✅")
|
||||||
|
|
||||||
|
return is_production
|
||||||
|
|
||||||
|
|
||||||
|
def _initialize_tracing_database(
|
||||||
|
selected: Optional[Type[TracingDatabaseDriver]], logger: Logger
|
||||||
|
) -> Type[TracingDatabaseDriver]:
|
||||||
|
for tracing_driver, paths in DEFAULT_TRACING_DATABASE_CONFIG_PATHS.items():
|
||||||
|
if selected is None or selected == tracing_driver:
|
||||||
|
if tracing_driver.initialized:
|
||||||
|
logger.warning(
|
||||||
|
f"{tracing_driver.__name__} has been already configured: skipping initialisation"
|
||||||
|
)
|
||||||
|
return tracing_driver
|
||||||
|
for p in paths:
|
||||||
|
if Path(p).exists():
|
||||||
|
logger.info(
|
||||||
|
f"Found credentials file ({Path(p).absolute()}), initialising {tracing_driver.__name__}"
|
||||||
|
)
|
||||||
|
tracing_driver.configure_credentials_from_file(p)
|
||||||
|
return tracing_driver
|
||||||
|
logger.warning(
|
||||||
|
"Cannot find credentials files, defaulting to using ParallelTinyDbDriver"
|
||||||
|
)
|
||||||
|
return ParallelTinyDbDriver
|
||||||
|
|
||||||
|
|
||||||
|
def _initialize_large_file(
|
||||||
|
selected: Optional[Type[LargeFileBase]], logger: Logger
|
||||||
|
) -> Type[LargeFileBase]:
|
||||||
|
for large_file, paths in DEFAULT_LARGE_FILE_CONFIG_PATHS.items():
|
||||||
|
if selected is None or selected == large_file:
|
||||||
|
if large_file.initialized:
|
||||||
|
logger.warning(
|
||||||
|
f"{large_file.__name__} has been already configured: skipping initialisation"
|
||||||
|
)
|
||||||
|
return large_file
|
||||||
|
for p in paths:
|
||||||
|
if Path(p).exists():
|
||||||
|
logger.info(
|
||||||
|
f"Found credentials file ({Path(p).absolute()}), initialising {large_file.__name__}"
|
||||||
|
)
|
||||||
|
large_file.configure_credentials_from_file(p)
|
||||||
|
return large_file
|
||||||
|
logger.warning("Cannot find credentials files, defaulting to using LargeFileLocal")
|
||||||
|
return LargeFileLocal
|
||||||
|
|
||||||
|
|
||||||
|
def _set_seed(seed: int) -> None:
|
||||||
|
random.seed(seed)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import numpy
|
||||||
|
|
||||||
|
numpy.random.seed(seed + 1)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
1
great_ai/deploy/__init__.py
Normal file
1
great_ai/deploy/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from .great_ai import GreatAI
|
||||||
266
great_ai/deploy/great_ai.py
Normal file
266
great_ai/deploy/great_ai.py
Normal 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)
|
||||||
4
great_ai/deploy/routes/__init__.py
Normal file
4
great_ai/deploy/routes/__init__.py
Normal 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
|
||||||
27
great_ai/deploy/routes/bootstrap_dashboard.py
Normal file
27
great_ai/deploy/routes/bootstrap_dashboard.py
Normal 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",
|
||||||
|
)
|
||||||
14
great_ai/deploy/routes/bootstrap_docs_endpoints.py
Normal file
14
great_ai/deploy/routes/bootstrap_docs_endpoints.py
Normal 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")
|
||||||
44
great_ai/deploy/routes/bootstrap_feedback_endpoints.py
Normal file
44
great_ai/deploy/routes/bootstrap_feedback_endpoints.py
Normal 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)
|
||||||
44
great_ai/deploy/routes/bootstrap_trace_endpoints.py
Normal file
44
great_ai/deploy/routes/bootstrap_trace_endpoints.py
Normal 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)
|
||||||
1
great_ai/deploy/routes/dashboard/__init__.py
Normal file
1
great_ai/deploy/routes/dashboard/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from .create_dash_app import create_dash_app
|
||||||
0
great_ai/deploy/routes/dashboard/assets/__init__.py
Normal file
0
great_ai/deploy/routes/dashboard/assets/__init__.py
Normal file
BIN
great_ai/deploy/routes/dashboard/assets/github.png
Normal file
BIN
great_ai/deploy/routes/dashboard/assets/github.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
232
great_ai/deploy/routes/dashboard/assets/index.css
Normal file
232
great_ai/deploy/routes/dashboard/assets/index.css
Normal 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);
|
||||||
|
}
|
||||||
259
great_ai/deploy/routes/dashboard/create_dash_app.py
Normal file
259
great_ai/deploy/routes/dashboard/create_dash_app.py
Normal 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
|
||||||
35
great_ai/deploy/routes/dashboard/get_description.py
Normal file
35
great_ai/deploy/routes/dashboard/get_description.py
Normal 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",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue