Repository navigation
Query builder: notes follow the user's language #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, Catch Prompt for AI agentsIn `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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Escape or remove terminal control characters from user-supplied values before printing CLI notes. Also at Prompt for AI agentsIn `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: | ||
|
|
@@ -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,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 | ||
|
|
@@ -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, | ||
|
|
||
There was a problem hiding this comment.
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,
stringskeeps the window labels in the original locale, whilelocale_textreads 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