← back

News Collector Revamp

Complete spec manual — 6 phases, same sources, better output

TL;DR
Replace monolithic internals with three clean layers: asyncpg DB, LLM routing, and a staged pipeline. Add heterogeneous data (articles vs metrics with full context in the pipeline). Expose everything as an agent API. Then add an agent steering layer — making config, lists, and projects agent-mutable via MCP tools.

Architecture Overview

30+ sources (RSS, API, WS, Telegram, Twitter) | | article = await source.run(session) v +-- INGEST (4 workers, 2 backlog) ---------+ | dedup check (url) | | INSERT article (state=0) | | -> embed_queue | +------------------------------------------+ | | embed_queue (or DB poll state=0) v +-- EMBED (4 workers, 2 backlog) ----------+ | claim state=-11 | | generate embedding (Weaviate vectorizer)| | INSERT into Weaviate | | UPDATE state=1 | | -> enrich_queue | +------------------------------------------+ | | enrich_queue (or DB poll state=1) v +-- ENRICH (10 workers, 5 backlog) --------+ | claim state=-12 | | keywords, symbols, querywords, novelty | | context: | | text (resolve article link chains) | | metrics (prices/engagement for | | article + linked graph) | | UPDATE data + state=2 | | -> score_queue | +------------------------------------------+ | | score_queue (or DB poll state=2) v +-- SCORE (4 workers, 2 backlog) ----------+ | claim state=-13 | | if needs_llm: | | relevance (LLM statement scoring | | with text+metric context) | | translation (LLM, if non-english) | | UPDATE data + state=3 | | -> notify_queue | +------------------------------------------+ | | notify_queue (or DB poll state=3) v +-- NOTIFY (10 workers, 5 backlog) --------+ | claim state=-14 | | ArticleSelector / AlertSelector | | if selected: format + telegram.notify | | UPDATE state=4 | +------------------------------------------+

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.

Execution Order

PhaseScopeSpec
1DB layer: asyncpg + state column01-DB-LAYER.md
2LLM layer: thin client + routing02-LLM-LAYER.md
3Pipeline: staged workers03-PIPELINE.md
4Heterogeneous data: articles vs metrics05-HETEROGENEOUS.md
5Agent access: market analyst API06-AGENT-ACCESS.md
6Steering layer: agent-mutable config07-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.

Supporting Layers

lib/news/db/

asyncpg pool. articles.py, notify.py, lists.py. Plain functions, no classes.

lib/news/llm.py

claude-serve -> codex-serve -> litellm. Thin HTTP client + routing.

models.py

keywords, symbols, relevance, novelty. All model logic untouched.

Files Changed Per Phase

PhaseNewModified
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)

Key Constraints

Phase 1 — DB Layer

asyncpg replacing SQLAlchemy · 01-DB-LAYER.md

Current State

lib/article/databases.py defines five classes wrapping SQLAlchemy Core:

ClassEngineTables
ArticleDatabaseAsyncEnginearticles, notifications
DocumentDatabaseAsyncEnginedocuments
NotificationDatabaseAsyncEnginenotifications
ListsDatabasesync Enginelists, config
ProjectsDatabaseGoogleSheetsClient(sheets)

SQLAlchemy Core used purely as query builder — no ORM, no relationships, no migrations.

Target State

Plain async functions in lib/src/lib/news/db/, accepting asyncpg.pool.Pool as first argument. No classes.

lib/src/lib/news/db/ __init__.py # re-export create_pool() articles.py # article CRUD notify.py # notification CRUD lists.py # lists + config key-value

Articles Schema

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);
ColumnValues
state0=INGESTED, 1=EMBEDDED, 2=ENRICHED, 3=SCORED, 4=DONE, -1=FAILED
kind0=page, 1=tweet, 2=message, 3=price (Phase 4)
eval0=first, increments on re-eval (Phase 4)

In-flight claim: state = -(stage + 10). E.g. embedding = -11, enriching = -12, scoring = -13.

Other Tables

notifications

article_id (unique FK), ctime, mtime, message_id, message, reactions (JSONB)

lists

list (name), word, mtime. Word lists synced from Google Sheets.

config

key (PK), value (JSONB), mtime. Key-value store.

Pattern: Direct asyncpg

# 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).

Function Signatures

All functions take pool: asyncpg.Pool as first argument.

