Spaces:
Sleeping
Sleeping
Download web_api/app.py from gauravmeena0708/epfo-circulars: direct link, hf CLI and curl.
- Browser
- Download file 15.9 kB
-
https://huggingface.co/spaces/gauravmeena0708/epfo-circulars/resolve/main/web_api/app.py
- Command line
-
hf download hf://spaces/gauravmeena0708/epfo-circulars/web_api/app.py
-
curl -L -o app.py https://huggingface.co/spaces/gauravmeena0708/epfo-circulars/resolve/main/web_api/app.py
15.9 kB
| """React research workspace API. Run: uvicorn web_api.app:app --port 7860.""" | |
| from datetime import date | |
| import asyncio | |
| from contextlib import asynccontextmanager, suppress | |
| from functools import lru_cache | |
| import io | |
| import json | |
| import logging | |
| import os | |
| from pathlib import Path | |
| import sqlite3 | |
| import threading | |
| import time | |
| from uuid import uuid4 | |
| from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Request, Header | |
| from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse, Response | |
| from fastapi.staticfiles import StaticFiles | |
| from pydantic import BaseModel, Field | |
| from policy_registry import registered_policies | |
| import currency | |
| from official_links import official_links, official_url | |
| import policy_pdfs | |
| from policy_history import PolicyHistory | |
| from policy_router import route_retrieval | |
| ROOT = Path(__file__).resolve().parents[1] | |
| from dotenv import load_dotenv | |
| load_dotenv(ROOT / '.env') | |
| LOG = logging.getLogger(__name__) | |
| async def lifespan(app): | |
| from .workspace import FILES, LOCK, TTL | |
| async def cleanup(): | |
| while True: | |
| await asyncio.sleep(60) | |
| with LOCK: | |
| expired = [key for key, value in FILES.items() if time.monotonic()-value['created'] > TTL] | |
| for key in expired: | |
| del FILES[key] | |
| task = asyncio.create_task(cleanup()) | |
| try: | |
| yield | |
| finally: | |
| task.cancel() | |
| with suppress(asyncio.CancelledError): | |
| await task | |
| app = FastAPI(title='EPFO Research Workspace', version='1.0.0', lifespan=lifespan) | |
| _lock = threading.RLock() | |
| _packets = {} | |
| _pdf_lock = threading.Lock() | |
| class SearchRequest(BaseModel): | |
| question: str = Field(min_length=3, max_length=6000) | |
| policy: str = 'auto' | |
| topic: str | None = Field(default=None, max_length=80) | |
| as_of: date = Field(default_factory=date.today) | |
| class AnswerRequest(BaseModel): | |
| language: str = 'English' | |
| def check_day(day): | |
| if not date(1952, 1, 1) <= day <= date.today(): | |
| raise HTTPException(422, 'Choose an evidence date between 1952 and today.') | |
| def policy_by_id(id): | |
| policy = next((p for p in registered_policies() if p.id == id), None) | |
| if policy is None: | |
| raise HTTPException(404, 'Policy not found.') | |
| return policy | |
| def resources(): | |
| import config | |
| from sentence_transformers import SentenceTransformer, CrossEncoder | |
| from vector_indexer import load_faiss_binary_index | |
| from passage_store import open_passage_store | |
| with _lock: | |
| directory = str(ROOT / config.DEFAULT_INDEX_DIR / 'data_index') | |
| model = SentenceTransformer(config.EMBEDDING_MODEL_NAME, device=config.EMBEDDING_DEVICE) | |
| index = load_faiss_binary_index(directory, model) | |
| store = open_passage_store(directory) | |
| if index is None or store is None or index.ntotal != len(store): | |
| raise RuntimeError('Search index unavailable') | |
| reranker = None | |
| if config.CROSS_ENCODER_MODEL_NAME: | |
| try: | |
| reranker = CrossEncoder(config.CROSS_ENCODER_MODEL_NAME, device=config.EMBEDDING_DEVICE) | |
| except Exception: | |
| LOG.warning('Reranker unavailable; using hybrid retrieval') | |
| return model, index, store, reranker, directory | |
| def hybrid_search(question, extra=0): | |
| import config | |
| from retriever import retrieve_relevant_chunks | |
| # Serialize CPU-heavy retrieval on a small Space; no shared question state. | |
| with _lock: | |
| model, index, store, reranker, directory = resources() | |
| # ``extra`` ranks a few more candidates (same retrieval pass) so newer circulars can be flagged. | |
| return retrieve_relevant_chunks(question, index, store.texts, store.metadata, model, | |
| cross_encoder_model=reranker, | |
| top_n_final=getattr(config, 'TOP_N_RETRIEVAL', 5) + extra if extra else None, | |
| bm25_cache_path=str(Path(directory) / 'faiss_index.bm25.json.gz')) | |
| def history_for(id): | |
| return PolicyHistory(policy_by_id(id).path) | |
| def pdf_path(policy, document): | |
| """A verified local copy of the original, if one is already on disk (no network).""" | |
| relative = document.get('local_pdf') | |
| if relative: | |
| path = (policy.path.parent / relative).resolve() | |
| if path.is_relative_to(ROOT / 'policy_data') and path.is_file(): | |
| # Never serve an unresolved Git LFS pointer as an archived PDF. | |
| with path.open('rb') as source: | |
| if source.read(5) == b'%PDF-': | |
| return path | |
| sha256 = policy_pdfs.expected_sha256(policy.path.parent, document) | |
| return policy_pdfs.cached_path(document.get('id'), sha256) if sha256 else None | |
| def pdf_available(policy, document): | |
| """Originals are no longer stored in Git; they are fetched from the public source URL.""" | |
| return bool(pdf_path(policy, document) or policy_pdfs.has_public_original(policy.path.parent, document)) | |
| def corpus_date(): | |
| path = ROOT / 'vector_store/data_index/faiss_index.passages.db' | |
| return currency.corpus_as_of(str(path)) if path.exists() else None | |
| def catalog(): | |
| path = ROOT / 'vector_store/data_index/faiss_index.passages.db' | |
| count = None | |
| if path.exists(): | |
| with sqlite3.connect(f'file:{path}?mode=ro', uri=True) as db: | |
| # Published indexes use contiguous zero-based passage IDs. This avoids | |
| # scanning the entire SQLite corpus just to paint the home screen. | |
| count = db.execute('select coalesce(max(id), -1) + 1 from passages').fetchone()[0] | |
| policies = [] | |
| for policy in registered_policies(): | |
| history = history_for(policy.id) | |
| checked = [d for d in history.documents.values() if d.get('source_file')] | |
| issued = [d['issued_on'] for d in history.documents.values()] | |
| policies.append(dict(id=policy.id, label=policy.label, scope=policy.scope, | |
| first_issued=min(issued, default=None), last_issued=max(issued, default=None), | |
| document_count=len(history.documents), checked_count=len(checked), | |
| topics=history.policy.get('topics', []), | |
| newest_checked=max((d['issued_on'] for d in checked), default=None))) | |
| latest = corpus_date() | |
| return dict(policies=policies, passage_count=count, corpus_as_of=latest.isoformat() if latest else None, answer_enabled=bool(os.environ.get('HF_TOKEN')), today=date.today().isoformat()) | |
| def _division_catalog(mtime): | |
| return json.loads((ROOT / 'policy_data/division_catalog.json').read_text(encoding='utf-8')) | |
| def divisions(): | |
| """Policy topics by issuing division, with status where a circular states it (built by scripts/build_division_catalog.py).""" | |
| path = ROOT / 'policy_data/division_catalog.json' | |
| if not path.is_file(): | |
| raise HTTPException(404, 'The division catalog is not available.') | |
| catalog = dict(_division_catalog(path.stat().st_mtime_ns)) | |
| known = {p.id: p.label for p in registered_policies()} | |
| # Only link collections that are registered here, so a stale catalog cannot point at a missing one. | |
| catalog['collections'] = known | |
| return catalog | |
| def policy_snapshot(id: str, as_of: date | None = None, topic: str | None = None): | |
| day = as_of or date.today() | |
| check_day(day) | |
| policy = policy_by_id(id) | |
| try: | |
| report = history_for(id).snapshot(day.isoformat(), topics=[topic] if topic else None) | |
| except ValueError as error: | |
| raise HTTPException(422, str(error)) from None | |
| for doc in report['documents']: | |
| doc['pdf_url'] = f'/api/policies/{id}/documents/{doc["id"]}/pdf' if pdf_available(policy, doc) else None | |
| doc.update(official_links({k: doc[k] for k in ('source_url', 'original_source_url') if k in doc})) | |
| return dict(label=policy.label, scope=policy.scope, **report) | |
| def source_pdf(id: str, document_id: str): | |
| policy = policy_by_id(id) | |
| document = history_for(id).documents.get(document_id) or {} | |
| path = pdf_path(policy, document) | |
| if path is None and policy_pdfs.has_public_original(policy.path.parent, document): | |
| try: | |
| # Serialised so two viewers of the same file do not download it twice. | |
| with _pdf_lock: | |
| path = policy_pdfs.ensure_original(policy.path.parent, document) | |
| except Exception as error: | |
| LOG.warning('Public original unavailable for %s: %s', document_id, error) | |
| # The reader's own browser can usually reach the official site even when this server cannot. | |
| return RedirectResponse(official_url(document['source_url']), status_code=302) | |
| if path is None: | |
| raise HTTPException(404, 'No original is available for this document.') | |
| return FileResponse(path, media_type='application/pdf', headers={'Content-Disposition': 'inline'}) | |
| def search(request: SearchRequest, http_request: Request): | |
| from .workspace import owner | |
| session = owner(http_request.headers.get("x-workspace-session")) | |
| check_day(request.as_of) | |
| if not request.question.strip(): | |
| raise HTTPException(422, 'Enter a question.') | |
| import config | |
| pool = [] | |
| def run_hybrid(): | |
| ranked = hybrid_search(request.question.strip(), extra=currency.EXTRA_CANDIDATES) | |
| top = getattr(config, 'TOP_N_RETRIEVAL', 5) | |
| pool[:] = ranked[top:] | |
| return ranked[:top] | |
| try: | |
| result = route_retrieval(request.question.strip(), request.as_of.isoformat(), | |
| run_hybrid, mode=request.policy, topic=request.topic) | |
| except ValueError as error: | |
| raise HTTPException(422, str(error)) from None | |
| except Exception: | |
| LOG.exception('Search failed') | |
| raise HTTPException(503, 'Search is unavailable. Please retry shortly.') from None | |
| # The old epfindia.gov.in site now lives at epfo.gov.in: show readers (and the model) current links. | |
| for chunk in result['chunks']: | |
| chunk['metadata'] = official_links(chunk.get('metadata', {})) | |
| for document in (result['policy_context'] or {}).get('documents', []): | |
| document.update(official_links({k: document[k] for k in ('source_url', 'original_source_url') if k in document})) | |
| sources = [] | |
| for number, chunk in enumerate(result['chunks'], 1): | |
| meta = dict(chunk.get('metadata', {})) | |
| doc_id = chunk.get('document_id') or meta.get('policy_document_id') | |
| local = None | |
| if result['policy_context'] and doc_id: | |
| pid = result['policy_context']['policy_id'] | |
| policy = policy_by_id(pid) | |
| doc = history_for(pid).documents.get(doc_id) | |
| if pdf_available(policy, doc or {}): | |
| local = f'/api/policies/{pid}/documents/{doc_id}/pdf' | |
| sources.append(dict(id=number, text=chunk['text'], metadata=meta, document_id=doc_id, pdf_url=local)) | |
| # Curated histories carry their own supersession review; the general collection does not. | |
| freshness = None | |
| if result['route'] == 'hybrid': | |
| freshness = currency.build_currency(result['chunks'], pool, request.question, corpus_as_of=corpus_date()) | |
| packet_id = uuid4().hex | |
| with _lock: | |
| now = time.monotonic() | |
| expired = [key for key, item in _packets.items() if now - item['created'] > 900] | |
| for key in expired: | |
| del _packets[key] | |
| if len(_packets) >= 50: | |
| del _packets[next(iter(_packets))] | |
| _packets[packet_id] = dict(created=now, owner=session, question=request.question, chunks=result['chunks'], context=result['policy_context'], currency=freshness) | |
| return dict(packet_id=packet_id, question=request.question, route=result['route'], sources=sources, | |
| policy_context=result['policy_context'], currency=freshness, as_of=request.as_of.isoformat()) | |
| def limiter(): | |
| import config | |
| from rate_limiter import SlidingWindowRateLimiter | |
| return SlidingWindowRateLimiter(config.LLM_RATE_LIMIT_MAX_REQUESTS, config.LLM_RATE_LIMIT_WINDOW_SECONDS) | |
| def answer(packet_id: str, request: AnswerRequest, http_request: Request, x_hf_token: str | None = Header(default=None)): | |
| from .workspace import owner | |
| session = owner(http_request.headers.get("x-workspace-session")) | |
| import config | |
| if request.language not in ('English', 'Hindi'): | |
| raise HTTPException(422, 'Choose English or Hindi.') | |
| with _lock: | |
| packet = _packets.get(packet_id) | |
| if packet is None or packet.get('owner') != session or time.monotonic() - packet['created'] > 900: | |
| raise HTTPException(410, 'Evidence expired. Search again before generating an answer.') | |
| if not packet['chunks']: | |
| raise HTTPException(422, 'No source evidence was found.') | |
| if not (x_hf_token or config.HF_TOKEN): | |
| raise HTTPException(503, 'AI generation is not configured. The source evidence remains available.') | |
| allowed, retry = (True, 0) if x_hf_token else limiter().try_acquire() | |
| if not allowed: | |
| raise HTTPException(429, 'Generation limit reached. Please retry shortly.', headers={'Retry-After': str(int(retry) + 1)}) | |
| from answer_generator import get_llm_answer, initialize_llm, describe_llm_error | |
| def events(): | |
| try: | |
| model = initialize_llm(hf_token=x_hf_token) if x_hf_token else initialize_llm() | |
| stream = get_llm_answer(packet['question'], packet['chunks'], model, stream=True, | |
| answer_language=request.language, policy_context=packet['context'], | |
| currency=packet.get('currency')) | |
| if isinstance(stream, str): | |
| stream = [stream] | |
| for chunk in stream: | |
| text = chunk.content if hasattr(chunk, 'content') else str(chunk) | |
| yield json.dumps({'text': text}, ensure_ascii=False) + '\n' | |
| yield json.dumps({'done': True}) + '\n' | |
| except Exception as error: | |
| yield json.dumps({'error': describe_llm_error(error)}) + '\n' | |
| return StreamingResponse(events(), media_type='application/x-ndjson', headers={'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no'}) | |
| def health(): | |
| return {'status': 'ok'} | |
| from .workspace import router as workspace_router | |
| app.include_router(workspace_router) | |
| class BodyLimit: | |
| def __init__(self, app): | |
| self.app = app | |
| async def __call__(self, scope, receive, send): | |
| if scope['type'] != 'http': | |
| return await self.app(scope, receive, send) | |
| maximum = 100 * 1024 * 1024 | |
| headers = dict(scope.get('headers', [])) | |
| try: | |
| length = int(headers.get(b'content-length', b'0')) | |
| except ValueError: | |
| length = 0 | |
| if length > maximum: | |
| from fastapi.responses import JSONResponse | |
| return await JSONResponse({'detail':'A request may contain at most 100 MB of files.'}, status_code=413)(scope, receive, send) | |
| total = 0 | |
| async def limited_receive(): | |
| nonlocal total | |
| message = await receive() | |
| total += len(message.get('body', b'')) | |
| if total > maximum: | |
| raise HTTPException(413, 'A request may contain at most 100 MB of files.') | |
| return message | |
| await self.app(scope, limited_receive, send) | |
| app.add_middleware(BodyLimit) | |
| DIST = ROOT / 'frontend/dist' | |
| if DIST.exists(): | |
| app.mount('/', StaticFiles(directory=DIST, html=True), name='frontend') | |