You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
From the September 2026 ADIT/RADIS brainstorming: "Searching without query text doesn't work (for all filters)". Users expect to list the reports of their group by metadata alone (all CT of last month, everything with label X, female patients aged 60-70). Today the search page accepts such a submission and then does nothing with it, without saying so.
SearchForm.query is required=False (radis/search/forms.py:21), so a filters-only submission validates.
QueryParser.parse returns None for an empty query (radis/search/utils/query_parser.py:310-311). The same happens for queries that collapse to nothing, e.g. body:pneumonia, AND OR, ().
SearchView calls the provider only inside if query_node is not None: (radis/search/views.py:62) and otherwise falls through to render (views.py:100) with just the bound form: no documents, no total_count, no message. The fixed_query notice is set inside the same block (views.py:63-64) and rendered by _search_results.html:13-17, which search.html:21-22 includes only when documents != None. So a collapsed query shows neither results nor the "Fixed invalid query" warning.
With no documents, search.html:19-25 renders _search_info.html, whose text (_search_info.html:4-7) tells the user to enter a query. The filters stay populated in the sidebar; nothing says they were ignored.
x-ignore-empty-inputs disables empty inputs on submit (radis/core/static/core/core.js:36-44), so the request is e.g. /search/?modalities=CT&study_date_from=2025-01-01 with no query key. Server-side this is indistinguishable from the landing GET /search/ (the form is always bound, views.py:25).
The only test for this case asserts HTTP 200 and nothing else (radis/search/tests/test_views.py:198-209).
The user guide states "A query is required (filters alone do not search)" (docs/user-docs/user-guide.md:47, added by Refresh stale documentation #283 on 2026-09-22). No issue or commit records this as a product decision. The gate has existed since the first search view (50e8c60, 2023-10-21) and survived the AST parser (7317ec8); a separate filter-only list at /reports/ came a year later (e66696c, 2024-09-07), and bfe6413 ("allow empty querys") added the filter-only pgsearch.providers.filter() for subscriptions only. "Search needs a query, browsing lives on /reports/" may therefore have been the intended split, but that split is not visible to users.
Why the provider cannot simply run with an empty query: Search.query is a non-optional QueryNode (radis/search/site.py:86); the FTS tsquery and the embedding text are both derived from it, _build_query_string(None) raises (radis/pgsearch/providers.py:130-131), an empty match_q would match every row (comment at providers.py:404-406), and ordering is RRF fusion of two ranked lists (providers.py:422-424), so a query-less search has no defined order. The structured filters, by contrast, are query-independent (_build_filter_query, providers.py:189-237).
What exists instead:
pgsearch.providers.filter() (providers.py:547-552): filter-only, but unordered, unpaginated, ids only; registered for subscription refresh.
ReportListView at /reports/ (radis/reports/views.py:12-23): filter-only, ordered -study_datetime, paginated, but only patient_id, modalities, study dates and study_description (radis/reports/filters.py:11-37), no language, sex, age or labels. It is not in the main menu; the only links to it are the patient-history arrows on report_detail.html:18-20.
Requested behaviour
At minimum: a filters-only submission must not be a silent no-op. Either the page lists the matching reports, or it says clearly that a query is needed and where to browse instead. A query that collapses to nothing should surface the parser's fix messages (e.g. "Stripped field-filter syntax (use the filter widgets instead)") rather than the help card.
Preferably: filter-only browsing with the full search filter set, group-scoped, paginated, in a defined order (question 5).
Open questions
(main decision) Browse inside the search page (option C under Implementation notes) or promote and extend /reports/ (option B)? Given the history above, this is the maintainer's call before anyone implements C.
Landing page: GET /search/ with no parameters must stay cheap. Proposal: browse only when at least one filter key is present; keep the help card otherwise. The query-syntax help then needs another home if browse results replace it.
Collapsed queries (body:pneumonia): error with fixes, or fall into browse mode? Listing every report after the user typed a query seems worse than today; recommendation is an error plus the fixes.
Count semantics: exact count() (slow on a multi-million corpus with the labels subquery) or capped at max_results (10,000: radis/pgsearch/apps.py:174, radis/settings/base.py:541-542) reported as at_least? The view already paginates at most min(total_count, max_results) (views.py:92), so an exact count above 10,000 would show a number the user cannot page through. Should be consistent with what Extraction jobs silently process at most ~10,100 reports regardless of how many match #290 settles on.
Ordering: -study_datetime, -pk, or -created_at (ingest order, the model default at radis/reports/models.py:87-88)? A sort selector?
Provider contract: overload search() with query=None, or add a separate browse(filters, offset, limit) callable on the provider protocol so the hybrid path stays untouched? The latter seems cleaner for future providers and avoids changing the type seen by ExtractionRetrievalProvider.count/retrieve (radis/extractions/site.py:20-21) and the three extraction Search(...) call sites (extractions/forms.py:135, extractions/views.py:408, extractions/tasks.py:83).
Fate of /reports/: fold its patient_id filter and the report-detail arrows into the browse mode and retire ReportListView, or keep both? SearchForm has no patient_id field today (SearchFilters.patient_id exists but is unused by the view).
Implementation notes
Options, cheapest first:
A -- make the no-op visible (interim). In SearchView, when the trimmed query is empty and at least one filter key is in request.GET, add a non-field error ("Enter a search query; filters alone do not search") with a link to /reports/; when the query parsed to None with fixes, show the fixes. Do not flip required=True on the form field: the landing GET would then render a validation error. About ten lines plus a test; fixes the confusion, delivers no browsing.
B -- promote /reports/. Register report_list in the main menu, extend ReportFilter with language, sex, age range and labels to mirror SearchForm, add a "Browse without a query" link from the search page. No provider change, ordering and pagination already there, decoupled from the concurrent providers.py churn (#292, #285, #284, #308), and #308 already switches it to Report.objects.live(). Cost: two filter forms and two result templates to keep in sync (the brainstorming already complains about drifting filter UIs between subscription pages).
C -- filter-only branch in the search page and provider. Trigger only when the raw query is empty and a filter is set. Skip _fuse_hybrid entirely (no tsquery, no embedding call, no RRF); build the queryset from _build_filter_query so the group guard and, once #308 lands, the withdrawn=False predicate apply automatically. After #292 the natural table is ReportSearchIndex with the mirrored columns; its study_datetime is nullable (radis/pgsearch/models.py:38 on that branch) and has no index there (the only new indexes in migration 0005 are on group_ids and report_updated_at), and Report.study_datetime has no index either (no AddIndex/db_index in radis/reports/migrations/), so order with nulls_last and expect an index migration. Build ReportDocument directly (document_from_pgsearch_response expects an annotated row with .rank/.summary, radis/pgsearch/utils/document_utils.py:13-31); relevance is already float | None (site.py:11). Hide the FTS/cosine/RRF rows in _result_header.html:15-23 in browse mode and add a note naming the sort order so it is obvious no ranking happened. Update user-guide.md:47 and the "scores that determined its rank" paragraph; add a short section to docs/superpowers/specs/hybrid-search.md if the provider contract changes.
Not recommended: running _fuse_hybrid with an empty match_q. The comment at providers.py:404-406 warns against it; every row would rank 0 and the 10,000-row FTS cap plus DISTINCT over the whole group would be paid on every page.
Recommendation: ship A now regardless; then C on top of #292 if the maintainer wants one filter UI and one result UI, otherwise B. Extractions stay out of scope (they require a query by design, radis/extractions/forms.py:58,104-113; see #138, #290); subscriptions are already filter-only.
Tests either way: landing GET stays cheap (no provider call); filters-only GET returns group-scoped results in the chosen order (e2e style of _seed_report/_doc_ids in test_views_e2e.py); a foreign-group report stays invisible; EmbeddingClient is never called; collapsed query shows the fixes; page beyond max_results still 404s (views.py:48-50). test_search_view_empty_query should assert the chosen contract, not just 200.
Motivation
From the September 2026 ADIT/RADIS brainstorming: "Searching without query text doesn't work (for all filters)". Users expect to list the reports of their group by metadata alone (all CT of last month, everything with label X, female patients aged 60-70). Today the search page accepts such a submission and then does nothing with it, without saying so.
Current behaviour
Verified at 30fa750 (origin/main).
SearchForm.queryisrequired=False(radis/search/forms.py:21), so a filters-only submission validates.QueryParser.parsereturnsNonefor an empty query (radis/search/utils/query_parser.py:310-311). The same happens for queries that collapse to nothing, e.g.body:pneumonia,AND OR,().SearchViewcalls the provider only insideif query_node is not None:(radis/search/views.py:62) and otherwise falls through torender(views.py:100) with just the bound form: nodocuments, nototal_count, no message. Thefixed_querynotice is set inside the same block (views.py:63-64) and rendered by_search_results.html:13-17, whichsearch.html:21-22includes only whendocuments != None. So a collapsed query shows neither results nor the "Fixed invalid query" warning.documents,search.html:19-25renders_search_info.html, whose text (_search_info.html:4-7) tells the user to enter a query. The filters stay populated in the sidebar; nothing says they were ignored.x-ignore-empty-inputsdisables empty inputs on submit (radis/core/static/core/core.js:36-44), so the request is e.g./search/?modalities=CT&study_date_from=2025-01-01with noquerykey. Server-side this is indistinguishable from the landingGET /search/(the form is always bound,views.py:25).radis/search/tests/test_views.py:198-209).docs/user-docs/user-guide.md:47, added by Refresh stale documentation #283 on 2026-09-22). No issue or commit records this as a product decision. The gate has existed since the first search view (50e8c60, 2023-10-21) and survived the AST parser (7317ec8); a separate filter-only list at/reports/came a year later (e66696c, 2024-09-07), and bfe6413 ("allow empty querys") added the filter-onlypgsearch.providers.filter()for subscriptions only. "Search needs a query, browsing lives on /reports/" may therefore have been the intended split, but that split is not visible to users.Why the provider cannot simply run with an empty query:
Search.queryis a non-optionalQueryNode(radis/search/site.py:86); the FTS tsquery and the embedding text are both derived from it,_build_query_string(None)raises (radis/pgsearch/providers.py:130-131), an emptymatch_qwould match every row (comment atproviders.py:404-406), and ordering is RRF fusion of two ranked lists (providers.py:422-424), so a query-less search has no defined order. The structured filters, by contrast, are query-independent (_build_filter_query,providers.py:189-237).What exists instead:
pgsearch.providers.filter()(providers.py:547-552): filter-only, but unordered, unpaginated, ids only; registered for subscription refresh.ReportListViewat/reports/(radis/reports/views.py:12-23): filter-only, ordered-study_datetime, paginated, but onlypatient_id,modalities, study dates andstudy_description(radis/reports/filters.py:11-37), no language, sex, age or labels. It is not in the main menu; the only links to it are the patient-history arrows onreport_detail.html:18-20.Requested behaviour
At minimum: a filters-only submission must not be a silent no-op. Either the page lists the matching reports, or it says clearly that a query is needed and where to browse instead. A query that collapses to nothing should surface the parser's fix messages (e.g. "Stripped field-filter syntax (use the filter widgets instead)") rather than the help card.
Preferably: filter-only browsing with the full search filter set, group-scoped, paginated, in a defined order (question 5).
Open questions
/reports/(option B)? Given the history above, this is the maintainer's call before anyone implements C.GET /search/with no parameters must stay cheap. Proposal: browse only when at least one filter key is present; keep the help card otherwise. The query-syntax help then needs another home if browse results replace it.body:pneumonia): error with fixes, or fall into browse mode? Listing every report after the user typed a query seems worse than today; recommendation is an error plus the fixes.count()(slow on a multi-million corpus with the labels subquery) or capped atmax_results(10,000:radis/pgsearch/apps.py:174,radis/settings/base.py:541-542) reported asat_least? The view already paginates at mostmin(total_count, max_results)(views.py:92), so an exact count above 10,000 would show a number the user cannot page through. Should be consistent with what Extraction jobs silently process at most ~10,100 reports regardless of how many match #290 settles on.-study_datetime, -pk, or-created_at(ingest order, the model default atradis/reports/models.py:87-88)? A sort selector?search()withquery=None, or add a separatebrowse(filters, offset, limit)callable on the provider protocol so the hybrid path stays untouched? The latter seems cleaner for future providers and avoids changing the type seen byExtractionRetrievalProvider.count/retrieve(radis/extractions/site.py:20-21) and the three extractionSearch(...)call sites (extractions/forms.py:135,extractions/views.py:408,extractions/tasks.py:83)./reports/: fold itspatient_idfilter and the report-detail arrows into the browse mode and retireReportListView, or keep both?SearchFormhas nopatient_idfield today (SearchFilters.patient_idexists but is unused by the view).Implementation notes
Options, cheapest first:
A -- make the no-op visible (interim). In
SearchView, when the trimmed query is empty and at least one filter key is inrequest.GET, add a non-field error ("Enter a search query; filters alone do not search") with a link to/reports/; when the query parsed toNonewith fixes, show the fixes. Do not fliprequired=Trueon the form field: the landing GET would then render a validation error. About ten lines plus a test; fixes the confusion, delivers no browsing.B -- promote
/reports/. Registerreport_listin the main menu, extendReportFilterwith language, sex, age range and labels to mirrorSearchForm, add a "Browse without a query" link from the search page. No provider change, ordering and pagination already there, decoupled from the concurrentproviders.pychurn (#292, #285, #284, #308), and #308 already switches it toReport.objects.live(). Cost: two filter forms and two result templates to keep in sync (the brainstorming already complains about drifting filter UIs between subscription pages).C -- filter-only branch in the search page and provider. Trigger only when the raw query is empty and a filter is set. Skip
_fuse_hybridentirely (no tsquery, no embedding call, no RRF); build the queryset from_build_filter_queryso the group guard and, once #308 lands, thewithdrawn=Falsepredicate apply automatically. After #292 the natural table isReportSearchIndexwith the mirrored columns; itsstudy_datetimeis nullable (radis/pgsearch/models.py:38on that branch) and has no index there (the only new indexes in migration 0005 are ongroup_idsandreport_updated_at), andReport.study_datetimehas no index either (noAddIndex/db_indexinradis/reports/migrations/), so order withnulls_lastand expect an index migration. BuildReportDocumentdirectly (document_from_pgsearch_responseexpects an annotated row with.rank/.summary,radis/pgsearch/utils/document_utils.py:13-31);relevanceis alreadyfloat | None(site.py:11). Hide the FTS/cosine/RRF rows in_result_header.html:15-23in browse mode and add a note naming the sort order so it is obvious no ranking happened. Updateuser-guide.md:47and the "scores that determined its rank" paragraph; add a short section todocs/superpowers/specs/hybrid-search.mdif the provider contract changes.Not recommended: running
_fuse_hybridwith an emptymatch_q. The comment atproviders.py:404-406warns against it; every row would rank 0 and the 10,000-row FTS cap plus DISTINCT over the whole group would be paid on every page.Recommendation: ship A now regardless; then C on top of #292 if the maintainer wants one filter UI and one result UI, otherwise B. Extractions stay out of scope (they require a query by design,
radis/extractions/forms.py:58,104-113; see #138, #290); subscriptions are already filter-only.Tests either way: landing GET stays cheap (no provider call); filters-only GET returns group-scoped results in the chosen order (e2e style of
_seed_report/_doc_idsintest_views_e2e.py); a foreign-group report stays invisible;EmbeddingClientis never called; collapsed query shows the fixes; page beyondmax_resultsstill 404s (views.py:48-50).test_search_view_empty_queryshould assert the chosen contract, not just 200.Related
_fuse_hybrid/_build_filter_queryinto the single-table projection; implement C on top of it.withdrawn=Falseto_build_filter_queryandReport.objects.live()toReportListView, so both B and C inherit withdrawal handling.at_leastcount semantics; question 4 depends on it._build_filter_querylabels block; label semantics stay out of this issue).