ModuleFunctions
articles.pyexists, select, select_url, select_all, select_like, select_keyword, insert, insert_url, update, pick_batch, update_state
notify.pyexists, select, select_reactions, insert, update_reactions
lists.pyselect_list, select_lists, insert, delete, select_value, set_value

Migration Path

  1. Create lib/news/db/ with asyncpg functions
  2. Create pool in main.py: pool = await asyncpg.create_pool(dsn)
  3. Replace ArticleDatabase calls with db.articles.insert(pool, ...)
  4. Replace ListsDatabase (sync) with async equivalents + TTL cache
  5. Remove SQLAlchemy engine creation from main
  6. Keep lib/article/databases.py untouched (used by server/)
Coexistence. server/ keeps SQLAlchemy, news-collector/ uses asyncpg. Same Postgres tables, different access patterns. No conflicts — collector writes, server reads.

Phase 2 — LLM Layer

thin client + routing · 02-LLM-LAYER.md

Current State

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.

What Changes

  1. Add claude-serve endpoint (self-hosted Claude via HTTP)
  2. Add codex-serve endpoint (self-hosted Codex via HTTP)
  3. Routing: claude-serve (preferred) -> codex-serve -> LiteLLM (fallback)
  4. Keep LiteLLM as base for 100+ cloud providers

Routing

query() | v claude-serve configured? ---yes--> POST /v1/chat/completions | | | no success? ---yes--> return v | codex-serve configured? ---yes--> POST /v1/chat/completions | | | no success? ---yes--> return v | LiteLLM Router v (always available) fall through | v return

Config

[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 Uses in Pipeline

ModelPurposeClient
ArticleRelevanceModelRate article against statementssmall_model
ArticleTranslationModelTranslate non-English articlesreasoning_model
ArticleQuestionnaireModelScore statement relevancesmall_model
QuestionAnsweringModelAnswer queries with contextreasoning_model

All use ChatClient.query(messages=[...]). No tool-use, no streaming. Simple request/response.

Rate Limiting

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

What Stays

Phase 3 — Pipeline

staged workers · 03-PIPELINE.md

Problem

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.

Design

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

Stage Loop Pattern

All stages run the same loop:

  1. Try queue.get() with poll_timeout (e.g. 5s)
  2. If got ID: claim row, process, advance state, push to next queue. Drain queue non-blocking for more.
  3. If timeout: poll DB for unclaimed rows at state = stage - 1, process (capped at max_backlog concurrency).
  4. Loop.

Workers are asyncio.create_task() behind two semaphores: sem(max_workers) for all work, backlog_sem(max_backlog) nested for DB-sourced work only.

DB Primitives

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,
            )

Config

[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

Wiring in main.py

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),
)

What Changes

CurrentAfter
asyncio.Lock on all processingWorker pools per stage
model.eval() does everythingSplit across 5 stages
Crash = lost mid-processingRestart picks up from DB
LLM blocks source pollingSources keep ingesting
Embedding ordering. Weaviate INSERT must happen before the next article's novelty QUERY. The EMBED stage enforces this: it serializes Weaviate writes so each embedding is visible before the next novelty query in ENRICH.

Error Handling

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.

Phase 4 — Heterogeneous Data

articles vs metrics · 05-HETEROGENEOUS.md

The Problem

The pipeline treats everything as isolated Article rows. But data has two shapes:

article Pages, tweets, messages

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.

metric Prices, impressions, engagement

Time-series data attached to articles. Context providers. Metric changes trigger re-evaluation of the parent article.

TypeExamplesPipeline Role
articlenews page, tweet, telegram msgsubject: evaluated
metricprice, impressions, volumecontext: enriches evaluation

Two Flows

SOURCES | +-------------+-------------+ | | new article metric change | | v v +-------------------+ +----------------------+ | FULL PIPELINE RUN | | THRESHOLD + TIMEOUT | | context = linked | | check | | graph (parents, | | | | quotes, lateral) | | crossed? ----no----> done +-------------------+ +--------|-------------+ | | yes v v ENRICH -> SCORE -> NOTIFY snapshot article_history eval++ , reset state | v re-queue into pipeline | v SCORE -> NOTIFY (updated metric context)

Context via Linked Graph

tweet A (thread start) | +--url--> tweet B (reply, links to A) | | | +--url--> tweet C (reply, links to B) | +--url--> tweet D (quote tweet of A) | +--url--> article E (external link in D) select_context() walks these URL chains by DB lookup. The link graph IS the thread structure. No thread_id column needed.

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.

