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
114 changes: 112 additions & 2 deletions crates/b2-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
use b2_core::embed::Embedder;
use b2_core::llm::{ChatTurn, FakeLlm, LlmProvider};
use b2_core::resource::{doc_kind, DocKind};
use b2_core::vault::{AnswerView, SearchEvidenceView, Vault};
use b2_core::vault::{AnswerView, SearchEvidenceView, SimilarStanding, Vault};
use b2_embed::{provision, EmbedConfig, EmbedError, LocalEmbedder};
use b2_llm::{
is_ollama, refusal_message, LlmConfig, LlmError, OpenAiCompatProvider, OLLAMA_INSTALL_URL,
Expand Down Expand Up @@ -205,6 +205,11 @@ enum Command {
/// Maximum number of similar notes to return.
#[arg(long, default_value_t = 10)]
limit: usize,
/// Explain one note against NOTE's list instead of printing the list: where it
/// stands (and why, if it is not a card), and the passage pairs behind it. No
/// model call. Read at `--limit`, so the rank is the one the list shows.
#[arg(long, value_name = "OTHER")]
explain: Option<String>,
},
/// Commit a typed connection SRC → DST into SRC's frontmatter `b2_relations:`.
/// SRC and DST are each a vault-relative path.
Expand Down Expand Up @@ -399,7 +404,16 @@ fn dispatch(cli: &Cli) -> Result<(), CliError> {
limit,
exclude,
} => cmd_search(cli, query, *limit, exclude),
Command::Similar { note, limit } => cmd_similar(cli, note, *limit),
Command::Similar {
note,
limit,
explain: Some(other),
} => cmd_explain_similar(cli, note, other, *limit),
Command::Similar {
note,
limit,
explain: None,
} => cmd_similar(cli, note, *limit),
Command::Link {
src,
dst,
Expand Down Expand Up @@ -1003,6 +1017,102 @@ fn cmd_similar(cli: &Cli, note: &str, limit: usize) -> Result<(), CliError> {
Ok(())
}

/// Longest passage excerpt `similar --explain` prints per side, so a pair stays readable
/// in a terminal. `--json` carries the full text.
const EXPLAIN_EXCERPT_CHARS: usize = 200;

fn cmd_explain_similar(cli: &Cli, note: &str, other: &str, limit: usize) -> Result<(), CliError> {
// A pure read over stored vectors, like `similar`: the fake is enough to open with.
let vault = open_vault(cli.vault_or_cwd(), false)?;
let ex = vault.explain_similar(note, other, limit)?;
if cli.json {
return print_json(&ex);
}
let anchor = display_name(ex.anchor.title.as_deref(), &ex.anchor.path);
let candidate = display_name(ex.candidate.title.as_deref(), &ex.candidate.path);
println!(
"{candidate} ({}) against {anchor} ({})",
ex.candidate.path, ex.anchor.path
);
let z = ex.z.map(|z| format!(", z {z:.2}")).unwrap_or_default();
let standing = match ex.standing {
SimilarStanding::SameNote => "That is the same note.".to_string(),
SimilarStanding::AnchorUnembedded => format!(
"{anchor} has no stored vectors yet, so there is nothing to compare from. Run `b2 reindex`."
),
SimilarStanding::Linked => {
"Already linked, so Similar leaves it out: it shows what you haven't connected."
.to_string()
}
SimilarStanding::Unembedded => {
"Not embedded yet, so it can't be compared. Run `b2 reindex`.".to_string()
}
SimilarStanding::NotShortlisted { shortlist } => format!(
"Never scored: judged as a whole note it ranks #{}, past the first-pass shortlist of {shortlist}.",
ex.centroid_rank.unwrap_or(0)
),
SimilarStanding::Ranked {
rank,
of,
served: true,
} => format!("Shown at #{rank} of {limit} ({of} notes scored{z})."),
SimilarStanding::Ranked {
rank,
of,
served: false,
} => format!("Ranked #{rank} of {of} scored{z}: past the {limit} shown."),
};
println!("{standing}");
if let (Some(c), SimilarStanding::Ranked { rank, .. }) = (ex.centroid_rank, ex.standing) {
println!("Judged as a whole note it ranks #{c}; by its best passage, #{rank}.");
}
if !ex.shared_neighbors.is_empty() {
let names: Vec<String> = ex
.shared_neighbors
.iter()
.map(|n| format!("{} ({})", display_name(n.title.as_deref(), &n.path), n.path))
.collect();
println!("Both link with: {}", names.join(", "));
}
if !ex.pairs.is_empty() {
println!("\nPassage pairs, nearest first:");
// Pairs carry a grade only when the list does; say why they don't, once, rather
// than print bare pairs that read as unmeasured by accident.
if ex.pairs.iter().all(|p| p.z.is_none()) {
println!(
" Ungraded: too few notes to compare against, or a vault indexed without the real model."
);
}
}
for (i, p) in ex.pairs.iter().enumerate() {
let grade = p.z.map(|z| format!(" z {z:.2}")).unwrap_or_default();
let same = if p.identical { " identical text" } else { "" };
println!("{:>3}.{grade}{same}", i + 1);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for (side, passage) in [("this", &p.anchor), ("that", &p.candidate)] {
let heading = passage
.heading_path
.as_deref()
.map(|h| format!("[{h}] "))
.unwrap_or_default();
println!(
" {side}: {heading}{}",
excerpt(&passage.text, EXPLAIN_EXCERPT_CHARS)
);
}
}
Ok(())
}

/// A passage flattened to one line and cut at `max` characters.
fn excerpt(text: &str, max: usize) -> String {
let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= max {
flat
} else {
format!("{}…", flat.chars().take(max).collect::<String>().trim_end())
}
}

fn cmd_link(
cli: &Cli,
src: &str,
Expand Down
81 changes: 81 additions & 0 deletions crates/b2-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,87 @@ fn similar_limit_zero_prints_nothing_rather_than_a_claim() {
);
}

/// `b2 similar NOTE --explain OTHER` (GH #236): the model-free explanation of one
/// card, read from the same computation as the list. JSON names both notes, the
/// standing (tagged by `kind`), and the passage pairs; the card it explains stands at
/// the rank the list showed it.
#[test]
fn similar_explain_describes_the_served_row() {
let (_g, root) = discovery_vault();
let list = similar(&root, "alpha.md");
let second = list[1]["path"].as_str().unwrap().to_string();

let out = run_in(
&root,
&["--json", "similar", "alpha.md", "--explain", &second],
);
assert!(out.status.success(), "{}", stderr(&out));
let ex: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(ex["anchor"]["path"], "alpha.md");
assert_eq!(ex["candidate"]["path"], second.as_str());
assert_eq!(ex["standing"]["kind"], "ranked");
assert_eq!(ex["standing"]["rank"], 2, "the rank the card was shown at");
assert_eq!(ex["standing"]["served"], true);
assert_eq!(ex["limit"], 10);
let pairs = ex["pairs"].as_array().unwrap();
assert!(!pairs.is_empty(), "an embedded pair has passages");
assert!(pairs[0]["anchor"]["text"]
.as_str()
.unwrap()
.contains("alpha"));
assert!(
(pairs[0]["score"].as_f64().unwrap() - list[1]["score"].as_f64().unwrap()).abs() < 1e-9
);

// Human: the standing in words, then the pairs.
let human = run_in(&root, &["similar", "alpha.md", "--explain", &second]);
assert!(human.status.success(), "{}", stderr(&human));
let text = stdout(&human);
assert!(text.contains("#2"), "says where it stands: {text}");
assert!(text.contains("alpha"), "shows this note's passage: {text}");
// Five notes under the fake embedder: nothing to grade against, and the output says
// so rather than printing pairs with no grade and no reason.
assert!(
text.contains("Ungraded"),
"an ungraded explanation says so: {text}"
);
}

#[test]
fn similar_explain_says_why_a_linked_note_is_not_a_card() {
let (_g, root) = discovery_vault();
let out = run_in(
&root,
&["link", "alpha.md", "beta.md", "--type", "supports"],
);
assert!(out.status.success(), "{}", stderr(&out));

let out = run_in(
&root,
&["--json", "similar", "alpha.md", "--explain", "beta.md"],
);
assert!(out.status.success(), "{}", stderr(&out));
let ex: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(ex["standing"]["kind"], "linked");

let human = run_in(&root, &["similar", "alpha.md", "--explain", "beta.md"]);
assert!(
stdout(&human).to_lowercase().contains("already linked"),
"{}",
stdout(&human)
);
}

#[test]
fn similar_explain_unknown_note_fails_cleanly() {
let (_g, root) = discovery_vault();
let out = run_in(&root, &["similar", "alpha.md", "--explain", "nope.md"]);
assert!(!out.status.success(), "unknown note must be a nonzero exit");
let err = stderr(&out).to_lowercase();
assert!(err.contains("not found"), "actionable message: {err}");
assert!(!err.contains("panicked"), "no stack trace: {err}");
}

#[test]
fn link_writes_frontmatter_and_shows_in_both_directions() {
let (_g, root) = discovery_vault();
Expand Down
Loading