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:
2026-05-28 11:13:45 +00:00
parent b63af20b72
commit 923b959610
7 changed files with 13 additions and 88 deletions

View File

@@ -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,

View File

@@ -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};

View File

@@ -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}"
);
}

View File

@@ -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() {

View File

@@ -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,
}
}
}

View File

@@ -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);
}

View File

@@ -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));
}
}