ashwinsdk commited on
Commit
3c5b3c1
Β·
0 Parent(s):

Initial deployment of NexVote AI service

Browse files
Files changed (5) hide show
  1. .gitignore +22 -0
  2. Dockerfile +31 -0
  3. README.md +51 -0
  4. main.py +292 -0
  5. requirements.txt +10 -0
.gitignore ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Virtual environments
2
+ venv/
3
+ .venv/
4
+ env/
5
+ ENV/
6
+
7
+ # Python cache
8
+ __pycache__/
9
+ *.pyc
10
+ *.pyo
11
+ *.pyd
12
+
13
+ # Model cache (Hugging Face transformers)
14
+ models/
15
+ .cache/
16
+
17
+ # Logs
18
+ *.log
19
+
20
+ # IDE
21
+ .vscode/
22
+ .idea/
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ build-essential \
7
+ wget && \
8
+ rm -rf /var/lib/apt/lists/*
9
+
10
+ COPY requirements.txt .
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ COPY main.py .
14
+
15
+ RUN useradd -m -u 1000 user
16
+ USER user
17
+
18
+ ENV HOME=/home/user \
19
+ PATH=/home/user/.local/bin:$PATH \
20
+ AI_SERVICE_PORT=8000
21
+
22
+ WORKDIR $HOME/app
23
+
24
+ COPY --chown=user . $HOME/app
25
+
26
+ EXPOSE 8000
27
+
28
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
29
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
30
+
31
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
README.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: NexVote AI Service
3
+ emoji: πŸ—³οΈ
4
+ colorFrom: purple
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 8000
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
+ # NexVote AI Service
13
+
14
+ AI-powered translation, summarization, and semantic search service for the NexVote decentralized voting platform.
15
+
16
+ ## Features
17
+
18
+ - Multi-language translation (6 languages: en, ta, hi, kn, ml, te)
19
+ - Proposal summarization (BART-large-cnn)
20
+ - Semantic search with embeddings (all-MiniLM-L6-v2)
21
+ - RESTful API with FastAPI
22
+
23
+ ## API Endpoints
24
+
25
+ - POST /translate - Translate text between supported languages
26
+ - POST /summarize - Generate proposal summaries
27
+ - POST /embed - Create vector embeddings
28
+ - POST /search - Semantic search across content
29
+ - GET /health - Service health check
30
+
31
+ ## Models Used
32
+
33
+ - Translation: Helsinki-NLP/opus-mt-{en-ta, ta-en, en-hi, hi-en, en-kn, kn-en, en-ml, ml-en, en-te, te-en}
34
+ - Summarization: facebook/bart-large-cnn
35
+ - Embeddings: sentence-transformers/all-MiniLM-L6-v2
36
+
37
+ ## Environment Variables
38
+
39
+ - AI_API_KEY: API key for authentication (optional)
40
+ - AI_SERVICE_PORT: Port to run the service on (default: 8000)
41
+
42
+ ## Local Development
43
+
44
+ ```bash
45
+ pip install -r requirements.txt
46
+ python main.py
47
+ ```
48
+
49
+ ## Documentation
50
+
51
+ Visit /docs for interactive API documentation (Swagger UI)
main.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NexVote AI Service
3
+ FastAPI server providing /summarize and /embed endpoints.
4
+ Uses a local model for embeddings and summarization to keep all data private.
5
+ """
6
+
7
+ import os
8
+ import logging
9
+ import hashlib
10
+ from typing import Optional
11
+
12
+ from dotenv import load_dotenv
13
+ from fastapi import FastAPI, HTTPException, Header
14
+ from pydantic import BaseModel
15
+ import numpy as np
16
+
17
+ load_dotenv(dotenv_path="../.env")
18
+
19
+ # ── Config ────────────────────────────────────────────────────────────────────
20
+
21
+ AI_API_KEY = os.getenv("AI_API_KEY", "change-me-ai-api-key")
22
+ EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "all-MiniLM-L6-v2")
23
+ SUMMARIZER_MODEL = os.getenv("SUMMARIZER_MODEL", "facebook/bart-large-cnn")
24
+ MAX_SUMMARY_TOKENS = int(os.getenv("MAX_SUMMARY_TOKENS", "200"))
25
+ EMBEDDING_DIMENSION = int(os.getenv("EMBEDDING_DIMENSION", "384"))
26
+ LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
27
+
28
+ TRANSLATION_MODELS = {
29
+ ("en", "ta"): "Helsinki-NLP/opus-mt-en-ta",
30
+ ("ta", "en"): "Helsinki-NLP/opus-mt-ta-en",
31
+ ("en", "hi"): "Helsinki-NLP/opus-mt-en-hi",
32
+ ("hi", "en"): "Helsinki-NLP/opus-mt-hi-en",
33
+ ("en", "kn"): "Helsinki-NLP/opus-mt-en-kn",
34
+ ("kn", "en"): "Helsinki-NLP/opus-mt-kn-en",
35
+ ("en", "ml"): "Helsinki-NLP/opus-mt-en-ml",
36
+ ("ml", "en"): "Helsinki-NLP/opus-mt-ml-en",
37
+ ("en", "te"): "Helsinki-NLP/opus-mt-en-te",
38
+ ("te", "en"): "Helsinki-NLP/opus-mt-te-en",
39
+ }
40
+
41
+ logging.basicConfig(level=getattr(logging, LOG_LEVEL))
42
+ logger = logging.getLogger("nexvote-ai")
43
+
44
+ app = FastAPI(
45
+ title="NexVote AI Service",
46
+ description="Local LLM service for proposal summarization and embedding generation.",
47
+ version="0.1.0",
48
+ )
49
+
50
+ # ── Lazy model loading ────────────────────────────────────────────────────────
51
+
52
+ _embedding_model = None
53
+ _summarizer = None
54
+ _translation_pipelines = {}
55
+
56
+
57
+ def get_embedding_model():
58
+ """Lazy-load the sentence transformer embedding model."""
59
+ global _embedding_model
60
+ if _embedding_model is None:
61
+ logger.info("Loading embedding model: %s", EMBEDDING_MODEL)
62
+ from sentence_transformers import SentenceTransformer
63
+
64
+ _embedding_model = SentenceTransformer(EMBEDDING_MODEL)
65
+ logger.info("Embedding model loaded (dimension=%d)", EMBEDDING_DIMENSION)
66
+ return _embedding_model
67
+
68
+
69
+ def get_summarizer():
70
+ """Lazy-load the summarization pipeline."""
71
+ global _summarizer
72
+ if _summarizer is None:
73
+ logger.info("Loading summarizer model: %s", SUMMARIZER_MODEL)
74
+ from transformers import pipeline
75
+
76
+ _summarizer = pipeline(
77
+ "summarization",
78
+ model=SUMMARIZER_MODEL,
79
+ max_length=MAX_SUMMARY_TOKENS,
80
+ min_length=30,
81
+ do_sample=False,
82
+ )
83
+ logger.info("Summarizer model loaded")
84
+ return _summarizer
85
+
86
+
87
+ def get_translator(source_lang: str, target_lang: str):
88
+ """Lazy-load a translation pipeline for the given language pair."""
89
+ key = f"{source_lang}->{target_lang}"
90
+ model_name = TRANSLATION_MODELS.get((source_lang, target_lang))
91
+ if not model_name:
92
+ raise HTTPException(status_code=400, detail="Translation pair not supported.")
93
+
94
+ if key not in _translation_pipelines:
95
+ logger.info("Loading translation model: %s", model_name)
96
+ from transformers import pipeline
97
+
98
+ _translation_pipelines[key] = pipeline("translation", model=model_name)
99
+ logger.info("Translation model loaded (%s)", key)
100
+
101
+ return _translation_pipelines[key]
102
+
103
+
104
+ # ── Auth helper ───────────────────────────────────────────────────────────────
105
+
106
+
107
+ def verify_api_key(x_api_key: Optional[str] = Header(None)):
108
+ """Verify the API key from the X-API-Key header."""
109
+ if AI_API_KEY and x_api_key != AI_API_KEY:
110
+ raise HTTPException(status_code=401, detail="Invalid or missing API key.")
111
+
112
+
113
+ # ── Request / Response models ─────────────────────────────────────────────────
114
+
115
+
116
+ class EmbedRequest(BaseModel):
117
+ text: str
118
+ id: Optional[str] = None
119
+
120
+
121
+ class EmbedResponse(BaseModel):
122
+ id: Optional[str]
123
+ embedding: list[float]
124
+ dimension: int
125
+
126
+
127
+ class SummarizeRequest(BaseModel):
128
+ text: str
129
+
130
+
131
+ class SummarizeResponse(BaseModel):
132
+ summary: str
133
+ original_length: int
134
+ summary_length: int
135
+
136
+
137
+ class TranslateRequest(BaseModel):
138
+ text: str
139
+ source_lang: str
140
+ target_lang: str
141
+
142
+
143
+ class TranslateResponse(BaseModel):
144
+ translation: str
145
+
146
+
147
+ class BatchEmbedRequest(BaseModel):
148
+ texts: list[str]
149
+ ids: Optional[list[str]] = None
150
+
151
+
152
+ class BatchEmbedResponse(BaseModel):
153
+ embeddings: list[list[float]]
154
+ ids: Optional[list[str]]
155
+ dimension: int
156
+ count: int
157
+
158
+
159
+ # ── Endpoints ─────────────────────────────────────────────────────────────────
160
+
161
+
162
+ @app.get("/health")
163
+ async def health():
164
+ """Health check endpoint."""
165
+ return {
166
+ "status": "ok",
167
+ "embedding_model": EMBEDDING_MODEL,
168
+ "summarizer_model": SUMMARIZER_MODEL,
169
+ "embedding_dimension": EMBEDDING_DIMENSION,
170
+ }
171
+
172
+
173
+ @app.post("/embed", response_model=EmbedResponse)
174
+ async def embed(req: EmbedRequest, x_api_key: Optional[str] = Header(None)):
175
+ """Generate an embedding vector for the given text."""
176
+ verify_api_key(x_api_key)
177
+
178
+ if not req.text or len(req.text.strip()) < 5:
179
+ raise HTTPException(status_code=400, detail="Text too short for embedding.")
180
+
181
+ request_id = hashlib.sha256(req.text[:100].encode()).hexdigest()[:12]
182
+ logger.info("Embed request [%s] text_length=%d", request_id, len(req.text))
183
+
184
+ try:
185
+ model = get_embedding_model()
186
+ embedding = model.encode(req.text, normalize_embeddings=True)
187
+ embedding_list = embedding.tolist()
188
+
189
+ return EmbedResponse(
190
+ id=req.id,
191
+ embedding=embedding_list,
192
+ dimension=len(embedding_list),
193
+ )
194
+ except Exception as e:
195
+ logger.error("Embedding failed [%s]: %s", request_id, str(e))
196
+ raise HTTPException(status_code=500, detail="Embedding generation failed.")
197
+
198
+
199
+ @app.post("/embed/batch", response_model=BatchEmbedResponse)
200
+ async def embed_batch(req: BatchEmbedRequest, x_api_key: Optional[str] = Header(None)):
201
+ """Generate embedding vectors for a batch of texts."""
202
+ verify_api_key(x_api_key)
203
+
204
+ if not req.texts or len(req.texts) == 0:
205
+ raise HTTPException(status_code=400, detail="No texts provided.")
206
+
207
+ if len(req.texts) > 100:
208
+ raise HTTPException(status_code=400, detail="Batch size exceeds maximum of 100.")
209
+
210
+ logger.info("Batch embed request count=%d", len(req.texts))
211
+
212
+ try:
213
+ model = get_embedding_model()
214
+ embeddings = model.encode(req.texts, normalize_embeddings=True, batch_size=32)
215
+ embeddings_list = [e.tolist() for e in embeddings]
216
+
217
+ return BatchEmbedResponse(
218
+ embeddings=embeddings_list,
219
+ ids=req.ids,
220
+ dimension=len(embeddings_list[0]) if embeddings_list else EMBEDDING_DIMENSION,
221
+ count=len(embeddings_list),
222
+ )
223
+ except Exception as e:
224
+ logger.error("Batch embedding failed: %s", str(e))
225
+ raise HTTPException(status_code=500, detail="Batch embedding generation failed.")
226
+
227
+
228
+ @app.post("/summarize", response_model=SummarizeResponse)
229
+ async def summarize(req: SummarizeRequest, x_api_key: Optional[str] = Header(None)):
230
+ """Generate a concise summary of the given text."""
231
+ verify_api_key(x_api_key)
232
+
233
+ if not req.text or len(req.text.strip()) < 20:
234
+ raise HTTPException(status_code=400, detail="Text too short for summarization.")
235
+
236
+ request_id = hashlib.sha256(req.text[:100].encode()).hexdigest()[:12]
237
+ logger.info("Summarize request [%s] text_length=%d", request_id, len(req.text))
238
+
239
+ try:
240
+ summarizer = get_summarizer()
241
+
242
+ # Truncate to model max input if necessary
243
+ input_text = req.text[:4096]
244
+ result = summarizer(input_text)
245
+ summary = result[0]["summary_text"]
246
+
247
+ return SummarizeResponse(
248
+ summary=summary,
249
+ original_length=len(req.text),
250
+ summary_length=len(summary),
251
+ )
252
+ except Exception as e:
253
+ logger.error("Summarization failed [%s]: %s", request_id, str(e))
254
+ raise HTTPException(status_code=500, detail="Summarization failed.")
255
+
256
+
257
+ @app.post("/translate", response_model=TranslateResponse)
258
+ async def translate(req: TranslateRequest, x_api_key: Optional[str] = Header(None)):
259
+ """Translate text between supported languages."""
260
+ verify_api_key(x_api_key)
261
+
262
+ if not req.text or len(req.text.strip()) < 1:
263
+ raise HTTPException(status_code=400, detail="Text is required for translation.")
264
+
265
+ source_lang = req.source_lang.lower().strip()
266
+ target_lang = req.target_lang.lower().strip()
267
+
268
+ if source_lang == target_lang:
269
+ return TranslateResponse(translation=req.text)
270
+
271
+ request_id = hashlib.sha256(req.text[:100].encode()).hexdigest()[:12]
272
+ logger.info("Translate request [%s] %s->%s", request_id, source_lang, target_lang)
273
+
274
+ try:
275
+ translator = get_translator(source_lang, target_lang)
276
+ result = translator(req.text)
277
+ translation = result[0]["translation_text"]
278
+ return TranslateResponse(translation=translation)
279
+ except HTTPException:
280
+ raise
281
+ except Exception as e:
282
+ logger.error("Translation failed [%s]: %s", request_id, str(e))
283
+ raise HTTPException(status_code=500, detail="Translation failed.")
284
+
285
+
286
+ # ── Entry point ───────────────────────────────────────────────────────────────
287
+
288
+ if __name__ == "__main__":
289
+ import uvicorn
290
+
291
+ port = int(os.getenv("AI_SERVICE_PORT", "8000"))
292
+ uvicorn.run(app, host="0.0.0.0", port=port)
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.32.0
3
+ sentence-transformers==3.3.0
4
+ transformers==4.46.0
5
+ torch==2.5.0
6
+ numpy>=1.26.0,<2.0.0
7
+ pydantic==2.10.0
8
+ python-dotenv==1.0.1
9
+ sentencepiece==0.2.0
10
+ sacremoses==0.1.1