Noise filtering built in. The keyword/author/source gate models.py:817-864 prevents LLM calls on non-matching items.

Metrics Table

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);
Ticker article

price, volume, market_cap populated. impressions = null.

Tweet article

impressions, likes, retweets populated. price = null.

Metrics as Pipeline Context

Context assembly happens in ENRICH, evaluation in SCORE.

ENRICH gathers context

Text context from linked graph (select_context). Metric context from metrics table for the article and all linked articles. Stored in data.context.

SCORE evaluates with 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:

BTC/USD (hyperliquid): price $67,234 (+2.3% 1h) vol $1.2B, mcap $1.3T 5m: 67100 67150 67200 67180 67234 ETH/USD (hyperliquid): price $3,456 (+0.8% 1h) vol $890M, mcap $415B tweet @elonmusk/1234: 45.2K impressions (+12K 1h) likes 2.1K, retweets 891, replies 342

Metrics Ingest

@dataclass
class Metric:
    article_id: int
    price: float | None = None
    volume: float | None = None
    impressions: float | None = None
    # ... etc
SourceMetrics
Hyperliquid WSprice, volume, position
Polymarket WSprice, volume
Tweet pollerimpressions, likes, retweets

Re-evaluation Flow

metric arrives | v INSERT into metrics table | v threshold crossed? ----no----> done | yes v min timeout elapsed? ----no----> done | yes v max K evals reached? ----yes----> done (budget) | no v snapshot article -> article_history eval++, reset state, push to queue | v SCORE -> NOTIFY (edit existing msg or send new)

Significance thresholds configurable per metric type. Max K evals per article prevents infinite loops.

Hyperliquid / Polymarket Changes

Before: each fill/trade creates a separate Article row.
After: they become metric-only sources on ticker articles.

article_history Table

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.

Full Schema Diagram

+---------------------------+ +---------------------------+ | articles | | metrics | +---------------------------+ +---------------------------+ | id SERIAL PK |<--+ | article_id INTEGER FK |---+ | url TEXT UNIQUE | | | mtime TIMESTAMP | | | created_at TIMESTAMP | | | price DOUBLE | | | updated_at TIMESTAMP | | | volume DOUBLE | | | state SMALLINT | | | market_cap DOUBLE | | | kind SMALLINT | | | impressions DOUBLE | | | eval SMALLINT | | | likes DOUBLE | | | data JSONB | | | retweets DOUBLE | | +---------------------------+ | | replies DOUBLE | | | | +---------------------------+ | | | PK(article_id, mtime) | | +-----------------------------------+ | 1:N v +---------------------------+ | article_history | +---------------------------+ | id SERIAL PK | | article_id INTEGER FK | | eval SMALLINT | | data JSONB | | state SMALLINT | | created_at TIMESTAMP | +---------------------------+ IDX(article_id, eval)

Impact on Earlier Phases

PhaseImpact
1 DBReserve kind, eval columns + metrics + article_history tables in migration. Defaulted, won't break existing code.
2 LLMNo impact. LLM layer is prompt-agnostic.
3 PipelineWorker loop supports re-queuing. Notify checks eval for edit-vs-new. Metrics poller = new source of work.

Phase 5 — Agent Access

market analyst API · 06-AGENT-ACCESS.md

What Already Exists

The collector is a complete market intelligence engine. It just has no API.

30+ sources

RSS, JSON APIs, WebSockets, Selenium scrapers, Telegram channels, Discourse forums, Hyperliquid, Polymarket. All running concurrently.

Vector search

Weaviate stores article embeddings. query_similar(text) returns articles ranked by semantic similarity.

Link graph context

select_context() walks URL chains to build full thread/conversation context. Includes metrics from Phase 4.

LLM scoring + Q&A

Articles scored against statements. QuestionAnsweringModel already takes queries, finds context, answers with citations.

Five Endpoints

Agent (MCP / HTTP) | +--> GET /feed recent articles, filtered by keyword/symbol/score | +--> GET /search semantic similarity search (Weaviate) | +--> GET /context full context assembly: linked graph + metrics | +--> POST /ask Q&A grounded in the knowledge base | +--> GET /metrics price/engagement time-series for article/symbol
EndpointWhat It DoesReuses
/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)

MCP Tools

For AI agents using tool-use (MCP protocol), each endpoint maps to a tool definition:

news_feed

Get recent articles with enrichment. Params: since, keyword, symbol, limit.

news_search

Semantic search across all articles. Params: query, days, limit.

news_context

