feat(news): add news ingestion and panel integration

Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
2026-04-08 23:23:38 -04:00
parent 51e2f74d5b
commit 7696464b64
57 changed files with 7440 additions and 250 deletions

View File

@@ -6,6 +6,30 @@ This template should help get you started developing with Tauri, React and Types
- [Rig Agent Harness Architecture](./docs/rig-agent-harness.md)
## Local-First News
The news runtime lives in `src-tauri/src/news/` and stores feed state plus articles in a local SQLite database under the app data directory. Reads are local-first: the UI and `/news` terminal command render cached articles immediately, while refreshes update the local cache in the background.
Frontend example:
```ts
import { useNewsFeed } from './src/news';
const { articles, refresh, toggleSaved, markRead } = useNewsFeed({
onlyHighlighted: true,
limit: 20,
});
```
Terminal usage:
```text
/news
/news NVDA
```
`/news` never fetches the network at read time. It filters articles already persisted in the local news database.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)

View File

@@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"test": "bun test"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.2",

View File

@@ -83,6 +83,16 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -271,6 +281,19 @@ dependencies = [
"system-deps",
]
[[package]]
name = "atom_syndication"
version = "0.12.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f68d23e2cb4fd958c705b91a6b4c80ceeaf27a9e11651272a8389d5ce1a4a3"
dependencies = [
"chrono",
"derive_builder",
"diligent-date-parser",
"never",
"quick-xml 0.37.5",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -893,14 +916,38 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
"darling_core 0.23.0",
"darling_macro 0.23.0",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.117",
]
[[package]]
@@ -916,13 +963,24 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"darling_core 0.23.0",
"quote",
"syn 2.0.117",
]
@@ -933,6 +991,24 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "deadpool"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
dependencies = [
"deadpool-runtime",
"lazy_static",
"num_cpus",
"tokio",
]
[[package]]
name = "deadpool-runtime"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
[[package]]
name = "deranged"
version = "0.5.8"
@@ -943,6 +1019,37 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling 0.20.11",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -987,6 +1094,15 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "diligent-date-parser"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ede7d79366f419921e2e2f67889c12125726692a313bffb474bd5f37a581e9"
dependencies = [
"chrono",
]
[[package]]
name = "dirs"
version = "6.0.0"
@@ -1235,6 +1351,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -1825,6 +1953,15 @@ dependencies = [
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1840,6 +1977,15 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -1925,6 +2071,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.9.0"
@@ -1939,6 +2091,7 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -2466,6 +2619,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2624,23 +2788,30 @@ dependencies = [
name = "mosaiciq"
version = "0.1.0"
dependencies = [
"atom_syndication",
"chrono",
"chrono-tz",
"crabrl",
"futures",
"hex",
"quick-xml 0.36.2",
"regex",
"reqwest 0.12.28",
"rig-core",
"rss",
"rusqlite",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-opener",
"tauri-plugin-store",
"tempfile",
"thiserror 2.0.18",
"tokio",
"urlencoding",
"wiremock",
"yfinance-rs",
]
@@ -2727,6 +2898,12 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "never"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91"
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@@ -2764,6 +2941,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "num_enum"
version = "0.7.6"
@@ -3678,6 +3865,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"encoding_rs",
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -4130,6 +4327,32 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rss"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2107738f003660f0a91f56fd3e3bd3ab5d918b2ddaf1e1ec2136fb1c46f71bf"
dependencies = [
"atom_syndication",
"derive_builder",
"never",
"quick-xml 0.37.5",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.11.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rust_decimal"
version = "1.41.0"
@@ -4559,7 +4782,7 @@ version = "3.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65"
dependencies = [
"darling",
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -6674,6 +6897,29 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "wiremock"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
"assert-json-diff",
"base64 0.22.1",
"deadpool",
"futures",
"http",
"http-body-util",
"hyper",
"hyper-util",
"log",
"once_cell",
"regex",
"serde",
"serde_json",
"tokio",
"url",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"

View File

@@ -24,7 +24,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
rig-core = "0.34.0"
tauri-plugin-store = "2"
tokio = { version = "1", features = ["time", "sync"] }
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "sync", "time"] }
futures = "0.3"
reqwest = { version = "0.12", features = ["json", "cookies", "gzip", "brotli"] }
chrono = { version = "0.4", features = ["clock"] }
@@ -35,6 +35,13 @@ regex = "1"
thiserror = "2"
urlencoding = "2"
yfinance-rs = "0.7.2"
atom_syndication = "0.12"
hex = "0.4"
rss = "2"
rusqlite = { version = "0.32", features = ["bundled"] }
sha2 = "0.10"
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
tempfile = "3"
wiremock = "0.6"

View File

@@ -0,0 +1,34 @@
{
"feeds": [
{
"id": "fed-press-all",
"name": "Federal Reserve Press Releases",
"url": "https://www.federalreserve.gov/feeds/press_all.xml",
"refreshMinutes": 30
},
{
"id": "fed-monetary",
"name": "Federal Reserve Monetary Policy",
"url": "https://www.federalreserve.gov/feeds/press_monetary.xml",
"refreshMinutes": 15
},
{
"id": "sec-press",
"name": "SEC Press Releases",
"url": "https://www.sec.gov/news/pressreleases.rss",
"refreshMinutes": 15
},
{
"id": "sec-8k",
"name": "SEC Current 8-K Filings",
"url": "https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type=8-K&count=100&output=atom",
"refreshMinutes": 15
},
{
"id": "sec-10q",
"name": "SEC Current 10-Q Filings",
"url": "https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type=10-Q&count=100&output=atom",
"refreshMinutes": 15
}
]
}

View File

@@ -4,10 +4,11 @@ use rig::completion::Message;
use crate::agent::ChatPanelContext;
use crate::error::AppError;
use crate::news::NewsArticle;
use crate::terminal::{
CashFlowPanelData, CashFlowPeriod, Company, CompanyPricePoint, DividendEvent,
DividendsPanelData, EarningsPanelData, EarningsPeriod, ErrorPanel, FinancialsPanelData,
Holding, NewsItem, PanelPayload, Portfolio, SourceStatus, StatementPeriod, StockAnalysis,
Holding, PanelPayload, Portfolio, SourceStatus, StatementPeriod, StockAnalysis,
};
const MAX_TEXT_FIELD_LENGTH: usize = 600;
@@ -153,19 +154,26 @@ fn compact_portfolio_panel(data: &Portfolio) -> Value {
})
}
fn compact_news_panel(data: &[NewsItem], ticker: Option<&str>) -> Value {
fn compact_news_panel(data: &[NewsArticle], ticker: Option<&str>) -> Value {
json!({
"ticker": ticker,
"items": data
.iter()
.take(MAX_NEWS_ITEMS)
.map(|item| json!({
"id": item.id,
"sourceId": item.source_id,
"source": truncate_text(&item.source),
"headline": truncate_text(&item.headline),
"timestamp": item.timestamp,
"snippet": truncate_text(&item.snippet),
"summary": truncate_text(&item.summary),
"url": item.url,
"relatedTickers": item.related_tickers,
"publishedAt": item.published_at,
"publishedTs": item.published_ts,
"sentiment": item.sentiment,
"highlightReason": item.highlight_reason,
"tickers": item.tickers,
"isRead": item.is_read,
"isSaved": item.is_saved,
}))
.collect::<Vec<_>>(),
})
@@ -393,10 +401,11 @@ mod tests {
use super::{build_panel_context_message, compact_panel_payload, truncate_text};
use crate::agent::ChatPanelContext;
use crate::news::types::{HighlightReason, NewsArticle, NewsSentiment};
use crate::terminal::{
CashFlowPanelData, CashFlowPeriod, Company, CompanyProfile, DividendEvent,
DividendsPanelData, EarningsPanelData, EarningsPeriod, ErrorPanel, FilingRef,
FinancialsPanelData, Frequency, Holding, NewsItem, PanelPayload, Portfolio, SourceStatus,
FinancialsPanelData, Frequency, Holding, PanelPayload, Portfolio, SourceStatus,
StatementPeriod, StockAnalysis,
};
@@ -422,7 +431,7 @@ mod tests {
let items = value["items"].as_array().unwrap();
assert_eq!(items.len(), 5);
assert!(items[0]["snippet"].as_str().unwrap().len() <= 603);
assert!(items[0]["summary"].as_str().unwrap().len() <= 603);
}
#[test]
@@ -555,15 +564,24 @@ mod tests {
}
}
fn sample_news_item(index: usize) -> NewsItem {
NewsItem {
fn sample_news_item(index: usize) -> NewsArticle {
NewsArticle {
id: format!("news-{index}"),
source_id: "source-id".to_string(),
source: "Source".to_string(),
headline: format!("Headline {index}"),
timestamp: "2026-04-06T10:00:00Z".to_string(),
snippet: "S".repeat(650),
summary: "S".repeat(650),
url: Some("https://example.com/story".to_string()),
related_tickers: vec!["AAPL".to_string()],
canonical_url: Some("https://example.com/story".to_string()),
published_at: "2026-04-06T10:00:00Z".to_string(),
published_ts: 1_775_469_600,
fetched_at: "2026-04-06T10:05:00Z".to_string(),
sentiment: NewsSentiment::Bull,
sentiment_score: 0.66,
highlight_reason: Some(HighlightReason::TickerDetected),
tickers: vec!["AAPL".to_string()],
is_read: false,
is_saved: false,
}
}

View File

@@ -1,4 +1,5 @@
//! Tauri command handlers.
pub mod news;
pub mod settings;
pub mod terminal;

View File

@@ -0,0 +1,49 @@
use tauri::{AppHandle, Emitter};
use crate::news::{
QueryNewsFeedRequest, QueryNewsFeedResponse, RefreshNewsFeedRequest, RefreshNewsFeedResult,
UpdateNewsArticleStateRequest,
};
use crate::state::AppState;
#[tauri::command]
pub async fn query_news_feed(
state: tauri::State<'_, AppState>,
request: QueryNewsFeedRequest,
) -> Result<QueryNewsFeedResponse, String> {
state
.news_service
.query_feed(request)
.await
.map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn refresh_news_feed(
app: AppHandle,
state: tauri::State<'_, AppState>,
request: RefreshNewsFeedRequest,
) -> Result<RefreshNewsFeedResult, String> {
let result = state
.news_service
.refresh_feed(request)
.await
.map_err(|error| error.to_string())?;
app.emit("news_feed_updated", &result)
.map_err(|error| error.to_string())?;
Ok(result)
}
#[tauri::command]
pub async fn update_news_article_state(
state: tauri::State<'_, AppState>,
request: UpdateNewsArticleStateRequest,
) -> Result<(), String> {
state
.news_service
.update_article_state(request)
.await
.map_err(|error| error.to_string())
}

View File

@@ -7,13 +7,14 @@
mod agent;
mod commands;
mod error;
mod news;
mod portfolio;
mod state;
mod terminal;
#[cfg(test)]
mod test_support;
use tauri::Manager;
use tauri::{Emitter, Manager};
/// Starts the Tauri application and registers the backend command surface.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -23,8 +24,13 @@ pub fn run() {
.setup(|app| {
let state = state::AppState::new(app.handle())
.map_err(|error| -> Box<dyn std::error::Error> { Box::new(error) })?;
let news_service = state.news_service.clone();
let app_handle = app.handle().clone();
app.manage(state);
news::scheduler::spawn_news_scheduler(news_service, move |result| {
let _ = app_handle.emit("news_feed_updated", &result);
});
Ok(())
})
.plugin(tauri_plugin_opener::init())
@@ -36,7 +42,10 @@ pub fn run() {
commands::settings::get_agent_config_status,
commands::settings::save_agent_runtime_config,
commands::settings::update_remote_api_key,
commands::settings::clear_remote_api_key
commands::settings::clear_remote_api_key,
commands::news::query_news_feed,
commands::news::refresh_news_feed,
commands::news::update_news_article_state
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -0,0 +1,304 @@
use std::collections::{BTreeSet, HashSet};
use std::sync::OnceLock;
use chrono::Utc;
use regex::Regex;
use reqwest::Url;
use sha2::{Digest, Sha256};
use crate::news::types::{
ClassifiedNewsArticle, HighlightReason, NewsSentiment, ParsedNewsArticle,
};
const POSITIVE_KEYWORDS: [(&str, f64); 8] = [
("surge", 0.22),
("beats", 0.18),
("record", 0.16),
("growth", 0.14),
("upgrades", 0.18),
("expands", 0.14),
("strong demand", 0.2),
("raises guidance", 0.28),
];
const NEGATIVE_KEYWORDS: [(&str, f64); 8] = [
("plunge", -0.24),
("misses", -0.18),
("cuts guidance", -0.28),
("layoffs", -0.16),
("downgrade", -0.18),
("fraud", -0.3),
("investigation", -0.22),
("default", -0.3),
];
pub fn classify_article(article: ParsedNewsArticle) -> ClassifiedNewsArticle {
let sentiment_score = sentiment_score(&article.headline, &article.summary);
let sentiment = sentiment_label(sentiment_score);
let tickers = extract_tickers(&article.headline, &article.summary);
let canonical_url = normalize_url(article.canonical_url.as_deref()).or(article.canonical_url);
let highlight_reason = highlight_reason(
&article.headline,
&article.summary,
sentiment_score,
!tickers.is_empty(),
article.published_ts,
&article.source_id,
);
let fingerprint = fingerprint(
canonical_url.as_deref(),
&article.source,
&article.headline,
article.published_ts,
&article.summary,
);
ClassifiedNewsArticle {
fingerprint,
source_id: article.source_id,
source: article.source,
headline: article.headline,
summary: article.summary,
url: article.url,
canonical_url,
published_at: article.published_at,
published_ts: article.published_ts,
fetched_at: article.fetched_at,
sentiment,
sentiment_score,
highlight_reason,
tickers,
}
}
pub fn sentiment_label(score: f64) -> NewsSentiment {
if score >= 0.35 {
NewsSentiment::Bull
} else if score <= -0.35 {
NewsSentiment::Bear
} else {
NewsSentiment::Neutral
}
}
pub fn sentiment_score(headline: &str, summary: &str) -> f64 {
let haystack = format!(
"{} {}",
headline.to_ascii_lowercase(),
summary.to_ascii_lowercase()
);
let score = POSITIVE_KEYWORDS
.iter()
.chain(NEGATIVE_KEYWORDS.iter())
.filter(|(keyword, _)| haystack.contains(keyword))
.map(|(_, weight)| *weight)
.sum::<f64>();
score.clamp(-1.0, 1.0)
}
pub fn extract_tickers(headline: &str, summary: &str) -> Vec<String> {
static DOLLAR_RE: OnceLock<Regex> = OnceLock::new();
static PAREN_RE: OnceLock<Regex> = OnceLock::new();
static CAPS_RE: OnceLock<Regex> = OnceLock::new();
static STOPLIST: OnceLock<HashSet<&'static str>> = OnceLock::new();
let stoplist = STOPLIST.get_or_init(|| {
[
"USA", "CEO", "ETF", "GDP", "CPI", "SEC", "FED", "USD", "EPS", "AI", "IPO", "DOJ",
"FOMC", "ECB",
]
.into_iter()
.collect()
});
let mut tickers = BTreeSet::new();
let full_text = format!("{headline} {summary}");
for captures in DOLLAR_RE
.get_or_init(|| Regex::new(r"\$([A-Z]{1,5})\b").expect("dollar ticker regex"))
.captures_iter(&full_text)
{
tickers.insert(captures[1].to_string());
}
for captures in PAREN_RE
.get_or_init(|| Regex::new(r"\(([A-Z]{1,5})\)").expect("paren ticker regex"))
.captures_iter(&full_text)
{
tickers.insert(captures[1].to_string());
}
for captures in CAPS_RE
.get_or_init(|| Regex::new(r"\b([A-Z]{1,5})\b").expect("caps ticker regex"))
.captures_iter(headline)
.chain(
CAPS_RE
.get_or_init(|| Regex::new(r"\b([A-Z]{1,5})\b").expect("caps ticker regex"))
.captures_iter(summary),
)
{
let candidate = captures[1].to_string();
if !stoplist.contains(candidate.as_str()) {
tickers.insert(candidate);
}
}
tickers.into_iter().collect()
}
pub fn fingerprint(
canonical_url: Option<&str>,
source: &str,
headline: &str,
published_ts: i64,
summary: &str,
) -> String {
let payload = if let Some(url) = canonical_url.filter(|value| !value.is_empty()) {
normalize_url(Some(url)).unwrap_or_else(|| url.to_string())
} else {
let published_day = published_ts.div_euclid(86_400);
format!(
"{}|{}|{}|{}",
source.to_ascii_lowercase(),
normalize_text(headline),
published_day,
summary
.chars()
.take(180)
.collect::<String>()
.to_ascii_lowercase()
)
};
hex::encode(Sha256::digest(payload.as_bytes()))
}
fn highlight_reason(
headline: &str,
summary: &str,
sentiment_score: f64,
has_ticker: bool,
published_ts: i64,
source_id: &str,
) -> Option<HighlightReason> {
let haystack = format!(
"{} {}",
headline.to_ascii_lowercase(),
summary.to_ascii_lowercase()
);
if ["breaking", "alert", "just in", "urgent"]
.iter()
.any(|keyword| haystack.contains(keyword))
{
return Some(HighlightReason::BreakingKeyword);
}
if [
"federal reserve",
"interest rate",
"monetary policy",
"inflation",
"cpi",
"gdp",
"jobs report",
"8-k",
"10-q",
]
.iter()
.any(|keyword| haystack.contains(keyword))
{
return Some(HighlightReason::MacroEvent);
}
if sentiment_score.abs() >= 0.7 {
return Some(HighlightReason::StrongSentiment);
}
if has_ticker {
return Some(HighlightReason::TickerDetected);
}
let recent_seconds = Utc::now().timestamp() - published_ts;
if recent_seconds <= 6 * 60 * 60
&& (source_id.contains("sec")
|| source_id.contains("fed")
|| ["filing", "guidance", "earnings", "policy", "meeting"]
.iter()
.any(|keyword| haystack.contains(keyword)))
{
return Some(HighlightReason::RecentHighValue);
}
None
}
fn normalize_url(url: Option<&str>) -> Option<String> {
let raw = url?.trim();
if raw.is_empty() {
return None;
}
let mut parsed = Url::parse(raw).ok()?;
parsed.set_fragment(None);
let retained_pairs = parsed
.query_pairs()
.filter(|(key, _)| !key.starts_with("utm_") && key != "cmpid" && key != "ref")
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect::<Vec<_>>();
parsed
.query_pairs_mut()
.clear()
.extend_pairs(retained_pairs);
let normalized = parsed.to_string().trim_end_matches('?').to_string();
Some(normalized.trim_end_matches('/').to_string())
}
fn normalize_text(value: &str) -> String {
value
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character.is_ascii_whitespace() {
character.to_ascii_lowercase()
} else {
' '
}
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::{extract_tickers, sentiment_label, sentiment_score};
use crate::news::types::NewsSentiment;
#[test]
fn sentiment_label_should_return_bull_above_threshold() {
let score = sentiment_score(
"NVIDIA beats estimates and raises guidance",
"Shares surge on strong demand",
);
assert_eq!(sentiment_label(score), NewsSentiment::Bull);
}
#[test]
fn sentiment_label_should_return_bear_below_threshold() {
let score = sentiment_score(
"Company cuts guidance after fraud investigation",
"Shares plunge on downgrade",
);
assert_eq!(sentiment_label(score), NewsSentiment::Bear);
}
#[test]
fn extract_tickers_should_filter_stoplist_symbols() {
let tickers = extract_tickers("FED mentions $NVDA and (TSLA)", "USA GDP CPI CEO ETF");
assert_eq!(tickers, vec!["NVDA".to_string(), "TSLA".to_string()]);
}
}

View File

@@ -0,0 +1,91 @@
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use crate::news::types::{NewsSourceConfig, NewsSourceConfigFile};
use crate::news::{NewsError, Result};
pub fn load_or_bootstrap_config(
config_path: &Path,
default_config_bytes: &[u8],
) -> Result<Vec<NewsSourceConfig>> {
if !config_path.exists() {
let Some(parent) = config_path.parent() else {
return Err(NewsError::Config(format!(
"config path has no parent: {}",
config_path.display()
)));
};
fs::create_dir_all(parent)?;
fs::write(config_path, default_config_bytes)?;
}
let config = serde_json::from_slice::<NewsSourceConfigFile>(&fs::read(config_path)?)?;
validate_config(&config.feeds)?;
Ok(config.feeds)
}
fn validate_config(feeds: &[NewsSourceConfig]) -> Result<()> {
if feeds.is_empty() {
return Err(NewsError::Config(
"feed configuration must contain at least one feed".to_string(),
));
}
let mut seen_ids = HashSet::new();
for feed in feeds {
if feed.id.trim().is_empty() {
return Err(NewsError::Config("feed id cannot be empty".to_string()));
}
if feed.name.trim().is_empty() {
return Err(NewsError::Config(format!(
"feed {} has an empty name",
feed.id
)));
}
if !(feed.url.starts_with("http://") || feed.url.starts_with("https://")) {
return Err(NewsError::Config(format!(
"feed {} must use http or https",
feed.id
)));
}
if feed.refresh_minutes == 0 {
return Err(NewsError::Config(format!(
"feed {} must use a positive refreshMinutes",
feed.id
)));
}
if !seen_ids.insert(feed.id.clone()) {
return Err(NewsError::Config(format!(
"duplicate feed id in config: {}",
feed.id
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::load_or_bootstrap_config;
#[test]
fn load_or_bootstrap_config_should_copy_default_file_when_missing() {
let temp_dir = tempdir().unwrap();
let config_path = temp_dir.path().join("news-feeds.json");
let feeds = load_or_bootstrap_config(
&config_path,
br#"{"feeds":[{"id":"fed","name":"Fed","url":"https://example.com","refreshMinutes":15}]}"#,
)
.unwrap();
assert_eq!(feeds.len(), 1);
assert!(fs::metadata(config_path).is_ok());
}
}

View File

@@ -0,0 +1,83 @@
use std::time::Duration;
use chrono::Utc;
use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED, USER_AGENT};
use reqwest::{Client, StatusCode};
use crate::news::types::{FeedSourceRecord, FetchResultKind, FetchedFeed};
use crate::news::{NewsError, Result};
const DEFAULT_USER_AGENT: &str = "MosaicIQ/0.1 (local-first-news)";
#[derive(Clone)]
pub struct FeedFetcher {
client: Client,
}
impl FeedFetcher {
pub fn new() -> Result<Self> {
Self::with_timeout(Duration::from_secs(8))
}
pub fn with_timeout(timeout: Duration) -> Result<Self> {
let client = Client::builder().timeout(timeout).build()?;
Ok(Self { client })
}
pub async fn fetch(&self, source: &FeedSourceRecord, force: bool) -> Result<FetchedFeed> {
let checked_at = Utc::now().to_rfc3339();
let mut request = self
.client
.get(&source.url)
.header(USER_AGENT, DEFAULT_USER_AGENT);
if !force {
if let Some(etag) = source.etag.as_deref() {
request = request.header(IF_NONE_MATCH, etag);
}
if let Some(last_modified) = source.last_modified.as_deref() {
request = request.header(IF_MODIFIED_SINCE, last_modified);
}
}
let response = request.send().await?;
let etag = response
.headers()
.get(ETAG)
.and_then(|value| value.to_str().ok())
.map(ToString::to_string);
let last_modified = response
.headers()
.get(LAST_MODIFIED)
.and_then(|value| value.to_str().ok())
.map(ToString::to_string);
if response.status() == StatusCode::NOT_MODIFIED {
return Ok(FetchedFeed {
kind: FetchResultKind::NotModified,
body: None,
etag,
last_modified,
checked_at,
});
}
if !response.status().is_success() {
let status = response.status();
let detail = response.text().await.unwrap_or_default();
return Err(NewsError::Parse(format!(
"feed {} returned {} {}",
source.id, status, detail
)));
}
Ok(FetchedFeed {
kind: FetchResultKind::Updated,
body: Some(response.text().await?),
etag,
last_modified,
checked_at,
})
}
}

View File

@@ -0,0 +1,38 @@
pub mod classifier;
pub mod config;
pub mod fetcher;
pub mod parser;
pub mod repository;
pub mod scheduler;
pub mod service;
pub mod types;
use thiserror::Error;
pub use service::NewsService;
pub use types::{
NewsArticle, QueryNewsFeedRequest, QueryNewsFeedResponse, RefreshNewsFeedRequest,
RefreshNewsFeedResult, UpdateNewsArticleStateRequest,
};
#[derive(Debug, Error)]
pub enum NewsError {
#[error("news configuration error: {0}")]
Config(String),
#[error("news I/O failed: {0}")]
Io(#[from] std::io::Error),
#[error("news HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("news database failed: {0}")]
Db(#[from] rusqlite::Error),
#[error("news JSON failed: {0}")]
Json(#[from] serde_json::Error),
#[error("news task join failed: {0}")]
Join(String),
#[error("news parse failed: {0}")]
Parse(String),
#[error("unknown news article: {0}")]
ArticleNotFound(String),
}
pub type Result<T> = std::result::Result<T, NewsError>;

View File

@@ -0,0 +1,255 @@
use std::io::Cursor;
use std::sync::OnceLock;
use atom_syndication::Feed as AtomFeed;
use chrono::{DateTime, Utc};
use regex::Regex;
use rss::Channel;
use crate::news::types::{FeedSourceRecord, ParsedFeed, ParsedNewsArticle};
use crate::news::{NewsError, Result};
pub fn parse_feed(source: &FeedSourceRecord, body: &str, fetched_at: &str) -> Result<ParsedFeed> {
if let Ok(channel) = Channel::read_from(Cursor::new(body.as_bytes())) {
return Ok(parse_rss(source, &channel, fetched_at));
}
if let Ok(feed) = AtomFeed::read_from(Cursor::new(body.as_bytes())) {
return Ok(parse_atom(source, &feed, fetched_at));
}
Err(NewsError::Parse(format!(
"feed {} is neither valid RSS nor Atom",
source.id
)))
}
fn parse_rss(source: &FeedSourceRecord, channel: &Channel, fetched_at: &str) -> ParsedFeed {
let mut articles = Vec::new();
let mut malformed_entries = 0;
for item in channel.items() {
match parse_rss_item(source, item, fetched_at) {
Ok(article) => articles.push(article),
Err(_) => malformed_entries += 1,
}
}
ParsedFeed {
articles,
malformed_entries,
}
}
fn parse_atom(source: &FeedSourceRecord, feed: &AtomFeed, fetched_at: &str) -> ParsedFeed {
let mut articles = Vec::new();
let mut malformed_entries = 0;
for entry in feed.entries() {
match parse_atom_entry(source, entry, fetched_at) {
Ok(article) => articles.push(article),
Err(_) => malformed_entries += 1,
}
}
ParsedFeed {
articles,
malformed_entries,
}
}
fn parse_rss_item(
source: &FeedSourceRecord,
item: &rss::Item,
fetched_at: &str,
) -> Result<ParsedNewsArticle> {
let headline = item
.title()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| NewsError::Parse(format!("feed {} item missing title", source.id)))?;
let summary = item
.content()
.or_else(|| item.description())
.map(strip_markup)
.unwrap_or_default();
let url = item
.link()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
let published = item.pub_date().or_else(|| {
item.dublin_core_ext()
.and_then(|ext| ext.dates().first().map(String::as_str))
});
Ok(ParsedNewsArticle {
source_id: source.id.clone(),
source: source.name.clone(),
headline: strip_markup(headline),
summary,
canonical_url: url.clone(),
url,
published_at: resolve_published_at(published, fetched_at)?,
published_ts: resolve_published_ts(published, fetched_at)?,
fetched_at: fetched_at.to_string(),
})
}
fn parse_atom_entry(
source: &FeedSourceRecord,
entry: &atom_syndication::Entry,
fetched_at: &str,
) -> Result<ParsedNewsArticle> {
let headline = entry.title().trim();
if headline.is_empty() {
return Err(NewsError::Parse(format!(
"feed {} entry missing title",
source.id
)));
}
let summary = entry
.summary()
.map(|text| strip_markup(text.as_str()))
.or_else(|| {
entry
.content()
.and_then(|content| content.value())
.map(strip_markup)
})
.unwrap_or_default();
let url = entry
.links()
.iter()
.find(|link| link.rel() == "alternate" || link.rel().is_empty() || link.rel() == "self")
.map(|link| link.href().trim().to_string())
.filter(|value| !value.is_empty());
let published = entry
.published()
.map(|value| value.to_rfc3339())
.unwrap_or_else(|| entry.updated().to_rfc3339());
Ok(ParsedNewsArticle {
source_id: source.id.clone(),
source: source.name.clone(),
headline: strip_markup(headline),
summary,
canonical_url: url.clone(),
url,
published_at: resolve_published_at(Some(published.as_str()), fetched_at)?,
published_ts: resolve_published_ts(Some(published.as_str()), fetched_at)?,
fetched_at: fetched_at.to_string(),
})
}
fn resolve_published_at(raw_value: Option<&str>, fallback: &str) -> Result<String> {
match raw_value.and_then(parse_datetime) {
Some(value) => Ok(value.to_rfc3339()),
None if !fallback.is_empty() => Ok(fallback.to_string()),
None => Err(NewsError::Parse(
"feed entry missing publish time".to_string(),
)),
}
}
fn resolve_published_ts(raw_value: Option<&str>, fallback: &str) -> Result<i64> {
match raw_value.and_then(parse_datetime) {
Some(value) => Ok(value.timestamp()),
None if !fallback.is_empty() => DateTime::parse_from_rfc3339(fallback)
.map(|value| value.timestamp())
.map_err(|error| NewsError::Parse(error.to_string())),
None => Err(NewsError::Parse(
"feed entry missing publish timestamp".to_string(),
)),
}
}
fn parse_datetime(value: &str) -> Option<DateTime<Utc>> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
DateTime::parse_from_rfc2822(trimmed)
.or_else(|_| DateTime::parse_from_rfc3339(trimmed))
.map(|value| value.with_timezone(&Utc))
.ok()
}
fn strip_markup(value: &str) -> String {
static TAG_RE: OnceLock<Regex> = OnceLock::new();
static WHITESPACE_RE: OnceLock<Regex> = OnceLock::new();
let without_tags = TAG_RE
.get_or_init(|| Regex::new(r"(?is)<[^>]+>").expect("tag regex should compile"))
.replace_all(value, " ");
WHITESPACE_RE
.get_or_init(|| Regex::new(r"\s+").expect("whitespace regex should compile"))
.replace_all(without_tags.trim(), " ")
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use super::parse_feed;
use crate::news::types::FeedSourceRecord;
#[test]
fn parse_feed_should_read_rss_fixture() {
let parsed = parse_feed(
&sample_source(),
include_str!("../../tests/fixtures/news/sample.rss"),
"2026-04-08T10:00:00Z",
)
.unwrap();
assert_eq!(parsed.articles.len(), 2);
assert_eq!(parsed.articles[0].headline, "Fed signals steady rates");
}
#[test]
fn parse_feed_should_read_atom_fixture() {
let parsed = parse_feed(
&sample_source(),
include_str!("../../tests/fixtures/news/sample.atom"),
"2026-04-08T10:00:00Z",
)
.unwrap();
assert_eq!(parsed.articles.len(), 2);
assert_eq!(parsed.articles[0].source, "Sample Feed");
}
#[test]
fn parse_feed_should_skip_malformed_entries() {
let parsed = parse_feed(
&sample_source(),
include_str!("../../tests/fixtures/news/malformed.rss"),
"2026-04-08T10:00:00Z",
)
.unwrap();
assert_eq!(parsed.articles.len(), 1);
assert_eq!(parsed.malformed_entries, 1);
}
fn sample_source() -> FeedSourceRecord {
FeedSourceRecord {
id: "sample".to_string(),
name: "Sample Feed".to_string(),
url: "https://example.com/feed.xml".to_string(),
refresh_minutes: 15,
etag: None,
last_modified: None,
last_checked_at: None,
last_success_at: None,
last_error: None,
failure_count: 0,
}
}
}

View File

@@ -0,0 +1,751 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use rusqlite::types::Value;
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
use crate::news::types::{
ArticleUpsertSummary, ClassifiedNewsArticle, FeedSourceRecord, HighlightReason, NewsArticle,
NewsSentiment, NewsSourceConfig, QueryNewsFeedRequest, QueryNewsFeedResponse,
UpdateNewsArticleStateRequest,
};
use crate::news::{NewsError, Result};
#[derive(Clone)]
pub struct NewsRepository {
db_path: PathBuf,
}
impl NewsRepository {
pub fn new(db_path: PathBuf) -> Result<Self> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let repository = Self { db_path };
let connection = repository.open_connection()?;
repository.initialize_schema(&connection)?;
Ok(repository)
}
pub fn sync_sources_blocking(&self, sources: Vec<NewsSourceConfig>) -> Result<()> {
let mut connection = self.open_connection()?;
sync_sources_in_connection(&mut connection, sources)
}
pub async fn sync_sources(&self, sources: Vec<NewsSourceConfig>) -> Result<()> {
self.with_connection(move |connection| sync_sources_in_connection(connection, sources))
.await
}
pub async fn list_sources(&self) -> Result<Vec<FeedSourceRecord>> {
self.with_connection(|connection| {
let mut statement = connection.prepare(
"SELECT id, name, url, refresh_minutes, etag, last_modified,
last_checked_at, last_success_at, last_error, failure_count
FROM feed_sources
ORDER BY name ASC",
)?;
let rows = statement.query_map([], |row| {
Ok(FeedSourceRecord {
id: row.get(0)?,
name: row.get(1)?,
url: row.get(2)?,
refresh_minutes: row.get::<_, u32>(3)?,
etag: row.get(4)?,
last_modified: row.get(5)?,
last_checked_at: row.get(6)?,
last_success_at: row.get(7)?,
last_error: row.get(8)?,
failure_count: row.get::<_, u32>(9)?,
})
})?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(NewsError::from)
})
.await
}
pub async fn record_fetch_success(
&self,
source_id: String,
checked_at: String,
etag: Option<String>,
last_modified: Option<String>,
) -> Result<()> {
self.with_connection(move |connection| {
connection.execute(
"UPDATE feed_sources
SET etag = COALESCE(?2, etag),
last_modified = COALESCE(?3, last_modified),
last_checked_at = ?4,
last_success_at = ?4,
last_error = NULL,
failure_count = 0
WHERE id = ?1",
params![source_id, etag, last_modified, checked_at],
)?;
Ok(())
})
.await
}
pub async fn record_fetch_failure(
&self,
source_id: String,
checked_at: String,
error_message: String,
) -> Result<()> {
self.with_connection(move |connection| {
connection.execute(
"UPDATE feed_sources
SET last_checked_at = ?2,
last_error = ?3,
failure_count = failure_count + 1
WHERE id = ?1",
params![source_id, checked_at, error_message],
)?;
Ok(())
})
.await
}
pub async fn upsert_articles(
&self,
articles: Vec<ClassifiedNewsArticle>,
) -> Result<ArticleUpsertSummary> {
self.with_connection(move |connection| {
let transaction = connection.transaction()?;
let mut summary = ArticleUpsertSummary::default();
for article in articles {
let existing = transaction
.query_row(
"SELECT id, source_id, source, headline, summary, url, canonical_url,
published_at, published_ts, sentiment, sentiment_score, highlight_reason
FROM articles
WHERE fingerprint = ?1",
params![article.fingerprint.clone()],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, Option<String>>(5)?,
row.get::<_, Option<String>>(6)?,
row.get::<_, String>(7)?,
row.get::<_, i64>(8)?,
row.get::<_, String>(9)?,
row.get::<_, f64>(10)?,
row.get::<_, Option<String>>(11)?,
))
},
)
.optional()?;
let is_changed = existing
.as_ref()
.map(|existing| {
existing.1 != article.source_id
|| existing.2 != article.source
|| existing.3 != article.headline
|| existing.4 != article.summary
|| existing.5 != article.url
|| existing.6 != article.canonical_url
|| existing.7 != article.published_at
|| existing.8 != article.published_ts
|| existing.9 != sentiment_to_db(&article.sentiment)
|| (existing.10 - article.sentiment_score).abs() > f64::EPSILON
|| existing.11 != article.highlight_reason.as_ref().map(highlight_to_db)
})
.unwrap_or(true);
if existing.is_none() {
summary.new_articles += 1;
} else if is_changed {
summary.updated_articles += 1;
} else {
summary.unchanged_articles += 1;
}
transaction.execute(
"INSERT INTO articles (
id, source_id, source, headline, summary, url, canonical_url, fingerprint,
published_at, published_ts, fetched_at, sentiment, sentiment_score,
highlight_reason, is_read, is_saved
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?1, ?8, ?9, ?10, ?11, ?12, ?13, 0, 0)
ON CONFLICT(id) DO UPDATE SET
source_id = excluded.source_id,
source = excluded.source,
headline = excluded.headline,
summary = excluded.summary,
url = excluded.url,
canonical_url = excluded.canonical_url,
published_at = excluded.published_at,
published_ts = excluded.published_ts,
fetched_at = excluded.fetched_at,
sentiment = excluded.sentiment,
sentiment_score = excluded.sentiment_score,
highlight_reason = excluded.highlight_reason",
params![
article.fingerprint,
article.source_id,
article.source,
article.headline,
article.summary,
article.url,
article.canonical_url,
article.published_at,
article.published_ts,
article.fetched_at,
sentiment_to_db(&article.sentiment),
article.sentiment_score,
article.highlight_reason.as_ref().map(highlight_to_db),
],
)?;
transaction.execute(
"DELETE FROM article_tickers WHERE article_id = ?1",
params![article.fingerprint],
)?;
for ticker in &article.tickers {
transaction.execute(
"INSERT OR IGNORE INTO article_tickers (article_id, ticker) VALUES (?1, ?2)",
params![article.fingerprint, ticker],
)?;
}
transaction.execute(
"DELETE FROM news_fts WHERE article_id = ?1",
params![article.fingerprint],
)?;
transaction.execute(
"INSERT INTO news_fts (article_id, headline, summary) VALUES (?1, ?2, ?3)",
params![article.fingerprint, article.headline, article.summary],
)?;
}
transaction.commit()?;
Ok(summary)
})
.await
}
pub async fn query_articles(
&self,
request: QueryNewsFeedRequest,
) -> Result<QueryNewsFeedResponse> {
self.with_connection(move |connection| query_articles(connection, request))
.await
}
pub async fn update_article_state(&self, request: UpdateNewsArticleStateRequest) -> Result<()> {
self.with_connection(move |connection| {
let rows_updated = connection.execute(
"UPDATE articles
SET is_read = COALESCE(?2, is_read),
is_saved = COALESCE(?3, is_saved)
WHERE id = ?1",
params![
request.article_id,
request.is_read.map(i64::from),
request.is_saved.map(i64::from),
],
)?;
if rows_updated == 0 {
return Err(NewsError::ArticleNotFound(request.article_id));
}
Ok(())
})
.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;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
CREATE TABLE IF NOT EXISTS feed_sources (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
refresh_minutes INTEGER NOT NULL,
etag TEXT,
last_modified TEXT,
last_checked_at TEXT,
last_success_at TEXT,
last_error TEXT,
failure_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS articles (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES feed_sources(id) ON DELETE CASCADE,
source TEXT NOT NULL,
headline TEXT NOT NULL,
summary TEXT NOT NULL,
url TEXT,
canonical_url TEXT,
fingerprint TEXT NOT NULL UNIQUE,
published_at TEXT NOT NULL,
published_ts INTEGER NOT NULL,
fetched_at TEXT NOT NULL,
sentiment TEXT NOT NULL,
sentiment_score REAL NOT NULL,
highlight_reason TEXT,
is_read INTEGER NOT NULL DEFAULT 0,
is_saved INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS articles_published_ts_idx ON articles (published_ts DESC);
CREATE INDEX IF NOT EXISTS articles_highlight_idx ON articles (highlight_reason);
CREATE INDEX IF NOT EXISTS articles_saved_idx ON articles (is_saved, published_ts DESC);
CREATE INDEX IF NOT EXISTS articles_read_idx ON articles (is_read, published_ts DESC);
CREATE TABLE IF NOT EXISTS article_tickers (
article_id TEXT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
ticker TEXT NOT NULL,
PRIMARY KEY (article_id, ticker)
);
CREATE INDEX IF NOT EXISTS article_tickers_ticker_idx ON article_tickers (ticker);
CREATE VIRTUAL TABLE IF NOT EXISTS news_fts USING fts5(article_id UNINDEXED, headline, summary);",
)?;
Ok(())
}
async fn with_connection<F, T>(&self, task: F) -> Result<T>
where
F: FnOnce(&mut Connection) -> Result<T> + Send + 'static,
T: Send + 'static,
{
let db_path = self.db_path.clone();
tokio::task::spawn_blocking(move || {
let mut connection = open_connection(&db_path)?;
task(&mut connection)
})
.await
.map_err(|error| NewsError::Join(error.to_string()))?
}
}
fn sync_sources_in_connection(
connection: &mut Connection,
sources: Vec<NewsSourceConfig>,
) -> Result<()> {
let transaction = connection.transaction()?;
for source in sources {
transaction.execute(
"INSERT INTO feed_sources (
id, name, url, refresh_minutes, failure_count
) VALUES (?1, ?2, ?3, ?4, 0)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
url = excluded.url,
refresh_minutes = excluded.refresh_minutes",
params![
source.id,
source.name,
source.url,
i64::from(source.refresh_minutes)
],
)?;
}
transaction.commit()?;
Ok(())
}
fn open_connection(path: &Path) -> Result<Connection> {
let connection = Connection::open(path)?;
connection.execute_batch(
"PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;",
)?;
Ok(connection)
}
fn query_articles(
connection: &mut Connection,
request: QueryNewsFeedRequest,
) -> Result<QueryNewsFeedResponse> {
let (where_clause, parameters) = build_article_filters(&request);
let count_sql = format!("SELECT COUNT(*) FROM articles a WHERE {where_clause}");
let total = connection.query_row(&count_sql, params_from_iter(parameters.iter()), |row| {
row.get::<_, usize>(0)
})?;
let limit = i64::try_from(request.limit.unwrap_or(50).min(200))
.map_err(|error| NewsError::Parse(error.to_string()))?;
let offset = i64::try_from(request.offset.unwrap_or(0))
.map_err(|error| NewsError::Parse(error.to_string()))?;
let mut query_params = parameters.clone();
query_params.push(Value::Integer(limit));
query_params.push(Value::Integer(offset));
let query_sql = format!(
"SELECT a.id, a.source_id, a.source, a.headline, a.summary, a.url, a.canonical_url,
a.published_at, a.published_ts, a.fetched_at, a.sentiment, a.sentiment_score,
a.highlight_reason, a.is_read, a.is_saved
FROM articles a
WHERE {where_clause}
ORDER BY a.published_ts DESC, a.fetched_at DESC
LIMIT ? OFFSET ?"
);
let mut statement = connection.prepare(&query_sql)?;
let rows = statement.query_map(params_from_iter(query_params.iter()), |row| {
Ok(NewsArticle {
id: row.get(0)?,
source_id: row.get(1)?,
source: row.get(2)?,
headline: row.get(3)?,
summary: row.get(4)?,
url: row.get(5)?,
canonical_url: row.get(6)?,
published_at: row.get(7)?,
published_ts: row.get(8)?,
fetched_at: row.get(9)?,
sentiment: sentiment_from_db(&row.get::<_, String>(10)?),
sentiment_score: row.get(11)?,
highlight_reason: row
.get::<_, Option<String>>(12)?
.as_deref()
.map(highlight_from_db),
is_read: row.get::<_, i64>(13)? != 0,
is_saved: row.get::<_, i64>(14)? != 0,
tickers: Vec::new(),
})
})?;
let mut articles = rows.collect::<std::result::Result<Vec<_>, _>>()?;
let ticker_map = load_tickers_for_articles(connection, &articles)?;
for article in &mut articles {
article.tickers = ticker_map.get(&article.id).cloned().unwrap_or_default();
}
let last_synced_at = connection
.query_row("SELECT MAX(last_success_at) FROM feed_sources", [], |row| {
row.get::<_, Option<String>>(0)
})
.optional()?
.flatten();
let sources = load_source_statuses(connection)?;
Ok(QueryNewsFeedResponse {
articles,
total,
last_synced_at,
sources,
})
}
fn build_article_filters(request: &QueryNewsFeedRequest) -> (String, Vec<Value>) {
let mut clauses = vec!["1 = 1".to_string()];
let mut parameters = Vec::new();
if let Some(ticker) = request
.ticker
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
clauses.push(
"EXISTS (SELECT 1 FROM article_tickers at WHERE at.article_id = a.id AND at.ticker = ?)"
.to_string(),
);
parameters.push(Value::Text(ticker.to_ascii_uppercase()));
}
if request.only_highlighted.unwrap_or(false) {
clauses.push("a.highlight_reason IS NOT NULL".to_string());
}
if request.only_saved.unwrap_or(false) {
clauses.push("a.is_saved = 1".to_string());
}
if request.only_unread.unwrap_or(false) {
clauses.push("a.is_read = 0".to_string());
}
if let Some(search) = request
.search
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
clauses.push(
"EXISTS (SELECT 1 FROM news_fts WHERE news_fts.article_id = a.id AND news_fts MATCH ?)"
.to_string(),
);
parameters.push(Value::Text(fts_query(search)));
}
(clauses.join(" AND "), parameters)
}
fn fts_query(input: &str) -> String {
let tokens = input
.split(|character: char| !character.is_ascii_alphanumeric())
.filter(|value| !value.is_empty())
.map(|value| format!("{value}*"))
.collect::<Vec<_>>();
if tokens.is_empty() {
input.to_string()
} else {
tokens.join(" AND ")
}
}
fn load_tickers_for_articles(
connection: &Connection,
articles: &[NewsArticle],
) -> Result<HashMap<String, Vec<String>>> {
let mut map = HashMap::new();
let mut statement = connection
.prepare("SELECT ticker FROM article_tickers WHERE article_id = ?1 ORDER BY ticker ASC")?;
for article in articles {
let rows = statement.query_map(params![article.id], |row| row.get::<_, String>(0))?;
map.insert(
article.id.clone(),
rows.collect::<std::result::Result<Vec<_>, _>>()?,
);
}
Ok(map)
}
fn load_source_statuses(
connection: &Connection,
) -> Result<Vec<crate::news::types::NewsSourceStatus>> {
let mut statement = connection.prepare(
"SELECT id, name, url, refresh_minutes, last_checked_at, last_success_at, last_error, failure_count
FROM feed_sources
ORDER BY name ASC",
)?;
let rows = statement.query_map([], |row| {
Ok(crate::news::types::NewsSourceStatus {
id: row.get(0)?,
name: row.get(1)?,
url: row.get(2)?,
refresh_minutes: row.get::<_, u32>(3)?,
last_checked_at: row.get(4)?,
last_success_at: row.get(5)?,
last_error: row.get(6)?,
failure_count: row.get::<_, u32>(7)?,
})
})?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(NewsError::from)
}
fn sentiment_to_db(value: &NewsSentiment) -> &'static str {
match value {
NewsSentiment::Bull => "BULL",
NewsSentiment::Bear => "BEAR",
NewsSentiment::Neutral => "NEUTRAL",
}
}
fn sentiment_from_db(value: &str) -> NewsSentiment {
match value {
"BULL" => NewsSentiment::Bull,
"BEAR" => NewsSentiment::Bear,
_ => NewsSentiment::Neutral,
}
}
fn highlight_to_db(value: &HighlightReason) -> String {
match value {
HighlightReason::BreakingKeyword => "breaking_keyword",
HighlightReason::MacroEvent => "macro_event",
HighlightReason::StrongSentiment => "strong_sentiment",
HighlightReason::TickerDetected => "ticker_detected",
HighlightReason::RecentHighValue => "recent_high_value",
}
.to_string()
}
fn highlight_from_db(value: &str) -> HighlightReason {
match value {
"breaking_keyword" => HighlightReason::BreakingKeyword,
"macro_event" => HighlightReason::MacroEvent,
"strong_sentiment" => HighlightReason::StrongSentiment,
"ticker_detected" => HighlightReason::TickerDetected,
_ => HighlightReason::RecentHighValue,
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use super::NewsRepository;
use crate::news::types::{
ClassifiedNewsArticle, HighlightReason, NewsSentiment, NewsSourceConfig,
QueryNewsFeedRequest, UpdateNewsArticleStateRequest,
};
#[tokio::test]
async fn upsert_articles_should_preserve_read_and_saved_flags() {
let repository = sample_repository().await;
seed_source(&repository).await;
repository
.upsert_articles(vec![sample_article("article-1", vec!["NVDA".to_string()])])
.await
.unwrap();
repository
.update_article_state(UpdateNewsArticleStateRequest {
article_id: "article-1".to_string(),
is_read: Some(true),
is_saved: Some(true),
})
.await
.unwrap();
repository
.upsert_articles(vec![ClassifiedNewsArticle {
headline: "Updated headline".to_string(),
..sample_article("article-1", vec!["NVDA".to_string()])
}])
.await
.unwrap();
let response = repository
.query_articles(QueryNewsFeedRequest {
ticker: Some("NVDA".to_string()),
search: None,
only_highlighted: None,
only_saved: Some(true),
only_unread: None,
limit: Some(10),
offset: Some(0),
})
.await
.unwrap();
assert_eq!(response.articles.len(), 1);
assert!(response.articles[0].is_read);
assert!(response.articles[0].is_saved);
}
#[tokio::test]
async fn query_articles_should_filter_by_ticker_saved_unread_highlight_and_search() {
let repository = sample_repository().await;
seed_source(&repository).await;
repository
.upsert_articles(vec![
sample_article("article-1", vec!["NVDA".to_string()]),
ClassifiedNewsArticle {
fingerprint: "article-2".to_string(),
headline: "Fed policy update".to_string(),
summary: "Macro event".to_string(),
tickers: vec!["AAPL".to_string()],
highlight_reason: Some(HighlightReason::MacroEvent),
..sample_article("article-2", vec!["AAPL".to_string()])
},
])
.await
.unwrap();
repository
.update_article_state(UpdateNewsArticleStateRequest {
article_id: "article-2".to_string(),
is_read: Some(true),
is_saved: Some(true),
})
.await
.unwrap();
let response = repository
.query_articles(QueryNewsFeedRequest {
ticker: Some("AAPL".to_string()),
search: Some("policy".to_string()),
only_highlighted: Some(true),
only_saved: Some(true),
only_unread: Some(false),
limit: Some(10),
offset: Some(0),
})
.await
.unwrap();
assert_eq!(response.total, 1);
assert_eq!(response.articles[0].id, "article-2");
}
#[tokio::test]
async fn new_should_create_schema_on_empty_database() {
let root = unique_test_directory("news-repository");
let repository = NewsRepository::new(root.join("news.sqlite")).unwrap();
let response = repository
.query_articles(QueryNewsFeedRequest {
ticker: None,
search: None,
only_highlighted: None,
only_saved: None,
only_unread: None,
limit: Some(5),
offset: Some(0),
})
.await
.unwrap();
assert_eq!(response.total, 0);
}
async fn sample_repository() -> NewsRepository {
let root = unique_test_directory("news-repository");
NewsRepository::new(root.join("news.sqlite")).unwrap()
}
async fn seed_source(repository: &NewsRepository) {
repository
.sync_sources(vec![NewsSourceConfig {
id: "sample".to_string(),
name: "Sample".to_string(),
url: "https://example.com/feed.xml".to_string(),
refresh_minutes: 15,
}])
.await
.unwrap();
}
fn sample_article(fingerprint: &str, tickers: Vec<String>) -> ClassifiedNewsArticle {
ClassifiedNewsArticle {
fingerprint: fingerprint.to_string(),
source_id: "sample".to_string(),
source: "Sample".to_string(),
headline: "NVIDIA beats estimates".to_string(),
summary: "Strong demand lifts outlook".to_string(),
url: Some("https://example.com/story".to_string()),
canonical_url: Some("https://example.com/story".to_string()),
published_at: "2026-04-08T10:00:00Z".to_string(),
published_ts: 1_775_642_400,
fetched_at: "2026-04-08T10:05:00Z".to_string(),
sentiment: NewsSentiment::Bull,
sentiment_score: 0.64,
highlight_reason: Some(HighlightReason::TickerDetected),
tickers,
}
}
fn unique_test_directory(prefix: &str) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("{prefix}-{suffix}"));
fs::create_dir_all(&path).unwrap();
path
}
}

View File

@@ -0,0 +1,28 @@
use std::sync::Arc;
use std::time::Duration;
use tokio::time;
use crate::news::service::NewsService;
use crate::news::types::{RefreshNewsFeedRequest, RefreshNewsFeedResult};
pub fn spawn_news_scheduler<F>(service: Arc<NewsService>, on_refresh: F)
where
F: Fn(RefreshNewsFeedResult) + Send + Sync + 'static,
{
let callback = Arc::new(on_refresh);
tokio::spawn(async move {
time::sleep(Duration::from_secs(5)).await;
loop {
if let Ok(result) = service
.refresh_feed(RefreshNewsFeedRequest { force: Some(false) })
.await
{
callback(result);
}
time::sleep(Duration::from_secs(15 * 60)).await;
}
});
}

View File

@@ -0,0 +1,387 @@
use std::path::PathBuf;
use std::sync::Arc;
use chrono::Utc;
use futures::stream::{self, StreamExt};
use crate::news::classifier::classify_article;
use crate::news::config::load_or_bootstrap_config;
use crate::news::fetcher::FeedFetcher;
use crate::news::parser::parse_feed;
use crate::news::repository::NewsRepository;
use crate::news::types::{
ArticleUpsertSummary, FeedSourceRecord, FetchResultKind, QueryNewsFeedRequest,
QueryNewsFeedResponse, RefreshNewsFeedRequest, RefreshNewsFeedResult,
UpdateNewsArticleStateRequest,
};
use crate::news::Result;
#[derive(Clone)]
pub struct NewsService {
repository: Arc<NewsRepository>,
fetcher: FeedFetcher,
config_path: PathBuf,
default_config_bytes: Arc<Vec<u8>>,
}
impl NewsService {
pub fn new(
db_path: PathBuf,
config_path: PathBuf,
default_config_bytes: &[u8],
) -> Result<Self> {
Self::with_fetcher(
db_path,
config_path,
default_config_bytes,
FeedFetcher::new()?,
)
}
pub(crate) fn with_fetcher(
db_path: PathBuf,
config_path: PathBuf,
default_config_bytes: &[u8],
fetcher: FeedFetcher,
) -> Result<Self> {
let feeds = load_or_bootstrap_config(&config_path, default_config_bytes)?;
let repository = Arc::new(NewsRepository::new(db_path)?);
repository.sync_sources_blocking(feeds)?;
Ok(Self {
repository,
fetcher,
config_path,
default_config_bytes: Arc::new(default_config_bytes.to_vec()),
})
}
pub async fn query_feed(&self, request: QueryNewsFeedRequest) -> Result<QueryNewsFeedResponse> {
self.repository.query_articles(request).await
}
pub async fn refresh_feed(
&self,
request: RefreshNewsFeedRequest,
) -> Result<RefreshNewsFeedResult> {
let force = request.force.unwrap_or(false);
let feeds = load_or_bootstrap_config(&self.config_path, &self.default_config_bytes)?;
self.repository.sync_sources(feeds).await?;
let now = Utc::now();
let sources = self.repository.list_sources().await?;
let due_sources = sources
.into_iter()
.filter(|source| force || source.is_due(now))
.collect::<Vec<_>>();
if due_sources.is_empty() {
return Ok(RefreshNewsFeedResult {
feeds_checked: 0,
feeds_succeeded: 0,
feeds_failed: 0,
new_articles: 0,
updated_articles: 0,
unchanged_articles: 0,
finished_at: now.to_rfc3339(),
});
}
let outcomes = stream::iter(due_sources)
.map(|source| {
let service = self.clone();
async move { service.refresh_source(source, force).await }
})
.buffer_unordered(4)
.collect::<Vec<_>>()
.await;
let mut result = RefreshNewsFeedResult {
feeds_checked: 0,
feeds_succeeded: 0,
feeds_failed: 0,
new_articles: 0,
updated_articles: 0,
unchanged_articles: 0,
finished_at: Utc::now().to_rfc3339(),
};
for outcome in outcomes {
result.feeds_checked += 1;
if outcome.succeeded {
result.feeds_succeeded += 1;
} else {
result.feeds_failed += 1;
}
result.new_articles += outcome.upsert_summary.new_articles;
result.updated_articles += outcome.upsert_summary.updated_articles;
result.unchanged_articles += outcome.upsert_summary.unchanged_articles;
}
Ok(result)
}
pub async fn update_article_state(&self, request: UpdateNewsArticleStateRequest) -> Result<()> {
self.repository.update_article_state(request).await
}
async fn refresh_source(&self, source: FeedSourceRecord, force: bool) -> RefreshOutcome {
let fetched = match self.fetcher.fetch(&source, force).await {
Ok(value) => value,
Err(error) => {
let checked_at = Utc::now().to_rfc3339();
let _ = self
.repository
.record_fetch_failure(source.id, checked_at, error.to_string())
.await;
return RefreshOutcome::failed();
}
};
if fetched.kind == FetchResultKind::NotModified {
let _ = self
.repository
.record_fetch_success(
source.id,
fetched.checked_at,
fetched.etag,
fetched.last_modified,
)
.await;
return RefreshOutcome::succeeded(ArticleUpsertSummary::default());
}
let Some(body) = fetched.body.as_deref() else {
let _ = self
.repository
.record_fetch_failure(
source.id,
fetched.checked_at,
"feed body missing after successful fetch".to_string(),
)
.await;
return RefreshOutcome::failed();
};
let parsed = match parse_feed(&source, body, &fetched.checked_at) {
Ok(value) => value,
Err(error) => {
let _ = self
.repository
.record_fetch_failure(source.id, fetched.checked_at, error.to_string())
.await;
return RefreshOutcome::failed();
}
};
let articles = parsed
.articles
.into_iter()
.map(classify_article)
.collect::<Vec<_>>();
let upsert_summary = match self.repository.upsert_articles(articles).await {
Ok(value) => value,
Err(error) => {
let _ = self
.repository
.record_fetch_failure(source.id, fetched.checked_at, error.to_string())
.await;
return RefreshOutcome::failed();
}
};
let _ = self
.repository
.record_fetch_success(
source.id,
fetched.checked_at,
fetched.etag,
fetched.last_modified,
)
.await;
RefreshOutcome::succeeded(upsert_summary)
}
}
struct RefreshOutcome {
succeeded: bool,
upsert_summary: ArticleUpsertSummary,
}
impl RefreshOutcome {
fn succeeded(upsert_summary: ArticleUpsertSummary) -> Self {
Self {
succeeded: true,
upsert_summary,
}
}
fn failed() -> Self {
Self {
succeeded: false,
upsert_summary: ArticleUpsertSummary::default(),
}
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::time::Duration;
use tempfile::tempdir;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::NewsService;
use crate::news::fetcher::FeedFetcher;
use crate::news::types::{QueryNewsFeedRequest, RefreshNewsFeedRequest};
#[tokio::test]
async fn refresh_feed_should_continue_when_one_feed_times_out() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ok.xml"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/rss+xml")
.set_body_string(include_str!("../../tests/fixtures/news/sample.rss")),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/slow.xml"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(150))
.set_body_string(include_str!("../../tests/fixtures/news/sample.rss")),
)
.mount(&server)
.await;
let temp_dir = tempdir().unwrap();
let config = format!(
r#"{{
"feeds": [
{{"id":"ok","name":"OK Feed","url":"{}/ok.xml","refreshMinutes":15}},
{{"id":"slow","name":"Slow Feed","url":"{}/slow.xml","refreshMinutes":15}}
]
}}"#,
server.uri(),
server.uri(),
);
let service = NewsService::with_fetcher(
temp_dir.path().join("news.sqlite"),
temp_dir.path().join("news-feeds.json"),
config.as_bytes(),
FeedFetcher::with_timeout(Duration::from_millis(50)).unwrap(),
)
.unwrap();
let result = service
.refresh_feed(RefreshNewsFeedRequest { force: Some(true) })
.await
.unwrap();
let response = service
.query_feed(QueryNewsFeedRequest {
ticker: None,
search: None,
only_highlighted: None,
only_saved: None,
only_unread: None,
limit: Some(10),
offset: Some(0),
})
.await
.unwrap();
assert_eq!(result.feeds_checked, 2);
assert_eq!(result.feeds_succeeded, 1);
assert_eq!(result.feeds_failed, 1);
assert_eq!(response.total, 2);
}
#[tokio::test]
async fn refresh_source_should_use_conditional_get_headers_after_initial_sync() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/etag.xml"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("etag", "v1")
.set_body_string(include_str!("../../tests/fixtures/news/sample.rss")),
)
.mount(&server)
.await;
let temp_dir = tempdir().unwrap();
let config = format!(
r#"{{"feeds":[{{"id":"etag","name":"ETag Feed","url":"{}/etag.xml","refreshMinutes":15}}]}}"#,
server.uri(),
);
let service = NewsService::with_fetcher(
temp_dir.path().join("news.sqlite"),
temp_dir.path().join("news-feeds.json"),
config.as_bytes(),
FeedFetcher::with_timeout(Duration::from_millis(100)).unwrap(),
)
.unwrap();
service
.refresh_feed(RefreshNewsFeedRequest { force: Some(true) })
.await
.unwrap();
server.reset().await;
Mock::given(method("GET"))
.and(path("/etag.xml"))
.and(header("if-none-match", "v1"))
.respond_with(ResponseTemplate::new(304))
.mount(&server)
.await;
let source = service
.repository
.list_sources()
.await
.unwrap()
.into_iter()
.next()
.unwrap();
let outcome = service.refresh_source(source, false).await;
assert!(outcome.succeeded);
assert_eq!(outcome.upsert_summary.new_articles, 0);
}
#[tokio::test]
async fn startup_should_create_database_schema_on_empty_path() {
let temp_dir = tempdir().unwrap();
let db_path = temp_dir.path().join("news.sqlite");
let config_path = temp_dir.path().join("news-feeds.json");
let service = NewsService::new(
db_path.clone(),
config_path,
br#"{"feeds":[{"id":"sample","name":"Sample Feed","url":"https://example.com/feed.xml","refreshMinutes":15}]}"#,
)
.unwrap();
let metadata = fs::metadata(db_path).unwrap();
let response = service
.query_feed(QueryNewsFeedRequest {
ticker: None,
search: None,
only_highlighted: None,
only_saved: None,
only_unread: None,
limit: Some(10),
offset: Some(0),
})
.await
.unwrap();
assert!(metadata.is_file());
assert_eq!(response.total, 0);
}
}

