Skip to content

Commit 98b256c

Browse files
committed
feat: human in the loop workflows with oo-ld panel ui
1 parent 36cc8e5 commit 98b256c

3 files changed

Lines changed: 252 additions & 0 deletions

File tree

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ install_requires =
5353
pyyaml
5454
pyld
5555
rdflib
56+
oold>=0.7.1
5657

5758
[options.packages.find]
5859
where = src

src/awl/hitl.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# decorator to generate the input of a function from console input
2+
3+
import functools
4+
import inspect
5+
import time
6+
7+
import panel as pn
8+
9+
from oold.model import LinkedBaseModel
10+
from oold.model.v1 import LinkedBaseModel as LinkedBaseModel_v1
11+
from oold.ui.panel.anywidget_vite.jsoneditor import OswEditor
12+
13+
14+
class HitlApp(pn.viewable.Viewer):
15+
def __init__(self, **params):
16+
super().__init__(**params)
17+
18+
self.message = pn.pane.Markdown(
19+
"""This is a human-in-the-loop application.
20+
Please fill in the required fields and click 'Save' to proceed.""" # noqa
21+
)
22+
self.jsoneditor = OswEditor(max_height=500, max_width=800)
23+
24+
self.save_btn_clicked = False
25+
self.save_btn = pn.widgets.Button(
26+
css_classes=["save_btn"], name="Save", button_type="primary"
27+
)
28+
pn.bind(self.on_save, self.save_btn, watch=True)
29+
30+
self._view = pn.Column(
31+
self.message,
32+
self.jsoneditor,
33+
# display jsoneditor value in a JSON pane for debugging
34+
# pn.pane.JSON(self.jsoneditor.param.value, theme="light"),
35+
self.save_btn,
36+
)
37+
38+
def on_save(self, event):
39+
# Handle the save event here
40+
self.save_btn_clicked = True
41+
42+
def __panel__(self):
43+
return self._view
44+
45+
46+
global ui
47+
ui: HitlApp = None
48+
49+
50+
def entry_point(gui: bool = False, jupyter: bool = False):
51+
"""
52+
Decorator factory to initialize the OswEditor and
53+
serve it before entering a workflow entry point.
54+
Spins up a Panel server to display the UI and waits
55+
for it to be ready if option gui is true.
56+
"""
57+
58+
def decorator(func):
59+
@functools.wraps(func)
60+
def wrapper(*args, **kwargs):
61+
global ui
62+
63+
def cleanup():
64+
"""
65+
Clean up the UI after the workflow is done.
66+
"""
67+
global ui
68+
ui.message.object = "Workflow completed. You can close the web ui now."
69+
ui.jsoneditor.visible = False
70+
ui.save_btn.visible = False
71+
if not jupyter:
72+
print("Stopping web ui...")
73+
74+
time.sleep(1)
75+
server.stop()
76+
ui = None
77+
78+
def run_threaded(): # func, *args, **kwargs):
79+
"""
80+
Run the function in a separate thread to avoid blocking the main thread.
81+
"""
82+
func(*args, **kwargs)
83+
cleanup()
84+
85+
if gui and ui is None:
86+
# Initialize the OswEditor
87+
ui = HitlApp()
88+
89+
if jupyter:
90+
# print("Running in Jupyter, using display() to show the UI.")
91+
import threading
92+
93+
# thread = threading.Thread(target=func, args=args, kwargs=kwargs)
94+
thread = threading.Thread(target=run_threaded)
95+
96+
# call ipython display function to show the UI
97+
display(ui.servable()) # noqa
98+
99+
thread.start()
100+
# thread.join()
101+
else:
102+
print("Spinning up web ui...")
103+
server = pn.serve(ui, threaded=True)
104+
105+
# wait for the UI to be ready
106+
while not ui.jsoneditor.ready:
107+
time.sleep(0.1)
108+
# print("Web ui is ready.")
109+
110+
if jupyter:
111+
# run the function in a thread to avoid blocking the Jupyter notebook
112+
print(
113+
"Running in Jupyter, executing the function in a separate thread."
114+
)
115+
result = None
116+
# result = func(*args, **kwargs)
117+
else:
118+
result = func(*args, **kwargs)
119+
120+
# Clean up after the workflow is done
121+
if gui and not jupyter:
122+
cleanup()
123+
124+
return result
125+
126+
return wrapper
127+
128+
return decorator
129+
130+
131+
def hitl(func):
132+
"""
133+
Decorator to generate the input of a function from console input.
134+
"""
135+
136+
@functools.wraps(func)
137+
def wrapper(*args, **kwargs):
138+
# Get the function's signature
139+
signature = inspect.signature(func)
140+
141+
# Prepare a dictionary to hold the inputs
142+
inputs = {}
143+
global ui
144+
# Iterate over the parameters in the signature
145+
# ToDo: DataClass or Pydantic model support
146+
for param in signature.parameters.values():
147+
# if parameter is a OOLD model run a jsoneditor
148+
if issubclass(param.annotation, LinkedBaseModel) or issubclass(
149+
param.annotation, LinkedBaseModel_v1
150+
):
151+
# If parameter is a model, use the OswEditor to get the value
152+
if ui is None:
153+
ui = HitlApp()
154+
pn.serve(ui, threaded=True)
155+
# wait for the UI to be ready
156+
while not ui.jsoneditor.ready:
157+
# print("Waiting for JSONEditor to be ready...")
158+
time.sleep(0.1)
159+
# print("Setting schema for parameter: ", param.name)
160+
ui.jsoneditor.set_schema(param.annotation.model_json_schema())
161+
162+
while not ui.save_btn_clicked:
163+
# print("Waiting for user input...")
164+
time.sleep(0.1)
165+
ui.save_btn_clicked = False # reset the button state
166+
inputs[param.name] = param.annotation(**ui.jsoneditor.get_value())
167+
# continue
168+
elif param.default is param.empty:
169+
# If parameter has no default, prompt for input
170+
user_input = input(
171+
f"Enter value for {param.name} ({param.annotation}): "
172+
)
173+
inputs[param.name] = user_input
174+
else:
175+
# If parameter has a default, use it
176+
inputs[param.name] = param.default
177+
178+
# Call the original function with the collected inputs
179+
return func(*inputs.values())
180+
181+
return wrapper
182+
183+
184+
# Example usage
185+
@hitl
186+
def example_function(name: str, age: int = 30):
187+
print(f"Name: {name}, Age: {age}")

