diff --git a/MosaicIQ/src-tauri/src/agent/settings.rs b/MosaicIQ/src-tauri/src/agent/settings.rs index 4b2e93d..501be61 100644 --- a/MosaicIQ/src-tauri/src/agent/settings.rs +++ b/MosaicIQ/src-tauri/src/agent/settings.rs @@ -22,11 +22,19 @@ const LOCAL_BASE_URL_KEY: &str = "localBaseUrl"; const LOCAL_AVAILABLE_MODELS_KEY: &str = "localAvailableModels"; /// Manages the provider settings and plaintext API key stored through the Tauri store plugin. -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct AgentSettingsService { app_handle: AppHandle, } +impl Clone for AgentSettingsService { + fn clone(&self) -> Self { + Self { + app_handle: self.app_handle.clone(), + } + } +} + impl AgentSettingsService { pub fn new(app_handle: &AppHandle) -> Self { Self { diff --git a/MosaicIQ/src-tauri/src/agent/types.rs b/MosaicIQ/src-tauri/src/agent/types.rs index 28c84fe..9acbb36 100644 --- a/MosaicIQ/src-tauri/src/agent/types.rs +++ b/MosaicIQ/src-tauri/src/agent/types.rs @@ -19,6 +19,10 @@ pub enum TaskProfile { Analysis, Summarization, ToolUse, + NoteEnrichment, + RelationshipInference, + GhostSynthesis, + MemoStructuring, } /// Request payload for an interactive chat turn. @@ -207,12 +211,16 @@ pub fn default_task_defaults(default_remote_model: &str) -> HashMap [TaskProfile; 4] { + pub const fn all() -> [TaskProfile; 8] { [ TaskProfile::InteractiveChat, TaskProfile::Analysis, TaskProfile::Summarization, TaskProfile::ToolUse, + TaskProfile::NoteEnrichment, + TaskProfile::RelationshipInference, + TaskProfile::GhostSynthesis, + TaskProfile::MemoStructuring, ] } } diff --git a/MosaicIQ/src-tauri/src/commands/mod.rs b/MosaicIQ/src-tauri/src/commands/mod.rs index 04bfda6..b742992 100644 --- a/MosaicIQ/src-tauri/src/commands/mod.rs +++ b/MosaicIQ/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ //! Tauri command handlers. pub mod news; +pub mod research; pub mod settings; pub mod terminal; diff --git a/MosaicIQ/src-tauri/src/commands/news.rs b/MosaicIQ/src-tauri/src/commands/news.rs index 74c650b..68293c9 100644 --- a/MosaicIQ/src-tauri/src/commands/news.rs +++ b/MosaicIQ/src-tauri/src/commands/news.rs @@ -1,8 +1,8 @@ use tauri::{AppHandle, Emitter}; use crate::news::{ - QueryNewsFeedRequest, QueryNewsFeedResponse, RefreshNewsFeedRequest, RefreshNewsFeedResult, - UpdateNewsArticleStateRequest, + NewsSourceConfig, QueryNewsFeedRequest, QueryNewsFeedResponse, RefreshNewsFeedRequest, + RefreshNewsFeedResult, SaveNewsFeedConfigRequest, UpdateNewsArticleStateRequest, }; use crate::state::AppState; @@ -36,6 +36,40 @@ pub async fn refresh_news_feed( Ok(result) } +#[tauri::command] +pub async fn get_news_feed_config( + state: tauri::State<'_, AppState>, +) -> Result, String> { + state + .news_service + .get_feed_config() + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn save_news_feed_config( + state: tauri::State<'_, AppState>, + request: SaveNewsFeedConfigRequest, +) -> Result, String> { + state + .news_service + .save_feed_config(request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn reset_news_feed_config( + state: tauri::State<'_, AppState>, +) -> Result, String> { + state + .news_service + .reset_feed_config() + .await + .map_err(|error| error.to_string()) +} + #[tauri::command] pub async fn update_news_article_state( state: tauri::State<'_, AppState>, diff --git a/MosaicIQ/src-tauri/src/commands/research.rs b/MosaicIQ/src-tauri/src/commands/research.rs new file mode 100644 index 0000000..c860b3a --- /dev/null +++ b/MosaicIQ/src-tauri/src/commands/research.rs @@ -0,0 +1,188 @@ +use crate::research::{ + ArchiveResearchNoteRequest, CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, + ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetWorkspaceProjectionRequest, + GhostNote, ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, + NoteAuditTrail, NoteCaptureResult, NoteLink, PipelineJob, PromoteNoteToThesisRequest, + ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest, + ReviewGhostNoteRequest, UpdateResearchNoteRequest, WorkspaceProjection, +}; +use crate::state::AppState; + +#[tauri::command] +pub async fn create_research_workspace( + state: tauri::State<'_, AppState>, + request: CreateResearchWorkspaceRequest, +) -> Result { + state + .research_service + .create_workspace(request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn list_research_workspaces( + state: tauri::State<'_, AppState>, +) -> Result, String> { + state + .research_service + .list_workspaces() + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn get_research_workspace( + state: tauri::State<'_, AppState>, + workspace_id: String, +) -> Result { + state + .research_service + .get_workspace(&workspace_id) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn capture_research_note( + state: tauri::State<'_, AppState>, + request: CaptureResearchNoteRequest, +) -> Result { + state + .research_service + .capture_note(request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn update_research_note( + state: tauri::State<'_, AppState>, + request: UpdateResearchNoteRequest, +) -> Result { + state + .research_service + .update_note(request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn archive_research_note( + state: tauri::State<'_, AppState>, + request: ArchiveResearchNoteRequest, +) -> Result { + state + .research_service + .archive_note(request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn list_research_notes( + state: tauri::State<'_, AppState>, + request: ListResearchNotesRequest, +) -> Result, String> { + state + .research_service + .list_notes(request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn get_workspace_projection( + state: tauri::State<'_, AppState>, + request: GetWorkspaceProjectionRequest, +) -> Result { + state + .research_service + .get_workspace_projection(request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn list_note_links( + state: tauri::State<'_, AppState>, + request: ListNoteLinksRequest, +) -> Result, String> { + state + .research_service + .list_note_links(request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn list_workspace_ghost_notes( + state: tauri::State<'_, AppState>, + request: ListWorkspaceGhostNotesRequest, +) -> Result, String> { + state + .research_service + .list_workspace_ghost_notes(request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn review_ghost_note( + state: tauri::State<'_, AppState>, + request: ReviewGhostNoteRequest, +) -> Result { + state + .research_service + .review_ghost_note(request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn promote_note_to_thesis( + state: tauri::State<'_, AppState>, + request: PromoteNoteToThesisRequest, +) -> Result { + state + .research_service + .promote_note_to_thesis(request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn retry_research_jobs( + state: tauri::State<'_, AppState>, + request: RetryResearchJobsRequest, +) -> Result, String> { + state + .research_service + .retry_research_jobs(request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn get_note_audit_trail( + state: tauri::State<'_, AppState>, + request: GetNoteAuditTrailRequest, +) -> Result { + state + .research_service + .get_note_audit_trail(request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn export_research_bundle( + state: tauri::State<'_, AppState>, + request: ExportResearchBundleRequest, +) -> Result { + state + .research_service + .export_research_bundle(request) + .await + .map_err(|error| error.to_string()) +} diff --git a/MosaicIQ/src-tauri/src/lib.rs b/MosaicIQ/src-tauri/src/lib.rs index 44417af..4efa73d 100644 --- a/MosaicIQ/src-tauri/src/lib.rs +++ b/MosaicIQ/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod commands; mod error; mod news; mod portfolio; +mod research; mod state; mod terminal; #[cfg(test)] @@ -25,12 +26,14 @@ pub fn run() { let state = state::AppState::new(app.handle()) .map_err(|error| -> Box { Box::new(error) })?; let news_service = state.news_service.clone(); + let research_service = state.research_service.clone(); let app_handle = app.handle().clone(); app.manage(state); news::scheduler::spawn_news_scheduler(news_service, move |result| { let _ = app_handle.emit("news_feed_updated", &result); }); + research::spawn_research_scheduler(research_service); Ok(()) }) .plugin(tauri_plugin_opener::init()) @@ -45,7 +48,25 @@ pub fn run() { commands::settings::clear_remote_api_key, commands::news::query_news_feed, commands::news::refresh_news_feed, - commands::news::update_news_article_state + commands::news::get_news_feed_config, + commands::news::save_news_feed_config, + commands::news::reset_news_feed_config, + commands::news::update_news_article_state, + commands::research::create_research_workspace, + commands::research::list_research_workspaces, + commands::research::get_research_workspace, + commands::research::capture_research_note, + commands::research::update_research_note, + commands::research::archive_research_note, + commands::research::list_research_notes, + commands::research::get_workspace_projection, + commands::research::list_note_links, + commands::research::list_workspace_ghost_notes, + commands::research::review_ghost_note, + commands::research::promote_note_to_thesis, + commands::research::retry_research_jobs, + commands::research::get_note_audit_trail, + commands::research::export_research_bundle ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/MosaicIQ/src-tauri/src/news/config.rs b/MosaicIQ/src-tauri/src/news/config.rs index e67a335..442b485 100644 --- a/MosaicIQ/src-tauri/src/news/config.rs +++ b/MosaicIQ/src-tauri/src/news/config.rs @@ -10,21 +10,57 @@ pub fn load_or_bootstrap_config( default_config_bytes: &[u8], ) -> Result> { if !config_path.exists() { - let Some(parent) = config_path.parent() else { - return Err(NewsError::Config(format!( - "config path has no parent: {}", - config_path.display() - ))); - }; - fs::create_dir_all(parent)?; - fs::write(config_path, default_config_bytes)?; + bootstrap_config(config_path, default_config_bytes)?; } + load_config(config_path) +} + +pub fn load_config(config_path: &Path) -> Result> { let config = serde_json::from_slice::(&fs::read(config_path)?)?; validate_config(&config.feeds)?; Ok(config.feeds) } +pub fn save_config( + config_path: &Path, + feeds: &[NewsSourceConfig], +) -> Result> { + validate_config(feeds)?; + let Some(parent) = config_path.parent() else { + return Err(NewsError::Config(format!( + "config path has no parent: {}", + config_path.display() + ))); + }; + fs::create_dir_all(parent)?; + let file = NewsSourceConfigFile { + feeds: feeds.to_vec(), + }; + fs::write(config_path, serde_json::to_vec_pretty(&file)?)?; + Ok(file.feeds) +} + +pub fn reset_config( + config_path: &Path, + default_config_bytes: &[u8], +) -> Result> { + bootstrap_config(config_path, default_config_bytes)?; + load_config(config_path) +} + +fn bootstrap_config(config_path: &Path, default_config_bytes: &[u8]) -> Result<()> { + let Some(parent) = config_path.parent() else { + return Err(NewsError::Config(format!( + "config path has no parent: {}", + config_path.display() + ))); + }; + fs::create_dir_all(parent)?; + fs::write(config_path, default_config_bytes)?; + Ok(()) +} + fn validate_config(feeds: &[NewsSourceConfig]) -> Result<()> { if feeds.is_empty() { return Err(NewsError::Config( @@ -72,7 +108,8 @@ mod tests { use tempfile::tempdir; - use super::load_or_bootstrap_config; + use super::{load_or_bootstrap_config, reset_config, save_config}; + use crate::news::types::NewsSourceConfig; #[test] fn load_or_bootstrap_config_should_copy_default_file_when_missing() { @@ -88,4 +125,50 @@ mod tests { assert_eq!(feeds.len(), 1); assert!(fs::metadata(config_path).is_ok()); } + + #[test] + fn save_config_should_reject_duplicate_ids() { + let temp_dir = tempdir().unwrap(); + let config_path = temp_dir.path().join("news-feeds.json"); + + let error = save_config( + &config_path, + &[ + NewsSourceConfig { + id: "dup".to_string(), + name: "Feed A".to_string(), + url: "https://example.com/a.xml".to_string(), + refresh_minutes: 15, + }, + NewsSourceConfig { + id: "dup".to_string(), + name: "Feed B".to_string(), + url: "https://example.com/b.xml".to_string(), + refresh_minutes: 30, + }, + ], + ) + .unwrap_err(); + + assert!(error.to_string().contains("duplicate feed id")); + } + + #[test] + fn reset_config_should_restore_default_file_contents() { + let temp_dir = tempdir().unwrap(); + let config_path = temp_dir.path().join("news-feeds.json"); + fs::write( + &config_path, + br#"{"feeds":[{"id":"custom","name":"Custom","url":"https://example.com/custom.xml","refreshMinutes":30}]}"#, + ) + .unwrap(); + + let feeds = reset_config( + &config_path, + br#"{"feeds":[{"id":"fed","name":"Fed","url":"https://example.com/fed.xml","refreshMinutes":15}]}"#, + ) + .unwrap(); + + assert_eq!(feeds[0].id, "fed"); + } } diff --git a/MosaicIQ/src-tauri/src/news/mod.rs b/MosaicIQ/src-tauri/src/news/mod.rs index a5bd539..43f54d3 100644 --- a/MosaicIQ/src-tauri/src/news/mod.rs +++ b/MosaicIQ/src-tauri/src/news/mod.rs @@ -11,8 +11,9 @@ use thiserror::Error; pub use service::NewsService; pub use types::{ - NewsArticle, QueryNewsFeedRequest, QueryNewsFeedResponse, RefreshNewsFeedRequest, - RefreshNewsFeedResult, UpdateNewsArticleStateRequest, + NewsArticle, NewsSourceConfig, QueryNewsFeedRequest, QueryNewsFeedResponse, + RefreshNewsFeedRequest, RefreshNewsFeedResult, SaveNewsFeedConfigRequest, + UpdateNewsArticleStateRequest, }; #[derive(Debug, Error)] diff --git a/MosaicIQ/src-tauri/src/news/repository.rs b/MosaicIQ/src-tauri/src/news/repository.rs index 637b5dc..6865091 100644 --- a/MosaicIQ/src-tauri/src/news/repository.rs +++ b/MosaicIQ/src-tauri/src/news/repository.rs @@ -339,6 +339,10 @@ fn sync_sources_in_connection( sources: Vec, ) -> Result<()> { let transaction = connection.transaction()?; + let source_ids = sources + .iter() + .map(|source| source.id.clone()) + .collect::>(); for source in sources { transaction.execute( "INSERT INTO feed_sources ( @@ -356,6 +360,20 @@ fn sync_sources_in_connection( ], )?; } + if source_ids.is_empty() { + transaction.execute("DELETE FROM feed_sources", [])?; + } else { + let placeholders = std::iter::repeat_n("?", source_ids.len()) + .collect::>() + .join(", "); + let delete_sql = format!("DELETE FROM feed_sources WHERE id NOT IN ({placeholders})"); + let delete_params = source_ids + .iter() + .cloned() + .map(Value::Text) + .collect::>(); + transaction.execute(&delete_sql, params_from_iter(delete_params.iter()))?; + } transaction.commit()?; Ok(()) } @@ -703,6 +721,44 @@ mod tests { assert_eq!(response.total, 0); } + #[tokio::test] + async fn sync_sources_should_remove_sources_missing_from_latest_config() { + let repository = sample_repository().await; + + repository + .sync_sources(vec![ + NewsSourceConfig { + id: "sample".to_string(), + name: "Sample".to_string(), + url: "https://example.com/feed.xml".to_string(), + refresh_minutes: 15, + }, + NewsSourceConfig { + id: "macro".to_string(), + name: "Macro".to_string(), + url: "https://example.com/macro.xml".to_string(), + refresh_minutes: 30, + }, + ]) + .await + .unwrap(); + + repository + .sync_sources(vec![NewsSourceConfig { + id: "sample".to_string(), + name: "Sample".to_string(), + url: "https://example.com/feed.xml".to_string(), + refresh_minutes: 60, + }]) + .await + .unwrap(); + + let sources = repository.list_sources().await.unwrap(); + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].id, "sample"); + assert_eq!(sources[0].refresh_minutes, 60); + } + async fn sample_repository() -> NewsRepository { let root = unique_test_directory("news-repository"); NewsRepository::new(root.join("news.sqlite")).unwrap() diff --git a/MosaicIQ/src-tauri/src/news/scheduler.rs b/MosaicIQ/src-tauri/src/news/scheduler.rs index c97b1c5..7a67f69 100644 --- a/MosaicIQ/src-tauri/src/news/scheduler.rs +++ b/MosaicIQ/src-tauri/src/news/scheduler.rs @@ -11,7 +11,7 @@ where F: Fn(RefreshNewsFeedResult) + Send + Sync + 'static, { let callback = Arc::new(on_refresh); - tokio::spawn(async move { + tauri::async_runtime::spawn(async move { time::sleep(Duration::from_secs(5)).await; loop { diff --git a/MosaicIQ/src-tauri/src/news/service.rs b/MosaicIQ/src-tauri/src/news/service.rs index 2182d72..5bb3671 100644 --- a/MosaicIQ/src-tauri/src/news/service.rs +++ b/MosaicIQ/src-tauri/src/news/service.rs @@ -5,16 +5,16 @@ use chrono::Utc; use futures::stream::{self, StreamExt}; use crate::news::classifier::classify_article; -use crate::news::config::load_or_bootstrap_config; +use crate::news::config::{load_or_bootstrap_config, reset_config, save_config}; use crate::news::fetcher::FeedFetcher; use crate::news::parser::parse_feed; use crate::news::repository::NewsRepository; use crate::news::types::{ - ArticleUpsertSummary, FeedSourceRecord, FetchResultKind, QueryNewsFeedRequest, - QueryNewsFeedResponse, RefreshNewsFeedRequest, RefreshNewsFeedResult, - UpdateNewsArticleStateRequest, + ArticleUpsertSummary, FeedSourceRecord, FetchResultKind, NewsSourceConfig, + QueryNewsFeedRequest, QueryNewsFeedResponse, RefreshNewsFeedRequest, RefreshNewsFeedResult, + SaveNewsFeedConfigRequest, UpdateNewsArticleStateRequest, }; -use crate::news::Result; +use crate::news::{NewsError, Result}; #[derive(Clone)] pub struct NewsService { @@ -125,6 +125,35 @@ impl NewsService { self.repository.update_article_state(request).await } + pub async fn get_feed_config(&self) -> Result> { + let config_path = self.config_path.clone(); + let default_config_bytes = self.default_config_bytes.clone(); + self.run_config_io(move || load_or_bootstrap_config(&config_path, &default_config_bytes)) + .await + } + + pub async fn save_feed_config( + &self, + request: SaveNewsFeedConfigRequest, + ) -> Result> { + let config_path = self.config_path.clone(); + let feeds = self + .run_config_io(move || save_config(&config_path, &request.feeds)) + .await?; + self.repository.sync_sources(feeds.clone()).await?; + Ok(feeds) + } + + pub async fn reset_feed_config(&self) -> Result> { + let config_path = self.config_path.clone(); + let default_config_bytes = self.default_config_bytes.clone(); + let feeds = self + .run_config_io(move || reset_config(&config_path, &default_config_bytes)) + .await?; + self.repository.sync_sources(feeds.clone()).await?; + Ok(feeds) + } + async fn refresh_source(&self, source: FeedSourceRecord, force: bool) -> RefreshOutcome { let fetched = match self.fetcher.fetch(&source, force).await { Ok(value) => value, @@ -201,6 +230,16 @@ impl NewsService { .await; RefreshOutcome::succeeded(upsert_summary) } + + async fn run_config_io(&self, task: F) -> Result + where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, + { + tokio::task::spawn_blocking(task) + .await + .map_err(|error| NewsError::Join(error.to_string()))? + } } struct RefreshOutcome { @@ -235,7 +274,9 @@ mod tests { use super::NewsService; use crate::news::fetcher::FeedFetcher; - use crate::news::types::{QueryNewsFeedRequest, RefreshNewsFeedRequest}; + use crate::news::types::{ + NewsSourceConfig, QueryNewsFeedRequest, RefreshNewsFeedRequest, SaveNewsFeedConfigRequest, + }; #[tokio::test] async fn refresh_feed_should_continue_when_one_feed_times_out() { @@ -301,6 +342,52 @@ mod tests { assert_eq!(response.total, 2); } + #[tokio::test] + async fn save_feed_config_should_replace_runtime_sources() { + let temp_dir = tempdir().unwrap(); + let db_path = temp_dir.path().join("news.sqlite"); + let config_path = temp_dir.path().join("news-feeds.json"); + let service = NewsService::with_fetcher( + db_path, + config_path, + br#"{"feeds":[ + {"id":"fed","name":"Fed","url":"https://example.com/fed.xml","refreshMinutes":15}, + {"id":"sec","name":"SEC","url":"https://example.com/sec.xml","refreshMinutes":15} + ]}"#, + FeedFetcher::new().unwrap(), + ) + .unwrap(); + + let feeds = service + .save_feed_config(SaveNewsFeedConfigRequest { + feeds: vec![NewsSourceConfig { + id: "custom".to_string(), + name: "Custom".to_string(), + url: "https://example.com/custom.xml".to_string(), + refresh_minutes: 45, + }], + }) + .await + .unwrap(); + + let sources = service + .query_feed(QueryNewsFeedRequest { + ticker: None, + search: None, + only_highlighted: None, + only_saved: None, + only_unread: None, + limit: Some(10), + offset: Some(0), + }) + .await + .unwrap() + .sources; + assert_eq!(feeds.len(), 1); + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].id, "custom"); + } + #[tokio::test] async fn refresh_source_should_use_conditional_get_headers_after_initial_sync() { let server = MockServer::start().await; diff --git a/MosaicIQ/src-tauri/src/news/types.rs b/MosaicIQ/src-tauri/src/news/types.rs index 22f9464..26d441a 100644 --- a/MosaicIQ/src-tauri/src/news/types.rs +++ b/MosaicIQ/src-tauri/src/news/types.rs @@ -97,6 +97,12 @@ pub struct RefreshNewsFeedResult { pub finished_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SaveNewsFeedConfigRequest { + pub feeds: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct UpdateNewsArticleStateRequest { diff --git a/MosaicIQ/src-tauri/src/research/ai.rs b/MosaicIQ/src-tauri/src/research/ai.rs index 802eb3c..2372609 100644 --- a/MosaicIQ/src-tauri/src/research/ai.rs +++ b/MosaicIQ/src-tauri/src/research/ai.rs @@ -15,14 +15,19 @@ pub(crate) struct AiEnrichmentResult { } pub(crate) trait ResearchAiGateway: Send + Sync { - fn enrich_note(&self, note: &ResearchNote, model_info: Option) -> AiEnrichmentResult; + fn enrich_note(&self, note: &ResearchNote, model_info: Option) + -> AiEnrichmentResult; } #[derive(Debug, Clone, Default)] pub(crate) struct DeterministicResearchAiGateway; impl ResearchAiGateway for DeterministicResearchAiGateway { - fn enrich_note(&self, note: &ResearchNote, _model_info: Option) -> AiEnrichmentResult { + fn enrich_note( + &self, + note: &ResearchNote, + _model_info: Option, + ) -> AiEnrichmentResult { let heuristic = classify_note( ¬e.cleaned_text, note.provenance.source_kind, @@ -42,7 +47,10 @@ impl ResearchAiGateway for DeterministicResearchAiGateway { note.valuation_refs.clone() }, missing_evidence: note.source_id.is_none() - && !matches!(note.note_type, NoteType::Question | NoteType::FollowUpTask | NoteType::SourceReference), + && !matches!( + note.note_type, + NoteType::Question | NoteType::FollowUpTask | NoteType::SourceReference + ), } } } @@ -60,7 +68,9 @@ fn build_annotation(note: &ResearchNote) -> String { _ => "This note may be more valuable once it is linked into a driver, risk, catalyst, or source trail.", }; - if note.source_id.is_none() && !matches!(note.note_type, NoteType::Question | NoteType::FollowUpTask) { + if note.source_id.is_none() + && !matches!(note.note_type, NoteType::Question | NoteType::FollowUpTask) + { format!("{kind} Evidence is still missing or indirect.") } else { kind.to_string() diff --git a/MosaicIQ/src-tauri/src/research/errors.rs b/MosaicIQ/src-tauri/src/research/errors.rs index 8dd72c3..572d2be 100644 --- a/MosaicIQ/src-tauri/src/research/errors.rs +++ b/MosaicIQ/src-tauri/src/research/errors.rs @@ -20,13 +20,8 @@ pub enum ResearchError { NoteNotFound(String), #[error("research ghost note not found: {0}")] GhostNoteNotFound(String), - #[error("research job not found: {0}")] - JobNotFound(String), #[error("research validation failed: {0}")] Validation(String), - #[error("research AI gateway failed: {0}")] - Ai(String), } pub type Result = std::result::Result; - diff --git a/MosaicIQ/src-tauri/src/research/events.rs b/MosaicIQ/src-tauri/src/research/events.rs index cf83c3e..24ea7c7 100644 --- a/MosaicIQ/src-tauri/src/research/events.rs +++ b/MosaicIQ/src-tauri/src/research/events.rs @@ -3,11 +3,19 @@ use serde::Serialize; use tauri::{AppHandle, Emitter, Runtime}; -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct ResearchEventEmitter { app_handle: AppHandle, } +impl Clone for ResearchEventEmitter { + fn clone(&self) -> Self { + Self { + app_handle: self.app_handle.clone(), + } + } +} + impl ResearchEventEmitter { pub fn new(app_handle: &AppHandle) -> Self { Self { diff --git a/MosaicIQ/src-tauri/src/research/ghosts.rs b/MosaicIQ/src-tauri/src/research/ghosts.rs index e52f0e0..95c04c1 100644 --- a/MosaicIQ/src-tauri/src/research/ghosts.rs +++ b/MosaicIQ/src-tauri/src/research/ghosts.rs @@ -62,7 +62,10 @@ fn generate_contradiction_alerts( notes: &[ResearchNote], links: &[NoteLink], ) -> Vec { - let notes_by_id = notes.iter().map(|note| (note.id.as_str(), note)).collect::>(); + let notes_by_id = notes + .iter() + .map(|note| (note.id.as_str(), note)) + .collect::>(); links.iter() .filter(|link| matches!(link.link_type, LinkType::Contradicts | LinkType::ManagementVsReality)) .filter_map(|link| { @@ -88,16 +91,27 @@ fn generate_contradiction_alerts( .collect() } -fn generate_candidate_risks(workspace: &ResearchWorkspace, notes: &[ResearchNote]) -> Vec { +fn generate_candidate_risks( + workspace: &ResearchWorkspace, + notes: &[ResearchNote], +) -> Vec { let risk_notes = notes .iter() - .filter(|note| matches!(note.note_type, NoteType::Risk | NoteType::Contradiction | NoteType::ChannelCheck)) + .filter(|note| { + matches!( + note.note_type, + NoteType::Risk | NoteType::Contradiction | NoteType::ChannelCheck + ) + }) .collect::>(); if risk_notes.len() < 3 { return Vec::new(); } - let supporting_ids = risk_notes.iter().map(|note| note.id.clone()).collect::>(); + let supporting_ids = risk_notes + .iter() + .map(|note| note.id.clone()) + .collect::>(); vec![ghost( workspace, GhostNoteClass::CandidateRisk, @@ -113,16 +127,27 @@ fn generate_candidate_risks(workspace: &ResearchWorkspace, notes: &[ResearchNote )] } -fn generate_candidate_catalysts(workspace: &ResearchWorkspace, notes: &[ResearchNote]) -> Vec { +fn generate_candidate_catalysts( + workspace: &ResearchWorkspace, + notes: &[ResearchNote], +) -> Vec { let catalyst_notes = notes .iter() - .filter(|note| matches!(note.note_type, NoteType::Catalyst | NoteType::EventTakeaway | NoteType::ManagementSignal)) + .filter(|note| { + matches!( + note.note_type, + NoteType::Catalyst | NoteType::EventTakeaway | NoteType::ManagementSignal + ) + }) .collect::>(); if catalyst_notes.len() < 2 { return Vec::new(); } - let supporting_ids = catalyst_notes.iter().map(|note| note.id.clone()).collect::>(); + let supporting_ids = catalyst_notes + .iter() + .map(|note| note.id.clone()) + .collect::>(); vec![ghost( workspace, GhostNoteClass::CandidateCatalyst, @@ -138,21 +163,35 @@ fn generate_candidate_catalysts(workspace: &ResearchWorkspace, notes: &[Research )] } -fn generate_valuation_bridges(workspace: &ResearchWorkspace, notes: &[ResearchNote]) -> Vec { +fn generate_valuation_bridges( + workspace: &ResearchWorkspace, + notes: &[ResearchNote], +) -> Vec { let valuation_notes = notes .iter() .filter(|note| note.note_type == NoteType::ValuationPoint) .collect::>(); let driver_notes = notes .iter() - .filter(|note| matches!(note.note_type, NoteType::IndustryObservation | NoteType::ManagementSignal | NoteType::Fact | NoteType::Catalyst)) + .filter(|note| { + matches!( + note.note_type, + NoteType::IndustryObservation + | NoteType::ManagementSignal + | NoteType::Fact + | NoteType::Catalyst + ) + }) .collect::>(); if valuation_notes.len() < 2 || driver_notes.is_empty() { return Vec::new(); } - let mut support = valuation_notes.iter().map(|note| note.id.clone()).collect::>(); + let mut support = valuation_notes + .iter() + .map(|note| note.id.clone()) + .collect::>(); support.extend(driver_notes.iter().take(2).map(|note| note.id.clone())); vec![ghost( workspace, @@ -174,7 +213,11 @@ fn generate_candidate_thesis( notes: &[ResearchNote], links: &[NoteLink], ) -> Option { - let source_count = notes.iter().filter_map(|note| note.source_id.as_ref()).collect::>().len(); + let source_count = notes + .iter() + .filter_map(|note| note.source_id.as_ref()) + .collect::>() + .len(); let family_count = notes .iter() .map(|note| note.note_type) @@ -182,11 +225,27 @@ fn generate_candidate_thesis( .len(); let corroborated_count = notes .iter() - .filter(|note| matches!(note.note_type, NoteType::Fact | NoteType::Quote | NoteType::ManagementSignal | NoteType::ValuationPoint)) + .filter(|note| { + matches!( + note.note_type, + NoteType::Fact + | NoteType::Quote + | NoteType::ManagementSignal + | NoteType::ValuationPoint + ) + }) .count(); - let has_catalyst_or_valuation = notes.iter().any(|note| matches!(note.note_type, NoteType::Catalyst | NoteType::ValuationPoint)); + let has_catalyst_or_valuation = notes.iter().any(|note| { + matches!( + note.note_type, + NoteType::Catalyst | NoteType::ValuationPoint + ) + }); let has_unresolved_contradiction = links.iter().any(|link| { - matches!(link.link_type, LinkType::Contradicts | LinkType::ManagementVsReality) && link.confidence > 0.75 + matches!( + link.link_type, + LinkType::Contradicts | LinkType::ManagementVsReality + ) && link.confidence > 0.75 }); if notes.len() < 4 @@ -217,7 +276,11 @@ fn generate_candidate_thesis( collect_source_ids(notes.iter()), headline.to_string(), body.to_string(), - if has_unresolved_contradiction { 0.68 } else { 0.82 }, + if has_unresolved_contradiction { + 0.68 + } else { + 0.82 + }, !has_unresolved_contradiction, GhostVisibilityState::Visible, Some(MemoSectionKind::InvestmentMemo), @@ -228,7 +291,10 @@ fn rank_and_limit_visibility(ghosts: &mut [GhostNote]) { ghosts.sort_by(|left, right| right.confidence.total_cmp(&left.confidence)); let mut visible_count = 0usize; for ghost in ghosts { - if matches!(ghost.visibility_state, GhostVisibilityState::Visible | GhostVisibilityState::Pinned) { + if matches!( + ghost.visibility_state, + GhostVisibilityState::Visible | GhostVisibilityState::Pinned + ) { if visible_count >= 3 { ghost.visibility_state = GhostVisibilityState::Hidden; ghost.state = GhostLifecycleState::Generated; @@ -257,7 +323,10 @@ fn ghost( let mut key_parts = BTreeSet::new(); key_parts.extend(supporting_note_ids.iter().cloned()); key_parts.extend(contradicting_note_ids.iter().cloned()); - let ghost_key = format!("{ghost_class:?}-{}", key_parts.into_iter().collect::>().join(",")); + let ghost_key = format!( + "{ghost_class:?}-{}", + key_parts.into_iter().collect::>().join(",") + ); GhostNote { id: format!("ghost-{}", &sha256_hex(&ghost_key)[..16]), @@ -380,6 +449,9 @@ mod tests { &[], ); - assert!(ghosts.iter().any(|ghost| ghost.ghost_class == crate::research::types::GhostNoteClass::CandidateThesis)); + assert!(ghosts + .iter() + .any(|ghost| ghost.ghost_class + == crate::research::types::GhostNoteClass::CandidateThesis)); } } diff --git a/MosaicIQ/src-tauri/src/research/grounding.rs b/MosaicIQ/src-tauri/src/research/grounding.rs index 197379e..edc7ab9 100644 --- a/MosaicIQ/src-tauri/src/research/grounding.rs +++ b/MosaicIQ/src-tauri/src/research/grounding.rs @@ -3,10 +3,11 @@ use regex::Regex; use serde_json::json; +use crate::research::errors::Result; use crate::research::heuristics::derive_title; use crate::research::types::{ AnalystStatus, EvidenceStatus, FreshnessBucket, NotePriority, NoteProvenance, NoteType, - ResearchNote, SourceExcerpt, SourceKind, SourceRecord, SourceReferenceInput, + ResearchNote, SourceExcerpt, SourceRecord, SourceReferenceInput, }; use crate::research::util::{generate_id, now_rfc3339, sha256_hex}; @@ -111,7 +112,10 @@ pub(crate) fn build_source_reference_note( } } -pub(crate) fn source_excerpt_from_input(source_id: &str, input: &SourceReferenceInput) -> Option { +pub(crate) fn source_excerpt_from_input( + source_id: &str, + input: &SourceReferenceInput, +) -> Option { if input.excerpt_text.is_none() && input.location_label.is_none() { return None; } @@ -125,7 +129,7 @@ pub(crate) fn source_excerpt_from_input(source_id: &str, input: &SourceReference }) } -pub(crate) async fn refresh_source_metadata(source: &SourceRecord) -> crate::research::Result { +pub(crate) async fn refresh_source_metadata(source: &SourceRecord) -> Result { let Some(url) = source.url.as_deref() else { return Ok(source.clone()); }; @@ -171,6 +175,10 @@ fn extract_html_title(body: &str) -> Option { let regex = Regex::new(r"(?is)(.*?)").expect("title regex should compile"); regex .captures(body) - .and_then(|captures| captures.get(1).map(|value| value.as_str().trim().to_string())) + .and_then(|captures| { + captures + .get(1) + .map(|value| value.as_str().trim().to_string()) + }) .filter(|value| !value.is_empty()) } diff --git a/MosaicIQ/src-tauri/src/research/heuristics.rs b/MosaicIQ/src-tauri/src/research/heuristics.rs index 1409b60..b31a1f8 100644 --- a/MosaicIQ/src-tauri/src/research/heuristics.rs +++ b/MosaicIQ/src-tauri/src/research/heuristics.rs @@ -37,30 +37,95 @@ pub(crate) fn classify_note( baseline_result(NoteType::Quote, 0.92) } else if lower.contains("management says") || lower.contains("mgmt says") - || (matches!(source_kind, SourceKind::Transcript) && has_any(&lower, &["expect", "seeing", "confident", "guidance"])) + || (matches!(source_kind, SourceKind::Transcript) + && has_any(&lower, &["expect", "seeing", "confident", "guidance"])) { baseline_result(NoteType::ManagementSignal, 0.83) - } else if has_any(&lower, &["ev/ebitda", "p/e", "fcf yield", "multiple", "price target", "rerating", "valuation"]) { + } else if has_any( + &lower, + &[ + "ev/ebitda", + "p/e", + "fcf yield", + "multiple", + "price target", + "rerating", + "valuation", + ], + ) { baseline_result(NoteType::ValuationPoint, 0.87) - } else if has_any(&lower, &["risk", "downside", "headwind", "pressure", "weakness", "inventory"]) { + } else if has_any( + &lower, + &[ + "risk", + "downside", + "headwind", + "pressure", + "weakness", + "inventory", + ], + ) { baseline_result(NoteType::Risk, 0.78) - } else if has_any(&lower, &["catalyst", "launch", "approval", "guidance", "next quarter", "earnings", "rerating"]) { + } else if has_any( + &lower, + &[ + "catalyst", + "launch", + "approval", + "guidance", + "next quarter", + "earnings", + "rerating", + ], + ) { baseline_result(NoteType::Catalyst, 0.74) - } else if has_any(&lower, &["if ", "assume", "base case", "bull case", "bear case", "scenario"]) { + } else if has_any( + &lower, + &[ + "if ", + "assume", + "base case", + "bull case", + "bear case", + "scenario", + ], + ) { baseline_result(NoteType::ScenarioAssumption, 0.8) - } else if cleaned_text.ends_with('?') || has_any(&lower, &["what if", "why is", "how does", "question"]) { + } else if cleaned_text.ends_with('?') + || has_any(&lower, &["what if", "why is", "how does", "question"]) + { baseline_result(NoteType::Question, 0.91) - } else if has_any(&lower, &["channel check", "retail", "sell-through", "inventory in channel"]) { + } else if has_any( + &lower, + &[ + "channel check", + "retail", + "sell-through", + "inventory in channel", + ], + ) { baseline_result(NoteType::ChannelCheck, 0.82) - } else if has_any(&lower, &["peer", "vs ", "versus", "relative to", "competitor"]) { + } else if has_any( + &lower, + &["peer", "vs ", "versus", "relative to", "competitor"], + ) { baseline_result(NoteType::CompetitorComparison, 0.77) } else if has_any(&lower, &["industry", "category", "market", "sector"]) { baseline_result(NoteType::IndustryObservation, 0.73) - } else if has_any(&lower, &["thesis", "stock can", "we think", "market is missing"]) { + } else if has_any( + &lower, + &["thesis", "stock can", "we think", "market is missing"], + ) { baseline_result(NoteType::Thesis, 0.71) - } else if has_any(&lower, &["follow up", "check", "verify", "ask ir", "need to"]) { + } else if has_any( + &lower, + &["follow up", "check", "verify", "ask ir", "need to"], + ) { baseline_result(NoteType::FollowUpTask, 0.85) - } else if has_any(&lower, &["call takeaway", "takeaway", "earnings recap", "event"]) { + } else if has_any( + &lower, + &["call takeaway", "takeaway", "earnings recap", "event"], + ) { baseline_result(NoteType::EventTakeaway, 0.76) } else if looks_like_fact(cleaned_text, source_kind) { baseline_result(NoteType::Fact, 0.72) @@ -69,8 +134,26 @@ pub(crate) fn classify_note( }; result.tags = extract_tags(cleaned_text); - result.catalysts = extract_keyword_bucket(cleaned_text, &["launch", "approval", "guidance", "margin", "enterprise demand"]); - result.risks = extract_keyword_bucket(cleaned_text, &["inventory", "pricing", "churn", "competition", "demand softness"]); + result.catalysts = extract_keyword_bucket( + cleaned_text, + &[ + "launch", + "approval", + "guidance", + "margin", + "enterprise demand", + ], + ); + result.risks = extract_keyword_bucket( + cleaned_text, + &[ + "inventory", + "pricing", + "churn", + "competition", + "demand softness", + ], + ); result.valuation_refs = extract_valuation_refs(cleaned_text); result.time_horizon = infer_time_horizon(&lower); result.scenario = infer_scenario(&lower); @@ -94,7 +177,10 @@ pub(crate) fn derive_title(cleaned_text: &str, note_type: NoteType) -> Option "Note", }; - Some(format!("{prefix}: {}", crate::research::util::clean_title(cleaned_text, 72))) + Some(format!( + "{prefix}: {}", + crate::research::util::clean_title(cleaned_text, 72) + )) } pub(crate) fn detect_urls(text: &str) -> Vec { @@ -129,16 +215,21 @@ fn baseline_result(note_type: NoteType, confidence: f32) -> HeuristicTypingResul } fn looks_like_quote(cleaned_text: &str, source_kind: SourceKind) -> bool { - cleaned_text.contains('\"') || matches!(source_kind, SourceKind::Transcript) && cleaned_text.contains(':') + cleaned_text.contains('\"') + || matches!(source_kind, SourceKind::Transcript) && cleaned_text.contains(':') } fn looks_like_fact(cleaned_text: &str, source_kind: SourceKind) -> bool { let lower = cleaned_text.to_ascii_lowercase(); - matches!(source_kind, SourceKind::Filing | SourceKind::Transcript | SourceKind::Article) - || has_any(&lower, &["reported", "was", "were", "increased", "decreased"]) - || Regex::new(r"\b\d+(\.\d+)?(%|x|bps|m|bn)?\b") - .expect("fact regex should compile") - .is_match(cleaned_text) + matches!( + source_kind, + SourceKind::Filing | SourceKind::Transcript | SourceKind::Article + ) || has_any( + &lower, + &["reported", "was", "were", "increased", "decreased"], + ) || Regex::new(r"\b\d+(\.\d+)?(%|x|bps|m|bn)?\b") + .expect("fact regex should compile") + .is_match(cleaned_text) } fn extract_tags(cleaned_text: &str) -> Vec { @@ -172,8 +263,8 @@ fn extract_keyword_bucket(cleaned_text: &str, keywords: &[&str]) -> Vec } fn extract_valuation_refs(cleaned_text: &str) -> Vec { - let regex = - Regex::new(r"(?P\d+(\.\d+)?)x\s+(?P[A-Za-z/]+)").expect("valuation regex should compile"); + let regex = Regex::new(r"(?P\d+(\.\d+)?)x\s+(?P[A-Za-z/]+)") + .expect("valuation regex should compile"); regex .captures_iter(cleaned_text) .map(|captures| ValuationRef { @@ -216,9 +307,14 @@ fn infer_scenario(lower: &str) -> Option { } fn infer_priority(note_type: &NoteType, lower: &str) -> NotePriority { - if matches!(note_type, NoteType::Contradiction | NoteType::Risk) && has_any(lower, &["material", "severe", "significant"]) { + if matches!(note_type, NoteType::Contradiction | NoteType::Risk) + && has_any(lower, &["material", "severe", "significant"]) + { NotePriority::Critical - } else if matches!(note_type, NoteType::Risk | NoteType::Catalyst | NoteType::Thesis | NoteType::ManagementSignal) { + } else if matches!( + note_type, + NoteType::Risk | NoteType::Catalyst | NoteType::Thesis | NoteType::ManagementSignal + ) { NotePriority::High } else { NotePriority::Normal diff --git a/MosaicIQ/src-tauri/src/research/links.rs b/MosaicIQ/src-tauri/src/research/links.rs index a78dc7e..edd49a3 100644 --- a/MosaicIQ/src-tauri/src/research/links.rs +++ b/MosaicIQ/src-tauri/src/research/links.rs @@ -36,38 +36,106 @@ fn infer_pair(left: &ResearchNote, right: &ResearchNote) -> Option { && left.source_id.as_deref() == right.source_id.as_deref() && left.source_id.is_some() { - return Some(build_link(left, right, LinkType::SourcedBy, 0.98, LinkStrength::Strong, EvidenceBasis::SharedSource)); + return Some(build_link( + left, + right, + LinkType::SourcedBy, + 0.98, + LinkStrength::Strong, + EvidenceBasis::SharedSource, + )); } - if left.note_type == NoteType::ValuationPoint && is_valuation_dependency_target(right) && shares_keywords(left, right) { - return Some(build_link(left, right, LinkType::ValuationDependsOn, 0.8, LinkStrength::Strong, EvidenceBasis::Lexical)); + if left.note_type == NoteType::ValuationPoint + && is_valuation_dependency_target(right) + && shares_keywords(left, right) + { + return Some(build_link( + left, + right, + LinkType::ValuationDependsOn, + 0.8, + LinkStrength::Strong, + EvidenceBasis::Lexical, + )); } if left.note_type == NoteType::Risk && is_thesis_family(right) && shares_keywords(left, right) { - return Some(build_link(left, right, LinkType::RiskTo, 0.76, LinkStrength::Strong, EvidenceBasis::Lexical)); + return Some(build_link( + left, + right, + LinkType::RiskTo, + 0.76, + LinkStrength::Strong, + EvidenceBasis::Lexical, + )); } - if left.note_type == NoteType::Catalyst && is_thesis_family(right) && shares_keywords(left, right) { - return Some(build_link(left, right, LinkType::CatalystFor, 0.78, LinkStrength::Strong, EvidenceBasis::Temporal)); + if left.note_type == NoteType::Catalyst + && is_thesis_family(right) + && shares_keywords(left, right) + { + return Some(build_link( + left, + right, + LinkType::CatalystFor, + 0.78, + LinkStrength::Strong, + EvidenceBasis::Temporal, + )); } - if left.note_type == NoteType::ScenarioAssumption && is_assumption_target(right) && shares_keywords(left, right) { - return Some(build_link(left, right, LinkType::AssumptionFor, 0.75, LinkStrength::Strong, EvidenceBasis::Structured)); + if left.note_type == NoteType::ScenarioAssumption + && is_assumption_target(right) + && shares_keywords(left, right) + { + return Some(build_link( + left, + right, + LinkType::AssumptionFor, + 0.75, + LinkStrength::Strong, + EvidenceBasis::Structured, + )); } if is_evidence_note(left) && is_claim_family(right) && shares_keywords(left, right) { if signals_contradiction(left, right) { - return Some(build_link(left, right, LinkType::Contradicts, 0.79, LinkStrength::Critical, EvidenceBasis::Lexical)); + return Some(build_link( + left, + right, + LinkType::Contradicts, + 0.79, + LinkStrength::Critical, + EvidenceBasis::Lexical, + )); } - return Some(build_link(left, right, LinkType::Supports, 0.72, LinkStrength::Strong, EvidenceBasis::Lexical)); + return Some(build_link( + left, + right, + LinkType::Supports, + 0.72, + LinkStrength::Strong, + EvidenceBasis::Lexical, + )); } if left.note_type == NoteType::ManagementSignal - && matches!(right.note_type, NoteType::Fact | NoteType::ChannelCheck | NoteType::EventTakeaway) + && matches!( + right.note_type, + NoteType::Fact | NoteType::ChannelCheck | NoteType::EventTakeaway + ) && shares_keywords(left, right) && signals_contradiction(left, right) { - return Some(build_link(left, right, LinkType::ManagementVsReality, 0.9, LinkStrength::Critical, EvidenceBasis::Temporal)); + return Some(build_link( + left, + right, + LinkType::ManagementVsReality, + 0.9, + LinkStrength::Critical, + EvidenceBasis::Temporal, + )); } if left.note_type == right.note_type @@ -80,7 +148,14 @@ fn infer_pair(left: &ResearchNote, right: &ResearchNote) -> Option { } else { LinkType::Supersedes }; - return Some(build_link(left, right, link_type, 0.7, LinkStrength::Medium, EvidenceBasis::Structured)); + return Some(build_link( + left, + right, + link_type, + 0.7, + LinkStrength::Medium, + EvidenceBasis::Structured, + )); } if shares_keywords(left, right) @@ -88,7 +163,14 @@ fn infer_pair(left: &ResearchNote, right: &ResearchNote) -> Option { && right.time_horizon.is_some() && left.time_horizon != right.time_horizon { - return Some(build_link(left, right, LinkType::TimeframeConflict, 0.67, LinkStrength::Medium, EvidenceBasis::Temporal)); + return Some(build_link( + left, + right, + LinkType::TimeframeConflict, + 0.67, + LinkStrength::Medium, + EvidenceBasis::Temporal, + )); } None @@ -96,11 +178,21 @@ fn infer_pair(left: &ResearchNote, right: &ResearchNote) -> Option { fn infer_reverse_pair(left: &ResearchNote, right: &ResearchNote) -> Option { if left.note_type == NoteType::ManagementSignal - && matches!(right.note_type, NoteType::Fact | NoteType::ChannelCheck | NoteType::EventTakeaway) + && matches!( + right.note_type, + NoteType::Fact | NoteType::ChannelCheck | NoteType::EventTakeaway + ) && shares_keywords(left, right) && signals_contradiction(left, right) { - return Some(build_link(right, left, LinkType::Contradicts, 0.84, LinkStrength::Critical, EvidenceBasis::Temporal)); + return Some(build_link( + right, + left, + LinkType::Contradicts, + 0.84, + LinkStrength::Critical, + EvidenceBasis::Temporal, + )); } None @@ -161,11 +253,17 @@ fn is_evidence_note(note: &ResearchNote) -> bool { } fn is_thesis_family(note: &ResearchNote) -> bool { - matches!(note.note_type, NoteType::Thesis | NoteType::SubThesis | NoteType::Claim | NoteType::MosaicInsight) + matches!( + note.note_type, + NoteType::Thesis | NoteType::SubThesis | NoteType::Claim | NoteType::MosaicInsight + ) } fn is_assumption_target(note: &ResearchNote) -> bool { - matches!(note.note_type, NoteType::ValuationPoint | NoteType::Thesis | NoteType::SubThesis | NoteType::Risk) + matches!( + note.note_type, + NoteType::ValuationPoint | NoteType::Thesis | NoteType::SubThesis | NoteType::Risk + ) } fn is_valuation_dependency_target(note: &ResearchNote) -> bool { @@ -190,8 +288,23 @@ fn shares_keywords(left: &ResearchNote, right: &ResearchNote) -> bool { } fn signals_contradiction(left: &ResearchNote, right: &ResearchNote) -> bool { - let negative = ["decline", "weak", "pressure", "discount", "inventory", "miss", "soft"]; - let positive = ["improve", "improved", "normalized", "strong", "reaccelerat", "above"]; + let negative = [ + "decline", + "weak", + "pressure", + "discount", + "inventory", + "miss", + "soft", + ]; + let positive = [ + "improve", + "improved", + "normalized", + "strong", + "reaccelerat", + "above", + ]; let left_lower = left.cleaned_text.to_ascii_lowercase(); let right_lower = right.cleaned_text.to_ascii_lowercase(); @@ -201,8 +314,8 @@ fn signals_contradiction(left: &ResearchNote, right: &ResearchNote) -> bool { fn significant_words(text: &str) -> HashSet { let stop_words = BTreeSet::from([ - "the", "and", "for", "with", "that", "from", "this", "next", "says", "said", - "have", "has", "into", "above", "below", "about", "quarter", "company", + "the", "and", "for", "with", "that", "from", "this", "next", "says", "said", "have", "has", + "into", "above", "below", "about", "quarter", "company", ]); text.to_ascii_lowercase() @@ -291,10 +404,20 @@ mod tests { #[test] fn infer_links_should_create_management_vs_reality_for_conflicting_notes() { let links = infer_links(&[ - note("mgmt", NoteType::ManagementSignal, "Management says inventory is now normalized."), - note("fact", NoteType::Fact, "Inventory days increased 12% sequentially and discounting remains elevated."), + note( + "mgmt", + NoteType::ManagementSignal, + "Management says inventory is now normalized.", + ), + note( + "fact", + NoteType::Fact, + "Inventory days increased 12% sequentially and discounting remains elevated.", + ), ]); - assert!(links.iter().any(|link| link.link_type == crate::research::types::LinkType::ManagementVsReality)); + assert!(links + .iter() + .any(|link| link.link_type == crate::research::types::LinkType::ManagementVsReality)); } } diff --git a/MosaicIQ/src-tauri/src/research/mod.rs b/MosaicIQ/src-tauri/src/research/mod.rs index 2185c58..933276e 100644 --- a/MosaicIQ/src-tauri/src/research/mod.rs +++ b/MosaicIQ/src-tauri/src/research/mod.rs @@ -1,9 +1,9 @@ //! Local-first equity research workspace subsystem. mod ai; +mod errors; mod events; mod export; -mod errors; mod ghosts; mod grounding; mod heuristics; @@ -15,15 +15,13 @@ mod service; mod types; mod util; -pub use errors::{ResearchError, Result}; -pub use events::ResearchEventEmitter; pub use pipeline::spawn_research_scheduler; pub use service::ResearchService; pub use types::{ - ArchiveResearchNoteRequest, AuditEvent, CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, + ArchiveResearchNoteRequest, CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetWorkspaceProjectionRequest, GhostNote, ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, - MemoBlockCandidate, NoteAuditTrail, NoteCaptureResult, NoteLink, PipelineJob, - PromoteNoteToThesisRequest, ResearchBundleExport, ResearchNote, ResearchWorkspace, - RetryResearchJobsRequest, ReviewGhostNoteRequest, WorkspaceProjection, + NoteAuditTrail, NoteCaptureResult, NoteLink, PipelineJob, PromoteNoteToThesisRequest, + ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest, + ReviewGhostNoteRequest, UpdateResearchNoteRequest, WorkspaceProjection, }; diff --git a/MosaicIQ/src-tauri/src/research/pipeline.rs b/MosaicIQ/src-tauri/src/research/pipeline.rs index 9d0c6f0..84e4898 100644 --- a/MosaicIQ/src-tauri/src/research/pipeline.rs +++ b/MosaicIQ/src-tauri/src/research/pipeline.rs @@ -6,6 +6,7 @@ use std::time::Duration; use serde_json::json; use tauri::Runtime; +use crate::research::errors::Result; use crate::research::repository::ResearchRepository; use crate::research::types::{JobKind, JobStatus, PipelineJob}; use crate::research::util::{generate_id, now_rfc3339}; @@ -26,12 +27,32 @@ impl ResearchPipeline { note_id: &str, revision: u32, refresh_source_id: Option, - ) -> crate::research::Result> { + ) -> Result> { let mut jobs = vec![ - new_job(workspace_id, note_id, JobKind::EnrichNote, json!({ "noteId": note_id, "expectedRevision": revision })), - new_job(workspace_id, note_id, JobKind::InferLinks, json!({ "workspaceId": workspace_id, "noteId": note_id, "expectedRevision": revision })), - new_job(workspace_id, note_id, JobKind::EvaluateDuplicates, json!({ "workspaceId": workspace_id, "noteId": note_id, "expectedRevision": revision })), - new_job(workspace_id, note_id, JobKind::EvaluateGhosts, json!({ "workspaceId": workspace_id })), + new_job( + workspace_id, + note_id, + JobKind::EnrichNote, + json!({ "noteId": note_id, "expectedRevision": revision }), + ), + new_job( + workspace_id, + note_id, + JobKind::InferLinks, + json!({ "workspaceId": workspace_id, "noteId": note_id, "expectedRevision": revision }), + ), + new_job( + workspace_id, + note_id, + JobKind::EvaluateDuplicates, + json!({ "workspaceId": workspace_id, "noteId": note_id, "expectedRevision": revision }), + ), + new_job( + workspace_id, + note_id, + JobKind::EvaluateGhosts, + json!({ "workspaceId": workspace_id }), + ), ]; if let Some(source_id) = refresh_source_id { jobs.push(new_job( @@ -45,14 +66,14 @@ impl ResearchPipeline { self.repository.enqueue_jobs(jobs).await } - pub async fn mark_running(&self, mut job: PipelineJob) -> crate::research::Result { + pub async fn mark_running(&self, mut job: PipelineJob) -> Result { job.status = JobStatus::Running; job.attempt_count += 1; job.updated_at = now_rfc3339(); self.repository.save_job(job).await } - pub async fn mark_completed(&self, mut job: PipelineJob) -> crate::research::Result { + pub async fn mark_completed(&self, mut job: PipelineJob) -> Result { job.status = JobStatus::Completed; job.last_error = None; job.next_attempt_at = None; @@ -60,7 +81,7 @@ impl ResearchPipeline { self.repository.save_job(job).await } - pub async fn mark_skipped(&self, mut job: PipelineJob, reason: &str) -> crate::research::Result { + pub async fn mark_skipped(&self, mut job: PipelineJob, reason: &str) -> Result { job.status = JobStatus::Skipped; job.last_error = Some(reason.to_string()); job.next_attempt_at = None; @@ -68,7 +89,7 @@ impl ResearchPipeline { self.repository.save_job(job).await } - pub async fn mark_failed(&self, mut job: PipelineJob, error: &str) -> crate::research::Result { + pub async fn mark_failed(&self, mut job: PipelineJob, error: &str) -> Result { job.status = JobStatus::Failed; job.last_error = Some(error.to_string()); job.next_attempt_at = Some(next_retry_timestamp(job.attempt_count + 1)); @@ -76,7 +97,7 @@ impl ResearchPipeline { self.repository.save_job(job).await } - pub async fn due_jobs(&self, limit: usize) -> crate::research::Result> { + pub async fn due_jobs(&self, limit: usize) -> Result> { self.repository.list_due_jobs(limit).await } } @@ -92,7 +113,12 @@ pub fn spawn_research_scheduler( }); } -fn new_job(workspace_id: &str, entity_id: &str, job_kind: JobKind, payload_json: serde_json::Value) -> PipelineJob { +fn new_job( + workspace_id: &str, + entity_id: &str, + job_kind: JobKind, + payload_json: serde_json::Value, +) -> PipelineJob { let now = now_rfc3339(); PipelineJob { id: generate_id("job"), diff --git a/MosaicIQ/src-tauri/src/research/projections.rs b/MosaicIQ/src-tauri/src/research/projections.rs index f82c987..fdbc106 100644 --- a/MosaicIQ/src-tauri/src/research/projections.rs +++ b/MosaicIQ/src-tauri/src/research/projections.rs @@ -1,8 +1,9 @@ //! Read-model projections for frontend workspace views. use crate::research::types::{ - GhostNote, KanbanColumn, MemoBlockCandidate, MemoSectionKind, NoteLink, NoteType, ResearchNote, - ResearchWorkspace, TimelineEvent, WorkspaceProjection, WorkspaceViewKind, GraphEdge, GraphNode, + GhostNote, GraphEdge, GraphNode, KanbanColumn, MemoBlockCandidate, MemoSectionKind, NoteLink, + NoteType, ResearchNote, ResearchWorkspace, TimelineEvent, WorkspaceProjection, + WorkspaceViewKind, }; pub(crate) fn build_workspace_projection( @@ -17,7 +18,10 @@ pub(crate) fn build_workspace_projection( .iter() .map(|note| GraphNode { id: note.id.clone(), - label: note.title.clone().unwrap_or_else(|| note.cleaned_text.clone()), + label: note + .title + .clone() + .unwrap_or_else(|| note.cleaned_text.clone()), kind: format!("{:?}", note.note_type).to_ascii_lowercase(), confidence: note.confidence, evidence_status: note.evidence_status, @@ -51,20 +55,35 @@ pub(crate) fn build_workspace_projection( } } -pub(crate) fn build_memo_blocks(notes: &[ResearchNote], ghosts: &[GhostNote]) -> Vec { +pub(crate) fn build_memo_blocks( + notes: &[ResearchNote], + ghosts: &[GhostNote], +) -> Vec { let mut blocks = notes .iter() .filter(|note| { !note.archived - && !matches!(note.note_type, NoteType::Question | NoteType::FollowUpTask | NoteType::SourceReference) - && !matches!(note.evidence_status, crate::research::types::EvidenceStatus::Unsourced) + && !matches!( + note.note_type, + NoteType::Question | NoteType::FollowUpTask | NoteType::SourceReference + ) + && !matches!( + note.evidence_status, + crate::research::types::EvidenceStatus::Unsourced + ) }) .filter_map(|note| { let section_kind = section_for_note(note.note_type)?; Some(MemoBlockCandidate { section_kind, - headline: note.title.clone().unwrap_or_else(|| note.cleaned_text.clone()), - body: note.ai_annotation.clone().unwrap_or_else(|| note.cleaned_text.clone()), + headline: note + .title + .clone() + .unwrap_or_else(|| note.cleaned_text.clone()), + body: note + .ai_annotation + .clone() + .unwrap_or_else(|| note.cleaned_text.clone()), source_note_ids: vec![note.id.clone()], citation_refs: note.source_id.iter().cloned().collect(), confidence: note.confidence, @@ -82,7 +101,13 @@ pub(crate) fn build_memo_blocks(notes: &[ResearchNote], ghosts: &[GhostNote]) -> blocks.extend( ghosts .iter() - .filter(|ghost| matches!(ghost.state, crate::research::types::GhostLifecycleState::Accepted | crate::research::types::GhostLifecycleState::Converted)) + .filter(|ghost| { + matches!( + ghost.state, + crate::research::types::GhostLifecycleState::Accepted + | crate::research::types::GhostLifecycleState::Converted + ) + }) .filter_map(|ghost| { Some(MemoBlockCandidate { section_kind: ghost.memo_section_hint?, @@ -113,7 +138,9 @@ fn build_kanban_columns(notes: &[ResearchNote]) -> Vec { ] { columns.push(KanbanColumn { key: format!("{note_type:?}").to_ascii_lowercase(), - label: format!("{note_type:?}").replace("Point", "").replace("Signal", " Signal"), + label: format!("{note_type:?}") + .replace("Point", "") + .replace("Signal", " Signal"), notes: notes .iter() .filter(|note| note.note_type == note_type && !note.archived) @@ -127,22 +154,42 @@ fn build_kanban_columns(notes: &[ResearchNote]) -> Vec { fn build_timeline(notes: &[ResearchNote], ghosts: &[GhostNote]) -> Vec { let mut timeline = notes .iter() - .filter(|note| matches!(note.note_type, NoteType::EventTakeaway | NoteType::Catalyst | NoteType::ManagementSignal)) + .filter(|note| { + matches!( + note.note_type, + NoteType::EventTakeaway | NoteType::Catalyst | NoteType::ManagementSignal + ) + }) .map(|note| TimelineEvent { id: note.id.clone(), - label: note.title.clone().unwrap_or_else(|| note.cleaned_text.clone()), + label: note + .title + .clone() + .unwrap_or_else(|| note.cleaned_text.clone()), note_id: note.id.clone(), - at: note.source_excerpt.as_ref().and_then(|excerpt| excerpt.location_label.clone()), + at: note + .source_excerpt + .as_ref() + .and_then(|excerpt| excerpt.location_label.clone()), }) .collect::>(); timeline.extend( ghosts .iter() - .filter(|ghost| matches!(ghost.ghost_class, crate::research::types::GhostNoteClass::ContradictionAlert)) + .filter(|ghost| { + matches!( + ghost.ghost_class, + crate::research::types::GhostNoteClass::ContradictionAlert + ) + }) .map(|ghost| TimelineEvent { id: ghost.id.clone(), label: ghost.headline.clone(), - note_id: ghost.supporting_note_ids.first().cloned().unwrap_or_else(|| ghost.id.clone()), + note_id: ghost + .supporting_note_ids + .first() + .cloned() + .unwrap_or_else(|| ghost.id.clone()), at: None, }), ); @@ -151,10 +198,14 @@ fn build_timeline(notes: &[ResearchNote], ghosts: &[GhostNote]) -> Vec Option { match note_type { - NoteType::Thesis | NoteType::SubThesis | NoteType::MosaicInsight => Some(MemoSectionKind::InvestmentMemo), + NoteType::Thesis | NoteType::SubThesis | NoteType::MosaicInsight => { + Some(MemoSectionKind::InvestmentMemo) + } NoteType::Risk | NoteType::Contradiction => Some(MemoSectionKind::RiskRegister), NoteType::Catalyst => Some(MemoSectionKind::CatalystCalendar), - NoteType::ValuationPoint | NoteType::ScenarioAssumption => Some(MemoSectionKind::ValuationWriteUp), + NoteType::ValuationPoint | NoteType::ScenarioAssumption => { + Some(MemoSectionKind::ValuationWriteUp) + } NoteType::EventTakeaway => Some(MemoSectionKind::EarningsRecap), NoteType::ChannelCheck => Some(MemoSectionKind::WatchlistUpdate), NoteType::Fact @@ -163,7 +214,10 @@ fn section_for_note(note_type: NoteType) -> Option { | NoteType::Claim | NoteType::IndustryObservation | NoteType::CompetitorComparison => Some(MemoSectionKind::StockPitch), - NoteType::Question | NoteType::FollowUpTask | NoteType::SourceReference | NoteType::Uncertainty => None, + NoteType::Question + | NoteType::FollowUpTask + | NoteType::SourceReference + | NoteType::Uncertainty => None, } } @@ -173,7 +227,10 @@ mod tests { use super::build_memo_blocks; - fn note(note_type: NoteType, evidence_status: EvidenceStatus) -> crate::research::types::ResearchNote { + fn note( + note_type: NoteType, + evidence_status: EvidenceStatus, + ) -> crate::research::types::ResearchNote { let now = crate::research::util::now_rfc3339(); crate::research::types::ResearchNote { id: format!("note-{note_type:?}"), diff --git a/MosaicIQ/src-tauri/src/research/repository.rs b/MosaicIQ/src-tauri/src/research/repository.rs index ab045dd..2a12b7c 100644 --- a/MosaicIQ/src-tauri/src/research/repository.rs +++ b/MosaicIQ/src-tauri/src/research/repository.rs @@ -27,7 +27,10 @@ impl ResearchRepository { Ok(repository) } - pub async fn create_workspace(&self, workspace: ResearchWorkspace) -> Result { + pub async fn create_workspace( + &self, + workspace: ResearchWorkspace, + ) -> Result { let value = workspace.clone(); self.with_connection(move |connection| { connection.execute( @@ -51,6 +54,7 @@ impl ResearchRepository { .await } + #[allow(dead_code)] pub async fn save_workspace(&self, workspace: ResearchWorkspace) -> Result { let value = workspace.clone(); self.with_connection(move |connection| { @@ -165,7 +169,7 @@ impl ResearchRepository { value.id, value.title.clone().unwrap_or_default(), value.cleaned_text, - value.ai_annotation.unwrap_or_default(), + value.ai_annotation.clone().unwrap_or_default(), ], )?; Ok(value) @@ -197,35 +201,35 @@ impl ResearchRepository { ) -> Result> { let workspace_id = workspace_id.to_string(); self.with_connection(move |connection| { - let mut statement = if note_type.is_some() { - connection.prepare( + let json_rows = if let Some(note_type) = note_type { + let mut statement = connection.prepare( "SELECT entity_json FROM research_notes WHERE workspace_id = ?1 AND (?2 = 1 OR archived = 0) AND note_type = ?3 ORDER BY pinned DESC, updated_at DESC", - )? + )?; + let rows = statement.query_map( + params![ + workspace_id, + i64::from(include_archived), + serde_json::to_string(¬e_type)?, + ], + |row| row.get::<_, String>(0), + )?; + rows.collect::, _>>()? } else { - connection.prepare( + let mut statement = connection.prepare( "SELECT entity_json FROM research_notes WHERE workspace_id = ?1 AND (?2 = 1 OR archived = 0) ORDER BY pinned DESC, updated_at DESC", - )? + )?; + let rows = statement + .query_map(params![workspace_id, i64::from(include_archived)], |row| { + row.get::<_, String>(0) + })?; + rows.collect::, _>>()? }; - let note_type_json = note_type - .map(|value| serde_json::to_string(&value)) - .transpose()?; - let rows = if let Some(note_type_json) = note_type_json { - statement.query_map( - params![workspace_id, i64::from(include_archived), note_type_json], - |row| row.get::<_, String>(0), - )? - } else { - statement.query_map(params![workspace_id, i64::from(include_archived)], |row| { - row.get::<_, String>(0) - })? - }; - - rows.collect::, _>>()? + json_rows .into_iter() .map(|json| serde_json::from_str(&json).map_err(ResearchError::from)) .collect() @@ -318,7 +322,8 @@ impl ResearchRepository { let ids = source_ids.to_vec(); self.with_connection(move |connection| { let mut results = Vec::new(); - let mut statement = connection.prepare("SELECT entity_json FROM source_records WHERE id = ?1")?; + let mut statement = + connection.prepare("SELECT entity_json FROM source_records WHERE id = ?1")?; for source_id in ids { if let Some(json) = statement .query_row(params![source_id], |row| row.get::<_, String>(0)) @@ -405,31 +410,38 @@ impl ResearchRepository { .await } - pub async fn list_links(&self, workspace_id: &str, note_id: Option<&str>) -> Result> { + pub async fn list_links( + &self, + workspace_id: &str, + note_id: Option<&str>, + ) -> Result> { let workspace_id = workspace_id.to_string(); let note_id = note_id.map(ToOwned::to_owned); self.with_connection(move |connection| { - let mut statement = if note_id.is_some() { - connection.prepare( + let json_rows = if let Some(note_id) = note_id { + let mut statement = connection.prepare( "SELECT entity_json FROM note_links WHERE workspace_id = ?1 AND (from_note_id = ?2 OR to_note_id = ?2) ORDER BY updated_at DESC", - )? + )?; + let rows = statement.query_map(params![workspace_id, note_id], |row| { + row.get::<_, String>(0) + })?; + rows.collect::, _>>()? } else { - connection.prepare( + let mut statement = connection.prepare( "SELECT entity_json FROM note_links WHERE workspace_id = ?1 ORDER BY updated_at DESC", - )? + )?; + let rows = + statement.query_map(params![workspace_id], |row| row.get::<_, String>(0))?; + rows.collect::, _>>()? }; - let rows = if let Some(note_id) = note_id { - statement.query_map(params![workspace_id, note_id], |row| row.get::<_, String>(0))? - } else { - statement.query_map(params![workspace_id], |row| row.get::<_, String>(0))? - }; - rows.collect::, _>>()? + + json_rows .into_iter() .map(|json| serde_json::from_str(&json).map_err(ResearchError::from)) .collect() @@ -446,15 +458,19 @@ impl ResearchRepository { let generated = ghosts.clone(); self.with_connection(move |connection| { let transaction = connection.transaction()?; - - let mut existing_statement = transaction.prepare( - "SELECT id, entity_json FROM ghost_notes WHERE workspace_id = ?1", + let existing = { + let mut existing_statement = transaction + .prepare("SELECT id, entity_json FROM ghost_notes WHERE workspace_id = ?1")?; + let existing_rows = existing_statement + .query_map(params![workspace_id.clone()], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + existing_rows.collect::, _>>()? + }; + transaction.execute( + "DELETE FROM ghost_notes WHERE workspace_id = ?1", + params![workspace_id], )?; - let existing_rows = existing_statement.query_map(params![workspace_id.clone()], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })?; - let existing = existing_rows.collect::, _>>()?; - transaction.execute("DELETE FROM ghost_notes WHERE workspace_id = ?1", params![workspace_id])?; for mut ghost in generated.clone() { if let Some((_, json)) = existing.iter().find(|(id, _)| *id == ghost.id) { @@ -537,7 +553,11 @@ impl ResearchRepository { .await } - pub async fn list_ghosts(&self, workspace_id: &str, include_hidden: bool) -> Result> { + pub async fn list_ghosts( + &self, + workspace_id: &str, + include_hidden: bool, + ) -> Result> { let workspace_id = workspace_id.to_string(); self.with_connection(move |connection| { let mut statement = connection.prepare( @@ -553,7 +573,10 @@ impl ResearchRepository { .map(|ghosts| { ghosts .into_iter() - .filter(|ghost| include_hidden || !matches!(ghost.visibility_state, GhostVisibilityState::Hidden)) + .filter(|ghost| { + include_hidden + || !matches!(ghost.visibility_state, GhostVisibilityState::Hidden) + }) .collect() }) }) @@ -612,7 +635,8 @@ impl ResearchRepository { params![ serde_json::to_string(&JobStatus::Queued)?, serde_json::to_string(&JobStatus::Failed)?, - i64::try_from(limit).map_err(|error| ResearchError::Validation(error.to_string()))?, + i64::try_from(limit) + .map_err(|error| ResearchError::Validation(error.to_string()))?, ], |row| row.get::<_, String>(0), )?; @@ -624,31 +648,36 @@ impl ResearchRepository { .await } - pub async fn list_jobs(&self, workspace_id: &str, job_kind: Option) -> Result> { + pub async fn list_jobs( + &self, + workspace_id: &str, + job_kind: Option, + ) -> Result> { let workspace_id = workspace_id.to_string(); self.with_connection(move |connection| { - let mut statement = if job_kind.is_some() { - connection.prepare( + let json_rows = if let Some(job_kind) = job_kind { + let mut statement = connection.prepare( "SELECT entity_json FROM pipeline_jobs WHERE workspace_id = ?1 AND job_kind = ?2 ORDER BY updated_at DESC", - )? + )?; + let rows = statement.query_map( + params![workspace_id, serde_json::to_string(&job_kind)?], + |row| row.get::<_, String>(0), + )?; + rows.collect::, _>>()? } else { - connection.prepare( + let mut statement = connection.prepare( "SELECT entity_json FROM pipeline_jobs WHERE workspace_id = ?1 ORDER BY updated_at DESC", - )? + )?; + let rows = + statement.query_map(params![workspace_id], |row| row.get::<_, String>(0))?; + rows.collect::, _>>()? }; - let rows = if let Some(job_kind) = job_kind { - statement.query_map( - params![workspace_id, serde_json::to_string(&job_kind)?], - |row| row.get::<_, String>(0), - )? - } else { - statement.query_map(params![workspace_id], |row| row.get::<_, String>(0))? - }; - rows.collect::, _>>()? + + json_rows .into_iter() .map(|json| serde_json::from_str(&json).map_err(ResearchError::from)) .collect() @@ -712,7 +741,10 @@ impl ResearchRepository { .await } - pub async fn list_audit_events_for_workspace(&self, workspace_id: &str) -> Result> { + pub async fn list_audit_events_for_workspace( + &self, + workspace_id: &str, + ) -> Result> { let workspace_id = workspace_id.to_string(); self.with_connection(move |connection| { let mut statement = connection.prepare( @@ -868,7 +900,9 @@ mod tests { use tempfile::tempdir; use super::ResearchRepository; - use crate::research::types::{ResearchStage, ResearchWorkspace, WorkspaceScope, WorkspaceViewKind}; + use crate::research::types::{ + ResearchStage, ResearchWorkspace, WorkspaceScope, WorkspaceViewKind, + }; #[tokio::test] async fn new_should_initialize_schema() { diff --git a/MosaicIQ/src-tauri/src/research/service.rs b/MosaicIQ/src-tauri/src/research/service.rs new file mode 100644 index 0000000..c0fd2bc --- /dev/null +++ b/MosaicIQ/src-tauri/src/research/service.rs @@ -0,0 +1,1088 @@ +//! Public orchestration service for the research subsystem. + +use std::sync::Arc; + +use serde_json::Value; +use tauri::{AppHandle, Runtime}; + +use crate::agent::{default_task_defaults, AgentSettingsService, TaskProfile}; +use crate::research::ai::{build_model_info, DeterministicResearchAiGateway, ResearchAiGateway}; +use crate::research::errors::{ResearchError, Result}; +use crate::research::events::ResearchEventEmitter; +use crate::research::export::export_bundle; +use crate::research::ghosts::generate_ghost_notes; +use crate::research::grounding::{ + build_source_record, build_source_reference_note, refresh_source_metadata, + source_excerpt_from_input, +}; +use crate::research::heuristics::{classify_note, derive_title, detect_urls, extract_tickers}; +use crate::research::links::infer_links; +use crate::research::pipeline::ResearchPipeline; +use crate::research::projections::{build_memo_blocks, build_workspace_projection}; +use crate::research::repository::ResearchRepository; +use crate::research::types::{ + ArchiveResearchNoteRequest, AuditActor, AuditEntityKind, AuditEvent, CaptureMethod, + CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, EvidenceStatus, + ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetWorkspaceProjectionRequest, + GhostLifecycleState, GhostReviewAction, GhostStatus, LinkOrigin, ListNoteLinksRequest, + ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, NoteAuditTrail, NoteCaptureResult, + NotePriority, NoteType, PromoteNoteToThesisRequest, ProvenanceActor, ResearchBundleExport, + ResearchNote, ResearchWorkspace, RetryResearchJobsRequest, ReviewGhostNoteRequest, SourceKind, + ThesisStatus, UpdateResearchNoteRequest, WorkspaceProjection, WorkspaceScope, +}; +use crate::research::util::{generate_id, normalize_text, now_rfc3339, sha256_hex}; + +pub struct ResearchService { + repository: Arc, + pipeline: Arc, + ai_gateway: Arc, + emitter: ResearchEventEmitter, + settings: AgentSettingsService, +} + +impl Clone for ResearchService { + fn clone(&self) -> Self { + Self { + repository: self.repository.clone(), + pipeline: self.pipeline.clone(), + ai_gateway: self.ai_gateway.clone(), + emitter: self.emitter.clone(), + settings: self.settings.clone(), + } + } +} + +impl ResearchService { + pub fn new(app_handle: &AppHandle, db_path: std::path::PathBuf) -> Result { + Self::new_with_ai_gateway( + app_handle, + db_path, + Arc::new(DeterministicResearchAiGateway), + ) + } + + pub fn new_with_ai_gateway( + app_handle: &AppHandle, + db_path: std::path::PathBuf, + ai_gateway: Arc, + ) -> Result { + let repository = Arc::new(ResearchRepository::new(db_path)?); + Ok(Self { + pipeline: Arc::new(ResearchPipeline::new(repository.clone())), + repository, + ai_gateway, + emitter: ResearchEventEmitter::new(app_handle), + settings: AgentSettingsService::new(app_handle), + }) + } + + pub async fn create_workspace( + &self, + request: CreateResearchWorkspaceRequest, + ) -> Result { + let now = now_rfc3339(); + let workspace = ResearchWorkspace { + id: generate_id("workspace"), + name: normalize_non_empty(&request.name, "workspace name")?, + primary_ticker: normalize_ticker(&request.primary_ticker)?, + scope: WorkspaceScope::SingleCompany, + stage: request.stage, + default_view: request.default_view, + pinned_note_ids: Vec::new(), + archived: false, + created_at: now.clone(), + updated_at: now, + }; + let saved = self.repository.create_workspace(workspace.clone()).await?; + self.record_audit( + &saved.id, + &saved.id, + AuditEntityKind::Workspace, + "workspace_created", + AuditActor::Analyst, + None, + None, + None, + Vec::new(), + ) + .await?; + self.emitter.workspace_updated(&saved); + Ok(saved) + } + + pub async fn list_workspaces(&self) -> Result> { + self.repository.list_workspaces().await + } + + pub async fn get_workspace(&self, workspace_id: &str) -> Result { + self.repository.get_workspace(workspace_id).await + } + + pub async fn capture_note( + &self, + request: CaptureResearchNoteRequest, + ) -> Result { + let workspace = self.repository.get_workspace(&request.workspace_id).await?; + let raw_text = normalize_non_empty(&request.raw_text, "note text")?; + let cleaned_text = normalize_text(&raw_text); + let source_kind = request + .source_ref + .as_ref() + .map(|source| source.kind) + .unwrap_or_else(|| { + if detect_urls(&raw_text).is_empty() { + SourceKind::Manual + } else { + SourceKind::Article + } + }); + let typing = classify_note(&cleaned_text, source_kind, request.user_note_type_override); + let now = now_rfc3339(); + let ticker = request + .ticker + .as_deref() + .map(normalize_ticker) + .transpose()? + .or_else(|| extract_tickers(&cleaned_text).into_iter().next()) + .or_else(|| Some(workspace.primary_ticker.clone())); + + let detected_source_ref = detect_urls(&raw_text).first().map(|url| { + crate::research::types::SourceReferenceInput { + kind: SourceKind::Article, + title: None, + url: Some(url.clone()), + published_at: None, + filing_accession: None, + form_type: None, + excerpt_text: None, + location_label: None, + } + }); + + let mut source_id = None; + if let Some(source_ref) = request.source_ref.as_ref().or(detected_source_ref.as_ref()) { + let checksum = source_ref.url.as_deref().map(sha256_hex); + let existing = self + .repository + .find_source_by_checksum_or_accession( + &workspace.id, + checksum.as_deref(), + source_ref.filing_accession.as_deref(), + ) + .await?; + let source = if let Some(existing) = existing { + existing + } else { + self.repository + .save_source(build_source_record( + &workspace.id, + ticker.as_deref(), + source_ref, + )) + .await? + }; + source_id = Some(source.id.clone()); + if self + .repository + .find_source_reference_note(&workspace.id, &source.id) + .await? + .is_none() + { + let source_note = build_source_reference_note( + &workspace.id, + ticker.as_deref(), + &source, + source_excerpt_from_input(&source.id, source_ref).as_ref(), + ); + self.repository.create_note(source_note.clone()).await?; + self.record_audit( + &workspace.id, + &source_note.id, + AuditEntityKind::Note, + "source_reference_note_created", + AuditActor::System, + None, + Some(source_note.revision), + None, + source_note.source_id.iter().cloned().collect(), + ) + .await?; + } + } + + let note = ResearchNote { + id: generate_id("note"), + workspace_id: workspace.id.clone(), + company_id: request.company_id, + ticker, + source_id: source_id.clone(), + raw_text: raw_text.clone(), + cleaned_text: cleaned_text.clone(), + title: derive_title(&cleaned_text, typing.note_type), + note_type: typing.note_type, + subtype: typing.subtype, + analyst_status: crate::research::types::AnalystStatus::Captured, + ai_annotation: None, + confidence: typing.confidence, + evidence_status: match source_kind { + SourceKind::Transcript if matches!(typing.note_type, NoteType::Quote) => { + EvidenceStatus::Quoted + } + SourceKind::Manual if source_id.is_none() => EvidenceStatus::Unsourced, + _ if source_id.is_some() => EvidenceStatus::SourceLinked, + _ => EvidenceStatus::Unsourced, + }, + inferred_links: Vec::new(), + ghost_status: GhostStatus::CandidateInput, + thesis_status: ThesisStatus::None, + created_at: now.clone(), + updated_at: now.clone(), + provenance: crate::research::types::NoteProvenance { + created_by: ProvenanceActor::Manual, + capture_method: capture_method_from_source_kind(source_kind), + source_kind, + origin_note_id: None, + origin_ghost_id: None, + model_info: None, + created_at: now, + raw_input_hash: sha256_hex(&raw_text), + }, + tags: typing.tags, + catalysts: typing.catalysts, + risks: typing.risks, + valuation_refs: typing.valuation_refs, + time_horizon: typing.time_horizon, + scenario: typing.scenario, + priority: typing.priority, + pinned: false, + archived: false, + revision: 1, + source_excerpt: request.source_ref.as_ref().and_then(|source_ref| { + source_excerpt_from_input(source_id.as_deref().unwrap_or_default(), source_ref) + }), + last_enriched_at: None, + last_linked_at: None, + stale_reason: None, + superseded_by_note_id: None, + }; + + let saved = self.repository.create_note(note).await?; + let queued_jobs = self + .pipeline + .enqueue_capture_jobs(&workspace.id, &saved.id, saved.revision, source_id.clone()) + .await?; + self.record_audit( + &workspace.id, + &saved.id, + AuditEntityKind::Note, + "note_captured", + AuditActor::Analyst, + None, + Some(saved.revision), + None, + source_id.iter().cloned().collect(), + ) + .await?; + for job in &queued_jobs { + self.record_audit( + &workspace.id, + &job.id, + AuditEntityKind::Job, + "job_enqueued", + AuditActor::System, + None, + None, + Some(job.id.clone()), + Vec::new(), + ) + .await?; + self.emitter.job_updated(job); + } + + let placement_hint = crate::research::types::PlacementHint { + tile_lane: placement_lane(saved.priority), + kanban_column: format!("{:?}", saved.note_type).to_ascii_lowercase(), + graph_anchor_note_id: source_id, + }; + self.emitter.note_updated(&saved); + self.kick_job_processor(); + + Ok(NoteCaptureResult { + note: saved, + placement_hint, + queued_jobs, + }) + } + + pub async fn update_note(&self, request: UpdateResearchNoteRequest) -> Result { + let mut note = self.repository.get_note(&request.note_id).await?; + let prior_revision = note.revision; + + if let Some(raw_text) = request.raw_text.as_deref() { + let raw_text = normalize_non_empty(raw_text, "note text")?; + note.raw_text = raw_text.clone(); + note.cleaned_text = normalize_text(&raw_text); + let typing = classify_note( + ¬e.cleaned_text, + note.provenance.source_kind, + request.note_type.or(Some(note.note_type)), + ); + note.note_type = request.note_type.unwrap_or(typing.note_type); + note.subtype = request.subtype.or(typing.subtype); + note.title = derive_title(¬e.cleaned_text, note.note_type); + note.tags = typing.tags; + note.catalysts = typing.catalysts; + note.risks = typing.risks; + note.valuation_refs = typing.valuation_refs; + note.time_horizon = typing.time_horizon; + note.scenario = typing.scenario; + note.priority = request.priority.unwrap_or(typing.priority); + note.stale_reason = Some(crate::research::types::StaleReason::NoteUpdated); + note.ai_annotation = None; + } + + if let Some(title) = request.title { + note.title = Some(title); + } + if let Some(note_type) = request.note_type { + note.note_type = note_type; + } + if let Some(analyst_status) = request.analyst_status { + note.analyst_status = analyst_status; + } + if let Some(pinned) = request.pinned { + note.pinned = pinned; + } + if let Some(priority) = request.priority { + note.priority = priority; + } + if let Some(thesis_status) = request.thesis_status { + note.thesis_status = thesis_status; + } + + note.revision += 1; + note.updated_at = now_rfc3339(); + note.last_enriched_at = None; + note.last_linked_at = None; + let saved = self.repository.save_note(note).await?; + let queued_jobs = self + .pipeline + .enqueue_capture_jobs(&saved.workspace_id, &saved.id, saved.revision, None) + .await?; + self.record_audit( + &saved.workspace_id, + &saved.id, + AuditEntityKind::Note, + "note_updated", + AuditActor::Analyst, + Some(prior_revision), + Some(saved.revision), + None, + saved.source_id.iter().cloned().collect(), + ) + .await?; + self.emitter.note_updated(&saved); + for job in &queued_jobs { + self.emitter.job_updated(job); + } + self.kick_job_processor(); + Ok(saved) + } + + pub async fn archive_note(&self, request: ArchiveResearchNoteRequest) -> Result { + let note = self + .repository + .archive_note(&request.note_id, request.archived) + .await?; + self.record_audit( + ¬e.workspace_id, + ¬e.id, + AuditEntityKind::Note, + if request.archived { + "note_archived" + } else { + "note_unarchived" + }, + AuditActor::Analyst, + Some(note.revision), + Some(note.revision), + None, + note.source_id.iter().cloned().collect(), + ) + .await?; + self.emitter.note_updated(¬e); + Ok(note) + } + + pub async fn list_notes(&self, request: ListResearchNotesRequest) -> Result> { + self.repository + .list_notes( + &request.workspace_id, + request.include_archived.unwrap_or(false), + request.note_type, + ) + .await + } + + pub async fn get_workspace_projection( + &self, + request: GetWorkspaceProjectionRequest, + ) -> Result { + let workspace = self.repository.get_workspace(&request.workspace_id).await?; + let notes = self + .repository + .list_notes(&request.workspace_id, false, None) + .await?; + let links = self + .repository + .list_links(&request.workspace_id, None) + .await?; + let ghosts = self + .repository + .list_ghosts(&request.workspace_id, false) + .await?; + Ok(build_workspace_projection( + workspace.clone(), + request.view.unwrap_or(workspace.default_view), + notes, + links, + ghosts, + )) + } + + pub async fn list_note_links( + &self, + request: ListNoteLinksRequest, + ) -> Result> { + self.repository + .list_links(&request.workspace_id, request.note_id.as_deref()) + .await + } + + pub async fn list_workspace_ghost_notes( + &self, + request: ListWorkspaceGhostNotesRequest, + ) -> Result> { + self.repository + .list_ghosts( + &request.workspace_id, + request.include_hidden.unwrap_or(false), + ) + .await + } + + pub async fn review_ghost_note( + &self, + request: ReviewGhostNoteRequest, + ) -> Result { + let mut ghost = self.repository.get_ghost(&request.ghost_note_id).await?; + ghost.updated_at = now_rfc3339(); + match request.action { + GhostReviewAction::Accept => { + ghost.state = GhostLifecycleState::Accepted; + ghost.visibility_state = crate::research::types::GhostVisibilityState::Visible; + } + GhostReviewAction::Ignore => { + ghost.state = GhostLifecycleState::Ignored; + ghost.visibility_state = crate::research::types::GhostVisibilityState::Collapsed; + } + GhostReviewAction::Dismiss => { + ghost.state = GhostLifecycleState::Dismissed; + ghost.visibility_state = crate::research::types::GhostVisibilityState::Hidden; + } + GhostReviewAction::Pin => { + ghost.visibility_state = crate::research::types::GhostVisibilityState::Pinned; + } + } + let saved = self.repository.save_ghost(ghost).await?; + self.record_audit( + &saved.workspace_id, + &saved.id, + AuditEntityKind::Ghost, + "ghost_reviewed", + AuditActor::Analyst, + None, + None, + None, + saved.source_ids.clone(), + ) + .await?; + self.emitter.ghost_updated(&saved); + Ok(saved) + } + + pub async fn promote_note_to_thesis( + &self, + request: PromoteNoteToThesisRequest, + ) -> Result { + let mut note = self.repository.get_note(&request.note_id).await?; + let prior_revision = note.revision; + note.note_type = request.note_type.unwrap_or(NoteType::Thesis); + note.thesis_status = request.thesis_status.unwrap_or(ThesisStatus::AcceptedCore); + note.analyst_status = crate::research::types::AnalystStatus::Accepted; + note.revision += 1; + note.updated_at = now_rfc3339(); + let saved = self.repository.save_note(note).await?; + self.record_audit( + &saved.workspace_id, + &saved.id, + AuditEntityKind::Note, + "note_promoted_to_thesis", + AuditActor::Analyst, + Some(prior_revision), + Some(saved.revision), + None, + saved.source_id.iter().cloned().collect(), + ) + .await?; + self.emitter.note_updated(&saved); + Ok(saved) + } + + pub async fn retry_research_jobs( + &self, + request: RetryResearchJobsRequest, + ) -> Result> { + let jobs = self + .repository + .retry_failed_jobs(&request.workspace_id, request.job_kind) + .await?; + for job in &jobs { + self.emitter.job_updated(job); + } + self.kick_job_processor(); + Ok(jobs) + } + + pub async fn get_note_audit_trail( + &self, + request: GetNoteAuditTrailRequest, + ) -> Result { + let note = self.repository.get_note(&request.note_id).await?; + let links = self + .repository + .list_links(¬e.workspace_id, Some(¬e.id)) + .await?; + let ghosts = self + .repository + .list_ghosts(¬e.workspace_id, true) + .await? + .into_iter() + .filter(|ghost| { + ghost.supporting_note_ids.contains(¬e.id) + || ghost.contradicting_note_ids.contains(¬e.id) + }) + .collect::>(); + let mut source_ids = note.source_id.iter().cloned().collect::>(); + source_ids.extend(ghosts.iter().flat_map(|ghost| ghost.source_ids.clone())); + let sources = self.repository.list_sources_by_ids(&source_ids).await?; + let audit_events = self + .repository + .list_audit_events_for_entity(¬e.id) + .await?; + let memo_blocks = build_memo_blocks(std::slice::from_ref(¬e), &ghosts); + + Ok(NoteAuditTrail { + note, + links, + related_ghosts: ghosts, + sources, + audit_events, + memo_blocks, + }) + } + + pub async fn export_research_bundle( + &self, + request: ExportResearchBundleRequest, + ) -> Result { + let workspace = self.repository.get_workspace(&request.workspace_id).await?; + let notes = self + .repository + .list_notes(&workspace.id, true, None) + .await?; + let links = self.repository.list_links(&workspace.id, None).await?; + let ghosts = self.repository.list_ghosts(&workspace.id, true).await?; + let sources = self.repository.list_sources(&workspace.id).await?; + let audit_events = self + .repository + .list_audit_events_for_workspace(&workspace.id) + .await?; + Ok(export_bundle( + workspace, + notes, + links, + ghosts, + sources, + audit_events, + )) + } + + pub async fn process_due_jobs(&self) -> Result<()> { + let jobs = self.pipeline.due_jobs(16).await?; + for job in jobs { + let running = self.pipeline.mark_running(job).await?; + self.emitter.job_updated(&running); + let result = self.process_job(&running).await; + match result { + Ok(()) => { + let completed = self.pipeline.mark_completed(running).await?; + self.emitter.job_updated(&completed); + } + Err(error) => { + let failed = if running.attempt_count >= running.max_attempts { + self.pipeline + .mark_skipped(running, &error.to_string()) + .await? + } else { + self.pipeline + .mark_failed(running, &error.to_string()) + .await? + }; + self.emitter.job_updated(&failed); + } + } + } + Ok(()) + } + + fn kick_job_processor(&self) { + if tokio::runtime::Handle::try_current().is_err() { + return; + } + + let service = (*self).clone(); + tauri::async_runtime::spawn(async move { + let _ = service.process_due_jobs().await; + }); + } + + async fn process_job(&self, job: &crate::research::types::PipelineJob) -> Result<()> { + match job.job_kind { + crate::research::types::JobKind::EnrichNote => self.process_enrich_note(job).await, + crate::research::types::JobKind::InferLinks => self.process_infer_links(job).await, + crate::research::types::JobKind::EvaluateDuplicates => Ok(()), + crate::research::types::JobKind::EvaluateGhosts => { + self.process_evaluate_ghosts(job).await + } + crate::research::types::JobKind::RefreshSourceMetadata => { + self.process_refresh_source(job).await + } + crate::research::types::JobKind::RecalculateStaleness => Ok(()), + } + } + + async fn process_enrich_note(&self, job: &crate::research::types::PipelineJob) -> Result<()> { + let note_id = payload_required_str(&job.payload_json, "noteId")?; + let expected_revision = payload_required_u32(&job.payload_json, "expectedRevision")?; + let mut note = self.repository.get_note(¬e_id).await?; + if note.revision != expected_revision { + return Err(ResearchError::Validation(format!( + "note revision changed from {expected_revision} to {}", + note.revision + ))); + } + + let model_info = self.model_info_for(TaskProfile::NoteEnrichment); + let enrichment = self.ai_gateway.enrich_note(¬e, model_info.clone()); + if let Some(note_type) = enrichment.note_type { + note.note_type = note_type; + } + note.ai_annotation = enrichment.annotation; + note.tags = merge_unique(note.tags, enrichment.tags); + note.risks = merge_unique(note.risks, enrichment.risks); + note.catalysts = merge_unique(note.catalysts, enrichment.catalysts); + note.valuation_refs = if note.valuation_refs.is_empty() { + enrichment.valuation_refs + } else { + note.valuation_refs + }; + note.last_enriched_at = Some(now_rfc3339()); + note.provenance.model_info = model_info; + if note.source_id.is_some() && matches!(note.evidence_status, EvidenceStatus::Unsourced) { + note.evidence_status = EvidenceStatus::SourceLinked; + } else if enrichment.missing_evidence { + note.evidence_status = EvidenceStatus::Inferred; + } + let saved = self.repository.save_note(note).await?; + self.record_audit( + &saved.workspace_id, + &saved.id, + AuditEntityKind::Note, + "note_enriched", + AuditActor::Ai, + Some(expected_revision), + Some(saved.revision), + Some(job.id.clone()), + saved.source_id.iter().cloned().collect(), + ) + .await?; + self.emitter.note_updated(&saved); + Ok(()) + } + + async fn process_infer_links(&self, job: &crate::research::types::PipelineJob) -> Result<()> { + let workspace_id = payload_required_str(&job.payload_json, "workspaceId")?; + let notes = self + .repository + .list_notes(&workspace_id, false, None) + .await?; + let links = infer_links(¬es); + let saved_links = self + .repository + .replace_links_for_workspace(&workspace_id, links.clone()) + .await?; + let now = now_rfc3339(); + let mut note_map = notes + .into_iter() + .map(|mut note| { + note.inferred_links = saved_links + .iter() + .filter(|link| link.from_note_id == note.id || link.to_note_id == note.id) + .map(|link| link.id.clone()) + .collect(); + note.last_linked_at = Some(now.clone()); + (note.id.clone(), note) + }) + .collect::>(); + for note in note_map.values_mut() { + self.repository.save_note(note.clone()).await?; + } + for link in &saved_links { + self.record_audit( + &workspace_id, + &link.id, + AuditEntityKind::Link, + "link_inferred", + match link.created_by { + LinkOrigin::Ai => AuditActor::Ai, + _ => AuditActor::System, + }, + None, + None, + Some(job.id.clone()), + Vec::new(), + ) + .await?; + } + Ok(()) + } + + async fn process_evaluate_ghosts( + &self, + job: &crate::research::types::PipelineJob, + ) -> Result<()> { + let workspace_id = payload_required_str(&job.payload_json, "workspaceId")?; + let workspace = self.repository.get_workspace(&workspace_id).await?; + let notes = self + .repository + .list_notes(&workspace_id, false, None) + .await?; + let links = self.repository.list_links(&workspace_id, None).await?; + let ghosts = generate_ghost_notes(&workspace, ¬es, &links); + let saved_ghosts = self + .repository + .replace_ghosts_for_workspace(&workspace_id, ghosts) + .await?; + for ghost in &saved_ghosts { + self.record_audit( + &workspace_id, + &ghost.id, + AuditEntityKind::Ghost, + "ghost_generated", + AuditActor::Ai, + None, + None, + Some(job.id.clone()), + ghost.source_ids.clone(), + ) + .await?; + self.emitter.ghost_updated(ghost); + } + self.emitter.workspace_updated(&workspace); + Ok(()) + } + + async fn process_refresh_source( + &self, + job: &crate::research::types::PipelineJob, + ) -> Result<()> { + let source_id = payload_required_str(&job.payload_json, "sourceId")?; + let source = self + .repository + .list_sources_by_ids(std::slice::from_ref(&source_id)) + .await? + .into_iter() + .next() + .ok_or_else(|| ResearchError::Validation(format!("unknown source {source_id}")))?; + let refreshed = refresh_source_metadata(&source).await?; + self.repository.save_source(refreshed.clone()).await?; + self.record_audit( + &refreshed.workspace_id, + &refreshed.id, + AuditEntityKind::Source, + "source_metadata_refreshed", + AuditActor::System, + None, + None, + Some(job.id.clone()), + vec![refreshed.id.clone()], + ) + .await?; + Ok(()) + } + + fn model_info_for( + &self, + task_profile: TaskProfile, + ) -> Option { + let Ok(settings) = self.settings.load() else { + return None; + }; + let defaults = if settings.task_defaults.is_empty() { + default_task_defaults(&settings.default_remote_model) + } else { + settings.task_defaults + }; + defaults + .get(&task_profile) + .map(|route| build_model_info(&route.model, &format!("{task_profile:?}"))) + .flatten() + } + + async fn record_audit( + &self, + workspace_id: &str, + entity_id: &str, + entity_kind: AuditEntityKind, + action: &str, + actor: AuditActor, + prior_revision: Option, + new_revision: Option, + job_id: Option, + source_ids: Vec, + ) -> Result<()> { + self.repository + .append_audit_event(AuditEvent { + id: generate_id("audit"), + workspace_id: workspace_id.to_string(), + entity_id: entity_id.to_string(), + entity_kind, + action: action.to_string(), + actor, + prior_revision, + new_revision, + job_id, + source_ids, + detail: None, + created_at: now_rfc3339(), + }) + .await?; + Ok(()) + } +} + +fn payload_required_str(payload: &Value, key: &str) -> Result { + payload + .get(key) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| ResearchError::Validation(format!("missing payload field {key}"))) +} + +fn payload_required_u32(payload: &Value, key: &str) -> Result { + payload + .get(key) + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| ResearchError::Validation(format!("missing payload field {key}"))) +} + +fn normalize_non_empty(input: &str, field_name: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(ResearchError::Validation(format!( + "{field_name} cannot be empty" + ))); + } + Ok(trimmed.to_string()) +} + +fn normalize_ticker(input: &str) -> Result { + normalize_non_empty(input, "ticker").map(|value| value.to_ascii_uppercase()) +} + +fn capture_method_from_source_kind(source_kind: SourceKind) -> CaptureMethod { + match source_kind { + SourceKind::Transcript => CaptureMethod::TranscriptClip, + SourceKind::Filing => CaptureMethod::FilingExtract, + SourceKind::NewsFeed | SourceKind::Article => CaptureMethod::NewsImport, + SourceKind::Model => CaptureMethod::ManualLink, + SourceKind::Manual => CaptureMethod::QuickEntry, + } +} + +fn placement_lane(priority: NotePriority) -> String { + match priority { + NotePriority::Critical => "critical", + NotePriority::High => "focus", + NotePriority::Normal => "active", + NotePriority::Low => "backlog", + } + .to_string() +} + +fn merge_unique(mut left: Vec, right: Vec) -> Vec { + for value in right { + if !left.contains(&value) { + left.push(value); + } + } + left +} + +#[cfg(test)] +mod tests { + use std::env; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime}; + + use super::ResearchService; + use crate::research::types::{ + CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, NoteType, ResearchStage, + WorkspaceViewKind, + }; + + fn build_app() -> tauri::App { + mock_builder() + .plugin(tauri_plugin_store::Builder::new().build()) + .build(mock_context(noop_assets())) + .unwrap() + } + + fn unique_identifier(prefix: &str) -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("com.mosaiciq.research.tests.{prefix}.{nanos}") + } + + fn with_test_home(prefix: &str, test: impl FnOnce() -> T) -> T { + let _lock = crate::test_support::env_lock().lock().unwrap(); + let home = env::temp_dir().join(unique_identifier(prefix)); + fs::create_dir_all(&home).unwrap(); + let original_home = env::var_os("HOME"); + env::set_var("HOME", &home); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(test)); + + if let Some(original_home) = original_home { + env::set_var("HOME", original_home); + } else { + env::remove_var("HOME"); + } + + result.unwrap() + } + + #[test] + fn capture_pipeline_should_return_note_and_jobs_immediately() { + with_test_home("capture", || { + let app = build_app(); + let service = ResearchService::new( + app.handle(), + env::temp_dir().join(unique_identifier("research.sqlite")), + ) + .unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let workspace = runtime + .block_on(service.create_workspace(CreateResearchWorkspaceRequest { + name: "Apple Research".to_string(), + primary_ticker: "AAPL".to_string(), + stage: ResearchStage::Capture, + default_view: WorkspaceViewKind::Canvas, + })) + .unwrap(); + + let result = runtime + .block_on(service.capture_note(CaptureResearchNoteRequest { + workspace_id: workspace.id, + raw_text: "Mgmt says enterprise demand improved sequentially and expects gross margin to exit above 70% next half.".to_string(), + company_id: None, + ticker: Some("AAPL".to_string()), + source_ref: None, + position_hint: None, + user_note_type_override: None, + })) + .unwrap(); + + assert_eq!(result.note.note_type, NoteType::ManagementSignal); + assert!(result.queued_jobs.len() >= 4); + }); + } + + #[test] + fn edited_note_should_cause_old_enrichment_job_to_fail_revision_check_and_preserve_new_note() { + with_test_home("revision", || { + let app = build_app(); + let service = ResearchService::new( + app.handle(), + env::temp_dir().join(unique_identifier("research.sqlite")), + ) + .unwrap(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let workspace = runtime + .block_on(service.create_workspace(CreateResearchWorkspaceRequest { + name: "Apple Research".to_string(), + primary_ticker: "AAPL".to_string(), + stage: ResearchStage::Capture, + default_view: WorkspaceViewKind::Canvas, + })) + .unwrap(); + let captured = runtime + .block_on(service.capture_note(CaptureResearchNoteRequest { + workspace_id: workspace.id.clone(), + raw_text: "Initial claim about margin expansion.".to_string(), + company_id: None, + ticker: Some("AAPL".to_string()), + source_ref: None, + position_hint: None, + user_note_type_override: None, + })) + .unwrap(); + + let outdated_jobs = runtime + .block_on(service.retry_research_jobs( + crate::research::types::RetryResearchJobsRequest { + workspace_id: workspace.id.clone(), + job_kind: Some(crate::research::types::JobKind::EnrichNote), + }, + )) + .unwrap(); + assert!(outdated_jobs.is_empty()); + + let updated = runtime + .block_on( + service.update_note(crate::research::types::UpdateResearchNoteRequest { + note_id: captured.note.id.clone(), + raw_text: Some( + "Updated claim about margin expansion with a new framing.".to_string(), + ), + title: None, + note_type: None, + subtype: None, + analyst_status: None, + pinned: None, + priority: None, + thesis_status: None, + }), + ) + .unwrap(); + + assert_eq!(updated.revision, 2); + }); + } +} diff --git a/MosaicIQ/src-tauri/src/research/types.rs b/MosaicIQ/src-tauri/src/research/types.rs index 0eff5fb..53122b7 100644 --- a/MosaicIQ/src-tauri/src/research/types.rs +++ b/MosaicIQ/src-tauri/src/research/types.rs @@ -171,6 +171,7 @@ pub enum FreshnessBucket { Stale, } +#[allow(dead_code)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] pub enum EvidenceStrength { @@ -848,4 +849,3 @@ pub struct GetNoteAuditTrailRequest { pub struct ExportResearchBundleRequest { pub workspace_id: String, } - diff --git a/MosaicIQ/src-tauri/src/research/util.rs b/MosaicIQ/src-tauri/src/research/util.rs index bcad89a..1238d0e 100644 --- a/MosaicIQ/src-tauri/src/research/util.rs +++ b/MosaicIQ/src-tauri/src/research/util.rs @@ -16,7 +16,11 @@ pub(crate) fn now_timestamp() -> i64 { } pub(crate) fn generate_id(prefix: &str) -> String { - format!("{prefix}-{}-{}", now_timestamp(), NEXT_ID.fetch_add(1, Ordering::Relaxed)) + format!( + "{prefix}-{}-{}", + now_timestamp(), + NEXT_ID.fetch_add(1, Ordering::Relaxed) + ) } pub(crate) fn sha256_hex(input: &str) -> String { @@ -35,7 +39,10 @@ pub(crate) fn clean_title(input: &str, max_len: usize) -> String { return normalized; } - let mut shortened = normalized.chars().take(max_len.saturating_sub(1)).collect::(); + let mut shortened = normalized + .chars() + .take(max_len.saturating_sub(1)) + .collect::(); shortened.push('…'); shortened } diff --git a/MosaicIQ/src-tauri/src/state.rs b/MosaicIQ/src-tauri/src/state.rs index 5b6b78f..32e3329 100644 --- a/MosaicIQ/src-tauri/src/state.rs +++ b/MosaicIQ/src-tauri/src/state.rs @@ -11,6 +11,7 @@ use crate::agent::{AgentService, AgentSettingsService}; use crate::error::AppError; use crate::news::NewsService; use crate::portfolio::PortfolioService; +use crate::research::ResearchService; use crate::terminal::google_finance::GoogleFinanceLookup; use crate::terminal::sec_edgar::{ LiveSecFetcher, SecEdgarClient, SecEdgarLookup, SecUserAgentProvider, @@ -100,6 +101,8 @@ pub struct AppState { pub news_service: Arc, /// Slash-command executor backed by shared services. pub command_service: Arc, + /// Equity research note, graph, and ghost writer runtime. + pub research_service: Arc>, /// Pending approvals for agent-triggered mutating commands. pub pending_agent_tool_approvals: Arc, } @@ -131,6 +134,17 @@ impl AppState { ) .map_err(|error| AppError::InvalidSettings(error.to_string()))?, ); + let research_root = app_handle + .path() + .app_data_dir() + .map_err(|_| { + AppError::InvalidSettings("research app data directory is unavailable".to_string()) + })? + .join("research"); + let research_service = Arc::new( + ResearchService::new(app_handle, research_root.join("research.sqlite")) + .map_err(|error| AppError::InvalidSettings(error.to_string()))?, + ); Ok(Self { agent: AsyncMutex::new(AgentService::new(app_handle)?), @@ -141,6 +155,7 @@ impl AppState { portfolio_service, news_service, )), + research_service, pending_agent_tool_approvals: Arc::new(PendingAgentToolApprovals::new()), }) } diff --git a/MosaicIQ/src/App.css b/MosaicIQ/src/App.css index f1d2682..d7cbe2b 100644 --- a/MosaicIQ/src/App.css +++ b/MosaicIQ/src/App.css @@ -61,6 +61,19 @@ --padding-sm: 0.5rem; --padding-md: 1rem; --padding-lg: 1.5rem; + + /* Research mode */ + --research-bg: #0b1016; + --research-surface: #0f141b; + --research-panel: #121923; + --research-grid: rgba(98, 123, 153, 0.12); + --research-accent-thesis: #8ab9f7; + --research-accent-risk: #ff8f9e; + --research-accent-catalyst: #7fe2b5; + --research-accent-evidence: #7fc0ff; + --research-accent-ghost: #d2b6ff; + --research-text-strong: #eef4ff; + --research-text-muted: #98aec6; } * { @@ -77,6 +90,16 @@ body { -moz-osx-font-smoothing: grayscale; } +:root[data-density='compact'] { + --padding-md: 0.75rem; + --padding-lg: 1rem; +} + +:root[data-density='detailed'] { + --padding-md: 1.125rem; + --padding-lg: 1.75rem; +} + #root { width: 100%; height: 100vh; @@ -160,6 +183,13 @@ body { transition: all 0.15s ease-in-out; } +.research-shell { + background-image: + linear-gradient(to right, transparent 0, transparent calc(100% - 1px), var(--research-grid) calc(100% - 1px)), + linear-gradient(to bottom, transparent 0, transparent calc(100% - 1px), var(--research-grid) calc(100% - 1px)); + background-size: 24px 24px; +} + /* Custom width for sidebar (Tailwind w-70 equivalent) */ .w-70 { width: 17.5rem; /* 280px */ @@ -234,4 +264,3 @@ body { .space-y-section-sm > * + * { margin-top: var(--section-vertical-sm); } - diff --git a/MosaicIQ/src/App.tsx b/MosaicIQ/src/App.tsx index 9af7c33..4c56321 100644 --- a/MosaicIQ/src/App.tsx +++ b/MosaicIQ/src/App.tsx @@ -1,31 +1,55 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { Sidebar } from './components/Sidebar/Sidebar'; import { ConfirmDialog } from './components/Settings/ConfirmDialog'; -import { SettingsPage } from './components/Settings/SettingsPage'; import { TabBar } from './components/TabBar/TabBar'; import { Terminal } from './components/Terminal/Terminal'; import { useAppShortcuts } from './hooks/useAppShortcuts'; import { usePortfolioWorkflow } from './hooks/usePortfolioWorkflow'; +import { useResearchCaptureFlow } from './hooks/useResearchCaptureFlow'; +import type { ResearchComposerState } from './hooks/useResearchComposer'; +import { useResearchWorkspaces } from './hooks/useResearchWorkspaces'; import { useTabs } from './hooks/useTabs'; +import { createEntry } from './hooks/useTerminal'; import { useTerminalOrchestrator } from './hooks/useTerminalOrchestrator'; import { useTickerHistory } from './hooks/useTickerHistory'; import { agentSettingsBridge } from './lib/agentSettingsBridge'; import { terminalBridge } from './lib/terminalBridge'; import { AgentConfigStatus } from './types/agentSettings'; +import { + NoteCaptureResult, + ResearchNavigationIntent, +} from './types/research'; import './App.css'; -type AppView = 'terminal' | 'settings'; +type AppView = 'terminal' | 'research' | 'settings'; + +const ResearchMode = React.lazy(() => + import('./components/Research/ResearchMode').then((module) => ({ + default: module.ResearchMode, + })), +); + +const SettingsPage = React.lazy(() => + import('./components/Settings/SettingsPage').then((module) => ({ + default: module.SettingsPage, + })), +); function App() { const tabs = useTabs(); const tickerHistory = useTickerHistory(); const portfolioWorkflow = usePortfolioWorkflow(); const [activeView, setActiveView] = React.useState('terminal'); + const [researchIntent, setResearchIntent] = React.useState(null); const [agentStatus, setAgentStatus] = React.useState(null); const [sidebarOpen, setSidebarOpen] = React.useState(true); const outputRef = useRef(null); const hasAutoLoadedPortfolioRef = useRef(false); const activePortfolioWorkflow = portfolioWorkflow.readWorkflow(tabs.activeWorkspaceId); + const researchWorkspaces = useResearchWorkspaces(); + const { captureNote: captureResearchNote } = useResearchCaptureFlow({ + ensureWorkspace: researchWorkspaces.ensureWorkspace, + }); const refreshAgentStatus = useCallback(async () => { const status = await agentSettingsBridge.getStatus(); @@ -50,6 +74,38 @@ function App() { setActiveView('terminal'); }, []); + const handleEnsureResearchWorkspace = useCallback( + (request: { ticker?: string; workspaceId?: string | null }) => + researchWorkspaces.ensureWorkspace(request), + [researchWorkspaces], + ); + + const handleCaptureResearchNote = useCallback( + async (args: { + draft: ResearchComposerState; + fallbackTicker?: string; + explicitWorkspaceId?: string | null; + autoCreateFromTicker?: boolean; + }): Promise => { + const result = await captureResearchNote(args); + researchWorkspaces.selectWorkspace(result.note.workspaceId); + return result; + }, + [captureResearchNote, researchWorkspaces], + ); + + const handleOpenResearch = useCallback( + async (intent: ResearchNavigationIntent = {}) => { + const workspace = await researchWorkspaces.ensureWorkspaceForIntent(intent); + if (workspace) { + researchWorkspaces.selectWorkspace(workspace.id); + } + setResearchIntent(intent); + setActiveView('research'); + }, + [researchWorkspaces], + ); + const { commandInputRef, clearWorkspaceSession, @@ -79,6 +135,19 @@ function App() { tabs.createWorkspace(); }, [tabs]); + const appendTerminalSystemMessage = useCallback( + (message: string) => { + tabs.appendWorkspaceEntry( + tabs.activeWorkspaceId, + createEntry({ + type: 'system', + content: message, + }), + ); + }, + [tabs], + ); + useAppShortcuts({ activeView, activeWorkspaceId: tabs.activeWorkspaceId, @@ -87,6 +156,9 @@ function App() { onClearTerminal: clearTerminal, onCloseWorkspace: tabs.closeWorkspace, onCreateWorkspace: handleCreateWorkspace, + onOpenResearch: () => { + void handleOpenResearch(); + }, onOpenSettings: () => { void handleOpenSettings(); }, @@ -131,8 +203,12 @@ function App() { return (
{ + void handleOpenResearch(); + }} + onOpenTerminal={() => setActiveView('terminal')} onOpenSettings={() => { void handleOpenSettings(); }} @@ -143,25 +219,43 @@ function App() { isTickerHistoryLoaded={tickerHistory.isLoaded} /> -
- { - setActiveView('terminal'); - tabs.setActiveWorkspace(id); - }} - onTabClose={(id) => tabs.closeWorkspace(id)} - onNewTab={handleCreateWorkspace} - onTabRename={(id, name) => tabs.renameWorkspace(id, name)} - /> +
+ {activeView === 'terminal' ? ( + { + setActiveView('terminal'); + tabs.setActiveWorkspace(id); + }} + onTabClose={(id) => tabs.closeWorkspace(id)} + onNewTab={handleCreateWorkspace} + onTabRename={(id, name) => tabs.renameWorkspace(id, name)} + /> + ) : null} {activeView === 'settings' ? ( - + Loading settings...
}> + + + ) : activeView === 'research' ? ( + Loading research workspace...
}> + setResearchIntent(null)} + /> + ) : ( { + void handleOpenResearch(intent); + }} + onAppendSystemMessage={appendTerminalSystemMessage} /> )}
diff --git a/MosaicIQ/src/components/Panels/PortfolioPanel.tsx b/MosaicIQ/src/components/Panels/PortfolioPanel.tsx index 7d0ffb1..346a650 100644 --- a/MosaicIQ/src/components/Panels/PortfolioPanel.tsx +++ b/MosaicIQ/src/components/Panels/PortfolioPanel.tsx @@ -67,7 +67,7 @@ export const PortfolioPanel: React.FC = ({ ]; return ( -
+
@@ -135,8 +135,8 @@ export const PortfolioPanel: React.FC = ({
) : ( -
- +
+
diff --git a/MosaicIQ/src/components/Research/ResearchCaptureBar.tsx b/MosaicIQ/src/components/Research/ResearchCaptureBar.tsx new file mode 100644 index 0000000..8cecb51 --- /dev/null +++ b/MosaicIQ/src/components/Research/ResearchCaptureBar.tsx @@ -0,0 +1,266 @@ +import React, { useMemo } from 'react'; +import { ArrowUpRight, BookPlus, Plus } from 'lucide-react'; +import { useResearchComposer, type ResearchComposerState } from '../../hooks/useResearchComposer'; +import type { + NoteCaptureResult, + NoteType, + ResearchNote, + ResearchWorkspace, + SourceKind, +} from '../../types/research'; +import { NOTE_TYPE_LABELS } from './primitives/researchMeta'; + +interface ResearchCaptureBarProps { + workspaces: ResearchWorkspace[]; + defaultWorkspaceId?: string | null; + defaultTicker?: string; + defaultRawText?: string; + seedKey?: string; + contextLabel?: string; + onWorkspaceChange?: (workspaceId: string) => void; + onEnsureWorkspace?: (request: { + ticker?: string; + workspaceId?: string | null; + }) => Promise; + onSubmitCapture?: (draft: ResearchComposerState) => Promise; + onCaptured?: (note: ResearchNote) => void; + onOpenResearch?: () => void; +} + +const noteTypeOptions: NoteType[] = [ + 'fact', + 'management_signal', + 'claim', + 'risk', + 'catalyst', + 'valuation_point', + 'question', +]; + +const sourceKinds: SourceKind[] = ['manual', 'article', 'transcript', 'filing', 'news_feed', 'model']; + +export const ResearchCaptureBar: React.FC = ({ + workspaces, + defaultWorkspaceId, + defaultTicker, + defaultRawText, + seedKey, + contextLabel, + onWorkspaceChange, + onEnsureWorkspace, + onSubmitCapture, + onCaptured, + onOpenResearch, +}) => { + const composer = useResearchComposer({ + defaultWorkspaceId, + defaultTicker, + }); + const { draft, error, insertFormatting, isSubmitting, isValid, setDraft, submit } = composer; + + const hasWorkspace = workspaces.length > 0; + + React.useEffect(() => { + const patch: Partial = {}; + if (defaultWorkspaceId) { + patch.workspaceId = defaultWorkspaceId; + } + if (defaultTicker) { + patch.ticker = defaultTicker; + } + if (defaultRawText !== undefined) { + patch.rawText = defaultRawText; + } + if (Object.keys(patch).length > 0) { + setDraft(patch); + } + }, [defaultRawText, defaultTicker, defaultWorkspaceId, seedKey, setDraft]); + + const activeWorkspaceLabel = useMemo( + () => + workspaces.find((workspace) => workspace.id === draft.workspaceId)?.name ?? + 'Choose workspace', + [draft.workspaceId, workspaces], + ); + + const handleSubmit = async () => { + try { + const result = onSubmitCapture ? await onSubmitCapture(draft) : await submit(); + composer.resetDraft( + result.note.workspaceId, + result.note.ticker ?? draft.ticker, + ); + onWorkspaceChange?.(result.note.workspaceId); + onCaptured?.(result.note); + } catch (captureError) { + composer.setErrorMessage( + captureError instanceof Error ? captureError.message : String(captureError), + ); + } + }; + + const handleCreateWorkspace = async () => { + const workspace = await onEnsureWorkspace?.({ + ticker: draft.ticker || defaultTicker, + workspaceId: null, + }); + if (workspace) { + setDraft({ workspaceId: workspace.id }); + onWorkspaceChange?.(workspace.id); + } + }; + + return ( +
+
+
+
Research Capture
+
+ {contextLabel || (defaultTicker ? `Context: ${defaultTicker}` : 'Capture a research fragment')} +
+
+
+ + {onOpenResearch ? ( + + ) : null} +
+
+ +