Phase 2 of the rebrand cascade. Renames all 9 workspace crates from magnotia-* to lumotia-* plus the src-tauri binary crate name: - magnotia-ai-formatting -> lumotia-ai-formatting - magnotia-audio -> lumotia-audio - magnotia-cloud-providers -> lumotia-cloud-providers - magnotia-core -> lumotia-core - magnotia-hotkey -> lumotia-hotkey - magnotia-llm -> lumotia-llm - magnotia-mcp -> lumotia-mcp - magnotia-storage -> lumotia-storage - magnotia-transcription -> lumotia-transcription - magnotia -> lumotia (src-tauri binary) - magnotia_lib -> lumotia_lib (src-tauri lib target) Crate directories (crates/audio/ etc.) stay as-is; only the Cargo.toml [package] name field changes plus all consumer module imports (magnotia_core -> lumotia_core, etc.). Remaining magnotia_* references at this point are intentional and scoped to later phases: tracing targets (Phase 4), DB setting keys magnotia_preferences/magnotia_history (Phase 5). cargo build --workspace passes. cargo test --workspace: 330 pass, 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
410 lines
13 KiB
Rust
410 lines
13 KiB
Rust
// Tauri commands wrapping lumotia_storage task CRUD.
|
|
// Pattern mirrors transcripts.rs — TaskDto is the camelCase frontend shape,
|
|
// storage functions are aliased with db_ prefix to avoid name collisions.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use uuid::Uuid;
|
|
|
|
use lumotia_llm::prompts::FeedbackExample as LlmFeedbackExample;
|
|
use lumotia_storage::{
|
|
complete_subtask_and_check_parent as db_complete_subtask, complete_task as db_complete_task,
|
|
delete_task as db_delete_task, get_task_by_id as db_get_task,
|
|
insert_subtask as db_insert_subtask, insert_task as db_insert_task,
|
|
list_feedback_examples as db_list_feedback_examples,
|
|
list_recent_completions as db_list_recent_completions, list_subtasks as db_list_subtasks,
|
|
list_tasks as db_list_tasks, set_task_energy as db_set_task_energy,
|
|
uncomplete_task as db_uncomplete_task, update_task as db_update_task, DailyCompletionCount,
|
|
FeedbackRow, FeedbackTargetType, TaskRow,
|
|
};
|
|
|
|
use crate::commands::power::PowerAssertion;
|
|
use crate::AppState;
|
|
|
|
/// Frontend-facing task shape. Matches the in-memory object in page.svelte.js.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct TaskDto {
|
|
pub id: String,
|
|
pub text: String,
|
|
pub bucket: String,
|
|
pub list_id: Option<String>,
|
|
pub effort: Option<String>,
|
|
pub notes: String,
|
|
pub done: bool,
|
|
pub done_at: Option<String>,
|
|
pub created_at: String,
|
|
pub source_transcript_id: Option<String>,
|
|
pub parent_task_id: Option<String>,
|
|
pub energy: Option<String>,
|
|
}
|
|
|
|
impl From<TaskRow> for TaskDto {
|
|
fn from(r: TaskRow) -> Self {
|
|
Self {
|
|
id: r.id,
|
|
text: r.text,
|
|
bucket: r.bucket,
|
|
list_id: r.list_id,
|
|
effort: r.effort,
|
|
notes: r.notes,
|
|
done: r.done,
|
|
done_at: r.done_at,
|
|
created_at: r.created_at,
|
|
source_transcript_id: r.source_transcript_id,
|
|
parent_task_id: r.parent_task_id,
|
|
energy: r.energy,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Accepted energy tag values. Kept as a const so frontend and storage
|
|
/// validate against the same list. Migration v11 enforces the same
|
|
/// set via a CHECK constraint.
|
|
const ENERGY_LEVELS: &[&str] = &["high", "medium", "brain_dead"];
|
|
|
|
fn validate_energy(raw: Option<&str>) -> Result<Option<&str>, String> {
|
|
match raw {
|
|
None => Ok(None),
|
|
Some(s) if ENERGY_LEVELS.contains(&s) => Ok(Some(s)),
|
|
Some(other) => Err(format!(
|
|
"energy must be one of {:?} or null, got {:?}",
|
|
ENERGY_LEVELS, other
|
|
)),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CreateTaskRequest {
|
|
pub id: String,
|
|
pub text: String,
|
|
pub bucket: String,
|
|
#[serde(default)]
|
|
pub source_transcript_id: Option<String>,
|
|
#[serde(default)]
|
|
pub list_id: Option<String>,
|
|
#[serde(default)]
|
|
pub effort: Option<String>,
|
|
#[serde(default)]
|
|
pub energy: Option<String>,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn create_task_cmd(
|
|
state: tauri::State<'_, AppState>,
|
|
request: CreateTaskRequest,
|
|
) -> Result<TaskDto, String> {
|
|
let energy = validate_energy(request.energy.as_deref())?;
|
|
db_insert_task(
|
|
&state.db,
|
|
&request.id,
|
|
&request.text,
|
|
&request.bucket,
|
|
request.source_transcript_id.as_deref(),
|
|
request.list_id.as_deref(),
|
|
request.effort.as_deref(),
|
|
energy,
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
// Fetch the freshly-inserted row so the frontend can stop doing
|
|
// client-side object construction. Mirrors list_tasks_cmd's shape.
|
|
db_get_task(&state.db, &request.id)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map(TaskDto::from)
|
|
.ok_or_else(|| format!("Task {} not found after insert", request.id))
|
|
}
|
|
|
|
/// Patch-shaped update. Any field omitted (or explicit `null`) leaves the
|
|
/// column untouched via `COALESCE` in the storage layer. Matches
|
|
/// `update_transcript`'s partial-update philosophy. `done` / `doneAt` are
|
|
/// intentionally absent — those flow through `complete_task_cmd` /
|
|
/// `uncomplete_task_cmd` to stamp the server-side timestamp.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct UpdateTaskRequest {
|
|
#[serde(default)]
|
|
pub text: Option<String>,
|
|
#[serde(default)]
|
|
pub bucket: Option<String>,
|
|
#[serde(default)]
|
|
pub list_id: Option<String>,
|
|
#[serde(default)]
|
|
pub effort: Option<String>,
|
|
#[serde(default)]
|
|
pub notes: Option<String>,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn update_task_cmd(
|
|
state: tauri::State<'_, AppState>,
|
|
id: String,
|
|
patch: UpdateTaskRequest,
|
|
) -> Result<TaskDto, String> {
|
|
let row = db_update_task(
|
|
&state.db,
|
|
&id,
|
|
patch.text.as_deref(),
|
|
patch.bucket.as_deref(),
|
|
patch.list_id.as_deref(),
|
|
patch.effort.as_deref(),
|
|
patch.notes.as_deref(),
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(TaskDto::from(row))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_tasks_cmd(state: tauri::State<'_, AppState>) -> Result<Vec<TaskDto>, String> {
|
|
db_list_tasks(&state.db)
|
|
.await
|
|
.map(|rows| rows.into_iter().map(TaskDto::from).collect())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn complete_task_cmd(
|
|
state: tauri::State<'_, AppState>,
|
|
id: String,
|
|
) -> Result<(), String> {
|
|
db_complete_task(&state.db, &id)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn delete_task_cmd(state: tauri::State<'_, AppState>, id: String) -> Result<(), String> {
|
|
db_delete_task(&state.db, &id)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn uncomplete_task_cmd(
|
|
state: tauri::State<'_, AppState>,
|
|
id: String,
|
|
) -> Result<(), String> {
|
|
db_uncomplete_task(&state.db, &id)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Phase 3: set or clear the `energy` tag on a task. Dedicated command
|
|
/// rather than a field on `update_task_cmd` because the existing update
|
|
/// path uses `COALESCE` semantics where `None` means "preserve" — which
|
|
/// makes clearing the tag impossible. This command always writes exactly
|
|
/// what you send, including `None` to explicitly clear.
|
|
#[tauri::command]
|
|
pub async fn set_task_energy_cmd(
|
|
state: tauri::State<'_, AppState>,
|
|
id: String,
|
|
energy: Option<String>,
|
|
) -> Result<TaskDto, String> {
|
|
let validated = validate_energy(energy.as_deref())?;
|
|
let row = db_set_task_energy(&state.db, &id, validated)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(TaskDto::from(row))
|
|
}
|
|
|
|
/// Convert HITL feedback rows fetched from storage into the few-shot
|
|
/// exemplar shape the LLM crate consumes. We reconstruct the `input`
|
|
/// (parent task text, transcript chunk) from `context_json` where the
|
|
/// recorder has stored it. Rows without usable input are dropped —
|
|
/// the prompt builder filters them too, but doing it here keeps the
|
|
/// exemplar list tight and the prompt budget predictable.
|
|
///
|
|
/// Malformed `context_json` is logged rather than silently dropped so
|
|
/// data-integrity regressions surface instead of disappearing.
|
|
fn to_llm_examples(rows: Vec<FeedbackRow>) -> Vec<LlmFeedbackExample> {
|
|
rows.into_iter()
|
|
.filter_map(|r| {
|
|
let raw = r.context_json.as_deref().unwrap_or("{}");
|
|
let ctx: serde_json::Value = match serde_json::from_str(raw) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: "lumotia_lib::feedback",
|
|
row_id = r.id,
|
|
error = %e,
|
|
"skipping feedback row with malformed context_json"
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
let input = ctx
|
|
.get("input")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string)
|
|
.unwrap_or_default();
|
|
if input.trim().is_empty() {
|
|
return None;
|
|
}
|
|
Some(LlmFeedbackExample {
|
|
input,
|
|
original_output: r.original_text,
|
|
corrected_output: r.corrected_text,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Rough character budget for the few-shot block. Qwen's tokenizer
|
|
/// averages ~3.5 chars per token in English, so 2000 chars is ~570
|
|
/// tokens — well inside the 64-token reserve + response-token gap
|
|
/// against the 8192-token context cap (see `LlmEngine::generate`).
|
|
///
|
|
/// Exceed this and we drop the oldest examples first. Rationale: the
|
|
/// retrieval already orders most-recent-first, and the most recent
|
|
/// correction is usually the one carrying the user's live preference.
|
|
const FEW_SHOT_CHAR_BUDGET: usize = 2000;
|
|
|
|
fn example_char_cost(ex: &LlmFeedbackExample) -> usize {
|
|
// Matches the render path in `prompts::render_feedback_exemplar`:
|
|
// "Input: {input}\nGood output: {good}". Prefix strings + newlines
|
|
// + the two bodies. Slight overestimate to leave headroom.
|
|
let good_len = ex
|
|
.corrected_output
|
|
.as_deref()
|
|
.or(ex.original_output.as_deref())
|
|
.map(str::len)
|
|
.unwrap_or(0);
|
|
ex.input.len() + good_len + 24
|
|
}
|
|
|
|
fn trim_to_budget(mut examples: Vec<LlmFeedbackExample>) -> Vec<LlmFeedbackExample> {
|
|
let mut running = 0usize;
|
|
let mut kept = Vec::with_capacity(examples.len());
|
|
for ex in examples.drain(..) {
|
|
let cost = example_char_cost(&ex);
|
|
if running + cost > FEW_SHOT_CHAR_BUDGET {
|
|
break;
|
|
}
|
|
running += cost;
|
|
kept.push(ex);
|
|
}
|
|
kept
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn decompose_and_store(
|
|
state: tauri::State<'_, AppState>,
|
|
parent_task_id: String,
|
|
profile_id: Option<String>,
|
|
) -> Result<Vec<TaskDto>, String> {
|
|
let parent = db_get_task(&state.db, &parent_task_id)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("Task {parent_task_id} not found"))?;
|
|
|
|
// Pull recent micro-step feedback so the system prompt gets
|
|
// conditioned on the user's preferred decomposition style. We
|
|
// cap at 5 examples AND at a char budget to keep the prompt
|
|
// under token budget regardless of how much feedback has been
|
|
// captured, and scope by profile so per-profile styles do not
|
|
// leak into each other.
|
|
let examples = db_list_feedback_examples(
|
|
&state.db,
|
|
FeedbackTargetType::MicroStep,
|
|
5,
|
|
0,
|
|
profile_id.as_deref(),
|
|
)
|
|
.await
|
|
.map(to_llm_examples)
|
|
.map(trim_to_budget)
|
|
.unwrap_or_default();
|
|
|
|
let engine = state.llm_engine.clone();
|
|
let parent_text = parent.text.clone();
|
|
let steps = tokio::task::spawn_blocking(move || {
|
|
let _power_guard = PowerAssertion::begin("magnotia LLM micro-step decomposition");
|
|
engine.decompose_task_with_feedback(&parent_text, &examples)
|
|
})
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let mut created = Vec::new();
|
|
for text in steps {
|
|
let id = Uuid::new_v4().to_string();
|
|
db_insert_subtask(&state.db, &id, &text, &parent_task_id)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if let Some(row) = db_get_task(&state.db, &id)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
{
|
|
created.push(TaskDto::from(row));
|
|
}
|
|
}
|
|
|
|
Ok(created)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn extract_tasks_from_transcript_cmd(
|
|
state: tauri::State<'_, AppState>,
|
|
transcript: String,
|
|
profile_id: Option<String>,
|
|
) -> Result<Vec<String>, String> {
|
|
let examples = db_list_feedback_examples(
|
|
&state.db,
|
|
FeedbackTargetType::TaskExtraction,
|
|
5,
|
|
0,
|
|
profile_id.as_deref(),
|
|
)
|
|
.await
|
|
.map(to_llm_examples)
|
|
.map(trim_to_budget)
|
|
.unwrap_or_default();
|
|
|
|
let engine = state.llm_engine.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
let _power_guard = PowerAssertion::begin("magnotia LLM task extraction");
|
|
engine.extract_tasks_with_feedback(&transcript, &examples)
|
|
})
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_subtasks_cmd(
|
|
state: tauri::State<'_, AppState>,
|
|
parent_task_id: String,
|
|
) -> Result<Vec<TaskDto>, String> {
|
|
db_list_subtasks(&state.db, &parent_task_id)
|
|
.await
|
|
.map(|rows| rows.into_iter().map(TaskDto::from).collect())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn complete_subtask_cmd(
|
|
state: tauri::State<'_, AppState>,
|
|
subtask_id: String,
|
|
) -> Result<(), String> {
|
|
db_complete_subtask(&state.db, &subtask_id)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Phase 8: daily completion counts for the Tasks-page badge and the
|
|
/// 7-day momentum sparkline. Returns a fixed-length oldest-first
|
|
/// series. Empty days are explicit zeros.
|
|
#[tauri::command]
|
|
pub async fn list_recent_completions_cmd(
|
|
state: tauri::State<'_, AppState>,
|
|
days: u32,
|
|
) -> Result<Vec<DailyCompletionCount>, String> {
|
|
db_list_recent_completions(&state.db, days)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|