refactor(app): retire short_query_hint helper, keep wire field as None
V009 unicode61 + 형태소 tokenizer 환경에서 2-char 한국어 query 가
hit 가능해졌으므로 V007 시기의 "3자 이상 권장" hint 가 obsolete.
SearchResponse.hint field 는 wire schema 보존 위해 struct 에 유지 +
항상 None.
- kebab-app/src/app.rs: short_query_hint 함수 + doc-comment 삭제.
2 호출 site 가 hint = None 으로 정리.
- kebab-app/src/lib.rs: re-export 에서 short_query_hint 제거.
- kebab-tui/{app.rs,search.rs,run.rs}: short_query_hint field + 4
호출 cascade 제거.
- kebab-cli/tests/wire_search_response.rs:
search_plain_emits_short_query_hint_to_stderr test 삭제.
search_json_emits_hint_field_for_short_query →
search_json_hint_absent_for_short_query_v009 으로 교체
(hint 항상 None 검증).
- kebab-search/src/lexical.rs::build_match_string: V007 의 trigram
multi-token OR-combine 분기는 V009 환경에서 redundant 하나 보존
(future 확장성) — doc-comment 1 줄 추가.
Wire schema shape 변경 없음 (search_response.schema.json:33 의 hint
field 보존, struct 에 None 으로 항상 셋팅).
Spec: docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md §7.2, §7.3, §11.3
Plan: docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan.md (S5)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -89,29 +89,6 @@ pub struct SearchResponse {
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
/// v0.17.0 A5 Step 4b: decide whether to attach a "3자 이상 키워드 권장"
|
||||
/// hint to a `SearchResponse`. Fires only when the result set is empty
|
||||
/// *and* the trimmed query is shorter than the trigram tokenizer can
|
||||
/// resolve. Raw FTS5 mode (`'...'`) opts out — the user explicitly
|
||||
/// invoked FTS5 syntax. Identical condition powers the CLI stderr line
|
||||
/// and (separately) the TUI status bar.
|
||||
pub fn short_query_hint(query_text: &str, hits_empty: bool) -> Option<String> {
|
||||
if !hits_empty {
|
||||
return None;
|
||||
}
|
||||
let trimmed = query_text.trim();
|
||||
let bytes = trimmed.as_bytes();
|
||||
// Raw single-quote mode: user opted into FTS5 syntax, no advisory.
|
||||
if bytes.len() >= 2 && bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'' {
|
||||
return None;
|
||||
}
|
||||
if trimmed.chars().count() < 3 {
|
||||
Some("3자 이상 키워드 권장 (trigram tokenizer 제약)".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Facade state — see module docs for lifetime rules.
|
||||
///
|
||||
/// The struct is public so long-lived callers (kb-eval, the future P9
|
||||
@@ -557,7 +534,7 @@ impl App {
|
||||
|
||||
// Trace path skips the budget loop. Caller will inspect
|
||||
// `hits.len()` and `trace.timing` rather than paginate.
|
||||
let hint = short_query_hint(&query.text, hits.is_empty());
|
||||
let hint: Option<String> = None;
|
||||
return Ok(SearchResponse {
|
||||
hits,
|
||||
next_cursor: None,
|
||||
@@ -641,7 +618,7 @@ impl App {
|
||||
None
|
||||
};
|
||||
|
||||
let hint = short_query_hint(&query.text, hits.is_empty());
|
||||
let hint: Option<String> = None;
|
||||
Ok(SearchResponse {
|
||||
hits,
|
||||
next_cursor,
|
||||
|
||||
@@ -72,7 +72,7 @@ pub mod reset;
|
||||
pub mod schema;
|
||||
mod staleness;
|
||||
|
||||
pub use app::{App, SearchResponse, short_query_hint};
|
||||
pub use app::{App, SearchResponse};
|
||||
#[doc(hidden)]
|
||||
pub use bulk::{BULK_QUERIES_MAX, bulk_search_with_config};
|
||||
pub use error_wire::{ERROR_V1_ID, ErrorV1, StructuredError, classify};
|
||||
|
||||
@@ -212,32 +212,10 @@ fn search_plain_emits_truncated_hint_to_stderr() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_plain_emits_short_query_hint_to_stderr() {
|
||||
// v0.17.0 A5 Step 6: 2-char query under trigram tokenizer emits
|
||||
// empty hits + stderr `[hint]` advisory. Empty workspace is enough
|
||||
// — hits are always empty so the hint condition depends only on
|
||||
// query length (<3 chars trimmed) + non-raw mode + hits.is_empty.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (cfg, workspace, _data) = common::write_config(dir.path(), 30);
|
||||
common::ingest(&cfg, &workspace);
|
||||
|
||||
let (_stdout, stderr) = common::run_search_with_args(&cfg, &["--mode", "lexical", "ab"]);
|
||||
assert!(
|
||||
stderr.contains("[hint]"),
|
||||
"stderr must carry short-query hint: {stderr:?}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("3자 이상"),
|
||||
"hint message must mention '3자 이상' (Korean advisory): {stderr:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_json_emits_hint_field_for_short_query() {
|
||||
// v0.17.0 A5 Step 6: --json mode carries the same advisory on the
|
||||
// `search_response.v1.hint` additive field. Empty hits + 2-char
|
||||
// query + non-raw mode trips the helper. Verifies the MCP-visible
|
||||
// surface (agents read the field instead of parsing stderr).
|
||||
fn search_json_hint_absent_for_short_query_v009() {
|
||||
// V009 unicode61 + 형태소 tokenizer 환경에서는 2-char 한국어 query 도
|
||||
// hit 가능하므로 short_query_hint helper 가 제거됨. hint 는 항상
|
||||
// None — wire schema field 는 유지되나 JSON 에서 omit 됨.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (cfg, workspace, _data) = common::write_config(dir.path(), 30);
|
||||
common::ingest(&cfg, &workspace);
|
||||
@@ -250,12 +228,9 @@ fn search_json_emits_hint_field_for_short_query() {
|
||||
v["hits"].as_array().unwrap().is_empty(),
|
||||
"empty hits expected for short query in empty KB: {v}"
|
||||
);
|
||||
assert_eq!(
|
||||
v["hint"]
|
||||
.as_str()
|
||||
.expect("hint field set on short empty result"),
|
||||
"3자 이상 키워드 권장 (trigram tokenizer 제약)",
|
||||
"hint must carry the standard advisory: {v}"
|
||||
assert!(
|
||||
v.get("hint").is_none(),
|
||||
"hint must be absent (always None post-V009): {v}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -202,6 +202,9 @@ impl Retriever for LexicalRetriever {
|
||||
/// - Finally wrap the combined expression in `text : (<expr>)` so the
|
||||
/// match is scoped to the body column. FTS5's column-filter syntax
|
||||
/// accepts an arbitrary OR/AND sub-expression inside the parens.
|
||||
///
|
||||
/// V009 unicode61 + 형태소 tokenizer 환경에서는 multi-token Korean
|
||||
/// query 의 OR-combine 분기는 redundant 하나 보존 (future 확장성).
|
||||
fn build_match_string(text: &str) -> Option<String> {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
||||
@@ -153,12 +153,6 @@ pub struct SearchState {
|
||||
/// `Ctrl-L`); the previous draft kept one for "symmetry" but
|
||||
/// it was dead code.
|
||||
pub worker_rx: Option<std::sync::mpsc::Receiver<SearchWorkerMessage>>,
|
||||
/// v0.17.0 A5 Step 5: advisory text shown when the last completed
|
||||
/// search returned no hits and the (trimmed) query is shorter than
|
||||
/// the FTS5 trigram tokenizer's 3-char minimum. `None` whenever
|
||||
/// the input changes (so a stale hint never overlaps a fresh
|
||||
/// typing session) or the next search returns ≥1 hit.
|
||||
pub short_query_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// p9-fb-08: payload posted by the search worker on completion.
|
||||
@@ -185,7 +179,6 @@ impl Default for SearchState {
|
||||
preview: None,
|
||||
generation: 0,
|
||||
worker_rx: None,
|
||||
short_query_hint: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,20 +382,6 @@ fn dynamic_status(app: &App) -> String {
|
||||
if app.search.as_ref().is_some_and(|s| s.searching) {
|
||||
return "searching…".to_string();
|
||||
}
|
||||
// v0.17.0 A5 Step 5: short-query advisory has higher priority than
|
||||
// the idle slot but lower than active operations (streaming /
|
||||
// searching / ingest progress) — the user should always see what
|
||||
// is happening *now* before reading guidance about the last
|
||||
// empty result. Slot only fires while focused on Search.
|
||||
if app.focus == Pane::Search {
|
||||
if let Some(hint) = app
|
||||
.search
|
||||
.as_ref()
|
||||
.and_then(|s| s.short_query_hint.as_deref())
|
||||
{
|
||||
return hint.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(state) = app.ingest_state.as_ref() {
|
||||
return crate::ingest_progress::status_line(state);
|
||||
}
|
||||
|
||||
@@ -440,7 +440,6 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome {
|
||||
/// with a fresh typing session.
|
||||
fn mark_input_changed(s: &mut crate::app::SearchState) {
|
||||
s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
|
||||
s.short_query_hint = None;
|
||||
}
|
||||
|
||||
fn cycle_mode(m: SearchMode) -> SearchMode {
|
||||
@@ -612,11 +611,6 @@ pub(crate) fn fire_search(state: &mut App) -> anyhow::Result<()> {
|
||||
s.generation = s.generation.wrapping_add(1);
|
||||
s.searching = true;
|
||||
s.input_dirty_at = None;
|
||||
// v0.17.0 A5 Step 5: hint belongs to the *prior* result set —
|
||||
// a fresh worker spawn invalidates it so the status bar
|
||||
// doesn't keep showing the old advisory while the new
|
||||
// query is in flight.
|
||||
s.short_query_hint = None;
|
||||
let q_text = s.input.as_str().to_string();
|
||||
s.last_query = Some((q_text.clone(), s.mode));
|
||||
(q_text, s.mode, s.generation)
|
||||
@@ -699,8 +693,6 @@ pub fn poll_worker(state: &mut App) {
|
||||
// the user submitted for *this* result set. If
|
||||
// input has drifted since spawn, the gen-check
|
||||
// already returned early.
|
||||
let q_text = s.last_query.as_ref().map_or("", |(t, _)| t.as_str());
|
||||
s.short_query_hint = kebab_app::short_query_hint(q_text, hits.is_empty());
|
||||
s.hits = hits;
|
||||
s.selected_hit = 0;
|
||||
s.preview = None;
|
||||
@@ -708,7 +700,6 @@ pub fn poll_worker(state: &mut App) {
|
||||
Err(e) => {
|
||||
s.hits.clear();
|
||||
s.selected_hit = 0;
|
||||
s.short_query_hint = None;
|
||||
state.error_overlay = Some(crate::error_popup::ErrorOverlay::from_anyhow(&e));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user