Gen. Overseer Lupo commited on
Commit
99589b3
Β·
1 Parent(s): 67b91ae

Add local extra JSON source and update config

Browse files
Files changed (12) hide show
  1. # +0 -0
  2. a +0 -0
  3. app/ingest.py +134 -175
  4. app/main.py +31 -23
  5. app/normalize.py +102 -0
  6. app/paths.py +16 -0
  7. app/sources/grantsgov_api.py +104 -45
  8. app/ui_streamlit.py +55 -11
  9. config/v6.yaml +57 -0
  10. ensure +0 -0
  11. is +0 -0
  12. package +0 -0
# ADDED
File without changes
a ADDED
File without changes
app/ingest.py CHANGED
@@ -1,206 +1,165 @@
1
- import os, json
 
 
2
  from pathlib import Path
3
- from typing import Dict, List
 
4
  import yaml
5
- from sentence_transformers import SentenceTransformer
6
  import numpy as np
 
7
 
8
- import json
9
- from pathlib import Path
10
-
11
- # --- helpers kept local to avoid circular/broken imports ---
12
-
13
- import json
14
- from pathlib import Path
15
- from typing import List, Dict, Any
16
-
17
- def load_fallback_json(path: str) -> List[Dict[str, Any]]:
18
- p = Path(path)
19
- with open(p, "r") as f:
20
- data = json.load(f)
21
- # Normalize to a list of dicts
22
- if isinstance(data, dict):
23
- data = data.get("items") or data.get("records") or [data]
24
- return data
25
-
26
- # replaces the previous map_to_records
27
- import hashlib
28
- from typing import List, Dict, Any
29
-
30
- def map_to_records(opp_hits: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
31
- """
32
- Normalize Grants.gov hits to our internal schema with a guaranteed 'id'.
33
- """
34
- def mk_id(h: Dict[str, Any]) -> str:
35
- # Prefer official ids; fall back to number; else hash a few fields
36
- rid = h.get("id") or h.get("number") or h.get("oppNumber") or ""
37
- if isinstance(rid, str) and rid.strip():
38
- return rid.strip()
39
- basis = f"{h.get('title','')}|{h.get('agencyName','')}|{h.get('openDate','')}"
40
- return hashlib.md5(basis.encode("utf-8")).hexdigest()
41
-
42
- recs: List[Dict[str, Any]] = []
43
- for h in opp_hits:
44
- title = h.get("title") or h.get("number") or "Untitled opportunity"
45
- grants_id = h.get("id")
46
- url = h.get("url") or (f"https://www.grants.gov/search-results-detail/{grants_id}" if grants_id else None)
47
-
48
- desc_bits = [
49
- h.get("agencyName") or "",
50
- f"Status: {h.get('oppStatus','')}".strip(),
51
- f"Opens: {h.get('openDate','')}".strip(),
52
- f"Closes: {h.get('closeDate','')}".strip(),
53
- ]
54
- description = " | ".join([b for b in desc_bits if b])
55
-
56
- recs.append({
57
- "id": mk_id(h),
58
- "title": title,
59
- "url": url,
60
- "description": description,
61
- "geo": h.get("geo") or [],
62
- "categories": h.get("aln") or h.get("fundingCategories") or [],
63
- "source": "grants.gov",
64
- })
65
- return recs
66
-
67
-
68
- def load_fallback_json(path: str):
69
- p = Path(path)
70
- with open(p, "r") as f:
71
- data = json.load(f)
72
- # Normalize to a list of dicts
73
- if isinstance(data, dict):
74
- data = data.get("items") or data.get("records") or [data]
75
- return data
76
 
77
 
 
78
 
79
  def load_config(cfg_path: str) -> Dict:
80
- with open(cfg_path, "r") as f:
81
  return yaml.safe_load(f)
82
 
83
- def ensure_dirs(env: Dict):
84
- Path(env["DOCSTORE_DIR"]).mkdir(parents=True, exist_ok=True)
85
- Path(env["INDEX_DIR"]).mkdir(parents=True, exist_ok=True)
86
-
87
- def hash_id(text: str) -> str:
88
- import hashlib
89
- return hashlib.sha1(text.encode("utf-8")).hexdigest()[:16]
90
-
91
- def normalize_record(src_name: str, rec: Dict) -> Dict:
92
- return {
93
- "id": rec.get("id") or hash_id(rec.get("title","") + rec.get("url","")),
94
- "source": src_name,
95
- "title": rec.get("title","").strip(),
96
- "summary": rec.get("summary","").strip(),
97
- "url": rec.get("url","").strip(),
98
- "deadline": rec.get("deadline","").strip(),
99
- "eligibility": rec.get("eligibility","").strip(),
100
- "agency": rec.get("agency","").strip(),
101
- "geo": rec.get("geo","").strip(),
102
- "categories": rec.get("categories", []),
103
- "raw": rec
104
- }
105
-
106
- def collect_from_grantsgov_api(src):
107
  """
108
- Call Grants.gov client and normalize results to a list[dict] for indexing.
 
109
  """
110
- # We import here to avoid circular imports at module load time.
111
- from app.sources.grantsgov_api import search_grants
112
- import json
113
-
114
- # Build payload from source config (keys expected by our search_grants client)
115
- page_size = int(src.get("page_size", 100))
116
- max_pages = int(src.get("max_pages", 10))
117
- payload = {
118
- "keyword": src.get("keyword", "") or src.get("keywords", ""),
119
- "oppNum": src.get("oppNum", ""),
120
- "eligibilities": src.get("eligibilities", []),
121
- "agencies": src.get("agencies", []),
122
- "oppStatuses": src.get("oppStatuses", "forecasted|posted"),
123
- "aln": src.get("aln", []),
124
- "fundingCategories": src.get("fundingCategories", []),
125
- }
126
-
127
- # Call the Grants.gov client
128
- result = search_grants(src.get("url", ""), payload, page_size=page_size, max_pages=max_pages)
129
-
130
- # Normalize: ensure we have list[dict] before mapping
131
- items = result.get("hits") if isinstance(result, dict) else result
132
- if isinstance(items, dict) and "oppHits" in items:
133
- items = items["oppHits"]
134
- if items is None:
135
- items = []
136
-
137
- norm_items = []
138
- for x in items:
139
- if isinstance(x, str):
140
- try:
141
- x = json.loads(x)
142
- except Exception:
143
- continue
144
- if isinstance(x, dict):
145
- norm_items.append(x)
146
-
147
- # Map to our internal record schema
148
- basic = map_to_records(norm_items)
149
- return basic
150
-
151
-
152
- def save_docstore(recs: List[Dict], env: Dict):
153
- ds_path = Path(env["DOCSTORE_DIR"]) / "docstore.jsonl"
154
- with open(ds_path, "w") as f:
155
  for r in recs:
156
- f.write(json.dumps(r) + "\n")
157
- return str(ds_path)
158
 
159
- def build_index(env: Dict):
160
- ds_path = Path(env["DOCSTORE_DIR"]) / "docstore.jsonl"
 
161
  if not ds_path.exists():
162
  raise RuntimeError("Docstore not found. Run ingest first.")
163
- model = SentenceTransformer("all-MiniLM-L6-v2")
164
- texts = []
165
- metas = []
166
- with open(ds_path, "r") as f:
 
167
  for line in f:
168
  rec = json.loads(line)
169
- text = " | ".join([rec.get("title",""), rec.get("summary",""), rec.get("eligibility",""), rec.get("agency","")])
170
- texts.append(text)
171
- metas.append({"id": rec["id"], "title": rec["title"], "url": rec["url"], "source": rec["source"], "geo": rec["geo"], "categories": rec["categories"]})
172
- emb = model.encode(texts, convert_to_numpy=True, show_progress_bar=True, normalize_embeddings=True)
173
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  import faiss
175
  dim = emb.shape[1]
176
  index = faiss.IndexFlatIP(dim)
177
  index.add(emb)
178
 
179
- Path(env["INDEX_DIR"]).mkdir(parents=True, exist_ok=True)
180
- faiss.write_index(index, str(Path(env["INDEX_DIR"]) / "faiss.index"))
181
- with open(Path(env["INDEX_DIR"]) / "meta.json", "w") as f:
182
- json.dump(metas, f)
183
  return len(texts)
184
 
185
- def ingest(cfg_path: str, env: Dict):
 
 
 
 
 
 
 
186
  cfg = load_config(cfg_path)
187
- ensure_dirs(env)
188
- all_recs = []
189
- for src in cfg.get("sources", []):
190
- if not src.get("enabled", False):
191
  continue
192
- if src["type"] == "grantsgov_api":
193
- recs = collect_from_grantsgov_api(src)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  else:
195
- recs = []
196
- all_recs.extend(recs)
197
- path = save_docstore(all_recs, env)
198
- n_indexed = build_index(env)
199
- return path, n_indexed
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
  if __name__ == "__main__":
202
- from dotenv import dotenv_values
203
- env = dotenv_values(".env")
204
- cfg_path = "config/v6.yaml"
205
- p, n = ingest(cfg_path, env)
 
206
  print(f"Ingested {n} records. Docstore at {p}")
 
1
+ # app/ingest.py
2
+ from __future__ import annotations
3
+ import json
4
  from pathlib import Path
5
+ from typing import Dict, List, Any
6
+
7
  import yaml
 
8
  import numpy as np
9
+ from sentence_transformers import SentenceTransformer
10
 
11
+ from app.paths import DOCSTORE_DIR, INDEX_DIR
12
+ from .normalize import normalize # ← central normalizer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
 
15
+ # -------------------- Config --------------------
16
 
17
  def load_config(cfg_path: str) -> Dict:
18
+ with open(cfg_path, "r", encoding="utf-8") as f:
19
  return yaml.safe_load(f)
20
 
21
+
22
+ # -------------------- Grants.gov collector --------------------
23
+
24
+ def _collect_from_grantsgov_api(src: Dict) -> List[Dict[str, Any]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  """
26
+ Calls the Grants.gov Search2 client and returns a list of RAW dicts
27
+ (adapter may already be close to unified; we'll still run normalize()).
28
  """
29
+ from app.sources.grantsgov_api import search_grants # local import to avoid cycles
30
+
31
+ api = src.get("api", {})
32
+ page_size = int(api.get("page_size", src.get("page_size", 100)))
33
+ max_pages = int(api.get("max_pages", src.get("max_pages", 5)))
34
+ payload = api.get("payload", src.get("payload", {}))
35
+ url = src.get("url", "")
36
+
37
+ out = search_grants(url, payload, page_size=page_size, max_pages=max_pages)
38
+ hits = out.get("hits", []) if isinstance(out, dict) else (out or [])
39
+ return [h for h in hits if isinstance(h, dict)]
40
+
41
+
42
+ # -------------------- Write docstore & build index --------------------
43
+
44
+ def _save_docstore(recs: List[Dict[str, Any]]) -> str:
45
+ DOCSTORE_DIR.mkdir(parents=True, exist_ok=True)
46
+ path = DOCSTORE_DIR / "docstore.jsonl"
47
+ with path.open("w", encoding="utf-8") as f:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  for r in recs:
49
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
50
+ return str(path)
51
 
52
+
53
+ def _build_index_from_docstore() -> int:
54
+ ds_path = DOCSTORE_DIR / "docstore.jsonl"
55
  if not ds_path.exists():
56
  raise RuntimeError("Docstore not found. Run ingest first.")
57
+
58
+ # Load records
59
+ texts: List[str] = []
60
+ metas: List[Dict[str, Any]] = []
61
+ with ds_path.open("r", encoding="utf-8") as f:
62
  for line in f:
63
  rec = json.loads(line)
64
+ title = rec.get("title") or ""
65
+ synopsis = rec.get("synopsis") or rec.get("summary") or ""
66
+ agency = rec.get("agency") or ""
67
+ eligibility = rec.get("eligibility") or ""
68
+ txt = "\n".join([title, synopsis, agency, eligibility]).strip()
69
+ texts.append(txt)
70
+
71
+ metas.append({
72
+ "id": rec.get("id"),
73
+ "title": title,
74
+ "url": rec.get("url"),
75
+ "source": rec.get("source"),
76
+ "geo": rec.get("geo"),
77
+ "categories": rec.get("categories"),
78
+ "agency": agency,
79
+ "deadline": rec.get("deadline"),
80
+ "program_number": rec.get("program_number"),
81
+ "posted_date": rec.get("posted_date"),
82
+ })
83
+
84
+ # Embed
85
+ model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2", device="cpu")
86
+ model.max_seq_length = 256
87
+ emb = model.encode(
88
+ texts,
89
+ convert_to_numpy=True,
90
+ normalize_embeddings=True,
91
+ show_progress_bar=True,
92
+ batch_size=32,
93
+ ).astype(np.float32, copy=False)
94
+
95
+ # FAISS index
96
  import faiss
97
  dim = emb.shape[1]
98
  index = faiss.IndexFlatIP(dim)
99
  index.add(emb)
100
 
101
+ INDEX_DIR.mkdir(parents=True, exist_ok=True)
102
+ faiss.write_index(index, str(INDEX_DIR / "faiss.index"))
103
+ (INDEX_DIR / "meta.json").write_text(json.dumps(metas, ensure_ascii=False))
104
+
105
  return len(texts)
106
 
107
+
108
+ # -------------------- Ingest main --------------------
109
+
110
+ def ingest(cfg_path: str = "config/sources.yaml", env: Dict | None = None):
111
+ """
112
+ Reads config, fetches from enabled sources, normalizes with a single map,
113
+ attaches categories/geo consistently, DEDUPEs, and builds the index.
114
+ """
115
  cfg = load_config(cfg_path)
116
+
117
+ all_rows: List[Dict[str, Any]] = []
118
+ for entry in cfg.get("sources", []):
119
+ if not entry.get("enabled"):
120
  continue
121
+
122
+ geo = entry.get("geo") or "US"
123
+ cats = entry.get("categories") or []
124
+ static = {"geo": geo, "categories": cats}
125
+
126
+ typ = entry.get("type")
127
+ rows: List[Dict[str, Any]] = []
128
+
129
+ if typ == "grantsgov_api":
130
+ raw_hits = _collect_from_grantsgov_api(entry)
131
+ rows = [normalize("grants_gov", h, static) for h in raw_hits]
132
+
133
+ elif typ == "local_sample":
134
+ p = Path(entry["path"]).expanduser()
135
+ blob = json.loads(p.read_text(encoding="utf-8"))
136
+ items = blob.get("opportunities") or []
137
+ rows = [normalize("local_sample", op, static) for op in items]
138
+
139
  else:
140
+ # Future adapters (doj_ojp, state_md, …) would plug in here using normalize("<key>", raw, static)
141
+ rows = []
142
+
143
+ all_rows.extend(rows)
144
+
145
+ # ---- DEDUPE (id β†’ url β†’ title) ----
146
+ seen, unique = set(), []
147
+ for r in all_rows:
148
+ key = r.get("id") or r.get("url") or r.get("title")
149
+ if not key or key in seen:
150
+ continue
151
+ seen.add(key)
152
+ unique.append(r)
153
+
154
+ path = _save_docstore(unique)
155
+ n = _build_index_from_docstore()
156
+ return path, n
157
+
158
 
159
  if __name__ == "__main__":
160
+ import argparse
161
+ ap = argparse.ArgumentParser()
162
+ ap.add_argument("--config", default="config/sources.yaml")
163
+ args = ap.parse_args()
164
+ p, n = ingest(args.config)
165
  print(f"Ingested {n} records. Docstore at {p}")
app/main.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import argparse, os, json
2
  from pathlib import Path
3
  from dotenv import dotenv_values
@@ -5,38 +6,43 @@ import pandas as pd
5
 
6
  from app.ingest import ingest
7
  from app.search import search
 
8
 
9
 
10
  def get_env():
11
  """
12
- Load environment paths. On Hugging Face Spaces, fall back to local 'data/' dirs.
13
- Never use absolute paths like /Users/... which don't exist in the container.
 
 
14
  """
15
- env = dotenv_values(".env") or {}
 
16
 
17
- # Root of the repo (parent of this file's folder)
18
- ROOT = Path(__file__).resolve().parents[1]
 
19
 
20
- def default_path(sub):
21
- return str(ROOT / sub)
 
 
 
22
 
23
- env.setdefault("DATA_DIR", default_path("data"))
24
- env.setdefault("DOCSTORE_DIR", default_path("data/docstore"))
25
- env.setdefault("INDEX_DIR", default_path("data/index"))
26
- env.setdefault("EXPORT_DIR", default_path("data/exports"))
27
 
28
- for k in ["DATA_DIR", "DOCSTORE_DIR", "INDEX_DIR", "EXPORT_DIR"]:
29
- os.makedirs(env[k], exist_ok=True)
 
30
 
31
  return env
32
 
33
 
34
-
35
  def ensure_index_exists(env: dict):
36
  """
37
  Ensure a FAISS index exists in env['INDEX_DIR'].
38
  If missing, run a minimal ingest using config/sources.yaml.
39
- This lets the Hugging Face Space self-heal on first boot.
40
  """
41
  index_dir = Path(env["INDEX_DIR"])
42
  faiss_idx = index_dir / "faiss.index"
@@ -46,14 +52,13 @@ def ensure_index_exists(env: dict):
46
  return # already built
47
 
48
  print("Index not found. Building now via ingest() …")
49
- # NOTE: This uses your existing ingestion pipeline.
50
- # If your ingest relies on API keys, set them in the Space's
51
- # Settings β†’ Variables and secrets, then Restart the Space.
52
  path, n = ingest("config/sources.yaml", env)
53
  print(f"Ingest complete. {n} records. Docstore: {path}")
54
 
55
 
56
- def cmd_ingest(args):
57
  env = get_env()
58
  path, n = ingest("config/sources.yaml", env)
59
  print(f"Ingest complete. {n} records. Docstore: {path}")
@@ -61,7 +66,7 @@ def cmd_ingest(args):
61
 
62
  def cmd_search(args):
63
  env = get_env()
64
- ensure_index_exists(env) # <β€” NEW: auto-build if missing
65
  filters = {}
66
  if args.geo:
67
  filters["geo"] = args.geo.split(",")
@@ -69,13 +74,16 @@ def cmd_search(args):
69
  filters["categories"] = args.categories.split(",")
70
  res = search(args.q, env, top_k=args.k, filters=filters)
71
  for r in res:
72
- print(f"- {r['title']} [{r['source']}] ({r['geo']}) score={r['score']:.3f}")
73
- print(f" {r['url']}")
 
 
 
74
 
75
 
76
  def cmd_export(args):
77
  env = get_env()
78
- ensure_index_exists(env) # <β€” NEW: auto-build if missing
79
  filters = {}
80
  if args.geo:
81
  filters["geo"] = args.geo.split(",")
 
1
+ # app/main.py
2
  import argparse, os, json
3
  from pathlib import Path
4
  from dotenv import dotenv_values
 
6
 
7
  from app.ingest import ingest
8
  from app.search import search
9
+ from app.paths import DATA_DIR, DOCSTORE_DIR, INDEX_DIR, EXPORT_DIR # ← canonical paths
10
 
11
 
12
  def get_env():
13
  """
14
+ Load environment with safe, repo-relative defaults from app.paths.
15
+ - Honors .env (dotenv) and real env vars if set.
16
+ - Falls back to ./data, ./data/docstore, ./data/index, ./data/exports
17
+ which work on macOS AND Hugging Face Spaces.
18
  """
19
+ # 1) Start with .env (if present)
20
+ env = dict(dotenv_values(".env") or {})
21
 
22
+ # 2) Merge in process env (so Space secrets / shell vars override .env)
23
+ for k, v in os.environ.items():
24
+ env[k] = v
25
 
26
+ # 3) Provide safe defaults from app.paths if not specified
27
+ env.setdefault("DATA_DIR", str(DATA_DIR))
28
+ env.setdefault("DOCSTORE_DIR", str(DOCSTORE_DIR))
29
+ env.setdefault("INDEX_DIR", str(INDEX_DIR))
30
+ env.setdefault("EXPORT_DIR", str(EXPORT_DIR))
31
 
32
+ # Optional UI/debug flags
33
+ env.setdefault("SHOW_DEV", "0")
 
 
34
 
35
+ # 4) Ensure directories exist
36
+ for k in ("DATA_DIR", "DOCSTORE_DIR", "INDEX_DIR", "EXPORT_DIR"):
37
+ Path(env[k]).mkdir(parents=True, exist_ok=True)
38
 
39
  return env
40
 
41
 
 
42
  def ensure_index_exists(env: dict):
43
  """
44
  Ensure a FAISS index exists in env['INDEX_DIR'].
45
  If missing, run a minimal ingest using config/sources.yaml.
 
46
  """
47
  index_dir = Path(env["INDEX_DIR"])
48
  faiss_idx = index_dir / "faiss.index"
 
52
  return # already built
53
 
54
  print("Index not found. Building now via ingest() …")
55
+ # Ingest reads config and writes index/meta/docstore
56
+ # If your ingest needs API keys, set them in Space Settings β†’ Variables
 
57
  path, n = ingest("config/sources.yaml", env)
58
  print(f"Ingest complete. {n} records. Docstore: {path}")
59
 
60
 
61
+ def cmd_ingest(_args):
62
  env = get_env()
63
  path, n = ingest("config/sources.yaml", env)
64
  print(f"Ingest complete. {n} records. Docstore: {path}")
 
66
 
67
  def cmd_search(args):
68
  env = get_env()
69
+ ensure_index_exists(env)
70
  filters = {}
71
  if args.geo:
72
  filters["geo"] = args.geo.split(",")
 
74
  filters["categories"] = args.categories.split(",")
75
  res = search(args.q, env, top_k=args.k, filters=filters)
76
  for r in res:
77
+ geo = r.get("geo")
78
+ if isinstance(geo, list):
79
+ geo = ",".join(geo)
80
+ print(f"- {r.get('title','(no title)')} [{r.get('source','')}] ({geo}) score={r.get('score',0):.3f}")
81
+ print(f" {r.get('url','')}")
82
 
83
 
84
  def cmd_export(args):
85
  env = get_env()
86
+ ensure_index_exists(env)
87
  filters = {}
88
  if args.geo:
89
  filters["geo"] = args.geo.split(",")
app/normalize.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, Callable, Optional
2
+ from datetime import datetime
3
+
4
+ def _iso(d: Any) -> Optional[str]:
5
+ if not d:
6
+ return None
7
+ s = str(d)
8
+ for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"):
9
+ try:
10
+ return datetime.strptime(s, fmt).date().isoformat()
11
+ except Exception:
12
+ pass
13
+ try:
14
+ return datetime.fromisoformat(s).date().isoformat()
15
+ except Exception:
16
+ return None
17
+
18
+ def _first(x: Any) -> Any:
19
+ return (x[0] if isinstance(x, (list, tuple)) and x else x)
20
+
21
+ def _list(x: Any) -> list:
22
+ if x is None:
23
+ return []
24
+ if isinstance(x, list):
25
+ return x
26
+ if isinstance(x, (set, tuple)):
27
+ return list(x)
28
+ return [x]
29
+
30
+ # Registry of source mappers: raw -> unified schema
31
+ MAPPERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {}
32
+
33
+ def mapper(name: str):
34
+ def _wrap(fn: Callable[[Dict[str, Any]], Dict[str, Any]]):
35
+ MAPPERS[name] = fn
36
+ return fn
37
+ return _wrap
38
+
39
+ @mapper("grants_gov")
40
+ def _map_grants_gov(h: Dict[str, Any]) -> Dict[str, Any]:
41
+ gg_id = h.get("id")
42
+ num = h.get("number")
43
+ aln_list = h.get("alnist") or h.get("aln") or []
44
+
45
+ out: Dict[str, Any] = {
46
+ "id": f"gg:{num or gg_id}",
47
+ "source": "grants.gov",
48
+ "title": h.get("title"),
49
+ "agency": h.get("agencyName") or h.get("agencyCode") or h.get("agency"),
50
+ "program_number": _first(aln_list) or h.get("program_number"),
51
+ "posted_date": _iso(h.get("openDate") or h.get("posted_date")),
52
+ "deadline": _iso(h.get("closeDate") or h.get("deadline")),
53
+ "synopsis": h.get("synopsis") or h.get("summary"),
54
+ "location_scope": h.get("location_scope") or ["US"],
55
+ "tags": h.get("tags") or [],
56
+ "url": h.get("url") or (f"https://www.grants.gov/search-results-detail/{gg_id}" if gg_id else None),
57
+ "raw": h,
58
+ }
59
+ # Optionals if present on the raw record
60
+ for k_src, k_dst in [
61
+ ("awardFloor", "award_floor"),
62
+ ("awardCeiling", "award_ceiling"),
63
+ ("expectedNumberOfAwards", "expected_awards"),
64
+ ("eligibility", "eligibility"),
65
+ ]:
66
+ if h.get(k_src) is not None or h.get(k_dst) is not None:
67
+ out[k_dst] = h.get(k_dst) if h.get(k_dst) is not None else h.get(k_src)
68
+ return out
69
+
70
+ @mapper("local_sample")
71
+ def _map_local_sample(op: Dict[str, Any]) -> Dict[str, Any]:
72
+ return {
73
+ "id": f"sample:{op.get('opportunityNumber')}",
74
+ "source": "sample_local",
75
+ "title": op.get("opportunityTitle"),
76
+ "agency": op.get("agency"),
77
+ "program_number": None,
78
+ "posted_date": _iso(op.get("postedDate")),
79
+ "deadline": _iso(op.get("closeDate")),
80
+ "synopsis": op.get("synopsis"),
81
+ "location_scope": ["US"],
82
+ "tags": [],
83
+ "url": None,
84
+ "raw": op,
85
+ }
86
+
87
+ def normalize(source_key: str, raw: Dict[str, Any], static: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
88
+ if source_key not in MAPPERS:
89
+ raise KeyError("No mapper registered for %r" % source_key)
90
+ rec = MAPPERS[source_key](raw)
91
+ static = static or {}
92
+ # attach geo
93
+ if static.get("geo"):
94
+ rec["geo"] = static["geo"]
95
+ # attach categories and mirror into tags
96
+ cats = _list(static.get("categories"))
97
+ rec.setdefault("categories", [])
98
+ for c in cats:
99
+ if c not in rec["categories"]:
100
+ rec["categories"].append(c)
101
+ rec["tags"] = list(set(_list(rec.get("tags")) + cats))
102
+ return rec
app/paths.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/paths.py
2
+ import os
3
+ import pathlib
4
+
5
+ # Repo root (…/grants_rag_app)
6
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
7
+
8
+ # Defaults: repo-relative folders that work on Mac AND on Hugging Face
9
+ DATA_DIR = pathlib.Path(os.getenv("DATA_DIR", ROOT / "data"))
10
+ DOCSTORE_DIR = pathlib.Path(os.getenv("DOCSTORE_DIR", DATA_DIR / "docstore"))
11
+ INDEX_DIR = pathlib.Path(os.getenv("INDEX_DIR", DATA_DIR / "index"))
12
+ EXPORT_DIR = pathlib.Path(os.getenv("EXPORT_DIR", DATA_DIR / "exports"))
13
+
14
+ # Make sure they exist (no-ops if they already do)
15
+ for p in (DATA_DIR, DOCSTORE_DIR, INDEX_DIR, EXPORT_DIR):
16
+ p.mkdir(parents=True, exist_ok=True)
app/sources/grantsgov_api.py CHANGED
@@ -1,46 +1,85 @@
1
  # app/sources/grantsgov_api.py
2
  from __future__ import annotations
 
 
3
  import requests
4
- from typing import Dict, List, Any
5
 
6
- API_URL = "https://api.grants.gov/v1/api/search2" # official search endpoint
 
7
 
8
  def _coerce_pipe(v: Any) -> str:
9
- """
10
- Accepts list/tuple/str/None and returns Grants.gov pipe-delimited string.
11
- """
12
  if v is None:
13
  return ""
14
  if isinstance(v, (list, tuple, set)):
15
  return "|".join([str(x) for x in v if x])
16
  return str(v)
17
 
18
- def search_grants(_unused_url: str, payload: Dict[str, Any], page_size: int = 100, max_pages: int = 10, timeout: int = 30) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  """
20
- Calls Grants.gov Search2 API with pagination.
21
- - _unused_url: kept for backward compatibility with your ingest() signature.
22
- - payload: may include keys: keyword, oppNum, eligibilities, agencies, oppStatuses, aln, fundingCategories
23
- - page_size: results per page (API uses 'rows')
24
- - max_pages: hard cap to avoid runaway calls
25
-
26
- Returns a dict with:
27
- {
28
- "hits": [ ...normalized opportunties... ],
29
- "hitCount": int
30
- }
31
  """
32
  all_hits: List[Dict[str, Any]] = []
33
  start = 0
34
  pages = 0
35
- hit_count = None
36
 
37
- # Normalize allowable filters
 
38
  keyword = payload.get("keyword", "") or payload.get("keywords", "")
39
  oppNum = payload.get("oppNum", "")
40
  eligibilities = _coerce_pipe(payload.get("eligibilities", ""))
41
- agencies = _coerce_pipe(payload.get("agencies", ""))
42
- oppStatuses = _coerce_pipe(payload.get("oppStatuses", "")) or "forecasted|posted"
43
- aln = _coerce_pipe(payload.get("aln", ""))
44
  fundingCategories = _coerce_pipe(payload.get("fundingCategories", ""))
45
 
46
  session = requests.Session()
@@ -49,6 +88,7 @@ def search_grants(_unused_url: str, payload: Dict[str, Any], page_size: int = 10
49
  while pages < max_pages:
50
  req_body = {
51
  "rows": page_size,
 
52
  "keyword": keyword,
53
  "oppNum": oppNum,
54
  "eligibilities": eligibilities,
@@ -56,42 +96,61 @@ def search_grants(_unused_url: str, payload: Dict[str, Any], page_size: int = 10
56
  "oppStatuses": oppStatuses,
57
  "aln": aln,
58
  "fundingCategories": fundingCategories,
59
- "startRecordNum": start, # paginate
60
  }
61
 
62
  resp = session.post(API_URL, json=req_body, headers=headers, timeout=timeout)
63
  resp.raise_for_status()
64
- j = resp.json()
65
 
66
- data = j.get("data", {}) or {}
67
  if hit_count is None:
68
- hit_count = int(data.get("hitCount", 0))
 
 
 
69
 
70
- opp_hits = data.get("oppHits", []) or []
71
- # Normalize fields to your expected schema
 
 
 
72
  for h in opp_hits:
73
- all_hits.append({
74
- "id": h.get("id"),
75
- "number": h.get("number"),
76
- "title": h.get("title"),
77
- "agencyCode": h.get("agencyCode"),
78
- "agencyName": h.get("agencyName"),
79
- "openDate": h.get("openDate"),
80
- "closeDate": h.get("closeDate"),
81
- "oppStatus": h.get("oppStatus"),
82
- "docType": h.get("docType"),
83
- "aln": h.get("alnist", []), # list of ALNs
84
  "source": "grants.gov",
85
- "url": f"https://www.grants.gov/search-results-detail/{h.get('id')}" if h.get("id") else None,
86
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
- # advance
89
  got = len(opp_hits)
90
- if got == 0:
91
- break
92
  start += got
93
  pages += 1
94
- if start >= hit_count:
95
  break
96
 
97
  return {"hits": all_hits, "hitCount": hit_count or 0}
 
1
  # app/sources/grantsgov_api.py
2
  from __future__ import annotations
3
+ from typing import Dict, List, Any, Optional
4
+ from datetime import datetime
5
  import requests
 
6
 
7
+ # Official Grants.gov Search2 endpoint (JSON POST)
8
+ API_URL = "https://api.grants.gov/v1/api/search2"
9
 
10
  def _coerce_pipe(v: Any) -> str:
11
+ """Accept list/tuple/set/str/None and return pipe-delimited string."""
 
 
12
  if v is None:
13
  return ""
14
  if isinstance(v, (list, tuple, set)):
15
  return "|".join([str(x) for x in v if x])
16
  return str(v)
17
 
18
+ def _first(x: Any) -> Optional[str]:
19
+ if isinstance(x, (list, tuple)) and x:
20
+ return str(x[0])
21
+ return str(x) if x is not None else None
22
+
23
+ def _parse_date(d: Any) -> Optional[str]:
24
+ """Return YYYY-MM-DD or None (be tolerant to formats)."""
25
+ if not d:
26
+ return None
27
+ s = str(d)
28
+ # common formats seen in the API
29
+ for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"):
30
+ try:
31
+ return datetime.strptime(s, fmt).date().isoformat()
32
+ except Exception:
33
+ pass
34
+ try:
35
+ return datetime.fromisoformat(s).date().isoformat()
36
+ except Exception:
37
+ return None
38
+
39
+ # Map common config keys β†’ API keys so older configs still work
40
+ _KEY_MAP = {
41
+ "opportunityStatuses": "oppStatuses",
42
+ "agencyCodes": "agencies",
43
+ "agencies": "agencies",
44
+ "alns": "aln",
45
+ }
46
+
47
+ def _remap_payload_keys(payload: Dict[str, Any]) -> Dict[str, Any]:
48
+ out = dict(payload or {})
49
+ for k, v in list(out.items()):
50
+ if k in _KEY_MAP:
51
+ out[_KEY_MAP[k]] = v
52
+ return out
53
+
54
+ def search_grants(
55
+ _unused_url: str,
56
+ payload: Dict[str, Any],
57
+ page_size: int = 100,
58
+ max_pages: int = 10,
59
+ timeout: int = 30,
60
+ ) -> Dict[str, Any]:
61
  """
62
+ Calls Grants.gov Search2 API with pagination and returns normalized results:
63
+
64
+ Returns:
65
+ {
66
+ "hits": [ { unified schema per record }, ... ],
67
+ "hitCount": int
68
+ }
 
 
 
 
69
  """
70
  all_hits: List[Dict[str, Any]] = []
71
  start = 0
72
  pages = 0
73
+ hit_count: Optional[int] = None
74
 
75
+ # Bridge payload keys and coerce to API expectations
76
+ payload = _remap_payload_keys(payload or {})
77
  keyword = payload.get("keyword", "") or payload.get("keywords", "")
78
  oppNum = payload.get("oppNum", "")
79
  eligibilities = _coerce_pipe(payload.get("eligibilities", ""))
80
+ agencies = _coerce_pipe(payload.get("agencies", ""))
81
+ oppStatuses = _coerce_pipe(payload.get("oppStatuses", "")) or "forecasted|posted"
82
+ aln = _coerce_pipe(payload.get("aln", ""))
83
  fundingCategories = _coerce_pipe(payload.get("fundingCategories", ""))
84
 
85
  session = requests.Session()
 
88
  while pages < max_pages:
89
  req_body = {
90
  "rows": page_size,
91
+ "startRecordNum": start, # pagination
92
  "keyword": keyword,
93
  "oppNum": oppNum,
94
  "eligibilities": eligibilities,
 
96
  "oppStatuses": oppStatuses,
97
  "aln": aln,
98
  "fundingCategories": fundingCategories,
 
99
  }
100
 
101
  resp = session.post(API_URL, json=req_body, headers=headers, timeout=timeout)
102
  resp.raise_for_status()
103
+ j = resp.json() or {}
104
 
105
+ data = j.get("data") or {}
106
  if hit_count is None:
107
+ try:
108
+ hit_count = int(data.get("hitCount", 0))
109
+ except Exception:
110
+ hit_count = 0
111
 
112
+ opp_hits = data.get("oppHits") or []
113
+ if not opp_hits:
114
+ break
115
+
116
+ # ---- Normalize each record to unified schema ----
117
  for h in opp_hits:
118
+ gg_id = h.get("id")
119
+ num = h.get("number")
120
+ aln_list = h.get("alnist", []) or []
121
+
122
+ norm = {
123
+ # unified schema (stable id avoids duplicates across configs)
124
+ "id": f"gg:{num or gg_id}",
 
 
 
 
125
  "source": "grants.gov",
126
+ "title": h.get("title"),
127
+ "agency": h.get("agencyName") or h.get("agencyCode"),
128
+ "program_number": _first(aln_list), # Assistance Listing (ALN/CFDA)
129
+ "posted_date": _parse_date(h.get("openDate")),
130
+ "deadline": _parse_date(h.get("closeDate")),
131
+ "synopsis": h.get("synopsis") or h.get("summary"),
132
+ "location_scope": ["US"], # Grants.gov is US-wide by default
133
+ "tags": [], # to be extended by ingest with config categories
134
+ "url": f"https://www.grants.gov/search-results-detail/{gg_id}" if gg_id else None,
135
+ "raw": h, # keep full source blob for traceability
136
+ }
137
+
138
+ # Optional award fields if present (keep None if absent)
139
+ if "awardFloor" in h:
140
+ norm["award_floor"] = h.get("awardFloor")
141
+ if "awardCeiling" in h:
142
+ norm["award_ceiling"] = h.get("awardCeiling")
143
+ if "expectedNumberOfAwards" in h:
144
+ norm["expected_awards"] = h.get("expectedNumberOfAwards")
145
+ if "eligibility" in h:
146
+ norm["eligibility"] = h.get("eligibility")
147
+
148
+ all_hits.append(norm)
149
 
 
150
  got = len(opp_hits)
 
 
151
  start += got
152
  pages += 1
153
+ if hit_count is not None and start >= hit_count:
154
  break
155
 
156
  return {"hits": all_hits, "hitCount": hit_count or 0}
app/ui_streamlit.py CHANGED
@@ -1,4 +1,11 @@
1
  # app/ui_streamlit.py
 
 
 
 
 
 
 
2
  import os, json
3
  from pathlib import Path
4
 
@@ -148,8 +155,6 @@ span.chip { display:inline-block; padding:3px 8px; border-radius:999px; backgrou
148
  .hero-text h1 { margin:0; font-size:28px; font-weight:700; color:#f97316; }
149
  .hero-text p { margin:6px 0 0; font-size:15px; color:#fcd34d; } /* gold */
150
 
151
-
152
-
153
  /* ===== FORCE DARK SELECT / MULTISELECT (works across Streamlit versions) ===== */
154
 
155
  /* Closed control (the visible box) */
@@ -204,8 +209,6 @@ div[data-baseweb="menu"] [role="option"][aria-selected="true"] {
204
  background: #334155 !important;
205
  color: #f8fafc !important;
206
  }
207
-
208
-
209
  </style>
210
  """, unsafe_allow_html=True)
211
 
@@ -222,7 +225,6 @@ st.markdown("""
222
  # ── Hide developer diagnostics by default ─────────────────────────────────────
223
  SHOW_DEV = os.environ.get("SHOW_DEV") == "1"
224
 
225
-
226
  # ── Environment + index ───────────────────────────────────────────────────────
227
  _env = get_env()
228
  ensure_index_exists(_env)
@@ -285,11 +287,22 @@ q = st.text_input("Search query", value=default_q)
285
  geo = st.multiselect("Geo filter (optional)", options=["US", "MD", "MA"], default=[])
286
  categories = st.multiselect(
287
  "Category filter (optional)",
288
- options=["capacity_building", "elderly", "prison_ministry", "evangelism", "transportation", "vehicle"],
 
 
 
 
289
  default=[]
290
  )
291
 
 
292
  top_k = st.slider("Results", 5, 50, 15)
 
 
 
 
 
 
293
 
294
  # Build filters only when selected
295
  filters = {}
@@ -347,20 +360,46 @@ with col1:
347
 
348
  with col2:
349
  if st.button("Export Results to CSV"):
350
- results = st.session_state.get("results", [])
351
- if not results:
352
  st.warning("No results to export. Run a search first.")
353
  else:
354
  os.makedirs(_env["EXPORT_DIR"], exist_ok=True)
355
  out_path = os.path.join(_env["EXPORT_DIR"], "results.csv")
356
  import pandas as pd
357
- pd.DataFrame(results).to_csv(out_path, index=False)
358
  st.success(f"Exported to {out_path}")
359
 
360
-
361
  st.markdown("---")
362
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  results = st.session_state.get("results", [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  if results:
365
  st.caption(f"Results: {len(results)}")
366
  for r in results:
@@ -375,6 +414,11 @@ if results:
375
  if url and not url.startswith("http"):
376
  st.caption("Note: This item may display an ID or number instead of a full link. Open on Grants.gov if needed.")
377
  st.write(f"[Open Link]({url}) \nScore: {r.get('score', 0):.3f}")
 
 
 
 
 
378
  st.markdown("---")
379
  else:
380
- st.info("Enter a query and click Search.")
 
1
  # app/ui_streamlit.py
2
+
3
+ # Ensure project root is on sys.path when Streamlit runs this as a script
4
+ import sys, pathlib
5
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
6
+ if str(ROOT) not in sys.path:
7
+ sys.path.insert(0, str(ROOT))
8
+
9
  import os, json
10
  from pathlib import Path
11
 
 
155
  .hero-text h1 { margin:0; font-size:28px; font-weight:700; color:#f97316; }
156
  .hero-text p { margin:6px 0 0; font-size:15px; color:#fcd34d; } /* gold */
157
 
 
 
158
  /* ===== FORCE DARK SELECT / MULTISELECT (works across Streamlit versions) ===== */
159
 
160
  /* Closed control (the visible box) */
 
209
  background: #334155 !important;
210
  color: #f8fafc !important;
211
  }
 
 
212
  </style>
213
  """, unsafe_allow_html=True)
214
 
 
225
  # ── Hide developer diagnostics by default ─────────────────────────────────────
226
  SHOW_DEV = os.environ.get("SHOW_DEV") == "1"
227
 
 
228
  # ── Environment + index ───────────────────────────────────────────────────────
229
  _env = get_env()
230
  ensure_index_exists(_env)
 
287
  geo = st.multiselect("Geo filter (optional)", options=["US", "MD", "MA"], default=[])
288
  categories = st.multiselect(
289
  "Category filter (optional)",
290
+ options=[
291
+ "capacity_building","elderly","prison_ministry","evangelism",
292
+ "transportation","vehicle",
293
+ "justice","reentry","victim_services","youth","women","workforce"
294
+ ],
295
  default=[]
296
  )
297
 
298
+
299
  top_k = st.slider("Results", 5, 50, 15)
300
+ sort_by = st.selectbox(
301
+ "Sort by",
302
+ ["Relevance", "Deadline (soonest first)"],
303
+ index=0,
304
+ )
305
+ only_open = st.checkbox("Only show opportunities with a future deadline", value=True)
306
 
307
  # Build filters only when selected
308
  filters = {}
 
360
 
361
  with col2:
362
  if st.button("Export Results to CSV"):
363
+ results_for_export = st.session_state.get("results", [])
364
+ if not results_for_export:
365
  st.warning("No results to export. Run a search first.")
366
  else:
367
  os.makedirs(_env["EXPORT_DIR"], exist_ok=True)
368
  out_path = os.path.join(_env["EXPORT_DIR"], "results.csv")
369
  import pandas as pd
370
+ pd.DataFrame(results_for_export).to_csv(out_path, index=False)
371
  st.success(f"Exported to {out_path}")
372
 
 
373
  st.markdown("---")
374
 
375
+ # ---- Sorting/filter helpers ----
376
+ from datetime import date, datetime
377
+
378
+ def _to_date(d):
379
+ if not d:
380
+ return None
381
+ try:
382
+ return datetime.fromisoformat(str(d)).date()
383
+ except Exception:
384
+ return None
385
+
386
+ # Pull results from session state and then apply UI-level filters/sorts
387
  results = st.session_state.get("results", [])
388
+
389
+ # Optionally filter to only-open
390
+ if only_open and results:
391
+ results = [r for r in results if (_to_date(r.get("deadline")) or date.max) >= date.today()]
392
+
393
+ # Apply sort if selected
394
+ if sort_by.startswith("Deadline") and results:
395
+ results.sort(
396
+ key=lambda r: (
397
+ _to_date(r.get("deadline")) is None,
398
+ _to_date(r.get("deadline")) or date.max,
399
+ )
400
+ )
401
+
402
+ # ---- Render results ----
403
  if results:
404
  st.caption(f"Results: {len(results)}")
405
  for r in results:
 
414
  if url and not url.startswith("http"):
415
  st.caption("Note: This item may display an ID or number instead of a full link. Open on Grants.gov if needed.")
416
  st.write(f"[Open Link]({url}) \nScore: {r.get('score', 0):.3f}")
417
+
418
+ posted = r.get("posted_date") or ""
419
+ deadline = r.get("deadline") or ""
420
+ st.caption(f"Posted: {posted} β€’ Deadline: {deadline}")
421
+
422
  st.markdown("---")
423
  else:
424
+ st.info("Enter a query and click Search.")
config/v6.yaml CHANGED
@@ -15,6 +15,20 @@ sources:
15
  keyword: "capacity building"
16
  oppStatuses: "posted"
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  - name: "Grants.gov (API: capacity building - vehicles/transportation)"
19
  type: grantsgov_api
20
  enabled: true
@@ -50,3 +64,46 @@ sources:
50
  keyword: "5310 Enhanced Mobility Seniors Individuals with Disabilities"
51
  oppStatuses: "posted"
52
  agencyCodes: ["FTA"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  keyword: "capacity building"
16
  oppStatuses: "posted"
17
 
18
+ - name: "DOL β€” Reentry & Workforce Capacity"
19
+ type: grantsgov_api
20
+ enabled: true
21
+ url: "https://www.grants.gov/grantsws/rest/opportunities/search/"
22
+ geo: "US"
23
+ categories: ["justice","reentry","workforce"] # simple, uncluttered tags
24
+ api:
25
+ page_size: 100
26
+ max_pages: 5
27
+ payload:
28
+ keyword: "reentry workforce employment training apprenticeships transitional jobs capacity building technical assistance"
29
+ oppStatuses: ["posted"]
30
+ agencies: ["DOL"] # broad DOL umbrella
31
+
32
  - name: "Grants.gov (API: capacity building - vehicles/transportation)"
33
  type: grantsgov_api
34
  enabled: true
 
64
  keyword: "5310 Enhanced Mobility Seniors Individuals with Disabilities"
65
  oppStatuses: "posted"
66
  agencyCodes: ["FTA"]
67
+
68
+ # --- DOJ / OJP family via Grants.gov ---
69
+ - name: "DOJ (All) β€” Capacity/Reentry"
70
+ type: grantsgov_api
71
+ enabled: true
72
+ url: "https://www.grants.gov/grantsws/rest/opportunities/search/"
73
+ geo: "US"
74
+ categories: ["justice", "reentry"]
75
+ api:
76
+ page_size: 100
77
+ max_pages: 5
78
+ payload:
79
+ keyword: "capacity building reentry community outreach victim services youth violence prevention"
80
+ oppStatuses: ["posted"]
81
+ agencies: ["DOJ"] # parent Dept. of Justice
82
+
83
+ - name: "OJP (BJA/OJJDP/OVC/BJS) β€” Capacity"
84
+ type: grantsgov_api
85
+ enabled: true
86
+ url: "https://www.grants.gov/grantsws/rest/opportunities/search/"
87
+ geo: "US"
88
+ categories: ["justice", "victim_services", "youth", "reentry"]
89
+ api:
90
+ page_size: 100
91
+ max_pages: 5
92
+ payload:
93
+ keyword: "capacity building community-based nonprofit technical assistance case management"
94
+ oppStatuses: ["posted"]
95
+ agencies: ["OJP","BJA","OJJDP","OVC","BJS"]
96
+
97
+ - name: "OVW β€” Violence Against Women"
98
+ type: grantsgov_api
99
+ enabled: true
100
+ url: "https://www.grants.gov/grantsws/rest/opportunities/search/"
101
+ geo: "US"
102
+ categories: ["justice", "victim_services", "women"]
103
+ api:
104
+ page_size: 100
105
+ max_pages: 5
106
+ payload:
107
+ keyword: "capacity building advocacy shelter prevention prosecution"
108
+ oppStatuses: ["posted"]
109
+ agencies: ["OVW"]
ensure ADDED
File without changes
is ADDED
File without changes
package ADDED
File without changes