From 47543a11758b1dbb8b702e01296d58e616d44c3e Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 21 Aug 2024 10:52:18 -0400 Subject: [PATCH 01/41] started rewrite of highlight dashboard, dashboard is in a good spot, but need to work on the date flow into it (via the communication protocol) --- modules/lo_dash_react_components/MANIFEST.in | 3 +- .../lo_dash_react_components/__init__.py | 4 + .../src/lib/components/LOCards.css | 2 +- .../src/lib/components/LOCards.react.js | 2 +- .../src/lib/components/LONameTag.react.js | 18 +- .../lib/components/WOAnnotatedText.react.js | 134 +++++++------ .../src/lib/components/WOSettings.react.js | 145 ++++++++++++++ .../src/lib/components/WOSettings.testdata.js | 13 ++ .../lib/components/WOStudentTextTile.react.js | 143 ++++++++++++++ .../components/WOStudentTextTile.testdata.js | 52 +++++ .../lo_dash_react_components/src/lib/index.js | 4 + .../wo_classroom_text_highlighter/MANIFEST.in | 1 + .../wo_classroom_text_highlighter/README.md | 140 ++++++++++++++ .../wo_classroom_text_highlighter/setup.cfg | 10 + .../wo_classroom_text_highlighter/setup.py | 14 ++ .../wo_classroom_text_highlighter/__init__.py | 0 .../assets/scripts.js | 181 ++++++++++++++++++ .../dash_dashboard.py | 138 +++++++++++++ .../wo_classroom_text_highlighter/module.py | 56 ++++++ .../wo_classroom_text_highlighter/options.py | 31 +++ .../preset_component.py | 87 +++++++++ .../writing_observer/module.py | 2 +- 22 files changed, 1101 insertions(+), 79 deletions(-) create mode 100644 modules/lo_dash_react_components/src/lib/components/WOSettings.react.js create mode 100644 modules/lo_dash_react_components/src/lib/components/WOSettings.testdata.js create mode 100644 modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js create mode 100644 modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.testdata.js create mode 100644 modules/wo_classroom_text_highlighter/MANIFEST.in create mode 100644 modules/wo_classroom_text_highlighter/README.md create mode 100644 modules/wo_classroom_text_highlighter/setup.cfg create mode 100644 modules/wo_classroom_text_highlighter/setup.py create mode 100644 modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/__init__.py create mode 100644 modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js create mode 100644 modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/dash_dashboard.py create mode 100644 modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/module.py create mode 100644 modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py create mode 100644 modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py diff --git a/modules/lo_dash_react_components/MANIFEST.in b/modules/lo_dash_react_components/MANIFEST.in index 6700bd3b..9d0565fb 100644 --- a/modules/lo_dash_react_components/MANIFEST.in +++ b/modules/lo_dash_react_components/MANIFEST.in @@ -9,4 +9,5 @@ include lo_dash_react_components/package-info.json recursive-include lo_dash_react_components/css *.css include README.md include LICENSE -include package.json \ No newline at end of file +include package.json +include requirements.txt 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 95134eec..4c6fecb3 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 @@ -10,6 +10,10 @@ from ._imports_ import * from ._imports_ import __all__ +from .LOConnectionStatusAIO import LOConnectionStatusAIO +from .LOConnectionAIO import LOConnectionAIO +from .ProfileSidebarAIO import ProfileSidebarAIO + if not hasattr(_dash, '__plotly_dash') and not hasattr(_dash, 'development'): print('Dash was not successfully imported. ' 'Make sure you don\'t have a file ' diff --git a/modules/lo_dash_react_components/src/lib/components/LOCards.css b/modules/lo_dash_react_components/src/lib/components/LOCards.css index d77dee7d..54f16ae2 100644 --- a/modules/lo_dash_react_components/src/lib/components/LOCards.css +++ b/modules/lo_dash_react_components/src/lib/components/LOCards.css @@ -4,7 +4,7 @@ align-items: center; } -.card { +.lo-card { border: 1px solid #ccc; border-radius: 8px; padding: 16px; diff --git a/modules/lo_dash_react_components/src/lib/components/LOCards.react.js b/modules/lo_dash_react_components/src/lib/components/LOCards.react.js index 60c2728c..c5890f10 100644 --- a/modules/lo_dash_react_components/src/lib/components/LOCards.react.js +++ b/modules/lo_dash_react_components/src/lib/components/LOCards.react.js @@ -7,7 +7,7 @@ import "./LOCards.css"; const LOCard = ({ title, description }) => { return ( -
+

{title}

{description}

diff --git a/modules/lo_dash_react_components/src/lib/components/LONameTag.react.js b/modules/lo_dash_react_components/src/lib/components/LONameTag.react.js index 36368352..87970be9 100644 --- a/modules/lo_dash_react_components/src/lib/components/LONameTag.react.js +++ b/modules/lo_dash_react_components/src/lib/components/LONameTag.react.js @@ -7,6 +7,13 @@ import PropTypes from "prop-types"; export default class LONameTag extends Component { render() { const { id, profile, className, includeName } = this.props; + + // Check for the existence of necessary profile keys + const hasValidPhotoUrl = profile?.photo_url && profile.photo_url !== '//lh3.googleusercontent.com/a/default-user'; + const givenName = profile?.name?.given_name ?? ''; + const familyName = profile?.name?.family_name ?? ''; + const fullName = profile?.name?.full_name ?? ''; + return (
{ - (profile.photo_url & profile.photo_url !== '//lh3.googleusercontent.com/a/default-user') - ? - : {`${profile.name.given_name.slice(0,1)}${profile.name.family_name.slice(0,1)}`} + hasValidPhotoUrl + ? + : {`${givenName.slice(0, 1)}${familyName.slice(0, 1)}`} } - {includeName ? {profile.name.full_name} : } + {includeName ? {fullName} : }
- ) + ); } } + LONameTag.defaultProps = { id: "", className: "", diff --git a/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.react.js b/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.react.js index aaf961f1..27593c54 100644 --- a/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.react.js +++ b/modules/lo_dash_react_components/src/lib/components/WOAnnotatedText.react.js @@ -1,90 +1,80 @@ -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import OverlayTrigger from 'react-bootstrap/OverlayTrigger' -import Popover from 'react-bootstrap/Popover' +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; +import Popover from 'react-bootstrap/Popover'; -import 'react-tooltip/dist/react-tooltip.css' +import 'react-tooltip/dist/react-tooltip.css'; /** * WOAnnotatedText */ export default class WOAnnotatedText extends Component { constructor(props) { - super(props) + super(props); this.state = { selectedItem: null - } - } - - handleOverlap = (chunks, text) => { - return chunks.reduce((prev, curr) => { - const lastChunk = prev[prev.length - 1] - if (lastChunk && lastChunk.end > curr.start) { - const commonText = text.substring(curr.start, lastChunk.end) - const remainderText = text.substring(lastChunk.end, curr.end) - const newLastChunk = { ...lastChunk, text: text.substring(lastChunk.start, curr.start) } - const commonChunk = { - text: commonText, - annotated: true, - id: `${newLastChunk.id}-${curr.id}`, - start: curr.start, - end: lastChunk.end, - tooltip: lastChunk.tooltip.concat(curr.tooltip), - style: { ...curr.style, ...lastChunk.style } - } - const newChunk = { - text: remainderText, - annotated: true, - id: curr.id, - start: lastChunk.end, - end: curr.end, - tooltip: curr.tooltip, - style: curr.style - } - return [...prev.slice(0, prev.length - 1), newLastChunk, commonChunk, newChunk] - } else { - return [...prev, curr] - } - }, []) + }; } replaceNewLines = (str) => { - const split = str.split('\n') + const split = str.split('\n'); if (split.length > 1) { return split.map((line, index) => ( {line} {split.length-1 === index ? :
}
- )) + )); } - return str + return str; } render() { - const { breakpoints, text, className } = this.props - const sortedList = [...breakpoints].sort((a, b) => a.start - b.start) - let chunks = sortedList.reduce((prev, { start, offset, tooltip, style }, index) => { - const lastOffset = prev.length ? prev[prev.length - 1].end : 0 - if (start > lastOffset) { - prev.push({ - text: text.substring(lastOffset, start), + const { breakpoints, text, className } = this.props; + + const breaks = new Set(); + breakpoints.forEach(obj => { + breaks.add(obj.start); + breaks.add(obj.start + obj.offset); + }); + breaks.add(0); + breaks.add(text.length); + + const ids = {}; + breaks.forEach(item => { + ids[item] = []; + }); + + const breaksList = [...breaks].sort((a, b) => a - b); + let matchingBreaks = []; + + breakpoints.forEach(obj => { + matchingBreaks = breaksList.filter(v => (v >= obj.start & v < (obj.start + obj.offset))); + matchingBreaks.forEach(b => { + ids[b] = ids[b].concat({ tooltip: obj.tooltip, style: obj.style }); + }); + }) + + const chunks = Array(breaksList.length - 1); + let curr, textChunk; + for (let i = 0; i < chunks.length; i++) { + curr = ids[breaksList[i]]; + textChunk = text.substring(breaksList[i], breaksList[i+1]); + if (curr.length === 0) { + chunks[i] = { + text: textChunk, annotated: false - }) + }; + } else { + chunks[i] = { + text: textChunk, + annotated: true, + id: i, + tooltip: curr.map(o => o.tooltip), + style: curr[0].style + }; } - prev.push({ - text: text.substring(start, start + offset), - annotated: true, - id: index, - start: start, - end: start + offset, - tooltip: [tooltip], - style: style - }) - return prev - }, []) - - chunks = this.handleOverlap(chunks, text) + } if (chunks.length === 0) { return
@@ -97,7 +87,7 @@ export default class WOAnnotatedText extends Component { chunks.push({ text: text.substring(chunks[chunks.length - 1].end), annotated: false - }) + }); } return (
@@ -111,14 +101,18 @@ export default class WOAnnotatedText extends Component { Annotations - {chunk.tooltip} +
    + {[...new Set(chunk.tooltip)].map((item, index) => ( +
  • + {item} +
  • + ))} +
} > - + {this.replaceNewLines(chunk.text)} @@ -127,7 +121,7 @@ export default class WOAnnotatedText extends Component { ))}
- ) + ); } } @@ -156,7 +150,7 @@ WOAnnotatedText.propTypes = { id: PropTypes.string, start: PropTypes.number, offset: PropTypes.number, - tooltip: PropTypes.node, + tooltip: PropTypes.string, style: PropTypes.object })), diff --git a/modules/lo_dash_react_components/src/lib/components/WOSettings.react.js b/modules/lo_dash_react_components/src/lib/components/WOSettings.react.js new file mode 100644 index 00000000..85e7643a --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOSettings.react.js @@ -0,0 +1,145 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +function generateNewHighlightColor () { + // Generate random RGB values + const r = Math.floor(Math.random() * 128) + 128; // 128-255 for brighter colors + const g = Math.floor(Math.random() * 128) + 128; + const b = Math.floor(Math.random() * 128) + 128; + + // Convert RGB to hex + const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; + return hex; +} + +function sortOptionsIntoTree (options) { + // Create a map of options by their ids + const optionsMap = new Map(); + options.forEach(option => optionsMap.set(option.id, option)); + + // Initialize an array to store the sorted options + const sortedOptions = []; + + // Function to recursively add children to the sorted array + function addChildren (parentId, depth) { + options + .filter(option => option.parent === parentId) + .forEach(option => { + sortedOptions.push({ ...option, depth }); + addChildren(option.id, depth + 1); // Recursively add children + }); + } + + // Start by adding top-level items (those with an empty parent) + addChildren('', 0); + + return sortedOptions; +} + +/** + * WOSettings is a generic settings interface. + * User can define + */ +export default class WOSettings extends Component { + constructor (props) { + super(props); + this.handleRowEvent = this.handleRowEvent.bind(this); + this.renderRow = this.renderRow.bind(this); + } + + handleRowEvent (event, key, type, colorPicker = false) { + const { setProps, options } = this.props; + const oldOptions = structuredClone(options); + const current = oldOptions.find(option => option.id === key); + if (colorPicker) { + current.types[type].color = event.target.value; + } else { + const { checked } = event.target; + current.types[type].value = checked; + current.types[type].color = current.types[type].color || generateNewHighlightColor(); + } + setProps({ options: oldOptions }); + } + + renderRow (row) { + const highlightCell = (row.types && 'highlight' in row.types) + ? (<> + this.handleRowEvent(e, row.id, 'highlight')} /> + {row.types.highlight.value + ? this.handleRowEvent(e, row.id, 'highlight', true)} /> + : null} + ) + : null; + const metricCell = (row.types && 'metric' in row.types) + ? this.handleRowEvent(e, row.id, 'metric')} /> + : null; + return ( + + {'\u00A0'.repeat(row.depth * 2) + row.label} + {highlightCell} + {metricCell} + + ); + } + + render () { + const { id, className, options } = this.props; + const rows = sortOptionsIntoTree(options); + return ( + + + + + + + + + + {rows.map((r) => this.renderRow(r))} + +
NameHighlightMetric
+ ); + } +}; + +WOSettings.defaultProps = { + id: '', + className: '', + options: {} +}; + +WOSettings.propTypes = { + /** + * The ID used to identify this component in Dash callbacks. + */ + id: PropTypes.string, + + /** + * Classes for the outer most div. + */ + className: PropTypes.string, + + /** + * Dash-assigned callback that should be called to report property changes + * to Dash, to make them available for callbacks. + */ + setProps: PropTypes.func, + + /** + * Array of available options + */ + options: PropTypes.arrayOf(PropTypes.exact({ + id: PropTypes.string, + label: PropTypes.string, + parent: PropTypes.string, + types: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.undefined + ]) + })) + +}; diff --git a/modules/lo_dash_react_components/src/lib/components/WOSettings.testdata.js b/modules/lo_dash_react_components/src/lib/components/WOSettings.testdata.js new file mode 100644 index 00000000..b4815610 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOSettings.testdata.js @@ -0,0 +1,13 @@ + +const testData = { + options: [ + { id: 'a1', label: 'A1', parent: 'a' }, + { id: 'a2', types: { highlight: {}, metric: {} }, label: 'A2', parent: 'a' }, + { id: 'a1a', types: { highlight: {}, metric: {} }, label: 'A1A', parent: 'a1' }, + { id: 'a', label: 'A', parent: '' }, + { id: 'b', types: { highlight: {}, metric: {} }, label: 'B', parent: '' }, + { id: 'c', types: { highlight: {}, metric: {} }, label: 'C', parent: '' } + ] +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js new file mode 100644 index 00000000..d7a91725 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js @@ -0,0 +1,143 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import Card from 'react-bootstrap/Card'; +import Form from 'react-bootstrap/Form'; + +import LONameTag from './LONameTag.react'; + +function createGoogleDocumentURL (docId) { + return `https://docs.google.com/document/d/${docId}`; +} + +/** + * WOStudentTextTile + */ +export default class WOStudentTextTile extends Component { + constructor (props) { + super(props); + this.handleDocumentSelectChange = this.handleDocumentSelectChange.bind(this); + } + + handleDocumentSelectChange (event) { + this.props.setProps({ selectedDocument: event.target.value }); + } + + render () { + const { id, className, style, showHeader, studentInfo, selectedDocument, currentOptionHash, childComponent } = this.props; + // HACK we need to pass the appropriate student information into the child component + childComponent.props._dashprivate_layout.props = {...studentInfo.documents[selectedDocument]}; + + const documentIsSelected = selectedDocument && studentInfo.documents[selectedDocument]; + let bodyClassName = documentIsSelected && currentOptionHash !== studentInfo.documents[selectedDocument].optionHash ? 'loading' : ''; + bodyClassName = `${bodyClassName} overflow-auto`; + return ( + + + + + + + + + {studentInfo.availableDocuments.map(doc => ( + + ))} + + + + { + documentIsSelected + ? <>{childComponent} + :
Document information not found.
+ } +
+
+ ); + } +} + +WOStudentTextTile.defaultProps = { + className: '', + showHeader: true, + style: {}, + studentInfo: { + profile: {}, + availableDocuments: [], + documents: {} + } +}; + +WOStudentTextTile.propTypes = { + /** + * The ID used to identify this component in Dash callbacks. + */ + id: PropTypes.string, + + /** + * Classes for the outer most div. + */ + className: PropTypes.string, + + /** + * Style to apply to the outer most item. This + * is usually used to set the size of the tile. + */ + style: PropTypes.object, + + /** + * Determine whether the header with the student + * name should be visible or not + */ + showHeader: PropTypes.bool, + + /** + * Which document is currently selected for this student + */ + selectedDocument: PropTypes.string, + + /** + * Hash of the current options, used to determine if we + * should be in a loading state or not. + */ + currentOptionHash: PropTypes.string, + + /** + * Component to use for within the card body + */ + childComponent: PropTypes.node, + + /** + * The breakpoints of our text + */ + studentInfo: PropTypes.exact({ + profile: PropTypes.object, + availableDocuments: PropTypes.arrayOf(PropTypes.exact({ + id: PropTypes.string, + title: PropTypes.string + })), + documents: PropTypes.object + // objectOf( + // PropTypes.shape({ + // text: PropTypes.string, + // breakpoints: PropTypes.arrayOf(PropTypes.any), + // optionHash: PropTypes.string + // }) + // ) + }), + + /** + * Dash-assigned callback that should be called to report property changes + * to Dash, to make them available for callbacks. + */ + setProps: PropTypes.func +}; diff --git a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.testdata.js b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.testdata.js new file mode 100644 index 00000000..aef563a5 --- /dev/null +++ b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.testdata.js @@ -0,0 +1,52 @@ +const testData = { + id: 'example', + showHeader: true, + currentOptionHash: '123', + studentInfo: { + availableDocuments: [ + { id: '1_2V-Npp1L0G3cw4lcH_ENSo_y_OV1BP3s8NdnwaFbVw', title: 'Document A' }, + { id: 'docB', title: 'Document B' }, + { id: 'docC', title: 'Document C' } + ], + profile: { + email_address: 'example@example.com', + name: { + family_name: 'Doe', + full_name: 'John Doe', + given_name: 'John' + }, + photo_url: '//lh3.googleusercontent.com/a/default-user' + }, + documents: { + '1_2V-Npp1L0G3cw4lcH_ENSo_y_OV1BP3s8NdnwaFbVw': { + optionHash: '123', + text: 'Lorem ipsum dolor sit amet, \nconsectetur adipiscing elit, sed do eiusmod tempor incididunt\n ut labore et dolore magna aliqua. Dictumst quisque sagittis purus sit amet. Mi quis hendrerit dolor magna eget est lorem ipsum. Arcu bibendum at varius vel pharetra. Nulla malesuada pellentesque elit eget gravida cum. Tincidunt tortor aliquam nulla facilisi cras fermentum odio. Amet venenatis urna cursus eget nunc scelerisque viverra mauris. Diam vel quam elementum pulvinar. Morbi tincidunt augue interdum velit euismod in pellentesque massa. Dignissim cras tincidunt lobortis feugiat vivamus at augue eget arcu. Enim praesent elementum facilisis leo vel fringilla est.\n\nSodales ut etiam sit amet nisl purus in mollis nunc. Suspendisse interdum consectetur libero id faucibus. Morbi leo urna molestie at elementum. In iaculis nunc sed augue lacus viverra. Tristique senectus et netus et malesuada fames ac turpis egestas. Accumsan lacus vel facilisis volutpat est. Consequat semper viverra nam libero justo laoreet sit. Euismod nisi porta lorem mollis aliquam ut porttitor leo. Enim facilisis gravida neque convallis a cras. Odio ut enim blandit volutpat maecenas. Justo nec ultrices dui sapien eget mi proin sed. Non sodales neque sodales ut etiam. Nulla aliquet enim tortor at auctor urna. At volutpat diam ut venenatis.\n\nNulla facilisi cras fermentum odio eu feugiat. Imperdiet massa tincidunt nunc pulvinar sapien et. Fermentum odio eu feugiat pretium nibh ipsum consequat nisl. Pellentesque pulvinar pellentesque habitant morbi tristique senectus et netus. Ac turpis egestas sed tempus urna et. Libero volutpat sed cras ornare arcu dui vivamus arcu. Varius duis at consectetur lorem. Tincidunt augue interdum velit euismod. Praesent elementum facilisis leo vel fringilla est ullamcorper. Facilisis magna etiam tempor orci eu lobortis. Amet est placerat in egestas erat imperdiet sed. Odio eu feugiat pretium nibh ipsum consequat nisl vel pretium. Lectus proin nibh nisl condimentum id venenatis a condimentum vitae. Lacus suspendisse faucibus interdum posuere lorem ipsum. Vel turpis nunc eget lorem dolor. Feugiat nibh sed pulvinar proin gravida hendrerit lectus. Convallis aenean et tortor at risus viverra adipiscing. Aliquet nec ullamcorper sit amet risus nullam eget felis. Massa eget egestas purus viverra accumsan in nisl nisi. Orci nulla pellentesque dignissim enim sit.\n\nUltrices mi tempus imperdiet nulla malesuada pellentesque elit eget. Augue neque gravida in fermentum. Sapien eget mi proin sed libero enim sed faucibus turpis. Velit sed ullamcorper morbi tincidunt. Enim sed faucibus turpis in eu mi bibendum neque. Gravida in fermentum et sollicitudin ac orci phasellus egestas. Risus at ultrices mi tempus imperdiet nulla malesuada. Ridiculus mus mauris vitae ultricies leo. Montes nascetur ridiculus mus mauris vitae ultricies leo integer. Mollis aliquam ut porttitor leo. Elementum nibh tellus molestie nunc non. Malesuada bibendum arcu vitae elementum. Nibh mauris cursus mattis molestie.\n\nMollis nunc sed id semper risus in hendrerit. In ornare quam viverra orci sagittis eu. Cursus vitae congue mauris rhoncus aenean vel elit. Imperdiet massa tincidunt nunc pulvinar. Lobortis scelerisque fermentum dui faucibus in. Sit amet consectetur adipiscing elit pellentesque habitant morbi. Interdum velit laoreet id donec ultrices tincidunt arcu. Elementum curabitur vitae nunc sed velit. Sed euismod nisi porta lorem mollis. Pretium aenean pharetra magna ac. Enim diam vulputate ut pharetra sit. In fermentum et sollicitudin ac orci phasellus egestas tellus rutrum. Sed viverra tellus in hac habitasse platea dictumst. Tellus rutrum tellus pellentesque eu. Velit dignissim sodales ut eu sem.', + breakpoints: [ + { + id: 'split0', + tooltip: 'This is the first tooltip', + start: 220, + offset: 5, + style: { textDecoration: 'underline' } + }, + { + id: 'split1', + tooltip: 'This is a tooltip', + start: 240, + offset: 25, + style: { textDecoration: 'underline' } + }, + { + id: 'split2', + tooltip: 'This is another tooltip', + start: 310, + offset: 15, + style: { backgroundColor: 'green' } + } + ] + } + } + } +}; + +export default testData; diff --git a/modules/lo_dash_react_components/src/lib/index.js b/modules/lo_dash_react_components/src/lib/index.js index 0124cfdd..61fe4081 100644 --- a/modules/lo_dash_react_components/src/lib/index.js +++ b/modules/lo_dash_react_components/src/lib/index.js @@ -5,6 +5,8 @@ import LOCollapse from './components/LOCollapse.react'; import WOAnnotatedText from './components/WOAnnotatedText.react'; import WOMetrics from './components/WOMetrics.react'; import WOIndicatorBars from './components/WOIndicatorBars.react'; +import WOSettings from './components/WOSettings.react'; +import WOStudentTextTile from './components/WOStudentTextTile.react'; import WOTextHighlight from './components/WOTextHighlight.react'; import StudentSelectHeader from './components/StudentSelectHeader.react'; import LOTextMinibars from './components/LOTextMinibars.react'; @@ -17,6 +19,8 @@ export { LOPanelLayout, LOCollapse, WOAnnotatedText, + WOStudentTextTile, + WOSettings, WOMetrics, WOIndicatorBars, WOTextHighlight, diff --git a/modules/wo_classroom_text_highlighter/MANIFEST.in b/modules/wo_classroom_text_highlighter/MANIFEST.in new file mode 100644 index 00000000..d1e41f77 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/MANIFEST.in @@ -0,0 +1 @@ +include wo_classroom_text_highlighter/assets/* diff --git a/modules/wo_classroom_text_highlighter/README.md b/modules/wo_classroom_text_highlighter/README.md new file mode 100644 index 00000000..577a9266 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/README.md @@ -0,0 +1,140 @@ +# Learning Observer Example Module + +Welcome to the Learning Observer (LO) example module. This document +will detail everything need to create a module for the LO. + +## packaage structure + +```bash +module/ + wo_classroom_text_highlighter/ + assets/ + ... + helpers/ + additional_script.py + module.py + reducers.py + dash_dashboards.py + MANIFEST.in + setup.py + setup.cfg +``` + +### setup.py + +This is a standard `setup.py` file. + +### setup.cfg + +Notice we include the following items in our `setup.cfg` file. + +```cfg +[options.entry_points] +lo_modules = + wo_classroom_text_highlighter = wo_classroom_text_highlighter.module + +[options.package_data] +wo_classroom_text_highlighter = helpers/* +``` + +The `lo_modules` entry point tells Learning Observer to treat `wo_classroom_text_highlighter.module` as a pluggable application. + +The package data section is where we include additional directories we want included in the build. + +### MANIFEST.in + +The manifest specifies which files to include during Python packaging. This specifies the additional non-python files we want included. If you do not have additional files needed, this file is unnecessary. + +For modules with Dash-made dashboards, this will typically include a relative path to the assets folder. + +### module.py + +This file defines everything about the module. See the dedicated section below. + +## Defining a module (module.py) + +Modules can include a variety items. This will cover each item and its purpose on the system. + +### NAME + +This one is pretty self explanatory. Give the module a short name to refer to it by. + +### EXECUTION_DAG + +The execution directed acyclic graph (DAG) is how we interact with the communication protocol. + +See `wo_classroom_text_highlighter/module.py:EXECUTION_DAG` for a detailed example. + +### REDUCERS + +Reducers to define on the system. These are functions that will run over incoming events from students. + +See `wo_classroom_text_highlighter/module.py:REDUCERS` for a detailed example. + +### DASH_PAGES + +Dashboards built using the Dash framework should be defined here. + +See `wo_classroom_text_highlighter/module.py:DASH_PAGES` for a detailed example. + +### COURSE_DASHBOARDS + +The registered course dashboards are provided to the users for navigating around dashboards, such as on their Home screen. + +See `wo_classroom_text_highlighter/module.py:COURSE_DASHBOARDS` for a detailed example. + +Note that the student counterpart, `STUDENT_DASHBOARDS`, exists. + +### THIRD_PARTY + +The third party items are downloaded and included when serving items from the module. This is usually used for including extra Javascript or CSS files. + +```python +THIRD_PARTY = { + 'name_of_item': { + 'url': 'url_to_third_party_tool', + 'hash': 'hash_of_download_OR_dict_of_versions_and_hashes' + } +} +``` + +### STATIC_FILE_GIT_REPOS + +We're still figuring this out, but we'd like to support hosting static files from the git repo of the module. +This allows us to have a Merkle-tree style record of which version is deployed in our log files. + +A common use case for this is serving static `.html` and `.js` files for your module. + +```python +STATIC_FILE_GIT_REPOS = { + 'repo_name': { + 'url': 'url_to_repo', + 'prefix': 'relative/path/to/directory', + # Branches we serve. This can either be a whitelist (e.g. which ones + # are available) or a blacklist (e.g. which ones are blocked) + 'whitelist': ['master'] + } +} +``` + +### EXTRA_VIEWS + +These are extra views to publish to the user. Currently, we only support `.json` files. + +```python +EXTRA_VIEWS = [{ + 'name': 'Name of view', + 'suburl': 'view-suburl', + 'static_json': python_dictionary_to_return +}] +``` + +## Creating a reducer (reducers.py) + +Reducers are ran over incoming student events. They can be defined using a decorator in the `learning_observer.stream_analytics` module. + +Each reducer should take the incoming `event` and the previous `internal_state` as parameters and return 2 new state objects. + +## Creating dashboards with Dash (dash_dashboard.py) + +Dash pages consist of a layout and callback functions. See `dash_dashboard.py` for a more detailed overview. diff --git a/modules/wo_classroom_text_highlighter/setup.cfg b/modules/wo_classroom_text_highlighter/setup.cfg new file mode 100644 index 00000000..50964c01 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/setup.cfg @@ -0,0 +1,10 @@ +[metadata] +name = Writing Observer Classroom Text Highlighter +description = Use this as a base template for creating new modules on the Learning Observer. + +[options] +packages = wo_classroom_text_highlighter + +[options.entry_points] +lo_modules = + wo_classroom_text_highlighter = wo_classroom_text_highlighter.module diff --git a/modules/wo_classroom_text_highlighter/setup.py b/modules/wo_classroom_text_highlighter/setup.py new file mode 100644 index 00000000..77bd0119 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/setup.py @@ -0,0 +1,14 @@ +''' +Install script. Everything is handled in setup.cfg + +To set up locally for development, run `python setup.py develop`, in a +virtualenv, preferably. +''' +from setuptools import setup + +setup( + name="wo_classroom_text_highlighter", + package_data={ + 'wo_classroom_text_highlighter': ['assets/*'], + } +) diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/__init__.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/__init__.py new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..eac4a3c8 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/scripts.js @@ -0,0 +1,181 @@ +/** + * Javascript callbacks to be used with the LO Example dashboard + */ + +// Initialize the `dash_clientside` object if it doesn't exist +if (!window.dash_clientside) { + window.dash_clientside = {}; +} + +const DASH_HTML_COMPONENTS = 'dash_html_components'; +const LO_DASH_REACT_COMPONENTS = 'lo_dash_react_components'; + +// TODO this ought to move to a more common place +function createDashComponent (namespace, type, props) { + return { namespace, type, props }; +} + +function determineSelectedNLPOptionsList (options) { + return options.filter(option => + (option.types?.highlight?.value === true) || + (option.types?.metric?.value === true) + ).map(option => option.id); +} + +// TODO this ought to move to a more common place +async function hashObject (obj) { + const jsonString = JSON.stringify(obj); + const encoder = new TextEncoder(); + const data = encoder.encode(jsonString); + + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, '0')).join(''); + + return hashHex; +} + +// TODO some of this will move to the communication protocol, but for now +// it lives here +function formatStudentData (student, selectedHighlights) { + // TODO make sure the comm protocol is providing the doc id + const highlightBreakpoints = selectedHighlights.reduce((acc, option) => { + const offsets = student[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; + }, []); + return { + profile: student.profile, + availableDocuments: [{ id: 'latest', title: 'Latest' }], + documents: { + latest: { + text: student.text, + breakpoints: highlightBreakpoints, + optionHash: '' + } + } + }; +} + +window.dash_clientside.wo_classroom_text_highlighter = { + /** + * Send updated queries to the communication protocol. + * @param {object} wsReadyState LOConnection status object + * @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) { + if (wsReadyState === undefined) { + return window.dash_clientside.no_update; + } + if (wsReadyState.readyState === 1) { + if (urlHash.length === 0) { return window.dash_clientside.no_update; } + const decodedParams = decode_string_dict(urlHash.slice(1)); + if (!decodedParams.course_id) { return window.dash_clientside.no_update; } + + // TODO pass this to the communication protocol + const optionsHash = hashObject(fullOptions); + + const nlpOptions = determineSelectedNLPOptionsList(fullOptions); + decodedParams.nlp_options = nlpOptions; + const outgoingMessage = { + wo_classroom_text_highlighter_query: { + execution_dag: 'writing_observer', + target_exports: ['docs_with_nlp_annotations'], + kwargs: decodedParams + } + }; + return JSON.stringify(outgoingMessage); + } + return window.dash_clientside.no_update; + }, + + toggleOptions: function (clicks, isOpen) { + if (!clicks) { + return window.dash_clientside.no_update; + } + return !isOpen; + }, + + adjustTileSize: function (width, height, studentIds) { + const total = studentIds.length; + return Array(total).fill({ width: `${100 / width}%`, height: `${height}px` }); + }, + + showHideHeader: function (show, ids) { + const total = ids.length; + return Array(total).fill(show ? 'd-none' : ''); + }, + + updateCurrentOptionHash: async function (options, ids) { + const optionHash = await hashObject(options); + const total = ids.length; + return Array(total).fill(optionHash); + }, + + /** + * Build the student UI components based on the stored websocket data + * @param {*} wsStorageData information stored in the websocket store + * @returns Dash object to be displayed on page + */ + populateOutput: function (wsStorageData, options, width, height, showHeader) { + console.log('wsStorageData', wsStorageData); + if (!wsStorageData) { + return 'No students'; + } + const data = wsStorageData?.wo_classroom_text_highlighter_query?.nlp_combined ?? []; + // Check if the returned items have errors + if (typeof data === 'object' && !Array.isArray(data) && data !== null && 'error' in data) { + return window.dash_clientside.no_update; + } + let output = []; + // TODO now for the fun stuff. We need to take the options, determine which ones we want + // and pass those to the apppropriate place. + // how should student look here? + + const selectedHighlights = options.filter(option => option.types?.highlight?.value); + // TODO do something with the selected metrics/progress bars/etc. + const selectedMetrics = options.filter(option => option.types?.metric?.value); + + for (const student of data) { + const studentTile = createDashComponent( + LO_DASH_REACT_COMPONENTS, 'WOStudentTextTile', + { + showHeader, + style: { width: `${100 / width}%`, height: `${height}px` }, + studentInfo: formatStudentData(student, selectedHighlights), + selectedDocument: 'latest', + childComponent: createDashComponent(LO_DASH_REACT_COMPONENTS, 'WOAnnotatedText', {}), + id: { type: 'WOStudentTextTile', index: student.user_id } + } + ); + output = output.concat(studentTile); + } + return output; + }, + + addPreset: function (clicks, name, options, store) { + if (!clicks) { return store; } + const copy = { ...store }; + copy[name] = options; + return copy; + }, + + applyPreset: function (clicks, data) { + const preset = window.dash_clientside.callback_context?.triggered_id.index ?? null; + const itemsClicked = clicks.some(item => item !== undefined); + if (!preset | !itemsClicked) { return window.dash_clientside.no_update; } + return data[preset]; + } +}; 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 new file mode 100644 index 00000000..1a4a4629 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/dash_dashboard.py @@ -0,0 +1,138 @@ +''' +This file will detail how to build a dashboard using +the Dash framework. + +If you are unfamiliar with Dash, it compiles python code +to react and serves it via a Flask server. You can register +callbacks to run when specific states change. Normal callbacks +execute Python code server side, but Clientside callbacks +execute Javascript code client side. Clientside functions are +preferred as it cuts down server and network resources. +''' +from dash import html, dcc, callback, clientside_callback, ClientsideFunction, Output, Input, State, ALL +import dash_bootstrap_components as dbc +import lo_dash_react_components as lodrc + +import wo_classroom_text_highlighter.options +import wo_classroom_text_highlighter.preset_component + +_prefix = 'wo-classroom-text-highlighter' +_namespace = 'wo_classroom_text_highlighter' +_websocket = f'{_prefix}-websocket' +_output = f'{_prefix}-output' + +_options_toggle = f'{_prefix}-options-toggle' +_options_collapse = f'{_prefix}-options-collapse' +# TODO abstract these into a more generic options component +_options_prefix = f'{_prefix}-options' +_options_width = f'{_options_prefix}-width' +_options_height = f'{_options_prefix}-height' +_options_hide_header = f'{_options_prefix}-hide-names' +_options_text_information = f'{_options_prefix}-text-information' + +options_component = [ + dbc.Label('Students per row'), + dbc.Input(type='number', min=1, max=10, value=3, step=1, id=_options_width), + dbc.Label('Height'), + dcc.Slider(min=100, max=800, marks=None, value=500, id=_options_height), + dbc.Label('Headers'), + dbc.Switch(value=True, id=_options_hide_header, label='Show/Hide'), + dbc.Label('Text information'), + wo_classroom_text_highlighter.preset_component.create_layout(), + lodrc.WOSettings(id=_options_text_information, options=wo_classroom_text_highlighter.options.OPTIONS) +] + +def layout(): + ''' + Function to define the page's layout. + ''' + page_layout = html.Div([ + html.H1('Writing Observer Classroom Text Highlighter'), + dbc.InputGroup([ + dbc.InputGroupText(lodrc.LOConnectionAIO(aio_id=_websocket)), + dbc.Button(html.I(className='fas fa-cog'), id=_options_toggle), + lodrc.ProfileSidebarAIO(class_name='rounded-0 rounded-end', color='secondary'), + ]), + dbc.Collapse(options_component, id=_options_collapse), + html.Div(id=_output, className='d-flex justify-content-around flex-wrap') + ]) + return page_layout + +# Send the initial state based on the url hash to LO. +# If this is not included, nothing will be returned from +# the communication protocol. +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='sendToLOConnection'), + Output(lodrc.LOConnectionAIO.ids.websocket(_websocket), 'send'), + Input(lodrc.LOConnectionAIO.ids.websocket(_websocket), 'state'), # used for initial setup + Input('_pages_location', 'hash'), + Input(_options_text_information, 'options') +) + +# Build the UI based on what we've received from the +# communicaton protocol +# This clientside callback and the serverside callback below are +# the same +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='populateOutput'), + Output(_output, 'children'), + Input(lodrc.LOConnectionAIO.ids.ws_store(_websocket), 'data'), + Input(_options_text_information, 'options'), + Input(_options_width, 'value'), + Input(_options_height, 'value'), + Input(_options_hide_header, 'value'), +) + +# Toggle if the options collapse is open or not +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='toggleOptions'), + Output(_options_collapse, 'is_open'), + Input(_options_toggle, 'n_clicks'), + State(_options_collapse, 'is_open') +) + +# Adjust student tile size +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='adjustTileSize'), + Output({'type': 'WOStudentTextTile', 'index': ALL}, 'style'), + Input(_options_width, 'value'), + Input(_options_height, 'value'), + State({'type': 'WOStudentTextTile', 'index': ALL}, 'id'), +) + +# Handle showing/hiding the student tile header +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='showHideHeader'), + Output({'type': 'WOStudentTextTile', 'index': ALL}, 'showHeader'), + Input(_options_hide_header, 'value'), + State({'type': 'WOStudentTextTile', 'index': ALL}, 'id'), +) + +# When options change, update the current option hash for all students. +# when the option hash is different from the students internal option hash +# a loading class is applied +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='updateCurrentOptionHash'), + Output({'type': 'WOStudentTextTile', 'index': ALL}, 'currentOptionHash'), + Input(_options_text_information, 'options'), + State({'type': 'WOStudentTextTile', 'index': ALL}, 'id'), +) + +# Save preset +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='addPreset'), + Output(wo_classroom_text_highlighter.preset_component._store, 'data'), + Input(wo_classroom_text_highlighter.preset_component._add_button, 'n_clicks'), + State(wo_classroom_text_highlighter.preset_component._add_input, 'value'), + State(_options_text_information, 'options'), + State(wo_classroom_text_highlighter.preset_component._store, 'data') +) + +# apply preset +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='applyPreset'), + Output(_options_text_information, 'options'), + Input({'type': wo_classroom_text_highlighter.preset_component._set_item, 'index': ALL}, 'n_clicks'), + State(wo_classroom_text_highlighter.preset_component._store, 'data'), + prevent_initial_call=True +) diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/module.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/module.py new file mode 100644 index 00000000..260540f4 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/module.py @@ -0,0 +1,56 @@ +''' +Writing Observer Classroom Text Highlighter + +Writing Observer dashboard for highlighting different attributes of text at the classroom level. +''' +import learning_observer.downloads as d +from learning_observer.dash_integration import thirdparty_url, static_url + +import wo_classroom_text_highlighter.dash_dashboard + +# Name for the module +NAME = 'Writing Observer Classroom Text Highlighter' + +''' +Define pages created with Dash. +''' +DASH_PAGES = [ + { + 'MODULE': wo_classroom_text_highlighter.dash_dashboard, + 'LAYOUT': wo_classroom_text_highlighter.dash_dashboard.layout, + 'ASSETS': 'assets', + 'TITLE': 'Writing Observer Classroom Text Highlighter', + 'DESCRIPTION': 'Writing Observer dashboard for highlighting different attributes of text at the classroom level.', + 'SUBPATH': 'wo-classroom-text-highlighter', + 'CSS': [ + thirdparty_url("css/fontawesome_all.css") + ], + 'SCRIPTS': [ + static_url("liblo.js") + ] + } +] + +''' +Additional files we want included that come from a third part. +''' +THIRD_PARTY = { + "css/fontawesome_all.css": d.FONTAWESOME_CSS, + "webfonts/fa-solid-900.woff2": d.FONTAWESOME_WOFF2, + "webfonts/fa-solid-900.ttf": d.FONTAWESOME_TTF +} + +''' +The Course Dashboards are used to populate the modules +on the home screen. + +Note the icon uses Font Awesome v5 +''' +COURSE_DASHBOARDS = [{ + 'name': NAME, + 'url': "/wo_classroom_text_highlighter/dash/wo-classroom-text-highlighter", + "icon": { + "type": "fas", + "icon": "fa-play-circle" + } +}] diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py new file mode 100644 index 00000000..23138f81 --- /dev/null +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py @@ -0,0 +1,31 @@ +import copy +import writing_observer + +parents = [] + +OPTIONS = [ + {'id': indicator['id'], 'types': {'highlight': {}, 'metric': {}}, 'label': indicator['name'], 'parent': 'text-information'} + for indicator in writing_observer.nlp_indicators.INDICATOR_JSONS +] +OPTIONS.append({'id': 'text-information', 'label': 'Text Information', 'parent': ''}) + + +def create_preset(options, is_even=True): + preset = copy.deepcopy(options) + for i, o in enumerate(preset): + # if i % 2 == 0 if is_even else 1: + if i % 2 == (0 if is_even else 1): + continue + if 'types' in o: + o['types']['highlight']['value'] = True + o['types']['highlight']['color'] = '#EEABAC' + return preset + +PRESET_EVEN = create_preset(OPTIONS, True) +PRESET_ODD = create_preset(OPTIONS, False) + +PRESETS = { + 'Clear': OPTIONS, + 'Even': PRESET_EVEN, + 'Odd': PRESET_ODD +} diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py new file mode 100644 index 00000000..88db5dfc --- /dev/null +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py @@ -0,0 +1,87 @@ +from dash import html, dcc, clientside_callback, callback, Output, Input, State, ALL, exceptions, Patch, ctx +import dash_bootstrap_components as dbc + +import wo_classroom_text_highlighter.options + +_prefix = 'option-preset' +_store = f'{_prefix}-store' +_add_input = f'{_prefix}-add-input' +_add_button = f'{_prefix}-add-button' +_tray = f'{_prefix}-tray' +_set_item = f'{_prefix}-set-item' +_remove_item = f'{_prefix}-remove-item' + + +def create_layout(): + add_preset = dbc.InputGroup([ + dbc.Input(id=_add_input, placeholder='Preset name', type='text', value=''), + dbc.Button([ + html.I(className='fas fa-plus me-1'), + 'Preset' + ], id=_add_button) + ]) + return html.Div([ + add_preset, + html.Div(id=_tray), + dcc.Store(id=_store, data=wo_classroom_text_highlighter.options.PRESETS) + ]) + + +# disabled add preset when name already exists +clientside_callback( + '''function (value, curr) { + if (value.length === 0) { return true; } + if (Object.keys(curr).includes(value)) { return true; } + return false; + }''', + Output(_add_button, 'disabled'), + Input(_add_input, 'value'), + Input(_store, 'data') +) + +# clear input on add +clientside_callback( + '''function (clicks, curr) { + if (clicks) { return ''; } + return curr; + }''', + Output(_add_input, 'value'), + Input(_add_button, 'n_clicks'), + State(_add_input, 'value') +) + + +def create_tray_item(preset): + contents = dbc.ButtonGroup([ + dbc.Button(preset, id={'type': _set_item, 'index': preset}), + dbc.Button(dcc.ConfirmDialogProvider( + html.I(className='fas fa-close fa-sm'), + id={'type': _remove_item, 'index': preset}, + message=f'Are you sure you want to delete the `{preset}` preset?' + ), color='secondary') + ]) + return contents + + +@callback( + Output(_tray, 'children'), + Input(_store, 'modified_timestamp'), + State(_store, 'data') +) +def create_tray_items_from_store(ts, data): + if ts is None: + raise exceptions.PreventUpdate + return [html.Span(create_tray_item(preset), className='me-1') for preset in data.keys()] + + +@callback( + Output(_store, 'data', allow_duplicate=True), + Input({'type': _remove_item, 'index': ALL}, 'submit_n_clicks'), + prevent_initial_call=True +) +def remove_item_from_store(clicks): + if not ctx.triggered_id or all(c is None for c in clicks): + raise exceptions.PreventUpdate + patched_store = Patch() + del patched_store[ctx.triggered_id['index']] + return patched_store diff --git a/modules/writing_observer/writing_observer/module.py b/modules/writing_observer/writing_observer/module.py index 095b6674..da1f03ac 100644 --- a/modules/writing_observer/writing_observer/module.py +++ b/modules/writing_observer/writing_observer/module.py @@ -49,7 +49,7 @@ name='nlp_source', type='nlp_source', description='Process the NLP components at time of execution '\ - 'dag `nlp` or read results from reducer `npl_sep_proc`.', + 'dag `nlp` or read results from reducer `nlp_sep_proc`.', default='nlp' ) pmss.parser('languagetool_source', parent='string', choices=['overall_lt', 'overall_lt_sep_proc'], transform=None) From 024636571dc7ec9d57d2ee288b074fdb053c0f0a Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 21 Aug 2024 11:05:53 -0400 Subject: [PATCH 02/41] added common all in one files --- .../LOConnectionAIO.py | 116 ++++++++++++++++++ .../LOConnectionStatusAIO.py | 98 +++++++++++++++ .../ProfileSidebarAIO.py | 76 ++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py create mode 100644 modules/lo_dash_react_components/lo_dash_react_components/LOConnectionStatusAIO.py create mode 100644 modules/lo_dash_react_components/lo_dash_react_components/ProfileSidebarAIO.py diff --git a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py new file mode 100644 index 00000000..54d4fec8 --- /dev/null +++ b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py @@ -0,0 +1,116 @@ +from dash import html, dcc, callback, clientside_callback, ClientsideFunction, Output, Input, State, MATCH, ALL, Patch, ctx +import dash_bootstrap_components as dbc +import uuid + +from .LOConnection import LOConnection + +class LOConnectionAIO(html.Div): + class ids: + websocket = lambda aio_id: { + 'component': 'LOConnectionAIO', + 'subcomponent': 'websocket', + 'aio_id': aio_id + } + connection_status = lambda aio_id: { + 'component': 'LOConnectionAIO', + 'subcomponent': 'connection_status', + 'aio_id': aio_id + } + last_updated_store = lambda aio_id: { + 'component': 'LOConnectionAIO', + 'subcomponent': 'last_updated_store', + 'aio_id': aio_id + } + last_updated_time = lambda aio_id: { + 'component': 'LOConnectionAIO', + 'subcomponent': 'last_updated_time', + 'aio_id': aio_id + } + last_updated_interval = lambda aio_id: { + 'component': 'LOConnectionAIO', + 'subcomponent': 'last_updated_interval', + 'aio_id': aio_id + } + ws_store = lambda aio_id: { + 'component': 'LOConnectionAIO', + 'subcomponent': 'ws_store', + 'aio_id': aio_id + } + + ids = ids + + def __init__(self, aio_id=None, data_scope=None): + if aio_id is None: + aio_id = str(uuid.uuid4()) + + # Determine which state we are in + component = [ + html.I(id=self.ids.connection_status(aio_id)), + html.Span('Last Updated:', className='mx-1'), + html.Span(id=self.ids.last_updated_time(aio_id)), + dcc.Interval(id=self.ids.last_updated_interval(aio_id), interval=5000), + LOConnection(id=self.ids.websocket(aio_id), data_scope=data_scope), + dcc.Store(id=self.ids.last_updated_store(aio_id), data=-1), + dcc.Store(id=self.ids.ws_store(aio_id), data={}) + ] + super().__init__(component) + + clientside_callback( + # ClientsideFunction(namespace='lo_dash_react_components', function_name='update_connection_status_icon'), + '''function (status) { + const icons = ['fas fa-sync-alt', 'fas fa-check text-success', 'fas fa-sync-alt', 'fas fa-times text-danger']; + const titles = ['Connecting to server', 'Connected to server', 'Closing connection', 'Disconnected from server']; + if (status === undefined) { + return [icons[3], titles[3]]; + } + return [icons[status.readyState], titles[status.readyState]]; + } + ''', + Output(ids.connection_status(MATCH), 'className'), + Output(ids.connection_status(MATCH), 'title'), + Input(ids.websocket(MATCH), 'state'), + ) + + clientside_callback( + # ClientsideFunction(namespace='lo_dash_react_components', function_name='update_connection_last_modified_text'), + '''function (lastTime, intervals) { + if (lastTime === -1) { + return 'Never'; + } + const currTime = new Date(); + const secondDiff = (currTime.getTime() - lastTime.getTime())/1000 + if (secondDiff < 1) { + return 'just now' + } + const ms_since_last_message = rendertime2(secondDiff); + return `${ms_since_last_message} ago`; + } + ''', + Output(ids.last_updated_time(MATCH), 'children'), + Input(ids.last_updated_store(MATCH), 'data'), + Input(ids.last_updated_interval(MATCH), 'n_intervals') + ) + + clientside_callback( + # ClientsideFunction(namespace='lo_dash_react_components', function_name='update_connection_last_modified_store'), + '''function (data) { + if (data !== undefined) { + return new Date(); + } + return window.dash_clientside.no_update; + }''', + Output(ids.last_updated_store(MATCH), 'data'), + Input(ids.websocket(MATCH), 'message') + ) + + clientside_callback( + # ClientsideFunction(namespace='lo_dash_react_components', function_name='update_connection_last_modified_store'), + '''function (incomingMessage) { + if (incomingMessage !== undefined) { + return JSON.parse(incomingMessage.data); + } + return window.dash_clientside.no_update; + }''', + Output(ids.ws_store(MATCH), 'data'), + Input(ids.websocket(MATCH), 'message') + ) diff --git a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionStatusAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionStatusAIO.py new file mode 100644 index 00000000..b50f757a --- /dev/null +++ b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionStatusAIO.py @@ -0,0 +1,98 @@ +from dash import html, dcc, callback, clientside_callback, ClientsideFunction, Output, Input, State, MATCH, ALL, Patch, ctx +import dash_bootstrap_components as dbc +import uuid + +from .LOConnection import LOConnection + +class LOConnectionStatusAIO(html.Div): + class ids: + websocket = lambda aio_id: { + 'component': 'LOConnectionStatus', + 'subcomponent': 'websocket', + 'aio_id': aio_id + } + connection_status = lambda aio_id: { + 'component': 'LOConnectionStatus', + 'subcomponent': 'connection_status', + 'aio_id': aio_id + } + last_updated_store = lambda aio_id: { + 'component': 'LOConnectionStatus', + 'subcomponent': 'last_updated_store', + 'aio_id': aio_id + } + last_updated_time = lambda aio_id: { + 'component': 'LOConnectionStatus', + 'subcomponent': 'last_updated_time', + 'aio_id': aio_id + } + last_updated_interval = lambda aio_id: { + 'component': 'LOConnectionStatus', + 'subcomponent': 'last_updated_interval', + 'aio_id': aio_id + } + + ids = ids + + def __init__(self, aio_id=None, data_scope=None): + if aio_id is None: + aio_id = str(uuid.uuid4()) + + # Determine which state we are in + component = [ + html.I(id=self.ids.connection_status(aio_id)), + html.Span('Last Updated:', className='mx-1'), + html.Span(id=self.ids.last_updated_time(aio_id)), + dcc.Interval(id=self.ids.last_updated_interval(aio_id), interval=5000), + LOConnection(id=self.ids.websocket(aio_id), data_scope=data_scope), + dcc.Store(id=self.ids.last_updated_store(aio_id), data=-1) + ] + super().__init__(component) + + clientside_callback( + # ClientsideFunction(namespace='lo_dash_react_components', function_name='update_connection_status_icon'), + '''function (status) { + const icons = ['fas fa-sync-alt', 'fas fa-check text-success', 'fas fa-sync-alt', 'fas fa-times text-danger']; + const titles = ['Connecting to server', 'Connected to server', 'Closing connection', 'Disconnected from server']; + if (status === undefined) { + return [icons[3], titles[3]]; + } + return [icons[status.readyState], titles[status.readyState]]; + } + ''', + Output(ids.connection_status(MATCH), 'className'), + Output(ids.connection_status(MATCH), 'title'), + Input(ids.websocket(MATCH), 'state'), + ) + + clientside_callback( + # ClientsideFunction(namespace='lo_dash_react_components', function_name='update_connection_last_modified_text'), + '''function (lastTime, intervals) { + if (lastTime === -1) { + return 'Never'; + } + const currTime = new Date(); + const secDiff = (currTime.getTime() - lastTime.getTime())/1000 + if (secDiff < 1) { + return 'just now' + } + const ms_since_last_message = rendertime2(secDiff); + return `${ms_since_last_message} ago`; + } + ''', + Output(ids.last_updated_time(MATCH), 'children'), + Input(ids.last_updated_store(MATCH), 'data'), + Input(ids.last_updated_interval(MATCH), 'n_intervals') + ) + + clientside_callback( + # ClientsideFunction(namespace='lo_dash_react_components', function_name='update_connection_last_modified_store'), + '''function (data) { + if (data !== undefined) { + return new Date(); + } + return window.dash_clientside.no_update; + }''', + Output(ids.last_updated_store(MATCH), 'data'), + Input(ids.websocket(MATCH), 'message') + ) diff --git a/modules/lo_dash_react_components/lo_dash_react_components/ProfileSidebarAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/ProfileSidebarAIO.py new file mode 100644 index 00000000..3039364a --- /dev/null +++ b/modules/lo_dash_react_components/lo_dash_react_components/ProfileSidebarAIO.py @@ -0,0 +1,76 @@ +from dash import html, clientside_callback, ClientsideFunction, Output, Input, State, MATCH +import dash_bootstrap_components as dbc +import uuid + +class ProfileSidebarAIO(html.Div): + class ids: + toggle_open = lambda aio_id: { + 'component': 'ProfileSidebarAIO', + 'subcomponent': 'toggle_open', + 'aio_id': aio_id + } + offcanvas = lambda aio_id: { + 'component': 'ProfileSidebarAIO', + 'subcomponent': 'offcanvas', + 'aio_id': aio_id + } + modules = lambda aio_id: { + 'component': 'ProfileSidebarAIO', + 'subcomponent': 'module_list', + 'aio_id': aio_id + } + + ids = ids + + def __init__(self, aio_id=None, class_name='', color='primary'): + if aio_id is None: + aio_id = str(uuid.uuid4()) + + component = [ + dbc.Button(html.I(className='fas fa-user'), id=self.ids.toggle_open(aio_id), color=color, class_name=class_name), + dbc.Offcanvas([ + dbc.Button([html.I(className='fas fa-home me-1'), 'Home'], href='/', external_link=True), + html.H4('Modules'), + html.Ul(id=self.ids.modules(aio_id)), + dbc.Button([html.I(className='fas fa-right-from-bracket me-1'), 'Logout'], color='danger', href='/auth/logout', external_link=True), + ], title='Profile', id=self.ids.offcanvas(aio_id), placement='end') + ] + super().__init__(component) + + clientside_callback( + # ClientsideFunction(namespace='lo_dash_react_components', function_name='toggle_sidebar'), + '''function (clicks, isOpen) { + if (clicks > 0) { return !isOpen; } + return isOpen; + } + ''', + Output(ids.offcanvas(MATCH), 'is_open'), + Input(ids.toggle_open(MATCH), 'n_clicks'), + State(ids.offcanvas(MATCH), 'is_open') + ) + + clientside_callback( + # ClientsideFunction(namespace='lo_dash_react_components', function_name='populate_module_list'), + # TODO include the course_id in these - will need to parse it out of the current string + '''async function (empty) { + const response = await fetch(`${window.location.protocol}//${window.location.hostname}:${window.location.port}/webapi/course_dashboards`); + + const modules = await response.json(); + const items = modules.map((x) => { + const link = { + namespace: 'dash_html_components', + type: 'A', + props: { children: x.name, href: x.url + window.location.hash } + } + return { + namespace: 'dash_html_components', + type: 'Li', + props: { children: link } + } + }) + return items; + } + ''', + Output(ids.modules(MATCH), 'children'), + Input(ids.modules(MATCH), 'className'), + ) From 8acb7c315c3ff37b310748b8a14578fe7684023a Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Fri, 30 Aug 2024 11:25:42 -0400 Subject: [PATCH 03/41] communication protocol is now a generator pipeline and the dashboard cna handle it --- .../communication_protocol/executor.py | 150 ++++++++++++------ .../communication_protocol/test_cases.py | 28 ++-- .../learning_observer/dashboard.py | 129 +++++++++++++-- .../LOConnectionAIO.py | 18 ++- .../lib/components/WOStudentTextTile.react.js | 2 +- .../assets/general.css | 4 + .../assets/scripts.js | 61 +++++-- .../writing_observer/awe_nlp.py | 10 +- .../writing_observer/document_timestamps.py | 14 +- .../writing_observer/stub_nlp.py | 6 +- 10 files changed, 311 insertions(+), 111 deletions(-) create mode 100644 modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/general.css diff --git a/learning_observer/learning_observer/communication_protocol/executor.py b/learning_observer/learning_observer/communication_protocol/executor.py index 6f235273..9c78c5ab 100644 --- a/learning_observer/learning_observer/communication_protocol/executor.py +++ b/learning_observer/learning_observer/communication_protocol/executor.py @@ -142,7 +142,7 @@ def substitute_parameter(parameter_name, parameters, required, default): @handler(learning_observer.communication_protocol.query.DISPATCH_MODES.JOIN) -def handle_join(left, right, left_on, right_on): +async def handle_join(left, right, left_on, right_on): """ We dispatch this function whenever we process a DISPATCH_MODES.JOIN node. Users will use this when they want to combine the output of multiple nodes. @@ -185,35 +185,33 @@ def handle_join(left, right, left_on, right_on): [{'error': "KeyError: key `lid` not found in `dict_keys(['left'])`", 'function': 'handle_join', 'error_provenance': {'target': {'left': True}, 'key': 'lid', 'exception': KeyError("Key lid not found in {'left': True}")}, 'timestamp': ... 'traceback': ... {'lid': 2, 'left': True, 'rid': 2, 'right': True}] """ right_dict = {} - for d in right: + right_iterator = _ensure_async_generator(right) + async for d in right_iterator: try: nested_value = get_nested_dict_value(d, right_on) right_dict[nested_value] = d except KeyError as e: pass - result = [] - for left_dict in left: + # TODO should we make sure left is an async generator? Probably + async for left_dict in left: try: lookup_key = get_nested_dict_value(left_dict, left_on) right_dict_match = right_dict.get(lookup_key) - if right_dict_match: merged_dict = {**left_dict, **right_dict_match} else: # defaults to left_dict if not match isn't found merged_dict = left_dict - result.append(merged_dict) + yield merged_dict except KeyError as e: - result.append(left_dict) + yield left_dict # result.append(DAGExecutionException( # f'KeyError: key `{left_on}` not found in `{left_dict.keys()}`', # inspect.currentframe().f_code.co_name, # {'target': left_dict, 'key': left_on, 'exception': e} # ).to_dict()) - return result - def exception_wrapper(func): """ @@ -400,8 +398,8 @@ async def handle_select(keys, fields=learning_observer.communication_protocol.qu if fields is None or fields == learning_observer.communication_protocol.query.SelectFields.Missing: fields_to_keep = {} - response = [] - for k in keys: + async_iterable_keys = _ensure_async_generator(keys) + async for k in async_iterable_keys: if isinstance(k, dict) and 'key' in k: # output from query added to response later query_response_element = { @@ -436,8 +434,7 @@ async def handle_select(keys, fields=learning_observer.communication_protocol.qu ).to_dict() # add necessary outputs to query response query_response_element[fields_to_keep[f]] = value - response.append(query_response_element) - return response + yield query_response_element # @handler(learning_observer.communication_protocol.query.DISPATCH_MODES.KEYS) @@ -459,8 +456,75 @@ def handle_keys(function, value_path, **kwargs): return unimplemented_handler() +async def _ensure_async_generator(it): + if isinstance(it, dict): + yield it + elif isinstance(it, collections.abc.AsyncIterable): + # If it is already an async iterable, yield from it + async for item in it: + yield item + elif isinstance(it, collections.abc.Iterable): + # If it is a synchronous iterable, iterate over it and yield items + for item in it: + yield item + else: + raise TypeError(f"Object of type {type(it)} is not iterable") + + +async def async_zip(gen1, gen2): + '''Zip 2 async generators together + TODO move this to a utility area + ''' + iterator1 = _ensure_async_generator(gen1) + iterator2 = _ensure_async_generator(gen2) + try: + while True: + item1, item2 = await asyncio.gather( + iterator1.__anext__(), + iterator2.__anext__() + ) + yield item1, item2 + except StopAsyncIteration: + pass + + +async def _extract_fields_with_provenance_for_students(students, student_path): + ''' + ''' + async for s in _ensure_async_generator(students): + s_field = get_nested_dict_value(s, student_path, '') + field = { + learning_observer.stream_analytics.fields.KeyField.STUDENT: s_field + } + provenance = s.get('provenance', {'value': s}) + provenance[student_path] = s_field + yield field, {'STUDENT': provenance} + + +async def _extract_fields_with_provenance_for_students_and_resources(students, student_path, resources, resources_path): + ''' + ''' + async for s, r in async_zip(students, resources): + # TODO should we also add the student_path item to the student provenance? + s_field = get_nested_dict_value(s, student_path, '') + r_field = get_nested_dict_value(r, resources_path, '') + fields = { + learning_observer.stream_analytics.fields.KeyField.STUDENT: s_field, + learning_observer.stream_analytics.helpers.EventField('doc_id'): r_field + } + s_provenance = s.get('provenance', {'value': s}) + s_provenance[student_path] = s_field + r_provenance = r.get('provenance', {'value': r}) + r_provenance[resources_path] = r_field + provenance = { + 'STUDENT': s_provenance, + 'RESOURCE': r_provenance + } + yield fields, provenance + + @handler(learning_observer.communication_protocol.query.DISPATCH_MODES.KEYS) -def hack_handle_keys(function, STUDENTS=None, STUDENTS_path=None, RESOURCES=None, RESOURCES_path=None): +async def hack_handle_keys(function, STUDENTS=None, STUDENTS_path=None, RESOURCES=None, RESOURCES_path=None): """ We INSTEAD dispatch this function whenever we process a DISPATCH_MODES.KEYS node. Whenever a user wants to perform a select operation, they first must make sure their @@ -472,46 +536,26 @@ def hack_handle_keys(function, STUDENTS=None, STUDENTS_path=None, RESOURCES=None associated with each. These are zipped together and returned to the user. """ func = next((item for item in learning_observer.module_loader.reducers() if item['id'] == function), None) - fields = [] - provenances = [] + fields_and_provenances = None if STUDENTS is not None and RESOURCES is None: - # handle only students - fields = [ - { - learning_observer.stream_analytics.fields.KeyField.STUDENT: get_nested_dict_value(s, STUDENTS_path) # TODO catch get_nested_dict_value errors - } for s in STUDENTS - ] - provenances = [s.get('provenance', {'value': s}) for s in STUDENTS] + fields_and_provenances = _extract_fields_with_provenance_for_students(STUDENTS, STUDENTS_path) elif STUDENTS is not None and RESOURCES is not None: - # handle both students and resources - fields = [ - { - learning_observer.stream_analytics.fields.KeyField.STUDENT: get_nested_dict_value(s, STUDENTS_path), # TODO catch get_nested_dict_value errors - learning_observer.stream_analytics.helpers.EventField('doc_id'): get_nested_dict_value(r, RESOURCES_path, '') # TODO catch get_nested_dict_value errors - } for s, r in zip(STUDENTS, RESOURCES) - ] - provenances = [ - { - 'STUDENT': s.get('provenance', {'value': s}), - 'RESOURCE': r.get('provenance', {'value': r}) - } for s, r in zip(STUDENTS, RESOURCES) - ] - - keys = [] - for f, p in zip(fields, provenances): + fields_and_provenances = _extract_fields_with_provenance_for_students_and_resources(STUDENTS, STUDENTS_path, RESOURCES, RESOURCES_path) + + if fields_and_provenances is None: + return + async for f, p in fields_and_provenances: key = learning_observer.stream_analytics.helpers.make_key( func['function'], f, learning_observer.stream_analytics.fields.KeyStateType.INTERNAL ) - keys.append( - { - 'key': key, - 'provenance': p, - 'default': func['default'] - } - ) - return keys + key_wrapper = { + 'key': key, + 'provenance': p, + 'default': func['default'] + } + yield key_wrapper def _has_error(node): @@ -590,6 +634,7 @@ def strip_provenance(variable): >>> strip_provenance({'nested_dict': {'provenance': 123, 'other': 123}}) {'nested_dict': {'provenance': 123, 'other': 123}} ''' + # TODO we might need to add a generator method to this if isinstance(variable, dict): return {key: value for key, value in variable.items() if key != 'provenance'} elif isinstance(variable, list): @@ -598,6 +643,12 @@ def strip_provenance(variable): return variable +async def _clean_json_via_generator(gen): + iterator = _ensure_async_generator(gen) + async for item in iterator: + yield clean_json(item) + + async def execute_dag(endpoint, parameters, functions, target_exports): """ This is the primary way to execute a DAG. @@ -687,10 +738,11 @@ async def visit(node_name): # Include execution history in output if operating in development settings if learning_observer.settings.RUN_MODE == learning_observer.settings.RUN_MODES.DEV: - return {e: clean_json(await visit(e)) for e in target_nodes} + return {e: _clean_json_via_generator(await visit(e)) for e in target_nodes} + # TODO test this code to make sure it works with async generators # Remove execution history if in deployed settings, with data flowing back to teacher dashboards - return {e: clean_json(strip_provenance(await visit(e))) for e in target_nodes} + return {e: _clean_json_via_generator(strip_provenance(await visit(e))) for e in target_nodes} if __name__ == "__main__": diff --git a/learning_observer/learning_observer/communication_protocol/test_cases.py b/learning_observer/learning_observer/communication_protocol/test_cases.py index fe3b6574..cc545c3d 100644 --- a/learning_observer/learning_observer/communication_protocol/test_cases.py +++ b/learning_observer/learning_observer/communication_protocol/test_cases.py @@ -32,6 +32,7 @@ import learning_observer.communication_protocol.integration import learning_observer.communication_protocol.query as q import learning_observer.communication_protocol.util +import learning_observer.communication_protocol.exception import learning_observer.constants as constants import learning_observer.offline @@ -106,7 +107,7 @@ async def dummy_map(value, example): 'parameters': ['course_id'], 'test_parameters': {}, "description": "Fetches student doc text; however, this errors since we do not provide the necessary parameters.", - 'expected': lambda x: isinstance(x, dict) and 'error' in x + 'expected': lambda x: isinstance(x, list) and 'error' in x[0] }, 'field_error': { 'returns': 'field_error', @@ -127,7 +128,7 @@ async def dummy_map(value, example): 'parameters': [], 'test_parameters': {}, 'description': "Throw an exception within a published function", - 'expected': lambda x: isinstance(x, dict) and 'error' in x + 'expected': lambda x: isinstance(x, list) and 'error' in x[0] }, 'join_key_error': { 'returns': 'join_key_error', @@ -141,13 +142,13 @@ async def dummy_map(value, example): 'parameters': [], 'test_parameters': {}, 'description': 'Test out circular node errors', - 'expected': lambda x: isinstance(x, dict) and 'error' in x + 'expected': lambda x: isinstance(x, list) and 'error' in x[0] } } } -def run_test_cases(test_cases, verbose=False): +async def run_test_cases(test_cases, verbose=False): """ Run all test cases. Print output from the ones specified. @@ -164,21 +165,24 @@ def run_test_cases(test_cases, verbose=False): print(f"Invalid test case. Available test cases are: {available_test_cases}") sys.exit() - learning_observer.offline.init() + learning_observer.offline.init('creds.yaml') for key in TEST_DAG['exports']: FLAT = learning_observer.communication_protocol.util.flatten(copy.deepcopy(TEST_DAG)) - EXECUTE = asyncio.run( - learning_observer.communication_protocol.executor.execute_dag( - copy.deepcopy(FLAT), parameters=TEST_DAG['exports'][key]['test_parameters'], - functions=DUMMY_FUNCTIONS, target_exports=[key] - ) + EXECUTE = await learning_observer.communication_protocol.executor.execute_dag( + copy.deepcopy(FLAT), parameters=TEST_DAG['exports'][key]['test_parameters'], + functions=DUMMY_FUNCTIONS, target_exports=[key] ) if (key in test_cases or 'all' in test_cases) and 'none' not in test_cases: print(f"Executing {key}") if verbose: print(json.dumps(EXECUTE, indent=2)) - assert (TEST_DAG['exports'][key]['expected'](EXECUTE[TEST_DAG['exports'][key]['returns']])) + + try: + driven_gen = [i async for i in EXECUTE[TEST_DAG['exports'][key]['returns']]] + except learning_observer.communication_protocol.exception.DAGExecutionException as e: + driven_gen = e.to_dict() + assert (TEST_DAG['exports'][key]['expected'](driven_gen)) print(' Received expected output.') @@ -190,4 +194,4 @@ def run_test_cases(test_cases, verbose=False): if args[''] == []: print(__doc__) sys.exit() - run_test_cases(args[''], args['--verbose']) + asyncio.run(run_test_cases(args[''], args['--verbose'])) diff --git a/learning_observer/learning_observer/dashboard.py b/learning_observer/learning_observer/dashboard.py index 1d3e0774..b466d976 100644 --- a/learning_observer/learning_observer/dashboard.py +++ b/learning_observer/learning_observer/dashboard.py @@ -465,6 +465,8 @@ async def dispatch_defined_execution_dag(dag, funcs): return query +# TODO both of these require us to pass in a list of functions +# this was the old way of doing this, we ought to change this DAG_DISPATCH = {dict: dispatch_defined_execution_dag, str: dispatch_named_execution_dag} @@ -505,7 +507,7 @@ async def execute_queries(client_data, request): target_exports = client_query.get('target_exports', []) query_func = learning_observer.communication_protocol.integration.prepare_dag_execution(query, target_exports) - client_parameters = client_query.get('kwargs', {}) + client_parameters = client_query.get('kwargs', {}).copy() runtime = learning_observer.runtime.Runtime(request) client_parameters['runtime'] = runtime query_func = query_func(**client_parameters) @@ -513,6 +515,69 @@ async def execute_queries(client_data, request): return await asyncio.gather(*funcs, return_exceptions=False) +async def _create_dag_generator(client_query, target, request): + execution_dags = learning_observer.module_loader.execution_dags() + dag = client_query['execution_dag'] + if type(dag) not in DAG_DISPATCH: + debug_log(await dag_unsupported_type(type(dag))) + # TODO return something + # funcs.append(dag_unsupported_type(type(dag))) + return + + # TODO fix this funcs + query = await DAG_DISPATCH[type(dag)](dag, []) + if query is None: + # TODO do we return something here? + return + + # NOTE dependent dags only work for on a single level dependency + # TODO allow multiple layers of dependency among dags + dependent_dags = extract_namespaced_dags(query['execution_dag']) + missing_dags = dependent_dags - execution_dags.keys() + if missing_dags: + print('missing dag') + debug_log(await dag_not_found(missing_dags)) + # TODO return something + # funcs.append(dag_not_found(missing_dags)) + return + for dep in dependent_dags: + dep_dag = copy.deepcopy(execution_dags[dep]['execution_dag']) + prefixed_dag = fully_qualify_names_with_default_namespace(dep_dag, dep) + query['execution_dag'] = {**query['execution_dag'], **{f'{dep}.{k}': v for k, v in prefixed_dag.items()}} + + target_exports = [target] + query_func = learning_observer.communication_protocol.integration.prepare_dag_execution(query, target_exports) + client_parameters = client_query.get('kwargs', {}).copy() + runtime = learning_observer.runtime.Runtime(request) + client_parameters['runtime'] = runtime + generator_dictionary = await query_func(**client_parameters) + return next(iter(generator_dictionary.values())) + + +def _find_student_or_resource(d): + '''HACK provenance is normally removed when not in Dev mode + however, we are assuming its still around for when this method + gets called. We ought to include some way for the communication protocol + to return the appropriate scope. + This method digs into the provenance and returns the user_id and + doc_id (if available) in a list. + ''' + if not isinstance(d, dict): + return [] + if 'provenance' in d: + provenance = d['provenance'] + output = [] + if 'STUDENT' in provenance: + output.append(provenance['STUDENT']['user_id']) + if 'RESOURCE' in provenance: + output.append('documents') + output.append(provenance['RESOURCE']['doc_id']) + if output: + return output + return _find_student_or_resource(provenance) + return [] + + @learning_observer.auth.teacher async def websocket_dashboard_handler(request): ''' @@ -527,12 +592,53 @@ async def websocket_dashboard_handler(request): ''' ws = aiohttp.web.WebSocketResponse(receive_timeout=0.3) await ws.prepare(request) - client_data = None + client_query = None + previous_client_query = None + batch = [] + + async def _send_update(update): + '''Send an update to our batch + ''' + batch.append(update) + + async def _batch_send(): + while True: + if batch: + try: + await ws.send_json(batch) + batch.clear() + except aiohttp.web_ws.WebSocketError: + break + if ws.closed: + break + # TODO this ought to be pulled from somewhere + await asyncio.sleep(1) + + async def _execute_dag(dag_query, target, params): + if params != client_query: + # the params are different and we should stop this generator + return + generator = await _create_dag_generator(dag_query, target, request) + await _drive_generator(generator, dag_query['kwargs']) + # TODO pull this from kwargs if available + await asyncio.sleep(10) + await _execute_dag(dag_query, target, params) + + async def _drive_generator(generator, dag_kwargs): + async for item in generator: + scope = _find_student_or_resource(item) + update_path = ".".join(scope) + if 'option_hash' in dag_kwargs: + item['option_hash'] = dag_kwargs['option_hash'] + await _send_update({'op': 'update', 'path': update_path, 'value': item}) + + send_batches = asyncio.create_task(_batch_send()) while True: try: - client_data = await ws.receive_json() - # TODO we should validate the client_data structure + received_params = await ws.receive_json() + client_query = received_params + # TODO we should validate the client_query structure except (TypeError, ValueError): # these Errors may signal a close if (await ws.receive()).type == aiohttp.WSMsgType.CLOSE: @@ -540,20 +646,19 @@ async def websocket_dashboard_handler(request): return aiohttp.web.Response() except asyncio.exceptions.TimeoutError: # this is the normal path of the code - # if the client_data hasn't been set, keep waiting for it - if client_data is None: + # if the client_query hasn't been set, keep waiting for it + if client_query is None: continue if ws.closed: debug_log("Socket closed.") return aiohttp.web.Response() - outputs = await execute_queries(client_data, request) - - await ws.send_json({q: v for q, v in zip(client_data.keys(), outputs)}) - # TODO allow the client to set the update timer. - # it would be cool if the client could set different sleep timers for each item - await asyncio.sleep(3) + if client_query != previous_client_query: + previous_client_query = copy.deepcopy(client_query) + for k, v in client_query.items(): + for target in v.get('target_exports', []): + asyncio.create_task(_execute_dag(v, target, client_query)) # Obsolete code -- we should put this back in after our refactor. Allows us to use diff --git a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py index 54d4fec8..c3749c10 100644 --- a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py +++ b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py @@ -104,13 +104,15 @@ def __init__(self, aio_id=None, data_scope=None): ) clientside_callback( - # ClientsideFunction(namespace='lo_dash_react_components', function_name='update_connection_last_modified_store'), - '''function (incomingMessage) { - if (incomingMessage !== undefined) { - return JSON.parse(incomingMessage.data); - } - return window.dash_clientside.no_update; - }''', + ClientsideFunction(namespace='lo_dash_react_components', function_name='update_dashboard_store_with_incoming_message'), + # '''function (incomingMessage, currentData) { + # if (incomingMessage !== undefined) { + # messages = JSON.parse(incomingMessage.data); + # return messages.forEach(message => applyDashboardStoreUpdate(currentData, message)); + # } + # return window.dash_clientside.no_update; + # }''', Output(ids.ws_store(MATCH), 'data'), - Input(ids.websocket(MATCH), 'message') + Input(ids.websocket(MATCH), 'message'), + State(ids.ws_store(MATCH), 'data') ) diff --git a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js index d7a91725..2623e1fc 100644 --- a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js +++ b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js @@ -31,7 +31,7 @@ export default class WOStudentTextTile extends Component { let bodyClassName = documentIsSelected && currentOptionHash !== studentInfo.documents[selectedDocument].optionHash ? 'loading' : ''; bodyClassName = `${bodyClassName} overflow-auto`; return ( - + byte.toString(16).padStart(2, '0')).join(''); - return hashHex; } // TODO some of this will move to the communication protocol, but for now // it lives here 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[option.id]?.offsets || []; + const offsets = student.documents[document][option.id]?.offsets || []; if (offsets) { const modifiedOffsets = offsets.map(offset => { return { @@ -56,18 +58,51 @@ function formatStudentData (student, selectedHighlights) { return acc; }, []); return { - profile: student.profile, + profile: student.documents[document].profile, availableDocuments: [{ id: 'latest', title: 'Latest' }], documents: { latest: { - text: student.text, + text: student.documents[document].text, breakpoints: highlightBreakpoints, - optionHash: '' + optionHash: student.documents[document].option_hash } } }; } +function applyDashboardStoreUpdate(mainDict, message) { + const pathKeys = message.path.split('.'); + let current = mainDict; + + // Traverse the path to get to the right location + for (let i = 0; i < pathKeys.length - 1; i++) { + const key = pathKeys[i]; + if (!(key in current)) { + current[key] = {}; // Create path if it doesn't exist + } + current = current[key]; + } + + const finalKey = pathKeys[pathKeys.length - 1]; + if (message.op === 'update') { + // Shallow merge using spread syntax + current[finalKey] = { + ...current[finalKey], // Existing data + ...message.value // New data (overwrites where necessary) + }; + } +} + +window.dash_clientside.lo_dash_react_components = { + update_dashboard_store_with_incoming_message: function (incomingMessage, currentData) { + if (incomingMessage !== undefined) { + const messages = JSON.parse(incomingMessage.data); + return messages.forEach(message => applyDashboardStoreUpdate(currentData, message)); + } + return window.dash_clientside.no_update; + } +}; + window.dash_clientside.wo_classroom_text_highlighter = { /** * Send updated queries to the communication protocol. @@ -83,12 +118,11 @@ window.dash_clientside.wo_classroom_text_highlighter = { if (urlHash.length === 0) { return window.dash_clientside.no_update; } const decodedParams = decode_string_dict(urlHash.slice(1)); if (!decodedParams.course_id) { return window.dash_clientside.no_update; } - // TODO pass this to the communication protocol - const optionsHash = hashObject(fullOptions); - + const optionsHash = await hashObject(fullOptions); const nlpOptions = determineSelectedNLPOptionsList(fullOptions); decodedParams.nlp_options = nlpOptions; + decodedParams.option_hash = optionsHash; const outgoingMessage = { wo_classroom_text_highlighter_query: { execution_dag: 'writing_observer', @@ -129,7 +163,7 @@ window.dash_clientside.wo_classroom_text_highlighter = { * @param {*} wsStorageData information stored in the websocket store * @returns Dash object to be displayed on page */ - populateOutput: function (wsStorageData, options, width, height, showHeader) { + populateOutput: async function (wsStorageData, options, width, height, showHeader) { console.log('wsStorageData', wsStorageData); if (!wsStorageData) { return 'No students'; @@ -148,16 +182,19 @@ window.dash_clientside.wo_classroom_text_highlighter = { // TODO do something with the selected metrics/progress bars/etc. const selectedMetrics = options.filter(option => option.types?.metric?.value); - for (const student of data) { + const optionHash = await hashObject(options); + + for (const student in wsStorageData) { const studentTile = createDashComponent( LO_DASH_REACT_COMPONENTS, 'WOStudentTextTile', { showHeader, style: { width: `${100 / width}%`, height: `${height}px` }, - studentInfo: formatStudentData(student, selectedHighlights), + studentInfo: formatStudentData(wsStorageData[student], selectedHighlights), selectedDocument: 'latest', childComponent: createDashComponent(LO_DASH_REACT_COMPONENTS, 'WOAnnotatedText', {}), - id: { type: 'WOStudentTextTile', index: student.user_id } + id: { type: 'WOStudentTextTile', index: student }, + currentOptionHash: optionHash } ); output = output.concat(studentTile); diff --git a/modules/writing_observer/writing_observer/awe_nlp.py b/modules/writing_observer/writing_observer/awe_nlp.py index 5a5aa8da..768a00ab 100644 --- a/modules/writing_observer/writing_observer/awe_nlp.py +++ b/modules/writing_observer/writing_observer/awe_nlp.py @@ -337,7 +337,7 @@ async def process_writings_with_caching(writing_data, options=None, mode=RUN_MOD RUN_MODES.SERIAL: process_texts_serial } - for writing in writing_data: + async for writing in writing_data: text = writing.get('text', '') if len(text) == 0: continue @@ -349,20 +349,18 @@ async def process_writings_with_caching(writing_data, options=None, mode=RUN_MOD found_features, writing = await check_available_features_in_cache(cache, text_hash, requested_features, writing) # If all options were found if found_features == requested_features: - results.append(writing) + yield writing continue # Check if some options are a subset of running_features: features that are needed but are already running unfound_features, found_features, writing = await check_and_wait_for_running_features(writing, requested_features, found_features, cache, sleep_interval, wait_time_for_running_features, text_hash) # If all options are found if found_features == requested_features: - results.append(writing) + yield writing continue # Add not found options to running_features and update cache - results.append(await process_and_cache_missing_features(unfound_features, found_features, requested_features, cache, text_hash, writing)) - - return results + yield await process_and_cache_missing_features(unfound_features, found_features, requested_features, cache, text_hash, writing) if __name__ == '__main__': diff --git a/modules/writing_observer/writing_observer/document_timestamps.py b/modules/writing_observer/writing_observer/document_timestamps.py index 98d2aedf..b7c9f528 100644 --- a/modules/writing_observer/writing_observer/document_timestamps.py +++ b/modules/writing_observer/writing_observer/document_timestamps.py @@ -20,27 +20,27 @@ def select_source(sources, source): @learning_observer.communication_protocol.integration.publish_function('writing_observer.fetch_doc_at_timestamp') -def fetch_doc_at_timestamp(overall_timestamps, requested_timestamp=None): +async def fetch_doc_at_timestamp(overall_timestamps, requested_timestamp=None): ''' Iterate over a list of students and determine their latest document in reference to the `requested_timestamp`. `requested_timestamp` should be a string of ms since unix epoch ''' - output = [] - for student in overall_timestamps: + # output = [] + # TODO this should be an async gen + async for student in overall_timestamps: timestamps = student.get('timestamps', {}) student['doc_id'] = '' if requested_timestamp is None: # perhaps this should fetch the latest doc id instead - output.append(student) + yield student continue sorted_ts = sorted(timestamps.keys()) bisect_index = bisect.bisect_right(sorted_ts, requested_timestamp) - 1 if bisect_index < 0: - output.append(student) + yield student continue target_ts = sorted_ts[bisect_index] student['doc_id'] = timestamps[target_ts] - output.append(student) - return output + yield student diff --git a/modules/writing_observer/writing_observer/stub_nlp.py b/modules/writing_observer/writing_observer/stub_nlp.py index 4357070a..bca9c352 100644 --- a/modules/writing_observer/writing_observer/stub_nlp.py +++ b/modules/writing_observer/writing_observer/stub_nlp.py @@ -128,8 +128,7 @@ async def process_texts(writing_data, options=None): ''' if options is None: options = writing_observer.nlp_indicators.INDICATORS.keys() - - for writing in writing_data: + async for writing in writing_data: text = writing.get('text', '') if len(text) == 0: continue @@ -158,8 +157,7 @@ async def process_texts(writing_data, options=None): 'offsets': select_random_segments(text, seed=id) }) writing.update(results) - - return writing_data + yield writing if __name__ == '__main__': From 2facc383f061e68b9c20d9b687c9cb533cb0a7e3 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 3 Sep 2024 09:09:04 -0400 Subject: [PATCH 04/41] added lock for batch sending to the client to prevent losing data --- .../learning_observer/dashboard.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/learning_observer/learning_observer/dashboard.py b/learning_observer/learning_observer/dashboard.py index b466d976..7ae5a90a 100644 --- a/learning_observer/learning_observer/dashboard.py +++ b/learning_observer/learning_observer/dashboard.py @@ -595,20 +595,26 @@ async def websocket_dashboard_handler(request): client_query = None previous_client_query = None batch = [] + lock = asyncio.Lock() async def _send_update(update): '''Send an update to our batch ''' - batch.append(update) + async with lock: + batch.append(update) async def _batch_send(): + '''If our batch has any items, send them to the client + then wait before checking again. + ''' while True: - if batch: - try: - await ws.send_json(batch) - batch.clear() - except aiohttp.web_ws.WebSocketError: - break + async with lock: + if batch: + try: + await ws.send_json(batch) + batch.clear() + except aiohttp.web_ws.WebSocketError: + break if ws.closed: break # TODO this ought to be pulled from somewhere From 06edf77c526e9584c72fed34e018b988db081da2 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 3 Sep 2024 14:56:34 -0400 Subject: [PATCH 05/41] added some more comments and TODOs that need discussing --- .../communication_protocol/executor.py | 35 ++++++++++++------- .../communication_protocol/test_cases.py | 1 + .../learning_observer/dashboard.py | 27 +++++++++++--- .../wo_classroom_text_highlighter/options.py | 8 +++++ .../preset_component.py | 3 ++ 5 files changed, 57 insertions(+), 17 deletions(-) diff --git a/learning_observer/learning_observer/communication_protocol/executor.py b/learning_observer/learning_observer/communication_protocol/executor.py index 9c78c5ab..a98368d0 100644 --- a/learning_observer/learning_observer/communication_protocol/executor.py +++ b/learning_observer/learning_observer/communication_protocol/executor.py @@ -185,16 +185,14 @@ async def handle_join(left, right, left_on, right_on): [{'error': "KeyError: key `lid` not found in `dict_keys(['left'])`", 'function': 'handle_join', 'error_provenance': {'target': {'left': True}, 'key': 'lid', 'exception': KeyError("Key lid not found in {'left': True}")}, 'timestamp': ... 'traceback': ... {'lid': 2, 'left': True, 'rid': 2, 'right': True}] """ right_dict = {} - right_iterator = _ensure_async_generator(right) - async for d in right_iterator: + async for d in _ensure_async_generator(right): try: nested_value = get_nested_dict_value(d, right_on) right_dict[nested_value] = d except KeyError as e: pass - # TODO should we make sure left is an async generator? Probably - async for left_dict in left: + async for left_dict in _ensure_async_generator(left): try: lookup_key = get_nested_dict_value(left_dict, left_on) right_dict_match = right_dict.get(lookup_key) @@ -310,6 +308,8 @@ def annotate_map_metadata(function, results, values, value_path, func_kwargs): return output +# TODO the map functions have not been fully tested with the +# new async generator pipeline. Do this. MAPS = { 'map_parallel': map_parallel, 'map_serial': map_serial, @@ -398,8 +398,7 @@ async def handle_select(keys, fields=learning_observer.communication_protocol.qu if fields is None or fields == learning_observer.communication_protocol.query.SelectFields.Missing: fields_to_keep = {} - async_iterable_keys = _ensure_async_generator(keys) - async for k in async_iterable_keys: + async for k in _ensure_async_generator(keys): if isinstance(k, dict) and 'key' in k: # output from query added to response later query_response_element = { @@ -457,6 +456,13 @@ def handle_keys(function, value_path, **kwargs): async def _ensure_async_generator(it): + '''We want to ensure that everything is an async generator + so they all fit nicely within an async generator pipeline. + + TODO this ought to live in a utilities file. Not sure if + this is common enough for the broaded system or just the + communication protocol. + ''' if isinstance(it, dict): yield it elif isinstance(it, collections.abc.AsyncIterable): @@ -473,7 +479,8 @@ async def _ensure_async_generator(it): async def async_zip(gen1, gen2): '''Zip 2 async generators together - TODO move this to a utility area + + TODO move this to a common utilities file ''' iterator1 = _ensure_async_generator(gen1) iterator2 = _ensure_async_generator(gen2) @@ -489,7 +496,9 @@ async def async_zip(gen1, gen2): async def _extract_fields_with_provenance_for_students(students, student_path): - ''' + '''We want both the field and provenance when iterating over + for each student during iteration. This function allows for + both items to be returned when handling keys. ''' async for s in _ensure_async_generator(students): s_field = get_nested_dict_value(s, student_path, '') @@ -502,10 +511,11 @@ async def _extract_fields_with_provenance_for_students(students, student_path): async def _extract_fields_with_provenance_for_students_and_resources(students, student_path, resources, resources_path): - ''' + '''We want both the field and provenance when iterating over + for each student during iteration. This function allows for + both items to be returned when handling keys. ''' async for s, r in async_zip(students, resources): - # TODO should we also add the student_path item to the student provenance? s_field = get_nested_dict_value(s, student_path, '') r_field = get_nested_dict_value(r, resources_path, '') fields = { @@ -643,9 +653,8 @@ def strip_provenance(variable): return variable -async def _clean_json_via_generator(gen): - iterator = _ensure_async_generator(gen) - async for item in iterator: +async def _clean_json_via_generator(iterator): + async for item in _ensure_async_generator(iterator): yield clean_json(item) diff --git a/learning_observer/learning_observer/communication_protocol/test_cases.py b/learning_observer/learning_observer/communication_protocol/test_cases.py index cc545c3d..b6b4dec4 100644 --- a/learning_observer/learning_observer/communication_protocol/test_cases.py +++ b/learning_observer/learning_observer/communication_protocol/test_cases.py @@ -130,6 +130,7 @@ async def dummy_map(value, example): 'description': "Throw an exception within a published function", 'expected': lambda x: isinstance(x, list) and 'error' in x[0] }, + # TODO this test case fails and was failing before switching to an async generator 'join_key_error': { 'returns': 'join_key_error', 'parameters': [], diff --git a/learning_observer/learning_observer/dashboard.py b/learning_observer/learning_observer/dashboard.py index 7ae5a90a..0642b6f2 100644 --- a/learning_observer/learning_observer/dashboard.py +++ b/learning_observer/learning_observer/dashboard.py @@ -279,6 +279,9 @@ async def websocket_dashboard_view(request): ''' Handler to aggregate student data, and serve it back to the client every half-second to second or so. + + TODO remove this method. This is the old way of passing data from + the server to the client (pre communication protocol). ''' # Extract parameters from the URL # @@ -471,6 +474,8 @@ async def dispatch_defined_execution_dag(dag, funcs): async def execute_queries(client_data, request): + '''TODO remove this method as it is no longer used. + ''' execution_dags = learning_observer.module_loader.execution_dags() funcs = [] # client_data = { @@ -555,12 +560,15 @@ async def _create_dag_generator(client_query, target, request): def _find_student_or_resource(d): - '''HACK provenance is normally removed when not in Dev mode - however, we are assuming its still around for when this method + '''This method digs into the provenance and returns the user_id and + doc_id (if available) in a list to use for creating the update_path + that is sent to the client. + HACK provenance is normally removed when not in Dev mode + however, we are assuming its still around when this method gets called. We ought to include some way for the communication protocol to return the appropriate scope. - This method digs into the provenance and returns the user_id and - doc_id (if available) in a list. + Currently most queries have `user_id` returned; however, there is no + equivalent for the `doc_id`. ''' if not isinstance(d, dict): return [] @@ -621,6 +629,10 @@ async def _batch_send(): await asyncio.sleep(1) async def _execute_dag(dag_query, target, params): + '''This method creates the DAG generator and drives it. + Once finished, we wait until rescheduling it. If the parameters + change, we exit before creating and driving the generator. + ''' if params != client_query: # the params are different and we should stop this generator return @@ -631,6 +643,9 @@ async def _execute_dag(dag_query, target, params): await _execute_dag(dag_query, target, params) async def _drive_generator(generator, dag_kwargs): + '''For each item in the generator, this method creates + an update to send to the client. + ''' async for item in generator: scope = _find_student_or_resource(item) update_path = ".".join(scope) @@ -662,6 +677,10 @@ async def _drive_generator(generator, dag_kwargs): if client_query != previous_client_query: previous_client_query = copy.deepcopy(client_query) + # HACK even though we can specificy multiple targets for a + # single DAG, this creates a new DAG for each. This eventually + # allows us to specify different parameters (such as the + # reschedule timeout). for k, v in client_query.items(): for target in v.get('target_exports', []): asyncio.create_task(_execute_dag(v, target, client_query)) diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py index 23138f81..c8ae1c2b 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py @@ -9,7 +9,15 @@ ] OPTIONS.append({'id': 'text-information', 'label': 'Text Information', 'parent': ''}) +# TODO create meaningful presets. Paul provided me with some ideas for +# presets to create. +# TODO currently each preset is the full list of options with specific +# values being set to true/including a color. We ought to just store +# the true values and their respective colors. +# Though if we keep the entire list in the preset, we can choose colors +# for non-true values before they are selected. +# TODO remove this function. it is sample code for creating fake presets def create_preset(options, is_even=True): preset = copy.deepcopy(options) for i, o in enumerate(preset): diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py index 88db5dfc..73082288 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py @@ -1,3 +1,6 @@ +'''This creates the input and clickable preset badges. +TODO create a react component that does this +''' from dash import html, dcc, clientside_callback, callback, Output, Input, State, ALL, exceptions, Patch, ctx import dash_bootstrap_components as dbc From 821f8dca6e387d1e66c639237a7b531ded3adf7f Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 3 Sep 2024 16:04:22 -0400 Subject: [PATCH 06/41] more comments on some of the front end code: --- .../assets/scripts.js | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) 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 70805f4a..6830e426 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 @@ -22,7 +22,7 @@ function determineSelectedNLPOptionsList (options) { ).map(option => option.id); } -// TODO this ought to move to a more common place +// TODO this ought to move to a more common place like liblo.js async function hashObject (obj) { const jsonString = JSON.stringify(obj); const encoder = new TextEncoder(); @@ -36,6 +36,9 @@ async function hashObject (obj) { // 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) { // TODO this ought to come from the comm protocol const document = Object.keys(student.documents)[0]; @@ -57,9 +60,17 @@ function formatStudentData (student, selectedHighlights) { } return acc; }, []); + const availableDocuments = Object.keys(student.docs).map(id => ({ + id, + title: student.docs[id].title || id + })); + availableDocuments.push({ 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. return { profile: student.documents[document].profile, - availableDocuments: [{ id: 'latest', title: 'Latest' }], + availableDocuments, documents: { latest: { text: student.documents[document].text, @@ -70,7 +81,7 @@ function formatStudentData (student, selectedHighlights) { }; } -function applyDashboardStoreUpdate(mainDict, message) { +function applyDashboardStoreUpdate (mainDict, message) { const pathKeys = message.path.split('.'); let current = mainDict; @@ -118,7 +129,7 @@ window.dash_clientside.wo_classroom_text_highlighter = { if (urlHash.length === 0) { return window.dash_clientside.no_update; } const decodedParams = decode_string_dict(urlHash.slice(1)); if (!decodedParams.course_id) { return window.dash_clientside.no_update; } - // TODO pass this to the communication protocol + const optionsHash = await hashObject(fullOptions); const nlpOptions = determineSelectedNLPOptionsList(fullOptions); decodedParams.nlp_options = nlpOptions; @@ -126,7 +137,7 @@ window.dash_clientside.wo_classroom_text_highlighter = { const outgoingMessage = { wo_classroom_text_highlighter_query: { execution_dag: 'writing_observer', - target_exports: ['docs_with_nlp_annotations'], + target_exports: ['docs_with_nlp_annotations', 'doc_list'], kwargs: decodedParams } }; @@ -191,6 +202,8 @@ window.dash_clientside.wo_classroom_text_highlighter = { showHeader, style: { width: `${100 / width}%`, height: `${height}px` }, studentInfo: formatStudentData(wsStorageData[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', childComponent: createDashComponent(LO_DASH_REACT_COMPONENTS, 'WOAnnotatedText', {}), id: { type: 'WOStudentTextTile', index: student }, From 71d38ee8e3a7a44f1de3dfdabf4f699598148368 Mon Sep 17 00:00:00 2001 From: Piotr Mitros Date: Mon, 9 Sep 2024 13:06:22 -0400 Subject: [PATCH 07/41] Comments --- .../communication_protocol/README.md | 55 +++++++++++++++++++ .../communication_protocol/debugger.py | 26 ++++++++- .../communication_protocol/util.py | 7 +++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 learning_observer/learning_observer/communication_protocol/README.md diff --git a/learning_observer/learning_observer/communication_protocol/README.md b/learning_observer/learning_observer/communication_protocol/README.md new file mode 100644 index 00000000..86b3f798 --- /dev/null +++ b/learning_observer/learning_observer/communication_protocol/README.md @@ -0,0 +1,55 @@ +# Communication Protocol / Query Language + +## Motivation + +In our first version of this system, we would simply compile the state for all the students, and ship that to the dashboard. However, that didn't allow us to make interactive dashboards, so we created a query language. This is inspired by SQL (with JOIN and friends), but designed for streaming data. + +It can be written in Python or, soon, JavaScript, which compile queries to a JSON object. The JSON object is very similar to SQL. + +## Security model + +We allow two modes of operation: + +- **Predefined queries** are designed for production use. The client cannot make arbitrary queries. +- **Open queries** are designed for development and data analysis, for example, working from a Jupyter notebook. This allows arbitrary queries, including ones which might not be performant or which might reveal sensitive data. + +The latter should only be used in a trusted environment, and on a read replica. + +## Shorthand / Getting Started + +For common queries, we have shorthand notation, to maintain simplicity. In the majority of cases, we want just want the latest reducer data for either a single student or a classroom of students. + +In `module.py`, you see this line: + +```python +EXECUTION_DAG = learning_observer.communication_protocol.util.generate_base_dag_for_student_reducer('student_event_counter', 'my_event_module') +``` + +This is shorthand for a common query which JOINs the class roster with the output of the reducers. The Python code for the query itself is [here](https://github.com/ETS-Next-Gen/writing_observer/blob/berickson/workshop/learning_observer/learning_observer/communication_protocol/util.py#L58), but the jist of the code is: + +```python +'roster': course_roster(runtime=q.parameter('runtime'), course_id=q.parameter("course_id", required=True)), +keys_node: q.keys(f'{module}.{reducer}', STUDENTS=q.variable('roster'), STUDENTS_path='user_id'), +select_node: q.select(q.variable(keys_node), fields=q.SelectFields.All), +join_node: q.join(LEFT=q.variable(select_node), RIGHT=q.variable('roster'), LEFT_ON='provenance.provenance.value.user_id', RIGHT_ON='user_id') +``` + +You can add a `print(EXECUTION_DAG)` statement to see the JSON representation this compiles to. + +To see the data protocol, open up develop tools from your browser, click on network, and see the communication_protocol response. + +## Playing / Debugging / Interactive operations + +* `debugger.py` has a view for executing queries manually. +* `explorer.py` has a view for showing predefined queries already on the server, and running those. + +As of this writing, these are likely broken, as it has not been recently tested and there were code changes. Both of these should also: +* Be available from the Jupyter notebook in the future +* Have a command line / test case version + +## Python Query Language + + + +## JSON Query Language + diff --git a/learning_observer/learning_observer/communication_protocol/debugger.py b/learning_observer/learning_observer/communication_protocol/debugger.py index 19019395..fb8770cd 100644 --- a/learning_observer/learning_observer/communication_protocol/debugger.py +++ b/learning_observer/learning_observer/communication_protocol/debugger.py @@ -1,6 +1,12 @@ ''' This provides a web interface for making queries via the communication protocol and seeing the text of the results. + +TODO: + +* This isn't really a debugger. Perhaps this should be called + interactive mode? Or developer mode? Or similar? +* Ideally, this should be moved to the Jupyter notebook ''' from dash import html, callback, Output, Input, State @@ -11,6 +17,7 @@ import lo_dash_react_components as lodrc +# These are IDs for page elements, used in the layout and for callbacks prefix = 'communication-debugger' ws = f'{prefix}-websocket' status = f'{prefix}-connection-status' @@ -27,7 +34,7 @@ def layout(): html.H1('Communication Protocol Debugger'), lodrc.LOConnection( id=ws, - url='ws://localhost:8888/wsapi/communication_protocol' + url='ws://localhost:8888/wsapi/communication_protocol' # HACK/TODO: This might not be 8888. We should use the default port. ), html.Div(id=status) ] @@ -69,6 +76,9 @@ def layout(): def create_status(title, icon): + ''' + Are we connected to the server? Connecting? Disconnected? Used by update_status below + ''' return html.Div( [ html.I(className=f'{icon} me-1'), @@ -82,6 +92,9 @@ def create_status(title, icon): Input(ws, 'state') ) def update_status(state): + ''' + Called when we connect / disconnect / etc. + ''' icons = ['fas fa-sync-alt', 'fas fa-check text-success', 'fas fa-sync-alt', 'fas fa-times text-danger'] titles = ['Connecting to server', 'Connected to server', 'Closing connection', 'Disconnected from server'] index = state.get('readyState', 3) if state is not None else 3 @@ -93,6 +106,10 @@ def update_status(state): Input(message, 'value') ) def determine_valid_json(value): + ''' + Disable or enable to submit button, so we can only submit a + query if it is valid JSON + ''' if value is None: return True try: @@ -108,6 +125,9 @@ def determine_valid_json(value): State(message, 'value') ) def send_message(clicks, value): + ''' + Send a message to the communication protocol on the web socket. + ''' if clicks is None: raise PreventUpdate return value @@ -118,6 +138,10 @@ def send_message(clicks, value): Input(ws, 'message') ) def receive_message(message): + ''' + Shows messages from the web socket in the field with ID + `response` (defined on top) + ''' if message is None: return {} return json.loads(message.get("data", {})) diff --git a/learning_observer/learning_observer/communication_protocol/util.py b/learning_observer/learning_observer/communication_protocol/util.py index ac53b8b5..f14f70ee 100644 --- a/learning_observer/learning_observer/communication_protocol/util.py +++ b/learning_observer/learning_observer/communication_protocol/util.py @@ -56,6 +56,13 @@ def flatten(endpoint): def generate_base_dag_for_student_reducer(reducer, module): + ''' + A very common use-case is that we want the latest reducer output for a specific reducer for one course. + + This is a shorthand way to do it. + + TODO: We should probably give this a better name. Hopefully quickly, before a lot of code depends on this. E.g. `predefined_query.course_reducer`, `predefined_query.student_reducer`, etc. (so also move them into their own namespace). + ''' course_roster = q.call('learning_observer.courseroster') keys_node = f'{reducer}_keys' select_node = f'{reducer}_output' From abdedb3de5b8a33666d907ed329f7fb98b24129d Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 10 Sep 2024 12:37:30 -0400 Subject: [PATCH 08/41] moved common functions to LO util --- .../communication_protocol/executor.py | 52 +++---------------- learning_observer/learning_observer/util.py | 40 +++++++++++++- 2 files changed, 44 insertions(+), 48 deletions(-) diff --git a/learning_observer/learning_observer/communication_protocol/executor.py b/learning_observer/learning_observer/communication_protocol/executor.py index a98368d0..4d064224 100644 --- a/learning_observer/learning_observer/communication_protocol/executor.py +++ b/learning_observer/learning_observer/communication_protocol/executor.py @@ -18,7 +18,7 @@ import learning_observer.stream_analytics.fields import learning_observer.stream_analytics.helpers from learning_observer.log_event import debug_log -from learning_observer.util import get_nested_dict_value, clean_json +from learning_observer.util import get_nested_dict_value, clean_json, ensure_async_generator, async_zip from learning_observer.communication_protocol.exception import DAGExecutionException dispatch = learning_observer.communication_protocol.query.dispatch @@ -185,14 +185,14 @@ async def handle_join(left, right, left_on, right_on): [{'error': "KeyError: key `lid` not found in `dict_keys(['left'])`", 'function': 'handle_join', 'error_provenance': {'target': {'left': True}, 'key': 'lid', 'exception': KeyError("Key lid not found in {'left': True}")}, 'timestamp': ... 'traceback': ... {'lid': 2, 'left': True, 'rid': 2, 'right': True}] """ right_dict = {} - async for d in _ensure_async_generator(right): + async for d in ensure_async_generator(right): try: nested_value = get_nested_dict_value(d, right_on) right_dict[nested_value] = d except KeyError as e: pass - async for left_dict in _ensure_async_generator(left): + async for left_dict in ensure_async_generator(left): try: lookup_key = get_nested_dict_value(left_dict, left_on) right_dict_match = right_dict.get(lookup_key) @@ -398,7 +398,7 @@ async def handle_select(keys, fields=learning_observer.communication_protocol.qu if fields is None or fields == learning_observer.communication_protocol.query.SelectFields.Missing: fields_to_keep = {} - async for k in _ensure_async_generator(keys): + async for k in ensure_async_generator(keys): if isinstance(k, dict) and 'key' in k: # output from query added to response later query_response_element = { @@ -455,52 +455,12 @@ def handle_keys(function, value_path, **kwargs): return unimplemented_handler() -async def _ensure_async_generator(it): - '''We want to ensure that everything is an async generator - so they all fit nicely within an async generator pipeline. - - TODO this ought to live in a utilities file. Not sure if - this is common enough for the broaded system or just the - communication protocol. - ''' - if isinstance(it, dict): - yield it - elif isinstance(it, collections.abc.AsyncIterable): - # If it is already an async iterable, yield from it - async for item in it: - yield item - elif isinstance(it, collections.abc.Iterable): - # If it is a synchronous iterable, iterate over it and yield items - for item in it: - yield item - else: - raise TypeError(f"Object of type {type(it)} is not iterable") - - -async def async_zip(gen1, gen2): - '''Zip 2 async generators together - - TODO move this to a common utilities file - ''' - iterator1 = _ensure_async_generator(gen1) - iterator2 = _ensure_async_generator(gen2) - try: - while True: - item1, item2 = await asyncio.gather( - iterator1.__anext__(), - iterator2.__anext__() - ) - yield item1, item2 - except StopAsyncIteration: - pass - - async def _extract_fields_with_provenance_for_students(students, student_path): '''We want both the field and provenance when iterating over for each student during iteration. This function allows for both items to be returned when handling keys. ''' - async for s in _ensure_async_generator(students): + async for s in ensure_async_generator(students): s_field = get_nested_dict_value(s, student_path, '') field = { learning_observer.stream_analytics.fields.KeyField.STUDENT: s_field @@ -654,7 +614,7 @@ def strip_provenance(variable): async def _clean_json_via_generator(iterator): - async for item in _ensure_async_generator(iterator): + async for item in ensure_async_generator(iterator): yield clean_json(item) diff --git a/learning_observer/learning_observer/util.py b/learning_observer/learning_observer/util.py index ade27781..7f78b9b7 100644 --- a/learning_observer/learning_observer/util.py +++ b/learning_observer/learning_observer/util.py @@ -8,7 +8,8 @@ We can relax the design invariant, but we should think carefully before doing so. ''' - +import asyncio +import collections import dash.development.base_component import datetime import enum @@ -16,7 +17,6 @@ import math import numbers import re -import socket import uuid from dateutil import parser @@ -252,6 +252,42 @@ def generate_unique_token(): return f'{count}-{timestamp()}-{str(uuid.uuid4())}' +async def ensure_async_generator(it): + '''Take an iterable or single dict item and return it + as an async generator. + ''' + if isinstance(it, dict): + yield it + elif isinstance(it, collections.abc.AsyncIterable): + # If it is already an async iterable, yield from it + async for item in it: + yield item + elif isinstance(it, collections.abc.Iterable): + # If it is a synchronous iterable, iterate over it and yield items + for item in it: + yield item + else: + raise TypeError(f"Object of type {type(it)} is not iterable") + + +async def async_zip(iterator1, iterator2): + '''Zip 2 async generators together. + This functions similar to `zip` + ''' + gen1 = ensure_async_generator(iterator1) + gen2 = ensure_async_generator(iterator2) + try: + while True: + # asyncio.gather finishes when both `anext` items are ready + item1, item2 = await asyncio.gather( + gen1.__anext__(), + gen2.__anext__() + ) + yield item1, item2 + except StopAsyncIteration: + pass + + # And a test case if __name__ == '__main__': assert to_safe_filename('{') == '-123-' From b737615e683fe28a968963e848062129cc41602d Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 11 Sep 2024 10:49:52 -0400 Subject: [PATCH 09/41] added more documentation to some portions of executor and adjusted where functions are places for common components --- .../communication_protocol/executor.py | 24 +++++++--- .../LOConnectionAIO.py | 37 +++++++++++---- .../assets/scripts.js | 47 ++++--------------- 3 files changed, 54 insertions(+), 54 deletions(-) diff --git a/learning_observer/learning_observer/communication_protocol/executor.py b/learning_observer/learning_observer/communication_protocol/executor.py index 4d064224..e6582302 100644 --- a/learning_observer/learning_observer/communication_protocol/executor.py +++ b/learning_observer/learning_observer/communication_protocol/executor.py @@ -456,9 +456,13 @@ def handle_keys(function, value_path, **kwargs): async def _extract_fields_with_provenance_for_students(students, student_path): - '''We want both the field and provenance when iterating over - for each student during iteration. This function allows for - both items to be returned when handling keys. + '''This is a helper function for the `hack_handle_keys` function. + This function prepares the key field dictionary and the provenance + for each student. + The key field dictionary is used to create the key we are attempting + to fetch from the KVS (used later in `hack_handle_keys`). The passed in + `item_path` is used for setting the appropriate dictionary value. + The provenance is the current history of the communication protocol for each item. ''' async for s in ensure_async_generator(students): s_field = get_nested_dict_value(s, student_path, '') @@ -471,9 +475,13 @@ async def _extract_fields_with_provenance_for_students(students, student_path): async def _extract_fields_with_provenance_for_students_and_resources(students, student_path, resources, resources_path): - '''We want both the field and provenance when iterating over - for each student during iteration. This function allows for - both items to be returned when handling keys. + '''This is a helper function for the `hack_handle_keys` function. + This function prepares the key field dictionary and the provenance + for each student/resource pair. + The key field dictionary is used to create the key we are attempting + to fetch from the KVS (used later in `hack_handle_keys`). The passed in + `item_path` is used for setting the appropriate dictionary value. + The provenance is the current history of the communication protocol for each item. ''' async for s, r in async_zip(students, resources): s_field = get_nested_dict_value(s, student_path, '') @@ -496,7 +504,9 @@ async def _extract_fields_with_provenance_for_students_and_resources(students, s @handler(learning_observer.communication_protocol.query.DISPATCH_MODES.KEYS) async def hack_handle_keys(function, STUDENTS=None, STUDENTS_path=None, RESOURCES=None, RESOURCES_path=None): """ - We INSTEAD dispatch this function whenever we process a DISPATCH_MODES.KEYS node. + This function is a HACK that is being used instead of `handle_keys` for any + `DISPATCH_MODE.KEYS` nodes. + Whenever a user wants to perform a select operation, they first must make sure their keys are formatted properly. This method builds the keys to access the appropriate reducers output. diff --git a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py index c3749c10..2ad605ea 100644 --- a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py +++ b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py @@ -104,14 +104,35 @@ def __init__(self, aio_id=None, data_scope=None): ) clientside_callback( - ClientsideFunction(namespace='lo_dash_react_components', function_name='update_dashboard_store_with_incoming_message'), - # '''function (incomingMessage, currentData) { - # if (incomingMessage !== undefined) { - # messages = JSON.parse(incomingMessage.data); - # return messages.forEach(message => applyDashboardStoreUpdate(currentData, message)); - # } - # return window.dash_clientside.no_update; - # }''', + '''function (incomingMessage, currentData) { + if (incomingMessage !== undefined) { + messages = JSON.parse(incomingMessage.data); + messages.forEach(message => { + const pathKeys = message.path.split('.'); + let current = currentData; + + // Traverse the path to get to the right location + for (let i = 0; i < pathKeys.length - 1; i++) { + const key = pathKeys[i]; + if (!(key in current)) { + current[key] = {}; // Create path if it doesn't exist + } + current = current[key]; + } + + const finalKey = pathKeys[pathKeys.length - 1]; + if (message.op === 'update') { + // Shallow merge using spread syntax + current[finalKey] = { + ...current[finalKey], // Existing data + ...message.value // New data (overwrites where necessary) + }; + } + }); + return currentData; // Return updated data + } + return window.dash_clientside.no_update; + }''', Output(ids.ws_store(MATCH), 'data'), Input(ids.websocket(MATCH), 'message'), State(ids.ws_store(MATCH), 'data') 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 6830e426..b18c54c6 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 @@ -60,11 +60,12 @@ function formatStudentData (student, selectedHighlights) { } 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 = 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. @@ -81,39 +82,6 @@ function formatStudentData (student, selectedHighlights) { }; } -function applyDashboardStoreUpdate (mainDict, message) { - const pathKeys = message.path.split('.'); - let current = mainDict; - - // Traverse the path to get to the right location - for (let i = 0; i < pathKeys.length - 1; i++) { - const key = pathKeys[i]; - if (!(key in current)) { - current[key] = {}; // Create path if it doesn't exist - } - current = current[key]; - } - - const finalKey = pathKeys[pathKeys.length - 1]; - if (message.op === 'update') { - // Shallow merge using spread syntax - current[finalKey] = { - ...current[finalKey], // Existing data - ...message.value // New data (overwrites where necessary) - }; - } -} - -window.dash_clientside.lo_dash_react_components = { - update_dashboard_store_with_incoming_message: function (incomingMessage, currentData) { - if (incomingMessage !== undefined) { - const messages = JSON.parse(incomingMessage.data); - return messages.forEach(message => applyDashboardStoreUpdate(currentData, message)); - } - return window.dash_clientside.no_update; - } -}; - window.dash_clientside.wo_classroom_text_highlighter = { /** * Send updated queries to the communication protocol. @@ -137,7 +105,8 @@ window.dash_clientside.wo_classroom_text_highlighter = { const outgoingMessage = { wo_classroom_text_highlighter_query: { execution_dag: 'writing_observer', - target_exports: ['docs_with_nlp_annotations', 'doc_list'], + // TODO add `doc_list` here when available + target_exports: ['docs_with_nlp_annotations'], kwargs: decodedParams } }; From 36073f02c67d4c97ce86058ebc40cc664c2c3b81 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 11 Sep 2024 12:13:12 -0400 Subject: [PATCH 10/41] added some more comments and broke out dashboard.py function --- .../learning_observer/dashboard.py | 93 ++++++++++++------- 1 file changed, 60 insertions(+), 33 deletions(-) diff --git a/learning_observer/learning_observer/dashboard.py b/learning_observer/learning_observer/dashboard.py index 0642b6f2..5de8ace0 100644 --- a/learning_observer/learning_observer/dashboard.py +++ b/learning_observer/learning_observer/dashboard.py @@ -439,30 +439,34 @@ def fully_qualify_names_with_default_namespace(dag, namespace_prefix): return dag -async def dispatch_named_execution_dag(dag_name, funcs): +async def dispatch_named_execution_dag(dag_name): + '''This method takes a Named Query and fetches it from the + available DAGs on the system. + ''' available_dags = learning_observer.module_loader.execution_dags() query = None try: query = available_dags[dag_name] except KeyError: debug_log(await dag_not_found(dag_name)) - funcs.append(dag_not_found(dag_name)) finally: return query -async def dispatch_defined_execution_dag(dag, funcs): +async def dispatch_defined_execution_dag(dag): + '''This method confirms that an Open Queries provided by the user + are 1) allowed to be submitted and 2) adhere to the appropriate + JSON structure. + ''' query = None if not learning_observer.settings.pmss_settings.dangerously_allow_insecure_dags(): debug_log(await dag_submission_not_allowed()) - funcs.append(dag_submission_not_allowed()) return query try: learning_observer.communication_protocol.schema.prevalidate_schema(dag) query = dag except jsonschema.ValidationError as e: debug_log(await dag_incorrect_format(e)) - funcs.append(dag_incorrect_format(e)) return query finally: return query @@ -520,55 +524,78 @@ async def execute_queries(client_data, request): return await asyncio.gather(*funcs, return_exceptions=False) -async def _create_dag_generator(client_query, target, request): +async def _handle_dependent_dags(query): + ''' + Handles dependent DAGs and ensures all dependencies are present. + NOTE dependent dags only work for on a single level dependency + TODO allow multiple layers of dependency among dags + ''' execution_dags = learning_observer.module_loader.execution_dags() - dag = client_query['execution_dag'] - if type(dag) not in DAG_DISPATCH: - debug_log(await dag_unsupported_type(type(dag))) - # TODO return something - # funcs.append(dag_unsupported_type(type(dag))) - return - - # TODO fix this funcs - query = await DAG_DISPATCH[type(dag)](dag, []) - if query is None: - # TODO do we return something here? - return - - # NOTE dependent dags only work for on a single level dependency - # TODO allow multiple layers of dependency among dags dependent_dags = extract_namespaced_dags(query['execution_dag']) missing_dags = dependent_dags - execution_dags.keys() + if missing_dags: - print('missing dag') + # TODO we ought to handle this as an error debug_log(await dag_not_found(missing_dags)) - # TODO return something - # funcs.append(dag_not_found(missing_dags)) return + for dep in dependent_dags: + # Copy and qualify names for dependent DAG dep_dag = copy.deepcopy(execution_dags[dep]['execution_dag']) prefixed_dag = fully_qualify_names_with_default_namespace(dep_dag, dep) + + # Merge dependent DAG with current query query['execution_dag'] = {**query['execution_dag'], **{f'{dep}.{k}': v for k, v in prefixed_dag.items()}} + return query + + +async def _prepare_dag_as_generator(client_query, query, target, request): + ''' + Prepares the query for execution, sets up client parameters and runtime. + ''' target_exports = [target] + + # Prepare the DAG execution function query_func = learning_observer.communication_protocol.integration.prepare_dag_execution(query, target_exports) + + # Handle client parameters and runtime setup client_parameters = client_query.get('kwargs', {}).copy() runtime = learning_observer.runtime.Runtime(request) client_parameters['runtime'] = runtime + + # Execute the query and return the first value from the generator generator_dictionary = await query_func(**client_parameters) return next(iter(generator_dictionary.values())) +async def _create_dag_generator(client_query, target, request): + dag = client_query['execution_dag'] + if type(dag) not in DAG_DISPATCH: + debug_log(await dag_unsupported_type(type(dag))) + return + + query = await DAG_DISPATCH[type(dag)](dag) + if query is None: + # the DAG_DISPATCH prints a more detailed message about why + debug_log('The submitted query failed.') + return + query = await _handle_dependent_dags(query) + return await _prepare_dag_as_generator(client_query, query, target, request) + + def _find_student_or_resource(d): - '''This method digs into the provenance and returns the user_id and - doc_id (if available) in a list to use for creating the update_path - that is sent to the client. - HACK provenance is normally removed when not in Dev mode - however, we are assuming its still around when this method - gets called. We ought to include some way for the communication protocol - to return the appropriate scope. - Currently most queries have `user_id` returned; however, there is no - equivalent for the `doc_id`. + '''HACK the communication protocol does not provide an easy way to + determine which student or student/document pair is being updated. + The protocol does include a provenance key with each item that includes + the history of what occured within the protocol. + In production settings, the provenance should be removed from the + user output. However, this method assumes that the provenance is still + around. + This method digs into the provenance and extracts the corresponding + student or student/document id. This information is used to tell the + client which items in their data-tree to update (i.e. update Billy's + History Essay with this new information). ''' if not isinstance(d, dict): return [] From c99df58fdda82b216729c70363747e8da66cfaa3 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Thu, 12 Sep 2024 09:24:20 -0400 Subject: [PATCH 11/41] updated map functions --- .../communication_protocol/executor.py | 107 ++++++++---------- .../communication_protocol/test_cases.py | 26 ++++- 2 files changed, 72 insertions(+), 61 deletions(-) diff --git a/learning_observer/learning_observer/communication_protocol/executor.py b/learning_observer/learning_observer/communication_protocol/executor.py index e6582302..b3f8baba 100644 --- a/learning_observer/learning_observer/communication_protocol/executor.py +++ b/learning_observer/learning_observer/communication_protocol/executor.py @@ -211,29 +211,16 @@ async def handle_join(left, right, left_on, right_on): # ).to_dict()) -def exception_wrapper(func): - """ - When we map values across a function, we want to catch any errors that may occur. - For asynchronous functions, we are able to use `asyncio.gather` which allows us to return - exceptions as normal results. This wrapper mimics this behavior for synchronous functions - and returns any exceptions as normal results. These exceptions are later caught by the - DAG executor and handled appropriately. This allows the system to keep executing the DAG even - if some values raise exceptions. - """ - def exception_catcher(*args, **kwargs): - try: - return func(*args, **kwargs) - except Exception as e: - return e - return exception_catcher - - async def map_coroutine_serial(func, values, value_path): """ We call map for coroutine functions operating in serial. See the `handle_map` function for more details regarding parameters. """ - return await asyncio.gather(*[func(get_nested_dict_value(v, value_path)) for v in values], return_exceptions=True) + async for v in ensure_async_generator(values): + try: + yield await func(get_nested_dict_value(v, value_path)), v + except Exception as e: + yield e, v async def map_coroutine_parallel(func, values, value_path): @@ -241,75 +228,82 @@ async def map_coroutine_parallel(func, values, value_path): We call map for coroutine functions operating in parallel. See the `handle_map` function for more details regarding parameters. """ - raise DAGExecutionException( - 'Asynchronous parallelization has not yet been implemented.', - inspect.currentframe().f_code.co_name, - {'function': func, 'values': values, 'value_path': value_path} - ) + tasks = [] + async for v in ensure_async_generator(values): + tasks.append(func(get_nested_dict_value(v, value_path))) + for task in asyncio.as_completed(tasks): + try: + yield await task, v + except Exception as e: + print(e, type(e)) + yield e, v -def map_parallel(func, values, value_path): +async def map_parallel(func, values, value_path): """ We call map for synchronous functions operating in parallel. See the `handle_map` function for more details regarding parameters. """ - with concurrent.futures.ProcessPoolExecutor() as executor: - # TODO catch any errors from get_nested_dict_value() - futures = [executor.submit(func, get_nested_dict_value(v, value_path)) for v in values] - results = [future.result() for future in futures] - return results + loop = asyncio.get_event_loop() + with concurrent.futures.ThreadPoolExecutor() as executor: + futures = [] + async for v in ensure_async_generator(values): + futures.append(loop.run_in_executor(executor, func, get_nested_dict_value(v, value_path))) + for future in asyncio.as_completed(futures): + try: + yield await future, v + except Exception as e: + yield e, v -def map_serial(func, values, value_path): +async def map_serial(func, values, value_path): """ We call map for synchronous functions operating in serial. See the `handle_map` function for more details regarding parameters. """ - outputs = [] - for v in values: + async for v in ensure_async_generator(values): try: - output = func(get_nested_dict_value(v, value_path)) - except DAGExecutionException as e: - output = e.to_dict() - outputs.append(output) - return outputs + yield func(get_nested_dict_value(v, value_path)), v + except Exception as e: + yield e, v -def annotate_map_metadata(function, results, values, value_path, func_kwargs): +async def _annotate_map_results_with_metadata(function, results, value_path, func_kwargs): """ - We annotate the list of raw results from mapping over a function with provenance - about the values passed in and the function used. Additionally, we want to - provide the proper metadata and output for any exceptions that took place - during execution of the map. + Each of the map functions yields the result (or errors) along with the value. + This function processes the output and the provenance to be further used in + the communicaton protocol. + If the result from the map is a dictionary, we use that as our base output. + If the result from the map is just a value, we wrap it in a dictionary. + If the result is an Exception, we wrap it in a DAGExecutionException and + use that as our result. This allows for some items to fail while others + were processed just fine. + Lastly, the provenance is added to our result. """ - output = [] - for res, item in zip(results, values): + async for map_result, item in results: provenance = { 'function': function, 'func_kwargs': func_kwargs, 'value': item, 'value_path': value_path } - if isinstance(res, dict): - out = res - elif isinstance(res, Exception): + if isinstance(map_result, dict): + out = map_result + elif isinstance(map_result, Exception): error_provenance = provenance.copy() - error_provenance['error'] = str(res) + error_provenance['error'] = str(map_result) out = DAGExecutionException( f'Function {function} did not execute properly during map.', inspect.currentframe().f_code.co_name, error_provenance, - res.__traceback__ + map_result.__traceback__ ).to_dict() else: - out = {'output': res} + out = {'output': map_result} out['provenance'] = provenance - output.append(out) - return output + yield out -# TODO the map functions have not been fully tested with the -# new async generator pipeline. Do this. MAPS = { 'map_parallel': map_parallel, 'map_serial': map_serial, @@ -365,15 +359,12 @@ async def handle_map(functions, function_name, values, value_path, func_kwargs=N func_with_kwargs = functools.partial(func, **func_kwargs) is_coroutine = inspect.iscoroutinefunction(func) map_function = MAPS[f'map{"_coroutine" if is_coroutine else ""}_{"parallel" if parallel else "serial"}'] - if not is_coroutine: - # wrap sync functions to return errors similar to asyncio.gather - func_with_kwargs = exception_wrapper(func_with_kwargs) results = map_function(func_with_kwargs, values, value_path) if inspect.isawaitable(results): results = await results - output = annotate_map_metadata(function_name, results, values, value_path, func_kwargs) + output = _annotate_map_results_with_metadata(function_name, results, value_path, func_kwargs) return output diff --git a/learning_observer/learning_observer/communication_protocol/test_cases.py b/learning_observer/learning_observer/communication_protocol/test_cases.py index b6b4dec4..a00b0c05 100644 --- a/learning_observer/learning_observer/communication_protocol/test_cases.py +++ b/learning_observer/learning_observer/communication_protocol/test_cases.py @@ -12,6 +12,7 @@ Test Cases: docs_with_roster Prints the dummy Google Docs text DAG. map_example Prints the map example test cases. + map_async_example Prints the map async example test cases. parameter_error Prints the parameter test case. field_error Prints the missing fields test case. malformed_key Prints the malformed key test case. @@ -26,7 +27,9 @@ import copy import docopt import json +import random import sys +import time import learning_observer.communication_protocol.executor import learning_observer.communication_protocol.integration @@ -57,7 +60,14 @@ def dummy_exception(): raise Exception('This is an exception that was raised in a published function.') -async def dummy_map(value, example): +async def dummy_async_map(value, example): + await asyncio.sleep(1) + if value.endswith('2'): + raise ValueError('Item ends with a 2') + return {'value': value, 'example': example} + +def dummy_sync_map(value, example): + time.sleep(1) if value.endswith('2'): raise ValueError('Item ends with a 2') return {'value': value, 'example': example} @@ -66,12 +76,14 @@ async def dummy_map(value, example): DUMMY_FUNCTIONS = { "learning_observer.dummyroster": dummy_roster, "learning_observer.dummycall": dummy_exception, - "learning_observer.dummymap": dummy_map + "learning_observer.dummyasyncmap": dummy_async_map, + "learning_observer.dummymap": dummy_sync_map } course_roster = q.call('learning_observer.dummyroster') exception_func = q.call('learning_observer.dummycall') map_func = q.call('learning_observer.dummymap') +async_map_func = q.call('learning_observer.dummyasyncmap') TEST_DAG = { 'execution_dag': { @@ -79,7 +91,8 @@ async def dummy_map(value, example): "doc_ids": q.select(q.keys('writing_observer.last_document', STUDENTS=q.variable("roster"), STUDENTS_path='user_id'), fields={'document_id': 'doc_id'}), "docs": q.select(q.keys('writing_observer.reconstruct', STUDENTS=q.variable("roster"), STUDENTS_path='user_id', RESOURCES=q.variable("doc_ids"), RESOURCES_path='doc_id'), fields={'text': 'text'}), "docs_join_roster": q.join(LEFT=q.variable("docs"), RIGHT=q.variable("roster"), LEFT_ON='provenance.provenance.STUDENT.value.user_id', RIGHT_ON='user_id'), - "map_students": q.map(map_func, q.variable('roster'), 'user_id', {'example': 123}), + "map_students": q.map(map_func, q.variable('roster'), 'user_id', {'example': 123}, parallel=True), + "map_async_students": q.map(async_map_func, q.variable('roster'), 'user_id', {'example': 123}, parallel=True), "field_error": q.select(q.keys('writing_observer.last_document', STUDENTS=q.variable("roster"), STUDENTS_path='user_id'), fields={'nonexistent_key': 'doc_id'}), "malformed_key_error": q.select([{'item': 1}, {'item': 2}], fields={'nonexistent_key': 'doc_id'}), "call_exception": exception_func(), @@ -102,6 +115,13 @@ async def dummy_map(value, example): "description": 'Show example of mapping students', 'expected': lambda x: isinstance(x, list) and 'value' in x[0] }, + 'map_async_example': { + 'returns': 'map_async_students', + 'parameters': ['course_id'], + 'test_parameters': {'course_id': 123}, + "description": 'Show example of mapping students', + 'expected': lambda x: isinstance(x, list) and 'value' in x[0] + }, 'parameter_error': { 'returns': 'docs_join_roster', 'parameters': ['course_id'], From 98799ebf631e5fe5d9b046d28b356bdb55e546c1 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 17 Sep 2024 16:44:18 -0400 Subject: [PATCH 12/41] started converting askgpt dashboard to use async generator comm protocol, both dashboards still need proper error handling --- .../communication_protocol/executor.py | 34 +++++++-- .../wo_bulk_essay_analysis/assets/scripts.js | 60 +++++++++------ .../dashboard/layout.py | 76 ++++++++++--------- .../wo_bulk_essay_analysis/gpt.py | 8 +- .../wo_bulk_essay_analysis/module.py | 10 ++- .../assets/scripts.js | 8 -- 6 files changed, 114 insertions(+), 82 deletions(-) diff --git a/learning_observer/learning_observer/communication_protocol/executor.py b/learning_observer/learning_observer/communication_protocol/executor.py index b3f8baba..190a0e57 100644 --- a/learning_observer/learning_observer/communication_protocol/executor.py +++ b/learning_observer/learning_observer/communication_protocol/executor.py @@ -191,7 +191,6 @@ async def handle_join(left, right, left_on, right_on): right_dict[nested_value] = d except KeyError as e: pass - async for left_dict in ensure_async_generator(left): try: lookup_key = get_nested_dict_value(left_dict, left_on) @@ -203,6 +202,7 @@ async def handle_join(left, right, left_on, right_on): merged_dict = left_dict yield merged_dict except KeyError as e: + debug_log(f'Encountered an error during join, returning left without right. Error: {e}.') yield left_dict # result.append(DAGExecutionException( # f'KeyError: key `{left_on}` not found in `{left_dict.keys()}`', @@ -228,14 +228,22 @@ async def map_coroutine_parallel(func, values, value_path): We call map for coroutine functions operating in parallel. See the `handle_map` function for more details regarding parameters. """ + async def _return_result_and_value(v): + '''Wrapper for the function to return both the result and + the value passed in. The value is yielded to annotate the + results metadata. + ''' + result = await func(get_nested_dict_value(v, value_path)) + return result, v + tasks = [] async for v in ensure_async_generator(values): - tasks.append(func(get_nested_dict_value(v, value_path))) + tasks.append(_return_result_and_value(v)) for task in asyncio.as_completed(tasks): try: - yield await task, v + task_result, task_value = await task + yield task_result, task_value except Exception as e: - print(e, type(e)) yield e, v @@ -244,14 +252,23 @@ async def map_parallel(func, values, value_path): We call map for synchronous functions operating in parallel. See the `handle_map` function for more details regarding parameters. """ + def _return_result_and_value(v): + '''Wrapper for the function to return both the result and + the value passed in. The value is yielded to annotate the + results metadata. + ''' + result = func(get_nested_dict_value(v, value_path)) + return result, v + loop = asyncio.get_event_loop() with concurrent.futures.ThreadPoolExecutor() as executor: futures = [] async for v in ensure_async_generator(values): - futures.append(loop.run_in_executor(executor, func, get_nested_dict_value(v, value_path))) + futures.append(loop.run_in_executor(executor, _return_result_and_value, v)) for future in asyncio.as_completed(futures): try: - yield await future, v + future_result, future_value = await future + yield future_result, future_value except Exception as e: yield e, v @@ -284,8 +301,9 @@ async def _annotate_map_results_with_metadata(function, results, value_path, fun provenance = { 'function': function, 'func_kwargs': func_kwargs, - 'value': item, - 'value_path': value_path + 'value': {k: v for k, v in item.items() if k != 'provenance'}, + 'value_path': value_path, + 'provenance': item['provenance'] if 'provenance' in item else {} } if isinstance(map_result, dict): out = map_result 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 9826c6d4..1f14bf52 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 @@ -3,22 +3,26 @@ */ if (!window.dash_clientside) { - window.dash_clientside = {} + window.dash_clientside = {}; } pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/3rd_party/pdf.worker.min.js' -const createStudentCard = function (student, prompt) { +const createStudentCard = function (s, prompt) { + // TODO this ought to come from the comm protocol + const document = Object.keys(s.documents)[0]; + const student = s.documents[document]; + const header = { namespace: 'dash_bootstrap_components', type: 'CardHeader', props: { children: student.profile.name.full_name } - } + }; const studentText = { namespace: 'lo_dash_react_components', type: 'WOAnnotatedText', props: { text: student.text, breakpoints: [], className: 'border-end' } - } + }; const feedbackMessage = { namespace: 'dash_html_components', type: 'Div', @@ -27,7 +31,7 @@ const createStudentCard = function (student, prompt) { className: student?.feedback ? 'p-1 overflow-auto' : '', style: { whiteSpace: 'pre-line' } } - } + }; const feedbackLoading = { namespace: 'dash_html_components', type: 'Div', @@ -39,8 +43,8 @@ const createStudentCard = function (student, prompt) { }, className: 'text-center' } - } - const feedback = prompt === student.prompt ? feedbackMessage : feedbackLoading + }; + const feedback = prompt === student.prompt ? feedbackMessage : feedbackLoading; const body = { namespace: 'lo_dash_react_components', type: 'LOPanelLayout', @@ -50,7 +54,7 @@ const createStudentCard = function (student, prompt) { shown: ['feedback-text'], className: 'overflow-auto p-1' } - } + }; const card = { namespace: 'dash_bootstrap_components', type: 'Card', @@ -58,7 +62,7 @@ const createStudentCard = function (student, prompt) { children: [header, body], style: { maxHeight: '375px' } } - } + }; return { namespace: 'dash_bootstrap_components', type: 'Col', @@ -67,8 +71,8 @@ const createStudentCard = function (student, prompt) { id: student.user_id, width: 4 } - } -} + }; +}; const charactersAfterChar = function (str, char) { const commaIndex = str.indexOf(char) @@ -76,7 +80,7 @@ const charactersAfterChar = function (str, char) { return '' } return str.slice(commaIndex + 1).trim() -} +}; const extractPDF = async function (base64String) { const pdfData = atob(charactersAfterChar(base64String, ',')) @@ -101,7 +105,7 @@ const extractPDF = async function (base64String) { const allText = allTexts.join('\n') return allText -} +}; window.dash_clientside.bulk_essay_feedback = { /** @@ -134,10 +138,17 @@ window.dash_clientside.bulk_essay_feedback = { target_exports: ['gpt_bulk'], kwargs: decoded } - } - return JSON.stringify(message) + }; + return JSON.stringify(message); } - return window.dash_clientside.no_update + return window.dash_clientside.no_update; + }, + + toggleAdvanced: function (clicks, isOpen) { + if (!clicks) { + return window.dash_clientside.no_update; + } + return !isOpen; }, /** @@ -183,12 +194,17 @@ window.dash_clientside.bulk_essay_feedback = { /** * update student cards based on new data in storage */ - update_student_grid: function (message, history) { - const currPrompt = history.length > 0 ? history[history.length - 1] : '' - const cards = message.map((x) => { - return createStudentCard(x, currPrompt) - }) - return cards + update_student_grid: function (wsStorageData, history) { + if (!wsStorageData) { + return 'No students'; + } + const currPrompt = history.length > 0 ? history[history.length - 1] : ''; + + let output = []; + for (const student in wsStorageData) { + output = output.concat(createStudentCard(wsStorageData[student], currPrompt)); + } + return output; }, /** 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 f2fb946d..c7735a59 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 @@ -13,8 +13,10 @@ DEBUG_FLAG = True prefix = 'bulk-essay-analysis' -websocket = f'{prefix}-websocket' -ws_store = f'{prefix}-ws-store' +_websocket = f'{prefix}-websocket' +_namespace = 'bulk_essay_feedback' +# TODO we still need to handle how errors are shown on the dashboard +# since updating to the async generator pipeline error_store = f'{prefix}-error-store' alert = f'{prefix}-alert' @@ -25,7 +27,9 @@ panel_layout = f'{prefix}-panel-layout' -advanced_collapse = f'{prefix}-advanced-collapse' +_advanced_toggle = f'{prefix}-advanced-toggle' +_advanced_collapse = f'{prefix}-advanced-collapse' + system_input = f'{prefix}-system-prompt-input' # document source doc_src = f'{prefix}-doc-src' @@ -63,25 +67,23 @@ def layout(): Generic layout function to create dashboard ''' # advanced menu for system prompt - advanced = dbc.Col([ - lodrc.LOCollapse([ + advanced = [ + dbc.InputGroup([ + dbc.InputGroupText('System prompt:'), + dbc.Textarea(id=system_input, value=system_prompt) + ]), + html.Div([ + dbc.Label('Document Source'), + dbc.RadioItems(options=[ + {'label': 'Latest Document', 'value': 'latest' }, + {'label': 'Specific Time', 'value': 'ts'}, + ], value='latest', id=doc_src), dbc.InputGroup([ - dbc.InputGroupText('System prompt:'), - dbc.Textarea(id=system_input, value=system_prompt) - ]), - html.Div([ - dbc.Label('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")) - ]) + 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")) ]) - ], label='Advanced', id=advanced_collapse, is_open=False), - ]) + ]) + ] # history panel history_favorite_panel = dbc.Card([ @@ -133,6 +135,12 @@ def layout(): 'The dashboard is subject to change based on ongoing feedback from teachers.' ), html.H2('AskGPT'), + dbc.InputGroup([ + dbc.InputGroupText(lodrc.LOConnectionAIO(aio_id=_websocket)), + dbc.Button(html.I(className='fas fa-cog'), id=_advanced_toggle), + lodrc.ProfileSidebarAIO(class_name='rounded-0 rounded-end', color='secondary'), + ]), + dbc.Collapse(advanced, id=_advanced_collapse), lodrc.LOPanelLayout( input_panel, panels=[ @@ -142,11 +150,8 @@ def layout(): shown=['history-favorite'], id=panel_layout ), - dbc.Row([advanced]), alert_component, dbc.Row(id=grid, class_name='g-2 mt-2'), - lodrc.LOConnection(id=websocket), - dcc.Store(id=ws_store, data=[]), dcc.Store(id=error_store, data=False) ], fluid=True) return dcc.Loading(cont) @@ -160,11 +165,19 @@ def layout(): Input(doc_src, 'value') ) +# Toggle if the advanced menu collapse is open or not +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='toggleAdvanced'), + Output(_advanced_collapse, 'is_open'), + Input(_advanced_toggle, 'n_clicks'), + State(_advanced_collapse, 'is_open') +) + # send request on websocket clientside_callback( ClientsideFunction(namespace='bulk_essay_feedback', function_name='send_to_loconnection'), - Output(websocket, 'send'), - Input(websocket, 'state'), # used for initial setup + Output(lodrc.LOConnectionAIO.ids.websocket(_websocket), 'send'), + Input(lodrc.LOConnectionAIO.ids.websocket(_websocket), 'state'), # used for initial setup Input('_pages_location', 'hash'), Input(submit, 'n_clicks'), Input(doc_src, 'value'), @@ -215,15 +228,6 @@ def layout(): State(panel_layout, 'shown') ) -# store message from LOConnection in storage for later use -clientside_callback( - ClientsideFunction(namespace='bulk_essay_feedback', function_name='receive_ws_message'), - Output(ws_store, 'data'), - Output(error_store, 'data'), - Input(websocket, 'message'), - prevent_initial_call=True -) - clientside_callback( ClientsideFunction(namespace='bulk_essay_feedback', function_name='update_alert_with_error'), Output(alert_text, 'children'), @@ -234,9 +238,9 @@ def layout(): # update student cards based on new data in storage clientside_callback( - ClientsideFunction(namespace='bulk_essay_feedback', function_name='update_student_grid'), + ClientsideFunction(namespace=_namespace, function_name='update_student_grid'), Output(grid, 'children'), - Input(ws_store, 'data'), + Input(lodrc.LOConnectionAIO.ids.ws_store(_websocket), 'data'), Input(history_store, 'data') ) 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 0f2ce6ee..ab5ce34b 100644 --- a/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py +++ b/modules/wo_bulk_essay_analysis/wo_bulk_essay_analysis/gpt.py @@ -71,9 +71,6 @@ async def chat_completion(self, prompt, system_prompt): class OllamaGPT(GPTAPI): '''GPT responder for handling request to the Ollama API - TODO this ought to just use requests instead of the specific ollama package - the format *should* be the same as the OpenAI responder. This will be one - less external module to rely on. ''' def __init__(self, **kwargs): ''' @@ -111,7 +108,7 @@ async def chat_completion(self, prompt, system_prompt): content = {'model': self.model, 'messages': messages, 'stream': False} async with aiohttp.ClientSession() as session: async with session.post(url, json=content) as resp: - json_resp = await resp.json(content_type=None) + json_resp = await resp.json() if resp.status == 200: return json_resp['message']['content'] error = 'Error occured while making Ollama request' @@ -170,7 +167,6 @@ async def process_student_essay(text, prompt, system_prompt, tags): We use a closure to allow the system to connect to the memoization KVS. ''' - copy_tags = tags.copy() @learning_observer.cache.async_memoization() @@ -203,7 +199,7 @@ async def gpt(gpt_prompt): async def test_responder(): - responder = OllamaGPT('llama2') + responder = OllamaGPT() response = await responder.chat_completion('Why is the sky blue?', 'You are a helper agent, please help fulfill user requests.') print('Response:', response) 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 67ef2cef..ecdfd629 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 @@ -31,8 +31,14 @@ 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={})}), - 'gpt_bulk': q.join(LEFT=q.variable('gpt_map'), LEFT_ON='provenance.value.provenance.provenance.STUDENT.value.user_id', RIGHT=q.variable('writing_observer.roster'), RIGHT_ON='user_id') + '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': { 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 b18c54c6..924e4052 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 @@ -148,15 +148,7 @@ window.dash_clientside.wo_classroom_text_highlighter = { if (!wsStorageData) { return 'No students'; } - const data = wsStorageData?.wo_classroom_text_highlighter_query?.nlp_combined ?? []; - // Check if the returned items have errors - if (typeof data === 'object' && !Array.isArray(data) && data !== null && 'error' in data) { - return window.dash_clientside.no_update; - } let output = []; - // TODO now for the fun stuff. We need to take the options, determine which ones we want - // and pass those to the apppropriate place. - // how should student look here? const selectedHighlights = options.filter(option => option.types?.highlight?.value); // TODO do something with the selected metrics/progress bars/etc. From dd8fb140b13d35a602851a997a9078b75330fc41 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 18 Sep 2024 09:40:30 -0400 Subject: [PATCH 13/41] fixed all but 1 broken doctests --- .../communication_protocol/executor.py | 34 +++++++++++-------- learning_observer/learning_observer/util.py | 11 ++++++ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/learning_observer/learning_observer/communication_protocol/executor.py b/learning_observer/learning_observer/communication_protocol/executor.py index 190a0e57..7d79df09 100644 --- a/learning_observer/learning_observer/communication_protocol/executor.py +++ b/learning_observer/learning_observer/communication_protocol/executor.py @@ -21,6 +21,9 @@ from learning_observer.util import get_nested_dict_value, clean_json, ensure_async_generator, async_zip from learning_observer.communication_protocol.exception import DAGExecutionException +# This function isn't used directly by the code but instead used by doctests +from learning_observer.util import async_generator_to_list + dispatch = learning_observer.communication_protocol.query.dispatch @@ -156,33 +159,30 @@ async def handle_join(left, right, left_on, right_on): ``` Generic join where left.lid == right.rid - >>> handle_join( + >>> asyncio.run(async_generator_to_list(handle_join( ... left=[{'lid': 1, 'left': True}, {'lid': 2, 'left': True}], ... right=[{'rid': 2, 'right': True}, {'rid': 1, 'right': True}], ... left_on='lid', right_on='rid' - ... ) + ... ))) [{'lid': 1, 'left': True, 'rid': 1, 'right': True}, {'lid': 2, 'left': True, 'rid': 2, 'right': True}] We return every item in `left` even if they do not have a matching item in `right`. This also demonstrates the behavior for `RIGHT_ON` not being found in one of the elements of `right`. - >>> handle_join( + >>> asyncio.run(async_generator_to_list(handle_join( ... left=[{'lid': 1, 'left': True}, {'lid': 2, 'left': True}], ... right=[{'right': True}, {'rid': 1, 'right': True}], ... left_on='lid', right_on='rid' - ... ) + ... ))) [{'lid': 1, 'left': True, 'rid': 1, 'right': True}, {'lid': 2, 'left': True}] - When `LEFT_ON` is not found, we return an error. Instead of throwing exceptions, - we return errors like normal results and allow the DAG executor to handle package - them and bubble them up. This allows to only error on a singular item and allow - the others to continue running. - >>> handle_join( + When `LEFT_ON` is not found, we return an whatever is in `left`. + >>> asyncio.run(async_generator_to_list(handle_join( ... left=[{'left': True}, {'lid': 2, 'left': True}], ... right=[{'rid': 2, 'right': True}, {'rid': 1, 'right': True}], ... left_on='lid', right_on='rid' - ... ) - [{'error': "KeyError: key `lid` not found in `dict_keys(['left'])`", 'function': 'handle_join', 'error_provenance': {'target': {'left': True}, 'key': 'lid', 'exception': KeyError("Key lid not found in {'left': True}")}, 'timestamp': ... 'traceback': ... {'lid': 2, 'left': True, 'rid': 2, 'right': True}] + ... ))) + [{'left': True}, {'lid': 2, 'left': True, 'rid': 2, 'right': True}] """ right_dict = {} async for d in ensure_async_generator(right): @@ -202,7 +202,8 @@ async def handle_join(left, right, left_on, right_on): merged_dict = left_dict yield merged_dict except KeyError as e: - debug_log(f'Encountered an error during join, returning left without right. Error: {e}.') + # TODO should we throw an error if we can't find a match in + # right or should we just yield left as is? yield left_dict # result.append(DAGExecutionException( # f'KeyError: key `{left_on}` not found in `{left_dict.keys()}`', @@ -348,17 +349,20 @@ async def handle_map(functions, function_name, values, value_path, func_kwargs=N ... return x * 2 Generic example of mapping a double function over [0, 1]. - >>> asyncio.run(handle_map({'double': double}, 'double', [{'path': i} for i in range(2)], 'path')) + >>> asyncio.run(async_generator_to_list(handle_map({'double': double}, 'double', [{'path': i} for i in range(2)], 'path'))) [{'output': 0, 'provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 0}, 'value_path': 'path'}}, {'output': 2, 'provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 1}, 'value_path': 'path'}}] Exceptions in each function with in the map are returned with normal results and handled later by the DAG executor. In our text, we return both a normal result and the result of an exception being caught. - >>> asyncio.run(handle_map({'double': double}, 'double', [{'path': i} for i in [1, 'fail']], 'path')) + >>> asyncio.run(async_generator_to_list(handle_map({'double': double}, 'double', [{'path': i} for i in [1, 'fail']], 'path'))) [{'output': 2, 'provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 1}, 'value_path': 'path'}}, {'error': 'Function double did not execute properly during map.', 'function': 'annotate_map_metadata', 'error_provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 'fail'}, 'value_path': 'path', 'error': 'Input must be an int'}, 'timestamp': ... 'traceback': ... 'provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 'fail'}, 'value_path': 'path'}}] + TODO fix this test case/workflow to work with the async generator pipeline. + Throwing the error here will cause the pipeline to break since the exception + does not have an `__aiter__` method. Example of trying to call nonexistent function, `triple` - >>> asyncio.run(handle_map({'double': double}, 'triple', [{'path': i} for i in range(2)], 'path')) + >>> asyncio.run(async_generator_to_list(handle_map({'double': double}, 'triple', [{'path': i} for i in range(2)], 'path'))) Traceback (most recent call last): ... learning_observer.communication_protocol.exception.DAGExecutionException: ('Could not find function `triple` in available functions.', 'handle_map', {'function_name': 'triple', 'available_functions': dict_keys(['double']), 'error': "'triple'"}, ...) diff --git a/learning_observer/learning_observer/util.py b/learning_observer/learning_observer/util.py index 7f78b9b7..96140943 100644 --- a/learning_observer/learning_observer/util.py +++ b/learning_observer/learning_observer/util.py @@ -288,6 +288,17 @@ async def async_zip(iterator1, iterator2): pass +async def async_generator_to_list(gen): + '''This is a helper function for converting an async generator + to a list. This is often used when testing pieces of an async + generator pipeline. + ''' + result = [] + async for item in gen: + result.append(item) + return result + + # And a test case if __name__ == '__main__': assert to_safe_filename('{') == '-123-' From 4f646a80c3e5e122b015debf5114fe50911195d1 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 18 Sep 2024 15:41:19 -0400 Subject: [PATCH 14/41] fixed another map bug and got error handling working --- .../communication_protocol/executor.py | 24 +++---- .../LOConnectionAIO.py | 65 ++++++++++++------- .../wo_bulk_essay_analysis/assets/scripts.js | 14 ++-- .../dashboard/layout.py | 10 +-- 4 files changed, 62 insertions(+), 51 deletions(-) diff --git a/learning_observer/learning_observer/communication_protocol/executor.py b/learning_observer/learning_observer/communication_protocol/executor.py index 7d79df09..4c03fd0b 100644 --- a/learning_observer/learning_observer/communication_protocol/executor.py +++ b/learning_observer/learning_observer/communication_protocol/executor.py @@ -234,18 +234,18 @@ async def _return_result_and_value(v): the value passed in. The value is yielded to annotate the results metadata. ''' - result = await func(get_nested_dict_value(v, value_path)) + try: + result = await func(get_nested_dict_value(v, value_path)) + except Exception as e: + result = e return result, v tasks = [] async for v in ensure_async_generator(values): tasks.append(_return_result_and_value(v)) for task in asyncio.as_completed(tasks): - try: - task_result, task_value = await task - yield task_result, task_value - except Exception as e: - yield e, v + task_result, task_value = await task + yield task_result, task_value async def map_parallel(func, values, value_path): @@ -258,7 +258,10 @@ def _return_result_and_value(v): the value passed in. The value is yielded to annotate the results metadata. ''' - result = func(get_nested_dict_value(v, value_path)) + try: + result = func(get_nested_dict_value(v, value_path)) + except Exception as e: + result = e return result, v loop = asyncio.get_event_loop() @@ -267,11 +270,8 @@ def _return_result_and_value(v): async for v in ensure_async_generator(values): futures.append(loop.run_in_executor(executor, _return_result_and_value, v)) for future in asyncio.as_completed(futures): - try: - future_result, future_value = await future - yield future_result, future_value - except Exception as e: - yield e, v + future_result, future_value = await future + yield future_result, future_value async def map_serial(func, values, value_path): diff --git a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py index 2ad605ea..0cd79bd5 100644 --- a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py +++ b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py @@ -36,6 +36,11 @@ class ids: 'subcomponent': 'ws_store', 'aio_id': aio_id } + error_store = lambda aio_id: { + 'component': 'LOConnectionAIO', + 'subcomponent': 'error_store', + 'aio_id': aio_id + } ids = ids @@ -51,12 +56,13 @@ def __init__(self, aio_id=None, data_scope=None): dcc.Interval(id=self.ids.last_updated_interval(aio_id), interval=5000), LOConnection(id=self.ids.websocket(aio_id), data_scope=data_scope), dcc.Store(id=self.ids.last_updated_store(aio_id), data=-1), - dcc.Store(id=self.ids.ws_store(aio_id), data={}) + dcc.Store(id=self.ids.ws_store(aio_id), data={}), + dcc.Store(id=self.ids.error_store(aio_id), data={}) ] super().__init__(component) + # Update connection status information clientside_callback( - # ClientsideFunction(namespace='lo_dash_react_components', function_name='update_connection_status_icon'), '''function (status) { const icons = ['fas fa-sync-alt', 'fas fa-check text-success', 'fas fa-sync-alt', 'fas fa-times text-danger']; const titles = ['Connecting to server', 'Connected to server', 'Closing connection', 'Disconnected from server']; @@ -71,8 +77,8 @@ def __init__(self, aio_id=None, data_scope=None): Input(ids.websocket(MATCH), 'state'), ) + # Update connection last modified text clientside_callback( - # ClientsideFunction(namespace='lo_dash_react_components', function_name='update_connection_last_modified_text'), '''function (lastTime, intervals) { if (lastTime === -1) { return 'Never'; @@ -91,8 +97,8 @@ def __init__(self, aio_id=None, data_scope=None): Input(ids.last_updated_interval(MATCH), 'n_intervals') ) + # Update when the data was last modified clientside_callback( - # ClientsideFunction(namespace='lo_dash_react_components', function_name='update_connection_last_modified_store'), '''function (data) { if (data !== undefined) { return new Date(); @@ -103,37 +109,46 @@ def __init__(self, aio_id=None, data_scope=None): Input(ids.websocket(MATCH), 'message') ) + # Handle incoming message from server clientside_callback( - '''function (incomingMessage, currentData) { + '''function (incomingMessage, currentData, errorStore) { + // console.log('LOConnection', incomingMessage, currentData, errorStore); if (incomingMessage !== undefined) { - messages = JSON.parse(incomingMessage.data); + const messages = JSON.parse(incomingMessage.data); messages.forEach(message => { - const pathKeys = message.path.split('.'); - let current = currentData; + const pathKeys = message.path.split('.'); + let current = currentData; - // Traverse the path to get to the right location - for (let i = 0; i < pathKeys.length - 1; i++) { - const key = pathKeys[i]; - if (!(key in current)) { - current[key] = {}; // Create path if it doesn't exist + // Traverse the path to get to the right location + for (let i = 0; i < pathKeys.length - 1; i++) { + const key = pathKeys[i]; + if (!(key in current)) { + current[key] = {}; // Create path if it doesn't exist + } + current = current[key]; } - current = current[key]; - } - const finalKey = pathKeys[pathKeys.length - 1]; - if (message.op === 'update') { - // Shallow merge using spread syntax - current[finalKey] = { - ...current[finalKey], // Existing data - ...message.value // New data (overwrites where necessary) - }; - } + if ('error' in message.value) { + errorStore[message.path] = message.value; + } else { + delete errorStore[message.path]; + } + const finalKey = pathKeys[pathKeys.length - 1]; + if (message.op === 'update' && !('error' in message.value)) { + // Shallow merge using spread syntax + current[finalKey] = { + ...current[finalKey], // Existing data + ...message.value // New data (overwrites where necessary) + }; + } }); - return currentData; // Return updated data + return [currentData, errorStore]; // Return updated data } return window.dash_clientside.no_update; }''', Output(ids.ws_store(MATCH), 'data'), + Output(ids.error_store(MATCH), 'data'), Input(ids.websocket(MATCH), 'message'), - State(ids.ws_store(MATCH), 'data') + State(ids.ws_store(MATCH), 'data'), + State(ids.error_store(MATCH), 'data') ) 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 1f14bf52..aa3f4819 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 @@ -6,7 +6,7 @@ if (!window.dash_clientside) { window.dash_clientside = {}; } -pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/3rd_party/pdf.worker.min.js' +pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/3rd_party/pdf.worker.min.js'; const createStudentCard = function (s, prompt) { // TODO this ought to come from the comm protocol @@ -75,11 +75,11 @@ const createStudentCard = function (s, prompt) { }; const charactersAfterChar = function (str, char) { - const commaIndex = str.indexOf(char) + const commaIndex = str.indexOf(char); if (commaIndex === -1) { - return '' + return ''; } - return str.slice(commaIndex + 1).trim() + return str.slice(commaIndex + 1).trim(); }; const extractPDF = async function (base64String) { @@ -194,7 +194,7 @@ window.dash_clientside.bulk_essay_feedback = { /** * update student cards based on new data in storage */ - update_student_grid: function (wsStorageData, history) { + updateStudentGridOutput: function (wsStorageData, history) { if (!wsStorageData) { return 'No students'; } @@ -322,8 +322,8 @@ window.dash_clientside.bulk_essay_feedback = { * - show alert * - JSON error data on the alert (only in debug) */ - update_alert_with_error: function (error) { - if (!error) { + updateAlertWithError: function (error) { + if (Object.keys(error).length === 0) { return ['', false, '']; } const text = 'Oops! Something went wrong ' + 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 c7735a59..4bf2b01e 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 @@ -15,9 +15,6 @@ prefix = 'bulk-essay-analysis' _websocket = f'{prefix}-websocket' _namespace = 'bulk_essay_feedback' -# TODO we still need to handle how errors are shown on the dashboard -# since updating to the async generator pipeline -error_store = f'{prefix}-error-store' alert = f'{prefix}-alert' alert_text = f'{prefix}-alert-text' @@ -152,7 +149,6 @@ def layout(): ), alert_component, dbc.Row(id=grid, class_name='g-2 mt-2'), - dcc.Store(id=error_store, data=False) ], fluid=True) return dcc.Loading(cont) @@ -229,16 +225,16 @@ def layout(): ) clientside_callback( - ClientsideFunction(namespace='bulk_essay_feedback', function_name='update_alert_with_error'), + ClientsideFunction(namespace=_namespace, function_name='updateAlertWithError'), Output(alert_text, 'children'), Output(alert, 'is_open'), Output(alert_error_dump, 'data'), - Input(error_store, 'data') + Input(lodrc.LOConnectionAIO.ids.error_store(_websocket), 'data') ) # update student cards based on new data in storage clientside_callback( - ClientsideFunction(namespace=_namespace, function_name='update_student_grid'), + ClientsideFunction(namespace=_namespace, function_name='updateStudentGridOutput'), Output(grid, 'children'), Input(lodrc.LOConnectionAIO.ids.ws_store(_websocket), 'data'), Input(history_store, 'data') From 30702be1db761d669bc82a6044af06bc6b35c28b Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 18 Sep 2024 16:51:19 -0400 Subject: [PATCH 15/41] now all the doctests are working again --- .../communication_protocol/executor.py | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/learning_observer/learning_observer/communication_protocol/executor.py b/learning_observer/learning_observer/communication_protocol/executor.py index 4c03fd0b..1795357b 100644 --- a/learning_observer/learning_observer/communication_protocol/executor.py +++ b/learning_observer/learning_observer/communication_protocol/executor.py @@ -348,36 +348,39 @@ async def handle_map(functions, function_name, values, value_path, func_kwargs=N ... raise ValueError("Input must be an int") ... return x * 2 + >>> async def process_map_test_result(func): + ... '''The map functions return an async generator. + ... This function awaits the creation of the generator and drives it. + ... ''' + ... result = await func + ... return await async_generator_to_list(result) + Generic example of mapping a double function over [0, 1]. - >>> asyncio.run(async_generator_to_list(handle_map({'double': double}, 'double', [{'path': i} for i in range(2)], 'path'))) - [{'output': 0, 'provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 0}, 'value_path': 'path'}}, {'output': 2, 'provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 1}, 'value_path': 'path'}}] + >>> asyncio.run(process_map_test_result(handle_map({'double': double}, 'double', [{'path': i} for i in range(2)], 'path'))) + [{'output': 0, 'provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 0}, 'value_path': 'path', 'provenance': {}}}, {'output': 2, 'provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 1}, 'value_path': 'path', 'provenance': {}}}] Exceptions in each function with in the map are returned with normal results and handled later by the DAG executor. In our text, we return both a normal result and the result of an exception being caught. - >>> asyncio.run(async_generator_to_list(handle_map({'double': double}, 'double', [{'path': i} for i in [1, 'fail']], 'path'))) - [{'output': 2, 'provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 1}, 'value_path': 'path'}}, {'error': 'Function double did not execute properly during map.', 'function': 'annotate_map_metadata', 'error_provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 'fail'}, 'value_path': 'path', 'error': 'Input must be an int'}, 'timestamp': ... 'traceback': ... 'provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 'fail'}, 'value_path': 'path'}}] + >>> asyncio.run(process_map_test_result(handle_map({'double': double}, 'double', [{'path': i} for i in [1, 'fail']], 'path'))) + [{'output': 2, 'provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 1}, 'value_path': 'path', 'provenance': {}}}, {'error': 'Function double did not execute properly during map.', 'function': '_annotate_map_results_with_metadata', 'error_provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 'fail'}, 'value_path': 'path', 'provenance': {}, 'error': 'Input must be an int'}, 'timestamp': ..., 'traceback': ..., 'provenance': {'function': 'double', 'func_kwargs': {}, 'value': {'path': 'fail'}, 'value_path': 'path', 'provenance': {}}}] - TODO fix this test case/workflow to work with the async generator pipeline. - Throwing the error here will cause the pipeline to break since the exception - does not have an `__aiter__` method. Example of trying to call nonexistent function, `triple` - >>> asyncio.run(async_generator_to_list(handle_map({'double': double}, 'triple', [{'path': i} for i in range(2)], 'path'))) - Traceback (most recent call last): - ... - learning_observer.communication_protocol.exception.DAGExecutionException: ('Could not find function `triple` in available functions.', 'handle_map', {'function_name': 'triple', 'available_functions': dict_keys(['double']), 'error': "'triple'"}, ...) + >>> asyncio.run(process_map_test_result(handle_map({'double': double}, 'triple', [{'path': i} for i in range(2)], 'path'))) + [{'error': 'Could not find function `triple` in available functions.', 'function': 'handle_map', 'error_provenance': {'function_name': 'triple', 'available_functions': dict_keys(['double']), 'error': "'triple'"}, 'timestamp': ..., 'traceback': ...}] """ if func_kwargs is None: func_kwargs = {} try: func = functions[function_name] except KeyError as e: - raise DAGExecutionException( + exception = DAGExecutionException( f'Could not find function `{function_name}` in available functions.', inspect.currentframe().f_code.co_name, {'function_name': function_name, 'available_functions': functions.keys(), 'error': str(e)}, e.__traceback__ - ) + ).to_dict() + return ensure_async_generator(exception) func_with_kwargs = functools.partial(func, **func_kwargs) is_coroutine = inspect.iscoroutinefunction(func) map_function = MAPS[f'map{"_coroutine" if is_coroutine else ""}_{"parallel" if parallel else "serial"}'] From a08e4574d10cf95e6647f954e1b0cd5bc3d48907 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Fri, 20 Sep 2024 09:09:53 -0400 Subject: [PATCH 16/41] added new presets, hid unimplemented features, and added error alert to highlight dashboard --- .../src/lib/components/WOSettings.react.js | 7 +- .../lib/components/WOStudentTextTile.react.js | 9 ++- .../assets/scripts.js | 15 +++++ .../dash_dashboard.py | 28 +++++++- .../wo_classroom_text_highlighter/options.py | 67 +++++++++++++------ .../preset_component.py | 7 +- 6 files changed, 105 insertions(+), 28 deletions(-) diff --git a/modules/lo_dash_react_components/src/lib/components/WOSettings.react.js b/modules/lo_dash_react_components/src/lib/components/WOSettings.react.js index 85e7643a..2295b8f0 100644 --- a/modules/lo_dash_react_components/src/lib/components/WOSettings.react.js +++ b/modules/lo_dash_react_components/src/lib/components/WOSettings.react.js @@ -77,7 +77,7 @@ export default class WOSettings extends Component { {'\u00A0'.repeat(row.depth * 2) + row.label} {highlightCell} - {metricCell} + {/* {metricCell} */} ); } @@ -85,6 +85,9 @@ export default class WOSettings extends Component { render () { const { id, className, options } = this.props; const rows = sortOptionsIntoTree(options); + // TODO due to a HACK with passing data to the child component of + // the student tiles, we currently only support a single child and + // expect it to be the highlighted text component. return ( - + {/* */} diff --git a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js index 2623e1fc..4e396523 100644 --- a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js +++ b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js @@ -30,6 +30,11 @@ export default class WOStudentTextTile extends Component { const documentIsSelected = selectedDocument && studentInfo.documents[selectedDocument]; let bodyClassName = documentIsSelected && currentOptionHash !== studentInfo.documents[selectedDocument].optionHash ? 'loading' : ''; bodyClassName = `${bodyClassName} overflow-auto`; + + // TODO the chunk of commented code allows for linking directly to the selected document + // and allows the user to select which document they wish to see at a given moment. + // Neither of these features are currently available due to limitations with the communication + // protocol. For now they are being commented out so users are not inclined to use them. return ( @@ -38,7 +43,7 @@ export default class WOStudentTextTile extends Component { profile={studentInfo.profile || {}} includeName={true} /> - ( ))} - + */} { 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 924e4052..e10a943f 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 @@ -152,6 +152,9 @@ window.dash_clientside.wo_classroom_text_highlighter = { const selectedHighlights = options.filter(option => option.types?.highlight?.value); // TODO do something with the selected metrics/progress bars/etc. + // currently due to a HACK with how we pass data to the `childComponent` + // we are only able to have a single child and we expect it to be the + // `WOAnnotatedText` component. const selectedMetrics = options.filter(option => option.types?.metric?.value); const optionHash = await hashObject(options); @@ -176,6 +179,18 @@ window.dash_clientside.wo_classroom_text_highlighter = { return output; }, + updateAlertWithError: function (error) { + if (Object.keys(error).length === 0) { + return ['', false, '']; + } + const text = 'Oops! Something went wrong ' + + "on our end. We've noted the " + + 'issue. Please try again later, or consider ' + + 'exploring a different dashboard for now. ' + + 'Thanks for your patience!'; + return [text, true, error]; + }, + addPreset: function (clicks, name, options, store) { if (!clicks) { return store; } const copy = { ...store }; 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 1a4a4629..80b62ee5 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 @@ -9,18 +9,23 @@ execute Javascript code client side. Clientside functions are preferred as it cuts down server and network resources. ''' -from dash import html, dcc, callback, clientside_callback, ClientsideFunction, Output, Input, State, ALL +from dash import html, dcc, clientside_callback, ClientsideFunction, Output, Input, State, ALL import dash_bootstrap_components as dbc +import dash_renderjson import lo_dash_react_components as lodrc +import learning_observer.settings import wo_classroom_text_highlighter.options import wo_classroom_text_highlighter.preset_component +DEBUG_FLAG = learning_observer.settings.RUN_MODE == learning_observer.settings.RUN_MODES.DEV + _prefix = 'wo-classroom-text-highlighter' _namespace = 'wo_classroom_text_highlighter' _websocket = f'{_prefix}-websocket' _output = f'{_prefix}-output' +# Option components _options_toggle = f'{_prefix}-options-toggle' _options_collapse = f'{_prefix}-options-collapse' # TODO abstract these into a more generic options component @@ -42,12 +47,24 @@ lodrc.WOSettings(id=_options_text_information, options=wo_classroom_text_highlighter.options.OPTIONS) ] +# Alert Component +_alert = f'{_prefix}-alert' +_alert_text = f'{_prefix}-alert-text' +_alert_error_dump = f'{_prefix}-alert-error-dump' + +alert_component = dbc.Alert([ + html.Div(id=_alert_text), + html.Div(dash_renderjson.DashRenderjson(id=_alert_error_dump), className='' if DEBUG_FLAG else 'd-none') +], id=_alert, color='danger', is_open=False) + + def layout(): ''' Function to define the page's layout. ''' page_layout = html.Div([ html.H1('Writing Observer Classroom Text Highlighter'), + alert_component, dbc.InputGroup([ dbc.InputGroupText(lodrc.LOConnectionAIO(aio_id=_websocket)), dbc.Button(html.I(className='fas fa-cog'), id=_options_toggle), @@ -118,6 +135,15 @@ def layout(): State({'type': 'WOStudentTextTile', 'index': ALL}, 'id'), ) +# Update alert with any errors that come through +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='updateAlertWithError'), + Output(_alert_text, 'children'), + Output(_alert, 'is_open'), + Output(_alert_error_dump, 'data'), + Input(lodrc.LOConnectionAIO.ids.error_store(_websocket), 'data') +) + # Save preset clientside_callback( ClientsideFunction(namespace=_namespace, function_name='addPreset'), diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py index c8ae1c2b..f67483a4 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py @@ -1,5 +1,5 @@ import copy -import writing_observer +import writing_observer.nlp_indicators parents = [] @@ -9,31 +9,54 @@ ] OPTIONS.append({'id': 'text-information', 'label': 'Text Information', 'parent': ''}) -# TODO create meaningful presets. Paul provided me with some ideas for -# presets to create. # TODO currently each preset is the full list of options with specific # values being set to true/including a color. We ought to just store # the true values and their respective colors. # Though if we keep the entire list in the preset, we can choose colors # for non-true values before they are selected. -# TODO remove this function. it is sample code for creating fake presets -def create_preset(options, is_even=True): - preset = copy.deepcopy(options) - for i, o in enumerate(preset): - # if i % 2 == 0 if is_even else 1: - if i % 2 == (0 if is_even else 1): - continue - if 'types' in o: - o['types']['highlight']['value'] = True - o['types']['highlight']['color'] = '#EEABAC' - return preset - -PRESET_EVEN = create_preset(OPTIONS, True) -PRESET_ODD = create_preset(OPTIONS, False) - -PRESETS = { - 'Clear': OPTIONS, - 'Even': PRESET_EVEN, - 'Odd': PRESET_ODD +# TODO these are used for creating the presets Paul provided +HIGHLIGHTING_COLORS = [ + "#FFD700", # Golden Yellow + "#87CEEB", # Sky Blue + "#98FB98", # Pale Green + "#FFB6C1", # Light Pink + "#F0E68C", # Khaki + "#FF69B4", # Hot Pink + "#AFEEEE", # Pale Turquoise + "#FFA07A", # Light Salmon + "#D8BFD8", # Thistle + "#ADD8E6", # Light Blue + "#FFDEAD", # Navajo White + "#FA8072", # Salmon + "#E6E6FA", # Lavender + "#FFE4E1", # Misty Rose + "#F5DEB3" # Wheat +] +PRESETS_TO_CREATE = { + 'Narrative': ['direct_speech_verbs', 'indirect_speech', 'character_trait_words', 'in_past_tense', 'social_awareness'], + 'Argumentative': ['statements_of_opinion', 'statements_of_fact', 'information_sources', 'attributions', 'citations'], + 'Parts of Speech': ['adjectives', 'adverbs', 'nouns', 'proper_nouns', 'verbs', 'prepositions', 'coordinating_conjunction', 'subordinating_conjunction', 'auxiliary_verb', 'pronoun'], + 'Sentence Structure': ['simple_sentences', 'simple_with_complex_predicates', 'simple_with_compound_predicates', 'simple_with_compound_complex_predicates', 'compound_sentences', 'complex_sentences', 'compound_complex_sentences'], + 'Organization': ['main_idea_sentences', 'supporting_idea_sentences', 'supporting_detail_sentences'], + 'Tone': ['positive_tone', 'negative_tone', 'emotion_words', 'opinion_words'], + 'Vocabulary': ['academic_language', 'informal_language', 'latinate_words', 'polysyllabic_words', 'low_frequency_words'] } + +PRESETS = {'Clear': OPTIONS} + + +def add_preset_to_presets(key, value): + color_index = 0 + preset = copy.deepcopy(OPTIONS) + for option in preset: + if option['id'] in value: + option['types']['highlight']['value'] = True + option['types']['highlight']['color'] = HIGHLIGHTING_COLORS[color_index] + color_index += 1 + + PRESETS[key] = preset + + +for k, v in PRESETS_TO_CREATE.items(): + add_preset_to_presets(k, v) diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py index 73082288..fbebdbef 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py @@ -16,13 +16,16 @@ def create_layout(): + # TODO we hide `class_name='d-none'` the ability to add presets for now since we + # do not have a long term storage solution for them. We could store them locally + # in the browser; however, this would just be a short-term solution. add_preset = dbc.InputGroup([ dbc.Input(id=_add_input, placeholder='Preset name', type='text', value=''), dbc.Button([ html.I(className='fas fa-plus me-1'), 'Preset' ], id=_add_button) - ]) + ], class_name='d-none') return html.Div([ add_preset, html.Div(id=_tray), @@ -55,6 +58,8 @@ def create_layout(): def create_tray_item(preset): + if preset == 'Clear': + return dbc.Button(preset, id={'type': _set_item, 'index': preset}, color='danger') contents = dbc.ButtonGroup([ dbc.Button(preset, id={'type': _set_item, 'index': preset}), dbc.Button(dcc.ConfirmDialogProvider( From 82e730b2dbdfa57e86be01ae686b904eb1e948e8 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 23 Sep 2024 11:19:34 -0400 Subject: [PATCH 17/41] more comments --- .../communication_protocol/debugger.py | 1 + .../communication_protocol/executor.py | 12 +++-- .../communication_protocol/explorer.py | 4 ++ .../communication_protocol/test_cases.py | 2 + .../learning_observer/dashboard.py | 54 ++----------------- .../LOConnectionAIO.py | 9 +++- .../LOConnectionStatusAIO.py | 12 ++++- .../ProfileSidebarAIO.py | 12 +++-- .../dash_dashboard.py | 26 ++++----- .../wo_classroom_text_highlighter/options.py | 10 +++- .../preset_component.py | 11 ++-- 11 files changed, 69 insertions(+), 84 deletions(-) diff --git a/learning_observer/learning_observer/communication_protocol/debugger.py b/learning_observer/learning_observer/communication_protocol/debugger.py index fb8770cd..e30f7bc5 100644 --- a/learning_observer/learning_observer/communication_protocol/debugger.py +++ b/learning_observer/learning_observer/communication_protocol/debugger.py @@ -7,6 +7,7 @@ * This isn't really a debugger. Perhaps this should be called interactive mode? Or developer mode? Or similar? * Ideally, this should be moved to the Jupyter notebook +* Make work with the new async generator pipeline ''' from dash import html, callback, Output, Input, State diff --git a/learning_observer/learning_observer/communication_protocol/executor.py b/learning_observer/learning_observer/communication_protocol/executor.py index 1795357b..3b0fd0ea 100644 --- a/learning_observer/learning_observer/communication_protocol/executor.py +++ b/learning_observer/learning_observer/communication_protocol/executor.py @@ -21,9 +21,6 @@ from learning_observer.util import get_nested_dict_value, clean_json, ensure_async_generator, async_zip from learning_observer.communication_protocol.exception import DAGExecutionException -# This function isn't used directly by the code but instead used by doctests -from learning_observer.util import async_generator_to_list - dispatch = learning_observer.communication_protocol.query.dispatch @@ -630,7 +627,6 @@ def strip_provenance(variable): >>> strip_provenance({'nested_dict': {'provenance': 123, 'other': 123}}) {'nested_dict': {'provenance': 123, 'other': 123}} ''' - # TODO we might need to add a generator method to this if isinstance(variable, dict): return {key: value for key, value in variable.items() if key != 'provenance'} elif isinstance(variable, list): @@ -735,6 +731,11 @@ async def visit(node_name): if learning_observer.settings.RUN_MODE == learning_observer.settings.RUN_MODES.DEV: return {e: _clean_json_via_generator(await visit(e)) for e in target_nodes} + # HACK currently `dashboard.py` relies on the provenance to tell users which + # items need updating, such as John Doe's history essay. This ought to be + # handled by the communication protocol during execution. Once that occurs, + # we can go back to stripping the provenance out. + return {e: _clean_json_via_generator(await visit(e)) for e in target_nodes} # TODO test this code to make sure it works with async generators # Remove execution history if in deployed settings, with data flowing back to teacher dashboards return {e: _clean_json_via_generator(strip_provenance(await visit(e))) for e in target_nodes} @@ -742,4 +743,7 @@ async def visit(node_name): if __name__ == "__main__": import doctest + # This function is used by doctests + from learning_observer.util import async_generator_to_list + doctest.testmod(optionflags=doctest.ELLIPSIS) diff --git a/learning_observer/learning_observer/communication_protocol/explorer.py b/learning_observer/learning_observer/communication_protocol/explorer.py index ebe45db3..4b4775d8 100644 --- a/learning_observer/learning_observer/communication_protocol/explorer.py +++ b/learning_observer/learning_observer/communication_protocol/explorer.py @@ -4,6 +4,10 @@ * The available queries * The DAG execution graphs associated with those * Parameters to said queries + +TODO at some point during development this broke and no longer +displays the DAGs correctly. This was a hacked together prototype +so efforts to fix have been pushed to wayside. ''' from dash import html, dcc, callback, Output, Input, State diff --git a/learning_observer/learning_observer/communication_protocol/test_cases.py b/learning_observer/learning_observer/communication_protocol/test_cases.py index a00b0c05..63822cc5 100644 --- a/learning_observer/learning_observer/communication_protocol/test_cases.py +++ b/learning_observer/learning_observer/communication_protocol/test_cases.py @@ -151,6 +151,8 @@ def dummy_sync_map(value, example): 'expected': lambda x: isinstance(x, list) and 'error' in x[0] }, # TODO this test case fails and was failing before switching to an async generator + # Should we be erroring if the `left_on` path doesn't exist or just yielding `left` + # as is? Currently, `executor.py` yields `left`. 'join_key_error': { 'returns': 'join_key_error', 'parameters': [], diff --git a/learning_observer/learning_observer/dashboard.py b/learning_observer/learning_observer/dashboard.py index 5de8ace0..a21cc418 100644 --- a/learning_observer/learning_observer/dashboard.py +++ b/learning_observer/learning_observer/dashboard.py @@ -1,5 +1,10 @@ ''' This generates dashboards from student data. + +TODO much of this file is no longer being used and the +unused code ought to be removed. We have iterated on how +we do this a few times and have landed in a much better +place than we started. ''' import asyncio @@ -472,58 +477,9 @@ async def dispatch_defined_execution_dag(dag): return query -# TODO both of these require us to pass in a list of functions -# this was the old way of doing this, we ought to change this DAG_DISPATCH = {dict: dispatch_defined_execution_dag, str: dispatch_named_execution_dag} -async def execute_queries(client_data, request): - '''TODO remove this method as it is no longer used. - ''' - execution_dags = learning_observer.module_loader.execution_dags() - funcs = [] - # client_data = { - # 'output_name': { - # 'execution_dag': 'writing_obssdfsderver', - # 'target_exports': ['docs_with_roster'], - # 'kwargs': {'course_id': 12345} - # }, - # } - for query_name, client_query in client_data.items(): - dag = client_query.get('execution_dag', query_name) - - if type(dag) not in DAG_DISPATCH: - debug_log(await dag_unsupported_type(type(dag))) - funcs.append(dag_unsupported_type(type(dag))) - continue - - query = await DAG_DISPATCH[type(dag)](dag, funcs) - if query is None: - continue - - # NOTE dependent dags only work for on a single level dependency - # TODO allow multiple layers of dependency among dags - dependent_dags = extract_namespaced_dags(query['execution_dag']) - missing_dags = dependent_dags - execution_dags.keys() - if missing_dags: - debug_log(await dag_not_found(missing_dags)) - funcs.append(dag_not_found(missing_dags)) - continue - for dep in dependent_dags: - dep_dag = copy.deepcopy(execution_dags[dep]['execution_dag']) - prefixed_dag = fully_qualify_names_with_default_namespace(dep_dag, dep) - query['execution_dag'] = {**query['execution_dag'], **{f'{dep}.{k}': v for k, v in prefixed_dag.items()}} - - target_exports = client_query.get('target_exports', []) - query_func = learning_observer.communication_protocol.integration.prepare_dag_execution(query, target_exports) - client_parameters = client_query.get('kwargs', {}).copy() - runtime = learning_observer.runtime.Runtime(request) - client_parameters['runtime'] = runtime - query_func = query_func(**client_parameters) - funcs.append(query_func) - return await asyncio.gather(*funcs, return_exceptions=False) - - async def _handle_dependent_dags(query): ''' Handles dependent DAGs and ensures all dependencies are present. diff --git a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py index 0cd79bd5..d920df4a 100644 --- a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py +++ b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py @@ -1,5 +1,10 @@ -from dash import html, dcc, callback, clientside_callback, ClientsideFunction, Output, Input, State, MATCH, ALL, Patch, ctx -import dash_bootstrap_components as dbc +''' +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 uuid from .LOConnection import LOConnection diff --git a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionStatusAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionStatusAIO.py index b50f757a..aeb8c4ce 100644 --- a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionStatusAIO.py +++ b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionStatusAIO.py @@ -1,5 +1,13 @@ -from dash import html, dcc, callback, clientside_callback, ClientsideFunction, Output, Input, State, MATCH, ALL, Patch, ctx -import dash_bootstrap_components as dbc +''' +This file creates an All-In-One component for the Learning +Observer server connection. This handles updating data from the +server and showing the time since it was last updated. + +TODO this file is still being used by the cookiecutter module. +This was replaced by LOConnectionAIO to utilize the new method +of updating data. +''' +from dash import html, dcc, clientside_callback, Output, Input, MATCH import uuid from .LOConnection import LOConnection diff --git a/modules/lo_dash_react_components/lo_dash_react_components/ProfileSidebarAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/ProfileSidebarAIO.py index 3039364a..0205429c 100644 --- a/modules/lo_dash_react_components/lo_dash_react_components/ProfileSidebarAIO.py +++ b/modules/lo_dash_react_components/lo_dash_react_components/ProfileSidebarAIO.py @@ -1,4 +1,10 @@ -from dash import html, clientside_callback, ClientsideFunction, Output, Input, State, MATCH +''' +This file creates an All-In-One component for a sidebar +component that allows users to navigate throughout the platform. +The sidebar shows a Home and Logout button as well as a list +of available dashboards. +''' +from dash import html, clientside_callback, Output, Input, State, MATCH import dash_bootstrap_components as dbc import uuid @@ -37,8 +43,8 @@ def __init__(self, aio_id=None, class_name='', color='primary'): ] super().__init__(component) + # Toggle sidebar clientside_callback( - # ClientsideFunction(namespace='lo_dash_react_components', function_name='toggle_sidebar'), '''function (clicks, isOpen) { if (clicks > 0) { return !isOpen; } return isOpen; @@ -49,8 +55,8 @@ def __init__(self, aio_id=None, class_name='', color='primary'): State(ids.offcanvas(MATCH), 'is_open') ) + # Update available dashboard items clientside_callback( - # ClientsideFunction(namespace='lo_dash_react_components', function_name='populate_module_list'), # TODO include the course_id in these - will need to parse it out of the current string '''async function (empty) { const response = await fetch(`${window.location.protocol}//${window.location.hostname}:${window.location.port}/webapi/course_dashboards`); 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 80b62ee5..6c85eae9 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 @@ -1,13 +1,6 @@ ''' -This file will detail how to build a dashboard using -the Dash framework. - -If you are unfamiliar with Dash, it compiles python code -to react and serves it via a Flask server. You can register -callbacks to run when specific states change. Normal callbacks -execute Python code server side, but Clientside callbacks -execute Javascript code client side. Clientside functions are -preferred as it cuts down server and network resources. +This file creates the layout and defines any callbacks +for the classroom highlight dashboard. ''' from dash import html, dcc, clientside_callback, ClientsideFunction, Output, Input, State, ALL import dash_bootstrap_components as dbc @@ -75,6 +68,7 @@ def layout(): ]) return page_layout + # Send the initial state based on the url hash to LO. # If this is not included, nothing will be returned from # the communication protocol. @@ -88,8 +82,6 @@ def layout(): # Build the UI based on what we've received from the # communicaton protocol -# This clientside callback and the serverside callback below are -# the same clientside_callback( ClientsideFunction(namespace=_namespace, function_name='populateOutput'), Output(_output, 'children'), @@ -117,7 +109,7 @@ def layout(): State({'type': 'WOStudentTextTile', 'index': ALL}, 'id'), ) -# Handle showing/hiding the student tile header +# Handle showing or hiding the student tile header clientside_callback( ClientsideFunction(namespace=_namespace, function_name='showHideHeader'), Output({'type': 'WOStudentTextTile', 'index': ALL}, 'showHeader'), @@ -126,8 +118,8 @@ def layout(): ) # When options change, update the current option hash for all students. -# when the option hash is different from the students internal option hash -# a loading class is applied +# When the option hash is different from the students internal option hash +# a loading class is applied to each student tile. clientside_callback( ClientsideFunction(namespace=_namespace, function_name='updateCurrentOptionHash'), Output({'type': 'WOStudentTextTile', 'index': ALL}, 'currentOptionHash'), @@ -135,7 +127,7 @@ def layout(): State({'type': 'WOStudentTextTile', 'index': ALL}, 'id'), ) -# Update alert with any errors that come through +# Update the alert component with any errors that come through clientside_callback( ClientsideFunction(namespace=_namespace, function_name='updateAlertWithError'), Output(_alert_text, 'children'), @@ -144,7 +136,7 @@ def layout(): Input(lodrc.LOConnectionAIO.ids.error_store(_websocket), 'data') ) -# Save preset +# Save options as preset clientside_callback( ClientsideFunction(namespace=_namespace, function_name='addPreset'), Output(wo_classroom_text_highlighter.preset_component._store, 'data'), @@ -154,7 +146,7 @@ def layout(): State(wo_classroom_text_highlighter.preset_component._store, 'data') ) -# apply preset +# Apply clicked preset clientside_callback( ClientsideFunction(namespace=_namespace, function_name='applyPreset'), Output(_options_text_information, 'options'), diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py index f67483a4..00f668eb 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py @@ -15,7 +15,7 @@ # Though if we keep the entire list in the preset, we can choose colors # for non-true values before they are selected. -# TODO these are used for creating the presets Paul provided +# Set of colors to use for highlighting with presets HIGHLIGHTING_COLORS = [ "#FFD700", # Golden Yellow "#87CEEB", # Sky Blue @@ -33,6 +33,8 @@ "#FFE4E1", # Misty Rose "#F5DEB3" # Wheat ] + +# TODO these are used for creating the common presets PRESETS_TO_CREATE = { 'Narrative': ['direct_speech_verbs', 'indirect_speech', 'character_trait_words', 'in_past_tense', 'social_awareness'], 'Argumentative': ['statements_of_opinion', 'statements_of_fact', 'information_sources', 'attributions', 'citations'], @@ -47,6 +49,11 @@ def add_preset_to_presets(key, value): + '''This function creates a copy of the options and + sets each of the items in `value` to True along with + a highlighted color. This is for creating presets + from the `PRESETS_TO_CREATE` object. + ''' color_index = 0 preset = copy.deepcopy(OPTIONS) for option in preset: @@ -58,5 +65,6 @@ def add_preset_to_presets(key, value): PRESETS[key] = preset +# Add each preset to PRESETS for k, v in PRESETS_TO_CREATE.items(): add_preset_to_presets(k, v) diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py index fbebdbef..e59fe17b 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py @@ -1,4 +1,5 @@ -'''This creates the input and clickable preset badges. +'''This creates the input and clickable badges for different +presets the user wants displayed. TODO create a react component that does this ''' from dash import html, dcc, clientside_callback, callback, Output, Input, State, ALL, exceptions, Patch, ctx @@ -16,20 +17,18 @@ def create_layout(): - # TODO we hide `class_name='d-none'` the ability to add presets for now since we - # do not have a long term storage solution for them. We could store them locally - # in the browser; however, this would just be a short-term solution. add_preset = dbc.InputGroup([ dbc.Input(id=_add_input, placeholder='Preset name', type='text', value=''), dbc.Button([ html.I(className='fas fa-plus me-1'), 'Preset' ], id=_add_button) - ], class_name='d-none') + ], class_name='') return html.Div([ add_preset, html.Div(id=_tray), - dcc.Store(id=_store, data=wo_classroom_text_highlighter.options.PRESETS) + # TODO we ought to store the presets on the server instead of browser storage + dcc.Store(id=_store, data=wo_classroom_text_highlighter.options.PRESETS, storage_type='local') ]) From 9cb20a10ee672d30a357b836f8652a6ce800409d Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 25 Sep 2024 09:34:31 -0400 Subject: [PATCH 18/41] addressed comments from PR #167 --- .../learning_observer/communication_protocol/util.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/learning_observer/learning_observer/communication_protocol/util.py b/learning_observer/learning_observer/communication_protocol/util.py index f14f70ee..fc3f8f8c 100644 --- a/learning_observer/learning_observer/communication_protocol/util.py +++ b/learning_observer/learning_observer/communication_protocol/util.py @@ -13,7 +13,10 @@ def _flatten_helper(top_level, current_level, prefix=''): """ - Flatten the dictionary. + This is a helper function for taking a dictionary of nested + calls to the communication protocol, such as `select(keys(...))`, + and converting them to a flat dictionary. E.g. one item for + the `select` call and one item for the `key` call. :param top_level: The top level dictionary :type top_level: dict @@ -42,7 +45,11 @@ def _flatten_helper(top_level, current_level, prefix=''): def flatten(endpoint): """ - Flatten the endpoint. + The DAG is provided as a complex dictinoary structure. This function + flattens the dictionary to a single layer. + A query with a node `select(keys(...))` would start with a single + dictionary item and be translated to one for the `select` and another + for the `keys` portion. :param endpoint: The endpoint dictionary :type endpoint: dict From dec4e3eb614073835ef5688b9455f8a8f0df680d Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 7 Oct 2024 15:10:17 -0400 Subject: [PATCH 19/41] fixed a handful of errors when trying to deploy --- .../wo_bulk_essay_analysis/assets/scripts.js | 9 +++++- .../dashboard/layout.py | 2 +- .../assets/scripts.js | 28 ++++++++++++++++--- .../preset_component.py | 4 +-- 4 files changed, 35 insertions(+), 8 deletions(-) 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 aa3f4819..ddc5d4e1 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 @@ -332,5 +332,12 @@ window.dash_clientside.bulk_essay_feedback = { 'exploring a different dashboard for now. ' + 'Thanks for your patience!'; return [text, true, error]; - } + }, + + disable_doc_src_datetime: function (value) { + if (value === 'ts') { + return [false, false]; + } + return [true, true]; + }, }; 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 4bf2b01e..c0f9c1e8 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 @@ -155,7 +155,7 @@ def layout(): # disbale document date/time options clientside_callback( - ClientsideFunction(namespace='clientside', function_name='disable_doc_src_datetime'), + 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') 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 e10a943f..1b6f09e0 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 @@ -28,10 +28,30 @@ async function hashObject (obj) { const encoder = new TextEncoder(); const data = encoder.encode(jsonString); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, '0')).join(''); - return hashHex; + // Check if crypto.subtle is available + if (crypto && crypto.subtle) { + try { + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, '0')).join(''); + return hashHex; + } catch (error) { + console.warn('crypto.subtle.digest failed; falling back to simple hash.'); + } + } + + // Fallback to the simple hash if crypto.subtle is unavailable + return simpleHash(jsonString); +} + +function simpleHash (str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash |= 0; // Convert to 32-bit integer + } + return hash.toString(16); } // TODO some of this will move to the communication protocol, but for now diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py index e59fe17b..0ea2c8c9 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py @@ -41,7 +41,7 @@ def create_layout(): }''', Output(_add_button, 'disabled'), Input(_add_input, 'value'), - Input(_store, 'data') + State(_store, 'data') ) # clear input on add @@ -76,7 +76,7 @@ def create_tray_item(preset): State(_store, 'data') ) def create_tray_items_from_store(ts, data): - if ts is None: + if ts is None and data is None: raise exceptions.PreventUpdate return [html.Span(create_tray_item(preset), className='me-1') for preset in data.keys()] From 40f1f1a501874c0e6c0da4ed08ca1860769b3945 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 15 Oct 2024 17:42:09 -0400 Subject: [PATCH 20/41] lots of dashboard feedback --- awe_requirements.txt | 2 + .../learning_observer/dashboard.py | 14 ++- .../LOConnectionAIO.py | 21 +++- .../lib/components/WOStudentTextTile.react.js | 13 ++- .../wo_bulk_essay_analysis/assets/scripts.js | 109 +++++++++++++----- .../dashboard/layout.py | 74 ++++++++---- .../assets/general.css | 26 ++++- .../assets/scripts.js | 11 +- .../dash_dashboard.py | 20 ++-- .../wo_classroom_text_highlighter/options.py | 6 +- .../preset_component.py | 16 +-- .../writing_observer/awe_nlp.py | 7 +- 12 files changed, 229 insertions(+), 90 deletions(-) diff --git a/awe_requirements.txt b/awe_requirements.txt index 2fde4a55..cebc4572 100644 --- a/awe_requirements.txt +++ b/awe_requirements.txt @@ -1,3 +1,5 @@ +spacy==3.4.4 +pydantic==1.10 AWE_SpellCorrect @ git+https://github.com/ETS-Next-Gen/AWE_SpellCorrect.git AWE_Components @ git+https://github.com/ETS-Next-Gen/AWE_Components.git AWE_Lexica @ git+https://github.com/ETS-Next-Gen/AWE_Lexica.git diff --git a/learning_observer/learning_observer/dashboard.py b/learning_observer/learning_observer/dashboard.py index a21cc418..8d06bfd3 100644 --- a/learning_observer/learning_observer/dashboard.py +++ b/learning_observer/learning_observer/dashboard.py @@ -11,6 +11,7 @@ import copy import inspect import json +import aiohttp.client_exceptions import jsonschema import numbers import pmss @@ -606,6 +607,8 @@ async def _batch_send(): batch.clear() except aiohttp.web_ws.WebSocketError: break + except aiohttp.client_exceptions.ClientConnectionResetError: + break if ws.closed: break # TODO this ought to be pulled from somewhere @@ -619,10 +622,17 @@ async def _execute_dag(dag_query, target, params): if params != client_query: # the params are different and we should stop this generator return + + # Create DAG generator and drive generator = await _create_dag_generator(dag_query, target, request) await _drive_generator(generator, dag_query['kwargs']) - # TODO pull this from kwargs if available - await asyncio.sleep(10) + + # Handle rescheduling the execution of the DAG for fresh data + dag_delay = dag_query['kwargs'].get('rerun_dag_delay', 10) + if dag_delay < 0: + # if dag_delay is negative, we skip repeated execution + return + await asyncio.sleep(dag_delay) await _execute_dag(dag_query, target, params) async def _drive_generator(generator, dag_kwargs): diff --git a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py index d920df4a..70de0ef4 100644 --- a/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py +++ b/modules/lo_dash_react_components/lo_dash_react_components/LOConnectionAIO.py @@ -139,12 +139,21 @@ def __init__(self, aio_id=None, data_scope=None): delete errorStore[message.path]; } const finalKey = pathKeys[pathKeys.length - 1]; - if (message.op === 'update' && !('error' in message.value)) { - // Shallow merge using spread syntax - current[finalKey] = { - ...current[finalKey], // Existing data - ...message.value // New data (overwrites where necessary) - }; + if (message.op === 'update') { + if (current[finalKey] === undefined) { + current[finalKey] = {}; + } + if ('error' in message.value) { + current[finalKey]['error'] = message.value; + current[finalKey]['option_hash'] = message.value['option_hash']; + } else { + delete current[finalKey]['error']; + // Shallow merge using spread syntax + current[finalKey] = { + ...current[finalKey], // Existing data + ...message.value // New data (overwrites where necessary) + }; + } } }); return [currentData, errorStore]; // Return updated data diff --git a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js index 4e396523..3ba657c9 100644 --- a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js +++ b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js @@ -28,9 +28,14 @@ export default class WOStudentTextTile extends Component { childComponent.props._dashprivate_layout.props = {...studentInfo.documents[selectedDocument]}; const documentIsSelected = selectedDocument && studentInfo.documents[selectedDocument]; - let bodyClassName = documentIsSelected && currentOptionHash !== studentInfo.documents[selectedDocument].optionHash ? 'loading' : ''; + const isLoading = documentIsSelected && currentOptionHash !== studentInfo.documents[selectedDocument].optionHash; + let bodyClassName = isLoading ? 'loading' : ''; bodyClassName = `${bodyClassName} overflow-auto`; + const loadedItem = documentIsSelected + ? <>{childComponent} + :
Document information not found.
; + // TODO the chunk of commented code allows for linking directly to the selected document // and allows the user to select which document they wish to see at a given moment. // Neither of these features are currently available due to limitations with the communication @@ -61,9 +66,9 @@ export default class WOStudentTextTile extends Component { { - documentIsSelected - ? <>{childComponent} - :
Document information not found.
+ isLoading + ?
+ : loadedItem } 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 ddc5d4e1..43d438b3 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,10 +8,11 @@ if (!window.dash_clientside) { pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/3rd_party/pdf.worker.min.js'; -const createStudentCard = function (s, prompt) { +const createStudentCard = async function (s, prompt) { // 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', @@ -23,6 +24,13 @@ const createStudentCard = function (s, prompt) { 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', @@ -36,21 +44,26 @@ const createStudentCard = function (s, prompt) { namespace: 'dash_html_components', type: 'Div', props: { - children: { + 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 = prompt === student.prompt ? feedbackMessage : feedbackLoading; + 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: feedback, id: 'feedback-text', width: '40%' }], + panels: [{ children: feedbackOrError, id: 'feedback-text', width: '40%' }], shown: ['feedback-text'], className: 'overflow-auto p-1' } @@ -69,11 +82,19 @@ const createStudentCard = function (s, prompt) { props: { children: card, id: student.user_id, - width: 4 + xs: 12, + lg: 6, + xxl: 4 } }; }; +const checkForResponse = function (s, promptHash) { + const document = Object.keys(s.documents)[0]; + const student = s.documents[document]; + return promptHash === student.option_hash; +}; + const charactersAfterChar = function (str, char) { const commaIndex = str.indexOf(char); if (commaIndex === -1) { @@ -113,25 +134,31 @@ window.dash_clientside.bulk_essay_feedback = { */ send_to_loconnection: async function (state, hash, clicks, docSrc, docDate, docTime, query, systemPrompt, tags) { if (state === undefined) { - return window.dash_clientside.no_update + return window.dash_clientside.no_update; } if (state.readyState === 1) { - 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 } + 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; } decoded.gpt_prompt = ''; decoded.message_id = ''; decoded.doc_source = docSrc; decoded.requested_timestamp = new Date(`${docDate}T${docTime}`).getTime().toString(); + // TODO what is a reasonable time to wait inbetween subsequent calls for + // the same arguments + decoded.rerun_dag_delay = 120; - const trig = window.dash_clientside.callback_context.triggered[0] + const trig = window.dash_clientside.callback_context.triggered[0]; if (trig.prop_id.includes('bulk-essay-analysis-submit-btn')) { decoded.gpt_prompt = query; decoded.system_prompt = systemPrompt; decoded.tags = tags; } + const optionsHash = await hashObject({ prompt: decoded.gpt_prompt }); + decoded.option_hash = optionsHash; + const message = { wo: { execution_dag: 'wo_bulk_essay_analysis', @@ -182,19 +209,19 @@ window.dash_clientside.bulk_essay_feedback = { namespace: 'dash_html_components', type: 'Li', props: { children: x } - } - }) + }; + }); return { namespace: 'dash_html_components', type: 'Ol', props: { children: items } - } + }; }, /** * update student cards based on new data in storage */ - updateStudentGridOutput: function (wsStorageData, history) { + updateStudentGridOutput: async function (wsStorageData, history) { if (!wsStorageData) { return 'No students'; } @@ -202,7 +229,7 @@ window.dash_clientside.bulk_essay_feedback = { let output = []; for (const student in wsStorageData) { - output = output.concat(createStudentCard(wsStorageData[student], currPrompt)); + output = output.concat(await createStudentCard(wsStorageData[student], currPrompt)); } return output; }, @@ -217,14 +244,14 @@ window.dash_clientside.bulk_essay_feedback = { */ open_and_populate_attachment_panel: async function (contents, filename, timestamp, shown) { if (filename === undefined) { - return ['', '', shown] + return ['', '', shown]; } let data = '' if (filename.endsWith('.pdf')) { - data = await extractPDF(contents) + data = await extractPDF(contents); } // TODO add support for docx-like files - return [data, filename.slice(0, filename.lastIndexOf('.')), shown.concat('attachment')] + return [data, filename.slice(0, filename.lastIndexOf('.')), shown.concat('attachment')]; }, /** @@ -248,19 +275,22 @@ window.dash_clientside.bulk_essay_feedback = { * - submit query button disbaled status * - helper text for why we disabled the submit query button */ - disabled_query_submit: function (query, store) { + disableQuerySubmitButton: function (query, loading, store) { if (query.length === 0) { - return [true, 'Please create a request before submitting.'] + return [true, 'Please create a request before submitting.']; } - const tags = Object.keys(store) - const queryTags = query.match(/[^{}]+(?=})/g) || [] - const diffs = queryTags.filter(x => !tags.includes(x)) + if (loading) { + return [true, 'Please wait until current query has finished before resubmitting.']; + } + const tags = Object.keys(store); + const queryTags = query.match(/[^{}]+(?=})/g) || []; + const diffs = queryTags.filter(x => !tags.includes(x)); if (diffs.length > 0) { - return [true, `Unable to find [${diffs.join(',')}] within the tags. Please check that the spelling is correct or remove the extra tags.`] + return [true, `Unable to find [${diffs.join(',')}] within the tags. Please check that the spelling is correct or remove the extra tags.`]; } else if (!queryTags.includes('student_text')) { - return [true, 'Submission requires the inclusion of {student_text} to run the request over the student essays.'] + return [true, 'Submission requires the inclusion of {student_text} to run the request over the student essays.']; } - return [false, ''] + return [false, '']; }, /** @@ -283,7 +313,7 @@ window.dash_clientside.bulk_essay_feedback = { * populate word bank of tags */ update_tag_buttons: function (tagStore) { - const tagLabels = Object.keys(tagStore) + const tagLabels = Object.keys(tagStore); const tags = tagLabels.map((val) => { const button = { namespace: 'dash_bootstrap_components', @@ -295,10 +325,10 @@ window.dash_clientside.bulk_essay_feedback = { size: 'sm', color: 'secondary' } - } - return button - }) - return tags + }; + return button; + }); + return tags; }, /** @@ -340,4 +370,19 @@ window.dash_clientside.bulk_essay_feedback = { } return [true, true]; }, + + updateLoadingInformation: async function (wsStorageData, history) { + const noLoading = [false, 0, '']; + if (!wsStorageData) { + return noLoading; + } + 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; + if (totalStudents === returnedResponses) { return noLoading; } + const loadingProgress = returnedResponses / totalStudents + 0.1; + const outputText = `Fetching responses from server. This may take a few minutes. (${returnedResponses}/${totalStudents} received)`; + return [true, loadingProgress, outputText]; + } }; 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 c0f9c1e8..f0d76e73 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 @@ -28,11 +28,12 @@ _advanced_collapse = f'{prefix}-advanced-collapse' system_input = f'{prefix}-system-prompt-input' -# document source +# 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' +# attachment upload DOM ids attachment_upload = f'{prefix}-attachment-upload' attachment_label = f'{prefix}-attachment-label' attachment_extracted_text = f'{prefix}-attachment-extracted-text' @@ -40,14 +41,23 @@ 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' +# prompt history DOM ids history_body = f'{prefix}-history-body' history_store = f'{prefix}-history-store' favorite_store = f'{prefix}-favorite-store' +# loading message/bar DOM ids +_loading_prefix = f'{prefix}-loading' +_loading_collapse = f'{_loading_prefix}-collapse' +_loading_progress = f'{_loading_prefix}-progress-bar' +_loading_information = f'{_loading_prefix}-information-text' + submit = f'{prefix}-submit-btn' submit_warning_message = f'{prefix}-submit-warning-msg' grid = f'{prefix}-essay-grid' @@ -65,8 +75,8 @@ def layout(): ''' # advanced menu for system prompt advanced = [ - dbc.InputGroup([ - dbc.InputGroupText('System prompt:'), + html.Div([ + dbc.Label('System prompt'), dbc.Textarea(id=system_input, value=system_prompt) ]), html.Div([ @@ -84,7 +94,7 @@ def layout(): # history panel history_favorite_panel = dbc.Card([ - dbc.CardHeader('Prompts'), + dbc.CardHeader('Prompt History'), dbc.CardBody([], id=history_body), dcc.Store(id=history_store, data=[]) ], class_name='h-100') @@ -107,48 +117,61 @@ def layout(): # query creator panel input_panel = dbc.Card([ - dbc.InputGroup([ - dbc.InputGroupText([], id=tags, class_name='flex-grow-1', style={'gap': '5px'}), + dbc.CardHeader('Prompt Input'), + 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'}), + html.Div([ + html.Span([ + 'Placeholders', + html.I(className='fas fa-circle-question ms-1', id=placeholder_tooltip) + ], className='me-1'), + 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.', + target=placeholder_tooltip + ), dcc.Store(id=tag_store, data={'student_text': ''}), - dbc.Button(dcc.Upload([html.I(className='fas fa-plus me-1'), 'Upload'], accept='.pdf', id=attachment_upload)) ]), - dbc.CardBody(dbc.Textarea(id=query_input, value=starting_prompt, class_name='h-100', style={'minHeight': '150px'})), dbc.CardFooter([ html.Small(id=submit_warning_message, className='text-danger'), dbc.Button('Submit', color='primary', id=submit, n_clicks=0, class_name='float-end') ]) - ], class_name='h-100') + ]) alert_component = dbc.Alert([ html.Div(id=alert_text), html.Div(DashRenderjson(id=alert_error_dump), className='' if DEBUG_FLAG else 'd-none') ], id=alert, color='danger', is_open=False) + loading_component = dbc.Collapse([ + html.Div(id=_loading_information), + dbc.Progress(id=_loading_progress, animated=True, striped=True, max=1.1) + ], id=_loading_collapse, is_open=False, class_name='mb-1 sticky-top bg-light') + # overall container cont = dbc.Container([ - html.H2('Prototype: Work in Progress'), - html.P( - 'This dashboard is a prototype allowing teachers to run ChatGPT over a set of essays. ' - 'The dashboard is subject to change based on ongoing feedback from teachers.' - ), html.H2('AskGPT'), dbc.InputGroup([ dbc.InputGroupText(lodrc.LOConnectionAIO(aio_id=_websocket)), - dbc.Button(html.I(className='fas fa-cog'), id=_advanced_toggle), + dbc.Button([html.I(className='fas fa-cog me-1'), 'Advanced'], id=_advanced_toggle), lodrc.ProfileSidebarAIO(class_name='rounded-0 rounded-end', color='secondary'), - ]), - dbc.Collapse(advanced, id=_advanced_collapse), + ], class_name='mb-1'), + dbc.Collapse(advanced, id=_advanced_collapse, class_name='mb-1'), lodrc.LOPanelLayout( input_panel, panels=[ - {'children': history_favorite_panel, 'width': '20%', 'id': 'history-favorite', 'side': 'left'}, + {'children': history_favorite_panel, 'width': '30%', 'id': 'history-favorite'}, {'children': attachment_panel, 'width': '40%', 'id': 'attachment'}, ], shown=['history-favorite'], id=panel_layout ), alert_component, - dbc.Row(id=grid, class_name='g-2 mt-2'), + html.H3('Student Text', className='mt-1'), + loading_component, + dbc.Row(id=grid, class_name='g-4'), ], fluid=True) return dcc.Loading(cont) @@ -187,10 +210,11 @@ def layout(): # enable/disabled submit based on query # makes sure there is a query and the tags are properly formatted clientside_callback( - ClientsideFunction(namespace='bulk_essay_feedback', function_name='disabled_query_submit'), + ClientsideFunction(namespace='bulk_essay_feedback', function_name='disableQuerySubmitButton'), Output(submit, 'disabled'), Output(submit_warning_message, 'children'), Input(query_input, 'value'), + Input(_loading_collapse, 'is_open'), State(tag_store, 'data') ) @@ -278,3 +302,13 @@ def layout(): State(panel_layout, 'shown'), prevent_initial_call=True ) + +# update loading information +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='updateLoadingInformation'), + Output(_loading_collapse, 'is_open'), + Output(_loading_progress, 'value'), + Output(_loading_information, 'children'), + Input(lodrc.LOConnectionAIO.ids.ws_store(_websocket), 'data'), + Input(history_store, 'data') +) diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/general.css b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/general.css index cccd9ae3..9138b723 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/general.css +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/assets/general.css @@ -1,4 +1,24 @@ -.WOStudentTextTile .loading { - background-color: #e0e0e0; - color: transparent; +.loading-circle { + border: 4px solid #e0e0e0; + border-top: 4px solid var(--bs-primary); + border-radius: 50%; + width: 24px; + height: 24px; + animation: spin 1s linear infinite; + margin: 0 auto; } + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.preset button:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} \ No newline at end of file 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 1b6f09e0..6167cfc4 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 @@ -102,6 +102,10 @@ function formatStudentData (student, selectedHighlights) { }; } +function styleStudentTile (width, height) { + return { width: `${(100 - width) / width}%`, height: `${height}px` }; +} + window.dash_clientside.wo_classroom_text_highlighter = { /** * Send updated queries to the communication protocol. @@ -144,7 +148,7 @@ window.dash_clientside.wo_classroom_text_highlighter = { adjustTileSize: function (width, height, studentIds) { const total = studentIds.length; - return Array(total).fill({ width: `${100 / width}%`, height: `${height}px` }); + return Array(total).fill(styleStudentTile(width, height)); }, showHideHeader: function (show, ids) { @@ -184,14 +188,15 @@ window.dash_clientside.wo_classroom_text_highlighter = { LO_DASH_REACT_COMPONENTS, 'WOStudentTextTile', { showHeader, - style: { width: `${100 / width}%`, height: `${height}px` }, + style: styleStudentTile(width, height), studentInfo: formatStudentData(wsStorageData[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', childComponent: createDashComponent(LO_DASH_REACT_COMPONENTS, 'WOAnnotatedText', {}), id: { type: 'WOStudentTextTile', index: student }, - currentOptionHash: optionHash + currentOptionHash: optionHash, + className: 'mb-2' } ); output = output.concat(studentTile); 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 6c85eae9..7ec00cdd 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 @@ -29,15 +29,16 @@ _options_text_information = f'{_options_prefix}-text-information' options_component = [ + 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'), + dbc.Label('Height of student tile'), dcc.Slider(min=100, max=800, marks=None, value=500, id=_options_height), - dbc.Label('Headers'), + dbc.Label('Student name headers'), dbc.Switch(value=True, id=_options_hide_header, label='Show/Hide'), - dbc.Label('Text information'), + 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) + lodrc.WOSettings(id=_options_text_information, options=wo_classroom_text_highlighter.options.OPTIONS, className='table table-striped align-middle') ] # Alert Component @@ -60,11 +61,14 @@ def layout(): alert_component, dbc.InputGroup([ dbc.InputGroupText(lodrc.LOConnectionAIO(aio_id=_websocket)), - dbc.Button(html.I(className='fas fa-cog'), id=_options_toggle), + dbc.Button([ + html.I(className='fas fa-cog me-1'), + 'Highlight Options' + ], id=_options_toggle), lodrc.ProfileSidebarAIO(class_name='rounded-0 rounded-end', color='secondary'), - ]), - dbc.Collapse(options_component, id=_options_collapse), - html.Div(id=_output, className='d-flex justify-content-around flex-wrap') + ], class_name='sticky-top mb-1'), + dbc.Offcanvas(options_component, id=_options_collapse, scrollable=True, title='Settings'), + html.Div(id=_output, className='d-flex justify-content-between flex-wrap') ]) return page_layout diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py index 00f668eb..6b29036f 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/options.py @@ -4,10 +4,9 @@ parents = [] OPTIONS = [ - {'id': indicator['id'], 'types': {'highlight': {}, 'metric': {}}, 'label': indicator['name'], 'parent': 'text-information'} + {'id': indicator['id'], 'types': {'highlight': {}, 'metric': {}}, 'label': indicator['name'], 'parent': ''} for indicator in writing_observer.nlp_indicators.INDICATOR_JSONS ] -OPTIONS.append({'id': 'text-information', 'label': 'Text Information', 'parent': ''}) # TODO currently each preset is the full list of options with specific # values being set to true/including a color. We ought to just store @@ -45,7 +44,8 @@ 'Vocabulary': ['academic_language', 'informal_language', 'latinate_words', 'polysyllabic_words', 'low_frequency_words'] } -PRESETS = {'Clear': OPTIONS} +deselect_all = 'Deselect All' +PRESETS = {deselect_all: OPTIONS} def add_preset_to_presets(key, value): diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py index 0ea2c8c9..dd9c86c6 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/preset_component.py @@ -23,7 +23,7 @@ def create_layout(): html.I(className='fas fa-plus me-1'), 'Preset' ], id=_add_button) - ], class_name='') + ], class_name='mb-1') return html.Div([ add_preset, html.Div(id=_tray), @@ -57,16 +57,16 @@ def create_layout(): def create_tray_item(preset): - if preset == 'Clear': - return dbc.Button(preset, id={'type': _set_item, 'index': preset}, color='danger') + if preset == wo_classroom_text_highlighter.options.deselect_all: + return dbc.Button(preset, id={'type': _set_item, 'index': preset}, color='warning') contents = dbc.ButtonGroup([ dbc.Button(preset, id={'type': _set_item, 'index': preset}), - dbc.Button(dcc.ConfirmDialogProvider( - html.I(className='fas fa-close fa-sm'), + dcc.ConfirmDialogProvider( + dbc.Button(html.I(className='fas fa-trash fa-xs'), color='secondary'), id={'type': _remove_item, 'index': preset}, message=f'Are you sure you want to delete the `{preset}` preset?' - ), color='secondary') - ]) + ) + ], class_name='preset') return contents @@ -78,7 +78,7 @@ def create_tray_item(preset): def create_tray_items_from_store(ts, data): if ts is None and data is None: raise exceptions.PreventUpdate - return [html.Span(create_tray_item(preset), className='me-1') for preset in data.keys()] + return [html.Div(create_tray_item(preset), className='d-inline-block me-1 mb-1') for preset in reversed(data.keys())] @callback( diff --git a/modules/writing_observer/writing_observer/awe_nlp.py b/modules/writing_observer/writing_observer/awe_nlp.py index 768a00ab..4e655ca3 100644 --- a/modules/writing_observer/writing_observer/awe_nlp.py +++ b/modules/writing_observer/writing_observer/awe_nlp.py @@ -46,7 +46,12 @@ def init_nlp(): '`awe_components` requires various models to operate properly. '\ f'Run `{learning_observer.paths.PYTHON_EXECUTABLE} awe_components/setup/data.py` to install all '\ 'of the necessary models.' - raise OSError(error_text) from e + + a = input('Spacy model `en_core_web_lg` not available. Would you like to download? (y/n)') + if a.strip().lower() not in ['y', 'yes']: + raise OSError(error_text) from e + import awe_components.setup.data + awe_components.setup.data.download_models() # Adding all of the components, since # each of them turns out to be implicated in From db4de774cc92a3cc12ebccd49b1f79a8e45560f3 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 16 Oct 2024 09:08:27 -0400 Subject: [PATCH 21/41] added stub gpt --- .../wo_bulk_essay_analysis/gpt.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 ab5ce34b..baf06a6a 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 aiohttp +import loremipsum import os import learning_observer.communication_protocol.integration @@ -117,9 +118,20 @@ async def chat_completion(self, prompt, system_prompt): raise GPTRequestErorr(error) +class StubGPT(GPTAPI): + '''GPT responder for handling stub requests + ''' + def __init__(self, **kwargs): + super().__init__() + + async def chat_completion(self, prompt, system_prompt): + return "\n".join(loremipsum.get_paragraphs(1)) + + GPT_RESPONDERS = { 'openai': OpenAIGPT, - 'ollama': OllamaGPT + 'ollama': OllamaGPT, + 'stub': StubGPT } From 6add83054d96d932f932899760eeaed24a22f281 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 16 Oct 2024 09:08:50 -0400 Subject: [PATCH 22/41] updated text --- .../wo_bulk_essay_analysis/assets/scripts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 43d438b3..fe5c7756 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 @@ -382,7 +382,7 @@ window.dash_clientside.bulk_essay_feedback = { const totalStudents = Object.keys(wsStorageData).length; if (totalStudents === returnedResponses) { return noLoading; } const loadingProgress = returnedResponses / totalStudents + 0.1; - const outputText = `Fetching responses from server. This may take a few minutes. (${returnedResponses}/${totalStudents} received)`; + const outputText = `Fetching responses from server. This will take a few minutes. (${returnedResponses}/${totalStudents} received)`; return [true, loadingProgress, outputText]; } }; From 473a681aaf8661fd1960edf177716fd48a017f6f Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 16 Oct 2024 10:10:52 -0400 Subject: [PATCH 23/41] protobuf fixes --- Makefile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 689ba465..e9f8c819 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ PACKAGES ?= wo,awe run: # If you haven't done so yet, run: make install # we need to make sure we are on the virtual env when we do this - cd learning_observer && python learning_observer --watchdog=restart + cd learning_observer && python learning_observer venv: # This is unnecessary since LO installs requirements on install. @@ -42,7 +42,9 @@ install-packages: venv # components. # TODO remove this extra step after AWE Component's `spacy` # is no longer version locked. - pip install -U typing-extensions + # pip install -U typing-extensions + pip uninstall -y protobuf + pip install --no-binary=protobuf protobuf==4.25 # testing commands test: From 16fc1cb6a3a65417002b248b80395317ced43e04 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 16 Oct 2024 16:17:23 -0400 Subject: [PATCH 24/41] added hack for fetching doc_id --- .../learning_observer/adapters/adapter.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/learning_observer/learning_observer/adapters/adapter.py b/learning_observer/learning_observer/adapters/adapter.py index aacf8f12..e8bdc8cc 100644 --- a/learning_observer/learning_observer/adapters/adapter.py +++ b/learning_observer/learning_observer/adapters/adapter.py @@ -52,8 +52,22 @@ def dash_to_underscore(event): return event +# HACK +import writing_observer.writing_analysis + +def document_link_to_doc_id(event): + ''' + Convert a document link to include a doc_id + ''' + doc_id = writing_observer.writing_analysis.get_doc_id(event) + if doc_id: + event['doc_id'] = doc_id + return event + + common_transformers = [ - dash_to_underscore + dash_to_underscore, + document_link_to_doc_id ] From 2d3749653f47f075755dc8aaea4c279c2e87a465 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 16 Oct 2024 16:22:17 -0400 Subject: [PATCH 25/41] fixed for the hack --- learning_observer/learning_observer/adapters/adapter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/learning_observer/learning_observer/adapters/adapter.py b/learning_observer/learning_observer/adapters/adapter.py index e8bdc8cc..df38c5fb 100644 --- a/learning_observer/learning_observer/adapters/adapter.py +++ b/learning_observer/learning_observer/adapters/adapter.py @@ -60,7 +60,9 @@ def document_link_to_doc_id(event): Convert a document link to include a doc_id ''' doc_id = writing_observer.writing_analysis.get_doc_id(event) - if doc_id: + if doc_id and 'client' in event and 'doc_id' in event['client']: + event['client']['doc_id'] = doc_id + if 'doc_id' in event: event['doc_id'] = doc_id return event From aeee94ce98a3c0ee7916c36bbe440cb6db77be2e Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 16 Oct 2024 17:14:23 -0400 Subject: [PATCH 26/41] removed hack --- .../learning_observer/adapters/adapter.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/learning_observer/learning_observer/adapters/adapter.py b/learning_observer/learning_observer/adapters/adapter.py index df38c5fb..26cbc6da 100644 --- a/learning_observer/learning_observer/adapters/adapter.py +++ b/learning_observer/learning_observer/adapters/adapter.py @@ -52,24 +52,8 @@ def dash_to_underscore(event): return event -# HACK -import writing_observer.writing_analysis - -def document_link_to_doc_id(event): - ''' - Convert a document link to include a doc_id - ''' - doc_id = writing_observer.writing_analysis.get_doc_id(event) - if doc_id and 'client' in event and 'doc_id' in event['client']: - event['client']['doc_id'] = doc_id - if 'doc_id' in event: - event['doc_id'] = doc_id - return event - - common_transformers = [ dash_to_underscore, - document_link_to_doc_id ] From 78005439755f730fb105dd182693a53ec86abef6 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Fri, 18 Oct 2024 12:54:45 -0400 Subject: [PATCH 27/41] added hack for backwards events without doc id --- .../learning_observer/adapters/adapter.py | 22 +++++++++++++++++++ learning_observer/util/restream.py | 14 +++++------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/learning_observer/learning_observer/adapters/adapter.py b/learning_observer/learning_observer/adapters/adapter.py index 26cbc6da..457b7e42 100644 --- a/learning_observer/learning_observer/adapters/adapter.py +++ b/learning_observer/learning_observer/adapters/adapter.py @@ -52,8 +52,30 @@ def dash_to_underscore(event): return event +# TODO this code ought to live in Writing Observer. Then, +# Learning Observer can import the adapters (rename to migrations) +# from each module and load them into a canonicalize_event function. +# We ought to have some pipeline for transforming events between +# versions for a given source. +# We ought to include the source and version pair when defining +# a migration function. +import writing_observer.writing_analysis + +def document_link_to_doc_id(event): + ''' + Convert a document link to include a doc_id + ''' + doc_id = writing_observer.writing_analysis.get_doc_id({'client': event}) + if doc_id and 'client' in event and 'doc_id' in event['client']: + event['client']['doc_id'] = doc_id + if 'doc_id' in event: + event['doc_id'] = doc_id + return event + + common_transformers = [ dash_to_underscore, + document_link_to_doc_id ] diff --git a/learning_observer/util/restream.py b/learning_observer/util/restream.py index a7624da6..8c395c50 100644 --- a/learning_observer/util/restream.py +++ b/learning_observer/util/restream.py @@ -71,31 +71,29 @@ async def restream( async with session.ws_connect(url) as web_socket: async with aiofiles.open(filename) as log_file: async for line in log_file: + json_line = json.loads(line.split('\t')[0]) if rate is not None: - jline = json.loads(line) - if jline['client']['event'] in skip: + if json_line['client']['event'] in skip: continue - new_ts = jline["server"]["time"] + new_ts = json_line["server"]["time"] if old_ts is not None: delay = (new_ts - old_ts) / rate if max_wait is not None: - delay = min(delay, max_wait) + delay = min(delay, float(max_wait)) print(line) print(delay) await asyncio.sleep(delay) old_ts = new_ts if extract_client or rename: - json_line = json.loads(line) if extract_client: json_line = json_line['client'] - print(json.dumps(json_line, indent=2)) if rename: if 'auth' not in json_line: json_line['auth'] = {} json_line['auth']['user_id'] = new_id - line = json.dumps(json_line) + jline = json.dumps(json_line) - await web_socket.send_str(line.strip()) + await web_socket.send_str(jline.strip()) return True From fd15d7fd409c16621441e31a0ea363a74d325417 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Fri, 18 Oct 2024 15:28:59 -0400 Subject: [PATCH 28/41] needed an else instead --- learning_observer/learning_observer/adapters/adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/learning_observer/learning_observer/adapters/adapter.py b/learning_observer/learning_observer/adapters/adapter.py index 457b7e42..9d359d47 100644 --- a/learning_observer/learning_observer/adapters/adapter.py +++ b/learning_observer/learning_observer/adapters/adapter.py @@ -68,7 +68,7 @@ def document_link_to_doc_id(event): doc_id = writing_observer.writing_analysis.get_doc_id({'client': event}) if doc_id and 'client' in event and 'doc_id' in event['client']: event['client']['doc_id'] = doc_id - if 'doc_id' in event: + else: event['doc_id'] = doc_id return event From f8f4362a4e946ca73a36d6f8109955a78ff98599 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 21 Oct 2024 09:42:13 -0400 Subject: [PATCH 29/41] loading + more documentation --- .../learning_observer/adapters/adapter.py | 4 ++-- .../learning_observer/incoming_student_event.py | 9 ++++++++- learning_observer/util/restream.py | 8 +++++++- .../src/lib/components/WOStudentTextTile.react.js | 14 ++++++++------ 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/learning_observer/learning_observer/adapters/adapter.py b/learning_observer/learning_observer/adapters/adapter.py index 9d359d47..d22119bc 100644 --- a/learning_observer/learning_observer/adapters/adapter.py +++ b/learning_observer/learning_observer/adapters/adapter.py @@ -66,9 +66,9 @@ def document_link_to_doc_id(event): Convert a document link to include a doc_id ''' doc_id = writing_observer.writing_analysis.get_doc_id({'client': event}) - if doc_id and 'client' in event and 'doc_id' in event['client']: + if doc_id and 'client' in event: event['client']['doc_id'] = doc_id - else: + elif doc_id: event['doc_id'] = doc_id return event diff --git a/learning_observer/learning_observer/incoming_student_event.py b/learning_observer/learning_observer/incoming_student_event.py index 26451827..22b8ae13 100644 --- a/learning_observer/learning_observer/incoming_student_event.py +++ b/learning_observer/learning_observer/incoming_student_event.py @@ -369,7 +369,14 @@ async def handle_auth_events(events): async for event in events: if 'auth' in event: - raise ValueError('Auth already exists in event, someone may be trying to hack the system') + # TODO when we restream events into the system from logs, + # they already have 'auth' in the event. + # create a settings flag for if we should allow this + # `insecure_demo_allow_auth_in_events` or similar + error = 'Auth already exists in event, either\n'\ + '1. Someone is trying to hack the system, or\n'\ + '2. Someone is restreaming events into the system.' + raise ValueError(error) if not authenticated: authenticated = await learning_observer.auth.events.authenticate( request=request, diff --git a/learning_observer/util/restream.py b/learning_observer/util/restream.py index 8c395c50..3641b692 100644 --- a/learning_observer/util/restream.py +++ b/learning_observer/util/restream.py @@ -71,7 +71,13 @@ async def restream( async with session.ws_connect(url) as web_socket: async with aiofiles.open(filename) as log_file: async for line in log_file: - json_line = json.loads(line.split('\t')[0]) + if filename.endswith('.study.log'): + # HACK the `.study.log` include the event along + # with a timestamp + json_line = json.loads(line.split('\t')[0]) + else: + json_line = json.loads(line) + if rate is not None: if json_line['client']['event'] in skip: continue diff --git a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js index 3ba657c9..ab68c60e 100644 --- a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js +++ b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js @@ -30,7 +30,7 @@ export default class WOStudentTextTile extends Component { const documentIsSelected = selectedDocument && studentInfo.documents[selectedDocument]; const isLoading = documentIsSelected && currentOptionHash !== studentInfo.documents[selectedDocument].optionHash; let bodyClassName = isLoading ? 'loading' : ''; - bodyClassName = `${bodyClassName} overflow-auto`; + bodyClassName = `${bodyClassName} overflow-auto position-relative`; const loadedItem = documentIsSelected ? <>{childComponent} @@ -65,11 +65,13 @@ export default class WOStudentTextTile extends Component { */} - { - isLoading - ?
- : loadedItem - } + {loadedItem} + {isLoading && ( +
+
+ Loading... +
+ )} ); From c4708427f9da0b114212a034e5aa07f3a81c07b3 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 21 Oct 2024 10:08:35 -0400 Subject: [PATCH 30/41] added runtime error to old dashboards that will no longer function --- .../wo_common_student_errors/dashboard/layout.py | 5 +++++ .../wo_document_list/wo_document_list/dashboard/layout.py | 5 +++++ .../wo_highlight_dashboard/dashboard/layout.py | 6 ++++++ 3 files changed, 16 insertions(+) diff --git a/modules/wo_common_student_errors/wo_common_student_errors/dashboard/layout.py b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/layout.py index 5e1d6476..6572a595 100644 --- a/modules/wo_common_student_errors/wo_common_student_errors/dashboard/layout.py +++ b/modules/wo_common_student_errors/wo_common_student_errors/dashboard/layout.py @@ -3,6 +3,11 @@ This layout is pretty messy as we are constantly prototyping new ways of displaying information ''' +# TODO this module no longer works properly since switching +# the communication protocol to use an async generator. +error = f'The module WO Common student errors is not compatible with the communication protocol api.\n'\ + 'Please uninstall this module with `pip uninstall wo-common-student-errors`.' +raise RuntimeError(error) # package imports import dash_bootstrap_components as dbc from dash_renderjson import DashRenderjson diff --git a/modules/wo_document_list/wo_document_list/dashboard/layout.py b/modules/wo_document_list/wo_document_list/dashboard/layout.py index d273791a..a7744a22 100644 --- a/modules/wo_document_list/wo_document_list/dashboard/layout.py +++ b/modules/wo_document_list/wo_document_list/dashboard/layout.py @@ -1,6 +1,11 @@ ''' Define layout for per student list of documents ''' +# TODO this module no longer works properly since switching +# the communication protocol to use an async generator. +error = f'The module WO Document List is not compatible with the communication protocol api.\n'\ + 'Please uninstall this module with `pip uninstall wo-document-list`.' +raise RuntimeError(error) # package imports import dash_bootstrap_components as dbc import lo_dash_react_components as lodrc diff --git a/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/layout.py b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/layout.py index c0323bcd..4d0dd7b8 100644 --- a/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/layout.py +++ b/modules/wo_highlight_dashboard/wo_highlight_dashboard/dashboard/layout.py @@ -1,6 +1,12 @@ ''' Define layout for student dashboard view ''' +# TODO this module no longer works properly since switching +# the communication protocol to use an async generator. +# Additionally, this module has been re-written as `wo_classroom_text_highlighter` +error = f'The module WO Highlight Dashboard is not compatible with the communication protocol api.\n'\ + 'Please uninstall this module with `pip uninstall wo-highlight-dashboard`.' +raise RuntimeError(error) # package imports import learning_observer.dash_wrapper as dash import dash_bootstrap_components as dbc From 15c07d134812bc070ac791617d49a05f654ec327 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 21 Oct 2024 10:38:25 -0400 Subject: [PATCH 31/41] updated wo_requirements to non broken dashboards. Using wo_requirements will work after merge --- wo_requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/wo_requirements.txt b/wo_requirements.txt index 55a5cd61..54e26e12 100644 --- a/wo_requirements.txt +++ b/wo_requirements.txt @@ -1,4 +1,3 @@ writing_observer @ git+https://github.com/ETS-Next-Gen/writing_observer.git#subdirectory=modules/writing_observer/ -wo_highlight_dashboard @ git+https://github.com/ETS-Next-Gen/writing_observer.git#subdirectory=modules/wo_highlight_dashboard/ -wo_common_student_errors @ git+https://github.com/ETS-Next-Gen/writing_observer.git#subdirectory=modules/wo_common_student_errors/ wo_bulk_essay_analysis @ git+https://github.com/ETS-Next-Gen/writing_observer.git#subdirectory=modules/wo_bulk_essay_analysis/ +wo_classroom_text_highlighter @ git+https://github.com/ETS-Next-Gen/writing_observer.git#subdirectory=modules/wo_classroom_text_highlighter/ From b6b778202b9f3f1d0a24a14fc1bfdf3714c301c4 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 21 Oct 2024 11:33:55 -0400 Subject: [PATCH 32/41] updated requirement for python311 install --- awe_requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/awe_requirements.txt b/awe_requirements.txt index cebc4572..04288739 100644 --- a/awe_requirements.txt +++ b/awe_requirements.txt @@ -1,5 +1,6 @@ spacy==3.4.4 pydantic==1.10 +spacytextblob==3.0.1 AWE_SpellCorrect @ git+https://github.com/ETS-Next-Gen/AWE_SpellCorrect.git AWE_Components @ git+https://github.com/ETS-Next-Gen/AWE_Components.git AWE_Lexica @ git+https://github.com/ETS-Next-Gen/AWE_Lexica.git From 4d7d53716c6a86a7687ced1fe521d02f30766508 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 21 Oct 2024 11:34:25 -0400 Subject: [PATCH 33/41] updated loading on student text tile --- .../src/lib/components/WOStudentTextTile.react.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js index ab68c60e..041bf875 100644 --- a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js +++ b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.react.js @@ -64,14 +64,14 @@ export default class WOStudentTextTile extends Component { ))} */} + {isLoading && ( +
+
+ Loading... +
+ )} {loadedItem} - {isLoading && ( -
-
- Loading... -
- )} ); From fb48376ba81817ec628011f6b5a8d5071ccde2c7 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 21 Oct 2024 16:37:09 -0400 Subject: [PATCH 34/41] fixed adapter/migrator imports, still a hack --- .../learning_observer/adapters/adapter.py | 33 ++++++------------- .../writing_observer/writing_analysis.py | 14 ++++++++ 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/learning_observer/learning_observer/adapters/adapter.py b/learning_observer/learning_observer/adapters/adapter.py index d22119bc..ad0b5b4e 100644 --- a/learning_observer/learning_observer/adapters/adapter.py +++ b/learning_observer/learning_observer/adapters/adapter.py @@ -51,33 +51,20 @@ def dash_to_underscore(event): return event - -# TODO this code ought to live in Writing Observer. Then, -# Learning Observer can import the adapters (rename to migrations) -# from each module and load them into a canonicalize_event function. -# We ought to have some pipeline for transforming events between -# versions for a given source. -# We ought to include the source and version pair when defining -# a migration function. -import writing_observer.writing_analysis - -def document_link_to_doc_id(event): - ''' - Convert a document link to include a doc_id - ''' - doc_id = writing_observer.writing_analysis.get_doc_id({'client': event}) - if doc_id and 'client' in event: - event['client']['doc_id'] = doc_id - elif doc_id: - event['doc_id'] = doc_id - return event - - common_transformers = [ dash_to_underscore, - document_link_to_doc_id ] +def add_common_migrator(migrator, file): + '''Add a migrator to the common transformers list. + TODO + We ought check each module on startup for migrators + and import them instead of using this function to + add them to the transformations. + ''' + print('Adding migrator', migrator, 'from', file), + common_transformers.append(migrator) + class EventAdapter: def __init__(self, metadata=None): diff --git a/modules/writing_observer/writing_observer/writing_analysis.py b/modules/writing_observer/writing_observer/writing_analysis.py index f052b789..c2cb9b33 100644 --- a/modules/writing_observer/writing_observer/writing_analysis.py +++ b/modules/writing_observer/writing_observer/writing_analysis.py @@ -13,6 +13,7 @@ import writing_observer.reconstruct_doc +import learning_observer.adapters import learning_observer.communication_protocol.integration from learning_observer.stream_analytics.helpers import student_event_reducer, kvs_pipeline, KeyField, EventField, Scope import learning_observer.settings @@ -409,3 +410,16 @@ def get_doc_id(event): doc_id = client.get('object', {}).get('id') return doc_id + +def document_link_to_doc_id(event): + ''' + Convert a document link to include a doc_id + ''' + doc_id = get_doc_id({'client': event}) + if doc_id and 'client' in event: + event['client']['doc_id'] = doc_id + elif doc_id: + event['doc_id'] = doc_id + return event + +learning_observer.adapters.adapter.add_common_migrator(document_link_to_doc_id, __file__) From 6b1d8577af2e5f95e52d464efb23893bf8a3e24d Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 21 Oct 2024 16:37:32 -0400 Subject: [PATCH 35/41] updated install script --- modules/writing_observer/writing_observer/awe_nlp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/writing_observer/writing_observer/awe_nlp.py b/modules/writing_observer/writing_observer/awe_nlp.py index 4e655ca3..ea8e71c0 100644 --- a/modules/writing_observer/writing_observer/awe_nlp.py +++ b/modules/writing_observer/writing_observer/awe_nlp.py @@ -44,7 +44,7 @@ def init_nlp(): except OSError as e: error_text = 'There was an issue loading `en_core_web_lg` from spacy. '\ '`awe_components` requires various models to operate properly. '\ - f'Run `{learning_observer.paths.PYTHON_EXECUTABLE} awe_components/setup/data.py` to install all '\ + f'Run `{learning_observer.paths.PYTHON_EXECUTABLE} -m awe_components.setup.data` to install all '\ 'of the necessary models.' a = input('Spacy model `en_core_web_lg` not available. Would you like to download? (y/n)') From 3b3cc7f20e93e534402e8b42d103e5daa5a8738b Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 21 Oct 2024 16:43:37 -0400 Subject: [PATCH 36/41] updated test data --- .../src/lib/components/WOStudentTextTile.testdata.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.testdata.js b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.testdata.js index aef563a5..0d297f06 100644 --- a/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.testdata.js +++ b/modules/lo_dash_react_components/src/lib/components/WOStudentTextTile.testdata.js @@ -20,7 +20,7 @@ const testData = { documents: { '1_2V-Npp1L0G3cw4lcH_ENSo_y_OV1BP3s8NdnwaFbVw': { optionHash: '123', - text: 'Lorem ipsum dolor sit amet, \nconsectetur adipiscing elit, sed do eiusmod tempor incididunt\n ut labore et dolore magna aliqua. Dictumst quisque sagittis purus sit amet. Mi quis hendrerit dolor magna eget est lorem ipsum. Arcu bibendum at varius vel pharetra. Nulla malesuada pellentesque elit eget gravida cum. Tincidunt tortor aliquam nulla facilisi cras fermentum odio. Amet venenatis urna cursus eget nunc scelerisque viverra mauris. Diam vel quam elementum pulvinar. Morbi tincidunt augue interdum velit euismod in pellentesque massa. Dignissim cras tincidunt lobortis feugiat vivamus at augue eget arcu. Enim praesent elementum facilisis leo vel fringilla est.\n\nSodales ut etiam sit amet nisl purus in mollis nunc. Suspendisse interdum consectetur libero id faucibus. Morbi leo urna molestie at elementum. In iaculis nunc sed augue lacus viverra. Tristique senectus et netus et malesuada fames ac turpis egestas. Accumsan lacus vel facilisis volutpat est. Consequat semper viverra nam libero justo laoreet sit. Euismod nisi porta lorem mollis aliquam ut porttitor leo. Enim facilisis gravida neque convallis a cras. Odio ut enim blandit volutpat maecenas. Justo nec ultrices dui sapien eget mi proin sed. Non sodales neque sodales ut etiam. Nulla aliquet enim tortor at auctor urna. At volutpat diam ut venenatis.\n\nNulla facilisi cras fermentum odio eu feugiat. Imperdiet massa tincidunt nunc pulvinar sapien et. Fermentum odio eu feugiat pretium nibh ipsum consequat nisl. Pellentesque pulvinar pellentesque habitant morbi tristique senectus et netus. Ac turpis egestas sed tempus urna et. Libero volutpat sed cras ornare arcu dui vivamus arcu. Varius duis at consectetur lorem. Tincidunt augue interdum velit euismod. Praesent elementum facilisis leo vel fringilla est ullamcorper. Facilisis magna etiam tempor orci eu lobortis. Amet est placerat in egestas erat imperdiet sed. Odio eu feugiat pretium nibh ipsum consequat nisl vel pretium. Lectus proin nibh nisl condimentum id venenatis a condimentum vitae. Lacus suspendisse faucibus interdum posuere lorem ipsum. Vel turpis nunc eget lorem dolor. Feugiat nibh sed pulvinar proin gravida hendrerit lectus. Convallis aenean et tortor at risus viverra adipiscing. Aliquet nec ullamcorper sit amet risus nullam eget felis. Massa eget egestas purus viverra accumsan in nisl nisi. Orci nulla pellentesque dignissim enim sit.\n\nUltrices mi tempus imperdiet nulla malesuada pellentesque elit eget. Augue neque gravida in fermentum. Sapien eget mi proin sed libero enim sed faucibus turpis. Velit sed ullamcorper morbi tincidunt. Enim sed faucibus turpis in eu mi bibendum neque. Gravida in fermentum et sollicitudin ac orci phasellus egestas. Risus at ultrices mi tempus imperdiet nulla malesuada. Ridiculus mus mauris vitae ultricies leo. Montes nascetur ridiculus mus mauris vitae ultricies leo integer. Mollis aliquam ut porttitor leo. Elementum nibh tellus molestie nunc non. Malesuada bibendum arcu vitae elementum. Nibh mauris cursus mattis molestie.\n\nMollis nunc sed id semper risus in hendrerit. In ornare quam viverra orci sagittis eu. Cursus vitae congue mauris rhoncus aenean vel elit. Imperdiet massa tincidunt nunc pulvinar. Lobortis scelerisque fermentum dui faucibus in. Sit amet consectetur adipiscing elit pellentesque habitant morbi. Interdum velit laoreet id donec ultrices tincidunt arcu. Elementum curabitur vitae nunc sed velit. Sed euismod nisi porta lorem mollis. Pretium aenean pharetra magna ac. Enim diam vulputate ut pharetra sit. In fermentum et sollicitudin ac orci phasellus egestas tellus rutrum. Sed viverra tellus in hac habitasse platea dictumst. Tellus rutrum tellus pellentesque eu. Velit dignissim sodales ut eu sem.', + text: "This summer was AMAZING!!! First, I went to the beach with my family for two whole weeks! We stayed in this super cool beach house that had its own private pool and hot tub. My siblings and I spent hours playing in the waves and building sandcastles on the beach. We even went on a snorkeling trip and saw some really cool fish!\n\nWhen we weren't at the beach, I hung out with my friends and we had a blast! We went to the trampoline park and played laser tag. I also started reading this really good book called \"The Giver\" and it was sooo good that I couldn't put it down.\n\nMy parents took me and my siblings on a road trip to visit our grandparents in another state. It was a pretty long drive, but we made some great memories along the way. We stopped at a few theme parks and went on some really cool rides. My favorite one was this roller coaster that had loops and corkscrews!\n\nAt home, I started working on my own little garden project. I planted some flowers and herbs, and even tried to grow my own tomatoes (which didn't quite work out as planned...). It was pretty cool seeing everything grow and flourish.\n\nOverall, this summer was definitely the best one yet!", breakpoints: [ { id: 'split0', From 8784c57beb430c42c6b5e196a53a1f1eb0ad2304 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 22 Oct 2024 07:58:56 -0400 Subject: [PATCH 37/41] added name to webapp footer --- learning_observer/learning_observer/static/webapp.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/learning_observer/learning_observer/static/webapp.html b/learning_observer/learning_observer/static/webapp.html index 7ca747d6..5238f5d2 100644 --- a/learning_observer/learning_observer/static/webapp.html +++ b/learning_observer/learning_observer/static/webapp.html @@ -43,8 +43,8 @@

Learning Observer - by Piotr Mitros. Copyright - (c) 2020-2022. Educational Testing + by Piotr Mitros and Bradley Erickson. Copyright + (c) 2020-2024. Educational Testing Service. The source code is available under the From 4e49c6e466cdd7968a57fe9f04d600861049c830 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 22 Oct 2024 07:59:31 -0400 Subject: [PATCH 38/41] added name to contributors --- CONTRIBUTORS.TXT | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.TXT b/CONTRIBUTORS.TXT index 48602bc9..ed669f73 100644 --- a/CONTRIBUTORS.TXT +++ b/CONTRIBUTORS.TXT @@ -1,3 +1,4 @@ Piotr Mitros Oren Livne Paul Deane +Bradley Erickson From 3d8c9bbc712a1a7423fbd4aaff23e8233aec44bb Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 22 Oct 2024 09:57:32 -0400 Subject: [PATCH 39/41] removed existing event auth and made note about needing to store it --- .../incoming_student_event.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/learning_observer/learning_observer/incoming_student_event.py b/learning_observer/learning_observer/incoming_student_event.py index 22b8ae13..a3d6c029 100644 --- a/learning_observer/learning_observer/incoming_student_event.py +++ b/learning_observer/learning_observer/incoming_student_event.py @@ -369,14 +369,19 @@ async def handle_auth_events(events): async for event in events: if 'auth' in event: - # TODO when we restream events into the system from logs, - # they already have 'auth' in the event. - # create a settings flag for if we should allow this - # `insecure_demo_allow_auth_in_events` or similar - error = 'Auth already exists in event, either\n'\ - '1. Someone is trying to hack the system, or\n'\ - '2. Someone is restreaming events into the system.' - raise ValueError(error) + ''' + If 'auth' already exists, this means + 1. Someone is trying to hack the system + 2. Someone is restreaming logs into the system + We should record the current auth to history and + then remove it from the event. The `.authenticate` + function will take care of re-authorizing the user. + + TODO determine how to store the auth history and append + current auth object. + ''' + del event['auth'] + if not authenticated: authenticated = await learning_observer.auth.events.authenticate( request=request, From 62089afbbc222711431f4a25951f3da1d32deeb8 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 22 Oct 2024 10:07:11 -0400 Subject: [PATCH 40/41] added comments to the makefile --- Makefile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Makefile b/Makefile index e9f8c819..f22128c1 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,7 @@ install-packages: venv pip install -e learning_observer/[${PACKAGES}] # Just a little bit of dependency hell... + # The AWE Components are built using a specific version of # `spacy`. This requires an out-of-date `typing-extensions` # package. There are few other dependecies that require a @@ -42,7 +43,14 @@ install-packages: venv # components. # TODO remove this extra step after AWE Component's `spacy` # is no longer version locked. + # This is no longer an issue, but we will leave until all + # dependecies can be resolved in the appropriate locations. # pip install -U typing-extensions + + # On Python3.11 with tensorflow, we get some odd errors + # regarding compatibility with `protobuf`. Some installation + # files are missing from the protobuf binary on pip. + # Using the `--no-binary` option includes all files. pip uninstall -y protobuf pip install --no-binary=protobuf protobuf==4.25 From 61ae87b7734bd97aeda70d903ef591b8ded21ebf Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 22 Oct 2024 10:46:08 -0400 Subject: [PATCH 41/41] small styling cleanups + documentation --- .../learning_observer/adapters/adapter.py | 2 +- .../dashboard/layout.py | 4 +- .../wo_bulk_essay_analysis/module.py | 2 +- .../wo_classroom_text_highlighter/README.md | 143 +----------------- .../dash_dashboard.py | 2 +- .../wo_classroom_text_highlighter/module.py | 4 +- 6 files changed, 12 insertions(+), 145 deletions(-) diff --git a/learning_observer/learning_observer/adapters/adapter.py b/learning_observer/learning_observer/adapters/adapter.py index ad0b5b4e..ea456a7d 100644 --- a/learning_observer/learning_observer/adapters/adapter.py +++ b/learning_observer/learning_observer/adapters/adapter.py @@ -52,7 +52,7 @@ def dash_to_underscore(event): return event common_transformers = [ - dash_to_underscore, + dash_to_underscore ] def add_common_migrator(migrator, file): 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 f0d76e73..e3ee9a69 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 @@ -118,6 +118,8 @@ def layout(): # 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.Textarea(id=query_input, value=starting_prompt, class_name='h-100', style={'minHeight': '150px'}), @@ -152,7 +154,7 @@ def layout(): # overall container cont = dbc.Container([ - html.H2('AskGPT'), + html.H2('Writing Observer - AskGPT'), dbc.InputGroup([ dbc.InputGroupText(lodrc.LOConnectionAIO(aio_id=_websocket)), dbc.Button([html.I(className='fas fa-cog me-1'), 'Advanced'], id=_advanced_toggle), 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 ecdfd629..d4c9f951 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 @@ -75,6 +75,6 @@ 'url': "/wo_bulk_essay_analysis/dash/bulk-essay-analysis", "icon": { "type": "fas", - "icon": "fa-pen-nib" + "icon": "fa-lightbulb" } }] diff --git a/modules/wo_classroom_text_highlighter/README.md b/modules/wo_classroom_text_highlighter/README.md index 577a9266..b1f7f2ed 100644 --- a/modules/wo_classroom_text_highlighter/README.md +++ b/modules/wo_classroom_text_highlighter/README.md @@ -1,140 +1,5 @@ -# Learning Observer Example Module +# WO Classroom Text Highlighter -Welcome to the Learning Observer (LO) example module. This document -will detail everything need to create a module for the LO. - -## packaage structure - -```bash -module/ - wo_classroom_text_highlighter/ - assets/ - ... - helpers/ - additional_script.py - module.py - reducers.py - dash_dashboards.py - MANIFEST.in - setup.py - setup.cfg -``` - -### setup.py - -This is a standard `setup.py` file. - -### setup.cfg - -Notice we include the following items in our `setup.cfg` file. - -```cfg -[options.entry_points] -lo_modules = - wo_classroom_text_highlighter = wo_classroom_text_highlighter.module - -[options.package_data] -wo_classroom_text_highlighter = helpers/* -``` - -The `lo_modules` entry point tells Learning Observer to treat `wo_classroom_text_highlighter.module` as a pluggable application. - -The package data section is where we include additional directories we want included in the build. - -### MANIFEST.in - -The manifest specifies which files to include during Python packaging. This specifies the additional non-python files we want included. If you do not have additional files needed, this file is unnecessary. - -For modules with Dash-made dashboards, this will typically include a relative path to the assets folder. - -### module.py - -This file defines everything about the module. See the dedicated section below. - -## Defining a module (module.py) - -Modules can include a variety items. This will cover each item and its purpose on the system. - -### NAME - -This one is pretty self explanatory. Give the module a short name to refer to it by. - -### EXECUTION_DAG - -The execution directed acyclic graph (DAG) is how we interact with the communication protocol. - -See `wo_classroom_text_highlighter/module.py:EXECUTION_DAG` for a detailed example. - -### REDUCERS - -Reducers to define on the system. These are functions that will run over incoming events from students. - -See `wo_classroom_text_highlighter/module.py:REDUCERS` for a detailed example. - -### DASH_PAGES - -Dashboards built using the Dash framework should be defined here. - -See `wo_classroom_text_highlighter/module.py:DASH_PAGES` for a detailed example. - -### COURSE_DASHBOARDS - -The registered course dashboards are provided to the users for navigating around dashboards, such as on their Home screen. - -See `wo_classroom_text_highlighter/module.py:COURSE_DASHBOARDS` for a detailed example. - -Note that the student counterpart, `STUDENT_DASHBOARDS`, exists. - -### THIRD_PARTY - -The third party items are downloaded and included when serving items from the module. This is usually used for including extra Javascript or CSS files. - -```python -THIRD_PARTY = { - 'name_of_item': { - 'url': 'url_to_third_party_tool', - 'hash': 'hash_of_download_OR_dict_of_versions_and_hashes' - } -} -``` - -### STATIC_FILE_GIT_REPOS - -We're still figuring this out, but we'd like to support hosting static files from the git repo of the module. -This allows us to have a Merkle-tree style record of which version is deployed in our log files. - -A common use case for this is serving static `.html` and `.js` files for your module. - -```python -STATIC_FILE_GIT_REPOS = { - 'repo_name': { - 'url': 'url_to_repo', - 'prefix': 'relative/path/to/directory', - # Branches we serve. This can either be a whitelist (e.g. which ones - # are available) or a blacklist (e.g. which ones are blocked) - 'whitelist': ['master'] - } -} -``` - -### EXTRA_VIEWS - -These are extra views to publish to the user. Currently, we only support `.json` files. - -```python -EXTRA_VIEWS = [{ - 'name': 'Name of view', - 'suburl': 'view-suburl', - 'static_json': python_dictionary_to_return -}] -``` - -## Creating a reducer (reducers.py) - -Reducers are ran over incoming student events. They can be defined using a decorator in the `learning_observer.stream_analytics` module. - -Each reducer should take the incoming `event` and the previous `internal_state` as parameters and return 2 new state objects. - -## Creating dashboards with Dash (dash_dashboard.py) - -Dash pages consist of a layout and callback functions. See `dash_dashboard.py` for a more detailed overview. +The Writing Observer Classroom Text Highlighter is dashboard for highlighting student text. +Teachers are preseted with a grid of their students and a document for each of them. +Then, the teachers can select different language metrics to highlight the text with. 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 7ec00cdd..a2e51808 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 @@ -57,7 +57,7 @@ def layout(): Function to define the page's layout. ''' page_layout = html.Div([ - html.H1('Writing Observer Classroom Text Highlighter'), + html.H1('Writing Observer - Classroom Text Highlighter'), alert_component, dbc.InputGroup([ dbc.InputGroupText(lodrc.LOConnectionAIO(aio_id=_websocket)), diff --git a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/module.py b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/module.py index 260540f4..68b8af37 100644 --- a/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/module.py +++ b/modules/wo_classroom_text_highlighter/wo_classroom_text_highlighter/module.py @@ -9,7 +9,7 @@ import wo_classroom_text_highlighter.dash_dashboard # Name for the module -NAME = 'Writing Observer Classroom Text Highlighter' +NAME = 'Writing Observer - Classroom Text Highlighter' ''' Define pages created with Dash. @@ -51,6 +51,6 @@ 'url': "/wo_classroom_text_highlighter/dash/wo-classroom-text-highlighter", "icon": { "type": "fas", - "icon": "fa-play-circle" + "icon": "fa-highlighter" } }]

Name HighlightMetricMetric