Complete spec manual — 6 phases, same sources, better output
Article row carries state SMALLINT (IntEnum 0-4, -1 failed). Five stages hand off via asyncio.Queue (instant). DB poll is fallback for crash recovery. Worker pool per stage with backlog cap so queue tasks always have capacity.
| Phase | Scope | Spec |
|---|---|---|
| 1 | DB layer: asyncpg + state column | 01-DB-LAYER.md |
| 2 | LLM layer: thin client + routing | 02-LLM-LAYER.md |
| 3 | Pipeline: staged workers | 03-PIPELINE.md |
| 4 | Heterogeneous data: articles vs metrics | 05-HETEROGENEOUS.md |
| 5 | Agent access: market analyst API | 06-AGENT-ACCESS.md |
| 6 | Steering layer: agent-mutable config | 07-STEERING.md |
Phases 1-3 are independently deployable. Phase 4 builds on all three. Phase 5 builds on all four. Phase 6 builds on Phase 5. Phase 1 migration reserves schema for Phases 4 and 6.
asyncpg pool. articles.py, notify.py, lists.py. Plain functions, no classes.
claude-serve -> codex-serve -> litellm. Thin HTTP client + routing.
keywords, symbols, relevance, novelty. All model logic untouched.
| Phase | New | Modified |
|---|---|---|
| 1 | lib/news/db/{articles,notify,lists}.py | main.py (swap engine creation) |
| 2 | lib/news/llm.py | llms.py (routing), config_.py (endpoints) |
| 3 | workers.py (stage loops) | main.py (replace on_article + lock) |
asyncpg.Pool, no classes for DBasyncpg replacing SQLAlchemy · 01-DB-LAYER.md
lib/article/databases.py defines five classes wrapping SQLAlchemy Core:
| Class | Engine | Tables |
|---|---|---|
| ArticleDatabase | AsyncEngine | articles, notifications |
| DocumentDatabase | AsyncEngine | documents |
| NotificationDatabase | AsyncEngine | notifications |
| ListsDatabase | sync Engine | lists, config |
| ProjectsDatabase | GoogleSheetsClient | (sheets) |
SQLAlchemy Core used purely as query builder — no ORM, no relationships, no migrations.
Plain async functions in lib/src/lib/news/db/, accepting asyncpg.pool.Pool as first argument. No classes.
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
url TEXT UNIQUE NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT now(),
state SMALLINT NOT NULL DEFAULT 0,
kind SMALLINT NOT NULL DEFAULT 0, -- reserved phase 4
eval SMALLINT NOT NULL DEFAULT 0, -- reserved phase 4
data JSONB
);
CREATE INDEX idx_articles_state
ON articles (state, created_at);
| Column | Values |
|---|---|
state | 0=INGESTED, 1=EMBEDDED, 2=ENRICHED, 3=SCORED, 4=DONE, -1=FAILED |
kind | 0=page, 1=tweet, 2=message, 3=price (Phase 4) |
eval | 0=first, increments on re-eval (Phase 4) |
In-flight claim: state = -(stage + 10). E.g. embedding = -11, enriching = -12, scoring = -13.
article_id (unique FK), ctime, mtime, message_id, message, reactions (JSONB)
list (name), word, mtime. Word lists synced from Google Sheets.
key (PK), value (JSONB), mtime. Key-value store.
# lib/news/db/articles.py
from asyncpg import Pool
async def insert(pool: Pool, url: str, data: dict) -> int:
async with pool.acquire() as conn:
return await conn.fetchval(
"""INSERT INTO articles (url, created_at, data)
VALUES ($1, now(), $2)
RETURNING id""",
url, data,
)
No ORM, no query builder, no class wrapping. Parameterized queries via positional $1, $2 placeholders (asyncpg native).
All functions take pool: asyncpg.Pool as first argument.
| Module | Functions |
|---|---|
| articles.py | exists, select, select_url, select_all, select_like, select_keyword, insert, insert_url, update, pick_batch, update_state |
| notify.py | exists, select, select_reactions, insert, update_reactions |
| lists.py | select_list, select_lists, insert, delete, select_value, set_value |
lib/news/db/ with asyncpg functionspool = await asyncpg.create_pool(dsn)db.articles.insert(pool, ...)lib/article/databases.py untouched (used by server/)server/ keeps SQLAlchemy, news-collector/ uses asyncpg. Same Postgres tables, different access patterns. No conflicts — collector writes, server reads.
thin client + routing · 02-LLM-LAYER.md
news-collector/llms.py (195 lines): ChatClient wrapping LiteLLM Router with LeakyBucket rate limiting, multi-key load balancing, 3 retries. Supports Gemini, OpenRouter, OpenAI, Anthropic — all via LiteLLM.
[endpoints]
claude_url = "http://localhost:8080/v1/chat/completions"
codex_url = "http://localhost:8081/v1/chat/completions"
Both optional. If neither is set, behavior is identical to today (LiteLLM only).
| Model | Purpose | Client |
|---|---|---|
| ArticleRelevanceModel | Rate article against statements | small_model |
| ArticleTranslationModel | Translate non-English articles | reasoning_model |
| ArticleQuestionnaireModel | Score statement relevance | small_model |
| QuestionAnsweringModel | Answer queries with context | reasoning_model |
All use ChatClient.query(messages=[...]). No tool-use, no streaming. Simple request/response.
Global LeakyBucket stays. Self-hosted endpoints can have separate buckets:
[endpoints]
claude_bucket_burst = 10.0
claude_bucket_rate = 1e9 # 1 req/sec = 60 RPM
create_chat_client() factory functionModelConfiguration dataclassstaged workers · 03-PIPELINE.md
Current on_article() holds a global asyncio.Lock for the entire chain — DB insert, Weaviate insert, all model evals, DB update, selector, notification. One article blocks all 30+ sources. The LLM call alone takes seconds.
Article row carries a state SMALLINT column. Processing split into stages. Each stage has a loop that picks articles at state = stage - 1, does work, advances to state = stage.
class State(IntEnum):
INGESTED = 0 # row inserted, deduped
EMBEDDED = 1 # Weaviate vectors inserted
ENRICHED = 2 # keywords, symbols, novelty, context
SCORED = 3 # LLM relevance + translation
DONE = 4 # selectors run, notified
FAILED = -1 # terminal, logged
All stages run the same loop:
queue.get() with poll_timeout (e.g. 5s)state = stage - 1, process (capped at max_backlog concurrency).Workers are asyncio.create_task() behind two semaphores: sem(max_workers) for all work, backlog_sem(max_backlog) nested for DB-sourced work only.
async def claim_one(pool, article_id, pick_state):
"""Queue fast path. Tx + row lock."""
claim = -(pick_state + 11)
async with pool.acquire() as conn:
async with conn.transaction():
return await conn.fetchrow(
"""UPDATE articles SET state = $3
WHERE id = $1 AND state = $2
RETURNING id, data""",
article_id, int(pick_state), claim,
)
async def pick_batch(pool, pick_state, limit):
"""DB recovery path. Tx + SKIP LOCKED."""
claim = -(pick_state + 11)
async with pool.acquire() as conn:
async with conn.transaction():
return await conn.fetch(
"""UPDATE articles SET state = $3
WHERE id IN (
SELECT id FROM articles
WHERE state = $1
ORDER BY created_at LIMIT $2
FOR UPDATE SKIP LOCKED
)
RETURNING id, data""",
int(pick_state), limit, claim,
)
[pipeline.ingest]
max_workers = 4
max_backlog = 2
poll_timeout = 5.0
[pipeline.embed]
max_workers = 4
max_backlog = 2
poll_timeout = 5.0
[pipeline.enrich]
max_workers = 10
max_backlog = 5
poll_timeout = 5.0
[pipeline.score]
max_workers = 4
max_backlog = 2
poll_timeout = 5.0
[pipeline.notify]
max_workers = 10
max_backlog = 5
poll_timeout = 5.0
embed_q = asyncio.Queue()
enrich_q = asyncio.Queue()
score_q = asyncio.Queue()
notify_q = asyncio.Queue()
await asyncio.gather(
ingest_loop(pool, sources, embed_q, ingest_cfg),
embed_loop(pool, embed_q, enrich_q, embed_cfg),
enrich_loop(pool, enrich_q, score_q, enrich_cfg),
score_loop(pool, score_q, notify_q, score_cfg),
notify_loop(pool, notify_q, None, notify_cfg),
)
| Current | After |
|---|---|
asyncio.Lock on all processing | Worker pools per stage |
model.eval() does everything | Split across 5 stages |
| Crash = lost mid-processing | Restart picks up from DB |
| LLM blocks source polling | Sources keep ingesting |
Failed processing -> state = -1 (FAILED). A recovery loop resets stale in-flight states (negative, older than N minutes) back to positive for retry. Permanently failed rows stay at -1.
articles vs metrics · 05-HETEROGENEOUS.md
The pipeline treats everything as isolated Article rows. But data has two shapes:
Each new item is a new evaluation. Context from linked graph. No re-evaluation. Each new tweet is its own pipeline run, with richer context from the graph.
Time-series data attached to articles. Context providers. Metric changes trigger re-evaluation of the parent article.
| Type | Examples | Pipeline Role |
|---|---|---|
| article | news page, tweet, telegram msg | subject: evaluated |
| metric | price, impressions, volume | context: enriches evaluation |
select_context() models.py:45-67 follows article.links types_.py:146 by DB lookup. Threads already linked by URL chains. No explicit thread_id needed.
CREATE TABLE metrics (
article_id INTEGER NOT NULL REFERENCES articles(id),
mtime TIMESTAMP NOT NULL DEFAULT now(),
price DOUBLE PRECISION,
volume DOUBLE PRECISION,
market_cap DOUBLE PRECISION,
impressions DOUBLE PRECISION,
likes DOUBLE PRECISION,
retweets DOUBLE PRECISION,
replies DOUBLE PRECISION,
PRIMARY KEY (article_id, mtime)
);
CREATE INDEX idx_metrics_article_time
ON metrics (article_id, mtime DESC);
price, volume, market_cap populated. impressions = null.
impressions, likes, retweets populated. price = null.
Context assembly happens in ENRICH, evaluation in SCORE.
Text context from linked graph (select_context). Metric context from metrics table for the article and all linked articles. Stored in data.context.
LLM prompt includes text + metric context. Compact time series alongside article text. The LLM sees the complete picture: what happened and what moved.
Metric context generators format 5min interval snapshots + latest point + delta:
@dataclass
class Metric:
article_id: int
price: float | None = None
volume: float | None = None
impressions: float | None = None
# ... etc
| Source | Metrics |
|---|---|
| Hyperliquid WS | price, volume, position |
| Polymarket WS | price, volume |
| Tweet poller | impressions, likes, retweets |
Significance thresholds configurable per metric type. Max K evals per article prevents infinite loops.
CREATE TABLE article_history (
id SERIAL PRIMARY KEY,
article_id INTEGER NOT NULL REFERENCES articles(id),
eval SMALLINT NOT NULL,
data JSONB NOT NULL,
state SMALLINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_article_history_article
ON article_history (article_id, eval);
On re-eval: snapshot current row into article_history, update article with new data + eval incremented. Articles table always has the latest version.
| Phase | Impact |
|---|---|
| 1 DB | Reserve kind, eval columns + metrics + article_history tables in migration. Defaulted, won't break existing code. |
| 2 LLM | No impact. LLM layer is prompt-agnostic. |
| 3 Pipeline | Worker loop supports re-queuing. Notify checks eval for edit-vs-new. Metrics poller = new source of work. |
market analyst API · 06-AGENT-ACCESS.md
The collector is a complete market intelligence engine. It just has no API.
RSS, JSON APIs, WebSockets, Selenium scrapers, Telegram channels, Discourse forums, Hyperliquid, Polymarket. All running concurrently.
Weaviate stores article embeddings. query_similar(text) returns articles ranked by semantic similarity.
select_context() walks URL chains to build full thread/conversation context. Includes metrics from Phase 4.
Articles scored against statements. QuestionAnsweringModel already takes queries, finds context, answers with citations.
| Endpoint | What It Does | Reuses |
|---|---|---|
/feed |
Stream of articles with enrichment data (keywords, symbols, scores, notification status). Filters by source, keyword, symbol, section, min_score. WS variant for real-time push. | db/articles.py queries |
/search |
Find articles semantically similar to a text query. Returns ranked results with similarity scores + full enrichment. | ArticleVectors.query_similar() |
/context |
Given an article or query, assemble full context: linked articles from URL graph + recent metrics for all articles in context. | select_context() + metrics table |
/ask |
Ask a question, get an answer grounded in the knowledge base with citations. Vector search + keyword matching + LLM. | QuestionAnsweringModel |
/metrics |
Time-series data for an article or symbol: price, volume, market_cap, impressions, likes, retweets. | metrics table (Phase 4) |
For AI agents using tool-use (MCP protocol), each endpoint maps to a tool definition:
Get recent articles with enrichment. Params: since, keyword, symbol, limit.
Semantic search across all articles. Params: query, days, limit.
Full context: linked graph + metrics. Params: article_id or query.
Q&A from knowledge base. Params: question.
Price/engagement time-series. Params: article_id, symbol, since.
New module: news-collector/api.py. Endpoints added to the existing aiohttp web server (port 49199). Handlers pull pool, vectors, models from app state.
| Component | Status |
|---|---|
| Article DB queries | exists (db/articles.py) |
| Vector similarity | exists (ArticleVectors.query_similar) |
| Link graph context | exists (select_context) |
| Keyword extraction | exists (ArticleKeywordsModel) |
| LLM Q&A | exists (QuestionAnsweringModel) |
| Metrics table | Phase 4 (db/metrics.py) |
| HTTP endpoints | new (api.py) |
| MCP tool wrappers | new (mcp.py) |
agent-mutable config · 07-STEERING.md
Phase 5 gives the agent read-only access. But configuration is split across three places: Google Sheets (word lists, projects), TOML (static thresholds), and DB config table (LLM prompts). The agent can observe but not steer.
DB becomes single source of truth for all runtime-mutable config. MCP tools for CRUD. Every mutation audit-logged.
Replaces Google Sheets for project data. First-class columns for ArticleSymbolsModel fields (symbol, name, twitter, telegram, discord). Everything else in meta JSONB.
DB lists table is already the runtime store. Invert: DB becomes master, Sheets becomes optional on-demand importer. No schema change.
Every steering operation logged: actor, action, target, detail. Enables get_audit_log for reviewing recent changes.
CREATE TABLE projects (
id SERIAL PRIMARY KEY,
symbol TEXT NOT NULL,
name TEXT NOT NULL,
twitter TEXT,
telegram TEXT,
discord TEXT,
website TEXT,
meta JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
UNIQUE (symbol, name)
);
CREATE INDEX idx_projects_symbol ON projects (symbol);
CREATE TABLE audit_log (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT now(),
actor TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
detail JSONB
);
CREATE INDEX idx_audit_log_time
ON audit_log (created_at DESC);
Steering tools added to same mcp.py as Phase 5 read-only tools. Four groups:
| Group | Tools |
|---|---|
| Lists | list_lists, get_list, add_to_list, remove_from_list |
| Projects | list_projects, get_project, add_project, update_project, remove_project |
| Config | list_config, get_config, set_config |
| Observability | get_audit_log, get_pipeline_status, get_source_status |
Plus optional import_from_sheets for one-time migration from Sheets (additive only, never deletes from DB).
| Before | After | |
|---|---|---|
| Authority | Sheets is master | DB is master |
| Sync | every 120s, overwrites DB | on-demand via MCP tool |
| Direction | Sheets → DB (forced) | Sheets → DB (additive) |
| Startup | google_poller.run() in gather | removed from gather |
| Phase | Impact |
|---|---|
| 1 DB | Reserve projects + audit_log tables in migration. Same pattern as metrics/article_history. |
| 3 Pipeline | No changes. Lists/projects read via existing TTL cache. Updates propagate on next refresh (120s). |
| 5 Agent | Steering tools added to same mcp.py. Same aiohttp server, same port. |
| File | Change |
|---|---|
lib/news/db/projects.py | new: project CRUD functions |
lib/news/db/audit.py | new: audit log functions |
news-collector/mcp.py | add steering tool definitions |
news-collector/main.py | remove google_poller.run() from gather |
news-collector/models.py | ArticleSymbolsModel reads from projects table |
04-OPEN-QUESTIONS.md
Current: sync SQLAlchemy with TTLCache(ttl=120). New async lists.py needs a cache strategy.
Leaning: module-level cache dict in lists.py with inline TTL. Simple, no class needed.
PolymarketTraderFinder and PositionTracker have own DB operations. Move to lib/news/db?
Leaning: leave as-is. Polymarket sources own their tables. Only migrate if they share core tables.
lib/vectors.py provides ArticleVectors and WordVectors. Move to lib/news?
Leaning: leave untouched. Shared across projects, stable, not part of DB migration.
Sheets -> DB sync via GoogleSheetsUpdater. Drop sheets dependency?
Resolved in Phase 6: DB becomes master. Sheets becomes optional on-demand importer via import_from_sheets MCP tool. See 07-STEERING.md.
After revamp, two DB layers exist: lib/article/ (SQLAlchemy, server/) and lib/news/db/ (asyncpg, collector).
Leaning: keep both. server/ is stable, rarely changes. Revisit when server/ needs work.
Flat [endpoints] section vs per-model endpoint vs named routing config.
Leaning: flat [endpoints] section. Add per-model later only if needed.
| File | Lines | What |
|---|---|---|
| models.py | 45-67 | select_context() follows link graph |
| types_.py | 146 | Article.links = List[str] (URL graph) |
| types_.py | 152 | Article.section (coin/market grouping) |
| tweetfeed.py | 183-188 | links via article.links (URL chains) |
| tweetfeed.py | 385-403 | visits thread page, parses all tweets |
| models.py | 817-864 | LLM gate filters noise |
| hyperliquid.py | 93-139 | fill -> Article with section=coin |
| polymarket.py | 57- | trade -> Article with market title |
| llms.py | all | ChatClient + LiteLLM Router |
| main.py | 165 | serialized ingest comment (NV matching) |