HardToFindAGoodUserName commited on
Commit
1a5ba1e
·
verified ·
1 Parent(s): 97f6502

Deploy Awesome-WAM long-context Q&A app

Browse files
Files changed (48) hide show
  1. .gitattributes +1 -0
  2. README.md +9 -14
  3. config/config.yaml +136 -0
  4. config/profile.md +45 -0
  5. data/wam.db +3 -0
  6. requirements.txt +10 -3
  7. src/awesome_wam.egg-info/PKG-INFO +24 -0
  8. src/awesome_wam.egg-info/SOURCES.txt +15 -0
  9. src/awesome_wam.egg-info/dependency_links.txt +1 -0
  10. src/awesome_wam.egg-info/requires.txt +18 -0
  11. src/awesome_wam.egg-info/top_level.txt +1 -0
  12. src/wam/__init__.py +3 -0
  13. src/wam/config.py +86 -0
  14. src/wam/llm/__init__.py +3 -0
  15. src/wam/llm/client.py +191 -0
  16. src/wam/logging.py +93 -0
  17. src/wam/models.py +38 -0
  18. src/wam/notify/__init__.py +1 -0
  19. src/wam/notify/mailer.py +318 -0
  20. src/wam/pipeline/__init__.py +1 -0
  21. src/wam/pipeline/analyze.py +45 -0
  22. src/wam/pipeline/extract.py +64 -0
  23. src/wam/pipeline/fetch.py +51 -0
  24. src/wam/pipeline/filter.py +57 -0
  25. src/wam/pipeline/innovation.py +43 -0
  26. src/wam/pipeline/links.py +84 -0
  27. src/wam/pipeline/people.py +118 -0
  28. src/wam/pipeline/schemas.py +100 -0
  29. src/wam/pipeline/score.py +71 -0
  30. src/wam/pipeline/summarize.py +39 -0
  31. src/wam/pipeline/trends.py +108 -0
  32. src/wam/render/__init__.py +1 -0
  33. src/wam/render/readme.py +259 -0
  34. src/wam/sources/__init__.py +1 -0
  35. src/wam/sources/arxiv.py +83 -0
  36. src/wam/sources/news.py +58 -0
  37. src/wam/sources/papers_with_code.py +58 -0
  38. src/wam/sources/pdf.py +53 -0
  39. src/wam/sources/semantic_scholar.py +142 -0
  40. src/wam/store/__init__.py +3 -0
  41. src/wam/store/benchmarks.py +70 -0
  42. src/wam/store/db.py +69 -0
  43. src/wam/store/papers.py +121 -0
  44. src/wam/store/people.py +50 -0
  45. src/wam/store/schema.sql +127 -0
  46. src/wam/webapp/__init__.py +1 -0
  47. src/wam/webapp/app.py +42 -0
  48. 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: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
  pinned: false
11
- short_description: Interactive QA about Awesome-Embodied&MM
12
- license: apache-2.0
13
  ---
14
 
15
- # Welcome to Streamlit!
16
 
17
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
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
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
 
 
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)}