Full context: linked graph + metrics. Params: article_id or query.

news_ask

Q&A from knowledge base. Params: question.

news_metrics

Price/engagement time-series. Params: article_id, symbol, since.

Implementation

New module: news-collector/api.py. Endpoints added to the existing aiohttp web server (port 49199). Handlers pull pool, vectors, models from app state.

ComponentStatus
Article DB queriesexists (db/articles.py)
Vector similarityexists (ArticleVectors.query_similar)
Link graph contextexists (select_context)
Keyword extractionexists (ArticleKeywordsModel)
LLM Q&Aexists (QuestionAnsweringModel)
Metrics tablePhase 4 (db/metrics.py)
HTTP endpointsnew (api.py)
MCP tool wrappersnew (mcp.py)
What this enables. An AI agent with these tools becomes a market analyst that can: monitor 30+ crypto news sources in real-time, search any topic across the full article history, get complete context for any event (linked articles + price movements + engagement), ask questions answered from grounded cited sources, and track metrics over time. The collector does the hard work. The agent does the reasoning.

Phase 6 — Steering Layer

agent-mutable config · 07-STEERING.md

Problem

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.

Design

DB becomes single source of truth for all runtime-mutable config. MCP tools for CRUD. Every mutation audit-logged.

Projects Table (new)

Replaces Google Sheets for project data. First-class columns for ArticleSymbolsModel fields (symbol, name, twitter, telegram, discord). Everything else in meta JSONB.

Lists: Invert Authority

DB lists table is already the runtime store. Invert: DB becomes master, Sheets becomes optional on-demand importer. No schema change.

Audit Log (new)

Every steering operation logged: actor, action, target, detail. Enables get_audit_log for reviewing recent changes.

Projects Schema

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);

Audit Log Schema

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);

MCP Tools

Steering tools added to same mcp.py as Phase 5 read-only tools. Four groups:

GroupTools
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).

What the Agent Can Steer

Watch new token: add_project("PEPE", "Pepe") + add_to_list("KEY_WORDS", "PEPE") Adjust LLM behavior: set_config("RELEVANCE_TASK", new_prompt) Add alert trigger: add_to_list("ALERT_WORDS", "SEC filing") Block noisy source: add_to_list("BLOCK_SOURCES", "spamsite.com") Track new trader: add_to_list("HYPERLIQUID_TRADERS", "0xabc...") Review changes: get_audit_log(limit=20)

GoogleSheetsUpdater Role Change

BeforeAfter
AuthoritySheets is masterDB is master
Syncevery 120s, overwrites DBon-demand via MCP tool
DirectionSheets → DB (forced)Sheets → DB (additive)
Startupgoogle_poller.run() in gatherremoved from gather

Impact on Earlier Phases

PhaseImpact
1 DBReserve projects + audit_log tables in migration. Same pattern as metrics/article_history.
3 PipelineNo changes. Lists/projects read via existing TTL cache. Updates propagate on next refresh (120s).
5 AgentSteering tools added to same mcp.py. Same aiohttp server, same port.

Implementation

FileChange
lib/news/db/projects.pynew: project CRUD functions
lib/news/db/audit.pynew: audit log functions
news-collector/mcp.pyadd steering tool definitions
news-collector/main.pyremove google_poller.run() from gather
news-collector/models.pyArticleSymbolsModel reads from projects table

Open Questions

04-OPEN-QUESTIONS.md

1. ListsDatabase Caching

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.

2. Polymarket Tables

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.

3. Weaviate Vector Storage

lib/vectors.py provides ArticleVectors and WordVectors. Move to lib/news?

Leaning: leave untouched. Shared across projects, stable, not part of DB migration.

4. Google Sheets Word Lists

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.

5. lib/article/ Coexistence

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.

6. Config Format for LLM Endpoints

Flat [endpoints] section vs per-model endpoint vs named routing config.

Leaning: flat [endpoints] section. Add per-model later only if needed.

Existing Code References

FileLinesWhat
models.py45-67select_context() follows link graph
types_.py146Article.links = List[str] (URL graph)
types_.py152Article.section (coin/market grouping)
tweetfeed.py183-188links via article.links (URL chains)
tweetfeed.py385-403visits thread page, parses all tweets
models.py817-864LLM gate filters noise
hyperliquid.py93-139fill -> Article with section=coin
polymarket.py57-trade -> Article with market title
llms.pyallChatClient + LiteLLM Router
main.py165serialized ingest comment (NV matching)