Download fieldsabi_app/server.py from Harbidel/FieldSabi: direct link, hf CLI and curl.
- Browser
- Download file 11.5 kB
-
https://huggingface.co/spaces/Harbidel/FieldSabi/resolve/main/fieldsabi_app/server.py
- Command line
-
hf download hf://spaces/Harbidel/FieldSabi/fieldsabi_app/server.py
-
curl -L -o server.py https://huggingface.co/spaces/Harbidel/FieldSabi/resolve/main/fieldsabi_app/server.py
11.5 kB
| """Same-origin, single-user development server. No audio or transcript logging.""" | |
| from functools import partial | |
| from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer | |
| from pathlib import Path | |
| import json | |
| import secrets | |
| import threading | |
| import time | |
| from urllib.parse import urlsplit, parse_qs | |
| MAX_BYTES = 20 * 1024 * 1024 | |
| class BusyError(Exception): | |
| pass | |
| def make_server(app_dir, transcribe=None, language=None, port=8765, answer=None, progress=None, speak=None): | |
| token = secrets.token_urlsafe(32) | |
| lock = threading.Lock() | |
| jobs = {} | |
| jobs_lock = threading.Lock() | |
| latest_answer = {} | |
| def answer_and_remember(data): | |
| latest_answer.clear() | |
| result = answer(data) | |
| latest_answer.update(result=result, expires=time.monotonic() + 900) | |
| return result | |
| class Handler(SimpleHTTPRequestHandler): | |
| def setup(self): | |
| super().setup() | |
| self.connection.settimeout(60) | |
| def log_message(self, *args): | |
| pass | |
| def list_directory(self, path): | |
| self.send_error(404) | |
| def end_headers(self): | |
| self.send_header('Cache-Control', 'no-store') | |
| self.send_header('X-Content-Type-Options', 'nosniff') | |
| self.send_header('Referrer-Policy', 'no-referrer') | |
| self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; media-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'") | |
| super().end_headers() | |
| def reply(self, status, data): | |
| payload = json.dumps(data, ensure_ascii=False).encode('utf-8') | |
| self.send_response(status) | |
| self.send_header('Content-Type', 'application/json; charset=utf-8') | |
| self.send_header('Content-Length', str(len(payload))) | |
| self.end_headers() | |
| try: | |
| self.wfile.write(payload) | |
| except (BrokenPipeError, ConnectionResetError): | |
| pass | |
| def do_GET(self): | |
| if urlsplit(self.path).path.startswith('/api/jobs/'): | |
| if not secrets.compare_digest(self.headers.get('X-FieldSabi-Token', ''), token): | |
| return self.reply(403, {'error': 'Refresh the app to reconnect.'}) | |
| job_id = urlsplit(self.path).path.rsplit('/', 1)[-1] | |
| with jobs_lock: | |
| job = jobs.get(job_id) | |
| result = dict(job) if job else None | |
| if result is None: | |
| return self.reply(404, {'error': 'Job expired or runtime restarted. Submit the question again.'}) | |
| if result['state'] == 'running': | |
| result['stage'] = result.get('stage') or (progress() if progress else 'Searching sources, loading the model or checking the answer') | |
| result['elapsed_seconds'] = round(time.monotonic() - result.pop('started')) | |
| return self.reply(200, result) | |
| if urlsplit(self.path).path == '/api/session': | |
| return self.reply(200, {'ready': transcribe is not None, 'language': language, | |
| 'csrf': token, 'answers_ready': answer is not None, | |
| 'speech_languages': ['en','yo','ha'] if speak else []}) | |
| if urlsplit(self.path).path.startswith('/api/'): | |
| return self.reply(404, {'error': 'Unknown endpoint.'}) | |
| return super().do_GET() | |
| def do_POST(self): | |
| is_answer = urlsplit(self.path).path == '/api/answer' | |
| is_speak = urlsplit(self.path).path == '/api/speak' | |
| if urlsplit(self.path).path not in {'/api/transcribe', '/api/answer', '/api/speak'}: | |
| return self.reply(404, {'error': 'Unknown endpoint.'}) | |
| if not secrets.compare_digest(self.headers.get('X-FieldSabi-Token', ''), token): | |
| return self.reply(403, {'error': 'Refresh the app before sending audio.'}) | |
| if (speak if is_speak else answer if is_answer else transcribe) is None: | |
| return self.reply(503, {'error': 'Speech model is not connected. Open the app in the Colab launcher.'}) | |
| selected = parse_qs(urlsplit(self.path).query).get('language', [''])[0] | |
| if not is_answer and not is_speak and selected != language: | |
| return self.reply(422, {'error': 'Choose the language loaded by this speech service.'}) | |
| try: | |
| length = int(self.headers.get('Content-Length', '0')) | |
| except ValueError: | |
| length = 0 | |
| if self.headers.get('Transfer-Encoding') or not 0 < length <= (100 if is_speak else 4300000 if is_answer else MAX_BYTES): | |
| return self.reply(413, {'error': 'Send a non-empty audio file up to 20 MB.'}) | |
| if not lock.acquire(blocking=False): | |
| return self.reply(429, {'error': 'Model service is busy. Please retry shortly.'}) | |
| handed_off = False | |
| try: | |
| data = self.rfile.read(length) | |
| if len(data) != length: | |
| return self.reply(400, {'error': 'Audio upload was incomplete.'}) | |
| if is_speak: | |
| from .tts import spoken_text | |
| payload = json.loads(data) | |
| selected_language = payload.get('language') if isinstance(payload, dict) else None | |
| if selected_language not in {'en','yo','ha'}: | |
| return self.reply(422, {'error':'Choose a supported speech language.'}) | |
| if latest_answer.get('expires', 0) < time.monotonic(): | |
| return self.reply(422, {'error':'The answer expired. Generate it again before listening.'}) | |
| text = spoken_text(latest_answer['result'], selected_language) | |
| job_id = secrets.token_urlsafe(24) | |
| with jobs_lock: | |
| jobs.clear() | |
| jobs[job_id] = {'state':'running', 'started':time.monotonic(), 'stage':'Preparing spoken answer'} | |
| def run_speech(): | |
| try: | |
| result = speak(text, selected_language) | |
| with jobs_lock: | |
| jobs[job_id].update(state='done', result=result) | |
| except Exception: | |
| with jobs_lock: | |
| jobs[job_id].update(state='failed', error='Speech generation failed. The written answer is still available. Check model access in the runtime.') | |
| finally: | |
| lock.release() | |
| threading.Thread(target=run_speech, daemon=True).start() | |
| handed_off = True | |
| return self.reply(202, {'job_id':job_id}) | |
| if is_answer: | |
| try: | |
| payload = json.loads(data) | |
| except (ValueError, UnicodeError): | |
| return self.reply(422, {'error': 'Send a valid question.'}) | |
| limits = {'question':1500, 'language':2, 'answer_mode':5, 'previous_question':1500, 'state':80, 'locality':100, 'topic':80, 'farm_context':500} | |
| if not isinstance(payload, dict) or any(not isinstance(payload.get(k, ''), str) or len(payload.get(k, '')) > limit for k, limit in limits.items()): | |
| return self.reply(422, {'error': 'Question or farm details are invalid or too long.'}) | |
| if not payload.get('question', '').strip() or payload.get('language') not in {'en','ha','yo','ig'}: | |
| return self.reply(422, {'error': 'Enter a question and choose a supported language.'}) | |
| if payload.get('answer_mode', '') not in {'','en','yo','ha','en-yo','en-ha','ig','en-ig'}: | |
| return self.reply(422, {'error':'Choose a supported answer language.'}) | |
| clean = {k:payload.get(k, '').strip() for k in limits} | |
| document = payload.get('document') | |
| if document is not None: | |
| if not isinstance(document, dict) or any(not isinstance(document.get(k), str) or len(document[k]) > size for k,size in {'name':150,'scope':100,'data':4200000}.items()): | |
| return self.reply(422, {'error':'Invalid reference document. Use a PDF up to 3 MB.'}) | |
| clean['document'] = {k:document[k] for k in ('name','scope','data')} | |
| if self.headers.get('Prefer') == 'respond-async': | |
| job_id = secrets.token_urlsafe(24) | |
| with jobs_lock: | |
| jobs.clear() # Single-user service: retain only the latest accepted job. | |
| jobs[job_id] = {'state':'running', 'started':time.monotonic()} | |
| def run(): | |
| try: | |
| result = answer_and_remember(clean) | |
| with jobs_lock: | |
| jobs[job_id].update(state='done', result=result) | |
| except ValueError as error: | |
| with jobs_lock: | |
| jobs[job_id].update(state='failed', error=str(error)) | |
| except Exception as error: | |
| # Print only the exception type, never question/audio/token values. | |
| print('FieldSabi answer failed:', type(error).__name__, flush=True) | |
| with jobs_lock: | |
| jobs[job_id].update(state='failed', error='Answer failed in the runtime. Check GPU memory and model access; restart the runtime if necessary.') | |
| finally: | |
| lock.release() | |
| worker = threading.Thread(target=run, daemon=True) | |
| worker.start() | |
| handed_off = True | |
| return self.reply(202, {'job_id':job_id}) | |
| return self.reply(200, answer_and_remember(clean)) | |
| text = transcribe(data).strip() | |
| words = text.split() | |
| if not text or len(text) > 1500 or (len(words) >= 12 and len(set(words[-8:])) <= 2): | |
| return self.reply(422, {'error': 'No usable transcript returned. Try a clearer, shorter recording.'}) | |
| self.reply(200, {'text': text, 'language': language}) | |
| except ValueError as error: | |
| self.reply(422, {'error': str(error)}) | |
| except Exception: | |
| self.reply(503, {'error': 'Model request failed. Check the runtime and retry.'}) | |
| finally: | |
| if not handed_off: | |
| lock.release() | |
| class Server(ThreadingHTTPServer): | |
| def server_close(self): | |
| super().server_close() | |
| # Notebook reruns must not release GPU models under an active job. | |
| with lock: | |
| pass | |
| return Server(('127.0.0.1', port), partial(Handler, directory=str(Path(app_dir).resolve()))) | |