jackmichael Claude Opus 4.5 commited on
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 CHANGED
@@ -80,12 +80,12 @@ import httpx
80
 
81
  async def self_ping():
82
  """Ping the service periodically to prevent sleeping."""
83
- port = os.getenv("PORT", "7860")
 
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
- # Initialize Services
155
- ingestion_service = IngestionService()
156
- shredding_service = ShreddingService()
157
- knowledge_engine = KnowledgeEngine()
158
- analysis_engine = AnalysisEngine()
159
- assembly_engine = AssemblyEngine()
160
- visual_engine = VisualEngine()
161
- nli_detector = NLIContradictionDetector(use_transformers=settings.LOAD_TRANSFORMER_MODELS)
162
- cross_encoder_reranker = CrossEncoderReranker(model_preference="balanced")
163
-
164
- # Services are initialized below
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = 5000) -> List[Dict]:
798
  """
799
- High-volume bulk search for Canadian tenders using Heavenkeys service keywords.
800
- Generates hundreds of targeted queries across all service areas, provinces,
801
- and procurement portals to maximize the number of unique opportunities found.
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
- logger.info(f"Bulk search: {len(queries)} queries to execute for up to {max_results} results")
 
 
 
 
 
 
 
 
 
 
 
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=max(total_wanted, 5000))
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": "^3.17.1",
12
- "@tiptap/extension-highlight": "^3.17.1",
13
- "@tiptap/extension-placeholder": "^3.17.1",
14
- "@tiptap/extension-text-align": "^3.17.1",
15
- "@tiptap/extension-text-style": "^3.17.1",
16
- "@tiptap/extension-underline": "^3.17.1",
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": "3.17.1",
2655
- "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.17.1.tgz",
2656
- "integrity": "sha512-QVlzpzGB+QcZgHgvFMRPckZutpkOLzNmZzhupNA7G2CMeeoCwZOJeZkyd3zvtAnRZkf7FrQBO123On30pJt7TA==",
2657
  "license": "MIT",
2658
  "funding": {
2659
  "type": "github",
2660
  "url": "https://github.com/sponsors/ueberdosis"
2661
  },
2662
  "peerDependencies": {
2663
- "@tiptap/extension-text-style": "^3.17.1"
 
2664
  }
2665
  },
2666
  "node_modules/@tiptap/extension-document": {
@@ -2748,16 +2749,16 @@
2748
  }
2749
  },
2750
  "node_modules/@tiptap/extension-highlight": {
2751
- "version": "3.17.1",
2752
- "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.17.1.tgz",
2753
- "integrity": "sha512-I4EdBhPVzJd4ECMI9kP0NE4aG4Numd46jy/AqeZyf3dqVgCxRyAbSyU7oy4aXUnsojYODrKKG6+djm07KgOGoQ==",
2754
  "license": "MIT",
2755
  "funding": {
2756
  "type": "github",
2757
  "url": "https://github.com/sponsors/ueberdosis"
2758
  },
2759
  "peerDependencies": {
2760
- "@tiptap/core": "^3.17.1"
2761
  }
2762
  },
2763
  "node_modules/@tiptap/extension-history": {
@@ -2841,16 +2842,17 @@
2841
  }
2842
  },
2843
  "node_modules/@tiptap/extension-placeholder": {
2844
- "version": "3.17.1",
2845
- "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.17.1.tgz",
2846
- "integrity": "sha512-cE8Rij5/1t4KnWE7GaDewhBek9DKNB+97yrxyggMegILg6v195hOmOkRZkyfnFMYZoBDlrfSAtX9wBvbZBqIsg==",
2847
  "license": "MIT",
2848
  "funding": {
2849
  "type": "github",
2850
  "url": "https://github.com/sponsors/ueberdosis"
2851
  },
2852
  "peerDependencies": {
2853
- "@tiptap/extensions": "^3.17.1"
 
2854
  }
2855
  },
2856
  "node_modules/@tiptap/extension-strike": {
@@ -2880,42 +2882,42 @@
2880
  }
2881
  },
2882
  "node_modules/@tiptap/extension-text-align": {
2883
- "version": "3.17.1",
2884
- "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.17.1.tgz",
2885
- "integrity": "sha512-CyJbZf823dqPZ/1zwRsza5pk/NQwFZwILdFYLVkV88I4+Ua9YVztI9kmwTB6dJyuKT4kTc7nhQHdaa957alGZQ==",
2886
  "license": "MIT",
2887
  "funding": {
2888
  "type": "github",
2889
  "url": "https://github.com/sponsors/ueberdosis"
2890
  },
2891
  "peerDependencies": {
2892
- "@tiptap/core": "^3.17.1"
2893
  }
2894
  },
2895
  "node_modules/@tiptap/extension-text-style": {
2896
- "version": "3.17.1",
2897
- "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.17.1.tgz",
2898
- "integrity": "sha512-TCMsEU92r/TfZkN8AKo/WIcJ1uNq/5NiZxloq5drF1HXxDDjwliurgwBw3OTGUlKQmer0N9hV0AAePY/G+5Akw==",
2899
  "license": "MIT",
2900
  "funding": {
2901
  "type": "github",
2902
  "url": "https://github.com/sponsors/ueberdosis"
2903
  },
2904
  "peerDependencies": {
2905
- "@tiptap/core": "^3.17.1"
2906
  }
2907
  },
2908
  "node_modules/@tiptap/extension-underline": {
2909
- "version": "3.17.1",
2910
- "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.17.1.tgz",
2911
- "integrity": "sha512-6RdBzmkg6DYs0EqPyoqLGkISXzCnPqM/q3A6nh3EmFmORcIDfuNmcidvA6EImebK8KQGmtZKsRhQSnK4CNQ39g==",
2912
  "license": "MIT",
2913
  "funding": {
2914
  "type": "github",
2915
  "url": "https://github.com/sponsors/ueberdosis"
2916
  },
2917
  "peerDependencies": {
2918
- "@tiptap/core": "^3.17.1"
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
- "dev": true,
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
- "dev": true,
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": "^3.17.1",
18
- "@tiptap/extension-highlight": "^3.17.1",
19
- "@tiptap/extension-placeholder": "^3.17.1",
20
- "@tiptap/extension-text-align": "^3.17.1",
21
- "@tiptap/extension-text-style": "^3.17.1",
22
- "@tiptap/extension-underline": "^3.17.1",
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
- // Generic fetch wrapper with error handling
 
 
 
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
- const response = await fetch(url, {
25
- ...options,
26
- headers: {
27
- ...defaultHeaders,
28
- ...options.headers,
29
- },
30
- });
31
 
32
- if (!response.ok) {
33
- const error = await response
34
- .json()
35
- .catch(() => ({ detail: "Unknown error" }));
36
- // Handle Pydantic validation errors (array of objects) or string errors
37
- const errorMessage = Array.isArray(error.detail)
38
- ? error.detail.map((e: any) => e.msg || e.message || JSON.stringify(e)).join(', ')
39
- : (error.detail || `HTTP ${response.status}`);
40
- throw new Error(errorMessage);
41
- }
42
 
43
- return response.json();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.message?.includes('Failed to fetch') || err.name === 'TypeError') {
 
 
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: 250,
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,