github-actions[bot] commited on
Commit
d1acd5b
Β·
0 Parent(s):

deploy from 11e6d598

Browse files
Files changed (5) hide show
  1. Dockerfile +55 -0
  2. README.md +35 -0
  3. app.py +251 -0
  4. requirements.txt +3 -0
  5. start.sh +44 -0
Dockerfile ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM ubuntu:22.04 AS build
2
+ WORKDIR /src
3
+
4
+ RUN printf 'Acquire::Retries "5";\nAcquire::http::Timeout "30";\nAcquire::https::Timeout "30";\n' \
5
+ > /etc/apt/apt.conf.d/80-retries && \
6
+ apt-get update && \
7
+ apt-get install -y --fix-missing build-essential cmake git curl ninja-build && \
8
+ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
9
+
10
+ ARG CRISPEMBED_REPO=https://github.com/CrispStrobe/CrispEmbed
11
+ ARG CRISPEMBED_REF=main
12
+
13
+ RUN git clone --depth 1 --recurse-submodules "${CRISPEMBED_REPO}" /src/CrispEmbed && \
14
+ cd /src/CrispEmbed && \
15
+ git fetch --depth 1 origin "${CRISPEMBED_REF}" && \
16
+ git checkout --detach FETCH_HEAD && \
17
+ git submodule update --init --recursive
18
+ WORKDIR /src/CrispEmbed
19
+ ARG CRISPEMBED_BUILD_JOBS
20
+ RUN jobs="${CRISPEMBED_BUILD_JOBS:-$(nproc)}" && \
21
+ cmake -S . -B build -G Ninja \
22
+ -DCMAKE_BUILD_TYPE=Release \
23
+ -DCRISPEMBED_BUILD_SHARED=ON && \
24
+ cmake --build build -j"${jobs}" --target crispembed-shared crispembed-server crispembed-cli
25
+
26
+ FROM ubuntu:22.04 AS runtime
27
+ WORKDIR /space
28
+
29
+ RUN printf 'Acquire::Retries "5";\nAcquire::http::Timeout "30";\nAcquire::https::Timeout "30";\n' \
30
+ > /etc/apt/apt.conf.d/80-retries && \
31
+ apt-get update && \
32
+ apt-get install -y --fix-missing bash curl python3 python3-pip && \
33
+ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
34
+
35
+ COPY --from=build /src/CrispEmbed/build /opt/crispembed/build
36
+ # In HF Space repo, files are at repo root (not hf-space/ subdirectory)
37
+ COPY requirements.txt /space/requirements.txt
38
+ RUN pip3 install --no-cache-dir -r /space/requirements.txt
39
+ RUN find /opt/crispembed/build -type f -name 'lib*.so*' -exec cp -a {} /usr/local/lib/ \; && ldconfig
40
+
41
+ COPY app.py start.sh /space/
42
+ RUN chmod +x /space/start.sh && \
43
+ useradd -m -u 1000 app && \
44
+ mkdir -p /models /cache && \
45
+ chown -R app:app /space /opt/crispembed /models /cache
46
+
47
+ ENV PATH=/opt/crispembed/build:$PATH
48
+ ENV CRISPEMBED_CACHE_DIR=/cache
49
+ ENV CRISPEMBED_SERVER_URL=http://127.0.0.1:8090
50
+ ENV GRADIO_SERVER_NAME=0.0.0.0
51
+ ENV GRADIO_SERVER_PORT=7860
52
+
53
+ USER app
54
+ EXPOSE 7860
55
+ ENTRYPOINT ["/space/start.sh"]
README.md ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: CrispEmbed
3
+ sdk: docker
4
+ app_port: 7860
5
+ pinned: false
6
+ ---
7
+
8
+ # CrispEmbed β€” Text Embedding & Math OCR Demo
9
+
10
+ Lightweight embedding inference via ggml. No Python runtime, no ONNX.
11
+
12
+ This Space demonstrates four things:
13
+
14
+ **Similarity**: cosine similarity between two texts.
15
+
16
+ **Semantic Search**: rank a small corpus against a query.
17
+
18
+ **Math OCR**: math image β†’ LaTeX.
19
+
20
+ **Batch Embed**: OpenAI-compatible `/v1/embeddings` batch endpoint.
21
+
22
+ This Space processes **text and math images only**. It does no face detection,
23
+ no face recognition, and no biometric processing of any kind. An image uploaded
24
+ to the Math OCR tab is written to a temporary file, transcribed, and deleted by
25
+ the application immediately afterwards; it is not stored or logged by this app.
26
+ (The Gradio/Hugging Face platform layer may cache uploads independently of the
27
+ application.)
28
+
29
+ The upstream engine additionally supports image/face embeddings, full-page OCR,
30
+ layout analysis and NER; those are not exposed here. See
31
+ [POLICY.md](https://github.com/CrispStrobe/CrispEmbed/blob/main/POLICY.md) for
32
+ intended purpose and acceptable use.
33
+
34
+ Powered by the [CrispEmbed](https://github.com/CrispStrobe/CrispEmbed)
35
+ C++ engine. Models auto-download on first use.
app.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CrispEmbed Gradio Space β€” text embeddings, math OCR, semantic search, and more.
2
+
3
+ Wraps the CrispEmbed C++ HTTP server (running on :8090) with a Gradio UI
4
+ served on :7860 (the only port HF Spaces exposes).
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import tempfile
10
+ import traceback
11
+
12
+ import gradio as gr
13
+ import numpy as np
14
+ import requests
15
+
16
+ SERVER_URL = os.environ.get("CRISPEMBED_SERVER_URL", "http://127.0.0.1:8090")
17
+
18
+
19
+ def _post(endpoint: str, payload: dict, timeout: int = 120) -> dict:
20
+ try:
21
+ r = requests.post(f"{SERVER_URL}{endpoint}", json=payload, timeout=timeout)
22
+ r.raise_for_status()
23
+ return r.json()
24
+ except Exception as e:
25
+ return {"error": str(e)}
26
+
27
+
28
+ def _get(endpoint: str) -> dict:
29
+ try:
30
+ r = requests.get(f"{SERVER_URL}{endpoint}", timeout=10)
31
+ r.raise_for_status()
32
+ return r.json()
33
+ except Exception as e:
34
+ return {"error": str(e)}
35
+
36
+
37
+ def cosine_sim(a, b):
38
+ a, b = np.array(a, dtype=np.float32), np.array(b, dtype=np.float32)
39
+ return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
40
+
41
+
42
+ # ─── Text Embedding + Similarity ───────────────────────────────────────────
43
+
44
+ def embed_texts(text_a: str, text_b: str) -> str:
45
+ if not text_a.strip():
46
+ return "Please enter at least one text."
47
+ texts = [text_a.strip()]
48
+ if text_b.strip():
49
+ texts.append(text_b.strip())
50
+
51
+ result = _post("/embed", {"texts": texts})
52
+ if "error" in result:
53
+ return f"Error: {result['error']}"
54
+
55
+ embeddings = result.get("embeddings", [])
56
+ dim = result.get("dim", 0)
57
+ lines = [f"Dimension: {dim}"]
58
+
59
+ for i, emb in enumerate(embeddings):
60
+ preview = ", ".join(f"{v:.4f}" for v in emb[:8])
61
+ lines.append(f"Text {i+1}: [{preview}, ...] (norm={np.linalg.norm(emb):.4f})")
62
+
63
+ if len(embeddings) == 2:
64
+ sim = cosine_sim(embeddings[0], embeddings[1])
65
+ lines.append(f"\nCosine similarity: {sim:.6f}")
66
+
67
+ return "\n".join(lines)
68
+
69
+
70
+ # ─── Semantic Search ────────────────────────────────────────────────────────
71
+
72
+ def semantic_search(query: str, corpus: str, top_k: int) -> str:
73
+ if not query.strip() or not corpus.strip():
74
+ return "Please enter a query and a corpus (one sentence per line)."
75
+
76
+ docs = [line.strip() for line in corpus.strip().split("\n") if line.strip()]
77
+ if not docs:
78
+ return "Corpus is empty."
79
+
80
+ all_texts = [query.strip()] + docs
81
+ result = _post("/embed", {"texts": all_texts})
82
+ if "error" in result:
83
+ return f"Error: {result['error']}"
84
+
85
+ embeddings = result.get("embeddings", [])
86
+ if len(embeddings) < 2:
87
+ return "Error: not enough embeddings returned."
88
+
89
+ query_emb = embeddings[0]
90
+ scores = []
91
+ for i, doc_emb in enumerate(embeddings[1:]):
92
+ scores.append((cosine_sim(query_emb, doc_emb), docs[i]))
93
+
94
+ scores.sort(key=lambda x: -x[0])
95
+ lines = [f"Query: {query.strip()}", f"Results (top {min(top_k, len(scores))}):", ""]
96
+ for rank, (score, doc) in enumerate(scores[:top_k], 1):
97
+ lines.append(f" {rank}. [{score:.4f}] {doc}")
98
+
99
+ return "\n".join(lines)
100
+
101
+
102
+ # ─── Math OCR ──────────────────────────────────────────────────────────────
103
+
104
+ def math_ocr(image) -> str:
105
+ if image is None:
106
+ return "Please upload or capture a math equation image."
107
+
108
+ try:
109
+ import PIL.Image
110
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False, dir="/tmp") as f:
111
+ if isinstance(image, np.ndarray):
112
+ PIL.Image.fromarray(image).save(f.name)
113
+ elif hasattr(image, "save"):
114
+ image.save(f.name)
115
+ else:
116
+ return f"Error: unexpected image type: {type(image)}"
117
+ tmp_path = f.name
118
+
119
+ result = _post("/math/ocr", {"image": tmp_path})
120
+
121
+ try:
122
+ os.unlink(tmp_path)
123
+ except OSError:
124
+ pass
125
+
126
+ if "error" in result:
127
+ return f"Error: {result['error']}"
128
+
129
+ latex = result.get("latex", "")
130
+ ms = result.get("ms", 0)
131
+ return f"LaTeX: {latex}\n\nInference time: {ms} ms"
132
+ except Exception as e:
133
+ return f"Error: {traceback.format_exc()}"
134
+
135
+
136
+ # ─── Batch Embed (OpenAI-compatible) ───────────────────────────────────────
137
+
138
+ def batch_embed(texts_raw: str, model_name: str) -> str:
139
+ if not texts_raw.strip():
140
+ return "Please enter texts (one per line)."
141
+
142
+ texts = [line.strip() for line in texts_raw.strip().split("\n") if line.strip()]
143
+ result = _post("/v1/embeddings", {"input": texts, "model": model_name or "default"})
144
+ if "error" in result:
145
+ return f"Error: {result['error']}"
146
+
147
+ data = result.get("data", [])
148
+ lines = [f"Model: {result.get('model', '?')}", f"Embeddings: {len(data)}", ""]
149
+ for item in data:
150
+ emb = item.get("embedding", [])
151
+ preview = ", ".join(f"{v:.4f}" for v in emb[:6])
152
+ lines.append(f" [{item.get('index', '?')}] dim={len(emb)}: [{preview}, ...]")
153
+
154
+ usage = result.get("usage", {})
155
+ if usage:
156
+ lines.append(f"\nTokens: {usage.get('total_tokens', '?')}")
157
+
158
+ return "\n".join(lines)
159
+
160
+
161
+ # ─── Health ────────────────────────────────────────────────────────────────
162
+
163
+ def health_check() -> str:
164
+ try:
165
+ r = requests.get(f"{SERVER_URL}/health", timeout=10)
166
+ return json.dumps(r.json(), indent=2)
167
+ except Exception as e:
168
+ return f"Error: {e}"
169
+
170
+
171
+ # ─── Build UI ──────────────────────────────────────────────────────────────
172
+
173
+ with gr.Blocks(title="CrispEmbed", theme=gr.themes.Soft()) as demo:
174
+ gr.Markdown("# CrispEmbed β€” Text Embedding, Semantic Search & Math OCR")
175
+ gr.Markdown(
176
+ "Powered by [CrispEmbed](https://github.com/CrispStrobe/CrispEmbed) "
177
+ "β€” lightweight embedding inference via ggml. "
178
+ "58 models, 10 architectures, no Python runtime."
179
+ )
180
+
181
+ with gr.Tab("Similarity"):
182
+ gr.Markdown("Enter two texts to compute cosine similarity.")
183
+ text_a = gr.Textbox(label="Text A", placeholder="The quick brown fox...")
184
+ text_b = gr.Textbox(label="Text B",
185
+ placeholder="A fast auburn canine...")
186
+ embed_btn = gr.Button("Compare", variant="primary")
187
+ embed_out = gr.Textbox(label="Result", lines=8)
188
+ embed_btn.click(embed_texts, inputs=[text_a, text_b], outputs=embed_out)
189
+
190
+ gr.Examples(
191
+ examples=[
192
+ ["The weather is lovely today.", "It's a beautiful day outside."],
193
+ ["Machine learning is a branch of AI.",
194
+ "Cooking is a culinary art."],
195
+ ["The cat sat on the mat.", "Dogs are loyal companions."],
196
+ ],
197
+ inputs=[text_a, text_b],
198
+ )
199
+
200
+ with gr.Tab("Semantic Search"):
201
+ gr.Markdown("Enter a query and a corpus (one sentence per line). "
202
+ "Returns the most similar sentences ranked by cosine similarity.")
203
+ search_query = gr.Textbox(label="Query",
204
+ placeholder="renewable energy sources")
205
+ search_corpus = gr.Textbox(
206
+ label="Corpus (one sentence per line)", lines=8,
207
+ value="Solar panels convert sunlight into electricity.\n"
208
+ "Wind turbines generate power from moving air.\n"
209
+ "Coal is a fossil fuel used in power plants.\n"
210
+ "Electric vehicles reduce carbon emissions.\n"
211
+ "The stock market fluctuated today.\n"
212
+ "Photosynthesis is how plants make food.\n"
213
+ "Nuclear fusion could provide limitless energy.\n"
214
+ "The recipe calls for two cups of flour.",
215
+ )
216
+ search_k = gr.Slider(1, 20, value=5, step=1, label="Top K")
217
+ search_btn = gr.Button("Search", variant="primary")
218
+ search_out = gr.Textbox(label="Results", lines=10)
219
+ search_btn.click(semantic_search,
220
+ inputs=[search_query, search_corpus, search_k],
221
+ outputs=search_out)
222
+
223
+ with gr.Tab("Math OCR"):
224
+ gr.Markdown("Upload an image of a math equation. "
225
+ "Returns LaTeX via on-device neural OCR (HMER).")
226
+ image_in = gr.Image(label="Math equation image", type="numpy")
227
+ ocr_btn = gr.Button("Recognize", variant="primary")
228
+ ocr_out = gr.Textbox(label="Result", lines=4)
229
+ ocr_btn.click(math_ocr, inputs=image_in, outputs=ocr_out)
230
+
231
+ with gr.Tab("Batch Embed (OpenAI API)"):
232
+ gr.Markdown("Batch-embed texts via the OpenAI-compatible `/v1/embeddings` endpoint. "
233
+ "One text per line.")
234
+ batch_texts = gr.Textbox(label="Texts (one per line)", lines=6,
235
+ placeholder="Hello world\nGoodbye world")
236
+ batch_model = gr.Textbox(label="Model name (optional)",
237
+ value="all-MiniLM-L6-v2")
238
+ batch_btn = gr.Button("Embed Batch", variant="primary")
239
+ batch_out = gr.Textbox(label="Result", lines=10)
240
+ batch_btn.click(batch_embed, inputs=[batch_texts, batch_model],
241
+ outputs=batch_out)
242
+
243
+ with gr.Tab("Health"):
244
+ gr.Markdown("Server status and loaded model info.")
245
+ health_btn = gr.Button("Check Health")
246
+ health_out = gr.Textbox(label="Server response", lines=10)
247
+ health_btn.click(health_check, outputs=health_out)
248
+
249
+
250
+ if __name__ == "__main__":
251
+ demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio>=5,<6
2
+ requests>=2.32
3
+ numpy>=1.24
start.sh ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Start the CrispEmbed C++ server in the background, then launch Gradio.
3
+
4
+ set -e
5
+
6
+ MODEL="${CRISPEMBED_MODEL:-all-MiniLM-L6-v2}"
7
+ OCR_MODEL="${CRISPEMBED_OCR_MODEL:-hmer-hw}"
8
+ SERVER_HOST="${CRISPEMBED_SERVER_HOST:-127.0.0.1}"
9
+ SERVER_PORT="${CRISPEMBED_SERVER_PORT:-8090}"
10
+ N_THREADS="${CRISPEMBED_THREADS:-4}"
11
+
12
+ echo "=== CrispEmbed Space ==="
13
+ echo " Model: $MODEL"
14
+ echo " OCR model: $OCR_MODEL"
15
+ echo " Server: $SERVER_HOST:$SERVER_PORT"
16
+ echo " Threads: $N_THREADS"
17
+
18
+ # Auto-download and start the embedding server.
19
+ # -m accepts a model name (auto-resolves + downloads from HF registry).
20
+ crispembed-server \
21
+ -m "$MODEL" \
22
+ --ocr "$OCR_MODEL" \
23
+ --host "$SERVER_HOST" \
24
+ --port "$SERVER_PORT" \
25
+ -t "$N_THREADS" &
26
+
27
+ SERVER_PID=$!
28
+
29
+ # Wait for the server to come up (up to 120s for first model download)
30
+ echo "Waiting for CrispEmbed server..."
31
+ for i in $(seq 1 120); do
32
+ if curl -sf "http://$SERVER_HOST:$SERVER_PORT/health" >/dev/null 2>&1; then
33
+ echo "Server ready!"
34
+ break
35
+ fi
36
+ if ! kill -0 "$SERVER_PID" 2>/dev/null; then
37
+ echo "ERROR: Server process died"
38
+ exit 1
39
+ fi
40
+ sleep 1
41
+ done
42
+
43
+ # Start Gradio
44
+ exec python3 /space/app.py