findEthics Claude commited on
Commit
69fcd44
·
1 Parent(s): 9245669

Add thread-safe model manager and combined search engines

Browse files

- Implemented ModelManager class with thread-safe lazy loading of ML models
- Added Brave Search API integration alongside DuckDuckGo search
- Enhanced CORS configuration with restricted origins for security
- Converted synchronous model inference to async with thread pool execution
- Improved error handling and expanded search result context
- Updated API version to 3.0.0

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +229 -87
app.py CHANGED
@@ -3,10 +3,15 @@ from fastapi.middleware.cors import CORSMiddleware
3
  from pydantic import BaseModel
4
  import torch
5
  from transformers import pipeline
 
 
6
  from duckduckgo_search import DDGS
7
  from typing import Optional, List, Dict, Any
8
  import logging
9
  import re
 
 
 
10
 
11
 
12
  import spacy
@@ -24,15 +29,22 @@ logger = logging.getLogger(__name__)
24
  app = FastAPI(
25
  title="Enhanced Chat API with Dynamic Model Selection",
26
  description="API with query classification and dynamic model loading for QA/Summarization",
27
- version="2.0.0"
28
  )
29
 
30
- # Configure CORS
31
  app.add_middleware(
32
  CORSMiddleware,
33
- allow_origins=["*"],
 
 
 
 
 
 
 
34
  allow_methods=["POST", "GET"],
35
- allow_headers=["*"],
36
  )
37
 
38
  # Request/Response models
@@ -51,10 +63,81 @@ class SearchRequest(BaseModel):
51
  query: str
52
  max_results: int = 5
53
 
54
- # Global pipelines with lazy loading
55
- classifier_pipeline = None
56
- qa_pipeline = None
57
- summarization_pipeline = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  nlp = spacy.load("en_core_web_sm")
59
  rake = Rake()
60
 
@@ -108,52 +191,20 @@ def clean_terms(terms: List[str]) -> List[str]:
108
 
109
  return final_terms
110
 
111
- def load_classifier():
112
- """Load zero-shot classification model"""
113
- global classifier_pipeline
114
- try:
115
- device = "cuda" if torch.cuda.is_available() else "cpu"
116
- classifier_pipeline = pipeline(
117
- "zero-shot-classification",
118
- model="valhalla/distilbart-mnli-12-3",
119
- device=0 if device == "cuda" else -1
120
- )
121
- logger.info("Zero-shot classifier loaded")
122
- except Exception as e:
123
- logger.error(f"Classifier load error: {e}")
124
-
125
- def load_qa_model():
126
- """Load question-answering model on demand"""
127
- global qa_pipeline
128
- try:
129
- device = "cuda" if torch.cuda.is_available() else "cpu"
130
- qa_pipeline = pipeline(
131
- "question-answering",
132
- model="distilbert-base-uncased-distilled-squad",
133
- device=0 if device == "cuda" else -1
134
- )
135
- logger.info("QA model loaded")
136
- except Exception as e:
137
- logger.error(f"QA model load error: {e}")
138
-
139
- def load_summarization_model():
140
- """Load summarization model on demand"""
141
- global summarization_pipeline
142
- try:
143
- device = "cuda" if torch.cuda.is_available() else "cpu"
144
- summarization_pipeline = pipeline(
145
- "summarization",
146
- model="sshleifer/distilbart-cnn-6-6",
147
- device=0 if device == "cuda" else -1
148
- )
149
- logger.info("Summarization model loaded")
150
- except Exception as e:
151
- logger.error(f"Summarization model load error: {e}")
152
 
 
153
  def classify_query(prompt: str) -> str:
154
  """Classify query using zero-shot learning"""
155
  candidate_labels = ['question answering', 'summarization']
156
  try:
 
