Codex ended here and GLM took over wish me luck
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use rusqlite::types::Value;
|
use rusqlite::types::Value;
|
||||||
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
|
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
|
||||||
@@ -14,6 +15,7 @@ use crate::news::{NewsError, Result};
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct NewsRepository {
|
pub struct NewsRepository {
|
||||||
db_path: PathBuf,
|
db_path: PathBuf,
|
||||||
|
connections: Arc<Mutex<Vec<Connection>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NewsRepository {
|
impl NewsRepository {
|
||||||
@@ -22,9 +24,13 @@ impl NewsRepository {
|
|||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let repository = Self { db_path };
|
let connection = open_connection(&db_path)?;
|
||||||
let connection = repository.open_connection()?;
|
let repository = Self {
|
||||||
|
db_path,
|
||||||
|
connections: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
};
|
||||||
repository.initialize_schema(&connection)?;
|
repository.initialize_schema(&connection)?;
|
||||||
|
repository.store_connection(connection)?;
|
||||||
Ok(repository)
|
Ok(repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,13 +331,24 @@ impl NewsRepository {
|
|||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
{
|
{
|
||||||
let db_path = self.db_path.clone();
|
let db_path = self.db_path.clone();
|
||||||
|
let connections = self.connections.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut connection = open_connection(&db_path)?;
|
let mut connection = take_connection(&connections, &db_path)?;
|
||||||
task(&mut connection)
|
let result = task(&mut connection);
|
||||||
|
let store_result = store_connection(&connections, connection);
|
||||||
|
match (result, store_result) {
|
||||||
|
(Ok(value), Ok(())) => Ok(value),
|
||||||
|
(Err(error), Ok(())) => Err(error),
|
||||||
|
(Ok(_), Err(error)) | (Err(_), Err(error)) => Err(error),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|error| NewsError::Join(error.to_string()))?
|
.map_err(|error| NewsError::Join(error.to_string()))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn store_connection(&self, connection: Connection) -> Result<()> {
|
||||||
|
store_connection(&self.connections, connection)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_sources_in_connection(
|
fn sync_sources_in_connection(
|
||||||
@@ -388,6 +405,27 @@ fn open_connection(path: &Path) -> Result<Connection> {
|
|||||||
Ok(connection)
|
Ok(connection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn take_connection(pool: &Mutex<Vec<Connection>>, db_path: &Path) -> Result<Connection> {
|
||||||
|
{
|
||||||
|
let mut guard = pool.lock().map_err(|error| {
|
||||||
|
NewsError::Config(format!("news connection pool poisoned: {error}"))
|
||||||
|
})?;
|
||||||
|
if let Some(connection) = guard.pop() {
|
||||||
|
return Ok(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open_connection(db_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_connection(pool: &Mutex<Vec<Connection>>, connection: Connection) -> Result<()> {
|
||||||
|
let mut guard = pool
|
||||||
|
.lock()
|
||||||
|
.map_err(|error| NewsError::Config(format!("news connection pool poisoned: {error}")))?;
|
||||||
|
guard.push(connection);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn query_articles(
|
fn query_articles(
|
||||||
connection: &mut Connection,
|
connection: &mut Connection,
|
||||||
request: QueryNewsFeedRequest,
|
request: QueryNewsFeedRequest,
|
||||||
|
|||||||
@@ -66,13 +66,6 @@ impl ResearchPipeline {
|
|||||||
self.repository.enqueue_jobs(jobs).await
|
self.repository.enqueue_jobs(jobs).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mark_running(&self, mut job: PipelineJob) -> Result<PipelineJob> {
|
|
||||||
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) -> Result<PipelineJob> {
|
pub async fn mark_completed(&self, mut job: PipelineJob) -> Result<PipelineJob> {
|
||||||
job.status = JobStatus::Completed;
|
job.status = JobStatus::Completed;
|
||||||
job.last_error = None;
|
job.last_error = None;
|
||||||
@@ -92,13 +85,13 @@ impl ResearchPipeline {
|
|||||||
pub async fn mark_failed(&self, mut job: PipelineJob, error: &str) -> Result<PipelineJob> {
|
pub async fn mark_failed(&self, mut job: PipelineJob, error: &str) -> Result<PipelineJob> {
|
||||||
job.status = JobStatus::Failed;
|
job.status = JobStatus::Failed;
|
||||||
job.last_error = Some(error.to_string());
|
job.last_error = Some(error.to_string());
|
||||||
job.next_attempt_at = Some(next_retry_timestamp(job.attempt_count + 1));
|
job.next_attempt_at = Some(next_retry_timestamp(job.attempt_count));
|
||||||
job.updated_at = now_rfc3339();
|
job.updated_at = now_rfc3339();
|
||||||
self.repository.save_job(job).await
|
self.repository.save_job(job).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn due_jobs(&self, limit: usize) -> Result<Vec<PipelineJob>> {
|
pub async fn claim_due_jobs(&self, limit: usize) -> Result<Vec<PipelineJob>> {
|
||||||
self.repository.list_due_jobs(limit).await
|
self.repository.claim_due_jobs(limit).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
//! SQLite-backed persistence for research workspaces, notes, links, ghosts, and jobs.
|
//! SQLite-backed persistence for research workspaces, notes, links, ghosts, and jobs.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::types::Value as SqlValue;
|
||||||
|
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
|
||||||
|
|
||||||
use crate::research::errors::{ResearchError, Result};
|
use crate::research::errors::{ResearchError, Result};
|
||||||
use crate::research::types::{
|
use crate::research::types::{
|
||||||
@@ -13,6 +16,7 @@ use crate::research::types::{
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ResearchRepository {
|
pub struct ResearchRepository {
|
||||||
db_path: PathBuf,
|
db_path: PathBuf,
|
||||||
|
connections: Arc<Mutex<Vec<Connection>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResearchRepository {
|
impl ResearchRepository {
|
||||||
@@ -21,9 +25,13 @@ impl ResearchRepository {
|
|||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let repository = Self { db_path };
|
let connection = open_connection(&db_path)?;
|
||||||
let connection = repository.open_connection()?;
|
let repository = Self {
|
||||||
|
db_path,
|
||||||
|
connections: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
};
|
||||||
repository.initialize_schema(&connection)?;
|
repository.initialize_schema(&connection)?;
|
||||||
|
repository.store_connection(connection)?;
|
||||||
Ok(repository)
|
Ok(repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +185,64 @@ impl ResearchRepository {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn save_notes_batch(&self, notes: Vec<ResearchNote>) -> Result<()> {
|
||||||
|
self.with_connection(move |connection| {
|
||||||
|
let transaction = connection.transaction()?;
|
||||||
|
|
||||||
|
for note in ¬es {
|
||||||
|
transaction.execute(
|
||||||
|
"INSERT INTO research_notes (
|
||||||
|
id, workspace_id, note_type, ticker, source_id, archived, pinned, revision,
|
||||||
|
evidence_status, created_at, updated_at, entity_json
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
workspace_id = excluded.workspace_id,
|
||||||
|
note_type = excluded.note_type,
|
||||||
|
ticker = excluded.ticker,
|
||||||
|
source_id = excluded.source_id,
|
||||||
|
archived = excluded.archived,
|
||||||
|
pinned = excluded.pinned,
|
||||||
|
revision = excluded.revision,
|
||||||
|
evidence_status = excluded.evidence_status,
|
||||||
|
updated_at = excluded.updated_at,
|
||||||
|
entity_json = excluded.entity_json",
|
||||||
|
params![
|
||||||
|
¬e.id,
|
||||||
|
¬e.workspace_id,
|
||||||
|
serde_json::to_string(¬e.note_type)?,
|
||||||
|
note.ticker.as_deref(),
|
||||||
|
note.source_id.as_deref(),
|
||||||
|
i64::from(note.archived),
|
||||||
|
i64::from(note.pinned),
|
||||||
|
i64::from(note.revision),
|
||||||
|
serde_json::to_string(¬e.evidence_status)?,
|
||||||
|
¬e.created_at,
|
||||||
|
¬e.updated_at,
|
||||||
|
serde_json::to_string(note)?,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
transaction.execute(
|
||||||
|
"DELETE FROM research_fts WHERE note_id = ?1",
|
||||||
|
params![¬e.id],
|
||||||
|
)?;
|
||||||
|
transaction.execute(
|
||||||
|
"INSERT INTO research_fts (note_id, title, cleaned_text, ai_annotation)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![
|
||||||
|
¬e.id,
|
||||||
|
note.title.clone().unwrap_or_default(),
|
||||||
|
¬e.cleaned_text,
|
||||||
|
note.ai_annotation.clone().unwrap_or_default(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.commit()?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_note(&self, note_id: &str) -> Result<ResearchNote> {
|
pub async fn get_note(&self, note_id: &str) -> Result<ResearchNote> {
|
||||||
let note_id = note_id.to_string();
|
let note_id = note_id.to_string();
|
||||||
self.with_connection(move |connection| {
|
self.with_connection(move |connection| {
|
||||||
@@ -319,20 +385,37 @@ impl ResearchRepository {
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let ids = source_ids.to_vec();
|
let mut seen = HashSet::new();
|
||||||
|
let ids = source_ids
|
||||||
|
.iter()
|
||||||
|
.filter(|source_id| seen.insert((*source_id).clone()))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
self.with_connection(move |connection| {
|
self.with_connection(move |connection| {
|
||||||
let mut results = Vec::new();
|
let placeholders = std::iter::repeat_n("?", ids.len())
|
||||||
let mut statement =
|
.collect::<Vec<_>>()
|
||||||
connection.prepare("SELECT entity_json FROM source_records WHERE id = ?1")?;
|
.join(", ");
|
||||||
for source_id in ids {
|
let query =
|
||||||
if let Some(json) = statement
|
format!("SELECT id, entity_json FROM source_records WHERE id IN ({placeholders})");
|
||||||
.query_row(params![source_id], |row| row.get::<_, String>(0))
|
let query_params = ids.iter().cloned().map(SqlValue::Text).collect::<Vec<_>>();
|
||||||
.optional()?
|
let mut statement = connection.prepare(&query)?;
|
||||||
{
|
let rows = statement.query_map(params_from_iter(query_params.iter()), |row| {
|
||||||
results.push(serde_json::from_str(&json)?);
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
}
|
})?;
|
||||||
}
|
let rows = rows.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
Ok(results)
|
let sources_by_id = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, json)| {
|
||||||
|
serde_json::from_str::<SourceRecord>(&json)
|
||||||
|
.map(|source| (id, source))
|
||||||
|
.map_err(ResearchError::from)
|
||||||
|
})
|
||||||
|
.collect::<Result<HashMap<_, _>>>()?;
|
||||||
|
|
||||||
|
Ok(ids
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|source_id| sources_by_id.get(&source_id).cloned())
|
||||||
|
.collect())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -621,29 +704,57 @@ impl ResearchRepository {
|
|||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_due_jobs(&self, limit: usize) -> Result<Vec<PipelineJob>> {
|
pub async fn claim_due_jobs(&self, limit: usize) -> Result<Vec<PipelineJob>> {
|
||||||
|
let now = crate::research::util::now_rfc3339();
|
||||||
self.with_connection(move |connection| {
|
self.with_connection(move |connection| {
|
||||||
let mut statement = connection.prepare(
|
let transaction = connection.transaction()?;
|
||||||
|
let mut statement = transaction.prepare(
|
||||||
"SELECT entity_json
|
"SELECT entity_json
|
||||||
FROM pipeline_jobs
|
FROM pipeline_jobs
|
||||||
WHERE status IN (?1, ?2)
|
WHERE status IN (?1, ?2)
|
||||||
AND (next_attempt_at IS NULL OR next_attempt_at <= datetime('now'))
|
AND (next_attempt_at IS NULL OR next_attempt_at <= ?3)
|
||||||
ORDER BY updated_at ASC
|
ORDER BY updated_at ASC
|
||||||
LIMIT ?3",
|
LIMIT ?4",
|
||||||
)?;
|
)?;
|
||||||
let rows = statement.query_map(
|
let rows = statement.query_map(
|
||||||
params![
|
params![
|
||||||
serde_json::to_string(&JobStatus::Queued)?,
|
serde_json::to_string(&JobStatus::Queued)?,
|
||||||
serde_json::to_string(&JobStatus::Failed)?,
|
serde_json::to_string(&JobStatus::Failed)?,
|
||||||
|
now.clone(),
|
||||||
i64::try_from(limit)
|
i64::try_from(limit)
|
||||||
.map_err(|error| ResearchError::Validation(error.to_string()))?,
|
.map_err(|error| ResearchError::Validation(error.to_string()))?,
|
||||||
],
|
],
|
||||||
|row| row.get::<_, String>(0),
|
|row| row.get::<_, String>(0),
|
||||||
)?;
|
)?;
|
||||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
let mut jobs = rows
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|json| serde_json::from_str(&json).map_err(ResearchError::from))
|
.map(|json| serde_json::from_str::<PipelineJob>(&json).map_err(ResearchError::from))
|
||||||
.collect()
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
drop(statement);
|
||||||
|
|
||||||
|
for job in &mut jobs {
|
||||||
|
job.status = JobStatus::Running;
|
||||||
|
job.attempt_count += 1;
|
||||||
|
job.updated_at = now.clone();
|
||||||
|
transaction.execute(
|
||||||
|
"UPDATE pipeline_jobs
|
||||||
|
SET status = ?2,
|
||||||
|
updated_at = ?3,
|
||||||
|
entity_json = ?4
|
||||||
|
WHERE id = ?1",
|
||||||
|
params![
|
||||||
|
&job.id,
|
||||||
|
serde_json::to_string(&job.status)?,
|
||||||
|
&job.updated_at,
|
||||||
|
serde_json::to_string(job)?,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.commit()?;
|
||||||
|
Ok(jobs)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -759,10 +870,6 @@ impl ResearchRepository {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_connection(&self) -> Result<Connection> {
|
|
||||||
open_connection(&self.db_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn initialize_schema(&self, connection: &Connection) -> Result<()> {
|
fn initialize_schema(&self, connection: &Connection) -> Result<()> {
|
||||||
connection.execute_batch(
|
connection.execute_batch(
|
||||||
"PRAGMA foreign_keys = ON;
|
"PRAGMA foreign_keys = ON;
|
||||||
@@ -799,6 +906,8 @@ impl ResearchRepository {
|
|||||||
ON research_notes (workspace_id, note_type, archived);
|
ON research_notes (workspace_id, note_type, archived);
|
||||||
CREATE INDEX IF NOT EXISTS research_notes_workspace_ticker_updated_idx
|
CREATE INDEX IF NOT EXISTS research_notes_workspace_ticker_updated_idx
|
||||||
ON research_notes (workspace_id, ticker, updated_at DESC);
|
ON research_notes (workspace_id, ticker, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS research_notes_workspace_source_type_idx
|
||||||
|
ON research_notes (workspace_id, source_id, note_type);
|
||||||
CREATE TABLE IF NOT EXISTS note_links (
|
CREATE TABLE IF NOT EXISTS note_links (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
workspace_id TEXT NOT NULL REFERENCES research_workspaces(id) ON DELETE CASCADE,
|
workspace_id TEXT NOT NULL REFERENCES research_workspaces(id) ON DELETE CASCADE,
|
||||||
@@ -831,6 +940,10 @@ impl ResearchRepository {
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS source_records_workspace_ticker_kind_published_idx
|
CREATE INDEX IF NOT EXISTS source_records_workspace_ticker_kind_published_idx
|
||||||
ON source_records (workspace_id, ticker, kind, published_at DESC);
|
ON source_records (workspace_id, ticker, kind, published_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS source_records_workspace_checksum_idx
|
||||||
|
ON source_records (workspace_id, json_extract(entity_json, '$.checksum'));
|
||||||
|
CREATE INDEX IF NOT EXISTS source_records_workspace_accession_idx
|
||||||
|
ON source_records (workspace_id, json_extract(entity_json, '$.filingAccession'));
|
||||||
CREATE TABLE IF NOT EXISTS source_excerpts (
|
CREATE TABLE IF NOT EXISTS source_excerpts (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
source_id TEXT NOT NULL,
|
source_id TEXT NOT NULL,
|
||||||
@@ -865,6 +978,10 @@ impl ResearchRepository {
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
entity_json TEXT NOT NULL
|
entity_json TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_events_entity_created_idx
|
||||||
|
ON audit_events (entity_id, created_at ASC);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_events_workspace_created_idx
|
||||||
|
ON audit_events (workspace_id, created_at ASC);
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS research_fts USING fts5(note_id UNINDEXED, title, cleaned_text, ai_annotation);",
|
CREATE VIRTUAL TABLE IF NOT EXISTS research_fts USING fts5(note_id UNINDEXED, title, cleaned_text, ai_annotation);",
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -876,13 +993,24 @@ impl ResearchRepository {
|
|||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
{
|
{
|
||||||
let db_path = self.db_path.clone();
|
let db_path = self.db_path.clone();
|
||||||
|
let connections = self.connections.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut connection = open_connection(&db_path)?;
|
let mut connection = take_connection(&connections, &db_path)?;
|
||||||
task(&mut connection)
|
let result = task(&mut connection);
|
||||||
|
let store_result = store_connection(&connections, connection);
|
||||||
|
match (result, store_result) {
|
||||||
|
(Ok(value), Ok(())) => Ok(value),
|
||||||
|
(Err(error), Ok(())) => Err(error),
|
||||||
|
(Ok(_), Err(error)) | (Err(_), Err(error)) => Err(error),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|error| ResearchError::Join(error.to_string()))?
|
.map_err(|error| ResearchError::Join(error.to_string()))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn store_connection(&self, connection: Connection) -> Result<()> {
|
||||||
|
store_connection(&self.connections, connection)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_connection(path: &Path) -> Result<Connection> {
|
fn open_connection(path: &Path) -> Result<Connection> {
|
||||||
@@ -895,6 +1023,27 @@ fn open_connection(path: &Path) -> Result<Connection> {
|
|||||||
Ok(connection)
|
Ok(connection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn take_connection(pool: &Mutex<Vec<Connection>>, db_path: &Path) -> Result<Connection> {
|
||||||
|
{
|
||||||
|
let mut guard = pool.lock().map_err(|error| {
|
||||||
|
ResearchError::Validation(format!("research connection pool poisoned: {error}"))
|
||||||
|
})?;
|
||||||
|
if let Some(connection) = guard.pop() {
|
||||||
|
return Ok(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open_connection(db_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_connection(pool: &Mutex<Vec<Connection>>, connection: Connection) -> Result<()> {
|
||||||
|
let mut guard = pool.lock().map_err(|error| {
|
||||||
|
ResearchError::Validation(format!("research connection pool poisoned: {error}"))
|
||||||
|
})?;
|
||||||
|
guard.push(connection);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Public orchestration service for the research subsystem.
|
//! Public orchestration service for the research subsystem.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -38,6 +39,7 @@ pub struct ResearchService<R: Runtime> {
|
|||||||
ai_gateway: Arc<dyn ResearchAiGateway>,
|
ai_gateway: Arc<dyn ResearchAiGateway>,
|
||||||
emitter: ResearchEventEmitter<R>,
|
emitter: ResearchEventEmitter<R>,
|
||||||
settings: AgentSettingsService<R>,
|
settings: AgentSettingsService<R>,
|
||||||
|
job_processor_lock: Arc<tokio::sync::Mutex<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R: Runtime> Clone for ResearchService<R> {
|
impl<R: Runtime> Clone for ResearchService<R> {
|
||||||
@@ -48,6 +50,7 @@ impl<R: Runtime> Clone for ResearchService<R> {
|
|||||||
ai_gateway: self.ai_gateway.clone(),
|
ai_gateway: self.ai_gateway.clone(),
|
||||||
emitter: self.emitter.clone(),
|
emitter: self.emitter.clone(),
|
||||||
settings: self.settings.clone(),
|
settings: self.settings.clone(),
|
||||||
|
job_processor_lock: self.job_processor_lock.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,6 +76,7 @@ impl<R: Runtime + 'static> ResearchService<R> {
|
|||||||
ai_gateway,
|
ai_gateway,
|
||||||
emitter: ResearchEventEmitter::new(app_handle),
|
emitter: ResearchEventEmitter::new(app_handle),
|
||||||
settings: AgentSettingsService::new(app_handle),
|
settings: AgentSettingsService::new(app_handle),
|
||||||
|
job_processor_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +211,7 @@ impl<R: Runtime + 'static> ResearchService<R> {
|
|||||||
source_note.source_id.iter().cloned().collect(),
|
source_note.source_id.iter().cloned().collect(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
self.emitter.note_updated(&source_note);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,22 +433,17 @@ impl<R: Runtime + 'static> ResearchService<R> {
|
|||||||
&self,
|
&self,
|
||||||
request: GetWorkspaceProjectionRequest,
|
request: GetWorkspaceProjectionRequest,
|
||||||
) -> Result<WorkspaceProjection> {
|
) -> Result<WorkspaceProjection> {
|
||||||
let workspace = self.repository.get_workspace(&request.workspace_id).await?;
|
let (workspace, notes, links, ghosts) = tokio::try_join!(
|
||||||
let notes = self
|
self.repository.get_workspace(&request.workspace_id),
|
||||||
.repository
|
self.repository
|
||||||
.list_notes(&request.workspace_id, false, None)
|
.list_notes(&request.workspace_id, false, None),
|
||||||
.await?;
|
self.repository.list_links(&request.workspace_id, None),
|
||||||
let links = self
|
self.repository.list_ghosts(&request.workspace_id, false),
|
||||||
.repository
|
)?;
|
||||||
.list_links(&request.workspace_id, None)
|
let active_view = request.view.unwrap_or(workspace.default_view);
|
||||||
.await?;
|
|
||||||
let ghosts = self
|
|
||||||
.repository
|
|
||||||
.list_ghosts(&request.workspace_id, false)
|
|
||||||
.await?;
|
|
||||||
Ok(build_workspace_projection(
|
Ok(build_workspace_projection(
|
||||||
workspace.clone(),
|
workspace,
|
||||||
request.view.unwrap_or(workspace.default_view),
|
active_view,
|
||||||
notes,
|
notes,
|
||||||
links,
|
links,
|
||||||
ghosts,
|
ghosts,
|
||||||
@@ -559,27 +559,27 @@ impl<R: Runtime + 'static> ResearchService<R> {
|
|||||||
request: GetNoteAuditTrailRequest,
|
request: GetNoteAuditTrailRequest,
|
||||||
) -> Result<NoteAuditTrail> {
|
) -> Result<NoteAuditTrail> {
|
||||||
let note = self.repository.get_note(&request.note_id).await?;
|
let note = self.repository.get_note(&request.note_id).await?;
|
||||||
let links = self
|
let (links, ghosts, audit_events) = tokio::try_join!(
|
||||||
.repository
|
self.repository
|
||||||
.list_links(¬e.workspace_id, Some(¬e.id))
|
.list_links(¬e.workspace_id, Some(¬e.id)),
|
||||||
.await?;
|
self.repository.list_ghosts(¬e.workspace_id, true),
|
||||||
let ghosts = self
|
self.repository.list_audit_events_for_entity(¬e.id),
|
||||||
.repository
|
)?;
|
||||||
.list_ghosts(¬e.workspace_id, true)
|
let ghosts = ghosts
|
||||||
.await?
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|ghost| {
|
.filter(|ghost| {
|
||||||
ghost.supporting_note_ids.contains(¬e.id)
|
ghost.supporting_note_ids.contains(¬e.id)
|
||||||
|| ghost.contradicting_note_ids.contains(¬e.id)
|
|| ghost.contradicting_note_ids.contains(¬e.id)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut source_ids = note.source_id.iter().cloned().collect::<Vec<_>>();
|
let source_ids = dedupe_ids(
|
||||||
source_ids.extend(ghosts.iter().flat_map(|ghost| ghost.source_ids.clone()));
|
note.source_id.iter().cloned().chain(
|
||||||
|
ghosts
|
||||||
|
.iter()
|
||||||
|
.flat_map(|ghost| ghost.source_ids.iter().cloned()),
|
||||||
|
),
|
||||||
|
);
|
||||||
let sources = self.repository.list_sources_by_ids(&source_ids).await?;
|
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);
|
let memo_blocks = build_memo_blocks(std::slice::from_ref(¬e), &ghosts);
|
||||||
|
|
||||||
Ok(NoteAuditTrail {
|
Ok(NoteAuditTrail {
|
||||||
@@ -619,30 +619,40 @@ impl<R: Runtime + 'static> ResearchService<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn process_due_jobs(&self) -> Result<()> {
|
pub async fn process_due_jobs(&self) -> Result<()> {
|
||||||
let jobs = self.pipeline.due_jobs(16).await?;
|
let Ok(_guard) = self.job_processor_lock.try_lock() else {
|
||||||
for job in jobs {
|
return Ok(());
|
||||||
let running = self.pipeline.mark_running(job).await?;
|
};
|
||||||
self.emitter.job_updated(&running);
|
|
||||||
let result = self.process_job(&running).await;
|
loop {
|
||||||
match result {
|
let jobs = self.pipeline.claim_due_jobs(16).await?;
|
||||||
Ok(()) => {
|
if jobs.is_empty() {
|
||||||
let completed = self.pipeline.mark_completed(running).await?;
|
break;
|
||||||
self.emitter.job_updated(&completed);
|
}
|
||||||
}
|
|
||||||
Err(error) => {
|
for running in jobs {
|
||||||
let failed = if running.attempt_count >= running.max_attempts {
|
self.emitter.job_updated(&running);
|
||||||
self.pipeline
|
let result = self.process_job(&running).await;
|
||||||
.mark_skipped(running, &error.to_string())
|
match result {
|
||||||
.await?
|
Ok(()) => {
|
||||||
} else {
|
let completed = self.pipeline.mark_completed(running).await?;
|
||||||
self.pipeline
|
self.emitter.job_updated(&completed);
|
||||||
.mark_failed(running, &error.to_string())
|
}
|
||||||
.await?
|
Err(error) => {
|
||||||
};
|
let failed = if running.attempt_count >= running.max_attempts {
|
||||||
self.emitter.job_updated(&failed);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,20 +743,30 @@ impl<R: Runtime + 'static> ResearchService<R> {
|
|||||||
.replace_links_for_workspace(&workspace_id, links.clone())
|
.replace_links_for_workspace(&workspace_id, links.clone())
|
||||||
.await?;
|
.await?;
|
||||||
let now = now_rfc3339();
|
let now = now_rfc3339();
|
||||||
let mut note_map = notes
|
let changed_notes = notes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|mut note| {
|
.filter_map(|mut note| {
|
||||||
note.inferred_links = saved_links
|
let inferred_links = saved_links
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|link| link.from_note_id == note.id || link.to_note_id == note.id)
|
.filter(|link| link.from_note_id == note.id || link.to_note_id == note.id)
|
||||||
.map(|link| link.id.clone())
|
.map(|link| link.id.clone())
|
||||||
.collect();
|
.collect::<Vec<_>>();
|
||||||
|
if note.inferred_links == inferred_links {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
note.inferred_links = inferred_links;
|
||||||
note.last_linked_at = Some(now.clone());
|
note.last_linked_at = Some(now.clone());
|
||||||
(note.id.clone(), note)
|
Some(note)
|
||||||
})
|
})
|
||||||
.collect::<std::collections::BTreeMap<_, _>>();
|
.collect::<Vec<_>>();
|
||||||
for note in note_map.values_mut() {
|
if !changed_notes.is_empty() {
|
||||||
self.repository.save_note(note.clone()).await?;
|
self.repository
|
||||||
|
.save_notes_batch(changed_notes.clone())
|
||||||
|
.await?;
|
||||||
|
for note in &changed_notes {
|
||||||
|
self.emitter.note_updated(note);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for link in &saved_links {
|
for link in &saved_links {
|
||||||
self.record_audit(
|
self.record_audit(
|
||||||
@@ -882,6 +902,16 @@ impl<R: Runtime + 'static> ResearchService<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dedupe_ids<I>(ids: I) -> Vec<String>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = String>,
|
||||||
|
{
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
ids.into_iter()
|
||||||
|
.filter(|id| seen.insert(id.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn payload_required_str(payload: &Value, key: &str) -> Result<String> {
|
fn payload_required_str(payload: &Value, key: &str) -> Result<String> {
|
||||||
payload
|
payload
|
||||||
.get(key)
|
.get(key)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Pin, Archive, RefreshCw, Sparkles } from 'lucide-react';
|
import { Pin, Archive, RefreshCw, Sparkles } from 'lucide-react';
|
||||||
import { researchBridge } from '../../lib/researchBridge';
|
|
||||||
import type {
|
import type {
|
||||||
GhostNote,
|
GhostNote,
|
||||||
NoteAuditTrail,
|
NoteAuditTrail,
|
||||||
@@ -14,6 +13,9 @@ import { GHOST_CLASS_LABELS, NOTE_TYPE_LABELS } from './primitives/researchMeta'
|
|||||||
interface ResearchInspectorProps {
|
interface ResearchInspectorProps {
|
||||||
note: ResearchNote | null;
|
note: ResearchNote | null;
|
||||||
ghost: GhostNote | null;
|
ghost: GhostNote | null;
|
||||||
|
auditTrail: NoteAuditTrail | null;
|
||||||
|
isLoadingAuditTrail: boolean;
|
||||||
|
onRefreshAuditTrail: () => void;
|
||||||
onUpdateNote: (noteId: string, patch: {
|
onUpdateNote: (noteId: string, patch: {
|
||||||
rawText?: string;
|
rawText?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -44,6 +46,9 @@ const editableNoteTypes: NoteType[] = [
|
|||||||
export const ResearchInspector: React.FC<ResearchInspectorProps> = ({
|
export const ResearchInspector: React.FC<ResearchInspectorProps> = ({
|
||||||
note,
|
note,
|
||||||
ghost,
|
ghost,
|
||||||
|
auditTrail,
|
||||||
|
isLoadingAuditTrail,
|
||||||
|
onRefreshAuditTrail,
|
||||||
onUpdateNote,
|
onUpdateNote,
|
||||||
onArchiveNote,
|
onArchiveNote,
|
||||||
onPromoteNote,
|
onPromoteNote,
|
||||||
@@ -52,24 +57,15 @@ export const ResearchInspector: React.FC<ResearchInspectorProps> = ({
|
|||||||
const [draftTitle, setDraftTitle] = useState('');
|
const [draftTitle, setDraftTitle] = useState('');
|
||||||
const [draftBody, setDraftBody] = useState('');
|
const [draftBody, setDraftBody] = useState('');
|
||||||
const [draftType, setDraftType] = useState<NoteType>('claim');
|
const [draftType, setDraftType] = useState<NoteType>('claim');
|
||||||
const [auditTrail, setAuditTrail] = useState<NoteAuditTrail | null>(null);
|
|
||||||
const [isLoadingAudit, setIsLoadingAudit] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!note) {
|
if (!note) {
|
||||||
setAuditTrail(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDraftTitle(note.title ?? '');
|
setDraftTitle(note.title ?? '');
|
||||||
setDraftBody(note.rawText);
|
setDraftBody(note.rawText);
|
||||||
setDraftType(note.noteType);
|
setDraftType(note.noteType);
|
||||||
setIsLoadingAudit(true);
|
|
||||||
void researchBridge.getNoteAuditTrail({ noteId: note.id }).then((trail) => {
|
|
||||||
setAuditTrail(trail);
|
|
||||||
}).finally(() => {
|
|
||||||
setIsLoadingAudit(false);
|
|
||||||
});
|
|
||||||
}, [note]);
|
}, [note]);
|
||||||
|
|
||||||
if (!note && !ghost) {
|
if (!note && !ghost) {
|
||||||
@@ -227,21 +223,12 @@ export const ResearchInspector: React.FC<ResearchInspectorProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] uppercase tracking-[0.18em] text-[#7d90a7]">Audit Trail</div>
|
<div className="text-[10px] uppercase tracking-[0.18em] text-[#7d90a7]">Audit Trail</div>
|
||||||
<div className="mt-1 text-sm text-[#dcecff]">
|
<div className="mt-1 text-sm text-[#dcecff]">
|
||||||
{isLoadingAudit ? 'Loading evidence trace...' : `${auditTrail?.links.length ?? 0} links, ${auditTrail?.sources.length ?? 0} sources`}
|
{isLoadingAuditTrail ? 'Loading evidence trace...' : `${auditTrail?.links.length ?? 0} links, ${auditTrail?.sources.length ?? 0} sources`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={onRefreshAuditTrail}
|
||||||
if (note) {
|
|
||||||
setIsLoadingAudit(true);
|
|
||||||
void researchBridge.getNoteAuditTrail({ noteId: note.id }).then((trail) => {
|
|
||||||
setAuditTrail(trail);
|
|
||||||
}).finally(() => {
|
|
||||||
setIsLoadingAudit(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center gap-2 rounded-xl border border-[#273447] px-3 py-2 text-xs uppercase tracking-[0.18em] text-[#8aaacb] transition-colors hover:border-[#4c6a8a] hover:text-white"
|
className="inline-flex items-center gap-2 rounded-xl border border-[#273447] px-3 py-2 text-xs uppercase tracking-[0.18em] text-[#8aaacb] transition-colors hover:border-[#4c6a8a] hover:text-white"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
const deferredSearch = useDeferredValue(filters.search);
|
const deferredSearch = useDeferredValue(filters.search);
|
||||||
const [memoDraft, setMemoDraft] = useState('');
|
const [memoDraft, setMemoDraft] = useState('');
|
||||||
const [auditTrail, setAuditTrail] = useState<NoteAuditTrail | null>(null);
|
const [auditTrail, setAuditTrail] = useState<NoteAuditTrail | null>(null);
|
||||||
|
const [isLoadingAuditTrail, setIsLoadingAuditTrail] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeWorkspaceId) {
|
if (!activeWorkspaceId) {
|
||||||
@@ -126,6 +127,7 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const projection = projectionState.projection;
|
const projection = projectionState.projection;
|
||||||
|
const currentWorkspace = projection?.workspace ?? workspaceState.workspace;
|
||||||
|
|
||||||
const filteredNotes = useMemo(() => {
|
const filteredNotes = useMemo(() => {
|
||||||
const notes = projection?.notes ?? [];
|
const notes = projection?.notes ?? [];
|
||||||
@@ -166,18 +168,37 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
const selectedNote = filteredNotes.find((note) => selection.selectedNoteIds.includes(note.id)) ?? null;
|
const selectedNote = filteredNotes.find((note) => selection.selectedNoteIds.includes(note.id)) ?? null;
|
||||||
const selectedGhost = filteredGhosts.find((ghost) => ghost.id === selection.selectedGhostId) ?? null;
|
const selectedGhost = filteredGhosts.find((ghost) => ghost.id === selection.selectedGhostId) ?? null;
|
||||||
|
|
||||||
|
const refreshAuditTrail = React.useCallback(async (noteId: string) => {
|
||||||
|
setIsLoadingAuditTrail(true);
|
||||||
|
try {
|
||||||
|
const trail = await researchBridge.getNoteAuditTrail({ noteId });
|
||||||
|
setAuditTrail(trail);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingAuditTrail(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedNote) {
|
if (!selectedNote) {
|
||||||
setAuditTrail(null);
|
setAuditTrail(null);
|
||||||
|
setIsLoadingAuditTrail(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void researchBridge.getNoteAuditTrail({ noteId: selectedNote.id }).then((trail) => {
|
setIsLoadingAuditTrail(true);
|
||||||
if (!cancelled) {
|
void researchBridge
|
||||||
setAuditTrail(trail);
|
.getNoteAuditTrail({ noteId: selectedNote.id })
|
||||||
}
|
.then((trail) => {
|
||||||
});
|
if (!cancelled) {
|
||||||
|
setAuditTrail(trail);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoadingAuditTrail(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -340,24 +361,23 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
<ResearchCaptureBar
|
<ResearchCaptureBar
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
defaultWorkspaceId={activeWorkspaceId}
|
defaultWorkspaceId={activeWorkspaceId}
|
||||||
defaultTicker={workspaceState.workspace?.primaryTicker ?? navigationIntent?.ticker}
|
defaultTicker={currentWorkspace?.primaryTicker ?? navigationIntent?.ticker}
|
||||||
contextLabel={workspaceState.workspace ? `${workspaceState.workspace.primaryTicker} workspace` : 'Quick capture'}
|
contextLabel={currentWorkspace ? `${currentWorkspace.primaryTicker} workspace` : 'Quick capture'}
|
||||||
onWorkspaceChange={onSelectWorkspace}
|
onWorkspaceChange={onSelectWorkspace}
|
||||||
onEnsureWorkspace={onEnsureWorkspace}
|
onEnsureWorkspace={onEnsureWorkspace}
|
||||||
onSubmitCapture={(draft) =>
|
onSubmitCapture={(draft) =>
|
||||||
onCaptureResearchNote({
|
onCaptureResearchNote({
|
||||||
draft,
|
draft,
|
||||||
fallbackTicker: workspaceState.workspace?.primaryTicker ?? navigationIntent?.ticker,
|
fallbackTicker: currentWorkspace?.primaryTicker ?? navigationIntent?.ticker,
|
||||||
explicitWorkspaceId: activeWorkspaceId,
|
explicitWorkspaceId: activeWorkspaceId,
|
||||||
autoCreateFromTicker: Boolean(
|
autoCreateFromTicker: Boolean(
|
||||||
workspaceState.workspace?.primaryTicker ?? navigationIntent?.ticker,
|
currentWorkspace?.primaryTicker ?? navigationIntent?.ticker,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onCaptured={(note) => {
|
onCaptured={(note) => {
|
||||||
onSelectWorkspace(note.workspaceId);
|
onSelectWorkspace(note.workspaceId);
|
||||||
selection.selectNote(note.id);
|
selection.selectNote(note.id);
|
||||||
void projectionState.refreshProjection();
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -378,9 +398,13 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
}
|
}
|
||||||
toolbar={
|
toolbar={
|
||||||
<ResearchToolbar
|
<ResearchToolbar
|
||||||
workspace={workspaceState.workspace}
|
workspace={currentWorkspace}
|
||||||
visibleGhostCount={visibleGhosts.length}
|
visibleGhostCount={visibleGhosts.length}
|
||||||
activeJobCount={Object.values(projectionState.jobs).length}
|
activeJobCount={
|
||||||
|
Object.values(projectionState.jobs).filter(
|
||||||
|
(job) => job.status === 'queued' || job.status === 'running',
|
||||||
|
).length
|
||||||
|
}
|
||||||
density={prefs.density}
|
density={prefs.density}
|
||||||
onDensityChange={setDensity}
|
onDensityChange={setDensity}
|
||||||
onToggleInspector={toggleInspector}
|
onToggleInspector={toggleInspector}
|
||||||
@@ -392,6 +416,13 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
|||||||
<ResearchInspector
|
<ResearchInspector
|
||||||
note={selectedNote}
|
note={selectedNote}
|
||||||
ghost={selectedGhost}
|
ghost={selectedGhost}
|
||||||
|
auditTrail={auditTrail}
|
||||||
|
isLoadingAuditTrail={isLoadingAuditTrail}
|
||||||
|
onRefreshAuditTrail={() => {
|
||||||
|
if (selectedNote) {
|
||||||
|
void refreshAuditTrail(selectedNote.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onUpdateNote={(noteId, patch) => workspaceState.updateNote({ noteId, ...patch })}
|
onUpdateNote={(noteId, patch) => workspaceState.updateNote({ noteId, ...patch })}
|
||||||
onArchiveNote={(noteId, archived) => workspaceState.archiveNote(noteId, archived)}
|
onArchiveNote={(noteId, archived) => workspaceState.archiveNote(noteId, archived)}
|
||||||
onPromoteNote={(noteId, thesisStatus, noteType) =>
|
onPromoteNote={(noteId, thesisStatus, noteType) =>
|
||||||
|
|||||||
@@ -7,9 +7,17 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useResearchEventSubscriptions } from '../lib/researchEvents';
|
import { useResearchEventSubscriptions } from '../lib/researchEvents';
|
||||||
|
import {
|
||||||
|
replaceProjectionWorkspace,
|
||||||
|
upsertProjectionGhost,
|
||||||
|
upsertProjectionNote,
|
||||||
|
} from '../lib/researchProjection';
|
||||||
import { researchBridge } from '../lib/researchBridge';
|
import { researchBridge } from '../lib/researchBridge';
|
||||||
import type {
|
import type {
|
||||||
|
GhostNote,
|
||||||
PipelineJob,
|
PipelineJob,
|
||||||
|
ResearchNote,
|
||||||
|
ResearchWorkspace,
|
||||||
WorkspaceProjection,
|
WorkspaceProjection,
|
||||||
WorkspaceViewKind,
|
WorkspaceViewKind,
|
||||||
} from '../types/research';
|
} from '../types/research';
|
||||||
@@ -26,6 +34,9 @@ type ProjectionAction =
|
|||||||
| { type: 'load_started'; refresh: boolean }
|
| { type: 'load_started'; refresh: boolean }
|
||||||
| { type: 'load_succeeded'; projection: WorkspaceProjection }
|
| { type: 'load_succeeded'; projection: WorkspaceProjection }
|
||||||
| { type: 'load_failed'; error: string }
|
| { type: 'load_failed'; error: string }
|
||||||
|
| { type: 'workspace_updated'; workspace: ResearchWorkspace }
|
||||||
|
| { type: 'note_updated'; note: ResearchNote }
|
||||||
|
| { type: 'ghost_updated'; ghost: GhostNote }
|
||||||
| { type: 'job_updated'; job: PipelineJob };
|
| { type: 'job_updated'; job: PipelineJob };
|
||||||
|
|
||||||
const createProjectionState = (): ProjectionState => ({
|
const createProjectionState = (): ProjectionState => ({
|
||||||
@@ -71,6 +82,27 @@ const projectionReducer = (
|
|||||||
[action.job.id]: action.job,
|
[action.job.id]: action.job,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
case 'workspace_updated':
|
||||||
|
return state.projection
|
||||||
|
? {
|
||||||
|
...state,
|
||||||
|
projection: replaceProjectionWorkspace(state.projection, action.workspace),
|
||||||
|
}
|
||||||
|
: state;
|
||||||
|
case 'note_updated':
|
||||||
|
return state.projection
|
||||||
|
? {
|
||||||
|
...state,
|
||||||
|
projection: upsertProjectionNote(state.projection, action.note),
|
||||||
|
}
|
||||||
|
: state;
|
||||||
|
case 'ghost_updated':
|
||||||
|
return state.projection
|
||||||
|
? {
|
||||||
|
...state,
|
||||||
|
projection: upsertProjectionGhost(state.projection, action.ghost),
|
||||||
|
}
|
||||||
|
: state;
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -132,19 +164,32 @@ export const useResearchProjection = (
|
|||||||
|
|
||||||
useResearchEventSubscriptions({
|
useResearchEventSubscriptions({
|
||||||
workspaceId: workspaceId ?? undefined,
|
workspaceId: workspaceId ?? undefined,
|
||||||
onWorkspaceUpdate: () => {
|
onWorkspaceUpdate: (payload) => {
|
||||||
scheduleRefresh();
|
startTransition(() => {
|
||||||
|
dispatch({ type: 'workspace_updated', workspace: payload.workspace });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onNoteUpdate: () => {
|
onNoteUpdate: (payload) => {
|
||||||
scheduleRefresh();
|
startTransition(() => {
|
||||||
|
dispatch({ type: 'note_updated', note: payload.note });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onGhostUpdate: () => {
|
onGhostUpdate: (payload) => {
|
||||||
scheduleRefresh();
|
startTransition(() => {
|
||||||
|
dispatch({ type: 'ghost_updated', ghost: payload.ghost });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onJobUpdate: (payload) => {
|
onJobUpdate: (payload) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
dispatch({ type: 'job_updated', job: payload.job });
|
dispatch({ type: 'job_updated', job: payload.job });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
payload.job.status === 'completed' &&
|
||||||
|
(payload.job.jobKind === 'infer_links' || payload.job.jobKind === 'evaluate_ghosts')
|
||||||
|
) {
|
||||||
|
scheduleRefresh();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
133
MosaicIQ/src/lib/researchProjection.test.ts
Normal file
133
MosaicIQ/src/lib/researchProjection.test.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import {
|
||||||
|
rebuildWorkspaceProjection,
|
||||||
|
upsertProjectionGhost,
|
||||||
|
upsertProjectionNote,
|
||||||
|
} from './researchProjection';
|
||||||
|
import type {
|
||||||
|
GhostNote,
|
||||||
|
ResearchNote,
|
||||||
|
ResearchWorkspace,
|
||||||
|
WorkspaceProjection,
|
||||||
|
} from '../types/research';
|
||||||
|
|
||||||
|
const workspace: ResearchWorkspace = {
|
||||||
|
id: 'workspace-1',
|
||||||
|
name: 'NVDA Research',
|
||||||
|
primaryTicker: 'NVDA',
|
||||||
|
scope: 'single_company',
|
||||||
|
stage: 'capture',
|
||||||
|
defaultView: 'canvas',
|
||||||
|
pinnedNoteIds: [],
|
||||||
|
archived: false,
|
||||||
|
createdAt: '2026-04-09T10:00:00Z',
|
||||||
|
updatedAt: '2026-04-09T10:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const note = (overrides: Partial<ResearchNote> = {}): ResearchNote => ({
|
||||||
|
id: 'note-1',
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
ticker: 'NVDA',
|
||||||
|
rawText: 'Demand is improving.',
|
||||||
|
cleanedText: 'Demand is improving.',
|
||||||
|
title: 'Demand',
|
||||||
|
noteType: 'claim',
|
||||||
|
analystStatus: 'captured',
|
||||||
|
confidence: 0.7,
|
||||||
|
evidenceStatus: 'source_linked',
|
||||||
|
inferredLinks: [],
|
||||||
|
ghostStatus: 'none',
|
||||||
|
thesisStatus: 'none',
|
||||||
|
createdAt: '2026-04-09T10:00:00Z',
|
||||||
|
updatedAt: '2026-04-09T10:00:00Z',
|
||||||
|
provenance: {
|
||||||
|
createdBy: 'manual',
|
||||||
|
captureMethod: 'quick_entry',
|
||||||
|
sourceKind: 'manual',
|
||||||
|
createdAt: '2026-04-09T10:00:00Z',
|
||||||
|
rawInputHash: 'hash',
|
||||||
|
},
|
||||||
|
tags: [],
|
||||||
|
catalysts: [],
|
||||||
|
risks: [],
|
||||||
|
valuationRefs: [],
|
||||||
|
priority: 'normal',
|
||||||
|
pinned: false,
|
||||||
|
archived: false,
|
||||||
|
revision: 1,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ghost = (overrides: Partial<GhostNote> = {}): GhostNote => ({
|
||||||
|
id: 'ghost-1',
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
ghostClass: 'candidate_thesis',
|
||||||
|
headline: 'Candidate thesis',
|
||||||
|
body: 'Margins may expand.',
|
||||||
|
tone: 'tentative',
|
||||||
|
confidence: 0.8,
|
||||||
|
visibilityState: 'visible',
|
||||||
|
state: 'accepted',
|
||||||
|
supportingNoteIds: ['note-1'],
|
||||||
|
contradictingNoteIds: [],
|
||||||
|
sourceIds: ['source-1'],
|
||||||
|
evidenceThresholdMet: true,
|
||||||
|
createdAt: '2026-04-09T10:00:00Z',
|
||||||
|
updatedAt: '2026-04-09T10:00:00Z',
|
||||||
|
memoSectionHint: 'investment_memo',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const projection = (
|
||||||
|
notes: ResearchNote[] = [note()],
|
||||||
|
ghosts: GhostNote[] = [],
|
||||||
|
): WorkspaceProjection =>
|
||||||
|
rebuildWorkspaceProjection(
|
||||||
|
{
|
||||||
|
workspace,
|
||||||
|
activeView: 'canvas',
|
||||||
|
notes: [],
|
||||||
|
links: [],
|
||||||
|
ghosts: [],
|
||||||
|
memoBlocks: [],
|
||||||
|
graphNodes: [],
|
||||||
|
graphEdges: [],
|
||||||
|
kanbanColumns: [],
|
||||||
|
timelineEvents: [],
|
||||||
|
},
|
||||||
|
{ notes, ghosts },
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('researchProjection helpers', () => {
|
||||||
|
it('upsertProjectionNote updates existing notes and recomputes derived views', () => {
|
||||||
|
const updated = upsertProjectionNote(
|
||||||
|
projection(),
|
||||||
|
note({
|
||||||
|
id: 'note-1',
|
||||||
|
noteType: 'event_takeaway',
|
||||||
|
title: 'Earnings reaction',
|
||||||
|
sourceExcerpt: {
|
||||||
|
sourceId: 'source-1',
|
||||||
|
locationLabel: 'Q1 2026',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.notes[0].noteType).toBe('event_takeaway');
|
||||||
|
expect(updated.timelineEvents).toEqual([
|
||||||
|
{
|
||||||
|
id: 'note-1',
|
||||||
|
label: 'Earnings reaction',
|
||||||
|
noteId: 'note-1',
|
||||||
|
at: 'Q1 2026',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upsertProjectionGhost recomputes memo blocks for accepted ghosts', () => {
|
||||||
|
const updated = upsertProjectionGhost(projection(), ghost());
|
||||||
|
|
||||||
|
expect(updated.ghosts).toHaveLength(1);
|
||||||
|
expect(updated.memoBlocks.some((block) => block.headline === 'Candidate thesis')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
266
MosaicIQ/src/lib/researchProjection.ts
Normal file
266
MosaicIQ/src/lib/researchProjection.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import type {
|
||||||
|
GhostNote,
|
||||||
|
GraphEdge,
|
||||||
|
GraphNode,
|
||||||
|
KanbanColumn,
|
||||||
|
MemoBlockCandidate,
|
||||||
|
MemoSectionKind,
|
||||||
|
NoteLink,
|
||||||
|
NoteType,
|
||||||
|
ResearchNote,
|
||||||
|
ResearchWorkspace,
|
||||||
|
TimelineEvent,
|
||||||
|
WorkspaceProjection,
|
||||||
|
} from '../types/research';
|
||||||
|
|
||||||
|
const MEMO_ACCEPTED_THESIS_STATUSES = new Set([
|
||||||
|
'accepted_support',
|
||||||
|
'accepted_core',
|
||||||
|
'bull_case',
|
||||||
|
'bear_case',
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const BLOCKED_MEMO_NOTE_TYPES = new Set([
|
||||||
|
'question',
|
||||||
|
'follow_up_task',
|
||||||
|
'source_reference',
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const TIMELINE_NOTE_TYPES = new Set([
|
||||||
|
'event_takeaway',
|
||||||
|
'catalyst',
|
||||||
|
'management_signal',
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const GHOST_MEMO_STATES = new Set(['accepted', 'converted'] as const);
|
||||||
|
|
||||||
|
const noteSort = (left: ResearchNote, right: ResearchNote) => {
|
||||||
|
if (left.pinned !== right.pinned) {
|
||||||
|
return Number(right.pinned) - Number(left.pinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
return right.updatedAt.localeCompare(left.updatedAt);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ghostSort = (left: GhostNote, right: GhostNote) =>
|
||||||
|
right.updatedAt.localeCompare(left.updatedAt);
|
||||||
|
|
||||||
|
const sectionForNote = (noteType: NoteType): MemoSectionKind | null => {
|
||||||
|
switch (noteType) {
|
||||||
|
case 'thesis':
|
||||||
|
case 'sub_thesis':
|
||||||
|
case 'mosaic_insight':
|
||||||
|
return 'investment_memo';
|
||||||
|
case 'risk':
|
||||||
|
case 'contradiction':
|
||||||
|
return 'risk_register';
|
||||||
|
case 'catalyst':
|
||||||
|
return 'catalyst_calendar';
|
||||||
|
case 'valuation_point':
|
||||||
|
case 'scenario_assumption':
|
||||||
|
return 'valuation_write_up';
|
||||||
|
case 'event_takeaway':
|
||||||
|
return 'earnings_recap';
|
||||||
|
case 'channel_check':
|
||||||
|
return 'watchlist_update';
|
||||||
|
case 'fact':
|
||||||
|
case 'quote':
|
||||||
|
case 'management_signal':
|
||||||
|
case 'claim':
|
||||||
|
case 'industry_observation':
|
||||||
|
case 'competitor_comparison':
|
||||||
|
return 'stock_pitch';
|
||||||
|
case 'question':
|
||||||
|
case 'follow_up_task':
|
||||||
|
case 'source_reference':
|
||||||
|
case 'uncertainty':
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMemoBlocks = (
|
||||||
|
notes: ResearchNote[],
|
||||||
|
ghosts: GhostNote[],
|
||||||
|
): MemoBlockCandidate[] => {
|
||||||
|
const noteBlocks = notes.flatMap((note) => {
|
||||||
|
if (
|
||||||
|
note.archived ||
|
||||||
|
BLOCKED_MEMO_NOTE_TYPES.has(note.noteType as never) ||
|
||||||
|
note.evidenceStatus === 'unsourced'
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionKind = sectionForNote(note.noteType);
|
||||||
|
if (!sectionKind) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sectionKind,
|
||||||
|
headline: note.title || note.cleanedText,
|
||||||
|
body: note.aiAnnotation || note.cleanedText,
|
||||||
|
sourceNoteIds: [note.id],
|
||||||
|
citationRefs: note.sourceId ? [note.sourceId] : [],
|
||||||
|
confidence: note.confidence,
|
||||||
|
accepted: MEMO_ACCEPTED_THESIS_STATUSES.has(note.thesisStatus as never),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const ghostBlocks = ghosts.flatMap((ghost) => {
|
||||||
|
if (!GHOST_MEMO_STATES.has(ghost.state as never) || !ghost.memoSectionHint) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sectionKind: ghost.memoSectionHint,
|
||||||
|
headline: ghost.headline,
|
||||||
|
body: ghost.body,
|
||||||
|
sourceNoteIds: ghost.supportingNoteIds,
|
||||||
|
citationRefs: ghost.sourceIds,
|
||||||
|
confidence: ghost.confidence,
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...noteBlocks, ...ghostBlocks];
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildGraphNodes = (notes: ResearchNote[]): GraphNode[] =>
|
||||||
|
notes.map((note) => ({
|
||||||
|
id: note.id,
|
||||||
|
label: note.title || note.cleanedText,
|
||||||
|
kind: note.noteType,
|
||||||
|
confidence: note.confidence,
|
||||||
|
evidenceStatus: note.evidenceStatus,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const buildGraphEdges = (links: NoteLink[]): GraphEdge[] =>
|
||||||
|
links.map((link) => ({
|
||||||
|
id: link.id,
|
||||||
|
from: link.fromNoteId,
|
||||||
|
to: link.toNoteId,
|
||||||
|
linkType: link.linkType,
|
||||||
|
strength: link.strength,
|
||||||
|
confidence: link.confidence,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const buildKanbanColumns = (notes: ResearchNote[]): KanbanColumn[] => {
|
||||||
|
const noteTypes: NoteType[] = [
|
||||||
|
'fact',
|
||||||
|
'management_signal',
|
||||||
|
'claim',
|
||||||
|
'risk',
|
||||||
|
'catalyst',
|
||||||
|
'valuation_point',
|
||||||
|
'question',
|
||||||
|
'source_reference',
|
||||||
|
];
|
||||||
|
|
||||||
|
return noteTypes.map((noteType) => ({
|
||||||
|
key: noteType,
|
||||||
|
label:
|
||||||
|
noteType === 'valuation_point'
|
||||||
|
? 'Valuation'
|
||||||
|
: noteType === 'management_signal'
|
||||||
|
? 'Management Signal'
|
||||||
|
: noteType.replace(/_/g, ' '),
|
||||||
|
notes: notes
|
||||||
|
.filter((note) => note.noteType === noteType && !note.archived)
|
||||||
|
.sort(noteSort),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTimeline = (
|
||||||
|
notes: ResearchNote[],
|
||||||
|
ghosts: GhostNote[],
|
||||||
|
): TimelineEvent[] => {
|
||||||
|
const noteEvents = notes.flatMap((note) => {
|
||||||
|
if (!TIMELINE_NOTE_TYPES.has(note.noteType as never)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: note.id,
|
||||||
|
label: note.title || note.cleanedText,
|
||||||
|
noteId: note.id,
|
||||||
|
at: note.sourceExcerpt?.locationLabel,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const ghostEvents = ghosts.flatMap((ghost) => {
|
||||||
|
if (ghost.ghostClass !== 'contradiction_alert') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: ghost.id,
|
||||||
|
label: ghost.headline,
|
||||||
|
noteId: ghost.supportingNoteIds[0] ?? ghost.id,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...noteEvents, ...ghostEvents];
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortNotes = (notes: ResearchNote[]) => [...notes].sort(noteSort);
|
||||||
|
|
||||||
|
const sortGhosts = (ghosts: GhostNote[]) => [...ghosts].sort(ghostSort);
|
||||||
|
|
||||||
|
export const rebuildWorkspaceProjection = (
|
||||||
|
projection: WorkspaceProjection,
|
||||||
|
overrides: Partial<Pick<WorkspaceProjection, 'workspace' | 'notes' | 'links' | 'ghosts'>>,
|
||||||
|
): WorkspaceProjection => {
|
||||||
|
const workspace = overrides.workspace ?? projection.workspace;
|
||||||
|
const notes = sortNotes(overrides.notes ?? projection.notes);
|
||||||
|
const links = overrides.links ?? projection.links;
|
||||||
|
const ghosts = sortGhosts(overrides.ghosts ?? projection.ghosts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspace,
|
||||||
|
activeView: projection.activeView,
|
||||||
|
notes,
|
||||||
|
links,
|
||||||
|
ghosts,
|
||||||
|
memoBlocks: buildMemoBlocks(notes, ghosts),
|
||||||
|
graphNodes: buildGraphNodes(notes),
|
||||||
|
graphEdges: buildGraphEdges(links),
|
||||||
|
kanbanColumns: buildKanbanColumns(notes),
|
||||||
|
timelineEvents: buildTimeline(notes, ghosts),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertProjectionNote = (
|
||||||
|
projection: WorkspaceProjection,
|
||||||
|
note: ResearchNote,
|
||||||
|
): WorkspaceProjection => {
|
||||||
|
const notes = projection.notes.some((current) => current.id === note.id)
|
||||||
|
? projection.notes.map((current) => (current.id === note.id ? note : current))
|
||||||
|
: [note, ...projection.notes];
|
||||||
|
|
||||||
|
return rebuildWorkspaceProjection(projection, { notes });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertProjectionGhost = (
|
||||||
|
projection: WorkspaceProjection,
|
||||||
|
ghost: GhostNote,
|
||||||
|
): WorkspaceProjection => {
|
||||||
|
const ghosts = projection.ghosts.some((current) => current.id === ghost.id)
|
||||||
|
? projection.ghosts.map((current) => (current.id === ghost.id ? ghost : current))
|
||||||
|
: [ghost, ...projection.ghosts];
|
||||||
|
|
||||||
|
return rebuildWorkspaceProjection(projection, { ghosts });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const replaceProjectionWorkspace = (
|
||||||
|
projection: WorkspaceProjection,
|
||||||
|
workspace: ResearchWorkspace,
|
||||||
|
): WorkspaceProjection => rebuildWorkspaceProjection(projection, { workspace });
|
||||||
@@ -30,4 +30,45 @@ export default defineConfig(async () => ({
|
|||||||
ignored: ["**/src-tauri/**"],
|
ignored: ["**/src-tauri/**"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
chunkSizeWarningLimit: 400,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (!id.includes("node_modules")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
id.includes("react-markdown") ||
|
||||||
|
id.includes("remark-gfm") ||
|
||||||
|
id.includes("mdast") ||
|
||||||
|
id.includes("micromark") ||
|
||||||
|
id.includes("hast") ||
|
||||||
|
id.includes("unist")
|
||||||
|
) {
|
||||||
|
return "markdown";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id.includes("recharts") || id.includes("/d3-")) {
|
||||||
|
return "charts";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
id.includes("/react/") ||
|
||||||
|
id.includes("react-dom") ||
|
||||||
|
id.includes("scheduler")
|
||||||
|
) {
|
||||||
|
return "react-vendor";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id.includes("@tauri-apps")) {
|
||||||
|
return "tauri";
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user