tests/test_hitl.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from enum import Enum
2+
from typing import Any
3+
4+
from pydantic import Field
5+
6+
from awl.hitl import entry_point
7+
from awl.hitl import hitl as human_in_the_loop
8+
from oold.model import LinkedBaseModel
9+
10+
11+
class MachineParams(LinkedBaseModel):
12+
"""Parameters transferred to the machine"""
13+
14+
model_config = {
15+
"json_schema_extra": {
16+
"required": ["param1"],
17+
},
18+
}
19+
param1: int = Field(50, ge=0, le=100)
20+
21+
22+
class Quality(str, Enum):
23+
good = "Good"
24+
bad = "Bad"
25+
26+
27+
class ProcessDocumentation(LinkedBaseModel):
28+
"""Visual result inspection"""
29+
30+
quality: Quality
31+
"""Good is defined as..."""
32+
33+
34+
@human_in_the_loop
35+
def set_machine_params(params: MachineParams):
36+
# transfer params to machine
37+
return params
38+
39+
40+
@human_in_the_loop
41+
def document_result(params: ProcessDocumentation):
42+
# validate documentation
43+
return params
44+
45+
46+
def archive_data(params: Any):
47+
# store documentation in database
48+
pass
49+
50+
51+
@entry_point(gui=True)
52+
def workflow():
53+
machine_params = set_machine_params() # prompts user
54+
result_evaluation = document_result() # prompts user
55+
print("Machine parameters: ", machine_params)
56+
print("Result evaluation: ", result_evaluation)
57+
archive_data(machine_params) # runs automatically
58+
archive_data(result_evaluation) # runs automatically
59+
60+
61+
if __name__ == "__main__":
62+
# print(json.dumps(MachineParams.model_json_schema(), indent=2))
63+
# print(json.dumps(ProcessDocumentation.model_json_schema(), indent=2))
64+
workflow()

0 commit comments

Comments
 (0)