157
  result = classifier_pipeline(
158
  prompt,
159
  candidate_labels,
@@ -164,24 +215,97 @@ def classify_query(prompt: str) -> str:
164
  logger.error(f"Classification failed: {e}")
165
  return 'question answering' # Fallback to QA
166
 
167
- def search_web(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
168
- """Enhanced web search with error handling"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  try:
170
  with DDGS() as ddgs:
171
- return [{
172
- "title": r.get("title", ""),
173
- "body": re.sub(r'\s+', ' ', r.get("body", "")).strip(),
174
- "href": r.get("href", "")
175
- } for r in ddgs.text(query, safesearch='off', max_results=max_results)]
 
 
 
 
176
  except Exception as e:
177
- logger.error(f"Search error: {e}")
178
  return []
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  def format_search_context(results: List[Dict[str, Any]]) -> str:
181
- """Create condensed context from search results"""
182
- return "\n".join(
183
- f"{i+1}. {res['title']}: {res['body'][:200]}"
184
- for i, res in enumerate(results[:5])
185
  )
186
 
187
  def preprocess_text(text: str) -> str:
@@ -191,18 +315,16 @@ def preprocess_text(text: str) -> str:
191
  return " ".join([
192
  token.lemma_ for token in doc
193
  if not token.is_stop and not token.is_punct
194
- ])[:1024]
195
 
196
  @app.on_event("startup")
197
  async def startup_event():
198
- """Initialize core models on startup"""
199
- load_classifier()
200
- load_qa_model()
201
- load_summarization_model()
202
 
203
  @app.post("/chat", response_model=ChatResponse)
204
  async def chat_endpoint(request: ChatRequest):
205
- """Enhanced chat endpoint with dynamic model selection"""
206
  logger.info(f"Request: {request.prompt}")
207
  try:
208
  search_results = []
@@ -211,27 +333,23 @@ async def chat_endpoint(request: ChatRequest):
211
  if request.use_search:
212
  search_terms = extract_search_terms(request.prompt.lower())
213
  search_query = " ".join(search_terms) or request.prompt
214
- search_results = search_web(search_query)
215
  search_context = format_search_context(search_results)
216
 
217
  logger.info(f"Search Context: {search_context}")
218
  # Query classification
219
- task_type = classify_query(request.prompt)
220
  logger.info(f"Classified task: {task_type}")
221
 
222
  if task_type == 'question answering':
223
- response = qa_pipeline(
224
- question=request.prompt,
225
- context=search_context or request.prompt,
226
- max_answer_len=100
227
- )['answer']
228
  else:
229
- response = summarization_pipeline(
230
- search_context.lower() or request.prompt.lower(),
231
- max_length=150,
232
- min_length=30,
233
- do_sample=False
234
- )[0]['summary_text']
235
 
236
  return ChatResponse(
237
  response=response,
@@ -245,9 +363,9 @@ async def chat_endpoint(request: ChatRequest):
245
 
246
  @app.post("/search")
247
  async def search_endpoint(request: SearchRequest):
248
- """Search endpoint with improved error handling"""
249
  try:
250
- return {"results": search_web(request.query, request.max_results)}
251
  except Exception as e:
252
  logger.error(f"Search endpoint error: {e}")
253
  raise HTTPException(status_code=500, detail=str(e))
@@ -256,12 +374,13 @@ async def search_endpoint(request: SearchRequest):
256
  async def root():
257
  """Enhanced health check with model status"""
258
  return {
259
- "message": "Open Source Chat API is running!",
260
  "models": {
261
- "classifier": bool(classifier_pipeline),
262
- "qa": bool(qa_pipeline),
263
- "summarization": bool(summarization_pipeline)
264
  },
 
265
  "endpoints": {
266
  "chat": "/chat",
267
  "search": "/search",
@@ -269,6 +388,29 @@ async def root():
269
  }
270
  }
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  if __name__ == "__main__":
273
  import uvicorn
274
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
3
  from pydantic import BaseModel
4
  import torch
5
  from transformers import pipeline
6
+ import requests
7
+ import os
8
  from duckduckgo_search import DDGS
9
  from typing import Optional, List, Dict, Any
10
  import logging
11
  import re
12
+ import asyncio
13
+ import threading
14
+ from functools import wraps
15
 
16
 
17
  import spacy
 
29
  app = FastAPI(
30
  title="Enhanced Chat API with Dynamic Model Selection",
31
  description="API with query classification and dynamic model loading for QA/Summarization",
32
+ version="3.0.0"
33
  )
34
 
35
+ # Configure CORS with restricted origins for security
36
  app.add_middleware(
37
  CORSMiddleware,
38
+ allow_origins=[
39
+ "https://huggingface.co",
40
+ "https://*.hf.space",
41
+ "http://localhost:3000",
42
+ "http://localhost:8000",
43
+ "http://127.0.0.1:3000",
44
+ "http://127.0.0.1:8000"
45
+ ],
46
  allow_methods=["POST", "GET"],
47
+ allow_headers=["Content-Type", "Authorization"],
48
  )
49
 
50
  # Request/Response models
 
63
  query: str
64
  max_results: int = 5
65
 
66
+ # Thread-safe model manager
67
+ class ModelManager:
68
+ def __init__(self):
69
+ self._classifier_pipeline = None
70
+ self._qa_pipeline = None
71
+ self._summarization_pipeline = None
72
+ self._classifier_lock = threading.RLock()
73
+ self._qa_lock = threading.RLock()
74
+ self._summarization_lock = threading.RLock()
75
+
76
+ def get_classifier(self):
77
+ if self._classifier_pipeline is None:
78
+ with self._classifier_lock:
79
+ if self._classifier_pipeline is None:
80
+ self._load_classifier()
81
+ return self._classifier_pipeline
82
+
83
+ def get_qa_model(self):
84
+ if self._qa_pipeline is None:
85
+ with self._qa_lock:
86
+ if self._qa_pipeline is None:
87
+ self._load_qa_model()
88
+ return self._qa_pipeline
89
+
90
+ def get_summarization_model(self):
91
+ if self._summarization_pipeline is None:
92
+ with self._summarization_lock:
93
+ if self._summarization_pipeline is None:
94
+ self._load_summarization_model()
95
+ return self._summarization_pipeline
96
+
97
+ def _load_classifier(self):
98
+ """Load zero-shot classification model"""
99
+ try:
100
+ device = "cuda" if torch.cuda.is_available() else "cpu"
101
+ self._classifier_pipeline = pipeline(
102
+ "zero-shot-classification",
103
+ model="valhalla/distilbart-mnli-12-3",
104
+ device=0 if device == "cuda" else -1
105
+ )
106
+ logger.info("Zero-shot classifier loaded")
107
+ except Exception as e:
108
+ logger.error(f"Classifier load error: {e}")
109
+ raise
110
+
111
+ def _load_qa_model(self):
112
+ """Load question-answering model"""
113
+ try:
114
+ device = "cuda" if torch.cuda.is_available() else "cpu"
115
+ self._qa_pipeline = pipeline(
116
+ "question-answering",
117
+ model="distilbert-base-uncased-distilled-squad",
118
+ device=0 if device == "cuda" else -1
119
+ )
120
+ logger.info("QA model loaded")
121
+ except Exception as e:
122
+ logger.error(f"QA model load error: {e}")
123
+ raise
124
+
125
+ def _load_summarization_model(self):
126
+ """Load summarization model"""
127
+ try:
128
+ device = "cuda" if torch.cuda.is_available() else "cpu"
129
+ self._summarization_pipeline = pipeline(
130
+ "summarization",
131
+ model="sshleifer/distilbart-cnn-6-6",
132
+ device=0 if device == "cuda" else -1
133
+ )
134
+ logger.info("Summarization model loaded")
135
+ except Exception as e:
136
+ logger.error(f"Summarization model load error: {e}")
137
+ raise
138
+
139
+ # Global thread-safe model manager and NLP tools
140
+ model_manager = ModelManager()
141
  nlp = spacy.load("en_core_web_sm")
142
  rake = Rake()
143
 
 
191
 
192
  return final_terms
193
 
194
+ def run_in_threadpool(func):
195
+ """Decorator to run synchronous model inference in thread pool"""
196
+ @wraps(func)
197
+ async def wrapper(*args, **kwargs):
198
+ loop = asyncio.get_event_loop()
199
+ return await loop.run_in_executor(None, func, *args, **kwargs)
200
+ return wrapper
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
 
202
+ @run_in_threadpool
203
  def classify_query(prompt: str) -> str:
204
  """Classify query using zero-shot learning"""
205
  candidate_labels = ['question answering', 'summarization']
206
  try:
207
+ classifier_pipeline = model_manager.get_classifier()
208
  result = classifier_pipeline(
209
  prompt,
210
  candidate_labels,
 
215
  logger.error(f"Classification failed: {e}")
216
  return 'question answering' # Fallback to QA
217
 
218
+ def search_brave(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
219
+ """Search using Brave Search API"""
220
+ try:
221
+ api_key = os.getenv('BRAVE_API_KEY') or 'BSAaiYwrOKAj6njwCZ5IZVaJBfvCVNL'
222
+ if not api_key:
223
+ logger.error("BRAVE_API_KEY environment variable not set")
224
+ return []
225
+
226
+ headers = {
227
+ 'Accept': 'application/json',
228
+ 'Accept-Encoding': 'gzip',
229
+ 'X-Subscription-Token': api_key
230
+ }
231
+
232
+ params = {
233
+ 'q': query,
234
+ 'count': max_results,
235
+ 'safesearch': 'moderate',
236
+ 'search_lang': 'en',
237
+ 'country': 'US'
238
+ }
239
+
240
+ response = requests.get(
241
+ 'https://api.search.brave.com/res/v1/web/search',
242
+ headers=headers,
243
+ params=params,
244
+ timeout=10
245
+ )
246
+ response.raise_for_status()
247
+
248
+ data = response.json()
249
+ results = []
250
+
251
+ for result in data.get('web', {}).get('results', []):
252
+ results.append({
253
+ "title": result.get("title", ""),
254
+ "body": re.sub(r'\s+', ' ', result.get("description", "")).strip(),
255
+ "href": result.get("url", ""),
256
+ "source": "Brave"
257
+ })
258
+
259
+ return results[:max_results]
260
+
261
+ except Exception as e:
262
+ logger.error(f"Brave Search error: {e}")
263
+ return []
264
+
265
+ def search_duckduckgo(query: str, max_results: int = 5) -> List[Dict[str, Any]]:
266
+ """Search using DuckDuckGo"""
267
  try:
268
  with DDGS() as ddgs:
269
+ results = []
270
+ for result in ddgs.text(query, safesearch='moderate', max_results=max_results):
271
+ results.append({
272
+ "title": result.get("title", ""),
273
+ "body": re.sub(r'\s+', ' ', result.get("body", "")).strip(),
274
+ "href": result.get("href", ""),
275
+ "source": "DuckDuckGo"
276
+ })
277
+ return results
278
  except Exception as e:
279
+ logger.error(f"DuckDuckGo Search error: {e}")
280
  return []
281
 
282
+ def search_web_combined(query: str, max_results: int = 10) -> List[Dict[str, Any]]:
283
+ """Combined web search using both Brave and DuckDuckGo"""
284
+ # Get 5 results from each search engine
285
+ brave_results = search_brave(query, 5)
286
+ duckduckgo_results = search_duckduckgo(query, 5)
287
+
288
+ # Combine results
289
+ combined_results = brave_results + duckduckgo_results
290
+
291
+ # Remove duplicates based on URL
292
+ seen_urls = set()
293
+ unique_results = []
294
+
295
+ for result in combined_results:
296
+ url = result.get("href", "")
297
+ if url and url not in seen_urls:
298
+ seen_urls.add(url)
299
+ unique_results.append(result)
300
+
301
+ # Return top results up to max_results
302
+ return unique_results[:max_results]
303
+
304
  def format_search_context(results: List[Dict[str, Any]]) -> str:
305
+ """Create expanded context from combined search results"""
306
+ return "\n".join(
307
+ f"{i+1}. [{res.get('source', 'Unknown')}] {res['title']}: {res['body'][:800]}"
308
+ for i, res in enumerate(results[:10])
309
  )
310
 
311
  def preprocess_text(text: str) -> str:
 
315
  return " ".join([
316
  token.lemma_ for token in doc
317
  if not token.is_stop and not token.is_punct
318
+ ])[:2048]
319
 
320
  @app.on_event("startup")
321
  async def startup_event():
322
+ """Initialize model manager on startup"""
323
+ logger.info("Model manager initialized - models will load on demand")
 
 
324
 
325
  @app.post("/chat", response_model=ChatResponse)
326
  async def chat_endpoint(request: ChatRequest):
327
+ """Enhanced chat endpoint with dynamic model selection and combined search"""
328
  logger.info(f"Request: {request.prompt}")
329
  try:
330
  search_results = []
 
333
  if request.use_search:
334
  search_terms = extract_search_terms(request.prompt.lower())
335
  search_query = " ".join(search_terms) or request.prompt
336
+ search_results = search_web_combined(search_query, 10)
337
  search_context = format_search_context(search_results)
338
 
339
  logger.info(f"Search Context: {search_context}")
340
  # Query classification
341
+ task_type = await classify_query(request.prompt)
342
  logger.info(f"Classified task: {task_type}")
343
 
344
  if task_type == 'question answering':
345
+ response = await run_qa_inference(
346
+ request.prompt,
347
+ search_context or request.prompt
348
+ )
 
349
  else:
350
+ response = await run_summarization_inference(
351
+ search_context.lower() or request.prompt.lower()
352
+ )
 
 
 
353
 
354
  return ChatResponse(
355
  response=response,
 
363
 
364
  @app.post("/search")
365
  async def search_endpoint(request: SearchRequest):
366
+ """Search endpoint with combined search engines"""
367
  try:
368
+ return {"results": search_web_combined(request.query, request.max_results)}
369
  except Exception as e:
370
  logger.error(f"Search endpoint error: {e}")
371
  raise HTTPException(status_code=500, detail=str(e))
 
374
  async def root():
375
  """Enhanced health check with model status"""
376
  return {
377
+ "message": "Open Source Chat API with Combined Search is running!",
378
  "models": {
379
+ "classifier": bool(model_manager._classifier_pipeline),
380
+ "qa": bool(model_manager._qa_pipeline),
381
+ "summarization": bool(model_manager._summarization_pipeline)
382
  },
383
+ "search_engines": ["Brave", "DuckDuckGo"],
384
  "endpoints": {
385
  "chat": "/chat",
386
  "search": "/search",
 
388
  }
389
  }
390
 
391
+ @run_in_threadpool
392
+ def run_qa_inference(question: str, context: str) -> str:
393
+ """Run QA model inference in thread pool"""
394
+ qa_pipeline = model_manager.get_qa_model()
395
+ result = qa_pipeline(
396
+ question=question,
397
+ context=context[:4000],
398
+ max_answer_len=200
399
+ )
400
+ return result['answer']
401
+
402
+ @run_in_threadpool
403
+ def run_summarization_inference(text: str) -> str:
404
+ """Run summarization model inference in thread pool"""
405
+ summarization_pipeline = model_manager.get_summarization_model()
406
+ result = summarization_pipeline(
407
+ text,
408
+ max_length=150,
409
+ min_length=30,
410
+ do_sample=False
411
+ )
412
+ return result[0]['summary_text']
413
+
414
  if __name__ == "__main__":
415
  import uvicorn
416
+ uvicorn.run(app, host="0.0.0.0", port=7860)