View File

@@ -0,0 +1,225 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum NewsSentiment {
Bull,
Bear,
Neutral,
}
impl Default for NewsSentiment {
fn default() -> Self {
Self::Neutral
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HighlightReason {
BreakingKeyword,
MacroEvent,
StrongSentiment,
TickerDetected,
RecentHighValue,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct NewsArticle {
pub id: String,
pub source_id: String,
pub source: String,
pub headline: String,
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub canonical_url: Option<String>,
pub published_at: String,
pub published_ts: i64,
pub fetched_at: String,
pub sentiment: NewsSentiment,
pub sentiment_score: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub highlight_reason: Option<HighlightReason>,
pub tickers: Vec<String>,
pub is_read: bool,
pub is_saved: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct QueryNewsFeedRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub ticker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub search: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub only_highlighted: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub only_saved: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub only_unread: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offset: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct QueryNewsFeedResponse {
pub articles: Vec<NewsArticle>,
pub total: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_synced_at: Option<String>,
pub sources: Vec<NewsSourceStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct RefreshNewsFeedRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub force: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RefreshNewsFeedResult {
pub feeds_checked: usize,
pub feeds_succeeded: usize,
pub feeds_failed: usize,
pub new_articles: usize,
pub updated_articles: usize,
pub unchanged_articles: usize,
pub finished_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UpdateNewsArticleStateRequest {
pub article_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_read: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_saved: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NewsSourceConfig {
pub id: String,
pub name: String,
pub url: String,
pub refresh_minutes: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NewsSourceConfigFile {
pub feeds: Vec<NewsSourceConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NewsSourceStatus {
pub id: String,
pub name: String,
pub url: String,
pub refresh_minutes: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_checked_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_success_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
pub failure_count: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FeedSourceRecord {
pub id: String,
pub name: String,
pub url: String,
pub refresh_minutes: u32,
pub etag: Option<String>,
pub last_modified: Option<String>,
pub last_checked_at: Option<String>,
pub last_success_at: Option<String>,
pub last_error: Option<String>,
pub failure_count: u32,
}
impl FeedSourceRecord {
pub(crate) fn is_due(&self, now: DateTime<Utc>) -> bool {
let Some(last_checked_at) = self.last_checked_at.as_deref() else {
return true;
};
DateTime::parse_from_rfc3339(last_checked_at)
.map(|value| value.with_timezone(&Utc))
.map(|value| now >= value + chrono::Duration::minutes(i64::from(self.refresh_minutes)))
.unwrap_or(true)
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ParsedFeed {
pub articles: Vec<ParsedNewsArticle>,
pub malformed_entries: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ParsedNewsArticle {
pub source_id: String,
pub source: String,
pub headline: String,
pub summary: String,
pub url: Option<String>,
pub canonical_url: Option<String>,
pub published_at: String,
pub published_ts: i64,
pub fetched_at: String,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ClassifiedNewsArticle {
pub fingerprint: String,
pub source_id: String,
pub source: String,
pub headline: String,
pub summary: String,
pub url: Option<String>,
pub canonical_url: Option<String>,
pub published_at: String,
pub published_ts: i64,
pub fetched_at: String,
pub sentiment: NewsSentiment,
pub sentiment_score: f64,
pub highlight_reason: Option<HighlightReason>,
pub tickers: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FetchResultKind {
Updated,
NotModified,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FetchedFeed {
pub kind: FetchResultKind,
pub body: Option<String>,
pub etag: Option<String>,
pub last_modified: Option<String>,
pub checked_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct ArticleUpsertSummary {
pub new_articles: usize,
pub updated_articles: usize,
pub unchanged_articles: usize,
}

View File

@@ -0,0 +1,85 @@
//! AI enrichment abstraction with a deterministic fallback implementation.
use crate::research::heuristics::classify_note;
use crate::research::types::{ModelInfo, NoteType, ResearchNote, SourceKind, ValuationRef};
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct AiEnrichmentResult {
pub note_type: Option<NoteType>,
pub annotation: Option<String>,
pub tags: Vec<String>,
pub risks: Vec<String>,
pub catalysts: Vec<String>,
pub valuation_refs: Vec<ValuationRef>,
pub missing_evidence: bool,
}
pub(crate) trait ResearchAiGateway: Send + Sync {
fn enrich_note(&self, note: &ResearchNote, model_info: Option<ModelInfo>) -> AiEnrichmentResult;
}
#[derive(Debug, Clone, Default)]
pub(crate) struct DeterministicResearchAiGateway;
impl ResearchAiGateway for DeterministicResearchAiGateway {
fn enrich_note(&self, note: &ResearchNote, _model_info: Option<ModelInfo>) -> AiEnrichmentResult {
let heuristic = classify_note(
&note.cleaned_text,
note.provenance.source_kind,
Some(note.note_type),
);
let annotation = Some(build_annotation(note));
AiEnrichmentResult {
note_type: Some(heuristic.note_type),
annotation,
tags: heuristic.tags,
risks: heuristic.risks,
catalysts: heuristic.catalysts,
valuation_refs: if note.valuation_refs.is_empty() {
heuristic.valuation_refs
} else {
note.valuation_refs.clone()
},
missing_evidence: note.source_id.is_none()
&& !matches!(note.note_type, NoteType::Question | NoteType::FollowUpTask | NoteType::SourceReference),
}
}
}
fn build_annotation(note: &ResearchNote) -> String {
let kind = match note.note_type {
NoteType::ManagementSignal => "Management is signaling a directional read that should be checked against operating evidence.",
NoteType::ValuationPoint => "This note matters if the valuation frame can be tied to a concrete operating driver.",
NoteType::Risk => "This note points to downside that should be sized and sourced before it informs conviction.",
NoteType::Catalyst => "This note suggests a possible stock-moving trigger and should be paired with timing evidence.",
NoteType::Fact => "This datapoint is most useful when linked directly into a claim, risk, or valuation bridge.",
NoteType::Quote => "Treat the quote as evidence, then separate the analyst interpretation into linked notes.",
NoteType::Claim | NoteType::Thesis | NoteType::SubThesis => "This statement is an inference until supporting evidence and explicit counterpoints are attached.",
NoteType::ChannelCheck => "This datapoint is potentially informative but should be kept caveated unless corroborated.",
_ => "This note may be more valuable once it is linked into a driver, risk, catalyst, or source trail.",
};
if note.source_id.is_none() && !matches!(note.note_type, NoteType::Question | NoteType::FollowUpTask) {
format!("{kind} Evidence is still missing or indirect.")
} else {
kind.to_string()
}
}
pub(crate) fn build_model_info(model: &str, task_profile: &str) -> Option<ModelInfo> {
if model.trim().is_empty() {
return None;
}
Some(ModelInfo {
task_profile: task_profile.to_string(),
model: model.to_string(),
provider: Some("remote".to_string()),
})
}
#[allow(dead_code)]
fn _kind_from_source(note: &ResearchNote) -> SourceKind {
note.provenance.source_kind
}

View File

@@ -0,0 +1,32 @@
//! Research subsystem error definitions.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ResearchError {
#[error("research I/O failed: {0}")]
Io(#[from] std::io::Error),
#[error("research database failed: {0}")]
Db(#[from] rusqlite::Error),
#[error("research JSON failed: {0}")]
Json(#[from] serde_json::Error),
#[error("research HTTP failed: {0}")]
Http(#[from] reqwest::Error),
#[error("research task join failed: {0}")]
Join(String),
#[error("research workspace not found: {0}")]
WorkspaceNotFound(String),
#[error("research note not found: {0}")]
NoteNotFound(String),
#[error("research ghost note not found: {0}")]
GhostNoteNotFound(String),
#[error("research job not found: {0}")]
JobNotFound(String),
#[error("research validation failed: {0}")]
Validation(String),
#[error("research AI gateway failed: {0}")]
Ai(String),
}
pub type Result<T> = std::result::Result<T, ResearchError>;

View File

@@ -0,0 +1,33 @@
//! Tauri event helpers for the research subsystem.
use serde::Serialize;
use tauri::{AppHandle, Emitter, Runtime};
#[derive(Debug, Clone)]
pub struct ResearchEventEmitter<R: Runtime> {
app_handle: AppHandle<R>,
}
impl<R: Runtime> ResearchEventEmitter<R> {
pub fn new(app_handle: &AppHandle<R>) -> Self {
Self {
app_handle: app_handle.clone(),
}
}
pub fn workspace_updated<T: Serialize>(&self, payload: &T) {
let _ = self.app_handle.emit("research_workspace_updated", payload);
}
pub fn note_updated<T: Serialize>(&self, payload: &T) {
let _ = self.app_handle.emit("research_note_updated", payload);
}
pub fn ghost_updated<T: Serialize>(&self, payload: &T) {
let _ = self.app_handle.emit("research_ghost_updated", payload);
}
pub fn job_updated<T: Serialize>(&self, payload: &T) {
let _ = self.app_handle.emit("research_job_updated", payload);
}
}

View File

@@ -0,0 +1,53 @@
//! Memo and bundle export helpers.
use serde_json::json;
use crate::research::projections::build_memo_blocks;
use crate::research::types::{
AuditEvent, GhostNote, NoteLink, ResearchBundleExport, ResearchNote, ResearchWorkspace,
SourceRecord,
};
pub(crate) fn export_bundle(
workspace: ResearchWorkspace,
notes: Vec<ResearchNote>,
links: Vec<NoteLink>,
ghosts: Vec<GhostNote>,
sources: Vec<SourceRecord>,
audit_events: Vec<AuditEvent>,
) -> ResearchBundleExport {
let memo_blocks = build_memo_blocks(&notes, &ghosts);
let markdown_memo = memo_blocks
.iter()
.map(|block| {
format!(
"## {}\n\n{}\n\nSources: {}\n",
block.headline,
block.body,
block.citation_refs.join(", ")
)
})
.collect::<Vec<_>>()
.join("\n");
let json_bundle = json!({
"workspace": workspace,
"notes": notes,
"links": links,
"ghosts": ghosts,
"sources": sources,
"auditEvents": audit_events,
"memoBlocks": memo_blocks,
});
ResearchBundleExport {
workspace: workspace.clone(),
notes: notes.clone(),
links: links.clone(),
ghosts: ghosts.clone(),
sources: sources.clone(),
audit_events: audit_events.clone(),
markdown_memo,
json_bundle,
}
}

View File

@@ -0,0 +1,385 @@
//! Provisional synthesis generation over note clusters and tensions.
use std::collections::{BTreeMap, BTreeSet, HashSet};
use crate::research::types::{
GhostLifecycleState, GhostNote, GhostNoteClass, GhostTone, GhostVisibilityState, LinkType,
MemoSectionKind, NoteLink, NoteType, ResearchNote, ResearchWorkspace,
};
use crate::research::util::{now_rfc3339, sha256_hex};
pub(crate) fn generate_ghost_notes(
workspace: &ResearchWorkspace,
notes: &[ResearchNote],
links: &[NoteLink],
) -> Vec<GhostNote> {
let mut ghosts = Vec::new();
ghosts.extend(generate_missing_evidence_prompts(workspace, notes, links));
ghosts.extend(generate_contradiction_alerts(workspace, notes, links));
ghosts.extend(generate_candidate_risks(workspace, notes));
ghosts.extend(generate_candidate_catalysts(workspace, notes));
ghosts.extend(generate_valuation_bridges(workspace, notes));
if let Some(thesis) = generate_candidate_thesis(workspace, notes, links) {
ghosts.push(thesis);
}
rank_and_limit_visibility(&mut ghosts);
ghosts
}
fn generate_missing_evidence_prompts(
workspace: &ResearchWorkspace,
notes: &[ResearchNote],
links: &[NoteLink],
) -> Vec<GhostNote> {
notes.iter()
.filter(|note| matches!(note.note_type, NoteType::Claim | NoteType::Thesis | NoteType::SubThesis))
.filter(|note| {
!links.iter().any(|link| {
link.from_note_id == note.id && matches!(link.link_type, LinkType::SourcedBy | LinkType::Supports)
})
})
.map(|note| ghost(
workspace,
GhostNoteClass::MissingEvidencePrompt,
vec![note.id.clone()],
Vec::new(),
note.source_id.iter().cloned().collect(),
"Missing evidence for claim".to_string(),
format!(
"Observed: this claim is currently unsupported by a linked source note. Inference: the argument may be directionally useful but should not be treated as evidence yet. What would confirm/refute: add a filing, transcript, article, or model-backed source."
),
0.42,
false,
GhostVisibilityState::Collapsed,
None,
))
.collect()
}
fn generate_contradiction_alerts(
workspace: &ResearchWorkspace,
notes: &[ResearchNote],
links: &[NoteLink],
) -> Vec<GhostNote> {
let notes_by_id = notes.iter().map(|note| (note.id.as_str(), note)).collect::<BTreeMap<_, _>>();
links.iter()
.filter(|link| matches!(link.link_type, LinkType::Contradicts | LinkType::ManagementVsReality))
.filter_map(|link| {
let left = notes_by_id.get(link.from_note_id.as_str())?;
let right = notes_by_id.get(link.to_note_id.as_str())?;
Some(ghost(
workspace,
GhostNoteClass::ContradictionAlert,
vec![left.id.clone(), right.id.clone()],
vec![right.id.clone()],
collect_source_ids([*left, *right]),
"Contradiction alert".to_string(),
format!(
"Observed: {}. Inference: this conflicts with {}. What would confirm/refute: reconcile the newer datapoint, source freshness, and management framing before treating either statement as settled.",
left.cleaned_text, right.cleaned_text
),
0.86,
true,
GhostVisibilityState::Visible,
Some(MemoSectionKind::RiskRegister),
))
})
.collect()
}
fn generate_candidate_risks(workspace: &ResearchWorkspace, notes: &[ResearchNote]) -> Vec<GhostNote> {
let risk_notes = notes
.iter()
.filter(|note| matches!(note.note_type, NoteType::Risk | NoteType::Contradiction | NoteType::ChannelCheck))
.collect::<Vec<_>>();
if risk_notes.len() < 3 {
return Vec::new();
}
let supporting_ids = risk_notes.iter().map(|note| note.id.clone()).collect::<Vec<_>>();
vec![ghost(
workspace,
GhostNoteClass::CandidateRisk,
supporting_ids,
Vec::new(),
collect_source_ids(risk_notes.into_iter()),
"Possible emerging risk cluster".to_string(),
"Observed: several notes point to downside pressure around the same operating area. Inference: this may represent an investable risk theme, but it should remain provisional until the source trail is tightened. What would confirm/refute: corroborate with fresh operating evidence or management disclosures.".to_string(),
0.73,
true,
GhostVisibilityState::Visible,
Some(MemoSectionKind::RiskRegister),
)]
}
fn generate_candidate_catalysts(workspace: &ResearchWorkspace, notes: &[ResearchNote]) -> Vec<GhostNote> {
let catalyst_notes = notes
.iter()
.filter(|note| matches!(note.note_type, NoteType::Catalyst | NoteType::EventTakeaway | NoteType::ManagementSignal))
.collect::<Vec<_>>();
if catalyst_notes.len() < 2 {
return Vec::new();
}
let supporting_ids = catalyst_notes.iter().map(|note| note.id.clone()).collect::<Vec<_>>();
vec![ghost(
workspace,
GhostNoteClass::CandidateCatalyst,
supporting_ids,
Vec::new(),
collect_source_ids(catalyst_notes.into_iter()),
"Possible catalyst cluster".to_string(),
"Observed: multiple notes point toward an identifiable event or operating trigger. Inference: this could matter for the next stock move if timing and evidence quality hold. What would confirm/refute: map the catalyst to dated milestones and an observable KPI.".to_string(),
0.7,
true,
GhostVisibilityState::Visible,
Some(MemoSectionKind::CatalystCalendar),
)]
}
fn generate_valuation_bridges(workspace: &ResearchWorkspace, notes: &[ResearchNote]) -> Vec<GhostNote> {
let valuation_notes = notes
.iter()
.filter(|note| note.note_type == NoteType::ValuationPoint)
.collect::<Vec<_>>();
let driver_notes = notes
.iter()
.filter(|note| matches!(note.note_type, NoteType::IndustryObservation | NoteType::ManagementSignal | NoteType::Fact | NoteType::Catalyst))
.collect::<Vec<_>>();
if valuation_notes.len() < 2 || driver_notes.is_empty() {
return Vec::new();
}
let mut support = valuation_notes.iter().map(|note| note.id.clone()).collect::<Vec<_>>();
support.extend(driver_notes.iter().take(2).map(|note| note.id.clone()));
vec![ghost(
workspace,
GhostNoteClass::ValuationBridge,
support,
Vec::new(),
collect_source_ids(valuation_notes.into_iter().chain(driver_notes.into_iter())),
"Possible valuation bridge".to_string(),
"Observed: valuation notes point to a discount while operating notes suggest a driver that could narrow that gap. Inference: there may be a rerating bridge if the operating evidence persists. What would confirm/refute: track the KPI that should transmit into multiple expansion.".to_string(),
0.76,
true,
GhostVisibilityState::Visible,
Some(MemoSectionKind::ValuationWriteUp),
)]
}
fn generate_candidate_thesis(
workspace: &ResearchWorkspace,
notes: &[ResearchNote],
links: &[NoteLink],
) -> Option<GhostNote> {
let source_count = notes.iter().filter_map(|note| note.source_id.as_ref()).collect::<HashSet<_>>().len();
let family_count = notes
.iter()
.map(|note| note.note_type)
.collect::<HashSet<_>>()
.len();
let corroborated_count = notes
.iter()
.filter(|note| matches!(note.note_type, NoteType::Fact | NoteType::Quote | NoteType::ManagementSignal | NoteType::ValuationPoint))
.count();
let has_catalyst_or_valuation = notes.iter().any(|note| matches!(note.note_type, NoteType::Catalyst | NoteType::ValuationPoint));
let has_unresolved_contradiction = links.iter().any(|link| {
matches!(link.link_type, LinkType::Contradicts | LinkType::ManagementVsReality) && link.confidence > 0.75
});
if notes.len() < 4
|| family_count < 2
|| source_count < 2
|| corroborated_count < 2
|| !has_catalyst_or_valuation
{
return None;
}
let headline = if has_unresolved_contradiction {
"Possible thesis emerging, but tension remains"
} else {
"Possible candidate thesis"
};
let body = if has_unresolved_contradiction {
"Observed: enough connected evidence exists to suggest an investable pattern, but at least one unresolved contradiction remains. Inference: a thesis may be forming, though conviction should stay tempered until the conflict is resolved. What would confirm/refute: close the contradiction with fresher operating evidence."
} else {
"Observed: multiple notes across evidence, catalyst, and valuation categories are pointing in the same direction. Inference: a coherent thesis may be emerging, though it should remain provisional until explicitly accepted by the analyst. What would confirm/refute: one more corroborating datapoint tied to the key driver."
};
Some(ghost(
workspace,
GhostNoteClass::CandidateThesis,
notes.iter().take(6).map(|note| note.id.clone()).collect(),
Vec::new(),
collect_source_ids(notes.iter()),
headline.to_string(),
body.to_string(),
if has_unresolved_contradiction { 0.68 } else { 0.82 },
!has_unresolved_contradiction,
GhostVisibilityState::Visible,
Some(MemoSectionKind::InvestmentMemo),
))
}
fn rank_and_limit_visibility(ghosts: &mut [GhostNote]) {
ghosts.sort_by(|left, right| right.confidence.total_cmp(&left.confidence));
let mut visible_count = 0usize;
for ghost in ghosts {
if matches!(ghost.visibility_state, GhostVisibilityState::Visible | GhostVisibilityState::Pinned) {
if visible_count >= 3 {
ghost.visibility_state = GhostVisibilityState::Hidden;
ghost.state = GhostLifecycleState::Generated;
} else {
visible_count += 1;
ghost.state = GhostLifecycleState::Visible;
}
}
}
}
fn ghost(
workspace: &ResearchWorkspace,
ghost_class: GhostNoteClass,
supporting_note_ids: Vec<String>,
contradicting_note_ids: Vec<String>,
source_ids: Vec<String>,
headline: String,
body: String,
confidence: f32,
evidence_threshold_met: bool,
visibility_state: GhostVisibilityState,
memo_section_hint: Option<MemoSectionKind>,
) -> GhostNote {
let now = now_rfc3339();
let mut key_parts = BTreeSet::new();
key_parts.extend(supporting_note_ids.iter().cloned());
key_parts.extend(contradicting_note_ids.iter().cloned());
let ghost_key = format!("{ghost_class:?}-{}", key_parts.into_iter().collect::<Vec<_>>().join(","));
GhostNote {
id: format!("ghost-{}", &sha256_hex(&ghost_key)[..16]),
workspace_id: workspace.id.clone(),
ghost_class,
headline,
body,
tone: GhostTone::Tentative,
confidence,
visibility_state,
state: GhostLifecycleState::Generated,
supporting_note_ids,
contradicting_note_ids,
source_ids,
evidence_threshold_met,
created_at: now.clone(),
updated_at: now,
superseded_by_ghost_id: None,
promoted_note_id: None,
memo_section_hint,
}
}
fn collect_source_ids<'a>(notes: impl IntoIterator<Item = &'a ResearchNote>) -> Vec<String> {
notes
.into_iter()
.filter_map(|note| note.source_id.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
#[cfg(test)]
mod tests {
use crate::research::types::{
AnalystStatus, EvidenceStatus, GhostStatus, NotePriority, NoteProvenance, NoteType,
ProvenanceActor, ResearchNote, ResearchWorkspace, ThesisStatus, WorkspaceScope,
WorkspaceViewKind,
};
use crate::research::util::{now_rfc3339, sha256_hex};
use super::generate_ghost_notes;
fn workspace() -> ResearchWorkspace {
let now = now_rfc3339();
ResearchWorkspace {
id: "workspace-1".to_string(),
name: "AAPL".to_string(),
primary_ticker: "AAPL".to_string(),
scope: WorkspaceScope::SingleCompany,
stage: crate::research::types::ResearchStage::Thesis,
default_view: WorkspaceViewKind::ThesisBuilder,
pinned_note_ids: Vec::new(),
archived: false,
created_at: now.clone(),
updated_at: now,
}
}
fn note(id: &str, note_type: NoteType, source_id: Option<&str>) -> ResearchNote {
let now = now_rfc3339();
ResearchNote {
id: id.to_string(),
workspace_id: "workspace-1".to_string(),
company_id: None,
ticker: Some("AAPL".to_string()),
source_id: source_id.map(ToOwned::to_owned),
raw_text: id.to_string(),
cleaned_text: id.to_string(),
title: None,
note_type,
subtype: None,
analyst_status: AnalystStatus::Captured,
ai_annotation: None,
confidence: 0.8,
evidence_status: EvidenceStatus::SourceLinked,
inferred_links: Vec::new(),
ghost_status: GhostStatus::None,
thesis_status: ThesisStatus::None,
created_at: now.clone(),
updated_at: now.clone(),
provenance: NoteProvenance {
created_by: ProvenanceActor::Manual,
capture_method: crate::research::types::CaptureMethod::QuickEntry,
source_kind: crate::research::types::SourceKind::Manual,
origin_note_id: None,
origin_ghost_id: None,
model_info: None,
created_at: now,
raw_input_hash: sha256_hex(id),
},
tags: Vec::new(),
catalysts: Vec::new(),
risks: Vec::new(),
valuation_refs: Vec::new(),
time_horizon: None,
scenario: None,
priority: NotePriority::Normal,
pinned: false,
archived: false,
revision: 1,
source_excerpt: None,
last_enriched_at: None,
last_linked_at: None,
stale_reason: None,
superseded_by_note_id: None,
}
}
#[test]
fn generate_ghost_notes_should_surface_candidate_thesis_when_evidence_threshold_met() {
let ghosts = generate_ghost_notes(
&workspace(),
&[
note("fact-1", NoteType::Fact, Some("source-1")),
note("quote-1", NoteType::Quote, Some("source-2")),
note("catalyst-1", NoteType::Catalyst, Some("source-2")),
note("valuation-1", NoteType::ValuationPoint, Some("source-1")),
],
&[],
);
assert!(ghosts.iter().any(|ghost| ghost.ghost_class == crate::research::types::GhostNoteClass::CandidateThesis));
}
}

View File

@@ -0,0 +1,176 @@
//! Source grounding helpers for notes and citations.
use regex::Regex;
use serde_json::json;
use crate::research::heuristics::derive_title;
use crate::research::types::{
AnalystStatus, EvidenceStatus, FreshnessBucket, NotePriority, NoteProvenance, NoteType,
ResearchNote, SourceExcerpt, SourceKind, SourceRecord, SourceReferenceInput,
};
use crate::research::util::{generate_id, now_rfc3339, sha256_hex};
pub(crate) fn build_source_record(
workspace_id: &str,
ticker: Option<&str>,
input: &SourceReferenceInput,
) -> SourceRecord {
let now = now_rfc3339();
let title = input
.title
.clone()
.or_else(|| input.url.as_deref().map(derive_title_from_url))
.unwrap_or_else(|| "Attached source".to_string());
SourceRecord {
id: generate_id("source"),
workspace_id: workspace_id.to_string(),
kind: input.kind,
ticker: ticker.map(ToOwned::to_owned),
title,
publisher: input.url.as_deref().and_then(extract_publisher),
url: input.url.clone(),
canonical_url: input.url.clone(),
filing_accession: input.filing_accession.clone(),
form_type: input.form_type.clone(),
published_at: input.published_at.clone(),
as_of_date: input.published_at.clone(),
ingested_at: now,
freshness_bucket: FreshnessBucket::Fresh,
checksum: input.url.as_deref().map(sha256_hex),
metadata_json: json!({
"kind": input.kind,
"locationLabel": input.location_label,
}),
superseded_by_source_id: None,
}
}
pub(crate) fn build_source_reference_note(
workspace_id: &str,
ticker: Option<&str>,
source: &SourceRecord,
excerpt: Option<&SourceExcerpt>,
) -> ResearchNote {
let now = now_rfc3339();
let raw_text = format!(
"{}{}",
source.title,
source
.url
.as_deref()
.map(|url| format!(" ({url})"))
.unwrap_or_default()
);
ResearchNote {
id: generate_id("note"),
workspace_id: workspace_id.to_string(),
company_id: None,
ticker: ticker.map(ToOwned::to_owned),
source_id: Some(source.id.clone()),
raw_text: raw_text.clone(),
cleaned_text: raw_text,
title: derive_title(&source.title, NoteType::SourceReference),
note_type: NoteType::SourceReference,
subtype: Some(format!("{:?}", source.kind).to_ascii_lowercase()),
analyst_status: AnalystStatus::Accepted,
ai_annotation: None,
confidence: 1.0,
evidence_status: EvidenceStatus::SourceLinked,
inferred_links: Vec::new(),
ghost_status: crate::research::types::GhostStatus::None,
thesis_status: crate::research::types::ThesisStatus::None,
created_at: now.clone(),
updated_at: now.clone(),
provenance: NoteProvenance {
created_by: crate::research::types::ProvenanceActor::Import,
capture_method: crate::research::types::CaptureMethod::ManualLink,
source_kind: source.kind,
origin_note_id: None,
origin_ghost_id: None,
model_info: None,
created_at: now,
raw_input_hash: sha256_hex(&source.title),
},
tags: vec!["source".to_string()],
catalysts: Vec::new(),
risks: Vec::new(),
valuation_refs: Vec::new(),
time_horizon: None,
scenario: None,
priority: NotePriority::Low,
pinned: false,
archived: false,
revision: 1,
source_excerpt: excerpt.cloned(),
last_enriched_at: None,
last_linked_at: None,
stale_reason: None,
superseded_by_note_id: None,
}
}
pub(crate) fn source_excerpt_from_input(source_id: &str, input: &SourceReferenceInput) -> Option<SourceExcerpt> {
if input.excerpt_text.is_none() && input.location_label.is_none() {
return None;
}
Some(SourceExcerpt {
source_id: source_id.to_string(),
excerpt_text: input.excerpt_text.clone(),
location_label: input.location_label.clone(),
start_offset: None,
end_offset: None,
})
}
pub(crate) async fn refresh_source_metadata(source: &SourceRecord) -> crate::research::Result<SourceRecord> {
let Some(url) = source.url.as_deref() else {
return Ok(source.clone());
};
let body = reqwest::get(url).await?.text().await?;
let title = extract_html_title(&body).unwrap_or_else(|| source.title.clone());
let mut refreshed = source.clone();
refreshed.title = title;
refreshed.publisher = extract_publisher(url);
refreshed.metadata_json = json!({
"kind": refreshed.kind,
"refreshedFromUrl": url,
});
Ok(refreshed)
}
fn derive_title_from_url(url: &str) -> String {
let mut trimmed = url
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_end_matches('/');
if let Some((host, path)) = trimmed.split_once('/') {
trimmed = if path.is_empty() { host } else { path };
}
trimmed.replace('-', " ")
}
fn extract_publisher(url: &str) -> Option<String> {
let without_scheme = url
.trim_start_matches("https://")
.trim_start_matches("http://");
let host = without_scheme.split('/').next().unwrap_or_default();
if host.is_empty() {
None
} else {
Some(host.to_string())
}
}
fn extract_html_title(body: &str) -> Option<String> {
let regex = Regex::new(r"(?is)<title>(.*?)</title>").expect("title regex should compile");
regex
.captures(body)
.and_then(|captures| captures.get(1).map(|value| value.as_str().trim().to_string()))
.filter(|value| !value.is_empty())
}

View File

@@ -0,0 +1,249 @@
//! Deterministic first-pass note typing and extraction rules.
use regex::Regex;
use crate::research::types::{
NotePriority, NoteType, ScenarioKind, SourceKind, TimeHorizon, ValuationRef,
};
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct HeuristicTypingResult {
pub note_type: NoteType,
pub subtype: Option<String>,
pub confidence: f32,
pub tags: Vec<String>,
pub catalysts: Vec<String>,
pub risks: Vec<String>,
pub valuation_refs: Vec<ValuationRef>,
pub time_horizon: Option<TimeHorizon>,
pub scenario: Option<ScenarioKind>,
pub priority: NotePriority,
}
pub(crate) fn classify_note(
cleaned_text: &str,
source_kind: SourceKind,
override_type: Option<NoteType>,
) -> HeuristicTypingResult {
if let Some(note_type) = override_type {
let mut result = baseline_result(note_type, 0.99);
result.tags = extract_tags(cleaned_text);
result.valuation_refs = extract_valuation_refs(cleaned_text);
return result;
}
let lower = cleaned_text.to_ascii_lowercase();
let mut result = if looks_like_quote(cleaned_text, source_kind) {
baseline_result(NoteType::Quote, 0.92)
} else if lower.contains("management says")
|| lower.contains("mgmt says")
|| (matches!(source_kind, SourceKind::Transcript) && has_any(&lower, &["expect", "seeing", "confident", "guidance"]))
{
baseline_result(NoteType::ManagementSignal, 0.83)
} else if has_any(&lower, &["ev/ebitda", "p/e", "fcf yield", "multiple", "price target", "rerating", "valuation"]) {
baseline_result(NoteType::ValuationPoint, 0.87)
} else if has_any(&lower, &["risk", "downside", "headwind", "pressure", "weakness", "inventory"]) {
baseline_result(NoteType::Risk, 0.78)
} else if has_any(&lower, &["catalyst", "launch", "approval", "guidance", "next quarter", "earnings", "rerating"]) {
baseline_result(NoteType::Catalyst, 0.74)
} else if has_any(&lower, &["if ", "assume", "base case", "bull case", "bear case", "scenario"]) {
baseline_result(NoteType::ScenarioAssumption, 0.8)
} else if cleaned_text.ends_with('?') || has_any(&lower, &["what if", "why is", "how does", "question"]) {
baseline_result(NoteType::Question, 0.91)
} else if has_any(&lower, &["channel check", "retail", "sell-through", "inventory in channel"]) {
baseline_result(NoteType::ChannelCheck, 0.82)
} else if has_any(&lower, &["peer", "vs ", "versus", "relative to", "competitor"]) {
baseline_result(NoteType::CompetitorComparison, 0.77)
} else if has_any(&lower, &["industry", "category", "market", "sector"]) {
baseline_result(NoteType::IndustryObservation, 0.73)
} else if has_any(&lower, &["thesis", "stock can", "we think", "market is missing"]) {
baseline_result(NoteType::Thesis, 0.71)
} else if has_any(&lower, &["follow up", "check", "verify", "ask ir", "need to"]) {
baseline_result(NoteType::FollowUpTask, 0.85)
} else if has_any(&lower, &["call takeaway", "takeaway", "earnings recap", "event"]) {
baseline_result(NoteType::EventTakeaway, 0.76)
} else if looks_like_fact(cleaned_text, source_kind) {
baseline_result(NoteType::Fact, 0.72)
} else {
baseline_result(NoteType::Claim, 0.58)
};
result.tags = extract_tags(cleaned_text);
result.catalysts = extract_keyword_bucket(cleaned_text, &["launch", "approval", "guidance", "margin", "enterprise demand"]);
result.risks = extract_keyword_bucket(cleaned_text, &["inventory", "pricing", "churn", "competition", "demand softness"]);
result.valuation_refs = extract_valuation_refs(cleaned_text);
result.time_horizon = infer_time_horizon(&lower);
result.scenario = infer_scenario(&lower);
result.priority = infer_priority(&result.note_type, &lower);
result
}
pub(crate) fn derive_title(cleaned_text: &str, note_type: NoteType) -> Option<String> {
if cleaned_text.is_empty() {
return None;
}
let prefix = match note_type {
NoteType::Risk => "Risk",
NoteType::Catalyst => "Catalyst",
NoteType::ValuationPoint => "Valuation",
NoteType::ManagementSignal => "Mgmt",
NoteType::Question => "Question",
NoteType::Contradiction => "Conflict",
NoteType::FollowUpTask => "Follow up",
_ => "Note",
};
Some(format!("{prefix}: {}", crate::research::util::clean_title(cleaned_text, 72)))
}
pub(crate) fn detect_urls(text: &str) -> Vec<String> {
let regex = Regex::new(r"https?://[^\s)]+").expect("URL regex should compile");
regex
.find_iter(text)
.map(|capture| capture.as_str().trim_end_matches('.').to_string())
.collect()
}
pub(crate) fn extract_tickers(text: &str) -> Vec<String> {
let regex = Regex::new(r"\b[A-Z]{2,5}\b").expect("ticker regex should compile");
regex
.find_iter(text)
.map(|capture| capture.as_str().to_string())
.collect()
}
fn baseline_result(note_type: NoteType, confidence: f32) -> HeuristicTypingResult {
HeuristicTypingResult {
note_type,
subtype: None,
confidence,
tags: Vec::new(),
catalysts: Vec::new(),
risks: Vec::new(),
valuation_refs: Vec::new(),
time_horizon: None,
scenario: None,
priority: NotePriority::Normal,
}
}
fn looks_like_quote(cleaned_text: &str, source_kind: SourceKind) -> bool {
cleaned_text.contains('\"') || matches!(source_kind, SourceKind::Transcript) && cleaned_text.contains(':')
}
fn looks_like_fact(cleaned_text: &str, source_kind: SourceKind) -> bool {
let lower = cleaned_text.to_ascii_lowercase();
matches!(source_kind, SourceKind::Filing | SourceKind::Transcript | SourceKind::Article)
|| has_any(&lower, &["reported", "was", "were", "increased", "decreased"])
|| Regex::new(r"\b\d+(\.\d+)?(%|x|bps|m|bn)?\b")
.expect("fact regex should compile")
.is_match(cleaned_text)
}
fn extract_tags(cleaned_text: &str) -> Vec<String> {
let mut tags = Vec::new();
let lower = cleaned_text.to_ascii_lowercase();
for tag in [
"margin",
"demand",
"inventory",
"pricing",
"guidance",
"subscription",
"enterprise",
"valuation",
"peer",
] {
if lower.contains(tag) {
tags.push(tag.to_string());
}
}
tags
}
fn extract_keyword_bucket(cleaned_text: &str, keywords: &[&str]) -> Vec<String> {
let lower = cleaned_text.to_ascii_lowercase();
keywords
.iter()
.filter(|keyword| lower.contains(**keyword))
.map(|keyword| (*keyword).to_string())
.collect()
}
fn extract_valuation_refs(cleaned_text: &str) -> Vec<ValuationRef> {
let regex =
Regex::new(r"(?P<multiple>\d+(\.\d+)?)x\s+(?P<metric>[A-Za-z/]+)").expect("valuation regex should compile");
regex
.captures_iter(cleaned_text)
.map(|captures| ValuationRef {
metric: captures["metric"].to_string(),
multiple: captures["multiple"].parse::<f64>().ok(),
unit: Some("x".to_string()),
basis: None,
})
.collect()
}
fn infer_time_horizon(lower: &str) -> Option<TimeHorizon> {
if has_any(lower, &["next quarter", "next half", "next earnings"]) {
Some(TimeHorizon::NextEarnings)
} else if has_any(lower, &["12 month", "twelve month", "next year"]) {
Some(TimeHorizon::TwelveMonth)
} else if has_any(lower, &["multi-year", "long term", "3 year", "5 year"]) {
Some(TimeHorizon::MultiYear)
} else if has_any(lower, &["near term", "this quarter"]) {
Some(TimeHorizon::NearTerm)
} else {
None
}
}
fn infer_scenario(lower: &str) -> Option<ScenarioKind> {
if lower.contains("bull case") {
Some(ScenarioKind::Bull)
} else if lower.contains("bear case") {
Some(ScenarioKind::Bear)
} else if lower.contains("downside") {
Some(ScenarioKind::DownsideCase)
} else if lower.contains("upside") {
Some(ScenarioKind::UpsideCase)
} else if lower.contains("base case") {
Some(ScenarioKind::Base)
} else {
None
}
}
fn infer_priority(note_type: &NoteType, lower: &str) -> NotePriority {
if matches!(note_type, NoteType::Contradiction | NoteType::Risk) && has_any(lower, &["material", "severe", "significant"]) {
NotePriority::Critical
} else if matches!(note_type, NoteType::Risk | NoteType::Catalyst | NoteType::Thesis | NoteType::ManagementSignal) {
NotePriority::High
} else {
NotePriority::Normal
}
}
fn has_any(input: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| input.contains(needle))
}
#[cfg(test)]
mod tests {
use crate::research::types::{NoteType, SourceKind, TimeHorizon};
use super::classify_note;
#[test]
fn classify_note_should_flag_management_signal_for_transcript_language() {
let result = classify_note(
"Mgmt says enterprise demand improved sequentially and expects gross margin to exit above 70% next half.",
SourceKind::Transcript,
None,
);
assert_eq!(result.note_type, NoteType::ManagementSignal);
assert_eq!(result.time_horizon, Some(TimeHorizon::NextEarnings));
}
}

View File

@@ -0,0 +1,300 @@
//! Deterministic relationship inference across research notes.
use std::collections::{BTreeSet, HashSet};
use crate::research::types::{
EvidenceBasis, LinkOrigin, LinkStrength, LinkType, NoteLink, NoteType, ResearchNote,
};
use crate::research::util::{generate_id, now_rfc3339};
pub(crate) fn infer_links(notes: &[ResearchNote]) -> Vec<NoteLink> {
let mut links = Vec::new();
for left_index in 0..notes.len() {
let left = &notes[left_index];
for right in &notes[(left_index + 1)..] {
if left.workspace_id != right.workspace_id || left.archived || right.archived {
continue;
}
if let Some(link) = infer_pair(left, right) {
links.push(link);
}
if let Some(link) = infer_reverse_pair(left, right) {
links.push(link);
}
}
}
links
}
fn infer_pair(left: &ResearchNote, right: &ResearchNote) -> Option<NoteLink> {
if left.note_type != NoteType::SourceReference
&& right.note_type == NoteType::SourceReference
&& left.source_id.as_deref() == right.source_id.as_deref()
&& left.source_id.is_some()
{
return Some(build_link(left, right, LinkType::SourcedBy, 0.98, LinkStrength::Strong, EvidenceBasis::SharedSource));
}
if left.note_type == NoteType::ValuationPoint && is_valuation_dependency_target(right) && shares_keywords(left, right) {
return Some(build_link(left, right, LinkType::ValuationDependsOn, 0.8, LinkStrength::Strong, EvidenceBasis::Lexical));
}
if left.note_type == NoteType::Risk && is_thesis_family(right) && shares_keywords(left, right) {
return Some(build_link(left, right, LinkType::RiskTo, 0.76, LinkStrength::Strong, EvidenceBasis::Lexical));
}
if left.note_type == NoteType::Catalyst && is_thesis_family(right) && shares_keywords(left, right) {
return Some(build_link(left, right, LinkType::CatalystFor, 0.78, LinkStrength::Strong, EvidenceBasis::Temporal));
}
if left.note_type == NoteType::ScenarioAssumption && is_assumption_target(right) && shares_keywords(left, right) {
return Some(build_link(left, right, LinkType::AssumptionFor, 0.75, LinkStrength::Strong, EvidenceBasis::Structured));
}
if is_evidence_note(left) && is_claim_family(right) && shares_keywords(left, right) {
if signals_contradiction(left, right) {
return Some(build_link(left, right, LinkType::Contradicts, 0.79, LinkStrength::Critical, EvidenceBasis::Lexical));
}
return Some(build_link(left, right, LinkType::Supports, 0.72, LinkStrength::Strong, EvidenceBasis::Lexical));
}
if left.note_type == NoteType::ManagementSignal
&& matches!(right.note_type, NoteType::Fact | NoteType::ChannelCheck | NoteType::EventTakeaway)
&& shares_keywords(left, right)
&& signals_contradiction(left, right)
{
return Some(build_link(left, right, LinkType::ManagementVsReality, 0.9, LinkStrength::Critical, EvidenceBasis::Temporal));
}
if left.note_type == right.note_type
&& left.ticker == right.ticker
&& text_similarity(left, right) > 0.94
&& left.revision != right.revision
{
let link_type = if left.updated_at <= right.updated_at {
LinkType::Updates
} else {
LinkType::Supersedes
};
return Some(build_link(left, right, link_type, 0.7, LinkStrength::Medium, EvidenceBasis::Structured));
}
if shares_keywords(left, right)
&& left.time_horizon.is_some()
&& right.time_horizon.is_some()
&& left.time_horizon != right.time_horizon
{
return Some(build_link(left, right, LinkType::TimeframeConflict, 0.67, LinkStrength::Medium, EvidenceBasis::Temporal));
}
None
}
fn infer_reverse_pair(left: &ResearchNote, right: &ResearchNote) -> Option<NoteLink> {
if left.note_type == NoteType::ManagementSignal
&& matches!(right.note_type, NoteType::Fact | NoteType::ChannelCheck | NoteType::EventTakeaway)
&& shares_keywords(left, right)
&& signals_contradiction(left, right)
{
return Some(build_link(right, left, LinkType::Contradicts, 0.84, LinkStrength::Critical, EvidenceBasis::Temporal));
}
None
}
fn build_link(
from: &ResearchNote,
to: &ResearchNote,
link_type: LinkType,
confidence: f32,
strength: LinkStrength,
evidence_basis: EvidenceBasis,
) -> NoteLink {
let now = now_rfc3339();
NoteLink {
id: generate_id("link"),
workspace_id: from.workspace_id.clone(),
from_note_id: from.id.clone(),
to_note_id: to.id.clone(),
link_type,
directional: !matches!(link_type, LinkType::TimeframeConflict),
confidence,
strength,
evidence_basis,
created_by: LinkOrigin::Heuristic,
created_at: now.clone(),
updated_at: now,
source_revision_pair: (from.revision, to.revision),
stale: false,
stale_reason: None,
}
}
fn is_claim_family(note: &ResearchNote) -> bool {
matches!(
note.note_type,
NoteType::Claim
| NoteType::Thesis
| NoteType::SubThesis
| NoteType::Risk
| NoteType::Catalyst
| NoteType::MosaicInsight
| NoteType::ManagementSignal
)
}
fn is_evidence_note(note: &ResearchNote) -> bool {
matches!(
note.note_type,
NoteType::Fact
| NoteType::Quote
| NoteType::EventTakeaway
| NoteType::ChannelCheck
| NoteType::IndustryObservation
| NoteType::CompetitorComparison
| NoteType::ManagementSignal
)
}
fn is_thesis_family(note: &ResearchNote) -> bool {
matches!(note.note_type, NoteType::Thesis | NoteType::SubThesis | NoteType::Claim | NoteType::MosaicInsight)
}
fn is_assumption_target(note: &ResearchNote) -> bool {
matches!(note.note_type, NoteType::ValuationPoint | NoteType::Thesis | NoteType::SubThesis | NoteType::Risk)
}
fn is_valuation_dependency_target(note: &ResearchNote) -> bool {
matches!(
note.note_type,
NoteType::Fact
| NoteType::Catalyst
| NoteType::ScenarioAssumption
| NoteType::IndustryObservation
| NoteType::ManagementSignal
| NoteType::Claim
)
}
fn shares_keywords(left: &ResearchNote, right: &ResearchNote) -> bool {
let left_words = significant_words(&left.cleaned_text);
let right_words = significant_words(&right.cleaned_text);
left_words.intersection(&right_words).count() >= 2
|| left.tags.iter().any(|tag| right.tags.contains(tag))
|| left.ticker == right.ticker && left.ticker.is_some()
}
fn signals_contradiction(left: &ResearchNote, right: &ResearchNote) -> bool {
let negative = ["decline", "weak", "pressure", "discount", "inventory", "miss", "soft"];
let positive = ["improve", "improved", "normalized", "strong", "reaccelerat", "above"];
let left_lower = left.cleaned_text.to_ascii_lowercase();
let right_lower = right.cleaned_text.to_ascii_lowercase();
has_any(&left_lower, &positive) && has_any(&right_lower, &negative)
|| has_any(&left_lower, &negative) && has_any(&right_lower, &positive)
}
fn significant_words(text: &str) -> HashSet<String> {
let stop_words = BTreeSet::from([
"the", "and", "for", "with", "that", "from", "this", "next", "says", "said",
"have", "has", "into", "above", "below", "about", "quarter", "company",
]);
text.to_ascii_lowercase()
.split(|character: char| !character.is_ascii_alphanumeric())
.filter(|value| value.len() >= 4 && !stop_words.contains(*value))
.map(ToOwned::to_owned)
.collect()
}
fn text_similarity(left: &ResearchNote, right: &ResearchNote) -> f32 {
let left_words = significant_words(&left.cleaned_text);
let right_words = significant_words(&right.cleaned_text);
if left_words.is_empty() || right_words.is_empty() {
return 0.0;
}
let intersection = left_words.intersection(&right_words).count() as f32;
let union = left_words.union(&right_words).count() as f32;
intersection / union
}
fn has_any(input: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| input.contains(needle))
}
#[cfg(test)]
mod tests {
use crate::research::types::{
AnalystStatus, EvidenceStatus, GhostStatus, NotePriority, NoteProvenance, NoteType,
ProvenanceActor, ResearchNote, ThesisStatus,
};
use crate::research::util::{now_rfc3339, sha256_hex};
use super::infer_links;
fn note(id: &str, note_type: NoteType, text: &str) -> ResearchNote {
let now = now_rfc3339();
ResearchNote {
id: id.to_string(),
workspace_id: "workspace-1".to_string(),
company_id: None,
ticker: Some("AAPL".to_string()),
source_id: None,
raw_text: text.to_string(),
cleaned_text: text.to_string(),
title: None,
note_type,
subtype: None,
analyst_status: AnalystStatus::Captured,
ai_annotation: None,
confidence: 0.8,
evidence_status: EvidenceStatus::Unsourced,
inferred_links: Vec::new(),
ghost_status: GhostStatus::None,
thesis_status: ThesisStatus::None,
created_at: now.clone(),
updated_at: now.clone(),
provenance: NoteProvenance {
created_by: ProvenanceActor::Manual,
capture_method: crate::research::types::CaptureMethod::QuickEntry,
source_kind: crate::research::types::SourceKind::Manual,
origin_note_id: None,
origin_ghost_id: None,
model_info: None,
created_at: now,
raw_input_hash: sha256_hex(text),
},
tags: Vec::new(),
catalysts: Vec::new(),
risks: Vec::new(),
valuation_refs: Vec::new(),
time_horizon: None,
scenario: None,
priority: NotePriority::Normal,
pinned: false,
archived: false,
revision: 1,
source_excerpt: None,
last_enriched_at: None,
last_linked_at: None,
stale_reason: None,
superseded_by_note_id: None,
}
}
#[test]
fn infer_links_should_create_management_vs_reality_for_conflicting_notes() {
let links = infer_links(&[
note("mgmt", NoteType::ManagementSignal, "Management says inventory is now normalized."),
note("fact", NoteType::Fact, "Inventory days increased 12% sequentially and discounting remains elevated."),
]);
assert!(links.iter().any(|link| link.link_type == crate::research::types::LinkType::ManagementVsReality));
}
}

View File

@@ -0,0 +1,29 @@
//! Local-first equity research workspace subsystem.
mod ai;
mod events;
mod export;
mod errors;
mod ghosts;
mod grounding;
mod heuristics;
mod links;
mod pipeline;
mod projections;
mod repository;
mod service;
mod types;
mod util;
pub use errors::{ResearchError, Result};
pub use events::ResearchEventEmitter;
pub use pipeline::spawn_research_scheduler;
pub use service::ResearchService;
pub use types::{
ArchiveResearchNoteRequest, AuditEvent, CaptureResearchNoteRequest, CreateResearchWorkspaceRequest,
ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetWorkspaceProjectionRequest,
GhostNote, ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest,
MemoBlockCandidate, NoteAuditTrail, NoteCaptureResult, NoteLink, PipelineJob,
PromoteNoteToThesisRequest, ResearchBundleExport, ResearchNote, ResearchWorkspace,
RetryResearchJobsRequest, ReviewGhostNoteRequest, WorkspaceProjection,
};

View File

@@ -0,0 +1,120 @@
//! Persisted background job queue and scheduler helpers.
use std::sync::Arc;
use std::time::Duration;
use serde_json::json;
use tauri::Runtime;
use crate::research::repository::ResearchRepository;
use crate::research::types::{JobKind, JobStatus, PipelineJob};
use crate::research::util::{generate_id, now_rfc3339};
#[derive(Clone)]
pub struct ResearchPipeline {
repository: Arc<ResearchRepository>,
}
impl ResearchPipeline {
pub fn new(repository: Arc<ResearchRepository>) -> Self {
Self { repository }
}
pub async fn enqueue_capture_jobs(
&self,
workspace_id: &str,
note_id: &str,
revision: u32,
refresh_source_id: Option<String>,
) -> crate::research::Result<Vec<PipelineJob>> {
let mut jobs = vec![
new_job(workspace_id, note_id, JobKind::EnrichNote, json!({ "noteId": note_id, "expectedRevision": revision })),
new_job(workspace_id, note_id, JobKind::InferLinks, json!({ "workspaceId": workspace_id, "noteId": note_id, "expectedRevision": revision })),
new_job(workspace_id, note_id, JobKind::EvaluateDuplicates, json!({ "workspaceId": workspace_id, "noteId": note_id, "expectedRevision": revision })),
new_job(workspace_id, note_id, JobKind::EvaluateGhosts, json!({ "workspaceId": workspace_id })),
];
if let Some(source_id) = refresh_source_id {
jobs.push(new_job(
workspace_id,
&source_id,
JobKind::RefreshSourceMetadata,
json!({ "sourceId": source_id }),
));
}
self.repository.enqueue_jobs(jobs).await
}
pub async fn mark_running(&self, mut job: PipelineJob) -> crate::research::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) -> crate::research::Result<PipelineJob> {
job.status = JobStatus::Completed;
job.last_error = None;
job.next_attempt_at = None;
job.updated_at = now_rfc3339();
self.repository.save_job(job).await
}
pub async fn mark_skipped(&self, mut job: PipelineJob, reason: &str) -> crate::research::Result<PipelineJob> {
job.status = JobStatus::Skipped;
job.last_error = Some(reason.to_string());
job.next_attempt_at = None;
job.updated_at = now_rfc3339();
self.repository.save_job(job).await
}
pub async fn mark_failed(&self, mut job: PipelineJob, error: &str) -> crate::research::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.updated_at = now_rfc3339();
self.repository.save_job(job).await
}
pub async fn due_jobs(&self, limit: usize) -> crate::research::Result<Vec<PipelineJob>> {
self.repository.list_due_jobs(limit).await
}
}
pub fn spawn_research_scheduler<R: Runtime + 'static>(
service: Arc<crate::research::service::ResearchService<R>>,
) {
tauri::async_runtime::spawn(async move {
loop {
let _ = service.process_due_jobs().await;
tokio::time::sleep(Duration::from_secs(3)).await;
}
});
}
fn new_job(workspace_id: &str, entity_id: &str, job_kind: JobKind, payload_json: serde_json::Value) -> PipelineJob {
let now = now_rfc3339();
PipelineJob {
id: generate_id("job"),
workspace_id: workspace_id.to_string(),
entity_id: entity_id.to_string(),
job_kind,
status: JobStatus::Queued,
attempt_count: 0,
max_attempts: 3,
next_attempt_at: None,
last_error: None,
payload_json,
created_at: now.clone(),
updated_at: now,
}
}
fn next_retry_timestamp(attempt_number: u32) -> String {
let seconds = match attempt_number {
0 | 1 => 30,
2 => 5 * 60,
_ => 30 * 60,
};
(chrono::Utc::now() + chrono::Duration::seconds(i64::from(seconds))).to_rfc3339()
}

View File

@@ -0,0 +1,239 @@
//! Read-model projections for frontend workspace views.
use crate::research::types::{
GhostNote, KanbanColumn, MemoBlockCandidate, MemoSectionKind, NoteLink, NoteType, ResearchNote,
ResearchWorkspace, TimelineEvent, WorkspaceProjection, WorkspaceViewKind, GraphEdge, GraphNode,
};
pub(crate) fn build_workspace_projection(
workspace: ResearchWorkspace,
requested_view: WorkspaceViewKind,
notes: Vec<ResearchNote>,
links: Vec<NoteLink>,
ghosts: Vec<GhostNote>,
) -> WorkspaceProjection {
let memo_blocks = build_memo_blocks(&notes, &ghosts);
let graph_nodes = notes
.iter()
.map(|note| GraphNode {
id: note.id.clone(),
label: note.title.clone().unwrap_or_else(|| note.cleaned_text.clone()),
kind: format!("{:?}", note.note_type).to_ascii_lowercase(),
confidence: note.confidence,
evidence_status: note.evidence_status,
})
.collect();
let graph_edges = links
.iter()
.map(|link| GraphEdge {
id: link.id.clone(),
from: link.from_note_id.clone(),
to: link.to_note_id.clone(),
link_type: link.link_type,
strength: link.strength,
confidence: link.confidence,
})
.collect();
let kanban_columns = build_kanban_columns(&notes);
let timeline_events = build_timeline(&notes, &ghosts);
WorkspaceProjection {
workspace,
active_view: requested_view,
notes,
links,
ghosts,
memo_blocks,
graph_nodes,
graph_edges,
kanban_columns,
timeline_events,
}
}
pub(crate) fn build_memo_blocks(notes: &[ResearchNote], ghosts: &[GhostNote]) -> Vec<MemoBlockCandidate> {
let mut blocks = notes
.iter()
.filter(|note| {
!note.archived
&& !matches!(note.note_type, NoteType::Question | NoteType::FollowUpTask | NoteType::SourceReference)
&& !matches!(note.evidence_status, crate::research::types::EvidenceStatus::Unsourced)
})
.filter_map(|note| {
let section_kind = section_for_note(note.note_type)?;
Some(MemoBlockCandidate {
section_kind,
headline: note.title.clone().unwrap_or_else(|| note.cleaned_text.clone()),
body: note.ai_annotation.clone().unwrap_or_else(|| note.cleaned_text.clone()),
source_note_ids: vec![note.id.clone()],
citation_refs: note.source_id.iter().cloned().collect(),
confidence: note.confidence,
accepted: matches!(
note.thesis_status,
crate::research::types::ThesisStatus::AcceptedSupport
| crate::research::types::ThesisStatus::AcceptedCore
| crate::research::types::ThesisStatus::BullCase
| crate::research::types::ThesisStatus::BearCase
),
})
})
.collect::<Vec<_>>();
blocks.extend(
ghosts
.iter()
.filter(|ghost| matches!(ghost.state, crate::research::types::GhostLifecycleState::Accepted | crate::research::types::GhostLifecycleState::Converted))
.filter_map(|ghost| {
Some(MemoBlockCandidate {
section_kind: ghost.memo_section_hint?,
headline: ghost.headline.clone(),
body: ghost.body.clone(),
source_note_ids: ghost.supporting_note_ids.clone(),
citation_refs: ghost.source_ids.clone(),
confidence: ghost.confidence,
accepted: true,
})
}),
);
blocks
}
fn build_kanban_columns(notes: &[ResearchNote]) -> Vec<KanbanColumn> {
let mut columns = Vec::new();
for note_type in [
NoteType::Fact,
NoteType::ManagementSignal,
NoteType::Claim,
NoteType::Risk,
NoteType::Catalyst,
NoteType::ValuationPoint,
NoteType::Question,
NoteType::SourceReference,
] {
columns.push(KanbanColumn {
key: format!("{note_type:?}").to_ascii_lowercase(),
label: format!("{note_type:?}").replace("Point", "").replace("Signal", " Signal"),
notes: notes
.iter()
.filter(|note| note.note_type == note_type && !note.archived)
.cloned()
.collect(),
});
}
columns
}
fn build_timeline(notes: &[ResearchNote], ghosts: &[GhostNote]) -> Vec<TimelineEvent> {
let mut timeline = notes
.iter()
.filter(|note| matches!(note.note_type, NoteType::EventTakeaway | NoteType::Catalyst | NoteType::ManagementSignal))
.map(|note| TimelineEvent {
id: note.id.clone(),
label: note.title.clone().unwrap_or_else(|| note.cleaned_text.clone()),
note_id: note.id.clone(),
at: note.source_excerpt.as_ref().and_then(|excerpt| excerpt.location_label.clone()),
})
.collect::<Vec<_>>();
timeline.extend(
ghosts
.iter()
.filter(|ghost| matches!(ghost.ghost_class, crate::research::types::GhostNoteClass::ContradictionAlert))
.map(|ghost| TimelineEvent {
id: ghost.id.clone(),
label: ghost.headline.clone(),
note_id: ghost.supporting_note_ids.first().cloned().unwrap_or_else(|| ghost.id.clone()),
at: None,
}),
);
timeline
}
fn section_for_note(note_type: NoteType) -> Option<MemoSectionKind> {
match note_type {
NoteType::Thesis | NoteType::SubThesis | NoteType::MosaicInsight => Some(MemoSectionKind::InvestmentMemo),
NoteType::Risk | NoteType::Contradiction => Some(MemoSectionKind::RiskRegister),
NoteType::Catalyst => Some(MemoSectionKind::CatalystCalendar),
NoteType::ValuationPoint | NoteType::ScenarioAssumption => Some(MemoSectionKind::ValuationWriteUp),
NoteType::EventTakeaway => Some(MemoSectionKind::EarningsRecap),
NoteType::ChannelCheck => Some(MemoSectionKind::WatchlistUpdate),
NoteType::Fact
| NoteType::Quote
| NoteType::ManagementSignal
| NoteType::Claim
| NoteType::IndustryObservation
| NoteType::CompetitorComparison => Some(MemoSectionKind::StockPitch),
NoteType::Question | NoteType::FollowUpTask | NoteType::SourceReference | NoteType::Uncertainty => None,
}
}
#[cfg(test)]
mod tests {
use crate::research::types::{EvidenceStatus, MemoSectionKind, NoteType};
use super::build_memo_blocks;
fn note(note_type: NoteType, evidence_status: EvidenceStatus) -> crate::research::types::ResearchNote {
let now = crate::research::util::now_rfc3339();
crate::research::types::ResearchNote {
id: format!("note-{note_type:?}"),
workspace_id: "workspace-1".to_string(),
company_id: None,
ticker: Some("AAPL".to_string()),
source_id: Some("source-1".to_string()),
raw_text: "sample".to_string(),
cleaned_text: "sample".to_string(),
title: Some("Sample".to_string()),
note_type,
subtype: None,
analyst_status: crate::research::types::AnalystStatus::Accepted,
ai_annotation: Some("annotation".to_string()),
confidence: 0.8,
evidence_status,
inferred_links: Vec::new(),
ghost_status: crate::research::types::GhostStatus::None,
thesis_status: crate::research::types::ThesisStatus::AcceptedSupport,
created_at: now.clone(),
updated_at: now.clone(),
provenance: crate::research::types::NoteProvenance {
created_by: crate::research::types::ProvenanceActor::Manual,
capture_method: crate::research::types::CaptureMethod::QuickEntry,
source_kind: crate::research::types::SourceKind::Manual,
origin_note_id: None,
origin_ghost_id: None,
model_info: None,
created_at: now,
raw_input_hash: "hash".to_string(),
},
tags: Vec::new(),
catalysts: Vec::new(),
risks: Vec::new(),
valuation_refs: Vec::new(),
time_horizon: None,
scenario: None,
priority: crate::research::types::NotePriority::Normal,
pinned: false,
archived: false,
revision: 1,
source_excerpt: None,
last_enriched_at: None,
last_linked_at: None,
stale_reason: None,
superseded_by_note_id: None,
}
}
#[test]
fn build_memo_blocks_should_exclude_unsourced_questions() {
let blocks = build_memo_blocks(
&[
note(NoteType::Question, EvidenceStatus::Unsourced),
note(NoteType::Fact, EvidenceStatus::Corroborated),
],
&[],
);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].section_kind, MemoSectionKind::StockPitch);
}
}

View File

@@ -0,0 +1,897 @@
//! SQLite-backed persistence for research workspaces, notes, links, ghosts, and jobs.
use std::path::{Path, PathBuf};
use rusqlite::{params, Connection, OptionalExtension};
use crate::research::errors::{ResearchError, Result};
use crate::research::types::{
AuditEvent, GhostLifecycleState, GhostNote, GhostVisibilityState, JobKind, JobStatus, NoteLink,
NoteType, PipelineJob, ResearchNote, ResearchWorkspace, SourceRecord,
};
#[derive(Clone)]
pub struct ResearchRepository {
db_path: PathBuf,
}
impl ResearchRepository {
pub fn new(db_path: PathBuf) -> Result<Self> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let repository = Self { db_path };
let connection = repository.open_connection()?;
repository.initialize_schema(&connection)?;
Ok(repository)
}
pub async fn create_workspace(&self, workspace: ResearchWorkspace) -> Result<ResearchWorkspace> {
let value = workspace.clone();
self.with_connection(move |connection| {
connection.execute(
"INSERT INTO research_workspaces (
id, name, primary_ticker, stage, default_view, archived, created_at, updated_at, entity_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
value.id,
value.name,
value.primary_ticker,
serde_json::to_string(&value.stage)?,
serde_json::to_string(&value.default_view)?,
i64::from(value.archived),
value.created_at,
value.updated_at,
serde_json::to_string(&value)?,
],
)?;
Ok(value)
})
.await
}
pub async fn save_workspace(&self, workspace: ResearchWorkspace) -> Result<ResearchWorkspace> {
let value = workspace.clone();
self.with_connection(move |connection| {
connection.execute(
"INSERT INTO research_workspaces (
id, name, primary_ticker, stage, default_view, archived, created_at, updated_at, entity_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
primary_ticker = excluded.primary_ticker,
stage = excluded.stage,
default_view = excluded.default_view,
archived = excluded.archived,
updated_at = excluded.updated_at,
entity_json = excluded.entity_json",
params![
value.id,
value.name,
value.primary_ticker,
serde_json::to_string(&value.stage)?,
serde_json::to_string(&value.default_view)?,
i64::from(value.archived),
value.created_at,
value.updated_at,
serde_json::to_string(&value)?,
],
)?;
Ok(value)
})
.await
}
pub async fn list_workspaces(&self) -> Result<Vec<ResearchWorkspace>> {
self.with_connection(|connection| {
let mut statement = connection.prepare(
"SELECT entity_json
FROM research_workspaces
WHERE archived = 0
ORDER BY updated_at DESC",
)?;
let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.map(|json| serde_json::from_str(&json).map_err(ResearchError::from))
.collect()
})
.await
}
pub async fn get_workspace(&self, workspace_id: &str) -> Result<ResearchWorkspace> {
let workspace_id = workspace_id.to_string();
self.with_connection(move |connection| {
let json = connection
.query_row(
"SELECT entity_json FROM research_workspaces WHERE id = ?1",
params![workspace_id],
|row| row.get::<_, String>(0),
)
.optional()?
.ok_or_else(|| ResearchError::WorkspaceNotFound(workspace_id.clone()))?;
Ok(serde_json::from_str(&json)?)
})
.await
}
pub async fn create_note(&self, note: ResearchNote) -> Result<ResearchNote> {
self.save_note(note).await
}
pub async fn save_note(&self, note: ResearchNote) -> Result<ResearchNote> {
let value = note.clone();
self.with_connection(move |connection| {
connection.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![
value.id,
value.workspace_id,
serde_json::to_string(&value.note_type)?,
value.ticker,
value.source_id,
i64::from(value.archived),
i64::from(value.pinned),
i64::from(value.revision),
serde_json::to_string(&value.evidence_status)?,
value.created_at,
value.updated_at,
serde_json::to_string(&value)?,
],
)?;
connection.execute(
"DELETE FROM research_fts WHERE note_id = ?1",
params![value.id.clone()],
)?;
connection.execute(
"INSERT INTO research_fts (note_id, title, cleaned_text, ai_annotation)
VALUES (?1, ?2, ?3, ?4)",
params![
value.id,
value.title.clone().unwrap_or_default(),
value.cleaned_text,
value.ai_annotation.unwrap_or_default(),
],
)?;
Ok(value)
})
.await
}
pub async fn get_note(&self, note_id: &str) -> Result<ResearchNote> {
let note_id = note_id.to_string();
self.with_connection(move |connection| {
let json = connection
.query_row(
"SELECT entity_json FROM research_notes WHERE id = ?1",
params![note_id],
|row| row.get::<_, String>(0),
)
.optional()?
.ok_or_else(|| ResearchError::NoteNotFound(note_id.clone()))?;
Ok(serde_json::from_str(&json)?)
})
.await
}
pub async fn list_notes(
&self,
workspace_id: &str,
include_archived: bool,
note_type: Option<NoteType>,
) -> Result<Vec<ResearchNote>> {
let workspace_id = workspace_id.to_string();
self.with_connection(move |connection| {
let mut statement = if note_type.is_some() {
connection.prepare(
"SELECT entity_json FROM research_notes
WHERE workspace_id = ?1 AND (?2 = 1 OR archived = 0) AND note_type = ?3
ORDER BY pinned DESC, updated_at DESC",
)?
} else {
connection.prepare(
"SELECT entity_json FROM research_notes
WHERE workspace_id = ?1 AND (?2 = 1 OR archived = 0)
ORDER BY pinned DESC, updated_at DESC",
)?
};
let note_type_json = note_type
.map(|value| serde_json::to_string(&value))
.transpose()?;
let rows = if let Some(note_type_json) = note_type_json {
statement.query_map(
params![workspace_id, i64::from(include_archived), note_type_json],
|row| row.get::<_, String>(0),
)?
} else {
statement.query_map(params![workspace_id, i64::from(include_archived)], |row| {
row.get::<_, String>(0)
})?
};
rows.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.map(|json| serde_json::from_str(&json).map_err(ResearchError::from))
.collect()
})
.await
}
pub async fn archive_note(&self, note_id: &str, archived: bool) -> Result<ResearchNote> {
let mut note = self.get_note(note_id).await?;
note.archived = archived;
note.updated_at = crate::research::util::now_rfc3339();
self.save_note(note).await
}
pub async fn find_source_reference_note(
&self,
workspace_id: &str,
source_id: &str,
) -> Result<Option<ResearchNote>> {
let workspace_id = workspace_id.to_string();
let source_id = source_id.to_string();
self.with_connection(move |connection| {
let json = connection
.query_row(
"SELECT entity_json
FROM research_notes
WHERE workspace_id = ?1 AND source_id = ?2 AND note_type = ?3
LIMIT 1",
params![
workspace_id,
source_id,
serde_json::to_string(&NoteType::SourceReference)?,
],
|row| row.get::<_, String>(0),
)
.optional()?;
json.map(|value| serde_json::from_str(&value).map_err(ResearchError::from))
.transpose()
})
.await
}
pub async fn save_source(&self, source: SourceRecord) -> Result<SourceRecord> {
let value = source.clone();
self.with_connection(move |connection| {
connection.execute(
"INSERT INTO source_records (
id, workspace_id, ticker, kind, published_at, entity_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(id) DO UPDATE SET
workspace_id = excluded.workspace_id,
ticker = excluded.ticker,
kind = excluded.kind,
published_at = excluded.published_at,
entity_json = excluded.entity_json",
params![
value.id,
value.workspace_id,
value.ticker,
serde_json::to_string(&value.kind)?,
value.published_at,
serde_json::to_string(&value)?,
],
)?;
Ok(value)
})
.await
}
pub async fn list_sources(&self, workspace_id: &str) -> Result<Vec<SourceRecord>> {
let workspace_id = workspace_id.to_string();
self.with_connection(move |connection| {
let mut statement = connection.prepare(
"SELECT entity_json FROM source_records WHERE workspace_id = ?1 ORDER BY published_at DESC, id DESC",
)?;
let rows = statement.query_map(params![workspace_id], |row| row.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.map(|json| serde_json::from_str(&json).map_err(ResearchError::from))
.collect()
})
.await
}
pub async fn list_sources_by_ids(&self, source_ids: &[String]) -> Result<Vec<SourceRecord>> {
if source_ids.is_empty() {
return Ok(Vec::new());
}
let ids = source_ids.to_vec();
self.with_connection(move |connection| {
let mut results = Vec::new();
let mut statement = connection.prepare("SELECT entity_json FROM source_records WHERE id = ?1")?;
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)
})
.await
}
pub async fn find_source_by_checksum_or_accession(
&self,
workspace_id: &str,
checksum: Option<&str>,
filing_accession: Option<&str>,
) -> Result<Option<SourceRecord>> {
let workspace_id = workspace_id.to_string();
let checksum = checksum.map(ToOwned::to_owned);
let filing_accession = filing_accession.map(ToOwned::to_owned);
self.with_connection(move |connection| {
let json = if let Some(checksum) = checksum {
connection
.query_row(
"SELECT entity_json FROM source_records
WHERE workspace_id = ?1 AND json_extract(entity_json, '$.checksum') = ?2
LIMIT 1",
params![workspace_id, checksum],
|row| row.get::<_, String>(0),
)
.optional()?
} else if let Some(filing_accession) = filing_accession {
connection
.query_row(
"SELECT entity_json FROM source_records
WHERE workspace_id = ?1 AND json_extract(entity_json, '$.filingAccession') = ?2
LIMIT 1",
params![workspace_id, filing_accession],
|row| row.get::<_, String>(0),
)
.optional()?
} else {
None
};
json.map(|value| serde_json::from_str(&value).map_err(ResearchError::from))
.transpose()
})
.await
}
pub async fn replace_links_for_workspace(
&self,
workspace_id: &str,
links: Vec<NoteLink>,
) -> Result<Vec<NoteLink>> {
let workspace_id = workspace_id.to_string();
let values = links.clone();
self.with_connection(move |connection| {
let transaction = connection.transaction()?;
transaction.execute("DELETE FROM note_links WHERE workspace_id = ?1", params![workspace_id])?;
for link in &values {
transaction.execute(
"INSERT INTO note_links (
id, workspace_id, from_note_id, to_note_id, link_type, stale, updated_at, entity_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
link.id,
link.workspace_id,
link.from_note_id,
link.to_note_id,
serde_json::to_string(&link.link_type)?,
i64::from(link.stale),
link.updated_at,
serde_json::to_string(link)?,
],
)?;
}
transaction.commit()?;
Ok(values)
})
.await
}
pub async fn list_links(&self, workspace_id: &str, note_id: Option<&str>) -> Result<Vec<NoteLink>> {
let workspace_id = workspace_id.to_string();
let note_id = note_id.map(ToOwned::to_owned);
self.with_connection(move |connection| {
let mut statement = if note_id.is_some() {
connection.prepare(
"SELECT entity_json
FROM note_links
WHERE workspace_id = ?1 AND (from_note_id = ?2 OR to_note_id = ?2)
ORDER BY updated_at DESC",
)?
} else {
connection.prepare(
"SELECT entity_json
FROM note_links
WHERE workspace_id = ?1
ORDER BY updated_at DESC",
)?
};
let rows = if let Some(note_id) = note_id {
statement.query_map(params![workspace_id, note_id], |row| row.get::<_, String>(0))?
} else {
statement.query_map(params![workspace_id], |row| row.get::<_, String>(0))?
};
rows.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.map(|json| serde_json::from_str(&json).map_err(ResearchError::from))
.collect()
})
.await
}
pub async fn replace_ghosts_for_workspace(
&self,
workspace_id: &str,
ghosts: Vec<GhostNote>,
) -> Result<Vec<GhostNote>> {
let workspace_id = workspace_id.to_string();
let generated = ghosts.clone();
self.with_connection(move |connection| {
let transaction = connection.transaction()?;
let mut existing_statement = transaction.prepare(
"SELECT id, entity_json FROM ghost_notes WHERE workspace_id = ?1",
)?;
let existing_rows = existing_statement.query_map(params![workspace_id.clone()], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let existing = existing_rows.collect::<std::result::Result<Vec<_>, _>>()?;
transaction.execute("DELETE FROM ghost_notes WHERE workspace_id = ?1", params![workspace_id])?;
for mut ghost in generated.clone() {
if let Some((_, json)) = existing.iter().find(|(id, _)| *id == ghost.id) {
let prior: GhostNote = serde_json::from_str(json)?;
if matches!(
prior.state,
GhostLifecycleState::Accepted
| GhostLifecycleState::Dismissed
| GhostLifecycleState::Converted
| GhostLifecycleState::Ignored
) {
ghost.state = prior.state;
}
if matches!(prior.visibility_state, GhostVisibilityState::Pinned) {
ghost.visibility_state = prior.visibility_state;
}
ghost.promoted_note_id = prior.promoted_note_id;
}
transaction.execute(
"INSERT INTO ghost_notes (
id, workspace_id, state, visibility_state, updated_at, entity_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
ghost.id,
ghost.workspace_id,
serde_json::to_string(&ghost.state)?,
serde_json::to_string(&ghost.visibility_state)?,
ghost.updated_at,
serde_json::to_string(&ghost)?,
],
)?;
}
transaction.commit()?;
Ok(generated)
})
.await
}
pub async fn get_ghost(&self, ghost_note_id: &str) -> Result<GhostNote> {
let ghost_note_id = ghost_note_id.to_string();
self.with_connection(move |connection| {
let json = connection
.query_row(
"SELECT entity_json FROM ghost_notes WHERE id = ?1",
params![ghost_note_id],
|row| row.get::<_, String>(0),
)
.optional()?
.ok_or_else(|| ResearchError::GhostNoteNotFound(ghost_note_id.clone()))?;
Ok(serde_json::from_str(&json)?)
})
.await
}
pub async fn save_ghost(&self, ghost: GhostNote) -> Result<GhostNote> {
let value = ghost.clone();
self.with_connection(move |connection| {
connection.execute(
"INSERT INTO ghost_notes (
id, workspace_id, state, visibility_state, updated_at, entity_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(id) DO UPDATE SET
state = excluded.state,
visibility_state = excluded.visibility_state,
updated_at = excluded.updated_at,
entity_json = excluded.entity_json",
params![
value.id,
value.workspace_id,
serde_json::to_string(&value.state)?,
serde_json::to_string(&value.visibility_state)?,
value.updated_at,
serde_json::to_string(&value)?,
],
)?;
Ok(value)
})
.await
}
pub async fn list_ghosts(&self, workspace_id: &str, include_hidden: bool) -> Result<Vec<GhostNote>> {
let workspace_id = workspace_id.to_string();
self.with_connection(move |connection| {
let mut statement = connection.prepare(
"SELECT entity_json FROM ghost_notes
WHERE workspace_id = ?1
ORDER BY updated_at DESC",
)?;
let rows = statement.query_map(params![workspace_id], |row| row.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.map(|json| serde_json::from_str::<GhostNote>(&json).map_err(ResearchError::from))
.collect::<Result<Vec<_>>>()
.map(|ghosts| {
ghosts
.into_iter()
.filter(|ghost| include_hidden || !matches!(ghost.visibility_state, GhostVisibilityState::Hidden))
.collect()
})
})
.await
}
pub async fn enqueue_jobs(&self, jobs: Vec<PipelineJob>) -> Result<Vec<PipelineJob>> {
let values = jobs.clone();
self.with_connection(move |connection| {
let transaction = connection.transaction()?;
for job in &values {
transaction.execute(
"INSERT INTO pipeline_jobs (
id, workspace_id, entity_id, job_kind, status, next_attempt_at, updated_at, entity_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
next_attempt_at = excluded.next_attempt_at,
updated_at = excluded.updated_at,
entity_json = excluded.entity_json",
params![
job.id,
job.workspace_id,
job.entity_id,
serde_json::to_string(&job.job_kind)?,
serde_json::to_string(&job.status)?,
job.next_attempt_at,
job.updated_at,
serde_json::to_string(job)?,
],
)?;
}
transaction.commit()?;
Ok(values)
})
.await
}
pub async fn save_job(&self, job: PipelineJob) -> Result<PipelineJob> {
let value = job.clone();
self.enqueue_jobs(vec![value.clone()]).await?;
Ok(value)
}
pub async fn list_due_jobs(&self, limit: usize) -> Result<Vec<PipelineJob>> {
self.with_connection(move |connection| {
let mut statement = connection.prepare(
"SELECT entity_json
FROM pipeline_jobs
WHERE status IN (?1, ?2)
AND (next_attempt_at IS NULL OR next_attempt_at <= datetime('now'))
ORDER BY updated_at ASC
LIMIT ?3",
)?;
let rows = statement.query_map(
params![
serde_json::to_string(&JobStatus::Queued)?,
serde_json::to_string(&JobStatus::Failed)?,
i64::try_from(limit).map_err(|error| ResearchError::Validation(error.to_string()))?,
],
|row| row.get::<_, String>(0),
)?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.map(|json| serde_json::from_str(&json).map_err(ResearchError::from))
.collect()
})
.await
}
pub async fn list_jobs(&self, workspace_id: &str, job_kind: Option<JobKind>) -> Result<Vec<PipelineJob>> {
let workspace_id = workspace_id.to_string();
self.with_connection(move |connection| {
let mut statement = if job_kind.is_some() {
connection.prepare(
"SELECT entity_json FROM pipeline_jobs
WHERE workspace_id = ?1 AND job_kind = ?2
ORDER BY updated_at DESC",
)?
} else {
connection.prepare(
"SELECT entity_json FROM pipeline_jobs
WHERE workspace_id = ?1
ORDER BY updated_at DESC",
)?
};
let rows = if let Some(job_kind) = job_kind {
statement.query_map(
params![workspace_id, serde_json::to_string(&job_kind)?],
|row| row.get::<_, String>(0),
)?
} else {
statement.query_map(params![workspace_id], |row| row.get::<_, String>(0))?
};
rows.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.map(|json| serde_json::from_str(&json).map_err(ResearchError::from))
.collect()
})
.await
}
pub async fn retry_failed_jobs(
&self,
workspace_id: &str,
job_kind: Option<JobKind>,
) -> Result<Vec<PipelineJob>> {
let mut jobs = self.list_jobs(workspace_id, job_kind).await?;
let mut retried = Vec::new();
for job in &mut jobs {
if matches!(job.status, JobStatus::Failed | JobStatus::Skipped) {
job.status = JobStatus::Queued;
job.next_attempt_at = None;
job.last_error = None;
job.updated_at = crate::research::util::now_rfc3339();
retried.push(job.clone());
}
}
self.enqueue_jobs(retried.clone()).await?;
Ok(retried)
}
pub async fn append_audit_event(&self, event: AuditEvent) -> Result<AuditEvent> {
let value = event.clone();
self.with_connection(move |connection| {
connection.execute(
"INSERT INTO audit_events (
id, workspace_id, entity_id, entity_kind, created_at, entity_json
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
value.id,
value.workspace_id,
value.entity_id,
serde_json::to_string(&value.entity_kind)?,
value.created_at,
serde_json::to_string(&value)?,
],
)?;
Ok(value)
})
.await
}
pub async fn list_audit_events_for_entity(&self, entity_id: &str) -> Result<Vec<AuditEvent>> {
let entity_id = entity_id.to_string();
self.with_connection(move |connection| {
let mut statement = connection.prepare(
"SELECT entity_json FROM audit_events WHERE entity_id = ?1 ORDER BY created_at ASC",
)?;
let rows = statement.query_map(params![entity_id], |row| row.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.map(|json| serde_json::from_str(&json).map_err(ResearchError::from))
.collect()
})
.await
}
pub async fn list_audit_events_for_workspace(&self, workspace_id: &str) -> Result<Vec<AuditEvent>> {
let workspace_id = workspace_id.to_string();
self.with_connection(move |connection| {
let mut statement = connection.prepare(
"SELECT entity_json FROM audit_events WHERE workspace_id = ?1 ORDER BY created_at ASC",
)?;
let rows = statement.query_map(params![workspace_id], |row| row.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.map(|json| serde_json::from_str(&json).map_err(ResearchError::from))
.collect()
})
.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;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
CREATE TABLE IF NOT EXISTS research_workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
primary_ticker TEXT NOT NULL,
stage TEXT NOT NULL,
default_view TEXT NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
entity_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS research_notes (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES research_workspaces(id) ON DELETE CASCADE,
note_type TEXT NOT NULL,
ticker TEXT,
source_id TEXT,
archived INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
revision INTEGER NOT NULL,
evidence_status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
entity_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS research_notes_workspace_archived_updated_idx
ON research_notes (workspace_id, archived, updated_at DESC);
CREATE INDEX IF NOT EXISTS research_notes_workspace_type_archived_idx
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 TABLE IF NOT EXISTS note_links (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES research_workspaces(id) ON DELETE CASCADE,
from_note_id TEXT NOT NULL,
to_note_id TEXT NOT NULL,
link_type TEXT NOT NULL,
stale INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
entity_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS note_links_workspace_from_type_idx
ON note_links (workspace_id, from_note_id, link_type);
CREATE TABLE IF NOT EXISTS ghost_notes (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES research_workspaces(id) ON DELETE CASCADE,
state TEXT NOT NULL,
visibility_state TEXT NOT NULL,
updated_at TEXT NOT NULL,
entity_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS ghost_notes_workspace_state_visibility_idx
ON ghost_notes (workspace_id, state, visibility_state, updated_at DESC);
CREATE TABLE IF NOT EXISTS source_records (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES research_workspaces(id) ON DELETE CASCADE,
ticker TEXT,
kind TEXT NOT NULL,
published_at TEXT,
entity_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS source_records_workspace_ticker_kind_published_idx
ON source_records (workspace_id, ticker, kind, published_at DESC);
CREATE TABLE IF NOT EXISTS source_excerpts (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL,
entity_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS pipeline_jobs (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES research_workspaces(id) ON DELETE CASCADE,
entity_id TEXT NOT NULL,
job_kind TEXT NOT NULL,
status TEXT NOT NULL,
next_attempt_at TEXT,
updated_at TEXT NOT NULL,
entity_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS pipeline_jobs_status_next_attempt_idx
ON pipeline_jobs (status, next_attempt_at);
CREATE TABLE IF NOT EXISTS workspace_view_state (
workspace_id TEXT PRIMARY KEY,
entity_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS memo_exports (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
entity_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES research_workspaces(id) ON DELETE CASCADE,
entity_id TEXT NOT NULL,
entity_kind TEXT NOT NULL,
created_at TEXT NOT NULL,
entity_json TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS research_fts USING fts5(note_id UNINDEXED, title, cleaned_text, ai_annotation);",
)?;
Ok(())
}
async fn with_connection<F, T>(&self, task: F) -> Result<T>
where
F: FnOnce(&mut Connection) -> Result<T> + Send + 'static,
T: Send + 'static,
{
let db_path = self.db_path.clone();
tokio::task::spawn_blocking(move || {
let mut connection = open_connection(&db_path)?;
task(&mut connection)
})
.await
.map_err(|error| ResearchError::Join(error.to_string()))?
}
}
fn open_connection(path: &Path) -> Result<Connection> {
let connection = Connection::open(path)?;
connection.execute_batch(
"PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;",
)?;
Ok(connection)
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::ResearchRepository;
use crate::research::types::{ResearchStage, ResearchWorkspace, WorkspaceScope, WorkspaceViewKind};
#[tokio::test]
async fn new_should_initialize_schema() {
let dir = tempdir().unwrap();
let repository = ResearchRepository::new(dir.path().join("research.sqlite")).unwrap();
let now = crate::research::util::now_rfc3339();
repository
.create_workspace(ResearchWorkspace {
id: "workspace-1".to_string(),
name: "AAPL".to_string(),
primary_ticker: "AAPL".to_string(),
scope: WorkspaceScope::SingleCompany,
stage: ResearchStage::Capture,
default_view: WorkspaceViewKind::Canvas,
pinned_note_ids: Vec::new(),
archived: false,
created_at: now.clone(),
updated_at: now,
})
.await
.unwrap();
let workspaces = repository.list_workspaces().await.unwrap();
assert_eq!(workspaces.len(), 1);
}
}

View File

@@ -0,0 +1,851 @@
//! Serializable research-domain entities, enums, and command DTOs.
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceScope {
#[default]
SingleCompany,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "snake_case")]
pub enum ResearchStage {
#[default]
Capture,
Organize,
Thesis,
Drafting,
Monitor,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceViewKind {
#[default]
Canvas,
Kanban,
Graph,
ThesisBuilder,
MemoDrafting,
EvidenceTrace,
CatalystRiskMap,
Timeline,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "snake_case")]
pub enum NoteType {
#[default]
Fact,
Quote,
ManagementSignal,
Claim,
Thesis,
SubThesis,
Risk,
Catalyst,
ValuationPoint,
ScenarioAssumption,
IndustryObservation,
CompetitorComparison,
Question,
Contradiction,
Uncertainty,
FollowUpTask,
SourceReference,
EventTakeaway,
ChannelCheck,
MosaicInsight,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "snake_case")]
pub enum AnalystStatus {
#[default]
Captured,
ReviewQueue,
Reviewed,
Accepted,
NeedsFollowUp,
Dismissed,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceStatus {
#[default]
Unsourced,
SourceLinked,
Quoted,
Corroborated,
Inferred,
Contradicted,
Stale,
Superseded,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "snake_case")]
pub enum GhostStatus {
#[default]
None,
CandidateInput,
GhostGenerated,
GhostPromoted,
GhostDismissed,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "snake_case")]
pub enum ThesisStatus {
#[default]
None,
CandidateSupport,
CandidateCore,
AcceptedSupport,
AcceptedCore,
BullCase,
BearCase,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum TimeHorizon {
NearTerm,
NextEarnings,
TwelveMonth,
MultiYear,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ScenarioKind {
Base,
Bull,
Bear,
DownsideCase,
UpsideCase,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "snake_case")]
pub enum NotePriority {
Low,
#[default]
Normal,
High,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StaleReason {
NewFiling,
NewGuidance,
NewEarnings,
SourceExpired,
NoteUpdated,
SupersededByAnalyst,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "snake_case")]
pub enum SourceKind {
Filing,
Transcript,
Article,
NewsFeed,
Model,
#[default]
Manual,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum FreshnessBucket {
Fresh,
Aging,
Stale,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceStrength {
PrimaryVerified,
PrimaryExcerpt,
SecondaryReputable,
SecondaryUnverified,
AnalystModel,
ManualUnsourced,
AiInferenceOnly,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ProvenanceActor {
Manual,
AiEnrichment,
GhostConversion,
Import,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum CaptureMethod {
QuickEntry,
TranscriptClip,
FilingExtract,
NewsImport,
ManualLink,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ModelInfo {
pub task_profile: String,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct NoteProvenance {
pub created_by: ProvenanceActor,
pub capture_method: CaptureMethod,
pub source_kind: SourceKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub origin_note_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub origin_ghost_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_info: Option<ModelInfo>,
pub created_at: String,
pub raw_input_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SourceExcerpt {
pub source_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub excerpt_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_offset: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ValuationRef {
pub metric: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub multiple: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub basis: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ResearchWorkspace {
pub id: String,
pub name: String,
pub primary_ticker: String,
pub scope: WorkspaceScope,
pub stage: ResearchStage,
pub default_view: WorkspaceViewKind,
pub pinned_note_ids: Vec<String>,
pub archived: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ResearchNote {
pub id: String,
pub workspace_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub company_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ticker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_id: Option<String>,
pub raw_text: String,
pub cleaned_text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub note_type: NoteType,
#[serde(skip_serializing_if = "Option::is_none")]
pub subtype: Option<String>,
pub analyst_status: AnalystStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_annotation: Option<String>,
pub confidence: f32,
pub evidence_status: EvidenceStatus,
pub inferred_links: Vec<String>,
pub ghost_status: GhostStatus,
pub thesis_status: ThesisStatus,
pub created_at: String,
pub updated_at: String,
pub provenance: NoteProvenance,
pub tags: Vec<String>,
pub catalysts: Vec<String>,
pub risks: Vec<String>,
pub valuation_refs: Vec<ValuationRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time_horizon: Option<TimeHorizon>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scenario: Option<ScenarioKind>,
pub priority: NotePriority,
pub pinned: bool,
pub archived: bool,
pub revision: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_excerpt: Option<SourceExcerpt>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_enriched_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_linked_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stale_reason: Option<StaleReason>,
#[serde(skip_serializing_if = "Option::is_none")]
pub superseded_by_note_id: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum LinkType {
Supports,
Contradicts,
Qualifies,
DerivedFrom,
SourcedBy,
Updates,
Supersedes,
PeerReadthrough,
ValuationDependsOn,
CatalystFor,
RiskTo,
TimeframeConflict,
AssumptionFor,
ManagementVsReality,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum LinkStrength {
Weak,
Medium,
Strong,
Critical,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceBasis {
Lexical,
SharedSource,
Temporal,
Numerical,
Structured,
ModelAssisted,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum LinkOrigin {
Heuristic,
Ai,
Analyst,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct NoteLink {
pub id: String,
pub workspace_id: String,
pub from_note_id: String,
pub to_note_id: String,
pub link_type: LinkType,
pub directional: bool,
pub confidence: f32,
pub strength: LinkStrength,
pub evidence_basis: EvidenceBasis,
pub created_by: LinkOrigin,
pub created_at: String,
pub updated_at: String,
pub source_revision_pair: (u32, u32),
pub stale: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub stale_reason: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum GhostNoteClass {
CandidateThesis,
CandidateRisk,
CandidateCatalyst,
MissingEvidencePrompt,
ContradictionAlert,
ValuationBridge,
ScenarioImplication,
MemoOutlineSuggestion,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(rename_all = "snake_case")]
pub enum GhostTone {
#[default]
Tentative,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum GhostVisibilityState {
Hidden,
Collapsed,
Visible,
Pinned,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum GhostLifecycleState {
Generated,
Visible,
Ignored,
Dismissed,
Accepted,
Converted,
Superseded,
Stale,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum MemoSectionKind {
StockPitch,
InvestmentMemo,
BullCase,
BearCase,
CatalystCalendar,
RiskRegister,
ValuationWriteUp,
EarningsPreview,
EarningsRecap,
WatchlistUpdate,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GhostNote {
pub id: String,
pub workspace_id: String,
pub ghost_class: GhostNoteClass,
pub headline: String,
pub body: String,
pub tone: GhostTone,
pub confidence: f32,
pub visibility_state: GhostVisibilityState,
pub state: GhostLifecycleState,
pub supporting_note_ids: Vec<String>,
pub contradicting_note_ids: Vec<String>,
pub source_ids: Vec<String>,
pub evidence_threshold_met: bool,
pub created_at: String,
pub updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub superseded_by_ghost_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub promoted_note_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memo_section_hint: Option<MemoSectionKind>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SourceRecord {
pub id: String,
pub workspace_id: String,
pub kind: SourceKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub ticker: Option<String>,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub canonical_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filing_accession: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub form_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub as_of_date: Option<String>,
pub ingested_at: String,
pub freshness_bucket: FreshnessBucket,
#[serde(skip_serializing_if = "Option::is_none")]
pub checksum: Option<String>,
pub metadata_json: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub superseded_by_source_id: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum JobKind {
EnrichNote,
InferLinks,
EvaluateDuplicates,
EvaluateGhosts,
RefreshSourceMetadata,
RecalculateStaleness,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum JobStatus {
Queued,
Running,
Completed,
Failed,
Skipped,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PipelineJob {
pub id: String,
pub workspace_id: String,
pub entity_id: String,
pub job_kind: JobKind,
pub status: JobStatus,
pub attempt_count: u32,
pub max_attempts: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_attempt_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
pub payload_json: Value,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum AuditEntityKind {
Workspace,
Note,
Link,
Ghost,
Source,
Job,
MemoExport,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum AuditActor {
Analyst,
System,
Ai,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AuditEvent {
pub id: String,
pub workspace_id: String,
pub entity_id: String,
pub entity_kind: AuditEntityKind,
pub action: String,
pub actor: AuditActor,
#[serde(skip_serializing_if = "Option::is_none")]
pub prior_revision: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_revision: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub job_id: Option<String>,
pub source_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlacementHint {
pub tile_lane: String,
pub kanban_column: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub graph_anchor_note_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct NoteCaptureResult {
pub note: ResearchNote,
pub placement_hint: PlacementHint,
pub queued_jobs: Vec<PipelineJob>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceProjection {
pub workspace: ResearchWorkspace,
pub active_view: WorkspaceViewKind,
pub notes: Vec<ResearchNote>,
pub links: Vec<NoteLink>,
pub ghosts: Vec<GhostNote>,
pub memo_blocks: Vec<MemoBlockCandidate>,
pub graph_nodes: Vec<GraphNode>,
pub graph_edges: Vec<GraphEdge>,
pub kanban_columns: Vec<KanbanColumn>,
pub timeline_events: Vec<TimelineEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GraphNode {
pub id: String,
pub label: String,
pub kind: String,
pub confidence: f32,
pub evidence_status: EvidenceStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GraphEdge {
pub id: String,
pub from: String,
pub to: String,
pub link_type: LinkType,
pub strength: LinkStrength,
pub confidence: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KanbanColumn {
pub key: String,
pub label: String,
pub notes: Vec<ResearchNote>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct TimelineEvent {
pub id: String,
pub label: String,
pub note_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MemoBlockCandidate {
pub section_kind: MemoSectionKind,
pub headline: String,
pub body: String,
pub source_note_ids: Vec<String>,
pub citation_refs: Vec<String>,
pub confidence: f32,
pub accepted: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ResearchBundleExport {
pub workspace: ResearchWorkspace,
pub notes: Vec<ResearchNote>,
pub links: Vec<NoteLink>,
pub ghosts: Vec<GhostNote>,
pub sources: Vec<SourceRecord>,
pub audit_events: Vec<AuditEvent>,
pub markdown_memo: String,
pub json_bundle: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct NoteAuditTrail {
pub note: ResearchNote,
pub links: Vec<NoteLink>,
pub related_ghosts: Vec<GhostNote>,
pub sources: Vec<SourceRecord>,
pub audit_events: Vec<AuditEvent>,
pub memo_blocks: Vec<MemoBlockCandidate>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SourceReferenceInput {
pub kind: SourceKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filing_accession: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub form_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excerpt_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location_label: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CreateResearchWorkspaceRequest {
pub name: String,
pub primary_ticker: String,
#[serde(default)]
pub stage: ResearchStage,
#[serde(default)]
pub default_view: WorkspaceViewKind,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CaptureResearchNoteRequest {
pub workspace_id: String,
pub raw_text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub company_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ticker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_ref: Option<SourceReferenceInput>,
#[serde(skip_serializing_if = "Option::is_none")]
pub position_hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_note_type_override: Option<NoteType>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct UpdateResearchNoteRequest {
pub note_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note_type: Option<NoteType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subtype: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub analyst_status: Option<AnalystStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pinned: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<NotePriority>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thesis_status: Option<ThesisStatus>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ArchiveResearchNoteRequest {
pub note_id: String,
pub archived: bool,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ListResearchNotesRequest {
pub workspace_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_archived: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note_type: Option<NoteType>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GetWorkspaceProjectionRequest {
pub workspace_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub view: Option<WorkspaceViewKind>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ListNoteLinksRequest {
pub workspace_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub note_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ListWorkspaceGhostNotesRequest {
pub workspace_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_hidden: Option<bool>,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum GhostReviewAction {
Accept,
Ignore,
Dismiss,
Pin,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ReviewGhostNoteRequest {
pub ghost_note_id: String,
pub action: GhostReviewAction,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PromoteNoteToThesisRequest {
pub note_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub thesis_status: Option<ThesisStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note_type: Option<NoteType>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RetryResearchJobsRequest {
pub workspace_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub job_kind: Option<JobKind>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GetNoteAuditTrailRequest {
pub note_id: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExportResearchBundleRequest {
pub workspace_id: String,
}

View File

@@ -0,0 +1,41 @@
//! Internal helpers shared across the research subsystem.
use std::sync::atomic::{AtomicU64, Ordering};
use chrono::Utc;
use sha2::{Digest, Sha256};
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
pub(crate) fn now_rfc3339() -> String {
Utc::now().to_rfc3339()
}
pub(crate) fn now_timestamp() -> i64 {
Utc::now().timestamp()
}
pub(crate) fn generate_id(prefix: &str) -> String {
format!("{prefix}-{}-{}", now_timestamp(), NEXT_ID.fetch_add(1, Ordering::Relaxed))
}
pub(crate) fn sha256_hex(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
format!("{:x}", hasher.finalize())
}
pub(crate) fn normalize_text(input: &str) -> String {
input.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub(crate) fn clean_title(input: &str, max_len: usize) -> String {
let normalized = normalize_text(input);
if normalized.chars().count() <= max_len {
return normalized;
}
let mut shortened = normalized.chars().take(max_len.saturating_sub(1)).collect::<String>();
shortened.push('…');
shortened
}

View File

@@ -4,11 +4,12 @@ use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Wry};
use tauri::{AppHandle, Manager, Wry};
use tokio::sync::{oneshot, Mutex as AsyncMutex};
use crate::agent::{AgentService, AgentSettingsService};
use crate::error::AppError;
use crate::news::NewsService;
use crate::portfolio::PortfolioService;
use crate::terminal::google_finance::GoogleFinanceLookup;
use crate::terminal::sec_edgar::{
@@ -95,7 +96,9 @@ impl SecUserAgentProvider for SettingsBackedSecUserAgentProvider {
pub struct AppState {
/// Stateful chat service used for per-session conversation history and agent config.
pub agent: AsyncMutex<AgentService<Wry>>,
/// Slash-command executor backed by shared mock data.
/// Local-first RSS/Atom news runtime backed by SQLite.
pub news_service: Arc<NewsService>,
/// Slash-command executor backed by shared services.
pub command_service: Arc<TerminalCommandService>,
/// Pending approvals for agent-triggered mutating commands.
pub pending_agent_tool_approvals: Arc<PendingAgentToolApprovals>,
@@ -113,13 +116,30 @@ impl AppState {
))));
let portfolio_service =
Arc::new(PortfolioService::new(app_handle, security_lookup.clone()));
let news_root = app_handle
.path()
.app_data_dir()
.map_err(|_| {
AppError::InvalidSettings("news app data directory is unavailable".to_string())
})?
.join("news");
let news_service = Arc::new(
NewsService::new(
news_root.join("news.sqlite"),
news_root.join("news-feeds.json"),
include_bytes!("../news-feeds.default.json"),
)
.map_err(|error| AppError::InvalidSettings(error.to_string()))?,
);
Ok(Self {
agent: AsyncMutex::new(AgentService::new(app_handle)?),
news_service: news_service.clone(),
command_service: Arc::new(TerminalCommandService::new(
security_lookup,
sec_edgar_lookup,
portfolio_service,
news_service,
)),
pending_agent_tool_approvals: Arc::new(PendingAgentToolApprovals::new()),
})

View File

@@ -1,6 +1,7 @@
use std::sync::Arc;
use std::time::Duration;
use crate::news::{NewsService, QueryNewsFeedRequest};
use crate::portfolio::{
CashConfirmation, PortfolioCommandError, PortfolioManagement, PortfolioStats,
PortfolioTransaction, TradeConfirmation, TransactionKind,
@@ -18,6 +19,7 @@ use crate::terminal::{
/// Executes supported slash commands against live search plus shared local fixture data.
pub struct TerminalCommandService {
mock_data: MockFinancialData,
news_service: Arc<NewsService>,
security_lookup: Arc<dyn SecurityLookup>,
edgar_lookup: Arc<dyn EdgarDataLookup>,
portfolio_service: Arc<dyn PortfolioManagement>,
@@ -30,12 +32,14 @@ impl TerminalCommandService {
security_lookup: Arc<dyn SecurityLookup>,
edgar_lookup: Arc<dyn EdgarDataLookup>,
portfolio_service: Arc<dyn PortfolioManagement>,
news_service: Arc<NewsService>,
) -> Self {
Self::with_dependencies(
load_mock_financial_data(),
security_lookup,
edgar_lookup,
portfolio_service,
news_service,
DEFAULT_LOOKUP_FOLLOWUP_DELAY,
)
}
@@ -45,10 +49,12 @@ impl TerminalCommandService {
security_lookup: Arc<dyn SecurityLookup>,
edgar_lookup: Arc<dyn EdgarDataLookup>,
portfolio_service: Arc<dyn PortfolioManagement>,
news_service: Arc<NewsService>,
lookup_followup_delay: Duration,
) -> Self {
Self {
mock_data,
news_service,
security_lookup,
edgar_lookup,
portfolio_service,
@@ -114,7 +120,7 @@ impl TerminalCommandService {
.await
}
}
"/news" => self.news(command.args.first().map(String::as_str)),
"/news" => self.news(command.args.first().map(String::as_str)).await,
"/analyze" => self.analyze(command.args.first().map(String::as_str)),
"/help" => help_response(),
_ => TerminalCommandResponse::Text {
@@ -241,21 +247,29 @@ impl TerminalCommandService {
self.load_search_match(query, selected_match, true).await
}
fn news(&self, ticker: Option<&str>) -> TerminalCommandResponse {
async fn news(&self, ticker: Option<&str>) -> TerminalCommandResponse {
let normalized_ticker = ticker.map(|value| value.trim().to_uppercase());
let news_items = match normalized_ticker.as_deref() {
Some(ticker) if !ticker.is_empty() => self
.mock_data
.news_items
.iter()
.filter(|item| {
item.related_tickers
.iter()
.any(|related| related.eq_ignore_ascii_case(ticker))
})
.cloned()
.collect(),
_ => self.mock_data.news_items.clone(),
let response = self
.news_service
.query_feed(QueryNewsFeedRequest {
ticker: normalized_ticker.clone().filter(|value| !value.is_empty()),
search: None,
only_highlighted: None,
only_saved: None,
only_unread: None,
limit: Some(50),
offset: Some(0),
})
.await;
let news_items = match response {
Ok(response) => response.articles,
Err(error) => {
return TerminalCommandResponse::Text {
content: format!("News feed unavailable: {error}"),
portfolio: None,
};
}
};
TerminalCommandResponse::panel(PanelPayload::News {
@@ -825,13 +839,19 @@ fn format_quantity(value: f64) -> String {
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use futures::future::BoxFuture;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::TerminalCommandService;
use crate::news::{NewsService, RefreshNewsFeedRequest};
use crate::portfolio::{
CashConfirmation, PortfolioCommandError, PortfolioManagement, PortfolioStats,
PortfolioTransaction, TradeConfirmation, TransactionKind,
@@ -1156,6 +1176,7 @@ mod tests {
lookup.clone(),
Arc::new(FakeEdgarLookup),
portfolio_service,
test_news_service(),
Duration::ZERO,
),
lookup,
@@ -1175,6 +1196,7 @@ mod tests {
}),
Arc::new(FakeEdgarLookup),
Arc::new(FakePortfolioService::default()),
test_news_service(),
Duration::ZERO,
)
}
@@ -1193,6 +1215,88 @@ mod tests {
futures::executor::block_on(service.lookup_company(symbol))
}
fn test_news_service() -> Arc<NewsService> {
let root = unique_test_directory("terminal-news-service");
Arc::new(
NewsService::new(
root.join("news.sqlite"),
root.join("news-feeds.json"),
br#"{"feeds":[{"id":"sample","name":"Sample Feed","url":"https://example.com/feed.xml","refreshMinutes":15}]}"#,
)
.unwrap(),
)
}
fn unique_test_directory(prefix: &str) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("{prefix}-{suffix}"));
fs::create_dir_all(&path).unwrap();
path
}
#[tokio::test]
async fn news_command_returns_articles_from_local_database() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/news.atom"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/atom+xml")
.set_body_string(include_str!("../../tests/fixtures/news/sample.atom")),
)
.mount(&server)
.await;
let root = unique_test_directory("terminal-news-command");
let config = format!(
r#"{{"feeds":[{{"id":"sample","name":"Sample Feed","url":"{}/news.atom","refreshMinutes":15}}]}}"#,
server.uri(),
);
let news_service = Arc::new(
NewsService::new(
root.join("news.sqlite"),
root.join("news-feeds.json"),
config.as_bytes(),
)
.unwrap(),
);
news_service
.refresh_feed(RefreshNewsFeedRequest { force: Some(true) })
.await
.unwrap();
server.reset().await;
let service = TerminalCommandService::with_dependencies(
load_mock_financial_data(),
Arc::new(FakeSecurityLookup::successful(vec![])),
Arc::new(FakeEdgarLookup),
Arc::new(FakePortfolioService::default()),
news_service,
Duration::ZERO,
);
let response = service
.execute(ExecuteTerminalCommandRequest {
workspace_id: "workspace-1".to_string(),
input: "/news NVDA".to_string(),
})
.await;
match response {
TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
PanelPayload::News { data, ticker } => {
assert_eq!(ticker.as_deref(), Some("NVDA"));
assert_eq!(data.len(), 1);
assert_eq!(data[0].tickers, vec!["NVDA".to_string()]);
}
other => panic!("expected news panel, got {other:?}"),
},
other => panic!("expected panel response, got {other:?}"),
}
}
#[test]
fn returns_company_panel_for_exact_search_match() {
let (service, lookup) = build_service(Ok(vec![SecurityMatch {
@@ -1351,6 +1455,7 @@ mod tests {
Arc::new(FakeSecurityLookup::successful(vec![])),
Arc::new(FakeEdgarLookup),
Arc::new(FakePortfolioService::default()),
test_news_service(),
Duration::ZERO,
);
@@ -1396,6 +1501,7 @@ mod tests {
Arc::new(FakeSecurityLookup::successful(vec![])),
Arc::new(FakeEdgarLookup),
Arc::new(FakePortfolioService::default()),
test_news_service(),
Duration::ZERO,
);
@@ -1416,6 +1522,7 @@ mod tests {
Arc::new(FakeSecurityLookup::successful(vec![])),
Arc::new(FakeEdgarLookup),
Arc::new(FakePortfolioService::default()),
test_news_service(),
Duration::ZERO,
);
@@ -1436,6 +1543,7 @@ mod tests {
Arc::new(FakeSecurityLookup::successful(vec![])),
Arc::new(FakeEdgarLookup),
Arc::new(FakePortfolioService::default()),
test_news_service(),
Duration::ZERO,
);

View File

@@ -10,6 +10,6 @@ pub use types::{
CashFlowPanelData, CashFlowPeriod, ChatCommandRequest, Company, CompanyPricePoint,
CompanyProfile, DividendEvent, DividendsPanelData, EarningsPanelData, EarningsPeriod,
ErrorPanel, ExecuteTerminalCommandRequest, FilingRef, FinancialsPanelData, Frequency, Holding,
LookupCompanyRequest, MockFinancialData, NewsItem, PanelPayload, Portfolio, SourceStatus,
LookupCompanyRequest, MockFinancialData, PanelPayload, Portfolio, SourceStatus,
StatementPeriod, StockAnalysis, TerminalCommandResponse,
};

View File

@@ -2,6 +2,8 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::news::NewsArticle;
/// Frontend request payload for slash-command execution.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
@@ -68,7 +70,7 @@ pub enum PanelPayload {
data: Portfolio,
},
News {
data: Vec<NewsItem>,
data: Vec<NewsArticle>,
ticker: Option<String>,
},
Analysis {
@@ -206,19 +208,6 @@ pub struct Portfolio {
pub stale_pricing_symbols: Option<Vec<String>>,
}
/// News item serialized with an ISO timestamp for transport safety.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct NewsItem {
pub id: String,
pub source: String,
pub headline: String,
pub timestamp: String,
pub snippet: String,
pub url: Option<String>,
pub related_tickers: Vec<String>,
}
/// Structured analysis payload for the analysis panel.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
@@ -239,7 +228,6 @@ pub struct StockAnalysis {
pub struct MockFinancialData {
#[allow(dead_code)]
pub companies: Vec<Company>,
pub news_items: Vec<NewsItem>,
pub analyses: HashMap<String, StockAnalysis>,
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Malformed RSS Feed</title>
<link>https://example.com</link>
<description>Malformed RSS description</description>
<item>
<title>Valid entry</title>
<link>https://example.com/valid-entry</link>
<description>Valid summary</description>
<pubDate>Tue, 08 Apr 2026 10:00:00 GMT</pubDate>
</item>
<item>
<description>Missing title should be skipped</description>
<pubDate>Tue, 08 Apr 2026 09:00:00 GMT</pubDate>
</item>
</channel>
</rss>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Sample Atom Feed</title>
<updated>2026-04-08T10:00:00Z</updated>
<id>https://example.com/feed</id>
<entry>
<title>SEC current report posts for NVDA</title>
<link href="https://example.com/nvda-8k" rel="alternate" />
<id>https://example.com/nvda-8k</id>
<updated>2026-04-08T10:00:00Z</updated>
<summary>Current report mentions NVDA supply updates.</summary>
</entry>
<entry>
<title>Federal Reserve releases policy minutes</title>
<link href="https://example.com/fed-minutes" rel="alternate" />
<id>https://example.com/fed-minutes</id>
<updated>2026-04-08T09:45:00Z</updated>
<summary>Minutes discuss inflation and labor market trends.</summary>
</entry>
</feed>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Sample RSS Feed</title>
<link>https://example.com</link>
<description>Sample RSS description</description>
<item>
<title>Fed signals steady rates</title>
<link>https://example.com/fed-rates</link>
<description><![CDATA[<p>The Federal Reserve left policy unchanged.</p>]]></description>
<pubDate>Tue, 08 Apr 2026 10:00:00 GMT</pubDate>
</item>
<item>
<title>SEC highlights new filing activity</title>
<link>https://example.com/sec-filings</link>
<description>Fresh 8-K activity across major issuers.</description>
<pubDate>Tue, 08 Apr 2026 09:30:00 GMT</pubDate>
</item>
</channel>
</rss>

11
MosaicIQ/src/bun-test.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
declare module 'bun:test' {
interface BunExpect {
(value: unknown): any;
any(value: unknown): any;
}
export const describe: (label: string, run: () => void | Promise<void>) => void;
export const it: (label: string, run: () => void | Promise<void>) => void;
export const expect: BunExpect;
export const mock: <T extends (...args: any[]) => any>(fn: T) => T;
}

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { NewsPanel } from './NewsPanel';
import type { NewsArticle } from '../../types/news';
const sampleNews: NewsArticle[] = [
{
id: '1',
sourceId: 'source-1',
source: 'Source 1',
headline: 'Breaking macro item',
summary: 'Summary 1',
url: 'https://example.com/1',
publishedAt: '2026-04-08T11:00:00Z',
publishedTs: 400,
fetchedAt: '2026-04-08T11:01:00Z',
sentiment: 'BULL',
sentimentScore: 0.7,
highlightReason: 'macro_event',
tickers: ['AAPL'],
isRead: false,
isSaved: false,
},
{
id: '2',
sourceId: 'source-2',
source: 'Source 2',
headline: 'Recent market item',
summary: 'Summary 2',
publishedAt: '2026-04-08T10:00:00Z',
publishedTs: 300,
fetchedAt: '2026-04-08T10:01:00Z',
sentiment: 'NEUTRAL',
sentimentScore: 0,
tickers: ['MSFT'],
isRead: false,
isSaved: false,
},
];
describe('NewsPanel', () => {
it('renders highlights and recent sections for generic /news', () => {
const html = renderToStaticMarkup(<NewsPanel news={sampleNews} />);
expect(html).toContain('Market News');
expect(html).toContain('Highlights');
expect(html).toContain('Recent');
});
it('hides highlights when there are no highlighted articles', () => {
const html = renderToStaticMarkup(
<NewsPanel
news={sampleNews.map((article) => ({
...article,
highlightReason: undefined,
}))}
/>,
);
expect(html).not.toContain('Highlights');
expect(html).toContain('Recent');
});
it('renders ticker mode as a single chronological view', () => {
const html = renderToStaticMarkup(<NewsPanel news={sampleNews} ticker="NVDA" />);
expect(html).toContain('News: NVDA');
expect(html).not.toContain('>Highlights<');
expect(html).not.toContain('>Recent<');
});
it('includes the ticker symbol in the empty state for ticker mode', () => {
const html = renderToStaticMarkup(<NewsPanel news={[]} ticker="NVDA" />);
expect(html).toContain('No news articles found for NVDA.');
});
});

View File

@@ -1,98 +1,348 @@
import React from 'react';
import { NewsItem } from '../../types/financial';
import React, { useEffect, useState } from 'react';
import { openUrl } from '@tauri-apps/plugin-opener';
import { newsBridge } from '../../lib/newsBridge';
import {
buildNewsTickerCommand,
formatNewsRelativeTime,
highlightReasonLabel,
newsSentimentTone,
partitionNewsSummaryArticles,
sortNewsArticlesChronologically,
} from '../../news';
import type { NewsArticle } from '../../types/news';
import { SentimentBadge } from '../ui/SentimentBadge';
interface NewsPanelProps {
news: NewsItem[];
news: NewsArticle[];
ticker?: string;
onRunCommand?: (command: string) => void;
}
interface NewsPanelHeaderProps {
title: string;
count: number;
ticker?: string;
}
const formatTime = (date: Date) => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
interface NewsSectionProps {
title: string;
children: React.ReactNode;
}
if (hours < 1) {
const minutes = Math.floor(diff / (1000 * 60));
return `${minutes}m ago`;
}
if (hours < 24) {
return `${hours}h ago`;
}
const days = Math.floor(hours / 24);
return `${days}d ago`;
interface NewsArticleCardProps {
article: NewsArticle;
isTickerMode: boolean;
isPending: boolean;
activeTicker?: string;
onOpenArticle: (article: NewsArticle) => Promise<void>;
onMarkRead: (articleId: string) => Promise<void>;
onToggleSaved: (articleId: string, isSaved: boolean) => Promise<void>;
onRunCommand?: (command: string) => void;
}
const NewsPanelHeader: React.FC<NewsPanelHeaderProps> = ({ title, count, ticker }) => (
<header className="mb-5 flex items-center gap-3">
<h2 className="text-heading-lg text-[#e0e0e0]">{title}</h2>
{ticker ? (
<span className="rounded border border-[#273547] bg-[#121c2a] px-2 py-1 font-mono text-[11px] uppercase tracking-[0.2em] text-[#9cc9f6]">
{ticker}
</span>
) : null}
<span className="rounded border border-[#1f3448] bg-[#0d1723] px-2 py-1 font-mono text-[10px] text-[#58a6ff]">
{count}
</span>
</header>
);
const NewsSection: React.FC<NewsSectionProps> = ({ title, children }) => (
<section className="space-y-3">
<div className="flex items-center gap-3">
<h3 className="font-mono text-[11px] uppercase tracking-[0.22em] text-[#7c8da1]">
{title}
</h3>
<div className="h-px flex-1 bg-[#1c2837]" />
</div>
{children}
</section>
);
const NewsArticleCard: React.FC<NewsArticleCardProps> = ({
article,
isTickerMode,
isPending,
activeTicker,
onOpenArticle,
onMarkRead,
onToggleSaved,
onRunCommand,
}) => {
const summaryClassName = isTickerMode ? 'line-clamp-3' : 'line-clamp-2';
const visibleTickers = article.tickers.slice(0, isTickerMode ? article.tickers.length : 3);
const runTickerCommand = (articleTicker: string) => {
if (!onRunCommand) {
return;
}
if (activeTicker && articleTicker.toUpperCase() === activeTicker.toUpperCase()) {
return;
}
onRunCommand(buildNewsTickerCommand(articleTicker));
};
return (
<article
className={`rounded-2xl border px-4 py-4 transition-colors ${
article.isRead
? 'border-[#172230] bg-[#0b121a]'
: 'border-[#223044] bg-[#101926]'
}`}
>
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="rounded bg-[#58a6ff]/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.2em] text-[#7fc0ff]">
{article.source}
</span>
<span className="font-mono text-[10px] text-[#7c8da1]">
{formatNewsRelativeTime(article.publishedAt)}
</span>
<SentimentBadge sentiment={newsSentimentTone(article.sentiment)} size="sm" />
{article.highlightReason ? (
<span className="rounded border border-[#4e3b16] bg-[#2a1f0a] px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-[#f0c36a]">
{highlightReasonLabel(article.highlightReason)}
</span>
) : null}
</div>
{article.url ? (
<button
type="button"
className={`mb-2 block line-clamp-2 text-left text-body-sm font-semibold leading-snug transition-colors ${
article.isRead
? 'text-[#9fb0c2] hover:text-[#c8d7e5]'
: 'text-[#e7f1fb] hover:text-[#7fc0ff]'
}`}
onClick={() => void onOpenArticle(article)}
>
{article.headline}
</button>
) : (
<h4
className={`mb-2 line-clamp-2 text-body-sm font-semibold leading-snug ${
article.isRead ? 'text-[#9fb0c2]' : 'text-[#e7f1fb]'
}`}
>
{article.headline}
</h4>
)}
{article.summary ? (
<p className={`mb-3 text-body-xs leading-relaxed text-[#8c9aad] ${summaryClassName}`}>
{article.summary}
</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
{visibleTickers.map((articleTicker) => {
const isActiveTicker =
activeTicker &&
articleTicker.toUpperCase() === activeTicker.toUpperCase();
return onRunCommand ? (
<button
key={articleTicker}
type="button"
disabled={Boolean(isActiveTicker)}
className={`rounded px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] ${
isActiveTicker
? 'bg-[#213449] text-[#8bb8df]'
: 'bg-[#16202d] text-[#a8cfff] transition-colors hover:bg-[#203042] hover:text-white'
} disabled:cursor-default`}
onClick={() => runTickerCommand(articleTicker)}
>
{articleTicker}
</button>
) : (
<span
key={articleTicker}
className="rounded bg-[#16202d] px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-[#a8cfff]"
>
{articleTicker}
</span>
);
})}
<div className="ml-auto flex items-center gap-2">
<button
type="button"
className="rounded border border-[#223044] px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-[#b4c7da] transition-colors hover:border-[#3a5878] hover:text-white disabled:opacity-50"
disabled={isPending}
onClick={() => void onMarkRead(article.id)}
>
{article.isRead ? 'Read' : 'Mark Read'}
</button>
<button
type="button"
className={`rounded border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] transition-colors disabled:opacity-50 ${
article.isSaved
? 'border-[#355d3f] bg-[#11301a] text-[#89d39a]'
: 'border-[#223044] text-[#b4c7da] hover:border-[#3a5878] hover:text-white'
}`}
disabled={isPending}
onClick={() => void onToggleSaved(article.id, !article.isSaved)}
>
{article.isSaved ? 'Saved' : 'Save'}
</button>
{article.url ? (
<button
type="button"
className="rounded border border-[#223044] px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-[#58a6ff] transition-colors hover:border-[#58a6ff]/40 hover:text-[#8dc8ff]"
onClick={() => void onOpenArticle(article)}
>
Open
</button>
) : null}
</div>
</div>
</article>
);
};
export const NewsPanel: React.FC<NewsPanelProps> = ({ news, ticker }) => {
export const NewsPanel: React.FC<NewsPanelProps> = ({ news, ticker, onRunCommand }) => {
const [articles, setArticles] = useState(news);
const [pendingArticleIds, setPendingArticleIds] = useState<Record<string, boolean>>({});
const normalizedTicker = ticker?.trim().toUpperCase();
const isTickerMode = Boolean(normalizedTicker);
useEffect(() => {
setArticles(news);
}, [news]);
const sortedArticles = sortNewsArticlesChronologically(articles);
const { highlights, recent } = partitionNewsSummaryArticles(sortedArticles);
const setPending = (articleId: string, pending: boolean) => {
setPendingArticleIds((current) => ({
...current,
[articleId]: pending,
}));
};
const replaceArticle = (articleId: string, updater: (article: NewsArticle) => NewsArticle) => {
setArticles((current) =>
current.map((article) => (article.id === articleId ? updater(article) : article)),
);
};
const toggleSaved = async (articleId: string, isSaved: boolean) => {
const previousArticle = articles.find((article) => article.id === articleId);
if (!previousArticle) {
return;
}
setPending(articleId, true);
replaceArticle(articleId, (article) => ({ ...article, isSaved }));
try {
await newsBridge.updateNewsArticleState({ articleId, isSaved });
} catch {
replaceArticle(articleId, () => previousArticle);
} finally {
setPending(articleId, false);
}
};
const markRead = async (articleId: string) => {
const previousArticle = articles.find((article) => article.id === articleId);
if (!previousArticle || previousArticle.isRead) {
return;
}
setPending(articleId, true);
replaceArticle(articleId, (article) => ({ ...article, isRead: true }));
try {
await newsBridge.updateNewsArticleState({ articleId, isRead: true });
} catch {
replaceArticle(articleId, () => previousArticle);
} finally {
setPending(articleId, false);
}
};
const openArticle = async (article: NewsArticle) => {
if (!article.url) {
return;
}
await markRead(article.id);
await openUrl(article.url);
};
const renderArticleCard = (article: NewsArticle) => (
<NewsArticleCard
key={article.id}
article={article}
isTickerMode={isTickerMode}
isPending={pendingArticleIds[article.id] === true}
activeTicker={normalizedTicker}
onOpenArticle={openArticle}
onMarkRead={markRead}
onToggleSaved={toggleSaved}
onRunCommand={onRunCommand}
/>
);
if (sortedArticles.length === 0) {
return (
<div className="py-4">
<NewsPanelHeader
title={isTickerMode ? `News: ${normalizedTicker}` : 'Market News'}
count={0}
ticker={normalizedTicker}
/>
<div className="rounded-2xl border border-dashed border-[#223044] bg-[#0c131c] px-6 py-10 text-center">
<p className="font-mono text-sm text-[#7c8da1]">
{isTickerMode
? `No news articles found for ${normalizedTicker}.`
: 'No news articles found.'}
</p>
{!isTickerMode ? (
<p className="mt-2 font-mono text-[11px] uppercase tracking-[0.16em] text-[#5d7491]">
Try again after the next background refresh.
</p>
) : null}
</div>
</div>
);
}
if (isTickerMode) {
return (
<div className="py-4">
<NewsPanelHeader
title={`News: ${normalizedTicker}`}
count={sortedArticles.length}
ticker={normalizedTicker}
/>
<div className="space-y-4">{sortedArticles.map(renderArticleCard)}</div>
</div>
);
}
return (
<div className="news-panel py-4">
{/* Header - Inline with badges */}
<header className="flex items-center gap-3 mb-4">
<h2 className="text-heading-lg text-[#e0e0e0]">
{ticker ? `News: ${ticker.toUpperCase()}` : 'Market News'}
</h2>
{ticker && (
<span className="px-2 py-1 text-[11px] font-mono uppercase tracking-wider bg-[#1a1a1a] text-[#888888] border border-[#2a2a2a]">
{ticker}
</span>
)}
<span className="px-2 py-1 text-[10px] font-mono bg-[#1a1a1a] text-[#58a6ff] border border-[#1a1a1a] rounded">
{news.length}
</span>
</header>
{/* News List - Minimal dividers */}
{news.length > 0 ? (
<div className="news-list space-y-4">
{news.map((item, idx) => (
<article
key={item.id}
className="news-item group relative"
>
{idx > 0 && <div className="border-t border-[#1a1a1a] pt-4" />}
{/* Source & Time */}
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] font-mono uppercase tracking-wider text-[#58a6ff] bg-[#58a6ff]/10 px-2 py-0.5 rounded">
{item.source}
</span>
<span className="text-[10px] text-[#888888] font-mono">
{formatTime(item.timestamp)}
</span>
</div>
{/* Headline */}
<h4 className="text-body-sm font-semibold text-[#e0e0e0] mb-2 leading-snug group-hover:text-[#58a6ff] transition-colors cursor-pointer">
{item.headline}
</h4>
{/* Snippet */}
<p className="text-body-xs text-[#888888] mb-3 leading-relaxed line-clamp-2">
{item.snippet}
</p>
{/* Related Tickers */}
{item.relatedTickers.length > 0 && (
<div className="flex gap-1.5 flex-wrap">
{item.relatedTickers.map((ticker) => (
<span
key={ticker}
className="text-[10px] font-mono text-[#888888] bg-[#1a1a1a] px-2 py-0.5 rounded hover:text-[#e0e0e0] hover:bg-[#2a2a2a] transition-colors cursor-pointer"
>
{ticker}
</span>
))}
</div>
)}
</article>
))}
</div>
) : (
/* Empty State */
<div className="p-8 text-center">
<div className="text-3xl mb-2">📰</div>
<p className="text-[#888888] font-mono text-sm">No news articles found</p>
</div>
)}
<div className="py-4">
<NewsPanelHeader title="Market News" count={sortedArticles.length} />
<div className="space-y-6">
{highlights.length > 0 ? (
<NewsSection title="Highlights">
<div className="space-y-4">{highlights.map(renderArticleCard)}</div>
</NewsSection>
) : null}
{recent.length > 0 ? (
<NewsSection title="Recent">
<div className="space-y-3">{recent.map(renderArticleCard)}</div>
</NewsSection>
) : null}
</div>
</div>
);
};

View File

@@ -265,7 +265,13 @@ export const TerminalOutput: React.FC<TerminalOutputProps> = ({
/>
);
case 'news':
return <NewsPanel news={panelData.data} ticker={panelData.ticker} />;
return (
<NewsPanel
news={panelData.data}
ticker={panelData.ticker}
onRunCommand={onRunCommand}
/>
);
case 'analysis':
return <AnalysisPanel analysis={panelData.data} />;
case 'financials':
@@ -284,13 +290,13 @@ export const TerminalOutput: React.FC<TerminalOutputProps> = ({
return (
<div
ref={outputRef}
className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-4 min-h-0"
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-6 py-4"
style={{
scrollbarWidth: 'thin',
scrollbarColor: '#2a2a2a #111111'
}}
>
<div ref={contentRef} className="break-words">
<div ref={contentRef} className="min-w-0 break-words">
{history.map((entry) => (
<div
key={entry.id}

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'bun:test';
import { createNewsFeedState, newsFeedReducer } from './useNewsFeed';
import type { NewsArticle } from '../types/news';
const sampleArticle: NewsArticle = {
id: 'article-1',
sourceId: 'sample',
source: 'Sample Feed',
headline: 'NVDA beats expectations',
summary: 'Strong demand lifted guidance.',
url: 'https://example.com/nvda',
canonicalUrl: 'https://example.com/nvda',
publishedAt: '2026-04-08T10:00:00Z',
publishedTs: 1775642400,
fetchedAt: '2026-04-08T10:01:00Z',
sentiment: 'BULL',
sentimentScore: 0.66,
highlightReason: 'ticker_detected',
tickers: ['NVDA'],
isRead: false,
isSaved: false,
};
describe('newsFeedReducer', () => {
it('merges filter updates without dropping existing values', () => {
const state = createNewsFeedState({ ticker: 'NVDA', limit: 20 });
const nextState = newsFeedReducer(state, {
type: 'filters_merged',
filters: { onlyHighlighted: true },
});
expect(nextState.filters).toEqual({
ticker: 'NVDA',
limit: 20,
onlyHighlighted: true,
offset: 0,
});
});
it('stores query results and clears loading flags', () => {
const state = newsFeedReducer(createNewsFeedState(), {
type: 'load_started',
refresh: false,
});
const nextState = newsFeedReducer(state, {
type: 'load_succeeded',
articles: [sampleArticle],
total: 1,
lastSyncedAt: '2026-04-08T10:05:00Z',
sources: [],
});
expect(nextState.isLoading).toBe(false);
expect(nextState.total).toBe(1);
expect(nextState.articles[0]?.id).toBe('article-1');
});
it('applies optimistic article state updates', () => {
const state = newsFeedReducer(createNewsFeedState(), {
type: 'load_succeeded',
articles: [sampleArticle],
total: 1,
lastSyncedAt: '2026-04-08T10:05:00Z',
sources: [],
});
const nextState = newsFeedReducer(state, {
type: 'article_updated',
articleId: 'article-1',
patch: { isSaved: true, isRead: true },
});
expect(nextState.articles[0]?.isSaved).toBe(true);
expect(nextState.articles[0]?.isRead).toBe(true);
});
});

View File

@@ -0,0 +1,214 @@
import {
startTransition,
useDeferredValue,
useEffect,
useEffectEvent,
useReducer,
} from 'react';
import { newsBridge } from '../lib/newsBridge';
import type {
NewsArticle,
NewsSourceStatus,
QueryNewsFeedRequest,
RefreshNewsFeedResult,
} from '../types/news';
export interface NewsFeedState {
articles: NewsArticle[];
total: number;
lastSyncedAt?: string;
sources: NewsSourceStatus[];
filters: QueryNewsFeedRequest;
isLoading: boolean;
isRefreshing: boolean;
error?: string;
}
type NewsFeedAction =
| { type: 'load_started'; refresh: boolean }
| {
type: 'load_succeeded';
articles: NewsArticle[];
total: number;
lastSyncedAt?: string;
sources: NewsSourceStatus[];
}
| { type: 'load_failed'; error: string }
| { type: 'filters_merged'; filters: QueryNewsFeedRequest }
| { type: 'article_updated'; articleId: string; patch: Partial<NewsArticle> };
export const createNewsFeedState = (
filters: QueryNewsFeedRequest = {},
): NewsFeedState => ({
articles: [],
total: 0,
lastSyncedAt: undefined,
sources: [],
filters,
isLoading: true,
isRefreshing: false,
error: undefined,
});
export const newsFeedReducer = (
state: NewsFeedState,
action: NewsFeedAction,
): NewsFeedState => {
switch (action.type) {
case 'load_started':
return {
...state,
isLoading: !action.refresh,
isRefreshing: action.refresh,
error: undefined,
};
case 'load_succeeded':
return {
...state,
articles: action.articles,
total: action.total,
lastSyncedAt: action.lastSyncedAt,
sources: action.sources,
isLoading: false,
isRefreshing: false,
error: undefined,
};
case 'load_failed':
return {
...state,
isLoading: false,
isRefreshing: false,
error: action.error,
};
case 'filters_merged':
return {
...state,
filters: {
...state.filters,
...action.filters,
offset: action.filters.offset ?? state.filters.offset ?? 0,
},
};
case 'article_updated':
return {
...state,
articles: state.articles.map((article) =>
article.id === action.articleId ? { ...article, ...action.patch } : article,
),
};
default:
return state;
}
};
export const useNewsFeed = (initialFilters: QueryNewsFeedRequest = {}) => {
const [state, dispatch] = useReducer(
newsFeedReducer,
initialFilters,
createNewsFeedState,
);
const deferredSearch = useDeferredValue(state.filters.search);
const deferredFilters = {
...state.filters,
search: deferredSearch,
};
const loadFeed = useEffectEvent(async (refresh: boolean) => {
dispatch({ type: 'load_started', refresh });
try {
const response = await newsBridge.queryNewsFeed(deferredFilters);
dispatch({
type: 'load_succeeded',
articles: response.articles,
total: response.total,
lastSyncedAt: response.lastSyncedAt,
sources: response.sources,
});
} catch (error) {
dispatch({
type: 'load_failed',
error: error instanceof Error ? error.message : String(error),
});
}
});
useEffect(() => {
void loadFeed(false);
}, [
deferredFilters.limit,
deferredFilters.offset,
deferredFilters.onlyHighlighted,
deferredFilters.onlySaved,
deferredFilters.onlyUnread,
deferredFilters.search,
deferredFilters.ticker,
loadFeed,
]);
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | undefined;
void newsBridge.listenForUpdates(() => {
if (!disposed) {
void loadFeed(false);
}
}).then((listener) => {
unlisten = listener;
});
return () => {
disposed = true;
unlisten?.();
};
}, [loadFeed]);
const setFilters = (filters: QueryNewsFeedRequest) => {
startTransition(() => {
dispatch({ type: 'filters_merged', filters });
});
};
const refresh = async (force = false): Promise<RefreshNewsFeedResult> => {
dispatch({ type: 'load_started', refresh: true });
try {
const result = await newsBridge.refreshNewsFeed({ force });
await loadFeed(true);
return result;
} catch (error) {
dispatch({
type: 'load_failed',
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
};
const toggleSaved = async (articleId: string, isSaved: boolean) => {
dispatch({ type: 'article_updated', articleId, patch: { isSaved } });
try {
await newsBridge.updateNewsArticleState({ articleId, isSaved });
} catch (error) {
await loadFeed(false);
throw error;
}
};
const markRead = async (articleId: string, isRead = true) => {
dispatch({ type: 'article_updated', articleId, patch: { isRead } });
try {
await newsBridge.updateNewsArticleState({ articleId, isRead });
} catch (error) {
await loadFeed(false);
throw error;
}
};
return {
...state,
refresh,
setFilters,
toggleSaved,
markRead,
};
};

View File

@@ -4,7 +4,7 @@ import {
PortfolioAction,
PortfolioActionDraft,
PortfolioActionSeed,
ResolvedTerminalCommandResponse,
TerminalCommandResponse,
} from '../types/terminal';
export type PortfolioSnapshotStatus = 'idle' | 'loading' | 'ready' | 'error';
@@ -123,7 +123,7 @@ export const usePortfolioWorkflow = () => {
(
workspaceId: string,
command: string,
response: ResolvedTerminalCommandResponse,
response: TerminalCommandResponse,
) => {
const action = commandToPortfolioAction(command);
if (!action) {

View File

@@ -15,7 +15,7 @@ import {
PortfolioAction,
PortfolioActionDraft,
PortfolioActionSeed,
ResolvedTerminalCommandResponse,
TerminalCommandResponse,
} from '../types/terminal';
type AppView = 'terminal' | 'settings';
@@ -67,7 +67,7 @@ export const useTerminalOrchestrator = ({
(
workspaceId: string,
command: string | undefined,
response: ResolvedTerminalCommandResponse,
response: TerminalCommandResponse,
) => {
tabs.appendWorkspaceEntry(
workspaceId,
@@ -196,7 +196,7 @@ export const useTerminalOrchestrator = ({
const processStreamItem = (
event: Omit<AgentStreamItemEvent, 'response'> & {
response?: ResolvedTerminalCommandResponse;
response?: TerminalCommandResponse;
},
) => {
if (event.sequence <= lastSequenceSeen) {

View File

@@ -1,23 +1,4 @@
import {
ChatPanelContext,
PanelPayload,
TerminalEntry,
TransportPanelPayload,
} from '../types/terminal';
const toTransportPanelPayload = (panel: PanelPayload): TransportPanelPayload => {
if (panel.type !== 'news') {
return panel;
}
return {
...panel,
data: panel.data.map((item) => ({
...item,
timestamp: item.timestamp.toISOString(),
})),
};
};
import { ChatPanelContext, TerminalEntry } from '../types/terminal';
export const extractChatPanelContext = (
history: TerminalEntry[],
@@ -44,7 +25,7 @@ export const extractChatPanelContext = (
return {
sourceCommand,
capturedAt: entry.timestamp?.toISOString(),
panel: toTransportPanelPayload(entry.content),
panel: entry.content,
};
}

View File

@@ -0,0 +1,91 @@
import { describe, expect, it, mock } from 'bun:test';
import { createNewsBridge } from './newsBridge';
describe('createNewsBridge', () => {
it('passes request payloads to the expected Tauri commands', async () => {
const invoke = mock(async <T>(
command: string,
args?: Record<string, unknown>,
): Promise<T> => {
if (command === 'query_news_feed') {
return {
articles: [],
total: 0,
sources: [],
} as T;
}
if (command === 'refresh_news_feed') {
return {
feedsChecked: 1,
feedsSucceeded: 1,
feedsFailed: 0,
newArticles: 0,
updatedArticles: 0,
unchangedArticles: 0,
finishedAt: '2026-04-08T10:00:00Z',
} as T;
}
expect(command).toBe('update_news_article_state');
expect(args).toEqual({
request: {
articleId: 'article-1',
isSaved: true,
},
});
return undefined as T;
});
const bridge = createNewsBridge(invoke);
const query = await bridge.queryNewsFeed({ ticker: 'NVDA', limit: 20 });
const refresh = await bridge.refreshNewsFeed({ force: true });
await bridge.updateNewsArticleState({ articleId: 'article-1', isSaved: true });
expect(invoke).toHaveBeenNthCalledWith(1, 'query_news_feed', {
request: { ticker: 'NVDA', limit: 20 },
});
expect(invoke).toHaveBeenNthCalledWith(2, 'refresh_news_feed', {
request: { force: true },
});
expect(query.total).toBe(0);
expect(refresh.feedsChecked).toBe(1);
});
it('subscribes to news updates with the expected event name', async () => {
const listen = mock(
async <T>(
_event: string,
handler: (event: { payload: T }) => void,
): Promise<() => void> => {
handler({
payload: {
feedsChecked: 1,
feedsSucceeded: 1,
feedsFailed: 0,
newArticles: 2,
updatedArticles: 1,
unchangedArticles: 0,
finishedAt: '2026-04-08T10:00:00Z',
} as T,
});
return () => {};
},
);
const handler = mock(() => {});
const bridge = createNewsBridge(async () => undefined as never, listen);
await bridge.listenForUpdates(handler);
expect(listen).toHaveBeenCalledWith('news_feed_updated', expect.any(Function));
expect(handler).toHaveBeenCalledWith({
feedsChecked: 1,
feedsSucceeded: 1,
feedsFailed: 0,
newArticles: 2,
updatedArticles: 1,
unchangedArticles: 0,
finishedAt: '2026-04-08T10:00:00Z',
});
});
});

View File

@@ -0,0 +1,49 @@
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import type {
QueryNewsFeedRequest,
QueryNewsFeedResponse,
RefreshNewsFeedRequest,
RefreshNewsFeedResult,
UpdateNewsArticleStateRequest,
} from '../types/news';
type Invoker = <T>(command: string, args?: Record<string, unknown>) => Promise<T>;
type Listener = <T>(
event: string,
handler: (event: { payload: T }) => void,
) => Promise<UnlistenFn>;
export interface NewsBridge {
queryNewsFeed(request: QueryNewsFeedRequest): Promise<QueryNewsFeedResponse>;
refreshNewsFeed(request?: RefreshNewsFeedRequest): Promise<RefreshNewsFeedResult>;
updateNewsArticleState(request: UpdateNewsArticleStateRequest): Promise<void>;
listenForUpdates(
handler: (payload: RefreshNewsFeedResult) => void,
): Promise<UnlistenFn>;
}
export const createNewsBridge = (
invoker: Invoker = invoke,
listener: Listener = listen,
): NewsBridge => ({
queryNewsFeed(request) {
return invoker<QueryNewsFeedResponse>('query_news_feed', { request });
},
refreshNewsFeed(request = {}) {
return invoker<RefreshNewsFeedResult>('refresh_news_feed', { request });
},
updateNewsArticleState(request) {
return invoker<void>('update_news_article_state', { request });
},
listenForUpdates(handler) {
return listener<RefreshNewsFeedResult>('news_feed_updated', (event) =>
handler(event.payload),
);
},
});
export const newsBridge = createNewsBridge();

View File

@@ -1,24 +1,20 @@
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { NewsItem } from '../types/financial';
import {
AgentStreamItemEvent,
ChatStreamStart,
LookupCompanyRequest,
ExecuteTerminalCommandRequest,
PanelPayload,
ResolveAgentToolApprovalRequest,
ResolvedTerminalCommandResponse,
StartChatStreamRequest,
TerminalCommandResponse,
TransportPanelPayload,
} from '../types/terminal';
import { Company } from '../types/financial';
interface StreamCallbacks {
workspaceId: string;
onStreamItem: (event: Omit<AgentStreamItemEvent, 'response'> & {
response?: ResolvedTerminalCommandResponse;
response?: TerminalCommandResponse;
}) => void;
}
@@ -30,36 +26,6 @@ const createRequestId = (): string => {
return `request-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
};
const deserializePanelPayload = (payload: TransportPanelPayload): PanelPayload => {
if (payload.type !== 'news') {
return payload;
}
// News timestamps cross the Tauri boundary as strings and are rehydrated here for panel rendering.
return {
...payload,
data: payload.data.map(
(item): NewsItem => ({
...item,
timestamp: new Date(item.timestamp),
}),
),
};
};
const deserializeTerminalCommandResponse = (
response: TerminalCommandResponse,
): ResolvedTerminalCommandResponse => {
if (response.kind === 'text') {
return response;
}
return {
kind: 'panel',
panel: deserializePanelPayload(response.panel),
};
};
class TerminalBridge {
private listenersReady: Promise<void> | null = null;
private unlistenFns: UnlistenFn[] = [];
@@ -78,9 +44,7 @@ class TerminalBridge {
}
callbacks.onStreamItem({
...event.payload,
response: event.payload.response
? deserializeTerminalCommandResponse(event.payload.response)
: undefined,
response: event.payload.response,
});
if (
event.payload.kind === 'stream_complete' ||
@@ -98,16 +62,10 @@ class TerminalBridge {
async executeTerminalCommand(
request: ExecuteTerminalCommandRequest,
): Promise<ResolvedTerminalCommandResponse> {
const response = await invoke<TerminalCommandResponse>('execute_terminal_command', {
): Promise<TerminalCommandResponse> {
return invoke<TerminalCommandResponse>('execute_terminal_command', {
request,
});
if (response.kind === 'text') {
return response;
}
return deserializeTerminalCommandResponse(response);
}
async lookupCompany(request: LookupCompanyRequest): Promise<Company> {

View File

@@ -1,6 +1,6 @@
import { Company } from '../types/financial';
import {
ResolvedTerminalCommandResponse,
TerminalCommandResponse,
TickerHistorySnapshot,
} from '../types/terminal';
@@ -9,7 +9,7 @@ const TICKER_REQUIRED_COMMANDS = new Set(['/fa', '/cf', '/dvd', '/em', '/analyze
const FREQUENCY_OPTION_COMMANDS = new Set(['/fa', '/cf', '/em']);
export const extractTickerSymbolFromResponse = (
response: ResolvedTerminalCommandResponse,
response: TerminalCommandResponse,
): string | null => {
if (response.kind !== 'panel') {
return null;

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'bun:test';
import {
buildNewsTickerCommand,
formatNewsRelativeTime,
highlightReasonLabel,
newsSentimentLabel,
newsSentimentTone,
partitionNewsSummaryArticles,
sortNewsArticlesChronologically,
} from './index';
import type { NewsArticle } from '../types/news';
const sampleArticles: NewsArticle[] = [
{
id: '3',
sourceId: 'source-1',
source: 'Source 1',
headline: 'Recent neutral item',
summary: 'Summary 3',
publishedAt: '2026-04-08T10:00:00Z',
publishedTs: 300,
fetchedAt: '2026-04-08T10:01:00Z',
sentiment: 'NEUTRAL',
sentimentScore: 0,
tickers: ['SPY'],
isRead: false,
isSaved: false,
},
{
id: '2',
sourceId: 'source-2',
source: 'Source 2',
headline: 'Older highlighted item',
summary: 'Summary 2',
publishedAt: '2026-04-08T09:00:00Z',
publishedTs: 200,
fetchedAt: '2026-04-08T09:01:00Z',
sentiment: 'BULL',
sentimentScore: 0.7,
highlightReason: 'macro_event',
tickers: ['AAPL'],
isRead: false,
isSaved: false,
},
{
id: '1',
sourceId: 'source-3',
source: 'Source 3',
headline: 'Newest highlighted item',
summary: 'Summary 1',
publishedAt: '2026-04-08T11:00:00Z',
publishedTs: 400,
fetchedAt: '2026-04-08T11:01:00Z',
sentiment: 'BEAR',
sentimentScore: -0.7,
highlightReason: 'strong_sentiment',
tickers: ['NVDA'],
isRead: false,
isSaved: false,
},
];
describe('news formatting helpers', () => {
it('formats recent and older relative times', () => {
expect(
formatNewsRelativeTime('2026-04-08T09:45:00Z', new Date('2026-04-08T10:00:00Z')),
).toBe('15m ago');
expect(
formatNewsRelativeTime('2026-04-07T10:00:00Z', new Date('2026-04-08T10:00:00Z')),
).toBe('1d ago');
});
it('maps sentiment values to terminal badge tone and label', () => {
expect(newsSentimentTone('BULL')).toBe('bullish');
expect(newsSentimentTone('BEAR')).toBe('bearish');
expect(newsSentimentLabel('NEUTRAL')).toBe('Neutral');
});
it('maps highlight reasons to concise labels', () => {
expect(highlightReasonLabel('macro_event')).toBe('Macro');
expect(highlightReasonLabel('recent_high_value')).toBe('Fresh');
});
it('sorts articles in reverse chronological order', () => {
expect(sortNewsArticlesChronologically(sampleArticles).map((article) => article.id)).toEqual([
'1',
'3',
'2',
]);
});
it('partitions summary articles without duplicating highlighted items', () => {
const { highlights, recent } = partitionNewsSummaryArticles(sampleArticles);
expect(highlights.map((article) => article.id)).toEqual(['1', '2']);
expect(recent.map((article) => article.id)).toEqual(['3']);
});
it('builds ticker commands from chip values', () => {
expect(buildNewsTickerCommand(' nvda ')).toBe('/news NVDA');
});
});

114
MosaicIQ/src/news/index.ts Normal file
View File

@@ -0,0 +1,114 @@
export { useNewsFeed, newsFeedReducer, createNewsFeedState } from '../hooks/useNewsFeed';
export type {
HighlightReason,
NewsArticle,
NewsSentiment,
NewsSourceStatus,
QueryNewsFeedRequest,
QueryNewsFeedResponse,
RefreshNewsFeedRequest,
RefreshNewsFeedResult,
UpdateNewsArticleStateRequest,
} from '../types/news';
import type {
HighlightReason,
NewsArticle,
NewsSentiment,
} from '../types/news';
const compareNewsArticles = (left: NewsArticle, right: NewsArticle) => {
if (right.publishedTs !== left.publishedTs) {
return right.publishedTs - left.publishedTs;
}
if (left.highlightReason && !right.highlightReason) {
return -1;
}
if (!left.highlightReason && right.highlightReason) {
return 1;
}
return left.id.localeCompare(right.id);
};
export const sortNewsArticlesChronologically = (articles: NewsArticle[]) =>
[...articles].sort(compareNewsArticles);
export const partitionNewsSummaryArticles = (
articles: NewsArticle[],
): { highlights: NewsArticle[]; recent: NewsArticle[] } => {
const sortedArticles = sortNewsArticlesChronologically(articles);
const highlights = sortedArticles
.filter((article) => article.highlightReason)
.slice(0, 4);
const highlightIds = new Set(highlights.map((article) => article.id));
const recent = sortedArticles
.filter((article) => !highlightIds.has(article.id))
.slice(0, 6);
return { highlights, recent };
};
export const formatNewsRelativeTime = (publishedAt: string, now = new Date()) => {
const publishedDate = new Date(publishedAt);
const diffMs = Math.max(0, now.getTime() - publishedDate.getTime());
const minutes = Math.floor(diffMs / (1000 * 60));
if (minutes < 1) {
return 'just now';
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
const days = Math.floor(hours / 24);
return `${days}d ago`;
};
export const newsSentimentLabel = (sentiment: NewsSentiment) => {
switch (sentiment) {
case 'BULL':
return 'Bullish';
case 'BEAR':
return 'Bearish';
default:
return 'Neutral';
}
};
export const highlightReasonLabel = (reason?: HighlightReason) => {
switch (reason) {
case 'breaking_keyword':
return 'Breaking';
case 'macro_event':
return 'Macro';
case 'strong_sentiment':
return 'High Conviction';
case 'ticker_detected':
return 'Ticker';
case 'recent_high_value':
return 'Fresh';
default:
return 'Standard';
}
};
export const newsSentimentTone = (sentiment: NewsSentiment) => {
switch (sentiment) {
case 'BULL':
return 'bullish';
case 'BEAR':
return 'bearish';
default:
return 'neutral';
}
};
export const buildNewsTickerCommand = (ticker: string) => `/news ${ticker.trim().toUpperCase()}`;

View File

@@ -73,16 +73,6 @@ export interface Portfolio {
stalePricingSymbols?: string[];
}
export interface NewsItem {
id: string;
source: string;
headline: string;
timestamp: Date;
snippet: string;
url?: string;
relatedTickers: string[];
}
export interface StockAnalysis {
symbol: string;
summary: string;
@@ -94,14 +84,9 @@ export interface StockAnalysis {
targetPrice?: number;
}
export interface SerializedNewsItem extends Omit<NewsItem, 'timestamp'> {
timestamp: string;
}
export interface MockFinancialData {
companies: Company[];
portfolio: Portfolio;
newsItems: SerializedNewsItem[];
analyses: Record<string, StockAnalysis>;
}

View File

@@ -0,0 +1,75 @@
export type NewsSentiment = 'BULL' | 'BEAR' | 'NEUTRAL';
export type HighlightReason =
| 'breaking_keyword'
| 'macro_event'
| 'strong_sentiment'
| 'ticker_detected'
| 'recent_high_value';
export interface NewsArticle {
id: string;
sourceId: string;
source: string;
headline: string;
summary: string;
url?: string;
canonicalUrl?: string;
publishedAt: string;
publishedTs: number;
fetchedAt: string;
sentiment: NewsSentiment;
sentimentScore: number;
highlightReason?: HighlightReason;
tickers: string[];
isRead: boolean;
isSaved: boolean;
}
export interface QueryNewsFeedRequest {
ticker?: string;
search?: string;
onlyHighlighted?: boolean;
onlySaved?: boolean;
onlyUnread?: boolean;
limit?: number;
offset?: number;
}
export interface NewsSourceStatus {
id: string;
name: string;
url: string;
refreshMinutes: number;
lastCheckedAt?: string;
lastSuccessAt?: string;
lastError?: string;
failureCount: number;
}
export interface QueryNewsFeedResponse {
articles: NewsArticle[];
total: number;
lastSyncedAt?: string;
sources: NewsSourceStatus[];
}
export interface RefreshNewsFeedRequest {
force?: boolean;
}
export interface RefreshNewsFeedResult {
feedsChecked: number;
feedsSucceeded: number;
feedsFailed: number;
newArticles: number;
updatedArticles: number;
unchangedArticles: number;
finishedAt: string;
}
export interface UpdateNewsArticleStateRequest {
articleId: string;
isRead?: boolean;
isSaved?: boolean;
}

View File

@@ -4,29 +4,17 @@ import {
DividendsPanelData,
EarningsPanelData,
FinancialsPanelData,
NewsItem,
Portfolio,
SerializedNewsItem,
StockAnalysis,
} from './financial';
import { TaskProfile } from './agentSettings';
import { NewsArticle } from './news';
export type PanelPayload =
| { type: 'company'; data: Company }
| { type: 'error'; data: ErrorPanel }
| { type: 'portfolio'; data: Portfolio }
| { type: 'news'; data: NewsItem[]; ticker?: string }
| { type: 'analysis'; data: StockAnalysis }
| { type: 'financials'; data: FinancialsPanelData }
| { type: 'cashFlow'; data: CashFlowPanelData }
| { type: 'dividends'; data: DividendsPanelData }
| { type: 'earnings'; data: EarningsPanelData };
export type TransportPanelPayload =
| { type: 'company'; data: Company }
| { type: 'error'; data: ErrorPanel }
| { type: 'portfolio'; data: Portfolio }
| { type: 'news'; data: SerializedNewsItem[]; ticker?: string }
| { type: 'news'; data: NewsArticle[]; ticker?: string }
| { type: 'analysis'; data: StockAnalysis }
| { type: 'financials'; data: FinancialsPanelData }
| { type: 'cashFlow'; data: CashFlowPanelData }
@@ -34,17 +22,13 @@ export type TransportPanelPayload =
| { type: 'earnings'; data: EarningsPanelData };
export type TerminalCommandResponse =
| { kind: 'text'; content: string; portfolio?: Portfolio }
| { kind: 'panel'; panel: TransportPanelPayload };
export type ResolvedTerminalCommandResponse =
| { kind: 'text'; content: string; portfolio?: Portfolio }
| { kind: 'panel'; panel: PanelPayload };
export interface ChatPanelContext {
sourceCommand?: string;
capturedAt?: string;
panel: TransportPanelPayload;
panel: PanelPayload;
}
export interface ExecuteTerminalCommandRequest {