Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add flow test http api #2089

Closed
wants to merge 56 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
90122d4
update
YingChen1996 Feb 23, 2024
2b2a189
update
YingChen1996 Feb 23, 2024
c325446
remove no need logic
YingChen1996 Feb 23, 2024
2600eca
update according to comment
YingChen1996 Feb 23, 2024
ba261fd
add get flow yaml and experiment list
YingChen1996 Feb 23, 2024
f8c4223
add image and ux_input api
YingChen1996 Feb 26, 2024
b9a0d37
add test
YingChen1996 Feb 26, 2024
36a93b9
update swagger
YingChen1996 Feb 26, 2024
8df80d4
flake8
Feb 27, 2024
cf860b3
Merge branch 'main' into chenyin/add_pfs_api
Feb 27, 2024
a4e7207
Merge branch 'main' into chenyin/add_pfs_api
YingChen1996 Feb 27, 2024
c912570
update test
YingChen1996 Feb 27, 2024
2f2df8e
update test
YingChen1996 Feb 27, 2024
53d9974
update according to comment
YingChen1996 Feb 27, 2024
6d6734b
update swagger
YingChen1996 Feb 27, 2024
634106c
flake8
YingChen1996 Feb 27, 2024
e6a97b1
Merge branch 'main' into chenyin/add_pfs_api
YingChen1996 Feb 29, 2024
f9c8a2f
udpate swagger
YingChen1996 Feb 29, 2024
8f03bd7
change get request
YingChen1996 Mar 1, 2024
a407a10
update
YingChen1996 Mar 1, 2024
a376cfe
update
YingChen1996 Mar 1, 2024
0c5f70e
Merge branch 'main' into chenyin/add_pfs_api
YingChen1996 Mar 1, 2024
b2c9cbe
update swagger
YingChen1996 Mar 1, 2024
c13021f
add indent
YingChen1996 Mar 1, 2024
ba34efd
flake8
YingChen1996 Mar 1, 2024
9bee9da
restrict image path
YingChen1996 Mar 4, 2024
c6c80fc
update image api and update test
YingChen1996 Mar 4, 2024
35c7ee8
Merge branch 'main' into chenyin/add_pfs_api
YingChen1996 Mar 4, 2024
e5a0e98
support flow detail log and encode flow
YingChen1996 Mar 5, 2024
d60aae9
add template
YingChen1996 Mar 5, 2024
ae525d1
Merge branch 'main' into chenyin/add_pfs_api
YingChen1996 Mar 5, 2024
8f00238
typo
YingChen1996 Mar 5, 2024
6527db2
typo
YingChen1996 Mar 5, 2024
a774b5e
save to file and return content
YingChen1996 Mar 7, 2024
2302db9
update swagger
YingChen1996 Mar 7, 2024
2143c35
revert
YingChen1996 Mar 7, 2024
c5f7a72
typo
YingChen1996 Mar 7, 2024
28c1658
update swagger
YingChen1996 Mar 7, 2024
d32789d
print url
YingChen1996 Mar 8, 2024
6b74d42
add feature list
Mar 14, 2024
3a06e4e
Merge branch 'main' into chenyin/add_pfs_api
Mar 14, 2024
3817e56
update according to comment
Mar 14, 2024
ea1d44c
use hash to name file
Mar 14, 2024
63eb6cb
Merge branch 'main' into chenyin/add_pfs_api
Mar 14, 2024
8d8aadb
typo
YingChen1996 Mar 14, 2024
8f314d0
update swagger
YingChen1996 Mar 14, 2024
2a84cb7
Merge branch 'main' into chenyin/add_pfs_api
YingChen1996 Mar 14, 2024
0cc83be
update util fucntion
Mar 14, 2024
0da2243
add ux file
YingChen1996 Mar 15, 2024
ad1fa1d
cspeel
YingChen1996 Mar 15, 2024
7fda635
Merge branch 'main' into dev/chenyin/add_pfs_api
YingChen1996 Mar 15, 2024
ab1b626
template file
YingChen1996 Mar 15, 2024
564fda3
str inner exception to avoid json error in flask response
YingChen1996 Mar 18, 2024
a492635
update swagger
YingChen1996 Mar 18, 2024
a00aa6c
Merge branch 'main' into dev/chenyin/add_pfs_api
YingChen1996 Mar 18, 2024
ef73930
fix test
YingChen1996 Mar 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
],
"ignorePaths": [
"**/*.js",
"**/*.mjs",
"**/*.css",
"**/*.pyc",
"**/*.log",
"**/*.jsonl",
Expand Down
38 changes: 18 additions & 20 deletions src/promptflow/promptflow/_cli/_pf/_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import tempfile
import webbrowser
from pathlib import Path
from urllib.parse import urlencode, urlunparse

from promptflow._cli._params import (
add_param_config,
Expand All @@ -31,7 +32,6 @@
ChatFlowDAGGenerator,
FlowDAGGenerator,
OpenAIConnectionGenerator,
StreamlitFileReplicator,
ToolMetaGenerator,
ToolPyGenerator,
copy_extra_files,
Expand All @@ -41,6 +41,7 @@
from promptflow._sdk._configuration import Configuration
from promptflow._sdk._constants import PROMPT_FLOW_DIR_NAME, ConnectionProvider
from promptflow._sdk._pf_client import PFClient
from promptflow._sdk._service.utils.utils import encrypt_flow_path
from promptflow._sdk.operations._flow_operations import FlowOperations
from promptflow._utils.logger_utils import get_cli_sdk_logger
from promptflow.exceptions import ErrorTarget, UserErrorException
Expand Down Expand Up @@ -393,7 +394,7 @@ def test_flow(args):
_test_flow_experiment(args, pf_client, inputs, environment_variables)
return
if args.multi_modal or args.ui:
_test_flow_multi_modal(args, pf_client)
_test_flow_multi_modal(args)
return
if args.interactive:
_test_flow_interactive(args, pf_client, inputs, environment_variables)
Expand All @@ -420,26 +421,23 @@ def _build_inputs_for_flow_test(args):
return inputs


def _test_flow_multi_modal(args, pf_client):
def _test_flow_multi_modal(args):
"""Test flow with multi modality mode."""
from promptflow._sdk._load_functions import load_flow

with tempfile.TemporaryDirectory() as temp_dir:
flow = load_flow(args.flow)

script_path = [
os.path.join(temp_dir, "main.py"),
os.path.join(temp_dir, "utils.py"),
os.path.join(temp_dir, "logo.png"),
]
for script in script_path:
StreamlitFileReplicator(
flow_name=flow.display_name if flow.display_name else flow.name,
flow_dag_path=flow.flow_dag_path,
).generate_to_file(script)
main_script_path = os.path.join(temp_dir, "main.py")
logger.info("Start streamlit with main script generated at: %s", main_script_path)
pf_client.flows._chat_with_ui(script=main_script_path, skip_open_browser=args.skip_open_browser)
from promptflow._sdk._tracing import _invoke_pf_svc

# Todo: use base64 encode for now, will consider whether need use encryption or use db to store flow path info
def generate_url(flow_path, port):
encrypted_flow_path = encrypt_flow_path(flow_path)
query_params = urlencode({"flow": encrypted_flow_path})
return urlunparse(("http", f"127.0.0.1:{port}", "/v1.0/ui/chat", "", query_params, ""))

pfs_port = _invoke_pf_svc()
flow = load_flow(args.flow)
flow_dir = os.path.abspath(flow.code)
chat_page_url = generate_url(flow_dir, pfs_port)
print(f"You can begin chat flow on {chat_page_url}")
webbrowser.open(chat_page_url)


def _test_flow_interactive(args, pf_client, inputs, environment_variables):
Expand Down
1 change: 1 addition & 0 deletions src/promptflow/promptflow/_sdk/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ def _prepare_home_dir() -> Path:
SPAN_TABLENAME = "span"
PFS_MODEL_DATETIME_FORMAT = "iso8601"

UX_INPUTS_JSON = "ux.inputs.json"
AzureMLWorkspaceTriad = namedtuple("AzureMLWorkspace", ["subscription_id", "resource_group_name", "workspace_name"])

# chat group
Expand Down
32 changes: 32 additions & 0 deletions src/promptflow/promptflow/_sdk/_service/apis/experiment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# ---------------------------------------------------------
YingChen1996 marked this conversation as resolved.
Show resolved Hide resolved
# Copyright (c) Microsoft Corporation. All rights reserved.
# ---------------------------------------------------------
from flask import jsonify, request

from promptflow._sdk._constants import get_list_view_type
from promptflow._sdk._service import Namespace, Resource
from promptflow._sdk._service.utils.utils import get_client_from_request

api = Namespace("Experiments", description="Experiments Management")

# Response model of experiment operation
dict_field = api.schema_model("ExperimentDict", {"additionalProperties": True, "type": "object"})
wangchao1230 marked this conversation as resolved.
Show resolved Hide resolved
list_field = api.schema_model("ExperimentList", {"type": "array", "items": {"$ref": "#/definitions/ExperimentDict"}})


@api.route("/")
class ExperimentList(Resource):
@api.response(code=200, description="Experiments", model=list_field)
@api.doc(description="List all experiments")
def get(self):
# parse query parameters
max_results = request.args.get("max_results", default=50, type=int)
archived_only = request.args.get("archived_only", default=False, type=bool)
include_archived = request.args.get("include_archived", default=False, type=bool)
list_view_type = get_list_view_type(archived_only=archived_only, include_archived=include_archived)

experiments = get_client_from_request()._experiments.list(
max_results=max_results, list_view_type=list_view_type
)
experiments_dict = [experiment._to_dict() for experiment in experiments]
return jsonify(experiments_dict)
145 changes: 145 additions & 0 deletions src/promptflow/promptflow/_sdk/_service/apis/flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# ---------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# ---------------------------------------------------------
import json
import os
import shutil
import uuid
from pathlib import Path

from flask import make_response
from flask_restx import reqparse

from promptflow._sdk._constants import DEFAULT_ENCODING, PROMPT_FLOW_DIR_NAME, UX_INPUTS_JSON
from promptflow._sdk._service import Namespace, Resource, fields
from promptflow._sdk._service.utils.utils import decrypt_flow_path, get_client_from_request
from promptflow._sdk._utils import json_load, read_write_by_user
from promptflow._utils.flow_utils import resolve_flow_path
from promptflow._utils.yaml_utils import load_yaml
from promptflow.exceptions import UserErrorException

api = Namespace("Flows", description="Flows Management")


dict_field = api.schema_model("FlowDict", {"additionalProperties": True, "type": "object"})

flow_test_model = api.model(
"FlowTest",
{
"node": fields.String(
required=False, description="If specified it will only test this node, else it will " "test the flow."
),
"variant": fields.String(
required=False,
description="Node & variant name in format of ${"
"node_name.variant_name}, will use default variant if "
"not specified.",
),
"output_path": fields.String(required=False, description="Output path of flow"),
"experiment": fields.String(required=False, description="Path of experiment template"),
"inputs": fields.Nested(dict_field, required=False),
"environment_variables": fields.Nested(dict_field, required=False),
},
)

flow_ux_input_model = api.model(
"FlowUxInput",
{
"flow": fields.String(required=True, description="Path to flow directory."),
"ux_inputs": fields.Nested(dict_field, required=True, description="Flow ux inputs"),
},
)

flow_path_parser = reqparse.RequestParser()
flow_path_parser.add_argument("flow", type=str, required=True, location="args", help="Path to flow directory.")


@api.route("/test")
class FlowTest(Resource):
@api.response(code=200, description="Flow test", model=dict_field)
@api.doc(description="Flow test")
@api.expect(flow_test_model)
def post(self):
args = flow_path_parser.parse_args()
flow = args.flow
flow = decrypt_flow_path(flow)
inputs = api.payload.get("inputs", None)
environment_variables = api.payload.get("environment_variables", None)
variant = api.payload.get("variant", None)
node = api.payload.get("node", None)
experiment = api.payload.get("experiment", None)
output_path = api.payload.get("output_path", None)
remove_dir = False

if output_path is None:
filename = str(uuid.uuid4())
if os.path.isdir(flow):
output_path = Path(flow) / PROMPT_FLOW_DIR_NAME / filename
else:
output_path = Path(os.path.dirname(flow)) / PROMPT_FLOW_DIR_NAME / filename
os.makedirs(output_path, exist_ok=True)
remove_dir = True
output_path = Path(output_path).resolve()
try:
result = get_client_from_request().flows._test_with_ui(
flow=flow,
inputs=inputs,
environment_variables=environment_variables,
variant=variant,
node=node,
experiment=experiment,
output_path=output_path,
)
finally:
if remove_dir:
shutil.rmtree(output_path)
return result


@api.route("/get")
class FlowGet(Resource):
@api.response(code=200, description="Return flow yaml as json", model=dict_field)
@api.doc(description="Return flow yaml as json")
def get(self):
YingChen1996 marked this conversation as resolved.
Show resolved Hide resolved
args = flow_path_parser.parse_args()
flow_path = args.flow
flow_path = decrypt_flow_path(flow_path)
if not os.path.exists(flow_path):
raise UserErrorException(f"The flow doesn't exist: {flow_path}")
flow_path_dir, flow_path_file = resolve_flow_path(Path(flow_path))
flow_info = load_yaml(flow_path_dir / flow_path_file)
return flow_info


@api.route("/ux_inputs")
class FlowUxInputs(Resource):
@api.response(code=200, description="Get the file content of file UX_INPUTS_JSON", model=dict_field)
@api.doc(description="Get the file content of file UX_INPUTS_JSON")
def get(self):
YingChen1996 marked this conversation as resolved.
Show resolved Hide resolved
args = flow_path_parser.parse_args()
flow_path = args.flow
flow_path = decrypt_flow_path(flow_path)
if not os.path.exists(flow_path):
raise UserErrorException(f"The flow doesn't exist: {flow_path}")
flow_ux_inputs_path = Path(flow_path) / PROMPT_FLOW_DIR_NAME / UX_INPUTS_JSON
if not flow_ux_inputs_path.exists():
flow_ux_inputs_path.touch(mode=read_write_by_user(), exist_ok=True)
try:
ux_inputs = json_load(flow_ux_inputs_path)
except json.decoder.JSONDecodeError:
ux_inputs = {}
return ux_inputs

@api.response(code=200, description="Set the file content of file UX_INPUTS_JSON", model=dict_field)
@api.doc(description="Set the file content of file UX_INPUTS_JSON")
@api.expect(flow_ux_input_model)
def post(self):
content = api.payload["ux_inputs"]
args = flow_path_parser.parse_args()
flow_path = args.flow
flow_path = decrypt_flow_path(flow_path)
flow_ux_inputs_path = Path(flow_path) / PROMPT_FLOW_DIR_NAME / UX_INPUTS_JSON
flow_ux_inputs_path.touch(mode=read_write_by_user(), exist_ok=True)
with open(flow_ux_inputs_path, mode="w", encoding=DEFAULT_ENCODING) as f:
json.dump(content, f, ensure_ascii=False, indent=2)
return make_response("UX_INPUTS_JSON content updated successfully", 200)
92 changes: 90 additions & 2 deletions src/promptflow/promptflow/_sdk/_service/apis/ui.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,98 @@
# ---------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# ---------------------------------------------------------

import base64
import hashlib
import os
from pathlib import Path

from flask import Response, current_app, render_template, send_from_directory, url_for
from flask_restx import reqparse
from werkzeug.utils import safe_join

from promptflow._sdk._constants import PROMPT_FLOW_DIR_NAME
from promptflow._sdk._service import Namespace, Resource, fields
from promptflow._sdk._service.utils.utils import decrypt_flow_path
from promptflow.exceptions import UserErrorException

api = Namespace("ui", description="UI")


media_save_model = api.model(
"MediaSave",
{
"base64_data": fields.String(required=True, description="Image base64 encoded data."),
"extension": fields.String(required=True, description="Image file extension."),
},
)

flow_path_parser = reqparse.RequestParser()
flow_path_parser.add_argument("flow", type=str, required=True, location="args", help="Path to flow directory.")

image_path_parser = reqparse.RequestParser()
image_path_parser.add_argument("image_path", type=str, required=True, location="args", help="Path of image.")


@api.route("/chat")
class ChatUI(Resource):
def get(self):
return Response(
render_template("chat_index.html", url_for=url_for),
mimetype="text/html",
)


def save_image(directory, base64_data, extension):
image_data = base64.b64decode(base64_data)
hash_object = hashlib.sha256(image_data)
filename = hash_object.hexdigest()
file_path = Path(directory) / f"{filename}.{extension}"
with open(file_path, "wb") as f:
f.write(image_data)
return file_path


@api.route("/media_save")
class MediaSave(Resource):
@api.response(code=200, description="Save image", model=fields.String)
@api.doc(description="Save image")
@api.expect(media_save_model)
def post(self):
args = flow_path_parser.parse_args()
flow = args.flow
flow = decrypt_flow_path(flow)
base64_data = api.payload["base64_data"]
extension = api.payload["extension"]
safe_path = safe_join(flow, PROMPT_FLOW_DIR_NAME)
if safe_path is None:
message = f"The untrusted path {PROMPT_FLOW_DIR_NAME} relative to the base directory {flow} detected!"
raise UserErrorException(message)
file_path = save_image(safe_path, base64_data, extension)
path = Path(file_path).relative_to(flow)
return str(path)


@api.route("/media")
class MediaView(Resource):
@api.response(code=200, description="Get image url", model=fields.String)
@api.doc(description="Get image url")
def get(self):
args = flow_path_parser.parse_args()
flow = args.flow
flow = decrypt_flow_path(flow)

args = image_path_parser.parse_args()
image_path = args.image_path
safe_path = safe_join(flow, image_path)
if safe_path is None:
message = f"The untrusted path {image_path} relative to the base directory {flow} detected!"
raise UserErrorException(message)
safe_path = Path(safe_path).resolve().as_posix()
if not os.path.exists(safe_path):
raise UserErrorException("The image doesn't exist")

from flask import current_app, send_from_directory
directory, filename = os.path.split(safe_path)
return send_from_directory(directory, filename)
Fixed Show fixed Hide fixed
YingChen1996 marked this conversation as resolved.
Show resolved Hide resolved


def serve_trace_ui(path):
Expand Down
Loading
Loading