File size: 9,340 Bytes
1916d8e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8754770
 
 
1916d8e
 
 
8754770
 
1916d8e
 
 
 
 
 
8754770
1916d8e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8754770
 
1916d8e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8754770
1916d8e
8754770
1916d8e
 
 
 
 
8754770
1916d8e
8754770
1916d8e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""

khazna_loader.py β€” Space-side loading of the Khazna index.



The harvest runs in GitHub Actions (nightly, 23:00 Dubai) and publishes

khazna_index.json to an HF Dataset repo. The Space downloads it β€” it never

harvests, because HF Spaces have an ephemeral filesystem and a 20-minute

harvest would block startup.



Behaviour:

    * startup      -> download once, load into memory (non-blocking)

    * daily 23:30  -> re-download (a few seconds)

    * unavailable  -> keep the last valid snapshot, or say "not checked" when
                      none is loaded; a load time is not a source harvest date.


Add to requirements.txt:

    huggingface_hub

    apscheduler

"""

from __future__ import annotations

import json
import logging
import os
import re
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from urllib.parse import urlsplit

logger = logging.getLogger("khazna")

HF_DATASET = os.getenv("KHAZNA_DATASET", "nikeshn/researchbee-data")
INDEX_FILE = "khazna_index.json"
LOCAL_PATH = Path("/tmp/khazna_index.json")
_DOI_RE = re.compile(r"^10\.\d{4,9}/\S+$", re.ASCII)
_DEPOSIT_STATES = {"open", "embargoed", "restricted", "closed", "metadata_only", "undetermined"}


def _normalise_doi(doi: str) -> str:
    d = doi.strip().lower()
    for prefix in ("https://doi.org/", "http://doi.org/", "https://dx.doi.org/", "http://dx.doi.org/", "doi:"):
        if d.startswith(prefix):
            d = d[len(prefix):]
            break
    return d.strip().strip("/")


def _validate_snapshot(data: object) -> dict[str, dict]:
    """Validate the existing flat source format before replacing a good index."""
    if not isinstance(data, dict) or not data:
        raise ValueError("empty_or_invalid_map")
    validated: dict[str, dict] = {}
    for key, value in data.items():
        if not isinstance(key, str) or not isinstance(value, dict):
            raise ValueError("invalid_record")
        doi = _normalise_doi(key)
        if not _DOI_RE.fullmatch(doi) or doi in validated:
            raise ValueError("invalid_or_duplicate_doi")
        record = dict(value)
        for field in ("deposit_state", "embargo_end", "portal_url"):
            if record.get(field) is not None and not isinstance(record[field], str):
                raise ValueError("invalid_record_field")
        state = (record.get("deposit_state") or "undetermined").strip().lower()
        record["deposit_state"] = state if state in _DEPOSIT_STATES else "undetermined"
        portal = (record.get("portal_url") or "").strip()
        if portal:
            parsed = urlsplit(portal)
            if parsed.scheme not in {"https", "http"} or not parsed.hostname or parsed.username or parsed.password:
                raise ValueError("invalid_portal_url")
        record["portal_url"] = portal
        validated[doi] = record
    return validated


class KhaznaIndex:
    def __init__(self) -> None:
        self._idx: dict[str, dict] = {}
        self._loaded_at: Optional[str] = None
        self._last_reload_at: Optional[str] = None
        self._last_reload_success: Optional[bool] = None
        self._last_reload_status = "not_attempted"
        self._load_source: Optional[str] = None
        self._download_origin = "unknown"
        self._reload_lock = threading.Lock()

    # ── loading ────────────────────────────────────────────────────────────
    def _download(self) -> Optional[Path]:
        try:
            from huggingface_hub import hf_hub_download
            p = hf_hub_download(
                repo_id=HF_DATASET,
                filename=INDEX_FILE,
                repo_type="dataset",
                token=os.getenv("HF_TOKEN"),   # omit if the dataset is public
            )
            self._download_origin = "huggingface_download_or_cache"
            return Path(p)
        except Exception:
            # SDK/network exception messages can contain tokens, signed URLs
            # or internal paths. Keep logs and public status categorical.
            logger.warning("Khazna index download unavailable; trying local fallback")
            self._download_origin = "local_fallback"
            return LOCAL_PATH if LOCAL_PATH.exists() else None

    def reload(self) -> bool:
        with self._reload_lock:
            self._last_reload_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
            self._last_reload_success = False
            self._download_origin = "unknown"
            try:
                path = self._download()
                if not path or not path.exists():
                    self._last_reload_status = "unavailable"
                    logger.warning("Khazna index unavailable; retaining any previously loaded snapshot")
                    return False
                data = json.loads(path.read_text(encoding="utf-8"))
                validated = _validate_snapshot(data)
            except (ValueError, UnicodeError):
                self._last_reload_status = "invalid_snapshot"
                logger.error("Khazna index invalid; retaining any previously loaded snapshot")
                return False
            except Exception:
                self._last_reload_status = "load_error"
                logger.error("Khazna index could not be loaded; retaining any previously loaded snapshot")
                return False
            self._idx = validated
            self._loaded_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
            self._last_reload_success = True
            self._last_reload_status = "loaded"
            self._load_source = self._download_origin
            logger.info("Khazna index loaded: %d DOIs; source harvest date unknown", len(self._idx))
            return True

    # ── lookup ─────────────────────────────────────────────────────────────
    def get(self, doi: str) -> Optional[dict]:
        if not isinstance(doi, str):
            return None
        return self._idx.get(_normalise_doi(doi))

    @property
    def available(self) -> bool:
        return bool(self._idx)

    @property
    def status(self) -> dict:
        return {"available": self.available,
                "records": len(self._idx),
                "loaded_at": self._loaded_at,
                "source": HF_DATASET,
                "last_reload_at": self._last_reload_at,
                "last_reload_success": self._last_reload_success,
                "last_reload_status": self._last_reload_status,
                "load_source": self._load_source,
                "retaining_previous_snapshot": self.available and self._last_reload_success is False,
                # The existing DOI map contains no harvest timestamp. Neither
                # loading a cache nor downloading establishes source freshness.
                "source_harvested_at": None,
                "snapshot_freshness": "unknown",
                "freshness_note": "Source harvest date is not supplied. loaded_at records loading only; an absent DOI is not proof of current repository absence."}


khazna_index = KhaznaIndex()


# ── FastAPI wiring (app.py) ────────────────────────────────────────────────
#
# from khazna_loader import khazna_index
# from apscheduler.schedulers.asyncio import AsyncIOScheduler
# from apscheduler.triggers.cron import CronTrigger
#
# @app.on_event("startup")
# async def _startup():
#     # Non-blocking: the Space serves traffic immediately; Khazna lookups
#     # report "not checked" for the few seconds before the index lands.
#     asyncio.create_task(asyncio.to_thread(khazna_index.reload))
#
#     sched = AsyncIOScheduler(timezone="Asia/Dubai")
#     # AsyncIOScheduler runs this synchronous job in its executor; do not call
#     # asyncio.create_task from a scheduler worker with no running event loop.
#     sched.add_job(khazna_index.reload,
#                   CronTrigger(hour=23, minute=30, timezone="Asia/Dubai"))
#     sched.start()
#
# @app.get("/api/health/khazna")
# async def khazna_health():
#     return khazna_index.status
#
#
# ── Using it in the license flow ───────────────────────────────────────────
#
# def khazna_status_for(doi: str) -> dict:
#     """Three-valued, never guesses absence when the index is missing."""
#     if not khazna_index.available:
#         return {"checked": False,
#                 "note": "Khazna could not be checked right now."}
#     rec = khazna_index.get(doi)
#     if rec is None:
#         return {"checked": True, "in_khazna": False}
#     return {"checked": True, "in_khazna": True,
#             "deposit_state": rec["deposit_state"],       # open|embargoed|...|metadata_only
#             "embargo_end":   rec.get("embargo_end"),
#             "portal_url":    rec.get("portal_url"),
#             "needs_deposit": rec["deposit_state"] in ("metadata_only", "closed")}