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::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rusqlite::types::Value;
|
||||
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
|
||||
@@ -14,6 +15,7 @@ use crate::news::{NewsError, Result};
|
||||
#[derive(Clone)]
|
||||
pub struct NewsRepository {
|
||||
db_path: PathBuf,
|
||||
connections: Arc<Mutex<Vec<Connection>>>,
|
||||
}
|
||||
|
||||
impl NewsRepository {
|
||||
@@ -22,9 +24,13 @@ impl NewsRepository {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let repository = Self { db_path };
|
||||
let connection = repository.open_connection()?;
|
||||
let connection = open_connection(&db_path)?;
|
||||
let repository = Self {
|
||||
db_path,
|
||||
connections: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
repository.initialize_schema(&connection)?;
|
||||
repository.store_connection(connection)?;
|
||||
Ok(repository)
|
||||
}
|
||||
|
||||
@@ -325,13 +331,24 @@ impl NewsRepository {
|
||||
T: Send + 'static,
|
||||
{
|
||||
let db_path = self.db_path.clone();
|
||||
let connections = self.connections.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut connection = open_connection(&db_path)?;
|
||||
task(&mut connection)
|
||||
let mut connection = take_connection(&connections, &db_path)?;
|
||||
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
|
||||
.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(
|
||||
@@ -388,6 +405,27 @@ fn open_connection(path: &Path) -> Result<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(
|
||||
connection: &mut Connection,
|
||||
request: QueryNewsFeedRequest,
|
||||
|
||||
@@ -66,13 +66,6 @@ impl ResearchPipeline {
|
||||
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> {
|
||||
job.status = JobStatus::Completed;
|
||||
job.last_error = None;
|
||||
@@ -92,13 +85,13 @@ impl ResearchPipeline {
|
||||
pub async fn mark_failed(&self, mut job: PipelineJob, error: &str) -> Result<PipelineJob> {
|
||||
job.status = JobStatus::Failed;
|
||||
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();
|
||||
self.repository.save_job(job).await
|
||||
}
|
||||
|
||||
pub async fn due_jobs(&self, limit: usize) -> Result<Vec<PipelineJob>> {
|
||||
self.repository.list_due_jobs(limit).await
|
||||
pub async fn claim_due_jobs(&self, limit: usize) -> Result<Vec<PipelineJob>> {
|
||||
self.repository.claim_due_jobs(limit).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! SQLite-backed persistence for research workspaces, notes, links, ghosts, and jobs.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
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::types::{
|
||||
@@ -13,6 +16,7 @@ use crate::research::types::{
|
||||
#[derive(Clone)]
|
||||
pub struct ResearchRepository {
|
||||
db_path: PathBuf,
|
||||
connections: Arc<Mutex<Vec<Connection>>>,
|
||||
}
|
||||
|
||||
impl ResearchRepository {
|
||||
@@ -21,9 +25,13 @@ impl ResearchRepository {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let repository = Self { db_path };
|
||||
let connection = repository.open_connection()?;
|
||||
let connection = open_connection(&db_path)?;
|
||||
let repository = Self {
|
||||
db_path,
|
||||
connections: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
repository.initialize_schema(&connection)?;
|
||||
repository.store_connection(connection)?;
|
||||
Ok(repository)
|
||||
}
|
||||
|
||||
@@ -177,6 +185,64 @@ impl ResearchRepository {
|
||||
.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> {
|
||||
let note_id = note_id.to_string();
|
||||
self.with_connection(move |connection| {
|
||||
@@ -319,20 +385,37 @@ impl ResearchRepository {
|
||||
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| {
|
||||
let mut results = Vec::new();
|
||||
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))
|
||||
.optional()?
|
||||
{
|
||||
results.push(serde_json::from_str(&json)?);
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
let placeholders = std::iter::repeat_n("?", ids.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let query =
|
||||
format!("SELECT id, entity_json FROM source_records WHERE id IN ({placeholders})");
|
||||
let query_params = ids.iter().cloned().map(SqlValue::Text).collect::<Vec<_>>();
|
||||
let mut statement = connection.prepare(&query)?;
|
||||
let rows = statement.query_map(params_from_iter(query_params.iter()), |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
let rows = rows.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
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
|
||||
}
|
||||
@@ -621,29 +704,57 @@ impl ResearchRepository {
|
||||
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| {
|
||||
let mut statement = connection.prepare(
|
||||
let transaction = connection.transaction()?;
|
||||
let mut statement = transaction.prepare(
|
||||
"SELECT entity_json
|
||||
FROM pipeline_jobs
|
||||
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
|
||||
LIMIT ?3",
|
||||
LIMIT ?4",
|
||||
)?;
|
||||
let rows = statement.query_map(
|
||||
params![
|
||||
serde_json::to_string(&JobStatus::Queued)?,
|
||||
serde_json::to_string(&JobStatus::Failed)?,
|
||||
now.clone(),
|
||||
i64::try_from(limit)
|
||||
.map_err(|error| ResearchError::Validation(error.to_string()))?,
|
||||
],
|
||||
|row| row.get::<_, String>(0),
|
||||
)?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
let mut jobs = rows
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.map(|json| serde_json::from_str(&json).map_err(ResearchError::from))
|
||||
.collect()
|
||||
.map(|json| serde_json::from_str::<PipelineJob>(&json).map_err(ResearchError::from))
|
||||
.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
|
||||
}
|
||||
@@ -759,10 +870,6 @@ impl ResearchRepository {
|
||||
.await
|
||||
}
|
||||
|
||||
fn open_connection(&self) -> Result<Connection> {
|
||||
open_connection(&self.db_path)
|
||||
}
|
||||
|
||||
fn initialize_schema(&self, connection: &Connection) -> Result<()> {
|
||||
connection.execute_batch(
|
||||
"PRAGMA foreign_keys = ON;
|
||||
@@ -799,6 +906,8 @@ impl ResearchRepository {
|
||||
ON research_notes (workspace_id, note_type, archived);
|
||||
CREATE INDEX IF NOT EXISTS research_notes_workspace_ticker_updated_idx
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
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
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id TEXT NOT NULL,
|
||||
@@ -865,6 +978,10 @@ impl ResearchRepository {
|
||||
created_at 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);",
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -876,13 +993,24 @@ impl ResearchRepository {
|
||||
T: Send + 'static,
|
||||
{
|
||||
let db_path = self.db_path.clone();
|
||||
let connections = self.connections.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut connection = open_connection(&db_path)?;
|
||||
task(&mut connection)
|
||||
let mut connection = take_connection(&connections, &db_path)?;
|
||||
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
|
||||
.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> {
|
||||
@@ -895,6 +1023,27 @@ fn open_connection(path: &Path) -> Result<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)]
|
||||
mod tests {
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Public orchestration service for the research subsystem.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
@@ -38,6 +39,7 @@ pub struct ResearchService<R: Runtime> {
|
||||
ai_gateway: Arc<dyn ResearchAiGateway>,
|
||||
emitter: ResearchEventEmitter<R>,
|
||||
settings: AgentSettingsService<R>,
|
||||
job_processor_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
impl<R: Runtime> Clone for ResearchService<R> {
|
||||
@@ -48,6 +50,7 @@ impl<R: Runtime> Clone for ResearchService<R> {
|
||||
ai_gateway: self.ai_gateway.clone(),
|
||||
emitter: self.emitter.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,
|
||||
emitter: ResearchEventEmitter::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(),
|
||||
)
|
||||
.await?;
|
||||
self.emitter.note_updated(&source_note);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,22 +433,17 @@ impl<R: Runtime + 'static> ResearchService<R> {
|
||||
&self,
|
||||
request: GetWorkspaceProjectionRequest,
|
||||
) -> Result<WorkspaceProjection> {
|
||||
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?;
|
||||
let (workspace, notes, links, ghosts) = tokio::try_join!(
|
||||
self.repository.get_workspace(&request.workspace_id),
|
||||
self.repository
|
||||
.list_notes(&request.workspace_id, false, None),
|
||||
self.repository.list_links(&request.workspace_id, None),
|
||||
self.repository.list_ghosts(&request.workspace_id, false),
|
||||
)?;
|
||||
let active_view = request.view.unwrap_or(workspace.default_view);
|
||||
Ok(build_workspace_projection(
|
||||
workspace.clone(),
|
||||
request.view.unwrap_or(workspace.default_view),
|
||||
workspace,
|
||||
active_view,
|
||||
notes,
|
||||
links,
|
||||
ghosts,
|
||||
@@ -559,27 +559,27 @@ impl<R: Runtime + 'static> ResearchService<R> {
|
||||
request: GetNoteAuditTrailRequest,
|
||||
) -> Result<NoteAuditTrail> {
|
||||
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?
|
||||
let (links, ghosts, audit_events) = tokio::try_join!(
|
||||
self.repository
|
||||
.list_links(¬e.workspace_id, Some(¬e.id)),
|
||||
self.repository.list_ghosts(¬e.workspace_id, true),
|
||||
self.repository.list_audit_events_for_entity(¬e.id),
|
||||
)?;
|
||||
let ghosts = ghosts
|
||||
.into_iter()
|
||||
.filter(|ghost| {
|
||||
ghost.supporting_note_ids.contains(¬e.id)
|
||||
|| ghost.contradicting_note_ids.contains(¬e.id)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut source_ids = note.source_id.iter().cloned().collect::<Vec<_>>();
|
||||
source_ids.extend(ghosts.iter().flat_map(|ghost| ghost.source_ids.clone()));
|
||||
let source_ids = dedupe_ids(
|
||||
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 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 {
|
||||
@@ -619,9 +619,17 @@ impl<R: Runtime + 'static> ResearchService<R> {
|
||||
}
|
||||
|
||||
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?;
|
||||
let Ok(_guard) = self.job_processor_lock.try_lock() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
loop {
|
||||
let jobs = self.pipeline.claim_due_jobs(16).await?;
|
||||
if jobs.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for running in jobs {
|
||||
self.emitter.job_updated(&running);
|
||||
let result = self.process_job(&running).await;
|
||||
match result {
|
||||
@@ -643,6 +651,8 @@ impl<R: Runtime + 'static> ResearchService<R> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -733,20 +743,30 @@ impl<R: Runtime + 'static> ResearchService<R> {
|
||||
.replace_links_for_workspace(&workspace_id, links.clone())
|
||||
.await?;
|
||||
let now = now_rfc3339();
|
||||
let mut note_map = notes
|
||||
let changed_notes = notes
|
||||
.into_iter()
|
||||
.map(|mut note| {
|
||||
note.inferred_links = saved_links
|
||||
.filter_map(|mut note| {
|
||||
let 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();
|
||||
.collect::<Vec<_>>();
|
||||
if note.inferred_links == inferred_links {
|
||||
return None;
|
||||
}
|
||||
|
||||
note.inferred_links = inferred_links;
|
||||
note.last_linked_at = Some(now.clone());
|
||||
(note.id.clone(), note)
|
||||
Some(note)
|
||||
})
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
for note in note_map.values_mut() {
|
||||
self.repository.save_note(note.clone()).await?;
|
||||
.collect::<Vec<_>>();
|
||||
if !changed_notes.is_empty() {
|
||||
self.repository
|
||||
.save_notes_batch(changed_notes.clone())
|
||||
.await?;
|
||||
for note in &changed_notes {
|
||||
self.emitter.note_updated(note);
|
||||
}
|
||||
}
|
||||
for link in &saved_links {
|
||||
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> {
|
||||
payload
|
||||
.get(key)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Pin, Archive, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { researchBridge } from '../../lib/researchBridge';
|
||||
import type {
|
||||
GhostNote,
|
||||
NoteAuditTrail,
|
||||
@@ -14,6 +13,9 @@ import { GHOST_CLASS_LABELS, NOTE_TYPE_LABELS } from './primitives/researchMeta'
|
||||
interface ResearchInspectorProps {
|
||||
note: ResearchNote | null;
|
||||
ghost: GhostNote | null;
|
||||
auditTrail: NoteAuditTrail | null;
|
||||
isLoadingAuditTrail: boolean;
|
||||
onRefreshAuditTrail: () => void;
|
||||
onUpdateNote: (noteId: string, patch: {
|
||||
rawText?: string;
|
||||
title?: string;
|
||||
@@ -44,6 +46,9 @@ const editableNoteTypes: NoteType[] = [
|
||||
export const ResearchInspector: React.FC<ResearchInspectorProps> = ({
|
||||
note,
|
||||
ghost,
|
||||
auditTrail,
|
||||
isLoadingAuditTrail,
|
||||
onRefreshAuditTrail,
|
||||
onUpdateNote,
|
||||
onArchiveNote,
|
||||
onPromoteNote,
|
||||
@@ -52,24 +57,15 @@ export const ResearchInspector: React.FC<ResearchInspectorProps> = ({
|
||||
const [draftTitle, setDraftTitle] = useState('');
|
||||
const [draftBody, setDraftBody] = useState('');
|
||||
const [draftType, setDraftType] = useState<NoteType>('claim');
|
||||
const [auditTrail, setAuditTrail] = useState<NoteAuditTrail | null>(null);
|
||||
const [isLoadingAudit, setIsLoadingAudit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!note) {
|
||||
setAuditTrail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftTitle(note.title ?? '');
|
||||
setDraftBody(note.rawText);
|
||||
setDraftType(note.noteType);
|
||||
setIsLoadingAudit(true);
|
||||
void researchBridge.getNoteAuditTrail({ noteId: note.id }).then((trail) => {
|
||||
setAuditTrail(trail);
|
||||
}).finally(() => {
|
||||
setIsLoadingAudit(false);
|
||||
});
|
||||
}, [note]);
|
||||
|
||||
if (!note && !ghost) {
|
||||
@@ -227,21 +223,12 @@ export const ResearchInspector: React.FC<ResearchInspectorProps> = ({
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-[#7d90a7]">Audit Trail</div>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (note) {
|
||||
setIsLoadingAudit(true);
|
||||
void researchBridge.getNoteAuditTrail({ noteId: note.id }).then((trail) => {
|
||||
setAuditTrail(trail);
|
||||
}).finally(() => {
|
||||
setIsLoadingAudit(false);
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={onRefreshAuditTrail}
|
||||
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" />
|
||||
|
||||
@@ -81,6 +81,7 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
const deferredSearch = useDeferredValue(filters.search);
|
||||
const [memoDraft, setMemoDraft] = useState('');
|
||||
const [auditTrail, setAuditTrail] = useState<NoteAuditTrail | null>(null);
|
||||
const [isLoadingAuditTrail, setIsLoadingAuditTrail] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWorkspaceId) {
|
||||
@@ -126,6 +127,7 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
]);
|
||||
|
||||
const projection = projectionState.projection;
|
||||
const currentWorkspace = projection?.workspace ?? workspaceState.workspace;
|
||||
|
||||
const filteredNotes = useMemo(() => {
|
||||
const notes = projection?.notes ?? [];
|
||||
@@ -166,17 +168,36 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
const selectedNote = filteredNotes.find((note) => selection.selectedNoteIds.includes(note.id)) ?? 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(() => {
|
||||
if (!selectedNote) {
|
||||
setAuditTrail(null);
|
||||
setIsLoadingAuditTrail(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void researchBridge.getNoteAuditTrail({ noteId: selectedNote.id }).then((trail) => {
|
||||
setIsLoadingAuditTrail(true);
|
||||
void researchBridge
|
||||
.getNoteAuditTrail({ noteId: selectedNote.id })
|
||||
.then((trail) => {
|
||||
if (!cancelled) {
|
||||
setAuditTrail(trail);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoadingAuditTrail(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -340,24 +361,23 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
<ResearchCaptureBar
|
||||
workspaces={workspaces}
|
||||
defaultWorkspaceId={activeWorkspaceId}
|
||||
defaultTicker={workspaceState.workspace?.primaryTicker ?? navigationIntent?.ticker}
|
||||
contextLabel={workspaceState.workspace ? `${workspaceState.workspace.primaryTicker} workspace` : 'Quick capture'}
|
||||
defaultTicker={currentWorkspace?.primaryTicker ?? navigationIntent?.ticker}
|
||||
contextLabel={currentWorkspace ? `${currentWorkspace.primaryTicker} workspace` : 'Quick capture'}
|
||||
onWorkspaceChange={onSelectWorkspace}
|
||||
onEnsureWorkspace={onEnsureWorkspace}
|
||||
onSubmitCapture={(draft) =>
|
||||
onCaptureResearchNote({
|
||||
draft,
|
||||
fallbackTicker: workspaceState.workspace?.primaryTicker ?? navigationIntent?.ticker,
|
||||
fallbackTicker: currentWorkspace?.primaryTicker ?? navigationIntent?.ticker,
|
||||
explicitWorkspaceId: activeWorkspaceId,
|
||||
autoCreateFromTicker: Boolean(
|
||||
workspaceState.workspace?.primaryTicker ?? navigationIntent?.ticker,
|
||||
currentWorkspace?.primaryTicker ?? navigationIntent?.ticker,
|
||||
),
|
||||
})
|
||||
}
|
||||
onCaptured={(note) => {
|
||||
onSelectWorkspace(note.workspaceId);
|
||||
selection.selectNote(note.id);
|
||||
void projectionState.refreshProjection();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -378,9 +398,13 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
}
|
||||
toolbar={
|
||||
<ResearchToolbar
|
||||
workspace={workspaceState.workspace}
|
||||
workspace={currentWorkspace}
|
||||
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}
|
||||
onDensityChange={setDensity}
|
||||
onToggleInspector={toggleInspector}
|
||||
@@ -392,6 +416,13 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
|
||||
<ResearchInspector
|
||||
note={selectedNote}
|
||||
ghost={selectedGhost}
|
||||
auditTrail={auditTrail}
|
||||
isLoadingAuditTrail={isLoadingAuditTrail}
|
||||
onRefreshAuditTrail={() => {
|
||||
if (selectedNote) {
|
||||
void refreshAuditTrail(selectedNote.id);
|
||||
}
|
||||
}}
|
||||
onUpdateNote={(noteId, patch) => workspaceState.updateNote({ noteId, ...patch })}
|
||||
onArchiveNote={(noteId, archived) => workspaceState.archiveNote(noteId, archived)}
|
||||
onPromoteNote={(noteId, thesisStatus, noteType) =>
|
||||
|
||||
@@ -7,9 +7,17 @@ import {
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useResearchEventSubscriptions } from '../lib/researchEvents';
|
||||
import {
|
||||
replaceProjectionWorkspace,
|
||||
upsertProjectionGhost,
|
||||
upsertProjectionNote,
|
||||
} from '../lib/researchProjection';
|
||||
import { researchBridge } from '../lib/researchBridge';
|
||||
import type {
|
||||
GhostNote,
|
||||
PipelineJob,
|
||||
ResearchNote,
|
||||
ResearchWorkspace,
|
||||
WorkspaceProjection,
|
||||
WorkspaceViewKind,
|
||||
} from '../types/research';
|
||||
@@ -26,6 +34,9 @@ type ProjectionAction =
|
||||
| { type: 'load_started'; refresh: boolean }
|
||||
| { type: 'load_succeeded'; projection: WorkspaceProjection }
|
||||
| { 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 };
|
||||
|
||||
const createProjectionState = (): ProjectionState => ({
|
||||
@@ -71,6 +82,27 @@ const projectionReducer = (
|
||||
[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:
|
||||
return state;
|
||||
}
|
||||
@@ -132,19 +164,32 @@ export const useResearchProjection = (
|
||||
|
||||
useResearchEventSubscriptions({
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
onWorkspaceUpdate: () => {
|
||||
scheduleRefresh();
|
||||
onWorkspaceUpdate: (payload) => {
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'workspace_updated', workspace: payload.workspace });
|
||||
});
|
||||
},
|
||||
onNoteUpdate: () => {
|
||||
scheduleRefresh();
|
||||
onNoteUpdate: (payload) => {
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'note_updated', note: payload.note });
|
||||
});
|
||||
},
|
||||
onGhostUpdate: () => {
|
||||
scheduleRefresh();
|
||||
onGhostUpdate: (payload) => {
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'ghost_updated', ghost: payload.ghost });
|
||||
});
|
||||
},
|
||||
onJobUpdate: (payload) => {
|
||||
startTransition(() => {
|
||||
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/**"],
|
||||
},
|
||||
},
|
||||
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