diff --git a/CHANGELOG.md b/CHANGELOG.md index b61dbf6..62b0943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,14 @@ from its first tag onward. ### Added +- **The query builder's notes follow the user's language.** Every note the + composer or the builder service writes now carries a locale key and its + values (`QueryNote.message_key`, `message_params`); both surfaces render it + from `en.json` / `es.json` through the shared `format_result_lines`, and + fall back to the English sentence (still in `message`) when a key is + missing. Still English for now: the item names inside the "cannot carry" + refusal, `QueryBuilderError` messages, and the catalog's own labels and + explanations. - **The query builder is finished: the GUI window, one vocabulary on both surfaces, and the last silent drops said aloud.** File → Build a Search… offers everything the CLI offers (engine, platform and company searches, diff --git a/packages/auto_apply/src/auto_apply/adapters/primary/cli/search_builder_screen.py b/packages/auto_apply/src/auto_apply/adapters/primary/cli/search_builder_screen.py index 5e32a24..79b99b1 100644 --- a/packages/auto_apply/src/auto_apply/adapters/primary/cli/search_builder_screen.py +++ b/packages/auto_apply/src/auto_apply/adapters/primary/cli/search_builder_screen.py @@ -58,8 +58,8 @@ def _parse_options(raw: list[str] | None) -> tuple[tuple[str, str], ...]: def _render(result) -> None: - """Print the result: labels through the locale, notes verbatim (they are - the composer's plain-language sentences).""" + """Print the result: labels and notes through the locale, with English + sentences as fallbacks.""" for line in format_result_lines(result, translate=get_text): print(line) # noqa: T201 diff --git a/packages/auto_apply/src/auto_apply/adapters/primary/gui/app.py b/packages/auto_apply/src/auto_apply/adapters/primary/gui/app.py index 4fa689f..02d1af1 100644 --- a/packages/auto_apply/src/auto_apply/adapters/primary/gui/app.py +++ b/packages/auto_apply/src/auto_apply/adapters/primary/gui/app.py @@ -91,7 +91,7 @@ from auto_apply.adapters.primary.gui.research_window import ResearchWindow from auto_apply.adapters.primary.gui.search_window import SearchBuilderWindow from auto_apply.adapters.primary.gui.settings_editor import SettingsEditor -from auto_apply.adapters.primary.gui.strings import get_strings +from auto_apply.adapters.primary.gui.strings import get_strings, locale_text from auto_apply.adapters.primary.gui.wizard import SessionConfigWizard as GUIWizard from auto_apply.application.services.profile_validator import TEMPLATE_PROFILE_NAME from auto_apply.application.services.ui_schema import UIField, build_ui_schema @@ -765,7 +765,9 @@ def _open_search_builder(self) -> None: if builder is None: builder = build_query_builder(self.profile) strings = get_strings() - SearchBuilderWindow(self, builder, translate=lambda key: strings.get(key, key)) + SearchBuilderWindow( + self, builder, translate=lambda key: strings.get(key) or locale_text(key) + ) # ===================================================================== # LIFECYCLE (Install / Repair…, Uninstall…) diff --git a/packages/auto_apply/src/auto_apply/adapters/primary/gui/strings.py b/packages/auto_apply/src/auto_apply/adapters/primary/gui/strings.py index 6879b78..b681802 100644 --- a/packages/auto_apply/src/auto_apply/adapters/primary/gui/strings.py +++ b/packages/auto_apply/src/auto_apply/adapters/primary/gui/strings.py @@ -27,6 +27,15 @@ def _tr(key: str, default: str) -> str: return value if value and value != key else default +def locale_text(key: str) -> str: + """The active locale's text for *key*, or *key* itself when it has none. + + For text the window does not list in get_strings(), such as the query + builder's note templates: the caller falls back when it gets the key back. + """ + return _tr(key, key) + + def get_strings(lang_code: str | None = None) -> dict[str, str]: """Returns a flat dict of UI labels for the given locale. diff --git a/packages/auto_apply/src/auto_apply/application/services/query_builder.py b/packages/auto_apply/src/auto_apply/application/services/query_builder.py index 000697d..fc81bcc 100644 --- a/packages/auto_apply/src/auto_apply/application/services/query_builder.py +++ b/packages/auto_apply/src/auto_apply/application/services/query_builder.py @@ -309,9 +309,10 @@ def build_company_query(self, url: str, request: QueryRequest) -> QueryResult: severity="refused", topic="platform", message=( - "that address is not on a hiring platform AA " + "That address is not on a hiring platform AA " "knows, so no search was built." ), + message_key="builder.note.company_unknown_platform", ), ), ) diff --git a/packages/auto_apply/src/auto_apply/domain/models/search_query.py b/packages/auto_apply/src/auto_apply/domain/models/search_query.py index 2e5db74..36a27f8 100644 --- a/packages/auto_apply/src/auto_apply/domain/models/search_query.py +++ b/packages/auto_apply/src/auto_apply/domain/models/search_query.py @@ -43,6 +43,8 @@ class QueryNote(_Frozen): severity: Literal["info", "refused"] topic: str message: str + message_key: str = "" + message_params: tuple[tuple[str, str], ...] = () class QueryResult(_Frozen): @@ -174,9 +176,12 @@ def format_result_lines( ) -> list[str]: """The result as display lines. - Notes render verbatim: they are the composer's plain-language sentences - and carry their own wording. The two labels are locale-dependent, so the - caller injects its translator; the default returns keys unchanged. + The translator takes one key and returns the locale's text for it (or + the key itself when the locale lacks it). Labels render through it + directly. A note with a message_key renders from its locale template, + filled with the note's own values; a missing key, or a template that + cannot be filled, falls back to the note's English sentence, so a note + is never blank and never a raw key. """ lines: list[str] = [] if result.bar_text is not None: @@ -184,10 +189,22 @@ def format_result_lines( if result.url is not None: lines.append(f"{translate('builder.result.link')}: {result.url}") for note in result.notes: - lines.append(f" {note.message}") + lines.append(f" {note_text(note, translate)}") return lines +def note_text(note: QueryNote, translate: Callable[[str], str]) -> str: + """One note in the active locale, or its English sentence.""" + if note.message_key: + template = translate(note.message_key) + if template and template != note.message_key: + try: + return template.format(**dict(note.message_params)) + except (KeyError, IndexError, ValueError): + pass + return note.message + + def resolved_remember_request( request: QueryRequest, restriction: str | None ) -> QueryRequest: diff --git a/packages/auto_apply/src/auto_apply/domain/services/query_composer.py b/packages/auto_apply/src/auto_apply/domain/services/query_composer.py index a19a343..a1f757b 100644 --- a/packages/auto_apply/src/auto_apply/domain/services/query_composer.py +++ b/packages/auto_apply/src/auto_apply/domain/services/query_composer.py @@ -73,12 +73,24 @@ def _clean_site(text: str) -> str: return site.strip().rstrip("/") -def _info(topic: str, message: str) -> QueryNote: - return QueryNote(severity="info", topic=topic, message=message) +def _info(topic: str, message: str, message_key: str = "", **kwargs: str) -> QueryNote: + return QueryNote( + severity="info", + topic=topic, + message=message, + message_key=message_key, + message_params=tuple(kwargs.items()), + ) -def _refused(topic: str, message: str) -> QueryNote: - return QueryNote(severity="refused", topic=topic, message=message) +def _refused(topic: str, message: str, message_key: str = "", **kwargs: str) -> QueryNote: + return QueryNote( + severity="refused", + topic=topic, + message=message, + message_key=message_key, + message_params=tuple(kwargs.items()), + ) def _offered_operators(spec: SearchSurfaceSpec) -> dict[str, OperatorSpec]: @@ -132,24 +144,32 @@ def _compose_bar( f"{spec.display_name} has no way to limit a typed search to one " "site, so no search text or link was written — one without the " "site limit would be a different search.", + message_key="builder.note.site_no_typed", + surface=spec.display_name, )], True if request.any_of and ("any_of" not in ops or not tq.or_token): return None, [_refused( "any_of", f"{spec.display_name} has no way to say 'any of these' in its " "search bar, so no search text or link was written.", + message_key="builder.note.any_of_no_typed", + surface=spec.display_name, )], True if request.exclude and "exclude" not in ops: return None, [_refused( "exclude", f"{spec.display_name} has no way to exclude words in its search " "bar, so no search text or link was written.", + message_key="builder.note.exclude_no_typed", + surface=spec.display_name, )], True if request.in_title and "intitle" not in ops: return None, [_refused( "intitle", f"{spec.display_name} has no title filter in its search bar, so " "no search text or link was written.", + message_key="builder.note.intitle_no_typed", + surface=spec.display_name, )], True quote = tq.phrase_quote @@ -159,6 +179,8 @@ def _compose_bar( "phrase", f"{spec.display_name} has no exact-phrase quoting; your phrase " "was written without quotes.", + message_key="builder.note.phrase_no_quotes", + surface=spec.display_name, )) for phrase in request.phrases: content = _clean_content(phrase) @@ -204,6 +226,11 @@ def _compose_bar( f"is reported to stop reading after {tq.max_words}{source}. " "Nothing was written — cutting it down would not be the search " "you asked for.", + message_key="builder.note.length", + count=str(len(bar.split())), + surface=spec.display_name, + limit=str(tq.max_words), + source=source, )) return None, notes, True return bar, notes, False @@ -235,6 +262,10 @@ def _compose_url( "template", f"{spec.display_name} has no link form called '{template_name}'. " f"Choices: {', '.join(spec.urls)}.", + message_key="builder.note.template_missing", + surface=spec.display_name, + template=template_name, + choices=", ".join(spec.urls), )] if bar is not None: @@ -255,6 +286,8 @@ def _compose_url( "phrase", f"{spec.display_name} has no exact-phrase quoting AA can " "verify, so your words went into the link without quotes.", + message_key="builder.note.phrase_url_unverified", + surface=spec.display_name, )) bits.extend(normalize_confusables(t) for t in request.required) # Every restriction a surface cannot carry stops the link in @@ -267,9 +300,14 @@ def _compose_url( "options", "Settings without any search terms make no link, so they were " "not applied.", + message_key="builder.note.options_empty", )) else: - notes.append(_info("empty", "Nothing to build yet — add at least one term.")) + notes.append(_info( + "empty", + "Nothing to build yet — add at least one term.", + message_key="builder.note.empty", + )) return None, notes # Empty parameters are never written (an empty "l=" claims a location @@ -294,6 +332,9 @@ def _compose_url( f"{request.location!r} was left out of the search. " "Add the place to your search words, or pick a surface that " "takes a location.", + message_key="builder.note.location_unsupported", + surface=spec.display_name, + location=request.location, )) for key, value in request.options: @@ -305,11 +346,18 @@ def _compose_url( key, f"{spec.display_name}'s '{key}' setting is not offered: " f"{known.explanation}", + message_key="builder.note.option_not_offered", + surface=spec.display_name, + option=key, + explanation=known.explanation, )) else: notes.append(_refused( key, f"{spec.display_name} has no '{key}' setting, so it was left out.", + message_key="builder.note.option_missing", + surface=spec.display_name, + option=key, )) continue if opt.free_form: @@ -319,6 +367,10 @@ def _compose_url( key, f"'{value}' is not a value {spec.display_name}'s '{key}' " "setting can carry.", + message_key="builder.note.option_invalid_value", + surface=spec.display_name, + option=key, + value=value, )) continue code = cleaned @@ -328,6 +380,10 @@ def _compose_url( notes.append(_refused( key, f"'{value}' is not offered for {opt.label}. Choices: {choices}.", + message_key="builder.note.option_invalid_choice", + label=opt.label, + value=value, + choices=choices, )) continue looked_up = opt.codes.get(value) @@ -338,6 +394,10 @@ def _compose_url( notes.append(_refused( key, f"'{value}' is not a choice for {opt.label}. Choices: {choices}.", + message_key="builder.note.option_invalid_choice", + label=opt.label, + value=value, + choices=choices, )) continue code = looked_up @@ -356,12 +416,19 @@ def _compose_url( key, f"{opt.label} cannot be typed into {spec.display_name}'s " f"search bar — the link carries it. {opt.typed_equivalent_note}", + message_key="builder.note.option_url_only_with_note", + label=opt.label, + surface=spec.display_name, + note=opt.typed_equivalent_note, )) elif opt.channel == "url": notes.append(_info( key, f"{opt.label} cannot be typed into {spec.display_name}'s " "search bar — the link carries it.", + message_key="builder.note.option_url_only", + label=opt.label, + surface=spec.display_name, )) return f"{template.base}?{urlencode(params)}", notes @@ -399,6 +466,9 @@ def compose(request: QueryRequest, spec: SearchSurfaceSpec) -> QueryResult: f"{spec.display_name} has no way to carry " f"{', '.join(unexpressible)}, so no search text or link was " "written — one without it would be a different search.", + message_key="builder.note.unexpressible", + surface=spec.display_name, + unexpressible=", ".join(unexpressible), )) hard_stop = True elif request.phrases or request.required: @@ -406,6 +476,8 @@ def compose(request: QueryRequest, spec: SearchSurfaceSpec) -> QueryResult: "bar", f"{spec.display_name} has no typed search form AA can write for " "you — the link below is the way to use it.", + message_key="builder.note.no_typed_form", + surface=spec.display_name, )) url: str | None = None @@ -414,7 +486,11 @@ def compose(request: QueryRequest, spec: SearchSurfaceSpec) -> QueryResult: notes.extend(url_notes) if bar is None and url is None and not notes: - notes.append(_info("empty", "Nothing to build yet — add at least one term.")) + notes.append(_info( + "empty", + "Nothing to build yet — add at least one term.", + message_key="builder.note.empty", + )) return QueryResult( surface=spec.name, diff --git a/packages/auto_apply/src/auto_apply/resources/locales/en.json b/packages/auto_apply/src/auto_apply/resources/locales/en.json index 0b2b391..9c65c74 100644 --- a/packages/auto_apply/src/auto_apply/resources/locales/en.json +++ b/packages/auto_apply/src/auto_apply/resources/locales/en.json @@ -212,6 +212,28 @@ "bar": "Search bar text", "link": "Link" }, + "note": { + "site_no_typed": "{surface} has no way to limit a typed search to one site, so no search text or link was written — one without the site limit would be a different search.", + "any_of_no_typed": "{surface} has no way to say 'any of these' in its search bar, so no search text or link was written.", + "exclude_no_typed": "{surface} has no way to exclude words in its search bar, so no search text or link was written.", + "intitle_no_typed": "{surface} has no title filter in its search bar, so no search text or link was written.", + "phrase_no_quotes": "{surface} has no exact-phrase quoting; your phrase was written without quotes.", + "length": "That search is {count} words; {surface} is reported to stop reading after {limit}{source}. Nothing was written — cutting it down would not be the search you asked for.", + "template_missing": "{surface} has no link form called '{template}'. Choices: {choices}.", + "phrase_url_unverified": "{surface} has no exact-phrase quoting AA can verify, so your words went into the link without quotes.", + "options_empty": "Settings without any search terms make no link, so they were not applied.", + "empty": "Nothing to build yet — add at least one term.", + "location_unsupported": "{surface} has no place for a location, so '{location}' was left out of the search. Add the place to your search words, or pick a surface that takes a location.", + "option_not_offered": "{surface}'s '{option}' setting is not offered: {explanation}", + "option_missing": "{surface} has no '{option}' setting, so it was left out.", + "option_invalid_value": "'{value}' is not a value {surface}'s '{option}' setting can carry.", + "option_invalid_choice": "'{value}' is not a choice for {label}. Choices: {choices}.", + "option_url_only_with_note": "{label} cannot be typed into {surface}'s search bar — the link carries it. {note}", + "option_url_only": "{label} cannot be typed into {surface}'s search bar — the link carries it.", + "unexpressible": "{surface} has no way to carry {unexpressible}, so no search text or link was written — one without it would be a different search.", + "no_typed_form": "{surface} has no typed search form AA can write for you — the link below is the way to use it.", + "company_unknown_platform": "That address is not on a hiring platform AA knows, so no search was built." + }, "platform": { "unverified": "Unverified: the search mechanism is documented, but how much of this platform it reaches is not yet known. Open the link yourself and check that results come from the platform; the user guide shows how to report the outcome.", "third_party": "Reported by others; AA has not checked it." diff --git a/packages/auto_apply/src/auto_apply/resources/locales/es.json b/packages/auto_apply/src/auto_apply/resources/locales/es.json index c338945..3975fa9 100644 --- a/packages/auto_apply/src/auto_apply/resources/locales/es.json +++ b/packages/auto_apply/src/auto_apply/resources/locales/es.json @@ -174,6 +174,28 @@ "bar": "Texto de la barra de búsqueda", "link": "Enlace" }, + "note": { + "site_no_typed": "{surface} no tiene forma de limitar una búsqueda escrita a un solo sitio, por lo que no se escribió texto de búsqueda ni enlace — una sin el límite del sitio sería una búsqueda diferente.", + "any_of_no_typed": "{surface} no tiene forma de decir 'cualquiera de estos' en su barra de búsqueda, por lo que no se escribió texto de búsqueda ni enlace.", + "exclude_no_typed": "{surface} no tiene forma de excluir palabras en su barra de búsqueda, por lo que no se escribió texto de búsqueda ni enlace.", + "intitle_no_typed": "{surface} no tiene filtro de título en su barra de búsqueda, por lo que no se escribió texto de búsqueda ni enlace.", + "phrase_no_quotes": "{surface} no tiene uso de comillas para frases exactas; su frase se escribió sin comillas.", + "length": "Esa búsqueda tiene {count} palabras; se reporta que {surface} deja de leer después de {limit}{source}. No se escribió nada — reducirla no sería la búsqueda que usted pidió.", + "template_missing": "{surface} no tiene un formulario de enlace llamado '{template}'. Opciones: {choices}.", + "phrase_url_unverified": "{surface} no tiene uso de comillas para frases exactas que AA pueda verificar, por lo que sus palabras se incluyeron en el enlace sin comillas.", + "options_empty": "La configuración sin ningún término de búsqueda no genera un enlace, por lo que no se aplicó.", + "empty": "No hay nada que construir todavía — agregue al menos un término.", + "location_unsupported": "{surface} no tiene lugar para una ubicación, por lo que '{location}' se omitió de la búsqueda. Agregue el lugar a sus palabras de búsqueda, o elija una superficie que acepte ubicación.", + "option_not_offered": "La configuración '{option}' de {surface} no se ofrece: {explanation}", + "option_missing": "{surface} no tiene configuración '{option}', por lo que se omitió.", + "option_invalid_value": "'{value}' no es un valor que la configuración '{option}' de {surface} pueda llevar.", + "option_invalid_choice": "'{value}' no es una opción para {label}. Opciones: {choices}.", + "option_url_only_with_note": "{label} no se puede escribir en la barra de búsqueda de {surface} — el enlace lo lleva. {note}", + "option_url_only": "{label} no se puede escribir en la barra de búsqueda de {surface} — el enlace lo lleva.", + "unexpressible": "{surface} no tiene forma de llevar {unexpressible}, por lo que no se escribió texto de búsqueda ni enlace — una sin esto sería una búsqueda diferente.", + "no_typed_form": "{surface} no tiene un formulario de búsqueda escrito que AA pueda escribir por usted — el enlace a continuación es la forma de usarlo.", + "company_unknown_platform": "Esa dirección no pertenece a una plataforma de contratación que AA conozca, por lo que no se construyó ninguna búsqueda." + }, "platform": { "unverified": "Sin verificar: el mecanismo de búsqueda está documentado, pero aún no se sabe qué parte de esta plataforma alcanza. Abra el enlace usted mismo y compruebe que los resultados provienen de la plataforma; la guía del usuario muestra cómo informar del resultado.", "third_party": "Reportado por otros; AA no lo ha comprobado." diff --git a/packages/auto_apply/tests/adapters/test_gui_search_window.py b/packages/auto_apply/tests/adapters/test_gui_search_window.py index da16f8c..a24ce0e 100644 --- a/packages/auto_apply/tests/adapters/test_gui_search_window.py +++ b/packages/auto_apply/tests/adapters/test_gui_search_window.py @@ -160,3 +160,26 @@ def test_window_announces_and_closes_accessibly(): assert "WM_DELETE_WINDOW" in source assert "takefocus=True" in source assert "_status_var.set" in source + + +def test_gui_notes_follow_the_locale_and_never_crash__teeth(tmp_path): + """TEETH: the GUI's translator path (window labels first, then the + locale) renders a note with values in Spanish, without raising. Mutation + caught: a translator contract that passes values the GUI's one-argument + translator cannot take (it raised TypeError), or notes left in English + because the GUI translator only knew the window's labels.""" + from auto_apply.adapters.primary.gui.strings import locale_text # noqa: PLC0415 + from auto_apply.application.services.i18n import configure_locale # noqa: PLC0415 + + result = _service(tmp_path / "recent.json").build_query( + build_request(surface="google", terms="Software Engineer", location="Seattle, WA") + ) + labels = {"builder.result.bar": "Texto", "builder.result.link": "Enlace"} + try: + configure_locale(language="es") + lines = gw.result_lines(result, lambda key: labels.get(key) or locale_text(key)) + finally: + configure_locale(language="en") # restore default for other tests + text = "\n".join(lines) + assert "no tiene lugar para una ubicación" in text + assert "Seattle, WA" in text diff --git a/packages/auto_apply/tests/adapters/test_search_builder_cli.py b/packages/auto_apply/tests/adapters/test_search_builder_cli.py index bd671a3..3550979 100644 --- a/packages/auto_apply/tests/adapters/test_search_builder_cli.py +++ b/packages/auto_apply/tests/adapters/test_search_builder_cli.py @@ -196,6 +196,72 @@ def test_format_result_lines_renders_refusal_notes_when_nothing_built__teeth() - assert lines == [" no site limit exists"] +def test_format_result_lines_translates_notes_and_falls_back__teeth() -> None: + """TEETH: a note with a message_key is translated using the injected + translator, substituting its parameters. A missing key falls back to the + English message. Mutation caught: ignoring message_key, failing to pass + kwargs, or failing to fall back.""" + result = QueryResult( + surface="google", display_name="Google", bar_text=None, url=None, + notes=( + QueryNote( + severity="info", + topic="location", + message="Fallback Paris", + message_key="builder.note.location", + message_params=(("location", "Paris"),), + ), + QueryNote( + severity="info", + topic="other", + message="Fallback Other", + message_key="builder.note.missing", + message_params=(), + ), + ), + ) + def fake_translate(key): + if key == "builder.note.location": + return "Translated {location}" + return key + + lines = format_result_lines(result, translate=fake_translate) + assert lines == [" Translated Paris", " Fallback Other"] + + +def test_a_note_renders_in_spanish_through_the_real_locale__teeth(tmp_path) -> None: + """TEETH: the location note renders in Spanish through the real locale + and the shared helper - what a person sees in the CLI. Mutation caught: + format_result_lines printing note.message again (the English sentence), + or the es.json template missing.""" + from auto_apply.application.services.i18n import configure_locale # noqa: PLC0415 + + result = _service(tmp_path).build_query( + build_request(surface="google", terms="Software Engineer", location="Seattle, WA") + ) + try: + configure_locale(language="es") + text = "\n".join(format_result_lines(result, translate=get_text)) + finally: + configure_locale(language="en") # restore default for other tests + assert "no tiene lugar para una ubicación" in text + assert "Seattle, WA" in text + assert "has no place for a location" not in text + + +def test_a_template_that_cannot_be_filled_falls_back_to_english__teeth() -> None: + """TEETH (never a raw key, never a crash): a locale template naming a + value the note does not carry falls back to the English sentence. + Mutation caught: the KeyError guard in note_text removed.""" + result = QueryResult( + surface="google", display_name="Google", bar_text=None, url=None, + notes=(QueryNote(severity="info", topic="location", message="English sentence", + message_key="builder.note.x", message_params=(("a", "1"),)),), + ) + lines = format_result_lines(result, translate=lambda key: "Needs {b}") + assert lines == [" English sentence"] + + def test_resolved_remember_request_folds_site_only__teeth() -> None: """TEETH (remembered platform/company queries are stored resolved). Mutation caught: folding anything but `site`, or folding an empty