Deploy Awesome-WAM long-context Q&A app
Browse files- .gitattributes +1 -0
- README.md +9 -14
- config/config.yaml +136 -0
- config/profile.md +45 -0
- data/wam.db +3 -0
- requirements.txt +10 -3
- src/awesome_wam.egg-info/PKG-INFO +24 -0
- src/awesome_wam.egg-info/SOURCES.txt +15 -0
- src/awesome_wam.egg-info/dependency_links.txt +1 -0
- src/awesome_wam.egg-info/requires.txt +18 -0
- src/awesome_wam.egg-info/top_level.txt +1 -0
- src/wam/__init__.py +3 -0
- src/wam/config.py +86 -0
- src/wam/llm/__init__.py +3 -0
- src/wam/llm/client.py +191 -0
- src/wam/logging.py +93 -0
- src/wam/models.py +38 -0
- src/wam/notify/__init__.py +1 -0
- src/wam/notify/mailer.py +318 -0
- src/wam/pipeline/__init__.py +1 -0
- src/wam/pipeline/analyze.py +45 -0
- src/wam/pipeline/extract.py +64 -0
- src/wam/pipeline/fetch.py +51 -0
- src/wam/pipeline/filter.py +57 -0
- src/wam/pipeline/innovation.py +43 -0
- src/wam/pipeline/links.py +84 -0
- src/wam/pipeline/people.py +118 -0
- src/wam/pipeline/schemas.py +100 -0
- src/wam/pipeline/score.py +71 -0
- src/wam/pipeline/summarize.py +39 -0
- src/wam/pipeline/trends.py +108 -0
- src/wam/render/__init__.py +1 -0
- src/wam/render/readme.py +259 -0
- src/wam/sources/__init__.py +1 -0
- src/wam/sources/arxiv.py +83 -0
- src/wam/sources/news.py +58 -0
- src/wam/sources/papers_with_code.py +58 -0
- src/wam/sources/pdf.py +53 -0
- src/wam/sources/semantic_scholar.py +142 -0
- src/wam/store/__init__.py +3 -0
- src/wam/store/benchmarks.py +70 -0
- src/wam/store/db.py +69 -0
- src/wam/store/papers.py +121 -0
- src/wam/store/people.py +50 -0
- src/wam/store/schema.sql +127 -0
- src/wam/webapp/__init__.py +1 -0
- src/wam/webapp/app.py +42 -0
- src/wam/webapp/qa.py +103 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
data/wam.db filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,20 +1,15 @@
|
|
| 1 |
---
|
| 2 |
title: Awesome Embodied MM
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
tags:
|
| 9 |
-
- streamlit
|
| 10 |
pinned: false
|
| 11 |
-
short_description:
|
| 12 |
-
license: apache-2.0
|
| 13 |
---
|
| 14 |
|
| 15 |
-
#
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
|
| 20 |
-
forums](https://discuss.streamlit.io).
|
|
|
|
| 1 |
---
|
| 2 |
title: Awesome Embodied MM
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
app_file: src/wam/webapp/app.py
|
|
|
|
|
|
|
| 8 |
pinned: false
|
| 9 |
+
short_description: Q&A over a curated World Action Models knowledge base
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Awesome-WAM Knowledge Base
|
| 13 |
|
| 14 |
+
Ask questions about World Action Models (world models, VLA, video-generation, robot
|
| 15 |
+
foundation models) — answers are grounded in a curated corpus with citations.
|
|
|
|
|
|
config/config.yaml
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Awesome-WAM system configuration.
|
| 2 |
+
# Everything domain/tuning-related lives here so behavior changes need no code edits.
|
| 3 |
+
|
| 4 |
+
provider:
|
| 5 |
+
# OpenRouter is OpenAI-compatible. Key read from this env var.
|
| 6 |
+
base_url: "https://openrouter.ai/api/v1"
|
| 7 |
+
api_key_env: "OPENROUTER_API_KEY"
|
| 8 |
+
# Sent as OpenRouter attribution headers (optional but recommended).
|
| 9 |
+
http_referer: "https://github.com/your-org/Awesome-WAM"
|
| 10 |
+
app_title: "Awesome-WAM"
|
| 11 |
+
|
| 12 |
+
models:
|
| 13 |
+
# Tiered routing. Cheap = filtering/first-pass (high volume); mid = analysis; strong =
|
| 14 |
+
# scoring. Chosen via a blind Opus-4.8 ranking of anonymized outputs on real papers (see
|
| 15 |
+
# scripts/bench_models.py). Latency is NOT a selection criterion for these batch stages
|
| 16 |
+
# (only interactive Q&A cares about latency, and its model is deferred):
|
| 17 |
+
# - cheap: deepseek-v4-flash — #1 on the scoring task (best skeptical N/A calibration,
|
| 18 |
+
# no fabrication) AND cheapest by ~50x; ideal for the high-volume relevance filter.
|
| 19 |
+
# - mid/strong: glm-5.1 — #1 overall in the blind ranking, low cost; its only weakness
|
| 20 |
+
# (27s latency) is irrelevant for batch work.
|
| 21 |
+
# Excluded: deepseek-v4-pro (abdicated scoring -> all-N/A), glm-4.7-flash (4/6 parse
|
| 22 |
+
# failures), gemini-3.1-flash-lite (fabricated scores).
|
| 23 |
+
tiers:
|
| 24 |
+
cheap: "deepseek/deepseek-v4-flash"
|
| 25 |
+
mid: "z-ai/glm-5.1"
|
| 26 |
+
strong: "z-ai/glm-5.1"
|
| 27 |
+
xstrong: "openai/gpt-5.5" # top tier for the most quality-critical stage (scoring)
|
| 28 |
+
# Optional per-stage overrides (stage name -> tier). Falls back to the tier passed in code.
|
| 29 |
+
stage_tiers:
|
| 30 |
+
filter: cheap
|
| 31 |
+
summarize: cheap
|
| 32 |
+
analyze: mid
|
| 33 |
+
extract: cheap # deepseek-v4-flash: fast + JSON-reliable + faithful for table extraction
|
| 34 |
+
score: xstrong # flagship two-layer scoring -> gpt-5.5
|
| 35 |
+
innovation: mid
|
| 36 |
+
front_summary: strong
|
| 37 |
+
qa: strong # TODO: pick a low-latency Q&A model in Phase 7 (deferred)
|
| 38 |
+
defaults:
|
| 39 |
+
temperature: 0.2
|
| 40 |
+
max_tokens: 4096
|
| 41 |
+
# USD per 1,000,000 tokens, {input, output}. Used for cost tracking/logging.
|
| 42 |
+
cost_per_million:
|
| 43 |
+
"deepseek/deepseek-v4-flash": {input: 0.098, output: 0.197}
|
| 44 |
+
"z-ai/glm-5.1": {input: 0.98, output: 3.08}
|
| 45 |
+
"openai/gpt-5.5": {input: 5.00, output: 30.00}
|
| 46 |
+
# alternates considered: gemini-3.5-flash {1.50, 9.00}, claude-sonnet-4.6 {3.00, 15.00}
|
| 47 |
+
|
| 48 |
+
constants:
|
| 49 |
+
request_timeout: 90 # seconds per LLM call
|
| 50 |
+
max_retries: 4
|
| 51 |
+
retry_backoff: 3.0 # seconds, exponential base
|
| 52 |
+
lookback_days: 60 # ignore papers older than this on fetch
|
| 53 |
+
relevance_threshold: 0.5 # below this -> dropped
|
| 54 |
+
analyze_cap: 40 # max papers fully analyzed per run (cost guard)
|
| 55 |
+
|
| 56 |
+
logging:
|
| 57 |
+
level: "INFO" # console level; debug file sink enabled via --debug/WAM_DEBUG
|
| 58 |
+
log_dir: "logs"
|
| 59 |
+
|
| 60 |
+
email:
|
| 61 |
+
feature_threshold: 6.0 # core papers with weighted_total >= this get a detailed card
|
| 62 |
+
max_featured: 8 # cap on featured (detailed) papers
|
| 63 |
+
max_grouped: 40 # cap on the grouped lower tier
|
| 64 |
+
group_lower_by_direction: true # group the lower tier by research direction (else flat list)
|
| 65 |
+
|
| 66 |
+
paths:
|
| 67 |
+
db: "data/wam.db"
|
| 68 |
+
pdf_cache: "cache/pdfs"
|
| 69 |
+
index_dir: "data/index"
|
| 70 |
+
|
| 71 |
+
# Two-layer scoring weights. Top-4 WAM metrics weighted 2x; computed over non-N/A metrics.
|
| 72 |
+
scoring:
|
| 73 |
+
general_weights:
|
| 74 |
+
novelty: 1.0
|
| 75 |
+
soundness: 1.0
|
| 76 |
+
impact: 1.0
|
| 77 |
+
wam_weights:
|
| 78 |
+
generalist: 2.0
|
| 79 |
+
inference_speed: 2.0
|
| 80 |
+
specialist: 2.0
|
| 81 |
+
inference_cost: 2.0
|
| 82 |
+
trustworthiness: 1.0
|
| 83 |
+
collaborative: 1.0
|
| 84 |
+
controlled_generation: 1.0
|
| 85 |
+
other: 1.0
|
| 86 |
+
|
| 87 |
+
# Source + search config is consumed in Phase 2 (fetch).
|
| 88 |
+
sources:
|
| 89 |
+
arxiv:
|
| 90 |
+
enabled: true
|
| 91 |
+
categories: ["cs.RO", "cs.AI", "cs.LG", "cs.CV"]
|
| 92 |
+
max_results: 300
|
| 93 |
+
semantic_scholar:
|
| 94 |
+
enabled: true
|
| 95 |
+
api_key_env: "SEMANTIC_SCHOLAR_API_KEY" # optional; higher rate limits if set
|
| 96 |
+
papers_with_code:
|
| 97 |
+
enabled: true
|
| 98 |
+
news:
|
| 99 |
+
enabled: true
|
| 100 |
+
feeds:
|
| 101 |
+
- "https://www.therobotreport.com/feed/"
|
| 102 |
+
- "https://spectrum.ieee.org/feeds/topic/robotics.rss"
|
| 103 |
+
- "https://huggingface.co/blog/feed.xml"
|
| 104 |
+
|
| 105 |
+
# Search keyword sets. The LLM filter makes the final core/adjacent/drop call,
|
| 106 |
+
# but these seed the source queries (broad net by design).
|
| 107 |
+
keywords:
|
| 108 |
+
core: # World Action Models proper
|
| 109 |
+
- "world action model"
|
| 110 |
+
- "action world model"
|
| 111 |
+
- "embodied foundation model"
|
| 112 |
+
- "robot foundation model"
|
| 113 |
+
- "generalist robot policy"
|
| 114 |
+
adjacent: # transferable techniques — kept in the innovation track
|
| 115 |
+
- "vision-language-action"
|
| 116 |
+
- "world model"
|
| 117 |
+
- "video generation"
|
| 118 |
+
- "action-conditioned video"
|
| 119 |
+
- "interactive world model"
|
| 120 |
+
|
| 121 |
+
qa:
|
| 122 |
+
# Knowledge-base Q&A is long-context (no embeddings/vector DB): a compact pack of
|
| 123 |
+
# paper summaries + scores + leaderboard + authors + trends is built from the DB and
|
| 124 |
+
# passed to the qa-tier model. See stage_tiers.qa for the model.
|
| 125 |
+
max_papers: 400 # cap papers in the pack (most-scored first) to bound context
|
| 126 |
+
include_dropped: true # include filtered-out papers (title-only) so the KB still covers them
|
| 127 |
+
|
| 128 |
+
trends:
|
| 129 |
+
sim_threshold: 0.62 # cosine edge threshold for the direction graph
|
| 130 |
+
min_front_size: 3 # ignore clusters smaller than this
|
| 131 |
+
window_days: 30 # momentum = recent window vs prior window
|
| 132 |
+
|
| 133 |
+
people:
|
| 134 |
+
min_papers: 2 # influential if on >= N tracked papers
|
| 135 |
+
min_citations: 2000 # (reserved) citation-based influence via Semantic Scholar
|
| 136 |
+
max_per_run: 30 # cap influential authors processed per run (most prolific first)
|
config/profile.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# WAM Research Interest Profile
|
| 2 |
+
|
| 3 |
+
This profile is read verbatim by the relevance-filter and scoring prompts. It defines
|
| 4 |
+
what "World Action Models" means for this project and what we care about. Edit freely —
|
| 5 |
+
no code changes needed.
|
| 6 |
+
|
| 7 |
+
## What counts as a World Action Model (WAM)
|
| 8 |
+
|
| 9 |
+
A WAM is a learned model that maps perception + goals to **actions** in the physical or
|
| 10 |
+
simulated world, often grounded in a learned **world model** (predictive dynamics). The
|
| 11 |
+
umbrella includes:
|
| 12 |
+
|
| 13 |
+
- Generalist robot/agent policies and embodied foundation models
|
| 14 |
+
- Vision-Language-Action (VLA) models that output actions
|
| 15 |
+
- World models / action-conditioned video & dynamics models used for planning or control
|
| 16 |
+
- Models that unify perception, prediction, and action for embodied agents
|
| 17 |
+
|
| 18 |
+
## Two relevance tracks
|
| 19 |
+
|
| 20 |
+
- **core** — the paper *is* a WAM (or directly proposes/evaluates one). Gets the full
|
| 21 |
+
two-layer rubric and benchmark extraction.
|
| 22 |
+
- **adjacent** — VLA / world model / video-generation work that is *not* a WAM itself but
|
| 23 |
+
carries a technique plausibly **transferable to WAM**. Gets an innovation note only
|
| 24 |
+
(key idea + why it could transfer); no rubric scores.
|
| 25 |
+
- **drop** — unrelated to the above.
|
| 26 |
+
|
| 27 |
+
## What we care most about (drives WAM-specific scoring)
|
| 28 |
+
|
| 29 |
+
Top-4 (weighted 2x):
|
| 30 |
+
1. **inference_speed** — real-time or faster control; latency/throughput on stated hardware.
|
| 31 |
+
2. **generalist** — generalizes across tasks, benchmarks, and especially *embodiments*.
|
| 32 |
+
3. **specialist** — extreme accuracy/robustness on a specific task or task set.
|
| 33 |
+
4. **inference_cost** — compute/$ to run; small models that punch above their weight.
|
| 34 |
+
|
| 35 |
+
Also tracked (weighted 1x): trustworthiness/safety, collaborative (multi-robot control),
|
| 36 |
+
controlled/steerable generation of videos or actions, and other features (e.g. async
|
| 37 |
+
inference, streaming, on-device).
|
| 38 |
+
|
| 39 |
+
## Scoring guidance
|
| 40 |
+
|
| 41 |
+
- Score each metric 0–10 from evidence in the paper; use **"N/A"** when the paper does not
|
| 42 |
+
address a metric — do not guess or penalize with a 0.
|
| 43 |
+
- Be **skeptical of self-reported numbers**. Note the dataset a model was trained/finetuned
|
| 44 |
+
on; the same model name on a different dataset is a different system.
|
| 45 |
+
- Reward reproducibility (released code/data/weights) under `impact`.
|
data/wam.db
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f7e93e5fdd9460355f30b196fe25fbf8bcf4b0f2fc6e40354e4ad6a797175324
|
| 3 |
+
size 1957888
|
requirements.txt
CHANGED
|
@@ -1,3 +1,10 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Runtime deps for the Hugging Face Space (Streamlit Q&A web app).
|
| 2 |
+
# The daily pipeline uses pyproject (pip install -e .); this file is for the Space.
|
| 3 |
+
openai>=1.40.0
|
| 4 |
+
pydantic>=2.7.0
|
| 5 |
+
pyyaml>=6.0
|
| 6 |
+
python-dotenv>=1.0.0
|
| 7 |
+
requests>=2.31.0
|
| 8 |
+
networkx>=3.2
|
| 9 |
+
streamlit>=1.36.0
|
| 10 |
+
# No embeddings/vector DB: Q&A is long-context over a knowledge pack built from data/wam.db.
|
src/awesome_wam.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: awesome-wam
|
| 3 |
+
Version: 0.1.0
|
| 4 |
+
Summary: Daily intelligence system for World Action Models (WAM) research: search, summarize, analyze, score, digest, and a RAG knowledge base.
|
| 5 |
+
Requires-Python: >=3.10
|
| 6 |
+
Description-Content-Type: text/markdown
|
| 7 |
+
Requires-Dist: openai>=1.40.0
|
| 8 |
+
Requires-Dist: pydantic>=2.7.0
|
| 9 |
+
Requires-Dist: pyyaml>=6.0
|
| 10 |
+
Requires-Dist: python-dotenv>=1.0.0
|
| 11 |
+
Requires-Dist: requests>=2.31.0
|
| 12 |
+
Requires-Dist: feedparser>=6.0.0
|
| 13 |
+
Requires-Dist: tenacity>=8.2.0
|
| 14 |
+
Requires-Dist: PyMuPDF>=1.24.0
|
| 15 |
+
Requires-Dist: networkx>=3.2
|
| 16 |
+
Provides-Extra: rag
|
| 17 |
+
Requires-Dist: sentence-transformers>=3.0.0; extra == "rag"
|
| 18 |
+
Requires-Dist: faiss-cpu>=1.8.0; extra == "rag"
|
| 19 |
+
Requires-Dist: streamlit>=1.36.0; extra == "rag"
|
| 20 |
+
Provides-Extra: dev
|
| 21 |
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
| 22 |
+
Requires-Dist: ruff>=0.5.0; extra == "dev"
|
| 23 |
+
|
| 24 |
+
# Awesome-WAM
|
src/awesome_wam.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
README.md
|
| 2 |
+
pyproject.toml
|
| 3 |
+
src/awesome_wam.egg-info/PKG-INFO
|
| 4 |
+
src/awesome_wam.egg-info/SOURCES.txt
|
| 5 |
+
src/awesome_wam.egg-info/dependency_links.txt
|
| 6 |
+
src/awesome_wam.egg-info/requires.txt
|
| 7 |
+
src/awesome_wam.egg-info/top_level.txt
|
| 8 |
+
src/wam/__init__.py
|
| 9 |
+
src/wam/config.py
|
| 10 |
+
src/wam/logging.py
|
| 11 |
+
src/wam/llm/__init__.py
|
| 12 |
+
src/wam/llm/client.py
|
| 13 |
+
src/wam/store/__init__.py
|
| 14 |
+
src/wam/store/db.py
|
| 15 |
+
src/wam/store/schema.sql
|
src/awesome_wam.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
src/awesome_wam.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openai>=1.40.0
|
| 2 |
+
pydantic>=2.7.0
|
| 3 |
+
pyyaml>=6.0
|
| 4 |
+
python-dotenv>=1.0.0
|
| 5 |
+
requests>=2.31.0
|
| 6 |
+
feedparser>=6.0.0
|
| 7 |
+
tenacity>=8.2.0
|
| 8 |
+
PyMuPDF>=1.24.0
|
| 9 |
+
networkx>=3.2
|
| 10 |
+
|
| 11 |
+
[dev]
|
| 12 |
+
pytest>=8.0.0
|
| 13 |
+
ruff>=0.5.0
|
| 14 |
+
|
| 15 |
+
[rag]
|
| 16 |
+
sentence-transformers>=3.0.0
|
| 17 |
+
faiss-cpu>=1.8.0
|
| 18 |
+
streamlit>=1.36.0
|
src/awesome_wam.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
wam
|
src/wam/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Awesome-WAM: daily intelligence system for World Action Models research."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0"
|
src/wam/config.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration loading.
|
| 2 |
+
|
| 3 |
+
Loads ``config/config.yaml`` plus the human-readable ``config/profile.md`` and resolves
|
| 4 |
+
the project root. Environment variables (e.g. API keys) are loaded from a local ``.env``
|
| 5 |
+
when present so dev runs work without exporting anything.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
from functools import lru_cache
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
import yaml
|
| 16 |
+
|
| 17 |
+
try: # optional: only needed for local dev convenience
|
| 18 |
+
from dotenv import load_dotenv
|
| 19 |
+
except ImportError: # pragma: no cover
|
| 20 |
+
load_dotenv = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def project_root() -> Path:
|
| 24 |
+
"""Repo root = two levels up from this file (src/wam/config.py)."""
|
| 25 |
+
return Path(__file__).resolve().parents[2]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class Config:
|
| 29 |
+
"""Thin wrapper over the parsed config dict with dotted-path access."""
|
| 30 |
+
|
| 31 |
+
def __init__(self, data: dict[str, Any], root: Path):
|
| 32 |
+
self._data = data
|
| 33 |
+
self.root = root
|
| 34 |
+
|
| 35 |
+
def get(self, path: str, default: Any = None) -> Any:
|
| 36 |
+
"""Fetch a nested value by dotted path, e.g. ``config.get("models.tiers.cheap")``."""
|
| 37 |
+
node: Any = self._data
|
| 38 |
+
for part in path.split("."):
|
| 39 |
+
if not isinstance(node, dict) or part not in node:
|
| 40 |
+
return default
|
| 41 |
+
node = node[part]
|
| 42 |
+
return node
|
| 43 |
+
|
| 44 |
+
def __getitem__(self, key: str) -> Any:
|
| 45 |
+
return self._data[key]
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def data(self) -> dict[str, Any]:
|
| 49 |
+
return self._data
|
| 50 |
+
|
| 51 |
+
# --- convenience resolvers -------------------------------------------------
|
| 52 |
+
def path(self, key: str) -> Path:
|
| 53 |
+
"""Resolve a configured relative path (under ``paths.*``) against the repo root."""
|
| 54 |
+
rel = self.get(f"paths.{key}")
|
| 55 |
+
if rel is None:
|
| 56 |
+
raise KeyError(f"paths.{key} not configured")
|
| 57 |
+
return (self.root / rel).resolve()
|
| 58 |
+
|
| 59 |
+
def require_env(self, env_var: str) -> str:
|
| 60 |
+
val = os.environ.get(env_var)
|
| 61 |
+
if not val:
|
| 62 |
+
raise RuntimeError(f"Required environment variable {env_var} is not set")
|
| 63 |
+
return val
|
| 64 |
+
|
| 65 |
+
def profile_text(self) -> str:
|
| 66 |
+
"""The research-interest profile fed to relevance/scoring prompts."""
|
| 67 |
+
return (self.root / "config" / "profile.md").read_text(encoding="utf-8")
|
| 68 |
+
|
| 69 |
+
def model_for_stage(self, stage: str, fallback_tier: str = "mid") -> str:
|
| 70 |
+
"""Resolve the OpenRouter model id for a pipeline stage via stage_tiers -> tiers."""
|
| 71 |
+
tier = self.get(f"models.stage_tiers.{stage}", fallback_tier)
|
| 72 |
+
model = self.get(f"models.tiers.{tier}")
|
| 73 |
+
if not model:
|
| 74 |
+
raise KeyError(f"No model configured for tier '{tier}'")
|
| 75 |
+
return model
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@lru_cache(maxsize=1)
|
| 79 |
+
def load_config(config_path: str | None = None) -> Config:
|
| 80 |
+
"""Load and cache the project config. Also loads ``.env`` if available."""
|
| 81 |
+
root = project_root()
|
| 82 |
+
if load_dotenv is not None:
|
| 83 |
+
load_dotenv(root / ".env")
|
| 84 |
+
path = Path(config_path) if config_path else root / "config" / "config.yaml"
|
| 85 |
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
| 86 |
+
return Config(data, root)
|
src/wam/llm/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from wam.llm.client import LLMClient
|
| 2 |
+
|
| 3 |
+
__all__ = ["LLMClient"]
|
src/wam/llm/client.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenRouter LLM client with tiered routing, validated JSON output, and cost tracking.
|
| 2 |
+
|
| 3 |
+
OpenRouter exposes an OpenAI-compatible API, so we drive it with the ``openai`` SDK pointed
|
| 4 |
+
at the OpenRouter base URL. Three usage paths:
|
| 5 |
+
|
| 6 |
+
- :meth:`complete` — free-form text.
|
| 7 |
+
- :meth:`complete_json` — forces a JSON object, validates it against a Pydantic model, and
|
| 8 |
+
retries with the validation error fed back to the model on malformed output.
|
| 9 |
+
|
| 10 |
+
Every call logs a structured record (tier, model, tokens, latency, cost) and updates the
|
| 11 |
+
shared :data:`wam.logging.COST` tracker.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import re
|
| 18 |
+
import time
|
| 19 |
+
from typing import Type, TypeVar
|
| 20 |
+
|
| 21 |
+
from openai import (APIConnectionError, APITimeoutError, APIStatusError, OpenAI,
|
| 22 |
+
RateLimitError)
|
| 23 |
+
from pydantic import BaseModel, ValidationError
|
| 24 |
+
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
| 25 |
+
|
| 26 |
+
from wam.config import Config, load_config
|
| 27 |
+
from wam.logging import COST, get_logger
|
| 28 |
+
|
| 29 |
+
log = get_logger("llm")
|
| 30 |
+
T = TypeVar("T", bound=BaseModel)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class LLMError(RuntimeError):
|
| 34 |
+
pass
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _is_transient(exc: BaseException) -> bool:
|
| 38 |
+
"""Retry only on rate limits, timeouts, connection drops, and 5xx — not 4xx."""
|
| 39 |
+
if isinstance(exc, (RateLimitError, APITimeoutError, APIConnectionError)):
|
| 40 |
+
return True
|
| 41 |
+
if isinstance(exc, APIStatusError):
|
| 42 |
+
return exc.status_code >= 500
|
| 43 |
+
return False
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class LLMClient:
|
| 47 |
+
def __init__(self, config: Config | None = None):
|
| 48 |
+
self.cfg = config or load_config()
|
| 49 |
+
api_key = self.cfg.require_env(self.cfg.get("provider.api_key_env", "OPENROUTER_API_KEY"))
|
| 50 |
+
self.client = OpenAI(
|
| 51 |
+
base_url=self.cfg.get("provider.base_url", "https://openrouter.ai/api/v1"),
|
| 52 |
+
api_key=api_key,
|
| 53 |
+
timeout=self.cfg.get("constants.request_timeout", 90),
|
| 54 |
+
default_headers={
|
| 55 |
+
# OpenRouter attribution headers (optional).
|
| 56 |
+
"HTTP-Referer": self.cfg.get("provider.http_referer", ""),
|
| 57 |
+
"X-Title": self.cfg.get("provider.app_title", "Awesome-WAM"),
|
| 58 |
+
},
|
| 59 |
+
)
|
| 60 |
+
self._cost_table = self.cfg.get("models.cost_per_million", {}) or {}
|
| 61 |
+
self._max_retries = int(self.cfg.get("constants.max_retries", 4))
|
| 62 |
+
self._backoff = float(self.cfg.get("constants.retry_backoff", 3.0))
|
| 63 |
+
|
| 64 |
+
# --- model resolution ------------------------------------------------------
|
| 65 |
+
def resolve_model(self, tier_or_stage: str) -> str:
|
| 66 |
+
"""Accept a tier name ('cheap'/'mid'/'strong') or a stage name."""
|
| 67 |
+
tiers = self.cfg.get("models.tiers", {}) or {}
|
| 68 |
+
if tier_or_stage in tiers:
|
| 69 |
+
return tiers[tier_or_stage]
|
| 70 |
+
return self.cfg.model_for_stage(tier_or_stage)
|
| 71 |
+
|
| 72 |
+
def _estimate_cost(self, model: str, in_tok: int, out_tok: int) -> float:
|
| 73 |
+
rates = self._cost_table.get(model)
|
| 74 |
+
if not rates:
|
| 75 |
+
return 0.0
|
| 76 |
+
return (in_tok * rates.get("input", 0.0) + out_tok * rates.get("output", 0.0)) / 1_000_000
|
| 77 |
+
|
| 78 |
+
# --- core call -------------------------------------------------------------
|
| 79 |
+
def _call(self, model: str, messages: list[dict], *, temperature: float, max_tokens: int,
|
| 80 |
+
json_mode: bool, label: str) -> tuple[str, int, int]:
|
| 81 |
+
@retry(
|
| 82 |
+
reraise=True,
|
| 83 |
+
stop=stop_after_attempt(self._max_retries),
|
| 84 |
+
wait=wait_exponential(multiplier=self._backoff, max=60),
|
| 85 |
+
retry=retry_if_exception(_is_transient),
|
| 86 |
+
)
|
| 87 |
+
def _do() -> tuple[str, int, int]:
|
| 88 |
+
kwargs: dict = {"model": model, "messages": messages, "temperature": temperature}
|
| 89 |
+
if max_tokens:
|
| 90 |
+
kwargs["max_tokens"] = max_tokens
|
| 91 |
+
if json_mode:
|
| 92 |
+
kwargs["response_format"] = {"type": "json_object"}
|
| 93 |
+
t0 = time.monotonic()
|
| 94 |
+
resp = self.client.chat.completions.create(**kwargs)
|
| 95 |
+
dt = time.monotonic() - t0
|
| 96 |
+
choice = resp.choices[0]
|
| 97 |
+
content = choice.message.content or ""
|
| 98 |
+
usage = resp.usage
|
| 99 |
+
in_tok = getattr(usage, "prompt_tokens", 0) or 0
|
| 100 |
+
out_tok = getattr(usage, "completion_tokens", 0) or 0
|
| 101 |
+
cost = self._estimate_cost(model, in_tok, out_tok)
|
| 102 |
+
COST.record(model, in_tok, out_tok, cost)
|
| 103 |
+
log.debug("call[%s] model=%s tokens=%d+%d cost=$%.5f %.2fs finish=%s",
|
| 104 |
+
label, model, in_tok, out_tok, cost, dt, choice.finish_reason)
|
| 105 |
+
return content, in_tok, out_tok
|
| 106 |
+
|
| 107 |
+
return _do()
|
| 108 |
+
|
| 109 |
+
# --- public API ------------------------------------------------------------
|
| 110 |
+
def complete(self, tier: str, system: str, user: str, *, temperature: float | None = None,
|
| 111 |
+
max_tokens: int | None = None, label: str = "complete",
|
| 112 |
+
model: str | None = None) -> str:
|
| 113 |
+
model = model or self.resolve_model(tier)
|
| 114 |
+
temperature = self.cfg.get("models.defaults.temperature", 0.2) if temperature is None else temperature
|
| 115 |
+
max_tokens = self.cfg.get("models.defaults.max_tokens", 4096) if max_tokens is None else max_tokens
|
| 116 |
+
messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
| 117 |
+
content, _, _ = self._call(model, messages, temperature=temperature,
|
| 118 |
+
max_tokens=max_tokens, json_mode=False, label=label)
|
| 119 |
+
return content
|
| 120 |
+
|
| 121 |
+
def complete_json(self, tier: str, system: str, user: str, schema: Type[T], *,
|
| 122 |
+
temperature: float | None = None, max_tokens: int | None = None,
|
| 123 |
+
label: str = "json", max_repair: int = 2, model: str | None = None) -> T:
|
| 124 |
+
"""Return an instance of ``schema``. Retries with the validation error on failure."""
|
| 125 |
+
model = model or self.resolve_model(tier)
|
| 126 |
+
temperature = self.cfg.get("models.defaults.temperature", 0.2) if temperature is None else temperature
|
| 127 |
+
max_tokens = self.cfg.get("models.defaults.max_tokens", 4096) if max_tokens is None else max_tokens
|
| 128 |
+
schema_json = json.dumps(schema.model_json_schema(), indent=2)
|
| 129 |
+
keys = ", ".join(schema.model_fields.keys())
|
| 130 |
+
sys_full = (f"{system}\n\nReturn ONLY a JSON object with exactly these top-level keys: "
|
| 131 |
+
f"{keys}. Put the values directly at the top level — do NOT wrap them in a "
|
| 132 |
+
f"'properties' object and do NOT echo the schema. Schema for reference:\n"
|
| 133 |
+
f"{schema_json}")
|
| 134 |
+
messages = [{"role": "system", "content": sys_full}, {"role": "user", "content": user}]
|
| 135 |
+
|
| 136 |
+
last_err: Exception | None = None
|
| 137 |
+
for attempt in range(max_repair + 1):
|
| 138 |
+
content, _, _ = self._call(model, messages, temperature=temperature,
|
| 139 |
+
max_tokens=max_tokens, json_mode=True,
|
| 140 |
+
label=f"{label}#{attempt}")
|
| 141 |
+
try:
|
| 142 |
+
return schema.model_validate(_extract_obj(content, schema))
|
| 143 |
+
except (ValidationError, json.JSONDecodeError, ValueError) as e:
|
| 144 |
+
last_err = e
|
| 145 |
+
log.warning("JSON validation failed for %s (attempt %d): %s", label, attempt,
|
| 146 |
+
str(e)[:200])
|
| 147 |
+
messages.append({"role": "assistant", "content": content})
|
| 148 |
+
messages.append({"role": "user", "content":
|
| 149 |
+
f"That did not validate: {e}. Return ONLY the corrected JSON "
|
| 150 |
+
f"object with top-level keys {keys}."})
|
| 151 |
+
raise LLMError(f"Could not get valid {schema.__name__} from {model}: {last_err}")
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _strip_fences(text: str) -> str:
|
| 155 |
+
"""Remove ```json ... ``` fences some models add despite json_mode."""
|
| 156 |
+
t = text.strip()
|
| 157 |
+
if t.startswith("```"):
|
| 158 |
+
t = t.split("\n", 1)[1] if "\n" in t else t
|
| 159 |
+
if t.endswith("```"):
|
| 160 |
+
t = t[: t.rfind("```")]
|
| 161 |
+
return t.strip()
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# Envelope keys some models wrap the real object in (e.g. deepseek echoes the JSON schema's
|
| 165 |
+
# "properties"; others use "output"/"result").
|
| 166 |
+
_ENVELOPES = ("properties", "output", "result", "data", "response", "json")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _loads_lenient(raw: str) -> object:
|
| 170 |
+
"""json.loads, retrying after stripping trailing commas (a common LLM malformation)."""
|
| 171 |
+
try:
|
| 172 |
+
return json.loads(raw)
|
| 173 |
+
except json.JSONDecodeError:
|
| 174 |
+
repaired = re.sub(r",(\s*[}\]])", r"\1", raw)
|
| 175 |
+
return json.loads(repaired)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _extract_obj(content: str, schema: type[BaseModel]) -> dict:
|
| 179 |
+
"""Parse model content to the target dict, tolerating common wrapper envelopes."""
|
| 180 |
+
raw = _strip_fences(content)
|
| 181 |
+
if not raw:
|
| 182 |
+
raise ValueError("empty content (model likely spent the token budget on reasoning)")
|
| 183 |
+
data = _loads_lenient(raw)
|
| 184 |
+
if isinstance(data, dict):
|
| 185 |
+
required = set(schema.model_fields.keys())
|
| 186 |
+
if not (required & data.keys()): # none of the expected keys at top level → unwrap
|
| 187 |
+
for key in _ENVELOPES:
|
| 188 |
+
inner = data.get(key)
|
| 189 |
+
if isinstance(inner, dict) and (required & inner.keys()):
|
| 190 |
+
return inner
|
| 191 |
+
return data
|
src/wam/logging.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Central logging.
|
| 2 |
+
|
| 3 |
+
A single console handler at the configured level; in **debug mode** an additional file
|
| 4 |
+
sink captures *everything* for the run under ``logs/run-<date>.log`` (gitignored). The LLM
|
| 5 |
+
client logs a structured record per call (tier, model, tokens, cost, latency) so a debug
|
| 6 |
+
run is a full, replayable trace. A module-level :class:`CostTracker` accumulates spend and
|
| 7 |
+
emits a per-run summary.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
import os
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
from datetime import date
|
| 16 |
+
from logging.handlers import RotatingFileHandler
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
_CONFIGURED = False
|
| 20 |
+
_FMT = "%(asctime)s %(levelname)-7s %(name)s | %(message)s"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def is_debug() -> bool:
|
| 24 |
+
return os.environ.get("WAM_DEBUG", "").lower() in {"1", "true", "yes"}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def setup_logging(level: str = "INFO", log_dir: str | Path = "logs", debug: bool | None = None,
|
| 28 |
+
run_date: str | None = None) -> logging.Logger:
|
| 29 |
+
"""Configure root logging once. Returns the ``wam`` logger.
|
| 30 |
+
|
| 31 |
+
When ``debug`` (or ``WAM_DEBUG`` env) is set, attaches a rotating file handler at DEBUG
|
| 32 |
+
level writing the full run trace to ``<log_dir>/run-<date>.log``.
|
| 33 |
+
"""
|
| 34 |
+
global _CONFIGURED
|
| 35 |
+
debug = is_debug() if debug is None else debug
|
| 36 |
+
root = logging.getLogger()
|
| 37 |
+
base_level = logging.DEBUG if debug else getattr(logging, level.upper(), logging.INFO)
|
| 38 |
+
root.setLevel(logging.DEBUG if debug else base_level)
|
| 39 |
+
|
| 40 |
+
if not _CONFIGURED:
|
| 41 |
+
console = logging.StreamHandler()
|
| 42 |
+
console.setLevel(base_level)
|
| 43 |
+
console.setFormatter(logging.Formatter(_FMT))
|
| 44 |
+
root.addHandler(console)
|
| 45 |
+
|
| 46 |
+
if debug:
|
| 47 |
+
d = Path(log_dir)
|
| 48 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 49 |
+
stamp = run_date or date.today().isoformat()
|
| 50 |
+
fh = RotatingFileHandler(d / f"run-{stamp}.log", maxBytes=20_000_000,
|
| 51 |
+
backupCount=5, encoding="utf-8")
|
| 52 |
+
fh.setLevel(logging.DEBUG)
|
| 53 |
+
fh.setFormatter(logging.Formatter(_FMT))
|
| 54 |
+
root.addHandler(fh)
|
| 55 |
+
_CONFIGURED = True
|
| 56 |
+
|
| 57 |
+
return logging.getLogger("wam")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def get_logger(name: str) -> logging.Logger:
|
| 61 |
+
return logging.getLogger(f"wam.{name}")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@dataclass
|
| 65 |
+
class CostTracker:
|
| 66 |
+
"""Accumulates token usage + estimated cost across a run."""
|
| 67 |
+
|
| 68 |
+
calls: int = 0
|
| 69 |
+
input_tokens: int = 0
|
| 70 |
+
output_tokens: int = 0
|
| 71 |
+
cost_usd: float = 0.0
|
| 72 |
+
by_model: dict[str, dict[str, float]] = field(default_factory=dict)
|
| 73 |
+
|
| 74 |
+
def record(self, model: str, in_tok: int, out_tok: int, cost: float) -> None:
|
| 75 |
+
self.calls += 1
|
| 76 |
+
self.input_tokens += in_tok
|
| 77 |
+
self.output_tokens += out_tok
|
| 78 |
+
self.cost_usd += cost
|
| 79 |
+
m = self.by_model.setdefault(model, {"calls": 0, "in": 0, "out": 0, "cost": 0.0})
|
| 80 |
+
m["calls"] += 1
|
| 81 |
+
m["in"] += in_tok
|
| 82 |
+
m["out"] += out_tok
|
| 83 |
+
m["cost"] += cost
|
| 84 |
+
|
| 85 |
+
def summary(self) -> str:
|
| 86 |
+
parts = [f"{self.calls} calls", f"{self.input_tokens}+{self.output_tokens} tok",
|
| 87 |
+
f"${self.cost_usd:.4f}"]
|
| 88 |
+
per = ", ".join(f"{k}: ${v['cost']:.4f}" for k, v in sorted(self.by_model.items()))
|
| 89 |
+
return f"LLM usage: {' / '.join(parts)}" + (f" [{per}]" if per else "")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# One shared tracker per process; the LLM client writes to it.
|
| 93 |
+
COST = CostTracker()
|
src/wam/models.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared Pydantic models.
|
| 2 |
+
|
| 3 |
+
``PaperRecord`` is the normalized in-memory shape produced by the fetch sources and stored
|
| 4 |
+
in the ``papers`` table. LLM output schemas (summary, analysis, scores, ...) are added in
|
| 5 |
+
later phases next to the stages that use them.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Links(BaseModel):
|
| 14 |
+
abs: str | None = None
|
| 15 |
+
pdf: str | None = None
|
| 16 |
+
project_page: str | None = None
|
| 17 |
+
code: str | None = None
|
| 18 |
+
doi: str | None = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class PaperRecord(BaseModel):
|
| 22 |
+
"""A fetched, source-normalized item (paper or news) before any LLM processing."""
|
| 23 |
+
|
| 24 |
+
id: str = Field(description="stable id, e.g. 'arxiv:2506.01234' or 'news:<sha1>'")
|
| 25 |
+
source: str
|
| 26 |
+
title: str
|
| 27 |
+
authors: list[str] = Field(default_factory=list)
|
| 28 |
+
published: str | None = None # ISO date
|
| 29 |
+
abstract: str | None = None
|
| 30 |
+
categories: list[str] = Field(default_factory=list)
|
| 31 |
+
links: Links = Field(default_factory=Links)
|
| 32 |
+
citations: int = 0
|
| 33 |
+
influential_citations: int = 0
|
| 34 |
+
has_code: bool = False
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def arxiv_id(self) -> str | None:
|
| 38 |
+
return self.id.split("arxiv:", 1)[1] if self.id.startswith("arxiv:") else None
|
src/wam/notify/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Email digest delivery (Gmail SMTP)."""
|
src/wam/notify/mailer.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build + send the daily digest email via Gmail SMTP.
|
| 2 |
+
|
| 3 |
+
Clean and scannable: the key points (counts, what's hot, top picks) sit above the fold, with
|
| 4 |
+
compact cards and obvious links. The recipient list lives in the ``SUBSCRIBERS`` env var
|
| 5 |
+
(comma-separated or a JSON array) and is NEVER committed. Uses inline CSS for client support.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
import smtplib
|
| 14 |
+
import sqlite3
|
| 15 |
+
from datetime import date
|
| 16 |
+
from email.mime.multipart import MIMEMultipart
|
| 17 |
+
from email.mime.text import MIMEText
|
| 18 |
+
|
| 19 |
+
from wam.config import Config
|
| 20 |
+
from wam.logging import get_logger
|
| 21 |
+
|
| 22 |
+
log = get_logger("notify.mailer")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _subscribers() -> list[str]:
|
| 26 |
+
raw = os.environ.get("SUBSCRIBERS", "").strip()
|
| 27 |
+
if not raw:
|
| 28 |
+
return []
|
| 29 |
+
if raw.startswith("["):
|
| 30 |
+
try:
|
| 31 |
+
return [e.strip() for e in json.loads(raw) if e.strip()]
|
| 32 |
+
except Exception: # noqa: BLE001
|
| 33 |
+
pass
|
| 34 |
+
return [e.strip() for e in raw.split(",") if e.strip()]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _links(lj: str | None) -> dict:
|
| 38 |
+
try:
|
| 39 |
+
return json.loads(lj or "{}")
|
| 40 |
+
except Exception: # noqa: BLE001
|
| 41 |
+
return {}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _clip(text: str, n: int) -> str:
|
| 45 |
+
text = (text or "").strip()
|
| 46 |
+
if len(text) <= n:
|
| 47 |
+
return text
|
| 48 |
+
return text[:n].rsplit(" ", 1)[0].rstrip(".,;: ") + "…"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
_GH = re.compile(r"https?://github\.com/[\w.\-/]+")
|
| 52 |
+
_HF = re.compile(r"https?://huggingface\.co/[\w.\-/]+")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _title_links(links: dict, abstract: str | None) -> str:
|
| 56 |
+
"""Small inline links next to a title: code (GitHub), HF, project page, pdf.
|
| 57 |
+
|
| 58 |
+
GitHub/HF URLs are pulled from links or, failing that, from the abstract text.
|
| 59 |
+
"""
|
| 60 |
+
abstract = abstract or ""
|
| 61 |
+
gh = links.get("code") if (links.get("code") and "github.com" in links["code"]) else None
|
| 62 |
+
if not gh:
|
| 63 |
+
m = _GH.search(abstract)
|
| 64 |
+
gh = m.group(0).rstrip(".)") if m else None
|
| 65 |
+
hf = links.get("hf")
|
| 66 |
+
if not hf:
|
| 67 |
+
if links.get("code") and "huggingface.co" in links["code"]:
|
| 68 |
+
hf = links["code"]
|
| 69 |
+
else:
|
| 70 |
+
m = _HF.search(abstract)
|
| 71 |
+
hf = m.group(0).rstrip(".)") if m else None
|
| 72 |
+
chips = []
|
| 73 |
+
style = 'style="color:#1a4fcc;font-size:11px;text-decoration:none;margin-left:6px"'
|
| 74 |
+
if gh:
|
| 75 |
+
chips.append(f'<a href="{gh}" {style}>⌨ code</a>')
|
| 76 |
+
if hf:
|
| 77 |
+
chips.append(f'<a href="{hf}" {style}>🤗 HF</a>')
|
| 78 |
+
if links.get("project_page"):
|
| 79 |
+
chips.append(f'<a href="{links["project_page"]}" {style}>🔗 page</a>')
|
| 80 |
+
if links.get("pdf"):
|
| 81 |
+
chips.append(f'<a href="{links["pdf"]}" {style}>📄 pdf</a>')
|
| 82 |
+
return "".join(chips)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _wt(row) -> float:
|
| 86 |
+
try:
|
| 87 |
+
return float(json.loads(row["scores_json"]).get("weighted_total") or 0)
|
| 88 |
+
except Exception: # noqa: BLE001
|
| 89 |
+
return 0.0
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _score_badge(v) -> str:
|
| 93 |
+
return (f'<span style="background:#1a4fcc;color:#fff;border-radius:4px;padding:1px 6px;'
|
| 94 |
+
f'font-size:12px;margin-left:6px">{v}</span>')
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
_GEN_LABELS = [("novelty", "nov"), ("soundness", "snd"), ("impact", "imp")]
|
| 98 |
+
# WAM order: top-4 (weighted 2x) first, then the rest.
|
| 99 |
+
_WAM_LABELS = [("inference_speed", "spd"), ("generalist", "gen"), ("specialist", "spec"),
|
| 100 |
+
("inference_cost", "cost"), ("trustworthiness", "trust"),
|
| 101 |
+
("collaborative", "collab"), ("controlled_generation", "ctrl"), ("other", "other")]
|
| 102 |
+
_WAM_TOP4 = {"inference_speed", "generalist", "specialist", "inference_cost"}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _v(x) -> str:
|
| 106 |
+
return str(x) if isinstance(x, int) else "–"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _chip(label: str, val: str, strong: bool = False) -> str:
|
| 110 |
+
bg, weight = ("#e8f0ff", "600") if strong else ("#f2f2f2", "400")
|
| 111 |
+
return (f'<span style="background:{bg};border-radius:4px;padding:1px 5px;margin:0 4px 2px 0;'
|
| 112 |
+
f'font-size:11px;color:#333;font-weight:{weight};display:inline-block">{label} {val}</span>')
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _metric_badges(scores: dict) -> str:
|
| 116 |
+
"""Full two-layer dimension badges (general + all WAM; top-4 WAM emphasized)."""
|
| 117 |
+
g, w = scores.get("general", {}) or {}, scores.get("wam", {}) or {}
|
| 118 |
+
gen = "".join(_chip(l, _v(g.get(k))) for k, l in _GEN_LABELS)
|
| 119 |
+
wam = "".join(_chip(l, _v(w.get(k)), k in _WAM_TOP4) for k, l in _WAM_LABELS)
|
| 120 |
+
return (f'<div style="margin:5px 0 0"><span style="color:#999;font-size:11px">general</span> '
|
| 121 |
+
f'{gen}</div>'
|
| 122 |
+
f'<div style="margin:2px 0 0"><span style="color:#999;font-size:11px">WAM</span> '
|
| 123 |
+
f'{wam}</div>')
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _top4_inline(scores: dict) -> str:
|
| 127 |
+
w = scores.get("wam", {}) or {}
|
| 128 |
+
return " · ".join(f"{l} {_v(w.get(k))}" for k, l in
|
| 129 |
+
[("inference_speed", "spd"), ("generalist", "gen"),
|
| 130 |
+
("specialist", "spec"), ("inference_cost", "cost")])
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _card(row, direction: str | None = None) -> str:
|
| 134 |
+
"""Detailed featured card: title (+ links), direction tag, blurb, metrics."""
|
| 135 |
+
s = json.loads(row["scores_json"])
|
| 136 |
+
sm = json.loads(row["summary_json"] or "{}")
|
| 137 |
+
links = _links(row["links_json"])
|
| 138 |
+
link = links.get("abs") or links.get("pdf") or "#"
|
| 139 |
+
tldr = sm.get("tldr", "")
|
| 140 |
+
extra = _clip(" ".join(x for x in (sm.get("method", ""), sm.get("results", "")) if x), 300)
|
| 141 |
+
dir_html = ""
|
| 142 |
+
if direction:
|
| 143 |
+
dir_html = (f'<div style="margin-top:4px"><span style="background:#eef3ff;color:#1a4fcc;'
|
| 144 |
+
f'border-radius:10px;padding:1px 9px;font-size:11px">📂 {direction}</span>'
|
| 145 |
+
f'</div>')
|
| 146 |
+
blurb = f'<div style="font-size:13px;color:#333;margin-top:5px">{tldr}</div>'
|
| 147 |
+
if extra:
|
| 148 |
+
blurb += f'<div style="font-size:12px;color:#666;margin-top:3px">{extra}</div>'
|
| 149 |
+
return (f'<div style="margin:12px 0;padding:10px 14px;border-left:3px solid #1a4fcc;'
|
| 150 |
+
f'background:#fafbff;border-radius:0 6px 6px 0">'
|
| 151 |
+
f'<a href="{link}" style="font-weight:600;color:#1a4fcc;text-decoration:none;'
|
| 152 |
+
f'font-size:14px">{row["title"]}</a>{_score_badge(s.get("weighted_total","?"))}'
|
| 153 |
+
f'{_title_links(links, row["abstract"])}'
|
| 154 |
+
f'{dir_html}{blurb}'
|
| 155 |
+
f'<div style="margin-top:6px">{_metric_badges(s)}</div></div>')
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _compact(row) -> str:
|
| 159 |
+
"""Compact grouped-tier item: score + title link + short description."""
|
| 160 |
+
s = json.loads(row["scores_json"])
|
| 161 |
+
tldr = json.loads(row["summary_json"] or "{}").get("tldr", "")
|
| 162 |
+
if len(tldr) > 160:
|
| 163 |
+
tldr = tldr[:160].rsplit(" ", 1)[0].rstrip(".,;: ") + "…"
|
| 164 |
+
link = (_links(row["links_json"]).get("abs") or _links(row["links_json"]).get("pdf") or "#")
|
| 165 |
+
desc = f' — <span style="color:#444">{tldr}</span>' if tldr else ""
|
| 166 |
+
return (f'<div style="font-size:13px;margin:5px 0">'
|
| 167 |
+
f'<b>{s.get("weighted_total","?")}</b> · '
|
| 168 |
+
f'<a href="{link}" style="color:#1a4fcc;text-decoration:none">{row["title"]}</a>'
|
| 169 |
+
f'{_title_links(_links(row["links_json"]), row["abstract"])}'
|
| 170 |
+
f'{desc}'
|
| 171 |
+
f'<div style="font-size:11px;color:#888;margin-top:1px">{_top4_inline(s)}</div></div>')
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _teams_html(conn: sqlite3.Connection) -> str:
|
| 175 |
+
"""Short closing paragraph on the most influential teams / contributors."""
|
| 176 |
+
groups = conn.execute("SELECT name FROM groups ORDER BY json_array_length(member_ids_json) "
|
| 177 |
+
"DESC LIMIT 3").fetchall()
|
| 178 |
+
authors = conn.execute("SELECT name, affiliation FROM authors ORDER BY "
|
| 179 |
+
"json_array_length(paper_ids_json) DESC LIMIT 5").fetchall()
|
| 180 |
+
if not authors and not groups:
|
| 181 |
+
return ""
|
| 182 |
+
if groups:
|
| 183 |
+
body = ", ".join(f"<b>{g['name']}</b>" for g in groups)
|
| 184 |
+
lead = f"Most active groups in the tracked corpus: {body}."
|
| 185 |
+
else:
|
| 186 |
+
body = ", ".join(f"<b>{a['name']}</b>" + (f" ({a['affiliation']})" if a["affiliation"]
|
| 187 |
+
else "") for a in authors)
|
| 188 |
+
lead = f"Most active contributors right now: {body}."
|
| 189 |
+
return ('<h2 style="font-size:15px;border-bottom:1px solid #eee;padding-bottom:4px;'
|
| 190 |
+
f'margin-top:18px">👥 Influential teams</h2>'
|
| 191 |
+
f'<p style="font-size:13px;color:#444;margin:6px 0 0">{lead}</p>')
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def build_html(cfg: Config, conn: sqlite3.Connection, today: str | None = None) -> tuple[str, str]:
|
| 195 |
+
today = today or date.today().isoformat()
|
| 196 |
+
counts = dict(conn.execute("SELECT track, count(*) FROM papers GROUP BY track").fetchall())
|
| 197 |
+
new_core = conn.execute(
|
| 198 |
+
"SELECT count(*) FROM papers WHERE track='core' AND first_seen=?", (today,)).fetchone()[0]
|
| 199 |
+
|
| 200 |
+
# Core papers: new today; fall back to recent if none new.
|
| 201 |
+
sel = ("SELECT id, title, abstract, links_json, scores_json, summary_json FROM papers WHERE "
|
| 202 |
+
"track='core' AND scores_json IS NOT NULL")
|
| 203 |
+
order = " ORDER BY json_extract(scores_json,'$.weighted_total') DESC"
|
| 204 |
+
rows = conn.execute(sel + " AND first_seen=?" + order, (today,)).fetchall()
|
| 205 |
+
fallback = not rows
|
| 206 |
+
if fallback:
|
| 207 |
+
rows = conn.execute(sel + order + " LIMIT 60").fetchall()
|
| 208 |
+
|
| 209 |
+
snap = conn.execute("SELECT max(snapshot_date) FROM fronts").fetchone()[0]
|
| 210 |
+
# Rising directions, excluding the catch-all "Miscellaneous" bucket.
|
| 211 |
+
hot = conn.execute(
|
| 212 |
+
"SELECT name, size, summary FROM fronts WHERE snapshot_date=? AND momentum='rising' "
|
| 213 |
+
"AND name NOT LIKE '%iscellaneous%' ORDER BY size DESC LIMIT 6", (snap,)).fetchall() \
|
| 214 |
+
if snap else []
|
| 215 |
+
# paper id -> research direction (smaller/more-specific fronts win, via size ASC)
|
| 216 |
+
dir_of: dict[str, str] = {}
|
| 217 |
+
if snap:
|
| 218 |
+
for fr in conn.execute("SELECT name, member_ids_json FROM fronts WHERE "
|
| 219 |
+
"snapshot_date=? ORDER BY size", (snap,)):
|
| 220 |
+
for pid in json.loads(fr["member_ids_json"] or "[]"):
|
| 221 |
+
dir_of[pid] = fr["name"]
|
| 222 |
+
|
| 223 |
+
feat_thr = float(cfg.get("email.feature_threshold", 7.0))
|
| 224 |
+
featured = [r for r in rows if _wt(r) >= feat_thr][: int(cfg.get("email.max_featured", 8))]
|
| 225 |
+
feat_ids = {r["id"] for r in featured}
|
| 226 |
+
rest = [r for r in rows if r["id"] not in feat_ids][: int(cfg.get("email.max_grouped", 40))]
|
| 227 |
+
|
| 228 |
+
S = "font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif"
|
| 229 |
+
parts = [f'<div style="max-width:640px;margin:auto;{S};color:#1a1a1a">']
|
| 230 |
+
parts.append(f'<h1 style="font-size:20px;margin:0 0 8px">🤖 WAM Daily — {today}</h1>')
|
| 231 |
+
new_adj = conn.execute("SELECT count(*) FROM papers WHERE track='adjacent' AND first_seen=?",
|
| 232 |
+
(today,)).fetchone()[0]
|
| 233 |
+
total_core, total_adj = counts.get("core", 0), counts.get("adjacent", 0)
|
| 234 |
+
|
| 235 |
+
def _stat(n, lbl):
|
| 236 |
+
return (f'<td style="text-align:center;padding:8px 6px">'
|
| 237 |
+
f'<div style="font-size:22px;font-weight:700;color:#1a4fcc">{n}</div>'
|
| 238 |
+
f'<div style="font-size:11px;color:#777">{lbl}</div></td>')
|
| 239 |
+
parts.append(
|
| 240 |
+
'<table style="width:100%;border-collapse:collapse;margin:0 0 18px;background:#f5f8ff;'
|
| 241 |
+
'border-radius:8px"><tr>'
|
| 242 |
+
+ _stat(new_core + new_adj, "relevant today") + _stat(new_core, "core today")
|
| 243 |
+
+ _stat(total_core + total_adj, "relevant total") + _stat(total_core, "core total")
|
| 244 |
+
+ '</tr></table>')
|
| 245 |
+
if hot:
|
| 246 |
+
parts.append('<h2 style="font-size:15px;border-bottom:1px solid #eee;'
|
| 247 |
+
'padding-bottom:4px">📈 What\'s hot</h2>')
|
| 248 |
+
items = "".join(
|
| 249 |
+
f'<div style="font-size:13px;margin:5px 0"><b>{h["name"]}</b> '
|
| 250 |
+
f'<span style="color:#999">({h["size"]})</span> — '
|
| 251 |
+
f'<span style="color:#444">{_clip(h["summary"] or "", 160)}</span></div>'
|
| 252 |
+
for h in hot)
|
| 253 |
+
parts.append(items)
|
| 254 |
+
|
| 255 |
+
# Tier 1 — featured (detailed cards)
|
| 256 |
+
label = "Top recent papers" if fallback else "Top new papers today"
|
| 257 |
+
parts.append(f'<h2 style="font-size:15px;border-bottom:1px solid #eee;padding-bottom:4px">'
|
| 258 |
+
f'⭐ {label} (score ≥ {feat_thr:g})</h2>')
|
| 259 |
+
if not featured:
|
| 260 |
+
parts.append('<p style="font-size:13px;color:#666">Nothing above the feature threshold '
|
| 261 |
+
'— see the rest below.</p>')
|
| 262 |
+
parts.extend(_card(r, dir_of.get(r["id"])) for r in featured)
|
| 263 |
+
|
| 264 |
+
# Tier 2 — the rest, grouped by research direction
|
| 265 |
+
if rest:
|
| 266 |
+
parts.append('<h2 style="font-size:15px;border-bottom:1px solid #eee;padding-bottom:4px;'
|
| 267 |
+
'margin-top:18px">More core papers</h2>')
|
| 268 |
+
if bool(cfg.get("email.group_lower_by_direction", True)):
|
| 269 |
+
groups: dict[str, list] = {}
|
| 270 |
+
for r in rest:
|
| 271 |
+
groups.setdefault(dir_of.get(r["id"], "Other"), []).append(r)
|
| 272 |
+
# Miscellaneous / Other always go last; otherwise larger groups first.
|
| 273 |
+
def _gkey(k):
|
| 274 |
+
last = k == "Other" or "iscellaneous" in k
|
| 275 |
+
return (last, -len(groups[k]))
|
| 276 |
+
for gname in sorted(groups, key=_gkey):
|
| 277 |
+
parts.append(f'<p style="font-weight:600;font-size:13px;margin:12px 0 2px">'
|
| 278 |
+
f'{gname} <span style="color:#999;font-weight:400">'
|
| 279 |
+
f'({len(groups[gname])})</span></p>')
|
| 280 |
+
parts.extend(_compact(r) for r in groups[gname])
|
| 281 |
+
else:
|
| 282 |
+
parts.extend(_compact(r) for r in rest)
|
| 283 |
+
parts.append(_teams_html(conn))
|
| 284 |
+
|
| 285 |
+
parts.append(
|
| 286 |
+
'<p style="color:#aaa;font-size:11px;margin-top:18px;border-top:1px solid #eee;'
|
| 287 |
+
'padding-top:8px">Dimensions — general: nov=novelty, snd=soundness, imp=impact · '
|
| 288 |
+
'WAM (bold = weighted 2×): spd=inference speed, gen=generalist, spec=specialist, '
|
| 289 |
+
'cost=inference cost, trust=trustworthiness, collab=collaborative, '
|
| 290 |
+
'ctrl=controlled generation. “–” = the paper doesn’t address it.</p>')
|
| 291 |
+
parts.append(f'<p style="color:#999;font-size:12px;margin-top:8px">Awesome-WAM · '
|
| 292 |
+
f'<a href="https://github.com/your-org/Awesome-WAM">repo</a></p></div>')
|
| 293 |
+
subject = f"WAM Daily — {today}: {new_core} new" + ("" if not fallback else " (recap)")
|
| 294 |
+
return subject, "".join(parts)
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def send(cfg: Config, conn: sqlite3.Connection, today: str | None = None,
|
| 298 |
+
test_to: str | None = None) -> int:
|
| 299 |
+
user = os.environ.get("GMAIL_USER")
|
| 300 |
+
pw = os.environ.get("GMAIL_APP_PASSWORD")
|
| 301 |
+
if not (user and pw):
|
| 302 |
+
log.warning("GMAIL_USER / GMAIL_APP_PASSWORD not set; skipping email send")
|
| 303 |
+
return 0
|
| 304 |
+
recipients = [test_to] if test_to else _subscribers()
|
| 305 |
+
if not recipients:
|
| 306 |
+
log.warning("no SUBSCRIBERS configured; skipping send")
|
| 307 |
+
return 0
|
| 308 |
+
subject, html = build_html(cfg, conn, today)
|
| 309 |
+
msg = MIMEMultipart("alternative")
|
| 310 |
+
msg["Subject"], msg["From"] = subject, user
|
| 311 |
+
msg["To"] = ", ".join(recipients)
|
| 312 |
+
msg.attach(MIMEText("This digest is best viewed as HTML.", "plain"))
|
| 313 |
+
msg.attach(MIMEText(html, "html"))
|
| 314 |
+
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
|
| 315 |
+
server.login(user, pw)
|
| 316 |
+
server.sendmail(user, recipients, msg.as_string())
|
| 317 |
+
log.info("sent digest to %d recipient(s)", len(recipients))
|
| 318 |
+
return len(recipients)
|
src/wam/pipeline/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Pipeline stages and the daily orchestrator."""
|
src/wam/pipeline/analyze.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Analyze stage — deep technical analysis for core-track papers (mid tier).
|
| 2 |
+
|
| 3 |
+
Capped at ``constants.analyze_cap`` papers per run (highest-relevance first) to bound cost.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import sqlite3
|
| 9 |
+
|
| 10 |
+
from wam.config import Config
|
| 11 |
+
from wam.llm import LLMClient
|
| 12 |
+
from wam.logging import get_logger
|
| 13 |
+
from wam.pipeline.schemas import PaperAnalysis
|
| 14 |
+
from wam.store import papers as ps
|
| 15 |
+
|
| 16 |
+
log = get_logger("pipeline.analyze")
|
| 17 |
+
|
| 18 |
+
SYSTEM = ("You are an expert reviewer of World Action Models (embodied/robot foundation "
|
| 19 |
+
"models, VLA, world models). Analyze the paper rigorously and skeptically. Always "
|
| 20 |
+
"write in English, regardless of the paper's language.\n\n"
|
| 21 |
+
"--- PROFILE ---\n{profile}")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def run(cfg: Config, client: LLMClient, conn: sqlite3.Connection,
|
| 25 |
+
limit: int | None = None) -> int:
|
| 26 |
+
cap = limit or int(cfg.get("constants.analyze_cap", 40))
|
| 27 |
+
system = SYSTEM.format(profile=cfg.profile_text())
|
| 28 |
+
todo = ps.needs_analysis(conn, limit=cap)
|
| 29 |
+
log.info("analyzing %d core papers (cap=%d)", len(todo), cap)
|
| 30 |
+
done = 0
|
| 31 |
+
for row in todo:
|
| 32 |
+
user = f"Title: {row['title']}\n\nAbstract: {row['abstract'] or '(none)'}"
|
| 33 |
+
try:
|
| 34 |
+
a = client.complete_json("analyze", system, user, PaperAnalysis,
|
| 35 |
+
label="analyze", max_tokens=5000)
|
| 36 |
+
except Exception as e: # noqa: BLE001
|
| 37 |
+
log.warning("analyze failed for %s: %s", row["id"], e)
|
| 38 |
+
continue
|
| 39 |
+
ps.set_analysis(conn, row["id"], a.model_dump_json())
|
| 40 |
+
done += 1
|
| 41 |
+
if done % 10 == 0:
|
| 42 |
+
conn.commit()
|
| 43 |
+
conn.commit()
|
| 44 |
+
log.info("analyzed %d papers", done)
|
| 45 |
+
return done
|
src/wam/pipeline/extract.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Extract stage — normalized benchmark rows + model cards for core papers (mid tier).
|
| 2 |
+
|
| 3 |
+
Reads the full PDF text (cached) since benchmark numbers live in tables, not the abstract.
|
| 4 |
+
Extraction is deliberately conservative: only numbers actually stated, each tagged with the
|
| 5 |
+
model variant (name + training dataset) and whether it's the authors' own claim. Capped at
|
| 6 |
+
``constants.analyze_cap`` per run.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import sqlite3
|
| 13 |
+
|
| 14 |
+
from wam.config import Config
|
| 15 |
+
from wam.llm import LLMClient
|
| 16 |
+
from wam.logging import get_logger
|
| 17 |
+
from wam.pipeline.schemas import ExtractionResult
|
| 18 |
+
from wam.sources import pdf
|
| 19 |
+
from wam.store import benchmarks as bm
|
| 20 |
+
from wam.store import papers as ps
|
| 21 |
+
|
| 22 |
+
log = get_logger("pipeline.extract")
|
| 23 |
+
|
| 24 |
+
SYSTEM = (
|
| 25 |
+
"Extract structured experimental results from this World Action Models paper. Rules:\n"
|
| 26 |
+
"- Only report numbers ACTUALLY stated in the text — never estimate or invent.\n"
|
| 27 |
+
"- Each model/system is identified by (model_name, training_dataset). The SAME name "
|
| 28 |
+
"trained/finetuned on a different dataset is a DIFFERENT variant — always capture the "
|
| 29 |
+
"training/finetune dataset when stated.\n"
|
| 30 |
+
"- For each result give the benchmark, task, metric name+value, and any inference "
|
| 31 |
+
"speed/cost with their units and hardware as reported.\n"
|
| 32 |
+
"- Set claimed_by_authors=false for numbers quoted from OTHER papers (baselines/"
|
| 33 |
+
"comparisons), true for the paper's own results.\n"
|
| 34 |
+
"- BE CONCISE: report only this paper's headline results — at most ~15 benchmark rows "
|
| 35 |
+
"and ~6 model variants (the proposed method + key baselines). Keep every 'notes' field "
|
| 36 |
+
"under 12 words; omit notes if not needed.\n"
|
| 37 |
+
"- Empty lists are fine if the paper reports no quantitative results.")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def run(cfg: Config, client: LLMClient, conn: sqlite3.Connection,
|
| 41 |
+
limit: int | None = None) -> int:
|
| 42 |
+
cap = limit or int(cfg.get("constants.analyze_cap", 40))
|
| 43 |
+
todo = ps.needs_extract(conn, limit=cap)
|
| 44 |
+
log.info("extracting benchmarks for %d core papers (cap=%d)", len(todo), cap)
|
| 45 |
+
done = 0
|
| 46 |
+
for row in todo:
|
| 47 |
+
links = json.loads(row["links_json"] or "{}")
|
| 48 |
+
# 35k chars (~9k tokens) comfortably covers intro+results+tables while staying fast.
|
| 49 |
+
text = pdf.get_text(cfg, row["id"], links.get("pdf") or "", max_chars=35000)
|
| 50 |
+
body = text or row["abstract"] or ""
|
| 51 |
+
user = (f"Title: {row['title']}\n\nAbstract: {row['abstract'] or ''}\n\n"
|
| 52 |
+
f"Paper text (may be truncated):\n{body}")
|
| 53 |
+
try:
|
| 54 |
+
res = client.complete_json("extract", SYSTEM, user, ExtractionResult,
|
| 55 |
+
label="extract", max_tokens=12000)
|
| 56 |
+
except Exception as e: # noqa: BLE001
|
| 57 |
+
log.warning("extract failed for %s: %s", row["id"], e)
|
| 58 |
+
continue
|
| 59 |
+
n_models, n_rows = bm.store_extraction(conn, row["id"], res.models, res.benchmarks)
|
| 60 |
+
done += 1
|
| 61 |
+
log.debug("%s -> %d variants, %d benchmark rows", row["id"], n_models, n_rows)
|
| 62 |
+
conn.commit()
|
| 63 |
+
log.info("extracted benchmarks from %d papers", done)
|
| 64 |
+
return done
|
src/wam/pipeline/fetch.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fetch + dedup stage.
|
| 2 |
+
|
| 3 |
+
Gathers candidates from all enabled sources, enriches them (Semantic Scholar citations),
|
| 4 |
+
dedups against what's already in the DB, persists the new ones, and updates enrichment on
|
| 5 |
+
all seen ones. PwC code-link lookups are deferred to the post-filter shortlist (Phase 3) to
|
| 6 |
+
keep this stage cheap. Returns the list of *newly inserted* records.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import sqlite3
|
| 12 |
+
|
| 13 |
+
from wam.config import Config
|
| 14 |
+
from wam.logging import get_logger
|
| 15 |
+
from wam.models import PaperRecord
|
| 16 |
+
from wam.sources import arxiv, news, semantic_scholar
|
| 17 |
+
from wam.store import papers as paper_store
|
| 18 |
+
|
| 19 |
+
log = get_logger("pipeline.fetch")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def gather(cfg: Config) -> list[PaperRecord]:
|
| 23 |
+
"""Pull + enrich candidates from all sources (no DB writes). Deduped by id."""
|
| 24 |
+
records: dict[str, PaperRecord] = {}
|
| 25 |
+
for rec in arxiv.fetch(cfg):
|
| 26 |
+
records.setdefault(rec.id, rec)
|
| 27 |
+
for rec in news.fetch(cfg):
|
| 28 |
+
records.setdefault(rec.id, rec)
|
| 29 |
+
candidates = list(records.values())
|
| 30 |
+
log.info("gathered %d unique candidates across sources", len(candidates))
|
| 31 |
+
semantic_scholar.enrich(cfg, [r for r in candidates if r.source != "news"])
|
| 32 |
+
return candidates
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def persist(conn: sqlite3.Connection, candidates: list[PaperRecord]) -> list[PaperRecord]:
|
| 36 |
+
"""Insert new records, refresh enrichment on existing. Returns the new ones."""
|
| 37 |
+
seen = paper_store.existing_ids(conn)
|
| 38 |
+
new = [r for r in candidates if r.id not in seen]
|
| 39 |
+
for rec in candidates:
|
| 40 |
+
if rec.id in seen:
|
| 41 |
+
paper_store.update_enrichment(conn, rec)
|
| 42 |
+
else:
|
| 43 |
+
paper_store.insert_new(conn, rec)
|
| 44 |
+
conn.commit()
|
| 45 |
+
log.info("persisted: %d new, %d already known", len(new), len(candidates) - len(new))
|
| 46 |
+
return new
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def run(cfg: Config, conn: sqlite3.Connection) -> list[PaperRecord]:
|
| 50 |
+
candidates = gather(cfg)
|
| 51 |
+
return persist(conn, candidates)
|
src/wam/pipeline/filter.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Relevance filter — the cheap-model gate.
|
| 2 |
+
|
| 3 |
+
Classifies each unfiltered paper into core / adjacent / drop and assigns a 0..1 relevance,
|
| 4 |
+
reading the research-interest profile so the call is personalized. News items bypass the LLM
|
| 5 |
+
(tagged ``news``). A failed call stores relevance=-1 so it retries next run. The DB `track`
|
| 6 |
+
column is the cache: filtered papers are never re-classified.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import sqlite3
|
| 12 |
+
|
| 13 |
+
from wam.config import Config
|
| 14 |
+
from wam.llm import LLMClient
|
| 15 |
+
from wam.logging import COST, get_logger
|
| 16 |
+
from wam.pipeline.schemas import RelevanceVerdict
|
| 17 |
+
from wam.store import papers as ps
|
| 18 |
+
|
| 19 |
+
log = get_logger("pipeline.filter")
|
| 20 |
+
|
| 21 |
+
SYSTEM = ("You triage papers for a World Action Models (WAM) intelligence digest. Use the "
|
| 22 |
+
"profile below to decide the track. Be decisive but inclusive of adjacent work with "
|
| 23 |
+
"transferable techniques. Write the reason in English.\n\n--- PROFILE ---\n{profile}")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def run(cfg: Config, client: LLMClient, conn: sqlite3.Connection,
|
| 27 |
+
limit: int | None = None) -> dict[str, int]:
|
| 28 |
+
profile = cfg.profile_text()
|
| 29 |
+
system = SYSTEM.format(profile=profile)
|
| 30 |
+
threshold = float(cfg.get("constants.relevance_threshold", 0.5))
|
| 31 |
+
counts = {"core": 0, "adjacent": 0, "drop": 0, "news": 0, "error": 0}
|
| 32 |
+
|
| 33 |
+
todo = ps.needs_filter(conn, limit=limit)
|
| 34 |
+
log.info("filtering %d papers (threshold=%.2f)", len(todo), threshold)
|
| 35 |
+
for i, row in enumerate(todo):
|
| 36 |
+
if i and i % 25 == 0:
|
| 37 |
+
conn.commit() # checkpoint so a long run doesn't lose progress on crash
|
| 38 |
+
log.info("filter progress: %d/%d", i, len(todo))
|
| 39 |
+
if row["source"] == "news":
|
| 40 |
+
ps.set_filter(conn, row["id"], "news", 1.0, "news item")
|
| 41 |
+
counts["news"] += 1
|
| 42 |
+
continue
|
| 43 |
+
user = f"Title: {row['title']}\n\nAbstract: {row['abstract'] or '(none)'}"
|
| 44 |
+
try:
|
| 45 |
+
v = client.complete_json("cheap", system, user, RelevanceVerdict,
|
| 46 |
+
label="filter", max_tokens=1500)
|
| 47 |
+
except Exception as e: # noqa: BLE001
|
| 48 |
+
log.warning("filter failed for %s: %s", row["id"], e)
|
| 49 |
+
ps._touch(conn, row["id"], relevance=-1)
|
| 50 |
+
counts["error"] += 1
|
| 51 |
+
continue
|
| 52 |
+
track = "drop" if (v.track == "drop" or v.relevance < threshold) else v.track
|
| 53 |
+
ps.set_filter(conn, row["id"], track, v.relevance, v.reason)
|
| 54 |
+
counts[track] += 1
|
| 55 |
+
conn.commit()
|
| 56 |
+
log.info("filter result: %s", counts)
|
| 57 |
+
return counts
|
src/wam/pipeline/innovation.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Innovation stage — for adjacent-track papers (VLA / world model / video gen).
|
| 2 |
+
|
| 3 |
+
No rubric scoring: just the key technical idea and why it could transfer to WAM (mid tier).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import sqlite3
|
| 9 |
+
|
| 10 |
+
from wam.config import Config
|
| 11 |
+
from wam.llm import LLMClient
|
| 12 |
+
from wam.logging import get_logger
|
| 13 |
+
from wam.pipeline.schemas import InnovationNote
|
| 14 |
+
from wam.store import papers as ps
|
| 15 |
+
|
| 16 |
+
log = get_logger("pipeline.innovation")
|
| 17 |
+
|
| 18 |
+
SYSTEM = ("This paper is adjacent to World Action Models (it is VLA / a world model / video "
|
| 19 |
+
"generation, not a WAM itself). Identify its core technical innovation and explain "
|
| 20 |
+
"concretely why/how it could transfer to World Action Models. Always write in "
|
| 21 |
+
"English, regardless of the paper's language.")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def run(cfg: Config, client: LLMClient, conn: sqlite3.Connection,
|
| 25 |
+
limit: int | None = None) -> int:
|
| 26 |
+
todo = ps.needs_innovation(conn, limit=limit)
|
| 27 |
+
log.info("extracting innovation notes for %d adjacent papers", len(todo))
|
| 28 |
+
done = 0
|
| 29 |
+
for row in todo:
|
| 30 |
+
user = f"Title: {row['title']}\n\nAbstract: {row['abstract'] or '(none)'}"
|
| 31 |
+
try:
|
| 32 |
+
note = client.complete_json("innovation", SYSTEM, user, InnovationNote,
|
| 33 |
+
label="innovation", max_tokens=2000)
|
| 34 |
+
except Exception as e: # noqa: BLE001
|
| 35 |
+
log.warning("innovation failed for %s: %s", row["id"], e)
|
| 36 |
+
continue
|
| 37 |
+
ps.set_innovation(conn, row["id"], note.model_dump_json())
|
| 38 |
+
done += 1
|
| 39 |
+
if done % 20 == 0:
|
| 40 |
+
conn.commit()
|
| 41 |
+
conn.commit()
|
| 42 |
+
log.info("innovation notes for %d papers", done)
|
| 43 |
+
return done
|
src/wam/pipeline/links.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Links enrichment — find each paper's GitHub / Hugging Face repo.
|
| 2 |
+
|
| 3 |
+
Repo URLs are almost always in the PDF body ("Code is available at https://github.com/…"),
|
| 4 |
+
not the abstract — so we scan the (cached) full text. A keyword-proximity heuristic picks the
|
| 5 |
+
authors' own repo over baselines/comparisons. Found URLs are written into ``links_json`` as
|
| 6 |
+
``code`` (GitHub) and ``hf`` (Hugging Face); the README and email then surface them.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import re
|
| 13 |
+
import sqlite3
|
| 14 |
+
|
| 15 |
+
from wam.config import Config
|
| 16 |
+
from wam.logging import get_logger
|
| 17 |
+
from wam.sources import pdf
|
| 18 |
+
|
| 19 |
+
log = get_logger("pipeline.links")
|
| 20 |
+
|
| 21 |
+
_GH = re.compile(r"https?://github\.com/[A-Za-z0-9_.\-]+/[A-Za-z0-9_.\-]+")
|
| 22 |
+
_HF = re.compile(r"https?://huggingface\.co/[A-Za-z0-9_.\-/]+")
|
| 23 |
+
# repos that are almost always dependencies/baselines, not the paper's own.
|
| 24 |
+
_GH_DENY = ("github.com/huggingface", "github.com/pytorch", "github.com/openai",
|
| 25 |
+
"github.com/google", "github.com/facebookresearch/detectron")
|
| 26 |
+
_KEYWORDS = ("code", "project", "available", "github", "hugging", "weights", "released",
|
| 27 |
+
"release", "repo", "page", "checkpoint")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _clean(url: str) -> str:
|
| 31 |
+
return url.rstrip(").,;:'\"]}").rstrip("/")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _best(text: str, pattern: re.Pattern, deny: tuple = ()) -> str | None:
|
| 35 |
+
cands = [(m.start(), _clean(m.group(0))) for m in pattern.finditer(text)]
|
| 36 |
+
cands = [(p, u) for p, u in cands if not any(d in u.lower() for d in deny)]
|
| 37 |
+
if not cands:
|
| 38 |
+
return None
|
| 39 |
+
low = text.lower()
|
| 40 |
+
for pos, url in cands: # prefer a URL sitting right after a "code/project/…" cue
|
| 41 |
+
if any(k in low[max(0, pos - 60):pos] for k in _KEYWORDS):
|
| 42 |
+
return url
|
| 43 |
+
return cands[0][1]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def run(cfg: Config, conn: sqlite3.Connection, limit: int | None = None,
|
| 47 |
+
download: bool = True) -> int:
|
| 48 |
+
q = ("SELECT id, abstract, links_json FROM papers WHERE track IN ('core','adjacent')")
|
| 49 |
+
if limit:
|
| 50 |
+
q += f" LIMIT {int(limit)}"
|
| 51 |
+
rows = conn.execute(q).fetchall()
|
| 52 |
+
log.info("links: scanning %d papers (download=%s)", len(rows), download)
|
| 53 |
+
found = 0
|
| 54 |
+
for i, r in enumerate(rows):
|
| 55 |
+
links = json.loads(r["links_json"] or "{}")
|
| 56 |
+
if links.get("code") and links.get("hf"):
|
| 57 |
+
continue
|
| 58 |
+
pdf_url = links.get("pdf") or (f"https://arxiv.org/pdf/{r['id'].split(':',1)[1]}"
|
| 59 |
+
if r["id"].startswith("arxiv:") else "")
|
| 60 |
+
text = pdf.get_text(cfg, r["id"], pdf_url if download else "", max_chars=60000)
|
| 61 |
+
blob = (text or "") + "\n" + (r["abstract"] or "")
|
| 62 |
+
if not blob.strip():
|
| 63 |
+
continue
|
| 64 |
+
changed = False
|
| 65 |
+
if not links.get("code"):
|
| 66 |
+
gh = _best(blob, _GH, _GH_DENY)
|
| 67 |
+
if gh:
|
| 68 |
+
links["code"] = gh
|
| 69 |
+
changed = True
|
| 70 |
+
if not links.get("hf"):
|
| 71 |
+
hf = _best(blob, _HF)
|
| 72 |
+
if hf:
|
| 73 |
+
links["hf"] = hf
|
| 74 |
+
changed = True
|
| 75 |
+
if changed:
|
| 76 |
+
conn.execute("UPDATE papers SET links_json=? WHERE id=?",
|
| 77 |
+
(json.dumps(links), r["id"]))
|
| 78 |
+
found += 1
|
| 79 |
+
if i and i % 25 == 0:
|
| 80 |
+
conn.commit()
|
| 81 |
+
log.info("links progress: %d/%d (found %d)", i, len(rows), found)
|
| 82 |
+
conn.commit()
|
| 83 |
+
log.info("links: enriched %d papers with code/HF", found)
|
| 84 |
+
return found
|
src/wam/pipeline/people.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""People stage — influential authors + research groups.
|
| 2 |
+
|
| 3 |
+
Identity comes from **Semantic Scholar author IDs** attached to each paper (reliable; avoids
|
| 4 |
+
name collisions), not name search. Influential authors (on >= N tracked papers) get an
|
| 5 |
+
LLM-summarized research direction. **Groups are co-authorship clusters**: influential authors
|
| 6 |
+
who publish together form a lab/group (connected components of the co-authorship graph) —
|
| 7 |
+
this works even though S2's affiliation data is sparse.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import sqlite3
|
| 14 |
+
from collections import defaultdict
|
| 15 |
+
|
| 16 |
+
from pydantic import BaseModel, Field
|
| 17 |
+
|
| 18 |
+
from wam.config import Config
|
| 19 |
+
from wam.llm import LLMClient
|
| 20 |
+
from wam.logging import get_logger
|
| 21 |
+
from wam.sources import semantic_scholar as s2
|
| 22 |
+
from wam.store import people as store
|
| 23 |
+
from wam.store.people import slug
|
| 24 |
+
|
| 25 |
+
log = get_logger("pipeline.people")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class Directions(BaseModel):
|
| 29 |
+
directions: str = Field(description="1-2 sentences on this author's main WAM-related "
|
| 30 |
+
"research directions, grounded in the listed papers")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def run(cfg: Config, client: LLMClient, conn: sqlite3.Connection,
|
| 34 |
+
limit: int | None = None) -> dict[str, int]:
|
| 35 |
+
min_papers = int(cfg.get("people.min_papers", 2))
|
| 36 |
+
cap = limit or int(cfg.get("people.max_per_run", 30))
|
| 37 |
+
|
| 38 |
+
rows = conn.execute(
|
| 39 |
+
"SELECT id, title, summary_json FROM papers WHERE track IN ('core','adjacent')").fetchall()
|
| 40 |
+
meta = {r["id"]: {"title": r["title"],
|
| 41 |
+
"tldr": (json.loads(r["summary_json"] or "{}").get("tldr", "")
|
| 42 |
+
if r["summary_json"] else "")} for r in rows}
|
| 43 |
+
arxiv_ids = [r["id"].split("arxiv:", 1)[1] for r in rows if r["id"].startswith("arxiv:")]
|
| 44 |
+
authors_by_paper = s2.fetch_authors(cfg, arxiv_ids)
|
| 45 |
+
log.info("got authors for %d papers", len(authors_by_paper))
|
| 46 |
+
if not authors_by_paper:
|
| 47 |
+
# Don't wipe the existing registry on a transient S2 failure (e.g. 429).
|
| 48 |
+
log.warning("no author data from S2 (rate-limited?); keeping existing registry")
|
| 49 |
+
return {"authors": 0, "groups": 0}
|
| 50 |
+
|
| 51 |
+
# Aggregate by S2 authorId.
|
| 52 |
+
agg: dict[str, dict] = {}
|
| 53 |
+
for pid, alist in authors_by_paper.items():
|
| 54 |
+
for a in alist:
|
| 55 |
+
aid = a.get("authorId")
|
| 56 |
+
if not aid:
|
| 57 |
+
continue
|
| 58 |
+
e = agg.setdefault(aid, {"name": a.get("name") or "?", "pids": set(),
|
| 59 |
+
"affiliation": None, "h_index": a.get("hIndex"),
|
| 60 |
+
"citations": a.get("citationCount")})
|
| 61 |
+
e["pids"].add(pid)
|
| 62 |
+
affs = a.get("affiliations") or []
|
| 63 |
+
if affs and not e["affiliation"]:
|
| 64 |
+
e["affiliation"] = affs[0]
|
| 65 |
+
|
| 66 |
+
influential = {aid: e for aid, e in agg.items() if len(e["pids"]) >= min_papers}
|
| 67 |
+
ranked = sorted(influential, key=lambda x: -len(influential[x]["pids"]))[:cap]
|
| 68 |
+
log.info("%d distinct S2 authors; %d influential (>=%d papers); processing top %d",
|
| 69 |
+
len(agg), len(influential), min_papers, len(ranked))
|
| 70 |
+
|
| 71 |
+
# Fresh rebuild each run.
|
| 72 |
+
conn.execute("DELETE FROM authors")
|
| 73 |
+
conn.execute("DELETE FROM groups")
|
| 74 |
+
|
| 75 |
+
kept = set(ranked)
|
| 76 |
+
for aid in ranked:
|
| 77 |
+
e = influential[aid]
|
| 78 |
+
titles = "\n".join(f"- {meta[p]['title']}: {meta[p]['tldr']}" for p in e["pids"]
|
| 79 |
+
if p in meta)
|
| 80 |
+
try:
|
| 81 |
+
directions = client.complete_json(
|
| 82 |
+
"cheap", "Summarize an author's research directions for a WAM digest. Write "
|
| 83 |
+
"in English.", f"Author: {e['name']}\nTracked papers:\n{titles}", Directions,
|
| 84 |
+
label="author-directions", max_tokens=2000).directions
|
| 85 |
+
except Exception as ex: # noqa: BLE001
|
| 86 |
+
log.warning("directions failed for %s: %s", e["name"], ex)
|
| 87 |
+
directions = None
|
| 88 |
+
store.upsert_author(
|
| 89 |
+
conn, author_id=aid, name=e["name"], affiliation=e["affiliation"],
|
| 90 |
+
s2_url=f"https://www.semanticscholar.org/author/{aid}", citations=e["citations"],
|
| 91 |
+
h_index=e["h_index"], paper_ids=sorted(e["pids"]), directions=directions)
|
| 92 |
+
conn.commit()
|
| 93 |
+
|
| 94 |
+
# Groups = co-authorship clusters among the influential authors.
|
| 95 |
+
import networkx as nx
|
| 96 |
+
g = nx.Graph()
|
| 97 |
+
g.add_nodes_from(kept)
|
| 98 |
+
for pid, alist in authors_by_paper.items():
|
| 99 |
+
ids = [a["authorId"] for a in alist if a.get("authorId") in kept]
|
| 100 |
+
for i in range(len(ids)):
|
| 101 |
+
for j in range(i + 1, len(ids)):
|
| 102 |
+
g.add_edge(ids[i], ids[j])
|
| 103 |
+
n_grp = 0
|
| 104 |
+
for comp in nx.connected_components(g):
|
| 105 |
+
members = [m for m in comp]
|
| 106 |
+
if len(members) < 2:
|
| 107 |
+
continue
|
| 108 |
+
lead = max(members, key=lambda m: len(influential[m]["pids"]))
|
| 109 |
+
aff = next((influential[m]["affiliation"] for m in members
|
| 110 |
+
if influential[m]["affiliation"]), None)
|
| 111 |
+
name = f"{influential[lead]['name']} group" + (f" · {aff}" if aff else "")
|
| 112 |
+
notable = sorted({p for m in members for p in influential[m]["pids"]})[:8]
|
| 113 |
+
store.upsert_group(conn, group_id=slug(name), name=name, affiliation=aff,
|
| 114 |
+
member_ids=members, directions=None, notable=notable)
|
| 115 |
+
n_grp += 1
|
| 116 |
+
conn.commit()
|
| 117 |
+
log.info("stored %d authors, %d co-authorship groups", len(ranked), n_grp)
|
| 118 |
+
return {"authors": len(ranked), "groups": n_grp}
|
src/wam/pipeline/schemas.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic schemas for LLM pipeline outputs (Phase 3+).
|
| 2 |
+
|
| 3 |
+
Each stage forces the model to return one of these; the LLM client validates and retries on
|
| 4 |
+
malformed output. ``"N/A"`` is allowed for any WAM metric the paper does not address so we
|
| 5 |
+
never penalize with a fake 0.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Literal
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel, Field
|
| 13 |
+
|
| 14 |
+
Track = Literal["core", "adjacent", "drop"]
|
| 15 |
+
# Scores are 0-10 ints, or the string "N/A" when unaddressed.
|
| 16 |
+
Score = int | Literal["N/A"]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class RelevanceVerdict(BaseModel):
|
| 20 |
+
track: Track = Field(description="core = is a WAM; adjacent = VLA/world-model/video-gen "
|
| 21 |
+
"with transferable ideas; drop = unrelated")
|
| 22 |
+
relevance: float = Field(ge=0.0, le=1.0, description="0..1 confidence this matters to WAM")
|
| 23 |
+
reason: str = Field(description="one sentence justification")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class PaperSummary(BaseModel):
|
| 27 |
+
tldr: str = Field(description="one-sentence takeaway")
|
| 28 |
+
problem: str
|
| 29 |
+
method: str
|
| 30 |
+
results: str
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class PaperAnalysis(BaseModel):
|
| 34 |
+
contributions: list[str] = Field(description="key contributions, most important first")
|
| 35 |
+
limitations: list[str]
|
| 36 |
+
wam_relevance: str = Field(description="why this matters for World Action Models")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class GeneralScores(BaseModel):
|
| 40 |
+
novelty: Score
|
| 41 |
+
soundness: Score
|
| 42 |
+
impact: Score
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class WAMScores(BaseModel):
|
| 46 |
+
generalist: Score
|
| 47 |
+
inference_speed: Score
|
| 48 |
+
specialist: Score
|
| 49 |
+
inference_cost: Score
|
| 50 |
+
trustworthiness: Score
|
| 51 |
+
collaborative: Score
|
| 52 |
+
controlled_generation: Score
|
| 53 |
+
other: Score
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class ScoreCard(BaseModel):
|
| 57 |
+
general: GeneralScores
|
| 58 |
+
wam: WAMScores
|
| 59 |
+
rationale: str = Field(description="brief justification grounded in the paper")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class InnovationNote(BaseModel):
|
| 63 |
+
key_idea: str = Field(description="the core technical innovation")
|
| 64 |
+
transferable_to_wam: str = Field(description="why/how it could transfer to World Action Models")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# --- benchmark extraction (Phase 3b) ----------------------------------------
|
| 68 |
+
class ModelVariant(BaseModel):
|
| 69 |
+
model_name: str = Field(description="model/system name as called in the paper")
|
| 70 |
+
training_dataset: str | None = Field(
|
| 71 |
+
default=None, description="dataset(s) it was trained/finetuned on — part of its "
|
| 72 |
+
"identity; same name on a different dataset is a different system")
|
| 73 |
+
base_model: str | None = None
|
| 74 |
+
params: str | None = Field(default=None, description="e.g. '7B', '300M'")
|
| 75 |
+
modality: str | None = None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class BenchmarkRow(BaseModel):
|
| 79 |
+
model_name: str
|
| 80 |
+
training_dataset: str | None = None
|
| 81 |
+
benchmark: str = Field(description="benchmark/dataset the result is on")
|
| 82 |
+
task: str | None = None
|
| 83 |
+
split: str | None = None
|
| 84 |
+
metric_name: str | None = Field(default=None, description="e.g. 'success rate', 'accuracy'")
|
| 85 |
+
metric_value: float | None = None
|
| 86 |
+
inference_speed: float | None = Field(default=None, description="numeric value if stated")
|
| 87 |
+
speed_unit: str | None = Field(default=None, description="e.g. 'Hz', 'fps', 'ms', 'tok/s'")
|
| 88 |
+
inference_cost: float | None = None
|
| 89 |
+
cost_unit: str | None = Field(default=None, description="e.g. 'GPU-hours', 'FLOPs', '$'")
|
| 90 |
+
hardware: str | None = Field(default=None, description="hardware as reported, if any")
|
| 91 |
+
claimed_by_authors: bool = Field(
|
| 92 |
+
default=True, description="True if this is the paper's own reported number (vs a "
|
| 93 |
+
"third-party / comparison number quoted from another paper)")
|
| 94 |
+
notes: str | None = None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class ExtractionResult(BaseModel):
|
| 98 |
+
"""Only extract numbers ACTUALLY stated in the text. Empty lists are fine."""
|
| 99 |
+
models: list[ModelVariant] = Field(default_factory=list)
|
| 100 |
+
benchmarks: list[BenchmarkRow] = Field(default_factory=list)
|
src/wam/pipeline/score.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Score stage — two-layer WAM rubric for core papers (strong tier).
|
| 2 |
+
|
| 3 |
+
Produces ``scores_json = {general, wam, weighted_total, rationale}``. The weighted total is
|
| 4 |
+
computed over *available* (non-"N/A") metrics using config weights; the top-4 WAM metrics are
|
| 5 |
+
weighted 2x by default so they dominate. Capped at ``constants.analyze_cap`` per run.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import sqlite3
|
| 12 |
+
|
| 13 |
+
from wam.config import Config
|
| 14 |
+
from wam.llm import LLMClient
|
| 15 |
+
from wam.logging import get_logger
|
| 16 |
+
from wam.pipeline.schemas import ScoreCard
|
| 17 |
+
from wam.store import papers as ps
|
| 18 |
+
|
| 19 |
+
log = get_logger("pipeline.score")
|
| 20 |
+
|
| 21 |
+
SYSTEM = ("Score the paper on the two-layer WAM rubric. Use the profile's definitions and "
|
| 22 |
+
"scoring guidance. Output 0-10 per metric, or \"N/A\" when the paper does not address "
|
| 23 |
+
"a metric (do NOT guess). Be skeptical of self-reported numbers. Write the "
|
| 24 |
+
"rationale in English.\n\n"
|
| 25 |
+
"--- PROFILE ---\n{profile}")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def weighted_total(card: ScoreCard, cfg: Config) -> float:
|
| 29 |
+
gw = cfg.get("scoring.general_weights", {}) or {}
|
| 30 |
+
ww = cfg.get("scoring.wam_weights", {}) or {}
|
| 31 |
+
num = den = 0.0
|
| 32 |
+
for metric, weight in gw.items():
|
| 33 |
+
v = getattr(card.general, metric, "N/A")
|
| 34 |
+
if isinstance(v, int):
|
| 35 |
+
num += weight * v
|
| 36 |
+
den += weight
|
| 37 |
+
for metric, weight in ww.items():
|
| 38 |
+
v = getattr(card.wam, metric, "N/A")
|
| 39 |
+
if isinstance(v, int):
|
| 40 |
+
num += weight * v
|
| 41 |
+
den += weight
|
| 42 |
+
return round(num / den, 2) if den else 0.0
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def run(cfg: Config, client: LLMClient, conn: sqlite3.Connection,
|
| 46 |
+
limit: int | None = None) -> int:
|
| 47 |
+
cap = limit or int(cfg.get("constants.analyze_cap", 40))
|
| 48 |
+
system = SYSTEM.format(profile=cfg.profile_text())
|
| 49 |
+
todo = ps.needs_score(conn, limit=cap)
|
| 50 |
+
log.info("scoring %d analyzed papers (cap=%d)", len(todo), cap)
|
| 51 |
+
done = 0
|
| 52 |
+
for row in todo:
|
| 53 |
+
analysis = conn.execute("SELECT summary_json, analysis_json FROM papers WHERE id=?",
|
| 54 |
+
(row["id"],)).fetchone()
|
| 55 |
+
ctx = (f"Title: {row['title']}\n\nAbstract: {row['abstract'] or '(none)'}\n\n"
|
| 56 |
+
f"Summary: {analysis['summary_json']}\n\nAnalysis: {analysis['analysis_json']}")
|
| 57 |
+
try:
|
| 58 |
+
card = client.complete_json("score", system, ctx, ScoreCard,
|
| 59 |
+
label="score", max_tokens=5000)
|
| 60 |
+
except Exception as e: # noqa: BLE001
|
| 61 |
+
log.warning("score failed for %s: %s", row["id"], e)
|
| 62 |
+
continue
|
| 63 |
+
payload = card.model_dump()
|
| 64 |
+
payload["weighted_total"] = weighted_total(card, cfg)
|
| 65 |
+
ps.set_scores(conn, row["id"], json.dumps(payload))
|
| 66 |
+
done += 1
|
| 67 |
+
if done % 10 == 0:
|
| 68 |
+
conn.commit()
|
| 69 |
+
conn.commit()
|
| 70 |
+
log.info("scored %d papers", done)
|
| 71 |
+
return done
|
src/wam/pipeline/summarize.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Summarize stage — structured TL;DR for core + adjacent papers (cheap tier)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import sqlite3
|
| 6 |
+
|
| 7 |
+
from wam.config import Config
|
| 8 |
+
from wam.llm import LLMClient
|
| 9 |
+
from wam.logging import get_logger
|
| 10 |
+
from wam.pipeline.schemas import PaperSummary
|
| 11 |
+
from wam.store import papers as ps
|
| 12 |
+
|
| 13 |
+
log = get_logger("pipeline.summarize")
|
| 14 |
+
|
| 15 |
+
SYSTEM = ("Summarize the paper for a World Action Models research digest. Be faithful to the "
|
| 16 |
+
"abstract; do not invent results. Always write in English, regardless of the "
|
| 17 |
+
"paper's language.")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def run(cfg: Config, client: LLMClient, conn: sqlite3.Connection,
|
| 21 |
+
limit: int | None = None) -> int:
|
| 22 |
+
todo = ps.needs_summary(conn, limit=limit)
|
| 23 |
+
log.info("summarizing %d papers", len(todo))
|
| 24 |
+
done = 0
|
| 25 |
+
for row in todo:
|
| 26 |
+
user = f"Title: {row['title']}\n\nAbstract: {row['abstract'] or '(none)'}"
|
| 27 |
+
try:
|
| 28 |
+
s = client.complete_json("summarize", SYSTEM, user, PaperSummary,
|
| 29 |
+
label="summarize", max_tokens=2000)
|
| 30 |
+
except Exception as e: # noqa: BLE001
|
| 31 |
+
log.warning("summarize failed for %s: %s", row["id"], e)
|
| 32 |
+
continue
|
| 33 |
+
ps.set_summary(conn, row["id"], s.model_dump_json())
|
| 34 |
+
done += 1
|
| 35 |
+
if done % 20 == 0:
|
| 36 |
+
conn.commit()
|
| 37 |
+
conn.commit()
|
| 38 |
+
log.info("summarized %d papers", done)
|
| 39 |
+
return done
|
src/wam/pipeline/trends.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Trends stage — popular research directions with momentum (long-context, no embeddings).
|
| 2 |
+
|
| 3 |
+
Feeds the full list of tracked papers (id + title + tldr) to a strong long-context model and
|
| 4 |
+
asks it to cluster them into research directions. Momentum (rising/steady/cooling) is then
|
| 5 |
+
computed in code from members' publication dates (recent vs prior window). Results land in
|
| 6 |
+
the ``fronts`` table and render into the README.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import sqlite3
|
| 13 |
+
from collections import Counter
|
| 14 |
+
from datetime import date, datetime, timedelta
|
| 15 |
+
|
| 16 |
+
from pydantic import BaseModel, Field
|
| 17 |
+
|
| 18 |
+
from wam.config import Config
|
| 19 |
+
from wam.llm import LLMClient
|
| 20 |
+
from wam.logging import get_logger
|
| 21 |
+
|
| 22 |
+
log = get_logger("pipeline.trends")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Direction(BaseModel):
|
| 26 |
+
name: str = Field(description="a 4-8 word research-direction name")
|
| 27 |
+
summary: str = Field(description="one sentence describing the direction")
|
| 28 |
+
members: list[int] = Field(description="indices (from the numbered list) of papers in it")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class TrendResult(BaseModel):
|
| 32 |
+
directions: list[Direction]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
SYSTEM = ("Cluster these World Action Models papers into coherent research directions "
|
| 36 |
+
"(aim for 6-12 directions). Every paper index should belong to exactly one "
|
| 37 |
+
"direction; put genuine outliers in a 'Miscellaneous' direction. Use the numbered "
|
| 38 |
+
"list; refer to papers only by their index. Write direction names and summaries in "
|
| 39 |
+
"English.")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _month(d: str | None) -> str:
|
| 43 |
+
return (d or "")[:7] or "unknown"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _momentum(dates: list[str], window: int, today: date) -> str:
|
| 47 |
+
def n_between(lo, hi):
|
| 48 |
+
c = 0
|
| 49 |
+
for d in dates:
|
| 50 |
+
if not d:
|
| 51 |
+
continue
|
| 52 |
+
try:
|
| 53 |
+
dd = date.fromisoformat(d)
|
| 54 |
+
except ValueError:
|
| 55 |
+
continue
|
| 56 |
+
if lo < dd <= hi:
|
| 57 |
+
c += 1
|
| 58 |
+
return c
|
| 59 |
+
recent = n_between(today - timedelta(days=window), today)
|
| 60 |
+
prior = n_between(today - timedelta(days=2 * window), today - timedelta(days=window))
|
| 61 |
+
if recent > prior * 1.3 and recent >= 2:
|
| 62 |
+
return "rising"
|
| 63 |
+
if recent < prior * 0.7:
|
| 64 |
+
return "cooling"
|
| 65 |
+
return "steady"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def run(cfg: Config, client: LLMClient, conn: sqlite3.Connection) -> int:
|
| 69 |
+
rows = conn.execute(
|
| 70 |
+
"SELECT id, title, published, summary_json FROM papers WHERE track IN ('core','adjacent') "
|
| 71 |
+
"ORDER BY published DESC").fetchall()
|
| 72 |
+
min_size = int(cfg.get("trends.min_front_size", 3))
|
| 73 |
+
if len(rows) < min_size:
|
| 74 |
+
log.info("not enough papers for trends (%d)", len(rows))
|
| 75 |
+
return 0
|
| 76 |
+
|
| 77 |
+
lines = []
|
| 78 |
+
for i, r in enumerate(rows):
|
| 79 |
+
tldr = json.loads(r["summary_json"] or "{}").get("tldr", "") if r["summary_json"] else ""
|
| 80 |
+
lines.append(f"{i}. {r['title']} — {tldr}")
|
| 81 |
+
log.info("clustering %d papers into directions (long-context)", len(rows))
|
| 82 |
+
# Clustering 200+ papers is a large-output task: use the fast, JSON-reliable cheap model
|
| 83 |
+
# (glm-5.1 was too slow here and truncated). Generous token ceiling for the index lists.
|
| 84 |
+
res = client.complete_json("cheap", SYSTEM, "\n".join(lines), TrendResult,
|
| 85 |
+
label="trends", max_tokens=12000)
|
| 86 |
+
log.info("model returned %d directions, member counts: %s", len(res.directions),
|
| 87 |
+
[len(d.members) for d in res.directions])
|
| 88 |
+
|
| 89 |
+
today = date.today()
|
| 90 |
+
window = int(cfg.get("trends.window_days", 30))
|
| 91 |
+
snapshot = today.isoformat()
|
| 92 |
+
conn.execute("DELETE FROM fronts WHERE snapshot_date=?", (snapshot,))
|
| 93 |
+
n = 0
|
| 94 |
+
for ci, d in enumerate(res.directions):
|
| 95 |
+
members = [rows[i] for i in d.members if 0 <= i < len(rows)]
|
| 96 |
+
if len(members) < min_size:
|
| 97 |
+
continue
|
| 98 |
+
dates = [m["published"] for m in members]
|
| 99 |
+
conn.execute(
|
| 100 |
+
"INSERT INTO fronts (front_id, snapshot_date, name, summary, member_ids_json, "
|
| 101 |
+
"size, momentum, volume_json) VALUES (?,?,?,?,?,?,?,?)",
|
| 102 |
+
(f"front-{ci}", snapshot, d.name, d.summary,
|
| 103 |
+
json.dumps([m["id"] for m in members]), len(members),
|
| 104 |
+
_momentum(dates, window, today), json.dumps(dict(Counter(_month(x) for x in dates)))))
|
| 105 |
+
n += 1
|
| 106 |
+
conn.commit()
|
| 107 |
+
log.info("stored %d research fronts (snapshot %s)", n, snapshot)
|
| 108 |
+
return n
|
src/wam/render/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Rendering: generate README, daily digests, and CSV exports from the SQLite store."""
|
src/wam/render/readme.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate the README (awesome list), a daily digest, and a benchmarks CSV export.
|
| 2 |
+
|
| 3 |
+
All outputs are derived from the SQLite store. Dropped papers stay in the DB/KB but are
|
| 4 |
+
excluded from these human-facing surfaces. A link-integrity check flags records missing the
|
| 5 |
+
required abstract link.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import csv
|
| 11 |
+
import json
|
| 12 |
+
import sqlite3
|
| 13 |
+
from datetime import date
|
| 14 |
+
|
| 15 |
+
from wam.config import Config
|
| 16 |
+
from wam.logging import get_logger
|
| 17 |
+
|
| 18 |
+
log = get_logger("render")
|
| 19 |
+
|
| 20 |
+
WAM_TOP4 = ["inference_speed", "generalist", "specialist", "inference_cost"]
|
| 21 |
+
TOP4_LABEL = {"inference_speed": "spd", "generalist": "gen", "specialist": "spec",
|
| 22 |
+
"inference_cost": "cost"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _clip(text: str, n: int) -> str:
|
| 26 |
+
"""Truncate at a word boundary with an ellipsis, never mid-word."""
|
| 27 |
+
text = (text or "").strip()
|
| 28 |
+
if len(text) <= n:
|
| 29 |
+
return text
|
| 30 |
+
cut = text[:n].rsplit(" ", 1)[0]
|
| 31 |
+
return cut.rstrip(".,;: ") + "…"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _links(links_json: str | None) -> dict:
|
| 35 |
+
try:
|
| 36 |
+
return json.loads(links_json or "{}")
|
| 37 |
+
except Exception: # noqa: BLE001
|
| 38 |
+
return {}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _link_md(links: dict) -> str:
|
| 42 |
+
parts = []
|
| 43 |
+
for key, label in (("abs", "abs"), ("pdf", "pdf"), ("project_page", "site"),
|
| 44 |
+
("code", "code"), ("doi", "doi")):
|
| 45 |
+
if links.get(key):
|
| 46 |
+
parts.append(f"[{label}]({links[key]})")
|
| 47 |
+
return " · ".join(parts) or "—"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _top4_badge(scores: dict) -> str:
|
| 51 |
+
wam = (scores or {}).get("wam", {})
|
| 52 |
+
cells = []
|
| 53 |
+
for m in WAM_TOP4:
|
| 54 |
+
v = wam.get(m)
|
| 55 |
+
cells.append(f"{TOP4_LABEL[m]} {v if isinstance(v, int) else '–'}")
|
| 56 |
+
return " · ".join(cells)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _fmt_authors(authors_json: str | None, n: int = 3) -> str:
|
| 60 |
+
a = json.loads(authors_json or "[]")
|
| 61 |
+
return ", ".join(a[:n]) + (" et al." if len(a) > n else "")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# --- sections ---------------------------------------------------------------
|
| 65 |
+
def _core_table(conn: sqlite3.Connection, limit: int = 50) -> str:
|
| 66 |
+
rows = conn.execute(
|
| 67 |
+
"SELECT id, title, published, links_json, scores_json FROM papers "
|
| 68 |
+
"WHERE track='core' AND scores_json IS NOT NULL "
|
| 69 |
+
"ORDER BY json_extract(scores_json,'$.weighted_total') DESC LIMIT ?", (limit,)
|
| 70 |
+
).fetchall()
|
| 71 |
+
if not rows:
|
| 72 |
+
return "_No scored papers yet._\n"
|
| 73 |
+
out = ["| Score | Paper | Published | Top-4 (spd·gen·spec·cost) | Links |",
|
| 74 |
+
"|------:|-------|-----------|---------------------------|-------|"]
|
| 75 |
+
for r in rows:
|
| 76 |
+
s = json.loads(r["scores_json"])
|
| 77 |
+
out.append(f"| **{s.get('weighted_total','?')}** | {r['title']} | "
|
| 78 |
+
f"{r['published'] or '—'} | {_top4_badge(s)} | {_link_md(_links(r['links_json']))} |")
|
| 79 |
+
return "\n".join(out) + "\n"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _leaderboard(conn: sqlite3.Connection, limit: int = 40) -> str:
|
| 83 |
+
rows = conn.execute(
|
| 84 |
+
"SELECT model_name, training_dataset, benchmark, task, metric_name, metric_value, "
|
| 85 |
+
"claimed_by_authors FROM benchmarks WHERE metric_value IS NOT NULL "
|
| 86 |
+
"ORDER BY benchmark, metric_value DESC LIMIT ?", (limit,)).fetchall()
|
| 87 |
+
if not rows:
|
| 88 |
+
return "_No benchmark results extracted yet._\n"
|
| 89 |
+
out = ["| Benchmark | Task | Model (training data) | Metric | Value | Source |",
|
| 90 |
+
"|-----------|------|-----------------------|--------|------:|:------:|"]
|
| 91 |
+
for r in rows:
|
| 92 |
+
td = f" _({r['training_dataset']})_" if r["training_dataset"] else ""
|
| 93 |
+
src = "authors" if r["claimed_by_authors"] else "3rd-party"
|
| 94 |
+
out.append(f"| {r['benchmark']} | {r['task'] or '—'} | {r['model_name']}{td} | "
|
| 95 |
+
f"{r['metric_name'] or '—'} | {r['metric_value']} | {src} |")
|
| 96 |
+
return "\n".join(out) + "\n"
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _innovation(conn: sqlite3.Connection, limit: int = 30) -> str:
|
| 100 |
+
rows = conn.execute(
|
| 101 |
+
"SELECT title, links_json, innovation_json FROM papers WHERE track='adjacent' "
|
| 102 |
+
"AND innovation_json IS NOT NULL ORDER BY relevance DESC LIMIT ?", (limit,)).fetchall()
|
| 103 |
+
if not rows:
|
| 104 |
+
return "_No adjacent-track innovations captured yet._\n"
|
| 105 |
+
out = []
|
| 106 |
+
for r in rows:
|
| 107 |
+
inv = json.loads(r["innovation_json"])
|
| 108 |
+
out.append(f"- **{r['title']}** — {_clip(inv.get('key_idea',''), 320)} "
|
| 109 |
+
f"_(→ WAM: {_clip(inv.get('transferable_to_wam',''), 260)})_ "
|
| 110 |
+
f"{_link_md(_links(r['links_json']))}")
|
| 111 |
+
return "\n".join(out) + "\n"
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _authors(conn: sqlite3.Connection, limit: int = 25) -> str:
|
| 115 |
+
rows = conn.execute(
|
| 116 |
+
"SELECT name, affiliation, citations, paper_ids_json, directions, s2_url FROM authors "
|
| 117 |
+
"ORDER BY json_array_length(paper_ids_json) DESC LIMIT ?", (limit,)).fetchall()
|
| 118 |
+
if not rows:
|
| 119 |
+
return "_No authors aggregated yet._\n"
|
| 120 |
+
out = []
|
| 121 |
+
for r in rows:
|
| 122 |
+
n = len(json.loads(r["paper_ids_json"] or "[]"))
|
| 123 |
+
name = f"[{r['name']}]({r['s2_url']})" if r["s2_url"] else r["name"]
|
| 124 |
+
aff = f" · {r['affiliation']}" if r["affiliation"] else ""
|
| 125 |
+
out.append(f"- **{name}** ({n} papers{aff}) — {_clip(r['directions'], 260)}")
|
| 126 |
+
return "\n".join(out) + "\n"
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _news(conn: sqlite3.Connection, limit: int = 15) -> str:
|
| 130 |
+
rows = conn.execute(
|
| 131 |
+
"SELECT title, authors_json, links_json FROM papers WHERE track='news' "
|
| 132 |
+
"ORDER BY published DESC LIMIT ?", (limit,)).fetchall()
|
| 133 |
+
if not rows:
|
| 134 |
+
return "_No news items yet._\n"
|
| 135 |
+
out = []
|
| 136 |
+
for r in rows:
|
| 137 |
+
outlet = (json.loads(r["authors_json"] or "[]") or ["—"])[0]
|
| 138 |
+
link = _links(r["links_json"]).get("abs", "")
|
| 139 |
+
out.append(f"- [{r['title']}]({link}) — _{outlet}_")
|
| 140 |
+
return "\n".join(out) + "\n"
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
_MOM_ICON = {"rising": "📈 rising", "cooling": "📉 cooling", "steady": "➡️ steady"}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _trends(conn: sqlite3.Connection, limit: int = 12) -> str:
|
| 147 |
+
snap = conn.execute("SELECT max(snapshot_date) FROM fronts").fetchone()[0]
|
| 148 |
+
if not snap:
|
| 149 |
+
return "_No trend snapshot yet._\n"
|
| 150 |
+
rows = conn.execute(
|
| 151 |
+
"SELECT name, summary, size, momentum FROM fronts WHERE snapshot_date=? "
|
| 152 |
+
"ORDER BY size DESC LIMIT ?", (snap, limit)).fetchall()
|
| 153 |
+
if not rows:
|
| 154 |
+
return "_No research fronts detected._\n"
|
| 155 |
+
out = ["| Direction | Papers | Momentum | Summary |", "|-----------|-------:|----------|---------|"]
|
| 156 |
+
for r in rows:
|
| 157 |
+
out.append(f"| **{r['name']}** | {r['size']} | {_MOM_ICON.get(r['momentum'], r['momentum'])} "
|
| 158 |
+
f"| {_clip(r['summary'], 120)} |")
|
| 159 |
+
return "\n".join(out) + "\n"
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _counts(conn: sqlite3.Connection) -> dict:
|
| 163 |
+
d = dict(conn.execute("SELECT track, count(*) FROM papers GROUP BY track").fetchall())
|
| 164 |
+
return {"core": d.get("core", 0), "adjacent": d.get("adjacent", 0),
|
| 165 |
+
"drop": d.get("drop", 0), "news": d.get("news", 0),
|
| 166 |
+
"benchmarks": conn.execute("SELECT count(*) FROM benchmarks").fetchone()[0],
|
| 167 |
+
"variants": conn.execute("SELECT count(*) FROM model_variants").fetchone()[0],
|
| 168 |
+
"authors": conn.execute("SELECT count(*) FROM authors").fetchone()[0]}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# --- public -----------------------------------------------------------------
|
| 172 |
+
def link_integrity(conn: sqlite3.Connection) -> list[str]:
|
| 173 |
+
issues = []
|
| 174 |
+
for r in conn.execute("SELECT id, links_json FROM papers WHERE track IN ('core','adjacent')"):
|
| 175 |
+
if not _links(r["links_json"]).get("abs"):
|
| 176 |
+
issues.append(f"{r['id']}: missing abstract link")
|
| 177 |
+
return issues
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def render_readme(cfg: Config, conn: sqlite3.Connection, today: str | None = None) -> str:
|
| 181 |
+
today = today or date.today().isoformat()
|
| 182 |
+
c = _counts(conn)
|
| 183 |
+
md = f"""# Awesome-WAM
|
| 184 |
+
|
| 185 |
+
> Daily-updated intelligence on **World Action Models** — world models, vision-language-action
|
| 186 |
+
> (VLA) models, action-conditioned video/world generation, robot foundation models, and
|
| 187 |
+
> embodied/physical AI. Auto-generated; do not edit by hand.
|
| 188 |
+
|
| 189 |
+
**Last updated:** {today} · **Tracked:** {c['core']} core · {c['adjacent']} adjacent ·
|
| 190 |
+
{c['news']} news · **{c['benchmarks']}** benchmark rows across **{c['variants']}** model
|
| 191 |
+
variants · **{c['authors']}** authors
|
| 192 |
+
|
| 193 |
+
> Scoring: two layers — general (novelty/soundness/impact) + WAM-specific. Top-4 WAM metrics
|
| 194 |
+
> (inference **speed**, **gen**eralist, **spec**ialist, inference **cost**) are weighted 2×.
|
| 195 |
+
> `–` means the paper does not address that metric (we never fabricate a score).
|
| 196 |
+
|
| 197 |
+
## 📈 Trends & Popular Directions
|
| 198 |
+
{_trends(conn)}
|
| 199 |
+
## 🏆 Top World Action Model Papers
|
| 200 |
+
{_core_table(conn)}
|
| 201 |
+
## 📊 Benchmark Leaderboard
|
| 202 |
+
_Model identity = (name, training dataset); the same name on different data is a distinct row.
|
| 203 |
+
Numbers are as reported; `authors` = self-reported, `3rd-party` = quoted comparison._
|
| 204 |
+
{_leaderboard(conn)}
|
| 205 |
+
## 🔬 Innovation Watch — adjacent fields (VLA / world models / video generation)
|
| 206 |
+
_Not scored; surfaced for techniques transferable to WAM._
|
| 207 |
+
{_innovation(conn)}
|
| 208 |
+
## 👥 Influential Authors & Groups
|
| 209 |
+
{_authors(conn)}
|
| 210 |
+
## 📰 Embodied / Physical-AI News
|
| 211 |
+
{_news(conn)}
|
| 212 |
+
---
|
| 213 |
+
_Generated by [Awesome-WAM](https://github.com/your-org/Awesome-WAM)._
|
| 214 |
+
"""
|
| 215 |
+
(cfg.root / "README.md").write_text(md, encoding="utf-8")
|
| 216 |
+
log.info("wrote README.md")
|
| 217 |
+
return md
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def render_digest(cfg: Config, conn: sqlite3.Connection, today: str | None = None) -> str:
|
| 221 |
+
today = today or date.today().isoformat()
|
| 222 |
+
new_core = conn.execute(
|
| 223 |
+
"SELECT count(*) FROM papers WHERE track='core' AND first_seen=?", (today,)).fetchone()[0]
|
| 224 |
+
new_adj = conn.execute(
|
| 225 |
+
"SELECT count(*) FROM papers WHERE track='adjacent' AND first_seen=?", (today,)).fetchone()[0]
|
| 226 |
+
rows = conn.execute(
|
| 227 |
+
"SELECT title, links_json, scores_json, summary_json FROM papers WHERE track='core' "
|
| 228 |
+
"AND first_seen=? AND scores_json IS NOT NULL "
|
| 229 |
+
"ORDER BY json_extract(scores_json,'$.weighted_total') DESC LIMIT 15", (today,)).fetchall()
|
| 230 |
+
lines = [f"# WAM Daily Digest — {today}\n",
|
| 231 |
+
f"**New today:** {new_core} core · {new_adj} adjacent papers\n",
|
| 232 |
+
"## Top new papers\n"]
|
| 233 |
+
if not rows:
|
| 234 |
+
lines.append("_No new scored core papers today._\n")
|
| 235 |
+
for r in rows:
|
| 236 |
+
s = json.loads(r["scores_json"])
|
| 237 |
+
tldr = json.loads(r["summary_json"] or "{}").get("tldr", "")
|
| 238 |
+
lines.append(f"### {r['title']} · **{s.get('weighted_total','?')}**\n"
|
| 239 |
+
f"{tldr}\n\n_{_top4_badge(s)}_ · {_link_md(_links(r['links_json']))}\n")
|
| 240 |
+
md = "\n".join(lines)
|
| 241 |
+
out = cfg.root / "data" / "digests"
|
| 242 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 243 |
+
(out / f"{today}.md").write_text(md, encoding="utf-8")
|
| 244 |
+
log.info("wrote digest %s.md", today)
|
| 245 |
+
return md
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def export_benchmarks_csv(cfg: Config, conn: sqlite3.Connection) -> None:
|
| 249 |
+
cols = ["variant_key", "model_name", "training_dataset", "benchmark", "task", "split",
|
| 250 |
+
"metric_name", "metric_value", "inference_speed", "speed_unit", "inference_cost",
|
| 251 |
+
"cost_unit", "hardware", "source_paper_id", "claimed_by_authors", "notes",
|
| 252 |
+
"extracted_on"]
|
| 253 |
+
rows = conn.execute(f"SELECT {','.join(cols)} FROM benchmarks ORDER BY benchmark, variant_key")
|
| 254 |
+
path = cfg.root / "data" / "benchmarks.csv"
|
| 255 |
+
with open(path, "w", newline="", encoding="utf-8") as f:
|
| 256 |
+
w = csv.writer(f)
|
| 257 |
+
w.writerow(cols)
|
| 258 |
+
w.writerows(rows)
|
| 259 |
+
log.info("exported %s", path)
|
src/wam/sources/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Paper/news source adapters. Each returns normalized ``PaperRecord`` objects."""
|
src/wam/sources/arxiv.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""arXiv source — the core candidate feed.
|
| 2 |
+
|
| 3 |
+
Queries the arXiv Atom API for recent papers in the configured categories matching a broad
|
| 4 |
+
keyword net (WAM + adjacent fields). The LLM filter makes the final core/adjacent/drop call
|
| 5 |
+
later; here we just cast a wide, recall-oriented net within the lookback window.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
from datetime import date, datetime, timedelta
|
| 12 |
+
|
| 13 |
+
import feedparser
|
| 14 |
+
import requests
|
| 15 |
+
|
| 16 |
+
from wam.config import Config
|
| 17 |
+
from wam.logging import get_logger
|
| 18 |
+
from wam.models import Links, PaperRecord
|
| 19 |
+
|
| 20 |
+
log = get_logger("source.arxiv")
|
| 21 |
+
|
| 22 |
+
API = "http://export.arxiv.org/api/query"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _build_query(categories: list[str], keywords: list[str]) -> str:
|
| 26 |
+
cats = " OR ".join(f"cat:{c}" for c in categories)
|
| 27 |
+
kws = " OR ".join(f'all:"{k}"' for k in keywords)
|
| 28 |
+
return f"({cats}) AND ({kws})"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _arxiv_id(entry) -> str:
|
| 32 |
+
# entry.id like http://arxiv.org/abs/2506.01234v2 -> 2506.01234
|
| 33 |
+
raw = entry.id.rsplit("/abs/", 1)[-1]
|
| 34 |
+
return raw.split("v")[0] if "v" in raw else raw
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def fetch(cfg: Config, *, max_results: int | None = None,
|
| 38 |
+
lookback_days: int | None = None) -> list[PaperRecord]:
|
| 39 |
+
scfg = cfg.get("sources.arxiv", {}) or {}
|
| 40 |
+
if not scfg.get("enabled", True):
|
| 41 |
+
return []
|
| 42 |
+
categories = scfg.get("categories", ["cs.RO", "cs.AI", "cs.LG", "cs.CV"])
|
| 43 |
+
keywords = (cfg.get("keywords.core", []) or []) + (cfg.get("keywords.adjacent", []) or [])
|
| 44 |
+
max_results = max_results or scfg.get("max_results", 300)
|
| 45 |
+
lookback_days = lookback_days or cfg.get("constants.lookback_days", 60)
|
| 46 |
+
cutoff = date.today() - timedelta(days=lookback_days)
|
| 47 |
+
|
| 48 |
+
params = {
|
| 49 |
+
"search_query": _build_query(categories, keywords),
|
| 50 |
+
"start": 0,
|
| 51 |
+
"max_results": max_results,
|
| 52 |
+
"sortBy": "submittedDate",
|
| 53 |
+
"sortOrder": "descending",
|
| 54 |
+
}
|
| 55 |
+
log.info("arxiv query: %s (max=%d, since=%s)", params["search_query"], max_results, cutoff)
|
| 56 |
+
resp = requests.get(API, params=params, timeout=cfg.get("constants.request_timeout", 90))
|
| 57 |
+
resp.raise_for_status()
|
| 58 |
+
feed = feedparser.parse(resp.text)
|
| 59 |
+
|
| 60 |
+
records: list[PaperRecord] = []
|
| 61 |
+
for e in feed.entries:
|
| 62 |
+
try:
|
| 63 |
+
published = datetime(*e.published_parsed[:6]).date()
|
| 64 |
+
except Exception: # noqa: BLE001
|
| 65 |
+
published = None
|
| 66 |
+
if published and published < cutoff:
|
| 67 |
+
continue
|
| 68 |
+
aid = _arxiv_id(e)
|
| 69 |
+
pdf = next((l.href for l in getattr(e, "links", []) if l.get("type") == "application/pdf"),
|
| 70 |
+
f"https://arxiv.org/pdf/{aid}")
|
| 71 |
+
records.append(PaperRecord(
|
| 72 |
+
id=f"arxiv:{aid}",
|
| 73 |
+
source="arxiv",
|
| 74 |
+
title=" ".join(e.title.split()),
|
| 75 |
+
authors=[a.name for a in getattr(e, "authors", [])],
|
| 76 |
+
published=published.isoformat() if published else None,
|
| 77 |
+
abstract=" ".join(e.summary.split()) if hasattr(e, "summary") else None,
|
| 78 |
+
categories=[t.term for t in getattr(e, "tags", [])],
|
| 79 |
+
links=Links(abs=f"https://arxiv.org/abs/{aid}", pdf=pdf),
|
| 80 |
+
))
|
| 81 |
+
log.info("arxiv returned %d entries, %d within lookback", len(feed.entries), len(records))
|
| 82 |
+
time.sleep(3) # be polite to arXiv
|
| 83 |
+
return records
|
src/wam/sources/news.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Embodied / physical-AI news via RSS.
|
| 2 |
+
|
| 3 |
+
News items are normalized into ``PaperRecord`` with ``source='news'`` so they flow through
|
| 4 |
+
the same store. They get a light summary later but not the full WAM rubric.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
from datetime import date, datetime, timedelta
|
| 11 |
+
|
| 12 |
+
import feedparser
|
| 13 |
+
|
| 14 |
+
from wam.config import Config
|
| 15 |
+
from wam.logging import get_logger
|
| 16 |
+
from wam.models import Links, PaperRecord
|
| 17 |
+
|
| 18 |
+
log = get_logger("source.news")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def fetch(cfg: Config, *, lookback_days: int | None = None) -> list[PaperRecord]:
|
| 22 |
+
ncfg = cfg.get("sources.news", {}) or {}
|
| 23 |
+
if not ncfg.get("enabled", True):
|
| 24 |
+
return []
|
| 25 |
+
feeds = ncfg.get("feeds", []) or []
|
| 26 |
+
lookback_days = lookback_days or cfg.get("constants.lookback_days", 60)
|
| 27 |
+
cutoff = date.today() - timedelta(days=lookback_days)
|
| 28 |
+
|
| 29 |
+
records: list[PaperRecord] = []
|
| 30 |
+
for url in feeds:
|
| 31 |
+
try:
|
| 32 |
+
feed = feedparser.parse(url)
|
| 33 |
+
except Exception as e: # noqa: BLE001
|
| 34 |
+
log.warning("news feed failed %s: %s", url, e)
|
| 35 |
+
continue
|
| 36 |
+
outlet = feed.feed.get("title", url) if hasattr(feed, "feed") else url
|
| 37 |
+
for e in feed.entries:
|
| 38 |
+
try:
|
| 39 |
+
published = datetime(*e.published_parsed[:6]).date()
|
| 40 |
+
except Exception: # noqa: BLE001
|
| 41 |
+
published = None
|
| 42 |
+
if published and published < cutoff:
|
| 43 |
+
continue
|
| 44 |
+
link = getattr(e, "link", "")
|
| 45 |
+
uid = hashlib.sha1(link.encode("utf-8")).hexdigest()[:16]
|
| 46 |
+
summary = getattr(e, "summary", "") or ""
|
| 47 |
+
records.append(PaperRecord(
|
| 48 |
+
id=f"news:{uid}",
|
| 49 |
+
source="news",
|
| 50 |
+
title=" ".join(getattr(e, "title", "(untitled)").split()),
|
| 51 |
+
authors=[outlet],
|
| 52 |
+
published=published.isoformat() if published else None,
|
| 53 |
+
abstract=" ".join(summary.split())[:2000],
|
| 54 |
+
categories=["news"],
|
| 55 |
+
links=Links(abs=link),
|
| 56 |
+
))
|
| 57 |
+
log.info("news: %d items within lookback from %d feeds", len(records), len(feeds))
|
| 58 |
+
return records
|
src/wam/sources/papers_with_code.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Papers with Code enrichment — has-code / repo link signal.
|
| 2 |
+
|
| 3 |
+
Best-effort lookup by arXiv id. This is one HTTP call per paper, so callers should pass only
|
| 4 |
+
the shortlist (e.g. post-filter core/adjacent papers) and respect ``max_lookups``.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import time
|
| 10 |
+
|
| 11 |
+
import requests
|
| 12 |
+
|
| 13 |
+
from wam.config import Config
|
| 14 |
+
from wam.logging import get_logger
|
| 15 |
+
from wam.models import PaperRecord
|
| 16 |
+
|
| 17 |
+
log = get_logger("source.pwc")
|
| 18 |
+
|
| 19 |
+
API = "https://paperswithcode.com/api/v1/papers/"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _repo_for_arxiv(aid: str, timeout: int) -> str | None:
|
| 23 |
+
try:
|
| 24 |
+
r = requests.get(API, params={"arxiv_id": aid}, timeout=timeout)
|
| 25 |
+
r.raise_for_status()
|
| 26 |
+
results = r.json().get("results") or []
|
| 27 |
+
if not results:
|
| 28 |
+
return None
|
| 29 |
+
pid = results[0]["id"]
|
| 30 |
+
rr = requests.get(f"{API}{pid}/repositories/", timeout=timeout)
|
| 31 |
+
rr.raise_for_status()
|
| 32 |
+
repos = rr.json().get("results") or []
|
| 33 |
+
if not repos:
|
| 34 |
+
return None
|
| 35 |
+
# Prefer the official repo if flagged, else the most-starred.
|
| 36 |
+
repos.sort(key=lambda x: (x.get("is_official", False), x.get("stars", 0)), reverse=True)
|
| 37 |
+
return repos[0].get("url")
|
| 38 |
+
except Exception as e: # noqa: BLE001
|
| 39 |
+
log.debug("pwc lookup failed for %s: %s", aid, e)
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def enrich(cfg: Config, records: list[PaperRecord], *, max_lookups: int = 60) -> list[PaperRecord]:
|
| 44 |
+
if not (cfg.get("sources.papers_with_code", {}) or {}).get("enabled", True):
|
| 45 |
+
return records
|
| 46 |
+
timeout = cfg.get("constants.request_timeout", 90)
|
| 47 |
+
found = 0
|
| 48 |
+
for rec in records[:max_lookups]:
|
| 49 |
+
if not rec.arxiv_id or rec.links.code:
|
| 50 |
+
continue
|
| 51 |
+
url = _repo_for_arxiv(rec.arxiv_id, timeout)
|
| 52 |
+
if url:
|
| 53 |
+
rec.links.code = url
|
| 54 |
+
rec.has_code = True
|
| 55 |
+
found += 1
|
| 56 |
+
time.sleep(0.5)
|
| 57 |
+
log.info("pwc found code for %d papers", found)
|
| 58 |
+
return records
|
src/wam/sources/pdf.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PDF text extraction with on-disk caching.
|
| 2 |
+
|
| 3 |
+
Benchmark numbers live in the paper body (tables), not the abstract, so the extractor needs
|
| 4 |
+
full text. We download each PDF once, extract text with PyMuPDF, and cache it keyed by a
|
| 5 |
+
content hash so arXiv revisions are re-extracted but repeats are free.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import hashlib
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import requests
|
| 14 |
+
|
| 15 |
+
from wam.config import Config
|
| 16 |
+
from wam.logging import get_logger
|
| 17 |
+
|
| 18 |
+
log = get_logger("source.pdf")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _cache_dir(cfg: Config) -> Path:
|
| 22 |
+
d = cfg.path("pdf_cache")
|
| 23 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 24 |
+
return d
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_text(cfg: Config, paper_id: str, pdf_url: str, *, max_chars: int = 60000) -> str | None:
|
| 28 |
+
"""Return cached/extracted text for a paper (truncated to max_chars), or None on failure."""
|
| 29 |
+
safe = paper_id.replace("/", "_").replace(":", "_")
|
| 30 |
+
cache = _cache_dir(cfg) / f"{safe}.txt"
|
| 31 |
+
if cache.exists():
|
| 32 |
+
return cache.read_text(encoding="utf-8")[:max_chars]
|
| 33 |
+
if not pdf_url:
|
| 34 |
+
return None
|
| 35 |
+
try:
|
| 36 |
+
import fitz # PyMuPDF
|
| 37 |
+
except ImportError: # pragma: no cover
|
| 38 |
+
log.warning("PyMuPDF not installed; skipping PDF extraction")
|
| 39 |
+
return None
|
| 40 |
+
try:
|
| 41 |
+
resp = requests.get(pdf_url, timeout=cfg.get("constants.request_timeout", 90),
|
| 42 |
+
headers={"User-Agent": "Awesome-WAM/0.1"})
|
| 43 |
+
resp.raise_for_status()
|
| 44 |
+
with fitz.open(stream=resp.content, filetype="pdf") as doc:
|
| 45 |
+
text = "\n".join(page.get_text() for page in doc)
|
| 46 |
+
except Exception as e: # noqa: BLE001
|
| 47 |
+
log.warning("PDF extraction failed for %s: %s", paper_id, e)
|
| 48 |
+
return None
|
| 49 |
+
cache.write_text(text, encoding="utf-8")
|
| 50 |
+
h = hashlib.sha256(resp.content).hexdigest()
|
| 51 |
+
cache.with_suffix(".hash").write_text(h, encoding="utf-8")
|
| 52 |
+
log.debug("cached PDF text for %s (%d chars)", paper_id, len(text))
|
| 53 |
+
return text[:max_chars]
|
src/wam/sources/semantic_scholar.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Semantic Scholar enrichment.
|
| 2 |
+
|
| 3 |
+
Adds citation signals (total + influential) and a DOI/open-access PDF link to existing
|
| 4 |
+
records, keyed by arXiv id via the batch endpoint. Best-effort: failures leave records as-is.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
+
import requests
|
| 13 |
+
|
| 14 |
+
from wam.config import Config
|
| 15 |
+
from wam.logging import get_logger
|
| 16 |
+
from wam.models import PaperRecord
|
| 17 |
+
|
| 18 |
+
log = get_logger("source.s2")
|
| 19 |
+
|
| 20 |
+
BATCH = "https://api.semanticscholar.org/graph/v1/paper/batch"
|
| 21 |
+
FIELDS = "citationCount,influentialCitationCount,externalIds,openAccessPdf"
|
| 22 |
+
AUTHOR_SEARCH = "https://api.semanticscholar.org/graph/v1/author/search"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _headers(cfg: Config) -> dict:
|
| 26 |
+
scfg = cfg.get("sources.semantic_scholar", {}) or {}
|
| 27 |
+
key = os.environ.get(scfg.get("api_key_env", "SEMANTIC_SCHOLAR_API_KEY") or "")
|
| 28 |
+
return {"x-api-key": key} if key else {}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def fetch_authors(cfg: Config, arxiv_ids: list[str]) -> dict[str, list[dict]]:
|
| 32 |
+
"""Map arxiv_id -> list of authors with reliable S2 ids (authorId, name, affiliations,
|
| 33 |
+
hIndex, citationCount), via the batch paper endpoint. Best-effort; missing -> absent."""
|
| 34 |
+
if not arxiv_ids:
|
| 35 |
+
return {}
|
| 36 |
+
fields = "authors.authorId,authors.name,authors.affiliations,authors.hIndex,authors.citationCount"
|
| 37 |
+
out: dict[str, list[dict]] = {}
|
| 38 |
+
headers = _headers(cfg)
|
| 39 |
+
timeout = cfg.get("constants.request_timeout", 90)
|
| 40 |
+
# Small chunks + generous backoff because the keyless S2 limit is tight (429-prone).
|
| 41 |
+
for start in range(0, len(arxiv_ids), 100):
|
| 42 |
+
chunk = arxiv_ids[start:start + 100]
|
| 43 |
+
ids = [f"ARXIV:{a}" for a in chunk]
|
| 44 |
+
data = None
|
| 45 |
+
for attempt, wait in enumerate((0, 6, 15, 30)):
|
| 46 |
+
if wait:
|
| 47 |
+
time.sleep(wait)
|
| 48 |
+
try:
|
| 49 |
+
r = requests.post(BATCH, params={"fields": fields}, json={"ids": ids},
|
| 50 |
+
headers=headers, timeout=timeout)
|
| 51 |
+
if r.status_code == 429:
|
| 52 |
+
log.info("s2 429, backing off (attempt %d)", attempt + 1)
|
| 53 |
+
continue
|
| 54 |
+
r.raise_for_status()
|
| 55 |
+
data = r.json()
|
| 56 |
+
break
|
| 57 |
+
except Exception as e: # noqa: BLE001
|
| 58 |
+
log.warning("s2 author batch error (attempt %d): %s", attempt + 1, e)
|
| 59 |
+
if not data:
|
| 60 |
+
continue
|
| 61 |
+
for aid, item in zip(chunk, data):
|
| 62 |
+
if item and item.get("authors"):
|
| 63 |
+
out[f"arxiv:{aid}"] = item["authors"]
|
| 64 |
+
time.sleep(1.5)
|
| 65 |
+
log.info("s2 fetched authors for %d/%d papers", len(out), len(arxiv_ids))
|
| 66 |
+
return out
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def search_author(cfg: Config, name: str) -> dict | None:
|
| 70 |
+
"""Best-effort: return {name, affiliation, citations, h_index, url} for a name, or None."""
|
| 71 |
+
try:
|
| 72 |
+
r = requests.get(AUTHOR_SEARCH, params={
|
| 73 |
+
"query": name, "fields": "name,affiliations,hIndex,citationCount,url", "limit": 1},
|
| 74 |
+
headers=_headers(cfg), timeout=cfg.get("constants.request_timeout", 90))
|
| 75 |
+
if r.status_code == 429:
|
| 76 |
+
time.sleep(3)
|
| 77 |
+
return None
|
| 78 |
+
r.raise_for_status()
|
| 79 |
+
data = (r.json().get("data") or [])
|
| 80 |
+
if not data:
|
| 81 |
+
return None
|
| 82 |
+
a = data[0]
|
| 83 |
+
affs = a.get("affiliations") or []
|
| 84 |
+
return {"id": a.get("authorId"), "name": a.get("name"),
|
| 85 |
+
"affiliation": affs[0] if affs else None,
|
| 86 |
+
"citations": a.get("citationCount"), "h_index": a.get("hIndex"),
|
| 87 |
+
"url": a.get("url")}
|
| 88 |
+
except Exception as e: # noqa: BLE001
|
| 89 |
+
log.debug("s2 author search failed for %s: %s", name, e)
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def enrich(cfg: Config, records: list[PaperRecord]) -> list[PaperRecord]:
|
| 94 |
+
scfg = cfg.get("sources.semantic_scholar", {}) or {}
|
| 95 |
+
if not scfg.get("enabled", True):
|
| 96 |
+
return records
|
| 97 |
+
arxiv_recs = [r for r in records if r.arxiv_id]
|
| 98 |
+
if not arxiv_recs:
|
| 99 |
+
return records
|
| 100 |
+
by_aid = {r.arxiv_id: r for r in arxiv_recs}
|
| 101 |
+
ids = [f"ARXIV:{aid}" for aid in by_aid]
|
| 102 |
+
|
| 103 |
+
headers = {}
|
| 104 |
+
key = os.environ.get(scfg.get("api_key_env", "SEMANTIC_SCHOLAR_API_KEY") or "")
|
| 105 |
+
if key:
|
| 106 |
+
headers["x-api-key"] = key
|
| 107 |
+
|
| 108 |
+
enriched = 0
|
| 109 |
+
for chunk_start in range(0, len(ids), 500): # batch endpoint caps at 500 ids
|
| 110 |
+
chunk = ids[chunk_start:chunk_start + 500]
|
| 111 |
+
try:
|
| 112 |
+
resp = requests.post(BATCH, params={"fields": FIELDS}, json={"ids": chunk},
|
| 113 |
+
headers=headers, timeout=cfg.get("constants.request_timeout", 90))
|
| 114 |
+
if resp.status_code == 429:
|
| 115 |
+
log.warning("s2 rate-limited; backing off")
|
| 116 |
+
time.sleep(5)
|
| 117 |
+
resp = requests.post(BATCH, params={"fields": FIELDS}, json={"ids": chunk},
|
| 118 |
+
headers=headers, timeout=90)
|
| 119 |
+
resp.raise_for_status()
|
| 120 |
+
data = resp.json()
|
| 121 |
+
except Exception as e: # noqa: BLE001
|
| 122 |
+
log.warning("s2 batch failed: %s", e)
|
| 123 |
+
continue
|
| 124 |
+
for s2_id, item in zip(chunk, data):
|
| 125 |
+
if not item:
|
| 126 |
+
continue
|
| 127 |
+
aid = s2_id.split("ARXIV:", 1)[1]
|
| 128 |
+
rec = by_aid.get(aid)
|
| 129 |
+
if not rec:
|
| 130 |
+
continue
|
| 131 |
+
rec.citations = item.get("citationCount") or 0
|
| 132 |
+
rec.influential_citations = item.get("influentialCitationCount") or 0
|
| 133 |
+
ext = item.get("externalIds") or {}
|
| 134 |
+
if ext.get("DOI"):
|
| 135 |
+
rec.links.doi = f"https://doi.org/{ext['DOI']}"
|
| 136 |
+
oa = item.get("openAccessPdf") or {}
|
| 137 |
+
if oa.get("url") and not rec.links.pdf:
|
| 138 |
+
rec.links.pdf = oa["url"]
|
| 139 |
+
enriched += 1
|
| 140 |
+
time.sleep(1)
|
| 141 |
+
log.info("s2 enriched %d/%d records", enriched, len(arxiv_recs))
|
| 142 |
+
return records
|
src/wam/store/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from wam.store.db import Database, connect
|
| 2 |
+
|
| 3 |
+
__all__ = ["Database", "connect"]
|
src/wam/store/benchmarks.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Benchmark + model-variant persistence.
|
| 2 |
+
|
| 3 |
+
Model identity = (model_name, training_dataset) -> a slugified ``variant_key``, so the same
|
| 4 |
+
model name finetuned on a different dataset is tracked as a distinct system. Benchmark rows
|
| 5 |
+
are append-and-merge (UNIQUE constraint dedups identical rows) so conflicting numbers from
|
| 6 |
+
different papers stay visible side by side.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
import sqlite3
|
| 13 |
+
from datetime import date, datetime
|
| 14 |
+
|
| 15 |
+
from wam.pipeline.schemas import BenchmarkRow, ModelVariant
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def variant_key(model_name: str, training_dataset: str | None) -> str:
|
| 19 |
+
base = f"{model_name}|{training_dataset or 'unknown'}".lower()
|
| 20 |
+
return re.sub(r"[^a-z0-9]+", "-", base).strip("-")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def upsert_variant(conn: sqlite3.Connection, v: ModelVariant, source_paper_id: str) -> str:
|
| 24 |
+
vk = variant_key(v.model_name, v.training_dataset)
|
| 25 |
+
conn.execute(
|
| 26 |
+
"""INSERT INTO model_variants
|
| 27 |
+
(variant_key, model_name, training_dataset, base_model, params, modality,
|
| 28 |
+
source_paper_id, updated_at)
|
| 29 |
+
VALUES (?,?,?,?,?,?,?,?)
|
| 30 |
+
ON CONFLICT(variant_key) DO UPDATE SET
|
| 31 |
+
base_model=COALESCE(excluded.base_model, base_model),
|
| 32 |
+
params=COALESCE(excluded.params, params),
|
| 33 |
+
modality=COALESCE(excluded.modality, modality),
|
| 34 |
+
updated_at=excluded.updated_at""",
|
| 35 |
+
(vk, v.model_name, v.training_dataset, v.base_model, v.params, v.modality,
|
| 36 |
+
source_paper_id, datetime.now().isoformat(timespec="seconds")),
|
| 37 |
+
)
|
| 38 |
+
return vk
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def insert_benchmark(conn: sqlite3.Connection, row: BenchmarkRow, source_paper_id: str) -> None:
|
| 42 |
+
vk = variant_key(row.model_name, row.training_dataset)
|
| 43 |
+
conn.execute(
|
| 44 |
+
"""INSERT OR IGNORE INTO benchmarks
|
| 45 |
+
(variant_key, model_name, training_dataset, benchmark, task, split, metric_name,
|
| 46 |
+
metric_value, inference_speed, speed_unit, inference_cost, cost_unit, hardware,
|
| 47 |
+
source_paper_id, claimed_by_authors, notes, extracted_on)
|
| 48 |
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
| 49 |
+
(vk, row.model_name, row.training_dataset, row.benchmark, row.task, row.split,
|
| 50 |
+
row.metric_name, row.metric_value, row.inference_speed, row.speed_unit,
|
| 51 |
+
row.inference_cost, row.cost_unit, row.hardware, source_paper_id,
|
| 52 |
+
int(row.claimed_by_authors), row.notes, date.today().isoformat()),
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def store_extraction(conn: sqlite3.Connection, paper_id: str, models: list[ModelVariant],
|
| 57 |
+
rows: list[BenchmarkRow]) -> tuple[int, int]:
|
| 58 |
+
for v in models:
|
| 59 |
+
upsert_variant(conn, v, paper_id)
|
| 60 |
+
# Ensure any variant referenced by a row exists even if not in `models`.
|
| 61 |
+
seen = {variant_key(v.model_name, v.training_dataset) for v in models}
|
| 62 |
+
for r in rows:
|
| 63 |
+
vk = variant_key(r.model_name, r.training_dataset)
|
| 64 |
+
if vk not in seen:
|
| 65 |
+
upsert_variant(conn, ModelVariant(model_name=r.model_name,
|
| 66 |
+
training_dataset=r.training_dataset), paper_id)
|
| 67 |
+
seen.add(vk)
|
| 68 |
+
insert_benchmark(conn, r, paper_id)
|
| 69 |
+
conn.execute("UPDATE papers SET benchmarks_extracted=1 WHERE id=?", (paper_id,))
|
| 70 |
+
return len(models), len(rows)
|
src/wam/store/db.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite access layer.
|
| 2 |
+
|
| 3 |
+
The DB is the canonical store; everything else (README, web app, digests) is exported from
|
| 4 |
+
it. This module just handles connection + schema init + a couple of low-level helpers; the
|
| 5 |
+
domain-specific upserts (papers, benchmarks, people, fronts) live in their own store modules
|
| 6 |
+
added in later phases.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import sqlite3
|
| 12 |
+
from datetime import date, datetime
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from wam.config import Config, load_config
|
| 16 |
+
from wam.logging import get_logger
|
| 17 |
+
|
| 18 |
+
log = get_logger("store")
|
| 19 |
+
|
| 20 |
+
SCHEMA_PATH = Path(__file__).with_name("schema.sql")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def connect(db_path: str | Path) -> sqlite3.Connection:
|
| 24 |
+
"""Open a connection with row access by name and schema applied."""
|
| 25 |
+
db_path = Path(db_path)
|
| 26 |
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
conn = sqlite3.connect(db_path)
|
| 28 |
+
conn.row_factory = sqlite3.Row
|
| 29 |
+
conn.executescript(SCHEMA_PATH.read_text(encoding="utf-8"))
|
| 30 |
+
_migrate(conn)
|
| 31 |
+
return conn
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _migrate(conn: sqlite3.Connection) -> None:
|
| 35 |
+
"""Idempotent additive migrations for DBs created before a column existed."""
|
| 36 |
+
cols = {r["name"] for r in conn.execute("PRAGMA table_info(papers)")}
|
| 37 |
+
if "benchmarks_extracted" not in cols:
|
| 38 |
+
conn.execute("ALTER TABLE papers ADD COLUMN benchmarks_extracted INTEGER DEFAULT 0")
|
| 39 |
+
conn.commit()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class Database:
|
| 43 |
+
"""Convenience wrapper around a connection bound to the configured DB path."""
|
| 44 |
+
|
| 45 |
+
def __init__(self, config: Config | None = None, db_path: str | Path | None = None):
|
| 46 |
+
self.cfg = config or load_config()
|
| 47 |
+
self.path = Path(db_path) if db_path else self.cfg.path("db")
|
| 48 |
+
self.conn = connect(self.path)
|
| 49 |
+
log.debug("opened db at %s", self.path)
|
| 50 |
+
|
| 51 |
+
def __enter__(self) -> "Database":
|
| 52 |
+
return self
|
| 53 |
+
|
| 54 |
+
def __exit__(self, *exc) -> None:
|
| 55 |
+
self.close()
|
| 56 |
+
|
| 57 |
+
def close(self) -> None:
|
| 58 |
+
self.conn.commit()
|
| 59 |
+
self.conn.close()
|
| 60 |
+
|
| 61 |
+
def log_run(self, stage: str, n_in: int, n_out: int, cost_usd: float = 0.0,
|
| 62 |
+
notes: str = "") -> None:
|
| 63 |
+
self.conn.execute(
|
| 64 |
+
"INSERT INTO runs (run_date, stage, n_in, n_out, cost_usd, notes, created_at) "
|
| 65 |
+
"VALUES (?,?,?,?,?,?,?)",
|
| 66 |
+
(date.today().isoformat(), stage, n_in, n_out, cost_usd, notes,
|
| 67 |
+
datetime.now().isoformat(timespec="seconds")),
|
| 68 |
+
)
|
| 69 |
+
self.conn.commit()
|
src/wam/store/papers.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Paper persistence: map ``PaperRecord`` <-> the ``papers`` table."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import sqlite3
|
| 7 |
+
from datetime import date, datetime
|
| 8 |
+
|
| 9 |
+
from wam.models import PaperRecord
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def existing_ids(conn: sqlite3.Connection) -> set[str]:
|
| 13 |
+
return {row["id"] for row in conn.execute("SELECT id FROM papers")}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def insert_new(conn: sqlite3.Connection, rec: PaperRecord, *, first_seen: str | None = None) -> None:
|
| 17 |
+
"""Insert a freshly-fetched record. No-op if the id already exists."""
|
| 18 |
+
first_seen = first_seen or date.today().isoformat()
|
| 19 |
+
conn.execute(
|
| 20 |
+
"""INSERT OR IGNORE INTO papers
|
| 21 |
+
(id, source, title, authors_json, published, first_seen, abstract, categories_json,
|
| 22 |
+
links_json, citations, influential_citations, has_code, status, updated_at)
|
| 23 |
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
| 24 |
+
(
|
| 25 |
+
rec.id, rec.source, rec.title, json.dumps(rec.authors), rec.published, first_seen,
|
| 26 |
+
rec.abstract, json.dumps(rec.categories), rec.links.model_dump_json(),
|
| 27 |
+
rec.citations, rec.influential_citations, int(rec.has_code), "new",
|
| 28 |
+
datetime.now().isoformat(timespec="seconds"),
|
| 29 |
+
),
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def update_enrichment(conn: sqlite3.Connection, rec: PaperRecord) -> None:
|
| 34 |
+
"""Refresh citation/code/link fields for an existing record (idempotent)."""
|
| 35 |
+
conn.execute(
|
| 36 |
+
"""UPDATE papers SET citations=?, influential_citations=?, has_code=?, links_json=?,
|
| 37 |
+
updated_at=? WHERE id=?""",
|
| 38 |
+
(rec.citations, rec.influential_citations, int(rec.has_code), rec.links.model_dump_json(),
|
| 39 |
+
datetime.now().isoformat(timespec="seconds"), rec.id),
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def count(conn: sqlite3.Connection) -> int:
|
| 44 |
+
return conn.execute("SELECT count(*) AS c FROM papers").fetchone()["c"]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# --- stage work-queues -------------------------------------------------------
|
| 48 |
+
# Each returns rows (id, title, abstract, ...) that still need a given stage. Relevance
|
| 49 |
+
# acts as a cache: a paper with track already set is not re-filtered.
|
| 50 |
+
|
| 51 |
+
def _rows(conn: sqlite3.Connection, where: str, params: tuple = ()) -> list[sqlite3.Row]:
|
| 52 |
+
return conn.execute(
|
| 53 |
+
f"SELECT id, source, title, abstract, published, categories_json FROM papers "
|
| 54 |
+
f"WHERE {where}", params).fetchall()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _limit_clause(where: str, limit: int | None) -> str:
|
| 58 |
+
return where + (f" LIMIT {int(limit)}" if limit else "")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def needs_filter(conn: sqlite3.Connection, limit: int | None = None) -> list[sqlite3.Row]:
|
| 62 |
+
# track NULL = unfiltered; relevance=-1 = a prior failure to retry.
|
| 63 |
+
return _rows(conn, _limit_clause("track IS NULL OR relevance = -1", limit))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def needs_summary(conn: sqlite3.Connection, limit: int | None = None) -> list[sqlite3.Row]:
|
| 67 |
+
return _rows(conn, _limit_clause(
|
| 68 |
+
"track IN ('core','adjacent') AND summary_json IS NULL", limit))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def needs_analysis(conn: sqlite3.Connection, limit: int | None = None) -> list[sqlite3.Row]:
|
| 72 |
+
q = "track = 'core' AND analysis_json IS NULL"
|
| 73 |
+
if limit:
|
| 74 |
+
q += f" ORDER BY relevance DESC LIMIT {int(limit)}"
|
| 75 |
+
return _rows(conn, q)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def needs_score(conn: sqlite3.Connection, limit: int | None = None) -> list[sqlite3.Row]:
|
| 79 |
+
q = "track = 'core' AND analysis_json IS NOT NULL AND scores_json IS NULL"
|
| 80 |
+
if limit:
|
| 81 |
+
q += f" ORDER BY relevance DESC LIMIT {int(limit)}"
|
| 82 |
+
return _rows(conn, q)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def needs_innovation(conn: sqlite3.Connection, limit: int | None = None) -> list[sqlite3.Row]:
|
| 86 |
+
return _rows(conn, _limit_clause("track = 'adjacent' AND innovation_json IS NULL", limit))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def needs_extract(conn: sqlite3.Connection, limit: int | None = None) -> list[sqlite3.Row]:
|
| 90 |
+
# core papers that have been analyzed but not yet benchmark-extracted
|
| 91 |
+
return conn.execute(_limit_clause(
|
| 92 |
+
"SELECT id, source, title, abstract, links_json FROM papers WHERE track='core' "
|
| 93 |
+
"AND analysis_json IS NOT NULL AND benchmarks_extracted=0", limit)).fetchall()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# --- stage writers -----------------------------------------------------------
|
| 97 |
+
def _touch(conn: sqlite3.Connection, pid: str, **cols) -> None:
|
| 98 |
+
cols["updated_at"] = datetime.now().isoformat(timespec="seconds")
|
| 99 |
+
sets = ", ".join(f"{k}=?" for k in cols)
|
| 100 |
+
conn.execute(f"UPDATE papers SET {sets} WHERE id=?", (*cols.values(), pid))
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def set_filter(conn, pid: str, track: str, relevance: float, reason: str) -> None:
|
| 104 |
+
_touch(conn, pid, track=track, relevance=relevance, relevance_reason=reason,
|
| 105 |
+
status="filtered")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def set_summary(conn, pid: str, summary_json: str) -> None:
|
| 109 |
+
_touch(conn, pid, summary_json=summary_json, status="summarized")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def set_analysis(conn, pid: str, analysis_json: str) -> None:
|
| 113 |
+
_touch(conn, pid, analysis_json=analysis_json, status="analyzed")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def set_scores(conn, pid: str, scores_json: str) -> None:
|
| 117 |
+
_touch(conn, pid, scores_json=scores_json, status="scored")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def set_innovation(conn, pid: str, innovation_json: str) -> None:
|
| 121 |
+
_touch(conn, pid, innovation_json=innovation_json, status="done")
|
src/wam/store/people.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authors + groups persistence."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
import sqlite3
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def slug(name: str) -> str:
|
| 12 |
+
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def upsert_author(conn: sqlite3.Connection, *, author_id: str, name: str,
|
| 16 |
+
affiliation: str | None, s2_url: str | None, citations: int | None,
|
| 17 |
+
h_index: int | None, paper_ids: list[str], directions: str | None) -> None:
|
| 18 |
+
conn.execute(
|
| 19 |
+
"""INSERT INTO authors
|
| 20 |
+
(id, name, affiliation, s2_url, citations, h_index, paper_ids_json, directions,
|
| 21 |
+
updated_at)
|
| 22 |
+
VALUES (?,?,?,?,?,?,?,?,?)
|
| 23 |
+
ON CONFLICT(id) DO UPDATE SET
|
| 24 |
+
affiliation=COALESCE(excluded.affiliation, affiliation),
|
| 25 |
+
s2_url=COALESCE(excluded.s2_url, s2_url),
|
| 26 |
+
citations=COALESCE(excluded.citations, citations),
|
| 27 |
+
h_index=COALESCE(excluded.h_index, h_index),
|
| 28 |
+
paper_ids_json=excluded.paper_ids_json,
|
| 29 |
+
directions=COALESCE(excluded.directions, directions),
|
| 30 |
+
updated_at=excluded.updated_at""",
|
| 31 |
+
(author_id, name, affiliation, s2_url, citations, h_index, json.dumps(paper_ids),
|
| 32 |
+
directions, datetime.now().isoformat(timespec="seconds")),
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def upsert_group(conn: sqlite3.Connection, *, group_id: str, name: str,
|
| 37 |
+
affiliation: str | None, member_ids: list[str], directions: str | None,
|
| 38 |
+
notable: list[str]) -> None:
|
| 39 |
+
conn.execute(
|
| 40 |
+
"""INSERT INTO groups (id, name, affiliation, member_ids_json, directions,
|
| 41 |
+
notable_json, updated_at)
|
| 42 |
+
VALUES (?,?,?,?,?,?,?)
|
| 43 |
+
ON CONFLICT(id) DO UPDATE SET
|
| 44 |
+
member_ids_json=excluded.member_ids_json,
|
| 45 |
+
directions=COALESCE(excluded.directions, directions),
|
| 46 |
+
notable_json=excluded.notable_json,
|
| 47 |
+
updated_at=excluded.updated_at""",
|
| 48 |
+
(group_id, name, affiliation, json.dumps(member_ids), directions, json.dumps(notable),
|
| 49 |
+
datetime.now().isoformat(timespec="seconds")),
|
| 50 |
+
)
|
src/wam/store/schema.sql
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Canonical SQLite store for Awesome-WAM.
|
| 2 |
+
-- JSON/markdown exports (README, web app, digests) are generated FROM this DB.
|
| 3 |
+
|
| 4 |
+
PRAGMA journal_mode = WAL;
|
| 5 |
+
PRAGMA foreign_keys = ON;
|
| 6 |
+
|
| 7 |
+
-- One row per discovered item (paper or news).
|
| 8 |
+
CREATE TABLE IF NOT EXISTS papers (
|
| 9 |
+
id TEXT PRIMARY KEY, -- e.g. "arxiv:2506.01234", "news:<hash>"
|
| 10 |
+
source TEXT NOT NULL, -- arxiv | semantic_scholar | papers_with_code | news
|
| 11 |
+
title TEXT NOT NULL,
|
| 12 |
+
authors_json TEXT, -- JSON array of author names
|
| 13 |
+
published TEXT, -- ISO date
|
| 14 |
+
first_seen TEXT NOT NULL, -- ISO date we first ingested it
|
| 15 |
+
abstract TEXT,
|
| 16 |
+
categories_json TEXT, -- JSON array
|
| 17 |
+
track TEXT, -- core | adjacent | news | drop (NULL = unfiltered)
|
| 18 |
+
links_json TEXT, -- JSON {abs,pdf,project_page,code,doi}
|
| 19 |
+
citations INTEGER DEFAULT 0,
|
| 20 |
+
influential_citations INTEGER DEFAULT 0,
|
| 21 |
+
has_code INTEGER DEFAULT 0, -- bool
|
| 22 |
+
relevance REAL, -- 0..1; -1 means scoring failed (retry next run)
|
| 23 |
+
relevance_reason TEXT,
|
| 24 |
+
summary_json TEXT, -- JSON {tldr,problem,method,results}
|
| 25 |
+
analysis_json TEXT, -- JSON {contributions,limitations,wam_relevance}
|
| 26 |
+
innovation_json TEXT, -- JSON {key_idea,transferable_to_wam} (adjacent)
|
| 27 |
+
scores_json TEXT, -- JSON {general,wam,weighted_total,rationale}
|
| 28 |
+
status TEXT DEFAULT 'new', -- new|filtered|summarized|analyzed|scored|done
|
| 29 |
+
pdf_hash TEXT,
|
| 30 |
+
benchmarks_extracted INTEGER DEFAULT 0, -- bool: benchmark/model extraction done
|
| 31 |
+
updated_at TEXT
|
| 32 |
+
);
|
| 33 |
+
CREATE INDEX IF NOT EXISTS idx_papers_track ON papers(track);
|
| 34 |
+
CREATE INDEX IF NOT EXISTS idx_papers_published ON papers(published);
|
| 35 |
+
CREATE INDEX IF NOT EXISTS idx_papers_status ON papers(status);
|
| 36 |
+
|
| 37 |
+
-- Normalized benchmark rows. Model identity = (model_name, training_dataset) -> variant_key.
|
| 38 |
+
CREATE TABLE IF NOT EXISTS benchmarks (
|
| 39 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 40 |
+
variant_key TEXT NOT NULL,
|
| 41 |
+
model_name TEXT NOT NULL,
|
| 42 |
+
training_dataset TEXT,
|
| 43 |
+
benchmark TEXT NOT NULL,
|
| 44 |
+
task TEXT,
|
| 45 |
+
split TEXT,
|
| 46 |
+
metric_name TEXT,
|
| 47 |
+
metric_value REAL,
|
| 48 |
+
inference_speed REAL,
|
| 49 |
+
speed_unit TEXT,
|
| 50 |
+
inference_cost REAL,
|
| 51 |
+
cost_unit TEXT,
|
| 52 |
+
hardware TEXT,
|
| 53 |
+
source_paper_id TEXT REFERENCES papers(id),
|
| 54 |
+
claimed_by_authors INTEGER DEFAULT 1,
|
| 55 |
+
notes TEXT,
|
| 56 |
+
extracted_on TEXT,
|
| 57 |
+
UNIQUE(variant_key, benchmark, task, metric_name, source_paper_id)
|
| 58 |
+
);
|
| 59 |
+
CREATE INDEX IF NOT EXISTS idx_bench_variant ON benchmarks(variant_key);
|
| 60 |
+
|
| 61 |
+
-- Model variant registry (the disambiguated identity).
|
| 62 |
+
CREATE TABLE IF NOT EXISTS model_variants (
|
| 63 |
+
variant_key TEXT PRIMARY KEY,
|
| 64 |
+
model_name TEXT NOT NULL,
|
| 65 |
+
training_dataset TEXT,
|
| 66 |
+
base_model TEXT,
|
| 67 |
+
params TEXT,
|
| 68 |
+
modality TEXT,
|
| 69 |
+
source_paper_id TEXT REFERENCES papers(id),
|
| 70 |
+
updated_at TEXT
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
-- Influential authors.
|
| 74 |
+
CREATE TABLE IF NOT EXISTS authors (
|
| 75 |
+
id TEXT PRIMARY KEY, -- semantic scholar id or slug
|
| 76 |
+
name TEXT NOT NULL,
|
| 77 |
+
affiliation TEXT,
|
| 78 |
+
s2_url TEXT,
|
| 79 |
+
citations INTEGER,
|
| 80 |
+
h_index INTEGER,
|
| 81 |
+
paper_ids_json TEXT, -- JSON array of tracked paper ids
|
| 82 |
+
directions TEXT, -- LLM summary of research directions
|
| 83 |
+
updated_at TEXT
|
| 84 |
+
);
|
| 85 |
+
|
| 86 |
+
-- Research groups / labs.
|
| 87 |
+
CREATE TABLE IF NOT EXISTS groups (
|
| 88 |
+
id TEXT PRIMARY KEY,
|
| 89 |
+
name TEXT NOT NULL,
|
| 90 |
+
affiliation TEXT,
|
| 91 |
+
member_ids_json TEXT,
|
| 92 |
+
directions TEXT,
|
| 93 |
+
notable_json TEXT, -- JSON array of notable paper ids
|
| 94 |
+
updated_at TEXT
|
| 95 |
+
);
|
| 96 |
+
|
| 97 |
+
-- Research fronts (trends), snapshot-versioned.
|
| 98 |
+
CREATE TABLE IF NOT EXISTS fronts (
|
| 99 |
+
front_id TEXT NOT NULL,
|
| 100 |
+
snapshot_date TEXT NOT NULL,
|
| 101 |
+
name TEXT,
|
| 102 |
+
summary TEXT,
|
| 103 |
+
member_ids_json TEXT,
|
| 104 |
+
size INTEGER,
|
| 105 |
+
momentum TEXT, -- rising | steady | cooling
|
| 106 |
+
volume_json TEXT, -- per-window counts
|
| 107 |
+
PRIMARY KEY (front_id, snapshot_date)
|
| 108 |
+
);
|
| 109 |
+
CREATE TABLE IF NOT EXISTS front_lineage (
|
| 110 |
+
current_id TEXT,
|
| 111 |
+
previous_id TEXT,
|
| 112 |
+
snapshot_date TEXT,
|
| 113 |
+
relationship TEXT, -- continuation | merge | split | new
|
| 114 |
+
overlap REAL
|
| 115 |
+
);
|
| 116 |
+
|
| 117 |
+
-- Run audit log.
|
| 118 |
+
CREATE TABLE IF NOT EXISTS runs (
|
| 119 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 120 |
+
run_date TEXT,
|
| 121 |
+
stage TEXT,
|
| 122 |
+
n_in INTEGER,
|
| 123 |
+
n_out INTEGER,
|
| 124 |
+
cost_usd REAL,
|
| 125 |
+
notes TEXT,
|
| 126 |
+
created_at TEXT
|
| 127 |
+
);
|
src/wam/webapp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Streamlit RAG Q&A web app over the WAM knowledge base."""
|
src/wam/webapp/app.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Streamlit Q&A app — the WAM knowledge base front-end (long-context, no RAG).
|
| 2 |
+
|
| 3 |
+
Run locally: streamlit run src/wam/webapp/app.py
|
| 4 |
+
On HF Spaces: set OPENROUTER_API_KEY as a Space secret; this file is the entry point.
|
| 5 |
+
|
| 6 |
+
The OpenRouter key stays server-side. Answers are grounded in a knowledge pack built from the
|
| 7 |
+
committed data/wam.db (paper summaries + scores + leaderboard + authors + trends).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 16 |
+
|
| 17 |
+
import streamlit as st # noqa: E402
|
| 18 |
+
|
| 19 |
+
from wam.config import load_config # noqa: E402
|
| 20 |
+
from wam.webapp.qa import answer # noqa: E402
|
| 21 |
+
|
| 22 |
+
st.set_page_config(page_title="Awesome-WAM Knowledge Base", page_icon="🤖", layout="centered")
|
| 23 |
+
st.title("🤖 Awesome-WAM Knowledge Base")
|
| 24 |
+
st.caption("Ask about World Action Models — papers, benchmarks, authors, trends. "
|
| 25 |
+
"Answers are grounded in the tracked corpus and cite paper ids.")
|
| 26 |
+
|
| 27 |
+
cfg = load_config()
|
| 28 |
+
db_exists = cfg.path("db").exists()
|
| 29 |
+
if not db_exists:
|
| 30 |
+
st.warning("No data found yet. Run the pipeline to populate data/wam.db.")
|
| 31 |
+
|
| 32 |
+
examples = ["Which VLA models report real-time inference?",
|
| 33 |
+
"What are the rising research directions?",
|
| 34 |
+
"Who works on world models for autonomous driving?"]
|
| 35 |
+
q = st.text_input("Your question", placeholder=examples[0])
|
| 36 |
+
st.caption("Try: " + " · ".join(f"_{e}_" for e in examples))
|
| 37 |
+
|
| 38 |
+
if q and db_exists:
|
| 39 |
+
with st.spinner("Reading the corpus and composing an answer…"):
|
| 40 |
+
res = answer(q, cfg=cfg)
|
| 41 |
+
st.markdown(res["answer"])
|
| 42 |
+
st.caption(f"Grounded in a {res['pack_chars']:,}-char knowledge pack.")
|
src/wam/webapp/qa.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Long-context Q&A over the WAM knowledge base (no RAG / no embeddings).
|
| 2 |
+
|
| 3 |
+
At our scale (~hundreds of tracked papers) the whole corpus's *summaries* + scores +
|
| 4 |
+
benchmark leaderboard + author directions + trends fit in a long-context model's window, so
|
| 5 |
+
we build a compact "knowledge pack" from SQLite and let the model answer directly with
|
| 6 |
+
citations — no retrieval step to miss documents, no vector index to maintain.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import sqlite3
|
| 13 |
+
|
| 14 |
+
from wam.config import Config, load_config
|
| 15 |
+
from wam.llm import LLMClient
|
| 16 |
+
from wam.logging import get_logger
|
| 17 |
+
from wam.store import Database
|
| 18 |
+
|
| 19 |
+
log = get_logger("webapp.qa")
|
| 20 |
+
|
| 21 |
+
SYSTEM = (
|
| 22 |
+
"You answer questions about World Action Models research using ONLY the knowledge pack "
|
| 23 |
+
"below (tracked papers with scores, a benchmark leaderboard, author directions, and "
|
| 24 |
+
"trends). Cite papers by their id like (arxiv:2606.01234). If the pack doesn't contain "
|
| 25 |
+
"the answer, say so — never invent papers, numbers, or citations. Be concise and concrete.")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def build_pack(conn: sqlite3.Connection, max_papers: int = 400,
|
| 29 |
+
include_dropped: bool = True) -> str:
|
| 30 |
+
"""Compact, citeable corpus snapshot for the long-context Q&A model."""
|
| 31 |
+
sections: list[str] = []
|
| 32 |
+
|
| 33 |
+
papers = conn.execute(
|
| 34 |
+
"SELECT id, title, track, published, scores_json, summary_json FROM papers "
|
| 35 |
+
"WHERE track IN ('core','adjacent') ORDER BY "
|
| 36 |
+
"COALESCE(json_extract(scores_json,'$.weighted_total'),0) DESC LIMIT ?", (max_papers,)
|
| 37 |
+
).fetchall()
|
| 38 |
+
lines = ["## PAPERS"]
|
| 39 |
+
for r in papers:
|
| 40 |
+
s = json.loads(r["scores_json"]) if r["scores_json"] else {}
|
| 41 |
+
tldr = json.loads(r["summary_json"] or "{}").get("tldr", "") if r["summary_json"] else ""
|
| 42 |
+
score = f" score={s['weighted_total']}" if s.get("weighted_total") is not None else ""
|
| 43 |
+
wam = json.dumps(s.get("wam", {})) if s else ""
|
| 44 |
+
lines.append(f"- ({r['id']}) [{r['track']}{score}] {r['title']} — {tldr}"
|
| 45 |
+
+ (f" wam_scores={wam}" if wam else ""))
|
| 46 |
+
sections.append("\n".join(lines))
|
| 47 |
+
|
| 48 |
+
# Dropped papers stay in the KB (title-only) so it still covers them, per project policy.
|
| 49 |
+
if include_dropped:
|
| 50 |
+
drops = conn.execute("SELECT id, title FROM papers WHERE track='drop' "
|
| 51 |
+
"ORDER BY first_seen DESC LIMIT 200").fetchall()
|
| 52 |
+
if drops:
|
| 53 |
+
sections.append("## OTHER (filtered-out) PAPERS — title only\n"
|
| 54 |
+
+ "\n".join(f"- ({d['id']}) {d['title']}" for d in drops))
|
| 55 |
+
|
| 56 |
+
bench = conn.execute(
|
| 57 |
+
"SELECT model_name, training_dataset, benchmark, task, metric_name, metric_value, "
|
| 58 |
+
"claimed_by_authors, source_paper_id FROM benchmarks WHERE metric_value IS NOT NULL "
|
| 59 |
+
"ORDER BY benchmark LIMIT 400").fetchall()
|
| 60 |
+
if bench:
|
| 61 |
+
bl = ["## BENCHMARK LEADERBOARD (model | training data | benchmark | task | metric=value | source)"]
|
| 62 |
+
for b in bench:
|
| 63 |
+
src = "authors" if b["claimed_by_authors"] else "3rd-party"
|
| 64 |
+
bl.append(f"- {b['model_name']} | {b['training_dataset'] or '?'} | {b['benchmark']} | "
|
| 65 |
+
f"{b['task'] or '-'} | {b['metric_name']}={b['metric_value']} | {src} "
|
| 66 |
+
f"({b['source_paper_id']})")
|
| 67 |
+
sections.append("\n".join(bl))
|
| 68 |
+
|
| 69 |
+
authors = conn.execute(
|
| 70 |
+
"SELECT name, affiliation, directions FROM authors ORDER BY "
|
| 71 |
+
"json_array_length(paper_ids_json) DESC LIMIT 60").fetchall()
|
| 72 |
+
if authors:
|
| 73 |
+
al = ["## INFLUENTIAL AUTHORS"]
|
| 74 |
+
for a in authors:
|
| 75 |
+
al.append(f"- {a['name']}{' ('+a['affiliation']+')' if a['affiliation'] else ''}: "
|
| 76 |
+
f"{a['directions'] or ''}")
|
| 77 |
+
sections.append("\n".join(al))
|
| 78 |
+
|
| 79 |
+
snap = conn.execute("SELECT max(snapshot_date) FROM fronts").fetchone()[0]
|
| 80 |
+
if snap:
|
| 81 |
+
fronts = conn.execute("SELECT name, size, momentum, summary FROM fronts WHERE "
|
| 82 |
+
"snapshot_date=? ORDER BY size DESC", (snap,)).fetchall()
|
| 83 |
+
fl = ["## TRENDS / DIRECTIONS (name | papers | momentum | summary)"]
|
| 84 |
+
for f in fronts:
|
| 85 |
+
fl.append(f"- {f['name']} | {f['size']} | {f['momentum']} | {f['summary']}")
|
| 86 |
+
sections.append("\n".join(fl))
|
| 87 |
+
|
| 88 |
+
return "\n\n".join(sections)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def answer(question: str, cfg: Config | None = None, client: LLMClient | None = None) -> dict:
|
| 92 |
+
cfg = cfg or load_config()
|
| 93 |
+
client = client or LLMClient(cfg)
|
| 94 |
+
with Database(cfg) as db:
|
| 95 |
+
pack = build_pack(db.conn, max_papers=int(cfg.get("qa.max_papers", 400)),
|
| 96 |
+
include_dropped=bool(cfg.get("qa.include_dropped", True)))
|
| 97 |
+
if not pack.strip():
|
| 98 |
+
return {"answer": "The knowledge base is empty — run the pipeline first.", "pack_chars": 0}
|
| 99 |
+
# Generous ceiling: the qa-tier model may be a reasoning model (burns tokens before the
|
| 100 |
+
# answer); too low a cap yields empty content.
|
| 101 |
+
ans = client.complete("qa", SYSTEM, f"KNOWLEDGE PACK:\n{pack}\n\nQUESTION: {question}",
|
| 102 |
+
label="qa", max_tokens=6000)
|
| 103 |
+
return {"answer": ans, "pack_chars": len(pack)}
|