File size: 15,856 Bytes
c0756c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c724b78
c0756c8
 
 
 
c724b78
76d2f37
c724b78
c0756c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c724b78
c0756c8
 
 
 
 
1ad509e
c0756c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c724b78
c0756c8
 
 
 
 
c724b78
c0756c8
 
c724b78
c0756c8
 
 
 
 
 
 
 
 
c724b78
c0756c8
c724b78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0756c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53d9d32
c0756c8
53d9d32
c0756c8
1ad509e
c0756c8
c724b78
 
c0756c8
 
4b94420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0756c8
1ad509e
c0756c8
 
 
1ad509e
 
 
 
c0756c8
c724b78
76d2f37
c0756c8
 
 
 
 
 
c724b78
 
 
 
 
 
 
 
 
 
76d2f37
c0756c8
c724b78
c0756c8
 
 
 
 
 
 
 
 
 
c724b78
 
 
 
 
 
 
 
c0756c8
 
c724b78
c0756c8
 
 
 
 
76d2f37
 
 
 
 
c0756c8
 
 
 
 
 
 
 
 
c724b78
c0756c8
 
c724b78
 
 
 
c0756c8
 
 
 
 
 
 
 
c724b78
c0756c8
c724b78
c0756c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c724b78
 
c0756c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
"""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__)
@asynccontextmanager
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


@lru_cache(maxsize=1)
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'))


@lru_cache(maxsize=16)
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


@app.get('/api/catalog')
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())


@lru_cache(maxsize=1)
def _division_catalog(mtime):
    return json.loads((ROOT / 'policy_data/division_catalog.json').read_text(encoding='utf-8'))


@app.get('/api/divisions')
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


@app.get('/api/policies/{id}')
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)


@app.get('/api/policies/{id}/documents/{document_id}/pdf')
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'})


@app.post('/api/search')
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())


@lru_cache(maxsize=1)
def limiter():
    import config
    from rate_limiter import SlidingWindowRateLimiter
    return SlidingWindowRateLimiter(config.LLM_RATE_LIMIT_MAX_REQUESTS, config.LLM_RATE_LIMIT_WINDOW_SECONDS)


@app.post('/api/answers/{packet_id}')
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'})


@app.get('/api/health')
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')