diff --git a/backend/alembic/versions/543d43d752b8_migrate_old_dictionary_entries_and_.py b/backend/alembic/versions/543d43d752b8_migrate_old_dictionary_entries_and_.py new file mode 100644 index 00000000..112ceb41 --- /dev/null +++ b/backend/alembic/versions/543d43d752b8_migrate_old_dictionary_entries_and_.py @@ -0,0 +1,177 @@ +"""migrate old dictionary entries and translation records + +Revision ID: 543d43d752b8 +Revises: c4a3a2f74bfe +Create Date: 2026-05-14 18:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import text + +# revision identifiers, used by Alembic. +revision: str = '543d43d752b8' +down_revision: Union[str, Sequence[str], None] = 'c4a3a2f74bfe' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema — migrate old dictionary entries and create TranslationLanguage records.""" + + connection = op.get_bind() + + # === Part 1: Migrate DictionaryEntry rows without language pair === + + # 1a. Set source_language = 'und' for rows where it's NULL or empty + connection.execute( + text(""" + UPDATE dictionary_entries + SET source_language = 'und' + WHERE source_language IS NULL OR source_language = '' OR source_language = 'und' + """) + ) + + # 1b. Set target_language from parent dictionary for rows where it's NULL, empty, or 'und' + connection.execute( + text(""" + UPDATE dictionary_entries + SET target_language = COALESCE( + (SELECT td.target_language FROM terminology_dictionaries td + WHERE td.id = dictionary_entries.dictionary_id), + 'und' + ) + WHERE target_language IS NULL + OR target_language = '' + OR target_language = 'und' + """) + ) + + # 1c. Set target_language default fallback in case dictionary's own target_language is also NULL/empty + connection.execute( + text(""" + UPDATE dictionary_entries + SET target_language = 'und' + WHERE target_language IS NULL OR target_language = '' + """) + ) + + # === Part 2: Create TranslationLanguage entries for old TranslationRecord rows === + # Records that have deprecated fields (final_value, llm_translation, user_edit) populated + # but no corresponding TranslationLanguage row + + # Determine target_language from the parent run's job + # We use a two-step approach: find records missing TranslationLanguage rows, + # then create one per record using the default language code from the job config + missing = connection.execute( + text(""" + SELECT r.id AS record_id, + r.run_id, + COALESCE(r.final_value, r.llm_translation, r.target_sql, '') AS resolved_value, + COALESCE(r.user_edit, r.llm_translation, r.target_sql, '') AS resolved_edit, + r.status + FROM translation_records r + WHERE r.status = 'SUCCESS' + AND (r.final_value IS NOT NULL OR r.llm_translation IS NOT NULL OR r.target_sql IS NOT NULL) + AND NOT EXISTS ( + SELECT 1 FROM translation_languages tl + WHERE tl.record_id = r.id + ) + """) + ).fetchall() + + import uuid + from datetime import datetime, timezone + + for row in missing: + record_id = row[0] + resolved_value = row[2] or "" + resolved_edit = row[3] or "" + status = row[4] or "SUCCESS" + + # Map record status to language status + lang_status = "translated" + if status in ("APPROVED", "approved"): + lang_status = "approved" + elif status in ("EDITED", "edited"): + lang_status = "edited" + + # Determine language code from job + lang_code_row = connection.execute( + text(""" + SELECT j.target_language, j.target_languages + FROM translation_records r + JOIN translation_runs rn ON rn.id = r.run_id + JOIN translation_jobs j ON j.id = rn.job_id + WHERE r.id = :rid + """), + {"rid": record_id}, + ).fetchone() + + language_code = "und" + if lang_code_row: + lang_code = lang_code_row[0] + lang_codes_json = lang_code_row[1] + + if lang_code: + language_code = lang_code + elif lang_codes_json: + try: + import json + codes = json.loads(lang_codes_json) if isinstance(lang_codes_json, str) else lang_codes_json + if isinstance(codes, list) and codes: + language_code = codes[0] + except (json.JSONDecodeError, TypeError): + pass + + # Determine final value (prefer user_edit over llm_translation) + final_value = resolved_edit if resolved_edit else resolved_value + + now = datetime.now(timezone.utc) + lang_id = str(uuid.uuid4()) + + connection.execute( + text(""" + INSERT INTO translation_languages + (id, record_id, language_code, source_language_detected, + translated_value, user_edit, final_value, status, created_at) + VALUES + (:id, :record_id, :language_code, 'und', + :translated_value, :user_edit, :final_value, :status, :created_at) + """), + { + "id": lang_id, + "record_id": record_id, + "language_code": language_code, + "translated_value": resolved_value or "", + "user_edit": resolved_edit or "", + "final_value": final_value or "", + "status": lang_status, + "created_at": now, + }, + ) + + +def downgrade() -> None: + """Downgrade — reverse the migration (delete created TranslationLanguage rows, restore old values).""" + connection = op.get_bind() + + # Remove TranslationLanguage entries that were created by this migration + # (those linked to records that had no existing TranslationLanguage rows) + connection.execute( + text(""" + DELETE FROM translation_languages + WHERE id IN ( + SELECT tl.id FROM translation_languages tl + JOIN translation_records r ON r.id = tl.record_id + WHERE r.created_at < '2026-05-15' + AND tl.created_at >= '2026-05-14T18:00:00' + ) + """) + ) + + # Note: dictionary_entries language updates are intentionally NOT reverted + # because 'und' is an acceptable safe default, and reverting could break + # unique constraints if entries were duplicated. diff --git a/backend/src/api/routes/translate/_correction_routes.py b/backend/src/api/routes/translate/_correction_routes.py index 404181e3..7230a2c1 100644 --- a/backend/src/api/routes/translate/_correction_routes.py +++ b/backend/src/api/routes/translate/_correction_routes.py @@ -47,6 +47,7 @@ async def submit_correction( origin_user_id=current_user.username, context_data=payload.context_data, usage_notes=payload.usage_notes, + keep_context=payload.keep_context, ) return result except ValueError as e: diff --git a/backend/src/plugins/translate/__tests__/test_inline_correction.py b/backend/src/plugins/translate/__tests__/test_inline_correction.py new file mode 100644 index 00000000..5648e8d1 --- /dev/null +++ b/backend/src/plugins/translate/__tests__/test_inline_correction.py @@ -0,0 +1,792 @@ +# region InlineCorrectionTests [TYPE Module] +# @SEMANTICS: test, translate, correction, inline, bulk +# @PURPOSE: Tests for inline correction (T109-T116, T117): single correction, dictionary submission, bulk replace. +# @LAYER: Test +# @RELATION: BINDS_TO -> [InlineCorrectionService:Module] +# @RELATION: BINDS_TO -> [BulkFindReplaceService:Module] +# @TEST_CONTRACT: InlineCorrectionService -> submit_correction, duplicate detection +# @TEST_CONTRACT: BulkFindReplaceService -> preview, apply, atomic +# @TEST_EDGE: single_correction -> Creates dictionary entry with origin tracking +# @TEST_EDGE: duplicate_detection -> Conflict detected for existing term +# @TEST_EDGE: bulk_replace_preview -> Returns accurate affected records +# @TEST_EDGE: bulk_replace_apply -> Updates values and optionally submits to dictionary + +from unittest.mock import MagicMock, patch + +from src.models.translate import TranslationLanguage, TranslationRecord +from src.plugins.translate.service import BulkFindReplaceService, InlineCorrectionService + + +# region TestInlineCorrectionService [TYPE Class] +# @PURPOSE: Tests for InlineCorrectionService: single correction, duplicate detection, origin tracking. +class TestInlineCorrectionService: + + # region test_single_correction_to_dict [TYPE Function] + # @BRIEF Single inline correction creates a dictionary entry with origin tracking. + def test_single_correction_to_dict(self): + db = MagicMock() + + # Mock language entry + lang_entry = MagicMock(spec=TranslationLanguage) + lang_entry.id = "lang-ru-1" + lang_entry.language_code = "ru" + lang_entry.source_language_detected = "en" + lang_entry.translated_value = "old translation" + lang_entry.final_value = None + lang_entry.record_id = "rec-1" + + # Mock record + record = MagicMock(spec=TranslationRecord) + record.id = "rec-1" + record.run_id = "run-1" + record.job_id = "job-1" + record.source_sql = "original source text" + record.source_object_name = "" + record.source_object_id = "0" + record.languages = [lang_entry] + + # Mock queries: lang_entry, record, then DictionaryEntry query (None = no conflict) + db.query.return_value.filter.return_value.first.side_effect = [lang_entry, record, None] + + result = InlineCorrectionService.submit_correction_to_dict( + db=db, + record_id="rec-1", + language_code="ru", + dictionary_id="dict-1", + corrected_value="new translation", + current_user="test-user", + ) + + assert result is not None + assert result.get("action") == "created" + # endregion test_single_correction_to_dict + + # region test_duplicate_detection [TYPE Function] + # @PURPOSE: Duplicate source term in same dictionary raises conflict. + def test_duplicate_detection(self): + db = MagicMock() + + # Mock language entry + lang_entry = MagicMock(spec=TranslationLanguage) + lang_entry.id = "lang-ru-1" + lang_entry.language_code = "ru" + lang_entry.source_language_detected = "en" + lang_entry.translated_value = "old translation" + lang_entry.record_id = "rec-1" + + # Mock record + record = MagicMock(spec=TranslationRecord) + record.id = "rec-1" + record.run_id = "run-1" + record.job_id = "job-1" + record.source_sql = "source text" + record.source_object_name = "" + record.source_object_id = "0" + record.languages = [lang_entry] + + # Mock queries: lang_entry, record, then DictionaryEntry query returns existing (conflict) + existing_entry = MagicMock() + existing_entry.id = "entry-existing" + existing_entry.source_term = "source text" + existing_entry.target_term = "existing translation" + + db.query.return_value.filter.return_value.first.side_effect = [lang_entry, record, existing_entry] + + result = InlineCorrectionService.submit_correction_to_dict( + db=db, + record_id="rec-1", + language_code="ru", + dictionary_id="dict-1", + corrected_value="new translation", + current_user="test-user", + ) + + assert result is not None + assert result.get("action") == "conflict_detected" + # endregion test_duplicate_detection + + # region test_correction_with_context_capture [TYPE Function] + # @PURPOSE: Correction accepts context_data_override and usage_notes. + def test_correction_with_context_capture(self): + db = MagicMock() + + # Mock language entry + lang_entry = MagicMock(spec=TranslationLanguage) + lang_entry.id = "lang-ru-1" + lang_entry.language_code = "ru" + lang_entry.source_language_detected = "en" + lang_entry.translated_value = "old" + lang_entry.record_id = "rec-1" + + # Mock record + record = MagicMock(spec=TranslationRecord) + record.id = "rec-1" + record.run_id = "run-1" + record.job_id = "job-1" + record.source_sql = "source" + record.source_object_name = "" + record.source_object_id = "0" + record.languages = [lang_entry] + + db.query.return_value.filter.return_value.first.side_effect = [lang_entry, record, None] + + result = InlineCorrectionService.submit_correction_to_dict( + db=db, + record_id="rec-1", + language_code="ru", + dictionary_id="dict-1", + corrected_value="new", + current_user="test-user", + context_data_override={"category": "electronics"}, + usage_notes="Use for product descriptions", + ) + + assert result is not None + assert result.get("action") == "created" + # endregion test_correction_with_context_capture + +# endregion TestInlineCorrectionService + + +# region TestBulkFindReplaceService [TYPE Class] +# @PURPOSE: Tests for BulkFindReplaceService: preview accuracy, atomic apply. +class TestBulkFindReplaceService: + + # region test_bulk_replace_preview_accuracy [TYPE Function] + # @BRIEF Preview returns accurate list of affected records. + def test_bulk_replace_preview_accuracy(self): + db = MagicMock() + + # Mock language entries — need source_text key for preview matching + def make_lang(lang_code, val): + entry = MagicMock(spec=TranslationLanguage) + entry.language_code = lang_code + entry.final_value = val or "" + entry.translated_value = val or "" + entry.id = f"lang-{lang_code}" + entry.record_id = f"rec-{lang_code}" + return entry + + lang_ru = make_lang("ru", "Hello world") + lang_en = make_lang("en", "not matched") + + rec = MagicMock(spec=TranslationRecord) + rec.id = "rec-1" + rec.source_sql = "Hello world" + rec.languages = [lang_ru, lang_en] + + db.query.return_value.options.return_value.filter.return_value.all.return_value = [rec] + + # Mock run exists + db.query.return_value.filter.return_value.first.return_value = MagicMock(id="run-1") + + result = BulkFindReplaceService.preview( + db=db, + run_id="run-1", + pattern="Hello", + is_regex=False, + replacement_text="Hi", + target_language="ru", + ) + + # Should find records with matching pattern + assert isinstance(result, list) + # endregion test_bulk_replace_preview_accuracy + + # region test_bulk_replace_apply_atomic [TYPE Function] + # @BRIEF Apply updates values atomically. + def test_bulk_replace_apply_atomic(self): + db = MagicMock() + + def make_lang(lang_code, val): + entry = MagicMock(spec=TranslationLanguage) + entry.language_code = lang_code + entry.final_value = val or "" + entry.translated_value = val or "" + entry.id = f"lang-{lang_code}" + entry.record_id = "rec-1" + return entry + + lang_ru = make_lang("ru", "Hello world") + lang_en = make_lang("en", "not touched") + + rec = MagicMock(spec=TranslationRecord) + rec.id = "rec-1" + rec.source_sql = "Hello world" + rec.languages = [lang_ru, lang_en] + rec.target_sql = "Hello world" + + db.query.return_value.options.return_value.filter.return_value.all.return_value = [rec] + db.query.return_value.filter.return_value.first.return_value = MagicMock(id="run-1") + + result = BulkFindReplaceService.apply( + db=db, + run_id="run-1", + pattern="Hello", + is_regex=False, + replacement_text="Hi", + target_language="ru", + submit_to_dictionary=False, + current_user="test-user", + ) + + assert result is not None + # endregion test_bulk_replace_apply_atomic + + # region test_bulk_replace_submit_to_dict [TYPE Function] + # @PURPOSE: Bulk replace with submit_to_dictionary creates dictionary entries. + @patch("src.plugins.translate.service.InlineCorrectionService.submit_correction_to_dict") + def test_bulk_replace_submit_to_dict(self, mock_submit_correction: MagicMock): + db = MagicMock() + + def make_lang(lang_code, val): + entry = MagicMock(spec=TranslationLanguage) + entry.language_code = lang_code + entry.final_value = val or "" + entry.translated_value = val or "" + entry.id = f"lang-{lang_code}" + entry.record_id = "rec-1" + return entry + + lang = make_lang("ru", "replace me") + + rec = MagicMock(spec=TranslationRecord) + rec.id = "rec-1" + rec.source_sql = "original" + rec.languages = [lang] + rec.target_sql = "replace me" + + db.query.return_value.options.return_value.filter.return_value.all.return_value = [rec] + db.query.return_value.filter.return_value.first.return_value = MagicMock(id="run-1") + + mock_submit_correction.return_value = {"entry_id": "new-entry"} + + result = BulkFindReplaceService.apply( + db=db, + run_id="run-1", + pattern="replace me", + is_regex=False, + replacement_text="replaced", + target_language="ru", + submit_to_dictionary=True, + dictionary_id="dict-1", + current_user="test-user", + ) + + assert result is not None + # endregion test_bulk_replace_submit_to_dict + +# endregion TestBulkFindReplaceService + + +# region TestContextAwareCorrection [TYPE Module] +# @SEMANTICS: test, translate, correction, context, jaccard, priority +# @PURPOSE: Tests for context-aware correction (T132): context capture, Jaccard similarity, priority flagging, truncation, context_source tagging. +# @LAYER: Test +# @RELATION: BINDS_TO -> [InlineCorrectionService:Class] +# @RELATION: BINDS_TO -> [ContextAwarePromptBuilder:Class] +# @TEST_CONTRACT: InlineCorrectionService -> context capture from source row, context editing/removal +# @TEST_CONTRACT: ContextAwarePromptBuilder -> Jaccard similarity, priority flagging, truncation, render_entry +# @TEST_EDGE: context_capture -> Auto-populates context_data from source row columns +# @TEST_EDGE: context_removal -> keep_context=false clears context_data and sets has_context=False +# @TEST_EDGE: jaccard_zero -> 0% overlap returns 0.0 +# @TEST_EDGE: jaccard_half -> 50% overlap returns 0.5 +# @TEST_EDGE: jaccard_full -> 100% overlap returns 1.0 +# @TEST_EDGE: priority_flagging -> Similarity >=0.5 triggers priority prefix +# @TEST_EDGE: truncation_500 -> Context rendering capped at ~500 tokens with annotation +# @TEST_EDGE: context_source_tagging -> auto/bulk/manual tags set correctly + +from unittest.mock import MagicMock + +from src.models.translate import TranslationLanguage, TranslationRecord +from src.plugins.translate.prompt_builder import ContextAwarePromptBuilder +from src.plugins.translate.service import InlineCorrectionService + + +# region TestContextCapture [TYPE Class] +# @PURPOSE: Tests for auto-capture and editing/removal of context_data on correction submission. +class TestContextCapture: + + # region test_context_auto_captured_from_source_row [TYPE Function] + # @BRIEF Submitting correction auto-populates context_data from source record columns. + def test_context_auto_captured_from_source_row(self): + db = MagicMock() + + lang_entry = MagicMock(spec=TranslationLanguage) + lang_entry.id = "lang-ru-1" + lang_entry.language_code = "ru" + lang_entry.source_language_detected = "en" + lang_entry.translated_value = "old" + lang_entry.record_id = "rec-1" + + record = MagicMock(spec=TranslationRecord) + record.id = "rec-1" + record.run_id = "run-1" + record.job_id = "job-1" + record.source_sql = "source text" + record.source_object_name = "chart-42" + record.source_object_id = "42" + record.source_object_type = "chart" + record.source_data = {"key": "dashboard_name", "value": "Sales Report"} + record.languages = [lang_entry] + + db.query.return_value.filter.return_value.first.side_effect = [lang_entry, record, None] + + # Capture the DictionaryEntry created via db.add + created_entries = [] + def capture_add(obj): + created_entries.append(obj) + db.add.side_effect = capture_add + + result = InlineCorrectionService.submit_correction_to_dict( + db=db, + record_id="rec-1", + language_code="ru", + dictionary_id="dict-1", + corrected_value="new translation", + current_user="test-user", + ) + + assert result["action"] == "created" + # Verify context was auto-captured from the record + entry = created_entries[0] + assert entry.has_context is True + assert entry.context_source == "auto" + assert entry.context_data is not None + assert "source_object_type" in entry.context_data + assert entry.context_data["source_object_type"] == "chart" + assert entry.context_data["source_object_name"] == "chart-42" + # endregion test_context_auto_captured_from_source_row + + # region test_context_removal_keep_context_false [TYPE Function] + # @BRIEF keep_context=False clears context_data and sets has_context=False. + def test_context_removal_keep_context_false(self): + db = MagicMock() + + lang_entry = MagicMock(spec=TranslationLanguage) + lang_entry.id = "lang-ru-1" + lang_entry.language_code = "ru" + lang_entry.source_language_detected = "en" + lang_entry.translated_value = "old" + lang_entry.record_id = "rec-1" + + record = MagicMock(spec=TranslationRecord) + record.id = "rec-1" + record.run_id = "run-1" + record.job_id = "job-1" + record.source_sql = "source text" + record.source_object_name = "" + record.source_object_id = "" + record.source_object_type = "" + record.source_data = {"category": "electronics"} + record.languages = [lang_entry] + + db.query.return_value.filter.return_value.first.side_effect = [lang_entry, record, None] + + created_entries = [] + db.add.side_effect = lambda obj: created_entries.append(obj) + + result = InlineCorrectionService.submit_correction_to_dict( + db=db, + record_id="rec-1", + language_code="ru", + dictionary_id="dict-1", + corrected_value="new", + current_user="test-user", + keep_context=False, + ) + + assert result["action"] == "created" + entry = created_entries[0] + assert entry.context_data is None + assert entry.has_context is False + assert entry.context_source == "manual" + # endregion test_context_removal_keep_context_false + + # region test_context_override_replaces_auto [TYPE Function] + # @BRIEF context_data_override replaces auto-captured context and sets source=manual. + def test_context_override_replaces_auto(self): + db = MagicMock() + + lang_entry = MagicMock(spec=TranslationLanguage) + lang_entry.id = "lang-ru-1" + lang_entry.language_code = "ru" + lang_entry.source_language_detected = "en" + lang_entry.translated_value = "old" + lang_entry.record_id = "rec-1" + + record = MagicMock(spec=TranslationRecord) + record.id = "rec-1" + record.run_id = "run-1" + record.job_id = "job-1" + record.source_sql = "source" + record.source_object_name = "chart-1" + record.source_object_id = "1" + record.source_object_type = "chart" + record.source_data = {"auto": "captured"} + record.languages = [lang_entry] + + db.query.return_value.filter.return_value.first.side_effect = [lang_entry, record, None] + + created_entries = [] + db.add.side_effect = lambda obj: created_entries.append(obj) + + result = InlineCorrectionService.submit_correction_to_dict( + db=db, + record_id="rec-1", + language_code="ru", + dictionary_id="dict-1", + corrected_value="new", + current_user="test-user", + context_data_override={"category": "manual_override", "region": "EU"}, + ) + + assert result["action"] == "created" + entry = created_entries[0] + assert entry.context_source == "manual" + assert entry.context_data == {"category": "manual_override", "region": "EU"} + # endregion test_context_override_replaces_auto + +# endregion TestContextCapture + + +# region TestJaccardSimilarity [TYPE Class] +# @PURPOSE: Tests for ContextAwarePromptBuilder Jaccard similarity and priority flagging. +class TestJaccardSimilarity: + + # region test_jaccard_zero_overlap [TYPE Function] + # @BRIEF Disjoint contexts return 0.0 similarity. + def test_jaccard_zero_overlap(self): + a = {"domain": "electronics"} + b = {"domain": "healthcare"} + assert ContextAwarePromptBuilder.compute_context_similarity(a, b) == 0.0 + # endregion test_jaccard_zero_overlap + + # region test_jaccard_half_overlap [TYPE Function] + # @BRIEF 50% overlap returns 0.5. + def test_jaccard_half_overlap(self): + # 1 shared out of 3 unique = 1/3 ≈ 0.333; construct exact 50% + # {a, b} vs {b, c} -> intersection={b}, union={a,b,c} = 1/3 + # To get 0.5: {a,b} vs {b,c} -> nope + # {a,b} vs {a,c} -> 1/3. {a,b} vs {a,b,c} -> 2/3 + # Need intersection/union = 0.5 -> union size = 2 * intersection size + # {a, b} vs {a, c}: intersection={a}=1, union={a,b,c}=3 -> 0.333 + # {a, b} vs {a}: intersection={a}=1, union={a,b}=2 -> 0.5 + a = {"cat": "electronics", "region": "US"} + b = {"cat": "electronics"} + assert ContextAwarePromptBuilder.compute_context_similarity(a, b) == 0.5 + # endregion test_jaccard_half_overlap + + # region test_jaccard_full_overlap [TYPE Function] + # @BRIEF Identical contexts return 1.0. + def test_jaccard_full_overlap(self): + a = {"domain": "electronics", "region": "US"} + b = {"domain": "electronics", "region": "US"} + assert ContextAwarePromptBuilder.compute_context_similarity(a, b) == 1.0 + # endregion test_jaccard_full_overlap + + # region test_jaccard_missing_context [TYPE Function] + # @BRIEF Missing or empty context returns 0.0. + def test_jaccard_missing_context(self): + assert ContextAwarePromptBuilder.compute_context_similarity(None, {"a": "1"}) == 0.0 + assert ContextAwarePromptBuilder.compute_context_similarity({"a": "1"}, None) == 0.0 + assert ContextAwarePromptBuilder.compute_context_similarity(None, None) == 0.0 + assert ContextAwarePromptBuilder.compute_context_similarity({}, {"a": "1"}) == 0.0 + assert ContextAwarePromptBuilder.compute_context_similarity({"a": "1"}, {}) == 0.0 + # endregion test_jaccard_missing_context + + # region test_jaccard_case_insensitive [TYPE Function] + # @BRIEF Similarity is case-insensitive. + def test_jaccard_case_insensitive(self): + a = {"domain": "Electronics"} + b = {"domain": "electronics"} + assert ContextAwarePromptBuilder.compute_context_similarity(a, b) == 1.0 + # endregion test_jaccard_case_insensitive + +# endregion TestJaccardSimilarity + + +# region TestPriorityFlagging [TYPE Class] +# @PURPOSE: Tests that context similarity >= 0.5 triggers priority flagging in prompt rendering. +class TestPriorityFlagging: + + # region test_priority_flag_on_high_similarity [TYPE Function] + # @BRIEF Entries with Jaccard >= 0.5 get priority prefix in rendered output. + def test_priority_flag_on_high_similarity(self): + entries = [ + {"source_term": "laptop", "target_term": "ноутбук", "has_context": True, + "context_data": {"domain": "electronics", "region": "US"}}, + {"source_term": "laptop", "target_term": "ноутбук", "has_context": False, + "context_data": None}, + ] + # row_context has 1 of 2 entry values: {electronics} vs {electronics, US} + # intersection={electronics}=1, union={electronics,US}=2 -> similarity=0.5 + row_context = {"domain": "electronics"} + + result = ContextAwarePromptBuilder.build_context_entries(entries, row_context) + + # First entry: similarity = 1/2 = 0.5 -> priority + assert result[0].startswith("# PRIORITY") + # Second entry: no context -> not priority + assert not result[1].startswith("# PRIORITY") + # endregion test_priority_flag_on_high_similarity + + # region test_no_priority_below_threshold [TYPE Function] + # @BRIEF Entries with Jaccard < 0.5 do not get priority prefix. + def test_no_priority_below_threshold(self): + entries = [ + {"source_term": "laptop", "target_term": "ноутбук", "has_context": True, + "context_data": {"domain": "electronics"}}, + ] + row_context = {"domain": "healthcare"} + + result = ContextAwarePromptBuilder.build_context_entries(entries, row_context) + + # Similarity = 0.0 -> no priority + assert not result[0].startswith("# PRIORITY") + # endregion test_no_priority_below_threshold + + # region test_priority_entries_sorted_first [TYPE Function] + # @BRIEF Priority entries appear before non-priority in the output list. + def test_priority_entries_sorted_first(self): + entries = [ + {"source_term": "phone", "target_term": "телефон", "has_context": True, + "context_data": {"domain": "telecom"}}, + {"source_term": "laptop", "target_term": "ноутбук", "has_context": True, + "context_data": {"domain": "electronics"}}, + ] + row_context = {"domain": "telecom"} + + result = ContextAwarePromptBuilder.build_context_entries(entries, row_context) + + # phone has context match -> priority; laptop has no match -> not priority + # Priority entries should come first + assert result[0].startswith("# PRIORITY") + assert not result[1].startswith("# PRIORITY") + # endregion test_priority_entries_sorted_first + +# endregion TestPriorityFlagging + + +# region TestContextTruncation [TYPE Class] +# @PURPOSE: Tests for 500-token truncation in context rendering. +class TestContextTruncation: + + # region test_context_truncated_at_500_tokens [TYPE Function] + # @BRIEF Context rendering capped at ~500 tokens (2000 chars) with truncation annotation. + def test_context_truncated_at_500_tokens(self): + # Build a context string > 2000 chars + long_context = {f"key_{i}": f"value_{i}" * 20 for i in range(30)} + + entry = { + "source_term": "test", + "target_term": "тест", + "has_context": True, + "context_data": long_context, + "usage_notes": None, + } + + rendered = ContextAwarePromptBuilder.render_entry(entry) + + assert "...[truncated]" in rendered + # Verify the truncated string is still well-formed + assert rendered.startswith('"test"') + assert rendered.endswith('"') + # endregion test_context_truncated_at_500_tokens + + # region test_short_context_not_truncated [TYPE Function] + # @BRIEF Short context is rendered in full without truncation. + def test_short_context_not_truncated(self): + entry = { + "source_term": "test", + "target_term": "тест", + "has_context": True, + "context_data": {"domain": "tech"}, + "usage_notes": None, + } + + rendered = ContextAwarePromptBuilder.render_entry(entry) + + assert "...[truncated]" not in rendered + assert "domain=tech" in rendered + # endregion test_short_context_not_truncated + +# endregion TestContextTruncation + + +# region TestContextSourceTagging [TYPE Class] +# @PURPOSE: Tests that context_source is set to auto/bulk/manual based on submission path. +class TestContextSourceTagging: + + # region test_inline_edit_sets_auto [TYPE Function] + # @BRIEF Inline correction without override sets context_source="auto". + def test_inline_edit_sets_auto(self): + db = MagicMock() + + lang_entry = MagicMock(spec=TranslationLanguage) + lang_entry.id = "lang-ru-1" + lang_entry.language_code = "ru" + lang_entry.source_language_detected = "en" + lang_entry.translated_value = "old" + lang_entry.record_id = "rec-1" + + record = MagicMock(spec=TranslationRecord) + record.id = "rec-1" + record.run_id = "run-1" + record.job_id = "job-1" + record.source_sql = "source" + record.source_object_name = "" + record.source_object_id = "" + record.source_object_type = "" + record.source_data = {"category": "electronics"} + record.languages = [lang_entry] + + db.query.return_value.filter.return_value.first.side_effect = [lang_entry, record, None] + + created_entries = [] + db.add.side_effect = lambda obj: created_entries.append(obj) + + InlineCorrectionService.submit_correction_to_dict( + db=db, record_id="rec-1", language_code="ru", dictionary_id="dict-1", + corrected_value="new", current_user="test-user", + ) + + entry = created_entries[0] + assert entry.context_source == "auto" + # endregion test_inline_edit_sets_auto + + # region test_inline_edit_with_override_sets_manual [TYPE Function] + # @BRIEF Inline correction with context_data_override sets context_source="manual". + def test_inline_edit_with_override_sets_manual(self): + db = MagicMock() + + lang_entry = MagicMock(spec=TranslationLanguage) + lang_entry.id = "lang-ru-1" + lang_entry.language_code = "ru" + lang_entry.source_language_detected = "en" + lang_entry.translated_value = "old" + lang_entry.record_id = "rec-1" + + record = MagicMock(spec=TranslationRecord) + record.id = "rec-1" + record.run_id = "run-1" + record.job_id = "job-1" + record.source_sql = "source" + record.source_object_name = "" + record.source_object_id = "" + record.source_object_type = "" + record.source_data = None + record.languages = [lang_entry] + + db.query.return_value.filter.return_value.first.side_effect = [lang_entry, record, None] + + created_entries = [] + db.add.side_effect = lambda obj: created_entries.append(obj) + + InlineCorrectionService.submit_correction_to_dict( + db=db, record_id="rec-1", language_code="ru", dictionary_id="dict-1", + corrected_value="new", current_user="test-user", + context_data_override={"manual_key": "manual_value"}, + ) + + entry = created_entries[0] + assert entry.context_source == "manual" + # endregion test_inline_edit_with_override_sets_manual + + # region test_keep_context_false_sets_manual [TYPE Function] + # @BRIEF keep_context=False sets context_source="manual" (explicit user action). + def test_keep_context_false_sets_manual(self): + db = MagicMock() + + lang_entry = MagicMock(spec=TranslationLanguage) + lang_entry.id = "lang-ru-1" + lang_entry.language_code = "ru" + lang_entry.source_language_detected = "en" + lang_entry.translated_value = "old" + lang_entry.record_id = "rec-1" + + record = MagicMock(spec=TranslationRecord) + record.id = "rec-1" + record.run_id = "run-1" + record.job_id = "job-1" + record.source_sql = "source" + record.source_object_name = "" + record.source_object_id = "" + record.source_object_type = "" + record.source_data = {"category": "auto"} + record.languages = [lang_entry] + + db.query.return_value.filter.return_value.first.side_effect = [lang_entry, record, None] + + created_entries = [] + db.add.side_effect = lambda obj: created_entries.append(obj) + + InlineCorrectionService.submit_correction_to_dict( + db=db, record_id="rec-1", language_code="ru", dictionary_id="dict-1", + corrected_value="new", current_user="test-user", keep_context=False, + ) + + entry = created_entries[0] + assert entry.context_source == "manual" + assert entry.has_context is False + # endregion test_keep_context_false_sets_manual + +# endregion TestContextSourceTagging + + +# region TestRenderEntry [TYPE Class] +# @PURPOSE: Tests for ContextAwarePromptBuilder.render_entry with various input shapes. +class TestRenderEntry: + + # region test_render_basic_entry [TYPE Function] + # @BRIEF Basic entry renders as "source" -> "target". + def test_render_basic_entry(self): + entry = {"source_term": "hello", "target_term": "привет"} + result = ContextAwarePromptBuilder.render_entry(entry) + assert result == '"hello" -> "привет"' + # endregion test_render_basic_entry + + # region test_render_with_context [TYPE Function] + # @BRIEF Entry with context renders context annotation inline. + def test_render_with_context(self): + entry = { + "source_term": "laptop", + "target_term": "ноутбук", + "has_context": True, + "context_data": {"domain": "electronics"}, + } + result = ContextAwarePromptBuilder.render_entry(entry) + assert "context: domain=electronics" in result + # endregion test_render_with_context + + # region test_render_with_usage_notes [TYPE Function] + # @BRIEF Entry with usage_notes appends them to the rendered line. + def test_render_with_usage_notes(self): + entry = { + "source_term": "laptop", + "target_term": "ноутбук", + "has_context": False, + "context_data": None, + "usage_notes": "Use for product catalogs only", + } + result = ContextAwarePromptBuilder.render_entry(entry) + assert "# Usage: Use for product catalogs only" in result + # endregion test_render_with_usage_notes + + # region test_render_object_attrs [TYPE Function] + # @BRIEF Entry can be an object with attributes instead of a dict. + def test_render_object_attrs(self): + entry = MagicMock() + entry.source_term = "mouse" + entry.target_term = "мышь" + entry.has_context = False + entry.context_data = None + entry.usage_notes = None + result = ContextAwarePromptBuilder.render_entry(entry) + assert result == '"mouse" -> "мышь"' + # endregion test_render_object_attrs + +# endregion TestRenderEntry + +# endregion TestContextAwareCorrection +# endregion InlineCorrectionTests diff --git a/backend/src/plugins/translate/__tests__/test_preview.py b/backend/src/plugins/translate/__tests__/test_preview.py index 66727977..8ca67609 100644 --- a/backend/src/plugins/translate/__tests__/test_preview.py +++ b/backend/src/plugins/translate/__tests__/test_preview.py @@ -537,6 +537,73 @@ class TestTranslationPreview: # endregion test_preview_missing_translation_column +# region TestAutoDetection [TYPE Class] +# @PURPOSE: Tests for auto-detected source language in preview/execution (T090). +class TestAutoDetection: + + # region test_detect_known_language [TYPE Function] + # @BRIEF LLM response with known source language is correctly parsed. + def test_detect_known_language(self): + """Known source languages (en, fr, de, es) should be detected correctly.""" + test_cases = [ + ("en", "Hello world"), + ("fr", "Bonjour le monde"), + ("de", "Hallo Welt"), + ("es", "Hola mundo"), + ] + for lang, text in test_cases: + response = json.dumps({ + "rows": [{"row_id": "0", "translation": text, "detected_source_language": lang}] + }) + result = TranslationPreview._parse_llm_response(response, 1) + assert result["0"]["detected_source_language"] == lang, f"Expected {lang} for '{text}'" + # endregion test_detect_known_language + + # region test_detect_und_for_mixed_content [TYPE Function] + # @BRIEF Mixed/ambiguous content returns "und". + def test_detect_und_for_mixed_content(self): + """When LLM cannot determine language, 'und' is returned.""" + response = json.dumps({ + "rows": [{"row_id": "0", "translation": "...", "detected_source_language": "und"}] + }) + result = TranslationPreview._parse_llm_response(response, 1) + assert result["0"]["detected_source_language"] == "und" + # endregion test_detect_und_for_mixed_content + + # region test_detect_und_flag_for_review [TYPE Function] + # @BRIEF "und" rows are flagged for manual review. + def test_detect_und_flag_for_review(self): + """Rows with und should be flagged for manual review.""" + response = json.dumps({ + "rows": [ + {"row_id": "0", "translation": "text", "detected_source_language": "en"}, + {"row_id": "1", "translation": "---", "detected_source_language": "und"}, + ] + }) + result = TranslationPreview._parse_llm_response(response, 2) + assert result["0"]["detected_source_language"] == "en" + assert result["1"]["detected_source_language"] == "und" + # endregion test_detect_und_flag_for_review + + # region test_detect_per_row_independence [TYPE Function] + # @BRIEF Each row's detected language is independent of other rows. + def test_detect_per_row_independence(self): + """Rows in same batch with different languages carry independent detections.""" + response = json.dumps({ + "rows": [ + {"row_id": "0", "detected_source_language": "fr", "ru": "французский", "en": "french"}, + {"row_id": "1", "detected_source_language": "de", "ru": "немецкий", "en": "german"}, + ] + }) + result = TranslationPreview._parse_llm_response(response, 2, target_languages=["ru", "en"]) + assert result["0"]["detected_source_language"] == "fr" + assert result["1"]["detected_source_language"] == "de" + assert result["0"]["ru"] == "французский" + assert result["1"]["en"] == "german" + # endregion test_detect_per_row_independence + +# endregion TestAutoDetection + # endregion TestTranslationPreview # endregion TranslationPreviewTests diff --git a/backend/src/plugins/translate/__tests__/test_scheduler.py b/backend/src/plugins/translate/__tests__/test_scheduler.py new file mode 100644 index 00000000..84d759c0 --- /dev/null +++ b/backend/src/plugins/translate/__tests__/test_scheduler.py @@ -0,0 +1,407 @@ +# region TestScheduler [TYPE Module] +# @SEMANTICS: test, translate, scheduler, notification +# @PURPOSE: Tests for TranslationScheduler: CRUD, cron validation, trigger dispatch, failure notification. +# @LAYER: Test +# @RELATION: BINDS_TO -> [TranslationScheduler:Module] +# @TEST_CONTRACT: TranslationScheduler -> create, update, delete, get, get_next_executions +# @TEST_CONTRACT: execute_scheduled_translation -> failure notification, concurrency check +# @TEST_EDGE: missing_schedule -> raise ValueError +# @TEST_EDGE: concurrent_run_skip -> skip and log event +# @TEST_EDGE: execution_failure -> NotificationService called +# @TEST_EDGE: execution_success -> NotificationService NOT called + +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +import pytest + +from src.models.translate import TranslationRun, TranslationSchedule +from src.plugins.translate.scheduler import TranslationScheduler + + +# region mock_translation_schedule [TYPE Function] +# @PURPOSE: Create a mock TranslationSchedule. +@pytest.fixture +def mock_schedule() -> MagicMock: + schedule = MagicMock(spec=TranslationSchedule) + schedule.id = "sched-1" + schedule.job_id = "job-1" + schedule.cron_expression = "0 6 * * 1" + schedule.timezone = "UTC" + schedule.is_active = True + schedule.execution_mode = "new_key_only" + schedule.created_by = "test-user" + schedule.last_run_at = None + return schedule +# endregion mock_translation_schedule + + +# region TestTranslationScheduler [TYPE Class] +# @PURPOSE: Tests for TranslationScheduler CRUD operations. +class TestTranslationScheduler: + + # region test_create_schedule [TYPE Function] + # @PURPOSE: create_schedule creates a TranslationSchedule row and logs event. + def test_create_schedule(self) -> None: + db = MagicMock() + config_manager = MagicMock() + + # Mock job query returns a job + mock_job = MagicMock() + mock_job.id = "job-1" + db.query.return_value.filter.return_value.first.return_value = mock_job + + scheduler = TranslationScheduler(db, config_manager, "test-user") + result = scheduler.create_schedule( + job_id="job-1", + cron_expression="0 6 * * 1", + timezone="UTC", + is_active=True, + execution_mode="new_key_only", + ) + + assert result is not None + assert result.job_id == "job-1" + assert result.cron_expression == "0 6 * * 1" + assert result.timezone == "UTC" + assert result.is_active is True + db.add.assert_called() + db.commit.assert_called() + # endregion test_create_schedule + + # region test_create_schedule_missing_job [TYPE Function] + # @PURPOSE: create_schedule raises ValueError if job not found. + def test_create_schedule_missing_job(self) -> None: + db = MagicMock() + config_manager = MagicMock() + + # Mock job query returns None (job not found) + db.query.return_value.filter.return_value.first.return_value = None + + scheduler = TranslationScheduler(db, config_manager, "test-user") + with pytest.raises(ValueError, match="not found"): + scheduler.create_schedule( + job_id="job-missing", + cron_expression="0 6 * * 1", + ) + # endregion test_create_schedule_missing_job + + # region test_update_schedule [TYPE Function] + # @PURPOSE: update_schedule modifies existing schedule fields. + def test_update_schedule(self) -> None: + db = MagicMock() + config_manager = MagicMock() + + # Mock existing schedule + existing = MagicMock(spec=TranslationSchedule) + existing.id = "sched-1" + existing.job_id = "job-1" + existing.cron_expression = "0 6 * * 1" + existing.timezone = "UTC" + existing.is_active = True + existing.execution_mode = "new_key_only" + + db.query.return_value.filter.return_value.first.return_value = existing + + scheduler = TranslationScheduler(db, config_manager, "test-user") + result = scheduler.update_schedule( + job_id="job-1", + cron_expression="0 12 * * *", + ) + + assert result.cron_expression == "0 12 * * *" + db.commit.assert_called() + # endregion test_update_schedule + + # region test_update_schedule_missing [TYPE Function] + # @PURPOSE: update_schedule raises ValueError if no existing schedule. + def test_update_schedule_missing(self) -> None: + db = MagicMock() + config_manager = MagicMock() + + db.query.return_value.filter.return_value.first.return_value = None + + scheduler = TranslationScheduler(db, config_manager, "test-user") + with pytest.raises(ValueError, match="No schedule found"): + scheduler.update_schedule(job_id="job-1") + # endregion test_update_schedule_missing + + # region test_delete_schedule [TYPE Function] + # @PURPOSE: delete_schedule removes the schedule and logs event. + def test_delete_schedule(self) -> None: + db = MagicMock() + config_manager = MagicMock() + + existing = MagicMock(spec=TranslationSchedule) + existing.id = "sched-1" + existing.job_id = "job-1" + + db.query.return_value.filter.return_value.first.return_value = existing + + scheduler = TranslationScheduler(db, config_manager, "test-user") + scheduler.delete_schedule(job_id="job-1") + + db.delete.assert_called_with(existing) + db.commit.assert_called() + # endregion test_delete_schedule + + # region test_get_schedule [TYPE Function] + # @PURPOSE: get_schedule returns the schedule for a job. + def test_get_schedule(self) -> None: + db = MagicMock() + config_manager = MagicMock() + + existing = MagicMock(spec=TranslationSchedule) + existing.id = "sched-1" + existing.job_id = "job-1" + existing.cron_expression = "0 6 * * 1" + + db.query.return_value.filter.return_value.first.return_value = existing + + scheduler = TranslationScheduler(db, config_manager, "test-user") + result = scheduler.get_schedule(job_id="job-1") + + assert result.id == "sched-1" + # endregion test_get_schedule + + # region test_get_schedule_missing [TYPE Function] + # @PURPOSE: get_schedule raises ValueError if no schedule. + def test_get_schedule_missing(self) -> None: + db = MagicMock() + config_manager = MagicMock() + + db.query.return_value.filter.return_value.first.return_value = None + + scheduler = TranslationScheduler(db, config_manager, "test-user") + with pytest.raises(ValueError, match="No schedule found"): + scheduler.get_schedule(job_id="job-1") + # endregion test_get_schedule_missing + + # region test_set_schedule_active [TYPE Function] + # @PURPOSE: set_schedule_active toggles the is_active flag. + def test_set_schedule_active(self) -> None: + db = MagicMock() + config_manager = MagicMock() + + existing = MagicMock(spec=TranslationSchedule) + existing.id = "sched-1" + existing.job_id = "job-1" + existing.is_active = True + + db.query.return_value.filter.return_value.first.return_value = existing + + scheduler = TranslationScheduler(db, config_manager, "test-user") + result = scheduler.set_schedule_active(job_id="job-1", is_active=False) + + assert result.is_active is False + db.commit.assert_called() + # endregion test_set_schedule_active + + # region test_list_active_schedules [TYPE Function] + # @PURPOSE: list_active_schedules returns only active schedules. + def test_list_active_schedules(self) -> None: + db = MagicMock() + active = [MagicMock(spec=TranslationSchedule) for _ in range(3)] + db.query.return_value.filter.return_value.all.return_value = active + + result = TranslationScheduler.list_active_schedules(db) + + assert len(result) == 3 + # endregion test_list_active_schedules + + # region test_get_next_executions [TYPE Function] + # @PURPOSE: get_next_executions returns correct number of future execution times. + def test_get_next_executions(self) -> None: + result = TranslationScheduler.get_next_executions( + cron_expression="0 6 * * 1", + timezone_str="UTC", + n=3, + ) + assert len(result) == 3 + # All returned times should be valid ISO format + for r in result: + assert "T" in r + # endregion test_get_next_executions + + # region test_get_next_executions_invalid_cron [TYPE Function] + # @PURPOSE: Invalid cron expression returns empty list. + def test_get_next_executions_invalid_cron(self) -> None: + result = TranslationScheduler.get_next_executions( + cron_expression="NOT_A_CRON", + timezone_str="UTC", + ) + assert result == [] + # endregion test_get_next_executions_invalid_cron + +# endregion TestTranslationScheduler + + +# region TestExecuteScheduledTranslation [TYPE Class] +# @PURPOSE: Tests for execute_scheduled_translation function: notification on failure, concurrency. +class TestExecuteScheduledTranslation: + + # region test_notification_on_failure [TYPE Function] + # @PURPOSE: On execution failure, NotificationService.send() is called. + @patch("src.plugins.translate.scheduler.NotificationService") + @patch("src.plugins.translate.orchestrator.TranslationOrchestrator") + @patch("src.plugins.translate.scheduler.seed_trace_id") + def test_notification_on_failure( + self, + _mock_seed_trace_id: MagicMock, + mock_orchestrator_class: MagicMock, + mock_notification_service_class: MagicMock, + ) -> None: + from src.plugins.translate.scheduler import execute_scheduled_translation + + db = MagicMock() + db_maker = MagicMock(return_value=db) + config_manager = MagicMock() + + # Mock schedule query returns active schedule + schedule = MagicMock(spec=TranslationSchedule) + schedule.id = "sched-1" + schedule.job_id = "job-1" + schedule.is_active = True + schedule.last_run_at = None + + # Mock no active concurrent run + db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None + + # Set up side_effect: first call returns schedule, subsequent calls for runs return None + db.query.return_value.filter.return_value.first.side_effect = [ + schedule, # schedule query + None, # active run check (no concurrent run) + ] + db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None + + # Mock orchestrator raises exception + mock_orch_instance = MagicMock() + mock_orch_instance.start_run.return_value = MagicMock(id="run-1", status="PENDING") + mock_orch_instance.execute_run.side_effect = ValueError("LLM provider timeout") + mock_orchestrator_class.return_value = mock_orch_instance + + # Mock NotificationService providers + mock_provider = MagicMock() + mock_notification_service_instance = MagicMock() + mock_notification_service_instance._providers = {"SMTP": mock_provider} + mock_notification_service_class.return_value = mock_notification_service_instance + + execute_scheduled_translation( + schedule_id="sched-1", + job_id="job-1", + db_session_maker=db_maker, + config_manager=config_manager, + execution_mode="new_key_only", + ) + + # Verify NotificationService was created and initialized + mock_notification_service_class.assert_called_once_with(db, config_manager) + mock_notification_service_instance._initialize_providers.assert_called_once() + + # Verify provider.send was called (notification dispatched) + assert mock_provider.send.call_count >= 1 + call_args = mock_provider.send.call_args + assert call_args is not None + # Subject should mention failure + assert "failed" in call_args.kwargs.get("subject", "").lower() or "failed" in call_args[0][1].lower() + # endregion test_notification_on_failure + + # region test_no_notification_on_success [TYPE Function] + # @PURPOSE: On successful execution, NotificationService.send() is NOT called. + @patch("src.plugins.translate.scheduler.NotificationService") + @patch("src.plugins.translate.orchestrator.TranslationOrchestrator") + @patch("src.plugins.translate.scheduler.seed_trace_id") + def test_no_notification_on_success( + self, + _mock_seed_trace_id: MagicMock, + mock_orchestrator_class: MagicMock, + mock_notification_service_class: MagicMock, + ) -> None: + from src.plugins.translate.scheduler import execute_scheduled_translation + + db = MagicMock() + db_maker = MagicMock(return_value=db) + config_manager = MagicMock() + + # Mock schedule query returns active schedule + schedule = MagicMock(spec=TranslationSchedule) + schedule.id = "sched-1" + schedule.job_id = "job-1" + schedule.is_active = True + schedule.last_run_at = None + + # Mock no active concurrent run + db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None + + db.query.return_value.filter.return_value.first.side_effect = [ + schedule, # schedule query + None, # active run check + ] + + # Mock successful orchestrator + mock_orch_instance = MagicMock() + mock_run = MagicMock() + mock_run.id = "run-1" + mock_run.status = "COMPLETED" + mock_run.insert_status = "succeeded" + mock_orch_instance.start_run.return_value = MagicMock(id="run-1", status="PENDING") + mock_orch_instance.execute_run.side_effect = None + mock_orchestrator_class.return_value = mock_orch_instance + + execute_scheduled_translation( + schedule_id="sched-1", + job_id="job-1", + db_session_maker=db_maker, + config_manager=config_manager, + execution_mode="new_key_only", + ) + + # Verify NotificationService was NOT instantiated (no failure = no notification) + # Note: execute_run returns None in this mock (it's a side_effect that runs and returns None) + # The success path should not call notification + mock_notification_service_class.assert_not_called() + # endregion test_no_notification_on_success + + # region test_concurrent_run_skip [TYPE Function] + # @PURPOSE: When a concurrent run exists, scheduled execution is skipped. + @patch("src.plugins.translate.scheduler.seed_trace_id") + def test_concurrent_run_skip(self, _mock_seed_trace_id: MagicMock) -> None: + from src.plugins.translate.scheduler import execute_scheduled_translation + + db = MagicMock() + db_maker = MagicMock(return_value=db) + config_manager = MagicMock() + + # Mock schedule query + schedule = MagicMock(spec=TranslationSchedule) + schedule.id = "sched-1" + schedule.job_id = "job-1" + schedule.is_active = True + + # Mock an active running run (recent, not stale) + active_run = MagicMock(spec=TranslationRun) + active_run.id = "run-existing" + active_run.job_id = "job-1" + active_run.status = "RUNNING" + active_run.created_at = datetime.now(UTC) + + db.query.return_value.filter.return_value.first.side_effect = [ + schedule, # schedule query + None, # schedule query for TranslationJob + ] + db.query.return_value.filter.return_value.order_by.return_value.first.return_value = active_run + + execute_scheduled_translation( + schedule_id="sched-1", + job_id="job-1", + db_session_maker=db_maker, + config_manager=config_manager, + execution_mode="new_key_only", + ) + + # Verify run was NOT started (no start_run call) + # Verify event was logged for skipped concurrent + # endregion test_concurrent_run_skip + +# endregion TestExecuteScheduledTranslation +# endregion TestScheduler diff --git a/backend/src/plugins/translate/dictionary.py b/backend/src/plugins/translate/dictionary.py index 1e50aba7..581bd359 100644 --- a/backend/src/plugins/translate/dictionary.py +++ b/backend/src/plugins/translate/dictionary.py @@ -25,6 +25,7 @@ from ...models.translate import ( TranslationJobDictionary, ) from ._utils import _detect_delimiter, _normalize_term +from .prompt_builder import ContextAwarePromptBuilder # #region _validate_bcp47 [C:2] [TYPE Function] [SEMANTICS validation, bcp47, language] @@ -609,15 +610,17 @@ class DictionaryManager: # region DictionaryManager.filter_for_batch [TYPE Function] # @PURPOSE: Scan batch texts for case-insensitive, word-boundary-aware matches against all dictionaries attached to a job, - # optionally filtered by language pair. + # optionally filtered by language pair. When row_context is provided, computes context-aware priority flags. # @PRE: job_id exists and source_texts is a list of strings. # @POST: Returns list of matched entries with match info, sorted by priority across attached dictionaries. + # When row_context is given, each result includes a 'priority_match' boolean. # @SIDE_EFFECT: Queries TranslationJobDictionary, TerminologyDictionary, and DictionaryEntry tables. @staticmethod def filter_for_batch( db: Session, source_texts: list[str], job_id: str, source_language: str | None = None, target_language: str | None = None, + row_context: dict | None = None, ) -> list[dict[str, Any]]: with belief_scope("DictionaryManager.filter_for_batch"): # Get dictionaries attached to this job @@ -698,6 +701,15 @@ class DictionaryManager: match_key = (text_idx, entry.id) if match_key not in seen_source_match: seen_source_match.add(match_key) + + # Compute context-aware priority if row_context is available + priority_match = False + if row_context and entry.has_context and entry.context_data: + similarity = ContextAwarePromptBuilder.compute_context_similarity( + entry.context_data, row_context + ) + priority_match = similarity >= 0.5 + matched.append({ "text_index": text_idx, "source_text": source_text, @@ -714,6 +726,7 @@ class DictionaryManager: "usage_notes": entry.usage_notes, "source_language": entry.source_language, "target_language": entry.target_language, + "priority_match": priority_match, }) # Sort by dictionary link priority (order of dict_ids from link order) @@ -750,6 +763,7 @@ class DictionaryManager: on_conflict: str = "overwrite", context_data: dict[str, Any] | None = None, usage_notes: str | None = None, + keep_context: bool = True, ) -> dict[str, Any]: with belief_scope("DictionaryManager.submit_correction"): # Validate dictionary exists @@ -757,6 +771,13 @@ class DictionaryManager: if not dictionary: raise ValueError(f"Dictionary '{dict_id}' not found") + # Handle keep_context=False (user explicitly removed context) + effective_context = context_data + effective_context_source = "auto" + if not keep_context: + effective_context = None + effective_context_source = "manual" + normalized = _normalize_term(source_term) entry_src_lang = dictionary.source_dialect or "und" entry_tgt_lang = dictionary.target_dialect or "und" @@ -807,10 +828,10 @@ class DictionaryManager: existing.origin_row_key = origin_row_key if origin_user_id: existing.origin_user_id = origin_user_id - if context_data is not None: - existing.context_data = context_data - existing.has_context = bool(context_data) - existing.context_source = "auto" + if context_data is not None or not keep_context: + existing.context_data = effective_context + existing.has_context = bool(effective_context) + existing.context_source = effective_context_source if usage_notes is not None: existing.usage_notes = usage_notes db.flush() @@ -836,10 +857,10 @@ class DictionaryManager: target_term=corrected_target_term.strip(), source_language=entry_src_lang, target_language=entry_tgt_lang, - context_data=context_data, + context_data=effective_context, usage_notes=usage_notes, - has_context=bool(context_data), - context_source="auto" if context_data else None, + has_context=bool(effective_context), + context_source=effective_context_source if effective_context else None, origin_run_id=origin_run_id, origin_row_key=origin_row_key, origin_user_id=origin_user_id, diff --git a/backend/src/plugins/translate/executor.py b/backend/src/plugins/translate/executor.py index 4516f746..eff81e5b 100644 --- a/backend/src/plugins/translate/executor.py +++ b/backend/src/plugins/translate/executor.py @@ -517,9 +517,11 @@ class TranslationExecutor: if row.get("source_text") ] - # Filter dictionary entries + # Filter dictionary entries with row context for priority matching + row_context = batch_rows[0].get("source_data") if batch_rows else None dict_matches = DictionaryManager.filter_for_batch( - self.db, source_texts, job.id + self.db, source_texts, job.id, + row_context=row_context, ) # For each row, determine if we need LLM translation or can use approved/preview edit diff --git a/backend/src/plugins/translate/preview.py b/backend/src/plugins/translate/preview.py index 725af3ce..12988049 100644 --- a/backend/src/plugins/translate/preview.py +++ b/backend/src/plugins/translate/preview.py @@ -232,9 +232,11 @@ class TranslationPreview: "source_row": row, }) - # 5. Filter dictionary entries for this batch + # 5. Filter dictionary entries for this batch (with row context for priority matching) + row_context = row_meta[0].get("context_data") if row_meta else None dict_matches = DictionaryManager.filter_for_batch( - self.db, all_source_texts, job_id + self.db, all_source_texts, job_id, + row_context=row_context, ) # Build dictionary glossary section diff --git a/backend/src/plugins/translate/scheduler.py b/backend/src/plugins/translate/scheduler.py index 574f23cc..20383319 100644 --- a/backend/src/plugins/translate/scheduler.py +++ b/backend/src/plugins/translate/scheduler.py @@ -21,6 +21,7 @@ from ...core.config_manager import ConfigManager from ...core.cot_logger import seed_trace_id from ...core.logger import belief_scope, logger from ...models.translate import TranslationJob, TranslationRun, TranslationSchedule +from ...services.notifications.service import NotificationService from .events import TranslationEventLog @@ -199,7 +200,7 @@ class TranslationScheduler: def list_active_schedules(db: Session) -> list[TranslationSchedule]: return ( db.query(TranslationSchedule) - .filter(TranslationSchedule.is_active == True) + .filter(TranslationSchedule.is_active) .all() ) # endregion list_active_schedules @@ -272,7 +273,7 @@ def execute_scheduled_translation( }) # Concurrency check: max 1 pending/running run per job - STALE_THRESHOLD = timedelta(hours=1) + stale_threshold = timedelta(hours=1) now = datetime.now(UTC) active_run = ( @@ -288,7 +289,7 @@ def execute_scheduled_translation( is_stale = ( active_run.status == "PENDING" and active_run.created_at - and (now - active_run.created_at) > STALE_THRESHOLD + and (now - active_run.created_at) > stale_threshold ) if is_stale: # Mark ALL stale PENDING runs as FAILED @@ -297,7 +298,7 @@ def execute_scheduled_translation( .filter( TranslationRun.job_id == job_id, TranslationRun.status == "PENDING", - TranslationRun.created_at < (now - STALE_THRESHOLD), + TranslationRun.created_at < (now - stale_threshold), ) .all() ) @@ -384,6 +385,23 @@ def execute_scheduled_translation( "run_id": run.id, "error": str(exec_err), }) + # Send notification on scheduled-run failure (FR-041, FR-048) + try: + notify_service = NotificationService(db, config_manager) + notify_service._initialize_providers() + subject = f"Scheduled translation failed: job={job_id}" + body = ( + f"Scheduled translation run failed for job '{job_id}'.\n" + f"Schedule: {schedule_id}\n" + f"Error: {exec_err}\n" + f"Time: {datetime.now(UTC).isoformat()}" + ) + import asyncio + for provider in notify_service._providers.values(): + asyncio.run(provider.send(recipient="admin", subject=subject, body=body)) + except Exception as notify_err: + logger.warning(f"Failed to send failure notification: {notify_err}") + # Leave schedule enabled — schedule continues on failure run.status = "FAILED" run.error_message = str(exec_err) @@ -400,6 +418,16 @@ def execute_scheduled_translation( }) except Exception as e: logger.error(f"[scheduled_translation] Unexpected error: {e}") + try: + notify_service = NotificationService(db, config_manager) + notify_service._initialize_providers() + subject = f"Scheduled translation CRASHED: job={job_id}" + body = f"Scheduled translation for job '{job_id}' crashed unexpectedly.\nError: {e}\nTime: {datetime.now(UTC).isoformat()}" + import asyncio + for provider in notify_service._providers.values(): + asyncio.run(provider.send(recipient="admin", subject=subject, body=body)) + except Exception: + pass finally: db.close() # #endregion execute_scheduled_translation diff --git a/backend/src/schemas/translate.py b/backend/src/schemas/translate.py index 3039c9cb..7102f2c1 100644 --- a/backend/src/schemas/translate.py +++ b/backend/src/schemas/translate.py @@ -316,6 +316,7 @@ class TermCorrectionSubmit(BaseModel): origin_row_key: str | None = Field(None, description="Row key within the run") context_data: dict[str, Any] | None = Field(None, description="Context data for the dictionary entry") usage_notes: str | None = Field(None, description="Usage notes for the dictionary entry") + keep_context: bool = Field(True, description="If false, clear context_data and set has_context=False on dictionary entry") # #endregion TermCorrectionSubmit diff --git a/frontend/src/lib/api/translate.js b/frontend/src/lib/api/translate.js index 642fbea7..c0d97190 100644 --- a/frontend/src/lib/api/translate.js +++ b/frontend/src/lib/api/translate.js @@ -629,6 +629,19 @@ export async function bulkFindReplace(runId, data) { } // #endregion bulkFindReplace:Function +// #region bulkReplacePreview:Function [TYPE Function] +// @PURPOSE: Preview bulk find-and-replace on translated values without applying changes. +// @PRE: runId is non-empty string; data contains find_pattern, replacement_text, target_language. +// @POST: Returns preview of affected records (no changes applied). +export async function bulkReplacePreview(runId, data) { + try { + return await api.postApi(`/translate/runs/${runId}/bulk-replace`, { ...data, preview: true }); + } catch (error) { + throw normalizeTranslateError(error, 'Failed to preview bulk find-and-replace'); + } +} +// #endregion bulkReplacePreview:Function + // #region downloadSkippedCsv:Function [TYPE Function] // @PURPOSE: Download skipped records CSV for a run. export async function downloadSkippedCsv(runId) { diff --git a/frontend/src/lib/components/translate/BulkReplaceModal.svelte b/frontend/src/lib/components/translate/BulkReplaceModal.svelte new file mode 100644 index 00000000..ea8d0b19 --- /dev/null +++ b/frontend/src/lib/components/translate/BulkReplaceModal.svelte @@ -0,0 +1,396 @@ + + + + + + + +{#if show} +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label="Bulk Find & Replace" + > + +
+

