faizath commited on
Commit
aaa807a
·
verified ·
1 Parent(s): bf0004f

feat(server): expose scoring over http

Browse files

Imputation draws a missing feature from a distribution that is 92.5
per cent legitimate, so a mostly empty row still yields a confident
legitimate verdict. A caller cannot tell that apart from a well evidenced
one unless the response says how much of the row was real.

The input is a feature row rather than a URL because extraction belongs
with the fetcher, in the application that already guards it.

Files changed (1) hide show
  1. server/app.py +194 -0
server/app.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP surface for a single model.
2
+
3
+ Scope, stated plainly because it is the difference between this and the full application:
4
+ this server scores a feature row that someone else extracted. It does not fetch pages, it
5
+ has no URL feature extractor, and it therefore has no abstention rule -- the caller who
6
+ produced the row owns the question of whether enough of it is real.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from contextlib import asynccontextmanager
15
+
16
+ from fastapi import FastAPI, HTTPException, Query
17
+ from pydantic import BaseModel, Field, field_validator
18
+
19
+ from phiusiil import __version__, schema
20
+ from server import load, predict
21
+
22
+ MODEL_DIR = Path(__file__).resolve().parent.parent / "model"
23
+
24
+ #: A batch large enough to amortise the fixed per-call preprocessing cost, small enough
25
+ #: that one request cannot monopolise the process. Preprocessing costs roughly the same
26
+ #: for 1 row as for 100; beyond about a thousand the distance kernel dominates instead.
27
+ MAX_BATCH = 1000
28
+
29
+ MODEL, STATS, MANIFEST = load.load_all(MODEL_DIR)
30
+
31
+ _BINARY_COLUMNS = frozenset(schema.CATEGORICAL_COLUMNS_FILTERED)
32
+ _FEATURE_NAMES = frozenset(schema.FEATURE_ORDER)
33
+
34
+ READY = {"value": False}
35
+
36
+
37
+ @asynccontextmanager
38
+ async def lifespan(_: FastAPI):
39
+ """Score the golden row once before accepting traffic.
40
+
41
+ Two jobs in one pass. It refuses to start if the artifact no longer reproduces the
42
+ prediction its training run recorded, and it pays the first-call cost up front --
43
+ scikit-learn's k-NN builds its neighbour index lazily, so the first real request
44
+ would otherwise absorb about a second of setup that has nothing to do with it.
45
+ """
46
+ from server.selftest import run_golden
47
+
48
+ failures = run_golden(MODEL_DIR)
49
+ if failures:
50
+ raise RuntimeError("artifact self-test failed: " + "; ".join(failures))
51
+ READY["value"] = True
52
+ yield
53
+
54
+
55
+ app = FastAPI(
56
+ title=MANIFEST["model_name"],
57
+ version=__version__,
58
+ lifespan=lifespan,
59
+ description=(
60
+ "Phishing URL classification from a pre-extracted 49-feature row. "
61
+ "Coursework reimplementation, not a security product."
62
+ ),
63
+ )
64
+
65
+
66
+ class Row(BaseModel):
67
+ features: dict[str, float | None] = Field(
68
+ ..., description="All 49 feature columns. null is allowed and will be imputed."
69
+ )
70
+ url: str | None = None
71
+ domain: str | None = None
72
+ tld: str | None = None
73
+ title: str | None = None
74
+
75
+ @field_validator("features")
76
+ @classmethod
77
+ def _known_and_well_formed(
78
+ cls, value: dict[str, float | None]
79
+ ) -> dict[str, float | None]:
80
+ unknown = sorted(set(value) - _FEATURE_NAMES)
81
+ if unknown:
82
+ raise ValueError(f"unknown feature columns: {unknown}")
83
+
84
+ # A fractional value in a binary column is accepted by every layer below and
85
+ # corrupts two of them silently: it misses the cascade's mode table, so the fill
86
+ # falls back to the global mode, and it is then truncated toward zero by the
87
+ # integer cast. Neither leaves a trace in the response.
88
+ bad = sorted(
89
+ name
90
+ for name, v in value.items()
91
+ if name in _BINARY_COLUMNS and v is not None and v not in (0, 1)
92
+ )
93
+ if bad:
94
+ raise ValueError(f"binary columns must be 0, 1 or null: {bad}")
95
+ return value
96
+
97
+ def as_record(self) -> dict[str, Any]:
98
+ record: dict[str, Any] = dict(self.features)
99
+ for name, value in (
100
+ ("URL", self.url),
101
+ ("Domain", self.domain),
102
+ ("TLD", self.tld),
103
+ ("Title", self.title),
104
+ ):
105
+ if value is not None:
106
+ record[name] = value
107
+ return record
108
+
109
+
110
+ class Batch(BaseModel):
111
+ rows: list[Row]
112
+
113
+
114
+ class Prediction(BaseModel):
115
+ model: str
116
+ label: int
117
+ verdict: str
118
+ phishing_score: float
119
+ n_provided: int
120
+ n_imputed: int
121
+ coverage_ratio: float
122
+ low_evidence: bool
123
+ vector: list[float] | None = None
124
+
125
+
126
+ def _predict(records: list[dict[str, Any]], debug: bool) -> list[Prediction]:
127
+ try:
128
+ frame = predict.build_frame(records)
129
+ labels, scores, matrix = predict.score(MODEL, STATS, frame)
130
+ except predict.InputError as exc:
131
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
132
+
133
+ return [
134
+ Prediction(
135
+ model=MANIFEST["model_key"],
136
+ label=int(label),
137
+ verdict=predict.verdict(label),
138
+ phishing_score=float(score_),
139
+ vector=[float(v) for v in matrix[i]] if debug else None,
140
+ **predict.evidence(records[i]),
141
+ )
142
+ for i, (label, score_) in enumerate(zip(labels, scores, strict=True))
143
+ ]
144
+
145
+
146
+ @app.get("/healthz")
147
+ def healthz() -> dict[str, str]:
148
+ """Liveness only. Deliberately does no scoring -- see /readyz for that."""
149
+ return {"status": "ok", "model": MANIFEST["model_key"]}
150
+
151
+
152
+ @app.get("/readyz")
153
+ def readyz() -> dict[str, Any]:
154
+ if not READY["value"]:
155
+ raise HTTPException(status_code=503, detail="self-test has not completed")
156
+ return {"status": "ready", "model": MANIFEST["model_key"]}
157
+
158
+
159
+ @app.get("/metadata")
160
+ def metadata() -> dict[str, Any]:
161
+ """Everything a caller needs to build a valid request, plus what this model scored."""
162
+ import json
163
+
164
+ return {
165
+ "model": {
166
+ "key": MANIFEST["model_key"],
167
+ "name": MANIFEST["model_name"],
168
+ "family": MANIFEST["family"],
169
+ "is_scratch": MANIFEST["is_scratch"],
170
+ "parameter_count": MANIFEST["parameter_count"],
171
+ },
172
+ "feature_order": list(schema.FEATURE_ORDER),
173
+ "demoted_features": MANIFEST["demoted_features"],
174
+ "positive_class": {"label": schema.PHISHING_LABEL, "meaning": "phishing"},
175
+ "metrics": json.loads((MODEL_DIR / "metrics.json").read_text("utf-8")),
176
+ "manifest": MANIFEST,
177
+ }
178
+
179
+
180
+ @app.post("/predict", response_model=Prediction)
181
+ def predict_one(row: Row, debug: bool = Query(False)) -> Prediction:
182
+ return _predict([row.as_record()], debug)[0]
183
+
184
+
185
+ @app.post("/predict/batch", response_model=list[Prediction])
186
+ def predict_batch(batch: Batch, debug: bool = Query(False)) -> list[Prediction]:
187
+ if not batch.rows:
188
+ raise HTTPException(status_code=422, detail="rows must not be empty")
189
+ if len(batch.rows) > MAX_BATCH:
190
+ raise HTTPException(
191
+ status_code=422,
192
+ detail=f"batch of {len(batch.rows)} exceeds the limit of {MAX_BATCH}",
193
+ )
194
+ return _predict([r.as_record() for r in batch.rows], debug)