File size: 4,352 Bytes
bc7e936
 
 
 
f7b909f
bc7e936
e3e0bee
 
 
bc7e936
 
 
 
16ff626
a08364d
bc7e936
 
16ff626
bc7e936
 
 
 
 
 
 
 
 
 
 
 
 
 
f7b909f
bc7e936
 
 
 
 
 
 
 
 
 
16ff626
 
 
 
 
 
 
 
 
 
 
 
bc7e936
 
 
16ff626
 
 
bc7e936
 
 
 
a08364d
bc7e936
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Registry of retrieval models the UI can offer.

One place to declare every embedding model, what it can search, and whether this
deployment actually serves it. Adding or swapping a model is an edit to `MODELS`
plus its vectors and weights on disk — no change to the API surface or the frontend.

The registry includes known models that are not deployed. The catalog returns
their availability and reason, while the picker lists models that can serve the
selected search. Unsupported requests return an error.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path

from . import paths

CODE_CATEGORIES = ("diagnosis", "medication", "lab", "procedure")
PHENO_CATEGORY = ("phenotype",)


@dataclass(frozen=True)
class ModelSpec:
    id: str
    label: str
    categories: tuple[str, ...]
    # Prebuilt phenotype vectors for this model (None -> no phenotype support).
    pheno_emb_dir: Path | None = None
    # True when the packaged FAISS code indexes were built with this model.
    code_indexes: bool = False
    # False when the vectors and weights are known but this deployment cannot run it.
    served: bool = True
    unavailable_reason: str = ""

    def supports(self, category: str) -> bool:
        return category in self.categories

    def available(self) -> tuple[bool, str]:
        """(is_available, reason_if_not) — declaration plus what is on disk."""
        if not self.served:
            return False, self.unavailable_reason or "Not deployed on this backend."
        if self.pheno_emb_dir is not None:
            config_path = self.pheno_emb_dir / "config.json"
            if not config_path.exists():
                return False, f"Phenotype vectors missing ({self.pheno_emb_dir.name})."
            try:
                config = json.loads(config_path.read_text())
            except (OSError, json.JSONDecodeError) as err:
                return False, f"Invalid phenotype vector config: {err}"
            manifest_id = config.get("model_id")
            if manifest_id is not None and manifest_id != self.id:
                return False, (
                    f"Phenotype vectors identify model '{manifest_id}', not '{self.id}'.")
        return True, ""


# Overridable so a deployment can point at a different vector build without a
# code change (the ENCODE_PARTA_EMB_DIR escape hatch, applied in paths.py).
_FTVA_EMB = paths.PARTA_EMB_DIR

MODELS: tuple[ModelSpec, ...] = (
    ModelSpec(
        id="bge_ft_va",
        label="BGE-FT-VA",
        categories=PHENO_CATEGORY + CODE_CATEGORIES,
        pheno_emb_dir=_FTVA_EMB,
        code_indexes=True,
    ),
)

DEFAULT_MODEL_ID = MODELS[0].id

_BY_ID = {m.id: m for m in MODELS}


class ModelError(Exception):
    """Raised with an HTTP status when a requested model cannot serve a request."""

    def __init__(self, status: int, detail: str):
        super().__init__(detail)
        self.status = status
        self.detail = detail


def resolve(model_id: str | None, category: str) -> ModelSpec:
    """The model to serve `category` with, or ModelError explaining why not."""
    if not model_id:
        model_id = DEFAULT_MODEL_ID
    spec = _BY_ID.get(model_id)
    if spec is None:
        known = ", ".join(_BY_ID)
        raise ModelError(400, f"Unknown model '{model_id}'. Available: {known}")
    if not spec.supports(category):
        raise ModelError(400, f"Model '{spec.label}' does not support {category} search.")
    if category in CODE_CATEGORIES and not spec.code_indexes:
        raise ModelError(503, f"No code indexes were built with '{spec.label}'.")
    if category in PHENO_CATEGORY and spec.pheno_emb_dir is None:
        raise ModelError(503, f"No phenotype vectors were built with '{spec.label}'.")
    ok, reason = spec.available()
    if not ok:
        raise ModelError(503, f"Model '{spec.label}' is not available: {reason}")
    return spec


def catalog() -> dict:
    """What the model picker renders."""
    entries = []
    for spec in MODELS:
        ok, reason = spec.available()
        entries.append({
            "id": spec.id,
            "label": spec.label,
            "categories": list(spec.categories),
            "available": ok,
            "unavailable_reason": reason,
        })
    return {"default": DEFAULT_MODEL_ID, "models": entries}