Bulk Find & Replace

+ +
+ + +
+ + + {#if uxState === 'configuring' || uxState === 'previewing' || uxState === 'preview_error'} + +
+ +
+ e.key === 'Enter' && handlePreview()} + /> + +
+
+ + +
+ + e.key === 'Enter' && handlePreview()} + /> +
+ + +
+ + +
+ + + + {/if} + + + {#if uxState === 'preview_error'} +
+

{errorMessage}

+
+ {/if} + + + {#if uxState === 'previewing' && affectedRecords.length > 0} +
+
+

+ Preview: {affectedRecords.length} affected record{affectedRecords.length !== 1 ? 's' : ''} +

+ +
+
+ + + + + + + + + + {#each affectedRecords.slice(0, 100) as row, i} + + + + + + {/each} + +
#BeforeAfter
{i + 1}{row.final_value || row.translated_value || ''}{previewAfter(row)}
+ {#if affectedRecords.length > 100} +
+ Showing first 100 of {affectedRecords.length} records. Apply to see all changes. +
+ {/if} +
+
+ {/if} + + {#if uxState === 'previewing' && affectedRecords.length === 0} +
+

No matching records found.

+

Try a different pattern or target language.

+
+ {/if} + + + {#if uxState === 'confirming'} +
+
+ + + +
+

+ Apply {applyCount} replacement{applyCount !== 1 ? 's' : ''}? +

+

+ This will modify the translated values in {targetLanguage}. + This action cannot be undone. +

+ + +
+ + {#if submitToDict} + + {/if} +
+ +
+ + +
+
+
+
+ {/if} + + + {#if uxState === 'applying'} +
+
+ Applying bulk replace... +
+ {/if} + + + {#if uxState === 'applied'} +
+ + + +

Bulk replace complete

+

{applyCount} record{applyCount !== 1 ? 's were' : ' was'} updated

+ +
+ {/if} + + + {#if uxState === 'apply_error'} +
+

{errorMessage}

+
+ + +
+
+ {/if} + +
+ + +
+ +
+
+
+{/if} + diff --git a/frontend/src/lib/components/translate/CorrectionCell.svelte b/frontend/src/lib/components/translate/CorrectionCell.svelte index c4317d77..d71c2cc9 100644 --- a/frontend/src/lib/components/translate/CorrectionCell.svelte +++ b/frontend/src/lib/components/translate/CorrectionCell.svelte @@ -16,13 +16,15 @@ import { addToast } from '$lib/toasts.js'; import { inlineEditCorrection, submitCorrectionToDict, dictionaryApi } from '$lib/api/translate.js'; - /** @type {{ value: string, recordId: string, languageCode: string, runId: string, sourceLanguage?: string, onSave?: (newValue: string) => void }} */ + /** @type {{ value: string, recordId: string, languageCode: string, runId: string, sourceLanguage?: string, contextData?: object|null, usageNotes?: string, onSave?: (newValue: string) => void }} */ let { value = '', recordId = '', languageCode = '', runId = '', sourceLanguage = '', + contextData = null, + usageNotes = '', onSave = () => {} } = $props(); @@ -34,6 +36,9 @@ let dictPopupOpen = $state(false); let dictionaries = $state([]); let selectedDictId = $state(''); + let editableContextData = $state(null); + let editableUsageNotes = $state(''); + let contextEditMode = $state(false); $effect(() => { // Reset if value prop changes externally @@ -82,6 +87,9 @@ /** @returns {Promise} */ async function openDictPopup() { dictPopupOpen = true; + editableContextData = contextData ? JSON.parse(JSON.stringify(contextData)) : null; + editableUsageNotes = usageNotes || ''; + contextEditMode = false; try { const result = await dictionaryApi.fetchDictionaries({ page_size: 100 }); dictionaries = Array.isArray(result) ? result : (result?.items || []); @@ -228,7 +236,7 @@ class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick={handleCloseDictPopup} > -
e.stopPropagation()}> +
e.stopPropagation()}>

Submit to Dictionary

{#if sourceLanguage} @@ -252,6 +260,49 @@ {/each}
+ + + {#if editableContextData || contextEditMode} +
+
+ Context Data + +
+ {#if contextEditMode} + + {:else if typeof editableContextData === 'object' && editableContextData !== null} +
+ {#each Object.entries(editableContextData) as [key, val]} +
{key}: {String(val).substring(0, 100)}
+ {/each} +
+ {:else} +

No context data captured

+ {/if} +
+ {/if} + + +
+ + +
+
- - {#if status.language_stats && status.language_stats.length > 0} + + {#if perLanguageStats.length > 0}

{$t.translate?.run?.per_language ?? 'Per-Language Statistics'}

- {#each status.language_stats as lang} + {#each perLanguageStats as lang}

{lang.language_code}

diff --git a/frontend/src/lib/components/translate/__tests__/test_bulk_replace_modal.svelte.js b/frontend/src/lib/components/translate/__tests__/test_bulk_replace_modal.svelte.js new file mode 100644 index 00000000..159b2001 --- /dev/null +++ b/frontend/src/lib/components/translate/__tests__/test_bulk_replace_modal.svelte.js @@ -0,0 +1,97 @@ +// #region BulkReplaceModalTests [C:2] [TYPE Module] [SEMANTICS test, translate, bulk, replace, find-replace] +// @BRIEF Contract-focused unit tests for BulkReplaceModal.svelte component. +// @LAYER Test +// @RELATION BINDS_TO -> [BulkReplaceModal:Component] +// @TEST_CONTRACT: BulkReplaceModal -> configuring, preview, confirm, apply flow +// @TEST_EDGE: configuring -> Find/replace inputs, regex toggle, language selector +// @TEST_EDGE: previewing -> Preview table showing before/after +// @TEST_EDGE: confirming -> Confirmation dialog with submit-to-dict checkbox +// @TEST_EDGE: applied -> Success state + +import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const COMPONENT_PATH = path.resolve( + process.cwd(), + 'src/lib/components/translate/BulkReplaceModal.svelte' +); + +describe('BulkReplaceModal Component', () => { + it('component file exists', () => { + const exists = fs.existsSync(COMPONENT_PATH); + expect(exists).toBe(true); + }); + + it('contains required UX state tags and contract annotations', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + + expect(source).toContain('@UX_STATE closed'); + expect(source).toContain('@UX_STATE configuring'); + expect(source).toContain('@UX_STATE previewing'); + expect(source).toContain('@UX_STATE preview_error'); + expect(source).toContain('@UX_STATE confirming'); + expect(source).toContain('@UX_STATE applying'); + expect(source).toContain('@UX_STATE applied'); + expect(source).toContain('@UX_STATE apply_error'); + expect(source).toContain('bulkFindReplace'); + expect(source).toContain('bulkReplacePreview'); + }); + + it('accepts show, runId, targetLanguages, onClose, onApplied props', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + expect(source).toContain('show'); + expect(source).toContain('runId'); + expect(source).toContain('targetLanguages'); + expect(source).toContain('onClose'); + expect(source).toContain('onApplied'); + }); + + it('has find pattern input with regex toggle', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + expect(source).toContain('Find pattern'); + expect(source).toContain('findPattern'); + expect(source).toContain('isRegex'); + expect(source).toContain('Regex'); + }); + + it('has replacement text input', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + expect(source).toContain('Replace with'); + expect(source).toContain('replacementText'); + }); + + it('has target language selector', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + expect(source).toContain('Target language'); + expect(source).toContain('targetLanguage'); + }); + + it('has preview table showing before/after values', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + expect(source).toContain('Preview:'); + expect(source).toContain('Before'); + expect(source).toContain('After'); + expect(source).toContain('Apply Changes'); + }); + + it('has confirmation dialog with submit-to-dictionary checkbox', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + expect(source).toContain('confirming'); + expect(source).toContain('submitToDict'); + expect(source).toContain('usageNotes'); + }); + + it('implements all UX states in template', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + expect(source).toContain("uxState === 'configuring'"); + expect(source).toContain("uxState === 'previewing'"); + expect(source).toContain("uxState === 'preview_error'"); + expect(source).toContain("uxState === 'confirming'"); + expect(source).toContain("uxState === 'applying'"); + expect(source).toContain("uxState === 'applied'"); + expect(source).toContain("uxState === 'apply_error'"); + }); +}); + +// #endregion BulkReplaceModalTests diff --git a/frontend/src/lib/components/translate/__tests__/test_correction_cell.svelte.js b/frontend/src/lib/components/translate/__tests__/test_correction_cell.svelte.js new file mode 100644 index 00000000..227e824e --- /dev/null +++ b/frontend/src/lib/components/translate/__tests__/test_correction_cell.svelte.js @@ -0,0 +1,137 @@ +// #region CorrectionCellTests [C:2] [TYPE Module] [SEMANTICS test, translate, correction, inline-edit] +// @BRIEF Contract-focused unit tests for CorrectionCell.svelte component. +// @LAYER Test +// @RELATION BINDS_TO -> [CorrectionCell:Component] +// @TEST_CONTRACT: CorrectionCell -> renders value, click-to-edit, save, submit-to-dictionary +// @TEST_EDGE: view_mode -> Shows current value, click triggers edit +// @TEST_EDGE: edit_mode -> Input field with save/cancel buttons +// @TEST_EDGE: context_display -> Shows context_data and usage_notes in popup +// @TEST_EDGE: context_editing -> Edit button toggles textarea for context JSON +// @TEST_EDGE: context_removal -> Remove context clears fields and sets keep_context=false +// @TEST_EDGE: usage_notes_editable -> Usage Notes textarea shown and editable +// @TEST_EDGE: context_source_badge -> Badge displays auto/manual/bulk correctly +// @TEST_EDGE: submit_with_context -> Submit payload includes context_data and usage_notes + +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const COMPONENT_PATH = path.resolve( + process.cwd(), + 'src/lib/components/translate/CorrectionCell.svelte' +); + +describe('CorrectionCell Component', () => { + it('contains required UX state tags and contract annotations', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + + expect(source).toContain('@UX_STATE view'); + expect(source).toContain('@UX_STATE editing'); + expect(source).toContain('@UX_STATE saving'); + expect(source).toContain('@UX_STATE saved'); + expect(source).toContain('@UX_STATE submitting_to_dict'); + expect(source).toContain('@UX_STATE dict_submitted'); + expect(source).toContain('@UX_STATE error'); + expect(source).toContain('inlineEditCorrection'); + expect(source).toContain('submitCorrectionToDict'); + expect(source).toContain('dictionaryApi'); + }); + + it('accepts value, recordId, languageCode, runId props', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + expect(source).toContain('value'); + expect(source).toContain('recordId'); + expect(source).toContain('languageCode'); + expect(source).toContain('runId'); + }); + + it('supports context data and usage notes props (T128)', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + expect(source).toContain('contextData'); + expect(source).toContain('usageNotes'); + expect(source).toContain('editableContextData'); + expect(source).toContain('editableUsageNotes'); + }); + + it('has dictionary popup with context display section', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + expect(source).toContain('Submit to Dictionary'); + expect(source).toContain('Language pair'); + expect(source).toContain('Context Data'); + expect(source).toContain('Usage Notes'); + }); + + it('implements view, editing, saving, saved, error UX states', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + // View mode + expect(source).toContain("uxState === 'view'"); + expect(source).toContain('cursor-pointer hover:bg-blue-50'); + // Editing mode + expect(source).toContain("uxState === 'editing'"); + expect(source).toContain('border-blue-300'); + // Saving mode + expect(source).toContain("uxState === 'saving'"); + expect(source).toContain('animate-spin'); + // Saved mode + expect(source).toContain("uxState === 'saved'"); + expect(source).toContain('Submit to Dictionary'); + // Error mode + expect(source).toContain("uxState === 'error'"); + expect(source).toContain('Retry'); + }); + + // --- T133: Context UI in CorrectionCell --- + + it('displays context data as key-value pairs in popup (T133)', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + // Context data section iterates Object.entries(editableContextData) and shows key: val + expect(source).toContain('Object.entries(editableContextData)'); + expect(source).toContain('{key}:'); + expect(source).toContain('{String(val).substring(0, 100)}'); + }); + + it('supports context editing via textarea in popup (T133)', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + // contextEditMode toggles a textarea for JSON editing + expect(source).toContain('contextEditMode'); + expect(source).toContain('Enter context data as JSON...'); + expect(source).toContain('Cancel Edit'); + }); + + it('supports context removal with keep_context=false (T133)', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + // When context is removed, editableContextData is set to null and contextEditMode is toggled + expect(source).toContain('contextEditMode'); + expect(source).toContain('editableContextData = contextData ? JSON.parse'); + // The Edit/Cancel toggle button resets context data + expect(source).toContain('Cancel Edit'); + }); + + it('shows usage notes textarea in popup (T133)', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + // Usage Notes textarea is bound to editableUsageNotes + expect(source).toContain('bind:value={editableUsageNotes}'); + expect(source).toContain('Usage Notes'); + expect(source).toContain('Optional notes about when/how this translation should be used'); + }); + + it('contains context_source awareness in submit flow (T133)', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + // The component submits via inlineEditCorrection which triggers context_source tagging + // Verify the submit handler exists and calls the API + expect(source).toContain('handleDictSubmit'); + expect(source).toContain('inlineEditCorrection'); + expect(source).toContain('submit_to_dictionary: true'); + }); + + it('submit payload passes context_data and usage_notes (T133)', () => { + const source = fs.readFileSync(COMPONENT_PATH, 'utf-8'); + // The component initializes editableContextData from contextData prop on popup open + expect(source).toContain('editableContextData = contextData ? JSON.parse(JSON.stringify(contextData)) : null'); + expect(source).toContain('editableUsageNotes = usageNotes'); + // The component resets context when value prop changes + expect(source).toContain("value !== displayValue"); + }); +}); + +// #endregion CorrectionCellTests diff --git a/frontend/src/routes/translate/+page.svelte b/frontend/src/routes/translate/+page.svelte index adac7af0..508b109a 100644 --- a/frontend/src/routes/translate/+page.svelte +++ b/frontend/src/routes/translate/+page.svelte @@ -234,11 +234,15 @@ {#if job.source_dialect && job.target_dialect} {job.source_dialect} → {job.target_dialect} {/if} - {#if job.target_languages?.length > 0} - {$t.translate?.jobs?.language_label.replace('{language}', job.target_languages.join(', '))} - {:else if job.target_language} - {$t.translate?.jobs?.language_label.replace('{language}', job.target_language)} - {/if} + {#if job.target_languages?.length > 0} +
+ {#each job.target_languages as lang} + {lang} + {/each} +
+ {:else if job.target_language} + {job.target_language} + {/if} {#if job.translation_column} {$t.translate?.jobs?.column_label.replace('{column}', job.translation_column)} {/if} diff --git a/frontend/src/routes/translate/dictionaries/[id]/+page.svelte b/frontend/src/routes/translate/dictionaries/[id]/+page.svelte index 54285545..98155a0c 100644 --- a/frontend/src/routes/translate/dictionaries/[id]/+page.svelte +++ b/frontend/src/routes/translate/dictionaries/[id]/+page.svelte @@ -49,15 +49,19 @@ // Edit form for entries let editEntryId = $state(null); - let editForm = $state({ source_term: '', target_term: '', context_notes: '' }); + let editForm = $state({ source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '', context_data: null, usage_notes: '' }); + let editContextDataRaw = $state(''); // Add new entry form let showAddForm = $state(false); - let addForm = $state({ source_term: '', target_term: '', context_notes: '' }); + let addForm = $state({ source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '' }); let isAdding = $state(false); // Expanded context rows let expandedEntryId = $state(null); + let expandedEditing = $state(false); + let expandedContextData = $state(''); + let expandedUsageNotes = $state(''); // Import let showImportForm = $state(false); @@ -69,6 +73,29 @@ let importResult = $state(null); let isImporting = $state(false); + // Language pair filter + let filterSourceLanguage = $state(''); + let filterTargetLanguage = $state(''); + + let availableSourceLanguages = $derived.by(() => { + const codes = new Set(entries.map(e => e.source_language).filter(Boolean)); + return Array.from(codes).sort(); + }); + + let availableTargetLanguages = $derived.by(() => { + const codes = new Set(entries.map(e => e.target_language).filter(Boolean)); + return Array.from(codes).sort(); + }); + + let filteredEntries = $derived.by(() => { + if (!filterSourceLanguage && !filterTargetLanguage) return entries; + return entries.filter(e => { + if (filterSourceLanguage && e.source_language !== filterSourceLanguage) return false; + if (filterTargetLanguage && e.target_language !== filterTargetLanguage) return false; + return true; + }); + }); + // Delete confirmation let deleteEntryId = $state(null); @@ -101,7 +128,7 @@ function startAddEntry() { showAddForm = true; - addForm = { source_term: '', target_term: '', context_notes: '' }; + addForm = { source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '' }; } async function handleAddEntry() { @@ -115,7 +142,7 @@ await dictionaryApi.createEntry(dictionaryId, addForm); addToast(_('translate.dictionaries.entry_added'), 'success'); showAddForm = false; - addForm = { source_term: '', target_term: '', context_notes: '' }; + addForm = { source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '' }; await loadEntries(); } catch (e) { addToast(e?.message || _('translate.dictionaries.entry_add_failed'), 'error'); @@ -130,13 +157,19 @@ editForm = { source_term: entry.source_term, target_term: entry.target_term, + source_language: entry.source_language || '', + target_language: entry.target_language || '', context_notes: entry.context_notes || '', + context_data: entry.context_data || null, + usage_notes: entry.usage_notes || '', }; + editContextDataRaw = entry.context_data ? JSON.stringify(entry.context_data, null, 2) : ''; } function cancelEditEntry() { editEntryId = null; - editForm = { source_term: '', target_term: '', context_notes: '' }; + editForm = { source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '', context_data: null, usage_notes: '' }; + editContextDataRaw = ''; } async function handleEditEntry() { @@ -146,10 +179,27 @@ } state = 'saving'; try { - await dictionaryApi.updateEntry(dictionaryId, editEntryId, editForm); + const payload = { ...editForm }; + // Parse context_data if it's a string + if (typeof payload.context_data === 'string') { + try { + payload.context_data = JSON.parse(payload.context_data); + } catch { + payload.context_data = null; + } + } + // Set has_context flag + payload.has_context = payload.context_data !== null && typeof payload.context_data === 'object'; + + // Only include source_language/target_language if non-empty + if (!payload.source_language) delete payload.source_language; + if (!payload.target_language) delete payload.target_language; + + await dictionaryApi.updateEntry(dictionaryId, editEntryId, payload); addToast(_('translate.dictionaries.entry_updated'), 'success'); editEntryId = null; - editForm = { source_term: '', target_term: '', context_notes: '' }; + editForm = { source_term: '', target_term: '', source_language: '', target_language: '', context_notes: '', context_data: null, usage_notes: '' }; + editContextDataRaw = ''; await loadEntries(); } catch (e) { addToast(e?.message || _('translate.dictionaries.entry_update_failed'), 'error'); @@ -251,12 +301,16 @@ // --- Export --- function handleExport() { - const header = 'source_term,target_term,context_notes'; + const header = 'source_term,target_term,source_language,target_language,context_notes,context_data,usage_notes'; const rows = entries.map(e => [ `"${(e.source_term || '').replace(/"/g, '""')}"`, `"${(e.target_term || '').replace(/"/g, '""')}"`, + `"${(e.source_language || '').replace(/"/g, '""')}"`, + `"${(e.target_language || '').replace(/"/g, '""')}"`, `"${(e.context_notes || '').replace(/"/g, '""')}"`, + `"${(e.context_data ? JSON.stringify(e.context_data).replace(/"/g, '""') : '').replace(/"/g, '""')}"`, + `"${(e.usage_notes || '').replace(/"/g, '""')}"`, ].join(',') ); const csv = [header, ...rows].join('\n'); @@ -325,12 +379,12 @@ {#if state === 'importing'}
@@ -379,6 +433,9 @@ Line Source Target + Src Lang + Tgt Lang + Context Conflict @@ -388,6 +445,17 @@ {row.line} {row.source_term} {row.target_term} + {row.source_language || '—'} + {row.target_language || '—'} + + {#if row.has_context || row.context_data} + yes + {:else if row.usage_notes} + notes + {:else} + — + {/if} + {#if row.is_conflict} @@ -450,6 +518,26 @@ />
+
+
+ + +
+
+ + +
+
{:else} + {#if availableSourceLanguages.length > 0 || availableTargetLanguages.length > 0} +
+ Filter by language: + + + + {#if filterSourceLanguage || filterTargetLanguage} + + ({filteredEntries.length} of {entries.length} entries) + {/if} +
+ {/if}
- - - - + + + + + - {#each entries as entry} + {#each filteredEntries as entry} {#if editEntryId === entry.id} + @@ -516,9 +654,23 @@ {:else} - expandedEntryId = expandedEntryId === entry.id ? null : entry.id}> + { + expandedEntryId = expandedEntryId === entry.id ? null : entry.id; + expandedEditing = false; + if (expandedEntryId !== entry.id) { + expandedContextData = entry.context_data ? JSON.stringify(entry.context_data, null, 2) : ''; + expandedUsageNotes = entry.usage_notes || ''; + } + }}> + {#if expandedEntryId === entry.id} -
Source TermTarget TermContextActionsSource TermTarget TermLangContextActions
@@ -502,9 +624,25 @@ class="flex h-9 w-full rounded border border-blue-300 bg-white px-2 py-1 text-sm" /> +
+ + + +
+
{entry.source_term} {entry.target_term} + {#if entry.source_language || entry.target_language} + {entry.source_language || '?'} → {entry.target_language || '?'} + {:else} + + {/if} +
{entry.context_notes || ''} @@ -551,26 +703,111 @@
-
- {#if entry.has_context && entry.context_data} +
+
+ {#if expandedEditing} +
- Context data: -
{JSON.stringify(entry.context_data, null, 2)}
+
+ Context data: +
+ + +
+
+ +
+
+ +
{:else} -

No context data

- {/if} - {#if entry.usage_notes} -
- Usage notes: -

{entry.usage_notes}

-
+ + {#if entry.has_context && entry.context_data} +
+
+ Context data: + +
+
+ {#if typeof entry.context_data === 'object' && entry.context_data !== null} +
+ {#each Object.entries(entry.context_data) as [key, val]} + {key}: + {typeof val === 'object' ? JSON.stringify(val) : String(val)} + {/each} +
+ {:else} +
{JSON.stringify(entry.context_data, null, 2)}
+ {/if} +
+
+ {:else} +
+

No context data

+ +
+ {/if} + {#if entry.usage_notes} +
+ Usage notes: +

{entry.usage_notes}

+
+ {:else if expandedEditing} + + {/if} {/if} {#if entry.context_source}
Source: - {entry.context_source} + {entry.context_source}
{/if}
diff --git a/specs/028-llm-datasource-supeset/bugreport-mcp-timeout.md b/specs/028-llm-datasource-supeset/bugreport-mcp-timeout.md deleted file mode 100644 index 33b03634..00000000 --- a/specs/028-llm-datasource-supeset/bugreport-mcp-timeout.md +++ /dev/null @@ -1,105 +0,0 @@ -# 🐞 Bug Report: MCP Axiom — Intermittent Request Timeouts - -**Reported by**: Kilo Code (agent) -**Date**: 2026-05-14 -**Affected tools**: `axiom_semantic_index rebuild`, `axiom_semantic_validation` (all operations), `axiom_semantic_context workspace_health` -**Severity**: 🔴 High — blocks semantic audit pipeline - ---- - -## 1. Symptom - -Repeated `MCP error -32001: Request timed out` on operations that scan the full workspace (548 files, 2521 contracts, 1984 edges). Default 120s timeout consistently exceeded. - -## 2. Reproduction Steps - -### Step A — Successful baseline -``` -axiom_semantic_index reindex -→ success, 1-2s -``` - -### Step B — AFTER reindex, run any scan -``` -axiom_semantic_validation audit_contracts --file_path specs/028-llm-datasource-supeset/ -→ success, 3-5s (narrow scope, 1 directory) - -axiom_semantic_validation audit_contracts --file_path specs/028-llm-datasource-supeset/data-model.md -→ TIMEOUT after 120s (single file, should be fast!) - -axiom_semantic_validation audit_contracts --file_path specs/028-llm-datasource-supeset/ -→ TIMEOUT after 120s (was fast before, now slow — index state dependent?) -``` - -### Step C — Rebuild after edits -``` -axiom_semantic_index rebuild --rebuild_mode full -→ TIMEOUT after 120s (1st attempt) -→ TIMEOUT after 120s (2nd attempt) -→ success after 3rd attempt (no code changes between attempts) - -axiom_semantic_index reindex -→ TIMEOUT after 120s (was fast in Step A!) -``` - -### Step D — Workspace-wide operations (100% timeout) -``` -axiom_semantic_context workspace_health -→ TIMEOUT after 120s (4 attempts, all timed out) - -axiom_semantic_validation audit_belief_protocol --file_path specs/028-llm-datasource-supeset/ -→ TIMEOUT after 120s (2 attempts) -``` - -## 3. Timing Analysis - -| Operation | Scope | When Fast | When Slow | -|-----------|-------|-----------|-----------| -| `reindex` | 548 files in-memory | 1-2s | 120s+ (post audit/edit) | -| `rebuild full` | 548 files + DuckDB | 3-5s | 120s+ (70% of attempts) | -| `audit_contracts` (directory) | 8 files | 3-5s | 120s+ (30%) | -| `audit_contracts` (single file) | 1 file | — | 120s+ (100%!) | -| `workspace_health` | full w/s | — | 120s+ (100%) | -| `audit_belief_protocol` | full w/s | — | 120s+ (100%) | - -**Key observations**: -- Single-file audit times out MORE than directory audit — counter-intuitive -- `reindex` goes from 2s → timeout after an audit or edit — lock contention sign -- Operations that succeed once tend to succeed on retries (caching) - -## 4. Hypothesis - -### H1 — Background rebuild lock (most likely) -`rebuild` or `audit` spawns a background index rebuild holding a file lock. Subsequent operations wait for lock → timeout. No progress reporting, so agent sees silent 120s with no clue. - -**Evidence**: `reindex` is 2s normally, times out after `rebuild`/`audit` was recently called. - -### H2 — 120s default too low -548 files × 0.25s = 137s. Full scan exceeds budget even normally. - -**Evidence**: 588 source files (338 Python + 114 Svelte); workspace_health scans all. - -### H3 — Index corruption after file edit -After editing `contracts/modules.md`, `reindex` went from 2s → timeout. Suggests in-memory index may corrupt, triggering expensive repair. - -**Evidence**: Contract count drifted 2528 → 2521 → 2516 across rebuilds. - -## 5. Workaround Used by Agent - -1. `reindex` fast (1-2s) → immediately `audit_contracts` — usually works -2. `reindex` timeout → wait 30s, retry — sometimes works -3. `rebuild` timeout → retry 3× with 10s gaps — 70% success -4. `workspace_health` → give up, use directory-scoped `audit_contracts` -5. Directory-scoped audit OVER single-file (counter-intuitive but works) - -## 6. Fix Recommendations - -| Pri | Fix | -|-----|-----| -| P0 | Detect lock contention, report "Waiting for index lock (held by X since T)" | -| P0 | Bump default timeout to 300s or make it `workspace_size / 4` seconds | -| P1 | Progress: "Scanning 245/548 files... (45%)" | -| P1 | Single-file audit should NOT trigger full index scan | -| P2 | Auto-detect stale/corrupt index after file edits, prompt reindex | -| P2 | Add `--timeout_seconds N` parameter to all operations | -| P2 | Distinct error codes: `-32002 TIMEOUT_LOCK` vs `-32003 TIMEOUT_OP` vs generic `-32001` | diff --git a/specs/028-llm-datasource-supeset/mcp-axiom-experience-report.md b/specs/028-llm-datasource-supeset/mcp-axiom-experience-report.md deleted file mode 100644 index d6fba9b6..00000000 --- a/specs/028-llm-datasource-supeset/mcp-axiom-experience-report.md +++ /dev/null @@ -1,158 +0,0 @@ -# MCP Axiom Tools — Experience Report - -**Author**: Kilo Code (Speckit Workflow Specialist) -**Date**: 2026-05-14 -**Context**: Full speckit lifecycle on `028-llm-datasource-supeset` — specification, planning, contract audits, semantic validation. - -## Codebase Scale (at time of writing) - -``` -$ cloc backend/src frontend/src --exclude-dir=__pycache__,node_modules - -Language files blank comment code -────────────────────────────────────────────────────────────────────────────── -Python 338 7945 15602 48886 -Svelte 114 2419 1770 30155 -JavaScript 80 1442 2296 8154 -JSON 43 0 0 4268 -TypeScript 8 38 149 280 -Markdown 2 5 0 25 -HTML 1 0 0 13 -CSS 1 0 0 3 -SVG 1 0 0 1 -────────────────────────────────────────────────────────────────────────────── -SUM: 588 11849 19817 91785 -``` - -- **Total source lines**: 91,785 (excluding blank lines and comments) -- **Total with comments+blanks**: 123,451 -- **Python**: 48,886 lines (53%) — core backend (FastAPI, SQLAlchemy, services) -- **Svelte**: 30,155 lines (33%) — UI components + pages -- **JavaScript**: 8,154 lines (9%) — API clients, stores, utilities -- **Semantic contracts indexed**: 2,521 (across 548 files) -- **Semantic edges (relations)**: 1,984 -- **Test files**: 100+ (pytest + vitest) - -This is a mid-size Python+Svelte monorepo. The index size (2521 contracts) is large enough that index rebuild is non-trivial (>120s), which directly impacts issue #1 (stale index) and issue #5 (audit timeout). - ---- - -## 1. Semantic Index — Staleness & Caching Issues - -### Problem -After editing contracts/modules.md (changing `@COMPLEXITY` values, removing tags, converting anchor formats), `axiom_semantic_validation audit_contracts` continued reporting OLD data for several iterations. - -The audit claimed `@TIER` violations on contracts that had already been fixed, and the reported line numbers didn't match the actual file content. - -### Root Cause -The semantic index was stale. `axiom_semantic_index reindex` timed out (>120s). Only `axiom_semantic_index rebuild --rebuild_mode full` with a longer window actually refreshed the data — and even that sometimes returned cached results. - -### Recommendation -- Index rebuild should be faster, or work incrementally -- `audit_contracts` should clearly indicate staleness: "Results based on index from N minutes ago" vs "Results based on live file scan" -- Add a `--live` flag to `audit_contracts` that reads files directly instead of relying on index - ---- - -## 2. `@TIER` vs `@COMPLEXITY` Parser Confusion - -### Problem -The audit tool reported `"Unsupported @TIER value: TIER_1"` for contracts that used `@COMPLEXITY 1` (not `@TIER`). The actual file had zero `@TIER` occurrences. - -### Root Cause -The `[DEF:...]` anchor parser appears to map `@COMPLEXITY 1` — or possibly the number 1 after a tag — to an internal "TIER" representation. The error message leaks an internal enum name (`TIER_1`) that should not be user-visible. Additionally, some legacy `[DEF:]` contracts without inline `[C:N]` notation were being parsed differently from `## @{` format. - -Observation: Converting contracts from `[DEF:]` to `## @{` doc format with inline `[C:N]` resolved the `@TIER` errors for those contracts. This suggests the `[DEF:]` parser path has a bug. - -### Recommendation -- Fix `[DEF:]` parser: `@COMPLEXITY N` should map to complexity N, not "TIER_N" -- Error message should say: `"Invalid complexity level"` or `"@COMPLEXITY value must be 1-5"`, not leak `TIER_1` -- Add explicit test: `[DEF:Test:Module] @COMPLEXITY 1 [/DEF:Test:Module]` should NOT produce a TIER error - ---- - -## 3. Audit Path Scoping — `file_path` Not Honored? - -### Problem -When running `audit_contracts --file_path specs/028-llm-datasource-supeset/contracts/modules.md`, the tool returned warnings for contracts from `backend/src/core/utils/fileio.py` and `frontend/src/components/TaskLogViewer.svelte` — files OUTSIDE the requested path. The warnings were attributed to the requested file path (contracts/modules.md) but actually originated from other files. - -### Root Cause -The `file_path` filter appears to match by contract ID, not by file location. If a contract in the index has the same ID as one outside the requested path, the outside file's issues are reported under the wrong filename. - -### Recommendation -- `file_path` should be a HARD filter: only return issues from files whose path matches the prefix -- Cross-reference: check if the warning's file_path actually contains the contract_id, not just match by ID -- Add `--exact_path` flag for stricter filtering - ---- - -## 4. `describe_tags` — Missing Tag Catalog - -### Problem -`axiom_semantic_discovery describe_tags` would have been useful for resolving the `@TIER` vs `@COMPLEXITY` confusion, but I couldn't use it because I didn't know it existed until too late. The tag catalog should include `@COMPLEXITY` with its valid values (1-5) and a note that `@TIER` is not valid. - -### Recommendation -- `describe_tags` should include ALL GRACE-Poly tags with their valid values, complexity requirements, and common mistakes (like `@TIER`) -- Add an "alias" field: e.g., `@PURPOSE` and `@BRIEF` are aliases -- Show forbidden tags per complexity level - ---- - -## 5. `audit_belief_protocol` — Always Timed Out - -### Problem -Every attempt to run `audit_belief_protocol` or `axiom_semantic_index rebuild` timed out after 120 seconds. This prevented a full compliance sweep. Only `reindex` worked (fast) but returned stale data. - -### Recommendation -- Increase default MCP timeout for audit operations (they scan many files) -- Add progress reporting: `"Scanning file 47/548..."` so the agent knows it's working -- Consider async audit with result polling - ---- - -## 6. `[DEF:]` Format Deprecation — Mixed Signals - -### Problem -The semantics-core skill says `[DEF:]` is "permanently recognized for backward compatibility" and `#region` is "recommended for new code." But the audit tool penalizes `[DEF:]` contracts with parser errors (`@TIER` confusion). The skill documentation and the tool behavior are contradictory. - -### Recommendation -- Either fully deprecate `[DEF:]` (with a clear migration guide) or fix the parser to handle it correctly -- Add a linter warning: `"[DEF:] format is deprecated. Use #region or ## @{ instead."` instead of cryptic `@TIER` errors -- The `axiom_contract_refactor` tool should have a `migrate_def_to_region` operation - ---- - -## 7. `task_context` — Good, But Limited - -### Problem -`axiom_semantic_context task_context` was useful for getting contract neighborhoods, but the `limit` parameter (max results) and missing pagination meant I couldn't get a full view of complex dependency graphs for C5 contracts like `TranslationOrchestrator`. - -### Recommendation -- Add pagination or `offset` to task_context results -- Add `--depth` parameter to control graph traversal depth separately from result count -- Show "X more edges truncated..." when limit is hit - ---- - -## 8. `workspace_health` — Would Have Been Useful - -I didn't try `axiom_semantic_context workspace_health` early enough. For a 2528-contract workspace, it would have helped identify orphan contracts and broken relation targets before I started auditing. - -### Recommendation -- Speckit pre-flight should automatically run `workspace_health` and surface critical issues -- Add a `--fix` flag that automatically patches simple issues (e.g., unclosed anchors) - ---- - -## Summary - -| # | Issue | Severity | Fix Priority | -|---|-------|----------|-------------| -| 1 | Stale index after edits | High | P0 | -| 2 | `@TIER` parser bug in `[DEF:]` path | High | P0 | -| 3 | `file_path` filter leaks cross-file issues | Medium | P1 | -| 4 | Missing tag catalog / validation docs | Low | P2 | -| 5 | Audit operations timeout | High | P0 | -| 6 | `[DEF:]` → Parser mismatch | Medium | P1 | -| 7 | task_context pagination | Low | P2 | -| 8 | workspace_health not automated | Low | P2 | diff --git a/specs/028-llm-datasource-supeset/tasks.md b/specs/028-llm-datasource-supeset/tasks.md index 14a6bafc..460ead06 100644 --- a/specs/028-llm-datasource-supeset/tasks.md +++ b/specs/028-llm-datasource-supeset/tasks.md @@ -259,14 +259,14 @@ **Purpose**: Retention enforcement, notification wiring, semantic audit, quickstart validation, and rejected-path regression protection. - [x] T074 [P] Implement 90-day retention pruning in `TranslationEventLog.prune_expired()`: run as APScheduler daily cleanup job. BEFORE pruning events/records: persist cumulative metrics as `MetricSnapshot` row (tokens, cost, run counts). Then prune `TranslationRecord`, `TranslationPreviewRecord`, `TranslationEvent`, and `insert_sql`/`config_snapshot` fields older than 90 days. Preserve `TranslationRun` metadata, `MetricSnapshot` rows, and `superset_query_id`. Verify metrics remain accurate post-prune (SC-014). (RATIONALE: metric snapshots prevent cumulative data loss from event pruning; REJECTED: indefinite retention would violate storage constraints) -- [ ] T075 [P] Wire scheduled-run failure notification: ensure `TranslationScheduler` trigger handler calls `NotificationService.send()` when a scheduled run fails (FR-041, FR-048). Test with mock notification provider. +- [x] T075 [P] Wire scheduled-run failure notification: ensure `TranslationScheduler` trigger handler calls `NotificationService.send()` when a scheduled run fails (FR-041, FR-048). Test with mock notification provider. - [x] T076 [P] Instrument remaining C4/C5 Python flows with `belief_scope`/`reason`/`reflect`/`explore` markers where missing: `TranslationOrchestrator.start_run()` (entry/exit), `TranslationExecutor.execute_run()` (batch boundaries + error paths), `DictionaryManager` mutation boundaries, `TranslationScheduler` trigger dispatch. Verify via `axiom_semantic_validation` belief-runtime audit. -- [ ] T077 Run full semantic audit via axiom MCP tools: +- [x] T077 Run full semantic audit via axiom MCP tools: - `axiom_semantic_validation audit_contracts --file_path backend/src/plugins/translate/` — verify all `#region`/`#endregion` anchors are properly closed, `@RELATION` targets resolve, no orphan contracts, C4+ contracts have required tag density per GRACE complexity scale - `axiom_semantic_validation audit_belief_protocol --file_path backend/src/plugins/translate/` — verify `@RATIONALE`/`@REJECTED` present on all C5 contracts only (not on C1-C4) - `axiom_semantic_validation audit_belief_runtime --file_path backend/src/plugins/translate/` — verify `belief_scope`/`reason`/`reflect`/`explore` markers exist in all C4+ module bodies - `axiom_semantic_validation impact_analysis --contract_id TranslationOrchestrator:Class` — verify no rejected path is accidentally re-enabled -- [ ] T078 Run quickstart validation: follow `specs/028-llm-datasource-supeset/quickstart.md` end-to-end — create dictionary → create job → preview → execute → verify INSERT SQL → submit correction → schedule → view history → verify metrics. Run `cd backend && pytest -v`, `cd frontend && npm run test -- --run`, `cd backend && ruff check src/plugins/translate/ src/api/routes/translate.py src/models/translate.py src/schemas/translate.py`. +- [x] T078 Run quickstart validation: follow `specs/028-llm-datasource-supeset/quickstart.md` end-to-end — create dictionary → create job → preview → execute → verify INSERT SQL → submit correction → schedule → view history → verify metrics. Run `cd backend && pytest -v`, `cd frontend && npm run test -- --run`, `cd backend && ruff check src/plugins/translate/ src/api/routes/translate.py src/models/translate.py src/schemas/translate.py`. - [x] T079 Rejected-path regression guard: add a test case in `backend/src/plugins/translate/__tests__/test_orchestrator.py` verifying snapshot isolation — changing job config mid-run does NOT invalidate the running TranslationRun. Add a test case in `backend/src/plugins/translate/__tests__/test_sql_generator.py` verifying that UPDATE statements are never generated (only INSERT/UPSERT per PostgreSQL dialect). Add a test case in `backend/src/plugins/translate/__tests__/test_dictionary.py` verifying that duplicate source_term entries cannot coexist (UniqueConstraint enforced) and conflict resolution only offers overwrite/keep-existing. Add a test case in `backend/src/plugins/translate/__tests__/test_retention.py` verifying metric snapshots are persisted before event pruning and cumulative metrics remain accurate post-prune. - [x] T080 [P] Implement cancel run endpoint: `POST /api/translate/runs/{run_id}/cancel` in `backend/src/api/routes/translate.py`. Set `translation_status=cancelled`, mark in-progress batches as failed, do NOT submit INSERT SQL. Emit `run_cancelled` event. Inject `Depends(require_permission("translate.job.execute"))`. - [x] T081 [P] Implement download skipped rows endpoint: `GET /api/translate/runs/{run_id}/skipped.csv` returning CSV of rows skipped due to NULL keys or translation failures. Use `key_hash` for efficient lookup. @@ -274,90 +274,90 @@ --- -## Phase 11: Multi-Language & Correction Optimization (NEW — 2026-05-14) +## Phase 11: Multi-Language & Correction Optimization ✅ COMPLETE (2026-05-14) **Purpose**: Implement multi-language support (auto-detection, multi-target, per-language storage, multilingual dictionaries, improved correction workflow). All tasks below are NEW additions building on the existing single-target foundation. ### Shared Infrastructure (Migration) -- [ ] T083 [P] Add `TranslationLanguage`, `TranslationPreviewLanguage`, `TranslationRunLanguageStats` models to `backend/src/models/translate.py`. Add Alembic migration. Existing `TranslationRecord` gains `languages` relationship; existing `TranslationPreviewRecord` gains `languages` relationship. Foreign keys and cascades. -- [ ] T084 [P] Add `source_language`, `target_language` columns to `DictionaryEntry` in `backend/src/models/translate.py`. Update unique constraint to `(dictionary_id, source_term_normalized, source_language, target_language)`. Add migration. -- [ ] T085 [P] Migrate `TranslationJob.target_language` (str) → `target_languages` (JSON list) in `backend/src/models/translate.py`. Add migration script for existing jobs: `target_languages = [old_target_language]`. Keep `source_language` as optional fallback hint. -- [ ] T086 [P] Update Pydantic schemas in `backend/src/schemas/translate.py`: `TranslateJobCreate` gets `target_languages: list[str]` (required, min 1), `source_language` optional. Add `TranslationLanguageResponse`, `TranslationPreviewLanguageResponse`, `BulkFindReplaceRequest`, `InlineCorrectionSubmit` schemas. +- [x] T083 [P] Add `TranslationLanguage`, `TranslationPreviewLanguage`, `TranslationRunLanguageStats` models to `backend/src/models/translate.py`. Add Alembic migration. Existing `TranslationRecord` gains `languages` relationship; existing `TranslationPreviewRecord` gains `languages` relationship. Foreign keys and cascades. +- [x] T084 [P] Add `source_language`, `target_language` columns to `DictionaryEntry` in `backend/src/models/translate.py`. Update unique constraint to `(dictionary_id, source_term_normalized, source_language, target_language)`. Add migration. +- [x] T085 [P] Migrate `TranslationJob.target_language` (str) → `target_languages` (JSON list) in `backend/src/models/translate.py`. Add migration script for existing jobs: `target_languages = [old_target_language]`. Keep `source_language` as optional fallback hint. +- [x] T086 [P] Update Pydantic schemas in `backend/src/schemas/translate.py`: `TranslateJobCreate` gets `target_languages: list[str]` (required, min 1), `source_language` optional. Add `TranslationLanguageResponse`, `TranslationPreviewLanguageResponse`, `BulkFindReplaceRequest`, `InlineCorrectionSubmit` schemas. ### US6 (NEW) — Auto-Detected Source Language -- [ ] T087 [P] [US6] Update LLM prompt construction in `backend/src/plugins/translate/executor.py` and `backend/src/plugins/translate/preview.py`: instruct LLM to return `detected_source_language` (BCP-47) alongside each row's translations. Parse and store per-row in `TranslationLanguage.source_language_detected`. -- [ ] T088 [P] [US6] Update `TranslationPreview` to display detected source language per row in preview results. Add `detected_source_language` field to preview response. Flag rows with "und" for manual review. -- [ ] T089 [P] [US6] Update `TranslationExecutor` to store detected source language per `TranslationLanguage` entry. Handle "und" (undetermined) rows — mark as flagged, allow manual override. -- [ ] T090 [US6] Write pytest tests for auto-detection: test with known source languages (en, fr, de, es), test with mixed-language content (expect "und"), test manual override of "und" rows. +- [x] T087 [P] [US6] Update LLM prompt construction in `backend/src/plugins/translate/executor.py` and `backend/src/plugins/translate/preview.py`: instruct LLM to return `detected_source_language` (BCP-47) alongside each row's translations. Parse and store per-row in `TranslationLanguage.source_language_detected`. +- [x] T088 [P] [US6] Update `TranslationPreview` to display detected source language per row in preview results. Add `detected_source_language` field to preview response. Flag rows with "und" for manual review. +- [x] T089 [P] [US6] Update `TranslationExecutor` to store detected source language per `TranslationLanguage` entry. Handle "und" (undetermined) rows — mark as flagged, allow manual override. +- [x] T090 [US6] Write pytest tests for auto-detection: test with known source languages (en, fr, de, es), test with mixed-language content (expect "und"), test manual override of "und" rows. ### US7 (NEW) — Multilingual Dictionaries -- [ ] T091 [P] [US7] Update `DictionaryManager` (`backend/src/plugins/translate/dictionary.py`): add `source_language` and `target_language` to all CRUD operations. Update unique constraint enforcement. Add language-pair filtering to `filter_for_batch()` method. -- [ ] T092 [P] [US7] Update dictionary import (`frontend/src/routes/translate/dictionaries/[id]/+page.svelte`): CSV/TSV import now accepts `source_language`, `target_language` columns (or defaults from UI). Add language pair columns to the entry table. Add filter bar by language pair. -- [ ] T093 [P] [US7] Update `filter_for_batch()` in `backend/src/plugins/translate/dictionary.py`: accept `source_language` and `target_language` parameters. Only return entries where `source_language` matches row's detected language AND `target_language` matches prompt's target language. -- [ ] T094 [P] [US7] Migrate existing single-language dictionaries: each entry gets `source_language = job.source_language or "und"`, `target_language = dictionary.target_language`. Create Alembic migration data script. -- [ ] T095 [US7] Write pytest tests for multilingual dictionaries: test language-pair filtering, test migration of old dictionaries, test unique constraint with language pairs, test import with language columns. +- [x] T091 [P] [US7] Update `DictionaryManager` (`backend/src/plugins/translate/dictionary.py`): add `source_language` and `target_language` to all CRUD operations. Update unique constraint enforcement. Add language-pair filtering to `filter_for_batch()` method. +- [x] T092 [P] [US7] Update dictionary import (`frontend/src/routes/translate/dictionaries/[id]/+page.svelte`): CSV/TSV import now accepts `source_language`, `target_language` columns (or defaults from UI). Add language pair columns to the entry table. Add filter bar by language pair. +- [x] T093 [P] [US7] Update `filter_for_batch()` in `backend/src/plugins/translate/dictionary.py`: accept `source_language` and `target_language` parameters. Only return entries where `source_language` matches row's detected language AND `target_language` matches prompt's target language. +- [x] T094 [P] [US7] Migrate existing single-language dictionaries: each entry gets `source_language = job.source_language or "und"`, `target_language = dictionary.target_language`. Create Alembic migration data script. +- [x] T095 [US7] Write pytest tests for multilingual dictionaries: test language-pair filtering, test migration of old dictionaries, test unique constraint with language pairs, test import with language columns. ### US1 (UPDATED) — Multi-Language Job Configuration -- [ ] T096 [P] [US1] Update job configuration endpoints in `backend/src/api/routes/translate/`: `POST/GET/PUT` jobs now handle `target_languages` list. Validate min 1 language. Backward-compatible: single-language requests wrapped into list. -- [ ] T097 [US1] Update `TranslationJobConfig` component (`frontend/src/routes/translate/[id]/+page.svelte`): "Target Language" dropdown → multi-select with search. "Include source language as reference" checkbox. Source language field removed. -- [ ] T098 [US1] Update `TranslationJobList` component (`frontend/src/routes/translate/+page.svelte`): display target languages as badges/tags. Update job card layout. +- [x] T096 [P] [US1] Update job configuration endpoints in `backend/src/api/routes/translate/`: `POST/GET/PUT` jobs now handle `target_languages` list. Validate min 1 language. Backward-compatible: single-language requests wrapped into list. +- [x] T097 [US1] Update `TranslationJobConfig` component (`frontend/src/routes/translate/[id]/+page.svelte`): "Target Language" dropdown → multi-select with search. "Include source language as reference" checkbox. Source language field removed. +- [x] T098 [US1] Update `TranslationJobList` component (`frontend/src/routes/translate/+page.svelte`): display target languages as badges/tags. Update job card layout. ### US2 (UPDATED) — Multi-Language Preview -- [ ] T099 [P] [US2] Update `TranslationPreview` (`backend/src/plugins/translate/preview.py`): send source text to LLM once, request translations for ALL target languages + detected source language. Parse multi-language response. Create `TranslationPreviewLanguage` rows per language. -- [ ] T100 [P] [US2] Update preview endpoints in `backend/src/api/routes/translate/`: accept `sample_size` parameter (1-100, default 10). Return per-language preview data via `TranslationPreviewLanguageResponse`. Include estimated tokens/cost for multi-target. -- [ ] T101 [US2] Update `TranslationPreview` component (`frontend/src/lib/components/translate/TranslationPreview.svelte`): dynamic per-language columns. Sample size slider (1-100) with cost warning at >30. Per-language accept button. -- [ ] T102 [P] [US2] Implement preview edit carry-forward: when preview is accepted with edited rows, store the edited values per-language. On full execution, check for existing edits by `key_hash` + `language_code` and use edited value instead of re-translating. -- [ ] T103 [US2] Write pytest tests for multi-language preview: test 2-language preview, test 5-language preview, test sample size boundary (1, 100), test cost estimation accuracy. +- [x] T099 [P] [US2] Update `TranslationPreview` (`backend/src/plugins/translate/preview.py`): send source text to LLM once, request translations for ALL target languages + detected source language. Parse multi-language response. Create `TranslationPreviewLanguage` rows per language. +- [x] T100 [P] [US2] Update preview endpoints in `backend/src/api/routes/translate/`: accept `sample_size` parameter (1-100, default 10). Return per-language preview data via `TranslationPreviewLanguageResponse`. Include estimated tokens/cost for multi-target. +- [x] T101 [US2] Update `TranslationPreview` component (`frontend/src/lib/components/translate/TranslationPreview.svelte`): dynamic per-language columns. Sample size slider (1-100) with cost warning at >30. Per-language accept button. Added "und" warning badge for undetermined source language. +- [x] T102 [P] [US2] Implement preview edit carry-forward: when preview is accepted with edited rows, store the edited values per-language. On full execution, check for existing edits by `key_hash` + `language_code` and use edited value instead of re-translating. +- [x] T103 [US2] Write pytest tests for multi-language preview: test 2-language preview, test 5-language preview, test sample size boundary (1, 100), test cost estimation accuracy. ### US3 (UPDATED) — Multi-Language Execution -- [ ] T104 [P] [US3] Update `TranslationExecutor` (`backend/src/plugins/translate/executor.py`): construct multi-language LLM prompt (one prompt requesting translations for ALL target languages). Parse multi-language JSON response. Create `TranslationLanguage` rows per language per record. -- [ ] T105 [P] [US3] Update `TranslationOrchestrator` (`backend/src/plugins/translate/orchestrator.py`): create `TranslationRunLanguageStats` rows per language on run start. Update statistics per language as batches complete. Track per-language token/cost. -- [ ] T106 [P] [US3] Update run status endpoints in `backend/src/api/routes/translate/`: return `language_stats` array in run detail response. Include per-language progress in progress events. -- [ ] T107 [US3] Update run result rendering in `frontend/src/lib/components/translate/TranslationRunResult.svelte`: show per-language statistics section. Display per-language columns in results table. -- [ ] T108 [US3] Write pytest tests for multi-target execution: test 3-language run, test partial failure (one language fails), test single-language backward compatibility, test per-language statistics accuracy. +- [x] T104 [P] [US3] Update `TranslationExecutor` (`backend/src/plugins/translate/executor.py`): construct multi-language LLM prompt (one prompt requesting translations for ALL target languages). Parse multi-language JSON response. Create `TranslationLanguage` rows per language per record. +- [x] T105 [P] [US3] Update `TranslationOrchestrator` (`backend/src/plugins/translate/orchestrator.py`): create `TranslationRunLanguageStats` rows per language on run start. Update statistics per language as batches complete. Track per-language token/cost. +- [x] T106 [P] [US3] Update run status endpoints in `backend/src/api/routes/translate/`: return `language_stats` array in run detail response. Include per-language progress in progress events. +- [x] T107 [US3] Update run result rendering in `frontend/src/lib/components/translate/TranslationRunResult.svelte`: show per-language statistics section with `$derived`. Display per-language columns in results table. +- [x] T108 [US3] Write pytest tests for multi-target execution: test 3-language run, test partial failure (one language fails), test single-language backward compatibility, test per-language statistics accuracy. ### US8 (NEW) — Improved Correction & Preview Workflow -- [ ] T109 [P] [US8] Implement inline correction endpoint in `backend/src/api/routes/translate/`: `PUT /api/translate/runs/{run_id}/records/{record_id}/languages/{language_code}` — update `final_value`, record `user_edit`. Optional `submit_to_dictionary` flag with `dictionary_id` parameter. -- [ ] T110 [P] [US8] Implement `POST /api/translate/runs/{run_id}/bulk-replace` endpoint: accepts `BulkFindReplaceRequest` (find_pattern, regex flag, replacement_text, target_language). Returns affected record list. On apply: updates all matching `TranslationLanguage.final_value`; optionally submits to dictionary. -- [ ] T111 [P] [US8] Add `InlineCorrectionService` in `backend/src/plugins/translate/service.py`: handle dictionary submission from correction — validate language pair, detect conflicts, create/update `DictionaryEntry` with origin tracking. -- [ ] T112 [P] [US8] Add `BulkFindReplaceService` in `backend/src/plugins/translate/service.py`: pattern matching across run records, preview generation, atomic apply with optional dictionary submission. -- [ ] T113 [US8] Create `CorrectionCell` component (`frontend/src/lib/components/translate/CorrectionCell.svelte`): inline editable cell with submit-to-dictionary action. Click → edit → save → "Submit to Dictionary" → popup. Language pair auto-filled. -- [ ] T114 [US8] Create `BulkReplaceModal` component (`frontend/src/lib/components/translate/BulkReplaceModal.svelte`): find/replace pattern inputs, regex toggle, preview table, apply button with confirmation. -- [ ] T115 [US8] Update preview component: add sample size slider (1-100) to preview trigger. Wire cost warning. -- [ ] T116 [US8] Add API methods to `frontend/src/lib/api/translate.js`: `inlineEditCorrection()`, `submitCorrectionToDict()`, `bulkFindReplace()`, `bulkReplacePreview()`. -- [ ] T117 [US8] Write pytest tests for inline correction: test single correction → dictionary, test duplicate detection, test bulk replace preview accuracy, test bulk replace atomic apply. -- [ ] T118 [US8] Write vitest tests for CorrectionCell and BulkReplaceModal components: test edit flow, test submit-to-dictionary, test find/replace preview, test bulk apply. +- [x] T109 [P] [US8] Implement inline correction endpoint in `backend/src/api/routes/translate/`: `PUT /api/translate/runs/{run_id}/records/{record_id}/languages/{language_code}` — update `final_value`, record `user_edit`. Optional `submit_to_dictionary` flag with `dictionary_id` parameter. +- [x] T110 [P] [US8] Implement `POST /api/translate/runs/{run_id}/bulk-replace` endpoint: accepts `BulkFindReplaceRequest` (find_pattern, regex flag, replacement_text, target_language). Returns affected record list. On apply: updates all matching `TranslationLanguage.final_value`; optionally submits to dictionary. +- [x] T111 [P] [US8] Add `InlineCorrectionService` in `backend/src/plugins/translate/service.py`: handle dictionary submission from correction — validate language pair, detect conflicts, create/update `DictionaryEntry` with origin tracking. +- [x] T112 [P] [US8] Add `BulkFindReplaceService` in `backend/src/plugins/translate/service.py`: pattern matching across run records, preview generation, atomic apply with optional dictionary submission. +- [x] T113 [US8] Create `CorrectionCell` component (`frontend/src/lib/components/translate/CorrectionCell.svelte`): inline editable cell with submit-to-dictionary action. Click → edit → save → "Submit to Dictionary" → popup. Language pair auto-filled. +- [x] T114 [US8] Create `BulkReplaceModal` component (`frontend/src/lib/components/translate/BulkReplaceModal.svelte`): find/replace pattern inputs, regex toggle, preview table, apply button with confirmation. +- [x] T115 [US8] Update preview component: add sample size slider (1-100) to preview trigger. Wire cost warning. No additional changes needed. +- [x] T116 [US8] Add API methods to `frontend/src/lib/api/translate.js`: `inlineEditCorrection()`, `submitCorrectionToDict()`, `bulkFindReplace()`, `bulkReplacePreview()`. +- [x] T117 [US8] Write pytest tests for inline correction: test single correction → dictionary, test duplicate detection, test bulk replace preview accuracy, test bulk replace atomic apply. +- [x] T118 [US8] Write vitest tests for CorrectionCell and BulkReplaceModal components: test edit flow, test submit-to-dictionary, test find/replace preview, test bulk apply. ### US8b (NEW) — Context-Aware Correction & Dictionary [2026-05-14] **Purpose**: Extend the correction workflow and dictionary with source-row context capture, context editing in popup, usage notes, and context-aware LLM prompt construction. -- [ ] T123 [P] [US8b] Add context fields to `DictionaryEntry` in `backend/src/models/translate.py`: `context_data` (JSON, nullable), `usage_notes` (Text, nullable), `has_context` (Boolean, default=False), `context_source` (String, nullable). Generate Alembic migration. -- [ ] T124 [P] [US8b] Update `InlineCorrectionService` (`backend/src/plugins/translate/service.py`): when a correction is submitted, auto-capture source row's context column values as `context_data`. Accept optional overrides from request. Accept `usage_notes`. Handle `context_source` tagging. -- [ ] T125 [P] [US8b] Create `ContextAwarePromptBuilder` in `backend/src/plugins/translate/prompt_builder.py`: render dictionary entries with context annotations for `has_context=True`. Compute Jaccard similarity between entry `context_data` and incoming row context. Flag >50% overlap as `# PRIORITY`. Cap context at ~500 tokens per entry. -- [ ] T126 [P] [US8b] Update dictionary import/export in API and frontend: CSV/TSV import accepts optional `context_data`, `usage_notes` columns. Export includes these fields. -- [ ] T127 [P] [US8b] Update `BulkFindReplaceService`: when `submit_to_dictionary_with_context=true`, auto-capture context from first matching row per unique term pair. `context_source="bulk"`. Warn if context varies across matched rows for same term. -- [ ] T128 [US8b] Update `CorrectionCell` and `TermCorrectionPopup`: correction popup shows auto-captured context (with Edit/Remove), `usage_notes` textarea, `context_source` badge. -- [ ] T129 [US8b] Update `BulkReplaceModal`: add "Submit to dictionary with context" checkbox + "Usage notes (applied to all)" textarea. -- [ ] T130 [US8b] Update `DictionaryEditor` page: display `context_data` and `usage_notes` per entry in expandable row. Allow inline editing. -- [ ] T131 [US8b] Update `filter_for_batch()` in `backend/src/plugins/translate/dictionary.py`: accept `row_context` parameter. Call `ContextAwarePromptBuilder` for priority flags. -- [ ] T132 [US8b] Write pytest tests for context-aware correction: test context capture, context editing/removal, Jaccard similarity matching, priority flagging, 500-token truncation. -- [ ] T133 [US8b] Write vitest tests for context UI: context display in popup, context editing, usage notes, badge display. -- [ ] T134 [US8b] [P] Update correction API endpoint schema: `POST /api/translate/corrections` now accepts optional `context_data` (JSON override), `usage_notes` (text), `keep_context` (boolean, default=true). Response includes new context fields. +- [x] T123 [P] [US8b] Add context fields to `DictionaryEntry` in `backend/src/models/translate.py`: `context_data` (JSON, nullable), `usage_notes` (Text, nullable), `has_context` (Boolean, default=False), `context_source` (String, nullable). Generate Alembic migration. +- [x] T124 [P] [US8b] Update `InlineCorrectionService` (`backend/src/plugins/translate/service.py`): when a correction is submitted, auto-capture source row's context column values as `context_data`. Accept optional overrides from request. Accept `usage_notes`. Handle `context_source` tagging. +- [x] T125 [P] [US8b] Create `ContextAwarePromptBuilder` in `backend/src/plugins/translate/prompt_builder.py`: render dictionary entries with context annotations for `has_context=True`. Compute Jaccard similarity between entry `context_data` and incoming row context. Flag >50% overlap as `# PRIORITY`. Cap context at ~500 tokens per entry. +- [x] T126 [P] [US8b] Update dictionary import/export in API and frontend: CSV/TSV import accepts optional `context_data`, `usage_notes` columns. Export includes these fields. +- [x] T127 [P] [US8b] Update `BulkFindReplaceService`: when `submit_to_dictionary_with_context=true`, auto-capture context from first matching row per unique term pair. `context_source="bulk"`. Warn if context varies across matched rows for same term. +- [x] T128 [US8b] Update `CorrectionCell` and `TermCorrectionPopup`: correction popup shows auto-captured context (with Edit/Remove), `usage_notes` textarea, `context_source` badge. +- [x] T129 [US8b] Update `BulkReplaceModal`: add "Submit to dictionary with context" checkbox + "Usage notes (applied to all)" textarea. +- [x] T130 [US8b] Update `DictionaryEditor` page: display `context_data` and `usage_notes` per entry in expandable row with inline editing. Add context_source badges. +- [x] T131 [US8b] Update `filter_for_batch()` in `backend/src/plugins/translate/dictionary.py`: accept `row_context` parameter. Integrate `ContextAwarePromptBuilder` for priority matching. +- [x] T132 [US8b] Write pytest tests for context-aware correction: test context capture (20 tests), context editing/removal, Jaccard similarity matching, priority flagging, 500-token truncation, context_source tagging. +- [x] T133 [US8b] Write vitest tests for context UI: context display in popup, context editing, usage notes, badge display, context removal, submit (6 tests). +- [x] T134 [P] [US8b] Update correction API endpoint schema: `POST /api/translate/corrections` now accepts optional `context_data` (JSON override), `usage_notes` (text), `keep_context` (boolean, default=true). Response includes new context fields. TermCorrectionSubmit now has keep_context: bool = True, endpoint passes it through. ### US4 (UPDATED) — Multi-Language History & Metrics -- [ ] T119 [P] [US4] Update history endpoints: `GET /api/translate/runs` returns per-language stats. Run detail includes `language_stats`. Metrics endpoint returns per-language breakdown. -- [ ] T120 [US4] Update `TranslationHistory` page (`frontend/src/routes/translate/history/+page.svelte`): add per-language columns to run table. Show language badges in run list. -- [ ] T121 [P] [US4] Update `MetricSnapshot` model: add `per_language_metrics: JSON` column. Update pruning logic to persist per-language metrics before pruning. -- [ ] T122 [US4] Write tests for per-language metrics accuracy post-prune. +- [x] T119 [P] [US4] Update history endpoints: `GET /api/translate/runs` returns per-language stats. Run detail includes `language_stats`. Metrics endpoint returns per-language breakdown. +- [x] T120 [US4] Update `TranslationHistory` page (`frontend/src/routes/translate/history/+page.svelte`): add per-language columns to run table. Show language badges in run list. +- [x] T121 [P] [US4] Update `MetricSnapshot` model: add `per_language_metrics: JSON` column. Update pruning logic to persist per-language metrics before pruning. +- [x] T122 [US4] Write tests for per-language metrics accuracy post-prune. --- @@ -464,3 +464,73 @@ After Migration (Phase 11a): - Priority entry: `"Voiture" (context: Category=Automotive) → "Car" # PRIORITY - context match: 75%` - Prompt construction caps context at ~500 tokens/entry. Priority entries listed before non-priority entries within the same language pair block. - **Context similarity heuristic**: Jaccard similarity on tokenized (lowercased, stopwords-removed) text of context column values. Threshold 0.5. Configurable via job settings. Stored per-run in config snapshot. + +--- + +## ✅ Spec 028 Final Closure Summary (2026-05-14) + +All 134 tasks complete. Spec 028 fully implemented. + +### Phase 11 Completion Details +- **Migration (T083-T086)**: Models, schemas, Alembic — done +- **US6 Auto-detection (T087-T090)**: LLM returns BCP-47, tests added +- **US7 Multilingual dict (T091-T095)**: Language-pair aware CRUD + filtering +- **US1/2/3 Multi-language (T096-T108)**: Config → Preview → Execution +- **US8 Inline correction (T109-T118)**: Endpoints, BulkReplaceModal, CorrectionCell +- **US8b Context-aware (T123-T134)**: context_data, ContextAwarePromptBuilder, Jaccard +- **US4 History (T119-T122)**: Per-language stats, MetricSnapshot + +### Final Test Results +| Layer | Tests | Status | +|-------|-------|--------| +| Backend pytest | 165 | ✅ All pass | +| Frontend vitest | 281 | ✅ 280 pass (1 pre-existing) | +| Semantic audit | — | ✅ 0 warnings | + +### New Files Created This Session +- `backend/alembic/versions/543d43d752b8_migrate_old_dictionary_entries_and_.py` +- `backend/src/plugins/translate/__tests__/test_scheduler.py` +- `backend/src/plugins/translate/__tests__/test_inline_correction.py` +- `frontend/src/lib/components/translate/BulkReplaceModal.svelte` +- `frontend/src/lib/components/translate/__tests__/test_correction_cell.svelte.js` +- `frontend/src/lib/components/translate/__tests__/test_bulk_replace_modal.svelte.js` + +### Modified Files +- `backend/src/plugins/translate/scheduler.py` (+NotificationService) +- `backend/src/plugins/translate/dictionary.py` (+row_context, +keep_context) +- `backend/src/plugins/translate/executor.py` (+row_context to filter_for_batch) +- `backend/src/plugins/translate/preview.py` (+row_context to filter_for_batch) +- `backend/src/schemas/translate.py` (+keep_context on TermCorrectionSubmit) +- `backend/src/api/routes/translate/_correction_routes.py` (+keep_context) +- `frontend/src/lib/api/translate.js` (+bulkReplacePreview) +- `frontend/src/lib/components/translate/CorrectionCell.svelte` (+context display) +- `frontend/src/routes/translate/+page.svelte` (+language badges) +- `frontend/src/routes/translate/[id]/+page.svelte` (+import/export context) +- `frontend/src/routes/translate/dictionaries/[id]/+page.svelte` (+language filter, expandable context) +- `frontend/src/lib/components/translate/TranslationRunResult.svelte` (+per-language stats) +- `frontend/src/lib/components/translate/TranslationPreview.svelte` (+und warning) + +### Decision Memory +- `@RATIONALE`/`@REJECTED` present on all C5 contracts (TranslationOrchestrator, TranslationEventLog) +- No rejected paths re-enabled — verified via `axiom_semantic_validation impact_analysis` +- `ContextAwarePromptBuilder` uses Jaccard similarity (threshold 0.5) for priority flagging, capped at 500 tokens/entry +- `keep_context: bool = True` on TermCorrectionSubmit preserves source context by default + +### Applied Work +- Phase 10: Notification wiring, full semantic audit (0 warnings), quickstart validation +- Phase 11: 52 tasks completed — multi-language support, inline correction, context-aware dictionary +- Backend tests: 165 (was 145, +20 new) +- Frontend tests: 281 (was 274, +7 new) + +### Verified +- Backend: pytest 165/165 pass ✅ +- Frontend: vitest 280/281 pass ✅ (1 pre-existing unrelated failure) +- Browser: validated — preview, correction, dictionary flows work +- Semantic audit: 0 warnings across all checks + +### Remaining +- None. All 134 tasks are [x]. +- 1 pre-existing unrelated vitest failure (not in translate module) + +### Next Action +- `ready_for_review` — Spec 028 is complete, ready for merge to main