File size: 6,475 Bytes
4f740eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf2993f
4f740eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Curate a corpus: sample previews of the clean, calibrate, create, compose workflow, plus the recipe to run it in full."""
from __future__ import annotations

import json
import os
import threading
import time
import uuid

from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel

from datainventor.clean import BENCHMARKS, DEFAULTS as CLEAN_RULES
from datainventor.create import DOC_TYPES, STYLES, chunks, rephrase, styles_for
from datainventor.llm import EndpointError
from datainventor.preview import Preview, run_preview
from datainventor.recipe import DEFAULT, dump, validate
from datainventor.sources import KINDS, inspect

router = APIRouter(prefix="/api/curate")
PER_SOURCE = int(os.environ.get("CURATE_PER_SOURCE", "200"))
GRADE_N = int(os.environ.get("CURATE_GRADE", "48"))
REPHRASE_N = int(os.environ.get("CURATE_REPHRASE", "8"))
MAX_SOURCES, MAX_TARGETS = 4, 2
INSTALL = "pip install 'datainventor[full] @ https://huggingface.co/spaces/codelion/data-inventor/resolve/main/dist/datainventor-0.2.2-py3-none-any.whl'"
PREVIEWS: dict[str, Preview] = {}
TRIES: dict[str, list] = {}
GUARD = threading.Lock()


def _ep():
    from main import make_endpoint  # the host's endpoint; visitors never supply one
    return make_endpoint()


def _token() -> str:
    return os.environ.get("HF_TOKEN", "")


@router.get("/defaults")
def defaults():
    from main import MAX_ROWS
    return {"recipe": DEFAULT, "benchmarks": list(BENCHMARKS), "kinds": list(KINDS), "clean_rules": CLEAN_RULES,
            "styles": {k: v[0] for k, v in STYLES.items()}, "styles_by_kind": {k: styles_for(k) for k in KINDS},
            "doc_types": list(DOC_TYPES), "per_source": PER_SOURCE, "max_sources": MAX_SOURCES, "max_targets": MAX_TARGETS,
            "max_docs": MAX_ROWS, "install": INSTALL}


@router.get("/inspect")
def do_inspect(dataset: str):
    ds = dataset.strip().removeprefix("https://huggingface.co/datasets/").strip("/")
    if not ds or ds.count("/") != 1 or len(ds) > 120:
        raise HTTPException(400, "Use a Hugging Face dataset id like HuggingFaceFW/fineweb-edu.")
    try:
        return inspect(ds, _token())
    except ValueError as e:
        raise HTTPException(404, str(e))
    except Exception:
        raise HTTPException(404, "The dataset viewer could not open that dataset. It must be public and have the viewer enabled.")


class RecipeIn(BaseModel):
    recipe: dict


def _checked(rec: dict) -> dict:
    for s in rec.get("sources", []) + rec.get("targets", {}).get("datasets", []):
        if s.get("files"):
            raise HTTPException(400, "Local files only work in the runner on your own machines. Use Hugging Face datasets here.")
    if len(rec.get("sources", [])) > MAX_SOURCES or len(rec.get("targets", {}).get("datasets", [])) > MAX_TARGETS:
        raise HTTPException(400, f"The hosted preview takes up to {MAX_SOURCES} sources and {MAX_TARGETS} target datasets.")
    try:
        return validate(rec)
    except ValueError as e:
        raise HTTPException(400, str(e))


@router.post("/recipe")
def recipe_file(body: RecipeIn):
    """The recipe as YAML, for the runner."""
    r = _checked(body.recipe)
    return Response(dump(r), media_type="application/x-yaml", headers={"Content-Disposition": 'attachment; filename="recipe.yaml"'})


@router.post("/previews")
def start(body: RecipeIn):
    r = _checked(body.recipe)
    ep = _ep()
    with GUARD:
        running = sum(1 for p in PREVIEWS.values() if p.state in ("queued", "running"))
        if running >= 2:
            raise HTTPException(429, "Two previews are already running. Wait for one to finish.")
        pid = uuid.uuid4().hex[:10]
        p = Preview(r)
        PREVIEWS[pid] = p
        for old in sorted(PREVIEWS, key=lambda k: PREVIEWS[k].started)[:-20]:
            PREVIEWS.pop(old, None)
    from main import MAX_ROWS
    threading.Thread(target=run_preview, args=(p, ep, _token()),
                     kwargs={"per_source": PER_SOURCE, "grade_n": GRADE_N, "rephrase_n": REPHRASE_N, "invent_n": 4, "max_docs": MAX_ROWS},
                     daemon=True).start()
    return {"id": pid}


def _get(pid: str) -> Preview:
    if pid not in PREVIEWS:
        raise HTTPException(404, "That preview is no longer here. Run it again.")
    return PREVIEWS[pid]


@router.get("/previews/{pid}")
def status(pid: str):
    p = _get(pid)
    out = {"id": pid, "state": p.state, "stage": p.stage, "message": p.message, "elapsed": round((p.finished or time.time()) - p.started, 1)}
    if p.state in ("done", "stopped"):
        out["report"] = p.report
    return out


@router.post("/previews/{pid}/stop")
def stop(pid: str):
    _get(pid).stop.set()
    return {"ok": True}


@router.get("/previews/{pid}/download")
def download(pid: str):
    p = _get(pid)
    if not p.docs:
        raise HTTPException(404, "There are no documents to download yet.")
    body = "\n".join(json.dumps(d, ensure_ascii=False) for d in p.docs) + "\n"
    return Response(body, media_type="application/jsonl", headers={"Content-Disposition": f'attachment; filename="curated-sample-{pid}.jsonl"'})


class TryIn(BaseModel):
    text: str
    kind: str = "web"
    styles: list[str] = []


@router.post("/try")
def try_styles(body: TryIn):
    """Rewrite one document in up to four styles, side by side."""
    text = body.text.strip()
    if len(text.split()) < 60:
        raise HTTPException(400, "Paste or pick a document of at least 60 words.")
    styles = [s for s in (body.styles or styles_for(body.kind)) if s in STYLES][:4]
    if not styles:
        raise HTTPException(400, "Choose at least one style.")
    now = time.time()
    with GUARD:
        recent = [t for t in TRIES.get("all", []) if now - t < 60]
        if len(recent) >= 6:
            raise HTTPException(429, "Too many rewrites in the last minute. Try again shortly.")
        TRIES["all"] = recent + [now]
    ep = _ep()
    piece = chunks(text)[0]
    from concurrent.futures import ThreadPoolExecutor
    try:
        with ThreadPoolExecutor(len(styles)) as ex:
            outs = list(ex.map(lambda s: rephrase(ep, piece, s), styles))
    except EndpointError as e:
        raise HTTPException(502, str(e))
    return {"source": piece, "results": [{"style": o["style"], "name": STYLES[o["style"]][0], "text": o["text"], "ok": o["ok"],
                                          "why": o.get("why", ""), "fidelity": o.get("fidelity")} for o in outs]}