feat: /history page (recognition log) + multi wake-word loading
# Recognition history page
New /history route in the Tauri GUI. Every voice/text phrase that
reaches the dispatcher gets a row with: timestamp, the phrase, what
happened, command id + confidence (if matched), which matcher fired
(intent / fuzzy / LLM-router / LLM-fallback).
Color coding:
- Green: command matched AND executed successfully
- Red: command matched but failed, OR dispatcher error
- Blue: LLM fallback handled it (Jarvis spoke a free-form reply)
- Orange: no match at all ("Не понял")
Live updates: GUI polls every 2s from the on-disk log, so the page
shows daemon writes in near real time even though they're separate
processes. Filter box for searching by phrase or command id.
Architecture:
- New core module `recognition_log` with ring buffer (cap 500) +
atomic JSON write-through to `<APP_CONFIG_DIR>/recognition_log.json`.
- `record(phrase, source, outcome)` is the single call-site from the
daemon's `execute_command()` — hooked into the 4 outcome paths
(matched-ok, matched-fail, not-found, llm-handled, error).
- `recent(limit)` reads the in-memory buffer (daemon's view).
- `recent_from_disk(limit)` re-reads the JSON file — GUI uses this
since the GUI process has its own buffer that doesn't see the
daemon's writes.
- 5 new unit tests covering ring buffer trimming, outcome serde
roundtrip, missing/corrupt/oversized file recovery.
GUI:
- `crates/jarvis-gui/src/tauri_commands/history.rs`: history_recent,
history_clear. Flattens the Outcome enum into a single struct that's
easier for the Svelte template to render.
- `frontend/src/routes/history/index.svelte`: ~270 lines. Stats badges
(✓ N matched / ✗ N misses / total), filter input, virtual list of
entry cards with color-coded left border. Polls every 2s.
- Header gets a new "История" / "History" button (between Plugins and
Settings). Russian + English locale entries added.
# Multi wake-word loading
Was: `init()` loaded the bundled `jarvis-default.rpw` + at most ONE
custom (from `settings.custom_wake_word`). User had to pick a single
trained model.
Now: loads the bundled default PLUS every .rpw in
`APP_CONFIG_DIR/wake_words/` simultaneously. Rustpotter natively
supports multiple wake-word triggers — each adds robustness for
different voice profiles. The legacy `custom_wake_word` field is
checked for back-compat but is a no-op if it points inside the
already-loaded directory.
User-facing impact: train the wake-word once via /wake-trainer →
restart daemon → detection improves automatically without picking a
single "active" model.
# Settings → "Обучить wake-word" button
Added a purple button on the settings page that links to
/wake-trainer. The trainer existed but had no in-GUI link, so users
couldn't find it without typing the URL. Now sits next to the
"Конструктор команд (Python)" button.
Tests: 140 → 145 rust core tests (+5 recognition_log). Frontend
rebuilds in 6.2s. Release builds of jarvis-app + jarvis-gui green.
Practical test: GUI launches (MainWindowTitle confirmed), seed log
file written + visible to the page.
This commit is contained in:
parent
965441d4db
commit
73fc404ec7
13 changed files with 867 additions and 63 deletions
|
|
@ -425,23 +425,33 @@ fn process_text_command(text: &str, rt: &tokio::runtime::Runtime) {
|
|||
|
||||
// Execute command, returns true if chaining should continue
|
||||
fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
|
||||
use jarvis_core::recognition_log::{record as log_record, Outcome};
|
||||
|
||||
let commands_list = match COMMANDS_LIST.get() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
log_record(text, "voice", Outcome::Error { message: "Commands not loaded".into() });
|
||||
ipc::send(IpcEvent::Error { message: "Commands not loaded".to_string() });
|
||||
ipc::send(IpcEvent::Idle);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let cmd_result = if let Some((intent_id, confidence)) =
|
||||
rt.block_on(intent::classify(text))
|
||||
|
||||
// Try intent classifier first; remember which path matched so the log
|
||||
// can show "via intent" vs "via fuzzy" — useful for understanding why
|
||||
// a particular phrase landed (or didn't).
|
||||
let (cmd_result, via, confidence_pct) = if let Some((intent_id, confidence)) =
|
||||
rt.block_on(intent::classify(text))
|
||||
{
|
||||
info!("Intent recognized: {} (confidence: {:.2})", intent_id, confidence);
|
||||
intent::get_command_by_intent(commands_list, &intent_id)
|
||||
(
|
||||
intent::get_command_by_intent(commands_list, &intent_id),
|
||||
"intent",
|
||||
Some((confidence * 100.0).clamp(0.0, 100.0) as u8),
|
||||
)
|
||||
} else {
|
||||
info!("Intent not recognized, trying levenshtein fallback...");
|
||||
commands::fetch_command(text, commands_list)
|
||||
(commands::fetch_command(text, commands_list), "fuzzy", None)
|
||||
};
|
||||
|
||||
if let Some((cmd_path, cmd_config)) = cmd_result {
|
||||
|
|
@ -468,6 +478,13 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
|
|||
// Skipped for macro-control commands themselves (filter inside record_step).
|
||||
jarvis_core::macros::record_step(text);
|
||||
|
||||
log_record(text, "voice", Outcome::Matched {
|
||||
command_id: cmd_config.id.clone(),
|
||||
confidence_pct,
|
||||
via: via.to_string(),
|
||||
success: true,
|
||||
});
|
||||
|
||||
ipc::send(IpcEvent::CommandExecuted {
|
||||
id: cmd_config.id.clone(),
|
||||
success: true,
|
||||
|
|
@ -478,6 +495,12 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
|
|||
Err(msg) => {
|
||||
error!("Error executing command: {}", msg);
|
||||
voices::play_error();
|
||||
log_record(text, "voice", Outcome::Matched {
|
||||
command_id: cmd_config.id.clone(),
|
||||
confidence_pct,
|
||||
via: via.to_string(),
|
||||
success: false,
|
||||
});
|
||||
ipc::send(IpcEvent::CommandExecuted {
|
||||
id: cmd_config.id.clone(),
|
||||
success: false,
|
||||
|
|
@ -503,6 +526,13 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
|
|||
// Re-dispatch with the canonical phrase for the chosen command.
|
||||
// Guard against infinite recursion: pass a marker if needed.
|
||||
if routed.substitute_phrase != text {
|
||||
log_record(text, "voice", Outcome::Matched {
|
||||
command_id: routed.command_id.clone(),
|
||||
confidence_pct: Some((routed.confidence * 100.0).clamp(0.0, 100.0) as u8),
|
||||
via: "router".to_string(),
|
||||
// Actual success/failure logged by the recursive call.
|
||||
success: true,
|
||||
});
|
||||
return execute_command(&routed.substitute_phrase, rt);
|
||||
}
|
||||
}
|
||||
|
|
@ -514,8 +544,10 @@ fn execute_command(text: &str, rt: &tokio::runtime::Runtime) -> bool {
|
|||
{
|
||||
info!("Auto-routing to LLM (no command match): {}", text);
|
||||
crate::llm_fallback::handle(text);
|
||||
log_record(text, "voice", Outcome::LlmHandled);
|
||||
} else {
|
||||
voices::play_not_found();
|
||||
log_record(text, "voice", Outcome::NotFound);
|
||||
ipc::send(IpcEvent::Error {
|
||||
message: format!("Command not found: {}", text)
|
||||
});
|
||||
|
|
|
|||
|
|
@ -103,6 +103,11 @@ fn main() -> Result<(), String> {
|
|||
// the user can flip the env var without restarting the daemon.
|
||||
jarvis_core::idle_banter::start_background();
|
||||
|
||||
eprintln!("[jarvis-app] step: recognition_log::init");
|
||||
if let Err(e) = jarvis_core::recognition_log::init() {
|
||||
warn!("Recognition log init failed: {}", e);
|
||||
}
|
||||
|
||||
eprintln!("[jarvis-app] step: recorder::init");
|
||||
if recorder::init().is_err() {
|
||||
notify_mic_problem();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue