Skip to content

Commit 0555a00

Browse files
rayketchamclaude
andcommitted
feat: external feed fetchers + IETF + scheduler wiring (data audit followup)
The third escape hatch from data poverty. Phase 5 shipped parsers; this adds the network fetchers, the IETF I-D feed, an aggregator, and the cron-side wiring. - feeds/_http.py: shared stdlib HTTP helper with timeout + UA - feeds/nvd.fetch + load: pulls NVD CVE 2.0 JSON, cache TTL 12h - feeds/arxiv.fetch + load: pulls arXiv cs.CR Atom, cache TTL 48h - feeds/ietf.py: parser + fetcher for IETF I-D last-call RSS, TTL 24h - feeds.get_external_seeds: aggregates across all healthy feeds, skips stale/missing without raising - engine/prompts.build_generation_prompt(... external_seeds=…): emits "EXTERNAL SIGNALS" section in the LLM prompt - engine/generator.IdeaGenerator.generate forwards external_seeds - cron/scheduler.generate_and_store: reads data/feeds/{nvd,arxiv,ietf}.json on every cycle, threads through the LLM call - scripts/refresh-feeds.sh: cron-friendly daily refresh End-to-end demo verified: a single prompt now contains BOTH the saturation summary (anti-seeds: certificate, detection, compliance...) AND fresh external seeds (CVEs, arXiv papers, IETF drafts) — the two asymmetric signals Claude needs to break out of the static-seed loop. Tests: 14 new (9 fetcher + 5 prompt wiring), 975 total passing. 2 pre-existing event-loop pollution failures in test_remaining_gaps.py unchanged (pass in isolation). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 57fe83d commit 0555a00

11 files changed

Lines changed: 612 additions & 10 deletions

File tree

scripts/refresh-feeds.sh

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/bin/bash
2+
# Refresh external feed caches (NVD, arXiv, IETF). Cron-friendly: writes
3+
# JSON caches under data/feeds/ that the generation runner reads on each
4+
# cycle. Each feed degrades to empty list on network failure (see logs).
5+
#
6+
# Recommended: daily at off-peak (e.g. 04:30 UTC).
7+
set -euo pipefail
8+
9+
cd /opt/project-forge
10+
11+
if [ -f .env ]; then
12+
set -a
13+
source .env
14+
set +a
15+
fi
16+
17+
export FORGE_DB_PATH="${FORGE_DB_PATH:-/opt/project-forge/data/forge.db}"
18+
19+
echo "$(date): refreshing external feeds..."
20+
python3 -c "
21+
from datetime import timedelta
22+
from pathlib import Path
23+
from project_forge.feeds import nvd, arxiv, ietf
24+
from project_forge.feeds.cache import FeedCache
25+
from project_forge.config import settings
26+
27+
base = Path(settings.db_path).parent / 'feeds'
28+
base.mkdir(parents=True, exist_ok=True)
29+
30+
nvd_items = nvd.fetch(cache=FeedCache(base / 'nvd.json', ttl=timedelta(hours=12)), days=7)
31+
print(f' NVD: {len(nvd_items)} items')
32+
33+
arxiv_items = arxiv.fetch(cache=FeedCache(base / 'arxiv.json', ttl=timedelta(hours=48)),
34+
category='cs.CR', max_results=25)
35+
print(f' arXiv cs.CR: {len(arxiv_items)} items')
36+
37+
ietf_items = ietf.fetch(cache=FeedCache(base / 'ietf.json', ttl=timedelta(hours=24)))
38+
print(f' IETF: {len(ietf_items)} items')
39+
"

src/project_forge/cron/scheduler.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
import logging
44
import random
55
import tempfile
6-
from datetime import UTC, datetime
6+
from datetime import UTC, datetime, timedelta
7+
from pathlib import Path
78

89
from project_forge.config import settings
910
from project_forge.engine.dedup import filter_and_save
1011
from project_forge.engine.generator import IdeaGenerator
1112
from project_forge.engine.quality_review import review_idea
1213
from project_forge.engine.scorer import is_high_value, score_idea
14+
from project_forge.feeds import get_external_seeds
15+
from project_forge.feeds.cache import FeedCache
1316
from project_forge.models import GenerationRun, Idea, IdeaCategory
1417
from project_forge.scaffold.builder import build_scaffold_spec, render_scaffold
1518
from project_forge.scaffold.github import create_issue, create_label, create_repo, push_initial_commit
@@ -20,6 +23,27 @@
2023
ALL_CATEGORIES = list(IdeaCategory)
2124

2225

26+
def _feeds_dir() -> Path:
27+
"""Where feed caches live. Co-located with the SQLite DB."""
28+
return settings.db_path.parent / "feeds"
29+
30+
31+
def _load_external_seeds() -> list[dict]:
32+
"""Aggregate cached items across all feeds; empty list when none fresh."""
33+
base = _feeds_dir()
34+
if not base.exists():
35+
return []
36+
nvd_cache = FeedCache(base / "nvd.json", ttl=timedelta(hours=12))
37+
arxiv_cache = FeedCache(base / "arxiv.json", ttl=timedelta(hours=48))
38+
ietf_cache = FeedCache(base / "ietf.json", ttl=timedelta(hours=24))
39+
return get_external_seeds(
40+
nvd_cache=nvd_cache,
41+
arxiv_cache=arxiv_cache,
42+
ietf_cache=ietf_cache,
43+
max_per_feed=3,
44+
)
45+
46+
2347
async def pick_category(db: Database) -> IdeaCategory:
2448
"""Pick a category, avoiding recent repeats and saturated categories."""
2549
recent = await db.get_recent_categories(limit=3)
@@ -96,6 +120,11 @@ async def generate_and_store(db: Database, generator: IdeaGenerator) -> Idea:
96120

97121
filter_summary = await build_filter_summary(db)
98122

123+
# Pull fresh external seed material (NVD CVEs, arXiv papers, IETF drafts)
124+
# from on-disk feed caches. Empty when caches are stale/missing — feed
125+
# refresh is a separate cron concern.
126+
external_seeds = _load_external_seeds()
127+
99128
run = GenerationRun(category=category)
100129

101130
try:
@@ -106,6 +135,7 @@ async def generate_and_store(db: Database, generator: IdeaGenerator) -> Idea:
106135
use_combinatoric=use_combinatoric,
107136
portfolio_context=portfolio_context,
108137
filter_summary=filter_summary,
138+
external_seeds=external_seeds,
109139
)
110140
result = review_idea(idea)
111141
if not result.passed:

src/project_forge/engine/generator.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ async def generate(
3333
portfolio_context: str | None = None,
3434
*,
3535
filter_summary: dict | None = None,
36+
external_seeds: list[dict] | None = None,
3637
) -> Idea:
3738
if prompt_override is not None:
3839
prompt = prompt_override
@@ -44,6 +45,7 @@ async def generate(
4445
use_combinatoric=use_combinatoric,
4546
portfolio_context=portfolio_context,
4647
filter_summary=filter_summary,
48+
external_seeds=external_seeds,
4749
)
4850

4951
logger.info("Generating idea for category: %s", category.value)

src/project_forge/engine/prompts.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
2828
{diversity_section}
2929
30-
{portfolio_section}{saturation_section}IMPORTANT CONSTRAINTS:
30+
{portfolio_section}{saturation_section}{external_signals_section}IMPORTANT CONSTRAINTS:
3131
- The idea must be DIFFERENT from these recently generated ideas: {recent_ideas}
3232
- Think about what's MISSING in the market, not what already exists
3333
- Consider the intersection of this category with unexpected domains
@@ -116,6 +116,23 @@ def build_url_ingest_prompt(
116116
)
117117

118118

119+
def _format_external_signals_section(external_seeds: list[dict] | None) -> str:
120+
"""Format the external-signals block. Empty string when no seeds."""
121+
if not external_seeds:
122+
return ""
123+
124+
from project_forge.feeds import format_for_prompt
125+
126+
rendered = format_for_prompt(external_seeds, max_items=5)
127+
if not rendered:
128+
return ""
129+
return (
130+
"EXTERNAL SIGNALS — recent items from CVE feeds, arXiv, IETF drafts. "
131+
"These are FRESH starting points; the gap they point to is real and current.\n"
132+
f"{rendered}\n\n"
133+
)
134+
135+
119136
def _format_saturation_section(filter_summary: dict | None) -> str:
120137
"""Format the saturation block. Empty string when no useful data."""
121138
if not filter_summary:
@@ -145,6 +162,7 @@ def build_generation_prompt(
145162
portfolio_context: str | None = None,
146163
*,
147164
filter_summary: dict | None = None,
165+
external_seeds: list[dict] | None = None,
148166
) -> str:
149167
seeds = CATEGORY_SEEDS[category]
150168
diversity_section = ""
@@ -194,6 +212,7 @@ def build_generation_prompt(
194212
diversity_section=diversity_section,
195213
portfolio_section=portfolio_section,
196214
saturation_section=_format_saturation_section(filter_summary),
215+
external_signals_section=_format_external_signals_section(external_seeds),
197216
recent_ideas=recent_str,
198217
category_value=category.value,
199218
)

src/project_forge/feeds/__init__.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,46 @@
11
"""External signal feeds for fresh idea seeds.
22
33
Each feed module provides:
4-
- fetch(): network call + cache write
5-
- load_cached(): read items from cache (None if stale/missing)
6-
- health(): FeedHealth status
4+
- fetch(*, cache): network call + cache write, returns parsed items
5+
- load(cache): read items from cache (None if stale/missing)
76
87
Common helpers in this package:
98
- FeedCache: file-backed JSON cache with TTL
109
- FeedHealth: ok/age/count summary
1110
- format_for_prompt: render items as seed lines for build_generation_prompt
11+
- get_external_seeds: aggregate items across all feeds (skip unhealthy)
1212
"""
1313

1414
from __future__ import annotations
1515

16+
from typing import TYPE_CHECKING
17+
18+
if TYPE_CHECKING:
19+
from project_forge.feeds.cache import FeedCache
20+
21+
22+
def get_external_seeds(
23+
*,
24+
nvd_cache: FeedCache | None = None,
25+
arxiv_cache: FeedCache | None = None,
26+
ietf_cache: FeedCache | None = None,
27+
max_per_feed: int = 5,
28+
) -> list[dict]:
29+
"""Aggregate cached items across all healthy feeds.
30+
31+
Stale/missing caches are silently skipped — never raise. Caller passes
32+
None for any feed it doesn't want included.
33+
"""
34+
out: list[dict] = []
35+
for cache in (nvd_cache, arxiv_cache, ietf_cache):
36+
if cache is None:
37+
continue
38+
items = cache.read()
39+
if not items:
40+
continue
41+
out.extend(items[:max_per_feed])
42+
return out
43+
1644

1745
def format_for_prompt(items: list[dict], max_items: int = 5) -> str:
1846
"""Render feed items as compact seed lines for the LLM prompt.
@@ -34,4 +62,4 @@ def format_for_prompt(items: list[dict], max_items: int = 5) -> str:
3462
return "\n".join(lines)
3563

3664

37-
__all__ = ["format_for_prompt"]
65+
__all__ = ["format_for_prompt", "get_external_seeds"]

src/project_forge/feeds/_http.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Shared HTTP helper for feed fetchers.
2+
3+
Single GET that returns bytes. Raises on any failure so the caller can
4+
log + degrade gracefully. Kept tiny and testable — the production stdlib
5+
path is opaque to type checkers, but the function is easy to monkey-patch.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import urllib.error
11+
import urllib.request
12+
13+
14+
def http_get_bytes(url: str, *, timeout: float = 15.0) -> bytes:
15+
"""Fetch a URL and return raw bytes. Raises on any HTTP/network error."""
16+
req = urllib.request.Request( # noqa: S310 — feeds use https://, validated by caller
17+
url,
18+
headers={"User-Agent": "project-forge/feeds 0.1"},
19+
)
20+
try:
21+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
22+
return resp.read()
23+
except urllib.error.URLError as exc:
24+
raise OSError(f"http_get_bytes failed for {url}: {exc}") from exc

src/project_forge/feeds/arxiv.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
1-
"""arXiv Atom feed parser for the cs.CR security category.
1+
"""arXiv Atom feed parser + fetcher for the cs.CR security category.
22
33
The arXiv API returns Atom XML; we parse it with the stdlib (no extra deps).
4+
The fetcher writes to FeedCache and degrades to empty list on network failure.
45
"""
56

67
from __future__ import annotations
78

89
import logging
910
import xml.etree.ElementTree as ET
11+
from typing import TYPE_CHECKING
12+
13+
from project_forge.feeds._http import http_get_bytes as _http_get_bytes
14+
15+
if TYPE_CHECKING:
16+
from project_forge.feeds.cache import FeedCache
1017

1118
logger = logging.getLogger(__name__)
1219

1320
_NS = "{http://www.w3.org/2005/Atom}"
21+
ARXIV_API_URL = "http://export.arxiv.org/api/query"
1422

1523

1624
def parse_arxiv_atom(xml_text: str) -> list[dict]:
@@ -43,3 +51,24 @@ def parse_arxiv_atom(xml_text: str) -> list[dict]:
4351
"ts": (published_elem.text if published_elem is not None and published_elem.text else "").strip(),
4452
})
4553
return items
54+
55+
56+
def fetch(*, cache: FeedCache, category: str = "cs.CR", max_results: int = 25) -> list[dict]:
57+
"""Fetch recent arXiv papers in `category` and write to cache."""
58+
url = (
59+
f"{ARXIV_API_URL}?search_query=cat:{category}"
60+
f"&sortBy=submittedDate&sortOrder=descending&max_results={max_results}"
61+
)
62+
try:
63+
raw = _http_get_bytes(url, timeout=15.0)
64+
except OSError as exc:
65+
logger.warning("arXiv fetch failed: %s", exc)
66+
return []
67+
68+
items = parse_arxiv_atom(raw.decode("utf-8", errors="replace"))
69+
cache.write(items)
70+
return items
71+
72+
73+
def load(cache: FeedCache) -> list[dict] | None:
74+
return cache.read()

src/project_forge/feeds/ietf.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""IETF Internet-Drafts RSS feed parser + fetcher.
2+
3+
Pulls from the IETF Datatracker's RSS for new draft submissions:
4+
https://datatracker.ietf.org/feed/last-call/
5+
6+
Each item names a current standardization effort, which is great seed
7+
material — concrete, technical, time-stamped, and pre-standardization
8+
(real opportunity space for tooling).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import logging
14+
import xml.etree.ElementTree as ET
15+
from typing import TYPE_CHECKING
16+
17+
from project_forge.feeds._http import http_get_bytes as _http_get_bytes
18+
19+
if TYPE_CHECKING:
20+
from project_forge.feeds.cache import FeedCache
21+
22+
logger = logging.getLogger(__name__)
23+
24+
IETF_FEED_URL = "https://datatracker.ietf.org/feed/last-call/"
25+
26+
27+
def parse_ietf_rss(xml_text: str) -> list[dict]:
28+
"""Convert IETF RSS XML into feed items."""
29+
try:
30+
root = ET.fromstring(xml_text) # noqa: S314 — input is from datatracker.ietf.org
31+
except ET.ParseError as exc:
32+
logger.warning("Failed to parse IETF RSS: %s", exc)
33+
return []
34+
35+
items: list[dict] = []
36+
for item in root.iter("item"):
37+
title = (item.findtext("title") or "").strip()
38+
desc = (item.findtext("description") or "").strip()
39+
link = (item.findtext("link") or "").strip()
40+
pub = (item.findtext("pubDate") or "").strip()
41+
if not title:
42+
continue
43+
items.append({
44+
"id": title, # draft name is the natural id
45+
"title": title,
46+
"summary": desc,
47+
"url": link,
48+
"ts": pub,
49+
})
50+
return items
51+
52+
53+
def fetch(*, cache: FeedCache) -> list[dict]:
54+
"""Fetch IETF I-D last-call RSS and write to cache."""
55+
try:
56+
raw = _http_get_bytes(IETF_FEED_URL, timeout=15.0)
57+
except OSError as exc:
58+
logger.warning("IETF fetch failed: %s", exc)
59+
return []
60+
61+
items = parse_ietf_rss(raw.decode("utf-8", errors="replace"))
62+
cache.write(items)
63+
return items
64+
65+
66+
def load(cache: FeedCache) -> list[dict] | None:
67+
return cache.read()

0 commit comments

Comments
 (0)