diff --git a/tools/delete_bookmarks_word_not_in_context.py b/tools/delete_bookmarks_word_not_in_context.py index a263d9685..33afb11e2 100644 --- a/tools/delete_bookmarks_word_not_in_context.py +++ b/tools/delete_bookmarks_word_not_in_context.py @@ -24,6 +24,7 @@ app.app_context().push() from zeeguu.core.model.bookmark import Bookmark +from zeeguu.core.model.study_choice import UnfitReason import unicodedata @@ -180,7 +181,7 @@ def delete_bookmarks(bookmark_ids, dry_run=True): else: # No other bookmarks exist - mark as unfit for study AND clear schedule # (Keep UserWord for historical data) - user_word.set_unfit_for_study(db.session) + user_word.set_unfit_for_study(db.session, UnfitReason.NO_USABLE_CONTEXT) userwords_updated += 1 # Delete the bookmark diff --git a/tools/migrations/26-10-25--user_word_study_choice.sql b/tools/migrations/26-10-25--user_word_study_choice.sql new file mode 100644 index 000000000..4e4dbd954 --- /dev/null +++ b/tools/migrations/26-10-25--user_word_study_choice.sql @@ -0,0 +1,54 @@ +-- user_word.user_preference -> study_choice + unfit_reason (roadmap 0b). +-- user_preference itself stays, unused, until a later migration drops it. +-- After this, run tools/recompute_fit_after_study_choice.py for the words +-- that lose their forced "use in exercises" (step 3). + +ALTER TABLE user_word + ADD COLUMN study_choice ENUM('study','skip','known') NULL DEFAULT NULL, + ADD COLUMN unfit_reason ENUM('rare_for_beginner','same_as_translation','too_short', + 'too_many_words','arbitrary_multi_word','bad_contexts','separated_mwe', + 'validator_gave_up','no_usable_context', + 'validator_invalid','replaced_by_fix','duplicate_meaning') NULL DEFAULT NULL; + +-- 1. The learner's skips and "I know this" +UPDATE user_word SET study_choice = 'skip' WHERE user_preference = '-1'; +UPDATE user_word SET study_choice = 'known' WHERE user_preference IN ('-6', '-4', '-2'); + +-- 2. -100: #896's write on the old word of a validator fix, else a learner's +-- "bad translation", which stays out as a skip until reports exist +UPDATE user_word uw +SET uw.unfit_reason = 'replaced_by_fix' +WHERE uw.user_preference = '-100' + AND EXISTS (SELECT 1 FROM validation_log v + WHERE v.action = 'fixed' AND v.user_word_id = uw.id AND v.meaning_id = uw.meaning_id); +UPDATE user_word SET study_choice = 'skip' WHERE user_preference = '-100' AND unfit_reason IS NULL; + +-- 3. 1 is the learner's "study", except the opt-ins #895's bug forced on the +-- target of a validator fix with no sign of the learner choosing it: no star, +-- not added by them, no "set to study" event (roadmap item 0) +UPDATE user_word uw +JOIN meaning m ON m.id = uw.meaning_id +JOIN phrase p ON p.id = m.origin_id +SET uw.study_choice = 'study' +WHERE uw.user_preference = '1' + AND NOT ( + uw.is_user_added = 0 + AND EXISTS (SELECT 1 FROM validation_log v + WHERE v.action = 'fixed' AND v.new_meaning_id = uw.meaning_id) + AND NOT EXISTS (SELECT 1 FROM bookmark b + WHERE b.user_word_id = uw.id + AND (b.starred = 1 OR b.translation_source = 'user_added')) + AND NOT EXISTS (SELECT 1 FROM user_activity_data a + WHERE a.user_id = uw.user_id + AND a.event = 'USER SET WORD TO STUDY' + AND a.value = p.content) + ); + +-- 4. Words the validator found wrong with no correction: a sticky verdict. +-- Other unfit words get their reason the next time they are recomputed. +UPDATE user_word uw +SET uw.unfit_reason = 'validator_invalid' +WHERE uw.fit_for_study = 0 + AND uw.unfit_reason IS NULL + AND EXISTS (SELECT 1 FROM validation_log v + WHERE v.action = 'invalid' AND v.user_word_id = uw.id AND v.meaning_id = uw.meaning_id); diff --git a/tools/recompute_fit_after_study_choice.py b/tools/recompute_fit_after_study_choice.py new file mode 100644 index 000000000..bdc40749f --- /dev/null +++ b/tools/recompute_fit_after_study_choice.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +""" +Run once after migration 26-10-25--user_word_study_choice.sql. The words whose +forced "use in exercises" (#895) was not carried over as the learner's study +choice are judged by the quality checks again; those that fail them leave +study. Prints what changes; pass --apply to write. +""" +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from zeeguu.api.app import create_app_for_scripts +from zeeguu.core.model import db + +app = create_app_for_scripts() +app.app_context().push() + +from sqlalchemy import text + +from zeeguu.core.model.user_word import UserWord + +apply = "--apply" in sys.argv + +# user_preference is no longer on the model; the column is still in the table +ids = [ + row[0] + for row in db.session.execute( + text("SELECT id FROM user_word WHERE user_preference = '1' AND study_choice IS NULL") + ) +] +print(f"{len(ids)} words lost a forced opt-in") + +leaving = 0 +for user_word_id in ids: + user_word = db.session.get(UserWord, user_word_id) + was_fit = user_word.fit_for_study + user_word.update_fit_for_study(db.session) + if was_fit and not user_word.fit_for_study: + leaving += 1 + print(f" {user_word.id} {user_word.meaning.origin.content!r} -> " + f"{user_word.meaning.translation.content!r}: {user_word.unfit_reason}") + if apply: + db.session.commit() + else: + db.session.rollback() + +print(f"{leaving} leave study{'' if apply else ' (dry run; --apply to write)'}") diff --git a/zeeguu/api/endpoints/bookmarks_and_words.py b/zeeguu/api/endpoints/bookmarks_and_words.py index 185ec4ee4..61f54f4fa 100644 --- a/zeeguu/api/endpoints/bookmarks_and_words.py +++ b/zeeguu/api/endpoints/bookmarks_and_words.py @@ -8,7 +8,7 @@ from zeeguu.core.model import User, Article, Bookmark from zeeguu.core.model.context_identifier import ContextIdentifier -from zeeguu.core.model.bookmark_user_preference import UserWordExPreference +from zeeguu.core.model.study_choice import StudyChoice, UnfitReason from zeeguu.core.model.example_sentence_context import ExampleSentenceContext from . import api, db_session from zeeguu.api.utils.json_result import json_result @@ -268,10 +268,10 @@ def delete_bookmark(bookmark_id): else: # preserve UserWord but mark it as not fit for study # in the future we can generate an example for this user word with the help of the robots! - user_word.set_unfit_for_study(db_session) + user_word.set_unfit_for_study(db_session, UnfitReason.NO_USABLE_CONTEXT) else: # No other bookmarks exist - ALWAYS keep the user_word for historical data - user_word.set_unfit_for_study(db_session) + user_word.set_unfit_for_study(db_session, UnfitReason.NO_USABLE_CONTEXT) # Delete any ExampleSentenceContext records that reference this bookmark ExampleSentenceContext.query.filter( @@ -297,9 +297,7 @@ def set_user_word_exercise_preference(bookmark_id): bookmark = get_bookmark_if_owned(bookmark_id) user_word = bookmark.user_word - # Fix: set preference on UserWord, not Bookmark - user_word.user_preference = UserWordExPreference.USE_IN_EXERCISES - user_word.update_fit_for_study(db_session) + user_word.choose(db_session, StudyChoice.STUDY) # Ensure a schedule exists for this UserWord if user_word.fit_for_study: @@ -321,21 +319,21 @@ def set_user_word_exercise_dislike(bookmark_id): reason = request.form.get("reason", "") if reason == "declared_known": - user_word.user_preference = UserWordExPreference.DECLARED_KNOWN + user_word.choose(db_session, StudyChoice.KNOWN) UserWordInteractionHistory.log( db_session, user_word, UserWordInteractionHistory.DECLARED_KNOWN, ) elif reason == "bad_translation": - user_word.user_preference = UserWordExPreference.BAD_TRANSLATION + # Until reports exist (roadmap 0b): kept out like any skip + user_word.choose(db_session, StudyChoice.SKIP) UserWordInteractionHistory.log( db_session, user_word, UserWordInteractionHistory.BAD_TRANSLATION, ) else: - user_word.user_preference = UserWordExPreference.DONT_USE_IN_EXERCISES + user_word.choose(db_session, StudyChoice.SKIP) - user_word.update_fit_for_study(db_session) BasicSRSchedule.clear_user_word_schedule(db_session, user_word) db_session.commit() return "OK" diff --git a/zeeguu/api/endpoints/generated_examples.py b/zeeguu/api/endpoints/generated_examples.py index 84d3dc9fc..75c912004 100644 --- a/zeeguu/api/endpoints/generated_examples.py +++ b/zeeguu/api/endpoints/generated_examples.py @@ -14,7 +14,7 @@ from zeeguu.core.llm_services.llm_service import prepare_learning_card from zeeguu.core.model import UserWord, User, Bookmark, Language, Meaning, MeaningReport, TranslationService from zeeguu.core.model.ai_generator import AIGenerator -from zeeguu.core.model.bookmark_user_preference import UserWordExPreference +from zeeguu.core.model.study_choice import StudyChoice from zeeguu.core.model.context_identifier import ContextIdentifier from zeeguu.core.model.context_type import ContextType from zeeguu.core.model.example_sentence import ExampleSentence @@ -576,8 +576,7 @@ def add_word_to_learning(): db_session, user, meaning, is_user_added=True ) - user_word.user_preference = UserWordExPreference.USE_IN_EXERCISES - user_word.update_fit_for_study(db_session) + user_word.choose(db_session, StudyChoice.STUDY) # Validate word position in example validation_result = validate_single_occurrence(final_word, final_example, origin_lang) diff --git a/zeeguu/core/account_management/user_account_merge.py b/zeeguu/core/account_management/user_account_merge.py index b9a2fb412..a76ab0d12 100644 --- a/zeeguu/core/account_management/user_account_merge.py +++ b/zeeguu/core/account_management/user_account_merge.py @@ -311,7 +311,7 @@ def _user_word_rows(user_id): row.meaning_id: row for row in _exec( """ - SELECT uw.id, uw.meaning_id, uw.fit_for_study, uw.user_preference, + SELECT uw.id, uw.meaning_id, uw.fit_for_study, uw.study_choice, uw.unfit_reason, uw.learned_time, uw.level, uw.is_user_added, uw.preferred_bookmark_id, (SELECT COUNT(*) FROM bookmark b WHERE b.user_word_id = uw.id) @@ -357,14 +357,16 @@ def _merge_user_word_pair(keep, drop): """ UPDATE user_word SET fit_for_study = COALESCE(fit_for_study, :fit), - user_preference = COALESCE(user_preference, :pref), + study_choice = COALESCE(study_choice, :choice), + unfit_reason = COALESCE(unfit_reason, :reason), learned_time = COALESCE(learned_time, :learned), is_user_added = CASE WHEN :added = 1 THEN 1 ELSE is_user_added END, preferred_bookmark_id = COALESCE(preferred_bookmark_id, :bm) WHERE id = :keep """, fit=drop.fit_for_study, - pref=drop.user_preference, + choice=drop.study_choice, + reason=drop.unfit_reason, learned=drop.learned_time, added=1 if drop.is_user_added else 0, bm=drop.preferred_bookmark_id, diff --git a/zeeguu/core/bookmark_operations/add_word_to_study.py b/zeeguu/core/bookmark_operations/add_word_to_study.py index c1b8667e3..7d0482dda 100644 --- a/zeeguu/core/bookmark_operations/add_word_to_study.py +++ b/zeeguu/core/bookmark_operations/add_word_to_study.py @@ -11,7 +11,7 @@ anything is created. """ -from zeeguu.core.model.bookmark_user_preference import UserWordExPreference +from zeeguu.core.model.study_choice import StudyChoice from zeeguu.core.model.context_identifier import ContextIdentifier from zeeguu.core.model.context_type import ContextType from zeeguu.core.word_scheduling import BasicSRSchedule @@ -81,8 +81,7 @@ def add_word_to_study( if existing is not None and ( BasicSRSchedule.find(existing) or existing.is_learned() - or UserWordExPreference.of(existing.user_preference) - != UserWordExPreference.NO_PREFERENCE + or existing.study_choice is not None ): return None, existing, None @@ -130,8 +129,7 @@ def add_word_to_study( # checks that hide looked-up words ("too short", ...) do not apply. Set # only now, with the bookmark in place: a word left half-made by a failure # above keeps no preference, and the next lesson tries it again. - user_word.user_preference = UserWordExPreference.USE_IN_EXERCISES - user_word.update_fit_for_study(db_session) + user_word.choose(db_session, StudyChoice.STUDY) if from_audio_lesson and ( BasicSRSchedule.scheduled_user_words_count(user, from_lang) diff --git a/zeeguu/core/bookmark_operations/update_bookmark.py b/zeeguu/core/bookmark_operations/update_bookmark.py index 7ec485417..62c76007a 100644 --- a/zeeguu/core/bookmark_operations/update_bookmark.py +++ b/zeeguu/core/bookmark_operations/update_bookmark.py @@ -30,7 +30,7 @@ def as_dict(self): "word": self.word, "context": self.context, } -from zeeguu.core.model.bookmark_user_preference import UserWordExPreference +from zeeguu.core.model.study_choice import StudyChoice from zeeguu.core.word_scheduling.basicSR.basicSR import BasicSRSchedule from zeeguu.core.word_scheduling.basicSR.four_levels_per_word import FourLevelsPerWord @@ -235,7 +235,6 @@ def preserve_learning_progress(db_session, old_user_word, new_user_word): new_user_word: New UserWord """ old_fit_for_study = old_user_word.fit_for_study - old_user_preference = old_user_word.user_preference old_level = old_user_word.level if not old_fit_for_study: @@ -246,10 +245,9 @@ def preserve_learning_progress(db_session, old_user_word, new_user_word): print(f"[UPDATE_BOOKMARK] Preserving level {old_level} from old UserWord") new_user_word.level = old_level - if UserWordExPreference.of(old_user_preference) == UserWordExPreference.USE_IN_EXERCISES: + if old_user_word.study_choice == StudyChoice.STUDY: print(f"[UPDATE_BOOKMARK] Carrying over the learner's opt-in to exercises") - new_user_word.user_preference = UserWordExPreference.USE_IN_EXERCISES - new_user_word.update_fit_for_study(db_session) + new_user_word.choose(db_session, StudyChoice.STUDY) def transfer_learning_progress(db_session, old_user_word, new_user_word, bookmark): diff --git a/zeeguu/core/bookmark_quality/__init__.py b/zeeguu/core/bookmark_quality/__init__.py index b8d276ae0..e19c08d72 100644 --- a/zeeguu/core/bookmark_quality/__init__.py +++ b/zeeguu/core/bookmark_quality/__init__.py @@ -1,2 +1,2 @@ from .positive_qualities import quality_meaning -from .negative_qualities import bad_quality_meaning +from .negative_qualities import bad_quality_meaning, bad_quality_reason diff --git a/zeeguu/core/bookmark_quality/fit_for_study.py b/zeeguu/core/bookmark_quality/fit_for_study.py index 567b4b8c6..321c167e4 100644 --- a/zeeguu/core/bookmark_quality/fit_for_study.py +++ b/zeeguu/core/bookmark_quality/fit_for_study.py @@ -1,19 +1,36 @@ -from zeeguu.core.bookmark_quality import quality_meaning -from zeeguu.core.model.bookmark_user_preference import UserWordExPreference +from zeeguu.core.bookmark_quality import bad_quality_reason +from zeeguu.core.model.study_choice import StudyChoice, UnfitReason -def fit_for_study(user_word): - preference = UserWordExPreference.of(user_word.user_preference) +def unfit_reason(user_word): + """ + Why the word can't be studied now, or None. A sticky verdict stays; + anything else is worked out again from the word, which also clears a + reason that only holds until the word is judged again (UnfitReason). + """ + if user_word.unfit_reason in UnfitReason.STICKY: + return user_word.unfit_reason # Separated MWEs (multi-word expressions with words between parts) are not fit for study # because fill-in-the-blank exercises don't work well with non-adjacent words if _is_separated_mwe(user_word): + return UnfitReason.SEPARATED_MWE + + return bad_quality_reason(user_word) + + +def fit_given(study_choice, reason): + """The learner's choice, then the system's reason: a STUDY choice lifts + only the quality heuristics.""" + if study_choice in (StudyChoice.SKIP, StudyChoice.KNOWN): return False + if reason is None: + return True + return reason in UnfitReason.LIFTABLE and study_choice == StudyChoice.STUDY + - return ( - quality_meaning(user_word) - or preference == UserWordExPreference.USE_IN_EXERCISES - ) and preference >= UserWordExPreference.NO_PREFERENCE +def fit_for_study(user_word): + return fit_given(user_word.study_choice, unfit_reason(user_word)) def _is_separated_mwe(user_word): @@ -21,12 +38,3 @@ def _is_separated_mwe(user_word): if user_word.preferred_bookmark is None: return False return user_word.preferred_bookmark.mwe_partner_token_i is not None - - -def feedback_prevents_further_study(exercise_log): - last_outcome = exercise_log.latest_exercise_outcome() - - if not last_outcome: - return False - - return last_outcome.free_text_feedback() or last_outcome.too_easy() diff --git a/zeeguu/core/bookmark_quality/negative_qualities.py b/zeeguu/core/bookmark_quality/negative_qualities.py index 70f76440a..64162d952 100644 --- a/zeeguu/core/bookmark_quality/negative_qualities.py +++ b/zeeguu/core/bookmark_quality/negative_qualities.py @@ -1,4 +1,5 @@ from zeeguu.core.model.meaning import MeaningFrequency, PhraseType +from zeeguu.core.model.study_choice import UnfitReason def bad_quality_bookmark(bookmark): @@ -27,16 +28,26 @@ def uncommon_word_for_beginner_user(user_word): return False -def bad_quality_meaning(user_word): +def bad_quality_reason(user_word): + """The first quality check the word fails, as an UnfitReason, or None.""" + if uncommon_word_for_beginner_user(user_word): + return UnfitReason.RARE_FOR_BEGINNER + if arbitrary_multi_word_translation(user_word): + return UnfitReason.ARBITRARY_MULTI_WORD + if origin_same_as_translation(user_word): + return UnfitReason.SAME_AS_TRANSLATION + if origin_has_too_many_words(user_word): + return UnfitReason.TOO_MANY_WORDS + if origin_is_a_very_short_word(user_word): + return UnfitReason.TOO_SHORT bookmarks = user_word.bookmarks() - return ( - uncommon_word_for_beginner_user(user_word) - or arbitrary_multi_word_translation(user_word) - or origin_same_as_translation(user_word) - or origin_has_too_many_words(user_word) - or origin_is_a_very_short_word(user_word) - or (bookmarks and all([bad_quality_bookmark(b) for b in bookmarks])) - ) + if bookmarks and all(bad_quality_bookmark(b) for b in bookmarks): + return UnfitReason.BAD_CONTEXTS + return None + + +def bad_quality_meaning(user_word): + return bad_quality_reason(user_word) is not None def arbitrary_multi_word_translation(user_word): diff --git a/zeeguu/core/llm_services/validation_service.py b/zeeguu/core/llm_services/validation_service.py index 7afbb9b99..257809699 100644 --- a/zeeguu/core/llm_services/validation_service.py +++ b/zeeguu/core/llm_services/validation_service.py @@ -26,6 +26,7 @@ from dataclasses import replace from typing import Optional +from zeeguu.core.model.study_choice import UnfitReason from zeeguu.logging import log from zeeguu.core.llm_services.translation_validator import BROKEN_SENTENCE, WORD_NOT_FIT @@ -136,6 +137,7 @@ def check_for_duplicate_meaning(cls, db_session, user_word) -> bool: f"is equivalent to UserWord {other_uw.id} (-> '{other_translation}'). " f"Marking as unfit for study." ) + user_word.unfit_reason = UnfitReason.DUPLICATE_MEANING user_word.fit_for_study = False db_session.add(user_word) db_session.commit() @@ -286,14 +288,14 @@ def _give_up(cls, db_session, user_word, meaning, result, context=None): whose words on it are judged in their own sentences, so the meaning is left as it is - and is not logged in validation_log, which records what happened to meanings - and only this learner's word leaves study. - Not for good, and not through user_preference, which is the learner's + Not for good, and not through study_choice, which is the learner's choice: when the learner does something with the word again (looks it up in a new sentence, stars it), fit_for_study is recomputed and the word is judged again, which after a broken sentence is what it needs. Commits. Returns None.""" log(f"[VALIDATION] Gave up on user_word {user_word.id} in {context!r}: " f"[{result.gave_up.lower()}] {result.reason}") - user_word.set_unfit_for_study(db_session) + user_word.set_unfit_for_study(db_session, UnfitReason.VALIDATOR_GAVE_UP) db_session.commit() return None @@ -366,7 +368,6 @@ def _fix_bookmark(cls, db_session, user_word, bookmark, validation_result, conte from zeeguu.core.model import Meaning, UserWord from zeeguu.core.model.meaning import MeaningFrequency, PhraseType from zeeguu.core.model.translation_service import TranslationService - from zeeguu.core.model.bookmark_user_preference import UserWordExPreference from zeeguu.core.model.validation_log import ValidationLog from zeeguu.core.bookmark_operations.update_bookmark import ( transfer_learning_progress, @@ -383,6 +384,7 @@ def _fix_bookmark(cls, db_session, user_word, bookmark, validation_result, conte if new_word == old_meaning.origin.content and new_translation == old_meaning.translation.content: log(f"[VALIDATION] No correction provided, marking as invalid") old_meaning.validated = Meaning.INVALID + user_word.unfit_reason = UnfitReason.VALIDATOR_INVALID user_word.fit_for_study = False db_session.add_all([old_meaning, user_word]) @@ -487,12 +489,10 @@ def _fix_bookmark(cls, db_session, user_word, bookmark, validation_result, conte # again and "fixed" again at every exercise: 108 words, 389 # validator calls in Sep-Oct 2026, one word 35 times. Kept, since # the learner's look-ups stay anchored in what they read, but - # excluded for good: a plain fit_for_study = False is undone by - # the next update_fit_for_study. Its schedule went to - # new_user_word with transfer_learning_progress. + # excluded for good: REPLACED_BY_FIX is sticky. Its schedule + # went to new_user_word with transfer_learning_progress. if old_user_word not in db_session.deleted: - old_user_word.user_preference = UserWordExPreference.BAD_TRANSLATION - old_user_word.set_unfit_for_study(db_session) + old_user_word.set_unfit_for_study(db_session, UnfitReason.REPLACED_BY_FIX) db_session.commit() log(f"[VALIDATION] Fixed and moved to new UserWord {new_user_word.id}") diff --git a/zeeguu/core/model/bookmark.py b/zeeguu/core/model/bookmark.py index 5b9226804..3f67a4c88 100644 --- a/zeeguu/core/model/bookmark.py +++ b/zeeguu/core/model/bookmark.py @@ -10,6 +10,7 @@ from zeeguu.core.model.db import db from zeeguu.core.model.meaning import Meaning from zeeguu.core.model.source import Source +from zeeguu.core.model.study_choice import UnfitReason from zeeguu.core.model.text import Text from zeeguu.core.model.user_word import UserWord @@ -434,7 +435,7 @@ def _self_heal_unanchorable_preferred(self): f"{self.id} -> {replacement.id}" ) else: - self.user_word.set_unfit_for_study(db.session) + self.user_word.set_unfit_for_study(db.session, UnfitReason.NO_USABLE_CONTEXT) db.session.commit() log( f"[BOOKMARK-ANCHOR] Unscheduled user_word " diff --git a/zeeguu/core/model/bookmark_user_preference.py b/zeeguu/core/model/bookmark_user_preference.py deleted file mode 100644 index 753a9f436..000000000 --- a/zeeguu/core/model/bookmark_user_preference.py +++ /dev/null @@ -1,18 +0,0 @@ -class UserWordExPreference(object): - # Defines user preference to use word in exercises - BAD_TRANSLATION = -100 # bad translation - permanent exclusion - DECLARED_KNOWN = -6 # declared known; whether it still is, is word knowledge's call - DONT_USE_IN_EXERCISES = -1 # other reasons - permanent exclusion - NO_PREFERENCE = 0 - USE_IN_EXERCISES = 1 # user explicitly opted in (overrides quality checks) - - @classmethod - def of(cls, preference): - """ - The preference as an int. user_word.user_preference is TEXT in prod - (the model says Integer), so it can come back as "1", and "1" != 1. - """ - try: - return int(preference or cls.NO_PREFERENCE) - except (ValueError, TypeError): - return cls.NO_PREFERENCE diff --git a/zeeguu/core/model/study_choice.py b/zeeguu/core/model/study_choice.py new file mode 100644 index 000000000..8b2a683e9 --- /dev/null +++ b/zeeguu/core/model/study_choice.py @@ -0,0 +1,55 @@ +""" +Whether a user word is studied comes from two columns (roadmap 0b, 25 Oct +2026). They replace user_word.user_preference, one number that mixed the +learner's choice, a countdown and validator writes, so a validator fix could +pass for the learner opting in (#895). + +- study_choice: what the learner chose. Only learner actions write it. +- unfit_reason: why the system keeps the word out. None when it may be studied. + +The learner doesn't overrule the system: a STUDY choice lifts only the +LIFTABLE reasons, the quality heuristics the "+" button exists for. +""" + + +class StudyChoice: + STUDY = "study" + SKIP = "skip" + KNOWN = "known" # whether it still is, is for word knowledge to tell + + ALL = (STUDY, SKIP, KNOWN) + + +class UnfitReason: + # Quality heuristics (negative_qualities), worked out again at every + # update_fit_for_study + RARE_FOR_BEGINNER = "rare_for_beginner" + SAME_AS_TRANSLATION = "same_as_translation" + TOO_SHORT = "too_short" + TOO_MANY_WORDS = "too_many_words" + ARBITRARY_MULTI_WORD = "arbitrary_multi_word" + BAD_CONTEXTS = "bad_contexts" + # Also worked out, but exercises can't show it at all + SEPARATED_MWE = "separated_mwe" + + # Set when something happened; the next update_fit_for_study (a new + # look-up, a star) judges the word again + VALIDATOR_GAVE_UP = "validator_gave_up" + NO_USABLE_CONTEXT = "no_usable_context" + + # Verdicts that stay until the pair changes + VALIDATOR_INVALID = "validator_invalid" + REPLACED_BY_FIX = "replaced_by_fix" + DUPLICATE_MEANING = "duplicate_meaning" + + ALL = ( + RARE_FOR_BEGINNER, SAME_AS_TRANSLATION, TOO_SHORT, TOO_MANY_WORDS, + ARBITRARY_MULTI_WORD, BAD_CONTEXTS, SEPARATED_MWE, + VALIDATOR_GAVE_UP, NO_USABLE_CONTEXT, + VALIDATOR_INVALID, REPLACED_BY_FIX, DUPLICATE_MEANING, + ) + LIFTABLE = { + RARE_FOR_BEGINNER, SAME_AS_TRANSLATION, TOO_SHORT, TOO_MANY_WORDS, + ARBITRARY_MULTI_WORD, BAD_CONTEXTS, + } + STICKY = {VALIDATOR_INVALID, REPLACED_BY_FIX, DUPLICATE_MEANING} diff --git a/zeeguu/core/model/user_word.py b/zeeguu/core/model/user_word.py index e19a2d544..65e5571e3 100644 --- a/zeeguu/core/model/user_word.py +++ b/zeeguu/core/model/user_word.py @@ -5,9 +5,9 @@ from wordstats import Word -from zeeguu.core.bookmark_quality.fit_for_study import fit_for_study +from zeeguu.core.bookmark_quality.fit_for_study import fit_given, unfit_reason from zeeguu.core.model import User, Meaning -from zeeguu.core.model.bookmark_user_preference import UserWordExPreference +from zeeguu.core.model.study_choice import StudyChoice, UnfitReason from zeeguu.logging import log from zeeguu.core.model.db import db @@ -33,7 +33,10 @@ class UserWord(db.Model): fit_for_study = db.Column(db.Boolean) - user_preference = db.Column(db.Integer) + # What the learner chose, and why the system keeps the word out; see + # study_choice.py. fit_for_study is worked out from the two. + study_choice = db.Column(db.Enum(*StudyChoice.ALL)) + unfit_reason = db.Column(db.Enum(*UnfitReason.ALL)) learned_time = db.Column(db.DateTime) @@ -57,8 +60,8 @@ def __init__( self.level = level self.user = user self.meaning = meaning - self.fit_for_study = fit_for_study(self) - self.user_preference = UserWordExPreference.NO_PREFERENCE + self.unfit_reason = unfit_reason(self) + self.fit_for_study = fit_given(None, self.unfit_reason) self.learned_time = None self.level = level self.is_user_added = is_user_added @@ -86,9 +89,16 @@ def add_new_exercise(self, exercise): def should_be_studied(self): return self.fit_for_study and not self.is_learned() - def set_unfit_for_study(self, session=None): + def choose(self, session, study_choice): + """The learner chose to study, skip, or declared the word known.""" + self.study_choice = study_choice + self.update_fit_for_study(session) + + def set_unfit_for_study(self, session, reason): + """Out of study for a reason the system decided (UnfitReason).""" from zeeguu.core.word_scheduling.basicSR.basicSR import BasicSRSchedule + self.unfit_reason = reason self.fit_for_study = False BasicSRSchedule.clear_user_word_schedule(session, self) if session: @@ -107,7 +117,8 @@ def update_fit_for_study(self, session=None): :return: """ old_fit_for_study = self.fit_for_study - self.fit_for_study = fit_for_study(self) + self.unfit_reason = unfit_reason(self) + self.fit_for_study = fit_given(self.study_choice, self.unfit_reason) # If the word became unfit for study, clear its schedule if old_fit_for_study and not self.fit_for_study: diff --git a/zeeguu/core/test/test_a0_lesson_words.py b/zeeguu/core/test/test_a0_lesson_words.py index a639d24ea..b060be406 100644 --- a/zeeguu/core/test/test_a0_lesson_words.py +++ b/zeeguu/core/test/test_a0_lesson_words.py @@ -186,7 +186,7 @@ def test_completing_again_does_not_schedule_again(self): def test_a_learned_word_or_a_choice_of_the_learner_is_left_alone(self): from datetime import datetime - from zeeguu.core.model.bookmark_user_preference import UserWordExPreference + from zeeguu.core.model.study_choice import StudyChoice from zeeguu.core.model.meaning import Meaning learned = UserWord.find_or_create( @@ -196,14 +196,14 @@ def test_a_learned_word_or_a_choice_of_the_learner_is_left_alone(self): excluded = UserWord.find_or_create( db.session, self.user, Meaning.find_or_create(db.session, "tak", "da", "thank you", "en", user=self.user) ) - excluded.user_preference = UserWordExPreference.DONT_USE_IN_EXERCISES + excluded.study_choice = StudyChoice.SKIP db.session.commit() self._taught([("kaffe", "coffee", "En kaffe, tak."), ("tak", "thank you", "En kaffe, tak.")]) assert lesson_words.schedule_words_from_lesson(db.session, self.user, self.dialogue) == 0 words = self._user_words() assert words["kaffe"].is_learned() - assert words["tak"].user_preference == UserWordExPreference.DONT_USE_IN_EXERCISES + assert words["tak"].study_choice == StudyChoice.SKIP def test_at_the_learners_limit_a_word_is_added_but_not_scheduled(self): from zeeguu.core.model.user_preference import UserPreference diff --git a/zeeguu/core/test/test_lesson_check_marks_pair.py b/zeeguu/core/test/test_lesson_check_marks_pair.py index 37e935783..96fb00d01 100644 --- a/zeeguu/core/test/test_lesson_check_marks_pair.py +++ b/zeeguu/core/test/test_lesson_check_marks_pair.py @@ -82,7 +82,7 @@ def test_a_fixable_pair_moves_the_learner_to_the_correction(self): bookmark_id = Bookmark.query.filter_by(user_word_id=self.user_word.id).first().id # The fix recomputes fit_for_study; the test's random word would fail # the quality checks (rare for a beginner), and lose its schedule - with patch("zeeguu.core.bookmark_quality.fit_for_study.quality_meaning", lambda user_word: True): + with patch("zeeguu.core.bookmark_quality.fit_for_study.bad_quality_reason", lambda user_word: None): meaning = self._generate_refused_with(WRONG_PAIR, pair_verdict=FIXABLE) self.assertEqual(meaning.validated, Meaning.INVALID) diff --git a/zeeguu/core/test/test_study_choice.py b/zeeguu/core/test/test_study_choice.py new file mode 100644 index 000000000..779a68f3d --- /dev/null +++ b/zeeguu/core/test/test_study_choice.py @@ -0,0 +1,91 @@ +"""Whether a word is studied: the learner's study_choice, then the system's +unfit_reason. A STUDY choice lifts only the quality heuristics.""" +from unittest import TestCase +from unittest.mock import patch + +import zeeguu.core +from zeeguu.core.bookmark_quality.fit_for_study import fit_given +from zeeguu.core.model.study_choice import StudyChoice, UnfitReason +from zeeguu.core.test.model_test_mixin import ModelTestMixIn +from zeeguu.core.test.rules.bookmark_rule import BookmarkRule +from zeeguu.core.test.rules.user_rule import UserRule + +session = zeeguu.core.model.db.session + + +class FitGivenTest(TestCase): + def test_skip_and_known_keep_any_word_out(self): + for choice in (StudyChoice.SKIP, StudyChoice.KNOWN): + self.assertFalse(fit_given(choice, None)) + + def test_no_reason_no_choice_is_fit(self): + self.assertTrue(fit_given(None, None)) + + def test_study_lifts_a_quality_heuristic(self): + self.assertFalse(fit_given(None, UnfitReason.SAME_AS_TRANSLATION)) + self.assertTrue(fit_given(StudyChoice.STUDY, UnfitReason.SAME_AS_TRANSLATION)) + + def test_study_does_not_lift_a_verdict_or_a_separated_mwe(self): + for reason in ( + UnfitReason.VALIDATOR_INVALID, + UnfitReason.VALIDATOR_GAVE_UP, + UnfitReason.REPLACED_BY_FIX, + UnfitReason.DUPLICATE_MEANING, + UnfitReason.NO_USABLE_CONTEXT, + UnfitReason.SEPARATED_MWE, + ): + self.assertFalse(fit_given(StudyChoice.STUDY, reason), reason) + + def test_every_reason_is_liftable_sticky_or_judged_again(self): + judged_again = { + UnfitReason.SEPARATED_MWE, + UnfitReason.VALIDATOR_GAVE_UP, + UnfitReason.NO_USABLE_CONTEXT, + } + self.assertEqual( + set(UnfitReason.ALL), UnfitReason.LIFTABLE | UnfitReason.STICKY | judged_again + ) + + +class UnfitReasonOnUserWordTest(ModelTestMixIn, TestCase): + def setUp(self): + super().setUp() + bookmark = BookmarkRule(UserRule().user).bookmark + self.user_word = bookmark.user_word + self.user_word.preferred_bookmark = bookmark + session.add(self.user_word) + session.commit() + + def _recompute(self): + with patch("zeeguu.core.bookmark_quality.fit_for_study.bad_quality_reason", lambda uw: None): + self.user_word.update_fit_for_study(session) + + def test_a_sticky_verdict_survives_a_recompute(self): + self.user_word.set_unfit_for_study(session, UnfitReason.REPLACED_BY_FIX) + self._recompute() + self.assertEqual(self.user_word.unfit_reason, UnfitReason.REPLACED_BY_FIX) + self.assertFalse(self.user_word.fit_for_study) + + def test_a_give_up_is_judged_again_at_the_next_recompute(self): + self.user_word.set_unfit_for_study(session, UnfitReason.VALIDATOR_GAVE_UP) + self._recompute() + self.assertIsNone(self.user_word.unfit_reason) + self.assertTrue(self.user_word.fit_for_study) + + def test_the_failing_quality_check_is_the_reason(self): + with patch( + "zeeguu.core.bookmark_quality.fit_for_study.bad_quality_reason", + lambda uw: UnfitReason.TOO_SHORT, + ): + self.user_word.update_fit_for_study(session) + self.assertEqual(self.user_word.unfit_reason, UnfitReason.TOO_SHORT) + self.assertFalse(self.user_word.fit_for_study) + + self.user_word.choose(session, StudyChoice.STUDY) + self.assertTrue(self.user_word.fit_for_study) + self.assertEqual(self.user_word.unfit_reason, UnfitReason.TOO_SHORT) # lifted, still known + + def test_known_takes_a_fit_word_out(self): + self._recompute() + self.user_word.choose(session, StudyChoice.KNOWN) + self.assertFalse(self.user_word.fit_for_study) diff --git a/zeeguu/core/test/test_validator_card_fits_sentence.py b/zeeguu/core/test/test_validator_card_fits_sentence.py index 4fecd8c31..1c205fe9f 100644 --- a/zeeguu/core/test/test_validator_card_fits_sentence.py +++ b/zeeguu/core/test/test_validator_card_fits_sentence.py @@ -163,8 +163,8 @@ def test_giving_up_leaves_the_meaning_and_takes_the_word_out_of_study(monkeypatc id=1, meaning=meaning, fit_for_study=True, - user_preference=None, - set_unfit_for_study=lambda session: setattr(user_word, "fit_for_study", False), + study_choice=None, + set_unfit_for_study=lambda session, reason: setattr(user_word, "fit_for_study", False), preferred_bookmark=SimpleNamespace( get_context=lambda: "Dronen er mennesker og kan flyve", translation_source="reading" ), @@ -175,7 +175,7 @@ def test_giving_up_leaves_the_meaning_and_takes_the_word_out_of_study(monkeypatc assert meaning.validated == Meaning.NOT_VALIDATED assert user_word.fit_for_study is False assert logged == [] # validation_log records what happened to meanings; nothing did - assert user_word.user_preference is None # the learner's choice, not the validator's + assert user_word.study_choice is None # the learner's choice, not the validator's def test_a_valid_card_its_own_sentence_does_not_say_is_a_misfit(): diff --git a/zeeguu/core/test/test_validator_fix_keeps_quality_checks.py b/zeeguu/core/test/test_validator_fix_keeps_quality_checks.py index e9d9ac6cd..e6227cdd0 100644 --- a/zeeguu/core/test/test_validator_fix_keeps_quality_checks.py +++ b/zeeguu/core/test/test_validator_fix_keeps_quality_checks.py @@ -8,7 +8,7 @@ import zeeguu.core from zeeguu.core.llm_services.translation_validator import ValidationResult from zeeguu.core.llm_services.validation_service import UserWordValidationService -from zeeguu.core.model.bookmark_user_preference import UserWordExPreference +from zeeguu.core.model.study_choice import StudyChoice from zeeguu.core.model.meaning import Meaning from zeeguu.core.test.model_test_mixin import ModelTestMixIn from zeeguu.core.test.rules.bookmark_rule import BookmarkRule @@ -51,12 +51,12 @@ def test_a_fix_to_an_unfit_pair_leaves_it_unfit(self): new_user_word = self._fix_to_the_word_itself() self.assertNotEqual(new_user_word.id, self.user_word.id) self.assertFalse(new_user_word.fit_for_study) - self.assertEqual(int(new_user_word.user_preference or 0), UserWordExPreference.NO_PREFERENCE) + self.assertIsNone(new_user_word.study_choice) def test_the_learners_own_opt_in_carries_over(self): - self.user_word.user_preference = UserWordExPreference.USE_IN_EXERCISES + self.user_word.study_choice = StudyChoice.STUDY session.add(self.user_word) session.commit() new_user_word = self._fix_to_the_word_itself() self.assertTrue(new_user_word.fit_for_study) - self.assertEqual(new_user_word.user_preference, UserWordExPreference.USE_IN_EXERCISES) + self.assertEqual(new_user_word.study_choice, StudyChoice.STUDY) diff --git a/zeeguu/core/test/test_user_preference_as_text.py b/zeeguu/core/test/test_validator_fix_recomputes_fit.py similarity index 64% rename from zeeguu/core/test/test_user_preference_as_text.py rename to zeeguu/core/test/test_validator_fix_recomputes_fit.py index df3a3be72..8b3136873 100644 --- a/zeeguu/core/test/test_user_preference_as_text.py +++ b/zeeguu/core/test/test_validator_fix_recomputes_fit.py @@ -1,6 +1,4 @@ -"""user_word.user_preference is TEXT in prod (the model says Integer), so it -comes back as "-6" or "0". Code that does arithmetic or compares it as an int -must go through UserWordExPreference.of().""" +"""After a validator fix, the corrected word gets judged by the quality checks.""" from unittest import TestCase from unittest.mock import patch @@ -17,29 +15,6 @@ session = zeeguu.core.model.db.session -class UserPreferenceAsTextTest(ModelTestMixIn, TestCase): - def setUp(self): - super().setUp() - self.user = UserRule().user - self.bookmark = BookmarkRule(self.user).bookmark - self.user_word = self.bookmark.user_word - self.meaning = self.user_word.meaning - - def test_an_audio_lesson_adds_a_word_whose_preference_is_a_text_zero(self): - from zeeguu.core.bookmark_operations.add_word_to_study import add_word_to_study - - self.user_word.user_preference = "0" # no preference, as prod returns it - session.add(self.user_word) - - added, _, _ = add_word_to_study( - session, self.user, self.meaning.origin.content, self.meaning.translation.content, - self.meaning.origin.language.code, self.meaning.translation.language.code, - f"Det er {self.meaning.origin.content} her.", from_audio_lesson=True, - ) - - self.assertIsNotNone(added) # added, not left alone - - class ValidatorFixRecomputesFitTest(ModelTestMixIn, TestCase): def test_the_corrected_word_is_judged_again(self): bookmark = BookmarkRule(UserRule().user).bookmark diff --git a/zeeguu/core/test/test_validator_fix_retires_old_word.py b/zeeguu/core/test/test_validator_fix_retires_old_word.py index 5a9b289ef..7311d6b9d 100644 --- a/zeeguu/core/test/test_validator_fix_retires_old_word.py +++ b/zeeguu/core/test/test_validator_fix_retires_old_word.py @@ -64,7 +64,7 @@ def test_an_old_word_held_by_another_bookmark_leaves_study(self): # and it stays out when something recomputes the flag, even if the # quality checks pass - with patch("zeeguu.core.bookmark_quality.fit_for_study.quality_meaning", lambda uw: True): + with patch("zeeguu.core.bookmark_quality.fit_for_study.bad_quality_reason", lambda uw: None): old.update_fit_for_study(session) self.assertFalse(old.fit_for_study) diff --git a/zeeguu/core/word_scheduling/basicSR/basicSR.py b/zeeguu/core/word_scheduling/basicSR/basicSR.py index ae2913778..afbe925be 100644 --- a/zeeguu/core/word_scheduling/basicSR/basicSR.py +++ b/zeeguu/core/word_scheduling/basicSR/basicSR.py @@ -110,7 +110,7 @@ def update(cls, db_session, user_word, outcome, time: datetime = None): time = datetime.now() if outcome == ExerciseOutcome.OTHER_FEEDBACK: - from zeeguu.core.model.bookmark_user_preference import UserWordExPreference + from zeeguu.core.model.study_choice import StudyChoice schedule = cls.find(user_word) if schedule: @@ -119,8 +119,8 @@ def update(cls, db_session, user_word, outcome, time: datetime = None): user_word.fit_for_study = 0 # Since the user has explicitly given feedback, this should - # be recorded as a user preference. - user_word.user_preference = UserWordExPreference.DONT_USE_IN_EXERCISES + # be recorded as their choice. + user_word.study_choice = StudyChoice.SKIP db_session.add(user_word) return