File size: 2,968 Bytes
e1679fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The coach as a web endpoint on Modal (CPU, scales to zero, so idle costs nothing).

    MODAL_PROFILE=labs3 modal deploy scripts/serve.py     # prints the URL; the page POSTs {"text": ...} to <url>/read
    MODAL_PROFILE=labs3 modal serve scripts/serve.py      # hot-reloading dev copy

Response: {"scores": {...six 1-5...}, "scores_raw": {...}, "parts": [{start,end,type,effectiveness,eff_probs,conf,text}],
"notes": [{kind,text,...}], "band": {"value": 6.5, "note": "..."} or null, "ms": 1234}.
"""
import modal

app = modal.App("essay-coach")
vol = modal.Volume.from_name("essay-data")
cache = modal.Volume.from_name("essay-hf-cache", create_if_missing=True)
image = (modal.Image.debian_slim(python_version="3.11")
         .pip_install("torch==2.4.1", index_url="https://download.pytorch.org/whl/cpu")
         .pip_install("transformers==4.46.3", "sentencepiece", "protobuf", "tiktoken", "safetensors", "pandas", "numpy<2.3",
                      "fastapi[standard]")
         .add_local_python_source("coach"))
SCORES, PARTS, BAND = "scores-v1", "parts-v1", "band-v1"


@app.cls(image=image, volumes={"/data": vol, "/root/.cache/huggingface": cache}, cpu=4, memory=8192,
         scaledown_window=300, timeout=120)
@modal.concurrent(max_inputs=4)
class Server:
    @modal.enter()
    def load(self):
        import json
        import os

        import torch

        from coach.infer import Coach
        torch.set_num_threads(4)
        self.coach = Coach(f"/data/runs/{SCORES}", f"/data/runs/{PARTS}", device="cpu")
        p = f"/data/runs/{BAND}/band.json"
        self.band = json.load(open(p)) if os.path.exists(p) else None

    def band_of(self, read, text, task2=True):
        import numpy as np

        from coach.data import TRAITS, TYPES
        if not self.band:
            return None
        ps = read["parts"]
        f = [read["scores_raw"][t] for t in TRAITS] + [np.log1p(len(text.split()))] + [sum(p["type"] == t for p in ps) for t in TYPES]
        f += [np.mean([p["effectiveness"] == "Effective" for p in ps]) if ps else 0.0, float(task2)]
        x = (np.array(f) - np.array(self.band["mean"])) / np.array(self.band["std"])
        v = float(x @ np.array(self.band["coef"]) + self.band["intercept"])
        cv = self.band["cv"]
        return {"value": float(np.clip(round(v * 2) / 2, 1, 9)), "raw": v,
                "note": f"An estimate, not a grade: on {cv['n']} human-banded essays it lands within half a band {cv['within_half']*100:.0f}% of the time."}

    @modal.fastapi_endpoint(method="POST", docs=True)
    def read(self, body: dict):
        import time
        t0 = time.time()
        text = str(body.get("text", ""))[:12000]
        if len(text.split()) < 20:
            return {"error": "Paste at least a few sentences."}
        out = self.coach.read(text)
        out["band"] = self.band_of(out, text, task2=bool(body.get("task2", True)))
        out["ms"] = int((time.time() - t0) * 1000)
        return out