From 1e23e8a3dbabe5cda0f033dec3055663fca02a14 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 7 Feb 2024 08:22:07 -0500 Subject: [PATCH 01/19] Indent bug, exceptions (#122) Co-authored-by: Piotr Mitros --- learning_observer/learning_observer/auth/__init__.py | 2 +- learning_observer/learning_observer/cache.py | 2 +- learning_observer/learning_observer/google.py | 2 +- .../wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/learning_observer/learning_observer/auth/__init__.py b/learning_observer/learning_observer/auth/__init__.py index 2deb79c6..5edc27b7 100644 --- a/learning_observer/learning_observer/auth/__init__.py +++ b/learning_observer/learning_observer/auth/__init__.py @@ -111,4 +111,4 @@ def verify_auth_precheck(): "If you are not planning to use Google auth (which is the case for most dev\n" + \ "settings), please disable Google authentication in creds.yaml by\n" + \ "removing the google_auth section under auth." - raise learning_observer.prestartup.StartupCheck(error) + raise learning_observer.prestartup.StartupCheck("Auth: " + error) diff --git a/learning_observer/learning_observer/cache.py b/learning_observer/learning_observer/cache.py index 124a7ee8..0271e448 100644 --- a/learning_observer/learning_observer/cache.py +++ b/learning_observer/learning_observer/cache.py @@ -23,7 +23,7 @@ def connect_to_memoization_kvs(): 'key in `creds.yaml`.\n'\ '```\nmemoization:\n type: stub\n```\nOR\n'\ '```\nmemoization:\n type: redis_ephemeral\n expiry: 60\n```' - raise learning_observer.prestartup.StartupCheck(error_text) + raise learning_observer.prestartup.StartupCheck("KVS: "+error_text) def async_memoization(): diff --git a/learning_observer/learning_observer/google.py b/learning_observer/learning_observer/google.py index 2fc900a6..5fc60d71 100644 --- a/learning_observer/learning_observer/google.py +++ b/learning_observer/learning_observer/google.py @@ -192,7 +192,7 @@ def connect_to_google_cache(): '```\ngoogle_cache:\n type: filesystem\n path: ./learning_observer/static_data/google\n'\ ' subdirs: true\n```\nOR\n'\ '```\ngoogle_cache:\n type: redis_ephemeral\n expiry: 600\n```' - raise learning_observer.prestartup.StartupCheck(error_text) + raise learning_observer.prestartup.StartupCheck("Google KVS: " + error_text) def initialize_and_register_routes(app): diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py index 367a774a..c198dcf8 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py @@ -140,9 +140,9 @@ def initialize_gpt_responder(): exceptions.append(e) debug_log(f'WARNING:: Unable to initialize GPT responder `{key}:`.\n{e}') gpt_responder = None - exception_text = 'Unable to initialize a GPT responder. Encountered the following errors:\n'\ - '\n'.join(str(e) for e in exceptions) - raise learning_observer.prestartup.StartupCheck(exception_text) + exception_text = 'Unable to initialize a GPT responder. Encountered the following errors:\n'\ + '\n'.join(str(e) for e in exceptions) + raise learning_observer.prestartup.StartupCheck("GPT: " + exception_text) @learning_observer.communication_protocol.integration.publish_function('wo_bulk_essay_analysis.gpt_essay_prompt') @@ -195,4 +195,4 @@ async def test_responder(): loop = asyncio.get_event_loop() asyncio.ensure_future(test_responder()) loop.run_forever() - loop.close() \ No newline at end of file + loop.close() From 0bd20c6a070ce6e058cc9cdf7b5dd27b28dc91af Mon Sep 17 00:00:00 2001 From: Piotr Mitros Date: Tue, 5 Mar 2024 14:56:42 -0500 Subject: [PATCH 02/19] Workaround for protocol buffer issue --- Makefile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9cd02d01..bddea975 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,9 @@ PYTHONFILES = $(wildcard \ run: # If you haven't done so yet, run: make install # Also, run: workon learning_observer - cd learning_observer && python learning_observer + # The export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION makes this slower, but avoids + # annoying version issues in dev. This should be resolved at some point. + export PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python; cd learning_observer && python learning_observer # Build browser extension extension-package: From 653b477f5f7d908b5fd4ff704fdf1e62d3ec8cae Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 11 Mar 2024 17:20:57 -0400 Subject: [PATCH 03/19] removed legacy jupyter iframe dashboard integration prototype code --- .../learning_observer/jupyter.py | 229 ------------------ 1 file changed, 229 deletions(-) delete mode 100644 learning_observer/learning_observer/jupyter.py diff --git a/learning_observer/learning_observer/jupyter.py b/learning_observer/learning_observer/jupyter.py deleted file mode 100644 index 6d5e3c49..00000000 --- a/learning_observer/learning_observer/jupyter.py +++ /dev/null @@ -1,229 +0,0 @@ -''' -Integration with Jupyter notebooks. - -We're still figuring this out. - -By the current design: - -1. The user can run the notebook -2. The user can create an iframe in the notebook -3. We have a server which serves the repos to iframes in - the notebook to render data. -4. We have tools to inject data into the iframes. - -This allows us to have a notebook where we can prototype -dashboards, and analyze data. - -The notebook architecture will allow us to capture the -analyses run in the notebook, for open science. - -Much of this code is untested and still in flux. - -For the most part, we're trying to minimize the amount of -code that needs to be written in the notebook and instead -inject the code and data directly into the iframe. -''' - -import argparse -import json -import uuid -import base64 - -import aiohttp.web - -import learning_observer.routes - -import gitserve.aio_gitserve - - -from IPython.core.display import display, HTML - - -DEFAULT_PORT = 8008 - - -def show_dashboard( - module, - repo, - branch="master", - path="index.html", - width=1280, - height=720, - port=DEFAULT_PORT -): - ''' - Show a dashboard in an iframe. - ''' - url = f"http://localhost:{port}/{repo}/{branch}/{path}" - - -def make_iframe(url="", width=1280, height=720): - ''' - Make an iframe for a given URL. - - Args: - url (str): The URL to load in the iframe. Should be blank if you want - to load the iframe from a string. - width (int): The width of the iframe. - height (int): The height of the iframe. - - Returns: - str: The iframe ID. - - There is a race condition if we try to `load_frame_text` in the - same Jupyter cell as this. - ''' - frameid = str(uuid.uuid1()) - - display(HTML(f""" - - """)) - return frameid - - -def load_frame_text(frameid, text): - ''' - Load text into an iframe. - - Args: - frameid (str): The ID of the iframe to inject into. - text (str): The text to inject. - ''' - inject_script(frameid, f""" - document.body.innerHTML = atob("{base64.b64encode(text.encode()).decode()}"); - """) - - -def inject_script(frameid, script): - ''' - Inject a script into an iframe. - - Args: - frameid (str): The ID of the iframe to inject into. - script (str): The script to inject. - - Returns: - None - ''' - b64_script = base64.b64encode(script.encode('utf-8')).decode('utf-8') - display(HTML(f""" - - """)) - - -def inject_data(frameid, data): - ''' - Inject data into an iframe. - - Args: - frameid (str): The ID of the iframe to inject into. - data (dict): The data to inject. - - Returns: - None - ''' - for key in data: - inject_script(frameid, f"window.{key} = {json.dumps(data[key])};") - - -def refresh_dashboard(frameid, data): - ''' - Rerender the dashboard from the data in the iframe. - - Args: - frameid (str): The ID of the iframe to inject into. - - Returns: - None - ''' - inject_script(frameid, f""" - refresh_dashboard({json.dumps(data)}); - """) - - -# def refresh_dashboard(frameid, data): -# ''' -# Refresh the dashboard with new data. - -# Args: -# frameid (str): The ID of the iframe to inject into. -# data (dict): The data to inject. - -# Returns: -# None -# ''' -# #inject_data(frameid, data) -# rerender_dashboard_from_data(frameid) -# inject_script(frameid, """ -# window.sendMessage({ -# type: "lo_inject_data", -# data: """ + json.dumps(data) + """ -# }, -# window.location -# ); -# """); -# ) - - -def run_server(repos, port=DEFAULT_PORT): - ''' - Run a server to serve the given repos. - - Args: - repos (list): A list of repos to serve. - port (int): The port to serve on. - - Returns: - Never :) -''' - app = aiohttp.web.Application() - # Override the dashboard route - - # Override static paths for libraries and similar - learning_observer.routes.register_static_routes(app) - # Add routes for repos - learning_observer.routes.register_repo_routes(app, repos) - aiohttp.web.run_app(app, port=port) - - -if __name__ == "__main__": - def to_bool(s): - ''' - Convert a string to a boolean. - - Args: - s (str): The string to convert. - - Returns: - bool: The converted string. - ''' - if s.lower().strip() in ['true', 't', 'yes', 'y', '1']: - return True - elif s.lower().strip() in ['false', 'f', 'no', 'n', '0']: - return False - else: - raise ValueError("Boolean value expected. Got {}".format(s)) - - parser = argparse.ArgumentParser(description="Run a server to serve the given repos.") - parser.add_argument("repos", type=str, nargs="+", help="The repos to serve.") - parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="The port to serve on.") - args = parser.parse_args() - repos = {} - for repo in args.repos: - repo_split_partial = repo.split(";") - repo_split_default = ["", "", "", False, True] - repo_split = repo_split_partial + repo_split_default[len(repo_split_partial):] - repos[repo_split[0]] = { - "module": repo_split[0], - "url": repo_split[1], - "prefix": repo_split[2], - "bare": to_bool(repo_split[3]), # This doesn't quite work yet - "working_tree": to_bool(repo_split[4]) - } - run_server(repos, args.port) From d5738327b602f2984a4e7a692a0d55f3c85d8039 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 13 Mar 2024 11:27:38 -0400 Subject: [PATCH 04/19] small component dashboard fixes --- .../lib/components/WOAnnotatedText.react.js | 2 +- .../components/WOAnnotatedText.testdata.js | 21 ++++++++++--------- .../lib/components/WOTextHighlight.react.js | 20 ++++++++++++------ .../components/WOTextHighlight.testdata.js | 2 +- .../dashboard/settings.py | 2 +- 5 files changed, 28 insertions(+), 19 deletions(-) diff --git a/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.react.js b/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.react.js index 6b6694b2..aaf961f1 100644 --- a/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.react.js +++ b/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.react.js @@ -54,7 +54,7 @@ export default class WOAnnotatedText extends Component { return split.map((line, index) => ( {line} -
+ {split.length-1 === index ? :
}
)) } diff --git a/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.testdata.js b/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.testdata.js index 8993093b..6f9c3136 100644 --- a/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.testdata.js +++ b/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.testdata.js @@ -1,27 +1,28 @@ const testData = { id: "example", - text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Dictumst quisque sagittis purus sit amet. Mi quis hendrerit dolor magna eget est lorem ipsum. Arcu bibendum at varius vel pharetra. Nulla malesuada pellentesque elit eget gravida cum. Tincidunt tortor aliquam nulla facilisi cras fermentum odio. Amet venenatis urna cursus eget nunc scelerisque viverra mauris. Diam vel quam elementum pulvinar. Morbi tincidunt augue interdum velit euismod in pellentesque massa. Dignissim cras tincidunt lobortis feugiat vivamus at augue eget arcu. Enim praesent elementum facilisis leo vel fringilla est.\n\nSodales ut etiam sit amet nisl purus in mollis nunc. Suspendisse interdum consectetur libero id faucibus. Morbi leo urna molestie at elementum. In iaculis nunc sed augue lacus viverra. Tristique senectus et netus et malesuada fames ac turpis egestas. Accumsan lacus vel facilisis volutpat est. Consequat semper viverra nam libero justo laoreet sit. Euismod nisi porta lorem mollis aliquam ut porttitor leo. Enim facilisis gravida neque convallis a cras. Odio ut enim blandit volutpat maecenas. Justo nec ultrices dui sapien eget mi proin sed. Non sodales neque sodales ut etiam. Nulla aliquet enim tortor at auctor urna. At volutpat diam ut venenatis.\n\nNulla facilisi cras fermentum odio eu feugiat. Imperdiet massa tincidunt nunc pulvinar sapien et. Fermentum odio eu feugiat pretium nibh ipsum consequat nisl. Pellentesque pulvinar pellentesque habitant morbi tristique senectus et netus. Ac turpis egestas sed tempus urna et. Libero volutpat sed cras ornare arcu dui vivamus arcu. Varius duis at consectetur lorem. Tincidunt augue interdum velit euismod. Praesent elementum facilisis leo vel fringilla est ullamcorper. Facilisis magna etiam tempor orci eu lobortis. Amet est placerat in egestas erat imperdiet sed. Odio eu feugiat pretium nibh ipsum consequat nisl vel pretium. Lectus proin nibh nisl condimentum id venenatis a condimentum vitae. Lacus suspendisse faucibus interdum posuere lorem ipsum. Vel turpis nunc eget lorem dolor. Feugiat nibh sed pulvinar proin gravida hendrerit lectus. Convallis aenean et tortor at risus viverra adipiscing. Aliquet nec ullamcorper sit amet risus nullam eget felis. Massa eget egestas purus viverra accumsan in nisl nisi. Orci nulla pellentesque dignissim enim sit.\n\nUltrices mi tempus imperdiet nulla malesuada pellentesque elit eget. Augue neque gravida in fermentum. Sapien eget mi proin sed libero enim sed faucibus turpis. Velit sed ullamcorper morbi tincidunt. Enim sed faucibus turpis in eu mi bibendum neque. Gravida in fermentum et sollicitudin ac orci phasellus egestas. Risus at ultrices mi tempus imperdiet nulla malesuada. Ridiculus mus mauris vitae ultricies leo. Montes nascetur ridiculus mus mauris vitae ultricies leo integer. Mollis aliquam ut porttitor leo. Elementum nibh tellus molestie nunc non. Malesuada bibendum arcu vitae elementum. Nibh mauris cursus mattis molestie.\n\nMollis nunc sed id semper risus in hendrerit. In ornare quam viverra orci sagittis eu. Cursus vitae congue mauris rhoncus aenean vel elit. Imperdiet massa tincidunt nunc pulvinar. Lobortis scelerisque fermentum dui faucibus in. Sit amet consectetur adipiscing elit pellentesque habitant morbi. Interdum velit laoreet id donec ultrices tincidunt arcu. Elementum curabitur vitae nunc sed velit. Sed euismod nisi porta lorem mollis. Pretium aenean pharetra magna ac. Enim diam vulputate ut pharetra sit. In fermentum et sollicitudin ac orci phasellus egestas tellus rutrum. Sed viverra tellus in hac habitasse platea dictumst. Tellus rutrum tellus pellentesque eu. Velit dignissim sodales ut eu sem.", + text: "Lorem ipsum dolor sit amet, \nconsectetur adipiscing elit, sed do eiusmod tempor incididunt\n ut labore et dolore magna aliqua. Dictumst quisque sagittis purus sit amet. Mi quis hendrerit dolor magna eget est lorem ipsum. Arcu bibendum at varius vel pharetra. Nulla malesuada pellentesque elit eget gravida cum. Tincidunt tortor aliquam nulla facilisi cras fermentum odio. Amet venenatis urna cursus eget nunc scelerisque viverra mauris. Diam vel quam elementum pulvinar. Morbi tincidunt augue interdum velit euismod in pellentesque massa. Dignissim cras tincidunt lobortis feugiat vivamus at augue eget arcu. Enim praesent elementum facilisis leo vel fringilla est.\n\nSodales ut etiam sit amet nisl purus in mollis nunc. Suspendisse interdum consectetur libero id faucibus. Morbi leo urna molestie at elementum. In iaculis nunc sed augue lacus viverra. Tristique senectus et netus et malesuada fames ac turpis egestas. Accumsan lacus vel facilisis volutpat est. Consequat semper viverra nam libero justo laoreet sit. Euismod nisi porta lorem mollis aliquam ut porttitor leo. Enim facilisis gravida neque convallis a cras. Odio ut enim blandit volutpat maecenas. Justo nec ultrices dui sapien eget mi proin sed. Non sodales neque sodales ut etiam. Nulla aliquet enim tortor at auctor urna. At volutpat diam ut venenatis.\n\nNulla facilisi cras fermentum odio eu feugiat. Imperdiet massa tincidunt nunc pulvinar sapien et. Fermentum odio eu feugiat pretium nibh ipsum consequat nisl. Pellentesque pulvinar pellentesque habitant morbi tristique senectus et netus. Ac turpis egestas sed tempus urna et. Libero volutpat sed cras ornare arcu dui vivamus arcu. Varius duis at consectetur lorem. Tincidunt augue interdum velit euismod. Praesent elementum facilisis leo vel fringilla est ullamcorper. Facilisis magna etiam tempor orci eu lobortis. Amet est placerat in egestas erat imperdiet sed. Odio eu feugiat pretium nibh ipsum consequat nisl vel pretium. Lectus proin nibh nisl condimentum id venenatis a condimentum vitae. Lacus suspendisse faucibus interdum posuere lorem ipsum. Vel turpis nunc eget lorem dolor. Feugiat nibh sed pulvinar proin gravida hendrerit lectus. Convallis aenean et tortor at risus viverra adipiscing. Aliquet nec ullamcorper sit amet risus nullam eget felis. Massa eget egestas purus viverra accumsan in nisl nisi. Orci nulla pellentesque dignissim enim sit.\n\nUltrices mi tempus imperdiet nulla malesuada pellentesque elit eget. Augue neque gravida in fermentum. Sapien eget mi proin sed libero enim sed faucibus turpis. Velit sed ullamcorper morbi tincidunt. Enim sed faucibus turpis in eu mi bibendum neque. Gravida in fermentum et sollicitudin ac orci phasellus egestas. Risus at ultrices mi tempus imperdiet nulla malesuada. Ridiculus mus mauris vitae ultricies leo. Montes nascetur ridiculus mus mauris vitae ultricies leo integer. Mollis aliquam ut porttitor leo. Elementum nibh tellus molestie nunc non. Malesuada bibendum arcu vitae elementum. Nibh mauris cursus mattis molestie.\n\nMollis nunc sed id semper risus in hendrerit. In ornare quam viverra orci sagittis eu. Cursus vitae congue mauris rhoncus aenean vel elit. Imperdiet massa tincidunt nunc pulvinar. Lobortis scelerisque fermentum dui faucibus in. Sit amet consectetur adipiscing elit pellentesque habitant morbi. Interdum velit laoreet id donec ultrices tincidunt arcu. Elementum curabitur vitae nunc sed velit. Sed euismod nisi porta lorem mollis. Pretium aenean pharetra magna ac. Enim diam vulputate ut pharetra sit. In fermentum et sollicitudin ac orci phasellus egestas tellus rutrum. Sed viverra tellus in hac habitasse platea dictumst. Tellus rutrum tellus pellentesque eu. Velit dignissim sodales ut eu sem.", breakpoints: [ + { + id: 'split0', + tooltip: 'This is the first tooltip', + start: 220, + offset: 5, + style: {textDecoration: 'underline'} + }, { id: 'split1', tooltip: 'This is a tooltip', - start: 100, + start: 240, offset: 25, style: {textDecoration: 'underline'} }, { id: 'split2', tooltip: 'This is another tooltip', - start: 110, + start: 310, offset: 15, style: {backgroundColor: 'green'} - }, - { - id: 'split3', - tooltip: 'This is a final tooltip', - start: 10, - offset: 25 - }, + } ] }; diff --git a/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.react.js b/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.react.js index fb872bc2..13292fd7 100644 --- a/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.react.js +++ b/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.react.js @@ -63,15 +63,16 @@ export default class WOTextHighlight extends Component { {text_newline_split.length === 1 ? text_slice : text_newline_split.map((line, i) => ( - - {line} - {i === text_newline_split.length - 1 ? "" :
} -
- ))} + + {line} + {i === text_newline_split.length - 1 ? "" :
} +
+ ))} ); }); } + const text_newline_split = text.split("\n"); // Return a div element with the child elements and appropriate attributes return (
- {child} + {text_newline_split.length === 1 + ? child + : text_newline_split.map((line, i) => ( + + {line} + {i === text_newline_split.length - 1 ? "" :
} +
+ ))}
); } diff --git a/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.testdata.js b/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.testdata.js index 435c22ed..fdc0a235 100644 --- a/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.testdata.js +++ b/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.testdata.js @@ -2,7 +2,7 @@ const testData = { id: "text-highlight-test", - text: "This is a test of the text highlight component.", + text: "This is a test of the text highlight component.\nThis is a new line of text data.\n\n\nHow about 3 new lines?", highlight_breakpoints: { testHighlight: { id: "testHighlight", diff --git a/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/settings.py b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/settings.py index 0505c939..51ed1ba6 100644 --- a/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/settings.py +++ b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/settings.py @@ -278,7 +278,7 @@ ), ], # make both items visible from the start - active_item=['item-0', 'item-2'], + active_item=['item-1', 'item-3'], always_open=True, # keep accordionitems open when click on others flush=True, # styles to take up width class_name='border-top' # bootstrap border on top From 32c10a6a2f76e64e052df71ff3dbe2d8b793c684 Mon Sep 17 00:00:00 2001 From: Brendon Hablutzel <77469216+Brendon-Hablutzel@users.noreply.github.com> Date: Sat, 23 Mar 2024 17:59:19 -0400 Subject: [PATCH 05/19] Initial Fix for Log Files Issue (#126) * This resolves an issue where log files were not being closed * We use weakref.finalize to have the garbage collector for the handler close them --- .../learning_observer/incoming_student_event.py | 7 +++++++ learning_observer/learning_observer/log_event.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/learning_observer/learning_observer/incoming_student_event.py b/learning_observer/learning_observer/incoming_student_event.py index 6a6abba4..8fcd311a 100644 --- a/learning_observer/learning_observer/incoming_student_event.py +++ b/learning_observer/learning_observer/incoming_student_event.py @@ -19,6 +19,7 @@ import urllib.parse import uuid import socket +import weakref import aiohttp @@ -191,6 +192,10 @@ async def handler(request, client_event): filename, preencoded=True, timestamp=True) await pipeline(event) + # when the handler garbage collected (no more events are being passed through), + # close the log file associated with this connection + weakref.finalize(handler, log_event.close_logfile, filename) + return handler @@ -282,6 +287,8 @@ async def decode_and_log_event(events): json_event = json.loads(msg.data) log_event.log_event(json_event, filename=filename) yield json_event + # done processing events, can close logfile now + log_event.close_logfile(filename) return decode_and_log_event diff --git a/learning_observer/learning_observer/log_event.py b/learning_observer/learning_observer/log_event.py index b6c63406..a880e5a4 100644 --- a/learning_observer/learning_observer/log_event.py +++ b/learning_observer/learning_observer/log_event.py @@ -298,3 +298,10 @@ def log_ajax(url, resp_json, request): ) with open(filename, "w") as ajax_log_fp: ajax_log_fp.write(encoded_payload) + +def close_logfile(filename): + # remove the file from the dict storing open log files and close it + old_file = files.pop(filename) + if old_file is None: + raise KeyError(f"Tried to remove log file {old_file} but it was not found") + old_file.close() From 26c19d5f831971f79851b3bd29ab62abda1c8c9e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Mar 2024 09:48:59 -0400 Subject: [PATCH 06/19] Bump ip from 2.0.0 to 2.0.1 in /modules/lo_dash_react_components (#124) Bumps [ip](https://github.com/indutny/node-ip) from 2.0.0 to 2.0.1. - [Commits](https://github.com/indutny/node-ip/compare/v2.0.0...v2.0.1) --- updated-dependencies: - dependency-name: ip dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- modules/lo_dash_react_components/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/lo_dash_react_components/package-lock.json b/modules/lo_dash_react_components/package-lock.json index 6059dba6..afa9d345 100644 --- a/modules/lo_dash_react_components/package-lock.json +++ b/modules/lo_dash_react_components/package-lock.json @@ -12163,9 +12163,9 @@ } }, "node_modules/ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==" + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==" }, "node_modules/ipaddr.js": { "version": "2.0.1", From 843d8c80582c425a08d1fb604cdf6870da49c2af Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Mar 2024 09:50:48 -0400 Subject: [PATCH 07/19] Bump follow-redirects in /modules/lo_dash_react_components (#128) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.4 to 1.15.6. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.4...v1.15.6) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- modules/lo_dash_react_components/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/lo_dash_react_components/package-lock.json b/modules/lo_dash_react_components/package-lock.json index afa9d345..976fbc6f 100644 --- a/modules/lo_dash_react_components/package-lock.json +++ b/modules/lo_dash_react_components/package-lock.json @@ -10444,9 +10444,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", - "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", From 1302bc7522cd6d488fa30143b4a89ae8514ef8ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Mar 2024 09:52:20 -0400 Subject: [PATCH 08/19] Bump webpack-dev-middleware in /modules/lo_dash_react_components (#129) Bumps [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware) from 5.3.3 to 5.3.4. - [Release notes](https://github.com/webpack/webpack-dev-middleware/releases) - [Changelog](https://github.com/webpack/webpack-dev-middleware/blob/v5.3.4/CHANGELOG.md) - [Commits](https://github.com/webpack/webpack-dev-middleware/compare/v5.3.3...v5.3.4) --- updated-dependencies: - dependency-name: webpack-dev-middleware dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- modules/lo_dash_react_components/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/lo_dash_react_components/package-lock.json b/modules/lo_dash_react_components/package-lock.json index 976fbc6f..8e1f6f00 100644 --- a/modules/lo_dash_react_components/package-lock.json +++ b/modules/lo_dash_react_components/package-lock.json @@ -28753,9 +28753,9 @@ } }, "node_modules/webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", "dependencies": { "colorette": "^2.0.10", "memfs": "^3.4.3", From 492cb4844215b8cc617d4053344bfe5667bd23a4 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 25 Mar 2024 08:56:09 -0400 Subject: [PATCH 09/19] fixed highlight bug and removed sentence types for time being --- .../lib/components/WOTextHighlight.react.js | 20 ++++++++++--------- .../writing_observer/nlp_indicators.py | 4 +++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.react.js b/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.react.js index 13292fd7..3112e5a3 100644 --- a/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.react.js +++ b/modules/lo_dash_react_components/src/lib/components/WOTextHighlight.react.js @@ -71,8 +71,17 @@ export default class WOTextHighlight extends Component { ); }); + } else { + const text_split = text.split("\n"); + child = text_split.length === 1 + ? child + : text_split.map((line, i) => ( + + {line} + {i === text_split.length - 1 ? "" :
} +
+ )) } - const text_newline_split = text.split("\n"); // Return a div element with the child elements and appropriate attributes return (
- {text_newline_split.length === 1 - ? child - : text_newline_split.map((line, i) => ( - - {line} - {i === text_newline_split.length - 1 ? "" :
} -
- ))} + {child}
); } diff --git a/modules/writing_observer/writing_observer/nlp_indicators.py b/modules/writing_observer/writing_observer/nlp_indicators.py index b495c4cd..f3468e76 100644 --- a/modules/writing_observer/writing_observer/nlp_indicators.py +++ b/modules/writing_observer/writing_observer/nlp_indicators.py @@ -71,7 +71,9 @@ ('Auxiliary Verb', 'Token', 'pos_', [('==', ['AUX'])], 'total'), ('Pronoun', 'Token', 'pos_', [('==', ['PRON'])], 'total'), # sentence variety - ('Sentence Types', 'Doc', 'sentence_types', None, 'counts'), + # The general 'Sentence Types' will return a complex object of all sentence types + # that we do not yet handle. + # ('Sentence Types', 'Doc', 'sentence_types', None, 'counts'), ('Simple Sentences', 'Doc', 'sentence_types', [('==', ['Simple'])], 'total'), ('Simple with Complex Predicates', 'Doc', 'sentence_types', [('==', ['SimpleComplexPred'])], 'total'), ('Simple with Compound Predicates', 'Doc', 'sentence_types', [('==', ['SimpleCompoundPred'])], 'total'), From fb00ab66b958989a173b1b1ceb3c6db5b253eca3 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 25 Mar 2024 09:59:32 -0400 Subject: [PATCH 10/19] Added abillity to start ipython kernel on startup Added ability to start the LO platform as an ipython kernel that Jupyter Clients can connect to. Full commit list: * added ipython kernel serving and logging * jupyter integration * added more documentation to jupyter stuff * running lo app alongside jupyter * updates and documentation * added documentation about interactive environments * Code review * fixed argument handling, renamed arguments, and more pr feedback * renamed jupyter helpers to interactive development * added v0 prototype for reloading reducers * cleaned up hot loading reducer code. added paths for possible migration policies * added unique token * fixed rebase * added future todo --------- Co-authored-by: Piotr Mitros --- autodocs/development.rst | 4 +- docs/interactive_environments.md | 96 +++++++ .../incoming_student_event.py | 15 +- .../interactive_development.py | 142 +++++++++++ .../learning_observer/ipython_integration.py | 237 ++++++++++++++++++ learning_observer/learning_observer/kvs.py | 10 + learning_observer/learning_observer/main.py | 22 +- .../learning_observer/module_loader.py | 11 +- .../learning_observer/redis_connection.py | 7 + .../learning_observer/settings.py | 28 ++- .../stream_analytics/__init__.py | 5 +- learning_observer/learning_observer/util.py | 12 + learning_observer/reducer_testing.ipynb | 224 +++++++++++++++++ requirements.txt | 1 + 14 files changed, 802 insertions(+), 12 deletions(-) create mode 100644 docs/interactive_environments.md create mode 100644 learning_observer/learning_observer/interactive_development.py create mode 100644 learning_observer/learning_observer/ipython_integration.py create mode 100644 learning_observer/reducer_testing.ipynb diff --git a/autodocs/development.rst b/autodocs/development.rst index a7e620ee..2fae1da4 100644 --- a/autodocs/development.rst +++ b/autodocs/development.rst @@ -11,6 +11,8 @@ Development :parser: myst_parser.sphinx_ .. include:: ../modules/writing_observer/README.md :parser: myst_parser.sphinx_ +.. include:: ../docs/interactive_environments.md + :parser: myst_parser.sphinx_ .. include:: ../docs/privacy.md :parser: myst_parser.sphinx_ .. include:: ../docs/config.md @@ -20,4 +22,4 @@ Development .. include:: ../docs/testing.md :parser: myst_parser.sphinx_ .. include:: ../docs/technologies.md - :parser: myst_parser.sphinx_ \ No newline at end of file + :parser: myst_parser.sphinx_ diff --git a/docs/interactive_environments.md b/docs/interactive_environments.md new file mode 100644 index 00000000..1c6fa24c --- /dev/null +++ b/docs/interactive_environments.md @@ -0,0 +1,96 @@ +# Interactive Environments + +The Learning Observer can launch itself in a variety of ways. These +include launching itself stand-alone, or from within an IPython +kernel. The latter allows for users to directly interact with the LO +system. Users can connect to the kernel through the command line or a +Jupyter clients, such as the `ipython` console, Jupyter Lab, or +Jupyter Notebooks. This is useful for debugging or rapidly prototyping +within the system. When starting a kernel, the Learning Observer +application can be started alongside the kernel. + +## IPython Kernel Commmunications + +We will give an overview of the IPython kernel architecture, and how +we fit in. First, the IPython kernel architecture: + +1. The `IPython` kernel handles event loops for different + commmunications that occur within itself. +1. These event loops handle code requests from the user or shutdown + requests from a system message. +1. The events are communicated using the [ZMQ Protocol](https://zeromq.org/). + +There are 5 dedicated sockets for communications where events occur: + +1. **Shell**: Requests for code execution from clients come in +1. **IOPub**: Broadcast channel which includes all side effects +1. **stdin**: Raw input from user +1. **Control**: Dedicated to shutdown and restart requests +1. **Heartbeat**: Ensure continuous connection + +Upon startup, we create a separate thread to subscribe to, monitor and +log events on the information rich IOPub socket. + +## Files + +* The **kernel file** is [describe, provide a simplified example or link to one] +* The **connection file** is [describe, provide a simplified example or link to one] + +## Launching Learning Observer from an IPython Kernel + +We use an +[aiohttp runner](https://docs.aiohttp.org/en/stable/web_reference.html#running-applications) +to serve the LO application through the internal `ipykernel.io_loop` +[Tornado](https://www.tornadoweb.org/en/stable/) event loop. The +runner method attaches itself to the provided event loop instead of +the normal running method which creates a new event loop. + +## IPython Shell/Kernel + +We can startup the server as: + +* A kernel we connect to (e.g. from Jupyter Lab) +* An interactive shell including a kernel + +```bash +# Start an interactive shell +python learning_observer/ --loconsole +``` + +The IPython kernel parses specific arguments, which we should +block. It also does not like blank arguments (e.g. --bar). + +```bash +# Start an ipython kernel +# note: the 1 is needed to make the ipython kernel instance we launch happy +python learning_observer/ --lokernel 1 +# this will provide you a specific kernel json file to use +# Connect to the specified kernel +jupyter console --existing kernel-123456.json +``` + +## Jupyter Clients + +### Connect with Jupyter + +Jupyter clients have a set of directories they will look for kernels in. +We need to create the LO kernel files so the client will be able to choose the LO kernel. +Running the LO platform once will automatically create the kernel file in the `//share/jupyter/kernels/` directory. + +```bash +# run once to create the kernel file +python learning_observer/ +# open jupyter client of your choice +jupyter lab +# select the LO kernel from the kernel dropdown +``` + +### Helpers + +The system offers some helpers for working with the LO platform from a Jupyter client. +The `local_reducer.ipynb` is an example notebook where we create a simple `event_count` reducer and create a corresponding dashboard. +This notebook calls `jupyter_helpers.add_reducer_to_lo` which handles adding your created reducer to all relavant aspects of the system. + +The goal here is to be able to rapidly prototype reducers, queries, and dashboards. In the longer term, we would like to be able to compile these into a module, and perhaps even inject them into a running Learning Observer system. + +A long-term goal in building out this file is to have a smooth pathway from research code to production dashboards, using common tools and frameworks. diff --git a/learning_observer/learning_observer/incoming_student_event.py b/learning_observer/learning_observer/incoming_student_event.py index 8fcd311a..26451827 100644 --- a/learning_observer/learning_observer/incoming_student_event.py +++ b/learning_observer/learning_observer/incoming_student_event.py @@ -16,9 +16,7 @@ import os import time import traceback -import urllib.parse import uuid -import socket import weakref import aiohttp @@ -314,6 +312,7 @@ async def incoming_websocket_handler(request): await ws.prepare(request) lock_fields = {} authenticated = False + reducers_last_updated = None event_handler = failing_event_handler decoder_and_logger = event_decoder_and_logger(request) @@ -341,7 +340,7 @@ async def update_event_handler(event): if not authenticated: return - nonlocal event_handler + nonlocal event_handler, reducers_last_updated if 'source' in lock_fields: debug_log('Updating the event_handler()') metadata = lock_fields.copy() @@ -349,6 +348,7 @@ async def update_event_handler(event): metadata = event metadata['auth'] = authenticated event_handler = await handle_incoming_client_event(metadata=metadata) + reducers_last_updated = learning_observer.stream_analytics.LAST_UPDATED async def handle_auth_events(events): '''This method checks a single method for auth and @@ -413,6 +413,14 @@ async def filter_blacklist_events(events): await ws.send_json(bl_status) await ws.close() + async def check_for_reducer_update(events): + '''Check to see if the reducers updated + ''' + async for event in events: + if reducers_last_updated != learning_observer.stream_analytics.LAST_UPDATED: + await update_event_handler(event) + yield event + async def pass_through_reducers(events): '''Pass events through the reducers ''' @@ -428,6 +436,7 @@ async def process_ws_message_through_pipeline(): events = decode_lock_fields(events) events = handle_auth_events(events) events = filter_blacklist_events(events) + events = check_for_reducer_update(events) events = pass_through_reducers(events) # empty loop to start the generator pipeline async for event in events: diff --git a/learning_observer/learning_observer/interactive_development.py b/learning_observer/learning_observer/interactive_development.py new file mode 100644 index 00000000..0d41027a --- /dev/null +++ b/learning_observer/learning_observer/interactive_development.py @@ -0,0 +1,142 @@ +import learning_observer.communication_protocol.query as q +import learning_observer.dashboard +import learning_observer.kvs +import learning_observer.module_loader +import learning_observer.stream_analytics +from learning_observer.stream_analytics.helpers import KeyField, Scope + +MODULE_NAME = 'jupyter-helper' + +def _transform_reducer_into_classroom_query(id): + '''Take a reducer and create the execution dag to run + that query over a classroom. + + HACK much of this is hardcoded and needs to be abstracted + + This relies on the reducer having `scope=Scope([KeyField.STUDENT])`. + ''' + course_roster = q.call('learning_observer.courseroster') + # TODO replace event_count stuff with more general items + dag = { + "execution_dag": { + "roster": course_roster(runtime=q.parameter("runtime"), course_id=q.parameter("course_id", required=True)), + 'event_count': q.select(q.keys(id, STUDENTS=q.variable("roster"), STUDENTS_path='user_id'), fields={'event_count': 'event_count'}), + }, + "exports": { + 'event_count': { + 'returns': 'event_count', + 'parameters': ['course_id'] + } + } + } + return dag + + +def construct_reducer(reducer_id, reducer_func, module=MODULE_NAME, scope=None, default=None): + scope = scope if scope else Scope([KeyField.STUDENT]) + reducer = { + # TODO not sure the best way to handle specifying context + 'context': 'org.mitros.writing_analytics', + 'function': reducer_func, + 'scope': scope, + 'default': default, + 'module': module, + 'id': reducer_id + } + return reducer + + +async def remove_reducer_results_from_kvs(reducer_id): + '''Find all keys that match the reducer and remove them + + TODO: Figure out if we should move this into kvs.py or otherwise bubble + it up. It seems like it may be more broadly applicable than just + interactive development. + ''' + kvs = learning_observer.kvs.KVS() + keys = await kvs.keys() + matched_keys = [k for k in keys if reducer_id in k] + for m in matched_keys: + await kvs.remove(m) + + +async def _restream_prior_event_logs(reducer): + '''Process event logs through a reducer in the background while + keeping the old reducer running. Once finished, swap active + reducers in the event pipeline. + ''' + # TODO process reducer over files corresponding to the removed keys + # files_to process = find_files(removed_keys) + # await learning_observer.offline.process_files(files_to_process) + raise NotImplementedError('Restreaming of prior event logs has not yet been implemented') + + +async def _drop_prior_data(reducer): + '''Remove the specified reducer, re-init our event pipeline, then + remove any data associated with the removed reducer. + ''' + reducer_id = reducer['id'] + learning_observer.module_loader.remove_reducer(reducer_id) + learning_observer.stream_analytics.init() + await remove_reducer_results_from_kvs(reducer_id) + + +async def _process_curr_reducer_output_through_func(reducer): + '''Take the current results of a given reducer and modify them + based on the provided function. + + The default func should just return the value + ''' + raise NotImplementedError('Processing reducer output through a migration function is not yet implemented.') + + +RESTREAM_PRIOR_DATA = 'restream_prior_data' +DROP_DATA = 'drop_data' +PROCESS_REDUCER_FUNC = 'process_reducer_function' +MIGRATION_POLICY = { + RESTREAM_PRIOR_DATA: _restream_prior_event_logs, + DROP_DATA: _drop_prior_data, + PROCESS_REDUCER_FUNC: _process_curr_reducer_output_through_func +} + + +async def hot_load_reducer(reducer, reload=False, migration_function=None): + reducer_id = reducer['id'] + adding_reducer = not reload + + reducers = learning_observer.module_loader.reducers() + existing_reducer = any(r['id'] == reducer_id for r in reducers) + + if migration_function is not None and migration_function not in MIGRATION_POLICY: + error_msg = f'Migration function, `{migration_function}`, is not a valid type. '\ + f'Available types are: [{", ".join(MIGRATION_POLICY.keys())}, None]' + raise KeyError(error_msg) + + if adding_reducer and existing_reducer: + error_msg = f'The reducer, `{reducer_id}`, currently '\ + 'exists and the `reload` parameter is set to False (this is the default). '\ + 'To add a reducer instead of reloading, use:\n'\ + '`hot_load_reducer(..., reload=True)`' + raise RuntimeError(error_msg) + + if adding_reducer and migration_function is not None and migration_function != RESTREAM_PRIOR_DATA: + error_msg = f'Migration function, `{migration_function}`, '\ + 'is not available when adding a new reducer.' + raise RuntimeError(error_msg) + + if migration_function is not None: + # TODO we want to pass more args/kwargs in here for the other migration policies + await MIGRATION_POLICY[migration_function](reducer) + + # add reducer to available reducers and re-init our pipeline + learning_observer.module_loader.add_reducer(reducer) + learning_observer.stream_analytics.init() + + # TODO determine the best way to update the execution dag + # much of this is currently hardcoded for Student scope. + # create a simple "module" to set the execution dag + obj = lambda: None + obj.EXECUTION_DAG = _transform_reducer_into_classroom_query(reducer_id) + learning_observer.module_loader.load_execution_dags(reducer['module'], obj) + + return diff --git a/learning_observer/learning_observer/ipython_integration.py b/learning_observer/learning_observer/ipython_integration.py new file mode 100644 index 00000000..44cf736a --- /dev/null +++ b/learning_observer/learning_observer/ipython_integration.py @@ -0,0 +1,237 @@ +''' +This file defines helpers for connecting to the server +via an ipython kernel. + +Use `start()` to launch a new kernel instance. +''' +from aiohttp import web +import asyncio +import dash +import IPython +import ipykernel.kernelapp +import ipykernel.ipkernel +import json +import logging +import os +import sys +import threading +from traitlets.config import Config +import zmq + +KERNEL_ID = 'learning_observer_kernel' + +# generic log file for seeing ipython output +logging.basicConfig(filename='ZMQ.log', encoding='utf-8', level=logging.DEBUG) + + +async def start_learning_observer_application_server(runner): + ''' + This will start the Learning Observer application on port + 9999 (Jupyter defaults to 8888). + + We use a runner since IPython expects to control the event loop, + so we plug into that one instead of our own. + ''' + await runner.setup() + # TODO set this to the correct port + site = web.TCPSite(runner, 'localhost', 9999) + await site.start() + + +def record_iopub_port(connection_file_path): + ''' + The iopub port is one of the five IPython kernel ZeroMQ + port. This one has all the inputs and outputs from the server. + + We will subscribe here, and listen on the the conversation between + the IPython kernel and the Jupyter client (e.g. notebook). + ''' + # Read the connection file to get the iopub_port + connection_info = json.load(open(connection_file_path)) + return connection_info['iopub_port'] + + +def start(kernel_only=False, connection_file=None, iopub_port=None, run_lo_app=False, lo_app=None): + '''Kernels can start in several ways: + + 1. A user starts up a kernel by running learning observer with `--ipython-kernel` OR + 2. A user starts up an interactive shell by running learning observer with `--ipython-console` OR + 3. A Jupyter client (lab/notebook) starts a kernel + + All 3 are methods are supported; however, we have not figured out + how to handle logging interactions with #2. + + #2 should be used for debugging purposes. In the future, we want + to log interactions here for open science purposes. + + When the Jupyter client starts, it passes in a connection file to + tell the system how to connect. We pass the connection file through + the `--ipython-kernel-connection-file` parameter. We inspect this + file to get the `iopub_port`, and subscribe to ZMQ to be able to + eavesdrop on the conversation. We log messages published on this port. + + Roadblocks: + - To initiate the kernel properly, you must be run `jupyter` from + the `/learning_observer` directory. The same location that we + normally start the server with `python learning_observer`. The + `passwd.lo` file is read in based on your current working + directory. Other files may also be read in this way. We just + haven't found and fixed them all yet. + ''' + + class LOKernel(ipykernel.ipkernel.IPythonKernel): + '''Intercept the kernel to fix any issues with + in the startup configuration and to start the + learning observer platform alongside. + + We nest this kernel class so we can start and stop the Learning + Observer application. The kernel classes are passed into an IPython + kernel launcher via the `kernel_class` parameter. This prevents + us from passing arguments, such as the Learning Observer application, + directly to the kernel. + ''' + def __init__(self, **kwargs): + super().__init__(**kwargs) + # When dash first loads, it initializes `jupyter_dash`. However, + # at that point, we have not loaded the IPython module yet. This + # re-initalizes it now that the IPython module is loaded. + dash.jupyter_dash.__init__() + if run_lo_app: + self.lo_runner = web.AppRunner(lo_app) + + def start(self): + super().start() + if run_lo_app: + asyncio.run_coroutine_threadsafe(start_learning_observer_application_server(self.lo_runner), self.io_loop.asyncio_loop) + + def do_shutdown(self, restart): + if run_lo_app: + asyncio.run_coroutine_threadsafe(self.lo_runner.cleanup(), self.io_loop.asyncio_loop) + return super().do_shutdown(restart) + + def do_execute(self, code, silent, + store_history=True, user_expressions=None, allow_stdin=False, *, cell_id=None): + '''This method handles execution of code cells. + If there is code to be run with all cells, it should be placed here. + ''' + return super().do_execute(code, silent, store_history, user_expressions, allow_stdin, + cell_id=cell_id) + + # Start the listener in a separate thread + # HACK fix this - should this just be a setting? + iopub = 12345 if iopub_port is None else iopub_port + if connection_file: + iopub = record_iopub_port(connection_file) + keep_monitoring_iopub = True + thread = threading.Thread(target=monitor_iopub, args=(iopub, lambda: keep_monitoring_iopub)) + thread.start() + + # TODO: + # I would prefer to be able to select which things are launched as flags rather + # than exclusive. + # + # e.g. --server_running=false --kernel --loconsole --notebook + # + # Would run the kernel, a console, and a notebook, but no application server + # + # As the number of services grows, this is more maintainable, I think. + # + # With pss, we might also define classes for reasonable defaults. + + # The IPython kernels automatically read in sys.argv. To avoid any conflicts + # with the kernel, we backup the sys.argv and reset them. + sys_argv_backup = sys.argv + sys.argv = sys.argv[:1] + if kernel_only and connection_file: + sys.argv.extend(['-f', connection_file]) + print('launching app') + ipykernel.kernelapp.launch_new_instance(kernel_class=LOKernel) + elif kernel_only: + c = Config() + c.IPKernelApp.iopub_port = iopub + IPython.embed_kernel(config=c, kernel_class=LOKernel) + else: + # TODO figure out how to log when using `.embed()`. The `embed` + # funciton uses a different structure compared to serving an + # entire kernel. We are unable to monitor the iopub port, because + # it doesn't exist in this context. + IPython.embed() + keep_monitoring_iopub = False + + +def load_kernel_spec(): + '''Load the `learning_observer_kernel`. This will create the + kernel is one does not already exist. + + TODO copy in logo files + ''' + current_script_path = os.path.abspath(__file__) # At some point, perhaps move into / use paths.py? + current_directory = os.path.dirname(current_script_path) + kernel_spec = { + 'argv': [sys.executable, current_directory, '--ipython-kernel', '--ipython-kernel-connection-file', '{connection_file}'], + 'display_name': 'Learning Observer Kernel', + 'language': 'python', + 'name': KERNEL_ID + } + + dirname = os.path.join(sys.prefix, 'share', 'jupyter', 'kernels', KERNEL_ID) + kernel_file = os.path.join(dirname, 'kernel.json') + # check if we should even make it + if os.path.isfile(kernel_file): + print('Kernel found!\nUsing the following kernel spec:') + print(json.dumps(json.load(open(kernel_file)), indent=2)) + return + print('Kernel NOT found!\nCreating a default kernel spec:') + print(json.dumps(kernel_spec, indent=2)) + os.mkdir(dirname) + json.dump(kernel_spec, open(kernel_file, 'w'), sort_keys=True) + # We can also store logos in the same directory + # under `logo-64x64.png` or `logo-32x32.png` + + +def monitor_iopub(port, stop=None): + ''' + Setup listener for the IO Pub ZMQ socket to listen for and log + messages about which code to run and results. + + Incoming messages are a list with the following items: + + ```python + inc_msg = [ + b'kernel..', # ZMQ routing prefix + b'', # Delimeter key (always the same) + b'some-cryptographic-str', # HMAC string for authentication + b'{"msg_id": "", ...}' # message contents + ] + ``` + + Jupyter recommends using `jupyter_client.session.Session` for + consuming messages sent back and forth. However, passing in a + session object only works when in a Jupyter notebook or lab. + This does not work when serving the kernel and connecting via + `jupyter console --existing kernel-.json` + ''' + stop = stop if stop else lambda: False + context = zmq.Context() + subscriber = context.socket(zmq.SUB) + subscriber.connect(f'tcp://localhost:{port}') + topic_filter = b'' + subscriber.setsockopt(zmq.SUBSCRIBE, topic_filter) + + # message types we want to record + logged_msg_types = ['execute_input', 'execute_result'] + try: + while not stop(): + message = subscriber.recv_multipart() + # TODO only log the types of messages we want to see + # there are 2 different ways to fetch the message type + # msg_type = message[0].decode().split('.')[-1] + # msg_type = json.loads(message[3].decode()) + # if msg_type.get('msg_type', None) in logged_msg_types: + logging.debug(f"Received message: {message}") + # TODO: Figure out where to log, what to log, etc. + except zmq.ZMQError as e: + logging.debug(f"Subscriber terminated due to error: {e}") + finally: + subscriber.close() + context.term() diff --git a/learning_observer/learning_observer/kvs.py b/learning_observer/learning_observer/kvs.py index dc912b50..591befc9 100644 --- a/learning_observer/learning_observer/kvs.py +++ b/learning_observer/learning_observer/kvs.py @@ -181,6 +181,16 @@ async def keys(self): await self.connect() return await learning_observer.redis_connection.keys() + async def remove(self, key): + ''' + Remove item from the KVS. + + HACK python didn't like `await del kvs[key]`, so I created this + method to use in the meantime. More thought should be put into this + ''' + await self.connect() + return await learning_observer.redis_connection.delete(key) + class EphemeralRedisKVS(_RedisKVS): ''' diff --git a/learning_observer/learning_observer/main.py b/learning_observer/learning_observer/main.py index 0cc751de..ebba4e56 100644 --- a/learning_observer/learning_observer/main.py +++ b/learning_observer/learning_observer/main.py @@ -23,6 +23,7 @@ import learning_observer.prestartup import learning_observer.webapp_helpers import learning_observer.watchdog_observer +import learning_observer.ipython_integration from learning_observer.log_event import debug_log @@ -134,11 +135,24 @@ def start(app): app = create_app() -if args.console: - import IPython - IPython.embed() -else: +# This creates the file that tells jupyter how to run our custom +# kernel. This command needs to be ran once (outside of Jupyter) +# before users can get access to the LO Kernel. +learning_observer.ipython_integration.load_kernel_spec() + +if args.ipython_kernel: + learning_observer.ipython_integration.start( + kernel_only=args.ipython_kernel, lo_app=app, + connection_file=args.ipython_kernel_connection_file, + run_lo_app=args.run_lo_application) +elif args.ipython_console: + learning_observer.ipython_integration.start( + kernel_only=False, lo_app=app, + run_lo_app=args.run_lo_application) +elif args.run_lo_application: start(app) +else: + raise RuntimeError('No services to start up.') # Port printing: # diff --git a/learning_observer/learning_observer/module_loader.py b/learning_observer/learning_observer/module_loader.py index 6854fdad..47bdbb7b 100644 --- a/learning_observer/learning_observer/module_loader.py +++ b/learning_observer/learning_observer/module_loader.py @@ -228,15 +228,24 @@ def add_reducer(reducer, string_id=None): ''' We add a reducer. In actual operation, this should only happen once, on module load. We'd like to be able to dynamic load and reload reducers in - interactive programming, so we offer the optnio of a `string_id` + interactive programming, so we offer the option of a `string_id` ''' global REDUCERS + # TODO this is filtering the reducers on a specific string_id. + # we ought to look for the matching reducer and replace it if it exists. if string_id is not None: REDUCERS = [r for r in REDUCERS if r.get("string_id", None) != string_id] REDUCERS.append(reducer) return REDUCERS +def remove_reducer(reducer_id): + '''Remove a reducer from the available reducers + ''' + global REDUCERS + REDUCERS = [r for r in REDUCERS if r['id'] != reducer_id] + + def load_reducers(component_name, module): ''' Load reducers from a module. diff --git a/learning_observer/learning_observer/redis_connection.py b/learning_observer/learning_observer/redis_connection.py index 97cd9d4a..ecc19a12 100644 --- a/learning_observer/learning_observer/redis_connection.py +++ b/learning_observer/learning_observer/redis_connection.py @@ -57,3 +57,10 @@ async def set(key, value, expiry=None): Set a key. We should eventually do multi-sets. Returns a future. ''' return await (await connection()).set(key, value, expiry) + + +async def delete(key): + ''' + Delete a key. Returns a future. + ''' + return await (await connection()).delete(key) diff --git a/learning_observer/learning_observer/settings.py b/learning_observer/learning_observer/settings.py index dff8157a..a3579b53 100644 --- a/learning_observer/learning_observer/settings.py +++ b/learning_observer/learning_observer/settings.py @@ -31,6 +31,15 @@ args = None parser = None +def str_to_bool(arg): + if isinstance(arg, bool): + return arg + if arg.lower() in ['true', '1']: + return True + if arg.lower() in ['false', '0']: + return False + raise argparse.ArgumentTypeError('Boolean like value expected.') + def parse_and_validate_arguments(): ''' @@ -53,11 +62,26 @@ def parse_and_validate_arguments(): default=None) parser.add_argument( - '--console', + '--ipython-console', help='Instead of launching a web server, run a debug console.', - default=None, action='store_true') + parser.add_argument( + '--ipython-kernel', + help='Launch an `ipython` kernel', + default=False, action='store_true') + + parser.add_argument( + '--ipython-kernel-connection-file', + help='Connection file passed into ipython-kernel. This is used by Juptyer Clients.', + type=str) + # TODO possibly include a --ipython-iopub-port param for monitoring purposes + + parser.add_argument( + '--run-lo-application', + help='Launce the Learning Observer application. This can be used with `--ipython-console` and `--ipython-kernel`.', + default=True, nargs='?', const=True, type=str_to_bool) + args = parser.parse_args() if not os.path.exists(args.config_file): diff --git a/learning_observer/learning_observer/stream_analytics/__init__.py b/learning_observer/learning_observer/stream_analytics/__init__.py index 73832097..e52cf138 100644 --- a/learning_observer/learning_observer/stream_analytics/__init__.py +++ b/learning_observer/learning_observer/stream_analytics/__init__.py @@ -16,10 +16,12 @@ import functools import learning_observer.exceptions import learning_observer.module_loader +import learning_observer.util from learning_observer.log_event import debug_log REDUCER_MODULES = None +LAST_UPDATED = None def reducer_modules(source): @@ -81,5 +83,6 @@ def init(): 'scope': scope }) - global REDUCER_MODULES + global REDUCER_MODULES, LAST_UPDATED REDUCER_MODULES = dict(srm) + LAST_UPDATED = learning_observer.util.generate_unique_token() diff --git a/learning_observer/learning_observer/util.py b/learning_observer/learning_observer/util.py index bd028aba..bd62bf32 100644 --- a/learning_observer/learning_observer/util.py +++ b/learning_observer/learning_observer/util.py @@ -17,6 +17,7 @@ import numbers import re import socket +import uuid from dateutil import parser import learning_observer @@ -230,6 +231,17 @@ def timeparse(timestamp): return parser.isoparse(timestamp) +count = 0 + + +def generate_unique_token(): + '''Update the system counter and return a new unique token. + ''' + global count + count = count + 1 + return f'{count}-{timestamp()}-{str(uuid.uuid4())}' + + # And a test case if __name__ == '__main__': assert to_safe_filename('{') == '-123-' diff --git a/learning_observer/reducer_testing.ipynb b/learning_observer/reducer_testing.ipynb new file mode 100644 index 00000000..f857602c --- /dev/null +++ b/learning_observer/reducer_testing.ipynb @@ -0,0 +1,224 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "4a7dd2a2-0337-49de-aad1-4f4acc3857ae", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a custom reducer\n", + "\n", + "# import helpers to define reducer scope\n", + "from learning_observer.stream_analytics.helpers import kvs_pipeline, KeyField, Scope\n", + "\n", + "@kvs_pipeline(scope=Scope([KeyField.STUDENT]), module_override='testing')\n", + "async def event_counter(event, state):\n", + " '''This is a simple reducer to count the total\n", + " events for a given scope.\n", + " '''\n", + " if state is None:\n", + " state = {}\n", + " state['event_count'] = state.get('event_count', 0) - 1\n", + " return state, state" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "169ee920-e84a-489c-8a47-eeadfbb84ba2", + "metadata": {}, + "outputs": [], + "source": [ + "# Implement reducer into system with our h\n", + "ID = 'event_counter'\n", + "module = 'example_mod'\n", + "\n", + "import learning_observer.interactive_development\n", + "reducer = learning_observer.interactive_development.construct_reducer(ID, event_counter, module=module, default={'event_count': 0})\n", + "await learning_observer.interactive_development.hot_load_reducer(reducer, reload=True, migration_function=learning_observer.interactive_development.DROP_DATA)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "0a1ca8fe-5ccc-41c6-8391-5e8b9c66cabc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[{'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({, }), 'default': {'saved_ts': 0}, 'module': , 'id': 'writing_observer.time_on_task'}, {'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({, }), 'default': {'text': ''}, 'module': , 'id': 'writing_observer.reconstruct'}, {'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({}), 'default': {}, 'module': , 'id': 'writing_observer.event_count'}, {'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({}), 'default': {'docs': []}, 'module': , 'id': 'writing_observer.document_list'}, {'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({}), 'default': {'document_id': ''}, 'module': , 'id': 'writing_observer.last_document'}, {'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({}), 'default': {'tags': {}}, 'module': , 'id': 'writing_observer.document_tagging'}, {'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({}), 'default': {'timestamps': {}}, 'module': , 'id': 'writing_observer.document_access_timestamps'}, {'context': 'org.mitros.writing_analytics', 'function': , 'scope': Scope({}), 'default': {'event_count': 0}, 'module': 'example_mod', 'id': 'event_counter'}]\n" + ] + } + ], + "source": [ + "import learning_observer.module_loader \n", + "print(learning_observer.module_loader.reducers())" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "e70bd437-cbb5-4848-a48e-57c68cf96527", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "211" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import learning_observer.kvs\n", + "\n", + "kvs = learning_observer.kvs.KVS()\n", + "len(await kvs.keys())" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "77b3cd68-839e-46f7-b93b-b44aac5d5276", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Create a dashboard to connect to the reducer you just wrote\n", + "# This dashboard creates a graph for \"Total events over time\"\n", + "import dash\n", + "from dash import Dash, html, dcc, callback, Output, Input, State, clientside_callback, Patch\n", + "import time\n", + "import json\n", + "import lo_dash_react_components as lodrc\n", + "import pandas as pd\n", + "import plotly.graph_objects as go\n", + "\n", + "app = Dash(__name__)\n", + "\n", + "fig = go.Figure(data=go.Scatter(\n", + " x=pd.Series(dtype=object), y=pd.Series(dtype=object)\n", + "))\n", + "\n", + "# create app layout\n", + "app.layout = html.Div([\n", + " html.H4('Graph of event count'),\n", + " dcc.Graph(id='graph', figure=fig),\n", + " html.H4('Incoming data.'),\n", + " lodrc.LOConnection(id='ws', url='ws://localhost:9999/wsapi/communication_protocol')\n", + "])\n", + "\n", + "# Receive message from websocket and update graph\n", + "clientside_callback(\n", + " '''function(msg) {\n", + " if (!msg) {\n", + " return window.dash_clientside.no_update;\n", + " }\n", + " // extract data from message\n", + " const data = JSON.parse(msg.data);\n", + " console.log(data);\n", + " const students = data.test.event_count;\n", + " if (students === undefined) { return window.dash_clientside.no_update; }\n", + " if (students.length === 0) {\n", + " return window.dash_clientside.no_update;\n", + " }\n", + " // prep data for dcc.Graph.extendData\n", + " const studentIndex = 0;\n", + " const x = [Date.now() / 1000];\n", + " const y = [students[studentIndex].event_count];\n", + " return [\n", + " { x: [x], y: [y] },\n", + " [0]\n", + " ];\n", + " }''',\n", + " Output('graph', 'extendData'),\n", + " Input('ws', 'message')\n", + ")\n", + " \n", + "# Send connection information on the websocket when the connectedj\n", + "# NOTE that this uses an f''' (triple quote) string.\n", + "# Any curly braces need to be doubled up because of this.\n", + "clientside_callback(\n", + " f'''function(state) {{\n", + " if (state === undefined) {{\n", + " return window.dash_clientside.no_update;\n", + " }}\n", + " if (state.readyState === 1) {{\n", + " return JSON.stringify({{\"test\": {{\"execution_dag\": \"{module}\", \"target_exports\": [\"event_count\"], \"kwargs\": {{\"course_id\": 12345}}}}}});\n", + " }}\n", + " }}''',\n", + " Output('ws', 'send'),\n", + " Input('ws', 'state')\n", + ")\n", + "\n", + "# `jupyter_mode='inline'` will run the dashboard below\n", + "# `supress_callback_exceptions=True` will prevent dash\n", + "# from warning you about callbacks with missing IDS.\n", + "# These callbacks are from other dashboards.\n", + "app.run_server(jupyter_mode='inline', suppress_callback_exceptions=True)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4449c151-2119-432c-85d8-6c6a8cf64457", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Learning Observer Kernel", + "language": "python", + "name": "learning_observer_kernel" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/requirements.txt b/requirements.txt index 1af8e297..a34328cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,6 +29,7 @@ lxml multiprocess myst_parser names +notebook numpy openai pandas From 19806295f211a7dd64d71c4c18be8835d0ccd3fa Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 27 Mar 2024 15:42:53 -0400 Subject: [PATCH 11/19] better startup error messages --- .../learning_observer/auth/roles.py | 2 +- learning_observer/learning_observer/paths.py | 4 ++-- learning_observer/learning_observer/routes.py | 6 +++--- .../wo_bulk_essay_analysis/gpt.py | 17 +++++++++++++---- .../writing_observer/awe_nlp.py | 3 ++- requirements.txt | 1 + 6 files changed, 22 insertions(+), 11 deletions(-) diff --git a/learning_observer/learning_observer/auth/roles.py b/learning_observer/learning_observer/auth/roles.py index 1112a448..8bea5157 100644 --- a/learning_observer/learning_observer/auth/roles.py +++ b/learning_observer/learning_observer/auth/roles.py @@ -45,6 +45,6 @@ def validate_user_lists(): paths.data(USER_FILES[k]) ) raise learning_observer.prestartup.StartupCheck( - f"Created a blank {k} file: static_data/{USER_FILES[k]}\n" + f"Created a blank {k} file: {paths.data(USER_FILES[k])}\n" f"Populate it with {k} accounts." ) diff --git a/learning_observer/learning_observer/paths.py b/learning_observer/learning_observer/paths.py index 15ef3634..24e716e3 100644 --- a/learning_observer/learning_observer/paths.py +++ b/learning_observer/learning_observer/paths.py @@ -30,13 +30,13 @@ BASE_PATH = os.path.abspath(os.path.dirname(__file__)) - +PYTHON_EXECUTABLE = sys.executable # If we e.g. `import settings` and `import learning_observer.settings`, we # will load startup code twice, and end up with double the global variables. # This is a test to avoid that bug. if not __name__.startswith("learning_observer."): - raise ImportErrror("Please use fully-qualified imports") + raise ImportError("Please use fully-qualified imports") sys.exit(-1) diff --git a/learning_observer/learning_observer/routes.py b/learning_observer/learning_observer/routes.py index 7777c0ad..a2c6ed07 100644 --- a/learning_observer/learning_observer/routes.py +++ b/learning_observer/learning_observer/routes.py @@ -254,9 +254,10 @@ def register_auth_webapp_views(app): fn=settings.settings['auth']['password_file'] )) print("Typically:") - print("python util/lo_passwd.py " + print("{python_src} learning_observer/util/lo_passwd.py " "--username {username} --password {password} " - "--filename {fn}".format( + "--filename learning_obsserver/{fn}".format( + python_src=paths.PYTHON_EXECUTABLE, username=getpass.getuser(), password=secrets.token_urlsafe(16), fn=settings.settings['auth']['password_file'] @@ -389,7 +390,6 @@ def register_repo_routes(app, repos): giturl = r'/static/repos/' + gitrepo['module'] + '/' + reponame + '/{branch:[^{}/]+}/{filename:[^{}]+}' debug_log(f"Module {reponame} is hosting {gitrepo} at {giturl}") - debug_log(f"""For testing: python learning_observer/jupyter.py "{reponame};{gitrepo['url']};{gitrepo['prefix']};False;True" """) # If the working tree is set in the repo, we can serve from the working tree # This can be overridden by the settings file, in either direction diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py index c198dcf8..5315ed69 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py @@ -71,6 +71,11 @@ async def chat_completion(self, prompt, system_prompt): class OllamaGPT(GPTAPI): + '''GPT responder for handling request to the Ollama API + TODO this ought to just use requests instead of the specific ollama package + the format *should* be the same as the OpenAI responder. This will be one + less external module to rely on. + ''' def __init__(self, **kwargs): ''' kwargs @@ -94,7 +99,7 @@ def __init__(self, **kwargs): 'run the following commands:\n'\ '```bash\ncurl https://ollama.ai/install.sh | sh\n'\ 'ollama run \n```') - self.client = ollama.AsyncClient(base_url=ollama_host) + self.client = ollama.AsyncClient(base_url=ollama_host) if ollama_host is not None else ollama.AsyncClient() async def chat_completion(self, prompt, system_prompt): messages = [ @@ -140,9 +145,13 @@ def initialize_gpt_responder(): exceptions.append(e) debug_log(f'WARNING:: Unable to initialize GPT responder `{key}:`.\n{e}') gpt_responder = None - exception_text = 'Unable to initialize a GPT responder. Encountered the following errors:\n'\ - '\n'.join(str(e) for e in exceptions) - raise learning_observer.prestartup.StartupCheck("GPT: " + exception_text) + no_responders = 'No GPT responders found in `creds.yaml`. To add a responder, add either'\ + '`openai` or `ollama` along with any subsettings to `modules.writing_observer.gpt_responders`.\n'\ + 'Example:\n```\ngpt_responders:\n ollama:\n model: llama2\n```' + exception_strings = '\n'.join(str(e) for e in exceptions) if len(exceptions) > 0 else no_responders + exception_text = 'Unable to initialize a GPT responder. Encountered the following errors:\n'\ + f'{exception_strings}' + raise learning_observer.prestartup.StartupCheck("GPT: " + exception_text) @learning_observer.communication_protocol.integration.publish_function('wo_bulk_essay_analysis.gpt_essay_prompt') diff --git a/modules/writing_observer/writing_observer/awe_nlp.py b/modules/writing_observer/writing_observer/awe_nlp.py index 4644d3bc..8bf18aca 100644 --- a/modules/writing_observer/writing_observer/awe_nlp.py +++ b/modules/writing_observer/writing_observer/awe_nlp.py @@ -27,6 +27,7 @@ import writing_observer.nlp_indicators import learning_observer.kvs +import learning_observer.paths import learning_observer.util RUN_MODES = enum.Enum('RUN_MODES', 'MULTIPROCESSING SERIAL') @@ -43,7 +44,7 @@ def init_nlp(): except OSError as e: error_text = 'There was an issue loading `en_core_web_lg` from spacy. '\ '`awe_components` requires various models to operate properly. '\ - 'Run `python awe_components/setup/data.py` to install all '\ + f'Run `{learning_observer.paths.PYTHON_EXECUTABLE} awe_components/setup/data.py` to install all '\ 'of the necessary models.' raise OSError(error_text) from e diff --git a/requirements.txt b/requirements.txt index a34328cb..1efb9a71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,7 @@ tsvx @ git+https://github.com/pmitros/tsvx.git@09bf7f33107f66413d929075a8b54c36c loremipsum @ git+https://github.com/testlabauto/loremipsum.git@b7bd71a6651207ef88993045cd755f20747f2a1e#egg=loremipsum google-auth ipython +ipykernel invoke jsonschema js2py From 1562bbf5381ac5bd7ded7694ea1d14bfea571ea3 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 3 Apr 2024 12:19:49 -0400 Subject: [PATCH 12/19] Create versioning.yml Testing versioning github action. --- .github/workflows/versioning.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/versioning.yml diff --git a/.github/workflows/versioning.yml b/.github/workflows/versioning.yml new file mode 100644 index 00000000..a0b2fd43 --- /dev/null +++ b/.github/workflows/versioning.yml @@ -0,0 +1,27 @@ +name: Pytest + +on: + push: + branches: + - master + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: GitHub Script + uses: actions/github-script@v7.0.1 + with: + # The script to run + script: | + const shortSHA = context.sha.substring(0, 7); + const date = new Date().toISOString().split('T')[0]; // Gets date in YYYY-MM-DD format + const formattedDate = date.replace(/-/g, '.'); // Replaces '-' with '.' + const tagName = `${formattedDate}-${shortSHA}`; + + github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/tags/${tagName}`, + sha: context.sha + }) From 4520ecf33223c6a09ae56a67faa66ed886a34200 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Thu, 4 Apr 2024 12:45:37 -0400 Subject: [PATCH 13/19] added memoization to course roster call in comm protocol --- learning_observer/learning_observer/google.py | 4 ++-- .../learning_observer/rosters.py | 20 ++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/learning_observer/learning_observer/google.py b/learning_observer/learning_observer/google.py index 5fc60d71..ed266a36 100644 --- a/learning_observer/learning_observer/google.py +++ b/learning_observer/learning_observer/google.py @@ -129,6 +129,8 @@ async def raw_google_ajax(runtime, target_url, **kwargs): request = runtime.get_request() url = target_url.format(**kwargs) user = await learning_observer.auth.get_active_user(request) + if constants.AUTH_HEADERS not in request: + raise aiohttp.web.HTTPUnauthorized(text="Please log in") # TODO: Consistent way to flag this cache_key = "raw_google/" + learning_observer.auth.encode_id('session', user[constants.USER_ID]) + '/' + learning_observer.util.url_pathname(url) if settings.feature_flag('use_google_ajax') is not None: @@ -139,8 +141,6 @@ async def raw_google_ajax(runtime, target_url, **kwargs): GOOGLE_TO_SNAKE ) async with aiohttp.ClientSession(loop=request.app.loop) as client: - if constants.AUTH_HEADERS not in request: - raise aiohttp.web.HTTPUnauthorized(text="Please log in") # TODO: Consistent way to flag this async with client.get(url, headers=request[constants.AUTH_HEADERS]) as resp: response = await resp.json() learning_observer.log_event.log_ajax(target_url, response, request) diff --git a/learning_observer/learning_observer/rosters.py b/learning_observer/learning_observer/rosters.py index 9c1d8c23..a16b82e5 100644 --- a/learning_observer/learning_observer/rosters.py +++ b/learning_observer/learning_observer/rosters.py @@ -69,6 +69,7 @@ import pathvalidate import learning_observer.auth as auth +import learning_observer.cache import learning_observer.constants as constants import learning_observer.google import learning_observer.kvs @@ -410,7 +411,6 @@ async def courselist(request): return course_list -@learning_observer.communication_protocol.integration.publish_function('learning_observer.courseroster') async def courseroster_runtime(runtime, course_id): ''' Wrapper to call courseroster with a runtime object @@ -418,6 +418,24 @@ async def courseroster_runtime(runtime, course_id): return await courseroster(runtime.get_request(), course_id) +@learning_observer.communication_protocol.integration.publish_function('learning_observer.courseroster') +async def memoize_courseroster_runtime(runtime, course_id): + '''Wrapper function for calling the course roster with runtime from + within the communication protocol. This is so we can memoize the + result without modifying the behavior of `courseroster_runtime` + when used outside of the communication protocol. + + TODO this node should only be ran once in the communication protocol. + For now, we use memoization to limit how often this node is called. + In the future, we ought to be able to specify how the values from + individual nodes are handled: static, dynamic (current), or memoized. + ''' + @learning_observer.cache.async_memoization() + async def memoization_layer(c): + return await courseroster_runtime(runtime, c) + return await memoization_layer(course_id) + + async def courseroster(request, course_id): ''' List all of the students in a course: Helper From 26df6ec683f03013ff7e1bef399657a784bcb579 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Apr 2024 09:14:35 -0400 Subject: [PATCH 14/19] Bump tar from 6.1.13 to 6.2.1 in /modules/lo_dash_react_components (#133) Bumps [tar](https://github.com/isaacs/node-tar) from 6.1.13 to 6.2.1. - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v6.1.13...v6.2.1) --- updated-dependencies: - dependency-name: tar dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- modules/lo_dash_react_components/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/modules/lo_dash_react_components/package-lock.json b/modules/lo_dash_react_components/package-lock.json index 8e1f6f00..655ecef6 100644 --- a/modules/lo_dash_react_components/package-lock.json +++ b/modules/lo_dash_react_components/package-lock.json @@ -27283,13 +27283,13 @@ } }, "node_modules/tar": { - "version": "6.1.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", - "integrity": "sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", - "minipass": "^4.0.0", + "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" @@ -27307,9 +27307,9 @@ } }, "node_modules/tar/node_modules/minipass": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.4.tgz", - "integrity": "sha512-lwycX3cBMTvcejsHITUgYj6Gy6A7Nh4Q6h9NP4sTHY1ccJlC7yKzDmiShEHsJ16Jf1nKGDEaiHxiltsJEvk0nQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", "engines": { "node": ">=8" } From 94337c9f113c9f86f7760f4c65f5b48de786b7b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Apr 2024 09:25:41 -0400 Subject: [PATCH 15/19] Bump express from 4.18.2 to 4.19.2 in /modules/lo_dash_react_components (#131) Bumps [express](https://github.com/expressjs/express) from 4.18.2 to 4.19.2. - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/master/History.md) - [Commits](https://github.com/expressjs/express/compare/4.18.2...4.19.2) --- updated-dependencies: - dependency-name: express dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../package-lock.json | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/modules/lo_dash_react_components/package-lock.json b/modules/lo_dash_react_components/package-lock.json index 655ecef6..164dd108 100644 --- a/modules/lo_dash_react_components/package-lock.json +++ b/modules/lo_dash_react_components/package-lock.json @@ -6569,12 +6569,12 @@ "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==" }, "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -6582,7 +6582,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -7669,9 +7669,9 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "engines": { "node": ">= 0.6" } @@ -9872,16 +9872,16 @@ } }, "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -20541,9 +20541,9 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", From 5467bbe0fed08181da4342ac1e6c9fab3f1df1ab Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 1 May 2024 12:15:54 -0400 Subject: [PATCH 16/19] PMSS integration (#134) * initially added a handful of pss settings * more updates * better descriptions * now uses module settings * renamed pss to pmss --- .../learning_observer/auth/social_sso.py | 51 +++++++++++++++++-- .../learning_observer/dashboard.py | 13 ++++- .../learning_observer/ipython_integration.py | 2 +- .../learning_observer/log_event.py | 13 ++++- learning_observer/learning_observer/main.py | 19 +++++-- .../learning_observer/pubsub/__init__.py | 4 +- .../learning_observer/rosters.py | 37 ++++++++++---- .../learning_observer/settings.py | 39 ++++++++++---- .../learning_observer/webapp_helpers.py | 20 +++++++- .../wo_bulk_essay_analysis/gpt.py | 5 +- .../writing_observer/aggregator.py | 29 +++++++++-- .../writing_observer/languagetool.py | 29 +++++++++-- .../writing_observer/writing_analysis.py | 10 +--- requirements.txt | 1 + 14 files changed, 220 insertions(+), 52 deletions(-) diff --git a/learning_observer/learning_observer/auth/social_sso.py b/learning_observer/learning_observer/auth/social_sso.py index 0df75626..c2b4b784 100644 --- a/learning_observer/learning_observer/auth/social_sso.py +++ b/learning_observer/learning_observer/auth/social_sso.py @@ -41,6 +41,35 @@ import learning_observer.constants as constants import learning_observer.exceptions +import pmss +# TODO the hostname setting currently expect the port +# to specified within the hostname. We ought to +# remove the port and instead use the port setting. +pmss.register_field( + name="hostname", + type=pmss.pmsstypes.TYPES.hostname, + description="The hostname of the LO webapp. Used to redirect OAuth clients.", + required=True +) +pmss.register_field( + name="protocol", + type=pmss.pmsstypes.TYPES.protocol, + description="The protocol (http / https) of the LO webapp. Used to redirect OAuth clients.", + required=True +) +pmss.register_field( + name="client_id", + type=pmss.pmsstypes.TYPES.string, + description="The Google OAuth client ID", + required=True +) +pmss.register_field( + name="client_secret", + type=pmss.pmsstypes.TYPES.string, + description="The Google OAuth client secret", + required=True +) + DEFAULT_GOOGLE_SCOPES = [ 'https://www.googleapis.com/auth/userinfo.profile', @@ -60,6 +89,20 @@ 'https://www.googleapis.com/auth/classroom.announcements.readonly' ] +# TODO Type list is not yet supported by PMSS 4/24/24 +# pmss.register_field( +# name='base_scopes', +# type='list', +# description='List of Google URLs to look for.', +# default=DEFAULT_GOOGLE_SCOPES +# ) +# pmss.register_field( +# name='additional_scopes', +# type='list', +# description='List of additional URLs to look for.', +# default=[] +# ) + async def social_handler(request): """Handles Google sign in. @@ -91,10 +134,10 @@ async def _google(request): if 'error' in request.query: return {} - hostname = settings.settings['hostname'] - protocol = settings.settings.get('protocol', 'https') + hostname = settings.pmss_settings.hostname() + protocol = settings.pmss_settings.protocol() common_params = { - 'client_id': settings.settings['auth']['google_oauth']['web']['client_id'], + 'client_id': settings.pmss_settings.client_id(types=['auth', 'google_oauth', 'web']), 'redirect_uri': f"{protocol}://{hostname}/auth/login/google" } @@ -125,7 +168,7 @@ async def _google(request): url = 'https://accounts.google.com/o/oauth2/token' params = common_params.copy() params.update({ - 'client_secret': settings.settings['auth']['google_oauth']['web']['client_secret'], + 'client_secret': settings.pmss_settings.client_secret(types=['auth', 'google_oauth', 'web']), 'code': request.query['code'], 'grant_type': 'authorization_code', }) diff --git a/learning_observer/learning_observer/dashboard.py b/learning_observer/learning_observer/dashboard.py index a6996b0c..1d3e0774 100644 --- a/learning_observer/learning_observer/dashboard.py +++ b/learning_observer/learning_observer/dashboard.py @@ -8,6 +8,7 @@ import json import jsonschema import numbers +import pmss import queue import time @@ -35,6 +36,16 @@ import learning_observer.communication_protocol.schema import learning_observer.settings +pmss.register_field( + name='dangerously_allow_insecure_dags', + type=pmss.pmsstypes.TYPES.boolean, + description='Data can be queried either by system defined execution DAGs '\ + '(directed acyclic graphs) or user created execution DAGs. '\ + 'This is useful for developing new system queries, but should not '\ + 'be used in production.', + default=False +) + def timelist_to_seconds(timelist): ''' @@ -439,7 +450,7 @@ async def dispatch_named_execution_dag(dag_name, funcs): async def dispatch_defined_execution_dag(dag, funcs): query = None - if not learning_observer.settings.settings.get('dangerously_allow_insecure_dags', False): + if not learning_observer.settings.pmss_settings.dangerously_allow_insecure_dags(): debug_log(await dag_submission_not_allowed()) funcs.append(dag_submission_not_allowed()) return query diff --git a/learning_observer/learning_observer/ipython_integration.py b/learning_observer/learning_observer/ipython_integration.py index 44cf736a..c481569d 100644 --- a/learning_observer/learning_observer/ipython_integration.py +++ b/learning_observer/learning_observer/ipython_integration.py @@ -136,7 +136,7 @@ def do_execute(self, code, silent, # # As the number of services grows, this is more maintainable, I think. # - # With pss, we might also define classes for reasonable defaults. + # With pmss, we might also define classes for reasonable defaults. # The IPython kernels automatically read in sys.argv. To avoid any conflicts # with the kernel, we backup the sys.argv and reset them. diff --git a/learning_observer/learning_observer/log_event.py b/learning_observer/learning_observer/log_event.py index a880e5a4..a92ef252 100644 --- a/learning_observer/learning_observer/log_event.py +++ b/learning_observer/learning_observer/log_event.py @@ -56,6 +56,7 @@ import hashlib import os import os.path +import pmss import learning_observer.constants import learning_observer.filesystem_state @@ -103,6 +104,16 @@ class LogLevel(Enum): EXTENDED = 'EXTENDED' +pmss.parser('debug_log_level', parent='string', choices=[level.value for level in LogLevel], transform=None) +pmss.register_field( + name='debug_log_level', + type='debug_log_level', + description='How much information do we want to log.\n'\ + '`NONE`: do not print anything\n'\ + '`SIMPLE`: print simple debug messages\n'\ + '`EXTENDED`: print debug message with stack trace and timestamp' +) + class LogDestination(Enum): ''' Where we log events? We can log to a file, or to the console. @@ -142,7 +153,7 @@ def initialize_logging_framework(): # In either case, we want to override from the settings file. if "logging" in settings.settings: if "debug_log_level" in settings.settings["logging"]: - DEBUG_LOG_LEVEL = LogLevel(settings.settings["logging"]["debug_log_level"]) + DEBUG_LOG_LEVEL = LogLevel(settings.pmss_settings.debug_log_level(types=['logging'])) if "debug_log_destinations" in settings.settings["logging"]: DEBUG_LOG_DESTINATIONS = list(map(LogDestination, settings.settings["logging"]["debug_log_destinations"])) diff --git a/learning_observer/learning_observer/main.py b/learning_observer/learning_observer/main.py index ebba4e56..6696b567 100644 --- a/learning_observer/learning_observer/main.py +++ b/learning_observer/learning_observer/main.py @@ -15,7 +15,7 @@ import aiohttp import aiohttp.web - +import pmss import uvloop import learning_observer.settings as settings @@ -27,6 +27,18 @@ from learning_observer.log_event import debug_log +pmss.register_field( + name='port', + type=pmss.pmsstypes.TYPES.port, + description='Determine which port to run the LO webapp on.', + # BUG the code breaks when we default to None since + # `TYPES.port` expects an integer. + # Before PMSS, if the port was None, then we would try + # to find an available open port. This functionality + # should remain with the introduction of PMSS. + default=8888 +) + # If we e.g. `import settings` and `import learning_observer.settings`, we # will load startup code twice, and end up with double the global variables. # This is a test to avoid that bug. @@ -69,10 +81,9 @@ def create_app(): # We don't want these to change on a restart. # We should check if reloading this module overwrites them. if port is None: - port = settings.settings.get("server", {}).get("port", None) + port = settings.pmss_settings.port(types=['server']) if runmode is None: - runmode = settings.settings.get("config", {}).get("run_mode", None) - + runmode = settings.pmss_settings.run_mode(types=['config']) if port is None and runmode == 'dev': port = learning_observer.webapp_helpers.find_open_port() diff --git a/learning_observer/learning_observer/pubsub/__init__.py b/learning_observer/learning_observer/pubsub/__init__.py index 59bad7bb..3528facd 100644 --- a/learning_observer/learning_observer/pubsub/__init__.py +++ b/learning_observer/learning_observer/pubsub/__init__.py @@ -14,8 +14,10 @@ One project which came up which might be relevant: https://github.com/encode/broadcaster -''' +TODO this module is no longer being used by the LO system. +This should be removed. +''' import sys import learning_observer.settings as settings diff --git a/learning_observer/learning_observer/rosters.py b/learning_observer/learning_observer/rosters.py index a16b82e5..4329eb5b 100644 --- a/learning_observer/learning_observer/rosters.py +++ b/learning_observer/learning_observer/rosters.py @@ -67,6 +67,7 @@ import aiohttp.web import pathvalidate +import pmss import learning_observer.auth as auth import learning_observer.cache @@ -85,6 +86,18 @@ COURSE_URL = 'https://classroom.googleapis.com/v1/courses' ROSTER_URL = 'https://classroom.googleapis.com/v1/courses/{courseid}/students' +pmss.parser('roster_source', parent='string', choices=['google_api', 'all', 'test', 'filesystem'], transform=None) +pmss.register_field( + name='source', + type='roster_source', + description='Source to use for student class rosters. This can be\n'\ + '`all`: aggregate all available students into a single class\n'\ + '`test`: use sample course and student files\n'\ + '`filesystem`: read rosters defined on filesystem\n'\ + '`google_api`: fetch from Google API', + required=True +) + def clean_google_ajax_data(resp_json, key, sort_key, default=None, source=None): ''' @@ -250,12 +263,13 @@ async def synthetic_ajax( Google is an amazingly unreliable B2B company, and this lets us develop without relying on them. ''' - if settings.settings['roster_data']['source'] == 'test': + roster_source = settings.pmss_settings.source(types=['roster_data']) + if roster_source == 'test': synthetic_data = { COURSE_URL: paths.data("courses.json"), ROSTER_URL: paths.data("students.json") } - elif settings.settings['roster_data']['source'] == 'filesystem': + elif roster_source == 'filesystem': debug_log(request[constants.USER]) safe_userid = pathvalidate.sanitize_filename(request[constants.USER][constants.USER_ID]) courselist_file = "courselist-" + safe_userid @@ -271,8 +285,8 @@ async def synthetic_ajax( courselist_file=courselist_file)) } else: - debug_log("Roster data source is not recognized:", settings.settings['roster_data']['source']) - raise ValueError("Roster data source is not recognized: {}".format(settings.settings['roster_data']['source']) + debug_log("Roster data source is not recognized:", roster_source) + raise ValueError("Roster data source is not recognized: {}".format(roster_source) + " (should be 'test' or 'filesystem')") try: data = json.load(open(synthetic_data[url])) @@ -334,6 +348,7 @@ def init(): or smaller functions otherwise. ''' global ajax + roster_source = settings.pmss_settings.source(types=['roster_data']) if 'roster_data' not in settings.settings: print(settings.settings) raise learning_observer.prestartup.StartupCheck( @@ -343,11 +358,11 @@ def init(): raise learning_observer.prestartup.StartupCheck( "Settings file needs a `roster_data` element with a `source` element. No `source` element found." ) - elif settings.settings['roster_data']['source'] in ['test', 'filesystem']: + elif roster_source in ['test', 'filesystem']: ajax = synthetic_ajax - elif settings.settings['roster_data']['source'] in ["google_api"]: + elif roster_source in ["google_api"]: ajax = google_ajax - elif settings.settings['roster_data']['source'] in ["all"]: + elif roster_source in ["all"]: ajax = all_ajax else: raise learning_observer.prestartup.StartupCheck( @@ -369,8 +384,8 @@ def init(): ] } - if settings.settings['roster_data']['source'] in REQUIRED_PATHS: - r_paths = REQUIRED_PATHS[settings.settings['roster_data']['source']] + if roster_source in REQUIRED_PATHS: + r_paths = REQUIRED_PATHS[roster_source] for p in r_paths: if not os.path.exists(p): raise learning_observer.prestartup.StartupCheck( @@ -396,7 +411,7 @@ async def courselist(request): List all of the courses a teacher manages: Helper ''' # New code - if settings.settings['roster_data']['source'] in ["google_api"]: + if settings.pmss_settings.source(types=['roster_data']) in ["google_api"]: runtime = learning_observer.runtime.Runtime(request) return await learning_observer.google.courses(runtime) @@ -440,7 +455,7 @@ async def courseroster(request, course_id): ''' List all of the students in a course: Helper ''' - if settings.settings['roster_data']['source'] in ["google_api"]: + if settings.pmss_settings.source(types=['roster_data']) in ["google_api"]: runtime = learning_observer.runtime.Runtime(request) return await learning_observer.google.roster(runtime, courseId=course_id) diff --git a/learning_observer/learning_observer/settings.py b/learning_observer/learning_observer/settings.py index a3579b53..f9140b3b 100644 --- a/learning_observer/learning_observer/settings.py +++ b/learning_observer/learning_observer/settings.py @@ -19,6 +19,14 @@ import learning_observer.paths +import pmss + +pmss_settings = pmss.init( + prog=__name__, + description="A system for monitoring", + epilog="For more information, see PMSS documentation.", + rulesets=[pmss.YAMLFileRuleset(filename=learning_observer.paths.config_file())] +) # If we e.g. `import settings` and `import learning_observer.settings`, we # will load startup code twice, and end up with double the global variables. @@ -47,6 +55,7 @@ def parse_and_validate_arguments(): configuration file location. ''' global args, parser + # TODO use PMSS instead of argparse to track these settings parser = argparse.ArgumentParser( description='The Learning Observer', formatter_class=argparse.ArgumentDefaultsHelpFormatter @@ -101,12 +110,26 @@ def parse_and_validate_arguments(): return args +# TODO we ought to refactor how this enum is built +# so the values are strings instead of integers +# # DEV = Development, with full debugging # DEPLOY = Running on a server, with good performance # INTERACTIVE = Processing data offline RUN_MODES = enum.Enum('RUN_MODES', 'DEV DEPLOY INTERACTIVE') RUN_MODE = None +pmss.parser('run_mode', parent='string', choices=['dev', 'deploy', 'interactive'], transform=None) +pmss.register_field( + name='run_mode', + type='run_mode', + description="Set which mode the server is running in.\n"\ + "`dev` for local development with full debugging\n"\ + "`deploy` for running on a server with better performance\n"\ + "`interactive` for processing data offline", + required=True +) + settings = None @@ -145,11 +168,12 @@ def load_settings(config): # Development versus deployment. This is helpful for logging, verbose # output, etc. global RUN_MODE - if settings['config']['run_mode'] == 'dev': + settings_run_mode = pmss_settings.run_mode(types=['config']) + if settings_run_mode == 'dev': RUN_MODE = RUN_MODES.DEV - elif settings['config']['run_mode'] == 'deploy': + elif settings_run_mode == 'deploy': RUN_MODE = RUN_MODES.DEPLOY - elif settings['config']['run_mode'] == 'interactive': + elif settings_run_mode == 'interactive': RUN_MODE = RUN_MODES.INTERACTIVE else: raise ValueError("Configuration setting for run_mode must be either 'dev', 'deploy', or 'interactive'") @@ -235,11 +259,4 @@ def module_setting(module_name, setting=None, default=None): Returns `default` if no setting (or `None` if not set) ''' initialized() - module_settings = settings.get( - 'modules', {} - ).get(module_name, None) - if setting is None: - return module_settings - if module_settings is not None: - return module_settings.get(setting, default) - return default + return getattr(pmss_settings, setting)(types=['modules', module_name]) diff --git a/learning_observer/learning_observer/webapp_helpers.py b/learning_observer/learning_observer/webapp_helpers.py index cb53b326..a201c399 100644 --- a/learning_observer/learning_observer/webapp_helpers.py +++ b/learning_observer/learning_observer/webapp_helpers.py @@ -2,6 +2,7 @@ This file contains assorted middlewares and helpers ''' import errno +import pmss import socket import aiohttp_cors @@ -14,6 +15,21 @@ import learning_observer.settings as settings +pmss.register_field( + name='session_secret', + type=pmss.TYPES.passwordtoken, + description='Unique secret key for YOUR deployment to encrypt/decrypt '\ + 'data stored in the session object.', + required=True +) +pmss.register_field( + name='session_max_age', + type=pmss.TYPES.integer, + description='Max age of a session in seconds.', + required=True +) + + async def request_logger_middleware(request, handler): ''' Print all hits. Helpful for debugging. Should eventually go into a @@ -51,8 +67,8 @@ def setup_session_storage(app): This is a helper function to setup session storage. ''' aiohttp_session.setup(app, aiohttp_session.cookie_storage.EncryptedCookieStorage( - learning_observer.auth.fernet_key(settings.settings['aio']['session_secret']), - max_age=settings.settings['aio']['session_max_age'])) + learning_observer.auth.fernet_key(settings.pmss_settings.session_secret(types=['aio'])), + max_age=settings.pmss_settings.session_max_age(types=['aio']))) def find_open_port(): diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py index 5315ed69..0f98f0ea 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py @@ -128,7 +128,10 @@ def initialize_gpt_responder(): try the next one. ''' global gpt_responder - responders = learning_observer.settings.module_setting('writing_observer', 'gpt_responders', {}) + # TODO change this to use settings.module_settings() instead + # that method now uses pmss which doesn't support lists and + # dictionaries yet. + responders = learning_observer.settings.settings['modules']['writing_observer'].get('gpt_responders', {}) exceptions = [] for key in responders: if key not in GPT_RESPONDERS: diff --git a/modules/writing_observer/writing_observer/aggregator.py b/modules/writing_observer/writing_observer/aggregator.py index 06978a12..f45e0671 100644 --- a/modules/writing_observer/writing_observer/aggregator.py +++ b/modules/writing_observer/writing_observer/aggregator.py @@ -4,6 +4,7 @@ code in. TODO refractor the code to be more organized ''' +import pmss import sys import time @@ -17,6 +18,22 @@ # import traceback import learning_observer.util +pmss.register_field( + name='use_nlp', + description='Flag for loading in and using AWE Components. These are '\ + 'used to extract NLP metrics from text. When enabled, the '\ + 'server start-up time takes longer.', + type=pmss.pmsstypes.TYPES.boolean, + default=False +) +pmss.register_field( + name='use_google_documents', + description="Flag for whether we should fetch the ground truth of a "\ + "document's text from the Google API to fix any errors "\ + "in the reconstruction reducer.", + type=pmss.pmsstypes.TYPES.boolean, + default=False +) def excerpt_active_text( text, cursor_position, @@ -218,7 +235,9 @@ async def merge_with_student_data(writing_data, student_data): return writing_data -use_nlp = learning_observer.settings.module_setting('writing_observer', 'use_nlp', False) +# TODO the use_nlp initialization code ought to live in a +# registered startup function +use_nlp = learning_observer.settings.module_setting('writing_observer', 'use_nlp') if use_nlp: try: import writing_observer.awe_nlp @@ -279,7 +298,7 @@ async def fetch_doc_from_google(student, doc_id): await kvs.set(key, text) return text - if learning_observer.settings.module_setting('writing_observer', 'use_google_documents', False): + if learning_observer.settings.module_setting('writing_observer', 'use_google_documents'): [await fetch_doc_from_google( learning_observer.util.get_nested_dict_value(d, 'provenance.provenance.value.user_id'), learning_observer.util.get_nested_dict_value(d, 'doc_id') @@ -339,6 +358,10 @@ async def fetch_doc_from_google(student): return writing_data +# TODO This is old way of querying data from the system. +# The code should all still function, but the proper way to +# do this is using the Communication Protocol. +# This function and any references should be removed. async def latest_data(runtime, student_data, options=None): ''' Retrieves the latest writing data for a set of students. @@ -356,7 +379,7 @@ async def latest_data(runtime, student_data, options=None): # HACK we have a cache downstream that relies on redis_ephemeral being setup # when that is resolved, we can remove the feature flag # Update reconstruct data from KVS with ground truth from Google API - if learning_observer.settings.module_setting('writing_observer', 'use_google_documents', False): + if learning_observer.settings.module_setting('writing_observer', 'use_google_documents'): await update_reconstruct_data_with_google_api(runtime, student_data) # Get the latest documents with the students appended. diff --git a/modules/writing_observer/writing_observer/languagetool.py b/modules/writing_observer/writing_observer/languagetool.py index 0b15f2af..9a4d3e75 100644 --- a/modules/writing_observer/writing_observer/languagetool.py +++ b/modules/writing_observer/writing_observer/languagetool.py @@ -1,3 +1,4 @@ +import pmss import requests import learning_observer.cache @@ -13,6 +14,26 @@ DEFAULT_PORT = 8081 lt_started = False +pmss.register_field( + name='use_languagetool', + description='Flag for connecting to and using LT (LanguageTool). LT is'\ + 'used to find language and mechanical errors in text.', + type=pmss.pmsstypes.TYPES.boolean, + default=False +) +pmss.register_field( + name='languagetool_host', + description='Hostname of the system LanguageTool is running on.', + type=pmss.pmsstypes.TYPES.hostname, + default='localhost' +) +pmss.register_field( + name='languagetool_port', + description='Port of the system LanguageTool is running on.', + type=pmss.pmsstypes.TYPES.port, + default=DEFAULT_PORT +) + @learning_observer.prestartup.register_startup_check def check_languagetool_running(): @@ -23,9 +44,9 @@ def check_languagetool_running(): TODO create a stub function for language tool to return dummy data when testing. See aggregator.py:214 for stubbing in the function ''' - if learning_observer.settings.module_setting('writing_observer', 'use_languagetool', False): - host = learning_observer.settings.module_setting('writing_observer', 'languagetool_host', 'localhost') - port = learning_observer.settings.module_setting('writing_observer', 'languagetool_port', DEFAULT_PORT) + if learning_observer.settings.module_setting('writing_observer', 'use_languagetool'): + host = learning_observer.settings.module_setting('writing_observer', 'languagetool_host') + port = learning_observer.settings.module_setting('writing_observer', 'languagetool_port') # HACK the following code is a hack to check if the LanguageTool Server is up and running or not # We ought to set the LT Client object on startup (here); however, @@ -62,7 +83,7 @@ def initialize_client(): ''' global client if client is None: - port = learning_observer.settings.module_setting('writing_observer', 'languagetool_port', DEFAULT_PORT) + port = learning_observer.settings.module_setting('writing_observer', 'languagetool_port') client = languagetoolClient.languagetoolClient(port=port) diff --git a/modules/writing_observer/writing_observer/writing_analysis.py b/modules/writing_observer/writing_observer/writing_analysis.py index cdd1d7b2..c2866b03 100644 --- a/modules/writing_observer/writing_observer/writing_analysis.py +++ b/modules/writing_observer/writing_observer/writing_analysis.py @@ -121,10 +121,7 @@ async def reconstruct(event, internal_state): writing_observer.reconstruct_doc.google_text(), change_list ) state = internal_state.json - if learning_observer.settings.module_setting( - "writing_observer", - "verbose", - False): + if learning_observer.settings.module_setting('writing_observer', 'verbose'): print(state) return state, state @@ -134,10 +131,7 @@ async def event_count(event, internal_state): ''' An example of a per-document pipeline ''' - if learning_observer.settings.module_setting( - "writing_observer", - "verbose", - False): + if learning_observer.settings.module_setting('writing_observer', 'verbose'): print(event) state = {"count": internal_state.get('count', 0) + 1} diff --git a/requirements.txt b/requirements.txt index 1efb9a71..6eade25c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,6 +36,7 @@ openai pandas pathvalidate pep8 +pmss psutil pyasn1 py-bcrypt From a553fb10035299ffaac52f1b71198570b48cd7dc Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 14 May 2024 08:10:39 -0400 Subject: [PATCH 17/19] Added placeholder to documents to help with reconstruction Added placeholder character to documents to help reconstruction use indices better. Full commit message list: Arglab reconstruction fixes (#136) * fix indexing offset the doc position based on the initial index received * add placeholder characters for out of bounds indexes * minor * commented and formatted code * minor * fix formatting to match the format used in the project * explain the PLACEHOLDER variable * removed tracking of images, because of inconsistencies with 'te' event * added space --------- Co-authored-by: Code-beep12 --- .../writing_observer/reconstruct_doc.py | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/modules/writing_observer/writing_observer/reconstruct_doc.py b/modules/writing_observer/writing_observer/reconstruct_doc.py index 6370d509..ab609fe8 100644 --- a/modules/writing_observer/writing_observer/reconstruct_doc.py +++ b/modules/writing_observer/writing_observer/reconstruct_doc.py @@ -9,6 +9,22 @@ import json +""" +The placeholder character is used to fill gaps in the document, particularly +when there's a mismatch between the index and the length of the document's text (doc._text). +In an empty document, the insertion index (from the insert event `is`) is 1. However, +when the extension is started on a non-empty document, the first insertion index will be +greater than 1. This can lead to inconsistencies in indexing. +This placeholder is used to fill the 'gap' between len(doc._text) and the first insertion +index recorded in the logs. +This is done for the delete event ('ds') as well. +Say the first insert event in the logs is of a character 'a' with an index of 10 . +This placeholder will be used to fill the gap between 1 and 10. Internally doc._text will +have 10 characters and when returning the output, all placeholders will be removed from +doc._text leaving only the character 'a'. +""" +PLACEHOLDER = '\x00' + class google_text(object): ''' @@ -145,6 +161,12 @@ def json(self): 'edit_metadata': self._edit_metadata } + def get_parsed_text(self): + ''' + Returns the text ignoring the normal placeholders + ''' + return self._text.replace(PLACEHOLDER, "") + def command_list(doc, commands): ''' @@ -179,6 +201,13 @@ def insert(doc, ty, ibi, s): * `ibi` is where the insert happens * `s` is the string to insert ''' + # The index of the next character after the last character of the text + nextchar_index = len(doc._text) + 1 + # If the insert index is greater than nextchar_index, insert placeholders to fill the gap + # This occurs when the document has undergone modifications before the logger has been initialized + if ibi > nextchar_index: + insert(doc, ty, nextchar_index, PLACEHOLDER * (ibi - nextchar_index)) + doc.update("{start}{insert}{end}".format( start=doc._text[0:ibi - 1], insert=s, @@ -197,6 +226,15 @@ def delete(doc, ty, si, ei): * `si` is the index of the start of deletion * `ei` is the end ''' + # Index of the last character in the text. `si` and `ei` shouldn't go beyond that + lastchar_index = len(doc._text) + # If the deletion indexes are greater than nextchar_index, insert placeholders to fill the gap + # This occurs when the document has undergone modifications before the logger has been initialized + if si > lastchar_index: + insert(doc, ty, lastchar_index + 1, PLACEHOLDER * (si - lastchar_index)) + if ei > lastchar_index: + insert(doc, ty, lastchar_index + 1, PLACEHOLDER * (ei - lastchar_index)) + doc.update("{start}{end}".format( start=doc._text[0:si - 1], end=doc._text[ei:] @@ -243,7 +281,7 @@ def null(doc, **kwargs): 'is': insert, 'mlti': multi, 'null': null, - 'sl': null + 'sl': null, } if __name__ == '__main__': @@ -254,4 +292,4 @@ def null(doc, **kwargs): doc = command_list(doc, docs_history_short) print(doc) print(doc.position) - print(doc.edit_metadata) + print(doc.edit_metadata) \ No newline at end of file From 58843d5e06d4057ef16c03f8b88ebc81bdf1f5d6 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Thu, 23 May 2024 09:59:56 -0400 Subject: [PATCH 18/19] Update requirements.txt Remove openai dependency (this was removed many commits ago, but the dependency lingered on). --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6eade25c..b877a845 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,7 +32,6 @@ myst_parser names notebook numpy -openai pandas pathvalidate pep8 From 0e272665e13f752e34be3c156411ff4ffa98eb76 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Thu, 23 May 2024 10:44:54 -0400 Subject: [PATCH 19/19] removed ollama depenedency --- .../wo_bulk_essay_analysis/gpt.py | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py index 0f98f0ea..0f2ce6ee 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py @@ -1,5 +1,4 @@ import aiohttp -import ollama import os import learning_observer.communication_protocol.integration @@ -88,8 +87,8 @@ def __init__(self, **kwargs): # the Ollama client checks for the `OLLAMA_HOST` env variable # or defaults to `localhost:11434`. We provide a warning when # a specific host is not found. - ollama_host = kwargs.get('host', os.getenv('OLLAMA_HOST', None)) - if ollama_host is None: + self.ollama_host = kwargs.get('host', os.getenv('OLLAMA_HOST', None)) + if self.ollama_host is None: debug_log('WARNING:: Ollama host not specified. Defaulting to '\ '`localhost:11434`.\nTo set a specific host, set '\ '`modules.writing_observer.gpt_responders.ollama.host` '\ @@ -99,19 +98,26 @@ def __init__(self, **kwargs): 'run the following commands:\n'\ '```bash\ncurl https://ollama.ai/install.sh | sh\n'\ 'ollama run \n```') - self.client = ollama.AsyncClient(base_url=ollama_host) if ollama_host is not None else ollama.AsyncClient() + self.ollama_host = 'http://localhost:11434' async def chat_completion(self, prompt, system_prompt): + '''Ollama only returns a single item compared to GPT returning a list + ''' + url = f'{self.ollama_host}/api/chat' messages = [ {'role': 'system', 'content': system_prompt}, {'role': 'user', 'content': prompt} ] - try: - response = await self.client.chat(model=self.model, messages=messages) - return response['message']['content'] - except (ollama.ResponseError, ollama.RequestError) as e: - exception_text = f'Error during ollama chat completion:\n{e}' - raise GPTRequestErorr(exception_text) + content = {'model': self.model, 'messages': messages, 'stream': False} + async with aiohttp.ClientSession() as session: + async with session.post(url, json=content) as resp: + json_resp = await resp.json(content_type=None) + if resp.status == 200: + return json_resp['message']['content'] + error = 'Error occured while making Ollama request' + if 'error' in json_resp: + error += f"\n{json_resp['error']['message']}" + raise GPTRequestErorr(error) GPT_RESPONDERS = {