Spaces:
Runtime error
Runtime error
Commit ·
a9d03a8
1
Parent(s): c774769
fix: Resolve slow page loads and self-ping failure
Browse files- Fix self-ping default port (7860→8000) to match local uvicorn
- Lazy-initialize all backend services to speed up startup
- Cap Canadian scraper to 40 DDG queries (was 240+) with endpoint-level caching
- Add 30s fetch timeout to frontend API wrapper with abort handling
- Reduce Opportunities max_per_source from 250 to 20
- Unify Dashboard API_BASE_URL with shared config and add timeout
- Downgrade tiptap extensions from v3 to v2 to fix peer dependency conflict
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- backend/app/main.py +30 -14
- backend/app/services/canadian_rfp_scraper.py +31 -6
- package-lock.json +34 -45
- package.json +6 -6
- services/api.ts +39 -21
- views/Dashboard.tsx +11 -7
- views/Opportunities.tsx +1 -1
backend/app/main.py
CHANGED
|
@@ -80,12 +80,12 @@ import httpx
|
|
| 80 |
|
| 81 |
async def self_ping():
|
| 82 |
"""Ping the service periodically to prevent sleeping."""
|
| 83 |
-
|
|
|
|
| 84 |
while True:
|
| 85 |
try:
|
| 86 |
await asyncio.sleep(600) # Ping every 10 minutes
|
| 87 |
-
async with httpx.AsyncClient() as client:
|
| 88 |
-
# Use localhost for internal keep-alive
|
| 89 |
await client.get(f"http://127.0.0.1:{port}/api/v1/health")
|
| 90 |
except Exception as e:
|
| 91 |
logger.warning(f"Self-ping failed: {e}")
|
|
@@ -151,17 +151,33 @@ async def log_requests(request: Request, call_next):
|
|
| 151 |
logger.info(f"Response: {request.method} {request.url.path} -> {response.status_code}")
|
| 152 |
return response
|
| 153 |
|
| 154 |
-
#
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
|
| 167 |
# ==================== Request/Response Models ====================
|
|
|
|
| 80 |
|
| 81 |
async def self_ping():
|
| 82 |
"""Ping the service periodically to prevent sleeping."""
|
| 83 |
+
# PORT env is set by HF Spaces (7860); locally uvicorn defaults to 8000
|
| 84 |
+
port = os.getenv("PORT", "8000")
|
| 85 |
while True:
|
| 86 |
try:
|
| 87 |
await asyncio.sleep(600) # Ping every 10 minutes
|
| 88 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
|
|
| 89 |
await client.get(f"http://127.0.0.1:{port}/api/v1/health")
|
| 90 |
except Exception as e:
|
| 91 |
logger.warning(f"Self-ping failed: {e}")
|
|
|
|
| 151 |
logger.info(f"Response: {request.method} {request.url.path} -> {response.status_code}")
|
| 152 |
return response
|
| 153 |
|
| 154 |
+
# Lazy-initialized services — created on first use to speed up startup
|
| 155 |
+
class _LazyService:
|
| 156 |
+
"""Proxy that defers service initialization until first attribute access."""
|
| 157 |
+
def __init__(self, factory):
|
| 158 |
+
object.__setattr__(self, '_factory', factory)
|
| 159 |
+
object.__setattr__(self, '_instance', None)
|
| 160 |
+
|
| 161 |
+
def _ensure(self):
|
| 162 |
+
if object.__getattribute__(self, '_instance') is None:
|
| 163 |
+
factory = object.__getattribute__(self, '_factory')
|
| 164 |
+
object.__setattr__(self, '_instance', factory())
|
| 165 |
+
return object.__getattribute__(self, '_instance')
|
| 166 |
+
|
| 167 |
+
def __getattr__(self, name):
|
| 168 |
+
return getattr(self._ensure(), name)
|
| 169 |
+
|
| 170 |
+
def __call__(self, *args, **kwargs):
|
| 171 |
+
return self._ensure()(*args, **kwargs)
|
| 172 |
+
|
| 173 |
+
ingestion_service = _LazyService(IngestionService)
|
| 174 |
+
shredding_service = _LazyService(ShreddingService)
|
| 175 |
+
knowledge_engine = _LazyService(KnowledgeEngine)
|
| 176 |
+
analysis_engine = _LazyService(AnalysisEngine)
|
| 177 |
+
assembly_engine = _LazyService(AssemblyEngine)
|
| 178 |
+
visual_engine = _LazyService(VisualEngine)
|
| 179 |
+
nli_detector = _LazyService(lambda: NLIContradictionDetector(use_transformers=settings.LOAD_TRANSFORMER_MODELS))
|
| 180 |
+
cross_encoder_reranker = _LazyService(lambda: CrossEncoderReranker(model_preference="balanced"))
|
| 181 |
|
| 182 |
|
| 183 |
# ==================== Request/Response Models ====================
|
backend/app/services/canadian_rfp_scraper.py
CHANGED
|
@@ -794,12 +794,14 @@ class CanadianRFPScraper:
|
|
| 794 |
self._set_cached(cache_key, enriched)
|
| 795 |
return enriched
|
| 796 |
|
| 797 |
-
def _search_canadian_tenders_bulk(self, max_results: int =
|
| 798 |
"""
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
"""
|
|
|
|
|
|
|
| 803 |
year = datetime.now().year
|
| 804 |
prev_year = year - 1
|
| 805 |
|
|
@@ -934,7 +936,18 @@ class CanadianRFPScraper:
|
|
| 934 |
queries.append(f"Canada government bid deadline {year}")
|
| 935 |
queries.append(f"Canadian tender closing date {year}")
|
| 936 |
|
| 937 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 938 |
|
| 939 |
all_results = []
|
| 940 |
seen_urls = set()
|
|
@@ -1074,13 +1087,21 @@ class CanadianRFPScraper:
|
|
| 1074 |
max_per_source: Max results per source
|
| 1075 |
min_relevance: Minimum relevance score (0-100) to include
|
| 1076 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1077 |
all_results = []
|
| 1078 |
seen_urls = set()
|
| 1079 |
|
| 1080 |
# For "all sources" or large source lists, use bulk search approach
|
| 1081 |
if source_keys is None or len(source_keys) > 3:
|
| 1082 |
total_wanted = max_per_source * len(source_keys or CANADIAN_SOURCES)
|
| 1083 |
-
bulk_results = self._search_canadian_tenders_bulk(max_results=
|
| 1084 |
|
| 1085 |
for r in bulk_results:
|
| 1086 |
url = r.get("url", "")
|
|
@@ -1112,6 +1133,10 @@ class CanadianRFPScraper:
|
|
| 1112 |
# Sort by relevance score descending
|
| 1113 |
all_results.sort(key=lambda x: -x.get("relevance_score", 0))
|
| 1114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1115 |
return all_results
|
| 1116 |
|
| 1117 |
def _enrich_result(self, result: Dict, source_key: str) -> Optional[Dict]:
|
|
|
|
| 794 |
self._set_cached(cache_key, enriched)
|
| 795 |
return enriched
|
| 796 |
|
| 797 |
+
def _search_canadian_tenders_bulk(self, max_results: int = 500) -> List[Dict]:
|
| 798 |
"""
|
| 799 |
+
Bulk search for Canadian tenders using Heavenkeys service keywords.
|
| 800 |
+
Uses targeted queries across service areas, provinces, and procurement portals.
|
| 801 |
+
Capped at a reasonable max to avoid excessive search engine queries.
|
| 802 |
"""
|
| 803 |
+
# Cap to avoid running hundreds of DDG queries (each takes 1-5 seconds)
|
| 804 |
+
max_results = min(max_results, 500)
|
| 805 |
year = datetime.now().year
|
| 806 |
prev_year = year - 1
|
| 807 |
|
|
|
|
| 936 |
queries.append(f"Canada government bid deadline {year}")
|
| 937 |
queries.append(f"Canadian tender closing date {year}")
|
| 938 |
|
| 939 |
+
# Limit the number of queries to avoid extremely long scrape times.
|
| 940 |
+
# Each DDG query takes 1-5 seconds; 40 queries ≈ 40-200 seconds max.
|
| 941 |
+
import random
|
| 942 |
+
MAX_QUERIES = 40
|
| 943 |
+
if len(queries) > MAX_QUERIES:
|
| 944 |
+
# Always keep the first few broad queries, shuffle the rest
|
| 945 |
+
priority_queries = queries[:10]
|
| 946 |
+
remaining = queries[10:]
|
| 947 |
+
random.shuffle(remaining)
|
| 948 |
+
queries = priority_queries + remaining[:MAX_QUERIES - 10]
|
| 949 |
+
|
| 950 |
+
logger.info(f"Bulk search: running {len(queries)} queries (capped) for up to {max_results} results")
|
| 951 |
|
| 952 |
all_results = []
|
| 953 |
seen_urls = set()
|
|
|
|
| 1087 |
max_per_source: Max results per source
|
| 1088 |
min_relevance: Minimum relevance score (0-100) to include
|
| 1089 |
"""
|
| 1090 |
+
# Check endpoint-level cache first (keyed on the full query params)
|
| 1091 |
+
sorted_keys = sorted(source_keys) if source_keys else ["__all__"]
|
| 1092 |
+
cache_key = f"all_sources_{'_'.join(sorted_keys)}_{max_per_source}_{min_relevance}"
|
| 1093 |
+
cached = self._get_cached(cache_key)
|
| 1094 |
+
if cached is not None:
|
| 1095 |
+
logger.info(f"Returning {len(cached)} cached results for Canadian search")
|
| 1096 |
+
return cached
|
| 1097 |
+
|
| 1098 |
all_results = []
|
| 1099 |
seen_urls = set()
|
| 1100 |
|
| 1101 |
# For "all sources" or large source lists, use bulk search approach
|
| 1102 |
if source_keys is None or len(source_keys) > 3:
|
| 1103 |
total_wanted = max_per_source * len(source_keys or CANADIAN_SOURCES)
|
| 1104 |
+
bulk_results = self._search_canadian_tenders_bulk(max_results=min(total_wanted, 500))
|
| 1105 |
|
| 1106 |
for r in bulk_results:
|
| 1107 |
url = r.get("url", "")
|
|
|
|
| 1133 |
# Sort by relevance score descending
|
| 1134 |
all_results.sort(key=lambda x: -x.get("relevance_score", 0))
|
| 1135 |
|
| 1136 |
+
# Cache the results for subsequent requests
|
| 1137 |
+
self._set_cached(cache_key, all_results)
|
| 1138 |
+
logger.info(f"Cached {len(all_results)} results for Canadian search")
|
| 1139 |
+
|
| 1140 |
return all_results
|
| 1141 |
|
| 1142 |
def _enrich_result(self, result: Dict, source_key: str) -> Optional[Dict]:
|
package-lock.json
CHANGED
|
@@ -8,12 +8,12 @@
|
|
| 8 |
"name": "contravaulthvnkz",
|
| 9 |
"version": "0.0.0",
|
| 10 |
"dependencies": {
|
| 11 |
-
"@tiptap/extension-color": "^
|
| 12 |
-
"@tiptap/extension-highlight": "^
|
| 13 |
-
"@tiptap/extension-placeholder": "^
|
| 14 |
-
"@tiptap/extension-text-align": "^
|
| 15 |
-
"@tiptap/extension-text-style": "^
|
| 16 |
-
"@tiptap/extension-underline": "^
|
| 17 |
"@tiptap/pm": "^2.10.2",
|
| 18 |
"@tiptap/react": "^2.10.2",
|
| 19 |
"@tiptap/starter-kit": "^2.10.2",
|
|
@@ -2651,16 +2651,17 @@
|
|
| 2651 |
}
|
| 2652 |
},
|
| 2653 |
"node_modules/@tiptap/extension-color": {
|
| 2654 |
-
"version": "
|
| 2655 |
-
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-
|
| 2656 |
-
"integrity": "sha512-
|
| 2657 |
"license": "MIT",
|
| 2658 |
"funding": {
|
| 2659 |
"type": "github",
|
| 2660 |
"url": "https://github.com/sponsors/ueberdosis"
|
| 2661 |
},
|
| 2662 |
"peerDependencies": {
|
| 2663 |
-
"@tiptap/
|
|
|
|
| 2664 |
}
|
| 2665 |
},
|
| 2666 |
"node_modules/@tiptap/extension-document": {
|
|
@@ -2748,16 +2749,16 @@
|
|
| 2748 |
}
|
| 2749 |
},
|
| 2750 |
"node_modules/@tiptap/extension-highlight": {
|
| 2751 |
-
"version": "
|
| 2752 |
-
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-
|
| 2753 |
-
"integrity": "sha512-
|
| 2754 |
"license": "MIT",
|
| 2755 |
"funding": {
|
| 2756 |
"type": "github",
|
| 2757 |
"url": "https://github.com/sponsors/ueberdosis"
|
| 2758 |
},
|
| 2759 |
"peerDependencies": {
|
| 2760 |
-
"@tiptap/core": "^
|
| 2761 |
}
|
| 2762 |
},
|
| 2763 |
"node_modules/@tiptap/extension-history": {
|
|
@@ -2841,16 +2842,17 @@
|
|
| 2841 |
}
|
| 2842 |
},
|
| 2843 |
"node_modules/@tiptap/extension-placeholder": {
|
| 2844 |
-
"version": "
|
| 2845 |
-
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-
|
| 2846 |
-
"integrity": "sha512-
|
| 2847 |
"license": "MIT",
|
| 2848 |
"funding": {
|
| 2849 |
"type": "github",
|
| 2850 |
"url": "https://github.com/sponsors/ueberdosis"
|
| 2851 |
},
|
| 2852 |
"peerDependencies": {
|
| 2853 |
-
"@tiptap/
|
|
|
|
| 2854 |
}
|
| 2855 |
},
|
| 2856 |
"node_modules/@tiptap/extension-strike": {
|
|
@@ -2880,42 +2882,42 @@
|
|
| 2880 |
}
|
| 2881 |
},
|
| 2882 |
"node_modules/@tiptap/extension-text-align": {
|
| 2883 |
-
"version": "
|
| 2884 |
-
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-
|
| 2885 |
-
"integrity": "sha512-
|
| 2886 |
"license": "MIT",
|
| 2887 |
"funding": {
|
| 2888 |
"type": "github",
|
| 2889 |
"url": "https://github.com/sponsors/ueberdosis"
|
| 2890 |
},
|
| 2891 |
"peerDependencies": {
|
| 2892 |
-
"@tiptap/core": "^
|
| 2893 |
}
|
| 2894 |
},
|
| 2895 |
"node_modules/@tiptap/extension-text-style": {
|
| 2896 |
-
"version": "
|
| 2897 |
-
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-
|
| 2898 |
-
"integrity": "sha512-
|
| 2899 |
"license": "MIT",
|
| 2900 |
"funding": {
|
| 2901 |
"type": "github",
|
| 2902 |
"url": "https://github.com/sponsors/ueberdosis"
|
| 2903 |
},
|
| 2904 |
"peerDependencies": {
|
| 2905 |
-
"@tiptap/core": "^
|
| 2906 |
}
|
| 2907 |
},
|
| 2908 |
"node_modules/@tiptap/extension-underline": {
|
| 2909 |
-
"version": "
|
| 2910 |
-
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-
|
| 2911 |
-
"integrity": "sha512-
|
| 2912 |
"license": "MIT",
|
| 2913 |
"funding": {
|
| 2914 |
"type": "github",
|
| 2915 |
"url": "https://github.com/sponsors/ueberdosis"
|
| 2916 |
},
|
| 2917 |
"peerDependencies": {
|
| 2918 |
-
"@tiptap/core": "^
|
| 2919 |
}
|
| 2920 |
},
|
| 2921 |
"node_modules/@tiptap/pm": {
|
|
@@ -3004,19 +3006,6 @@
|
|
| 3004 |
"url": "https://github.com/sponsors/ueberdosis"
|
| 3005 |
}
|
| 3006 |
},
|
| 3007 |
-
"node_modules/@tiptap/starter-kit/node_modules/@tiptap/extension-text-style": {
|
| 3008 |
-
"version": "2.27.2",
|
| 3009 |
-
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
| 3010 |
-
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
|
| 3011 |
-
"license": "MIT",
|
| 3012 |
-
"funding": {
|
| 3013 |
-
"type": "github",
|
| 3014 |
-
"url": "https://github.com/sponsors/ueberdosis"
|
| 3015 |
-
},
|
| 3016 |
-
"peerDependencies": {
|
| 3017 |
-
"@tiptap/core": "^2.7.0"
|
| 3018 |
-
}
|
| 3019 |
-
},
|
| 3020 |
"node_modules/@types/aws-lambda": {
|
| 3021 |
"version": "8.10.160",
|
| 3022 |
"resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.160.tgz",
|
|
@@ -3144,7 +3133,7 @@
|
|
| 3144 |
"version": "19.2.9",
|
| 3145 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
|
| 3146 |
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
|
| 3147 |
-
"
|
| 3148 |
"license": "MIT",
|
| 3149 |
"dependencies": {
|
| 3150 |
"csstype": "^3.2.2"
|
|
@@ -3590,7 +3579,7 @@
|
|
| 3590 |
"version": "3.2.3",
|
| 3591 |
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
| 3592 |
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
| 3593 |
-
"
|
| 3594 |
"license": "MIT"
|
| 3595 |
},
|
| 3596 |
"node_modules/debug": {
|
|
|
|
| 8 |
"name": "contravaulthvnkz",
|
| 9 |
"version": "0.0.0",
|
| 10 |
"dependencies": {
|
| 11 |
+
"@tiptap/extension-color": "^2.10.2",
|
| 12 |
+
"@tiptap/extension-highlight": "^2.10.2",
|
| 13 |
+
"@tiptap/extension-placeholder": "^2.10.2",
|
| 14 |
+
"@tiptap/extension-text-align": "^2.10.2",
|
| 15 |
+
"@tiptap/extension-text-style": "^2.10.2",
|
| 16 |
+
"@tiptap/extension-underline": "^2.10.2",
|
| 17 |
"@tiptap/pm": "^2.10.2",
|
| 18 |
"@tiptap/react": "^2.10.2",
|
| 19 |
"@tiptap/starter-kit": "^2.10.2",
|
|
|
|
| 2651 |
}
|
| 2652 |
},
|
| 2653 |
"node_modules/@tiptap/extension-color": {
|
| 2654 |
+
"version": "2.27.2",
|
| 2655 |
+
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-2.27.2.tgz",
|
| 2656 |
+
"integrity": "sha512-sOKCP8/2V3sRM3FdWgMe1lFE5ewsWNCRafiVoujS1+TTHGCj4jw6W+LiumBUk7cRI8kXW/rqGWVC4RVdknYUCA==",
|
| 2657 |
"license": "MIT",
|
| 2658 |
"funding": {
|
| 2659 |
"type": "github",
|
| 2660 |
"url": "https://github.com/sponsors/ueberdosis"
|
| 2661 |
},
|
| 2662 |
"peerDependencies": {
|
| 2663 |
+
"@tiptap/core": "^2.7.0",
|
| 2664 |
+
"@tiptap/extension-text-style": "^2.7.0"
|
| 2665 |
}
|
| 2666 |
},
|
| 2667 |
"node_modules/@tiptap/extension-document": {
|
|
|
|
| 2749 |
}
|
| 2750 |
},
|
| 2751 |
"node_modules/@tiptap/extension-highlight": {
|
| 2752 |
+
"version": "2.27.2",
|
| 2753 |
+
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.27.2.tgz",
|
| 2754 |
+
"integrity": "sha512-ZjlktDdMjruMJFAVz0TbQf0v92Jqkc7Ri1iZJqBXuLid+r+GxUzl2CVAV7qq5yagkGQgvAG+WGsMk880HgR3MA==",
|
| 2755 |
"license": "MIT",
|
| 2756 |
"funding": {
|
| 2757 |
"type": "github",
|
| 2758 |
"url": "https://github.com/sponsors/ueberdosis"
|
| 2759 |
},
|
| 2760 |
"peerDependencies": {
|
| 2761 |
+
"@tiptap/core": "^2.7.0"
|
| 2762 |
}
|
| 2763 |
},
|
| 2764 |
"node_modules/@tiptap/extension-history": {
|
|
|
|
| 2842 |
}
|
| 2843 |
},
|
| 2844 |
"node_modules/@tiptap/extension-placeholder": {
|
| 2845 |
+
"version": "2.27.2",
|
| 2846 |
+
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.2.tgz",
|
| 2847 |
+
"integrity": "sha512-IjsgSVYJRjpAKmIoapU0E2R4E2FPY3kpvU7/1i7PUYisylqejSJxmtJPGYw0FOMQY9oxnEEvfZHMBA610tqKpg==",
|
| 2848 |
"license": "MIT",
|
| 2849 |
"funding": {
|
| 2850 |
"type": "github",
|
| 2851 |
"url": "https://github.com/sponsors/ueberdosis"
|
| 2852 |
},
|
| 2853 |
"peerDependencies": {
|
| 2854 |
+
"@tiptap/core": "^2.7.0",
|
| 2855 |
+
"@tiptap/pm": "^2.7.0"
|
| 2856 |
}
|
| 2857 |
},
|
| 2858 |
"node_modules/@tiptap/extension-strike": {
|
|
|
|
| 2882 |
}
|
| 2883 |
},
|
| 2884 |
"node_modules/@tiptap/extension-text-align": {
|
| 2885 |
+
"version": "2.27.2",
|
| 2886 |
+
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.2.tgz",
|
| 2887 |
+
"integrity": "sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==",
|
| 2888 |
"license": "MIT",
|
| 2889 |
"funding": {
|
| 2890 |
"type": "github",
|
| 2891 |
"url": "https://github.com/sponsors/ueberdosis"
|
| 2892 |
},
|
| 2893 |
"peerDependencies": {
|
| 2894 |
+
"@tiptap/core": "^2.7.0"
|
| 2895 |
}
|
| 2896 |
},
|
| 2897 |
"node_modules/@tiptap/extension-text-style": {
|
| 2898 |
+
"version": "2.27.2",
|
| 2899 |
+
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
| 2900 |
+
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
|
| 2901 |
"license": "MIT",
|
| 2902 |
"funding": {
|
| 2903 |
"type": "github",
|
| 2904 |
"url": "https://github.com/sponsors/ueberdosis"
|
| 2905 |
},
|
| 2906 |
"peerDependencies": {
|
| 2907 |
+
"@tiptap/core": "^2.7.0"
|
| 2908 |
}
|
| 2909 |
},
|
| 2910 |
"node_modules/@tiptap/extension-underline": {
|
| 2911 |
+
"version": "2.27.2",
|
| 2912 |
+
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.27.2.tgz",
|
| 2913 |
+
"integrity": "sha512-gPOsbAcw1S07ezpAISwoO8f0RxpjcSH7VsHEFDVuXm4ODE32nhvSinvHQjv2icRLOXev+bnA7oIBu7Oy859gWQ==",
|
| 2914 |
"license": "MIT",
|
| 2915 |
"funding": {
|
| 2916 |
"type": "github",
|
| 2917 |
"url": "https://github.com/sponsors/ueberdosis"
|
| 2918 |
},
|
| 2919 |
"peerDependencies": {
|
| 2920 |
+
"@tiptap/core": "^2.7.0"
|
| 2921 |
}
|
| 2922 |
},
|
| 2923 |
"node_modules/@tiptap/pm": {
|
|
|
|
| 3006 |
"url": "https://github.com/sponsors/ueberdosis"
|
| 3007 |
}
|
| 3008 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3009 |
"node_modules/@types/aws-lambda": {
|
| 3010 |
"version": "8.10.160",
|
| 3011 |
"resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.160.tgz",
|
|
|
|
| 3133 |
"version": "19.2.9",
|
| 3134 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
|
| 3135 |
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
|
| 3136 |
+
"devOptional": true,
|
| 3137 |
"license": "MIT",
|
| 3138 |
"dependencies": {
|
| 3139 |
"csstype": "^3.2.2"
|
|
|
|
| 3579 |
"version": "3.2.3",
|
| 3580 |
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
| 3581 |
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
| 3582 |
+
"devOptional": true,
|
| 3583 |
"license": "MIT"
|
| 3584 |
},
|
| 3585 |
"node_modules/debug": {
|
package.json
CHANGED
|
@@ -14,12 +14,12 @@
|
|
| 14 |
"lint": "next lint"
|
| 15 |
},
|
| 16 |
"dependencies": {
|
| 17 |
-
"@tiptap/extension-color": "^
|
| 18 |
-
"@tiptap/extension-highlight": "^
|
| 19 |
-
"@tiptap/extension-placeholder": "^
|
| 20 |
-
"@tiptap/extension-text-align": "^
|
| 21 |
-
"@tiptap/extension-text-style": "^
|
| 22 |
-
"@tiptap/extension-underline": "^
|
| 23 |
"@tiptap/pm": "^2.10.2",
|
| 24 |
"@tiptap/react": "^2.10.2",
|
| 25 |
"@tiptap/starter-kit": "^2.10.2",
|
|
|
|
| 14 |
"lint": "next lint"
|
| 15 |
},
|
| 16 |
"dependencies": {
|
| 17 |
+
"@tiptap/extension-color": "^2.10.2",
|
| 18 |
+
"@tiptap/extension-highlight": "^2.10.2",
|
| 19 |
+
"@tiptap/extension-placeholder": "^2.10.2",
|
| 20 |
+
"@tiptap/extension-text-align": "^2.10.2",
|
| 21 |
+
"@tiptap/extension-text-style": "^2.10.2",
|
| 22 |
+
"@tiptap/extension-underline": "^2.10.2",
|
| 23 |
"@tiptap/pm": "^2.10.2",
|
| 24 |
"@tiptap/react": "^2.10.2",
|
| 25 |
"@tiptap/starter-kit": "^2.10.2",
|
services/api.ts
CHANGED
|
@@ -10,37 +10,54 @@ const getApiBaseUrl = () => {
|
|
| 10 |
|
| 11 |
export const API_BASE_URL = getApiBaseUrl();
|
| 12 |
|
| 13 |
-
//
|
|
|
|
|
|
|
|
|
|
| 14 |
async function fetchAPI<T>(
|
| 15 |
endpoint: string,
|
| 16 |
-
options: RequestInit = {},
|
| 17 |
): Promise<T> {
|
| 18 |
const url = `${API_BASE_URL}${endpoint}`;
|
|
|
|
| 19 |
|
| 20 |
const defaultHeaders: HeadersInit = {
|
| 21 |
"Content-Type": "application/json",
|
| 22 |
};
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
...defaultHeaders,
|
| 28 |
-
...options.headers,
|
| 29 |
-
},
|
| 30 |
-
});
|
| 31 |
|
| 32 |
-
|
| 33 |
-
const
|
| 34 |
-
.
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
:
|
| 40 |
-
|
| 41 |
-
}
|
| 42 |
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
// File upload helper
|
|
@@ -1223,7 +1240,8 @@ export const canadianRFPAPI = {
|
|
| 1223 |
fetchAPI<CanadianSearchResponse>("/opportunities/canadian/search", {
|
| 1224 |
method: "POST",
|
| 1225 |
body: JSON.stringify(query),
|
| 1226 |
-
|
|
|
|
| 1227 |
|
| 1228 |
/** Scrape a single Canadian tender source */
|
| 1229 |
scrapeSource: (source: string, maxResults: number = 20) =>
|
|
|
|
| 10 |
|
| 11 |
export const API_BASE_URL = getApiBaseUrl();
|
| 12 |
|
| 13 |
+
// Default request timeout (30 seconds)
|
| 14 |
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
| 15 |
+
|
| 16 |
+
// Generic fetch wrapper with error handling and timeout
|
| 17 |
async function fetchAPI<T>(
|
| 18 |
endpoint: string,
|
| 19 |
+
options: RequestInit & { timeoutMs?: number } = {},
|
| 20 |
): Promise<T> {
|
| 21 |
const url = `${API_BASE_URL}${endpoint}`;
|
| 22 |
+
const { timeoutMs = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options;
|
| 23 |
|
| 24 |
const defaultHeaders: HeadersInit = {
|
| 25 |
"Content-Type": "application/json",
|
| 26 |
};
|
| 27 |
|
| 28 |
+
// AbortController for request timeout
|
| 29 |
+
const controller = new AbortController();
|
| 30 |
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
+
try {
|
| 33 |
+
const response = await fetch(url, {
|
| 34 |
+
...fetchOptions,
|
| 35 |
+
headers: {
|
| 36 |
+
...defaultHeaders,
|
| 37 |
+
...fetchOptions.headers,
|
| 38 |
+
},
|
| 39 |
+
signal: controller.signal,
|
| 40 |
+
});
|
|
|
|
| 41 |
|
| 42 |
+
if (!response.ok) {
|
| 43 |
+
const error = await response
|
| 44 |
+
.json()
|
| 45 |
+
.catch(() => ({ detail: "Unknown error" }));
|
| 46 |
+
const errorMessage = Array.isArray(error.detail)
|
| 47 |
+
? error.detail.map((e: any) => e.msg || e.message || JSON.stringify(e)).join(', ')
|
| 48 |
+
: (error.detail || `HTTP ${response.status}`);
|
| 49 |
+
throw new Error(errorMessage);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return response.json();
|
| 53 |
+
} catch (err: any) {
|
| 54 |
+
if (err.name === 'AbortError') {
|
| 55 |
+
throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s — the server may be starting up. Please try again.`);
|
| 56 |
+
}
|
| 57 |
+
throw err;
|
| 58 |
+
} finally {
|
| 59 |
+
clearTimeout(timeoutId);
|
| 60 |
+
}
|
| 61 |
}
|
| 62 |
|
| 63 |
// File upload helper
|
|
|
|
| 1240 |
fetchAPI<CanadianSearchResponse>("/opportunities/canadian/search", {
|
| 1241 |
method: "POST",
|
| 1242 |
body: JSON.stringify(query),
|
| 1243 |
+
timeoutMs: 120_000, // 2 min — scraping multiple sources takes time
|
| 1244 |
+
} as any),
|
| 1245 |
|
| 1246 |
/** Scrape a single Canadian tender source */
|
| 1247 |
scrapeSource: (source: string, maxResults: number = 20) =>
|
views/Dashboard.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
import React, { useState, useEffect, useMemo } from 'react';
|
| 4 |
import { useRouter } from 'next/navigation';
|
| 5 |
-
import api from '../services/api';
|
| 6 |
|
| 7 |
interface DashboardStats {
|
| 8 |
total_rfps: number;
|
|
@@ -29,8 +29,6 @@ interface OnboardingTask {
|
|
| 29 |
action?: string;
|
| 30 |
}
|
| 31 |
|
| 32 |
-
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
|
| 33 |
-
|
| 34 |
const Dashboard = () => {
|
| 35 |
const router = useRouter();
|
| 36 |
const [stats, setStats] = useState<DashboardStats>({
|
|
@@ -84,15 +82,19 @@ const Dashboard = () => {
|
|
| 84 |
const fetchDashboardData = async () => {
|
| 85 |
setLoading(true);
|
| 86 |
setBackendError(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
try {
|
| 88 |
// Fetch stats
|
| 89 |
-
const statsRes = await fetch(`${API_BASE_URL}/stats`);
|
| 90 |
if (statsRes.ok) {
|
| 91 |
setStats(await statsRes.json());
|
| 92 |
}
|
| 93 |
|
| 94 |
// Fetch projects
|
| 95 |
-
const projectsRes = await fetch(`${API_BASE_URL}/projects`);
|
| 96 |
if (projectsRes.ok) {
|
| 97 |
const data = await projectsRes.json();
|
| 98 |
const mappedProjects = data.map((p: any) => ({
|
|
@@ -105,17 +107,19 @@ const Dashboard = () => {
|
|
| 105 |
win_probability: 70,
|
| 106 |
file_count: p.file_count || 0
|
| 107 |
}));
|
| 108 |
-
console.log('Fetched and mapped projects:', mappedProjects);
|
| 109 |
setProjects(mappedProjects);
|
| 110 |
}
|
| 111 |
} catch (err: any) {
|
| 112 |
console.error('Failed to fetch dashboard data:', err);
|
| 113 |
-
if (err.
|
|
|
|
|
|
|
| 114 |
setBackendError('Cannot connect to backend server. Please start the backend.');
|
| 115 |
} else {
|
| 116 |
setBackendError(err.message || 'Failed to load dashboard data');
|
| 117 |
}
|
| 118 |
} finally {
|
|
|
|
| 119 |
setLoading(false);
|
| 120 |
}
|
| 121 |
};
|
|
|
|
| 2 |
|
| 3 |
import React, { useState, useEffect, useMemo } from 'react';
|
| 4 |
import { useRouter } from 'next/navigation';
|
| 5 |
+
import api, { API_BASE_URL } from '../services/api';
|
| 6 |
|
| 7 |
interface DashboardStats {
|
| 8 |
total_rfps: number;
|
|
|
|
| 29 |
action?: string;
|
| 30 |
}
|
| 31 |
|
|
|
|
|
|
|
| 32 |
const Dashboard = () => {
|
| 33 |
const router = useRouter();
|
| 34 |
const [stats, setStats] = useState<DashboardStats>({
|
|
|
|
| 82 |
const fetchDashboardData = async () => {
|
| 83 |
setLoading(true);
|
| 84 |
setBackendError(null);
|
| 85 |
+
|
| 86 |
+
const controller = new AbortController();
|
| 87 |
+
const timeoutId = setTimeout(() => controller.abort(), 15_000);
|
| 88 |
+
|
| 89 |
try {
|
| 90 |
// Fetch stats
|
| 91 |
+
const statsRes = await fetch(`${API_BASE_URL}/stats`, { signal: controller.signal });
|
| 92 |
if (statsRes.ok) {
|
| 93 |
setStats(await statsRes.json());
|
| 94 |
}
|
| 95 |
|
| 96 |
// Fetch projects
|
| 97 |
+
const projectsRes = await fetch(`${API_BASE_URL}/projects`, { signal: controller.signal });
|
| 98 |
if (projectsRes.ok) {
|
| 99 |
const data = await projectsRes.json();
|
| 100 |
const mappedProjects = data.map((p: any) => ({
|
|
|
|
| 107 |
win_probability: 70,
|
| 108 |
file_count: p.file_count || 0
|
| 109 |
}));
|
|
|
|
| 110 |
setProjects(mappedProjects);
|
| 111 |
}
|
| 112 |
} catch (err: any) {
|
| 113 |
console.error('Failed to fetch dashboard data:', err);
|
| 114 |
+
if (err.name === 'AbortError') {
|
| 115 |
+
setBackendError('Backend request timed out. Please ensure the backend is running.');
|
| 116 |
+
} else if (err.message?.includes('Failed to fetch') || err.name === 'TypeError') {
|
| 117 |
setBackendError('Cannot connect to backend server. Please start the backend.');
|
| 118 |
} else {
|
| 119 |
setBackendError(err.message || 'Failed to load dashboard data');
|
| 120 |
}
|
| 121 |
} finally {
|
| 122 |
+
clearTimeout(timeoutId);
|
| 123 |
setLoading(false);
|
| 124 |
}
|
| 125 |
};
|
views/Opportunities.tsx
CHANGED
|
@@ -76,7 +76,7 @@ const Opportunities = () => {
|
|
| 76 |
setCurrentPage(1);
|
| 77 |
try {
|
| 78 |
const result = await api.canadianRFP.search({
|
| 79 |
-
max_per_source:
|
| 80 |
min_relevance: 0,
|
| 81 |
level: levelFilter !== 'all' ? levelFilter : undefined,
|
| 82 |
province: provinceFilter !== 'all' ? provinceFilter : undefined,
|
|
|
|
| 76 |
setCurrentPage(1);
|
| 77 |
try {
|
| 78 |
const result = await api.canadianRFP.search({
|
| 79 |
+
max_per_source: 20,
|
| 80 |
min_relevance: 0,
|
| 81 |
level: levelFilter !== 'all' ? levelFilter : undefined,
|
| 82 |
province: provinceFilter !== 'all' ? provinceFilter : undefined,
|