Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
)
Comment on lines +769 to +770

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Builder labels and notes diverge

When the active locale changes while a query-builder window is open, strings keeps the window labels in the original locale, while locale_text reads the changed global locale for notes, so the window displays labels and notes in different languages.

Make the note translator use the same locale snapshot as the window labels.

Also at packages/auto_apply/src/auto_apply/adapters/primary/gui/strings.py:30-36.

Prompt for AI agents
In `packages/auto_apply/src/auto_apply/adapters/primary/gui/app.py` at lines 769-770:

**Builder labels and notes diverge**

When the active locale changes while a query-builder window is open, `strings` keeps the window labels in the original locale, while `locale_text` reads the changed global locale for notes, so the window displays labels and notes in different languages.

Make the note translator use the same locale snapshot as the window labels.

Also at `packages/auto_apply/src/auto_apply/adapters/primary/gui/strings.py:30-36`.


# =====================================================================
# LIFECYCLE (Install / Repair…, Uninstall…)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -174,20 +176,35 @@ 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:
lines.append(f"{translate('builder.result.bar')}: {result.bar_text}")
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):
Comment on lines +202 to +203

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Result rendering fails

When a locale template uses an attribute-style placeholder whose lookup fails, template.format(**dict(note.message_params)) raises an uncaught AttributeError, so note_text cannot return the English fallback and CLI or GUI result rendering fails.

Catch AttributeError alongside the existing formatting exceptions so note_text returns the English fallback.

Prompt for AI agents
In `packages/auto_apply/src/auto_apply/domain/models/search_query.py` at lines 202-203:

**Result rendering fails**

When a locale template uses an attribute-style placeholder whose lookup fails, `template.format(**dict(note.message_params))` raises an uncaught `AttributeError`, so `note_text` cannot return the English fallback and CLI or GUI result rendering fails.

Catch `AttributeError` alongside the existing formatting exceptions so `note_text` returns the English fallback.

pass
return note.message


def resolved_remember_request(
request: QueryRequest, restriction: str | None
) -> QueryRequest:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Location escapes terminal output

When a scripted CLI invocation supplies an unsupported location containing terminal control sequences, the CLI passes the formatted note to print, where terminal control sequences in request.location are interpreted, allowing crafted input to forge or hide output and trigger terminal-side actions.

Escape or remove terminal control characters from user-supplied values before printing CLI notes.

Also at packages/auto_apply/src/auto_apply/domain/models/search_query.py:202.

Prompt for AI agents
In `packages/auto_apply/src/auto_apply/domain/services/query_composer.py` at line 337:

**Location escapes terminal output**

When a scripted CLI invocation supplies an unsupported location containing terminal control sequences, the CLI passes the formatted note to `print`, where terminal control sequences in `request.location` are interpreted, allowing crafted input to forge or hide output and trigger terminal-side actions.

Escape or remove terminal control characters from user-supplied values before printing CLI notes.

Also at `packages/auto_apply/src/auto_apply/domain/models/search_query.py:202`.

))

for key, value in request.options:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -399,13 +466,18 @@ 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:
notes.append(_info(
"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
Expand All @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions packages/auto_apply/src/auto_apply/resources/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Loading
Loading