diff --git a/VERSION b/VERSION index 20ad2997..42ec10d5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.02.27T21.25.37.849Z.3207a114.berickson.20260220.dami.portfolio.pr +0.1.0+2026.03.06T16.07.48.997Z.0bfff3a0.berickson.20260303.copy.paste.reducer diff --git a/modules/wo_bulk_essay_analysis/VERSION b/modules/wo_bulk_essay_analysis/VERSION index fff79c93..42ec10d5 100644 --- a/modules/wo_bulk_essay_analysis/VERSION +++ b/modules/wo_bulk_essay_analysis/VERSION @@ -1 +1 @@ -0.1.0+2026.02.20T19.02.56.547Z.85e344b1.berickson.20260210.dashboard.updates +0.1.0+2026.03.06T16.07.48.997Z.0bfff3a0.berickson.20260303.copy.paste.reducer 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 8641af6a..6ed6589c 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 @@ -18,6 +18,62 @@ function fetchSelectedItemsFromOptions (value, options, type) { }, []); } +function createPasteBadge (children, color = 'secondary', className = 'me-1') { + return createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, + 'Badge', + { children, color, className } + ); +} + +function createBinnedPasteEventsBadge (document) { + const lengthBins = document?.length_bins ?? {}; + const smallPastes = lengthBins.short_1_20 ?? 0; + const mediumPastes = lengthBins.medium_21_200 ?? 0; + const largePastes = lengthBins.long_201_plus ?? 0; + const largeColor = largePastes > 0 ? 'warning' : 'secondary'; + const mediumColor = mediumPastes > 0 ? 'warning' : 'seconday'; + + return createDashComponent( + DASH_HTML_COMPONENTS, + 'Div', + { + children: [ + createPasteBadge(`${smallPastes} small (<20)`, 'secondary'), + createPasteBadge(`${mediumPastes} medium (21-200)`,mediumColor), + createPasteBadge(`${largePastes} large (201+)`, largeColor) + ], + className: 'd-inline' + } + ); +} + +function createPasteMetricComponent (document, metricId) { + const pasteCount = document?.pastes_with_length ?? 0; + const totalPasteChars = document?.total_paste_chars ?? 0; + + switch (metricId) { + case 'paste_events': + return createPasteBadge(`${pasteCount} pastes`, pasteCount > 0 ? 'secondary' : 'light', 'me-1'); + case 'paste_bins': + return createBinnedPasteEventsBadge(document); + case 'total_paste_chars': + const totalPastedColor = totalPasteChars > 500 ? 'danger' : totalPasteChars > 100 ? 'warning' : 'secondary'; + return createPasteBadge(`${totalPasteChars} pasted chars`, totalPastedColor, 'me-1'); + case 'paste': + return createPasteMetricComponent(document, 'paste_events'); + default: + return null; + } +} + +window.WOPasteMetricHelpers = { + createPasteBadge, + createBinnedPasteEventsBadge, + createPasteMetricComponent +}; + + function createProcessTags (document, metrics) { const children = metrics.map(metric => { switch (metric.id) { @@ -32,10 +88,22 @@ function createProcessTags (document, metrics) { DASH_BOOTSTRAP_COMPONENTS, 'Badge', { children: document[metric.id], color } ); + case 'paste_events': + case 'paste_bins': + case 'total_paste_chars': + case 'paste': + return window.WOPasteMetricHelpers.createPasteMetricComponent(document, metric.id); + case 'copy': + const copyCount = document?.copy_count ?? 0; + const copyColor = copyCount > 0 ? 'primary' : 'secondary' + return createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Badge', + { children: `${copyCount} copies`, color: copyColor } + ); default: break; } - }); + }).filter(Boolean); return createDashComponent(DASH_HTML_COMPONENTS, 'Div', { children, className: 'sticky-top' }); } @@ -214,7 +282,7 @@ const fileTextExtractors = { docx: extractDOCX }; -const AIAssistantLoadingQueries = ['gpt_bulk', 'time_on_task', 'activity']; +const AIAssistantLoadingQueries = ['gpt_bulk', 'time_on_task', 'activity', 'paste_metrics', 'copy_cut_metrics']; // ── Walkthrough step definitions ────────────────────────────────────── const BULK_WALKTHROUGH_STEPS = [ @@ -633,7 +701,7 @@ window.dash_clientside.bulk_essay_feedback = { const message = { wo: { execution_dag: 'writing_observer', - target_exports: ['gpt_bulk', 'document_list', 'document_sources', 'time_on_task', 'activity'], + target_exports: ['gpt_bulk', 'document_list', 'document_sources', 'time_on_task', 'activity', 'paste_metrics', 'copy_cut_metrics'], kwargs: decoded } }; diff --git a/modules/wo_classroom_text_highlighter/VERSION b/modules/wo_classroom_text_highlighter/VERSION index fff79c93..42ec10d5 100644 --- a/modules/wo_classroom_text_highlighter/VERSION +++ b/modules/wo_classroom_text_highlighter/VERSION @@ -1 +1 @@ -0.1.0+2026.02.20T19.02.56.547Z.85e344b1.berickson.20260210.dashboard.updates +0.1.0+2026.03.06T16.07.48.997Z.0bfff3a0.berickson.20260303.copy.paste.reducer 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 7ffae744..d05e6785 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 @@ -113,12 +113,25 @@ function createProcessTags (document, metrics) { const color = document[metric.id] === 'active' ? 'success' : 'warning'; return createDashComponent( DASH_BOOTSTRAP_COMPONENTS, 'Badge', - { children: document[metric.id], color } + { children: document[metric.id], color, className: 'me-1' } + ); + case 'paste_events': + case 'paste_bins': + case 'total_paste_chars': + case 'paste': + if (!window.WOPasteMetricHelpers?.createPasteMetricComponent) { return null; } + return window.WOPasteMetricHelpers.createPasteMetricComponent(document, metric.id); + case 'copy': + const copyCount = document?.copy_count ?? 0; + const copyColor = copyCount > 0 ? 'primary' : 'secondary' + return createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Badge', + { children: `${copyCount} copies`, color: copyColor } ); default: break; } - }); + }).filter(Boolean); return createDashComponent(DASH_HTML_COMPONENTS, 'Div', { children, className: 'sticky-top' }); } @@ -140,7 +153,7 @@ function studentHasResponded (student, appliedHash) { return true; } -const ClassroomTextHighlightLoadingQueries = ['docs_with_nlp_annotations', 'time_on_task', 'activity']; +const ClassroomTextHighlightLoadingQueries = ['docs_with_nlp_annotations', 'time_on_task', 'activity', 'paste_metrics', 'copy_cut_metrics']; // ── Walkthrough step definitions ────────────────────────────────────── const WALKTHROUGH_STEPS = [ @@ -369,7 +382,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', 'document_sources', 'document_list', 'time_on_task', 'activity'], + target_exports: ['docs_with_nlp_annotations', 'document_sources', 'document_list', 'time_on_task', 'activity', 'paste_metrics', 'copy_cut_metrics'], kwargs: decodedParams } }; 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 07a9ab5a..eefe5a52 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,7 +4,11 @@ PROCESS_OPTIONS = [ {'id': 'process_information', 'label': 'Process Information', 'parent': ''}, {'id': 'time_on_task', 'label': 'Time on Task', 'types': ['metric'], 'parent': 'process_information'}, - {'id': 'status', 'label': 'Status', 'types': ['metric'], 'parent': 'process_information'} + {'id': 'status', 'label': 'Status', 'types': ['metric'], 'parent': 'process_information'}, + {'id': 'paste_events', 'label': '# Paste Events', 'types': ['metric'], 'parent': 'process_information'}, + {'id': 'paste_bins', 'label': 'Binned Paste Events', 'types': ['metric'], 'parent': 'process_information'}, + {'id': 'total_paste_chars', 'label': 'Total Characters Pasted', 'types': ['metric'], 'parent': 'process_information'}, + {'id': 'copy', 'label': 'Copy', 'types': ['metric'], 'parent': 'process_information'} ] OPTIONS = PROCESS_OPTIONS + [ {'id': indicator['id'], 'types': ['highlight'], 'label': indicator['name'], 'parent': indicator['category']} @@ -16,7 +20,11 @@ DEFAULT_VALUE = { 'time_on_task': {'metric': {'value': True}}, - 'status': {'metric': {'value': True}} + 'status': {'metric': {'value': True}}, + 'paste_events': {'metric': {'value': True}}, + 'paste_bins': {'metric': {'value': True}}, + 'total_paste_chars': {'metric': {'value': True}}, + 'copy': {'metric': {'value': True}} } # Set of colors to use for highlighting with presets diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION index 3c8236e9..42ec10d5 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2026.02.27T13.40.48.021Z.a86fc6ac.berickson.20260220.dami.portfolio.pr +0.1.0+2026.03.06T16.07.48.997Z.0bfff3a0.berickson.20260303.copy.paste.reducer diff --git a/modules/writing_observer/writing_observer/copy_paste_utils.py b/modules/writing_observer/writing_observer/copy_paste_utils.py new file mode 100644 index 00000000..c7f7b11d --- /dev/null +++ b/modules/writing_observer/writing_observer/copy_paste_utils.py @@ -0,0 +1,322 @@ +""" +Helpers for extracting copy, cut, and paste signals from writing analytics +events. +""" + +from __future__ import annotations + +import datetime as dt + +PASTE_WAIT_MS = 2500 +MENU_FLAG_MS = 1500 +DEDUP_MS = 750 +BIG_PASTE_THRESHOLD = 200 +MAX_RECENT_PASTE_TEXT_CHARS = 500 +MAX_RECURSION_DEPTH = 50 + + +class Actions: + COPY = frozenset({"copy", "clipboard_copy", "gdocs_copy", "menu_copy", "edit_copy"}) + CUT = frozenset({"cut", "clipboard_cut", "gdocs_cut", "menu_cut", "edit_cut"}) + PASTE = frozenset({"paste", "clipboard_paste", "gdocs_paste", "insert_from_clipboard"}) + MENU_PASTE = frozenset({"menu_paste", "edit_paste", "contextmenu_paste"}) + GDOCS_SAVE = "google_docs_save" + + +def unwrap_event(event): + if isinstance(event, dict) and isinstance(event.get("client"), dict): + return event["client"] + return event if isinstance(event, dict) else {} + + +def _get_event_time(event, client): + """Resolve the timestamp once per event, with fallback.""" + server_time = (event.get("server") or {}).get("time") + if server_time is not None: + return server_time + return client.get("timestamp") or (client.get("metadata") or {}).get("ts") + + +def event_action(client): + action = client.get("action") or client.get("event") or client.get("type") or client.get("event_type") or "" + if action: + return str(action).lower() + keystroke = client.get("keystroke", {}) if isinstance(client.get("keystroke"), dict) else {} + return str(keystroke.get("action") or keystroke.get("type") or "").lower() + + +def keys_info(client): + keystroke = client.get("keystroke", {}) if isinstance(client.get("keystroke"), dict) else {} + key = keystroke.get("key") or client.get("key") or "" + code = keystroke.get("code") or client.get("code") or "" + event_type = keystroke.get("type") or client.get("type") or "" + ctrl = bool(keystroke.get("ctrl") or client.get("ctrl") or keystroke.get("ctrlKey") or client.get("ctrlKey")) + meta = bool(keystroke.get("metaKey") or client.get("metaKey")) + key_code = keystroke.get("keyCode") or client.get("keyCode") or keystroke.get("which") or client.get("which") + return { + "key": str(key).lower() if key else "", + "code": str(code), + "event_type": str(event_type).lower(), + "ctrl_or_meta": bool(ctrl or meta), + "key_code": int(key_code) if isinstance(key_code, (int, float)) else None, + } + + +def _is_key_combo(info, key_char, code_str, key_code_int): + return info["event_type"] == "keydown" and info["ctrl_or_meta"] and ( + info["key"] == key_char or info["code"] == code_str or info["key_code"] == key_code_int + ) + + +def is_copy(client): + action = event_action(client) + if action in Actions.COPY: + return True + return _is_key_combo(keys_info(client), "c", "KeyC", 67) + + +def is_cut(client): + action = event_action(client) + if action in Actions.CUT: + return True + return _is_key_combo(keys_info(client), "x", "KeyX", 88) + + +def is_paste_keyboard(client): + action = event_action(client) + if action in Actions.PASTE: + return True + return _is_key_combo(keys_info(client), "v", "KeyV", 86) + + +def looks_like_menu_paste(client): + action = event_action(client) + if action in Actions.MENU_PASTE: + return True + return action == "contextmenu" + + +def timestamp_ms(event, client=None): + client = client or unwrap_event(event) + value = _get_event_time(event, client) + if isinstance(value, (int, float)): + return int(value * 1000) if value < 10**11 else int(value) + return int(dt.datetime.now(dt.timezone.utc).timestamp() * 1000) + + +def collect_inserted_text(command, output, _depth=0): + if not isinstance(command, dict) or _depth > MAX_RECURSION_DEPTH: + return + + if command.get("ty") == "is": + string_value = command.get("s") + if isinstance(string_value, str) and string_value: + output.append(string_value) + + if isinstance(command.get("nmc"), dict): + collect_inserted_text(command["nmc"], output, _depth + 1) + + for child in command.get("mts") or []: + collect_inserted_text(child, output, _depth + 1) + + +def extract_insert_from_gdocs_save(client): + parts = [] + for bundle in client.get("bundles") or []: + for command in (bundle or {}).get("commands") or []: + collect_inserted_text(command, parts) + return "".join(parts) + + +def paste_length_bin(length): + if length <= 0: + return "none" + if length <= 20: + return "short_1_20" + if length <= 200: + return "medium_21_200" + return "long_201_plus" + + +def append_recent(items, entry, limit=10): + result = list(items or []) + result.append(entry) + return result[-limit:] + + +def clip_recent_paste_text(text, limit=MAX_RECENT_PASTE_TEXT_CHARS): + if text is None: + return None, False + if len(text) <= limit: + return text, False + return text[:limit], True + + +def default_paste_state(): + return { + "paste_count": 0, + "pastes_with_length": 0, + "total_paste_chars": 0, + "max_paste_len": 0, + "last_paste_len": 0, + "big_pastes": 0, + "length_bins": { + "short_1_20": 0, + "medium_21_200": 0, + "long_201_plus": 0, + }, + "recent_pastes": [], + "awaiting_paste_until": 0, + "maybe_menu_paste_until": 0, + "last_paste_signal_ms": 0, + } + + +def default_copy_cut_state(): + return { + "copy_count": 0, + "cut_count": 0, + "last_copy_ts": 0, + "last_cut_ts": 0, + "recent_events": [], + } + + +def update_paste_state(event, state): + state = dict(default_paste_state() if state is None else state) + state["length_bins"] = dict(state.get("length_bins", {})) + state["recent_pastes"] = [dict(p) for p in state.get("recent_pastes", [])] + + client = unwrap_event(event) + ts_ms = timestamp_ms(event, client) + action = event_action(client) + + if is_paste_keyboard(client): + if ts_ms - state.get("last_paste_signal_ms", 0) <= DEDUP_MS: + return False + state["paste_count"] = state.get("paste_count", 0) + 1 + state["last_paste_signal_ms"] = ts_ms + state["awaiting_paste_until"] = ts_ms + PASTE_WAIT_MS + state["recent_pastes"] = append_recent( + state["recent_pastes"], + { + "timestamp_ms": ts_ms, + "length": None, + "source": "keyboard", + "text": None, + "text_truncated": False, + "resolved": False, + }, + ) + return state + + if looks_like_menu_paste(client): + state["maybe_menu_paste_until"] = ts_ms + MENU_FLAG_MS + return state + + if action != Actions.GDOCS_SAVE: + return False + + inserted_text = extract_insert_from_gdocs_save(client) + if not inserted_text: + return False + + paste_length = len(inserted_text) + awaiting_paste_until = state.get("awaiting_paste_until", 0) + maybe_menu_paste_until = state.get("maybe_menu_paste_until", 0) + counted_from_save = False + + if ts_ms <= maybe_menu_paste_until and ts_ms > awaiting_paste_until: + if ts_ms - state.get("last_paste_signal_ms", 0) <= DEDUP_MS: + return False + state["paste_count"] = state.get("paste_count", 0) + 1 + state["last_paste_signal_ms"] = ts_ms + counted_from_save = True + + if ts_ms > awaiting_paste_until and not counted_from_save: + return False + + state["pastes_with_length"] = state.get("pastes_with_length", 0) + 1 + state["total_paste_chars"] = state.get("total_paste_chars", 0) + paste_length + state["max_paste_len"] = max(state.get("max_paste_len", 0), paste_length) + state["last_paste_len"] = paste_length + if paste_length >= BIG_PASTE_THRESHOLD: + state["big_pastes"] = state.get("big_pastes", 0) + 1 + + bin_name = paste_length_bin(paste_length) + if bin_name != "none": + state["length_bins"][bin_name] = state["length_bins"].get(bin_name, 0) + 1 + + clipped_text, was_truncated = clip_recent_paste_text(inserted_text) + + # --- Coalesce: find the pending keyboard entry and enrich it --- + if not counted_from_save: + resolved = False + for entry in reversed(state["recent_pastes"]): + if not entry.get("resolved") and entry.get("source") == "keyboard": + entry["length"] = paste_length + entry["text"] = clipped_text + entry["text_truncated"] = was_truncated + entry["resolved"] = True + resolved = True + break + if not resolved: + # Defensive fallback: no pending entry found, append standalone + state["recent_pastes"] = append_recent( + state["recent_pastes"], + { + "timestamp_ms": ts_ms, + "length": paste_length, + "source": "keyboard", + "text": clipped_text, + "text_truncated": was_truncated, + "resolved": True, + }, + ) + else: + state["recent_pastes"] = append_recent( + state["recent_pastes"], + { + "timestamp_ms": ts_ms, + "length": paste_length, + "source": "menu", + "text": clipped_text, + "text_truncated": was_truncated, + "resolved": True, + }, + ) + + state["awaiting_paste_until"] = 0 + state["maybe_menu_paste_until"] = 0 + return state + + +def update_copy_cut_state(event, state): + state = dict(default_copy_cut_state() if state is None else state) + state["recent_events"] = list(state.get("recent_events", [])) + + client = unwrap_event(event) + ts_ms = timestamp_ms(event, client) + event_type = None + + if is_copy(client): + if ts_ms - state.get("last_copy_ts", 0) <= DEDUP_MS: + return False + state["copy_count"] = state.get("copy_count", 0) + 1 + state["last_copy_ts"] = ts_ms + event_type = "copy" + elif is_cut(client): + if ts_ms - state.get("last_cut_ts", 0) <= DEDUP_MS: + return False + state["cut_count"] = state.get("cut_count", 0) + 1 + state["last_cut_ts"] = ts_ms + event_type = "cut" + + if not event_type: + return False + + state["recent_events"] = append_recent( + state.get("recent_events"), + {"timestamp_ms": ts_ms, "event_type": event_type}, + ) + return state diff --git a/modules/writing_observer/writing_observer/module.py b/modules/writing_observer/writing_observer/module.py index 568bb1fd..6da99bce 100644 --- a/modules/writing_observer/writing_observer/module.py +++ b/modules/writing_observer/writing_observer/module.py @@ -24,6 +24,7 @@ import writing_observer.languagetool import writing_observer.tag_docs import writing_observer.document_timestamps +import writing_observer.copy_paste_utils from writing_observer.nlp_indicators import INDICATOR_JSONS @@ -163,6 +164,8 @@ async def roster_with_provenance(roster, course_id): 'update_docs': update_via_google(runtime=q.parameter("runtime"), doc_ids=q.variable('doc_sources')), "docs_combined": q.join(LEFT=q.variable("docs"), RIGHT=q.variable("roster"), LEFT_ON='provenance.provenance.STUDENT.value.user_id', RIGHT_ON='user_id'), + "paste_metrics": q.select(q.keys('writing_observer.lo_paste_reducer', STUDENTS=q.variable("roster"), STUDENTS_path='user_id', RESOURCES=q.variable("doc_sources"), RESOURCES_path='doc_id'), fields={'pastes_with_length': 'pastes_with_length', 'length_bins': 'length_bins', 'total_paste_chars': 'total_paste_chars'}), + "copy_cut_metrics": q.select(q.keys('writing_observer.lo_copy_cut_reducer', STUDENTS=q.variable("roster"), STUDENTS_path='user_id', RESOURCES=q.variable("doc_sources"), RESOURCES_path='doc_id'), fields={'copy_count': 'copy_count'}), 'nlp': process_texts(writing_data=q.variable('docs'), options=q.parameter('nlp_options', required=False, default=[])), 'nlp_sep_proc': q.select(q.keys('writing_observer.nlp_components', STUDENTS=q.variable('roster'), STUDENTS_path='user_id', RESOURCES=q.variable("doc_ids"), RESOURCES_path='doc_id'), fields='All'), 'nlp_combined': q.join(LEFT=q.variable(nlp_source), LEFT_ON='provenance.provenance.STUDENT.value.user_id', RIGHT=q.variable('roster'), RIGHT_ON='user_id'), @@ -232,6 +235,16 @@ async def roster_with_provenance(roster, course_id): "parameters": ["course_id"], "output": "" }, + "paste_metrics": { + "returns": "paste_metrics", + "parameters": ["course_id"], + "output": "" + }, + "copy_cut_metrics": { + "returns": "copy_cut_metrics", + "parameters": ["course_id"], + "output": "" + }, "roster": { "returns": "roster_with_provenance", "parameters": ["course_id"], @@ -353,6 +366,18 @@ async def roster_with_provenance(roster, course_id): # Incoming event APIs REDUCERS = [ + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.gdoc_scope, + 'function': writing_observer.writing_analysis.lo_paste_reducer, + 'default': writing_observer.copy_paste_utils.default_paste_state() + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.gdoc_scope, + 'function': writing_observer.writing_analysis.lo_copy_cut_reducer, + 'default': writing_observer.copy_paste_utils.default_copy_cut_state() + }, { 'context': "org.mitros.writing_analytics", 'scope': writing_observer.writing_analysis.gdoc_scope, diff --git a/modules/writing_observer/writing_observer/writing_analysis.py b/modules/writing_observer/writing_observer/writing_analysis.py index 85dc8742..e5cde008 100644 --- a/modules/writing_observer/writing_observer/writing_analysis.py +++ b/modules/writing_observer/writing_observer/writing_analysis.py @@ -11,6 +11,7 @@ import re import time +import writing_observer.copy_paste_utils import writing_observer.reconstruct_doc import learning_observer.adapters @@ -35,6 +36,21 @@ # (e.g. all the numbers would go up/down 20%, but behavior was # substantatively identical). +pmss.register_field( + name='time_on_task_threshold', + type=pmss.pmsstypes.TYPES.integer, + description='Maximum time to pass before marking a session as over. '\ + 'Should be 60-300 seconds in production, but 5 seconds is nice for '\ + 'debugging in a local deployment.', + default=60 +) +pmss.register_field( + name='binned_time_on_task_bin_size', + type=pmss.pmsstypes.TYPES.integer, + description='How large (in seconds) to make timestamp bins when '\ + 'recording binned time on task.', + default=600 +) pmss.register_field( name='activity_threshold', type=pmss.pmsstypes.TYPES.integer, @@ -152,6 +168,28 @@ async def event_count(event, internal_state): return state, state +@kvs_pipeline( + scope=gdoc_scope, + null_state=writing_observer.copy_paste_utils.default_paste_state() +) +async def lo_paste_reducer(event, internal_state): + state = writing_observer.copy_paste_utils.update_paste_state(event, internal_state) + if not state: + return False, False + return state, state + + +@kvs_pipeline( + scope=gdoc_scope, + null_state=writing_observer.copy_paste_utils.default_copy_cut_state() +) +async def lo_copy_cut_reducer(event, internal_state): + state = writing_observer.copy_paste_utils.update_copy_cut_state(event, internal_state) + if not state: + return False, False + return state, state + + @kvs_pipeline(scope=student_scope, null_state={}) async def student_profile(event, internal_state): '''Store profile information for a given id @@ -449,7 +487,7 @@ async def last_document(event, internal_state): # Document URls are as follows: # https://docs.google.com/document/d/18JAnmxzVD_lGSfa8t6Se66KLZm30YFrC_4M-D2zdYG4/edit -DOC_URL_re = re.compile("^https://docs.google.com/document/d/(?P[^/\s]+)/(?P[a-zA-Z]+)") # noqa: W605 \s is invalid escape +DOC_URL_re = re.compile("^https://docs.google.com/document/d/(?P[^/\\s]+)/(?P[a-zA-Z]+)") def get_doc_id(event): @@ -481,17 +519,10 @@ def get_doc_id(event): if doc_id: return doc_id - # Failing that pull out the url event. - # Object_value = event.get('client', {}).get('object', None) url = client.get('object', {}).get('url') if not url: return None - # Now test if the object has a URL and if that corresponds - # to a doc edit/review URL as opposed to their main page. - # if so return the id from it. In the off chance the id - # is still not present or is none then this will return - # none. url_match = DOC_URL_re.match(url) if not url_match: return None @@ -499,6 +530,7 @@ 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