From 9cc440d2261f0378bfcf3cdd5b6d850d73625992 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Thu, 27 Feb 2025 15:10:43 -0500 Subject: [PATCH 1/8] added new options to llm dashboard - students per row, height, individual analysis, hide headers --- VERSION | 2 +- modules/lo_dash_react_components/package.json | 2 +- .../src/lib/components/LOPanelLayout.react.js | 5 +- .../wo_bulk_essay_analysis/assets/scripts.js | 190 ++++++++++++++---- .../wo_bulk_essay_analysis/assets/styles.css | 6 + .../dashboard/layout.py | 137 +++++++++++-- .../wo_bulk_essay_analysis/module.py | 6 +- modules/wo_classroom_text_highlighter/VERSION | 2 +- .../assets/scripts.js | 2 +- .../dash_dashboard.py | 4 +- 10 files changed, 289 insertions(+), 67 deletions(-) create mode 100644 modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css diff --git a/VERSION b/VERSION index e5a2081c..8e03b4af 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2025.02.26T16.23.19.270Z.e6d405f7.master +0.1.0+2025.02.27T20.10.43.091Z.5544fa81.berickson.022025.gpt.dashboard.updates diff --git a/modules/lo_dash_react_components/package.json b/modules/lo_dash_react_components/package.json index 8c2fe18f..9679e22d 100644 --- a/modules/lo_dash_react_components/package.json +++ b/modules/lo_dash_react_components/package.json @@ -14,7 +14,7 @@ "watch-css": "sass src/lib:lo_dash_react_components/css --watch", "start-all": "npm-run-all --parallel watch-css react-start webpack-start dash-start", "clean-build:python": "rm -rf dist/ && rm -rf build/", - "build:python": "npm run clean-build:python && python setup.py sdist bdist_wheel" + "build:python": "npm run clean-build:python && npm run build && python setup.py sdist bdist_wheel" }, "author": "Piotr Mitros ", "license": "AGPL-3.0", diff --git a/modules/lo_dash_react_components/src/lib/components/LOPanelLayout.react.js b/modules/lo_dash_react_components/src/lib/components/LOPanelLayout.react.js index e794cac0..deb6282a 100644 --- a/modules/lo_dash_react_components/src/lib/components/LOPanelLayout.react.js +++ b/modules/lo_dash_react_components/src/lib/components/LOPanelLayout.react.js @@ -24,7 +24,7 @@ export default class LOPanelLayout extends Component { {leftPanels.map(panel =>
{panel.children} @@ -36,7 +36,7 @@ export default class LOPanelLayout extends Component { {rightPanels.map(panel =>
{panel.children} @@ -77,6 +77,7 @@ LOPanelLayout.propTypes = { width: PropTypes.string, offset: PropTypes.number, side: PropTypes.string, + className: PropTypes.string, id: PropTypes.string.isRequired })), diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js index fe5c7756..45d4a997 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js @@ -8,17 +8,12 @@ if (!window.dash_clientside) { pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/3rd_party/pdf.worker.min.js'; -const createStudentCard = async function (s, prompt) { +const createStudentCard = async function (s, prompt, width, height, showHeader) { // TODO this ought to come from the comm protocol const document = Object.keys(s.documents)[0]; const student = s.documents[document]; const promptHash = await hashObject({ prompt }); - const header = { - namespace: 'dash_bootstrap_components', - type: 'CardHeader', - props: { children: student.profile.name.full_name } - }; const studentText = { namespace: 'lo_dash_react_components', type: 'WOAnnotatedText', @@ -58,39 +53,51 @@ const createStudentCard = async function (s, prompt) { }; const feedback = promptHash === student.option_hash ? feedbackMessage : feedbackLoading; const feedbackOrError = 'error' in student ? errorMessage : feedback; - const body = { - namespace: 'lo_dash_react_components', - type: 'LOPanelLayout', - props: { - children: studentText, - panels: [{ children: feedbackOrError, id: 'feedback-text', width: '40%' }], - shown: ['feedback-text'], - className: 'overflow-auto p-1' - } - }; - const card = { - namespace: 'dash_bootstrap_components', - type: 'Card', - props: { - children: [header, body], - style: { maxHeight: '375px' } + const studentTile = createDashComponent( + LO_DASH_REACT_COMPONENTS, 'WOStudentTextTile', + { + showHeader, + studentInfo: formatStudentData(s, []), + // TODO the selectedDocument ought to remain the same upon updating the student object + // i.e. it should be pulled from the current client student state + selectedDocument: 'latest', + childComponent: studentText, + id: { type: 'WOAIAssistStudentTileText', index: student.user_id }, + currentOptionHash: promptHash, + style: {height: `${height}px`} } - }; - return { - namespace: 'dash_bootstrap_components', - type: 'Col', - props: { - children: card, - id: student.user_id, - xs: 12, - lg: 6, - xxl: 4 + ); + const tileWrapper = createDashComponent( + DASH_HTML_COMPONENTS, 'Div', + { + className: 'position-relative mb-2', + children: [ + studentTile, + createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Card', + { children: feedbackOrError, body: true } + ), + createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Button', + { + id: { type: 'WOAIAssistStudentTileExpand', index: student.user_id }, + children: createDashComponent(DASH_HTML_COMPONENTS, 'I', {className: 'fas fa-expand'}), + class_name: 'position-absolute top-0 end-0 m-1', + color: 'transparent' + } + ) + ], + id: { type: 'WOAIAssistStudentTile', index: student.user_id }, + style: {width: `${(100 - width) / width}%`} } - }; + ) + return tileWrapper; }; const checkForResponse = function (s, promptHash) { + // TODO BUG we need to check the selected document here const document = Object.keys(s.documents)[0]; + // should probably be `s.documents[s.selected-document]` const student = s.documents[document]; return promptHash === student.option_hash; }; @@ -195,9 +202,9 @@ window.dash_clientside.bulk_essay_feedback = { */ update_input_history_on_query_submission: async function (clicks, query, history) { if (clicks > 0) { - return ['', history.concat(query)] + return history.concat(query) } - return [query, window.dash_clientside.no_update] + return window.dash_clientside.no_update }, /** @@ -221,7 +228,7 @@ window.dash_clientside.bulk_essay_feedback = { /** * update student cards based on new data in storage */ - updateStudentGridOutput: async function (wsStorageData, history) { + updateStudentGridOutput: async function (wsStorageData, history, width, height, showHeader) { if (!wsStorageData) { return 'No students'; } @@ -229,7 +236,7 @@ window.dash_clientside.bulk_essay_feedback = { let output = []; for (const student in wsStorageData) { - output = output.concat(await createStudentCard(wsStorageData[student], currPrompt)); + output = output.concat(await createStudentCard(wsStorageData[student], currPrompt, width, height, showHeader)); } return output; }, @@ -384,5 +391,112 @@ window.dash_clientside.bulk_essay_feedback = { const loadingProgress = returnedResponses / totalStudents + 0.1; const outputText = `Fetching responses from server. This will take a few minutes. (${returnedResponses}/${totalStudents} received)`; return [true, loadingProgress, outputText]; - } + }, + + adjustTileSize: function (width, height, studentIds) { + const total = studentIds.length; + return [ + Array(total).fill({width: `${(100 - width) / width}%`}), + Array(total).fill({height: `${height}px`}), + ]; + }, + + selectStudentForExpansion: function (clicks, shownPanels, ids) { + const triggeredItem = window.dash_clientside.callback_context?.triggered_id ?? null; + if (!triggeredItem) { return window.dash_clientside.no_update; } + let id = null; + if (triggeredItem?.type === 'WOAIAssistStudentTileExpand') { + id = triggeredItem?.index + if (clicks[ids.findIndex(item => item.index == id)]) { + shownPanels = shownPanels.concat('bulk-essay-analysis-expanded-student-panel'); + } + } else { + return window.dash_clientside.no_update; + } + return [id, shownPanels]; + }, + + expandSelectedStudent: async function (selectedStudent, wsData, showHeader, history) { + if (!selectedStudent | !(selectedStudent in wsData)) { + return window.dash_clientside.no_update; + } + const prompt = history.length > 0 ? history[history.length - 1] : ''; + const s = wsData[selectedStudent]; + const document = Object.keys(s.documents)[0]; + const student = s.documents[document]; + const promptHash = await hashObject({ prompt }); + + const studentText = { + namespace: 'lo_dash_react_components', + type: 'WOAnnotatedText', + props: { text: student.text, breakpoints: [], className: 'border-end' } + }; + const errorMessage = { + namespace: 'dash_html_components', + type: 'Div', + props: { + children: 'An error occurred while processing the text.' + } + }; + const feedbackMessage = { + namespace: 'dash_html_components', + type: 'Div', + props: { + children: student?.feedback ? student.feedback : '', + className: student?.feedback ? 'p-1 overflow-auto' : '', + style: { whiteSpace: 'pre-line' } + } + }; + const feedbackLoading = { + namespace: 'dash_html_components', + type: 'Div', + props: { + children: [{ + namespace: 'dash_bootstrap_components', + type: 'Spinner', + props: {} + }, { + namespace: 'dash_html_components', + type: 'Div', + props: { children: 'Waiting for a response.' } + }], + className: 'text-center' + } + }; + const feedback = promptHash === student.option_hash ? feedbackMessage : feedbackLoading; + const feedbackOrError = 'error' in student ? errorMessage : feedback; + const studentTile = createDashComponent( + LO_DASH_REACT_COMPONENTS, 'WOStudentTextTile', + { + showHeader, + studentInfo: formatStudentData(s, []), + // TODO the selectedDocument ought to remain the same upon updating the student object + // i.e. it should be pulled from the current client student state + selectedDocument: 'latest', + childComponent: studentText, + id: { type: 'WOAIAssistStudentTileText', index: student.user_id }, + currentOptionHash: promptHash, + } + ); + const individualWrapper = createDashComponent( + DASH_HTML_COMPONENTS, 'Div', + { + className: '', + children: [ + studentTile, + createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Card', + { children: feedbackOrError, body: true, className: 'individual-student-feedback' } + ) + ] + } + ) + return individualWrapper; + }, + + closeExpandedStudent: function (clicks, shown) { + if (!clicks) { return window.dash_clientside.no_update; } + shown = shown.filter(item => item !== 'bulk-essay-analysis-expanded-student-panel'); + return shown + }, }; diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css new file mode 100644 index 00000000..708f6272 --- /dev/null +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css @@ -0,0 +1,6 @@ +.individual-student-feedback { + position: -webkit-sticky; + position: sticky; + bottom: 0; + min-height: 250px; +} diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py index 45123ef2..639e1e7e 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py @@ -6,6 +6,7 @@ from dash_renderjson import DashRenderjson import datetime import lo_dash_react_components as lodrc +import random from dash import html, dcc, clientside_callback, ClientsideFunction, Output, Input, State, ALL @@ -24,10 +25,16 @@ panel_layout = f'{prefix}-panel-layout' -_advanced_toggle = f'{prefix}-advanced-toggle' -_advanced_collapse = f'{prefix}-advanced-collapse' +_advanced = f'{prefix}-advanced' +_advanced_toggle = f'{_advanced}-toggle' +_advanced_collapse = f'{_advanced}-collapse' +_advanced_width = f'{_advanced}-width' +_advanced_height = f'{_advanced}-height' +_advanced_hide_header = f'{_advanced}-hide-header' + +_system_input = f'{prefix}-system-prompt-input' +_system_input_tooltip = f'{_system_input}-tooltip' -system_input = f'{prefix}-system-prompt-input' # document source DOM ids doc_src = f'{prefix}-doc-src' doc_src_date = f'{prefix}-doc-src-date' @@ -60,13 +67,41 @@ submit = f'{prefix}-submit-btn' submit_warning_message = f'{prefix}-submit-warning-msg' +_student_data_wrapper = f'{prefix}-student-data' grid = f'{prefix}-essay-grid' +# Expanded student +_expanded_student = f'{prefix}-expanded-student' +_expanded_student_selected = f'{_expanded_student}-selected' +_expanded_student_panel = f'{_expanded_student}-panel' +_expanded_student_child = f'{_expanded_student}-child' +_expanded_student_close = f'{_expanded_student}-close' +expanded_student_component = html.Div([ + html.Div([ + html.H3('Individual Student', className='d-inline-block'), + dbc.Button( + html.I(className='fas fa-close'), + className='float-end', id=_expanded_student_close, + color='transparent'), + ]), + dbc.Input(id=_expanded_student_selected, class_name='d-none'), + html.Div(id=_expanded_student_child) +], className='p-2') + # default prompts -system_prompt = 'You are an assistant to a language arts teacher in a school setting. '\ - 'Your task is to help the teacher assess, understand, and provide feedback on student essays.' +system_prompt = 'You are a helpful assistant for grade school teachers. Your task is to analyze '\ + 'student writing and provide clear, constructive, and age-appropriate feedback. '\ + 'Focus on key writing traits such as clarity, creativity, grammar, and organization. '\ + 'When summarizing, highlight the main ideas and key details. Always maintain a '\ + 'positive and encouraging tone to support student growth.' -starting_prompt = 'Provide 3 bullet points summarizing the following text:\n{student_text}' +starting_prompt = [ + 'Provide 3 bullet points summarizing this text:\n{student_text}', + 'List 3 strengths in this student\'s writing. Use bullet points and focus on creativity or clear ideas:\n{student_text}', + 'Find 2-3 grammar or spelling errors in this text. For each, quote the sentence and suggest a fix:\n{student_text}', + 'Identify 1) Main theme 2) Best sentence 3) One area to improve. Use numbered responses:\n{student_text}', + 'Give one specific compliment and one gentle suggestion to improve this story:\n{student_text}' +] def layout(): @@ -76,11 +111,7 @@ def layout(): # advanced menu for system prompt advanced = [ html.Div([ - dbc.Label('System prompt'), - dbc.Textarea(id=system_input, value=system_prompt) - ]), - html.Div([ - dbc.Label('Document Source'), + html.H4('Document Source'), dbc.RadioItems(options=[ {'label': 'Latest Document', 'value': 'latest' }, {'label': 'Specific Time', 'value': 'ts'}, @@ -88,7 +119,14 @@ def layout(): dbc.InputGroup([ dcc.DatePickerSingle(id=doc_src_date, date=datetime.date.today()), dbc.Input(type='time', id=doc_src_timestamp, value=datetime.datetime.now().strftime("%H:%M")) - ]) + ]), + html.H4('View Options'), + dbc.Label('Students per row'), + dbc.Input(type='number', min=1, max=10, value=3, step=1, id=_advanced_width), + dbc.Label('Height of student tile'), + dcc.Slider(min=100, max=800, marks=None, value=350, id=_advanced_height), + dbc.Label('Student name headers'), + dbc.Switch(value=True, id=_advanced_hide_header, label='Show/Hide'), ]) ] @@ -122,7 +160,17 @@ def layout(): # then remove the `class_name='d-none'` from this button. dbc.Button(dcc.Upload([html.I(className='fas fa-plus me-1'), 'Upload'], accept='.pdf', id=attachment_upload), class_name='d-none'), dbc.CardBody([ - dbc.Textarea(id=query_input, value=starting_prompt, class_name='h-100', style={'minHeight': '150px'}), + dbc.Label([ + 'System prompt', + html.I(className='fas fa-circle-question ms-1', id=_system_input_tooltip) + ]), + dbc.Tooltip( + "A system prompt guides the AI's responses. It sets the context for how the AI should analyze or summarize student text.", + target=_system_input_tooltip + ), + dbc.Textarea(id=_system_input, value=system_prompt, style={'minHeight': '120px'}), + dbc.Label('Query'), + dbc.Textarea(id=query_input, value=random.choice(starting_prompt), class_name='h-100', style={'minHeight': '150px'}), html.Div([ html.Span([ 'Placeholders', @@ -131,7 +179,7 @@ def layout(): html.Span([], id=tags), ], className='mt-1'), dbc.Tooltip( - 'Click a placeholder to insert it into your prompt. Upon submission, it will be replaced with the corresponding value.', + 'Click a placeholder to insert it into your query. Upon submission, it will be replaced with the corresponding value.', target=placeholder_tooltip ), dcc.Store(id=tag_store, data={'student_text': ''}), @@ -154,7 +202,7 @@ def layout(): # overall container cont = dbc.Container([ - html.H2('Writing Observer - AskGPT'), + html.H1('Writing Observer - Classroom AI Feedback Assistant'), dbc.InputGroup([ dbc.InputGroupText(lodrc.LOConnectionAIO(aio_id=_websocket)), dbc.Button([html.I(className='fas fa-cog me-1'), 'Advanced'], id=_advanced_toggle), @@ -173,7 +221,15 @@ def layout(): alert_component, html.H3('Student Text', className='mt-1'), loading_component, - dbc.Row(id=grid, class_name='g-4'), + lodrc.LOPanelLayout( + html.Div(id=grid, className='d-flex justify-content-between flex-wrap'), + panels=[ + {'children': expanded_student_component, + 'width': '30%', 'id': _expanded_student_panel, + 'side': 'right', 'className': 'vh-100 overflow-auto'} + ], + id=_student_data_wrapper, shown=[] + ), ], fluid=True) return html.Div(cont) @@ -205,7 +261,7 @@ def layout(): Input(doc_src_date, 'date'), Input(doc_src_timestamp, 'value'), State(query_input, 'value'), - State(system_input, 'value'), + State(_system_input, 'value'), State(tag_store, 'data'), ) @@ -223,7 +279,6 @@ def layout(): # add submitted query to history and clear input clientside_callback( ClientsideFunction(namespace='bulk_essay_feedback', function_name='update_input_history_on_query_submission'), - Output(query_input, 'value'), Output(history_store, 'data'), Input(submit, 'n_clicks'), State(query_input, 'value'), @@ -263,7 +318,10 @@ def layout(): ClientsideFunction(namespace=_namespace, function_name='updateStudentGridOutput'), Output(grid, 'children'), Input(lodrc.LOConnectionAIO.ids.ws_store(_websocket), 'data'), - Input(history_store, 'data') + Input(history_store, 'data'), + Input(_advanced_width, 'value'), + Input(_advanced_height, 'value'), + Input(_advanced_hide_header, 'value') ) # append tag in curly braces to input @@ -314,3 +372,44 @@ def layout(): Input(lodrc.LOConnectionAIO.ids.ws_store(_websocket), 'data'), Input(history_store, 'data') ) + +# Adjust student tile size +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='adjustTileSize'), + Output({'type': 'WOAIAssistStudentTile', 'index': ALL}, 'style', allow_duplicate=True), + Output({'type': 'WOAIAssistStudentTileText', 'index': ALL}, 'style', allow_duplicate=True), + Input(_advanced_width, 'value'), + Input(_advanced_height, 'value'), + State({'type': 'WOAIAssistStudentTile', 'index': ALL}, 'id'), + prevent_initial_call=True +) + +# Expand a single student +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='selectStudentForExpansion'), + Output(_expanded_student_selected, 'value'), + Output(_student_data_wrapper, 'shown', allow_duplicate=True), + Input({'type': 'WOAIAssistStudentTileExpand', 'index': ALL}, 'n_clicks'), + State(_student_data_wrapper, 'shown'), + State({'type': 'WOAIAssistStudentTile', 'index': ALL}, 'id'), + prevent_initial_call=True +) + +# Update expanded children based on selected student +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='expandSelectedStudent'), + Output(_expanded_student_child, 'children'), + Input(_expanded_student_selected, 'value'), + Input(lodrc.LOConnectionAIO.ids.ws_store(_websocket), 'data'), + Input(_advanced_hide_header, 'value'), + Input(history_store, 'data'), +) + +# Close expanded student +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='closeExpandedStudent'), + Output(_student_data_wrapper, 'shown', allow_duplicate=True), + Input(_expanded_student_close, 'n_clicks'), + State(_student_data_wrapper, 'shown'), + prevent_initial_call=True +) diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py index d4c9f951..bb37220b 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py @@ -6,15 +6,15 @@ import wo_bulk_essay_analysis.dashboard.layout -NAME = "Writing Observer - AskGPT" +NAME = "Writing Observer - Classroom AI Feedback Assistant" DASH_PAGES = [ { "MODULE": wo_bulk_essay_analysis.dashboard.layout, "LAYOUT": wo_bulk_essay_analysis.dashboard.layout.layout, "ASSETS": 'assets', - "TITLE": "AskGPT", - "DESCRIPTION": "The AskGPT is a robust educational tool that leverages AI to simultaneously analyze and provide feedback on large batches of essays, delivering comprehensive insights and constructive critiques for educators in diverse group settings.", + "TITLE": "Classroom AI Feedback Assistant", + "DESCRIPTION": "The Classroom AI Feedback Assistant is a robust educational tool that leverages AI to simultaneously analyze and provide feedback on large batches of essays, delivering comprehensive insights and constructive critiques for educators in diverse group settings.", "SUBPATH": "bulk-essay-analysis", "CSS": [ thirdparty_url("css/bootstrap.min.css"), diff --git a/modules/wo_classroom_text_highlighter/VERSION b/modules/wo_classroom_text_highlighter/VERSION index e5a2081c..8e03b4af 100644 --- a/modules/wo_classroom_text_highlighter/VERSION +++ b/modules/wo_classroom_text_highlighter/VERSION @@ -1 +1 @@ -0.1.0+2025.02.26T16.23.19.270Z.e6d405f7.master +0.1.0+2025.02.27T20.10.43.091Z.5544fa81.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js index 134662c7..ae233d95 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js @@ -230,7 +230,7 @@ window.dash_clientside.wo_classroom_text_highlighter = { id: { type: 'WOStudentTileExpand', index: student}, children: createDashComponent(DASH_HTML_COMPONENTS, 'I', {className: 'fas fa-expand'}), class_name: 'position-absolute top-0 end-0 m-1', - color: 'light' + color: 'transparent' } ) ], diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/dash_dashboard.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/dash_dashboard.py index add6b385..0724dce8 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/dash_dashboard.py +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/dash_dashboard.py @@ -127,7 +127,9 @@ def layout(): html.Div(id=_output, className='d-flex justify-content-between flex-wrap'), panels=[ {'children': options_component, 'width': '30%', 'id': _options_prefix, 'side': 'left' }, - {'children': expanded_student_component, 'width': '30%', 'id': _expanded_student_panel, 'side': 'right' } + {'children': expanded_student_component, + 'width': '30%', 'id': _expanded_student_panel, + 'side': 'right', 'className': 'vh-100 overflow-auto'} ], id=_options_collapse, shown=[] ), From cbfeabe5a24e2781b4eeae10450c41dd97aafb0b Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 4 Mar 2025 15:37:04 -0500 Subject: [PATCH 2/8] added ability to add placeholders and also upload to populate placeholder text --- VERSION | 2 +- modules/wo_bulk_essay_analysis/VERSION | 1 + modules/wo_bulk_essay_analysis/pyproject.toml | 3 + modules/wo_bulk_essay_analysis/setup.cfg | 5 +- modules/wo_bulk_essay_analysis/setup.py | 13 -- .../wo_bulk_essay_analysis/assets/scripts.js | 117 +++++++++++--- .../wo_bulk_essay_analysis/assets/styles.css | 9 ++ .../dashboard/layout.py | 148 +++++++++++------- modules/wo_classroom_text_highlighter/VERSION | 2 +- .../assets/scripts.js | 1 + 10 files changed, 204 insertions(+), 97 deletions(-) create mode 100644 modules/wo_bulk_essay_analysis/VERSION create mode 100644 modules/wo_bulk_essay_analysis/pyproject.toml delete mode 100644 modules/wo_bulk_essay_analysis/setup.py diff --git a/VERSION b/VERSION index 8e03b4af..c84dbbf4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2025.02.27T20.10.43.091Z.5544fa81.berickson.022025.gpt.dashboard.updates +0.1.0+2025.03.04T20.37.04.583Z.9cc440d2.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_bulk_essay_analysis/VERSION b/modules/wo_bulk_essay_analysis/VERSION new file mode 100644 index 00000000..c84dbbf4 --- /dev/null +++ b/modules/wo_bulk_essay_analysis/VERSION @@ -0,0 +1 @@ +0.1.0+2025.03.04T20.37.04.583Z.9cc440d2.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_bulk_essay_analysis/pyproject.toml b/modules/wo_bulk_essay_analysis/pyproject.toml new file mode 100644 index 00000000..8fe2f47a --- /dev/null +++ b/modules/wo_bulk_essay_analysis/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/modules/wo_bulk_essay_analysis/setup.cfg b/modules/wo_bulk_essay_analysis/setup.cfg index 524a8e0b..a4db1fe6 100644 --- a/modules/wo_bulk_essay_analysis/setup.cfg +++ b/modules/wo_bulk_essay_analysis/setup.cfg @@ -2,12 +2,15 @@ name = Writing Observer Automated Essay Feedback description = Dashboard for interfacing a classroom of essays with automated feedback url = https://github.com/ETS-Next-Gen/writing_observer -version = 0.1 +version = file:VERSION [options] packages = find: include_package_data = true +[options.package_data] +wo_bulk_essay_analysis = assets/* + [options.entry_points] lo_modules = wo_bulk_essay_analysis = wo_bulk_essay_analysis.module diff --git a/modules/wo_bulk_essay_analysis/setup.py b/modules/wo_bulk_essay_analysis/setup.py deleted file mode 100644 index e562a4cb..00000000 --- a/modules/wo_bulk_essay_analysis/setup.py +++ /dev/null @@ -1,13 +0,0 @@ -''' -Rather minimalistic install script. To install, run `python -setup.py develop` or just install via requirements.txt -''' - -from setuptools import setup, find_packages - -setup( - name="wo_bulk_essay_analysis", - package_data={ - 'wo_bulk_essay_analysis': ['assets/*'], - } -) diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js index 45d4a997..0e38b14c 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js @@ -249,25 +249,25 @@ window.dash_clientside.bulk_essay_feedback = { * - default attachment name (based on filename) * - whether we show the attachment upload panel */ - open_and_populate_attachment_panel: async function (contents, filename, timestamp, shown) { + handleFileUploadToTextField: async function (contents, filename, timestamp) { if (filename === undefined) { - return ['', '', shown]; + return ''; } let data = '' if (filename.endsWith('.pdf')) { data = await extractPDF(contents); } // TODO add support for docx-like files - return [data, filename.slice(0, filename.lastIndexOf('.')), shown.concat('attachment')]; + return data; }, /** * append tag in curly braces to input */ add_tag_to_input: function (clicks, curr, store) { - const trig = window.dash_clientside.callback_context.triggered[0] - const trigProp = trig.prop_id - const trigJSON = JSON.parse(trigProp.slice(0, trigProp.lastIndexOf('.'))) + const trig = window.dash_clientside.callback_context.triggered[0]; + const trigProp = trig.prop_id; + const trigJSON = JSON.parse(trigProp.slice(0, trigProp.lastIndexOf('.'))); if (trig.value > 0) { return curr.concat(` {${trigJSON.index}}`) } @@ -307,13 +307,32 @@ window.dash_clientside.bulk_essay_feedback = { * - save button disbaled status * - helper text for why we are disabled */ - disable_attachment_save_button: function (label, tags) { - if (label.length === 0) { - return [true, 'Please add a unique label to your attachment'] - } else if (tags.includes(label)) { - return [true, `Label ${label} is already in use.`] + disableAttachmentSaveButton: function (label, content, currentTagStore, replacementId) { + const tags = Object.keys(currentTagStore); + if (label.length === 0 & content.length === 0) { + return [true, '']; + } else if (label.length === 0) { + return [true, 'Add a label for your content']; + } else if (content.length === 0) { + return [true, 'Add content for your label']; + } else if ((!replacementId | replacementId !== label) & tags.includes(label)) { + return [true, `Label ${label} is already in use.`]; } - return [false, ''] + return [false, '']; + }, + + openTagAddModal: function (clicks, editClicks, currentTagStore, ids) { + const triggeredItem = window.dash_clientside.callback_context?.triggered_id ?? null; + if (!triggeredItem) { return window.dash_clientside.no_update; } + if (triggeredItem === 'bulk-essay-analysis-tags-add-open-btn') { + return [true, null, '', ''] + } + const id = triggeredItem.index; + const index = ids.findIndex(item => item.index == id); + if (editClicks[index]) { + return [true, id, id, currentTagStore[id]]; + } + return window.dash_clientside.no_update; }, /** @@ -322,18 +341,48 @@ window.dash_clientside.bulk_essay_feedback = { update_tag_buttons: function (tagStore) { const tagLabels = Object.keys(tagStore); const tags = tagLabels.map((val) => { - const button = { - namespace: 'dash_bootstrap_components', - type: 'Button', - props: { + const isStudentText = val === 'student_text'; + const button = createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Button', + { children: val, - id: { type: 'bulk-essay-analysis-tag', index: val }, + id: { type: 'bulk-essay-analysis-tags-tag', index: val }, n_clicks: 0, - size: 'sm', - color: 'secondary' + color: isStudentText ? 'warning' : 'secondary' } - }; - return button; + ); + if (isStudentText) { return button; } + const editButton = createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Button', + { + children: createDashComponent(DASH_HTML_COMPONENTS, 'I', { className: 'fas fa-edit'}), + color: 'secondary', + id: { type: 'bulk-essay-analysis-tags-tag-edit', index: val }, + n_clicks: 0 + } + ); + const deleteButton = createDashComponent( + DASH_CORE_COMPONENTS, 'ConfirmDialogProvider', + { + children: createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Button', + { + children: createDashComponent(DASH_HTML_COMPONENTS, 'I', { className: 'fas fa-trash'}), + color: 'secondary' + } + ), + id: { type: 'bulk-essay-analysis-tags-tag-delete', index: val }, + message: `Are you sure you want to delete the \`${val}\` placeholder?` + } + ); + const buttonGroup = createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'ButtonGroup', + { + children: [button, editButton, deleteButton], + class_name: 'placeholder ms-1' + } + ) + return buttonGroup; }); return tags; }, @@ -341,13 +390,29 @@ window.dash_clientside.bulk_essay_feedback = { /** * Save attachment to tag storage */ - save_attachment: function (clicks, label, text, tagStore, shown) { + savePlaceholder: function (clicks, label, text, replacementId, tagStore) { if (clicks > 0) { - const newStore = tagStore - newStore[label] = text - return [newStore, shown.filter(item => item !== 'attachment')] + const newStore = tagStore; + if (!!replacementId & replacementId !== label) { + delete newStore[replacementId]; + } + newStore[label] = text; + return [newStore, false]; + } + return window.dash_clientside.no_update; + }, + + removePlaceholder: function (clicks, tagStore, ids) { + const triggeredItem = window.dash_clientside.callback_context?.triggered_id ?? null; + if (!triggeredItem) { return window.dash_clientside.no_update; } + const id = triggeredItem.index; + const index = ids.findIndex(item => item.index == id); + if (clicks[index]) { + const newStore = tagStore; + delete newStore[id]; + return newStore; } - return tagStore + return window.dash_clientside.no_update; }, /** diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css index 708f6272..63b0eced 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css @@ -4,3 +4,12 @@ bottom: 0; min-height: 250px; } + +.placeholder button:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.placeholder>div:last-child { + display: inline; +} diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py index 639e1e7e..95c06f72 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py @@ -40,19 +40,52 @@ doc_src_date = f'{prefix}-doc-src-date' doc_src_timestamp = f'{prefix}-doc-src-timestamp' -# attachment upload DOM ids -attachment_upload = f'{prefix}-attachment-upload' -attachment_label = f'{prefix}-attachment-label' -attachment_extracted_text = f'{prefix}-attachment-extracted-text' -attachment_save = f'{prefix}-attachment-save' -attachment_warning_message = f'{prefix}-attachment-warning-message' -attachment_store = f'{prefix}-attachment-store' - # placeholder DOM ids -tags = f'{prefix}-tags' -placeholder_tooltip = f'{prefix}-placeholder-tooltip' -tag = f'{prefix}-tag' -tag_store = f'{prefix}-tags-store' +_tags = f'{prefix}-tags' +placeholder_tooltip = f'{_tags}-placeholder-tooltip' +tag = f'{_tags}-tag' +_tag_edit = f'{tag}-edit' +_tag_delete = f'{tag}-delete' +tag_store = f'{_tags}-tags-store' +_tag_add = f'{_tags}-add' +_tag_replacement_id = f'{_tag_add}-replacement-id' +_tag_add_modal = f'{_tag_add}-modal' +_tag_add_open = f'{_tag_add}-open-btn' +_tag_add_label = f'{_tag_add}-label' +_tag_add_text = f'{_tag_add}-text' +_tag_add_upload = f'{_tag_add}-upload' +_tag_add_warning = f'{_tag_add}-warning' +_tag_add_save = f'{_tag_add}-save' +tag_modal = dbc.Modal([ + dbc.ModalHeader('Add Placeholder'), + dbc.ModalBody([ + dbc.Input(id=_tag_replacement_id, class_name='d-none'), + dbc.Label('Label'), + dbc.Input( + placeholder='Name your placeholder (e.g., "Narrative Grade 8 Rubric")', + id=_tag_add_label, + value='' + ), + dbc.Label('Contents'), + dbc.Textarea( + placeholder='Enter text here... Uploading a file replaces this content', + id=_tag_add_text, + style={'height': '300px'}, + value='' + ), + dbc.Button( + dcc.Upload( + [html.I(className='fas fa-plus me-1'), 'Upload'], + accept='.txt,.docx,.md,.pdf', + id=_tag_add_upload + ) + ) + ]), + dbc.ModalFooter([ + html.Small(id=_tag_add_warning, className='text-danger'), + dbc.Button('Save', class_name='ms-auto', id=_tag_add_save), + ]) +], id=_tag_add_modal, is_open=False) # prompt history DOM ids history_body = f'{prefix}-history-body' @@ -137,28 +170,9 @@ def layout(): dcc.Store(id=history_store, data=[]) ], class_name='h-100') - # attachment information panel - attachment_panel = dbc.Card([ - dbc.CardHeader('Upload'), - dbc.CardBody([ - dbc.Label('What is this?'), - dbc.Input(placeholder='e.g. argumentative attachment', id=attachment_label, value=''), - dbc.Label('Extracted text from attachment'), - dbc.Textarea(value='', id=attachment_extracted_text, style={'height': '300px'}) - ]), - dbc.CardFooter([ - html.Small(id=attachment_warning_message, className='text-danger'), - dbc.Button('Save', id=attachment_save, color='primary', n_clicks=0, class_name='float-end') - ]), - dcc.Store(id=attachment_store, data='') - ], class_name='h-100') - # query creator panel input_panel = dbc.Card([ dbc.CardHeader('Prompt Input'), - # TODO figure out the proper way to create new tags/upload docs - # then remove the `class_name='d-none'` from this button. - dbc.Button(dcc.Upload([html.I(className='fas fa-plus me-1'), 'Upload'], accept='.pdf', id=attachment_upload), class_name='d-none'), dbc.CardBody([ dbc.Label([ 'System prompt', @@ -176,12 +190,14 @@ def layout(): 'Placeholders', html.I(className='fas fa-circle-question ms-1', id=placeholder_tooltip) ], className='me-1'), - html.Span([], id=tags), + html.Span([], id=_tags), + dbc.Button([html.I(className='fas fa-add me-1'), 'Add'], id=_tag_add_open, class_name='ms-1') ], className='mt-1'), dbc.Tooltip( 'Click a placeholder to insert it into your query. Upon submission, it will be replaced with the corresponding value.', target=placeholder_tooltip ), + tag_modal, dcc.Store(id=tag_store, data={'student_text': ''}), ]), dbc.CardFooter([ @@ -213,7 +229,6 @@ def layout(): input_panel, panels=[ {'children': history_favorite_panel, 'width': '30%', 'id': 'history-favorite'}, - {'children': attachment_panel, 'width': '40%', 'id': 'attachment'}, ], shown=['history-favorite'], id=panel_layout @@ -273,7 +288,7 @@ def layout(): Output(submit_warning_message, 'children'), Input(query_input, 'value'), Input(_loading_collapse, 'is_open'), - State(tag_store, 'data') + Input(tag_store, 'data') ) # add submitted query to history and clear input @@ -293,16 +308,27 @@ def layout(): Input(history_store, 'data') ) +# Toggle if the add placeholder is open or not +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='openTagAddModal'), + Output(_tag_add_modal, 'is_open'), + Output(_tag_replacement_id, 'value'), + Output(_tag_add_label, 'value'), + Output(_tag_add_text, 'value'), + Input(_tag_add_open, 'n_clicks'), + Input({'type': _tag_edit, 'index': ALL}, 'n_clicks'), + State(tag_store, 'data'), + State({'type': _tag_edit, 'index': ALL}, 'id'), +) + # show attachment panel upon uploading document and populate fields clientside_callback( - ClientsideFunction(namespace='bulk_essay_feedback', function_name='open_and_populate_attachment_panel'), - Output(attachment_extracted_text, 'value'), - Output(attachment_label, 'value'), - Output(panel_layout, 'shown'), - Input(attachment_upload, 'contents'), - Input(attachment_upload, 'filename'), - Input(attachment_upload, 'last_modified'), - State(panel_layout, 'shown') + ClientsideFunction(namespace='bulk_essay_feedback', function_name='handleFileUploadToTextField'), + Output(_tag_add_text, 'value', allow_duplicate=True), + Input(_tag_add_upload, 'contents'), + Input(_tag_add_upload, 'filename'), + Input(_tag_add_upload, 'last_modified'), + prevent_initial_call=True ) clientside_callback( @@ -336,30 +362,42 @@ def layout(): # enable/disable the save attachment button if tag is already in use/blank clientside_callback( - ClientsideFunction(namespace='bulk_essay_feedback', function_name='disable_attachment_save_button'), - Output(attachment_save, 'disabled'), - Output(attachment_warning_message, 'children'), - Input(attachment_label, 'value'), - State({'type': tag, 'index': ALL}, 'value') + ClientsideFunction(namespace='bulk_essay_feedback', function_name='disableAttachmentSaveButton'), + Output(_tag_add_save, 'disabled'), + Output(_tag_add_warning, 'children'), + Input(_tag_add_label, 'value'), + Input(_tag_add_text, 'value'), + State(tag_store, 'data'), + State(_tag_replacement_id, 'value') ) # populate word bank of tags clientside_callback( ClientsideFunction(namespace='bulk_essay_feedback', function_name='update_tag_buttons'), - Output(tags, 'children'), + Output(_tags, 'children'), Input(tag_store, 'data') ) -# save attachment to tag storage +# save placeholder to storage clientside_callback( - ClientsideFunction(namespace='bulk_essay_feedback', function_name='save_attachment'), + ClientsideFunction(namespace='bulk_essay_feedback', function_name='savePlaceholder'), Output(tag_store, 'data'), - Output(panel_layout, 'shown', allow_duplicate=True), - Input(attachment_save, 'n_clicks'), - State(attachment_label, 'value'), - State(attachment_extracted_text, 'value'), + Output(_tag_add_modal, 'is_open', allow_duplicate=True), + Input(_tag_add_save, 'n_clicks'), + State(_tag_add_label, 'value'), + State(_tag_add_text, 'value'), + State(_tag_replacement_id, 'value'), + State(tag_store, 'data'), + prevent_initial_call=True +) + +# remove placeholder from storage +clientside_callback( + ClientsideFunction(namespace='bulk_essay_feedback', function_name='removePlaceholder'), + Output(tag_store, 'data', allow_duplicate=True), + Input({'type': _tag_delete, 'index': ALL}, 'submit_n_clicks'), State(tag_store, 'data'), - State(panel_layout, 'shown'), + State({'type': _tag_delete, 'index': ALL}, 'id'), prevent_initial_call=True ) diff --git a/modules/wo_classroom_text_highlighter/VERSION b/modules/wo_classroom_text_highlighter/VERSION index 8e03b4af..c84dbbf4 100644 --- a/modules/wo_classroom_text_highlighter/VERSION +++ b/modules/wo_classroom_text_highlighter/VERSION @@ -1 +1 @@ -0.1.0+2025.02.27T20.10.43.091Z.5544fa81.berickson.022025.gpt.dashboard.updates +0.1.0+2025.03.04T20.37.04.583Z.9cc440d2.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js index ae233d95..6b6f0184 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js @@ -8,6 +8,7 @@ if (!window.dash_clientside) { } const DASH_HTML_COMPONENTS = 'dash_html_components'; +const DASH_CORE_COMPONENTS = 'dash_core_components'; const DASH_BOOTSTRAP_COMPONENTS = 'dash_bootstrap_components'; const LO_DASH_REACT_COMPONENTS = 'lo_dash_react_components'; From 6cd371992b5a5894a2b38011c18ee94390ec5400 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Thu, 6 Mar 2025 10:46:55 -0500 Subject: [PATCH 3/8] added ability to upload txt, md, docx, and pdf files --- VERSION | 2 +- modules/wo_bulk_essay_analysis/VERSION | 2 +- .../wo_bulk_essay_analysis/assets/scripts.js | 45 ++++++++++++++----- .../wo_bulk_essay_analysis/assets/styles.css | 4 +- .../dashboard/layout.py | 4 +- .../wo_bulk_essay_analysis/module.py | 10 ++++- 6 files changed, 50 insertions(+), 17 deletions(-) diff --git a/VERSION b/VERSION index c84dbbf4..05df75e5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2025.03.04T20.37.04.583Z.9cc440d2.berickson.022025.gpt.dashboard.updates +0.1.0+2025.03.06T15.46.55.393Z.cbfeabe5.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_bulk_essay_analysis/VERSION b/modules/wo_bulk_essay_analysis/VERSION index c84dbbf4..05df75e5 100644 --- a/modules/wo_bulk_essay_analysis/VERSION +++ b/modules/wo_bulk_essay_analysis/VERSION @@ -1 +1 @@ -0.1.0+2025.03.04T20.37.04.583Z.9cc440d2.berickson.022025.gpt.dashboard.updates +0.1.0+2025.03.06T15.46.55.393Z.cbfeabe5.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js index 0e38b14c..d02277ad 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js @@ -135,6 +135,20 @@ const extractPDF = async function (base64String) { return allText }; +const extractTXT = async function (base64String) { + return atob(charactersAfterChar(base64String, ',')); +}; + +const extractMD = async function (base64String) { + return atob(charactersAfterChar(base64String, ',')); +}; + +const extractDOCX = async function (base64String) { + const arrayBuffer = Uint8Array.from(atob(charactersAfterChar(base64String, ',')), c => c.charCodeAt(0)).buffer; + const result = await mammoth.extractRawText({ arrayBuffer: arrayBuffer }); + return result.value; // The raw text +}; + window.dash_clientside.bulk_essay_feedback = { /** * Sends data to server via websocket @@ -254,10 +268,21 @@ window.dash_clientside.bulk_essay_feedback = { return ''; } let data = '' - if (filename.endsWith('.pdf')) { - data = await extractPDF(contents); + try { + if (filename.endsWith('.pdf')) { + data = await extractPDF(contents); + } else if (filename.endsWith('.txt')) { + data = await extractTXT(contents); + } else if (filename.endsWith('.docx')) { + data = await extractDOCX(contents); + } else if (filename.endsWith('.md')) { + data = await extractMD(contents); + } else { + console.error('Unsupported file type'); + } + } catch (error) { + console.error('Error extracting text from file:', error); } - // TODO add support for docx-like files return data; }, @@ -348,17 +373,16 @@ window.dash_clientside.bulk_essay_feedback = { children: val, id: { type: 'bulk-essay-analysis-tags-tag', index: val }, n_clicks: 0, - color: isStudentText ? 'warning' : 'secondary' + color: isStudentText ? 'warning' : 'info' } ); - if (isStudentText) { return button; } const editButton = createDashComponent( DASH_BOOTSTRAP_COMPONENTS, 'Button', { children: createDashComponent(DASH_HTML_COMPONENTS, 'I', { className: 'fas fa-edit'}), - color: 'secondary', id: { type: 'bulk-essay-analysis-tags-tag-edit', index: val }, - n_clicks: 0 + n_clicks: 0, + color: 'info' } ); const deleteButton = createDashComponent( @@ -368,18 +392,19 @@ window.dash_clientside.bulk_essay_feedback = { DASH_BOOTSTRAP_COMPONENTS, 'Button', { children: createDashComponent(DASH_HTML_COMPONENTS, 'I', { className: 'fas fa-trash'}), - color: 'secondary' + color: 'info', } ), id: { type: 'bulk-essay-analysis-tags-tag-delete', index: val }, message: `Are you sure you want to delete the \`${val}\` placeholder?` } ); + const buttons = isStudentText ? [button] : [button, editButton, deleteButton] const buttonGroup = createDashComponent( DASH_BOOTSTRAP_COMPONENTS, 'ButtonGroup', { - children: [button, editButton, deleteButton], - class_name: 'placeholder ms-1' + children: buttons, + class_name: `${isStudentText ? '' : 'prompt-variable-tag'} ms-1 mb-1`, } ) return buttonGroup; diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css index 63b0eced..9e9d7347 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/styles.css @@ -5,11 +5,11 @@ min-height: 250px; } -.placeholder button:last-child { +.prompt-variable-tag button:last-child { border-top-left-radius: 0; border-bottom-left-radius: 0; } -.placeholder>div:last-child { +.prompt-variable-tag>div:last-child { display: inline; } diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py index 95c06f72..cc63da2c 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py @@ -76,7 +76,7 @@ dbc.Button( dcc.Upload( [html.I(className='fas fa-plus me-1'), 'Upload'], - accept='.txt,.docx,.md,.pdf', + accept='.txt,.md,.pdf,.docx', id=_tag_add_upload ) ) @@ -191,7 +191,7 @@ def layout(): html.I(className='fas fa-circle-question ms-1', id=placeholder_tooltip) ], className='me-1'), html.Span([], id=_tags), - dbc.Button([html.I(className='fas fa-add me-1'), 'Add'], id=_tag_add_open, class_name='ms-1') + dbc.Button([html.I(className='fas fa-add me-1'), 'Add'], id=_tag_add_open, class_name='ms-1 mb-1') ], className='mt-1'), dbc.Tooltip( 'Click a placeholder to insert it into your query. Upon submission, it will be replaced with the corresponding value.', diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py index bb37220b..5e6b624f 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py @@ -22,7 +22,8 @@ ], "SCRIPTS": [ thirdparty_url('pdf.js'), - thirdparty_url('pdf.worker.js') + thirdparty_url('pdf.worker.js'), + thirdparty_url('mammoth.js') ] } ] @@ -64,6 +65,13 @@ '3ebb7dad9946bd3d00bdcd29527dc753fde4b950b2a7a052bd8f66ee643bb736767' } }, + 'mammoth.js': { + 'url': 'https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.9.0/mammoth.browser.min.js', + 'hash': { + '1.9.0': '7e77162c6d0103528615896ba72fcca385ab2f64699cd06d744a6d740c16179' + '322e02e2d45adf1c4d8720f6c8ac7c54e19c6a061eb0814f2abb4b80738d8766a' + } + }, "css/bootstrap.min.css": d.BOOTSTRAP_MIN_CSS, "css/fontawesome_all.css": d.FONTAWESOME_CSS, "webfonts/fa-solid-900.woff2": d.FONTAWESOME_WOFF2, From 57041e9f9d29fa8f76b56dd2ad378582336b9ed2 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 10 Mar 2025 15:22:26 -0400 Subject: [PATCH 4/8] minor updates --- VERSION | 2 +- modules/wo_bulk_essay_analysis/VERSION | 2 +- .../wo_bulk_essay_analysis/assets/scripts.js | 8 ++++---- .../wo_bulk_essay_analysis/dashboard/layout.py | 2 +- .../wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py | 1 + 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/VERSION b/VERSION index 05df75e5..35316a17 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2025.03.06T15.46.55.393Z.cbfeabe5.berickson.022025.gpt.dashboard.updates +0.1.0+2025.03.10T19.22.26.296Z.6cd37199.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_bulk_essay_analysis/VERSION b/modules/wo_bulk_essay_analysis/VERSION index 05df75e5..35316a17 100644 --- a/modules/wo_bulk_essay_analysis/VERSION +++ b/modules/wo_bulk_essay_analysis/VERSION @@ -1 +1 @@ -0.1.0+2025.03.06T15.46.55.393Z.cbfeabe5.berickson.022025.gpt.dashboard.updates +0.1.0+2025.03.10T19.22.26.296Z.6cd37199.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js index d02277ad..c681530e 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js @@ -27,8 +27,8 @@ const createStudentCard = async function (s, prompt, width, height, showHeader) } }; const feedbackMessage = { - namespace: 'dash_html_components', - type: 'Div', + namespace: DASH_CORE_COMPONENTS, + type: 'Markdown', props: { children: student?.feedback ? student.feedback : '', className: student?.feedback ? 'p-1 overflow-auto' : '', @@ -529,8 +529,8 @@ window.dash_clientside.bulk_essay_feedback = { } }; const feedbackMessage = { - namespace: 'dash_html_components', - type: 'Div', + namespace: DASH_CORE_COMPONENTS, + type: 'Markdown', props: { children: student?.feedback ? student.feedback : '', className: student?.feedback ? 'p-1 overflow-auto' : '', diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py index cc63da2c..89ad19a2 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py @@ -201,7 +201,7 @@ def layout(): dcc.Store(id=tag_store, data={'student_text': ''}), ]), dbc.CardFooter([ - html.Small(id=submit_warning_message, className='text-danger'), + html.Small(id=submit_warning_message, className='text-warning'), dbc.Button('Submit', color='primary', id=submit, n_clicks=0, class_name='float-end') ]) ]) 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 c2790fa0..7562f9ef 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,4 +1,5 @@ import learning_observer.communication_protocol.integration +import learning_observer.cache import learning_observer.prestartup import learning_observer.settings From d500c747a01733b27cb4c4fd4a29096d95eb86d4 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Thu, 27 Mar 2025 16:51:04 -0400 Subject: [PATCH 5/8] various small improvements plus document source item --- VERSION | 2 +- learning_observer/VERSION | 2 +- learning_observer/learning_observer/cache.py | 6 +- .../communication_protocol/executor.py | 5 +- .../communication_protocol/util.py | 1 - .../learning_observer/dashboard.py | 10 +- learning_observer/learning_observer/google.py | 13 ++ .../learning_observer/rosters.py | 4 +- learning_observer/learning_observer/routes.py | 2 + .../LODocumentSourceSelectorAIO.py | 167 ++++++++++++++++++ .../lo_dash_react_components/__init__.py | 1 + modules/wo_bulk_essay_analysis/VERSION | 2 +- .../wo_bulk_essay_analysis/assets/scripts.js | 61 +++---- .../dashboard/layout.py | 46 ++--- .../wo_bulk_essay_analysis/module.py | 21 --- modules/wo_classroom_text_highlighter/VERSION | 2 +- .../assets/scripts.js | 109 ++++++------ .../dash_dashboard.py | 21 ++- modules/writing_observer/VERSION | 2 +- .../writing_observer/aggregator.py | 21 ++- .../writing_observer/document_timestamps.py | 9 +- .../writing_observer/module.py | 52 +++++- 22 files changed, 384 insertions(+), 175 deletions(-) create mode 100644 modules/lo_dash_react_components/lo_dash_react_components/LODocumentSourceSelectorAIO.py diff --git a/VERSION b/VERSION index 35316a17..0abb9458 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2025.03.10T19.22.26.296Z.6cd37199.berickson.022025.gpt.dashboard.updates +0.1.0+2025.03.27T20.51.04.053Z.57041e9f.berickson.022025.gpt.dashboard.updates diff --git a/learning_observer/VERSION b/learning_observer/VERSION index f6be1883..0abb9458 100644 --- a/learning_observer/VERSION +++ b/learning_observer/VERSION @@ -1 +1 @@ -0.1.0+2024.12.20T14.09.00.406Z.257bae3a.master +0.1.0+2025.03.27T20.51.04.053Z.57041e9f.berickson.022025.gpt.dashboard.updates diff --git a/learning_observer/learning_observer/cache.py b/learning_observer/learning_observer/cache.py index 180bb7aa..ab11259c 100644 --- a/learning_observer/learning_observer/cache.py +++ b/learning_observer/learning_observer/cache.py @@ -8,8 +8,8 @@ cache_backend = None -def create_key_from_args(*args, **kwargs): - key_dict = {'args': args, 'kwargs': kwargs} +def create_key_from_args(func, *args, **kwargs): + key_dict = {'func': str(func), 'args': args, 'kwargs': kwargs} key_str = json.dumps(key_dict, sort_keys=True) return key_str @@ -37,7 +37,7 @@ async def wrapper(*args, **kwargs): return await func(*args, **kwargs) # process item if the cache is present - key = create_key_from_args(args, kwargs) + key = create_key_from_args(func, args, kwargs) if key in await cache_backend.keys(): return await cache_backend[key] result = await func(*args, **kwargs) diff --git a/learning_observer/learning_observer/communication_protocol/executor.py b/learning_observer/learning_observer/communication_protocol/executor.py index 3b0fd0ea..c161ecf7 100644 --- a/learning_observer/learning_observer/communication_protocol/executor.py +++ b/learning_observer/learning_observer/communication_protocol/executor.py @@ -93,6 +93,9 @@ async def call_dispatch(functions, function_name, args, kwargs): ... learning_observer.communication_protocol.exception.DAGExecutionException: ('Function double did not execute properly during call.', 'call_dispatch', {'function_name': 'double', 'args': [None], 'kwargs': {}, 'error': 'Input cannot be None'}, ...) """ + # TODO add in provenance to the call + # this probably requires switching to an async generator instead of regular return + provenance = {'function_name': function_name, 'args': args, 'kwargs': kwargs} try: function = functions[function_name] result = function(*args, **kwargs) @@ -528,6 +531,7 @@ async def hack_handle_keys(function, STUDENTS=None, STUDENTS_path=None, RESOURCE We create a list of fields needed for the `make_key()` function as well as the provenance associated with each. These are zipped together and returned to the user. """ + # TODO do something if `func` is not found func = next((item for item in learning_observer.module_loader.reducers() if item['id'] == function), None) fields_and_provenances = None if STUDENTS is not None and RESOURCES is None: @@ -703,7 +707,6 @@ async def visit(node_name): # We've already done this one. if node_name in visited: return nodes[node_name] - # Execute all the child nodes await walk_dict(nodes[node_name]) diff --git a/learning_observer/learning_observer/communication_protocol/util.py b/learning_observer/learning_observer/communication_protocol/util.py index fc3f8f8c..b48eb9c7 100644 --- a/learning_observer/learning_observer/communication_protocol/util.py +++ b/learning_observer/learning_observer/communication_protocol/util.py @@ -2,7 +2,6 @@ This file provides utility functions specific to the communication protocol. ''' -import inspect import learning_observer.communication_protocol.query as q import learning_observer.communication_protocol.exception diff --git a/learning_observer/learning_observer/dashboard.py b/learning_observer/learning_observer/dashboard.py index cc11a386..8c83b774 100644 --- a/learning_observer/learning_observer/dashboard.py +++ b/learning_observer/learning_observer/dashboard.py @@ -560,10 +560,15 @@ def _find_student_or_resource(d): provenance = d['provenance'] output = [] if 'STUDENT' in provenance: + output.append('students') output.append(provenance['STUDENT']['user_id']) if 'RESOURCE' in provenance: - output.append('documents') - output.append(provenance['RESOURCE']['doc_id']) + if 'doc_id' in provenance['RESOURCE']: + output.append('documents') + output.append(provenance['RESOURCE']['doc_id']) + if 'assignment_id' in provenance['RESOURCE']: + output.append('assignments') + output.append(provenance['RESOURCE']['assignment_id']) if output: return output return _find_student_or_resource(provenance) @@ -629,6 +634,7 @@ async def _execute_dag(dag_query, target, params): await _drive_generator(generator, dag_query['kwargs']) # Handle rescheduling the execution of the DAG for fresh data + # TODO add some way to specific specific endpoint delays dag_delay = dag_query['kwargs'].get('rerun_dag_delay', 10) if dag_delay < 0: # if dag_delay is negative, we skip repeated execution diff --git a/learning_observer/learning_observer/google.py b/learning_observer/learning_observer/google.py index 60cf3ebd..f1e45f89 100644 --- a/learning_observer/learning_observer/google.py +++ b/learning_observer/learning_observer/google.py @@ -381,6 +381,19 @@ def clean_course_list(google_json): return courses +@register_cleaner('course_work', 'assignments') +def clean_course_work(google_json): + ''' + Google's course work is one object deeper than we'd like, and update_time + sort order is nicer. This will clean it up a bit + ''' + assignments = google_json.get('courseWork', []) + assignments.sort( + key=lambda x: x.get('update_time', 0) + ) + return assignments + + # Google Docs def _force_text_length(text, length): ''' diff --git a/learning_observer/learning_observer/rosters.py b/learning_observer/learning_observer/rosters.py index 4329eb5b..6e66baf3 100644 --- a/learning_observer/learning_observer/rosters.py +++ b/learning_observer/learning_observer/rosters.py @@ -446,9 +446,9 @@ async def memoize_courseroster_runtime(runtime, course_id): individual nodes are handled: static, dynamic (current), or memoized. ''' @learning_observer.cache.async_memoization() - async def memoization_layer(c): + async def course_roster_memoization_layer(c): return await courseroster_runtime(runtime, c) - return await memoization_layer(course_id) + return await course_roster_memoization_layer(course_id) async def courseroster(request, course_id): diff --git a/learning_observer/learning_observer/routes.py b/learning_observer/learning_observer/routes.py index 5034b81b..b1fa0adc 100644 --- a/learning_observer/learning_observer/routes.py +++ b/learning_observer/learning_observer/routes.py @@ -240,6 +240,8 @@ def register_auth_webapp_views(app): debug_log("Running with Google authentication") app.add_routes([ aiohttp.web.get( + # TODO only allow the available sign-in options found in pmss + # '/auth/login/{provider:google|canvas|schoology}', '/auth/login/{provider:google}', handler=learning_observer.auth.social_handler), ]) diff --git a/modules/lo_dash_react_components/lo_dash_react_components/LODocumentSourceSelectorAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/LODocumentSourceSelectorAIO.py new file mode 100644 index 00000000..8025ba28 --- /dev/null +++ b/modules/lo_dash_react_components/lo_dash_react_components/LODocumentSourceSelectorAIO.py @@ -0,0 +1,167 @@ +''' +This file creates an All-In-One component for the Learning +Observer server connection. This handles updating data from the +server (based on individual tree updates), storing any errors +that occured, and showing the time since it was last updated. +''' +from dash import html, dcc, clientside_callback, Output, Input, State, MATCH +import dash_bootstrap_components as dbc +import datetime +import uuid + +class LODocumentSourceSelectorAIO(dbc.Card): + class ids: + source_selector = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'source_selector', + 'aio_id': aio_id + } + assignment_wrapper = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'assignment_wrapper', + 'aio_id': aio_id + } + assignment_input = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'assignment_input', + 'aio_id': aio_id + } + datetime_wrapper = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'datetime_wrapper', + 'aio_id': aio_id + } + date_input = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'date_input', + 'aio_id': aio_id + } + timestamp_input = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'timestamp_input', + 'aio_id': aio_id + } + kwargs_store = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'kwargs_store', + 'aio_id': aio_id + } + apply = lambda aio_id: { + 'component': 'LODocumentSourceSelectorAIO', + 'subcomponent': 'apply', + 'aio_id': aio_id + } + + ids = ids + + def __init__(self, aio_id=None): + if aio_id is None: + aio_id = str(uuid.uuid4()) + + + card_body = dbc.CardBody([ + dbc.Label('Source'), + dbc.RadioItems( + id=self.ids.source_selector(aio_id), + options={'latest': 'Latest Document', + 'assignment': 'Assignment', + 'timestamp': 'Specific Time'}, + inline=True, + value='latest'), + html.Div('Additional Arguments'), + html.Div([ + dbc.RadioItems(id=self.ids.assignment_input(aio_id)), + ], id=self.ids.assignment_wrapper(aio_id)), + html.Div([ + dbc.InputGroup([ + dcc.DatePickerSingle( + id=self.ids.date_input(aio_id), + date=datetime.date.today()), + dbc.Input( + type='time', + id=self.ids.timestamp_input(aio_id), + value=datetime.datetime.now().strftime("%H:%M")) + ]) + ], id=self.ids.datetime_wrapper(aio_id)), + dbc.Button('Apply', id=self.ids.apply(aio_id), class_name='mt-1', n_clicks=0), + dcc.Store(id=self.ids.kwargs_store(aio_id), data={'src': 'latest'}) + ]) + component = [ + dbc.CardHeader('Document Source'), + card_body + ] + super().__init__(component) + + # Update data + clientside_callback( + '''function (clicks, src, assignment, date, time) { + if (clicks === 0) { return window.dash_clientside.no_update; } + let kwargs = {}; + if (src === 'assignment') { + kwargs.assignment = assignment; + } else if (src === 'timestamp') { + kwargs.requested_timestamp = new Date(`${date}T${time}`).getTime().toString() + } + return {src, kwargs}; + } + ''', + Output(ids.kwargs_store(MATCH), 'data'), + Input(ids.apply(MATCH), 'n_clicks'), + State(ids.source_selector(MATCH), 'value'), + State(ids.assignment_input(MATCH), 'value'), + State(ids.date_input(MATCH), 'date'), + State(ids.timestamp_input(MATCH), 'value'), + ) + + clientside_callback( + '''function (src) { + if (src === 'assignment') { + return ['d-none', '']; + } else if (src === 'timestamp') { + return ['', 'd-none'] + } + return ['d-none', 'd-none']; + } + ''', + Output(ids.datetime_wrapper(MATCH), 'className'), + Output(ids.assignment_wrapper(MATCH), 'className'), + Input(ids.source_selector(MATCH), 'value'), + ) + + clientside_callback( + '''async function (id, hash) { + if (hash.length === 0) { return window.dash_clientside.no_update; } + const decoded = decode_string_dict(hash.slice(1)); + if (!decoded.course_id) { return window.dash_clientside.no_update; } + const response = await fetch(`${window.location.protocol}//${window.location.hostname}:${window.location.port}/google/course_work/${decoded.course_id}`); + const data = await response.json(); + const options = data.courseWork.map(function (item) { + return { label: item.title, value: item.id }; + }); + return options; + } + ''', + Output(ids.assignment_input(MATCH), 'options'), + Input(ids.source_selector(MATCH), 'id'), + Input('_pages_location', 'hash'), + ) + + clientside_callback( + '''function (src, assignment, date, time, current) { + if (src === 'assignment' & (assignment === undefined | current.kwargs?.assignment === assignment)) { + return true; + } + if (src === 'timestamp' & current.kwargs?.requested_timestamp === new Date(`${date}T${time}`).getTime().toString()) { + return true; + } + if (src === 'latest' & current.src === 'latest') { return true; } + return false; + } + ''', + Output(ids.apply(MATCH), 'disabled'), + Input(ids.source_selector(MATCH), 'value'), + Input(ids.assignment_input(MATCH), 'value'), + Input(ids.date_input(MATCH), 'date'), + Input(ids.timestamp_input(MATCH), 'value'), + Input(ids.kwargs_store(MATCH), 'data'), + ) diff --git a/modules/lo_dash_react_components/lo_dash_react_components/__init__.py b/modules/lo_dash_react_components/lo_dash_react_components/__init__.py index 4c6fecb3..f22c1ea6 100644 --- a/modules/lo_dash_react_components/lo_dash_react_components/__init__.py +++ b/modules/lo_dash_react_components/lo_dash_react_components/__init__.py @@ -12,6 +12,7 @@ from .LOConnectionStatusAIO import LOConnectionStatusAIO from .LOConnectionAIO import LOConnectionAIO +from .LODocumentSourceSelectorAIO import LODocumentSourceSelectorAIO from .ProfileSidebarAIO import ProfileSidebarAIO if not hasattr(_dash, '__plotly_dash') and not hasattr(_dash, 'development'): diff --git a/modules/wo_bulk_essay_analysis/VERSION b/modules/wo_bulk_essay_analysis/VERSION index 35316a17..0abb9458 100644 --- a/modules/wo_bulk_essay_analysis/VERSION +++ b/modules/wo_bulk_essay_analysis/VERSION @@ -1 +1 @@ -0.1.0+2025.03.10T19.22.26.296Z.6cd37199.berickson.022025.gpt.dashboard.updates +0.1.0+2025.03.27T20.51.04.053Z.57041e9f.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js index c681530e..a6a21d78 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js @@ -9,9 +9,9 @@ if (!window.dash_clientside) { pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/3rd_party/pdf.worker.min.js'; const createStudentCard = async function (s, prompt, width, height, showHeader) { - // TODO this ought to come from the comm protocol - const document = Object.keys(s.documents)[0]; - const student = s.documents[document]; + + const selectedDocument = s.doc_id || Object.keys(s.documents || {})[0] || ''; + const student = s.documents?.[selectedDocument] ?? {}; const promptHash = await hashObject({ prompt }); const studentText = { @@ -53,18 +53,17 @@ const createStudentCard = async function (s, prompt, width, height, showHeader) }; const feedback = promptHash === student.option_hash ? feedbackMessage : feedbackLoading; const feedbackOrError = 'error' in student ? errorMessage : feedback; + const userId = student?.user_id || '0'; const studentTile = createDashComponent( LO_DASH_REACT_COMPONENTS, 'WOStudentTextTile', { showHeader, studentInfo: formatStudentData(s, []), - // TODO the selectedDocument ought to remain the same upon updating the student object - // i.e. it should be pulled from the current client student state - selectedDocument: 'latest', + selectedDocument, childComponent: studentText, - id: { type: 'WOAIAssistStudentTileText', index: student.user_id }, + id: { type: 'WOAIAssistStudentTileText', index: userId }, currentOptionHash: promptHash, - style: {height: `${height}px`} + style: { height: `${height}px` } } ); const tileWrapper = createDashComponent( @@ -80,14 +79,14 @@ const createStudentCard = async function (s, prompt, width, height, showHeader) createDashComponent( DASH_BOOTSTRAP_COMPONENTS, 'Button', { - id: { type: 'WOAIAssistStudentTileExpand', index: student.user_id }, + id: { type: 'WOAIAssistStudentTileExpand', index: userId }, children: createDashComponent(DASH_HTML_COMPONENTS, 'I', {className: 'fas fa-expand'}), class_name: 'position-absolute top-0 end-0 m-1', color: 'transparent' } ) ], - id: { type: 'WOAIAssistStudentTile', index: student.user_id }, + id: { type: 'WOAIAssistStudentTile', index: userId }, style: {width: `${(100 - width) / width}%`} } ) @@ -95,10 +94,9 @@ const createStudentCard = async function (s, prompt, width, height, showHeader) }; const checkForResponse = function (s, promptHash) { - // TODO BUG we need to check the selected document here - const document = Object.keys(s.documents)[0]; - // should probably be `s.documents[s.selected-document]` - const student = s.documents[document]; + if (!('documents' in s)) { return false; } + const selectedDocument = s.doc_id || Object.keys(s.documents || {})[0] || ''; + const student = s.documents[selectedDocument]; return promptHash === student.option_hash; }; @@ -153,7 +151,7 @@ window.dash_clientside.bulk_essay_feedback = { /** * Sends data to server via websocket */ - send_to_loconnection: async function (state, hash, clicks, docSrc, docDate, docTime, query, systemPrompt, tags) { + send_to_loconnection: async function (state, hash, clicks, docKwargs, query, systemPrompt, tags) { if (state === undefined) { return window.dash_clientside.no_update; } @@ -164,11 +162,11 @@ window.dash_clientside.bulk_essay_feedback = { decoded.gpt_prompt = ''; decoded.message_id = ''; - decoded.doc_source = docSrc; - decoded.requested_timestamp = new Date(`${docDate}T${docTime}`).getTime().toString(); + decoded.doc_source = docKwargs.src; + decoded.doc_source_kwargs = docKwargs.kwargs; // TODO what is a reasonable time to wait inbetween subsequent calls for // the same arguments - decoded.rerun_dag_delay = 120; + decoded.rerun_dag_delay = 30; const trig = window.dash_clientside.callback_context.triggered[0]; if (trig.prop_id.includes('bulk-essay-analysis-submit-btn')) { @@ -182,8 +180,8 @@ window.dash_clientside.bulk_essay_feedback = { const message = { wo: { - execution_dag: 'wo_bulk_essay_analysis', - target_exports: ['gpt_bulk'], + execution_dag: 'writing_observer', + target_exports: ['gpt_bulk', 'document_list', 'document_sources'], kwargs: decoded } }; @@ -249,8 +247,8 @@ window.dash_clientside.bulk_essay_feedback = { const currPrompt = history.length > 0 ? history[history.length - 1] : ''; let output = []; - for (const student in wsStorageData) { - output = output.concat(await createStudentCard(wsStorageData[student], currPrompt, width, height, showHeader)); + for (const student in wsStorageData.students) { + output = output.concat(await createStudentCard(wsStorageData.students[student], currPrompt, width, height, showHeader)); } return output; }, @@ -461,13 +459,6 @@ window.dash_clientside.bulk_essay_feedback = { return [text, true, error]; }, - disable_doc_src_datetime: function (value) { - if (value === 'ts') { - return [false, false]; - } - return [true, true]; - }, - updateLoadingInformation: async function (wsStorageData, history) { const noLoading = [false, 0, '']; if (!wsStorageData) { @@ -475,8 +466,8 @@ window.dash_clientside.bulk_essay_feedback = { } const currentPrompt = history.length > 0 ? history[history.length - 1] : ''; const promptHash = await hashObject({ prompt: currentPrompt }); - const returnedResponses = Object.values(wsStorageData).filter(student => checkForResponse(student, promptHash)).length; - const totalStudents = Object.keys(wsStorageData).length; + const returnedResponses = Object.values(wsStorageData.students).filter(student => checkForResponse(student, promptHash)).length; + const totalStudents = Object.keys(wsStorageData.students).length; if (totalStudents === returnedResponses) { return noLoading; } const loadingProgress = returnedResponses / totalStudents + 0.1; const outputText = `Fetching responses from server. This will take a few minutes. (${returnedResponses}/${totalStudents} received)`; @@ -507,11 +498,13 @@ window.dash_clientside.bulk_essay_feedback = { }, expandSelectedStudent: async function (selectedStudent, wsData, showHeader, history) { - if (!selectedStudent | !(selectedStudent in wsData)) { + console.log('wsData', wsData); + if (!selectedStudent | !(selectedStudent in wsData.students)) { return window.dash_clientside.no_update; } const prompt = history.length > 0 ? history[history.length - 1] : ''; - const s = wsData[selectedStudent]; + const s = wsData.students[selectedStudent]; + const selectedDocument = s.doc_id || Object.keys(s.documents || {})[0] || ''; const document = Object.keys(s.documents)[0]; const student = s.documents[document]; const promptHash = await hashObject({ prompt }); @@ -562,7 +555,7 @@ window.dash_clientside.bulk_essay_feedback = { studentInfo: formatStudentData(s, []), // TODO the selectedDocument ought to remain the same upon updating the student object // i.e. it should be pulled from the current client student state - selectedDocument: 'latest', + selectedDocument, childComponent: studentText, id: { type: 'WOAIAssistStudentTileText', index: student.user_id }, currentOptionHash: promptHash, diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py index 89ad19a2..5df3c0f7 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/dashboard/layout.py @@ -26,6 +26,7 @@ panel_layout = f'{prefix}-panel-layout' _advanced = f'{prefix}-advanced' +_advanced_doc_src = f'{_advanced}-document-source' _advanced_toggle = f'{_advanced}-toggle' _advanced_collapse = f'{_advanced}-collapse' _advanced_width = f'{_advanced}-width' @@ -35,11 +36,6 @@ _system_input = f'{prefix}-system-prompt-input' _system_input_tooltip = f'{_system_input}-tooltip' -# document source DOM ids -doc_src = f'{prefix}-doc-src' -doc_src_date = f'{prefix}-doc-src-date' -doc_src_timestamp = f'{prefix}-doc-src-timestamp' - # placeholder DOM ids _tags = f'{prefix}-tags' placeholder_tooltip = f'{_tags}-placeholder-tooltip' @@ -144,22 +140,18 @@ def layout(): # advanced menu for system prompt advanced = [ html.Div([ - html.H4('Document Source'), - dbc.RadioItems(options=[ - {'label': 'Latest Document', 'value': 'latest' }, - {'label': 'Specific Time', 'value': 'ts'}, - ], value='latest', id=doc_src), - dbc.InputGroup([ - dcc.DatePickerSingle(id=doc_src_date, date=datetime.date.today()), - dbc.Input(type='time', id=doc_src_timestamp, value=datetime.datetime.now().strftime("%H:%M")) + lodrc.LODocumentSourceSelectorAIO(aio_id=_advanced_doc_src), + dbc.Card([ + dbc.CardHeader('View Options'), + dbc.CardBody([ + dbc.Label('Students per row'), + dbc.Input(type='number', min=1, max=10, value=3, step=1, id=_advanced_width), + dbc.Label('Height of student tile'), + dcc.Slider(min=100, max=800, marks=None, value=350, id=_advanced_height), + dbc.Label('Student name headers'), + dbc.Switch(value=True, id=_advanced_hide_header, label='Show/Hide'), + ]) ]), - html.H4('View Options'), - dbc.Label('Students per row'), - dbc.Input(type='number', min=1, max=10, value=3, step=1, id=_advanced_width), - dbc.Label('Height of student tile'), - dcc.Slider(min=100, max=800, marks=None, value=350, id=_advanced_height), - dbc.Label('Student name headers'), - dbc.Switch(value=True, id=_advanced_hide_header, label='Show/Hide'), ]) ] @@ -201,7 +193,7 @@ def layout(): dcc.Store(id=tag_store, data={'student_text': ''}), ]), dbc.CardFooter([ - html.Small(id=submit_warning_message, className='text-warning'), + html.Small(id=submit_warning_message, className='text-secondary'), dbc.Button('Submit', color='primary', id=submit, n_clicks=0, class_name='float-end') ]) ]) @@ -249,14 +241,6 @@ def layout(): return html.Div(cont) -# disbale document date/time options -clientside_callback( - ClientsideFunction(namespace='bulk_essay_feedback', function_name='disable_doc_src_datetime'), - Output(doc_src_date, 'disabled'), - Output(doc_src_timestamp, 'disabled'), - Input(doc_src, 'value') -) - # Toggle if the advanced menu collapse is open or not clientside_callback( ClientsideFunction(namespace=_namespace, function_name='toggleAdvanced'), @@ -272,9 +256,7 @@ def layout(): Input(lodrc.LOConnectionAIO.ids.websocket(_websocket), 'state'), # used for initial setup Input('_pages_location', 'hash'), Input(submit, 'n_clicks'), - Input(doc_src, 'value'), - Input(doc_src_date, 'date'), - Input(doc_src_timestamp, 'value'), + Input(lodrc.LODocumentSourceSelectorAIO.ids.kwargs_store(_advanced_doc_src), 'data'), State(query_input, 'value'), State(_system_input, 'value'), State(tag_store, 'data'), diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py index 5e6b624f..1a13d7c5 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py @@ -28,27 +28,6 @@ } ] -gpt_bulk_essay = q.call('wo_bulk_essay_analysis.gpt_essay_prompt') - -EXECUTION_DAG = { - 'execution_dag': { - 'gpt_map': q.map( - gpt_bulk_essay, - values=q.variable('writing_observer.docs'), - value_path='text', - func_kwargs={'prompt': q.parameter('gpt_prompt'), 'system_prompt': q.parameter('system_prompt'), 'tags': q.parameter('tags', required=False, default={})}, - parallel=True - ), - 'gpt_bulk': q.join(LEFT=q.variable('gpt_map'), LEFT_ON='provenance.provenance.provenance.STUDENT.value.user_id', RIGHT=q.variable('writing_observer.roster'), RIGHT_ON='user_id') - }, - 'exports': { - 'gpt_bulk': { - 'returns': 'gpt_bulk', - 'parameters': ['course_id', 'gpt_prompt', 'system_prompt'], - 'output': '' - } - } -} THIRD_PARTY = { 'pdf.js': { diff --git a/modules/wo_classroom_text_highlighter/VERSION b/modules/wo_classroom_text_highlighter/VERSION index c84dbbf4..0abb9458 100644 --- a/modules/wo_classroom_text_highlighter/VERSION +++ b/modules/wo_classroom_text_highlighter/VERSION @@ -1 +1 @@ -0.1.0+2025.03.04T20.37.04.583Z.9cc440d2.berickson.022025.gpt.dashboard.updates +0.1.0+2025.03.27T20.51.04.053Z.57041e9f.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js index 6b6f0184..389ee143 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js @@ -62,45 +62,43 @@ function simpleHash (str) { // from the student and populates it under latest. We shouldn't hardcode // anything like latest here and instead pull it from the communication protocol function formatStudentData (student, selectedHighlights) { - // TODO this ought to come from the comm protocol - const document = Object.keys(student.documents)[0]; - - // TODO make sure the comm protocol is providing the doc id - const highlightBreakpoints = selectedHighlights.reduce((acc, option) => { - const offsets = student.documents[document][option.id]?.offsets || []; - if (offsets) { - const modifiedOffsets = offsets.map(offset => { - return { - id: '', - tooltip: option.label, - start: offset[0], - offset: offset[1], - style: { backgroundColor: option.types.highlight.color } - }; - }); - acc = acc.concat(modifiedOffsets); - } - return acc; - }, []); - // const availableDocuments = Object.keys(student.docs).map(id => ({ - // id, - // title: student.docs[id].title || id - // })); - // availableDocuments.push({ id: 'latest', title: 'Latest' }); - const availableDocuments = [{ id: 'latest', title: 'Latest' }] - // TODO currently we only populate the latest data of the student documents - // this is currently the muddiest part of the data flow and ought to be - // cleaned up. + let profile = {}; + let documents = {}; + for (let document in student.documents || []) { + // TODO this ought to come from the comm protocol + const breakpoints = selectedHighlights.reduce((acc, option) => { + const offsets = student.documents[document][option.id]?.offsets || []; + if (offsets) { + const modifiedOffsets = offsets.map(offset => { + return { + id: '', + tooltip: option.label, + start: offset[0], + offset: offset[1], + style: { backgroundColor: option.types.highlight.color } + }; + }); + acc = acc.concat(modifiedOffsets); + } + return acc; + }, []); + const text = student.documents[document].text; + const optionHash = student.documents[document].option_hash; + profile = student.documents[document].profile; + documents[document] = {text, optionHash, breakpoints} + } + let availableDocuments = []; + if ('availableDocuments' in student) { + availableDocuments = Object.keys(student.availableDocuments).map(id => ({ + id, + title: student.availableDocuments[id].title || id, + last_access: student.availableDocuments[id].last_access || null + })); + } return { - profile: student.documents[document].profile, + profile, availableDocuments, - documents: { - latest: { - text: student.documents[document].text, - breakpoints: highlightBreakpoints, - optionHash: student.documents[document].option_hash - } - } + documents }; } @@ -115,7 +113,7 @@ window.dash_clientside.wo_classroom_text_highlighter = { * @param {string} urlHash query string from hash for determining course id * @returns stringified json object that is sent to the communication protocl */ - sendToLOConnection: async function (wsReadyState, urlHash, fullOptions) { + sendToLOConnection: async function (wsReadyState, urlHash, docKwargs, fullOptions) { if (wsReadyState === undefined) { return window.dash_clientside.no_update; } @@ -128,11 +126,12 @@ window.dash_clientside.wo_classroom_text_highlighter = { const nlpOptions = determineSelectedNLPOptionsList(fullOptions); decodedParams.nlp_options = nlpOptions; decodedParams.option_hash = optionsHash; + decodedParams.doc_source = docKwargs.src; + decodedParams.doc_source_kwargs = docKwargs.kwargs; const outgoingMessage = { wo_classroom_text_highlighter_query: { execution_dag: 'writing_observer', - // TODO add `doc_list` here when available - target_exports: ['docs_with_nlp_annotations'], + target_exports: ['docs_with_nlp_annotations', 'document_sources', 'document_list'], kwargs: decodedParams } }; @@ -156,7 +155,7 @@ window.dash_clientside.wo_classroom_text_highlighter = { if (shown.includes(optionPrefix)) { shown = shown.filter(item => item !== optionPrefix); } else { - shown = shown.concat(optionPrefix); + shown = shown.concat(optionPrefix); } return shown; }, @@ -164,7 +163,7 @@ window.dash_clientside.wo_classroom_text_highlighter = { closeOptions: function (clicks, shown) { if (!clicks) { return window.dash_clientside.no_update; } shown = shown.filter(item => item !== 'wo-classroom-text-highlighter-options'); - return shown + return shown; }, adjustTileSize: function (width, height, studentIds) { @@ -190,7 +189,7 @@ window.dash_clientside.wo_classroom_text_highlighter = { */ populateOutput: async function (wsStorageData, options, width, height, showHeader) { // console.log('wsStorageData', wsStorageData); - if (!wsStorageData) { + if (!wsStorageData?.students) { return 'No students'; } let output = []; @@ -203,16 +202,17 @@ window.dash_clientside.wo_classroom_text_highlighter = { const selectedMetrics = options.filter(option => option.types?.metric?.value); const optionHash = await hashObject(options); - - for (const student in wsStorageData) { + const students = wsStorageData.students; + for (const student in students) { + const selectedDocument = students[student].doc_id || Object.keys(students[student].documents || {})[0] || ''; const studentTile = createDashComponent( LO_DASH_REACT_COMPONENTS, 'WOStudentTextTile', { showHeader, - studentInfo: formatStudentData(wsStorageData[student], selectedHighlights), + studentInfo: formatStudentData(students[student], selectedHighlights), // TODO the selectedDocument ought to remain the same upon updating the student object // i.e. it should be pulled from the current client student state - selectedDocument: 'latest', + selectedDocument, childComponent: createDashComponent(LO_DASH_REACT_COMPONENTS, 'WOAnnotatedText', {}), id: { type: 'WOStudentTextTile', index: student }, currentOptionHash: optionHash, @@ -272,12 +272,13 @@ window.dash_clientside.wo_classroom_text_highlighter = { updateLoadingInformation: async function (wsStorageData, nlpOptions) { const noLoading = [false, 0, '']; - if (!wsStorageData) { + if (!wsStorageData?.students) { return noLoading; } + const students = wsStorageData.students; const promptHash = await hashObject(nlpOptions); - const returnedResponses = Object.values(wsStorageData).filter(student => checkForResponse(student, promptHash)).length; - const totalStudents = Object.keys(wsStorageData).length; + const returnedResponses = Object.values(students).filter(student => checkForResponse(student, promptHash)).length; + const totalStudents = Object.keys(students).length; if (totalStudents === returnedResponses) { return noLoading; } const loadingProgress = returnedResponses / totalStudents + 0.1; const outputText = `Fetching responses from server. This will take a few minutes. (${returnedResponses}/${totalStudents} received)`; @@ -293,14 +294,14 @@ window.dash_clientside.wo_classroom_text_highlighter = { if (!currentChild) { return window.dash_clientside.no_update; } id = currentChild?.props.id.index; } else if (triggeredItem?.type === 'WOStudentTileExpand') { - id = triggeredItem?.index + id = triggeredItem?.index; shownPanels = shownPanels.concat('wo-classroom-text-highlighter-expanded-student-panel'); } else { return window.dash_clientside.no_update; } - index = ids.findIndex(item => item.index === id); - child = children[index][0] - return [child, shownPanels] + const index = ids.findIndex(item => item.index === id); + child = children[index][0]; + return [child, shownPanels]; }, closeExpandedStudent: function (clicks, shown) { diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/dash_dashboard.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/dash_dashboard.py index 0724dce8..c02c9c0f 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/dash_dashboard.py +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/dash_dashboard.py @@ -36,6 +36,7 @@ _options_close = f'{_prefix}-options-close' # TODO abstract these into a more generic options component _options_prefix = f'{_prefix}-options' +_options_doc_src = f'{_options_prefix}-document-source' _options_width = f'{_options_prefix}-width' _options_height = f'{_options_prefix}-height' _options_hide_header = f'{_options_prefix}-hide-names' @@ -49,13 +50,18 @@ className='float-end', id=_options_close, color='transparent'), ]), - html.H4('View Options'), - dbc.Label('Students per row'), - dbc.Input(type='number', min=1, max=10, value=3, step=1, id=_options_width), - dbc.Label('Height of student tile'), - dcc.Slider(min=100, max=800, marks=None, value=500, id=_options_height), - dbc.Label('Student name headers'), - dbc.Switch(value=True, id=_options_hide_header, label='Show/Hide'), + lodrc.LODocumentSourceSelectorAIO(aio_id=_options_doc_src), + dbc.Card([ + dbc.CardHeader('View Options'), + dbc.CardBody([ + dbc.Label('Students per row'), + dbc.Input(type='number', min=1, max=10, value=3, step=1, id=_options_width), + dbc.Label('Height of student tile'), + dcc.Slider(min=100, max=800, marks=None, value=500, id=_options_height), + dbc.Label('Student name headers'), + dbc.Switch(value=True, id=_options_hide_header, label='Show/Hide'), + ]) + ]), html.H4('Highlight Options'), wo_classroom_text_highlighter.preset_component.create_layout(), lodrc.WOSettings(id=_options_text_information, options=wo_classroom_text_highlighter.options.OPTIONS, className='table table-striped align-middle') @@ -145,6 +151,7 @@ def layout(): Output(lodrc.LOConnectionAIO.ids.websocket(_websocket), 'send'), Input(lodrc.LOConnectionAIO.ids.websocket(_websocket), 'state'), # used for initial setup Input('_pages_location', 'hash'), + Input(lodrc.LODocumentSourceSelectorAIO.ids.kwargs_store(_options_doc_src), 'data'), Input(_options_text_information, 'options') ) diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION index a8ceee52..0abb9458 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2025.01.02T16.11.54.892Z.0ba3c08e.berickson.requirements.cleanup +0.1.0+2025.03.27T20.51.04.053Z.57041e9f.berickson.022025.gpt.dashboard.updates diff --git a/modules/writing_observer/writing_observer/aggregator.py b/modules/writing_observer/writing_observer/aggregator.py index 188de004..28f967b4 100644 --- a/modules/writing_observer/writing_observer/aggregator.py +++ b/modules/writing_observer/writing_observer/aggregator.py @@ -414,7 +414,7 @@ async def latest_data(runtime, student_data, options=None): @learning_observer.communication_protocol.integration.publish_function('google.fetch_assignment_docs') -async def fetch_assignment_docs(runtime, course_id, assignment_id): +async def fetch_assignment_docs(runtime, course_id, kwargs=None): ''' Invoke the Google API to retrieve a list of students, where each student possesses a collection of documents associated with the specified assignment. @@ -422,4 +422,21 @@ async def fetch_assignment_docs(runtime, course_id, assignment_id): I wasn't sure where to put this code, so I just tossed it here for now. This entire file needs a bit of reworking, what's a little more? ''' - return await learning_observer.google.assigned_docs(runtime, courseId=course_id, courseWorkId=assignment_id) + if kwargs is None: + kwargs = {} + assignment_id = kwargs.get('assignment') + output = [] + if assignment_id: + output = await learning_observer.google.assigned_docs(runtime, courseId=course_id, courseWorkId=assignment_id) + # TODO format to return doc_id, we might wnat to return a list - how do we handle it? + async for student in learning_observer.util.ensure_async_generator(output): + s = {} + s['doc_id'] = student['documents'][0]['id'] + provenance = { + 'provenance': {'STUDENT': { + 'value': {'user_id': student['user_id']}, + 'user_id': student['user_id'] + }} + } + s['provenance'] = provenance + yield s diff --git a/modules/writing_observer/writing_observer/document_timestamps.py b/modules/writing_observer/writing_observer/document_timestamps.py index b7c9f528..950e90e6 100644 --- a/modules/writing_observer/writing_observer/document_timestamps.py +++ b/modules/writing_observer/writing_observer/document_timestamps.py @@ -20,15 +20,16 @@ def select_source(sources, source): @learning_observer.communication_protocol.integration.publish_function('writing_observer.fetch_doc_at_timestamp') -async def fetch_doc_at_timestamp(overall_timestamps, requested_timestamp=None): +async def fetch_doc_at_timestamp(overall_timestamps, kwargs=None): ''' Iterate over a list of students and determine their latest document - in reference to the `requested_timestamp`. + in reference to the `kwargs.requested_timestamp`. `requested_timestamp` should be a string of ms since unix epoch ''' - # output = [] - # TODO this should be an async gen + if kwargs is None: + kwargs = {} + requested_timestamp = kwargs.get('requested_timestamp', None) async for student in overall_timestamps: timestamps = student.get('timestamps', {}) student['doc_id'] = '' diff --git a/modules/writing_observer/writing_observer/module.py b/modules/writing_observer/writing_observer/module.py index da1f03ac..2c82bf5d 100644 --- a/modules/writing_observer/writing_observer/module.py +++ b/modules/writing_observer/writing_observer/module.py @@ -78,6 +78,18 @@ nlp_source = learning_observer.settings.module_setting('writing_observer', setting='nlp_source') lt_single_source = learning_observer.settings.module_setting('writing_observer', setting='languagetool_individual_source') lt_group_source = learning_observer.settings.module_setting('writing_observer', setting='languagetool_source') + +gpt_bulk_essay = q.call('wo_bulk_essay_analysis.gpt_essay_prompt') + +# Document sources +document_sources = source_selector( + sources={'timestamp': q.variable('docs_at_ts'), + 'latest': q.variable('doc_ids'), + 'assignment': q.variable('assignment_docs') + }, + source=q.parameter('doc_source', required=False, default='latest') +) + EXECUTION_DAG = { "execution_dag": { "roster": course_roster(runtime=q.parameter("runtime"), course_id=q.parameter("course_id", required=True)), @@ -122,18 +134,28 @@ 'tagged_doc_list': q.join(LEFT=q.variable('unwind_tags'), RIGHT=q.variable('unwind_doc_list'), LEFT_ON='doc_id', RIGHT_ON='doc.id'), 'grouped_doc_list_by_student': group_docs_by(items=q.variable('tagged_doc_list'), value_path='provenance.provenance.value.user_id'), 'tagged_docs_per_student': q.join(LEFT=q.variable('roster'), RIGHT=q.variable('grouped_doc_list_by_student'), LEFT_ON='user_id', RIGHT_ON='user_id'), + # Student document list + 'document_list': q.select(q.keys('writing_observer.document_list', STUDENTS=q.variable('roster'), STUDENTS_path='user_id'), fields={'docs': 'availableDocuments'}), # the following nodes just fetches docs related to an assignment on Google Classroom - 'assignment_docs': assignment_documents(runtime=q.parameter('runtime'), course_id=q.parameter('course_id', required=True), assignment_id=q.parameter('assignment_id', required=True)), + 'assignment_docs': assignment_documents(runtime=q.parameter('runtime'), course_id=q.parameter('course_id', required=True), kwargs=q.parameter('doc_source_kwargs')), # fetch the doc less than or equal to a timestamp 'timestamped_docs': q.select(q.keys('writing_observer.document_access_timestamps', STUDENTS=q.variable('roster'), STUDENTS_path='user_id'), fields={'timestamps': 'timestamps'}), - 'docs_at_ts': document_access_ts(overall_timestamps=q.variable('timestamped_docs'), requested_timestamp=q.parameter('requested_timestamp')), + 'docs_at_ts': document_access_ts(overall_timestamps=q.variable('timestamped_docs'), kwargs=q.parameter('doc_source_kwargs')), # figure out where to source document ids from # current options include `ts` for a given timestamp # or `latest` for the most recently accessed - 'doc_sources': source_selector(sources={'ts': q.variable('docs_at_ts'), 'latest': q.variable('doc_ids')}, source=q.parameter('doc_source', required=False, default='latest')), + 'doc_sources': document_sources, + 'gpt_map': q.map( + gpt_bulk_essay, + values=q.variable('docs'), + value_path='text', + func_kwargs={'prompt': q.parameter('gpt_prompt'), 'system_prompt': q.parameter('system_prompt'), 'tags': q.parameter('tags', required=False, default={})}, + parallel=True + ), + 'gpt_bulk': q.join(LEFT=q.variable('gpt_map'), LEFT_ON='provenance.provenance.provenance.STUDENT.value.user_id', RIGHT=q.variable('roster'), RIGHT_ON='user_id'), }, "exports": { "docs_with_roster": { @@ -141,6 +163,26 @@ "parameters": ["course_id"], "output": "" }, + "roster": { + "returns": "roster", + "parameters": ["course_id"], + "output": "" + }, + "document_list": { + "returns": "document_list", + "parameters": ["course_id"], + "output": "" + }, + "document_sources": { + "returns": "doc_sources", + "parameters": ["course_id"], + "output": "" + }, + 'gpt_bulk': { + 'returns': 'gpt_bulk', + 'parameters': ['course_id', 'gpt_prompt', 'system_prompt'], + 'output': '' + }, "docs_with_nlp_annotations": { "returns": "nlp_combined", "parameters": ["course_id", "nlp_options"], @@ -165,10 +207,6 @@ 'returns': 'tagged_docs_per_student', 'parameters': ['course_id', 'tag_path'] }, - 'assignment_docs': { - 'returns': 'assignment_docs', - 'parameters': ['course_id', 'assignment_id'] - }, 'latest_doc_ids': { 'returns': 'latest_doc_ids', 'parameters': ['course_id'] From 64ddc3b2d77dc4a3ee5bd1a00cc73c872cbd5c80 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 1 Apr 2025 10:32:29 -0400 Subject: [PATCH 6/8] pr feedback and linting --- .gitignore | 4 +- VERSION | 2 +- modules/wo_bulk_essay_analysis/VERSION | 2 +- .../wo_bulk_essay_analysis/assets/scripts.js | 120 ++++++++++-------- .../wo_bulk_essay_analysis/module.py | 2 + modules/writing_observer/VERSION | 2 +- .../writing_observer/aggregator.py | 5 +- 7 files changed, 79 insertions(+), 58 deletions(-) diff --git a/.gitignore b/.gitignore index d951ebd5..65002d61 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,6 @@ LanguageTool-5.4 package-lock.json learning_observer/learning_observer/static_data/google/ learning_observer/learning_observer/static_data/admins.yaml -.ipynb_checkpoints/ \ No newline at end of file +.ipynb_checkpoints/ +.eggs/ +.next/ \ No newline at end of file diff --git a/VERSION b/VERSION index 0abb9458..549c217f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2025.03.27T20.51.04.053Z.57041e9f.berickson.022025.gpt.dashboard.updates +0.1.0+2025.04.01T14.32.29.308Z.d500c747.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_bulk_essay_analysis/VERSION b/modules/wo_bulk_essay_analysis/VERSION index 0abb9458..549c217f 100644 --- a/modules/wo_bulk_essay_analysis/VERSION +++ b/modules/wo_bulk_essay_analysis/VERSION @@ -1 +1 @@ -0.1.0+2025.03.27T20.51.04.053Z.57041e9f.berickson.022025.gpt.dashboard.updates +0.1.0+2025.04.01T14.32.29.308Z.d500c747.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js index a6a21d78..bbd3f03c 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js @@ -9,7 +9,6 @@ if (!window.dash_clientside) { pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/3rd_party/pdf.worker.min.js'; const createStudentCard = async function (s, prompt, width, height, showHeader) { - const selectedDocument = s.doc_id || Object.keys(s.documents || {})[0] || ''; const student = s.documents?.[selectedDocument] ?? {}; const promptHash = await hashObject({ prompt }); @@ -80,19 +79,25 @@ const createStudentCard = async function (s, prompt, width, height, showHeader) DASH_BOOTSTRAP_COMPONENTS, 'Button', { id: { type: 'WOAIAssistStudentTileExpand', index: userId }, - children: createDashComponent(DASH_HTML_COMPONENTS, 'I', {className: 'fas fa-expand'}), + children: createDashComponent(DASH_HTML_COMPONENTS, 'I', { className: 'fas fa-expand' }), class_name: 'position-absolute top-0 end-0 m-1', color: 'transparent' } ) ], id: { type: 'WOAIAssistStudentTile', index: userId }, - style: {width: `${(100 - width) / width}%`} + style: { width: `${(100 - width) / width}%` } } - ) + ); return tileWrapper; }; +/** + * Check for if we should trigger loading on a student or not. + * @param {*} s student + * @param {*} promptHash current hash of prompts + * @returns true if student's selected document's hash is the same as promptHash + */ const checkForResponse = function (s, promptHash) { if (!('documents' in s)) { return false; } const selectedDocument = s.doc_id || Object.keys(s.documents || {})[0] || ''; @@ -108,29 +113,30 @@ const charactersAfterChar = function (str, char) { return str.slice(commaIndex + 1).trim(); }; +// Helper functions for extracting text from files const extractPDF = async function (base64String) { - const pdfData = atob(charactersAfterChar(base64String, ',')) + const pdfData = atob(charactersAfterChar(base64String, ',')); // Use PDF.js to load and parse the PDF - const pdf = await pdfjsLib.getDocument({ data: pdfData }).promise + const pdf = await pdfjsLib.getDocument({ data: pdfData }).promise; - const totalPages = pdf.numPages - const allTextPromises = [] + const totalPages = pdf.numPages; + const allTextPromises = []; for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) { const pageTextPromise = pdf.getPage(pageNumber).then(function (page) { - return page.getTextContent() + return page.getTextContent(); }).then(function (textContent) { - return textContent.items.map(item => item.str).join(' ') - }) + return textContent.items.map(item => item.str).join(' '); + }); - allTextPromises.push(pageTextPromise) + allTextPromises.push(pageTextPromise); } - const allTexts = await Promise.all(allTextPromises) - const allText = allTexts.join('\n') + const allTexts = await Promise.all(allTextPromises); + const allText = allTexts.join('\n'); - return allText + return allText; }; const extractTXT = async function (base64String) { @@ -143,10 +149,17 @@ const extractMD = async function (base64String) { const extractDOCX = async function (base64String) { const arrayBuffer = Uint8Array.from(atob(charactersAfterChar(base64String, ',')), c => c.charCodeAt(0)).buffer; - const result = await mammoth.extractRawText({ arrayBuffer: arrayBuffer }); + const result = await mammoth.extractRawText({ arrayBuffer }); return result.value; // The raw text }; +const fileTextExtractors = { + pdf: extractPDF, + txt: extractTXT, + md: extractMD, + docx: extractDOCX +}; + window.dash_clientside.bulk_essay_feedback = { /** * Sends data to server via websocket @@ -214,9 +227,9 @@ window.dash_clientside.bulk_essay_feedback = { */ update_input_history_on_query_submission: async function (clicks, query, history) { if (clicks > 0) { - return history.concat(query) + return history.concat(query); } - return window.dash_clientside.no_update + return window.dash_clientside.no_update; }, /** @@ -254,27 +267,17 @@ window.dash_clientside.bulk_essay_feedback = { }, /** - * show attachment panel upon uploading document and populate fields - * - * updates the following - * - extracted text from uploaded file - * - default attachment name (based on filename) - * - whether we show the attachment upload panel + * Uploads file content as str */ handleFileUploadToTextField: async function (contents, filename, timestamp) { if (filename === undefined) { return ''; } - let data = '' + let data = ''; try { - if (filename.endsWith('.pdf')) { - data = await extractPDF(contents); - } else if (filename.endsWith('.txt')) { - data = await extractTXT(contents); - } else if (filename.endsWith('.docx')) { - data = await extractDOCX(contents); - } else if (filename.endsWith('.md')) { - data = await extractMD(contents); + const filetype = charactersAfterChar(filename, '.'); + if (filetype in fileTextExtractors) { + data = await fileTextExtractors[filetype](contents); } else { console.error('Unsupported file type'); } @@ -292,9 +295,9 @@ window.dash_clientside.bulk_essay_feedback = { const trigProp = trig.prop_id; const trigJSON = JSON.parse(trigProp.slice(0, trigProp.lastIndexOf('.'))); if (trig.value > 0) { - return curr.concat(` {${trigJSON.index}}`) + return curr.concat(` {${trigJSON.index}}`); } - return window.dash_clientside.no_update + return window.dash_clientside.no_update; }, /** @@ -344,14 +347,18 @@ window.dash_clientside.bulk_essay_feedback = { return [false, '']; }, + /** + * Opens the tag modal when users want to add a new one or edit an + * existing tag. + */ openTagAddModal: function (clicks, editClicks, currentTagStore, ids) { const triggeredItem = window.dash_clientside.callback_context?.triggered_id ?? null; if (!triggeredItem) { return window.dash_clientside.no_update; } if (triggeredItem === 'bulk-essay-analysis-tags-add-open-btn') { - return [true, null, '', ''] + return [true, null, '', '']; } const id = triggeredItem.index; - const index = ids.findIndex(item => item.index == id); + const index = ids.findIndex(item => item.index === id); if (editClicks[index]) { return [true, id, id, currentTagStore[id]]; } @@ -377,7 +384,7 @@ window.dash_clientside.bulk_essay_feedback = { const editButton = createDashComponent( DASH_BOOTSTRAP_COMPONENTS, 'Button', { - children: createDashComponent(DASH_HTML_COMPONENTS, 'I', { className: 'fas fa-edit'}), + children: createDashComponent(DASH_HTML_COMPONENTS, 'I', { className: 'fas fa-edit' }), id: { type: 'bulk-essay-analysis-tags-tag-edit', index: val }, n_clicks: 0, color: 'info' @@ -389,29 +396,29 @@ window.dash_clientside.bulk_essay_feedback = { children: createDashComponent( DASH_BOOTSTRAP_COMPONENTS, 'Button', { - children: createDashComponent(DASH_HTML_COMPONENTS, 'I', { className: 'fas fa-trash'}), - color: 'info', + children: createDashComponent(DASH_HTML_COMPONENTS, 'I', { className: 'fas fa-trash' }), + color: 'info' } ), id: { type: 'bulk-essay-analysis-tags-tag-delete', index: val }, message: `Are you sure you want to delete the \`${val}\` placeholder?` } ); - const buttons = isStudentText ? [button] : [button, editButton, deleteButton] + const buttons = isStudentText ? [button] : [button, editButton, deleteButton]; const buttonGroup = createDashComponent( DASH_BOOTSTRAP_COMPONENTS, 'ButtonGroup', { children: buttons, - class_name: `${isStudentText ? '' : 'prompt-variable-tag'} ms-1 mb-1`, + class_name: `${isStudentText ? '' : 'prompt-variable-tag'} ms-1 mb-1` } - ) + ); return buttonGroup; }); return tags; }, /** - * Save attachment to tag storage + * Save placeholder to browser storage and close edit placeholder modal */ savePlaceholder: function (clicks, label, text, replacementId, tagStore) { if (clicks > 0) { @@ -425,11 +432,14 @@ window.dash_clientside.bulk_essay_feedback = { return window.dash_clientside.no_update; }, + /** + * Remove placeholder from store on confirm dialogue yes + */ removePlaceholder: function (clicks, tagStore, ids) { const triggeredItem = window.dash_clientside.callback_context?.triggered_id ?? null; if (!triggeredItem) { return window.dash_clientside.no_update; } const id = triggeredItem.index; - const index = ids.findIndex(item => item.index == id); + const index = ids.findIndex(item => item.index === id); if (clicks[index]) { const newStore = tagStore; delete newStore[id]; @@ -459,6 +469,10 @@ window.dash_clientside.bulk_essay_feedback = { return [text, true, error]; }, + /** + * Iterate over students and figure out if any of them have not loaded + * yet. We hash the last history item to compare to. + */ updateLoadingInformation: async function (wsStorageData, history) { const noLoading = [false, 0, '']; if (!wsStorageData) { @@ -477,8 +491,8 @@ window.dash_clientside.bulk_essay_feedback = { adjustTileSize: function (width, height, studentIds) { const total = studentIds.length; return [ - Array(total).fill({width: `${(100 - width) / width}%`}), - Array(total).fill({height: `${height}px`}), + Array(total).fill({ width: `${(100 - width) / width}%` }), + Array(total).fill({ height: `${height}px` }) ]; }, @@ -487,8 +501,8 @@ window.dash_clientside.bulk_essay_feedback = { if (!triggeredItem) { return window.dash_clientside.no_update; } let id = null; if (triggeredItem?.type === 'WOAIAssistStudentTileExpand') { - id = triggeredItem?.index - if (clicks[ids.findIndex(item => item.index == id)]) { + id = triggeredItem?.index; + if (clicks[ids.findIndex(item => item.index === id)]) { shownPanels = shownPanels.concat('bulk-essay-analysis-expanded-student-panel'); } } else { @@ -558,7 +572,7 @@ window.dash_clientside.bulk_essay_feedback = { selectedDocument, childComponent: studentText, id: { type: 'WOAIAssistStudentTileText', index: student.user_id }, - currentOptionHash: promptHash, + currentOptionHash: promptHash } ); const individualWrapper = createDashComponent( @@ -573,13 +587,13 @@ window.dash_clientside.bulk_essay_feedback = { ) ] } - ) + ); return individualWrapper; }, closeExpandedStudent: function (clicks, shown) { if (!clicks) { return window.dash_clientside.no_update; } shown = shown.filter(item => item !== 'bulk-essay-analysis-expanded-student-panel'); - return shown - }, + return shown; + } }; diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py index 1a13d7c5..0f180f75 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/module.py @@ -30,6 +30,7 @@ THIRD_PARTY = { + # PDF parser for reading in files clientside 'pdf.js': { 'url': 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.9.179/pdf.min.js', 'hash': { @@ -44,6 +45,7 @@ '3ebb7dad9946bd3d00bdcd29527dc753fde4b950b2a7a052bd8f66ee643bb736767' } }, + # Docx parser for reading in files clientside 'mammoth.js': { 'url': 'https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.9.0/mammoth.browser.min.js', 'hash': { diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION index 0abb9458..549c217f 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2025.03.27T20.51.04.053Z.57041e9f.berickson.022025.gpt.dashboard.updates +0.1.0+2025.04.01T14.32.29.308Z.d500c747.berickson.022025.gpt.dashboard.updates diff --git a/modules/writing_observer/writing_observer/aggregator.py b/modules/writing_observer/writing_observer/aggregator.py index 28f967b4..85b3809c 100644 --- a/modules/writing_observer/writing_observer/aggregator.py +++ b/modules/writing_observer/writing_observer/aggregator.py @@ -428,10 +428,13 @@ async def fetch_assignment_docs(runtime, course_id, kwargs=None): output = [] if assignment_id: output = await learning_observer.google.assigned_docs(runtime, courseId=course_id, courseWorkId=assignment_id) - # TODO format to return doc_id, we might wnat to return a list - how do we handle it? async for student in learning_observer.util.ensure_async_generator(output): s = {} s['doc_id'] = student['documents'][0]['id'] + # HACK a piece above the source selector in the communication protocol + # expects all items returned to have the same provenance. This mirrors + # the provenance that will be returned by the other sources. + # TODO modify the source selector to handle the provenance provenance = { 'provenance': {'STUDENT': { 'value': {'user_id': student['user_id']}, From 44725993d3aed7424560975bc8a4ee268bec2f96 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 1 Apr 2025 10:37:25 -0400 Subject: [PATCH 7/8] linted highlight dashboard js --- VERSION | 2 +- modules/wo_classroom_text_highlighter/VERSION | 2 +- .../assets/scripts.js | 34 ++++++++----------- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/VERSION b/VERSION index 549c217f..b44313b8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2025.04.01T14.32.29.308Z.d500c747.berickson.022025.gpt.dashboard.updates +0.1.0+2025.04.01T14.37.25.779Z.64ddc3b2.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_classroom_text_highlighter/VERSION b/modules/wo_classroom_text_highlighter/VERSION index 0abb9458..b44313b8 100644 --- a/modules/wo_classroom_text_highlighter/VERSION +++ b/modules/wo_classroom_text_highlighter/VERSION @@ -1 +1 @@ -0.1.0+2025.03.27T20.51.04.053Z.57041e9f.berickson.022025.gpt.dashboard.updates +0.1.0+2025.04.01T14.37.25.779Z.64ddc3b2.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js index 389ee143..3ad95152 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js @@ -58,14 +58,10 @@ function simpleHash (str) { // TODO some of this will move to the communication protocol, but for now // it lives here -// Currently the system only handles grabbing the first document available -// from the student and populates it under latest. We shouldn't hardcode -// anything like latest here and instead pull it from the communication protocol function formatStudentData (student, selectedHighlights) { let profile = {}; - let documents = {}; - for (let document in student.documents || []) { - // TODO this ought to come from the comm protocol + const documents = {}; + for (const document in student.documents || []) { const breakpoints = selectedHighlights.reduce((acc, option) => { const offsets = student.documents[document][option.id]?.offsets || []; if (offsets) { @@ -85,7 +81,7 @@ function formatStudentData (student, selectedHighlights) { const text = student.documents[document].text; const optionHash = student.documents[document].option_hash; profile = student.documents[document].profile; - documents[document] = {text, optionHash, breakpoints} + documents[document] = { text, optionHash, breakpoints }; } let availableDocuments = []; if ('availableDocuments' in student) { @@ -151,7 +147,7 @@ window.dash_clientside.wo_classroom_text_highlighter = { if (!clicks) { return window.dash_clientside.no_update; } - const optionPrefix = 'wo-classroom-text-highlighter-options' + const optionPrefix = 'wo-classroom-text-highlighter-options'; if (shown.includes(optionPrefix)) { shown = shown.filter(item => item !== optionPrefix); } else { @@ -228,17 +224,17 @@ window.dash_clientside.wo_classroom_text_highlighter = { createDashComponent( DASH_BOOTSTRAP_COMPONENTS, 'Button', { - id: { type: 'WOStudentTileExpand', index: student}, - children: createDashComponent(DASH_HTML_COMPONENTS, 'I', {className: 'fas fa-expand'}), + id: { type: 'WOStudentTileExpand', index: student }, + children: createDashComponent(DASH_HTML_COMPONENTS, 'I', { className: 'fas fa-expand' }), class_name: 'position-absolute top-0 end-0 m-1', color: 'transparent' } ) ], - id: { type: 'WOStudentTile', index: student}, + id: { type: 'WOStudentTile', index: student }, style: styleStudentTile(width, height) } - ) + ); output = output.concat(tileWrapper); } return output; @@ -307,31 +303,31 @@ window.dash_clientside.wo_classroom_text_highlighter = { closeExpandedStudent: function (clicks, shown) { if (!clicks) { return window.dash_clientside.no_update; } shown = shown.filter(item => item !== 'wo-classroom-text-highlighter-expanded-student-panel'); - return shown + return shown; }, updateLegend: function (options) { const selectedHighlights = options.filter(option => option.types?.highlight?.value); if (selectedHighlights.length === 0) { - return ['No options selected. Click on the `Highlight Options` to select them.', 0] + return ['No options selected. Click on the `Highlight Options` to select them.', 0]; } let output = selectedHighlights.map(highlight => { - const color = highlight.types.highlight.color + const color = highlight.types.highlight.color; const legendItem = createDashComponent( DASH_HTML_COMPONENTS, 'Div', { children: [ createDashComponent( DASH_HTML_COMPONENTS, 'Span', - { style: { width: '0.875rem', height: '0.875rem', backgroundColor: color, display: 'inline-block', marginRight: '0.5rem' }} + { style: { width: '0.875rem', height: '0.875rem', backgroundColor: color, display: 'inline-block', marginRight: '0.5rem' } } ), highlight.label ] } - ) - return legendItem + ); + return legendItem; }); - output = output.concat('Note: words in the student text may have multiple highlights. Hover over a word for the full list of which options apply') + output = output.concat('Note: words in the student text may have multiple highlights. Hover over a word for the full list of which options apply'); return [output, selectedHighlights.length]; } }; From ab9577e0ebd0c04359dbb593cb5705b65317ad5d Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 1 Apr 2025 11:05:13 -0400 Subject: [PATCH 8/8] resolved a few TODO statements --- VERSION | 2 +- modules/wo_bulk_essay_analysis/VERSION | 2 +- .../wo_bulk_essay_analysis/assets/scripts.js | 2 -- modules/wo_classroom_text_highlighter/VERSION | 2 +- .../wo_classroom_text_highlighter/assets/scripts.js | 2 -- modules/writing_observer/VERSION | 2 +- .../writing_observer/writing_observer/document_timestamps.py | 2 ++ 7 files changed, 6 insertions(+), 8 deletions(-) diff --git a/VERSION b/VERSION index b44313b8..2e2f7e4a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2025.04.01T14.37.25.779Z.64ddc3b2.berickson.022025.gpt.dashboard.updates +0.1.0+2025.04.01T15.05.13.407Z.44725993.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_bulk_essay_analysis/VERSION b/modules/wo_bulk_essay_analysis/VERSION index 549c217f..2e2f7e4a 100644 --- a/modules/wo_bulk_essay_analysis/VERSION +++ b/modules/wo_bulk_essay_analysis/VERSION @@ -1 +1 @@ -0.1.0+2025.04.01T14.32.29.308Z.d500c747.berickson.022025.gpt.dashboard.updates +0.1.0+2025.04.01T15.05.13.407Z.44725993.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js index bbd3f03c..6a00a564 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/assets/scripts.js @@ -567,8 +567,6 @@ window.dash_clientside.bulk_essay_feedback = { { showHeader, studentInfo: formatStudentData(s, []), - // TODO the selectedDocument ought to remain the same upon updating the student object - // i.e. it should be pulled from the current client student state selectedDocument, childComponent: studentText, id: { type: 'WOAIAssistStudentTileText', index: student.user_id }, diff --git a/modules/wo_classroom_text_highlighter/VERSION b/modules/wo_classroom_text_highlighter/VERSION index b44313b8..2e2f7e4a 100644 --- a/modules/wo_classroom_text_highlighter/VERSION +++ b/modules/wo_classroom_text_highlighter/VERSION @@ -1 +1 @@ -0.1.0+2025.04.01T14.37.25.779Z.64ddc3b2.berickson.022025.gpt.dashboard.updates +0.1.0+2025.04.01T15.05.13.407Z.44725993.berickson.022025.gpt.dashboard.updates diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js index 3ad95152..683081ea 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js @@ -206,8 +206,6 @@ window.dash_clientside.wo_classroom_text_highlighter = { { showHeader, studentInfo: formatStudentData(students[student], selectedHighlights), - // TODO the selectedDocument ought to remain the same upon updating the student object - // i.e. it should be pulled from the current client student state selectedDocument, childComponent: createDashComponent(LO_DASH_REACT_COMPONENTS, 'WOAnnotatedText', {}), id: { type: 'WOStudentTextTile', index: student }, diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION index 549c217f..2e2f7e4a 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2025.04.01T14.32.29.308Z.d500c747.berickson.022025.gpt.dashboard.updates +0.1.0+2025.04.01T15.05.13.407Z.44725993.berickson.022025.gpt.dashboard.updates diff --git a/modules/writing_observer/writing_observer/document_timestamps.py b/modules/writing_observer/writing_observer/document_timestamps.py index 950e90e6..4f872655 100644 --- a/modules/writing_observer/writing_observer/document_timestamps.py +++ b/modules/writing_observer/writing_observer/document_timestamps.py @@ -13,6 +13,8 @@ def select_source(sources, source): within the protocol, we could make it so the system only runs the requested source node. TODO make this a dispatch type within the protocol + TODO add provenance at this layer. Each source might have a different + provenance structure. This should create one to use. ''' if source not in sources: raise KeyError(f'Source, `{source}`, not found in available sources: {sources.keys()}')