From 30ee2d4562293a29b79662b80a9e324e2003c260 Mon Sep 17 00:00:00 2001 From: saminur Date: Mon, 2 Mar 2026 06:28:35 -0500 Subject: [PATCH 1/6] added copycut and paste redcuers --- VERSION | 2 +- modules/writing_observer/VERSION | 2 +- .../writing_observer/copy_paste_utils.py | 283 ++++++++++++++++++ .../writing_observer/module.py | 42 +++ .../writing_observer/writing_analysis.py | 48 ++- 5 files changed, 367 insertions(+), 10 deletions(-) create mode 100644 modules/writing_observer/writing_observer/copy_paste_utils.py diff --git a/VERSION b/VERSION index fff79c93..5d9a8404 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.02.20T19.02.56.547Z.85e344b1.berickson.20260210.dashboard.updates +0.1.0+2026.03.03T13.55.54.711Z.c20cfe16.berickson.20260303.copy.paste.reducer diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION index e5c21f01..5d9a8404 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2026.02.20T12.50.18.986Z.f3ada3ba.berickson.20260210.dashboard.updates +0.1.0+2026.03.03T13.55.54.711Z.c20cfe16.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..61384cb2 --- /dev/null +++ b/modules/writing_observer/writing_observer/copy_paste_utils.py @@ -0,0 +1,283 @@ +""" +Helpers for extracting copy, cut, and paste signals from writing analytics +events. +""" + +from __future__ import annotations + +import datetime as dt +import re + + +PASTE_WAIT_MS = 2500 +MENU_FLAG_MS = 1500 +DEDUP_MS = 750 +BIG_PASTE_THRESHOLD = 200 + +DOC_URL_RE = re.compile(r"^https://docs.google.com/document/d/(?P[^/\s]+)/(?P[a-zA-Z]+)") + + +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_doc_id(event): + client = unwrap_event(event) + doc_id = client.get("doc_id") + if doc_id: + return doc_id + + url = client.get("object", {}).get("url") + if not url or not DOC_URL_RE.match(url): + return None + + return client.get("object", {}).get("id") + + +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_copy(client): + action = event_action(client) + if action in {"copy", "clipboard_copy", "gdocs_copy", "menu_copy", "edit_copy"}: + return True + info = keys_info(client) + return info["event_type"] == "keydown" and info["ctrl_or_meta"] and ( + info["key"] == "c" or info["code"] == "KeyC" or info["key_code"] == 67 + ) + + +def is_cut(client): + action = event_action(client) + if action in {"cut", "clipboard_cut", "gdocs_cut", "menu_cut", "edit_cut"}: + return True + info = keys_info(client) + return info["event_type"] == "keydown" and info["ctrl_or_meta"] and ( + info["key"] == "x" or info["code"] == "KeyX" or info["key_code"] == 88 + ) + + +def is_paste_keyboard(client): + action = event_action(client) + if action in {"paste", "clipboard_paste", "gdocs_paste", "insert_from_clipboard"}: + return True + info = keys_info(client) + return info["event_type"] == "keydown" and info["ctrl_or_meta"] and ( + info["key"] == "v" or info["code"] == "KeyV" or info["key_code"] == 86 + ) + + +def looks_like_menu_paste(client): + action = event_action(client) + if action in {"menu_paste", "edit_paste", "contextmenu_paste"}: + return True + return action == "contextmenu" + + +def timestamp_ms(event, client=None): + client = client or unwrap_event(event) + for source in (client, event): + for key in ("timestamp", "ts", "time", "t"): + value = source.get(key) + if isinstance(value, (int, float)): + return int(value * 1000) if value < 10**11 else int(value) + + server = event.get("server", {}) if isinstance(event, dict) else {} + value = server.get("time") if isinstance(server, dict) else None + if isinstance(value, (int, float)): + return int(value * 1000) + + return int(dt.datetime.utcnow().timestamp() * 1000) + + +def collect_inserted_text(command, output): + if not isinstance(command, dict): + 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) + + for child in command.get("mts") or []: + collect_inserted_text(child, output) + + +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): + items = list(items or []) + items.append(entry) + if len(items) > limit: + items = items[-limit:] + return items + + +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) + client = event.get("client", {}) or {} + ts_ms = timestamp_ms(event, 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.get("recent_pastes"), + {"timestamp_ms": ts_ms, "length": None, "source": "keyboard_signal"}, + ) + return state + + if looks_like_menu_paste(client): + state["maybe_menu_paste_until"] = ts_ms + MENU_FLAG_MS + return state + + if event_action(client) != "google_docs_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.setdefault("length_bins", {}) + state["length_bins"][bin_name] = state["length_bins"].get(bin_name, 0) + 1 + + state["recent_pastes"] = append_recent( + state.get("recent_pastes"), + { + "timestamp_ms": ts_ms, + "length": paste_length, + "source": "menu_inferred" if counted_from_save else "google_docs_save", + }, + ) + 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) + client = event.get("client", {}) or {} + 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 5036c853..fa9ec0ff 100644 --- a/modules/writing_observer/writing_observer/module.py +++ b/modules/writing_observer/writing_observer/module.py @@ -99,6 +99,8 @@ 'update_docs': update_via_google(runtime=q.parameter("runtime"), doc_ids=q.variable('doc_sources')), "docs": q.select(q.keys('writing_observer.reconstruct', STUDENTS=q.variable("roster"), STUDENTS_path='user_id', RESOURCES=q.variable("update_docs"), RESOURCES_path='doc_id'), fields={'text': 'text'}), "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='All'), + "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='All'), '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'), @@ -168,6 +170,16 @@ "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", "parameters": ["course_id"], @@ -264,6 +276,36 @@ # 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': { + '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 + } + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.gdoc_scope, + 'function': writing_observer.writing_analysis.lo_copy_cut_reducer, + 'default': { + 'copy_count': 0, + 'cut_count': 0, + 'last_copy_ts': 0, + 'last_cut_ts': 0, + 'recent_events': [] + } + }, { '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 From a6c4ab954d8d6f48d05ca4b7ee7ee76b347c2593 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 3 Mar 2026 12:42:08 -0500 Subject: [PATCH 2/6] updated reducers to call funcs for default value --- VERSION | 2 +- modules/writing_observer/VERSION | 2 +- .../writing_observer/module.py | 23 +++---------------- 3 files changed, 5 insertions(+), 22 deletions(-) diff --git a/VERSION b/VERSION index 5d9a8404..d6d6ba1d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.03.03T13.55.54.711Z.c20cfe16.berickson.20260303.copy.paste.reducer +0.1.0+2026.03.03T17.42.08.714Z.30ee2d45.berickson.20260303.copy.paste.reducer diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION index 5d9a8404..d6d6ba1d 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2026.03.03T13.55.54.711Z.c20cfe16.berickson.20260303.copy.paste.reducer +0.1.0+2026.03.03T17.42.08.714Z.30ee2d45.berickson.20260303.copy.paste.reducer diff --git a/modules/writing_observer/writing_observer/module.py b/modules/writing_observer/writing_observer/module.py index fa9ec0ff..fe7b87c5 100644 --- a/modules/writing_observer/writing_observer/module.py +++ b/modules/writing_observer/writing_observer/module.py @@ -21,6 +21,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 @@ -280,31 +281,13 @@ 'context': "org.mitros.writing_analytics", 'scope': writing_observer.writing_analysis.gdoc_scope, 'function': writing_observer.writing_analysis.lo_paste_reducer, - 'default': { - '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 - } + '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': { - 'copy_count': 0, - 'cut_count': 0, - 'last_copy_ts': 0, - 'last_cut_ts': 0, - 'recent_events': [] - } + 'default': writing_observer.copy_paste_utils.default_copy_cut_state() }, { 'context': "org.mitros.writing_analytics", From c3581ff221531099676d32cf3d37059ee8e441c0 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 3 Mar 2026 12:43:10 -0500 Subject: [PATCH 3/6] added pasted text into object --- VERSION | 2 +- modules/writing_observer/VERSION | 2 +- .../writing_observer/copy_paste_utils.py | 20 ++++++++++++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index d6d6ba1d..07bee2ac 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.03.03T17.42.08.714Z.30ee2d45.berickson.20260303.copy.paste.reducer +0.1.0+2026.03.03T17.43.10.782Z.a6c4ab95.berickson.20260303.copy.paste.reducer diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION index d6d6ba1d..07bee2ac 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2026.03.03T17.42.08.714Z.30ee2d45.berickson.20260303.copy.paste.reducer +0.1.0+2026.03.03T17.43.10.782Z.a6c4ab95.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 index 61384cb2..94de5fc1 100644 --- a/modules/writing_observer/writing_observer/copy_paste_utils.py +++ b/modules/writing_observer/writing_observer/copy_paste_utils.py @@ -13,6 +13,7 @@ MENU_FLAG_MS = 1500 DEDUP_MS = 750 BIG_PASTE_THRESHOLD = 200 +MAX_RECENT_PASTE_TEXT_CHARS = 500 DOC_URL_RE = re.compile(r"^https://docs.google.com/document/d/(?P[^/\s]+)/(?P[a-zA-Z]+)") @@ -156,6 +157,14 @@ def append_recent(items, entry, limit=10): return items +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, @@ -199,7 +208,13 @@ def update_paste_state(event, state): state["awaiting_paste_until"] = ts_ms + PASTE_WAIT_MS state["recent_pastes"] = append_recent( state.get("recent_pastes"), - {"timestamp_ms": ts_ms, "length": None, "source": "keyboard_signal"}, + { + "timestamp_ms": ts_ms, + "length": None, + "source": "keyboard_signal", + "text": None, + "text_truncated": False, + } ) return state @@ -241,12 +256,15 @@ def update_paste_state(event, state): state.setdefault("length_bins", {}) state["length_bins"][bin_name] = state["length_bins"].get(bin_name, 0) + 1 + clipped_text, was_truncated = clip_recent_paste_text(inserted_text) state["recent_pastes"] = append_recent( state.get("recent_pastes"), { "timestamp_ms": ts_ms, "length": paste_length, "source": "menu_inferred" if counted_from_save else "google_docs_save", + "text": clipped_text, + "text_truncated": was_truncated, }, ) state["awaiting_paste_until"] = 0 From ab2a2552764b1958cdf1c7df457d95c6e48d3c60 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Wed, 4 Mar 2026 08:36:06 -0500 Subject: [PATCH 4/6] cleaned up copy paste utils file --- VERSION | 2 +- modules/writing_observer/VERSION | 2 +- .../writing_observer/copy_paste_utils.py | 155 ++++++++++-------- 3 files changed, 90 insertions(+), 69 deletions(-) diff --git a/VERSION b/VERSION index 07bee2ac..a81ee59f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.03.03T17.43.10.782Z.a6c4ab95.berickson.20260303.copy.paste.reducer +0.1.0+2026.03.04T13.36.06.771Z.c3581ff2.berickson.20260303.copy.paste.reducer diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION index 07bee2ac..a81ee59f 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2026.03.03T17.43.10.782Z.a6c4ab95.berickson.20260303.copy.paste.reducer +0.1.0+2026.03.04T13.36.06.771Z.c3581ff2.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 index 94de5fc1..c7f7b11d 100644 --- a/modules/writing_observer/writing_observer/copy_paste_utils.py +++ b/modules/writing_observer/writing_observer/copy_paste_utils.py @@ -6,16 +6,21 @@ from __future__ import annotations import datetime as dt -import re - 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 + -DOC_URL_RE = re.compile(r"^https://docs.google.com/document/d/(?P[^/\s]+)/(?P[a-zA-Z]+)") +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): @@ -24,17 +29,12 @@ def unwrap_event(event): return event if isinstance(event, dict) else {} -def get_doc_id(event): - client = unwrap_event(event) - doc_id = client.get("doc_id") - if doc_id: - return doc_id - - url = client.get("object", {}).get("url") - if not url or not DOC_URL_RE.match(url): - return None - - return client.get("object", {}).get("id") +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): @@ -62,61 +62,50 @@ def keys_info(client): } +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 {"copy", "clipboard_copy", "gdocs_copy", "menu_copy", "edit_copy"}: + if action in Actions.COPY: return True - info = keys_info(client) - return info["event_type"] == "keydown" and info["ctrl_or_meta"] and ( - info["key"] == "c" or info["code"] == "KeyC" or info["key_code"] == 67 - ) + return _is_key_combo(keys_info(client), "c", "KeyC", 67) def is_cut(client): action = event_action(client) - if action in {"cut", "clipboard_cut", "gdocs_cut", "menu_cut", "edit_cut"}: + if action in Actions.CUT: return True - info = keys_info(client) - return info["event_type"] == "keydown" and info["ctrl_or_meta"] and ( - info["key"] == "x" or info["code"] == "KeyX" or info["key_code"] == 88 - ) + return _is_key_combo(keys_info(client), "x", "KeyX", 88) def is_paste_keyboard(client): action = event_action(client) - if action in {"paste", "clipboard_paste", "gdocs_paste", "insert_from_clipboard"}: + if action in Actions.PASTE: return True - info = keys_info(client) - return info["event_type"] == "keydown" and info["ctrl_or_meta"] and ( - info["key"] == "v" or info["code"] == "KeyV" or info["key_code"] == 86 - ) + return _is_key_combo(keys_info(client), "v", "KeyV", 86) def looks_like_menu_paste(client): action = event_action(client) - if action in {"menu_paste", "edit_paste", "contextmenu_paste"}: + if action in Actions.MENU_PASTE: return True return action == "contextmenu" def timestamp_ms(event, client=None): client = client or unwrap_event(event) - for source in (client, event): - for key in ("timestamp", "ts", "time", "t"): - value = source.get(key) - if isinstance(value, (int, float)): - return int(value * 1000) if value < 10**11 else int(value) - - server = event.get("server", {}) if isinstance(event, dict) else {} - value = server.get("time") if isinstance(server, dict) else None + value = _get_event_time(event, client) if isinstance(value, (int, float)): - return int(value * 1000) + return int(value * 1000) if value < 10**11 else int(value) + return int(dt.datetime.now(dt.timezone.utc).timestamp() * 1000) - return int(dt.datetime.utcnow().timestamp() * 1000) - -def collect_inserted_text(command, output): - if not isinstance(command, dict): +def collect_inserted_text(command, output, _depth=0): + if not isinstance(command, dict) or _depth > MAX_RECURSION_DEPTH: return if command.get("ty") == "is": @@ -125,10 +114,10 @@ def collect_inserted_text(command, output): output.append(string_value) if isinstance(command.get("nmc"), dict): - collect_inserted_text(command["nmc"], output) + collect_inserted_text(command["nmc"], output, _depth + 1) for child in command.get("mts") or []: - collect_inserted_text(child, output) + collect_inserted_text(child, output, _depth + 1) def extract_insert_from_gdocs_save(client): @@ -150,11 +139,9 @@ def paste_length_bin(length): def append_recent(items, entry, limit=10): - items = list(items or []) - items.append(entry) - if len(items) > limit: - items = items[-limit:] - return items + result = list(items or []) + result.append(entry) + return result[-limit:] def clip_recent_paste_text(text, limit=MAX_RECENT_PASTE_TEXT_CHARS): @@ -197,8 +184,12 @@ def default_copy_cut_state(): def update_paste_state(event, state): state = dict(default_paste_state() if state is None else state) - client = event.get("client", {}) or {} + 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: @@ -207,14 +198,15 @@ def update_paste_state(event, state): state["last_paste_signal_ms"] = ts_ms state["awaiting_paste_until"] = ts_ms + PASTE_WAIT_MS state["recent_pastes"] = append_recent( - state.get("recent_pastes"), + state["recent_pastes"], { "timestamp_ms": ts_ms, "length": None, - "source": "keyboard_signal", + "source": "keyboard", "text": None, "text_truncated": False, - } + "resolved": False, + }, ) return state @@ -222,7 +214,7 @@ def update_paste_state(event, state): state["maybe_menu_paste_until"] = ts_ms + MENU_FLAG_MS return state - if event_action(client) != "google_docs_save": + if action != Actions.GDOCS_SAVE: return False inserted_text = extract_insert_from_gdocs_save(client) @@ -253,20 +245,47 @@ def update_paste_state(event, state): bin_name = paste_length_bin(paste_length) if bin_name != "none": - state.setdefault("length_bins", {}) state["length_bins"][bin_name] = state["length_bins"].get(bin_name, 0) + 1 clipped_text, was_truncated = clip_recent_paste_text(inserted_text) - state["recent_pastes"] = append_recent( - state.get("recent_pastes"), - { - "timestamp_ms": ts_ms, - "length": paste_length, - "source": "menu_inferred" if counted_from_save else "google_docs_save", - "text": clipped_text, - "text_truncated": was_truncated, - }, - ) + + # --- 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 @@ -274,7 +293,9 @@ def update_paste_state(event, state): def update_copy_cut_state(event, state): state = dict(default_copy_cut_state() if state is None else state) - client = event.get("client", {}) or {} + state["recent_events"] = list(state.get("recent_events", [])) + + client = unwrap_event(event) ts_ms = timestamp_ms(event, client) event_type = None From 0bfff3a07b5c34fa33210378d266ec39b728094e Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Thu, 5 Mar 2026 09:05:55 -0500 Subject: [PATCH 5/6] added paste metric to dashboard --- VERSION | 2 +- modules/wo_bulk_essay_analysis/VERSION | 2 +- .../wo_bulk_essay_analysis/assets/scripts.js | 11 +++++++++-- modules/wo_classroom_text_highlighter/VERSION | 2 +- .../wo_classroom_text_highlighter/assets/scripts.js | 13 ++++++++++--- .../wo_classroom_text_highlighter/options.py | 6 ++++-- modules/writing_observer/VERSION | 2 +- modules/writing_observer/writing_observer/module.py | 2 +- 8 files changed, 28 insertions(+), 12 deletions(-) diff --git a/VERSION b/VERSION index a81ee59f..94ac92ee 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.03.04T13.36.06.771Z.c3581ff2.berickson.20260303.copy.paste.reducer +0.1.0+2026.03.05T14.05.55.178Z.ab2a2552.berickson.20260303.copy.paste.reducer diff --git a/modules/wo_bulk_essay_analysis/VERSION b/modules/wo_bulk_essay_analysis/VERSION index fff79c93..94ac92ee 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.05T14.05.55.178Z.ab2a2552.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..56d65b46 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 @@ -32,6 +32,13 @@ function createProcessTags (document, metrics) { DASH_BOOTSTRAP_COMPONENTS, 'Badge', { children: document[metric.id], color } ); + case 'paste': + const pasteCount = document?.pastes_with_length ?? 0; + const pasteColor = pasteCount > 0 ? 'warning' : 'secondary' + return createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Badge', + { children: `${pasteCount} pastes`, color: pasteColor } + ); default: break; } @@ -214,7 +221,7 @@ const fileTextExtractors = { docx: extractDOCX }; -const AIAssistantLoadingQueries = ['gpt_bulk', 'time_on_task', 'activity']; +const AIAssistantLoadingQueries = ['gpt_bulk', 'time_on_task', 'activity', 'paste_metrics']; // ── Walkthrough step definitions ────────────────────────────────────── const BULK_WALKTHROUGH_STEPS = [ @@ -633,7 +640,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'], kwargs: decoded } }; diff --git a/modules/wo_classroom_text_highlighter/VERSION b/modules/wo_classroom_text_highlighter/VERSION index fff79c93..94ac92ee 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.05T14.05.55.178Z.ab2a2552.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..a9d7906b 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,7 +113,14 @@ 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': + const pasteCount = document?.pastes_with_length ?? 0; + const pasteColor = pasteCount > 0 ? 'warning' : 'secondary' + return createDashComponent( + DASH_BOOTSTRAP_COMPONENTS, 'Badge', + { children: `${pasteCount} pastes`, color: pasteColor } ); default: break; @@ -140,7 +147,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']; // ── Walkthrough step definitions ────────────────────────────────────── const WALKTHROUGH_STEPS = [ @@ -369,7 +376,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'], 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..22be1217 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,8 @@ 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', 'label': 'Paste', 'types': ['metric'], 'parent': 'process_information'} ] OPTIONS = PROCESS_OPTIONS + [ {'id': indicator['id'], 'types': ['highlight'], 'label': indicator['name'], 'parent': indicator['category']} @@ -16,7 +17,8 @@ DEFAULT_VALUE = { 'time_on_task': {'metric': {'value': True}}, - 'status': {'metric': {'value': True}} + 'status': {'metric': {'value': True}}, + 'paste': {'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 a81ee59f..94ac92ee 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2026.03.04T13.36.06.771Z.c3581ff2.berickson.20260303.copy.paste.reducer +0.1.0+2026.03.05T14.05.55.178Z.ab2a2552.berickson.20260303.copy.paste.reducer diff --git a/modules/writing_observer/writing_observer/module.py b/modules/writing_observer/writing_observer/module.py index fe7b87c5..72f4b292 100644 --- a/modules/writing_observer/writing_observer/module.py +++ b/modules/writing_observer/writing_observer/module.py @@ -100,7 +100,7 @@ 'update_docs': update_via_google(runtime=q.parameter("runtime"), doc_ids=q.variable('doc_sources')), "docs": q.select(q.keys('writing_observer.reconstruct', STUDENTS=q.variable("roster"), STUDENTS_path='user_id', RESOURCES=q.variable("update_docs"), RESOURCES_path='doc_id'), fields={'text': 'text'}), "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='All'), + "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'}), "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='All'), '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'), From 85f77ddb7751631789699ea8294035cd9739fc98 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Fri, 6 Mar 2026 11:07:48 -0500 Subject: [PATCH 6/6] added more paste options and a copy option --- VERSION | 2 +- modules/wo_bulk_essay_analysis/VERSION | 2 +- .../wo_bulk_essay_analysis/assets/scripts.js | 73 +++++++++++++++++-- modules/wo_classroom_text_highlighter/VERSION | 2 +- .../assets/scripts.js | 18 +++-- .../wo_classroom_text_highlighter/options.py | 10 ++- modules/writing_observer/VERSION | 2 +- .../writing_observer/module.py | 4 +- 8 files changed, 93 insertions(+), 20 deletions(-) diff --git a/VERSION b/VERSION index 94ac92ee..42ec10d5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.03.05T14.05.55.178Z.ab2a2552.berickson.20260303.copy.paste.reducer +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 94ac92ee..42ec10d5 100644 --- a/modules/wo_bulk_essay_analysis/VERSION +++ b/modules/wo_bulk_essay_analysis/VERSION @@ -1 +1 @@ -0.1.0+2026.03.05T14.05.55.178Z.ab2a2552.berickson.20260303.copy.paste.reducer +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 56d65b46..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,17 +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': - const pasteCount = document?.pastes_with_length ?? 0; - const pasteColor = pasteCount > 0 ? 'warning' : 'secondary' + 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: `${pasteCount} pastes`, color: pasteColor } + { children: `${copyCount} copies`, color: copyColor } ); default: break; } - }); + }).filter(Boolean); return createDashComponent(DASH_HTML_COMPONENTS, 'Div', { children, className: 'sticky-top' }); } @@ -221,7 +282,7 @@ const fileTextExtractors = { docx: extractDOCX }; -const AIAssistantLoadingQueries = ['gpt_bulk', 'time_on_task', 'activity', 'paste_metrics']; +const AIAssistantLoadingQueries = ['gpt_bulk', 'time_on_task', 'activity', 'paste_metrics', 'copy_cut_metrics']; // ── Walkthrough step definitions ────────────────────────────────────── const BULK_WALKTHROUGH_STEPS = [ @@ -640,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', 'paste_metrics'], + 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 94ac92ee..42ec10d5 100644 --- a/modules/wo_classroom_text_highlighter/VERSION +++ b/modules/wo_classroom_text_highlighter/VERSION @@ -1 +1 @@ -0.1.0+2026.03.05T14.05.55.178Z.ab2a2552.berickson.20260303.copy.paste.reducer +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 a9d7906b..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 @@ -115,17 +115,23 @@ function createProcessTags (document, metrics) { DASH_BOOTSTRAP_COMPONENTS, 'Badge', { children: document[metric.id], color, className: 'me-1' } ); + case 'paste_events': + case 'paste_bins': + case 'total_paste_chars': case 'paste': - const pasteCount = document?.pastes_with_length ?? 0; - const pasteColor = pasteCount > 0 ? 'warning' : 'secondary' + 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: `${pasteCount} pastes`, color: pasteColor } + { children: `${copyCount} copies`, color: copyColor } ); default: break; } - }); + }).filter(Boolean); return createDashComponent(DASH_HTML_COMPONENTS, 'Div', { children, className: 'sticky-top' }); } @@ -147,7 +153,7 @@ function studentHasResponded (student, appliedHash) { return true; } -const ClassroomTextHighlightLoadingQueries = ['docs_with_nlp_annotations', 'time_on_task', 'activity', 'paste_metrics']; +const ClassroomTextHighlightLoadingQueries = ['docs_with_nlp_annotations', 'time_on_task', 'activity', 'paste_metrics', 'copy_cut_metrics']; // ── Walkthrough step definitions ────────────────────────────────────── const WALKTHROUGH_STEPS = [ @@ -376,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', 'paste_metrics'], + 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 22be1217..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 @@ -5,7 +5,10 @@ {'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': 'paste', 'label': 'Paste', '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']} @@ -18,7 +21,10 @@ DEFAULT_VALUE = { 'time_on_task': {'metric': {'value': True}}, 'status': {'metric': {'value': True}}, - 'paste': {'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 94ac92ee..42ec10d5 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2026.03.05T14.05.55.178Z.ab2a2552.berickson.20260303.copy.paste.reducer +0.1.0+2026.03.06T16.07.48.997Z.0bfff3a0.berickson.20260303.copy.paste.reducer diff --git a/modules/writing_observer/writing_observer/module.py b/modules/writing_observer/writing_observer/module.py index 72f4b292..2e3a1dca 100644 --- a/modules/writing_observer/writing_observer/module.py +++ b/modules/writing_observer/writing_observer/module.py @@ -100,8 +100,8 @@ 'update_docs': update_via_google(runtime=q.parameter("runtime"), doc_ids=q.variable('doc_sources')), "docs": q.select(q.keys('writing_observer.reconstruct', STUDENTS=q.variable("roster"), STUDENTS_path='user_id', RESOURCES=q.variable("update_docs"), RESOURCES_path='doc_id'), fields={'text': 'text'}), "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'}), - "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='All'), + "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'),